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]>
200 lines
6.5 KiB
Rust
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
|
|
}
|
|
}
|