use anyhow::{Context, Result};
use clap::Args;
use rusqlite::params;
use crate::cli::CliOutput;
pub const SEED_RULE_IDS: &[&str] = &["R001", "R002", "R003", "R004"];
#[derive(Args, Debug, Clone)]
pub struct InstallDefaultsArgs {
#[arg(long)]
pub yes: bool,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Default, serde::Serialize)]
pub struct InstallDefaultsReport {
pub activated: Vec<String>,
pub already_enabled: Vec<String>,
pub missing: Vec<String>,
}
pub fn run(
db_path: &std::path::Path,
args: InstallDefaultsArgs,
out: &mut CliOutput<'_>,
) -> Result<()> {
let conn = rusqlite::Connection::open(db_path).with_context(|| {
format!(
"governance install-defaults: open db at {}",
db_path.display()
)
})?;
let mut preview: Vec<SeedRuleRow> = Vec::with_capacity(SEED_RULE_IDS.len());
let mut missing: Vec<String> = Vec::new();
for id in SEED_RULE_IDS {
match load_seed_row(&conn, id)? {
Some(row) => preview.push(row),
None => missing.push((*id).to_string()),
}
}
let operator_pubkey = crate::governance::rules_store::resolve_operator_pubkey();
if operator_pubkey.is_some() {
let unsigned_seed_rows: Vec<&SeedRuleRow> = preview
.iter()
.filter(|r| r.attest_level != crate::governance::rules_store::ATTEST_OPERATOR_SIGNED)
.collect();
if !unsigned_seed_rows.is_empty() {
let unsigned_ids: Vec<&str> =
unsigned_seed_rows.iter().map(|r| r.id.as_str()).collect();
anyhow::bail!(
"governance install-defaults: refused (#1042) — operator pubkey is resolved \
(AI_MEMORY_OPERATOR_PUBKEY env or operator.key.pub on disk) but the \
following seed rule(s) are still attest_level=unsigned: {}. \
Activating them now would print 'Activated' but the engine's \
enforced_rule_passes() would silently drop every one at wire-action time. \
First run `ai-memory rules sign-seed --key <path-to-private-key>` to upgrade \
the seed rows to operator_signed, THEN re-run install-defaults.",
unsigned_ids.join(", "),
);
}
}
if !args.yes {
if args.json {
anyhow::bail!("governance install-defaults: --json requires --yes (non-interactive)");
}
render_preview(out, &preview, &missing)?;
if !confirm_proceed(out)? {
writeln!(out.stdout, "Aborted. No rules were activated.")?;
return Ok(());
}
}
let mut report = InstallDefaultsReport {
missing: missing.clone(),
..Default::default()
};
for row in &preview {
if row.enabled {
report.already_enabled.push(row.id.clone());
continue;
}
let affected = conn
.execute(
"UPDATE governance_rules SET enabled = 1 WHERE id = ?1 AND enabled = 0",
params![row.id],
)
.with_context(|| format!("install-defaults: UPDATE enabled=1 for {}", row.id))?;
if affected > 0 {
report.activated.push(row.id.clone());
}
}
if args.json {
let envelope = serde_json::json!({
"verb": "governance.install-defaults",
"result": &report,
});
writeln!(
out.stdout,
"{}",
serde_json::to_string(&envelope)
.context("install-defaults: serialise JSON envelope")?
)?;
} else {
writeln!(
out.stdout,
"Activated {} rule(s); {} already-enabled; {} missing.",
report.activated.len(),
report.already_enabled.len(),
report.missing.len(),
)?;
if !report.activated.is_empty() {
writeln!(out.stdout, " activated: {}", report.activated.join(", "))?;
}
if !report.missing.is_empty() {
writeln!(out.stdout, " missing: {}", report.missing.join(", "))?;
}
}
Ok(())
}
struct SeedRuleRow {
id: String,
kind: String,
matcher: String,
severity: String,
enabled: bool,
attest_level: String,
}
fn load_seed_row(conn: &rusqlite::Connection, id: &str) -> Result<Option<SeedRuleRow>> {
use rusqlite::OptionalExtension;
conn.query_row(
"SELECT id, kind, matcher, severity, enabled, attest_level \
FROM governance_rules WHERE id = ?1",
params![id],
|r| {
Ok(SeedRuleRow {
id: r.get::<_, String>(0)?,
kind: r.get::<_, String>(1)?,
matcher: r.get::<_, String>(2)?,
severity: r.get::<_, String>(3)?,
enabled: r.get::<_, i64>(4)? != 0,
attest_level: r.get::<_, String>(5)?,
})
},
)
.optional()
.with_context(|| format!("install-defaults: SELECT governance_rules id={id}"))
}
fn render_preview(
out: &mut CliOutput<'_>,
preview: &[SeedRuleRow],
missing: &[String],
) -> Result<()> {
writeln!(
out.stdout,
"The following seed rules will be enabled (R001-R004):"
)?;
for row in preview {
let state = if row.enabled {
"already-on"
} else {
"will-enable"
};
writeln!(
out.stdout,
" {:<5} {:<17} {:<32} {:<8} [{}]",
row.id, row.kind, row.matcher, row.severity, state,
)?;
}
if !missing.is_empty() {
writeln!(
out.stdout,
"Warning: the following seed rule ids were not found in the DB: {}",
missing.join(", ")
)?;
writeln!(
out.stdout,
" (re-run `ai-memory schema-init` or check migration 0024 applied)"
)?;
}
Ok(())
}
fn confirm_proceed(out: &mut CliOutput<'_>) -> Result<bool> {
write!(out.stdout, "Proceed? [y/N]: ")?;
out.stdout.flush().ok();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("install-defaults: read stdin")?;
let trimmed = answer.trim().to_ascii_lowercase();
Ok(matches!(trimmed.as_str(), "y" | "yes"))
}
#[cfg(test)]
mod tests {
use super::*;
fn seed_db_at(db_path: &std::path::Path) {
let conn = rusqlite::Connection::open(db_path).unwrap();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS governance_rules (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
matcher TEXT NOT NULL,
severity TEXT NOT NULL,
reason TEXT NOT NULL,
namespace TEXT NOT NULL DEFAULT '_global',
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
signature BLOB,
attest_level TEXT NOT NULL DEFAULT 'unsigned'
);",
)
.unwrap();
for (id, kind, matcher) in [
("R001", "filesystem_write", r#"{"glob":"/tmp/**"}"#),
("R002", "filesystem_write", r#"{"glob":"/var/tmp/**"}"#),
("R003", "filesystem_write", r#"{"glob":"/private/tmp/**"}"#),
(
"R004",
"process_spawn",
r#"{"binary":"cargo","disk_free_min_gib":20}"#,
),
] {
conn.execute(
"INSERT INTO governance_rules (id, kind, matcher, severity, reason, \
namespace, created_by, created_at, enabled, signature, attest_level) \
VALUES (?1, ?2, ?3, 'refuse', 'seed', '_global', 'system:seed', 0, 0, NULL, 'unsigned')",
params![id, kind, matcher],
)
.unwrap();
}
}
fn yes_args() -> InstallDefaultsArgs {
InstallDefaultsArgs {
yes: true,
json: false,
}
}
#[test]
fn seed_rule_ids_is_the_canonical_four() {
assert_eq!(SEED_RULE_IDS, &["R001", "R002", "R003", "R004"]);
}
fn fresh_db() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("governance.db");
seed_db_at(&db_path);
(dir, db_path)
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
use std::sync::{Mutex, OnceLock};
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
struct TestPubkeyGuard;
impl Drop for TestPubkeyGuard {
fn drop(&mut self) {
unsafe { std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY") };
}
}
fn install_test_pubkey() -> TestPubkeyGuard {
use base64::Engine;
use ed25519_dalek::SigningKey;
use rand_core::OsRng;
let signing = SigningKey::generate(&mut OsRng);
let pubkey_b64 =
base64::engine::general_purpose::STANDARD.encode(signing.verifying_key().to_bytes());
unsafe { std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", pubkey_b64) };
TestPubkeyGuard
}
#[test]
fn install_defaults_refuses_when_pubkey_resolved_seed_rows_unsigned_1042() {
let _g = env_lock();
let _pk = install_test_pubkey();
let (_dir, db_path) = fresh_db();
let mut so = Vec::<u8>::new();
let mut se = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut so, &mut se);
let result = run(&db_path, yes_args(), &mut out);
let err = result
.expect_err("#1042: install-defaults MUST refuse when pubkey + unsigned seed rows");
let msg = format!("{err:#}");
assert!(
msg.contains("operator pubkey is resolved")
&& msg.contains("attest_level=unsigned")
&& msg.contains("sign-seed"),
"#1042: refusal MUST cite pubkey + unsigned + sign-seed remediation; got: {msg}"
);
let conn = rusqlite::Connection::open(&db_path).unwrap();
for id in SEED_RULE_IDS {
let enabled: i64 = conn
.query_row(
"SELECT enabled FROM governance_rules WHERE id = ?1",
params![id],
|r| r.get(0),
)
.unwrap();
assert_eq!(
enabled, 0,
"#1042: refusal MUST fire BEFORE the UPDATE — rule {id} must stay disabled"
);
}
}
#[test]
fn install_defaults_flips_enabled_on_seeded_rows() {
let _g = env_lock();
let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
let (_dir, db_path) = fresh_db();
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
for id in SEED_RULE_IDS {
let enabled: i64 = conn
.query_row(
"SELECT enabled FROM governance_rules WHERE id = ?1",
params![id],
|r| r.get(0),
)
.unwrap();
assert_eq!(enabled, 0, "rule {id} must start disabled");
}
}
let mut so = Vec::<u8>::new();
let mut se = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut so, &mut se);
run(&db_path, yes_args(), &mut out).unwrap();
let conn = rusqlite::Connection::open(&db_path).unwrap();
for id in SEED_RULE_IDS {
let enabled: i64 = conn
.query_row(
"SELECT enabled FROM governance_rules WHERE id = ?1",
params![id],
|r| r.get(0),
)
.unwrap();
assert_eq!(enabled, 1, "rule {id} must be activated");
}
let stdout = String::from_utf8(so).unwrap();
assert!(stdout.contains("Activated 4 rule(s)"));
}
#[test]
fn install_defaults_idempotent_when_already_enabled() {
let _g = env_lock();
let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
let (_dir, db_path) = fresh_db();
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"UPDATE governance_rules SET enabled = 1 WHERE id IN ('R001','R002','R003','R004')",
[],
)
.unwrap();
}
let mut so = Vec::<u8>::new();
let mut se = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut so, &mut se);
run(&db_path, yes_args(), &mut out).unwrap();
let stdout = String::from_utf8(so).unwrap();
assert!(stdout.contains("Activated 0 rule(s)"));
assert!(stdout.contains("4 already-enabled"));
}
#[test]
fn install_defaults_reports_missing_rows() {
let _g = env_lock();
let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
let (_dir, db_path) = fresh_db();
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute("DELETE FROM governance_rules WHERE id = 'R003'", [])
.unwrap();
}
let mut so = Vec::<u8>::new();
let mut se = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut so, &mut se);
run(&db_path, yes_args(), &mut out).unwrap();
let stdout = String::from_utf8(so).unwrap();
assert!(
stdout.contains("1 missing") || stdout.contains("missing: R003"),
"stdout was: {stdout}",
);
}
#[test]
fn json_mode_emits_envelope() {
let _g = env_lock();
let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
let (_dir, db_path) = fresh_db();
let mut so = Vec::<u8>::new();
let mut se = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut so, &mut se);
run(
&db_path,
InstallDefaultsArgs {
yes: true,
json: true,
},
&mut out,
)
.unwrap();
let stdout = String::from_utf8(so).unwrap();
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(v["verb"], "governance.install-defaults");
assert_eq!(v["result"]["activated"].as_array().unwrap().len(), 4);
}
#[test]
fn json_without_yes_refuses() {
let _g = env_lock();
let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
let (_dir, db_path) = fresh_db();
let mut so = Vec::<u8>::new();
let mut se = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut so, &mut se);
let err = run(
&db_path,
InstallDefaultsArgs {
yes: false,
json: true,
},
&mut out,
)
.expect_err("expected refusal");
assert!(
err.to_string().contains("--json requires --yes"),
"got: {err}"
);
}
#[test]
fn render_preview_emits_one_row_per_seeded_rule() {
let preview = vec![
SeedRuleRow {
id: "R001".into(),
kind: "filesystem_write".into(),
matcher: r#"{"glob":"/tmp/**"}"#.into(),
severity: "refuse".into(),
enabled: false,
attest_level: "unsigned".into(),
},
SeedRuleRow {
id: "R002".into(),
kind: "filesystem_write".into(),
matcher: r#"{"glob":"/var/tmp/**"}"#.into(),
severity: "refuse".into(),
enabled: true,
attest_level: "unsigned".into(),
},
];
let missing: Vec<String> = vec![];
let mut so = Vec::<u8>::new();
let mut se = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut so, &mut se);
render_preview(&mut out, &preview, &missing).unwrap();
drop(out);
let stdout = String::from_utf8(so).unwrap();
assert!(stdout.contains("The following seed rules will be enabled"));
assert!(stdout.contains("R001"));
assert!(stdout.contains("R002"));
assert!(stdout.contains("will-enable"));
assert!(stdout.contains("already-on"));
assert!(!stdout.contains("Warning"));
}
#[test]
fn render_preview_emits_warning_block_when_missing_present() {
let preview: Vec<SeedRuleRow> = vec![];
let missing = vec!["R003".to_string(), "R004".to_string()];
let mut so = Vec::<u8>::new();
let mut se = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut so, &mut se);
render_preview(&mut out, &preview, &missing).unwrap();
drop(out);
let stdout = String::from_utf8(so).unwrap();
assert!(stdout.contains("Warning"));
assert!(stdout.contains("R003"));
assert!(stdout.contains("R004"));
assert!(stdout.contains("re-run `ai-memory schema-init`"));
}
#[test]
fn load_seed_row_returns_none_for_unknown_id() {
let (_dir, db_path) = fresh_db();
let conn = rusqlite::Connection::open(&db_path).unwrap();
let row = load_seed_row(&conn, "R999-nonexistent").unwrap();
assert!(row.is_none());
}
#[test]
fn load_seed_row_returns_typed_row_with_disabled_default() {
let (_dir, db_path) = fresh_db();
let conn = rusqlite::Connection::open(&db_path).unwrap();
let row = load_seed_row(&conn, "R001").unwrap();
let row = row.expect("R001 seeded");
assert_eq!(row.id, "R001");
assert_eq!(row.kind, "filesystem_write");
assert_eq!(row.severity, "refuse");
assert!(!row.enabled, "seeded rows ship at enabled = 0");
}
#[test]
fn install_defaults_human_render_emits_activated_and_missing_lines() {
let _g = env_lock();
let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
let (_dir, db_path) = fresh_db();
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute("DELETE FROM governance_rules WHERE id = 'R002'", [])
.unwrap();
}
let mut so = Vec::<u8>::new();
let mut se = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut so, &mut se);
run(&db_path, yes_args(), &mut out).unwrap();
drop(out);
let stdout = String::from_utf8(so).unwrap();
assert!(stdout.contains("Activated 3 rule(s)"));
assert!(stdout.contains("1 missing"));
assert!(stdout.contains(" activated:"));
assert!(stdout.contains(" missing:"));
assert!(stdout.contains("R002"));
}
#[test]
fn install_defaults_json_envelope_pins_wire_shape_when_partial_missing() {
let _g = env_lock();
let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
let (_dir, db_path) = fresh_db();
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"DELETE FROM governance_rules WHERE id IN ('R003','R004')",
[],
)
.unwrap();
}
let mut so = Vec::<u8>::new();
let mut se = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut so, &mut se);
run(
&db_path,
InstallDefaultsArgs {
yes: true,
json: true,
},
&mut out,
)
.unwrap();
drop(out);
let stdout = String::from_utf8(so).unwrap();
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(v["verb"], "governance.install-defaults");
let result = &v["result"];
let activated = result["activated"].as_array().unwrap();
assert_eq!(activated.len(), 2);
let missing = result["missing"].as_array().unwrap();
assert_eq!(missing.len(), 2);
assert!(missing.iter().any(|x| x == "R003"));
assert!(missing.iter().any(|x| x == "R004"));
}
#[test]
fn run_propagates_open_error_for_non_existent_db_with_unwritable_parent() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("nonexistent-dir/missing.db");
let mut so = Vec::<u8>::new();
let mut se = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut so, &mut se);
let err = run(&db_path, yes_args(), &mut out).expect_err("must fail");
let chain = format!("{err:#}");
assert!(
chain.contains("governance install-defaults: open db at"),
"expected context, got: {chain}"
);
}
}