Skip to main content

chio_kernel/
approval_channels.rs

1//! Phase 3.6 approval channels.
2//!
3//! A channel is a delivery mechanism that gets an `ApprovalRequest` in
4//! front of a human. The kernel treats channels as fire-and-forget
5//! sinks: on failure the request stays in the approval store and can
6//! still be fetched via `GET /approvals/pending`, matching the
7//! fail-closed rule in the HITL protocol.
8//!
9//! Two channels ship in this phase:
10//!
11//! 1. `WebhookChannel` -- blocking HTTP POST to a configured URL.
12//!    Production integrations wire this into their own dashboard or
13//!    ticketing system.
14//! 2. `RecordingChannel` -- captures every dispatch in an in-memory
15//!    ring so tests (and host adapters) can assert that a dispatch
16//!    fired without standing up an HTTP listener.
17
18use std::sync::Mutex;
19use std::time::Duration;
20
21use serde::Serialize;
22
23use crate::approval::{ApprovalChannel, ApprovalRequest, ChannelError, ChannelHandle};
24
25/// Payload shape delivered by `WebhookChannel`. Stable so receivers can
26/// parse it without pulling in kernel types.
27#[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
34/// Blocking HTTP webhook channel. Uses `ureq`, which is already in the
35/// kernel's dependency tree.
36pub 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    /// Attach a static header, typically an HMAC or bearer token. Only
58    /// one is supported because every real caller uses a single auth
59    /// secret; adding a second would invite operator confusion.
60    #[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/// In-memory channel that captures every dispatched `ApprovalRequest`
112/// for later inspection. Useful in tests and for the `api-poll`
113/// dispatch mode (where the "channel" is really the local store
114/// itself).
115#[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    /// Snapshot of every dispatched request.
126    pub fn captured(&self) -> Vec<ApprovalRequest> {
127        self.captured
128            .lock()
129            .map(|guard| guard.clone())
130            .unwrap_or_default()
131    }
132
133    /// Number of requests dispatched so far.
134    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}