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};
use crate::policy::{self, FlagPolicy, FlagStyle};

static TEA_READ_ONLY_SUBCOMMANDS: WordSet = WordSet::new(&[
    "b", "branch", "branches",
    "i", "issue", "issues",
    "label", "labels",
    "milestone", "milestones", "ms",
    "n", "notification", "notifications",
    "org", "organization", "organizations",
    "pr", "pull", "pulls",
    "r", "release", "releases",
    "repo", "repos",
    "t", "time", "times",
]);

static TEA_READ_ONLY_ACTIONS: WordSet =
    WordSet::new(&["list", "view"]);

static TEA_ALWAYS_SAFE: WordSet =
    WordSet::new(&["--version", "-v", "whoami"]);

static TEA_LOGIN_SAFE: WordSet =
    WordSet::new(&["list"]);

static TEA_LIST_POLICY: FlagPolicy = FlagPolicy {
    standalone: WordSet::flags(&["--help", "-h"]),
    valued: WordSet::flags(&[
        "--fields", "--limit", "--login", "--output",
        "--page", "--repo", "--state",
        "-L", "-R", "-f", "-l", "-o", "-p", "-s",
    ]),
    bare: true,
    max_positional: None,
    flag_style: FlagStyle::Strict,
};

static TEA_VIEW_POLICY: FlagPolicy = FlagPolicy {
    standalone: WordSet::flags(&[
        "--comments", "--help",
        "-c", "-h",
    ]),
    valued: WordSet::flags(&[
        "--login", "--output", "--repo",
        "-R", "-l", "-o",
    ]),
    bare: false,
    max_positional: None,
    flag_style: FlagStyle::Strict,
};

pub fn is_safe_tea(tokens: &[Token]) -> Verdict {
    if tokens.len() < 2 {
        return Verdict::Denied;
    }
    let subcmd = &tokens[1];

    if TEA_READ_ONLY_SUBCOMMANDS.contains(subcmd) {
        if tokens.len() == 2 {
            return Verdict::Allowed(SafetyLevel::Inert);
        }
        if !TEA_READ_ONLY_ACTIONS.contains(&tokens[2]) {
            return Verdict::Denied;
        }
        let policy = if tokens[2] == "view" {
            &TEA_VIEW_POLICY
        } else {
            &TEA_LIST_POLICY
        };
        return if policy::check(&tokens[2..], policy) { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
    }

    if TEA_ALWAYS_SAFE.contains(subcmd) {
        return if tokens.len() == 2 { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
    }

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

    Verdict::Denied

}

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

pub fn command_docs() -> Vec<crate::docs::CommandDoc> {
    use crate::docs::{CommandDoc, DocBuilder, wordset_items};
    vec![
        CommandDoc::handler("tea",
            "https://gitea.com/gitea/tea",
            DocBuilder::new()
                .section(format!("Subcommands {} are allowed with actions: {} or bare invocation.",
                    wordset_items(&TEA_READ_ONLY_SUBCOMMANDS),
                    wordset_items(&TEA_READ_ONLY_ACTIONS)))
                .section(format!("Always safe: {}.",
                    wordset_items(&TEA_ALWAYS_SAFE)))
                .section("logins/login (list only).")
                .section("")
                .build()),
    ]
}

#[cfg(test)]
pub(super) const REGISTRY: &[crate::handlers::CommandEntry] = &[
    crate::handlers::CommandEntry::Subcommand { cmd: "tea", bare_ok: false, subs: &[
        crate::handlers::SubEntry::Nested { name: "issue", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "issues", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "i", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "pr", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "pull", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "pulls", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "release", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "releases", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "r", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "repo", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "repos", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "branch", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "branches", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "b", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "label", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "labels", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "milestone", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "milestones", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "ms", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "org", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "organization", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "organizations", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "notification", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "notifications", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "n", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "time", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "times", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "t", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
            crate::handlers::SubEntry::Policy { name: "view" },
        ]},
        crate::handlers::SubEntry::Nested { name: "login", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
        ]},
        crate::handlers::SubEntry::Nested { name: "logins", subs: &[
            crate::handlers::SubEntry::Policy { name: "list" },
        ]},
    ]},
];

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

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

    safe! {
        tea_issue_list: "tea issue list",
        tea_issues_list: "tea issues list",
        tea_issue_view: "tea issue view 1",
        tea_issue_view_comments: "tea issue view 1 --comments",
        tea_pull_list: "tea pull list",
        tea_pull_list_state: "tea pull list --state open",
        tea_pr_view: "tea pr view 1",
        tea_release_list: "tea release list",
        tea_repo_bare: "tea repo",
        tea_repo_list: "tea repos list",
        tea_branch_list: "tea branch list",
        tea_label_list: "tea labels list",
        tea_milestone_list: "tea milestones list",
        tea_org_list: "tea org list",
        tea_notifications_bare: "tea notifications",
        tea_times_list: "tea times list",
        tea_whoami: "tea whoami",
        tea_version: "tea --version",
        tea_login_list: "tea login list",
        tea_logins_list: "tea logins list",
    }

    denied! {
        tea_whoami_with_extra_denied: "tea whoami --extra",
    }
}