use crate::admission::{AdmissionGate, GateContext, GateOutcome};
use car_ir::ActionProposal;
use car_verify::infoflow::{
check_information_flow, gate_flow, Confidentiality, FlowAction, FlowGatePolicy, FlowPolicy,
ToolLabels, TrustLevel,
};
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()
}
}
fn source(capability: &str, confidentiality: Confidentiality) -> ToolLabels {
ToolLabels {
capability: Some(capability.to_string()),
confidentiality,
..Default::default()
}
}
let mut m = HashMap::new();
m.insert("http_request".to_string(), sink("net_send"));
m.insert("web_search".to_string(), sink("net_send"));
m.insert("browser".to_string(), sink("net_send"));
m.insert("search".to_string(), sink("net_send"));
m.insert("generate_music".to_string(), sink("net_send"));
m.insert("generate_jingle".to_string(), sink("net_send"));
m.insert("generate_studio_image".to_string(), sink("net_send"));
m.insert("generate_song".to_string(), sink("net_send"));
m.insert("run_applescript".to_string(), sink("host_automation"));
m.insert("remember".to_string(), sink("persistent_memory"));
m.insert(
"recall".to_string(),
source(
"persistent_memory_read",
car_verify::infoflow::Confidentiality::Internal,
),
);
m.insert(
"read_image_text".to_string(),
source(
"vision_read",
car_verify::infoflow::Confidentiality::Internal,
),
);
m.insert(
"classify_image".to_string(),
source(
"vision_read",
car_verify::infoflow::Confidentiality::Internal,
),
);
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
}
fn merge_builtin_label(builtin: ToolLabels, project: ToolLabels) -> ToolLabels {
let capability = builtin.capability.clone().or(project.capability);
let confidentiality = std::cmp::max(builtin.confidentiality, project.confidentiality);
let trust = if matches!(builtin.trust, TrustLevel::Untrusted)
|| matches!(project.trust, TrustLevel::Untrusted)
{
TrustLevel::Untrusted
} else {
TrustLevel::Trusted
};
ToolLabels {
capability,
confidentiality,
trust,
sink: builtin.sink || project.sink,
declassifier: builtin.declassifier,
}
}
fn merge_flow_policy(builtin: FlowPolicy, project: FlowPolicy) -> FlowPolicy {
let mut forbidden_sequences = builtin.forbidden_sequences;
forbidden_sequences.extend(project.forbidden_sequences);
forbidden_sequences.sort();
forbidden_sequences.dedup();
FlowPolicy {
min_confidential: std::cmp::min(builtin.min_confidential, project.min_confidential),
forbidden_sequences,
}
}
fn merge_gate_policy(builtin: FlowGatePolicy, project: FlowGatePolicy) -> FlowGatePolicy {
FlowGatePolicy {
on_sensitive_to_sink: std::cmp::max(
builtin.on_sensitive_to_sink,
project.on_sensitive_to_sink,
),
on_forbidden_sequence: std::cmp::max(
builtin.on_forbidden_sequence,
project.on_forbidden_sequence,
),
}
}
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()),
})?;
for (name, project_label) in file.labels {
match config.labels.remove(&name) {
Some(builtin_label) => {
config
.labels
.insert(name, merge_builtin_label(builtin_label, project_label));
}
None => {
config.labels.insert(name, project_label);
}
}
}
config.flow_policy = merge_flow_policy(config.flow_policy, file.flow_policy);
config.gate_policy = merge_gate_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("web_search").unwrap().sink);
assert!(labels.get("generate_music").unwrap().sink);
assert!(labels.get("generate_jingle").unwrap().sink);
assert!(labels.get("generate_studio_image").unwrap().sink);
assert!(labels.get("generate_song").unwrap().sink);
assert!(labels.get("run_applescript").unwrap().sink);
assert!(labels.get("remember").unwrap().sink);
assert!(labels.get("browser").unwrap().sink);
assert!(!labels.get("read_file").unwrap().sink);
}
#[test]
fn builtins_mark_recall_as_internal_source() {
let labels = builtin_tool_labels();
let recall = labels.get("recall").unwrap();
assert_eq!(
recall.confidentiality,
car_verify::infoflow::Confidentiality::Internal
);
assert!(!recall.sink);
}
#[test]
fn builtins_mark_vision_tools_as_internal_sources() {
let labels = builtin_tool_labels();
for name in ["read_image_text", "classify_image"] {
let label = labels.get(name).unwrap();
assert_eq!(
label.confidentiality,
car_verify::infoflow::Confidentiality::Internal,
"{name} should carry internal taint"
);
assert!(!label.sink, "{name} is a source, not a 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:?}"
);
}
#[tokio::test]
async fn confidential_to_web_search_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 = [("query".to_string(), serde_json::Value::from(1))].into();
let mut a2 = tool_action("a2", "web_search");
a2.state_dependencies = vec!["query".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 web_search must be blocked, got {outcome:?}"
);
}
#[tokio::test]
async fn recalled_memory_to_web_search_is_blocked_by_default() {
let gate = InformationFlowGate::with_builtin_labels();
let mut a1 = tool_action("a1", "recall");
a1.expected_effects = [("memory_context".to_string(), serde_json::Value::from(1))].into();
let mut a2 = tool_action("a2", "web_search");
a2.state_dependencies = vec!["memory_context".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 { .. }),
"recalled durable memory reaching web_search must be blocked, got {outcome:?}"
);
}
#[tokio::test]
async fn image_text_to_web_search_is_blocked_by_default() {
let gate = InformationFlowGate::with_builtin_labels();
let mut a1 = tool_action("a1", "read_image_text");
a1.expected_effects = [("ocr_text".to_string(), serde_json::Value::from(1))].into();
let mut a2 = tool_action("a2", "web_search");
a2.state_dependencies = vec!["ocr_text".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 { .. }),
"OCR output reaching web_search must be blocked, got {outcome:?}"
);
}
#[tokio::test]
async fn image_classification_to_persistent_memory_is_blocked_by_default() {
let gate = InformationFlowGate::with_builtin_labels();
let mut a1 = tool_action("a1", "classify_image");
a1.expected_effects = [("image_labels".to_string(), serde_json::Value::from(1))].into();
let mut a2 = tool_action("a2", "remember");
a2.state_dependencies = vec!["image_labels".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 { .. }),
"image classification output reaching persistent memory must be blocked, got {outcome:?}"
);
}
#[tokio::test]
async fn confidential_to_studio_generator_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 = [("prompt".to_string(), serde_json::Value::from(1))].into();
let mut a2 = tool_action("a2", "generate_studio_image");
a2.state_dependencies = vec!["prompt".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 Studio generator must be blocked, got {outcome:?}"
);
}
#[tokio::test]
async fn confidential_to_host_automation_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 = [("script".to_string(), serde_json::Value::from(1))].into();
let mut a2 = tool_action("a2", "run_applescript");
a2.state_dependencies = vec!["script".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 host automation must be blocked, got {outcome:?}"
);
}
#[tokio::test]
async fn confidential_to_persistent_memory_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 = [("fact".to_string(), serde_json::Value::from(1))].into();
let mut a2 = tool_action("a2", "remember");
a2.state_dependencies = vec!["fact".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 persistent memory 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"));
assert!(cfg.labels.contains_key("web_search"));
assert!(cfg.labels.contains_key("generate_studio_image"));
assert!(cfg.labels.contains_key("run_applescript"));
assert!(cfg.labels.contains_key("remember"));
assert!(cfg.labels.contains_key("recall"));
assert!(cfg.labels.contains_key("read_image_text"));
assert!(cfg.labels.contains_key("classify_image"));
}
#[test]
fn load_merges_file_without_weakening_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": "not_net", "sink": false},
"recall": {"confidentiality": "public"},
"remember": {"sink": false, "declassifier": true}
},
"flow_policy": {
"min_confidential": "secret",
"forbidden_sequences": [["fs_read", "net_send"]]
},
"gate_policy": {
"on_sensitive_to_sink": "allow",
"on_forbidden_sequence": "block"
}
}"#,
)
.unwrap();
let cfg = load_tool_labels(&dir).unwrap();
assert_eq!(
cfg.labels.get("my_tool").unwrap().confidentiality,
car_verify::infoflow::Confidentiality::Secret
);
let http = cfg.labels.get("http_request").unwrap();
assert!(http.sink);
assert_eq!(http.capability.as_deref(), Some("net_send"));
assert_eq!(
cfg.labels.get("recall").unwrap().confidentiality,
car_verify::infoflow::Confidentiality::Internal
);
let remember = cfg.labels.get("remember").unwrap();
assert!(remember.sink);
assert!(!remember.declassifier);
assert_eq!(
cfg.flow_policy.min_confidential,
car_verify::infoflow::Confidentiality::Internal
);
assert_eq!(
cfg.flow_policy.forbidden_sequences,
vec![("fs_read".to_string(), "net_send".to_string())]
);
assert_eq!(
cfg.gate_policy.on_sensitive_to_sink,
car_verify::infoflow::FlowAction::Block
);
assert_eq!(
cfg.gate_policy.on_forbidden_sequence,
car_verify::infoflow::FlowAction::Block
);
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();
}
}