1B: Clawmates §15 MCP door — autonomous gated egress for tool-free agents
ci / gates (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / sandbox-k8s (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / e2e (push) Has been cancelled

Tool-free ZeroClaw agents get one actuator: an MCP server (POST /mcp, JSON-RPC
2.0, protocol 2024-11-05) that fronts the existing §15 machinery. The human
approver is replaced by an automated policy (default allow-all → agents are
autonomous; CLAWMATES_DOOR_POLICY=deny is a kill switch), but the governed parts
stay: every action is journaled to the append-only audit log, actions execute
through the runtime's gated-tool path, and broker credential-custody is wired in
for v2 tools. Synchronous execution returns the real result inline.

- crates/cm-api/src/mcp_door.rs: initialize/tools.list/tools.call handler; auth
  (bearer -> workspace), classify effects, policy auto-decide, execute, audit.
  v1 exposes email_send (-> outbox); slack/pay (broker+grant chain) is next.
- crates/cm-runtime: Runtime::{tool_descriptor_json, tool_gate_category,
  execute_door_tool} — door-facing entry that builds ToolContext and consumes a
  grant for runtime-executed gated tools.
- deploy/clawmates-runtime: agents now carry mcp_bundles=["clawmates_door"];
  [[mcp.servers]] points at the door (bearer injected at deploy, not committed).

Validated live on gw-04: tools/call email_send -> isError:false + outbox row +
"agent|door.executed" audit, no human. 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 18:32:42 -07:00
co-authored by Claude Opus 4.8
parent fbe783e278
commit 87f612016e
4 changed files with 360 additions and 10 deletions
+270
View File
@@ -0,0 +1,270 @@
//! 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__<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")];
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<String>,
#[serde(default)]
id: Option<Value>,
method: String,
#[serde(default)]
params: Option<Value>,
}
fn ok(id: Option<Value>, result: Value) -> Json<Value> {
Json(json!({ "jsonrpc": "2.0", "id": id, "result": result }))
}
fn err(id: Option<Value>, code: i64, message: &str) -> Json<Value> {
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<Value>, is_error: bool, text: String) -> Json<Value> {
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),
}
/// 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,
_category: Option<cm_domain::GatedCategory>,
_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,
}
}
/// Authenticate the bearer header → workspace/user. `None` if missing/invalid.
async fn authed(state: &AppState, headers: &HeaderMap) -> Option<cm_auth::AuthedUser> {
let token = headers
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))?;
state.auth.authenticate(token).await.ok()
}
/// `POST /mcp` — the JSON-RPC entrypoint ZeroClaw agents connect to.
pub async fn mcp(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<JsonRpcReq>,
) -> Json<Value> {
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<Value> = 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,
format!("unknown tool {mcp_name:?} (this door exposes: email_send)"),
);
};
let category = state.runtime.tool_gate_category(internal);
// The gate — human replaced by automated policy.
if let PolicyOutcome::Deny(reason) =
policy_decide(user.workspace_id, mcp_name, category, &args)
{
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 a workspace agent (outbox/audit FK).
let agent_id = match cm_db::repo::agents::roster(&state.pool, user.workspace_id).await {
Ok(roster) if !roster.is_empty() => roster[0].id,
Ok(_) => {
return tool_result(req.id, true, "no agent in workspace to act on behalf of".into())
}
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.
match state
.runtime
.execute_door_tool(user.workspace_id, agent_id, internal, args.clone(), None)
.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"), None); // not exposed in v1
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");
}
}