use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use rusqlite::{Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use crate::governance::rule_cache::RuleCache;
use crate::governance::rules_store::Rule;
use crate::signed_events::{append_signed_event, payload_hash};
pub(crate) const MATCHER_COMMAND_SUBSTRING: &str = "command_substring";
pub(crate) const MATCHER_COMMAND_REGEX: &str = "command_regex";
pub const GOVERNANCE_CHECK_EVENT_TYPE: &str = "governance.check";
pub mod action_kinds {
pub const BASH: &str = "bash";
pub const FILESYSTEM_WRITE: &str = "filesystem_write";
pub const NETWORK_REQUEST: &str = "network_request";
pub const PROCESS_SPAWN: &str = "process_spawn";
pub const CUSTOM: &str = "custom";
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AgentAction {
Bash {
command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
cwd: Option<PathBuf>,
},
FilesystemWrite {
path: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
byte_estimate: Option<u64>,
},
NetworkRequest {
host: String,
#[serde(default)]
scheme: String,
},
ProcessSpawn {
binary: String,
#[serde(default)]
args: Vec<String>,
},
Custom {
custom_kind: String,
payload: serde_json::Value,
},
}
impl AgentAction {
#[must_use]
pub fn kind(&self) -> &str {
match self {
AgentAction::Bash { .. } => action_kinds::BASH,
AgentAction::FilesystemWrite { .. } => action_kinds::FILESYSTEM_WRITE,
AgentAction::NetworkRequest { .. } => action_kinds::NETWORK_REQUEST,
AgentAction::ProcessSpawn { .. } => action_kinds::PROCESS_SPAWN,
AgentAction::Custom { .. } => action_kinds::CUSTOM,
}
}
pub fn canonical_bytes(&self) -> Result<Vec<u8>> {
let val = serde_json::to_value(self)
.context("agent_action canonical_bytes: serialize AgentAction")?;
serde_json::to_vec(&val).context("agent_action canonical_bytes: re-serialize Value to vec")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "decision", rename_all = "snake_case")]
pub enum Decision {
Allow,
Refuse { rule_id: String, reason: String },
Warn { rule_id: String, reason: String },
}
impl Decision {
#[must_use]
pub fn is_refusal(&self) -> bool {
matches!(self, Decision::Refuse { .. })
}
#[must_use]
pub fn is_allowed(&self) -> bool {
!self.is_refusal()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Refuse,
Warn,
Log,
}
impl Severity {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Severity::Refuse => "refuse",
Severity::Warn => "warn",
Severity::Log => "log",
}
}
#[must_use]
pub fn from_str(s: &str) -> Option<Severity> {
match s {
"refuse" => Some(Severity::Refuse),
"warn" => Some(Severity::Warn),
"log" => Some(Severity::Log),
_ => None,
}
}
}
#[must_use]
pub fn matcher_applies(rule: &Rule, action: &AgentAction) -> bool {
if rule.kind != action.kind() {
return false;
}
let Ok(matcher) = serde_json::from_str::<serde_json::Value>(&rule.matcher) else {
return false;
};
match action {
AgentAction::Bash { command, .. } => match_bash(&matcher, command),
AgentAction::FilesystemWrite { path, .. } => match_filesystem_write(&matcher, path),
AgentAction::NetworkRequest { host, .. } => match_network_request(&matcher, host),
AgentAction::ProcessSpawn { binary, args } => match_process_spawn(&matcher, binary, args),
AgentAction::Custom {
custom_kind,
payload,
} => match_custom(&matcher, custom_kind, payload),
}
}
pub fn validate_command_substring(value: &str) -> Result<(), String> {
if value.is_empty() {
return Err("command_substring must not be empty".to_string());
}
const FORBIDDEN: &[char] = &['.', '*', '+', '?', '[', ']', '(', ')', '^', '$', '|', '\\'];
if let Some(pos) = value.find(|c: char| FORBIDDEN.contains(&c)) {
let offending = value.as_bytes()[pos] as char;
return Err(format!(
"command_substring rejects regex metacharacter {offending:?} at byte {pos}: \
the matcher is a LITERAL substring match (despite the legacy `command_regex` \
field name). Quote the literal text you want to match, e.g. `\"rm -rf\"` \
rather than `\"rm\\s+-rf\"`. If you need true regex semantics, file an issue \
— the engine will gain a typed `command_regex` discriminator in a future ship."
));
}
Ok(())
}
fn match_bash(matcher: &serde_json::Value, command: &str) -> bool {
let needle = matcher
.get(MATCHER_COMMAND_SUBSTRING)
.or_else(|| matcher.get(MATCHER_COMMAND_REGEX))
.and_then(|v| v.as_str());
let Some(needle) = needle else {
return false;
};
command.contains(needle)
}
fn match_filesystem_write(matcher: &serde_json::Value, path: &std::path::Path) -> bool {
let Some(glob) = matcher.get("glob").and_then(|v| v.as_str()) else {
return false;
};
let path_str = path.to_string_lossy();
crate::governance::glob_matches(glob, &path_str)
}
fn match_network_request(matcher: &serde_json::Value, host: &str) -> bool {
let Some(target_host) = matcher.get("host").and_then(|v| v.as_str()) else {
return false;
};
crate::governance::glob_matches(target_host, host)
}
fn match_process_spawn(matcher: &serde_json::Value, binary: &str, args: &[String]) -> bool {
let Some(target_binary) = matcher.get("binary").and_then(|v| v.as_str()) else {
return false;
};
if target_binary != binary {
return false;
}
if let Some(needle) = matcher.get("args_contain").and_then(|v| v.as_str()) {
let joined = args.join(" ");
if !joined.contains(needle) {
return false;
}
}
if let Some(threshold) = matcher
.get("disk_free_min_gib")
.and_then(serde_json::Value::as_u64)
{
let free_gib = match disk_free_gib_at_root() {
Some(g) => g,
None => return false,
};
return free_gib < threshold;
}
true
}
fn match_custom(matcher: &serde_json::Value, kind: &str, payload: &serde_json::Value) -> bool {
let Some(target_kind) = matcher.get("kind").and_then(|v| v.as_str()) else {
return false;
};
if target_kind != kind {
return false;
}
if let Some(ns_glob) = matcher.get("namespace_glob").and_then(|v| v.as_str()) {
let Some(ns) = payload.get("namespace").and_then(|v| v.as_str()) else {
return false;
};
if !crate::governance::glob_matches(ns_glob, ns) {
return false;
}
}
if let Some(target_tier) = matcher.get("tier").and_then(|v| v.as_str()) {
let Some(tier) = payload.get("tier").and_then(|v| v.as_str()) else {
return false;
};
if target_tier != tier {
return false;
}
}
if let Some(needle) = matcher.get("title_contains").and_then(|v| v.as_str()) {
let Some(title) = payload.get("title").and_then(|v| v.as_str()) else {
return false;
};
if !title.contains(needle) {
return false;
}
}
true
}
#[must_use]
fn disk_free_gib_at_root() -> Option<u64> {
disk_free_gib_at_path(std::path::Path::new("/"))
}
#[cfg(unix)]
fn disk_free_gib_at_path(path: &std::path::Path) -> Option<u64> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
let mut buf: libc::statvfs = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::statvfs(c_path.as_ptr(), &raw mut buf) };
if rc != 0 {
return None;
}
let free_bytes = u64::from(buf.f_bavail).saturating_mul(u64::from(buf.f_frsize));
Some(free_bytes / (1024 * 1024 * 1024))
}
#[cfg(not(unix))]
fn disk_free_gib_at_path(_path: &std::path::Path) -> Option<u64> {
None
}
pub struct RuleEngine {
rules: Arc<Vec<Rule>>,
}
impl RuleEngine {
pub fn load_for_action(conn: &Connection, action: &AgentAction) -> Result<Self> {
Self::load_for_action_cached(conn, None, action)
}
pub fn load_for_action_cached(
conn: &Connection,
cache: Option<&RuleCache>,
action: &AgentAction,
) -> Result<Self> {
let kind = action.kind();
let rules = if let Some(c) = cache {
c.get_or_load(conn, kind).with_context(|| {
format!("RuleEngine::load_for_action_cached: get_or_load({kind})")
})?
} else {
let v = crate::governance::rules_store::list_enabled_by_kind(conn, kind).with_context(
|| format!("RuleEngine::load_for_action: list_enabled_by_kind({kind})"),
)?;
Arc::new(v)
};
Ok(Self { rules })
}
#[must_use]
pub fn from_rules(rules: Vec<Rule>) -> Self {
Self {
rules: Arc::new(rules),
}
}
#[must_use]
pub fn evaluate(&self, _agent_id: &str, action: &AgentAction) -> Decision {
let mut first_warn: Option<(String, String)> = None;
for rule in self.rules.iter() {
if !matcher_applies(rule, action) {
continue;
}
let severity = Severity::from_str(&rule.severity).unwrap_or(Severity::Log);
match severity {
Severity::Refuse => {
return Decision::Refuse {
rule_id: rule.id.clone(),
reason: rule.reason.clone(),
};
}
Severity::Warn => {
if first_warn.is_none() {
first_warn = Some((rule.id.clone(), rule.reason.clone()));
}
}
Severity::Log => {
}
}
}
match first_warn {
Some((rule_id, reason)) => Decision::Warn { rule_id, reason },
None => Decision::Allow,
}
}
#[must_use]
pub fn rules(&self) -> &[Rule] {
&self.rules
}
}
pub fn check_agent_action(
conn: &Connection,
agent_id: &str,
action: &AgentAction,
) -> Result<Decision> {
check_agent_action_cached(conn, None, agent_id, action)
}
pub fn check_agent_action_cached(
conn: &Connection,
cache: Option<&RuleCache>,
agent_id: &str,
action: &AgentAction,
) -> Result<Decision> {
let engine = RuleEngine::load_for_action_cached(conn, cache, action).with_context(|| {
format!(
"check_agent_action_cached: load engine for {}",
action.kind()
)
})?;
let decision = engine.evaluate(agent_id, action);
emit_check_event(conn, agent_id, action, &decision)?;
emit_forensic_decision(agent_id, action, &decision);
Ok(decision)
}
fn emit_forensic_decision(agent_id: &str, action: &AgentAction, decision: &Decision) {
let (decision_str, rule_id) = match decision {
Decision::Allow => ("allow", String::new()),
Decision::Refuse { rule_id, .. } => ("refuse", rule_id.clone()),
Decision::Warn { rule_id, .. } => ("warn", rule_id.clone()),
};
let payload = serde_json::json!({
"action": action,
"decision_detail": decision,
});
crate::governance::audit::record_decision(
agent_id,
decision_str,
action.kind(),
&rule_id,
payload,
);
}
fn emit_check_event(
conn: &Connection,
agent_id: &str,
action: &AgentAction,
decision: &Decision,
) -> Result<()> {
let canonical = serde_json::json!({
"action": action,
"decision": decision,
});
let bytes =
serde_json::to_vec(&canonical).context("emit_check_event: serialize canonical payload")?;
let hash = payload_hash(&bytes);
let (signature, attest_level) = match crate::governance::audit::try_sign_audit_payload(&hash) {
Some((sig, level)) => (Some(sig), level.to_string()),
None => (
None,
crate::models::AttestLevel::Unsigned.as_str().to_string(),
),
};
let event = crate::signed_events::SignedEvent {
id: uuid::Uuid::new_v4().to_string(),
agent_id: agent_id.to_string(),
event_type: GOVERNANCE_CHECK_EVENT_TYPE.to_string(),
payload_hash: hash,
signature,
attest_level,
timestamp: chrono::Utc::now().to_rfc3339(),
..crate::signed_events::SignedEvent::default()
};
append_signed_event(conn, &event).context("emit_check_event: append_signed_event")?;
Ok(())
}
pub fn check_agent_action_no_audit(conn: &Connection, action: &AgentAction) -> Result<Decision> {
check_agent_action_no_audit_cached(conn, None, action)
}
pub fn check_agent_action_no_audit_cached(
conn: &Connection,
cache: Option<&RuleCache>,
action: &AgentAction,
) -> Result<Decision> {
let engine = RuleEngine::load_for_action_cached(conn, cache, action).with_context(|| {
format!(
"check_agent_action_no_audit_cached: load engine for {}",
action.kind()
)
})?;
let decision = engine.evaluate("", action);
emit_forensic_decision("", action, &decision);
Ok(decision)
}
pub fn check_agent_action_deferred(
conn: &Connection,
agent_id: &str,
action: &AgentAction,
queue: &crate::governance::deferred_audit::DeferredAuditQueue,
) -> Result<Decision> {
check_agent_action_deferred_cached(conn, None, agent_id, action, queue)
}
pub fn check_agent_action_deferred_cached(
conn: &Connection,
cache: Option<&RuleCache>,
agent_id: &str,
action: &AgentAction,
queue: &crate::governance::deferred_audit::DeferredAuditQueue,
) -> Result<Decision> {
let decision = check_agent_action_no_audit_cached(conn, cache, action)?;
if decision.is_refusal() {
queue.submit_refusal(agent_id, action, &decision);
}
Ok(decision)
}
pub fn count_matching_rules(conn: &Connection, action: &AgentAction) -> Result<usize> {
let engine = RuleEngine::load_for_action(conn, action)
.with_context(|| format!("count_matching_rules: load engine for {}", action.kind()))?;
Ok(engine
.rules()
.iter()
.filter(|r| matcher_applies(r, action))
.count())
}
pub fn most_recent_check(conn: &Connection, agent_id: Option<&str>) -> Result<Option<String>> {
let row: Option<String> = if let Some(aid) = agent_id {
conn.query_row(
"SELECT timestamp FROM signed_events \
WHERE event_type = ?1 AND agent_id = ?2 \
ORDER BY timestamp DESC LIMIT 1",
rusqlite::params![GOVERNANCE_CHECK_EVENT_TYPE, aid],
|r| r.get::<_, String>(0),
)
.optional()?
} else {
conn.query_row(
"SELECT timestamp FROM signed_events \
WHERE event_type = ?1 \
ORDER BY timestamp DESC LIMIT 1",
rusqlite::params![GOVERNANCE_CHECK_EVENT_TYPE],
|r| r.get::<_, String>(0),
)
.optional()?
};
Ok(row)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::governance::rules_store;
fn fresh_conn() -> Connection {
let conn = 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,
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'
);
CREATE TABLE signed_events (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload_hash BLOB NOT NULL,
signature BLOB,
attest_level TEXT NOT NULL DEFAULT 'unsigned',
timestamp TEXT NOT NULL,
-- v34 (V-4 closeout, #698) — cross-row chain columns.
prev_hash BLOB,
sequence INTEGER
);",
)
.unwrap();
conn
}
#[must_use = "the guard must be held for the scope of the test"]
fn no_operator_pubkey() -> rules_store::ForceNoOperatorPubkeyGuard {
rules_store::force_no_operator_pubkey_for_test()
}
#[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())
}
fn add_rule(
conn: &Connection,
id: &str,
kind: &str,
matcher: &str,
severity: &str,
enabled: bool,
) {
rules_store::insert(
conn,
&Rule {
id: id.to_string(),
kind: kind.to_string(),
matcher: matcher.to_string(),
severity: severity.to_string(),
reason: format!("{id}: test"),
namespace: "_global".to_string(),
created_by: "test".to_string(),
created_at: 0,
enabled,
signature: None,
attest_level: crate::models::AttestLevel::Unsigned.as_str().to_string(),
},
)
.unwrap();
}
#[test]
fn agent_action_kind_strings_are_stable() {
assert_eq!(
AgentAction::Bash {
command: "ls".into(),
cwd: None
}
.kind(),
"bash"
);
assert_eq!(
AgentAction::FilesystemWrite {
path: "/x".into(),
byte_estimate: None
}
.kind(),
"filesystem_write"
);
assert_eq!(
AgentAction::NetworkRequest {
host: "h".into(),
scheme: "https".into()
}
.kind(),
"network_request"
);
assert_eq!(
AgentAction::ProcessSpawn {
binary: "b".into(),
args: vec![]
}
.kind(),
"process_spawn"
);
assert_eq!(
AgentAction::Custom {
custom_kind: "k".into(),
payload: serde_json::json!({})
}
.kind(),
"custom"
);
}
#[test]
fn severity_roundtrip() {
for s in &[Severity::Refuse, Severity::Warn, Severity::Log] {
assert_eq!(Severity::from_str(s.as_str()), Some(*s));
}
assert_eq!(Severity::from_str("nope"), None);
}
#[test]
fn allow_when_no_rule_matches() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let action = AgentAction::Bash {
command: "ls -la".into(),
cwd: None,
};
let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
assert_eq!(decision, Decision::Allow);
assert!(decision.is_allowed());
}
#[test]
fn refuse_filesystem_write_glob_match() {
let _forensic = forensic_lock();
let _no_pubkey = no_operator_pubkey();
let conn = fresh_conn();
add_rule(
&conn,
"R001",
"filesystem_write",
r#"{"glob":"/tmp/**"}"#,
"refuse",
true,
);
let action = AgentAction::FilesystemWrite {
path: "/tmp/foo.txt".into(),
byte_estimate: None,
};
let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
assert!(decision.is_refusal());
match decision {
Decision::Refuse { rule_id, .. } => assert_eq!(rule_id, "R001"),
_ => panic!("expected refuse"),
}
}
#[test]
fn allow_filesystem_write_outside_glob() {
let _forensic = forensic_lock();
let conn = fresh_conn();
add_rule(
&conn,
"R001",
"filesystem_write",
r#"{"glob":"/tmp/**"}"#,
"refuse",
true,
);
let action = AgentAction::FilesystemWrite {
path: "/Users/foo/safe.txt".into(),
byte_estimate: None,
};
let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
assert_eq!(decision, Decision::Allow);
}
#[test]
fn disabled_rule_does_not_match() {
let _forensic = forensic_lock();
let conn = fresh_conn();
add_rule(
&conn,
"R001",
"filesystem_write",
r#"{"glob":"/tmp/**"}"#,
"refuse",
false, );
let action = AgentAction::FilesystemWrite {
path: "/tmp/foo".into(),
byte_estimate: None,
};
let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
assert_eq!(decision, Decision::Allow);
}
#[test]
fn warn_rule_returns_warn_not_refuse() {
let _forensic = forensic_lock();
let _no_pubkey = no_operator_pubkey();
let conn = fresh_conn();
add_rule(
&conn,
"W001",
"bash",
r#"{"command_regex":"rm -rf"}"#,
"warn",
true,
);
let action = AgentAction::Bash {
command: "rm -rf /opt/scratch".into(),
cwd: None,
};
let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
match decision {
Decision::Warn { rule_id, .. } => assert_eq!(rule_id, "W001"),
_ => panic!("expected warn"),
}
}
#[test]
fn refuse_wins_over_warn_when_both_match() {
let _forensic = forensic_lock();
let _no_pubkey = no_operator_pubkey();
let conn = fresh_conn();
add_rule(
&conn,
"W001",
"bash",
r#"{"command_regex":"rm"}"#,
"warn",
true,
);
add_rule(
&conn,
"R900",
"bash",
r#"{"command_regex":"rm -rf /"}"#,
"refuse",
true,
);
let action = AgentAction::Bash {
command: "rm -rf /".into(),
cwd: None,
};
let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
assert!(decision.is_refusal());
}
#[test]
fn process_spawn_binary_match() {
let _forensic = forensic_lock();
let _no_pubkey = no_operator_pubkey();
let conn = fresh_conn();
add_rule(
&conn,
"R-cargo",
"process_spawn",
r#"{"binary":"cargo"}"#,
"refuse",
true,
);
let action = AgentAction::ProcessSpawn {
binary: "cargo".into(),
args: vec!["build".into()],
};
let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
assert!(decision.is_refusal());
}
#[test]
fn process_spawn_binary_mismatch_allows() {
let _forensic = forensic_lock();
let conn = fresh_conn();
add_rule(
&conn,
"R-cargo",
"process_spawn",
r#"{"binary":"cargo"}"#,
"refuse",
true,
);
let action = AgentAction::ProcessSpawn {
binary: "npm".into(),
args: vec!["install".into()],
};
let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
assert_eq!(decision, Decision::Allow);
}
#[test]
fn network_request_exact_host_match() {
let _forensic = forensic_lock();
let _no_pubkey = no_operator_pubkey();
let conn = fresh_conn();
add_rule(
&conn,
"R-evil",
"network_request",
r#"{"host":"evil.example.com"}"#,
"refuse",
true,
);
let action = AgentAction::NetworkRequest {
host: "evil.example.com".into(),
scheme: "https".into(),
};
let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
assert!(decision.is_refusal());
let allow_action = AgentAction::NetworkRequest {
host: "good.example.com".into(),
scheme: "https".into(),
};
let allow_decision = check_agent_action(&conn, "agent:t", &allow_action).unwrap();
assert_eq!(allow_decision, Decision::Allow);
}
#[test]
fn network_request_glob_host_match_closes_fail_open() {
let _forensic = forensic_lock();
let _no_pubkey = no_operator_pubkey();
let conn = fresh_conn();
add_rule(
&conn,
"R-evil-glob",
"network_request",
r#"{"host":"*.evil.example.com"}"#,
"refuse",
true,
);
for sub in ["api.evil.example.com", "c2.evil.example.com"] {
let action = AgentAction::NetworkRequest {
host: sub.into(),
scheme: "https".into(),
};
let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
assert!(
decision.is_refusal(),
"wildcard DENY rule must refuse subdomain {sub}"
);
}
let allow_action = AgentAction::NetworkRequest {
host: "good.example.org".into(),
scheme: "https".into(),
};
assert_eq!(
check_agent_action(&conn, "agent:t", &allow_action).unwrap(),
Decision::Allow
);
}
#[test]
fn custom_action_matches_on_kind() {
let _forensic = forensic_lock();
let _no_pubkey = no_operator_pubkey();
let conn = fresh_conn();
add_rule(
&conn,
"R-custom",
"custom",
r#"{"kind":"approve_deploy"}"#,
"refuse",
true,
);
let action = AgentAction::Custom {
custom_kind: "approve_deploy".into(),
payload: serde_json::json!({"env": "prod"}),
};
let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
assert!(decision.is_refusal());
}
#[test]
fn custom_namespace_glob_predicate_scopes_refusal() {
let _forensic = forensic_lock();
let _no_pubkey = no_operator_pubkey();
let conn = fresh_conn();
add_rule(
&conn,
"R-ns",
"custom",
r#"{"kind":"memory_write","namespace_glob":"secure/**"}"#,
"refuse",
true,
);
let inside = AgentAction::Custom {
custom_kind: "memory_write".into(),
payload: serde_json::json!({"namespace": "secure/keys", "tier": "long"}),
};
assert!(
check_agent_action(&conn, "agent:t", &inside)
.unwrap()
.is_refusal()
);
let outside = AgentAction::Custom {
custom_kind: "memory_write".into(),
payload: serde_json::json!({"namespace": "public/notes", "tier": "long"}),
};
assert_eq!(
check_agent_action(&conn, "agent:t", &outside).unwrap(),
Decision::Allow
);
}
#[test]
fn custom_tier_and_title_predicates_and_together() {
let _forensic = forensic_lock();
let _no_pubkey = no_operator_pubkey();
let conn = fresh_conn();
add_rule(
&conn,
"R-tt",
"custom",
r#"{"kind":"memory_write","tier":"long","title_contains":"SECRET"}"#,
"refuse",
true,
);
let both = AgentAction::Custom {
custom_kind: "memory_write".into(),
payload: serde_json::json!({"tier": "long", "title": "the SECRET plan"}),
};
assert!(
check_agent_action(&conn, "agent:t", &both)
.unwrap()
.is_refusal()
);
let wrong_tier = AgentAction::Custom {
custom_kind: "memory_write".into(),
payload: serde_json::json!({"tier": "mid", "title": "the SECRET plan"}),
};
assert_eq!(
check_agent_action(&conn, "agent:t", &wrong_tier).unwrap(),
Decision::Allow
);
let wrong_title = AgentAction::Custom {
custom_kind: "memory_write".into(),
payload: serde_json::json!({"tier": "long", "title": "harmless note"}),
};
assert_eq!(
check_agent_action(&conn, "agent:t", &wrong_title).unwrap(),
Decision::Allow
);
}
#[test]
fn custom_predicate_missing_payload_field_does_not_match() {
let _forensic = forensic_lock();
let _no_pubkey = no_operator_pubkey();
let conn = fresh_conn();
add_rule(
&conn,
"R-miss",
"custom",
r#"{"kind":"memory_write","namespace_glob":"secure/**"}"#,
"refuse",
true,
);
let no_ns = AgentAction::Custom {
custom_kind: "memory_write".into(),
payload: serde_json::json!({"tier": "long"}),
};
assert_eq!(
check_agent_action(&conn, "agent:t", &no_ns).unwrap(),
Decision::Allow
);
}
#[test]
fn custom_kind_only_rule_ignores_payload() {
let _forensic = forensic_lock();
let _no_pubkey = no_operator_pubkey();
let conn = fresh_conn();
add_rule(
&conn,
"R-kindonly",
"custom",
r#"{"kind":"memory_write"}"#,
"refuse",
true,
);
let action = AgentAction::Custom {
custom_kind: "memory_write".into(),
payload: serde_json::json!({"namespace": "anything", "tier": "short"}),
};
assert!(
check_agent_action(&conn, "agent:t", &action)
.unwrap()
.is_refusal()
);
}
#[test]
fn check_emits_signed_event() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let action = AgentAction::Bash {
command: "ls".into(),
cwd: None,
};
let _ = check_agent_action(&conn, "agent:test", &action).unwrap();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM signed_events WHERE event_type = ?1 AND agent_id = ?2",
rusqlite::params![GOVERNANCE_CHECK_EVENT_TYPE, "agent:test"],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn deferred_check_allow_signs_nothing_on_request_thread() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let (queue, _rx) = crate::governance::deferred_audit::DeferredAuditQueue::new();
let action = AgentAction::Custom {
custom_kind: "memory_write".into(),
payload: serde_json::json!({"namespace": "anything", "tier": "short"}),
};
let decision =
check_agent_action_deferred_cached(&conn, None, "agent:hotpath", &action, &queue)
.unwrap();
assert_eq!(decision, Decision::Allow);
assert!(!decision.is_refusal());
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM signed_events", [], |r| r.get(0))
.unwrap();
assert_eq!(
count, 0,
"write-path governance gate must not synchronously sign on ALLOW; \
per-row Ed25519 signing belongs off the request thread"
);
}
#[test]
fn refuse_short_circuit_still_emits_event() {
let _forensic = forensic_lock();
let conn = fresh_conn();
add_rule(
&conn,
"R001",
"filesystem_write",
r#"{"glob":"/tmp/**"}"#,
"refuse",
true,
);
let action = AgentAction::FilesystemWrite {
path: "/tmp/x".into(),
byte_estimate: None,
};
let _ = check_agent_action(&conn, "agent:t", &action).unwrap();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM signed_events WHERE event_type = ?1",
rusqlite::params![GOVERNANCE_CHECK_EVENT_TYPE],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn count_matching_rules_skips_audit() {
let _no_pubkey = no_operator_pubkey();
let conn = fresh_conn();
add_rule(
&conn,
"R1",
"bash",
r#"{"command_regex":"foo"}"#,
"refuse",
true,
);
let action = AgentAction::Bash {
command: "foo bar".into(),
cwd: None,
};
assert_eq!(count_matching_rules(&conn, &action).unwrap(), 1);
let audit_count: i64 = conn
.query_row("SELECT COUNT(*) FROM signed_events", [], |r| r.get(0))
.unwrap();
assert_eq!(audit_count, 0);
}
#[test]
fn malformed_matcher_does_not_panic() {
let _forensic = forensic_lock();
let conn = fresh_conn();
add_rule(&conn, "R-bad", "bash", "not json", "refuse", true);
let action = AgentAction::Bash {
command: "anything".into(),
cwd: None,
};
let decision = check_agent_action(&conn, "agent:t", &action).unwrap();
assert_eq!(decision, Decision::Allow);
}
#[test]
fn matcher_applies_kind_mismatch_returns_false() {
let rule = Rule {
id: "R".to_string(),
kind: "bash".to_string(),
matcher: r#"{"command_regex":"x"}"#.to_string(),
severity: "refuse".to_string(),
reason: "r".to_string(),
namespace: "_global".to_string(),
created_by: "test".to_string(),
created_at: 0,
enabled: true,
signature: None,
attest_level: crate::models::AttestLevel::Unsigned.as_str().to_string(),
};
let action = AgentAction::FilesystemWrite {
path: "/x".into(),
byte_estimate: None,
};
assert!(!matcher_applies(&rule, &action));
}
#[test]
fn canonical_bytes_includes_kind() {
let a = AgentAction::Bash {
command: "ls".into(),
cwd: None,
};
let bytes = a.canonical_bytes().unwrap();
let s = std::str::from_utf8(&bytes).unwrap();
assert!(s.contains("\"kind\""), "got {s}");
assert!(s.contains("\"bash\""), "got {s}");
}
#[test]
fn most_recent_check_empty_returns_none() {
let conn = fresh_conn();
assert_eq!(most_recent_check(&conn, None).unwrap(), None);
assert_eq!(most_recent_check(&conn, Some("agent:x")).unwrap(), None);
}
#[test]
fn most_recent_check_returns_latest() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let action = AgentAction::Bash {
command: "x".into(),
cwd: None,
};
check_agent_action(&conn, "agent:a", &action).unwrap();
assert!(most_recent_check(&conn, Some("agent:a")).unwrap().is_some());
assert!(most_recent_check(&conn, Some("agent:b")).unwrap().is_none());
assert!(most_recent_check(&conn, None).unwrap().is_some());
}
#[test]
fn no_audit_allow_when_no_rule_matches() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let action = AgentAction::Bash {
command: "ls".into(),
cwd: None,
};
let decision = check_agent_action_no_audit(&conn, &action).unwrap();
assert_eq!(decision, Decision::Allow);
let audit_count: i64 = conn
.query_row("SELECT COUNT(*) FROM signed_events", [], |r| r.get(0))
.unwrap();
assert_eq!(audit_count, 0, "no_audit variant must not write audit rows");
}
#[test]
fn no_audit_refuses_with_same_shape_as_audited_path() {
let _forensic = forensic_lock();
let _no_pubkey = no_operator_pubkey();
let conn = fresh_conn();
add_rule(
&conn,
"R-test",
"custom",
r#"{"kind":"memory_write"}"#,
"refuse",
true,
);
let action = AgentAction::Custom {
custom_kind: "memory_write".into(),
payload: serde_json::json!({"namespace": "secrets/api"}),
};
let decision = check_agent_action_no_audit(&conn, &action).unwrap();
match decision {
Decision::Refuse { rule_id, reason } => {
assert_eq!(rule_id, "R-test");
assert!(reason.contains("R-test"), "reason: {reason}");
}
other => panic!("expected Refuse, got {other:?}"),
}
let audit_count: i64 = conn
.query_row("SELECT COUNT(*) FROM signed_events", [], |r| r.get(0))
.unwrap();
assert_eq!(audit_count, 0, "refusal in no_audit variant must not write");
}
#[test]
fn no_audit_disabled_rule_yields_allow() {
let _forensic = forensic_lock();
let conn = fresh_conn();
add_rule(
&conn,
"R-disabled",
"custom",
r#"{"kind":"memory_write"}"#,
"refuse",
false,
);
let action = AgentAction::Custom {
custom_kind: "memory_write".into(),
payload: serde_json::json!({}),
};
let decision = check_agent_action_no_audit(&conn, &action).unwrap();
assert_eq!(decision, Decision::Allow);
}
#[test]
fn no_audit_warn_returned_when_no_refuse_matches() {
let _forensic = forensic_lock();
let _no_pubkey = no_operator_pubkey();
let conn = fresh_conn();
add_rule(
&conn,
"W-test",
"custom",
r#"{"kind":"memory_write"}"#,
"warn",
true,
);
let action = AgentAction::Custom {
custom_kind: "memory_write".into(),
payload: serde_json::json!({}),
};
let decision = check_agent_action_no_audit(&conn, &action).unwrap();
match decision {
Decision::Warn { rule_id, .. } => assert_eq!(rule_id, "W-test"),
other => panic!("expected Warn, got {other:?}"),
}
}
#[test]
fn decision_serializes_as_tagged_enum() {
let d = Decision::Refuse {
rule_id: "R1".to_string(),
reason: "no".to_string(),
};
let v = serde_json::to_value(&d).unwrap();
assert_eq!(v["decision"], "refuse");
assert_eq!(v["rule_id"], "R1");
let allow = Decision::Allow;
let av = serde_json::to_value(&allow).unwrap();
assert_eq!(av["decision"], "allow");
}
#[test]
fn matcher_applies_returns_false_on_kind_mismatch() {
let rule = Rule {
id: "R".into(),
kind: "bash".into(),
matcher: r#"{"command_regex":"rm"}"#.into(),
severity: "refuse".into(),
reason: "r".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
};
let action = AgentAction::FilesystemWrite {
path: "/x".into(),
byte_estimate: None,
};
assert!(!matcher_applies(&rule, &action));
}
#[test]
fn matcher_applies_returns_false_on_malformed_matcher_json() {
let rule = Rule {
id: "R".into(),
kind: "bash".into(),
matcher: "{not valid json".into(),
severity: "refuse".into(),
reason: "r".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
};
let action = AgentAction::Bash {
command: "ls".into(),
cwd: None,
};
assert!(!matcher_applies(&rule, &action));
}
#[test]
fn matcher_applies_bash_with_missing_field_returns_false() {
let rule = Rule {
id: "R".into(),
kind: "bash".into(),
matcher: r#"{"other_field":"x"}"#.into(),
severity: "refuse".into(),
reason: "r".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
};
let action = AgentAction::Bash {
command: "ls".into(),
cwd: None,
};
assert!(!matcher_applies(&rule, &action));
}
#[test]
fn matcher_applies_network_request_exact_host() {
let rule = Rule {
id: "R".into(),
kind: "network_request".into(),
matcher: r#"{"host":"evil.example.com"}"#.into(),
severity: "refuse".into(),
reason: "r".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
};
let evil = AgentAction::NetworkRequest {
host: "evil.example.com".into(),
scheme: "https".into(),
};
let good = AgentAction::NetworkRequest {
host: "good.example.com".into(),
scheme: "https".into(),
};
assert!(matcher_applies(&rule, &evil));
assert!(!matcher_applies(&rule, &good));
}
#[test]
fn matcher_applies_process_spawn_with_binary_only() {
let rule = Rule {
id: "R".into(),
kind: "process_spawn".into(),
matcher: r#"{"binary":"cargo"}"#.into(),
severity: "refuse".into(),
reason: "r".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
};
let cargo = AgentAction::ProcessSpawn {
binary: "cargo".into(),
args: vec!["build".into()],
};
let other = AgentAction::ProcessSpawn {
binary: "ls".into(),
args: vec![],
};
assert!(matcher_applies(&rule, &cargo));
assert!(!matcher_applies(&rule, &other));
}
#[test]
fn matcher_applies_process_spawn_with_missing_binary_field() {
let rule = Rule {
id: "R".into(),
kind: "process_spawn".into(),
matcher: r#"{}"#.into(),
severity: "refuse".into(),
reason: "r".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
};
let action = AgentAction::ProcessSpawn {
binary: "cargo".into(),
args: vec![],
};
assert!(!matcher_applies(&rule, &action));
}
#[test]
fn matcher_applies_filesystem_write_missing_glob_field() {
let rule = Rule {
id: "R".into(),
kind: "filesystem_write".into(),
matcher: r#"{"other":"x"}"#.into(),
severity: "refuse".into(),
reason: "r".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
};
let action = AgentAction::FilesystemWrite {
path: "/x".into(),
byte_estimate: None,
};
assert!(!matcher_applies(&rule, &action));
}
#[test]
fn matcher_applies_custom_missing_kind_field() {
let rule = Rule {
id: "R".into(),
kind: "custom".into(),
matcher: r#"{}"#.into(),
severity: "refuse".into(),
reason: "r".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
};
let action = AgentAction::Custom {
custom_kind: "memory_write".into(),
payload: serde_json::json!({}),
};
assert!(!matcher_applies(&rule, &action));
}
#[test]
fn count_matching_rules_returns_count() {
let _no_pubkey = no_operator_pubkey();
let conn = fresh_conn();
add_rule(
&conn,
"R1",
"bash",
r#"{"command_regex":"rm"}"#,
"refuse",
true,
);
add_rule(
&conn,
"R2",
"bash",
r#"{"command_regex":"rm"}"#,
"warn",
true,
);
add_rule(
&conn,
"R3",
"bash",
r#"{"command_regex":"ls"}"#,
"refuse",
true,
);
let action = AgentAction::Bash {
command: "rm -rf".into(),
cwd: None,
};
let count = count_matching_rules(&conn, &action).unwrap();
assert_eq!(count, 2, "two rules match 'rm', one matches 'ls'");
}
#[test]
fn count_matching_rules_zero_when_no_rules() {
let conn = fresh_conn();
let action = AgentAction::Bash {
command: "ls".into(),
cwd: None,
};
let count = count_matching_rules(&conn, &action).unwrap();
assert_eq!(count, 0);
}
#[test]
fn decision_matches_for_each_variant() {
let w = Decision::Warn {
rule_id: "W".into(),
reason: "warn".into(),
};
assert!(matches!(w, Decision::Warn { .. }));
let allow = Decision::Allow;
assert!(matches!(allow, Decision::Allow));
assert!(allow.is_allowed());
let refuse = Decision::Refuse {
rule_id: "R".into(),
reason: "no".into(),
};
assert!(refuse.is_refusal());
}
#[test]
fn severity_as_str_round_trip() {
for s in [Severity::Refuse, Severity::Warn, Severity::Log] {
let back = Severity::from_str(s.as_str()).unwrap();
assert_eq!(s, back);
}
}
#[test]
fn agent_action_serialize_round_trip_for_each_variant() {
let actions = [
AgentAction::Bash {
command: "ls".into(),
cwd: None,
},
AgentAction::FilesystemWrite {
path: "/tmp/x".into(),
byte_estimate: Some(1024),
},
AgentAction::NetworkRequest {
host: "h.example.com".into(),
scheme: "https".into(),
},
AgentAction::ProcessSpawn {
binary: "cargo".into(),
args: vec!["build".into()],
},
AgentAction::Custom {
custom_kind: "memory_write".into(),
payload: serde_json::json!({"ns": "a"}),
},
];
for a in &actions {
let json = serde_json::to_value(a).unwrap();
assert!(json.is_object(), "action should serialize as object");
assert!(
json["type"].is_string() || json["kind"].is_string() || json.get("type").is_some()
);
}
}
#[test]
fn rule_engine_from_rules_evaluate_allow_when_no_match() {
let engine = RuleEngine::from_rules(vec![]);
let decision = engine.evaluate(
"agent:t",
&AgentAction::Bash {
command: "ls".into(),
cwd: None,
},
);
assert_eq!(decision, Decision::Allow);
assert!(engine.rules().is_empty());
}
#[test]
fn rule_engine_first_refusal_wins_over_warn() {
let warn_rule = Rule {
id: "W1".into(),
kind: "bash".into(),
matcher: r#"{"command_substring":"rm"}"#.into(),
severity: "warn".into(),
reason: "warn-rm".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
};
let refuse_rule = Rule {
id: "R1".into(),
kind: "bash".into(),
matcher: r#"{"command_substring":"rm -rf"}"#.into(),
severity: "refuse".into(),
reason: "refuse-rm-rf".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
};
let engine = RuleEngine::from_rules(vec![warn_rule, refuse_rule]);
let decision = engine.evaluate(
"agent:t",
&AgentAction::Bash {
command: "rm -rf /tmp/x".into(),
cwd: None,
},
);
match decision {
Decision::Refuse { rule_id, .. } => assert_eq!(rule_id, "R1"),
other => panic!("expected Refuse, got {other:?}"),
}
}
#[test]
fn rule_engine_warn_when_only_warn_matches() {
let rule = Rule {
id: "W1".into(),
kind: "bash".into(),
matcher: r#"{"command_substring":"rm"}"#.into(),
severity: "warn".into(),
reason: "warn-rm".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
};
let engine = RuleEngine::from_rules(vec![rule]);
let decision = engine.evaluate(
"agent:t",
&AgentAction::Bash {
command: "rm /tmp/x".into(),
cwd: None,
},
);
match decision {
Decision::Warn { rule_id, reason } => {
assert_eq!(rule_id, "W1");
assert_eq!(reason, "warn-rm");
}
other => panic!("expected Warn, got {other:?}"),
}
}
#[test]
fn rule_engine_log_severity_is_silent() {
let rule = Rule {
id: "L1".into(),
kind: "bash".into(),
matcher: r#"{"command_substring":"ls"}"#.into(),
severity: "log".into(),
reason: "log-ls".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
};
let engine = RuleEngine::from_rules(vec![rule]);
let decision = engine.evaluate(
"agent:t",
&AgentAction::Bash {
command: "ls -la".into(),
cwd: None,
},
);
assert_eq!(decision, Decision::Allow);
}
#[test]
fn rule_engine_load_for_action_round_trips_through_sqlite() {
let _no_pubkey = no_operator_pubkey();
let conn = fresh_conn();
add_rule(
&conn,
"R-engine",
"filesystem_write",
r#"{"glob":"/tmp/**"}"#,
"refuse",
true,
);
let action = AgentAction::FilesystemWrite {
path: "/tmp/engine.txt".into(),
byte_estimate: None,
};
let engine = RuleEngine::load_for_action(&conn, &action).unwrap();
assert_eq!(engine.rules().len(), 1);
assert_eq!(engine.rules()[0].id, "R-engine");
let decision = engine.evaluate("agent:t", &action);
match decision {
Decision::Refuse { rule_id, .. } => assert_eq!(rule_id, "R-engine"),
other => panic!("expected Refuse, got {other:?}"),
}
}
}