door: richer auto-policy (c) + broker-backed tools (b)

(c) policy_decide is now async with real governance: kill switch +
per-workspace hourly rate cap (audit_log count) + email recipient-domain
allowlist + a governor extension point. Still allow-all by default (autonomous).

(b) the door now supports broker-executed tools: for a broker tool it mints an
auto-approved approval + single-use grant (agent->session->run->approval->
decide), then executes via the runtime so the broker consumes the grant and
reveals the credential — the agent never holds it. Exposes slack_post.
cm-runtime gains tool_broker_executed + tool_preview accessors.

4 door unit tests + clippy clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-16 21:24:48 -07:00
co-authored by Claude Opus 4.8
parent ea5687565a
commit 19e9943f79
2 changed files with 146 additions and 26 deletions
+133 -26
View File
@@ -30,7 +30,10 @@ const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
/// Tools the door exposes, as `(mcp_name, internal_registry_name)`. The agent
/// sees `clawmates__<mcp_name>`; ZeroClaw strips the prefix and calls us with
/// `<mcp_name>`. We keep MCP names underscore-only (some models choke on dots).
const EXPOSED_TOOLS: &[(&str, &str)] = &[("email_send", "email.send")];
const EXPOSED_TOOLS: &[(&str, &str)] = &[
("email_send", "email.send"),
("slack_post", "slack.post"),
];
fn internal_name(mcp_name: &str) -> Option<&'static str> {
EXPOSED_TOOLS
@@ -75,28 +78,120 @@ enum PolicyOutcome {
Deny(String),
}
/// Default policy: **allow-all** — agents are fully autonomous. This is the
/// single seam where governance plugs in later: deterministic rules (recipient
/// allowlists, per-category rate/spend caps, "deny if input is tainted") and/or
/// a governor agent that auto-decides each request. Returning `Approve` here
/// means the topology self-governs only by whatever rules we add.
fn policy_decide(
_workspace: cm_domain::WorkspaceId,
_mcp_tool: &str,
/// Decides each door call in place of a human. The human is removed; autonomy
/// is *governed* (not ungoverned) by, in order:
/// 1. a kill switch (`CLAWMATES_DOOR_POLICY=deny`) — halt all egress, no redeploy;
/// 2. a per-workspace hourly rate cap (`CLAWMATES_DOOR_RATE_LIMIT`, counts
/// executed door actions in the audit log);
/// 3. an email recipient-domain allowlist (`CLAWMATES_DOOR_EMAIL_ALLOW`);
/// 4. a governor hook (extension point) — a deterministic rule set or a
/// governor agent can veto here.
///
/// Default (no env set) = allow-all → agents fully autonomous.
async fn policy_decide(
state: &AppState,
workspace: cm_domain::WorkspaceId,
mcp_tool: &str,
_category: Option<cm_domain::GatedCategory>,
_args: &Value,
args: &Value,
) -> PolicyOutcome {
// Operational kill switch: `CLAWMATES_DOOR_POLICY=deny` halts all autonomous
// egress without a redeploy. Default (unset / any other value) = allow-all,
// i.e. agents are fully autonomous. Richer governance (recipient allowlists,
// per-category rate/spend caps, taint blocks, a governor agent) plugs in
// right here.
match std::env::var("CLAWMATES_DOOR_POLICY").as_deref() {
Ok("deny") => {
PolicyOutcome::Deny("door policy is set to deny (kill switch active)".into())
}
_ => PolicyOutcome::Approve,
// 1. Kill switch.
if std::env::var("CLAWMATES_DOOR_POLICY").as_deref() == Ok("deny") {
return PolicyOutcome::Deny("door policy is set to deny (kill switch active)".into());
}
// 2. Per-workspace hourly rate cap (counts executed door actions).
if let Some(cap) = std::env::var("CLAWMATES_DOOR_RATE_LIMIT")
.ok()
.and_then(|v| v.parse::<i64>().ok())
{
let used: i64 = sqlx::query_scalar(
"SELECT count(*) FROM audit_log
WHERE workspace_id = $1 AND event_type = 'door.executed'
AND created_at > now() - interval '1 hour'",
)
.bind(workspace.as_uuid())
.fetch_one(&state.pool)
.await
.unwrap_or(0);
if used >= cap {
return PolicyOutcome::Deny(format!(
"hourly rate limit reached ({used}/{cap} door actions this hour)"
));
}
}
// 3. Email recipient-domain allowlist.
if mcp_tool == "email_send" {
if let Ok(allow) = std::env::var("CLAWMATES_DOOR_EMAIL_ALLOW") {
let to = args.get("to").and_then(|v| v.as_str()).unwrap_or("");
let domain = to.rsplit('@').next().unwrap_or("").to_ascii_lowercase();
let permitted = allow
.split(',')
.map(|d| d.trim().to_ascii_lowercase())
.any(|d| !d.is_empty() && d == domain);
if !permitted {
return PolicyOutcome::Deny(format!(
"recipient domain {domain:?} is not in CLAWMATES_DOOR_EMAIL_ALLOW"
));
}
}
}
// 4. Governor hook (extension point): a deterministic rule set or a governor
// agent (an LLM that judges the payload) can veto here — the seam for the
// "self-governing topology" story. Default: no veto.
PolicyOutcome::Approve
}
/// Mint an auto-approved approval + single-use execution grant for a
/// broker-executed door tool. The broker (cm-secrets) verifies & consumes this
/// grant before it reveals the credential — so even autonomous actions keep
/// credential custody out of the agent. Returns the approval id.
async fn mint_grant(
state: &AppState,
user: &cm_auth::AuthedUser,
agent_id: cm_domain::AgentId,
internal: &str,
category: Option<cm_domain::GatedCategory>,
args: &Value,
) -> Result<uuid::Uuid, String> {
// approvals.run_id / requested_by_agent are strict FKs → agent → session → run.
let session =
cm_db::repo::sessions::create(&state.pool, agent_id, user.workspace_id, "mcp-door")
.await
.map_err(|e| e.to_string())?;
let run_id = cm_db::repo::runs::create(&state.pool, session.id)
.await
.map_err(|e| e.to_string())?;
let approval = cm_safety::approvals::create(
&state.pool,
cm_safety::NewApproval {
workspace_id: user.workspace_id,
run_id,
session_key: session.id.as_uuid().to_string(),
action_type: internal.to_string(),
category: category.unwrap_or(cm_domain::GatedCategory::OutboundMessage),
payload: args.clone(),
preview: state.runtime.tool_preview(internal, args),
requested_by_agent: agent_id,
taint_sources: Vec::new(),
expires_at: Some(time::OffsetDateTime::now_utc() + time::Duration::hours(1)),
},
)
.await
.map_err(|e| e.to_string())?;
// Auto-decide (policy already approved above): mints the single-use grant
// and writes the decision to the audit log.
cm_safety::approvals::decide(
&state.pool,
approval.id,
user.user_id,
cm_safety::Decision::Approve,
)
.await
.map_err(|e| e.to_string())?;
Ok(approval.id)
}
/// Authenticate the bearer header → workspace/user. `None` if missing/invalid.
@@ -164,7 +259,7 @@ pub async fn mcp(
// The gate — human replaced by automated policy.
if let PolicyOutcome::Deny(reason) =
policy_decide(user.workspace_id, mcp_name, category, &args)
policy_decide(&state, user.workspace_id, mcp_name, category, &args).await
{
let _ = cm_db::repo::audit::append(
&state.pool,
@@ -188,12 +283,24 @@ pub async fn mcp(
Err(_) => return tool_result(req.id, true, "failed to resolve workspace agent".into()),
};
// Execute through the runtime's gated-tool path (v1 tools are
// runtime-executed; no grant needed). Broker-backed tools (v2) will
// pass an approval_id so the broker consumes a single-use grant.
// Broker-executed tools (e.g. slack.post) need a single-use grant
// the broker consumes — the agent never holds the credential. Mint
// an auto-approved approval+grant (the human is the policy above).
// Runtime-executed tools (email.send -> outbox) need no grant.
let approval_id = if state.runtime.tool_broker_executed(internal) {
match mint_grant(&state, &user, agent_id, internal, category, &args).await {
Ok(id) => Some(id),
Err(e) => {
return tool_result(req.id, true, format!("could not mint grant: {e}"))
}
}
} else {
None
};
match state
.runtime
.execute_door_tool(user.workspace_id, agent_id, internal, args.clone(), None)
.execute_door_tool(user.workspace_id, agent_id, internal, args.clone(), approval_id)
.await
{
Ok(output) => {
@@ -241,7 +348,7 @@ mod tests {
#[test]
fn exposed_tool_name_maps_to_registry_name() {
assert_eq!(internal_name("email_send"), Some("email.send"));
assert_eq!(internal_name("slack_post"), None); // not exposed in v1
assert_eq!(internal_name("slack_post"), Some("slack.post"));
assert_eq!(internal_name("shell"), None);
}