humanize_cli_core/
hooks.rs1use crate::fs::{is_protected_state_file, is_round_specific_file};
7
8#[derive(Debug, Clone)]
10pub struct HookResult {
11 pub allowed: bool,
13 pub reason: Option<String>,
15}
16
17impl HookResult {
18 pub fn allowed() -> Self {
20 Self {
21 allowed: true,
22 reason: None,
23 }
24 }
25
26 pub fn blocked(reason: impl Into<String>) -> Self {
28 Self {
29 allowed: false,
30 reason: Some(reason.into()),
31 }
32 }
33}
34
35#[derive(Debug, Clone)]
37pub struct ReadValidatorInput {
38 pub file_path: String,
40}
41
42pub fn validate_read(input: &ReadValidatorInput) -> HookResult {
44 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#[derive(Debug, Clone)]
57pub struct WriteValidatorInput {
58 pub file_path: String,
60}
61
62pub fn validate_write(input: &WriteValidatorInput) -> HookResult {
64 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#[derive(Debug, Clone)]
77pub struct EditValidatorInput {
78 pub file_path: String,
80 #[allow(dead_code)]
82 pub old_string: String,
83 #[allow(dead_code)]
85 pub new_string: String,
86}
87
88pub fn validate_edit(input: &EditValidatorInput) -> HookResult {
90 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#[derive(Debug, Clone)]
103pub struct BashValidatorInput {
104 pub command: String,
106}
107
108const FILE_MODIFICATION_PATTERNS: &[&str] = &[
110 "rm ", "rm\t", "rmdir", "mv ", "mv\t", "cp ", "> ", ">>", "2>", "| ", " && ", "; ", "`", "$(",
111 "chmod", "chown", "mkdir -p",
112];
113
114const 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
136pub fn validate_bash(input: &BashValidatorInput) -> HookResult {
138 let cmd = input.command.trim();
139 let cmd_lower = cmd.to_lowercase();
140
141 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 for pattern in FILE_MODIFICATION_PATTERNS {
150 if cmd.contains(pattern) {
151 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
165fn 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#[derive(Debug, Clone)]
180pub struct PlanFileValidatorInput {
181 pub plan_file: String,
183}
184
185pub fn validate_plan_file(input: &PlanFileValidatorInput) -> HookResult {
187 let path = input.plan_file.trim();
188
189 if path.starts_with('/') {
191 return HookResult::blocked(format!("Absolute path not allowed for plan file: {}", path));
192 }
193
194 if path.contains("..") {
196 return HookResult::blocked(format!("Parent directory traversal not allowed: {}", path));
197 }
198
199 HookResult::allowed()
202}
203
204#[derive(Debug, Clone)]
206pub struct PostToolUseInput {
207 pub tool_name: String,
209 pub tool_input: String,
211 pub pending_session_file: String,
213 #[allow(dead_code)]
215 pub session_id: String,
216}
217
218pub fn process_post_tool_use(input: &PostToolUseInput) -> HookResult {
220 if input.tool_name != "Bash" {
222 return HookResult::allowed();
223 }
224
225 let pending_path = std::path::Path::new(&input.pending_session_file);
227 if !pending_path.exists() {
228 return HookResult::allowed();
229 }
230
231 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 if !input.tool_input.contains(&expected_cmd) {
239 return HookResult::allowed();
240 }
241
242 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}