Skip to content

fix(installer): strip SS3 escapes and floor escape-only input (cli#516) - #736

Merged
LukasWodka merged 4 commits into
developfrom
fix/516-ss3-sanitiser
Aug 17, 2026
Merged

fix(installer): strip SS3 escapes and floor escape-only input (cli#516)#736
LukasWodka merged 4 commits into
developfrom
fix/516-ss3-sanitiser

Conversation

@LukasWodka

@LukasWodka LukasWodka commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

The bash + PowerShell half of tracebloc/cli#516. Companion PR (the Go copy): tracebloc/cli#520land both, they are one rule.

The bug

_strip_paste_garbage (scripts/lib/common.sh) and ConvertTo-SanitizedInput (scripts/install-k8s.ps1) 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.

SS3 residue was worse than the CSI residue fixed in #362 / tracebloc/cli#364 on 2026-07-21 (not re-litigated here — that fix is correct and stays). CSI residue cleans to empty, so the name prompt's non-empty check re-prompts. SS3 does not: ESC is dropped as a control byte but O and the final byte are printable, so

"\x1bOD\x1bOD\x1bOD\x1bOA\x1bOA\x1bOA" -> "ODODODOAOAOA" -> namespace "odododoaoaoa"
"\x1bOH\x1bOF" -> "OHOF"      "\x1bOP\x1bOQ" -> "OPOQ"

and the namespace is immutable. Nothing downstream can refuse it — the backend validates DNS-1123 form by idempotence against the slug rule, so escape-derived garbage is a perfectly canonical label. Form is exactly what this input preserves.

The fix — two changes, in each of the two copies here

1. The strip matches CSI and SS3 in one pattern.

local esc_pattern="${esc}(\\[[0-9;]*|O)[A-Za-z~]"
$s = $Value -replace "$esc(\[[0-9;]*|O)[A-Za-z~]", ""

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 the strip, 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 emits empty — which every caller here already treats as "no answer": provision.sh re-prompts, cluster.sh's _read_sanitized yields an empty var, _sanitize_credential warns.

Scoped to "an ESC survived the known families" rather than the ticket's "cleaned ≠ raw and remainder under N chars": "cleaned ≠ raw" fires on every ordinary paste and every stray tab, so an N large enough to catch residue also rejects short real names. This trigger needs no magic number — a clean value never reaches it, real content beside an unknown escape is kept, and only a value that is nothing but residue is refused.

Three things the tests caught that review did not

The C locale. The bash floor checks for content with LC_ALL=C tr -dc '0-9A-Za-z\200-\377', not [[ "$probe" =~ [[:alnum:]] ]]. Bash's regex engine is locale-dependent, and under the C locale the installer often runs in, [[:alnum:]] does not match a UTF-8 letter — so the first draft auto-named a perfectly good 日本 the moment an unknown escape sat next to it. Keeping every byte ≥ 0x80 makes the question locale-independent. (.NET's regex is Unicode-aware, so the PowerShell copy uses [\p{L}\p{Nd}].)

A hang. The probe's first draft copied the strip's while [[ $s =~ $pat ]]; do s="${s/${BASH_REMATCH[0]}/}"; done shape. Pattern substitution treats BASH_REMATCH as a glob, not a literal. That is safe for the CSI loop by construction — its match is ESC '[' [0-9;]* <final>, which can never contain a ], so it can never form a complete bracket expression. The probe's pattern has [^A-Za-z0-9~]* in the middle, which can swallow a ]: on ESC [ ; ] A the regex matches the whole value, the glob <ESC>[;]A then means ESC ';' 'A' — not present — the substitution removes nothing, and the loop never terminates. A hang at the installer's name prompt, on a value the floor exists to refuse. Replaced with a single LC_ALL=C sed -E pass: no glob semantics, no loop.

An encoding-dependent verdict (Bugbot, Medium, resolved). The probe's final-byte run was unbounded, so \x1bNChello had the whole name swallowed and was refused while \x1bNC日本 was kept — keep-vs-reject depending on the script the user's name is written in. Now {1,2}: one is too few (it leaves the D of an unrecognised SS3-shaped pair behind, and the floor stops firing on the very shape this PR is about), unbounded is too many. An escape final is one byte, an intro plus a final is two, and every keyboard-input family fits in that.

Mutation evidence

Five anchors, each applied to common.sh, each confirmed to redden, each restored:

mutation result
SS3 dropped from the strip pattern 2 red: "SS3 escapes around real content", "SS3 and CSI mixed"
floor short-circuited (if false && …) 2 red: "truncated SS3", "unknown escape family, residue only"
tr check swapped for =~ [[:alnum:]] 1 red: "the floor counts non-Latin letters as real content"
sed pass reverted to the glob loop 1 red: the ESC [ ; ] A test (status 142 under a local timeout stand-in — the call never returns)
probe's run reverted {1,2}+ 1 red: "the floor keeps an ASCII name after an unknown escape"

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; the SS3 strip itself is carried by the mixed-content cases, which the floor cannot mask.

Tests

scripts/tests/install-client-helm.bats: 13 _strip_paste_garbage cases (was 2). The floor cases use SS2 (ESC N <final>) as a stand-in for "the next family" — genuinely not matched by the strip, so they exercise the floor and nothing else. The hang test is bounded the way common.bats bounds its recursion guard; macOS ships no timeout(1), so Linux CI is the authority on that half.

  • make check — green
  • make bats1074 ok, 0 not ok
  • make check-all — green (adds helm-template + 465 helm-unittest)
  • pwsh -NoProfile -Command "Invoke-Pester scripts/tests/ -Output Normal"672 passed, 0 failed, 13 skipped
  • scripts/gen-manifest.sh re-run — common.sh and install-k8s.ps1 are both in the bootstrap's integrity surface, so scripts/manifest.sha256 is updated here.

Two things this PR does not do, stated plainly

1. No committed PowerShell test for the new behaviour. ConvertTo-SanitizedInput's SS3 + floor behaviour was verified against the same 18-case corpus as the other two implementations (18/18), and the existing Pester suite is green with the change in — but its committed cases still cover only CSI, because scripts/tests/install-k8s.Tests.ps1 was outside this change's scope. That is a real hole, not an oversight, and it is the first sub-task on the follow-up below.

2. No shared fixture (tracebloc/cli#516 item 4). The ticket is right that three hand-maintained copies with no shared corpus is why all three missed SS3 at once, and I built exactly that corpus while doing this. I did not land it, for two reasons: it spans two repos, so it needs a vendoring + drift-check mechanism (the shape of scripts/tests/check-drift.sh) that is larger and riskier than the fix; and a fixture wired into two of three implementations, with the third still hand-maintained, is precisely the "appears to verify something, is not connected to what it claims to check" pattern. Ship it to three or not at all.

Follow-up: tracebloc/backend#2084. Until it lands, all three copies now carry a "change all three together" pointer to the other two.


Note

Medium Risk
Changes provisioning/name sanitization on the install path—wrong behavior could block valid names or still mint bad namespaces—but scope is localized to escape stripping with broad bats coverage and empty-input handling already wired in callers.

Overview
Fixes cli#516 by aligning _strip_paste_garbage in common.sh and ConvertTo-SanitizedInput in install-k8s.ps1 with the Go sanitizer: the strip regex now removes CSI and SS3 (ESC O + final) sequences, not CSI alone. SS3 is what arrow/Home/F-keys emit after vim/less/tmux leave DECCKM mode; leftover O/D/A bytes had produced plausible garbage names (e.g. ODOA) and immutable namespaces, unlike CSI residue that cleaned to empty and re-prompted.

Adds a post-strip floor: if an ESC still remains, the value is probed (bounded {1,2} final-byte removal) for real alphanumeric content. Bash uses a single sed pass plus LC_ALL=C tr so ESC [ ; ] A cannot hang the name prompt via glob-based BASH_REMATCH substitution, and non-Latin letters still count as content. Escape-only input returns empty so existing callers re-prompt or fail closed.

Tests in install-client-helm.bats grow from 2 to 13 cases (SS3, floor, hang, locale/encoding). manifest.sha256 is updated for the touched bootstrap scripts.

Reviewed by Cursor Bugbot for commit b29d549. Bugbot is set up for automated code reviews on this repo. Configure here.

_strip_paste_garbage and ConvertTo-SanitizedInput 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 client#362 / cli#364 (2026-07-21, not re-litigated here): CSI cleans to
empty and the name prompt 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 — the
backend validates DNS-1123 form by idempotence against the slug rule, and form is
exactly what this input preserves.

Two changes in each of the two copies here:

  1. The strip matches CSI and SS3 in one pattern.
  2. A post-sanitise floor. If an ESC SURVIVES the strip 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 emits empty, which every caller already treats as "no answer"
     (re-prompt, or auto-name in the CLI). Scoped to "an ESC survived" so a clean
     value never reaches it and real content beside an unknown escape is kept.

The bash floor tests for content with `tr`, not `=~ [[:alnum:]]`: bash's regex
engine is locale-dependent and under the C locale the installer often runs in,
[[:alnum:]] does not match a UTF-8 letter. That was caught by a test, not by
review — see the mutation evidence below.

Tests: 9 new bats cases for _strip_paste_garbage (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).

Mutation-proven, three anchors, each applied and each detected:
  • SS3 dropped from the strip pattern -> 2 cases red (SS3 around content, mixed)
  • floor short-circuited to false      -> 2 cases red (truncated SS3, unknown family)
  • tr check swapped for [[:alnum:]]    -> 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 PowerShell peer's behaviour was verified against the same 17-case corpus out
of tree (17/17), but its committed tests live in scripts/tests/install-k8s.Tests.ps1,
which is outside this change's scope — see the PR body.

scripts/manifest.sha256 regenerated (common.sh and install-k8s.ps1 are both in the
bootstrap's integrity surface).

The Go peer gets the same two changes in tracebloc/cli.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4ce3c2d. Configure here.

Comment thread scripts/lib/common.sh
Self-review finding on the commit before this one, caught before a reviewer saw
it: the floor's probe copied the strip's `while [[ $s =~ $pat ]]; do
s="${s/${BASH_REMATCH[0]}/}"; done` shape, and pattern substitution treats
BASH_REMATCH as a GLOB, not a literal.

That is safe for the CSI loop by construction — its match is ESC '[' [0-9;]*
<final>, which can never contain a `]`, so it can never form a complete bracket
expression and the glob always degrades to the literal. The floor's probe
pattern has `[^A-Za-z0-9~]*` in the middle, which CAN swallow a `]`. On the
input ESC [ ; ] A the regex matches the whole value, the glob `<ESC>[;]A` then
means ESC ';' 'A' — not present in the string — the substitution removes
nothing, and the loop never terminates. A hang at the installer's name prompt,
on a value the floor exists to refuse.

Replaced with a single `LC_ALL=C sed -E` pass: no glob semantics, no loop, same
result on all 17 corpus cases.

Mutation-proven: restoring the loop turns the new test red (status 142 under a
local timeout stand-in — the call never returns), and the sed version passes it.
The test is bounded the way common.bats bounds its recursion guard; macOS ships
no timeout(1), so Linux CI is the authority on the hang half.

scripts/manifest.sha256 regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka added a commit to tracebloc/cli that referenced this pull request Aug 17, 2026
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>
Bugbot, Medium, on #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 to tracebloc/cli that referenced this pull request Aug 17, 2026
…520)

* fix(sanitize): strip SS3 escapes and floor escape-only names (cli#516)

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>

* chore(release): VERSION 0.10.8 -> 0.10.9 (cli#516)

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>

* fix(sanitize): bound the floor's probe to two final bytes (cli#516)

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>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Conflict was scripts/manifest.sha256 only — a generated file. Resolved by
re-running scripts/gen-manifest.sh over the merged tree rather than hand-merging
two sets of hashes, which would produce a manifest matching neither side and make
the bootstrap refuse its own scripts.

client#735 (the Windows-on-ARM cosign asset) landed on develop and regenerated
the same file; both sides also touch install-k8s.ps1, which merged cleanly.

@saqlainsyed007 saqlainsyed007 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — reviewed for correctness.

Verified empirically (sourced the PR-head scripts/lib/common.sh and ran _strip_paste_garbage):

  • All in-PR bats vectors reproduce exactly (SS3 strip, floor rejection of residue-only, non-Latin content kept, ESC [ ; ] A terminates via the sed pass rather than hanging).
  • Own adversarial vectors also pass: CSI ESC[3~, bracketed paste, pure-CSI→empty, legit na~me, 3-byte unknown run.

Manifest integrity: recomputed SHA-256 of both touched bootstrap files (common.sh, install-k8s.ps1) at the PR head — both match scripts/manifest.sha256. Good, since this is the installer's Tier-0 integrity surface.

Caller contract: confirmed the floor's empty return is treated as "no answer" by every consumer — provision.sh re-prompts (×3) then errors, cluster.sh's _read_sanitized yields an empty var, install-client-helm.sh's _sanitize_credential warns. No path mints an empty namespace.

Logic: the combined CSI+SS3 strip stays glob-safe in the ${s/${BASH_REMATCH[0]}/} loop (a match can never contain ], so no infinite substitution), and the floor's sed-not-glob-loop + LC_ALL=C tr choices are correct and locale-independent (checked the 日本 / C-locale case directly).

All 40 CI checks are green (Bugbot, Pester on Windows + Ubuntu, bats, static analysis, gitleaks). The one Bugbot thread (floor over-sweep → {1,2} bound) is resolved. The bash \200-\377 vs PowerShell [\p{L}\p{Nd}] content definitions differ only for symbol-only input beside an unknown escape — never a valid name — and it's documented intent, so not a blocker.

The two disclosed follow-ups (committed PowerShell test for the new behaviour; the shared cross-repo fixture) are correctly deferred to backend#2084.

@LukasWodka
LukasWodka merged commit b32b797 into develop Aug 17, 2026
47 checks passed
@LukasWodka
LukasWodka deleted the fix/516-ss3-sanitiser branch August 17, 2026 15:23
LukasWodka added a commit that referenced this pull request Aug 17, 2026
`develop` gained #735 (amd64 cosign bootstrap on Windows-on-ARM) and #736
(SS3 escape stripping), both of which touch `scripts/lib/common.sh` — the file
this branch also edits, since the binfmt probe moved there so both arch gates
read one probe.

`common.sh` and `install-client-helm.bats` auto-merged. `manifest.sha256` was
the only conflict and was REGENERATED with `scripts/gen-manifest.sh` rather
than hand-merged: the manifest is a derived artifact, and a hand-resolved one
records hashes for a tree that never existed. Re-running the generator now
produces no diff, so the committed manifest matches the merged tree.

Verified on the merged result, not on either side: the full bats suite exits 0
with 0 failures (1119+ tests), so #739's `_pf_arch` / `_assert_engine_runs_on_this_arch`
changes still hold against develop's `common.sh`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants