Skip to main content

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

Fail-Closed Design

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

ModuleTypeDescription
aclAclManagerRole-based access control with channel scoping
allowlistAllowlistManagerUser/group/channel allowlist and blocklist
scannerCascadeScannerMulti-stage content scanning pipeline
auditAuditLoggerHash-chained, tamper-evident audit log
cryptoUtility functionsHashing, HMAC, key derivation
dm_pairingDmPairingSecure DM channel pairing protocol
group_policyGroupPolicyGroup-level security policies
identityIdentityResolverCross-channel identity linking
tokensScopedTokenTime-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

RoleMessagesSessionsConfigAdminTools
ownerRWXRWXRWXRWXAll
adminRWXRWRRAll
userRWRPermitted
viewerRR
botRWRWPermitted

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 }
}
}
}
Performance Optimization

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>),
}

Security Flow Summary