feat(execpolicy): expose matched approval rule metadata
This commit is contained in:
+42
-9
@@ -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
@@ -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");
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user