safe-chains 0.201.0

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

static CONVERT_FORMATS: WordSet = WordSet::new(&["binary1", "json", "swift", "xml1"]);
static EXTRACT_FORMATS: WordSet = WordSet::new(&["binary1", "json", "raw", "xml1"]);

pub fn check_plutil_convert(tokens: &[Token]) -> Verdict {
    if tokens.len() == 2 && matches!(tokens[1].as_str(), "--help" | "-h") {
        return Verdict::Allowed(SafetyLevel::Inert);
    }
    let Some(fmt) = tokens.get(1) else {
        return Verdict::Denied;
    };
    if !CONVERT_FORMATS.contains(fmt) {
        return Verdict::Denied;
    }
    walk_modifier_args(tokens, 2)
}

pub fn check_plutil_extract(tokens: &[Token]) -> Verdict {
    if tokens.len() == 2 && matches!(tokens[1].as_str(), "--help" | "-h") {
        return Verdict::Allowed(SafetyLevel::Inert);
    }
    let Some(_keypath) = tokens.get(1) else {
        return Verdict::Denied;
    };
    let Some(fmt) = tokens.get(2) else {
        return Verdict::Denied;
    };
    if !EXTRACT_FORMATS.contains(fmt) {
        return Verdict::Denied;
    }
    walk_modifier_args(tokens, 3)
}

fn walk_modifier_args(tokens: &[Token], start: usize) -> Verdict {
    let mut i = start;
    let mut output_mode = OutputMode::InPlace;
    while i < tokens.len() {
        match tokens[i].as_str() {
            "-r" | "-s" => i += 1,
            "-o" => {
                let Some(value) = tokens.get(i + 1) else {
                    return Verdict::Denied;
                };
                if value.as_str().starts_with("--") {
                    return Verdict::Denied;
                }
                output_mode = if value.as_str() == "-" {
                    OutputMode::Stdout
                } else {
                    OutputMode::File
                };
                i += 2;
            }
            "--" => break,
            arg if arg.starts_with('-') => return Verdict::Denied,
            _ => i += 1,
        }
    }
    match output_mode {
        OutputMode::Stdout => Verdict::Allowed(SafetyLevel::Inert),
        OutputMode::File | OutputMode::InPlace => Verdict::Allowed(SafetyLevel::SafeWrite),
    }
}

enum OutputMode {
    Stdout,
    File,
    InPlace,
}

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

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

    safe! {
        convert_stdout_xml: "plutil -convert xml1 -o - /tmp/foo.plist",
        convert_stdout_binary: "plutil -convert binary1 -o - /tmp/foo.plist",
        convert_stdout_json: "plutil -convert json -o - /tmp/foo.plist",
        convert_stdout_swift: "plutil -convert swift -o - /tmp/foo.plist",
        convert_stdout_stdin: "plutil -convert xml1 -o -",
        convert_stdout_readable: "plutil -convert json -r -o - /tmp/foo.plist",
        convert_stdout_silent: "plutil -convert xml1 -s -o - /tmp/foo.plist",
        convert_stdout_multi_input: "plutil -convert xml1 -o - -- /tmp/a.plist /tmp/b.plist",
        convert_stdout_piped: "plutil -convert xml1 -o - /tmp/foo.plist | grep boundary",
        convert_to_file: "plutil -convert json Info.plist -o out.json",
        convert_to_file_explicit: "plutil -convert xml1 -o /tmp/out.plist /tmp/in.plist",
        convert_in_place: "plutil -convert xml1 /tmp/foo.plist",
        convert_help: "plutil -convert --help",
        convert_help_short: "plutil -convert -h",
        extract_raw: "plutil -extract CFBundleVersion raw Info.plist",
        extract_json: "plutil -extract CFBundleShortVersionString json Info.plist",
        extract_to_file: "plutil -extract CFBundleURLTypes xml1 -o urls.plist Info.plist",
        extract_to_stdout: "plutil -extract CFBundleURLTypes xml1 -o - Info.plist",
        extract_help: "plutil -extract --help",
    }

    denied! {
        convert_bad_format: "plutil -convert invalid -o - /tmp/foo.plist",
        convert_objc_removed: "plutil -convert objc -o - /tmp/foo.plist",
        convert_no_format: "plutil -convert",
        convert_dash_dash_as_o_value: "plutil -convert xml1 -o -- /tmp/in",
        convert_extension_flag: "plutil -convert xml1 -e plist -o - /tmp/in",
        convert_unknown_flag: "plutil -convert xml1 -o - --unknown /tmp/in",
        extract_no_keypath: "plutil -extract",
        extract_bad_format: "plutil -extract Key bogus Info.plist",
        extract_unknown_flag: "plutil -extract Key json --evil Info.plist",
    }
}