safe-chains 0.125.0

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

static INERT_SUBS: &[&str] = &[
    "display", "display-message",
    "has", "has-session",
    "info",
    "list-buffers", "list-clients", "list-commands",
    "list-keys", "list-panes", "list-sessions", "list-windows",
    "ls", "lsb", "lsc", "lscm", "lsk", "lsp", "lsw",
    "show", "show-environment", "show-options",
    "showenv",
    "start", "start-server",
];

static SAFE_WRITE_SUBS: &[&str] = &[
    "a", "attach", "attach-session",
    "detach", "detach-client",
    "kill-pane", "kill-server", "kill-session", "kill-window",
    "killp", "killw",
    "new", "new-session", "new-window", "neww",
    "rename", "rename-session", "rename-window", "renamew",
    "resize-pane", "resize-window", "resizep", "resizew",
    "respawn-pane", "respawn-window", "respawnp", "respawnw",
    "select-pane", "select-window", "selectp", "selectw",
    "set", "set-environment", "set-option",
    "setenv",
    "split", "split-window", "splitw",
    "swap-pane", "swap-window", "swapp", "swapw",
    "switch", "switch-client", "switchc",
];

static DELEGATION_SUBS: &[&str] = &[
    "confirm", "confirm-before",
    "if", "if-shell",
    "pipe-pane", "pipep",
    "run", "run-shell",
];

fn is_inert_sub(s: &str) -> bool {
    INERT_SUBS.binary_search(&s).is_ok()
}

fn is_safe_write_sub(s: &str) -> bool {
    SAFE_WRITE_SUBS.binary_search(&s).is_ok()
}

fn is_delegation_sub(s: &str) -> bool {
    DELEGATION_SUBS.binary_search(&s).is_ok()
}

fn find_command_arg(tokens: &[Token], sub: &str) -> Option<usize> {
    match sub {
        "run-shell" | "run" => {
            let mut i = 1;
            while i < tokens.len() {
                let t = tokens[i].as_str();
                if t == "-b" {
                    i += 1;
                    continue;
                }
                if t == "-d" || t == "-t" {
                    i += 2;
                    continue;
                }
                if !t.starts_with('-') {
                    return Some(i);
                }
                return None;
            }
            None
        }
        "if-shell" | "if" => {
            let mut i = 1;
            while i < tokens.len() {
                let t = tokens[i].as_str();
                if t == "-b" {
                    i += 1;
                    continue;
                }
                if t == "-F" || t == "-t" {
                    i += 2;
                    continue;
                }
                if !t.starts_with('-') {
                    return Some(i);
                }
                return None;
            }
            None
        }
        "pipe-pane" | "pipep" => {
            let mut i = 1;
            while i < tokens.len() {
                let t = tokens[i].as_str();
                if t == "-I" || t == "-O" {
                    i += 1;
                    continue;
                }
                if t == "-o" || t == "-t" {
                    i += 2;
                    continue;
                }
                if !t.starts_with('-') {
                    return Some(i);
                }
                return None;
            }
            None
        }
        "confirm-before" | "confirm" => {
            let mut i = 1;
            while i < tokens.len() {
                let t = tokens[i].as_str();
                if t == "-b" {
                    i += 1;
                    continue;
                }
                if t == "-p" || t == "-t" {
                    i += 2;
                    continue;
                }
                if !t.starts_with('-') {
                    return Some(i);
                }
                return None;
            }
            None
        }
        _ => None,
    }
}

pub fn is_safe_tmux(tokens: &[Token]) -> Verdict {
    if tokens.len() < 2 {
        return Verdict::Denied;
    }

    let mut cmd_idx = 1;
    while cmd_idx < tokens.len() {
        let t = tokens[cmd_idx].as_str();
        if t == "-S" || t == "-L" || t == "-f" {
            cmd_idx += 2;
            continue;
        }
        if t == "-l" || t == "-u" || t == "-v" || t == "-T" || t == "-N" {
            cmd_idx += 1;
            continue;
        }
        if matches!(t, "--help" | "-h" | "--version" | "-V") && cmd_idx + 1 >= tokens.len() {
            return Verdict::Allowed(SafetyLevel::Inert);
        }
        break;
    }

    if cmd_idx >= tokens.len() {
        return Verdict::Denied;
    }

    let sub = tokens[cmd_idx].as_str();

    if is_inert_sub(sub) {
        return Verdict::Allowed(SafetyLevel::Inert);
    }

    if is_safe_write_sub(sub) {
        return Verdict::Allowed(SafetyLevel::SafeWrite);
    }

    if is_delegation_sub(sub) {
        let rest = &tokens[cmd_idx..];
        if let Some(arg_idx) = find_command_arg(rest, sub) {
            let inner = rest[arg_idx].as_str();
            let v = crate::command_verdict(inner);
            if (sub == "if-shell" || sub == "if")
                && let Some(then_idx) = rest.get(arg_idx + 1) {
                let then_v = crate::command_verdict(then_idx.as_str());
                if !then_v.is_allowed() {
                    return Verdict::Denied;
                }
                if let Some(else_tok) = rest.get(arg_idx + 2) {
                    let else_v = crate::command_verdict(else_tok.as_str());
                    return v.combine(then_v).combine(else_v);
                }
                return v.combine(then_v);
            }
            return v;
        }
        return Verdict::Denied;
    }

    if sub == "send-keys" || sub == "send" {
        return Verdict::Allowed(SafetyLevel::SafeWrite);
    }

    Verdict::Denied
}

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

pub fn command_docs() -> Vec<crate::docs::CommandDoc> {
    vec![
        crate::docs::CommandDoc::handler("tmux",
            "https://man7.org/linux/man-pages/man1/tmux.1.html",
            "Read-only: list-sessions, list-windows, list-panes, list-clients, list-buffers, \
             list-keys, list-commands, show-options, show-environment, display-message, info, \
             has-session, start-server. \
             Session management (SafeWrite): new-session, kill-session, kill-window, kill-pane, \
             kill-server, attach-session, detach-client, switch-client, new-window, split-window, \
             select-window, select-pane, rename-session, rename-window, resize-pane, resize-window, \
             set-option, set-environment, send-keys. \
             Delegation: run-shell, if-shell, pipe-pane, confirm-before \
             (recursively validates inner commands)."),
    ]
}

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

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

    safe! {
        tmux_ls: "tmux list-sessions",
        tmux_ls_short: "tmux ls",
        tmux_lsw: "tmux list-windows",
        tmux_lsw_short: "tmux lsw",
        tmux_lsp: "tmux list-panes",
        tmux_lsp_short: "tmux lsp",
        tmux_lsc: "tmux list-clients",
        tmux_lsb: "tmux list-buffers",
        tmux_lsk: "tmux list-keys",
        tmux_lscm: "tmux list-commands",
        tmux_show: "tmux show-options",
        tmux_show_short: "tmux show",
        tmux_showenv: "tmux show-environment",
        tmux_showenv_short: "tmux showenv",
        tmux_display: "tmux display-message",
        tmux_display_short: "tmux display",
        tmux_info: "tmux info",
        tmux_has: "tmux has-session",
        tmux_has_short: "tmux has",
        tmux_start: "tmux start-server",
        tmux_new_session: "tmux new-session",
        tmux_new_short: "tmux new",
        tmux_kill_session: "tmux kill-session",
        tmux_kill_window: "tmux kill-window",
        tmux_kill_pane: "tmux kill-pane",
        tmux_kill_server: "tmux kill-server",
        tmux_attach: "tmux attach-session",
        tmux_attach_short: "tmux attach",
        tmux_attach_a: "tmux a",
        tmux_detach: "tmux detach-client",
        tmux_switch: "tmux switch-client",
        tmux_neww: "tmux new-window",
        tmux_splitw: "tmux split-window",
        tmux_selectw: "tmux select-window",
        tmux_selectp: "tmux select-pane",
        tmux_rename: "tmux rename-session",
        tmux_renamew: "tmux rename-window",
        tmux_resizep: "tmux resize-pane",
        tmux_resizew: "tmux resize-window",
        tmux_set: "tmux set-option",
        tmux_setenv: "tmux set-environment",
        tmux_send_keys: "tmux send-keys",
        tmux_send: "tmux send",
        tmux_socket: "tmux -S /tmp/sock ls",
        tmux_label: "tmux -L test ls",
        tmux_run_safe: "tmux run-shell 'git status'",
        tmux_run_safe_short: "tmux run 'ls -la'",
        tmux_if_shell_safe: "tmux if-shell 'true' 'ls'",
        tmux_pipe_pane_safe: "tmux pipe-pane 'cat'",
        tmux_confirm_safe: "tmux confirm-before 'ls'",
        tmux_if_shell_format: "tmux if-shell -F '#{pane_in_mode}' 'ls'",
        tmux_pipe_pane_output: "tmux pipe-pane -o /tmp/log 'cat'",
        tmux_run_background: "tmux run-shell -b 'git status'",
        tmux_run_delay: "tmux run-shell -d 5 'ls'",
        tmux_help: "tmux --help",
        tmux_swap_pane: "tmux swap-pane",
        tmux_swap_window: "tmux swap-window",
        tmux_respawn_pane: "tmux respawn-pane",
        tmux_respawn_window: "tmux respawn-window",
    }

    denied! {
        tmux_bare_denied: "tmux",
        tmux_source_denied: "tmux source-file ~/.tmux.conf",
        tmux_run_unsafe_denied: "tmux run-shell 'rm -rf /'",
        tmux_if_shell_unsafe_denied: "tmux if-shell 'true' 'rm -rf /'",
        tmux_pipe_pane_unsafe_denied: "tmux pipe-pane 'rm -rf /'",
        tmux_confirm_unsafe_denied: "tmux confirm-before 'rm -rf /'",
        tmux_run_no_cmd_denied: "tmux run-shell",
        tmux_if_shell_format_unsafe_denied: "tmux if-shell -F '#{cond}' 'rm -rf /'",
        tmux_pipe_pane_output_unsafe_denied: "tmux pipe-pane -o /tmp/log 'rm -rf /'",
        tmux_unknown_denied: "tmux load-buffer foo",
    }

    inert! {
        level_tmux_ls: "tmux ls",
        level_tmux_info: "tmux info",
        level_tmux_show: "tmux show-options",
    }

    safe_write! {
        level_tmux_new: "tmux new-session",
        level_tmux_kill: "tmux kill-session",
        level_tmux_attach: "tmux attach-session",
        level_tmux_send: "tmux send-keys",
    }
}