diff --git a/crates/cm-api/src/delivery_secrets.rs b/crates/cm-api/src/delivery_secrets.rs index ea4b090..708770b 100644 --- a/crates/cm-api/src/delivery_secrets.rs +++ b/crates/cm-api/src/delivery_secrets.rs @@ -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> = 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. diff --git a/crates/cm-api/src/evaluator_tools.rs b/crates/cm-api/src/evaluator_tools.rs index 3ae16b9..cd5fdec 100644 --- a/crates/cm-api/src/evaluator_tools.rs +++ b/crates/cm-api/src/evaluator_tools.rs @@ -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(), } } } diff --git a/crates/cm-api/src/mission_events.rs b/crates/cm-api/src/mission_events.rs index b0c9e4b..8e31730 100644 --- a/crates/cm-api/src/mission_events.rs +++ b/crates/cm-api/src/mission_events.rs @@ -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) diff --git a/crates/cm-api/src/phase_runner.rs b/crates/cm-api/src/phase_runner.rs index e676472..d3426bd 100644 --- a/crates/cm-api/src/phase_runner.rs +++ b/crates/cm-api/src/phase_runner.rs @@ -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 {