Skip to content

fix(diag): a bundle identifier is redacted by the key that names it - #67

Merged
Behnam-RK merged 5 commits into
mainfrom
fix/redact-identity-and-text-coverage
Sep 13, 2026
Merged

Behnam-RK merged 5 commits into
mainfrom
fix/redact-identity-and-text-coverage

Conversation

@Behnam-RK

@Behnam-RK Behnam-RK commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Closes #65. Closes #63.

#65 — the independent read of 3001e6d

3001e6d merged after the last hosted review of #55 (4112501), so nobody read it.
The prior was that a change of that shape carries a leak. It held — five defects:

  • Two identities could share one placeholder. The ordinal was the count of
    order entries for a kind, so a refusal — which advances the token but appends one
    entry — vacated a number and reissued it. profile-1 refused its way to profile-2,
    then alpha counted one entry and was also given profile-2. One token, two servers,
    and a legend reading 2 distinct profile names → profile-2 … profile-2. A bundle that
    reports two servers as one looks like a working bundle until someone diagnoses with it.
  • The legend advertised tokens that were not in the bundle. A skipped ordinal makes
    the tokens non-consecutive, and a range promises everything between its ends — the same
    lie the old 1..n rendering told, moved to the interior. Range when consecutive, list
    otherwise.
  • knownNames could launder its own tokens, which json.go asserted was impossible.
    The replay sorts longest-first, so a value spelled like a token had the pair
    (profile-1profile-2) applied to text the replay had just written.
  • placeholderRe had lost the iface kind — it is generated from one list now.
  • ifaceAttrRe matched tunnel=, which nothing in the tree writes.

#63 — both halves, because the issue's half alone does not reach it

The issue proposed fixing this at the source: stop interpolating identifiers into prose.
That is necessary and not sufficient — the entries that actually leaked are
rules-preview.txt and log.txt, a rendered pf/nft ruleset and slog records. A pf rule
carries an interface as pass out quick on { nordlynx } because that is what a pf rule
is, and an OS error string is written by the OS.

So both:

  • knownNames now replays interface names, which nothing did. nordlynx was shipping
    verbatim in a default redacted bundle from the rendered rulesets, doctor's prose,
    state.json's tunnel detail and the log. reserved gains vpn, tunnel and
    wireguard first — keepIface keeps only a generic stem plus digits, so an interface
    can legitimately be called those, and the replay would have rewritten dezhban's own words.
  • Text is now the replay plus the shape passes; shapes is what a caller says when it
    wants the shape passes alone. Every external caller wanted the replay, so a second door
    would only have left the wrong one reachable.
  • At the source, doctor.json's details carry the identifier as a field under a key the
    walk already knows, with both renderers composing the sentence. state.json publishes
    one entry per tunnel interface — a comma-joined name minted one token for the pair
    and stopped keepIface seeing the generic half as the kernel's.

ADR-0016 records why prose keeps
its identifiers where we do not author the text, so the next round does not re-litigate it
from issue comments.

Review loop

Four rounds ran over this branch. Every defect after round 0 was in code the loop itself
wrote
— the branch's own behaviour has been clean for three reviewer rounds. Naming them
rather than absorbing them:

Round Reviewer Scope Findings Pre-existing Loop-introduced Defects fixed
0 me, before asking whole branch 3 0 3 3
1 read-only subagent whole branch 4 1 3 0
2 read-only subagent what the loop wrote 6 0 6 0
3 me + read-only subagent the slice round 2 missed 2 0 2 1
4 me + read-only subagent prose vs code, whole branch 1 1 0 1

Regressions the loop introduced and then fixed:

  • rollback assigned the checkpoint's own map, so the Redactor mutated it on the next mint.
  • The app's Overview rendered up (up) and read .first, hiding every interface but
    one once state.json started publishing one entry each.
  • Two tests did not bite: one passed against the old two-group ifaceAttrRe, and one
    passed only because an earlier row of its own table had minted first.
  • A comment claimed an invariant "cannot" break and then guarded against it breaking.

Round 4 covered a dimension the first three never did — whether the prose this branch
ships is true of the code — and found one real defect in the page that is compiled into
the macOS app: docs/usage/cli.md claimed "a token is never the spelling of a name in
the bundle". False in the residual this package already documents, and actively
misleading: an interface called profile-1 ships verbatim while the legend beside it
says profile-1 is a redacted profile name, so the one thing a reader can do with it is
read an interface name as a redaction. The page now states the guarantees that hold and
the case that is not covered, pinned by TestANameSpelledLikeAnExistingTokenKeepsItsSpelling.

One finding was declined with evidence: scoping the minted guard by kind. It reads as
a tightening and is a regression — a name that is both a profile and an endpoint is replayed
as profile-1 into host=…, and a kind-scoped guard mints host-2 for it: a token standing
for a token, plus a phantom legend row. Recorded in ADR-0016's Risks and pinned by
TestAReplayedTokenIsNotReMintedUnderAnotherKind.

Verification

task check, GOOS=linux/windows go vet, swift test (270, was 263). Every new test was
run against the code it guards to confirm it fails there.

Plus a real dezhban report over a fixture carrying a vendor interface, a single-label
endpoint and a profile literally named profile-1:

  • nothing leaks — grep -ri 'nordlynx\|mullvad\|protonvpn' over the unpacked bundle is empty
  • rules-preview.txt reads pass out quick on { iface-1 utun4 }
  • the real profile named profile-1 appears nowhere, and no token stands for two identities
  • every JSON entry parses; utun4, lo0, private subnets, the posture strings and doctor's
    check names all survive
  • --include-network still returns all of it, and says so

Not in scope

control.Group is still written into doctor prose unredacted. It is the same value
config.json ships in the clear, so redacting it only in one place would be incoherent —
it needs its own decision about whether unix group names are identities.

🤖 Generated with Claude Code

Behnam-RK and others added 4 commits September 13, 2026 10:21
Closes #65 and #63.

#65 asked for an independent read of 3001e6d, which merged after the last
hosted review of #55 and so was never read. The prior was that a change of
that shape carries a leak. It held: five defects, and the first is the worst
kind.

Two identities could share one placeholder. The ordinal was derived by
COUNTING order entries of a kind, so a refusal — which advances the token but
appends one entry — vacated a number and handed it to the next value.
`profile-1` refused its way to `profile-2`, then `alpha` counted one entry and
was given `profile-2` as well. One token, two servers, and a legend reading
"2 distinct profile names -> profile-2 ... profile-2". The counter is
monotonic now, a candidate is refused if it is any value the bundle carries,
and a discarded pass rolls the counter back with the rest.

The legend no longer renders a range across a hole. A refused ordinal makes
the tokens non-consecutive, and a range promises everything between its ends —
the same lie the 1..n rendering told, moved to the interior. Range when they
run consecutively, list when they do not.

placeholderRe had drifted: the iface kind was added with its own mint path and
the pattern was not. It is generated from one list of kinds now, and that
matters because it became a guard rather than a comment.

knownNames could launder its own tokens, which json.go asserted it could not.
The replay sorts longest-first, so a value SPELLED like a token had the pair
(profile-1 -> profile-2) applied to text the replay had just written. A value
matching placeholderRe is skipped; leaving it verbatim costs nothing, since by
its spelling it names nobody.

ifaceAttrRe also matched `tunnel=`, which nothing in the tree writes.

#63 was the gap #55 left open: Text ran no literal-name pass, so
rules-preview.txt and log.txt had no coverage of names the bundle had already
replaced. The issue proposed fixing it at the source. That is necessary and
not sufficient — the entries that leak are a rendered pf/nft ruleset and slog
records, and `pass out quick on { nordlynx }` cannot carry an interface as
anything but a bare word. So both halves:

knownNames now replays interface names, which nothing did. `nordlynx` was
shipping verbatim in a default bundle from the rendered rulesets, doctor's
prose, state.json's tunnel detail and the log. reserved gains vpn, tunnel and
wireguard first — keepIface keeps only a generic stem plus digits, so an
interface can be called those, and the replay would have rewritten dezhban's
own words. Text is now the replay plus the shape passes, and shapes is what a
caller says when it wants the shape passes alone; every external caller wanted
the replay, so a second door would only have left the wrong one reachable.

Guarding the mint rather than each pass: with the replay running first, a
shape pass is handed a token, and placeholder refuses to mint for one.

And at the source, doctor.json's details carry the identifier as a field under
a key the walk already knows, with both renderers composing the sentence. The
discover check names the connected VPN once, as data, instead of after every
line. state.json publishes one entry per tunnel interface — a comma-joined
name minted ONE token for the pair and stopped keepIface seeing the generic
half as the kernel's.

ADR-0016 records why the prose keeps its identifiers where we do not author
it, so the next round does not re-litigate it from the issue comments.

Verified: task check, GOOS=linux/windows go vet, swift test (265), and a real
`dezhban report` over a fixture carrying a vendor interface, a single-label
endpoint and a profile called `profile-1` — nothing leaks, rules-preview.txt
reads `on { iface-1 utun4 }`, every JSON entry parses, the legend names only
tokens that are in the bundle, and --include-network still returns all of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nged

A review loop over the previous commit. Three defects in its own new code, none
in the branch's behaviour, plus two tests that did not bite.

rollback assigned the checkpoint's own map, so the Redactor mutated it on the
next mint and the checkpoint stopped describing the state it was taken at — a
second rollback from it restored whatever had happened since. Copied now.

The Overview row paired the up/down word with a tunnel detail that no longer
repeats the interface name, so it rendered "up (up)", and it read `.first`,
which hides every interface but one now that state.json publishes one entry
each. PostureUI.tunnelSummary owns that reading, in Core where it is testable.

TestTheInterfaceAttrKeepsItsKeyAndItsQuoting passed against the old two-group
pattern too, so the `tunnel=` removal was pinned by nothing. Re-adding the
alternative now fails by name: `tunnel=something-else` comes back
`iface=iface-2`, which is both the over-claim and the silently wrong key.

The mint guard stays CROSS-KIND, and that is now written down where someone
about to "tighten" it will read it. Scoping it by kind was proposed and is a
regression: a name that is both a profile and an endpoint is replayed as
`profile-1` into `host=`, and a kind-scoped guard mints `host-2` for it — a
token standing for a token, and a legend counting a hostname that is nowhere in
the bundle. TestAReplayedTokenIsNotReMintedUnderAnotherKind fails against it.

Also: the residual in the package comment now says the collision holds across
kinds, which is what made it read as covering only one; rollback's rebuild
states the order/seen invariant and refuses an empty token, since "" in minted
would make placeholder return early and ship the next empty value unredacted;
and doctorDetail.line is pinned against Swift's DoctorDetail.line by the same
ten-case table on both sides, so two renderers cannot drift.

Verified: task check, swift test (270, was 265), and each new test run against
the code it guards to confirm it fails there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… fix

The decision the review loop settled, in the record rather than only in a code
comment — it reads as a thing to tighten, and the next reader will otherwise
re-derive the regression from scratch. Plus the CHANGELOG bullet for the
Overview row, which is user-visible and was missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 3 of the review loop, over the slice the loop itself wrote after round 2
read the tree. No defect in the branch; two in the loop's own output.

The rebuild in rollback claimed the seen lookup "cannot come back empty" and
then guarded against exactly that. Both cannot be true. placeholder is the only
place that appends to order and it writes seen in the same breath, so the
invariant is absolute and the guard was dead code reading as doubt about it —
and it took the wrong branch for the case it imagined: skipping a token leaves
it out of minted, and a token not in minted is one a later pass mints a second
token for. That is the split-identifier failure placeholderRe exists to prevent.

Nothing covered the minted/values half of the rebuild at all. Deleting it now
fails by name: the surviving token is re-minted as profile-2 and the legend
counts three identities where the bundle carries two.

And the `tunnel=nordlynx` case passed only because an earlier row of its own
table had minted iface-1 first — reordering the rows would have changed what it
asserted without changing how it read. Split out, with the mint seeded
explicitly and the fresh-Redactor half added, so the removal of `tunnel=` is
pinned from both directions.

Not fixed, deliberately: name("") mints a token for an empty string when called
directly. Unreachable through JSON — the walk leaves empty values alone — and
pre-existing, since name is untouched here. A round that grows its own scope is
the failure this loop guards against.

Verified: task check, swift test (270), and each test run against the code it
guards to confirm it fails there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Behnam-RK Behnam-RK added the run-ci Run CI on this PR (gates .github/workflows/ci.yml) label Sep 13, 2026
… deliver

Round 4 of the review loop, over a dimension the first three never touched:
whether the prose this branch ships is true of the code it ships. One finding,
in the page that is compiled into the macOS app and read offline.

docs/usage/cli.md claimed "a token is never the spelling of a name in the
bundle, and no two identities ever share a token". The second half is enforced.
The first is false in the case this package already documents as a residual: a
name spelled like a token ALREADY MINTED keeps its spelling, because the token
is in entries that are written. An interface called `profile-1`, on a host where
`profile-1` is some profile's placeholder, ships verbatim — and the legend beside
it says `profile-1` is a redacted profile name, so the one thing a reader can do
with it is read an interface name as a redaction. That is worse than saying
nothing, and it is exactly the failure this package's own rule names: it must
never claim to have redacted something it did not.

The page now states the guarantees that hold — no two identities share a token,
and a token is never the name it replaced — and then states the case that is not
covered, including what to do about it (do not name things `kind-number`).

Pinned by TestANameSpelledLikeAnExistingTokenKeepsItsSpelling, so the residual
cannot drift away from the two documents that now describe it. Pinned rather
than only written down because the fix that suggests itself — scoping the mint
guard by kind — is the regression an earlier round already rejected.

Verified: task check, swift test (270 — this round added a Go test, not a Swift one), go test ./... clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Behnam-RK
Behnam-RK force-pushed the fix/redact-identity-and-text-coverage branch from 53caf52 to fa06a34 Compare September 13, 2026 07:32
@Behnam-RK
Behnam-RK merged commit 87fb7b1 into main Sep 13, 2026
6 checks passed
@Behnam-RK
Behnam-RK deleted the fix/redact-identity-and-text-coverage branch September 13, 2026 07:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-ci Run CI on this PR (gates .github/workflows/ci.yml)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

redact: internal/redact wants a fresh read on main redact: Text has no literal-name pass, so log.txt and rules-preview.txt can leak names

1 participant