From dbfbb13cccebeb9837df6745bd290d85fa35ddfa Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:18:23 -0500 Subject: [PATCH] fix(core): authorize patch move destinations (#38133) --- packages/core/src/tool/patch.ts | 1 + packages/core/test/patch.test.ts | 31 +++++++++++++++++++++++++++ packages/core/test/tool-patch.test.ts | 22 +++++++++++++++++++ packages/util/src/patch.ts | 8 +++---- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index fef7e9a6d2..7a70b3c5c3 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -189,6 +189,7 @@ export const Plugin = { catch: (error) => new ToolFailure({ message: `patch verification failed: ${String(error)}` }), }) const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined + if (moveTarget) targets.push(moveTarget) if (moveTarget?.externalDirectory) { yield* permission.assert({ action: "external_directory", diff --git a/packages/core/test/patch.test.ts b/packages/core/test/patch.test.ts index d094ced63b..7717c6bc66 100644 --- a/packages/core/test/patch.test.ts +++ b/packages/core/test/patch.test.ts @@ -40,6 +40,18 @@ describe("Patch", () => { expect(() => parse("*** Begin Patch\n*** Add File: add.txt\n+added")).toThrow( "The last line of the patch must be '*** End Patch'", ) + expect(() => parse("extra\n*** Begin Patch\n*** End Patch")).toThrow( + "The first line of the patch must be '*** Begin Patch'", + ) + expect(() => parse("*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nextra")).toThrow( + "The last line of the patch must be '*** End Patch'", + ) + }) + + test("allows whitespace after the end marker", () => { + expect(parse("*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\n \t\n")).toEqual([ + { type: "add", path: "add.txt", contents: "added" }, + ]) }) test("strips a heredoc wrapper", () => { @@ -169,6 +181,25 @@ describe("Patch", () => { ).toBe('He said "hi"\n') }) + test("matches Unicode minus signs and spaces", () => { + expect( + Patch.derive("minus.txt", [{ oldLines: ["value - 1"], newLines: ["value - 2"] }], "value − 1\n") + .content, + ).toBe("value - 2\n") + const spaces = ["\u00A0", "\u2002", "\u2003", "\u2004", "\u2005", "\u2006", "\u2007", "\u2008", "\u2009", "\u200A", "\u202F", "\u205F", "\u3000"] + spaces.forEach( + (space) => { + expect( + Patch.derive( + "spaces.txt", + [{ oldLines: ["hello world"], newLines: ["hello there"] }], + `hello${space}world\n`, + ).content, + ).toBe("hello there\n") + }, + ) + }) + test("matches EOF-anchored chunks from the end", () => { expect( Patch.derive( diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 8395335b92..f43b49342c 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -351,6 +351,28 @@ describe("PatchTool", () => { ), ) + it.live("includes the move destination in edit permission resources", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + const source = path.join(directory, "old", "name.txt") + yield* Effect.promise(() => fs.mkdir(path.dirname(source), { recursive: true })) + yield* Effect.promise(() => fs.writeFile(source, "old content\n")) + yield* executeTool( + registry, + call( + "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch", + ), + ) + expect(assertions).toMatchObject([ + { + action: "edit", + resources: ["old/name.txt", "renamed/dir/name.txt"], + }, + ]) + }), + ), + ) + it.live("inserts lines with an insert-only hunk", () => withTempTool((directory, registry) => Effect.gen(function* () { diff --git a/packages/util/src/patch.ts b/packages/util/src/patch.ts index 600c604401..e969dfd6af 100644 --- a/packages/util/src/patch.ts +++ b/packages/util/src/patch.ts @@ -47,8 +47,8 @@ export function parse(patchText: string): Result.Result, Par const lines = stripHeredoc(patchText.trim()) .split("\n") .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)) - const begin = lines.findIndex((line) => line.trim() === "*** Begin Patch") - const end = lines.findIndex((line) => line.trim() === "*** End Patch") + const begin = lines[0]?.trim() === "*** Begin Patch" ? 0 : -1 + const end = lines.at(-1)?.trim() === "*** End Patch" ? lines.length - 1 : -1 if (begin === -1) return Result.fail(new BoundaryError({ boundary: "first" })) if (end === -1 || begin >= end) return Result.fail(new BoundaryError({ boundary: "last" })) @@ -227,9 +227,9 @@ const normalize = (value: string) => value .replace(/[‘’‚‛]/g, "'") .replace(/[“”„‟]/g, '"') - .replace(/[‐‑‒–—―]/g, "-") + .replace(/[‐‑‒–—―−]/g, "-") .replace(/…/g, "...") - .replace(/ /g, " ") + .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ") const splitBom = (text: string) => text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text } const stripHeredoc = (input: string) =>