ADR 25 — Log-derived ingress redaction
Status: accepted, P7 implementation (2026-09-22)
The context already derives from session log events. Session.Record therefore
is the single context ingress; adding a separate mutable context would weaken
replay. Sanitize payloads there, persist through the log writer's mandatory
redaction pass, and retain only the returned event. Forward sanitized typed
payloads to frontends. Emit redact.event with source sequence and substitution
metadata. No audit event contains the removed value.
Redaction scans decoded JSON strings, including nested tool arguments and JSON-encoded tool output, so unicode escapes and PEM newlines cannot evade it. A collision between redacted object keys fails closed. The writer applies the same controller after custom filters, including for callers outside core. Ingress always uses the default known-class detector; egress allowlists must not permit a credential into context or the log. Entropy-only findings remain unchanged, consistent with the existing ladder principle; L3 is deferred to P8.
User messages enter the log before the plan gate. The planner's direct provider request, system text, and tool schemas use the same controller. Tool execution still receives original arguments and values so guard checks and taint semantics remain intact; sanitization is applied to the contextual and displayed copies.
A credential can span arbitrary model stream chunks. Buffer each response and redact the complete content and thinking before emitting display events. This trades token-by-token display for containment; cancellation still cancels the provider request. Scheme display fragments are similarly buffered until the evaluation or turn completes. A future incremental redactor must prove split-prefix and multiline-key handling before streaming is restored.
Limits: this detects known credential shapes, not every secret or encoding. Standalone Scheme eval/repl are operator tools, outside the conversational boundary. Existing historical logs are not rewritten. The stable binary and bootstrap operator-rule retirement require a separate successful promotion.
Amendment 2026-10-05: editing what the model cannot read
The model cannot read a credential-shaped text, a fake one in a test fixture
included, and so could not match a line holding one with fs-edit: a chat
failed thirty times to change one. ADR 43 has fs-edit match in the text the
model reads and keep the credential a placeholder stands for. Redaction itself
is unchanged: ingress still redacts everything, with the default scanner, and
the log still holds no credential-shaped text.
Amendment 2026-10-05: a credential inside a token run
L1 took each run of token characters (letters, digits and the separators
- _ . / +) as one candidate and matched each known shape against the whole
run. A full stop ending a sentence, a path's slash or a + joined a key to
its neighbours, the run matched no shape, and the key was neither redacted
nor checked: a probe found The bucket key is AKIAABCDEFGHIJKLMNOP. and
The token is ghp_…. unchanged, and the same text in an fs-write asked
nothing. Every place L1 runs was affected, since all of them use this
scanner: the log, what is sent to models and the judge, and the guard's
checks of payloads, command lines and writes.
L1 now looks for a known shape inside a run. A credential of a class begins
at the run's start or after a separator its shape cannot hold, takes the
longest text the shape allows, and may leave only separators (a full stop, a
closing dash) before the next such separator or the run's end. A separator
the shape can hold is no boundary for it: the / of a path ends an OpenAI
key, but the - of a base64url blob does not make -sk-… inside it one,
and the . of a dotted name is no JWT boundary. A run that matches a shape
whole is the same finding as before; a run with no known shape in it is
judged whole for the entropy residual, as before; an allowlisted value is
compared with the credential, not the run. No shape, class, threshold or
allowlist rule changed, and ingress still uses the default scanner.
What it costs: a shape that already matched loose text whole now matches it
as a path component too, so docs/sk- followed by twenty letters, digits,
dashes or underscores is an OpenAI key there as it was standing alone. Paths,
version strings, base64 and base64url blobs, Go symbols and dotted test names
are no new findings (TestSeparatorsMakeNoKeys, TestDottedNamesAreNotSecrets).
The Phase 4 benchmark is unchanged (about 0.09 ms for 64 KB); 64 KB of paths
or base64 scans as fast as before, and a 64 KB run with a separator every
other byte takes 1.2 ms instead of 0.9 ms. Each rule names the separators its
shape holds, and TestJoinsAreWhatTheShapesHold checks them against the
regex. Still missed: a key joined to a letter or digit (AKIA…x), or after
a separator its own shape holds (my_ghp_…). An Anthropic key's shape holds
., so a full stop after one goes into its placeholder.
Amendment 2026-10-05: findings are in order, and the guard judges all of them
Scanner.Scan listed every PEM block first and the token findings after, so a
credential written ahead of a private key came out behind it, and both
redaction loops (Scanner.Redact, redact.Redact), which slice the text from
the end of the last finding to the start of the next, sliced backwards and
panicked. A .env holding an AWS key and then a private key was enough to
crash whatever redacted it: the log writer, ingress, the Jev send path and
fs-edit's view (ADR 43). A token run can also lie inside a block (a PGP
key's Comment: line) or cross its start, since a dash is a token character
("sk-ant-api03-…-----BEGIN"), and those findings overlapped the block's.
Scan now returns the findings in order of appearance and never overlapping,
which its doc comment always said. A block keeps its class and takes in the
tokens that overlap it: its span grows to cover a known token that crosses its
edge, so redaction leaves none of that credential behind. An unknown-entropy
token that overlaps a block is dropped, since nothing redacts or decides on the
residual and growing a block over it would redact text no known class claims.
Nothing is loosened: a block is a finding wherever it was one before, only its
span can be wider.
The guard's TextSecret took the first known finding and judged its class
alone. Blocks used to be first, so a private key was always the class judged;
in order of appearance the first is whatever the model wrote first, and a key
bound for the host would carry a private key after it. The same hole was
already there between two token classes (a bound Anthropic key ahead of an
unbound GitHub token). The guard now judges every known finding of an egress
payload, contained or not, and of text that Outgoing is asked about: all of
them must be bound for the destination, and the first that is not is the one
the refusal names. The command-line check and the in-repository write confirm
act on any known finding and are unchanged.
Amendment 2026-10-16: a failed keyed send names no query
Redaction recognises a secret by its shape, and a key bound at a query:
position need not have one. When such a request failed to send, Go's
*url.Error named the whole address, ?key= and the key included, and that
error became http-request's error, logged and shown to the model; an MCP
server bound at a query position did the same. keychain.SendError now
reduces the address in a send error to its scheme, host and path, and both
keyed senders (Session.Do and the MCP HTTP transport) return their errors
through it. A reply body that repeats the query is outside this: the binding
chose a server that sees the key.