@@ -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.
89125export 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