door: richer auto-policy (c) + broker-backed tools (b)
(c) policy_decide is now async with real governance: kill switch + per-workspace hourly rate cap (audit_log count) + email recipient-domain allowlist + a governor extension point. Still allow-all by default (autonomous). (b) the door now supports broker-executed tools: for a broker tool it mints an auto-approved approval + single-use grant (agent->session->run->approval-> decide), then executes via the runtime so the broker consumes the grant and reveals the credential — the agent never holds it. Exposes slack_post. cm-runtime gains tool_broker_executed + tool_preview accessors. 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
ea5687565a
commit
19e9943f79
+133
-26
@@ -30,7 +30,10 @@ 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")];
|
||||
const EXPOSED_TOOLS: &[(&str, &str)] = &[
|
||||
("email_send", "email.send"),
|
||||
("slack_post", "slack.post"),
|
||||
];
|
||||
|
||||
fn internal_name(mcp_name: &str) -> Option<&'static str> {
|
||||
EXPOSED_TOOLS
|
||||
@@ -75,28 +78,120 @@ enum PolicyOutcome {
|
||||
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,
|
||||
/// 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,
|
||||
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,
|
||||
// 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 hook (extension point): a deterministic rule set or a governor
|
||||
// agent (an LLM that judges the payload) can veto here — the seam for the
|
||||
// "self-governing topology" story. Default: no veto.
|
||||
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.
|
||||
@@ -164,7 +259,7 @@ pub async fn mcp(
|
||||
|
||||
// The gate — human replaced by automated policy.
|
||||
if let PolicyOutcome::Deny(reason) =
|
||||
policy_decide(user.workspace_id, mcp_name, category, &args)
|
||||
policy_decide(&state, user.workspace_id, mcp_name, category, &args).await
|
||||
{
|
||||
let _ = cm_db::repo::audit::append(
|
||||
&state.pool,
|
||||
@@ -188,12 +283,24 @@ pub async fn mcp(
|
||||
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.
|
||||
// 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(), None)
|
||||
.execute_door_tool(user.workspace_id, agent_id, internal, args.clone(), approval_id)
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
@@ -241,7 +348,7 @@ mod tests {
|
||||
#[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("slack_post"), Some("slack.post"));
|
||||
assert_eq!(internal_name("shell"), None);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user