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;
9use tuff_core::manifest::CapabilityType;
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::Workflow,
21    CapabilityType::McpServer,
22    CapabilityType::Policy,
23];
24
25pub const SUPPORTED_AGENTS: &[&str] = &["Codex"];
26
27pub const HOOK_SETTINGS_RELPATH: &str = ".agents/hook.json";
28
29/// The rules file Tuff owns for compiled policy command rules. Codex loads
30/// every `.rules` file under `<repo>/.codex/rules/` in a trusted project.
31pub const RULES_RELPATH: &str = ".codex/rules/tuff.rules";
32const CODEX_RULES_DOCS: &str = "https://developers.openai.com/codex/rules";
33
34/// How Codex enforces each kind of policy rule, from its rules documentation
35/// and checked against Codex CLI 0.154.0 on 2026-09-15. Command rules
36/// compile to `prefix_rule` entries; Codex rules match commands only.
37pub fn policy_matrix() -> Vec<PolicyCoverageEntry> {
38    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";
39    const FILES: &str = "Codex rules match commands, not file paths, and Tuff does not compile Codex's sandbox permission profiles";
40    const MCP: &str = "Tuff does not compile MCP tool rules for Codex yet";
41    let row =
42        |effect, subject, coverage, mechanism: Option<&str>, caveat: String| PolicyCoverageEntry {
43            effect,
44            subject,
45            coverage,
46            mechanism: mechanism.map(str::to_string),
47            caveat: Some(caveat),
48            source: Some(CODEX_RULES_DOCS.to_string()),
49        };
50    use PolicyEffect::{Ask, Deny};
51    use PolicySubjectKind::{Command, Edit, Mcp, Read};
52    use tuff_hooks_spec::CoverageLevel::{Partial, Unsupported};
53    vec![
54        row(
55            Deny,
56            Command,
57            Partial,
58            Some(".codex/rules/tuff.rules prefix_rule(decision = \"forbidden\")"),
59            COMMAND.to_string(),
60        ),
61        row(Deny, Read, Unsupported, None, FILES.to_string()),
62        row(Deny, Edit, Unsupported, None, FILES.to_string()),
63        row(Deny, Mcp, Unsupported, None, MCP.to_string()),
64        row(
65            Ask,
66            Command,
67            Partial,
68            Some(".codex/rules/tuff.rules prefix_rule(decision = \"prompt\")"),
69            format!(
70                "{COMMAND}; where Codex never asks for approval, as in codex exec by default, the command is refused"
71            ),
72        ),
73        row(Ask, Read, Unsupported, None, FILES.to_string()),
74        row(Ask, Edit, Unsupported, None, FILES.to_string()),
75        row(Ask, Mcp, Unsupported, None, MCP.to_string()),
76    ]
77}
78
79/// The Codex rules file entry one policy rule compiles to, or `None` for a
80/// kind of rule Codex rules cannot express.
81///
82/// `deny` becomes `decision = "forbidden"` and `ask` becomes
83/// `decision = "prompt"`. The rule's `reason`, when given, becomes the
84/// `justification` Codex shows when it refuses the command.
85pub fn permission_rules(rule: &PolicyRule) -> Result<Option<Vec<String>>> {
86    let PolicySubject::Command(arguments) = rule.subject()? else {
87        return Ok(None);
88    };
89    let decision = match rule.effect()? {
90        PolicyEffect::Deny => "forbidden",
91        PolicyEffect::Ask => "prompt",
92    };
93    let pattern = arguments
94        .iter()
95        .map(|argument| starlark_string(argument))
96        .collect::<Vec<_>>()
97        .join(", ");
98    let justification = rule
99        .reason
100        .as_deref()
101        .map(|reason| format!(", justification = {}", starlark_string(reason)))
102        .unwrap_or_default();
103    Ok(Some(vec![format!(
104        "prefix_rule(pattern = [{pattern}], decision = \"{decision}\"{justification})"
105    )]))
106}
107
108/// A double-quoted Starlark string literal. A policy has already refused
109/// whitespace in command arguments, so control characters can only come
110/// from a reason, where a space keeps the rule on one line.
111fn starlark_string(text: &str) -> String {
112    let mut quoted = String::with_capacity(text.len() + 2);
113    quoted.push('"');
114    for character in text.chars() {
115        match character {
116            '"' => quoted.push_str("\\\""),
117            '\\' => quoted.push_str("\\\\"),
118            character if character.is_control() => quoted.push(' '),
119            character => quoted.push(character),
120        }
121    }
122    quoted.push('"');
123    quoted
124}
125
126pub struct Codex;
127
128pub const HOOK_COMPATIBILITY: CompatibilityMatrix = CompatibilityMatrix {
129    spec_version: SPEC_VERSION,
130    adapter: ID,
131    events: &[
132        CompatibilityEntry {
133            event: HookEvent::BeforeFinish,
134            native_event: Some("before_finish"),
135            aliases: &[],
136            coverage: CoverageLevel::Full,
137            scope: &[],
138            caveat: None,
139            source: None,
140            since_harness_version: None,
141            until_harness_version: None,
142        },
143        CompatibilityEntry {
144            event: HookEvent::AfterSave,
145            native_event: Some("after_save"),
146            aliases: &[],
147            coverage: CoverageLevel::Full,
148            scope: &[],
149            caveat: None,
150            source: None,
151            since_harness_version: None,
152            until_harness_version: None,
153        },
154        CompatibilityEntry {
155            event: HookEvent::PreToolUse,
156            native_event: Some("pre_tool_execution"),
157            aliases: &["pre_tool_execution"],
158            coverage: CoverageLevel::Partial,
159            scope: &["local function tools", "Bash", "Edit", "Write", "MCP"],
160            caveat: Some("Codex hosted tools do not use the local function-tool hook path."),
161            source: Some("https://learn.chatgpt.com/docs/hooks.md"),
162            since_harness_version: None,
163            until_harness_version: None,
164        },
165        CompatibilityEntry {
166            event: HookEvent::PostToolUse,
167            native_event: Some("post_tool_execution"),
168            aliases: &["post_tool_execution"],
169            coverage: CoverageLevel::Partial,
170            scope: &["local function tools", "Bash", "Edit", "Write", "MCP"],
171            caveat: Some("Codex hosted tools do not use the local function-tool hook path."),
172            source: Some("https://learn.chatgpt.com/docs/hooks.md"),
173            since_harness_version: None,
174            until_harness_version: None,
175        },
176        CompatibilityEntry {
177            event: HookEvent::SessionStart,
178            native_event: None,
179            aliases: &[],
180            coverage: CoverageLevel::Unsupported,
181            scope: &[],
182            caveat: Some("Codex hook.json does not currently define a session-start event."),
183            source: None,
184            since_harness_version: None,
185            until_harness_version: None,
186        },
187        CompatibilityEntry {
188            event: HookEvent::SessionEnd,
189            native_event: None,
190            aliases: &[],
191            coverage: CoverageLevel::Unsupported,
192            scope: &[],
193            caveat: Some("Codex hook.json does not currently define a session-end event."),
194            source: None,
195            since_harness_version: None,
196            until_harness_version: None,
197        },
198        CompatibilityEntry {
199            event: HookEvent::Stop,
200            native_event: None,
201            aliases: &[],
202            coverage: CoverageLevel::Unsupported,
203            scope: &[],
204            caveat: Some("Codex hook.json does not currently define a stop event."),
205            source: None,
206            since_harness_version: None,
207            until_harness_version: None,
208        },
209    ],
210};
211
212impl AgentAdapter for Codex {
213    fn id(&self) -> &'static str {
214        ID
215    }
216
217    fn display_name(&self) -> &'static str {
218        DISPLAY_NAME
219    }
220
221    fn dir_prefix(&self) -> &'static str {
222        ".agents"
223    }
224
225    fn mcp_config_relpath(&self) -> &'static str {
226        ".agents/mcp.json"
227    }
228
229    fn supported_agents(&self) -> &[&'static str] {
230        SUPPORTED_AGENTS
231    }
232
233    fn kinds_supported(&self) -> &[CapabilityType] {
234        SUPPORTED_TYPES
235    }
236
237    fn hook_compatibility(&self) -> &'static CompatibilityMatrix {
238        &HOOK_COMPATIBILITY
239    }
240
241    fn hook_settings_relpath(&self) -> &'static str {
242        HOOK_SETTINGS_RELPATH
243    }
244
245    fn scaffold_hook_event(&self) -> &'static str {
246        "before_finish"
247    }
248
249    fn hook_settings_shape(&self) -> HookSettingsShape {
250        HookSettingsShape::Grouped
251    }
252
253    fn policy_compatibility(&self) -> Vec<PolicyCoverageEntry> {
254        policy_matrix()
255    }
256
257    fn permissions_settings_relpath(&self) -> Option<&'static str> {
258        Some(RULES_RELPATH)
259    }
260
261    fn native_permission_rules(&self, rule: &PolicyRule) -> Result<Option<Vec<String>>> {
262        permission_rules(rule)
263    }
264
265    fn detect(&self, repo_root: &Path) -> bool {
266        repo_root.join(".agents").exists() || repo_root.join("AGENTS.md").exists()
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    /// RFC-106 D2: Codex shares Claude Code's remote-server shape, `type`
275    /// and `${VAR}` both. Pinned per adapter rather than inferred from the
276    /// shared default, because assuming harnesses agree is what produced
277    /// debt item #1.
278    #[test]
279    fn a_remote_server_entry_declares_type_and_renders_headers() {
280        let server = tuff_core::manifest::McpServerConfig {
281            transport: tuff_core::manifest::McpTransport::Http,
282            command: None,
283            args: Vec::new(),
284            url: Some("https://mcp.example.test/mcp".to_string()),
285            env: Default::default(),
286            headers: [(
287                "Authorization".to_string(),
288                tuff_core::manifest::HeaderRef {
289                    from_env: "EXAMPLE_TOKEN".to_string(),
290                    format: Some("Bearer {}".to_string()),
291                },
292            )]
293            .into_iter()
294            .collect(),
295            metadata: None,
296        };
297
298        let entry = Codex.mcp_server_entry(&server);
299
300        assert_eq!(
301            entry,
302            serde_json::json!({
303                "type": "http",
304                "url": "https://mcp.example.test/mcp",
305                "headers": {"Authorization": "Bearer ${EXAMPLE_TOKEN}"},
306            })
307        );
308    }
309
310    #[test]
311    fn command_rules_compile_to_prefix_rules_and_other_subjects_to_nothing() {
312        let rule = |effect: &str| PolicyRule {
313            effect: effect.to_string(),
314            command: None,
315            read: None,
316            edit: None,
317            mcp: None,
318            reason: None,
319        };
320        let words = |words: &[&str]| Some(words.iter().map(|word| word.to_string()).collect());
321        let rules = [
322            PolicyRule {
323                command: words(&["git", "push", "--force"]),
324                reason: Some("Force pushes rewrite \"shared\" history.".to_string()),
325                ..rule("deny")
326            },
327            PolicyRule {
328                command: words(&["terraform", "apply"]),
329                ..rule("ask")
330            },
331            PolicyRule {
332                read: words(&[".env"]),
333                ..rule("deny")
334            },
335            PolicyRule {
336                mcp: Some("github:delete_*".to_string()),
337                ..rule("deny")
338            },
339        ];
340        let compiled: Vec<_> = rules
341            .iter()
342            .map(|rule| permission_rules(rule).unwrap())
343            .collect();
344        assert_eq!(
345            compiled,
346            vec![
347                Some(vec![
348                    r#"prefix_rule(pattern = ["git", "push", "--force"], decision = "forbidden", justification = "Force pushes rewrite \"shared\" history.")"#
349                        .to_string()
350                ]),
351                Some(vec![
352                    r#"prefix_rule(pattern = ["terraform", "apply"], decision = "prompt")"#
353                        .to_string()
354                ]),
355                None,
356                None,
357            ]
358        );
359    }
360
361    #[test]
362    fn the_policy_matrix_enforces_command_rules_only() {
363        let matrix = Codex.policy_compatibility();
364        assert_eq!(matrix.len(), 8);
365        for entry in &matrix {
366            let expected = if entry.subject == PolicySubjectKind::Command {
367                tuff_hooks_spec::CoverageLevel::Partial
368            } else {
369                tuff_hooks_spec::CoverageLevel::Unsupported
370            };
371            assert_eq!(entry.coverage, expected, "{entry:?}");
372            assert!(entry.caveat.is_some(), "{entry:?}");
373        }
374    }
375
376    #[test]
377    fn id_and_display_name_are_not_empty() {
378        assert!(!ID.is_empty());
379        assert!(!DISPLAY_NAME.is_empty());
380    }
381
382    #[test]
383    fn supported_types_covers_all_capability_types() {
384        assert_eq!(SUPPORTED_TYPES.len(), 6);
385    }
386
387    #[test]
388    fn merging_the_same_fragment_twice_does_not_duplicate_the_hook() {
389        let fragment = serde_json::json!({
390            "hooks": {
391                "before_finish": [{"hooks": [{"type": "command", "command": "sh .agents/hooks/demo/run.sh"}]}]
392            }
393        });
394
395        let once = Codex
396            .merge_hook_fragment(None, &fragment)
397            .expect("first merge");
398        let twice = Codex
399            .merge_hook_fragment(Some(&once), &fragment)
400            .expect("second merge");
401
402        let settings: serde_json::Value = serde_json::from_slice(&twice).expect("valid json");
403        let groups = settings["hooks"]["before_finish"]
404            .as_array()
405            .expect("event array");
406        assert_eq!(
407            groups.len(),
408            1,
409            "re-adding a hook must not register it twice"
410        );
411        assert_eq!(
412            once, twice,
413            "a redundant merge must leave the file unchanged"
414        );
415    }
416}