use anyhow::{Context, Result};
use clap::Args;
use serde_json::Value;
use crate::cli::CliOutput;
use crate::mcp::tools::check_agent_action::{
DEFAULT_AGENT_ID, build_action as build_agent_action, run_check,
};
#[derive(Args, Debug, Clone)]
pub struct CheckActionArgs {
#[arg(long, value_name = "KIND")]
pub kind: String,
#[arg(long, value_name = "COMMAND")]
pub command: Option<String>,
#[arg(long, value_name = "PATH")]
pub path: Option<String>,
#[arg(long, value_name = "HOST")]
pub host: Option<String>,
#[arg(long, value_name = "BINARY")]
pub binary: Option<String>,
#[arg(long = "custom-kind", value_name = "KIND")]
pub custom_kind: Option<String>,
#[arg(long = "agent-id", value_name = "ID")]
pub agent_id: Option<String>,
#[arg(long)]
pub json: bool,
}
impl CheckActionArgs {
fn to_arguments(&self) -> Value {
let mut obj = serde_json::Map::new();
obj.insert("kind".to_string(), Value::String(self.kind.clone()));
if let Some(v) = &self.command {
obj.insert("command".to_string(), Value::String(v.clone()));
}
if let Some(v) = &self.path {
obj.insert("path".to_string(), Value::String(v.clone()));
}
if let Some(v) = &self.host {
obj.insert("host".to_string(), Value::String(v.clone()));
}
if let Some(v) = &self.binary {
obj.insert("binary".to_string(), Value::String(v.clone()));
}
if let Some(v) = &self.custom_kind {
obj.insert(
crate::models::field_names::CUSTOM_KIND.to_string(),
Value::String(v.clone()),
);
}
Value::Object(obj)
}
}
pub fn run(
db_path: &std::path::Path,
args: &CheckActionArgs,
out: &mut CliOutput<'_>,
) -> Result<()> {
let conn = rusqlite::Connection::open(db_path)
.with_context(|| format!("governance check-action: open db at {}", db_path.display()))?;
let arguments = args.to_arguments();
let action = build_agent_action(&args.kind, &arguments)
.map_err(|e| anyhow::anyhow!("governance check-action: {e}"))?;
let agent_id = args
.agent_id
.clone()
.unwrap_or_else(|| DEFAULT_AGENT_ID.to_string());
let envelope = run_check(&conn, &agent_id, &args.kind, &action)
.map_err(|e| anyhow::anyhow!("governance check-action: {e}"))?;
if args.json {
let decision_obj = envelope.get("decision").cloned().unwrap_or(Value::Null);
let verdict = decision_obj
.get("decision")
.and_then(Value::as_str)
.unwrap_or("unknown");
let payload = if verdict == "refuse" {
let rule_id = decision_obj
.get("rule_id")
.and_then(Value::as_str)
.unwrap_or("");
let reason = decision_obj
.get("reason")
.and_then(Value::as_str)
.unwrap_or("");
serde_json::json!({
"error": crate::errors::error_codes::GOVERNANCE_REFUSED,
"decision": "deny",
"rule_id": rule_id,
"reason": reason,
"kind": args.kind,
"agent_id": agent_id,
})
} else {
envelope.clone()
};
writeln!(
out.stdout,
"{}",
serde_json::to_string(&payload)
.context("governance check-action: serialise JSON envelope")?
)?;
return Ok(());
}
let decision = envelope.get("decision").cloned().unwrap_or(Value::Null);
let verdict = decision
.get("decision")
.and_then(Value::as_str)
.unwrap_or("unknown");
match verdict {
"allow" => writeln!(out.stdout, "Allow")?,
"refuse" => {
let rule_id = decision
.get("rule_id")
.and_then(Value::as_str)
.unwrap_or("?");
let reason = decision
.get("reason")
.and_then(Value::as_str)
.unwrap_or("?");
writeln!(out.stdout, "Refuse: {rule_id} — {reason}")?;
}
"warn" => {
let rule_id = decision
.get("rule_id")
.and_then(Value::as_str)
.unwrap_or("?");
let reason = decision
.get("reason")
.and_then(Value::as_str)
.unwrap_or("?");
writeln!(out.stdout, "Warn: {rule_id} — {reason}")?;
}
other => writeln!(out.stdout, "Unknown verdict: {other}")?,
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::params;
use tempfile::NamedTempFile;
fn seed_rules_db() -> NamedTempFile {
let tmp = NamedTempFile::new().unwrap();
let conn = rusqlite::Connection::open(tmp.path()).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,
prev_hash BLOB,
sequence INTEGER
);",
)
.unwrap();
conn.execute(
"INSERT INTO governance_rules (id, kind, matcher, severity, reason, \
namespace, created_by, created_at, enabled, signature, attest_level) \
VALUES (?1, ?2, ?3, 'refuse', ?4, '_global', 'test', 0, 1, NULL, 'unsigned')",
params![
"R001",
"filesystem_write",
r#"{"glob":"/tmp/**"}"#,
"no /tmp writes",
],
)
.unwrap();
tmp
}
#[test]
fn refuses_filesystem_write_to_tmp() {
let _audit_lock = crate::governance::audit::forensic_sink_test_lock().lock();
let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
let tmp = seed_rules_db();
let mut so = Vec::<u8>::new();
let mut se = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut so, &mut se);
let args = CheckActionArgs {
kind: "filesystem_write".into(),
command: None,
path: Some("/tmp/foo.txt".into()),
host: None,
binary: None,
custom_kind: None,
agent_id: None,
json: true,
};
run(tmp.path(), &args, &mut out).unwrap();
let stdout = String::from_utf8(so).unwrap();
let v: Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(
v["error"], "GOVERNANCE_REFUSED",
"#1103: refuse verdict must surface `error=GOVERNANCE_REFUSED`"
);
assert_eq!(
v["decision"], "deny",
"#1103: refuse verdict must surface `decision=deny` (CLI/HTTP parity tag)"
);
assert_eq!(
v["rule_id"], "R001",
"#1103: refuse verdict must surface flat `rule_id` at the top level"
);
assert_eq!(
v["kind"], "filesystem_write",
"#1103: refuse envelope echoes the action kind"
);
assert!(
v["reason"].as_str().unwrap_or("").contains("/tmp"),
"#1103: refuse envelope carries the substrate `reason` string"
);
}
#[test]
fn allow_verdict_keeps_legacy_envelope_1103() {
let _audit_lock = crate::governance::audit::forensic_sink_test_lock().lock();
let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
let tmp = seed_rules_db();
let mut so = Vec::<u8>::new();
let mut se = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut so, &mut se);
let args = CheckActionArgs {
kind: "filesystem_write".into(),
command: None,
path: Some("/home/user/ok.txt".into()),
host: None,
binary: None,
custom_kind: None,
agent_id: None,
json: true,
};
run(tmp.path(), &args, &mut out).unwrap();
let stdout = String::from_utf8(so).unwrap();
let v: Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(v["decision"]["decision"], "allow");
assert!(
v.get("error").is_none(),
"#1103: allow verdicts must NOT carry the GOVERNANCE_REFUSED tag"
);
}
#[test]
fn allows_filesystem_write_outside_tmp() {
let _audit_lock = crate::governance::audit::forensic_sink_test_lock().lock();
let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
let tmp = seed_rules_db();
let mut so = Vec::<u8>::new();
let mut se = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut so, &mut se);
let args = CheckActionArgs {
kind: "filesystem_write".into(),
command: None,
path: Some("/home/user/ok.txt".into()),
host: None,
binary: None,
custom_kind: None,
agent_id: None,
json: false,
};
run(tmp.path(), &args, &mut out).unwrap();
let stdout = String::from_utf8(so).unwrap();
assert!(stdout.trim() == "Allow", "got: {stdout}");
}
#[test]
fn missing_required_field_errors() {
let _audit_lock = crate::governance::audit::forensic_sink_test_lock().lock();
let tmp = seed_rules_db();
let mut so = Vec::<u8>::new();
let mut se = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut so, &mut se);
let args = CheckActionArgs {
kind: "filesystem_write".into(),
command: None,
path: None,
host: None,
binary: None,
custom_kind: None,
agent_id: None,
json: false,
};
let err = run(tmp.path(), &args, &mut out).unwrap_err();
assert!(err.to_string().contains("path"), "got: {err}");
}
}