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#[must_use]
77pub fn redact(payload: &Map<String, Value>) -> (Map<String, Value>, u32) {
78    let mut hits = 0;
79    let mut out = Map::new();
80    for (key, value) in payload {
81        out.insert(key.clone(), walk(value, &mut hits));
82    }
83    (out, hits)
84}
85
86fn walk(value: &Value, hits: &mut u32) -> Value {
87    match value {
88        Value::String(text) => {
89            let (replaced, found) = redact_text(text, Marker::Fingerprinted);
90            *hits = hits.saturating_add(found);
91            Value::String(replaced)
92        }
93        Value::Array(items) => Value::Array(items.iter().map(|item| walk(item, hits)).collect()),
94        Value::Object(map) => {
95            let mut out = Map::new();
96            for (key, item) in map {
97                out.insert(key.clone(), walk(item, hits));
98            }
99            Value::Object(out)
100        }
101        Value::Null | Value::Bool(_) | Value::Number(_) => value.clone(),
102    }
103}
104
105/// One string, with every secret shape in it replaced in place.
106///
107/// The spans arrive sorted and non-overlapping from the detector, so a
108/// single left-to-right pass is enough and no offset needs adjusting
109/// after a replacement.
110#[must_use]
111pub fn redact_text(text: &str, marker: Marker) -> (String, u32) {
112    let bytes = text.as_bytes();
113    let spans = kernel::secret::scan(bytes);
114    if spans.is_empty() {
115        return (text.to_owned(), 0);
116    }
117    let mut out = String::with_capacity(text.len());
118    let mut at = 0usize;
119    let mut hits: u32 = 0;
120    for span in spans {
121        let end = span.start.saturating_add(span.len);
122        if span.start < at || end > bytes.len() {
123            continue;
124        }
125        let Some(before) = bytes
126            .get(at..span.start)
127            .and_then(|b| std::str::from_utf8(b).ok())
128        else {
129            continue;
130        };
131        let Some(found) = bytes.get(span.start..end) else {
132            continue;
133        };
134        out.push_str(before);
135        out.push_str(&marker.spell(found));
136        at = end;
137        hits = hits.saturating_add(1);
138    }
139    // A span that ended inside a character would leave the tail
140    // unreadable; the honest answer is to keep nothing of it rather than
141    // emit half a character.
142    if let Some(rest) = bytes.get(at..).and_then(|b| std::str::from_utf8(b).ok()) {
143        out.push_str(rest);
144    }
145    (out, hits)
146}
147
148/// How many hex characters of a value's hash stand for the value.
149/// Sixteen is enough to tell two values apart in a history and far too
150/// few to reconstruct one.
151const FINGERPRINT_HEX: usize = 16;
152
153/// The one short name this city gives a secret it must refer to without
154/// holding: derived from the value, so one value reads alike in every
155/// session and two values never read alike in one.
156///
157/// A name counted out instead — `cap-1`, `cap-2` — would give the same
158/// value a different name in every session and two different values the
159/// same name across two, which is how a stored credential comes back as
160/// somebody else's.
161#[must_use]
162pub fn fingerprint(found: &[u8]) -> String {
163    let digest = B3Hash::digest(found).to_string();
164    digest.get(..FINGERPRINT_HEX).unwrap_or(&digest).to_owned()
165}
166
167#[cfg(test)]
168#[allow(
169    clippy::unwrap_used,
170    clippy::expect_used,
171    clippy::panic,
172    clippy::indexing_slicing,
173    reason = "test code"
174)]
175mod tests {
176    use super::*;
177
178    /// Assembled at runtime: a literal key-shaped sample in the source
179    /// would be a finding about this file.
180    fn key_shaped() -> String {
181        ["sk", "9fQ2xZ", "7Lm4Rt", "0Bv8Kd", "3Wp6"].join("")
182    }
183
184    #[test]
185    fn a_key_shaped_run_of_bytes_does_not_reach_the_payload() {
186        let key = key_shaped();
187        let mut payload = Map::new();
188        payload.insert(
189            "message".to_owned(),
190            Value::String(format!("the token is {key} and it works")),
191        );
192        let (redacted, hits) = redact(&payload);
193        assert_eq!(hits, 1);
194        let text = redacted
195            .get("message")
196            .and_then(Value::as_str)
197            .unwrap()
198            .to_owned();
199        assert!(!text.contains(&key), "the value itself is gone");
200        assert!(text.contains("the token is "), "the sentence survives");
201        assert!(text.contains("secret:redacted/"));
202    }
203
204    #[test]
205    fn the_same_value_twice_marks_the_same_way_and_two_values_do_not() {
206        let key = key_shaped();
207        let other = ["sk", "2Tg7Yh", "4Nn1Qs", "8Cz5Ud", "6Ke0"].join("");
208        let (first, _) = redact_text(&key, Marker::Fingerprinted);
209        let (again, _) = redact_text(&key, Marker::Fingerprinted);
210        let (different, _) = redact_text(&other, Marker::Fingerprinted);
211        assert_eq!(first, again, "one value, one marker");
212        assert_ne!(first, different, "two values are distinguishable");
213    }
214
215    #[test]
216    fn nested_strings_are_reached_and_ordinary_prose_is_untouched() {
217        let key = key_shaped();
218        let mut inner = Map::new();
219        inner.insert("text".to_owned(), Value::String(key.clone()));
220        let mut payload = Map::new();
221        payload.insert(
222            "content".to_owned(),
223            Value::Array(vec![Value::Object(inner)]),
224        );
225        payload.insert(
226            "prose".to_owned(),
227            Value::String("nothing here looks like a key at all".to_owned()),
228        );
229        payload.insert("count".to_owned(), Value::Number(3.into()));
230        let (redacted, hits) = redact(&payload);
231        assert_eq!(hits, 1);
232        assert_eq!(
233            redacted.get("prose").and_then(Value::as_str),
234            Some("nothing here looks like a key at all"),
235            "prose is not a shape, and a scanner that trimmed it would be unusable"
236        );
237        assert_eq!(redacted.get("count").and_then(Value::as_u64), Some(3));
238        let text = serde_json::to_string(&redacted).unwrap();
239        assert!(!text.contains(&key));
240    }
241
242    /// One value, one short name, wherever the city has to refer to it,
243    /// and a name a reader can act on: the marker parses as a reference,
244    /// so somebody who meets one in history knows both that a value was
245    /// there and that the way to a value is the vault, which will tell
246    /// them the `redacted` realm holds nothing.
247    #[test]
248    fn a_marker_is_a_reference_a_reader_can_follow_to_an_honest_nothing() {
249        let key = key_shaped();
250        let short = fingerprint(key.as_bytes());
251        assert_eq!(short.len(), 16);
252        let (marked, hits) = redact_text(&key, Marker::Fingerprinted);
253        assert_eq!(hits, 1);
254        assert_eq!(marked, format!("secret:redacted/{short}"));
255        let reference = kernel::SecretRef::parse(&marked).unwrap();
256        assert_eq!(reference.realm(), REDACTED_REALM);
257        assert_eq!(reference.name(), short);
258    }
259
260    /// The two sinks mark by one rule and differ by one argument: a log
261    /// line says a secret was there, history says which one.
262    #[test]
263    fn a_plain_marker_says_that_something_was_here_and_nothing_more() {
264        let key = key_shaped();
265        let (line, hits) = redact_text(&format!("token {key} used"), Marker::Plain);
266        assert_eq!(hits, 1);
267        assert_eq!(line, "token secret:redacted used");
268        assert!(!line.contains(&fingerprint(key.as_bytes())));
269        let (kept, _) = redact_text(&format!("token {key} used"), Marker::Fingerprinted);
270        assert!(kept.contains(&fingerprint(key.as_bytes())));
271    }
272
273    #[test]
274    fn a_payload_with_nothing_to_hide_comes_back_unchanged() {
275        let mut payload = Map::new();
276        payload.insert(
277            "message".to_owned(),
278            Value::String("measured in metres".to_owned()),
279        );
280        let (redacted, hits) = redact(&payload);
281        assert_eq!(hits, 0);
282        assert_eq!(redacted, payload);
283    }
284}