- 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]>
148 lines
4.9 KiB
Rust
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(_)));
|
|
}
|
|
}
|
|
}
|
|
}
|