feat(llm): reversible anonymizer at the LLM boundary#3
Merged
Conversation
On escalation the payload carried Trigger.Raw, the ring-buffer context lines, the template, and the source name verbatim — real production log lines with IPs, emails, tokens, hostnames, and home paths. Against local Ollama nothing leaves the box; the moment --llm-url points at a remote provider it does. Add an opt-in --llm-anonymize that masks the outgoing payload with stable, type-tagged placeholders (<IP_1>, <HOST_1>, <TOKEN_1>, …) and restores the model's answer on the way back, so the pipeline, template state, and TUI keep the real values and only the provider sees placeholders. The token->original map is per-request, in memory only, never persisted or shared. Design: - internal/anonymize: a pure, ordered detector set (specific -> general: JWT, AWS key, Bearer/sk-, URL/conn-string creds, email, UUID, IPv6, IPv4, URL-host, bare infra-suffix host, home-dir user). Bare hostnames mask only on private/infra suffixes (internal/local/svc/…): public TLDs would eat Go module paths and stack frames — a whole goroutine dump is now one escalation — for no privacy gain. Every char class excludes <>, so placeholders are inert to later detectors and to the fail-closed re-scan. - Mask FAILS CLOSED: it re-scans its output for deterministic-shape residue and errors rather than leak; the decorator then skips the escalation (card shows unavailable) and never calls the inner backend — no raw send on error. - A decorator Backend (internal/llm/anonymizing.go) owns the seam, wrapping OpenAICompatible only when the flag is on, so default-off is byte-for-byte unchanged. - Config lives under llm: (anonymize, anonymize_suffixes) with --llm-anonymize and --llm-anonymize-suffix. When a non-local --llm-url is used with masking off, a one-line startup notice fires — to stderr in --plain, and to the TUI status bar (stderr would be wiped by the alternate screen). The pipeline's own template masker is untouched and not merged with this. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an Anonymization section to the README: what is masked, that hosts inside URLs/conn-strings are masked regardless of TLD while bare public hostnames in prose are deliberately left alone, that a masking failure skips the escalation, and — plainly — that this is best-effort risk reduction and NOT a guarantee, with the local-Ollama default needing none of it. List --llm-anonymize in the flags table and tick the anonymization item under Epic M7. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Template field reaches the anonymizer AFTER the pipeline's template masker
has rewritten it, so a secret arrives shape-broken:
sk-abcdefghij0123456789XYZ as sk-abcdefghij<NUM>XYZ, AKIAIOSFODNN7EXAMPLE as
AKIAIOSFODNN<NUM>EXAMPLE, a JWT as a run riddled with <NUM>. The detectors are
anchored on an unbroken run of [A-Za-z0-9], so they missed all three and the
surviving characters went over the wire in the clear. Found in manual wire
testing; a class of bug, not one regex.
The four prefix-anchored secret detectors now treat a pipeline placeholder as
another character of the token run. Where a length requirement would otherwise
break ({16,} after sk-, exactly {16} after AKIA/ASIA), the shortened form is
accepted only when the run provably contains a pipeline placeholder, rather
than lowering the threshold and widening prose false positives everywhere.
This does not weaken placeholder inertness: the alternation names the
pipeline's six UNTAGGED placeholders and demands '>' right after the name, so
it can never match this package's own numbered output (<IP_1> has '_' there),
and every widened detector stays anchored on a prefix no placeholder carries.
Asserted from both sides -- no detector matches, and residue() stays clean.
Tests feed the real pipeline.Templatize output rather than hand-written
mangled forms, since the coupling between the two maskers is what caused the
bug. All four cases fail before this change; the headline wire test now
carries pre-templated secrets and catches the leak on the recorded bytes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Masking at the LLM boundary sees both the raw line and the pipeline's pre-masked template, so a host can pick up <HOST_1> from one and <HOST_2> from the other. Cosmetic, not a leak -- but it weakens the "one host recurs" signal enough to be worth stating. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Escalating an event sends the trigger line, the ring-buffer context, the template and the source name to the configured LLM endpoint — verbatim. Against a local Ollama nothing leaves the machine, which is why this was never a v1 blocker; the moment
--llm-urlpoints at a hosted provider, real production logs (IPs, emails, tokens, hostnames, home paths) go over the wire in the clear.This adds an opt-in, reversible anonymizer at that boundary. Default off — with the flag absent the outgoing payload is byte-for-byte what it was before.
How
A decorator
Backend(internal/llm/anonymizing.go) wraps the OpenAI-compatible backend and is constructed only when--llm-anonymizeis on. It masks on the way out and restores on the way in, so the pipeline, the template state and the TUI keep the real values — you are triaging your own system in your own terminal.The masker itself (
internal/anonymize) is a pure package with nollmdependency, deliberately kept separate from the pipeline's template masker: the two mask for different reasons and change for different reasons.<IP_1>,<EMAIL_1>,<TOKEN_1>,<HOST_1>,<UUID_1>,<USER_1>. The tag is load-bearing: the model needs to know "this is an IP" to suggest anything useful, so a generic<REDACTED>would gut the explanation. The same value maps to the same placeholder within a request.sk-→ URL and connection-string credentials → email → UUID → IPv6 → IPv4 → URL-authority host → bare infra host → home-directory username. An email contains a host, a URL contains a host and possibly credentials, an IP is digits and dots — the order decides who wins.--llm-urlis non-local and masking is off: to stderr in--plain, and to the status bar in the TUI, where the alternate screen would otherwise wipe it.Two deliberately narrow choices
Bare hostnames are masked only on private/infra suffixes (
internal,local,lan,corp,svc, …), not on public TLDs. Go panics are full ofgithub.com/...,golang.org/x/...,go.uber.org/zap, and since multiline grouping a whole goroutine dump is a single escalation — masking those would degrade the explanation for no privacy gain. The sensitivity lives indb-01.acme.internal, not ingithub.com. Hosts inside URLs, emails and connection strings are masked regardless of suffix.Home paths mask only the username segment —
/home/<USER_1>/go/pkg/mod/...— because blanking the whole path would gut every Go stack frame.A leak found in manual wire testing
The first implementation masked the trigger line correctly but leaked secret fragments through the
Templatefield. The template reaches the anonymizer after the pipeline masker has rewritten it, so a secret arrives shape-broken —sk-abcdefghij0123456789XYZassk-abcdefghij<NUM>XYZ— and detectors anchored on an unbroken run of[A-Za-z0-9]missed it. The same applies to AWS keys and JWTs: a class of bug, not one regex.Fixed in 23fb180: the four prefix-anchored secret detectors now treat a pipeline placeholder as another character of the token run. The tests feed real
pipeline.Templatizeoutput rather than hand-written mangled strings, since the coupling between the two maskers is what caused the bug in the first place.Not a guarantee
The README says so plainly. Free-text logs can contain anything, so this is best-effort risk reduction. Two known gaps are documented rather than hidden: a bare public hostname in prose is not masked, and one value can pick up two placeholders (one from the raw line, one from the pre-masked template) — cosmetic, not a leak.
Verification
gofmt,go vet,golangci-lint(0 issues),go test ./...,go test -race ./...,go build ./...— all green.sk-token, AWS key, JWT, infra hostname and home path produces a request body containing none of those literals; the same run without the flag shows all of them.