safe-chains 0.125.0

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

static MVN_STANDALONE: WordSet = WordSet::new(&[
    "--also-make", "--also-make-dependents", "--batch-mode",
    "--debug", "--errors", "--fail-at-end",
    "--fail-fast", "--fail-never", "--no-transfer-progress",
    "--offline", "--quiet", "--show-version",
    "--strict-checksums", "--update-snapshots",
    "-B", "-U", "-X", "-e", "-f", "-o", "-q",
]);

static MVN_VALUED: WordSet = WordSet::new(&[
    "--activate-profiles", "--define", "--file",
    "--log-file", "--projects", "--threads",
    "-D", "-P", "-T", "-f", "-l", "-s",
]);

static MVN_SAFE_PHASES: WordSet = WordSet::new(&[
    "--version", "-v", "compile", "dependency:list", "dependency:tree",
    "help:describe", "test", "test-compile", "validate", "verify",
]);

pub fn is_safe_mvn(tokens: &[Token]) -> Verdict {
    if tokens.len() < 2 {
        return Verdict::Denied;
    }
    if !MVN_SAFE_PHASES.contains(&tokens[1]) {
        return Verdict::Denied;
    }
    if tokens[1] == "--version" || tokens[1] == "-v" {
        return if tokens.len() == 2 { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
    }
    let mut i = 2;
    while i < tokens.len() {
        let t = &tokens[i];
        if !t.starts_with('-') {
            i += 1;
            continue;
        }
        if t.starts_with("-D") && t.len() > 2 {
            i += 1;
            continue;
        }
        if MVN_STANDALONE.contains(t) {
            i += 1;
            continue;
        }
        if MVN_VALUED.contains(t) {
            i += 2;
            continue;
        }
        if let Some((flag, _)) = t.as_str().split_once('=')
            && MVN_VALUED.contains(flag)
        {
            i += 1;
            continue;
        }
        return Verdict::Denied;
    }
    let level = match tokens[1].as_str() {
        "compile" | "test-compile" => SafetyLevel::SafeWrite,
        "test" | "validate" | "verify" => SafetyLevel::SafeRead,
        _ => SafetyLevel::Inert,
    };
    Verdict::Allowed(level)
}

pub(crate) fn dispatch(cmd: &str, tokens: &[Token]) -> Option<Verdict> {
    match cmd {
        "mvn" | "mvnw" => Some(is_safe_mvn(tokens)),
        _ => None,
    }
}

pub fn command_docs() -> Vec<crate::docs::CommandDoc> {
    use crate::docs::CommandDoc;
    vec![
        CommandDoc::handler("mvn / mvnw",
            "https://maven.apache.org/ref/current/maven-embedder/cli.html",
            "Phases: compile, dependency:list, dependency:tree, help:describe, \
             test, test-compile, validate, verify."),
    ]
}

#[cfg(test)]
pub(in crate::handlers::jvm) const REGISTRY: &[crate::handlers::CommandEntry] = &[
    crate::handlers::CommandEntry::Custom { cmd: "mvn", valid_prefix: Some("mvn test") },
    crate::handlers::CommandEntry::Custom { cmd: "mvnw", valid_prefix: Some("mvnw test") },
];

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

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

    safe! {
        mvn_version: "mvn --version",
        mvn_version_short: "mvn -v",
        mvn_dependency_tree: "mvn dependency:tree",
        mvn_dependency_tree_offline: "mvn dependency:tree --offline",
        mvn_dependency_list: "mvn dependency:list",
        mvn_help_describe: "mvn help:describe -Dplugin=compiler",
        mvn_validate: "mvn validate",
        mvn_test: "mvn test",
        mvn_test_define: "mvn test -Dtest=MyTest",
        mvn_test_batch: "mvn test --batch-mode",
        mvn_compile: "mvn compile",
        mvn_verify: "mvn verify",
        mvn_test_compile: "mvn test-compile",
        mvnw_test: "mvnw test",
        mvnw_version: "mvnw --version",
    }

    denied! {
        mvn_deploy_denied: "mvn deploy",
        mvn_install_denied: "mvn install",
        mvn_clean_denied: "mvn clean",
        bare_mvn_denied: "mvn",
    }
}