Codex Handbook
protocol/src/approvals.rs 451 lines
use crate::mcp::RequestId;use crate::models::AdditionalPermissionProfile;use crate::models::PermissionProfile;use crate::parse_command::ParsedCommand;use crate::protocol::FileChange;use crate::protocol::ReviewDecision;use crate::request_permissions::RequestPermissionProfile;use codex_utils_absolute_path::AbsolutePathBuf;use schemars::JsonSchema;use serde::Deserialize;use serde::Serialize;use serde_json::Value as JsonValue;use std::collections::HashMap;use std::path::PathBuf;use ts_rs::TS;/// Fully resolved permissions for rerunning an intercepted child process.#[derive(Debug, Clone, PartialEq, Eq)]pub struct ResolvedPermissionProfile {    pub permission_profile: PermissionProfile,}#[allow(clippy::large_enum_variant)]#[derive(Debug, Clone, PartialEq, Eq)]pub enum EscalationPermissions {    /// Permissions to merge with the active turn permissions.    AdditionalPermissionProfile(AdditionalPermissionProfile),    /// Fully resolved permissions that should replace the active turn permissions.    ResolvedPermissionProfile(ResolvedPermissionProfile),}/// Proposed execpolicy change to allow commands starting with this prefix.////// The `command` tokens form the prefix that would be added as an execpolicy/// `prefix_rule(..., decision="allow")`, letting the agent bypass approval for/// commands that start with this token sequence.#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]#[serde(transparent)]#[ts(type = "Array<string>")]pub struct ExecPolicyAmendment {    pub command: Vec<String>,}impl ExecPolicyAmendment {    pub fn new(command: Vec<String>) -> Self {        Self { command }    }    pub fn command(&self) -> &[String] {        &self.command    }}impl From<Vec<String>> for ExecPolicyAmendment {    fn from(command: Vec<String>) -> Self {        Self { command }    }}#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]#[serde(rename_all = "snake_case")]pub enum NetworkApprovalProtocol {    // TODO(viyatb): Add websocket protocol variants when managed proxy policy    // decisions expose websocket traffic as a distinct approval context.    Http,    #[serde(alias = "https_connect", alias = "http-connect")]    Https,    Socks5Tcp,    Socks5Udp,}#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]pub struct NetworkApprovalContext {    pub host: String,    pub protocol: NetworkApprovalProtocol,}#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]#[serde(rename_all = "snake_case")]pub enum NetworkPolicyRuleAction {    Allow,    Deny,}#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]#[serde(rename_all = "lowercase")]pub enum GuardianRiskLevel {    Low,    Medium,    High,    Critical,}#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]#[serde(rename_all = "lowercase")]pub enum GuardianUserAuthorization {    Unknown,    Low,    Medium,    High,}/// Final allow/deny outcome returned by the guardian reviewer.#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]#[serde(rename_all = "lowercase")]pub enum GuardianAssessmentOutcome {    Allow,    Deny,}#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]#[serde(rename_all = "snake_case")]pub enum GuardianAssessmentStatus {    InProgress,    Approved,    Denied,    TimedOut,    Aborted,}#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]#[serde(rename_all = "snake_case")]pub enum GuardianAssessmentDecisionSource {    Agent,}#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]#[serde(rename_all = "snake_case")]pub enum GuardianCommandSource {    Shell,    UnifiedExec,}#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]#[serde(tag = "type", rename_all = "snake_case")]#[ts(tag = "type", rename_all = "snake_case")]pub enum GuardianAssessmentAction {    Command {        source: GuardianCommandSource,        command: String,        cwd: AbsolutePathBuf,    },    Execve {        source: GuardianCommandSource,        program: String,        argv: Vec<String>,        cwd: AbsolutePathBuf,    },    ApplyPatch {        cwd: AbsolutePathBuf,        files: Vec<AbsolutePathBuf>,    },    NetworkAccess {        target: String,        host: String,        protocol: NetworkApprovalProtocol,        port: u16,    },    McpToolCall {        server: String,        tool_name: String,        connector_id: Option<String>,        connector_name: Option<String>,        tool_title: Option<String>,    },    RequestPermissions {        reason: Option<String>,        permissions: RequestPermissionProfile,    },}#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]pub struct NetworkPolicyAmendment {    pub host: String,    pub action: NetworkPolicyRuleAction,}#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]pub struct GuardianAssessmentEvent {    /// Stable identifier for this guardian review lifecycle.    pub id: String,    /// Thread item being reviewed, when the review maps to a concrete item.    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub target_item_id: Option<String>,    /// Turn ID that this assessment belongs to.    /// Uses `#[serde(default)]` for backwards compatibility.    #[serde(default)]    pub turn_id: String,    #[serde(default)]    #[ts(type = "number")]    pub started_at_ms: i64,    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional, type = "number")]    pub completed_at_ms: Option<i64>,    pub status: GuardianAssessmentStatus,    /// Coarse risk label. Omitted while the assessment is in progress.    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub risk_level: Option<GuardianRiskLevel>,    /// How directly the transcript authorizes the reviewed action.    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub user_authorization: Option<GuardianUserAuthorization>,    /// Human-readable explanation of the final assessment. Omitted while in progress.    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub rationale: Option<String>,    /// Source that produced the terminal assessment decision.    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub decision_source: Option<GuardianAssessmentDecisionSource>,    /// Canonical action payload that was reviewed.    pub action: GuardianAssessmentAction,}#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]pub struct ExecApprovalRequestEvent {    /// Identifier for the associated command execution item.    pub call_id: String,    /// Identifier for this specific approval callback.    ///    /// When absent, the approval is for the command item itself (`call_id`).    /// This is present for subcommand approvals (via execve intercept).    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub approval_id: Option<String>,    /// Turn ID that this command belongs to.    /// Uses `#[serde(default)]` for backwards compatibility.    #[serde(default)]    pub turn_id: String,    #[ts(type = "number")]    pub started_at_ms: i64,    /// The command to be executed.    pub command: Vec<String>,    /// The command's working directory.    pub cwd: AbsolutePathBuf,    /// Optional human-readable reason for the approval (e.g. retry without sandbox).    #[serde(skip_serializing_if = "Option::is_none")]    pub reason: Option<String>,    /// Optional network context for a blocked request that can be approved.    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub network_approval_context: Option<NetworkApprovalContext>,    /// Proposed execpolicy amendment that can be applied to allow future runs.    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,    /// Proposed network policy amendments (for example allow/deny this host in future).    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub proposed_network_policy_amendments: Option<Vec<NetworkPolicyAmendment>>,    /// Optional additional filesystem permissions requested for this command.    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub additional_permissions: Option<AdditionalPermissionProfile>,    /// Ordered list of decisions the client may present for this prompt.    ///    /// When absent, clients should derive the legacy default set from the    /// other fields on this request.    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub available_decisions: Option<Vec<ReviewDecision>>,    pub parsed_cmd: Vec<ParsedCommand>,}impl ExecApprovalRequestEvent {    pub fn effective_approval_id(&self) -> String {        self.approval_id            .clone()            .unwrap_or_else(|| self.call_id.clone())    }    pub fn effective_available_decisions(&self) -> Vec<ReviewDecision> {        // available_decisions is a new field that may not be populated by older        // senders, so we fall back to the legacy logic if it's not present.        match &self.available_decisions {            Some(decisions) => decisions.clone(),            None => Self::default_available_decisions(                self.network_approval_context.as_ref(),                self.proposed_execpolicy_amendment.as_ref(),                self.proposed_network_policy_amendments.as_deref(),                self.additional_permissions.as_ref(),            ),        }    }    pub fn default_available_decisions(        network_approval_context: Option<&NetworkApprovalContext>,        proposed_execpolicy_amendment: Option<&ExecPolicyAmendment>,        proposed_network_policy_amendments: Option<&[NetworkPolicyAmendment]>,        additional_permissions: Option<&AdditionalPermissionProfile>,    ) -> Vec<ReviewDecision> {        if network_approval_context.is_some() {            let mut decisions = vec![ReviewDecision::Approved, ReviewDecision::ApprovedForSession];            if let Some(amendment) = proposed_network_policy_amendments.and_then(|amendments| {                amendments                    .iter()                    .find(|amendment| amendment.action == NetworkPolicyRuleAction::Allow)            }) {                decisions.push(ReviewDecision::NetworkPolicyAmendment {                    network_policy_amendment: amendment.clone(),                });            }            decisions.push(ReviewDecision::Abort);            return decisions;        }        if additional_permissions.is_some() {            return vec![ReviewDecision::Approved, ReviewDecision::Abort];        }        let mut decisions = vec![ReviewDecision::Approved];        if let Some(prefix) = proposed_execpolicy_amendment {            decisions.push(ReviewDecision::ApprovedExecpolicyAmendment {                proposed_execpolicy_amendment: prefix.clone(),            });        }        decisions.push(ReviewDecision::Abort);        decisions    }}#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]#[serde(tag = "mode", rename_all = "snake_case")]#[ts(tag = "mode")]pub enum ElicitationRequest {    Form {        #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]        #[ts(optional, rename = "_meta")]        meta: Option<JsonValue>,        message: String,        requested_schema: JsonValue,    },    Url {        #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]        #[ts(optional, rename = "_meta")]        meta: Option<JsonValue>,        message: String,        url: String,        elicitation_id: String,    },}impl ElicitationRequest {    pub fn message(&self) -> &str {        match self {            Self::Form { message, .. } | Self::Url { message, .. } => message,        }    }}#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]pub struct ElicitationRequestEvent {    /// Turn ID that this elicitation belongs to, when known.    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub turn_id: Option<String>,    pub server_name: String,    #[ts(type = "string | number")]    pub id: RequestId,    pub request: ElicitationRequest,}#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]#[serde(rename_all = "lowercase")]pub enum ElicitationAction {    Accept,    Decline,    Cancel,}#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]pub struct ApplyPatchApprovalRequestEvent {    /// Responses API call id for the associated patch apply call, if available.    pub call_id: String,    /// Turn ID that this patch belongs to.    /// Uses `#[serde(default)]` for backwards compatibility with older senders.    #[serde(default)]    pub turn_id: String,    #[ts(type = "number")]    pub started_at_ms: i64,    pub changes: HashMap<PathBuf, FileChange>,    /// Optional explanatory reason (e.g. request for extra write access).    #[serde(skip_serializing_if = "Option::is_none")]    pub reason: Option<String>,    /// When set, the agent is asking the user to allow writes under this root for the remainder of the session.    #[serde(skip_serializing_if = "Option::is_none")]    pub grant_root: Option<PathBuf>,}#[cfg(test)]mod tests {    use super::*;    use codex_utils_absolute_path::test_support::PathBufExt;    use codex_utils_absolute_path::test_support::test_path_buf;    use pretty_assertions::assert_eq;    #[test]    fn guardian_assessment_action_deserializes_command_shape() {        let action: GuardianAssessmentAction = serde_json::from_value(serde_json::json!({            "type": "command",            "source": "shell",            "command": "rm -rf /tmp/guardian",            "cwd": test_path_buf("/tmp"),        }))        .expect("guardian action");        assert_eq!(            action,            GuardianAssessmentAction::Command {                source: GuardianCommandSource::Shell,                command: "rm -rf /tmp/guardian".to_string(),                cwd: test_path_buf("/tmp").abs(),            }        );    }    #[cfg(unix)]    #[test]    fn guardian_assessment_action_round_trips_execve_shape() {        let value = serde_json::json!({            "type": "execve",            "source": "shell",            "program": "/bin/rm",            "argv": ["/usr/bin/rm", "-f", "/tmp/file.sqlite"],            "cwd": "/tmp",        });        let action: GuardianAssessmentAction =            serde_json::from_value(value.clone()).expect("guardian action");        assert_eq!(            serde_json::to_value(&action).expect("serialize guardian action"),            value        );        assert_eq!(            action,            GuardianAssessmentAction::Execve {                source: GuardianCommandSource::Shell,                program: "/bin/rm".to_string(),                argv: vec![                    "/usr/bin/rm".to_string(),                    "-f".to_string(),                    "/tmp/file.sqlite".to_string(),                ],                cwd: test_path_buf("/tmp").abs(),            }        );    }}