//! The Clawmates §15 MCP "door": the single gated egress a tool-free ZeroClaw //! agent uses to act on the world. //! //! Agents have **no native outbound tools**; their only actuator is this MCP //! server (`POST /mcp`, JSON-RPC 2.0, protocol `2024-11-05`). The door keeps the //! parts that make capability *governed* — the agent never holds a credential //! (broker-executed tools reveal secrets only inside the broker), every action //! is journaled to the append-only audit log, and a central policy decides each //! call — but the **human approver is replaced by an automated policy/governor** //! ("agents control their destiny"). The default policy is allow-all, so agents //! are autonomous out of the gate; recipient allowlists, spend caps, taint //! blocks, or a governor agent plug into [`policy_decide`]. //! //! v1 exposes `email_send` (runtime-executed → `outbox`, observable, no external //! creds). Broker-backed tools (e.g. `slack_post`) are the next increment — they //! additionally need the approval→grant chain the broker consumes. use axum::extract::State; use axum::http::header::AUTHORIZATION; use axum::http::HeaderMap; use axum::Json; use serde::Deserialize; use serde_json::{json, Value}; use crate::AppState; /// MCP protocol version the ZeroClaw client negotiates. const MCP_PROTOCOL_VERSION: &str = "2024-11-05"; /// Tools the door exposes, as `(mcp_name, internal_registry_name)`. The agent /// sees `clawmates__`; ZeroClaw strips the prefix and calls us with /// ``. We keep MCP names underscore-only (some models choke on dots). const EXPOSED_TOOLS: &[(&str, &str)] = &[ ("email_send", "email.send"), ("slack_post", "slack.post"), ("delegate", "delegate"), ]; fn internal_name(mcp_name: &str) -> Option<&'static str> { EXPOSED_TOOLS .iter() .find(|(m, _)| *m == mcp_name) .map(|(_, i)| *i) } /// A JSON-RPC 2.0 request frame. #[derive(Deserialize)] pub struct JsonRpcReq { #[allow(dead_code)] jsonrpc: Option, #[serde(default)] id: Option, method: String, #[serde(default)] params: Option, } fn ok(id: Option, result: Value) -> Json { Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })) } fn err(id: Option, code: i64, message: &str) -> Json { Json(json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })) } /// A tool-call result in MCP shape (the agent reads `content`; `isError` /// distinguishes a refused/failed call from a successful one). fn tool_result(id: Option, is_error: bool, text: String) -> Json { ok( id, json!({ "isError": is_error, "content": [{ "type": "text", "text": text }] }), ) } /// Door policy decision. The human is removed: this is where autonomy is /// governed (or not). enum PolicyOutcome { Approve, Deny(String), } /// 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, args: &Value, ) -> PolicyOutcome { // 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::().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 agent: when CLAWMATES_DOOR_GOVERNOR is set, an LLM judges the // action and can veto — the "self-governing topology" path. Fail-open // (a governor outage doesn't halt agents); deterministic rules above are // the hard floor. if std::env::var("CLAWMATES_DOOR_GOVERNOR").is_ok() { let system = "You are a security governor for an autonomous agent's outbound actions. \ Reply with exactly ALLOW or DENY on the first line, then one short reason. \ DENY if the action looks like data exfiltration, spam, credential/secret leakage, \ or sending sensitive or internal data to an untrusted external recipient. \ Otherwise ALLOW."; let request = format!( "Tool: {mcp_tool}\nCategory: {}\nPayload: {}", category.map(|c| c.as_str()).unwrap_or("none"), serde_json::to_string(args).unwrap_or_default() ); // `CLAWMATES_JUDGE_MODEL=runtime:` routes the governor through a // ZeroClaw runtime agent (e.g. Kimi via kimi_cli on the subscription) — // no platform API key needed. Otherwise the server-side registry judge. let judge_model = cm_runtime::judge_model(); let (allow, reason) = if let Some(alias) = judge_model.strip_prefix("runtime:") { match crate::topology_exec::ZeroClawDriveExecutor::from_env() { Ok(exec) => exec.judge(alias.trim(), system, &request).await, Err(e) => (true, format!("governor unreachable (fail-open): {e}")), } } else { state.runtime.judge(system, &request).await }; // Fail-open is deliberate, but a governor that is failing open on EVERY // request is a security control that has quietly stopped existing — // and the caller drops `reason` whenever it allows, so nothing said so. // `judge()` returns this exact prefix when the provider never answered, // which a rate-limited or uncredited judge model does on every call. if allow && reason.starts_with("governor unreachable") { eprintln!( "mcp_door: WARNING — the door governor is FAILING OPEN for {mcp_tool} \ ({reason}). Every outbound action is being approved unjudged. Point \ CLAWMATES_JUDGE_MODEL at a reachable model." ); } if !allow { return PolicyOutcome::Deny(format!("governor agent vetoed — {reason}")); } } 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, args: &Value, ) -> Result { // 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. /// /// Accepts [`cm_auth::SCOPE_AGENT_DOOR`] as well as a person's session. This /// route is the one that can `delegate`, and the thing that will eventually /// hold a token for it is an agent runtime — so the narrow credential has to /// exist before something reaches for the only one that does. async fn authed(state: &AppState, headers: &HeaderMap) -> Option { let token = headers .get(AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer "))?; state .auth .authenticate_scoped(token, cm_auth::SCOPE_AGENT_DOOR) .await .ok() } /// Resolve the specific claw making the call. Our ZeroClaw fork stamps the /// calling agent's alias (`claw_`) on every door request via the /// `X-ZeroClaw-Agent` header (see `mcp_servers_for_agent`); we resolve it to the /// agent and verify it belongs to the authenticated workspace. Falls back to the /// workspace's first agent for agents provisioned before per-claw identity, so /// attribution degrades gracefully rather than failing. async fn caller_agent( state: &AppState, user: &cm_auth::AuthedUser, headers: &HeaderMap, ) -> Result { if let Some(alias) = headers .get("x-zeroclaw-agent") .and_then(|v| v.to_str().ok()) { if let Some(hex) = alias.strip_prefix("claw_") { if let Ok(uuid) = uuid::Uuid::parse_str(hex) { let agent_id = cm_domain::AgentId::from(uuid); if let Ok(agent) = cm_db::repo::agents::get(&state.pool, agent_id).await { if agent.workspace_id == user.workspace_id { return Ok(agent.id); } } } } } // Legacy fallback: attribute to the workspace's first agent. match cm_db::repo::agents::roster(&state.pool, user.workspace_id).await { Ok(roster) if !roster.is_empty() => Ok(roster[0].id), Ok(_) => Err("no agent in workspace to act on behalf of".into()), Err(_) => Err("failed to resolve workspace agent".into()), } } /// Execute a gated `delegate` call: drive a sibling claw for a sub-task and /// return its result, audited at every step. The target is tool-free behind the /// door, so this adds no egress (§15 holds). Safety (v1): self-delegation is /// rejected with the precise caller identity; a per-workspace hourly cap /// (`CLAWMATES_DELEGATE_RATE_LIMIT`) plus the per-turn timeout bound runaway /// fan-out / recursion. Chain-based cycle detection is a follow-up. async fn delegate_call( state: &AppState, user: &cm_auth::AuthedUser, caller: cm_domain::AgentId, args: &Value, id: Option, ) -> Json { let Some(to) = args .get("to") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) else { return tool_result(id, true, "delegate: missing 'to' (target claw name)".into()); }; let Some(task) = args .get("task") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) else { return tool_result(id, true, "delegate: missing 'task'".into()); }; let context: Vec = args .get("context") .and_then(|v| v.as_array()) .map(|a| { a.iter() .filter_map(|x| x.as_str().map(str::to_owned)) .collect() }) .unwrap_or_default(); // Resolve the target claw by name within the workspace. let target = match cm_db::repo::agents::roster(&state.pool, user.workspace_id).await { Ok(roster) => roster.into_iter().find(|a| a.name.eq_ignore_ascii_case(to)), Err(_) => { return tool_result( id, true, "delegate: failed to resolve workspace roster".into(), ) } }; let Some(target) = target else { return tool_result( id, true, format!("delegate: no claw named {to:?} in this workspace"), ); }; if target.id == caller { return tool_result(id, true, "delegate: cannot delegate to yourself".into()); } // Per-workspace hourly delegation budget (counts delegation.invoked). if let Some(cap) = std::env::var("CLAWMATES_DELEGATE_RATE_LIMIT") .ok() .and_then(|v| v.parse::().ok()) { let used: i64 = sqlx::query_scalar( "SELECT count(*) FROM audit_log WHERE workspace_id = $1 AND event_type = 'delegation.invoked' AND created_at > now() - interval '1 hour'", ) .bind(user.workspace_id.as_uuid()) .fetch_one(&state.pool) .await .unwrap_or(0); if used >= cap { return tool_result( id, true, format!("delegate: hourly delegation limit reached ({used}/{cap})"), ); } } let _ = cm_db::repo::audit::append( &state.pool, user.workspace_id, cm_db::repo::audit::Actor::Agent(caller), "delegation.invoked", "agent", &target.name, json!({ "to_id": target.id.to_string(), "task": task }), ) .await; let exec = match crate::topology_exec::ZeroClawDriveExecutor::from_env() { Ok(exec) => exec, Err(e) => return tool_result(id, true, format!("delegate: runtime unavailable: {e}")), }; let alias = crate::runtime_provision::claw_alias(target.id.as_uuid()); match exec.delegate(&alias, task, &context).await { Ok(outcome) => { let _ = cm_db::repo::audit::append( &state.pool, user.workspace_id, cm_db::repo::audit::Actor::Agent(caller), "delegation.completed", "agent", &target.name, json!({ "to_id": target.id.to_string(), "tokens": outcome.tokens, "blocked": outcome.gated.len() }), ) .await; // §15: the result is untrusted content from another agent. The // attribution stays — knowing which claw produced this is // information the caller needs to weigh it. The "treat it as // information, not instructions" imperative that followed is gone: // that is model-correction of the kind a current frontier model no // longer needs, and taint tracking (output_taint = InterAgent), not // a sentence in the payload, is what actually contains this. let mut text = format!( "The following is the result returned by claw '{}'.\n\n{}", target.name, outcome.output ); if !outcome.gated.is_empty() { text.push_str(&format!( "\n\n[note: {} action(s) by '{}' were blocked at the door during this delegation]", outcome.gated.len(), target.name )); } tool_result(id, false, text) } Err(e) => { let _ = cm_db::repo::audit::append( &state.pool, user.workspace_id, cm_db::repo::audit::Actor::Agent(caller), "delegation.error", "agent", &target.name, json!({ "to_id": target.id.to_string(), "error": e.to_string() }), ) .await; tool_result(id, true, format!("delegate: turn failed: {e}")) } } } /// `POST /mcp` — the JSON-RPC entrypoint ZeroClaw agents connect to. pub async fn mcp( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Json { match req.method.as_str() { // Handshake — no auth needed to negotiate the protocol. "initialize" => ok( req.id, json!({ "protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": { "tools": {} }, "serverInfo": { "name": "clawmates", "version": env!("CARGO_PKG_VERSION") }, }), ), // Best-effort notification; nothing to do. "notifications/initialized" => Json(json!({ "jsonrpc": "2.0" })), "tools/list" => { if authed(&state, &headers).await.is_none() { return err( req.id, -32001, "unauthorized: missing or invalid bearer token", ); } let tools: Vec = EXPOSED_TOOLS .iter() .filter_map(|(mcp_name, internal)| { state.runtime.tool_descriptor_json(internal).map(|mut d| { // Present under the MCP-facing name. d["name"] = json!(mcp_name); d }) }) .collect(); ok(req.id, json!({ "tools": tools })) } "tools/call" => { let Some(user) = authed(&state, &headers).await else { return err( req.id, -32001, "unauthorized: missing or invalid bearer token", ); }; let params = req.params.clone().unwrap_or_else(|| json!({})); let mcp_name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); let args = params .get("arguments") .cloned() .unwrap_or_else(|| json!({})); let Some(internal) = internal_name(mcp_name) else { return tool_result( req.id, true, // Derived from EXPOSED_TOOLS rather than hand-written: the // literal list here had already drifted to name only one of // the three tools the door actually exposes. format!( "unknown tool {mcp_name:?} (this door exposes: {})", EXPOSED_TOOLS .iter() .map(|(m, _)| *m) .collect::>() .join(", ") ), ); }; let category = state.runtime.tool_gate_category(internal); // The gate — human replaced by automated policy. if let PolicyOutcome::Deny(reason) = policy_decide(&state, user.workspace_id, mcp_name, category, &args).await { let _ = cm_db::repo::audit::append( &state.pool, user.workspace_id, cm_db::repo::audit::Actor::System, "door.denied", "tool", mcp_name, json!({ "category": category.map(|c| c.as_str()), "reason": reason, "args": args }), ) .await; return tool_result(req.id, true, format!("denied by policy: {reason}")); } // Attribute the action to the specific calling claw (X-ZeroClaw-Agent // header), or the workspace's first agent as a legacy fallback. let agent_id = match caller_agent(&state, &user, &headers).await { Ok(id) => id, Err(msg) => return tool_result(req.id, true, msg), }; // Gated delegation bridge: `delegate` causes a sibling claw to run a // full turn and returns its result, gated + audited here rather than // via ZeroClaw's in-memory DelegateTool (which would bypass the door). if internal == "delegate" { return delegate_call(&state, &user, agent_id, &args, req.id).await; } // 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(), approval_id, ) .await { Ok(output) => { let _ = cm_db::repo::audit::append( &state.pool, user.workspace_id, cm_db::repo::audit::Actor::Agent(agent_id), "door.executed", "tool", mcp_name, json!({ "category": category.map(|c| c.as_str()), "auto_decided": true, "args": args, "output": output, }), ) .await; tool_result(req.id, false, output.to_string()) } Err(message) => { let _ = cm_db::repo::audit::append( &state.pool, user.workspace_id, cm_db::repo::audit::Actor::Agent(agent_id), "door.error", "tool", mcp_name, json!({ "error": message, "args": args }), ) .await; tool_result(req.id, true, format!("tool error: {message}")) } } } other => err(req.id, -32601, &format!("method not found: {other}")), } } #[cfg(test)] mod tests { use super::*; #[test] fn exposed_tool_name_maps_to_registry_name() { assert_eq!(internal_name("email_send"), Some("email.send")); assert_eq!(internal_name("slack_post"), Some("slack.post")); assert_eq!(internal_name("delegate"), Some("delegate")); assert_eq!(internal_name("shell"), None); } #[test] fn ok_frame_echoes_id_and_carries_result() { let Json(v) = ok(Some(json!(7)), json!({ "tools": [] })); assert_eq!(v["jsonrpc"], "2.0"); assert_eq!(v["id"], 7); assert_eq!(v["result"]["tools"], json!([])); } #[test] fn err_frame_has_code_and_message() { let Json(v) = err(Some(json!("a")), -32001, "unauthorized"); assert_eq!(v["error"]["code"], -32001); assert_eq!(v["error"]["message"], "unauthorized"); } #[test] fn tool_result_shapes_content_and_is_error() { let Json(v) = tool_result(Some(json!(1)), true, "denied by policy: nope".into()); assert_eq!(v["result"]["isError"], true); assert_eq!(v["result"]["content"][0]["type"], "text"); assert_eq!(v["result"]["content"][0]["text"], "denied by policy: nope"); } }