From 3b0ed9dcb38319ac4170a4bbc22f1fa6f920e901 Mon Sep 17 00:00:00 2001 From: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com> Date: Thu, 14 May 2026 08:45:00 +0000 Subject: [PATCH] fix(filesystem): prevent $ replacement patterns in edit_file newText String.prototype.replace interprets special patterns like $$, $&, $`, and $' in the replacement string. Use a callback instead so that the return value is always treated as a literal replacement. Fixes #4157 --- src/filesystem/__tests__/lib.test.ts | 80 ++++++++++++++++++++++++++-- src/filesystem/lib.ts | 5 +- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index f7e585af22..13572c4c40 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -555,15 +555,15 @@ describe('Lib Functions', () => { it('handles CRLF line endings in file content', async () => { mockFs.readFile.mockResolvedValue('line1\r\nline2\r\nline3\r\n'); - + const edits = [ { oldText: 'line2', newText: 'modified line2' } ]; - + mockFs.rename.mockResolvedValueOnce(undefined); - + await applyFileEdits('/test/file.txt', edits, false); - + expect(mockFs.writeFile).toHaveBeenCalledWith( expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/), 'line1\nmodified line2\nline3\n', @@ -574,6 +574,78 @@ describe('Lib Functions', () => { '/test/file.txt' ); }); + + it('treats $$ in newText as literal $$ not a single $', async () => { + mockFs.readFile.mockResolvedValue('price: PLACEHOLDER\n'); + + const edits = [ + { oldText: 'PLACEHOLDER', newText: '$$100 USD' } + ]; + + mockFs.rename.mockResolvedValueOnce(undefined); + + await applyFileEdits('/test/file.txt', edits, false); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/), + 'price: $$100 USD\n', + 'utf-8' + ); + }); + + it('treats $& in newText as literal $& not the matched substring', async () => { + mockFs.readFile.mockResolvedValue('value: OLD\n'); + + const edits = [ + { oldText: 'OLD', newText: '$&NEW' } + ]; + + mockFs.rename.mockResolvedValueOnce(undefined); + + await applyFileEdits('/test/file.txt', edits, false); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/), + 'value: $&NEW\n', + 'utf-8' + ); + }); + + it('treats $` in newText as literal $` not the preceding substring', async () => { + mockFs.readFile.mockResolvedValue('hello PLACEHOLDER world\n'); + + const edits = [ + { oldText: 'PLACEHOLDER', newText: '$`literal' } + ]; + + mockFs.rename.mockResolvedValueOnce(undefined); + + await applyFileEdits('/test/file.txt', edits, false); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/), + 'hello $`literal world\n', + 'utf-8' + ); + }); + + it("treats $' in newText as literal $' not the following substring", async () => { + mockFs.readFile.mockResolvedValue('hello PLACEHOLDER world\n'); + + const edits = [ + { oldText: 'PLACEHOLDER', newText: "$'literal" } + ]; + + mockFs.rename.mockResolvedValueOnce(undefined); + + await applyFileEdits('/test/file.txt', edits, false); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/), + "hello $'literal world\n", + 'utf-8' + ); + }); }); describe('tailFile', () => { diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 17e4654cd5..7389522521 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -207,7 +207,10 @@ export async function applyFileEdits( // If exact match exists, use it if (modifiedContent.includes(normalizedOld)) { - modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew); + // Use a function as replacement to prevent special replacement patterns + // (e.g. $&, $`, $', $$) in normalizedNew from being interpreted by + // String.prototype.replace — the callback return value is always literal. + modifiedContent = modifiedContent.replace(normalizedOld, () => normalizedNew); continue; }