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
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
mod error;
|
mod error;
|
||||||
mod extract;
|
mod extract;
|
||||||
|
mod mcp_door;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod topology_exec;
|
mod topology_exec;
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ impl AppState {
|
|||||||
pub fn router(state: AppState) -> Router {
|
pub fn router(state: AppState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/healthz", get(routes::health::healthz))
|
.route("/healthz", get(routes::health::healthz))
|
||||||
|
.route("/mcp", post(mcp_door::mcp))
|
||||||
.route("/api/auth/login", post(routes::auth::login))
|
.route("/api/auth/login", post(routes::auth::login))
|
||||||
.route("/api/auth/logout", post(routes::auth::logout))
|
.route("/api/auth/logout", post(routes::auth::logout))
|
||||||
.route("/api/user/me", get(routes::identity::me))
|
.route("/api/user/me", get(routes::identity::me))
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -193,6 +193,70 @@ impl Runtime {
|
|||||||
self.inner.config.max_tokens
|
self.inner.config.max_tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MCP-shaped tool definition (`{name, description, inputSchema}`) for one
|
||||||
|
/// registered tool, or `None` if unknown. Used by the MCP door's
|
||||||
|
/// `tools/list`.
|
||||||
|
pub fn tool_descriptor_json(&self, name: &str) -> Option<Value> {
|
||||||
|
self.inner
|
||||||
|
.tools
|
||||||
|
.descriptors()
|
||||||
|
.into_iter()
|
||||||
|
.find(|d| d.name == name)
|
||||||
|
.map(|d| {
|
||||||
|
json!({
|
||||||
|
"name": d.name,
|
||||||
|
"description": d.description,
|
||||||
|
"inputSchema": d.input_schema,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a tool's declared effects into a §15 gated category (assuming
|
||||||
|
/// no upstream taint). `None` means the action needs no approval.
|
||||||
|
pub fn tool_gate_category(&self, name: &str) -> Option<cm_domain::GatedCategory> {
|
||||||
|
let effects = self.inner.tools.effects_of(name);
|
||||||
|
let taint = TaintSet::from_strings(&[]);
|
||||||
|
match GatePolicy.classify(effects, &taint) {
|
||||||
|
GateDecision::RequireApproval(category) => Some(category),
|
||||||
|
GateDecision::Allow => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a tool on behalf of the MCP door — capability is already decided
|
||||||
|
/// by door policy (the human approver is replaced by an automated policy /
|
||||||
|
/// governor). Builds the tool context from runtime config; when an
|
||||||
|
/// `approval_id` is supplied and the tool is runtime-executed, the
|
||||||
|
/// single-use grant is consumed before the action runs (broker-executed
|
||||||
|
/// tools consume it inside the broker).
|
||||||
|
pub async fn execute_door_tool(
|
||||||
|
&self,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
agent_id: AgentId,
|
||||||
|
name: &str,
|
||||||
|
input: Value,
|
||||||
|
approval_id: Option<Uuid>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let ctx = ToolContext {
|
||||||
|
pool: self.inner.pool.clone(),
|
||||||
|
workspace_id,
|
||||||
|
agent_id,
|
||||||
|
blob: self.inner.blob.clone(),
|
||||||
|
approval_id,
|
||||||
|
broker_socket: self.inner.config.broker_socket.clone(),
|
||||||
|
slack_base_url: self.inner.config.slack_base_url.clone(),
|
||||||
|
sandboxes: self.inner.config.sandboxes.clone(),
|
||||||
|
browser: self.inner.config.browser.clone(),
|
||||||
|
};
|
||||||
|
if let Some(aid) = approval_id {
|
||||||
|
if !self.inner.tools.broker_executed(name) {
|
||||||
|
grants::consume(&self.inner.pool, aid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.inner.tools.execute(&ctx, name, input).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Live-attach to a run that is still streaming.
|
/// Live-attach to a run that is still streaming.
|
||||||
pub async fn subscribe(&self, run_id: Uuid) -> Option<broadcast::Receiver<RunEventEnvelope>> {
|
pub async fn subscribe(&self, run_id: Uuid) -> Option<broadcast::Receiver<RunEventEnvelope>> {
|
||||||
self.inner
|
self.inner
|
||||||
|
|||||||
@@ -34,31 +34,45 @@ excluded_tools = ["shell", "file_read", "file_write", "http_request", "browser",
|
|||||||
[agents.coordinator]
|
[agents.coordinator]
|
||||||
model_provider = "claude_cli.default"
|
model_provider = "claude_cli.default"
|
||||||
risk_profile = "toolfree"
|
risk_profile = "toolfree"
|
||||||
|
mcp_bundles = ["clawmates_door"]
|
||||||
|
|
||||||
[agents.researcher]
|
[agents.researcher]
|
||||||
model_provider = "claude_cli.default"
|
model_provider = "claude_cli.default"
|
||||||
risk_profile = "toolfree"
|
risk_profile = "toolfree"
|
||||||
|
mcp_bundles = ["clawmates_door"]
|
||||||
|
|
||||||
[agents.writer]
|
[agents.writer]
|
||||||
model_provider = "claude_cli.default"
|
model_provider = "claude_cli.default"
|
||||||
risk_profile = "toolfree"
|
risk_profile = "toolfree"
|
||||||
|
mcp_bundles = ["clawmates_door"]
|
||||||
|
|
||||||
[agents.worker]
|
[agents.worker]
|
||||||
model_provider = "claude_cli.default"
|
model_provider = "claude_cli.default"
|
||||||
risk_profile = "toolfree"
|
risk_profile = "toolfree"
|
||||||
|
mcp_bundles = ["clawmates_door"]
|
||||||
|
|
||||||
# Default fallback alias for roles not present above.
|
# Default fallback alias for roles not present above.
|
||||||
[agents.scout]
|
[agents.scout]
|
||||||
model_provider = "claude_cli.default"
|
model_provider = "claude_cli.default"
|
||||||
risk_profile = "toolfree"
|
risk_profile = "toolfree"
|
||||||
|
mcp_bundles = ["clawmates_door"]
|
||||||
|
|
||||||
# Step 1B — the Clawmates §15 MCP door (uncomment + point at the MCP server):
|
# Step 1B — the Clawmates §15 MCP door. Tool-free agents reach it as their ONLY
|
||||||
# [mcp]
|
# actuator; MCP tools inject AFTER the empty allowlist above. The door enforces
|
||||||
# enabled = true
|
# §15 (audit, broker credential custody) but auto-decides via policy (no human;
|
||||||
# deferred_loading = true
|
# default allow-all → agents are autonomous). The Authorization bearer is a
|
||||||
# [mcp.servers.clawmates]
|
# per-workspace door token (a Clawmates session token) — injected on gw-04, NOT
|
||||||
# transport = "http"
|
# committed; the placeholder below is replaced at deploy time.
|
||||||
# url = "http://mcp:3000/mcp"
|
[mcp]
|
||||||
# [mcp_bundles.clawmates_door]
|
enabled = true
|
||||||
# servers = ["clawmates"]
|
deferred_loading = false
|
||||||
# then add `mcp_bundles = ["clawmates_door"]` to each [agents.*] block above.
|
|
||||||
|
[[mcp.servers]]
|
||||||
|
name = "clawmates"
|
||||||
|
transport = "http"
|
||||||
|
url = "http://clawmates_server_1:8080/mcp"
|
||||||
|
tool_timeout_secs = 120
|
||||||
|
headers = { Authorization = "Bearer REPLACE_WITH_DOOR_TOKEN" }
|
||||||
|
|
||||||
|
[mcp_bundles.clawmates_door]
|
||||||
|
servers = ["clawmates"]
|
||||||
|
|||||||
Reference in New Issue
Block a user