Skip to content

Commit 2d4550e

Browse files
ZhiXiao-Linclaude
andcommitted
refactor: merge HITL/Permissions overlap — remove ToolCategory, simplify ConfirmationPolicy, separate Allow/Ask paths in agent loop
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 291928f commit 2d4550e

3 files changed

Lines changed: 56 additions & 383 deletions

File tree

core/src/agent.rs

Lines changed: 55 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -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

core/src/session.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2603,8 +2603,7 @@ mod tests {
26032603

26042604
// Set new policy
26052605
let new_policy = ConfirmationPolicy::enabled()
2606-
.with_yolo_lanes([SessionLane::Query, SessionLane::Execute])
2607-
.with_auto_approve_tools(["bash".to_string()]);
2606+
.with_yolo_lanes([SessionLane::Query, SessionLane::Execute]);
26082607

26092608
let result = manager
26102609
.set_confirmation_policy("session-1", new_policy)
@@ -2613,7 +2612,6 @@ mod tests {
26132612
assert!(result.enabled);
26142613
assert!(result.yolo_lanes.contains(&SessionLane::Query));
26152614
assert!(result.yolo_lanes.contains(&SessionLane::Execute));
2616-
assert!(result.auto_approve_tools.contains("bash"));
26172615

26182616
// Verify policy was persisted
26192617
let policy = manager.get_confirmation_policy("session-1").await.unwrap();

0 commit comments

Comments
 (0)