1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use roder_api::events::{HookRunRecorded, RoderEvent, ThreadId, TurnId};
12use roder_api::tools::ToolCall;
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value, json};
15use time::OffsetDateTime;
16use tokio::process::Command;
17
18use crate::runtime::Runtime;
19
20const DEFAULT_TIMEOUT_SECONDS: u64 = 10;
21const MAX_OUTPUT_BYTES: usize = 8 * 1024;
22
23#[derive(Debug, Clone, Serialize)]
24pub struct HookInspection {
25 pub source: String,
26 pub path: PathBuf,
27 pub event_count: usize,
28 pub handler_count: usize,
29}
30
31#[derive(Debug, Clone)]
32pub enum PreToolUseResult {
33 Continue(Option<Value>),
36 Denied(String),
37}
38
39const DEFAULT_DENIAL_REASON: &str = "denied by PreToolUse hook";
42
43#[derive(Debug, Default, Clone, Deserialize)]
44#[serde(deny_unknown_fields)]
45struct HooksFile {
46 #[serde(default)]
47 hooks: HookEvents,
48}
49
50#[derive(Debug, Default, Clone, Deserialize)]
51struct HookEvents {
52 #[serde(rename = "PreToolUse", default)]
53 pre_tool_use: Vec<MatcherGroup>,
54 #[serde(rename = "PermissionRequest", default)]
55 permission_request: Vec<MatcherGroup>,
56 #[serde(rename = "PostToolUse", default)]
57 post_tool_use: Vec<MatcherGroup>,
58 #[serde(rename = "PreCompact", default)]
59 pre_compact: Vec<MatcherGroup>,
60 #[serde(rename = "PostCompact", default)]
61 post_compact: Vec<MatcherGroup>,
62 #[serde(rename = "SessionStart", default)]
63 session_start: Vec<MatcherGroup>,
64 #[serde(rename = "SessionEnd", default)]
65 session_end: Vec<MatcherGroup>,
66 #[serde(rename = "UserPromptSubmit", default)]
67 user_prompt_submit: Vec<MatcherGroup>,
68 #[serde(rename = "SubagentStart", default)]
69 subagent_start: Vec<MatcherGroup>,
70 #[serde(rename = "SubagentStop", default)]
71 subagent_stop: Vec<MatcherGroup>,
72 #[serde(rename = "Stop", default)]
73 stop: Vec<MatcherGroup>,
74}
75
76impl HookEvents {
77 fn groups_for(&self, event: &str) -> &[MatcherGroup] {
78 match event {
79 "PreToolUse" => &self.pre_tool_use,
80 "PermissionRequest" => &self.permission_request,
81 "PostToolUse" => &self.post_tool_use,
82 "PreCompact" => &self.pre_compact,
83 "PostCompact" => &self.post_compact,
84 "SessionStart" => &self.session_start,
85 "SessionEnd" => &self.session_end,
86 "UserPromptSubmit" => &self.user_prompt_submit,
87 "SubagentStart" => &self.subagent_start,
88 "SubagentStop" => &self.subagent_stop,
89 "Stop" => &self.stop,
90 _ => &[],
91 }
92 }
93
94 fn event_count(&self) -> usize {
95 [
96 &self.pre_tool_use,
97 &self.permission_request,
98 &self.post_tool_use,
99 &self.pre_compact,
100 &self.post_compact,
101 &self.session_start,
102 &self.session_end,
103 &self.user_prompt_submit,
104 &self.subagent_start,
105 &self.subagent_stop,
106 &self.stop,
107 ]
108 .into_iter()
109 .filter(|groups| !groups.is_empty())
110 .count()
111 }
112
113 fn handler_count(&self) -> usize {
114 [
115 &self.pre_tool_use,
116 &self.permission_request,
117 &self.post_tool_use,
118 &self.pre_compact,
119 &self.post_compact,
120 &self.session_start,
121 &self.session_end,
122 &self.user_prompt_submit,
123 &self.subagent_start,
124 &self.subagent_stop,
125 &self.stop,
126 ]
127 .into_iter()
128 .flatten()
129 .map(|group| group.hooks.len())
130 .sum()
131 }
132}
133
134#[derive(Debug, Clone, Deserialize)]
135#[serde(deny_unknown_fields)]
136struct MatcherGroup {
137 #[serde(default)]
138 matcher: Option<String>,
139 #[serde(default)]
140 hooks: Vec<HookHandler>,
141}
142
143#[derive(Debug, Clone, Deserialize)]
144#[serde(tag = "type")]
145enum HookHandler {
146 #[serde(rename = "command")]
147 Command {
148 command: String,
149 #[serde(default, rename = "commandWindows", alias = "command_windows")]
150 command_windows: Option<String>,
151 #[serde(default, rename = "timeout")]
152 timeout_seconds: Option<u64>,
153 #[serde(default, rename = "statusMessage")]
154 status_message: Option<String>,
155 #[serde(default, rename = "async")]
156 asynchronous: bool,
157 #[serde(default, rename = "additionalContextLimit")]
158 additional_context_limit: Option<usize>,
159 },
160 #[serde(rename = "mcp_tool")]
161 McpTool {
162 server: String,
163 tool: String,
164 #[serde(default)]
165 input: Map<String, Value>,
166 #[serde(default, rename = "timeout")]
167 timeout_seconds: Option<u64>,
168 #[serde(default, rename = "statusMessage")]
169 status_message: Option<String>,
170 },
171 #[serde(rename = "prompt")]
172 Prompt {
173 #[serde(default)]
176 prompt: Option<String>,
177 },
178 #[serde(rename = "agent")]
179 Agent {
180 #[serde(default)]
183 prompt: Option<String>,
184 },
185}
186
187pub fn inspect(workspace: impl AsRef<Path>) -> Option<HookInspection> {
188 let (path, source) = hooks_path(workspace.as_ref())?;
189 let file: HooksFile = serde_json::from_slice(&std::fs::read(&path).ok()?).ok()?;
190 Some(HookInspection {
191 source: source.to_string(),
192 path,
193 event_count: file.hooks.event_count(),
194 handler_count: file.hooks.handler_count(),
195 })
196}
197
198pub async fn run_pre_tool_use(
199 runtime: &Runtime,
200 thread_id: &ThreadId,
201 turn_id: &TurnId,
202 workspace: Option<&str>,
203 call: &ToolCall,
204) -> PreToolUseResult {
205 let (handlers, problems) = matching_handlers(workspace, "PreToolUse", Some(&call.name));
206 report_matcher_problems(runtime, thread_id, turn_id, call, "PreToolUse", problems);
207 let mut rewritten: Option<Value> = None;
208 for handler in handlers {
209 let payload = hook_payload(
212 thread_id,
213 turn_id,
214 workspace,
215 "PreToolUse",
216 call,
217 rewritten.clone().unwrap_or_else(|| call.arguments.clone()),
218 );
219 let outcome = run_handler(
220 runtime,
221 thread_id,
222 turn_id,
223 call,
224 "PreToolUse",
225 &handler,
226 &payload,
227 )
228 .await;
229 if let Some(denial) = outcome.denial {
230 return PreToolUseResult::Denied(denial);
231 }
232 if let Some(updated) = outcome.updated_input {
233 rewritten = Some(updated);
234 }
235 }
236 PreToolUseResult::Continue(rewritten)
237}
238
239pub async fn run_post_tool_use(
240 runtime: &Runtime,
241 thread_id: &ThreadId,
242 turn_id: &TurnId,
243 workspace: Option<&str>,
244 call: &ToolCall,
245 output: &str,
246) {
247 let payload = hook_payload(
248 thread_id,
249 turn_id,
250 workspace,
251 "PostToolUse",
252 call,
253 json!({"toolOutput": output}),
254 );
255 let (handlers, problems) = matching_handlers(workspace, "PostToolUse", Some(&call.name));
256 report_matcher_problems(runtime, thread_id, turn_id, call, "PostToolUse", problems);
257 for handler in handlers {
258 let _ = run_handler(
259 runtime,
260 thread_id,
261 turn_id,
262 call,
263 "PostToolUse",
264 &handler,
265 &payload,
266 )
267 .await;
268 }
269}
270
271pub async fn run_lifecycle(
272 runtime: &Runtime,
273 thread_id: &ThreadId,
274 turn_id: &TurnId,
275 workspace: Option<&str>,
276 event: &str,
277 matcher: Option<&str>,
278 input: Value,
279) {
280 let call = ToolCall {
281 id: format!("hook-{event}"),
282 name: matcher.unwrap_or(event).to_string(),
283 raw_arguments: input.to_string(),
284 arguments: input.clone(),
285 thread_id: thread_id.clone(),
286 turn_id: turn_id.clone(),
287 };
288 let payload = hook_payload(thread_id, turn_id, workspace, event, &call, input);
289 let (handlers, problems) = matching_handlers(workspace, event, matcher);
290 report_matcher_problems(runtime, thread_id, turn_id, &call, event, problems);
291 for handler in handlers {
292 let _ = run_handler(
293 runtime, thread_id, turn_id, &call, event, &handler, &payload,
294 )
295 .await;
296 }
297}
298
299fn report_matcher_problems(
303 runtime: &Runtime,
304 thread_id: &ThreadId,
305 turn_id: &TurnId,
306 call: &ToolCall,
307 event: &str,
308 problems: Vec<String>,
309) {
310 for problem in problems {
311 runtime
312 .bus
313 .emit(RoderEvent::HookRunRecorded(HookRunRecorded {
314 thread_id: thread_id.clone(),
315 turn_id: turn_id.clone(),
316 tool_id: call.id.clone(),
317 tool_name: call.name.clone(),
318 hook_event_name: event.to_string(),
319 handler_type: "matcher".to_string(),
320 status: "failed".to_string(),
321 detail: problem,
322 output: None,
323 timestamp: OffsetDateTime::now_utc(),
324 }));
325 }
326}
327
328struct HookOutcome {
329 denial: Option<String>,
330 updated_input: Option<Value>,
331}
332
333async fn run_handler(
334 runtime: &Runtime,
335 thread_id: &ThreadId,
336 turn_id: &TurnId,
337 call: &ToolCall,
338 event: &str,
339 handler: &HookHandler,
340 payload: &Value,
341) -> HookOutcome {
342 let (handler_type, detail, context_limit, status_override, output) = match handler {
343 HookHandler::Command {
344 command,
345 command_windows,
346 timeout_seconds,
347 status_message,
348 asynchronous,
349 additional_context_limit,
350 } => {
351 let command = if cfg!(windows) {
352 command_windows.as_deref().unwrap_or(command)
353 } else {
354 command.as_str()
355 };
356 if *asynchronous {
357 let mut process = Command::new("sh");
358 process
359 .arg("-lc")
360 .arg(command)
361 .stdin(std::process::Stdio::null())
362 .stdout(std::process::Stdio::null())
363 .stderr(std::process::Stdio::null());
364 if let Some(cwd) = payload.get("cwd").and_then(Value::as_str)
365 && !cwd.is_empty()
366 {
367 process.current_dir(cwd);
368 }
369 (
370 "command",
371 status_message
372 .clone()
373 .unwrap_or_else(|| command.to_string()),
374 *additional_context_limit,
375 None,
376 process
377 .spawn()
378 .map(|_| "async hook started".to_string())
379 .map_err(|error| error.to_string()),
380 )
381 } else {
382 let mut process = Command::new("sh");
383 process
384 .arg("-lc")
385 .arg(command)
386 .stdin(std::process::Stdio::piped())
387 .stdout(std::process::Stdio::piped())
388 .stderr(std::process::Stdio::piped());
389 if let Some(cwd) = payload.get("cwd").and_then(Value::as_str)
390 && !cwd.is_empty()
391 {
392 process.current_dir(cwd);
393 }
394 let result = async {
395 let mut child = process.spawn().map_err(|error| error.to_string())?;
396 use tokio::io::AsyncWriteExt;
397 if let Some(mut stdin) = child.stdin.take() {
398 stdin
399 .write_all(payload.to_string().as_bytes())
400 .await
401 .map_err(|error| error.to_string())?;
402 }
403 let output = child
404 .wait_with_output()
405 .await
406 .map_err(|error| error.to_string())?;
407 if output.status.success() {
408 Ok(String::from_utf8_lossy(&output.stdout).to_string())
409 } else {
410 Err(String::from_utf8_lossy(&output.stderr).to_string())
411 }
412 };
413 let output = match tokio::time::timeout(
414 Duration::from_secs(timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECONDS)),
415 result,
416 )
417 .await
418 {
419 Ok(result) => result,
420 Err(_) => Err("hook timed out".to_string()),
421 };
422 (
423 "command",
424 status_message
425 .clone()
426 .unwrap_or_else(|| command.to_string()),
427 *additional_context_limit,
428 None,
429 output,
430 )
431 }
432 }
433 HookHandler::McpTool {
434 server,
435 tool,
436 input,
437 timeout_seconds,
438 status_message,
439 } => {
440 let tool_name = format!("mcp__{server}__{tool}");
441 let input = expand_input(&Value::Object(input.clone()), payload);
442 let result = tokio::time::timeout(
443 Duration::from_secs(timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECONDS)),
444 runtime.execute_workflow_tool(thread_id.clone(), &tool_name, input),
445 )
446 .await;
447 let output = match result {
448 Ok(Ok(result)) if !result.is_error => Ok(result.text),
449 Ok(Ok(result)) => Err(result.text),
450 Ok(Err(error)) => Err(error.to_string()),
451 Err(_) => Err("hook timed out".to_string()),
452 };
453 (
454 "mcp_tool",
455 status_message.clone().unwrap_or(tool_name),
456 None,
457 None,
458 output,
459 )
460 }
461 HookHandler::Prompt { prompt } => {
462 let output = prompt
463 .as_ref()
464 .map(|prompt| expand_string(prompt, payload))
465 .ok_or_else(|| "Codex-compatible prompt handler has no prompt text".to_string());
466 (
467 "prompt",
468 "prompt hook".to_string(),
469 None,
470 Some("skipped"),
471 output,
472 )
473 }
474 HookHandler::Agent { prompt } => {
475 let output = prompt
476 .as_ref()
477 .map(|prompt| expand_string(prompt, payload))
478 .ok_or_else(|| "Codex-compatible agent handler has no prompt text".to_string());
479 (
480 "agent",
481 "agent hook".to_string(),
482 None,
483 Some("skipped"),
484 output,
485 )
486 }
487 };
488 let (status, text) = match output {
489 Ok(text) => (status_override.unwrap_or("success"), text),
490 Err(error) => ("failed", error),
491 };
492 let (denial, updated_input) = if status_override.is_some() {
495 (None, None)
496 } else {
497 parse_pre_tool_use_output(&serde_json::from_str(&text).unwrap_or(Value::Null))
498 };
499 let output = truncate(&text, context_limit);
500 runtime
502 .bus
503 .emit(RoderEvent::HookRunRecorded(HookRunRecorded {
504 thread_id: thread_id.clone(),
505 turn_id: turn_id.clone(),
506 tool_id: call.id.clone(),
507 tool_name: call.name.clone(),
508 hook_event_name: event.to_string(),
509 handler_type: handler_type.to_string(),
510 status: if denial.is_some() {
511 "blocked".to_string()
512 } else {
513 status.to_string()
514 },
515 detail,
516 output: Some(output),
517 timestamp: OffsetDateTime::now_utc(),
518 }));
519 HookOutcome {
520 denial,
521 updated_input,
522 }
523}
524
525fn parse_pre_tool_use_output(parsed: &Value) -> (Option<String>, Option<Value>) {
529 if let Some(output) = parsed.get("hookSpecificOutput") {
530 let decision = output.get("permissionDecision").and_then(Value::as_str);
531 let reason = output
532 .get("permissionDecisionReason")
533 .and_then(Value::as_str)
534 .map(str::trim)
535 .filter(|reason| !reason.is_empty())
536 .map(str::to_string);
537 let updated = output.get("updatedInput").cloned();
538 return match decision {
539 Some("deny") => (
540 Some(reason.unwrap_or_else(|| DEFAULT_DENIAL_REASON.to_string())),
541 None,
542 ),
543 Some("allow") => (None, updated),
544 _ => (None, None),
545 };
546 }
547 let denial = parsed
548 .get("decision")
549 .and_then(Value::as_str)
550 .filter(|decision| *decision == "deny" || *decision == "block")
551 .map(|_| {
552 parsed
553 .get("reason")
554 .and_then(Value::as_str)
555 .map(str::trim)
556 .filter(|reason| !reason.is_empty())
557 .map(str::to_string)
558 .unwrap_or_else(|| DEFAULT_DENIAL_REASON.to_string())
559 });
560 (denial, parsed.get("updatedInput").cloned())
561}
562
563fn hook_payload(
564 thread_id: &str,
565 turn_id: &str,
566 workspace: Option<&str>,
567 event: &str,
568 call: &ToolCall,
569 input: Value,
570) -> Value {
571 json!({"hookEventName": event, "sessionId": thread_id, "turnId": turn_id, "cwd": workspace.unwrap_or(""), "workspaceRoot": workspace.unwrap_or(""), "toolName": call.name, "toolUseId": call.id, "toolInput": input})
572}
573
574fn matching_handlers(
576 workspace: Option<&str>,
577 event: &str,
578 matcher_input: Option<&str>,
579) -> (Vec<HookHandler>, Vec<String>) {
580 let Some((path, _)) = workspace.and_then(|workspace| hooks_path(Path::new(workspace))) else {
581 return (Vec::new(), Vec::new());
582 };
583 let Some(file) = load_hooks_file(&path) else {
584 return (Vec::new(), Vec::new());
585 };
586 let mut handlers = Vec::new();
587 let mut problems = Vec::new();
588 for group in file.hooks.groups_for(event) {
589 match matcher_matches(group.matcher.as_deref(), matcher_input) {
590 Ok(true) => handlers.extend(group.hooks.iter().cloned()),
591 Ok(false) => {}
592 Err(problem) => problems.push(problem),
593 }
594 }
595 (handlers, problems)
596}
597
598fn load_hooks_file(path: &Path) -> Option<HooksFile> {
604 static CACHE: std::sync::OnceLock<std::sync::Mutex<HashMap<PathBuf, CachedHooksFile>>> =
605 std::sync::OnceLock::new();
606 let metadata = std::fs::metadata(path).ok()?;
607 let stamp = (
608 metadata
609 .modified()
610 .ok()
611 .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
612 .map(|since| since.as_nanos()),
613 metadata.len(),
614 );
615
616 let cache = CACHE.get_or_init(Default::default);
617 if let Ok(cache) = cache.lock()
618 && let Some(cached) = cache.get(path)
619 && cached.stamp == stamp
620 {
621 return Some(cached.file.clone());
622 }
623
624 let file = serde_json::from_slice::<HooksFile>(&std::fs::read(path).ok()?).ok()?;
625 if let Ok(mut cache) = cache.lock() {
626 cache.insert(
627 path.to_path_buf(),
628 CachedHooksFile {
629 stamp,
630 file: file.clone(),
631 },
632 );
633 }
634 Some(file)
635}
636
637struct CachedHooksFile {
638 stamp: (Option<u128>, u64),
639 file: HooksFile,
640}
641
642fn matcher_matches(matcher: Option<&str>, input: Option<&str>) -> Result<bool, String> {
648 let Some(matcher) = matcher else {
649 return Ok(true);
650 };
651 let regex = regex::Regex::new(matcher)
652 .map_err(|error| format!("invalid matcher {matcher:?}: {error}"))?;
653 Ok(input.is_some_and(|input| regex.is_match(input)))
654}
655
656fn hooks_path(workspace: &Path) -> Option<(PathBuf, &'static str)> {
657 let roder = workspace.join(".roder/hooks.json");
658 if roder.is_file() {
659 return Some((roder, "roder"));
660 }
661 let codex = workspace.join(".codex/hooks.json");
662 codex.is_file().then_some((codex, "codex"))
663}
664
665fn expand_input(input: &Value, payload: &Value) -> Value {
666 match input {
667 Value::String(value) => Value::String(expand_string(value, payload)),
668 Value::Array(values) => Value::Array(
669 values
670 .iter()
671 .map(|value| expand_input(value, payload))
672 .collect(),
673 ),
674 Value::Object(values) => Value::Object(
675 values
676 .iter()
677 .map(|(key, value)| (key.clone(), expand_input(value, payload)))
678 .collect(),
679 ),
680 value => value.clone(),
681 }
682}
683
684fn expand_string(value: &str, payload: &Value) -> String {
685 let mut result = value.to_string();
686 for (key, replacement) in [
687 ("tool_input", payload.get("toolInput")),
688 ("tool_name", payload.get("toolName")),
689 ] {
690 if let Some(replacement) = replacement {
691 result = result.replace(
692 &format!("${{{key}}}"),
693 replacement.as_str().unwrap_or(&replacement.to_string()),
694 );
695 }
696 }
697 result
698}
699
700fn truncate(value: &str, limit: Option<usize>) -> String {
707 let budget = limit.unwrap_or(MAX_OUTPUT_BYTES).min(MAX_OUTPUT_BYTES);
708 if value.len() <= budget {
709 return value.to_string();
710 }
711 let mut end = budget;
712 while end > 0 && !value.is_char_boundary(end) {
713 end -= 1;
714 }
715 value[..end].to_string()
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721 #[test]
722 fn roder_path_has_precedence() {
723 let temp = tempfile::tempdir().unwrap();
724 std::fs::create_dir_all(temp.path().join(".roder")).unwrap();
725 std::fs::create_dir_all(temp.path().join(".codex")).unwrap();
726 std::fs::write(temp.path().join(".roder/hooks.json"), r#"{"hooks":{}}"#).unwrap();
727 std::fs::write(temp.path().join(".codex/hooks.json"), r#"{"hooks":{}}"#).unwrap();
728 assert_eq!(hooks_path(temp.path()).unwrap().1, "roder");
729 }
730
731 #[test]
732 fn matcher_uses_codex_regex_semantics() {
733 assert_eq!(matcher_matches(Some("Read|Write"), Some("Write")), Ok(true));
734 assert_eq!(matcher_matches(Some("Read"), Some("Write")), Ok(false));
735 assert_eq!(matcher_matches(None, Some("anything")), Ok(true));
736
737 let problem = matcher_matches(Some("Read("), Some("Read")).unwrap_err();
739 assert!(problem.contains("invalid matcher"), "{problem}");
740 }
741
742 #[test]
743 fn prompt_and_agent_handlers_accept_unit_and_roder_prompt_forms() {
744 let unit_prompt: HookHandler = serde_json::from_str(r#"{"type":"prompt"}"#).unwrap();
745 let prompted_agent: HookHandler =
746 serde_json::from_str(r#"{"type":"agent","prompt":"Review ${tool_name}"}"#).unwrap();
747 assert!(matches!(unit_prompt, HookHandler::Prompt { prompt: None }));
748 assert!(matches!(
749 prompted_agent,
750 HookHandler::Agent { prompt: Some(_) }
751 ));
752 }
753
754 #[test]
755 fn hook_output_parses_deny_and_rewrite() {
756 let deny: Value = serde_json::from_str(r#"{"decision":"deny","reason":"no"}"#).unwrap();
757 assert_eq!(deny.get("decision").and_then(Value::as_str), Some("deny"));
758 let rewrite: Value =
759 serde_json::from_str(r#"{"hookSpecificOutput":{"updatedInput":{"path":"safe"}}}"#)
760 .unwrap();
761 assert_eq!(
762 rewrite
763 .pointer("/hookSpecificOutput/updatedInput/path")
764 .and_then(Value::as_str),
765 Some("safe")
766 );
767 }
768
769 #[test]
770 fn truncate_respects_a_byte_budget_and_char_boundaries() {
771 let wide = "\u{4e2d}".repeat(MAX_OUTPUT_BYTES);
774 let trimmed = truncate(&wide, None);
775 assert!(trimmed.len() <= MAX_OUTPUT_BYTES, "{}", trimmed.len());
776 assert!(
777 trimmed.chars().all(|c| c == '\u{4e2d}'),
778 "cut mid-character"
779 );
780
781 assert_eq!(truncate("abcdef", Some(3)), "abc");
783 assert_eq!(truncate("abcdef", Some(usize::MAX)).len(), 6);
784 assert_eq!(truncate("abc", None), "abc");
785 }
786
787 #[test]
788 fn copied_codex_configuration_accepts_all_event_and_command_fields() {
789 let file: HooksFile = serde_json::from_str(
790 r#"{
791 "hooks": {
792 "PermissionRequest": [{"matcher":"shell","hooks":[{
793 "type":"command","command":"echo hook",
794 "commandWindows":"echo hook","timeout":2,
795 "async":false,"statusMessage":"checking",
796 "additionalContextLimit":2500
797 }]}]
798 }
799 }"#,
800 )
801 .expect("Codex hooks file parses");
802 assert_eq!(file.hooks.event_count(), 1);
803 assert_eq!(file.hooks.handler_count(), 1);
804 }
805
806 #[test]
807 fn codex_pre_tool_use_output_honours_nested_allow_and_deny() {
808 let deny: Value = serde_json::from_str(
809 r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run"}}"#,
810 )
811 .unwrap();
812 assert_eq!(
813 parse_pre_tool_use_output(&deny).0.as_deref(),
814 Some("do not run")
815 );
816
817 let rewrite: Value = serde_json::from_str(
818 r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"echo safe"}}}"#,
819 )
820 .unwrap();
821 assert_eq!(
822 parse_pre_tool_use_output(&rewrite)
823 .1
824 .and_then(|value| value.get("command").cloned())
825 .and_then(|value| value.as_str().map(str::to_string))
826 .as_deref(),
827 Some("echo safe")
828 );
829
830 let blank_reason: Value = serde_json::from_str(
833 r#"{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":" "}}"#,
834 )
835 .unwrap();
836 assert_eq!(
837 parse_pre_tool_use_output(&blank_reason).0.as_deref(),
838 Some(DEFAULT_DENIAL_REASON)
839 );
840
841 let no_reason: Value =
842 serde_json::from_str(r#"{"hookSpecificOutput":{"permissionDecision":"deny"}}"#)
843 .unwrap();
844 assert_eq!(
845 parse_pre_tool_use_output(&no_reason).0.as_deref(),
846 Some(DEFAULT_DENIAL_REASON)
847 );
848
849 let unknown: Value =
851 serde_json::from_str(r#"{"hookSpecificOutput":{"permissionDecision":"ask"}}"#).unwrap();
852 assert_eq!(parse_pre_tool_use_output(&unknown), (None, None));
853 }
854
855 #[test]
856 fn legacy_top_level_deny_blocks_without_a_reason() {
857 for raw in [
858 r#"{"decision":"deny"}"#,
859 r#"{"decision":"block","reason":" "}"#,
860 ] {
861 let parsed: Value = serde_json::from_str(raw).unwrap();
862 assert_eq!(
863 parse_pre_tool_use_output(&parsed).0.as_deref(),
864 Some(DEFAULT_DENIAL_REASON),
865 "{raw} must block"
866 );
867 }
868
869 let allowed: Value = serde_json::from_str(r#"{"decision":"approve"}"#).unwrap();
870 assert_eq!(parse_pre_tool_use_output(&allowed).0, None);
871 }
872}