use crate::models::field_names;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use clap::{Args, Subcommand};
use ed25519_dalek::{Signer, SigningKey};
use serde::Serialize;
use crate::cli::CliOutput;
use crate::governance::agent_action::{AgentAction, action_kinds as ak, check_agent_action};
use crate::governance::rules_store::{self, Rule};
use crate::identity::keypair as kp;
const OPERATOR_KEY_FILENAME: &str = "operator.key";
pub const OPERATOR_KEY_ID: &str = "operator";
pub const OPERATOR_SIGNED_LEVEL: &str =
crate::governance::rules_store::OPERATOR_SIGNED_ATTEST_LEVEL;
const ED25519_SEED_LEN: usize = ed25519_dalek::SECRET_KEY_LENGTH;
const ED25519_PUBLIC_LEN: usize = ed25519_dalek::PUBLIC_KEY_LENGTH;
#[derive(Args)]
pub struct RulesArgs {
#[arg(long, value_name = "PATH", global = true)]
pub key_dir: Option<PathBuf>,
#[command(subcommand)]
pub action: RulesAction,
}
#[derive(Subcommand)]
pub enum RulesAction {
Add {
#[arg(long)]
id: String,
#[arg(long)]
kind: String,
#[arg(long)]
matcher: String,
#[arg(long, default_value = "refuse")]
severity: String,
#[arg(long)]
reason: String,
#[arg(long, default_value = crate::quotas::GLOBAL_NAMESPACE)]
namespace: String,
#[arg(long)]
disabled: bool,
#[arg(long)]
sign: bool,
},
List,
Check {
#[arg(long)]
kind: String,
#[arg(long)]
payload: String,
#[arg(long)]
agent_id: Option<String>,
},
Enable {
#[arg(long)]
id: String,
#[arg(long)]
sign: bool,
},
Disable {
#[arg(long)]
id: String,
#[arg(long)]
sign: bool,
},
Remove {
#[arg(long)]
id: String,
#[arg(long)]
sign: bool,
},
Keygen {
#[arg(long, value_name = "PATH")]
out: Option<PathBuf>,
#[arg(long)]
force: bool,
},
SignSeed {
#[arg(long, value_name = "PATH")]
key: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
db: Option<PathBuf>,
},
}
#[derive(Serialize)]
struct CliEnvelope<'a> {
verb: &'a str,
result: serde_json::Value,
}
pub fn run(
db_path: &std::path::Path,
args: RulesArgs,
json: bool,
out: &mut CliOutput<'_>,
) -> Result<()> {
let conn = crate::db::open(db_path)
.with_context(|| format!("rules: open db at {}", db_path.display()))?;
let key_dir = resolve_key_dir(args.key_dir.as_deref())?;
match args.action {
RulesAction::Add {
id,
kind,
matcher,
severity,
reason,
namespace,
disabled,
sign,
} => {
if !sign {
bail!("governance.no_operator_key: `rules add` requires --sign");
}
let signing_key = load_operator_signing_key_from_dir(&key_dir)?;
let matcher_json: serde_json::Value = serde_json::from_str(&matcher)
.with_context(|| format!("rules add: matcher is not valid JSON: {matcher}"))?;
if let Some(val) = matcher_json
.get(crate::governance::agent_action::MATCHER_COMMAND_SUBSTRING)
.or_else(|| {
matcher_json.get(crate::governance::agent_action::MATCHER_COMMAND_REGEX)
})
.and_then(|v| v.as_str())
{
crate::governance::agent_action::validate_command_substring(val)
.map_err(|e| anyhow::anyhow!("rules add: {e}"))?;
if matcher_json
.get(crate::governance::agent_action::MATCHER_COMMAND_REGEX)
.is_some()
&& matcher_json
.get(crate::governance::agent_action::MATCHER_COMMAND_SUBSTRING)
.is_none()
{
tracing::warn!(
"rules add: matcher field `command_regex` is DEPRECATED — rename to \
`command_substring` (the engine has always done literal substring \
matching, not regex). See SEC-12 in the v0.7.0 cluster-D fix."
);
}
}
let created_at = chrono::Utc::now().timestamp();
let agent_id = resolve_agent_id();
let mut rule = Rule {
id: id.clone(),
kind,
matcher,
severity,
reason,
namespace,
created_by: agent_id,
created_at,
enabled: !disabled,
signature: None,
attest_level: crate::models::AttestLevel::Unsigned.as_str().to_string(),
};
let canonical = rules_store::canonical_bytes_for_signing(&rule)?;
let sig = signing_key.sign(&canonical);
rule.signature = Some(sig.to_bytes().to_vec());
rule.attest_level = OPERATOR_SIGNED_LEVEL.to_string();
rules_store::insert(&conn, &rule)?;
emit_ok(json, out, "rules.add", &rule_to_json(&rule))?;
Ok(())
}
RulesAction::List => {
let rules = rules_store::list(&conn)?;
let payload = serde_json::Value::Array(rules.iter().map(rule_to_json).collect());
emit_ok(json, out, "rules.list", &payload)?;
Ok(())
}
RulesAction::Check {
kind,
payload,
agent_id,
} => {
let action = build_action(&kind, &payload)?;
let resolved_agent = agent_id.unwrap_or_else(resolve_agent_id);
let decision = check_agent_action(&conn, &resolved_agent, &action)?;
emit_ok(json, out, "rules.check", &serde_json::to_value(&decision)?)?;
Ok(())
}
RulesAction::Enable { id, sign } => {
if !sign {
bail!("governance.no_operator_key: `rules enable` requires --sign");
}
let signing_key = load_operator_signing_key_from_dir(&key_dir)?;
let Some(mut rule) = rules_store::get(&conn, &id)? else {
bail!("rules.enable: no rule with id={id}");
};
rule.enabled = true;
let canonical = rules_store::canonical_bytes_for_signing(&rule)?;
let sig = signing_key.sign(&canonical);
rules_store::set_enabled(&conn, &id, true)?;
rules_store::update_signature(&conn, &id, &sig.to_bytes(), OPERATOR_SIGNED_LEVEL)?;
let updated =
rules_store::get(&conn, &id)?.context("rules.enable: row vanished after update")?;
emit_ok(json, out, "rules.enable", &rule_to_json(&updated))?;
Ok(())
}
RulesAction::Disable { id, sign } => {
if !sign {
bail!("governance.no_operator_key: `rules disable` requires --sign");
}
let signing_key = load_operator_signing_key_from_dir(&key_dir)?;
let Some(mut rule) = rules_store::get(&conn, &id)? else {
bail!("rules.disable: no rule with id={id}");
};
rule.enabled = false;
let canonical = rules_store::canonical_bytes_for_signing(&rule)?;
let sig = signing_key.sign(&canonical);
rules_store::set_enabled(&conn, &id, false)?;
rules_store::update_signature(&conn, &id, &sig.to_bytes(), OPERATOR_SIGNED_LEVEL)?;
let updated = rules_store::get(&conn, &id)?
.context("rules.disable: row vanished after update")?;
emit_ok(json, out, "rules.disable", &rule_to_json(&updated))?;
Ok(())
}
RulesAction::Remove { id, sign } => {
if !sign {
bail!("governance.no_operator_key: `rules remove` requires --sign");
}
let signing_key = load_operator_signing_key_from_dir(&key_dir)?;
let removed = rules_store::remove_signed(&conn, &id, &signing_key, OPERATOR_KEY_ID)?;
let payload = serde_json::json!({ "id": id, "removed": removed });
emit_ok(json, out, "rules.remove", &payload)?;
Ok(())
}
RulesAction::Keygen {
out: out_path,
force,
} => {
let key_dir_overridden = args.key_dir.is_some() || kp::key_dir_env_override().is_some();
let resolved =
resolve_keygen_out_path(out_path.as_deref(), &key_dir, key_dir_overridden)?;
let fingerprint = keygen_operator(&resolved, force, out)?;
if let Ok(rules) = rules_store::list(&conn) {
let dormant = rules
.iter()
.filter(|r| r.enabled && r.attest_level != OPERATOR_SIGNED_LEVEL)
.count();
if dormant > 0 {
writeln!(
out.stderr,
"WARNING: {dormant} enabled rule(s) are not operator-signed. \
Generating this operator key activates signature enforcement, so \
those rules will be SKIPPED at load time until you run \
`ai-memory rules sign-seed`."
)?;
}
}
let payload = serde_json::json!({
"path": resolved.display().to_string(),
"public_path": format!("{}.pub", resolved.display()),
"fingerprint": fingerprint,
});
emit_ok(json, out, "rules.keygen", &payload)?;
Ok(())
}
RulesAction::SignSeed { key, db } => {
let resolved_key: Option<PathBuf> = key.or_else(|| {
let key_layout = key_dir.join(OPERATOR_KEY_FILENAME);
if key_layout.exists() {
return Some(key_layout);
}
let priv_layout = key_dir.join("operator.priv");
if priv_layout.exists() {
return Some(priv_layout);
}
None
});
if let Some(db_path) = db {
let conn2 = crate::db::open(&db_path).with_context(|| {
format!("rules.sign-seed: open db at {}", db_path.display())
})?;
sign_seed_rules(&conn2, resolved_key.as_deref(), json, out)?;
} else {
sign_seed_rules(&conn, resolved_key.as_deref(), json, out)?;
}
Ok(())
}
}
}
fn resolve_keygen_out_path(
explicit_out: Option<&Path>,
key_dir: &Path,
key_dir_overridden: bool,
) -> Result<PathBuf> {
if let Some(p) = explicit_out {
return Ok(p.to_path_buf());
}
if key_dir_overridden {
return Ok(key_dir.join(OPERATOR_KEY_FILENAME));
}
resolve_operator_key_path(None)
}
fn resolve_operator_key_path(override_path: Option<&Path>) -> Result<PathBuf> {
if let Some(p) = override_path {
return Ok(p.to_path_buf());
}
let base = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("rules.keygen: OS did not advertise a config directory"))?;
Ok(base.join("ai-memory").join(OPERATOR_KEY_FILENAME))
}
fn keygen_operator(path: &Path, force: bool, out: &mut CliOutput<'_>) -> Result<String> {
let pub_path = pub_sibling_path(path);
if !force && (path.exists() || pub_path.exists()) {
bail!(
"rules.keygen: refusing to overwrite existing key material at {} (or {}). \
Pass --force to replace — note that all prior operator-signed rules \
will fail signature verification with the new key.",
path.display(),
pub_path.display()
);
}
if force && (path.exists() || pub_path.exists()) {
writeln!(
out.stderr,
"WARNING: rules.keygen --force replaces existing operator key. \
All prior operator-signed rules become INVALID and will be skipped at \
load time until re-signed with the new key."
)?;
}
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)
.with_context(|| format!("rules.keygen: create parent dir {}", parent.display()))?;
}
let mut csprng = rand_core::OsRng;
let signing = SigningKey::generate(&mut csprng);
let verifying = signing.verifying_key();
let seed = signing.to_bytes();
let pub_bytes = verifying.to_bytes();
write_operator_private_seed(path, &seed, out)?;
write_operator_public_key(&pub_path, &pub_bytes)?;
let fingerprint = pub_fingerprint(&pub_bytes);
writeln!(
out.stdout,
"Ed25519 operator key generated: {fingerprint} -> {}",
path.display()
)?;
Ok(fingerprint)
}
fn write_operator_private_seed(
path: &Path,
seed: &[u8; ED25519_SEED_LEN],
#[cfg_attr(unix, allow(unused_variables))] out: &mut CliOutput<'_>,
) -> Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::remove_file(path);
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.with_context(|| format!("rules.keygen: create {}", path.display()))?;
file.write_all(seed)
.with_context(|| format!("rules.keygen: write seed to {}", path.display()))?;
file.sync_all()
.with_context(|| format!("rules.keygen: fsync {}", path.display()))?;
drop(file);
let mode = std::fs::metadata(path)
.with_context(|| format!("rules.keygen: stat {}", path.display()))?
.permissions()
.mode()
& 0o777;
if mode != 0o600 {
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(path, perms)
.with_context(|| format!("rules.keygen: chmod 0600 {}", path.display()))?;
let verified = std::fs::metadata(path)?.permissions().mode() & 0o777;
if verified != 0o600 {
bail!(
"rules.keygen: could not enforce mode 0600 on {} (observed {verified:o})",
path.display()
);
}
}
Ok(())
}
#[cfg(not(unix))]
{
writeln!(
out.stderr,
"WARNING: Windows: operator key permissions not enforced; protect manually"
)?;
std::fs::write(path, seed)
.with_context(|| format!("rules.keygen: write seed to {}", path.display()))?;
Ok(())
}
}
fn write_operator_public_key(pub_path: &Path, pub_bytes: &[u8; ED25519_PUBLIC_LEN]) -> Result<()> {
use base64::Engine;
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let _ = std::fs::remove_file(pub_path);
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o644)
.open(pub_path)
.with_context(|| format!("rules.keygen: create {}", pub_path.display()))?;
file.write_all(encoded.as_bytes())
.with_context(|| format!("rules.keygen: write pub to {}", pub_path.display()))?;
file.sync_all()
.with_context(|| format!("rules.keygen: fsync {}", pub_path.display()))?;
}
#[cfg(not(unix))]
{
std::fs::write(pub_path, encoded.as_bytes())
.with_context(|| format!("rules.keygen: write pub to {}", pub_path.display()))?;
}
Ok(())
}
fn pub_fingerprint(pub_bytes: &[u8; ED25519_PUBLIC_LEN]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(pub_bytes);
let digest = hasher.finalize();
let mut out = String::with_capacity(16);
for byte in digest.iter().take(8) {
out.push_str(&format!("{byte:02x}"));
}
out
}
fn pub_sibling_path(seed_path: &Path) -> PathBuf {
let mut s = seed_path.as_os_str().to_os_string();
s.push(".pub");
PathBuf::from(s)
}
pub fn load_operator_signing_key(path: &Path) -> Result<SigningKey> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(path)
.with_context(|| format!("load_operator_signing_key: stat {}", path.display()))?;
let mode = meta.permissions().mode() & 0o777;
if mode != 0o600 {
bail!(
"load_operator_signing_key: {} has mode {mode:o}; permissions too open; \
chmod 0600 {} to restore",
path.display(),
path.display()
);
}
}
let bytes = std::fs::read(path)
.with_context(|| format!("load_operator_signing_key: read {}", path.display()))?;
if bytes.len() != ED25519_SEED_LEN {
bail!(
"load_operator_signing_key: {} has {} bytes, expected {ED25519_SEED_LEN}",
path.display(),
bytes.len()
);
}
let mut seed = [0u8; ED25519_SEED_LEN];
seed.copy_from_slice(&bytes);
Ok(SigningKey::from_bytes(&seed))
}
fn sign_seed_rules(
conn: &rusqlite::Connection,
key_path: Option<&Path>,
json: bool,
out: &mut CliOutput<'_>,
) -> Result<usize> {
let resolved = match key_path {
Some(p) => p.to_path_buf(),
None => resolve_operator_key_path(None)?,
};
let signing_key = load_operator_signing_key(&resolved).with_context(|| {
format!(
"rules.sign-seed: load operator key from {}",
resolved.display()
)
})?;
let rules = rules_store::list(conn)?;
let mut signed_now = 0usize;
let mut summary: Vec<serde_json::Value> = Vec::new();
for rule in rules {
let canonical = rules_store::canonical_bytes_for_signing(&rule)?;
let signature = signing_key.sign(&canonical);
let sig_bytes = signature.to_bytes();
let already_signed = matches!(
(rule.signature.as_deref(), rule.attest_level.as_str()),
(Some(existing), OPERATOR_SIGNED_LEVEL) if existing == sig_bytes.as_slice()
);
if !already_signed {
rules_store::update_signature(
conn,
&rule.id,
sig_bytes.as_slice(),
OPERATOR_SIGNED_LEVEL,
)?;
signed_now += 1;
}
summary.push(serde_json::json!({
"id": rule.id,
(field_names::ATTEST_LEVEL): OPERATOR_SIGNED_LEVEL,
"signed_now": !already_signed,
}));
}
let payload = serde_json::json!({
"signed_now": signed_now,
"rules": summary,
});
emit_ok(json, out, "rules.sign-seed", &payload)?;
Ok(signed_now)
}
fn resolve_key_dir(override_dir: Option<&std::path::Path>) -> Result<PathBuf> {
if let Some(p) = override_dir {
return Ok(p.to_path_buf());
}
kp::default_key_dir()
}
fn load_operator_signing_key_from_dir(
key_dir: &std::path::Path,
) -> Result<ed25519_dalek::SigningKey> {
let priv_legacy = key_dir.join("operator.priv");
let pub_legacy = key_dir.join("operator.pub");
if priv_legacy.exists() && pub_legacy.exists() {
let kp = kp::load(OPERATOR_KEY_ID, key_dir).with_context(|| {
format!(
"governance.no_operator_key: failed loading operator.priv/operator.pub at {}",
key_dir.display()
)
})?;
return kp.private.ok_or_else(|| {
anyhow::anyhow!(
"governance.no_operator_key: operator keypair has no private half (public-only load)"
)
});
}
let priv_keygen = key_dir.join(OPERATOR_KEY_FILENAME);
let pub_keygen = key_dir.join("operator.key.pub");
if priv_keygen.exists() {
let signing = load_operator_signing_key(&priv_keygen).with_context(|| {
format!(
"governance.no_operator_key: failed loading {}",
priv_keygen.display()
)
})?;
if pub_keygen.exists() {
use base64::Engine;
let encoded = std::fs::read_to_string(&pub_keygen).with_context(|| {
format!("governance.no_operator_key: read {}", pub_keygen.display())
})?;
let trimmed = encoded.trim();
let pub_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(trimmed)
.with_context(|| {
format!(
"governance.no_operator_key: decode base64url public key at {}",
pub_keygen.display()
)
})?;
if pub_bytes.len() != ED25519_PUBLIC_LEN {
bail!(
"governance.no_operator_key: public key {} decoded to {} bytes (expected {ED25519_PUBLIC_LEN})",
pub_keygen.display(),
pub_bytes.len(),
);
}
if signing.verifying_key().to_bytes().as_slice() != pub_bytes.as_slice() {
bail!(
"governance.no_operator_key: private key {} does not match public key {}",
priv_keygen.display(),
pub_keygen.display(),
);
}
}
return Ok(signing);
}
if let Some(parent) = key_dir.parent() {
let parent_priv = parent.join(OPERATOR_KEY_FILENAME);
let parent_pub = parent.join("operator.key.pub");
if parent_priv.exists() {
let signing = load_operator_signing_key(&parent_priv).with_context(|| {
format!(
"governance.no_operator_key: failed loading {}",
parent_priv.display()
)
})?;
if parent_pub.exists() {
use base64::Engine;
let encoded = std::fs::read_to_string(&parent_pub).with_context(|| {
format!("governance.no_operator_key: read {}", parent_pub.display())
})?;
let trimmed = encoded.trim();
let pub_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(trimmed)
.with_context(|| {
format!(
"governance.no_operator_key: decode base64url public key at {}",
parent_pub.display()
)
})?;
if pub_bytes.len() != ED25519_PUBLIC_LEN {
bail!(
"governance.no_operator_key: public key {} decoded to {} bytes (expected {ED25519_PUBLIC_LEN})",
parent_pub.display(),
pub_bytes.len(),
);
}
if signing.verifying_key().to_bytes().as_slice() != pub_bytes.as_slice() {
bail!(
"governance.no_operator_key: private key {} does not match public key {}",
parent_priv.display(),
parent_pub.display(),
);
}
}
return Ok(signing);
}
}
bail!(
"governance.no_operator_key: no operator key found at {dir} \
(also checked parent dir for the keygen layout). \
Expected either `operator.priv` + `operator.pub` (raw 32-byte pair, \
as produced by per-agent `keypair` generation) OR \
`operator.key` + `operator.key.pub` (raw 32-byte seed + base64url \
verifier, as produced by `ai-memory rules keygen` — searched both \
`{dir}/` and `{dir}/../`)",
dir = key_dir.display(),
)
}
fn resolve_agent_id() -> String {
crate::identity::resolve_agent_id(None, None)
.unwrap_or_else(|_| format!("anonymous:pid-{}", std::process::id()))
}
fn build_action(kind: &str, payload_json: &str) -> Result<AgentAction> {
let payload: serde_json::Value = serde_json::from_str(payload_json)
.with_context(|| format!("rules check: payload is not valid JSON: {payload_json}"))?;
match kind {
ak::BASH => {
let command = payload
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("bash payload requires `command` string"))?
.to_string();
let cwd = payload
.get("cwd")
.and_then(|v| v.as_str())
.map(PathBuf::from);
Ok(AgentAction::Bash { command, cwd })
}
ak::FILESYSTEM_WRITE => {
let path = payload
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("filesystem_write payload requires `path` string"))?
.to_string();
let byte_estimate = payload
.get("byte_estimate")
.and_then(serde_json::Value::as_u64);
Ok(AgentAction::FilesystemWrite {
path: PathBuf::from(path),
byte_estimate,
})
}
ak::NETWORK_REQUEST => {
let host = payload
.get("host")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("network_request payload requires `host` string"))?
.to_string();
let scheme = payload
.get("scheme")
.and_then(|v| v.as_str())
.unwrap_or("https")
.to_string();
Ok(AgentAction::NetworkRequest { host, scheme })
}
ak::PROCESS_SPAWN => {
let binary = payload
.get("binary")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("process_spawn payload requires `binary` string"))?
.to_string();
let args = payload
.get("args")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
Ok(AgentAction::ProcessSpawn { binary, args })
}
"custom" => {
let custom_kind = payload
.get(field_names::CUSTOM_KIND)
.or_else(|| payload.get("kind"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("custom payload requires `custom_kind` string"))?
.to_string();
Ok(AgentAction::Custom {
custom_kind,
payload,
})
}
other => bail!("rules check: unknown kind `{other}`"),
}
}
fn rule_to_json(rule: &Rule) -> serde_json::Value {
use base64::Engine;
let sig_b64 = rule
.signature
.as_ref()
.map(|b| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b));
serde_json::json!({
"id": rule.id,
"kind": rule.kind,
"matcher": rule.matcher,
"severity": rule.severity,
"reason": rule.reason,
"namespace": rule.namespace,
(field_names::CREATED_BY): rule.created_by,
(field_names::CREATED_AT): rule.created_at,
"enabled": rule.enabled,
"signature_b64": sig_b64,
(field_names::ATTEST_LEVEL): rule.attest_level,
})
}
fn emit_ok(
json: bool,
out: &mut CliOutput<'_>,
verb: &str,
result: &serde_json::Value,
) -> Result<()> {
if json {
let env = CliEnvelope {
verb,
result: result.clone(),
};
writeln!(out.stdout, "{}", serde_json::to_string(&env)?)?;
} else {
writeln!(out.stdout, "{}", serde_json::to_string_pretty(result)?)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[must_use = "the guard must be held for the scope of the test"]
fn forensic_lock() -> std::sync::MutexGuard<'static, ()> {
crate::governance::audit::forensic_sink_test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner())
}
#[test]
fn build_action_bash_parses() {
let a = build_action("bash", r#"{"command":"ls -la"}"#).unwrap();
match a {
AgentAction::Bash { command, cwd } => {
assert_eq!(command, "ls -la");
assert!(cwd.is_none());
}
_ => panic!("expected bash"),
}
}
#[test]
fn build_action_filesystem_write_parses() {
let a = build_action("filesystem_write", r#"{"path":"/tmp/x"}"#).unwrap();
match a {
AgentAction::FilesystemWrite { path, .. } => {
assert_eq!(path, PathBuf::from("/tmp/x"));
}
_ => panic!("expected filesystem_write"),
}
}
#[test]
fn build_action_network_request_parses_with_scheme_default() {
let a = build_action("network_request", r#"{"host":"x.example.com"}"#).unwrap();
match a {
AgentAction::NetworkRequest { host, scheme } => {
assert_eq!(host, "x.example.com");
assert_eq!(scheme, "https");
}
_ => panic!("expected network_request"),
}
}
#[test]
fn build_action_process_spawn_parses() {
let a = build_action(
"process_spawn",
r#"{"binary":"cargo","args":["build","--release"]}"#,
)
.unwrap();
match a {
AgentAction::ProcessSpawn { binary, args } => {
assert_eq!(binary, "cargo");
assert_eq!(args, vec!["build", "--release"]);
}
_ => panic!("expected process_spawn"),
}
}
#[test]
fn build_action_custom_parses() {
let a = build_action("custom", r#"{"custom_kind":"deploy","env":"prod"}"#).unwrap();
match a {
AgentAction::Custom { custom_kind, .. } => assert_eq!(custom_kind, "deploy"),
_ => panic!("expected custom"),
}
}
#[test]
fn build_action_unknown_kind_errors() {
assert!(build_action("nope", "{}").is_err());
}
#[test]
fn build_action_invalid_json_errors() {
assert!(build_action("bash", "not json").is_err());
}
#[test]
fn build_action_missing_required_field_errors() {
assert!(build_action("bash", "{}").is_err());
assert!(build_action("filesystem_write", "{}").is_err());
}
#[test]
fn rule_to_json_encodes_signature_as_base64() {
let mut rule = Rule {
id: "R1".into(),
kind: "bash".into(),
matcher: r#"{"command_regex":"x"}"#.into(),
severity: "refuse".into(),
reason: "test".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
};
let v = rule_to_json(&rule);
assert_eq!(v["signature_b64"], serde_json::Value::Null);
rule.signature = Some(vec![0xff, 0x00, 0xaa]);
let v = rule_to_json(&rule);
assert_eq!(
v["signature_b64"],
serde_json::Value::String("_wCq".to_string())
);
}
#[test]
fn pub_sibling_path_appends_dot_pub() {
let p = pub_sibling_path(Path::new("/x/y/operator.key"));
assert_eq!(p, PathBuf::from("/x/y/operator.key.pub"));
}
#[test]
fn pub_fingerprint_is_deterministic_and_16_hex_chars() {
let bytes = [0u8; 32];
let fp1 = pub_fingerprint(&bytes);
let fp2 = pub_fingerprint(&bytes);
assert_eq!(fp1, fp2, "fingerprint must be deterministic");
assert_eq!(fp1.len(), 16, "fingerprint must be 16 hex chars");
assert!(
fp1.chars().all(|c| c.is_ascii_hexdigit()),
"fingerprint must be ASCII hex"
);
let mut other = [0u8; 32];
other[0] = 1;
let fp3 = pub_fingerprint(&other);
assert_ne!(fp1, fp3);
}
#[cfg(unix)]
#[test]
fn keygen_writes_priv_0600_and_pub_0644_then_loads() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("operator.key");
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let fp = keygen_operator(&key_path, false, &mut out).expect("keygen");
assert_eq!(fp.len(), 16);
let meta = std::fs::metadata(&key_path).unwrap();
let mode = meta.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "priv key must be 0600, got {mode:o}");
let bytes = std::fs::read(&key_path).unwrap();
assert_eq!(bytes.len(), 32, "priv seed must be 32 bytes");
let pub_path = pub_sibling_path(&key_path);
let pmode = std::fs::metadata(&pub_path).unwrap().permissions().mode() & 0o777;
assert_eq!(pmode, 0o644, "pub key must be 0644, got {pmode:o}");
let pub_b64 = std::fs::read_to_string(&pub_path).unwrap();
use base64::Engine;
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(pub_b64.trim())
.expect("pub base64 decodes");
assert_eq!(decoded.len(), 32);
let signing = load_operator_signing_key(&key_path).expect("load");
let verifying = signing.verifying_key();
assert_eq!(verifying.to_bytes()[..], decoded[..]);
let s = String::from_utf8(stdout).unwrap();
assert!(s.contains(&fp), "stdout must include fingerprint, got: {s}");
assert!(s.starts_with("Ed25519 operator key generated:"));
}
#[cfg(unix)]
#[test]
fn keygen_refuses_overwrite_without_force() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("operator.key");
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
keygen_operator(&key_path, false, &mut out).expect("first");
let bytes_before = std::fs::read(&key_path).unwrap();
let err = keygen_operator(&key_path, false, &mut out).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("refusing to overwrite"), "got: {msg}");
let bytes_after = std::fs::read(&key_path).unwrap();
assert_eq!(bytes_before, bytes_after);
}
#[cfg(unix)]
#[test]
fn keygen_force_overwrites_and_warns_on_stderr() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("operator.key");
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let fp1 = keygen_operator(&key_path, false, &mut out).expect("first");
let fp2 = keygen_operator(&key_path, true, &mut out).expect("force");
assert_ne!(fp1, fp2, "fresh keypair must have new fingerprint");
let s = String::from_utf8(stderr).unwrap();
assert!(
s.contains("WARNING") && s.contains("INVALID"),
"stderr must warn about prior-signature invalidation, got: {s}"
);
}
#[cfg(unix)]
#[test]
fn load_operator_signing_key_refuses_open_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("operator.key");
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
keygen_operator(&key_path, false, &mut out).expect("keygen");
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644)).unwrap();
let err = load_operator_signing_key(&key_path).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("0600"), "error must mention 0600, got: {msg}");
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
#[test]
fn load_operator_signing_key_rejects_wrong_length() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("operator.key");
std::fs::write(&key_path, b"too-short").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
let err = load_operator_signing_key(&key_path).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("expected") || msg.contains("bytes"),
"got: {msg}"
);
}
fn fresh_rules_conn() -> rusqlite::Connection {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE governance_rules (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
matcher TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('refuse','warn','log')),
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();
conn
}
#[cfg(unix)]
#[test]
fn sign_seed_rules_marks_all_rows_operator_signed() {
let tdir = tempfile::tempdir().unwrap();
let key_path = tdir.path().join("operator.key");
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
keygen_operator(&key_path, false, &mut out).unwrap();
let conn = fresh_rules_conn();
for id in ["R001", "R002"] {
rules_store::insert(
&conn,
&Rule {
id: id.to_string(),
kind: "filesystem_write".into(),
matcher: r#"{"glob":"/tmp/**"}"#.into(),
severity: "refuse".into(),
reason: "test".into(),
namespace: "_global".into(),
created_by: "system:seed".into(),
created_at: 0,
enabled: false,
signature: None,
attest_level: "unsigned".into(),
},
)
.unwrap();
}
let signed = sign_seed_rules(&conn, Some(&key_path), true, &mut out).unwrap();
assert_eq!(signed, 2);
for id in ["R001", "R002"] {
let row = rules_store::get(&conn, id).unwrap().unwrap();
assert_eq!(row.attest_level, "operator_signed");
assert_eq!(
row.signature.as_ref().map(Vec::len),
Some(ed25519_dalek::SIGNATURE_LENGTH)
);
assert!(!row.enabled, "sign-seed must NOT flip enabled");
}
}
#[cfg(unix)]
fn fresh_env_with_operator_key() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf)
{
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("ai-memory.db");
drop(crate::db::open(&db_path).expect("db::open"));
let kp = kp::generate(OPERATOR_KEY_ID).expect("generate");
let key_dir = dir.path().join("keys");
std::fs::create_dir_all(&key_dir).expect("mkdir keys");
kp::save(&kp, &key_dir).expect("save kp");
(dir, db_path, key_dir)
}
#[cfg(unix)]
#[test]
fn run_rules_list_emits_seeded_rules() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::List,
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
run(&db_path, args, true, &mut out).expect("list");
let s = String::from_utf8(stdout).unwrap();
assert!(s.contains("\"verb\":\"rules.list\""), "got: {s}");
assert!(s.contains("\"result\":["), "got: {s}");
}
#[cfg(unix)]
#[test]
fn run_rules_list_human_format_emits_pretty_array() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::List,
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
run(&db_path, args, false, &mut out).expect("list");
let s = String::from_utf8(stdout).unwrap();
assert!(s.contains("["), "got: {s}");
}
#[cfg(unix)]
#[test]
fn run_rules_add_without_sign_refuses() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::Add {
id: "R-test".into(),
kind: "bash".into(),
matcher: r#"{"command_regex":"^ls"}"#.into(),
severity: "refuse".into(),
reason: "test".into(),
namespace: "_global".into(),
disabled: false,
sign: false,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
let msg = format!("{err:#}");
assert!(msg.contains("no_operator_key"), "got: {msg}");
}
#[cfg(unix)]
#[test]
fn run_rules_add_with_sign_persists_signed_rule() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let args = RulesArgs {
key_dir: Some(key_dir.clone()),
action: RulesAction::Add {
id: "R-add-1".into(),
kind: "bash".into(),
matcher: r#"{"command_substring":"rm -rf /"}"#.into(),
severity: "refuse".into(),
reason: "rm-rf is bad".into(),
namespace: "_global".into(),
disabled: false,
sign: true,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
run(&db_path, args, true, &mut out).expect("add");
let s = String::from_utf8(stdout).unwrap();
assert!(s.contains("rules.add"), "got: {s}");
assert!(s.contains("R-add-1"), "got: {s}");
assert!(s.contains("operator_signed"), "got: {s}");
let conn = rusqlite::Connection::open(&db_path).unwrap();
let r = rules_store::get(&conn, "R-add-1").unwrap().unwrap();
assert_eq!(r.attest_level, "operator_signed");
assert!(r.signature.is_some());
}
#[cfg(unix)]
#[test]
fn run_rules_add_with_bad_matcher_json_errors() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::Add {
id: "R-bad".into(),
kind: "bash".into(),
matcher: "{ not json".into(), severity: "refuse".into(),
reason: "x".into(),
namespace: "_global".into(),
disabled: false,
sign: true,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
let msg = format!("{err:#}");
assert!(msg.contains("matcher"), "got: {msg}");
}
#[cfg(unix)]
#[test]
fn run_rules_add_disabled_lands_disabled_row() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::Add {
id: "R-dis".into(),
kind: "filesystem_write".into(),
matcher: r#"{"glob":"/tmp/**"}"#.into(),
severity: "warn".into(),
reason: "noisy".into(),
namespace: "_global".into(),
disabled: true,
sign: true,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
run(&db_path, args, false, &mut out).expect("add");
let conn = rusqlite::Connection::open(&db_path).unwrap();
let r = rules_store::get(&conn, "R-dis").unwrap().unwrap();
assert!(!r.enabled, "disabled flag must propagate");
}
#[cfg(unix)]
#[test]
fn run_rules_check_evaluates_action_against_empty_set() {
let _forensic = forensic_lock();
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::Check {
kind: "bash".into(),
payload: r#"{"command":"ls"}"#.into(),
agent_id: Some("tester".into()),
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
run(&db_path, args, true, &mut out).expect("check");
let s = String::from_utf8(stdout).unwrap();
assert!(s.contains("rules.check"), "got: {s}");
}
#[cfg(unix)]
#[test]
fn run_rules_check_without_agent_id_uses_default() {
let _forensic = forensic_lock();
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::Check {
kind: "network_request".into(),
payload: r#"{"host":"example.com","scheme":"https"}"#.into(),
agent_id: None,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
run(&db_path, args, false, &mut out).expect("check");
}
#[cfg(unix)]
#[test]
fn run_rules_enable_unsign_refuses() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::Enable {
id: "R-x".into(),
sign: false,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
assert!(format!("{err:#}").contains("no_operator_key"));
}
#[cfg(unix)]
#[test]
fn run_rules_enable_unknown_id_errors() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::Enable {
id: "R-does-not-exist".into(),
sign: true,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let err = run(&db_path, args, false, &mut out).expect_err("must error");
assert!(format!("{err:#}").contains("no rule with id"));
}
#[cfg(unix)]
#[test]
fn run_rules_enable_and_disable_roundtrip() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let args = RulesArgs {
key_dir: Some(key_dir.clone()),
action: RulesAction::Add {
id: "R-toggle".into(),
kind: "bash".into(),
matcher: r#"{"command_substring":"x"}"#.into(),
severity: "warn".into(),
reason: "toggle me".into(),
namespace: "_global".into(),
disabled: true,
sign: true,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
run(&db_path, args, false, &mut out).expect("add");
let args = RulesArgs {
key_dir: Some(key_dir.clone()),
action: RulesAction::Enable {
id: "R-toggle".into(),
sign: true,
},
};
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
run(&db_path, args, false, &mut out).expect("enable");
let conn = rusqlite::Connection::open(&db_path).unwrap();
assert!(
rules_store::get(&conn, "R-toggle")
.unwrap()
.unwrap()
.enabled
);
drop(conn);
let args = RulesArgs {
key_dir: Some(key_dir.clone()),
action: RulesAction::Disable {
id: "R-toggle".into(),
sign: true,
},
};
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
run(&db_path, args, true, &mut out).expect("disable");
let conn = rusqlite::Connection::open(&db_path).unwrap();
assert!(
!rules_store::get(&conn, "R-toggle")
.unwrap()
.unwrap()
.enabled
);
}
#[cfg(unix)]
#[test]
fn run_rules_disable_unsign_refuses() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::Disable {
id: "R-x".into(),
sign: false,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
assert!(format!("{err:#}").contains("no_operator_key"));
}
#[cfg(unix)]
#[test]
fn run_rules_disable_unknown_id_errors() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::Disable {
id: "R-missing".into(),
sign: true,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let err = run(&db_path, args, false, &mut out).expect_err("must error");
assert!(format!("{err:#}").contains("no rule with id"));
}
#[cfg(unix)]
#[test]
fn run_rules_remove_unsign_refuses() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::Remove {
id: "R-x".into(),
sign: false,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
assert!(format!("{err:#}").contains("no_operator_key"));
}
#[cfg(unix)]
#[test]
fn run_rules_remove_signed_deletes_row() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let args = RulesArgs {
key_dir: Some(key_dir.clone()),
action: RulesAction::Add {
id: "R-rm".into(),
kind: "bash".into(),
matcher: r#"{"command_substring":"x"}"#.into(),
severity: "warn".into(),
reason: "rm me".into(),
namespace: "_global".into(),
disabled: false,
sign: true,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
run(&db_path, args, false, &mut out).expect("add");
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::Remove {
id: "R-rm".into(),
sign: true,
},
};
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
run(&db_path, args, true, &mut out).expect("remove");
let s = String::from_utf8(stdout).unwrap();
assert!(s.contains("rules.remove"), "got: {s}");
assert!(s.contains("\"removed\":true"), "got: {s}");
let conn = rusqlite::Connection::open(&db_path).unwrap();
assert!(rules_store::get(&conn, "R-rm").unwrap().is_none());
}
#[cfg(unix)]
#[test]
fn run_rules_keygen_writes_keypair_under_explicit_out() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("ai-memory.db");
drop(crate::db::open(&db_path).expect("db::open"));
let key_path = dir.path().join("op.key");
let args = RulesArgs {
key_dir: None,
action: RulesAction::Keygen {
out: Some(key_path.clone()),
force: false,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
run(&db_path, args, true, &mut out).expect("keygen");
let s = String::from_utf8(stdout).unwrap();
assert!(s.contains("rules.keygen"), "got: {s}");
assert!(key_path.exists(), "priv key missing");
let pub_path = pub_sibling_path(&key_path);
assert!(pub_path.exists(), "pub key missing");
}
#[cfg(unix)]
#[test]
fn run_rules_sign_seed_signs_existing_rules() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let conn = rusqlite::Connection::open(&db_path).unwrap();
rules_store::insert(
&conn,
&Rule {
id: "R-ss".into(),
kind: "bash".into(),
matcher: r#"{"command_regex":"^x"}"#.into(),
severity: "refuse".into(),
reason: "t".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
},
)
.unwrap();
drop(conn);
let dir2 = tempfile::tempdir().unwrap();
let key_file = dir2.path().join("operator.key");
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
keygen_operator(&key_file, false, &mut out).unwrap();
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::SignSeed {
key: Some(key_file),
db: Some(db_path.clone()),
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let placeholder_db = tempfile::tempdir().unwrap();
let placeholder_path = placeholder_db.path().join("placeholder.db");
drop(crate::db::open(&placeholder_path).unwrap());
run(&placeholder_path, args, true, &mut out).expect("sign-seed");
let s = String::from_utf8(stdout).unwrap();
assert!(s.contains("rules.sign-seed"), "got: {s}");
}
#[cfg(unix)]
#[test]
fn run_rules_sign_seed_reuses_open_conn_when_no_db_override() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let dir2 = tempfile::tempdir().unwrap();
let key_file = dir2.path().join("operator.key");
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
keygen_operator(&key_file, false, &mut out).unwrap();
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::SignSeed {
key: Some(key_file),
db: None,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
run(&db_path, args, false, &mut out).expect("sign-seed reuse");
}
#[cfg(unix)]
fn assert_sign_seed_succeeds_with_key_dir_only(
db_path: &std::path::Path,
key_dir: std::path::PathBuf,
) {
let conn = rusqlite::Connection::open(db_path).unwrap();
rules_store::insert(
&conn,
&Rule {
id: "R-822".into(),
kind: "bash".into(),
matcher: r#"{"command_regex":"^x"}"#.into(),
severity: "refuse".into(),
reason: "t".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
},
)
.unwrap();
drop(conn);
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::SignSeed {
key: None, db: None,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let result = run(db_path, args, true, &mut out);
let stderr_s = String::from_utf8_lossy(&stderr).to_string();
assert!(
result.is_ok(),
"#822: sign-seed must honor --key-dir; got err={result:?} stderr={stderr_s}"
);
let s = String::from_utf8(stdout).unwrap();
assert!(s.contains("rules.sign-seed"), "got: {s}");
}
#[cfg(unix)]
#[test]
fn run_rules_sign_seed_honors_key_dir_layout_key() {
let (dir, db_path, _kp_key_dir) = fresh_env_with_operator_key();
let key_dir = dir.path().join("keys-822-key");
std::fs::create_dir_all(&key_dir).unwrap();
let key_file = key_dir.join("operator.key");
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
keygen_operator(&key_file, false, &mut out).unwrap();
assert!(key_file.exists(), "keygen must lay down operator.key");
assert!(
!key_dir.join("operator.priv").exists(),
"this branch must not have the .priv layout present"
);
assert_sign_seed_succeeds_with_key_dir_only(&db_path, key_dir);
}
#[cfg(unix)]
#[test]
fn run_rules_sign_seed_honors_key_dir_layout_priv() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
assert!(
key_dir.join("operator.priv").exists(),
"fresh_env_with_operator_key must lay down operator.priv"
);
assert!(
!key_dir.join("operator.key").exists(),
"this branch must not have the .key layout present"
);
assert_sign_seed_succeeds_with_key_dir_only(&db_path, key_dir);
}
#[cfg(unix)]
#[test]
fn run_rules_sign_seed_neither_layout_falls_through_to_legacy_path_and_errors() {
static HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = HOME_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let prev_home = std::env::var("HOME").ok();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("ai-memory.db");
drop(crate::db::open(&db_path).expect("db::open"));
let key_dir = dir.path().join("empty-keys");
std::fs::create_dir_all(&key_dir).expect("mkdir empty-keys");
assert!(
!key_dir.join("operator.key").exists() && !key_dir.join("operator.priv").exists(),
"preconditions: neither layout may exist for this branch"
);
let fake_home = dir.path().join("fake-home");
let fake_xdg = dir.path().join("fake-xdg-config");
std::fs::create_dir_all(&fake_home).unwrap();
std::fs::create_dir_all(&fake_xdg).unwrap();
unsafe {
std::env::set_var("HOME", &fake_home);
std::env::set_var("XDG_CONFIG_HOME", &fake_xdg);
}
let conn = rusqlite::Connection::open(&db_path).unwrap();
rules_store::insert(
&conn,
&Rule {
id: "R-827".into(),
kind: "bash".into(),
matcher: r#"{"command_regex":"^x"}"#.into(),
severity: "refuse".into(),
reason: "t".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
},
)
.unwrap();
drop(conn);
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::SignSeed {
key: None, db: None,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let result = run(&db_path, args, true, &mut out);
unsafe {
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
let err = result
.expect_err("#827: third branch must Err, not silently succeed against real $HOME");
let msg = format!("{err:#}");
assert!(
msg.contains("operator.key"),
"#827: error must cite the legacy operator.key fallback path; got: {msg}"
);
assert!(
msg.contains("sign-seed") || msg.contains("rules.sign-seed"),
"#827: error must surface from the sign-seed verb; got: {msg}"
);
}
#[test]
fn resolve_key_dir_returns_override() {
let p = std::path::PathBuf::from("/some/explicit/dir");
let out = resolve_key_dir(Some(&p)).unwrap();
assert_eq!(out, p);
}
#[test]
fn resolve_operator_key_path_returns_override() {
let p = std::path::PathBuf::from("/custom/operator.key");
let out = resolve_operator_key_path(Some(&p)).unwrap();
assert_eq!(out, p);
}
#[test]
fn resolve_operator_key_path_default_includes_ai_memory() {
let p = resolve_operator_key_path(None).unwrap();
let s = p.display().to_string();
assert!(
s.contains("ai-memory"),
"default path missing ai-memory: {s}"
);
assert!(s.ends_with("operator.key"), "got: {s}");
}
#[test]
fn resolve_keygen_out_path_explicit_out_wins_1610() {
let out = std::path::PathBuf::from("/custom/operator.key");
let kd = std::path::PathBuf::from("/etc/ai-memory/keys");
let r = resolve_keygen_out_path(Some(&out), &kd, true).unwrap();
assert_eq!(r, out, "--out must win over a key-dir override");
}
#[test]
fn resolve_keygen_out_path_overridden_key_dir_wins_1610() {
let kd = std::path::PathBuf::from("/etc/ai-memory/keys");
let r = resolve_keygen_out_path(None, &kd, true).unwrap();
assert_eq!(r, kd.join(OPERATOR_KEY_FILENAME));
}
#[test]
fn resolve_keygen_out_path_no_override_falls_back_to_legacy_singleton_1610() {
let kd = std::path::PathBuf::from("/ignored/keys");
let r = resolve_keygen_out_path(None, &kd, false).unwrap();
let s = r.display().to_string();
assert!(s.contains("ai-memory"), "legacy singleton path: {s}");
assert!(
!s.starts_with("/ignored"),
"must NOT use key_dir when no override is in force: {s}"
);
}
#[test]
fn emit_ok_human_format_emits_pretty_json() {
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let payload = serde_json::json!({"foo":"bar","n":1});
emit_ok(false, &mut out, "test.verb", &payload).unwrap();
let s = String::from_utf8(stdout).unwrap();
assert!(s.contains("\"foo\": \"bar\""), "got: {s}");
assert!(s.contains("\n"), "pretty must include newlines: {s}");
}
#[test]
fn emit_ok_json_format_envelopes_under_verb() {
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let payload = serde_json::json!({"x":1});
emit_ok(true, &mut out, "test.verb", &payload).unwrap();
let s = String::from_utf8(stdout).unwrap();
assert!(s.contains("\"verb\":\"test.verb\""), "got: {s}");
assert!(s.contains("\"result\":{\"x\":1}"), "got: {s}");
}
#[test]
fn resolve_agent_id_returns_non_empty() {
let id = resolve_agent_id();
assert!(!id.is_empty());
}
#[cfg(unix)]
#[test]
fn sign_seed_rules_is_idempotent() {
let tdir = tempfile::tempdir().unwrap();
let key_path = tdir.path().join("operator.key");
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
keygen_operator(&key_path, false, &mut out).unwrap();
let conn = fresh_rules_conn();
rules_store::insert(
&conn,
&Rule {
id: "R001".into(),
kind: "filesystem_write".into(),
matcher: r#"{"glob":"/tmp/**"}"#.into(),
severity: "refuse".into(),
reason: "t".into(),
namespace: "_global".into(),
created_by: "system:seed".into(),
created_at: 0,
enabled: false,
signature: None,
attest_level: "unsigned".into(),
},
)
.unwrap();
let signed1 = sign_seed_rules(&conn, Some(&key_path), true, &mut out).unwrap();
assert_eq!(signed1, 1);
let sig_after_first = rules_store::get(&conn, "R001").unwrap().unwrap().signature;
let signed2 = sign_seed_rules(&conn, Some(&key_path), true, &mut out).unwrap();
assert_eq!(signed2, 0);
let sig_after_second = rules_store::get(&conn, "R001").unwrap().unwrap().signature;
assert_eq!(
sig_after_first, sig_after_second,
"idempotent sign-seed must preserve the existing signature bytes"
);
}
#[cfg(unix)]
#[test]
fn rules_add_command_regex_only_fires_deprecation_branch_and_lands_rule() {
let _g = forensic_lock();
let tdir = tempfile::tempdir().unwrap();
let key_path = tdir.path().join("operator.key");
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
keygen_operator(&key_path, false, &mut out).unwrap();
let db_path = tdir.path().join("rules.db");
drop(crate::storage::open(&db_path).expect("init schema"));
let args = RulesArgs {
key_dir: Some(tdir.path().to_path_buf()),
action: RulesAction::Add {
id: "R900-cov".into(),
kind: "bash".into(),
matcher: r#"{"command_regex":"rm -rf"}"#.into(),
severity: "refuse".into(),
reason: "coverage: deprecated-field branch".into(),
namespace: crate::quotas::GLOBAL_NAMESPACE.into(),
disabled: false,
sign: true,
},
};
run(&db_path, args, false, &mut out).expect("rules add --sign");
let conn = rusqlite::Connection::open(&db_path).unwrap();
let rule = rules_store::get(&conn, "R900-cov")
.unwrap()
.expect("rule landed");
assert!(
rule.signature.is_some(),
"rules add --sign must store a signature"
);
assert_eq!(rule.namespace, crate::quotas::GLOBAL_NAMESPACE);
}
#[test]
fn rules_add_namespace_clap_default_is_global() {
use clap::Parser;
#[derive(Parser)]
struct Harness {
#[command(flatten)]
rules: RulesArgs,
}
let h = Harness::try_parse_from([
"harness",
"add",
"--id",
"RX",
"--kind",
"bash",
"--matcher",
"{}",
"--reason",
"cov",
"--sign",
])
.expect("parse");
match h.rules.action {
RulesAction::Add { namespace, .. } => {
assert_eq!(namespace, crate::quotas::GLOBAL_NAMESPACE);
}
_ => panic!("expected Add"),
}
}
struct FailingWriter;
impl std::io::Write for FailingWriter {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"test writer: broken pipe",
))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[cfg(unix)]
#[test]
fn run_rules_sign_seed_db_override_open_failure_errors() {
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let bad_db = _dir.path().join("no-such-dir").join("x.db");
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::SignSeed {
key: None,
db: Some(bad_db),
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let err = run(&db_path, args, true, &mut out).expect_err("open must fail");
let msg = format!("{err:#}");
assert!(msg.contains("rules.sign-seed: open db"), "got: {msg}");
}
#[cfg(unix)]
#[test]
fn keygen_create_parent_dir_failure_errors() {
let dir = tempfile::tempdir().unwrap();
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, b"i am a file").unwrap();
let key_path = blocker.join("sub").join("op.key");
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let err = keygen_operator(&key_path, false, &mut out).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("create parent dir"), "got: {msg}");
}
#[cfg(unix)]
#[test]
fn keygen_force_warning_broken_pipe_propagates() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("operator.key");
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
keygen_operator(&key_path, false, &mut out).expect("first keygen");
let mut failing = FailingWriter;
let mut stdout2: Vec<u8> = Vec::new();
let mut out2 = CliOutput {
stdout: &mut stdout2,
stderr: &mut failing,
};
let res = keygen_operator(&key_path, true, &mut out2);
assert!(res.is_err(), "stderr write failure must propagate");
}
#[cfg(unix)]
#[test]
fn keygen_success_line_broken_pipe_propagates() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("operator.key");
let mut failing = FailingWriter;
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut failing,
stderr: &mut stderr,
};
let res = keygen_operator(&key_path, false, &mut out);
assert!(res.is_err(), "stdout write failure must propagate");
assert!(key_path.exists(), "key material still lands on disk");
}
#[cfg(unix)]
#[test]
fn sign_seed_update_signature_failure_propagates() {
let tdir = tempfile::tempdir().unwrap();
let key_path = tdir.path().join("operator.key");
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
keygen_operator(&key_path, false, &mut out).unwrap();
let conn = fresh_rules_conn();
rules_store::insert(
&conn,
&Rule {
id: "R-fail-upd".into(),
kind: "bash".into(),
matcher: r#"{"command_substring":"x"}"#.into(),
severity: "refuse".into(),
reason: "t".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: false,
signature: None,
attest_level: "unsigned".into(),
},
)
.unwrap();
conn.execute_batch(
"CREATE TRIGGER test_fail_sig_update BEFORE UPDATE ON governance_rules \
BEGIN SELECT RAISE(ABORT, 'test trigger: signature update refused'); END;",
)
.unwrap();
let err = sign_seed_rules(&conn, Some(&key_path), true, &mut out).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("signature update refused"), "got: {msg}");
}
#[cfg(unix)]
#[test]
fn mutation_verb_legacy_layout_load_failure_cites_key_dir() {
use std::os::unix::fs::PermissionsExt;
let (_dir, db_path, key_dir) = fresh_env_with_operator_key();
let priv_path = key_dir.join("operator.priv");
std::fs::set_permissions(&priv_path, std::fs::Permissions::from_mode(0o644)).unwrap();
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::Enable {
id: "R-any".into(),
sign: true,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let err = run(&db_path, args, false, &mut out).expect_err("must refuse");
let msg = format!("{err:#}");
assert!(
msg.contains("failed loading operator.priv/operator.pub"),
"got: {msg}"
);
std::fs::set_permissions(&priv_path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
#[test]
fn list_on_fresh_unmigrated_db_succeeds() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("fresh-never-migrated.db");
assert!(!db_path.exists(), "db must not exist before the rules call");
let args = RulesArgs {
key_dir: None,
action: RulesAction::List,
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
run(&db_path, args, true, &mut out).expect("rules list must succeed on a fresh db");
let s = String::from_utf8(stdout).unwrap();
assert!(
s.contains("\"verb\":\"rules.list\"") && s.contains("R001"),
"expected the seeded rules from the migrated fresh db, got: {s}"
);
}
#[cfg(unix)]
fn fresh_env_with_keygen_layout() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf)
{
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("ai-memory.db");
drop(crate::db::open(&db_path).expect("db::open"));
let key_dir = dir.path().join("keys-l2");
std::fs::create_dir_all(&key_dir).expect("mkdir");
let key_file = key_dir.join(OPERATOR_KEY_FILENAME);
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
keygen_operator(&key_file, false, &mut out).expect("keygen");
(dir, db_path, key_dir)
}
#[cfg(unix)]
fn enable_err_with_key_dir(db_path: &Path, key_dir: std::path::PathBuf) -> anyhow::Error {
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::Enable {
id: "R-never".into(),
sign: true,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
run(db_path, args, false, &mut out).expect_err("must error")
}
#[cfg(unix)]
#[test]
fn keygen_layout_pub_not_base64_refused() {
let (_dir, db_path, key_dir) = fresh_env_with_keygen_layout();
std::fs::write(key_dir.join("operator.key.pub"), "!!!not-base64!!!").unwrap();
let err = enable_err_with_key_dir(&db_path, key_dir);
let msg = format!("{err:#}");
assert!(msg.contains("decode base64url public key"), "got: {msg}");
}
#[cfg(unix)]
#[test]
fn keygen_layout_pub_wrong_length_refused() {
use base64::Engine;
let (_dir, db_path, key_dir) = fresh_env_with_keygen_layout();
let short = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 16]);
std::fs::write(key_dir.join("operator.key.pub"), short).unwrap();
let err = enable_err_with_key_dir(&db_path, key_dir);
let msg = format!("{err:#}");
assert!(msg.contains("decoded to 16 bytes"), "got: {msg}");
}
#[cfg(unix)]
#[test]
fn keygen_layout_pub_mismatch_refused() {
use base64::Engine;
let (_dir, db_path, key_dir) = fresh_env_with_keygen_layout();
let other = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([9u8; 32]);
std::fs::write(key_dir.join("operator.key.pub"), other).unwrap();
let err = enable_err_with_key_dir(&db_path, key_dir);
let msg = format!("{err:#}");
assert!(msg.contains("does not match public key"), "got: {msg}");
}
#[cfg(unix)]
fn fresh_env_with_parent_keygen_layout()
-> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("ai-memory.db");
drop(crate::db::open(&db_path).expect("db::open"));
let key_file = dir.path().join(OPERATOR_KEY_FILENAME);
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
keygen_operator(&key_file, false, &mut out).expect("keygen");
let key_dir = dir.path().join("keys");
std::fs::create_dir_all(&key_dir).expect("mkdir keys");
(dir, db_path, key_dir)
}
#[cfg(unix)]
#[test]
fn parent_dir_keygen_fallback_signs_mutation_verbs() {
let (_dir, db_path, key_dir) = fresh_env_with_parent_keygen_layout();
let args = RulesArgs {
key_dir: Some(key_dir),
action: RulesAction::Add {
id: "R-l3".into(),
kind: "bash".into(),
matcher: r#"{"command_substring":"halt"}"#.into(),
severity: "refuse".into(),
reason: "layout-3 coverage".into(),
namespace: "_global".into(),
disabled: false,
sign: true,
},
};
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
run(&db_path, args, false, &mut out).expect("layout-3 add --sign");
let conn = rusqlite::Connection::open(&db_path).unwrap();
let r = rules_store::get(&conn, "R-l3")
.unwrap()
.expect("rule landed");
assert_eq!(r.attest_level, OPERATOR_SIGNED_LEVEL);
assert!(r.signature.is_some());
}
#[cfg(unix)]
#[test]
fn parent_dir_keygen_fallback_pub_wrong_length_refused() {
use base64::Engine;
let (dir, db_path, key_dir) = fresh_env_with_parent_keygen_layout();
let short = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([3u8; 8]);
std::fs::write(dir.path().join("operator.key.pub"), short).unwrap();
let err = enable_err_with_key_dir(&db_path, key_dir);
let msg = format!("{err:#}");
assert!(msg.contains("decoded to 8 bytes"), "got: {msg}");
}
#[cfg(unix)]
#[test]
fn parent_dir_keygen_fallback_pub_mismatch_refused() {
use base64::Engine;
let (dir, db_path, key_dir) = fresh_env_with_parent_keygen_layout();
let other = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([4u8; 32]);
std::fs::write(dir.path().join("operator.key.pub"), other).unwrap();
let err = enable_err_with_key_dir(&db_path, key_dir);
let msg = format!("{err:#}");
assert!(msg.contains("does not match public key"), "got: {msg}");
}
#[cfg(unix)]
#[test]
fn no_operator_key_anywhere_names_all_layouts() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("ai-memory.db");
drop(crate::db::open(&db_path).expect("db::open"));
let key_dir = dir.path().join("empty-parent").join("empty-keys");
std::fs::create_dir_all(&key_dir).unwrap();
let err = enable_err_with_key_dir(&db_path, key_dir);
let msg = format!("{err:#}");
assert!(msg.contains("no operator key found"), "got: {msg}");
assert!(msg.contains("operator.priv"), "got: {msg}");
assert!(msg.contains("rules keygen"), "got: {msg}");
}
}