Skip to main content

rightkit_agent_engine_protocol/
approvals.rs

1//! Structured approval request contracts ported from Codex's protocol.
2//!
3//! DIRECT_PORT_RUST (atom `S:I4`): ported from codex `codex-rs/protocol/src/
4//! protocol.rs:963` and `approvals.rs` (`ExecApprovalRequestEvent`,
5//! `ReviewDecision`, `ExecPolicyAmendment`, network/guardian approval
6//! contexts, elicitation requests). San's three-way `PermissionDecision`
7//! (`Permit`/`Reject`/`Prompt`) and the `PermissionGateRequest`/
8//! `PermissionGateResponse` pair are the same-family the host app seam these
9//! Codex types are wired through — see `docs/canon/daemon-master-canon-
10//! atoms.md` for the exact donor revision and NOTICE entry. No behavioral
11//! changes were made to the ported shapes beyond the module path
12//! (`codex_protocol` -> `rightkit_agent_engine_protocol::approvals`).
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17/// San's three-way permission result: permit, reject, or human prompt.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum PermissionDecision {
21    Permit,
22    Reject,
23    Prompt,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct PermissionDecisionResult {
29    pub decision: PermissionDecision,
30    pub reason: String,
31    pub tool_name: String,
32    pub description: String,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub request_id: Option<String>,
35    #[serde(default)]
36    pub reviewable: bool,
37}
38
39/// Structured pending request sent to an operator surface. `tool_call_id`
40/// binds response to one concrete invocation; `input` remains untrusted data.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct PermissionGateRequest {
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub request_id: Option<String>,
46    pub tool_call_id: String,
47    pub tool_name: String,
48    pub description: String,
49    pub input: Value,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub struct PermissionGateResponse {
55    pub allow: bool,
56    pub reason: String,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(transparent)]
61pub struct ExecPolicyAmendment {
62    pub command: Vec<String>,
63}
64impl ExecPolicyAmendment {
65    pub fn new(command: Vec<String>) -> Self {
66        Self { command }
67    }
68    pub fn command(&self) -> &[String] {
69        &self.command
70    }
71}
72impl From<Vec<String>> for ExecPolicyAmendment {
73    fn from(command: Vec<String>) -> Self {
74        Self { command }
75    }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum NetworkApprovalProtocol {
81    Http,
82    Https,
83    Socks5Tcp,
84    Socks5Udp,
85}
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct NetworkApprovalContext {
88    pub host: String,
89    pub protocol: NetworkApprovalProtocol,
90}
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum NetworkPolicyRuleAction {
94    Allow,
95    Deny,
96}
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct NetworkPolicyAmendment {
99    pub host: String,
100    pub action: NetworkPolicyRuleAction,
101}
102
103/// Exact effect context bound to a pending approval callback.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct PendingApprovalContext {
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub request_id: Option<String>,
108    pub tool_name: String,
109    #[serde(default)]
110    pub command: Option<String>,
111    #[serde(default)]
112    pub network: Option<NetworkApprovalContext>,
113    /// EFF-002: digest of the final post-transform payload shown to the
114    /// approver. Session grants bind to it. Optional for back-compat.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub payload_digest: Option<String>,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "lowercase")]
121pub enum GuardianRiskLevel {
122    Low,
123    Medium,
124    High,
125    Critical,
126}
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum GuardianUserAuthorization {
130    Unknown,
131    Low,
132    Medium,
133    High,
134}
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "lowercase")]
137pub enum GuardianAssessmentOutcome {
138    Allow,
139    Deny,
140}
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum GuardianAssessmentStatus {
144    InProgress,
145    Approved,
146    Denied,
147    TimedOut,
148    Aborted,
149}
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum GuardianAssessmentDecisionSource {
153    Agent,
154}
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum GuardianCommandSource {
158    Shell,
159    UnifiedExec,
160}
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(tag = "type", rename_all = "snake_case")]
163pub enum GuardianAssessmentAction {
164    Command {
165        source: GuardianCommandSource,
166        command: String,
167        cwd: String,
168    },
169    Execve {
170        source: GuardianCommandSource,
171        program: String,
172        argv: Vec<String>,
173        cwd: String,
174    },
175    WriteStdin {
176        approval_id: String,
177        process_id: String,
178        stdin: String,
179        cwd: String,
180    },
181    ApplyPatch {
182        cwd: String,
183        files: Vec<String>,
184    },
185    NetworkAccess {
186        target: String,
187        host: String,
188        protocol: NetworkApprovalProtocol,
189        port: u16,
190    },
191    McpToolCall {
192        server: String,
193        tool_name: String,
194        connector_id: Option<String>,
195        connector_name: Option<String>,
196        tool_title: Option<String>,
197    },
198    RequestPermissions {
199        reason: Option<String>,
200        permissions: Value,
201    },
202}
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204pub struct GuardianAssessmentEvent {
205    pub id: String,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub target_item_id: Option<String>,
208    #[serde(default)]
209    pub turn_id: String,
210    #[serde(default)]
211    pub started_at_ms: i64,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub completed_at_ms: Option<i64>,
214    pub status: GuardianAssessmentStatus,
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub risk_level: Option<GuardianRiskLevel>,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub user_authorization: Option<GuardianUserAuthorization>,
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub rationale: Option<String>,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub decision_source: Option<GuardianAssessmentDecisionSource>,
223    pub action: GuardianAssessmentAction,
224}
225
226#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub enum ExecApprovalKind {
229    #[default]
230    Command,
231    WriteStdin,
232}
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum ReviewDecision {
236    Approved,
237    ApprovedForSession,
238    ApprovedExecpolicyAmendment {
239        proposed_execpolicy_amendment: ExecPolicyAmendment,
240    },
241    NetworkPolicyAmendment {
242        network_policy_amendment: NetworkPolicyAmendment,
243    },
244    Abort,
245}
246impl ReviewDecision {
247    pub const fn is_approved(&self) -> bool {
248        matches!(
249            self,
250            Self::Approved
251                | Self::ApprovedForSession
252                | Self::ApprovedExecpolicyAmendment { .. }
253                | Self::NetworkPolicyAmendment { .. }
254        )
255    }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259pub struct ExecApprovalRequestEvent {
260    #[serde(default)]
261    pub kind: ExecApprovalKind,
262    pub call_id: String,
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub approval_id: Option<String>,
265    #[serde(default)]
266    pub turn_id: String,
267    pub started_at_ms: i64,
268    pub command: Vec<String>,
269    pub cwd: String,
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub reason: Option<String>,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub network_approval_context: Option<NetworkApprovalContext>,
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub proposed_network_policy_amendments: Option<Vec<NetworkPolicyAmendment>>,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub available_decisions: Option<Vec<ReviewDecision>>,
280}
281impl ExecApprovalRequestEvent {
282    pub fn effective_approval_id(&self) -> String {
283        self.approval_id
284            .clone()
285            .unwrap_or_else(|| self.call_id.clone())
286    }
287    pub fn effective_available_decisions(&self) -> Vec<ReviewDecision> {
288        self.available_decisions.clone().unwrap_or_else(|| {
289            let mut out = vec![ReviewDecision::Approved];
290            if let Some(prefix) = &self.proposed_execpolicy_amendment {
291                out.push(ReviewDecision::ApprovedExecpolicyAmendment {
292                    proposed_execpolicy_amendment: prefix.clone(),
293                });
294            }
295            out.push(ReviewDecision::Abort);
296            out
297        })
298    }
299}
300
301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
302#[serde(
303    tag = "mode",
304    rename_all = "snake_case",
305    rename_all_fields = "camelCase"
306)]
307pub enum ElicitationRequest {
308    Form {
309        message: String,
310        requested_schema: Value,
311    },
312    Url {
313        message: String,
314        url: String,
315        elicitation_id: String,
316    },
317}
318#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
319#[serde(rename_all = "camelCase")]
320pub struct ElicitationRequestEvent {
321    pub turn_id: Option<String>,
322    pub server_name: String,
323    pub id: String,
324    pub request: ElicitationRequest,
325}
326#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
327#[serde(rename_all = "lowercase")]
328pub enum ElicitationAction {
329    Accept,
330    Decline,
331    Cancel,
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    #[test]
338    fn exec_policy_amendment_is_transparent_array() {
339        let amendment = ExecPolicyAmendment::new(vec!["cargo".into(), "check".into()]);
340        assert_eq!(
341            serde_json::to_string(&amendment).unwrap(),
342            r#"["cargo","check"]"#
343        );
344        assert_eq!(
345            amendment.command(),
346            &["cargo".to_string(), "check".to_string()]
347        );
348    }
349    #[test]
350    fn approval_decisions_keep_codex_wire_tags() {
351        let value = serde_json::to_value(ReviewDecision::ApprovedForSession).unwrap();
352        assert_eq!(value, serde_json::json!("approved_for_session"));
353        let value = serde_json::to_value(NetworkApprovalProtocol::Https).unwrap();
354        assert_eq!(value, serde_json::json!("https"));
355    }
356    #[test]
357    fn approval_id_falls_back_to_call_id() {
358        let request = ExecApprovalRequestEvent {
359            kind: ExecApprovalKind::Command,
360            call_id: "call-1".into(),
361            approval_id: None,
362            turn_id: String::new(),
363            started_at_ms: 0,
364            command: vec!["echo".into()],
365            cwd: "/tmp".into(),
366            reason: None,
367            network_approval_context: None,
368            proposed_execpolicy_amendment: None,
369            proposed_network_policy_amendments: None,
370            available_decisions: None,
371        };
372        assert_eq!(request.effective_approval_id(), "call-1");
373    }
374    #[test]
375    fn san_permission_gate_contract_round_trips() {
376        let request = PermissionGateRequest {
377            request_id: Some("req-1".into()),
378            tool_call_id: "call-1".into(),
379            tool_name: "Bash".into(),
380            description: "run command".into(),
381            input: serde_json::json!({"command":"git status"}),
382        };
383        let value = serde_json::to_value(&request).unwrap();
384        assert_eq!(value["toolCallId"], "call-1");
385        assert_eq!(
386            serde_json::from_value::<PermissionDecision>(serde_json::json!("prompt")).unwrap(),
387            PermissionDecision::Prompt
388        );
389    }
390}