1use std::path::{Path, PathBuf};
2
3use crate::verdict::{SafetyLevel, Verdict};
4
5pub mod agy;
6pub mod claude;
7pub mod codex;
8pub mod copilot;
9pub mod cursor;
10pub mod droid;
11pub mod gemini;
12pub mod grok;
13pub mod opencode;
14pub mod qwen;
15
16pub trait Target: Send + Sync {
17 fn name(&self) -> &'static str;
18
19 fn shell_tool_name(&self) -> &'static str {
23 "Bash"
24 }
25
26 #[cfg(test)]
38 fn sample_envelope(&self, _tool: &str, _command: &str) -> Option<String> {
39 None
40 }
41
42 fn display_name(&self) -> &'static str;
43
44 fn detect_paths(&self, home: &Path) -> Vec<PathBuf>;
45
46 fn install(&self, home: &Path) -> Result<InstallOutcome, String>;
47
48 fn hook_format(&self) -> Option<&dyn HookFormat> {
49 None
50 }
51}
52
53pub trait HookFormat: Send + Sync {
54 fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError>;
55
56 fn render_response(&self, verdict: Verdict) -> HookResponse;
57
58 fn decision_pointer(&self) -> &'static str;
71
72 fn render_context(&self, _context: &str) -> HookResponse {
80 HookResponse {
81 stdout: String::new(),
82 exit_code: 0,
83 }
84 }
85
86 fn gated_policy(&self) -> GatedPolicy {
92 GatedPolicy::Defer
93 }
94
95 fn render_deny(&self, _reason: &str) -> HookResponse {
99 HookResponse {
100 stdout: String::new(),
101 exit_code: 0,
102 }
103 }
104
105 fn render_ask(&self, _reason: &str) -> HookResponse {
109 HookResponse {
110 stdout: String::new(),
111 exit_code: 0,
112 }
113 }
114}
115
116#[derive(Clone, Copy, PartialEq, Eq, Debug)]
118pub enum GatedPolicy {
119 Defer,
120 Deny,
121 Ask,
122}
123
124#[derive(Debug)]
125pub struct ParseError {
126 pub message: String,
127}
128
129impl std::fmt::Display for ParseError {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 f.write_str(&self.message)
132 }
133}
134
135impl std::error::Error for ParseError {}
136
137pub struct HookInput {
138 pub command: String,
139 pub cwd: Option<String>,
140 pub root: Option<String>,
143 pub session_id: Option<String>,
148}
149
150pub(crate) fn append_hook_entry(
163 settings: &mut serde_json::Value,
164 outer: &str,
165 event: &str,
166 entry: serde_json::Value,
167) -> Result<(), String> {
168 use serde_json::json;
169 if !settings.is_object() {
170 *settings = json!({});
171 }
172 let Some(obj) = settings.as_object_mut() else {
173 unreachable!("settings was just set to an object");
174 };
175 let hooks = obj.entry(outer).or_insert_with(|| json!({}));
176 let Some(hooks) = hooks.as_object_mut() else {
177 return Err(format!(
178 "`{outer}` is {}, expected an object — leaving the file unchanged",
179 json_kind(&obj[outer])
180 ));
181 };
182 let slot = hooks.entry(event).or_insert_with(|| json!([]));
183 if !slot.is_array() {
184 return Err(format!(
185 "`{outer}.{event}` is {}, expected an array — leaving the file unchanged",
186 json_kind(slot)
187 ));
188 }
189 let Some(arr) = slot.as_array_mut() else {
190 unreachable!("just checked it is an array");
191 };
192 arr.push(entry);
193 Ok(())
194}
195
196fn json_kind(v: &serde_json::Value) -> &'static str {
197 match v {
198 serde_json::Value::Null => "null",
199 serde_json::Value::Bool(_) => "a boolean",
200 serde_json::Value::Number(_) => "a number",
201 serde_json::Value::String(_) => "a string",
202 serde_json::Value::Array(_) => "an array",
203 serde_json::Value::Object(_) => "an object",
204 }
205}
206
207pub(crate) fn env_root(var: &str) -> Option<String> {
210 std::env::var(var).ok().filter(|s| !s.is_empty())
211}
212
213pub struct HookResponse {
214 pub stdout: String,
215 pub exit_code: i32,
216}
217
218pub enum InstallOutcome {
219 Installed { path: PathBuf },
220 AlreadyConfigured { path: PathBuf },
221 Skipped { reason: String },
222}
223
224impl InstallOutcome {
225 pub fn message(&self, target_display: &str) -> String {
226 match self {
227 InstallOutcome::Installed { path } => {
228 format!("{target_display}: installed → {}", path.display())
229 }
230 InstallOutcome::AlreadyConfigured { path } => {
231 format!("{target_display}: already configured at {}", path.display())
232 }
233 InstallOutcome::Skipped { reason } => {
234 format!("{target_display}: skipped — {reason}")
235 }
236 }
237 }
238}
239
240pub fn registry() -> Vec<Box<dyn Target>> {
241 vec![
242 Box::new(claude::ClaudeTarget),
243 Box::new(codex::CodexTarget),
244 Box::new(agy::AntigravityTarget),
245 Box::new(cursor::CursorTarget),
246 Box::new(gemini::GeminiTarget),
247 Box::new(grok::GrokTarget),
248 Box::new(copilot::CopilotTarget),
249 Box::new(qwen::QwenTarget),
250 Box::new(droid::DroidTarget),
251 Box::new(opencode::OpenCodeTarget),
252 ]
253}
254
255pub fn find(name: &str) -> Option<Box<dyn Target>> {
256 registry().into_iter().find(|t| t.name() == name)
257}
258
259pub fn detect_installed(home: &Path) -> Vec<Box<dyn Target>> {
260 registry()
261 .into_iter()
262 .filter(|t| t.detect_paths(home).iter().any(|p| p.exists()))
263 .collect()
264}
265
266pub fn allow_reason(verdict: Verdict) -> &'static str {
267 match verdict {
268 Verdict::Allowed(SafetyLevel::SafeWrite) => {
269 "All commands in chain are safe utilities (includes file writes)"
270 }
271 Verdict::Allowed(SafetyLevel::SafeRead) => {
272 "All commands in chain are safe utilities (includes code execution)"
273 }
274 _ => "All commands in chain are safe utilities",
275 }
276}
277
278#[cfg(test)]
279mod append_hook_entry_tests {
280 use super::*;
281 use serde_json::json;
282
283 #[test]
284 fn creates_the_path_when_absent() {
285 let mut s = json!({});
286 append_hook_entry(&mut s, "hooks", "PreToolUse", json!({"matcher": "Bash"})).unwrap();
287 assert_eq!(s["hooks"]["PreToolUse"][0]["matcher"], "Bash");
288 }
289
290 #[test]
291 fn appends_beside_an_existing_entry() {
292 let mut s = json!({"hooks": {"PreToolUse": [{"matcher": "Other"}]}});
293 append_hook_entry(&mut s, "hooks", "PreToolUse", json!({"matcher": "Bash"})).unwrap();
294 let arr = s["hooks"]["PreToolUse"].as_array().unwrap();
295 assert_eq!(arr.len(), 2, "the user's existing hook must survive");
296 assert_eq!(arr[0]["matcher"], "Other");
297 }
298
299 #[test]
303 fn refuses_a_wrong_typed_outer_key_without_panicking() {
304 for wrong in [json!("a string"), json!([1, 2]), json!(7), json!(null)] {
305 let mut s = json!({ "hooks": wrong });
306 let before = s.clone();
307 let err = append_hook_entry(&mut s, "hooks", "PreToolUse", json!({})).unwrap_err();
308 assert!(err.contains("expected an object"), "unhelpful error: {err}");
309 assert_eq!(s, before, "the user's value must be left alone, not replaced");
310 }
311 }
312
313 #[test]
314 fn refuses_a_wrong_typed_event_key_without_panicking() {
315 let mut s = json!({"hooks": {"PreToolUse": "a string"}});
316 let before = s.clone();
317 let err = append_hook_entry(&mut s, "hooks", "PreToolUse", json!({})).unwrap_err();
318 assert!(err.contains("expected an array"), "unhelpful error: {err}");
319 assert_eq!(s, before, "the user's value must be left alone, not replaced");
320 }
321
322 #[test]
323 fn replaces_a_non_object_root() {
324 let mut s = json!("garbage");
326 append_hook_entry(&mut s, "hooks", "PreToolUse", json!({"matcher": "Bash"})).unwrap();
327 assert_eq!(s["hooks"]["PreToolUse"][0]["matcher"], "Bash");
328 }
329}
330
331#[cfg(test)]
332mod tool_filter_tests {
333 use super::*;
334
335 #[test]
349 fn no_target_decides_on_a_foreign_tool() {
350 let mut failures = Vec::new();
351 let mut checked = 0usize;
352 for target in registry() {
353 let Some(fmt) = target.hook_format() else { continue };
354 let Some(shell) = target.sample_envelope(target.shell_tool_name(), "ls") else {
355 continue; };
357 let name = target.name();
358 if let Err(e) = fmt.parse_input(&shell) {
361 failures.push(format!(
362 "{name}: rejected its own shell tool `{}`: {}",
363 target.shell_tool_name(),
364 e.message
365 ));
366 }
367 for foreign in ["Read", "Write", "Edit", "WebFetch"] {
368 let Some(env) = target.sample_envelope(foreign, "rm -rf /") else { continue };
369 checked += 1;
370 if fmt.parse_input(&env).is_ok() {
371 failures.push(format!(
372 "{name}: parsed a `{foreign}` envelope instead of abstaining"
373 ));
374 }
375 }
376 }
377 assert!(checked > 0, "no target was probed — the guard is vacuous");
378 assert!(failures.is_empty(), "foreign-tool decisions:\n{}", failures.join("\n"));
379 }
380}