Skip to main content

tuff_adapter_codex/
lib.rs

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};
13
14pub const ID: &str = "codex";
15pub const DISPLAY_NAME: &str = "Codex";
16pub const SUPPORTED_TYPES: &[CapabilityType] = &[
17    CapabilityType::Skill,
18    CapabilityType::Tool,
19    CapabilityType::Hook,
20    CapabilityType::McpServer,
21    CapabilityType::Policy,
22];
23
24pub const SUPPORTED_AGENTS: &[&str] = &["Codex"];
25
26/// Codex reads a project's hooks from `.codex/hooks.json`, in the grouped
27/// shape Claude Code uses, and only in a trusted project after the hooks
28/// are approved. Checked in Codex CLI 0.154.0 on 2026-09-16: a
29/// `PreToolUse` and a `SessionStart` hook registered here both ran.
30pub const HOOK_SETTINGS_RELPATH: &str = ".codex/hooks.json";
31const CODEX_HOOKS_DOCS: &str = "https://learn.chatgpt.com/docs/hooks";
32
33/// Codex reads a project's MCP servers from `.codex/config.toml`, under
34/// `[mcp_servers.<id>]`, only in a trusted project. Checked in Codex CLI
35/// 0.154.0 on 2026-09-16: a server declared there was listed by
36/// `codex mcp list` and its tools reached a live session.
37pub const MCP_CONFIG_RELPATH: &str = ".codex/config.toml";
38
39/// The rules file Tuff owns for compiled policy command rules. Codex loads
40/// every `.rules` file under `<repo>/.codex/rules/` in a trusted project.
41pub const RULES_RELPATH: &str = ".codex/rules/tuff.rules";
42const CODEX_RULES_DOCS: &str = "https://developers.openai.com/codex/rules";
43const CODEX_MCP_DOCS: &str = "https://developers.openai.com/codex/mcp";
44
45/// How Codex enforces each kind of policy rule, from its rules and MCP
46/// documentation and checked against Codex CLI 0.154.0 on 2026-09-15 and
47/// 16. Command rules compile to `prefix_rule` entries, and MCP tool rules
48/// to settings on the server's table in `.codex/config.toml`.
49pub fn policy_matrix() -> Vec<PolicyCoverageEntry> {
50    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";
51    const FILES: &str = "Codex rules match commands, not file paths, and Tuff does not compile Codex's sandbox permission profiles";
52    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";
53    let row =
54        |effect, subject, coverage, mechanism: Option<&str>, caveat: String| PolicyCoverageEntry {
55            effect,
56            subject,
57            coverage,
58            mechanism: mechanism.map(str::to_string),
59            caveat: Some(caveat),
60            source: Some(
61                if subject == Mcp {
62                    CODEX_MCP_DOCS
63                } else {
64                    CODEX_RULES_DOCS
65                }
66                .to_string(),
67            ),
68        };
69    use PolicyEffect::{Ask, Deny};
70    use PolicySubjectKind::{Command, Edit, Mcp, Read};
71    use tuff_hooks_spec::CoverageLevel::{Partial, Unsupported};
72    vec![
73        row(
74            Deny,
75            Command,
76            Partial,
77            Some(".codex/rules/tuff.rules prefix_rule(decision = \"forbidden\")"),
78            COMMAND.to_string(),
79        ),
80        row(Deny, Read, Unsupported, None, FILES.to_string()),
81        row(Deny, Edit, Unsupported, None, FILES.to_string()),
82        row(
83            Deny,
84            Mcp,
85            Partial,
86            Some(".codex/config.toml [mcp_servers.<server>] disabled_tools"),
87            format!("{MCP}; Codex removes the tool from the session"),
88        ),
89        row(
90            Ask,
91            Command,
92            Partial,
93            Some(".codex/rules/tuff.rules prefix_rule(decision = \"prompt\")"),
94            format!(
95                "{COMMAND}; where Codex never asks for approval, as in codex exec by default, the command is refused"
96            ),
97        ),
98        row(Ask, Read, Unsupported, None, FILES.to_string()),
99        row(Ask, Edit, Unsupported, None, FILES.to_string()),
100        row(
101            Ask,
102            Mcp,
103            Partial,
104            Some(
105                ".codex/config.toml [mcp_servers.<server>.tools.<tool>] approval_mode = \"prompt\"",
106            ),
107            format!(
108                "{MCP}; Codex approves the call without asking when its approval policy is never and the sandbox allows full disk access or is off"
109            ),
110        ),
111    ]
112}
113
114/// The settings file a rule of one subject compiles into: command rules go
115/// to the rules file, MCP tool rules to the config that declares servers.
116pub fn permission_relpath(subject: PolicySubjectKind) -> Option<&'static str> {
117    match subject {
118        PolicySubjectKind::Command => Some(RULES_RELPATH),
119        PolicySubjectKind::Mcp => Some(MCP_CONFIG_RELPATH),
120        PolicySubjectKind::Read | PolicySubjectKind::Edit => None,
121    }
122}
123
124/// Why Codex cannot enforce a rule its matrix covers: an MCP rule with a
125/// `*`, since `disabled_tools` and a tool's `approval_mode` take exact
126/// names (`ToolFilter` in codex-rs 0.154.0 compares names as a set).
127pub fn rule_gap(rule: &PolicyRule) -> Result<Option<String>> {
128    Ok(match rule.subject()? {
129        PolicySubject::Mcp { server, tool } if server.contains('*') || tool.contains('*') => {
130            Some(
131                "Codex names MCP servers and tools exactly in disabled_tools and approval_mode, so a pattern with '*' has no Codex form"
132                    .to_string(),
133            )
134        }
135        _ => None,
136    })
137}
138
139/// The native rule one policy rule compiles to, or `None` for a kind of
140/// rule Codex cannot express.
141///
142/// A command rule becomes a rules file entry: `deny` becomes
143/// `decision = "forbidden"` and `ask` becomes `decision = "prompt"`, and the
144/// rule's `reason`, when given, becomes the `justification` Codex shows when
145/// it refuses the command. An MCP tool rule becomes `<server>:<tool>`, which
146/// the policy module writes into `.codex/config.toml`.
147pub fn permission_rules(rule: &PolicyRule) -> Result<Option<Vec<String>>> {
148    let arguments = match rule.subject()? {
149        PolicySubject::Command(arguments) => arguments,
150        PolicySubject::Mcp { server, tool } => {
151            if rule_gap(rule)?.is_some() {
152                return Ok(None);
153            }
154            return Ok(Some(vec![format!("{server}:{tool}")]));
155        }
156        PolicySubject::Read(_) | PolicySubject::Edit(_) => return Ok(None),
157    };
158    let decision = match rule.effect()? {
159        PolicyEffect::Deny => "forbidden",
160        PolicyEffect::Ask => "prompt",
161    };
162    let pattern = arguments
163        .iter()
164        .map(|argument| starlark_string(argument))
165        .collect::<Vec<_>>()
166        .join(", ");
167    let justification = rule
168        .reason
169        .as_deref()
170        .map(|reason| format!(", justification = {}", starlark_string(reason)))
171        .unwrap_or_default();
172    Ok(Some(vec![format!(
173        "prefix_rule(pattern = [{pattern}], decision = \"{decision}\"{justification})"
174    )]))
175}
176
177/// A double-quoted Starlark string literal. A policy has already refused
178/// whitespace in command arguments, so control characters can only come
179/// from a reason, where a space keeps the rule on one line.
180fn starlark_string(text: &str) -> String {
181    let mut quoted = String::with_capacity(text.len() + 2);
182    quoted.push('"');
183    for character in text.chars() {
184        match character {
185            '"' => quoted.push_str("\\\""),
186            '\\' => quoted.push_str("\\\\"),
187            character if character.is_control() => quoted.push(' '),
188            character => quoted.push(character),
189        }
190    }
191    quoted.push('"');
192    quoted
193}
194
195pub struct Codex;
196
197/// Codex's hook events, from its hooks documentation. The old snake_case
198/// names Tuff wrote before 0.12.0 (`pre_tool_execution`, `before_finish`,
199/// `after_save`) stay as aliases, so a manifest that names one still
200/// resolves.
201pub const HOOK_COMPATIBILITY: CompatibilityMatrix = CompatibilityMatrix {
202    spec_version: SPEC_VERSION,
203    adapter: ID,
204    events: &[
205        CompatibilityEntry {
206            event: HookEvent::SessionStart,
207            native_event: Some("SessionStart"),
208            aliases: &["SessionStart"],
209            coverage: CoverageLevel::Full,
210            scope: &["session lifecycle"],
211            caveat: None,
212            source: Some(CODEX_HOOKS_DOCS),
213            since_harness_version: None,
214            until_harness_version: None,
215        },
216        CompatibilityEntry {
217            event: HookEvent::SessionEnd,
218            native_event: Some("SessionEnd"),
219            aliases: &["SessionEnd"],
220            coverage: CoverageLevel::Full,
221            scope: &["session lifecycle"],
222            caveat: None,
223            source: Some(CODEX_HOOKS_DOCS),
224            since_harness_version: None,
225            until_harness_version: None,
226        },
227        CompatibilityEntry {
228            event: HookEvent::PreToolUse,
229            native_event: Some("PreToolUse"),
230            aliases: &["PreToolUse", "pre_tool_execution"],
231            coverage: CoverageLevel::Full,
232            scope: &["tool calls"],
233            caveat: None,
234            source: Some(CODEX_HOOKS_DOCS),
235            since_harness_version: None,
236            until_harness_version: None,
237        },
238        CompatibilityEntry {
239            event: HookEvent::PostToolUse,
240            native_event: Some("PostToolUse"),
241            aliases: &["PostToolUse", "post_tool_execution"],
242            coverage: CoverageLevel::Full,
243            scope: &["tool calls"],
244            caveat: None,
245            source: Some(CODEX_HOOKS_DOCS),
246            since_harness_version: None,
247            until_harness_version: None,
248        },
249        CompatibilityEntry {
250            event: HookEvent::BeforeFinish,
251            native_event: Some("Stop"),
252            aliases: &["before_finish"],
253            coverage: CoverageLevel::Partial,
254            scope: &["main-agent completion"],
255            caveat: Some(
256                "Codex Stop runs after the agent finishes responding and can request continuation; it does not represent every possible pre-finish boundary.",
257            ),
258            source: Some(CODEX_HOOKS_DOCS),
259            since_harness_version: None,
260            until_harness_version: None,
261        },
262        CompatibilityEntry {
263            event: HookEvent::AfterSave,
264            native_event: None,
265            aliases: &["after_save"],
266            coverage: CoverageLevel::Unsupported,
267            scope: &[],
268            caveat: Some(
269                "Codex documents no after-save event; PostToolUse on its edit tools is the closest moment.",
270            ),
271            source: Some(CODEX_HOOKS_DOCS),
272            since_harness_version: None,
273            until_harness_version: None,
274        },
275        CompatibilityEntry {
276            event: HookEvent::Stop,
277            native_event: Some("Stop"),
278            aliases: &["Stop"],
279            coverage: CoverageLevel::Full,
280            scope: &["main-agent completion"],
281            caveat: None,
282            source: Some(CODEX_HOOKS_DOCS),
283            since_harness_version: None,
284            until_harness_version: None,
285        },
286    ],
287};
288
289/// The `[mcp_servers.<id>]` table Codex reads, or a refusal for a
290/// declaration its config cannot carry.
291///
292/// Codex forwards a variable from the user's environment under its own
293/// name (`env_vars`), so a declaration that renames one is refused rather
294/// than written as a literal `${VAR}` Codex would not expand. A header is
295/// either a bare variable (`env_http_headers`) or `Authorization: Bearer`
296/// (`bearer_token_env_var`); any other format is refused.
297pub fn mcp_server_entry(server: &McpServerConfig) -> Result<serde_json::Value> {
298    match server.transport {
299        McpTransport::Stdio => {
300            let mut entry = serde_json::json!({
301                "command": server.command.clone().unwrap_or_default(),
302                "args": server.args,
303            });
304            let mut forwarded = Vec::new();
305            for (name, reference) in &server.env {
306                if *name != reference.from_env {
307                    return Err(TuffError::unsupported(format!(
308                        "Codex forwards an environment variable under its own name, so it cannot give the server '{name}' from '{}'",
309                        reference.from_env
310                    ))
311                    .with_hint(format!(
312                        "export {name} itself before starting Codex, and declare from_env = \"{name}\""
313                    )));
314                }
315                forwarded.push(name.clone());
316            }
317            if !forwarded.is_empty() {
318                entry["env_vars"] = serde_json::Value::from(forwarded);
319            }
320            Ok(entry)
321        }
322        McpTransport::Http => {
323            let mut entry = serde_json::json!({
324                "url": server.url.clone().unwrap_or_default(),
325            });
326            let mut plain: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
327            for (name, reference) in &server.headers {
328                match reference.format.as_deref() {
329                    None => {
330                        plain.insert(
331                            name.clone(),
332                            serde_json::Value::String(reference.from_env.clone()),
333                        );
334                    }
335                    Some("Bearer {}") if name.eq_ignore_ascii_case("authorization") => {
336                        entry["bearer_token_env_var"] =
337                            serde_json::Value::String(reference.from_env.clone());
338                    }
339                    Some(format) => {
340                        return Err(TuffError::unsupported(format!(
341                            "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"
342                        ))
343                        .with_hint("drop the format, or use Authorization with format = \"Bearer {}\""));
344                    }
345                }
346            }
347            if !plain.is_empty() {
348                entry["env_http_headers"] = serde_json::Value::Object(plain);
349            }
350            Ok(entry)
351        }
352    }
353}
354
355impl AgentAdapter for Codex {
356    fn id(&self) -> &'static str {
357        ID
358    }
359
360    fn display_name(&self) -> &'static str {
361        DISPLAY_NAME
362    }
363
364    fn dir_prefix(&self) -> &'static str {
365        ".agents"
366    }
367
368    fn mcp_config_relpath(&self) -> &'static str {
369        MCP_CONFIG_RELPATH
370    }
371
372    fn mcp_server_entry_checked(&self, server: &McpServerConfig) -> Result<serde_json::Value> {
373        mcp_server_entry(server)
374    }
375
376    fn install_note(&self, kind: CapabilityType) -> Option<&'static str> {
377        match kind {
378            CapabilityType::Hook => Some(
379                "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",
380            ),
381            CapabilityType::McpServer | CapabilityType::Tool => Some(
382                "Codex loads a project's MCP servers from .codex/config.toml only in a trusted project",
383            ),
384            _ => None,
385        }
386    }
387
388    fn supported_agents(&self) -> &[&'static str] {
389        SUPPORTED_AGENTS
390    }
391
392    fn kinds_supported(&self) -> &[CapabilityType] {
393        SUPPORTED_TYPES
394    }
395
396    fn hook_compatibility(&self) -> &'static CompatibilityMatrix {
397        &HOOK_COMPATIBILITY
398    }
399
400    fn hook_settings_relpath(&self) -> &'static str {
401        HOOK_SETTINGS_RELPATH
402    }
403
404    fn scaffold_hook_event(&self) -> &'static str {
405        "SessionStart"
406    }
407
408    fn hook_settings_shape(&self) -> HookSettingsShape {
409        HookSettingsShape::Grouped
410    }
411
412    fn policy_compatibility(&self) -> Vec<PolicyCoverageEntry> {
413        policy_matrix()
414    }
415
416    fn permissions_settings_relpath(&self) -> Option<&'static str> {
417        Some(RULES_RELPATH)
418    }
419
420    fn permission_relpath_for(&self, subject: PolicySubjectKind) -> Option<&'static str> {
421        permission_relpath(subject)
422    }
423
424    fn policy_rule_gap(&self, rule: &PolicyRule) -> Result<Option<String>> {
425        rule_gap(rule)
426    }
427
428    fn native_permission_rules(&self, rule: &PolicyRule) -> Result<Option<Vec<String>>> {
429        permission_rules(rule)
430    }
431
432    fn detect(&self, repo_root: &Path) -> bool {
433        repo_root.join(".agents").exists() || repo_root.join("AGENTS.md").exists()
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    /// RFC-106 D2: Codex shares Claude Code's remote-server shape, `type`
442    /// and `${VAR}` both. Pinned per adapter rather than inferred from the
443    /// shared default, because assuming harnesses agree is what produced
444    /// debt item #1.
445    #[test]
446    fn a_remote_server_entry_declares_type_and_renders_headers() {
447        let server = tuff_core::manifest::McpServerConfig {
448            transport: tuff_core::manifest::McpTransport::Http,
449            command: None,
450            args: Vec::new(),
451            url: Some("https://mcp.example.test/mcp".to_string()),
452            env: Default::default(),
453            headers: [(
454                "Authorization".to_string(),
455                tuff_core::manifest::HeaderRef {
456                    from_env: "EXAMPLE_TOKEN".to_string(),
457                    format: Some("Bearer {}".to_string()),
458                },
459            )]
460            .into_iter()
461            .collect(),
462            metadata: None,
463        };
464
465        let entry = Codex.mcp_server_entry_checked(&server).unwrap();
466
467        // Codex has no `type`; a bearer Authorization header is a token
468        // variable, and any other header a variable sent whole.
469        assert_eq!(
470            entry,
471            serde_json::json!({
472                "url": "https://mcp.example.test/mcp",
473                "bearer_token_env_var": "EXAMPLE_TOKEN",
474            })
475        );
476    }
477
478    #[test]
479    fn a_stdio_server_forwards_its_variables_by_name_and_refuses_a_rename() {
480        use tuff_core::manifest::EnvRef;
481        let mut server = tuff_core::manifest::McpServerConfig {
482            transport: tuff_core::manifest::McpTransport::Stdio,
483            command: Some("npx".to_string()),
484            args: vec!["-y".to_string(), "pkg".to_string()],
485            url: None,
486            env: [(
487                "GITHUB_PERSONAL_ACCESS_TOKEN".to_string(),
488                EnvRef {
489                    from_env: "GITHUB_PERSONAL_ACCESS_TOKEN".to_string(),
490                },
491            )]
492            .into_iter()
493            .collect(),
494            headers: Default::default(),
495            metadata: None,
496        };
497        assert_eq!(
498            Codex.mcp_server_entry_checked(&server).unwrap(),
499            serde_json::json!({
500                "command": "npx",
501                "args": ["-y", "pkg"],
502                "env_vars": ["GITHUB_PERSONAL_ACCESS_TOKEN"],
503            })
504        );
505
506        server.env.insert(
507            "API_KEY".to_string(),
508            EnvRef {
509                from_env: "MY_OTHER_KEY".to_string(),
510            },
511        );
512        let error = Codex.mcp_server_entry_checked(&server).unwrap_err();
513        assert!(
514            error
515                .to_string()
516                .contains("cannot give the server 'API_KEY'"),
517            "{error}"
518        );
519    }
520
521    #[test]
522    fn a_plain_header_variable_is_sent_whole_and_a_formatted_one_is_refused() {
523        use tuff_core::manifest::HeaderRef;
524        let header = |name: &str, format: Option<&str>| {
525            (
526                name.to_string(),
527                HeaderRef {
528                    from_env: "KEY".to_string(),
529                    format: format.map(str::to_string),
530                },
531            )
532        };
533        let mut server = tuff_core::manifest::McpServerConfig {
534            transport: tuff_core::manifest::McpTransport::Http,
535            command: None,
536            args: Vec::new(),
537            url: Some("https://mcp.example.test/mcp".to_string()),
538            env: Default::default(),
539            headers: [header("X-Api-Key", None)].into_iter().collect(),
540            metadata: None,
541        };
542        assert_eq!(
543            Codex.mcp_server_entry_checked(&server).unwrap(),
544            serde_json::json!({
545                "url": "https://mcp.example.test/mcp",
546                "env_http_headers": {"X-Api-Key": "KEY"},
547            })
548        );
549        server.headers.extend([header("X-Signed", Some("HMAC {}"))]);
550        let error = Codex.mcp_server_entry_checked(&server).unwrap_err();
551        assert!(
552            error.to_string().contains("'X-Signed' as 'HMAC {}'"),
553            "{error}"
554        );
555    }
556
557    #[test]
558    fn hooks_register_in_dot_codex_with_codexs_event_names() {
559        assert_eq!(Codex.hook_settings_relpath(), ".codex/hooks.json");
560        assert_eq!(Codex.mcp_config_relpath(), ".codex/config.toml");
561        let native = |event: &str| {
562            HOOK_COMPATIBILITY
563                .find_event(event)
564                .and_then(|entry| entry.native_event_name())
565        };
566        assert_eq!(native("pre_tool_use"), Some("PreToolUse"));
567        assert_eq!(
568            native("pre_tool_execution"),
569            Some("PreToolUse"),
570            "old alias"
571        );
572        assert_eq!(native("before_finish"), Some("Stop"));
573        assert_eq!(native("session_start"), Some("SessionStart"));
574        assert_eq!(native("after_save"), None);
575    }
576
577    #[test]
578    fn command_rules_compile_to_prefix_rules_and_other_subjects_to_nothing() {
579        let rule = |effect: &str| PolicyRule {
580            effect: effect.to_string(),
581            command: None,
582            read: None,
583            edit: None,
584            mcp: None,
585            reason: None,
586        };
587        let words = |words: &[&str]| Some(words.iter().map(|word| word.to_string()).collect());
588        let rules = [
589            PolicyRule {
590                command: words(&["git", "push", "--force"]),
591                reason: Some("Force pushes rewrite \"shared\" history.".to_string()),
592                ..rule("deny")
593            },
594            PolicyRule {
595                command: words(&["terraform", "apply"]),
596                ..rule("ask")
597            },
598            PolicyRule {
599                read: words(&[".env"]),
600                ..rule("deny")
601            },
602            PolicyRule {
603                mcp: Some("github:delete_*".to_string()),
604                ..rule("deny")
605            },
606            PolicyRule {
607                mcp: Some("github:delete_repo".to_string()),
608                ..rule("deny")
609            },
610            PolicyRule {
611                mcp: Some("github:merge_pull_request".to_string()),
612                ..rule("ask")
613            },
614        ];
615        let compiled: Vec<_> = rules
616            .iter()
617            .map(|rule| permission_rules(rule).unwrap())
618            .collect();
619        assert_eq!(
620            compiled,
621            vec![
622                Some(vec![
623                    r#"prefix_rule(pattern = ["git", "push", "--force"], decision = "forbidden", justification = "Force pushes rewrite \"shared\" history.")"#
624                        .to_string()
625                ]),
626                Some(vec![
627                    r#"prefix_rule(pattern = ["terraform", "apply"], decision = "prompt")"#
628                        .to_string()
629                ]),
630                None,
631                None,
632                Some(vec!["github:delete_repo".to_string()]),
633                Some(vec!["github:merge_pull_request".to_string()]),
634            ]
635        );
636        let gaps: Vec<_> = rules
637            .iter()
638            .map(|rule| rule_gap(rule).unwrap().is_some())
639            .collect();
640        assert_eq!(gaps, vec![false, false, false, true, false, false]);
641        assert_eq!(
642            permission_relpath(PolicySubjectKind::Mcp),
643            Some(MCP_CONFIG_RELPATH)
644        );
645        assert_eq!(
646            permission_relpath(PolicySubjectKind::Command),
647            Some(RULES_RELPATH)
648        );
649        assert_eq!(permission_relpath(PolicySubjectKind::Read), None);
650    }
651
652    #[test]
653    fn the_policy_matrix_enforces_command_and_mcp_rules() {
654        let matrix = Codex.policy_compatibility();
655        assert_eq!(matrix.len(), 8);
656        for entry in &matrix {
657            let expected = if matches!(
658                entry.subject,
659                PolicySubjectKind::Command | PolicySubjectKind::Mcp
660            ) {
661                tuff_hooks_spec::CoverageLevel::Partial
662            } else {
663                tuff_hooks_spec::CoverageLevel::Unsupported
664            };
665            assert_eq!(entry.coverage, expected, "{entry:?}");
666            assert!(entry.caveat.is_some(), "{entry:?}");
667        }
668    }
669
670    #[test]
671    fn id_and_display_name_are_not_empty() {
672        assert!(!ID.is_empty());
673        assert!(!DISPLAY_NAME.is_empty());
674    }
675
676    #[test]
677    fn supported_types_covers_all_capability_types() {
678        assert_eq!(SUPPORTED_TYPES.len(), 5);
679    }
680
681    #[test]
682    fn merging_the_same_fragment_twice_does_not_duplicate_the_hook() {
683        let fragment = serde_json::json!({
684            "hooks": {
685                "before_finish": [{"hooks": [{"type": "command", "command": "sh .agents/hooks/demo/run.sh"}]}]
686            }
687        });
688
689        let once = Codex
690            .merge_hook_fragment(None, &fragment)
691            .expect("first merge");
692        let twice = Codex
693            .merge_hook_fragment(Some(&once), &fragment)
694            .expect("second merge");
695
696        let settings: serde_json::Value = serde_json::from_slice(&twice).expect("valid json");
697        let groups = settings["hooks"]["before_finish"]
698            .as_array()
699            .expect("event array");
700        assert_eq!(
701            groups.len(),
702            1,
703            "re-adding a hook must not register it twice"
704        );
705        assert_eq!(
706            once, twice,
707            "a redundant merge must leave the file unchanged"
708        );
709    }
710}