@@ -342,15 +342,24 @@ describe("pathEscapePlugin", () => {
342342 pathEscapeBlockReason (
343343 { options : { path : "../secret.txt" } } ,
344344 "/project" ,
345+ ( ) => [ ] ,
346+ "read_file" ,
345347 ) ,
346348 ) . toMatch ( / e s c a p e s w o r k i n g d i r e c t o r y / ) ;
347349 expect (
348- pathEscapeBlockReason ( { filepath : "../secret.txt" } , "/project" ) ,
350+ pathEscapeBlockReason (
351+ { filepath : "../secret.txt" } ,
352+ "/project" ,
353+ ( ) => [ ] ,
354+ "read_file" ,
355+ ) ,
349356 ) . toMatch ( / e s c a p e s w o r k i n g d i r e c t o r y / ) ;
350357 expect (
351358 pathEscapeBlockReason (
352359 { paths : [ "src/index.ts" , "../secret.txt" ] } ,
353360 "/project" ,
361+ ( ) => [ ] ,
362+ "read_file" ,
354363 ) ,
355364 ) . toMatch ( / e s c a p e s w o r k i n g d i r e c t o r y / ) ;
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 ( / e s c a p e s w o r k i n g d i r e c t o r y / ) ;
388399 expect (
389- pathEscapeBlockReason ( { [ key ] : "../secret.txt" } , "/project" ) ,
400+ pathEscapeBlockReason (
401+ { [ key ] : "../secret.txt" } ,
402+ "/project" ,
403+ ( ) => [ ] ,
404+ "read_file" ,
405+ ) ,
390406 ) . toMatch ( / e s c a p e s w o r k i n g d i r e c t o r y / ) ;
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 ( / t o o l - o u t p u t / ) ;
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 ( / t o o l - o u t p u t / ) ;
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 ( / a r c h i v e / ) ;
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 ( / t o o l - o u t p u t / ) ;
545+ expect (
546+ pathEscapeBlockReason (
547+ { path : "archive:///occ-abc" } ,
548+ "/project" ,
549+ ( ) => [ ] ,
550+ omitted ,
551+ ) ,
552+ ) . toMatch ( / a r c h i v e / ) ;
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 ( / t o o l - o u t p u t / ) ;
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 ( / a r c h i v e / ) ;
580+ } ) ;
581+ } ) ;
432582} ) ;
0 commit comments