Skip to main content

git_worktree_manager/operations/
guard.rs

1//! `gw guard` — Claude Code PreToolUse(Bash) hook helper.
2//!
3//! Input format: a JSON object with at least `tool_name` and `tool_input`.
4//! Policy: when `tool_name == "Bash"` and the call's cwd is unhealthy
5//! (missing path or not a directory), block the tool call with a strong
6//! abort instruction in stderr — regardless of which command is being run.
7//!
8//! Rationale: the guard exists for sessions whose worktree was removed out
9//! from under them (e.g. `gw rm` while Claude was paused). In that state
10//! every shell command runs in a stale environment, so the safe default is
11//! to refuse all of them and ask the user before continuing. Sessions with
12//! a healthy cwd pass through transparently.
13
14use std::io::Read;
15use std::path::Path;
16
17use crate::error::{CwError, Result};
18
19#[derive(Debug, serde::Deserialize)]
20struct HookPayload {
21    tool_name: Option<String>,
22    tool_input: Option<serde_json::Value>,
23}
24
25fn read_input(source: &str) -> std::io::Result<String> {
26    if source == "-" {
27        let mut s = String::new();
28        std::io::stdin().read_to_string(&mut s)?;
29        Ok(s)
30    } else {
31        std::fs::read_to_string(source)
32    }
33}
34
35fn cwd_is_healthy(cwd: &Path) -> bool {
36    cwd.exists() && cwd.is_dir()
37}
38
39pub fn run(tool_input_source: &str) -> Result<()> {
40    let raw = read_input(tool_input_source).map_err(CwError::Io)?;
41    // Malformed or empty input: pass through rather than blocking the tool call.
42    // A hook that exits non-zero unconditionally due to bad input would be
43    // worse than a hook that silently allows (the guard is best-effort).
44    let payload: HookPayload = match serde_json::from_str(&raw) {
45        Ok(p) => p,
46        Err(_) => return Ok(()),
47    };
48
49    if payload.tool_name.as_deref() != Some("Bash") {
50        return Ok(());
51    }
52    let input = match payload.tool_input {
53        Some(v) => v,
54        None => return Ok(()),
55    };
56    let cwd_str = input.get("cwd").and_then(|v| v.as_str());
57    let cwd = match cwd_str {
58        Some(s) => std::path::PathBuf::from(s),
59        None => std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
60    };
61    if cwd_is_healthy(&cwd) {
62        return Ok(());
63    }
64
65    let command = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
66    eprintln!(
67        "STOP. gw guard: cwd '{}' no longer exists or is not a directory. \
68         The worktree may have been removed while this session was paused. \
69         Do NOT execute any further commands. Ask the user to confirm the \
70         situation before continuing. (blocked command: '{}')",
71        cwd.display(),
72        command,
73    );
74    Err(CwError::ExitCode(2))
75}