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 /// The format that answers THIS envelope. Most harnesses send one hook event, so it is
53 /// `hook_format`. Codex sends `PreToolUse` and `PermissionRequest` to the same command and
54 /// expects a different answer to each, so it picks by the event the envelope names.
55 fn hook_format_for(&self, _stdin: &str) -> Option<&dyn HookFormat> {
56 self.hook_format()
57 }
58
59 /// Every format `hook_format_for` can return, so the contract guards that need no envelope walk
60 /// all of them and a second event's format cannot ship unchecked. The foreign-tool guard is
61 /// driven by `sample_envelope` and reaches only the format that envelope routes to; Codex's
62 /// `PermissionRequest` tool filter has its own test in `codex.rs`.
63 fn hook_formats(&self) -> Vec<&dyn HookFormat> {
64 self.hook_format().into_iter().collect()
65 }
66}
67
68pub trait HookFormat: Send + Sync {
69 fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError>;
70
71 fn render_response(&self, verdict: Verdict) -> HookResponse;
72
73 /// The JSON pointer this harness reads its decision from.
74 ///
75 /// Deliberately has NO default: getting the field wrong fails SILENTLY — the harness ignores
76 /// the unknown key and falls back to its own permissions, so a mis-wired target still lets
77 /// commands run and looks like it works while never deciding anything. Requiring the
78 /// declaration means a new target cannot be added without stating its contract, and
79 /// `every_target_emits_its_decision_at_the_declared_field` checks every emission against it —
80 /// including that the decision does NOT appear at another harness's pointer, which is what a
81 /// copy-pasted target looks like.
82 ///
83 /// Note the leaf name alone is not the contract: Claude nests
84 /// `/hookSpecificOutput/permissionDecision` while Copilot uses a flat `/permissionDecision`.
85 fn decision_pointer(&self) -> &'static str;
86
87 /// Surface explanatory context to the model on a non-approval *without*
88 /// changing the permission decision (the command still flows through the
89 /// tool's normal approval path, and the user's own allowlist still applies).
90 ///
91 /// The default abstains silently — same as today's empty deny body. A target
92 /// overrides this only when its hook schema has a verified field for
93 /// injecting model-visible context without a permission decision.
94 fn render_context(&self, _context: &str) -> HookResponse {
95 HookResponse { stdout: String::new(), exit_code: 0 }
96 }
97
98 /// How this harness's hook must handle a GATED command (one safe-chains does not auto-approve),
99 /// derived from its capabilities (`docs/design/harness-capability-model.md`):
100 /// - `Defer` — stay silent; the harness's own per-command human review is the check (Claude).
101 /// - `Deny` — veto it; the harness has no human review and no escalate (Codex).
102 /// - `Ask` — escalate to an in-the-moment human prompt (Antigravity's `ask`).
103 fn gated_policy(&self) -> GatedPolicy {
104 GatedPolicy::Defer
105 }
106
107 /// The hook output that VETOES a gated command, for a `Deny` harness. Default abstains (so a
108 /// stray call can't fail open). The shape must be exactly what the harness supports, or a
109 /// harness that "continues on malformed output" (e.g. Codex) fails open.
110 fn render_deny(&self, _reason: &str) -> HookResponse {
111 HookResponse { stdout: String::new(), exit_code: 0 }
112 }
113
114 /// The hook output that ESCALATES a gated command to a human prompt, for an `Ask` harness.
115 /// Default abstains. (Antigravity fails CLOSED on a malformed/absent decision, so an Ask target
116 /// must always emit a valid decision.)
117 fn render_ask(&self, _reason: &str) -> HookResponse {
118 HookResponse { stdout: String::new(), exit_code: 0 }
119 }
120
121 /// Whether the envelope's `cwd` is the directory the command runs in. `false` when the harness
122 /// can run the command somewhere else without saying where (Codex's `exec_command` takes a
123 /// `workdir` that its `PermissionRequest` payload leaves out), so `evaluation_dirs` refuses to
124 /// resolve a relative path against it.
125 fn cwd_is_the_commands(&self) -> bool {
126 true
127 }
128}
129
130/// The working directory nothing can be inside: a relative path resolved against it lands outside
131/// every workspace, so a command whose safety rests on where its relative paths point is not
132/// approved, while one that names no such path classifies as it would anywhere.
133pub const UNKNOWN_WORKDIR: &str = "/nonexistent/safe-chains-unknown-workdir";
134
135/// The (cwd, root) a command is classified under. Most harnesses send no distinct project root, so
136/// the root defaults to the cwd, which engages the workspace boundary with the one directory known.
137/// Where the cwd is not the command's own (`HookFormat::cwd_is_the_commands`), the reported cwd
138/// stays the workspace root and the command is placed at `UNKNOWN_WORKDIR`.
139pub fn evaluation_dirs(format: &dyn HookFormat, input: &HookInput) -> (Option<String>, Option<String>) {
140 let root = input.root.clone().or_else(|| input.cwd.clone());
141 if format.cwd_is_the_commands() { (input.cwd.clone(), root) } else { (Some(UNKNOWN_WORKDIR.to_string()), root) }
142}
143
144/// How a harness's hook handles a gated command — see `HookFormat::gated_policy`.
145#[derive(Clone, Copy, PartialEq, Eq, Debug)]
146pub enum GatedPolicy {
147 Defer,
148 Deny,
149 Ask,
150}
151
152#[derive(Debug)]
153pub struct ParseError {
154 pub message: String,
155}
156
157impl std::fmt::Display for ParseError {
158 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159 f.write_str(&self.message)
160 }
161}
162
163impl std::error::Error for ParseError {}
164
165pub struct HookInput {
166 pub command: String,
167 pub cwd: Option<String>,
168 /// The project root, when the harness supplies one (HP-19) — a `*_PROJECT_DIR` env var
169 /// for most, `workspace_roots` in the payload for cursor. Absent for codex/copilot.
170 pub root: Option<String>,
171 /// The harness's session/conversation id, when it supplies one (`session_id` for
172 /// Claude/Gemini/Qwen/Droid, `sessionId` for grok, `conversation_id` for cursor). It comes from
173 /// the harness's own envelope, so the agent cannot forge it — which is what makes it usable as
174 /// the anchor for recognizing the session's scratchpad (see `pathctx::session_scratchpad`).
175 pub session_id: Option<String>,
176}
177
178/// May a GRANT be emitted for this command?
179///
180/// A blank command classifies as `Allowed(Inert)` — an empty script really is inert — but rendering
181/// that as `permissionDecision: "allow"` asserts "every command in this chain is safe" about ZERO
182/// commands, and on the harnesses whose allow is authoritative it replaces the user's prompt.
183///
184/// The check lives HERE, next to the decision contract, rather than in the binary. It was in
185/// `main.rs` first: the shipped hook was safe, but `render_response` is public and knew nothing
186/// about blankness, so any second caller reintroduced the bug — and the integration guard passed
187/// only because it drives the binary. The `hook_envelope` fuzz target found exactly that by calling
188/// the format directly.
189pub fn may_grant(command: &str, verdict: crate::Verdict) -> bool {
190 verdict.is_allowed() && !command.trim().is_empty()
191}
192
193/// The decision for one parsed envelope: the response to emit, or `None` to abstain.
194///
195/// The single seam every caller goes through, so the blank-command rule cannot be bypassed by
196/// reaching for `render_response` directly.
197pub fn respond(format: &dyn HookFormat, command: &str, verdict: crate::Verdict) -> Option<HookResponse> {
198 (may_grant(command, verdict) && within_unknown_workdir_ceiling(format, verdict)).then(|| format.render_response(verdict))
199}
200
201/// Where the command may run in a directory the hook was not told (`cwd_is_the_commands` is
202/// false), a write is granted only when the unknown-folder mode judged it.
203///
204/// `UNKNOWN_WORKDIR` alone catches a write that NAMES a relative path, but `git commit -am x`,
205/// `cargo fmt` or `cargo build` write into whatever directory they run in without naming one, and
206/// classify the same wherever that is. At a folder level above `reads` (`pathctx::folder`), each
207/// relative path was placed or left out by its anchor value and each such write was refused unless
208/// it is accounted for, so the verdict already says what the level allows. Without that, only a
209/// read is granted, which is the `reads` level.
210fn within_unknown_workdir_ceiling(format: &dyn HookFormat, verdict: crate::Verdict) -> bool {
211 format.cwd_is_the_commands()
212 || crate::pathctx::folder::judges_writes()
213 || matches!(verdict, Verdict::Allowed(level) if level <= SafetyLevel::SafeRead)
214}
215
216/// The folder level a hook judges writes at when `format` does not report the command's folder:
217/// the `--unknown-folder` flag, else the user config, else `developer`. `None` when the folder is
218/// known, where the dial does not apply.
219pub fn unknown_folder_level(
220 format: &dyn HookFormat,
221 flag: Option<crate::pathctx::anchor::FolderLevel>,
222) -> Option<crate::pathctx::anchor::FolderLevel> {
223 (!format.cwd_is_the_commands()).then(|| flag.unwrap_or_else(|| crate::registry::folder_config::user_setting().level()))
224}
225
226/// Append `entry` to `settings[outer][event]`, creating the path when absent.
227///
228/// Refuses — rather than overwriting — when an existing key has the wrong TYPE. Four targets wrote
229/// this by hand as `entry(k).or_insert_with(…).as_object_mut().expect("created above as an
230/// object")`, and the message says why it looked safe: it reads as if the key had just been
231/// created. `or_insert_with` returns the EXISTING value, so a settings file carrying
232/// `"hooks": "something"` made `--setup` PANIC — and under `--auto-detect` that aborts the whole
233/// run, so every target after it goes uninstalled.
234///
235/// Erroring beats replacing. The value is the user's, an unreadable one usually means a
236/// hand-edit or a schema we don't know, and silently rewriting config we did not understand is
237/// not ours to do. `install` writes only on `Ok`, so the file is left untouched either way.
238pub(crate) fn append_hook_entry(
239 settings: &mut serde_json::Value,
240 outer: &str,
241 event: &str,
242 entry: serde_json::Value,
243) -> Result<(), String> {
244 use serde_json::json;
245 // Refuse a non-object ROOT rather than replace it, for the same reason a wrong-typed inner key
246 // is refused below: an unreadable value is usually a hand-edit or a schema we do not know, and
247 // rewriting config we did not understand is not ours to do.
248 //
249 // This only ever fires on a file that EXISTS and parses to something that is not an object
250 // (`[1,2,3]`, `"a string"`, `42`). Every caller turns a MISSING file into an empty object
251 // before reaching here, so refusing cannot break a first-time `--setup`.
252 if !settings.is_object() {
253 return Err(format!("the settings file is {}, expected an object. Leaving the file unchanged.", json_kind(settings)));
254 }
255 let Some(obj) = settings.as_object_mut() else {
256 unreachable!("just checked it is an object");
257 };
258 let hooks = obj.entry(outer).or_insert_with(|| json!({}));
259 let Some(hooks) = hooks.as_object_mut() else {
260 return Err(format!("`{outer}` is {}, expected an object. Leaving the file unchanged.", json_kind(&obj[outer])));
261 };
262 let slot = hooks.entry(event).or_insert_with(|| json!([]));
263 if !slot.is_array() {
264 return Err(format!("`{outer}.{event}` is {}, expected an array. Leaving the file unchanged.", json_kind(slot)));
265 }
266 let Some(arr) = slot.as_array_mut() else {
267 unreachable!("just checked it is an array");
268 };
269 arr.push(entry);
270 Ok(())
271}
272
273fn json_kind(v: &serde_json::Value) -> &'static str {
274 match v {
275 serde_json::Value::Null => "null",
276 serde_json::Value::Bool(_) => "a boolean",
277 serde_json::Value::Number(_) => "a number",
278 serde_json::Value::String(_) => "a string",
279 serde_json::Value::Array(_) => "an array",
280 serde_json::Value::Object(_) => "an object",
281 }
282}
283
284/// Read a harness project-root env var from the hook process environment (set by the
285/// harness, not the agent's shell — see HARNESS-BEHAVIORS.md). Empty → `None`.
286pub(crate) fn env_root(var: &str) -> Option<String> {
287 std::env::var(var).ok().filter(|s| !s.is_empty())
288}
289
290pub struct HookResponse {
291 pub stdout: String,
292 pub exit_code: i32,
293}
294
295pub enum InstallOutcome {
296 Installed { path: PathBuf },
297 AlreadyConfigured { path: PathBuf },
298 Skipped { reason: String },
299}
300
301impl InstallOutcome {
302 pub fn message(&self, target_display: &str) -> String {
303 match self {
304 InstallOutcome::Installed { path } => {
305 format!("{target_display}: installed → {}", path.display())
306 }
307 InstallOutcome::AlreadyConfigured { path } => {
308 format!("{target_display}: already configured at {}", path.display())
309 }
310 InstallOutcome::Skipped { reason } => {
311 format!("{target_display}: skipped, {reason}")
312 }
313 }
314 }
315}
316
317pub fn registry() -> Vec<Box<dyn Target>> {
318 vec![
319 Box::new(claude::ClaudeTarget),
320 Box::new(codex::CodexTarget),
321 Box::new(agy::AntigravityTarget),
322 Box::new(cursor::CursorTarget),
323 Box::new(gemini::GeminiTarget),
324 Box::new(grok::GrokTarget),
325 Box::new(copilot::CopilotTarget),
326 Box::new(qwen::QwenTarget),
327 Box::new(droid::DroidTarget),
328 Box::new(opencode::OpenCodeTarget),
329 ]
330}
331
332pub fn find(name: &str) -> Option<Box<dyn Target>> {
333 registry().into_iter().find(|t| t.name() == name)
334}
335
336pub fn detect_installed(home: &Path) -> Vec<Box<dyn Target>> {
337 registry().into_iter().filter(|t| t.detect_paths(home).iter().any(|p| p.exists())).collect()
338}
339
340pub fn allow_reason(verdict: Verdict) -> &'static str {
341 match verdict {
342 Verdict::Allowed(SafetyLevel::SafeWrite) => "All commands in chain are safe utilities (includes file writes)",
343 Verdict::Allowed(SafetyLevel::SafeRead) => "All commands in chain are safe utilities (includes code execution)",
344 _ => "All commands in chain are safe utilities",
345 }
346}
347
348#[cfg(test)]
349mod append_hook_entry_tests {
350 use super::*;
351 use serde_json::json;
352
353 #[test]
354 fn creates_the_path_when_absent() {
355 let mut s = json!({});
356 append_hook_entry(&mut s, "hooks", "PreToolUse", json!({"matcher": "Bash"})).unwrap();
357 assert_eq!(s["hooks"]["PreToolUse"][0]["matcher"], "Bash");
358 }
359
360 #[test]
361 fn appends_beside_an_existing_entry() {
362 let mut s = json!({"hooks": {"PreToolUse": [{"matcher": "Other"}]}});
363 append_hook_entry(&mut s, "hooks", "PreToolUse", json!({"matcher": "Bash"})).unwrap();
364 let arr = s["hooks"]["PreToolUse"].as_array().unwrap();
365 assert_eq!(arr.len(), 2, "the user's existing hook must survive");
366 assert_eq!(arr[0]["matcher"], "Other");
367 }
368
369 /// The panic this replaced: `entry(k).or_insert_with(…).as_object_mut().expect(…)` reads as if
370 /// the key was just created, but `or_insert_with` returns the EXISTING value. A settings file
371 /// with `"hooks": "x"` crashed `--setup` — and under `--auto-detect` that aborted the whole run.
372 #[test]
373 fn refuses_a_wrong_typed_outer_key_without_panicking() {
374 for wrong in [json!("a string"), json!([1, 2]), json!(7), json!(null)] {
375 let mut s = json!({ "hooks": wrong });
376 let before = s.clone();
377 let err = append_hook_entry(&mut s, "hooks", "PreToolUse", json!({})).unwrap_err();
378 assert!(err.contains("expected an object"), "unhelpful error: {err}");
379 assert_eq!(s, before, "the user's value must be left alone, not replaced");
380 }
381 }
382
383 #[test]
384 fn refuses_a_wrong_typed_event_key_without_panicking() {
385 let mut s = json!({"hooks": {"PreToolUse": "a string"}});
386 let before = s.clone();
387 let err = append_hook_entry(&mut s, "hooks", "PreToolUse", json!({})).unwrap_err();
388 assert!(err.contains("expected an array"), "unhelpful error: {err}");
389 assert_eq!(s, before, "the user's value must be left alone, not replaced");
390 }
391
392 /// A non-object ROOT is refused, not replaced.
393 ///
394 /// This test previously asserted the opposite, on the reasoning that "a file whose ROOT is not
395 /// an object carries nothing to preserve". That is the same argument this module already
396 /// rejected one level in, where a wrong-typed `hooks` value is refused because an unreadable
397 /// value usually means a hand-edit or a schema we do not know. A root we cannot read is not
398 /// more disposable than a key we cannot read — it is less, since it is the whole file.
399 ///
400 /// Refusing is safe for a first-time `--setup`: every caller turns a MISSING file into an empty
401 /// object before reaching here, so this fires only for a file that exists and parses to a
402 /// non-object.
403 #[test]
404 fn refuses_a_non_object_root_without_replacing_it() {
405 for root in [json!("garbage"), json!([1, 2, 3]), json!(42), json!(null)] {
406 let mut s = root.clone();
407 let err = append_hook_entry(&mut s, "hooks", "PreToolUse", json!({"matcher": "Bash"}))
408 .expect_err("a non-object root must be refused");
409 assert!(err.contains("expected an object"), "unhelpful error: {err}");
410 assert_eq!(s, root, "the user's file must be left alone, not replaced");
411 }
412
413 // An object root is still the ordinary path.
414 let mut s = json!({"unrelated": true});
415 append_hook_entry(&mut s, "hooks", "PreToolUse", json!({"matcher": "Bash"})).unwrap();
416 assert_eq!(s["hooks"]["PreToolUse"][0]["matcher"], "Bash");
417 assert_eq!(s["unrelated"], true, "unrelated keys survive");
418 }
419}
420
421#[cfg(test)]
422mod tool_filter_tests {
423 use super::*;
424
425 /// No target decides on a tool that is not its shell tool.
426 ///
427 /// The hook is wired with a matcher (`Bash`, `Execute`, `run_shell_command`), so normally only
428 /// shell calls arrive. But a matcher is configuration: it can be hand-edited, and grok is
429 /// documented to auto-load `~/.claude/settings.json`, which hands Claude's hook a foreign
430 /// envelope. Deciding on a `Read`/`Write`/`Edit` call grants or vetoes a tool whose semantics
431 /// were never analysed — and for the ALLOW-capable targets that is a grant, issued on the
432 /// strength of a `command` field the tool does not even have. Four targets did exactly that.
433 ///
434 /// Driven by each target's OWN `sample_envelope`, because nine harnesses have nine schemas and
435 /// a generic probe silently fails to parse (which looks like a pass). A target whose envelope
436 /// carries no tool identifier returns `None` and is exempt — a researched claim, recorded per
437 /// target, not a default.
438 #[test]
439 fn no_target_decides_on_a_foreign_tool() {
440 let mut failures = Vec::new();
441 let mut checked = 0usize;
442 for target in registry() {
443 if target.hook_format().is_none() {
444 continue;
445 }
446 let Some(shell) = target.sample_envelope(target.shell_tool_name(), "ls") else {
447 continue; // envelope carries no tool identifier — cannot self-filter
448 };
449 let name = target.name();
450 let routed = |env: &str| target.hook_format_for(env).map(|fmt| fmt.parse_input(env));
451 // The shell tool must still parse, or "reject everything" would satisfy the negative
452 // half and look like a working filter.
453 match routed(&shell) {
454 Some(Ok(_)) => {}
455 Some(Err(e)) => failures.push(format!("{name}: rejected its own shell tool `{}`: {}", target.shell_tool_name(), e.message)),
456 None => failures.push(format!("{name}: hook_format_for found no format for its own shell envelope")),
457 }
458 for foreign in ["Read", "Write", "Edit", "WebFetch"] {
459 let Some(env) = target.sample_envelope(foreign, "rm -rf /") else { continue };
460 checked += 1;
461 if matches!(routed(&env), Some(Ok(_))) {
462 failures.push(format!("{name}: parsed a `{foreign}` envelope instead of abstaining"));
463 }
464 }
465 }
466 assert!(checked > 0, "no target was probed — the guard is vacuous");
467 assert!(failures.is_empty(), "foreign-tool decisions:\n{}", failures.join("\n"));
468 }
469}