Files
clawmates/crates/cm-tools/tests/gate_policy.rs
T
Omar SobhandClaude Fable 5 add4f79fed Rebrand: TeamClaw -> Clawmates (clawmates.work)
Full-depth rename per the approved plan; the 'claw' product vocabulary
(claws, /claws routes, clawId, Claw Chat) stays — it is now the brand.

- Display brand: Clawmates (manifest, titles, hero, login/rail logo
  'clawmates'); default host app.clawmates.work; registry
  ghcr.io/clawmates
- Crates tc-* -> cm-* (16 crates + all imports); binaries
  clawmates-server/broker/bundler; images clawmates/*; env prefix
  CLAWMATES_* (+ CM_TEST_DATABASE_URL / CM_LIVE_LLM); config
  clawmates.toml; helm chart deploy/helm/clawmates with clawmates-*
  resources; db names clawmates*; sockets /run/clawmates; cookie
  cm_session; kind cluster clawmates-test; seccomp node profile
  clawmates-agent-profile.json
- All 9 Playwright brand assertions updated in lockstep; historical
  spec document left untouched as the only remaining 'TeamClaw'
- Local env migrated: dev pg clawmates-dev-pg/clawmates_dev, shared
  test server clawmates-test-pg, kind cluster recreated with image +
  profile, compose images rebuilt under clawmates/*

Verified end to end: 161 Rust + 68 frontend tests, 29 Playwright
journeys, 4 live kind tests, helm/install/LOC/placeholder gates, and
the clean-room install rehearsal serving the clawmates login page from
a signed bundle of the rebuilt images.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 12:31:25 -05:00

148 lines
4.9 KiB
Rust

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<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(_)));
}
}
}
}