safe-chains 0.110.0

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

static R_CMD_CHECK_POLICY: FlagPolicy = FlagPolicy {
    standalone: WordSet::flags(&[
        "--as-cran", "--no-build-vignettes", "--no-examples",
        "--no-manual", "--no-tests", "--no-vignettes",
    ]),
    valued: WordSet::flags(&["--output", "-o"]),
    bare: false,
    max_positional: None,
    flag_style: FlagStyle::Strict,
};

static R_CMD_CONFIG_POLICY: FlagPolicy = FlagPolicy {
    standalone: WordSet::flags(&[]),
    valued: WordSet::flags(&[]),
    bare: false,
    max_positional: Some(1),
    flag_style: FlagStyle::Strict,
};

fn is_safe_r(tokens: &[Token]) -> Verdict {
    if tokens.len() < 3 {
        return Verdict::Denied;
    }
    if tokens[1] != "CMD" {
        return Verdict::Denied;
    }
    let ok = match tokens[2].as_str() {
        "check" => policy::check(&tokens[2..], &R_CMD_CHECK_POLICY),
        "config" => policy::check(&tokens[2..], &R_CMD_CONFIG_POLICY),
        _ => false,
    };
    if ok { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied }
}

fn is_safe_rscript(tokens: &[Token]) -> Verdict {
    if tokens.len() == 2 && matches!(tokens[1].as_str(), "--help" | "-h" | "--version" | "-V") { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied }

}

pub(crate) fn dispatch(cmd: &str, tokens: &[Token]) -> Option<Verdict> {
    match cmd {
        "R" => Some(is_safe_r(tokens)),
        "Rscript" => Some(is_safe_rscript(tokens)),
        _ => None,
    }
}

pub fn command_docs() -> Vec<crate::docs::CommandDoc> {
    use crate::docs::CommandDoc;
    vec![
        CommandDoc::handler("R",
            "https://cran.r-project.org/manuals.html",
            "- CMD check <package> (with --as-cran, --no-tests, --no-examples, --no-vignettes, --no-build-vignettes, --no-manual, --output)\n- CMD config <var>"),
        CommandDoc::handler("Rscript",
            "https://cran.r-project.org/manuals.html",
            "- --version\n- --help"),
    ]
}

#[cfg(test)]
pub(crate) const REGISTRY: &[super::CommandEntry] = &[
    super::CommandEntry::Custom { cmd: "R", valid_prefix: Some("R CMD check pkg") },
    super::CommandEntry::Custom { cmd: "Rscript", valid_prefix: Some("Rscript --version") },
];

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

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

    safe! {
        r_cmd_check: "R CMD check mypackage",
        r_cmd_check_as_cran: "R CMD check --as-cran mypackage",
        r_cmd_check_no_tests: "R CMD check --no-tests mypackage",
        r_cmd_check_no_vignettes: "R CMD check --no-vignettes mypackage",
        r_cmd_check_no_manual: "R CMD check --no-manual mypackage",
        r_cmd_check_output: "R CMD check --output /tmp mypackage",
        r_cmd_config: "R CMD config CC",
        r_cmd_config_cflags: "R CMD config CFLAGS",
        rscript_version: "Rscript --version",
        rscript_help: "Rscript --help",
    }

    denied! {
        r_bare_denied: "R",
        r_cmd_install_denied: "R CMD INSTALL mypackage",
        r_cmd_remove_denied: "R CMD REMOVE mypackage",
        r_cmd_build_denied: "R CMD build mypackage",
        r_cmd_batch_denied: "R CMD BATCH script.R",
        r_no_save_denied: "R --no-save -e 'system(\"rm -rf /\")'",
        r_e_denied: "R -e 'print(1)'",
        rscript_e_denied: "Rscript -e 'print(1)'",
        rscript_file_denied: "Rscript script.R",
        rscript_bare_denied: "Rscript",
        r_cmd_unknown_denied: "R CMD xyzzy",
        r_cmd_config_bare_denied: "R CMD config",
        r_cmd_check_bare_denied: "R CMD check",
    }
}