use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum TaintLevel {
Public,
#[default]
Internal,
Private,
}
impl TaintLevel {
fn rank(self) -> u8 {
match self {
TaintLevel::Public => 0,
TaintLevel::Internal => 1,
TaintLevel::Private => 2,
}
}
pub fn max(self, other: TaintLevel) -> TaintLevel {
if self >= other { self } else { other }
}
pub fn from_str_loose(s: &str) -> Option<TaintLevel> {
match s.to_lowercase().as_str() {
"public" => Some(TaintLevel::Public),
"internal" => Some(TaintLevel::Internal),
"private" => Some(TaintLevel::Private),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
TaintLevel::Public => "public",
TaintLevel::Internal => "internal",
TaintLevel::Private => "private",
}
}
}
impl PartialOrd for TaintLevel {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TaintLevel {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.rank().cmp(&other.rank())
}
}
impl fmt::Display for TaintLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ToolDirection {
Inbound,
#[default]
Internal,
Outbound,
}
impl ToolDirection {
pub fn from_str_loose(s: &str) -> Option<ToolDirection> {
match s.to_lowercase().as_str() {
"inbound" => Some(ToolDirection::Inbound),
"internal" => Some(ToolDirection::Internal),
"outbound" => Some(ToolDirection::Outbound),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
ToolDirection::Inbound => "inbound",
ToolDirection::Internal => "internal",
ToolDirection::Outbound => "outbound",
}
}
}
impl fmt::Display for ToolDirection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolClassification {
pub sensitivity: TaintLevel,
pub direction: ToolDirection,
pub clearance: TaintLevel,
}
impl ToolClassification {
pub fn new(sensitivity: TaintLevel, direction: ToolDirection, clearance: TaintLevel) -> Self {
Self {
sensitivity,
direction,
clearance,
}
}
pub fn is_outbound(&self) -> bool {
self.direction == ToolDirection::Outbound
}
pub fn check_clearance(&self, taint: TaintLevel) -> bool {
if !self.is_outbound() {
return true;
}
taint <= self.clearance
}
}
impl Default for ToolClassification {
fn default() -> Self {
Self {
sensitivity: TaintLevel::Internal,
direction: ToolDirection::Internal,
clearance: TaintLevel::Public,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegionTaint {
current_level: TaintLevel,
entry_taints: Vec<TaintLevel>,
}
impl RegionTaint {
pub fn new() -> Self {
Self {
current_level: TaintLevel::Public,
entry_taints: Vec::new(),
}
}
pub fn level(&self) -> TaintLevel {
self.current_level
}
pub fn add_entry(&mut self, taint: TaintLevel) {
self.entry_taints.push(taint);
self.current_level = self.current_level.max(taint);
}
pub fn remove_oldest(&mut self) {
if !self.entry_taints.is_empty() {
self.entry_taints.remove(0);
self.recompute();
}
}
pub fn remove_at(&mut self, idx: usize) {
if idx < self.entry_taints.len() {
self.entry_taints.remove(idx);
self.recompute();
}
}
pub fn clear(&mut self) {
self.entry_taints.clear();
self.current_level = TaintLevel::Public;
}
pub fn recompute(&mut self) {
self.current_level = self
.entry_taints
.iter()
.copied()
.max()
.unwrap_or(TaintLevel::Public);
}
pub fn entry_count(&self) -> usize {
self.entry_taints.len()
}
pub fn from_entry_taints(entry_taints: Vec<TaintLevel>) -> Self {
let current_level = entry_taints
.iter()
.copied()
.max()
.unwrap_or(TaintLevel::Public);
Self {
current_level,
entry_taints,
}
}
pub fn entry_taint(&self, index: usize) -> Option<TaintLevel> {
self.entry_taints.get(index).copied()
}
}
impl Default for RegionTaint {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityConfig {
pub taint_tracking: bool,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
taint_tracking: true,
}
}
}
pub fn resolve_taint_enabled(
global: bool,
agent: Option<&SecurityConfig>,
stage: Option<&SecurityConfig>,
) -> bool {
let manifest = stage
.map(|s| s.taint_tracking)
.or_else(|| agent.map(|a| a.taint_tracking));
global || manifest.unwrap_or(false)
}
pub fn resolve_security(
global: bool,
agent: Option<&SecurityConfig>,
stage: Option<&SecurityConfig>,
) -> SecurityConfig {
let mut resolved = match stage.or(agent) {
Some(c) => c.clone(),
None => SecurityConfig {
taint_tracking: global,
},
};
resolved.taint_tracking = resolve_taint_enabled(global, agent, stage);
resolved
}
pub fn resolve_batch_tool_hint(global: bool, agent: Option<bool>, stage: Option<bool>) -> bool {
stage.or(agent).unwrap_or(global)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GateDecision {
Allowed,
Blocked {
taint_level: TaintLevel,
clearance: TaintLevel,
source_regions: Vec<String>,
tool_name: String,
},
}
impl GateDecision {
pub fn is_allowed(&self) -> bool {
matches!(self, GateDecision::Allowed)
}
pub fn blocked_levels(&self) -> Option<(TaintLevel, TaintLevel)> {
match self {
GateDecision::Blocked {
taint_level,
clearance,
..
} => Some((*taint_level, *clearance)),
GateDecision::Allowed => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GateEvent {
pub timestamp: i64,
pub agent_id: String,
pub tool_name: String,
pub taint_level: TaintLevel,
pub clearance: TaintLevel,
pub allowed: bool,
pub decision_source: GateDecisionSource,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum GateDecisionSource {
AutoAllow,
AutoBlock,
AllowlistRule { rule_index: usize },
ScriptedRule { script_name: String },
UserAllowOnce,
UserAlwaysAllow,
UserDenied,
TaintDisabled,
YoloAutoApprove,
}
pub fn builtin_tool_classification(tool_name: &str) -> ToolClassification {
match tool_name {
"read_file" => ToolClassification::new(
TaintLevel::Internal,
ToolDirection::Inbound,
TaintLevel::Public,
),
"write_file" => ToolClassification::new(
TaintLevel::Internal,
ToolDirection::Internal,
TaintLevel::Public,
),
"edit_file" => ToolClassification::new(
TaintLevel::Internal,
ToolDirection::Internal,
TaintLevel::Public,
),
"list_dir" => ToolClassification::new(
TaintLevel::Internal,
ToolDirection::Inbound,
TaintLevel::Public,
),
"shell" | "bash" => ToolClassification::new(
TaintLevel::Public,
ToolDirection::Outbound,
TaintLevel::Public,
),
"web_search" | "web_fetch" | "http_get" | "http_post" | "fetch" => ToolClassification::new(
TaintLevel::Public,
ToolDirection::Outbound,
TaintLevel::Public,
),
"ask_user_text" | "ask_user_choice" | "ask_user_confirm" | "present_for_review" => {
ToolClassification::new(
TaintLevel::Internal,
ToolDirection::Internal,
TaintLevel::Public,
)
}
"spawn_agent" | "check_agent" | "wait_for_agent" | "send_to_agent" | "kill_agent" => {
ToolClassification::new(
TaintLevel::Internal,
ToolDirection::Internal,
TaintLevel::Public,
)
}
_ => ToolClassification::new(
TaintLevel::Public,
ToolDirection::Outbound,
TaintLevel::Public,
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn taint_rebuilds_from_persisted_entries_at_the_highest_level() {
let restored = RegionTaint::from_entry_taints(vec![
TaintLevel::Public,
TaintLevel::Private,
TaintLevel::Internal,
]);
assert_eq!(restored.level(), TaintLevel::Private);
assert_eq!(restored.entry_taint(1), Some(TaintLevel::Private));
assert_eq!(
RegionTaint::from_entry_taints(Vec::new()).level(),
TaintLevel::Public
);
}
#[test]
fn taint_level_ordering() {
assert!(TaintLevel::Public < TaintLevel::Internal);
assert!(TaintLevel::Internal < TaintLevel::Private);
assert!(TaintLevel::Public < TaintLevel::Private);
}
#[test]
fn taint_level_equality() {
assert_eq!(TaintLevel::Public, TaintLevel::Public);
assert_eq!(TaintLevel::Internal, TaintLevel::Internal);
assert_eq!(TaintLevel::Private, TaintLevel::Private);
assert_ne!(TaintLevel::Public, TaintLevel::Private);
}
#[test]
fn taint_level_max() {
assert_eq!(
TaintLevel::Public.max(TaintLevel::Internal),
TaintLevel::Internal
);
assert_eq!(
TaintLevel::Private.max(TaintLevel::Public),
TaintLevel::Private
);
assert_eq!(
TaintLevel::Internal.max(TaintLevel::Internal),
TaintLevel::Internal
);
}
#[test]
fn taint_level_default_is_internal() {
assert_eq!(TaintLevel::default(), TaintLevel::Internal);
}
#[test]
fn taint_level_display() {
assert_eq!(format!("{}", TaintLevel::Public), "public");
assert_eq!(format!("{}", TaintLevel::Internal), "internal");
assert_eq!(format!("{}", TaintLevel::Private), "private");
}
#[test]
fn taint_level_from_str_loose() {
assert_eq!(
TaintLevel::from_str_loose("public"),
Some(TaintLevel::Public)
);
assert_eq!(
TaintLevel::from_str_loose("INTERNAL"),
Some(TaintLevel::Internal)
);
assert_eq!(
TaintLevel::from_str_loose("Private"),
Some(TaintLevel::Private)
);
assert_eq!(TaintLevel::from_str_loose("unknown"), None);
}
#[test]
fn taint_level_as_str() {
assert_eq!(TaintLevel::Public.as_str(), "public");
assert_eq!(TaintLevel::Internal.as_str(), "internal");
assert_eq!(TaintLevel::Private.as_str(), "private");
}
#[test]
fn taint_level_serde_roundtrip() {
for level in [
TaintLevel::Public,
TaintLevel::Internal,
TaintLevel::Private,
] {
let json = serde_json::to_string(&level).unwrap();
let back: TaintLevel = serde_json::from_str(&json).unwrap();
assert_eq!(level, back);
}
}
#[test]
fn taint_level_hash() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(TaintLevel::Public);
set.insert(TaintLevel::Internal);
set.insert(TaintLevel::Private);
set.insert(TaintLevel::Public); assert_eq!(set.len(), 3);
}
#[test]
fn tool_direction_from_str_loose() {
assert_eq!(
ToolDirection::from_str_loose("inbound"),
Some(ToolDirection::Inbound)
);
assert_eq!(
ToolDirection::from_str_loose("OUTBOUND"),
Some(ToolDirection::Outbound)
);
assert_eq!(
ToolDirection::from_str_loose("Internal"),
Some(ToolDirection::Internal)
);
assert_eq!(ToolDirection::from_str_loose("nope"), None);
}
#[test]
fn tool_direction_default_is_internal() {
assert_eq!(ToolDirection::default(), ToolDirection::Internal);
}
#[test]
fn tool_direction_display() {
assert_eq!(format!("{}", ToolDirection::Inbound), "inbound");
assert_eq!(format!("{}", ToolDirection::Internal), "internal");
assert_eq!(format!("{}", ToolDirection::Outbound), "outbound");
}
#[test]
fn tool_direction_serde_roundtrip() {
for dir in [
ToolDirection::Inbound,
ToolDirection::Internal,
ToolDirection::Outbound,
] {
let json = serde_json::to_string(&dir).unwrap();
let back: ToolDirection = serde_json::from_str(&json).unwrap();
assert_eq!(dir, back);
}
}
#[test]
fn tool_classification_default() {
let tc = ToolClassification::default();
assert_eq!(tc.sensitivity, TaintLevel::Internal);
assert_eq!(tc.direction, ToolDirection::Internal);
assert_eq!(tc.clearance, TaintLevel::Public);
}
#[test]
fn tool_classification_outbound_check() {
let tc = ToolClassification::new(
TaintLevel::Public,
ToolDirection::Outbound,
TaintLevel::Internal,
);
assert!(tc.is_outbound());
assert!(tc.check_clearance(TaintLevel::Public));
assert!(tc.check_clearance(TaintLevel::Internal));
assert!(!tc.check_clearance(TaintLevel::Private));
}
#[test]
fn tool_classification_non_outbound_always_passes() {
let tc = ToolClassification::new(
TaintLevel::Private,
ToolDirection::Inbound,
TaintLevel::Public, );
assert!(!tc.is_outbound());
assert!(tc.check_clearance(TaintLevel::Private));
}
#[test]
fn tool_classification_serde_roundtrip() {
let tc = ToolClassification::new(
TaintLevel::Private,
ToolDirection::Outbound,
TaintLevel::Internal,
);
let json = serde_json::to_string(&tc).unwrap();
let back: ToolClassification = serde_json::from_str(&json).unwrap();
assert_eq!(tc, back);
}
#[test]
fn region_taint_starts_public() {
let rt = RegionTaint::new();
assert_eq!(rt.level(), TaintLevel::Public);
assert_eq!(rt.entry_count(), 0);
}
#[test]
fn region_taint_add_entry_raises_level() {
let mut rt = RegionTaint::new();
rt.add_entry(TaintLevel::Internal);
assert_eq!(rt.level(), TaintLevel::Internal);
rt.add_entry(TaintLevel::Private);
assert_eq!(rt.level(), TaintLevel::Private);
}
#[test]
fn region_taint_add_public_doesnt_lower() {
let mut rt = RegionTaint::new();
rt.add_entry(TaintLevel::Private);
rt.add_entry(TaintLevel::Public);
assert_eq!(rt.level(), TaintLevel::Private);
}
#[test]
fn region_taint_remove_oldest_recovers() {
let mut rt = RegionTaint::new();
rt.add_entry(TaintLevel::Private);
rt.add_entry(TaintLevel::Public);
assert_eq!(rt.level(), TaintLevel::Private);
rt.remove_oldest(); assert_eq!(rt.level(), TaintLevel::Public);
}
#[test]
fn region_taint_remove_oldest_empty() {
let mut rt = RegionTaint::new();
rt.remove_oldest(); assert_eq!(rt.level(), TaintLevel::Public);
}
#[test]
fn region_taint_clear() {
let mut rt = RegionTaint::new();
rt.add_entry(TaintLevel::Private);
rt.add_entry(TaintLevel::Internal);
rt.clear();
assert_eq!(rt.level(), TaintLevel::Public);
assert_eq!(rt.entry_count(), 0);
}
#[test]
fn region_taint_recompute() {
let mut rt = RegionTaint::new();
rt.add_entry(TaintLevel::Private);
rt.add_entry(TaintLevel::Internal);
rt.add_entry(TaintLevel::Public);
assert_eq!(rt.entry_count(), 3);
rt.remove_oldest();
assert_eq!(rt.level(), TaintLevel::Internal);
assert_eq!(rt.entry_count(), 2);
}
#[test]
fn region_taint_entry_taint() {
let mut rt = RegionTaint::new();
rt.add_entry(TaintLevel::Public);
rt.add_entry(TaintLevel::Private);
assert_eq!(rt.entry_taint(0), Some(TaintLevel::Public));
assert_eq!(rt.entry_taint(1), Some(TaintLevel::Private));
assert_eq!(rt.entry_taint(2), None);
}
#[test]
fn region_taint_default() {
let rt = RegionTaint::default();
assert_eq!(rt.level(), TaintLevel::Public);
}
#[test]
fn region_taint_serde_roundtrip() {
let mut rt = RegionTaint::new();
rt.add_entry(TaintLevel::Internal);
rt.add_entry(TaintLevel::Private);
let json = serde_json::to_string(&rt).unwrap();
let back: RegionTaint = serde_json::from_str(&json).unwrap();
assert_eq!(back.level(), TaintLevel::Private);
assert_eq!(back.entry_count(), 2);
}
#[test]
fn security_config_default() {
let sc = SecurityConfig::default();
assert!(sc.taint_tracking);
}
#[test]
fn security_config_serde_roundtrip() {
let sc = SecurityConfig {
taint_tracking: false,
};
let json = serde_json::to_string(&sc).unwrap();
let back: SecurityConfig = serde_json::from_str(&json).unwrap();
assert!(!back.taint_tracking);
}
#[test]
fn gate_decision_allowed() {
let d = GateDecision::Allowed;
assert!(d.is_allowed());
}
#[test]
fn gate_decision_blocked() {
let d = GateDecision::Blocked {
taint_level: TaintLevel::Private,
clearance: TaintLevel::Public,
source_regions: vec!["conversation".into()],
tool_name: "send_email".into(),
};
assert!(!d.is_allowed());
}
#[test]
fn gate_event_serde_roundtrip() {
let event = GateEvent {
timestamp: 1234567890,
agent_id: "agent-1".into(),
tool_name: "send_email".into(),
taint_level: TaintLevel::Private,
clearance: TaintLevel::Public,
allowed: false,
decision_source: GateDecisionSource::UserDenied,
};
let json = serde_json::to_string(&event).unwrap();
let back: GateEvent = serde_json::from_str(&json).unwrap();
assert_eq!(back.agent_id, "agent-1");
assert!(!back.allowed);
}
#[test]
fn gate_decision_source_variants() {
let sources = vec![
GateDecisionSource::AutoAllow,
GateDecisionSource::AllowlistRule { rule_index: 0 },
GateDecisionSource::ScriptedRule {
script_name: "test.rhai".into(),
},
GateDecisionSource::UserAllowOnce,
GateDecisionSource::UserAlwaysAllow,
GateDecisionSource::UserDenied,
GateDecisionSource::TaintDisabled,
];
for src in sources {
let json = serde_json::to_string(&src).unwrap();
let back: GateDecisionSource = serde_json::from_str(&json).unwrap();
assert_eq!(src, back);
}
}
#[test]
fn builtin_read_file_classification() {
let tc = builtin_tool_classification("read_file");
assert_eq!(tc.sensitivity, TaintLevel::Internal);
assert_eq!(tc.direction, ToolDirection::Inbound);
}
#[test]
fn builtin_shell_classification() {
let tc = builtin_tool_classification("shell");
assert_eq!(tc.sensitivity, TaintLevel::Public);
assert_eq!(tc.direction, ToolDirection::Outbound);
assert_eq!(tc.clearance, TaintLevel::Public);
let tc2 = builtin_tool_classification("bash");
assert_eq!(tc2.direction, ToolDirection::Outbound);
}
#[test]
fn network_capable_tools_are_outbound() {
for name in ["web_search", "web_fetch", "http_get", "http_post", "fetch"] {
let tc = builtin_tool_classification(name);
assert_eq!(tc.sensitivity, TaintLevel::Public, "{name}");
assert_eq!(tc.direction, ToolDirection::Outbound, "{name}");
}
}
#[test]
fn builtin_ask_user_classification() {
for name in [
"ask_user_text",
"ask_user_choice",
"ask_user_confirm",
"present_for_review",
] {
let tc = builtin_tool_classification(name);
assert_eq!(tc.direction, ToolDirection::Internal);
}
}
#[test]
fn builtin_subagent_classification() {
for name in [
"spawn_agent",
"check_agent",
"wait_for_agent",
"send_to_agent",
"kill_agent",
] {
let tc = builtin_tool_classification(name);
assert_eq!(tc.direction, ToolDirection::Internal);
}
}
#[test]
fn builtin_write_file_classification() {
let tc = builtin_tool_classification("write_file");
assert_eq!(tc.direction, ToolDirection::Internal);
}
#[test]
fn unknown_tools_fail_closed_as_outbound() {
let tc = builtin_tool_classification("some_mcp_tool");
assert_eq!(tc.sensitivity, TaintLevel::Public);
assert_eq!(tc.direction, ToolDirection::Outbound);
assert_eq!(tc.clearance, TaintLevel::Public);
}
#[test]
fn builtin_edit_file_classification() {
let tc = builtin_tool_classification("edit_file");
assert_eq!(tc.sensitivity, TaintLevel::Internal);
assert_eq!(tc.direction, ToolDirection::Internal);
assert_eq!(tc.clearance, TaintLevel::Public);
}
#[test]
fn builtin_list_dir_classification() {
let tc = builtin_tool_classification("list_dir");
assert_eq!(tc.sensitivity, TaintLevel::Internal);
assert_eq!(tc.direction, ToolDirection::Inbound);
assert_eq!(tc.clearance, TaintLevel::Public);
}
fn sec(taint: bool) -> SecurityConfig {
SecurityConfig {
taint_tracking: taint,
}
}
#[test]
fn resolve_taint_enabled_inherits_global_when_unset() {
assert!(!resolve_taint_enabled(false, None, None));
assert!(resolve_taint_enabled(true, None, None));
}
#[test]
fn resolve_taint_enabled_agent_may_opt_in_but_not_out() {
assert!(resolve_taint_enabled(false, Some(&sec(true)), None));
assert!(resolve_taint_enabled(true, Some(&sec(false)), None));
}
#[test]
fn resolve_taint_enabled_stage_may_opt_in_but_not_out() {
assert!(resolve_taint_enabled(
false,
Some(&sec(false)),
Some(&sec(true))
));
assert!(resolve_taint_enabled(
true,
Some(&sec(true)),
Some(&sec(false))
));
}
#[test]
fn resolve_batch_tool_hint_cascade() {
assert!(resolve_batch_tool_hint(true, None, None));
assert!(!resolve_batch_tool_hint(false, None, None));
assert!(!resolve_batch_tool_hint(true, Some(false), None));
assert!(resolve_batch_tool_hint(false, Some(true), None));
assert!(!resolve_batch_tool_hint(true, Some(true), Some(false)));
assert!(resolve_batch_tool_hint(false, Some(false), Some(true)));
}
#[test]
fn gate_decision_blocked_levels() {
let blocked = GateDecision::Blocked {
taint_level: TaintLevel::Private,
clearance: TaintLevel::Public,
source_regions: vec![],
tool_name: "shell".into(),
};
assert_eq!(
blocked.blocked_levels(),
Some((TaintLevel::Private, TaintLevel::Public))
);
assert_eq!(GateDecision::Allowed.blocked_levels(), None);
}
#[test]
fn resolve_security_prefers_most_specific_but_clamps_taint() {
assert!(resolve_security(true, None, None).taint_tracking);
assert!(!resolve_security(false, None, None).taint_tracking);
assert!(resolve_security(false, Some(&sec(false)), Some(&sec(true))).taint_tracking);
assert!(resolve_security(true, Some(&sec(false)), None).taint_tracking);
}
#[test]
fn test_region_taint_remove_at_recomputes_level() {
let mut rt = RegionTaint::new();
rt.add_entry(TaintLevel::Public);
rt.add_entry(TaintLevel::Private);
rt.add_entry(TaintLevel::Public);
assert_eq!(rt.level(), TaintLevel::Private);
rt.remove_at(1);
assert_eq!(rt.entry_count(), 2);
assert_eq!(rt.level(), TaintLevel::Public);
rt.remove_at(99);
assert_eq!(rt.entry_count(), 2);
}
}