Skip to main content

safe_chains/targets/
codex.rs

1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4use serde_json::{Map, Value, json};
5
6use super::{HookFormat, HookInput, HookResponse, InstallOutcome, ParseError, Target};
7use crate::verdict::Verdict;
8
9pub struct CodexTarget;
10
11const HOOK_COMMAND: &str = "safe-chains hook codex";
12
13/// The events `--setup` registers, both answered by `safe-chains hook codex`. `PreToolUse` vetoes
14/// gated commands before they run; `PermissionRequest` approves safe reads when Codex would
15/// otherwise prompt (a sandbox escalation, or an `untrusted` approval policy).
16const EVENTS: [&str; 2] = ["PreToolUse", "PermissionRequest"];
17
18impl Target for CodexTarget {
19    fn name(&self) -> &'static str {
20        "codex"
21    }
22
23    fn display_name(&self) -> &'static str {
24        "Codex (OpenAI)"
25    }
26
27    #[cfg(test)]
28    fn sample_envelope(&self, tool: &str, command: &str) -> Option<String> {
29        Some(format!(r#"{{"tool_name":"{tool}","tool_input":{{"command":"{command}"}}}}"#))
30    }
31
32    fn detect_paths(&self, home: &Path) -> Vec<PathBuf> {
33        vec![home.join(".codex")]
34    }
35
36    /// Adds whichever of `EVENTS` is missing, so a user who installed the `PreToolUse` hook
37    /// before `PermissionRequest` support gets the second entry from a re-run of `--setup`.
38    fn install(&self, home: &Path) -> Result<InstallOutcome, String> {
39        let dir = home.join(".codex");
40        if !dir.exists() {
41            return Ok(InstallOutcome::Skipped { reason: format!("~/.codex not found at {} (Codex CLI not installed)", dir.display()) });
42        }
43
44        let path = dir.join("hooks.json");
45        let mut settings = if path.exists() {
46            let contents = std::fs::read_to_string(&path).map_err(|e| format!("Could not read {}: {e}", path.display()))?;
47            serde_json::from_str(&contents).map_err(|e| format!("Could not parse {}: {e}", path.display()))?
48        } else {
49            Value::Object(Map::new())
50        };
51
52        let missing: Vec<&str> = EVENTS.into_iter().filter(|event| !has_safe_chains_hook(&settings, event)).collect();
53        if missing.is_empty() {
54            return Ok(InstallOutcome::AlreadyConfigured { path });
55        }
56        for event in missing {
57            add_hook(&mut settings, event, HOOK_COMMAND).map_err(|e| format!("{}: {e}", path.display()))?;
58        }
59        let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
60        std::fs::write(&path, format!("{output}\n")).map_err(|e| format!("Could not write {}: {e}", path.display()))?;
61        Ok(InstallOutcome::Installed { path })
62    }
63
64    fn hook_format(&self) -> Option<&dyn HookFormat> {
65        Some(&CodexHookFormat)
66    }
67
68    /// Codex names the event in `hook_event_name`, which it sets itself (the agent cannot reach
69    /// it). Anything that is not a `PermissionRequest`, an envelope without the field included,
70    /// keeps the `PreToolUse` answer this target has always given.
71    fn hook_format_for(&self, stdin: &str) -> Option<&dyn HookFormat> {
72        if event_name(stdin).as_deref() == Some("PermissionRequest") { Some(&CodexPermissionRequestFormat) } else { Some(&CodexHookFormat) }
73    }
74
75    fn hook_formats(&self) -> Vec<&dyn HookFormat> {
76        vec![&CodexHookFormat, &CodexPermissionRequestFormat]
77    }
78}
79
80#[derive(Deserialize)]
81struct EventProbe {
82    #[serde(default)]
83    hook_event_name: Option<String>,
84}
85
86fn event_name(stdin: &str) -> Option<String> {
87    serde_json::from_str::<EventProbe>(stdin).ok().and_then(|probe| probe.hook_event_name)
88}
89
90struct CodexHookFormat;
91
92#[derive(Deserialize)]
93struct ToolInput {
94    command: String,
95}
96
97#[derive(Deserialize)]
98struct CodexHookEnvelope {
99    /// Optional so a harness that omits it still works; when present and naming another tool we
100    /// abstain (see parse_input).
101    #[serde(default)]
102    tool_name: Option<String>,
103    tool_input: ToolInput,
104    #[serde(default)]
105    cwd: Option<String>,
106}
107
108impl HookFormat for CodexHookFormat {
109    fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
110        let envelope: CodexHookEnvelope = serde_json::from_str(stdin).map_err(|e| ParseError { message: e.to_string() })?;
111        // Self-filter on the tool: the hook can be delivered for a non-shell call by a
112        // hand-edited matcher, and deciding on one grants or vetoes a tool never analysed.
113        if let Some(name) = &envelope.tool_name
114            && name != "Bash"
115        {
116            return Err(ParseError { message: format!("not a shell tool: {name}") });
117        }
118        Ok(HookInput {
119            command: envelope.tool_input.command,
120            cwd: envelope.cwd,
121            root: None, // codex sends cwd but no distinct project root (HARNESS-BEHAVIORS.md)
122            // No scratchpad layout researched for this harness yet (see docs/design/agent-scratchpad.md).
123            session_id: None,
124        })
125    }
126
127    fn decision_pointer(&self) -> &'static str {
128        "/hookSpecificOutput/permissionDecision" // mirrors Claude's nesting
129    }
130
131    fn render_response(&self, _verdict: Verdict) -> HookResponse {
132        // SAFE command → emit nothing. A PreToolUse `permissionDecision:"allow"` is still rejected
133        // as unsupported (v0.144.3 through v0.160.1), and Codex "continues on unsupported output"
134        // anyway. Silence lets the safe command run through Codex's own flow; where that flow
135        // would prompt, the PermissionRequest format below approves it.
136        HookResponse { stdout: String::new(), exit_code: 0 }
137    }
138
139    // Codex has no human-review-on-silence (only sandbox-escape prompts) and no `ask`, but its
140    // sandbox permits BROAD READS (`cat /etc/shadow` runs), so a gated command must be denied by
141    // the hook. See docs/design/harness-capability-model.md. Verified against v0.144.3, 2026-07-13.
142    fn gated_policy(&self) -> super::GatedPolicy {
143        super::GatedPolicy::Deny
144    }
145
146    fn render_deny(&self, reason: &str) -> HookResponse {
147        let body = json!({
148            "hookSpecificOutput": {
149                "hookEventName": "PreToolUse",
150                "permissionDecision": "deny",
151                "permissionDecisionReason": reason,
152            }
153        });
154        HookResponse { stdout: serde_json::to_string(&body).unwrap_or_default(), exit_code: 0 }
155    }
156}
157
158/// Codex's `PermissionRequest` event (v0.122.0 and later): it fires only when Codex is about to
159/// ask for approval, and an `allow` answers that prompt. Anything short of a safe read emits
160/// nothing, which leaves Codex's normal approval in place, so `gated_policy` keeps the `Defer`
161/// default. Only `allow` is ever emitted: `updatedInput`, `updatedPermissions` and
162/// `interrupt:true` make Codex fail the hook.
163struct CodexPermissionRequestFormat;
164
165/// A host-level network approval arrives as the command with a `network-access <host>`
166/// description, and is classified like any other request: the classifier judges network commands
167/// for Codex as it does for Claude Code.
168#[derive(Deserialize)]
169struct PermissionRequestToolInput {
170    command: String,
171}
172
173#[derive(Deserialize)]
174struct PermissionRequestEnvelope {
175    hook_event_name: String,
176    tool_name: String,
177    tool_input: PermissionRequestToolInput,
178    cwd: String,
179}
180
181impl HookFormat for CodexPermissionRequestFormat {
182    fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
183        let envelope: PermissionRequestEnvelope = serde_json::from_str(stdin).map_err(|e| ParseError { message: e.to_string() })?;
184        if envelope.hook_event_name != "PermissionRequest" {
185            return Err(ParseError { message: format!("not a PermissionRequest: {}", envelope.hook_event_name) });
186        }
187        // Required here, not optional: this event also carries `apply_patch`, whose
188        // `tool_input.command` is a patch, and MCP calls, `write_stdin` and `request_permissions`.
189        if envelope.tool_name != "Bash" {
190            return Err(ParseError { message: format!("not a shell tool: {}", envelope.tool_name) });
191        }
192        // The cwd is the workspace root here (see `cwd_is_the_commands`), so a root that is the
193        // whole filesystem, or one that is not a path at all, would put every write inside it.
194        if !bounds_a_workspace(&envelope.cwd) {
195            return Err(ParseError { message: format!("no usable workspace in cwd: {}", envelope.cwd) });
196        }
197        Ok(HookInput { command: envelope.tool_input.command, cwd: Some(envelope.cwd), root: None, session_id: None })
198    }
199
200    /// Codex reports the turn's cwd, but `exec_command` runs in its own `workdir`, which this
201    /// payload leaves out. An approval here runs outside the sandbox, so a relative path must not be
202    /// trusted to land where the reported cwd says: the model could set `workdir` to `~` and have
203    /// `echo x >> .zshrc` approved as a worktree write.
204    fn cwd_is_the_commands(&self) -> bool {
205        false
206    }
207
208    fn decision_pointer(&self) -> &'static str {
209        "/hookSpecificOutput/decision/behavior"
210    }
211
212    fn render_response(&self, verdict: Verdict) -> HookResponse {
213        if !verdict.is_allowed() {
214            return HookResponse { stdout: String::new(), exit_code: 0 };
215        }
216        let body = json!({
217            "hookSpecificOutput": {
218                "hookEventName": "PermissionRequest",
219                "decision": { "behavior": "allow" },
220            }
221        });
222        HookResponse { stdout: serde_json::to_string(&body).unwrap_or_default(), exit_code: 0 }
223    }
224}
225
226/// An absolute path that still names a directory below `/` once `.` and `..` are folded away, so
227/// `/.`, `/Users/..` and `//` are refused like `/` itself.
228fn bounds_a_workspace(cwd: &str) -> bool {
229    if !cwd.starts_with('/') {
230        return false;
231    }
232    let mut depth = 0usize;
233    for part in cwd.split('/') {
234        match part {
235            "" | "." => {}
236            ".." => depth = depth.saturating_sub(1),
237            _ => depth += 1,
238        }
239    }
240    depth > 0
241}
242
243fn hook_entry(binary: &str) -> Value {
244    json!({
245        "matcher": "Bash",
246        "hooks": [{
247            "type": "command",
248            "command": binary,
249        }]
250    })
251}
252
253/// An entry counts only when it runs `safe-chains hook codex` on shell calls: one under an
254/// `apply_patch` matcher, or running another target's hook, leaves `Bash` uncovered on that event.
255fn has_safe_chains_hook(settings: &Value, event: &str) -> bool {
256    settings
257        .get("hooks")
258        .and_then(|h| h.get(event))
259        .and_then(|arr| arr.as_array())
260        .is_some_and(|entries| {
261            entries.iter().any(|entry| {
262                matcher_covers_bash(entry.get("matcher"))
263                    && entry.get("hooks").and_then(|h| h.as_array()).is_some_and(|hooks| {
264                        hooks
265                            .iter()
266                            .any(|hook| hook.get("command").and_then(|c| c.as_str()).is_some_and(|cmd| cmd.contains(HOOK_COMMAND)))
267                    })
268            })
269        })
270}
271
272fn matcher_covers_bash(matcher: Option<&Value>) -> bool {
273    match matcher {
274        None | Some(Value::Null) => true,
275        Some(Value::String(m)) => m.is_empty() || m == "*" || m.contains("Bash"),
276        Some(_) => false,
277    }
278}
279
280/// Codex nests lifecycle events under a top-level `hooks` object (NOT Claude's flat `PreToolUse`
281/// key) — a flat key makes Codex reject the whole file. See developers.openai.com/codex/hooks.
282///
283/// This used to REPLACE a wrong-typed `hooks` or `PreToolUse` value with an empty one, destroying
284/// whatever the user had there without saying so. The shared helper refuses instead: an unreadable
285/// value is usually a hand-edit or a schema we don't know, and rewriting config we did not
286/// understand is not ours to do.
287fn add_hook(settings: &mut Value, event: &str, binary: &str) -> Result<(), String> {
288    super::append_hook_entry(settings, "hooks", event, hook_entry(binary))
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::verdict::SafetyLevel;
295
296    fn target() -> CodexTarget {
297        CodexTarget
298    }
299
300    fn installed(home: &Path) -> Value {
301        let contents = std::fs::read_to_string(home.join(".codex/hooks.json")).unwrap();
302        serde_json::from_str(&contents).unwrap()
303    }
304
305    fn permission_request(tool: &str, command: &str) -> String {
306        json!({
307            "session_id": "s",
308            "turn_id": "t",
309            "transcript_path": null,
310            "cwd": "/w",
311            "hook_event_name": "PermissionRequest",
312            "model": "m",
313            "permission_mode": "default",
314            "tool_name": tool,
315            "tool_input": { "command": command, "description": "needs to write outside the sandbox" },
316        })
317        .to_string()
318    }
319
320    #[test]
321    fn install_no_codex_dir_skips() {
322        let dir = tempfile::tempdir().unwrap();
323        let outcome = target().install(dir.path()).unwrap();
324        assert!(matches!(outcome, InstallOutcome::Skipped { .. }));
325    }
326
327    #[test]
328    fn install_creates_hooks_file() {
329        let dir = tempfile::tempdir().unwrap();
330        std::fs::create_dir(dir.path().join(".codex")).unwrap();
331        let outcome = target().install(dir.path()).unwrap();
332        assert!(matches!(outcome, InstallOutcome::Installed { .. }));
333        let settings = installed(dir.path());
334        for event in EVENTS {
335            assert!(has_safe_chains_hook(&settings, event), "{event} not installed");
336        }
337        // Codex nests events under a top-level `hooks` object; a flat top-level `PreToolUse`
338        // (Claude's shape) makes Codex reject the entire file (`unknown field PreToolUse`).
339        assert!(settings.get("PreToolUse").is_none(), "must not use Claude's flat PreToolUse key");
340        assert!(settings.get("PermissionRequest").is_none(), "must not use a flat PermissionRequest key");
341        assert_eq!(settings.as_object().map(|o| o.len()), Some(1), "only the `hooks` key: {settings}");
342    }
343
344    #[test]
345    fn install_idempotent() {
346        let dir = tempfile::tempdir().unwrap();
347        std::fs::create_dir(dir.path().join(".codex")).unwrap();
348        target().install(dir.path()).unwrap();
349        let first = installed(dir.path());
350        let outcome = target().install(dir.path()).unwrap();
351        assert!(matches!(outcome, InstallOutcome::AlreadyConfigured { .. }));
352        assert_eq!(installed(dir.path()), first, "a second run must not add duplicate entries");
353    }
354
355    #[test]
356    fn install_adds_permission_request_beside_an_older_pre_tool_use_install() {
357        let dir = tempfile::tempdir().unwrap();
358        let codex_dir = dir.path().join(".codex");
359        std::fs::create_dir(&codex_dir).unwrap();
360        let older = json!({"hooks": {"PreToolUse": [hook_entry(HOOK_COMMAND)]}});
361        std::fs::write(codex_dir.join("hooks.json"), older.to_string()).unwrap();
362
363        let outcome = target().install(dir.path()).unwrap();
364        assert!(matches!(outcome, InstallOutcome::Installed { .. }));
365        let settings = installed(dir.path());
366        assert!(has_safe_chains_hook(&settings, "PermissionRequest"));
367        assert_eq!(settings["hooks"]["PreToolUse"], older["hooks"]["PreToolUse"], "the existing entry must be left exactly as it was");
368    }
369
370    #[test]
371    fn install_uses_subcommand_invocation() {
372        // The binary entry must be `safe-chains hook codex`, not just
373        // `safe-chains`, so the runtime knows which envelope to emit.
374        let dir = tempfile::tempdir().unwrap();
375        std::fs::create_dir(dir.path().join(".codex")).unwrap();
376        target().install(dir.path()).unwrap();
377        let settings = installed(dir.path());
378        for event in EVENTS {
379            assert_eq!(settings["hooks"][event][0]["hooks"][0]["command"], HOOK_COMMAND);
380            assert_eq!(settings["hooks"][event][0]["matcher"], "Bash");
381        }
382    }
383
384    #[test]
385    fn install_preserves_existing_hooks() {
386        let dir = tempfile::tempdir().unwrap();
387        let codex_dir = dir.path().join(".codex");
388        std::fs::create_dir(&codex_dir).unwrap();
389        std::fs::write(
390            codex_dir.join("hooks.json"),
391            r#"{"hooks": {"PostToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "log-it"}]}], "PermissionRequest": [{"matcher": "apply_patch", "hooks": [{"type": "command", "command": "mine"}]}]}}"#,
392        )
393        .unwrap();
394        target().install(dir.path()).unwrap();
395        let settings = installed(dir.path());
396        assert!(has_safe_chains_hook(&settings, "PreToolUse"));
397        assert!(has_safe_chains_hook(&settings, "PermissionRequest"));
398        assert!(settings["hooks"].get("PostToolUse").is_some(), "existing PostToolUse must be preserved");
399        assert_eq!(settings["hooks"]["PermissionRequest"][0]["hooks"][0]["command"], "mine", "the user's own entry comes first, untouched");
400    }
401
402    #[test]
403    fn parse_input_extracts_command() {
404        let stdin = r#"{"tool_name": "Bash", "tool_input": {"command": "ls -la"}}"#;
405        let parsed = CodexHookFormat.parse_input(stdin).unwrap();
406        assert_eq!(parsed.command, "ls -la");
407    }
408
409    #[test]
410    fn parse_input_with_optional_cwd() {
411        let stdin = r#"{"tool_input": {"command": "pwd"}, "cwd": "/Users/me"}"#;
412        let parsed = CodexHookFormat.parse_input(stdin).unwrap();
413        assert_eq!(parsed.cwd.as_deref(), Some("/Users/me"));
414    }
415
416    #[test]
417    fn parse_input_rejects_garbage() {
418        assert!(CodexHookFormat.parse_input("not json").is_err());
419        assert!(CodexHookFormat.parse_input("{}").is_err());
420    }
421
422    #[test]
423    fn render_response_safe_emits_empty_body() {
424        // A PreToolUse `permissionDecision:"allow"` is unsupported on Codex. A safe command emits
425        // nothing (Codex continues → runs it); it must NOT emit an allow envelope.
426        let r = CodexHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
427        assert_eq!(r.stdout, "");
428        let r = CodexHookFormat.render_response(Verdict::Denied);
429        assert_eq!(r.stdout, "");
430    }
431
432    #[test]
433    fn gated_command_is_denied_with_the_supported_shape() {
434        // Codex handles a gated command by DENYING (no interactive approval, sandbox permits reads).
435        assert_eq!(CodexHookFormat.gated_policy(), super::super::GatedPolicy::Deny);
436        let r = CodexHookFormat.render_deny("blocked: not on the allowlist");
437        let v: Value = serde_json::from_str(&r.stdout).unwrap();
438        assert_eq!(v.pointer("/hookSpecificOutput/permissionDecision").and_then(|d| d.as_str()), Some("deny"));
439        assert_eq!(v.pointer("/hookSpecificOutput/hookEventName").and_then(|d| d.as_str()), Some("PreToolUse"));
440        assert_eq!(
441            v.pointer("/hookSpecificOutput/permissionDecisionReason").and_then(|d| d.as_str()),
442            Some("blocked: not on the allowlist"),
443        );
444        assert_eq!(r.exit_code, 0);
445    }
446
447    #[test]
448    fn render_context_defaults_to_abstain() {
449        // Codex's hook schema isn't verified for context injection, so it keeps
450        // the safe default: emit nothing, leaving the normal flow untouched.
451        let r = CodexHookFormat.render_context("anything");
452        assert_eq!(r.stdout, "");
453        assert_eq!(r.exit_code, 0);
454    }
455
456    #[test]
457    fn routes_only_a_permission_request_to_the_permission_format() {
458        let t = target();
459        let pointer = |stdin: &str| t.hook_format_for(stdin).map(|f| f.decision_pointer());
460        let permission = CodexPermissionRequestFormat.decision_pointer();
461        let pre_tool_use = CodexHookFormat.decision_pointer();
462        assert_eq!(pointer(&permission_request("Bash", "ls")), Some(permission));
463        for other in [
464            r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls"}}"#,
465            r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#,
466            r#"{"hook_event_name":"permissionrequest","tool_name":"Bash","tool_input":{"command":"ls"}}"#,
467            r#"{"hook_event_name":7,"tool_name":"Bash","tool_input":{"command":"ls"}}"#,
468            "not json",
469            "",
470        ] {
471            assert_eq!(pointer(other), Some(pre_tool_use), "{other} must keep the PreToolUse answer");
472        }
473    }
474
475    #[test]
476    fn permission_request_passes_shell_syntax_and_network_approvals_to_the_classifier() {
477        for command in [
478            "echo $(cat x)", "echo `cat x`", "diff <(ls) b", "curl https://x/$HOME", "read l < f", "cat f | xargs curl",
479            "git status && ls", "ls; ls", "ls\nls", "cat *", "cat ~/x", "ls {a,b}", "git status", "bash -c 'ls -la'",
480        ] {
481            let parsed = CodexPermissionRequestFormat.parse_input(&permission_request("Bash", command)).unwrap();
482            assert_eq!(parsed.command, command);
483        }
484        let mut network: Value = serde_json::from_str(&permission_request("Bash", "curl https://example.com")).unwrap();
485        for description in [json!("network-access example.com"), json!(null), json!(7)] {
486            network["tool_input"]["description"] = description.clone();
487            let parsed = CodexPermissionRequestFormat.parse_input(&network.to_string());
488            assert_eq!(parsed.map(|p| p.command).ok().as_deref(), Some("curl https://example.com"), "{description}");
489        }
490    }
491
492    #[test]
493    fn permission_request_parses_a_bash_envelope() {
494        let parsed = CodexPermissionRequestFormat.parse_input(&permission_request("Bash", "git status")).unwrap();
495        assert_eq!(parsed.command, "git status");
496        assert_eq!(parsed.cwd.as_deref(), Some("/w"));
497    }
498
499    #[test]
500    fn permission_request_abstains_on_every_other_tool() {
501        for tool in ["apply_patch", "write_stdin", "request_permissions", "mcp__server__tool", "Edit", "bash"] {
502            assert!(
503                CodexPermissionRequestFormat.parse_input(&permission_request(tool, "ls")).is_err(),
504                "{tool} must not be classified as a shell command"
505            );
506        }
507        let no_tool = r#"{"hook_event_name":"PermissionRequest","tool_input":{"command":"ls"},"cwd":"/w"}"#;
508        assert!(CodexPermissionRequestFormat.parse_input(no_tool).is_err(), "tool_name is required on this event");
509        let pre_tool_use = r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls"},"cwd":"/w"}"#;
510        assert!(CodexPermissionRequestFormat.parse_input(pre_tool_use).is_err());
511        let argv = r#"{"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":["ls"]},"cwd":"/w"}"#;
512        assert!(CodexPermissionRequestFormat.parse_input(argv).is_err(), "only a command string is classified");
513    }
514
515    #[test]
516    fn permission_request_allows_exactly_the_documented_shape() {
517        for level in [SafetyLevel::Inert, SafetyLevel::SafeRead, SafetyLevel::SafeWrite] {
518            let r = CodexPermissionRequestFormat.render_response(Verdict::Allowed(level));
519            assert_eq!(r.exit_code, 0);
520            let v: Value = serde_json::from_str(&r.stdout).unwrap();
521            assert_eq!(v, json!({"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": {"behavior": "allow"}}}));
522        }
523    }
524
525    #[test]
526    fn permission_request_emits_nothing_short_of_a_safe_verdict() {
527        assert_eq!(CodexPermissionRequestFormat.gated_policy(), super::super::GatedPolicy::Defer);
528        for r in [
529            CodexPermissionRequestFormat.render_response(Verdict::Denied),
530            CodexPermissionRequestFormat.render_deny("x"),
531            CodexPermissionRequestFormat.render_ask("x"),
532            CodexPermissionRequestFormat.render_context("x"),
533        ] {
534            assert_eq!(r.stdout, "");
535            assert_eq!(r.exit_code, 0);
536        }
537    }
538
539    #[test]
540    fn permission_request_needs_a_cwd_that_bounds_a_workspace() {
541        let with_cwd = |cwd: Value| {
542            json!({"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"ls"},"cwd":cwd}).to_string()
543        };
544        for unusable in [json!("/"), json!("//"), json!(""), json!("relative/dir"), json!(null), json!(7)] {
545            assert!(CodexPermissionRequestFormat.parse_input(&with_cwd(unusable.clone())).is_err(), "cwd {unusable} must abstain");
546        }
547        let missing = r#"{"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"ls"}}"#;
548        assert!(CodexPermissionRequestFormat.parse_input(missing).is_err());
549        assert!(CodexPermissionRequestFormat.parse_input(&with_cwd(json!("/w"))).is_ok());
550    }
551
552    #[test]
553    fn only_the_permission_request_cwd_is_not_the_commands() {
554        assert!(CodexHookFormat.cwd_is_the_commands());
555        assert!(!CodexPermissionRequestFormat.cwd_is_the_commands());
556        let input = CodexPermissionRequestFormat.parse_input(&permission_request("Bash", "ls")).unwrap();
557        let (cwd, root) = super::super::evaluation_dirs(&CodexPermissionRequestFormat, &input);
558        assert_eq!(cwd.as_deref(), Some(super::super::UNKNOWN_WORKDIR));
559        assert_eq!(root.as_deref(), Some("/w"), "the reported cwd stays the workspace");
560    }
561
562    #[test]
563    fn install_ignores_an_entry_that_does_not_run_this_hook_on_shell_calls() {
564        let dir = tempfile::tempdir().unwrap();
565        let codex_dir = dir.path().join(".codex");
566        std::fs::create_dir(&codex_dir).unwrap();
567        let elsewhere = json!({"hooks": {
568            "PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "safe-chains hook claude"}]}],
569            "PermissionRequest": [{"matcher": "apply_patch", "hooks": [{"type": "command", "command": HOOK_COMMAND}]}],
570        }});
571        std::fs::write(codex_dir.join("hooks.json"), elsewhere.to_string()).unwrap();
572
573        assert!(matches!(target().install(dir.path()).unwrap(), InstallOutcome::Installed { .. }));
574        let settings = installed(dir.path());
575        for event in EVENTS {
576            let entries = settings["hooks"][event].as_array().unwrap();
577            assert_eq!(entries.len(), 2, "{event}: ours is added beside the other entry");
578            assert_eq!(entries[1], hook_entry(HOOK_COMMAND));
579        }
580    }
581
582    #[test]
583    fn matcher_coverage_of_shell_calls() {
584        for covers in [None, Some(json!(null)), Some(json!("")), Some(json!("*")), Some(json!("Bash")), Some(json!("Bash|apply_patch"))] {
585            assert!(matcher_covers_bash(covers.as_ref()), "{covers:?}");
586        }
587        for misses in [json!("apply_patch"), json!("mcp__x__y"), json!(["Bash"]), json!(1)] {
588            assert!(!matcher_covers_bash(Some(&misses)), "{misses}");
589        }
590    }
591
592    #[test]
593    fn install_refuses_a_wrong_typed_permission_request_slot_and_writes_nothing() {
594        let dir = tempfile::tempdir().unwrap();
595        let codex_dir = dir.path().join(".codex");
596        std::fs::create_dir(&codex_dir).unwrap();
597        let path = codex_dir.join("hooks.json");
598        std::fs::write(&path, r#"{"hooks": {"PermissionRequest": "corrupted"}}"#).unwrap();
599        let before = std::fs::read(&path).unwrap();
600
601        let Err(err) = target().install(dir.path()) else { panic!("a wrong-typed slot must be refused") };
602        assert!(err.contains("hooks.PermissionRequest"), "the error names the slot: {err}");
603        assert_eq!(std::fs::read(&path).unwrap(), before, "PreToolUse must not be written alone either");
604    }
605
606    #[test]
607    fn only_a_read_is_granted_where_the_workdir_is_unknown() {
608        use super::super::respond;
609        let grants = |format: &dyn HookFormat, level| respond(format, "x", Verdict::Allowed(level)).is_some_and(|r| !r.stdout.is_empty());
610        assert!(grants(&CodexPermissionRequestFormat, SafetyLevel::Inert));
611        assert!(grants(&CodexPermissionRequestFormat, SafetyLevel::SafeRead));
612        assert!(!grants(&CodexPermissionRequestFormat, SafetyLevel::SafeWrite), "a write could land anywhere");
613        assert!(
614            respond(&CodexHookFormat, "x", Verdict::Allowed(SafetyLevel::SafeWrite)).is_some(),
615            "where the cwd is the command's, the ceiling does not apply"
616        );
617    }
618
619    #[test]
620    fn a_cwd_that_folds_to_the_filesystem_root_bounds_nothing() {
621        for root in ["/", "//", "/.", "/./", "/Users/..", "/tmp/..", "/a/b/../..", "/../.."] {
622            assert!(!bounds_a_workspace(root), "{root}");
623        }
624        for dir in ["/w", "/Users/me/proj", "/a/b/..", "/./w", "/../w"] {
625            assert!(bounds_a_workspace(dir), "{dir}");
626        }
627    }
628
629    proptest::proptest! {
630        /// Folding `.` and `..` is the whole rule: a directory stays a workspace however many `.`
631        /// segments it carries, and climbing back out of every segment it named reaches `/`.
632        #[test]
633        fn bounds_a_workspace_follows_the_folded_depth(
634            names in proptest::collection::vec("[a-zA-Z0-9_-]{1,8}", 1..6),
635            dots in 0usize..4,
636        ) {
637            let dir = format!("/{}", names.join("/"));
638            proptest::prop_assert!(bounds_a_workspace(&dir));
639            let dotted = dir.clone() + &"/.".repeat(dots);
640            let climbed = dir.clone() + &"/..".repeat(names.len() + dots);
641            let relative = names.join("/");
642            proptest::prop_assert!(bounds_a_workspace(&dotted), "{}", dotted);
643            proptest::prop_assert!(!bounds_a_workspace(&climbed), "{}", climbed);
644            proptest::prop_assert!(!bounds_a_workspace(&relative), "a relative path is never a workspace: {}", relative);
645        }
646
647        /// Whatever the command says and whatever the model wrote as its justification, a Bash
648        /// request with a usable cwd reaches the classifier unchanged: the event adds no rule of its
649        /// own about the command's text, only the read ceiling on the verdict.
650        #[test]
651        fn every_bash_request_reaches_the_classifier_verbatim(command in "\\PC*", description in proptest::option::of("\\PC*")) {
652            let mut payload: Value = serde_json::from_str(&permission_request("Bash", &command)).unwrap();
653            payload["tool_input"]["description"] = description.map_or(Value::Null, Value::String);
654            let parsed = CodexPermissionRequestFormat.parse_input(&payload.to_string());
655            proptest::prop_assert_eq!(parsed.map(|p| p.command).ok(), Some(command));
656        }
657    }
658}