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);
}
}
+1
View File
@@ -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;
+36 -1
View File
@@ -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