Skip to main content

oneharness_core/domain/
mock.rs

1//! The runtime *mock/spy responder*: given a harness's pre-tool hook event on
2//! stdin and a caller-supplied ruleset, decide whether to intercept the call and
3//! render that harness's native verdict — a **deny** (the model reads the
4//! message as tool feedback) or an **input rewrite** (the call runs with
5//! substituted arguments, so a shell command can be swapped for a stub that
6//! prints canned output, or a file read redirected to a fixture).
7//!
8//! This is the read-write sibling of [`crate::domain::gate`] and rides the same
9//! installed-hook loop: `oneharness sync` installs a hook that invokes
10//! `oneharness mock <id> --rules <file>`; this module is what that invocation
11//! runs. It is pure — rules in, verdict out — so the per-harness wire protocol
12//! is unit-testable without a real harness. The thin stdin/stdout wrapper (and
13//! the spy-log append, the one I/O) lives in the binary (`src/commands/mock.rs`).
14//!
15//! Verdict shapes are per-harness registry data
16//! ([`crate::domain::harness::HarnessSpec::mock_rewrite`] for rewrites;
17//! [`DenyShape`] for denies), sourced from each CLI's published hook protocol,
18//! never guessed — and **loud when absent**: a ruleset asking for an action a
19//! harness cannot express is a usage error, never a silent allow. Which
20//! harnesses honor a rewrite live is drift-alarmed by the `oh_mock_enforce`
21//! e2e phases; the `explore-hooks` probe is how a new shape gets sourced (see
22//! `docs/mock-spy-design.md`).
23
24use serde::{Deserialize, Serialize};
25use serde_json::{json, Value};
26
27use crate::domain::gate::DenyShape;
28
29/// How a harness expresses "allow this call, but with these rewritten
30/// arguments" from a pre-tool hook. Every variant is live-verified (the
31/// `explore-hooks` probe and/or an `oh_mock_enforce` phase — see
32/// `docs/mock-spy-design.md` for the per-harness evidence); absent for a
33/// harness whose protocol has no rewrite verdict (Goose), whose hooks never
34/// fire headlessly (Copilot), or whose documented rewrite was live-refuted
35/// (Qwen).
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum RewriteShape {
38    /// Claude Code / Codex: `hookSpecificOutput.updatedInput` beside an
39    /// `allow` permission decision. (Qwen documents this shape too but was
40    /// live-refuted — see the registry.)
41    ClaudeNested,
42    /// Crush: a flat `{"version":1,"decision":"allow","updated_input":{…}}`
43    /// (its `updated_input` is a shallow-merge patch of the tool input).
44    CrushFlat,
45    /// OpenCode: the oneharness plugin shim applies a flat
46    /// `{"decision":"allow","updated_input":{…}}` reply by merging it into the
47    /// tool's mutable `args` before execution.
48    OpencodeShim,
49    /// Cursor: a flat `{"permission":"allow","updated_input":{…}}` on its
50    /// `preToolUse` event. Exactly the probe-verified reply — no reason slot
51    /// (extra fields are unverified against its parser, so none are sent).
52    CursorPermission,
53}
54
55impl RewriteShape {
56    /// Stable token for JSON surfaces (`oneharness list`).
57    pub fn as_str(self) -> &'static str {
58        match self {
59            RewriteShape::ClaudeNested => "claude-nested",
60            RewriteShape::CrushFlat => "crush-flat",
61            RewriteShape::OpencodeShim => "opencode-shim",
62            RewriteShape::CursorPermission => "cursor-permission",
63        }
64    }
65}
66
67/// How `run --mock-rules` / `run --spy-file` delivers the mock hook to this
68/// harness **for one invocation** — the single-flag ephemeral path. Every
69/// variant is live-verified; `None` (qwen: project hooks don't fire headlessly;
70/// copilot: hooks never fire headlessly at all) makes the flag a loud usage
71/// error for the harness, never a silently inert install.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum MockDelivery {
74    /// The hook rides the argv via a per-run settings flag (Claude Code's
75    /// `--settings <file>`, probe-verified to load hooks headlessly): zero
76    /// workspace mutation — existing project/user config still applies, the
77    /// mock hook is layered on top for this invocation only.
78    SettingsFlag { flag: &'static str },
79    /// The hook is installed into the project-scope config in the working
80    /// directory via the non-destructive merge (existing keys and hooks
81    /// preserved — layered on top), with every touched file snapshotted first
82    /// and restored after the run. `extra_args` are appended to the harness's
83    /// argv — how Codex's hooks engine is opted in per invocation.
84    ProjectHooks { extra_args: &'static [&'static str] },
85}
86
87/// Render the hook command an installed mock hook runs: this binary's `mock`
88/// verb with the ruleset and spy log wired in. Paths are embedded verbatim, so
89/// the caller must have refused whitespace-bearing ones first (the OpenCode
90/// shim tokenizes the command on spaces, and shell-run hooks would split too).
91pub fn hook_command(exe: &str, id: &str, rules: Option<&str>, spy: Option<&str>) -> String {
92    let mut command = format!("{exe} mock {id}");
93    if let Some(rules) = rules {
94        command.push_str(" --rules ");
95        command.push_str(rules);
96    }
97    if let Some(spy) = spy {
98        command.push_str(" --spy-file ");
99        command.push_str(spy);
100    }
101    command
102}
103
104/// The settings JSON a [`MockDelivery::SettingsFlag`] harness receives: a
105/// PreToolUse hook (no matcher — every tool) invoking `command`. Exactly the
106/// shape the explore-hooks probe verified Claude Code loads from a per-run
107/// `--settings` file.
108pub fn settings_hooks_json(command: &str) -> String {
109    json!({
110        "hooks": {
111            "PreToolUse": [
112                { "hooks": [ { "type": "command", "command": command } ] }
113            ]
114        }
115    })
116    .to_string()
117}
118
119/// Compile a [`Action::Stub`] into the substituted shell-tool input: a
120/// `printf` of the declared output (single-quoted with POSIX escaping, so any
121/// text — quotes, `$`, backticks, newlines — is emitted verbatim and nothing
122/// is ever interpreted), plus an `exit` when a non-zero code fakes a failure.
123pub fn stub_input(output: &str, exit_code: i32) -> Value {
124    let quoted = format!("'{}'", output.replace('\'', "'\\''"));
125    let mut command = format!("printf '%s\\n' {quoted}");
126    if exit_code != 0 {
127        command.push_str(&format!("; exit {exit_code}"));
128    }
129    json!({ "command": command })
130}
131
132/// A parsed mock ruleset: the first rule whose `match` covers the event wins.
133/// Deserialized from the JSON file `oneharness mock --rules <path>` reads;
134/// unknown fields are rejected loudly (a typo must never become a silent
135/// allow-everything).
136#[derive(Debug, Clone, PartialEq, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct MockRules {
139    pub rules: Vec<MockRule>,
140}
141
142/// One rule: match criteria plus the action to take when they hold.
143#[derive(Debug, Clone, PartialEq, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct MockRule {
146    #[serde(rename = "match")]
147    pub matcher: MatchSpec,
148    pub action: Action,
149}
150
151/// What a rule matches on. At least one criterion is required (an empty match
152/// would silently intercept everything); when several are given, **all** must
153/// hold (AND). Criteria: the tool name (exact or regex), the raw event JSON
154/// (substring or regex), and per-field predicates on the tool's input.
155#[derive(Debug, Clone, PartialEq, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct MatchSpec {
158    /// Case-insensitive exact match on the event's tool name. Tool names are
159    /// per-harness (`Bash` on Claude Code, `bash` on OpenCode/Crush,
160    /// `run_shell_command` on Qwen), so cross-harness rules usually prefer
161    /// `event_contains` or an `input` predicate.
162    #[serde(default)]
163    pub tool: Option<String>,
164    /// Regex (RE2, unanchored) on the tool name — e.g. `"^(Bash|bash)$"` to
165    /// span a harness's casing. Anchor with `^…$` for an exact match.
166    #[serde(default)]
167    pub tool_regex: Option<String>,
168    /// Substring match over the raw event JSON — harness-agnostic, because the
169    /// tool's command/args always serialize into the event (the same principle
170    /// as [`crate::domain::gate::should_deny`]).
171    #[serde(default)]
172    pub event_contains: Option<String>,
173    /// Regex (RE2, unanchored) over the raw event JSON — e.g. `"git\\s+push"`.
174    #[serde(default)]
175    pub event_regex: Option<String>,
176    /// Per-field predicates on the tool's input arguments (`tool_input`, or
177    /// Copilot's `toolArgs`): the map key is the argument name (`command`,
178    /// `file_path`, …) and the value a [`StringPredicate`]. All listed fields
179    /// must match, and a field absent from the event fails the rule. A field
180    /// whose value is not a string is compared against its compact JSON form,
181    /// so a predicate can still target an array/object argument.
182    #[serde(default)]
183    pub input: std::collections::BTreeMap<String, StringPredicate>,
184}
185
186/// A predicate on one string value (a `tool_input` field, matched by
187/// [`MatchSpec::input`]). At least one form must be set; when several are, all
188/// must hold (AND). `equals` is an exact match (an empty string is allowed —
189/// it matches only an empty value, not everything); `contains` is a substring;
190/// `regex` is an unanchored RE2 (linear-time) match. `contains`/`regex` reject
191/// an empty pattern (it would match everything).
192#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
193#[serde(deny_unknown_fields)]
194pub struct StringPredicate {
195    #[serde(default)]
196    pub equals: Option<String>,
197    #[serde(default)]
198    pub contains: Option<String>,
199    #[serde(default)]
200    pub regex: Option<String>,
201}
202
203impl StringPredicate {
204    /// Whether every set form holds for `value`. A regex that somehow fails to
205    /// compile (validated at parse time, so unreachable in practice) is treated
206    /// as a non-match — never an accidental match-everything.
207    fn matches(&self, value: &str) -> bool {
208        if let Some(want) = &self.equals {
209            if value != want {
210                return false;
211            }
212        }
213        if let Some(needle) = &self.contains {
214            if !value.contains(needle.as_str()) {
215                return false;
216            }
217        }
218        if let Some(pattern) = &self.regex {
219            match regex::Regex::new(pattern) {
220                Ok(re) if re.is_match(value) => {}
221                _ => return false,
222            }
223        }
224        true
225    }
226
227    /// The forms that are set (for validation): `(any_set, has_empty_needle)`.
228    fn forms(&self) -> (bool, bool) {
229        let any = self.equals.is_some() || self.contains.is_some() || self.regex.is_some();
230        let empty_needle =
231            self.contains.as_deref() == Some("") || self.regex.as_deref() == Some("");
232        (any, empty_needle)
233    }
234}
235
236/// The interception to perform when a rule matches.
237#[derive(Debug, Clone, PartialEq, Deserialize)]
238#[serde(rename_all = "snake_case", deny_unknown_fields)]
239pub enum Action {
240    /// Block the call; the harness surfaces `message` to the model as the
241    /// tool's feedback. Expressible wherever `oneharness gate` works.
242    Deny { message: String },
243    /// Allow the call with `input` substituted for the tool's arguments — the
244    /// general rewrite: swap a shell command, or redirect a read's `file_path`
245    /// to a fixture. `input` is passed to the harness verbatim (each applies
246    /// its own semantics; Crush shallow-merges).
247    Rewrite {
248        input: Value,
249        /// Optional reason surfaced where the harness's shape carries one.
250        #[serde(default)]
251        message: Option<String>,
252    },
253    /// Fake a SHELL call's result by declaring only the output (and optional
254    /// exit code): oneharness generates a safely-quoted `printf` stub itself
255    /// and delivers it as an input rewrite, so no user-authored command — and
256    /// nothing real — ever executes. The model receives `output` (plus a
257    /// trailing newline, like real command output) as the tool's genuine
258    /// result. Sugar over [`Action::Rewrite`], so it needs the same
259    /// `mock_rewrite` capability; shell tools only (their input is a `command`
260    /// field on every rewrite-capable harness) — mock file reads with a
261    /// `rewrite` of `file_path` instead.
262    Stub {
263        output: String,
264        /// The stub's exit code (default 0). Non-zero fakes a failing command.
265        #[serde(default)]
266        exit_code: i32,
267    },
268}
269
270impl Action {
271    /// Stable token for the spy log and error messages.
272    pub fn kind(&self) -> &'static str {
273        match self {
274            Action::Deny { .. } => "deny",
275            Action::Rewrite { .. } => "rewrite",
276            Action::Stub { .. } => "stub",
277        }
278    }
279}
280
281/// Parse and validate a ruleset. Loud on any fault — an unparseable or invalid
282/// ruleset must abort the run (usage error), never degrade to allow-everything.
283pub fn parse_rules(text: &str) -> Result<MockRules, String> {
284    let rules: MockRules = serde_json::from_str(text).map_err(|e| e.to_string())?;
285    for (i, rule) in rules.rules.iter().enumerate() {
286        let m = &rule.matcher;
287        // Empty top-level needles would match everything — refuse each loudly.
288        for (field, value) in [
289            ("tool", &m.tool),
290            ("tool_regex", &m.tool_regex),
291            ("event_contains", &m.event_contains),
292            ("event_regex", &m.event_regex),
293        ] {
294            if value.as_deref() == Some("") {
295                return Err(format!(
296                    "rule {i}: empty `match.{field}` is not allowed (it would match everything)"
297                ));
298            }
299        }
300        // Compile the regex criteria so an invalid pattern is a loud parse
301        // error, never a silent match-nothing at runtime.
302        for (field, pattern) in [
303            ("tool_regex", &m.tool_regex),
304            ("event_regex", &m.event_regex),
305        ] {
306            if let Some(pattern) = pattern {
307                regex::Regex::new(pattern)
308                    .map_err(|e| format!("rule {i}: invalid `match.{field}` regex: {e}"))?;
309            }
310        }
311        // Per-field input predicates: each needs a form, no empty needle, valid
312        // regex.
313        for (key, pred) in &m.input {
314            let (any, empty_needle) = pred.forms();
315            if !any {
316                return Err(format!(
317                    "rule {i}: `match.input.{key}` needs one of `equals`/`contains`/`regex`"
318                ));
319            }
320            if empty_needle {
321                return Err(format!(
322                    "rule {i}: empty `contains`/`regex` in `match.input.{key}` is not allowed (it would match everything)"
323                ));
324            }
325            if let Some(pattern) = &pred.regex {
326                regex::Regex::new(pattern).map_err(|e| {
327                    format!("rule {i}: invalid `match.input.{key}.regex` regex: {e}")
328                })?;
329            }
330        }
331        // At least one criterion, or the rule intercepts everything.
332        let no_criteria = m.tool.is_none()
333            && m.tool_regex.is_none()
334            && m.event_contains.is_none()
335            && m.event_regex.is_none()
336            && m.input.is_empty();
337        if no_criteria {
338            return Err(format!(
339                "rule {i}: `match` needs at least one of `tool`, `tool_regex`, `event_contains`, `event_regex`, or `input`"
340            ));
341        }
342        if let Action::Rewrite { input, .. } = &rule.action {
343            if !input.is_object() {
344                return Err(format!(
345                    "rule {i}: `rewrite.input` must be a JSON object (the substituted tool arguments)"
346                ));
347            }
348        }
349    }
350    Ok(rules)
351}
352
353/// The action a ruleset uses that the harness cannot express, if any — checked
354/// up front so an unrenderable ruleset is a loud usage error before any event
355/// is read, never a silent downgrade at match time.
356pub fn unsupported_action(
357    rules: &MockRules,
358    gate_deny: Option<DenyShape>,
359    rewrite: Option<RewriteShape>,
360) -> Option<&'static str> {
361    for rule in &rules.rules {
362        match rule.action {
363            Action::Deny { .. } if gate_deny.is_none() => return Some("deny"),
364            Action::Rewrite { .. } if rewrite.is_none() => return Some("rewrite"),
365            // A stub is delivered as a rewrite, so it needs the same shape.
366            Action::Stub { .. } if rewrite.is_none() => return Some("stub"),
367            _ => {}
368        }
369    }
370    None
371}
372
373/// Decide which rule (if any) intercepts `event` — the raw hook JSON the
374/// harness piped to stdin. First match wins; no match means allow-through
375/// (empty stdout, the universal "no objection").
376pub fn decide<'r>(event: &str, rules: &'r MockRules) -> Option<(usize, &'r Action)> {
377    // Parse the event once (the process handles a single hook call), so every
378    // rule sees the same tool name and input object without re-parsing.
379    let parsed: Option<Value> = serde_json::from_str(event.trim()).ok();
380    let tool = parsed.as_ref().and_then(tool_name_of);
381    rules
382        .rules
383        .iter()
384        .enumerate()
385        .find(|(_, rule)| rule_matches(rule, event, tool.as_deref(), parsed.as_ref()))
386        .map(|(i, rule)| (i, &rule.action))
387}
388
389fn rule_matches(rule: &MockRule, event: &str, tool: Option<&str>, parsed: Option<&Value>) -> bool {
390    let m = &rule.matcher;
391    if let Some(want) = m.tool.as_deref() {
392        // A `tool` criterion can only match an event that names its tool; an
393        // empty want never matches (also rejected at parse time).
394        match tool {
395            Some(name) if !want.is_empty() && name.eq_ignore_ascii_case(want) => {}
396            _ => return false,
397        }
398    }
399    if let Some(pattern) = m.tool_regex.as_deref() {
400        match (tool, regex::Regex::new(pattern)) {
401            (Some(name), Ok(re)) if re.is_match(name) => {}
402            _ => return false,
403        }
404    }
405    if let Some(needle) = m.event_contains.as_deref() {
406        if needle.is_empty() || !event.contains(needle) {
407            return false;
408        }
409    }
410    if let Some(pattern) = m.event_regex.as_deref() {
411        match regex::Regex::new(pattern) {
412            Ok(re) if re.is_match(event) => {}
413            _ => return false,
414        }
415    }
416    if !m.input.is_empty() {
417        let args = parsed.and_then(tool_input_of);
418        for (key, pred) in &m.input {
419            // A field absent from the event fails the rule (never fabricated).
420            let Some(value) = args.and_then(|a| a.get(key)) else {
421                return false;
422            };
423            // Match a string field directly; coerce anything else to its
424            // compact JSON so a predicate can still target a non-string arg.
425            let owned;
426            let text = match value.as_str() {
427                Some(s) => s,
428                None => {
429                    owned = serde_json::to_string(value).unwrap_or_default();
430                    &owned
431                }
432            };
433            if !pred.matches(text) {
434                return false;
435            }
436        }
437    }
438    true
439}
440
441/// Best-effort tool name from a hook event: the field every gated harness (and
442/// the oneharness OpenCode shim) uses is `tool_name`; `toolName` (Copilot) and
443/// `tool` are accepted for robustness. `None` when the event is not JSON or
444/// names no tool — a `tool` matcher then simply cannot match (never fabricated).
445pub fn extract_tool_name(event: &str) -> Option<String> {
446    let value: Value = serde_json::from_str(event.trim()).ok()?;
447    tool_name_of(&value)
448}
449
450/// The tool name from an already-parsed event value.
451fn tool_name_of(value: &Value) -> Option<String> {
452    for key in ["tool_name", "toolName", "tool"] {
453        if let Some(name) = value.get(key).and_then(Value::as_str) {
454            if !name.is_empty() {
455                return Some(name.to_string());
456            }
457        }
458    }
459    None
460}
461
462/// The tool's input-arguments object from a parsed event: `tool_input` (every
463/// gated harness + the OpenCode shim) or `toolArgs` (Copilot).
464fn tool_input_of(value: &Value) -> Option<&serde_json::Map<String, Value>> {
465    for key in ["tool_input", "toolArgs"] {
466        if let Some(obj) = value.get(key).and_then(Value::as_object) {
467            return Some(obj);
468        }
469    }
470    None
471}
472
473/// Render the stdout that substitutes `input` for the tool's arguments,
474/// carrying `reason` where the harness's shape has a slot for one. Pure: the
475/// returned string is exactly the JSON the harness (or the OpenCode shim)
476/// reads; the caller appends the trailing newline.
477pub fn render_rewrite(shape: RewriteShape, input: &Value, reason: &str) -> String {
478    let value = match shape {
479        RewriteShape::ClaudeNested => json!({
480            "hookSpecificOutput": {
481                "hookEventName": "PreToolUse",
482                "permissionDecision": "allow",
483                "permissionDecisionReason": reason,
484                "updatedInput": input,
485            }
486        }),
487        RewriteShape::CrushFlat => json!({
488            "version": 1,
489            "decision": "allow",
490            "reason": reason,
491            "updated_input": input,
492        }),
493        RewriteShape::OpencodeShim => json!({
494            "decision": "allow",
495            "reason": reason,
496            "updated_input": input,
497        }),
498        // Cursor's probe-verified reply carries no reason slot; sending only
499        // what was verified keeps its parser from rejecting the verdict.
500        RewriteShape::CursorPermission => json!({
501            "permission": "allow",
502            "updated_input": input,
503        }),
504    };
505    value.to_string()
506}
507
508/// One spy-log line: the observed hook event plus what the responder did with
509/// it. Appended as JSONL by the command layer for every invocation — with or
510/// without a ruleset — so a consumer gets the *original* tool call (pre-rewrite
511/// intent), which the harness's own transcript `events` cannot show once an
512/// input was substituted.
513#[derive(Debug, Serialize)]
514pub struct SpyRecord<'a> {
515    pub harness: &'a str,
516    /// The raw hook event, parsed when it is JSON (else the raw string, never
517    /// dropped).
518    pub event: Value,
519    /// `allow` (no rule matched — the fall-through), `deny`, or `rewrite`.
520    pub action: &'static str,
521    /// Index of the matched rule in the ruleset; `null` on the fall-through.
522    pub rule: Option<usize>,
523}
524
525/// Render the spy-log line for one invocation (no trailing newline).
526pub fn spy_line(harness: &str, event: &str, decision: Option<(usize, &Action)>) -> String {
527    let record = SpyRecord {
528        harness,
529        event: serde_json::from_str(event.trim())
530            .unwrap_or_else(|_| Value::String(event.to_string())),
531        action: decision.map_or("allow", |(_, action)| action.kind()),
532        rule: decision.map(|(i, _)| i),
533    };
534    serde_json::to_string(&record).expect("SpyRecord serialization cannot fail")
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    fn rules(json: &str) -> MockRules {
542        parse_rules(json).expect("test ruleset must parse")
543    }
544
545    const REWRITE_RULES: &str = r#"{
546        "rules": [
547            {
548                "match": {"tool": "Bash", "event_contains": "git push"},
549                "action": {"deny": {"message": "pushes are mocked"}}
550            },
551            {
552                "match": {"event_contains": "git status"},
553                "action": {"rewrite": {"input": {"command": "printf clean"}}}
554            }
555        ]
556    }"#;
557
558    #[test]
559    fn parse_accepts_valid_rules_and_rejects_faults_loudly() {
560        assert_eq!(rules(REWRITE_RULES).rules.len(), 2);
561        // Unknown fields are a loud parse error, not a silent skip.
562        assert!(parse_rules(r#"{"rules":[],"extra":1}"#).is_err());
563        assert!(parse_rules(
564            r#"{"rules":[{"match":{"typo_contains":"x"},"action":{"deny":{"message":"m"}}}]}"#
565        )
566        .is_err());
567        // An empty match would intercept everything — refused.
568        let err = parse_rules(r#"{"rules":[{"match":{},"action":{"deny":{"message":"m"}}}]}"#)
569            .unwrap_err();
570        assert!(err.contains("at least one of"), "{err}");
571        // Empty strings are refused too (an empty needle matches everything).
572        for m in [
573            r#"{"tool": ""}"#,
574            r#"{"event_contains": ""}"#,
575            r#"{"tool": "", "event_contains": "x"}"#,
576        ] {
577            let text =
578                format!(r#"{{"rules":[{{"match":{m},"action":{{"deny":{{"message":"m"}}}}}}]}}"#);
579            assert!(parse_rules(&text).is_err(), "{m} must be rejected");
580        }
581        // A rewrite's input must be the substituted arguments object.
582        let err = parse_rules(
583            r#"{"rules":[{"match":{"tool":"Bash"},"action":{"rewrite":{"input":"echo"}}}]}"#,
584        )
585        .unwrap_err();
586        assert!(err.contains("must be a JSON object"), "{err}");
587        // Not JSON at all.
588        assert!(parse_rules("not json").is_err());
589    }
590
591    #[test]
592    fn decide_first_match_wins_and_falls_through() {
593        let r = rules(REWRITE_RULES);
594        // Rule 0: tool + substring both hold.
595        let event = r#"{"tool_name":"Bash","tool_input":{"command":"git push origin"}}"#;
596        let (i, action) = decide(event, &r).expect("must match");
597        assert_eq!(i, 0);
598        assert_eq!(action.kind(), "deny");
599        // Rule 1 matches on substring alone (no tool criterion).
600        let event = r#"{"tool_name":"shell","tool_input":{"command":"git status"}}"#;
601        let (i, action) = decide(event, &r).expect("must match");
602        assert_eq!(i, 1);
603        assert_eq!(action.kind(), "rewrite");
604        // Neither: fall through.
605        assert!(decide(r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#, &r).is_none());
606        // Rule 0 requires BOTH criteria: right tool, wrong substring -> rule 1
607        // doesn't match either -> fall through.
608        assert!(decide(r#"{"tool_name":"Bash","tool_input":{"command":"rm"}}"#, &r).is_none());
609        // Wrong tool with rule 0's substring: rule 0 misses, no other rule
610        // carries "git push" -> fall through (tool criterion is enforced).
611        assert!(decide(
612            r#"{"tool_name":"Edit","tool_input":{"command":"git push"}}"#,
613            &r
614        )
615        .is_none());
616    }
617
618    #[test]
619    fn tool_regex_and_event_regex_match() {
620        // One cross-harness rule spanning `Bash`/`bash` and asserting a push.
621        let r = rules(
622            r#"{"rules":[{"match":{"tool_regex":"^(?i)bash$","event_regex":"git\\s+push"},"action":{"deny":{"message":"m"}}}]}"#,
623        );
624        assert!(decide(
625            r#"{"tool_name":"Bash","tool_input":{"command":"git    push"}}"#,
626            &r
627        )
628        .is_some());
629        assert!(decide(
630            r#"{"tool_name":"bash","tool_input":{"command":"git push"}}"#,
631            &r
632        )
633        .is_some());
634        // Wrong tool, or no push, or no tool name → no match.
635        assert!(decide(
636            r#"{"tool_name":"Edit","tool_input":{"command":"git push"}}"#,
637            &r
638        )
639        .is_none());
640        assert!(decide(
641            r#"{"tool_name":"bash","tool_input":{"command":"git status"}}"#,
642            &r
643        )
644        .is_none());
645        assert!(decide(r#"{"tool_input":{"command":"git push"}}"#, &r).is_none());
646    }
647
648    #[test]
649    fn input_field_predicates_match_and_coerce() {
650        // Regex on the command field; substring + equals on others.
651        let r = rules(
652            r#"{"rules":[{"match":{"input":{"command":{"regex":"rm\\s+-rf"}}},"action":{"deny":{"message":"m"}}}]}"#,
653        );
654        assert!(decide(
655            r#"{"tool_name":"bash","tool_input":{"command":"rm  -rf /tmp/x"}}"#,
656            &r
657        )
658        .is_some());
659        assert!(decide(r#"{"tool_name":"bash","tool_input":{"command":"ls"}}"#, &r).is_none());
660        // An absent field fails the rule (never fabricated).
661        assert!(decide(
662            r#"{"tool_name":"bash","tool_input":{"other":"rm -rf"}}"#,
663            &r
664        )
665        .is_none());
666
667        // `equals` is exact; multiple criteria on one field AND together.
668        let r = rules(
669            r#"{"rules":[{"match":{"input":{"file_path":{"equals":"/etc/passwd"}}},"action":{"deny":{"message":"m"}}}]}"#,
670        );
671        assert!(decide(
672            r#"{"tool_name":"Read","tool_input":{"file_path":"/etc/passwd"}}"#,
673            &r
674        )
675        .is_some());
676        assert!(decide(
677            r#"{"tool_name":"Read","tool_input":{"file_path":"/etc/passwd.bak"}}"#,
678            &r
679        )
680        .is_none());
681
682        // A non-string field is coerced to compact JSON, so a predicate can
683        // still target an array/object argument.
684        let r = rules(
685            r#"{"rules":[{"match":{"input":{"args":{"contains":"--force"}}},"action":{"deny":{"message":"m"}}}]}"#,
686        );
687        assert!(decide(
688            r#"{"tool_name":"exec","tool_input":{"args":["git","push","--force"]}}"#,
689            &r
690        )
691        .is_some());
692        assert!(decide(
693            r#"{"tool_name":"exec","tool_input":{"args":["git","status"]}}"#,
694            &r
695        )
696        .is_none());
697
698        // Copilot's `toolArgs` is accepted as the input object too.
699        let r = rules(
700            r#"{"rules":[{"match":{"input":{"command":{"contains":"push"}}},"action":{"deny":{"message":"m"}}}]}"#,
701        );
702        assert!(decide(
703            r#"{"toolName":"shell","toolArgs":{"command":"git push"}}"#,
704            &r
705        )
706        .is_some());
707    }
708
709    #[test]
710    fn input_and_tool_criteria_are_anded() {
711        // Both a tool pin and an input predicate must hold.
712        let r = rules(
713            r#"{"rules":[{"match":{"tool":"Bash","input":{"command":{"contains":"push"}}},"action":{"deny":{"message":"m"}}}]}"#,
714        );
715        assert!(decide(
716            r#"{"tool_name":"Bash","tool_input":{"command":"git push"}}"#,
717            &r
718        )
719        .is_some());
720        // Right command, wrong tool → no match (AND).
721        assert!(decide(
722            r#"{"tool_name":"Edit","tool_input":{"command":"git push"}}"#,
723            &r
724        )
725        .is_none());
726    }
727
728    #[test]
729    fn regex_and_input_validation_is_loud() {
730        // Invalid regex in each location is a loud parse error.
731        for m in [
732            r#"{"tool_regex":"("}"#,
733            r#"{"event_regex":"["}"#,
734            r#"{"input":{"command":{"regex":"("}}}"#,
735        ] {
736            let text =
737                format!(r#"{{"rules":[{{"match":{m},"action":{{"deny":{{"message":"m"}}}}}}]}}"#);
738            let err = parse_rules(&text).unwrap_err();
739            assert!(
740                err.contains("invalid") && err.contains("regex"),
741                "{m}: {err}"
742            );
743        }
744        // An input predicate with no form set is refused.
745        let err = parse_rules(
746            r#"{"rules":[{"match":{"input":{"command":{}}},"action":{"deny":{"message":"m"}}}]}"#,
747        )
748        .unwrap_err();
749        assert!(err.contains("needs one of"), "{err}");
750        // Empty contains/regex in an input predicate is refused; empty `equals`
751        // is allowed (it matches only an empty value, not everything).
752        assert!(parse_rules(
753            r#"{"rules":[{"match":{"input":{"c":{"contains":""}}},"action":{"deny":{"message":"m"}}}]}"#
754        )
755        .is_err());
756        let ok = parse_rules(
757            r#"{"rules":[{"match":{"input":{"c":{"equals":""}}},"action":{"deny":{"message":"m"}}}]}"#,
758        )
759        .unwrap();
760        assert!(decide(r#"{"tool_name":"x","tool_input":{"c":""}}"#, &ok).is_some());
761        assert!(decide(r#"{"tool_name":"x","tool_input":{"c":"nonempty"}}"#, &ok).is_none());
762        // Empty tool_regex/event_regex refused (would match everything).
763        for f in ["tool_regex", "event_regex"] {
764            let text = format!(
765                r#"{{"rules":[{{"match":{{"{f}":""}},"action":{{"deny":{{"message":"m"}}}}}}]}}"#
766            );
767            assert!(parse_rules(&text).is_err(), "{f}");
768        }
769    }
770
771    #[test]
772    fn tool_matching_is_case_insensitive_and_needs_a_named_tool() {
773        let r = rules(r#"{"rules":[{"match":{"tool":"bash"},"action":{"deny":{"message":"m"}}}]}"#);
774        assert!(decide(r#"{"tool_name":"Bash"}"#, &r).is_some());
775        assert!(decide(r#"{"toolName":"BASH"}"#, &r).is_some());
776        assert!(decide(r#"{"tool":"bash"}"#, &r).is_some());
777        // No tool name in the event: a tool criterion cannot match (honest, not
778        // fabricated) — and a non-JSON event can never satisfy it either.
779        assert!(decide(r#"{"command":"bash stuff"}"#, &r).is_none());
780        assert!(decide("not json", &r).is_none());
781    }
782
783    #[test]
784    fn extract_tool_name_reads_known_fields_only() {
785        assert_eq!(
786            extract_tool_name(r#"{"tool_name":"Bash"}"#).as_deref(),
787            Some("Bash")
788        );
789        assert_eq!(
790            extract_tool_name(r#"{"toolName":"shell"}"#).as_deref(),
791            Some("shell")
792        );
793        assert_eq!(
794            extract_tool_name(r#"{"tool":"bash"}"#).as_deref(),
795            Some("bash")
796        );
797        // Precedence: tool_name first (the field every gated harness uses).
798        assert_eq!(
799            extract_tool_name(r#"{"tool":"b","tool_name":"a"}"#).as_deref(),
800            Some("a")
801        );
802        assert!(extract_tool_name(r#"{"tool_name":""}"#).is_none());
803        assert!(extract_tool_name(r#"{"tool_name":42}"#).is_none());
804        assert!(extract_tool_name("nope").is_none());
805    }
806
807    #[test]
808    fn unsupported_action_reports_the_first_unrenderable_verb() {
809        let r = rules(REWRITE_RULES);
810        // Everything renderable: no complaint.
811        assert!(unsupported_action(
812            &r,
813            Some(DenyShape::ClaudeNested),
814            Some(RewriteShape::ClaudeNested)
815        )
816        .is_none());
817        // No rewrite shape: the rewrite rule is unrenderable.
818        assert_eq!(
819            unsupported_action(&r, Some(DenyShape::ClaudeNested), None),
820            Some("rewrite")
821        );
822        // No deny shape either: deny reported (it is the first offending rule).
823        assert_eq!(unsupported_action(&r, None, None), Some("deny"));
824        // A deny-only ruleset needs no rewrite shape.
825        let deny_only =
826            rules(r#"{"rules":[{"match":{"tool":"Bash"},"action":{"deny":{"message":"m"}}}]}"#);
827        assert!(unsupported_action(&deny_only, Some(DenyShape::CopilotFlat), None).is_none());
828    }
829
830    #[test]
831    fn render_rewrite_matches_each_protocol() {
832        let input = json!({"command": "printf mocked"});
833        let claude: Value =
834            serde_json::from_str(&render_rewrite(RewriteShape::ClaudeNested, &input, "r")).unwrap();
835        assert_eq!(claude["hookSpecificOutput"]["hookEventName"], "PreToolUse");
836        assert_eq!(claude["hookSpecificOutput"]["permissionDecision"], "allow");
837        assert_eq!(
838            claude["hookSpecificOutput"]["permissionDecisionReason"],
839            "r"
840        );
841        assert_eq!(
842            claude["hookSpecificOutput"]["updatedInput"]["command"],
843            "printf mocked"
844        );
845        let crush: Value =
846            serde_json::from_str(&render_rewrite(RewriteShape::CrushFlat, &input, "r")).unwrap();
847        assert_eq!(crush["version"], 1);
848        assert_eq!(crush["decision"], "allow");
849        assert_eq!(crush["updated_input"]["command"], "printf mocked");
850        let oc: Value =
851            serde_json::from_str(&render_rewrite(RewriteShape::OpencodeShim, &input, "r")).unwrap();
852        assert_eq!(oc["decision"], "allow");
853        assert_eq!(oc["updated_input"]["command"], "printf mocked");
854        assert!(oc.get("hookSpecificOutput").is_none());
855        // Cursor: permission + updated_input ONLY — the probe-verified reply
856        // carries no reason field, so none may be added.
857        let cursor: Value =
858            serde_json::from_str(&render_rewrite(RewriteShape::CursorPermission, &input, "r"))
859                .unwrap();
860        assert_eq!(
861            cursor,
862            json!({"permission": "allow", "updated_input": {"command": "printf mocked"}})
863        );
864    }
865
866    #[test]
867    fn hook_command_wires_rules_and_spy() {
868        assert_eq!(
869            hook_command("/bin/oh", "crush", Some("/w/r.json"), Some("/w/s.jsonl")),
870            "/bin/oh mock crush --rules /w/r.json --spy-file /w/s.jsonl"
871        );
872        // Spy-only (no rules): a pure observer hook.
873        assert_eq!(
874            hook_command("/bin/oh", "goose", None, Some("/w/s.jsonl")),
875            "/bin/oh mock goose --spy-file /w/s.jsonl"
876        );
877        assert_eq!(
878            hook_command("oh", "claude-code", Some("r.json"), None),
879            "oh mock claude-code --rules r.json"
880        );
881    }
882
883    #[test]
884    fn settings_hooks_json_matches_the_probe_verified_shape() {
885        let v: Value = serde_json::from_str(&settings_hooks_json("oh mock claude-code")).unwrap();
886        assert_eq!(
887            v,
888            json!({
889                "hooks": {
890                    "PreToolUse": [
891                        { "hooks": [ { "type": "command", "command": "oh mock claude-code" } ] }
892                    ]
893                }
894            })
895        );
896    }
897
898    #[test]
899    fn stub_input_quotes_any_output_safely() {
900        // Plain text: a printf of the output, trailing newline like a real
901        // command, exit 0 implied (no exit suffix).
902        assert_eq!(
903            stub_input("nothing to commit", 0)["command"],
904            "printf '%s\\n' 'nothing to commit'"
905        );
906        // Nothing is ever shell-interpreted: quotes, $, backticks, newlines all
907        // ride inside the single-quoted argument (POSIX quote escaping).
908        assert_eq!(
909            stub_input("it's `x` a $HOME\nline2", 0)["command"],
910            "printf '%s\\n' 'it'\\''s `x` a $HOME\nline2'"
911        );
912        // A non-zero exit code fakes a failing command.
913        assert_eq!(
914            stub_input("boom", 3)["command"],
915            "printf '%s\\n' 'boom'; exit 3"
916        );
917    }
918
919    #[test]
920    fn stub_action_parses_and_requires_the_rewrite_shape() {
921        let rules = parse_rules(
922            r#"{"rules":[{"match":{"event_contains":"git status"},"action":{"stub":{"output":"clean"}}}]}"#,
923        )
924        .unwrap();
925        let (_, action) = decide(r#"{"tool_input":{"command":"git status"}}"#, &rules).unwrap();
926        assert_eq!(action.kind(), "stub");
927        assert_eq!(
928            *action,
929            Action::Stub {
930                output: "clean".into(),
931                exit_code: 0
932            }
933        );
934        // exit_code is optional sugar with an explicit form.
935        let rules = parse_rules(
936            r#"{"rules":[{"match":{"tool":"Bash"},"action":{"stub":{"output":"e","exit_code":2}}}]}"#,
937        )
938        .unwrap();
939        assert!(matches!(
940            rules.rules[0].action,
941            Action::Stub { exit_code: 2, .. }
942        ));
943        // A stub compiles to a rewrite, so it needs the same capability.
944        assert_eq!(
945            unsupported_action(&rules, Some(DenyShape::Decision("block")), None),
946            Some("stub")
947        );
948        assert!(unsupported_action(
949            &rules,
950            Some(DenyShape::Decision("block")),
951            Some(RewriteShape::CrushFlat)
952        )
953        .is_none());
954    }
955
956    #[test]
957    fn rewrite_shape_tokens_are_stable() {
958        assert_eq!(RewriteShape::ClaudeNested.as_str(), "claude-nested");
959        assert_eq!(RewriteShape::CrushFlat.as_str(), "crush-flat");
960        assert_eq!(RewriteShape::OpencodeShim.as_str(), "opencode-shim");
961        assert_eq!(RewriteShape::CursorPermission.as_str(), "cursor-permission");
962    }
963
964    #[test]
965    fn spy_line_records_event_action_and_rule() {
966        let r = rules(REWRITE_RULES);
967        let event = r#"{"tool_name":"Bash","tool_input":{"command":"git push"}}"#;
968        let decision = decide(event, &r);
969        let line: Value = serde_json::from_str(&spy_line("claude-code", event, decision)).unwrap();
970        assert_eq!(line["harness"], "claude-code");
971        assert_eq!(line["action"], "deny");
972        assert_eq!(line["rule"], 0);
973        // The event is embedded as parsed JSON, so consumers query it directly.
974        assert_eq!(line["event"]["tool_input"]["command"], "git push");
975        // Fall-through: action `allow`, rule null; a non-JSON event is kept as
976        // a string, never dropped.
977        let line: Value = serde_json::from_str(&spy_line("goose", "raw text", None)).unwrap();
978        assert_eq!(line["action"], "allow");
979        assert!(line["rule"].is_null());
980        assert_eq!(line["event"], "raw text");
981    }
982}