safe-chains 0.125.0

Auto-allow safe, read-only bash commands in agentic coding tools
Documentation
use std::io;
use std::process;

use clap::Parser;
use serde::Deserialize;
use serde_json::json;

use safe_chains::cli::Cli;
use safe_chains::verdict::{SafetyLevel, Verdict};

#[derive(Deserialize)]
struct ToolInput {
    command: String,
}

#[derive(Deserialize)]
struct HookInput {
    tool_input: ToolInput,
}

fn print_docs() {
    let docs = safe_chains::docs::all_command_docs();
    print!("{}", safe_chains::docs::render_markdown(&docs));
}

fn print_opencode_config() {
    let patterns = safe_chains::all_opencode_patterns();
    print!("{}", safe_chains::docs::render_opencode_json(&patterns));
}

fn run_cli(command: &str, threshold: SafetyLevel) {
    let verdict = safe_chains::command_verdict(command);
    let ok = match verdict {
        Verdict::Allowed(level) => level <= threshold,
        Verdict::Denied => false,
    };
    process::exit(i32::from(!ok));
}

fn emit_allow(reason: &str) {
    let output = json!({
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "allow",
            "permissionDecisionReason": reason,
        }
    });
    serde_json::to_writer(io::stdout(), &output).ok();
}

fn run_claude_hook() {
    let input: HookInput = match serde_json::from_reader(io::stdin()) {
        Ok(v) => v,
        Err(_) => process::exit(0),
    };

    if safe_chains::is_safe_command(&input.tool_input.command) {
        emit_allow("All commands in chain are safe read-only utilities");
        return;
    }

    let patterns = safe_chains::allowlist::Matcher::load();
    if patterns.is_empty() {
        process::exit(0);
    }

    let Some(script) = safe_chains::cst::parse(&input.tool_input.command) else {
        process::exit(0);
    };

    let all_covered = script.0.iter().all(|stmt| {
        safe_chains::cst::is_safe_pipeline(&stmt.pipeline)
            || stmt
                .pipeline
                .commands
                .iter()
                .all(|cmd| safe_chains::allowlist::is_cmd_covered(cmd, &patterns))
    });

    if all_covered {
        emit_allow("All commands covered by safe-chains rules or user-approved settings");
    } else {
        process::exit(0);
    }
}

fn main() {
    let cli = Cli::try_parse();

    match cli {
        Ok(cli) => {
            if cli.setup {
                safe_chains::setup::run_setup();
            } else if cli.list_commands {
                print_docs();
            } else if cli.opencode_config {
                print_opencode_config();
            } else if let Some(command) = cli.command {
                let threshold = cli.level.unwrap_or(SafetyLevel::SafeWrite);
                run_cli(&command, threshold);
            } else {
                run_claude_hook();
            }
        }
        Err(e) if e.kind() == clap::error::ErrorKind::DisplayHelp
              || e.kind() == clap::error::ErrorKind::DisplayVersion =>
        {
            print!("{e}");
        }
        Err(_) => {
            run_claude_hook();
        }
    }
}