Description
string_replace and diff_edit pass the model's replacement text as the second
argument to String.prototype.replace. That argument is not a literal the
engine runs GetSubstitution over it, so four token sequences are rewritten
before the bytes reach disk:
$$ -> collapses to a single "$"
$& -> injects the matched text
$` -> injects everything BEFORE the match
$' -> injects everything AFTER the match
These are ordinary characters in shell scripts, Makefiles, docker-compose files,
CI YAML, and any code that builds regexes. The file silently ends up containing
something the model never asked for and the user never approved.
The $` and $' forms are the damaging ones: each duplicates a whole half of the file
into the middle of the edit, so corruption scales with file size.
Environment
- OS: Windows 11 (language-level; reproduces on every platform)
- Node version: v22.22.3
- Nanocoder version: 1.30.0
- Provider: any
- Model: any
Steps to Reproduce
read_file a shell script so the read-tracker marks it seen.
- Call
string_replace on it with a new_str containing any of the four tokens
(the reduced case below uses all four).
- Compare the approved preview with the bytes on disk.
Reduced to the one line that matters:
const file = '#!/bin/sh\necho "old"\nexit 0\n';
const old_str = 'echo "old"';
const new_str = 'echo "pid=$$ match=$& pre=$` post=$\'"';
file.replace(old_str, new_str); // what the tool does
file.replace(old_str, () => new_str); // what it should do
Expected Behavior
The file contains exactly the new_str the model supplied, byte for byte.
Actual Behavior
requested new_str : "echo \"pid=$$ match=$& pre=$` post=$'\""
current behaviour: "#!/bin/sh\necho \"pid=$ match=echo \"old\" pre=#!/bin/sh\n post=\nexit 0\n\"\nexit 0\n"
with fn replacer : "#!/bin/sh\necho \"pid=$$ match=$& pre=$` post=$'\"\nexit 0\n"
The tool reports success. Nothing warns the model or the user.
Logs/Screenshots
Affected call sites:
source/tools/file-ops/string-replace.tsx:91 fileContent.replace(old_str, new_str), the write path
source/tools/file-ops/diff-edit.tsx:165 newContent.replace(block.search, block.replace), once per block
source/tools/file-ops/string-replace.tsx:165 the same call in the VS Code diff formatter
Additional Context
The approval gate is bypassed by construction rather than by attack: the terminal
confirmation renders old_str / new_str directly, so the diff the user approves
is not the diff that lands.
Suggested fix — use a function replacer at both write sites, so no substitution
parsing happens at all:
const newContent = fileContent.replace(old_str, () => new_str);
Splicing by index works too and avoids re-scanning the string. Line 165 of
string-replace.tsx should get the same treatment so the VS Code preview keeps
matching what is written. Worth a regression test whose new_str carries all
four tokens.
Description
string_replaceanddiff_editpass the model's replacement text as the secondargument to
String.prototype.replace. That argument is not a literal theengine runs
GetSubstitutionover it, so four token sequences are rewrittenbefore the bytes reach disk:
These are ordinary characters in shell scripts, Makefiles, docker-compose files,
CI YAML, and any code that builds regexes. The file silently ends up containing
something the model never asked for and the user never approved.
The
$`and$'forms are the damaging ones: each duplicates a whole half of the fileinto the middle of the edit, so corruption scales with file size.
Environment
Steps to Reproduce
read_filea shell script so the read-tracker marks it seen.string_replaceon it with anew_strcontaining any of the four tokens(the reduced case below uses all four).
Reduced to the one line that matters:
Expected Behavior
The file contains exactly the
new_strthe model supplied, byte for byte.Actual Behavior
The tool reports success. Nothing warns the model or the user.
Logs/Screenshots
Affected call sites:
source/tools/file-ops/string-replace.tsx:91fileContent.replace(old_str, new_str), the write pathsource/tools/file-ops/diff-edit.tsx:165newContent.replace(block.search, block.replace), once per blocksource/tools/file-ops/string-replace.tsx:165the same call in the VS Code diff formatterAdditional Context
The approval gate is bypassed by construction rather than by attack: the terminal
confirmation renders
old_str/new_strdirectly, so the diff the user approvesis not the diff that lands.
Suggested fix — use a function replacer at both write sites, so no substitution
parsing happens at all:
Splicing by index works too and avoids re-scanning the string. Line 165 of
string-replace.tsxshould get the same treatment so the VS Code preview keepsmatching what is written. Worth a regression test whose
new_strcarries allfour tokens.