Skip to content

Commit 4a0304a

Browse files
committed
Scope spill URI sandbox bypass to reader tools only
1 parent dfb297f commit 4a0304a

2 files changed

Lines changed: 49 additions & 4 deletions

File tree

src/permission/gate.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,7 @@ export function createPermissionGate(
671671
call.arguments,
672672
effectiveCwd,
673673
escapeRoots,
674+
call.name,
674675
);
675676
if (escapeReason !== undefined) {
676677
return { kind: "deny", reason: escapeReason };

src/plugins/path-escape-plugin.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export function pathEscapePlugin(
3535
cwd,
3636
rootsProvider,
3737
resolveAllowOutside(options.allowOutside),
38+
call.name,
3839
);
3940
} catch (err) {
4041
const message = err instanceof Error ? err.message : String(err);
@@ -50,15 +51,16 @@ function escapeArgs(
5051
cwd: string,
5152
rootsProvider: RootsProvider,
5253
allowOutside: boolean,
54+
toolName?: string,
5355
): Record<string, unknown> {
5456
if (!allowOutside) {
55-
const reason = pathEscapeBlockReason(args, cwd, rootsProvider);
57+
const reason = pathEscapeBlockReason(args, cwd, rootsProvider, toolName);
5658
if (reason !== undefined) throw new Error(reason);
5759
}
5860
const out: Record<string, unknown> = {};
5961
for (const [key, value] of Object.entries(args)) {
6062
if (typeof value === "string" && looksLikePath(key)) {
61-
out[key] = sanitizePath(value, cwd, rootsProvider, allowOutside);
63+
out[key] = sanitizePath(value, cwd, rootsProvider, allowOutside, toolName);
6264
} else {
6365
out[key] = value;
6466
}
@@ -83,17 +85,54 @@ export function looksLikePath(key: string): boolean {
8385
);
8486
}
8587

88+
// Only read_file can consume a spilled tool-output blob; every other tool
89+
// rejects the scheme in toolOutputUriPlugin. The sandbox skips containment
90+
// for the same tool so a non-reader is denied here too instead of only by
91+
// plugin order.
92+
// archive:/// refs are served to read_file, grep, and search_files by
93+
// evidenceArchiveSearchPlugin (see advertiseArchiveSurface); other tools have
94+
// no archive reader, so the sandbox only skips containment for those three.
95+
const TOOL_OUTPUT_URI_TOOL = "read_file";
96+
const ARCHIVE_URI_TOOLS = new Set(["read_file", "grep", "search_files"]);
97+
98+
// "skip" when this tool may receive the virtual ref, a block message when it
99+
// may not, undefined when the value is an ordinary filesystem path. An
100+
// omitted toolName keeps the legacy skip so direct callers that predate the
101+
// parameter see no behavior change; the middleware and the permission gate
102+
// always pass a name.
103+
function virtualRefVerdict(
104+
value: string,
105+
toolName: string | undefined,
106+
): "skip" | string | undefined {
107+
if (isToolOutputLike(value)) {
108+
if (toolName === undefined || toolName === TOOL_OUTPUT_URI_TOOL) {
109+
return "skip";
110+
}
111+
return `cannot ${toolName} a tool-output:// URI: ${value}. Use read_file with that URI to read the spilled output instead.`;
112+
}
113+
if (isArchiveLike(value)) {
114+
if (toolName === undefined || ARCHIVE_URI_TOOLS.has(toolName)) {
115+
return "skip";
116+
}
117+
return `cannot ${toolName} an archive:/// ref: ${value}. Only read_file, grep, and search_files accept archive:/// refs.`;
118+
}
119+
return undefined;
120+
}
121+
86122
// Same sandbox pathEscapePlugin enforces at execution. The permission gate
87123
// consults this at authorize time so it can deny instead of asking for a call
88124
// the plugin will reject after Accept.
89125
export function pathEscapeBlockReason(
90126
args: Record<string, unknown>,
91127
cwd: string,
92128
rootsProvider: RootsProvider = () => [],
129+
toolName?: string,
93130
): string | undefined {
94131
for (const [key, value] of Object.entries(args)) {
95132
if (typeof value !== "string" || !looksLikePath(key)) continue;
96-
if (isToolOutputLike(value) || isArchiveLike(value)) continue;
133+
const verdict = virtualRefVerdict(value, toolName);
134+
if (verdict === "skip") continue;
135+
if (typeof verdict === "string") return verdict;
97136
if (resolveWorkspacePath(cwd, value, rootsProvider) === undefined) {
98137
return `Path escapes working directory: ${value}`;
99138
}
@@ -106,10 +145,15 @@ function sanitizePath(
106145
cwd: string,
107146
rootsProvider: RootsProvider,
108147
allowOutside: boolean,
148+
toolName?: string,
109149
): string {
110-
if (isToolOutputLike(value) || isArchiveLike(value)) {
150+
const verdict = virtualRefVerdict(value, toolName);
151+
if (verdict === "skip") {
111152
return value;
112153
}
154+
if (typeof verdict === "string") {
155+
throw new Error(verdict);
156+
}
113157
const resolved = resolveWorkspacePath(cwd, value, rootsProvider);
114158
if (resolved !== undefined) {
115159
return resolved;

0 commit comments

Comments
 (0)