use crate::{
capabilities::CapabilitySet, checkpoint::Checkpoint, CostBudget, Runtime, ToolExecutor,
TransactionCheckMode,
};
use car_ir::*;
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
struct TestExecutor;
#[async_trait::async_trait]
impl ToolExecutor for TestExecutor {
async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
match tool {
"add" => {
let a = params.get("a").and_then(|v| v.as_i64()).unwrap_or(0);
let b = params.get("b").and_then(|v| v.as_i64()).unwrap_or(0);
Ok(Value::from(a + b))
}
"echo" => {
let msg = params.get("message").and_then(|v| v.as_str()).unwrap_or("");
Ok(Value::from(msg))
}
"fail" => Err("boom".to_string()),
"slow" => {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
Ok(Value::from("done"))
}
_ => Err(format!("unknown tool: {}", tool)),
}
}
}
fn make_runtime() -> Runtime {
Runtime::new().with_executor(Arc::new(TestExecutor))
}
async fn setup_runtime() -> Runtime {
let rt = make_runtime();
rt.register_tool("add").await;
rt.register_tool("echo").await;
rt.register_tool("fail").await;
rt.register_tool("slow").await;
rt
}
fn tool_call(tool: &str, params: HashMap<String, Value>) -> Action {
{
let mut a = Action::new(ActionType::ToolCall);
a.id = uuid::Uuid::new_v4().simple().to_string()[..12].to_string();
a.tool = Some(tool.to_string());
a.parameters = params;
a
}
}
fn state_write(key: &str, value: Value) -> Action {
{
let mut a = Action::new(ActionType::StateWrite);
a.id = uuid::Uuid::new_v4().simple().to_string()[..12].to_string();
a.parameters = [
("key".to_string(), Value::from(key)),
("value".to_string(), value),
]
.into();
a
}
}
fn proposal(actions: Vec<Action>) -> ActionProposal {
ActionProposal {
id: "test-proposal".to_string(),
source: "test".to_string(),
actions,
timestamp: chrono::Utc::now(),
context: HashMap::new(),
}
}
#[tokio::test]
async fn test_tool_call_succeeds() {
let rt = setup_runtime().await;
let p = proposal(vec![tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
)]);
let result = rt.execute(&p).await;
assert!(result.all_succeeded());
assert_eq!(result.results[0].output, Some(Value::from(3)));
}
#[tokio::test]
async fn test_agent_basics_read_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("note.txt");
fs::write(&path, "alpha\nbeta\ngamma\n").unwrap();
let rt = Runtime::new();
rt.register_agent_basics().await;
let result = rt
.execute(&proposal(vec![tool_call(
"read_file",
[("path".to_string(), Value::from(path.display().to_string()))].into(),
)]))
.await;
assert!(result.all_succeeded());
let output = result.results[0].output.as_ref().unwrap();
assert_eq!(
output.get("content"),
Some(&Value::from(" 1\talpha\n 2\tbeta\n 3\tgamma"))
);
assert_eq!(output.get("total_lines"), Some(&Value::from(3)));
}
#[tokio::test]
async fn test_agent_basics_write_and_edit_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("story.txt");
let rt = Runtime::new();
rt.register_agent_basics().await;
let write = tool_call(
"write_file",
[
(
"path".to_string(),
Value::from(file_path.display().to_string()),
),
("content".to_string(), Value::from("hello world")),
]
.into(),
);
let edit = tool_call(
"edit_file",
[
(
"path".to_string(),
Value::from(file_path.display().to_string()),
),
("old_text".to_string(), Value::from("world")),
("new_text".to_string(), Value::from("car")),
]
.into(),
);
let result = rt.execute(&proposal(vec![write, edit])).await;
assert!(result.all_succeeded());
assert_eq!(fs::read_to_string(file_path).unwrap(), "hello car");
}
#[tokio::test]
async fn test_agent_basics_edit_requires_prior_read() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("f.txt");
fs::write(&path, "hello world").unwrap();
let rt = Runtime::new();
rt.register_agent_basics().await;
let result = rt
.execute(&proposal(vec![tool_call(
"edit_file",
[
("path".to_string(), Value::from(path.display().to_string())),
("old_text".to_string(), Value::from("hello")),
("new_text".to_string(), Value::from("hi")),
]
.into(),
)]))
.await;
assert!(!result.all_succeeded());
let err = result.results[0]
.error
.as_ref()
.expect("a gated edit must carry an error");
assert!(err.contains("before editing it"), "{err}");
assert_eq!(fs::read_to_string(&path).unwrap(), "hello world");
}
#[tokio::test]
async fn test_agent_basics_read_ledger_isolated_by_runtime_session() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("f.txt");
fs::write(&path, "hello world").unwrap();
let rt = Runtime::new();
rt.register_agent_basics().await;
let first = rt.open_session().await;
let second = rt.open_session().await;
let path_s = path.display().to_string();
let read = rt
.execute_with_session(
&proposal(vec![tool_call(
"read_file",
[("path".to_string(), Value::from(path_s.clone()))].into(),
)]),
&first,
)
.await;
assert!(read.all_succeeded(), "{:?}", read.results[0].error);
let edit = rt
.execute_with_session(
&proposal(vec![tool_call(
"edit_file",
[
("path".to_string(), Value::from(path_s)),
("old_text".to_string(), Value::from("hello")),
("new_text".to_string(), Value::from("hi")),
]
.into(),
)]),
&second,
)
.await;
assert!(!edit.all_succeeded());
assert!(edit.results[0]
.error
.as_deref()
.unwrap_or_default()
.contains("before editing it"));
assert_eq!(fs::read_to_string(path).unwrap(), "hello world");
}
#[tokio::test]
async fn test_agent_basics_substrate_swap_clears_read_ledger() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("f.txt");
fs::write(&path, "hello world").unwrap();
let rt = Runtime::new();
rt.register_agent_basics().await;
let path_s = path.display().to_string();
let read = rt
.execute(&proposal(vec![tool_call(
"read_file",
[("path".to_string(), Value::from(path_s.clone()))].into(),
)]))
.await;
assert!(read.all_succeeded(), "{:?}", read.results[0].error);
rt.set_substrate(Arc::new(crate::substrate::LocalSubstrate::new()))
.await;
let edit = rt
.execute(&proposal(vec![tool_call(
"edit_file",
[
("path".to_string(), Value::from(path_s)),
("old_text".to_string(), Value::from("hello")),
("new_text".to_string(), Value::from("hi")),
]
.into(),
)]))
.await;
assert!(!edit.all_succeeded());
assert!(edit.results[0]
.error
.as_deref()
.unwrap_or_default()
.contains("before editing it"));
assert_eq!(fs::read_to_string(path).unwrap(), "hello world");
}
#[tokio::test]
async fn runtime_routes_builtins_through_single_executor_ledger() {
struct LedgerExecutor {
substrate: Arc<dyn crate::substrate::Substrate>,
ledger: crate::agent_basics::ReadLedger,
}
#[async_trait::async_trait]
impl ToolExecutor for LedgerExecutor {
async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
match crate::agent_basics::execute_with_ledger(
&self.substrate,
&self.ledger,
tool,
params,
)
.await
{
Some(r) => r,
None => Err(format!("unknown tool: {tool}")),
}
}
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("f.txt");
fs::write(&path, "alpha beta").unwrap();
let path_s = path.display().to_string();
let substrate: Arc<dyn crate::substrate::Substrate> =
Arc::new(crate::substrate::LocalSubstrate::new());
let rt = Runtime::new().with_executor(Arc::new(LedgerExecutor {
substrate: substrate.clone(),
ledger: crate::agent_basics::ReadLedger::new(),
}));
rt.register_agent_basics().await;
let read = rt
.execute(&proposal(vec![tool_call(
"read_file",
[("path".to_string(), Value::from(path_s.clone()))].into(),
)]))
.await;
assert!(
read.all_succeeded(),
"read failed: {:?}",
read.results[0].error
);
let edit = rt
.execute(&proposal(vec![tool_call(
"edit_file",
[
("path".to_string(), Value::from(path_s.clone())),
("old_text".to_string(), Value::from("alpha")),
("new_text".to_string(), Value::from("ALPHA")),
]
.into(),
)]))
.await;
assert!(
edit.all_succeeded(),
"read then edit must succeed through one executor ledger: {:?}",
edit.results[0].error
);
let other = dir.path().join("g.txt");
fs::write(&other, "gamma").unwrap();
let unread = rt
.execute(&proposal(vec![tool_call(
"edit_file",
[
("path".to_string(), Value::from(other.display().to_string())),
("old_text".to_string(), Value::from("gamma")),
("new_text".to_string(), Value::from("GAMMA")),
]
.into(),
)]))
.await;
assert!(!unread.all_succeeded());
let err = unread.results[0]
.error
.as_ref()
.expect("the unread edit must carry the gate error");
assert!(err.contains("before editing it"), "{err}");
}
#[tokio::test]
async fn test_agent_basics_find_and_grep_files() {
let dir = tempfile::tempdir().unwrap();
let src_dir = dir.path().join("src");
fs::create_dir_all(&src_dir).unwrap();
fs::write(src_dir.join("lib.rs"), "fn main() {}\n").unwrap();
fs::write(src_dir.join("data.txt"), "hello car runtime\n").unwrap();
let rt = Runtime::new();
rt.register_agent_basics().await;
let find = tool_call(
"find_files",
[
("pattern".to_string(), Value::from("*.rs")),
(
"path".to_string(),
Value::from(dir.path().display().to_string()),
),
]
.into(),
);
let grep = tool_call(
"grep_files",
[
("pattern".to_string(), Value::from("car")),
(
"path".to_string(),
Value::from(dir.path().display().to_string()),
),
]
.into(),
);
let result = rt.execute(&proposal(vec![find, grep])).await;
assert!(result.all_succeeded());
assert_eq!(
result.results[0].output.as_ref().unwrap()["count"],
Value::from(1)
);
assert_eq!(
result.results[1].output.as_ref().unwrap()["count"],
Value::from(1)
);
}
#[tokio::test]
async fn test_state_write_and_read() {
let rt = setup_runtime().await;
let p = proposal(vec![state_write("x", Value::from(42)), {
let mut a = state_write("unused", Value::Null);
a.action_type = ActionType::StateRead;
a.parameters = [("key".to_string(), Value::from("x"))].into();
a.state_dependencies = vec!["x".to_string()];
a
}]);
let result = rt.execute(&p).await;
assert!(result.all_succeeded());
assert_eq!(result.results[1].output, Some(Value::from(42)));
}
#[tokio::test]
async fn test_unknown_tool_rejected() {
let rt = Runtime::new().with_executor(Arc::new(TestExecutor));
let p = proposal(vec![tool_call("nonexistent", HashMap::new())]);
let result = rt.execute(&p).await;
assert_eq!(result.results[0].status, ActionStatus::Rejected);
}
#[tokio::test]
async fn test_precondition_blocks() {
let rt = setup_runtime().await;
let mut action = tool_call("echo", [("message".to_string(), Value::from("hi"))].into());
action.preconditions = vec![Precondition {
key: "auth".to_string(),
operator: "eq".to_string(),
value: Value::Bool(true),
description: String::new(),
}];
let result = rt.execute(&proposal(vec![action])).await;
assert_eq!(result.results[0].status, ActionStatus::Rejected);
}
#[tokio::test]
async fn test_precondition_passes() {
let rt = setup_runtime().await;
rt.state.set("auth", Value::Bool(true), "setup");
let mut action = tool_call("echo", [("message".to_string(), Value::from("hi"))].into());
action.preconditions = vec![Precondition {
key: "auth".to_string(),
operator: "eq".to_string(),
value: Value::Bool(true),
description: String::new(),
}];
let result = rt.execute(&proposal(vec![action])).await;
assert!(result.all_succeeded());
}
#[tokio::test]
async fn test_abort_stops_remaining() {
let rt = setup_runtime().await;
let mut fail_action = tool_call("fail", HashMap::new());
fail_action.failure_behavior = FailureBehavior::Abort;
let echo_action = tool_call("echo", [("message".to_string(), Value::from("hi"))].into());
let result = rt.execute(&proposal(vec![fail_action, echo_action])).await;
assert_eq!(result.results[0].status, ActionStatus::Failed);
assert_eq!(result.results[1].status, ActionStatus::Skipped);
}
#[tokio::test]
async fn test_skip_continues() {
let rt = setup_runtime().await;
let mut fail_action = tool_call("fail", HashMap::new());
fail_action.failure_behavior = FailureBehavior::Skip;
let echo_action = tool_call("echo", [("message".to_string(), Value::from("hi"))].into());
let result = rt.execute(&proposal(vec![fail_action, echo_action])).await;
assert_eq!(result.results[0].status, ActionStatus::Skipped);
assert_eq!(result.results[1].status, ActionStatus::Succeeded);
}
#[tokio::test]
async fn test_abort_rolls_back_state() {
let rt = setup_runtime().await;
let mut fail_action = tool_call("fail", HashMap::new());
fail_action.failure_behavior = FailureBehavior::Abort;
let p = proposal(vec![state_write("x", Value::from(1)), fail_action]);
rt.execute(&p).await;
assert_eq!(rt.state.get("x"), None);
}
#[tokio::test]
async fn test_timeout() {
let rt = setup_runtime().await;
let mut action = tool_call("slow", HashMap::new());
action.timeout_ms = Some(50);
let result = rt.execute(&proposal(vec![action])).await;
assert_eq!(result.results[0].status, ActionStatus::Failed);
assert!(result.results[0]
.error
.as_ref()
.unwrap()
.contains("timed out"));
}
#[tokio::test]
async fn test_idempotent_cached() {
let rt = setup_runtime().await;
let mut action1 = tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
);
action1.idempotent = true;
rt.execute(&proposal(vec![action1.clone()])).await;
let mut action2 = action1.clone();
action2.id = "second".to_string();
let result = rt.execute(&proposal(vec![action2])).await;
assert_eq!(result.results[0].output, Some(Value::from(3)));
assert_eq!(result.results[0].duration_ms, Some(0.0));
}
#[tokio::test]
async fn test_retry_succeeds() {
let call_count = Arc::new(AtomicU32::new(0));
let cc = call_count.clone();
struct FlakyExecutor(Arc<AtomicU32>);
#[async_trait::async_trait]
impl ToolExecutor for FlakyExecutor {
async fn execute(&self, _tool: &str, _params: &Value) -> Result<Value, String> {
let n = self.0.fetch_add(1, Ordering::SeqCst) + 1;
if n < 3 {
Err("not yet".to_string())
} else {
Ok(Value::from("ok"))
}
}
}
let rt = Runtime::new().with_executor(Arc::new(FlakyExecutor(cc)));
rt.register_tool("flaky").await;
let mut action = tool_call("flaky", HashMap::new());
action.failure_behavior = FailureBehavior::Retry;
action.max_retries = 3;
let result = rt.execute(&proposal(vec![action])).await;
assert_eq!(result.results[0].status, ActionStatus::Succeeded);
assert_eq!(call_count.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_policy_blocks() {
let rt = setup_runtime().await;
{
let mut policies = rt.policies.write().await;
policies.register(
"no_echo",
Box::new(|action, _state| {
if action.tool.as_deref() == Some("echo") {
Some("echo forbidden".to_string())
} else {
None
}
}),
"",
);
}
let action = tool_call("echo", [("message".to_string(), Value::from("hi"))].into());
let result = rt.execute(&proposal(vec![action])).await;
assert_eq!(result.results[0].status, ActionStatus::Rejected);
assert!(result.results[0]
.error
.as_ref()
.unwrap()
.contains("forbidden"));
}
#[tokio::test]
async fn test_unregister_policy_stops_enforcement() {
let rt = setup_runtime().await;
let echo = || {
proposal(vec![tool_call(
"echo",
[("message".to_string(), Value::from("hi"))].into(),
)])
};
{
let mut policies = rt.policies.write().await;
policies.register(
"no_echo",
Box::new(|action, _state| {
if action.tool.as_deref() == Some("echo") {
Some("echo forbidden".to_string())
} else {
None
}
}),
"global echo deny",
);
}
let denied = rt.execute(&echo()).await;
assert_eq!(denied.results[0].status, ActionStatus::Rejected);
assert_eq!(
rt.list_policies(None).await.unwrap(),
vec![("no_echo".to_string(), "global echo deny".to_string())]
);
assert_eq!(rt.unregister_policy("no_echo", None).await.unwrap(), 1);
assert!(rt.list_policies(None).await.unwrap().is_empty());
let allowed = rt.execute(&echo()).await;
assert_eq!(
allowed.results[0].status,
ActionStatus::Succeeded,
"unregistering the policy must actually stop enforcement"
);
assert_eq!(rt.unregister_policy("no_echo", None).await.unwrap(), 0);
}
#[tokio::test]
async fn test_unregister_policy_is_scope_aware() {
let rt = setup_runtime().await;
let session_id = rt.open_session().await;
{
let mut policies = rt.policies.write().await;
policies.register("shared_name", Box::new(|_, _| None), "global one");
}
rt.register_policy_in_session(
&session_id,
"shared_name",
Box::new(|_, _| None),
"session one",
)
.await
.unwrap();
assert_eq!(
rt.unregister_policy("shared_name", Some(&session_id))
.await
.unwrap(),
1
);
assert!(rt
.list_policies(Some(&session_id))
.await
.unwrap()
.is_empty());
assert_eq!(
rt.list_policies(None).await.unwrap().len(),
1,
"removing a session policy must leave the global one alone"
);
assert!(rt.unregister_policy("x", Some("ghost")).await.is_err());
assert!(rt.list_policies(Some("ghost")).await.is_err());
}
#[tokio::test]
async fn test_session_policy_denies_what_global_allows() {
let rt = setup_runtime().await;
let session_id = rt.open_session().await;
rt.register_policy_in_session(
&session_id,
"no_echo_in_session",
Box::new(|action, _state| {
if action.tool.as_deref() == Some("echo") {
Some("echo forbidden in this session".to_string())
} else {
None
}
}),
"Session-scoped echo deny",
)
.await
.unwrap();
let global = rt
.execute(&proposal(vec![tool_call(
"echo",
[("message".to_string(), Value::from("hi"))].into(),
)]))
.await;
assert_eq!(global.results[0].status, ActionStatus::Succeeded);
let scoped = rt
.execute_with_session(
&proposal(vec![tool_call(
"echo",
[("message".to_string(), Value::from("hi"))].into(),
)]),
&session_id,
)
.await;
assert_eq!(scoped.results[0].status, ActionStatus::Rejected);
assert!(scoped.results[0]
.error
.as_ref()
.unwrap()
.contains("forbidden in this session"));
}
#[tokio::test]
async fn test_global_policy_still_enforced_under_session() {
let rt = setup_runtime().await;
{
let mut policies = rt.policies.write().await;
policies.register(
"global_no_fail",
Box::new(|action, _state| {
if action.tool.as_deref() == Some("fail") {
Some("fail tool blocked globally".to_string())
} else {
None
}
}),
"Global block on fail",
);
}
let session_id = rt.open_session().await;
let result = rt
.execute_with_session(
&proposal(vec![tool_call("fail", HashMap::new())]),
&session_id,
)
.await;
assert_eq!(result.results[0].status, ActionStatus::Rejected);
assert!(result.results[0]
.error
.as_ref()
.unwrap()
.contains("blocked globally"));
}
#[tokio::test]
async fn test_close_session_drops_its_policies() {
let rt = setup_runtime().await;
let session_id = rt.open_session().await;
rt.register_policy_in_session(
&session_id,
"no_add",
Box::new(|action, _state| {
if action.tool.as_deref() == Some("add") {
Some("add denied in session".to_string())
} else {
None
}
}),
"",
)
.await
.unwrap();
let denied = rt
.execute_with_session(
&proposal(vec![tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
)]),
&session_id,
)
.await;
assert_eq!(denied.results[0].status, ActionStatus::Rejected);
let removed = rt.close_session(&session_id).await;
assert!(removed);
assert!(!rt.session_exists(&session_id).await);
assert!(!rt.close_session(&session_id).await);
let allowed = rt
.execute(&proposal(vec![tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
)]))
.await;
assert_eq!(allowed.results[0].status, ActionStatus::Succeeded);
}
#[tokio::test]
async fn test_unknown_session_id_rejects_actions() {
let rt = setup_runtime().await;
let result = rt
.execute_with_session(
&proposal(vec![tool_call(
"echo",
[("message".to_string(), Value::from("x"))].into(),
)]),
"no-such-session",
)
.await;
assert_eq!(result.results[0].status, ActionStatus::Rejected);
assert!(result.results[0]
.error
.as_ref()
.unwrap()
.contains("unknown session id"));
}
#[tokio::test]
async fn test_register_in_unknown_session_errors() {
let rt = setup_runtime().await;
let err = rt
.register_policy_in_session("ghost", "p", Box::new(|_, _| None), "")
.await
.expect_err("should refuse to register against unknown session");
assert!(err.contains("unknown session id"));
}
#[tokio::test]
async fn test_global_only_path_unchanged_when_no_session_used() {
let rt = setup_runtime().await;
let action = tool_call("echo", [("message".to_string(), Value::from("hi"))].into());
let result = rt.execute(&proposal(vec![action])).await;
assert_eq!(result.results[0].status, ActionStatus::Succeeded);
}
#[tokio::test]
async fn test_expected_effects() {
let rt = setup_runtime().await;
let mut action = tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
);
action.expected_effects = [("result".to_string(), Value::from(3))].into();
rt.execute(&proposal(vec![action])).await;
assert_eq!(rt.state.get("result"), Some(Value::from(3)));
}
#[tokio::test]
async fn test_cost_summary_computed() {
let rt = setup_runtime().await;
let p = proposal(vec![
tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
),
state_write("x", Value::from(42)),
tool_call("echo", [("message".to_string(), Value::from("hi"))].into()),
]);
let result = rt.execute(&p).await;
assert!(result.all_succeeded());
assert_eq!(result.cost.tool_calls, 2);
assert_eq!(result.cost.actions_executed, 3);
assert_eq!(result.cost.actions_skipped, 0);
assert!(result.cost.total_duration_ms > 0.0);
assert_eq!(result.cost.retries, 0);
}
#[tokio::test]
async fn test_cost_summary_with_skipped() {
let rt = setup_runtime().await;
let mut fail_action = tool_call("fail", HashMap::new());
fail_action.failure_behavior = FailureBehavior::Abort;
let echo_action = tool_call("echo", [("message".to_string(), Value::from("hi"))].into());
let result = rt.execute(&proposal(vec![fail_action, echo_action])).await;
assert_eq!(result.cost.actions_executed, 1);
assert_eq!(result.cost.actions_skipped, 1);
assert_eq!(result.cost.tool_calls, 0); }
#[tokio::test]
async fn test_cost_summary_counts_rejected_separately() {
let rt = setup_runtime().await;
let p = proposal(vec![
tool_call("nope", HashMap::new()),
tool_call("nope", HashMap::new()),
]);
let result = rt.execute(&p).await;
assert!(result
.results
.iter()
.all(|r| r.status == ActionStatus::Rejected));
assert_eq!(
result.cost.actions_executed, 0,
"nothing ran, so nothing was executed"
);
assert_eq!(result.cost.actions_rejected, 2);
assert_eq!(result.cost.tool_calls, 0);
}
#[tokio::test]
async fn test_cost_budget_max_actions() {
let rt = setup_runtime().await;
rt.set_cost_budget(CostBudget {
max_tool_calls: None,
max_duration_ms: None,
max_actions: Some(1),
})
.await;
let p = proposal(vec![
tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
),
tool_call("echo", [("message".to_string(), Value::from("hi"))].into()),
]);
let result = rt.execute(&p).await;
assert_eq!(result.results[0].status, ActionStatus::Succeeded);
assert_eq!(result.results[1].status, ActionStatus::Skipped);
assert!(result.results[1]
.error
.as_ref()
.unwrap()
.contains("cost budget exceeded"));
assert_eq!(result.cost.actions_executed, 1);
assert_eq!(result.cost.actions_skipped, 1);
}
#[tokio::test]
async fn test_cost_budget_max_tool_calls() {
let rt = setup_runtime().await;
rt.set_cost_budget(CostBudget {
max_tool_calls: Some(1),
max_duration_ms: None,
max_actions: None,
})
.await;
let p = proposal(vec![
tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
),
tool_call("echo", [("message".to_string(), Value::from("hi"))].into()),
]);
let result = rt.execute(&p).await;
assert_eq!(result.results[0].status, ActionStatus::Succeeded);
assert_eq!(result.results[1].status, ActionStatus::Skipped);
assert_eq!(result.cost.tool_calls, 1);
}
#[tokio::test]
async fn test_result_cache_hit_returns_stored_result() {
let call_count = Arc::new(AtomicU32::new(0));
let cc = call_count.clone();
struct CountingExecutor(Arc<AtomicU32>);
#[async_trait::async_trait]
impl ToolExecutor for CountingExecutor {
async fn execute(&self, _tool: &str, params: &Value) -> Result<Value, String> {
self.0.fetch_add(1, Ordering::SeqCst);
let a = params.get("a").and_then(|v| v.as_i64()).unwrap_or(0);
let b = params.get("b").and_then(|v| v.as_i64()).unwrap_or(0);
Ok(Value::from(a + b))
}
}
let rt = Runtime::new().with_executor(Arc::new(CountingExecutor(cc)));
rt.register_tool("add").await;
rt.enable_tool_cache("add", 60).await;
let action = tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
);
let r1 = rt.execute(&proposal(vec![action.clone()])).await;
assert!(r1.all_succeeded());
assert_eq!(r1.results[0].output, Some(Value::from(3)));
assert_eq!(call_count.load(Ordering::SeqCst), 1);
let r2 = rt.execute(&proposal(vec![action])).await;
assert!(r2.all_succeeded());
assert_eq!(r2.results[0].output, Some(Value::from(3)));
assert_eq!(call_count.load(Ordering::SeqCst), 1); }
#[tokio::test]
async fn test_result_cache_expired_entries_return_none() {
let call_count = Arc::new(AtomicU32::new(0));
let cc = call_count.clone();
struct CountingExecutor2(Arc<AtomicU32>);
#[async_trait::async_trait]
impl ToolExecutor for CountingExecutor2 {
async fn execute(&self, _tool: &str, _params: &Value) -> Result<Value, String> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(Value::from("result"))
}
}
let rt = Runtime::new().with_executor(Arc::new(CountingExecutor2(cc)));
rt.register_tool("echo").await;
rt.enable_tool_cache("echo", 0).await;
let action = tool_call("echo", [("message".to_string(), Value::from("hi"))].into());
rt.execute(&proposal(vec![action.clone()])).await;
assert_eq!(call_count.load(Ordering::SeqCst), 1);
rt.execute(&proposal(vec![action])).await;
assert_eq!(call_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_result_cache_different_params_different_keys() {
let call_count = Arc::new(AtomicU32::new(0));
let cc = call_count.clone();
struct CountingExecutor3(Arc<AtomicU32>);
#[async_trait::async_trait]
impl ToolExecutor for CountingExecutor3 {
async fn execute(&self, _tool: &str, params: &Value) -> Result<Value, String> {
self.0.fetch_add(1, Ordering::SeqCst);
let a = params.get("a").and_then(|v| v.as_i64()).unwrap_or(0);
let b = params.get("b").and_then(|v| v.as_i64()).unwrap_or(0);
Ok(Value::from(a + b))
}
}
let rt = Runtime::new().with_executor(Arc::new(CountingExecutor3(cc)));
rt.register_tool("add").await;
rt.enable_tool_cache("add", 60).await;
let action_a = tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
);
let action_b = tool_call(
"add",
[
("a".to_string(), Value::from(3)),
("b".to_string(), Value::from(4)),
]
.into(),
);
let r1 = rt.execute(&proposal(vec![action_a.clone()])).await;
assert_eq!(r1.results[0].output, Some(Value::from(3)));
let r2 = rt.execute(&proposal(vec![action_b.clone()])).await;
assert_eq!(r2.results[0].output, Some(Value::from(7)));
assert_eq!(call_count.load(Ordering::SeqCst), 2);
rt.execute(&proposal(vec![action_a])).await;
assert_eq!(call_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_capability_default_allows_everything() {
let rt = setup_runtime().await;
rt.set_capabilities(CapabilitySet::new()).await;
let p = proposal(vec![
tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
),
state_write("x", Value::from(42)),
]);
let result = rt.execute(&p).await;
assert!(result.all_succeeded());
}
#[tokio::test]
async fn test_capability_allowed_tool_passes() {
let rt = setup_runtime().await;
let caps = CapabilitySet::new().allow_tool("add");
rt.set_capabilities(caps).await;
let p = proposal(vec![tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
)]);
let result = rt.execute(&p).await;
assert!(result.all_succeeded());
}
#[tokio::test]
async fn test_capability_denied_tool_rejected() {
let rt = setup_runtime().await;
let caps = CapabilitySet::new().deny_tool("echo");
rt.set_capabilities(caps).await;
let p = proposal(vec![tool_call(
"echo",
[("message".to_string(), Value::from("hi"))].into(),
)]);
let result = rt.execute(&p).await;
assert_eq!(result.results[0].status, ActionStatus::Rejected);
assert!(result.results[0]
.error
.as_ref()
.unwrap()
.contains("capability denied: tool 'echo' not allowed"));
}
#[tokio::test]
async fn test_capability_unlisted_tool_rejected() {
let rt = setup_runtime().await;
let caps = CapabilitySet::new().allow_tool("add");
rt.set_capabilities(caps).await;
let p = proposal(vec![tool_call(
"echo",
[("message".to_string(), Value::from("hi"))].into(),
)]);
let result = rt.execute(&p).await;
assert_eq!(result.results[0].status, ActionStatus::Rejected);
assert!(result.results[0]
.error
.as_ref()
.unwrap()
.contains("capability denied: tool 'echo' not allowed"));
}
#[tokio::test]
async fn test_capability_deny_overrides_allow() {
let rt = setup_runtime().await;
let caps = CapabilitySet::new().allow_tool("echo").deny_tool("echo");
rt.set_capabilities(caps).await;
let p = proposal(vec![tool_call(
"echo",
[("message".to_string(), Value::from("hi"))].into(),
)]);
let result = rt.execute(&p).await;
assert_eq!(result.results[0].status, ActionStatus::Rejected);
}
#[tokio::test]
async fn test_capability_state_key_allowed() {
let rt = setup_runtime().await;
let caps = CapabilitySet::new().allow_state_key("x");
rt.set_capabilities(caps).await;
let p = proposal(vec![state_write("x", Value::from(42))]);
let result = rt.execute(&p).await;
assert!(result.all_succeeded());
}
#[tokio::test]
async fn test_capability_state_key_denied() {
let rt = setup_runtime().await;
let caps = CapabilitySet::new().allow_state_key("x");
rt.set_capabilities(caps).await;
let p = proposal(vec![state_write("secret", Value::from("nope"))]);
let result = rt.execute(&p).await;
assert_eq!(result.results[0].status, ActionStatus::Rejected);
assert!(result.results[0]
.error
.as_ref()
.unwrap()
.contains("capability denied: state key 'secret' not allowed"));
}
#[tokio::test]
async fn test_capability_state_read_key_denied() {
let rt = setup_runtime().await;
rt.state.set("secret", Value::from(42), "setup");
let caps = CapabilitySet::new().allow_state_key("x");
rt.set_capabilities(caps).await;
let mut read_action = state_write("unused", Value::Null);
read_action.action_type = ActionType::StateRead;
read_action.parameters = [("key".to_string(), Value::from("secret"))].into();
let p = proposal(vec![read_action]);
let result = rt.execute(&p).await;
assert_eq!(result.results[0].status, ActionStatus::Rejected);
assert!(result.results[0]
.error
.as_ref()
.unwrap()
.contains("capability denied: state key 'secret' not allowed"));
}
#[tokio::test]
async fn test_capability_max_actions_enforced() {
let rt = setup_runtime().await;
let caps = CapabilitySet::new().with_max_actions(1);
rt.set_capabilities(caps).await;
let p = proposal(vec![
tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
),
tool_call("echo", [("message".to_string(), Value::from("hi"))].into()),
]);
let result = rt.execute(&p).await;
assert_eq!(result.results[0].status, ActionStatus::Rejected);
assert_eq!(result.results[1].status, ActionStatus::Rejected);
assert!(result.results[0]
.error
.as_ref()
.unwrap()
.contains("capability denied"));
}
#[tokio::test]
async fn test_capability_max_actions_within_budget() {
let rt = setup_runtime().await;
let caps = CapabilitySet::new().with_max_actions(2);
rt.set_capabilities(caps).await;
let p = proposal(vec![tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
)]);
let result = rt.execute(&p).await;
assert!(result.all_succeeded());
}
#[tokio::test]
async fn test_no_capabilities_allows_everything() {
let rt = setup_runtime().await;
let p = proposal(vec![
tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
),
state_write("anything", Value::from(99)),
]);
let result = rt.execute(&p).await;
assert!(result.all_succeeded());
}
#[tokio::test]
async fn test_result_cache_invalidate_clears_correctly() {
let call_count = Arc::new(AtomicU32::new(0));
let cc = call_count.clone();
struct CountingExecutor4(Arc<AtomicU32>);
#[async_trait::async_trait]
impl ToolExecutor for CountingExecutor4 {
async fn execute(&self, _tool: &str, params: &Value) -> Result<Value, String> {
self.0.fetch_add(1, Ordering::SeqCst);
let a = params.get("a").and_then(|v| v.as_i64()).unwrap_or(0);
let b = params.get("b").and_then(|v| v.as_i64()).unwrap_or(0);
Ok(Value::from(a + b))
}
}
let rt = Runtime::new().with_executor(Arc::new(CountingExecutor4(cc)));
rt.register_tool("add").await;
rt.enable_tool_cache("add", 60).await;
let action = tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
);
rt.execute(&proposal(vec![action.clone()])).await;
assert_eq!(call_count.load(Ordering::SeqCst), 1);
rt.result_cache.invalidate("add").await;
rt.execute(&proposal(vec![action])).await;
assert_eq!(call_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_save_checkpoint_captures_state_and_tools() {
let rt = setup_runtime().await;
rt.state.set("key1", Value::from("value1"), "test");
rt.state.set("key2", Value::from(42), "test");
let cp = rt.save_checkpoint().await;
assert!(!cp.checkpoint_id.is_empty());
assert_eq!(cp.state.get("key1"), Some(&Value::from("value1")));
assert_eq!(cp.state.get("key2"), Some(&Value::from(42)));
assert!(cp.tools.contains(&"add".to_string()));
assert!(cp.tools.contains(&"echo".to_string()));
assert!(cp.tools.contains(&"fail".to_string()));
assert!(cp.tools.contains(&"slow".to_string()));
assert_eq!(cp.tools.len(), 4);
}
#[tokio::test]
async fn test_restore_checkpoint_restores_state_and_tools() {
let rt = Runtime::new();
let cp = Checkpoint {
checkpoint_id: "test-cp".to_string(),
created_at: chrono::Utc::now(),
state: [("restored_key".to_string(), Value::from("restored_value"))].into(),
events: vec![],
tools: vec!["tool_a".to_string(), "tool_b".to_string()],
metadata: HashMap::new(),
};
rt.restore_checkpoint(&cp).await;
assert_eq!(
rt.state.get("restored_key"),
Some(Value::from("restored_value"))
);
let tools = rt.tools.read().await;
assert!(tools.contains_key("tool_a"));
assert!(tools.contains_key("tool_b"));
assert_eq!(tools.len(), 2);
}
#[tokio::test]
async fn test_restore_checkpoint_clears_previous_tools() {
let rt = Runtime::new();
rt.register_tool("old_tool").await;
let cp = Checkpoint {
checkpoint_id: "test-cp".to_string(),
created_at: chrono::Utc::now(),
state: HashMap::new(),
events: vec![],
tools: vec!["new_tool".to_string()],
metadata: HashMap::new(),
};
rt.restore_checkpoint(&cp).await;
let tools = rt.tools.read().await;
assert!(!tools.contains_key("old_tool"));
assert!(tools.contains_key("new_tool"));
assert_eq!(tools.len(), 1);
}
#[tokio::test]
async fn test_checkpoint_file_roundtrip() {
let rt = setup_runtime().await;
rt.state
.set("persist_key", Value::from("persist_value"), "test");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("checkpoint.json");
let path_str = path.to_str().unwrap();
rt.save_checkpoint_to_file(path_str).await.unwrap();
assert!(path.exists());
let rt2 = Runtime::new();
let loaded = rt2.load_checkpoint_from_file(path_str).await.unwrap();
assert_eq!(
rt2.state.get("persist_key"),
Some(Value::from("persist_value"))
);
let tools = rt2.tools.read().await;
assert!(tools.contains_key("add"));
assert!(tools.contains_key("echo"));
assert!(!loaded.checkpoint_id.is_empty());
assert_eq!(
loaded.state.get("persist_key"),
Some(&Value::from("persist_value"))
);
}
#[tokio::test]
async fn test_checkpoint_serializes_as_json() {
let cp = Checkpoint {
checkpoint_id: "json-test".to_string(),
created_at: chrono::Utc::now(),
state: [("k".to_string(), Value::from(1))].into(),
events: vec![serde_json::json!({"kind": "test"})],
tools: vec!["mytool".to_string()],
metadata: [("meta_key".to_string(), Value::from("meta_val"))].into(),
};
let json = serde_json::to_string(&cp).unwrap();
let deserialized: Checkpoint = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.checkpoint_id, "json-test");
assert_eq!(deserialized.state.get("k"), Some(&Value::from(1)));
assert_eq!(deserialized.tools, vec!["mytool".to_string()]);
assert_eq!(
deserialized.metadata.get("meta_key"),
Some(&Value::from("meta_val"))
);
assert_eq!(deserialized.events.len(), 1);
}
#[tokio::test]
async fn test_checkpoint_captures_events() {
let rt = setup_runtime().await;
let p = proposal(vec![tool_call(
"add",
[
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into(),
)]);
rt.execute(&p).await;
let cp = rt.save_checkpoint().await;
assert!(!cp.events.is_empty());
}
#[tokio::test]
async fn test_load_checkpoint_from_nonexistent_file() {
let rt = Runtime::new();
let result = rt
.load_checkpoint_from_file("/tmp/nonexistent_checkpoint_file.json")
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("read error"));
}
#[tokio::test]
async fn restore_checkpoint_replaces_state_completely() {
let rt = Runtime::new();
rt.state
.set("pre_existing", serde_json::json!("should_be_gone"), "setup");
rt.state
.set("shared_key", serde_json::json!("old_value"), "setup");
let checkpoint = Checkpoint {
checkpoint_id: "cp-replace".to_string(),
created_at: chrono::Utc::now(),
state: {
let mut m = std::collections::HashMap::new();
m.insert(
"shared_key".to_string(),
serde_json::json!("checkpoint_value"),
);
m.insert("checkpoint_only".to_string(), serde_json::json!(true));
m
},
events: vec![],
tools: vec![],
metadata: HashMap::new(),
};
rt.restore_checkpoint(&checkpoint).await;
assert_eq!(
rt.state.get("shared_key"),
Some(serde_json::json!("checkpoint_value"))
);
assert_eq!(
rt.state.get("checkpoint_only"),
Some(serde_json::json!(true))
);
assert_eq!(rt.state.get("pre_existing"), None);
}
#[tokio::test]
async fn restore_checkpoint_does_not_emit_synthetic_state_transitions() {
let rt = Runtime::new();
rt.state
.set("pre_existing", serde_json::json!("old"), "setup");
let _before = rt.state.transition_count();
let checkpoint = Checkpoint {
checkpoint_id: "cp-transitions".to_string(),
created_at: chrono::Utc::now(),
state: [("replacement".to_string(), serde_json::json!(1))].into(),
events: vec![],
tools: vec![],
metadata: HashMap::new(),
};
rt.restore_checkpoint(&checkpoint).await;
assert_eq!(rt.state.transition_count(), 0);
}
#[tokio::test]
async fn restore_checkpoint_clears_idempotency_cache() {
let call_count = Arc::new(AtomicU32::new(0));
let cc = call_count.clone();
struct CountingExecutor(Arc<AtomicU32>);
#[async_trait::async_trait]
impl ToolExecutor for CountingExecutor {
async fn execute(&self, _tool: &str, _params: &Value) -> Result<Value, String> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(Value::from(42))
}
}
let rt = Runtime::new().with_executor(Arc::new(CountingExecutor(cc)));
rt.register_tool("counter").await;
let mut action = tool_call("counter", HashMap::new());
action.idempotent = true;
rt.execute(&proposal(vec![action.clone()])).await;
assert_eq!(call_count.load(Ordering::SeqCst), 1);
let mut action2 = action.clone();
action2.id = "second".to_string();
let cached_result = rt.execute(&proposal(vec![action2])).await;
assert_eq!(call_count.load(Ordering::SeqCst), 1); assert_eq!(cached_result.results[0].duration_ms, Some(0.0));
let checkpoint = Checkpoint {
checkpoint_id: "cp-idem".to_string(),
created_at: chrono::Utc::now(),
state: HashMap::new(),
events: vec![],
tools: vec!["counter".to_string()],
metadata: HashMap::new(),
};
rt.restore_checkpoint(&checkpoint).await;
let mut action3 = action.clone();
action3.id = "third".to_string();
let fresh_result = rt.execute(&proposal(vec![action3])).await;
assert_eq!(call_count.load(Ordering::SeqCst), 2); assert_eq!(fresh_result.results[0].status, ActionStatus::Succeeded);
}
#[tokio::test]
async fn test_register_tool_backward_compat() {
let rt = Runtime::new();
rt.register_tool("echo").await;
let tools = rt.tools.read().await;
assert!(tools.contains_key("echo"));
let schema = tools.get("echo").unwrap();
assert_eq!(schema.name, "echo");
assert_eq!(schema.description, "");
assert!(!schema.idempotent);
assert!(schema.cache_ttl_secs.is_none());
assert!(schema.rate_limit.is_none());
}
#[tokio::test]
async fn test_register_tool_schema_full() {
let rt = make_runtime();
let schema = ToolSchema {
name: "add".to_string(),
description: "Add two numbers".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"a": {"type": "number"},
"b": {"type": "number"}
},
"required": ["a", "b"]
}),
returns: Some(serde_json::json!({"type": "number"})),
idempotent: true,
cache_ttl_secs: None,
rate_limit: None,
};
rt.register_tool_schema(schema).await;
let tools = rt.tools.read().await;
let s = tools.get("add").unwrap();
assert_eq!(s.description, "Add two numbers");
assert!(s.idempotent);
assert_eq!(
s.parameters
.get("required")
.unwrap()
.as_array()
.unwrap()
.len(),
2
);
}
#[tokio::test]
async fn test_tool_schemas_returns_all() {
let rt = Runtime::new();
rt.register_tool("a").await;
rt.register_tool("b").await;
rt.register_tool("c").await;
let schemas = rt.tool_schemas().await;
assert_eq!(schemas.len(), 3);
let names: Vec<String> = schemas.iter().map(|s| s.name.clone()).collect();
assert!(names.contains(&"a".to_string()));
assert!(names.contains(&"b".to_string()));
assert!(names.contains(&"c".to_string()));
}
#[tokio::test]
async fn test_schema_auto_configures_cache() {
let rt = make_runtime();
let schema = ToolSchema {
name: "cached_tool".to_string(),
description: String::new(),
parameters: Value::Object(Default::default()),
returns: None,
idempotent: true,
cache_ttl_secs: Some(120),
rate_limit: None,
};
rt.register_tool_schema(schema).await;
let params = serde_json::json!({"x": 1});
rt.result_cache
.put("cached_tool", ¶ms, Value::from(42))
.await;
let cached = rt.result_cache.get("cached_tool", ¶ms).await;
assert_eq!(cached, Some(Value::from(42)));
}
#[tokio::test]
async fn test_schema_auto_configures_rate_limit() {
let rt = make_runtime();
let schema = ToolSchema {
name: "limited_tool".to_string(),
description: String::new(),
parameters: Value::Object(Default::default()),
returns: None,
idempotent: false,
cache_ttl_secs: None,
rate_limit: Some(ToolRateLimit {
max_calls: 5,
interval_secs: 1.0,
}),
};
rt.register_tool_schema(schema).await;
rt.rate_limiter.acquire("limited_tool").await;
}
#[tokio::test]
async fn test_schema_parameter_validation_rejects_missing_required() {
let rt = make_runtime();
let schema = ToolSchema {
name: "strict_tool".to_string(),
description: String::new(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"x": {"type": "number"}
},
"required": ["x"]
}),
returns: None,
idempotent: false,
cache_ttl_secs: None,
rate_limit: None,
};
rt.register_tool_schema(schema).await;
let action = tool_call("strict_tool", HashMap::new());
let p = proposal(vec![action]);
let result = rt.execute(&p).await;
assert_eq!(result.results[0].status, ActionStatus::Rejected);
assert!(result.results[0]
.error
.as_ref()
.unwrap()
.contains("missing required parameter 'x'"));
}
use crate::executor::{ReplanCallback, ReplanConfig, ReplanContext};
struct MockReplanner {
call_count: AtomicU32,
}
#[async_trait::async_trait]
impl ReplanCallback for MockReplanner {
async fn replan(&self, _ctx: &ReplanContext) -> Result<ActionProposal, String> {
self.call_count.fetch_add(1, Ordering::SeqCst);
Ok(ActionProposal {
id: "replan-proposal".to_string(),
source: "replanner".to_string(),
actions: vec![tool_call(
"echo",
[("message".to_string(), Value::from("recovered"))].into(),
)],
timestamp: chrono::Utc::now(),
context: HashMap::new(),
})
}
}
struct FailingReplanner;
#[async_trait::async_trait]
impl ReplanCallback for FailingReplanner {
async fn replan(&self, _ctx: &ReplanContext) -> Result<ActionProposal, String> {
Err("replanner failed".to_string())
}
}
struct PersistentlyFailingReplanner {
call_count: AtomicU32,
}
#[async_trait::async_trait]
impl ReplanCallback for PersistentlyFailingReplanner {
async fn replan(&self, _ctx: &ReplanContext) -> Result<ActionProposal, String> {
self.call_count.fetch_add(1, Ordering::SeqCst);
Ok(ActionProposal {
id: format!("replan-{}", self.call_count.load(Ordering::SeqCst)),
source: "replanner".to_string(),
actions: vec![tool_call("fail", HashMap::new())],
timestamp: chrono::Utc::now(),
context: HashMap::new(),
})
}
}
struct BadToolReplanner;
#[async_trait::async_trait]
impl ReplanCallback for BadToolReplanner {
async fn replan(&self, _ctx: &ReplanContext) -> Result<ActionProposal, String> {
Ok(ActionProposal {
id: "bad-replan".to_string(),
source: "test".to_string(),
actions: vec![tool_call("nonexistent_tool", HashMap::new())],
timestamp: chrono::Utc::now(),
context: HashMap::new(),
})
}
}
#[tokio::test]
async fn test_replan_recovers_from_failure() {
let replanner = Arc::new(MockReplanner {
call_count: AtomicU32::new(0),
});
let rt = make_runtime().with_replan(
replanner.clone(),
ReplanConfig {
max_replans: 3,
delay_ms: 0,
verify_before_execute: false,
replan_on_rejected: false,
},
);
rt.register_tool("echo").await;
rt.register_tool("fail").await;
let p = proposal(vec![tool_call("fail", HashMap::new())]);
let result = rt.execute(&p).await;
assert!(
result.all_succeeded(),
"expected success after replan, got: {:?}",
result.results
);
assert_eq!(replanner.call_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_replan_disabled_by_default() {
let rt = setup_runtime().await;
let p = proposal(vec![tool_call("fail", HashMap::new())]);
let result = rt.execute(&p).await;
assert!(!result.all_succeeded());
}
#[tokio::test]
async fn test_replan_exhausted_after_max_attempts() {
let replanner = Arc::new(PersistentlyFailingReplanner {
call_count: AtomicU32::new(0),
});
let rt = make_runtime().with_replan(
replanner.clone(),
ReplanConfig {
max_replans: 2,
delay_ms: 0,
verify_before_execute: false,
replan_on_rejected: false,
},
);
rt.register_tool("fail").await;
let p = proposal(vec![tool_call("fail", HashMap::new())]);
let result = rt.execute(&p).await;
assert!(!result.all_succeeded());
assert_eq!(replanner.call_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_replan_callback_failure_returns_original() {
let rt = make_runtime().with_replan(
Arc::new(FailingReplanner),
ReplanConfig {
max_replans: 3,
delay_ms: 0,
verify_before_execute: false,
replan_on_rejected: false,
},
);
rt.register_tool("fail").await;
let p = proposal(vec![tool_call("fail", HashMap::new())]);
let result = rt.execute(&p).await;
assert!(!result.all_succeeded());
}
#[tokio::test]
async fn test_rejected_does_not_replan_by_default() {
let replanner = Arc::new(MockReplanner {
call_count: AtomicU32::new(0),
});
let rt = make_runtime().with_replan(
replanner.clone(),
ReplanConfig {
max_replans: 3,
delay_ms: 0,
verify_before_execute: false,
replan_on_rejected: false,
},
);
rt.register_tool("echo").await;
rt.register_tool("blocked").await;
rt.set_capabilities(CapabilitySet::new().deny_tool("blocked"))
.await;
let p = proposal(vec![tool_call("blocked", HashMap::new())]);
let result = rt.execute(&p).await;
assert_eq!(result.results[0].status, ActionStatus::Rejected);
assert_eq!(replanner.call_count.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn test_rejected_replans_when_opted_in() {
let replanner = Arc::new(MockReplanner {
call_count: AtomicU32::new(0),
});
let rt = make_runtime().with_replan(
replanner.clone(),
ReplanConfig {
max_replans: 3,
delay_ms: 0,
verify_before_execute: false,
replan_on_rejected: true,
},
);
rt.register_tool("echo").await;
rt.register_tool("blocked").await;
rt.set_capabilities(CapabilitySet::new().deny_tool("blocked"))
.await;
let p = proposal(vec![tool_call("blocked", HashMap::new())]);
let result = rt.execute(&p).await;
assert!(
result.all_succeeded(),
"expected success after replan-on-rejected, got: {:?}",
result.results
);
assert_eq!(replanner.call_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_replan_context_contains_failure_info() {
use std::sync::Mutex;
struct CapturingReplanner {
captured: Mutex<Option<ReplanContext>>,
}
#[async_trait::async_trait]
impl ReplanCallback for CapturingReplanner {
async fn replan(&self, ctx: &ReplanContext) -> Result<ActionProposal, String> {
*self.captured.lock().unwrap() = Some(ctx.clone());
Ok(ActionProposal {
id: "recovery".to_string(),
source: "test".to_string(),
actions: vec![],
timestamp: chrono::Utc::now(),
context: HashMap::new(),
})
}
}
let replanner = Arc::new(CapturingReplanner {
captured: Mutex::new(None),
});
let rt = make_runtime().with_replan(
replanner.clone(),
ReplanConfig {
max_replans: 1,
delay_ms: 0,
verify_before_execute: false,
replan_on_rejected: false,
},
);
rt.register_tool("fail").await;
let p = proposal(vec![tool_call("fail", HashMap::new())]);
let _result = rt.execute(&p).await;
let ctx = replanner.captured.lock().unwrap().take().unwrap();
assert_eq!(ctx.attempt, 1);
assert_eq!(ctx.proposal_id, "test-proposal");
assert_eq!(ctx.original_action_count, 1);
assert!(!ctx.failed_actions.is_empty());
assert_eq!(ctx.failed_actions[0].error, "boom");
assert_eq!(ctx.replans_remaining, 0);
}
#[tokio::test]
async fn test_replan_state_is_clean_between_attempts() {
use std::sync::Mutex;
struct StateCheckingReplanner {
captured_state: Mutex<Option<HashMap<String, Value>>>,
}
#[async_trait::async_trait]
impl ReplanCallback for StateCheckingReplanner {
async fn replan(&self, ctx: &ReplanContext) -> Result<ActionProposal, String> {
*self.captured_state.lock().unwrap() = Some(ctx.state_snapshot.clone());
Ok(ActionProposal {
id: "replan-clean".to_string(),
source: "test".to_string(),
actions: vec![tool_call(
"echo",
[("message".to_string(), Value::from("clean"))].into(),
)],
timestamp: chrono::Utc::now(),
context: HashMap::new(),
})
}
}
let replanner = Arc::new(StateCheckingReplanner {
captured_state: Mutex::new(None),
});
let rt = make_runtime().with_replan(
replanner.clone(),
ReplanConfig {
max_replans: 1,
delay_ms: 0,
verify_before_execute: false,
replan_on_rejected: false,
},
);
rt.register_tool("echo").await;
rt.register_tool("fail").await;
let mut write_then_fail = vec![
state_write("dirty_key", Value::from("dirty_value")),
tool_call("fail", HashMap::new()),
];
write_then_fail[1]
.state_dependencies
.push("dirty_key".to_string());
let p = proposal(write_then_fail);
let result = rt.execute(&p).await;
assert!(result.all_succeeded());
let snapshot = replanner.captured_state.lock().unwrap().take().unwrap();
assert!(
!snapshot.contains_key("dirty_key"),
"state should be clean after rollback, but found dirty_key: {:?}",
snapshot
);
}
#[tokio::test]
async fn test_replan_with_unregistered_tool_is_rejected() {
let rt = make_runtime().with_replan(
Arc::new(BadToolReplanner),
ReplanConfig {
max_replans: 2,
delay_ms: 0,
verify_before_execute: false,
replan_on_rejected: false,
},
);
rt.register_tool("fail").await;
let p = proposal(vec![tool_call("fail", HashMap::new())]);
let result = rt.execute(&p).await;
assert!(!result.all_succeeded());
}
#[tokio::test]
async fn test_replan_quality_gate_rejects_bad_proposal() {
let replanner = Arc::new(MockReplanner {
call_count: AtomicU32::new(0),
});
let rt = make_runtime().with_replan(
replanner.clone(),
ReplanConfig {
max_replans: 1,
delay_ms: 0,
verify_before_execute: true,
replan_on_rejected: false,
},
);
rt.register_tool("echo").await;
rt.register_tool("fail").await;
let p = proposal(vec![tool_call("fail", HashMap::new())]);
let result = rt.execute(&p).await;
assert!(result.all_succeeded());
assert_eq!(replanner.call_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_replan_quality_gate_blocks_invalid_replan() {
let rt = make_runtime().with_replan(
Arc::new(BadToolReplanner),
ReplanConfig {
max_replans: 2,
delay_ms: 0,
verify_before_execute: true,
replan_on_rejected: false,
},
);
rt.register_tool("fail").await;
let p = proposal(vec![tool_call("fail", HashMap::new())]);
let result = rt.execute(&p).await;
assert!(!result.all_succeeded());
}
#[tokio::test]
async fn test_plan_and_execute_picks_best() {
let rt = setup_runtime().await;
let good = proposal(vec![tool_call(
"echo",
[("message".to_string(), Value::from("hello"))].into(),
)]);
let bad = proposal(vec![tool_call("fail", HashMap::new())]);
let result = rt.plan_and_execute(&[bad, good], None, None).await;
assert!(result.all_succeeded());
}
#[tokio::test]
async fn test_plan_and_execute_falls_back() {
let rt = setup_runtime().await;
let fails = proposal(vec![tool_call("fail", HashMap::new())]);
let succeeds = proposal(vec![tool_call(
"echo",
[("message".to_string(), Value::from("fallback"))].into(),
)]);
let result = rt.plan_and_execute(&[fails, succeeds], None, None).await;
assert!(result.all_succeeded());
}
#[tokio::test]
async fn test_plan_and_execute_empty_candidates() {
let rt = setup_runtime().await;
let result = rt.plan_and_execute(&[], None, None).await;
assert_eq!(result.proposal_id, "empty");
assert!(result.results.is_empty());
}
#[tokio::test]
async fn execute_with_cancel_emits_canceled_results_when_token_tripped() {
use tokio_util::sync::CancellationToken;
let rt = setup_runtime().await;
let mut a1 = state_write("x", Value::from(1));
a1.id = "a1".into();
let mut a2 = state_write("y", Value::from(2));
a2.id = "a2".into();
a2.state_dependencies = vec!["x".into()];
let mut a3 = state_write("z", Value::from(3));
a3.id = "a3".into();
a3.state_dependencies = vec!["y".into()];
let proposal = ActionProposal {
id: "p-cancel".into(),
source: "test".into(),
actions: vec![a1, a2, a3],
timestamp: chrono::Utc::now(),
context: HashMap::new(),
};
let token = CancellationToken::new();
token.cancel();
let result = rt.execute_with_cancel(&proposal, &token).await;
assert_eq!(result.results.len(), 3, "all 3 actions accounted for");
for r in &result.results {
let err = r.error.as_deref().unwrap_or("");
assert!(
err.contains("canceled"),
"action {} should be canceled, got status={:?} error={:?}",
r.action_id,
r.status,
r.error
);
}
}
#[tokio::test]
async fn execute_with_cancel_skips_later_levels_when_tripped_mid_execute() {
use tokio_util::sync::CancellationToken;
let rt = setup_runtime().await;
let mut a0 = state_write("x", Value::from(1));
a0.id = "a0".into();
let mut a1 = tool_call("slow", HashMap::new());
a1.id = "a1".into();
a1.state_dependencies = vec!["x".into()];
let mut a2 = state_write("y", Value::from(2));
a2.id = "a2".into();
a2.state_dependencies = vec!["x".into()];
let mut a3 = state_write("z", Value::from(3));
a3.id = "a3".into();
a3.state_dependencies = vec!["y".into()];
let proposal = ActionProposal {
id: "p-mid".into(),
source: "test".into(),
actions: vec![a0, a1, a2, a3],
timestamp: chrono::Utc::now(),
context: HashMap::new(),
};
let token = CancellationToken::new();
let token_for_trip = token.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
token_for_trip.cancel();
});
let result = rt.execute_with_cancel(&proposal, &token).await;
let a0_result = result.results.iter().find(|r| r.action_id == "a0").unwrap();
assert_eq!(a0_result.status, ActionStatus::Succeeded);
let later_canceled = result
.results
.iter()
.filter(|r| matches!(r.action_id.as_str(), "a2" | "a3"))
.filter(|r| r.error.as_deref().is_some_and(|e| e.contains("canceled")))
.count();
assert!(
later_canceled >= 1,
"expected ≥1 of a2/a3 to be canceled mid-execute, got results: {:?}",
result
.results
.iter()
.map(|r| (&r.action_id, &r.status, &r.error))
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn execute_with_cancel_no_op_when_token_never_tripped() {
use tokio_util::sync::CancellationToken;
let rt = setup_runtime().await;
let mut params = HashMap::new();
params.insert("a".into(), Value::from(1));
params.insert("b".into(), Value::from(2));
let action = tool_call("add", params);
let proposal = ActionProposal {
id: "p-no-cancel".into(),
source: "test".into(),
actions: vec![action],
timestamp: chrono::Utc::now(),
context: HashMap::new(),
};
let token = CancellationToken::new();
let result = rt.execute_with_cancel(&proposal, &token).await;
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].status, ActionStatus::Succeeded);
assert_eq!(result.results[0].output, Some(Value::from(3)));
}
#[tokio::test]
async fn execute_falls_through_to_execute_with_cancel_with_inert_token() {
let rt = setup_runtime().await;
let mut params = HashMap::new();
params.insert("a".into(), Value::from(40));
params.insert("b".into(), Value::from(2));
let proposal = ActionProposal {
id: "p-default".into(),
source: "test".into(),
actions: vec![tool_call("add", params)],
timestamp: chrono::Utc::now(),
context: HashMap::new(),
};
let result = rt.execute(&proposal).await;
assert_eq!(result.results[0].output, Some(Value::from(42)));
}
#[tokio::test]
async fn execute_scoped_isolates_state_writes_between_tenants() {
let rt = setup_runtime().await;
let acme_scope = crate::scope::RuntimeScope {
caller_id: Some("alice".into()),
tenant_id: Some("acme".into()),
claims: Default::default(),
};
let _ = rt
.execute_scoped(
&proposal(vec![state_write("config", Value::from("A"))]),
&acme_scope,
)
.await;
let globex_scope = crate::scope::RuntimeScope {
caller_id: Some("bob".into()),
tenant_id: Some("globex".into()),
claims: Default::default(),
};
let _ = rt
.execute_scoped(
&proposal(vec![state_write("config", Value::from("G"))]),
&globex_scope,
)
.await;
assert_eq!(
rt.state.scoped(Some("acme")).get("config"),
Some(Value::from("A"))
);
assert_eq!(
rt.state.scoped(Some("globex")).get("config"),
Some(Value::from("G"))
);
assert!(rt.state.get("config").is_none());
}
#[tokio::test]
async fn execute_scoped_state_read_returns_tenant_value() {
let rt = setup_runtime().await;
let scope = crate::scope::RuntimeScope {
caller_id: Some("alice".into()),
tenant_id: Some("acme".into()),
claims: Default::default(),
};
let mut params = HashMap::new();
params.insert("key".into(), Value::from("greeting"));
let read_action = {
let mut a = Action::new(ActionType::StateRead);
a.id = "r1".into();
a.parameters = params;
a.max_retries = 0;
a
};
let _ = rt
.execute_scoped(
&proposal(vec![state_write("greeting", Value::from("hello acme"))]),
&scope,
)
.await;
let result = rt
.execute_scoped(&proposal(vec![read_action]), &scope)
.await;
assert_eq!(result.results[0].output, Some(Value::from("hello acme")));
}
fn conflicting_proposal() -> ActionProposal {
let mut a = tool_call("echo", HashMap::new());
a.expected_effects = [("k".to_string(), Value::from(1))].into();
let mut b = tool_call("echo", HashMap::new());
b.expected_effects = [("k".to_string(), Value::from(2))].into();
proposal(vec![a, b])
}
#[tokio::test]
async fn transaction_strict_rejects_conflicting_proposal() {
let rt = setup_runtime().await;
rt.set_transaction_check_mode(TransactionCheckMode::Strict)
.await;
let result = rt.execute(&conflicting_proposal()).await;
assert!(result
.results
.iter()
.all(|r| r.status == ActionStatus::Rejected));
assert!(result.results.iter().any(|r| r
.error
.as_deref()
.unwrap_or("")
.contains("transactional conflict")));
let log = rt.log.lock().await;
let conflict = log
.events()
.iter()
.find(|e| e.kind == car_eventlog::EventKind::TransactionConflict)
.expect("a TransactionConflict event");
assert_eq!(
conflict.data.get("kind").and_then(|v| v.as_str()),
Some("write_write")
);
}
#[tokio::test]
async fn transaction_off_by_default_skips_check() {
let rt = setup_runtime().await;
let result = rt.execute(&conflicting_proposal()).await;
assert!(result
.results
.iter()
.all(|r| r.status == ActionStatus::Succeeded));
let log = rt.log.lock().await;
assert!(!log
.events()
.iter()
.any(|e| e.kind == car_eventlog::EventKind::TransactionConflict));
}
#[tokio::test]
async fn transaction_warn_records_but_executes() {
let rt = setup_runtime().await;
rt.set_transaction_check_mode(TransactionCheckMode::Warn)
.await;
let result = rt.execute(&conflicting_proposal()).await;
assert!(result
.results
.iter()
.all(|r| r.status == ActionStatus::Succeeded));
let log = rt.log.lock().await;
assert!(log
.events()
.iter()
.any(|e| e.kind == car_eventlog::EventKind::TransactionConflict));
}
struct BlockToolGate {
forbidden_tool: String,
}
#[async_trait::async_trait]
impl crate::admission::AdmissionGate for BlockToolGate {
fn name(&self) -> &str {
"block_tool"
}
async fn check(
&self,
proposal: &ActionProposal,
_ctx: &crate::admission::GateContext<'_>,
) -> crate::admission::GateOutcome {
let blocked: Vec<String> = proposal
.actions
.iter()
.filter(|a| a.tool.as_deref() == Some(self.forbidden_tool.as_str()))
.map(|a| a.id.clone())
.collect();
if blocked.is_empty() {
crate::admission::GateOutcome::Allow
} else {
crate::admission::GateOutcome::reject_actions(
blocked,
format!("tool '{}' is forbidden", self.forbidden_tool),
)
}
}
}
struct AlwaysApprovalGate;
#[async_trait::async_trait]
impl crate::admission::AdmissionGate for AlwaysApprovalGate {
fn name(&self) -> &str {
"always_approval"
}
async fn check(
&self,
_proposal: &ActionProposal,
_ctx: &crate::admission::GateContext<'_>,
) -> crate::admission::GateOutcome {
crate::admission::GateOutcome::NeedsApproval {
actions: std::collections::HashSet::new(),
fingerprint: "fp-test".to_string(),
reason: "needs review".to_string(),
}
}
}
#[tokio::test]
async fn admission_no_gates_is_zero_overhead() {
let rt = setup_runtime().await;
assert_eq!(rt.admission_gate_count().await, 0);
let result = rt
.execute(&proposal(vec![tool_call("echo", HashMap::new())]))
.await;
assert!(result
.results
.iter()
.all(|r| r.status == ActionStatus::Succeeded));
let log = rt.log.lock().await;
assert!(!log
.events()
.iter()
.any(|e| e.kind == car_eventlog::EventKind::AdmissionGateDecision));
}
#[tokio::test]
async fn admission_gate_blocks_proposal_before_any_action_runs() {
let rt = setup_runtime().await;
rt.register_admission_gate(Arc::new(BlockToolGate {
forbidden_tool: "fail".to_string(),
}))
.await;
let result = rt
.execute(&proposal(vec![
tool_call("echo", HashMap::new()),
tool_call("fail", HashMap::new()),
]))
.await;
assert!(
result
.results
.iter()
.all(|r| r.status == ActionStatus::Rejected),
"every action should be rejected at admission"
);
assert!(result.results.iter().any(|r| r
.error
.as_deref()
.unwrap_or("")
.contains("tool 'fail' is forbidden")));
let log = rt.log.lock().await;
let ev = log
.events()
.iter()
.find(|e| e.kind == car_eventlog::EventKind::AdmissionGateDecision)
.expect("an AdmissionGateDecision event");
assert_eq!(
ev.data.get("gate").and_then(|v| v.as_str()),
Some("block_tool")
);
assert_eq!(
ev.data.get("decision").and_then(|v| v.as_str()),
Some("reject")
);
}
#[tokio::test]
async fn admission_gate_allows_clean_proposal() {
let rt = setup_runtime().await;
rt.register_admission_gate(Arc::new(BlockToolGate {
forbidden_tool: "fail".to_string(),
}))
.await;
let result = rt
.execute(&proposal(vec![tool_call("echo", HashMap::new())]))
.await;
assert!(result
.results
.iter()
.all(|r| r.status == ActionStatus::Succeeded));
let log = rt.log.lock().await;
let ev = log
.events()
.iter()
.find(|e| e.kind == car_eventlog::EventKind::AdmissionGateDecision)
.expect("an allow decision is still audited");
assert_eq!(
ev.data.get("decision").and_then(|v| v.as_str()),
Some("allow")
);
}
#[tokio::test]
async fn admission_needs_approval_fails_closed_without_transport() {
let rt = setup_runtime().await;
rt.register_admission_gate(Arc::new(AlwaysApprovalGate))
.await;
let result = rt
.execute(&proposal(vec![tool_call("echo", HashMap::new())]))
.await;
assert!(result
.results
.iter()
.all(|r| r.status == ActionStatus::Rejected));
assert!(result.results.iter().any(|r| r
.error
.as_deref()
.unwrap_or("")
.contains("requires human approval")));
}
#[tokio::test]
async fn admission_clear_gates_restores_default() {
let rt = setup_runtime().await;
rt.register_admission_gate(Arc::new(BlockToolGate {
forbidden_tool: "fail".to_string(),
}))
.await;
assert_eq!(rt.admission_gate_count().await, 1);
rt.clear_admission_gates().await;
assert_eq!(rt.admission_gate_count().await, 0);
let result = rt
.execute(&proposal(vec![tool_call("fail", HashMap::new())]))
.await;
assert!(result
.results
.iter()
.all(|r| r.status == ActionStatus::Failed));
}
#[tokio::test]
async fn load_project_policies_registers_rules() {
let rt = setup_runtime().await;
let car_dir = std::env::temp_dir().join(format!("car_a2_{}", uuid::Uuid::new_v4().simple()));
let pol = car_dir.join("policies");
fs::create_dir_all(&pol).unwrap();
fs::write(
pol.join("security.toml"),
"deny_tool = [\"deploy\"]\ndeny_keyword = [\"rm -rf /\"]\n",
)
.unwrap();
let count = rt.load_project_policies(&car_dir).await.unwrap();
assert_eq!(count, 2);
let names = rt.policies.read().await.policy_names();
assert!(names.iter().any(|n| n == "deny_tool:deploy"));
assert!(names.iter().any(|n| n == "deny_keyword:rm -rf /"));
fs::remove_dir_all(&car_dir).ok();
}
#[tokio::test]
async fn information_flow_gate_blocks_secret_to_sink_in_executor() {
let rt = setup_runtime().await;
rt.register_tool("read_secret").await;
rt.register_tool("http_request").await;
let mut labels = crate::flow::builtin_tool_labels();
labels.insert(
"read_secret".to_string(),
car_verify::infoflow::ToolLabels {
capability: Some("fs_read".to_string()),
confidentiality: car_verify::infoflow::Confidentiality::Secret,
..Default::default()
},
);
rt.register_admission_gate(Arc::new(crate::flow::InformationFlowGate::new(
crate::flow::ToolLabelConfig {
labels,
..Default::default()
},
)))
.await;
let mut a1 = tool_call("read_secret", HashMap::new());
a1.expected_effects = [("data".to_string(), Value::from(1))].into();
let mut a2 = tool_call("http_request", HashMap::new());
a2.state_dependencies = vec!["data".to_string()];
let result = rt.execute(&proposal(vec![a1, a2])).await;
assert!(result
.results
.iter()
.all(|r| r.status == ActionStatus::Rejected));
let log = rt.log.lock().await;
assert!(log.events().iter().any(|e| {
e.kind == car_eventlog::EventKind::AdmissionGateDecision
&& e.data.get("gate").and_then(|v| v.as_str()) == Some("information_flow")
&& e.data.get("decision").and_then(|v| v.as_str()) == Some("reject")
}));
}
#[tokio::test]
async fn install_information_flow_gate_from_missing_dir_uses_builtins() {
let rt = setup_runtime().await;
rt.install_information_flow_gate("/nonexistent/.car")
.await
.unwrap();
assert_eq!(rt.admission_gate_count().await, 1);
}
struct FixedApprovalGate {
fp: String,
}
#[async_trait::async_trait]
impl crate::admission::AdmissionGate for FixedApprovalGate {
fn name(&self) -> &str {
"fixed_approval"
}
async fn check(
&self,
_proposal: &ActionProposal,
_ctx: &crate::admission::GateContext<'_>,
) -> crate::admission::GateOutcome {
crate::admission::GateOutcome::NeedsApproval {
actions: std::collections::HashSet::new(),
fingerprint: self.fp.clone(),
reason: "needs review".to_string(),
}
}
}
#[tokio::test]
async fn admission_approval_resolves_via_durable_ledger() {
let rt = setup_runtime().await;
rt.set_approval_ledger_in_memory().await;
rt.register_admission_gate(Arc::new(FixedApprovalGate {
fp: "fp-xyz".to_string(),
}))
.await;
let p = || proposal(vec![tool_call("echo", HashMap::new())]);
let r1 = rt.execute(&p()).await;
assert!(r1
.results
.iter()
.all(|r| r.status == ActionStatus::Rejected));
assert!(r1.results.iter().any(|r| r
.error
.as_deref()
.unwrap_or("")
.contains("requires human approval")));
rt.approve_admission("fp-xyz", "operator", "looks fine")
.await
.unwrap();
let r2 = rt.execute(&p()).await;
assert!(
r2.results
.iter()
.all(|r| r.status == ActionStatus::Succeeded),
"approved escalation should execute"
);
rt.clear_admission_gates().await;
rt.register_admission_gate(Arc::new(FixedApprovalGate {
fp: "fp-no".to_string(),
}))
.await;
rt.reject_admission("fp-no", "operator", "nope")
.await
.unwrap();
let r3 = rt.execute(&p()).await;
assert!(r3.results.iter().any(|r| r
.error
.as_deref()
.unwrap_or("")
.contains("rejected by operator")));
}
#[tokio::test]
async fn approval_ledger_persists_across_reload() {
let dir = std::env::temp_dir().join(format!("car_a7_{}", uuid::Uuid::new_v4().simple()));
fs::create_dir_all(&dir).unwrap();
let path = dir.join("approvals.jsonl");
{
let rt = setup_runtime().await;
rt.set_approval_ledger_path(&path).await.unwrap();
rt.approve_admission("fp-persist", "op", "ok")
.await
.unwrap();
}
{
let rt = setup_runtime().await;
rt.set_approval_ledger_path(&path).await.unwrap();
assert_eq!(
rt.admission_decision("fp-persist").await,
Some(car_policy::ApprovalDecision::Approved)
);
}
fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn tool_receipts_catch_fabricated_claim_and_ground_true_claim() {
use car_eventlog::tool_receipts::{ClaimKind, ToolClaim};
let rt = setup_runtime().await;
let r = rt
.execute(&proposal(vec![tool_call("echo", HashMap::new())]))
.await;
assert!(r
.results
.iter()
.all(|x| x.status == ActionStatus::Succeeded));
let grounded = rt
.verify_tool_receipts(
&[ToolClaim {
kind: ClaimKind::Invoked,
tool: "echo".to_string(),
call_id: None,
count: None,
text: None,
}],
Some("test-proposal"),
)
.await;
assert!(grounded.grounded, "a real invocation must be grounded");
let report = rt
.verify_tool_receipts(
&[ToolClaim {
kind: ClaimKind::Invoked,
tool: "deploy".to_string(),
call_id: None,
count: None,
text: Some("I deployed it".to_string()),
}],
Some("test-proposal"),
)
.await;
assert!(!report.grounded);
assert_eq!(
report.hallucinations[0].kind,
car_eventlog::tool_receipts::HallucinationKind::FabricatedToolReference
);
let log = rt.log.lock().await;
assert!(log
.events()
.iter()
.any(|e| e.kind == car_eventlog::EventKind::ToolReceiptHallucination));
}
#[tokio::test]
async fn tool_receipts_after_retention_trim_are_ungroundable_not_fabricated() {
use car_eventlog::tool_receipts::{ClaimKind, ToolClaim};
let rt = setup_runtime().await;
let r = rt
.execute(&proposal(vec![tool_call("echo", HashMap::new())]))
.await;
assert!(r
.results
.iter()
.all(|x| x.status == ActionStatus::Succeeded));
{
let mut log = rt.log.lock().await;
let removed = log.enforce_retention(
&car_eventlog::RetentionPolicy {
max_events: Some(0),
max_age_secs: None,
},
chrono::Utc::now(),
);
assert!(removed > 0, "retention must have trimmed the run's events");
}
let truthful = ToolClaim {
kind: ClaimKind::Invoked,
tool: "echo".to_string(),
call_id: None,
count: None,
text: None,
};
let report = rt
.verify_tool_receipts(std::slice::from_ref(&truthful), Some("test-proposal"))
.await;
assert!(
report.hallucinations.is_empty(),
"an evicted-window claim must not be flagged: {:?}",
report.hallucinations
);
assert!(report.grounded, "non-accusatory outcome");
assert_eq!(report.ungroundable.len(), 1);
assert!(report.ungroundable[0]
.explanation
.contains("window evicted"));
let report = rt
.verify_tool_receipts(std::slice::from_ref(&truthful), None)
.await;
assert!(report.hallucinations.is_empty());
assert_eq!(report.ungroundable.len(), 1);
}
#[tokio::test]
async fn idempotency_cache_persists_across_restart() {
let dir = std::env::temp_dir().join(format!("car_c3_{}", uuid::Uuid::new_v4().simple()));
fs::create_dir_all(&dir).unwrap();
let path = dir.join("idempotency.jsonl");
let mut idem = tool_call("echo", HashMap::new());
idem.idempotent = true;
{
let rt = setup_runtime().await;
rt.set_idempotency_cache_path(&path).await.unwrap();
let r = rt.execute(&proposal(vec![idem.clone()])).await;
assert!(r
.results
.iter()
.all(|x| x.status == ActionStatus::Succeeded));
}
{
let rt = setup_runtime().await;
let loaded = rt.set_idempotency_cache_path(&path).await.unwrap();
assert_eq!(loaded, 1, "one cached entry should load from the journal");
let r = rt.execute(&proposal(vec![idem.clone()])).await;
assert!(r
.results
.iter()
.all(|x| x.status == ActionStatus::Succeeded));
let log = rt.log.lock().await;
assert!(
log.events()
.iter()
.any(|e| e.kind == car_eventlog::EventKind::ActionDeduplicated),
"the reloaded entry should dedupe the re-submitted action"
);
}
fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn event_log_hash_chain_verifies_after_execution() {
let rt = setup_runtime().await;
rt.enable_event_log_hash_chaining().await;
rt.execute(&proposal(vec![tool_call("echo", HashMap::new())]))
.await;
rt.execute(&proposal(vec![tool_call("echo", HashMap::new())]))
.await;
let verified = rt.verify_event_log_chain().await.expect("chain intact");
assert!(verified > 0);
}
#[tokio::test]
async fn load_project_policies_missing_dir_is_ok() {
let rt = setup_runtime().await;
let count = rt
.load_project_policies("/nonexistent/project/.car")
.await
.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn harness_config_caps_retries() {
let rt = setup_runtime().await;
let mut a = tool_call("fail", HashMap::new());
a.failure_behavior = FailureBehavior::Retry;
a.max_retries = 3;
rt.execute(&proposal(vec![a.clone()])).await;
let baseline_retries = {
let log = rt.log.lock().await;
log.events()
.iter()
.filter(|e| e.kind == car_eventlog::EventKind::ActionRetrying)
.count()
};
assert_eq!(baseline_retries, 3);
rt.set_harness_config(car_memgine::HarnessConfig {
max_retries: 0,
..Default::default()
})
.await;
let before = rt.log.lock().await.events().len();
rt.execute(&proposal(vec![a])).await;
let log = rt.log.lock().await;
let capped_retries = log.events()[before..]
.iter()
.filter(|e| e.kind == car_eventlog::EventKind::ActionRetrying)
.count();
assert_eq!(capped_retries, 0, "harness config must cap retries live");
}
#[tokio::test]
async fn harness_config_is_none_until_installed() {
let rt = setup_runtime().await;
assert!(rt.harness_config().await.is_none());
rt.set_harness_config(car_memgine::HarnessConfig::default())
.await;
assert!(rt.harness_config().await.is_some());
}
struct StreamingExecutor {
started: Arc<AtomicU32>,
}
#[async_trait::async_trait]
impl ToolExecutor for StreamingExecutor {
async fn execute(&self, tool: &str, _params: &Value) -> Result<Value, String> {
match tool {
"echo" => Ok(Value::from("oneshot")),
_ => Err(format!("unknown tool: {tool}")),
}
}
async fn execute_stream(
&self,
tool: &str,
_params: &Value,
_action_id: &str,
) -> Result<tokio::sync::mpsc::Receiver<car_ir::ToolStreamChunk>, String> {
if tool != "tail_forever" {
return Err(format!("tool '{tool}': streaming unsupported"));
}
self.started.fetch_add(1, Ordering::SeqCst);
let (tx, rx) = tokio::sync::mpsc::channel(8);
tokio::spawn(async move {
let _ = tx
.send(car_ir::ToolStreamChunk::Text {
text: "first line".into(),
})
.await;
loop {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
if tx
.send(car_ir::ToolStreamChunk::Progress {
fraction: 0.1,
message: None,
})
.await
.is_err()
{
break;
}
}
});
Ok(rx)
}
}
#[tokio::test]
async fn detached_tool_returns_handle_without_blocking_dag() {
let started = Arc::new(AtomicU32::new(0));
let rt = Runtime::new().with_executor(Arc::new(StreamingExecutor {
started: started.clone(),
}));
rt.register_tool("tail_forever").await;
rt.register_tool("echo").await;
let mut detached = tool_call("tail_forever", HashMap::new());
detached.invocation_mode = car_ir::ToolInvocationMode::LongRunning;
let oneshot = tool_call("echo", HashMap::new());
let p = proposal(vec![detached.clone(), oneshot]);
let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), rt.execute(&p))
.await
.expect("proposal must not block on the long-running tool");
let results = outcome.results;
assert_eq!(results.len(), 2);
assert_eq!(started.load(Ordering::SeqCst), 1);
let detached_result = results.iter().find(|r| r.action_id == detached.id).unwrap();
assert_eq!(detached_result.status, ActionStatus::Succeeded);
let handle_id = detached_result
.output
.as_ref()
.and_then(|o| o.get("tool_handle"))
.and_then(|h| h.as_str())
.expect("output carries tool_handle")
.to_string();
let mut saw_text = false;
for _ in 0..100 {
if let Some(poll) = rt.tool_poll(&handle_id).await {
if poll
.chunks
.iter()
.any(|c| matches!(c, car_ir::ToolStreamChunk::Text { .. }))
{
saw_text = true;
assert_eq!(poll.status, car_ir::ToolStatus::Running);
break;
}
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert!(saw_text, "streamed chunk reaches poll while running");
assert!(rt.tool_cancel(&handle_id).await);
let poll = rt
.tool_poll(&handle_id)
.await
.expect("handle still queryable");
assert_eq!(poll.status, car_ir::ToolStatus::Cancelled);
}
#[tokio::test]
async fn detached_tool_against_oneshot_executor_is_rejected() {
let rt = setup_runtime().await;
let mut a = tool_call("echo", HashMap::new());
a.invocation_mode = car_ir::ToolInvocationMode::Streaming;
a.max_retries = 0;
let p = proposal(vec![a]);
let outcome = rt.execute(&p).await;
assert_eq!(outcome.results[0].status, ActionStatus::Failed);
assert!(outcome.results[0]
.error
.as_ref()
.unwrap()
.contains("does not support streaming"));
}
struct FakeSink {
sent: std::sync::Mutex<Vec<crate::messaging::OutboundMessage>>,
}
impl FakeSink {
fn new() -> Self {
Self {
sent: std::sync::Mutex::new(Vec::new()),
}
}
fn sent(&self) -> Vec<crate::messaging::OutboundMessage> {
self.sent.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl crate::messaging::MessageSink for FakeSink {
async fn channels(&self) -> Vec<String> {
vec!["imessage".to_string()]
}
async fn send(
&self,
msg: &crate::messaging::OutboundMessage,
) -> Result<crate::messaging::MessageReceipt, String> {
self.sent.lock().unwrap().push(msg.clone());
Ok(crate::messaging::MessageReceipt::delivered("imessage").with_message_id("m-1"))
}
}
fn messaging_action(params: HashMap<String, Value>) -> Action {
let mut a = tool_call("messaging.send", params);
a.max_retries = 0;
a
}
fn message_params() -> HashMap<String, Value> {
[
("channel".to_string(), Value::from("imessage")),
("to".to_string(), Value::from("+15551112222")),
("body".to_string(), Value::from("build is green")),
("idempotency_key".to_string(), Value::from("run-42")),
]
.into()
}
#[tokio::test]
async fn test_messaging_send_is_only_advertised_with_a_sink() {
let bare = Runtime::new();
assert!(
!bare
.tool_schemas()
.await
.iter()
.any(|s| s.name == "messaging.send"),
"a runtime with no sink must not advertise a tool it cannot execute"
);
let wired = Runtime::new().with_message_sink(Arc::new(FakeSink::new()));
assert!(wired
.tool_schemas()
.await
.iter()
.any(|s| s.name == "messaging.send"));
let entry = wired.registry.get("messaging.send").await.unwrap();
assert_eq!(entry.permission, crate::registry::ToolPermission::AskUser);
assert!(entry.side_effects);
assert_eq!(entry.category.as_deref(), Some("messaging"));
}
#[tokio::test]
async fn test_messaging_send_without_sink_is_refused() {
let rt = make_runtime();
rt.register_tool_schema(car_ir::builtins::messaging_send())
.await;
let outcome = rt
.execute(&proposal(vec![messaging_action(message_params())]))
.await;
assert_eq!(outcome.results[0].status, ActionStatus::Failed);
let err = outcome.results[0].error.as_ref().unwrap();
assert!(
err.contains("messaging is not configured on this runtime"),
"{err}"
);
assert!(!err.contains("unknown tool"), "{err}");
assert!(!err.contains("no handler"), "{err}");
}
#[tokio::test]
async fn test_messaging_send_returns_the_serialized_receipt() {
let sink = Arc::new(FakeSink::new());
let rt = Runtime::new().with_message_sink(sink.clone());
let outcome = rt
.execute(&proposal(vec![messaging_action(message_params())]))
.await;
assert!(outcome.all_succeeded(), "{:?}", outcome.results[0].error);
assert_eq!(
outcome.results[0].output,
Some(serde_json::json!({
"channel": "imessage",
"message_id": "m-1",
"deduplicated": false,
}))
);
let sent = sink.sent();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].channel, "imessage");
assert_eq!(
sent[0].to,
crate::messaging::Recipient::Direct("+15551112222".to_string())
);
assert_eq!(sent[0].body, "build is green");
assert_eq!(sent[0].idempotency_key.as_deref(), Some("run-42"));
}
#[tokio::test]
async fn test_messaging_send_bad_params_never_reach_the_sink() {
let sink = Arc::new(FakeSink::new());
let rt = Runtime::new().with_message_sink(sink.clone());
let mut params = message_params();
params.insert("body".to_string(), Value::from(" "));
let outcome = rt.execute(&proposal(vec![messaging_action(params)])).await;
assert_eq!(outcome.results[0].status, ActionStatus::Failed);
assert!(outcome.results[0]
.error
.as_ref()
.unwrap()
.contains("'body' must not be empty"));
assert!(sink.sent().is_empty());
}
#[tokio::test]
async fn test_messaging_send_is_blocked_by_policy() {
let sink = Arc::new(FakeSink::new());
let rt = Runtime::new().with_message_sink(sink.clone());
{
let mut policies = rt.policies.write().await;
policies.register(
"no_outbound",
Box::new(|action, _state| {
if action.tool.as_deref() == Some("messaging.send") {
Some("outbound messaging denied by project policy".to_string())
} else {
None
}
}),
"deny messaging.send",
);
}
let outcome = rt
.execute(&proposal(vec![messaging_action(message_params())]))
.await;
assert_eq!(outcome.results[0].status, ActionStatus::Rejected);
assert!(
sink.sent().is_empty(),
"a policy-denied send must never reach the transport"
);
}
const POLICY_ALLOWLISTED_RECIPIENT: &str = "+15551112222";
const POLICY_UNLISTED_RECIPIENT: &str = "+15559998888";
const POLICY_MAX_CALLS: usize = 2;
fn governed_send(to: &str, body: &str, key: &str) -> Action {
messaging_action(
[
("channel".to_string(), Value::from("imessage")),
("to".to_string(), Value::from(to)),
("body".to_string(), Value::from(body)),
("idempotency_key".to_string(), Value::from(key)),
]
.into(),
)
}
struct GovernedMessagingProject {
_dir: tempfile::TempDir,
rt: Runtime,
sink: Arc<FakeSink>,
}
async fn governed_messaging_project() -> GovernedMessagingProject {
let dir = tempfile::tempdir().unwrap();
let policies = dir.path().join(".car").join("policies");
fs::create_dir_all(&policies).unwrap();
fs::write(
policies.join("outbound-messaging.toml"),
format!(
r#"
# Only this one person may be messaged. Any other recipient — or a send that
# names none — is denied.
[[allow_tool_param]]
tool = "messaging.send"
param = "to"
allow = ["{POLICY_ALLOWLISTED_RECIPIENT}"]
# Never let a credential-shaped string leave in a message body. The shape here
# is a synthetic marker invented for this fixture, not any real credential
# format.
[[deny_tool_param_matching]]
tool = "messaging.send"
param = "body"
matches = "EXAMPLE-NOT-A-REAL-SECRET-[A-Z0-9]{{6}}"
# Bound how much outbound messaging a run can produce. The window is an hour so
# the cap is reached by call count alone, never by elapsed time.
[[rate_limit_tool]]
tool = "messaging.send"
max_calls = {POLICY_MAX_CALLS}
interval_secs = 3600.0
"#
),
)
.unwrap();
let sink = Arc::new(FakeSink::new());
let rt = Runtime::new().with_message_sink(sink.clone());
let loaded = rt
.load_project_policies(dir.path().join(".car"))
.await
.expect("the fixture policy file must parse");
assert_eq!(loaded, 3, "all three rules in the file must be loaded");
GovernedMessagingProject {
_dir: dir,
rt,
sink,
}
}
#[tokio::test]
async fn project_policy_allowlist_admits_the_allowlisted_recipient() {
let p = governed_messaging_project().await;
let outcome =
p.rt.execute(&proposal(vec![governed_send(
POLICY_ALLOWLISTED_RECIPIENT,
"build is green",
"run-1",
)]))
.await;
assert!(outcome.all_succeeded(), "{:?}", outcome.results[0].error);
let sent = p.sink.sent();
assert_eq!(
sent.len(),
1,
"the allowlisted send must reach the transport"
);
assert_eq!(
sent[0].to,
crate::messaging::Recipient::Direct(POLICY_ALLOWLISTED_RECIPIENT.to_string())
);
assert_eq!(sent[0].body, "build is green");
}
#[tokio::test]
async fn project_policy_allowlist_refuses_an_unlisted_recipient_without_sending() {
let p = governed_messaging_project().await;
let outcome =
p.rt.execute(&proposal(vec![governed_send(
POLICY_UNLISTED_RECIPIENT,
"build is green",
"run-1",
)]))
.await;
assert_eq!(outcome.results[0].status, ActionStatus::Rejected);
assert!(
p.sink.sent().is_empty(),
"a refused send must never be handed to a transport — the verdict alone \
does not prove the message did not go out"
);
}
#[tokio::test]
async fn project_policy_content_rule_refuses_a_credential_shaped_body_without_sending() {
let p = governed_messaging_project().await;
let leak =
p.rt.execute(&proposal(vec![governed_send(
POLICY_ALLOWLISTED_RECIPIENT,
"deploy key is EXAMPLE-NOT-A-REAL-SECRET-AB12CD",
"run-1",
)]))
.await;
assert_eq!(leak.results[0].status, ActionStatus::Rejected);
assert!(
p.sink.sent().is_empty(),
"a body matching the denied pattern must never reach the transport"
);
let ordinary =
p.rt.execute(&proposal(vec![governed_send(
POLICY_ALLOWLISTED_RECIPIENT,
"deploy finished",
"run-2",
)]))
.await;
assert!(ordinary.all_succeeded(), "{:?}", ordinary.results[0].error);
let sent = p.sink.sent();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].body, "deploy finished");
}
#[tokio::test]
async fn project_policy_rate_cap_refuses_the_send_after_max_calls_without_sending() {
let p = governed_messaging_project().await;
for i in 0..POLICY_MAX_CALLS {
let outcome =
p.rt.execute(&proposal(vec![governed_send(
POLICY_ALLOWLISTED_RECIPIENT,
"status update",
&format!("run-{i}"),
)]))
.await;
assert!(
outcome.all_succeeded(),
"send {i} is within the cap: {:?}",
outcome.results[0].error
);
}
let over =
p.rt.execute(&proposal(vec![governed_send(
POLICY_ALLOWLISTED_RECIPIENT,
"status update",
"run-over",
)]))
.await;
assert_eq!(over.results[0].status, ActionStatus::Rejected);
assert_eq!(
p.sink.sent().len(),
POLICY_MAX_CALLS,
"the transport must have seen exactly the calls the cap admits, and not \
the one over it"
);
}
#[tokio::test]
async fn project_policy_refusal_names_the_rule_that_fired() {
let p = governed_messaging_project().await;
let names = p.rt.policies.read().await.policy_names();
for expected in [
"allow_tool_param:messaging.send.to",
"deny_tool_param_matching:messaging.send.body",
"rate_limit_tool:messaging.send",
] {
assert!(
names.iter().any(|n| n == expected),
"expected a rule registered as '{expected}', got {names:?}"
);
}
let refusal = |outcome: &ProposalResult| {
assert_eq!(outcome.results[0].status, ActionStatus::Rejected);
outcome.results[0].error.clone().unwrap()
};
let unlisted =
p.rt.execute(&proposal(vec![governed_send(
POLICY_UNLISTED_RECIPIENT,
"build is green",
"run-1",
)]))
.await;
let err = refusal(&unlisted);
assert!(
err.contains("policy 'allow_tool_param:messaging.send.to'"),
"{err}"
);
let leak =
p.rt.execute(&proposal(vec![governed_send(
POLICY_ALLOWLISTED_RECIPIENT,
"deploy key is EXAMPLE-NOT-A-REAL-SECRET-AB12CD",
"run-2",
)]))
.await;
let err = refusal(&leak);
assert!(
err.contains("policy 'deny_tool_param_matching:messaging.send.body'"),
"{err}"
);
assert!(!err.contains("EXAMPLE-NOT-A-REAL-SECRET-AB12CD"), "{err}");
let capped =
p.rt.execute(&proposal(vec![governed_send(
POLICY_ALLOWLISTED_RECIPIENT,
"build is green",
"run-3",
)]))
.await;
let err = refusal(&capped);
assert!(
err.contains("policy 'rate_limit_tool:messaging.send'"),
"{err}"
);
assert!(
p.sink.sent().is_empty(),
"not one of these three refusals may have reached the transport"
);
}
struct AnyToolExecutor;
#[async_trait::async_trait]
impl ToolExecutor for AnyToolExecutor {
async fn execute(&self, tool: &str, _params: &Value) -> Result<Value, String> {
Ok(Value::from(format!("{tool} ok")))
}
}
fn taint_intent_config() -> crate::intent_gate::IntentGateConfig {
crate::intent_gate::IntentGateConfig {
intent: car_verify::intent::IntentSpec {
allowed_tools: vec!["fetch_web".to_string(), "summarize".to_string()],
..Default::default()
},
untrusted_tools: vec!["fetch_web".to_string()],
on_untainted_drift: None,
}
}
async fn taint_runtime() -> Runtime {
let rt = Runtime::new().with_executor(Arc::new(AnyToolExecutor));
rt.register_tool("fetch_web").await;
rt.register_tool("summarize").await;
rt.register_tool("send_payment").await;
rt
}
fn poisoning_proposal() -> ActionProposal {
let mut fetch = tool_call("fetch_web", HashMap::new());
fetch.max_retries = 0;
fetch
.expected_effects
.insert("page".to_string(), Value::from("attacker text"));
ActionProposal {
id: "p-fetch".to_string(),
..proposal(vec![fetch])
}
}
fn replanned_payment_proposal() -> ActionProposal {
let mut pay = tool_call("send_payment", HashMap::new());
pay.max_retries = 0;
pay.state_dependencies.push("page".to_string());
ActionProposal {
id: "p-replan".to_string(),
..proposal(vec![pay])
}
}
async fn intent_verdict(rt: &Runtime, proposal_id: &str) -> String {
let log = rt.log.lock().await;
log.events()
.iter()
.find(|e| {
e.kind == car_eventlog::EventKind::AdmissionGateDecision
&& e.proposal_id.as_deref() == Some(proposal_id)
&& e.data.get("gate").and_then(|v| v.as_str()) == Some("intent")
})
.and_then(|e| e.data.get("decision").and_then(|v| v.as_str()))
.unwrap_or("<no intent decision>")
.to_string()
}
#[tokio::test]
async fn replanned_out_of_intent_action_is_hard_rejected_via_runtime_taint() {
let rt = taint_runtime().await;
rt.install_intent_gate(taint_intent_config()).await;
let first = rt.execute(&poisoning_proposal()).await;
assert!(first.all_succeeded(), "{:?}", first.results);
assert_eq!(intent_verdict(&rt, "p-fetch").await, "allow");
let ledger = rt.taint_ledger().await.expect("intent gate installs one");
assert!(ledger.tainted_keys(None).await.contains("page"));
let second = rt.execute(&replanned_payment_proposal()).await;
assert!(
second
.results
.iter()
.all(|r| r.status == ActionStatus::Rejected),
"the replanned proposal must not execute: {:?}",
second.results
);
assert_eq!(
intent_verdict(&rt, "p-replan").await,
"reject",
"an out-of-intent action reading a tainted key is the injection signature"
);
}
#[tokio::test]
async fn replanned_out_of_intent_action_only_escalates_without_runtime_taint() {
let rt = taint_runtime().await;
rt.register_admission_gate(Arc::new(crate::intent_gate::IntentGate::new(
taint_intent_config(),
)))
.await;
assert!(
rt.taint_ledger().await.is_none(),
"registering the gate directly installs no ledger"
);
let first = rt.execute(&poisoning_proposal()).await;
assert!(first.all_succeeded(), "{:?}", first.results);
let second = rt.execute(&replanned_payment_proposal()).await;
assert!(second
.results
.iter()
.all(|r| r.status == ActionStatus::Rejected));
assert_eq!(
intent_verdict(&rt, "p-replan").await,
"needs_approval",
"without runtime provenance this is only an approval escalation"
);
}
#[tokio::test]
async fn a_trusted_tool_laundering_a_tainted_key_is_still_caught() {
let rt = taint_runtime().await;
rt.install_intent_gate(taint_intent_config()).await;
assert!(rt.execute(&poisoning_proposal()).await.all_succeeded());
let mut summarize = tool_call("summarize", HashMap::new());
summarize.max_retries = 0;
summarize.state_dependencies.push("page".to_string());
summarize
.expected_effects
.insert("digest".to_string(), Value::from("summary"));
let laundered = ActionProposal {
id: "p-launder".to_string(),
..proposal(vec![summarize])
};
assert!(rt.execute(&laundered).await.all_succeeded());
let mut pay = tool_call("send_payment", HashMap::new());
pay.max_retries = 0;
pay.state_dependencies.push("digest".to_string());
let p = ActionProposal {
id: "p-after-launder".to_string(),
..proposal(vec![pay])
};
rt.execute(&p).await;
assert_eq!(intent_verdict(&rt, "p-after-launder").await, "reject");
}
#[tokio::test]
async fn no_intent_gate_means_no_taint_ledger() {
let rt = taint_runtime().await;
assert!(rt.taint_ledger().await.is_none());
assert!(rt.execute(&poisoning_proposal()).await.all_succeeded());
assert!(
rt.taint_ledger().await.is_none(),
"executing must not conjure a ledger"
);
rt.install_intent_gate(taint_intent_config()).await;
assert!(rt.taint_ledger().await.is_some());
}
#[tokio::test]
async fn reinstalling_the_intent_gate_replaces_it_and_rebinds_the_ledger() {
let rt = taint_runtime().await;
rt.install_intent_gate(taint_intent_config()).await;
assert!(rt.execute(&poisoning_proposal()).await.all_succeeded());
let first_ledger = rt.taint_ledger().await.expect("intent gate installs one");
assert!(first_ledger.tainted_keys(None).await.contains("page"));
rt.install_intent_gate(taint_intent_config()).await;
assert_eq!(
rt.admission_gate_names()
.await
.iter()
.filter(|n| n.as_str() == "intent")
.count(),
1,
"a reinstall must replace the intent gate, not add a second one: {:?}",
rt.admission_gate_names().await
);
let live_ledger = rt.taint_ledger().await.expect("still installed");
assert!(
!Arc::ptr_eq(&first_ledger, &live_ledger),
"each install builds a fresh ledger"
);
let repoison = ActionProposal {
id: "p-fetch-2".to_string(),
..poisoning_proposal()
};
assert!(rt.execute(&repoison).await.all_succeeded());
assert!(
live_ledger.tainted_keys(None).await.contains("page"),
"the executor must write to the ledger the reinstall bound"
);
let mut overwrite = tool_call("summarize", HashMap::new());
overwrite.max_retries = 0;
overwrite
.expected_effects
.insert("page".to_string(), Value::from("operator-authored text"));
let clean = ActionProposal {
id: "p-overwrite".to_string(),
..proposal(vec![overwrite])
};
assert!(rt.execute(&clean).await.all_succeeded());
assert!(
!live_ledger.tainted_keys(None).await.contains("page"),
"a trusted overwrite must clear the key in the LIVE ledger"
);
rt.execute(&replanned_payment_proposal()).await;
assert_eq!(
intent_verdict(&rt, "p-replan").await,
"needs_approval",
"after a trusted overwrite the reader is untainted drift, not an injection"
);
}
fn scoped_taint_intent_config() -> crate::intent_gate::IntentGateConfig {
crate::intent_gate::IntentGateConfig {
intent: car_verify::intent::IntentSpec {
allowed_tools: vec!["fetch_web".to_string(), "summarize".to_string()],
allowed_resources: vec!["page".to_string(), "digest".to_string()],
..Default::default()
},
untrusted_tools: vec!["fetch_web".to_string()],
on_untainted_drift: None,
}
}
fn trusted_tool_out_of_scope_proposal() -> ActionProposal {
let mut summarize = tool_call("summarize", HashMap::new());
summarize.max_retries = 0;
summarize.state_dependencies.push("page".to_string());
summarize
.expected_effects
.insert("outbox/exfil".to_string(), Value::from("attacker text"));
ActionProposal {
id: "p-scoped".to_string(),
..proposal(vec![summarize])
}
}
#[tokio::test]
async fn a_trusted_tool_writing_out_of_scope_is_rejected_when_tainted() {
let rt = taint_runtime().await;
rt.install_intent_gate(scoped_taint_intent_config()).await;
assert!(rt.execute(&poisoning_proposal()).await.all_succeeded());
assert_eq!(intent_verdict(&rt, "p-fetch").await, "allow");
let p = trusted_tool_out_of_scope_proposal();
let outcome = rt.execute(&p).await;
assert!(
outcome
.results
.iter()
.all(|r| r.status == ActionStatus::Rejected),
"the out-of-scope write must not execute: {:?}",
outcome.results
);
assert_eq!(
intent_verdict(&rt, "p-scoped").await,
"reject",
"a trusted tool writing out of scope while reading a tainted key is the injection signature"
);
}
#[tokio::test]
async fn a_trusted_tool_writing_out_of_scope_only_escalates_without_the_ledger() {
let rt = taint_runtime().await;
rt.register_admission_gate(Arc::new(crate::intent_gate::IntentGate::new(
scoped_taint_intent_config(),
)))
.await;
assert!(rt.taint_ledger().await.is_none());
assert!(rt.execute(&poisoning_proposal()).await.all_succeeded());
rt.execute(&trusted_tool_out_of_scope_proposal()).await;
assert_eq!(
intent_verdict(&rt, "p-scoped").await,
"needs_approval",
"without runtime provenance this is only an approval escalation"
);
}