Codex Handbook
core/src/tools/handlers/shell_spec.rs 411 lines
use codex_tools::JsonSchema;use codex_tools::ResponsesApiTool;use codex_tools::ToolSpec;use serde_json::Value;use serde_json::json;use std::collections::BTreeMap;#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub struct CommandToolOptions {    pub allow_login_shell: bool,    pub exec_permission_approvals_enabled: bool,}#[cfg(test)]pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec {    create_exec_command_tool_with_environment_id(        options, /*include_environment_id*/ false, /*include_shell_parameter*/ true,    )}pub(crate) fn create_exec_command_tool_with_environment_id(    options: CommandToolOptions,    include_environment_id: bool,    include_shell_parameter: bool,) -> ToolSpec {    let mut properties = BTreeMap::from([        (            "cmd".to_string(),            JsonSchema::string(Some("Shell command to execute.".to_string())),        ),        (            "workdir".to_string(),            JsonSchema::string(Some(                "Working directory for the command. Defaults to the turn cwd."                    .to_string(),            )),        ),        (            "tty".to_string(),            JsonSchema::boolean(Some(                "True allocates a PTY for the command; false or omitted uses plain pipes."                    .to_string(),            )),        ),        (            "yield_time_ms".to_string(),            JsonSchema::number(Some(                "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms.".to_string(),            )),        ),        (            "max_output_tokens".to_string(),            JsonSchema::number(Some(                "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.".to_string(),            )),        ),    ]);    if include_shell_parameter {        properties.insert(            "shell".to_string(),            JsonSchema::string(Some(                "Shell binary to launch. Defaults to the user's default shell.".to_string(),            )),        );    }    if options.allow_login_shell {        properties.insert(            "login".to_string(),            JsonSchema::boolean(Some(                "True runs the shell with -l/-i semantics; false disables them. Defaults to true."                    .to_string(),            )),        );    }    if include_environment_id {        properties.insert(            "environment_id".to_string(),            JsonSchema::string(Some(                "Environment id from <environment_context>. Omit to use the primary environment."                    .to_string(),            )),        );    }    properties.extend(create_approval_parameters(        options.exec_permission_approvals_enabled,    ));    ToolSpec::Function(ResponsesApiTool {        name: "exec_command".to_string(),        description: if cfg!(windows) {            format!(                "Runs a command in a PTY, returning output or a session ID for ongoing interaction.\n\n{}",                windows_shell_guidance()            )        } else {            "Runs a command in a PTY, returning output or a session ID for ongoing interaction."                .to_string()        },        strict: false,        defer_loading: None,        parameters: JsonSchema::object(            properties,            Some(vec!["cmd".to_string()]),            Some(false.into()),        ),        output_schema: Some(unified_exec_output_schema()),    })}pub fn create_write_stdin_tool() -> ToolSpec {    let properties = BTreeMap::from([        (            "session_id".to_string(),            JsonSchema::number(Some(                "Identifier of the running unified exec session.".to_string(),            )),        ),        (            "chars".to_string(),            JsonSchema::string(Some(                "Bytes to write to stdin. Defaults to empty, which polls without writing.".to_string(),            )),        ),        (            "yield_time_ms".to_string(),            JsonSchema::number(Some(                "Wait before yielding output. Non-empty writes default to 250 ms and cap at 30000 ms; empty polls wait 5000-300000 ms by default.".to_string(),            )),        ),        (            "max_output_tokens".to_string(),            JsonSchema::number(Some(                "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.".to_string(),            )),        ),    ]);    ToolSpec::Function(ResponsesApiTool {        name: "write_stdin".to_string(),        description:            "Writes characters to an existing unified exec session and returns recent output."                .to_string(),        strict: false,        defer_loading: None,        parameters: JsonSchema::object(            properties,            Some(vec!["session_id".to_string()]),            Some(false.into()),        ),        output_schema: Some(unified_exec_output_schema()),    })}pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec {    let mut properties = BTreeMap::from([        (            "command".to_string(),            JsonSchema::string(Some(                "Shell script to run in the user's default shell.".to_string(),            )),        ),        (            "workdir".to_string(),            JsonSchema::string(Some(                "Working directory for the command. Defaults to the turn cwd.".to_string(),            )),        ),        (            "timeout_ms".to_string(),            JsonSchema::number(Some(                "Maximum command runtime. Defaults to 10000 ms.".to_string(),            )),        ),    ]);    if options.allow_login_shell {        properties.insert(            "login".to_string(),            JsonSchema::boolean(Some(                "True runs with login shell semantics; false disables them. Defaults to true."                    .to_string(),            )),        );    }    properties.extend(create_approval_parameters(        options.exec_permission_approvals_enabled,    ));    let description = if cfg!(windows) {        format!(            r#"Runs a Powershell command (Windows) and returns its output.Examples of valid command strings:- ls -a (show hidden): "Get-ChildItem -Force"- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"- ps aux | grep python: "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"- setting an env var: "$env:FOO='bar'; echo $env:FOO"- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -"{}"#,            windows_shell_guidance()        )    } else {        r#"Runs a shell command and returns its output.- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#            .to_string()    };    ToolSpec::Function(ResponsesApiTool {        name: "shell_command".to_string(),        description,        strict: false,        defer_loading: None,        parameters: JsonSchema::object(            properties,            Some(vec!["command".to_string()]),            Some(false.into()),        ),        output_schema: None,    })}pub fn create_request_permissions_tool(description: String) -> ToolSpec {    let properties = BTreeMap::from([        (            "reason".to_string(),            JsonSchema::string(Some(                "Optional short explanation for why additional permissions are needed.".to_string(),            )),        ),        (            "environment_id".to_string(),            JsonSchema::string(Some(                "Environment id from <environment_context>. Omit to use the primary environment."                    .to_string(),            )),        ),        ("permissions".to_string(), permission_profile_schema()),    ]);    ToolSpec::Function(ResponsesApiTool {        name: "request_permissions".to_string(),        description,        strict: false,        defer_loading: None,        parameters: JsonSchema::object(            properties,            Some(vec!["permissions".to_string()]),            Some(false.into()),        ),        output_schema: None,    })}pub fn request_permissions_tool_description() -> String {    "Request additional filesystem or network permissions from the user and wait for the client to grant a subset of the requested permission profile. Use environment_id to target a specific attached environment; omit it to use the primary environment. Relative filesystem paths resolve against the selected environment cwd. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope."        .to_string()}fn unified_exec_output_schema() -> Value {    json!({        "type": "object",        "properties": {            "chunk_id": {                "type": "string",                "description": "Chunk identifier included when the response reports one."            },            "wall_time_seconds": {                "type": "number",                "description": "Elapsed wall time spent waiting for output in seconds."            },            "exit_code": {                "type": "number",                "description": "Process exit code when the command finished during this call."            },            "session_id": {                "type": "number",                "description": "Session identifier to pass to write_stdin when the process is still running."            },            "original_token_count": {                "type": "number",                "description": "Approximate token count before output truncation."            },            "output": {                "type": "string",                "description": "Command output text, possibly truncated."            }        },        "required": ["wall_time_seconds", "output"],        "additionalProperties": false    })}fn create_approval_parameters(    exec_permission_approvals_enabled: bool,) -> BTreeMap<String, JsonSchema> {    let mut sandbox_permission_values = vec![json!("use_default")];    if exec_permission_approvals_enabled {        sandbox_permission_values.push(json!("with_additional_permissions"));    }    sandbox_permission_values.push(json!("require_escalated"));    let sandbox_permissions_description = if exec_permission_approvals_enabled {        "Per-command sandbox override. Defaults to `use_default`; use `with_additional_permissions` with `additional_permissions`, or `require_escalated` for unsandboxed execution."    } else {        "Per-command sandbox override. Defaults to `use_default`; use `require_escalated` for unsandboxed execution."    };    let mut properties = BTreeMap::from([        (            "sandbox_permissions".to_string(),            JsonSchema::string_enum(                sandbox_permission_values,                Some(sandbox_permissions_description.to_string()),            ),        ),        (            "justification".to_string(),            JsonSchema::string(Some(                "User-facing approval question for `require_escalated`; omit otherwise.".to_string(),            )),        ),        (            "prefix_rule".to_string(),            JsonSchema::array(JsonSchema::string(/*description*/ None), Some(                    r#"Reusable approval prefix for `cmd`, only with `sandbox_permissions: "require_escalated"`; for example ["git", "pull"]."#.to_string(),                )),        ),    ]);    if exec_permission_approvals_enabled {        let mut additional_permissions = permission_profile_schema();        additional_permissions.description = Some(            "Sandboxed filesystem or network access for this command; only with `sandbox_permissions: \"with_additional_permissions\"`."                .to_string(),        );        properties.insert("additional_permissions".to_string(), additional_permissions);    }    properties}fn permission_profile_schema() -> JsonSchema {    let mut schema = JsonSchema::object(        BTreeMap::from([            ("network".to_string(), network_permissions_schema()),            ("file_system".to_string(), file_system_permissions_schema()),        ]),        /*required*/ None,        Some(false.into()),    );    schema.description = Some("Filesystem or network access request.".to_string());    schema}fn network_permissions_schema() -> JsonSchema {    let mut schema = JsonSchema::object(        BTreeMap::from([(            "enabled".to_string(),            JsonSchema::boolean(Some(                "True requests network access; false or omitted requests none.".to_string(),            )),        )]),        /*required*/ None,        Some(false.into()),    );    schema.description = Some("Network access request.".to_string());    schema}fn file_system_permissions_schema() -> JsonSchema {    let mut schema = JsonSchema::object(        BTreeMap::from([            (                "read".to_string(),                JsonSchema::array(                    JsonSchema::string(/*description*/ None),                    Some(                        "Absolute paths to grant read access; omit when none are needed."                            .to_string(),                    ),                ),            ),            (                "write".to_string(),                JsonSchema::array(                    JsonSchema::string(/*description*/ None),                    Some(                        "Absolute paths to grant write access; omit when none are needed."                            .to_string(),                    ),                ),            ),        ]),        /*required*/ None,        Some(false.into()),    );    schema.description = Some("Filesystem access request.".to_string());    schema}fn windows_shell_guidance() -> &'static str {    r#"Windows safety rules:- Do not compose destructive filesystem commands across shells. Do not enumerate paths in PowerShell and then pass them to `cmd /c`, batch builtins, or another shell for deletion or moving. Use one shell end-to-end, prefer native PowerShell cmdlets such as `Remove-Item` / `Move-Item` with `-LiteralPath`, and avoid string-built shell commands for file operations.- Before any recursive delete or move on Windows, verify the resolved absolute target paths stay within the intended workspace or explicitly named target directory. Never issue a recursive delete or move against a computed path if the final target has not been checked.- When using `Start-Process` to launch a background helper or service, pass `-WindowStyle Hidden` unless the user explicitly asked for a visible interactive window. Use visible windows only for interactive tools the user needs to see or control."#}#[cfg(test)]#[path = "shell_spec_tests.rs"]mod tests;