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
29pub const RULES_RELPATH: &str = ".codex/rules/tuff.rules";
32const CODEX_RULES_DOCS: &str = "https://developers.openai.com/codex/rules";
33
34pub 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
79pub 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
108fn 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 #[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}