Skip to main content

codex_cli_sdk/
permissions.rs

1use crate::types::events::{ApprovalRequestEvent, PatchApprovalRequestEvent};
2use serde::{Deserialize, Serialize};
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6
7/// Decision for an approval request.
8#[derive(Debug, Clone, Default, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum ApprovalDecision {
11    /// Approve the action.
12    Approved,
13    /// Approve this and all identical future instances in this session.
14    ApprovedForSession,
15    /// Deny — agent should try an alternative approach.
16    #[default]
17    Denied,
18    /// Abort — stop the session until next user command.
19    Abort,
20}
21
22/// Wrapper around [`ApprovalDecision`] with forward-compatible fields.
23#[derive(Debug, Clone)]
24pub struct ApprovalOutcome {
25    /// The approval decision.
26    pub decision: ApprovalDecision,
27
28    /// Reserved: command override.
29    ///
30    /// **This field is a no-op.** It is gated behind the `unstable` feature flag
31    /// because the Codex CLI protocol does not yet support input mutation.
32    /// Enable the `unstable` feature only if you are experimenting with future
33    /// protocol changes.
34    #[cfg(feature = "unstable")]
35    pub updated_command: Option<String>,
36}
37
38impl ApprovalOutcome {
39    /// Create a new outcome with no command override.
40    pub fn new(decision: ApprovalDecision) -> Self {
41        Self {
42            decision,
43            #[cfg(feature = "unstable")]
44            updated_command: None,
45        }
46    }
47
48    /// Set a command override (forward-compat, currently no-op).
49    ///
50    /// Requires the `unstable` feature. The value is not forwarded to the CLI
51    /// until the CLI protocol supports input mutation.
52    #[cfg(feature = "unstable")]
53    pub fn with_updated_command(mut self, command: impl Into<String>) -> Self {
54        self.updated_command = Some(command.into());
55        self
56    }
57}
58
59impl From<ApprovalDecision> for ApprovalOutcome {
60    fn from(decision: ApprovalDecision) -> Self {
61        Self {
62            decision,
63            #[cfg(feature = "unstable")]
64            updated_command: None,
65        }
66    }
67}
68
69/// Context provided to the approval callback.
70#[derive(Debug, Clone)]
71pub struct ApprovalContext {
72    /// The approval request event from the CLI.
73    pub request: ApprovalRequestEvent,
74    /// The thread ID this approval belongs to.
75    pub thread_id: Option<String>,
76}
77
78/// Callback type for handling approval requests.
79///
80/// Returns [`ApprovalOutcome`] which wraps a decision and an optional command override.
81/// For simple cases, return `ApprovalDecision::Approved.into()`.
82pub type ApprovalCallback = Arc<
83    dyn Fn(ApprovalContext) -> Pin<Box<dyn Future<Output = ApprovalOutcome> + Send>> + Send + Sync,
84>;
85
86/// Context provided to the patch approval callback.
87#[derive(Debug, Clone)]
88pub struct PatchApprovalContext {
89    /// The patch approval request event from the CLI.
90    pub request: PatchApprovalRequestEvent,
91    /// The thread ID this approval belongs to.
92    pub thread_id: Option<String>,
93}
94
95/// Callback type for handling patch approval requests.
96///
97/// Returns [`ApprovalOutcome`] for forward-compatibility (the `updated_command`
98/// field is ignored for patch approvals).
99pub type PatchApprovalCallback = Arc<
100    dyn Fn(PatchApprovalContext) -> Pin<Box<dyn Future<Output = ApprovalOutcome> + Send>>
101        + Send
102        + Sync,
103>;
104
105/// The JSON message written to stdin to respond to an approval request.
106#[derive(Debug, Serialize)]
107pub(crate) struct ApprovalResponse {
108    pub op: String,
109    pub id: String,
110    pub decision: ApprovalDecision,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub turn_id: Option<String>,
113}
114
115impl ApprovalResponse {
116    pub fn new(request_id: String, decision: ApprovalDecision) -> Self {
117        Self {
118            op: "ExecApproval".to_string(),
119            id: request_id,
120            decision,
121            turn_id: None,
122        }
123    }
124}
125
126/// The JSON message for patch approval responses.
127#[derive(Debug, Serialize)]
128pub(crate) struct PatchApprovalResponse {
129    pub op: String,
130    pub id: String,
131    pub decision: ApprovalDecision,
132}
133
134impl PatchApprovalResponse {
135    pub fn new(request_id: String, decision: ApprovalDecision) -> Self {
136        Self {
137            op: "PatchApproval".to_string(),
138            id: request_id,
139            decision,
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn approval_response_serializes() {
150        let response = ApprovalResponse::new("req-1".into(), ApprovalDecision::Approved);
151        let json = serde_json::to_string(&response).unwrap();
152        assert!(json.contains("ExecApproval"));
153        assert!(json.contains("approved"));
154        assert!(!json.contains("turn_id")); // None should be skipped
155    }
156
157    #[test]
158    fn approval_decision_round_trip() {
159        let decision = ApprovalDecision::ApprovedForSession;
160        let json = serde_json::to_string(&decision).unwrap();
161        let parsed: ApprovalDecision = serde_json::from_str(&json).unwrap();
162        assert!(matches!(parsed, ApprovalDecision::ApprovedForSession));
163    }
164
165    #[test]
166    fn patch_approval_response_serializes() {
167        let response = PatchApprovalResponse::new("req-2".into(), ApprovalDecision::Denied);
168        let json = serde_json::to_string(&response).unwrap();
169        assert!(json.contains("PatchApproval"));
170        assert!(json.contains("denied"));
171    }
172
173    #[test]
174    fn default_decision_is_denied() {
175        assert!(matches!(
176            ApprovalDecision::default(),
177            ApprovalDecision::Denied
178        ));
179    }
180
181    #[test]
182    fn approval_outcome_from_decision() {
183        let outcome: ApprovalOutcome = ApprovalDecision::Approved.into();
184        assert!(matches!(outcome.decision, ApprovalDecision::Approved));
185    }
186
187    #[cfg(feature = "unstable")]
188    #[test]
189    fn approval_outcome_with_updated_command() {
190        let outcome = ApprovalOutcome::new(ApprovalDecision::Approved)
191            .with_updated_command("safe-command --flag");
192        assert!(matches!(outcome.decision, ApprovalDecision::Approved));
193        assert_eq!(
194            outcome.updated_command,
195            Some("safe-command --flag".to_string())
196        );
197    }
198
199    #[cfg(feature = "unstable")]
200    #[test]
201    fn approval_outcome_wire_compatible() {
202        // The ApprovalResponse only uses the decision — updated_command is not serialized.
203        let outcome =
204            ApprovalOutcome::new(ApprovalDecision::Approved).with_updated_command("overridden");
205        let response = ApprovalResponse::new("req-1".into(), outcome.decision);
206        let json = serde_json::to_string(&response).unwrap();
207        assert!(!json.contains("overridden"));
208        assert!(json.contains("approved"));
209    }
210}