Skip to main content

forge_test_harness/sdk_wire/
session_redact.rs

1//! Session-persistence → stream-json transformer + PII redactor.
2//!
3//! Claude Code persists each session as JSONL under
4//! `$CLAUDE_CONFIG_DIR/projects/<slug>/<session-id>.jsonl`, overlapping
5//! the wire protocol in broad shape but adding camelCase
6//! persistence-only fields and dropping wire-only frames.
7//! [`transform_persistence_line`] converts one such line to wire shape;
8//! both it and [`WireRedactor`] then redact.
9//!
10//! Most rules are SPELLING rules - home paths, `.claude-<profile>`
11//! directories (`.claude-plugin` excepted, it is a plugin manifest),
12//! `-Users-<name>-` project slugs, and the home-directory owner as a bare
13//! word. Those recognise nothing by meaning, so a check written from them
14//! can only confirm they ran, and **prose in a captured tool result is
15//! not redacted at all**. Read a new capture before committing it.
16//!
17//! Four rules are instead value-blind, replacing a field wholesale
18//! rather than rewriting spellings inside it: everything non-categorical
19//! under `account`, the body of a `hook_response` frame, the command /
20//! skill inventory, and an `mcpServers[].config` blob. Each is a field
21//! where no spelling rule could ever be enough - a hook is an arbitrary
22//! local program, and its output on this repo's own captures was the
23//! author's entire cross-project memory index, in prose, which every
24//! spelling rule passed through untouched.
25//!
26//! Prefer that shape when adding a rule. A fixed-point gate over this
27//! module can only ever check that a capture agrees with these rules, so
28//! its reach is bounded by them: it is strengthened by a rule that
29//! recognises nothing, never by a longer list of things to recognise.
30//!
31//! Two rules are entry-point specific: the owner rule needs a whole
32//! trace to discover the name, so only [`WireRedactor`] has it; message
33//! bodies, tool inputs and results are stubbed only by
34//! [`transform_persistence_line`], which also drops the
35//! persistence-only fields rather than tokenising them.
36//!
37//! Deterministic: one input line always gives the same output line.
38
39use std::collections::HashMap;
40use std::str::FromStr;
41use std::sync::OnceLock;
42
43use serde_json::Value;
44
45/// Home-path prefixes, replaced by `<redacted-home>` with the path tail
46/// kept. The classes must exclude `"`: these patterns also run over raw
47/// line text, where a class that swallows the delimiter eats the field
48/// that follows. That is also what keeps the `,` they DO match harmless -
49/// drop the `"` again and the comma inclusion becomes a field-eater.
50fn path_regex() -> &'static regex::Regex {
51    static RE: OnceLock<regex::Regex> = OnceLock::new();
52    RE.get_or_init(|| {
53        regex::Regex::new(r#"(/Users/[^/\s\\"]+|/home/[^/\s\\"]+|/Volumes/[^/\s\\"]+)"#)
54            .expect("redactor path regex must compile")
55    })
56}
57
58/// A `claude` profile directory name, which [`path_regex`] cannot reach:
59/// it replaces only the home prefix, and `~/.claude-alt` has no prefix at
60/// all. Bare `.claude` is the default config dir, so it is not matched.
61fn profile_regex() -> &'static regex::Regex {
62    static RE: OnceLock<regex::Regex> = OnceLock::new();
63    RE.get_or_init(|| {
64        regex::Regex::new(r"\.claude-[A-Za-z0-9._-]+").expect("redactor profile regex must compile")
65    })
66}
67
68/// The project-slug spelling of a home path, which Claude Code mints by
69/// turning `/` into `-` (`-Users-<name>-Projects-forge`). Captures the
70/// name so discovery can harvest it as well as redact it.
71fn slug_regex() -> &'static regex::Regex {
72    static RE: OnceLock<regex::Regex> = OnceLock::new();
73    RE.get_or_init(|| {
74        regex::Regex::new(r#"-Users-([^-/\s\\"]+)-"#).expect("redactor slug regex must compile")
75    })
76}
77
78/// A plugin manifest directory, not a profile - [`profile_regex`] would
79/// otherwise take it for one.
80const PLUGIN_MANIFEST_DIR: &str = ".claude-plugin";
81
82/// Shortest owner name scrubbed as a bare token; a shorter one is an
83/// error, not a skip. This does not make token scrubbing safe -
84/// `assistant` and `permission` clear it - but a collision above the
85/// floor fails loudly on replay, and below it would not.
86const MIN_OWNER_LEN: usize = 8;
87
88/// True when any string-level rule would rewrite `s`.
89fn needs_scrub(s: &str) -> bool {
90    path_regex().is_match(s) || profile_regex().is_match(s) || slug_regex().is_match(s)
91}
92
93/// Apply every string-level rule to `s`. Ordered widest-first so an
94/// absolute path loses its home prefix before its profile segment.
95fn scrub_text(s: &str) -> String {
96    let out = path_regex().replace_all(s, "<redacted-home>");
97    // The regex crate has no lookahead, so the plugin dir is excluded
98    // by the replacement rather than by the pattern.
99    let out = profile_regex().replace_all(&out, |caps: &regex::Captures| {
100        if &caps[0] == PLUGIN_MANIFEST_DIR {
101            PLUGIN_MANIFEST_DIR.to_string()
102        } else {
103            "<redacted-profile>".to_string()
104        }
105    });
106    slug_regex().replace_all(&out, "<redacted-home>-").into_owned()
107}
108
109/// Captures the home-directory OWNER name out of an absolute home path.
110/// Narrower than [`path_regex`] in two ways: no `/Volumes/` arm, because
111/// a volume name is not a user name, and a user-name character class,
112/// because stderr puts `:` or a quote straight after the path and a
113/// captured fragment then matches nothing.
114fn owner_regex() -> &'static regex::Regex {
115    static RE: OnceLock<regex::Regex> = OnceLock::new();
116    RE.get_or_init(|| {
117        regex::Regex::new(r"/(?:Users|home)/([A-Za-z0-9._-]+)")
118            .expect("redactor owner regex must compile")
119    })
120}
121
122/// Account keys whose value is a categorical CLI label, not personal
123/// data: the corpus records two distinct `subscriptionType` labels and
124/// collapsing them would delete a wire fact the fixture exists to
125/// record. Only keys observed nested under an account object belong
126/// here - an exemption that never fires just risks leaving a real value.
127const ACCOUNT_CATEGORICAL_KEYS: &[&str] = &["apiProvider", "subscriptionType"];
128
129/// Placeholder every non-categorical string under `account` /
130/// `accounts`, keeping the keys so the fixture still records the CLI's
131/// shape. Structural rather than value-matching, so a field the CLI adds
132/// later is redacted before anyone notices it leaked.
133fn redact_account_recursive(v: &mut Value) {
134    match v {
135        Value::Array(a) => a.iter_mut().for_each(redact_account_recursive),
136        Value::Object(o) => {
137            for (key, val) in o.iter_mut() {
138                if key == "account" || key == "accounts" {
139                    redact_account_field(val, key);
140                } else {
141                    redact_account_recursive(val);
142                }
143            }
144        }
145        _ => {}
146    }
147}
148
149/// Redact one value under an account field. `key` names whatever it hung
150/// off, so a bare `"account": "…"` and a nested
151/// `"account": {"profile": {"email": …}}` both get a naming placeholder.
152fn redact_account_field(v: &mut Value, key: &str) {
153    match v {
154        Value::String(s) if !ACCOUNT_CATEGORICAL_KEYS.contains(&key) => {
155            let placeholder = format!("<redacted-{key}>");
156            if *s != placeholder {
157                // `tool_use.input` is not stubbed on the wire path, so an
158                // MCP tool taking an `account` argument has that argument
159                // collapsed and the fixture then records a wire fact that
160                // never happened. Log so a fixture-regen review sees it.
161                eprintln!("redact_account_field: collapsed a value under key={key:?}");
162                *s = placeholder;
163            }
164        }
165        Value::Array(a) => a.iter_mut().for_each(|e| redact_account_field(e, key)),
166        Value::Object(o) => {
167            for (k, val) in o.iter_mut() {
168                redact_account_field(val, k);
169            }
170        }
171        _ => {}
172    }
173}
174
175/// Body fields of a `hook_response` frame. A hook is an arbitrary local
176/// program, so these carry whatever the capture machine happened to
177/// print, which on this repo's own captures was the author's entire
178/// cross-project memory index.
179const HOOK_BODY_KEYS: &[&str] = &["output", "stdout", "stderr"];
180
181/// What replaces a hook body. Non-empty, so a capture cannot pass the
182/// fixed-point gate by having merely been emptied by hand.
183const HOOK_BODY_PLACEHOLDER: &str = "<redacted-hook-body>";
184
185/// Stub the body of every `hook_response` frame, keeping the keys and
186/// every other field so the fixture still records the CLI's shape.
187///
188/// Value-blind by construction: the spelling rules elsewhere in this file
189/// are a no-op on prose, so no amount of pattern-matching makes a hook
190/// body safe to commit. The frame is scoped by its own `type` + `subtype`
191/// rather than by key name, because `stdout` is also how a Bash tool
192/// result reports itself and that one is wire surface worth keeping.
193///
194/// An empty body is left alone: it cannot carry anything, and a quiet
195/// hook is a wire fact the fixture should still record.
196fn redact_hook_body_recursive(v: &mut Value) {
197    match v {
198        Value::Array(a) => a.iter_mut().for_each(redact_hook_body_recursive),
199        Value::Object(o) => {
200            if o.get("type").and_then(Value::as_str) == Some("system")
201                && o.get("subtype").and_then(Value::as_str) == Some("hook_response")
202            {
203                for key in HOOK_BODY_KEYS {
204                    if let Some(Value::String(body)) = o.get_mut(*key)
205                        && !body.is_empty()
206                        && body != HOOK_BODY_PLACEHOLDER
207                    {
208                        HOOK_BODY_PLACEHOLDER.clone_into(body);
209                    }
210                }
211            }
212            for val in o.values_mut() {
213                redact_hook_body_recursive(val);
214            }
215        }
216        _ => {}
217    }
218}
219
220/// Per-entry fields of a command inventory entry. `argumentHint` is only
221/// stubbed when already present and non-empty: the parser collapses an
222/// empty hint to `None`, so inventing one would record a wire fact that
223/// never happened.
224const COMMAND_ENTRY_KEYS: &[&str] = &["description", "argumentHint"];
225
226/// Stub the command / skill inventory the CLI reports for whatever is
227/// installed on the capture machine: `slash_commands` and `skills` on
228/// `system/init`, `commands` on `system/commands_changed`, and the
229/// `commands` reply carried by a `control_response`.
230///
231/// Both halves go, not just the descriptions. A description is local
232/// prose; a name is a local project's name, and this repo's own captures
233/// carried a work project in both. Nothing in forge matches on a specific
234/// command name - the parser takes any non-empty string and the
235/// lookup compares against what the user typed - so a placeholder keeps
236/// every wire fact the fixture records.
237///
238/// Placeholders are index-suffixed to keep the list's length and the
239/// entries distinct, which a single shared placeholder would collapse.
240fn redact_command_inventory_recursive(v: &mut Value) {
241    match v {
242        Value::Array(a) => a.iter_mut().for_each(redact_command_inventory_recursive),
243        Value::Object(o) => {
244            let is_system = o.get("type").and_then(Value::as_str) == Some("system");
245            let is_init = is_system && o.get("subtype").and_then(Value::as_str) == Some("init");
246            let is_changed =
247                is_system && o.get("subtype").and_then(Value::as_str) == Some("commands_changed");
248            let is_control = o.get("type").and_then(Value::as_str) == Some("control_response");
249            if is_init {
250                for (key, kind) in [("slash_commands", "command"), ("skills", "skill")] {
251                    if let Some(val) = o.get_mut(key) {
252                        stub_inventory(val, kind);
253                    }
254                }
255            }
256            if is_changed && let Some(val) = o.get_mut("commands") {
257                stub_inventory(val, "command");
258            }
259            if is_control {
260                for val in o.values_mut() {
261                    stub_nested_commands(val);
262                }
263                return;
264            }
265            for val in o.values_mut() {
266                redact_command_inventory_recursive(val);
267            }
268        }
269        _ => {}
270    }
271}
272
273/// Inventory arrays a `control_response` can carry, and what each holds:
274/// `commands` from the command-list reply, `skillFrontmatter` from the
275/// context-usage reply. Both are keyed inside that frame rather than
276/// globally, which is what keeps a same-named field elsewhere on the wire
277/// out of scope.
278const CONTROL_INVENTORY_KEYS: &[(&str, &str)] =
279    &[("commands", "command"), ("skillFrontmatter", "skill")];
280
281/// Find every inventory array under a `control_response` and stub it.
282/// Scoped to that frame by the caller.
283fn stub_nested_commands(v: &mut Value) {
284    match v {
285        Value::Array(a) => a.iter_mut().for_each(stub_nested_commands),
286        Value::Object(o) => {
287            for (key, val) in o.iter_mut() {
288                match CONTROL_INVENTORY_KEYS.iter().find(|(k, _)| *k == key.as_str()) {
289                    Some((_, kind)) => stub_inventory(val, kind),
290                    None => stub_nested_commands(val),
291                }
292            }
293        }
294        _ => {}
295    }
296}
297
298/// Replace each entry of one inventory array, whichever of the two shapes
299/// it uses: a bare name string, or an object carrying a name plus local
300/// prose about it. A non-array is left alone.
301fn stub_inventory(arr: &mut Value, kind: &str) {
302    let Value::Array(entries) = arr else { return };
303    for (i, entry) in entries.iter_mut().enumerate() {
304        let name = format!("<redacted-{kind}-{i}>");
305        match entry {
306            Value::String(s) => *s = name,
307            Value::Object(o) => {
308                if let Some(Value::String(s)) = o.get_mut("name") {
309                    *s = name;
310                }
311                for key in COMMAND_ENTRY_KEYS {
312                    if let Some(Value::String(s)) = o.get_mut(*key)
313                        && !s.is_empty()
314                    {
315                        *s = format!("<redacted-{kind}-{key}>");
316                    }
317                }
318            }
319            _ => {}
320        }
321    }
322}
323
324/// Keys inside an `mcpServers[].config` blob whose value is a CLI label
325/// rather than local configuration. `type` is the transport discriminant
326/// (`stdio` / `http` / `sse` / `claudeai-proxy`), which the fixture exists
327/// to record.
328const MCP_CONFIG_CATEGORICAL_KEYS: &[&str] = &["type"];
329
330/// What replaces a value inside an `mcpServers[].config` blob.
331const MCP_CONFIG_PLACEHOLDER: &str = "<redacted-mcp-config>";
332
333/// Stub the `config` blob on every `mcpServers[]` entry, keeping the keys
334/// and every sibling field.
335///
336/// `McpServerStatus::config` is decoded as an opaque `serde_json::Value`
337/// and nothing in forge reads inside it, so the only wire fact here is
338/// the shape. What it actually holds is the capture machine's own MCP
339/// configuration: on this repo's captures, a LAN address, an API-key
340/// environment variable, and a server id issued by Anthropic's MCP proxy.
341/// `name`, `status`, `scope`, `error` and `tools` are untouched - those
342/// are what the CLI reports and what forge renders.
343fn redact_mcp_config_recursive(v: &mut Value) {
344    match v {
345        Value::Array(a) => a.iter_mut().for_each(redact_mcp_config_recursive),
346        Value::Object(o) => {
347            if let Some(Value::Array(servers)) = o.get_mut("mcpServers") {
348                for server in servers.iter_mut() {
349                    if let Value::Object(fields) = server
350                        && let Some(config) = fields.get_mut("config")
351                    {
352                        stub_mcp_config(config);
353                    }
354                }
355            }
356            for val in o.values_mut() {
357                redact_mcp_config_recursive(val);
358            }
359        }
360        _ => {}
361    }
362}
363
364/// Replace every non-categorical string inside one config blob, at any
365/// depth, keeping array lengths and object keys.
366fn stub_mcp_config(v: &mut Value) {
367    match v {
368        Value::String(s) => {
369            if s != MCP_CONFIG_PLACEHOLDER {
370                MCP_CONFIG_PLACEHOLDER.clone_into(s);
371            }
372        }
373        Value::Array(a) => a.iter_mut().for_each(stub_mcp_config),
374        Value::Object(o) => {
375            for (key, val) in o.iter_mut() {
376                if !MCP_CONFIG_CATEGORICAL_KEYS.contains(&key.as_str()) {
377                    stub_mcp_config(val);
378                }
379            }
380        }
381        _ => {}
382    }
383}
384
385/// Replace each discovered owner name with `<redacted-user>` in string
386/// VALUES only. Keys are left alone: an owner name is never a wire key,
387/// and rewriting one would change the shape rather than a value.
388fn scrub_owner_tokens_recursive(v: &mut Value, owners: &[regex::Regex]) {
389    match v {
390        Value::String(s) => *s = replace_owners(s, owners),
391        Value::Array(a) => a.iter_mut().for_each(|e| scrub_owner_tokens_recursive(e, owners)),
392        Value::Object(o) => {
393            for val in o.values_mut() {
394                scrub_owner_tokens_recursive(val, owners);
395            }
396        }
397        _ => {}
398    }
399}
400
401/// Apply every owner pattern to `s`.
402fn replace_owners(s: &str, owners: &[regex::Regex]) -> String {
403    owners
404        .iter()
405        .fold(s.to_string(), |acc, owner| owner.replace_all(&acc, "<redacted-user>").into_owned())
406}
407
408/// Collect owner names from both spellings of a home path in `s`: the
409/// path and the project slug.
410///
411/// # Errors
412///
413/// A name shorter than `MIN_OWNER_LEN`.
414fn collect_owners(s: &str, out: &mut Vec<String>) -> Result<(), String> {
415    let from_paths = owner_regex().captures_iter(s);
416    let from_slugs = slug_regex().captures_iter(s);
417    for caps in from_paths.chain(from_slugs) {
418        let name = caps[1].to_string();
419        if name.len() < MIN_OWNER_LEN {
420            return Err(format!(
421                "discovered a {}-character home-directory owner, below this \
422                 redactor's {MIN_OWNER_LEN}-character floor",
423                name.len()
424            ));
425        }
426        if !out.contains(&name) {
427            out.push(name);
428        }
429    }
430    Ok(())
431}
432
433/// Collect owner names from every string a parsed line holds. Reading
434/// parsed values rather than raw text matters: over raw text
435/// `{"cwd":"/Users/ada","type":"system"}` captures a name running to
436/// end-of-line, which then matches nothing.
437///
438/// # Errors
439///
440/// As `collect_owners`.
441fn collect_owners_recursive(v: &Value, out: &mut Vec<String>) -> Result<(), String> {
442    match v {
443        Value::String(s) => collect_owners(s, out)?,
444        Value::Array(a) => {
445            for e in a {
446                collect_owners_recursive(e, out)?;
447            }
448        }
449        Value::Object(o) => {
450            for (k, val) in o {
451                collect_owners(k, out)?;
452                collect_owners_recursive(val, out)?;
453            }
454        }
455        _ => {}
456    }
457    Ok(())
458}
459
460/// Redacts one captured wire trace. Whole-trace rather than per-line
461/// because the identity also leaks as a bare name in prose, which is
462/// only recognisable once a home path elsewhere in the file supplies it -
463/// from the trace, so the same file redacts identically on any machine.
464pub struct WireRedactor {
465    owners: Vec<regex::Regex>,
466}
467
468impl WireRedactor {
469    /// Discover the home-directory owner(s) named anywhere in `lines`. A
470    /// trace naming no home path discovers none, so a bare name in its
471    /// prose survives.
472    ///
473    /// # Errors
474    ///
475    /// A discovered name below `MIN_OWNER_LEN`.
476    pub fn for_trace<'a>(lines: impl IntoIterator<Item = &'a str>) -> Result<Self, String> {
477        let mut names: Vec<String> = Vec::new();
478        for line in lines {
479            match serde_json::from_str::<Value>(line) {
480                Ok(v) => collect_owners_recursive(&v, &mut names)?,
481                Err(_) => collect_owners(line, &mut names)?,
482            }
483        }
484        // Longest first: the word-boundary anchors stop a short name
485        // matching inside a longer one only while the extension is a word
486        // character, and `first.last` home dirs are the case where it is
487        // not. `sort_by` is stable, so ties keep discovery order.
488        names.sort_by_key(|n| std::cmp::Reverse(n.len()));
489        let owners = names
490            .iter()
491            .filter_map(|n| regex::Regex::new(&format!(r"\b{}\b", regex::escape(n))).ok())
492            .collect();
493        Ok(Self { owners })
494    }
495
496    /// Redact one line, by one of three paths: structurally where the
497    /// no-op round trip is byte-exact; structurally again on a
498    /// structure-preserving re-encode (a number-token count match -
499    /// 17-digit floats re-spell under serde_json's parse, and that is
500    /// the canonicalisation); by the text rules otherwise, for a
501    /// non-JSON line or a JSON line with nothing structurally to
502    /// redact whose re-encode would differ.
503    ///
504    /// # Errors
505    ///
506    /// Serialisation failure, and account or hook-body fields on a line
507    /// whose re-encode loses structure (a number-token count mismatch -
508    /// duplicate keys collapse on parse). That arm is LIVE in this
509    /// build. The message names the frame's `type`, never the body it
510    /// refused to write.
511    pub fn redact_line(&self, line: &str) -> Result<String, String> {
512        let Ok(parsed) = serde_json::from_str::<Value>(line) else {
513            return Ok(self.scrub_raw(line));
514        };
515        let reencoded = serde_json::to_string(&parsed).map_err(|e| format!("serialise: {e}"))?;
516        if reencoded != line {
517            let mut probe = parsed.clone();
518            redact_account_recursive(&mut probe);
519            redact_hook_body_recursive(&mut probe);
520            redact_command_inventory_recursive(&mut probe);
521            redact_mcp_config_recursive(&mut probe);
522            if probe != parsed {
523                // With `preserve_order` a re-encode can only differ
524                // from the original in number spelling - including a
525                // one-ulp drift on 17-digit floats, where serde_json's
526                // parse is the canonicaliser. A matching token COUNT
527                // means no structure was lost (duplicate keys collapse
528                // on parse), so redacting on the parsed tree is safe;
529                // numbers beyond f64 precision re-spell rather than
530                // survive. A count mismatch still refuses.
531                if number_token_counts_match(line, &reencoded) {
532                    return self.redact_structurally(parsed);
533                }
534                let ty = parsed.get("type").and_then(Value::as_str).unwrap_or("<no type>");
535                return Err(format!(
536                    "a {ty} frame carries account, hook-body, command-inventory or \
537                     mcp-config fields but does not survive a value-faithful re-encode, so \
538                     they cannot be redacted structurally"
539                ));
540            }
541            return Ok(self.scrub_raw(line));
542        }
543        self.redact_structurally(parsed)
544    }
545
546    /// The structural rules plus path and owner scrubbing, over an
547    /// already-parsed line.
548    fn redact_structurally(&self, mut v: Value) -> Result<String, String> {
549        redact_account_recursive(&mut v);
550        redact_hook_body_recursive(&mut v);
551        redact_command_inventory_recursive(&mut v);
552        redact_mcp_config_recursive(&mut v);
553        scrub_paths_recursive(&mut v);
554        scrub_owner_tokens_recursive(&mut v, &self.owners);
555        serde_json::to_string(&v).map_err(|e| format!("serialise: {e}"))
556    }
557
558    /// The text rules alone, for a line that must not be re-encoded.
559    fn scrub_raw(&self, s: &str) -> String {
560        replace_owners(&scrub_text(s), &self.owners)
561    }
562}
563
564/// Number literals appearing outside string literals, in order. With
565/// serde_json's `preserve_order`, two serialisations of the same
566/// `Value` can only differ in these, so the number of these tokens is
567/// what decides whether a re-encode lost structure. Values are parsed
568/// to f64 only to keep the sequence numbers, not compared: a 17-digit
569/// float's parse drifts by one ulp, so equality would reject the very
570/// re-spelling this gate exists to allow.
571fn number_tokens(line: &str) -> Vec<f64> {
572    let mut tokens = Vec::new();
573    let mut start: Option<usize> = None;
574    let mut in_string = false;
575    let mut escaped = false;
576    for (i, c) in line.char_indices() {
577        if in_string {
578            if escaped {
579                escaped = false;
580            } else if c == '\\' {
581                escaped = true;
582            } else if c == '"' {
583                in_string = false;
584            }
585            continue;
586        }
587        let numeric = c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E');
588        if numeric && start.is_none() {
589            start = Some(i);
590        } else if !numeric {
591            if let Some(s) = start.take() {
592                tokens.push(&line[s..i]);
593            }
594            if c == '"' {
595                in_string = true;
596            }
597        }
598    }
599    if let Some(s) = start {
600        tokens.push(&line[s..]);
601    }
602    tokens.into_iter().filter_map(|t| f64::from_str(t).ok()).collect()
603}
604
605/// True when both lines carry the same NUMBER of number literals.
606/// A count change means the re-encode lost or gained structure -
607/// duplicate keys collapse on parse - and that still refuses; equal
608/// counts pass, accepting serde_json's canonical re-spelling.
609fn number_token_counts_match(original: &str, reencoded: &str) -> bool {
610    number_tokens(original).len() == number_tokens(reencoded).len()
611}
612
613/// Recursively walk a JSON value and rewrite every absolute home path
614/// segment to `<redacted-home>`. Defence-in-depth on top of the
615/// structural redaction (which strips `text` / `tool_use_input` /
616/// `tool_result_content`); catches any path leak that survives in
617/// less-common fields.
618fn scrub_paths_recursive(v: &mut Value) {
619    match v {
620        Value::String(s) if needs_scrub(s) => {
621            *s = scrub_text(s);
622        }
623        Value::Array(a) => a.iter_mut().for_each(scrub_paths_recursive),
624        Value::Object(o) => {
625            // First scrub every value in place.
626            for val in o.values_mut() {
627                scrub_paths_recursive(val);
628            }
629            // Then scrub object KEYS too - fixture readers should see
630            // no leaks even when a path appears as a map key (env-var
631            // dumps, file-history snapshots, future Anthropic API
632            // shapes that key by absolute path). Build a replacement
633            // map only when at least one key changes.
634            let needs_rewrite = o.keys().any(|k| needs_scrub(k));
635            if needs_rewrite {
636                let entries: Vec<(String, Value)> =
637                    o.iter_mut().map(|(k, v)| (scrub_text(k), v.take())).collect();
638                o.clear();
639                for (k, v) in entries {
640                    if o.contains_key(&k) {
641                        // Two distinct source paths redacted to the
642                        // same opaque key; the second insert silently
643                        // overwrites. This is intentional (privacy
644                        // collapse - the redactor should not preserve
645                        // distinguishing path info), but log so a
646                        // fixture-regen review can spot unexpected
647                        // shape changes.
648                        eprintln!(
649                            "scrub_paths_recursive: key collision after redaction; \
650                             distinct source paths collapsed into key={k:?}"
651                        );
652                    }
653                    o.insert(k, v);
654                }
655            }
656        }
657        _ => {}
658    }
659}
660
661/// Per-run state so that each distinct `session_id` / uuid gets a stable
662/// opaque token. Reused across every line in the same transformation
663/// pass so references inside a single session stay consistent.
664#[derive(Default)]
665pub struct RedactState {
666    ids: HashMap<String, String>,
667}
668
669impl RedactState {
670    /// Token-of-record for an arbitrary id. The first time we see a
671    /// given input, we mint `<prefix>_<n>` (n monotonic per prefix).
672    fn opaque(&mut self, prefix: &str, input: &str) -> String {
673        if let Some(existing) = self.ids.get(input) {
674            return existing.clone();
675        }
676        let n = self.ids.values().filter(|v| v.starts_with(&format!("{prefix}_"))).count();
677        let out = format!("{prefix}_{n}");
678        self.ids.insert(input.to_string(), out.clone());
679        out
680    }
681}
682
683/// Transform one persistence-format JSONL line into a stream-json
684/// wire-shape line. Returns `Ok(None)` for entries that don't map
685/// (persistence-only types like `file-history-snapshot`, `attachment`,
686/// `last-prompt`).
687///
688/// # Errors
689///
690/// Returns a string describing the first shape mismatch. The caller
691/// decides whether to propagate or skip the line.
692pub fn transform_persistence_line(
693    line: &str,
694    state: &mut RedactState,
695) -> Result<Option<String>, String> {
696    let mut v: Value = serde_json::from_str(line).map_err(|e| format!("json parse: {e}"))?;
697    let Some(ty) = v.get("type").and_then(Value::as_str).map(str::to_string) else {
698        return Ok(None);
699    };
700    if !matches!(
701        ty.as_str(),
702        "assistant" | "user" | "system" | "result" | "rate_limit_event" | "stream_event" | "error"
703    ) {
704        return Ok(None);
705    }
706
707    let obj = v.as_object_mut().ok_or_else(|| "top-level not an object".to_string())?;
708
709    // Rename sessionId → session_id.
710    if let Some(sid) = obj.remove("sessionId")
711        && let Some(s) = sid.as_str()
712    {
713        let opaque = state.opaque("session", s);
714        obj.insert("session_id".into(), Value::String(opaque));
715    }
716
717    // Map parentUuid → parent_tool_use_id when present + non-null.
718    if let Some(p) = obj.remove("parentUuid") {
719        if let Some(s) = p.as_str() {
720            let opaque = state.opaque("tool_use", s);
721            obj.insert("parent_tool_use_id".into(), Value::String(opaque));
722        } else {
723            obj.insert("parent_tool_use_id".into(), Value::Null);
724        }
725    } else {
726        obj.insert("parent_tool_use_id".into(), Value::Null);
727    }
728
729    // Opaque-map the top-level uuid.
730    if let Some(u) = obj.get("uuid").and_then(Value::as_str).map(str::to_string) {
731        let opaque = state.opaque("uuid", &u);
732        obj.insert("uuid".into(), Value::String(opaque));
733    }
734
735    // Fold the disk's synthetic marks into the wire's own spelling
736    // before the disk marks drop, user frames only: the CLI writes
737    // `isSynthetic` from `isMeta || isVisibleInTranscriptOnly ||
738    // isCompactSummary` there, and `turnCompanion` never travels.
739    // Keeping one disk mark and dropping its siblings manufactures a
740    // row the CLI cannot emit.
741    let synthetic = ty == "user"
742        && ["isMeta", "isVisibleInTranscriptOnly", "isCompactSummary"]
743            .iter()
744            .any(|mark| obj.get(*mark).and_then(Value::as_bool) == Some(true));
745
746    // `isVirtual` is the one other spelling pair: the wire writes
747    // `is_virtual`.
748    let virtual_row = obj.get("isVirtual").and_then(Value::as_bool) == Some(true);
749
750    // Drop persistence-only fields the wire decoder doesn't expect.
751    // `toolUseResult` / `lastPrompt` / `content` (snapshot body) carry
752    // raw tool output / prompt text with embedded paths + project
753    // content - must be removed entirely, not just scrubbed. The
754    // camelCase names below are disk-only too: no live wire capture
755    // carries any of them, and the CLI writes several of them on every
756    // session row, so a regenerated baseline that kept them would
757    // describe a wire shape the CLI does not send.
758    for field in [
759        "attachment",
760        "classifierMetaLines",
761        "content",
762        "cwd",
763        "entrypoint",
764        "gitBranch",
765        "imagePasteIds",
766        "isCompactSummary",
767        "isMeta",
768        "isSidechain",
769        "isSnapshotUpdate",
770        "isVirtual",
771        "isVisibleInTranscriptOnly",
772        "lastPrompt",
773        "messageId",
774        "operation",
775        "permissionMode",
776        "promptId",
777        "promptSource",
778        "queueSkipAttachments",
779        "queueTranscriptOnly",
780        "requestId",
781        "slug",
782        "snapshot",
783        "sourceToolAssistantUUID",
784        "sourceToolUseID",
785        "timestamp",
786        "toolDenialKind",
787        "toolUseResult",
788        "turnCompanion",
789        "turnOrigin",
790        "userType",
791        "version",
792    ] {
793        obj.remove(field);
794    }
795
796    if synthetic {
797        obj.insert("isSynthetic".into(), Value::Bool(true));
798    }
799    if virtual_row {
800        obj.insert("is_virtual".into(), Value::Bool(true));
801    }
802
803    // Redact message content + tool input/result bodies.
804    if let Some(msg) = obj.get_mut("message").and_then(Value::as_object_mut) {
805        if let Some(Value::Array(content)) = msg.get_mut("content") {
806            for block in content.iter_mut() {
807                redact_content_block(block, state);
808            }
809        } else if let Some(Value::String(_)) = msg.get("content") {
810            msg.insert("content".into(), Value::String("<redacted-text>".into()));
811        }
812        // Opaque-map the nested message id.
813        if let Some(mid) = msg.get("id").and_then(Value::as_str).map(str::to_string) {
814            msg.insert("id".into(), Value::String(state.opaque("msg", &mid)));
815        }
816    }
817
818    // Defence-in-depth: scrub account fields and home paths from any
819    // string anywhere in the transformed tree before serialising.
820    // Catches leaks in fields the structural redactor doesn't enumerate
821    // (Unknown blocks' arbitrary string fields, MCP tool names, server
822    // error messages, etc.).
823    redact_account_recursive(&mut v);
824    scrub_paths_recursive(&mut v);
825
826    let out = serde_json::to_string(&v).map_err(|e| format!("json serialise: {e}"))?;
827    Ok(Some(out))
828}
829
830fn redact_content_block(block: &mut Value, state: &mut RedactState) {
831    let Some(obj) = block.as_object_mut() else {
832        return;
833    };
834    let ty = obj.get("type").and_then(Value::as_str).unwrap_or("").to_string();
835    match ty.as_str() {
836        "text" => {
837            if let Some(Value::String(t)) = obj.get("text") {
838                let bytes = t.len();
839                obj.insert("text".into(), Value::String(format!("<redacted-text {bytes}b>")));
840            }
841        }
842        "thinking" => {
843            if let Some(Value::String(t)) = obj.get("thinking") {
844                let bytes = t.len();
845                obj.insert(
846                    "thinking".into(),
847                    Value::String(format!("<redacted-thinking {bytes}b>")),
848                );
849            }
850            // `signature` is a signed opaque token - redact fully.
851            if obj.contains_key("signature") {
852                obj.insert("signature".into(), Value::String("<redacted-signature>".into()));
853            }
854        }
855        "tool_use" => {
856            // Keep `name` (informational), scrub `id` + `input`.
857            if let Some(id) = obj.get("id").and_then(Value::as_str).map(str::to_string) {
858                obj.insert("id".into(), Value::String(state.opaque("tool_use", &id)));
859            }
860            if obj.contains_key("input") {
861                obj.insert("input".into(), serde_json::json!({"_redacted": true}));
862            }
863        }
864        "tool_result" => {
865            if let Some(id) = obj.get("tool_use_id").and_then(Value::as_str).map(str::to_string) {
866                obj.insert("tool_use_id".into(), Value::String(state.opaque("tool_use", &id)));
867            }
868            if let Some(Value::String(c)) = obj.get("content") {
869                let bytes = c.len();
870                obj.insert(
871                    "content".into(),
872                    Value::String(format!("<redacted-tool-result {bytes}b>")),
873                );
874            } else if let Some(Value::Array(arr)) = obj.get_mut("content") {
875                // Structured tool_result content - redact each sub-block.
876                for sub in arr.iter_mut() {
877                    redact_content_block(sub, state);
878                }
879            }
880        }
881        "document" | "image" => {
882            // Anthropic API document/image block. Shape:
883            // `{"type":"<kind>","source":{"type":"base64",
884            //   "media_type":"<mime>","data":"<base64 bytes>"}}`.
885            // The `data` field can be megabytes - replace with a
886            // size-tagged stub (`<redacted-<kind>-data Nb>`) so the
887            // fixture records the shape but not the content. Keep
888            // `media_type` so fixtures document what kind of
889            // attachment was present.
890            if let Some(Value::Object(src)) = obj.get_mut("source") {
891                if let Some(Value::String(d)) = src.get_mut("data") {
892                    let bytes = d.len();
893                    *d = format!("<redacted-{ty}-data {bytes}b>");
894                }
895                for k in ["text", "content", "url"] {
896                    if let Some(Value::String(s)) = src.get_mut(k) {
897                        let bytes = s.len();
898                        *s = format!("<redacted-{ty}-{k} {bytes}b>");
899                    }
900                }
901            }
902        }
903        _ => {
904            // Unknown block type - keep shape, scrub any obvious
905            // text-carrying fields to be safe.
906            for (k, val) in obj.iter_mut() {
907                if let Value::String(s) = val
908                    && (k == "text" || k == "content" || k == "message")
909                {
910                    *val = Value::String(format!("<redacted-{} {}b>", k, s.len()));
911                }
912            }
913        }
914    }
915}
916
917/// Transform + redact an entire persistence .jsonl file into
918/// stream-json-shaped output. One `TraceLog.entries` item per
919/// decodable line, all tagged `"in"` (CLI → SDK direction).
920///
921/// # Errors
922///
923/// Returns the first line error if any non-persistence line refuses to
924/// transform. Persistence-only lines are silently skipped.
925pub fn redact_session_file(body: &str) -> Result<Vec<String>, String> {
926    let mut state = RedactState::default();
927    let mut out = Vec::new();
928    for line in body.lines() {
929        let line = line.trim();
930        if line.is_empty() {
931            continue;
932        }
933        match transform_persistence_line(line, &mut state) {
934            Ok(Some(transformed)) => out.push(transformed),
935            Ok(None) => {}
936            Err(e) => return Err(e),
937        }
938    }
939    Ok(out)
940}
941
942/// Handy bundle: given a persistence file path, produce its redacted
943/// stream-json lines plus a small summary string for logs.
944///
945/// # Errors
946///
947/// IO errors or per-line transform errors.
948pub fn redact_session_path(path: &std::path::Path) -> Result<(Vec<String>, String), String> {
949    let body = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?;
950    let lines = redact_session_file(&body)?;
951    let summary = format!(
952        "{}: in={} out={}",
953        path.display(),
954        body.lines().filter(|l| !l.trim().is_empty()).count(),
955        lines.len()
956    );
957    Ok((lines, summary))
958}
959
960#[cfg(test)]
961mod tests {
962    use super::*;
963    use serde_json::json;
964
965    #[test]
966    fn scrub_paths_recursive_value_only_no_keys() {
967        let mut v = json!({"k": "/Users/alice/foo.txt"});
968        scrub_paths_recursive(&mut v);
969        assert_eq!(v, json!({"k": "<redacted-home>/foo.txt"}));
970    }
971
972    #[test]
973    fn scrub_paths_recursive_object_keys_rewritten() {
974        let mut v = json!({"/Users/alice/foo.txt": 1, "harmless": 2});
975        scrub_paths_recursive(&mut v);
976        let obj = v.as_object().unwrap();
977        assert!(obj.contains_key("<redacted-home>/foo.txt"));
978        assert!(obj.contains_key("harmless"));
979        assert_eq!(obj.len(), 2);
980    }
981
982    #[test]
983    fn scrub_paths_recursive_collisions_collapse_last_wins() {
984        // Two distinct source paths redact to the same opaque key.
985        // Privacy-preserving by design - the redactor must not
986        // expose distinguishing path info via key uniqueness.
987        let mut v = json!({
988            "/Users/alice/foo": "a",
989            "/Users/bob/foo": "b",
990        });
991        scrub_paths_recursive(&mut v);
992        let obj = v.as_object().unwrap();
993        assert_eq!(obj.len(), 1);
994        assert!(obj.contains_key("<redacted-home>/foo"));
995    }
996
997    #[test]
998    fn scrub_paths_recursive_nested_object_keys_and_values() {
999        let mut v = json!({
1000            "/Volumes/data/disk": {
1001                "inner_key_/home/x/foo": "/Users/x/y/z",
1002                "ok": 42
1003            }
1004        });
1005        scrub_paths_recursive(&mut v);
1006        // Outer key rewritten.
1007        let outer = v.as_object().unwrap();
1008        let inner = outer.get("<redacted-home>/disk").unwrap().as_object().unwrap();
1009        // Inner key rewritten too.
1010        assert!(inner.contains_key("inner_key_<redacted-home>/foo"));
1011        assert!(inner.contains_key("ok"));
1012        // Inner string value rewritten.
1013        assert_eq!(inner["inner_key_<redacted-home>/foo"], json!("<redacted-home>/y/z"));
1014    }
1015
1016    #[test]
1017    fn scrub_paths_recursive_array_values() {
1018        let mut v = json!(["/Users/alice/x", "ok", {"k": "/home/bob"}]);
1019        scrub_paths_recursive(&mut v);
1020        let arr = v.as_array().unwrap();
1021        assert_eq!(arr[0], json!("<redacted-home>/x"));
1022        assert_eq!(arr[1], json!("ok"));
1023        assert_eq!(arr[2], json!({"k": "<redacted-home>"}));
1024    }
1025
1026    /// Every key in the tree, at every depth.
1027    fn key_count(v: &Value) -> usize {
1028        match v {
1029            Value::Object(o) => o.len() + o.values().map(key_count).sum::<usize>(),
1030            Value::Array(a) => a.iter().map(key_count).sum(),
1031            _ => 0,
1032        }
1033    }
1034
1035    /// One line standing in for a whole trace. Every caller's fixture
1036    /// round-trips, so this only exercises the structural path; the
1037    /// raw-text path is covered by the lossy-float test below. The key
1038    /// count guards against dropping a field, which no
1039    /// what-was-removed assertion can see.
1040    fn redact_one(line: &str) -> Value {
1041        let out =
1042            WireRedactor::for_trace([line]).expect("discovers").redact_line(line).expect("redacts");
1043        let after: Value = serde_json::from_str(&out).expect("redacted line stays valid JSON");
1044        let before: Value = serde_json::from_str(line).expect("fixture is valid JSON");
1045        assert_eq!(
1046            key_count(&before),
1047            key_count(&after),
1048            "redaction changed the key count\n  in:  {line}\n  out: {out}"
1049        );
1050        after
1051    }
1052
1053    #[test]
1054    fn account_fields_are_placeholdered_and_keys_kept() {
1055        let v = redact_one(
1056            &json!({"response": {"response": {"account": {
1057                "email": "someone@example.com",
1058                "organization": "Some Org",
1059                "subscriptionType": "max",
1060                "apiProvider": "anthropic",
1061            }}}})
1062            .to_string(),
1063        );
1064        assert_eq!(
1065            v["response"]["response"]["account"],
1066            json!({
1067                "email": "<redacted-email>",
1068                "organization": "<redacted-organization>",
1069                "subscriptionType": "max",
1070                "apiProvider": "anthropic",
1071            })
1072        );
1073    }
1074
1075    /// The shapes an `account` field turns up in besides a flat object.
1076    #[test]
1077    fn account_redaction_reaches_bare_nested_and_listed_forms() {
1078        let v = redact_one(
1079            &json!({
1080                "bare": {"account": "someone@example.com"},
1081                "nested": {"account": {"profile": {"email": "someone@example.com"}}},
1082                "listed": {"accounts": [{"email": "someone@example.com"}]},
1083            })
1084            .to_string(),
1085        );
1086        assert_eq!(v["bare"]["account"], json!("<redacted-account>"));
1087        assert_eq!(v["nested"]["account"]["profile"]["email"], json!("<redacted-email>"));
1088        assert_eq!(v["listed"]["accounts"][0]["email"], json!("<redacted-email>"));
1089    }
1090
1091    #[test]
1092    fn profile_dir_is_redacted_home_relative_and_absolute() {
1093        let v = redact_one(
1094            &json!({"text": "see ~/.claude-alt/CLAUDE.md and /Users/alexandra/.claude-gateway1/x"})
1095                .to_string(),
1096        );
1097        assert_eq!(
1098            v["text"],
1099            json!("see ~/<redacted-profile>/CLAUDE.md and <redacted-home>/<redacted-profile>/x")
1100        );
1101    }
1102
1103    #[test]
1104    fn project_slug_form_of_a_home_path_is_redacted() {
1105        let v = redact_one(
1106            &json!({"text": "projects/-Users-alexandra-Projects-forge/memory"}).to_string(),
1107        );
1108        assert_eq!(v["text"], json!("projects/<redacted-home>-Projects-forge/memory"));
1109    }
1110
1111    /// The prose spellings a path regex cannot see, keyed off the home
1112    /// path on a different line of the same trace.
1113    #[test]
1114    fn owner_name_in_prose_is_redacted_from_a_home_path_elsewhere_in_the_trace() {
1115        let path_line = json!({"cwd": "/Users/alexandra/Projects/forge"}).to_string();
1116        let prose_line =
1117            json!({"text": "bundle id `dev.alexandra.app`, repo `alexandra/proxy`"}).to_string();
1118        let redactor =
1119            WireRedactor::for_trace([path_line.as_str(), prose_line.as_str()]).expect("discovers");
1120        let v: Value = serde_json::from_str(&redactor.redact_line(&prose_line).expect("redacts"))
1121            .expect("valid JSON");
1122        assert_eq!(
1123            v["text"],
1124            json!("bundle id `dev.<redacted-user>.app`, repo `<redacted-user>/proxy`")
1125        );
1126    }
1127
1128    /// Over raw text this line captures a name running to end-of-line,
1129    /// which then matches nothing.
1130    #[test]
1131    fn owner_is_discovered_when_the_home_path_ends_the_json_string() {
1132        let line = json!({"cwd": "/Users/alexandra", "note": "ping alexandra"}).to_string();
1133        assert_eq!(redact_one(&line)["note"], json!("ping <redacted-user>"));
1134    }
1135
1136    /// Stderr is what the non-JSON branch exists for, and it puts `:`
1137    /// straight after the path - a captured fragment matches nothing.
1138    #[test]
1139    fn an_owner_is_discovered_from_a_stderr_line_not_a_fragment_of_it() {
1140        let stderr = "EACCES: /Users/alexandra: permission denied";
1141        let prose = json!({"note": "owned by alexandra"}).to_string();
1142        let r = WireRedactor::for_trace([stderr, prose.as_str()]).expect("discovers");
1143        let v: Value = serde_json::from_str(&r.redact_line(&prose).expect("redacts")).unwrap();
1144        assert_eq!(v["note"], json!("owned by <redacted-user>"));
1145    }
1146
1147    /// The slug has to be a discovery source, not only a redaction
1148    /// target.
1149    #[test]
1150    fn an_owner_is_discovered_from_the_slug_spelling_too() {
1151        let slug = json!({"p": "projects/-Users-alexandra-Projects-forge/x"}).to_string();
1152        let prose = json!({"note": "ping alexandra"}).to_string();
1153        let r = WireRedactor::for_trace([slug.as_str(), prose.as_str()]).expect("discovers");
1154        let v: Value = serde_json::from_str(&r.redact_line(&prose).expect("redacts")).unwrap();
1155        assert_eq!(v["note"], json!("ping <redacted-user>"));
1156    }
1157
1158    /// Refused rather than skipped, reporting the length and never the
1159    /// name.
1160    #[test]
1161    fn an_owner_below_the_length_floor_is_refused() {
1162        let line = json!({"cwd": "/home/node"}).to_string();
1163        let err = WireRedactor::for_trace([line.as_str()]).err().expect("must refuse");
1164        assert!(err.contains("4-character"), "{err}");
1165        assert!(!err.contains("node"), "error names the owner: {err}");
1166    }
1167
1168    /// Only a standalone occurrence is a name; inside a longer word it
1169    /// is a coincidence.
1170    #[test]
1171    fn an_owner_inside_a_longer_word_is_left_alone() {
1172        let line = json!({"cwd": "/Users/alexandra", "note": "alexandras alexandra"}).to_string();
1173        assert_eq!(redact_one(&line)["note"], json!("alexandras <redacted-user>"));
1174    }
1175
1176    /// `first.last` home dirs: the word-boundary anchors do NOT cover
1177    /// this, since `\balexandra\b` matches inside `alexandra.bell`, so
1178    /// the longer name must be applied first.
1179    #[test]
1180    fn a_name_that_prefixes_another_does_not_strand_its_tail() {
1181        let short = json!({"cwd": "/Users/alexandra"}).to_string();
1182        let long = json!({"cwd": "/Users/alexandra.bell"}).to_string();
1183        let prose = json!({"note": "alexandra.bell"}).to_string();
1184        let r = WireRedactor::for_trace([short.as_str(), long.as_str(), prose.as_str()])
1185            .expect("discovers");
1186        let v: Value = serde_json::from_str(&r.redact_line(&prose).expect("redacts")).unwrap();
1187        assert_eq!(v["note"], json!("<redacted-user>"));
1188    }
1189
1190    /// Claude Code's plugin-manifest dir is not a profile, and a fixture
1191    /// carrying a plugin path has no PII to lose.
1192    #[test]
1193    fn the_plugin_manifest_dir_is_not_taken_for_a_profile() {
1194        let line = json!({"p": "/tmp/x/.claude-plugin/marketplace.json"}).to_string();
1195        assert_eq!(redact_one(&line)["p"], json!("/tmp/x/.claude-plugin/marketplace.json"));
1196    }
1197
1198    /// Keys go through the same rules as values, not the path rule alone.
1199    #[test]
1200    fn object_keys_get_every_rule_not_just_the_path_one() {
1201        let mut v = json!({"~/.claude-alt/settings.json": 1});
1202        scrub_paths_recursive(&mut v);
1203        assert!(v.as_object().unwrap().contains_key("~/<redacted-profile>/settings.json"));
1204    }
1205
1206    /// The inbound tee records non-JSON stdout too, which no structural
1207    /// rule can reach.
1208    #[test]
1209    fn a_line_that_is_not_json_still_gets_the_text_rules() {
1210        let line = "node:internal/fs: ENOENT /Users/alexandra/.claude-alt/settings.json";
1211        assert_eq!(
1212            WireRedactor::for_trace([line]).expect("discovers").redact_line(line).as_deref(),
1213            Ok("node:internal/fs: ENOENT <redacted-home>/<redacted-profile>/settings.json")
1214        );
1215    }
1216
1217    /// The raw-text path: the float survives while the path is still
1218    /// scrubbed. No `/…` tail on purpose - the path runs into the closing
1219    /// quote, where a class not excluding `"` eats the next field.
1220    #[test]
1221    fn a_redacted_line_that_cannot_round_trip_keeps_its_numbers_and_fields() {
1222        let line = r#"{"total_cost_usd":1.3382134999999997,"cwd":"/Users/alexandra","uuid":"a/b"}"#;
1223        let out =
1224            WireRedactor::for_trace([line]).expect("discovers").redact_line(line).expect("redacts");
1225
1226        assert!(out.contains("1.3382134999999997"), "float was re-encoded: {out}");
1227        assert!(!out.contains("/Users/"), "path survived: {out}");
1228        let after: Value = serde_json::from_str(&out).expect("stays valid JSON");
1229        let before: Value = serde_json::from_str(line).expect("fixture is valid JSON");
1230        assert_eq!(key_count(&before), key_count(&after), "a field was dropped: {out}");
1231        assert_eq!(after["uuid"], json!("a/b"), "the field after the path was eaten: {out}");
1232    }
1233
1234    /// Account fields are only reachable structurally. A re-encode
1235    /// whose token count matches is structure-preserving, so the
1236    /// structural path is safe to take - the line comes back redacted
1237    /// with the float re-spelled to serde_json's canonical form.
1238    #[test]
1239    fn account_fields_on_a_value_faithful_reencode_redact_structurally() {
1240        let line = r#"{"total_cost_usd":1.3382134999999997,"account":{"email":"a@b.co"}}"#;
1241        let out = WireRedactor::for_trace([line])
1242            .expect("discovers")
1243            .redact_line(line)
1244            .expect("value-faithful re-encode redacts");
1245        assert!(!out.contains("a@b.co"), "email survived: {out}");
1246        assert!(
1247            out.contains("1.3382134999999995"),
1248            "the float re-spells to serde_json's canonical parse: {out}"
1249        );
1250        let after: Value = serde_json::from_str(&out).expect("stays valid JSON");
1251        assert_eq!(
1252            after["account"]["email"],
1253            json!("<redacted-email>"),
1254            "the email collapses to its placeholder: {out}"
1255        );
1256    }
1257
1258    /// A re-encode that LOSES structure must refuse: duplicate keys
1259    /// collapse on parse, dropping a number token, so the re-encode
1260    /// would silently lose a field. Fail closed rather than write
1261    /// evidence the CLI never sent.
1262    #[test]
1263    fn account_fields_on_a_structure_losing_reencode_fail_closed() {
1264        let line = r#"{"total_cost_usd":1.5,"total_cost_usd":1.3382134999999997,"account":{"email":"a@b.co"}}"#;
1265        let err = WireRedactor::for_trace([line])
1266            .expect("discovers")
1267            .redact_line(line)
1268            .expect_err("must fail closed");
1269        assert!(err.contains("account"), "{err}");
1270        assert!(!err.contains("a@b.co"), "the error prints the value it refused to write: {err}");
1271    }
1272
1273    /// A volume name is not a user name.
1274    #[test]
1275    fn volume_name_is_not_discovered_as_an_owner() {
1276        let line = json!({"a": "/Volumes/data/x", "b": "data set"}).to_string();
1277        assert_eq!(redact_one(&line)["b"], json!("data set"));
1278    }
1279
1280    /// Re-encoding is lossy on some 17-digit floats, so a line with
1281    /// nothing to redact must come back untouched.
1282    #[test]
1283    fn a_line_with_nothing_to_redact_is_returned_verbatim() {
1284        let line = r#"{"type":"result","total_cost_usd":1.3382134999999997}"#;
1285        let r = WireRedactor::for_trace([line]).expect("discovers");
1286        assert_eq!(r.redact_line(line).as_deref(), Ok(line));
1287    }
1288
1289    #[test]
1290    fn scrub_paths_recursive_no_op_on_clean_input() {
1291        let mut v = json!({"name": "alice", "n": 42, "list": [1, 2, "ok"]});
1292        let original = v.clone();
1293        scrub_paths_recursive(&mut v);
1294        assert_eq!(v, original);
1295    }
1296
1297    /// Run one persistence row through the transform and parse the wire
1298    /// frame back out.
1299    fn transform_row(row: &Value) -> Value {
1300        let out = transform_persistence_line(&row.to_string(), &mut RedactState::default())
1301            .expect("transforms")
1302            .expect("maps to a wire frame");
1303        serde_json::from_str(&out).expect("stays valid JSON")
1304    }
1305
1306    /// The disk gives a row nobody typed four spellings; the wire has one.
1307    /// A corpus row carrying a disk spelling with the mark that would have
1308    /// travelled beside it removed describes a mark set the CLI cannot
1309    /// emit - the wire writes `isSynthetic` from the disk's `isMeta ||
1310    /// isVisibleInTranscriptOnly || isCompactSummary`, and `turnCompanion`
1311    /// never travels.
1312    #[test]
1313    fn disk_synthetic_marks_fold_into_the_wires_spelling() {
1314        // The companion row the CLI writes beside an image paste.
1315        let companion = transform_row(&json!({
1316            "parentUuid": "cef55743-4eec-4553-86f9-68a54e1cb284",
1317            "isSidechain": false,
1318            "promptId": "9b1257b5-1eae-47ce-8ab6-4c80952bca2c",
1319            "type": "user",
1320            "message": {"role": "user", "content": [{"type": "text", "text": "companion notice"}]},
1321            "isMeta": true,
1322            "turnCompanion": true,
1323            "uuid": "a22e6f91-7b16-4586-95e6-88b654b1c928",
1324            "timestamp": "2026-09-27T12:17:30.732Z",
1325            "userType": "external",
1326            "entrypoint": "sdk-cli",
1327            "cwd": "/home/dev/Projects/example-app",
1328            "sessionId": "3f1c0a1e-0000-4000-8000-000000000002",
1329            "version": "2.1.280",
1330            "gitBranch": "main",
1331        }));
1332        assert_eq!(
1333            companion.get("isSynthetic"),
1334            Some(&json!(true)),
1335            "a row the wire stamps must reach the corpus stamped, in the wire's spelling: \
1336             {companion}"
1337        );
1338        for mark in ["isMeta", "turnCompanion", "isCompactSummary", "isVisibleInTranscriptOnly"] {
1339            assert!(
1340                companion.get(mark).is_none(),
1341                "the disk spelling {mark} reached the wire, which has none: {companion}"
1342            );
1343        }
1344
1345        // The compaction summary's spellings, each arm on its own: they
1346        // co-occur on the rows this machine wrote, which would let one
1347        // arm's removal ride the other's stamp.
1348        for mark in ["isVisibleInTranscriptOnly", "isCompactSummary"] {
1349            let mut row = json!({
1350                "type": "user",
1351                "message": {"role": "user", "content": "continued from a previous conversation"},
1352                "uuid": "uuid-summary",
1353            });
1354            row[mark] = json!(true);
1355            let summary = transform_row(&row);
1356            assert_eq!(
1357                summary.get("isSynthetic"),
1358                Some(&json!(true)),
1359                "the predicate's arm {mark} stamps the same wire spelling: {summary}"
1360            );
1361            assert!(
1362                summary.get(mark).is_none(),
1363                "the disk spelling {mark} reached the wire: {summary}"
1364            );
1365        }
1366    }
1367
1368    /// Every other name the disk writes and the wire does not, swept
1369    /// together: each was carried by no live capture, and the CLI's own
1370    /// outbound user-frame literal names none of them. `isVirtual` is the
1371    /// exception that proves the rule - it is the one with a wire
1372    /// spelling, `is_virtual`.
1373    #[test]
1374    fn persistence_only_names_never_reach_the_wire() {
1375        let mut row = json!({
1376            "type": "user",
1377            "message": {"role": "user", "content": "swept row"},
1378            "uuid": "uuid-swept",
1379            "isMeta": true,
1380        });
1381        let disk_only = [
1382            "promptSource",
1383            "turnOrigin",
1384            "imagePasteIds",
1385            "toolDenialKind",
1386            "slug",
1387            "sourceToolUseID",
1388            "queueSkipAttachments",
1389            "queueTranscriptOnly",
1390            "classifierMetaLines",
1391        ];
1392        for name in disk_only {
1393            row[name] = json!("disk value");
1394        }
1395        row["isVirtual"] = json!(true);
1396        let out = transform_row(&row);
1397        for name in disk_only {
1398            assert!(out.get(name).is_none(), "the disk-only name {name} reached the wire: {out}");
1399        }
1400        assert_eq!(
1401            out.get("is_virtual"),
1402            Some(&json!(true)),
1403            "the wire's own spelling carries the virtual mark: {out}"
1404        );
1405        assert!(
1406            out.get("isVirtual").is_none(),
1407            "the disk spelling of the virtual mark reached the wire: {out}"
1408        );
1409        assert_eq!(
1410            out.get("isSynthetic"),
1411            Some(&json!(true)),
1412            "the sweep does not eat the fold: {out}"
1413        );
1414    }
1415
1416    /// The other half of the fold: only a user frame the CLI's predicate
1417    /// holds for is stamped. A row it does not hold for must not grow the
1418    /// key, `turnCompanion` - which the wire does not carry at all - must
1419    /// not stand one in, and a system row's disk mark is dropped without
1420    /// one: the wire's system frames carry no mark (the captured
1421    /// `compact_boundary` is the evidence).
1422    #[test]
1423    fn only_a_user_frame_the_cli_stamps_for_is_marked() {
1424        let unmarked = transform_row(&json!({
1425            "type": "user",
1426            "message": {"role": "user", "content": "typed by hand"},
1427            "uuid": "uuid-typed",
1428        }));
1429        assert_eq!(
1430            unmarked.get("isSynthetic"),
1431            None,
1432            "a row the CLI does not stamp must not grow the key: {unmarked}"
1433        );
1434
1435        let companion_only = transform_row(&json!({
1436            "type": "user",
1437            "message": {"role": "user", "content": "companion text"},
1438            "turnCompanion": true,
1439            "uuid": "uuid-companion",
1440        }));
1441        assert_eq!(
1442            companion_only.get("isSynthetic"),
1443            None,
1444            "turnCompanion is a disk field and stamps nothing: {companion_only}"
1445        );
1446        assert!(
1447            companion_only.get("turnCompanion").is_none(),
1448            "the disk field still reached the wire: {companion_only}"
1449        );
1450
1451        let system = transform_row(&json!({
1452            "type": "system",
1453            "subtype": "turn_duration",
1454            "durationMs": 4,
1455            "isMeta": true,
1456            "messageCount": 2,
1457            "uuid": "uuid-turn",
1458        }));
1459        assert_eq!(
1460            system.get("isSynthetic"),
1461            None,
1462            "a system frame carries no synthetic mark on the wire: {system}"
1463        );
1464        assert!(system.get("isMeta").is_none(), "the disk mark still reached the wire: {system}");
1465    }
1466}