Skip to main content

ai_memory/governance/
agent_action.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Substrate-level agent-action rules engine (issue #691).
5//!
6//! The K9 governance pipeline in [`crate::governance`] gates only the
7//! six substrate-INTERNAL ops ([`crate::governance::Op`]). It has no
8//! insertion point for agent-EXTERNAL actions like Bash command
9//! execution, filesystem writes outside the substrate, network
10//! requests, or process spawns. Issue #691 RCA: every operator hard
11//! rule that has ever been violated in the v0.7.0 campaign (5-6
12//! occurrences of `/tmp` writes, low-disk `cargo` runs) lived OUTSIDE
13//! the K9 surface.
14//!
15//! This module adds a second engine — [`check_agent_action`] — that
16//! evaluates a declarative table of rules at every external-action
17//! entry point. Rules are typed data in the `governance_rules` table
18//! (migration `0024_v07_governance_rules.sql`); the engine here is
19//! the read path that compiles a rule's `matcher` JSON into an
20//! [`AgentAction`] match decision and returns a [`Decision`].
21//!
22//! # Enforcement language (honest)
23//!
24//! - **Substrate-INTERNAL ops** ([`memory_store`], [`memory_link`],
25//!   etc.): the K9 pipeline is **substrate-authoritative** —
26//!   mechanically applied at the write path. The agent cannot
27//!   bypass.
28//! - **Agent-EXTERNAL ops** (Bash / FilesystemWrite outside the
29//!   substrate / NetworkRequest / ProcessSpawn): this engine is
30//!   **substrate-rule-bound, harness-mediated**. The rule lives in
31//!   the substrate's `governance_rules` table; the harness (Claude
32//!   Code PreToolUse hook of type `mcp_tool`) consults the substrate
33//!   via [`crate::mcp::tools::check_agent_action`] and honors the
34//!   decision. That is mechanical at the **harness hook boundary**
35//!   (operator-configured), not at the **agent attention** boundary
36//!   (probabilistic).
37//!
38//! # Wired-state (v0.7.0 7th-form closeout — issue #760)
39//!
40//! This module is now **wired at the harness boundary** across four
41//! daemon-side wire-points enumerated in issue #691:
42//!
43//! | Wire-point                          | AgentAction variant   | File:line                                   |
44//! |-------------------------------------|-----------------------|---------------------------------------------|
45//! | Skill manifest emission             | `FilesystemWrite`     | `src/mcp/tools/skill_export.rs:162,209`     |
46//! | Federation peer POST                | `NetworkRequest`      | `src/federation/sync.rs:66`                 |
47//! | Hooks subprocess spawn              | `ProcessSpawn`        | `src/hooks/executor.rs:399,783`             |
48//! | LLM (Ollama / OpenAI) HTTP          | `NetworkRequest`      | `src/llm.rs:421`                            |
49//!
50//! Every wire-point calls [`crate::governance::wire_check::check`]
51//! BEFORE the external action proceeds. The daemon `bootstrap_serve`
52//! installs ONE [`crate::governance::wire_check::GOVERNANCE_PRE_ACTION`]
53//! closure that consults [`check_agent_action_no_audit`] against the
54//! operator-signed `governance_rules` table. CLI one-shot binaries
55//! never install the hook so direct operator ops stay unimpeded.
56//!
57//! The substrate-INTERNAL `Custom("memory_write")` gate runs through
58//! the parallel [`crate::storage::GOVERNANCE_PRE_WRITE`] hook.
59//!
60//! Seed rules R001-R004 land at `enabled = 0` per migration
61//! `0024_v07_governance_rules.sql`. The operator activates them via
62//! `ai-memory governance install-defaults` (or per-rule via
63//! `ai-memory rules enable <id> --sign` after running `rules keygen`).
64//! Until activation the wire is mechanically inert — the audit-honest
65//! property is that the wire EXISTS and is consulted on every external
66//! action, not that any specific rule fires by default.
67
68use std::path::PathBuf;
69use std::sync::Arc;
70
71use anyhow::{Context, Result};
72use rusqlite::{Connection, OptionalExtension};
73use serde::{Deserialize, Serialize};
74
75use crate::governance::rule_cache::RuleCache;
76use crate::governance::rules_store::Rule;
77use crate::signed_events::{append_signed_event, payload_hash};
78
79/// Canonical bash-matcher field (#767 SEC-12) — shared with the CLI
80/// `rules add` validation path (#1558 batch 6).
81pub(crate) const MATCHER_COMMAND_SUBSTRING: &str = "command_substring";
82/// Legacy matcher-field alias accepted through the rename cycle.
83pub(crate) const MATCHER_COMMAND_REGEX: &str = "command_regex";
84
85/// Wire-name for the `governance.check` event_type recorded in the
86/// `signed_events` audit chain every time [`check_agent_action`]
87/// runs. Audit-side dashboards filter on this string.
88pub const GOVERNANCE_CHECK_EVENT_TYPE: &str = "governance.check";
89
90/// #1558 batch 5 wave 3 — canonical [`AgentAction::kind`] wire tags.
91/// One spelling per action kind; the `kind()` match arms below, the
92/// CLI `rules test` payload parser, and the MCP
93/// `memory_check_agent_action` argument parser all reference these
94/// consts so the `governance_rules.kind` lookup vocabulary cannot
95/// drift across surfaces.
96pub mod action_kinds {
97    /// [`AgentAction::Bash`] wire tag.
98    pub const BASH: &str = "bash";
99    /// [`AgentAction::FilesystemWrite`] wire tag.
100    pub const FILESYSTEM_WRITE: &str = "filesystem_write";
101    /// [`AgentAction::NetworkRequest`] wire tag.
102    pub const NETWORK_REQUEST: &str = "network_request";
103    /// [`AgentAction::ProcessSpawn`] wire tag.
104    pub const PROCESS_SPAWN: &str = "process_spawn";
105    /// [`AgentAction::Custom`] wire tag.
106    pub const CUSTOM: &str = "custom";
107}
108
109// ---------------------------------------------------------------------------
110// AgentAction — the agent-external action vocabulary
111// ---------------------------------------------------------------------------
112
113/// One agent-external action proposed for evaluation. The harness's
114/// PreToolUse hook constructs one of these from the tool input and
115/// hands it to [`check_agent_action`] via MCP; the CLI's `rules
116/// check` verb does the same locally.
117///
118/// The variant names are the canonical `kind` strings in the
119/// `governance_rules.kind` column (lower_snake). Adding a new variant
120/// is wire-compatible — existing rules with unknown kinds are
121/// ignored by the engine, not failed.
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123#[serde(tag = "kind", rename_all = "snake_case")]
124pub enum AgentAction {
125    /// A shell command the harness is about to execute. `cwd` is the
126    /// resolved working directory when the harness knows it
127    /// (Bash-tool calls always carry one; one-shot dispatches may
128    /// not).
129    Bash {
130        command: String,
131        #[serde(default, skip_serializing_if = "Option::is_none")]
132        cwd: Option<PathBuf>,
133    },
134    /// A filesystem write outside the substrate (a file create /
135    /// edit / append). `byte_estimate` lets a future quota rule
136    /// refuse a write that would tip a disk into ENOSPC; today it
137    /// is informational.
138    FilesystemWrite {
139        path: PathBuf,
140        #[serde(default, skip_serializing_if = "Option::is_none")]
141        byte_estimate: Option<u64>,
142    },
143    /// An outbound network request the harness is about to issue.
144    /// `scheme` is the wire scheme (`https`, `http`, etc.) for
145    /// future scheme-restrictive rules; the K9 pipeline never
146    /// inspects this path.
147    NetworkRequest {
148        host: String,
149        #[serde(default)]
150        scheme: String,
151    },
152    /// A child-process spawn — `cargo build`, `npm install`,
153    /// `colima delete`, etc. `binary` is the resolved program name
154    /// (not the full path); `args` are the literal argv tail.
155    ProcessSpawn {
156        binary: String,
157        #[serde(default)]
158        args: Vec<String>,
159    },
160    /// Extension point for actions outside the four canonical kinds.
161    /// `payload` is whatever shape the caller proposes; matcher
162    /// rules of kind `custom` consult its `custom_kind` field plus
163    /// their own JSON `matches` map. The inner field is named
164    /// `custom_kind` rather than `kind` to avoid colliding with the
165    /// outer `#[serde(tag = "kind")]` discriminator.
166    Custom {
167        custom_kind: String,
168        payload: serde_json::Value,
169    },
170}
171
172impl AgentAction {
173    /// Canonical lower-snake tag used to look up rules in the
174    /// `governance_rules.kind` column. Stable wire format.
175    #[must_use]
176    pub fn kind(&self) -> &str {
177        match self {
178            AgentAction::Bash { .. } => action_kinds::BASH,
179            AgentAction::FilesystemWrite { .. } => action_kinds::FILESYSTEM_WRITE,
180            AgentAction::NetworkRequest { .. } => action_kinds::NETWORK_REQUEST,
181            AgentAction::ProcessSpawn { .. } => action_kinds::PROCESS_SPAWN,
182            AgentAction::Custom { .. } => action_kinds::CUSTOM,
183        }
184    }
185
186    /// JSON shape suitable for `signed_events.payload_hash` input.
187    /// Stable across versions: the field order is `kind` first then
188    /// remaining variant fields. Used both for audit and for the
189    /// canonical representation a future signature would commit to.
190    ///
191    /// # Errors
192    ///
193    /// Returns an error only if `serde_json` cannot serialize the
194    /// variant — in practice never happens with the shapes here.
195    pub fn canonical_bytes(&self) -> Result<Vec<u8>> {
196        let val = serde_json::to_value(self)
197            .context("agent_action canonical_bytes: serialize AgentAction")?;
198        serde_json::to_vec(&val).context("agent_action canonical_bytes: re-serialize Value to vec")
199    }
200}
201
202// ---------------------------------------------------------------------------
203// Decision — the engine output
204// ---------------------------------------------------------------------------
205
206/// Outcome of [`check_agent_action`]. Mirrors the [`crate::governance::Decision`]
207/// vocabulary but narrower: this engine has no `Modify` (rules can't
208/// rewrite an external action) and no `Ask` (the harness path is
209/// synchronous — operator-approval queueing is the K10 surface, not
210/// this one).
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(tag = "decision", rename_all = "snake_case")]
213pub enum Decision {
214    /// Action proceeds. No matching `refuse` rule. There may be
215    /// `warn` / `log` rules that emitted to the audit chain but the
216    /// caller is cleared to proceed.
217    Allow,
218    /// Action refused. `rule_id` names the rule whose matcher fired;
219    /// `reason` is its operator-authored explanation.
220    Refuse { rule_id: String, reason: String },
221    /// Action proceeds with a logged warning. `rule_id` + `reason`
222    /// are present for the audit row but the harness should not
223    /// block.
224    Warn { rule_id: String, reason: String },
225}
226
227impl Decision {
228    /// `true` if the decision blocks the action.
229    #[must_use]
230    pub fn is_refusal(&self) -> bool {
231        matches!(self, Decision::Refuse { .. })
232    }
233
234    /// `true` if the decision permits the action (Allow or Warn).
235    #[must_use]
236    pub fn is_allowed(&self) -> bool {
237        !self.is_refusal()
238    }
239}
240
241// ---------------------------------------------------------------------------
242// Severity — the column type in `governance_rules`
243// ---------------------------------------------------------------------------
244
245/// Per-rule severity. Drives whether a matched rule blocks the
246/// action (`Refuse`), emits a logged warning (`Warn`), or is silent
247/// (`Log`).
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(rename_all = "snake_case")]
250pub enum Severity {
251    Refuse,
252    Warn,
253    Log,
254}
255
256impl Severity {
257    /// Wire string for the `governance_rules.severity` column.
258    #[must_use]
259    pub fn as_str(self) -> &'static str {
260        match self {
261            Severity::Refuse => "refuse",
262            Severity::Warn => "warn",
263            Severity::Log => "log",
264        }
265    }
266
267    /// Parse from the wire string. Returns `None` on unknown values;
268    /// the caller is expected to surface a clear loader error.
269    #[must_use]
270    pub fn from_str(s: &str) -> Option<Severity> {
271        match s {
272            "refuse" => Some(Severity::Refuse),
273            "warn" => Some(Severity::Warn),
274            "log" => Some(Severity::Log),
275            _ => None,
276        }
277    }
278}
279
280// ---------------------------------------------------------------------------
281// Matchers — per-kind JSON evaluators
282// ---------------------------------------------------------------------------
283
284/// Evaluate whether `rule`'s `matcher` JSON applies to `action`.
285///
286/// Per-kind matcher shapes:
287///
288/// | AgentAction          | Matcher JSON shape                                                      |
289/// |----------------------|-------------------------------------------------------------------------|
290/// | `Bash`               | `{"command_substring":"..."}` — literal substring match on `command`    |
291/// | `FilesystemWrite`    | `{"glob":"/tmp/**"}` — tiny glob over `path`                            |
292/// | `NetworkRequest`     | `{"host":"*.evil.example.com"}` — glob host match (plain host = exact)   |
293/// | `ProcessSpawn`       | `{"binary":"cargo","disk_free_min_gib":20,"args_contain":"..."}` — binary + disk + optional argv substring |
294/// | `Custom`             | `{"kind":"<kind>","namespace_glob":"secure/**","tier":"long","title_contains":"..."}` — kind + optional payload predicates (ANDed) |
295///
296/// # Bash field naming (SEC-12 / COR-10, Cluster D, issue #767)
297///
298/// The substring-match field is `command_substring`. The legacy name
299/// `command_regex` is accepted as a SILENT alias for one ship cycle
300/// so existing operator configs continue to load — the engine never
301/// treated the value as a regex (always a literal substring). New
302/// configs MUST use `command_substring`. The CLI loader emits a
303/// deprecation warning when it sees the legacy name. See
304/// [`validate_command_substring`] for the regex-metacharacter
305/// rejection that the CLI add path enforces.
306///
307/// Returns `false` on a kind/matcher mismatch (e.g. a `bash` rule
308/// against a `FilesystemWrite` action) — the caller pre-filters on
309/// `kind` so this should not happen, but the engine is defensive.
310#[must_use]
311pub fn matcher_applies(rule: &Rule, action: &AgentAction) -> bool {
312    if rule.kind != action.kind() {
313        return false;
314    }
315    let Ok(matcher) = serde_json::from_str::<serde_json::Value>(&rule.matcher) else {
316        // Malformed matcher JSON — treat as non-matching rather than
317        // panic. The operator-facing `ai-memory rules add` validates
318        // the JSON at write time so this is a defense-in-depth
319        // fallback.
320        return false;
321    };
322
323    match action {
324        AgentAction::Bash { command, .. } => match_bash(&matcher, command),
325        AgentAction::FilesystemWrite { path, .. } => match_filesystem_write(&matcher, path),
326        AgentAction::NetworkRequest { host, .. } => match_network_request(&matcher, host),
327        AgentAction::ProcessSpawn { binary, args } => match_process_spawn(&matcher, binary, args),
328        AgentAction::Custom {
329            custom_kind,
330            payload,
331        } => match_custom(&matcher, custom_kind, payload),
332    }
333}
334
335/// SEC-12 (Cluster D, issue #767) — operator-facing validator for
336/// the `command_substring` matcher value. Rejects any regex
337/// metacharacter the field name (`command_regex` pre-rename) used to
338/// suggest the engine supported — `. * + ? [ ] ( ) ^ $ |`. The
339/// engine has always treated the value as a literal substring; the
340/// validator catches an operator who pastes a real regex expecting
341/// it to work and would otherwise silently produce a never-matching
342/// rule (e.g. `rm\s+-rf` is never a substring of `rm -rf /`).
343///
344/// Backslash is permitted (Windows paths, escape sequences in
345/// operator-authored shell snippets) but a backslash followed by a
346/// regex metacharacter is still flagged — the operator likely meant
347/// "literal `.`" expecting the engine to honour the escape, which it
348/// does not.
349///
350/// # Errors
351///
352/// Returns `Err(String)` describing the offending character (and
353/// position) for the operator-facing CLI message. The caller surfaces
354/// the error verbatim to stderr + exits non-zero.
355pub fn validate_command_substring(value: &str) -> Result<(), String> {
356    if value.is_empty() {
357        return Err("command_substring must not be empty".to_string());
358    }
359    // Regex metacharacters the legacy `command_regex` name suggested
360    // the engine honoured. The engine has always done substring; the
361    // validator catches the operator misuse.
362    const FORBIDDEN: &[char] = &['.', '*', '+', '?', '[', ']', '(', ')', '^', '$', '|', '\\'];
363    if let Some(pos) = value.find(|c: char| FORBIDDEN.contains(&c)) {
364        let offending = value.as_bytes()[pos] as char;
365        return Err(format!(
366            "command_substring rejects regex metacharacter {offending:?} at byte {pos}: \
367             the matcher is a LITERAL substring match (despite the legacy `command_regex` \
368             field name). Quote the literal text you want to match, e.g. `\"rm -rf\"` \
369             rather than `\"rm\\s+-rf\"`. If you need true regex semantics, file an issue \
370             — the engine will gain a typed `command_regex` discriminator in a future ship."
371        ));
372    }
373    Ok(())
374}
375
376fn match_bash(matcher: &serde_json::Value, command: &str) -> bool {
377    // SEC-12 (Cluster D, issue #767) — accept the new canonical
378    // `command_substring` AND the legacy alias `command_regex` so
379    // existing operator configs continue to load through the ship
380    // cycle that renames the field. New configs MUST use
381    // `command_substring`; the CLI add path warns when it sees the
382    // legacy name.
383    let needle = matcher
384        .get(MATCHER_COMMAND_SUBSTRING)
385        .or_else(|| matcher.get(MATCHER_COMMAND_REGEX))
386        .and_then(|v| v.as_str());
387    let Some(needle) = needle else {
388        return false;
389    };
390    // The matcher value is a LITERAL substring (never a regex —
391    // despite the legacy field name). The CLI add path validates
392    // operator-supplied values with [`validate_command_substring`].
393    command.contains(needle)
394}
395
396fn match_filesystem_write(matcher: &serde_json::Value, path: &std::path::Path) -> bool {
397    let Some(glob) = matcher.get("glob").and_then(|v| v.as_str()) else {
398        return false;
399    };
400    let path_str = path.to_string_lossy();
401    crate::governance::glob_matches(glob, &path_str)
402}
403
404fn match_network_request(matcher: &serde_json::Value, host: &str) -> bool {
405    let Some(target_host) = matcher.get("host").and_then(|v| v.as_str()) else {
406        return false;
407    };
408    // Glob match on host (same engine as the filesystem `glob` matcher).
409    // A plain host with no `*` matches exactly — so pre-existing exact-host
410    // rules are unchanged — while `*.example.com`-style patterns now fire
411    // as the operator intended. Pre-fix this was a literal `==`, so a glob
412    // host pattern silently never matched: a DENY rule written as
413    // `{"host":"*.evil.example.com"}` would fail-OPEN, letting every
414    // subdomain through the gate. Hostnames contain no `/`, so the
415    // single-`*` (segment-bounded) and `**` (cross-segment) forms behave
416    // identically here.
417    crate::governance::glob_matches(target_host, host)
418}
419
420fn match_process_spawn(matcher: &serde_json::Value, binary: &str, args: &[String]) -> bool {
421    let Some(target_binary) = matcher.get("binary").and_then(|v| v.as_str()) else {
422        return false;
423    };
424    if target_binary != binary {
425        return false;
426    }
427    // SEC-13 (Cluster D, issue #767) — optional `args_contain`
428    // matcher. When present, the rule fires ONLY if the joined argv
429    // tail (space-separated, lossy String) contains the substring.
430    // Same literal-substring contract as the bash matcher — full
431    // regex is intentionally out of scope.
432    if let Some(needle) = matcher.get("args_contain").and_then(|v| v.as_str()) {
433        let joined = args.join(" ");
434        if !joined.contains(needle) {
435            return false;
436        }
437    }
438    // Optional `disk_free_min_gib`: refuse spawn when free disk on
439    // the working volume drops below the threshold. The engine
440    // probes `/` (root volume) via `statvfs`-equivalent and converts
441    // to GiB. If the probe fails, we treat the rule as NOT matching
442    // (avoid spurious refusals on systems where the probe is
443    // unsupported); the caller can layer a stricter "refuse on
444    // probe failure" policy later.
445    if let Some(threshold) = matcher
446        .get("disk_free_min_gib")
447        .and_then(serde_json::Value::as_u64)
448    {
449        let free_gib = match disk_free_gib_at_root() {
450            Some(g) => g,
451            None => return false,
452        };
453        return free_gib < threshold;
454    }
455    true
456}
457
458fn match_custom(matcher: &serde_json::Value, kind: &str, payload: &serde_json::Value) -> bool {
459    let Some(target_kind) = matcher.get("kind").and_then(|v| v.as_str()) else {
460        return false;
461    };
462    if target_kind != kind {
463        return false;
464    }
465    // #1457 (SEC, MED-HIGH) — optional payload predicates. Before this
466    // change a `custom` rule could only key off the opaque `kind`
467    // string, so an operator could not write a governance rule that
468    // refuses, e.g., long-tier writes into a protected namespace even
469    // though the substrate pre-write hook already publishes
470    // `namespace`/`tier`/`memory_kind`/`title` in the Custom payload
471    // (see `bootstrap_serve`'s GOVERNANCE_PRE_WRITE closure). Each
472    // predicate below is ANDed with the others; an absent predicate is
473    // simply not constraining. All predicates must match for the rule
474    // to fire. A predicate that references a field missing from the
475    // payload makes the rule NOT match (fail-safe: the rule can only
476    // *refuse* a write it can positively identify).
477
478    // `namespace_glob`: glob over the payload `namespace` string,
479    // reusing the same engine as the FilesystemWrite `glob` matcher.
480    if let Some(ns_glob) = matcher.get("namespace_glob").and_then(|v| v.as_str()) {
481        let Some(ns) = payload.get("namespace").and_then(|v| v.as_str()) else {
482            return false;
483        };
484        if !crate::governance::glob_matches(ns_glob, ns) {
485            return false;
486        }
487    }
488
489    // `tier`: exact match on the payload `tier` string (e.g. "long").
490    if let Some(target_tier) = matcher.get("tier").and_then(|v| v.as_str()) {
491        let Some(tier) = payload.get("tier").and_then(|v| v.as_str()) else {
492            return false;
493        };
494        if target_tier != tier {
495            return false;
496        }
497    }
498
499    // `title_contains`: literal substring over the payload `title`,
500    // same contract as the bash/process-spawn substring matchers (no
501    // regex by design).
502    if let Some(needle) = matcher.get("title_contains").and_then(|v| v.as_str()) {
503        let Some(title) = payload.get("title").and_then(|v| v.as_str()) else {
504            return false;
505        };
506        if !title.contains(needle) {
507            return false;
508        }
509    }
510
511    true
512}
513
514/// Probe free disk space at `/` in GiB. Returns `None` when the
515/// platform does not expose the `statvfs` API or the call fails.
516/// Used by [`match_process_spawn`] to evaluate the
517/// `disk_free_min_gib` threshold on R004 (cargo refused on low-disk
518/// system).
519#[must_use]
520fn disk_free_gib_at_root() -> Option<u64> {
521    disk_free_gib_at_path(std::path::Path::new("/"))
522}
523
524/// Probe free disk space at `path` in GiB. Pulled out as a function
525/// so tests can exercise the conversion logic against a known path
526/// without depending on the root filesystem layout.
527#[cfg(unix)]
528fn disk_free_gib_at_path(path: &std::path::Path) -> Option<u64> {
529    use std::ffi::CString;
530    use std::os::unix::ffi::OsStrExt;
531
532    let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
533    // SAFETY: `statvfs` reads through the C-string pointer and
534    // writes to the libc::statvfs struct passed by mutable reference.
535    // The struct is zeroed first; the pointer outlives the call.
536    let mut buf: libc::statvfs = unsafe { std::mem::zeroed() };
537    // SAFETY: `c_path.as_ptr()` is a valid NUL-terminated C string
538    // for the duration of the call; `&mut buf` is a valid mutable
539    // reference. The call writes to `buf` and returns 0 on success.
540    let rc = unsafe { libc::statvfs(c_path.as_ptr(), &raw mut buf) };
541    if rc != 0 {
542        return None;
543    }
544    // Free blocks for unprivileged users × fragment size = free bytes.
545    let free_bytes = u64::from(buf.f_bavail).saturating_mul(u64::from(buf.f_frsize));
546    Some(free_bytes / (1024 * 1024 * 1024))
547}
548
549/// Windows / wasm / other-target stub. The seed rule R004 is a
550/// no-op on these targets (the `cargo` refusal is a unix-host
551/// concern; CI on Windows has its own disk discipline).
552#[cfg(not(unix))]
553fn disk_free_gib_at_path(_path: &std::path::Path) -> Option<u64> {
554    None
555}
556
557// ---------------------------------------------------------------------------
558// RuleEngine — unified rule-load + decision-routing core (issue #850)
559// ---------------------------------------------------------------------------
560
561/// Refactor Wave-2 Tier-A2 (issue #850) — unified rule engine consumed
562/// by every governance entry point.
563///
564/// Before this refactor each of the three callsites that consult
565/// `governance_rules` (the substrate `GOVERNANCE_PRE_WRITE` hook, the
566/// `wire_check` agent-external hook, and the audited `check_agent_action`
567/// MCP / CLI surface) duplicated the rule-load + first-refusal-wins
568/// loop in its own function (`check_agent_action`,
569/// `check_agent_action_no_audit`, `check_agent_action_deferred`).
570/// Adding a new severity variant or matcher field meant touching three
571/// near-identical loops. The `RuleEngine` collapses the load + routing
572/// logic into one place; the three legacy free functions remain as
573/// thin wrappers so the public API is wire-stable.
574///
575/// `rules` holds the snapshot of enabled rules of the *target kind*
576/// (the engine is constructed per-action, not per-table — kind-scoped
577/// loading matches the existing `list_enabled_by_kind` shape and
578/// preserves the signature-verification side effects in
579/// [`crate::governance::rules_store::list_enabled_by_kind`]).
580///
581/// The combinator is **first-refusal-wins** with `warn` falling
582/// through and `log` being silent — identical semantics to the
583/// pre-refactor inline loops.
584pub struct RuleEngine {
585    /// `Arc<Vec<Rule>>` so the per-instance [`RuleCache`] (#991) can
586    /// share a snapshot across many `load_for_action_cached` calls
587    /// without cloning the row data. Cache miss / un-cached path
588    /// wraps a fresh `Vec` in `Arc::new`; cache hits clone the
589    /// `Arc` (refcount bump, no row data copy).
590    rules: Arc<Vec<Rule>>,
591}
592
593impl RuleEngine {
594    /// Construct an engine scoped to a single `AgentAction`'s kind
595    /// without consulting any cache. Equivalent to
596    /// `load_for_action_cached(conn, None, action)`.
597    ///
598    /// Reads the enabled rule rows of matching `kind` from
599    /// `governance_rules` via
600    /// [`crate::governance::rules_store::list_enabled_by_kind`]; the
601    /// signature-verification gate (L1-6 bypass-impossibility
602    /// invariant) runs inside that helper and is preserved verbatim.
603    ///
604    /// # Errors
605    ///
606    /// Propagates any SQLite error from `list_enabled_by_kind`.
607    pub fn load_for_action(conn: &Connection, action: &AgentAction) -> Result<Self> {
608        Self::load_for_action_cached(conn, None, action)
609    }
610
611    /// Cached variant of [`Self::load_for_action`] (#991).
612    ///
613    /// When `cache` is `Some`, consults the per-instance [`RuleCache`]
614    /// — cache hit returns the cached `Arc<Vec<Rule>>` without
615    /// re-running the SQL + Ed25519-verify path; cache miss loads via
616    /// [`crate::governance::rules_store::list_enabled_by_kind`] and
617    /// inserts. When `cache` is `None`, behaves exactly like
618    /// [`Self::load_for_action`] (no cache consultation, fresh load).
619    ///
620    /// # Errors
621    ///
622    /// Propagates any SQLite error from `list_enabled_by_kind`.
623    pub fn load_for_action_cached(
624        conn: &Connection,
625        cache: Option<&RuleCache>,
626        action: &AgentAction,
627    ) -> Result<Self> {
628        let kind = action.kind();
629        let rules = if let Some(c) = cache {
630            c.get_or_load(conn, kind).with_context(|| {
631                format!("RuleEngine::load_for_action_cached: get_or_load({kind})")
632            })?
633        } else {
634            let v = crate::governance::rules_store::list_enabled_by_kind(conn, kind).with_context(
635                || format!("RuleEngine::load_for_action: list_enabled_by_kind({kind})"),
636            )?;
637            Arc::new(v)
638        };
639        Ok(Self { rules })
640    }
641
642    /// Construct an engine directly from a pre-loaded rules slice.
643    /// Useful for tests that want to skip the SQLite round-trip or
644    /// for future callsites that already hold a cached rule list.
645    #[must_use]
646    pub fn from_rules(rules: Vec<Rule>) -> Self {
647        Self {
648            rules: Arc::new(rules),
649        }
650    }
651
652    /// Evaluate `action` against the loaded rules. Returns the
653    /// first-refusal-wins [`Decision`].
654    ///
655    /// `agent_id` is unused by the matcher today but threaded through
656    /// so future agent-scoped matchers (operator allow-lists, agent
657    /// quotas) can consult it without an API break.
658    #[must_use]
659    pub fn evaluate(&self, _agent_id: &str, action: &AgentAction) -> Decision {
660        let mut first_warn: Option<(String, String)> = None;
661        for rule in self.rules.iter() {
662            if !matcher_applies(rule, action) {
663                continue;
664            }
665            let severity = Severity::from_str(&rule.severity).unwrap_or(Severity::Log);
666            match severity {
667                Severity::Refuse => {
668                    return Decision::Refuse {
669                        rule_id: rule.id.clone(),
670                        reason: rule.reason.clone(),
671                    };
672                }
673                Severity::Warn => {
674                    if first_warn.is_none() {
675                        first_warn = Some((rule.id.clone(), rule.reason.clone()));
676                    }
677                }
678                Severity::Log => {
679                    // Log-only: silent in the engine. Audited entry
680                    // points still emit the final decision's signed
681                    // event below; per-log emission would amplify.
682                }
683            }
684        }
685        match first_warn {
686            Some((rule_id, reason)) => Decision::Warn { rule_id, reason },
687            None => Decision::Allow,
688        }
689    }
690
691    /// Borrow the loaded rule slice. Used by [`count_matching_rules`]
692    /// and by tests that want to assert load-side behaviour without
693    /// running the matcher.
694    #[must_use]
695    pub fn rules(&self) -> &[Rule] {
696        &self.rules
697    }
698}
699
700// ---------------------------------------------------------------------------
701// check_agent_action — the public entry point
702// ---------------------------------------------------------------------------
703
704/// Evaluate `action` against every enabled rule of matching kind in
705/// the `governance_rules` table and return a [`Decision`].
706///
707/// Thin wrapper over [`RuleEngine::load_for_action`] +
708/// [`RuleEngine::evaluate`]; the audit-emit side effect is the only
709/// reason this entry point exists distinct from the `_no_audit`
710/// variant.
711///
712/// The combinator is **first-refusal wins**: as soon as a `refuse`
713/// rule matches, the engine returns `Refuse` and stops scanning
714/// (subsequent matches are not evaluated). If no `refuse` rule
715/// matches, the engine returns the first `warn` match (or `Allow`
716/// if none).
717///
718/// Every call — refusal AND allow — emits one row to the
719/// `signed_events` audit table with `event_type =
720/// "governance.check"` and `payload_hash` over the canonical
721/// representation of (action, decision). This is the load-bearing
722/// audit chain for the v1.0 procurement review.
723///
724/// # Errors
725///
726/// Returns an error if the SQLite query fails or the audit emit
727/// fails. A serde encoding error on `canonical_bytes` is propagated.
728///
729/// # Examples
730///
731/// ```ignore
732/// # use ai_memory::governance::agent_action::{AgentAction, Decision, check_agent_action};
733/// # use rusqlite::Connection;
734/// let conn: Connection = todo!();
735/// let action = AgentAction::FilesystemWrite {
736///     path: "/tmp/foo".into(),
737///     byte_estimate: None,
738/// };
739/// let decision = check_agent_action(&conn, "agent:test", &action)?;
740/// match decision {
741///     Decision::Refuse { rule_id, reason } => {
742///         eprintln!("refused by {rule_id}: {reason}");
743///     }
744///     _ => { /* proceed */ }
745/// }
746/// # Ok::<_, anyhow::Error>(())
747/// ```
748pub fn check_agent_action(
749    conn: &Connection,
750    agent_id: &str,
751    action: &AgentAction,
752) -> Result<Decision> {
753    check_agent_action_cached(conn, None, agent_id, action)
754}
755
756/// Cached variant of [`check_agent_action`] (#991).
757///
758/// `cache: Some(...)` consults the per-instance [`RuleCache`] (cache
759/// hit returns the cached `Arc<Vec<Rule>>` without re-running the SQL
760/// + Ed25519-verify path). `cache: None` behaves exactly like
761/// [`check_agent_action`] (no cache consultation).
762///
763/// # Errors
764///
765/// Returns an error if the SQLite query fails or the audit emit fails.
766pub fn check_agent_action_cached(
767    conn: &Connection,
768    cache: Option<&RuleCache>,
769    agent_id: &str,
770    action: &AgentAction,
771) -> Result<Decision> {
772    let engine = RuleEngine::load_for_action_cached(conn, cache, action).with_context(|| {
773        format!(
774            "check_agent_action_cached: load engine for {}",
775            action.kind()
776        )
777    })?;
778    let decision = engine.evaluate(agent_id, action);
779    emit_check_event(conn, agent_id, action, &decision)?;
780    emit_forensic_decision(agent_id, action, &decision);
781    Ok(decision)
782}
783
784/// v0.7.0 #697 — translate a `(action, decision)` into the forensic
785/// log shape and emit. No-op when the forensic sink is uninitialised.
786fn emit_forensic_decision(agent_id: &str, action: &AgentAction, decision: &Decision) {
787    let (decision_str, rule_id) = match decision {
788        Decision::Allow => ("allow", String::new()),
789        Decision::Refuse { rule_id, .. } => ("refuse", rule_id.clone()),
790        Decision::Warn { rule_id, .. } => ("warn", rule_id.clone()),
791    };
792    // payload is `{action, decision_detail}` — keeps the forensic row
793    // self-describing without depending on cross-table joins for a
794    // SIEM walking the chain.
795    let payload = serde_json::json!({
796        "action": action,
797        "decision_detail": decision,
798    });
799    crate::governance::audit::record_decision(
800        agent_id,
801        decision_str,
802        action.kind(),
803        &rule_id,
804        payload,
805    );
806}
807
808/// Append a `governance.check` row to `signed_events`. Helper so
809/// every exit point in [`check_agent_action`] is symmetric (audit
810/// chain is otherwise lossy on the Refuse short-circuit path).
811fn emit_check_event(
812    conn: &Connection,
813    agent_id: &str,
814    action: &AgentAction,
815    decision: &Decision,
816) -> Result<()> {
817    // Canonical representation: serialize {action, decision} as a
818    // stable JSON object and hash it. A future format-agility
819    // change recomputes the hash over a different canonical
820    // encoding without touching the call sites.
821    let canonical = serde_json::json!({
822        "action": action,
823        "decision": decision,
824    });
825    let bytes =
826        serde_json::to_vec(&canonical).context("emit_check_event: serialize canonical payload")?;
827    let hash = payload_hash(&bytes);
828    // v0.7.0 #1035 — sign the payload_hash with the daemon's
829    // process-wide audit key when one is installed. When `init` ran
830    // with `signing_key: None` (no key on disk), the helper returns
831    // `None` and we fall through to the legacy unsigned posture.
832    let (signature, attest_level) = match crate::governance::audit::try_sign_audit_payload(&hash) {
833        Some((sig, level)) => (Some(sig), level.to_string()),
834        None => (
835            None,
836            crate::models::AttestLevel::Unsigned.as_str().to_string(),
837        ),
838    };
839    let event = crate::signed_events::SignedEvent {
840        id: uuid::Uuid::new_v4().to_string(),
841        agent_id: agent_id.to_string(),
842        event_type: GOVERNANCE_CHECK_EVENT_TYPE.to_string(),
843        payload_hash: hash,
844        signature,
845        attest_level,
846        timestamp: chrono::Utc::now().to_rfc3339(),
847        ..crate::signed_events::SignedEvent::default()
848    };
849    append_signed_event(conn, &event).context("emit_check_event: append_signed_event")?;
850    Ok(())
851}
852
853/// v0.7.0 L1-6 Deliverable E — read-only variant of [`check_agent_action`]
854/// suitable for the substrate pre-write hook path.
855///
856/// Identical to [`check_agent_action`] except it does NOT emit a
857/// `governance.check` row to `signed_events`. Two reasons the
858/// pre-write hook can't use the full audit path:
859///
860///   1. Re-entrancy. The hook fires INSIDE `storage::insert` —
861///      i.e. while the caller already holds the substrate's
862///      `Connection`. Calling `append_signed_event` on a sibling
863///      connection would race the write lock under WAL; calling it
864///      on the same connection would corrupt the in-flight INSERT's
865///      statement state.
866///   2. Symmetry. The substrate-INTERNAL gate path is already
867///      audited at every callsite (handlers/http.rs and mcp/tools/store.rs
868///      both emit an `AuditAction::Store` row on success / a typed
869///      MemoryError on failure). A second emit here would amplify.
870///
871/// First-refusal-wins combinator: same as the audited path. Returns
872/// `Decision::Refuse { rule_id, reason }` for the first `refuse`
873/// match, `Decision::Warn { rule_id, reason }` for the first `warn`
874/// match when no refusal fires, otherwise `Decision::Allow`.
875///
876/// # Errors
877///
878/// Returns an error if the SQLite query for enabled rules fails.
879pub fn check_agent_action_no_audit(conn: &Connection, action: &AgentAction) -> Result<Decision> {
880    check_agent_action_no_audit_cached(conn, None, action)
881}
882
883/// Cached variant of [`check_agent_action_no_audit`] (#991).
884///
885/// `cache: Some(...)` consults the per-instance [`RuleCache`]; `cache:
886/// None` behaves exactly like [`check_agent_action_no_audit`].
887///
888/// # Errors
889///
890/// Returns an error if the rules-table SELECT fails.
891pub fn check_agent_action_no_audit_cached(
892    conn: &Connection,
893    cache: Option<&RuleCache>,
894    action: &AgentAction,
895) -> Result<Decision> {
896    let engine = RuleEngine::load_for_action_cached(conn, cache, action).with_context(|| {
897        format!(
898            "check_agent_action_no_audit_cached: load engine for {}",
899            action.kind()
900        )
901    })?;
902    let decision = engine.evaluate("", action);
903    emit_forensic_decision("", action, &decision);
904    Ok(decision)
905}
906
907/// v0.7.0 Policy-Engine Item 3 — deferred-audit variant of
908/// [`check_agent_action_no_audit`] used by the substrate
909/// `GOVERNANCE_PRE_WRITE` hook (issue #691 follow-up).
910///
911/// Identical matching semantics to [`check_agent_action_no_audit`]:
912/// reads from the connection passed in (single-use, hot-path
913/// no-allocation on the Allow leg). On a refusal it ALSO submits a
914/// [`crate::governance::deferred_audit::DeferredAuditEvent`] to the
915/// supplied queue so the background drainer can chain-log the
916/// refusal to `signed_events` AFTER the in-flight write
917/// transaction has released its lock.
918///
919/// # Why this exists
920///
921/// The `GOVERNANCE_PRE_WRITE` storage hook fires INSIDE
922/// `storage::insert`, while the substrate's writer connection is
923/// held under `Arc<Mutex<Connection>>`. Calling
924/// `append_signed_event` on that same connection would re-enter the
925/// in-flight INSERT and deadlock. The `_no_audit` variant solved
926/// the deadlock but at the cost of dropping the chain-log property
927/// for storage refusals. This variant fixes that by deferring the
928/// audit write to a background tokio task with its OWN
929/// `Connection` (SQLite WAL allows parallel writers).
930///
931/// On Allow / Warn paths the queue is NOT touched — the
932/// load-bearing audit emit only happens on `Refuse`.
933///
934/// # Errors
935///
936/// Returns an error if the rules-table SELECT fails. The deferred
937/// audit submit is fire-and-forget (it never errors out to the
938/// caller; a closed receiver bumps a metric counter and emits a
939/// tracing::warn).
940pub fn check_agent_action_deferred(
941    conn: &Connection,
942    agent_id: &str,
943    action: &AgentAction,
944    queue: &crate::governance::deferred_audit::DeferredAuditQueue,
945) -> Result<Decision> {
946    check_agent_action_deferred_cached(conn, None, agent_id, action, queue)
947}
948
949/// Cached variant of [`check_agent_action_deferred`] (#991).
950///
951/// `cache: Some(...)` consults the per-instance [`RuleCache`]; `cache:
952/// None` behaves exactly like [`check_agent_action_deferred`]. This is
953/// the hot-path entry point used by the substrate
954/// `GOVERNANCE_PRE_WRITE` storage hook — passing a cache here is the
955/// load-bearing win that recovers the original #983 0.5-3ms-per-write
956/// gain without the cross-connection poisoning that triggered the
957/// #990 revert.
958///
959/// # Errors
960///
961/// Returns an error if the rules-table SELECT fails.
962pub fn check_agent_action_deferred_cached(
963    conn: &Connection,
964    cache: Option<&RuleCache>,
965    agent_id: &str,
966    action: &AgentAction,
967    queue: &crate::governance::deferred_audit::DeferredAuditQueue,
968) -> Result<Decision> {
969    let decision = check_agent_action_no_audit_cached(conn, cache, action)?;
970    if decision.is_refusal() {
971        queue.submit_refusal(agent_id, action, &decision);
972    }
973    Ok(decision)
974}
975
976/// Convenience for tests + the future K10 wiring: count how many
977/// rules match the given action without running side effects.
978/// Skips the audit emit (read-only).
979///
980/// # Errors
981///
982/// Returns an error if the SQLite query fails.
983pub fn count_matching_rules(conn: &Connection, action: &AgentAction) -> Result<usize> {
984    let engine = RuleEngine::load_for_action(conn, action)
985        .with_context(|| format!("count_matching_rules: load engine for {}", action.kind()))?;
986    Ok(engine
987        .rules()
988        .iter()
989        .filter(|r| matcher_applies(r, action))
990        .count())
991}
992
993/// Read-side helper: return the most-recent `governance.check`
994/// audit row for `agent_id` (or any agent when `agent_id` is None).
995/// Used by the MCP `rule_list` tool to surface "last check" info
996/// in the operator UI.
997///
998/// # Errors
999///
1000/// Returns an error if the SQLite query fails.
1001pub fn most_recent_check(conn: &Connection, agent_id: Option<&str>) -> Result<Option<String>> {
1002    let row: Option<String> = if let Some(aid) = agent_id {
1003        conn.query_row(
1004            "SELECT timestamp FROM signed_events \
1005             WHERE event_type = ?1 AND agent_id = ?2 \
1006             ORDER BY timestamp DESC LIMIT 1",
1007            rusqlite::params![GOVERNANCE_CHECK_EVENT_TYPE, aid],
1008            |r| r.get::<_, String>(0),
1009        )
1010        .optional()?
1011    } else {
1012        conn.query_row(
1013            "SELECT timestamp FROM signed_events \
1014             WHERE event_type = ?1 \
1015             ORDER BY timestamp DESC LIMIT 1",
1016            rusqlite::params![GOVERNANCE_CHECK_EVENT_TYPE],
1017            |r| r.get::<_, String>(0),
1018        )
1019        .optional()?
1020    };
1021    Ok(row)
1022}
1023
1024// ---------------------------------------------------------------------------
1025// Tests
1026// ---------------------------------------------------------------------------
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031    use crate::governance::rules_store;
1032
1033    /// Build a fresh in-memory connection with the governance_rules
1034    /// table and the signed_events table — the engine's only two
1035    /// dependencies. Avoids pulling in the full migration ladder
1036    /// (which would also drag in fts5 / hnsw / etc.).
1037    fn fresh_conn() -> Connection {
1038        let conn = Connection::open_in_memory().unwrap();
1039        conn.execute_batch(
1040            "CREATE TABLE governance_rules (
1041                 id TEXT PRIMARY KEY,
1042                 kind TEXT NOT NULL,
1043                 matcher TEXT NOT NULL,
1044                 severity TEXT NOT NULL,
1045                 reason TEXT NOT NULL,
1046                 namespace TEXT NOT NULL DEFAULT '_global',
1047                 created_by TEXT NOT NULL,
1048                 created_at INTEGER NOT NULL,
1049                 enabled INTEGER NOT NULL DEFAULT 1,
1050                 signature BLOB,
1051                 attest_level TEXT NOT NULL DEFAULT 'unsigned'
1052             );
1053             CREATE TABLE signed_events (
1054                 id TEXT PRIMARY KEY,
1055                 agent_id TEXT NOT NULL,
1056                 event_type TEXT NOT NULL,
1057                 payload_hash BLOB NOT NULL,
1058                 signature BLOB,
1059                 attest_level TEXT NOT NULL DEFAULT 'unsigned',
1060                 timestamp TEXT NOT NULL,
1061                 -- v34 (V-4 closeout, #698) — cross-row chain columns.
1062                 prev_hash BLOB,
1063                 sequence INTEGER
1064             );",
1065        )
1066        .unwrap();
1067        conn
1068    }
1069
1070    /// Issue #819 — short alias for the test-only thread-local guard
1071    /// that forces [`rules_store::resolve_operator_pubkey`] to return
1072    /// `None`. Tests that insert unsigned rules and expect
1073    /// `check_agent_action` to honor them must hold this guard for
1074    /// their full body, otherwise on dev hosts with a real
1075    /// `operator.key.pub` staged at the platform config path the
1076    /// L1-6 signature gate will skip the unsigned fixtures and the
1077    /// assertions will fail (test failures don't reproduce on
1078    /// clean-HOME CI; the guard makes the local dev loop match CI).
1079    #[must_use = "the guard must be held for the scope of the test"]
1080    fn no_operator_pubkey() -> rules_store::ForceNoOperatorPubkeyGuard {
1081        rules_store::force_no_operator_pubkey_for_test()
1082    }
1083
1084    /// Issue #899 — guard against cross-test forensic-sink bleed.
1085    ///
1086    /// Every test that calls [`check_agent_action`] (or
1087    /// [`check_agent_action_no_audit`]) indirectly fires
1088    /// [`crate::governance::audit::record_decision`] via
1089    /// [`emit_forensic_decision`]. If a sibling test in
1090    /// `governance::audit::tests` has just initialised the
1091    /// process-wide forensic sink at its tempdir, this thread's
1092    /// `record_decision` would land a row in that sibling's
1093    /// tempdir — bleeding the sibling's row count.
1094    ///
1095    /// Tests that exercise `check_agent_action*` MUST hold this
1096    /// lock for the duration of the call. The lock is the same
1097    /// `OnceLock<Mutex<()>>` `audit::tests` uses, so the two
1098    /// modules now serialise their access to the shared sink.
1099    /// Acquire pattern mirrors `no_operator_pubkey`:
1100    ///
1101    /// ```ignore
1102    /// let _forensic = forensic_lock();
1103    /// let _no_pubkey = no_operator_pubkey();
1104    /// let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
1105    /// ```
1106    #[must_use = "the guard must be held for the scope of the test"]
1107    fn forensic_lock() -> std::sync::MutexGuard<'static, ()> {
1108        crate::governance::audit::forensic_sink_test_lock()
1109            .lock()
1110            .unwrap_or_else(|e| e.into_inner())
1111    }
1112
1113    fn add_rule(
1114        conn: &Connection,
1115        id: &str,
1116        kind: &str,
1117        matcher: &str,
1118        severity: &str,
1119        enabled: bool,
1120    ) {
1121        rules_store::insert(
1122            conn,
1123            &Rule {
1124                id: id.to_string(),
1125                kind: kind.to_string(),
1126                matcher: matcher.to_string(),
1127                severity: severity.to_string(),
1128                reason: format!("{id}: test"),
1129                namespace: "_global".to_string(),
1130                created_by: "test".to_string(),
1131                created_at: 0,
1132                enabled,
1133                signature: None,
1134                attest_level: crate::models::AttestLevel::Unsigned.as_str().to_string(),
1135            },
1136        )
1137        .unwrap();
1138    }
1139
1140    #[test]
1141    fn agent_action_kind_strings_are_stable() {
1142        assert_eq!(
1143            AgentAction::Bash {
1144                command: "ls".into(),
1145                cwd: None
1146            }
1147            .kind(),
1148            "bash"
1149        );
1150        assert_eq!(
1151            AgentAction::FilesystemWrite {
1152                path: "/x".into(),
1153                byte_estimate: None
1154            }
1155            .kind(),
1156            "filesystem_write"
1157        );
1158        assert_eq!(
1159            AgentAction::NetworkRequest {
1160                host: "h".into(),
1161                scheme: "https".into()
1162            }
1163            .kind(),
1164            "network_request"
1165        );
1166        assert_eq!(
1167            AgentAction::ProcessSpawn {
1168                binary: "b".into(),
1169                args: vec![]
1170            }
1171            .kind(),
1172            "process_spawn"
1173        );
1174        assert_eq!(
1175            AgentAction::Custom {
1176                custom_kind: "k".into(),
1177                payload: serde_json::json!({})
1178            }
1179            .kind(),
1180            "custom"
1181        );
1182    }
1183
1184    #[test]
1185    fn severity_roundtrip() {
1186        for s in &[Severity::Refuse, Severity::Warn, Severity::Log] {
1187            assert_eq!(Severity::from_str(s.as_str()), Some(*s));
1188        }
1189        assert_eq!(Severity::from_str("nope"), None);
1190    }
1191
1192    #[test]
1193    fn allow_when_no_rule_matches() {
1194        let _forensic = forensic_lock();
1195        let conn = fresh_conn();
1196        let action = AgentAction::Bash {
1197            command: "ls -la".into(),
1198            cwd: None,
1199        };
1200        let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
1201        assert_eq!(decision, Decision::Allow);
1202        assert!(decision.is_allowed());
1203    }
1204
1205    #[test]
1206    fn refuse_filesystem_write_glob_match() {
1207        let _forensic = forensic_lock();
1208        let _no_pubkey = no_operator_pubkey();
1209        let conn = fresh_conn();
1210        add_rule(
1211            &conn,
1212            "R001",
1213            "filesystem_write",
1214            r#"{"glob":"/tmp/**"}"#,
1215            "refuse",
1216            true,
1217        );
1218        let action = AgentAction::FilesystemWrite {
1219            path: "/tmp/foo.txt".into(),
1220            byte_estimate: None,
1221        };
1222        let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
1223        assert!(decision.is_refusal());
1224        match decision {
1225            Decision::Refuse { rule_id, .. } => assert_eq!(rule_id, "R001"),
1226            _ => panic!("expected refuse"),
1227        }
1228    }
1229
1230    #[test]
1231    fn allow_filesystem_write_outside_glob() {
1232        let _forensic = forensic_lock();
1233        let conn = fresh_conn();
1234        add_rule(
1235            &conn,
1236            "R001",
1237            "filesystem_write",
1238            r#"{"glob":"/tmp/**"}"#,
1239            "refuse",
1240            true,
1241        );
1242        let action = AgentAction::FilesystemWrite {
1243            path: "/Users/foo/safe.txt".into(),
1244            byte_estimate: None,
1245        };
1246        let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
1247        assert_eq!(decision, Decision::Allow);
1248    }
1249
1250    #[test]
1251    fn disabled_rule_does_not_match() {
1252        let _forensic = forensic_lock();
1253        let conn = fresh_conn();
1254        add_rule(
1255            &conn,
1256            "R001",
1257            "filesystem_write",
1258            r#"{"glob":"/tmp/**"}"#,
1259            "refuse",
1260            false, // disabled
1261        );
1262        let action = AgentAction::FilesystemWrite {
1263            path: "/tmp/foo".into(),
1264            byte_estimate: None,
1265        };
1266        let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
1267        assert_eq!(decision, Decision::Allow);
1268    }
1269
1270    #[test]
1271    fn warn_rule_returns_warn_not_refuse() {
1272        let _forensic = forensic_lock();
1273        let _no_pubkey = no_operator_pubkey();
1274        let conn = fresh_conn();
1275        add_rule(
1276            &conn,
1277            "W001",
1278            "bash",
1279            r#"{"command_regex":"rm -rf"}"#,
1280            "warn",
1281            true,
1282        );
1283        let action = AgentAction::Bash {
1284            command: "rm -rf /opt/scratch".into(),
1285            cwd: None,
1286        };
1287        let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
1288        match decision {
1289            Decision::Warn { rule_id, .. } => assert_eq!(rule_id, "W001"),
1290            _ => panic!("expected warn"),
1291        }
1292    }
1293
1294    #[test]
1295    fn refuse_wins_over_warn_when_both_match() {
1296        let _forensic = forensic_lock();
1297        let _no_pubkey = no_operator_pubkey();
1298        let conn = fresh_conn();
1299        add_rule(
1300            &conn,
1301            "W001",
1302            "bash",
1303            r#"{"command_regex":"rm"}"#,
1304            "warn",
1305            true,
1306        );
1307        add_rule(
1308            &conn,
1309            "R900",
1310            "bash",
1311            r#"{"command_regex":"rm -rf /"}"#,
1312            "refuse",
1313            true,
1314        );
1315        let action = AgentAction::Bash {
1316            command: "rm -rf /".into(),
1317            cwd: None,
1318        };
1319        let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
1320        assert!(decision.is_refusal());
1321    }
1322
1323    #[test]
1324    fn process_spawn_binary_match() {
1325        let _forensic = forensic_lock();
1326        let _no_pubkey = no_operator_pubkey();
1327        let conn = fresh_conn();
1328        add_rule(
1329            &conn,
1330            "R-cargo",
1331            "process_spawn",
1332            r#"{"binary":"cargo"}"#,
1333            "refuse",
1334            true,
1335        );
1336        let action = AgentAction::ProcessSpawn {
1337            binary: "cargo".into(),
1338            args: vec!["build".into()],
1339        };
1340        let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
1341        assert!(decision.is_refusal());
1342    }
1343
1344    #[test]
1345    fn process_spawn_binary_mismatch_allows() {
1346        let _forensic = forensic_lock();
1347        let conn = fresh_conn();
1348        add_rule(
1349            &conn,
1350            "R-cargo",
1351            "process_spawn",
1352            r#"{"binary":"cargo"}"#,
1353            "refuse",
1354            true,
1355        );
1356        let action = AgentAction::ProcessSpawn {
1357            binary: "npm".into(),
1358            args: vec!["install".into()],
1359        };
1360        let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
1361        assert_eq!(decision, Decision::Allow);
1362    }
1363
1364    #[test]
1365    fn network_request_exact_host_match() {
1366        let _forensic = forensic_lock();
1367        let _no_pubkey = no_operator_pubkey();
1368        let conn = fresh_conn();
1369        add_rule(
1370            &conn,
1371            "R-evil",
1372            "network_request",
1373            r#"{"host":"evil.example.com"}"#,
1374            "refuse",
1375            true,
1376        );
1377        let action = AgentAction::NetworkRequest {
1378            host: "evil.example.com".into(),
1379            scheme: "https".into(),
1380        };
1381        let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
1382        assert!(decision.is_refusal());
1383
1384        let allow_action = AgentAction::NetworkRequest {
1385            host: "good.example.com".into(),
1386            scheme: "https".into(),
1387        };
1388        let allow_decision = check_agent_action(&conn, "agent:t", &allow_action).unwrap();
1389        assert_eq!(allow_decision, Decision::Allow);
1390    }
1391
1392    // SR — network host matcher glob support. Pre-fix the matcher did a
1393    // literal `==`, so a DENY rule written with a `*.example.com` wildcard
1394    // silently never matched (fail-OPEN): every subdomain sailed past the
1395    // gate. The fix routes the host through `glob_matches`, so the wildcard
1396    // DENY rule now fires on every subdomain while an exact host outside the
1397    // pattern is still allowed.
1398    #[test]
1399    fn network_request_glob_host_match_closes_fail_open() {
1400        let _forensic = forensic_lock();
1401        let _no_pubkey = no_operator_pubkey();
1402        let conn = fresh_conn();
1403        add_rule(
1404            &conn,
1405            "R-evil-glob",
1406            "network_request",
1407            r#"{"host":"*.evil.example.com"}"#,
1408            "refuse",
1409            true,
1410        );
1411
1412        // A subdomain under the wildcard must be refused (pre-fix: allowed).
1413        for sub in ["api.evil.example.com", "c2.evil.example.com"] {
1414            let action = AgentAction::NetworkRequest {
1415                host: sub.into(),
1416                scheme: "https".into(),
1417            };
1418            let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
1419            assert!(
1420                decision.is_refusal(),
1421                "wildcard DENY rule must refuse subdomain {sub}"
1422            );
1423        }
1424
1425        // A host outside the wildcard is still allowed.
1426        let allow_action = AgentAction::NetworkRequest {
1427            host: "good.example.org".into(),
1428            scheme: "https".into(),
1429        };
1430        assert_eq!(
1431            check_agent_action(&conn, "agent:t", &allow_action).unwrap(),
1432            Decision::Allow
1433        );
1434    }
1435
1436    #[test]
1437    fn custom_action_matches_on_kind() {
1438        let _forensic = forensic_lock();
1439        let _no_pubkey = no_operator_pubkey();
1440        let conn = fresh_conn();
1441        add_rule(
1442            &conn,
1443            "R-custom",
1444            "custom",
1445            r#"{"kind":"approve_deploy"}"#,
1446            "refuse",
1447            true,
1448        );
1449        let action = AgentAction::Custom {
1450            custom_kind: "approve_deploy".into(),
1451            payload: serde_json::json!({"env": "prod"}),
1452        };
1453        let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
1454        assert!(decision.is_refusal());
1455    }
1456
1457    // ---- #1457 (SEC, MED-HIGH): custom payload predicates ------------------
1458
1459    /// A `namespace_glob` predicate refuses a matching memory_write and
1460    /// leaves non-matching namespaces alone.
1461    #[test]
1462    fn custom_namespace_glob_predicate_scopes_refusal() {
1463        let _forensic = forensic_lock();
1464        let _no_pubkey = no_operator_pubkey();
1465        let conn = fresh_conn();
1466        add_rule(
1467            &conn,
1468            "R-ns",
1469            "custom",
1470            r#"{"kind":"memory_write","namespace_glob":"secure/**"}"#,
1471            "refuse",
1472            true,
1473        );
1474        let inside = AgentAction::Custom {
1475            custom_kind: "memory_write".into(),
1476            payload: serde_json::json!({"namespace": "secure/keys", "tier": "long"}),
1477        };
1478        assert!(
1479            check_agent_action(&conn, "agent:t", &inside)
1480                .unwrap()
1481                .is_refusal()
1482        );
1483        let outside = AgentAction::Custom {
1484            custom_kind: "memory_write".into(),
1485            payload: serde_json::json!({"namespace": "public/notes", "tier": "long"}),
1486        };
1487        assert_eq!(
1488            check_agent_action(&conn, "agent:t", &outside).unwrap(),
1489            Decision::Allow
1490        );
1491    }
1492
1493    /// `tier` and `title_contains` predicates AND together: the rule
1494    /// fires only when BOTH match.
1495    #[test]
1496    fn custom_tier_and_title_predicates_and_together() {
1497        let _forensic = forensic_lock();
1498        let _no_pubkey = no_operator_pubkey();
1499        let conn = fresh_conn();
1500        add_rule(
1501            &conn,
1502            "R-tt",
1503            "custom",
1504            r#"{"kind":"memory_write","tier":"long","title_contains":"SECRET"}"#,
1505            "refuse",
1506            true,
1507        );
1508        let both = AgentAction::Custom {
1509            custom_kind: "memory_write".into(),
1510            payload: serde_json::json!({"tier": "long", "title": "the SECRET plan"}),
1511        };
1512        assert!(
1513            check_agent_action(&conn, "agent:t", &both)
1514                .unwrap()
1515                .is_refusal()
1516        );
1517        // Right title, wrong tier ⇒ no match.
1518        let wrong_tier = AgentAction::Custom {
1519            custom_kind: "memory_write".into(),
1520            payload: serde_json::json!({"tier": "mid", "title": "the SECRET plan"}),
1521        };
1522        assert_eq!(
1523            check_agent_action(&conn, "agent:t", &wrong_tier).unwrap(),
1524            Decision::Allow
1525        );
1526        // Right tier, title lacks needle ⇒ no match.
1527        let wrong_title = AgentAction::Custom {
1528            custom_kind: "memory_write".into(),
1529            payload: serde_json::json!({"tier": "long", "title": "harmless note"}),
1530        };
1531        assert_eq!(
1532            check_agent_action(&conn, "agent:t", &wrong_title).unwrap(),
1533            Decision::Allow
1534        );
1535    }
1536
1537    /// A predicate referencing a payload field that is absent makes the
1538    /// rule NOT match (fail-safe — a refusal must positively identify
1539    /// its target).
1540    #[test]
1541    fn custom_predicate_missing_payload_field_does_not_match() {
1542        let _forensic = forensic_lock();
1543        let _no_pubkey = no_operator_pubkey();
1544        let conn = fresh_conn();
1545        add_rule(
1546            &conn,
1547            "R-miss",
1548            "custom",
1549            r#"{"kind":"memory_write","namespace_glob":"secure/**"}"#,
1550            "refuse",
1551            true,
1552        );
1553        let no_ns = AgentAction::Custom {
1554            custom_kind: "memory_write".into(),
1555            payload: serde_json::json!({"tier": "long"}),
1556        };
1557        assert_eq!(
1558            check_agent_action(&conn, "agent:t", &no_ns).unwrap(),
1559            Decision::Allow
1560        );
1561    }
1562
1563    /// Backwards-compat: a kind-only `custom` rule still fires
1564    /// regardless of payload contents.
1565    #[test]
1566    fn custom_kind_only_rule_ignores_payload() {
1567        let _forensic = forensic_lock();
1568        let _no_pubkey = no_operator_pubkey();
1569        let conn = fresh_conn();
1570        add_rule(
1571            &conn,
1572            "R-kindonly",
1573            "custom",
1574            r#"{"kind":"memory_write"}"#,
1575            "refuse",
1576            true,
1577        );
1578        let action = AgentAction::Custom {
1579            custom_kind: "memory_write".into(),
1580            payload: serde_json::json!({"namespace": "anything", "tier": "short"}),
1581        };
1582        assert!(
1583            check_agent_action(&conn, "agent:t", &action)
1584                .unwrap()
1585                .is_refusal()
1586        );
1587    }
1588
1589    #[test]
1590    fn check_emits_signed_event() {
1591        let _forensic = forensic_lock();
1592        let conn = fresh_conn();
1593        let action = AgentAction::Bash {
1594            command: "ls".into(),
1595            cwd: None,
1596        };
1597        let _ = check_agent_action(&conn, "agent:test", &action).unwrap();
1598        let count: i64 = conn
1599            .query_row(
1600                "SELECT COUNT(*) FROM signed_events WHERE event_type = ?1 AND agent_id = ?2",
1601                rusqlite::params![GOVERNANCE_CHECK_EVENT_TYPE, "agent:test"],
1602                |r| r.get(0),
1603            )
1604            .unwrap();
1605        assert_eq!(count, 1);
1606    }
1607
1608    #[test]
1609    fn deferred_check_allow_signs_nothing_on_request_thread() {
1610        // Grounds the EXPLAIN-audit slate "Fix #5" premise (move
1611        // audit-chain Ed25519 per-row signing off the request thread):
1612        // the `memory_store` write path's governance gate
1613        // (`storage::GOVERNANCE_PRE_WRITE` -> this fn) performs ZERO
1614        // synchronous signing on an ALLOW verdict. Contrast
1615        // `check_emits_signed_event`, which proves the SYNCHRONOUS
1616        // `check_agent_action` signs + appends a `signed_events` row on
1617        // EVERY check (Allow included) — that path is reached only by the
1618        // CLI `rules check` one-shot and the explicit
1619        // `memory_check_agent_action` tool, never by a memory write.
1620        let _forensic = forensic_lock();
1621        let conn = fresh_conn();
1622        let (queue, _rx) = crate::governance::deferred_audit::DeferredAuditQueue::new();
1623        let action = AgentAction::Custom {
1624            custom_kind: "memory_write".into(),
1625            payload: serde_json::json!({"namespace": "anything", "tier": "short"}),
1626        };
1627        let decision =
1628            check_agent_action_deferred_cached(&conn, None, "agent:hotpath", &action, &queue)
1629                .unwrap();
1630        assert_eq!(decision, Decision::Allow);
1631        // No rule matched -> ALLOW -> NOT a refusal -> nothing enqueued
1632        // to the off-thread drainer either.
1633        assert!(!decision.is_refusal());
1634        // The load-bearing assertion: the request thread wrote ZERO
1635        // signed_events rows. A regression that re-routed the write-path
1636        // gate through the synchronous `emit_check_event` (per-row
1637        // Ed25519 sign + chain INSERT) would make this count == 1.
1638        let count: i64 = conn
1639            .query_row("SELECT COUNT(*) FROM signed_events", [], |r| r.get(0))
1640            .unwrap();
1641        assert_eq!(
1642            count, 0,
1643            "write-path governance gate must not synchronously sign on ALLOW; \
1644             per-row Ed25519 signing belongs off the request thread"
1645        );
1646    }
1647
1648    #[test]
1649    fn refuse_short_circuit_still_emits_event() {
1650        let _forensic = forensic_lock();
1651        let conn = fresh_conn();
1652        add_rule(
1653            &conn,
1654            "R001",
1655            "filesystem_write",
1656            r#"{"glob":"/tmp/**"}"#,
1657            "refuse",
1658            true,
1659        );
1660        let action = AgentAction::FilesystemWrite {
1661            path: "/tmp/x".into(),
1662            byte_estimate: None,
1663        };
1664        let _ = check_agent_action(&conn, "agent:t", &action).unwrap();
1665        let count: i64 = conn
1666            .query_row(
1667                "SELECT COUNT(*) FROM signed_events WHERE event_type = ?1",
1668                rusqlite::params![GOVERNANCE_CHECK_EVENT_TYPE],
1669                |r| r.get(0),
1670            )
1671            .unwrap();
1672        assert_eq!(count, 1);
1673    }
1674
1675    #[test]
1676    fn count_matching_rules_skips_audit() {
1677        let _no_pubkey = no_operator_pubkey();
1678        let conn = fresh_conn();
1679        add_rule(
1680            &conn,
1681            "R1",
1682            "bash",
1683            r#"{"command_regex":"foo"}"#,
1684            "refuse",
1685            true,
1686        );
1687        let action = AgentAction::Bash {
1688            command: "foo bar".into(),
1689            cwd: None,
1690        };
1691        assert_eq!(count_matching_rules(&conn, &action).unwrap(), 1);
1692        // No audit row written by count.
1693        let audit_count: i64 = conn
1694            .query_row("SELECT COUNT(*) FROM signed_events", [], |r| r.get(0))
1695            .unwrap();
1696        assert_eq!(audit_count, 0);
1697    }
1698
1699    #[test]
1700    fn malformed_matcher_does_not_panic() {
1701        let _forensic = forensic_lock();
1702        let conn = fresh_conn();
1703        add_rule(&conn, "R-bad", "bash", "not json", "refuse", true);
1704        let action = AgentAction::Bash {
1705            command: "anything".into(),
1706            cwd: None,
1707        };
1708        let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
1709        assert_eq!(decision, Decision::Allow);
1710    }
1711
1712    #[test]
1713    fn matcher_applies_kind_mismatch_returns_false() {
1714        let rule = Rule {
1715            id: "R".to_string(),
1716            kind: "bash".to_string(),
1717            matcher: r#"{"command_regex":"x"}"#.to_string(),
1718            severity: "refuse".to_string(),
1719            reason: "r".to_string(),
1720            namespace: "_global".to_string(),
1721            created_by: "test".to_string(),
1722            created_at: 0,
1723            enabled: true,
1724            signature: None,
1725            attest_level: crate::models::AttestLevel::Unsigned.as_str().to_string(),
1726        };
1727        let action = AgentAction::FilesystemWrite {
1728            path: "/x".into(),
1729            byte_estimate: None,
1730        };
1731        assert!(!matcher_applies(&rule, &action));
1732    }
1733
1734    #[test]
1735    fn canonical_bytes_includes_kind() {
1736        let a = AgentAction::Bash {
1737            command: "ls".into(),
1738            cwd: None,
1739        };
1740        let bytes = a.canonical_bytes().unwrap();
1741        let s = std::str::from_utf8(&bytes).unwrap();
1742        assert!(s.contains("\"kind\""), "got {s}");
1743        assert!(s.contains("\"bash\""), "got {s}");
1744    }
1745
1746    #[test]
1747    fn most_recent_check_empty_returns_none() {
1748        let conn = fresh_conn();
1749        assert_eq!(most_recent_check(&conn, None).unwrap(), None);
1750        assert_eq!(most_recent_check(&conn, Some("agent:x")).unwrap(), None);
1751    }
1752
1753    #[test]
1754    fn most_recent_check_returns_latest() {
1755        let _forensic = forensic_lock();
1756        let conn = fresh_conn();
1757        let action = AgentAction::Bash {
1758            command: "x".into(),
1759            cwd: None,
1760        };
1761        check_agent_action(&conn, "agent:a", &action).unwrap();
1762        assert!(most_recent_check(&conn, Some("agent:a")).unwrap().is_some());
1763        assert!(most_recent_check(&conn, Some("agent:b")).unwrap().is_none());
1764        assert!(most_recent_check(&conn, None).unwrap().is_some());
1765    }
1766
1767    // -----------------------------------------------------------------
1768    // L1-6 Deliverable E — check_agent_action_no_audit coverage
1769    // (substrate pre-write hook consults this variant; identical
1770    // matching semantics, zero side effects on `signed_events`)
1771    // -----------------------------------------------------------------
1772
1773    #[test]
1774    fn no_audit_allow_when_no_rule_matches() {
1775        let _forensic = forensic_lock();
1776        let conn = fresh_conn();
1777        let action = AgentAction::Bash {
1778            command: "ls".into(),
1779            cwd: None,
1780        };
1781        let decision = check_agent_action_no_audit(&conn, &action).unwrap();
1782        assert_eq!(decision, Decision::Allow);
1783        let audit_count: i64 = conn
1784            .query_row("SELECT COUNT(*) FROM signed_events", [], |r| r.get(0))
1785            .unwrap();
1786        assert_eq!(audit_count, 0, "no_audit variant must not write audit rows");
1787    }
1788
1789    #[test]
1790    fn no_audit_refuses_with_same_shape_as_audited_path() {
1791        let _forensic = forensic_lock();
1792        let _no_pubkey = no_operator_pubkey();
1793        let conn = fresh_conn();
1794        add_rule(
1795            &conn,
1796            "R-test",
1797            "custom",
1798            r#"{"kind":"memory_write"}"#,
1799            "refuse",
1800            true,
1801        );
1802        let action = AgentAction::Custom {
1803            custom_kind: "memory_write".into(),
1804            payload: serde_json::json!({"namespace": "secrets/api"}),
1805        };
1806        let decision = check_agent_action_no_audit(&conn, &action).unwrap();
1807        match decision {
1808            Decision::Refuse { rule_id, reason } => {
1809                assert_eq!(rule_id, "R-test");
1810                assert!(reason.contains("R-test"), "reason: {reason}");
1811            }
1812            other => panic!("expected Refuse, got {other:?}"),
1813        }
1814        let audit_count: i64 = conn
1815            .query_row("SELECT COUNT(*) FROM signed_events", [], |r| r.get(0))
1816            .unwrap();
1817        assert_eq!(audit_count, 0, "refusal in no_audit variant must not write");
1818    }
1819
1820    #[test]
1821    fn no_audit_disabled_rule_yields_allow() {
1822        let _forensic = forensic_lock();
1823        let conn = fresh_conn();
1824        add_rule(
1825            &conn,
1826            "R-disabled",
1827            "custom",
1828            r#"{"kind":"memory_write"}"#,
1829            "refuse",
1830            false,
1831        );
1832        let action = AgentAction::Custom {
1833            custom_kind: "memory_write".into(),
1834            payload: serde_json::json!({}),
1835        };
1836        let decision = check_agent_action_no_audit(&conn, &action).unwrap();
1837        assert_eq!(decision, Decision::Allow);
1838    }
1839
1840    #[test]
1841    fn no_audit_warn_returned_when_no_refuse_matches() {
1842        let _forensic = forensic_lock();
1843        let _no_pubkey = no_operator_pubkey();
1844        let conn = fresh_conn();
1845        add_rule(
1846            &conn,
1847            "W-test",
1848            "custom",
1849            r#"{"kind":"memory_write"}"#,
1850            "warn",
1851            true,
1852        );
1853        let action = AgentAction::Custom {
1854            custom_kind: "memory_write".into(),
1855            payload: serde_json::json!({}),
1856        };
1857        let decision = check_agent_action_no_audit(&conn, &action).unwrap();
1858        match decision {
1859            Decision::Warn { rule_id, .. } => assert_eq!(rule_id, "W-test"),
1860            other => panic!("expected Warn, got {other:?}"),
1861        }
1862    }
1863
1864    #[test]
1865    fn decision_serializes_as_tagged_enum() {
1866        let d = Decision::Refuse {
1867            rule_id: "R1".to_string(),
1868            reason: "no".to_string(),
1869        };
1870        let v = serde_json::to_value(&d).unwrap();
1871        assert_eq!(v["decision"], "refuse");
1872        assert_eq!(v["rule_id"], "R1");
1873        let allow = Decision::Allow;
1874        let av = serde_json::to_value(&allow).unwrap();
1875        assert_eq!(av["decision"], "allow");
1876    }
1877
1878    #[test]
1879    fn matcher_applies_returns_false_on_kind_mismatch() {
1880        let rule = Rule {
1881            id: "R".into(),
1882            kind: "bash".into(),
1883            matcher: r#"{"command_regex":"rm"}"#.into(),
1884            severity: "refuse".into(),
1885            reason: "r".into(),
1886            namespace: "_global".into(),
1887            created_by: "test".into(),
1888            created_at: 0,
1889            enabled: true,
1890            signature: None,
1891            attest_level: "unsigned".into(),
1892        };
1893        let action = AgentAction::FilesystemWrite {
1894            path: "/x".into(),
1895            byte_estimate: None,
1896        };
1897        assert!(!matcher_applies(&rule, &action));
1898    }
1899
1900    #[test]
1901    fn matcher_applies_returns_false_on_malformed_matcher_json() {
1902        let rule = Rule {
1903            id: "R".into(),
1904            kind: "bash".into(),
1905            matcher: "{not valid json".into(),
1906            severity: "refuse".into(),
1907            reason: "r".into(),
1908            namespace: "_global".into(),
1909            created_by: "test".into(),
1910            created_at: 0,
1911            enabled: true,
1912            signature: None,
1913            attest_level: "unsigned".into(),
1914        };
1915        let action = AgentAction::Bash {
1916            command: "ls".into(),
1917            cwd: None,
1918        };
1919        assert!(!matcher_applies(&rule, &action));
1920    }
1921
1922    #[test]
1923    fn matcher_applies_bash_with_missing_field_returns_false() {
1924        let rule = Rule {
1925            id: "R".into(),
1926            kind: "bash".into(),
1927            matcher: r#"{"other_field":"x"}"#.into(),
1928            severity: "refuse".into(),
1929            reason: "r".into(),
1930            namespace: "_global".into(),
1931            created_by: "test".into(),
1932            created_at: 0,
1933            enabled: true,
1934            signature: None,
1935            attest_level: "unsigned".into(),
1936        };
1937        let action = AgentAction::Bash {
1938            command: "ls".into(),
1939            cwd: None,
1940        };
1941        assert!(!matcher_applies(&rule, &action));
1942    }
1943
1944    #[test]
1945    fn matcher_applies_network_request_exact_host() {
1946        let rule = Rule {
1947            id: "R".into(),
1948            kind: "network_request".into(),
1949            matcher: r#"{"host":"evil.example.com"}"#.into(),
1950            severity: "refuse".into(),
1951            reason: "r".into(),
1952            namespace: "_global".into(),
1953            created_by: "test".into(),
1954            created_at: 0,
1955            enabled: true,
1956            signature: None,
1957            attest_level: "unsigned".into(),
1958        };
1959        let evil = AgentAction::NetworkRequest {
1960            host: "evil.example.com".into(),
1961            scheme: "https".into(),
1962        };
1963        let good = AgentAction::NetworkRequest {
1964            host: "good.example.com".into(),
1965            scheme: "https".into(),
1966        };
1967        assert!(matcher_applies(&rule, &evil));
1968        assert!(!matcher_applies(&rule, &good));
1969    }
1970
1971    #[test]
1972    fn matcher_applies_process_spawn_with_binary_only() {
1973        let rule = Rule {
1974            id: "R".into(),
1975            kind: "process_spawn".into(),
1976            matcher: r#"{"binary":"cargo"}"#.into(),
1977            severity: "refuse".into(),
1978            reason: "r".into(),
1979            namespace: "_global".into(),
1980            created_by: "test".into(),
1981            created_at: 0,
1982            enabled: true,
1983            signature: None,
1984            attest_level: "unsigned".into(),
1985        };
1986        let cargo = AgentAction::ProcessSpawn {
1987            binary: "cargo".into(),
1988            args: vec!["build".into()],
1989        };
1990        let other = AgentAction::ProcessSpawn {
1991            binary: "ls".into(),
1992            args: vec![],
1993        };
1994        assert!(matcher_applies(&rule, &cargo));
1995        assert!(!matcher_applies(&rule, &other));
1996    }
1997
1998    #[test]
1999    fn matcher_applies_process_spawn_with_missing_binary_field() {
2000        let rule = Rule {
2001            id: "R".into(),
2002            kind: "process_spawn".into(),
2003            matcher: r#"{}"#.into(),
2004            severity: "refuse".into(),
2005            reason: "r".into(),
2006            namespace: "_global".into(),
2007            created_by: "test".into(),
2008            created_at: 0,
2009            enabled: true,
2010            signature: None,
2011            attest_level: "unsigned".into(),
2012        };
2013        let action = AgentAction::ProcessSpawn {
2014            binary: "cargo".into(),
2015            args: vec![],
2016        };
2017        assert!(!matcher_applies(&rule, &action));
2018    }
2019
2020    #[test]
2021    fn matcher_applies_filesystem_write_missing_glob_field() {
2022        let rule = Rule {
2023            id: "R".into(),
2024            kind: "filesystem_write".into(),
2025            matcher: r#"{"other":"x"}"#.into(),
2026            severity: "refuse".into(),
2027            reason: "r".into(),
2028            namespace: "_global".into(),
2029            created_by: "test".into(),
2030            created_at: 0,
2031            enabled: true,
2032            signature: None,
2033            attest_level: "unsigned".into(),
2034        };
2035        let action = AgentAction::FilesystemWrite {
2036            path: "/x".into(),
2037            byte_estimate: None,
2038        };
2039        assert!(!matcher_applies(&rule, &action));
2040    }
2041
2042    #[test]
2043    fn matcher_applies_custom_missing_kind_field() {
2044        let rule = Rule {
2045            id: "R".into(),
2046            kind: "custom".into(),
2047            matcher: r#"{}"#.into(),
2048            severity: "refuse".into(),
2049            reason: "r".into(),
2050            namespace: "_global".into(),
2051            created_by: "test".into(),
2052            created_at: 0,
2053            enabled: true,
2054            signature: None,
2055            attest_level: "unsigned".into(),
2056        };
2057        let action = AgentAction::Custom {
2058            custom_kind: "memory_write".into(),
2059            payload: serde_json::json!({}),
2060        };
2061        assert!(!matcher_applies(&rule, &action));
2062    }
2063
2064    #[test]
2065    fn count_matching_rules_returns_count() {
2066        let _no_pubkey = no_operator_pubkey();
2067        let conn = fresh_conn();
2068        add_rule(
2069            &conn,
2070            "R1",
2071            "bash",
2072            r#"{"command_regex":"rm"}"#,
2073            "refuse",
2074            true,
2075        );
2076        add_rule(
2077            &conn,
2078            "R2",
2079            "bash",
2080            r#"{"command_regex":"rm"}"#,
2081            "warn",
2082            true,
2083        );
2084        add_rule(
2085            &conn,
2086            "R3",
2087            "bash",
2088            r#"{"command_regex":"ls"}"#,
2089            "refuse",
2090            true,
2091        );
2092        let action = AgentAction::Bash {
2093            command: "rm -rf".into(),
2094            cwd: None,
2095        };
2096        let count = count_matching_rules(&conn, &action).unwrap();
2097        assert_eq!(count, 2, "two rules match 'rm', one matches 'ls'");
2098    }
2099
2100    #[test]
2101    fn count_matching_rules_zero_when_no_rules() {
2102        let conn = fresh_conn();
2103        let action = AgentAction::Bash {
2104            command: "ls".into(),
2105            cwd: None,
2106        };
2107        let count = count_matching_rules(&conn, &action).unwrap();
2108        assert_eq!(count, 0);
2109    }
2110
2111    #[test]
2112    fn decision_matches_for_each_variant() {
2113        let w = Decision::Warn {
2114            rule_id: "W".into(),
2115            reason: "warn".into(),
2116        };
2117        assert!(matches!(w, Decision::Warn { .. }));
2118        let allow = Decision::Allow;
2119        assert!(matches!(allow, Decision::Allow));
2120        assert!(allow.is_allowed());
2121        let refuse = Decision::Refuse {
2122            rule_id: "R".into(),
2123            reason: "no".into(),
2124        };
2125        assert!(refuse.is_refusal());
2126    }
2127
2128    #[test]
2129    fn severity_as_str_round_trip() {
2130        for s in [Severity::Refuse, Severity::Warn, Severity::Log] {
2131            let back = Severity::from_str(s.as_str()).unwrap();
2132            assert_eq!(s, back);
2133        }
2134    }
2135
2136    #[test]
2137    fn agent_action_serialize_round_trip_for_each_variant() {
2138        let actions = [
2139            AgentAction::Bash {
2140                command: "ls".into(),
2141                cwd: None,
2142            },
2143            AgentAction::FilesystemWrite {
2144                path: "/tmp/x".into(),
2145                byte_estimate: Some(1024),
2146            },
2147            AgentAction::NetworkRequest {
2148                host: "h.example.com".into(),
2149                scheme: "https".into(),
2150            },
2151            AgentAction::ProcessSpawn {
2152                binary: "cargo".into(),
2153                args: vec!["build".into()],
2154            },
2155            AgentAction::Custom {
2156                custom_kind: "memory_write".into(),
2157                payload: serde_json::json!({"ns": "a"}),
2158            },
2159        ];
2160        for a in &actions {
2161            let json = serde_json::to_value(a).unwrap();
2162            assert!(json.is_object(), "action should serialize as object");
2163            // Has discriminator field.
2164            assert!(
2165                json["type"].is_string() || json["kind"].is_string() || json.get("type").is_some()
2166            );
2167        }
2168    }
2169
2170    // -----------------------------------------------------------------
2171    // Refactor Wave-2 Tier-A2 (issue #850) — RuleEngine unit coverage.
2172    // The three entry-point wrappers (check_agent_action,
2173    // check_agent_action_no_audit, check_agent_action_deferred) all
2174    // route through RuleEngine now; the tests above already exercise
2175    // them at the wrapper boundary. The cases below pin the engine's
2176    // direct semantics so a future regression in the wrapper layer
2177    // shows up at the engine level too.
2178    // -----------------------------------------------------------------
2179
2180    #[test]
2181    fn rule_engine_from_rules_evaluate_allow_when_no_match() {
2182        let engine = RuleEngine::from_rules(vec![]);
2183        let decision = engine.evaluate(
2184            "agent:t",
2185            &AgentAction::Bash {
2186                command: "ls".into(),
2187                cwd: None,
2188            },
2189        );
2190        assert_eq!(decision, Decision::Allow);
2191        assert!(engine.rules().is_empty());
2192    }
2193
2194    #[test]
2195    fn rule_engine_first_refusal_wins_over_warn() {
2196        let warn_rule = Rule {
2197            id: "W1".into(),
2198            kind: "bash".into(),
2199            matcher: r#"{"command_substring":"rm"}"#.into(),
2200            severity: "warn".into(),
2201            reason: "warn-rm".into(),
2202            namespace: "_global".into(),
2203            created_by: "test".into(),
2204            created_at: 0,
2205            enabled: true,
2206            signature: None,
2207            attest_level: "unsigned".into(),
2208        };
2209        let refuse_rule = Rule {
2210            id: "R1".into(),
2211            kind: "bash".into(),
2212            matcher: r#"{"command_substring":"rm -rf"}"#.into(),
2213            severity: "refuse".into(),
2214            reason: "refuse-rm-rf".into(),
2215            namespace: "_global".into(),
2216            created_by: "test".into(),
2217            created_at: 0,
2218            enabled: true,
2219            signature: None,
2220            attest_level: "unsigned".into(),
2221        };
2222        // Order rules so warn comes first — first-refusal-wins must
2223        // still return refuse regardless of slice order.
2224        let engine = RuleEngine::from_rules(vec![warn_rule, refuse_rule]);
2225        let decision = engine.evaluate(
2226            "agent:t",
2227            &AgentAction::Bash {
2228                command: "rm -rf /tmp/x".into(),
2229                cwd: None,
2230            },
2231        );
2232        match decision {
2233            Decision::Refuse { rule_id, .. } => assert_eq!(rule_id, "R1"),
2234            other => panic!("expected Refuse, got {other:?}"),
2235        }
2236    }
2237
2238    #[test]
2239    fn rule_engine_warn_when_only_warn_matches() {
2240        let rule = Rule {
2241            id: "W1".into(),
2242            kind: "bash".into(),
2243            matcher: r#"{"command_substring":"rm"}"#.into(),
2244            severity: "warn".into(),
2245            reason: "warn-rm".into(),
2246            namespace: "_global".into(),
2247            created_by: "test".into(),
2248            created_at: 0,
2249            enabled: true,
2250            signature: None,
2251            attest_level: "unsigned".into(),
2252        };
2253        let engine = RuleEngine::from_rules(vec![rule]);
2254        let decision = engine.evaluate(
2255            "agent:t",
2256            &AgentAction::Bash {
2257                command: "rm /tmp/x".into(),
2258                cwd: None,
2259            },
2260        );
2261        match decision {
2262            Decision::Warn { rule_id, reason } => {
2263                assert_eq!(rule_id, "W1");
2264                assert_eq!(reason, "warn-rm");
2265            }
2266            other => panic!("expected Warn, got {other:?}"),
2267        }
2268    }
2269
2270    #[test]
2271    fn rule_engine_log_severity_is_silent() {
2272        let rule = Rule {
2273            id: "L1".into(),
2274            kind: "bash".into(),
2275            matcher: r#"{"command_substring":"ls"}"#.into(),
2276            severity: "log".into(),
2277            reason: "log-ls".into(),
2278            namespace: "_global".into(),
2279            created_by: "test".into(),
2280            created_at: 0,
2281            enabled: true,
2282            signature: None,
2283            attest_level: "unsigned".into(),
2284        };
2285        let engine = RuleEngine::from_rules(vec![rule]);
2286        let decision = engine.evaluate(
2287            "agent:t",
2288            &AgentAction::Bash {
2289                command: "ls -la".into(),
2290                cwd: None,
2291            },
2292        );
2293        // Log-only rules do not produce Warn or Refuse — engine
2294        // collapses to Allow.
2295        assert_eq!(decision, Decision::Allow);
2296    }
2297
2298    #[test]
2299    fn rule_engine_load_for_action_round_trips_through_sqlite() {
2300        let _no_pubkey = no_operator_pubkey();
2301        let conn = fresh_conn();
2302        add_rule(
2303            &conn,
2304            "R-engine",
2305            "filesystem_write",
2306            r#"{"glob":"/tmp/**"}"#,
2307            "refuse",
2308            true,
2309        );
2310        let action = AgentAction::FilesystemWrite {
2311            path: "/tmp/engine.txt".into(),
2312            byte_estimate: None,
2313        };
2314        let engine = RuleEngine::load_for_action(&conn, &action).unwrap();
2315        // Engine carries exactly the kind-scoped rule we inserted.
2316        assert_eq!(engine.rules().len(), 1);
2317        assert_eq!(engine.rules()[0].id, "R-engine");
2318        let decision = engine.evaluate("agent:t", &action);
2319        match decision {
2320            Decision::Refuse { rule_id, .. } => assert_eq!(rule_id, "R-engine"),
2321            other => panic!("expected Refuse, got {other:?}"),
2322        }
2323    }
2324}