use crate::infoflow::{
check_information_flow, Confidentiality, FlowPolicy, FlowReport, FlowViolation,
FlowViolationKind, ToolLabels, TrustLevel,
};
use car_ir::ActionProposal;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
pub const CAP_EVALUATOR: &str = "skillhone_evaluate";
pub const CAP_REDACTOR: &str = "skillhone_redact";
pub const CAP_OPTIMIZER: &str = "skillhone_optimize";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BoundaryRole {
Evaluator,
Redactor,
Optimizer,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BoundaryRoles {
#[serde(default)]
pub evaluator_tools: Vec<String>,
#[serde(default)]
pub redactor_tools: Vec<String>,
#[serde(default)]
pub optimizer_tools: Vec<String>,
}
impl BoundaryRoles {
pub fn validate(&self) -> Result<(), String> {
if self.evaluator_tools.is_empty() || self.optimizer_tools.is_empty() {
return Err(
"a boundary needs at least one evaluator tool and one optimizer tool".to_string(),
);
}
let mut seen: HashMap<&str, BoundaryRole> = HashMap::new();
for (tools, role) in [
(&self.evaluator_tools, BoundaryRole::Evaluator),
(&self.redactor_tools, BoundaryRole::Redactor),
(&self.optimizer_tools, BoundaryRole::Optimizer),
] {
for t in tools {
if let Some(prev) = seen.insert(t.as_str(), role) {
return Err(format!(
"tool '{t}' is assigned to two roles ({prev:?} and {role:?}); \
each tool belongs to exactly one role"
));
}
}
}
Ok(())
}
pub fn labels(&self) -> HashMap<String, ToolLabels> {
let evaluators: HashSet<&str> = self.evaluator_tools.iter().map(String::as_str).collect();
let optimizers: HashSet<&str> = self.optimizer_tools.iter().map(String::as_str).collect();
let mut map: HashMap<String, ToolLabels> = HashMap::new();
for t in &self.redactor_tools {
let sole_redactor =
!evaluators.contains(t.as_str()) && !optimizers.contains(t.as_str());
map.insert(
t.clone(),
ToolLabels {
capability: Some(CAP_REDACTOR.to_string()),
declassifier: sole_redactor,
..Default::default()
},
);
}
for t in &self.evaluator_tools {
let e = map.entry(t.clone()).or_default();
e.capability = Some(CAP_EVALUATOR.to_string());
e.confidentiality = Confidentiality::Secret;
e.declassifier = false;
}
for t in &self.optimizer_tools {
let e = map.entry(t.clone()).or_default();
e.capability = Some(CAP_OPTIMIZER.to_string());
e.sink = true;
e.trust = TrustLevel::Untrusted;
e.declassifier = false;
}
map
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoundaryReport {
pub upheld: bool,
pub leaks: Vec<FlowViolation>,
pub summary: String,
pub flow: FlowReport,
}
pub fn check_eval_optimize_boundary(
proposal: &ActionProposal,
roles: &BoundaryRoles,
) -> BoundaryReport {
if let Err(reason) = roles.validate() {
return BoundaryReport {
upheld: false,
leaks: Vec::new(),
summary: format!(
"boundary not checked: the role assignment is invalid ({reason}). No claim is made about this proposal — fix the roles and re-check."
),
flow: FlowReport {
safe: false,
violations: Vec::new(),
},
};
}
let labels = roles.labels();
let flow = check_information_flow(proposal, &labels, &FlowPolicy::default());
let leaks: Vec<FlowViolation> = flow
.violations
.iter()
.filter(|v| v.kind == FlowViolationKind::SensitiveToSink)
.cloned()
.collect();
let upheld = leaks.is_empty();
let summary = if upheld {
"evaluator↔optimizer boundary holds: no unredacted evidence reaches the optimizer"
.to_string()
} else {
format!(
"boundary breached: {} flow(s) carry unredacted evaluator evidence to the optimizer \
without passing the redactor",
leaks.len()
)
};
BoundaryReport {
upheld,
leaks,
summary,
flow,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn action(id: &str, tool: &str, reads: &[&str], writes: &[&str]) -> car_ir::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<car_ir::Action>) -> ActionProposal {
serde_json::from_value(json!({ "actions": actions })).unwrap()
}
fn roles() -> BoundaryRoles {
BoundaryRoles {
evaluator_tools: vec!["run_probe".into()],
redactor_tools: vec!["redact".into()],
optimizer_tools: vec!["draft_revision".into()],
}
}
#[test]
fn direct_evidence_to_optimizer_is_a_leak() {
let p = proposal(vec![
action("e1", "run_probe", &[], &["evidence"]),
action("o1", "draft_revision", &["evidence"], &["revision"]),
]);
let r = check_eval_optimize_boundary(&p, &roles());
assert!(!r.upheld, "{}", r.summary);
assert_eq!(r.leaks.len(), 1);
assert_eq!(r.leaks[0].kind, FlowViolationKind::SensitiveToSink);
assert_eq!(r.leaks[0].actions, vec!["o1"]);
assert_eq!(r.leaks[0].key.as_deref(), Some("evidence"));
}
#[test]
fn evidence_through_redactor_upholds_the_boundary() {
let p = proposal(vec![
action("e1", "run_probe", &[], &["evidence"]),
action("r1", "redact", &["evidence"], &["redacted"]),
action("o1", "draft_revision", &["redacted"], &["revision"]),
]);
let r = check_eval_optimize_boundary(&p, &roles());
assert!(r.upheld, "redacted evidence is allowed: {:?}", r.leaks);
}
#[test]
fn transitive_evidence_without_redaction_still_leaks() {
let p = proposal(vec![
action("e1", "run_probe", &[], &["evidence"]),
action("c1", "copy", &["evidence"], &["copy"]),
action("o1", "draft_revision", &["copy"], &[]),
]);
let r = check_eval_optimize_boundary(&p, &roles());
assert!(!r.upheld);
assert_eq!(r.leaks[0].actions, vec!["o1"]);
}
#[test]
fn optimizer_reading_only_its_own_data_is_fine() {
let p = proposal(vec![
action("e1", "run_probe", &[], &["evidence"]),
action("o1", "draft_revision", &["prior_history"], &["revision"]),
]);
let r = check_eval_optimize_boundary(&p, &roles());
assert!(r.upheld, "{}", r.summary);
}
#[test]
fn an_invalid_boundary_is_never_reported_as_upheld() {
let proposal = proposal(vec![
action("a", "score", &[], &["evidence"]),
action("b", "tune", &["evidence"], &[]),
]);
let missing_optimizer = BoundaryRoles {
evaluator_tools: vec!["score".into()],
redactor_tools: vec![],
optimizer_tools: vec![],
};
assert!(
missing_optimizer.validate().is_err(),
"control: validate rejects this"
);
let report = check_eval_optimize_boundary(&proposal, &missing_optimizer);
assert!(
!report.upheld,
"an unvalidated boundary must not report as upheld: {}",
report.summary
);
assert!(report.summary.contains("invalid"));
assert!(!report.flow.safe, "no check ran, so no safety claim");
let overlapping = BoundaryRoles {
evaluator_tools: vec!["both".into()],
redactor_tools: vec![],
optimizer_tools: vec!["both".into()],
};
assert!(
overlapping.validate().is_err(),
"control: validate rejects this"
);
assert!(!check_eval_optimize_boundary(&proposal, &overlapping).upheld);
}
#[test]
fn validate_rejects_overlapping_roles() {
let bad = BoundaryRoles {
evaluator_tools: vec!["shared".into()],
redactor_tools: vec![],
optimizer_tools: vec!["shared".into()],
};
assert!(bad.validate().is_err());
}
#[test]
fn validate_requires_both_sides() {
let only_eval = BoundaryRoles {
evaluator_tools: vec!["run_probe".into()],
..Default::default()
};
assert!(only_eval.validate().is_err());
}
#[test]
fn overlap_fails_safe_no_declassifier() {
let bad = BoundaryRoles {
evaluator_tools: vec!["run_probe".into(), "shared".into()],
redactor_tools: vec!["shared".into()],
optimizer_tools: vec!["draft_revision".into()],
};
let labels = bad.labels();
let shared = &labels["shared"];
assert!(
!shared.declassifier,
"a tool that is also an evaluator must not declassify"
);
assert_eq!(shared.confidentiality, Confidentiality::Secret);
}
}