Skip to main content

ai_memory/cli/
rules.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `ai-memory rules` subcommand — operator-facing CRUD for the
5//! substrate-level agent-action rules engine (issue #691).
6//!
7//! Six verbs:
8//!
9//! * `add`     — insert a new rule (mutation: requires operator key).
10//! * `list`    — print every rule, including disabled ones (read).
11//! * `check`   — evaluate a proposed action against the live rule set
12//!               and print the [`Decision`] (read).
13//! * `enable`  — flip `enabled = 1` on an existing rule (mutation).
14//! * `disable` — flip `enabled = 0` on an existing rule (mutation).
15//! * `remove`  — delete a rule (mutation).
16//!
17//! # Operator identity (mutation gate)
18//!
19//! Per issue #691 design revision 2026-05-13, the four mutation
20//! verbs require the operator's Ed25519 keypair on disk at
21//! `${AI_MEMORY_KEY_DIR:-~/.config/ai-memory/keys}/operator.priv`
22//! (mode 0600). The CLI:
23//!
24//! 1. Resolves the key directory (env override → default).
25//! 2. Loads `operator.priv` and verifies mode bits (0600 on Unix).
26//! 3. Signs the canonical rule encoding via Ed25519.
27//! 4. Persists the signature alongside the rule (
28//!    [`crate::governance::rules_store::update_signature`]).
29//!
30//! If the key file is absent / wrong-mode, the CLI refuses with
31//! `governance.no_operator_key` error. No mutation lands.
32//!
33//! The HTTP / MCP surfaces enforce the same gate: HTTP verifies an
34//! Ed25519 signature header against `operator.pub`; MCP stdio
35//! mutation tools are explicitly disabled (return
36//! `governance.not_available_over_mcp`).
37
38use crate::models::field_names;
39use std::path::{Path, PathBuf};
40
41use anyhow::{Context, Result, bail};
42use clap::{Args, Subcommand};
43use ed25519_dalek::{Signer, SigningKey};
44use serde::Serialize;
45
46use crate::cli::CliOutput;
47use crate::governance::agent_action::{AgentAction, action_kinds as ak, check_agent_action};
48use crate::governance::rules_store::{self, Rule};
49use crate::identity::keypair as kp;
50
51/// Operator Ed25519 private-key file name under the key dir (#1558 batch 6).
52const OPERATOR_KEY_FILENAME: &str = "operator.key";
53
54/// Wire id reserved for the operator's keypair file on disk. Stored
55/// under the same directory as per-agent keys but treated specially
56/// — the agent_id resolution stack never returns this id; only the
57/// rules subcommand looks for it.
58pub const OPERATOR_KEY_ID: &str = "operator";
59
60/// `attest_level` stamped on rules after the operator signs them.
61/// Re-exported from the governance layer so the rules table and the
62/// `signed_events` audit chain share one source of truth for the
63/// literal (see [`crate::governance::rules_store::OPERATOR_SIGNED_ATTEST_LEVEL`]).
64pub const OPERATOR_SIGNED_LEVEL: &str =
65    crate::governance::rules_store::OPERATOR_SIGNED_ATTEST_LEVEL;
66
67/// Length of a raw Ed25519 signing-key seed on disk.
68const ED25519_SEED_LEN: usize = ed25519_dalek::SECRET_KEY_LENGTH;
69/// Length of a raw Ed25519 verifying-key on disk (decoded base64).
70const ED25519_PUBLIC_LEN: usize = ed25519_dalek::PUBLIC_KEY_LENGTH;
71
72#[derive(Args)]
73pub struct RulesArgs {
74    /// Override the default key storage directory.
75    /// Honors `AI_MEMORY_KEY_DIR` env var when this flag is omitted.
76    #[arg(long, value_name = "PATH", global = true)]
77    pub key_dir: Option<PathBuf>,
78    #[command(subcommand)]
79    pub action: RulesAction,
80}
81
82#[derive(Subcommand)]
83pub enum RulesAction {
84    /// Add a new agent-action rule. Requires operator keypair on
85    /// disk; signs the canonical row encoding before persisting.
86    Add {
87        /// Rule id (e.g. R005, `tmp-noisy-build`). Must be unique.
88        #[arg(long)]
89        id: String,
90        /// Action kind: `bash` / `filesystem_write` / `network_request`
91        /// / `process_spawn` / `custom`.
92        #[arg(long)]
93        kind: String,
94        /// Matcher JSON. Shape depends on `--kind`. See
95        /// `docs/governance/agent-action-rules.md`.
96        #[arg(long)]
97        matcher: String,
98        /// Severity: `refuse` / `warn` / `log`.
99        #[arg(long, default_value = "refuse")]
100        severity: String,
101        /// Human-readable reason surfaced to the agent on a match.
102        #[arg(long)]
103        reason: String,
104        /// Optional namespace scope. Defaults to `_global`.
105        #[arg(long, default_value = crate::quotas::GLOBAL_NAMESPACE)]
106        namespace: String,
107        /// Land the rule with `enabled = 0` (operator activates
108        /// later via `ai-memory rules enable <id> --sign`).
109        #[arg(long)]
110        disabled: bool,
111        /// Sign the rule with the operator keypair on disk. Required
112        /// for non-dry-run inserts; without `--sign` the CLI refuses.
113        #[arg(long)]
114        sign: bool,
115    },
116    /// List every rule (enabled + disabled). Read-only, no key
117    /// required.
118    List,
119    /// Evaluate a proposed action against the live rule set without
120    /// committing it. Read-only. The output is the same JSON
121    /// [`Decision`] shape the MCP / HTTP path returns.
122    Check {
123        /// Action kind: same vocabulary as `add --kind`.
124        #[arg(long)]
125        kind: String,
126        /// Action payload JSON. For Bash: `{"command":"ls"}`.
127        /// For `FilesystemWrite`: `{"path":"/tmp/x"}`. Etc.
128        #[arg(long)]
129        payload: String,
130        /// Optional agent id; defaults to the resolved NHI id for
131        /// audit-row provenance.
132        #[arg(long)]
133        agent_id: Option<String>,
134    },
135    /// Activate a rule (flip `enabled = 1`). Requires `--sign`.
136    Enable {
137        /// Rule id.
138        #[arg(long)]
139        id: String,
140        /// Sign the activation with the operator key.
141        #[arg(long)]
142        sign: bool,
143    },
144    /// Deactivate a rule (flip `enabled = 0`). Requires `--sign`.
145    Disable {
146        /// Rule id.
147        #[arg(long)]
148        id: String,
149        /// Sign the deactivation with the operator key.
150        #[arg(long)]
151        sign: bool,
152    },
153    /// Remove a rule from the table. Requires `--sign`.
154    Remove {
155        /// Rule id.
156        #[arg(long)]
157        id: String,
158        /// Sign the removal with the operator key.
159        #[arg(long)]
160        sign: bool,
161    },
162    /// v0.7.0 L1-6 — generate a fresh Ed25519 operator keypair and
163    /// write the private 32-byte seed to `--out` (mode 0600 on Unix)
164    /// plus a base64-encoded public key sibling at `<out>.pub`
165    /// (mode 0644). Default `--out` is `~/.config/ai-memory/operator.key`.
166    ///
167    /// Refuses to overwrite an existing file unless `--force` is passed;
168    /// even with `--force` a stderr warning is emitted (an existing
169    /// operator key is the keystone of the signature verify chain — a
170    /// silent overwrite would invalidate every prior signed rule).
171    ///
172    /// The 32-byte seed never appears in stdout, stderr, or any
173    /// memory the agent emits. Only the fingerprint
174    /// `sha256(public_key)[:16]` is logged.
175    Keygen {
176        /// Output path for the 32-byte private seed. The base64
177        /// public key sibling is written to `<out>.pub`.
178        #[arg(long, value_name = "PATH")]
179        out: Option<PathBuf>,
180        /// Overwrite an existing private/public key pair. Emits a
181        /// stderr warning even when set. Default: refuse to overwrite.
182        #[arg(long)]
183        force: bool,
184    },
185    /// v0.7.0 L1-6 — sign every seeded rule (R001..R004 today) with
186    /// the operator key. Sets `signature = ed25519(canonical_payload)`
187    /// and `attest_level = 'operator_signed'`. `enabled` stays at 0
188    /// — the operator audits and activates manually after this runs.
189    ///
190    /// The canonical payload includes `enabled`, so a direct
191    /// `UPDATE governance_rules SET enabled = 1` after signing would
192    /// fail signature verification at load time — that is the
193    /// bypass-prevention property.
194    SignSeed {
195        /// Path to the operator private seed (32 bytes) — same shape
196        /// `rules keygen --out` writes. Defaults to
197        /// `~/.config/ai-memory/operator.key`.
198        #[arg(long, value_name = "PATH")]
199        key: Option<PathBuf>,
200        /// Override the DB path (useful for smoke tests against a
201        /// scratch sqlite file). Defaults to the same `--db` the
202        /// rest of the `rules` verbs use (the top-level `ai-memory
203        /// --db` flag).
204        #[arg(long, value_name = "PATH")]
205        db: Option<PathBuf>,
206    },
207}
208
209/// JSON envelope used by `--json` callers — keeps a stable wire shape
210/// across the six verbs.
211#[derive(Serialize)]
212struct CliEnvelope<'a> {
213    verb: &'a str,
214    result: serde_json::Value,
215}
216
217/// Dispatch entry point called by `daemon_runtime::run`.
218///
219/// # Errors
220///
221/// Returns an error on a SQLite / key / signature failure; the
222/// caller surfaces the error to the operator via the standard
223/// `anyhow` chain.
224pub fn run(
225    db_path: &std::path::Path,
226    args: RulesArgs,
227    json: bool,
228    out: &mut CliOutput<'_>,
229) -> Result<()> {
230    // Open via the migrating path (`crate::db::open` runs apply_migrations)
231    // rather than a raw `rusqlite::Connection::open`, so the `rules` verbs
232    // work against a FRESH db — the `governance_rules` table is created on
233    // open like every other db-opening CLI command. Pre-fix, a standalone
234    // `ai-memory rules <verb>` against a never-migrated db failed with
235    // `rules_store::list: prepare — no such table: governance_rules`; this
236    // broke Form-7 governance bootstrap on fresh (esp. postgres-backed)
237    // fleet peers where the daemon — which would otherwise have migrated the
238    // local sqlite — has not yet started. Surfaced by the do-1461 A2A run.
239    let conn = crate::db::open(db_path)
240        .with_context(|| format!("rules: open db at {}", db_path.display()))?;
241    let key_dir = resolve_key_dir(args.key_dir.as_deref())?;
242
243    match args.action {
244        RulesAction::Add {
245            id,
246            kind,
247            matcher,
248            severity,
249            reason,
250            namespace,
251            disabled,
252            sign,
253        } => {
254            if !sign {
255                bail!("governance.no_operator_key: `rules add` requires --sign");
256            }
257            let signing_key = load_operator_signing_key_from_dir(&key_dir)?;
258            // Validate matcher JSON shape now — better to refuse at
259            // input time than on the next check call.
260            let matcher_json: serde_json::Value = serde_json::from_str(&matcher)
261                .with_context(|| format!("rules add: matcher is not valid JSON: {matcher}"))?;
262            // SEC-12 / COR-10 (Cluster D, issue #767) — the bash
263            // matcher field is a LITERAL substring (despite the
264            // legacy `command_regex` field name). Reject regex
265            // metacharacters at CLI input time so an operator who
266            // pastes `rm\s+-rf` does not silently install a
267            // never-matching rule.
268            if let Some(val) = matcher_json
269                .get(crate::governance::agent_action::MATCHER_COMMAND_SUBSTRING)
270                .or_else(|| {
271                    matcher_json.get(crate::governance::agent_action::MATCHER_COMMAND_REGEX)
272                })
273                .and_then(|v| v.as_str())
274            {
275                crate::governance::agent_action::validate_command_substring(val)
276                    .map_err(|e| anyhow::anyhow!("rules add: {e}"))?;
277                if matcher_json
278                    .get(crate::governance::agent_action::MATCHER_COMMAND_REGEX)
279                    .is_some()
280                    && matcher_json
281                        .get(crate::governance::agent_action::MATCHER_COMMAND_SUBSTRING)
282                        .is_none()
283                {
284                    tracing::warn!(
285                        "rules add: matcher field `command_regex` is DEPRECATED — rename to \
286                         `command_substring` (the engine has always done literal substring \
287                         matching, not regex). See SEC-12 in the v0.7.0 cluster-D fix."
288                    );
289                }
290            }
291            let created_at = chrono::Utc::now().timestamp();
292            let agent_id = resolve_agent_id();
293            let mut rule = Rule {
294                id: id.clone(),
295                kind,
296                matcher,
297                severity,
298                reason,
299                namespace,
300                created_by: agent_id,
301                created_at,
302                enabled: !disabled,
303                signature: None,
304                attest_level: crate::models::AttestLevel::Unsigned.as_str().to_string(),
305            };
306            // v0.7.0 issue #800 / Form 7 critical fix: sign the
307            // canonical bytes that `verify_rule_signature` will read
308            // back. `canonical_bytes` (without `enabled`) and
309            // `canonical_bytes_for_signing` (with `enabled`) were
310            // out-of-sync between the signer and verifier — the
311            // signatures produced here never validated, the L1-6
312            // gate silently skipped every "operator_signed" rule,
313            // and Form 7 enforcement returned `allow` for every
314            // action. Use `canonical_bytes_for_signing` so the
315            // verifier accepts what we produce.
316            let canonical = rules_store::canonical_bytes_for_signing(&rule)?;
317            let sig = signing_key.sign(&canonical);
318            rule.signature = Some(sig.to_bytes().to_vec());
319            rule.attest_level = OPERATOR_SIGNED_LEVEL.to_string();
320            rules_store::insert(&conn, &rule)?;
321            emit_ok(json, out, "rules.add", &rule_to_json(&rule))?;
322            Ok(())
323        }
324        RulesAction::List => {
325            let rules = rules_store::list(&conn)?;
326            let payload = serde_json::Value::Array(rules.iter().map(rule_to_json).collect());
327            emit_ok(json, out, "rules.list", &payload)?;
328            Ok(())
329        }
330        RulesAction::Check {
331            kind,
332            payload,
333            agent_id,
334        } => {
335            let action = build_action(&kind, &payload)?;
336            let resolved_agent = agent_id.unwrap_or_else(resolve_agent_id);
337            let decision = check_agent_action(&conn, &resolved_agent, &action)?;
338            emit_ok(json, out, "rules.check", &serde_json::to_value(&decision)?)?;
339            Ok(())
340        }
341        RulesAction::Enable { id, sign } => {
342            if !sign {
343                bail!("governance.no_operator_key: `rules enable` requires --sign");
344            }
345            let signing_key = load_operator_signing_key_from_dir(&key_dir)?;
346            let Some(mut rule) = rules_store::get(&conn, &id)? else {
347                bail!("rules.enable: no rule with id={id}");
348            };
349            rule.enabled = true;
350            // Issue #800 critical fix: signer must use the same
351            // canonical encoding as `verify_rule_signature`
352            // (otherwise the L1-6 enforcement gate skips every rule
353            // and Form 7 returns `allow` for every action). See the
354            // matching comment in the `Add` arm.
355            let canonical = rules_store::canonical_bytes_for_signing(&rule)?;
356            let sig = signing_key.sign(&canonical);
357            rules_store::set_enabled(&conn, &id, true)?;
358            rules_store::update_signature(&conn, &id, &sig.to_bytes(), OPERATOR_SIGNED_LEVEL)?;
359            let updated =
360                rules_store::get(&conn, &id)?.context("rules.enable: row vanished after update")?;
361            emit_ok(json, out, "rules.enable", &rule_to_json(&updated))?;
362            Ok(())
363        }
364        RulesAction::Disable { id, sign } => {
365            if !sign {
366                bail!("governance.no_operator_key: `rules disable` requires --sign");
367            }
368            let signing_key = load_operator_signing_key_from_dir(&key_dir)?;
369            let Some(mut rule) = rules_store::get(&conn, &id)? else {
370                bail!("rules.disable: no rule with id={id}");
371            };
372            rule.enabled = false;
373            // Issue #800 critical fix: parity with the Enable arm —
374            // signer + verifier must use the same canonical bytes.
375            let canonical = rules_store::canonical_bytes_for_signing(&rule)?;
376            let sig = signing_key.sign(&canonical);
377            rules_store::set_enabled(&conn, &id, false)?;
378            rules_store::update_signature(&conn, &id, &sig.to_bytes(), OPERATOR_SIGNED_LEVEL)?;
379            let updated = rules_store::get(&conn, &id)?
380                .context("rules.disable: row vanished after update")?;
381            emit_ok(json, out, "rules.disable", &rule_to_json(&updated))?;
382            Ok(())
383        }
384        RulesAction::Remove { id, sign } => {
385            if !sign {
386                bail!("governance.no_operator_key: `rules remove` requires --sign");
387            }
388            let signing_key = load_operator_signing_key_from_dir(&key_dir)?;
389            // v0.7.0 SR — route deletion through the audited path so the
390            // removal lands an operator-signed row on the signed_events
391            // chain before the rule row is deleted (atomic). The old
392            // bare `rules_store::remove` left no tamper-evident trace.
393            let removed = rules_store::remove_signed(&conn, &id, &signing_key, OPERATOR_KEY_ID)?;
394            let payload = serde_json::json!({ "id": id, "removed": removed });
395            emit_ok(json, out, "rules.remove", &payload)?;
396            Ok(())
397        }
398        RulesAction::Keygen {
399            out: out_path,
400            force,
401        } => {
402            // #1610 — keygen must write where the signing verbs read.
403            // When the operator relocated the key store (`--key-dir`
404            // flag or `AI_MEMORY_KEY_DIR`), the default write path is
405            // `<key_dir>/operator.key` — the exact Layout-2 path
406            // `load_operator_signing_key_from_dir` and the L1-6
407            // verify ladder consult. Only with NO override in force
408            // does the legacy singleton `<config>/ai-memory/operator.key`
409            // location apply (the Layout-3 parent fallback covers it).
410            let key_dir_overridden = args.key_dir.is_some() || kp::key_dir_env_override().is_some();
411            let resolved =
412                resolve_keygen_out_path(out_path.as_deref(), &key_dir, key_dir_overridden)?;
413            let fingerprint = keygen_operator(&resolved, force, out)?;
414            // #1686 — generating an operator key flips the substrate to
415            // attest-active (`resolve_operator_pubkey().is_some()`), which makes
416            // `enforced_rule_passes` SKIP every enabled rule that is not
417            // operator-signed. The --force path warns about prior operator-signed
418            // rules going invalid, but a FRESH keygen silently disables any
419            // enabled-but-unsigned seed rules. Warn loudly so the operator knows
420            // to run `rules sign-seed`.
421            if let Ok(rules) = rules_store::list(&conn) {
422                let dormant = rules
423                    .iter()
424                    .filter(|r| r.enabled && r.attest_level != OPERATOR_SIGNED_LEVEL)
425                    .count();
426                if dormant > 0 {
427                    writeln!(
428                        out.stderr,
429                        "WARNING: {dormant} enabled rule(s) are not operator-signed. \
430                         Generating this operator key activates signature enforcement, so \
431                         those rules will be SKIPPED at load time until you run \
432                         `ai-memory rules sign-seed`."
433                    )?;
434                }
435            }
436            let payload = serde_json::json!({
437                "path": resolved.display().to_string(),
438                "public_path": format!("{}.pub", resolved.display()),
439                "fingerprint": fingerprint,
440            });
441            emit_ok(json, out, "rules.keygen", &payload)?;
442            Ok(())
443        }
444        RulesAction::SignSeed { key, db } => {
445            // The top-level `--db` flag already produced `conn` above.
446            // When the operator passes `--db` on the subcommand (the
447            // L1-6 ergonomic shortcut for one-shot scripts), reopen
448            // against that path; otherwise reuse the open handle.
449            //
450            // #822: precedence for the operator key path —
451            //   1. explicit `--key <PATH>` on the subcommand wins;
452            //   2. else derive a path under the top-level `--key-dir` /
453            //      `AI_MEMORY_KEY_DIR` resolution (line above), matching
454            //      the dual-layout discipline of
455            //      `load_operator_signing_key_from_dir` —
456            //         a. `<key_dir>/operator.key` (the singleton
457            //            layout `rules keygen` writes);
458            //         b. `<key_dir>/operator.priv` (the legacy `kp::save`
459            //            layout that paired with `operator.pub`).
460            //      Both files are raw 32-byte ed25519 seeds, so the
461            //      same `load_operator_signing_key` reader handles
462            //      either path without further branching;
463            //   3. else `sign_seed_rules`'s own fallback to
464            //      `resolve_operator_key_path(None)` (the legacy
465            //      `~/.config/ai-memory/operator.key` shape) keeps
466            //      working when neither is supplied.
467            let resolved_key: Option<PathBuf> = key.or_else(|| {
468                let key_layout = key_dir.join(OPERATOR_KEY_FILENAME);
469                if key_layout.exists() {
470                    return Some(key_layout);
471                }
472                let priv_layout = key_dir.join("operator.priv");
473                if priv_layout.exists() {
474                    return Some(priv_layout);
475                }
476                None
477            });
478            if let Some(db_path) = db {
479                // Migrating open (see the note on the top-level `conn`): the
480                // `--db` sign-seed override must also create the schema on a
481                // fresh path.
482                let conn2 = crate::db::open(&db_path).with_context(|| {
483                    format!("rules.sign-seed: open db at {}", db_path.display())
484                })?;
485                sign_seed_rules(&conn2, resolved_key.as_deref(), json, out)?;
486            } else {
487                sign_seed_rules(&conn, resolved_key.as_deref(), json, out)?;
488            }
489            Ok(())
490        }
491    }
492}
493
494// ---------------------------------------------------------------------------
495// L1-6 — operator keypair generation + loading
496// ---------------------------------------------------------------------------
497
498/// Resolve where `rules keygen` writes (#1610). Precedence:
499///
500/// 1. explicit `--out <PATH>` — always wins;
501/// 2. `<key_dir>/operator.key` when a key-dir override is in force
502///    (`--key-dir` flag or `AI_MEMORY_KEY_DIR`) — keeps the write
503///    path and the `--sign` verbs' read path
504///    ([`load_operator_signing_key_from_dir`] Layout 2) on the SAME
505///    directory, closing the split-brain where keygen wrote
506///    `~/.config/ai-memory/operator.key` while `enable --sign` read
507///    an empty `/etc/ai-memory/keys` and R001–R004 never enabled;
508/// 3. the legacy singleton `<config>/ai-memory/operator.key`
509///    ([`resolve_operator_key_path`]) when nothing is overridden —
510///    the Layout-3 parent fallback makes `keygen → enable` work
511///    there without any mirroring.
512fn resolve_keygen_out_path(
513    explicit_out: Option<&Path>,
514    key_dir: &Path,
515    key_dir_overridden: bool,
516) -> Result<PathBuf> {
517    if let Some(p) = explicit_out {
518        return Ok(p.to_path_buf());
519    }
520    if key_dir_overridden {
521        return Ok(key_dir.join(OPERATOR_KEY_FILENAME));
522    }
523    resolve_operator_key_path(None)
524}
525
526/// Resolve the operator key path: explicit `--out` override → default
527/// `~/.config/ai-memory/operator.key`. The default lives next to the
528/// per-agent `keys/` directory rather than under it because the
529/// operator key is a singleton, not an enumerable list — see
530/// `migrations/sqlite/0024_v07_governance_rules.sql` for the design
531/// note.
532fn resolve_operator_key_path(override_path: Option<&Path>) -> Result<PathBuf> {
533    if let Some(p) = override_path {
534        return Ok(p.to_path_buf());
535    }
536    let base = dirs::config_dir()
537        .ok_or_else(|| anyhow::anyhow!("rules.keygen: OS did not advertise a config directory"))?;
538    Ok(base.join("ai-memory").join(OPERATOR_KEY_FILENAME))
539}
540
541/// Generate a fresh Ed25519 keypair, write the 32-byte seed to `path`
542/// (mode 0600 on Unix) and the base64-encoded verifying key to
543/// `<path>.pub` (mode 0644). Returns the public-key fingerprint
544/// (`sha256(pub_bytes)` truncated to 16 hex chars) for the success
545/// line.
546///
547/// # Invariants
548///
549/// - Refuses to overwrite an existing private or public file unless
550///   `force` is true.
551/// - Even with `force`, emits a `WARNING` line to `stderr` reminding
552///   the operator that all prior signatures will become invalid.
553/// - On non-Unix targets the mode bits cannot be enforced; the
554///   function emits a `WARNING` to `stderr` and skips the chmod.
555///
556/// # Security
557///
558/// The 32-byte seed is in scope only inside this function. It is
559/// never returned, never logged, never embedded in a `tracing!`
560/// macro. The caller receives only the fingerprint.
561fn keygen_operator(path: &Path, force: bool, out: &mut CliOutput<'_>) -> Result<String> {
562    let pub_path = pub_sibling_path(path);
563
564    if !force && (path.exists() || pub_path.exists()) {
565        bail!(
566            "rules.keygen: refusing to overwrite existing key material at {} (or {}). \
567             Pass --force to replace — note that all prior operator-signed rules \
568             will fail signature verification with the new key.",
569            path.display(),
570            pub_path.display()
571        );
572    }
573    if force && (path.exists() || pub_path.exists()) {
574        writeln!(
575            out.stderr,
576            "WARNING: rules.keygen --force replaces existing operator key. \
577             All prior operator-signed rules become INVALID and will be skipped at \
578             load time until re-signed with the new key."
579        )?;
580    }
581
582    if let Some(parent) = path.parent()
583        && !parent.as_os_str().is_empty()
584    {
585        std::fs::create_dir_all(parent)
586            .with_context(|| format!("rules.keygen: create parent dir {}", parent.display()))?;
587    }
588
589    // SECURITY: `OsRng` is the platform CSPRNG; ed25519-dalek's
590    // `SigningKey::generate` consumes 32 bytes from it as the seed.
591    let mut csprng = rand_core::OsRng;
592    let signing = SigningKey::generate(&mut csprng);
593    let verifying = signing.verifying_key();
594    let seed = signing.to_bytes();
595    let pub_bytes = verifying.to_bytes();
596
597    // Private seed: mode 0600 on Unix; on Windows write the file but
598    // emit a stderr warning that mode bits are unenforced.
599    write_operator_private_seed(path, &seed, out)?;
600    // Public key: base64(URL_SAFE_NO_PAD) of the 32-byte verifying key.
601    write_operator_public_key(&pub_path, &pub_bytes)?;
602
603    // Best-effort post-write fingerprint. We zero `seed` after use
604    // out of habit; the local variable goes out of scope at function
605    // end so the memory page is reclaimed on the next allocation.
606    let fingerprint = pub_fingerprint(&pub_bytes);
607
608    // SECURITY: print the fingerprint, never the seed.
609    writeln!(
610        out.stdout,
611        "Ed25519 operator key generated: {fingerprint} -> {}",
612        path.display()
613    )?;
614
615    // `seed` is `[u8; 32]`, a `Copy` type, so an explicit `drop`
616    // call is a no-op. The Rust compiler reclaims the stack slot
617    // automatically on scope exit; we simply rely on that.
618
619    Ok(fingerprint)
620}
621
622/// Write the 32-byte private seed to `path` with mode 0600. The file
623/// is created with `O_CREAT | O_WRONLY | O_TRUNC` so a pre-existing
624/// file is truncated and the new bytes land atomically. After the
625/// write we verify the mode bits via `stat` and refuse if anything
626/// other than 0o600 is observed.
627fn write_operator_private_seed(
628    path: &Path,
629    seed: &[u8; ED25519_SEED_LEN],
630    #[cfg_attr(unix, allow(unused_variables))] out: &mut CliOutput<'_>,
631) -> Result<()> {
632    #[cfg(unix)]
633    {
634        use std::io::Write;
635        use std::os::unix::fs::OpenOptionsExt;
636        use std::os::unix::fs::PermissionsExt;
637
638        // Remove first so a stricter pre-existing mode does not block
639        // the create_new path; we already gated overwrite above.
640        let _ = std::fs::remove_file(path);
641        let mut file = std::fs::OpenOptions::new()
642            .write(true)
643            .create_new(true)
644            .mode(0o600)
645            .open(path)
646            .with_context(|| format!("rules.keygen: create {}", path.display()))?;
647        file.write_all(seed)
648            .with_context(|| format!("rules.keygen: write seed to {}", path.display()))?;
649        file.sync_all()
650            .with_context(|| format!("rules.keygen: fsync {}", path.display()))?;
651        drop(file);
652
653        // Verify the mode bits actually landed (defense against an
654        // `OpenOptionsExt::mode` regression or a weird umask path).
655        let mode = std::fs::metadata(path)
656            .with_context(|| format!("rules.keygen: stat {}", path.display()))?
657            .permissions()
658            .mode()
659            & 0o777;
660        if mode != 0o600 {
661            // Try once more to chmod to 0600 — best effort recovery.
662            let mut perms = std::fs::metadata(path)?.permissions();
663            perms.set_mode(0o600);
664            std::fs::set_permissions(path, perms)
665                .with_context(|| format!("rules.keygen: chmod 0600 {}", path.display()))?;
666            let verified = std::fs::metadata(path)?.permissions().mode() & 0o777;
667            if verified != 0o600 {
668                bail!(
669                    "rules.keygen: could not enforce mode 0600 on {} (observed {verified:o})",
670                    path.display()
671                );
672            }
673        }
674        Ok(())
675    }
676    #[cfg(not(unix))]
677    {
678        writeln!(
679            out.stderr,
680            "WARNING: Windows: operator key permissions not enforced; protect manually"
681        )?;
682        std::fs::write(path, seed)
683            .with_context(|| format!("rules.keygen: write seed to {}", path.display()))?;
684        Ok(())
685    }
686}
687
688/// Write the base64-encoded verifying key to `<path>.pub`. World-
689/// readable (mode 0644 on Unix) because public keys are by definition
690/// non-secret.
691fn write_operator_public_key(pub_path: &Path, pub_bytes: &[u8; ED25519_PUBLIC_LEN]) -> Result<()> {
692    use base64::Engine;
693    let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
694    #[cfg(unix)]
695    {
696        use std::io::Write;
697        use std::os::unix::fs::OpenOptionsExt;
698        let _ = std::fs::remove_file(pub_path);
699        let mut file = std::fs::OpenOptions::new()
700            .write(true)
701            .create_new(true)
702            .mode(0o644)
703            .open(pub_path)
704            .with_context(|| format!("rules.keygen: create {}", pub_path.display()))?;
705        file.write_all(encoded.as_bytes())
706            .with_context(|| format!("rules.keygen: write pub to {}", pub_path.display()))?;
707        file.sync_all()
708            .with_context(|| format!("rules.keygen: fsync {}", pub_path.display()))?;
709    }
710    #[cfg(not(unix))]
711    {
712        std::fs::write(pub_path, encoded.as_bytes())
713            .with_context(|| format!("rules.keygen: write pub to {}", pub_path.display()))?;
714    }
715    Ok(())
716}
717
718/// Compute the `sha256(pub_bytes)` fingerprint truncated to 16 hex
719/// chars. Used in the success line `Ed25519 operator key generated:
720/// <fp> -> <path>` so the operator can sanity-check the public key
721/// without inspecting the file. Truncated to 16 chars (64 bits) —
722/// collision resistance is irrelevant here (the operator already
723/// trusts the file path; this is for human-readable disambiguation).
724fn pub_fingerprint(pub_bytes: &[u8; ED25519_PUBLIC_LEN]) -> String {
725    use sha2::{Digest, Sha256};
726    let mut hasher = Sha256::new();
727    hasher.update(pub_bytes);
728    let digest = hasher.finalize();
729    let mut out = String::with_capacity(16);
730    for byte in digest.iter().take(8) {
731        out.push_str(&format!("{byte:02x}"));
732    }
733    out
734}
735
736/// Resolve the public-key sibling path for a given private-seed path.
737/// `~/.config/ai-memory/operator.key` → `~/.config/ai-memory/operator.key.pub`.
738fn pub_sibling_path(seed_path: &Path) -> PathBuf {
739    let mut s = seed_path.as_os_str().to_os_string();
740    s.push(".pub");
741    PathBuf::from(s)
742}
743
744/// Load the operator signing key from `path` (32 raw bytes, mode
745/// 0600 on Unix). This is the public helper exposed for tests and
746/// the L1-6 sign-seed pipeline.
747///
748/// # Errors
749///
750/// - Returns a clear error mentioning `0600` when the file mode is
751///   anything other than 0o600 on Unix.
752/// - Returns an error when the file length is not exactly 32 bytes.
753/// - On non-Unix targets the mode check is skipped (file ACL applies
754///   instead; the OSS layer does not enforce hardware-backed storage —
755///   see `src/identity/keypair.rs` "Hardware-backed key storage"
756///   section).
757pub fn load_operator_signing_key(path: &Path) -> Result<SigningKey> {
758    #[cfg(unix)]
759    {
760        use std::os::unix::fs::PermissionsExt;
761        let meta = std::fs::metadata(path)
762            .with_context(|| format!("load_operator_signing_key: stat {}", path.display()))?;
763        let mode = meta.permissions().mode() & 0o777;
764        if mode != 0o600 {
765            bail!(
766                "load_operator_signing_key: {} has mode {mode:o}; permissions too open; \
767                 chmod 0600 {} to restore",
768                path.display(),
769                path.display()
770            );
771        }
772    }
773    let bytes = std::fs::read(path)
774        .with_context(|| format!("load_operator_signing_key: read {}", path.display()))?;
775    if bytes.len() != ED25519_SEED_LEN {
776        bail!(
777            "load_operator_signing_key: {} has {} bytes, expected {ED25519_SEED_LEN}",
778            path.display(),
779            bytes.len()
780        );
781    }
782    let mut seed = [0u8; ED25519_SEED_LEN];
783    seed.copy_from_slice(&bytes);
784    Ok(SigningKey::from_bytes(&seed))
785}
786
787/// L1-6 Deliverable B — sign R001..R004 (and any other rows in
788/// `governance_rules`) with the operator key. Idempotent: re-running
789/// computes the same canonical bytes → same signature → same UPDATE;
790/// a row whose `signature` already matches the freshly computed bytes
791/// is a no-op.
792///
793/// `enabled` STAYS at whatever the row already holds — operator
794/// activates manually after audit. Canonical bytes include `enabled`
795/// (see [`rules_store::canonical_bytes_for_signing`]), so a post-sign
796/// `UPDATE governance_rules SET enabled = 1` would invalidate the
797/// recorded signature: that is the bypass-prevention property the
798/// L1-6 integration tests pin.
799///
800/// Returns the number of rows that were freshly signed (excluding
801/// idempotent no-ops).
802fn sign_seed_rules(
803    conn: &rusqlite::Connection,
804    key_path: Option<&Path>,
805    json: bool,
806    out: &mut CliOutput<'_>,
807) -> Result<usize> {
808    let resolved = match key_path {
809        Some(p) => p.to_path_buf(),
810        None => resolve_operator_key_path(None)?,
811    };
812    let signing_key = load_operator_signing_key(&resolved).with_context(|| {
813        format!(
814            "rules.sign-seed: load operator key from {}",
815            resolved.display()
816        )
817    })?;
818
819    let rules = rules_store::list(conn)?;
820    let mut signed_now = 0usize;
821    let mut summary: Vec<serde_json::Value> = Vec::new();
822    for rule in rules {
823        let canonical = rules_store::canonical_bytes_for_signing(&rule)?;
824        let signature = signing_key.sign(&canonical);
825        let sig_bytes = signature.to_bytes();
826        let already_signed = matches!(
827            (rule.signature.as_deref(), rule.attest_level.as_str()),
828            (Some(existing), OPERATOR_SIGNED_LEVEL) if existing == sig_bytes.as_slice()
829        );
830        if !already_signed {
831            rules_store::update_signature(
832                conn,
833                &rule.id,
834                sig_bytes.as_slice(),
835                OPERATOR_SIGNED_LEVEL,
836            )?;
837            signed_now += 1;
838        }
839        summary.push(serde_json::json!({
840            "id": rule.id,
841            (field_names::ATTEST_LEVEL): OPERATOR_SIGNED_LEVEL,
842            "signed_now": !already_signed,
843        }));
844    }
845
846    let payload = serde_json::json!({
847        "signed_now": signed_now,
848        "rules": summary,
849    });
850    emit_ok(json, out, "rules.sign-seed", &payload)?;
851    Ok(signed_now)
852}
853
854/// Resolve the operator key directory, honoring `--key-dir` →
855/// `AI_MEMORY_KEY_DIR` → `kp::default_key_dir()`.
856fn resolve_key_dir(override_dir: Option<&std::path::Path>) -> Result<PathBuf> {
857    if let Some(p) = override_dir {
858        return Ok(p.to_path_buf());
859    }
860    kp::default_key_dir()
861}
862
863/// Load the operator's signing key from `key_dir`. Auto-detects which
864/// of the two operator-key naming conventions is in use:
865///
866/// 1. `operator.priv` (raw 32-byte seed) + `operator.pub` (raw 32-byte
867///    verifying key) — the legacy dir-based layout the `add` / `enable`
868///    / `disable` / `remove` verbs originally targeted, loaded via
869///    [`kp::load`].
870/// 2. `operator.key` (raw 32-byte seed) + `operator.key.pub` (base64url
871///    no-pad encoded 32-byte verifying key) — the layout `rules keygen`
872///    writes (`~/.config/ai-memory/operator.key`) per the L1-6 spec.
873///
874/// v0.7.0 G-PHASE-E-3 (#708) — before this fix, `rules keygen` wrote
875/// files under (2) but `rules enable --sign` only looked for (1), so
876/// the documented flow `keygen → enable` was broken end-to-end without
877/// any error message that hinted at the naming mismatch. Now both
878/// conventions are accepted; the error message when neither is found
879/// names both so the operator can pick the right one.
880///
881/// Refuses if no matching pair is present, if the private-half mode
882/// bits are not 0600 on Unix, or if the parsed bytes are not a valid
883/// 32-byte Ed25519 signing key. Returns the typed `SigningKey` ready
884/// to call `.sign()`.
885fn load_operator_signing_key_from_dir(
886    key_dir: &std::path::Path,
887) -> Result<ed25519_dalek::SigningKey> {
888    // Layout 1 — `operator.priv` + `operator.pub` (the legacy dir
889    // layout). `kp::load` already handles mode-bit + length + curve
890    // checks. Empty-dir cases that lack any operator file land in the
891    // unified error path below.
892    let priv_legacy = key_dir.join("operator.priv");
893    let pub_legacy = key_dir.join("operator.pub");
894    if priv_legacy.exists() && pub_legacy.exists() {
895        let kp = kp::load(OPERATOR_KEY_ID, key_dir).with_context(|| {
896            format!(
897                "governance.no_operator_key: failed loading operator.priv/operator.pub at {}",
898                key_dir.display()
899            )
900        })?;
901        return kp.private.ok_or_else(|| {
902            anyhow::anyhow!(
903                "governance.no_operator_key: operator keypair has no private half (public-only load)"
904            )
905        });
906    }
907    // Layout 2 — `operator.key` (raw 32-byte seed) + `operator.key.pub`
908    // (base64url no-pad encoded 32-byte verifying key). This is what
909    // `rules keygen` writes; verify the public half decodes and matches
910    // the seed's derived verifying key before returning so a tampered
911    // .pub surfaces here, not on the next signature-verify call.
912    let priv_keygen = key_dir.join(OPERATOR_KEY_FILENAME);
913    let pub_keygen = key_dir.join("operator.key.pub");
914    if priv_keygen.exists() {
915        let signing = load_operator_signing_key(&priv_keygen).with_context(|| {
916            format!(
917                "governance.no_operator_key: failed loading {}",
918                priv_keygen.display()
919            )
920        })?;
921        if pub_keygen.exists() {
922            use base64::Engine;
923            let encoded = std::fs::read_to_string(&pub_keygen).with_context(|| {
924                format!("governance.no_operator_key: read {}", pub_keygen.display())
925            })?;
926            let trimmed = encoded.trim();
927            let pub_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
928                .decode(trimmed)
929                .with_context(|| {
930                    format!(
931                        "governance.no_operator_key: decode base64url public key at {}",
932                        pub_keygen.display()
933                    )
934                })?;
935            if pub_bytes.len() != ED25519_PUBLIC_LEN {
936                bail!(
937                    "governance.no_operator_key: public key {} decoded to {} bytes (expected {ED25519_PUBLIC_LEN})",
938                    pub_keygen.display(),
939                    pub_bytes.len(),
940                );
941            }
942            if signing.verifying_key().to_bytes().as_slice() != pub_bytes.as_slice() {
943                bail!(
944                    "governance.no_operator_key: private key {} does not match public key {}",
945                    priv_keygen.display(),
946                    pub_keygen.display(),
947                );
948            }
949        }
950        return Ok(signing);
951    }
952    // Layout 3 (#800 Gap #6 — keygen↔enable path-mismatch fallback) —
953    // `ai-memory rules keygen` writes the operator key to
954    // `<config-dir>/operator.key` (parent of the key_dir), per
955    // `resolve_operator_key_path`'s "singleton, not enumerable list"
956    // rationale documented in
957    // `migrations/sqlite/0024_v07_governance_rules.sql`. The L1-6
958    // verify path (`rules_store::resolve_operator_pubkey`) reads from
959    // the parent dir for the same reason. Before this fallback, the
960    // `enable/disable/add --sign` verbs refused with
961    // `governance.no_operator_key` even when a fresh keygen had just
962    // run. The install-batman-active.sh script worked around it by
963    // mirroring the key into both locations. This in-process fallback
964    // closes the wart so a fresh keygen + immediate enable just works.
965    if let Some(parent) = key_dir.parent() {
966        let parent_priv = parent.join(OPERATOR_KEY_FILENAME);
967        let parent_pub = parent.join("operator.key.pub");
968        if parent_priv.exists() {
969            let signing = load_operator_signing_key(&parent_priv).with_context(|| {
970                format!(
971                    "governance.no_operator_key: failed loading {}",
972                    parent_priv.display()
973                )
974            })?;
975            if parent_pub.exists() {
976                use base64::Engine;
977                let encoded = std::fs::read_to_string(&parent_pub).with_context(|| {
978                    format!("governance.no_operator_key: read {}", parent_pub.display())
979                })?;
980                let trimmed = encoded.trim();
981                let pub_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
982                    .decode(trimmed)
983                    .with_context(|| {
984                        format!(
985                            "governance.no_operator_key: decode base64url public key at {}",
986                            parent_pub.display()
987                        )
988                    })?;
989                if pub_bytes.len() != ED25519_PUBLIC_LEN {
990                    bail!(
991                        "governance.no_operator_key: public key {} decoded to {} bytes (expected {ED25519_PUBLIC_LEN})",
992                        parent_pub.display(),
993                        pub_bytes.len(),
994                    );
995                }
996                if signing.verifying_key().to_bytes().as_slice() != pub_bytes.as_slice() {
997                    bail!(
998                        "governance.no_operator_key: private key {} does not match public key {}",
999                        parent_priv.display(),
1000                        parent_pub.display(),
1001                    );
1002                }
1003            }
1004            return Ok(signing);
1005        }
1006    }
1007
1008    // Neither layout present — name all three so the operator picks
1009    // the right one to materialise.
1010    bail!(
1011        "governance.no_operator_key: no operator key found at {dir} \
1012         (also checked parent dir for the keygen layout). \
1013         Expected either `operator.priv` + `operator.pub` (raw 32-byte pair, \
1014         as produced by per-agent `keypair` generation) OR \
1015         `operator.key` + `operator.key.pub` (raw 32-byte seed + base64url \
1016         verifier, as produced by `ai-memory rules keygen` — searched both \
1017         `{dir}/` and `{dir}/../`)",
1018        dir = key_dir.display(),
1019    )
1020}
1021
1022/// Resolve the caller's agent_id for `created_by` provenance. Uses
1023/// the same NHI vocabulary as the rest of the CLI. Falls back to a
1024/// process-bound id if env / clientInfo resolution fails.
1025fn resolve_agent_id() -> String {
1026    crate::identity::resolve_agent_id(None, None)
1027        .unwrap_or_else(|_| format!("anonymous:pid-{}", std::process::id()))
1028}
1029
1030/// Build an [`AgentAction`] from `kind` + JSON payload. Used by
1031/// `rules check` to mirror the harness PreToolUse hook input.
1032fn build_action(kind: &str, payload_json: &str) -> Result<AgentAction> {
1033    let payload: serde_json::Value = serde_json::from_str(payload_json)
1034        .with_context(|| format!("rules check: payload is not valid JSON: {payload_json}"))?;
1035    match kind {
1036        ak::BASH => {
1037            let command = payload
1038                .get("command")
1039                .and_then(|v| v.as_str())
1040                .ok_or_else(|| anyhow::anyhow!("bash payload requires `command` string"))?
1041                .to_string();
1042            let cwd = payload
1043                .get("cwd")
1044                .and_then(|v| v.as_str())
1045                .map(PathBuf::from);
1046            Ok(AgentAction::Bash { command, cwd })
1047        }
1048        ak::FILESYSTEM_WRITE => {
1049            let path = payload
1050                .get("path")
1051                .and_then(|v| v.as_str())
1052                .ok_or_else(|| anyhow::anyhow!("filesystem_write payload requires `path` string"))?
1053                .to_string();
1054            let byte_estimate = payload
1055                .get("byte_estimate")
1056                .and_then(serde_json::Value::as_u64);
1057            Ok(AgentAction::FilesystemWrite {
1058                path: PathBuf::from(path),
1059                byte_estimate,
1060            })
1061        }
1062        ak::NETWORK_REQUEST => {
1063            let host = payload
1064                .get("host")
1065                .and_then(|v| v.as_str())
1066                .ok_or_else(|| anyhow::anyhow!("network_request payload requires `host` string"))?
1067                .to_string();
1068            let scheme = payload
1069                .get("scheme")
1070                .and_then(|v| v.as_str())
1071                .unwrap_or("https")
1072                .to_string();
1073            Ok(AgentAction::NetworkRequest { host, scheme })
1074        }
1075        ak::PROCESS_SPAWN => {
1076            let binary = payload
1077                .get("binary")
1078                .and_then(|v| v.as_str())
1079                .ok_or_else(|| anyhow::anyhow!("process_spawn payload requires `binary` string"))?
1080                .to_string();
1081            let args = payload
1082                .get("args")
1083                .and_then(|v| v.as_array())
1084                .map(|arr| {
1085                    arr.iter()
1086                        .filter_map(|v| v.as_str().map(String::from))
1087                        .collect()
1088                })
1089                .unwrap_or_default();
1090            Ok(AgentAction::ProcessSpawn { binary, args })
1091        }
1092        "custom" => {
1093            let custom_kind = payload
1094                .get(field_names::CUSTOM_KIND)
1095                .or_else(|| payload.get("kind"))
1096                .and_then(|v| v.as_str())
1097                .ok_or_else(|| anyhow::anyhow!("custom payload requires `custom_kind` string"))?
1098                .to_string();
1099            Ok(AgentAction::Custom {
1100                custom_kind,
1101                payload,
1102            })
1103        }
1104        other => bail!("rules check: unknown kind `{other}`"),
1105    }
1106}
1107
1108/// Render a [`Rule`] as JSON for CLI output. The signature is
1109/// base64-encoded (URL-safe, no padding) so the JSON is operator-
1110/// readable. Empty signature ⇒ null.
1111fn rule_to_json(rule: &Rule) -> serde_json::Value {
1112    use base64::Engine;
1113    let sig_b64 = rule
1114        .signature
1115        .as_ref()
1116        .map(|b| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b));
1117    serde_json::json!({
1118        "id": rule.id,
1119        "kind": rule.kind,
1120        "matcher": rule.matcher,
1121        "severity": rule.severity,
1122        "reason": rule.reason,
1123        "namespace": rule.namespace,
1124        (field_names::CREATED_BY): rule.created_by,
1125        (field_names::CREATED_AT): rule.created_at,
1126        "enabled": rule.enabled,
1127        "signature_b64": sig_b64,
1128        (field_names::ATTEST_LEVEL): rule.attest_level,
1129    })
1130}
1131
1132fn emit_ok(
1133    json: bool,
1134    out: &mut CliOutput<'_>,
1135    verb: &str,
1136    result: &serde_json::Value,
1137) -> Result<()> {
1138    if json {
1139        let env = CliEnvelope {
1140            verb,
1141            result: result.clone(),
1142        };
1143        writeln!(out.stdout, "{}", serde_json::to_string(&env)?)?;
1144    } else {
1145        // Human format: pretty-print the result tree. The verb header
1146        // is suppressed (the CLI command itself is the implicit
1147        // context).
1148        writeln!(out.stdout, "{}", serde_json::to_string_pretty(result)?)?;
1149    }
1150    Ok(())
1151}
1152
1153// ---------------------------------------------------------------------------
1154// Tests
1155// ---------------------------------------------------------------------------
1156
1157#[cfg(test)]
1158mod tests {
1159    use super::*;
1160
1161    /// Issue #899 — guard against cross-test forensic-sink bleed.
1162    ///
1163    /// `RulesAction::Check` fires `check_agent_action`, which
1164    /// indirectly emits `crate::governance::audit::record_decision`.
1165    /// Tests in this module that exercise `RulesAction::Check`
1166    /// MUST hold this lock so a sibling `audit::tests::*` test does
1167    /// not see this thread's `record_decision` land in its tempdir.
1168    /// See `governance::audit::forensic_sink_test_lock`.
1169    #[must_use = "the guard must be held for the scope of the test"]
1170    fn forensic_lock() -> std::sync::MutexGuard<'static, ()> {
1171        crate::governance::audit::forensic_sink_test_lock()
1172            .lock()
1173            .unwrap_or_else(|e| e.into_inner())
1174    }
1175
1176    #[test]
1177    fn build_action_bash_parses() {
1178        let a = build_action("bash", r#"{"command":"ls -la"}"#).unwrap();
1179        match a {
1180            AgentAction::Bash { command, cwd } => {
1181                assert_eq!(command, "ls -la");
1182                assert!(cwd.is_none());
1183            }
1184            _ => panic!("expected bash"),
1185        }
1186    }
1187
1188    #[test]
1189    fn build_action_filesystem_write_parses() {
1190        let a = build_action("filesystem_write", r#"{"path":"/tmp/x"}"#).unwrap();
1191        match a {
1192            AgentAction::FilesystemWrite { path, .. } => {
1193                assert_eq!(path, PathBuf::from("/tmp/x"));
1194            }
1195            _ => panic!("expected filesystem_write"),
1196        }
1197    }
1198
1199    #[test]
1200    fn build_action_network_request_parses_with_scheme_default() {
1201        let a = build_action("network_request", r#"{"host":"x.example.com"}"#).unwrap();
1202        match a {
1203            AgentAction::NetworkRequest { host, scheme } => {
1204                assert_eq!(host, "x.example.com");
1205                assert_eq!(scheme, "https");
1206            }
1207            _ => panic!("expected network_request"),
1208        }
1209    }
1210
1211    #[test]
1212    fn build_action_process_spawn_parses() {
1213        let a = build_action(
1214            "process_spawn",
1215            r#"{"binary":"cargo","args":["build","--release"]}"#,
1216        )
1217        .unwrap();
1218        match a {
1219            AgentAction::ProcessSpawn { binary, args } => {
1220                assert_eq!(binary, "cargo");
1221                assert_eq!(args, vec!["build", "--release"]);
1222            }
1223            _ => panic!("expected process_spawn"),
1224        }
1225    }
1226
1227    #[test]
1228    fn build_action_custom_parses() {
1229        let a = build_action("custom", r#"{"custom_kind":"deploy","env":"prod"}"#).unwrap();
1230        match a {
1231            AgentAction::Custom { custom_kind, .. } => assert_eq!(custom_kind, "deploy"),
1232            _ => panic!("expected custom"),
1233        }
1234    }
1235
1236    #[test]
1237    fn build_action_unknown_kind_errors() {
1238        assert!(build_action("nope", "{}").is_err());
1239    }
1240
1241    #[test]
1242    fn build_action_invalid_json_errors() {
1243        assert!(build_action("bash", "not json").is_err());
1244    }
1245
1246    #[test]
1247    fn build_action_missing_required_field_errors() {
1248        assert!(build_action("bash", "{}").is_err());
1249        assert!(build_action("filesystem_write", "{}").is_err());
1250    }
1251
1252    #[test]
1253    fn rule_to_json_encodes_signature_as_base64() {
1254        let mut rule = Rule {
1255            id: "R1".into(),
1256            kind: "bash".into(),
1257            matcher: r#"{"command_regex":"x"}"#.into(),
1258            severity: "refuse".into(),
1259            reason: "test".into(),
1260            namespace: "_global".into(),
1261            created_by: "test".into(),
1262            created_at: 0,
1263            enabled: true,
1264            signature: None,
1265            attest_level: "unsigned".into(),
1266        };
1267        let v = rule_to_json(&rule);
1268        assert_eq!(v["signature_b64"], serde_json::Value::Null);
1269        rule.signature = Some(vec![0xff, 0x00, 0xaa]);
1270        let v = rule_to_json(&rule);
1271        assert_eq!(
1272            v["signature_b64"],
1273            serde_json::Value::String("_wCq".to_string())
1274        );
1275    }
1276
1277    // -----------------------------------------------------------------
1278    // L1-6 — keygen + load_operator_signing_key unit tests
1279    // -----------------------------------------------------------------
1280
1281    #[test]
1282    fn pub_sibling_path_appends_dot_pub() {
1283        let p = pub_sibling_path(Path::new("/x/y/operator.key"));
1284        assert_eq!(p, PathBuf::from("/x/y/operator.key.pub"));
1285    }
1286
1287    #[test]
1288    fn pub_fingerprint_is_deterministic_and_16_hex_chars() {
1289        let bytes = [0u8; 32];
1290        let fp1 = pub_fingerprint(&bytes);
1291        let fp2 = pub_fingerprint(&bytes);
1292        assert_eq!(fp1, fp2, "fingerprint must be deterministic");
1293        assert_eq!(fp1.len(), 16, "fingerprint must be 16 hex chars");
1294        assert!(
1295            fp1.chars().all(|c| c.is_ascii_hexdigit()),
1296            "fingerprint must be ASCII hex"
1297        );
1298        // Different input → different fingerprint.
1299        let mut other = [0u8; 32];
1300        other[0] = 1;
1301        let fp3 = pub_fingerprint(&other);
1302        assert_ne!(fp1, fp3);
1303    }
1304
1305    #[cfg(unix)]
1306    #[test]
1307    fn keygen_writes_priv_0600_and_pub_0644_then_loads() {
1308        use std::os::unix::fs::PermissionsExt;
1309
1310        let dir = tempfile::tempdir().unwrap();
1311        let key_path = dir.path().join("operator.key");
1312        let mut stdout: Vec<u8> = Vec::new();
1313        let mut stderr: Vec<u8> = Vec::new();
1314        let mut out = CliOutput {
1315            stdout: &mut stdout,
1316            stderr: &mut stderr,
1317        };
1318        let fp = keygen_operator(&key_path, false, &mut out).expect("keygen");
1319        assert_eq!(fp.len(), 16);
1320
1321        // Private file: mode 0600 + 32 bytes.
1322        let meta = std::fs::metadata(&key_path).unwrap();
1323        let mode = meta.permissions().mode() & 0o777;
1324        assert_eq!(mode, 0o600, "priv key must be 0600, got {mode:o}");
1325        let bytes = std::fs::read(&key_path).unwrap();
1326        assert_eq!(bytes.len(), 32, "priv seed must be 32 bytes");
1327
1328        // Public file: mode 0644 + base64 of 32 bytes.
1329        let pub_path = pub_sibling_path(&key_path);
1330        let pmode = std::fs::metadata(&pub_path).unwrap().permissions().mode() & 0o777;
1331        assert_eq!(pmode, 0o644, "pub key must be 0644, got {pmode:o}");
1332        let pub_b64 = std::fs::read_to_string(&pub_path).unwrap();
1333        use base64::Engine;
1334        let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
1335            .decode(pub_b64.trim())
1336            .expect("pub base64 decodes");
1337        assert_eq!(decoded.len(), 32);
1338
1339        // load_operator_signing_key round-trips and the derived
1340        // verifying key matches the .pub bytes.
1341        let signing = load_operator_signing_key(&key_path).expect("load");
1342        let verifying = signing.verifying_key();
1343        assert_eq!(verifying.to_bytes()[..], decoded[..]);
1344
1345        // Stdout includes the fingerprint and the path; never the seed.
1346        let s = String::from_utf8(stdout).unwrap();
1347        assert!(s.contains(&fp), "stdout must include fingerprint, got: {s}");
1348        // Seed bytes should never round-trip through stdout (defensive
1349        // check: the random seed is unlikely to be valid utf8 anyway,
1350        // but we assert the success line is the only stdout content).
1351        assert!(s.starts_with("Ed25519 operator key generated:"));
1352    }
1353
1354    #[cfg(unix)]
1355    #[test]
1356    fn keygen_refuses_overwrite_without_force() {
1357        let dir = tempfile::tempdir().unwrap();
1358        let key_path = dir.path().join("operator.key");
1359        let mut stdout: Vec<u8> = Vec::new();
1360        let mut stderr: Vec<u8> = Vec::new();
1361        let mut out = CliOutput {
1362            stdout: &mut stdout,
1363            stderr: &mut stderr,
1364        };
1365        keygen_operator(&key_path, false, &mut out).expect("first");
1366        let bytes_before = std::fs::read(&key_path).unwrap();
1367
1368        // Second call without --force must refuse.
1369        let err = keygen_operator(&key_path, false, &mut out).unwrap_err();
1370        let msg = format!("{err:#}");
1371        assert!(msg.contains("refusing to overwrite"), "got: {msg}");
1372
1373        // Bytes on disk must not have changed.
1374        let bytes_after = std::fs::read(&key_path).unwrap();
1375        assert_eq!(bytes_before, bytes_after);
1376    }
1377
1378    #[cfg(unix)]
1379    #[test]
1380    fn keygen_force_overwrites_and_warns_on_stderr() {
1381        let dir = tempfile::tempdir().unwrap();
1382        let key_path = dir.path().join("operator.key");
1383        let mut stdout: Vec<u8> = Vec::new();
1384        let mut stderr: Vec<u8> = Vec::new();
1385        let mut out = CliOutput {
1386            stdout: &mut stdout,
1387            stderr: &mut stderr,
1388        };
1389        let fp1 = keygen_operator(&key_path, false, &mut out).expect("first");
1390        let fp2 = keygen_operator(&key_path, true, &mut out).expect("force");
1391        assert_ne!(fp1, fp2, "fresh keypair must have new fingerprint");
1392
1393        let s = String::from_utf8(stderr).unwrap();
1394        assert!(
1395            s.contains("WARNING") && s.contains("INVALID"),
1396            "stderr must warn about prior-signature invalidation, got: {s}"
1397        );
1398    }
1399
1400    #[cfg(unix)]
1401    #[test]
1402    fn load_operator_signing_key_refuses_open_permissions() {
1403        use std::os::unix::fs::PermissionsExt;
1404
1405        let dir = tempfile::tempdir().unwrap();
1406        let key_path = dir.path().join("operator.key");
1407        let mut stdout: Vec<u8> = Vec::new();
1408        let mut stderr: Vec<u8> = Vec::new();
1409        let mut out = CliOutput {
1410            stdout: &mut stdout,
1411            stderr: &mut stderr,
1412        };
1413        keygen_operator(&key_path, false, &mut out).expect("keygen");
1414        // Loosen perms.
1415        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644)).unwrap();
1416        let err = load_operator_signing_key(&key_path).unwrap_err();
1417        let msg = format!("{err:#}");
1418        assert!(msg.contains("0600"), "error must mention 0600, got: {msg}");
1419        // Restore so the tempdir cleanup works.
1420        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
1421    }
1422
1423    #[test]
1424    fn load_operator_signing_key_rejects_wrong_length() {
1425        let dir = tempfile::tempdir().unwrap();
1426        let key_path = dir.path().join("operator.key");
1427        // Write a short file that bypasses the mode check (or, on
1428        // unix, the mode check fires first — both paths exercise the
1429        // "refuse to sign with non-conforming material" property).
1430        std::fs::write(&key_path, b"too-short").unwrap();
1431        #[cfg(unix)]
1432        {
1433            use std::os::unix::fs::PermissionsExt;
1434            std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
1435        }
1436        let err = load_operator_signing_key(&key_path).unwrap_err();
1437        let msg = format!("{err:#}");
1438        // Either the length error or the stat error is acceptable —
1439        // both are refusals.
1440        assert!(
1441            msg.contains("expected") || msg.contains("bytes"),
1442            "got: {msg}"
1443        );
1444    }
1445
1446    // -----------------------------------------------------------------
1447    // L1-6 — sign_seed_rules unit tests
1448    // -----------------------------------------------------------------
1449
1450    /// Build a fresh in-memory rules-only schema for `sign_seed_rules`
1451    /// tests. Same shape as the engine's `fresh_conn` helper in
1452    /// `governance::agent_action::tests` but here we only need the
1453    /// rules table (no audit chain — sign-seed is pure SQL UPDATE).
1454    fn fresh_rules_conn() -> rusqlite::Connection {
1455        let conn = rusqlite::Connection::open_in_memory().unwrap();
1456        conn.execute_batch(
1457            "CREATE TABLE governance_rules (
1458                 id TEXT PRIMARY KEY,
1459                 kind TEXT NOT NULL,
1460                 matcher TEXT NOT NULL,
1461                 severity TEXT NOT NULL CHECK (severity IN ('refuse','warn','log')),
1462                 reason TEXT NOT NULL,
1463                 namespace TEXT NOT NULL DEFAULT '_global',
1464                 created_by TEXT NOT NULL,
1465                 created_at INTEGER NOT NULL,
1466                 enabled INTEGER NOT NULL DEFAULT 1,
1467                 signature BLOB,
1468                 attest_level TEXT NOT NULL DEFAULT 'unsigned'
1469             );",
1470        )
1471        .unwrap();
1472        conn
1473    }
1474
1475    #[cfg(unix)]
1476    #[test]
1477    fn sign_seed_rules_marks_all_rows_operator_signed() {
1478        let tdir = tempfile::tempdir().unwrap();
1479        let key_path = tdir.path().join("operator.key");
1480        let mut stdout: Vec<u8> = Vec::new();
1481        let mut stderr: Vec<u8> = Vec::new();
1482        let mut out = CliOutput {
1483            stdout: &mut stdout,
1484            stderr: &mut stderr,
1485        };
1486        keygen_operator(&key_path, false, &mut out).unwrap();
1487
1488        let conn = fresh_rules_conn();
1489        // Seed two unsigned rules to mirror the migration's R001..R004
1490        // shape (enabled=false, attest_level='unsigned').
1491        for id in ["R001", "R002"] {
1492            rules_store::insert(
1493                &conn,
1494                &Rule {
1495                    id: id.to_string(),
1496                    kind: "filesystem_write".into(),
1497                    matcher: r#"{"glob":"/tmp/**"}"#.into(),
1498                    severity: "refuse".into(),
1499                    reason: "test".into(),
1500                    namespace: "_global".into(),
1501                    created_by: "system:seed".into(),
1502                    created_at: 0,
1503                    enabled: false,
1504                    signature: None,
1505                    attest_level: "unsigned".into(),
1506                },
1507            )
1508            .unwrap();
1509        }
1510
1511        let signed = sign_seed_rules(&conn, Some(&key_path), true, &mut out).unwrap();
1512        assert_eq!(signed, 2);
1513
1514        // Every row is now operator_signed with a 64-byte signature
1515        // and `enabled` UNCHANGED (audit must be operator-driven).
1516        for id in ["R001", "R002"] {
1517            let row = rules_store::get(&conn, id).unwrap().unwrap();
1518            assert_eq!(row.attest_level, "operator_signed");
1519            assert_eq!(
1520                row.signature.as_ref().map(Vec::len),
1521                Some(ed25519_dalek::SIGNATURE_LENGTH)
1522            );
1523            assert!(!row.enabled, "sign-seed must NOT flip enabled");
1524        }
1525    }
1526
1527    // -----------------------------------------------------------------
1528    // C-3 coverage uplift — drive `run()` for every subcommand. The
1529    // mutation verbs require an operator keypair on disk under
1530    // `<key_dir>/operator.priv` (kp::save layout); the keygen + sign-seed
1531    // verbs use the singleton-file layout.
1532    // -----------------------------------------------------------------
1533
1534    /// Set up a tempdir with a `db::open`-initialized SQLite at
1535    /// `db_path` and an operator keypair saved under `key_dir`. Returns
1536    /// the tempdir guard (must outlive the test) and the two paths.
1537    #[cfg(unix)]
1538    fn fresh_env_with_operator_key() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf)
1539    {
1540        let dir = tempfile::tempdir().expect("tempdir");
1541        let db_path = dir.path().join("ai-memory.db");
1542        // Initialize the full schema.
1543        drop(crate::db::open(&db_path).expect("db::open"));
1544        // Save an operator keypair at <key_dir>/operator.{priv,pub}.
1545        let kp = kp::generate(OPERATOR_KEY_ID).expect("generate");
1546        let key_dir = dir.path().join("keys");
1547        std::fs::create_dir_all(&key_dir).expect("mkdir keys");
1548        kp::save(&kp, &key_dir).expect("save kp");
1549        (dir, db_path, key_dir)
1550    }
1551
1552    #[cfg(unix)]
1553    #[test]
1554    fn run_rules_list_emits_seeded_rules() {
1555        // `db::open` runs migration 0024 which seeds R001..R004 disabled.
1556        // The list verb returns them with attest_level=unsigned. We pin
1557        // the dispatch + JSON envelope shape (not the seed content,
1558        // since the migration is owned by L0.7-2).
1559        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1560        let args = RulesArgs {
1561            key_dir: Some(key_dir),
1562            action: RulesAction::List,
1563        };
1564        let mut stdout: Vec<u8> = Vec::new();
1565        let mut stderr: Vec<u8> = Vec::new();
1566        let mut out = CliOutput {
1567            stdout: &mut stdout,
1568            stderr: &mut stderr,
1569        };
1570        run(&db_path, args, true, &mut out).expect("list");
1571        let s = String::from_utf8(stdout).unwrap();
1572        // Envelope wraps the result under "rules.list".
1573        assert!(s.contains("\"verb\":\"rules.list\""), "got: {s}");
1574        // List result is an array; either empty or pre-seeded.
1575        assert!(s.contains("\"result\":["), "got: {s}");
1576    }
1577
1578    #[cfg(unix)]
1579    #[test]
1580    fn run_rules_list_human_format_emits_pretty_array() {
1581        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1582        let args = RulesArgs {
1583            key_dir: Some(key_dir),
1584            action: RulesAction::List,
1585        };
1586        let mut stdout: Vec<u8> = Vec::new();
1587        let mut stderr: Vec<u8> = Vec::new();
1588        let mut out = CliOutput {
1589            stdout: &mut stdout,
1590            stderr: &mut stderr,
1591        };
1592        // json=false → emit_ok's pretty-print branch.
1593        run(&db_path, args, false, &mut out).expect("list");
1594        let s = String::from_utf8(stdout).unwrap();
1595        assert!(s.contains("["), "got: {s}");
1596    }
1597
1598    #[cfg(unix)]
1599    #[test]
1600    fn run_rules_add_without_sign_refuses() {
1601        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1602        let args = RulesArgs {
1603            key_dir: Some(key_dir),
1604            action: RulesAction::Add {
1605                id: "R-test".into(),
1606                kind: "bash".into(),
1607                matcher: r#"{"command_regex":"^ls"}"#.into(),
1608                severity: "refuse".into(),
1609                reason: "test".into(),
1610                namespace: "_global".into(),
1611                disabled: false,
1612                sign: false,
1613            },
1614        };
1615        let mut stdout: Vec<u8> = Vec::new();
1616        let mut stderr: Vec<u8> = Vec::new();
1617        let mut out = CliOutput {
1618            stdout: &mut stdout,
1619            stderr: &mut stderr,
1620        };
1621        let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
1622        let msg = format!("{err:#}");
1623        assert!(msg.contains("no_operator_key"), "got: {msg}");
1624    }
1625
1626    #[cfg(unix)]
1627    #[test]
1628    fn run_rules_add_with_sign_persists_signed_rule() {
1629        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1630        let args = RulesArgs {
1631            key_dir: Some(key_dir.clone()),
1632            action: RulesAction::Add {
1633                id: "R-add-1".into(),
1634                kind: "bash".into(),
1635                // SEC-12/COR-10: literal substring (engine has always done
1636                // substring match, despite the legacy field name).
1637                matcher: r#"{"command_substring":"rm -rf /"}"#.into(),
1638                severity: "refuse".into(),
1639                reason: "rm-rf is bad".into(),
1640                namespace: "_global".into(),
1641                disabled: false,
1642                sign: true,
1643            },
1644        };
1645        let mut stdout: Vec<u8> = Vec::new();
1646        let mut stderr: Vec<u8> = Vec::new();
1647        let mut out = CliOutput {
1648            stdout: &mut stdout,
1649            stderr: &mut stderr,
1650        };
1651        run(&db_path, args, true, &mut out).expect("add");
1652        let s = String::from_utf8(stdout).unwrap();
1653        assert!(s.contains("rules.add"), "got: {s}");
1654        assert!(s.contains("R-add-1"), "got: {s}");
1655        assert!(s.contains("operator_signed"), "got: {s}");
1656
1657        // Confirm the row landed.
1658        let conn = rusqlite::Connection::open(&db_path).unwrap();
1659        let r = rules_store::get(&conn, "R-add-1").unwrap().unwrap();
1660        assert_eq!(r.attest_level, "operator_signed");
1661        assert!(r.signature.is_some());
1662    }
1663
1664    #[cfg(unix)]
1665    #[test]
1666    fn run_rules_add_with_bad_matcher_json_errors() {
1667        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1668        let args = RulesArgs {
1669            key_dir: Some(key_dir),
1670            action: RulesAction::Add {
1671                id: "R-bad".into(),
1672                kind: "bash".into(),
1673                matcher: "{ not json".into(), // malformed
1674                severity: "refuse".into(),
1675                reason: "x".into(),
1676                namespace: "_global".into(),
1677                disabled: false,
1678                sign: true,
1679            },
1680        };
1681        let mut stdout: Vec<u8> = Vec::new();
1682        let mut stderr: Vec<u8> = Vec::new();
1683        let mut out = CliOutput {
1684            stdout: &mut stdout,
1685            stderr: &mut stderr,
1686        };
1687        let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
1688        let msg = format!("{err:#}");
1689        assert!(msg.contains("matcher"), "got: {msg}");
1690    }
1691
1692    #[cfg(unix)]
1693    #[test]
1694    fn run_rules_add_disabled_lands_disabled_row() {
1695        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1696        let args = RulesArgs {
1697            key_dir: Some(key_dir),
1698            action: RulesAction::Add {
1699                id: "R-dis".into(),
1700                kind: "filesystem_write".into(),
1701                matcher: r#"{"glob":"/tmp/**"}"#.into(),
1702                severity: "warn".into(),
1703                reason: "noisy".into(),
1704                namespace: "_global".into(),
1705                disabled: true,
1706                sign: true,
1707            },
1708        };
1709        let mut stdout: Vec<u8> = Vec::new();
1710        let mut stderr: Vec<u8> = Vec::new();
1711        let mut out = CliOutput {
1712            stdout: &mut stdout,
1713            stderr: &mut stderr,
1714        };
1715        run(&db_path, args, false, &mut out).expect("add");
1716        let conn = rusqlite::Connection::open(&db_path).unwrap();
1717        let r = rules_store::get(&conn, "R-dis").unwrap().unwrap();
1718        assert!(!r.enabled, "disabled flag must propagate");
1719    }
1720
1721    #[cfg(unix)]
1722    #[test]
1723    fn run_rules_check_evaluates_action_against_empty_set() {
1724        let _forensic = forensic_lock();
1725        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1726        let args = RulesArgs {
1727            key_dir: Some(key_dir),
1728            action: RulesAction::Check {
1729                kind: "bash".into(),
1730                payload: r#"{"command":"ls"}"#.into(),
1731                agent_id: Some("tester".into()),
1732            },
1733        };
1734        let mut stdout: Vec<u8> = Vec::new();
1735        let mut stderr: Vec<u8> = Vec::new();
1736        let mut out = CliOutput {
1737            stdout: &mut stdout,
1738            stderr: &mut stderr,
1739        };
1740        run(&db_path, args, true, &mut out).expect("check");
1741        let s = String::from_utf8(stdout).unwrap();
1742        // Decision JSON envelope — at minimum "rules.check" verb shows up.
1743        assert!(s.contains("rules.check"), "got: {s}");
1744    }
1745
1746    #[cfg(unix)]
1747    #[test]
1748    fn run_rules_check_without_agent_id_uses_default() {
1749        let _forensic = forensic_lock();
1750        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1751        let args = RulesArgs {
1752            key_dir: Some(key_dir),
1753            action: RulesAction::Check {
1754                kind: "network_request".into(),
1755                payload: r#"{"host":"example.com","scheme":"https"}"#.into(),
1756                agent_id: None,
1757            },
1758        };
1759        let mut stdout: Vec<u8> = Vec::new();
1760        let mut stderr: Vec<u8> = Vec::new();
1761        let mut out = CliOutput {
1762            stdout: &mut stdout,
1763            stderr: &mut stderr,
1764        };
1765        run(&db_path, args, false, &mut out).expect("check");
1766    }
1767
1768    #[cfg(unix)]
1769    #[test]
1770    fn run_rules_enable_unsign_refuses() {
1771        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1772        let args = RulesArgs {
1773            key_dir: Some(key_dir),
1774            action: RulesAction::Enable {
1775                id: "R-x".into(),
1776                sign: false,
1777            },
1778        };
1779        let mut stdout: Vec<u8> = Vec::new();
1780        let mut stderr: Vec<u8> = Vec::new();
1781        let mut out = CliOutput {
1782            stdout: &mut stdout,
1783            stderr: &mut stderr,
1784        };
1785        let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
1786        assert!(format!("{err:#}").contains("no_operator_key"));
1787    }
1788
1789    #[cfg(unix)]
1790    #[test]
1791    fn run_rules_enable_unknown_id_errors() {
1792        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1793        let args = RulesArgs {
1794            key_dir: Some(key_dir),
1795            action: RulesAction::Enable {
1796                id: "R-does-not-exist".into(),
1797                sign: true,
1798            },
1799        };
1800        let mut stdout: Vec<u8> = Vec::new();
1801        let mut stderr: Vec<u8> = Vec::new();
1802        let mut out = CliOutput {
1803            stdout: &mut stdout,
1804            stderr: &mut stderr,
1805        };
1806        let err = run(&db_path, args, false, &mut out).expect_err("must error");
1807        assert!(format!("{err:#}").contains("no rule with id"));
1808    }
1809
1810    #[cfg(unix)]
1811    #[test]
1812    fn run_rules_enable_and_disable_roundtrip() {
1813        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1814        // First add a disabled rule.
1815        let args = RulesArgs {
1816            key_dir: Some(key_dir.clone()),
1817            action: RulesAction::Add {
1818                id: "R-toggle".into(),
1819                kind: "bash".into(),
1820                // SEC-12/COR-10: literal substring matcher.
1821                matcher: r#"{"command_substring":"x"}"#.into(),
1822                severity: "warn".into(),
1823                reason: "toggle me".into(),
1824                namespace: "_global".into(),
1825                disabled: true,
1826                sign: true,
1827            },
1828        };
1829        let mut stdout: Vec<u8> = Vec::new();
1830        let mut stderr: Vec<u8> = Vec::new();
1831        let mut out = CliOutput {
1832            stdout: &mut stdout,
1833            stderr: &mut stderr,
1834        };
1835        run(&db_path, args, false, &mut out).expect("add");
1836
1837        // Enable.
1838        let args = RulesArgs {
1839            key_dir: Some(key_dir.clone()),
1840            action: RulesAction::Enable {
1841                id: "R-toggle".into(),
1842                sign: true,
1843            },
1844        };
1845        let mut stdout = Vec::new();
1846        let mut stderr = Vec::new();
1847        let mut out = CliOutput {
1848            stdout: &mut stdout,
1849            stderr: &mut stderr,
1850        };
1851        run(&db_path, args, false, &mut out).expect("enable");
1852        let conn = rusqlite::Connection::open(&db_path).unwrap();
1853        assert!(
1854            rules_store::get(&conn, "R-toggle")
1855                .unwrap()
1856                .unwrap()
1857                .enabled
1858        );
1859        drop(conn);
1860
1861        // Disable.
1862        let args = RulesArgs {
1863            key_dir: Some(key_dir.clone()),
1864            action: RulesAction::Disable {
1865                id: "R-toggle".into(),
1866                sign: true,
1867            },
1868        };
1869        let mut stdout = Vec::new();
1870        let mut stderr = Vec::new();
1871        let mut out = CliOutput {
1872            stdout: &mut stdout,
1873            stderr: &mut stderr,
1874        };
1875        run(&db_path, args, true, &mut out).expect("disable");
1876        let conn = rusqlite::Connection::open(&db_path).unwrap();
1877        assert!(
1878            !rules_store::get(&conn, "R-toggle")
1879                .unwrap()
1880                .unwrap()
1881                .enabled
1882        );
1883    }
1884
1885    #[cfg(unix)]
1886    #[test]
1887    fn run_rules_disable_unsign_refuses() {
1888        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1889        let args = RulesArgs {
1890            key_dir: Some(key_dir),
1891            action: RulesAction::Disable {
1892                id: "R-x".into(),
1893                sign: false,
1894            },
1895        };
1896        let mut stdout: Vec<u8> = Vec::new();
1897        let mut stderr: Vec<u8> = Vec::new();
1898        let mut out = CliOutput {
1899            stdout: &mut stdout,
1900            stderr: &mut stderr,
1901        };
1902        let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
1903        assert!(format!("{err:#}").contains("no_operator_key"));
1904    }
1905
1906    #[cfg(unix)]
1907    #[test]
1908    fn run_rules_disable_unknown_id_errors() {
1909        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1910        let args = RulesArgs {
1911            key_dir: Some(key_dir),
1912            action: RulesAction::Disable {
1913                id: "R-missing".into(),
1914                sign: true,
1915            },
1916        };
1917        let mut stdout: Vec<u8> = Vec::new();
1918        let mut stderr: Vec<u8> = Vec::new();
1919        let mut out = CliOutput {
1920            stdout: &mut stdout,
1921            stderr: &mut stderr,
1922        };
1923        let err = run(&db_path, args, false, &mut out).expect_err("must error");
1924        assert!(format!("{err:#}").contains("no rule with id"));
1925    }
1926
1927    #[cfg(unix)]
1928    #[test]
1929    fn run_rules_remove_unsign_refuses() {
1930        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1931        let args = RulesArgs {
1932            key_dir: Some(key_dir),
1933            action: RulesAction::Remove {
1934                id: "R-x".into(),
1935                sign: false,
1936            },
1937        };
1938        let mut stdout: Vec<u8> = Vec::new();
1939        let mut stderr: Vec<u8> = Vec::new();
1940        let mut out = CliOutput {
1941            stdout: &mut stdout,
1942            stderr: &mut stderr,
1943        };
1944        let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
1945        assert!(format!("{err:#}").contains("no_operator_key"));
1946    }
1947
1948    #[cfg(unix)]
1949    #[test]
1950    fn run_rules_remove_signed_deletes_row() {
1951        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1952        // Add then remove.
1953        let args = RulesArgs {
1954            key_dir: Some(key_dir.clone()),
1955            action: RulesAction::Add {
1956                id: "R-rm".into(),
1957                kind: "bash".into(),
1958                // SEC-12/COR-10: literal substring matcher.
1959                matcher: r#"{"command_substring":"x"}"#.into(),
1960                severity: "warn".into(),
1961                reason: "rm me".into(),
1962                namespace: "_global".into(),
1963                disabled: false,
1964                sign: true,
1965            },
1966        };
1967        let mut stdout: Vec<u8> = Vec::new();
1968        let mut stderr: Vec<u8> = Vec::new();
1969        let mut out = CliOutput {
1970            stdout: &mut stdout,
1971            stderr: &mut stderr,
1972        };
1973        run(&db_path, args, false, &mut out).expect("add");
1974
1975        let args = RulesArgs {
1976            key_dir: Some(key_dir),
1977            action: RulesAction::Remove {
1978                id: "R-rm".into(),
1979                sign: true,
1980            },
1981        };
1982        let mut stdout = Vec::new();
1983        let mut stderr = Vec::new();
1984        let mut out = CliOutput {
1985            stdout: &mut stdout,
1986            stderr: &mut stderr,
1987        };
1988        run(&db_path, args, true, &mut out).expect("remove");
1989        let s = String::from_utf8(stdout).unwrap();
1990        assert!(s.contains("rules.remove"), "got: {s}");
1991        assert!(s.contains("\"removed\":true"), "got: {s}");
1992        let conn = rusqlite::Connection::open(&db_path).unwrap();
1993        assert!(rules_store::get(&conn, "R-rm").unwrap().is_none());
1994    }
1995
1996    #[cfg(unix)]
1997    #[test]
1998    fn run_rules_keygen_writes_keypair_under_explicit_out() {
1999        let dir = tempfile::tempdir().unwrap();
2000        let db_path = dir.path().join("ai-memory.db");
2001        drop(crate::db::open(&db_path).expect("db::open"));
2002        let key_path = dir.path().join("op.key");
2003        let args = RulesArgs {
2004            key_dir: None,
2005            action: RulesAction::Keygen {
2006                out: Some(key_path.clone()),
2007                force: false,
2008            },
2009        };
2010        let mut stdout: Vec<u8> = Vec::new();
2011        let mut stderr: Vec<u8> = Vec::new();
2012        let mut out = CliOutput {
2013            stdout: &mut stdout,
2014            stderr: &mut stderr,
2015        };
2016        run(&db_path, args, true, &mut out).expect("keygen");
2017        let s = String::from_utf8(stdout).unwrap();
2018        assert!(s.contains("rules.keygen"), "got: {s}");
2019        assert!(key_path.exists(), "priv key missing");
2020        let pub_path = pub_sibling_path(&key_path);
2021        assert!(pub_path.exists(), "pub key missing");
2022    }
2023
2024    #[cfg(unix)]
2025    #[test]
2026    fn run_rules_sign_seed_signs_existing_rules() {
2027        // Build a fully-initialized DB, add a rule via run(), then call
2028        // sign-seed via run() (with --db override + --key explicit).
2029        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
2030        // Add an unsigned-attest-level rule directly so sign_seed_rules
2031        // has at least one row to operate on.
2032        let conn = rusqlite::Connection::open(&db_path).unwrap();
2033        rules_store::insert(
2034            &conn,
2035            &Rule {
2036                id: "R-ss".into(),
2037                kind: "bash".into(),
2038                matcher: r#"{"command_regex":"^x"}"#.into(),
2039                severity: "refuse".into(),
2040                reason: "t".into(),
2041                namespace: "_global".into(),
2042                created_by: "test".into(),
2043                created_at: 0,
2044                enabled: true,
2045                signature: None,
2046                attest_level: "unsigned".into(),
2047            },
2048        )
2049        .unwrap();
2050        drop(conn);
2051
2052        // The sign-seed verb expects the singleton-file layout
2053        // (`~/.config/ai-memory/operator.key`). We saved the keypair
2054        // in dir-layout for the other tests, so generate a fresh
2055        // singleton-file via `keygen_operator` first.
2056        let dir2 = tempfile::tempdir().unwrap();
2057        let key_file = dir2.path().join("operator.key");
2058        let mut stdout: Vec<u8> = Vec::new();
2059        let mut stderr: Vec<u8> = Vec::new();
2060        let mut out = CliOutput {
2061            stdout: &mut stdout,
2062            stderr: &mut stderr,
2063        };
2064        keygen_operator(&key_file, false, &mut out).unwrap();
2065
2066        let args = RulesArgs {
2067            key_dir: Some(key_dir),
2068            action: RulesAction::SignSeed {
2069                key: Some(key_file),
2070                db: Some(db_path.clone()),
2071            },
2072        };
2073        let mut stdout: Vec<u8> = Vec::new();
2074        let mut stderr: Vec<u8> = Vec::new();
2075        let mut out = CliOutput {
2076            stdout: &mut stdout,
2077            stderr: &mut stderr,
2078        };
2079        // We pass a separate `--db` to drive the dispatch's
2080        // `if let Some(db_path) = db` branch (line 350-354).
2081        let placeholder_db = tempfile::tempdir().unwrap();
2082        let placeholder_path = placeholder_db.path().join("placeholder.db");
2083        drop(crate::db::open(&placeholder_path).unwrap());
2084        run(&placeholder_path, args, true, &mut out).expect("sign-seed");
2085        let s = String::from_utf8(stdout).unwrap();
2086        assert!(s.contains("rules.sign-seed"), "got: {s}");
2087    }
2088
2089    #[cfg(unix)]
2090    #[test]
2091    fn run_rules_sign_seed_reuses_open_conn_when_no_db_override() {
2092        // Drives the else-branch (line 356) where the top-level
2093        // `--db` flag's open connection is reused.
2094        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
2095        let dir2 = tempfile::tempdir().unwrap();
2096        let key_file = dir2.path().join("operator.key");
2097        let mut stdout: Vec<u8> = Vec::new();
2098        let mut stderr: Vec<u8> = Vec::new();
2099        let mut out = CliOutput {
2100            stdout: &mut stdout,
2101            stderr: &mut stderr,
2102        };
2103        keygen_operator(&key_file, false, &mut out).unwrap();
2104        let args = RulesArgs {
2105            key_dir: Some(key_dir),
2106            action: RulesAction::SignSeed {
2107                key: Some(key_file),
2108                db: None,
2109            },
2110        };
2111        let mut stdout: Vec<u8> = Vec::new();
2112        let mut stderr: Vec<u8> = Vec::new();
2113        let mut out = CliOutput {
2114            stdout: &mut stdout,
2115            stderr: &mut stderr,
2116        };
2117        run(&db_path, args, false, &mut out).expect("sign-seed reuse");
2118    }
2119
2120    /// #822 regression helper: drive `rules sign-seed --key-dir <dir>`
2121    /// (with no explicit `--key`) and assert it succeeds. Pre-fix this
2122    /// fell through to `~/.config/ai-memory/operator.key` and failed
2123    /// with `No such file or directory` on CI runners with no $HOME
2124    /// keypair laid down.
2125    #[cfg(unix)]
2126    fn assert_sign_seed_succeeds_with_key_dir_only(
2127        db_path: &std::path::Path,
2128        key_dir: std::path::PathBuf,
2129    ) {
2130        let conn = rusqlite::Connection::open(db_path).unwrap();
2131        rules_store::insert(
2132            &conn,
2133            &Rule {
2134                id: "R-822".into(),
2135                kind: "bash".into(),
2136                matcher: r#"{"command_regex":"^x"}"#.into(),
2137                severity: "refuse".into(),
2138                reason: "t".into(),
2139                namespace: "_global".into(),
2140                created_by: "test".into(),
2141                created_at: 0,
2142                enabled: true,
2143                signature: None,
2144                attest_level: "unsigned".into(),
2145            },
2146        )
2147        .unwrap();
2148        drop(conn);
2149
2150        let args = RulesArgs {
2151            key_dir: Some(key_dir),
2152            action: RulesAction::SignSeed {
2153                key: None, // load-bearing: NO explicit --key.
2154                db: None,
2155            },
2156        };
2157        let mut stdout: Vec<u8> = Vec::new();
2158        let mut stderr: Vec<u8> = Vec::new();
2159        let mut out = CliOutput {
2160            stdout: &mut stdout,
2161            stderr: &mut stderr,
2162        };
2163        let result = run(db_path, args, true, &mut out);
2164        let stderr_s = String::from_utf8_lossy(&stderr).to_string();
2165        assert!(
2166            result.is_ok(),
2167            "#822: sign-seed must honor --key-dir; got err={result:?} stderr={stderr_s}"
2168        );
2169        let s = String::from_utf8(stdout).unwrap();
2170        assert!(s.contains("rules.sign-seed"), "got: {s}");
2171    }
2172
2173    /// #822 regression — layout-2 (`<key_dir>/operator.key`, the
2174    /// singleton-file layout `rules keygen --out <dir>/operator.key`
2175    /// writes; this is the layout the failing CI test used).
2176    #[cfg(unix)]
2177    #[test]
2178    fn run_rules_sign_seed_honors_key_dir_layout_key() {
2179        let (dir, db_path, _kp_key_dir) = fresh_env_with_operator_key();
2180        // Lay down only the singleton-file layout.
2181        let key_dir = dir.path().join("keys-822-key");
2182        std::fs::create_dir_all(&key_dir).unwrap();
2183        let key_file = key_dir.join("operator.key");
2184        let mut stdout: Vec<u8> = Vec::new();
2185        let mut stderr: Vec<u8> = Vec::new();
2186        let mut out = CliOutput {
2187            stdout: &mut stdout,
2188            stderr: &mut stderr,
2189        };
2190        keygen_operator(&key_file, false, &mut out).unwrap();
2191        assert!(key_file.exists(), "keygen must lay down operator.key");
2192        assert!(
2193            !key_dir.join("operator.priv").exists(),
2194            "this branch must not have the .priv layout present"
2195        );
2196        assert_sign_seed_succeeds_with_key_dir_only(&db_path, key_dir);
2197    }
2198
2199    /// #822 regression — layout-1 (`<key_dir>/operator.priv` +
2200    /// `operator.pub`, the legacy `kp::save` layout that
2201    /// `fresh_env_with_operator_key` writes).
2202    #[cfg(unix)]
2203    #[test]
2204    fn run_rules_sign_seed_honors_key_dir_layout_priv() {
2205        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
2206        assert!(
2207            key_dir.join("operator.priv").exists(),
2208            "fresh_env_with_operator_key must lay down operator.priv"
2209        );
2210        assert!(
2211            !key_dir.join("operator.key").exists(),
2212            "this branch must not have the .key layout present"
2213        );
2214        assert_sign_seed_succeeds_with_key_dir_only(&db_path, key_dir);
2215    }
2216
2217    /// #827 regression — third branch of the `key.or_else(...)` chain.
2218    /// When `--key-dir <dir>` is supplied and `<dir>` contains NEITHER
2219    /// `operator.key` nor `operator.priv`, `resolved_key` is `None`
2220    /// and `sign_seed_rules` falls through to
2221    /// `resolve_operator_key_path(None)` (the legacy
2222    /// `~/.config/ai-memory/operator.key` shape). With `HOME` +
2223    /// `XDG_CONFIG_HOME` pointed at an empty tempdir, that legacy
2224    /// path also fails to resolve, surfacing as a load-error citing
2225    /// the legacy path. The test pins that this third branch yields
2226    /// a clean Err (not a panic, not an Ok), closing the
2227    /// PR #820 cli/rules.rs coverage-floor breach.
2228    #[cfg(unix)]
2229    #[test]
2230    fn run_rules_sign_seed_neither_layout_falls_through_to_legacy_path_and_errors() {
2231        // Serialize HOME/XDG mutation against parallel tests in this
2232        // module so we don't race other tests that read those vars.
2233        static HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2234        let _guard = HOME_ENV_LOCK
2235            .lock()
2236            .unwrap_or_else(std::sync::PoisonError::into_inner);
2237
2238        // Snapshot prior values so we restore even on assertion panic.
2239        let prev_home = std::env::var("HOME").ok();
2240        let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
2241
2242        let dir = tempfile::tempdir().expect("tempdir");
2243        let db_path = dir.path().join("ai-memory.db");
2244        // Initialize the full schema — same shape as
2245        // `fresh_env_with_operator_key` minus the keypair.
2246        drop(crate::db::open(&db_path).expect("db::open"));
2247
2248        // `key_dir` exists but contains neither `operator.key` nor
2249        // `operator.priv` — this is the load-bearing precondition that
2250        // forces `resolved_key = None` and triggers the third branch.
2251        let key_dir = dir.path().join("empty-keys");
2252        std::fs::create_dir_all(&key_dir).expect("mkdir empty-keys");
2253        assert!(
2254            !key_dir.join("operator.key").exists() && !key_dir.join("operator.priv").exists(),
2255            "preconditions: neither layout may exist for this branch"
2256        );
2257
2258        // Point HOME + XDG_CONFIG_HOME at empty subdirs so
2259        // `dirs::config_dir()` (the source of the legacy default path)
2260        // resolves to a directory with no operator.key.
2261        let fake_home = dir.path().join("fake-home");
2262        let fake_xdg = dir.path().join("fake-xdg-config");
2263        std::fs::create_dir_all(&fake_home).unwrap();
2264        std::fs::create_dir_all(&fake_xdg).unwrap();
2265        // SAFETY: env mutation is serialized by `HOME_ENV_LOCK` above.
2266        unsafe {
2267            std::env::set_var("HOME", &fake_home);
2268            std::env::set_var("XDG_CONFIG_HOME", &fake_xdg);
2269        }
2270
2271        // Arrange a Rule so `sign_seed_rules` has work to do; the load
2272        // path fails before any row is touched, but having a row makes
2273        // the test failure mode obvious if the third branch ever
2274        // silently succeeds against the real `$HOME`.
2275        let conn = rusqlite::Connection::open(&db_path).unwrap();
2276        rules_store::insert(
2277            &conn,
2278            &Rule {
2279                id: "R-827".into(),
2280                kind: "bash".into(),
2281                matcher: r#"{"command_regex":"^x"}"#.into(),
2282                severity: "refuse".into(),
2283                reason: "t".into(),
2284                namespace: "_global".into(),
2285                created_by: "test".into(),
2286                created_at: 0,
2287                enabled: true,
2288                signature: None,
2289                attest_level: "unsigned".into(),
2290            },
2291        )
2292        .unwrap();
2293        drop(conn);
2294
2295        let args = RulesArgs {
2296            key_dir: Some(key_dir),
2297            action: RulesAction::SignSeed {
2298                key: None, // load-bearing: NO explicit --key.
2299                db: None,
2300            },
2301        };
2302        let mut stdout: Vec<u8> = Vec::new();
2303        let mut stderr: Vec<u8> = Vec::new();
2304        let mut out = CliOutput {
2305            stdout: &mut stdout,
2306            stderr: &mut stderr,
2307        };
2308        let result = run(&db_path, args, true, &mut out);
2309
2310        // Restore env BEFORE assertions so we don't leak state on
2311        // assertion panic.
2312        // SAFETY: env mutation is serialized by `HOME_ENV_LOCK` above.
2313        unsafe {
2314            match prev_home {
2315                Some(v) => std::env::set_var("HOME", v),
2316                None => std::env::remove_var("HOME"),
2317            }
2318            match prev_xdg {
2319                Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
2320                None => std::env::remove_var("XDG_CONFIG_HOME"),
2321            }
2322        }
2323
2324        let err = result
2325            .expect_err("#827: third branch must Err, not silently succeed against real $HOME");
2326        let msg = format!("{err:#}");
2327        // The error chain must cite the legacy `operator.key` path —
2328        // that is the literal value `resolve_operator_key_path(None)`
2329        // returns (`<config>/ai-memory/operator.key`). Asserting on
2330        // the filename is brittle-resistant: it survives moves of the
2331        // base path so long as the suffix discipline holds.
2332        assert!(
2333            msg.contains("operator.key"),
2334            "#827: error must cite the legacy operator.key fallback path; got: {msg}"
2335        );
2336        assert!(
2337            msg.contains("sign-seed") || msg.contains("rules.sign-seed"),
2338            "#827: error must surface from the sign-seed verb; got: {msg}"
2339        );
2340    }
2341
2342    #[test]
2343    fn resolve_key_dir_returns_override() {
2344        let p = std::path::PathBuf::from("/some/explicit/dir");
2345        let out = resolve_key_dir(Some(&p)).unwrap();
2346        assert_eq!(out, p);
2347    }
2348
2349    #[test]
2350    fn resolve_operator_key_path_returns_override() {
2351        let p = std::path::PathBuf::from("/custom/operator.key");
2352        let out = resolve_operator_key_path(Some(&p)).unwrap();
2353        assert_eq!(out, p);
2354    }
2355
2356    #[test]
2357    fn resolve_operator_key_path_default_includes_ai_memory() {
2358        let p = resolve_operator_key_path(None).unwrap();
2359        let s = p.display().to_string();
2360        assert!(
2361            s.contains("ai-memory"),
2362            "default path missing ai-memory: {s}"
2363        );
2364        assert!(s.ends_with("operator.key"), "got: {s}");
2365    }
2366
2367    #[test]
2368    fn resolve_keygen_out_path_explicit_out_wins_1610() {
2369        let out = std::path::PathBuf::from("/custom/operator.key");
2370        let kd = std::path::PathBuf::from("/etc/ai-memory/keys");
2371        let r = resolve_keygen_out_path(Some(&out), &kd, true).unwrap();
2372        assert_eq!(r, out, "--out must win over a key-dir override");
2373    }
2374
2375    #[test]
2376    fn resolve_keygen_out_path_overridden_key_dir_wins_1610() {
2377        // The F1 split-brain: keygen must write into the SAME dir the
2378        // --sign verbs read when the operator relocated the key store.
2379        let kd = std::path::PathBuf::from("/etc/ai-memory/keys");
2380        let r = resolve_keygen_out_path(None, &kd, true).unwrap();
2381        assert_eq!(r, kd.join(OPERATOR_KEY_FILENAME));
2382    }
2383
2384    #[test]
2385    fn resolve_keygen_out_path_no_override_falls_back_to_legacy_singleton_1610() {
2386        let kd = std::path::PathBuf::from("/ignored/keys");
2387        let r = resolve_keygen_out_path(None, &kd, false).unwrap();
2388        let s = r.display().to_string();
2389        assert!(s.contains("ai-memory"), "legacy singleton path: {s}");
2390        assert!(
2391            !s.starts_with("/ignored"),
2392            "must NOT use key_dir when no override is in force: {s}"
2393        );
2394    }
2395
2396    #[test]
2397    fn emit_ok_human_format_emits_pretty_json() {
2398        let mut stdout: Vec<u8> = Vec::new();
2399        let mut stderr: Vec<u8> = Vec::new();
2400        let mut out = CliOutput {
2401            stdout: &mut stdout,
2402            stderr: &mut stderr,
2403        };
2404        let payload = serde_json::json!({"foo":"bar","n":1});
2405        emit_ok(false, &mut out, "test.verb", &payload).unwrap();
2406        let s = String::from_utf8(stdout).unwrap();
2407        // Pretty-print includes newlines + 2-space indent.
2408        assert!(s.contains("\"foo\": \"bar\""), "got: {s}");
2409        assert!(s.contains("\n"), "pretty must include newlines: {s}");
2410    }
2411
2412    #[test]
2413    fn emit_ok_json_format_envelopes_under_verb() {
2414        let mut stdout: Vec<u8> = Vec::new();
2415        let mut stderr: Vec<u8> = Vec::new();
2416        let mut out = CliOutput {
2417            stdout: &mut stdout,
2418            stderr: &mut stderr,
2419        };
2420        let payload = serde_json::json!({"x":1});
2421        emit_ok(true, &mut out, "test.verb", &payload).unwrap();
2422        let s = String::from_utf8(stdout).unwrap();
2423        assert!(s.contains("\"verb\":\"test.verb\""), "got: {s}");
2424        assert!(s.contains("\"result\":{\"x\":1}"), "got: {s}");
2425    }
2426
2427    #[test]
2428    fn resolve_agent_id_returns_non_empty() {
2429        // The fn falls back to `anonymous:pid-<N>` if identity
2430        // resolution fails — never returns an empty string.
2431        let id = resolve_agent_id();
2432        assert!(!id.is_empty());
2433    }
2434
2435    #[cfg(unix)]
2436    #[test]
2437    fn sign_seed_rules_is_idempotent() {
2438        let tdir = tempfile::tempdir().unwrap();
2439        let key_path = tdir.path().join("operator.key");
2440        let mut stdout: Vec<u8> = Vec::new();
2441        let mut stderr: Vec<u8> = Vec::new();
2442        let mut out = CliOutput {
2443            stdout: &mut stdout,
2444            stderr: &mut stderr,
2445        };
2446        keygen_operator(&key_path, false, &mut out).unwrap();
2447
2448        let conn = fresh_rules_conn();
2449        rules_store::insert(
2450            &conn,
2451            &Rule {
2452                id: "R001".into(),
2453                kind: "filesystem_write".into(),
2454                matcher: r#"{"glob":"/tmp/**"}"#.into(),
2455                severity: "refuse".into(),
2456                reason: "t".into(),
2457                namespace: "_global".into(),
2458                created_by: "system:seed".into(),
2459                created_at: 0,
2460                enabled: false,
2461                signature: None,
2462                attest_level: "unsigned".into(),
2463            },
2464        )
2465        .unwrap();
2466
2467        // First call: signs 1 row.
2468        let signed1 = sign_seed_rules(&conn, Some(&key_path), true, &mut out).unwrap();
2469        assert_eq!(signed1, 1);
2470        let sig_after_first = rules_store::get(&conn, "R001").unwrap().unwrap().signature;
2471
2472        // Second call: no-op because the canonical bytes + key are the
2473        // same so the computed signature matches the stored one.
2474        let signed2 = sign_seed_rules(&conn, Some(&key_path), true, &mut out).unwrap();
2475        assert_eq!(signed2, 0);
2476        let sig_after_second = rules_store::get(&conn, "R001").unwrap().unwrap().signature;
2477        assert_eq!(
2478            sig_after_first, sig_after_second,
2479            "idempotent sign-seed must preserve the existing signature bytes"
2480        );
2481    }
2482
2483    /// Coverage restoration (post-#1558 floor dip): drive the FULL
2484    /// `run()` Add path with a `command_regex`-only matcher so the
2485    /// SEC-12 deprecation-warn branch executes, the operator-key
2486    /// load + sign path runs, and the rule lands in the store.
2487    #[cfg(unix)]
2488    #[test]
2489    fn rules_add_command_regex_only_fires_deprecation_branch_and_lands_rule() {
2490        let _g = forensic_lock();
2491        let tdir = tempfile::tempdir().unwrap();
2492        let key_path = tdir.path().join("operator.key");
2493        let mut stdout: Vec<u8> = Vec::new();
2494        let mut stderr: Vec<u8> = Vec::new();
2495        let mut out = CliOutput {
2496            stdout: &mut stdout,
2497            stderr: &mut stderr,
2498        };
2499        keygen_operator(&key_path, false, &mut out).unwrap();
2500
2501        // Full migration ladder so governance_rules (v30) exists at
2502        // the path `run()` opens.
2503        let db_path = tdir.path().join("rules.db");
2504        drop(crate::storage::open(&db_path).expect("init schema"));
2505
2506        let args = RulesArgs {
2507            key_dir: Some(tdir.path().to_path_buf()),
2508            action: RulesAction::Add {
2509                id: "R900-cov".into(),
2510                kind: "bash".into(),
2511                matcher: r#"{"command_regex":"rm -rf"}"#.into(),
2512                severity: "refuse".into(),
2513                reason: "coverage: deprecated-field branch".into(),
2514                namespace: crate::quotas::GLOBAL_NAMESPACE.into(),
2515                disabled: false,
2516                sign: true,
2517            },
2518        };
2519        run(&db_path, args, false, &mut out).expect("rules add --sign");
2520
2521        let conn = rusqlite::Connection::open(&db_path).unwrap();
2522        let rule = rules_store::get(&conn, "R900-cov")
2523            .unwrap()
2524            .expect("rule landed");
2525        assert!(
2526            rule.signature.is_some(),
2527            "rules add --sign must store a signature"
2528        );
2529        assert_eq!(rule.namespace, crate::quotas::GLOBAL_NAMESPACE);
2530    }
2531
2532    /// Coverage restoration: the clap `default_value =
2533    /// crate::quotas::GLOBAL_NAMESPACE` expansion on `--namespace`
2534    /// (today's #1558 batch-1 routing) only executes through a real
2535    /// parse — direct enum construction skips it.
2536    #[test]
2537    fn rules_add_namespace_clap_default_is_global() {
2538        use clap::Parser;
2539        #[derive(Parser)]
2540        struct Harness {
2541            #[command(flatten)]
2542            rules: RulesArgs,
2543        }
2544        let h = Harness::try_parse_from([
2545            "harness",
2546            "add",
2547            "--id",
2548            "RX",
2549            "--kind",
2550            "bash",
2551            "--matcher",
2552            "{}",
2553            "--reason",
2554            "cov",
2555            "--sign",
2556        ])
2557        .expect("parse");
2558        match h.rules.action {
2559            RulesAction::Add { namespace, .. } => {
2560                assert_eq!(namespace, crate::quotas::GLOBAL_NAMESPACE);
2561            }
2562            _ => panic!("expected Add"),
2563        }
2564    }
2565
2566    // -----------------------------------------------------------------
2567    // GA-drive 2026-06-09 (per-module floor 95%) — targeted coverage
2568    // for the remaining uncovered branches: the keygen-layout parent-
2569    // dir fallback (#800 Gap #6, layout 3), the layout-2 tampered-pub
2570    // refusals, the `sign-seed --db` open failure, the keygen error
2571    // branches, and the no-key-anywhere refusal.
2572    // -----------------------------------------------------------------
2573
2574    /// Writer that always fails — drives the `?` error branch on the
2575    /// keygen `writeln!` sites (broken-pipe propagation contract).
2576    struct FailingWriter;
2577    impl std::io::Write for FailingWriter {
2578        fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
2579            Err(std::io::Error::new(
2580                std::io::ErrorKind::BrokenPipe,
2581                "test writer: broken pipe",
2582            ))
2583        }
2584        fn flush(&mut self) -> std::io::Result<()> {
2585            Ok(())
2586        }
2587    }
2588
2589    #[cfg(unix)]
2590    #[test]
2591    fn run_rules_sign_seed_db_override_open_failure_errors() {
2592        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
2593        // A db path whose parent directory does not exist — the
2594        // subcommand-level `--db` reopen must fail with the
2595        // `rules.sign-seed: open db at` context.
2596        let bad_db = _dir.path().join("no-such-dir").join("x.db");
2597        let args = RulesArgs {
2598            key_dir: Some(key_dir),
2599            action: RulesAction::SignSeed {
2600                key: None,
2601                db: Some(bad_db),
2602            },
2603        };
2604        let mut stdout: Vec<u8> = Vec::new();
2605        let mut stderr: Vec<u8> = Vec::new();
2606        let mut out = CliOutput {
2607            stdout: &mut stdout,
2608            stderr: &mut stderr,
2609        };
2610        let err = run(&db_path, args, true, &mut out).expect_err("open must fail");
2611        let msg = format!("{err:#}");
2612        assert!(msg.contains("rules.sign-seed: open db"), "got: {msg}");
2613    }
2614
2615    #[cfg(unix)]
2616    #[test]
2617    fn keygen_create_parent_dir_failure_errors() {
2618        // The `--out` parent path component is a REGULAR FILE, so
2619        // create_dir_all fails (ENOTDIR) and the with_context label
2620        // names the parent dir.
2621        let dir = tempfile::tempdir().unwrap();
2622        let blocker = dir.path().join("blocker");
2623        std::fs::write(&blocker, b"i am a file").unwrap();
2624        let key_path = blocker.join("sub").join("op.key");
2625        let mut stdout: Vec<u8> = Vec::new();
2626        let mut stderr: Vec<u8> = Vec::new();
2627        let mut out = CliOutput {
2628            stdout: &mut stdout,
2629            stderr: &mut stderr,
2630        };
2631        let err = keygen_operator(&key_path, false, &mut out).unwrap_err();
2632        let msg = format!("{err:#}");
2633        assert!(msg.contains("create parent dir"), "got: {msg}");
2634    }
2635
2636    #[cfg(unix)]
2637    #[test]
2638    fn keygen_force_warning_broken_pipe_propagates() {
2639        // Existing key + --force: the stderr WARNING writeln must
2640        // propagate a write failure instead of panicking.
2641        let dir = tempfile::tempdir().unwrap();
2642        let key_path = dir.path().join("operator.key");
2643        let mut stdout: Vec<u8> = Vec::new();
2644        let mut stderr: Vec<u8> = Vec::new();
2645        let mut out = CliOutput {
2646            stdout: &mut stdout,
2647            stderr: &mut stderr,
2648        };
2649        keygen_operator(&key_path, false, &mut out).expect("first keygen");
2650
2651        let mut failing = FailingWriter;
2652        let mut stdout2: Vec<u8> = Vec::new();
2653        let mut out2 = CliOutput {
2654            stdout: &mut stdout2,
2655            stderr: &mut failing,
2656        };
2657        let res = keygen_operator(&key_path, true, &mut out2);
2658        assert!(res.is_err(), "stderr write failure must propagate");
2659    }
2660
2661    #[cfg(unix)]
2662    #[test]
2663    fn keygen_success_line_broken_pipe_propagates() {
2664        // The final fingerprint writeln to stdout fails — keys are on
2665        // disk but the handler must surface the I/O error.
2666        let dir = tempfile::tempdir().unwrap();
2667        let key_path = dir.path().join("operator.key");
2668        let mut failing = FailingWriter;
2669        let mut stderr: Vec<u8> = Vec::new();
2670        let mut out = CliOutput {
2671            stdout: &mut failing,
2672            stderr: &mut stderr,
2673        };
2674        let res = keygen_operator(&key_path, false, &mut out);
2675        assert!(res.is_err(), "stdout write failure must propagate");
2676        assert!(key_path.exists(), "key material still lands on disk");
2677    }
2678
2679    #[cfg(unix)]
2680    #[test]
2681    fn sign_seed_update_signature_failure_propagates() {
2682        // An abort trigger on governance_rules makes the UPDATE fail
2683        // so sign_seed_rules' update_signature `?` branch executes.
2684        let tdir = tempfile::tempdir().unwrap();
2685        let key_path = tdir.path().join("operator.key");
2686        let mut stdout: Vec<u8> = Vec::new();
2687        let mut stderr: Vec<u8> = Vec::new();
2688        let mut out = CliOutput {
2689            stdout: &mut stdout,
2690            stderr: &mut stderr,
2691        };
2692        keygen_operator(&key_path, false, &mut out).unwrap();
2693
2694        let conn = fresh_rules_conn();
2695        rules_store::insert(
2696            &conn,
2697            &Rule {
2698                id: "R-fail-upd".into(),
2699                kind: "bash".into(),
2700                matcher: r#"{"command_substring":"x"}"#.into(),
2701                severity: "refuse".into(),
2702                reason: "t".into(),
2703                namespace: "_global".into(),
2704                created_by: "test".into(),
2705                created_at: 0,
2706                enabled: false,
2707                signature: None,
2708                attest_level: "unsigned".into(),
2709            },
2710        )
2711        .unwrap();
2712        conn.execute_batch(
2713            "CREATE TRIGGER test_fail_sig_update BEFORE UPDATE ON governance_rules \
2714             BEGIN SELECT RAISE(ABORT, 'test trigger: signature update refused'); END;",
2715        )
2716        .unwrap();
2717        let err = sign_seed_rules(&conn, Some(&key_path), true, &mut out).unwrap_err();
2718        let msg = format!("{err:#}");
2719        assert!(msg.contains("signature update refused"), "got: {msg}");
2720    }
2721
2722    #[cfg(unix)]
2723    #[test]
2724    fn mutation_verb_legacy_layout_load_failure_cites_key_dir() {
2725        use std::os::unix::fs::PermissionsExt;
2726        // operator.priv + operator.pub both present (layout 1 entry
2727        // condition) but the priv mode bits are insecure → kp::load
2728        // refuses and the with_context label names both files.
2729        let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
2730        let priv_path = key_dir.join("operator.priv");
2731        std::fs::set_permissions(&priv_path, std::fs::Permissions::from_mode(0o644)).unwrap();
2732        let args = RulesArgs {
2733            key_dir: Some(key_dir),
2734            action: RulesAction::Enable {
2735                id: "R-any".into(),
2736                sign: true,
2737            },
2738        };
2739        let mut stdout: Vec<u8> = Vec::new();
2740        let mut stderr: Vec<u8> = Vec::new();
2741        let mut out = CliOutput {
2742            stdout: &mut stdout,
2743            stderr: &mut stderr,
2744        };
2745        let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
2746        let msg = format!("{err:#}");
2747        assert!(
2748            msg.contains("failed loading operator.priv/operator.pub"),
2749            "got: {msg}"
2750        );
2751        // Restore so tempdir cleanup works.
2752        std::fs::set_permissions(&priv_path, std::fs::Permissions::from_mode(0o600)).unwrap();
2753    }
2754
2755    #[test]
2756    fn list_on_fresh_unmigrated_db_succeeds() {
2757        // Regression (do-1461 A2A run, 2026-06-15): `ai-memory rules list`
2758        // against a FRESH, never-migrated db must migrate-on-open and
2759        // succeed — NOT fail with `rules_store::list: prepare — no such
2760        // table: governance_rules`. Pre-fix the rules CLI used a raw
2761        // `rusqlite::Connection::open` that skipped migrations, which broke
2762        // Form-7 governance bootstrap on fresh fleet peers (especially
2763        // postgres-backed ones) where the daemon — which would have
2764        // migrated the local sqlite — has not started yet.
2765        let dir = tempfile::tempdir().unwrap();
2766        let db_path = dir.path().join("fresh-never-migrated.db");
2767        assert!(!db_path.exists(), "db must not exist before the rules call");
2768        let args = RulesArgs {
2769            key_dir: None,
2770            action: RulesAction::List,
2771        };
2772        let mut stdout: Vec<u8> = Vec::new();
2773        let mut stderr: Vec<u8> = Vec::new();
2774        let mut out = CliOutput {
2775            stdout: &mut stdout,
2776            stderr: &mut stderr,
2777        };
2778        run(&db_path, args, true, &mut out).expect("rules list must succeed on a fresh db");
2779        // Migrations created + seeded governance_rules → the list verb prints
2780        // the seed rules (R001..R004) rather than erroring on a missing table.
2781        let s = String::from_utf8(stdout).unwrap();
2782        assert!(
2783            s.contains("\"verb\":\"rules.list\"") && s.contains("R001"),
2784            "expected the seeded rules from the migrated fresh db, got: {s}"
2785        );
2786    }
2787
2788    /// Set up a key_dir holding the layout-2 singleton-file pair
2789    /// (`operator.key` + `operator.key.pub`) and an initialized DB.
2790    /// Returns (tempdir guard, db_path, key_dir).
2791    #[cfg(unix)]
2792    fn fresh_env_with_keygen_layout() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf)
2793    {
2794        let dir = tempfile::tempdir().expect("tempdir");
2795        let db_path = dir.path().join("ai-memory.db");
2796        drop(crate::db::open(&db_path).expect("db::open"));
2797        let key_dir = dir.path().join("keys-l2");
2798        std::fs::create_dir_all(&key_dir).expect("mkdir");
2799        let key_file = key_dir.join(OPERATOR_KEY_FILENAME);
2800        let mut stdout: Vec<u8> = Vec::new();
2801        let mut stderr: Vec<u8> = Vec::new();
2802        let mut out = CliOutput {
2803            stdout: &mut stdout,
2804            stderr: &mut stderr,
2805        };
2806        keygen_operator(&key_file, false, &mut out).expect("keygen");
2807        (dir, db_path, key_dir)
2808    }
2809
2810    /// Drive a mutation verb (`enable --sign`) so the key load runs;
2811    /// returns the error (the rule id never resolves — every test
2812    /// using this asserts on the key-load refusal that fires first).
2813    #[cfg(unix)]
2814    fn enable_err_with_key_dir(db_path: &Path, key_dir: std::path::PathBuf) -> anyhow::Error {
2815        let args = RulesArgs {
2816            key_dir: Some(key_dir),
2817            action: RulesAction::Enable {
2818                id: "R-never".into(),
2819                sign: true,
2820            },
2821        };
2822        let mut stdout: Vec<u8> = Vec::new();
2823        let mut stderr: Vec<u8> = Vec::new();
2824        let mut out = CliOutput {
2825            stdout: &mut stdout,
2826            stderr: &mut stderr,
2827        };
2828        run(db_path, args, false, &mut out).expect_err("must error")
2829    }
2830
2831    #[cfg(unix)]
2832    #[test]
2833    fn keygen_layout_pub_not_base64_refused() {
2834        let (_dir, db_path, key_dir) = fresh_env_with_keygen_layout();
2835        std::fs::write(key_dir.join("operator.key.pub"), "!!!not-base64!!!").unwrap();
2836        let err = enable_err_with_key_dir(&db_path, key_dir);
2837        let msg = format!("{err:#}");
2838        assert!(msg.contains("decode base64url public key"), "got: {msg}");
2839    }
2840
2841    #[cfg(unix)]
2842    #[test]
2843    fn keygen_layout_pub_wrong_length_refused() {
2844        use base64::Engine;
2845        let (_dir, db_path, key_dir) = fresh_env_with_keygen_layout();
2846        let short = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 16]);
2847        std::fs::write(key_dir.join("operator.key.pub"), short).unwrap();
2848        let err = enable_err_with_key_dir(&db_path, key_dir);
2849        let msg = format!("{err:#}");
2850        assert!(msg.contains("decoded to 16 bytes"), "got: {msg}");
2851    }
2852
2853    #[cfg(unix)]
2854    #[test]
2855    fn keygen_layout_pub_mismatch_refused() {
2856        use base64::Engine;
2857        let (_dir, db_path, key_dir) = fresh_env_with_keygen_layout();
2858        // A syntactically valid but WRONG 32-byte public key.
2859        let other = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([9u8; 32]);
2860        std::fs::write(key_dir.join("operator.key.pub"), other).unwrap();
2861        let err = enable_err_with_key_dir(&db_path, key_dir);
2862        let msg = format!("{err:#}");
2863        assert!(msg.contains("does not match public key"), "got: {msg}");
2864    }
2865
2866    /// Set up the layout-3 shape: the keygen pair lives in the PARENT
2867    /// of the key_dir (`resolve_operator_key_path` singleton rationale)
2868    /// while the key_dir itself is an empty `keys/` subdir.
2869    #[cfg(unix)]
2870    fn fresh_env_with_parent_keygen_layout()
2871    -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
2872        let dir = tempfile::tempdir().expect("tempdir");
2873        let db_path = dir.path().join("ai-memory.db");
2874        drop(crate::db::open(&db_path).expect("db::open"));
2875        let key_file = dir.path().join(OPERATOR_KEY_FILENAME);
2876        let mut stdout: Vec<u8> = Vec::new();
2877        let mut stderr: Vec<u8> = Vec::new();
2878        let mut out = CliOutput {
2879            stdout: &mut stdout,
2880            stderr: &mut stderr,
2881        };
2882        keygen_operator(&key_file, false, &mut out).expect("keygen");
2883        let key_dir = dir.path().join("keys");
2884        std::fs::create_dir_all(&key_dir).expect("mkdir keys");
2885        (dir, db_path, key_dir)
2886    }
2887
2888    #[cfg(unix)]
2889    #[test]
2890    fn parent_dir_keygen_fallback_signs_mutation_verbs() {
2891        // #800 Gap #6 — a fresh `rules keygen` (parent-dir layout) +
2892        // immediate `rules add --sign` must just work.
2893        let (_dir, db_path, key_dir) = fresh_env_with_parent_keygen_layout();
2894        let args = RulesArgs {
2895            key_dir: Some(key_dir),
2896            action: RulesAction::Add {
2897                id: "R-l3".into(),
2898                kind: "bash".into(),
2899                matcher: r#"{"command_substring":"halt"}"#.into(),
2900                severity: "refuse".into(),
2901                reason: "layout-3 coverage".into(),
2902                namespace: "_global".into(),
2903                disabled: false,
2904                sign: true,
2905            },
2906        };
2907        let mut stdout: Vec<u8> = Vec::new();
2908        let mut stderr: Vec<u8> = Vec::new();
2909        let mut out = CliOutput {
2910            stdout: &mut stdout,
2911            stderr: &mut stderr,
2912        };
2913        run(&db_path, args, false, &mut out).expect("layout-3 add --sign");
2914        let conn = rusqlite::Connection::open(&db_path).unwrap();
2915        let r = rules_store::get(&conn, "R-l3")
2916            .unwrap()
2917            .expect("rule landed");
2918        assert_eq!(r.attest_level, OPERATOR_SIGNED_LEVEL);
2919        assert!(r.signature.is_some());
2920    }
2921
2922    #[cfg(unix)]
2923    #[test]
2924    fn parent_dir_keygen_fallback_pub_wrong_length_refused() {
2925        use base64::Engine;
2926        let (dir, db_path, key_dir) = fresh_env_with_parent_keygen_layout();
2927        let short = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([3u8; 8]);
2928        std::fs::write(dir.path().join("operator.key.pub"), short).unwrap();
2929        let err = enable_err_with_key_dir(&db_path, key_dir);
2930        let msg = format!("{err:#}");
2931        assert!(msg.contains("decoded to 8 bytes"), "got: {msg}");
2932    }
2933
2934    #[cfg(unix)]
2935    #[test]
2936    fn parent_dir_keygen_fallback_pub_mismatch_refused() {
2937        use base64::Engine;
2938        let (dir, db_path, key_dir) = fresh_env_with_parent_keygen_layout();
2939        let other = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([4u8; 32]);
2940        std::fs::write(dir.path().join("operator.key.pub"), other).unwrap();
2941        let err = enable_err_with_key_dir(&db_path, key_dir);
2942        let msg = format!("{err:#}");
2943        assert!(msg.contains("does not match public key"), "got: {msg}");
2944    }
2945
2946    #[cfg(unix)]
2947    #[test]
2948    fn no_operator_key_anywhere_names_all_layouts() {
2949        // key_dir AND its parent hold no key material — the unified
2950        // refusal must name every accepted layout so the operator can
2951        // pick one to materialise.
2952        let dir = tempfile::tempdir().unwrap();
2953        let db_path = dir.path().join("ai-memory.db");
2954        drop(crate::db::open(&db_path).expect("db::open"));
2955        let key_dir = dir.path().join("empty-parent").join("empty-keys");
2956        std::fs::create_dir_all(&key_dir).unwrap();
2957        let err = enable_err_with_key_dir(&db_path, key_dir);
2958        let msg = format!("{err:#}");
2959        assert!(msg.contains("no operator key found"), "got: {msg}");
2960        assert!(msg.contains("operator.priv"), "got: {msg}");
2961        assert!(msg.contains("rules keygen"), "got: {msg}");
2962    }
2963}