@@ -1232,30 +1232,48 @@ impl AgentLoop {
12321232
12331233 ( denial_msg, 1 , true , None )
12341234 }
1235- // Both Allow and Ask go through HITL confirmation check.
1236- // Permission Allow means "not denied by policy", but HITL
1237- // confirmation is an independent safety layer that still applies
1238- // for mutating operations.
1239- PermissionDecision :: Allow | PermissionDecision :: Ask => {
1240- let decision_str = if permission_decision == PermissionDecision :: Allow {
1241- "allow"
1242- } else {
1243- "ask"
1244- } ;
1235+ PermissionDecision :: Allow => {
1236+ tracing:: info!(
1237+ tool_name = tool_call. name. as_str( ) ,
1238+ permission = "allow" ,
1239+ "Tool permission: allow"
1240+ ) ;
1241+ tool_span. record ( "a3s.tool.permission" , "allow" ) ;
1242+
1243+ // Permission explicitly allows — execute directly, no HITL
1244+ let stream_ctx = self . streaming_tool_context (
1245+ & event_tx,
1246+ & tool_call. id ,
1247+ & tool_call. name ,
1248+ ) ;
1249+ let result = self
1250+ . tool_executor
1251+ . execute_with_context (
1252+ & tool_call. name ,
1253+ & tool_call. args ,
1254+ & stream_ctx,
1255+ )
1256+ . await ;
1257+
1258+ match result {
1259+ Ok ( r) => ( r. output , r. exit_code , r. exit_code != 0 , r. metadata ) ,
1260+ Err ( e) => {
1261+ ( format ! ( "Tool execution error: {}" , e) , 1 , true , None )
1262+ }
1263+ }
1264+ }
1265+ PermissionDecision :: Ask => {
12451266 tracing:: info!(
12461267 tool_name = tool_call. name. as_str( ) ,
1247- permission = decision_str,
1248- "Tool permission: {}" ,
1249- decision_str
1268+ permission = "ask" ,
1269+ "Tool permission: ask"
12501270 ) ;
1251- tool_span. record ( "a3s.tool.permission" , decision_str ) ;
1271+ tool_span. record ( "a3s.tool.permission" , "ask" ) ;
12521272
1253- // HITL: Check if this tool requires confirmation
1273+ // Permission says Ask — delegate to HITL confirmation manager
12541274 if let Some ( cm) = & self . config . confirmation_manager {
1255- // Check if this tool actually requires confirmation
1256- // (considers HITL enabled, YOLO lanes, auto-approve lists, etc.)
1275+ // Check YOLO lanes: if the tool's lane is in YOLO mode, skip confirmation
12571276 if !cm. requires_confirmation ( & tool_call. name ) . await {
1258- // No confirmation needed - execute directly
12591277 let stream_ctx = self . streaming_tool_context (
12601278 & event_tx,
12611279 & tool_call. id ,
@@ -1341,9 +1359,7 @@ impl AgentLoop {
13411359
13421360 match confirmation_result {
13431361 Ok ( Ok ( response) ) => {
1344- // Got confirmation response
13451362 if response. approved {
1346- // Approved: execute the tool
13471363 let stream_ctx = self . streaming_tool_context (
13481364 & event_tx,
13491365 & tool_call. id ,
@@ -1373,7 +1389,6 @@ impl AgentLoop {
13731389 ) ,
13741390 }
13751391 } else {
1376- // Rejected by user
13771392 let rejection_msg = format ! (
13781393 "Tool '{}' execution was rejected by user. Reason: {}" ,
13791394 tool_call. name,
@@ -1383,15 +1398,13 @@ impl AgentLoop {
13831398 }
13841399 }
13851400 Ok ( Err ( _) ) => {
1386- // Channel closed (confirmation manager dropped)
13871401 let msg = format ! (
13881402 "Tool '{}' confirmation failed: confirmation channel closed" ,
13891403 tool_call. name
13901404 ) ;
13911405 ( msg, 1 , true , None )
13921406 }
13931407 Err ( _) => {
1394- // Timeout - check timeout action
13951408 cm. check_timeouts ( ) . await ;
13961409
13971410 match timeout_action {
@@ -1403,7 +1416,6 @@ impl AgentLoop {
14031416 ( msg, 1 , true , None )
14041417 }
14051418 crate :: hitl:: TimeoutAction :: AutoApprove => {
1406- // Auto-approve on timeout: execute the tool
14071419 let stream_ctx = self . streaming_tool_context (
14081420 & event_tx,
14091421 & tool_call. id ,
@@ -1437,44 +1449,20 @@ impl AgentLoop {
14371449 }
14381450 }
14391451 } else {
1440- // No confirmation manager configured
1441- if permission_decision == PermissionDecision :: Allow {
1442- // Permission explicitly allows and no CM - execute directly
1443- let stream_ctx = self . streaming_tool_context (
1444- & event_tx,
1445- & tool_call. id ,
1446- & tool_call. name ,
1447- ) ;
1448- let result = self
1449- . tool_executor
1450- . execute_with_context (
1451- & tool_call. name ,
1452- & tool_call. args ,
1453- & stream_ctx,
1454- )
1455- . await ;
1456-
1457- match result {
1458- Ok ( r) => ( r. output , r. exit_code , r. exit_code != 0 , r. metadata ) ,
1459- Err ( e) => {
1460- ( format ! ( "Tool execution error: {}" , e) , 1 , true , None )
1461- }
1462- }
1463- } else {
1464- // Ask without confirmation manager - safe deny
1465- let msg = format ! (
1466- "Tool '{}' requires confirmation but no HITL confirmation manager is configured. \
1467- Configure a confirmation policy to enable tool execution.",
1468- tool_call. name
1469- ) ;
1470- tracing:: warn!(
1471- tool_name = tool_call. name. as_str( ) ,
1472- "Tool requires confirmation but no HITL manager configured"
1473- ) ;
1474- ( msg, 1 , true , None )
1475- }
1452+ // Ask without confirmation manager — safe deny
1453+ let msg = format ! (
1454+ "Tool '{}' requires confirmation but no HITL confirmation manager is configured. \
1455+ Configure a confirmation policy to enable tool execution.",
1456+ tool_call. name
1457+ ) ;
1458+ tracing:: warn!(
1459+ tool_name = tool_call. name. as_str( ) ,
1460+ "Tool requires confirmation but no HITL manager configured"
1461+ ) ;
1462+ ( msg, 1 , true , None )
14761463 }
14771464 }
1465+
14781466 } ;
14791467
14801468 // Auto-load skill if metadata signals it
@@ -2771,9 +2759,9 @@ mod tests {
27712759 }
27722760
27732761 #[ tokio:: test]
2774- async fn test_agent_hitl_with_permission_allow_still_checks_hitl ( ) {
2775- // Even when permission is Allow, HITL confirmation should still be
2776- // triggered for mutating tools (defense-in-depth) .
2762+ async fn test_agent_hitl_with_permission_allow_skips_hitl ( ) {
2763+ // When permission is Allow, HITL confirmation is skipped entirely.
2764+ // PermissionPolicy is the declarative rule engine; Allow = execute directly .
27772765 use crate :: hitl:: { ConfirmationManager , ConfirmationPolicy } ;
27782766 use tokio:: sync:: broadcast;
27792767
@@ -2806,29 +2794,22 @@ mod tests {
28062794 ..Default :: default ( )
28072795 } ;
28082796
2809- // Spawn a task to approve the confirmation
2810- let cm_clone = confirmation_manager. clone ( ) ;
2811- tokio:: spawn ( async move {
2812- tokio:: time:: sleep ( std:: time:: Duration :: from_millis ( 50 ) ) . await ;
2813- cm_clone. confirm ( "tool-1" , true , None ) . await . ok ( ) ;
2814- } ) ;
2815-
28162797 let agent = AgentLoop :: new ( mock_client, tool_executor, test_tool_context ( ) , config) ;
28172798 let result = agent. execute ( & [ ] , "Echo" , None ) . await . unwrap ( ) ;
28182799
2819- // Should execute after HITL approval
2800+ // Should execute directly without HITL (permission Allow skips confirmation)
28202801 assert_eq ! ( result. text, "Allowed!" ) ;
28212802
2822- // Should have ConfirmationRequired event (Allow no longer bypasses HITL)
2803+ // Should NOT have ConfirmationRequired event (Allow bypasses HITL)
28232804 let mut found_confirmation = false ;
28242805 while let Ok ( event) = event_rx. try_recv ( ) {
28252806 if matches ! ( event, AgentEvent :: ConfirmationRequired { .. } ) {
28262807 found_confirmation = true ;
28272808 }
28282809 }
28292810 assert ! (
2830- found_confirmation,
2831- "HITL should be triggered even when permission is Allow (defense-in-depth) "
2811+ ! found_confirmation,
2812+ "Permission Allow should skip HITL confirmation "
28322813 ) ;
28332814 }
28342815
0 commit comments