safe-chains 0.148.0

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

static GIT_REMOTE_MUTATING: WordSet = WordSet::new(&[
    "add", "prune", "remove", "rename", "set-branches", "set-url",
]);

pub fn check_git_remote(tokens: &[Token]) -> Verdict {
    if tokens.get(1).is_none_or(|a| !GIT_REMOTE_MUTATING.contains(a)) { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied }
}

static GIT_C_KV_EXACT: WordSet = WordSet::new(&[
    "core.askPass=",
    "core.askPass=false",
    "core.askpass=",
    "core.askpass=false",
    "core.pager=cat",
    "core.pager=less",
    "credential.helper=",
    "http.sslVerify=false",
    "http.sslVerify=true",
    "http.sslverify=false",
    "http.sslverify=true",
    "init.defaultBranch=main",
    "init.defaultBranch=master",
    "init.defaultBranch=trunk",
]);

fn is_safe_git_c_kv(kv: &str) -> bool {
    if GIT_C_KV_EXACT.contains(kv) {
        return true;
    }
    let Some((key, _)) = kv.split_once('=') else {
        return false;
    };
    let key_lc = key.to_ascii_lowercase();
    matches!(key_lc.as_str(), "safe.directory")
        || key_lc.starts_with("advice.")
        || key_lc.starts_with("color.")
}

pub fn is_safe_git(tokens: &[Token]) -> Verdict {
    let mut filtered: Vec<Token> = Vec::with_capacity(tokens.len());
    if let Some(t) = tokens.first() {
        filtered.push(t.clone());
    } else {
        return Verdict::Denied;
    }
    let mut i = 1;
    let mut saw_c = false;
    while i < tokens.len() {
        let t = &tokens[i];
        if t == "-c" {
            let Some(next) = tokens.get(i + 1) else {
                return Verdict::Denied;
            };
            if !is_safe_git_c_kv(next.as_str()) {
                return Verdict::Denied;
            }
            saw_c = true;
            i += 2;
            continue;
        }
        filtered.push(t.clone());
        i += 1;
    }
    if !saw_c {
        return crate::registry::toml_dispatch(tokens).unwrap_or(Verdict::Denied);
    }
    crate::registry::toml_dispatch(&filtered).unwrap_or(Verdict::Denied)
}

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

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

    safe! {
        git_log: "git log --oneline -5",
        git_log_graph: "git log --graph --all --oneline",
        git_log_author: "git log --author=foo --since=2024-01-01",
        git_log_format: "git log --format='%H %s' -n 10",
        git_log_stat: "git log --stat --no-merges",
        git_log_grep_i: "git log --all --oneline --grep=pattern -i",
        git_log_bare: "git log",
        git_diff: "git diff --stat",
        git_diff_cached: "git diff --cached --name-only",
        git_diff_algorithm: "git diff --diff-algorithm=patience",
        git_show: "git show HEAD:some/file.rb",
        git_show_stat: "git show --stat HEAD",
        git_status: "git status --porcelain",
        git_status_short: "git status -sb",
        git_fetch: "git fetch origin master",
        git_fetch_all: "git fetch --all --prune",
        git_ls_tree: "git ls-tree HEAD",
        git_ls_tree_r: "git ls-tree -r --name-only HEAD",
        git_grep: "git grep pattern",
        git_grep_flags: "git grep -n -i --count pattern",
        git_rev_parse: "git rev-parse HEAD",
        git_rev_parse_toplevel: "git rev-parse --show-toplevel",
        git_merge_base: "git merge-base master HEAD",
        git_merge_tree: "git merge-tree HEAD~1 HEAD master",
        git_version: "git --version",
        git_help: "git help log",
        git_shortlog: "git shortlog -s",
        git_shortlog_ne: "git shortlog -sne",
        git_describe: "git describe --tags",
        git_describe_always: "git describe --always --abbrev=7",
        git_blame: "git blame file.rb",
        git_blame_flags: "git blame -L 10,20 -w file.rb",
        git_reflog: "git reflog",
        git_reflog_n: "git reflog -n 10",
        git_ls_files: "git ls-files",
        git_ls_files_others: "git ls-files --others --exclude-standard",
        git_ls_remote: "git ls-remote origin",
        git_ls_remote_tags: "git ls-remote --tags origin",
        git_diff_tree: "git diff-tree --no-commit-id -r HEAD",
        git_cat_file: "git cat-file -p HEAD",
        git_cat_file_t: "git cat-file -t HEAD",
        git_check_ignore: "git check-ignore .jj/",
        git_check_ignore_v: "git check-ignore -v .gitignore",
        git_name_rev: "git name-rev HEAD",
        git_for_each_ref: "git for-each-ref refs/heads",
        git_for_each_ref_format: "git for-each-ref --format='%(refname)' --sort=-committerdate",
        git_count_objects: "git count-objects -v",
        git_verify_commit: "git verify-commit HEAD",
        git_verify_tag: "git verify-tag v1.0",
        git_merge_base_all: "git merge-base --all HEAD main",
        git_c_flag: "git -C /some/repo diff --stat",
        git_c_nested: "git -C /some/repo -C nested log",
        git_config_askpass_disable: "git -c core.askPass=false ls-remote https://github.com/foo/bar",
        git_config_askpass_empty: "git -c core.askPass= ls-remote https://github.com/foo/bar",
        git_config_askpass_lower: "git -c core.askpass=false fetch origin",
        git_config_credential_helper_empty: "git -c credential.helper= ls-remote origin",
        git_config_safe_directory: "git -c safe.directory=/repo status",
        git_config_safe_directory_star: "git -c safe.directory=* log",
        git_config_advice: "git -c advice.detachedHead=false log HEAD",
        git_config_color_ui: "git -c color.ui=never status",
        git_config_pager_cat: "git -c core.pager=cat log",
        git_config_sslverify_false: "git -c http.sslVerify=false fetch origin",
        git_config_init_default_branch: "git -c init.defaultBranch=main ls-remote origin",
        git_config_multiple_c: "git -c core.askPass=false -c credential.helper= ls-remote origin",
        git_config_c_with_C: "git -C /repo -c core.askPass=false log",
        git_remote_bare: "git remote",
        git_remote_v: "git remote -v",
        git_remote_get_url: "git remote get-url origin",
        git_remote_show: "git remote show origin",
        git_branch_list: "git branch",
        git_branch_list_all: "git branch -a",
        git_branch_list_verbose: "git branch -v",
        git_branch_contains: "git branch --contains abc123",
        git_stash_list: "git stash list",
        git_stash_show: "git stash show -p",
        git_tag_list: "git tag",
        git_tag_list_pattern: "git tag -l 'v1.*'",
        git_tag_list_long: "git tag --list",
        git_config_list: "git config --list",
        git_config_get: "git config --get user.name",
        git_config_get_all: "git config --get-all remote.origin.url",
        git_config_get_regexp: "git config --get-regexp 'remote.*'",
        git_config_l: "git config -l",
        git_config_local_list: "git config --local --list",
        git_config_global_list: "git config --global --list",
        git_config_system_l: "git config --system -l",
        git_config_local_get: "git config --local --get user.name",
        git_config_file_list: "git config --file .gitconfig --list",
        git_config_f_list: "git config -f .gitconfig -l",
        git_config_show_origin_list: "git config --show-origin --list",
        git_config_show_scope_local_list: "git config --show-scope --local --list",
        git_config_name_only_list: "git config --name-only --list",
        git_config_worktree_list: "git config --worktree -l",
        git_config_blob_get: "git config --blob HEAD:.gitmodules --get submodule.foo.url",
        git_config_local_help: "git config --local --help",
        git_worktree_list: "git worktree list",
        git_worktree_list_porcelain: "git worktree list --porcelain",
        git_worktree_list_verbose: "git worktree list -v",
        git_notes_show: "git notes show HEAD",
        git_notes_list: "git notes list",
        git_worktree_help: "git worktree --help",
        git_worktree_help_h: "git worktree -h",
    }

    denied! {
        git_rebase_help_denied: "git rebase --help",
        git_push_help_denied: "git push --help",
        git_fetch_upload_pack_denied: "git fetch --upload-pack=malicious origin",
        git_ls_remote_upload_pack_denied: "git ls-remote --upload-pack malicious origin",
        git_tag_delete_denied: "git tag -d v1.0",
        git_tag_annotate_denied: "git tag -a v1.0 -m 'release'",
        git_tag_sign_denied: "git tag -s v1.0",
        git_tag_force_denied: "git tag -f v1.0",
        git_config_set_denied: "git config user.name foo",
        git_config_unset_denied: "git config --unset user.name",
        git_config_local_set_denied: "git config --local user.name foo",
        git_config_local_unset_denied: "git config --local --unset user.name",
        git_config_local_evil_list_denied: "git config --local --evil --list",
        git_config_list_trailing_flag_denied: "git config --list --evil",
        git_config_get_trailing_flag_denied: "git config --get user.name --evil",
        git_worktree_add_denied: "git worktree add ../new-branch",
        git_worktree_list_trailing_denied: "git worktree list --evil",
        git_notes_list_trailing_flag_denied: "git notes list --evil",
        git_branch_d_denied: "git branch -D feature",
        git_branch_delete_denied: "git branch --delete feature",
        git_branch_move_denied: "git branch -m old new",
        git_branch_copy_denied: "git branch -c old new",
        git_branch_set_upstream_denied: "git branch --set-upstream-to=origin/main",
        git_remote_add_denied: "git remote add upstream https://github.com/foo/bar",
        git_remote_remove_denied: "git remote remove upstream",
        git_remote_rename_denied: "git remote rename origin upstream",
        git_config_flag_denied: "git -c user.name=foo log",
        git_config_editor_denied: "git -c core.editor=vim commit",
        git_config_ssh_command_denied: "git -c core.sshCommand=evil ls-remote origin",
        git_config_alias_denied: "git -c alias.evil=push log",
        git_config_hooks_path_denied: "git -c core.hooksPath=/tmp log",
        git_config_pager_arbitrary_denied: "git -c core.pager=evil log",
        git_config_no_value_denied: "git -c log",
        git_help_bypass_denied: "git push -- --help",
    }
}