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