Skip to main content

runtime/
redact.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! What a model said, on its way into history.
7//!
8//! Everything a provider sends back becomes a ledger payload verbatim,
9//! and a model that read a key can repeat one. The ledger is permanent
10//! and exportable, so it is the sink that matters: a secret there
11//! outlives the session, the machine and the person's memory of it.
12//!
13//! Two decisions carry this module.
14//!
15//! **The window keeps the bytes; the ledger does not.** The blocks the
16//! next request is built from are extracted before this runs, so
17//! redacting here cannot break a thinking block's signature or make the
18//! conversation stop making sense. History and context are two sinks
19//! with two different requirements, and only one of them is forever.
20//!
21//! **The replacement is a marker, not a vault entry.** A key the model
22//! echoed is not a credential the city was asked to keep; storing it
23//! would give it a life nobody asked for, and inventing a realm for it
24//! would put a made-up name in the vault's namespace. The marker carries
25//! the first sixteen hex of the content hash instead, which is enough to
26//! see that two occurrences are the same value and never enough to be
27//! one.
28//!
29//! **Every sink in this crate marks the same way.** The ledger and the
30//! diagnostic log differ in one decision only — whether the marker
31//! carries a fingerprint — and that decision is the [`Marker`] argument
32//! below rather than a second scan-and-replace written next to the
33//! second sink.
34
35use kernel::B3Hash;
36use serde_json::{Map, Value};
37
38/// The realm every redaction marker sits in. Not a vault realm: nothing
39/// is stored under it, and `secret:` references resolve through the
40/// vault, so a reader who tries will be told there is nothing there.
41const REDACTED_REALM: &str = "redacted";
42
43/// What stands where a secret stood, and how much it says about it.
44///
45/// The two sinks differ here and nowhere else. History is kept,
46/// searched and compared, so a marker there tells one value from
47/// another; a diagnostic line is written once and read once, and a
48/// fingerprint in it would be a correlation handle in a stream that
49/// nothing correlates.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Marker {
52    /// `secret:redacted` — something was here, and that is all.
53    Plain,
54    /// `secret:redacted/<sixteen hex>` — something was here, and two
55    /// occurrences of one value read alike.
56    Fingerprinted,
57}
58
59impl Marker {
60    /// The text this marker puts in the place of `found`.
61    #[must_use]
62    pub fn spell(self, found: &[u8]) -> String {
63        match self {
64            Marker::Plain => format!("secret:{REDACTED_REALM}"),
65            Marker::Fingerprinted => {
66                format!("secret:{REDACTED_REALM}/{}", fingerprint(found))
67            }
68        }
69    }
70}
71
72/// Replaces every secret-shaped span in every string of `payload`.
73///
74/// Returns the payload and how many spans were replaced, because a count
75/// is what a diagnostic line can say without saying what it found.
76///
77/// The payload is rewritten where it lies (runtime D37): a payload with
78/// no hit comes back as the same allocation, byte for byte, and a hit
79/// replaces the string it hit and moves nothing else.
80#[must_use]
81pub fn redact(mut payload: Map<String, Value>) -> (Map<String, Value>, u32) {
82    let mut hits = 0;
83    for value in payload.values_mut() {
84        walk(value, &mut hits);
85    }
86    (payload, hits)
87}
88
89fn walk(value: &mut Value, hits: &mut u32) {
90    match value {
91        Value::String(text) => {
92            if let Some((replaced, found)) = replaced(text, Marker::Fingerprinted) {
93                *hits = hits.saturating_add(found);
94                *text = replaced;
95            }
96        }
97        Value::Array(items) => items.iter_mut().for_each(|item| walk(item, hits)),
98        Value::Object(map) => map.values_mut().for_each(|item| walk(item, hits)),
99        Value::Null | Value::Bool(_) | Value::Number(_) => {}
100    }
101}
102
103/// The copying walk the owned one replaced, kept as the reference the
104/// equivalence proptest holds it to.
105#[cfg(test)]
106fn copied(payload: &Map<String, Value>) -> (Map<String, Value>, u32) {
107    fn walk(value: &Value, hits: &mut u32) -> Value {
108        match value {
109            Value::String(text) => {
110                let (replaced, found) = redact_text(text, Marker::Fingerprinted);
111                *hits = hits.saturating_add(found);
112                Value::String(replaced)
113            }
114            Value::Array(items) => {
115                Value::Array(items.iter().map(|item| walk(item, hits)).collect())
116            }
117            Value::Object(map) => Value::Object(
118                map.iter()
119                    .map(|(key, item)| (key.clone(), walk(item, hits)))
120                    .collect(),
121            ),
122            Value::Null | Value::Bool(_) | Value::Number(_) => value.clone(),
123        }
124    }
125    let mut hits = 0;
126    let out = payload
127        .iter()
128        .map(|(key, value)| (key.clone(), walk(value, &mut hits)))
129        .collect();
130    (out, hits)
131}
132
133/// One string, with every secret shape in it replaced in place.
134///
135/// The spans arrive sorted and non-overlapping from the detector, so a
136/// single left-to-right pass is enough and no offset needs adjusting
137/// after a replacement.
138#[must_use]
139pub fn redact_text(text: &str, marker: Marker) -> (String, u32) {
140    replaced(text, marker).unwrap_or_else(|| (text.to_owned(), 0))
141}
142
143/// The text with its secret shapes replaced and how many there were, or
144/// nothing when no span was replaced, so a caller that owns the text
145/// keeps it instead of taking a copy.
146fn replaced(text: &str, marker: Marker) -> Option<(String, u32)> {
147    let bytes = text.as_bytes();
148    let spans = kernel::secret::scan(bytes);
149    if spans.is_empty() {
150        return None;
151    }
152    let mut out = String::with_capacity(text.len());
153    let mut at = 0usize;
154    let mut hits: u32 = 0;
155    for span in spans {
156        let end = span.start.saturating_add(span.len);
157        if span.start < at || end > bytes.len() {
158            continue;
159        }
160        let Some(before) = bytes
161            .get(at..span.start)
162            .and_then(|b| std::str::from_utf8(b).ok())
163        else {
164            continue;
165        };
166        let Some(found) = bytes.get(span.start..end) else {
167            continue;
168        };
169        out.push_str(before);
170        out.push_str(&marker.spell(found));
171        at = end;
172        hits = hits.saturating_add(1);
173    }
174    // A span that ended inside a character would leave the tail
175    // unreadable; the honest answer is to keep nothing of it rather than
176    // emit half a character.
177    if let Some(rest) = bytes.get(at..).and_then(|b| std::str::from_utf8(b).ok()) {
178        out.push_str(rest);
179    }
180    (hits > 0).then_some((out, hits))
181}
182
183/// How many hex characters of a value's hash stand for the value.
184/// Sixteen is enough to tell two values apart in a history and far too
185/// few to reconstruct one.
186const FINGERPRINT_HEX: usize = 16;
187
188/// The one short name this city gives a secret it must refer to without
189/// holding: derived from the value, so one value reads alike in every
190/// session and two values never read alike in one.
191///
192/// A name counted out instead — `cap-1`, `cap-2` — would give the same
193/// value a different name in every session and two different values the
194/// same name across two, which is how a stored credential comes back as
195/// somebody else's.
196#[must_use]
197pub fn fingerprint(found: &[u8]) -> String {
198    let digest = B3Hash::digest(found).to_string();
199    digest.get(..FINGERPRINT_HEX).unwrap_or(&digest).to_owned()
200}
201
202#[cfg(test)]
203#[allow(
204    clippy::unwrap_used,
205    clippy::expect_used,
206    clippy::panic,
207    clippy::indexing_slicing,
208    reason = "test code"
209)]
210mod tests {
211    use super::*;
212
213    /// Assembled at runtime: a literal key-shaped sample in the source
214    /// would be a finding about this file.
215    fn key_shaped() -> String {
216        ["sk", "9fQ2xZ", "7Lm4Rt", "0Bv8Kd", "3Wp6"].join("")
217    }
218
219    #[test]
220    fn a_key_shaped_run_of_bytes_does_not_reach_the_payload() {
221        let key = key_shaped();
222        let mut payload = Map::new();
223        payload.insert(
224            "message".to_owned(),
225            Value::String(format!("the token is {key} and it works")),
226        );
227        let (redacted, hits) = redact(payload);
228        assert_eq!(hits, 1);
229        let text = redacted
230            .get("message")
231            .and_then(Value::as_str)
232            .unwrap()
233            .to_owned();
234        assert!(!text.contains(&key), "the value itself is gone");
235        assert!(text.contains("the token is "), "the sentence survives");
236        assert!(text.contains("secret:redacted/"));
237    }
238
239    #[test]
240    fn the_same_value_twice_marks_the_same_way_and_two_values_do_not() {
241        let key = key_shaped();
242        let other = ["sk", "2Tg7Yh", "4Nn1Qs", "8Cz5Ud", "6Ke0"].join("");
243        let (first, _) = redact_text(&key, Marker::Fingerprinted);
244        let (again, _) = redact_text(&key, Marker::Fingerprinted);
245        let (different, _) = redact_text(&other, Marker::Fingerprinted);
246        assert_eq!(first, again, "one value, one marker");
247        assert_ne!(first, different, "two values are distinguishable");
248    }
249
250    #[test]
251    fn nested_strings_are_reached_and_ordinary_prose_is_untouched() {
252        let key = key_shaped();
253        let mut inner = Map::new();
254        inner.insert("text".to_owned(), Value::String(key.clone()));
255        let mut payload = Map::new();
256        payload.insert(
257            "content".to_owned(),
258            Value::Array(vec![Value::Object(inner)]),
259        );
260        payload.insert(
261            "prose".to_owned(),
262            Value::String("nothing here looks like a key at all".to_owned()),
263        );
264        payload.insert("count".to_owned(), Value::Number(3.into()));
265        let (redacted, hits) = redact(payload);
266        assert_eq!(hits, 1);
267        assert_eq!(
268            redacted.get("prose").and_then(Value::as_str),
269            Some("nothing here looks like a key at all"),
270            "prose is not a shape, and a scanner that trimmed it would be unusable"
271        );
272        assert_eq!(redacted.get("count").and_then(Value::as_u64), Some(3));
273        let text = serde_json::to_string(&redacted).unwrap();
274        assert!(!text.contains(&key));
275    }
276
277    /// One value, one short name, wherever the city has to refer to it,
278    /// and a name a reader can act on: the marker parses as a reference,
279    /// so somebody who meets one in history knows both that a value was
280    /// there and that the way to a value is the vault, which will tell
281    /// them the `redacted` realm holds nothing.
282    #[test]
283    fn a_marker_is_a_reference_a_reader_can_follow_to_an_honest_nothing() {
284        let key = key_shaped();
285        let short = fingerprint(key.as_bytes());
286        assert_eq!(short.len(), 16);
287        let (marked, hits) = redact_text(&key, Marker::Fingerprinted);
288        assert_eq!(hits, 1);
289        assert_eq!(marked, format!("secret:redacted/{short}"));
290        let reference = kernel::SecretRef::parse(&marked).unwrap();
291        assert_eq!(reference.realm(), REDACTED_REALM);
292        assert_eq!(reference.name(), short);
293    }
294
295    /// The two sinks mark by one rule and differ by one argument: a log
296    /// line says a secret was there, history says which one.
297    #[test]
298    fn a_plain_marker_says_that_something_was_here_and_nothing_more() {
299        let key = key_shaped();
300        let (line, hits) = redact_text(&format!("token {key} used"), Marker::Plain);
301        assert_eq!(hits, 1);
302        assert_eq!(line, "token secret:redacted used");
303        assert!(!line.contains(&fingerprint(key.as_bytes())));
304        let (kept, _) = redact_text(&format!("token {key} used"), Marker::Fingerprinted);
305        assert!(kept.contains(&fingerprint(key.as_bytes())));
306    }
307
308    #[test]
309    fn a_payload_with_nothing_to_hide_comes_back_unchanged() {
310        let mut payload = Map::new();
311        payload.insert(
312            "message".to_owned(),
313            Value::String("measured in metres".to_owned()),
314        );
315        let (redacted, hits) = redact(payload.clone());
316        assert_eq!(hits, 0);
317        assert_eq!(redacted, payload);
318    }
319
320    fn text_at<'v>(payload: &'v Map<String, Value>, key: &str) -> &'v str {
321        payload.get(key).and_then(Value::as_str).unwrap()
322    }
323
324    /// D37: a clean payload costs the scan and nothing else, and a hit
325    /// costs the string it hit and nothing else. The address of a
326    /// string's bytes is the observation: a copy would move them.
327    #[test]
328    fn a_clean_string_comes_back_in_the_allocation_it_went_in_with() {
329        let key = key_shaped();
330        let mut payload = Map::new();
331        payload.insert(
332            "prose".to_owned(),
333            Value::String("measured in metres".to_owned()),
334        );
335        payload.insert("token".to_owned(), Value::String(format!("use {key}")));
336        let before = text_at(&payload, "prose").as_ptr();
337        let (redacted, hits) = redact(payload);
338        assert_eq!(hits, 1);
339        assert_eq!(
340            text_at(&redacted, "prose").as_ptr(),
341            before,
342            "the clean string was copied"
343        );
344        assert!(!text_at(&redacted, "token").contains(&key));
345    }
346
347    /// Strings of prose with a key-shaped value spliced in at random,
348    /// under objects and arrays nested to a few levels.
349    fn payloads() -> impl proptest::strategy::Strategy<Value = Map<String, Value>> {
350        use proptest::prelude::*;
351        let text =
352            ("[a-z =:]{0,12}", any::<bool>(), "[a-z ]{0,6}").prop_map(|(head, keyed, tail)| {
353                let middle = if keyed { key_shaped() } else { String::new() };
354                Value::String(format!("{head}{middle}{tail}"))
355            });
356        let leaf = prop_oneof![
357            Just(Value::Null),
358            any::<bool>().prop_map(Value::Bool),
359            any::<i64>().prop_map(|n| Value::Number(n.into())),
360            text,
361        ];
362        let tree = leaf.prop_recursive(3, 24, 4, |inner| {
363            prop_oneof![
364                proptest::collection::vec(inner.clone(), 0..4).prop_map(Value::Array),
365                proptest::collection::btree_map("[a-c]{1,2}", inner, 0..4)
366                    .prop_map(|map| Value::Object(map.into_iter().collect())),
367            ]
368        });
369        proptest::collection::btree_map("[a-d]{1,2}", tree, 0..5)
370            .prop_map(|map| map.into_iter().collect())
371    }
372
373    proptest::proptest! {
374        /// The owned walk replaces exactly what the copying walk it
375        /// replaced did, and counts the same.
376        #[test]
377        fn the_owned_walk_agrees_with_the_copying_one(payload in payloads()) {
378            proptest::prop_assert_eq!(redact(payload.clone()), copied(&payload));
379        }
380    }
381}
382
383/// The phase instruments of the tools whose results pass this module:
384/// each times one call through the tool's own face (path resolution, IO
385/// and the envelope as one phase), then the secret scan of its result,
386/// and prints one line per tool in microseconds (`crates/runtime/Spec.lean`
387/// §10, the per-tool phases). They live here because the scan is the
388/// phase every tool shares.
389#[cfg(test)]
390#[allow(
391    clippy::unwrap_used,
392    clippy::disallowed_methods,
393    reason = "test code: an instrument reads the wall clock"
394)]
395mod phases {
396    use std::path::Path;
397    use std::sync::{Arc, Mutex};
398    use std::time::{Duration, Instant};
399
400    use kernel::{Address, Payload, ReadVerdict, Tool, ToolCall, ToolName, ToolOutcome};
401    use serde_json::{Map, Value, json};
402
403    use crate::backlog::{Backlog, PollBudget};
404    use crate::catalog::Catalog;
405    use crate::sandbox::{EchoSandbox, Fuel};
406    use crate::tools::{
407        EditTool, ExecSetup, ExecTool, ReadBound, ReadTool, SearchTool, version_of,
408    };
409
410    #[test]
411    #[ignore = "an instrument: run it by name with --run-ignored only --no-capture"]
412    fn instrument_read_phases() {
413        let city = tempfile::tempdir().unwrap();
414        std::fs::write(city.path().join("history.txt"), history_text()).unwrap();
415        let catalog = Arc::new(Mutex::new(Catalog::new()));
416        let tool =
417            ReadTool::new(city.path(), catalog, everywhere(), Path::new("no-store")).unwrap();
418        let read = call("read", json!({ "path": "history.txt" }));
419        report("read", (0..32).map(|_| timed(&tool, &read)));
420    }
421
422    /// `write` is `edit` with a `base_version` of `new`: it creates the
423    /// file, and its echo is the whole file as a diff.
424    #[test]
425    #[ignore = "an instrument: run it by name with --run-ignored only --no-capture"]
426    fn instrument_write_and_edit_phases() {
427        let city = tempfile::tempdir().unwrap();
428        let work = Address::parse("work").unwrap();
429        let domain = kernel::WriteDomain::new(vec![work.clone()]).unwrap();
430        std::fs::create_dir_all(city.path().join("work")).unwrap();
431        let tool = EditTool::new(
432            city.path(),
433            work,
434            domain,
435            crate::PolicyCell::new(kernel::RunPolicy::of(kernel::Mode::Work)).reader(),
436        )
437        .unwrap();
438        let text = history_text();
439        let edit = |path: &str, base: &str, old: &str, new: &str| {
440            call(
441                "edit",
442                json!({ "path": path, "base_version": base, "old": old, "new": new }),
443            )
444        };
445        let writes: Vec<_> = (0..32)
446            .map(|at| timed(&tool, &edit(&format!("work/h{at}.txt"), "new", "", &text)))
447            .collect();
448        report("write", writes);
449        let edited = format!("{text}tail\n");
450        let edits: Vec<_> = (0..32)
451            .map(|at| {
452                let path = format!("work/h{at}.txt");
453                std::fs::write(city.path().join(&path), &edited).unwrap();
454                timed(
455                    &tool,
456                    &edit(&path, &version_of(edited.as_bytes()), "tail", "TAIL"),
457                )
458            })
459            .collect();
460        report("edit", edits);
461    }
462
463    /// The host's own shell prints the file (`cmd` on Windows, `sh` on
464    /// macOS and Linux) under `EchoSandbox`, so the reading is the tool's
465    /// cost and not a sandbox arm's.
466    #[test]
467    #[ignore = "an instrument: run it by name with --run-ignored only --no-capture"]
468    fn instrument_exec_phases() {
469        let chamber = tempfile::tempdir().unwrap();
470        std::fs::write(chamber.path().join("history.txt"), history_text()).unwrap();
471        let setup = ExecSetup {
472            workdir: chamber.path().to_path_buf(),
473            mounts: Vec::new(),
474            python_wasm: None,
475            shell: crate::Shell::Absent,
476            fuel: Fuel(1_000_000),
477            env_passthrough: Vec::new(),
478            domain: Address::parse("work").unwrap(),
479            run: kernel::RunId::from_bytes([1; 16]),
480            policy: crate::PolicyCell::new(kernel::RunPolicy::of(kernel::Mode::Work)).reader(),
481        };
482        let patient = Backlog::with_window(PollBudget::new(6_000, 20));
483        let tool = ExecTool::new(setup, Box::new(EchoSandbox::new()), patient).unwrap();
484        let (path, args) = if cfg!(windows) {
485            ("cmd", ["/C", "type history.txt"])
486        } else {
487            ("sh", ["-c", "cat history.txt"])
488        };
489        let exec = call(
490            "exec",
491            json!({ "arm": { "program": { "path": path, "args": args } } }),
492        );
493        report("exec", (0..8).map(|_| timed(&tool, &exec)));
494    }
495
496    #[test]
497    #[ignore = "an instrument: run it by name with --run-ignored only --no-capture"]
498    fn instrument_search_phases() {
499        let city = tempfile::tempdir().unwrap();
500        std::fs::create_dir_all(city.path().join("lab")).unwrap();
501        std::fs::write(city.path().join("lab").join("history.txt"), history_text()).unwrap();
502        let tool = SearchTool::new(city.path(), everywhere()).unwrap();
503        let search = call("search", json!({ "text": "HMAC_KEY" }));
504        report("search", (0..32).map(|_| timed(&tool, &search)));
505    }
506
507    fn everywhere() -> ReadBound {
508        Arc::new(|_: &Address| ReadVerdict::Open)
509    }
510
511    fn call(name: &str, args: Value) -> ToolCall {
512        let Value::Object(args) = args else {
513            return call(name, Value::Object(Map::new()));
514        };
515        ToolCall {
516            id: "call-1".to_owned(),
517            name: ToolName::parse(name).unwrap(),
518            args: Payload::new(args).unwrap(),
519        }
520    }
521
522    fn timed(tool: &dyn Tool, call: &ToolCall) -> (Duration, ToolOutcome) {
523        let started = Instant::now();
524        let outcome = tool.invoke(call).unwrap();
525        (started.elapsed(), outcome)
526    }
527
528    /// Text shaped like what a tool hands back from a working city:
529    /// hashes and oids, a labelled key, a mixed token and prose, about
530    /// 64 KiB. Assembled at runtime so this file holds no scannable span.
531    fn history_text() -> String {
532        let hex = "9f3c1a7e5b20d48c6f1ea7b3905d2ce81f64b07a".repeat(2);
533        let token = ["kJ8vQ2xR9m", "W4nZ7pL3sT", "6yB1cD5fG0", "hN8aE2iU4o"].concat();
534        let line = format!(
535            "prev={hex} oid={} HMAC_KEY={} note: see {token} for the run, and nothing else\n",
536            hex.get(..40).unwrap(),
537            hex.get(3..43).unwrap()
538        );
539        line.repeat(400)
540    }
541
542    /// Prints one line per tool: rounds, the spans the scan replaced, the
543    /// summed call time, and the summed time the scan of each result took.
544    fn report(tool: &str, calls: impl IntoIterator<Item = (Duration, ToolOutcome)>) {
545        let (mut rounds, mut found) = (0u32, 0u32);
546        let (mut call, mut scan) = (Duration::ZERO, Duration::ZERO);
547        for (took, outcome) in calls {
548            let started = Instant::now();
549            let (_redacted, hits) = super::redact(outcome.result.into_map());
550            scan = scan.saturating_add(started.elapsed());
551            call = call.saturating_add(took);
552            rounds = rounds.saturating_add(1);
553            found = found.saturating_add(hits);
554        }
555        println!(
556            "instrument_tool_phases tool={tool} rounds={rounds} spans={found} call_us={} secret_scan_us={}",
557            call.as_micros(),
558            scan.as_micros()
559        );
560    }
561}