diff --git a/codex-rs/core/src/bash.rs b/codex-rs/core/src/bash.rs index e85ce76a5..edeebaba3 100644 --- a/codex-rs/core/src/bash.rs +++ b/codex-rs/core/src/bash.rs @@ -104,7 +104,7 @@ pub fn extract_bash_command(command: &[String]) -> Option<(&str, &str)> { let [shell, flag, script] = command else { return None; }; - if flag != "-lc" || !is_well_known_sh_shell(shell) { + if !matches!(flag.as_str(), "-lc" | "-c") || !is_well_known_sh_shell(shell) { return None; } Some((shell, script)) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 9efff1da4..2a49eb1ed 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -29,6 +29,9 @@ pub enum Stage { pub enum Feature { /// Use the single unified PTY-backed exec tool. UnifiedExec, + /// Use the shell command tool that takes `command` as a single string of + /// shell instead of an array of args passed to `execvp(3)`. + ShellCommandTool, /// Enable experimental RMCP features such as OAuth login. RmcpClient, /// Include the freeform apply_patch tool. @@ -250,6 +253,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, + FeatureSpec { + id: Feature::ShellCommandTool, + key: "shell_command_tool", + stage: Stage::Experimental, + default_enabled: false, + }, FeatureSpec { id: Feature::RmcpClient, key: "rmcp_client", diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 197c00bad..90f6c6b97 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -31,16 +31,37 @@ pub enum Shell { impl Shell { pub fn name(&self) -> Option { match self { - Shell::Zsh(zsh) => std::path::Path::new(&zsh.shell_path) - .file_name() - .map(|s| s.to_string_lossy().to_string()), - Shell::Bash(bash) => std::path::Path::new(&bash.shell_path) - .file_name() - .map(|s| s.to_string_lossy().to_string()), + Shell::Zsh(ZshShell { shell_path, .. }) | Shell::Bash(BashShell { shell_path, .. }) => { + std::path::Path::new(shell_path) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + } Shell::PowerShell(ps) => Some(ps.exe.clone()), Shell::Unknown => None, } } + + /// Takes a string of shell and returns the full list of command args to + /// use with `exec()` to run the shell command. + pub fn derive_exec_args(&self, command: &str, use_login_shell: bool) -> Vec { + match self { + Shell::Zsh(ZshShell { shell_path, .. }) | Shell::Bash(BashShell { shell_path, .. }) => { + let arg = if use_login_shell { "-lc" } else { "-c" }; + vec![shell_path.clone(), arg.to_string(), command.to_string()] + } + Shell::PowerShell(ps) => { + let mut args = vec![ps.exe.clone(), "-NoLogo".to_string()]; + if !use_login_shell { + args.push("-NoProfile".to_string()); + } + + args.push("-Command".to_string()); + args.push(command.to_string()); + args + } + Shell::Unknown => shlex::split(command).unwrap_or_else(|| vec![command.to_string()]), + } + } } #[cfg(unix)] diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 28a84f23f..e894a0844 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -63,27 +63,10 @@ impl SessionTask for UserShellCommandTask { // Execute the user's script under their default shell when known; this // allows commands that use shell features (pipes, &&, redirects, etc.). // We do not source rc files or otherwise reformat the script. - let shell_invocation = match session.user_shell() { - crate::shell::Shell::Zsh(zsh) => vec![ - zsh.shell_path.clone(), - "-lc".to_string(), - self.command.clone(), - ], - crate::shell::Shell::Bash(bash) => vec![ - bash.shell_path.clone(), - "-lc".to_string(), - self.command.clone(), - ], - crate::shell::Shell::PowerShell(ps) => vec![ - ps.exe.clone(), - "-NoProfile".to_string(), - "-Command".to_string(), - self.command.clone(), - ], - crate::shell::Shell::Unknown => { - shlex::split(&self.command).unwrap_or_else(|| vec![self.command.clone()]) - } - }; + let use_login_shell = true; + let shell_invocation = session + .user_shell() + .derive_exec_args(&self.command, use_login_shell); let call_id = Uuid::new_v4().to_string(); let raw_command = self.command.clone(); diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 187b44441..dcf848e37 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -19,6 +19,7 @@ pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; pub use read_file::ReadFileHandler; +pub use shell::ShellCommandHandler; pub use shell::ShellHandler; pub use test_sync::TestSyncHandler; pub use unified_exec::UnifiedExecHandler; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index b97242a9a..81915fc12 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -1,4 +1,5 @@ use async_trait::async_trait; +use codex_protocol::models::ShellCommandToolCallParams; use codex_protocol::models::ShellToolCallParams; use std::sync::Arc; @@ -25,6 +26,8 @@ use crate::tools::sandboxing::ToolCtx; pub struct ShellHandler; +pub struct ShellCommandHandler; + impl ShellHandler { fn to_exec_params(params: ShellToolCallParams, turn_context: &TurnContext) -> ExecParams { ExecParams { @@ -39,6 +42,28 @@ impl ShellHandler { } } +impl ShellCommandHandler { + fn to_exec_params( + params: ShellCommandToolCallParams, + session: &crate::codex::Session, + turn_context: &TurnContext, + ) -> ExecParams { + let shell = session.user_shell(); + let use_login_shell = true; + let command = shell.derive_exec_args(¶ms.command, use_login_shell); + + ExecParams { + command, + cwd: turn_context.resolve_path(params.workdir.clone()), + timeout_ms: params.timeout_ms, + env: create_env(&turn_context.shell_environment_policy), + with_escalated_permissions: params.with_escalated_permissions, + justification: params.justification, + arg0: None, + } + } +} + #[async_trait] impl ToolHandler for ShellHandler { fn kind(&self) -> ToolKind { @@ -102,6 +127,49 @@ impl ToolHandler for ShellHandler { } } +#[async_trait] +impl ToolHandler for ShellCommandHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + tracker, + call_id, + tool_name, + payload, + } = invocation; + + let ToolPayload::Function { arguments } = payload else { + return Err(FunctionCallError::RespondToModel(format!( + "unsupported payload for shell_command handler: {tool_name}" + ))); + }; + + let params: ShellCommandToolCallParams = serde_json::from_str(&arguments).map_err(|e| { + FunctionCallError::RespondToModel(format!("failed to parse function arguments: {e:?}")) + })?; + let exec_params = Self::to_exec_params(params, session.as_ref(), turn.as_ref()); + ShellHandler::run_exec_like( + tool_name.as_str(), + exec_params, + session, + turn, + tracker, + call_id, + false, + ) + .await + } +} + impl ShellHandler { async fn run_exec_like( tool_name: &str, @@ -240,3 +308,49 @@ impl ShellHandler { }) } } + +#[cfg(test)] +mod tests { + use crate::is_safe_command::is_known_safe_command; + use crate::shell::BashShell; + use crate::shell::Shell; + use crate::shell::ZshShell; + + /// The logic for is_known_safe_command() has heuristics for known shells, + /// so we must ensure the commands generated by [ShellCommandHandler] can be + /// recognized as safe if the `command` is safe. + #[test] + fn commands_generated_by_shell_command_handler_can_be_matched_by_is_known_safe_command() { + let bash_shell = Shell::Bash(BashShell { + shell_path: "/bin/bash".to_string(), + bashrc_path: "/home/user/.bashrc".to_string(), + }); + assert_safe(&bash_shell, "ls -la"); + + let zsh_shell = Shell::Zsh(ZshShell { + shell_path: "/bin/zsh".to_string(), + zshrc_path: "/home/user/.zshrc".to_string(), + }); + assert_safe(&zsh_shell, "ls -la"); + + #[cfg(target_os = "windows")] + { + use crate::shell::PowerShellConfig; + + let powershell = Shell::PowerShell(PowerShellConfig { + exe: "pwsh.exe".to_string(), + bash_exe_fallback: None, + }); + assert_safe(&powershell, "ls -Name"); + } + } + + fn assert_safe(shell: &Shell, command: &str) { + assert!(is_known_safe_command( + &shell.derive_exec_args(command, /* use_login_shell */ true) + )); + assert!(is_known_safe_command( + &shell.derive_exec_args(command, /* use_login_shell */ false) + )); + } +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 471f42c08..82ef57d65 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -20,6 +20,8 @@ pub enum ConfigShellToolType { Default, Local, UnifiedExec, + /// Takes a command as a single string to be run in the user's default shell. + ShellCommand, } #[derive(Debug, Clone)] @@ -48,6 +50,8 @@ impl ToolsConfig { let shell_type = if features.enabled(Feature::UnifiedExec) { ConfigShellToolType::UnifiedExec + } else if features.enabled(Feature::ShellCommandTool) { + ConfigShellToolType::ShellCommand } else { model_family.shell_type.clone() }; @@ -302,6 +306,53 @@ fn create_shell_tool() -> ToolSpec { }) } +fn create_shell_command_tool() -> ToolSpec { + let mut properties = BTreeMap::new(); + properties.insert( + "command".to_string(), + JsonSchema::String { + description: Some( + "The shell script to execute in the user's default shell".to_string(), + ), + }, + ); + properties.insert( + "workdir".to_string(), + JsonSchema::String { + description: Some("The working directory to execute the command in".to_string()), + }, + ); + properties.insert( + "timeout_ms".to_string(), + JsonSchema::Number { + description: Some("The timeout for the command in milliseconds".to_string()), + }, + ); + 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 with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()), + }, + ); + + ToolSpec::Function(ResponsesApiTool { + name: "shell_command".to_string(), + description: "Runs a shell command string and returns its output.".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["command".to_string()]), + additional_properties: Some(false.into()), + }, + }) +} + fn create_view_image_tool() -> ToolSpec { // Support only local filesystem path. let mut properties = BTreeMap::new(); @@ -891,6 +942,7 @@ pub(crate) fn build_specs( use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadFileHandler; + use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellHandler; use crate::tools::handlers::TestSyncHandler; use crate::tools::handlers::UnifiedExecHandler; @@ -906,6 +958,7 @@ pub(crate) fn build_specs( let view_image_handler = Arc::new(ViewImageHandler); let mcp_handler = Arc::new(McpHandler); let mcp_resource_handler = Arc::new(McpResourceHandler); + let shell_command_handler = Arc::new(ShellCommandHandler); match &config.shell_type { ConfigShellToolType::Default => { @@ -920,12 +973,16 @@ pub(crate) fn build_specs( builder.register_handler("exec_command", unified_exec_handler.clone()); builder.register_handler("write_stdin", unified_exec_handler); } + ConfigShellToolType::ShellCommand => { + builder.push_spec(create_shell_command_tool()); + } } // Always register shell aliases so older prompts remain compatible. builder.register_handler("shell", shell_handler.clone()); builder.register_handler("container.exec", shell_handler.clone()); builder.register_handler("local_shell", shell_handler); + builder.register_handler("shell_command", shell_command_handler); builder.push_spec_with_parallel_support(create_list_mcp_resources_tool(), true); builder.push_spec_with_parallel_support(create_list_mcp_resource_templates_tool(), true); @@ -1061,6 +1118,7 @@ mod tests { ConfigShellToolType::Default => Some("shell"), ConfigShellToolType::Local => Some("local_shell"), ConfigShellToolType::UnifiedExec => None, + ConfigShellToolType::ShellCommand => Some("shell_command"), } } @@ -1293,6 +1351,22 @@ mod tests { assert_contains_tool_names(&tools, &subset); } + #[test] + fn test_build_specs_shell_command_present() { + assert_model_tools( + "codex-mini-latest", + Features::with_defaults().enable(Feature::ShellCommandTool), + &[ + "shell_command", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "view_image", + ], + ); + } + #[test] #[ignore] fn test_parallel_support_flags() { @@ -1748,6 +1822,21 @@ mod tests { assert_eq!(description, expected); } + #[test] + fn test_shell_command_tool() { + let tool = super::create_shell_command_tool(); + let ToolSpec::Function(ResponsesApiTool { + description, name, .. + }) = &tool + else { + panic!("expected function tool"); + }; + assert_eq!(name, "shell_command"); + + let expected = "Runs a shell command string and returns its output."; + assert_eq!(description, expected); + } + #[test] fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() { let model_family = find_family_for_model("gpt-5-codex") diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index a824ee91e..f44d84709 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -292,7 +292,7 @@ impl From> for ResponseInputItem { } /// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec` -/// or shell`, the `arguments` field should deserialize to this struct. +/// or `shell`, the `arguments` field should deserialize to this struct. #[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct ShellToolCallParams { pub command: Vec, @@ -307,6 +307,22 @@ pub struct ShellToolCallParams { pub justification: Option, } +/// If the `name` of a `ResponseItem::FunctionCall` is `shell_command`, the +/// `arguments` field should deserialize to this struct. +#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +pub struct ShellCommandToolCallParams { + pub command: String, + pub workdir: Option, + + /// This is the maximum time in milliseconds that the command is allowed to run. + #[serde(alias = "timeout")] + pub timeout_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub with_escalated_permissions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub justification: Option, +} + /// Responses API compatible content items that can be returned by a tool call. /// This is a subset of ContentItem with the types we support as function call outputs. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]