@@ -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
6470function 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.
141183export 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