fix(diag): a bundle identifier is redacted by the key that names it - #67
Merged
Merged
Conversation
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>
… 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
force-pushed
the
fix/redact-identity-and-text-coverage
branch
from
September 13, 2026 07:32
53caf52 to
fa06a34
Compare
This was referenced Sep 13, 2026
Closed
Closed
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.
Closes #65. Closes #63.
#65 — the independent read of
3001e6d3001e6dmerged 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:
orderentries for a kind, so a refusal — which advances the token but appends oneentry — vacated a number and reissued it.
profile-1refused its way toprofile-2,then
alphacounted one entry and was also givenprofile-2. One token, two servers,and a legend reading
2 distinct profile names → profile-2 … profile-2. A bundle thatreports two servers as one looks like a working bundle until someone diagnoses with it.
the tokens non-consecutive, and a range promises everything between its ends — the same
lie the old
1..nrendering told, moved to the interior. Range when consecutive, listotherwise.
knownNamescould launder its own tokens, whichjson.goasserted was impossible.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.placeholderRehad lost theifacekind — it is generated from one list now.ifaceAttrRematchedtunnel=, 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.txtandlog.txt, a rendered pf/nft ruleset and slog records. A pf rulecarries an interface as
pass out quick on { nordlynx }because that is what a pf ruleis, and an OS error string is written by the OS.
So both:
knownNamesnow replays interface names, which nothing did.nordlynxwas shippingverbatim in a default redacted bundle from the rendered rulesets, doctor's prose,
state.json's tunnel detail and the log.reservedgainsvpn,tunnelandwireguardfirst —keepIfacekeeps only a generic stem plus digits, so an interfacecan legitimately be called those, and the replay would have rewritten dezhban's own words.
Textis now the replay plus the shape passes;shapesis what a caller says when itwants the shape passes alone. Every external caller wanted the replay, so a second door
would only have left the wrong one reachable.
doctor.json'sdetailscarry the identifier as a field under a key thewalk already knows, with both renderers composing the sentence.
state.jsonpublishesone entry per tunnel interface — a comma-joined name minted one token for the pair
and stopped
keepIfaceseeing 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:
Regressions the loop introduced and then fixed:
rollbackassigned the checkpoint's own map, so the Redactor mutated it on the next mint.up (up)and read.first, hiding every interface butone once
state.jsonstarted publishing one entry each.ifaceAttrRe, and onepassed only because an earlier row of its own table had minted first.
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.mdclaimed "a token is never the spelling of a name inthe bundle". False in the residual this package already documents, and actively
misleading: an interface called
profile-1ships verbatim while the legend beside itsays
profile-1is a redacted profile name, so the one thing a reader can do with it isread 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
mintedguard by kind. It reads asa tightening and is a regression — a name that is both a profile and an endpoint is replayed
as
profile-1intohost=…, and a kind-scoped guard mintshost-2for it: a token standingfor 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 wasrun against the code it guards to confirm it fails there.
Plus a real
dezhban reportover a fixture carrying a vendor interface, a single-labelendpoint and a profile literally named
profile-1:grep -ri 'nordlynx\|mullvad\|protonvpn'over the unpacked bundle is emptyrules-preview.txtreadspass out quick on { iface-1 utun4 }profile-1appears nowhere, and no token stands for two identitiesutun4,lo0, private subnets, the posture strings and doctor'scheck names all survive
--include-networkstill returns all of it, and says soNot in scope
control.Groupis still written into doctor prose unredacted. It is the same valueconfig.jsonships 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