feat(delivery): refuse to push a phase whose changes contain a server credential
deploy / test (push) Successful in 5m48s
deploy / build (push) Successful in 6m29s

Container-tier missions carry model-provider keys in their environment (Claude
Code needs its credential; the fallback chain needs GLM and Kimi), the agent
has Bash, and no gate rule mentioned them. Delivery is the one exit all work
passes through, so it now checks the outgoing diff and commit messages for the
EXACT values of every watched secret (verbatim or base64): on a hit the push is
refused with the key names recorded, the stored patch — served to the UI — is
redacted, and a delivery.secret_blocked event is written. Values never logged.

Exact matching, not shapes: text about keys is not flagged. A test pins the
watched list to a superset of what containers are given in both auth modes.
CLAWMATES_DELIVERY_CANARY (a random non-credential) lets the refusal be proven
live without a real key.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-09-23 11:55:55 -05:00
co-authored by Claude Opus 5.5
parent 4030347b56
commit a86bd7272d
3 changed files with 201 additions and 1 deletions
+164
View File
@@ -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<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 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");
}
}
}
#[test]
fn short_values_are_never_watched() {
// A too-short value would match ordinary text; from_env drops it.
assert!(MIN_LEN >= 16);
}
}