Files
clawmates/crates/cm-tools/src/lib.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

200 lines
6.5 KiB
Rust

//! The gate policy (spec §15, acceptance-blocking).
//!
//! Tools declare *effects*; the policy maps effects to the six gated
//! categories. Classification is deny-by-default: any externally-visible
//! effect without a more specific category gates as an outbound message,
//! and input tainted by untrusted sources gates every external effect.
//! This module is pure — no I/O — so its invariants are property-tested.
use cm_domain::GatedCategory;
use serde::{Deserialize, Serialize};
/// What a tool can do, declared statically by the tool itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Effect {
/// Reads data inside the workspace boundary.
ReadsWorkspaceData,
/// Writes data inside the workspace boundary.
WritesWorkspaceData,
/// Contacts systems outside the workspace without sending user content
/// (e.g. fetching a public page).
ReachesExternally,
/// Sends messages/emails/posts outside the workspace.
SendsExternally,
/// Exposes keys, secrets, or credentials.
SharesSecrets,
/// Changes access, permissions, or sharing.
ChangesAccess,
/// Financial transactions and credit purchases.
MovesMoney,
/// Deletes files or records.
DeletesData,
/// Grants or extends external-infrastructure access.
GrantsInfraAccess,
}
impl Effect {
/// Effects visible outside the workspace boundary.
pub fn is_external(&self) -> bool {
!matches!(
self,
Effect::ReadsWorkspaceData | Effect::WritesWorkspaceData
)
}
/// The §15 category this effect always gates as, if any.
fn gated_category(&self) -> Option<GatedCategory> {
match self {
Effect::SendsExternally => Some(GatedCategory::OutboundMessage),
Effect::SharesSecrets => Some(GatedCategory::SecretSharing),
Effect::ChangesAccess => Some(GatedCategory::AccessChange),
Effect::MovesMoney => Some(GatedCategory::FinancialTransaction),
Effect::DeletesData => Some(GatedCategory::FileDeletion),
Effect::GrantsInfraAccess => Some(GatedCategory::InfraAccessGrant),
Effect::ReadsWorkspaceData
| Effect::WritesWorkspaceData
| Effect::ReachesExternally => None,
}
}
/// Severity order for picking the headline category of a multi-effect
/// tool (most consequential wins).
fn severity(&self) -> u8 {
match self {
Effect::ReadsWorkspaceData => 0,
Effect::WritesWorkspaceData => 1,
Effect::ReachesExternally => 2,
Effect::SendsExternally => 3,
Effect::DeletesData => 4,
Effect::ChangesAccess => 5,
Effect::SharesSecrets => 6,
Effect::GrantsInfraAccess => 7,
Effect::MovesMoney => 8,
}
}
}
/// Where a piece of content came from. Anything here marks the content as
/// untrusted-by-default (§15): data, never instructions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaintSource {
Web,
Email,
InterAgent,
ToolResult,
}
impl std::str::FromStr for TaintSource {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"web" => Ok(TaintSource::Web),
"email" => Ok(TaintSource::Email),
"inter_agent" => Ok(TaintSource::InterAgent),
"tool_result" => Ok(TaintSource::ToolResult),
other => Err(format!("unknown taint source: {other}")),
}
}
}
impl TaintSource {
pub fn as_str(&self) -> &'static str {
match self {
TaintSource::Web => "web",
TaintSource::Email => "email",
TaintSource::InterAgent => "inter_agent",
TaintSource::ToolResult => "tool_result",
}
}
}
/// The set of untrusted sources that influenced a tool input.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct TaintSet {
sources: Vec<TaintSource>,
}
impl TaintSet {
pub fn clean() -> TaintSet {
TaintSet::default()
}
/// Rebuilds a set from stored strings (checkpoints, step rows);
/// unknown strings are ignored rather than dropped runs.
pub fn from_strings(raw: &[String]) -> TaintSet {
let sources: Vec<TaintSource> = raw.iter().filter_map(|s| s.parse().ok()).collect();
TaintSet::from_sources(&sources)
}
pub fn from_sources(sources: &[TaintSource]) -> TaintSet {
let mut unique: Vec<TaintSource> = Vec::new();
for source in sources {
if !unique.contains(source) {
unique.push(*source);
}
}
TaintSet { sources: unique }
}
pub fn is_clean(&self) -> bool {
self.sources.is_empty()
}
pub fn sources(&self) -> &[TaintSource] {
&self.sources
}
/// Storage form for `steps.taint` / `approvals.taint_sources`.
pub fn as_strings(&self) -> Vec<String> {
self.sources.iter().map(|s| s.as_str().to_owned()).collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateDecision {
Allow,
RequireApproval(GatedCategory),
}
/// Pure classifier from declared effects + input taint to a decision.
#[derive(Debug, Default)]
pub struct GatePolicy;
impl GatePolicy {
pub fn classify(&self, effects: &[Effect], taint: &TaintSet) -> GateDecision {
let headline = effects.iter().max_by_key(|e| e.severity()).copied();
let Some(headline) = headline else {
return GateDecision::Allow;
};
if let Some(category) = effects
.iter()
.filter_map(Effect::gated_category)
.max_by_key(|c| {
effects
.iter()
.filter(|e| e.gated_category() == Some(*c))
.map(|e| e.severity())
.max()
.unwrap_or(0)
})
{
return GateDecision::RequireApproval(category);
}
// No always-gated effect. External reach gates by default — and
// unconditionally when the input carries untrusted taint (§15).
if headline.is_external() {
return GateDecision::RequireApproval(GatedCategory::OutboundMessage);
}
if !taint.is_clean() && effects.iter().any(Effect::is_external) {
return GateDecision::RequireApproval(GatedCategory::OutboundMessage);
}
GateDecision::Allow
}
}