diff --git a/crates/cm-api/src/delivery_secrets.rs b/crates/cm-api/src/delivery_secrets.rs new file mode 100644 index 0000000..ea4b090 --- /dev/null +++ b/crates/cm-api/src/delivery_secrets.rs @@ -0,0 +1,164 @@ +//! Keep the credentials a mission can see out of what a mission delivers. +//! +//! Container-tier missions carry model-provider keys in their environment — +//! Claude Code needs its own credential, and the fallback chain needs the GLM +//! and Kimi keys (`mission_runtime::forwarded_provider_keys`). The agent runs +//! Bash, so it can read them, and a prompt-injected page can ask it to. The +//! cheapest place to stop the worst consequence is the one exit every mission's +//! work passes through: delivery. Measured 2026-09-23 on prod: all three keys +//! present in every mission container, and no gate rule mentions them. +//! +//! Exact, not heuristic. The server holds the real values, so this looks for +//! THOSE strings (and their base64), not for things shaped like keys — no +//! false positives on a README that explains what an API key looks like, and +//! no false negatives on a key format nobody wrote a regex for. +//! +//! What this does not cover, stated so nobody assumes it does: a key sent +//! straight to a host over the network (see the `untrusted-target` shadow rule +//! and docs/TASK-PERMISSION-AND-TAINT.md), and a key transformed by anything +//! but base64. The fix for both is keeping the keys out of the container. + +use base64::Engine; + +/// Every server-side secret a delivery must never carry. The provider keys a +/// mission container receives, plus server-only keys that would be as bad to +/// publish. Tested to be a superset of what the container is actually given. +pub const WATCHED: &[&str] = &[ + "CLAUDE_CODE_OAUTH_TOKEN", + "ANTHROPIC_API_KEY", + "ZAI_API_KEY", + "KIMI_API_KEY", + "GROQ_API_KEY", + "OPENAI_API_KEY", + "ELEVENLABS_API_KEY", + "TYPESAFE_API_KEY", + // Not a credential: a random value set only on the server, watched exactly + // like one, so the refusal can be proven end to end on a live mission + // without ever putting a real key in an agent's output. + "CLAWMATES_DELIVERY_CANARY", +]; + +/// Shorter than this is not a credential, and matching it would find it in +/// ordinary text. +const MIN_LEN: usize = 16; + +/// The watched secrets that are set here, as `(name, value)`. +pub fn from_env() -> Vec<(String, String)> { + WATCHED + .iter() + .filter_map(|n| { + let v = std::env::var(n).ok()?; + let v = v.trim().to_string(); + (v.len() >= MIN_LEN).then(|| (n.to_string(), v)) + }) + .collect() +} + +/// The spellings of one secret to look for: verbatim, and base64 with and +/// without padding (the one encoding an agent reaches for to "hide" a string). +fn spellings(value: &str) -> Vec { + let b64 = base64::engine::general_purpose::STANDARD.encode(value.as_bytes()); + let trimmed = b64.trim_end_matches('=').to_string(); + let mut v = vec![value.to_string(), b64]; + if !v.contains(&trimmed) { + v.push(trimmed); + } + v +} + +/// Names of the secrets present in `text`, sorted and deduplicated. +pub fn leaks_in(text: &str, secrets: &[(String, String)]) -> Vec { + let mut found: Vec = secrets + .iter() + .filter(|(_, value)| spellings(value).iter().any(|s| text.contains(s.as_str()))) + .map(|(name, _)| name.clone()) + .collect(); + found.sort(); + found.dedup(); + found +} + +/// `text` with every spelling of every secret replaced by `[REDACTED:]`. +pub fn redact(text: &str, secrets: &[(String, String)]) -> String { + let mut out = text.to_string(); + for (name, value) in secrets { + // Longest first, so the unpadded base64 cannot eat part of the padded. + let mut s = spellings(value); + s.sort_by_key(|x| std::cmp::Reverse(x.len())); + for spelling in s { + out = out.replace(&spelling, &format!("[REDACTED:{name}]")); + } + } + out +} + +/// The refusal recorded in place of a push. +pub fn refusal(names: &[String]) -> String { + format!( + "REFUSED to push: the phase's changes contain {} (a server credential the mission \ + container can read). The work is committed on the local branch only and the stored \ + patch is redacted. Rotate the key(s) if this was not a test.", + names.join(", ") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn secrets() -> Vec<(String, String)> { + vec![ + ("ZAI_API_KEY".into(), "a1b2c3d4e5f6a7b8c9d0.ZyXwVuTsRqPo".into()), + ("KIMI_API_KEY".into(), "sk-kimi-0123456789abcdefghij".into()), + ] + } + + #[test] + fn a_verbatim_key_is_found_and_named() { + let patch = "+export ZAI=a1b2c3d4e5f6a7b8c9d0.ZyXwVuTsRqPo\n"; + assert_eq!(leaks_in(patch, &secrets()), vec!["ZAI_API_KEY".to_string()]); + } + + /// The one transformation an agent reaches for to get a string past a check. + #[test] + fn a_base64_key_is_found_with_or_without_padding() { + let b64 = base64::engine::general_purpose::STANDARD.encode("sk-kimi-0123456789abcdefghij"); + assert_eq!(leaks_in(&format!("+{b64}\n"), &secrets()), vec!["KIMI_API_KEY".to_string()]); + let unpadded = b64.trim_end_matches('='); + assert_eq!(leaks_in(&format!("+{unpadded}\n"), &secrets()), vec!["KIMI_API_KEY".to_string()]); + } + + /// Exact values, not shapes: text ABOUT keys is not a leak. + #[test] + fn text_that_merely_looks_like_a_key_is_not_a_leak() { + let patch = "+ZAI_API_KEY=\n+sk-kimi-XXXXXXXXXXXXXXXXXXXX\n"; + assert!(leaks_in(patch, &secrets()).is_empty()); + } + + #[test] + fn redaction_removes_every_spelling_and_names_the_key() { + let b64 = base64::engine::general_purpose::STANDARD.encode("sk-kimi-0123456789abcdefghij"); + let patch = format!("+a1b2c3d4e5f6a7b8c9d0.ZyXwVuTsRqPo\n+{b64}\n"); + let r = redact(&patch, &secrets()); + assert!(leaks_in(&r, &secrets()).is_empty(), "{r}"); + assert!(r.contains("[REDACTED:ZAI_API_KEY]") && r.contains("[REDACTED:KIMI_API_KEY]"), "{r}"); + } + + /// Whatever a mission container is GIVEN must be watched here, in both auth + /// modes — or a key added to the forwarding list later leaks unwatched. + #[test] + fn every_forwarded_key_is_watched() { + use crate::mission_runtime::{forwarded_provider_keys, RuntimeAuth}; + for auth in [RuntimeAuth::ApiKey, RuntimeAuth::Subscription] { + for k in forwarded_provider_keys(auth) { + assert!(WATCHED.contains(&k), "{k} is forwarded into mission containers but not watched"); + } + } + } + + #[test] + fn short_values_are_never_watched() { + // A too-short value would match ordinary text; from_env drops it. + assert!(MIN_LEN >= 16); + } +} diff --git a/crates/cm-api/src/lib.rs b/crates/cm-api/src/lib.rs index fa8fe7f..70336d5 100644 --- a/crates/cm-api/src/lib.rs +++ b/crates/cm-api/src/lib.rs @@ -26,6 +26,7 @@ pub mod microvm_client; pub mod microvm_executor; pub mod microvm_turn_executor; pub mod continuous_research; +pub mod delivery_secrets; pub mod mission_delivery; pub mod podcast; pub mod mission_events; diff --git a/crates/cm-api/src/mission_delivery.rs b/crates/cm-api/src/mission_delivery.rs index 435a813..1d949ff 100644 --- a/crates/cm-api/src/mission_delivery.rs +++ b/crates/cm-api/src/mission_delivery.rs @@ -315,6 +315,20 @@ pub async fn capture_phase_diff_at( // `--name-status` line means (renames are three fields; the NEW path is the // one that changed). let all_paths = crate::auto_merge::changed_paths(&name_status); + // Server credentials in the outgoing work: named here, redacted from the + // stored patch (it is served to the UI), and the push refused below. + let secrets = crate::delivery_secrets::from_env(); + let mut leaked = crate::delivery_secrets::leaks_in(&patch, &secrets); + let patch = if leaked.is_empty() { + patch + } else { + eprintln!( + "mission_delivery: mission {mission_id} phase {phase_id} — the diff contains {} \ + — redacting the stored patch and refusing to push", + leaked.join(", ") + ); + crate::delivery_secrets::redact(&patch, &secrets) + }; let files_truncated = all_paths.len() > MAX_CAPTURED_PATHS; let files: Vec<(char, String)> = all_paths.into_iter().take(MAX_CAPTURED_PATHS).collect(); let empty = patch.trim().is_empty(); @@ -423,7 +437,26 @@ pub async fn capture_phase_diff_at( } outcome = Some(o); } - match push_url_for(pool, mission_id).await { + // Commit messages leave with the branch too. + if let Ok(log) = git(&repo, &["log", "--format=%B", &format!("{base_sha}..HEAD")]).await { + leaked.extend(crate::delivery_secrets::leaks_in(&log, &secrets)); + leaked.sort(); + leaked.dedup(); + } + let url_or_refusal = if leaked.is_empty() { + push_url_for(pool, mission_id).await + } else { + crate::mission_events::record( + pool, + crate::mission_events::MissionEvent::new(mission_id, "delivery.secret_blocked") + .phase(phase_id) + .detail(serde_json::json!({ "keys": leaked, "branch": c.branch })), + ) + .await; + publish_error = Some(crate::delivery_secrets::refusal(&leaked)); + Ok(None) + }; + match url_or_refusal { Ok(Some(url)) => { let verified = outcome.as_ref().and_then(TestOutcome::verified); match publish_phase_branch(&repo, &url, &c.branch, gate, verified).await { @@ -456,6 +489,8 @@ pub async fn capture_phase_diff_at( } } } + // Refused above for a leaked credential; that reason stands. + Ok(_) if !leaked.is_empty() => {} Ok(None) => { // Legitimate: a mission with no repo bound has nowhere to // push. Still recorded, because "not pushed" with no reason