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};

static JJ_GLOBAL_STANDALONE: WordSet = WordSet::new(&[
    "--debug", "--ignore-immutable", "--ignore-working-copy",
    "--no-pager", "--quiet", "--verbose",
]);

static JJ_GLOBAL_VALUED: WordSet =
    WordSet::new(&["--at-op", "--at-operation", "--color", "--repository", "-R"]);

static JJ_READ_ONLY: WordSet =
    WordSet::new(&["--help", "--version", "-h", "cat", "diff", "help", "log", "root", "show", "st", "status", "version"]);

static JJ_MULTI: &[(&str, WordSet)] = &[
    ("bookmark", WordSet::new(&["list"])),
    ("config", WordSet::new(&["get", "list"])),
    ("file", WordSet::new(&["list", "show"])),
    ("git", WordSet::new(&["fetch"])),
    ("op", WordSet::new(&["log"])),
    ("resolve", WordSet::new(&["--list"])),
    ("tag", WordSet::new(&["list"])),
    ("workspace", WordSet::new(&["list"])),
];

static JJ_TRIPLE: &[(&str, &str, WordSet)] =
    &[("git", "remote", WordSet::new(&["list"]))];

pub fn is_safe_jj(tokens: &[Token]) -> Verdict {
    let mut args = &tokens[1..];
    loop {
        if args.is_empty() {
            return Verdict::Denied;
        }
        if JJ_GLOBAL_STANDALONE.contains(&args[0]) {
            args = &args[1..];
        } else if JJ_GLOBAL_VALUED.contains(&args[0]) {
            if args.len() < 2 {
                return Verdict::Denied;
            }
            args = &args[2..];
        } else if let Some(prefix) = args[0].split_value("=") {
            let flag = args[0].as_str().split_once('=').map_or(args[0].as_str(), |(k, _)| k);
            if JJ_GLOBAL_VALUED.contains(flag) && !prefix.is_empty() {
                args = &args[1..];
            } else {
                break;
            }
        } else {
            break;
        }
    }
    if args.is_empty() {
        return Verdict::Denied;
    }
    if JJ_READ_ONLY.contains(&args[0]) {
        return Verdict::Allowed(SafetyLevel::Inert);
    }
    for (prefix, actions) in JJ_MULTI.iter() {
        if args[0] == *prefix && args.get(1).is_some_and(|a| actions.contains(a) || a == "--help" || a == "-h") {
            return Verdict::Allowed(SafetyLevel::Inert);
        }
    }
    for (first, second, actions) in JJ_TRIPLE.iter() {
        if args[0] == *first && args.get(1).is_some_and(|t| t == *second) {
            return if args.get(2).is_some_and(|a| actions.contains(a)) { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
        }
    }
    Verdict::Denied

}

pub(in crate::handlers::vcs) fn dispatch(cmd: &str, tokens: &[Token]) -> Option<Verdict> {
    match cmd {
        "jj" => Some(is_safe_jj(tokens)),
        _ => None,
    }
}

pub(in crate::handlers::vcs) fn command_docs() -> Vec<crate::docs::CommandDoc> {
    use crate::docs::{CommandDoc, doc_multi, wordset_items};
    vec![
        CommandDoc::handler("jj",
            "https://jj-vcs.github.io/jj/latest/cli-reference/",
            doc_multi(&JJ_READ_ONLY, JJ_MULTI)
                .triple_word(JJ_TRIPLE)
                .section(format!(
                    "Skips global flags: standalone ({}), valued ({}).",
                    wordset_items(&JJ_GLOBAL_STANDALONE),
                    wordset_items(&JJ_GLOBAL_VALUED),
                ))
                .build()),
    ]
}

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

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

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

    safe! {
        jj_log: "jj log",
        jj_diff: "jj diff --stat",
        jj_show: "jj show abc123",
        jj_status: "jj status",
        jj_st: "jj st",
        jj_help: "jj help",
        jj_version: "jj --version",
        jj_version_subcmd: "jj version",
        jj_op_log: "jj op log",
        jj_file_show: "jj file show some/path",
        jj_config_get: "jj config get user.name",
        jj_config_list: "jj config list",
        jj_bookmark_list: "jj bookmark list",
        jj_git_remote_list: "jj git remote list",
        jj_ignore_working_copy_diff: "jj --ignore-working-copy diff --from 'trunk()' --to '@' --summary",
        jj_no_pager_log: "jj --no-pager log",
        jj_repository_flag: "jj -R /some/repo status",
        jj_color_valued: "jj --color auto log",
        jj_color_eq: "jj --color=auto log",
        jj_at_op: "jj --at-op @- diff",
        jj_multiple_global_flags: "jj --no-pager --ignore-working-copy --color=auto diff",
        jj_global_flag_multi_word: "jj --no-pager bookmark list",
        jj_git_fetch: "jj git fetch",
        jj_git_fetch_with_global_flags: "jj --no-pager git fetch",
        jj_root: "jj root",
        jj_file_list: "jj file list",
        jj_file_list_with_flags: "jj --no-pager file list",
        jj_workspace_list: "jj workspace list",
        jj_workspace_list_with_flags: "jj --no-pager workspace list",
        jj_resolve_list: "jj resolve --list",
        jj_resolve_list_file: "jj resolve --list somefile",
        jj_tag_list: "jj tag list",
        jj_tag_list_with_flags: "jj --no-pager tag list",
        jj_cat: "jj cat some/file.rb",
        jj_cat_revision: "jj cat -r master some/file.rb",
        jj_cat_pipe_standardrb: "jj cat -r master spec/foo_spec.rb | bundle exec standardrb --stdin spec/foo_spec.rb 2>&1",
        jj_workspace_help: "jj workspace --help",
        jj_workspace_help_h: "jj workspace -h",
    }

    denied! {
        jj_global_flag_no_subcommand_denied: "jj --ignore-working-copy",
        jj_global_flag_mutating_denied: "jj --ignore-working-copy new master",
        jj_new_denied: "jj new master",
        jj_edit_denied: "jj edit abc123",
        jj_squash_denied: "jj squash",
        jj_describe_denied: "jj describe -m 'test'",
        jj_bookmark_denied: "jj bookmark set my-branch",
        jj_git_push_denied: "jj git push",
        jj_rebase_denied: "jj rebase -d master",
        jj_restore_denied: "jj restore file.rb",
        jj_abandon_denied: "jj abandon",
        jj_config_set_denied: "jj config set user.name foo",
        jj_workspace_add_denied: "jj workspace add ../new",
        jj_workspace_forget_denied: "jj workspace forget default",
        jj_file_annotate_denied: "jj file annotate",
        jj_resolve_denied: "jj resolve somefile",
        jj_tag_create_denied: "jj tag create v1.0",
        jj_help_bypass_denied: "jj new -- --help",
        bare_jj_denied: "jj",
    }
}