fix(mcp): refuse ambiguous patch_node/delete_node on duplicated ids (#159)
Docmost duplicates block ids on copy/paste, and copyPageContent writes the source document verbatim with the same ids. `patchNode`/`deleteNode` address a block by `attrs.id` via replaceNodeById/deleteNodeById, which act on EVERY node sharing the id — so a single patch_node/delete_node could silently replace/remove multiple unrelated blocks with no signal to the model (red-team finding #6). Guard both write paths: when more than one node matches the id, skip the write entirely (the transform returns null -> no mutation) and throw a clear "ambiguous id — N nodes share it" error so the model re-targets with a more specific anchor. Only an unambiguous single match is written; the 0-match and 1-match behavior is unchanged. The duplicate-count basis is covered by node-ops.test.mjs (replaceNodeById / deleteNodeById report count===2 for a 2-duplicate doc). The end-to-end guard is not unit-tested because patchNode/deleteNode require a live collab provider and the test suite has no provider mock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1292,13 +1292,22 @@ export class DocmostClient {
|
|||||||
replaced = 0;
|
replaced = 0;
|
||||||
const { doc: nd, replaced: r } = replaceNodeById(liveDoc, nodeId, target);
|
const { doc: nd, replaced: r } = replaceNodeById(liveDoc, nodeId, target);
|
||||||
replaced = r;
|
replaced = r;
|
||||||
if (replaced === 0)
|
// 0 matches -> skip the write. >1 matches -> the id is AMBIGUOUS: Docmost
|
||||||
return null; // no match -> skip the write entirely
|
// duplicates block ids on copy/paste (and copyPageContent writes them
|
||||||
|
// verbatim), so replacing "the node with id X" would silently clobber
|
||||||
|
// EVERY duplicate (#159). Refuse: skip the write and throw below so the
|
||||||
|
// model re-targets with a more specific anchor instead of corrupting the
|
||||||
|
// page. Only an unambiguous single match is written.
|
||||||
|
if (replaced !== 1)
|
||||||
|
return null;
|
||||||
return nd;
|
return nd;
|
||||||
});
|
});
|
||||||
if (replaced === 0) {
|
if (replaced === 0) {
|
||||||
throw new Error(`patch_node: no node with id "${nodeId}" found on page ${pageId}`);
|
throw new Error(`patch_node: no node with id "${nodeId}" found on page ${pageId}`);
|
||||||
}
|
}
|
||||||
|
if (replaced > 1) {
|
||||||
|
throw new Error(`patch_node: id "${nodeId}" is ambiguous — ${replaced} nodes on page ${pageId} share it (block ids are duplicated on copy/paste). Refusing to replace all of them; nothing was changed. Re-target with a more specific anchor.`);
|
||||||
|
}
|
||||||
return { success: true, replaced, nodeId, verify: mutation.verify };
|
return { success: true, replaced, nodeId, verify: mutation.verify };
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -1381,13 +1390,21 @@ export class DocmostClient {
|
|||||||
deleted = 0;
|
deleted = 0;
|
||||||
const { doc: nd, deleted: d } = deleteNodeById(liveDoc, nodeId);
|
const { doc: nd, deleted: d } = deleteNodeById(liveDoc, nodeId);
|
||||||
deleted = d;
|
deleted = d;
|
||||||
if (deleted === 0)
|
// 0 matches -> skip the write. >1 matches -> the id is AMBIGUOUS (block
|
||||||
return null; // no match -> skip the write entirely
|
// ids are duplicated on copy/paste, #159): deleting "the node with id X"
|
||||||
|
// would silently remove EVERY duplicate. Refuse: skip the write and throw
|
||||||
|
// below so the model re-targets. Only an unambiguous single match is
|
||||||
|
// deleted.
|
||||||
|
if (deleted !== 1)
|
||||||
|
return null;
|
||||||
return nd;
|
return nd;
|
||||||
});
|
});
|
||||||
if (deleted === 0) {
|
if (deleted === 0) {
|
||||||
throw new Error(`delete_node: no node with id "${nodeId}" found on page ${pageId}`);
|
throw new Error(`delete_node: no node with id "${nodeId}" found on page ${pageId}`);
|
||||||
}
|
}
|
||||||
|
if (deleted > 1) {
|
||||||
|
throw new Error(`delete_node: id "${nodeId}" is ambiguous — ${deleted} nodes on page ${pageId} share it (block ids are duplicated on copy/paste). Refusing to delete all of them; nothing was changed. Re-target with a more specific anchor.`);
|
||||||
|
}
|
||||||
return { success: true, deleted, nodeId, verify: mutation.verify };
|
return { success: true, deleted, nodeId, verify: mutation.verify };
|
||||||
}
|
}
|
||||||
/** Build the public share URL for a page. */
|
/** Build the public share URL for a page. */
|
||||||
|
|||||||
@@ -1625,7 +1625,13 @@ export class DocmostClient {
|
|||||||
replaced = 0;
|
replaced = 0;
|
||||||
const { doc: nd, replaced: r } = replaceNodeById(liveDoc, nodeId, target);
|
const { doc: nd, replaced: r } = replaceNodeById(liveDoc, nodeId, target);
|
||||||
replaced = r;
|
replaced = r;
|
||||||
if (replaced === 0) return null; // no match -> skip the write entirely
|
// 0 matches -> skip the write. >1 matches -> the id is AMBIGUOUS: Docmost
|
||||||
|
// duplicates block ids on copy/paste (and copyPageContent writes them
|
||||||
|
// verbatim), so replacing "the node with id X" would silently clobber
|
||||||
|
// EVERY duplicate (#159). Refuse: skip the write and throw below so the
|
||||||
|
// model re-targets with a more specific anchor instead of corrupting the
|
||||||
|
// page. Only an unambiguous single match is written.
|
||||||
|
if (replaced !== 1) return null;
|
||||||
return nd;
|
return nd;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1635,6 +1641,11 @@ export class DocmostClient {
|
|||||||
`patch_node: no node with id "${nodeId}" found on page ${pageId}`,
|
`patch_node: no node with id "${nodeId}" found on page ${pageId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (replaced > 1) {
|
||||||
|
throw new Error(
|
||||||
|
`patch_node: id "${nodeId}" is ambiguous — ${replaced} nodes on page ${pageId} share it (block ids are duplicated on copy/paste). Refusing to replace all of them; nothing was changed. Re-target with a more specific anchor.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, replaced, nodeId, verify: mutation.verify };
|
return { success: true, replaced, nodeId, verify: mutation.verify };
|
||||||
}
|
}
|
||||||
@@ -1755,7 +1766,12 @@ export class DocmostClient {
|
|||||||
deleted = 0;
|
deleted = 0;
|
||||||
const { doc: nd, deleted: d } = deleteNodeById(liveDoc, nodeId);
|
const { doc: nd, deleted: d } = deleteNodeById(liveDoc, nodeId);
|
||||||
deleted = d;
|
deleted = d;
|
||||||
if (deleted === 0) return null; // no match -> skip the write entirely
|
// 0 matches -> skip the write. >1 matches -> the id is AMBIGUOUS (block
|
||||||
|
// ids are duplicated on copy/paste, #159): deleting "the node with id X"
|
||||||
|
// would silently remove EVERY duplicate. Refuse: skip the write and throw
|
||||||
|
// below so the model re-targets. Only an unambiguous single match is
|
||||||
|
// deleted.
|
||||||
|
if (deleted !== 1) return null;
|
||||||
return nd;
|
return nd;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1765,6 +1781,11 @@ export class DocmostClient {
|
|||||||
`delete_node: no node with id "${nodeId}" found on page ${pageId}`,
|
`delete_node: no node with id "${nodeId}" found on page ${pageId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (deleted > 1) {
|
||||||
|
throw new Error(
|
||||||
|
`delete_node: id "${nodeId}" is ambiguous — ${deleted} nodes on page ${pageId} share it (block ids are duplicated on copy/paste). Refusing to delete all of them; nothing was changed. Re-target with a more specific anchor.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, deleted, nodeId, verify: mutation.verify };
|
return { success: true, deleted, nodeId, verify: mutation.verify };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user