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