Skip to main content

humanize_cli_core/
hooks.rs

1//! Hook validation logic for Humanize.
2//!
3//! This module provides validation functions for Claude Code tool hooks,
4//! including read, write, edit, bash, and plan file validators.
5
6use crate::fs::{is_protected_state_file, is_round_specific_file};
7
8/// Result of a hook validation.
9#[derive(Debug, Clone)]
10pub struct HookResult {
11    /// Whether the operation is allowed.
12    pub allowed: bool,
13    /// Reason for blocking (if blocked).
14    pub reason: Option<String>,
15}
16
17impl HookResult {
18    /// Create an allowed result.
19    pub fn allowed() -> Self {
20        Self {
21            allowed: true,
22            reason: None,
23        }
24    }
25
26    /// Create a blocked result with a reason.
27    pub fn blocked(reason: impl Into<String>) -> Self {
28        Self {
29            allowed: false,
30            reason: Some(reason.into()),
31        }
32    }
33}
34
35/// Input for read validator hook.
36#[derive(Debug, Clone)]
37pub struct ReadValidatorInput {
38    /// File path being read.
39    pub file_path: String,
40}
41
42/// Validate a read operation.
43pub fn validate_read(input: &ReadValidatorInput) -> HookResult {
44    // Block reading round-specific files
45    if is_round_specific_file(&input.file_path) {
46        return HookResult::blocked(format!(
47            "Reading round-specific files is not allowed: {}",
48            input.file_path
49        ));
50    }
51
52    HookResult::allowed()
53}
54
55/// Input for write validator hook.
56#[derive(Debug, Clone)]
57pub struct WriteValidatorInput {
58    /// File path being written.
59    pub file_path: String,
60}
61
62/// Validate a write operation.
63pub fn validate_write(input: &WriteValidatorInput) -> HookResult {
64    // Block writing to protected state files
65    if is_protected_state_file(&input.file_path) {
66        return HookResult::blocked(format!(
67            "Writing to protected state files is not allowed: {}",
68            input.file_path
69        ));
70    }
71
72    HookResult::allowed()
73}
74
75/// Input for edit validator hook.
76#[derive(Debug, Clone)]
77pub struct EditValidatorInput {
78    /// File path being edited.
79    pub file_path: String,
80    /// Old string being replaced.
81    #[allow(dead_code)]
82    pub old_string: String,
83    /// New string being inserted.
84    #[allow(dead_code)]
85    pub new_string: String,
86}
87
88/// Validate an edit operation.
89pub fn validate_edit(input: &EditValidatorInput) -> HookResult {
90    // Block editing protected state files
91    if is_protected_state_file(&input.file_path) {
92        return HookResult::blocked(format!(
93            "Editing protected state files is not allowed: {}",
94            input.file_path
95        ));
96    }
97
98    HookResult::allowed()
99}
100
101/// Input for bash validator hook.
102#[derive(Debug, Clone)]
103pub struct BashValidatorInput {
104    /// The bash command being executed.
105    pub command: String,
106}
107
108/// Patterns that indicate file modification.
109const FILE_MODIFICATION_PATTERNS: &[&str] = &[
110    "rm ", "rm\t", "rmdir", "mv ", "mv\t", "cp ", "> ", ">>", "2>", "| ", " && ", "; ", "`", "$(",
111    "chmod", "chown", "mkdir -p",
112];
113
114/// Patterns that are generally safe.
115const SAFE_COMMAND_PATTERNS: &[&str] = &[
116    "git status",
117    "git log",
118    "git diff",
119    "git branch",
120    "git rev-parse",
121    "cargo build",
122    "cargo check",
123    "cargo test",
124    "cargo clippy",
125    "cargo fmt --check",
126    "echo ",
127    "ls ",
128    "cat ",
129    "head ",
130    "tail ",
131    "grep ",
132    "which ",
133    "pwd",
134];
135
136/// Validate a bash command.
137pub fn validate_bash(input: &BashValidatorInput) -> HookResult {
138    let cmd = input.command.trim();
139    let cmd_lower = cmd.to_lowercase();
140
141    // Check for safe commands first
142    for safe_pattern in SAFE_COMMAND_PATTERNS {
143        if cmd_lower.starts_with(safe_pattern.to_lowercase().as_str()) {
144            return HookResult::allowed();
145        }
146    }
147
148    // Check for file modification patterns
149    for pattern in FILE_MODIFICATION_PATTERNS {
150        if cmd.contains(pattern) {
151            // Allow if it's a read-only redirection like "grep pattern file | head"
152            if *pattern == "| " && is_pipe_to_readonly(&cmd_lower) {
153                continue;
154            }
155            return HookResult::blocked(format!(
156                "Command contains file-modifying pattern '{}': {}",
157                pattern, cmd
158            ));
159        }
160    }
161
162    HookResult::allowed()
163}
164
165/// Check if a pipe is to a read-only command.
166fn is_pipe_to_readonly(cmd: &str) -> bool {
167    let readonly_commands = [
168        "head", "tail", "grep", "wc", "sort", "uniq", "cut", "awk", "sed -n",
169    ];
170    for ro_cmd in readonly_commands {
171        if cmd.contains(&format!("| {}", ro_cmd)) {
172            return true;
173        }
174    }
175    false
176}
177
178/// Input for plan file validator hook.
179#[derive(Debug, Clone)]
180pub struct PlanFileValidatorInput {
181    /// The plan file path.
182    pub plan_file: String,
183}
184
185/// Validate a plan file path.
186pub fn validate_plan_file(input: &PlanFileValidatorInput) -> HookResult {
187    let path = input.plan_file.trim();
188
189    // Block absolute paths
190    if path.starts_with('/') {
191        return HookResult::blocked(format!("Absolute path not allowed for plan file: {}", path));
192    }
193
194    // Block parent traversal
195    if path.contains("..") {
196        return HookResult::blocked(format!("Parent directory traversal not allowed: {}", path));
197    }
198
199    // Block symlinks (would need filesystem check for full implementation)
200
201    HookResult::allowed()
202}
203
204/// Input for PostToolUse hook (session handshake).
205#[derive(Debug, Clone)]
206pub struct PostToolUseInput {
207    /// The tool that was used.
208    pub tool_name: String,
209    /// The tool input (JSON).
210    pub tool_input: String,
211    /// Path to the pending session ID signal file.
212    pub pending_session_file: String,
213    /// The session ID to record.
214    #[allow(dead_code)]
215    pub session_id: String,
216}
217
218/// Process PostToolUse hook for session handshake.
219pub fn process_post_tool_use(input: &PostToolUseInput) -> HookResult {
220    // Only process Bash tool
221    if input.tool_name != "Bash" {
222        return HookResult::allowed();
223    }
224
225    // Check if the pending signal file exists
226    let pending_path = std::path::Path::new(&input.pending_session_file);
227    if !pending_path.exists() {
228        return HookResult::allowed();
229    }
230
231    // Read the expected command from the signal file
232    let expected_cmd = match std::fs::read_to_string(pending_path) {
233        Ok(content) => content.trim().to_string(),
234        Err(_) => return HookResult::allowed(),
235    };
236
237    // Check if the bash command matches
238    if !input.tool_input.contains(&expected_cmd) {
239        return HookResult::allowed();
240    }
241
242    // This is the setup command - session ID should be recorded
243    // (The actual state.md update would be done by the main loop logic)
244
245    // Remove the signal file
246    let _ = std::fs::remove_file(pending_path);
247
248    HookResult::allowed()
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn test_validate_read_round_file() {
257        let input = ReadValidatorInput {
258            file_path: ".humanize/rlcr/test/round-1-summary.md".to_string(),
259        };
260        let result = validate_read(&input);
261        assert!(!result.allowed);
262    }
263
264    #[test]
265    fn test_validate_read_normal_file() {
266        let input = ReadValidatorInput {
267            file_path: "src/main.rs".to_string(),
268        };
269        let result = validate_read(&input);
270        assert!(result.allowed);
271    }
272
273    #[test]
274    fn test_validate_write_protected() {
275        let input = WriteValidatorInput {
276            file_path: ".humanize/rlcr/2026-03-17/state.md".to_string(),
277        };
278        let result = validate_write(&input);
279        assert!(!result.allowed);
280    }
281
282    #[test]
283    fn test_validate_bash_safe() {
284        let input = BashValidatorInput {
285            command: "git status".to_string(),
286        };
287        let result = validate_bash(&input);
288        assert!(result.allowed);
289    }
290
291    #[test]
292    fn test_validate_bash_dangerous() {
293        let input = BashValidatorInput {
294            command: "rm -rf /".to_string(),
295        };
296        let result = validate_bash(&input);
297        assert!(!result.allowed);
298    }
299
300    #[test]
301    fn test_validate_plan_file_absolute() {
302        let input = PlanFileValidatorInput {
303            plan_file: "/etc/passwd".to_string(),
304        };
305        let result = validate_plan_file(&input);
306        assert!(!result.allowed);
307    }
308}