@@ -430,6 +430,42 @@ public async Task SystemRun_WithPromptPolicy_AllowsOnce_WhenUserApproves()
430430 }
431431 }
432432
433+ [ Fact ]
434+ public async Task SystemRun_WithPromptPolicy_PromptsOnceForShellWrapper_WhenUserApprovesOnce ( )
435+ {
436+ var tempDir = Path . Combine ( Path . GetTempPath ( ) , $ "test-{ Guid . NewGuid ( ) : N} ") ;
437+ Directory . CreateDirectory ( tempDir ) ;
438+
439+ try
440+ {
441+ var logger = new ExecTestLogger ( ) ;
442+ var policy = new ExecApprovalPolicy ( tempDir , logger ) ;
443+ policy . SetRules ( Array . Empty < ExecApprovalRule > ( ) , ExecApprovalAction . Prompt ) ;
444+ var runner = new FakeCommandRunner ( ) ;
445+ var prompt = new FakePromptHandler ( ExecApprovalPromptDecision . AllowOnce ( ) ) ;
446+ var cap = new SystemCapability ( logger ) ;
447+ cap . SetCommandRunner ( runner ) ;
448+ cap . SetApprovalPolicy ( policy ) ;
449+ cap . SetPromptHandler ( prompt ) ;
450+
451+ var res = await cap . ExecuteAsync ( new NodeInvokeRequest
452+ {
453+ Id = "prompt-wrapper-1" ,
454+ Command = "system.run" ,
455+ Args = Parse ( """{"command":"cmd /c echo hello"}""" )
456+ } ) ;
457+
458+ Assert . True ( res . Ok , res . Error ) ;
459+ Assert . NotNull ( runner . LastRequest ) ;
460+ Assert . Equal ( 1 , prompt . CallCount ) ;
461+ Assert . Empty ( policy . Rules ) ;
462+ }
463+ finally
464+ {
465+ try { Directory . Delete ( tempDir , true ) ; } catch { }
466+ }
467+ }
468+
433469 [ Fact ]
434470 public async Task SystemRun_WithPromptPolicy_PersistsExactAllowRule_WhenUserAlwaysAllows ( )
435471 {
@@ -464,6 +500,88 @@ public async Task SystemRun_WithPromptPolicy_PersistsExactAllowRule_WhenUserAlwa
464500 }
465501 }
466502
503+ [ Fact ]
504+ public async Task SystemRun_WithPromptPolicy_AlwaysAllowWrapperPersistsSingleRule_AndCoversRepeat ( )
505+ {
506+ var tempDir = Path . Combine ( Path . GetTempPath ( ) , $ "test-{ Guid . NewGuid ( ) : N} ") ;
507+ Directory . CreateDirectory ( tempDir ) ;
508+
509+ try
510+ {
511+ var logger = new ExecTestLogger ( ) ;
512+ var policy = new ExecApprovalPolicy ( tempDir , logger ) ;
513+ policy . SetRules ( Array . Empty < ExecApprovalRule > ( ) , ExecApprovalAction . Prompt ) ;
514+ var runner = new FakeCommandRunner ( ) ;
515+ var prompt = new FakePromptHandler ( ExecApprovalPromptDecision . AlwaysAllow ( ) ) ;
516+ var cap = new SystemCapability ( logger ) ;
517+ cap . SetCommandRunner ( runner ) ;
518+ cap . SetApprovalPolicy ( policy ) ;
519+ cap . SetPromptHandler ( prompt ) ;
520+
521+ var req = new NodeInvokeRequest
522+ {
523+ Id = "prompt-wrapper-2" ,
524+ Command = "system.run" ,
525+ Args = Parse ( """{"command":"cmd /c echo repeat"}""" )
526+ } ;
527+
528+ var first = await cap . ExecuteAsync ( req ) ;
529+ Assert . True ( first . Ok , first . Error ) ;
530+ Assert . Equal ( 1 , prompt . CallCount ) ;
531+ Assert . Single ( policy . Rules ) ;
532+ Assert . Equal ( "cmd /c echo repeat" , policy . Rules [ 0 ] . Pattern ) ;
533+
534+ var second = await cap . ExecuteAsync ( req ) ;
535+ Assert . True ( second . Ok , second . Error ) ;
536+ Assert . Equal ( 1 , prompt . CallCount ) ;
537+ }
538+ finally
539+ {
540+ try { Directory . Delete ( tempDir , true ) ; } catch { }
541+ }
542+ }
543+
544+ [ Fact ]
545+ public async Task SystemRun_WithPromptPolicy_StillDeniesExplicitBlockedShellWrapperPayload ( )
546+ {
547+ var tempDir = Path . Combine ( Path . GetTempPath ( ) , $ "test-{ Guid . NewGuid ( ) : N} ") ;
548+ Directory . CreateDirectory ( tempDir ) ;
549+
550+ try
551+ {
552+ var logger = new ExecTestLogger ( ) ;
553+ var policy = new ExecApprovalPolicy ( tempDir , logger ) ;
554+ policy . SetRules (
555+ new [ ]
556+ {
557+ new ExecApprovalRule { Pattern = "del *" , Action = ExecApprovalAction . Deny }
558+ } ,
559+ ExecApprovalAction . Prompt ) ;
560+ var runner = new FakeCommandRunner ( ) ;
561+ var prompt = new FakePromptHandler ( ExecApprovalPromptDecision . AllowOnce ( ) ) ;
562+ var cap = new SystemCapability ( logger ) ;
563+ cap . SetCommandRunner ( runner ) ;
564+ cap . SetApprovalPolicy ( policy ) ;
565+ cap . SetPromptHandler ( prompt ) ;
566+
567+ var res = await cap . ExecuteAsync ( new NodeInvokeRequest
568+ {
569+ Id = "prompt-wrapper-3" ,
570+ Command = "system.run" ,
571+ Args = Parse ( """{"command":"cmd /c del C:\\important.txt"}""" )
572+ } ) ;
573+
574+ Assert . False ( res . Ok ) ;
575+ Assert . Contains ( "denied" , res . Error ! , StringComparison . OrdinalIgnoreCase ) ;
576+ Assert . Null ( runner . LastRequest ) ;
577+ Assert . Equal ( 1 , prompt . CallCount ) ;
578+ }
579+ finally
580+ {
581+ try { Directory . Delete ( tempDir , true ) ; } catch { }
582+ }
583+ }
584+
467585 [ Fact ]
468586 public async Task SystemRun_WithPromptPolicy_Denies_WhenUserDenies ( )
469587 {
@@ -525,10 +643,15 @@ public FakePromptHandler(ExecApprovalPromptDecision decision)
525643 _decision = decision ;
526644 }
527645
646+ public int CallCount { get ; private set ; }
647+
528648 public Task < ExecApprovalPromptDecision > RequestAsync (
529649 ExecApprovalPromptRequest request ,
530- CancellationToken cancellationToken = default ) =>
531- Task . FromResult ( _decision ) ;
650+ CancellationToken cancellationToken = default )
651+ {
652+ CallCount ++ ;
653+ return Task . FromResult ( _decision ) ;
654+ }
532655 }
533656}
534657
0 commit comments