Files
clawmates/crates/tc-tools/tests/gate_policy.rs
T
Omar SobhandClaude Fable 5 de38449b41 P2 BLOCKING exit green: approval interception chain end-to-end
- tc-tools: Effect declarations -> §15 GatedCategory mapping, deny-by-default
  external reach, taint invariant property-tested (tainted external effects
  are NEVER auto-allowed)
- tc-safety: pending approvals with exact payload+preview, CAS decide with
  audit + single-use grant in one tx, checkpoint suspend/load, exclusive
  resume claim, expiry sweep, decided-unresumed work queue (migration 0004
  adds the outbox the gated email.send tool writes)
- tc-runtime: resumable LoopState checkpointed to agent_runs; gated tool ->
  approval row -> approval_required/run_suspended events -> suspend; resume
  consumes the grant BEFORE executing (spent grant = no execution), rejection
  feeds a structured refusal in-band; durable resume sweeper; continuous
  journal seq across suspension (tested). ContentPart::Text became a struct
  variant — internally-tagged newtype primitives don't serialize
- tc-api: GET/decide approvals endpoints (409 double-decide, tenant
  isolation), decision triggers in-process resume; full chain proven over
  HTTP incl. gateway resumeFrom continuation
- frontend: approval_required/run_suspended events, suspended reply state,
  inline ApprovalCard (§10: summary, category, exact payload preview,
  approve/reject -> decide + stream re-attach), /approvals queue page, nav
- E2E (14 journeys, workers:1 to serialize the shared backend): gated email
  blocks with disabled composer -> approve -> continuation + ✓ step + reload
  replay; reject -> ✗ step, nothing executed; queue page decides pending

106 Rust + 61 frontend tests + 14 Playwright journeys green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 04:58:47 -05:00

148 lines
4.9 KiB
Rust

use tc_domain::GatedCategory;
use tc_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<Value = Effect> {
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<Value = TaintSet> {
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(_)));
}
}
}
}