From 76ac3714f1d75bfd440e477c66167928ac227ff0 Mon Sep 17 00:00:00 2001 From: Omar Sobh Date: Sun, 20 Sep 2026 22:07:39 -0500 Subject: [PATCH] sec(door): closed by default, governor fails closed; self-authoring off by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz --- crates/cm-api/src/level_up.rs | 47 +++++++++++++-- crates/cm-api/src/mcp_door.rs | 67 ++++++++++++++++------ crates/cm-api/src/skill_self_authoring.rs | 12 ++-- crates/cm-api/src/topology_exec.rs | 10 ++-- crates/cm-runtime/src/lib.rs | 3 +- crates/cm-runtime/src/runtime.rs | 53 +++++++++++++---- deploy/compose/docker-compose.override.yml | 4 ++ docs/CAPABILITY-REVIEW.md | 6 +- 8 files changed, 157 insertions(+), 45 deletions(-) diff --git a/crates/cm-api/src/level_up.rs b/crates/cm-api/src/level_up.rs index cfe5407..8b43930 100644 --- a/crates/cm-api/src/level_up.rs +++ b/crates/cm-api/src/level_up.rs @@ -227,20 +227,57 @@ pub async fn apply( /// Is autonomous skill authoring on? /// -/// Default ON, by operator decision. Stated at boot rather than assumed: this -/// flips a human approval gate that has existed since the feature shipped, and -/// a safety gate that changes state silently is how nobody notices it changed. +/// Default OFF since 2026-09-20, by operator decision. It shipped default ON, +/// and in the months since no agent-authored skill was ever delivered to a +/// mission or scored by the Skill-Use scorer — prod's `level_up_proposals` +/// held zero rows on the day of the flip. An auto-apply loop whose output has +/// never been measured is a supply chain of our own making (the shape Cisco +/// found in OpenClaw's third-party skills), so it waits for a human until +/// `promoted_from_brain` skills go through the `files` delivery arm and get +/// a Trigger/Compliance score like the hand-authored ones. Stated at boot +/// either way: a safety gate that changes state silently is how nobody +/// notices it changed. pub fn self_authoring_enabled() -> bool { - !matches!( + matches!( std::env::var("CLAWMATES_SKILL_SELF_AUTHORING") .unwrap_or_default() .trim() .to_ascii_lowercase() .as_str(), - "0" | "off" | "false" + "1" | "on" | "true" ) } +#[cfg(test)] +mod self_authoring_flag_tests { + /// Serialised through one env var; each case restores the prior state. + fn with(value: Option<&str>, f: impl FnOnce()) { + let key = "CLAWMATES_SKILL_SELF_AUTHORING"; + let prior = std::env::var(key).ok(); + match value { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + f(); + match prior { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + } + + /// Off unless switched on. The previous default was the reverse. + #[test] + fn off_by_default_on_by_explicit_opt_in() { + with(None, || assert!(!super::self_authoring_enabled())); + with(Some(""), || assert!(!super::self_authoring_enabled())); + with(Some("0"), || assert!(!super::self_authoring_enabled())); + with(Some("yes"), || assert!(!super::self_authoring_enabled())); + with(Some("1"), || assert!(super::self_authoring_enabled())); + with(Some("on"), || assert!(super::self_authoring_enabled())); + with(Some("TRUE"), || assert!(super::self_authoring_enabled())); + } +} + /// Apply a pending proposal's `skill_candidate` items with no human decision. /// /// ONLY `skill_candidate`. The other item kinds are deliberately left to the diff --git a/crates/cm-api/src/mcp_door.rs b/crates/cm-api/src/mcp_door.rs index a1cd57d..c7ef2b7 100644 --- a/crates/cm-api/src/mcp_door.rs +++ b/crates/cm-api/src/mcp_door.rs @@ -7,9 +7,10 @@ //! (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`]. +//! ("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 @@ -85,10 +86,14 @@ enum PolicyOutcome { /// 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. +/// 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) = allow-all → agents fully autonomous. +/// 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, @@ -140,9 +145,10 @@ async fn policy_decide( } // 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. + // 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. \ @@ -166,24 +172,39 @@ async fn policy_decide( } 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") { + // 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 FAILING OPEN for {mcp_tool} \ - ({reason}). Every outbound action is being approved unjudged. Point \ - CLAWMATES_JUDGE_MODEL at a reachable model." + "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; } - 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 @@ -609,6 +630,18 @@ pub async fn mcp( 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")); diff --git a/crates/cm-api/src/skill_self_authoring.rs b/crates/cm-api/src/skill_self_authoring.rs index 08715ee..7d3c5f1 100644 --- a/crates/cm-api/src/skill_self_authoring.rs +++ b/crates/cm-api/src/skill_self_authoring.rs @@ -2,8 +2,9 @@ //! //! `level_up` has generated complete skill drafts from a model since it //! shipped; the only thing between a draft and the catalogue was an operator -//! ticking a checkbox in `LevelUpDrawer`. This worker removes the checkbox, by -//! operator decision. +//! ticking a checkbox in `LevelUpDrawer`. This worker removes the checkbox — +//! when switched on. It is OFF by default since 2026-09-20; see +//! `level_up::self_authoring_enabled` for why. //! //! What is deliberately NOT removed is the record. Every write stays //! workspace-scoped and versioned, cannot take the name of a hand-authored @@ -29,8 +30,9 @@ const SWEEP_INTERVAL: Duration = Duration::from_secs(120); pub fn spawn(pool: PgPool) { if !crate::level_up::self_authoring_enabled() { eprintln!( - "skill_self_authoring: DISABLED (CLAWMATES_SKILL_SELF_AUTHORING) — \ - agent skill drafts wait for a human in the level-up drawer" + "skill_self_authoring: DISABLED (the default since 2026-09-20) — \ + agent skill drafts wait for a human in the level-up drawer. \ + Set CLAWMATES_SKILL_SELF_AUTHORING=1 to let agents apply their own." ); return; } @@ -38,7 +40,7 @@ pub fn spawn(pool: PgPool) { "skill_self_authoring: ENABLED — agents apply their own skill drafts \ without human approval. Writes are workspace-scoped, versioned, and \ cannot take a hand-authored skill's name; each lands with no approver \ - recorded. Set CLAWMATES_SKILL_SELF_AUTHORING=0 to restore the gate." + recorded. Unset CLAWMATES_SKILL_SELF_AUTHORING to restore the gate." ); tokio::spawn(async move { loop { diff --git a/crates/cm-api/src/topology_exec.rs b/crates/cm-api/src/topology_exec.rs index c682fab..f177380 100644 --- a/crates/cm-api/src/topology_exec.rs +++ b/crates/cm-api/src/topology_exec.rs @@ -605,19 +605,19 @@ impl ZeroClawDriveExecutor { } /// Use a runtime agent as a governance judge: drive `alias` with the judge - /// prompt and parse the verdict (`DENY` anywhere ⇒ deny, else allow). This + /// prompt and parse the verdict with [`cm_runtime::governor_allows`]. This /// lets a **subscription-only** model (e.g. Kimi via `kimi_cli`) be the judge /// with no platform API key — the registry/SDK path GLM and Kimi can't take. - /// Fail-open (returns `(true, …)`) so a judge outage never halts agents. + /// Fail-closed: an unreachable judge denies, for the reason given on + /// [`cm_runtime::Runtime::judge`]. pub async fn judge(&self, alias: &str, system: &str, user: &str) -> (bool, String) { let prompt = format!("{system}\n\n{user}"); match self.drive(alias, &prompt).await { Ok(outcome) => { let text = outcome.output.trim().to_string(); - let allow = !text.to_uppercase().contains("DENY"); - (allow, text) + (cm_runtime::governor_allows(&text), text) } - Err(e) => (true, format!("governor unreachable (fail-open): {e}")), + Err(e) => (false, format!("governor unreachable (fail-closed): {e}")), } } diff --git a/crates/cm-runtime/src/lib.rs b/crates/cm-runtime/src/lib.rs index 24086a5..35cdc06 100644 --- a/crates/cm-runtime/src/lib.rs +++ b/crates/cm-runtime/src/lib.rs @@ -14,7 +14,8 @@ mod tools; pub use events::{RunEventBody, RunEventEnvelope}; pub use outbox::{drain_once, spawn_drainer, EmailSender, LettreSender, SmtpConfig}; pub use runtime::{ - judge_model, ProviderRegistry, Runtime, RuntimeConfig, RuntimeError, StartedRun, + governor_allows, judge_model, ProviderRegistry, Runtime, RuntimeConfig, RuntimeError, + StartedRun, }; pub use sandboxes::{NodeDriverProvider, SandboxManager}; pub use terminals::{DriveConfig, TerminalManager}; diff --git a/crates/cm-runtime/src/runtime.rs b/crates/cm-runtime/src/runtime.rs index 045f050..a7f4689 100644 --- a/crates/cm-runtime/src/runtime.rs +++ b/crates/cm-runtime/src/runtime.rs @@ -30,6 +30,16 @@ const APPROVAL_TTL: time::Duration = time::Duration::hours(24); /// comparison scorer). Judges use the strongest model — default /// `claude-opus-5` — while everything else runs on the configured default /// model (`claude-sonnet-4-6`). Override with `CLAWMATES_JUDGE_MODEL`. +/// Read a governor's verdict. Only an explicit `ALLOW` with no `DENY` +/// anywhere approves; an empty reply, a reply that never says either, or one +/// that says both is a denial. The previous rule was `!contains("DENY")`, +/// under which a model that answered nothing at all — a truncated stream, a +/// refusal, a reply in the wrong shape — approved the action. +pub fn governor_allows(reply: &str) -> bool { + let upper = reply.to_ascii_uppercase(); + upper.contains("ALLOW") && !upper.contains("DENY") +} + pub fn judge_model() -> String { std::env::var("CLAWMATES_JUDGE_MODEL").unwrap_or_else(|_| "claude-opus-5".to_string()) } @@ -298,13 +308,18 @@ impl Runtime { } /// Governor agent: ask the **judge model** to judge an action. Returns - /// `(allow, reason)`. The verdict is the first token (`ALLOW`/`DENY`) of the - /// model's reply. Best-effort and **fail-open** — if the model is - /// unreachable it returns `(true, …)` so a governor outage doesn't halt - /// autonomous agents (the governor is an extra soft check atop deterministic - /// policy, not the only gate). Judges use the strongest model - /// ([`judge_model`], default `claude-opus-4-8`); everything else runs on the - /// configured default model. + /// `(allow, reason)`, parsed by [`governor_allows`]: only an explicit + /// `ALLOW` with no `DENY` approves. + /// + /// **Fail-closed** since 2026-09-20. This returned `(true, …)` when the + /// model was unreachable so that a governor outage would not halt + /// autonomous agents — which meant that on the day the judge plan emptied + /// (2026-08-29, 2026-09-09) every outbound action was approved unjudged, + /// with nothing but a log line saying so. Open Agent Passport (arXiv + /// 2603.20953) measured the difference a restrictive default makes: + /// social-engineered actions succeeded 74.6% of the time under a + /// permissive policy and 0 of 879 under a restrictive one. A door that + /// cannot reach its governor now says so and denies. pub async fn judge(&self, system: &str, user: &str) -> (bool, String) { let (provider, model) = self.resolve_provider(&judge_model()); let request = ChatRequest { @@ -327,10 +342,10 @@ impl Runtime { } } } - Err(e) => return (true, format!("governor unreachable (fail-open): {e}")), + Err(e) => return (false, format!("governor unreachable (fail-closed): {e}")), } - let allow = !text.to_uppercase().contains("DENY"); - (allow, text.trim().to_string()) + let text = text.trim().to_string(); + (governor_allows(&text), text) } /// One-shot completion: send `system`+`user` to `model`, collect the full @@ -1077,3 +1092,21 @@ fn chat_messages(history: &[MessageWithSteps], user_text: &str) -> Vec