Skip to main content

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