safe_chains/targets/
codex.rs1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4use serde_json::{Map, Value, json};
5
6use super::{HookFormat, HookInput, HookResponse, InstallOutcome, ParseError, Target};
7use crate::verdict::Verdict;
8
9pub struct CodexTarget;
10
11impl Target for CodexTarget {
12 fn name(&self) -> &'static str {
13 "codex"
14 }
15
16 fn display_name(&self) -> &'static str {
17 "Codex (OpenAI)"
18 }
19
20 #[cfg(test)]
21 fn sample_envelope(&self, tool: &str, command: &str) -> Option<String> {
22 Some(format!(r#"{{"tool_name":"{tool}","tool_input":{{"command":"{command}"}}}}"#))
23 }
24
25 fn detect_paths(&self, home: &Path) -> Vec<PathBuf> {
26 vec![home.join(".codex")]
27 }
28
29 fn install(&self, home: &Path) -> Result<InstallOutcome, String> {
30 let dir = home.join(".codex");
31 if !dir.exists() {
32 return Ok(InstallOutcome::Skipped {
33 reason: format!(
34 "~/.codex not found at {} (Codex CLI not installed)",
35 dir.display()
36 ),
37 });
38 }
39
40 let path = dir.join("hooks.json");
41 let binary = "safe-chains hook codex";
42
43 if path.exists() {
44 let contents = std::fs::read_to_string(&path)
45 .map_err(|e| format!("Could not read {}: {e}", path.display()))?;
46 let mut settings: Value = serde_json::from_str(&contents)
47 .map_err(|e| format!("Could not parse {}: {e}", path.display()))?;
48
49 if has_safe_chains_hook(&settings) {
50 return Ok(InstallOutcome::AlreadyConfigured { path });
51 }
52
53 add_hook(&mut settings, binary).map_err(|e| format!("{}: {e}", path.display()))?;
54 let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
55 std::fs::write(&path, format!("{output}\n"))
56 .map_err(|e| format!("Could not write {}: {e}", path.display()))?;
57 Ok(InstallOutcome::Installed { path })
58 } else {
59 let mut settings = Value::Object(Map::new());
60 add_hook(&mut settings, binary).map_err(|e| format!("{}: {e}", path.display()))?;
61 let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
62 std::fs::write(&path, format!("{output}\n"))
63 .map_err(|e| format!("Could not write {}: {e}", path.display()))?;
64 Ok(InstallOutcome::Installed { path })
65 }
66 }
67
68 fn hook_format(&self) -> Option<&dyn HookFormat> {
69 Some(&CodexHookFormat)
70 }
71}
72
73struct CodexHookFormat;
74
75#[derive(Deserialize)]
76struct ToolInput {
77 command: String,
78}
79
80#[derive(Deserialize)]
81struct CodexHookEnvelope {
82 #[serde(default)]
85 tool_name: Option<String>,
86 tool_input: ToolInput,
87 #[serde(default)]
88 cwd: Option<String>,
89}
90
91impl HookFormat for CodexHookFormat {
92 fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
93 let envelope: CodexHookEnvelope = serde_json::from_str(stdin).map_err(|e| ParseError {
94 message: e.to_string(),
95 })?;
96 if let Some(name) = &envelope.tool_name
99 && name != "Bash"
100 {
101 return Err(ParseError { message: format!("not a shell tool: {name}") });
102 }
103 Ok(HookInput {
104 command: envelope.tool_input.command,
105 cwd: envelope.cwd,
106 root: None, session_id: None,
109 })
110 }
111
112 fn decision_pointer(&self) -> &'static str {
113 "/hookSpecificOutput/permissionDecision" }
115
116 fn render_response(&self, _verdict: Verdict) -> HookResponse {
117 HookResponse {
122 stdout: String::new(),
123 exit_code: 0,
124 }
125 }
126
127 fn gated_policy(&self) -> super::GatedPolicy {
131 super::GatedPolicy::Deny
132 }
133
134 fn render_deny(&self, reason: &str) -> HookResponse {
135 let body = json!({
136 "hookSpecificOutput": {
137 "hookEventName": "PreToolUse",
138 "permissionDecision": "deny",
139 "permissionDecisionReason": reason,
140 }
141 });
142 HookResponse {
143 stdout: serde_json::to_string(&body).unwrap_or_default(),
144 exit_code: 0,
145 }
146 }
147}
148
149fn hook_entry(binary: &str) -> Value {
150 json!({
151 "matcher": "Bash",
152 "hooks": [{
153 "type": "command",
154 "command": binary,
155 }]
156 })
157}
158
159fn has_safe_chains_hook(settings: &Value) -> bool {
160 settings
161 .get("hooks")
162 .and_then(|h| h.get("PreToolUse"))
163 .and_then(|arr| arr.as_array())
164 .is_some_and(|entries| {
165 entries.iter().any(|entry| {
166 entry
167 .get("hooks")
168 .and_then(|h| h.as_array())
169 .is_some_and(|hooks| {
170 hooks.iter().any(|hook| {
171 hook.get("command")
172 .and_then(|c| c.as_str())
173 .is_some_and(|cmd| cmd.contains("safe-chains"))
174 })
175 })
176 })
177 })
178}
179
180fn add_hook(settings: &mut Value, binary: &str) -> Result<(), String> {
188 super::append_hook_entry(settings, "hooks", "PreToolUse", hook_entry(binary))
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use crate::verdict::SafetyLevel;
195
196 fn target() -> CodexTarget {
197 CodexTarget
198 }
199
200 #[test]
201 fn install_no_codex_dir_skips() {
202 let dir = tempfile::tempdir().unwrap();
203 let outcome = target().install(dir.path()).unwrap();
204 assert!(matches!(outcome, InstallOutcome::Skipped { .. }));
205 }
206
207 #[test]
208 fn install_creates_hooks_file() {
209 let dir = tempfile::tempdir().unwrap();
210 std::fs::create_dir(dir.path().join(".codex")).unwrap();
211 let outcome = target().install(dir.path()).unwrap();
212 assert!(matches!(outcome, InstallOutcome::Installed { .. }));
213 let contents = std::fs::read_to_string(dir.path().join(".codex/hooks.json")).unwrap();
214 let settings: Value = serde_json::from_str(&contents).unwrap();
215 assert!(has_safe_chains_hook(&settings));
216 assert!(settings.get("hooks").and_then(|h| h.get("PreToolUse")).is_some());
219 assert!(settings.get("PreToolUse").is_none(), "must not use Claude's flat PreToolUse key");
220 }
221
222 #[test]
223 fn install_idempotent() {
224 let dir = tempfile::tempdir().unwrap();
225 std::fs::create_dir(dir.path().join(".codex")).unwrap();
226 target().install(dir.path()).unwrap();
227 let outcome = target().install(dir.path()).unwrap();
228 assert!(matches!(outcome, InstallOutcome::AlreadyConfigured { .. }));
229 }
230
231 #[test]
232 fn install_uses_subcommand_invocation() {
233 let dir = tempfile::tempdir().unwrap();
236 std::fs::create_dir(dir.path().join(".codex")).unwrap();
237 target().install(dir.path()).unwrap();
238 let contents = std::fs::read_to_string(dir.path().join(".codex/hooks.json")).unwrap();
239 assert!(contents.contains("safe-chains hook codex"));
240 }
241
242 #[test]
243 fn install_preserves_existing_hooks() {
244 let dir = tempfile::tempdir().unwrap();
245 let codex_dir = dir.path().join(".codex");
246 std::fs::create_dir(&codex_dir).unwrap();
247 std::fs::write(
248 codex_dir.join("hooks.json"),
249 r#"{"PostToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "log-it"}]}]}"#,
250 )
251 .unwrap();
252 target().install(dir.path()).unwrap();
253 let contents = std::fs::read_to_string(codex_dir.join("hooks.json")).unwrap();
254 let settings: Value = serde_json::from_str(&contents).unwrap();
255 assert!(has_safe_chains_hook(&settings));
256 assert!(
257 settings.get("PostToolUse").is_some(),
258 "existing PostToolUse must be preserved"
259 );
260 }
261
262 #[test]
263 fn parse_input_extracts_command() {
264 let stdin = r#"{"tool_name": "Bash", "tool_input": {"command": "ls -la"}}"#;
265 let parsed = CodexHookFormat.parse_input(stdin).unwrap();
266 assert_eq!(parsed.command, "ls -la");
267 }
268
269 #[test]
270 fn parse_input_with_optional_cwd() {
271 let stdin = r#"{"tool_input": {"command": "pwd"}, "cwd": "/Users/me"}"#;
272 let parsed = CodexHookFormat.parse_input(stdin).unwrap();
273 assert_eq!(parsed.cwd.as_deref(), Some("/Users/me"));
274 }
275
276 #[test]
277 fn parse_input_rejects_garbage() {
278 assert!(CodexHookFormat.parse_input("not json").is_err());
279 assert!(CodexHookFormat.parse_input("{}").is_err());
280 }
281
282 #[test]
283 fn render_response_safe_emits_empty_body() {
284 let r = CodexHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
287 assert_eq!(r.stdout, "");
288 let r = CodexHookFormat.render_response(Verdict::Denied);
289 assert_eq!(r.stdout, "");
290 }
291
292 #[test]
293 fn gated_command_is_denied_with_the_supported_shape() {
294 assert_eq!(CodexHookFormat.gated_policy(), super::super::GatedPolicy::Deny);
296 let r = CodexHookFormat.render_deny("blocked: not on the allowlist");
297 let v: Value = serde_json::from_str(&r.stdout).unwrap();
298 assert_eq!(v.pointer("/hookSpecificOutput/permissionDecision").and_then(|d| d.as_str()), Some("deny"));
299 assert_eq!(v.pointer("/hookSpecificOutput/hookEventName").and_then(|d| d.as_str()), Some("PreToolUse"));
300 assert_eq!(
301 v.pointer("/hookSpecificOutput/permissionDecisionReason").and_then(|d| d.as_str()),
302 Some("blocked: not on the allowlist"),
303 );
304 assert_eq!(r.exit_code, 0);
305 }
306
307 #[test]
308 fn render_context_defaults_to_abstain() {
309 let r = CodexHookFormat.render_context("anything");
312 assert_eq!(r.stdout, "");
313 assert_eq!(r.exit_code, 0);
314 }
315}