Codex Handbook
shell-command/src/command_safety/is_safe_command.rs 802 lines
use crate::bash::parse_shell_lc_plain_commands;use crate::command_safety::is_dangerous_command::executable_name_lookup_key;// Find the first matching git subcommand, skipping known global options that// may appear before it (e.g., `-C`, `-c`, `--git-dir`).// Implemented in `is_dangerous_command` and shared here.use crate::command_safety::is_dangerous_command::find_git_subcommand;#[cfg(windows)]use crate::command_safety::windows_safe_commands::is_safe_command_windows;#[cfg(windows)]use crate::command_safety::windows_safe_commands::is_safe_powershell_words as is_safe_powershell_words_windows;pub fn is_known_safe_command(command: &[String]) -> bool {    let command: Vec<String> = command        .iter()        .map(|s| {            if s == "zsh" {                "bash".to_string()            } else {                s.clone()            }        })        .collect();    #[cfg(windows)]    {        if is_safe_command_windows(&command) {            return true;        }    }    if is_safe_to_call_with_exec(&command) {        return true;    }    // Support `bash -lc "..."` where the script consists solely of one or    // more "plain" commands (only bare words / quoted strings) combined with    // a conservative allow‑list of shell operators that themselves do not    // introduce side effects ( "&&", "||", ";", and "|" ). If every    // individual command in the script is itself a known‑safe command, then    // the composite expression is considered safe.    if let Some(all_commands) = parse_shell_lc_plain_commands(&command)        && !all_commands.is_empty()        && all_commands            .iter()            .all(|cmd| is_safe_to_call_with_exec(cmd))    {        return true;    }    false}/// Returns whether already-tokenized PowerShell words are read-only enough to/// be auto-approved by the Windows safelist.pub fn is_safe_powershell_words(command: &[String]) -> bool {    #[cfg(windows)]    {        is_safe_powershell_words_windows(command)    }    #[cfg(not(windows))]    {        let _ = command;        false    }}fn is_safe_to_call_with_exec(command: &[String]) -> bool {    let Some(cmd0) = command.first().map(String::as_str) else {        return false;    };    match executable_name_lookup_key(cmd0).as_deref() {        Some(cmd) if cfg!(target_os = "linux") && matches!(cmd, "numfmt" | "tac") => true,        #[rustfmt::skip]        Some(            "cat" |            "cd" |            "cut" |            "echo" |            "expr" |            "false" |            "grep" |            "head" |            "id" |            "ls" |            "nl" |            "paste" |            "pwd" |            "rev" |            "seq" |            "stat" |            "tail" |            "tr" |            "true" |            "uname" |            "uniq" |            "wc" |            "which" |            "whoami") => {            true        },        Some("base64") => {            const UNSAFE_BASE64_OPTIONS: &[&str] = &["-o", "--output"];            !command.iter().skip(1).any(|arg| {                UNSAFE_BASE64_OPTIONS.contains(&arg.as_str())                    || arg.starts_with("--output=")                    || (arg.starts_with("-o") && arg != "-o")            })        }        Some("find") => {            // Certain options to `find` can delete files, write to files, or            // execute arbitrary commands, so we cannot auto-approve the            // invocation of `find` in such cases.            #[rustfmt::skip]            const UNSAFE_FIND_OPTIONS: &[&str] = &[                // Options that can execute arbitrary commands.                "-exec", "-execdir", "-ok", "-okdir",                // Option that deletes matching files.                "-delete",                // Options that write pathnames to a file.                "-fls", "-fprint", "-fprint0", "-fprintf",            ];            !command                .iter()                .any(|arg| UNSAFE_FIND_OPTIONS.contains(&arg.as_str()))        }        // Ripgrep        Some("rg") => {            const UNSAFE_RIPGREP_OPTIONS_WITH_ARGS: &[&str] = &[                // Takes an arbitrary command that is executed for each match.                "--pre",                // Takes a command that can be used to obtain the local hostname.                "--hostname-bin",            ];            const UNSAFE_RIPGREP_OPTIONS_WITHOUT_ARGS: &[&str] = &[                // Calls out to other decompression tools, so do not auto-approve                // out of an abundance of caution.                "--search-zip",                "-z",            ];            !command.iter().any(|arg| {                UNSAFE_RIPGREP_OPTIONS_WITHOUT_ARGS.contains(&arg.as_str())                    || UNSAFE_RIPGREP_OPTIONS_WITH_ARGS                        .iter()                        .any(|&opt| arg == opt || arg.starts_with(&format!("{opt}=")))            })        }        // Git        Some("git") => is_safe_git_command(command),        // Special-case `sed -n {N|M,N}p`        Some("sed")            if {                command.len() <= 4                    && command.get(1).map(String::as_str) == Some("-n")                    && is_valid_sed_n_arg(command.get(2).map(String::as_str))            } =>        {            true        }        // ── anything else ─────────────────────────────────────────────────        _ => false,    }}pub(crate) fn is_safe_git_command(command: &[String]) -> bool {    let Some((subcommand_idx, subcommand)) =        find_git_subcommand(command, &["status", "log", "diff", "show", "branch"])    else {        return false;    };    let global_args = &command[1..subcommand_idx];    if git_has_unsafe_global_option(global_args) {        return false;    }    let subcommand_args = &command[subcommand_idx + 1..];    match subcommand {        "status" | "log" | "diff" | "show" => git_subcommand_args_are_read_only(subcommand_args),        "branch" => {            git_subcommand_args_are_read_only(subcommand_args)                && git_branch_is_read_only(subcommand_args)        }        other => {            debug_assert!(false, "unexpected git subcommand from matcher: {other}");            false        }    }}// Treat `git branch` as safe only when the arguments clearly indicate// a read-only query, not a branch mutation (create/rename/delete).fn git_branch_is_read_only(branch_args: &[String]) -> bool {    if branch_args.is_empty() {        // `git branch` with no additional args lists branches.        return true;    }    let mut saw_read_only_flag = false;    for arg in branch_args.iter().map(String::as_str) {        match arg {            "--list" | "-l" | "--show-current" | "-a" | "--all" | "-r" | "--remotes" | "-v"            | "-vv" | "--verbose" => {                saw_read_only_flag = true;            }            _ if arg.starts_with("--format=") => {                saw_read_only_flag = true;            }            _ => {                // Any other flag or positional argument may create, rename, or delete branches.                return false;            }        }    }    saw_read_only_flag}#[derive(Clone, Copy)]enum GitOptionPattern {    Exact(&'static str),    ShortWithInlineValue(&'static str),    Prefix(&'static str),}const UNSAFE_GIT_GLOBAL_OPTIONS: &[GitOptionPattern] = &[    GitOptionPattern::Exact("-C"),    GitOptionPattern::ShortWithInlineValue("-C"),    GitOptionPattern::Exact("-c"),    GitOptionPattern::ShortWithInlineValue("-c"),    GitOptionPattern::Exact("-p"),    GitOptionPattern::Exact("--config-env"),    GitOptionPattern::Prefix("--config-env="),    GitOptionPattern::Exact("--exec-path"),    GitOptionPattern::Prefix("--exec-path="),    GitOptionPattern::Exact("--git-dir"),    GitOptionPattern::Prefix("--git-dir="),    GitOptionPattern::Exact("--namespace"),    GitOptionPattern::Prefix("--namespace="),    GitOptionPattern::Exact("--paginate"),    GitOptionPattern::Exact("--super-prefix"),    GitOptionPattern::Prefix("--super-prefix="),    GitOptionPattern::Exact("--work-tree"),    GitOptionPattern::Prefix("--work-tree="),];const UNSAFE_GIT_SUBCOMMAND_OPTIONS: &[GitOptionPattern] = &[    GitOptionPattern::Exact("--output"),    GitOptionPattern::Prefix("--output="),    GitOptionPattern::Exact("--ext-diff"),    GitOptionPattern::Exact("--textconv"),    GitOptionPattern::Exact("--exec"),    GitOptionPattern::Prefix("--exec="),];impl GitOptionPattern {    fn matches(self, arg: &str) -> bool {        match self {            GitOptionPattern::Exact(option) => arg == option,            GitOptionPattern::ShortWithInlineValue(option) => {                arg.starts_with(option) && arg.len() > option.len()            }            GitOptionPattern::Prefix(prefix) => arg.starts_with(prefix),        }    }}fn git_matches_option_pattern(arg: &str, patterns: &[GitOptionPattern]) -> bool {    patterns.iter().any(|pattern| pattern.matches(arg))}fn git_has_unsafe_global_option(global_args: &[String]) -> bool {    global_args        .iter()        .map(String::as_str)        .any(|arg| git_matches_option_pattern(arg, UNSAFE_GIT_GLOBAL_OPTIONS))}fn git_subcommand_args_are_read_only(args: &[String]) -> bool {    !args        .iter()        .map(String::as_str)        .any(|arg| git_matches_option_pattern(arg, UNSAFE_GIT_SUBCOMMAND_OPTIONS))}// (bash parsing helpers implemented in crate::bash)/* ----------------------------------------------------------Example---------------------------------------------------------- *//// Returns true if `arg` matches /^(\d+,)?\d+p$/fn is_valid_sed_n_arg(arg: Option<&str>) -> bool {    // unwrap or bail    let s = match arg {        Some(s) => s,        None => return false,    };    // must end with 'p', strip it    let core = match s.strip_suffix('p') {        Some(rest) => rest,        None => return false,    };    // split on ',' and ensure 1 or 2 numeric parts    let parts: Vec<&str> = core.split(',').collect();    match parts.as_slice() {        // single number, e.g. "10"        [num] => !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()),        // two numbers, e.g. "1,5"        [a, b] => {            !a.is_empty()                && !b.is_empty()                && a.chars().all(|c| c.is_ascii_digit())                && b.chars().all(|c| c.is_ascii_digit())        }        // anything else (more than one comma) is invalid        _ => false,    }}#[cfg(test)]mod tests {    use super::*;    use std::string::ToString;    fn vec_str(args: &[&str]) -> Vec<String> {        args.iter().map(ToString::to_string).collect()    }    #[test]    fn known_safe_examples() {        assert!(is_safe_to_call_with_exec(&vec_str(&["ls"])));        assert!(is_safe_to_call_with_exec(&vec_str(&["git", "status"])));        assert!(is_safe_to_call_with_exec(&vec_str(&["git", "branch"])));        assert!(is_safe_to_call_with_exec(&vec_str(&[            "git",            "branch",            "--show-current"        ])));        assert!(is_safe_to_call_with_exec(&vec_str(&["base64"])));        assert!(is_safe_to_call_with_exec(&vec_str(&[            "sed", "-n", "1,5p", "file.txt"        ])));        assert!(is_safe_to_call_with_exec(&vec_str(&[            "nl",            "-nrz",            "Cargo.toml"        ])));        // Safe `find` command (no unsafe options).        assert!(is_safe_to_call_with_exec(&vec_str(&[            "find", ".", "-name", "file.txt"        ])));        if cfg!(target_os = "linux") {            assert!(is_safe_to_call_with_exec(&vec_str(&["numfmt", "1000"])));            assert!(is_safe_to_call_with_exec(&vec_str(&["tac", "Cargo.toml"])));        } else {            assert!(!is_safe_to_call_with_exec(&vec_str(&["numfmt", "1000"])));            assert!(!is_safe_to_call_with_exec(&vec_str(&["tac", "Cargo.toml"])));        }    }    #[test]    fn git_branch_mutating_flags_are_not_safe() {        assert!(!is_known_safe_command(&vec_str(&[            "git", "branch", "-d", "feature"        ])));        assert!(!is_known_safe_command(&vec_str(&[            "git",            "branch",            "new-branch"        ])));    }    #[test]    fn git_branch_global_options_respect_safety_rules() {        assert!(is_known_safe_command(&vec_str(&[            "git",            "branch",            "--show-current",        ])));        assert!(!is_known_safe_command(&vec_str(&[            "git", "branch", "-d", "feature",        ])));        assert!(!is_known_safe_command(&vec_str(&[            "bash",            "-lc",            "git branch -d feature",        ])));    }    #[test]    fn git_first_positional_is_the_subcommand() {        // In git, the first non-option token is the subcommand. Later positional        // args (like branch names) must not be treated as subcommands.        assert!(!is_known_safe_command(&vec_str(&[            "git", "checkout", "status",        ])));    }    #[test]    fn git_output_flags_are_not_safe() {        assert!(!is_known_safe_command(&vec_str(&[            "git",            "log",            "--output=/tmp/git-log-out-test",            "-n",            "1",        ])));        assert!(!is_known_safe_command(&vec_str(&[            "git",            "diff",            "--output",            "/tmp/git-diff-out-test",        ])));        assert!(!is_known_safe_command(&vec_str(&[            "git",            "show",            "--output=/tmp/git-show-out-test",            "HEAD",        ])));    }    #[test]    fn git_global_pagination_flags_are_not_safe() {        assert!(!is_known_safe_command(&vec_str(&[            "git",            "--paginate",            "log",            "-1",        ])));        assert!(!is_known_safe_command(&vec_str(&[            "git", "-p", "log", "-1",        ])));        assert!(!is_known_safe_command(&vec_str(&[            "bash",            "-lc",            "git --paginate log -1",        ])));        assert!(!is_known_safe_command(&vec_str(&[            "bash",            "-lc",            "git -p log -1",        ])));    }    #[test]    fn git_subcommand_patch_flags_remain_safe() {        assert!(is_known_safe_command(&vec_str(&["git", "log", "-p", "-1"])));        assert!(is_known_safe_command(&vec_str(&["git", "diff", "-p"])));        assert!(is_known_safe_command(&vec_str(&[            "git", "show", "-p", "HEAD",        ])));        assert!(is_known_safe_command(&vec_str(&[            "bash",            "-lc",            "git log -p -1",        ])));    }    #[test]    fn git_global_override_flags_are_not_safe() {        assert!(!is_known_safe_command(&vec_str(&[            "git", "-C", ".", "status",        ])));        assert!(!is_known_safe_command(&vec_str(&["git", "-C.", "status",])));        assert!(!is_known_safe_command(&vec_str(&[            "git",            "-c",            "core.pager=cat",            "log",            "-n",            "1",        ])));        assert!(!is_known_safe_command(&vec_str(&[            "git",            "-ccore.pager=cat",            "status",        ])));        for args in [            vec_str(&["git", "--config-env", "core.pager=PAGER", "show", "HEAD"]),            vec_str(&["git", "--config-env=core.pager=PAGER", "show", "HEAD"]),            vec_str(&["git", "--git-dir", ".evil-git", "diff", "HEAD~1..HEAD"]),            vec_str(&["git", "--git-dir=.evil-git", "diff", "HEAD~1..HEAD"]),            vec_str(&["git", "--work-tree", ".", "status"]),            vec_str(&["git", "--work-tree=.", "status"]),            vec_str(&["git", "--exec-path", ".git/helpers", "show", "HEAD"]),            vec_str(&["git", "--exec-path=.git/helpers", "show", "HEAD"]),            vec_str(&["git", "--namespace", "attacker", "show", "HEAD"]),            vec_str(&["git", "--namespace=attacker", "show", "HEAD"]),            vec_str(&["git", "--super-prefix", "attacker/", "show", "HEAD"]),            vec_str(&["git", "--super-prefix=attacker/", "show", "HEAD"]),        ] {            assert!(                !is_known_safe_command(&args),                "expected {args:?} to require approval due to unsafe git global option",            );        }        assert!(!is_known_safe_command(&vec_str(&[            "bash",            "-lc",            "git -C .project-deps/test-fixtures status",        ])));        assert!(!is_known_safe_command(&vec_str(&[            "bash",            "-lc",            "git --git-dir=.evil-git diff HEAD~1..HEAD",        ])));    }    #[test]    fn cargo_check_is_not_safe() {        assert!(!is_known_safe_command(&vec_str(&["cargo", "check"])));    }    #[test]    fn zsh_lc_safe_command_sequence() {        assert!(is_known_safe_command(&vec_str(&["zsh", "-lc", "ls"])));    }    #[test]    fn unknown_or_partial() {        assert!(!is_safe_to_call_with_exec(&vec_str(&["foo"])));        assert!(!is_safe_to_call_with_exec(&vec_str(&["git", "fetch"])));        assert!(!is_safe_to_call_with_exec(&vec_str(&[            "sed", "-n", "xp", "file.txt"        ])));        // Unsafe `find` commands.        for args in [            vec_str(&["find", ".", "-name", "file.txt", "-exec", "rm", "{}", ";"]),            vec_str(&[                "find", ".", "-name", "*.py", "-execdir", "python3", "{}", ";",            ]),            vec_str(&["find", ".", "-name", "file.txt", "-ok", "rm", "{}", ";"]),            vec_str(&["find", ".", "-name", "*.py", "-okdir", "python3", "{}", ";"]),            vec_str(&["find", ".", "-delete", "-name", "file.txt"]),            vec_str(&["find", ".", "-fls", "/etc/passwd"]),            vec_str(&["find", ".", "-fprint", "/etc/passwd"]),            vec_str(&["find", ".", "-fprint0", "/etc/passwd"]),            vec_str(&["find", ".", "-fprintf", "/root/suid.txt", "%#m %u %p\n"]),        ] {            assert!(                !is_safe_to_call_with_exec(&args),                "expected {args:?} to be unsafe"            );        }    }    #[test]    fn base64_output_options_are_unsafe() {        for args in [            vec_str(&["base64", "-o", "out.bin"]),            vec_str(&["base64", "--output", "out.bin"]),            vec_str(&["base64", "--output=out.bin"]),            vec_str(&["base64", "-ob64.txt"]),        ] {            assert!(                !is_safe_to_call_with_exec(&args),                "expected {args:?} to be considered unsafe due to output option"            );        }    }    #[test]    fn ripgrep_rules() {        // Safe ripgrep invocations – none of the unsafe flags are present.        assert!(is_safe_to_call_with_exec(&vec_str(&[            "rg",            "Cargo.toml",            "-n"        ])));        // Unsafe flags that do not take an argument (present verbatim).        for args in [            vec_str(&["rg", "--search-zip", "files"]),            vec_str(&["rg", "-z", "files"]),        ] {            assert!(                !is_safe_to_call_with_exec(&args),                "expected {args:?} to be considered unsafe due to zip-search flag",            );        }        // Unsafe flags that expect a value, provided in both split and = forms.        for args in [            vec_str(&["rg", "--pre", "pwned", "files"]),            vec_str(&["rg", "--pre=pwned", "files"]),            vec_str(&["rg", "--hostname-bin", "pwned", "files"]),            vec_str(&["rg", "--hostname-bin=pwned", "files"]),        ] {            assert!(                !is_safe_to_call_with_exec(&args),                "expected {args:?} to be considered unsafe due to external-command flag",            );        }    }    #[test]    fn windows_powershell_full_path_is_safe() {        if !cfg!(windows) {            // Windows only because on Linux path splitting doesn't handle `/` separators properly            return;        }        let Some(powershell) = crate::powershell::try_find_pwsh_executable_blocking()            .or_else(crate::powershell::try_find_powershell_executable_blocking)        else {            return;        };        let powershell = powershell.as_path().to_str().unwrap();        assert!(is_known_safe_command(&vec_str(&[            powershell,            "-Command",            "Get-Location",        ])));    }    #[test]    fn windows_git_full_path_is_safe() {        if !cfg!(windows) {            return;        }        assert!(is_known_safe_command(&vec_str(&[            r"C:\Program Files\Git\cmd\git.exe",            "status",        ])));    }    #[test]    fn bash_lc_safe_examples() {        assert!(is_known_safe_command(&vec_str(&["bash", "-lc", "ls"])));        assert!(is_known_safe_command(&vec_str(&["bash", "-lc", "ls -1"])));        assert!(is_known_safe_command(&vec_str(&[            "bash",            "-lc",            "git status"        ])));        assert!(is_known_safe_command(&vec_str(&[            "bash",            "-lc",            "grep -R \"Cargo.toml\" -n"        ])));        assert!(is_known_safe_command(&vec_str(&[            "bash",            "-lc",            "sed -n 1,5p file.txt"        ])));        assert!(is_known_safe_command(&vec_str(&[            "bash",            "-lc",            "sed -n '1,5p' file.txt"        ])));        assert!(is_known_safe_command(&vec_str(&[            "bash",            "-lc",            "find . -name file.txt"        ])));    }    #[test]    fn bash_lc_safe_examples_with_operators() {        assert!(is_known_safe_command(&vec_str(&[            "bash",            "-lc",            "grep -R \"Cargo.toml\" -n || true"        ])));        assert!(is_known_safe_command(&vec_str(&[            "bash",            "-lc",            "ls && pwd"        ])));        assert!(is_known_safe_command(&vec_str(&[            "bash",            "-lc",            "echo 'hi' ; ls"        ])));        assert!(is_known_safe_command(&vec_str(&[            "bash",            "-lc",            "ls | wc -l"        ])));    }    #[test]    fn bash_lc_unsafe_examples() {        assert!(            !is_known_safe_command(&vec_str(&["bash", "-lc", "git", "status"])),            "Four arg version is not known to be safe."        );        assert!(            !is_known_safe_command(&vec_str(&["bash", "-lc", "'git status'"])),            "The extra quoting around 'git status' makes it a program named 'git status' and is therefore unsafe."        );        assert!(            !is_known_safe_command(&vec_str(&["bash", "-lc", "find . -name file.txt -delete"])),            "Unsafe find option should not be auto-approved."        );        // Disallowed because of unsafe command in sequence.        assert!(            !is_known_safe_command(&vec_str(&["bash", "-lc", "ls && rm -rf /"])),            "Sequence containing unsafe command must be rejected"        );        // Disallowed because of parentheses / subshell.        assert!(            !is_known_safe_command(&vec_str(&["bash", "-lc", "(ls)"])),            "Parentheses (subshell) are not provably safe with the current parser"        );        assert!(            !is_known_safe_command(&vec_str(&["bash", "-lc", "ls || (pwd && echo hi)"])),            "Nested parentheses are not provably safe with the current parser"        );        // Disallowed redirection.        assert!(            !is_known_safe_command(&vec_str(&["bash", "-lc", "ls > out.txt"])),            "> redirection should be rejected"        );    }    #[test]    fn direct_powershell_words_use_windows_safelist() {        let command = vec_str(&["Get-Content", "Cargo.toml"]);        if cfg!(windows) {            assert!(is_safe_powershell_words(&command));        } else {            assert!(!is_safe_powershell_words(&command));        }    }    #[cfg(unix)]    #[test]    fn non_windows_safe_classification_does_not_spawn_repo_powershell_path() {        use std::fs;        use std::io::Write;        use std::os::unix::fs::PermissionsExt;        use std::time::SystemTime;        use std::time::UNIX_EPOCH;        let unique = SystemTime::now()            .duration_since(UNIX_EPOCH)            .expect("system clock should be after unix epoch")            .as_nanos();        let temp_dir = std::env::temp_dir().join(format!(            "codex-safe-command-pwsh-test-{}-{unique}",            std::process::id()        ));        fs::create_dir(&temp_dir).expect("create temp dir for fake pwsh");        let fake_pwsh = temp_dir.join("pwsh");        let marker = temp_dir.join("marker");        let quoted_marker = marker.to_string_lossy().replace('\'', "'\\''");        let mut script = fs::File::create(&fake_pwsh).expect("create fake pwsh");        writeln!(            script,            "#!/bin/sh\nprintf spawned > '{quoted_marker}'\nexit 0"        )        .expect("write fake pwsh");        let mut permissions = fs::metadata(&fake_pwsh)            .expect("stat fake pwsh")            .permissions();        permissions.set_mode(0o755);        fs::set_permissions(&fake_pwsh, permissions).expect("make fake pwsh executable");        assert!(!is_known_safe_command(&[            fake_pwsh.to_string_lossy().into_owned(),            "-Command".to_string(),            "Get-ChildItem".to_string(),        ]));        assert!(            !marker.exists(),            "non-Windows safety classification must not spawn a PowerShell-looking path"        );        fs::remove_dir_all(temp_dir).expect("remove temp dir");    }}