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};

static XCRUN_SHOW_FLAGS: WordSet = WordSet::new(&[
    "--find", "--show-sdk-build-version", "--show-sdk-path",
    "--show-sdk-platform-path", "--show-sdk-platform-version",
    "--show-sdk-version", "--show-toolchain-path",
]);

static NOTARYTOOL_SAFE: WordSet = WordSet::new(&["history", "info", "log"]);

pub fn is_safe_xcrun(tokens: &[Token]) -> Verdict {
    if tokens.len() < 2 {
        return Verdict::Denied;
    }
    let mut i = 1;
    while i < tokens.len() {
        let t = &tokens[i];
        if t == "--sdk" || t == "--toolchain" {
            i += 2;
            continue;
        }
        if t.is_one_of(&["-v", "--verbose", "-l", "--log", "-n", "--no-cache"]) {
            i += 1;
            continue;
        }
        break;
    }
    if i >= tokens.len() {
        return Verdict::Denied;
    }
    if XCRUN_SHOW_FLAGS.contains(&tokens[i]) {
        return Verdict::Allowed(SafetyLevel::Inert);
    }
    if tokens[i] == "simctl" {
        return if tokens.get(i + 1).is_some_and(|a| a == "list") { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
    }
    if tokens[i] == "stapler" {
        return if tokens.get(i + 1).is_some_and(|a| a == "validate") { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
    }
    if tokens[i] == "notarytool" {
        return if tokens.get(i + 1).is_some_and(|a| NOTARYTOOL_SAFE.contains(a)) { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
    }
    Verdict::Denied

}

pub(in crate::handlers::xcode) fn dispatch(cmd: &str, tokens: &[Token]) -> Option<Verdict> {
    if cmd == "xcrun" {
        Some(is_safe_xcrun(tokens))
    } else {
        None
    }
}

pub(in crate::handlers::xcode) fn command_docs() -> Vec<crate::docs::CommandDoc> {
    use crate::docs::CommandDoc;
    vec![
        CommandDoc::handler("xcrun",
            "https://ss64.com/mac/xcrun.html",
            "Allowed: --find, --show-sdk-*, --show-toolchain-path. \
             Multi-level: notarytool history/info/log, simctl list, stapler validate. \
             Prefix flags --sdk/--toolchain (with arg), -v/-l/-n are skipped."),
    ]
}

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

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

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

    safe! {
        xcrun_find: "xcrun --find clang",
        xcrun_show_sdk_path: "xcrun --show-sdk-path",
        xcrun_show_sdk_version: "xcrun --show-sdk-version",
        xcrun_show_sdk_platform_path: "xcrun --show-sdk-platform-path",
        xcrun_show_toolchain_path: "xcrun --show-toolchain-path",
        xcrun_sdk_flag_with_find: "xcrun --sdk iphoneos --find clang",
        xcrun_simctl_list: "xcrun simctl list",
        xcrun_stapler_validate: "xcrun stapler validate /tmp/app",
        xcrun_notarytool_history: "xcrun notarytool history",
        xcrun_notarytool_info: "xcrun notarytool info abc-123",
        xcrun_notarytool_log: "xcrun notarytool log abc-123",
    }

    denied! {
        xcrun_simctl_delete_denied: "xcrun simctl delete all",
        xcrun_simctl_boot_denied: "xcrun simctl boot DEVICE_ID",
        xcrun_arbitrary_tool_denied: "xcrun clang file.c",
        xcrun_no_args_denied: "xcrun",
        xcrun_stapler_staple_denied: "xcrun stapler staple /tmp/app",
        xcrun_notarytool_submit_denied: "xcrun notarytool submit app.zip",
        xcrun_notarytool_bare_denied: "xcrun notarytool",
    }
}