Files
codex/prs/bolinfest/PR-1763.md
2025-09-02 15:17:45 -07:00

43 KiB
Raw Blame History

PR #1763: [tools] Let the model request approval for shell commands

Description

Summary

Let's try something new: tell the model about the sandbox, and let it decide when it will need to break the sandbox. Some local testing suggests that it works pretty well with zero iteration on the prompt!

Example of this PR in action, on this PR: Screenshot 2025-07-31 at 12 17 24 PM It wasn't totally correct - I'll try to steer the prompt a bit more.

Things I'm considering out of scope for this change, but that I think we should do:

  • simplify/consolidate our tool config logic, so we don't have to pass an infinite list of params to create_tools_json_for_responses_api
  • Continue iterating on our tool prompt and system prompt to make this call more accurate

Testing

  • Added unit tests
  • Played around with it locally and it feels really good

Full Diff

diff --git a/codex-rs/common/src/approval_mode_cli_arg.rs b/codex-rs/common/src/approval_mode_cli_arg.rs
index a74ceb2b81..00c6f32f53 100644
--- a/codex-rs/common/src/approval_mode_cli_arg.rs
+++ b/codex-rs/common/src/approval_mode_cli_arg.rs
@@ -18,6 +18,9 @@ pub enum ApprovalModeCliArg {
     /// will escalate to the user to ask for un-sandboxed execution.
     OnFailure,
 
+    /// Only ask for approval if the model requests it.
+    OnRequest,
+
     /// Never ask for user approval
     /// Execution failures are immediately returned to the model.
     Never,
@@ -28,6 +31,7 @@ impl From<ApprovalModeCliArg> for AskForApproval {
         match value {
             ApprovalModeCliArg::Untrusted => AskForApproval::UnlessTrusted,
             ApprovalModeCliArg::OnFailure => AskForApproval::OnFailure,
+            ApprovalModeCliArg::OnRequest => AskForApproval::OnRequest,
             ApprovalModeCliArg::Never => AskForApproval::Never,
         }
     }
diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs
index 5ede774b1c..c79ce28edd 100644
--- a/codex-rs/core/src/chat_completions.rs
+++ b/codex-rs/core/src/chat_completions.rs
@@ -24,6 +24,7 @@ use crate::error::Result;
 use crate::models::ContentItem;
 use crate::models::ResponseItem;
 use crate::openai_tools::create_tools_json_for_chat_completions_api;
+use crate::protocol::SandboxPolicy;
 use crate::util::backoff;
 
 /// Implementation for the classic Chat Completions API.
@@ -33,6 +34,7 @@ pub(crate) async fn stream_chat_completions(
     include_plan_tool: bool,
     client: &reqwest::Client,
     provider: &ModelProviderInfo,
+    sandbox_policy: Option<SandboxPolicy>,
 ) -> Result<ResponseStream> {
     // Build messages array
     let mut messages = Vec::<serde_json::Value>::new();
@@ -110,7 +112,12 @@ pub(crate) async fn stream_chat_completions(
         }
     }
 
-    let tools_json = create_tools_json_for_chat_completions_api(prompt, model, include_plan_tool)?;
+    let tools_json = create_tools_json_for_chat_completions_api(
+        prompt,
+        model,
+        include_plan_tool,
+        sandbox_policy,
+    )?;
     let payload = json!({
         "model": model,
         "messages": messages,
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
index 0f7f51c2fc..db9520cea0 100644
--- a/codex-rs/core/src/client.rs
+++ b/codex-rs/core/src/client.rs
@@ -38,6 +38,7 @@ use crate::model_provider_info::WireApi;
 use crate::models::ContentItem;
 use crate::models::ResponseItem;
 use crate::openai_tools::create_tools_json_for_responses_api;
+use crate::protocol::SandboxPolicy;
 use crate::protocol::TokenUsage;
 use crate::util::backoff;
 use std::sync::Arc;
@@ -76,9 +77,13 @@ impl ModelClient {
     /// Dispatches to either the Responses or Chat implementation depending on
     /// the provider config.  Public callers always invoke `stream()`  the
     /// specialised helpers are private to avoid accidental misuse.
-    pub async fn stream(&self, prompt: &Prompt) -> Result<ResponseStream> {
+    pub async fn stream(
+        &self,
+        prompt: &Prompt,
+        sandbox_policy: Option<SandboxPolicy>,
+    ) -> Result<ResponseStream> {
         match self.provider.wire_api {
-            WireApi::Responses => self.stream_responses(prompt).await,
+            WireApi::Responses => self.stream_responses(prompt, sandbox_policy).await,
             WireApi::Chat => {
                 // Create the raw streaming connection first.
                 let response_stream = stream_chat_completions(
@@ -87,6 +92,7 @@ impl ModelClient {
                     self.config.include_plan_tool,
                     &self.client,
                     &self.provider,
+                    sandbox_policy,
                 )
                 .await?;
 
@@ -115,7 +121,11 @@ impl ModelClient {
     }
 
     /// Implementation for the OpenAI *Responses* experimental API.
-    async fn stream_responses(&self, prompt: &Prompt) -> Result<ResponseStream> {
+    async fn stream_responses(
+        &self,
+        prompt: &Prompt,
+        sandbox_policy: Option<SandboxPolicy>,
+    ) -> Result<ResponseStream> {
         if let Some(path) = &*CODEX_RS_SSE_FIXTURE {
             // short circuit for tests
             warn!(path, "Streaming from fixture");
@@ -146,6 +156,7 @@ impl ModelClient {
             prompt,
             &self.config.model,
             self.config.include_plan_tool,
+            sandbox_policy,
         )?;
         let reasoning = create_reasoning_param_for_request(&self.config, self.effort, self.summary);
 
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index 5d48cd457f..2d679338b2 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -206,7 +206,7 @@ pub(crate) struct Session {
     base_instructions: Option<String>,
     user_instructions: Option<String>,
     pub(crate) approval_policy: AskForApproval,
-    sandbox_policy: SandboxPolicy,
+    pub(crate) sandbox_policy: SandboxPolicy,
     shell_environment_policy: ShellEnvironmentPolicy,
     pub(crate) writable_roots: Mutex<Vec<PathBuf>>,
     disable_response_storage: bool,
@@ -1276,7 +1276,13 @@ async fn try_run_turn(
         })
     };
 
-    let mut stream = sess.client.clone().stream(&prompt).await?;
+    // only provide the sandbox policy if the approval policy is OnRequest
+    let sandbox_policy = if sess.approval_policy == AskForApproval::OnRequest {
+        Some(sess.sandbox_policy.clone())
+    } else {
+        None
+    };
+    let mut stream = sess.client.clone().stream(&prompt, sandbox_policy).await?;
 
     let mut output = Vec::new();
     loop {
@@ -1477,6 +1483,8 @@ async fn handle_response_item(
                 command: action.command,
                 workdir: action.working_directory,
                 timeout_ms: action.timeout_ms,
+                with_escalated_permissions: None,
+                justification: None,
             };
             let effective_call_id = match (call_id, id) {
                 (Some(call_id), _) => call_id,
@@ -1562,6 +1570,8 @@ fn to_exec_params(params: ShellToolCallParams, sess: &Session) -> ExecParams {
         cwd: sess.resolve_path(params.workdir.clone()),
         timeout_ms: params.timeout_ms,
         env: create_env(&sess.shell_environment_policy),
+        with_escalated_permissions: params.with_escalated_permissions,
+        justification: params.justification,
     }
 }
 
@@ -1661,13 +1671,19 @@ async fn handle_container_exec_with_params(
                 cwd: cwd.clone(),
                 timeout_ms: params.timeout_ms,
                 env: HashMap::new(),
+                with_escalated_permissions: None,
+                justification: None,
             };
             let safety = if *user_explicitly_approved_this_action {
                 SafetyCheck::AutoApprove {
                     sandbox_type: SandboxType::None,
                 }
             } else {
-                assess_safety_for_untrusted_command(sess.approval_policy, &sess.sandbox_policy)
+                assess_safety_for_untrusted_command(
+                    sess.approval_policy,
+                    &sess.sandbox_policy,
+                    params.with_escalated_permissions.unwrap_or(false),
+                )
             };
             (
                 params,
@@ -1683,6 +1699,7 @@ async fn handle_container_exec_with_params(
                     sess.approval_policy,
                     &sess.sandbox_policy,
                     &state.approved_commands,
+                    params.with_escalated_permissions.unwrap_or(false),
                 )
             };
             let command_for_display = params.command.clone();
@@ -1699,7 +1716,7 @@ async fn handle_container_exec_with_params(
                     call_id.clone(),
                     params.command.clone(),
                     params.cwd.clone(),
-                    None,
+                    params.justification.clone(),
                 )
                 .await;
             match rx_approve.await.unwrap_or_default() {
@@ -1824,8 +1841,11 @@ async fn handle_sandbox_error(
     let cwd = exec_command_context.cwd.clone();
     let is_apply_patch = exec_command_context.apply_patch.is_some();
 
-    // Early out if the user never wants to be asked for approval; just return to the model immediately
-    if sess.approval_policy == AskForApproval::Never {
+    // Early out if either the user never wants to be asked for approval, or
+    // we're letting the model manage escalation requests.
+    if sess.approval_policy == AskForApproval::Never
+        || sess.approval_policy == AskForApproval::OnRequest
+    {
         return ResponseInputItem::FunctionCallOutput {
             call_id,
             output: FunctionCallOutputPayload {
@@ -1991,7 +2011,7 @@ fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option<St
 }
 
 async fn drain_to_completed(sess: &Session, prompt: &Prompt) -> CodexResult<()> {
-    let mut stream = sess.client.clone().stream(prompt).await?;
+    let mut stream = sess.client.clone().stream(prompt, None).await?;
     loop {
         let maybe_event = stream.next().await;
         let Some(event) = maybe_event else {
diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs
index 06416e6768..5e467663c3 100644
--- a/codex-rs/core/src/exec.rs
+++ b/codex-rs/core/src/exec.rs
@@ -43,6 +43,8 @@ pub struct ExecParams {
     pub cwd: PathBuf,
     pub timeout_ms: Option<u64>,
     pub env: HashMap<String, String>,
+    pub with_escalated_permissions: Option<bool>,
+    pub justification: Option<String>,
 }
 
 #[derive(Clone, Copy, Debug, PartialEq)]
@@ -74,6 +76,8 @@ pub async fn process_exec_tool_call(
                 cwd,
                 timeout_ms,
                 env,
+                with_escalated_permissions: _,
+                justification: _,
             } = params;
             let child = spawn_command_under_seatbelt(
                 command,
@@ -91,6 +95,8 @@ pub async fn process_exec_tool_call(
                 cwd,
                 timeout_ms,
                 env,
+                with_escalated_permissions: _,
+                justification: _,
             } = params;
 
             let codex_linux_sandbox_exe = codex_linux_sandbox_exe
@@ -230,6 +236,8 @@ async fn exec(
         cwd,
         timeout_ms,
         env,
+        with_escalated_permissions: _,
+        justification: _,
     }: ExecParams,
     sandbox_policy: &SandboxPolicy,
     ctrl_c: Arc<Notify>,
diff --git a/codex-rs/core/src/models.rs b/codex-rs/core/src/models.rs
index 166404915a..56f9192c6d 100644
--- a/codex-rs/core/src/models.rs
+++ b/codex-rs/core/src/models.rs
@@ -183,6 +183,8 @@ pub struct ShellToolCallParams {
     // The wire format uses `timeout`, which has ambiguous units, so we use
     // `timeout_ms` as the field name so it is clear in code.
     pub timeout_ms: Option<u64>,
+    pub with_escalated_permissions: Option<bool>,
+    pub justification: Option<String>,
 }
 
 #[derive(Debug, Clone)]
@@ -295,6 +297,30 @@ mod tests {
                 command: vec!["ls".to_string(), "-l".to_string()],
                 workdir: Some("/tmp".to_string()),
                 timeout_ms: Some(1000),
+                with_escalated_permissions: None,
+                justification: None,
+            },
+            params
+        );
+    }
+    #[test]
+    fn deserialize_shell_tool_call_params_with_escalated_permissions() {
+        let json = r#"{
+            "command": ["ls", "-l"],
+            "workdir": "/tmp",
+            "timeout": 1000,
+            "with_escalated_permissions": true,
+            "justification": "I need internet access to run npm install"
+        }"#;
+
+        let params: ShellToolCallParams = serde_json::from_str(json).unwrap();
+        assert_eq!(
+            ShellToolCallParams {
+                command: vec!["ls".to_string(), "-l".to_string()],
+                workdir: Some("/tmp".to_string()),
+                timeout_ms: Some(1000),
+                with_escalated_permissions: Some(true),
+                justification: Some("I need internet access to run npm install".to_string()),
             },
             params
         );
diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs
index 0f1e7d9ca7..71404d26df 100644
--- a/codex-rs/core/src/openai_tools.rs
+++ b/codex-rs/core/src/openai_tools.rs
@@ -5,11 +5,12 @@ use std::sync::LazyLock;
 
 use crate::client_common::Prompt;
 use crate::plan_tool::PLAN_TOOL;
+use crate::protocol::SandboxPolicy;
 
 #[derive(Debug, Clone, Serialize)]
 pub(crate) struct ResponsesApiTool {
     pub(crate) name: &'static str,
-    pub(crate) description: &'static str,
+    pub(crate) description: String,
     pub(crate) strict: bool,
     pub(crate) parameters: JsonSchema,
 }
@@ -29,8 +30,18 @@ pub(crate) enum OpenAiTool {
 #[derive(Debug, Clone, Serialize)]
 #[serde(tag = "type", rename_all = "lowercase")]
 pub(crate) enum JsonSchema {
-    String,
-    Number,
+    Boolean {
+        #[serde(skip_serializing_if = "Option::is_none")]
+        description: Option<String>,
+    },
+    String {
+        #[serde(skip_serializing_if = "Option::is_none")]
+        description: Option<String>,
+    },
+    Number {
+        #[serde(skip_serializing_if = "Option::is_none")]
+        description: Option<String>,
+    },
     Array {
         items: Box<JsonSchema>,
     },
@@ -48,15 +59,21 @@ static DEFAULT_TOOLS: LazyLock<Vec<OpenAiTool>> = LazyLock::new(|| {
     properties.insert(
         "command".to_string(),
         JsonSchema::Array {
-            items: Box::new(JsonSchema::String),
+            items: Box::new(JsonSchema::String { description: None }),
         },
     );
-    properties.insert("workdir".to_string(), JsonSchema::String);
-    properties.insert("timeout".to_string(), JsonSchema::Number);
+    properties.insert(
+        "workdir".to_string(),
+        JsonSchema::String { description: None },
+    );
+    properties.insert(
+        "timeout".to_string(),
+        JsonSchema::Number { description: None },
+    );
 
     vec![OpenAiTool::Function(ResponsesApiTool {
         name: "shell",
-        description: "Runs a shell command, and returns its output.",
+        description: "Runs a shell command, and returns its output.".to_string(),
         strict: false,
         parameters: JsonSchema::Object {
             properties,
@@ -72,13 +89,18 @@ static DEFAULT_CODEX_MODEL_TOOLS: LazyLock<Vec<OpenAiTool>> =
 /// Returns JSON values that are compatible with Function Calling in the
 /// Responses API:
 /// https://platform.openai.com/docs/guides/function-calling?api-mode=responses
+/// TODO: we're starting to get more complex with our tool config, let's
 pub(crate) fn create_tools_json_for_responses_api(
     prompt: &Prompt,
     model: &str,
     include_plan_tool: bool,
+    sandbox_policy: Option<SandboxPolicy>,
 ) -> crate::error::Result<Vec<serde_json::Value>> {
     // Assemble tool list: built-in tools + any extra tools from the prompt.
-    let default_tools = if model.starts_with("codex") {
+    let default_tools = if let Some(sandbox_policy) = sandbox_policy {
+        // if sandbox_policy is provided, create the shell tool
+        &vec![create_shell_tool_for_sandbox(sandbox_policy)]
+    } else if model.contains("codex") {
         &DEFAULT_CODEX_MODEL_TOOLS
     } else {
         &DEFAULT_TOOLS
@@ -102,6 +124,109 @@ pub(crate) fn create_tools_json_for_responses_api(
     Ok(tools_json)
 }
 
+fn create_shell_tool_for_sandbox(sandbox_policy: SandboxPolicy) -> OpenAiTool {
+    let mut properties = BTreeMap::new();
+    properties.insert(
+        "command".to_string(),
+        JsonSchema::Array {
+            items: Box::new(JsonSchema::String {
+                description: Some("The command to execute".to_string()),
+            }),
+        },
+    );
+    properties.insert(
+        "workdir".to_string(),
+        JsonSchema::String {
+            description: Some("The working directory to execute the command in".to_string()),
+        },
+    );
+    properties.insert(
+        "timeout".to_string(),
+        JsonSchema::Number {
+            description: Some("The timeout for the command in milliseconds".to_string()),
+        },
+    );
+
+    if sandbox_policy != SandboxPolicy::DangerFullAccess {
+        properties.insert(
+        "with_escalated_permissions".to_string(),
+        JsonSchema::Boolean {
+            description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()),
+        },
+    );
+        properties.insert(
+        "justification".to_string(),
+        JsonSchema::String {
+            description: Some("Only set if ask_for_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()),
+        },
+    );
+    }
+
+    let description = match sandbox_policy {
+        SandboxPolicy::WorkspaceWrite {
+            writable_roots: _,
+            network_access,
+        } => {
+            format!(
+                r#"
+The shell tool is used to execute shell commands.
+
+- When invoking the shell tool, your call will be running in a landlock sandbox, and some shell commands will require escalated privileges:
+  - Types of actions that require escalated privileges:
+    - Reading files outside the current directory
+    - Writing files outside the current directory, and protected folders like .git or .env{}
+  - Examples of commands that require escalated privileges:
+    - git commit
+    - npm install or pnpm install
+    - cargo build
+    - cargo test
+- When invoking a command that will require escalated privileges:
+  - Provide the with_escalated_permissions parameter with the boolean value true
+  - Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter."#,
+                if !network_access {
+                    "\n  - Commands that require network access\n"
+                } else {
+                    ""
+                }
+            )
+        }
+        SandboxPolicy::DangerFullAccess => {
+            "Runs a shell command, and returns its output.".to_string()
+        }
+        SandboxPolicy::ReadOnly => {
+            r#"
+The shell tool is used to execute shell commands.
+IMPORTANT: If you are running the apply_patch command, you will need to provide the with_escalated_permissions parameter with the boolean value true.
+
+- When invoking the shell tool, your call will be running in a landlock sandbox, and some shell commands (including apply_patch) will require escalated permissions:
+  - Types of actions that require escalated privileges:
+    - Reading files outside the current directory
+    - Writing files
+    - Applying patches
+  - Examples of commands that require escalated privileges:
+    - apply_patch
+    - git commit
+    - npm install or pnpm install
+    - cargo build
+    - cargo test
+- When invoking a command that will require escalated privileges:
+  - Provide the with_escalated_permissions parameter with the boolean value true
+  - Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter"#.to_string()
+        }
+    };
+
+    OpenAiTool::Function(ResponsesApiTool {
+        name: "shell",
+        description,
+        strict: false,
+        parameters: JsonSchema::Object {
+            properties,
+            required: &["command"],
+            additional_properties: false,
+        },
+    })
+}
+
 /// Returns JSON values that are compatible with Function Calling in the
 /// Chat Completions API:
 /// https://platform.openai.com/docs/guides/function-calling?api-mode=chat
@@ -109,11 +234,12 @@ pub(crate) fn create_tools_json_for_chat_completions_api(
     prompt: &Prompt,
     model: &str,
     include_plan_tool: bool,
+    sandbox_policy: Option<SandboxPolicy>,
 ) -> crate::error::Result<Vec<serde_json::Value>> {
     // We start with the JSON for the Responses API and than rewrite it to match
     // the chat completions tool call format.
     let responses_api_tools_json =
-        create_tools_json_for_responses_api(prompt, model, include_plan_tool)?;
+        create_tools_json_for_responses_api(prompt, model, include_plan_tool, sandbox_policy)?;
     let tools_json = responses_api_tools_json
         .into_iter()
         .filter_map(|mut tool| {
@@ -163,3 +289,50 @@ fn mcp_tool_to_openai_tool(
         "type": "function",
     })
 }
+
+#[cfg(test)]
+mod tests {
+    #![allow(clippy::unwrap_used)]
+
+    use super::*;
+
+    #[test]
+    fn test_create_tools_json_for_responses_api() {
+        let prompt = Prompt {
+            ..Default::default()
+        };
+        let model = "gpt-4o-mini";
+        let include_plan_tool = true;
+        let sandbox_policy = None;
+
+        let tools_json =
+            create_tools_json_for_responses_api(&prompt, model, include_plan_tool, sandbox_policy)
+                .unwrap();
+        assert_eq!(tools_json[0]["name"], "shell");
+        let properties = tools_json[0]["parameters"]["properties"]
+            .as_object()
+            .unwrap();
+        assert!(!properties.contains_key("with_escalated_permissions"));
+        assert!(!properties.contains_key("justification"));
+    }
+
+    #[test]
+    fn test_create_tools_json_for_responses_api_with_sandbox() {
+        let prompt = Prompt {
+            ..Default::default()
+        };
+        let model = "codex-mini-latest";
+        let include_plan_tool = true;
+        let sandbox_policy = Some(SandboxPolicy::ReadOnly);
+
+        let tools_json =
+            create_tools_json_for_responses_api(&prompt, model, include_plan_tool, sandbox_policy)
+                .unwrap();
+        assert_eq!(tools_json[0]["name"], "shell");
+        let properties = tools_json[0]["parameters"]["properties"]
+            .as_object()
+            .unwrap();
+        assert!(properties.contains_key("with_escalated_permissions"));
+        assert!(properties.contains_key("justification"));
+    }
+}
diff --git a/codex-rs/core/src/plan_tool.rs b/codex-rs/core/src/plan_tool.rs
index dbddb8b5eb..190bc70cee 100644
--- a/codex-rs/core/src/plan_tool.rs
+++ b/codex-rs/core/src/plan_tool.rs
@@ -39,8 +39,11 @@ pub struct UpdatePlanArgs {
 
 pub(crate) static PLAN_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
     let mut plan_item_props = BTreeMap::new();
-    plan_item_props.insert("step".to_string(), JsonSchema::String);
-    plan_item_props.insert("status".to_string(), JsonSchema::String);
+    plan_item_props.insert("step".to_string(), JsonSchema::String { description: None });
+    plan_item_props.insert(
+        "status".to_string(),
+        JsonSchema::String { description: None },
+    );
 
     let plan_items_schema = JsonSchema::Array {
         items: Box::new(JsonSchema::Object {
@@ -51,7 +54,10 @@ pub(crate) static PLAN_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
     };
 
     let mut properties = BTreeMap::new();
-    properties.insert("explanation".to_string(), JsonSchema::String);
+    properties.insert(
+        "explanation".to_string(),
+        JsonSchema::String { description: None },
+    );
     properties.insert("plan".to_string(), plan_items_schema);
 
     OpenAiTool::Function(ResponsesApiTool {
@@ -66,7 +72,7 @@ Until all the steps are finished, there should always be exactly one in_progress
 Call the update_plan tool whenever you finish a step, marking the completed step as `completed` and marking the next step as `in_progress`.
 Before running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step.
 Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so.
-When all steps are completed, call update_plan one last time with all steps marked as `completed`."#,
+When all steps are completed, call update_plan one last time with all steps marked as `completed`."#.to_string(),
         strict: false,
         parameters: JsonSchema::Object {
             properties,
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
index 1b4832a273..090dd9ab7b 100644
--- a/codex-rs/core/src/protocol.rs
+++ b/codex-rs/core/src/protocol.rs
@@ -149,6 +149,11 @@ pub enum AskForApproval {
     /// the user to approve execution without a sandbox.
     OnFailure,
 
+    // Experimental: Commands are run inside a sandbox, and the model can
+    // proactively request escalation of privileges. Failures are handled by
+    // the model.
+    OnRequest,
+
     /// Never ask the user to approve commands. Failures are immediately returned
     /// to the model, and never escalated to the user for approval.
     Never,
diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs
index 224705f8f3..f78989b250 100644
--- a/codex-rs/core/src/safety.rs
+++ b/codex-rs/core/src/safety.rs
@@ -11,7 +11,7 @@ use crate::is_safe_command::is_known_safe_command;
 use crate::protocol::AskForApproval;
 use crate::protocol::SandboxPolicy;
 
-#[derive(Debug)]
+#[derive(Debug, PartialEq)]
 pub enum SafetyCheck {
     AutoApprove { sandbox_type: SandboxType },
     AskUser,
@@ -31,7 +31,7 @@ pub fn assess_patch_safety(
     }
 
     match policy {
-        AskForApproval::OnFailure | AskForApproval::Never => {
+        AskForApproval::OnFailure | AskForApproval::Never | AskForApproval::OnRequest => {
             // Continue to see if this can be auto-approved.
         }
         // TODO(ragona): I'm not sure this is actually correct? I believe in this case
@@ -76,6 +76,7 @@ pub fn assess_command_safety(
     approval_policy: AskForApproval,
     sandbox_policy: &SandboxPolicy,
     approved: &HashSet<Vec<String>>,
+    request_escalated_privileges: bool,
 ) -> SafetyCheck {
     // A command is "trusted" because either:
     // - it belongs to a set of commands we consider "safe" by default, or
@@ -96,12 +97,17 @@ pub fn assess_command_safety(
         };
     }
 
-    assess_safety_for_untrusted_command(approval_policy, sandbox_policy)
+    assess_safety_for_untrusted_command(
+        approval_policy,
+        sandbox_policy,
+        request_escalated_privileges,
+    )
 }
 
 pub(crate) fn assess_safety_for_untrusted_command(
     approval_policy: AskForApproval,
     sandbox_policy: &SandboxPolicy,
+    with_escalated_permissions: bool,
 ) -> SafetyCheck {
     use AskForApproval::*;
     use SandboxPolicy::*;
@@ -113,9 +119,23 @@ pub(crate) fn assess_safety_for_untrusted_command(
             // commands.
             SafetyCheck::AskUser
         }
-        (OnFailure, DangerFullAccess) | (Never, DangerFullAccess) => SafetyCheck::AutoApprove {
+        (OnFailure, DangerFullAccess)
+        | (Never, DangerFullAccess)
+        | (OnRequest, DangerFullAccess) => SafetyCheck::AutoApprove {
             sandbox_type: SandboxType::None,
         },
+        (OnRequest, ReadOnly) | (OnRequest, WorkspaceWrite { .. }) => {
+            if with_escalated_permissions {
+                SafetyCheck::AskUser
+            } else {
+                match get_platform_sandbox() {
+                    Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type },
+                    // Fall back to asking since the command is untrusted and
+                    // we do not have a sandbox available
+                    None => SafetyCheck::AskUser,
+                }
+            }
+        }
         (Never, ReadOnly)
         | (Never, WorkspaceWrite { .. })
         | (OnFailure, ReadOnly)
@@ -264,4 +284,47 @@ mod tests {
             &cwd,
         ))
     }
+
+    #[test]
+    fn test_request_escalated_privileges() {
+        // Should not be a trusted command
+        let command = vec!["git commit".to_string()];
+        let approval_policy = AskForApproval::OnRequest;
+        let sandbox_policy = SandboxPolicy::ReadOnly;
+        let approved: HashSet<Vec<String>> = HashSet::new();
+        let request_escalated_privileges = true;
+
+        let safety_check = assess_command_safety(
+            &command,
+            approval_policy,
+            &sandbox_policy,
+            &approved,
+            request_escalated_privileges,
+        );
+
+        assert_eq!(safety_check, SafetyCheck::AskUser);
+    }
+
+    #[test]
+    fn test_request_escalated_privileges_no_sandbox_fallback() {
+        let command = vec!["git commit".to_string()];
+        let approval_policy = AskForApproval::OnRequest;
+        let sandbox_policy = SandboxPolicy::ReadOnly;
+        let approved: HashSet<Vec<String>> = HashSet::new();
+        let request_escalated_privileges = false;
+
+        let safety_check = assess_command_safety(
+            &command,
+            approval_policy,
+            &sandbox_policy,
+            &approved,
+            request_escalated_privileges,
+        );
+
+        let expected = match get_platform_sandbox() {
+            Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type },
+            None => SafetyCheck::AskUser,
+        };
+        assert_eq!(safety_check, expected);
+    }
 }
diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs
index 98addffce2..1f1f3c51fb 100644
--- a/codex-rs/core/src/shell.rs
+++ b/codex-rs/core/src/shell.rs
@@ -215,6 +215,8 @@ mod tests {
                         "HOME".to_string(),
                         temp_home.path().to_str().unwrap().to_string(),
                     )]),
+                    with_escalated_permissions: None,
+                    justification: None,
                 },
                 SandboxType::None,
                 Arc::new(Notify::new()),
diff --git a/codex-rs/linux-sandbox/tests/landlock.rs b/codex-rs/linux-sandbox/tests/landlock.rs
index 7eacda46c1..e5cb66515a 100644
--- a/codex-rs/linux-sandbox/tests/landlock.rs
+++ b/codex-rs/linux-sandbox/tests/landlock.rs
@@ -44,6 +44,8 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
         cwd: std::env::current_dir().expect("cwd should exist"),
         timeout_ms: Some(timeout_ms),
         env: create_env_from_core_vars(),
+        with_escalated_permissions: None,
+        justification: None,
     };
 
     let sandbox_policy = SandboxPolicy::WorkspaceWrite {
@@ -137,6 +139,8 @@ async fn assert_network_blocked(cmd: &[&str]) {
         // do not stall the suite.
         timeout_ms: Some(NETWORK_TIMEOUT_MS),
         env: create_env_from_core_vars(),
+        with_escalated_permissions: None,
+        justification: None,
     };
 
     let sandbox_policy = SandboxPolicy::new_read_only_policy();
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index d4c2f3c12d..efaec7f19a 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -10,7 +10,6 @@ use crate::tui;
 use codex_core::config::Config;
 use codex_core::protocol::Event;
 use codex_core::protocol::EventMsg;
-use codex_core::protocol::ExecApprovalRequestEvent;
 use codex_core::protocol::Op;
 use color_eyre::eyre::Result;
 use crossterm::SynchronizedUpdate;

Review Comments

codex-rs/common/src/approval_mode_cli_arg.rs

@@ -18,6 +18,9 @@ pub enum ApprovalModeCliArg {
     /// will escalate to the user to ask for un-sandboxed execution.
     OnFailure,
 
+    /// Only ask for approval if the model requests it.

If we keep this, we should also update config.md to document this.

@@ -18,6 +18,9 @@ pub enum ApprovalModeCliArg {
     /// will escalate to the user to ask for un-sandboxed execution.
     OnFailure,
 
+    /// Only ask for approval if the model requests it.

I feel like this description is lacking something, though it's tough, because we don't have a lot of space in --help output.

Is this more accurate/meaningful?

Only ask for approval when the model believes the command needs to be run outside the sandbox.

Mainly, I'm debating whether we need to work the word "sandbox," "permissions," or "privileges" in there?

codex-rs/core/src/client.rs

@@ -76,9 +77,13 @@ impl ModelClient {
     /// Dispatches to either the Responses or Chat implementation depending on
     /// the provider config.  Public callers always invoke `stream()`  the
     /// specialised helpers are private to avoid accidental misuse.
-    pub async fn stream(&self, prompt: &Prompt) -> Result<ResponseStream> {
+    pub async fn stream(
+        &self,
+        prompt: &Prompt,
+        sandbox_policy: Option<SandboxPolicy>,

I agree that we should have an "environment information for tool formatting" struct because seeing SandboxPolicy used somewhere I don't expect causes me some anxiety.

codex-rs/core/src/codex.rs

@@ -1227,7 +1227,13 @@ async fn try_run_turn(
         })
     };
 
-    let mut stream = sess.client.clone().stream(&prompt).await?;
+    // only provide the sandbox policy if the approval policy is OnRequest
+    let sandbox_policy = if sess.approval_policy == AskForApproval::OnRequest {

Assuming we introduce a new struct, perhaps we should make this a method of Session instead, like get_tool_call_context()?

codex-rs/core/src/exec.rs

@@ -43,6 +43,8 @@ pub struct ExecParams {
     pub cwd: PathBuf,
     pub timeout_ms: Option<u64>,
     pub env: HashMap<String, String>,
+    pub with_escalated_permissions: Option<bool>,

This doesn't feel right to me: I am having trouble tracing through where this is used from pure online code review...maybe I'll pull this in locally...

codex-rs/core/src/models.rs

@@ -183,6 +183,8 @@ pub struct ShellToolCallParams {
     // The wire format uses `timeout`, which has ambiguous units, so we use
     // `timeout_ms` as the field name so it is clear in code.
     pub timeout_ms: Option<u64>,
+    pub with_escalated_permissions: Option<bool>,

Since I believe these are nonstandard, please add #[serde(skip_serializing_if = "Option::is_none")] to both of these.

Have you tested this with GPT-4.1? I'm pretty sure that was the first model that started specifying local_shell?

@@ -295,6 +297,30 @@ mod tests {
                 command: vec!["ls".to_string(), "-l".to_string()],
                 workdir: Some("/tmp".to_string()),
                 timeout_ms: Some(1000),
+                with_escalated_permissions: None,
+                justification: None,
+            },
+            params
+        );
+    }
+    #[test]

blank line please!

It looks like there are some nightly rustfmt options I need to experiment with:

https://rust-lang.github.io/rustfmt/?version=v1.8.0&search=#blank_lines_lower_bound

codex-rs/core/src/openai_tools.rs

@@ -163,3 +289,50 @@ fn mcp_tool_to_openai_tool(
         "type": "function",
     })
 }
+
+#[cfg(test)]
+mod tests {
+    #![allow(clippy::unwrap_used)]
+
+    use super::*;
+
+    #[test]
+    fn test_create_tools_json_for_responses_api() {
+        let prompt = Prompt {
+            ..Default::default()
+        };
+        let model = "gpt-4o-mini";
+        let include_plan_tool = true;
+        let sandbox_policy = None;
+
+        let tools_json =
+            create_tools_json_for_responses_api(&prompt, model, include_plan_tool, sandbox_policy)
+                .unwrap();
+        assert_eq!(tools_json[0]["name"], "shell");

Please do one assert_eq!() and use the json!() macro to construct the expected value.

@@ -163,3 +289,50 @@ fn mcp_tool_to_openai_tool(
         "type": "function",
     })
 }
+
+#[cfg(test)]
+mod tests {
+    #![allow(clippy::unwrap_used)]
+
+    use super::*;
+
+    #[test]
+    fn test_create_tools_json_for_responses_api() {
+        let prompt = Prompt {
+            ..Default::default()
+        };
+        let model = "gpt-4o-mini";
+        let include_plan_tool = true;
+        let sandbox_policy = None;
+
+        let tools_json =
+            create_tools_json_for_responses_api(&prompt, model, include_plan_tool, sandbox_policy)
+                .unwrap();
+        assert_eq!(tools_json[0]["name"], "shell");
+        let properties = tools_json[0]["parameters"]["properties"]
+            .as_object()
+            .unwrap();
+        assert!(!properties.contains_key("with_escalated_permissions"));
+        assert!(!properties.contains_key("justification"));
+    }
+
+    #[test]
+    fn test_create_tools_json_for_responses_api_with_sandbox() {
+        let prompt = Prompt {
+            ..Default::default()
+        };
+        let model = "codex-mini-latest";
+        let include_plan_tool = true;
+        let sandbox_policy = Some(SandboxPolicy::ReadOnly);
+
+        let tools_json =

Same here.

@@ -72,13 +89,18 @@ static DEFAULT_CODEX_MODEL_TOOLS: LazyLock<Vec<OpenAiTool>> =
 /// Returns JSON values that are compatible with Function Calling in the
 /// Responses API:
 /// https://platform.openai.com/docs/guides/function-calling?api-mode=responses
+/// TODO: we're starting to get more complex with our tool config, let's
 pub(crate) fn create_tools_json_for_responses_api(
     prompt: &Prompt,
     model: &str,
     include_plan_tool: bool,
+    sandbox_policy: Option<SandboxPolicy>,
 ) -> crate::error::Result<Vec<serde_json::Value>> {
     // Assemble tool list: built-in tools + any extra tools from the prompt.
-    let default_tools = if model.starts_with("codex") {
+    let default_tools = if let Some(sandbox_policy) = sandbox_policy {
+        // if sandbox_policy is provided, create the shell tool
+        &vec![create_shell_tool_for_sandbox(sandbox_policy)]
+    } else if model.contains("codex") {

Now that things are getting more dynamic, let's just get rid of DEFAULT_TOOLS and DEFAULT_CODEX_MODEL_TOOLS and just always built the Vec here.

@@ -102,18 +124,122 @@ pub(crate) fn create_tools_json_for_responses_api(
     Ok(tools_json)
 }
 
+fn create_shell_tool_for_sandbox(sandbox_policy: SandboxPolicy) -> OpenAiTool {
+    let mut properties = BTreeMap::new();
+    properties.insert(
+        "command".to_string(),
+        JsonSchema::Array {
+            items: Box::new(JsonSchema::String {
+                description: Some("The command to execute".to_string()),
+            }),
+        },
+    );
+    properties.insert(
+        "workdir".to_string(),
+        JsonSchema::String {
+            description: Some("The working directory to execute the command in".to_string()),
+        },
+    );
+    properties.insert(
+        "timeout".to_string(),
+        JsonSchema::Number {
+            description: Some("The timeout for the command in milliseconds".to_string()),
+        },
+    );
+
+    if sandbox_policy != SandboxPolicy::DangerFullAccess {
+        properties.insert(
+        "with_escalated_permissions".to_string(),
+        JsonSchema::Boolean {
+            description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()),
+        },
+    );
+        properties.insert(
+        "justification".to_string(),
+        JsonSchema::String {
+            description: Some("Only set if ask_for_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()),
+        },
+    );
+    }
+
+    let description = match sandbox_policy {
+        SandboxPolicy::WorkspaceWrite {
+            writable_roots: _,
+            network_access,
+        } => {
+            format!(
+                r#"
+The shell tool is used to execute shell commands.
+
+- When invoking the shell tool, your call will be running in a landlock sandbox, and some shell commands will require escalated privileges:
+  - Types of actions that require escalated privileges:
+    - Reading files outside the current directory
+    - Writing files outside the current directory, and protected folders like .git or .env{}
+  - Examples of commands that require escalated privileges:
+    - git commit
+    - npm install or pnpm install
+    - cargo build
+    - cargo test
+- When invoking a command that will require escalated privileges:
+  - Provide the with_escalated_permissions parameter with the boolean value true
+  - Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter."#,
+                if !network_access {
+                    "\n  - Commands that require network access\n"
+                } else {
+                    ""
+                }
+            )
+        }
+        SandboxPolicy::DangerFullAccess => {
+            "Runs a shell command, and returns its output.".to_string()

Grammar: no comma.

            "Runs a shell command and returns its output.".to_string()

codex-rs/core/src/safety.rs

@@ -264,4 +284,47 @@ mod tests {
             &cwd,
         ))
     }
+
+    #[test]
+    fn test_request_escalated_privileges() {
+        // Should not be a trusted command
+        let command = vec!["git commit".to_string()];

To make this more realistic:

        let command = vec!["git".to_string(), "commit".to_string()];