Codex Handbook
core/src/tools/sandboxing.rs 457 lines
//! Shared approvals and sandboxing traits used by tool runtimes.//!//! Consolidates the approval flow primitives (`ApprovalDecision`, `ApprovalStore`,//! `ApprovalCtx`, `Approvable`) together with the sandbox orchestration traits//! and helpers (`Sandboxable`, `ToolRuntime`, `SandboxAttempt`, etc.).use crate::sandboxing::ExecOptions;use crate::sandboxing::SandboxPermissions;use crate::session::session::Session;use crate::session::turn_context::TurnContext;use crate::state::SessionServices;use crate::tools::hook_names::HookToolName;use crate::tools::network_approval::NetworkApprovalSpec;use codex_network_proxy::NetworkProxy;use codex_protocol::approvals::ExecPolicyAmendment;use codex_protocol::approvals::NetworkApprovalContext;use codex_protocol::error::CodexErr;use codex_protocol::permissions::FileSystemSandboxKind;use codex_protocol::permissions::FileSystemSandboxPolicy;use codex_protocol::protocol::AskForApproval;use codex_protocol::protocol::ReviewDecision;use codex_sandboxing::SandboxCommand;use codex_sandboxing::SandboxManager;use codex_sandboxing::SandboxTransformRequest;use codex_sandboxing::SandboxType;use codex_sandboxing::SandboxablePreference;use codex_tools::ToolName;use codex_utils_absolute_path::AbsolutePathBuf;use codex_utils_path_uri::PathUri;use futures::Future;use futures::future::BoxFuture;use serde::Serialize;use std::collections::HashMap;use std::fmt::Debug;use std::hash::Hash;use std::sync::Arc;use tokio_util::sync::CancellationToken;#[derive(Clone, Default, Debug)]pub(crate) struct ApprovalStore {    // Store serialized keys for generic caching across requests.    map: HashMap<String, ReviewDecision>,}impl ApprovalStore {    pub fn get<K>(&self, key: &K) -> Option<ReviewDecision>    where        K: Serialize,    {        let s = serde_json::to_string(key).ok()?;        self.map.get(&s).cloned()    }    pub fn put<K>(&mut self, key: K, value: ReviewDecision)    where        K: Serialize,    {        if let Ok(s) = serde_json::to_string(&key) {            self.map.insert(s, value);        }    }}/// Takes a vector of approval keys and returns a ReviewDecision./// There will be one key in most cases, but apply_patch can modify multiple files at once.////// - If all keys are already approved for session, we skip prompting./// - If the user approves for session, we store the decision for each key individually///   so future requests touching any subset can also skip prompting.pub(crate) async fn with_cached_approval<K, F, Fut>(    services: &SessionServices,    // Name of the tool, used for metrics collection.    tool_name: &str,    keys: Vec<K>,    fetch: F,) -> ReviewDecisionwhere    K: Serialize,    F: FnOnce() -> Fut,    Fut: Future<Output = ReviewDecision>,{    // To be defensive here, don't bother with checking the cache if keys are empty.    if keys.is_empty() {        return fetch().await;    }    let already_approved = {        let store = services.tool_approvals.lock().await;        keys.iter()            .all(|key| matches!(store.get(key), Some(ReviewDecision::ApprovedForSession)))    };    if already_approved {        return ReviewDecision::ApprovedForSession;    }    let decision = fetch().await;    services.session_telemetry.counter(        "codex.approval.requested",        /*inc*/ 1,        &[            ("tool", tool_name),            ("approved", decision.to_opaque_string()),        ],    );    if matches!(decision, ReviewDecision::ApprovedForSession) {        let mut store = services.tool_approvals.lock().await;        for key in keys {            store.put(key, ReviewDecision::ApprovedForSession);        }    }    decision}#[derive(Clone)]pub(crate) struct ApprovalCtx<'a> {    pub session: &'a Arc<Session>,    pub turn: &'a Arc<TurnContext>,    pub call_id: &'a str,    /// Guardian review lifecycle ID for this approval, when guardian is reviewing it.    ///    /// This is separate from `call_id`: `call_id` identifies the tool item under    /// review, while this ID identifies the review itself. Keeping both lets    /// denial handling, overrides, and app-server notifications refer to the    /// review without overloading the tool call ID as a review ID.    pub guardian_review_id: Option<String>,    pub retry_reason: Option<String>,    pub network_approval_context: Option<NetworkApprovalContext>,}#[derive(Clone, Debug, PartialEq, Eq)]pub(crate) struct PermissionRequestPayload {    pub tool_name: HookToolName,    pub tool_input: serde_json::Value,}impl PermissionRequestPayload {    pub(crate) fn bash(command: String, description: Option<String>) -> Self {        let mut tool_input = serde_json::Map::new();        tool_input.insert("command".to_string(), serde_json::Value::String(command));        if let Some(description) = description {            tool_input.insert(                "description".to_string(),                serde_json::Value::String(description),            );        }        Self {            tool_name: HookToolName::bash(),            tool_input: serde_json::Value::Object(tool_input),        }    }}// Specifies what tool orchestrator should do with a given tool call.#[derive(Clone, Debug, PartialEq, Eq)]pub(crate) enum ExecApprovalRequirement {    /// No approval required for this tool call.    Skip {        /// The first attempt should skip sandboxing (e.g., when explicitly        /// greenlit by policy).        bypass_sandbox: bool,        /// Proposed execpolicy amendment to skip future approvals for similar commands        /// Only applies if the command fails to run in sandbox and codex prompts the user to run outside the sandbox.        proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,    },    /// Approval required for this tool call.    NeedsApproval {        reason: Option<String>,        /// Proposed execpolicy amendment to skip future approvals for similar commands        /// See core/src/exec_policy.rs for more details on how proposed_execpolicy_amendment is determined.        proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,    },    /// Execution forbidden for this tool call.    Forbidden { reason: String },}impl ExecApprovalRequirement {    pub fn proposed_execpolicy_amendment(&self) -> Option<&ExecPolicyAmendment> {        match self {            Self::NeedsApproval {                proposed_execpolicy_amendment: Some(prefix),                ..            } => Some(prefix),            Self::Skip {                proposed_execpolicy_amendment: Some(prefix),                ..            } => Some(prefix),            _ => None,        }    }}/// - Never, OnFailure: do not ask/// - OnRequest: ask unless filesystem access is unrestricted/// - Granular: ask unless filesystem access is unrestricted, but auto-reject///   when granular sandbox approval is disabled./// - UnlessTrusted: always askpub(crate) fn default_exec_approval_requirement(    policy: AskForApproval,    file_system_sandbox_policy: &FileSystemSandboxPolicy,) -> ExecApprovalRequirement {    let needs_approval = match policy {        AskForApproval::Never | AskForApproval::OnFailure => false,        AskForApproval::OnRequest | AskForApproval::Granular(_) => {            matches!(                file_system_sandbox_policy.kind,                FileSystemSandboxKind::Restricted            )        }        AskForApproval::UnlessTrusted => true,    };    if needs_approval        && matches!(            policy,            AskForApproval::Granular(granular_config)                if !granular_config.allows_sandbox_approval()        )    {        ExecApprovalRequirement::Forbidden {            reason: "approval policy disallowed sandbox approval prompt".to_string(),        }    } else if needs_approval {        ExecApprovalRequirement::NeedsApproval {            reason: None,            proposed_execpolicy_amendment: None,        }    } else {        ExecApprovalRequirement::Skip {            bypass_sandbox: false,            proposed_execpolicy_amendment: None,        }    }}#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub(crate) enum SandboxOverride {    NoOverride,    BypassSandboxFirstAttempt,}pub(crate) fn sandbox_override_for_first_attempt(    sandbox_permissions: SandboxPermissions,    exec_approval_requirement: &ExecApprovalRequirement,    file_system_sandbox_policy: &FileSystemSandboxPolicy,) -> SandboxOverride {    // Deny-read restrictions are part of the active permission policy. Running    // without a filesystem sandbox would discard them, even if the command was    // otherwise approved by rules or explicit escalation.    if !unsandboxed_execution_allowed(file_system_sandbox_policy) {        return SandboxOverride::NoOverride;    }    // ExecPolicy `Allow` can intentionally imply full trust (Skip + bypass_sandbox=true),    // which supersedes `with_additional_permissions` sandboxed execution hints.    if matches!(        exec_approval_requirement,        ExecApprovalRequirement::Skip {            bypass_sandbox: true,            ..        }    ) {        return SandboxOverride::BypassSandboxFirstAttempt;    }    if sandbox_permissions.requires_escalated_permissions() {        SandboxOverride::BypassSandboxFirstAttempt    } else {        SandboxOverride::NoOverride    }}/// Returns true when the active filesystem policy can be represented by/// running without a filesystem sandbox.////// Denied reads only exist inside the sandbox. If a policy contains any/// denied-read paths, bypassing the sandbox would silently grant those reads,/// so escalation must keep the command sandboxed with the denied reads intact.pub(crate) fn unsandboxed_execution_allowed(    file_system_sandbox_policy: &FileSystemSandboxPolicy,) -> bool {    !file_system_sandbox_policy.has_denied_read_restrictions()}pub(crate) fn sandbox_permissions_preserving_denied_reads(    sandbox_permissions: SandboxPermissions,    file_system_sandbox_policy: &FileSystemSandboxPolicy,) -> SandboxPermissions {    if sandbox_permissions.requires_escalated_permissions()        && !unsandboxed_execution_allowed(file_system_sandbox_policy)    {        // `RequireEscalated` normally asks the executor to bypass the sandbox.        // When denied reads are active, that would drop the only mechanism that        // enforces them, so fall back to the default sandboxed attempt instead.        SandboxPermissions::UseDefault    } else {        sandbox_permissions    }}pub(crate) fn managed_network_for_sandbox_permissions(    network: Option<&NetworkProxy>,    sandbox_permissions: SandboxPermissions,) -> Option<&NetworkProxy> {    if sandbox_permissions.requires_escalated_permissions() {        None    } else {        network    }}pub(crate) trait Approvable<Req> {    type ApprovalKey: Hash + Eq + Clone + Debug + Serialize;    // In most cases (shell, unified_exec), a request will have a single approval key.    //    // However, apply_patch needs session "Allow, don't ask again" semantics that    // apply to multiple atomic targets (e.g., apply_patch approves per file path). Returning    // a list of keys lets the runtime treat the request as approved-for-session only if    // *all* keys are already approved, while still caching approvals per-key so future    // requests touching a subset can be auto-approved.    fn approval_keys(&self, req: &Req) -> Vec<Self::ApprovalKey>;    /// Return per-request sandbox permissions for first-attempt sandbox    /// selection. Most tools use the ambient sandbox policy unchanged.    fn sandbox_permissions(&self, _req: &Req) -> SandboxPermissions {        SandboxPermissions::UseDefault    }    fn should_bypass_approval(&self, policy: AskForApproval, already_approved: bool) -> bool {        if already_approved {            // We do not ask one more time            return true;        }        matches!(policy, AskForApproval::Never)    }    /// Return `Some(_)` to specify a custom exec approval requirement, or `None`    /// to fall back to policy-based default.    fn exec_approval_requirement(&self, _req: &Req) -> Option<ExecApprovalRequirement> {        None    }    /// Return hook input for approval-time policy hooks when this runtime wants    /// hook evaluation to run before guardian or user approval.    fn permission_request_payload(&self, _req: &Req) -> Option<PermissionRequestPayload> {        None    }    /// Decide we can request an approval for no-sandbox execution.    fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool {        match policy {            AskForApproval::OnFailure => true,            AskForApproval::UnlessTrusted => true,            AskForApproval::Never => false,            AskForApproval::OnRequest => false,            AskForApproval::Granular(granular_config) => granular_config.sandbox_approval,        }    }    fn start_approval_async<'a>(        &'a mut self,        req: &'a Req,        ctx: ApprovalCtx<'a>,    ) -> BoxFuture<'a, ReviewDecision>;}pub(crate) trait Sandboxable {    fn sandbox_preference(&self) -> SandboxablePreference;    fn escalate_on_failure(&self) -> bool {        true    }}pub(crate) struct ToolCtx {    pub session: Arc<Session>,    pub turn: Arc<TurnContext>,    pub call_id: String,    pub tool_name: ToolName,}#[derive(Debug)]pub(crate) enum ToolError {    Rejected(String),    Codex(CodexErr),}pub(crate) trait ToolRuntime<Req, Out>: Approvable<Req> + Sandboxable {    fn network_approval_spec(&self, _req: &Req, _ctx: &ToolCtx) -> Option<NetworkApprovalSpec> {        None    }    fn sandbox_cwd<'a>(&self, _req: &'a Req) -> Option<&'a AbsolutePathBuf> {        None    }    async fn run(        &mut self,        req: &Req,        attempt: &SandboxAttempt<'_>,        ctx: &ToolCtx,    ) -> Result<Out, ToolError>;}pub(crate) struct SandboxAttempt<'a> {    pub sandbox: SandboxType,    pub permissions: &'a codex_protocol::models::PermissionProfile,    pub enforce_managed_network: bool,    pub(crate) manager: &'a SandboxManager,    pub(crate) sandbox_cwd: &'a PathUri,    pub(crate) workspace_roots: &'a [AbsolutePathBuf],    pub codex_linux_sandbox_exe: Option<&'a std::path::PathBuf>,    pub use_legacy_landlock: bool,    pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel,    pub windows_sandbox_private_desktop: bool,    pub network_denial_cancellation_token: Option<CancellationToken>,}impl<'a> SandboxAttempt<'a> {    pub fn env_for(        &self,        command: SandboxCommand,        options: ExecOptions,        network: Option<&NetworkProxy>,    ) -> Result<crate::sandboxing::ExecRequest, CodexErr> {        let request = self            .manager            .transform(SandboxTransformRequest {                command,                permissions: self.permissions,                sandbox: self.sandbox,                enforce_managed_network: self.enforce_managed_network,                network,                sandbox_policy_cwd: self.sandbox_cwd,                codex_linux_sandbox_exe: self                    .codex_linux_sandbox_exe                    .map(std::path::PathBuf::as_path),                use_legacy_landlock: self.use_legacy_landlock,                windows_sandbox_level: self.windows_sandbox_level,                windows_sandbox_private_desktop: self.windows_sandbox_private_desktop,            })            .map_err(CodexErr::from)?;        Ok(crate::sandboxing::ExecRequest::from_sandbox_exec_request(            request,            options,            self.workspace_roots.to_vec(),        ))    }}#[cfg(test)]#[path = "sandboxing_tests.rs"]mod tests;