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
13const 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 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 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 #[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 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, session_id: None,
124 })
125 }
126
127 fn decision_pointer(&self) -> &'static str {
128 "/hookSpecificOutput/permissionDecision" }
130
131 fn render_response(&self, _verdict: Verdict) -> HookResponse {
132 HookResponse { stdout: String::new(), exit_code: 0 }
137 }
138
139 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
158struct CodexPermissionRequestFormat;
164
165#[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 if envelope.tool_name != "Bash" {
190 return Err(ParseError { message: format!("not a shell tool: {}", envelope.tool_name) });
191 }
192 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 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
226fn 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
253fn 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
280fn 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 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 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 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 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 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 #[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 #[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}