From 4e3e12eee74d01a8aadb4966d38e7d395cd768c1 Mon Sep 17 00:00:00 2001 From: Turisla Date: Sat, 13 Jun 2026 01:53:39 +0800 Subject: [PATCH] feat(execpolicy): expose matched approval rule metadata (#2971) --- crates/core/src/lib.rs | 51 +++++++++++++++++++++++++++++++------- crates/hooks/src/lib.rs | 17 ++++++++++++- crates/protocol/src/lib.rs | 3 +++ docs/RUNTIME_API.md | 4 +++ 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 900e16b2..77455e2b 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1137,7 +1137,7 @@ impl Runtime { .await; self.hooks .emit(HookEvent::GenericEventFrame { - frame: error_frame.clone(), + frame: Box::new(error_frame.clone()), }) .await; return Ok(json!({ @@ -1156,6 +1156,7 @@ impl Runtime { let reason = decision.reason().to_string(); let maybe_approval_frame = approval_request_frame( &decision.requirement, + decision.matched_rule.as_deref(), call_id, approval_id.clone(), response_id.clone(), @@ -1173,7 +1174,7 @@ impl Runtime { if let Some(frame) = maybe_approval_frame { self.hooks .emit(HookEvent::GenericEventFrame { - frame: frame.clone(), + frame: Box::new(frame.clone()), }) .await; events.push(event_frame_payload(&frame)); @@ -1197,7 +1198,7 @@ impl Runtime { }; self.hooks .emit(HookEvent::GenericEventFrame { - frame: start_frame.clone(), + frame: Box::new(start_frame.clone()), }) .await; self.hooks @@ -1221,7 +1222,7 @@ impl Runtime { }; self.hooks .emit(HookEvent::GenericEventFrame { - frame: result_frame.clone(), + frame: Box::new(result_frame.clone()), }) .await; self.hooks @@ -1253,7 +1254,7 @@ impl Runtime { }; self.hooks .emit(HookEvent::GenericEventFrame { - frame: error_frame.clone(), + frame: Box::new(error_frame.clone()), }) .await; self.hooks @@ -1299,18 +1300,18 @@ impl Runtime { }; self.hooks .emit(HookEvent::GenericEventFrame { - frame: EventFrame::McpStartupUpdate { + frame: Box::new(EventFrame::McpStartupUpdate { update: codewhale_protocol::McpStartupUpdateEvent { server_name: update.server_name, status, }, - }, + }), }) .await; } self.hooks .emit(HookEvent::GenericEventFrame { - frame: EventFrame::McpStartupComplete { + frame: Box::new(EventFrame::McpStartupComplete { summary: codewhale_protocol::McpStartupCompleteEvent { ready: summary.ready.clone(), failed: summary @@ -1323,7 +1324,7 @@ impl Runtime { .collect(), cancelled: summary.cancelled.clone(), }, - }, + }), }) .await; summary @@ -1578,6 +1579,7 @@ fn to_persisted_source(source: &codewhale_protocol::SessionSource) -> SessionSou fn approval_request_frame( requirement: &ExecApprovalRequirement, + matched_rule: Option<&str>, call_id: String, approval_id: String, turn_id: String, @@ -1620,6 +1622,7 @@ fn approval_request_frame( command, cwd, reason: reason.clone(), + matched_rule: matched_rule.map(|rule| rule.to_string().into_boxed_str()), network_approval_context: None, proposed_execpolicy_amendment: proposed_execpolicy_amendment .as_ref() @@ -1886,6 +1889,36 @@ mod tests { assert_eq!(permission_path_for_call(&call), None); } + #[test] + fn approval_request_frame_includes_matched_rule() { + let requirement = ExecApprovalRequirement::NeedsApproval { + reason: "Typed ask rule 'tool=exec_shell command=cargo test' requires approval." + .to_string(), + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: Vec::new(), + }; + + let frame = approval_request_frame( + &requirement, + Some("tool=exec_shell command=cargo test"), + "call-1".to_string(), + "approval-1".to_string(), + "turn-1".to_string(), + "cargo test --workspace".to_string(), + "/repo".to_string(), + ) + .expect("approval frame"); + + let EventFrame::ExecApprovalRequest { request } = frame else { + panic!("expected exec approval request frame"); + }; + assert_eq!( + request.matched_rule.as_deref(), + Some("tool=exec_shell command=cargo test") + ); + assert_eq!(request.reason, requirement.reason()); + } + #[test] fn enqueue_creates_queued_job_with_zero_progress() { let mut jm = JobManager::default(); diff --git a/crates/hooks/src/lib.rs b/crates/hooks/src/lib.rs index 60ca14f1..35d6b1a1 100644 --- a/crates/hooks/src/lib.rs +++ b/crates/hooks/src/lib.rs @@ -72,7 +72,7 @@ pub enum HookEvent { /// mapping it to a more specific variant. GenericEventFrame { /// The raw event frame to forward. - frame: EventFrame, + frame: Box, }, } @@ -333,6 +333,21 @@ mod tests { assert_eq!(encoded["payload"]["exit_code"], 0); } + #[test] + fn generic_event_frame_serialization_is_unchanged_by_boxing() { + let event = HookEvent::GenericEventFrame { + frame: Box::new(EventFrame::ResponseStart { + response_id: "resp-1".to_string(), + }), + }; + + let encoded = event.to_json(); + + assert_eq!(encoded["type"], "generic_event_frame"); + assert_eq!(encoded["frame"]["event"], "response_start"); + assert_eq!(encoded["frame"]["response_id"], "resp-1"); + } + #[tokio::test] async fn jsonl_sink_creates_parent_dir_and_appends_events() { let root = unique_temp_dir("jsonl_sink"); diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 02f697fd..2e940e19 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -472,6 +472,9 @@ pub struct ExecApprovalRequestEvent { pub cwd: String, /// Human-readable reason why approval is needed. pub reason: String, + /// Policy rule that matched this approval request, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub matched_rule: Option>, /// Network context if the approval involves network access. #[serde(skip_serializing_if = "Option::is_none")] pub network_approval_context: Option, diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 454cb189..59c4d301 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -407,6 +407,10 @@ Common event names: `thread.started`, `thread.forked`, `turn.started`, `item.failed`, `item.interrupted`, `approval.required`, `approval.decided`, `approval.timeout`, `sandbox.denied`, `coherence.state`. +`approval.required` events may include a `matched_rule` string when an +execution-policy rule caused the prompt. This field is explanatory metadata for +clients and does not grant or persist permissions. + ## Security boundary - **Localhost by default**. The server binds to `127.0.0.1` by default.