feat(secrets): redact server credentials from everything a mission records, and from the judge
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:
co-authored by
Claude Opus 5.5
parent
a86bd7272d
commit
4602e9b896
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user