use crate::admission::{AdmissionGate, GateContext, GateOutcome};
use car_ir::{ActionProposal, ToolSchema};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::sync::RwLock as TokioRwLock;
const DEFAULT_MAX_ACTIONS: usize = 100;
const ADVISORY_ISSUE_FRAGMENTS: &[&str] = &[
"repeated identical tool call",
"precondition will fail",
"not available at this point",
];
pub fn is_blocking_issue(issue: &car_verify::VerifyIssue) -> bool {
issue.severity == "error"
&& !ADVISORY_ISSUE_FRAGMENTS
.iter()
.any(|frag| issue.message.contains(frag))
}
pub fn blocking_errors(
proposal: &ActionProposal,
state: Option<&HashMap<String, serde_json::Value>>,
tools: &HashMap<String, ToolSchema>,
max_actions: usize,
) -> Vec<car_verify::VerifyIssue> {
let result = if tools.is_empty() {
car_verify::verify(proposal, state, None, max_actions)
} else {
car_verify::verify_with_schemas(proposal, state, Some(tools), max_actions)
};
if result.valid {
return Vec::new();
}
result
.issues
.into_iter()
.filter(is_blocking_issue)
.collect()
}
pub struct StaticVerificationGate {
tools: Arc<TokioRwLock<HashMap<String, ToolSchema>>>,
max_actions: usize,
}
impl StaticVerificationGate {
pub fn new(tools: Arc<TokioRwLock<HashMap<String, ToolSchema>>>) -> Self {
Self {
tools,
max_actions: DEFAULT_MAX_ACTIONS,
}
}
pub fn with_max_actions(mut self, max_actions: usize) -> Self {
self.max_actions = max_actions;
self
}
}
#[async_trait::async_trait]
impl AdmissionGate for StaticVerificationGate {
fn name(&self) -> &str {
"static_verification"
}
async fn check(&self, proposal: &ActionProposal, ctx: &GateContext<'_>) -> GateOutcome {
let errors = {
let tools = self.tools.read().await;
blocking_errors(proposal, Some(ctx.state), &tools, self.max_actions)
};
if errors.is_empty() {
return GateOutcome::Allow;
}
let real_ids: HashSet<&str> = proposal.actions.iter().map(|a| a.id.as_str()).collect();
let blocked: HashSet<String> = errors
.iter()
.map(|i| i.action_id.clone())
.filter(|id| real_ids.contains(id.as_str()))
.collect();
let reason = format!(
"static verification failed: {}",
errors
.iter()
.map(|i| i.message.as_str())
.collect::<Vec<_>>()
.join("; ")
);
GateOutcome::Reject { blocked, reason }
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn schema(name: &str) -> ToolSchema {
serde_json::from_value(json!({ "name": name })).expect("tool schema fixture")
}
fn registry(names: &[&str]) -> Arc<TokioRwLock<HashMap<String, ToolSchema>>> {
let map = names
.iter()
.map(|n| (n.to_string(), schema(n)))
.collect::<HashMap<_, _>>();
Arc::new(TokioRwLock::new(map))
}
fn proposal_of(actions: serde_json::Value) -> ActionProposal {
serde_json::from_value(json!({
"id": "p1", "source": "test", "actions": actions,
}))
.expect("proposal fixture")
}
fn one(action_id: &str, tool: &str) -> ActionProposal {
proposal_of(json!([{
"id": action_id, "type": "tool_call", "tool": tool, "parameters": {},
}]))
}
async fn check(
gate: &StaticVerificationGate,
proposal: &ActionProposal,
state: &HashMap<String, serde_json::Value>,
) -> GateOutcome {
let versions = HashMap::new();
let ctx = GateContext {
session_id: None,
scope: None,
state,
versions: &versions,
};
gate.check(proposal, &ctx).await
}
#[tokio::test]
async fn rejects_before_any_action_when_a_later_tool_is_unregistered() {
let gate = StaticVerificationGate::new(registry(&["echo"]));
let proposal = proposal_of(json!([
{ "id": "a1", "type": "tool_call", "tool": "echo", "parameters": {} },
{ "id": "a2", "type": "tool_call", "tool": "ghost", "parameters": {} },
]));
match check(&gate, &proposal, &HashMap::new()).await {
GateOutcome::Reject { blocked, reason } => {
assert!(blocked.contains("a2"), "the offending action must be named");
assert!(
!blocked.contains("a1"),
"the valid action is collateral, not the cause"
);
assert!(
reason.contains("static verification failed"),
"got: {reason}"
);
}
other => panic!("expected Reject, got {other:?}"),
}
}
#[tokio::test]
async fn rejects_parameter_schema_violation() {
let strict: ToolSchema = serde_json::from_value(json!({
"name": "write",
"parameters": {
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"],
},
}))
.expect("schema");
let reg = registry(&[]);
reg.write().await.insert("write".to_string(), strict);
let gate = StaticVerificationGate::new(reg);
match check(&gate, &one("a1", "write"), &HashMap::new()).await {
GateOutcome::Reject { reason, .. } => {
assert!(
reason.contains("path"),
"reason should name the field: {reason}"
)
}
other => panic!("expected Reject for a missing required param, got {other:?}"),
}
}
#[tokio::test]
async fn allows_a_valid_proposal() {
let gate = StaticVerificationGate::new(registry(&["echo"]));
assert!(
matches!(
check(&gate, &one("a1", "echo"), &HashMap::new()).await,
GateOutcome::Allow
),
"a valid proposal must not be blocked"
);
}
#[tokio::test]
async fn empty_registry_does_not_block_every_proposal() {
let gate = StaticVerificationGate::new(registry(&[]));
assert!(
matches!(
check(&gate, &one("a1", "anything_at_all"), &HashMap::new()).await,
GateOutcome::Allow
),
"an empty tool registry means 'undeclared', not 'nothing exists'"
);
}
#[tokio::test]
async fn sees_tools_registered_after_construction() {
let reg = registry(&[]);
let gate = StaticVerificationGate::new(reg.clone());
reg.write().await.insert("echo".to_string(), schema("echo"));
assert!(
matches!(
check(&gate, &one("a1", "ghost"), &HashMap::new()).await,
GateOutcome::Reject { .. }
),
"the registry is now non-empty, so an unknown tool must be caught"
);
}
#[tokio::test]
async fn advisory_findings_do_not_block() {
let gate = StaticVerificationGate::new(registry(&["poll"]));
let proposal = proposal_of(json!([
{ "id": "a1", "type": "tool_call", "tool": "poll", "parameters": {} },
{ "id": "a2", "type": "tool_call", "tool": "poll", "parameters": {} },
{ "id": "a3", "type": "tool_call", "tool": "poll", "parameters": {},
"state_dependencies": ["written_but_undeclared"] },
{ "id": "a4", "type": "tool_call", "tool": "poll", "parameters": {},
"preconditions": [{ "key": "missing", "op": "exists" }] },
]));
let schemas: HashMap<String, ToolSchema> =
[("poll".to_string(), schema("poll"))].into_iter().collect();
let raw = car_verify::verify_with_schemas(&proposal, None, Some(&schemas), 100);
for frag in ADVISORY_ISSUE_FRAGMENTS {
assert!(
raw.errors().iter().any(|i| i.message.contains(frag)),
"car-verify no longer reports an error containing {frag:?} — update \
ADVISORY_ISSUE_FRAGMENTS, or the gate will start blocking on it"
);
}
assert!(
matches!(
check(&gate, &proposal, &HashMap::new()).await,
GateOutcome::Allow
),
"state-dependent and heuristic findings must not reject a proposal"
);
}
#[tokio::test]
async fn warnings_do_not_block() {
let gate = StaticVerificationGate::new(registry(&["w"]));
let proposal = proposal_of(json!([
{ "id": "a1", "type": "tool_call", "tool": "w", "parameters": {},
"expected_effects": { "k": 1 } },
{ "id": "a2", "type": "tool_call", "tool": "w", "parameters": {},
"expected_effects": { "k": 2 } },
]));
assert!(
matches!(
check(&gate, &proposal, &HashMap::new()).await,
GateOutcome::Allow
),
"a write conflict is a warning, not a refusal"
);
}
}