Skip to main content

git_worktree_manager/operations/
guard.rs

1//! `gw guard` — Claude Code hook helper that vets inbound Bash tool calls.
2//!
3//! Input format: a JSON object with at least `tool_name` and `tool_input`.
4//! For Bash, `tool_input.command` is matched against a small risk pattern
5//! list. If the command is risky AND the cwd looks unhealthy (missing or
6//! not a directory), the helper exits non-zero with a stderr message,
7//! causing Claude Code to refuse the tool call.
8
9use std::io::Read;
10use std::path::Path;
11
12use crate::error::{CwError, Result};
13
14const RISK_PATTERNS: &[&str] = &[
15    "git push",
16    "gh release",
17    "gh pr merge",
18    "npm publish",
19    "cargo publish",
20    "bun publish",
21    "pnpm publish",
22];
23
24#[derive(Debug, serde::Deserialize)]
25struct HookPayload {
26    tool_name: Option<String>,
27    tool_input: Option<serde_json::Value>,
28}
29
30fn read_input(source: &str) -> std::io::Result<String> {
31    if source == "-" {
32        let mut s = String::new();
33        std::io::stdin().read_to_string(&mut s)?;
34        Ok(s)
35    } else {
36        std::fs::read_to_string(source)
37    }
38}
39
40fn cwd_is_healthy(cwd: &Path) -> bool {
41    cwd.exists() && cwd.is_dir()
42}
43
44fn command_is_risky(cmd: &str) -> bool {
45    let normalized = cmd.split_ascii_whitespace().collect::<Vec<_>>().join(" ");
46    RISK_PATTERNS.iter().any(|pat| normalized.contains(pat))
47}
48
49pub fn run(tool_input_source: &str) -> Result<()> {
50    let raw = read_input(tool_input_source).map_err(CwError::Io)?;
51    let payload: HookPayload = serde_json::from_str(&raw).map_err(CwError::Json)?;
52
53    if payload.tool_name.as_deref() != Some("Bash") {
54        return Ok(());
55    }
56    let input = match payload.tool_input {
57        Some(v) => v,
58        None => return Ok(()),
59    };
60    let command = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
61    if !command_is_risky(command) {
62        return Ok(());
63    }
64    let cwd_str = input.get("cwd").and_then(|v| v.as_str());
65    let cwd = match cwd_str {
66        Some(s) => std::path::PathBuf::from(s),
67        None => std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
68    };
69    if !cwd_is_healthy(&cwd) {
70        eprintln!(
71            "gw guard: blocking risky command '{}' — cwd '{}' does not exist or is not a directory.",
72            command,
73            cwd.display(),
74        );
75        return Err(CwError::ExitCode(2));
76    }
77    Ok(())
78}