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};
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
29/// Codex reads a project's hooks from `.codex/hooks.json`, in the grouped
30/// shape Claude Code uses, and only in a trusted project after the hooks
31/// are approved. Checked in Codex CLI 0.154.0 on 2026-09-16: a
32/// `PreToolUse` and a `SessionStart` hook registered here both ran.
33pub const HOOK_SETTINGS_RELPATH: &str = ".codex/hooks.json";
34const CODEX_HOOKS_DOCS: &str = "https://learn.chatgpt.com/docs/hooks";
35
36/// Codex reads a project's MCP servers from `.codex/config.toml`, under
37/// `[mcp_servers.<id>]`, only in a trusted project. Checked in Codex CLI
38/// 0.154.0 on 2026-09-16: a server declared there was listed by
39/// `codex mcp list` and its tools reached a live session.
40pub const MCP_CONFIG_RELPATH: &str = ".codex/config.toml";
41
42/// The rules file Tuff owns for compiled policy command rules. Codex loads
43/// every `.rules` file under `<repo>/.codex/rules/` in a trusted project.
44pub 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
48/// How Codex enforces each kind of policy rule, from its rules and MCP
49/// documentation and checked against Codex CLI 0.154.0 on 2026-09-15 and
50/// 16. Command rules compile to `prefix_rule` entries, and MCP tool rules
51/// to settings on the server's table in `.codex/config.toml`.
52pub 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
131/// The settings file a rule of one subject compiles into: command rules go
132/// to the rules file, MCP tool rules to the config that declares servers.
133pub 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
141/// Why Codex cannot enforce a rule its matrix covers: an MCP rule with a
142/// `*`, since `disabled_tools` and a tool's `approval_mode` take exact
143/// names (`ToolFilter` in codex-rs 0.154.0 compares names as a set).
144pub 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
156/// The native rule one policy rule compiles to, or `None` for a kind of
157/// rule Codex cannot express.
158///
159/// A command rule becomes a rules file entry: `deny` becomes
160/// `decision = "forbidden"` and `ask` becomes `decision = "prompt"`, and the
161/// rule's `reason`, when given, becomes the `justification` Codex shows when
162/// it refuses the command. An MCP tool rule becomes `<server>:<tool>`, which
163/// the policy module writes into `.codex/config.toml`.
164pub 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
194/// A double-quoted Starlark string literal. A policy has already refused
195/// whitespace in command arguments, so control characters can only come
196/// from a reason, where a space keeps the rule on one line.
197fn 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
214/// Codex's hook events, from its hooks documentation. The old snake_case
215/// names Tuff wrote before 0.12.0 (`pre_tool_execution`, `before_finish`,
216/// `after_save`) stay as aliases, so a manifest that names one still
217/// resolves.
218pub 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
306/// The `[mcp_servers.<id>]` table Codex reads, or a refusal for a
307/// declaration its config cannot carry.
308///
309/// Codex forwards a variable from the user's environment under its own
310/// name (`env_vars`), so a declaration that renames one is refused rather
311/// than written as a literal `${VAR}` Codex would not expand. A header is
312/// either a bare variable (`env_http_headers`) or `Authorization: Bearer`
313/// (`bearer_token_env_var`); any other format is refused.
314pub 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
493/// Read a Codex `PreToolUse` hook input. `Bash` and `apply_patch` carry
494/// their text in `tool_input.command`; an MCP tool is named
495/// `mcp__<server>__<tool>`.
496pub 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
544/// The files an `apply_patch` envelope adds, updates, deletes, or moves to.
545pub 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
561/// A `PreToolUse` answer. Codex refuses a call with a `deny` decision and
562/// does not support `ask` from a hook, so an ask rule matched here says
563/// nothing and is left to the native rule, if any. No match prints
564/// nothing; input that cannot be read is denied.
565pub 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    /// RFC-106 D2: Codex shares Claude Code's remote-server shape, `type`
596    /// and `${VAR}` both. Pinned per adapter rather than inferred from the
597    /// shared default, because assuming harnesses agree is what produced
598    /// debt item #1.
599    #[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        // Codex has no `type`; a bearer Authorization header is a token
622        // variable, and any other header a variable sent whole.
623        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}