feat(execpolicy): expose matched approval rule metadata (#2971)

This commit is contained in:
Turisla
2026-06-13 01:53:39 +08:00
committed by GitHub
parent 5d9b5f67cb
commit 4e3e12eee7
4 changed files with 65 additions and 10 deletions
+42 -9
View File
@@ -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();
+16 -1
View File
@@ -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<EventFrame>,
},
}
@@ -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");
+3
View File
@@ -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<Box<str>>,
/// Network context if the approval involves network access.
#[serde(skip_serializing_if = "Option::is_none")]
pub network_approval_context: Option<NetworkApprovalContext>,
+4
View File
@@ -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.