1use std::path::Path;
2
3use tuff_hooks_spec::{
4 CompatibilityEntry, CompatibilityMatrix, CoverageLevel, HookEvent, SPEC_VERSION,
5};
6
7use tuff_core::adapter::{AgentAdapter, HookSettingsShape};
8use tuff_core::error::{Result, TuffError};
9use tuff_core::manifest::{CapabilityType, McpServerConfig, McpTransport};
10use tuff_core::policy::{
11 PolicyCoverageEntry, PolicyEffect, PolicyRule, PolicySubject, PolicySubjectKind,
12};
13use tuff_core::policy_eval::{
14 PolicyAction, PolicyHookAnswer, PolicyHookRequest, PolicyHookUse, PolicyVerdict,
15};
16
17pub const ID: &str = "codex";
18pub const DISPLAY_NAME: &str = "Codex";
19pub const SUPPORTED_TYPES: &[CapabilityType] = &[
20 CapabilityType::Skill,
21 CapabilityType::Tool,
22 CapabilityType::Hook,
23 CapabilityType::McpServer,
24 CapabilityType::Policy,
25];
26
27pub const SUPPORTED_AGENTS: &[&str] = &["Codex"];
28
29pub const HOOK_SETTINGS_RELPATH: &str = ".codex/hooks.json";
34const CODEX_HOOKS_DOCS: &str = "https://learn.chatgpt.com/docs/hooks";
35
36pub const MCP_CONFIG_RELPATH: &str = ".codex/config.toml";
41
42pub const RULES_RELPATH: &str = ".codex/rules/tuff.rules";
45const CODEX_RULES_DOCS: &str = "https://developers.openai.com/codex/rules";
46const CODEX_MCP_DOCS: &str = "https://developers.openai.com/codex/mcp";
47
48pub fn policy_matrix() -> Vec<PolicyCoverageEntry> {
53 const COMMAND: &str = "matches the command's leading words, and each command of a simple chain joined by &&, ||, ; or |; a script with redirection, $(...), a variable assignment, a wildcard, or control flow is matched as one command and not caught, and a program run by absolute path such as /usr/bin/git may not be matched; Codex loads project rules only in a trusted project and labels rules experimental";
54 const FILES: &str = "tuff policy evaluate runs as a PreToolUse hook and checks the files a Bash command names on its command line, as arguments of programs such as cat, head, sed, and grep or as redirections; a script or program that opens a file itself is not seen; tuff must be on the PATH, the project must be trusted and the hook approved in Codex, and Codex lets the call through if the hook fails";
55 const EDITS: &str = "tuff policy evaluate runs as a PreToolUse hook and checks the files an apply_patch call changes and the files a Bash command writes on its command line, as redirections or arguments of programs such as tee, rm, and mv; a script or program that writes a file itself is not seen; tuff must be on the PATH, the project must be trusted and the hook approved in Codex, and Codex lets the call through if the hook fails";
56 const NO_ASK: &str = "Codex rules match commands, not file paths, and a Codex PreToolUse hook can deny a call but cannot ask";
57 const MCP: &str = "the server and tool must be exact names, since Codex has no pattern form for them, and the server must be declared in .codex/config.toml, which Codex loads only in a trusted project";
58 let row =
59 |effect, subject, coverage, mechanism: Option<&str>, caveat: String| PolicyCoverageEntry {
60 effect,
61 subject,
62 coverage,
63 mechanism: mechanism.map(str::to_string),
64 caveat: Some(caveat),
65 source: Some(
66 match subject {
67 Mcp => CODEX_MCP_DOCS,
68 Read | Edit => CODEX_HOOKS_DOCS,
69 Command => CODEX_RULES_DOCS,
70 }
71 .to_string(),
72 ),
73 };
74 use PolicyEffect::{Ask, Deny};
75 use PolicySubjectKind::{Command, Edit, Mcp, Read};
76 use tuff_hooks_spec::CoverageLevel::{Partial, Unsupported};
77 vec![
78 row(
79 Deny,
80 Command,
81 Partial,
82 Some(".codex/rules/tuff.rules prefix_rule(decision = \"forbidden\")"),
83 COMMAND.to_string(),
84 ),
85 row(
86 Deny,
87 Read,
88 Partial,
89 Some(".codex/hooks.json PreToolUse (Bash): tuff policy evaluate"),
90 FILES.to_string(),
91 ),
92 row(
93 Deny,
94 Edit,
95 Partial,
96 Some(".codex/hooks.json PreToolUse (Bash, apply_patch): tuff policy evaluate"),
97 EDITS.to_string(),
98 ),
99 row(
100 Deny,
101 Mcp,
102 Partial,
103 Some(".codex/config.toml [mcp_servers.<server>] disabled_tools"),
104 format!("{MCP}; Codex removes the tool from the session"),
105 ),
106 row(
107 Ask,
108 Command,
109 Partial,
110 Some(".codex/rules/tuff.rules prefix_rule(decision = \"prompt\")"),
111 format!(
112 "{COMMAND}; where Codex never asks for approval, as in codex exec by default, the command is refused"
113 ),
114 ),
115 row(Ask, Read, Unsupported, None, NO_ASK.to_string()),
116 row(Ask, Edit, Unsupported, None, NO_ASK.to_string()),
117 row(
118 Ask,
119 Mcp,
120 Partial,
121 Some(
122 ".codex/config.toml [mcp_servers.<server>.tools.<tool>] approval_mode = \"prompt\"",
123 ),
124 format!(
125 "{MCP}; Codex approves the call without asking when its approval policy is never and the sandbox allows full disk access or is off"
126 ),
127 ),
128 ]
129}
130
131pub fn permission_relpath(subject: PolicySubjectKind) -> Option<&'static str> {
134 match subject {
135 PolicySubjectKind::Command => Some(RULES_RELPATH),
136 PolicySubjectKind::Mcp => Some(MCP_CONFIG_RELPATH),
137 PolicySubjectKind::Read | PolicySubjectKind::Edit => None,
138 }
139}
140
141pub fn rule_gap(rule: &PolicyRule) -> Result<Option<String>> {
145 Ok(match rule.subject()? {
146 PolicySubject::Mcp { server, tool } if server.contains('*') || tool.contains('*') => {
147 Some(
148 "Codex names MCP servers and tools exactly in disabled_tools and approval_mode, so a pattern with '*' has no Codex form"
149 .to_string(),
150 )
151 }
152 _ => None,
153 })
154}
155
156pub fn permission_rules(rule: &PolicyRule) -> Result<Option<Vec<String>>> {
165 let arguments = match rule.subject()? {
166 PolicySubject::Command(arguments) => arguments,
167 PolicySubject::Mcp { server, tool } => {
168 if rule_gap(rule)?.is_some() {
169 return Ok(None);
170 }
171 return Ok(Some(vec![format!("{server}:{tool}")]));
172 }
173 PolicySubject::Read(_) | PolicySubject::Edit(_) => return Ok(None),
174 };
175 let decision = match rule.effect()? {
176 PolicyEffect::Deny => "forbidden",
177 PolicyEffect::Ask => "prompt",
178 };
179 let pattern = arguments
180 .iter()
181 .map(|argument| starlark_string(argument))
182 .collect::<Vec<_>>()
183 .join(", ");
184 let justification = rule
185 .reason
186 .as_deref()
187 .map(|reason| format!(", justification = {}", starlark_string(reason)))
188 .unwrap_or_default();
189 Ok(Some(vec![format!(
190 "prefix_rule(pattern = [{pattern}], decision = \"{decision}\"{justification})"
191 )]))
192}
193
194fn starlark_string(text: &str) -> String {
198 let mut quoted = String::with_capacity(text.len() + 2);
199 quoted.push('"');
200 for character in text.chars() {
201 match character {
202 '"' => quoted.push_str("\\\""),
203 '\\' => quoted.push_str("\\\\"),
204 character if character.is_control() => quoted.push(' '),
205 character => quoted.push(character),
206 }
207 }
208 quoted.push('"');
209 quoted
210}
211
212pub struct Codex;
213
214pub const HOOK_COMPATIBILITY: CompatibilityMatrix = CompatibilityMatrix {
219 spec_version: SPEC_VERSION,
220 adapter: ID,
221 events: &[
222 CompatibilityEntry {
223 event: HookEvent::SessionStart,
224 native_event: Some("SessionStart"),
225 aliases: &["SessionStart"],
226 coverage: CoverageLevel::Full,
227 scope: &["session lifecycle"],
228 caveat: None,
229 source: Some(CODEX_HOOKS_DOCS),
230 since_harness_version: None,
231 until_harness_version: None,
232 },
233 CompatibilityEntry {
234 event: HookEvent::SessionEnd,
235 native_event: Some("SessionEnd"),
236 aliases: &["SessionEnd"],
237 coverage: CoverageLevel::Full,
238 scope: &["session lifecycle"],
239 caveat: None,
240 source: Some(CODEX_HOOKS_DOCS),
241 since_harness_version: None,
242 until_harness_version: None,
243 },
244 CompatibilityEntry {
245 event: HookEvent::PreToolUse,
246 native_event: Some("PreToolUse"),
247 aliases: &["PreToolUse", "pre_tool_execution"],
248 coverage: CoverageLevel::Full,
249 scope: &["tool calls"],
250 caveat: None,
251 source: Some(CODEX_HOOKS_DOCS),
252 since_harness_version: None,
253 until_harness_version: None,
254 },
255 CompatibilityEntry {
256 event: HookEvent::PostToolUse,
257 native_event: Some("PostToolUse"),
258 aliases: &["PostToolUse", "post_tool_execution"],
259 coverage: CoverageLevel::Full,
260 scope: &["tool calls"],
261 caveat: None,
262 source: Some(CODEX_HOOKS_DOCS),
263 since_harness_version: None,
264 until_harness_version: None,
265 },
266 CompatibilityEntry {
267 event: HookEvent::BeforeFinish,
268 native_event: Some("Stop"),
269 aliases: &["before_finish"],
270 coverage: CoverageLevel::Partial,
271 scope: &["main-agent completion"],
272 caveat: Some(
273 "Codex Stop runs after the agent finishes responding and can request continuation; it does not represent every possible pre-finish boundary.",
274 ),
275 source: Some(CODEX_HOOKS_DOCS),
276 since_harness_version: None,
277 until_harness_version: None,
278 },
279 CompatibilityEntry {
280 event: HookEvent::AfterSave,
281 native_event: None,
282 aliases: &["after_save"],
283 coverage: CoverageLevel::Unsupported,
284 scope: &[],
285 caveat: Some(
286 "Codex documents no after-save event; PostToolUse on its edit tools is the closest moment.",
287 ),
288 source: Some(CODEX_HOOKS_DOCS),
289 since_harness_version: None,
290 until_harness_version: None,
291 },
292 CompatibilityEntry {
293 event: HookEvent::Stop,
294 native_event: Some("Stop"),
295 aliases: &["Stop"],
296 coverage: CoverageLevel::Full,
297 scope: &["main-agent completion"],
298 caveat: None,
299 source: Some(CODEX_HOOKS_DOCS),
300 since_harness_version: None,
301 until_harness_version: None,
302 },
303 ],
304};
305
306pub fn mcp_server_entry(server: &McpServerConfig) -> Result<serde_json::Value> {
315 match server.transport {
316 McpTransport::Stdio => {
317 let mut entry = serde_json::json!({
318 "command": server.command.clone().unwrap_or_default(),
319 "args": server.args,
320 });
321 let mut forwarded = Vec::new();
322 for (name, reference) in &server.env {
323 if *name != reference.from_env {
324 return Err(TuffError::unsupported(format!(
325 "Codex forwards an environment variable under its own name, so it cannot give the server '{name}' from '{}'",
326 reference.from_env
327 ))
328 .with_hint(format!(
329 "export {name} itself before starting Codex, and declare from_env = \"{name}\""
330 )));
331 }
332 forwarded.push(name.clone());
333 }
334 if !forwarded.is_empty() {
335 entry["env_vars"] = serde_json::Value::from(forwarded);
336 }
337 Ok(entry)
338 }
339 McpTransport::Http => {
340 let mut entry = serde_json::json!({
341 "url": server.url.clone().unwrap_or_default(),
342 });
343 let mut plain: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
344 for (name, reference) in &server.headers {
345 match reference.format.as_deref() {
346 None => {
347 plain.insert(
348 name.clone(),
349 serde_json::Value::String(reference.from_env.clone()),
350 );
351 }
352 Some("Bearer {}") if name.eq_ignore_ascii_case("authorization") => {
353 entry["bearer_token_env_var"] =
354 serde_json::Value::String(reference.from_env.clone());
355 }
356 Some(format) => {
357 return Err(TuffError::unsupported(format!(
358 "Codex cannot build the header '{name}' as '{format}' from a variable; it sends a variable's value as the whole header, or a bearer token in Authorization"
359 ))
360 .with_hint("drop the format, or use Authorization with format = \"Bearer {}\""));
361 }
362 }
363 }
364 if !plain.is_empty() {
365 entry["env_http_headers"] = serde_json::Value::Object(plain);
366 }
367 Ok(entry)
368 }
369 }
370}
371
372impl AgentAdapter for Codex {
373 fn id(&self) -> &'static str {
374 ID
375 }
376
377 fn display_name(&self) -> &'static str {
378 DISPLAY_NAME
379 }
380
381 fn dir_prefix(&self) -> &'static str {
382 ".agents"
383 }
384
385 fn mcp_config_relpath(&self) -> &'static str {
386 MCP_CONFIG_RELPATH
387 }
388
389 fn mcp_server_entry_checked(&self, server: &McpServerConfig) -> Result<serde_json::Value> {
390 mcp_server_entry(server)
391 }
392
393 fn install_note(&self, kind: CapabilityType) -> Option<&'static str> {
394 match kind {
395 CapabilityType::Hook => Some(
396 "Codex runs a project's hooks only in a trusted project, and only after they are approved: review them with /hooks in Codex, or start Codex with --dangerously-bypass-hook-trust in automation that vets its own hooks",
397 ),
398 CapabilityType::McpServer | CapabilityType::Tool => Some(
399 "Codex loads a project's MCP servers from .codex/config.toml only in a trusted project",
400 ),
401 _ => None,
402 }
403 }
404
405 fn supported_agents(&self) -> &[&'static str] {
406 SUPPORTED_AGENTS
407 }
408
409 fn kinds_supported(&self) -> &[CapabilityType] {
410 SUPPORTED_TYPES
411 }
412
413 fn hook_compatibility(&self) -> &'static CompatibilityMatrix {
414 &HOOK_COMPATIBILITY
415 }
416
417 fn hook_settings_relpath(&self) -> &'static str {
418 HOOK_SETTINGS_RELPATH
419 }
420
421 fn scaffold_hook_event(&self) -> &'static str {
422 "SessionStart"
423 }
424
425 fn hook_settings_shape(&self) -> HookSettingsShape {
426 HookSettingsShape::Grouped
427 }
428
429 fn policy_compatibility(&self) -> Vec<PolicyCoverageEntry> {
430 policy_matrix()
431 }
432
433 fn permissions_settings_relpath(&self) -> Option<&'static str> {
434 Some(RULES_RELPATH)
435 }
436
437 fn permission_relpath_for(&self, subject: PolicySubjectKind) -> Option<&'static str> {
438 permission_relpath(subject)
439 }
440
441 fn policy_rule_gap(&self, rule: &PolicyRule) -> Result<Option<String>> {
442 rule_gap(rule)
443 }
444
445 fn native_permission_rules(&self, rule: &PolicyRule) -> Result<Option<Vec<String>>> {
446 permission_rules(rule)
447 }
448
449 fn policy_hook_use(&self, rule: &PolicyRule) -> Result<PolicyHookUse> {
450 Ok(match (rule.effect()?, rule.subject()?.kind()) {
451 (PolicyEffect::Deny, PolicySubjectKind::Read | PolicySubjectKind::Edit) => {
452 PolicyHookUse::Required
453 }
454 _ => PolicyHookUse::Never,
455 })
456 }
457
458 fn policy_hook_fragment(
459 &self,
460 command: &str,
461 subjects: &[PolicySubjectKind],
462 ) -> Option<serde_json::Value> {
463 let matcher = if subjects.contains(&PolicySubjectKind::Edit) {
464 "^(Bash|apply_patch)$"
465 } else if subjects.contains(&PolicySubjectKind::Read) {
466 "^Bash$"
467 } else {
468 return None;
469 };
470 Some(serde_json::json!({
471 "hooks": {
472 "PreToolUse": [{
473 "matcher": matcher,
474 "hooks": [{"type": "command", "command": command}]
475 }]
476 }
477 }))
478 }
479
480 fn policy_hook_request(&self, input: &serde_json::Value) -> Result<PolicyHookRequest> {
481 pre_tool_use_request(input)
482 }
483
484 fn policy_hook_answer(&self, event: &str, verdict: PolicyVerdict<'_>) -> PolicyHookAnswer {
485 pre_tool_use_answer(event, verdict)
486 }
487
488 fn detect(&self, repo_root: &Path) -> bool {
489 repo_root.join(".agents").exists() || repo_root.join("AGENTS.md").exists()
490 }
491}
492
493pub fn pre_tool_use_request(input: &serde_json::Value) -> Result<PolicyHookRequest> {
497 let text = |value: &serde_json::Value, key: &str| {
498 value
499 .get(key)
500 .and_then(serde_json::Value::as_str)
501 .map(str::to_string)
502 };
503 let event = text(input, "hook_event_name").unwrap_or_else(|| "PreToolUse".to_string());
504 let tool =
505 text(input, "tool_name").ok_or_else(|| TuffError::usage("hook input has no tool_name"))?;
506 let tool_input = input.get("tool_input").cloned().unwrap_or_default();
507 let command = || -> Result<String> {
508 match tool_input.get("command") {
509 Some(serde_json::Value::String(command)) => Ok(command.clone()),
510 Some(serde_json::Value::Array(words)) => Ok(words
511 .iter()
512 .filter_map(serde_json::Value::as_str)
513 .collect::<Vec<_>>()
514 .join(" ")),
515 _ => Err(TuffError::usage(format!(
516 "{tool} hook input has no tool_input.command"
517 ))),
518 }
519 };
520 let mut actions = Vec::new();
521 match tool.as_str() {
522 "Bash" => actions.push(PolicyAction::Shell(command()?)),
523 "apply_patch" => actions.extend(patch_paths(&command()?).map(PolicyAction::Edit)),
524 other => {
525 if let Some((server, tool)) = other
526 .strip_prefix("mcp__")
527 .and_then(|rest| rest.split_once("__"))
528 {
529 actions.push(PolicyAction::Mcp {
530 server: server.to_string(),
531 tool: tool.to_string(),
532 });
533 }
534 }
535 }
536 Ok(PolicyHookRequest {
537 event,
538 cwd: text(input, "cwd").map(Into::into),
539 roots: Vec::new(),
540 actions,
541 })
542}
543
544pub fn patch_paths(patch: &str) -> impl Iterator<Item = String> + '_ {
546 const HEADERS: [&str; 4] = [
547 "*** Add File: ",
548 "*** Update File: ",
549 "*** Delete File: ",
550 "*** Move to: ",
551 ];
552 patch.lines().filter_map(|line| {
553 let line = line.trim_start();
554 HEADERS
555 .iter()
556 .find_map(|header| line.strip_prefix(header))
557 .map(|path| path.trim().to_string())
558 })
559}
560
561pub fn pre_tool_use_answer(event: &str, verdict: PolicyVerdict<'_>) -> PolicyHookAnswer {
566 let reason = match verdict {
567 PolicyVerdict::Matched(decision) if decision.effect == PolicyEffect::Deny => {
568 decision.message()
569 }
570 PolicyVerdict::Failed(message) => message.to_string(),
571 PolicyVerdict::NoMatch | PolicyVerdict::Matched(_) => {
572 return PolicyHookAnswer {
573 stdout: String::new(),
574 exit_code: 0,
575 };
576 }
577 };
578 let answer = serde_json::json!({
579 "hookSpecificOutput": {
580 "hookEventName": event,
581 "permissionDecision": "deny",
582 "permissionDecisionReason": reason,
583 }
584 });
585 PolicyHookAnswer {
586 stdout: answer.to_string(),
587 exit_code: 0,
588 }
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594
595 #[test]
600 fn a_remote_server_entry_declares_type_and_renders_headers() {
601 let server = tuff_core::manifest::McpServerConfig {
602 transport: tuff_core::manifest::McpTransport::Http,
603 command: None,
604 args: Vec::new(),
605 url: Some("https://mcp.example.test/mcp".to_string()),
606 env: Default::default(),
607 headers: [(
608 "Authorization".to_string(),
609 tuff_core::manifest::HeaderRef {
610 from_env: "EXAMPLE_TOKEN".to_string(),
611 format: Some("Bearer {}".to_string()),
612 },
613 )]
614 .into_iter()
615 .collect(),
616 metadata: None,
617 };
618
619 let entry = Codex.mcp_server_entry_checked(&server).unwrap();
620
621 assert_eq!(
624 entry,
625 serde_json::json!({
626 "url": "https://mcp.example.test/mcp",
627 "bearer_token_env_var": "EXAMPLE_TOKEN",
628 })
629 );
630 }
631
632 #[test]
633 fn a_stdio_server_forwards_its_variables_by_name_and_refuses_a_rename() {
634 use tuff_core::manifest::EnvRef;
635 let mut server = tuff_core::manifest::McpServerConfig {
636 transport: tuff_core::manifest::McpTransport::Stdio,
637 command: Some("npx".to_string()),
638 args: vec!["-y".to_string(), "pkg".to_string()],
639 url: None,
640 env: [(
641 "GITHUB_PERSONAL_ACCESS_TOKEN".to_string(),
642 EnvRef {
643 from_env: "GITHUB_PERSONAL_ACCESS_TOKEN".to_string(),
644 },
645 )]
646 .into_iter()
647 .collect(),
648 headers: Default::default(),
649 metadata: None,
650 };
651 assert_eq!(
652 Codex.mcp_server_entry_checked(&server).unwrap(),
653 serde_json::json!({
654 "command": "npx",
655 "args": ["-y", "pkg"],
656 "env_vars": ["GITHUB_PERSONAL_ACCESS_TOKEN"],
657 })
658 );
659
660 server.env.insert(
661 "API_KEY".to_string(),
662 EnvRef {
663 from_env: "MY_OTHER_KEY".to_string(),
664 },
665 );
666 let error = Codex.mcp_server_entry_checked(&server).unwrap_err();
667 assert!(
668 error
669 .to_string()
670 .contains("cannot give the server 'API_KEY'"),
671 "{error}"
672 );
673 }
674
675 #[test]
676 fn a_plain_header_variable_is_sent_whole_and_a_formatted_one_is_refused() {
677 use tuff_core::manifest::HeaderRef;
678 let header = |name: &str, format: Option<&str>| {
679 (
680 name.to_string(),
681 HeaderRef {
682 from_env: "KEY".to_string(),
683 format: format.map(str::to_string),
684 },
685 )
686 };
687 let mut server = tuff_core::manifest::McpServerConfig {
688 transport: tuff_core::manifest::McpTransport::Http,
689 command: None,
690 args: Vec::new(),
691 url: Some("https://mcp.example.test/mcp".to_string()),
692 env: Default::default(),
693 headers: [header("X-Api-Key", None)].into_iter().collect(),
694 metadata: None,
695 };
696 assert_eq!(
697 Codex.mcp_server_entry_checked(&server).unwrap(),
698 serde_json::json!({
699 "url": "https://mcp.example.test/mcp",
700 "env_http_headers": {"X-Api-Key": "KEY"},
701 })
702 );
703 server.headers.extend([header("X-Signed", Some("HMAC {}"))]);
704 let error = Codex.mcp_server_entry_checked(&server).unwrap_err();
705 assert!(
706 error.to_string().contains("'X-Signed' as 'HMAC {}'"),
707 "{error}"
708 );
709 }
710
711 #[test]
712 fn hooks_register_in_dot_codex_with_codexs_event_names() {
713 assert_eq!(Codex.hook_settings_relpath(), ".codex/hooks.json");
714 assert_eq!(Codex.mcp_config_relpath(), ".codex/config.toml");
715 let native = |event: &str| {
716 HOOK_COMPATIBILITY
717 .find_event(event)
718 .and_then(|entry| entry.native_event_name())
719 };
720 assert_eq!(native("pre_tool_use"), Some("PreToolUse"));
721 assert_eq!(
722 native("pre_tool_execution"),
723 Some("PreToolUse"),
724 "old alias"
725 );
726 assert_eq!(native("before_finish"), Some("Stop"));
727 assert_eq!(native("session_start"), Some("SessionStart"));
728 assert_eq!(native("after_save"), None);
729 }
730
731 #[test]
732 fn command_rules_compile_to_prefix_rules_and_other_subjects_to_nothing() {
733 let rule = |effect: &str| PolicyRule {
734 effect: effect.to_string(),
735 command: None,
736 read: None,
737 edit: None,
738 mcp: None,
739 reason: None,
740 };
741 let words = |words: &[&str]| Some(words.iter().map(|word| word.to_string()).collect());
742 let rules = [
743 PolicyRule {
744 command: words(&["git", "push", "--force"]),
745 reason: Some("Force pushes rewrite \"shared\" history.".to_string()),
746 ..rule("deny")
747 },
748 PolicyRule {
749 command: words(&["terraform", "apply"]),
750 ..rule("ask")
751 },
752 PolicyRule {
753 read: words(&[".env"]),
754 ..rule("deny")
755 },
756 PolicyRule {
757 mcp: Some("github:delete_*".to_string()),
758 ..rule("deny")
759 },
760 PolicyRule {
761 mcp: Some("github:delete_repo".to_string()),
762 ..rule("deny")
763 },
764 PolicyRule {
765 mcp: Some("github:merge_pull_request".to_string()),
766 ..rule("ask")
767 },
768 ];
769 let compiled: Vec<_> = rules
770 .iter()
771 .map(|rule| permission_rules(rule).unwrap())
772 .collect();
773 assert_eq!(
774 compiled,
775 vec![
776 Some(vec![
777 r#"prefix_rule(pattern = ["git", "push", "--force"], decision = "forbidden", justification = "Force pushes rewrite \"shared\" history.")"#
778 .to_string()
779 ]),
780 Some(vec![
781 r#"prefix_rule(pattern = ["terraform", "apply"], decision = "prompt")"#
782 .to_string()
783 ]),
784 None,
785 None,
786 Some(vec!["github:delete_repo".to_string()]),
787 Some(vec!["github:merge_pull_request".to_string()]),
788 ]
789 );
790 let gaps: Vec<_> = rules
791 .iter()
792 .map(|rule| rule_gap(rule).unwrap().is_some())
793 .collect();
794 assert_eq!(gaps, vec![false, false, false, true, false, false]);
795 assert_eq!(
796 permission_relpath(PolicySubjectKind::Mcp),
797 Some(MCP_CONFIG_RELPATH)
798 );
799 assert_eq!(
800 permission_relpath(PolicySubjectKind::Command),
801 Some(RULES_RELPATH)
802 );
803 assert_eq!(permission_relpath(PolicySubjectKind::Read), None);
804 }
805
806 #[test]
807 fn the_policy_matrix_enforces_every_rule_but_asking_about_files() {
808 let matrix = Codex.policy_compatibility();
809 assert_eq!(matrix.len(), 8);
810 for entry in &matrix {
811 let expected = if entry.effect == PolicyEffect::Deny
812 || matches!(
813 entry.subject,
814 PolicySubjectKind::Command | PolicySubjectKind::Mcp
815 ) {
816 tuff_hooks_spec::CoverageLevel::Partial
817 } else {
818 tuff_hooks_spec::CoverageLevel::Unsupported
819 };
820 assert_eq!(entry.coverage, expected, "{entry:?}");
821 assert!(entry.caveat.is_some(), "{entry:?}");
822 }
823 }
824
825 #[test]
826 fn id_and_display_name_are_not_empty() {
827 assert!(!ID.is_empty());
828 assert!(!DISPLAY_NAME.is_empty());
829 }
830
831 #[test]
832 fn supported_types_covers_all_capability_types() {
833 assert_eq!(SUPPORTED_TYPES.len(), 5);
834 }
835
836 #[test]
837 fn merging_the_same_fragment_twice_does_not_duplicate_the_hook() {
838 let fragment = serde_json::json!({
839 "hooks": {
840 "before_finish": [{"hooks": [{"type": "command", "command": "sh .agents/hooks/demo/run.sh"}]}]
841 }
842 });
843
844 let once = Codex
845 .merge_hook_fragment(None, &fragment)
846 .expect("first merge");
847 let twice = Codex
848 .merge_hook_fragment(Some(&once), &fragment)
849 .expect("second merge");
850
851 let settings: serde_json::Value = serde_json::from_slice(&twice).expect("valid json");
852 let groups = settings["hooks"]["before_finish"]
853 .as_array()
854 .expect("event array");
855 assert_eq!(
856 groups.len(),
857 1,
858 "re-adding a hook must not register it twice"
859 );
860 assert_eq!(
861 once, twice,
862 "a redundant merge must leave the file unchanged"
863 );
864 }
865}