1use 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
51const OPERATOR_KEY_FILENAME: &str = "operator.key";
53
54pub const OPERATOR_KEY_ID: &str = "operator";
59
60pub const OPERATOR_SIGNED_LEVEL: &str =
65 crate::governance::rules_store::OPERATOR_SIGNED_ATTEST_LEVEL;
66
67const ED25519_SEED_LEN: usize = ed25519_dalek::SECRET_KEY_LENGTH;
69const ED25519_PUBLIC_LEN: usize = ed25519_dalek::PUBLIC_KEY_LENGTH;
71
72#[derive(Args)]
73pub struct RulesArgs {
74 #[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 {
87 #[arg(long)]
89 id: String,
90 #[arg(long)]
93 kind: String,
94 #[arg(long)]
97 matcher: String,
98 #[arg(long, default_value = "refuse")]
100 severity: String,
101 #[arg(long)]
103 reason: String,
104 #[arg(long, default_value = crate::quotas::GLOBAL_NAMESPACE)]
106 namespace: String,
107 #[arg(long)]
110 disabled: bool,
111 #[arg(long)]
114 sign: bool,
115 },
116 List,
119 Check {
123 #[arg(long)]
125 kind: String,
126 #[arg(long)]
129 payload: String,
130 #[arg(long)]
133 agent_id: Option<String>,
134 },
135 Enable {
137 #[arg(long)]
139 id: String,
140 #[arg(long)]
142 sign: bool,
143 },
144 Disable {
146 #[arg(long)]
148 id: String,
149 #[arg(long)]
151 sign: bool,
152 },
153 Remove {
155 #[arg(long)]
157 id: String,
158 #[arg(long)]
160 sign: bool,
161 },
162 Keygen {
176 #[arg(long, value_name = "PATH")]
179 out: Option<PathBuf>,
180 #[arg(long)]
183 force: bool,
184 },
185 SignSeed {
195 #[arg(long, value_name = "PATH")]
199 key: Option<PathBuf>,
200 #[arg(long, value_name = "PATH")]
205 db: Option<PathBuf>,
206 },
207}
208
209#[derive(Serialize)]
212struct CliEnvelope<'a> {
213 verb: &'a str,
214 result: serde_json::Value,
215}
216
217pub fn run(
225 db_path: &std::path::Path,
226 args: RulesArgs,
227 json: bool,
228 out: &mut CliOutput<'_>,
229) -> Result<()> {
230 let conn = crate::db::open(db_path)
240 .with_context(|| format!("rules: open db at {}", db_path.display()))?;
241 let key_dir = resolve_key_dir(args.key_dir.as_deref())?;
242
243 match args.action {
244 RulesAction::Add {
245 id,
246 kind,
247 matcher,
248 severity,
249 reason,
250 namespace,
251 disabled,
252 sign,
253 } => {
254 if !sign {
255 bail!("governance.no_operator_key: `rules add` requires --sign");
256 }
257 let signing_key = load_operator_signing_key_from_dir(&key_dir)?;
258 let matcher_json: serde_json::Value = serde_json::from_str(&matcher)
261 .with_context(|| format!("rules add: matcher is not valid JSON: {matcher}"))?;
262 if let Some(val) = matcher_json
269 .get(crate::governance::agent_action::MATCHER_COMMAND_SUBSTRING)
270 .or_else(|| {
271 matcher_json.get(crate::governance::agent_action::MATCHER_COMMAND_REGEX)
272 })
273 .and_then(|v| v.as_str())
274 {
275 crate::governance::agent_action::validate_command_substring(val)
276 .map_err(|e| anyhow::anyhow!("rules add: {e}"))?;
277 if matcher_json
278 .get(crate::governance::agent_action::MATCHER_COMMAND_REGEX)
279 .is_some()
280 && matcher_json
281 .get(crate::governance::agent_action::MATCHER_COMMAND_SUBSTRING)
282 .is_none()
283 {
284 tracing::warn!(
285 "rules add: matcher field `command_regex` is DEPRECATED — rename to \
286 `command_substring` (the engine has always done literal substring \
287 matching, not regex). See SEC-12 in the v0.7.0 cluster-D fix."
288 );
289 }
290 }
291 let created_at = chrono::Utc::now().timestamp();
292 let agent_id = resolve_agent_id();
293 let mut rule = Rule {
294 id: id.clone(),
295 kind,
296 matcher,
297 severity,
298 reason,
299 namespace,
300 created_by: agent_id,
301 created_at,
302 enabled: !disabled,
303 signature: None,
304 attest_level: crate::models::AttestLevel::Unsigned.as_str().to_string(),
305 };
306 let canonical = rules_store::canonical_bytes_for_signing(&rule)?;
317 let sig = signing_key.sign(&canonical);
318 rule.signature = Some(sig.to_bytes().to_vec());
319 rule.attest_level = OPERATOR_SIGNED_LEVEL.to_string();
320 rules_store::insert(&conn, &rule)?;
321 emit_ok(json, out, "rules.add", &rule_to_json(&rule))?;
322 Ok(())
323 }
324 RulesAction::List => {
325 let rules = rules_store::list(&conn)?;
326 let payload = serde_json::Value::Array(rules.iter().map(rule_to_json).collect());
327 emit_ok(json, out, "rules.list", &payload)?;
328 Ok(())
329 }
330 RulesAction::Check {
331 kind,
332 payload,
333 agent_id,
334 } => {
335 let action = build_action(&kind, &payload)?;
336 let resolved_agent = agent_id.unwrap_or_else(resolve_agent_id);
337 let decision = check_agent_action(&conn, &resolved_agent, &action)?;
338 emit_ok(json, out, "rules.check", &serde_json::to_value(&decision)?)?;
339 Ok(())
340 }
341 RulesAction::Enable { id, sign } => {
342 if !sign {
343 bail!("governance.no_operator_key: `rules enable` requires --sign");
344 }
345 let signing_key = load_operator_signing_key_from_dir(&key_dir)?;
346 let Some(mut rule) = rules_store::get(&conn, &id)? else {
347 bail!("rules.enable: no rule with id={id}");
348 };
349 rule.enabled = true;
350 let canonical = rules_store::canonical_bytes_for_signing(&rule)?;
356 let sig = signing_key.sign(&canonical);
357 rules_store::set_enabled(&conn, &id, true)?;
358 rules_store::update_signature(&conn, &id, &sig.to_bytes(), OPERATOR_SIGNED_LEVEL)?;
359 let updated =
360 rules_store::get(&conn, &id)?.context("rules.enable: row vanished after update")?;
361 emit_ok(json, out, "rules.enable", &rule_to_json(&updated))?;
362 Ok(())
363 }
364 RulesAction::Disable { id, sign } => {
365 if !sign {
366 bail!("governance.no_operator_key: `rules disable` requires --sign");
367 }
368 let signing_key = load_operator_signing_key_from_dir(&key_dir)?;
369 let Some(mut rule) = rules_store::get(&conn, &id)? else {
370 bail!("rules.disable: no rule with id={id}");
371 };
372 rule.enabled = false;
373 let canonical = rules_store::canonical_bytes_for_signing(&rule)?;
376 let sig = signing_key.sign(&canonical);
377 rules_store::set_enabled(&conn, &id, false)?;
378 rules_store::update_signature(&conn, &id, &sig.to_bytes(), OPERATOR_SIGNED_LEVEL)?;
379 let updated = rules_store::get(&conn, &id)?
380 .context("rules.disable: row vanished after update")?;
381 emit_ok(json, out, "rules.disable", &rule_to_json(&updated))?;
382 Ok(())
383 }
384 RulesAction::Remove { id, sign } => {
385 if !sign {
386 bail!("governance.no_operator_key: `rules remove` requires --sign");
387 }
388 let signing_key = load_operator_signing_key_from_dir(&key_dir)?;
389 let removed = rules_store::remove_signed(&conn, &id, &signing_key, OPERATOR_KEY_ID)?;
394 let payload = serde_json::json!({ "id": id, "removed": removed });
395 emit_ok(json, out, "rules.remove", &payload)?;
396 Ok(())
397 }
398 RulesAction::Keygen {
399 out: out_path,
400 force,
401 } => {
402 let key_dir_overridden = args.key_dir.is_some() || kp::key_dir_env_override().is_some();
411 let resolved =
412 resolve_keygen_out_path(out_path.as_deref(), &key_dir, key_dir_overridden)?;
413 let fingerprint = keygen_operator(&resolved, force, out)?;
414 if let Ok(rules) = rules_store::list(&conn) {
422 let dormant = rules
423 .iter()
424 .filter(|r| r.enabled && r.attest_level != OPERATOR_SIGNED_LEVEL)
425 .count();
426 if dormant > 0 {
427 writeln!(
428 out.stderr,
429 "WARNING: {dormant} enabled rule(s) are not operator-signed. \
430 Generating this operator key activates signature enforcement, so \
431 those rules will be SKIPPED at load time until you run \
432 `ai-memory rules sign-seed`."
433 )?;
434 }
435 }
436 let payload = serde_json::json!({
437 "path": resolved.display().to_string(),
438 "public_path": format!("{}.pub", resolved.display()),
439 "fingerprint": fingerprint,
440 });
441 emit_ok(json, out, "rules.keygen", &payload)?;
442 Ok(())
443 }
444 RulesAction::SignSeed { key, db } => {
445 let resolved_key: Option<PathBuf> = key.or_else(|| {
468 let key_layout = key_dir.join(OPERATOR_KEY_FILENAME);
469 if key_layout.exists() {
470 return Some(key_layout);
471 }
472 let priv_layout = key_dir.join("operator.priv");
473 if priv_layout.exists() {
474 return Some(priv_layout);
475 }
476 None
477 });
478 if let Some(db_path) = db {
479 let conn2 = crate::db::open(&db_path).with_context(|| {
483 format!("rules.sign-seed: open db at {}", db_path.display())
484 })?;
485 sign_seed_rules(&conn2, resolved_key.as_deref(), json, out)?;
486 } else {
487 sign_seed_rules(&conn, resolved_key.as_deref(), json, out)?;
488 }
489 Ok(())
490 }
491 }
492}
493
494fn resolve_keygen_out_path(
513 explicit_out: Option<&Path>,
514 key_dir: &Path,
515 key_dir_overridden: bool,
516) -> Result<PathBuf> {
517 if let Some(p) = explicit_out {
518 return Ok(p.to_path_buf());
519 }
520 if key_dir_overridden {
521 return Ok(key_dir.join(OPERATOR_KEY_FILENAME));
522 }
523 resolve_operator_key_path(None)
524}
525
526fn resolve_operator_key_path(override_path: Option<&Path>) -> Result<PathBuf> {
533 if let Some(p) = override_path {
534 return Ok(p.to_path_buf());
535 }
536 let base = dirs::config_dir()
537 .ok_or_else(|| anyhow::anyhow!("rules.keygen: OS did not advertise a config directory"))?;
538 Ok(base.join("ai-memory").join(OPERATOR_KEY_FILENAME))
539}
540
541fn keygen_operator(path: &Path, force: bool, out: &mut CliOutput<'_>) -> Result<String> {
562 let pub_path = pub_sibling_path(path);
563
564 if !force && (path.exists() || pub_path.exists()) {
565 bail!(
566 "rules.keygen: refusing to overwrite existing key material at {} (or {}). \
567 Pass --force to replace — note that all prior operator-signed rules \
568 will fail signature verification with the new key.",
569 path.display(),
570 pub_path.display()
571 );
572 }
573 if force && (path.exists() || pub_path.exists()) {
574 writeln!(
575 out.stderr,
576 "WARNING: rules.keygen --force replaces existing operator key. \
577 All prior operator-signed rules become INVALID and will be skipped at \
578 load time until re-signed with the new key."
579 )?;
580 }
581
582 if let Some(parent) = path.parent()
583 && !parent.as_os_str().is_empty()
584 {
585 std::fs::create_dir_all(parent)
586 .with_context(|| format!("rules.keygen: create parent dir {}", parent.display()))?;
587 }
588
589 let mut csprng = rand_core::OsRng;
592 let signing = SigningKey::generate(&mut csprng);
593 let verifying = signing.verifying_key();
594 let seed = signing.to_bytes();
595 let pub_bytes = verifying.to_bytes();
596
597 write_operator_private_seed(path, &seed, out)?;
600 write_operator_public_key(&pub_path, &pub_bytes)?;
602
603 let fingerprint = pub_fingerprint(&pub_bytes);
607
608 writeln!(
610 out.stdout,
611 "Ed25519 operator key generated: {fingerprint} -> {}",
612 path.display()
613 )?;
614
615 Ok(fingerprint)
620}
621
622fn write_operator_private_seed(
628 path: &Path,
629 seed: &[u8; ED25519_SEED_LEN],
630 #[cfg_attr(unix, allow(unused_variables))] out: &mut CliOutput<'_>,
631) -> Result<()> {
632 #[cfg(unix)]
633 {
634 use std::io::Write;
635 use std::os::unix::fs::OpenOptionsExt;
636 use std::os::unix::fs::PermissionsExt;
637
638 let _ = std::fs::remove_file(path);
641 let mut file = std::fs::OpenOptions::new()
642 .write(true)
643 .create_new(true)
644 .mode(0o600)
645 .open(path)
646 .with_context(|| format!("rules.keygen: create {}", path.display()))?;
647 file.write_all(seed)
648 .with_context(|| format!("rules.keygen: write seed to {}", path.display()))?;
649 file.sync_all()
650 .with_context(|| format!("rules.keygen: fsync {}", path.display()))?;
651 drop(file);
652
653 let mode = std::fs::metadata(path)
656 .with_context(|| format!("rules.keygen: stat {}", path.display()))?
657 .permissions()
658 .mode()
659 & 0o777;
660 if mode != 0o600 {
661 let mut perms = std::fs::metadata(path)?.permissions();
663 perms.set_mode(0o600);
664 std::fs::set_permissions(path, perms)
665 .with_context(|| format!("rules.keygen: chmod 0600 {}", path.display()))?;
666 let verified = std::fs::metadata(path)?.permissions().mode() & 0o777;
667 if verified != 0o600 {
668 bail!(
669 "rules.keygen: could not enforce mode 0600 on {} (observed {verified:o})",
670 path.display()
671 );
672 }
673 }
674 Ok(())
675 }
676 #[cfg(not(unix))]
677 {
678 writeln!(
679 out.stderr,
680 "WARNING: Windows: operator key permissions not enforced; protect manually"
681 )?;
682 std::fs::write(path, seed)
683 .with_context(|| format!("rules.keygen: write seed to {}", path.display()))?;
684 Ok(())
685 }
686}
687
688fn write_operator_public_key(pub_path: &Path, pub_bytes: &[u8; ED25519_PUBLIC_LEN]) -> Result<()> {
692 use base64::Engine;
693 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
694 #[cfg(unix)]
695 {
696 use std::io::Write;
697 use std::os::unix::fs::OpenOptionsExt;
698 let _ = std::fs::remove_file(pub_path);
699 let mut file = std::fs::OpenOptions::new()
700 .write(true)
701 .create_new(true)
702 .mode(0o644)
703 .open(pub_path)
704 .with_context(|| format!("rules.keygen: create {}", pub_path.display()))?;
705 file.write_all(encoded.as_bytes())
706 .with_context(|| format!("rules.keygen: write pub to {}", pub_path.display()))?;
707 file.sync_all()
708 .with_context(|| format!("rules.keygen: fsync {}", pub_path.display()))?;
709 }
710 #[cfg(not(unix))]
711 {
712 std::fs::write(pub_path, encoded.as_bytes())
713 .with_context(|| format!("rules.keygen: write pub to {}", pub_path.display()))?;
714 }
715 Ok(())
716}
717
718fn pub_fingerprint(pub_bytes: &[u8; ED25519_PUBLIC_LEN]) -> String {
725 use sha2::{Digest, Sha256};
726 let mut hasher = Sha256::new();
727 hasher.update(pub_bytes);
728 let digest = hasher.finalize();
729 let mut out = String::with_capacity(16);
730 for byte in digest.iter().take(8) {
731 out.push_str(&format!("{byte:02x}"));
732 }
733 out
734}
735
736fn pub_sibling_path(seed_path: &Path) -> PathBuf {
739 let mut s = seed_path.as_os_str().to_os_string();
740 s.push(".pub");
741 PathBuf::from(s)
742}
743
744pub fn load_operator_signing_key(path: &Path) -> Result<SigningKey> {
758 #[cfg(unix)]
759 {
760 use std::os::unix::fs::PermissionsExt;
761 let meta = std::fs::metadata(path)
762 .with_context(|| format!("load_operator_signing_key: stat {}", path.display()))?;
763 let mode = meta.permissions().mode() & 0o777;
764 if mode != 0o600 {
765 bail!(
766 "load_operator_signing_key: {} has mode {mode:o}; permissions too open; \
767 chmod 0600 {} to restore",
768 path.display(),
769 path.display()
770 );
771 }
772 }
773 let bytes = std::fs::read(path)
774 .with_context(|| format!("load_operator_signing_key: read {}", path.display()))?;
775 if bytes.len() != ED25519_SEED_LEN {
776 bail!(
777 "load_operator_signing_key: {} has {} bytes, expected {ED25519_SEED_LEN}",
778 path.display(),
779 bytes.len()
780 );
781 }
782 let mut seed = [0u8; ED25519_SEED_LEN];
783 seed.copy_from_slice(&bytes);
784 Ok(SigningKey::from_bytes(&seed))
785}
786
787fn sign_seed_rules(
803 conn: &rusqlite::Connection,
804 key_path: Option<&Path>,
805 json: bool,
806 out: &mut CliOutput<'_>,
807) -> Result<usize> {
808 let resolved = match key_path {
809 Some(p) => p.to_path_buf(),
810 None => resolve_operator_key_path(None)?,
811 };
812 let signing_key = load_operator_signing_key(&resolved).with_context(|| {
813 format!(
814 "rules.sign-seed: load operator key from {}",
815 resolved.display()
816 )
817 })?;
818
819 let rules = rules_store::list(conn)?;
820 let mut signed_now = 0usize;
821 let mut summary: Vec<serde_json::Value> = Vec::new();
822 for rule in rules {
823 let canonical = rules_store::canonical_bytes_for_signing(&rule)?;
824 let signature = signing_key.sign(&canonical);
825 let sig_bytes = signature.to_bytes();
826 let already_signed = matches!(
827 (rule.signature.as_deref(), rule.attest_level.as_str()),
828 (Some(existing), OPERATOR_SIGNED_LEVEL) if existing == sig_bytes.as_slice()
829 );
830 if !already_signed {
831 rules_store::update_signature(
832 conn,
833 &rule.id,
834 sig_bytes.as_slice(),
835 OPERATOR_SIGNED_LEVEL,
836 )?;
837 signed_now += 1;
838 }
839 summary.push(serde_json::json!({
840 "id": rule.id,
841 (field_names::ATTEST_LEVEL): OPERATOR_SIGNED_LEVEL,
842 "signed_now": !already_signed,
843 }));
844 }
845
846 let payload = serde_json::json!({
847 "signed_now": signed_now,
848 "rules": summary,
849 });
850 emit_ok(json, out, "rules.sign-seed", &payload)?;
851 Ok(signed_now)
852}
853
854fn resolve_key_dir(override_dir: Option<&std::path::Path>) -> Result<PathBuf> {
857 if let Some(p) = override_dir {
858 return Ok(p.to_path_buf());
859 }
860 kp::default_key_dir()
861}
862
863fn load_operator_signing_key_from_dir(
886 key_dir: &std::path::Path,
887) -> Result<ed25519_dalek::SigningKey> {
888 let priv_legacy = key_dir.join("operator.priv");
893 let pub_legacy = key_dir.join("operator.pub");
894 if priv_legacy.exists() && pub_legacy.exists() {
895 let kp = kp::load(OPERATOR_KEY_ID, key_dir).with_context(|| {
896 format!(
897 "governance.no_operator_key: failed loading operator.priv/operator.pub at {}",
898 key_dir.display()
899 )
900 })?;
901 return kp.private.ok_or_else(|| {
902 anyhow::anyhow!(
903 "governance.no_operator_key: operator keypair has no private half (public-only load)"
904 )
905 });
906 }
907 let priv_keygen = key_dir.join(OPERATOR_KEY_FILENAME);
913 let pub_keygen = key_dir.join("operator.key.pub");
914 if priv_keygen.exists() {
915 let signing = load_operator_signing_key(&priv_keygen).with_context(|| {
916 format!(
917 "governance.no_operator_key: failed loading {}",
918 priv_keygen.display()
919 )
920 })?;
921 if pub_keygen.exists() {
922 use base64::Engine;
923 let encoded = std::fs::read_to_string(&pub_keygen).with_context(|| {
924 format!("governance.no_operator_key: read {}", pub_keygen.display())
925 })?;
926 let trimmed = encoded.trim();
927 let pub_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
928 .decode(trimmed)
929 .with_context(|| {
930 format!(
931 "governance.no_operator_key: decode base64url public key at {}",
932 pub_keygen.display()
933 )
934 })?;
935 if pub_bytes.len() != ED25519_PUBLIC_LEN {
936 bail!(
937 "governance.no_operator_key: public key {} decoded to {} bytes (expected {ED25519_PUBLIC_LEN})",
938 pub_keygen.display(),
939 pub_bytes.len(),
940 );
941 }
942 if signing.verifying_key().to_bytes().as_slice() != pub_bytes.as_slice() {
943 bail!(
944 "governance.no_operator_key: private key {} does not match public key {}",
945 priv_keygen.display(),
946 pub_keygen.display(),
947 );
948 }
949 }
950 return Ok(signing);
951 }
952 if let Some(parent) = key_dir.parent() {
966 let parent_priv = parent.join(OPERATOR_KEY_FILENAME);
967 let parent_pub = parent.join("operator.key.pub");
968 if parent_priv.exists() {
969 let signing = load_operator_signing_key(&parent_priv).with_context(|| {
970 format!(
971 "governance.no_operator_key: failed loading {}",
972 parent_priv.display()
973 )
974 })?;
975 if parent_pub.exists() {
976 use base64::Engine;
977 let encoded = std::fs::read_to_string(&parent_pub).with_context(|| {
978 format!("governance.no_operator_key: read {}", parent_pub.display())
979 })?;
980 let trimmed = encoded.trim();
981 let pub_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
982 .decode(trimmed)
983 .with_context(|| {
984 format!(
985 "governance.no_operator_key: decode base64url public key at {}",
986 parent_pub.display()
987 )
988 })?;
989 if pub_bytes.len() != ED25519_PUBLIC_LEN {
990 bail!(
991 "governance.no_operator_key: public key {} decoded to {} bytes (expected {ED25519_PUBLIC_LEN})",
992 parent_pub.display(),
993 pub_bytes.len(),
994 );
995 }
996 if signing.verifying_key().to_bytes().as_slice() != pub_bytes.as_slice() {
997 bail!(
998 "governance.no_operator_key: private key {} does not match public key {}",
999 parent_priv.display(),
1000 parent_pub.display(),
1001 );
1002 }
1003 }
1004 return Ok(signing);
1005 }
1006 }
1007
1008 bail!(
1011 "governance.no_operator_key: no operator key found at {dir} \
1012 (also checked parent dir for the keygen layout). \
1013 Expected either `operator.priv` + `operator.pub` (raw 32-byte pair, \
1014 as produced by per-agent `keypair` generation) OR \
1015 `operator.key` + `operator.key.pub` (raw 32-byte seed + base64url \
1016 verifier, as produced by `ai-memory rules keygen` — searched both \
1017 `{dir}/` and `{dir}/../`)",
1018 dir = key_dir.display(),
1019 )
1020}
1021
1022fn resolve_agent_id() -> String {
1026 crate::identity::resolve_agent_id(None, None)
1027 .unwrap_or_else(|_| format!("anonymous:pid-{}", std::process::id()))
1028}
1029
1030fn build_action(kind: &str, payload_json: &str) -> Result<AgentAction> {
1033 let payload: serde_json::Value = serde_json::from_str(payload_json)
1034 .with_context(|| format!("rules check: payload is not valid JSON: {payload_json}"))?;
1035 match kind {
1036 ak::BASH => {
1037 let command = payload
1038 .get("command")
1039 .and_then(|v| v.as_str())
1040 .ok_or_else(|| anyhow::anyhow!("bash payload requires `command` string"))?
1041 .to_string();
1042 let cwd = payload
1043 .get("cwd")
1044 .and_then(|v| v.as_str())
1045 .map(PathBuf::from);
1046 Ok(AgentAction::Bash { command, cwd })
1047 }
1048 ak::FILESYSTEM_WRITE => {
1049 let path = payload
1050 .get("path")
1051 .and_then(|v| v.as_str())
1052 .ok_or_else(|| anyhow::anyhow!("filesystem_write payload requires `path` string"))?
1053 .to_string();
1054 let byte_estimate = payload
1055 .get("byte_estimate")
1056 .and_then(serde_json::Value::as_u64);
1057 Ok(AgentAction::FilesystemWrite {
1058 path: PathBuf::from(path),
1059 byte_estimate,
1060 })
1061 }
1062 ak::NETWORK_REQUEST => {
1063 let host = payload
1064 .get("host")
1065 .and_then(|v| v.as_str())
1066 .ok_or_else(|| anyhow::anyhow!("network_request payload requires `host` string"))?
1067 .to_string();
1068 let scheme = payload
1069 .get("scheme")
1070 .and_then(|v| v.as_str())
1071 .unwrap_or("https")
1072 .to_string();
1073 Ok(AgentAction::NetworkRequest { host, scheme })
1074 }
1075 ak::PROCESS_SPAWN => {
1076 let binary = payload
1077 .get("binary")
1078 .and_then(|v| v.as_str())
1079 .ok_or_else(|| anyhow::anyhow!("process_spawn payload requires `binary` string"))?
1080 .to_string();
1081 let args = payload
1082 .get("args")
1083 .and_then(|v| v.as_array())
1084 .map(|arr| {
1085 arr.iter()
1086 .filter_map(|v| v.as_str().map(String::from))
1087 .collect()
1088 })
1089 .unwrap_or_default();
1090 Ok(AgentAction::ProcessSpawn { binary, args })
1091 }
1092 "custom" => {
1093 let custom_kind = payload
1094 .get(field_names::CUSTOM_KIND)
1095 .or_else(|| payload.get("kind"))
1096 .and_then(|v| v.as_str())
1097 .ok_or_else(|| anyhow::anyhow!("custom payload requires `custom_kind` string"))?
1098 .to_string();
1099 Ok(AgentAction::Custom {
1100 custom_kind,
1101 payload,
1102 })
1103 }
1104 other => bail!("rules check: unknown kind `{other}`"),
1105 }
1106}
1107
1108fn rule_to_json(rule: &Rule) -> serde_json::Value {
1112 use base64::Engine;
1113 let sig_b64 = rule
1114 .signature
1115 .as_ref()
1116 .map(|b| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b));
1117 serde_json::json!({
1118 "id": rule.id,
1119 "kind": rule.kind,
1120 "matcher": rule.matcher,
1121 "severity": rule.severity,
1122 "reason": rule.reason,
1123 "namespace": rule.namespace,
1124 (field_names::CREATED_BY): rule.created_by,
1125 (field_names::CREATED_AT): rule.created_at,
1126 "enabled": rule.enabled,
1127 "signature_b64": sig_b64,
1128 (field_names::ATTEST_LEVEL): rule.attest_level,
1129 })
1130}
1131
1132fn emit_ok(
1133 json: bool,
1134 out: &mut CliOutput<'_>,
1135 verb: &str,
1136 result: &serde_json::Value,
1137) -> Result<()> {
1138 if json {
1139 let env = CliEnvelope {
1140 verb,
1141 result: result.clone(),
1142 };
1143 writeln!(out.stdout, "{}", serde_json::to_string(&env)?)?;
1144 } else {
1145 writeln!(out.stdout, "{}", serde_json::to_string_pretty(result)?)?;
1149 }
1150 Ok(())
1151}
1152
1153#[cfg(test)]
1158mod tests {
1159 use super::*;
1160
1161 #[must_use = "the guard must be held for the scope of the test"]
1170 fn forensic_lock() -> std::sync::MutexGuard<'static, ()> {
1171 crate::governance::audit::forensic_sink_test_lock()
1172 .lock()
1173 .unwrap_or_else(|e| e.into_inner())
1174 }
1175
1176 #[test]
1177 fn build_action_bash_parses() {
1178 let a = build_action("bash", r#"{"command":"ls -la"}"#).unwrap();
1179 match a {
1180 AgentAction::Bash { command, cwd } => {
1181 assert_eq!(command, "ls -la");
1182 assert!(cwd.is_none());
1183 }
1184 _ => panic!("expected bash"),
1185 }
1186 }
1187
1188 #[test]
1189 fn build_action_filesystem_write_parses() {
1190 let a = build_action("filesystem_write", r#"{"path":"/tmp/x"}"#).unwrap();
1191 match a {
1192 AgentAction::FilesystemWrite { path, .. } => {
1193 assert_eq!(path, PathBuf::from("/tmp/x"));
1194 }
1195 _ => panic!("expected filesystem_write"),
1196 }
1197 }
1198
1199 #[test]
1200 fn build_action_network_request_parses_with_scheme_default() {
1201 let a = build_action("network_request", r#"{"host":"x.example.com"}"#).unwrap();
1202 match a {
1203 AgentAction::NetworkRequest { host, scheme } => {
1204 assert_eq!(host, "x.example.com");
1205 assert_eq!(scheme, "https");
1206 }
1207 _ => panic!("expected network_request"),
1208 }
1209 }
1210
1211 #[test]
1212 fn build_action_process_spawn_parses() {
1213 let a = build_action(
1214 "process_spawn",
1215 r#"{"binary":"cargo","args":["build","--release"]}"#,
1216 )
1217 .unwrap();
1218 match a {
1219 AgentAction::ProcessSpawn { binary, args } => {
1220 assert_eq!(binary, "cargo");
1221 assert_eq!(args, vec!["build", "--release"]);
1222 }
1223 _ => panic!("expected process_spawn"),
1224 }
1225 }
1226
1227 #[test]
1228 fn build_action_custom_parses() {
1229 let a = build_action("custom", r#"{"custom_kind":"deploy","env":"prod"}"#).unwrap();
1230 match a {
1231 AgentAction::Custom { custom_kind, .. } => assert_eq!(custom_kind, "deploy"),
1232 _ => panic!("expected custom"),
1233 }
1234 }
1235
1236 #[test]
1237 fn build_action_unknown_kind_errors() {
1238 assert!(build_action("nope", "{}").is_err());
1239 }
1240
1241 #[test]
1242 fn build_action_invalid_json_errors() {
1243 assert!(build_action("bash", "not json").is_err());
1244 }
1245
1246 #[test]
1247 fn build_action_missing_required_field_errors() {
1248 assert!(build_action("bash", "{}").is_err());
1249 assert!(build_action("filesystem_write", "{}").is_err());
1250 }
1251
1252 #[test]
1253 fn rule_to_json_encodes_signature_as_base64() {
1254 let mut rule = Rule {
1255 id: "R1".into(),
1256 kind: "bash".into(),
1257 matcher: r#"{"command_regex":"x"}"#.into(),
1258 severity: "refuse".into(),
1259 reason: "test".into(),
1260 namespace: "_global".into(),
1261 created_by: "test".into(),
1262 created_at: 0,
1263 enabled: true,
1264 signature: None,
1265 attest_level: "unsigned".into(),
1266 };
1267 let v = rule_to_json(&rule);
1268 assert_eq!(v["signature_b64"], serde_json::Value::Null);
1269 rule.signature = Some(vec![0xff, 0x00, 0xaa]);
1270 let v = rule_to_json(&rule);
1271 assert_eq!(
1272 v["signature_b64"],
1273 serde_json::Value::String("_wCq".to_string())
1274 );
1275 }
1276
1277 #[test]
1282 fn pub_sibling_path_appends_dot_pub() {
1283 let p = pub_sibling_path(Path::new("/x/y/operator.key"));
1284 assert_eq!(p, PathBuf::from("/x/y/operator.key.pub"));
1285 }
1286
1287 #[test]
1288 fn pub_fingerprint_is_deterministic_and_16_hex_chars() {
1289 let bytes = [0u8; 32];
1290 let fp1 = pub_fingerprint(&bytes);
1291 let fp2 = pub_fingerprint(&bytes);
1292 assert_eq!(fp1, fp2, "fingerprint must be deterministic");
1293 assert_eq!(fp1.len(), 16, "fingerprint must be 16 hex chars");
1294 assert!(
1295 fp1.chars().all(|c| c.is_ascii_hexdigit()),
1296 "fingerprint must be ASCII hex"
1297 );
1298 let mut other = [0u8; 32];
1300 other[0] = 1;
1301 let fp3 = pub_fingerprint(&other);
1302 assert_ne!(fp1, fp3);
1303 }
1304
1305 #[cfg(unix)]
1306 #[test]
1307 fn keygen_writes_priv_0600_and_pub_0644_then_loads() {
1308 use std::os::unix::fs::PermissionsExt;
1309
1310 let dir = tempfile::tempdir().unwrap();
1311 let key_path = dir.path().join("operator.key");
1312 let mut stdout: Vec<u8> = Vec::new();
1313 let mut stderr: Vec<u8> = Vec::new();
1314 let mut out = CliOutput {
1315 stdout: &mut stdout,
1316 stderr: &mut stderr,
1317 };
1318 let fp = keygen_operator(&key_path, false, &mut out).expect("keygen");
1319 assert_eq!(fp.len(), 16);
1320
1321 let meta = std::fs::metadata(&key_path).unwrap();
1323 let mode = meta.permissions().mode() & 0o777;
1324 assert_eq!(mode, 0o600, "priv key must be 0600, got {mode:o}");
1325 let bytes = std::fs::read(&key_path).unwrap();
1326 assert_eq!(bytes.len(), 32, "priv seed must be 32 bytes");
1327
1328 let pub_path = pub_sibling_path(&key_path);
1330 let pmode = std::fs::metadata(&pub_path).unwrap().permissions().mode() & 0o777;
1331 assert_eq!(pmode, 0o644, "pub key must be 0644, got {pmode:o}");
1332 let pub_b64 = std::fs::read_to_string(&pub_path).unwrap();
1333 use base64::Engine;
1334 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
1335 .decode(pub_b64.trim())
1336 .expect("pub base64 decodes");
1337 assert_eq!(decoded.len(), 32);
1338
1339 let signing = load_operator_signing_key(&key_path).expect("load");
1342 let verifying = signing.verifying_key();
1343 assert_eq!(verifying.to_bytes()[..], decoded[..]);
1344
1345 let s = String::from_utf8(stdout).unwrap();
1347 assert!(s.contains(&fp), "stdout must include fingerprint, got: {s}");
1348 assert!(s.starts_with("Ed25519 operator key generated:"));
1352 }
1353
1354 #[cfg(unix)]
1355 #[test]
1356 fn keygen_refuses_overwrite_without_force() {
1357 let dir = tempfile::tempdir().unwrap();
1358 let key_path = dir.path().join("operator.key");
1359 let mut stdout: Vec<u8> = Vec::new();
1360 let mut stderr: Vec<u8> = Vec::new();
1361 let mut out = CliOutput {
1362 stdout: &mut stdout,
1363 stderr: &mut stderr,
1364 };
1365 keygen_operator(&key_path, false, &mut out).expect("first");
1366 let bytes_before = std::fs::read(&key_path).unwrap();
1367
1368 let err = keygen_operator(&key_path, false, &mut out).unwrap_err();
1370 let msg = format!("{err:#}");
1371 assert!(msg.contains("refusing to overwrite"), "got: {msg}");
1372
1373 let bytes_after = std::fs::read(&key_path).unwrap();
1375 assert_eq!(bytes_before, bytes_after);
1376 }
1377
1378 #[cfg(unix)]
1379 #[test]
1380 fn keygen_force_overwrites_and_warns_on_stderr() {
1381 let dir = tempfile::tempdir().unwrap();
1382 let key_path = dir.path().join("operator.key");
1383 let mut stdout: Vec<u8> = Vec::new();
1384 let mut stderr: Vec<u8> = Vec::new();
1385 let mut out = CliOutput {
1386 stdout: &mut stdout,
1387 stderr: &mut stderr,
1388 };
1389 let fp1 = keygen_operator(&key_path, false, &mut out).expect("first");
1390 let fp2 = keygen_operator(&key_path, true, &mut out).expect("force");
1391 assert_ne!(fp1, fp2, "fresh keypair must have new fingerprint");
1392
1393 let s = String::from_utf8(stderr).unwrap();
1394 assert!(
1395 s.contains("WARNING") && s.contains("INVALID"),
1396 "stderr must warn about prior-signature invalidation, got: {s}"
1397 );
1398 }
1399
1400 #[cfg(unix)]
1401 #[test]
1402 fn load_operator_signing_key_refuses_open_permissions() {
1403 use std::os::unix::fs::PermissionsExt;
1404
1405 let dir = tempfile::tempdir().unwrap();
1406 let key_path = dir.path().join("operator.key");
1407 let mut stdout: Vec<u8> = Vec::new();
1408 let mut stderr: Vec<u8> = Vec::new();
1409 let mut out = CliOutput {
1410 stdout: &mut stdout,
1411 stderr: &mut stderr,
1412 };
1413 keygen_operator(&key_path, false, &mut out).expect("keygen");
1414 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644)).unwrap();
1416 let err = load_operator_signing_key(&key_path).unwrap_err();
1417 let msg = format!("{err:#}");
1418 assert!(msg.contains("0600"), "error must mention 0600, got: {msg}");
1419 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
1421 }
1422
1423 #[test]
1424 fn load_operator_signing_key_rejects_wrong_length() {
1425 let dir = tempfile::tempdir().unwrap();
1426 let key_path = dir.path().join("operator.key");
1427 std::fs::write(&key_path, b"too-short").unwrap();
1431 #[cfg(unix)]
1432 {
1433 use std::os::unix::fs::PermissionsExt;
1434 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
1435 }
1436 let err = load_operator_signing_key(&key_path).unwrap_err();
1437 let msg = format!("{err:#}");
1438 assert!(
1441 msg.contains("expected") || msg.contains("bytes"),
1442 "got: {msg}"
1443 );
1444 }
1445
1446 fn fresh_rules_conn() -> rusqlite::Connection {
1455 let conn = rusqlite::Connection::open_in_memory().unwrap();
1456 conn.execute_batch(
1457 "CREATE TABLE governance_rules (
1458 id TEXT PRIMARY KEY,
1459 kind TEXT NOT NULL,
1460 matcher TEXT NOT NULL,
1461 severity TEXT NOT NULL CHECK (severity IN ('refuse','warn','log')),
1462 reason TEXT NOT NULL,
1463 namespace TEXT NOT NULL DEFAULT '_global',
1464 created_by TEXT NOT NULL,
1465 created_at INTEGER NOT NULL,
1466 enabled INTEGER NOT NULL DEFAULT 1,
1467 signature BLOB,
1468 attest_level TEXT NOT NULL DEFAULT 'unsigned'
1469 );",
1470 )
1471 .unwrap();
1472 conn
1473 }
1474
1475 #[cfg(unix)]
1476 #[test]
1477 fn sign_seed_rules_marks_all_rows_operator_signed() {
1478 let tdir = tempfile::tempdir().unwrap();
1479 let key_path = tdir.path().join("operator.key");
1480 let mut stdout: Vec<u8> = Vec::new();
1481 let mut stderr: Vec<u8> = Vec::new();
1482 let mut out = CliOutput {
1483 stdout: &mut stdout,
1484 stderr: &mut stderr,
1485 };
1486 keygen_operator(&key_path, false, &mut out).unwrap();
1487
1488 let conn = fresh_rules_conn();
1489 for id in ["R001", "R002"] {
1492 rules_store::insert(
1493 &conn,
1494 &Rule {
1495 id: id.to_string(),
1496 kind: "filesystem_write".into(),
1497 matcher: r#"{"glob":"/tmp/**"}"#.into(),
1498 severity: "refuse".into(),
1499 reason: "test".into(),
1500 namespace: "_global".into(),
1501 created_by: "system:seed".into(),
1502 created_at: 0,
1503 enabled: false,
1504 signature: None,
1505 attest_level: "unsigned".into(),
1506 },
1507 )
1508 .unwrap();
1509 }
1510
1511 let signed = sign_seed_rules(&conn, Some(&key_path), true, &mut out).unwrap();
1512 assert_eq!(signed, 2);
1513
1514 for id in ["R001", "R002"] {
1517 let row = rules_store::get(&conn, id).unwrap().unwrap();
1518 assert_eq!(row.attest_level, "operator_signed");
1519 assert_eq!(
1520 row.signature.as_ref().map(Vec::len),
1521 Some(ed25519_dalek::SIGNATURE_LENGTH)
1522 );
1523 assert!(!row.enabled, "sign-seed must NOT flip enabled");
1524 }
1525 }
1526
1527 #[cfg(unix)]
1538 fn fresh_env_with_operator_key() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf)
1539 {
1540 let dir = tempfile::tempdir().expect("tempdir");
1541 let db_path = dir.path().join("ai-memory.db");
1542 drop(crate::db::open(&db_path).expect("db::open"));
1544 let kp = kp::generate(OPERATOR_KEY_ID).expect("generate");
1546 let key_dir = dir.path().join("keys");
1547 std::fs::create_dir_all(&key_dir).expect("mkdir keys");
1548 kp::save(&kp, &key_dir).expect("save kp");
1549 (dir, db_path, key_dir)
1550 }
1551
1552 #[cfg(unix)]
1553 #[test]
1554 fn run_rules_list_emits_seeded_rules() {
1555 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1560 let args = RulesArgs {
1561 key_dir: Some(key_dir),
1562 action: RulesAction::List,
1563 };
1564 let mut stdout: Vec<u8> = Vec::new();
1565 let mut stderr: Vec<u8> = Vec::new();
1566 let mut out = CliOutput {
1567 stdout: &mut stdout,
1568 stderr: &mut stderr,
1569 };
1570 run(&db_path, args, true, &mut out).expect("list");
1571 let s = String::from_utf8(stdout).unwrap();
1572 assert!(s.contains("\"verb\":\"rules.list\""), "got: {s}");
1574 assert!(s.contains("\"result\":["), "got: {s}");
1576 }
1577
1578 #[cfg(unix)]
1579 #[test]
1580 fn run_rules_list_human_format_emits_pretty_array() {
1581 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1582 let args = RulesArgs {
1583 key_dir: Some(key_dir),
1584 action: RulesAction::List,
1585 };
1586 let mut stdout: Vec<u8> = Vec::new();
1587 let mut stderr: Vec<u8> = Vec::new();
1588 let mut out = CliOutput {
1589 stdout: &mut stdout,
1590 stderr: &mut stderr,
1591 };
1592 run(&db_path, args, false, &mut out).expect("list");
1594 let s = String::from_utf8(stdout).unwrap();
1595 assert!(s.contains("["), "got: {s}");
1596 }
1597
1598 #[cfg(unix)]
1599 #[test]
1600 fn run_rules_add_without_sign_refuses() {
1601 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1602 let args = RulesArgs {
1603 key_dir: Some(key_dir),
1604 action: RulesAction::Add {
1605 id: "R-test".into(),
1606 kind: "bash".into(),
1607 matcher: r#"{"command_regex":"^ls"}"#.into(),
1608 severity: "refuse".into(),
1609 reason: "test".into(),
1610 namespace: "_global".into(),
1611 disabled: false,
1612 sign: false,
1613 },
1614 };
1615 let mut stdout: Vec<u8> = Vec::new();
1616 let mut stderr: Vec<u8> = Vec::new();
1617 let mut out = CliOutput {
1618 stdout: &mut stdout,
1619 stderr: &mut stderr,
1620 };
1621 let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
1622 let msg = format!("{err:#}");
1623 assert!(msg.contains("no_operator_key"), "got: {msg}");
1624 }
1625
1626 #[cfg(unix)]
1627 #[test]
1628 fn run_rules_add_with_sign_persists_signed_rule() {
1629 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1630 let args = RulesArgs {
1631 key_dir: Some(key_dir.clone()),
1632 action: RulesAction::Add {
1633 id: "R-add-1".into(),
1634 kind: "bash".into(),
1635 matcher: r#"{"command_substring":"rm -rf /"}"#.into(),
1638 severity: "refuse".into(),
1639 reason: "rm-rf is bad".into(),
1640 namespace: "_global".into(),
1641 disabled: false,
1642 sign: true,
1643 },
1644 };
1645 let mut stdout: Vec<u8> = Vec::new();
1646 let mut stderr: Vec<u8> = Vec::new();
1647 let mut out = CliOutput {
1648 stdout: &mut stdout,
1649 stderr: &mut stderr,
1650 };
1651 run(&db_path, args, true, &mut out).expect("add");
1652 let s = String::from_utf8(stdout).unwrap();
1653 assert!(s.contains("rules.add"), "got: {s}");
1654 assert!(s.contains("R-add-1"), "got: {s}");
1655 assert!(s.contains("operator_signed"), "got: {s}");
1656
1657 let conn = rusqlite::Connection::open(&db_path).unwrap();
1659 let r = rules_store::get(&conn, "R-add-1").unwrap().unwrap();
1660 assert_eq!(r.attest_level, "operator_signed");
1661 assert!(r.signature.is_some());
1662 }
1663
1664 #[cfg(unix)]
1665 #[test]
1666 fn run_rules_add_with_bad_matcher_json_errors() {
1667 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1668 let args = RulesArgs {
1669 key_dir: Some(key_dir),
1670 action: RulesAction::Add {
1671 id: "R-bad".into(),
1672 kind: "bash".into(),
1673 matcher: "{ not json".into(), severity: "refuse".into(),
1675 reason: "x".into(),
1676 namespace: "_global".into(),
1677 disabled: false,
1678 sign: true,
1679 },
1680 };
1681 let mut stdout: Vec<u8> = Vec::new();
1682 let mut stderr: Vec<u8> = Vec::new();
1683 let mut out = CliOutput {
1684 stdout: &mut stdout,
1685 stderr: &mut stderr,
1686 };
1687 let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
1688 let msg = format!("{err:#}");
1689 assert!(msg.contains("matcher"), "got: {msg}");
1690 }
1691
1692 #[cfg(unix)]
1693 #[test]
1694 fn run_rules_add_disabled_lands_disabled_row() {
1695 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1696 let args = RulesArgs {
1697 key_dir: Some(key_dir),
1698 action: RulesAction::Add {
1699 id: "R-dis".into(),
1700 kind: "filesystem_write".into(),
1701 matcher: r#"{"glob":"/tmp/**"}"#.into(),
1702 severity: "warn".into(),
1703 reason: "noisy".into(),
1704 namespace: "_global".into(),
1705 disabled: true,
1706 sign: true,
1707 },
1708 };
1709 let mut stdout: Vec<u8> = Vec::new();
1710 let mut stderr: Vec<u8> = Vec::new();
1711 let mut out = CliOutput {
1712 stdout: &mut stdout,
1713 stderr: &mut stderr,
1714 };
1715 run(&db_path, args, false, &mut out).expect("add");
1716 let conn = rusqlite::Connection::open(&db_path).unwrap();
1717 let r = rules_store::get(&conn, "R-dis").unwrap().unwrap();
1718 assert!(!r.enabled, "disabled flag must propagate");
1719 }
1720
1721 #[cfg(unix)]
1722 #[test]
1723 fn run_rules_check_evaluates_action_against_empty_set() {
1724 let _forensic = forensic_lock();
1725 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1726 let args = RulesArgs {
1727 key_dir: Some(key_dir),
1728 action: RulesAction::Check {
1729 kind: "bash".into(),
1730 payload: r#"{"command":"ls"}"#.into(),
1731 agent_id: Some("tester".into()),
1732 },
1733 };
1734 let mut stdout: Vec<u8> = Vec::new();
1735 let mut stderr: Vec<u8> = Vec::new();
1736 let mut out = CliOutput {
1737 stdout: &mut stdout,
1738 stderr: &mut stderr,
1739 };
1740 run(&db_path, args, true, &mut out).expect("check");
1741 let s = String::from_utf8(stdout).unwrap();
1742 assert!(s.contains("rules.check"), "got: {s}");
1744 }
1745
1746 #[cfg(unix)]
1747 #[test]
1748 fn run_rules_check_without_agent_id_uses_default() {
1749 let _forensic = forensic_lock();
1750 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1751 let args = RulesArgs {
1752 key_dir: Some(key_dir),
1753 action: RulesAction::Check {
1754 kind: "network_request".into(),
1755 payload: r#"{"host":"example.com","scheme":"https"}"#.into(),
1756 agent_id: None,
1757 },
1758 };
1759 let mut stdout: Vec<u8> = Vec::new();
1760 let mut stderr: Vec<u8> = Vec::new();
1761 let mut out = CliOutput {
1762 stdout: &mut stdout,
1763 stderr: &mut stderr,
1764 };
1765 run(&db_path, args, false, &mut out).expect("check");
1766 }
1767
1768 #[cfg(unix)]
1769 #[test]
1770 fn run_rules_enable_unsign_refuses() {
1771 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1772 let args = RulesArgs {
1773 key_dir: Some(key_dir),
1774 action: RulesAction::Enable {
1775 id: "R-x".into(),
1776 sign: false,
1777 },
1778 };
1779 let mut stdout: Vec<u8> = Vec::new();
1780 let mut stderr: Vec<u8> = Vec::new();
1781 let mut out = CliOutput {
1782 stdout: &mut stdout,
1783 stderr: &mut stderr,
1784 };
1785 let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
1786 assert!(format!("{err:#}").contains("no_operator_key"));
1787 }
1788
1789 #[cfg(unix)]
1790 #[test]
1791 fn run_rules_enable_unknown_id_errors() {
1792 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1793 let args = RulesArgs {
1794 key_dir: Some(key_dir),
1795 action: RulesAction::Enable {
1796 id: "R-does-not-exist".into(),
1797 sign: true,
1798 },
1799 };
1800 let mut stdout: Vec<u8> = Vec::new();
1801 let mut stderr: Vec<u8> = Vec::new();
1802 let mut out = CliOutput {
1803 stdout: &mut stdout,
1804 stderr: &mut stderr,
1805 };
1806 let err = run(&db_path, args, false, &mut out).expect_err("must error");
1807 assert!(format!("{err:#}").contains("no rule with id"));
1808 }
1809
1810 #[cfg(unix)]
1811 #[test]
1812 fn run_rules_enable_and_disable_roundtrip() {
1813 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1814 let args = RulesArgs {
1816 key_dir: Some(key_dir.clone()),
1817 action: RulesAction::Add {
1818 id: "R-toggle".into(),
1819 kind: "bash".into(),
1820 matcher: r#"{"command_substring":"x"}"#.into(),
1822 severity: "warn".into(),
1823 reason: "toggle me".into(),
1824 namespace: "_global".into(),
1825 disabled: true,
1826 sign: true,
1827 },
1828 };
1829 let mut stdout: Vec<u8> = Vec::new();
1830 let mut stderr: Vec<u8> = Vec::new();
1831 let mut out = CliOutput {
1832 stdout: &mut stdout,
1833 stderr: &mut stderr,
1834 };
1835 run(&db_path, args, false, &mut out).expect("add");
1836
1837 let args = RulesArgs {
1839 key_dir: Some(key_dir.clone()),
1840 action: RulesAction::Enable {
1841 id: "R-toggle".into(),
1842 sign: true,
1843 },
1844 };
1845 let mut stdout = Vec::new();
1846 let mut stderr = Vec::new();
1847 let mut out = CliOutput {
1848 stdout: &mut stdout,
1849 stderr: &mut stderr,
1850 };
1851 run(&db_path, args, false, &mut out).expect("enable");
1852 let conn = rusqlite::Connection::open(&db_path).unwrap();
1853 assert!(
1854 rules_store::get(&conn, "R-toggle")
1855 .unwrap()
1856 .unwrap()
1857 .enabled
1858 );
1859 drop(conn);
1860
1861 let args = RulesArgs {
1863 key_dir: Some(key_dir.clone()),
1864 action: RulesAction::Disable {
1865 id: "R-toggle".into(),
1866 sign: true,
1867 },
1868 };
1869 let mut stdout = Vec::new();
1870 let mut stderr = Vec::new();
1871 let mut out = CliOutput {
1872 stdout: &mut stdout,
1873 stderr: &mut stderr,
1874 };
1875 run(&db_path, args, true, &mut out).expect("disable");
1876 let conn = rusqlite::Connection::open(&db_path).unwrap();
1877 assert!(
1878 !rules_store::get(&conn, "R-toggle")
1879 .unwrap()
1880 .unwrap()
1881 .enabled
1882 );
1883 }
1884
1885 #[cfg(unix)]
1886 #[test]
1887 fn run_rules_disable_unsign_refuses() {
1888 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1889 let args = RulesArgs {
1890 key_dir: Some(key_dir),
1891 action: RulesAction::Disable {
1892 id: "R-x".into(),
1893 sign: false,
1894 },
1895 };
1896 let mut stdout: Vec<u8> = Vec::new();
1897 let mut stderr: Vec<u8> = Vec::new();
1898 let mut out = CliOutput {
1899 stdout: &mut stdout,
1900 stderr: &mut stderr,
1901 };
1902 let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
1903 assert!(format!("{err:#}").contains("no_operator_key"));
1904 }
1905
1906 #[cfg(unix)]
1907 #[test]
1908 fn run_rules_disable_unknown_id_errors() {
1909 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1910 let args = RulesArgs {
1911 key_dir: Some(key_dir),
1912 action: RulesAction::Disable {
1913 id: "R-missing".into(),
1914 sign: true,
1915 },
1916 };
1917 let mut stdout: Vec<u8> = Vec::new();
1918 let mut stderr: Vec<u8> = Vec::new();
1919 let mut out = CliOutput {
1920 stdout: &mut stdout,
1921 stderr: &mut stderr,
1922 };
1923 let err = run(&db_path, args, false, &mut out).expect_err("must error");
1924 assert!(format!("{err:#}").contains("no rule with id"));
1925 }
1926
1927 #[cfg(unix)]
1928 #[test]
1929 fn run_rules_remove_unsign_refuses() {
1930 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1931 let args = RulesArgs {
1932 key_dir: Some(key_dir),
1933 action: RulesAction::Remove {
1934 id: "R-x".into(),
1935 sign: false,
1936 },
1937 };
1938 let mut stdout: Vec<u8> = Vec::new();
1939 let mut stderr: Vec<u8> = Vec::new();
1940 let mut out = CliOutput {
1941 stdout: &mut stdout,
1942 stderr: &mut stderr,
1943 };
1944 let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
1945 assert!(format!("{err:#}").contains("no_operator_key"));
1946 }
1947
1948 #[cfg(unix)]
1949 #[test]
1950 fn run_rules_remove_signed_deletes_row() {
1951 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1952 let args = RulesArgs {
1954 key_dir: Some(key_dir.clone()),
1955 action: RulesAction::Add {
1956 id: "R-rm".into(),
1957 kind: "bash".into(),
1958 matcher: r#"{"command_substring":"x"}"#.into(),
1960 severity: "warn".into(),
1961 reason: "rm me".into(),
1962 namespace: "_global".into(),
1963 disabled: false,
1964 sign: true,
1965 },
1966 };
1967 let mut stdout: Vec<u8> = Vec::new();
1968 let mut stderr: Vec<u8> = Vec::new();
1969 let mut out = CliOutput {
1970 stdout: &mut stdout,
1971 stderr: &mut stderr,
1972 };
1973 run(&db_path, args, false, &mut out).expect("add");
1974
1975 let args = RulesArgs {
1976 key_dir: Some(key_dir),
1977 action: RulesAction::Remove {
1978 id: "R-rm".into(),
1979 sign: true,
1980 },
1981 };
1982 let mut stdout = Vec::new();
1983 let mut stderr = Vec::new();
1984 let mut out = CliOutput {
1985 stdout: &mut stdout,
1986 stderr: &mut stderr,
1987 };
1988 run(&db_path, args, true, &mut out).expect("remove");
1989 let s = String::from_utf8(stdout).unwrap();
1990 assert!(s.contains("rules.remove"), "got: {s}");
1991 assert!(s.contains("\"removed\":true"), "got: {s}");
1992 let conn = rusqlite::Connection::open(&db_path).unwrap();
1993 assert!(rules_store::get(&conn, "R-rm").unwrap().is_none());
1994 }
1995
1996 #[cfg(unix)]
1997 #[test]
1998 fn run_rules_keygen_writes_keypair_under_explicit_out() {
1999 let dir = tempfile::tempdir().unwrap();
2000 let db_path = dir.path().join("ai-memory.db");
2001 drop(crate::db::open(&db_path).expect("db::open"));
2002 let key_path = dir.path().join("op.key");
2003 let args = RulesArgs {
2004 key_dir: None,
2005 action: RulesAction::Keygen {
2006 out: Some(key_path.clone()),
2007 force: false,
2008 },
2009 };
2010 let mut stdout: Vec<u8> = Vec::new();
2011 let mut stderr: Vec<u8> = Vec::new();
2012 let mut out = CliOutput {
2013 stdout: &mut stdout,
2014 stderr: &mut stderr,
2015 };
2016 run(&db_path, args, true, &mut out).expect("keygen");
2017 let s = String::from_utf8(stdout).unwrap();
2018 assert!(s.contains("rules.keygen"), "got: {s}");
2019 assert!(key_path.exists(), "priv key missing");
2020 let pub_path = pub_sibling_path(&key_path);
2021 assert!(pub_path.exists(), "pub key missing");
2022 }
2023
2024 #[cfg(unix)]
2025 #[test]
2026 fn run_rules_sign_seed_signs_existing_rules() {
2027 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
2030 let conn = rusqlite::Connection::open(&db_path).unwrap();
2033 rules_store::insert(
2034 &conn,
2035 &Rule {
2036 id: "R-ss".into(),
2037 kind: "bash".into(),
2038 matcher: r#"{"command_regex":"^x"}"#.into(),
2039 severity: "refuse".into(),
2040 reason: "t".into(),
2041 namespace: "_global".into(),
2042 created_by: "test".into(),
2043 created_at: 0,
2044 enabled: true,
2045 signature: None,
2046 attest_level: "unsigned".into(),
2047 },
2048 )
2049 .unwrap();
2050 drop(conn);
2051
2052 let dir2 = tempfile::tempdir().unwrap();
2057 let key_file = dir2.path().join("operator.key");
2058 let mut stdout: Vec<u8> = Vec::new();
2059 let mut stderr: Vec<u8> = Vec::new();
2060 let mut out = CliOutput {
2061 stdout: &mut stdout,
2062 stderr: &mut stderr,
2063 };
2064 keygen_operator(&key_file, false, &mut out).unwrap();
2065
2066 let args = RulesArgs {
2067 key_dir: Some(key_dir),
2068 action: RulesAction::SignSeed {
2069 key: Some(key_file),
2070 db: Some(db_path.clone()),
2071 },
2072 };
2073 let mut stdout: Vec<u8> = Vec::new();
2074 let mut stderr: Vec<u8> = Vec::new();
2075 let mut out = CliOutput {
2076 stdout: &mut stdout,
2077 stderr: &mut stderr,
2078 };
2079 let placeholder_db = tempfile::tempdir().unwrap();
2082 let placeholder_path = placeholder_db.path().join("placeholder.db");
2083 drop(crate::db::open(&placeholder_path).unwrap());
2084 run(&placeholder_path, args, true, &mut out).expect("sign-seed");
2085 let s = String::from_utf8(stdout).unwrap();
2086 assert!(s.contains("rules.sign-seed"), "got: {s}");
2087 }
2088
2089 #[cfg(unix)]
2090 #[test]
2091 fn run_rules_sign_seed_reuses_open_conn_when_no_db_override() {
2092 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
2095 let dir2 = tempfile::tempdir().unwrap();
2096 let key_file = dir2.path().join("operator.key");
2097 let mut stdout: Vec<u8> = Vec::new();
2098 let mut stderr: Vec<u8> = Vec::new();
2099 let mut out = CliOutput {
2100 stdout: &mut stdout,
2101 stderr: &mut stderr,
2102 };
2103 keygen_operator(&key_file, false, &mut out).unwrap();
2104 let args = RulesArgs {
2105 key_dir: Some(key_dir),
2106 action: RulesAction::SignSeed {
2107 key: Some(key_file),
2108 db: None,
2109 },
2110 };
2111 let mut stdout: Vec<u8> = Vec::new();
2112 let mut stderr: Vec<u8> = Vec::new();
2113 let mut out = CliOutput {
2114 stdout: &mut stdout,
2115 stderr: &mut stderr,
2116 };
2117 run(&db_path, args, false, &mut out).expect("sign-seed reuse");
2118 }
2119
2120 #[cfg(unix)]
2126 fn assert_sign_seed_succeeds_with_key_dir_only(
2127 db_path: &std::path::Path,
2128 key_dir: std::path::PathBuf,
2129 ) {
2130 let conn = rusqlite::Connection::open(db_path).unwrap();
2131 rules_store::insert(
2132 &conn,
2133 &Rule {
2134 id: "R-822".into(),
2135 kind: "bash".into(),
2136 matcher: r#"{"command_regex":"^x"}"#.into(),
2137 severity: "refuse".into(),
2138 reason: "t".into(),
2139 namespace: "_global".into(),
2140 created_by: "test".into(),
2141 created_at: 0,
2142 enabled: true,
2143 signature: None,
2144 attest_level: "unsigned".into(),
2145 },
2146 )
2147 .unwrap();
2148 drop(conn);
2149
2150 let args = RulesArgs {
2151 key_dir: Some(key_dir),
2152 action: RulesAction::SignSeed {
2153 key: None, db: None,
2155 },
2156 };
2157 let mut stdout: Vec<u8> = Vec::new();
2158 let mut stderr: Vec<u8> = Vec::new();
2159 let mut out = CliOutput {
2160 stdout: &mut stdout,
2161 stderr: &mut stderr,
2162 };
2163 let result = run(db_path, args, true, &mut out);
2164 let stderr_s = String::from_utf8_lossy(&stderr).to_string();
2165 assert!(
2166 result.is_ok(),
2167 "#822: sign-seed must honor --key-dir; got err={result:?} stderr={stderr_s}"
2168 );
2169 let s = String::from_utf8(stdout).unwrap();
2170 assert!(s.contains("rules.sign-seed"), "got: {s}");
2171 }
2172
2173 #[cfg(unix)]
2177 #[test]
2178 fn run_rules_sign_seed_honors_key_dir_layout_key() {
2179 let (dir, db_path, _kp_key_dir) = fresh_env_with_operator_key();
2180 let key_dir = dir.path().join("keys-822-key");
2182 std::fs::create_dir_all(&key_dir).unwrap();
2183 let key_file = key_dir.join("operator.key");
2184 let mut stdout: Vec<u8> = Vec::new();
2185 let mut stderr: Vec<u8> = Vec::new();
2186 let mut out = CliOutput {
2187 stdout: &mut stdout,
2188 stderr: &mut stderr,
2189 };
2190 keygen_operator(&key_file, false, &mut out).unwrap();
2191 assert!(key_file.exists(), "keygen must lay down operator.key");
2192 assert!(
2193 !key_dir.join("operator.priv").exists(),
2194 "this branch must not have the .priv layout present"
2195 );
2196 assert_sign_seed_succeeds_with_key_dir_only(&db_path, key_dir);
2197 }
2198
2199 #[cfg(unix)]
2203 #[test]
2204 fn run_rules_sign_seed_honors_key_dir_layout_priv() {
2205 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
2206 assert!(
2207 key_dir.join("operator.priv").exists(),
2208 "fresh_env_with_operator_key must lay down operator.priv"
2209 );
2210 assert!(
2211 !key_dir.join("operator.key").exists(),
2212 "this branch must not have the .key layout present"
2213 );
2214 assert_sign_seed_succeeds_with_key_dir_only(&db_path, key_dir);
2215 }
2216
2217 #[cfg(unix)]
2229 #[test]
2230 fn run_rules_sign_seed_neither_layout_falls_through_to_legacy_path_and_errors() {
2231 static HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2234 let _guard = HOME_ENV_LOCK
2235 .lock()
2236 .unwrap_or_else(std::sync::PoisonError::into_inner);
2237
2238 let prev_home = std::env::var("HOME").ok();
2240 let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
2241
2242 let dir = tempfile::tempdir().expect("tempdir");
2243 let db_path = dir.path().join("ai-memory.db");
2244 drop(crate::db::open(&db_path).expect("db::open"));
2247
2248 let key_dir = dir.path().join("empty-keys");
2252 std::fs::create_dir_all(&key_dir).expect("mkdir empty-keys");
2253 assert!(
2254 !key_dir.join("operator.key").exists() && !key_dir.join("operator.priv").exists(),
2255 "preconditions: neither layout may exist for this branch"
2256 );
2257
2258 let fake_home = dir.path().join("fake-home");
2262 let fake_xdg = dir.path().join("fake-xdg-config");
2263 std::fs::create_dir_all(&fake_home).unwrap();
2264 std::fs::create_dir_all(&fake_xdg).unwrap();
2265 unsafe {
2267 std::env::set_var("HOME", &fake_home);
2268 std::env::set_var("XDG_CONFIG_HOME", &fake_xdg);
2269 }
2270
2271 let conn = rusqlite::Connection::open(&db_path).unwrap();
2276 rules_store::insert(
2277 &conn,
2278 &Rule {
2279 id: "R-827".into(),
2280 kind: "bash".into(),
2281 matcher: r#"{"command_regex":"^x"}"#.into(),
2282 severity: "refuse".into(),
2283 reason: "t".into(),
2284 namespace: "_global".into(),
2285 created_by: "test".into(),
2286 created_at: 0,
2287 enabled: true,
2288 signature: None,
2289 attest_level: "unsigned".into(),
2290 },
2291 )
2292 .unwrap();
2293 drop(conn);
2294
2295 let args = RulesArgs {
2296 key_dir: Some(key_dir),
2297 action: RulesAction::SignSeed {
2298 key: None, db: None,
2300 },
2301 };
2302 let mut stdout: Vec<u8> = Vec::new();
2303 let mut stderr: Vec<u8> = Vec::new();
2304 let mut out = CliOutput {
2305 stdout: &mut stdout,
2306 stderr: &mut stderr,
2307 };
2308 let result = run(&db_path, args, true, &mut out);
2309
2310 unsafe {
2314 match prev_home {
2315 Some(v) => std::env::set_var("HOME", v),
2316 None => std::env::remove_var("HOME"),
2317 }
2318 match prev_xdg {
2319 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
2320 None => std::env::remove_var("XDG_CONFIG_HOME"),
2321 }
2322 }
2323
2324 let err = result
2325 .expect_err("#827: third branch must Err, not silently succeed against real $HOME");
2326 let msg = format!("{err:#}");
2327 assert!(
2333 msg.contains("operator.key"),
2334 "#827: error must cite the legacy operator.key fallback path; got: {msg}"
2335 );
2336 assert!(
2337 msg.contains("sign-seed") || msg.contains("rules.sign-seed"),
2338 "#827: error must surface from the sign-seed verb; got: {msg}"
2339 );
2340 }
2341
2342 #[test]
2343 fn resolve_key_dir_returns_override() {
2344 let p = std::path::PathBuf::from("/some/explicit/dir");
2345 let out = resolve_key_dir(Some(&p)).unwrap();
2346 assert_eq!(out, p);
2347 }
2348
2349 #[test]
2350 fn resolve_operator_key_path_returns_override() {
2351 let p = std::path::PathBuf::from("/custom/operator.key");
2352 let out = resolve_operator_key_path(Some(&p)).unwrap();
2353 assert_eq!(out, p);
2354 }
2355
2356 #[test]
2357 fn resolve_operator_key_path_default_includes_ai_memory() {
2358 let p = resolve_operator_key_path(None).unwrap();
2359 let s = p.display().to_string();
2360 assert!(
2361 s.contains("ai-memory"),
2362 "default path missing ai-memory: {s}"
2363 );
2364 assert!(s.ends_with("operator.key"), "got: {s}");
2365 }
2366
2367 #[test]
2368 fn resolve_keygen_out_path_explicit_out_wins_1610() {
2369 let out = std::path::PathBuf::from("/custom/operator.key");
2370 let kd = std::path::PathBuf::from("/etc/ai-memory/keys");
2371 let r = resolve_keygen_out_path(Some(&out), &kd, true).unwrap();
2372 assert_eq!(r, out, "--out must win over a key-dir override");
2373 }
2374
2375 #[test]
2376 fn resolve_keygen_out_path_overridden_key_dir_wins_1610() {
2377 let kd = std::path::PathBuf::from("/etc/ai-memory/keys");
2380 let r = resolve_keygen_out_path(None, &kd, true).unwrap();
2381 assert_eq!(r, kd.join(OPERATOR_KEY_FILENAME));
2382 }
2383
2384 #[test]
2385 fn resolve_keygen_out_path_no_override_falls_back_to_legacy_singleton_1610() {
2386 let kd = std::path::PathBuf::from("/ignored/keys");
2387 let r = resolve_keygen_out_path(None, &kd, false).unwrap();
2388 let s = r.display().to_string();
2389 assert!(s.contains("ai-memory"), "legacy singleton path: {s}");
2390 assert!(
2391 !s.starts_with("/ignored"),
2392 "must NOT use key_dir when no override is in force: {s}"
2393 );
2394 }
2395
2396 #[test]
2397 fn emit_ok_human_format_emits_pretty_json() {
2398 let mut stdout: Vec<u8> = Vec::new();
2399 let mut stderr: Vec<u8> = Vec::new();
2400 let mut out = CliOutput {
2401 stdout: &mut stdout,
2402 stderr: &mut stderr,
2403 };
2404 let payload = serde_json::json!({"foo":"bar","n":1});
2405 emit_ok(false, &mut out, "test.verb", &payload).unwrap();
2406 let s = String::from_utf8(stdout).unwrap();
2407 assert!(s.contains("\"foo\": \"bar\""), "got: {s}");
2409 assert!(s.contains("\n"), "pretty must include newlines: {s}");
2410 }
2411
2412 #[test]
2413 fn emit_ok_json_format_envelopes_under_verb() {
2414 let mut stdout: Vec<u8> = Vec::new();
2415 let mut stderr: Vec<u8> = Vec::new();
2416 let mut out = CliOutput {
2417 stdout: &mut stdout,
2418 stderr: &mut stderr,
2419 };
2420 let payload = serde_json::json!({"x":1});
2421 emit_ok(true, &mut out, "test.verb", &payload).unwrap();
2422 let s = String::from_utf8(stdout).unwrap();
2423 assert!(s.contains("\"verb\":\"test.verb\""), "got: {s}");
2424 assert!(s.contains("\"result\":{\"x\":1}"), "got: {s}");
2425 }
2426
2427 #[test]
2428 fn resolve_agent_id_returns_non_empty() {
2429 let id = resolve_agent_id();
2432 assert!(!id.is_empty());
2433 }
2434
2435 #[cfg(unix)]
2436 #[test]
2437 fn sign_seed_rules_is_idempotent() {
2438 let tdir = tempfile::tempdir().unwrap();
2439 let key_path = tdir.path().join("operator.key");
2440 let mut stdout: Vec<u8> = Vec::new();
2441 let mut stderr: Vec<u8> = Vec::new();
2442 let mut out = CliOutput {
2443 stdout: &mut stdout,
2444 stderr: &mut stderr,
2445 };
2446 keygen_operator(&key_path, false, &mut out).unwrap();
2447
2448 let conn = fresh_rules_conn();
2449 rules_store::insert(
2450 &conn,
2451 &Rule {
2452 id: "R001".into(),
2453 kind: "filesystem_write".into(),
2454 matcher: r#"{"glob":"/tmp/**"}"#.into(),
2455 severity: "refuse".into(),
2456 reason: "t".into(),
2457 namespace: "_global".into(),
2458 created_by: "system:seed".into(),
2459 created_at: 0,
2460 enabled: false,
2461 signature: None,
2462 attest_level: "unsigned".into(),
2463 },
2464 )
2465 .unwrap();
2466
2467 let signed1 = sign_seed_rules(&conn, Some(&key_path), true, &mut out).unwrap();
2469 assert_eq!(signed1, 1);
2470 let sig_after_first = rules_store::get(&conn, "R001").unwrap().unwrap().signature;
2471
2472 let signed2 = sign_seed_rules(&conn, Some(&key_path), true, &mut out).unwrap();
2475 assert_eq!(signed2, 0);
2476 let sig_after_second = rules_store::get(&conn, "R001").unwrap().unwrap().signature;
2477 assert_eq!(
2478 sig_after_first, sig_after_second,
2479 "idempotent sign-seed must preserve the existing signature bytes"
2480 );
2481 }
2482
2483 #[cfg(unix)]
2488 #[test]
2489 fn rules_add_command_regex_only_fires_deprecation_branch_and_lands_rule() {
2490 let _g = forensic_lock();
2491 let tdir = tempfile::tempdir().unwrap();
2492 let key_path = tdir.path().join("operator.key");
2493 let mut stdout: Vec<u8> = Vec::new();
2494 let mut stderr: Vec<u8> = Vec::new();
2495 let mut out = CliOutput {
2496 stdout: &mut stdout,
2497 stderr: &mut stderr,
2498 };
2499 keygen_operator(&key_path, false, &mut out).unwrap();
2500
2501 let db_path = tdir.path().join("rules.db");
2504 drop(crate::storage::open(&db_path).expect("init schema"));
2505
2506 let args = RulesArgs {
2507 key_dir: Some(tdir.path().to_path_buf()),
2508 action: RulesAction::Add {
2509 id: "R900-cov".into(),
2510 kind: "bash".into(),
2511 matcher: r#"{"command_regex":"rm -rf"}"#.into(),
2512 severity: "refuse".into(),
2513 reason: "coverage: deprecated-field branch".into(),
2514 namespace: crate::quotas::GLOBAL_NAMESPACE.into(),
2515 disabled: false,
2516 sign: true,
2517 },
2518 };
2519 run(&db_path, args, false, &mut out).expect("rules add --sign");
2520
2521 let conn = rusqlite::Connection::open(&db_path).unwrap();
2522 let rule = rules_store::get(&conn, "R900-cov")
2523 .unwrap()
2524 .expect("rule landed");
2525 assert!(
2526 rule.signature.is_some(),
2527 "rules add --sign must store a signature"
2528 );
2529 assert_eq!(rule.namespace, crate::quotas::GLOBAL_NAMESPACE);
2530 }
2531
2532 #[test]
2537 fn rules_add_namespace_clap_default_is_global() {
2538 use clap::Parser;
2539 #[derive(Parser)]
2540 struct Harness {
2541 #[command(flatten)]
2542 rules: RulesArgs,
2543 }
2544 let h = Harness::try_parse_from([
2545 "harness",
2546 "add",
2547 "--id",
2548 "RX",
2549 "--kind",
2550 "bash",
2551 "--matcher",
2552 "{}",
2553 "--reason",
2554 "cov",
2555 "--sign",
2556 ])
2557 .expect("parse");
2558 match h.rules.action {
2559 RulesAction::Add { namespace, .. } => {
2560 assert_eq!(namespace, crate::quotas::GLOBAL_NAMESPACE);
2561 }
2562 _ => panic!("expected Add"),
2563 }
2564 }
2565
2566 struct FailingWriter;
2577 impl std::io::Write for FailingWriter {
2578 fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
2579 Err(std::io::Error::new(
2580 std::io::ErrorKind::BrokenPipe,
2581 "test writer: broken pipe",
2582 ))
2583 }
2584 fn flush(&mut self) -> std::io::Result<()> {
2585 Ok(())
2586 }
2587 }
2588
2589 #[cfg(unix)]
2590 #[test]
2591 fn run_rules_sign_seed_db_override_open_failure_errors() {
2592 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
2593 let bad_db = _dir.path().join("no-such-dir").join("x.db");
2597 let args = RulesArgs {
2598 key_dir: Some(key_dir),
2599 action: RulesAction::SignSeed {
2600 key: None,
2601 db: Some(bad_db),
2602 },
2603 };
2604 let mut stdout: Vec<u8> = Vec::new();
2605 let mut stderr: Vec<u8> = Vec::new();
2606 let mut out = CliOutput {
2607 stdout: &mut stdout,
2608 stderr: &mut stderr,
2609 };
2610 let err = run(&db_path, args, true, &mut out).expect_err("open must fail");
2611 let msg = format!("{err:#}");
2612 assert!(msg.contains("rules.sign-seed: open db"), "got: {msg}");
2613 }
2614
2615 #[cfg(unix)]
2616 #[test]
2617 fn keygen_create_parent_dir_failure_errors() {
2618 let dir = tempfile::tempdir().unwrap();
2622 let blocker = dir.path().join("blocker");
2623 std::fs::write(&blocker, b"i am a file").unwrap();
2624 let key_path = blocker.join("sub").join("op.key");
2625 let mut stdout: Vec<u8> = Vec::new();
2626 let mut stderr: Vec<u8> = Vec::new();
2627 let mut out = CliOutput {
2628 stdout: &mut stdout,
2629 stderr: &mut stderr,
2630 };
2631 let err = keygen_operator(&key_path, false, &mut out).unwrap_err();
2632 let msg = format!("{err:#}");
2633 assert!(msg.contains("create parent dir"), "got: {msg}");
2634 }
2635
2636 #[cfg(unix)]
2637 #[test]
2638 fn keygen_force_warning_broken_pipe_propagates() {
2639 let dir = tempfile::tempdir().unwrap();
2642 let key_path = dir.path().join("operator.key");
2643 let mut stdout: Vec<u8> = Vec::new();
2644 let mut stderr: Vec<u8> = Vec::new();
2645 let mut out = CliOutput {
2646 stdout: &mut stdout,
2647 stderr: &mut stderr,
2648 };
2649 keygen_operator(&key_path, false, &mut out).expect("first keygen");
2650
2651 let mut failing = FailingWriter;
2652 let mut stdout2: Vec<u8> = Vec::new();
2653 let mut out2 = CliOutput {
2654 stdout: &mut stdout2,
2655 stderr: &mut failing,
2656 };
2657 let res = keygen_operator(&key_path, true, &mut out2);
2658 assert!(res.is_err(), "stderr write failure must propagate");
2659 }
2660
2661 #[cfg(unix)]
2662 #[test]
2663 fn keygen_success_line_broken_pipe_propagates() {
2664 let dir = tempfile::tempdir().unwrap();
2667 let key_path = dir.path().join("operator.key");
2668 let mut failing = FailingWriter;
2669 let mut stderr: Vec<u8> = Vec::new();
2670 let mut out = CliOutput {
2671 stdout: &mut failing,
2672 stderr: &mut stderr,
2673 };
2674 let res = keygen_operator(&key_path, false, &mut out);
2675 assert!(res.is_err(), "stdout write failure must propagate");
2676 assert!(key_path.exists(), "key material still lands on disk");
2677 }
2678
2679 #[cfg(unix)]
2680 #[test]
2681 fn sign_seed_update_signature_failure_propagates() {
2682 let tdir = tempfile::tempdir().unwrap();
2685 let key_path = tdir.path().join("operator.key");
2686 let mut stdout: Vec<u8> = Vec::new();
2687 let mut stderr: Vec<u8> = Vec::new();
2688 let mut out = CliOutput {
2689 stdout: &mut stdout,
2690 stderr: &mut stderr,
2691 };
2692 keygen_operator(&key_path, false, &mut out).unwrap();
2693
2694 let conn = fresh_rules_conn();
2695 rules_store::insert(
2696 &conn,
2697 &Rule {
2698 id: "R-fail-upd".into(),
2699 kind: "bash".into(),
2700 matcher: r#"{"command_substring":"x"}"#.into(),
2701 severity: "refuse".into(),
2702 reason: "t".into(),
2703 namespace: "_global".into(),
2704 created_by: "test".into(),
2705 created_at: 0,
2706 enabled: false,
2707 signature: None,
2708 attest_level: "unsigned".into(),
2709 },
2710 )
2711 .unwrap();
2712 conn.execute_batch(
2713 "CREATE TRIGGER test_fail_sig_update BEFORE UPDATE ON governance_rules \
2714 BEGIN SELECT RAISE(ABORT, 'test trigger: signature update refused'); END;",
2715 )
2716 .unwrap();
2717 let err = sign_seed_rules(&conn, Some(&key_path), true, &mut out).unwrap_err();
2718 let msg = format!("{err:#}");
2719 assert!(msg.contains("signature update refused"), "got: {msg}");
2720 }
2721
2722 #[cfg(unix)]
2723 #[test]
2724 fn mutation_verb_legacy_layout_load_failure_cites_key_dir() {
2725 use std::os::unix::fs::PermissionsExt;
2726 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
2730 let priv_path = key_dir.join("operator.priv");
2731 std::fs::set_permissions(&priv_path, std::fs::Permissions::from_mode(0o644)).unwrap();
2732 let args = RulesArgs {
2733 key_dir: Some(key_dir),
2734 action: RulesAction::Enable {
2735 id: "R-any".into(),
2736 sign: true,
2737 },
2738 };
2739 let mut stdout: Vec<u8> = Vec::new();
2740 let mut stderr: Vec<u8> = Vec::new();
2741 let mut out = CliOutput {
2742 stdout: &mut stdout,
2743 stderr: &mut stderr,
2744 };
2745 let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
2746 let msg = format!("{err:#}");
2747 assert!(
2748 msg.contains("failed loading operator.priv/operator.pub"),
2749 "got: {msg}"
2750 );
2751 std::fs::set_permissions(&priv_path, std::fs::Permissions::from_mode(0o600)).unwrap();
2753 }
2754
2755 #[test]
2756 fn list_on_fresh_unmigrated_db_succeeds() {
2757 let dir = tempfile::tempdir().unwrap();
2766 let db_path = dir.path().join("fresh-never-migrated.db");
2767 assert!(!db_path.exists(), "db must not exist before the rules call");
2768 let args = RulesArgs {
2769 key_dir: None,
2770 action: RulesAction::List,
2771 };
2772 let mut stdout: Vec<u8> = Vec::new();
2773 let mut stderr: Vec<u8> = Vec::new();
2774 let mut out = CliOutput {
2775 stdout: &mut stdout,
2776 stderr: &mut stderr,
2777 };
2778 run(&db_path, args, true, &mut out).expect("rules list must succeed on a fresh db");
2779 let s = String::from_utf8(stdout).unwrap();
2782 assert!(
2783 s.contains("\"verb\":\"rules.list\"") && s.contains("R001"),
2784 "expected the seeded rules from the migrated fresh db, got: {s}"
2785 );
2786 }
2787
2788 #[cfg(unix)]
2792 fn fresh_env_with_keygen_layout() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf)
2793 {
2794 let dir = tempfile::tempdir().expect("tempdir");
2795 let db_path = dir.path().join("ai-memory.db");
2796 drop(crate::db::open(&db_path).expect("db::open"));
2797 let key_dir = dir.path().join("keys-l2");
2798 std::fs::create_dir_all(&key_dir).expect("mkdir");
2799 let key_file = key_dir.join(OPERATOR_KEY_FILENAME);
2800 let mut stdout: Vec<u8> = Vec::new();
2801 let mut stderr: Vec<u8> = Vec::new();
2802 let mut out = CliOutput {
2803 stdout: &mut stdout,
2804 stderr: &mut stderr,
2805 };
2806 keygen_operator(&key_file, false, &mut out).expect("keygen");
2807 (dir, db_path, key_dir)
2808 }
2809
2810 #[cfg(unix)]
2814 fn enable_err_with_key_dir(db_path: &Path, key_dir: std::path::PathBuf) -> anyhow::Error {
2815 let args = RulesArgs {
2816 key_dir: Some(key_dir),
2817 action: RulesAction::Enable {
2818 id: "R-never".into(),
2819 sign: true,
2820 },
2821 };
2822 let mut stdout: Vec<u8> = Vec::new();
2823 let mut stderr: Vec<u8> = Vec::new();
2824 let mut out = CliOutput {
2825 stdout: &mut stdout,
2826 stderr: &mut stderr,
2827 };
2828 run(db_path, args, false, &mut out).expect_err("must error")
2829 }
2830
2831 #[cfg(unix)]
2832 #[test]
2833 fn keygen_layout_pub_not_base64_refused() {
2834 let (_dir, db_path, key_dir) = fresh_env_with_keygen_layout();
2835 std::fs::write(key_dir.join("operator.key.pub"), "!!!not-base64!!!").unwrap();
2836 let err = enable_err_with_key_dir(&db_path, key_dir);
2837 let msg = format!("{err:#}");
2838 assert!(msg.contains("decode base64url public key"), "got: {msg}");
2839 }
2840
2841 #[cfg(unix)]
2842 #[test]
2843 fn keygen_layout_pub_wrong_length_refused() {
2844 use base64::Engine;
2845 let (_dir, db_path, key_dir) = fresh_env_with_keygen_layout();
2846 let short = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 16]);
2847 std::fs::write(key_dir.join("operator.key.pub"), short).unwrap();
2848 let err = enable_err_with_key_dir(&db_path, key_dir);
2849 let msg = format!("{err:#}");
2850 assert!(msg.contains("decoded to 16 bytes"), "got: {msg}");
2851 }
2852
2853 #[cfg(unix)]
2854 #[test]
2855 fn keygen_layout_pub_mismatch_refused() {
2856 use base64::Engine;
2857 let (_dir, db_path, key_dir) = fresh_env_with_keygen_layout();
2858 let other = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([9u8; 32]);
2860 std::fs::write(key_dir.join("operator.key.pub"), other).unwrap();
2861 let err = enable_err_with_key_dir(&db_path, key_dir);
2862 let msg = format!("{err:#}");
2863 assert!(msg.contains("does not match public key"), "got: {msg}");
2864 }
2865
2866 #[cfg(unix)]
2870 fn fresh_env_with_parent_keygen_layout()
2871 -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
2872 let dir = tempfile::tempdir().expect("tempdir");
2873 let db_path = dir.path().join("ai-memory.db");
2874 drop(crate::db::open(&db_path).expect("db::open"));
2875 let key_file = dir.path().join(OPERATOR_KEY_FILENAME);
2876 let mut stdout: Vec<u8> = Vec::new();
2877 let mut stderr: Vec<u8> = Vec::new();
2878 let mut out = CliOutput {
2879 stdout: &mut stdout,
2880 stderr: &mut stderr,
2881 };
2882 keygen_operator(&key_file, false, &mut out).expect("keygen");
2883 let key_dir = dir.path().join("keys");
2884 std::fs::create_dir_all(&key_dir).expect("mkdir keys");
2885 (dir, db_path, key_dir)
2886 }
2887
2888 #[cfg(unix)]
2889 #[test]
2890 fn parent_dir_keygen_fallback_signs_mutation_verbs() {
2891 let (_dir, db_path, key_dir) = fresh_env_with_parent_keygen_layout();
2894 let args = RulesArgs {
2895 key_dir: Some(key_dir),
2896 action: RulesAction::Add {
2897 id: "R-l3".into(),
2898 kind: "bash".into(),
2899 matcher: r#"{"command_substring":"halt"}"#.into(),
2900 severity: "refuse".into(),
2901 reason: "layout-3 coverage".into(),
2902 namespace: "_global".into(),
2903 disabled: false,
2904 sign: true,
2905 },
2906 };
2907 let mut stdout: Vec<u8> = Vec::new();
2908 let mut stderr: Vec<u8> = Vec::new();
2909 let mut out = CliOutput {
2910 stdout: &mut stdout,
2911 stderr: &mut stderr,
2912 };
2913 run(&db_path, args, false, &mut out).expect("layout-3 add --sign");
2914 let conn = rusqlite::Connection::open(&db_path).unwrap();
2915 let r = rules_store::get(&conn, "R-l3")
2916 .unwrap()
2917 .expect("rule landed");
2918 assert_eq!(r.attest_level, OPERATOR_SIGNED_LEVEL);
2919 assert!(r.signature.is_some());
2920 }
2921
2922 #[cfg(unix)]
2923 #[test]
2924 fn parent_dir_keygen_fallback_pub_wrong_length_refused() {
2925 use base64::Engine;
2926 let (dir, db_path, key_dir) = fresh_env_with_parent_keygen_layout();
2927 let short = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([3u8; 8]);
2928 std::fs::write(dir.path().join("operator.key.pub"), short).unwrap();
2929 let err = enable_err_with_key_dir(&db_path, key_dir);
2930 let msg = format!("{err:#}");
2931 assert!(msg.contains("decoded to 8 bytes"), "got: {msg}");
2932 }
2933
2934 #[cfg(unix)]
2935 #[test]
2936 fn parent_dir_keygen_fallback_pub_mismatch_refused() {
2937 use base64::Engine;
2938 let (dir, db_path, key_dir) = fresh_env_with_parent_keygen_layout();
2939 let other = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([4u8; 32]);
2940 std::fs::write(dir.path().join("operator.key.pub"), other).unwrap();
2941 let err = enable_err_with_key_dir(&db_path, key_dir);
2942 let msg = format!("{err:#}");
2943 assert!(msg.contains("does not match public key"), "got: {msg}");
2944 }
2945
2946 #[cfg(unix)]
2947 #[test]
2948 fn no_operator_key_anywhere_names_all_layouts() {
2949 let dir = tempfile::tempdir().unwrap();
2953 let db_path = dir.path().join("ai-memory.db");
2954 drop(crate::db::open(&db_path).expect("db::open"));
2955 let key_dir = dir.path().join("empty-parent").join("empty-keys");
2956 std::fs::create_dir_all(&key_dir).unwrap();
2957 let err = enable_err_with_key_dir(&db_path, key_dir);
2958 let msg = format!("{err:#}");
2959 assert!(msg.contains("no operator key found"), "got: {msg}");
2960 assert!(msg.contains("operator.priv"), "got: {msg}");
2961 assert!(msg.contains("rules keygen"), "got: {msg}");
2962 }
2963}