Skip to content

Commit 233bb39

Browse files
Merge pull request #1006 from corbitsdev/cl-6727-only-skip-tool-output-uri-sandbox-for-read_file
Only skip the spill URI sandbox for reader tools
2 parents 9e2774f + c668956 commit 233bb39

4 files changed

Lines changed: 266 additions & 17 deletions

File tree

src/permission/gate.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,3 +475,38 @@ describe("grant-mismatch asks carry the guard reason as a notice (CL-6824)", ()
475475
expect(seen).toHaveLength(0);
476476
});
477477
});
478+
479+
// Spill URI sandbox (CL-6727): the permission gate denies a non-reader
480+
// virtual ref at authorize time, mirroring the execution-time middleware
481+
// deny, while the exempted reader is not denied.
482+
describe("spill URI sandbox at authorize time (CL-6727)", () => {
483+
const cwd = mkdtempSync(join(tmpdir(), "gate-spill-uri-"));
484+
const gate = createPermissionGate({
485+
approvals: [],
486+
interactive: false,
487+
skipPermissions: false,
488+
reactorGated: false,
489+
cwd,
490+
});
491+
492+
test("grep + tool-output:/// is denied at authorize", async () => {
493+
const verdict = await gate.authorizeCall({
494+
id: "spill-grep",
495+
name: "grep",
496+
arguments: { pattern: "foo", path: "tool-output:///abc123" },
497+
});
498+
expect(verdict.effect).toBe("deny");
499+
expect(verdict.effect === "deny" ? verdict.reason : "").toMatch(
500+
/tool-output/,
501+
);
502+
});
503+
504+
test("read_file + the same tool-output:/// URI is not denied", async () => {
505+
const verdict = await gate.authorizeCall({
506+
id: "spill-read",
507+
name: "read_file",
508+
arguments: { path: "tool-output:///abc123" },
509+
});
510+
expect(verdict.effect).not.toBe("deny");
511+
});
512+
});

src/permission/gate.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,7 @@ export function createPermissionGate(
666666
call.arguments,
667667
effectiveCwd,
668668
escapeRoots,
669+
call.name,
669670
);
670671
if (escapeReason !== undefined) {
671672
return { kind: "deny", reason: escapeReason };

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

Lines changed: 153 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -342,15 +342,24 @@ describe("pathEscapePlugin", () => {
342342
pathEscapeBlockReason(
343343
{ options: { path: "../secret.txt" } },
344344
"/project",
345+
() => [],
346+
"read_file",
345347
),
346348
).toMatch(/escapes working directory/);
347349
expect(
348-
pathEscapeBlockReason({ filepath: "../secret.txt" }, "/project"),
350+
pathEscapeBlockReason(
351+
{ filepath: "../secret.txt" },
352+
"/project",
353+
() => [],
354+
"read_file",
355+
),
349356
).toMatch(/escapes working directory/);
350357
expect(
351358
pathEscapeBlockReason(
352359
{ paths: ["src/index.ts", "../secret.txt"] },
353360
"/project",
361+
() => [],
362+
"read_file",
354363
),
355364
).toMatch(/escapes working directory/);
356365
});
@@ -369,6 +378,8 @@ describe("pathEscapePlugin", () => {
369378
pathEscapeBlockReason(
370379
{ options: { command: "../secret.txt" } },
371380
"/project",
381+
() => [],
382+
"custom_tool",
372383
),
373384
).toBeUndefined();
374385
});
@@ -386,7 +397,12 @@ describe("pathEscapePlugin", () => {
386397
expect(result.isError).toBe(true);
387398
expect(result.content).toMatch(/escapes working directory/);
388399
expect(
389-
pathEscapeBlockReason({ [key]: "../secret.txt" }, "/project"),
400+
pathEscapeBlockReason(
401+
{ [key]: "../secret.txt" },
402+
"/project",
403+
() => [],
404+
"read_file",
405+
),
390406
).toMatch(/escapes working directory/);
391407
}
392408
});
@@ -406,7 +422,9 @@ describe("pathEscapePlugin", () => {
406422
);
407423
expect(result.isError).not.toBe(true);
408424
expect(seen()).toEqual(args);
409-
expect(pathEscapeBlockReason(args, "/project")).toBeUndefined();
425+
expect(
426+
pathEscapeBlockReason(args, "/project", () => [], "custom_tool"),
427+
).toBeUndefined();
410428
});
411429

412430
test("normalizePathArguments shares the plugin rewrite identity", () => {
@@ -429,4 +447,136 @@ describe("pathEscapePlugin", () => {
429447
).toEqual({ xpath: "src/index.ts" });
430448
});
431449
});
450+
451+
describe("spill URI sandbox (CL-6727)", () => {
452+
test("pathEscapeBlockReason blocks a tool-output URI for a non-reader", () => {
453+
const reason = pathEscapeBlockReason(
454+
{ path: "tool-output:///abc123" },
455+
"/project",
456+
() => [],
457+
"grep",
458+
);
459+
expect(reason).toMatch(/tool-output/);
460+
});
461+
462+
test("middleware blocks a non-reader tool-output call with no rejector plugin", async () => {
463+
const plugin = pathEscapePlugin("/project");
464+
const handler = plugin.middleware
465+
? plugin.middleware(nextHandler)
466+
: nextHandler;
467+
const result = await handler(
468+
makeCall("grep", {
469+
pattern: "foo",
470+
path: "tool-output:///abc123",
471+
}),
472+
new AbortController().signal,
473+
);
474+
expect(result.isError).toBe(true);
475+
expect(result.content).toMatch(/tool-output/);
476+
});
477+
478+
test("read_file still passes a tool-output URI through", async () => {
479+
expect(
480+
pathEscapeBlockReason(
481+
{ path: "tool-output:///abc123" },
482+
"/project",
483+
() => [],
484+
"read_file",
485+
),
486+
).toBeUndefined();
487+
const plugin = pathEscapePlugin("/project");
488+
const next = async (call: ToolCall): Promise<ToolResult> => ({
489+
callId: call.id,
490+
content: JSON.stringify(call.arguments),
491+
});
492+
const handler = plugin.middleware ? plugin.middleware(next) : next;
493+
const result = await handler(
494+
makeCall("read_file", { path: "tool-output:///abc123" }),
495+
new AbortController().signal,
496+
);
497+
expect(result.isError).not.toBe(true);
498+
const args = JSON.parse(String(result.content)) as { path: string };
499+
expect(args.path).toBe("tool-output:///abc123");
500+
});
501+
502+
test("archive refs pass for archive readers but not for other tools", async () => {
503+
for (const name of ["read_file", "grep", "search_files"]) {
504+
expect(
505+
pathEscapeBlockReason(
506+
{ path: "archive:///occ-abc" },
507+
"/project",
508+
() => [],
509+
name,
510+
),
511+
).toBeUndefined();
512+
}
513+
expect(
514+
pathEscapeBlockReason(
515+
{ path: "archive:///occ-abc" },
516+
"/project",
517+
() => [],
518+
"write_file",
519+
),
520+
).toMatch(/archive/);
521+
const plugin = pathEscapePlugin("/project");
522+
const handler = plugin.middleware
523+
? plugin.middleware(nextHandler)
524+
: nextHandler;
525+
const blocked = await handler(
526+
makeCall("write_file", {
527+
path: "archive:///occ-abc",
528+
content: "hi",
529+
}),
530+
new AbortController().signal,
531+
);
532+
expect(blocked.isError).toBe(true);
533+
});
534+
535+
test("omitted toolName fails closed on virtual refs", () => {
536+
const omitted = undefined as unknown as string;
537+
expect(
538+
pathEscapeBlockReason(
539+
{ path: "tool-output:///abc123" },
540+
"/project",
541+
() => [],
542+
omitted,
543+
),
544+
).toMatch(/tool-output/);
545+
expect(
546+
pathEscapeBlockReason(
547+
{ path: "archive:///occ-abc" },
548+
"/project",
549+
() => [],
550+
omitted,
551+
),
552+
).toMatch(/archive/);
553+
});
554+
555+
test("allowOutside still denies a non-reader virtual ref at execution", async () => {
556+
const plugin = pathEscapePlugin("/project", () => [], {
557+
allowOutside: true,
558+
});
559+
const handler = plugin.middleware
560+
? plugin.middleware(nextHandler)
561+
: nextHandler;
562+
const spill = await handler(
563+
makeCall("grep", {
564+
pattern: "foo",
565+
path: "tool-output:///abc123",
566+
}),
567+
new AbortController().signal,
568+
);
569+
expect(spill.isError).toBe(true);
570+
expect(spill.content).toMatch(/tool-output/);
571+
const archive = await handler(
572+
makeCall("write_file", {
573+
path: "archive:///occ-abc",
574+
content: "hi",
575+
}),
576+
new AbortController().signal,
577+
);
578+
expect(archive.isError).toBe(true);
579+
expect(archive.content).toMatch(/archive/);
580+
});
581+
});
432582
});

0 commit comments

Comments
 (0)