@@ -27,8 +27,11 @@ import {
2727 decodeExecutionBoundary ,
2828 executionBoundaryContains ,
2929 executionBoundaryDisplayMode ,
30+ projectSandboxBoundaryNegotiation ,
31+ type SandboxBoundaryRequest ,
3032 validateSandboxBoundaryExpansion ,
3133} from '../sandbox-boundary.js' ;
34+ import type { RuntimeEvent } from '../runtime-event.js' ;
3235import {
3336 canReadPath ,
3437 canWritePath ,
@@ -375,6 +378,250 @@ describe('SandboxBoundaryExpansion', () => {
375378 } ) ;
376379} ) ;
377380
381+ describe ( 'projectSandboxBoundaryNegotiation' , ( ) => {
382+ const base = ( id : string , partial : Partial < RuntimeEvent > ) : RuntimeEvent => ( {
383+ id,
384+ invocationId : 'invocation-1' ,
385+ runId : 'run-1' ,
386+ sessionId : 'session-1' ,
387+ turnId : 'turn-1' ,
388+ ts : 1 ,
389+ partial : false ,
390+ role : 'system' ,
391+ author : 'system' ,
392+ ...partial ,
393+ } ) ;
394+
395+ const request = ( id : string , requestId : string , toolUseId : string ) : RuntimeEvent =>
396+ base ( id , {
397+ refs : { toolCallId : toolUseId } ,
398+ actions : {
399+ stateDelta : {
400+ sandboxBoundaryRequest : {
401+ requestId,
402+ toolUseId,
403+ justification : 'Need the smallest boundary expansion.' ,
404+ expansion : { network : { enabled : true } } ,
405+ } ,
406+ } ,
407+ } ,
408+ } ) ;
409+
410+ const decision = (
411+ id : string ,
412+ requestId : string ,
413+ toolUseId : string ,
414+ status : 'approved' | 'denied' | 'conflict' ,
415+ ) : RuntimeEvent =>
416+ base ( id , {
417+ author : 'user' ,
418+ refs : { toolCallId : toolUseId } ,
419+ actions : {
420+ stateDelta : {
421+ sandboxBoundaryDecision : {
422+ requestId,
423+ decision : status === 'denied' ? 'deny' : 'allow' ,
424+ status,
425+ revision : status === 'approved' ? 1 : 0 ,
426+ } ,
427+ } ,
428+ } ,
429+ } ) ;
430+
431+ const failurePair = (
432+ id : string ,
433+ toolName : string ,
434+ toolCallId : string ,
435+ reason : 'invalid_boundary_declaration' | 'sandbox_boundary_required' ,
436+ hidden = false ,
437+ ) : RuntimeEvent [ ] => [
438+ base ( `${ id } -call` , {
439+ role : 'model' ,
440+ author : 'agent' ,
441+ ...( hidden ? { modelVisibility : 'hidden' as const } : { } ) ,
442+ refs : { toolCallId, stepId : `${ id } -step` } ,
443+ content : {
444+ kind : 'function_call' ,
445+ id : toolCallId ,
446+ name : toolName ,
447+ args : toolName === 'Bash' ? { boundary_intent : 'expand' } : { } ,
448+ } ,
449+ } ) ,
450+ base ( `${ id } -response` , {
451+ role : 'tool' ,
452+ author : 'tool' ,
453+ ...( hidden ? { modelVisibility : 'hidden' as const } : { } ) ,
454+ refs : { toolCallId, stepId : `${ id } -step` } ,
455+ content : {
456+ kind : 'function_response' ,
457+ id : toolCallId ,
458+ name : toolName ,
459+ isError : true ,
460+ result : {
461+ kind : 'text' ,
462+ text : 'Sandbox boundary correction failed.' ,
463+ sandboxFailure : { reason } ,
464+ } ,
465+ } ,
466+ } ) ,
467+ ] ;
468+
469+ test ( 'restores denial and both correction budgets, including hidden Code Mode calls' , ( ) => {
470+ const events = [
471+ request ( 'request-1' , 'boundary-1' , 'tool-1' ) ,
472+ decision ( 'decision-1' , 'boundary-1' , 'tool-1' , 'denied' ) ,
473+ ...failurePair (
474+ 'invalid-1' ,
475+ 'request_sandbox_boundary' ,
476+ 'tool-2' ,
477+ 'invalid_boundary_declaration' ,
478+ ) ,
479+ ...failurePair ( 'unresolved-1' , 'Bash' , 'tool-3' , 'sandbox_boundary_required' , true ) ,
480+ ] ;
481+
482+ assert . deepEqual ( projectSandboxBoundaryNegotiation ( events ) , {
483+ kind : 'valid' ,
484+ state : {
485+ denied : true ,
486+ invalidRounds : 1 ,
487+ unresolvedRounds : 1 ,
488+ finalizationRequested : false ,
489+ } ,
490+ } ) ;
491+ } ) ;
492+
493+ test ( 'approved requests reset prior correction state' , ( ) => {
494+ const events = [
495+ ...failurePair (
496+ 'invalid-1' ,
497+ 'request_sandbox_boundary' ,
498+ 'tool-1' ,
499+ 'invalid_boundary_declaration' ,
500+ ) ,
501+ request ( 'request-1' , 'boundary-1' , 'tool-2' ) ,
502+ decision ( 'decision-1' , 'boundary-1' , 'tool-2' , 'approved' ) ,
503+ ] ;
504+ assert . deepEqual ( projectSandboxBoundaryNegotiation ( events ) , {
505+ kind : 'valid' ,
506+ state : {
507+ denied : false ,
508+ invalidRounds : 0 ,
509+ unresolvedRounds : 0 ,
510+ finalizationRequested : false ,
511+ } ,
512+ } ) ;
513+ } ) ;
514+
515+ test ( 'fails closed for malformed or legacy boundary facts' , ( ) => {
516+ const malformed = request ( 'request-1' , 'boundary-1' , 'tool-1' ) ;
517+ malformed . actions ! . stateDelta ! . sandboxBoundaryRequest = {
518+ requestId : 'boundary-1' ,
519+ toolUseId : 'tool-1' ,
520+ justification : 'missing expansion' ,
521+ } ;
522+ assert . equal ( projectSandboxBoundaryNegotiation ( [ malformed ] ) . kind , 'invalid' ) ;
523+
524+ const [ call , response ] = failurePair (
525+ 'legacy-1' ,
526+ 'request_sandbox_boundary' ,
527+ 'tool-1' ,
528+ 'invalid_boundary_declaration' ,
529+ ) ;
530+ ( response . content as Extract < RuntimeEvent [ 'content' ] , { kind : 'function_response' } > ) . result = {
531+ kind : 'text' ,
532+ text : 'Tool arguments failed validation' ,
533+ } ;
534+ assert . equal ( projectSandboxBoundaryNegotiation ( [ call , response ] ) . kind , 'invalid' ) ;
535+ } ) ;
536+
537+ test ( 'fails closed when boundary facts do not preserve call identity' , ( ) => {
538+ const mismatchedDecision = decision ( 'decision-1' , 'boundary-1' , 'other-tool' , 'denied' ) ;
539+ assert . equal (
540+ projectSandboxBoundaryNegotiation ( [
541+ request ( 'request-1' , 'boundary-1' , 'tool-1' ) ,
542+ mismatchedDecision ,
543+ ] ) . kind ,
544+ 'invalid' ,
545+ ) ;
546+
547+ const [ call , response ] = failurePair (
548+ 'invalid-1' ,
549+ 'request_sandbox_boundary' ,
550+ 'tool-1' ,
551+ 'invalid_boundary_declaration' ,
552+ ) ;
553+ ( response . content as Extract < RuntimeEvent [ 'content' ] , { kind : 'function_response' } > ) . name =
554+ 'Bash' ;
555+ assert . equal ( projectSandboxBoundaryNegotiation ( [ call , response ] ) . kind , 'invalid' ) ;
556+ } ) ;
557+
558+ test ( 'fails closed when a boundary failure marker is attached to a non-boundary tool' , ( ) => {
559+ const [ call , response ] = failurePair (
560+ 'forged-1' ,
561+ 'Read' ,
562+ 'tool-1' ,
563+ 'invalid_boundary_declaration' ,
564+ ) ;
565+ assert . equal ( projectSandboxBoundaryNegotiation ( [ call , response ] ) . kind , 'invalid' ) ;
566+ } ) ;
567+
568+ test ( 'requests finalization after the bounded correction budget' , ( ) => {
569+ const events = [
570+ ...failurePair (
571+ 'invalid-1' ,
572+ 'request_sandbox_boundary' ,
573+ 'tool-1' ,
574+ 'invalid_boundary_declaration' ,
575+ ) ,
576+ ...failurePair (
577+ 'invalid-2' ,
578+ 'request_sandbox_boundary' ,
579+ 'tool-2' ,
580+ 'invalid_boundary_declaration' ,
581+ ) ,
582+ ...failurePair (
583+ 'invalid-3' ,
584+ 'request_sandbox_boundary' ,
585+ 'tool-3' ,
586+ 'invalid_boundary_declaration' ,
587+ ) ,
588+ ] ;
589+ const result = projectSandboxBoundaryNegotiation ( events ) ;
590+ assert . equal ( result . kind , 'valid' ) ;
591+ if ( result . kind === 'valid' ) assert . equal ( result . state . finalizationRequested , true ) ;
592+ } ) ;
593+
594+ test ( 'restores a durable denial when the RuntimeEvent ack was lost' , ( ) => {
595+ const durableRequest : SandboxBoundaryRequest = {
596+ sessionId : 'session-1' ,
597+ requestId : 'boundary-1' ,
598+ status : 'denied' ,
599+ baseRevision : 0 ,
600+ expansion : { network : { enabled : true } } ,
601+ justification : 'Need network access.' ,
602+ createdAt : 1 ,
603+ settledAt : 2 ,
604+ turnId : 'turn-1' ,
605+ runId : 'run-1' ,
606+ } ;
607+ assert . deepEqual (
608+ projectSandboxBoundaryNegotiation (
609+ [ base ( 'source-event' , { turnId : 'turn-1' , runId : 'run-1' } ) ] ,
610+ [ durableRequest ] ,
611+ ) ,
612+ {
613+ kind : 'valid' ,
614+ state : {
615+ denied : true ,
616+ invalidRounds : 0 ,
617+ unresolvedRounds : 0 ,
618+ finalizationRequested : false ,
619+ } ,
620+ } ,
621+ ) ;
622+ } ) ;
623+ } ) ;
624+
378625describe ( 'ExecutionBoundary' , ( ) => {
379626 test ( 'decodes only a complete full boundary snapshot' , ( ) => {
380627 const managed = createGenesisExecutionBoundary ( 'ask' ) ;
0 commit comments