use cm_domain::GatedCategory; use cm_tools::{Effect, GateDecision, GatePolicy, TaintSet, TaintSource}; fn policy() -> GatePolicy { GatePolicy } #[test] fn effect_free_tools_are_allowed() { let decision = policy().classify(&[], &TaintSet::clean()); assert_eq!(decision, GateDecision::Allow); } #[test] fn read_only_effects_are_allowed() { let decision = policy().classify(&[Effect::ReadsWorkspaceData], &TaintSet::clean()); assert_eq!(decision, GateDecision::Allow); } #[test] fn every_spec_15_category_has_a_triggering_effect() { // The six gated categories (§15) and the effect that triggers each. let cases = [ (Effect::SendsExternally, GatedCategory::OutboundMessage), (Effect::SharesSecrets, GatedCategory::SecretSharing), (Effect::ChangesAccess, GatedCategory::AccessChange), (Effect::MovesMoney, GatedCategory::FinancialTransaction), (Effect::DeletesData, GatedCategory::FileDeletion), (Effect::GrantsInfraAccess, GatedCategory::InfraAccessGrant), ]; for (effect, category) in cases { let decision = policy().classify(&[effect], &TaintSet::clean()); assert_eq!( decision, GateDecision::RequireApproval(category), "{effect:?} must gate as {category:?}" ); } } #[test] fn the_most_severe_effect_wins_for_multi_effect_tools() { let decision = policy().classify( &[Effect::ReadsWorkspaceData, Effect::MovesMoney], &TaintSet::clean(), ); assert_eq!( decision, GateDecision::RequireApproval(GatedCategory::FinancialTransaction) ); } #[test] fn undeclared_external_reach_is_gated_by_default() { // A tool that reaches outside the workspace but declares nothing // specific is still gated (§15 untrusted-by-default). let decision = policy().classify(&[Effect::ReachesExternally], &TaintSet::clean()); assert_eq!( decision, GateDecision::RequireApproval(GatedCategory::OutboundMessage) ); } #[test] fn tainted_input_gates_even_benign_external_tools() { let taint = TaintSet::from_sources(&[TaintSource::Web]); let decision = policy().classify(&[Effect::ReachesExternally], &taint); assert!(matches!(decision, GateDecision::RequireApproval(_))); } #[test] fn tainted_input_does_not_gate_purely_internal_reads() { // Untrusted content is data, not instructions; reading workspace data // with tainted input has no external effect to protect. let taint = TaintSet::from_sources(&[TaintSource::InterAgent]); let decision = policy().classify(&[Effect::ReadsWorkspaceData], &taint); assert_eq!(decision, GateDecision::Allow); } mod properties { use super::*; use proptest::prelude::*; fn arb_effect() -> impl Strategy { prop_oneof![ Just(Effect::ReadsWorkspaceData), Just(Effect::WritesWorkspaceData), Just(Effect::ReachesExternally), Just(Effect::SendsExternally), Just(Effect::SharesSecrets), Just(Effect::ChangesAccess), Just(Effect::MovesMoney), Just(Effect::DeletesData), Just(Effect::GrantsInfraAccess), ] } fn arb_taint() -> impl Strategy { proptest::collection::vec( prop_oneof![ Just(TaintSource::Web), Just(TaintSource::Email), Just(TaintSource::InterAgent), Just(TaintSource::ToolResult), ], 0..4, ) .prop_map(|sources| TaintSet::from_sources(&sources)) } proptest! { /// §15 invariant: input carrying untrusted taint combined with ANY /// externally-visible effect is never auto-allowed. #[test] fn tainted_external_is_never_allowed( effects in proptest::collection::vec(arb_effect(), 1..4), taint in arb_taint(), ) { let has_external = effects.iter().any(|e| e.is_external()); let decision = GatePolicy.classify(&effects, &taint); if !taint.is_clean() && has_external { prop_assert!( matches!(decision, GateDecision::RequireApproval(_)), "tainted external effects must require approval, got {decision:?}" ); } } /// Gated effects require approval regardless of taint. #[test] fn gated_effects_always_require_approval( taint in arb_taint(), ) { for effect in [ Effect::SendsExternally, Effect::SharesSecrets, Effect::ChangesAccess, Effect::MovesMoney, Effect::DeletesData, Effect::GrantsInfraAccess, ] { let decision = GatePolicy.classify(&[effect], &taint); prop_assert!(matches!(decision, GateDecision::RequireApproval(_))); } } } }