Security Model
ClawDesk implements a defense-in-depth security model with four cascading layers. Every message passes through all four layers before reaching the agent pipeline. The security subsystem is implemented in the clawdesk-security crate.
Security Cascade
If any security layer encounters an error (not just a policy violation), the message is rejected. The system fails closed — never open. This prevents security bypasses through error states.
Module Structure
| Module | Type | Description |
|---|---|---|
acl | AclManager | Role-based access control with channel scoping |
allowlist | AllowlistManager | User/group/channel allowlist and blocklist |
scanner | CascadeScanner | Multi-stage content scanning pipeline |
audit | AuditLogger | Hash-chained, tamper-evident audit log |
crypto | Utility functions | Hashing, HMAC, key derivation |
dm_pairing | DmPairing | Secure DM channel pairing protocol |
group_policy | GroupPolicy | Group-level security policies |
identity | IdentityResolver | Cross-channel identity linking |
tokens | ScopedToken | Time-limited, permission-scoped API tokens |
Layer 1: Access Control (ACL)
The ACL system implements role-based access control (RBAC) with channel-level scoping:
// crates/clawdesk-security/src/acl.rs
#[derive(Debug, Clone)]
pub struct AclManager {
rules: Vec<AclRule>,
default_policy: AclPolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AclRule {
/// Who this rule applies to
pub subject: AclSubject,
/// What resource is being accessed
pub resource: AclResource,
/// What action is being performed
pub action: AclAction,
/// Allow or deny
pub effect: AclEffect,
/// Priority (higher = evaluated first)
pub priority: i32,
/// Optional conditions
pub conditions: Vec<AclCondition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AclSubject {
User(UserId),
Group(GroupId),
Channel(ChannelId),
Role(Role),
Everyone,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AclResource {
Messages,
Sessions,
Config,
Admin,
Tools(ToolPattern),
Models(ModelPattern),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AclAction {
Read,
Write,
Execute,
Delete,
Admin,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum AclEffect {
Allow,
Deny,
}
ACL Evaluation
ACL rules are evaluated in priority order with first-match-wins semantics:
impl AclManager {
pub fn evaluate(
&self,
user: &UserId,
groups: &[GroupId],
resource: &AclResource,
action: &AclAction,
) -> AclDecision {
// Sort rules by priority (descending)
let mut rules: Vec<_> = self.rules.iter()
.filter(|r| self.matches_subject(r, user, groups))
.filter(|r| self.matches_resource(r, resource))
.filter(|r| self.matches_action(r, action))
.filter(|r| self.evaluate_conditions(r, user))
.collect();
rules.sort_by(|a, b| b.priority.cmp(&a.priority));
match rules.first() {
Some(rule) => match rule.effect {
AclEffect::Allow => AclDecision::Allowed {
rule_id: rule.id(),
reason: "matched allow rule".into(),
},
AclEffect::Deny => AclDecision::Denied {
rule_id: rule.id(),
reason: format!("denied by rule: {}", rule.description()),
},
},
None => match self.default_policy {
AclPolicy::AllowByDefault => AclDecision::Allowed {
rule_id: "default".into(),
reason: "no matching rules, default allow".into(),
},
AclPolicy::DenyByDefault => AclDecision::Denied {
rule_id: "default".into(),
reason: "no matching rules, default deny".into(),
},
},
}
}
}
Permission Matrix
| Role | Messages | Sessions | Config | Admin | Tools |
|---|---|---|---|---|---|
owner | RWX | RWX | RWX | RWX | All |
admin | RWX | RW | R | R | All |
user | RW | R | — | — | Permitted |
viewer | R | R | — | — | — |
bot | RW | RW | — | — | Permitted |
Layer 2: Allowlist
The allowlist provides fine-grained filtering based on user identity, group membership, and channel:
// crates/clawdesk-security/src/allowlist.rs
pub struct AllowlistManager {
/// Explicit allow entries
allowlist: HashSet<AllowlistEntry>,
/// Explicit block entries (override allowlist)
blocklist: HashSet<AllowlistEntry>,
/// Default behavior when not listed
default: AllowlistDefault,
}
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub enum AllowlistEntry {
User(UserId),
Group(GroupId),
Channel(ChannelId),
Domain(String), // email domain
IpRange(IpNetwork), // IP CIDR block
}
impl AllowlistManager {
pub fn check(
&self,
user: &UserId,
channel: &ChannelId,
ip: Option<IpAddr>,
) -> AllowlistDecision {
// Blocklist always wins
if self.is_blocked(user, channel, ip) {
return AllowlistDecision::Blocked {
reason: "entity is on the blocklist".into(),
};
}
// Check allowlist
if self.is_allowed(user, channel, ip) {
return AllowlistDecision::Allowed;
}
// Fall through to default
match self.default {
AllowlistDefault::Open => AllowlistDecision::Allowed,
AllowlistDefault::Closed => AllowlistDecision::Blocked {
reason: "entity not on allowlist (closed mode)".into(),
},
}
}
}
Layer 3: Content Scanner (CascadeScanner)
The content scanner uses a 3-stage cascade with increasing computational cost:
Stage 1: Regex Scanner
Fast pattern matching for known dangerous patterns:
pub struct RegexScanner {
/// Compiled regex patterns
patterns: Vec<CompiledPattern>,
/// Pattern categories
categories: HashMap<String, Vec<usize>>,
}
#[derive(Debug)]
pub struct CompiledPattern {
pub name: String,
pub regex: Regex,
pub severity: Severity,
pub category: String,
}
impl RegexScanner {
pub fn scan(&self, content: &str) -> Vec<ScanHit> {
let mut hits = Vec::new();
for pattern in &self.patterns {
for m in pattern.regex.find_iter(content) {
hits.push(ScanHit {
pattern: pattern.name.clone(),
severity: pattern.severity,
category: pattern.category.clone(),
offset: m.start(),
length: m.len(),
stage: ScanStage::Regex,
});
}
}
hits
}
}
Stage 2: AST Scanner
Parses code blocks and analyzes abstract syntax trees:
pub struct AstScanner {
/// Language-specific parsers
parsers: HashMap<Language, Box<dyn CodeParser>>,
/// AST patterns to detect
ast_patterns: Vec<AstPattern>,
}
impl AstScanner {
pub fn scan(&self, content: &str) -> Vec<ScanHit> {
let mut hits = Vec::new();
// Extract code blocks from markdown
for block in extract_code_blocks(content) {
if let Some(parser) = self.parsers.get(&block.language) {
let ast = parser.parse(&block.code);
for pattern in &self.ast_patterns {
if pattern.matches(&ast) {
hits.push(ScanHit {
pattern: pattern.name.clone(),
severity: pattern.severity,
category: "code_analysis".into(),
offset: block.offset,
length: block.code.len(),
stage: ScanStage::Ast,
});
}
}
}
}
hits
}
}
Stage 3: Semantic Scanner
Uses embedding similarity to detect semantically dangerous content:
pub struct SemanticScanner {
embedding_model: Arc<dyn EmbeddingModel>,
dangerous_embeddings: Vec<(String, Vec<f32>)>,
similarity_threshold: f32, // default: 0.85
}
impl SemanticScanner {
pub async fn scan(&self, content: &str) -> Vec<ScanHit> {
let embedding = self.embedding_model.embed(content).await
.unwrap_or_default();
let mut hits = Vec::new();
for (category, dangerous_emb) in &self.dangerous_embeddings {
let similarity = cosine_similarity(&embedding, dangerous_emb);
if similarity > self.similarity_threshold {
hits.push(ScanHit {
pattern: format!("semantic_{category}"),
severity: Severity::High,
category: category.clone(),
offset: 0,
length: content.len(),
stage: ScanStage::Semantic,
});
}
}
hits
}
}
Cascade Orchestration
// crates/clawdesk-security/src/scanner.rs
pub struct CascadeScanner {
regex: RegexScanner,
ast: AstScanner,
semantic: SemanticScanner,
quarantine_severity: Severity,
}
impl CascadeScanner {
/// Run the 3-stage cascade.
/// Short-circuits at the first stage that finds a match
/// above the quarantine severity threshold.
pub async fn scan(&self, content: &str) -> ScanResult {
// Stage 1: Regex (fast, < 0.1ms)
let regex_hits = self.regex.scan(content);
let regex_critical: Vec<_> = regex_hits.iter()
.filter(|h| h.severity >= self.quarantine_severity)
.collect();
if !regex_critical.is_empty() {
return ScanResult::Flagged {
hits: regex_hits,
stopped_at: ScanStage::Regex,
};
}
// Stage 2: AST (moderate, < 1ms)
let ast_hits = self.ast.scan(content);
let ast_critical: Vec<_> = ast_hits.iter()
.filter(|h| h.severity >= self.quarantine_severity)
.collect();
if !ast_critical.is_empty() {
let mut all_hits = regex_hits;
all_hits.extend(ast_hits);
return ScanResult::Flagged {
hits: all_hits,
stopped_at: ScanStage::Ast,
};
}
// Stage 3: Semantic (slow, < 10ms)
let semantic_hits = self.semantic.scan(content).await;
let mut all_hits = regex_hits;
all_hits.extend(ast_hits);
all_hits.extend(semantic_hits.clone());
if semantic_hits.iter().any(|h| h.severity >= self.quarantine_severity) {
return ScanResult::Flagged {
hits: all_hits,
stopped_at: ScanStage::Semantic,
};
}
if all_hits.is_empty() {
ScanResult::Clean
} else {
ScanResult::Warnings { hits: all_hits }
}
}
}
The cascade design ensures that >95% of messages are cleared by Stage 1 (regex) alone, costing < 0.1ms. Only messages containing code blocks proceed to Stage 2, and only ambiguous cases reach Stage 3. This keeps the average scanning cost under 0.2ms.
Layer 4: Hash-Chained Audit Log
Every security decision is recorded in a hash-chained audit log that provides tamper evidence:
// crates/clawdesk-security/src/audit.rs
pub struct AuditLogger {
store: Arc<dyn AuditStore>,
last_hash: Mutex<[u8; 32]>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AuditEntry {
/// Monotonic sequence number
pub seq: u64,
/// Timestamp
pub timestamp: DateTime<Utc>,
/// SHA-256 hash of the previous entry
pub prev_hash: [u8; 32],
/// SHA-256 hash of this entry (computed over all other fields)
pub hash: [u8; 32],
/// What happened
pub event: AuditEvent,
/// Who triggered it
pub actor: AuditActor,
/// Outcome
pub outcome: AuditOutcome,
/// Additional context
pub context: serde_json::Value,
}
impl AuditLogger {
pub async fn log(&self, event: AuditEvent, actor: AuditActor, outcome: AuditOutcome, context: serde_json::Value) -> Result<(), SecurityError> {
let mut last_hash = self.last_hash.lock().await;
let entry = AuditEntry {
seq: self.next_seq(),
timestamp: Utc::now(),
prev_hash: *last_hash,
hash: [0u8; 32], // placeholder
event,
actor,
outcome,
context,
};
// Compute hash over all fields except `hash` itself
let hash = Self::compute_hash(&entry);
let entry = AuditEntry { hash, ..entry };
// Update chain
*last_hash = hash;
// Persist
self.store.append(entry).await?;
Ok(())
}
fn compute_hash(entry: &AuditEntry) -> [u8; 32] {
use sha2::{Sha256, Digest};
let mut hasher = Sha256::new();
hasher.update(entry.seq.to_le_bytes());
hasher.update(entry.timestamp.timestamp().to_le_bytes());
hasher.update(entry.prev_hash);
hasher.update(serde_json::to_vec(&entry.event).unwrap());
hasher.update(serde_json::to_vec(&entry.actor).unwrap());
hasher.update(serde_json::to_vec(&entry.outcome).unwrap());
hasher.finalize().into()
}
/// Verify the integrity of the audit chain.
pub async fn verify_chain(&self) -> Result<ChainVerification, SecurityError> {
let entries = self.store.read_all().await?;
for window in entries.windows(2) {
let prev = &window[0];
let curr = &window[1];
// Verify hash chain
if curr.prev_hash != prev.hash {
return Ok(ChainVerification::Broken {
at_seq: curr.seq,
expected_prev: prev.hash,
actual_prev: curr.prev_hash,
});
}
// Verify entry hash
let expected = Self::compute_hash(curr);
if curr.hash != expected {
return Ok(ChainVerification::Tampered {
at_seq: curr.seq,
});
}
}
Ok(ChainVerification::Valid {
entries: entries.len(),
})
}
}
Tamper Detection
The hash-chain provides $O(n)$ tamper detection: modifying any entry breaks the chain for all subsequent entries.
$$ H_i = \text{SHA256}(\text{seq}_i | \text{ts}i | H{i-1} | \text{event}_i | \text{actor}_i | \text{outcome}_i) $$
$$ \text{Tamper at entry } j \Rightarrow \forall i > j: H_i \text{ is invalid} $$
Scoped Tokens
API access uses scoped tokens with limited permissions and time-to-live:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScopedToken {
/// Unique token identifier
pub id: TokenId,
/// The bearer token value (displayed only at creation)
pub secret: String,
/// Permissions granted by this token
pub scopes: Vec<TokenScope>,
/// Expiration time
pub expires_at: DateTime<Utc>,
/// Who created this token
pub created_by: UserId,
/// Optional IP restriction
pub allowed_ips: Option<Vec<IpNetwork>>,
/// Maximum requests per minute
pub rate_limit: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TokenScope {
MessagesRead,
MessagesWrite,
SessionsRead,
SessionsManage,
ConfigRead,
ConfigWrite,
AdminFull,
ModelsAccess(Vec<String>),
ChannelsAccess(Vec<ChannelId>),
}