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
26pub const HOOK_SETTINGS_RELPATH: &str = ".codex/hooks.json";
31const CODEX_HOOKS_DOCS: &str = "https://learn.chatgpt.com/docs/hooks";
32
33pub const MCP_CONFIG_RELPATH: &str = ".codex/config.toml";
38
39pub 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
45pub 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
114pub 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
124pub 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
139pub 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
177fn 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
197pub 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
289pub 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 #[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 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}