Skip to main content

codex_protocol/
approvals.rs

1use crate::mcp::RequestId;
2use crate::models::AdditionalPermissionProfile;
3use crate::models::PermissionProfile;
4use crate::parse_command::ParsedCommand;
5use crate::protocol::FileChange;
6use crate::protocol::ReviewDecision;
7use crate::request_permissions::RequestPermissionProfile;
8use codex_utils_absolute_path::AbsolutePathBuf;
9use schemars::JsonSchema;
10use serde::Deserialize;
11use serde::Serialize;
12use serde_json::Value as JsonValue;
13use std::collections::HashMap;
14use std::path::PathBuf;
15use ts_rs::TS;
16
17/// Fully resolved permissions for rerunning an intercepted child process.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ResolvedPermissionProfile {
20    pub permission_profile: PermissionProfile,
21}
22
23#[allow(clippy::large_enum_variant)]
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum EscalationPermissions {
26    /// Permissions to merge with the active turn permissions.
27    AdditionalPermissionProfile(AdditionalPermissionProfile),
28    /// Fully resolved permissions that should replace the active turn permissions.
29    ResolvedPermissionProfile(ResolvedPermissionProfile),
30}
31
32/// Proposed execpolicy change to allow commands starting with this prefix.
33///
34/// The `command` tokens form the prefix that would be added as an execpolicy
35/// `prefix_rule(..., decision="allow")`, letting the agent bypass approval for
36/// commands that start with this token sequence.
37#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
38#[serde(transparent)]
39#[ts(type = "Array<string>")]
40pub struct ExecPolicyAmendment {
41    pub command: Vec<String>,
42}
43
44impl ExecPolicyAmendment {
45    pub fn new(command: Vec<String>) -> Self {
46        Self { command }
47    }
48
49    pub fn command(&self) -> &[String] {
50        &self.command
51    }
52}
53
54impl From<Vec<String>> for ExecPolicyAmendment {
55    fn from(command: Vec<String>) -> Self {
56        Self { command }
57    }
58}
59
60#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
61#[serde(rename_all = "snake_case")]
62pub enum NetworkApprovalProtocol {
63    // TODO(viyatb): Add websocket protocol variants when managed proxy policy
64    // decisions expose websocket traffic as a distinct approval context.
65    Http,
66    #[serde(alias = "https_connect", alias = "http-connect")]
67    Https,
68    Socks5Tcp,
69    Socks5Udp,
70}
71
72#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
73pub struct NetworkApprovalContext {
74    pub host: String,
75    pub protocol: NetworkApprovalProtocol,
76}
77
78#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
79#[serde(rename_all = "snake_case")]
80pub enum NetworkPolicyRuleAction {
81    Allow,
82    Deny,
83}
84
85#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
86#[serde(rename_all = "lowercase")]
87pub enum GuardianRiskLevel {
88    Low,
89    Medium,
90    High,
91    Critical,
92}
93
94#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
95#[serde(rename_all = "lowercase")]
96pub enum GuardianUserAuthorization {
97    Unknown,
98    Low,
99    Medium,
100    High,
101}
102
103/// Final allow/deny outcome returned by the guardian reviewer.
104#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
105#[serde(rename_all = "lowercase")]
106pub enum GuardianAssessmentOutcome {
107    Allow,
108    Deny,
109}
110
111#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
112#[serde(rename_all = "snake_case")]
113pub enum GuardianAssessmentStatus {
114    InProgress,
115    Approved,
116    Denied,
117    TimedOut,
118    Aborted,
119}
120
121#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
122#[serde(rename_all = "snake_case")]
123pub enum GuardianAssessmentDecisionSource {
124    Agent,
125}
126
127#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
128#[serde(rename_all = "snake_case")]
129pub enum GuardianCommandSource {
130    Shell,
131    UnifiedExec,
132}
133
134#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
135#[serde(tag = "type", rename_all = "snake_case")]
136#[ts(tag = "type", rename_all = "snake_case")]
137pub enum GuardianAssessmentAction {
138    Command {
139        source: GuardianCommandSource,
140        command: String,
141        cwd: AbsolutePathBuf,
142    },
143    Execve {
144        source: GuardianCommandSource,
145        program: String,
146        argv: Vec<String>,
147        cwd: AbsolutePathBuf,
148    },
149    ApplyPatch {
150        cwd: AbsolutePathBuf,
151        files: Vec<AbsolutePathBuf>,
152    },
153    NetworkAccess {
154        target: String,
155        host: String,
156        protocol: NetworkApprovalProtocol,
157        port: u16,
158    },
159    McpToolCall {
160        server: String,
161        tool_name: String,
162        connector_id: Option<String>,
163        connector_name: Option<String>,
164        tool_title: Option<String>,
165    },
166    RequestPermissions {
167        reason: Option<String>,
168        permissions: RequestPermissionProfile,
169    },
170}
171
172#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
173pub struct NetworkPolicyAmendment {
174    pub host: String,
175    pub action: NetworkPolicyRuleAction,
176}
177
178#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
179pub struct GuardianAssessmentEvent {
180    /// Stable identifier for this guardian review lifecycle.
181    pub id: String,
182    /// Thread item being reviewed, when the review maps to a concrete item.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    #[ts(optional)]
185    pub target_item_id: Option<String>,
186    /// Turn ID that this assessment belongs to.
187    /// Uses `#[serde(default)]` for backwards compatibility.
188    #[serde(default)]
189    pub turn_id: String,
190    #[serde(default)]
191    #[ts(type = "number")]
192    pub started_at_ms: i64,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    #[ts(optional, type = "number")]
195    pub completed_at_ms: Option<i64>,
196    pub status: GuardianAssessmentStatus,
197    /// Coarse risk label. Omitted while the assessment is in progress.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    #[ts(optional)]
200    pub risk_level: Option<GuardianRiskLevel>,
201    /// How directly the transcript authorizes the reviewed action.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    #[ts(optional)]
204    pub user_authorization: Option<GuardianUserAuthorization>,
205    /// Human-readable explanation of the final assessment. Omitted while in progress.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    #[ts(optional)]
208    pub rationale: Option<String>,
209    /// Source that produced the terminal assessment decision.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    #[ts(optional)]
212    pub decision_source: Option<GuardianAssessmentDecisionSource>,
213    /// Canonical action payload that was reviewed.
214    pub action: GuardianAssessmentAction,
215}
216
217#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
218pub struct ExecApprovalRequestEvent {
219    /// Identifier for the associated command execution item.
220    pub call_id: String,
221    /// Identifier for this specific approval callback.
222    ///
223    /// When absent, the approval is for the command item itself (`call_id`).
224    /// This is present for subcommand approvals (via execve intercept).
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    #[ts(optional)]
227    pub approval_id: Option<String>,
228    /// Turn ID that this command belongs to.
229    /// Uses `#[serde(default)]` for backwards compatibility.
230    #[serde(default)]
231    pub turn_id: String,
232    /// Environment in which the command will run.
233    #[serde(
234        default,
235        rename = "environmentId",
236        alias = "environment_id",
237        skip_serializing_if = "Option::is_none"
238    )]
239    #[ts(optional)]
240    #[ts(rename = "environmentId")]
241    pub environment_id: Option<String>,
242    #[ts(type = "number")]
243    pub started_at_ms: i64,
244    /// The command to be executed.
245    pub command: Vec<String>,
246    /// The command's working directory.
247    pub cwd: AbsolutePathBuf,
248    /// Optional human-readable reason for the approval (e.g. retry without sandbox).
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub reason: Option<String>,
251    /// Optional network context for a blocked request that can be approved.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    #[ts(optional)]
254    pub network_approval_context: Option<NetworkApprovalContext>,
255    /// Proposed execpolicy amendment that can be applied to allow future runs.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    #[ts(optional)]
258    pub proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
259    /// Proposed network policy amendments (for example allow/deny this host in future).
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    #[ts(optional)]
262    pub proposed_network_policy_amendments: Option<Vec<NetworkPolicyAmendment>>,
263    /// Optional additional filesystem permissions requested for this command.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    #[ts(optional)]
266    pub additional_permissions: Option<AdditionalPermissionProfile>,
267    /// Ordered list of decisions the client may present for this prompt.
268    ///
269    /// When absent, clients should derive the legacy default set from the
270    /// other fields on this request.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    #[ts(optional)]
273    pub available_decisions: Option<Vec<ReviewDecision>>,
274    pub parsed_cmd: Vec<ParsedCommand>,
275}
276
277impl ExecApprovalRequestEvent {
278    pub fn effective_approval_id(&self) -> String {
279        self.approval_id
280            .clone()
281            .unwrap_or_else(|| self.call_id.clone())
282    }
283
284    pub fn effective_available_decisions(&self) -> Vec<ReviewDecision> {
285        // available_decisions is a new field that may not be populated by older
286        // senders, so we fall back to the legacy logic if it's not present.
287        match &self.available_decisions {
288            Some(decisions) => decisions.clone(),
289            None => Self::default_available_decisions(
290                self.network_approval_context.as_ref(),
291                self.proposed_execpolicy_amendment.as_ref(),
292                self.proposed_network_policy_amendments.as_deref(),
293                self.additional_permissions.as_ref(),
294            ),
295        }
296    }
297
298    pub fn default_available_decisions(
299        network_approval_context: Option<&NetworkApprovalContext>,
300        proposed_execpolicy_amendment: Option<&ExecPolicyAmendment>,
301        proposed_network_policy_amendments: Option<&[NetworkPolicyAmendment]>,
302        additional_permissions: Option<&AdditionalPermissionProfile>,
303    ) -> Vec<ReviewDecision> {
304        if network_approval_context.is_some() {
305            let mut decisions = vec![ReviewDecision::Approved, ReviewDecision::ApprovedForSession];
306            if let Some(amendment) = proposed_network_policy_amendments.and_then(|amendments| {
307                amendments
308                    .iter()
309                    .find(|amendment| amendment.action == NetworkPolicyRuleAction::Allow)
310            }) {
311                decisions.push(ReviewDecision::NetworkPolicyAmendment {
312                    network_policy_amendment: amendment.clone(),
313                });
314            }
315            decisions.push(ReviewDecision::Abort);
316            return decisions;
317        }
318
319        if additional_permissions.is_some() {
320            return vec![ReviewDecision::Approved, ReviewDecision::Abort];
321        }
322
323        let mut decisions = vec![ReviewDecision::Approved];
324        if let Some(prefix) = proposed_execpolicy_amendment {
325            decisions.push(ReviewDecision::ApprovedExecpolicyAmendment {
326                proposed_execpolicy_amendment: prefix.clone(),
327            });
328        }
329        decisions.push(ReviewDecision::Abort);
330        decisions
331    }
332}
333
334#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
335#[serde(tag = "mode", rename_all = "snake_case")]
336#[ts(tag = "mode")]
337pub enum ElicitationRequest {
338    Form {
339        #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
340        #[ts(optional, rename = "_meta")]
341        meta: Option<JsonValue>,
342        message: String,
343        requested_schema: JsonValue,
344    },
345    #[serde(rename = "openai/form")]
346    #[ts(rename = "openai/form")]
347    OpenAiForm {
348        #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
349        #[ts(optional, rename = "_meta")]
350        meta: Option<JsonValue>,
351        message: String,
352        requested_schema: JsonValue,
353    },
354    Url {
355        #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
356        #[ts(optional, rename = "_meta")]
357        meta: Option<JsonValue>,
358        message: String,
359        url: String,
360        elicitation_id: String,
361    },
362}
363
364#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
365pub struct ElicitationRequestEvent {
366    /// Turn ID that this elicitation belongs to, when known.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    #[ts(optional)]
369    pub turn_id: Option<String>,
370    pub server_name: String,
371    #[ts(type = "string | number")]
372    pub id: RequestId,
373    pub request: ElicitationRequest,
374}
375
376#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
377#[serde(rename_all = "lowercase")]
378pub enum ElicitationAction {
379    Accept,
380    Decline,
381    Cancel,
382}
383
384#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
385pub struct ApplyPatchApprovalRequestEvent {
386    /// Responses API call id for the associated patch apply call, if available.
387    pub call_id: String,
388    /// Turn ID that this patch belongs to.
389    /// Uses `#[serde(default)]` for backwards compatibility with older senders.
390    #[serde(default)]
391    pub turn_id: String,
392    #[ts(type = "number")]
393    pub started_at_ms: i64,
394    pub changes: HashMap<PathBuf, FileChange>,
395    /// Optional explanatory reason (e.g. request for extra write access).
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub reason: Option<String>,
398    /// When set, the agent is asking the user to allow writes under this root for the remainder of the session.
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub grant_root: Option<PathBuf>,
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use codex_utils_absolute_path::test_support::PathBufExt;
407    use codex_utils_absolute_path::test_support::test_path_buf;
408    use pretty_assertions::assert_eq;
409
410    #[test]
411    fn guardian_assessment_action_deserializes_command_shape() {
412        let action: GuardianAssessmentAction = serde_json::from_value(serde_json::json!({
413            "type": "command",
414            "source": "shell",
415            "command": "rm -rf /tmp/guardian",
416            "cwd": test_path_buf("/tmp"),
417        }))
418        .expect("guardian action");
419
420        assert_eq!(
421            action,
422            GuardianAssessmentAction::Command {
423                source: GuardianCommandSource::Shell,
424                command: "rm -rf /tmp/guardian".to_string(),
425                cwd: test_path_buf("/tmp").abs(),
426            }
427        );
428    }
429
430    #[cfg(unix)]
431    #[test]
432    fn guardian_assessment_action_round_trips_execve_shape() {
433        let value = serde_json::json!({
434            "type": "execve",
435            "source": "shell",
436            "program": "/bin/rm",
437            "argv": ["/usr/bin/rm", "-f", "/tmp/file.sqlite"],
438            "cwd": "/tmp",
439        });
440        let action: GuardianAssessmentAction =
441            serde_json::from_value(value.clone()).expect("guardian action");
442
443        assert_eq!(
444            serde_json::to_value(&action).expect("serialize guardian action"),
445            value
446        );
447
448        assert_eq!(
449            action,
450            GuardianAssessmentAction::Execve {
451                source: GuardianCommandSource::Shell,
452                program: "/bin/rm".to_string(),
453                argv: vec![
454                    "/usr/bin/rm".to_string(),
455                    "-f".to_string(),
456                    "/tmp/file.sqlite".to_string(),
457                ],
458                cwd: test_path_buf("/tmp").abs(),
459            }
460        );
461    }
462}