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}