feat(secrets): redact server credentials from everything a mission records, and from the judge
deploy / test (push) Successful in 5m38s
deploy / build (push) Successful in 6m19s

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]>
This commit is contained in:
Omar Sobh
2026-09-23 12:12:25 -05:00
co-authored by Claude Opus 5.5
parent a86bd7272d
commit 4602e9b896
4 changed files with 78 additions and 4 deletions
+46
View File
@@ -92,6 +92,40 @@ pub fn redact(text: &str, secrets: &[(String, String)]) -> String {
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!(
@@ -156,6 +190,18 @@ mod tests {
}
}
/// 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.
+4 -1
View File
@@ -366,7 +366,10 @@ impl Sandbox {
ran: true,
refused: false,
exit_code: out.exit_code,
evidence: clamp_output(&body),
// Scrubbed BEFORE the judge sees it: the judge is another
// company's model, and `cat` on an agent's file would
// otherwise send a leaked credential to it.
evidence: crate::delivery_secrets::scrub(&clamp_output(&body)).into_owned(),
}
}
}
+9 -2
View File
@@ -114,11 +114,18 @@ impl MissionEvent {
/// writers there are. `INSERT … SELECT … WHERE (subquery) < cap` makes the
/// decision inside the statement.
pub async fn record(pool: &PgPool, e: MissionEvent) {
// Never store a server credential. Every event a mission produces passes
// here — tool output (a `printenv`), judge verdicts, prompts — and all of
// it is served to the UI. See `delivery_secrets`.
let detail = if e.detail.is_null() {
Value::Object(Default::default())
} else {
e.detail
crate::delivery_secrets::scrub_json(e.detail)
};
let target = e
.target
.as_deref()
.map(|t| crate::delivery_secrets::scrub(t).into_owned());
// The cap is still decided INSIDE the insert (see the test below), and now
// only counts the kinds it is meant to bound.
let capped = is_capped(&e.kind);
@@ -136,7 +143,7 @@ pub async fn record(pool: &PgPool, e: MissionEvent) {
.bind(e.run_id)
.bind(e.agent_id)
.bind(&e.kind)
.bind(&e.target)
.bind(&target)
.bind(&detail)
.bind(PER_PHASE_CAP)
.bind(capped)
+19 -1
View File
@@ -2733,7 +2733,25 @@ async fn evaluate_finished_phases(
.await
.unwrap_or_else(|e| format!("(evidence collection failed: {e})"));
let verdict = crate::evaluator::evaluate(runtime, mission_id, &condition, &evidence).await;
// The evidence goes to an external judge; never with a credential in it.
let evidence = crate::delivery_secrets::scrub(&evidence).into_owned();
let mut verdict =
crate::evaluator::evaluate(runtime, mission_id, &condition, &evidence).await;
// The judge reads the work and QUOTES it: a live canary run recorded the
// canary verbatim in the verdict's reason. Redact before the verdict is
// stored, turned into events, or written into the repo's project memory
// (which later missions receive).
{
use crate::delivery_secrets::scrub;
verdict.reason = scrub(&verdict.reason).into_owned();
verdict.guidance = scrub(&verdict.guidance).into_owned();
for c in verdict.checks.iter_mut() {
c.evidence = scrub(&c.evidence).into_owned();
}
if let Some(x) = verdict.expectation.as_mut() {
*x = scrub(x).into_owned();
}
}
if let Err(e) =
crate::evaluator::record(pool, mission_id, phase_id, iteration, &verdict).await
{