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 = 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 let matcher_json: serde_json::Value = serde_json::from_str(&matcher)
252 .with_context(|| format!("rules add: matcher is not valid JSON: {matcher}"))?;
253 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 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 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 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 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 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 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
460fn 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
492fn 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
507fn 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 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 write_operator_private_seed(path, &seed, out)?;
566 write_operator_public_key(&pub_path, &pub_bytes)?;
568
569 let fingerprint = pub_fingerprint(&pub_bytes);
573
574 writeln!(
576 out.stdout,
577 "Ed25519 operator key generated: {fingerprint} -> {}",
578 path.display()
579 )?;
580
581 Ok(fingerprint)
586}
587
588fn 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 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 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 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
654fn 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
684fn 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
702fn 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
710pub 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
753fn 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
820fn 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
829fn load_operator_signing_key_from_dir(
852 key_dir: &std::path::Path,
853) -> Result<ed25519_dalek::SigningKey> {
854 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 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 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 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
988fn resolve_agent_id() -> String {
992 crate::identity::resolve_agent_id(None, None)
993 .unwrap_or_else(|_| format!("anonymous:pid-{}", std::process::id()))
994}
995
996fn 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
1074fn 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 writeln!(out.stdout, "{}", serde_json::to_string_pretty(result)?)?;
1115 }
1116 Ok(())
1117}
1118
1119#[cfg(test)]
1124mod tests {
1125 use super::*;
1126
1127 #[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 #[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 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 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 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 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 let s = String::from_utf8(stdout).unwrap();
1313 assert!(s.contains(&fp), "stdout must include fingerprint, got: {s}");
1314 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 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 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 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 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 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 assert!(
1407 msg.contains("expected") || msg.contains("bytes"),
1408 "got: {msg}"
1409 );
1410 }
1411
1412 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 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 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 #[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 drop(crate::db::open(&db_path).expect("db::open"));
1510 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 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 assert!(s.contains("\"verb\":\"rules.list\""), "got: {s}");
1540 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 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 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 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(), 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 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 let args = RulesArgs {
1782 key_dir: Some(key_dir.clone()),
1783 action: RulesAction::Add {
1784 id: "R-toggle".into(),
1785 kind: "bash".into(),
1786 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 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 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 let args = RulesArgs {
1920 key_dir: Some(key_dir.clone()),
1921 action: RulesAction::Add {
1922 id: "R-rm".into(),
1923 kind: "bash".into(),
1924 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 let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
1996 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 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 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 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 #[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, 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 #[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 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 #[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 #[cfg(unix)]
2195 #[test]
2196 fn run_rules_sign_seed_neither_layout_falls_through_to_legacy_path_and_errors() {
2197 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 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 drop(crate::db::open(&db_path).expect("db::open"));
2213
2214 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 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 unsafe {
2233 std::env::set_var("HOME", &fake_home);
2234 std::env::set_var("XDG_CONFIG_HOME", &fake_xdg);
2235 }
2236
2237 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, 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 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 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 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 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 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 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 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 #[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 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 #[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 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 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 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 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 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 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 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 std::fs::set_permissions(&priv_path, std::fs::Permissions::from_mode(0o600)).unwrap();
2719 }
2720
2721 #[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 #[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 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 #[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 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 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}