1use anyhow::{Context, Result};
49use clap::Args;
50use rusqlite::params;
51
52use crate::cli::CliOutput;
53
54pub const SEED_RULE_IDS: &[&str] = &["R001", "R002", "R003", "R004"];
58
59#[derive(Args, Debug, Clone)]
61pub struct InstallDefaultsArgs {
62 #[arg(long)]
65 pub yes: bool,
66
67 #[arg(long)]
71 pub json: bool,
72}
73
74#[derive(Debug, Default, serde::Serialize)]
77pub struct InstallDefaultsReport {
78 pub activated: Vec<String>,
80 pub already_enabled: Vec<String>,
82 pub missing: Vec<String>,
85}
86
87pub fn run(
97 db_path: &std::path::Path,
98 args: InstallDefaultsArgs,
99 out: &mut CliOutput<'_>,
100) -> Result<()> {
101 let conn = rusqlite::Connection::open(db_path).with_context(|| {
102 format!(
103 "governance install-defaults: open db at {}",
104 db_path.display()
105 )
106 })?;
107
108 let mut preview: Vec<SeedRuleRow> = Vec::with_capacity(SEED_RULE_IDS.len());
111 let mut missing: Vec<String> = Vec::new();
112 for id in SEED_RULE_IDS {
113 match load_seed_row(&conn, id)? {
114 Some(row) => preview.push(row),
115 None => missing.push((*id).to_string()),
116 }
117 }
118
119 let operator_pubkey = crate::governance::rules_store::resolve_operator_pubkey();
145 if operator_pubkey.is_some() {
146 let unsigned_seed_rows: Vec<&SeedRuleRow> = preview
147 .iter()
148 .filter(|r| r.attest_level != crate::governance::rules_store::ATTEST_OPERATOR_SIGNED)
149 .collect();
150 if !unsigned_seed_rows.is_empty() {
151 let unsigned_ids: Vec<&str> =
152 unsigned_seed_rows.iter().map(|r| r.id.as_str()).collect();
153 anyhow::bail!(
154 "governance install-defaults: refused (#1042) — operator pubkey is resolved \
155 (AI_MEMORY_OPERATOR_PUBKEY env or operator.key.pub on disk) but the \
156 following seed rule(s) are still attest_level=unsigned: {}. \
157 Activating them now would print 'Activated' but the engine's \
158 enforced_rule_passes() would silently drop every one at wire-action time. \
159 First run `ai-memory rules sign-seed --key <path-to-private-key>` to upgrade \
160 the seed rows to operator_signed, THEN re-run install-defaults.",
161 unsigned_ids.join(", "),
162 );
163 }
164 }
165
166 if !args.yes {
168 if args.json {
171 anyhow::bail!("governance install-defaults: --json requires --yes (non-interactive)");
172 }
173 render_preview(out, &preview, &missing)?;
174 if !confirm_proceed(out)? {
175 writeln!(out.stdout, "Aborted. No rules were activated.")?;
176 return Ok(());
177 }
178 }
179
180 let mut report = InstallDefaultsReport {
182 missing: missing.clone(),
183 ..Default::default()
184 };
185 for row in &preview {
186 if row.enabled {
187 report.already_enabled.push(row.id.clone());
188 continue;
189 }
190 let affected = conn
191 .execute(
192 "UPDATE governance_rules SET enabled = 1 WHERE id = ?1 AND enabled = 0",
193 params![row.id],
194 )
195 .with_context(|| format!("install-defaults: UPDATE enabled=1 for {}", row.id))?;
196 if affected > 0 {
197 report.activated.push(row.id.clone());
198 }
199 }
200
201 if args.json {
202 let envelope = serde_json::json!({
203 "verb": "governance.install-defaults",
204 "result": &report,
205 });
206 writeln!(
207 out.stdout,
208 "{}",
209 serde_json::to_string(&envelope)
210 .context("install-defaults: serialise JSON envelope")?
211 )?;
212 } else {
213 writeln!(
214 out.stdout,
215 "Activated {} rule(s); {} already-enabled; {} missing.",
216 report.activated.len(),
217 report.already_enabled.len(),
218 report.missing.len(),
219 )?;
220 if !report.activated.is_empty() {
221 writeln!(out.stdout, " activated: {}", report.activated.join(", "))?;
222 }
223 if !report.missing.is_empty() {
224 writeln!(out.stdout, " missing: {}", report.missing.join(", "))?;
225 }
226 }
227 Ok(())
228}
229
230struct SeedRuleRow {
232 id: String,
233 kind: String,
234 matcher: String,
235 severity: String,
236 enabled: bool,
237 attest_level: String,
243}
244
245fn load_seed_row(conn: &rusqlite::Connection, id: &str) -> Result<Option<SeedRuleRow>> {
246 use rusqlite::OptionalExtension;
247 conn.query_row(
248 "SELECT id, kind, matcher, severity, enabled, attest_level \
249 FROM governance_rules WHERE id = ?1",
250 params![id],
251 |r| {
252 Ok(SeedRuleRow {
253 id: r.get::<_, String>(0)?,
254 kind: r.get::<_, String>(1)?,
255 matcher: r.get::<_, String>(2)?,
256 severity: r.get::<_, String>(3)?,
257 enabled: r.get::<_, i64>(4)? != 0,
258 attest_level: r.get::<_, String>(5)?,
259 })
260 },
261 )
262 .optional()
263 .with_context(|| format!("install-defaults: SELECT governance_rules id={id}"))
264}
265
266fn render_preview(
267 out: &mut CliOutput<'_>,
268 preview: &[SeedRuleRow],
269 missing: &[String],
270) -> Result<()> {
271 writeln!(
272 out.stdout,
273 "The following seed rules will be enabled (R001-R004):"
274 )?;
275 for row in preview {
276 let state = if row.enabled {
277 "already-on"
278 } else {
279 "will-enable"
280 };
281 writeln!(
282 out.stdout,
283 " {:<5} {:<17} {:<32} {:<8} [{}]",
284 row.id, row.kind, row.matcher, row.severity, state,
285 )?;
286 }
287 if !missing.is_empty() {
288 writeln!(
289 out.stdout,
290 "Warning: the following seed rule ids were not found in the DB: {}",
291 missing.join(", ")
292 )?;
293 writeln!(
294 out.stdout,
295 " (re-run `ai-memory schema-init` or check migration 0024 applied)"
296 )?;
297 }
298 Ok(())
299}
300
301fn confirm_proceed(out: &mut CliOutput<'_>) -> Result<bool> {
302 write!(out.stdout, "Proceed? [y/N]: ")?;
303 out.stdout.flush().ok();
304 let mut answer = String::new();
305 std::io::stdin()
306 .read_line(&mut answer)
307 .context("install-defaults: read stdin")?;
308 let trimmed = answer.trim().to_ascii_lowercase();
309 Ok(matches!(trimmed.as_str(), "y" | "yes"))
310}
311
312#[cfg(test)]
317mod tests {
318 use super::*;
319
320 fn seed_db_at(db_path: &std::path::Path) {
324 let conn = rusqlite::Connection::open(db_path).unwrap();
325 conn.execute_batch(
326 "CREATE TABLE IF NOT EXISTS governance_rules (
327 id TEXT PRIMARY KEY,
328 kind TEXT NOT NULL,
329 matcher TEXT NOT NULL,
330 severity TEXT NOT NULL,
331 reason TEXT NOT NULL,
332 namespace TEXT NOT NULL DEFAULT '_global',
333 created_by TEXT NOT NULL,
334 created_at INTEGER NOT NULL,
335 enabled INTEGER NOT NULL DEFAULT 1,
336 signature BLOB,
337 attest_level TEXT NOT NULL DEFAULT 'unsigned'
338 );",
339 )
340 .unwrap();
341 for (id, kind, matcher) in [
342 ("R001", "filesystem_write", r#"{"glob":"/tmp/**"}"#),
343 ("R002", "filesystem_write", r#"{"glob":"/var/tmp/**"}"#),
344 ("R003", "filesystem_write", r#"{"glob":"/private/tmp/**"}"#),
345 (
346 "R004",
347 "process_spawn",
348 r#"{"binary":"cargo","disk_free_min_gib":20}"#,
349 ),
350 ] {
351 conn.execute(
352 "INSERT INTO governance_rules (id, kind, matcher, severity, reason, \
353 namespace, created_by, created_at, enabled, signature, attest_level) \
354 VALUES (?1, ?2, ?3, 'refuse', 'seed', '_global', 'system:seed', 0, 0, NULL, 'unsigned')",
355 params![id, kind, matcher],
356 )
357 .unwrap();
358 }
359 }
360
361 fn yes_args() -> InstallDefaultsArgs {
364 InstallDefaultsArgs {
365 yes: true,
366 json: false,
367 }
368 }
369
370 #[test]
371 fn seed_rule_ids_is_the_canonical_four() {
372 assert_eq!(SEED_RULE_IDS, &["R001", "R002", "R003", "R004"]);
373 }
374
375 fn fresh_db() -> (tempfile::TempDir, std::path::PathBuf) {
377 let dir = tempfile::tempdir().unwrap();
378 let db_path = dir.path().join("governance.db");
379 seed_db_at(&db_path);
380 (dir, db_path)
381 }
382
383 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
386 use std::sync::{Mutex, OnceLock};
387 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
388 LOCK.get_or_init(|| Mutex::new(()))
389 .lock()
390 .unwrap_or_else(std::sync::PoisonError::into_inner)
391 }
392
393 struct TestPubkeyGuard;
398 impl Drop for TestPubkeyGuard {
399 fn drop(&mut self) {
400 unsafe { std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY") };
403 }
404 }
405 fn install_test_pubkey() -> TestPubkeyGuard {
406 use base64::Engine;
407 use ed25519_dalek::SigningKey;
408 use rand_core::OsRng;
409 let signing = SigningKey::generate(&mut OsRng);
410 let pubkey_b64 =
411 base64::engine::general_purpose::STANDARD.encode(signing.verifying_key().to_bytes());
412 unsafe { std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", pubkey_b64) };
414 TestPubkeyGuard
415 }
416
417 #[test]
418 fn install_defaults_refuses_when_pubkey_resolved_seed_rows_unsigned_1042() {
419 let _g = env_lock();
426 let _pk = install_test_pubkey();
427 let (_dir, db_path) = fresh_db();
428
429 let mut so = Vec::<u8>::new();
430 let mut se = Vec::<u8>::new();
431 let mut out = CliOutput::from_std(&mut so, &mut se);
432 let result = run(&db_path, yes_args(), &mut out);
433 let err = result
434 .expect_err("#1042: install-defaults MUST refuse when pubkey + unsigned seed rows");
435 let msg = format!("{err:#}");
436 assert!(
437 msg.contains("operator pubkey is resolved")
438 && msg.contains("attest_level=unsigned")
439 && msg.contains("sign-seed"),
440 "#1042: refusal MUST cite pubkey + unsigned + sign-seed remediation; got: {msg}"
441 );
442 let conn = rusqlite::Connection::open(&db_path).unwrap();
445 for id in SEED_RULE_IDS {
446 let enabled: i64 = conn
447 .query_row(
448 "SELECT enabled FROM governance_rules WHERE id = ?1",
449 params![id],
450 |r| r.get(0),
451 )
452 .unwrap();
453 assert_eq!(
454 enabled, 0,
455 "#1042: refusal MUST fire BEFORE the UPDATE — rule {id} must stay disabled"
456 );
457 }
458 }
459
460 #[test]
461 fn install_defaults_flips_enabled_on_seeded_rows() {
462 let _g = env_lock();
463 let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
468 let (_dir, db_path) = fresh_db();
469 {
471 let conn = rusqlite::Connection::open(&db_path).unwrap();
472 for id in SEED_RULE_IDS {
473 let enabled: i64 = conn
474 .query_row(
475 "SELECT enabled FROM governance_rules WHERE id = ?1",
476 params![id],
477 |r| r.get(0),
478 )
479 .unwrap();
480 assert_eq!(enabled, 0, "rule {id} must start disabled");
481 }
482 }
483
484 let mut so = Vec::<u8>::new();
485 let mut se = Vec::<u8>::new();
486 let mut out = CliOutput::from_std(&mut so, &mut se);
487 run(&db_path, yes_args(), &mut out).unwrap();
488
489 let conn = rusqlite::Connection::open(&db_path).unwrap();
490 for id in SEED_RULE_IDS {
491 let enabled: i64 = conn
492 .query_row(
493 "SELECT enabled FROM governance_rules WHERE id = ?1",
494 params![id],
495 |r| r.get(0),
496 )
497 .unwrap();
498 assert_eq!(enabled, 1, "rule {id} must be activated");
499 }
500 let stdout = String::from_utf8(so).unwrap();
501 assert!(stdout.contains("Activated 4 rule(s)"));
502 }
503
504 #[test]
505 fn install_defaults_idempotent_when_already_enabled() {
506 let _g = env_lock();
507 let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
508 let (_dir, db_path) = fresh_db();
509 {
511 let conn = rusqlite::Connection::open(&db_path).unwrap();
512 conn.execute(
513 "UPDATE governance_rules SET enabled = 1 WHERE id IN ('R001','R002','R003','R004')",
514 [],
515 )
516 .unwrap();
517 }
518
519 let mut so = Vec::<u8>::new();
520 let mut se = Vec::<u8>::new();
521 let mut out = CliOutput::from_std(&mut so, &mut se);
522 run(&db_path, yes_args(), &mut out).unwrap();
523
524 let stdout = String::from_utf8(so).unwrap();
525 assert!(stdout.contains("Activated 0 rule(s)"));
526 assert!(stdout.contains("4 already-enabled"));
527 }
528
529 #[test]
530 fn install_defaults_reports_missing_rows() {
531 let _g = env_lock();
532 let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
533 let (_dir, db_path) = fresh_db();
534 {
536 let conn = rusqlite::Connection::open(&db_path).unwrap();
537 conn.execute("DELETE FROM governance_rules WHERE id = 'R003'", [])
538 .unwrap();
539 }
540
541 let mut so = Vec::<u8>::new();
542 let mut se = Vec::<u8>::new();
543 let mut out = CliOutput::from_std(&mut so, &mut se);
544 run(&db_path, yes_args(), &mut out).unwrap();
545
546 let stdout = String::from_utf8(so).unwrap();
547 assert!(
548 stdout.contains("1 missing") || stdout.contains("missing: R003"),
549 "stdout was: {stdout}",
550 );
551 }
552
553 #[test]
554 fn json_mode_emits_envelope() {
555 let _g = env_lock();
556 let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
557 let (_dir, db_path) = fresh_db();
558 let mut so = Vec::<u8>::new();
559 let mut se = Vec::<u8>::new();
560 let mut out = CliOutput::from_std(&mut so, &mut se);
561 run(
562 &db_path,
563 InstallDefaultsArgs {
564 yes: true,
565 json: true,
566 },
567 &mut out,
568 )
569 .unwrap();
570 let stdout = String::from_utf8(so).unwrap();
571 let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
572 assert_eq!(v["verb"], "governance.install-defaults");
573 assert_eq!(v["result"]["activated"].as_array().unwrap().len(), 4);
574 }
575
576 #[test]
577 fn json_without_yes_refuses() {
578 let _g = env_lock();
579 let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
580 let (_dir, db_path) = fresh_db();
581 let mut so = Vec::<u8>::new();
582 let mut se = Vec::<u8>::new();
583 let mut out = CliOutput::from_std(&mut so, &mut se);
584 let err = run(
585 &db_path,
586 InstallDefaultsArgs {
587 yes: false,
588 json: true,
589 },
590 &mut out,
591 )
592 .expect_err("expected refusal");
593 assert!(
594 err.to_string().contains("--json requires --yes"),
595 "got: {err}"
596 );
597 }
598
599 #[test]
606 fn render_preview_emits_one_row_per_seeded_rule() {
607 let preview = vec![
608 SeedRuleRow {
609 id: "R001".into(),
610 kind: "filesystem_write".into(),
611 matcher: r#"{"glob":"/tmp/**"}"#.into(),
612 severity: "refuse".into(),
613 enabled: false,
614 attest_level: "unsigned".into(),
615 },
616 SeedRuleRow {
617 id: "R002".into(),
618 kind: "filesystem_write".into(),
619 matcher: r#"{"glob":"/var/tmp/**"}"#.into(),
620 severity: "refuse".into(),
621 enabled: true,
622 attest_level: "unsigned".into(),
623 },
624 ];
625 let missing: Vec<String> = vec![];
626
627 let mut so = Vec::<u8>::new();
628 let mut se = Vec::<u8>::new();
629 let mut out = CliOutput::from_std(&mut so, &mut se);
630 render_preview(&mut out, &preview, &missing).unwrap();
631 drop(out);
632 let stdout = String::from_utf8(so).unwrap();
633 assert!(stdout.contains("The following seed rules will be enabled"));
635 assert!(stdout.contains("R001"));
637 assert!(stdout.contains("R002"));
638 assert!(stdout.contains("will-enable"));
641 assert!(stdout.contains("already-on"));
642 assert!(!stdout.contains("Warning"));
644 }
645
646 #[test]
647 fn render_preview_emits_warning_block_when_missing_present() {
648 let preview: Vec<SeedRuleRow> = vec![];
649 let missing = vec!["R003".to_string(), "R004".to_string()];
650
651 let mut so = Vec::<u8>::new();
652 let mut se = Vec::<u8>::new();
653 let mut out = CliOutput::from_std(&mut so, &mut se);
654 render_preview(&mut out, &preview, &missing).unwrap();
655 drop(out);
656 let stdout = String::from_utf8(so).unwrap();
657 assert!(stdout.contains("Warning"));
659 assert!(stdout.contains("R003"));
660 assert!(stdout.contains("R004"));
661 assert!(stdout.contains("re-run `ai-memory schema-init`"));
662 }
663
664 #[test]
665 fn load_seed_row_returns_none_for_unknown_id() {
666 let (_dir, db_path) = fresh_db();
667 let conn = rusqlite::Connection::open(&db_path).unwrap();
668 let row = load_seed_row(&conn, "R999-nonexistent").unwrap();
669 assert!(row.is_none());
670 }
671
672 #[test]
673 fn load_seed_row_returns_typed_row_with_disabled_default() {
674 let (_dir, db_path) = fresh_db();
675 let conn = rusqlite::Connection::open(&db_path).unwrap();
676 let row = load_seed_row(&conn, "R001").unwrap();
677 let row = row.expect("R001 seeded");
678 assert_eq!(row.id, "R001");
679 assert_eq!(row.kind, "filesystem_write");
680 assert_eq!(row.severity, "refuse");
681 assert!(!row.enabled, "seeded rows ship at enabled = 0");
682 }
683
684 #[test]
685 fn install_defaults_human_render_emits_activated_and_missing_lines() {
686 let _g = env_lock();
687 let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
688 let (_dir, db_path) = fresh_db();
692 {
693 let conn = rusqlite::Connection::open(&db_path).unwrap();
694 conn.execute("DELETE FROM governance_rules WHERE id = 'R002'", [])
695 .unwrap();
696 }
697 let mut so = Vec::<u8>::new();
698 let mut se = Vec::<u8>::new();
699 let mut out = CliOutput::from_std(&mut so, &mut se);
700 run(&db_path, yes_args(), &mut out).unwrap();
701 drop(out);
702 let stdout = String::from_utf8(so).unwrap();
703 assert!(stdout.contains("Activated 3 rule(s)"));
705 assert!(stdout.contains("1 missing"));
706 assert!(stdout.contains(" activated:"));
708 assert!(stdout.contains(" missing:"));
710 assert!(stdout.contains("R002"));
711 }
712
713 #[test]
714 fn install_defaults_json_envelope_pins_wire_shape_when_partial_missing() {
715 let _g = env_lock();
716 let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
717 let (_dir, db_path) = fresh_db();
719 {
720 let conn = rusqlite::Connection::open(&db_path).unwrap();
721 conn.execute(
722 "DELETE FROM governance_rules WHERE id IN ('R003','R004')",
723 [],
724 )
725 .unwrap();
726 }
727 let mut so = Vec::<u8>::new();
728 let mut se = Vec::<u8>::new();
729 let mut out = CliOutput::from_std(&mut so, &mut se);
730 run(
731 &db_path,
732 InstallDefaultsArgs {
733 yes: true,
734 json: true,
735 },
736 &mut out,
737 )
738 .unwrap();
739 drop(out);
740 let stdout = String::from_utf8(so).unwrap();
741 let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
742 assert_eq!(v["verb"], "governance.install-defaults");
743 let result = &v["result"];
744 let activated = result["activated"].as_array().unwrap();
746 assert_eq!(activated.len(), 2);
747 let missing = result["missing"].as_array().unwrap();
748 assert_eq!(missing.len(), 2);
749 assert!(missing.iter().any(|x| x == "R003"));
750 assert!(missing.iter().any(|x| x == "R004"));
751 }
752
753 #[test]
754 fn run_propagates_open_error_for_non_existent_db_with_unwritable_parent() {
755 let dir = tempfile::tempdir().unwrap();
759 let db_path = dir.path().join("nonexistent-dir/missing.db");
760 let mut so = Vec::<u8>::new();
761 let mut se = Vec::<u8>::new();
762 let mut out = CliOutput::from_std(&mut so, &mut se);
763 let err = run(&db_path, yes_args(), &mut out).expect_err("must fail");
764 let chain = format!("{err:#}");
767 assert!(
768 chain.contains("governance install-defaults: open db at"),
769 "expected context, got: {chain}"
770 );
771 }
772}