
Tool descriptions are not agent guardrails
An edit tool can promise a unique match and still replace the first duplicate. Put the constraint in executable code, then test the refusal cases before giving an agent write access.
js ▸ function replaceExactlyOnce(text, oldText, newText) {
if (![text, oldText, newText].every(x => typeof x === 'string')) {
throw new TypeError('All arguments must be strings');
}
if (oldText.length === 0) throw new Error('Empty target');
const first = text.indexOf(oldText);
if (first === -1) throw new Error('Target missing');
if (text.indexOf(oldText, first + 1) !== -1) {
throw new Error('Target is ambiguous');
}
return text.slice(0, first) + newText
+ text.slice(first + oldText.length);
}An agent asks to replace a token. The file contains it twice. The tool reports success, and the wrong setting changes. No clever attacker needed. The description promised a constraint the code never enforced.
Hadley Wickham's June 19 coding-agent tutorial contains a small, useful example. The edit-tool description requires one occurrence of the old text. The implementation checks that the text exists, then calls R's sub. Exists and exists exactly once are different contracts. This is a finding about the published example, not a verdict on a production agent.
The R documentation specifies that sub changes the first occurrence. Substituting gsub would change every occurrence, which would still violate a contract requiring exactly one. The missing operation is rejection.
Make ambiguity an error
My rule for tool review: turn every constraint in the description into a refusal test. The happy-path demo shows what the tool can do. The refusal shows what it won't let the agent do. That second result is the one to ask for before handing over write access.
Here is the small JavaScript helper used for this piece; Codex executed the tests below. It takes literal strings and returns new text. It doesn't touch a file:
function replaceExactlyOnce(text, oldText, newText) {
if (![text, oldText, newText].every(x => typeof x === 'string')) {
throw new TypeError('All arguments must be strings');
}
if (oldText.length === 0) throw new Error('Empty target');
const first = text.indexOf(oldText);
if (first === -1) throw new Error('Target missing');
if (text.indexOf(oldText, first + 1) !== -1) {
throw new Error('Target is ambiguous');
}
return text.slice(0, first) + newText
+ text.slice(first + oldText.length);
}
Searching again one character after the first start deliberately catches overlapping matches. Replacing aa inside aaa is ambiguous under this helper's contract, even though a non-overlapping replacement routine might see one replaceable span. Define that choice explicitly; do not let a library's default quietly become your editing policy.
String slicing also keeps the replacement literal. A replacement containing $& is inserted as those two characters. There is no regular-expression interpretation to remember when the model supplies a string that happens to contain punctuation.
Test the refusals
Run these assertions after the helper in Node:
import assert from 'node:assert/strict';
assert.equal(replaceExactlyOnce('left token right', 'token', 'new'),
'left new right');
assert.throws(() => replaceExactlyOnce('token token', 'token', 'new'));
assert.throws(() => replaceExactlyOnce('other', 'token', 'new'));
assert.throws(() => replaceExactlyOnce('token', '', 'new'));
assert.throws(() => replaceExactlyOnce('aaa', 'aa', 'new'));
assert.equal(replaceExactlyOnce('token', 'token', '$&'), '$&');
Six assertions, six passes in this article's local run. That buys us evidence about this helper on these inputs — not a safety certificate for the file editor around it. Keep the result attached to the thing actually tested.
There are still decisions outside this function: permitted paths, write authorization, and changes made after the file was read. A unique match inside an outdated snapshot remains an outdated snapshot. A real file tool needs to check those conditions separately, including a way to detect intervening changes.
Put the check where the write happens. Return a structured error so the agent can reread the file and choose a larger span. Picking the first match makes the tool look successful by hiding the ambiguity from the component that needs to resolve it. Refusing gives the next attempt something useful: the reason this one couldn't proceed.
Steal this for your next tool review: find one sentence containing “must,” “only,” or “never.” Build the input that breaks that promise. Then watch the implementation, not the model's explanation. If the write still happens, you have found documentation waiting to become code.
For the wider design question, read the guardrail benchmark argument: what remains out of reach when the model or filter gets it wrong?
Treat each constraint in a tool description as a test case until executable code makes it a boundary.


