diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 732f9590f..97ce03cad 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1392,6 +1392,10 @@ ], "description": "Agent-related settings (thread limits, etc.)." }, + "allow_login_shell": { + "description": "Whether the model may request a login shell for shell-based tools. Default to `true`\n\nIf `true`, the model may request a login shell (`login = true`), and omitting `login` defaults to using a login shell. If `false`, the model can never use a login shell: `login = true` requests are rejected, and omitting `login` defaults to a non-login shell.", + "type": "boolean" + }, "analytics": { "allOf": [ { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b5614cced..2d5d283d6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -609,6 +609,7 @@ impl TurnContext { features: &features, web_search_mode: self.tools_config.web_search_mode, }) + .with_allow_login_shell(self.tools_config.allow_login_shell) .with_agent_roles(config.agent_roles.clone()); Self { @@ -941,6 +942,7 @@ impl Session { features: &per_turn_config.features, web_search_mode: Some(per_turn_config.web_search_mode.value()), }) + .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) .with_agent_roles(per_turn_config.agent_roles.clone()); let cwd = session_configuration.cwd.clone(); @@ -4122,6 +4124,7 @@ async fn spawn_review_thread( features: &review_features, web_search_mode: Some(review_web_search_mode), }) + .with_allow_login_shell(config.permissions.allow_login_shell) .with_agent_roles(config.agent_roles.clone()); let review_prompt = resolved.prompt.clone(); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1675067c7..3b8e0382e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -139,6 +139,15 @@ pub struct Permissions { pub sandbox_policy: Constrained, /// Effective network configuration applied to all spawned processes. pub network: Option, + /// Whether the model may request a login shell for shell-based tools. + /// Default to `true` + /// + /// If `true`, the model may request a login shell (`login = true`), and + /// omitting `login` defaults to using a login shell. + /// If `false`, the model can never use a login shell: `login = true` + /// requests are rejected, and omitting `login` defaults to a non-login + /// shell. + pub allow_login_shell: bool, /// Policy used to build process environments for shell/unified exec. pub shell_environment_policy: ShellEnvironmentPolicy, /// Effective Windows sandbox mode derived from `[windows].sandbox` or @@ -952,6 +961,16 @@ pub struct ConfigToml { #[serde(default)] pub shell_environment_policy: ShellEnvironmentPolicyToml, + /// Whether the model may request a login shell for shell-based tools. + /// Default to `true` + /// + /// If `true`, the model may request a login shell (`login = true`), and + /// omitting `login` defaults to using a login shell. + /// If `false`, the model can never use a login shell: `login = true` + /// requests are rejected, and omitting `login` defaults to a non-login + /// shell. + pub allow_login_shell: Option, + /// Sandbox mode to use. pub sandbox_mode: Option, @@ -1710,6 +1729,7 @@ impl Config { .clone(); let shell_environment_policy = cfg.shell_environment_policy.into(); + let allow_login_shell = cfg.allow_login_shell.unwrap_or(true); let history = cfg.history.unwrap_or_default(); @@ -1950,6 +1970,7 @@ impl Config { approval_policy: constrained_approval_policy.value, sandbox_policy: constrained_sandbox_policy.value, network, + allow_login_shell, shell_environment_policy, windows_sandbox_mode, macos_seatbelt_profile_extensions: None, @@ -4519,6 +4540,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -4637,6 +4659,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -4753,6 +4776,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -4855,6 +4879,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -5429,6 +5454,27 @@ mcp_oauth_callback_port = 5678 Ok(()) } + #[test] + fn config_loads_allow_login_shell_from_toml() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg: ConfigToml = toml::from_str( + r#" +model = "gpt-5.1" +allow_login_shell = false +"#, + ) + .expect("TOML deserialization should succeed for allow_login_shell"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert!(!config.permissions.allow_login_shell); + Ok(()) + } + #[test] fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/memories/usage.rs b/codex-rs/core/src/memories/usage.rs index dbdcd2573..ff82d6e16 100644 --- a/codex-rs/core/src/memories/usage.rs +++ b/codex-rs/core/src/memories/usage.rs @@ -84,22 +84,28 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec serde_json::from_str::(arguments) .ok() .map(|params| { + if !invocation.turn.tools_config.allow_login_shell && params.login == Some(true) { + return (Vec::new(), invocation.turn.resolve_path(params.workdir)); + } + let use_login_shell = params + .login + .unwrap_or(invocation.turn.tools_config.allow_login_shell); let command = invocation .session .user_shell() - .derive_exec_args(¶ms.command, params.login.unwrap_or(true)); + .derive_exec_args(¶ms.command, use_login_shell); (command, invocation.turn.resolve_path(params.workdir)) }), "exec_command" => serde_json::from_str::(arguments) .ok() - .map(|params| { - ( - crate::tools::handlers::unified_exec::get_command( - ¶ms, - invocation.session.user_shell(), - ), - invocation.turn.resolve_path(params.workdir), + .and_then(|params| { + let command = crate::tools::handlers::unified_exec::get_command( + ¶ms, + invocation.session.user_shell(), + invocation.turn.tools_config.allow_login_shell, ) + .ok()?; + Some((command, invocation.turn.resolve_path(params.workdir))) }), _ => None, } diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index e00bd4297..3c497bfcf 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -1372,6 +1372,7 @@ permissions: } ), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions, @@ -1408,6 +1409,7 @@ permissions: {} crate::protocol::SandboxPolicy::new_read_only_policy(), ), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: Some( @@ -1421,6 +1423,7 @@ permissions: {} crate::protocol::SandboxPolicy::new_read_only_policy(), ), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -1515,6 +1518,7 @@ permissions: crate::protocol::SandboxPolicy::new_read_only_policy(), ), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, diff --git a/codex-rs/core/src/skills/permissions.rs b/codex-rs/core/src/skills/permissions.rs index 1cf18e105..24f3c9444 100644 --- a/codex-rs/core/src/skills/permissions.rs +++ b/codex-rs/core/src/skills/permissions.rs @@ -112,6 +112,7 @@ pub(crate) fn compile_permission_profile( approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(sandbox_policy), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions, @@ -347,6 +348,7 @@ mod tests { exclude_slash_tmp: false, }), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, #[cfg(target_os = "macos")] @@ -391,6 +393,7 @@ mod tests { approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, #[cfg(target_os = "macos")] @@ -439,6 +442,7 @@ mod tests { }, }), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, #[cfg(target_os = "macos")] diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 8ca6de0d8..f040b4ba6 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -63,8 +63,20 @@ impl ShellHandler { } impl ShellCommandHandler { - fn base_command(shell: &Shell, command: &str, login: Option) -> Vec { - let use_login_shell = login.unwrap_or(true); + fn resolve_use_login_shell( + login: Option, + allow_login_shell: bool, + ) -> Result { + if !allow_login_shell && login == Some(true) { + return Err(FunctionCallError::RespondToModel( + "login shell is disabled by config; omit `login` or set it to false.".to_string(), + )); + } + + Ok(login.unwrap_or(allow_login_shell)) + } + + fn base_command(shell: &Shell, command: &str, use_login_shell: bool) -> Vec { shell.derive_exec_args(command, use_login_shell) } @@ -73,11 +85,13 @@ impl ShellCommandHandler { session: &crate::codex::Session, turn_context: &TurnContext, thread_id: ThreadId, - ) -> ExecParams { + allow_login_shell: bool, + ) -> Result { let shell = session.user_shell(); - let command = Self::base_command(shell.as_ref(), ¶ms.command, params.login); + let use_login_shell = Self::resolve_use_login_shell(params.login, allow_login_shell)?; + let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell); - ExecParams { + Ok(ExecParams { command, cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), @@ -87,7 +101,7 @@ impl ShellCommandHandler { windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), arg0: None, - } + }) } } @@ -183,8 +197,15 @@ impl ToolHandler for ShellCommandHandler { serde_json::from_str::(arguments) .map(|params| { + let use_login_shell = match Self::resolve_use_login_shell( + params.login, + invocation.turn.tools_config.allow_login_shell, + ) { + Ok(use_login_shell) => use_login_shell, + Err(_) => return true, + }; let shell = invocation.session.user_shell(); - let command = Self::base_command(shell.as_ref(), ¶ms.command, params.login); + let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell); !is_known_safe_command(&command) }) .unwrap_or(true) @@ -213,7 +234,8 @@ impl ToolHandler for ShellCommandHandler { session.as_ref(), turn.as_ref(), session.conversation_id, - ); + turn.tools_config.allow_login_shell, + )?; ShellHandler::run_exec_like(RunExecLikeArgs { tool_name, exec_params, @@ -439,7 +461,9 @@ mod tests { &session, &turn_context, session.conversation_id, - ); + true, + ) + .expect("login shells should be allowed"); // ExecParams cannot derive Eq due to the CancellationToken field, so we manually compare the fields. assert_eq!(exec_params.command, expected_command); @@ -464,18 +488,57 @@ mod tests { shell_snapshot, }; - let login_command = - ShellCommandHandler::base_command(&shell, "echo login shell", Some(true)); + let login_command = ShellCommandHandler::base_command(&shell, "echo login shell", true); assert_eq!( login_command, shell.derive_exec_args("echo login shell", true) ); let non_login_command = - ShellCommandHandler::base_command(&shell, "echo non login shell", Some(false)); + ShellCommandHandler::base_command(&shell, "echo non login shell", false); assert_eq!( non_login_command, shell.derive_exec_args("echo non login shell", false) ); } + + #[tokio::test] + async fn shell_command_handler_defaults_to_non_login_when_disallowed() { + let (session, turn_context) = make_session_and_context().await; + let params = ShellCommandToolCallParams { + command: "echo hello".to_string(), + workdir: None, + login: None, + timeout_ms: None, + sandbox_permissions: None, + prefix_rule: None, + justification: None, + }; + + let exec_params = ShellCommandHandler::to_exec_params( + ¶ms, + &session, + &turn_context, + session.conversation_id, + false, + ) + .expect("non-login shells should still be allowed"); + + assert_eq!( + exec_params.command, + session.user_shell().derive_exec_args("echo hello", false) + ); + } + + #[test] + fn shell_command_handler_rejects_login_when_disallowed() { + let err = ShellCommandHandler::resolve_use_login_shell(Some(true), false) + .expect_err("explicit login should be rejected"); + + assert!( + err.to_string() + .contains("login shell is disabled by config"), + "unexpected error: {err}" + ); + } } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 1bad6fa63..62da64a87 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -32,8 +32,8 @@ pub(crate) struct ExecCommandArgs { pub(crate) workdir: Option, #[serde(default)] shell: Option, - #[serde(default = "default_login")] - login: bool, + #[serde(default)] + login: Option, #[serde(default = "default_tty")] tty: bool, #[serde(default = "default_exec_yield_time_ms")] @@ -68,10 +68,6 @@ fn default_write_stdin_yield_time_ms() -> u64 { 250 } -fn default_login() -> bool { - true -} - fn default_tty() -> bool { false } @@ -98,7 +94,14 @@ impl ToolHandler for UnifiedExecHandler { let Ok(params) = serde_json::from_str::(arguments) else { return true; }; - let command = get_command(¶ms, invocation.session.user_shell()); + let command = match get_command( + ¶ms, + invocation.session.user_shell(), + invocation.turn.tools_config.allow_login_shell, + ) { + Ok(command) => command, + Err(_) => return true, + }; !is_known_safe_command(&command) } @@ -129,7 +132,12 @@ impl ToolHandler for UnifiedExecHandler { "exec_command" => { let args: ExecCommandArgs = parse_arguments(&arguments)?; let process_id = manager.allocate_process_id().await; - let command = get_command(&args, session.user_shell()); + let command = get_command( + &args, + session.user_shell(), + turn.tools_config.allow_login_shell, + ) + .map_err(FunctionCallError::RespondToModel)?; let ExecCommandArgs { workdir, @@ -238,7 +246,11 @@ impl ToolHandler for UnifiedExecHandler { } } -pub(crate) fn get_command(args: &ExecCommandArgs, session_shell: Arc) -> Vec { +pub(crate) fn get_command( + args: &ExecCommandArgs, + session_shell: Arc, + allow_login_shell: bool, +) -> Result, String> { let model_shell = args.shell.as_ref().map(|shell_str| { let mut shell = get_shell_by_model_provided_path(&PathBuf::from(shell_str)); shell.shell_snapshot = crate::shell::empty_shell_snapshot_receiver(); @@ -246,8 +258,17 @@ pub(crate) fn get_command(args: &ExecCommandArgs, session_shell: Arc) -> }); let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref()); + let use_login_shell = match args.login { + Some(true) if !allow_login_shell => { + return Err( + "login shell is disabled by config; omit `login` or set it to false.".to_string(), + ); + } + Some(use_login_shell) => use_login_shell, + None => allow_login_shell, + }; - shell.derive_exec_args(&args.cmd, args.login) + Ok(shell.derive_exec_args(&args.cmd, use_login_shell)) } fn format_response(response: &UnifiedExecResponse) -> String { @@ -283,6 +304,7 @@ fn format_response(response: &UnifiedExecResponse) -> String { mod tests { use super::*; use crate::shell::default_user_shell; + use pretty_assertions::assert_eq; use std::sync::Arc; #[test] @@ -293,7 +315,8 @@ mod tests { assert!(args.shell.is_none()); - let command = get_command(&args, Arc::new(default_user_shell())); + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; assert_eq!(command.len(), 3); assert_eq!(command[2], "echo hello"); @@ -308,7 +331,8 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("/bin/bash")); - let command = get_command(&args, Arc::new(default_user_shell())); + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; assert_eq!(command.last(), Some(&"echo hello".to_string())); if command @@ -328,7 +352,8 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("powershell")); - let command = get_command(&args, Arc::new(default_user_shell())); + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; assert_eq!(command[2], "echo hello"); Ok(()) @@ -342,9 +367,25 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("cmd")); - let command = get_command(&args, Arc::new(default_user_shell())); + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; assert_eq!(command[2], "echo hello"); Ok(()) } + + #[test] + fn test_get_command_rejects_explicit_login_when_disallowed() -> anyhow::Result<()> { + let json = r#"{"cmd": "echo hello", "login": true}"#; + + let args: ExecCommandArgs = parse_arguments(json)?; + let err = get_command(&args, Arc::new(default_user_shell()), false) + .expect_err("explicit login should be rejected"); + + assert!( + err.contains("login shell is disabled by config"), + "unexpected error: {err}" + ); + Ok(()) + } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index fe9c7ee8a..7bd1fc978 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -35,6 +35,7 @@ const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str = #[derive(Debug, Clone)] pub(crate) struct ToolsConfig { pub shell_type: ConfigShellToolType, + pub allow_login_shell: bool, pub apply_patch_tool_type: Option, pub web_search_mode: Option, pub agent_roles: BTreeMap, @@ -96,6 +97,7 @@ impl ToolsConfig { Self { shell_type, + allow_login_shell: true, apply_patch_tool_type, web_search_mode: *web_search_mode, agent_roles: BTreeMap::new(), @@ -112,6 +114,11 @@ impl ToolsConfig { self.agent_roles = agent_roles; self } + + pub fn with_allow_login_shell(mut self, allow_login_shell: bool) -> Self { + self.allow_login_shell = allow_login_shell; + self + } } /// Generic JSON‑Schema subset needed for our tool definitions @@ -211,7 +218,7 @@ fn create_approval_parameters() -> BTreeMap { properties } -fn create_exec_command_tool() -> ToolSpec { +fn create_exec_command_tool(allow_login_shell: bool) -> ToolSpec { let mut properties = BTreeMap::from([ ( "cmd".to_string(), @@ -234,14 +241,6 @@ fn create_exec_command_tool() -> ToolSpec { description: Some("Shell binary to launch. Defaults to the user's default shell.".to_string()), }, ), - ( - "login".to_string(), - JsonSchema::Boolean { - description: Some( - "Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(), - ), - }, - ), ( "tty".to_string(), JsonSchema::Boolean { @@ -269,6 +268,16 @@ fn create_exec_command_tool() -> ToolSpec { }, ), ]); + if allow_login_shell { + properties.insert( + "login".to_string(), + JsonSchema::Boolean { + description: Some( + "Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(), + ), + }, + ); + } properties.extend(create_approval_parameters()); ToolSpec::Function(ResponsesApiTool { @@ -385,7 +394,7 @@ Examples of valid command strings: }) } -fn create_shell_command_tool() -> ToolSpec { +fn create_shell_command_tool(allow_login_shell: bool) -> ToolSpec { let mut properties = BTreeMap::from([ ( "command".to_string(), @@ -402,6 +411,14 @@ fn create_shell_command_tool() -> ToolSpec { }, ), ( + "timeout_ms".to_string(), + JsonSchema::Number { + description: Some("The timeout for the command in milliseconds".to_string()), + }, + ), + ]); + if allow_login_shell { + properties.insert( "login".to_string(), JsonSchema::Boolean { description: Some( @@ -409,14 +426,8 @@ fn create_shell_command_tool() -> ToolSpec { .to_string(), ), }, - ), - ( - "timeout_ms".to_string(), - JsonSchema::Number { - description: Some("The timeout for the command in milliseconds".to_string()), - }, - ), - ]); + ); + } properties.extend(create_approval_parameters()); let description = if cfg!(windows) { @@ -1462,7 +1473,10 @@ pub(crate) fn build_specs( builder.push_spec_with_parallel_support(ToolSpec::LocalShell {}, true); } ConfigShellToolType::UnifiedExec => { - builder.push_spec_with_parallel_support(create_exec_command_tool(), true); + builder.push_spec_with_parallel_support( + create_exec_command_tool(config.allow_login_shell), + true, + ); builder.push_spec(create_write_stdin_tool()); builder.register_handler("exec_command", unified_exec_handler.clone()); builder.register_handler("write_stdin", unified_exec_handler); @@ -1471,7 +1485,10 @@ pub(crate) fn build_specs( // Do nothing. } ConfigShellToolType::ShellCommand => { - builder.push_spec_with_parallel_support(create_shell_command_tool(), true); + builder.push_spec_with_parallel_support( + create_shell_command_tool(config.allow_login_shell), + true, + ); } } @@ -1816,7 +1833,7 @@ mod tests { // Build expected from the same helpers used by the builder. let mut expected: BTreeMap = BTreeMap::from([]); for spec in [ - create_exec_command_tool(), + create_exec_command_tool(true), create_write_stdin_tool(), PLAN_TOOL.clone(), create_request_user_input_tool(), @@ -2790,7 +2807,7 @@ Examples of valid command strings: #[test] fn test_shell_command_tool() { - let tool = super::create_shell_command_tool(); + let tool = super::create_shell_command_tool(true); let ToolSpec::Function(ResponsesApiTool { description, name, .. }) = &tool