use serde_json::{Value, json};
use crate::governance::agent_action::{AgentAction, action_kinds as ak, check_agent_action};
use crate::mcp::param_names;
pub const DEFAULT_AGENT_ID: &str = "anonymous:mcp";
pub fn handle_check_agent_action(
conn: &rusqlite::Connection,
arguments: &Value,
) -> Result<Value, String> {
let kind = arguments
.get(param_names::KIND)
.and_then(Value::as_str)
.ok_or_else(|| "kind is required".to_string())?;
let action = build_action(kind, arguments)?;
let agent_id = arguments
.get(param_names::AGENT_ID)
.and_then(Value::as_str)
.unwrap_or(DEFAULT_AGENT_ID)
.to_string();
run_check(conn, &agent_id, kind, &action)
}
pub fn run_check(
conn: &rusqlite::Connection,
agent_id: &str,
kind: &str,
action: &AgentAction,
) -> Result<Value, String> {
let decision = check_agent_action(conn, agent_id, action).map_err(|e| e.to_string())?;
Ok(json!({
"decision": decision,
"kind": kind,
"agent_id": agent_id,
}))
}
pub fn build_action(kind: &str, arguments: &Value) -> Result<AgentAction, String> {
use std::path::PathBuf;
match kind {
ak::BASH => {
let command = arguments
.get("command")
.and_then(Value::as_str)
.ok_or_else(|| "bash kind requires `command`".to_string())?
.to_string();
let cwd = arguments
.get("cwd")
.and_then(Value::as_str)
.map(PathBuf::from);
Ok(AgentAction::Bash { command, cwd })
}
ak::FILESYSTEM_WRITE => {
let path = arguments
.get("path")
.and_then(Value::as_str)
.ok_or_else(|| "filesystem_write kind requires `path`".to_string())?
.to_string();
let byte_estimate = arguments
.get(param_names::BYTE_ESTIMATE)
.and_then(Value::as_u64);
Ok(AgentAction::FilesystemWrite {
path: PathBuf::from(path),
byte_estimate,
})
}
ak::NETWORK_REQUEST => {
let host = arguments
.get("host")
.and_then(Value::as_str)
.ok_or_else(|| "network_request kind requires `host`".to_string())?
.to_string();
let scheme = arguments
.get("scheme")
.and_then(Value::as_str)
.unwrap_or("https")
.to_string();
Ok(AgentAction::NetworkRequest { host, scheme })
}
ak::PROCESS_SPAWN => {
let binary = arguments
.get("binary")
.and_then(Value::as_str)
.ok_or_else(|| "process_spawn kind requires `binary`".to_string())?
.to_string();
let args = arguments
.get("args")
.and_then(Value::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 = arguments
.get(crate::models::field_names::CUSTOM_KIND)
.or_else(|| arguments.get(param_names::KIND_INNER))
.and_then(Value::as_str)
.ok_or_else(|| "custom kind requires `custom_kind`".to_string())?
.to_string();
Ok(AgentAction::Custom {
custom_kind,
payload: arguments.clone(),
})
}
other => Err(format!("unknown kind `{other}`")),
}
}
#[allow(dead_code)]
pub const MCP_MUTATION_DISABLED_ERROR: &str = "governance.not_available_over_mcp: rule mutation is operator-only \
(CLI `ai-memory rules` or HTTP `POST /api/v1/governance/rules`)";
use crate::mcp::registry::McpTool;
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct CheckAgentActionRequest {
pub kind: String,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub byte_estimate: Option<i64>,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub scheme: Option<String>,
#[serde(default)]
pub binary: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub custom_kind: Option<String>,
#[serde(default)]
pub agent_id: Option<String>,
}
#[allow(dead_code)]
pub struct CheckAgentActionTool;
impl McpTool for CheckAgentActionTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_CHECK_AGENT_ACTION
}
fn description() -> &'static str {
"Check action vs governance_rules (#691); Allow/Refuse/Warn."
}
fn docs() -> &'static str {
"#691: read-only rule check. Harness PreToolUse hook calls on every Bash/Write/Edit. Rule MUTATION over MCP is disabled — use `ai-memory rules --sign` CLI or signed HTTP admin endpoints."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<CheckAgentActionRequest>()
}
fn family() -> &'static str {
crate::profile::Family::Power.name()
}
}
#[cfg(test)]
mod d1_5_986_tests {
use super::*;
use crate::mcp::parity_test_helpers::{
assert_descriptions_match, assert_property_set_parity, derived_props_for,
};
#[test]
fn check_agent_action_parity_986() {
let derived = derived_props_for::<CheckAgentActionRequest>();
assert_property_set_parity("memory_check_agent_action", &derived);
assert_descriptions_match("memory_check_agent_action", &derived);
}
#[test]
fn check_agent_action_tool_metadata_986() {
assert_eq!(CheckAgentActionTool::name(), "memory_check_agent_action");
assert_eq!(CheckAgentActionTool::family(), "power");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::governance::rules_store::{self, Rule};
#[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 fresh_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,
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
}
#[test]
fn missing_kind_errors() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let r = handle_check_agent_action(&conn, &json!({}));
assert!(r.is_err());
}
#[test]
fn bash_kind_allows_when_no_rule() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let r = handle_check_agent_action(&conn, &json!({"kind":"bash","command":"ls"})).unwrap();
assert_eq!(r["decision"]["decision"], "allow");
}
#[test]
fn advertised_enforced_actions_round_trip_the_kind_parser_1605() {
let _forensic = forensic_lock();
let conn = fresh_conn();
for kind in crate::config::ENFORCED_AGENT_ACTIONS {
let args = match *kind {
ak::BASH => json!({"kind": kind, "command": "ls"}),
ak::FILESYSTEM_WRITE => {
json!({"kind": kind, "path": "./scratch.txt"})
}
ak::NETWORK_REQUEST => {
json!({"kind": kind, "host": "example.com"})
}
ak::PROCESS_SPAWN => {
json!({"kind": kind, "binary": "/usr/bin/ls"})
}
other => panic!(
"ENFORCED_AGENT_ACTIONS advertises {other:?}, which this \
round-trip test does not know how to construct — extend \
the match alongside the new action kind"
),
};
let r = handle_check_agent_action(&conn, &args)
.unwrap_or_else(|e| panic!("advertised kind {kind:?} must parse, got error: {e}"));
assert_eq!(
r["decision"]["decision"], "allow",
"advertised kind {kind:?} must reach the engine (no rules \
seeded → allow)"
);
}
}
#[test]
fn filesystem_write_kind_refuses_on_glob() {
let _forensic = forensic_lock();
let _no_pubkey = rules_store::force_no_operator_pubkey_for_test();
let conn = fresh_conn();
rules_store::insert(
&conn,
&Rule {
id: "R001".into(),
kind: "filesystem_write".into(),
matcher: r#"{"glob":"/tmp/**"}"#.into(),
severity: "refuse".into(),
reason: "no /tmp".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
},
)
.unwrap();
let r =
handle_check_agent_action(&conn, &json!({"kind":"filesystem_write","path":"/tmp/foo"}))
.unwrap();
assert_eq!(r["decision"]["decision"], "refuse");
assert_eq!(r["decision"]["rule_id"], "R001");
}
#[test]
fn unknown_kind_errors() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let r = handle_check_agent_action(&conn, &json!({"kind":"nope"}));
assert!(r.is_err());
}
#[test]
fn missing_required_field_errors() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let r = handle_check_agent_action(&conn, &json!({"kind":"bash"}));
assert!(r.is_err());
}
#[test]
fn mutation_disabled_error_string_is_stable() {
assert!(MCP_MUTATION_DISABLED_ERROR.starts_with("governance.not_available_over_mcp"));
}
#[test]
fn filesystem_write_missing_path_errors() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let err =
handle_check_agent_action(&conn, &json!({"kind": "filesystem_write"})).unwrap_err();
assert!(err.contains("path"), "got: {err}");
}
#[test]
fn filesystem_write_with_byte_estimate_allows_when_no_rule() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let resp = handle_check_agent_action(
&conn,
&json!({
"kind": "filesystem_write",
"path": "/home/test/file.txt",
"byte_estimate": 1024u64,
}),
)
.expect("ok");
assert_eq!(resp["decision"]["decision"], "allow");
}
#[test]
fn network_request_default_scheme_allows() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let resp = handle_check_agent_action(
&conn,
&json!({"kind": "network_request", "host": "example.com"}),
)
.expect("ok");
assert_eq!(resp["decision"]["decision"], "allow");
}
#[test]
fn network_request_custom_scheme() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let resp = handle_check_agent_action(
&conn,
&json!({"kind": "network_request", "host": "host.local", "scheme": "ssh"}),
)
.expect("ok");
assert_eq!(resp["decision"]["decision"], "allow");
}
#[test]
fn network_request_missing_host_errors() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let err =
handle_check_agent_action(&conn, &json!({"kind": "network_request"})).unwrap_err();
assert!(err.contains("host"), "got: {err}");
}
#[test]
fn process_spawn_no_args_allows() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let resp = handle_check_agent_action(
&conn,
&json!({"kind": "process_spawn", "binary": "/usr/bin/ls"}),
)
.expect("ok");
assert_eq!(resp["decision"]["decision"], "allow");
}
#[test]
fn process_spawn_with_args() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let resp = handle_check_agent_action(
&conn,
&json!({
"kind": "process_spawn",
"binary": "/bin/echo",
"args": ["hello", "world"],
}),
)
.expect("ok");
assert_eq!(resp["decision"]["decision"], "allow");
}
#[test]
fn process_spawn_missing_binary_errors() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let err = handle_check_agent_action(&conn, &json!({"kind": "process_spawn"})).unwrap_err();
assert!(err.contains("binary"), "got: {err}");
}
#[test]
fn custom_kind_allows() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let resp = handle_check_agent_action(
&conn,
&json!({"kind": "custom", "custom_kind": "my-custom-action"}),
)
.expect("ok");
assert_eq!(resp["decision"]["decision"], "allow");
}
#[test]
fn custom_kind_missing_custom_kind_errors() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let err = handle_check_agent_action(&conn, &json!({"kind": "custom"})).unwrap_err();
assert!(err.contains("custom_kind"), "got: {err}");
}
#[test]
fn custom_kind_kind_inner_alias() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let resp = handle_check_agent_action(
&conn,
&json!({"kind": "custom", "kind_inner": "alias-action"}),
)
.expect("ok");
assert_eq!(resp["decision"]["decision"], "allow");
}
#[test]
fn bash_with_cwd_allows() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let resp = handle_check_agent_action(
&conn,
&json!({"kind": "bash", "command": "pwd", "cwd": "/tmp"}),
)
.expect("ok");
assert_eq!(resp["decision"]["decision"], "allow");
}
#[test]
fn agent_id_echoed_in_response() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let resp = handle_check_agent_action(
&conn,
&json!({
"kind": "bash",
"command": "ls",
"agent_id": "ai:alice",
}),
)
.expect("ok");
assert_eq!(resp["agent_id"].as_str(), Some("ai:alice"));
}
#[test]
fn default_agent_id_when_omitted() {
let _forensic = forensic_lock();
let conn = fresh_conn();
let resp = handle_check_agent_action(&conn, &json!({"kind": "bash", "command": "ls"}))
.expect("ok");
assert_eq!(resp["agent_id"].as_str(), Some("anonymous:mcp"));
}
#[test]
fn warn_severity_surfaces_rule_id() {
let _forensic = forensic_lock();
let _no_pubkey = rules_store::force_no_operator_pubkey_for_test();
let conn = fresh_conn();
rules_store::insert(
&conn,
&Rule {
id: "W001".into(),
kind: "bash".into(),
matcher: r#"{"command_regex":"warn-this"}"#.into(),
severity: "warn".into(),
reason: "warn reason".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
},
)
.unwrap();
let resp = handle_check_agent_action(
&conn,
&json!({"kind": "bash", "command": "warn-this please"}),
)
.expect("ok");
assert_eq!(resp["decision"]["decision"], "warn");
assert_eq!(resp["decision"]["rule_id"], "W001");
}
#[test]
fn process_spawn_refuses_on_binary_match() {
let _forensic = forensic_lock();
let _no_pubkey = rules_store::force_no_operator_pubkey_for_test();
let conn = fresh_conn();
rules_store::insert(
&conn,
&Rule {
id: "P002".into(),
kind: "process_spawn".into(),
matcher: r#"{"binary":"/bin/forbidden"}"#.into(),
severity: "refuse".into(),
reason: "binary not allowed".into(),
namespace: "_global".into(),
created_by: "test".into(),
created_at: 0,
enabled: true,
signature: None,
attest_level: "unsigned".into(),
},
)
.unwrap();
let resp = handle_check_agent_action(
&conn,
&json!({"kind": "process_spawn", "binary": "/bin/forbidden"}),
)
.expect("ok");
assert_eq!(resp["decision"]["decision"], "refuse");
assert_eq!(resp["decision"]["rule_id"], "P002");
}
#[test]
fn handle_check_agent_action_uses_uncached_path_1114() {
let body = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/mcp/tools/check_agent_action.rs"
))
.expect("read self");
let calls_uncached = body.contains("check_agent_action(conn, agent_id, action)");
let calls_cached_no_doc =
body.contains("check_agent_action_cached(conn, ") && !body.contains("operator-driven");
assert!(
calls_uncached || !calls_cached_no_doc,
"#1023 + #1114: handle_check_agent_action must use the un-cached \
check_agent_action path (operator-driven debug entry point) UNLESS \
the cache wiring is documented above the call site. A silent flip \
without doc-comment update fails this pin."
);
assert!(
body.contains("operator-driven") || body.contains("check_agent_action_cached"),
"#1023 + #1114: the documentation block above the substrate \
read must describe either the un-cached operator path or the \
cache-served hook path — neither marker found"
);
}
}