The live canary run (01a0cf3d) proved the push refusal — push refused, patch redacted, delivery.secret_blocked, no branch on the forge — and found the next leak: the judge QUOTED the canary verbatim in its verdict, which is stored, shown in the UI and written into the repo's project memory for later missions. - mission_events::record (the one insert path) scrubs every event's detail and target: tool output such as a printenv, prompts, verdict events - the verdict's reason, guidance, check outputs and plan are scrubbed before being stored or remembered - the evidence sent to the judge, and each check's output before the judge model reads it, are scrubbed — the judge is another company's model Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
211 lines
8.4 KiB
Rust
211 lines
8.4 KiB
Rust
//! 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<String> {
|
|
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<String> {
|
|
let mut found: Vec<String> = 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:<NAME>]`.
|
|
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<Vec<(String, String)>> = 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=<your key here>\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);
|
|
}
|
|
}
|