//! 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 watched secrets, read once per process. Keys do not change under a /// running server; reading the environment on every recorded event would. pub fn cached() -> &'static [(String, String)] { static S: std::sync::OnceLock> = std::sync::OnceLock::new(); S.get_or_init(from_env) } /// `text` with the process's watched secrets redacted, or unchanged (and /// unallocated) when none appear. pub fn scrub(text: &str) -> std::borrow::Cow<'_, str> { let secrets = cached(); if leaks_in(text, secrets).is_empty() { std::borrow::Cow::Borrowed(text) } else { std::borrow::Cow::Owned(redact(text, secrets)) } } /// A JSON value with the watched secrets redacted from every string in it. /// /// Through the serialized form: a secret has no quote or backslash in it, so /// replacing it with `[REDACTED:NAME]` leaves the JSON valid. If it somehow did /// not re-parse, the redacted TEXT is kept as a string rather than the /// original value — failing toward hiding the secret. pub fn scrub_json(v: serde_json::Value) -> serde_json::Value { let raw = v.to_string(); match scrub(&raw) { std::borrow::Cow::Borrowed(_) => v, std::borrow::Cow::Owned(clean) => { serde_json::from_str(&clean).unwrap_or(serde_json::Value::String(clean)) } } } /// 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"); } } } /// JSON stays JSON after redaction, including a secret inside a nested /// tool response — the shape a `printenv` lands in. #[test] fn json_redaction_keeps_the_document_valid() { let v = serde_json::json!({"response":{"stdout":"ZAI=a1b2c3d4e5f6a7b8c9d0.ZyXwVuTsRqPo\n"},"n":3}); let raw = v.to_string(); let clean = redact(&raw, &secrets()); let back: serde_json::Value = serde_json::from_str(&clean).expect("still JSON"); assert_eq!(back["n"], 3); assert!(back["response"]["stdout"].as_str().unwrap().contains("[REDACTED:ZAI_API_KEY]")); } #[test] fn short_values_are_never_watched() { // A too-short value would match ordinary text; from_env drops it. assert!(MIN_LEN >= 16); } }