diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index 7a70b3c5c3..1aa3127565 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -155,6 +155,23 @@ export const Plugin = { prepared.push({ ...hunk, target, before: original.replace(/^\uFEFF/, ""), after: "" }) return } + if (!target.externalDirectory && !hunk.movePath) { + const resolved = resolveTarget(location, yield* fs.resolve(target.canonical)) + if (resolved.externalDirectory) { + yield* permission.assert({ + action: "external_directory", + resources: [resolved.externalDirectory.resource], + save: [resolved.externalDirectory.resource], + metadata: { + filepath: resolved.canonical, + parentDir: resolved.externalDirectory.directory, + }, + sessionID: context.sessionID, + agent: context.agent, + source, + }) + } + } const previous = updates.get(target.canonical) const original = previous ?? diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index f43b49342c..b9f2dea236 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -825,7 +825,7 @@ describe("PatchTool", () => { ), ) - it.live("follows an internal symlink to an external file without external permission", () => + it.live("approves an external target before updating it through an internal symlink", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), ([active, outside]) => { @@ -844,7 +844,10 @@ describe("PatchTool", () => { call("*** Begin Patch\n*** Update File: link.txt\n@@\n-before\n+after\n*** End Patch"), ), ).toMatchObject({ type: "text" }) - expect(assertions.map((input) => input.action)).toEqual(["edit"]) + expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) + expect(assertions[0]?.resources).toEqual([ + path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"), + ]) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") }), ), @@ -858,6 +861,41 @@ describe("PatchTool", () => { ), ) + it.live("does not update an external symlink target when external permission is denied", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([active, outside]) => { + reset() + if (process.platform === "win32") return Effect.void + denyAction = "external_directory" + const target = path.join(outside.path, "external.txt") + const link = path.join(active.path, "link.txt") + return Effect.promise(() => fs.writeFile(target, "before\n")).pipe( + Effect.andThen(Effect.promise(() => fs.symlink(target, link))), + Effect.andThen( + withTool(active.path, (registry) => + Effect.gen(function* () { + expect( + yield* executeTool( + registry, + call("*** Begin Patch\n*** Update File: link.txt\n@@\n-before\n+after\n*** End Patch"), + ), + ).toMatchObject({ type: "error" }) + expect(assertions.map((input) => input.action)).toEqual(["external_directory"]) + expect(readsBeforeEditApproval).toBe(0) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n") + }), + ), + ), + ) + }, + ([active, outside]) => + Effect.promise(() => + Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), + ), + ), + ) + it.live("approves a relative external target before reading and requests edit permission afterward", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),