@@ -38,6 +38,18 @@ const evaluateShellAllowlistMock = vi.hoisted(() =>
3838 segmentAllowlistEntries : [ ] ,
3939 } ) ) ,
4040) ;
41+ const resolveExecApprovalsFromFileMock = vi . hoisted ( ( ) =>
42+ vi . fn ( ( ) => ( {
43+ allowlist : [ ] ,
44+ file : { version : 1 , agents : { } } ,
45+ agent : {
46+ security : "full" ,
47+ ask : "off" ,
48+ askFallback : "deny" ,
49+ autoAllowSkills : false ,
50+ } ,
51+ } ) ) ,
52+ ) ;
4153const requiresExecApprovalMock = vi . hoisted ( ( ) => vi . fn ( ( ) => true ) ) ;
4254const resolveExecHostApprovalContextMock = vi . hoisted ( ( ) =>
4355 vi . fn ( ( ) => ( {
@@ -93,10 +105,7 @@ vi.mock("../infra/exec-approvals.js", () => ({
93105 hasDurableExecApproval : vi . fn ( ( ) => false ) ,
94106 requiresExecApproval : requiresExecApprovalMock ,
95107 resolveExecApprovalAllowedDecisions : vi . fn ( ( ) => [ "allow-once" , "allow-always" , "deny" ] ) ,
96- resolveExecApprovalsFromFile : vi . fn ( ( ) => ( {
97- allowlist : [ ] ,
98- file : { version : 1 , agents : { } } ,
99- } ) ) ,
108+ resolveExecApprovalsFromFile : resolveExecApprovalsFromFileMock ,
100109} ) ) ;
101110
102111vi . mock ( "../infra/command-analysis/inline-eval.js" , ( ) => ( {
@@ -275,6 +284,17 @@ describe("executeNodeHostCommand", () => {
275284 segments : [ { resolution : null , argv : [ "bun" , "./script.ts" ] } ] ,
276285 segmentAllowlistEntries : [ ] ,
277286 } ) ;
287+ resolveExecApprovalsFromFileMock . mockReset ( ) ;
288+ resolveExecApprovalsFromFileMock . mockReturnValue ( {
289+ allowlist : [ ] ,
290+ file : { version : 1 , agents : { } } ,
291+ agent : {
292+ security : "full" ,
293+ ask : "off" ,
294+ askFallback : "deny" ,
295+ autoAllowSkills : false ,
296+ } ,
297+ } ) ;
278298 requiresExecApprovalMock . mockReset ( ) ;
279299 requiresExecApprovalMock . mockReturnValue ( true ) ;
280300 resolveExecHostApprovalContextMock . mockReset ( ) ;
@@ -460,6 +480,94 @@ describe("executeNodeHostCommand", () => {
460480 expect ( warnings . join ( "\n" ) ) . toContain ( "needs a person" ) ;
461481 } ) ;
462482
483+ it . each ( [
484+ {
485+ name : "ask always" ,
486+ nodeSecurity : "full" ,
487+ nodeAsk : "always" ,
488+ } ,
489+ {
490+ name : "deny security" ,
491+ nodeSecurity : "deny" ,
492+ nodeAsk : "off" ,
493+ } ,
494+ ] as const ) (
495+ "requests human approval when node policy has $name floor" ,
496+ async ( { nodeSecurity, nodeAsk } ) => {
497+ const autoReviewer = vi . fn < ExecAutoReviewer > ( async ( ) => ( {
498+ decision : "allow-once" ,
499+ risk : "low" ,
500+ rationale : "test reviewer would allow it" ,
501+ } ) ) ;
502+ resolveExecHostApprovalContextMock . mockReturnValue ( {
503+ approvals : { allowlist : [ ] , file : { version : 1 , agents : { } } } ,
504+ hostSecurity : "allowlist" ,
505+ hostAsk : "on-miss" ,
506+ askFallback : "deny" ,
507+ } ) ;
508+ resolveExecApprovalsFromFileMock . mockReturnValue ( {
509+ allowlist : [ ] ,
510+ file : { version : 1 , agents : { } } ,
511+ agent : {
512+ security : nodeSecurity ,
513+ ask : nodeAsk ,
514+ askFallback : "deny" ,
515+ autoAllowSkills : false ,
516+ } ,
517+ } ) ;
518+ callGatewayToolMock . mockImplementation (
519+ async ( method : string , _options : unknown , params : MockNodeInvokeParams | undefined ) => {
520+ if ( method === "exec.approvals.node.get" ) {
521+ return { file : { version : 1 , agents : { } } } ;
522+ }
523+ if ( method === "exec.approval.resolve" ) {
524+ return { payload : { } } ;
525+ }
526+ if ( method !== "node.invoke" ) {
527+ throw new Error ( `unexpected gateway method: ${ method } ` ) ;
528+ }
529+ if ( params ?. command === "system.run.prepare" ) {
530+ return { payload : { plan : preparedPlan } } ;
531+ }
532+ if ( params ?. command === "system.run" ) {
533+ return {
534+ payload : {
535+ success : true ,
536+ stdout : "ok" ,
537+ stderr : "" ,
538+ exitCode : 0 ,
539+ timedOut : false ,
540+ } ,
541+ } ;
542+ }
543+ throw new Error ( `unexpected node invoke command: ${ String ( params ?. command ) } ` ) ;
544+ } ,
545+ ) ;
546+
547+ const result = await executeNodeHostCommand ( {
548+ command : "bun ./script.ts" ,
549+ workdir : "/tmp/work" ,
550+ env : { } ,
551+ security : "allowlist" ,
552+ ask : "on-miss" ,
553+ autoReview : true ,
554+ autoReviewer,
555+ defaultTimeoutSec : 30 ,
556+ approvalRunningNoticeMs : 0 ,
557+ warnings : [ ] ,
558+ agentId : "requested-agent" ,
559+ sessionKey : "requested-session" ,
560+ } ) ;
561+
562+ expect ( result . details ?. status ) . toBe ( "approval-pending" ) ;
563+ expect ( autoReviewer ) . not . toHaveBeenCalled ( ) ;
564+ expect ( createAndRegisterDefaultExecApprovalRequestMock ) . toHaveBeenCalledTimes ( 1 ) ;
565+ expect (
566+ callGatewayToolMock . mock . calls . some ( ( [ method ] ) => method === "exec.approval.resolve" ) ,
567+ ) . toBe ( false ) ;
568+ } ,
569+ ) ;
570+
463571 it ( "auto-reviews strict inline-eval commands before asking a human" , async ( ) => {
464572 const autoReviewer = vi . fn < ExecAutoReviewer > ( async ( ) => ( {
465573 decision : "allow-once" ,
0 commit comments