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