//! The gate policy (spec §15, acceptance-blocking). //! //! Tools declare *effects*; the policy maps effects to the six gated //! categories. Classification is deny-by-default: any externally-visible //! effect without a more specific category gates as an outbound message, //! and input tainted by untrusted sources gates every external effect. //! This module is pure — no I/O — so its invariants are property-tested. use cm_domain::GatedCategory; use serde::{Deserialize, Serialize}; /// What a tool can do, declared statically by the tool itself. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Effect { /// Reads data inside the workspace boundary. ReadsWorkspaceData, /// Writes data inside the workspace boundary. WritesWorkspaceData, /// Contacts systems outside the workspace without sending user content /// (e.g. fetching a public page). ReachesExternally, /// Sends messages/emails/posts outside the workspace. SendsExternally, /// Exposes keys, secrets, or credentials. SharesSecrets, /// Changes access, permissions, or sharing. ChangesAccess, /// Financial transactions and credit purchases. MovesMoney, /// Deletes files or records. DeletesData, /// Grants or extends external-infrastructure access. GrantsInfraAccess, } impl Effect { /// Effects visible outside the workspace boundary. pub fn is_external(&self) -> bool { !matches!( self, Effect::ReadsWorkspaceData | Effect::WritesWorkspaceData ) } /// The §15 category this effect always gates as, if any. fn gated_category(&self) -> Option { match self { Effect::SendsExternally => Some(GatedCategory::OutboundMessage), Effect::SharesSecrets => Some(GatedCategory::SecretSharing), Effect::ChangesAccess => Some(GatedCategory::AccessChange), Effect::MovesMoney => Some(GatedCategory::FinancialTransaction), Effect::DeletesData => Some(GatedCategory::FileDeletion), Effect::GrantsInfraAccess => Some(GatedCategory::InfraAccessGrant), Effect::ReadsWorkspaceData | Effect::WritesWorkspaceData | Effect::ReachesExternally => None, } } /// Severity order for picking the headline category of a multi-effect /// tool (most consequential wins). fn severity(&self) -> u8 { match self { Effect::ReadsWorkspaceData => 0, Effect::WritesWorkspaceData => 1, Effect::ReachesExternally => 2, Effect::SendsExternally => 3, Effect::DeletesData => 4, Effect::ChangesAccess => 5, Effect::SharesSecrets => 6, Effect::GrantsInfraAccess => 7, Effect::MovesMoney => 8, } } } /// Where a piece of content came from. Anything here marks the content as /// untrusted-by-default (§15): data, never instructions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TaintSource { Web, Email, InterAgent, ToolResult, } impl std::str::FromStr for TaintSource { type Err = String; fn from_str(s: &str) -> Result { match s { "web" => Ok(TaintSource::Web), "email" => Ok(TaintSource::Email), "inter_agent" => Ok(TaintSource::InterAgent), "tool_result" => Ok(TaintSource::ToolResult), other => Err(format!("unknown taint source: {other}")), } } } impl TaintSource { pub fn as_str(&self) -> &'static str { match self { TaintSource::Web => "web", TaintSource::Email => "email", TaintSource::InterAgent => "inter_agent", TaintSource::ToolResult => "tool_result", } } } /// The set of untrusted sources that influenced a tool input. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct TaintSet { sources: Vec, } impl TaintSet { pub fn clean() -> TaintSet { TaintSet::default() } /// Rebuilds a set from stored strings (checkpoints, step rows); /// unknown strings are ignored rather than dropped runs. pub fn from_strings(raw: &[String]) -> TaintSet { let sources: Vec = raw.iter().filter_map(|s| s.parse().ok()).collect(); TaintSet::from_sources(&sources) } pub fn from_sources(sources: &[TaintSource]) -> TaintSet { let mut unique: Vec = Vec::new(); for source in sources { if !unique.contains(source) { unique.push(*source); } } TaintSet { sources: unique } } pub fn is_clean(&self) -> bool { self.sources.is_empty() } pub fn sources(&self) -> &[TaintSource] { &self.sources } /// Storage form for `steps.taint` / `approvals.taint_sources`. pub fn as_strings(&self) -> Vec { self.sources.iter().map(|s| s.as_str().to_owned()).collect() } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GateDecision { Allow, RequireApproval(GatedCategory), } /// Pure classifier from declared effects + input taint to a decision. #[derive(Debug, Default)] pub struct GatePolicy; impl GatePolicy { pub fn classify(&self, effects: &[Effect], taint: &TaintSet) -> GateDecision { let headline = effects.iter().max_by_key(|e| e.severity()).copied(); let Some(headline) = headline else { return GateDecision::Allow; }; if let Some(category) = effects .iter() .filter_map(Effect::gated_category) .max_by_key(|c| { effects .iter() .filter(|e| e.gated_category() == Some(*c)) .map(|e| e.severity()) .max() .unwrap_or(0) }) { return GateDecision::RequireApproval(category); } // No always-gated effect. External reach gates by default — and // unconditionally when the input carries untrusted taint (§15). if headline.is_external() { return GateDecision::RequireApproval(GatedCategory::OutboundMessage); } if !taint.is_clean() && effects.iter().any(Effect::is_external) { return GateDecision::RequireApproval(GatedCategory::OutboundMessage); } GateDecision::Allow } }