Skip to main content

roder_core/
hooks.rs

1//! Local Codex-compatible hook discovery and dispatch.
2//!
3//! `.roder/hooks.json` takes precedence over `.codex/hooks.json`. Hook failures
4//! are diagnostic and fail open; only an explicit `PreToolUse` deny blocks a
5//! tool call.
6
7use 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    /// `Some` only when a hook rewrote the tool input; `None` leaves the call
34    /// (and its original `raw_arguments`) exactly as the model produced it.
35    Continue(Option<Value>),
36    Denied(String),
37}
38
39/// Stand-in reason when a hook denies a call without explaining why. The denial
40/// is the decision, not the prose: a blank reason must still block.
41const 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        /// Roder extension: textual guidance recorded with the hook execution.
174        /// Unit-style Codex prompt hooks remain valid and produce diagnostics.
175        #[serde(default)]
176        prompt: Option<String>,
177    },
178    #[serde(rename = "agent")]
179    Agent {
180        /// Roder extension: task guidance recorded with the hook execution.
181        /// Unit-style Codex agent hooks remain valid and produce diagnostics.
182        #[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        // Rebuilt per handler: a chain of rewriting hooks must each see the
210        // input as the previous hook left it, not the model's original.
211        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
299/// Surface matchers that could not be compiled. Without this an unparseable
300/// matcher silently disabled its hook, which is the one failure a diagnostics
301/// feature must not swallow.
302fn 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    // Only a handler that actually ran can answer the permission contract; a
493    // skipped prompt/agent handler must never be read as a decision.
494    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    // Bypass Runtime::emit to avoid lifecycle hooks recursively observing their own diagnostics.
501    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
525/// Parses the Codex `PreToolUse` output contract. The legacy top-level form is
526/// retained for older copied hook scripts, while hook-specific output follows
527/// Codex's strict permission-decision rules.
528fn 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
574/// Handlers selected for this event, plus any matchers that could not be used.
575fn 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
598/// Parsed `hooks.json`, memoised on the file's modified time and length.
599///
600/// Dispatch reads the file on every hook run — twice per tool call plus each
601/// lifecycle event — so an unchanged file is parsed once and cloned after that.
602/// An edit changes at least one of mtime or length, which drops the entry.
603fn 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
642/// Whether a matcher selects this input, or the reason it could not be used.
643///
644/// An unparseable matcher used to read as "no match", silently disabling the
645/// hook. Callers now surface the error through the same diagnostic channel the
646/// hook runs report on.
647fn 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
700/// Trim recorded hook output to a byte budget, honouring a handler's
701/// `additionalContextLimit` when it asks for less than the global cap.
702///
703/// Counting chars against a byte budget let multi-byte output reach four times
704/// the intended size, so this cuts on the nearest char boundary at or below the
705/// limit instead.
706fn 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        // An unusable matcher reports itself instead of quietly not matching.
738        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        // Multi-byte input used to slip through at up to 4x the cap because the
772        // budget counted chars.
773        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        // additionalContextLimit narrows the budget but can never widen it.
782        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        // A deny with a blank reason is still a deny: the decision blocks the
831        // call and a stand-in reason is supplied for the transcript.
832        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        // An unrecognised decision still falls through without blocking.
850        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}