Summary
The escape-sequence sanitiser handles CSI (ESC [ … final) only. It does not handle SS3 (ESC O final), which is what the arrow keys emit when the terminal is in DECCKM application-cursor mode — the state vim, less or tmux leave behind on an unclean exit. Unlike the CSI case, SS3 residue survives as a non-empty, plausible-looking name and mints an immutable namespace.
There is also no minimum-viable name rule at any layer, and the backend's namespace validator cannot catch this by construction.
Not the 2026-07-20 bug — that one is fixed
The original report ([D[D[D[A[A[A becoming a client name) was fixed the next day, at two layers: cli#364 (strip control chars before slugifying) and client#362 (helper moved to common.sh, applied at the name prompt). Verified by running the real sanitiser on the exact bytes — 1b 5b 44 ×3 / 1b 5b 41 ×3 → empty string → the non-empty check fails → re-prompt ×3 → hard error. Fails closed. Please don't re-litigate that part.
The remaining hole
Three copies of the pattern, all CSI-only:
cli/internal/cli/sanitize.go:13
client/scripts/lib/common.sh:378
client/scripts/install-k8s.ps1:4152
Measured against the real Go functions:
"\x1bOD\x1bOD\x1bOD\x1bOA\x1bOA\x1bOA" -> "ODODODOAOAOA" -> slug "odododoaoaoa"
"\x1bOH\x1bOF" (Home/End) -> "OHOF" -> slug "ohof"
"\x1bOP\x1bOQ" (F1/F2) -> "OPOQ" -> slug "opoq"
The ESC is removed as a control byte; O and the final byte are printable, so the result passes the non-empty check and is accepted verbatim.
Test coverage shows the shape of the gap exactly: sanitize_test.go has six CSI cases (\x1b[D, \x1b[1;5D, \x1b[3~, both paste wrappers, a lone ESC) and zero \x1bO. client/scripts/tests/install-client-helm.bats:115-123 likewise has two cases, both CSI.
Server-side validation cannot catch this
is_dns1123_label (backend/common/utils/slug.py:80) validates by idempotence against the slug rule: slugify_dns1123("d-d-d-a-a-a") == "d-d-d-a-a-a", so escape-derived garbage is a perfectly canonical DNS-1123 label. The server checks form, and form is precisely what this bug preserves.
Nor does anything else: the CLI accepts any non-empty string after sanitising (client.go:192-199 — empty auto-names, non-empty is taken as-is), and EdgeDevice inherits first_name from User with only Django's max-length/non-blank defaults. There is no validate_first_name in the backend at all.
So the defence has to live at the CLI boundary.
Proposed fix
- Extend the strip to the SS3 shape
\x1bO[A-Za-z~] — a small regex change in all three copies.
- Add a post-sanitise floor: require the cleaned name to contain at least one alphanumeric that did not come from an escape final byte. Simplest safe form: if the cleaned string differs from the raw input and the remainder is under N characters, reject or auto-name.
- Add the SS3 case to both test files.
Keep it in sanitizeClientName and its two peers, not in slug.go — internal/slug must stay in lock-step with backend/common/utils/slug.py (stated at slug.go's doc comment and sanitize.go:22-26: hygiene at ingestion, never in the shared slug rule). That design is right; it just had no ingestion filter until 2026-07-21, and the filter it got is incomplete.
The structural point
One rule, three hand-maintained copies in three languages, with no shared fixture between them. That is why the SS3 case is missing from all three at once rather than from one. Worth a shared test corpus that each implementation is run against, so the next escape family is a one-line addition rather than three independent misses.
Summary
The escape-sequence sanitiser handles CSI (
ESC [ … final) only. It does not handle SS3 (ESC O final), which is what the arrow keys emit when the terminal is in DECCKM application-cursor mode — the state vim, less or tmux leave behind on an unclean exit. Unlike the CSI case, SS3 residue survives as a non-empty, plausible-looking name and mints an immutable namespace.There is also no minimum-viable name rule at any layer, and the backend's namespace validator cannot catch this by construction.
Not the 2026-07-20 bug — that one is fixed
The original report (
[D[D[D[A[A[Abecoming a client name) was fixed the next day, at two layers: cli#364 (strip control chars before slugifying) and client#362 (helper moved tocommon.sh, applied at the name prompt). Verified by running the real sanitiser on the exact bytes —1b 5b 44×3 /1b 5b 41×3 → empty string → the non-empty check fails → re-prompt ×3 → hard error. Fails closed. Please don't re-litigate that part.The remaining hole
Three copies of the pattern, all CSI-only:
cli/internal/cli/sanitize.go:13client/scripts/lib/common.sh:378client/scripts/install-k8s.ps1:4152Measured against the real Go functions:
The
ESCis removed as a control byte;Oand the final byte are printable, so the result passes the non-empty check and is accepted verbatim.Test coverage shows the shape of the gap exactly:
sanitize_test.gohas six CSI cases (\x1b[D,\x1b[1;5D,\x1b[3~, both paste wrappers, a lone ESC) and zero\x1bO.client/scripts/tests/install-client-helm.bats:115-123likewise has two cases, both CSI.Server-side validation cannot catch this
is_dns1123_label(backend/common/utils/slug.py:80) validates by idempotence against the slug rule:slugify_dns1123("d-d-d-a-a-a") == "d-d-d-a-a-a", so escape-derived garbage is a perfectly canonical DNS-1123 label. The server checks form, and form is precisely what this bug preserves.Nor does anything else: the CLI accepts any non-empty string after sanitising (
client.go:192-199— empty auto-names, non-empty is taken as-is), andEdgeDeviceinheritsfirst_namefromUserwith only Django's max-length/non-blank defaults. There is novalidate_first_namein the backend at all.So the defence has to live at the CLI boundary.
Proposed fix
\x1bO[A-Za-z~]— a small regex change in all three copies.Keep it in
sanitizeClientNameand its two peers, not inslug.go—internal/slugmust stay in lock-step withbackend/common/utils/slug.py(stated atslug.go's doc comment andsanitize.go:22-26: hygiene at ingestion, never in the shared slug rule). That design is right; it just had no ingestion filter until 2026-07-21, and the filter it got is incomplete.The structural point
One rule, three hand-maintained copies in three languages, with no shared fixture between them. That is why the SS3 case is missing from all three at once rather than from one. Worth a shared test corpus that each implementation is run against, so the next escape family is a one-line addition rather than three independent misses.