safe-chains 0.125.0

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

static SK_POLICY: FlagPolicy = FlagPolicy {
    standalone: WordSet::flags(&[
        "--ansi", "--border", "--cycle", "--exact", "--exit-0",
        "--help", "--inline-info", "--keep-right", "--layout",
        "--multi", "--no-bold", "--no-clear", "--no-hscroll", "--no-mouse",
        "--no-sort", "--print-query", "--print-score", "--print0",
        "--read0", "--regex", "--reverse", "--select-1",
        "--tac", "--version",
        "-0", "-1", "-V", "-e", "-h", "-m",
    ]),
    valued: WordSet::flags(&[
        "--algo", "--border-label", "--color", "--delimiter",
        "--expect", "--header", "--header-lines", "--height",
        "--history", "--history-size", "--margin", "--min-height",
        "--nth", "--output-format", "--padding",
        "--pre-select-file", "--pre-select-items", "--pre-select-n",
        "--prompt", "--query", "--scrollbar", "--skip-to-pattern",
        "--tabstop", "--tiebreak", "--with-nth",
        "-d", "-n", "-p", "-q",
    ]),
    bare: true,
    max_positional: None,
    flag_style: FlagStyle::Strict,
    numeric_dash: false,
};

static HISTORY_FLAGS: WordSet = WordSet::flags(&["--history"]);

fn has_history(tokens: &[Token]) -> bool {
    tokens[1..].iter().any(|t| {
        HISTORY_FLAGS.contains(t)
            || t.as_str().starts_with("--history=")
    })
}

pub fn is_safe_sk(tokens: &[Token]) -> Verdict {
    if !policy::check(tokens, &SK_POLICY) {
        return Verdict::Denied;
    }
    if tokens.len() > 1 && has_history(tokens) {
        Verdict::Allowed(SafetyLevel::SafeWrite)
    } else {
        Verdict::Allowed(SafetyLevel::Inert)
    }
}

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

pub fn command_docs() -> Vec<crate::docs::CommandDoc> {
    vec![
        crate::docs::CommandDoc::handler(
            "sk",
            "https://github.com/lotabout/skim",
            "Fuzzy finder. Display, filtering, and layout flags allowed. --history allowed (SafeWrite).",
        ),
    ]
}

#[cfg(test)]
pub(super) const REGISTRY: &[crate::handlers::CommandEntry] = &[
    crate::handlers::CommandEntry::Custom { cmd: "sk", valid_prefix: None },
];

#[cfg(test)]
mod tests {
    use crate::is_safe_command;
    use crate::command_verdict;
    use crate::verdict::{SafetyLevel, Verdict};
    fn check(cmd: &str) -> bool { is_safe_command(cmd) }

    safe! {
        sk_bare: "sk",
        sk_help: "sk --help",
        sk_version: "sk --version",
        sk_multi: "sk -m",
        sk_exact: "sk --exact",
        sk_query: "sk -q pattern",
        sk_height: "sk --height 40%",
        sk_ansi: "sk --ansi",
        sk_prompt: "sk --prompt '> '",
        sk_header: "sk --header 'Pick one'",
        sk_color: "sk --color=dark",
        sk_delimiter: "sk -d : -n 2",
        sk_select_1: "sk --select-1 --exit-0",
        sk_history: "sk --history /tmp/sk-history",
        sk_history_eq: "sk --history=/tmp/sk-history",
    }

    safe_write! {
        sk_history_level: "sk --history /tmp/sk-history",
    }

    denied! {
        sk_preview: "sk --preview 'cat {}'",
        sk_cmd: "sk -c 'find .'",
        sk_interactive: "sk -i -c 'grep {}'",
        sk_bind: "sk --bind 'ctrl-j:accept'",
    }

    #[test]
    fn sk_bare_is_inert() {
        assert_eq!(command_verdict("sk"), Verdict::Allowed(SafetyLevel::Inert));
    }
}