Skip to main content

ai_memory/governance/
rules_store.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Typed CRUD over the `governance_rules` table (migration
5//! `0024_v07_governance_rules.sql`).
6//!
7//! The table holds the substrate-level agent-action rules consulted
8//! by [`crate::governance::agent_action::check_agent_action`]. This
9//! module owns the SQL — no other code path is allowed to SELECT /
10//! INSERT / UPDATE / DELETE from `governance_rules` directly.
11//!
12//! The shape is deliberately small: five verbs ([`insert`],
13//! [`get`], [`list`], [`list_enabled_by_kind`], [`remove`]) plus
14//! two state mutators ([`set_enabled`], [`update_signature`]). All
15//! verbs are idempotent against a missing row (`get` / `remove`
16//! return `None` / `Ok(false)` rather than erroring).
17//!
18//! # Operator-mutation gating (NOT enforced here)
19//!
20//! The CRUD functions are pure SQL. The operator-keypair-on-disk
21//! gating lives in `src/cli/rules.rs` and the HTTP handler — both
22//! verify the signature header / file presence BEFORE calling these
23//! verbs. The MCP read-only `rule_list` tool calls [`list`] /
24//! [`get`]; mutation tools over MCP are explicitly disabled per
25//! issue #691 design revision 2026-05-13.
26
27use crate::models::field_names;
28use anyhow::{Context, Result};
29use rusqlite::{Connection, OptionalExtension, params};
30use serde::{Deserialize, Serialize};
31
32/// `attest_level` value carried by operator-signed governance rules —
33/// shared with `governance install-defaults` (#1558 batch 6).
34pub(crate) const ATTEST_OPERATOR_SIGNED: &str = "operator_signed";
35
36/// One row of `governance_rules`. Field order matches the SQL column
37/// order so projection / debugging is symmetric.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct Rule {
40    pub id: String,
41    pub kind: String,
42    pub matcher: String,
43    pub severity: String,
44    pub reason: String,
45    pub namespace: String,
46    pub created_by: String,
47    pub created_at: i64,
48    pub enabled: bool,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub signature: Option<Vec<u8>>,
51    pub attest_level: String,
52}
53
54/// Insert a fresh rule. Returns an error if `id` already exists —
55/// callers that want upsert semantics should call [`get`] first.
56///
57/// # Errors
58///
59/// Propagates SQLite errors. The `severity` value is enforced by the
60/// table CHECK constraint (one of `refuse`/`warn`/`log`); a bad
61/// value here will surface as a constraint error.
62pub fn insert(conn: &Connection, rule: &Rule) -> Result<()> {
63    conn.execute(
64        "INSERT INTO governance_rules (
65             id, kind, matcher, severity, reason, namespace,
66             created_by, created_at, enabled, signature, attest_level
67         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
68        params![
69            rule.id,
70            rule.kind,
71            rule.matcher,
72            rule.severity,
73            rule.reason,
74            rule.namespace,
75            rule.created_by,
76            rule.created_at,
77            i64::from(rule.enabled),
78            rule.signature,
79            rule.attest_level,
80        ],
81    )
82    .with_context(|| format!("rules_store::insert: id={}", rule.id))?;
83    Ok(())
84}
85
86/// Fetch a rule by id. Returns `None` when no row matches.
87///
88/// # Errors
89///
90/// Propagates SQLite errors. A row whose `enabled` column is not 0/1
91/// or whose `severity` is outside the CHECK list will still
92/// deserialize — the engine treats unknown severities as `Log`
93/// (defensive), so the returned row is consumable.
94pub fn get(conn: &Connection, id: &str) -> Result<Option<Rule>> {
95    let row = conn
96        .query_row(
97            "SELECT id, kind, matcher, severity, reason, namespace,
98                    created_by, created_at, enabled, signature, attest_level
99             FROM governance_rules WHERE id = ?1",
100            params![id],
101            row_to_rule,
102        )
103        .optional()
104        .with_context(|| format!("rules_store::get: id={id}"))?;
105    Ok(row)
106}
107
108/// List every rule in the table (enabled and disabled). Ordered by
109/// `id ASC` for deterministic CLI output.
110///
111/// # Errors
112///
113/// Propagates SQLite errors.
114pub fn list(conn: &Connection) -> Result<Vec<Rule>> {
115    let mut stmt = conn
116        .prepare(
117            "SELECT id, kind, matcher, severity, reason, namespace,
118                    created_by, created_at, enabled, signature, attest_level
119             FROM governance_rules ORDER BY id ASC",
120        )
121        .context("rules_store::list: prepare")?;
122    let rows = stmt
123        .query_map([], row_to_rule)
124        .context("rules_store::list: query_map")?;
125    let mut out = Vec::new();
126    for r in rows {
127        out.push(r.context("rules_store::list: row")?);
128    }
129    Ok(out)
130}
131
132/// List only the rules of `kind` that are enabled AND pass the L1-6
133/// load-time signature verification. The dominant query shape called
134/// once per [`crate::governance::agent_action::check_agent_action`]
135/// invocation; covered by `idx_governance_rules_kind_enabled`.
136///
137/// Ordered by `id ASC` to make first-refusal-wins deterministic for
138/// audit reproduction.
139///
140/// # L1-6 enforcement policy
141///
142/// Activation is driven by the OPERATOR PUBKEY presence — the
143/// substrate stays in pre-L1-6 mode until the operator places a
144/// pubkey on disk (or sets `AI_MEMORY_OPERATOR_PUBKEY`).
145///
146/// - Pubkey NOT resolved (cold start, fresh install, or test
147///   environment): every `enabled = 1` row passes through unchanged —
148///   this preserves the pre-L1-6 contract that
149///   `agent_action::check_agent_action` evaluated rules without any
150///   signature pre-check.
151/// - Pubkey resolved + row is `attest_level = 'operator_signed'` +
152///   signature verifies: rule is enforced.
153/// - Pubkey resolved + row is `attest_level = 'operator_signed'`
154///   but signature does NOT verify (tampered row, post-sign direct
155///   SQL mutation, wrong key): `tracing::error!` and SKIP. The
156///   daemon does NOT crash; a tampered rule must never bring down
157///   the substrate.
158/// - Pubkey resolved + row is `attest_level = 'unsigned'` (a
159///   freshly-seeded row that the operator has not yet signed):
160///   misconfiguration → `tracing::warn!` and SKIP. Enforced rules
161///   MUST be operator-signed once the operator has activated L1-6
162///   by placing the pubkey.
163///
164/// The activation cliff (place pubkey ⇒ require signatures) is the
165/// operator's switch. It avoids breaking the pre-L1-6 test fleet that
166/// inserts unsigned-enabled rules + asserts they fire, while
167/// guaranteeing the bypass-prevention property the moment the
168/// operator opts in.
169///
170/// # Errors
171///
172/// Propagates SQLite errors. Verification failures are NOT errors —
173/// they are logged and the rule is filtered out.
174pub fn list_enabled_by_kind(conn: &Connection, kind: &str) -> Result<Vec<Rule>> {
175    let mut stmt = conn
176        .prepare(
177            "SELECT id, kind, matcher, severity, reason, namespace,
178                    created_by, created_at, enabled, signature, attest_level
179             FROM governance_rules
180             WHERE kind = ?1 AND enabled = 1
181             ORDER BY id ASC",
182        )
183        .context("rules_store::list_enabled_by_kind: prepare")?;
184    let rows = stmt
185        .query_map(params![kind], row_to_rule)
186        .context("rules_store::list_enabled_by_kind: query_map")?;
187    let operator_pubkey = resolve_operator_pubkey();
188    let mut out = Vec::new();
189    for r in rows {
190        let rule = r.context("rules_store::list_enabled_by_kind: row")?;
191        if enforced_rule_passes(&rule, operator_pubkey.as_ref()) {
192            out.push(rule);
193        }
194    }
195    Ok(out)
196}
197
198/// L1-6 — decide whether `rule` (already filtered to `enabled = 1`
199/// by the SQL WHERE clause) should pass to the enforcement engine.
200/// See [`list_enabled_by_kind`] for the policy summary. Pulled out so
201/// the L1-6 integration tests can exercise the matrix
202/// (signed/tampered/unsigned-enabled/no-key) directly without driving
203/// SQLite.
204#[must_use]
205pub fn enforced_rule_passes(
206    rule: &Rule,
207    operator_pubkey: Option<&ed25519_dalek::VerifyingKey>,
208) -> bool {
209    match (operator_pubkey, rule.attest_level.as_str()) {
210        (Some(pk), ATTEST_OPERATOR_SIGNED) => match verify_rule_signature(rule, pk) {
211            Ok(()) => true,
212            Err(_) => {
213                tracing::error!(
214                    rule_id = %rule.id,
215                    "L1-6: operator_signed rule failed signature verification — \
216                     skipping. Tampered row OR rule was directly modified after \
217                     signing (e.g. `UPDATE governance_rules SET enabled = 1`). \
218                     Re-sign with `ai-memory rules sign-seed` after audit."
219                );
220                false
221            }
222        },
223        (Some(_), _) => {
224            // Pubkey is available (operator has activated L1-6) but
225            // the rule is not operator_signed. It has `enabled = 1`
226            // (SQL filter); refuse to enforce unsigned-enabled rules
227            // as misconfiguration.
228            tracing::warn!(
229                rule_id = %rule.id,
230                attest_level = %rule.attest_level,
231                "L1-6: enabled rule is not operator_signed — skipping. Run \
232                 `ai-memory rules sign-seed` to commit the operator signature."
233            );
234            false
235        }
236        (None, _) => {
237            // No operator pubkey configured — substrate is in pre-L1-6
238            // mode. Every `enabled = 1` row passes through unchanged
239            // (preserves the pre-L1-6 contract that
240            // `check_agent_action` evaluated rules without any
241            // signature pre-check). The operator activates L1-6 by
242            // placing the pubkey at the default path or setting the
243            // env var.
244            true
245        }
246    }
247}
248
249/// Base64url-no-pad (or standard) encoded verifying key written by
250/// `ai-memory rules keygen` (Layout 2 in
251/// [`crate::cli::rules::load_operator_signing_key_from_dir`]).
252const OPERATOR_PUBKEY_KEYGEN_FILE: &str = "operator.key.pub";
253/// Raw 32-byte verifying key written by the legacy `kp::save` keypair
254/// layout (Layout 1 — pairs with `operator.priv`).
255const OPERATOR_PUBKEY_LEGACY_FILE: &str = "operator.pub";
256
257/// `attest_level` stamped on operator-signed governance rows AND on the
258/// `signed_events` audit emissions that record their lifecycle.
259///
260/// Single source of truth for the `"operator_signed"` attestation
261/// string — the CLI re-exports it as
262/// [`crate::cli::rules::OPERATOR_SIGNED_LEVEL`] so the rules table
263/// (`update_signature`) and the audit chain (`remove_signed`) cannot
264/// drift apart on the literal.
265pub const OPERATOR_SIGNED_ATTEST_LEVEL: &str = "operator_signed";
266
267/// Resolve the operator verifying key. The lookup order mirrors the
268/// SIGNER's key-dir resolution
269/// ([`crate::cli::rules::load_operator_signing_key_from_dir`]) so the
270/// verify path can never diverge from the sign path:
271///
272/// 1. `AI_MEMORY_OPERATOR_PUBKEY` env var (base64, URL-safe-no-pad or
273///    standard padded — same as the rest of the codebase).
274/// 2. The resolved key directory ([`crate::identity::keypair::default_key_dir`],
275///    which honors `AI_MEMORY_KEY_DIR`):
276///    a. `<key_dir>/operator.key.pub` (base64, keygen Layout 2);
277///    b. `<key_dir>/operator.pub` (raw 32 bytes, legacy Layout 1).
278/// 3. The key directory's PARENT (the singleton operator-key location —
279///    `<config>/ai-memory/operator.key.pub` when `AI_MEMORY_KEY_DIR` is
280///    unset, since the default key dir is `<config>/ai-memory/keys`):
281///    a. `<parent>/operator.key.pub` (base64);
282///    b. `<parent>/operator.pub` (raw 32 bytes).
283///
284/// v0.7.0 H5 (HIGH) — pre-fix the verifier read ONLY the env var and
285/// `<config>/ai-memory/operator.key.pub`, ignoring `AI_MEMORY_KEY_DIR`
286/// and the raw-bytes legacy layout. An operator who relocated the key
287/// store via `AI_MEMORY_KEY_DIR` (the documented production override)
288/// signed rules with a key the verifier could not find, so
289/// `resolve_operator_pubkey` returned `None` and every `enabled = 1`
290/// rule silently fell through the `(None, _) => true` pre-L1-6 arm of
291/// [`enforced_rule_passes`] WITHOUT a signature check — a fail-open
292/// signature bypass. Honoring the same key-dir ladder as the signer
293/// closes the divergence: when a key exists the verifier finds it and
294/// enforcement moves to the `(Some(pk), _)` verify arm.
295///
296/// Returns `None` when no source resolves a 32-byte verifying key. A
297/// failure to decode any source is silently treated as "no key" (the
298/// once-per-process diagnostic in [`log_missing_operator_pubkey_once`]
299/// surfaces the misconfig to the operator).
300///
301/// Exposed `pub` so the daemon startup path
302/// (`bootstrap_serve`) can call this directly to enforce the SEC-2
303/// fail-closed posture (refuse to boot when `enabled = 1` rules are
304/// present but no pubkey is resolved).
305#[must_use]
306pub fn resolve_operator_pubkey() -> Option<ed25519_dalek::VerifyingKey> {
307    // Test-only escape hatch (issue #819). On dev hosts where the
308    // operator has staged a real operator.key.pub at
309    // `~/Library/Application Support/ai-memory/operator.key.pub`,
310    // the unit-test fixtures in `src/governance/agent_action.rs` +
311    // various integration tests insert unsigned rules and then call
312    // `check_agent_action` expecting Warn/Refuse. With a real pubkey
313    // resolvable on disk, `enforced_rule_passes` correctly skips the
314    // unsigned rules and the assertions fail.
315    //
316    // The thread-local guard below — only compiled in under
317    // `#[cfg(test)]` — lets a specific test scope force this
318    // function to return `None` so the dev-host posture matches the
319    // clean-HOME CI posture. Production code paths are entirely
320    // unaffected.
321    #[cfg(test)]
322    if force_no_operator_pubkey_active() {
323        return None;
324    }
325
326    use base64::Engine;
327    let from_bytes = |bytes: &[u8]| -> Option<ed25519_dalek::VerifyingKey> {
328        if bytes.len() != ed25519_dalek::PUBLIC_KEY_LENGTH {
329            return None;
330        }
331        let mut arr = [0u8; ed25519_dalek::PUBLIC_KEY_LENGTH];
332        arr.copy_from_slice(bytes);
333        ed25519_dalek::VerifyingKey::from_bytes(&arr).ok()
334    };
335    let try_decode = |s: &str| -> Option<ed25519_dalek::VerifyingKey> {
336        let trimmed = s.trim();
337        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
338            .decode(trimmed)
339            .or_else(|_| base64::engine::general_purpose::STANDARD.decode(trimmed))
340            .ok()?;
341        from_bytes(&bytes)
342    };
343    // Read a base64-encoded `.pub` (keygen layout); fall back to raw
344    // 32-byte bytes (legacy layout) when the text does not base64-decode
345    // to a valid key.
346    let try_pub_file = |path: &std::path::Path| -> Option<ed25519_dalek::VerifyingKey> {
347        let raw = std::fs::read(path).ok()?;
348        if let Ok(text) = std::str::from_utf8(&raw)
349            && let Some(pk) = try_decode(text)
350        {
351            return Some(pk);
352        }
353        from_bytes(&raw)
354    };
355
356    if let Ok(v) = std::env::var("AI_MEMORY_OPERATOR_PUBKEY")
357        && !v.is_empty()
358        && let Some(pk) = try_decode(&v)
359    {
360        return Some(pk);
361    }
362
363    // Resolve the same key directory the signer uses (honors
364    // AI_MEMORY_KEY_DIR), then walk the key-dir + its parent for both
365    // the base64 keygen layout and the raw-bytes legacy layout.
366    let key_dir = crate::identity::keypair::default_key_dir().ok()?;
367    let mut candidates: Vec<std::path::PathBuf> = vec![
368        key_dir.join(OPERATOR_PUBKEY_KEYGEN_FILE),
369        key_dir.join(OPERATOR_PUBKEY_LEGACY_FILE),
370    ];
371    if let Some(parent) = key_dir.parent() {
372        candidates.push(parent.join(OPERATOR_PUBKEY_KEYGEN_FILE));
373        candidates.push(parent.join(OPERATOR_PUBKEY_LEGACY_FILE));
374    }
375    candidates.iter().find_map(|p| try_pub_file(p))
376}
377
378/// Issue #819 — test-only escape hatch for [`resolve_operator_pubkey`].
379///
380/// Returns true when the current thread has an active
381/// [`ForceNoOperatorPubkeyGuard`] in scope. Production code does
382/// not call this (the `#[cfg(test)]` gate strips it from non-test
383/// builds entirely).
384#[cfg(test)]
385fn force_no_operator_pubkey_active() -> bool {
386    FORCE_NO_OPERATOR_PUBKEY.with(std::cell::Cell::get)
387}
388
389#[cfg(test)]
390thread_local! {
391    static FORCE_NO_OPERATOR_PUBKEY: std::cell::Cell<bool> =
392        const { std::cell::Cell::new(false) };
393}
394
395/// Issue #819 — return a RAII guard that forces
396/// [`resolve_operator_pubkey`] to return `None` for the duration of
397/// the current scope on the current thread.
398///
399/// Per-thread (not process-wide) so parallel tests in the same
400/// binary don't race on env mutation. The guard restores the prior
401/// state on drop.
402///
403/// Use:
404/// ```ignore
405/// let _no_pubkey = force_no_operator_pubkey_for_test();
406/// let decision = check_agent_action(&conn, "agent:t", &action)?;
407/// // ... assertions ...
408/// // `_no_pubkey` drops at end of scope, restoring resolver behavior.
409/// ```
410#[cfg(test)]
411#[must_use = "the guard must be held for its scope to suppress pubkey resolution"]
412pub fn force_no_operator_pubkey_for_test() -> ForceNoOperatorPubkeyGuard {
413    let prior = FORCE_NO_OPERATOR_PUBKEY.with(|c| c.replace(true));
414    ForceNoOperatorPubkeyGuard { prior }
415}
416
417/// RAII guard returned by [`force_no_operator_pubkey_for_test`].
418/// Restores the prior value on drop so nested scopes compose.
419#[cfg(test)]
420pub struct ForceNoOperatorPubkeyGuard {
421    prior: bool,
422}
423
424#[cfg(test)]
425impl Drop for ForceNoOperatorPubkeyGuard {
426    fn drop(&mut self) {
427        FORCE_NO_OPERATOR_PUBKEY.with(|c| c.set(self.prior));
428    }
429}
430
431/// v0.7.0 SEC-2 (Cluster D, issue #767) — count of `enabled = 1`
432/// rows in `governance_rules`. Used by the daemon startup path to
433/// decide whether to surface the fail-OPEN error
434/// (`tracing::error!`) and, when
435/// `[governance] require_operator_pubkey = true`, refuse to boot.
436///
437/// Returns `Ok(0)` when the table is empty or the migration that
438/// creates it has not yet run — the caller treats absent table as
439/// "no enabled rules" rather than a hard error so a cold-start
440/// daemon can complete its migration pass before the L1-6 audit
441/// runs.
442///
443/// # Errors
444///
445/// Propagates SQLite errors OTHER than the "no such table" case,
446/// which is mapped to `Ok(0)`.
447pub fn count_enabled_rules(conn: &Connection) -> Result<i64> {
448    let result = conn.query_row(
449        "SELECT COUNT(*) FROM governance_rules WHERE enabled = 1",
450        [],
451        |row| row.get::<_, i64>(0),
452    );
453    match result {
454        Ok(n) => Ok(n),
455        Err(rusqlite::Error::SqliteFailure(_, Some(msg)))
456            if msg.contains("no such table") || msg.contains("does not exist") =>
457        {
458            Ok(0)
459        }
460        Err(rusqlite::Error::SqliteFailure(_, None)) => Ok(0),
461        Err(e) => Err(anyhow::Error::new(e).context("rules_store::count_enabled_rules")),
462    }
463}
464
465/// v0.7.0 SEC-2 (Cluster D, issue #767) — `true` when the substrate
466/// is in pre-L1-6 mode (no operator pubkey resolved). Exposed so the
467/// capabilities-v3 envelope can surface `l1_6_attest: false` without
468/// re-decoding the pubkey on every call.
469#[must_use]
470pub fn l1_6_attest_active() -> bool {
471    resolve_operator_pubkey().is_some()
472}
473
474/// v0.7.0 SEC-2 (Cluster D, issue #767) — once-per-process diagnostic
475/// surfaced from the daemon startup path when the substrate is in
476/// the fail-OPEN posture (any `enabled = 1` row exists but no
477/// operator pubkey is resolved). Idempotent across repeated calls;
478/// re-invocation from a test harness (or a `bootstrap_serve` reuse)
479/// stays silent on the second+ trip.
480pub fn log_missing_operator_pubkey_once(enabled_rule_count: i64) {
481    use std::sync::OnceLock;
482    static LOGGED: OnceLock<()> = OnceLock::new();
483    if LOGGED.set(()).is_err() {
484        return;
485    }
486    tracing::error!(
487        enabled_rule_count,
488        "SEC-2: governance_rules contains {enabled_rule_count} enabled row(s) but no operator \
489         pubkey is resolved (AI_MEMORY_OPERATOR_PUBKEY unset AND \
490         ~/.config/ai-memory/operator.key.pub absent). Substrate is in FAIL-OPEN posture: every \
491         enabled rule passes through without signature verification, so a SQL-write gadget that \
492         can mutate `governance_rules` can install or flip rules without operator consent. \
493         Activate L1-6 by either (a) running `ai-memory rules keygen` + `ai-memory rules \
494         sign-seed` to place an operator key + sign the existing rows, or (b) setting `[governance] \
495         require_operator_pubkey = true` in config.toml to refuse boot until the pubkey is in \
496         place."
497    );
498}
499
500/// Remove a rule by id. Returns `true` when a row was deleted,
501/// `false` when no row matched.
502///
503/// # Errors
504///
505/// Propagates SQLite errors.
506pub fn remove(conn: &Connection, id: &str) -> Result<bool> {
507    let affected = conn
508        .execute("DELETE FROM governance_rules WHERE id = ?1", params![id])
509        .with_context(|| format!("rules_store::remove: id={id}"))?;
510    Ok(affected > 0)
511}
512
513/// Remove a rule by id, emitting an operator-signed audit event to the
514/// `signed_events` chain BEFORE the row is deleted — atomically within
515/// a single `IMMEDIATE` transaction.
516///
517/// Returns `true` when a row was deleted (and an audit event emitted),
518/// `false` when no row matched (no event emitted).
519///
520/// v0.7.0 SR — closes the unaudited/unsigned-deletion gap. A bare
521/// `DELETE FROM governance_rules` (the old [`remove`] path the CLI
522/// called) left no tamper-evident trace of WHICH rule was removed or
523/// under whose authority, so a SQL-write gadget — or a careless
524/// operator — could erase a refuse-severity rule with zero audit
525/// residue. The emitted event's `payload_hash` is the SHA-256 over the
526/// removed rule's [`canonical_bytes_for_signing`] and its `signature`
527/// is the operator's Ed25519 signature over that hash, so an auditor
528/// can both prove the deletion was operator-authorized and reconstruct
529/// the removed rule's identity from the append-only chain.
530///
531/// Atomicity: the audit INSERT and the row DELETE share one
532/// transaction. On any error the transaction rolls back, so the rule
533/// row is never deleted unless its audit event was durably written
534/// (and vice versa).
535///
536/// # Errors
537///
538/// Propagates SQLite errors, signing-key/serialisation errors, and
539/// audit-chain INSERT errors.
540pub fn remove_signed(
541    conn: &Connection,
542    id: &str,
543    signing_key: &ed25519_dalek::SigningKey,
544    operator_agent_id: &str,
545) -> Result<bool> {
546    use ed25519_dalek::Signer;
547
548    let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)
549        .context("rules_store::remove_signed: begin IMMEDIATE tx")?;
550
551    let Some(rule) = get(&tx, id)? else {
552        // No matching row — nothing to delete or audit. Commit the empty
553        // transaction so the IMMEDIATE write-lock is released cleanly.
554        tx.commit()
555            .context("rules_store::remove_signed: commit empty tx")?;
556        return Ok(false);
557    };
558
559    let canonical = canonical_bytes_for_signing(&rule)?;
560    let payload_hash = crate::signed_events::payload_hash(&canonical);
561    let signature = signing_key.sign(&payload_hash);
562
563    let event = crate::signed_events::SignedEvent {
564        id: uuid::Uuid::new_v4().to_string(),
565        agent_id: operator_agent_id.to_string(),
566        event_type: crate::signed_events::event_types::GOVERNANCE_RULE_REMOVED.to_string(),
567        payload_hash,
568        signature: Some(signature.to_bytes().to_vec()),
569        attest_level: OPERATOR_SIGNED_ATTEST_LEVEL.to_string(),
570        timestamp: chrono::Utc::now().to_rfc3339(),
571        ..crate::signed_events::SignedEvent::default()
572    };
573    crate::signed_events::append_signed_event_no_tx(&tx, &event)?;
574
575    let affected = tx
576        .execute("DELETE FROM governance_rules WHERE id = ?1", params![id])
577        .with_context(|| format!("rules_store::remove_signed: delete id={id}"))?;
578
579    tx.commit()
580        .context("rules_store::remove_signed: commit tx")?;
581    Ok(affected > 0)
582}
583
584/// Flip the `enabled` column on an existing rule. Returns `true`
585/// when a row was updated, `false` when no row matched.
586///
587/// Used by `ai-memory rules enable` / `ai-memory rules disable` —
588/// the CLI verifies the operator signature before calling here.
589///
590/// # Errors
591///
592/// Propagates SQLite errors.
593pub fn set_enabled(conn: &Connection, id: &str, enabled: bool) -> Result<bool> {
594    let affected = conn
595        .execute(
596            "UPDATE governance_rules SET enabled = ?1 WHERE id = ?2",
597            params![i64::from(enabled), id],
598        )
599        .with_context(|| format!("rules_store::set_enabled: id={id} enabled={enabled}"))?;
600    Ok(affected > 0)
601}
602
603/// Persist an operator signature + bump `attest_level` to
604/// `operator_signed` on an existing rule. Used by `ai-memory rules
605/// enable --sign` / `ai-memory rules add --sign` after the CLI
606/// computes the Ed25519 signature over the canonical row encoding.
607///
608/// # Errors
609///
610/// Propagates SQLite errors. Returns `false` when no row matched.
611pub fn update_signature(
612    conn: &Connection,
613    id: &str,
614    signature: &[u8],
615    attest_level: &str,
616) -> Result<bool> {
617    let affected = conn
618        .execute(
619            "UPDATE governance_rules
620             SET signature = ?1, attest_level = ?2
621             WHERE id = ?3",
622            params![signature, attest_level, id],
623        )
624        .with_context(|| format!("rules_store::update_signature: id={id}"))?;
625    Ok(affected > 0)
626}
627
628/// Canonical byte encoding of a rule for signature input. Stable
629/// across versions: the field order is fixed; the wire format is
630/// `serde_json` compact (no whitespace). A future signature-format
631/// migration recomputes the bytes via a different canonicalizer
632/// without touching the CLI call sites.
633///
634/// # Note on `enabled`
635///
636/// This historical helper (used by `ai-memory rules add --sign`)
637/// does NOT include `enabled` in the canonical payload — it pre-dates
638/// the L1-6 bypass-prevention design. Use
639/// [`canonical_bytes_for_signing`] for L1-6 sign + verify call sites;
640/// that variant commits to `enabled` so direct
641/// `UPDATE governance_rules SET enabled = 1` after signing fails
642/// verification at load time.
643///
644/// # Errors
645///
646/// Propagates `serde_json` encoding errors.
647pub fn canonical_bytes(rule: &Rule) -> Result<Vec<u8>> {
648    let canonical = serde_json::json!({
649        "id": rule.id,
650        "kind": rule.kind,
651        "matcher": rule.matcher,
652        "severity": rule.severity,
653        "reason": rule.reason,
654        "namespace": rule.namespace,
655        (field_names::CREATED_BY): rule.created_by,
656        (field_names::CREATED_AT): rule.created_at,
657    });
658    serde_json::to_vec(&canonical).context("rules_store::canonical_bytes: serialize")
659}
660
661/// v0.7.0 L1-6 — canonical byte encoding for the substrate-rules
662/// signing pipeline. Commits to the same row fields as
663/// [`canonical_bytes`] PLUS `enabled`. The latter is the
664/// bypass-prevention property: a direct
665/// `UPDATE governance_rules SET enabled = 1` between sign and load
666/// changes the canonical bytes → the recorded signature no longer
667/// verifies → the rule is skipped at enforcement time
668/// (no panic, audit-row logged at `error!`).
669///
670/// `created_at` is intentionally OMITTED so a re-signing pass (idempotent
671/// `ai-memory rules sign-seed` invocations) produces the same bytes
672/// regardless of whether the operator re-ran the migration that seeded
673/// `created_at = 0`. The signed property is "what the rule does", not
674/// "when the seed row landed". A future rotation-policy verb can layer
675/// timestamp commitments on top without changing this primitive.
676///
677/// # NSA CSI MCP Security mapping
678///
679/// Addresses **NSA recommendation (e) Sign and verify MCP messages** and
680/// **NSA concern (b) Insecure context or data serialization** per the
681/// NSA Cybersecurity Information document on MCP security
682/// (U/OO/6030316-26 \| PP-26-1834, May 2026, Version 1.0). The canonical
683/// bytes discipline is the substrate's primary cryptographic-integrity
684/// primitive — every signed governance rule traces back to this
685/// function. The mapping is documented in
686/// [`docs/compliance/nsa-csi-mcp.html`](../../docs/compliance/nsa-csi-mcp.html)
687/// §3.2 (NSA concern b) and §4.5 (NSA recommendation e); the
688/// capability inventory anchor is `form_7_canonical_bytes_signing` in
689/// [`docs/compliance/_inventory/v0.7.0-capabilities.json`](../../docs/compliance/_inventory/v0.7.0-capabilities.json).
690///
691/// # Errors
692///
693/// Propagates `serde_json` encoding errors.
694pub fn canonical_bytes_for_signing(rule: &Rule) -> Result<Vec<u8>> {
695    let canonical = serde_json::json!({
696        "id": rule.id,
697        "kind": rule.kind,
698        "matcher": rule.matcher,
699        "severity": rule.severity,
700        "reason": rule.reason,
701        "namespace": rule.namespace,
702        (field_names::CREATED_BY): rule.created_by,
703        "enabled": rule.enabled,
704    });
705    serde_json::to_vec(&canonical).context("rules_store::canonical_bytes_for_signing: serialize")
706}
707
708/// v0.7.0 L1-6 — verify the operator signature recorded on `rule`
709/// against `operator_pubkey`. Returns `Ok(())` when the signature is
710/// present, well-formed, and verifies against
711/// [`canonical_bytes_for_signing`] of the row.
712///
713/// # Errors
714///
715/// - Returns a `SignatureError` when `rule.signature` is absent.
716/// - Returns a `SignatureError` when the signature is not 64 bytes.
717/// - Returns a `SignatureError` when the Ed25519 verify call fails
718///   (tampered row, wrong operator key, or post-signing direct SQL
719///   mutation — which is exactly the bypass attempt this catches).
720pub fn verify_rule_signature(
721    rule: &Rule,
722    operator_pubkey: &ed25519_dalek::VerifyingKey,
723) -> Result<(), ed25519_dalek::SignatureError> {
724    use ed25519_dalek::{Signature, Verifier};
725
726    let Some(sig_bytes) = rule.signature.as_ref() else {
727        return Err(ed25519_dalek::SignatureError::new());
728    };
729    if sig_bytes.len() != ed25519_dalek::SIGNATURE_LENGTH {
730        return Err(ed25519_dalek::SignatureError::new());
731    }
732    let mut sig_arr = [0u8; ed25519_dalek::SIGNATURE_LENGTH];
733    sig_arr.copy_from_slice(sig_bytes);
734    let signature = Signature::from_bytes(&sig_arr);
735    // canonical_bytes_for_signing can only fail on a serde_json
736    // internal error, which does not happen for the field shapes
737    // here. Map any such failure to a verification error rather than
738    // a panic — the caller is in the load-time enforcement path and
739    // must NOT crash the daemon.
740    let canonical =
741        canonical_bytes_for_signing(rule).map_err(|_| ed25519_dalek::SignatureError::new())?;
742    operator_pubkey.verify(&canonical, &signature)
743}
744
745fn row_to_rule(row: &rusqlite::Row<'_>) -> rusqlite::Result<Rule> {
746    Ok(Rule {
747        id: row.get(0)?,
748        kind: row.get(1)?,
749        matcher: row.get(2)?,
750        severity: row.get(3)?,
751        reason: row.get(4)?,
752        namespace: row.get(5)?,
753        created_by: row.get(6)?,
754        created_at: row.get(7)?,
755        enabled: row.get::<_, i64>(8)? != 0,
756        signature: row.get(9)?,
757        attest_level: row.get(10)?,
758    })
759}
760
761// ---------------------------------------------------------------------------
762// Tests
763// ---------------------------------------------------------------------------
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768
769    fn fresh_conn() -> Connection {
770        let conn = Connection::open_in_memory().unwrap();
771        conn.execute_batch(
772            "CREATE TABLE governance_rules (
773                 id TEXT PRIMARY KEY,
774                 kind TEXT NOT NULL,
775                 matcher TEXT NOT NULL,
776                 severity TEXT NOT NULL CHECK (severity IN ('refuse','warn','log')),
777                 reason TEXT NOT NULL,
778                 namespace TEXT NOT NULL DEFAULT '_global',
779                 created_by TEXT NOT NULL,
780                 created_at INTEGER NOT NULL,
781                 enabled INTEGER NOT NULL DEFAULT 1,
782                 signature BLOB,
783                 attest_level TEXT NOT NULL DEFAULT 'unsigned'
784             );",
785        )
786        .unwrap();
787        conn
788    }
789
790    fn make_rule(id: &str, kind: &str, enabled: bool) -> Rule {
791        Rule {
792            id: id.to_string(),
793            kind: kind.to_string(),
794            matcher: r#"{"k":"v"}"#.to_string(),
795            severity: "refuse".to_string(),
796            reason: "test".to_string(),
797            namespace: "_global".to_string(),
798            created_by: "test".to_string(),
799            created_at: 12345,
800            enabled,
801            signature: None,
802            attest_level: "unsigned".to_string(),
803        }
804    }
805
806    #[test]
807    fn insert_then_get_roundtrip() {
808        let conn = fresh_conn();
809        let rule = make_rule("R1", "bash", true);
810        insert(&conn, &rule).unwrap();
811        let got = get(&conn, "R1").unwrap();
812        assert_eq!(got.as_ref(), Some(&rule));
813    }
814
815    #[test]
816    fn get_returns_none_when_missing() {
817        let conn = fresh_conn();
818        assert_eq!(get(&conn, "nope").unwrap(), None);
819    }
820
821    #[test]
822    fn insert_duplicate_id_errors() {
823        let conn = fresh_conn();
824        let rule = make_rule("R1", "bash", true);
825        insert(&conn, &rule).unwrap();
826        assert!(insert(&conn, &rule).is_err());
827    }
828
829    #[test]
830    fn list_orders_by_id_ascending() {
831        let conn = fresh_conn();
832        insert(&conn, &make_rule("R3", "bash", true)).unwrap();
833        insert(&conn, &make_rule("R1", "bash", true)).unwrap();
834        insert(&conn, &make_rule("R2", "bash", true)).unwrap();
835        let all = list(&conn).unwrap();
836        let ids: Vec<&str> = all.iter().map(|r| r.id.as_str()).collect();
837        assert_eq!(ids, vec!["R1", "R2", "R3"]);
838    }
839
840    #[test]
841    fn list_enabled_by_kind_filters_correctly() {
842        // #1263 — on dev hosts where the operator already has an
843        // `operator.key.pub` configured, the substrate's enrichment
844        // hook reads + applies signed-rule semantics that this test
845        // doesn't seed; the result is a spurious failure isolated to
846        // dev hosts. Pin the no-pubkey posture via the RAII guard
847        // pattern documented in `governance/agent_action.rs::no_operator_pubkey`.
848        let _no_pubkey = force_no_operator_pubkey_for_test();
849        let conn = fresh_conn();
850        insert(&conn, &make_rule("R1", "bash", true)).unwrap();
851        insert(&conn, &make_rule("R2", "bash", false)).unwrap();
852        insert(&conn, &make_rule("R3", "filesystem_write", true)).unwrap();
853        let bash_rules = list_enabled_by_kind(&conn, "bash").unwrap();
854        assert_eq!(bash_rules.len(), 1);
855        assert_eq!(bash_rules[0].id, "R1");
856        let fs_rules = list_enabled_by_kind(&conn, "filesystem_write").unwrap();
857        assert_eq!(fs_rules.len(), 1);
858        assert_eq!(fs_rules[0].id, "R3");
859        let other = list_enabled_by_kind(&conn, "network_request").unwrap();
860        assert!(other.is_empty());
861    }
862
863    #[test]
864    fn remove_returns_true_on_hit_false_on_miss() {
865        let conn = fresh_conn();
866        insert(&conn, &make_rule("R1", "bash", true)).unwrap();
867        assert!(remove(&conn, "R1").unwrap());
868        assert!(!remove(&conn, "R1").unwrap());
869        assert_eq!(get(&conn, "R1").unwrap(), None);
870    }
871
872    /// Build a connection carrying BOTH the `governance_rules` table
873    /// (from [`fresh_conn`]) and the `signed_events` audit table so the
874    /// [`remove_signed`] audit emission has somewhere to land.
875    fn fresh_conn_with_audit() -> Connection {
876        let conn = fresh_conn();
877        conn.execute_batch(
878            "CREATE TABLE signed_events (
879                 id TEXT PRIMARY KEY,
880                 agent_id TEXT NOT NULL,
881                 event_type TEXT NOT NULL,
882                 payload_hash BLOB NOT NULL,
883                 signature BLOB,
884                 attest_level TEXT NOT NULL DEFAULT 'unsigned',
885                 timestamp TEXT NOT NULL,
886                 prev_hash BLOB,
887                 sequence INTEGER
888             );
889             CREATE UNIQUE INDEX idx_signed_events_sequence ON signed_events(sequence);",
890        )
891        .unwrap();
892        conn
893    }
894
895    /// v0.7.0 SR regression — `remove_signed` must (1) delete the rule
896    /// and (2) leave an operator-signed `governance.rule_removed` row on
897    /// the audit chain whose signature verifies against the operator
898    /// pubkey over the removed rule's canonical payload hash. Pre-fix the
899    /// CLI called the bare `remove`, deleting the rule with zero audit
900    /// residue.
901    #[test]
902    fn remove_signed_emits_operator_signed_audit_event_and_deletes() {
903        use ed25519_dalek::Verifier;
904        let conn = fresh_conn_with_audit();
905        let rule = make_rule("R1", "bash", true);
906        insert(&conn, &rule).unwrap();
907
908        let mut csprng = rand_core::OsRng;
909        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
910        let operator_pubkey = signing.verifying_key();
911
912        let removed = remove_signed(&conn, "R1", &signing, "operator").unwrap();
913        assert!(removed, "remove_signed must report the row was deleted");
914        assert_eq!(get(&conn, "R1").unwrap(), None, "rule row must be gone");
915
916        // Exactly one audit row, of the expected type + operator level.
917        let (event_type, attest_level, payload_hash, sig): (String, String, Vec<u8>, Vec<u8>) =
918            conn.query_row(
919                "SELECT event_type, attest_level, payload_hash, signature FROM signed_events",
920                [],
921                |row| {
922                    Ok((
923                        row.get::<_, String>(0)?,
924                        row.get::<_, String>(1)?,
925                        row.get::<_, Vec<u8>>(2)?,
926                        row.get::<_, Vec<u8>>(3)?,
927                    ))
928                },
929            )
930            .expect("exactly one signed_events row must exist");
931        assert_eq!(
932            event_type,
933            crate::signed_events::event_types::GOVERNANCE_RULE_REMOVED
934        );
935        assert_eq!(attest_level, OPERATOR_SIGNED_ATTEST_LEVEL);
936
937        // payload_hash must be the SHA-256 over the removed rule's
938        // canonical bytes, and the signature must verify against it.
939        let expected_hash =
940            crate::signed_events::payload_hash(&canonical_bytes_for_signing(&rule).unwrap());
941        assert_eq!(payload_hash, expected_hash);
942        assert_eq!(
943            sig.len(),
944            ed25519_dalek::SIGNATURE_LENGTH,
945            "signature must be 64 bytes"
946        );
947        let mut sig_arr = [0u8; ed25519_dalek::SIGNATURE_LENGTH];
948        sig_arr.copy_from_slice(&sig);
949        let signature = ed25519_dalek::Signature::from_bytes(&sig_arr);
950        operator_pubkey
951            .verify(&payload_hash, &signature)
952            .expect("operator signature over payload_hash must verify");
953    }
954
955    /// v0.7.0 SR regression — `remove_signed` on a missing id returns
956    /// `false` and emits NO audit event (no spurious chain rows).
957    #[test]
958    fn remove_signed_missing_id_returns_false_and_emits_nothing() {
959        let conn = fresh_conn_with_audit();
960        let mut csprng = rand_core::OsRng;
961        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
962
963        let removed = remove_signed(&conn, "nope", &signing, "operator").unwrap();
964        assert!(!removed);
965        let count: i64 = conn
966            .query_row("SELECT COUNT(*) FROM signed_events", [], |row| row.get(0))
967            .unwrap();
968        assert_eq!(count, 0, "no audit row may be emitted for a no-op removal");
969    }
970
971    #[test]
972    fn set_enabled_toggles() {
973        let conn = fresh_conn();
974        insert(&conn, &make_rule("R1", "bash", false)).unwrap();
975        assert!(set_enabled(&conn, "R1", true).unwrap());
976        assert!(get(&conn, "R1").unwrap().unwrap().enabled);
977        assert!(set_enabled(&conn, "R1", false).unwrap());
978        assert!(!get(&conn, "R1").unwrap().unwrap().enabled);
979    }
980
981    #[test]
982    fn set_enabled_missing_returns_false() {
983        let conn = fresh_conn();
984        assert!(!set_enabled(&conn, "nope", true).unwrap());
985    }
986
987    #[test]
988    fn update_signature_persists_blob_and_attest_level() {
989        let conn = fresh_conn();
990        insert(&conn, &make_rule("R1", "bash", true)).unwrap();
991        let sig = vec![1u8, 2, 3, 4];
992        assert!(update_signature(&conn, "R1", &sig, "operator_signed").unwrap());
993        let got = get(&conn, "R1").unwrap().unwrap();
994        assert_eq!(got.signature, Some(sig));
995        assert_eq!(got.attest_level, "operator_signed");
996    }
997
998    #[test]
999    fn update_signature_missing_returns_false() {
1000        let conn = fresh_conn();
1001        assert!(!update_signature(&conn, "nope", &[1, 2, 3], "operator_signed").unwrap());
1002    }
1003
1004    #[test]
1005    fn canonical_bytes_excludes_signature_fields() {
1006        let mut rule = make_rule("R1", "bash", true);
1007        rule.signature = Some(vec![9, 9, 9]);
1008        rule.attest_level = "operator_signed".to_string();
1009        let bytes = canonical_bytes(&rule).unwrap();
1010        let s = std::str::from_utf8(&bytes).unwrap();
1011        // The signature itself must NOT appear in the canonical
1012        // input (otherwise we'd be signing the signature).
1013        assert!(!s.contains("signature"));
1014        assert!(!s.contains("attest_level"));
1015        assert!(s.contains("\"id\":\"R1\""));
1016        assert!(s.contains("\"kind\":\"bash\""));
1017    }
1018
1019    #[test]
1020    fn severity_check_constraint_rejects_unknown() {
1021        let conn = fresh_conn();
1022        let mut rule = make_rule("R1", "bash", true);
1023        rule.severity = "unknown".to_string();
1024        assert!(insert(&conn, &rule).is_err());
1025    }
1026
1027    #[test]
1028    fn rule_serde_roundtrip() {
1029        let rule = make_rule("R1", "bash", true);
1030        let v = serde_json::to_value(&rule).unwrap();
1031        let back: Rule = serde_json::from_value(v).unwrap();
1032        assert_eq!(back, rule);
1033    }
1034
1035    // -----------------------------------------------------------------
1036    // L1-6 — canonical_bytes_for_signing + verify_rule_signature
1037    // -----------------------------------------------------------------
1038
1039    #[test]
1040    fn canonical_bytes_for_signing_includes_enabled() {
1041        let mut rule = make_rule("R1", "bash", true);
1042        let bytes_enabled = canonical_bytes_for_signing(&rule).unwrap();
1043        rule.enabled = false;
1044        let bytes_disabled = canonical_bytes_for_signing(&rule).unwrap();
1045        assert_ne!(
1046            bytes_enabled, bytes_disabled,
1047            "flipping `enabled` must change canonical bytes"
1048        );
1049        // Both encodings must literally contain the `"enabled"` field.
1050        for b in [&bytes_enabled, &bytes_disabled] {
1051            let s = std::str::from_utf8(b).unwrap();
1052            assert!(s.contains("\"enabled\""), "missing enabled in: {s}");
1053        }
1054    }
1055
1056    #[test]
1057    fn canonical_bytes_for_signing_excludes_signature_and_attest_level() {
1058        let mut rule = make_rule("R1", "bash", true);
1059        rule.signature = Some(vec![1, 2, 3, 4]);
1060        rule.attest_level = "operator_signed".to_string();
1061        let bytes = canonical_bytes_for_signing(&rule).unwrap();
1062        let s = std::str::from_utf8(&bytes).unwrap();
1063        assert!(!s.contains("signature"), "got: {s}");
1064        assert!(!s.contains("attest_level"), "got: {s}");
1065        // created_at is also excluded so a re-sign on the same row
1066        // (even after a migration replay that wrote a fresh `now()`
1067        // timestamp) produces stable bytes.
1068        assert!(!s.contains("created_at"), "got: {s}");
1069    }
1070
1071    #[test]
1072    fn verify_rule_signature_round_trips_under_correct_key() {
1073        use ed25519_dalek::Signer;
1074        let mut rule = make_rule("R1", "bash", false);
1075        let mut csprng = rand_core::OsRng;
1076        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
1077        let verifying = signing.verifying_key();
1078        let canonical = canonical_bytes_for_signing(&rule).unwrap();
1079        let sig = signing.sign(&canonical);
1080        rule.signature = Some(sig.to_bytes().to_vec());
1081        assert!(verify_rule_signature(&rule, &verifying).is_ok());
1082    }
1083
1084    #[test]
1085    fn verify_rule_signature_fails_on_enabled_flip() {
1086        use ed25519_dalek::Signer;
1087        let mut rule = make_rule("R1", "bash", false);
1088        let mut csprng = rand_core::OsRng;
1089        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
1090        let verifying = signing.verifying_key();
1091        let canonical = canonical_bytes_for_signing(&rule).unwrap();
1092        let sig = signing.sign(&canonical);
1093        rule.signature = Some(sig.to_bytes().to_vec());
1094        // Now flip `enabled` after signing — verify must fail.
1095        rule.enabled = true;
1096        assert!(
1097            verify_rule_signature(&rule, &verifying).is_err(),
1098            "signature must not verify after `enabled` flip"
1099        );
1100    }
1101
1102    #[test]
1103    fn verify_rule_signature_fails_on_matcher_tamper() {
1104        use ed25519_dalek::Signer;
1105        let mut rule = make_rule("R1", "bash", false);
1106        let mut csprng = rand_core::OsRng;
1107        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
1108        let verifying = signing.verifying_key();
1109        let canonical = canonical_bytes_for_signing(&rule).unwrap();
1110        let sig = signing.sign(&canonical);
1111        rule.signature = Some(sig.to_bytes().to_vec());
1112        // Tamper with matcher.
1113        rule.matcher = r#"{"k":"tampered"}"#.to_string();
1114        assert!(verify_rule_signature(&rule, &verifying).is_err());
1115    }
1116
1117    #[test]
1118    fn verify_rule_signature_fails_under_wrong_key() {
1119        use ed25519_dalek::Signer;
1120        let mut rule = make_rule("R1", "bash", false);
1121        let mut csprng = rand_core::OsRng;
1122        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
1123        let other = ed25519_dalek::SigningKey::generate(&mut csprng);
1124        let canonical = canonical_bytes_for_signing(&rule).unwrap();
1125        let sig = signing.sign(&canonical);
1126        rule.signature = Some(sig.to_bytes().to_vec());
1127        // Verify under the wrong public key.
1128        assert!(verify_rule_signature(&rule, &other.verifying_key()).is_err());
1129    }
1130
1131    #[test]
1132    fn verify_rule_signature_fails_on_missing_signature() {
1133        let mut csprng = rand_core::OsRng;
1134        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
1135        let rule = make_rule("R1", "bash", false);
1136        assert!(rule.signature.is_none());
1137        assert!(verify_rule_signature(&rule, &signing.verifying_key()).is_err());
1138    }
1139
1140    #[test]
1141    fn verify_rule_signature_fails_on_wrong_length_signature() {
1142        let mut csprng = rand_core::OsRng;
1143        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
1144        let mut rule = make_rule("R1", "bash", false);
1145        rule.signature = Some(vec![0u8; 8]); // not 64
1146        assert!(verify_rule_signature(&rule, &signing.verifying_key()).is_err());
1147    }
1148
1149    // -----------------------------------------------------------------
1150    // L1-6 — enforced_rule_passes
1151    // -----------------------------------------------------------------
1152
1153    fn signed_rule(id: &str, enabled: bool, signing: &ed25519_dalek::SigningKey) -> Rule {
1154        use ed25519_dalek::Signer;
1155        let mut rule = make_rule(id, "bash", enabled);
1156        rule.attest_level = "operator_signed".to_string();
1157        let canonical = canonical_bytes_for_signing(&rule).unwrap();
1158        let sig = signing.sign(&canonical);
1159        rule.signature = Some(sig.to_bytes().to_vec());
1160        rule
1161    }
1162
1163    #[test]
1164    fn enforced_rule_passes_when_no_pubkey_configured() {
1165        // Pre-L1-6 compat: every enabled rule passes through when no
1166        // operator pubkey is configured.
1167        let rule = make_rule("R1", "bash", true);
1168        assert!(enforced_rule_passes(&rule, None));
1169        // Even a row marked operator_signed but without a real
1170        // signature passes through when there is no key to verify
1171        // against — the substrate is in pre-L1-6 mode.
1172        let mut signed_ish = make_rule("R2", "bash", true);
1173        signed_ish.attest_level = "operator_signed".to_string();
1174        assert!(enforced_rule_passes(&signed_ish, None));
1175    }
1176
1177    #[test]
1178    fn enforced_rule_passes_signed_under_correct_key() {
1179        let mut csprng = rand_core::OsRng;
1180        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
1181        let pk = signing.verifying_key();
1182        let rule = signed_rule("R1", false, &signing);
1183        assert!(enforced_rule_passes(&rule, Some(&pk)));
1184    }
1185
1186    #[test]
1187    fn enforced_rule_passes_rejects_tampered_signed_row() {
1188        let mut csprng = rand_core::OsRng;
1189        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
1190        let pk = signing.verifying_key();
1191        let mut rule = signed_rule("R1", false, &signing);
1192        // Direct enabled-flip bypass attempt — sig no longer verifies.
1193        rule.enabled = true;
1194        assert!(!enforced_rule_passes(&rule, Some(&pk)));
1195    }
1196
1197    #[test]
1198    fn enforced_rule_passes_rejects_unsigned_with_pubkey_configured() {
1199        let mut csprng = rand_core::OsRng;
1200        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
1201        let pk = signing.verifying_key();
1202        let rule = make_rule("R1", "bash", true); // attest_level = unsigned
1203        assert!(!enforced_rule_passes(&rule, Some(&pk)));
1204    }
1205
1206    #[test]
1207    fn enforced_rule_passes_rejects_signed_under_wrong_key() {
1208        let mut csprng = rand_core::OsRng;
1209        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
1210        let other = ed25519_dalek::SigningKey::generate(&mut csprng);
1211        let rule = signed_rule("R1", false, &signing);
1212        assert!(!enforced_rule_passes(&rule, Some(&other.verifying_key())));
1213    }
1214
1215    // -----------------------------------------------------------------
1216    // v0.7-polish coverage recovery (issue #767) — count_enabled_rules
1217    // edge cases + log_missing_operator_pubkey_once invocation.
1218    // -----------------------------------------------------------------
1219
1220    #[test]
1221    fn count_enabled_rules_returns_zero_when_table_empty() {
1222        let conn = fresh_conn();
1223        assert_eq!(count_enabled_rules(&conn).unwrap(), 0);
1224    }
1225
1226    #[test]
1227    fn count_enabled_rules_returns_zero_when_table_missing() {
1228        // Bare in-memory connection — never created governance_rules.
1229        // Maps `no such table` to Ok(0) per docstring contract.
1230        let conn = Connection::open_in_memory().unwrap();
1231        assert_eq!(count_enabled_rules(&conn).unwrap(), 0);
1232    }
1233
1234    #[test]
1235    fn count_enabled_rules_counts_only_enabled_rows() {
1236        let conn = fresh_conn();
1237        insert(&conn, &make_rule("R1", "bash", true)).unwrap();
1238        insert(&conn, &make_rule("R2", "bash", false)).unwrap();
1239        insert(&conn, &make_rule("R3", "filesystem_write", true)).unwrap();
1240        // Two of three rows are enabled = 1.
1241        assert_eq!(count_enabled_rules(&conn).unwrap(), 2);
1242    }
1243
1244    #[test]
1245    fn count_enabled_rules_single_enabled_row() {
1246        let conn = fresh_conn();
1247        insert(&conn, &make_rule("R1", "bash", true)).unwrap();
1248        assert_eq!(count_enabled_rules(&conn).unwrap(), 1);
1249    }
1250
1251    #[test]
1252    fn log_missing_operator_pubkey_once_is_idempotent() {
1253        // The once-guard means repeat invocations are silent no-ops.
1254        // Drive it twice and confirm neither panics. The tracing
1255        // emission goes to the global subscriber; the assertion here
1256        // is that the once-cell mechanic works (no panic on second
1257        // call).
1258        log_missing_operator_pubkey_once(7);
1259        log_missing_operator_pubkey_once(99);
1260        // Reaching this line is the assertion: the second call did
1261        // not panic and returned cleanly via the `OnceLock::set` early
1262        // return.
1263    }
1264
1265    #[test]
1266    fn resolve_operator_pubkey_returns_none_when_env_and_file_absent() {
1267        // The cert harness for resolve_operator_pubkey is platform-bound
1268        // (XDG paths differ on macOS / Linux). The trivial smoke is to
1269        // call it under a wiped env: in either platform, the env is
1270        // unset and the disk file does not exist for the test runner's
1271        // user, so we receive None. This pins the early-out path.
1272        //
1273        // SAFETY: tests in this module run serially within this binary
1274        // because they share a fresh_conn fixture; but other binaries
1275        // run in parallel — we therefore use the `_TEST_BENIGN` suffix
1276        // to avoid collision with any real env any other test may set.
1277        let prior = std::env::var("AI_MEMORY_OPERATOR_PUBKEY").ok();
1278        // SAFETY: temporarily clearing then restoring; cargo test runs
1279        // in a process not shared with prod, so this transient unset
1280        // is safe.
1281        unsafe { std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY") };
1282        let _ = resolve_operator_pubkey();
1283        // l1_6_attest_active just wraps resolve_operator_pubkey; smoke.
1284        let _ = l1_6_attest_active();
1285        if let Some(v) = prior {
1286            unsafe { std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", v) };
1287        }
1288    }
1289
1290    #[test]
1291    fn resolve_operator_pubkey_accepts_url_safe_no_pad_base64() {
1292        use base64::Engine;
1293        // Generate a real verifying key and encode it.
1294        let mut csprng = rand_core::OsRng;
1295        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
1296        let vk = signing.verifying_key();
1297        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(vk.as_bytes());
1298
1299        let prior = std::env::var("AI_MEMORY_OPERATOR_PUBKEY").ok();
1300        unsafe { std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", &encoded) };
1301        let got = resolve_operator_pubkey();
1302        assert!(got.is_some(), "expected to decode URL_SAFE_NO_PAD pubkey");
1303        assert_eq!(got.unwrap().as_bytes(), vk.as_bytes());
1304        // Restore prior state.
1305        match prior {
1306            Some(v) => unsafe { std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", v) },
1307            None => unsafe { std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY") },
1308        }
1309    }
1310
1311    /// H5 regression — the verifier must resolve the operator pubkey from
1312    /// the SAME key directory the signer writes to (honoring
1313    /// `AI_MEMORY_KEY_DIR`), reading the base64 keygen-layout
1314    /// `operator.key.pub` file. Pre-fix the verifier ignored
1315    /// `AI_MEMORY_KEY_DIR`, so a custom key-dir deployment resolved no
1316    /// pubkey and L1-6 attestation silently failed open.
1317    #[test]
1318    fn resolve_operator_pubkey_reads_keygen_layout_from_key_dir() {
1319        use base64::Engine;
1320        let _lock = crate::identity::keypair::key_dir_env_lock()
1321            .lock()
1322            .unwrap_or_else(std::sync::PoisonError::into_inner);
1323
1324        let mut csprng = rand_core::OsRng;
1325        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
1326        let vk = signing.verifying_key();
1327        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(vk.as_bytes());
1328
1329        let dir = tempfile::TempDir::new().expect("tempdir");
1330        std::fs::write(dir.path().join(OPERATOR_PUBKEY_KEYGEN_FILE), encoded).unwrap();
1331
1332        // The env-var path takes precedence, so it MUST be unset for this
1333        // test to exercise the key-dir resolution branch.
1334        let prior_pubkey = std::env::var("AI_MEMORY_OPERATOR_PUBKEY").ok();
1335        let prior_key_dir = std::env::var("AI_MEMORY_KEY_DIR").ok();
1336        unsafe {
1337            std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY");
1338            std::env::set_var("AI_MEMORY_KEY_DIR", dir.path());
1339        }
1340
1341        let got = resolve_operator_pubkey();
1342
1343        // Restore env before asserting so a failed assertion never leaks
1344        // state into sibling tests.
1345        unsafe {
1346            match prior_pubkey {
1347                Some(v) => std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", v),
1348                None => std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY"),
1349            }
1350            match prior_key_dir {
1351                Some(v) => std::env::set_var("AI_MEMORY_KEY_DIR", v),
1352                None => std::env::remove_var("AI_MEMORY_KEY_DIR"),
1353            }
1354        }
1355
1356        assert!(
1357            got.is_some(),
1358            "verifier must resolve operator.key.pub from AI_MEMORY_KEY_DIR"
1359        );
1360        assert_eq!(got.unwrap().as_bytes(), vk.as_bytes());
1361    }
1362
1363    /// H5 regression — the verifier must also accept the legacy raw
1364    /// 32-byte `operator.pub` layout from the resolved key directory, so
1365    /// pre-keygen deployments keep verifying without re-enrolment.
1366    #[test]
1367    fn resolve_operator_pubkey_reads_legacy_raw_layout_from_key_dir() {
1368        let _lock = crate::identity::keypair::key_dir_env_lock()
1369            .lock()
1370            .unwrap_or_else(std::sync::PoisonError::into_inner);
1371
1372        let mut csprng = rand_core::OsRng;
1373        let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
1374        let vk = signing.verifying_key();
1375
1376        let dir = tempfile::TempDir::new().expect("tempdir");
1377        // Legacy layout: raw 32 bytes, no base64 wrapper.
1378        std::fs::write(dir.path().join(OPERATOR_PUBKEY_LEGACY_FILE), vk.as_bytes()).unwrap();
1379
1380        let prior_pubkey = std::env::var("AI_MEMORY_OPERATOR_PUBKEY").ok();
1381        let prior_key_dir = std::env::var("AI_MEMORY_KEY_DIR").ok();
1382        unsafe {
1383            std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY");
1384            std::env::set_var("AI_MEMORY_KEY_DIR", dir.path());
1385        }
1386
1387        let got = resolve_operator_pubkey();
1388
1389        unsafe {
1390            match prior_pubkey {
1391                Some(v) => std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", v),
1392                None => std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY"),
1393            }
1394            match prior_key_dir {
1395                Some(v) => std::env::set_var("AI_MEMORY_KEY_DIR", v),
1396                None => std::env::remove_var("AI_MEMORY_KEY_DIR"),
1397            }
1398        }
1399
1400        assert!(
1401            got.is_some(),
1402            "verifier must resolve legacy operator.pub from AI_MEMORY_KEY_DIR"
1403        );
1404        assert_eq!(got.unwrap().as_bytes(), vk.as_bytes());
1405    }
1406}