Compare commits

...

2 Commits

Author SHA1 Message Date
Aiden Cline 1e3f8abf2d fix(core): authorize external symlink targets 2026-07-21 13:23:34 -05:00
Aiden Cline d246597d1d fix(core): align patch Unicode matching 2026-07-21 13:21:52 -05:00
4 changed files with 73 additions and 3 deletions
+17
View File
@@ -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 ??
+16
View File
@@ -200,6 +200,22 @@ describe("Patch", () => {
)
})
test("does not normalize ellipses", () => {
expect(() =>
Patch.derive("ellipsis.txt", [{ oldLines: ["wait..."], newLines: ["done"] }], "wait…\n"),
).toThrow("Failed to find expected lines")
})
test("prefers a later exact match over an earlier normalized match", () => {
expect(
Patch.derive(
"quotes.txt",
[{ oldLines: ['He said "hello"'], newLines: ['He said "goodbye"'] }],
'He said “hello”\nmiddle\nHe said "hello"\n',
).content,
).toBe('He said “hello”\nmiddle\nHe said "goodbye"\n')
})
test("matches EOF-anchored chunks from the end", () => {
expect(
Patch.derive(
+40 -2
View File
@@ -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()])),
-1
View File
@@ -228,7 +228,6 @@ const normalize = (value: string) =>
.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 }