use crate::dag::transitive_ancestors;
use car_ir::{build_dag, Action, ActionProposal, ActionType};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
fn writes_of(a: &Action) -> Vec<String> {
let mut w: Vec<String> = a.expected_effects.keys().cloned().collect();
if a.action_type == ActionType::StateWrite {
if let Some(k) = a.parameters.get("key").and_then(|v| v.as_str()) {
if !w.iter().any(|e| e == k) {
w.push(k.to_string());
}
}
}
w
}
fn reads_of(a: &Action) -> Vec<String> {
a.state_dependencies.clone()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum Confidentiality {
#[default]
Public,
Internal,
Secret,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum TrustLevel {
#[default]
Trusted,
Untrusted,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolLabels {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability: Option<String>,
#[serde(default)]
pub confidentiality: Confidentiality,
#[serde(default)]
pub trust: TrustLevel,
#[serde(default)]
pub sink: bool,
#[serde(default)]
pub declassifier: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowPolicy {
#[serde(default = "default_min_confidential")]
pub min_confidential: Confidentiality,
#[serde(default)]
pub forbidden_sequences: Vec<(String, String)>,
}
fn default_min_confidential() -> Confidentiality {
Confidentiality::Internal
}
impl Default for FlowPolicy {
fn default() -> Self {
Self {
min_confidential: Confidentiality::Internal,
forbidden_sequences: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FlowViolationKind {
SensitiveToSink,
ForbiddenSequence,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowViolation {
pub kind: FlowViolationKind,
pub actions: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
pub explanation: String,
pub mitigation: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowReport {
pub safe: bool,
pub violations: Vec<FlowViolation>,
}
impl FlowReport {
pub const fn evidence_tier(&self) -> crate::EvidenceTier {
crate::EvidenceTier::DecisionProcedure
}
}
fn labels_for<'a>(
action: &Action,
labels: &'a HashMap<String, ToolLabels>,
) -> Option<&'a ToolLabels> {
action.tool.as_deref().and_then(|t| labels.get(t))
}
pub fn check_information_flow(
proposal: &ActionProposal,
labels: &HashMap<String, ToolLabels>,
policy: &FlowPolicy,
) -> FlowReport {
let actions = &proposal.actions;
let mut violations = Vec::new();
let mut taint: HashMap<String, Confidentiality> = HashMap::new();
for level in build_dag(actions) {
for idx in level {
let action = &actions[idx];
let lbl = labels_for(action, labels).cloned().unwrap_or_default();
let input_taint = reads_of(action)
.iter()
.filter_map(|k| taint.get(k).copied())
.max()
.unwrap_or(Confidentiality::Public);
let is_sink = lbl.sink || lbl.trust == TrustLevel::Untrusted;
if is_sink && input_taint >= policy.min_confidential {
let key = reads_of(action).into_iter().find(|k| {
taint.get(k).copied().unwrap_or(Confidentiality::Public)
>= policy.min_confidential
});
violations.push(FlowViolation {
kind: FlowViolationKind::SensitiveToSink,
actions: vec![action.id.clone()],
key: key.clone(),
explanation: format!(
"action '{}'{} consumes {:?}-level data{} — sensitive data must not reach an exfiltration/untrusted sink",
action.id,
action.tool.as_deref().map(|t| format!(" (tool '{t}')")).unwrap_or_default(),
input_taint,
key.as_deref().map(|k| format!(" via key '{k}'")).unwrap_or_default(),
),
mitigation:
"route the data through a declassifier/sanitizer before this sink, or remove the dependency"
.to_string(),
});
}
let output_taint = if lbl.declassifier {
Confidentiality::Public
} else {
input_taint.max(lbl.confidentiality)
};
for k in writes_of(action) {
if lbl.declassifier {
taint.insert(k, Confidentiality::Public);
} else {
let e = taint.entry(k).or_insert(Confidentiality::Public);
if output_taint > *e {
*e = output_taint;
}
}
}
}
}
if !policy.forbidden_sequences.is_empty() {
let ancestors = transitive_ancestors(actions);
let cap = |i: usize| labels_for(&actions[i], labels).and_then(|l| l.capability.clone());
for after_idx in 0..actions.len() {
let Some(after_cap) = cap(after_idx) else {
continue;
};
for &before_idx in &ancestors[after_idx] {
let Some(before_cap) = cap(before_idx) else {
continue;
};
let forbidden = policy
.forbidden_sequences
.iter()
.any(|(b, a)| *b == before_cap && *a == after_cap);
if !forbidden {
continue;
}
let cleared = ancestors[after_idx].iter().any(|&mid| {
mid != before_idx
&& ancestors[mid].contains(&before_idx)
&& labels_for(&actions[mid], labels)
.map(|l| l.declassifier)
.unwrap_or(false)
});
if cleared {
continue;
}
violations.push(FlowViolation {
kind: FlowViolationKind::ForbiddenSequence,
actions: vec![
actions[before_idx].id.clone(),
actions[after_idx].id.clone(),
],
key: None,
explanation: format!(
"forbidden ordering: '{}' ({before_cap}) happens-before '{}' ({after_cap})",
actions[before_idx].id, actions[after_idx].id
),
mitigation:
"insert a declassifier/sanitizer between them, or break the dependency"
.to_string(),
});
}
}
}
FlowReport {
safe: violations.is_empty(),
violations,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FlowAction {
Allow,
RequireApproval,
Block,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowGatePolicy {
#[serde(default = "default_sensitive_action")]
pub on_sensitive_to_sink: FlowAction,
#[serde(default = "default_sequence_action")]
pub on_forbidden_sequence: FlowAction,
}
fn default_sensitive_action() -> FlowAction {
FlowAction::Block
}
fn default_sequence_action() -> FlowAction {
FlowAction::RequireApproval
}
impl Default for FlowGatePolicy {
fn default() -> Self {
Self {
on_sensitive_to_sink: FlowAction::Block,
on_forbidden_sequence: FlowAction::RequireApproval,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowGateDecision {
pub action: FlowAction,
pub blocked: Vec<FlowViolation>,
pub needs_approval: Vec<FlowViolation>,
pub reason: String,
}
pub fn gate_flow(report: &FlowReport, policy: &FlowGatePolicy) -> FlowGateDecision {
let mut blocked = Vec::new();
let mut needs_approval = Vec::new();
for v in &report.violations {
let action = match v.kind {
FlowViolationKind::SensitiveToSink => policy.on_sensitive_to_sink,
FlowViolationKind::ForbiddenSequence => policy.on_forbidden_sequence,
};
match action {
FlowAction::Block => blocked.push(v.clone()),
FlowAction::RequireApproval => needs_approval.push(v.clone()),
FlowAction::Allow => {}
}
}
let action = if !blocked.is_empty() {
FlowAction::Block
} else if !needs_approval.is_empty() {
FlowAction::RequireApproval
} else {
FlowAction::Allow
};
let reason = match action {
FlowAction::Block => format!(
"blocked: {} flow hazard(s) must not run ({} also need approval)",
blocked.len(),
needs_approval.len()
),
FlowAction::RequireApproval => format!(
"{} flow hazard(s) require human approval before running",
needs_approval.len()
),
FlowAction::Allow => "no flow hazards — may proceed".to_string(),
};
FlowGateDecision {
action,
blocked,
needs_approval,
reason,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn action(id: &str, tool: &str, reads: &[&str], writes: &[&str]) -> Action {
let effects: serde_json::Map<String, serde_json::Value> =
writes.iter().map(|w| (w.to_string(), json!("v"))).collect();
serde_json::from_value(json!({
"type": "tool_call",
"id": id,
"tool": tool,
"state_dependencies": reads,
"expected_effects": effects,
}))
.unwrap()
}
fn proposal(actions: Vec<Action>) -> ActionProposal {
serde_json::from_value(json!({ "actions": actions })).unwrap()
}
fn labels(pairs: Vec<(&str, ToolLabels)>) -> HashMap<String, ToolLabels> {
pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect()
}
#[test]
fn secret_reaching_sink_is_flagged() {
let p = proposal(vec![
action("a1", "read_secret", &[], &["s"]),
action("a2", "send", &["s"], &[]),
]);
let lbls = labels(vec![
(
"read_secret",
ToolLabels {
confidentiality: Confidentiality::Secret,
..Default::default()
},
),
(
"send",
ToolLabels {
sink: true,
..Default::default()
},
),
]);
let r = check_information_flow(&p, &lbls, &FlowPolicy::default());
assert!(!r.safe);
assert_eq!(r.violations.len(), 1);
assert_eq!(r.violations[0].kind, FlowViolationKind::SensitiveToSink);
assert_eq!(r.violations[0].key.as_deref(), Some("s"));
assert_eq!(r.violations[0].actions, vec!["a2"]);
}
#[test]
fn declassifier_between_clears_the_flow() {
let p = proposal(vec![
action("a1", "read_secret", &[], &["s"]),
action("a2", "sanitize", &["s"], &["clean"]),
action("a3", "send", &["clean"], &[]),
]);
let lbls = labels(vec![
(
"read_secret",
ToolLabels {
confidentiality: Confidentiality::Secret,
..Default::default()
},
),
(
"sanitize",
ToolLabels {
declassifier: true,
..Default::default()
},
),
(
"send",
ToolLabels {
sink: true,
..Default::default()
},
),
]);
let r = check_information_flow(&p, &lbls, &FlowPolicy::default());
assert!(
r.safe,
"sanitized data reaching the sink is fine: {:?}",
r.violations
);
}
#[test]
fn public_data_to_sink_is_safe() {
let p = proposal(vec![
action("a1", "read_public", &[], &["s"]),
action("a2", "send", &["s"], &[]),
]);
let lbls = labels(vec![
(
"read_public",
ToolLabels {
confidentiality: Confidentiality::Public,
..Default::default()
},
),
(
"send",
ToolLabels {
sink: true,
..Default::default()
},
),
]);
assert!(check_information_flow(&p, &lbls, &FlowPolicy::default()).safe);
}
#[test]
fn untrusted_action_counts_as_sink() {
let p = proposal(vec![
action("a1", "read_secret", &[], &["s"]),
action("a2", "eval_untrusted", &["s"], &[]),
]);
let lbls = labels(vec![
(
"read_secret",
ToolLabels {
confidentiality: Confidentiality::Secret,
..Default::default()
},
),
(
"eval_untrusted",
ToolLabels {
trust: TrustLevel::Untrusted,
..Default::default()
},
),
]);
let r = check_information_flow(&p, &lbls, &FlowPolicy::default());
assert!(!r.safe);
assert_eq!(r.violations[0].kind, FlowViolationKind::SensitiveToSink);
}
#[test]
fn taint_propagates_transitively() {
let p = proposal(vec![
action("a1", "read_secret", &[], &["s"]),
action("a2", "copy", &["s"], &["t"]),
action("a3", "send", &["t"], &[]),
]);
let lbls = labels(vec![
(
"read_secret",
ToolLabels {
confidentiality: Confidentiality::Secret,
..Default::default()
},
),
("copy", ToolLabels::default()), (
"send",
ToolLabels {
sink: true,
..Default::default()
},
),
]);
let r = check_information_flow(&p, &lbls, &FlowPolicy::default());
assert!(!r.safe, "taint must flow through the intermediate copy");
assert_eq!(r.violations[0].actions, vec!["a3"]);
}
#[test]
fn forbidden_sequence_flagged_by_ancestry() {
let p = proposal(vec![
action("a1", "reader", &[], &["x"]),
action("a2", "sender", &["x"], &[]),
]);
let lbls = labels(vec![
(
"reader",
ToolLabels {
capability: Some("secret_read".into()),
..Default::default()
},
),
(
"sender",
ToolLabels {
capability: Some("net_send".into()),
..Default::default()
},
),
]);
let policy = FlowPolicy {
forbidden_sequences: vec![("secret_read".into(), "net_send".into())],
..Default::default()
};
let r = check_information_flow(&p, &lbls, &policy);
assert!(!r.safe);
assert_eq!(r.violations[0].kind, FlowViolationKind::ForbiddenSequence);
assert_eq!(r.violations[0].actions, vec!["a1", "a2"]);
}
#[test]
fn unordered_capabilities_are_not_a_sequence_violation() {
let p = proposal(vec![
action("a1", "reader", &[], &["x"]),
action("a2", "sender", &["y"], &[]),
]);
let lbls = labels(vec![
(
"reader",
ToolLabels {
capability: Some("secret_read".into()),
..Default::default()
},
),
(
"sender",
ToolLabels {
capability: Some("net_send".into()),
..Default::default()
},
),
]);
let policy = FlowPolicy {
forbidden_sequences: vec![("secret_read".into(), "net_send".into())],
..Default::default()
};
let seq_violations = check_information_flow(&p, &lbls, &policy)
.violations
.into_iter()
.filter(|v| v.kind == FlowViolationKind::ForbiddenSequence)
.count();
assert_eq!(seq_violations, 0);
}
#[test]
fn empty_labels_is_trivially_safe() {
let p = proposal(vec![action("a1", "anything", &["a"], &["b"])]);
let r = check_information_flow(&p, &HashMap::new(), &FlowPolicy::default());
assert!(r.safe);
}
#[test]
fn internal_threshold_can_be_raised_to_secret_only() {
let p = proposal(vec![
action("a1", "read_internal", &[], &["s"]),
action("a2", "send", &["s"], &[]),
]);
let lbls = labels(vec![
(
"read_internal",
ToolLabels {
confidentiality: Confidentiality::Internal,
..Default::default()
},
),
(
"send",
ToolLabels {
sink: true,
..Default::default()
},
),
]);
assert!(!check_information_flow(&p, &lbls, &FlowPolicy::default()).safe);
let secret_only = FlowPolicy {
min_confidential: Confidentiality::Secret,
..Default::default()
};
assert!(check_information_flow(&p, &lbls, &secret_only).safe);
}
#[test]
fn safe_report_gates_to_allow() {
let report = FlowReport {
safe: true,
violations: vec![],
};
let d = gate_flow(&report, &FlowGatePolicy::default());
assert_eq!(d.action, FlowAction::Allow);
assert!(d.blocked.is_empty() && d.needs_approval.is_empty());
}
#[test]
fn sensitive_to_sink_blocks_by_default() {
let report = FlowReport {
safe: false,
violations: vec![FlowViolation {
kind: FlowViolationKind::SensitiveToSink,
actions: vec!["a2".into()],
key: Some("s".into()),
explanation: "x".into(),
mitigation: "y".into(),
}],
};
let d = gate_flow(&report, &FlowGatePolicy::default());
assert_eq!(d.action, FlowAction::Block);
assert_eq!(d.blocked.len(), 1);
}
#[test]
fn forbidden_sequence_escalates_to_approval_by_default() {
let report = FlowReport {
safe: false,
violations: vec![FlowViolation {
kind: FlowViolationKind::ForbiddenSequence,
actions: vec!["a1".into(), "a2".into()],
key: None,
explanation: "x".into(),
mitigation: "y".into(),
}],
};
let d = gate_flow(&report, &FlowGatePolicy::default());
assert_eq!(d.action, FlowAction::RequireApproval);
assert_eq!(d.needs_approval.len(), 1);
}
#[test]
fn block_dominates_when_both_present() {
let report = FlowReport {
safe: false,
violations: vec![
FlowViolation {
kind: FlowViolationKind::ForbiddenSequence,
actions: vec![],
key: None,
explanation: "".into(),
mitigation: "".into(),
},
FlowViolation {
kind: FlowViolationKind::SensitiveToSink,
actions: vec![],
key: None,
explanation: "".into(),
mitigation: "".into(),
},
],
};
let d = gate_flow(&report, &FlowGatePolicy::default());
assert_eq!(d.action, FlowAction::Block);
assert_eq!(d.blocked.len(), 1);
assert_eq!(d.needs_approval.len(), 1);
}
#[test]
fn policy_can_relax_sink_to_approval() {
let report = FlowReport {
safe: false,
violations: vec![FlowViolation {
kind: FlowViolationKind::SensitiveToSink,
actions: vec![],
key: None,
explanation: "".into(),
mitigation: "".into(),
}],
};
let policy = FlowGatePolicy {
on_sensitive_to_sink: FlowAction::RequireApproval,
..Default::default()
};
assert_eq!(
gate_flow(&report, &policy).action,
FlowAction::RequireApproval
);
}
}