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 ",
111    "rm\t",
112    "rmdir",
113    "mv ",
114    "mv\t",
115    "cp ",
116    "> ",
117    ">>",
118    "2>",
119    "| ",
120    " && ",
121    "; ",
122    "`",
123    "$(",
124    "chmod",
125    "chown",
126    "mkdir -p",
127];
128
129/// Patterns that are generally safe.
130const SAFE_COMMAND_PATTERNS: &[&str] = &[
131    "git status",
132    "git log",
133    "git diff",
134    "git branch",
135    "git rev-parse",
136    "cargo build",
137    "cargo check",
138    "cargo test",
139    "cargo clippy",
140    "cargo fmt --check",
141    "echo ",
142    "ls ",
143    "cat ",
144    "head ",
145    "tail ",
146    "grep ",
147    "which ",
148    "pwd",
149];
150
151/// Validate a bash command.
152pub fn validate_bash(input: &BashValidatorInput) -> HookResult {
153    let cmd = input.command.trim();
154    let cmd_lower = cmd.to_lowercase();
155
156    // Check for safe commands first
157    for safe_pattern in SAFE_COMMAND_PATTERNS {
158        if cmd_lower.starts_with(safe_pattern.to_lowercase().as_str()) {
159            return HookResult::allowed();
160        }
161    }
162
163    // Check for file modification patterns
164    for pattern in FILE_MODIFICATION_PATTERNS {
165        if cmd.contains(pattern) {
166            // Allow if it's a read-only redirection like "grep pattern file | head"
167            if *pattern == "| " && is_pipe_to_readonly(&cmd_lower) {
168                continue;
169            }
170            return HookResult::blocked(format!(
171                "Command contains file-modifying pattern '{}': {}",
172                pattern, cmd
173            ));
174        }
175    }
176
177    HookResult::allowed()
178}
179
180/// Check if a pipe is to a read-only command.
181fn is_pipe_to_readonly(cmd: &str) -> bool {
182    let readonly_commands = ["head", "tail", "grep", "wc", "sort", "uniq", "cut", "awk", "sed -n"];
183    for ro_cmd in readonly_commands {
184        if cmd.contains(&format!("| {}", ro_cmd)) {
185            return true;
186        }
187    }
188    false
189}
190
191/// Input for plan file validator hook.
192#[derive(Debug, Clone)]
193pub struct PlanFileValidatorInput {
194    /// The plan file path.
195    pub plan_file: String,
196}
197
198/// Validate a plan file path.
199pub fn validate_plan_file(input: &PlanFileValidatorInput) -> HookResult {
200    let path = input.plan_file.trim();
201
202    // Block absolute paths
203    if path.starts_with('/') {
204        return HookResult::blocked(format!(
205            "Absolute path not allowed for plan file: {}",
206            path
207        ));
208    }
209
210    // Block parent traversal
211    if path.contains("..") {
212        return HookResult::blocked(format!(
213            "Parent directory traversal not allowed: {}",
214            path
215        ));
216    }
217
218    // Block symlinks (would need filesystem check for full implementation)
219
220    HookResult::allowed()
221}
222
223/// Input for PostToolUse hook (session handshake).
224#[derive(Debug, Clone)]
225pub struct PostToolUseInput {
226    /// The tool that was used.
227    pub tool_name: String,
228    /// The tool input (JSON).
229    pub tool_input: String,
230    /// Path to the pending session ID signal file.
231    pub pending_session_file: String,
232    /// The session ID to record.
233    #[allow(dead_code)]
234    pub session_id: String,
235}
236
237/// Process PostToolUse hook for session handshake.
238pub fn process_post_tool_use(input: &PostToolUseInput) -> HookResult {
239    // Only process Bash tool
240    if input.tool_name != "Bash" {
241        return HookResult::allowed();
242    }
243
244    // Check if the pending signal file exists
245    let pending_path = std::path::Path::new(&input.pending_session_file);
246    if !pending_path.exists() {
247        return HookResult::allowed();
248    }
249
250    // Read the expected command from the signal file
251    let expected_cmd = match std::fs::read_to_string(pending_path) {
252        Ok(content) => content.trim().to_string(),
253        Err(_) => return HookResult::allowed(),
254    };
255
256    // Check if the bash command matches
257    if !input.tool_input.contains(&expected_cmd) {
258        return HookResult::allowed();
259    }
260
261    // This is the setup command - session ID should be recorded
262    // (The actual state.md update would be done by the main loop logic)
263
264    // Remove the signal file
265    let _ = std::fs::remove_file(pending_path);
266
267    HookResult::allowed()
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_validate_read_round_file() {
276        let input = ReadValidatorInput {
277            file_path: ".humanize/rlcr/test/round-1-summary.md".to_string(),
278        };
279        let result = validate_read(&input);
280        assert!(!result.allowed);
281    }
282
283    #[test]
284    fn test_validate_read_normal_file() {
285        let input = ReadValidatorInput {
286            file_path: "src/main.rs".to_string(),
287        };
288        let result = validate_read(&input);
289        assert!(result.allowed);
290    }
291
292    #[test]
293    fn test_validate_write_protected() {
294        let input = WriteValidatorInput {
295            file_path: ".humanize/rlcr/2026-03-17/state.md".to_string(),
296        };
297        let result = validate_write(&input);
298        assert!(!result.allowed);
299    }
300
301    #[test]
302    fn test_validate_bash_safe() {
303        let input = BashValidatorInput {
304            command: "git status".to_string(),
305        };
306        let result = validate_bash(&input);
307        assert!(result.allowed);
308    }
309
310    #[test]
311    fn test_validate_bash_dangerous() {
312        let input = BashValidatorInput {
313            command: "rm -rf /".to_string(),
314        };
315        let result = validate_bash(&input);
316        assert!(!result.allowed);
317    }
318
319    #[test]
320    fn test_validate_plan_file_absolute() {
321        let input = PlanFileValidatorInput {
322            plan_file: "/etc/passwd".to_string(),
323        };
324        let result = validate_plan_file(&input);
325        assert!(!result.allowed);
326    }
327}