safe-chains 0.110.0

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

static JJPR_AUTH_POLICY: FlagPolicy = FlagPolicy {
    standalone: WordSet::flags(&["--help", "-h"]),
    valued: WordSet::flags(&[]),
    bare: true,
    max_positional: None,
    flag_style: FlagStyle::Strict,
};

static JJPR_SUBMIT_DRY_POLICY: FlagPolicy = FlagPolicy {
    standalone: WordSet::flags(&[
        "--draft", "--dry-run", "--help", "--no-fetch", "--ready",
        "-h",
    ]),
    valued: WordSet::flags(&[
        "--base", "--remote", "--reviewer",
    ]),
    bare: true,
    max_positional: None,
    flag_style: FlagStyle::Strict,
};

static JJPR_STATUS_POLICY: FlagPolicy = FlagPolicy {
    standalone: WordSet::flags(&[
        "--dry-run", "--help", "--no-fetch",
        "-h",
    ]),
    valued: WordSet::flags(&[]),
    bare: true,
    max_positional: None,
    flag_style: FlagStyle::Strict,
};

static JJPR_MERGE_DRY_POLICY: FlagPolicy = FlagPolicy {
    standalone: WordSet::flags(&[
        "--dry-run", "--help", "--no-ci-check", "--no-fetch", "--watch",
        "-h",
    ]),
    valued: WordSet::flags(&[
        "--base", "--merge-method", "--reconcile-strategy", "--remote", "--required-approvals",
    ]),
    bare: true,
    max_positional: None,
    flag_style: FlagStyle::Strict,
};

static AUTH_ACTIONS: WordSet = WordSet::new(&["setup", "test"]);

fn is_safe_jjpr(tokens: &[Token]) -> Verdict {
    if tokens.len() < 2 {
        return Verdict::Allowed(SafetyLevel::Inert);
    }
    let subcmd = &tokens[1];

    if matches!(subcmd.as_str(), "--help" | "-h" | "--version" | "-V") {
        return if tokens.len() == 2 { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
    }

    if subcmd == "help" {
        return if tokens.len() <= 3 { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
    }

    if tokens.len() == 3 && matches!(tokens[2].as_str(), "--help" | "-h" | "help") {
        return Verdict::Allowed(SafetyLevel::Inert);
    }

    if subcmd == "auth" {
        if tokens.len() < 3 || !AUTH_ACTIONS.contains(&tokens[2]) {
            return Verdict::Denied;
        }
        return if policy::check(&tokens[2..], &JJPR_AUTH_POLICY) { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
    }

    if subcmd == "status" {
        return if policy::check(&tokens[1..], &JJPR_STATUS_POLICY) { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
    }

    if subcmd == "submit" {
        return if has_flag(&tokens[1..], None, Some("--dry-run"))
            && policy::check(&tokens[1..], &JJPR_SUBMIT_DRY_POLICY)
        { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
    }

    if subcmd == "merge" {
        return if has_flag(&tokens[1..], None, Some("--dry-run"))
            && policy::check(&tokens[1..], &JJPR_MERGE_DRY_POLICY)
        { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
    }

    if subcmd == "config" {
        return Verdict::Denied;
    }

    Verdict::Denied

}

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

pub fn command_docs() -> Vec<crate::docs::CommandDoc> {
    use crate::docs::{CommandDoc, DocBuilder};
    vec![
        CommandDoc::handler("jjpr",
            "https://github.com/michaeldhopkins/jjpr",
            DocBuilder::new()
                .section("Bare invocation allowed (displays stack status).")
                .section("status allowed.")
                .section("auth (test, setup).")
                .section("submit (requires --dry-run), merge (requires --dry-run).")
                .section("--help allowed on all subcommands.")
                .section("")
                .build()),
    ]
}

#[cfg(test)]
pub(super) const REGISTRY: &[crate::handlers::CommandEntry] = &[
    crate::handlers::CommandEntry::Subcommand { cmd: "jjpr", bare_ok: true, subs: &[
        crate::handlers::SubEntry::Nested { name: "auth", subs: &[
            crate::handlers::SubEntry::Policy { name: "test" },
            crate::handlers::SubEntry::Policy { name: "setup" },
        ]},
        crate::handlers::SubEntry::Policy { name: "config" },
        crate::handlers::SubEntry::Guarded { name: "merge", valid_suffix: "--dry-run" },
        crate::handlers::SubEntry::Policy { name: "status" },
        crate::handlers::SubEntry::Guarded { name: "submit", valid_suffix: "--dry-run" },
    ]},
];

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

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

    safe! {
        bare: "jjpr",
        help: "jjpr --help",
        help_short: "jjpr -h",
        version: "jjpr --version",
        version_short: "jjpr -V",
        auth_test: "jjpr auth test",
        auth_setup: "jjpr auth setup",
        auth_help: "jjpr auth --help",
        auth_help_short: "jjpr auth -h",
        auth_help_sub: "jjpr auth help",
        status_bare: "jjpr status",
        status_help: "jjpr status --help",
        status_help_short: "jjpr status -h",
        status_help_sub: "jjpr status help",
        status_no_fetch: "jjpr status --no-fetch",
        status_branch: "jjpr status cycle/renewal-request-sent-layout",
        status_branch_no_fetch: "jjpr status my-stack --no-fetch",
        submit_dry: "jjpr submit --dry-run",
        submit_dry_bookmark: "jjpr submit my-stack --dry-run",
        submit_dry_draft: "jjpr submit --dry-run --draft",
        submit_dry_reviewer: "jjpr submit --dry-run --reviewer user",
        submit_help: "jjpr submit --help",
        submit_help_short: "jjpr submit -h",
        submit_help_sub: "jjpr submit help",
        merge_dry: "jjpr merge --dry-run",
        merge_dry_bookmark: "jjpr merge my-stack --dry-run",
        merge_dry_method: "jjpr merge --dry-run --merge-method squash",
        merge_dry_watch: "jjpr merge --dry-run --watch",
        merge_dry_remote: "jjpr merge --dry-run --remote origin",
        merge_dry_reconcile: "jjpr merge --dry-run --reconcile-strategy rebase",
        merge_help: "jjpr merge --help",
        merge_help_short: "jjpr merge -h",
        merge_help_sub: "jjpr merge help",
        help_sub: "jjpr help",
        help_sub_submit: "jjpr help submit",
        help_sub_merge: "jjpr help merge",
        help_sub_auth: "jjpr help auth",
        help_sub_config: "jjpr help config",
        help_sub_status: "jjpr help status",
        config_help: "jjpr config --help",
        config_help_short: "jjpr config -h",
        config_help_sub: "jjpr config help",
    }

    denied! {
        submit_denied: "jjpr submit",
        submit_bookmark_denied: "jjpr submit my-stack",
        merge_denied: "jjpr merge",
        merge_bookmark_denied: "jjpr merge my-stack",
        config_init_denied: "jjpr config init",
        unknown_sub_denied: "jjpr foo",
        unknown_flag_denied: "jjpr --unknown",
        auth_bare_denied: "jjpr auth",
        auth_unknown_denied: "jjpr auth login",
        status_unknown_flag_denied: "jjpr status --verbose",
    }
}