Files
clawmates/crates/cm-api/src/routes/approvals.rs
T
Omar SobhandClaude Opus 5 2656d73def
deploy / test (push) Successful in 5m18s
deploy / build (push) Successful in 5m43s
feat(door): a calibrated governor with three outcomes — allow, deny, HELD for a person
cm_decide:🚪 three Nouls per outbound action (data leaving the
organisation, a credential in the content, unsolicited/abusive), the max
is the deny probability. Measured on 24 hand-labelled door actions
(eval/door-actions.json): AUROC 1.000, [email protected] 0.96, no false denies, no
misses, 4 of 24 in the review band — three deny-labelled actions it would
not refuse alone (db dump 0.71, delegate-exfil 0.74, threat 0.77) and the
one genuinely borderline allow (repo name to a contractor 0.56). 168 ms,
~600 tokens per action, off the z.ai quota.

mcp_door: PolicyOutcome::Hold. With TYPESAFE_API_KEY set, above DENY_AT
(0.8) refused, below ALLOW_BELOW (0.2) executed, between them the action
gets a pending approval (session_key door:<id>) and the agent is told it
is queued and not to retry. The approvals route recognises a held door
action and executes it on approve — the grant decide mints, the tool
consumes — rather than resuming a chat run. The chat-model governor
stays as the fallback without a key; it has no middle band. Fail-closed
on an unreachable or malformed answer. Thresholds overridable per
deployment (CLAWMATES_DOOR_DENY_AT / _ALLOW_BELOW).

decide-eval --kind door reports the band outcome, not only a threshold.
Harness: a door scenario exercising all three bands directly against /mcp
with email_send (its effect is an outbox row), then approving the held
one and checking it executes then and not before.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-21 13:54:33 -05:00

117 lines
3.8 KiB
Rust

use axum::extract::{Path, State};
use axum::Json;
use cm_safety::{approvals, Approval, Decision, ResumeReady, SafetyError};
use serde_json::{json, Value};
use uuid::Uuid;
use crate::{ApiError, AppState, Authed};
impl From<SafetyError> for ApiError {
fn from(err: SafetyError) -> Self {
match err {
SafetyError::NotFound => ApiError::NotFound,
SafetyError::AlreadyDecided => ApiError::Conflict,
_ => ApiError::Internal,
}
}
}
/// Loads an approval, hiding other workspaces' approvals entirely.
async fn workspace_approval(
state: &AppState,
user: &cm_auth::AuthedUser,
id: Uuid,
) -> Result<Approval, ApiError> {
let approval = approvals::get(&state.pool, id).await?;
if approval.workspace_id != user.workspace_id {
return Err(ApiError::NotFound);
}
Ok(approval)
}
/// GET /api/approvals — the pending review queue (§10), oldest first.
pub async fn list(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<Approval>>, ApiError> {
let pending = approvals::list_pending(&state.pool, user.workspace_id).await?;
Ok(Json(pending))
}
/// GET /api/approvals/{id}
pub async fn get(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Approval>, ApiError> {
Ok(Json(workspace_approval(&state, &user, id).await?))
}
async fn decide(
state: AppState,
user: cm_auth::AuthedUser,
id: Uuid,
decision: Decision,
) -> Result<Json<Value>, ApiError> {
workspace_approval(&state, &user, id).await?;
let approval = approvals::decide(&state.pool, id, user.user_id, decision).await?;
// A held DOOR action has no chat run to resume: the tool itself is what
// was waiting. Approve executes it now, with the grant decide just
// minted; reject leaves the audit trail decide already wrote.
if approval
.session_key
.starts_with(crate::mcp_door::HELD_SESSION_KEY_PREFIX)
{
let executed = if decision == Decision::Approve {
Some(crate::mcp_door::execute_held(&state, &approval).await)
} else {
None
};
return Ok(Json(json!({
"id": approval.id,
"status": approval.status,
"door_action": approval.action_type,
"executed": executed.as_ref().map(|r| r.is_ok()),
"error": executed.and_then(|r| r.err()),
})));
}
// Kick the resume before returning. resume_run's awaited portion is only
// the setup (claim + checkpoint load + open the broadcast channel); it
// spawns the actual multi-step work internally, so this doesn't block the
// client on the run itself — but it does guarantee the channel exists by
// the time the client reconnects via /api/gateway, closing the observable
// race where subscribe(run_id) would return None. The sweeper remains the
// durable fallback if this process dies before setup completes.
let ready = ResumeReady {
run_id: approval.run_id,
approval_id: approval.id,
approved: decision == Decision::Approve,
};
let _ = state.runtime.resume_run(ready).await;
Ok(Json(
json!({ "id": approval.id, "status": approval.status }),
))
}
/// POST /api/approvals/{id}/approve — executes the gated action (§15:
/// approve-only execution, idempotent, audited).
pub async fn approve(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
decide(state, user, id, Decision::Approve).await
}
/// POST /api/approvals/{id}/reject — the agent learns the refusal in-band.
pub async fn reject(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
decide(state, user, id, Decision::Reject).await
}