Skip to main content

apm/cmd/
path_guard.rs

1use apm_core::wrapper::path_guard::{PathGuard, canonicalize_lenient};
2use std::io::Read;
3use std::path::{Path, PathBuf};
4
5/// `apm path-guard` — PreToolUse hook called by Claude Code before every
6/// Edit, Write, and Bash tool invocation.
7///
8/// Reads a JSON payload from stdin:
9/// ```json
10/// {"tool_name": "Edit", "tool_input": {"file_path": "/some/path"}}
11/// ```
12///
13/// Exit codes:
14/// - 0: tool call allowed
15/// - 2: tool call blocked; rejection message printed to stdout
16pub fn run() {
17    let mut stdin_buf = String::new();
18    if std::io::stdin().read_to_string(&mut stdin_buf).is_err() {
19        std::process::exit(0);
20    }
21
22    let payload: serde_json::Value = match serde_json::from_str(&stdin_buf) {
23        Ok(v) => v,
24        Err(_) => {
25            // Malformed JSON — allow (do not block on parse failure)
26            std::process::exit(0);
27        }
28    };
29
30    let tool_name = match payload.get("tool_name").and_then(|v| v.as_str()) {
31        Some(n) => n,
32        None => std::process::exit(0),
33    };
34
35    // Only intercept Edit, Write, and Bash
36    if !matches!(tool_name, "Edit" | "Write" | "Bash") {
37        std::process::exit(0);
38    }
39
40    // Read required environment variables
41    let worktree_str = match std::env::var("APM_TICKET_WORKTREE") {
42        Ok(v) if !v.is_empty() => v,
43        _ => std::process::exit(0), // not running inside an APM worker — allow
44    };
45    let worktree = Path::new(&worktree_str);
46
47    let apm_bin = std::env::var("APM_BIN").unwrap_or_default();
48    let sys_file = std::env::var("APM_SYSTEM_PROMPT_FILE").unwrap_or_default();
49    let msg_file = std::env::var("APM_USER_MESSAGE_FILE").unwrap_or_default();
50
51    // Load IsolationConfig by walking up from the worktree
52    let isolation = load_isolation_config(worktree);
53
54    // Build PathGuard
55    let mut write_protected: Vec<PathBuf> = Vec::new();
56    if !apm_bin.is_empty() {
57        write_protected.push(canonicalize_lenient(Path::new(&apm_bin)));
58    }
59    if !sys_file.is_empty() {
60        write_protected.push(canonicalize_lenient(Path::new(&sys_file)));
61    }
62    if !msg_file.is_empty() {
63        write_protected.push(canonicalize_lenient(Path::new(&msg_file)));
64    }
65
66    let guard = match PathGuard::new(worktree, &isolation.read_allow, &write_protected) {
67        Ok(g) => g,
68        Err(_) => std::process::exit(0), // construction failure — allow
69    };
70
71    let tool_input = match payload.get("tool_input") {
72        Some(v) => v,
73        None => std::process::exit(0),
74    };
75
76    let result = match tool_name {
77        "Edit" | "Write" => {
78            let file_path = match tool_input.get("file_path").and_then(|v| v.as_str()) {
79                Some(p) => p,
80                None => std::process::exit(0),
81            };
82            guard.check_write(Path::new(file_path))
83        }
84        "Bash" => {
85            let command = match tool_input.get("command").and_then(|v| v.as_str()) {
86                Some(c) => c,
87                None => std::process::exit(0),
88            };
89            guard.check_bash(command)
90        }
91        _ => std::process::exit(0),
92    };
93
94    match result {
95        Ok(()) => std::process::exit(0),
96        Err(msg) => {
97            #[allow(clippy::print_stdout)]
98            {
99                println!("{}", msg);
100            }
101            std::process::exit(2);
102        }
103    }
104}
105
106/// Walk upward from `worktree` to find `.apm/config.toml` and return the
107/// `IsolationConfig`. Falls back to defaults if not found.
108fn load_isolation_config(worktree: &Path) -> apm_core::config::IsolationConfig {
109    let mut dir = worktree;
110    loop {
111        let config_path = dir.join(".apm").join("config.toml");
112        if config_path.exists() {
113            if let Ok(config) = apm_core::config::Config::load(dir) {
114                return config.isolation;
115            }
116        }
117        match dir.parent() {
118            Some(parent) => dir = parent,
119            None => break,
120        }
121    }
122    apm_core::config::IsolationConfig::default()
123}