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]>
117 lines
3.8 KiB
Rust
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
|
|
}
|