1B: Clawmates §15 MCP door — autonomous gated egress for tool-free agents
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:
co-authored by
Claude Opus 4.8
parent
fbe783e278
commit
87f612016e
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user