fix(sanitize): strip SS3 escapes and floor escape-only names (#516) - #520
Merged
Conversation
sanitizeClientName handled CSI (ESC '[' … final) only. SS3 (ESC 'O' final) is
what the same arrow / Home / End / F-keys emit once the terminal is in DECCKM
application-cursor mode — the state vim, less or tmux leave behind on an unclean
exit. That residue was worse than the CSI residue fixed in cli#364 / client#362
(2026-07-21, not re-litigated here): CSI cleans to empty and re-prompts, while
'O' and the final byte are printable, so ESC OD ×3 ESC OA ×3 survived as the
plausible name "ODODODOAOAOA" and minted the permanent namespace "odododoaoaoa".
Nothing downstream can refuse it: is_dns1123_label validates by idempotence
against the slug rule, so escape-derived garbage is a perfectly canonical label.
Form is exactly what this input preserves.
Two changes, both in sanitizeClientName — deliberately NOT in internal/slug,
which must stay a faithful mirror of backend/common/utils/slug.py:
1. escSequence now matches CSI and SS3 in one pattern.
2. A post-sanitise floor. If an ESC SURVIVES step 1 the value carries an escape
family we do not recognise — which is precisely how SS3 got here — so it
must show one alphanumeric that did not come from an escape final byte,
probed with a greedier pattern whose output is never returned. Nothing but
residue returns "", the same path an omitted --name takes. Scoped to "an ESC
survived" so a clean name never reaches it and real content beside an
unknown escape is kept; the failure it chooses is the recoverable one.
Tests: 10 new cases in the table (SS3 arrows / Home-End / F-keys / mixed with
CSI / truncated / a bare O is not an escape; the floor with SS2 standing in for
"the next family", including the non-Latin-content case) plus a test pinning the
ticket's exact repro and the slug it used to mint.
Mutation-proven, three anchors, each applied and each detected:
• SS3 dropped from escSequence -> 2 cases red ("na\x1bODme", SS3+CSI mixed)
• floor short-circuited to false -> 2 cases red (truncated SS3, unknown family)
• hasAlphanumeric made ASCII-only -> 1 case red (non-Latin content)
The "SS3 arrows only" case is green under anchor 1 because the floor also covers
it; anchor 1 is carried by the mixed-content cases, which the floor cannot mask.
The bash and PowerShell peers get the same two changes in tracebloc/client.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
version-bump-gate is a required check and it refuses a PR that touches internal/* while VERSION still names an already-released version: v0.10.8 is out, so shipping this fix under it would put different bytes under an existing release. 0.10.9 is untagged and above every released final version, and it is the same target the other two open PRs on develop bump to — identical one-line changes merge without conflict, and all three then ship under the pending 0.10.9. Not a hand-cut release: the release train still reads this file and cuts the tag from it at the prod hop. The gate's own message is explicit that it never bumps for you, and that a stale VERSION fails days later on somebody else's hop (backend#1561) rather than here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bugbot, Medium, on tracebloc/client#736: the floor's probe used an unbounded `[A-Za-z~]+` after an unrecognised ESC, so every ASCII letter following the escape was swallowed into the probe and the value read as residue-only. It is right, and the sharper half of it is the part I had not seen: `\x1bNChello` was refused while `\x1bNC日本` was kept, which makes keep-vs-reject depend on the script the user's name is written in. I had accepted the over-strictness on purpose; I had not noticed it was inconsistent. Bounded to `{1,2}`. Two, not one and not unbounded: one leaves the 'D' of an unrecognised SS3-shaped pair behind and the floor stops firing on the exact family shape this ticket is about, while unbounded eats a whole name. An escape final is one byte, an intro plus a final is two, and every keyboard-input escape family (SS2, SS3, the 7-bit C1 forms) fits in that — so the bound is a statement about escapes rather than a tuning constant. Every case the floor is meant to catch is unaffected: ESC N B / ESC N C, a truncated ESC O, and ESC [ ; ] A all still collapse to empty. Applied to all three copies so the rule stays one rule. Mutation-proven: reverting `{1,2}` to `+` turns the new case red in Go ("\x1bNChello" -> "") and in bats. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
added a commit
that referenced
this pull request
Aug 17, 2026
version-bump-gate: v0.10.8 is already released and this PR changes a published path (internal/*), so the train would otherwise cut the next tag from a stale file. 0.10.9 is the same pending version cli#518, #519 and #520 bump to — they all ship under it together, and the identical change merges without conflict. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
added a commit
that referenced
this pull request
Aug 17, 2026
version-bump-gate fails any PR touching a published path while the current VERSION names a released tag, and its publish glob is `internal/*` — which matches internal/cli/copy_catalog_test.go even though a _test.go file ships nothing. 0.10.9 is being cut regardless (cli#518, #519, #520 and #521 all bump to it), so this change genuinely rides under that version; the identical one-line edit merges without conflict. Preferred over the skip-version-gate override: the label is for a false positive nobody should have to reason about later, and the honest statement here is that this is part of 0.10.9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
saadqbal
approved these changes
Aug 17, 2026
saadqbal
left a comment
Collaborator
There was a problem hiding this comment.
Careful, well-reasoned PR. The SS3 grammar (ESC O + one final) is right, escSequence still strips CSI/paste, and a bare O is untouched. Nice touch that the real backstop stays step 4: strings.Map drops every ESC on every non-empty path, so even families nobody stripped as a whole (OSC/DCS/APC, truncated ESC O, ESC O ESC) can't reach the terminal — the floor and escSequence only keep the name clean. The two-final-byte bound and its script-independence argument hold up, and the tests are non-vacuous (SS3->floor, legit-O, other families intact, the ASCII/non-Latin symmetry). Ran the suite + an adversarial "no ESC survives" sweep, both green. 👍
4 tasks
LukasWodka
added a commit
that referenced
this pull request
Aug 17, 2026
…e window (#517) (#521) * fix(auth): a transient poll failure retries; the expiry copy names the window cli#517. The device-poll loop's `default:` branch was terminal, so anything that was not one of the four RFC 8628 sentinels ended the sign-in — a DNS blip, a backend restart, a proxy 502. Inside a ten-minute human-paced window that is a long exposure, and under the installer it threw away a run that had already built a cluster. The default is inverted: unknown failures retry, and every terminal state is now enumerated in classifyPollError — the four sentinels, a 426 version floor, a cancelled context, and any *APIError that is not 5xx / 408 / 429. So a server's refusal still stops on the first poll; only failures that never reached a verdict are ridden out. Retries are bounded by maxPollFailures consecutive failures (reset by any answer), so an unreachable backend reports itself instead of burning the code's window and then blaming the user. Also from #517: • the expiry message names the window ("sign-in codes are valid for 10 minutes"), derived from the server's expires_in rather than hardcoded — without it a ten-minute timeout reads as an instant failure; • "Run `tracebloc login` to start a new one" is suppressed when TRACEBLOC_INSTALLER is set. That advice is right for a hand-typed login and wrong under the installer, which prints its own next step; the two used to contradict each other on screen. • a Ctrl-C landing mid-request now exits quietly, like one landing between polls, instead of reporting the operator's interrupt as a sign-in failure. Every message stays a literal argument of errors.New / fmt.Errorf so the copy catalog's AST harvest can still see it; TestCopyCatalogSeesTheSignInStrings pins that, because composing copy inside a helper drops it from the catalog silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(release): VERSION 0.10.8 -> 0.10.9 version-bump-gate: v0.10.8 is already released and this PR changes a published path (internal/*), so the train would otherwise cut the next tag from a stale file. 0.10.9 is the same pending version cli#518, #519 and #520 bump to — they all ship under it together, and the identical change merges without conflict. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
added a commit
that referenced
this pull request
Aug 17, 2026
…104 invisible) (#522) * fix(test): the copy catalog skipped every message written as a join harvestMessages type-asserted arguments straight to *ast.BasicLit, so a message split across source lines — fmt.Errorf("unknown backend environment %q — valid values are … "+ "set CLIENT_ENV or pass --env", env) — is an *ast.BinaryExpr and was skipped ENTIRELY. Not the second half: the whole message. This file's own header calls the golden "the completeness backstop", and it passed forever while a whole syntactic class of copy was invisible to it. 104 previously-unseen messages, 0 removed. They are not marginal — they are the long validation errors that tell a user how to fix their data: the BOM in an Excel "CSV UTF-8" export, non-UTF-8 CSVs, masks that don't match the image resolution, labels.csv rows referencing absent images, symlinks in the dataset tree. The copy most worth guarding against drift was the copy the guard could not see. literalString folds ADD chains of literals (and parenthesised ones), refusing any join with a non-literal operand. That refusal is the load-bearing half: emitting the literal fragments of a part-computed message would put a sentence in the catalog that no user ever sees, and mark it inventoried while the real text drifts. Absent is honest; half is not. Proven in BOTH directions on the same mutation — breaking the reported message in auth.go: with the fix TestCopyCatalog FAILS without the fix TestCopyCatalog passes <- the guard could not see it TestLiteralString pins the fold with inputs written down independently of the matcher, so a typo in one cannot plant itself in the other; reverting the fold reddens 5 of its cases. TestHarvestMessages_SeesConcatenatedCopy pins the reported defect itself. Found while doing cli#517 (#521), where new copy composed inside a helper vanished from the catalog the same way; that PR worked around it by keeping every sentence a direct literal argument. This is the underlying scanner gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(version): 0.10.8 -> 0.10.9 version-bump-gate fails any PR touching a published path while the current VERSION names a released tag, and its publish glob is `internal/*` — which matches internal/cli/copy_catalog_test.go even though a _test.go file ships nothing. 0.10.9 is being cut regardless (cli#518, #519, #520 and #521 all bump to it), so this change genuinely rides under that version; the identical one-line edit merges without conflict. Preferred over the skip-version-gate override: the label is for a false positive nobody should have to reason about later, and the honest statement here is that this is part of 0.10.9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <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.
Closes #516. Companion PR (bash + PowerShell copies of the same rule): tracebloc/client#736 — land both, they are one rule.
The bug
sanitizeClientNamehandled CSI (ESC [ … final) only. SS3 (ESC O final) is what the same arrow / Home / End / F-keys emit once the terminal is in DECCKM application-cursor mode — the state vim, less or tmux leave behind on an unclean exit.SS3 residue was worse than the CSI residue fixed in #364 / tracebloc/client#362 on 2026-07-21 (not re-litigated here — that fix is correct and stays). CSI residue cleans to empty, fails the non-empty check and re-prompts. SS3 does not:
ESCis dropped as a C0 byte butOand the final byte are printable, soand the namespace is immutable. Nothing downstream can refuse it:
is_dns1123_labelvalidates by idempotence against the slug rule, so escape-derived garbage is a perfectly canonical label. Form is exactly what this input preserves — so the defence has to be about content, and it has to live here.The fix
Both changes are in
sanitizeClientName, not ininternal/slug— that must stay a faithful mirror ofbackend/common/utils/slug.py, and both files say so. Hygiene belongs at ingestion.1.
escSequencematches CSI and SS3 in one pattern.2. A post-sanitise floor. The strip knows CSI, SS3 and the paste markers; it cannot know the family nobody has reported yet — and that is exactly how SS3 got here. So: if an ESC survives step 1, the value carries a shape we do not recognise, and it must show one alphanumeric that did not come from an escape final byte. The probe is
ESC+ intermediates + at most two final-class bytes; its output is a yes/no and is never returned. Nothing but residue returns"", whichclient createalready treats exactly like an omitted--name.Why that rule, of the ones the ticket floated. The ticket suggested "cleaned ≠ raw and remainder under N characters". I picked a different trigger because "cleaned ≠ raw" fires on every ordinary paste and every stray tab, so an N large enough to catch residue also auto-names short real names, and an N small enough to spare them catches nothing. Scoping to "an ESC survived the known families" is narrower and needs no magic number: a clean name never reaches it,
--name boxbeside an unknown escape keepsbox, and only a value that is nothing but residue is refused. Where it is wrong it is wrong in the recoverable direction — auto-naming a name is annoying; minting a permanent namespace out of keyboard noise is not.Why the probe's run is bounded at two — this came out of a Bugbot Medium on the companion PR (tracebloc/client#736). One is too few: it leaves the
Dof an unrecognised SS3-shaped pair behind, and the floor stops firing on the exact family shape this ticket is about. Unbounded is too many:\x1bNChellohad the whole name swallowed and was refused while\x1bNC日本was kept, so keep-vs-reject depended on the script the user's name is written in. Two is a statement about escapes rather than a tuning constant — an escape final is one byte, an intro plus a final is two, and every keyboard-input family (SS2, SS3, the 7-bit C1 forms) fits in that.Mutation evidence
Four anchors, each applied to the source, each confirmed to redden, each restored:
escSequencena\x1bODme→"naODme", SS3+CSI mixed →"aODbc"if false && …)"O", unknown family →"NBNC"hasAlphanumericmade ASCII-only\x1bNC日本→""{1,2}→+\x1bNChello→""Worth stating rather than hiding: the "SS3 arrows only → empty" case stays green under the first mutation, because the floor catches it too. It is kept as the ticket's documented repro, and the SS3 strip itself is carried by the mixed-content cases, which the floor cannot mask.
Tests
internal/cli/sanitize_test.go: 26 table cases (was 15) plus a newTestSanitizeClientName_ss3SurvivesAsAPlausibleNamepinning the ticket's exact repro and the slug it used to mint. The floor cases use SS2 (ESC N <final>) as a stand-in for "the next family" — genuinely not matched byescSequence, so they exercise the floor and nothing else.make checkandmake check-allgreen (go test ./...: 17 packages ok; lint, staticcheck, golangci-lint 0 issues, govulncheck clean).VERSION0.10.8 → 0.10.9, in its own commit:version-bump-gateis a required check and refuses a PR touchinginternal/*while VERSION names an already-released version. 0.10.9 is the same target #518 and #519 bump to — identical one-line changes merge without conflict, and all three then ship under the pending 0.10.9. The train still cuts the tag from the file; nothing is hand-released here.On the shared fixture (ticket item 4) — deferred, deliberately
The ticket is right that three hand-maintained copies with no shared corpus is why all three missed SS3 at once. I built exactly that corpus while doing this (18 raw→expected pairs, run against all three implementations, 18/18 each) — but I did not land it as a shared fixture, for two reasons:
cliandclientneeds a vendoring + drift-check mechanism (the shape ofclient'scheck-drift.sh), which is a larger and riskier change than the bug fix and belongs in its own PR.scripts/tests/install-k8s.Tests.ps1, outside this change's scope — wiring two implementations to a corpus and leaving the third out produces exactly the "looks like a check, is not connected to what it claims to check" shape the org standards warn about.Follow-up filed: tracebloc/backend#2084 (it carries the "add the PowerShell SS3 cases" sub-task explicitly). Until it lands, all three copies now carry a "change all three together" pointer to the other two.
One gap this PR does not close, stated plainly rather than left to be discovered: the PowerShell copy's SS3 behaviour is verified (18/18 out of tree, and the existing Pester suite is 672 passed / 0 failed with the change in) but has no committed SS3 test, because
install-k8s.Tests.ps1was outside this change's scope. That is a real hole and is the first sub-task on the follow-up.