@@ -16,6 +16,10 @@ import {
1616 pathEscapeBlockReason ,
1717 pathEscapePlugin ,
1818} from "./path-escape-plugin.js" ;
19+ import {
20+ encodeResumeCursor ,
21+ type ResumeCursor ,
22+ } from "../util/tool-output-uri.js" ;
1923import type { ToolCall , ToolResult } from "@intx/types/runtime" ;
2024
2125function makeCall ( name : string , args : Record < string , unknown > ) : ToolCall {
@@ -455,6 +459,80 @@ describe("pathEscapePlugin", () => {
455459 expect ( args . path ) . toBe ( "tool-output:///abc123" ) ;
456460 } ) ;
457461
462+ test ( "a forged file cursor for an outside-root path is denied, not served" , async ( ) => {
463+ const cwd = await mkdtemp ( join ( tmpdir ( ) , "corbits-escape-cursor-cwd-" ) ) ;
464+ const outsideDir = await mkdtemp (
465+ join ( tmpdir ( ) , "corbits-escape-cursor-outside-" ) ,
466+ ) ;
467+ const outsidePath = join ( outsideDir , "secret.txt" ) ;
468+ await writeFile ( outsidePath , "top-secret" ) ;
469+ try {
470+ const forged = encodeResumeCursor ( {
471+ source : { kind : "file" , path : outsidePath } ,
472+ offset : 0 ,
473+ limit : 4 ,
474+ nonce : "forged-nonce" ,
475+ } satisfies ResumeCursor ) ;
476+ expect (
477+ pathEscapeBlockReason ( { path : forged } , cwd , ( ) => [ ] , "read_file" ) ,
478+ ) . toMatch ( / e s c a p e s w o r k i n g d i r e c t o r y / ) ;
479+ const plugin = pathEscapePlugin ( cwd , ( ) => [ ] ) ;
480+ const handler = plugin . middleware
481+ ? plugin . middleware ( nextHandler )
482+ : nextHandler ;
483+ const result = await handler (
484+ makeCall ( "read_file" , { path : forged } ) ,
485+ new AbortController ( ) . signal ,
486+ ) ;
487+ expect ( result . isError ) . toBe ( true ) ;
488+ 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 / ) ;
489+ } finally {
490+ await rm ( cwd , { recursive : true , force : true } ) ;
491+ await rm ( outsideDir , { recursive : true , force : true } ) ;
492+ }
493+ } ) ;
494+
495+ test ( "an in-bounds file cursor and a blob cursor keep the read_file exemption" , async ( ) => {
496+ const cwd = await mkdtemp ( join ( tmpdir ( ) , "corbits-escape-cursor-ok-" ) ) ;
497+ const insidePath = join ( cwd , "notes.txt" ) ;
498+ await writeFile ( insidePath , "notes" ) ;
499+ try {
500+ const inBounds = encodeResumeCursor ( {
501+ source : { kind : "file" , path : insidePath } ,
502+ offset : 4 ,
503+ limit : 4 ,
504+ nonce : "minted-nonce" ,
505+ } satisfies ResumeCursor ) ;
506+ expect (
507+ pathEscapeBlockReason ( { path : inBounds } , cwd , ( ) => [ ] , "read_file" ) ,
508+ ) . toBeUndefined ( ) ;
509+ const blob = encodeResumeCursor ( {
510+ source : { kind : "blob" , uri : "tool-output:///abc123" } ,
511+ offset : 0 ,
512+ limit : 4 ,
513+ nonce : "blob-nonce" ,
514+ } satisfies ResumeCursor ) ;
515+ expect (
516+ pathEscapeBlockReason ( { path : blob } , cwd , ( ) => [ ] , "read_file" ) ,
517+ ) . toBeUndefined ( ) ;
518+ const plugin = pathEscapePlugin ( cwd , ( ) => [ ] ) ;
519+ const next = async ( call : ToolCall ) : Promise < ToolResult > => ( {
520+ callId : call . id ,
521+ content : JSON . stringify ( call . arguments ) ,
522+ } ) ;
523+ const handler = plugin . middleware ? plugin . middleware ( next ) : next ;
524+ const result = await handler (
525+ makeCall ( "read_file" , { path : inBounds } ) ,
526+ new AbortController ( ) . signal ,
527+ ) ;
528+ expect ( result . isError ) . not . toBe ( true ) ;
529+ const args = JSON . parse ( String ( result . content ) ) as { path : string } ;
530+ expect ( args . path ) . toBe ( inBounds ) ;
531+ } finally {
532+ await rm ( cwd , { recursive : true , force : true } ) ;
533+ }
534+ } ) ;
535+
458536 test ( "archive refs pass for archive readers but not for other tools" , async ( ) => {
459537 for ( const name of [ "read_file" , "grep" , "search_files" ] ) {
460538 expect (
0 commit comments