chio_kernel/
approval_channels.rs1use std::sync::Mutex;
19use std::time::Duration;
20
21use serde::Serialize;
22
23use crate::approval::{ApprovalChannel, ApprovalRequest, ChannelError, ChannelHandle};
24
25#[derive(Debug, Clone, Serialize)]
28pub struct WebhookPayload<'a> {
29 pub event: &'static str,
30 pub approval: &'a ApprovalRequest,
31 pub callback_url: String,
32}
33
34pub struct WebhookChannel {
37 endpoint: String,
38 timeout: Duration,
39 header: Option<(String, String)>,
40}
41
42impl WebhookChannel {
43 pub fn new(endpoint: impl Into<String>) -> Self {
44 Self {
45 endpoint: endpoint.into(),
46 timeout: Duration::from_secs(5),
47 header: None,
48 }
49 }
50
51 #[must_use]
52 pub fn with_timeout(mut self, timeout: Duration) -> Self {
53 self.timeout = timeout;
54 self
55 }
56
57 #[must_use]
61 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
62 self.header = Some((name.into(), value.into()));
63 self
64 }
65}
66
67impl ApprovalChannel for WebhookChannel {
68 fn name(&self) -> &str {
69 "webhook"
70 }
71
72 fn dispatch(&self, request: &ApprovalRequest) -> Result<ChannelHandle, ChannelError> {
73 let payload = WebhookPayload {
74 event: "approval_requested",
75 approval: request,
76 callback_url: format!("/approvals/{}/respond", request.approval_id),
77 };
78 let body = serde_json::to_string(&payload)
79 .map_err(|e| ChannelError::Config(format!("cannot serialize payload: {e}")))?;
80
81 let agent = ureq::AgentBuilder::new().timeout(self.timeout).build();
82 let mut req = agent
83 .post(&self.endpoint)
84 .set("content-type", "application/json");
85 if let Some((name, value)) = &self.header {
86 req = req.set(name, value);
87 }
88
89 let response = req.send_string(&body);
90 match response {
91 Ok(resp) => {
92 let channel_ref = resp
93 .header("x-request-id")
94 .map(ToOwned::to_owned)
95 .unwrap_or_else(|| request.approval_id.clone());
96 Ok(ChannelHandle {
97 channel: "webhook".into(),
98 channel_ref,
99 action_url: Some(format!("/approvals/{}", request.approval_id)),
100 })
101 }
102 Err(ureq::Error::Status(status, resp)) => {
103 let body = resp.into_string().unwrap_or_default();
104 Err(ChannelError::Remote { status, body })
105 }
106 Err(ureq::Error::Transport(err)) => Err(ChannelError::Transport(err.to_string())),
107 }
108 }
109}
110
111#[derive(Default)]
116pub struct RecordingChannel {
117 captured: Mutex<Vec<ApprovalRequest>>,
118}
119
120impl RecordingChannel {
121 pub fn new() -> Self {
122 Self::default()
123 }
124
125 pub fn captured(&self) -> Vec<ApprovalRequest> {
127 self.captured
128 .lock()
129 .map(|guard| guard.clone())
130 .unwrap_or_default()
131 }
132
133 pub fn len(&self) -> usize {
135 self.captured.lock().map(|guard| guard.len()).unwrap_or(0)
136 }
137
138 pub fn is_empty(&self) -> bool {
139 self.len() == 0
140 }
141}
142
143impl ApprovalChannel for RecordingChannel {
144 fn name(&self) -> &str {
145 "recording"
146 }
147
148 fn dispatch(&self, request: &ApprovalRequest) -> Result<ChannelHandle, ChannelError> {
149 let mut guard = self
150 .captured
151 .lock()
152 .map_err(|_| ChannelError::Transport("recording channel poisoned".into()))?;
153 guard.push(request.clone());
154 Ok(ChannelHandle {
155 channel: "recording".into(),
156 channel_ref: format!("rec-{}", request.approval_id),
157 action_url: None,
158 })
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use chio_core::crypto::Keypair;
166
167 #[test]
168 fn recording_channel_captures_dispatches() {
169 let subject = Keypair::generate();
170 let approver = Keypair::generate();
171 let channel = RecordingChannel::new();
172 let req = ApprovalRequest {
173 approval_id: "a-1".into(),
174 policy_id: "p-1".into(),
175 subject_id: "agent-1".into(),
176 capability_id: "c-1".into(),
177 subject_public_key: Some(subject.public_key()),
178 tool_server: "srv".into(),
179 tool_name: "tool".into(),
180 action: "invoke".into(),
181 parameter_hash: "h".into(),
182 expires_at: 10,
183 callback_hint: None,
184 created_at: 0,
185 summary: String::new(),
186 governed_intent: None,
187 trusted_approvers: vec![approver.public_key()],
188 triggered_by: vec![],
189 };
190 let handle = channel.dispatch(&req).unwrap();
191 assert_eq!(handle.channel, "recording");
192 assert_eq!(channel.len(), 1);
193 assert_eq!(channel.captured()[0].approval_id, "a-1");
194 }
195}