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 ",
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
129const 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
151pub fn validate_bash(input: &BashValidatorInput) -> HookResult {
153 let cmd = input.command.trim();
154 let cmd_lower = cmd.to_lowercase();
155
156 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 for pattern in FILE_MODIFICATION_PATTERNS {
165 if cmd.contains(pattern) {
166 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
180fn 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#[derive(Debug, Clone)]
193pub struct PlanFileValidatorInput {
194 pub plan_file: String,
196}
197
198pub fn validate_plan_file(input: &PlanFileValidatorInput) -> HookResult {
200 let path = input.plan_file.trim();
201
202 if path.starts_with('/') {
204 return HookResult::blocked(format!(
205 "Absolute path not allowed for plan file: {}",
206 path
207 ));
208 }
209
210 if path.contains("..") {
212 return HookResult::blocked(format!(
213 "Parent directory traversal not allowed: {}",
214 path
215 ));
216 }
217
218 HookResult::allowed()
221}
222
223#[derive(Debug, Clone)]
225pub struct PostToolUseInput {
226 pub tool_name: String,
228 pub tool_input: String,
230 pub pending_session_file: String,
232 #[allow(dead_code)]
234 pub session_id: String,
235}
236
237pub fn process_post_tool_use(input: &PostToolUseInput) -> HookResult {
239 if input.tool_name != "Bash" {
241 return HookResult::allowed();
242 }
243
244 let pending_path = std::path::Path::new(&input.pending_session_file);
246 if !pending_path.exists() {
247 return HookResult::allowed();
248 }
249
250 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 if !input.tool_input.contains(&expected_cmd) {
258 return HookResult::allowed();
259 }
260
261 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}