safe_chains/targets/mod.rs
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 /// The harness's SHELL tool — the only tool this hook should decide about. Droid's is
20 /// `Execute`, Gemini's `run_shell_command`; most are `Bash`. Defaults to `Bash`, the common
21 /// case.
22 fn shell_tool_name(&self) -> &'static str {
23 "Bash"
24 }
25
26 /// A sample envelope this target's `parse_input` accepts, naming `tool`, or `None` when the
27 /// harness's envelope carries NO tool identifier.
28 ///
29 /// `None` is a researched claim, not a default: it says the envelope has no field naming the
30 /// tool, so the hook cannot tell a shell call from any other and must rely on its configured
31 /// matcher alone. `Some` obliges the target to abstain on a foreign tool —
32 /// `no_target_decides_on_a_foreign_tool` holds it to that in both directions.
33 ///
34 /// Test-only; each target knows its own envelope shape, and a generic one cannot stand in for
35 /// nine different schemas (Copilot nests `toolArgs` as a JSON STRING, Antigravity uses
36 /// `toolCall.args.commandLine`, Grok is camelCase).
37 #[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 /// The JSON pointer this harness reads its decision from.
59 ///
60 /// Deliberately has NO default: getting the field wrong fails SILENTLY — the harness ignores
61 /// the unknown key and falls back to its own permissions, so a mis-wired target still lets
62 /// commands run and looks like it works while never deciding anything. Requiring the
63 /// declaration means a new target cannot be added without stating its contract, and
64 /// `every_target_emits_its_decision_at_the_declared_field` checks every emission against it —
65 /// including that the decision does NOT appear at another harness's pointer, which is what a
66 /// copy-pasted target looks like.
67 ///
68 /// Note the leaf name alone is not the contract: Claude nests
69 /// `/hookSpecificOutput/permissionDecision` while Copilot uses a flat `/permissionDecision`.
70 fn decision_pointer(&self) -> &'static str;
71
72 /// Surface explanatory context to the model on a non-approval *without*
73 /// changing the permission decision (the command still flows through the
74 /// tool's normal approval path, and the user's own allowlist still applies).
75 ///
76 /// The default abstains silently — same as today's empty deny body. A target
77 /// overrides this only when its hook schema has a verified field for
78 /// injecting model-visible context without a permission decision.
79 fn render_context(&self, _context: &str) -> HookResponse {
80 HookResponse {
81 stdout: String::new(),
82 exit_code: 0,
83 }
84 }
85
86 /// How this harness's hook must handle a GATED command (one safe-chains does not auto-approve),
87 /// derived from its capabilities (`docs/design/harness-capability-model.md`):
88 /// - `Defer` — stay silent; the harness's own per-command human review is the check (Claude).
89 /// - `Deny` — veto it; the harness has no human review and no escalate (Codex).
90 /// - `Ask` — escalate to an in-the-moment human prompt (Antigravity's `ask`).
91 fn gated_policy(&self) -> GatedPolicy {
92 GatedPolicy::Defer
93 }
94
95 /// The hook output that VETOES a gated command, for a `Deny` harness. Default abstains (so a
96 /// stray call can't fail open). The shape must be exactly what the harness supports, or a
97 /// harness that "continues on malformed output" (e.g. Codex) fails open.
98 fn render_deny(&self, _reason: &str) -> HookResponse {
99 HookResponse {
100 stdout: String::new(),
101 exit_code: 0,
102 }
103 }
104
105 /// The hook output that ESCALATES a gated command to a human prompt, for an `Ask` harness.
106 /// Default abstains. (Antigravity fails CLOSED on a malformed/absent decision, so an Ask target
107 /// must always emit a valid decision.)
108 fn render_ask(&self, _reason: &str) -> HookResponse {
109 HookResponse {
110 stdout: String::new(),
111 exit_code: 0,
112 }
113 }
114}
115
116/// How a harness's hook handles a gated command — see `HookFormat::gated_policy`.
117#[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 /// The project root, when the harness supplies one (HP-19) — a `*_PROJECT_DIR` env var
141 /// for most, `workspace_roots` in the payload for cursor. Absent for codex/copilot.
142 pub root: Option<String>,
143 /// The harness's session/conversation id, when it supplies one (`session_id` for
144 /// Claude/Gemini/Qwen/Droid, `sessionId` for grok, `conversation_id` for cursor). It comes from
145 /// the harness's own envelope, so the agent cannot forge it — which is what makes it usable as
146 /// the anchor for recognizing the session's scratchpad (see `pathctx::session_scratchpad`).
147 pub session_id: Option<String>,
148}
149
150/// May a GRANT be emitted for this command?
151///
152/// A blank command classifies as `Allowed(Inert)` — an empty script really is inert — but rendering
153/// that as `permissionDecision: "allow"` asserts "every command in this chain is safe" about ZERO
154/// commands, and on the harnesses whose allow is authoritative it replaces the user's prompt.
155///
156/// The check lives HERE, next to the decision contract, rather than in the binary. It was in
157/// `main.rs` first: the shipped hook was safe, but `render_response` is public and knew nothing
158/// about blankness, so any second caller reintroduced the bug — and the integration guard passed
159/// only because it drives the binary. The `hook_envelope` fuzz target found exactly that by calling
160/// the format directly.
161pub fn may_grant(command: &str, verdict: crate::Verdict) -> bool {
162 verdict.is_allowed() && !command.trim().is_empty()
163}
164
165/// The decision for one parsed envelope: the response to emit, or `None` to abstain.
166///
167/// The single seam every caller goes through, so the blank-command rule cannot be bypassed by
168/// reaching for `render_response` directly.
169pub fn respond(format: &dyn HookFormat, command: &str, verdict: crate::Verdict) -> Option<HookResponse> {
170 may_grant(command, verdict).then(|| format.render_response(verdict))
171}
172
173/// Append `entry` to `settings[outer][event]`, creating the path when absent.
174///
175/// Refuses — rather than overwriting — when an existing key has the wrong TYPE. Four targets wrote
176/// this by hand as `entry(k).or_insert_with(…).as_object_mut().expect("created above as an
177/// object")`, and the message says why it looked safe: it reads as if the key had just been
178/// created. `or_insert_with` returns the EXISTING value, so a settings file carrying
179/// `"hooks": "something"` made `--setup` PANIC — and under `--auto-detect` that aborts the whole
180/// run, so every target after it goes uninstalled.
181///
182/// Erroring beats replacing. The value is the user's, an unreadable one usually means a
183/// hand-edit or a schema we don't know, and silently rewriting config we did not understand is
184/// not ours to do. `install` writes only on `Ok`, so the file is left untouched either way.
185pub(crate) fn append_hook_entry(
186 settings: &mut serde_json::Value,
187 outer: &str,
188 event: &str,
189 entry: serde_json::Value,
190) -> Result<(), String> {
191 use serde_json::json;
192 if !settings.is_object() {
193 *settings = json!({});
194 }
195 let Some(obj) = settings.as_object_mut() else {
196 unreachable!("settings was just set to an object");
197 };
198 let hooks = obj.entry(outer).or_insert_with(|| json!({}));
199 let Some(hooks) = hooks.as_object_mut() else {
200 return Err(format!(
201 "`{outer}` is {}, expected an object — leaving the file unchanged",
202 json_kind(&obj[outer])
203 ));
204 };
205 let slot = hooks.entry(event).or_insert_with(|| json!([]));
206 if !slot.is_array() {
207 return Err(format!(
208 "`{outer}.{event}` is {}, expected an array — leaving the file unchanged",
209 json_kind(slot)
210 ));
211 }
212 let Some(arr) = slot.as_array_mut() else {
213 unreachable!("just checked it is an array");
214 };
215 arr.push(entry);
216 Ok(())
217}
218
219fn json_kind(v: &serde_json::Value) -> &'static str {
220 match v {
221 serde_json::Value::Null => "null",
222 serde_json::Value::Bool(_) => "a boolean",
223 serde_json::Value::Number(_) => "a number",
224 serde_json::Value::String(_) => "a string",
225 serde_json::Value::Array(_) => "an array",
226 serde_json::Value::Object(_) => "an object",
227 }
228}
229
230/// Read a harness project-root env var from the hook process environment (set by the
231/// harness, not the agent's shell — see HARNESS-BEHAVIORS.md). Empty → `None`.
232pub(crate) fn env_root(var: &str) -> Option<String> {
233 std::env::var(var).ok().filter(|s| !s.is_empty())
234}
235
236pub struct HookResponse {
237 pub stdout: String,
238 pub exit_code: i32,
239}
240
241pub enum InstallOutcome {
242 Installed { path: PathBuf },
243 AlreadyConfigured { path: PathBuf },
244 Skipped { reason: String },
245}
246
247impl InstallOutcome {
248 pub fn message(&self, target_display: &str) -> String {
249 match self {
250 InstallOutcome::Installed { path } => {
251 format!("{target_display}: installed → {}", path.display())
252 }
253 InstallOutcome::AlreadyConfigured { path } => {
254 format!("{target_display}: already configured at {}", path.display())
255 }
256 InstallOutcome::Skipped { reason } => {
257 format!("{target_display}: skipped — {reason}")
258 }
259 }
260 }
261}
262
263pub fn registry() -> Vec<Box<dyn Target>> {
264 vec![
265 Box::new(claude::ClaudeTarget),
266 Box::new(codex::CodexTarget),
267 Box::new(agy::AntigravityTarget),
268 Box::new(cursor::CursorTarget),
269 Box::new(gemini::GeminiTarget),
270 Box::new(grok::GrokTarget),
271 Box::new(copilot::CopilotTarget),
272 Box::new(qwen::QwenTarget),
273 Box::new(droid::DroidTarget),
274 Box::new(opencode::OpenCodeTarget),
275 ]
276}
277
278pub fn find(name: &str) -> Option<Box<dyn Target>> {
279 registry().into_iter().find(|t| t.name() == name)
280}
281
282pub fn detect_installed(home: &Path) -> Vec<Box<dyn Target>> {
283 registry()
284 .into_iter()
285 .filter(|t| t.detect_paths(home).iter().any(|p| p.exists()))
286 .collect()
287}
288
289pub fn allow_reason(verdict: Verdict) -> &'static str {
290 match verdict {
291 Verdict::Allowed(SafetyLevel::SafeWrite) => {
292 "All commands in chain are safe utilities (includes file writes)"
293 }
294 Verdict::Allowed(SafetyLevel::SafeRead) => {
295 "All commands in chain are safe utilities (includes code execution)"
296 }
297 _ => "All commands in chain are safe utilities",
298 }
299}
300
301#[cfg(test)]
302mod append_hook_entry_tests {
303 use super::*;
304 use serde_json::json;
305
306 #[test]
307 fn creates_the_path_when_absent() {
308 let mut s = json!({});
309 append_hook_entry(&mut s, "hooks", "PreToolUse", json!({"matcher": "Bash"})).unwrap();
310 assert_eq!(s["hooks"]["PreToolUse"][0]["matcher"], "Bash");
311 }
312
313 #[test]
314 fn appends_beside_an_existing_entry() {
315 let mut s = json!({"hooks": {"PreToolUse": [{"matcher": "Other"}]}});
316 append_hook_entry(&mut s, "hooks", "PreToolUse", json!({"matcher": "Bash"})).unwrap();
317 let arr = s["hooks"]["PreToolUse"].as_array().unwrap();
318 assert_eq!(arr.len(), 2, "the user's existing hook must survive");
319 assert_eq!(arr[0]["matcher"], "Other");
320 }
321
322 /// The panic this replaced: `entry(k).or_insert_with(…).as_object_mut().expect(…)` reads as if
323 /// the key was just created, but `or_insert_with` returns the EXISTING value. A settings file
324 /// with `"hooks": "x"` crashed `--setup` — and under `--auto-detect` that aborted the whole run.
325 #[test]
326 fn refuses_a_wrong_typed_outer_key_without_panicking() {
327 for wrong in [json!("a string"), json!([1, 2]), json!(7), json!(null)] {
328 let mut s = json!({ "hooks": wrong });
329 let before = s.clone();
330 let err = append_hook_entry(&mut s, "hooks", "PreToolUse", json!({})).unwrap_err();
331 assert!(err.contains("expected an object"), "unhelpful error: {err}");
332 assert_eq!(s, before, "the user's value must be left alone, not replaced");
333 }
334 }
335
336 #[test]
337 fn refuses_a_wrong_typed_event_key_without_panicking() {
338 let mut s = json!({"hooks": {"PreToolUse": "a string"}});
339 let before = s.clone();
340 let err = append_hook_entry(&mut s, "hooks", "PreToolUse", json!({})).unwrap_err();
341 assert!(err.contains("expected an array"), "unhelpful error: {err}");
342 assert_eq!(s, before, "the user's value must be left alone, not replaced");
343 }
344
345 #[test]
346 fn replaces_a_non_object_root() {
347 // A file whose ROOT is not an object carries nothing to preserve.
348 let mut s = json!("garbage");
349 append_hook_entry(&mut s, "hooks", "PreToolUse", json!({"matcher": "Bash"})).unwrap();
350 assert_eq!(s["hooks"]["PreToolUse"][0]["matcher"], "Bash");
351 }
352}
353
354#[cfg(test)]
355mod tool_filter_tests {
356 use super::*;
357
358 /// No target decides on a tool that is not its shell tool.
359 ///
360 /// The hook is wired with a matcher (`Bash`, `Execute`, `run_shell_command`), so normally only
361 /// shell calls arrive. But a matcher is configuration: it can be hand-edited, and grok is
362 /// documented to auto-load `~/.claude/settings.json`, which hands Claude's hook a foreign
363 /// envelope. Deciding on a `Read`/`Write`/`Edit` call grants or vetoes a tool whose semantics
364 /// were never analysed — and for the ALLOW-capable targets that is a grant, issued on the
365 /// strength of a `command` field the tool does not even have. Four targets did exactly that.
366 ///
367 /// Driven by each target's OWN `sample_envelope`, because nine harnesses have nine schemas and
368 /// a generic probe silently fails to parse (which looks like a pass). A target whose envelope
369 /// carries no tool identifier returns `None` and is exempt — a researched claim, recorded per
370 /// target, not a default.
371 #[test]
372 fn no_target_decides_on_a_foreign_tool() {
373 let mut failures = Vec::new();
374 let mut checked = 0usize;
375 for target in registry() {
376 let Some(fmt) = target.hook_format() else { continue };
377 let Some(shell) = target.sample_envelope(target.shell_tool_name(), "ls") else {
378 continue; // envelope carries no tool identifier — cannot self-filter
379 };
380 let name = target.name();
381 // The shell tool must still parse, or "reject everything" would satisfy the negative
382 // half and look like a working filter.
383 if let Err(e) = fmt.parse_input(&shell) {
384 failures.push(format!(
385 "{name}: rejected its own shell tool `{}`: {}",
386 target.shell_tool_name(),
387 e.message
388 ));
389 }
390 for foreign in ["Read", "Write", "Edit", "WebFetch"] {
391 let Some(env) = target.sample_envelope(foreign, "rm -rf /") else { continue };
392 checked += 1;
393 if fmt.parse_input(&env).is_ok() {
394 failures.push(format!(
395 "{name}: parsed a `{foreign}` envelope instead of abstaining"
396 ));
397 }
398 }
399 }
400 assert!(checked > 0, "no target was probed — the guard is vacuous");
401 assert!(failures.is_empty(), "foreign-tool decisions:\n{}", failures.join("\n"));
402 }
403}