`/mcp` — `email_send`, `slack_post`, `delegate` — authenticated with `authenticate`, which accepts only `full`. Nothing hands it a token today, so this cost nothing yet; the moment something did, the only credential that worked would have been an owner's session, held by an agent runtime. `SCOPE_AGENT_DOOR` is that credential's narrow form. `full` still works, so the UI and every human caller are unaffected, and the route now names what it accepts rather than accepting everything by default. The test that matters is not that each scope opens its own route: it is that holding one grants nothing the other has. Both tokens live where an agent can read them. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
643 lines
25 KiB
Rust
643 lines
25 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"). 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"),
|
|
("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 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<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 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:<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
|
|
};
|
|
// 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<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::*;
|
|
|
|
#[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");
|
|
}
|
|
}
|