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]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9f9f507c15
commit
de38449b41
@@ -0,0 +1,147 @@
|
||||
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(_)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user