Expand description
Session-persistence → stream-json transformer + PII redactor.
Claude Code persists each session as JSONL under
$CLAUDE_CONFIG_DIR/projects/<slug>/<session-id>.jsonl, overlapping
the wire protocol in broad shape but adding camelCase
persistence-only fields and dropping wire-only frames.
transform_persistence_line converts one such line to wire shape;
both it and WireRedactor then redact.
Most rules are SPELLING rules - home paths, .claude-<profile>
directories (.claude-plugin excepted, it is a plugin manifest),
-Users-<name>- project slugs, and the home-directory owner as a bare
word. Those recognise nothing by meaning, so a check written from them
can only confirm they ran, and prose in a captured tool result is
not redacted at all. Read a new capture before committing it.
Four rules are instead value-blind, replacing a field wholesale
rather than rewriting spellings inside it: everything non-categorical
under account, the body of a hook_response frame, the command /
skill inventory, and an mcpServers[].config blob. Each is a field
where no spelling rule could ever be enough - a hook is an arbitrary
local program, and its output on this repo’s own captures was the
author’s entire cross-project memory index, in prose, which every
spelling rule passed through untouched.
Prefer that shape when adding a rule. A fixed-point gate over this module can only ever check that a capture agrees with these rules, so its reach is bounded by them: it is strengthened by a rule that recognises nothing, never by a longer list of things to recognise.
Two rules are entry-point specific: the owner rule needs a whole
trace to discover the name, so only WireRedactor has it; message
bodies, tool inputs and results are stubbed only by
transform_persistence_line, which also drops the
persistence-only fields rather than tokenising them.
Deterministic: one input line always gives the same output line.
Re-exports§
use std::collections::HashMap;🔒use std::str::FromStr;🔒use std::sync::OnceLock;🔒use serde_json::Value;🔒
Structs§
- Redact
State - Per-run state so that each distinct
session_id/ uuid gets a stable opaque token. Reused across every line in the same transformation pass so references inside a single session stay consistent. - Wire
Redactor - Redacts one captured wire trace. Whole-trace rather than per-line because the identity also leaks as a bare name in prose, which is only recognisable once a home path elsewhere in the file supplies it - from the trace, so the same file redacts identically on any machine.
Constants§
- ACCOUNT_
CATEGORICAL_ 🔒KEYS - Account keys whose value is a categorical CLI label, not personal
data: the corpus records two distinct
subscriptionTypelabels and collapsing them would delete a wire fact the fixture exists to record. Only keys observed nested under an account object belong here - an exemption that never fires just risks leaving a real value. - COMMAND_
ENTRY_ 🔒KEYS - Per-entry fields of a command inventory entry.
argumentHintis only stubbed when already present and non-empty: the parser collapses an empty hint toNone, so inventing one would record a wire fact that never happened. - CONTROL_
INVENTORY_ 🔒KEYS - Inventory arrays a
control_responsecan carry, and what each holds:commandsfrom the command-list reply,skillFrontmatterfrom the context-usage reply. Both are keyed inside that frame rather than globally, which is what keeps a same-named field elsewhere on the wire out of scope. - HOOK_
BODY_ 🔒KEYS - Body fields of a
hook_responseframe. A hook is an arbitrary local program, so these carry whatever the capture machine happened to print, which on this repo’s own captures was the author’s entire cross-project memory index. - HOOK_
BODY_ 🔒PLACEHOLDER - What replaces a hook body. Non-empty, so a capture cannot pass the fixed-point gate by having merely been emptied by hand.
- MCP_
CONFIG_ 🔒CATEGORICAL_ KEYS - Keys inside an
mcpServers[].configblob whose value is a CLI label rather than local configuration.typeis the transport discriminant (stdio/http/sse/claudeai-proxy), which the fixture exists to record. - MCP_
CONFIG_ 🔒PLACEHOLDER - What replaces a value inside an
mcpServers[].configblob. - MIN_
OWNER_ 🔒LEN - Shortest owner name scrubbed as a bare token; a shorter one is an
error, not a skip. This does not make token scrubbing safe -
assistantandpermissionclear it - but a collision above the floor fails loudly on replay, and below it would not. - PLUGIN_
MANIFEST_ 🔒DIR - A plugin manifest directory, not a profile -
profile_regexwould otherwise take it for one.
Functions§
- collect_
owners 🔒 - Collect owner names from both spellings of a home path in
s: the path and the project slug. - collect_
owners_ 🔒recursive - Collect owner names from every string a parsed line holds. Reading
parsed values rather than raw text matters: over raw text
{"cwd":"/Users/ada","type":"system"}captures a name running to end-of-line, which then matches nothing. - needs_
scrub 🔒 - True when any string-level rule would rewrite
s. - number_
token_ 🔒counts_ match - True when both lines carry the same NUMBER of number literals. A count change means the re-encode lost or gained structure - duplicate keys collapse on parse - and that still refuses; equal counts pass, accepting serde_json’s canonical re-spelling.
- number_
tokens 🔒 - Number literals appearing outside string literals, in order. With
serde_json’s
preserve_order, two serialisations of the sameValuecan only differ in these, so the number of these tokens is what decides whether a re-encode lost structure. Values are parsed to f64 only to keep the sequence numbers, not compared: a 17-digit float’s parse drifts by one ulp, so equality would reject the very re-spelling this gate exists to allow. - owner_
regex 🔒 - Captures the home-directory OWNER name out of an absolute home path.
Narrower than
path_regexin two ways: no/Volumes/arm, because a volume name is not a user name, and a user-name character class, because stderr puts:or a quote straight after the path and a captured fragment then matches nothing. - path_
regex 🔒 - Home-path prefixes, replaced by
<redacted-home>with the path tail kept. The classes must exclude": these patterns also run over raw line text, where a class that swallows the delimiter eats the field that follows. That is also what keeps the,they DO match harmless - drop the"again and the comma inclusion becomes a field-eater. - profile_
regex 🔒 - A
claudeprofile directory name, whichpath_regexcannot reach: it replaces only the home prefix, and~/.claude-althas no prefix at all. Bare.claudeis the default config dir, so it is not matched. - redact_
account_ 🔒field - Redact one value under an account field.
keynames whatever it hung off, so a bare"account": "…"and a nested"account": {"profile": {"email": …}}both get a naming placeholder. - redact_
account_ 🔒recursive - Placeholder every non-categorical string under
account/accounts, keeping the keys so the fixture still records the CLI’s shape. Structural rather than value-matching, so a field the CLI adds later is redacted before anyone notices it leaked. - redact_
command_ 🔒inventory_ recursive - Stub the command / skill inventory the CLI reports for whatever is
installed on the capture machine:
slash_commandsandskillsonsystem/init,commandsonsystem/commands_changed, and thecommandsreply carried by acontrol_response. - redact_
content_ 🔒block - redact_
hook_ 🔒body_ recursive - Stub the body of every
hook_responseframe, keeping the keys and every other field so the fixture still records the CLI’s shape. - redact_
mcp_ 🔒config_ recursive - Stub the
configblob on everymcpServers[]entry, keeping the keys and every sibling field. - redact_
session_ file - Transform + redact an entire persistence .jsonl file into
stream-json-shaped output. One
TraceLog.entriesitem per decodable line, all tagged"in"(CLI → SDK direction). - redact_
session_ path - Handy bundle: given a persistence file path, produce its redacted stream-json lines plus a small summary string for logs.
- replace_
owners 🔒 - Apply every owner pattern to
s. - scrub_
owner_ 🔒tokens_ recursive - Replace each discovered owner name with
<redacted-user>in string VALUES only. Keys are left alone: an owner name is never a wire key, and rewriting one would change the shape rather than a value. - scrub_
paths_ 🔒recursive - Recursively walk a JSON value and rewrite every absolute home path
segment to
<redacted-home>. Defence-in-depth on top of the structural redaction (which stripstext/tool_use_input/tool_result_content); catches any path leak that survives in less-common fields. - scrub_
text 🔒 - Apply every string-level rule to
s. Ordered widest-first so an absolute path loses its home prefix before its profile segment. - slug_
regex 🔒 - The project-slug spelling of a home path, which Claude Code mints by
turning
/into-(-Users-<name>-Projects-forge). Captures the name so discovery can harvest it as well as redact it. - stub_
inventory 🔒 - Replace each entry of one inventory array, whichever of the two shapes it uses: a bare name string, or an object carrying a name plus local prose about it. A non-array is left alone.
- stub_
mcp_ 🔒config - Replace every non-categorical string inside one config blob, at any depth, keeping array lengths and object keys.
- stub_
nested_ 🔒commands - Find every inventory array under a
control_responseand stub it. Scoped to that frame by the caller. - transform_
persistence_ line - Transform one persistence-format JSONL line into a stream-json
wire-shape line. Returns
Ok(None)for entries that don’t map (persistence-only types likefile-history-snapshot,attachment,last-prompt).