feat: add patchNodeField operation for surgical string edits (v2.46.0) (#698)

Add dedicated `patchNodeField` operation to `n8n_update_partial_workflow`
for surgical find/replace edits in node string fields. Strict alternative
to the existing `__patch_find_replace` in updateNode — errors on not-found,
detects ambiguous matches, supports replaceAll and regex flags.

Security hardening:
- Prototype pollution protection in setNestedProperty/getNestedProperty
- ReDoS protection rejecting unsafe regex patterns (nested quantifiers)
- Resource limits: max 50 patches, 500-char regex, 512KB field size

Fixes #696

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Romuald Członkowski
2026-04-03 09:53:44 +02:00
committed by GitHub
parent ca20586eda
commit 2d4115530c
28 changed files with 1019 additions and 37 deletions

View File

@@ -33,7 +33,7 @@ function getValidator(repository: NodeRepository): WorkflowValidator {
// Operation types that identify nodes by nodeId/nodeName
const NODE_TARGETING_OPERATIONS = new Set([
'updateNode', 'removeNode', 'moveNode', 'enableNode', 'disableNode'
'updateNode', 'removeNode', 'moveNode', 'enableNode', 'disableNode', 'patchNodeField'
]);
// Zod schema for the diff request
@@ -47,6 +47,8 @@ const workflowDiffSchema = z.object({
nodeId: z.string().optional(),
nodeName: z.string().optional(),
updates: z.any().optional(),
fieldPath: z.string().optional(),
patches: z.any().optional(),
position: z.tuple([z.number(), z.number()]).optional(),
// Connection operations
source: z.string().optional(),
@@ -569,6 +571,8 @@ function inferIntentFromOperations(operations: any[]): string {
return `Remove node ${op.nodeName || op.nodeId || ''}`.trim();
case 'updateNode':
return `Update node ${op.nodeName || op.nodeId || ''}`.trim();
case 'patchNodeField':
return `Patch field on node ${op.nodeName || op.nodeId || ''}`.trim();
case 'addConnection':
return `Connect ${op.source || 'node'} to ${op.target || 'node'}`;
case 'removeConnection':
@@ -604,6 +608,10 @@ function inferIntentFromOperations(operations: any[]): string {
const count = opTypes.filter((t) => t === 'updateNode').length;
summary.push(`update ${count} node${count > 1 ? 's' : ''}`);
}
if (typeSet.has('patchNodeField')) {
const count = opTypes.filter((t) => t === 'patchNodeField').length;
summary.push(`patch ${count} field${count > 1 ? 's' : ''}`);
}
if (typeSet.has('addConnection') || typeSet.has('rewireConnection')) {
summary.push('modify connections');
}

View File

@@ -4,7 +4,7 @@ export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = {
name: 'n8n_update_partial_workflow',
category: 'workflow_management',
essentials: {
description: 'Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, rewireConnection, cleanStaleConnections, replaceConnections, updateSettings, updateName, add/removeTag, activateWorkflow, deactivateWorkflow, transferWorkflow. Supports smart parameters (branch, case) for multi-output nodes. Full support for AI connections (ai_languageModel, ai_tool, ai_memory, ai_embedding, ai_vectorStore, ai_document, ai_textSplitter, ai_outputParser).',
description: 'Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, patchNodeField, moveNode, enable/disableNode, addConnection, removeConnection, rewireConnection, cleanStaleConnections, replaceConnections, updateSettings, updateName, add/removeTag, activateWorkflow, deactivateWorkflow, transferWorkflow. Supports smart parameters (branch, case) for multi-output nodes. Full support for AI connections (ai_languageModel, ai_tool, ai_memory, ai_embedding, ai_vectorStore, ai_document, ai_textSplitter, ai_outputParser).',
keyParameters: ['id', 'operations', 'continueOnError'],
example: 'n8n_update_partial_workflow({id: "wf_123", operations: [{type: "rewireConnection", source: "IF", from: "Old", to: "New", branch: "true"}]})',
performance: 'Fast (50-200ms)',
@@ -27,14 +27,15 @@ export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = {
]
},
full: {
description: `Updates workflows using surgical diff operations instead of full replacement. Supports 17 operation types for precise modifications. Operations are validated and applied atomically by default - all succeed or none are applied.
description: `Updates workflows using surgical diff operations instead of full replacement. Supports 18 operation types for precise modifications. Operations are validated and applied atomically by default - all succeed or none are applied.
## Available Operations:
### Node Operations (6 types):
### Node Operations (7 types):
- **addNode**: Add a new node with name, type, and position (required)
- **removeNode**: Remove a node by ID or name
- **updateNode**: Update node properties using dot notation (e.g., 'parameters.url')
- **patchNodeField**: Surgically edit string fields using find/replace patches. Strict mode: errors if find string not found, errors if multiple matches (ambiguity) unless replaceAll is set. Supports replaceAll and regex flags.
- **moveNode**: Change node position [x, y]
- **enableNode**: Enable a disabled node
- **disableNode**: Disable an active node
@@ -335,6 +336,11 @@ n8n_update_partial_workflow({
'// Validate before applying\nn8n_update_partial_workflow({id: "bcd", operations: [{type: "removeNode", nodeName: "Old Process"}], validateOnly: true})',
'// Surgically edit code using __patch_find_replace (avoids replacing entire code block)\nn8n_update_partial_workflow({id: "pfr1", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "const limit = 10;", "replace": "const limit = 50;"}]}}}]})',
'// Multiple sequential patches on the same property\nn8n_update_partial_workflow({id: "pfr2", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "api.old-domain.com", "replace": "api.new-domain.com"}, {"find": "Authorization: Bearer old_token", "replace": "Authorization: Bearer new_token"}]}}}]})',
'\n// ============ PATCHNODEFIELD EXAMPLES (strict find/replace) ============',
'// Surgical code edit with patchNodeField (errors if not found)\nn8n_update_partial_workflow({id: "pnf1", operations: [{type: "patchNodeField", nodeName: "Code", fieldPath: "parameters.jsCode", patches: [{find: "const limit = 10;", replace: "const limit = 50;"}]}]})',
'// Replace all occurrences of a string\nn8n_update_partial_workflow({id: "pnf2", operations: [{type: "patchNodeField", nodeName: "Code", fieldPath: "parameters.jsCode", patches: [{find: "api.old.com", replace: "api.new.com", replaceAll: true}]}]})',
'// Multiple sequential patches\nn8n_update_partial_workflow({id: "pnf3", operations: [{type: "patchNodeField", nodeName: "Set Email", fieldPath: "parameters.assignments.assignments.6.value", patches: [{find: "© 2025 n8n-mcp", replace: "© 2026 n8n-mcp"}, {find: "<p>Unsubscribe</p>", replace: ""}]}]})',
'// Regex-based replacement\nn8n_update_partial_workflow({id: "pnf4", operations: [{type: "patchNodeField", nodeName: "Code", fieldPath: "parameters.jsCode", patches: [{find: "const\\\\s+limit\\\\s*=\\\\s*\\\\d+", replace: "const limit = 100", regex: true}]}]})',
'\n// ============ AI CONNECTION EXAMPLES ============',
'// Connect language model to AI Agent\nn8n_update_partial_workflow({id: "ai1", operations: [{type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel"}]})',
'// Connect tool to AI Agent\nn8n_update_partial_workflow({id: "ai2", operations: [{type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"}]})',
@@ -373,7 +379,10 @@ n8n_update_partial_workflow({
'Configure Vector Store retrieval systems',
'Swap language models in existing AI workflows',
'Batch-update AI tool connections',
'Transfer workflows between team projects (enterprise)'
'Transfer workflows between team projects (enterprise)',
'Surgical string edits in email templates, code, or JSON bodies (patchNodeField)',
'Fix typos or update URLs in large HTML content without re-transmitting the full string',
'Bulk find/replace across node field content (replaceAll flag)'
],
performance: 'Very fast - typically 50-200ms. Much faster than full updates as only changes are processed.',
bestPractices: [
@@ -396,7 +405,10 @@ n8n_update_partial_workflow({
'To remove properties, set them to null in the updates object',
'When migrating from deprecated properties, remove the old property and add the new one in the same operation',
'Use null to resolve mutual exclusivity validation errors between properties',
'Batch multiple property removals in a single updateNode operation for efficiency'
'Batch multiple property removals in a single updateNode operation for efficiency',
'Prefer patchNodeField over __patch_find_replace for strict error handling — patchNodeField errors on not-found and detects ambiguous matches',
'Use replaceAll: true in patchNodeField when you want to replace all occurrences of a string',
'Use regex: true in patchNodeField for pattern-based replacements (e.g., whitespace-insensitive matching)'
],
pitfalls: [
'**REQUIRES N8N_API_URL and N8N_API_KEY environment variables** - will not work without n8n API access',
@@ -419,6 +431,9 @@ n8n_update_partial_workflow({
'**Corrupted workflows beyond repair**: Workflows in paradoxical states (API returns corrupt, API rejects updates) cannot be fixed via API - must be recreated',
'**__patch_find_replace for code edits**: Instead of replacing entire code blocks, use `{"parameters.jsCode": {"__patch_find_replace": [{"find": "old text", "replace": "new text"}]}}` to surgically edit string properties',
'__patch_find_replace replaces the FIRST occurrence of each find string. Patches are applied sequentially — order matters',
'**patchNodeField is strict**: it ERRORS if the find string is not found (unlike __patch_find_replace which only warns)',
'**patchNodeField detects ambiguity**: if find matches multiple times, it ERRORS unless replaceAll: true is set',
'When using regex: true in patchNodeField, escape special regex characters (., *, +, etc.) if you want literal matching',
'To remove a property, set it to null in the updates object',
'When properties are mutually exclusive (e.g., continueOnFail and onError), setting only the new property will fail - you must remove the old one with null',
'Removing a required property may cause validation errors - check node documentation first',

View File

@@ -147,7 +147,7 @@ export const n8nManagementTools: ToolDefinition[] = [
},
{
name: 'n8n_update_partial_workflow',
description: `Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, updateSettings, updateName, add/removeTag, activate/deactivateWorkflow, transferWorkflow. See tools_documentation("n8n_update_partial_workflow", "full") for details.`,
description: `Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, patchNodeField, moveNode, enable/disableNode, addConnection, removeConnection, updateSettings, updateName, add/removeTag, activate/deactivateWorkflow, transferWorkflow. See tools_documentation("n8n_update_partial_workflow", "full") for details.`,
inputSchema: {
type: 'object',
additionalProperties: true, // Allow any extra properties Claude Desktop might add

View File

@@ -29,7 +29,8 @@ import {
DeactivateWorkflowOperation,
CleanStaleConnectionsOperation,
ReplaceConnectionsOperation,
TransferWorkflowOperation
TransferWorkflowOperation,
PatchNodeFieldOperation
} from '../types/workflow-diff';
import { Workflow, WorkflowNode, WorkflowConnection } from '../types/n8n-api';
import { Logger } from '../utils/logger';
@@ -39,6 +40,55 @@ import { isActivatableTrigger } from '../utils/node-type-utils';
const logger = new Logger({ prefix: '[WorkflowDiffEngine]' });
// Safety limits for patchNodeField operations
const PATCH_LIMITS = {
MAX_PATCHES: 50, // Max patches per operation
MAX_REGEX_LENGTH: 500, // Max regex pattern length (chars)
MAX_FIELD_SIZE_REGEX: 512 * 1024, // Max field size for regex operations (512KB)
};
// Keys that must never appear in property paths (prototype pollution prevention)
const DANGEROUS_PATH_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
/**
* Check if a regex pattern contains constructs known to cause catastrophic backtracking.
* Detects nested quantifiers like (a+)+, (a*)+, (a+)*, (a|b+)+ etc.
*/
function isUnsafeRegex(pattern: string): boolean {
// Detect nested quantifiers: a quantifier applied to a group that itself contains a quantifier
// Examples: (a+)+, (a+)*, (.*)+, (\w+)*, (a|b+)+
// This catches the most common ReDoS patterns
const nestedQuantifier = /\([^)]*[+*][^)]*\)[+*{]/;
if (nestedQuantifier.test(pattern)) return true;
// Detect overlapping alternations with quantifiers: (a|a)+, (\w|\d)+
const overlappingAlternation = /\([^)]*\|[^)]*\)[+*{]/;
// Only flag if alternation branches share characters (heuristic: both contain \w, ., or same literal)
if (overlappingAlternation.test(pattern)) {
const match = pattern.match(/\(([^)]*)\|([^)]*)\)[+*{]/);
if (match) {
const [, left, right] = match;
// Flag if both branches use broad character classes
const broadClasses = ['.', '\\w', '\\d', '\\s', '\\S', '\\W', '\\D', '[^'];
const leftHasBroad = broadClasses.some(c => left.includes(c));
const rightHasBroad = broadClasses.some(c => right.includes(c));
if (leftHasBroad && rightHasBroad) return true;
}
}
return false;
}
function countOccurrences(str: string, search: string): number {
let count = 0;
let pos = 0;
while ((pos = str.indexOf(search, pos)) !== -1) {
count++;
pos += search.length;
}
return count;
}
/**
* Not safe for concurrent use — create a new instance per request.
* Instance state is reset at the start of each applyDiff() call.
@@ -79,7 +129,7 @@ export class WorkflowDiffEngine {
const workflowCopy = JSON.parse(JSON.stringify(workflow));
// Group operations by type for two-pass processing
const nodeOperationTypes = ['addNode', 'removeNode', 'updateNode', 'moveNode', 'enableNode', 'disableNode'];
const nodeOperationTypes = ['addNode', 'removeNode', 'updateNode', 'patchNodeField', 'moveNode', 'enableNode', 'disableNode'];
const nodeOperations: Array<{ operation: WorkflowDiffOperation; index: number }> = [];
const otherOperations: Array<{ operation: WorkflowDiffOperation; index: number }> = [];
@@ -296,6 +346,8 @@ export class WorkflowDiffEngine {
return this.validateRemoveNode(workflow, operation);
case 'updateNode':
return this.validateUpdateNode(workflow, operation);
case 'patchNodeField':
return this.validatePatchNodeField(workflow, operation as PatchNodeFieldOperation);
case 'moveNode':
return this.validateMoveNode(workflow, operation);
case 'enableNode':
@@ -341,6 +393,9 @@ export class WorkflowDiffEngine {
case 'updateNode':
this.applyUpdateNode(workflow, operation);
break;
case 'patchNodeField':
this.applyPatchNodeField(workflow, operation as PatchNodeFieldOperation);
break;
case 'moveNode':
this.applyMoveNode(workflow, operation);
break;
@@ -498,6 +553,77 @@ export class WorkflowDiffEngine {
return null;
}
private validatePatchNodeField(workflow: Workflow, operation: PatchNodeFieldOperation): string | null {
if (!operation.nodeId && !operation.nodeName) {
return `patchNodeField requires either "nodeId" or "nodeName"`;
}
if (!operation.fieldPath || typeof operation.fieldPath !== 'string') {
return `patchNodeField requires a "fieldPath" string (e.g., "parameters.jsCode")`;
}
// Prototype pollution protection
const pathSegments = operation.fieldPath.split('.');
if (pathSegments.some(k => DANGEROUS_PATH_KEYS.has(k))) {
return `patchNodeField: fieldPath "${operation.fieldPath}" contains a forbidden key (__proto__, constructor, or prototype)`;
}
if (!Array.isArray(operation.patches) || operation.patches.length === 0) {
return `patchNodeField requires a non-empty "patches" array of {find, replace} objects`;
}
// Resource limit: max patches per operation
if (operation.patches.length > PATCH_LIMITS.MAX_PATCHES) {
return `patchNodeField: too many patches (${operation.patches.length}). Maximum is ${PATCH_LIMITS.MAX_PATCHES} per operation. Split into multiple operations if needed.`;
}
for (let i = 0; i < operation.patches.length; i++) {
const patch = operation.patches[i];
if (!patch || typeof patch.find !== 'string' || typeof patch.replace !== 'string') {
return `Invalid patch entry at index ${i}: each entry must have "find" (string) and "replace" (string)`;
}
if (patch.find.length === 0) {
return `Invalid patch entry at index ${i}: "find" must not be empty`;
}
if (patch.regex) {
// Resource limit: max regex pattern length
if (patch.find.length > PATCH_LIMITS.MAX_REGEX_LENGTH) {
return `Regex pattern at patch index ${i} is too long (${patch.find.length} chars). Maximum is ${PATCH_LIMITS.MAX_REGEX_LENGTH} characters.`;
}
try {
new RegExp(patch.find);
} catch (e) {
return `Invalid regex pattern at patch index ${i}: ${e instanceof Error ? e.message : 'invalid regex'}`;
}
// ReDoS protection: reject patterns with nested quantifiers
if (isUnsafeRegex(patch.find)) {
return `Potentially unsafe regex pattern at patch index ${i}: nested quantifiers or overlapping alternations can cause excessive backtracking. Simplify the pattern or use literal matching (regex: false).`;
}
}
}
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
if (!node) {
return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', 'patchNodeField');
}
const currentValue = this.getNestedProperty(node, operation.fieldPath);
if (currentValue === undefined) {
return `Cannot apply patchNodeField to "${operation.fieldPath}": property does not exist on node "${node.name}"`;
}
if (typeof currentValue !== 'string') {
return `Cannot apply patchNodeField to "${operation.fieldPath}": current value is ${typeof currentValue}, expected string`;
}
// Resource limit: cap field size for regex operations
const hasRegex = operation.patches.some(p => p.regex);
if (hasRegex && typeof currentValue === 'string' && currentValue.length > PATCH_LIMITS.MAX_FIELD_SIZE_REGEX) {
return `Field "${operation.fieldPath}" is too large for regex operations (${Math.round(currentValue.length / 1024)}KB). Maximum is ${PATCH_LIMITS.MAX_FIELD_SIZE_REGEX / 1024}KB. Use literal matching (regex: false) for large fields.`;
}
return null;
}
private validateMoveNode(workflow: Workflow, operation: MoveNodeOperation): string | null {
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
if (!node) {
@@ -775,10 +901,74 @@ export class WorkflowDiffEngine {
Object.assign(node, sanitized);
}
private applyPatchNodeField(workflow: Workflow, operation: PatchNodeFieldOperation): void {
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
if (!node) return;
this.modifiedNodeIds.add(node.id);
let current = this.getNestedProperty(node, operation.fieldPath) as string;
for (let i = 0; i < operation.patches.length; i++) {
const patch = operation.patches[i];
if (patch.regex) {
const globalRegex = new RegExp(patch.find, 'g');
const matches = current.match(globalRegex);
if (!matches || matches.length === 0) {
throw new Error(
`patchNodeField: regex pattern "${patch.find}" not found in "${operation.fieldPath}" (patch index ${i}). ` +
`Use n8n_get_workflow to inspect the current value.`
);
}
if (matches.length > 1 && !patch.replaceAll) {
throw new Error(
`patchNodeField: regex pattern "${patch.find}" matches ${matches.length} times in "${operation.fieldPath}" (patch index ${i}). ` +
`Set "replaceAll": true to replace all occurrences, or refine the pattern to match exactly once.`
);
}
const regex = patch.replaceAll ? globalRegex : new RegExp(patch.find);
current = current.replace(regex, patch.replace);
} else {
const occurrences = countOccurrences(current, patch.find);
if (occurrences === 0) {
throw new Error(
`patchNodeField: "${patch.find.substring(0, 80)}" not found in "${operation.fieldPath}" (patch index ${i}). ` +
`Ensure the find string exactly matches the current content (including whitespace and newlines). ` +
`Use n8n_get_workflow to inspect the current value.`
);
}
if (occurrences > 1 && !patch.replaceAll) {
throw new Error(
`patchNodeField: "${patch.find.substring(0, 80)}" found ${occurrences} times in "${operation.fieldPath}" (patch index ${i}). ` +
`Set "replaceAll": true to replace all occurrences, or use a more specific find string that matches exactly once.`
);
}
if (patch.replaceAll) {
current = current.split(patch.find).join(patch.replace);
} else {
current = current.replace(patch.find, patch.replace);
}
}
}
this.setNestedProperty(node, operation.fieldPath, current);
// Sanitize node after updates
const sanitized = sanitizeNode(node);
Object.assign(node, sanitized);
}
private applyMoveNode(workflow: Workflow, operation: MoveNodeOperation): void {
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
if (!node) return;
node.position = operation.position;
}
@@ -1320,6 +1510,7 @@ export class WorkflowDiffEngine {
const keys = path.split('.');
let current = obj;
for (const key of keys) {
if (DANGEROUS_PATH_KEYS.has(key)) return undefined;
if (current == null || typeof current !== 'object') return undefined;
current = current[key];
}
@@ -1330,6 +1521,11 @@ export class WorkflowDiffEngine {
const keys = path.split('.');
let current = obj;
// Prototype pollution protection
if (keys.some(k => DANGEROUS_PATH_KEYS.has(k))) {
throw new Error(`Invalid property path: "${path}" contains a forbidden key`);
}
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) {

View File

@@ -55,6 +55,19 @@ export interface DisableNodeOperation extends DiffOperation {
nodeName?: string;
}
export interface PatchNodeFieldOperation extends DiffOperation {
type: 'patchNodeField';
nodeId?: string;
nodeName?: string;
fieldPath: string; // Dot-notation path, e.g. "parameters.jsCode"
patches: Array<{
find: string;
replace: string;
replaceAll?: boolean; // Default: false. Replace all occurrences.
regex?: boolean; // Default: false. Treat find as a regex pattern.
}>;
}
// Connection Operations
export interface AddConnectionOperation extends DiffOperation {
type: 'addConnection';
@@ -153,6 +166,7 @@ export type WorkflowDiffOperation =
| AddNodeOperation
| RemoveNodeOperation
| UpdateNodeOperation
| PatchNodeFieldOperation
| MoveNodeOperation
| EnableNodeOperation
| DisableNodeOperation
@@ -208,10 +222,10 @@ export interface NodeReference {
}
// Utility functions type guards
export function isNodeOperation(op: WorkflowDiffOperation): op is
AddNodeOperation | RemoveNodeOperation | UpdateNodeOperation |
export function isNodeOperation(op: WorkflowDiffOperation): op is
AddNodeOperation | RemoveNodeOperation | UpdateNodeOperation | PatchNodeFieldOperation |
MoveNodeOperation | EnableNodeOperation | DisableNodeOperation {
return ['addNode', 'removeNode', 'updateNode', 'moveNode', 'enableNode', 'disableNode'].includes(op.type);
return ['addNode', 'removeNode', 'updateNode', 'patchNodeField', 'moveNode', 'enableNode', 'disableNode'].includes(op.type);
}
export function isConnectionOperation(op: WorkflowDiffOperation): op is