Three fail-open paths on the §15 door: no env at all meant allow-all; a
governor that could not be reached approved with a WARNING; and a reply
that never said DENY — empty, truncated, a refusal — approved, because the
rule was !contains("DENY"). On the two days the judge plan emptied every
outbound action was approved by nobody.
Now: governor_allows() needs an explicit ALLOW and no DENY; both judge()
implementations return false when unreachable; with no governor the door
opens only on CLAWMATES_DOOR_POLICY=allow. Open Agent Passport (arXiv
2603.20953): 74.6% social-engineering success under a permissive policy,
0 of 879 under a restrictive one. Local override gains the governor prod
already runs.
skill_self_authoring: default flipped to OFF. No agent-authored skill has
ever been delivered to a mission or scored; prod held zero proposals.
Enable with CLAWMATES_SKILL_SELF_AUTHORING=1 once promoted skills go
through the files arm and get a Skill-Use score.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
676 lines
27 KiB
Rust
676 lines
27 KiB
Rust
//! 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"). Recipient allowlists, spend caps, taint
|
|
//! blocks, or a governor agent plug into [`policy_decide`]. Since 2026-09-20
|
|
//! the door is **closed by default**: a call is approved only by a governor
|
|
//! that answered ALLOW, or by an explicit `CLAWMATES_DOOR_POLICY=allow`.
|
|
//!
|
|
//! 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"),
|
|
("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<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),
|
|
}
|
|
|
|
/// 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 agent (`CLAWMATES_DOOR_GOVERNOR`) — must answer ALLOW;
|
|
/// unreachable, silent, or off-contract means DENY;
|
|
/// 5. with no governor, an explicit `CLAWMATES_DOOR_POLICY=allow`.
|
|
///
|
|
/// Default (no env set) = **deny**. This was allow-all until 2026-09-20, and
|
|
/// the governor failed open on top of that, so with the judge plan emptied
|
|
/// every outbound action was approved by nobody. See [`ungoverned_default`]
|
|
/// and `cm_runtime::Runtime::judge` for the measurement behind the flip.
|
|
async fn policy_decide(
|
|
state: &AppState,
|
|
workspace: cm_domain::WorkspaceId,
|
|
mcp_tool: &str,
|
|
category: Option<cm_domain::GatedCategory>,
|
|
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::<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 agent: when CLAWMATES_DOOR_GOVERNOR is set, an LLM judges the
|
|
// action — the "self-governing topology" path. Fail-CLOSED: a governor
|
|
// that cannot be reached, or that does not say ALLOW, denies. The
|
|
// deterministic rules above are the hard floor; this is the only
|
|
// approver.
|
|
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:<alias>` 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
|
|
};
|
|
// `judge()` returns this exact prefix when the provider never answered,
|
|
// which a rate-limited or uncredited judge model does on every call.
|
|
// Still logged loudly: a door that denies everything because its
|
|
// governor is down is safe, and is also a platform with no outbound
|
|
// actions until someone reads this line.
|
|
if reason.starts_with("governor unreachable") {
|
|
eprintln!(
|
|
"mcp_door: WARNING — the door governor is unreachable, DENYING {mcp_tool} \
|
|
({reason}). Point CLAWMATES_JUDGE_MODEL at a reachable model."
|
|
);
|
|
}
|
|
if !allow {
|
|
return PolicyOutcome::Deny(format!("governor agent vetoed — {reason}"));
|
|
}
|
|
return PolicyOutcome::Approve;
|
|
}
|
|
|
|
// 5. No governor. The door is closed unless the operator opened it.
|
|
ungoverned_default(std::env::var("CLAWMATES_DOOR_POLICY").ok().as_deref())
|
|
}
|
|
|
|
/// The posture with no governor configured. Only the literal `allow` opens
|
|
/// the door; unset, empty, or anything else keeps it shut and says how to
|
|
/// open it. `deny` is handled earlier as the kill switch and lands here too.
|
|
fn ungoverned_default(policy: Option<&str>) -> PolicyOutcome {
|
|
match policy.map(str::trim) {
|
|
Some("allow") => PolicyOutcome::Approve,
|
|
_ => PolicyOutcome::Deny(
|
|
"the door has no governor and no allow policy — set CLAWMATES_DOOR_GOVERNOR=1 \
|
|
or, to run ungoverned, CLAWMATES_DOOR_POLICY=allow"
|
|
.into(),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// 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<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_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_<id>`) 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<cm_domain::AgentId, String> {
|
|
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<Value>,
|
|
) -> Json<Value> {
|
|
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<String> = 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::<i64>().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<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,
|
|
// 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::<Vec<_>>()
|
|
.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::*;
|
|
|
|
/// The default posture is closed. Before 2026-09-20 an unset policy
|
|
/// meant allow-all.
|
|
#[test]
|
|
fn the_door_is_closed_unless_opened() {
|
|
assert!(matches!(ungoverned_default(None), PolicyOutcome::Deny(_)));
|
|
assert!(matches!(ungoverned_default(Some("")), PolicyOutcome::Deny(_)));
|
|
assert!(matches!(ungoverned_default(Some("deny")), PolicyOutcome::Deny(_)));
|
|
assert!(matches!(ungoverned_default(Some("yes")), PolicyOutcome::Deny(_)));
|
|
assert!(matches!(ungoverned_default(Some("allow")), PolicyOutcome::Approve));
|
|
assert!(matches!(ungoverned_default(Some(" allow ")), PolicyOutcome::Approve));
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|