use crate::admission::{AdmissionGate, GateContext, GateOutcome};
use car_ir::ActionProposal;
use car_verify::infoflow::{
check_information_flow, gate_flow, FlowAction, FlowGatePolicy, FlowPolicy, ToolLabels,
};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::path::Path;
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ToolLabelConfig {
#[serde(default)]
pub labels: HashMap<String, ToolLabels>,
#[serde(default)]
pub flow_policy: FlowPolicy,
#[serde(default)]
pub gate_policy: FlowGatePolicy,
}
pub fn builtin_tool_labels() -> HashMap<String, ToolLabels> {
fn sink(capability: &str) -> ToolLabels {
ToolLabels {
capability: Some(capability.to_string()),
sink: true,
..Default::default()
}
}
fn cap(capability: &str) -> ToolLabels {
ToolLabels {
capability: Some(capability.to_string()),
..Default::default()
}
}
let mut m = HashMap::new();
m.insert("http_request".to_string(), sink("net_send"));
m.insert("browser".to_string(), sink("net_send"));
m.insert("search".to_string(), sink("net_send"));
m.insert("read_file".to_string(), cap("fs_read"));
m.insert("write_file".to_string(), cap("fs_write"));
m.insert("edit_file".to_string(), cap("fs_write"));
m.insert("list_dir".to_string(), cap("fs_read"));
m.insert("find_files".to_string(), cap("fs_read"));
m.insert("grep_files".to_string(), cap("fs_read"));
m.insert("shell".to_string(), cap("process_exec"));
m
}
pub fn load_tool_labels(car_dir: impl AsRef<Path>) -> Result<ToolLabelConfig, FlowLoadError> {
let mut config = ToolLabelConfig {
labels: builtin_tool_labels(),
flow_policy: FlowPolicy::default(),
gate_policy: FlowGatePolicy::default(),
};
let path = car_dir.as_ref().join("tool-labels.json");
if !path.exists() {
return Ok(config);
}
let src = std::fs::read_to_string(&path).map_err(|e| FlowLoadError {
message: format!("reading {}: {e}", path.display()),
})?;
let file: ToolLabelConfig = serde_json::from_str(&src).map_err(|e| FlowLoadError {
message: format!("parsing {}: {e}", path.display()),
})?;
config.labels.extend(file.labels);
config.flow_policy = file.flow_policy;
config.gate_policy = file.gate_policy;
Ok(config)
}
pub struct InformationFlowGate {
labels: HashMap<String, ToolLabels>,
flow_policy: FlowPolicy,
gate_policy: FlowGatePolicy,
}
impl InformationFlowGate {
pub fn new(config: ToolLabelConfig) -> Self {
Self {
labels: config.labels,
flow_policy: config.flow_policy,
gate_policy: config.gate_policy,
}
}
pub fn with_builtin_labels() -> Self {
Self::new(ToolLabelConfig {
labels: builtin_tool_labels(),
..Default::default()
})
}
}
#[async_trait::async_trait]
impl AdmissionGate for InformationFlowGate {
fn name(&self) -> &str {
"information_flow"
}
async fn check(&self, proposal: &ActionProposal, _ctx: &GateContext<'_>) -> GateOutcome {
let report = check_information_flow(proposal, &self.labels, &self.flow_policy);
if report.safe {
return GateOutcome::Allow;
}
let decision = gate_flow(&report, &self.gate_policy);
match decision.action {
FlowAction::Allow => GateOutcome::Allow,
FlowAction::Block => {
let blocked: HashSet<String> = decision
.blocked
.iter()
.flat_map(|v| v.actions.iter().cloned())
.collect();
GateOutcome::Reject {
blocked,
reason: decision.reason,
}
}
FlowAction::RequireApproval => {
let actions: HashSet<String> = decision
.needs_approval
.iter()
.flat_map(|v| v.actions.iter().cloned())
.collect();
let mut fps: Vec<String> = decision
.needs_approval
.iter()
.map(car_policy::flow_fingerprint)
.collect();
fps.sort();
fps.dedup();
let fingerprint = if fps.is_empty() {
"flow:unknown".to_string()
} else {
use sha2::Digest;
let canonical = serde_json::to_string(&fps).unwrap_or_default();
format!("flow:sha256:{:x}", sha2::Sha256::digest(canonical.as_bytes()))
};
GateOutcome::NeedsApproval {
actions,
fingerprint,
reason: decision.reason,
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct FlowLoadError {
pub message: String,
}
impl fmt::Display for FlowLoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "tool-labels load error: {}", self.message)
}
}
impl std::error::Error for FlowLoadError {}
#[cfg(test)]
mod tests {
use super::*;
use car_ir::{Action, ActionType, FailureBehavior};
fn tool_action(id: &str, tool: &str) -> Action {
Action {
id: id.to_string(),
action_type: ActionType::ToolCall,
tool: Some(tool.to_string()),
parameters: HashMap::new(),
preconditions: vec![],
expected_effects: HashMap::new(),
state_dependencies: vec![],
read_set: vec![],
write_set: vec![],
assumptions: vec![],
invocation_mode: Default::default(),
idempotent: false,
max_retries: 0,
failure_behavior: FailureBehavior::Abort,
timeout_ms: None,
metadata: HashMap::new(),
}
}
fn proposal(actions: Vec<Action>) -> ActionProposal {
ActionProposal {
id: "p".to_string(),
source: "test".to_string(),
actions,
timestamp: chrono::Utc::now(),
context: HashMap::new(),
}
}
#[test]
fn builtins_mark_network_tools_as_sinks() {
let labels = builtin_tool_labels();
assert!(labels.get("http_request").unwrap().sink);
assert!(labels.get("browser").unwrap().sink);
assert!(!labels.get("read_file").unwrap().sink);
}
#[tokio::test]
async fn clean_proposal_is_allowed() {
let gate = InformationFlowGate::with_builtin_labels();
let ctx_state = HashMap::new();
let ctx_versions = HashMap::new();
let ctx = GateContext {
session_id: None,
scope: None,
state: &ctx_state,
versions: &ctx_versions,
};
let p = proposal(vec![
tool_action("a1", "read_file"),
tool_action("a2", "http_request"),
]);
assert!(matches!(gate.check(&p, &ctx).await, GateOutcome::Allow));
}
#[tokio::test]
async fn confidential_to_sink_is_blocked() {
let mut labels = builtin_tool_labels();
labels.insert(
"read_secret".to_string(),
ToolLabels {
capability: Some("fs_read".to_string()),
confidentiality: car_verify::infoflow::Confidentiality::Secret,
..Default::default()
},
);
let gate = InformationFlowGate::new(ToolLabelConfig {
labels,
..Default::default()
});
let mut a1 = tool_action("a1", "read_secret");
a1.expected_effects = [("data".to_string(), serde_json::Value::from(1))].into();
let mut a2 = tool_action("a2", "http_request");
a2.state_dependencies = vec!["data".to_string()];
let ctx_state = HashMap::new();
let ctx_versions = HashMap::new();
let ctx = GateContext {
session_id: None,
scope: None,
state: &ctx_state,
versions: &ctx_versions,
};
let outcome = gate.check(&proposal(vec![a1, a2]), &ctx).await;
assert!(
matches!(outcome, GateOutcome::Reject { .. }),
"secret reaching a sink must be blocked, got {outcome:?}"
);
}
#[test]
fn load_missing_file_returns_builtins() {
let cfg = load_tool_labels("/nonexistent/.car").unwrap();
assert!(cfg.labels.contains_key("http_request"));
}
#[test]
fn load_merges_file_over_builtins() {
let dir = std::env::temp_dir().join(format!("car_flow_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("tool-labels.json"),
r#"{"labels":{"my_tool":{"confidentiality":"secret"},"http_request":{"capability":"net_send","sink":false}}}"#,
)
.unwrap();
let cfg = load_tool_labels(&dir).unwrap();
assert_eq!(
cfg.labels.get("my_tool").unwrap().confidentiality,
car_verify::infoflow::Confidentiality::Secret
);
assert!(!cfg.labels.get("http_request").unwrap().sink);
assert!(cfg.labels.contains_key("read_file"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn load_malformed_file_is_error() {
let dir = std::env::temp_dir().join(format!("car_flow_bad_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("tool-labels.json"), "{not json").unwrap();
assert!(load_tool_labels(&dir).is_err());
std::fs::remove_dir_all(&dir).ok();
}
}