Skip to content

Commit ac77922

Browse files
committed
Scope spill URI sandbox bypass to reader tools only
1 parent eac78e6 commit ac77922

2 files changed

Lines changed: 70 additions & 12 deletions

File tree

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.ts

Lines changed: 69 additions & 12 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,20 @@ 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
}
58-
return escapeValue(args, cwd, rootsProvider, allowOutside) as Record<
59-
string,
60-
unknown
61-
>;
60+
return escapeValue(
61+
args,
62+
cwd,
63+
rootsProvider,
64+
allowOutside,
65+
undefined,
66+
toolName,
67+
) as Record<string, unknown>;
6268
}
6369

6470
function escapeValue(
@@ -67,15 +73,16 @@ function escapeValue(
6773
rootsProvider: RootsProvider,
6874
allowOutside: boolean,
6975
key?: string,
76+
toolName?: string,
7077
): unknown {
7178
if (typeof value === "string") {
7279
return key !== undefined && looksLikePath(key)
73-
? sanitizePath(value, cwd, rootsProvider, allowOutside)
80+
? sanitizePath(value, cwd, rootsProvider, allowOutside, toolName)
7481
: value;
7582
}
7683
if (Array.isArray(value)) {
7784
return value.map((entry) =>
78-
escapeValue(entry, cwd, rootsProvider, allowOutside, key),
85+
escapeValue(entry, cwd, rootsProvider, allowOutside, key, toolName),
7986
);
8087
}
8188
if (typeof value === "object" && value !== null) {
@@ -87,6 +94,7 @@ function escapeValue(
8794
rootsProvider,
8895
allowOutside,
8996
entryKey,
97+
toolName,
9098
);
9199
}
92100
return out;
@@ -135,15 +143,50 @@ export function looksLikePath(key: string): boolean {
135143
);
136144
}
137145

146+
// Only read_file can consume a spilled tool-output blob; every other tool
147+
// rejects the scheme in toolOutputUriPlugin. The sandbox skips containment
148+
// for the same tool so a non-reader is denied here too instead of only by
149+
// plugin order.
150+
// archive:/// refs are served to read_file, grep, and search_files by
151+
// evidenceArchiveSearchPlugin (see advertiseArchiveSurface); other tools have
152+
// no archive reader, so the sandbox only skips containment for those three.
153+
const TOOL_OUTPUT_URI_TOOL = "read_file";
154+
const ARCHIVE_URI_TOOLS = new Set(["read_file", "grep", "search_files"]);
155+
156+
// "skip" when this tool may receive the virtual ref, a block message when it
157+
// may not, undefined when the value is an ordinary filesystem path. An
158+
// omitted toolName keeps the legacy skip so direct callers that predate the
159+
// parameter see no behavior change; the middleware and the permission gate
160+
// always pass a name.
161+
function virtualRefVerdict(
162+
value: string,
163+
toolName: string | undefined,
164+
): "skip" | string | undefined {
165+
if (isToolOutputLike(value)) {
166+
if (toolName === undefined || toolName === TOOL_OUTPUT_URI_TOOL) {
167+
return "skip";
168+
}
169+
return `cannot ${toolName} a tool-output:// URI: ${value}. Use read_file with that URI to read the spilled output instead.`;
170+
}
171+
if (isArchiveLike(value)) {
172+
if (toolName === undefined || ARCHIVE_URI_TOOLS.has(toolName)) {
173+
return "skip";
174+
}
175+
return `cannot ${toolName} an archive:/// ref: ${value}. Only read_file, grep, and search_files accept archive:/// refs.`;
176+
}
177+
return undefined;
178+
}
179+
138180
// Same sandbox pathEscapePlugin enforces at execution. The permission gate
139181
// consults this at authorize time so it can deny instead of asking for a call
140182
// the plugin will reject after Accept.
141183
export function pathEscapeBlockReason(
142184
args: Record<string, unknown>,
143185
cwd: string,
144186
rootsProvider: RootsProvider = () => [],
187+
toolName?: string,
145188
): string | undefined {
146-
return blockReasonFor(args, cwd, rootsProvider);
189+
return blockReasonFor(args, cwd, rootsProvider, undefined, toolName);
147190
}
148191

149192
// Deep-walk identity for the permission gate's authorize/execution cache.
@@ -190,25 +233,34 @@ function blockReasonFor(
190233
cwd: string,
191234
rootsProvider: RootsProvider,
192235
key?: string,
236+
toolName?: string,
193237
): string | undefined {
194238
if (typeof value === "string") {
195239
if (key === undefined || !looksLikePath(key)) return undefined;
196-
if (isToolOutputLike(value) || isArchiveLike(value)) return undefined;
240+
const verdict = virtualRefVerdict(value, toolName);
241+
if (verdict === "skip") return undefined;
242+
if (typeof verdict === "string") return verdict;
197243
if (resolveWorkspacePath(cwd, value, rootsProvider) === undefined) {
198244
return `Path escapes working directory: ${value}`;
199245
}
200246
return undefined;
201247
}
202248
if (Array.isArray(value)) {
203249
for (const entry of value) {
204-
const reason = blockReasonFor(entry, cwd, rootsProvider, key);
250+
const reason = blockReasonFor(entry, cwd, rootsProvider, key, toolName);
205251
if (reason !== undefined) return reason;
206252
}
207253
return undefined;
208254
}
209255
if (typeof value === "object" && value !== null) {
210256
for (const [entryKey, entryValue] of Object.entries(value)) {
211-
const reason = blockReasonFor(entryValue, cwd, rootsProvider, entryKey);
257+
const reason = blockReasonFor(
258+
entryValue,
259+
cwd,
260+
rootsProvider,
261+
entryKey,
262+
toolName,
263+
);
212264
if (reason !== undefined) return reason;
213265
}
214266
}
@@ -220,10 +272,15 @@ function sanitizePath(
220272
cwd: string,
221273
rootsProvider: RootsProvider,
222274
allowOutside: boolean,
275+
toolName?: string,
223276
): string {
224-
if (isToolOutputLike(value) || isArchiveLike(value)) {
277+
const verdict = virtualRefVerdict(value, toolName);
278+
if (verdict === "skip") {
225279
return value;
226280
}
281+
if (typeof verdict === "string") {
282+
throw new Error(verdict);
283+
}
227284
const resolved = resolveWorkspacePath(cwd, value, rootsProvider);
228285
if (resolved !== undefined) {
229286
return resolved;

0 commit comments

Comments
 (0)