Skip to content

Commit e2b235e

Browse files
Merge pull request #1483 from corbitsdev/cl-10775-drop-path-escape-throw
refactor(plugins): drop the duplicate path-escape throw
2 parents 510d686 + 8ad7ac3 commit e2b235e

4 files changed

Lines changed: 93 additions & 51 deletions

File tree

‎src/permission/gate.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -719,9 +719,8 @@ export function createPermissionGate(
719719
if (cachedDenial !== undefined)
720720
return { kind: "deny", reason: cachedDenial };
721721

722-
// Path-escape will hard-reject these at execution; deny here rather than
723-
// showing Accept for a call that cannot succeed. Workers sandbox against
724-
// their own cwd's roots, not the session listing.
722+
// Hard-deny workspace escapes here, before allow and before ask.
723+
// Workers sandbox against their own cwd's roots, not the session listing.
725724
const escapeRoots =
726725
effectiveCwd === resolvedCwd
727726
? rootsProvider

‎src/permission/permission.test.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ import {
4444
import type { Approval, ApprovalOutcome, PermissionRequest } from "./types.js";
4545
import { initTemporaryGitRepo } from "../../testkit/temporary-git-repo.js";
4646
import { secretGuardPlugin } from "../plugins/secret-guard-plugin.js";
47-
import { pathEscapePlugin } from "../plugins/path-escape-plugin.js";
47+
import {
48+
pathEscapePlugin,
49+
pathEscapeBlockReason,
50+
} from "../plugins/path-escape-plugin.js";
4851

4952
const shellCall = (command: string): ToolCall =>
5053
toolCall("run_shell", { command });
@@ -2668,7 +2671,7 @@ describe("listWorktreeRoots", () => {
26682671
expect(result.isError).not.toBe(true);
26692672
});
26702673

2671-
test("pathEscapePlugin still rejects a relative path into a genuinely unrelated directory", async () => {
2674+
test("the gate denies a relative path into a genuinely unrelated directory", async () => {
26722675
const { repo } = createRepoWithWorktree();
26732676
const roots = await listWorktreeRoots(repo);
26742677
const outside = mkdtempSync(join(tmpdir(), "intercode-unrelated-plugin-"));
@@ -2681,8 +2684,15 @@ describe("listWorktreeRoots", () => {
26812684
toolCall("read_file", { path: relativeToOutside }),
26822685
new AbortController().signal,
26832686
);
2684-
expect(result.isError).toBe(true);
2685-
expect(result.content).toMatch(/escapes working directory/);
2687+
expect(result.isError).not.toBe(true);
2688+
expect(
2689+
pathEscapeBlockReason(
2690+
{ path: relativeToOutside },
2691+
repo,
2692+
() => roots,
2693+
"read_file",
2694+
),
2695+
).toMatch(/escapes working directory/);
26862696
});
26872697
});
26882698

‎src/plugins/path-escape-plugin.test.ts‎

Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -44,26 +44,31 @@ describe("pathEscapePlugin", () => {
4444
["source", "copy", "/etc/passwd"],
4545
["filename", "write_file", "../secret.txt"],
4646
] as const) {
47-
test(`blocks escape via ${key} key (${tool})`, async () => {
47+
test(`leaves an outside ${key} unchanged; the gate denies ${tool}`, async () => {
4848
const plugin = pathEscapePlugin("/project");
49-
const handler = pluginHandler(plugin, okHandler);
49+
const handler = pluginHandler(plugin, echoArgsHandler);
5050
const result = await handler(
5151
makeToolCall(tool, { [key]: value }),
5252
neverAbort(),
5353
);
54-
expect(result.isError).toBe(true);
54+
expect(result.isError).not.toBe(true);
55+
const args = JSON.parse(String(result.content)) as Record<string, string>;
56+
expect(args[key]).toBe(value);
57+
expect(
58+
pathEscapeBlockReason({ [key]: value }, "/project", () => [], tool),
59+
).toMatch(/escapes working directory/);
5560
});
5661
}
5762

58-
test("the block message tells the operator the path escapes the working directory", async () => {
59-
const plugin = pathEscapePlugin("/project");
60-
const handler = pluginHandler(plugin, okHandler);
61-
const result = await handler(
62-
makeToolCall("read_file", { path: "../secret.txt" }),
63-
neverAbort(),
64-
);
65-
expect(result.isError).toBe(true);
66-
expect(result.content).toMatch(/escapes working directory/);
63+
test("the gate denial tells the operator the path escapes the working directory", () => {
64+
expect(
65+
pathEscapeBlockReason(
66+
{ path: "../secret.txt" },
67+
"/project",
68+
() => [],
69+
"read_file",
70+
),
71+
).toMatch(/escapes working directory/);
6772
});
6873

6974
test("allows cwd path itself", async () => {
@@ -114,8 +119,17 @@ describe("pathEscapePlugin", () => {
114119
makeToolCall("read_file", { path: "../other-repo/README.md" }),
115120
neverAbort(),
116121
);
117-
expect(blocked.isError).toBe(true);
118-
expect(blocked.content).toMatch(/escapes working directory/);
122+
expect(blocked.isError).not.toBe(true);
123+
const blockedArgs = JSON.parse(String(blocked.content)) as { path: string };
124+
expect(blockedArgs.path).toBe("../other-repo/README.md");
125+
expect(
126+
pathEscapeBlockReason(
127+
{ path: "../other-repo/README.md" },
128+
"/project",
129+
() => [],
130+
"read_file",
131+
),
132+
).toMatch(/escapes working directory/);
119133

120134
allow = true;
121135
const allowed = await handler(
@@ -204,15 +218,20 @@ describe("pathEscapePlugin", () => {
204218
return { next, seen: () => seen };
205219
};
206220

207-
test("blocks escape in a nested object under a path-like key", async () => {
221+
test("passes a nested escape through; the gate denies it", async () => {
222+
const args = { options: { path: "../secret.txt" } };
208223
const plugin = pathEscapePlugin("/project");
209-
const handler = pluginHandler(plugin, okHandler);
224+
const { next, seen } = captureNext();
225+
const handler = pluginHandler(plugin, next);
210226
const result = await handler(
211-
makeToolCall("read_file", { options: { path: "../secret.txt" } }),
227+
makeToolCall("read_file", args),
212228
neverAbort(),
213229
);
214-
expect(result.isError).toBe(true);
215-
expect(result.content).toMatch(/escapes working directory/);
230+
expect(result.isError).not.toBe(true);
231+
expect(seen()).toEqual(args);
232+
expect(
233+
pathEscapeBlockReason(args, "/project", () => [], "read_file"),
234+
).toMatch(/escapes working directory/);
216235
});
217236

218237
test("resolves nested in-bounds paths instead of passing them through", async () => {
@@ -229,28 +248,38 @@ describe("pathEscapePlugin", () => {
229248
});
230249
});
231250

232-
test("blocks escape via the filepath spelling", async () => {
251+
test("passes an escape via the filepath spelling; the gate denies it", async () => {
252+
const args = { filepath: "../secret.txt" };
233253
const plugin = pathEscapePlugin("/project");
234-
const handler = pluginHandler(plugin, okHandler);
254+
const { next, seen } = captureNext();
255+
const handler = pluginHandler(plugin, next);
235256
const result = await handler(
236-
makeToolCall("read_file", { filepath: "../secret.txt" }),
257+
makeToolCall("read_file", args),
237258
neverAbort(),
238259
);
239-
expect(result.isError).toBe(true);
240-
expect(result.content).toMatch(/escapes working directory/);
260+
expect(result.isError).not.toBe(true);
261+
expect(seen()).toEqual(args);
262+
expect(
263+
pathEscapeBlockReason(args, "/project", () => [], "read_file"),
264+
).toMatch(/escapes working directory/);
241265
});
242266

243-
test("blocks escape in a string array under a path-like key", async () => {
267+
test("passes an escape in a string array; the gate denies it", async () => {
268+
const args = { paths: ["src/index.ts", "../secret.txt"] };
244269
const plugin = pathEscapePlugin("/project");
245-
const handler = pluginHandler(plugin, okHandler);
270+
const { next, seen } = captureNext();
271+
const handler = pluginHandler(plugin, next);
246272
const result = await handler(
247-
makeToolCall("read_file", {
248-
paths: ["src/index.ts", "../secret.txt"],
249-
}),
273+
makeToolCall("read_file", args),
250274
neverAbort(),
251275
);
252-
expect(result.isError).toBe(true);
253-
expect(result.content).toMatch(/escapes working directory/);
276+
expect(result.isError).not.toBe(true);
277+
expect(seen()).toEqual({
278+
paths: ["/project/src/index.ts", "../secret.txt"],
279+
});
280+
expect(
281+
pathEscapeBlockReason(args, "/project", () => [], "read_file"),
282+
).toMatch(/escapes working directory/);
254283
});
255284

256285
test("pathEscapeBlockReason agrees with execution time on nested escapes", async () => {
@@ -302,14 +331,15 @@ describe("pathEscapePlugin", () => {
302331

303332
test("FILE_PATH and file-path match case- and separator-insensitively", async () => {
304333
const plugin = pathEscapePlugin("/project");
305-
const handler = pluginHandler(plugin, okHandler);
334+
const { next, seen } = captureNext();
335+
const handler = pluginHandler(plugin, next);
306336
for (const key of ["FILE_PATH", "file-path"]) {
307337
const result = await handler(
308338
makeToolCall("read_file", { [key]: "../secret.txt" }),
309339
neverAbort(),
310340
);
311-
expect(result.isError).toBe(true);
312-
expect(result.content).toMatch(/escapes working directory/);
341+
expect(result.isError).not.toBe(true);
342+
expect(seen()).toEqual({ [key]: "../secret.txt" });
313343
expect(
314344
pathEscapeBlockReason(
315345
{ [key]: "../secret.txt" },
@@ -529,13 +559,14 @@ describe("pathEscapePlugin", () => {
529559
const plugin = pathEscapePlugin(cwd, () => [], {
530560
trustedPluginRoots: () => [pluginDir],
531561
});
532-
const handler = pluginHandler(plugin, okHandler);
562+
const handler = pluginHandler(plugin, echoArgsHandler);
533563
const result = await handler(
534564
makeToolCall("write_file", { path: target, content: "x" }),
535565
neverAbort(),
536566
);
537-
expect(result.isError).toBe(true);
538-
expect(result.content).toMatch(/escapes working directory/);
567+
expect(result.isError).not.toBe(true);
568+
const args = JSON.parse(String(result.content)) as { path: string };
569+
expect(args.path).toBe(target);
539570
expect(
540571
pathEscapeBlockReason(
541572
{ path: target, content: "x" },

‎src/plugins/path-escape-plugin.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,9 @@ function escapeArgs(
8080
toolName: string,
8181
trustedPluginRoots?: RootsProvider,
8282
): Record<string, unknown> {
83-
// One fused walk: sanitizePath throws the same deny strings
84-
// pathEscapeBlockReason computes, so a surviving call is exactly one the
85-
// block-reason leg would have passed.
83+
// Virtual-ref denials still throw. Workspace escapes pass through unchanged
84+
// when allowOutside is false; the permission gate denies them via
85+
// pathEscapeBlockReason before allow and before ask.
8686
const roots = rootsForEscape(toolName, rootsProvider, trustedPluginRoots);
8787
return walkPathArgs(args, undefined, (value) =>
8888
sanitizePath(value, cwd, roots, allowOutside, toolName),
@@ -200,9 +200,9 @@ export function virtualRefVerdict(
200200
return undefined;
201201
}
202202

203-
// Same sandbox pathEscapePlugin enforces at execution. The permission gate
204-
// consults this at authorize time so it can deny instead of asking for a call
205-
// the plugin will reject after Accept.
203+
// The permission gate consults this before allow and before ask so an
204+
// out-of-workspace path is denied instead of prompted. sanitizePath does not
205+
// throw a second copy of that denial.
206206
export function pathEscapeBlockReason(
207207
args: Record<string, unknown>,
208208
cwd: string,
@@ -297,5 +297,7 @@ function sanitizePath(
297297
// so later plugins see a stable path, not a relative escape fragment.
298298
return resolve(cwd, value);
299299
}
300-
throw new Error(`Path escapes working directory: ${value}`);
300+
// The gate already hard-denies this escape. Leave the original path so a
301+
// later permission check sees the same value the operator sent.
302+
return value;
301303
}

0 commit comments

Comments
 (0)