fix: hardening round 2 — #45 #46 #47 - #60
Conversation
Lost or mangled ESC[201~ terminators previously let the paste drain consume keystrokes forever, and pasted content entered the buffer raw — escape sequences included. - shellframe_read_key gains an optional timeout argument setting SHELLFRAME_KEY_TIMEOUT (rc>128 on bash >=4; bash 3.2 reports TIMEOUT for timed empty reads since it cannot distinguish EOF by rc) - shellframe_sanitize strips ANSI escapes (CSI, OSC/DCS string forms, Fe) and C0 controls except \n and \t from untrusted text - editor drain ends on EOF or SHELLFRAME_PASTE_SILENCE_LIMIT seconds of silence (default 5) and inserts sanitized bytes Tests: sanitizer unit table (10 cases incl. literal-backslash and truncated-escape edges); PTY test proves recovery from a lost terminator on both bash 5.3 and 3.2.
The SGR decoder existed as two drifting inline copies (input.sh, shell.sh); neither validated parameters and the button mask preserved the motion bit, so motion reports surfaced as phantom 'button 32' presses. - _shellframe_parse_sgr_mouse: one decoder for both readers; requires exactly three numeric fields or the event is discarded - motion reports (bit 32) are parsed, flagged via SHELLFRAME_MOUSE_MOTION, and dropped — hover/drag is #57 scope - button mask is now & ~60 (clears shift/alt/ctrl/motion), keeping buttons 0-2 and wheel 64/65 Unit table covers malformed sequences, motion, plain press, wheel release, shift+click, and ctrl+wheel; PTY mouse-routing integration passes unchanged.
Layout helpers count code points, not terminal columns — CJK/emoji and combining characters misalign bordered and columnar surfaces. Own the limitation in README + docs/api.md; a width-aware shellframe_str_width remains proposed under #54.
…ound 2) Blocking: under bash 3.2 with a UTF-8 locale, case-pattern bracket ranges follow collation order — [@-~] did not match letters, so a CSI never terminated and everything after the first escape was dropped. LC_COLLATE=C is now pinned for the function; the sanitizer unit file exports en_US.UTF-8 when the host provides it so CI's C-locale runners and UTF-8 hosts both exercise the class. Should-fix: two O(n) pattern scans now short-circuit clean input (no ESC, no control chars beyond \n\t) before the per-character loop — a 24 KB clean paste drops from seconds to ~3 ms; multibyte content passes through untouched. Verified: sanitizer table 11/11 under C and en_US.UTF-8 on bash 5.3 and /bin/bash 3.2.
Review round 2 response — blocking fix pushed (
|
Review — verdict: changes requested (posted as a comment: GitHub blocks request-changes from the author's own account)Reviewed against head Blocking1.
|
…iene Blockers: - read_key: a NUL byte (read -d '' delimiter hit, rc=0) was classified as EOF/TIMEOUT — the editor's EOF branch then dropped the whole paste. rc=0 with an empty value is now an empty keystroke; only genuine failures set flags. Editor EOF exit unified with the silence exit: buffered bytes are inserted either way. - SGR consolidation regression: discarded events (malformed/motion) fell through as raw 10-byte sequences, so any-key widgets dismissed on drag steps. Both readers now swallow ESC[<-prefixed decode failures as empty keys — and ONLY those: unrecognized CSI sequences still return raw (a first-round over-swallow caught by the existing drain tests). Should-fixes: - sanitizer handles nF intermediates (tput sgr0's ESC( B leaked its final byte); accumulation is chunked through an array (O(n^2) string append made a dirty 50 KB paste take 33 s on 3.2) - fractional SHELLFRAME_EDITOR_PASTE_SILENCE_LIMIT floors to >=1 on bash 3.2 inside read_key instead of ending drains instantly - env renamed to SHELLFRAME_EDITOR_PASTE_SILENCE_LIMIT (naming rule) Tests/hygiene: - ANSI-stripping test now captures PTY_RAW=1 and asserts the injected SGR is absent from the submitted text (was vacuous under stripping) - lost-terminator test budget trimmed 27s -> ~5s - KEY_TIMEOUT / NUL / motion-swallow unit cases added - docs/api.md: shellframe_sanitize, MOUSE_MOTION, paste limit documented - CI runs the suite under en_US.UTF-8 so collation paths match real Macs Suite 1563/1563 (host); Docker matrix 3/3 PASS.
Review round 2 response — all three blockers fixed (
|
Round-3 review of
|
| round 1 | now | |
|---|---|---|
Sanitizer, /bin/bash 3.2, en_US.UTF-8 |
[hello X] (rest swallowed) |
[hello XredYZ] — and ESC ( B (tput sgr0) no longer leaks a B |
ESC[<32;5;5M motion through shellframe_read_key |
raw 10-byte key | key '', SHELLFRAME_MOUSE_MOTION=1 (3.2 and 5) |
| NUL under timed read | eof=1 (5) / timeout=1 (3.2) | eof=0 timeout=0 on both |
test-sanitize.sh 11/11 and test-input.sh 66/66 on /bin/bash 3.2 under en_US.UTF-8; CI now runs the macOS leg in UTF-8 — that's the right fix for "green only because the runner has no locale". Fractional limit floored on 3.2 (no error, times out after 1 s). test-editor.sh 17 s (was 31 s). Full suite 1563/1563 against ptyunit 1.6.1. The stray file was the old untracked autocomplete plan — nothing sensitive, correctly reverted.
⚠ One thing to fix before merge: the fast path is quadratic on bash 3.2 (src/clip.sh:206-208)
local LC_COLLATE=C fixed collation but not string cost, and the new probe strips \n/\t with ${_raw//…/} — on bash 3.2 that copies the string per match, so it's O(matches × n). Measured on /bin/bash 3.2, LANG=en_US.UTF-8, clean paste (no escapes at all):
| size | this head | with the patch below |
|---|---|---|
| 2 KB | 0 s | 0 s |
| 10 KB | 5 s | 0 s |
| 20 KB | >40 s (killed) | 0 s |
| 50 KB | — | 0 s |
| 200 KB | — | 0 s |
| dirty 48 KB | >40 s | 7 s |
bash 5 is fine either way (50 KB clean in 1 s). But before this PR, paste on stock macOS bash was instant (unsanitized); after it, pasting a 10 KB log file freezes the editor for 5 s and a 20 KB one effectively hangs — on the primary target. Two-line fix, verified with a UTF-8 payload (dé survives byte-wise processing, since ESC/C0 are single bytes that never occur inside a UTF-8 sequence):
- local LC_COLLATE=C
+ local LC_ALL=C # byte semantics: O(1) ${s:i:1} on 3.2, and C collation for ranges
- local _probe="${_raw//$'\n'/}"
- _probe="${_probe//$'\t'/}"
- if [[ "$_probe" != *$'\x1b'* && "$_probe" != *[[:cntrl:]]* ]]; then
+ # Single glob scans are O(n) even on bash 3.2; ${var//x/} is O(matches*n) there.
+ # Class = C0 minus \t \n, plus DEL.
+ local _c0x; _c0x=$(printf '\001-\010\013-\037\177')
+ if [[ "$_raw" != *$'\x1b'* && "$_raw" != *[$_c0x]* ]]; thentest-sanitize.sh stays green with it. Worth adding one perf guard test: sanitize a 50 KB clean string and assert it completes (a read -t/watchdog-style bound), so this can't regress silently.
Leftover nits — defer freely
- Doubled ESC still leaks the CSI body:
$'a\033\033[1mW'→a[1mW(bash 5 and 3.2). In state 1, add$'\x1b') _state=1 ;;before the*)Fe arm. SHELLFRAME_MOUSE_ACTIONis set before field validation (input.sh:155), so a rejectedESC[<0MleavesACTION=press. Move the assignment after the regex check.- State 5's malformed-resync
*) _state=0drops the offending byte rather than re-dispatching it.
Reproductions on macOS /bin/bash 3.2.57 and bash 5.3.15, en_US.UTF-8 and C.
🤖 Generated with Claude Code
…round 3)
${var//x/} stripping is O(matches*n) on bash 3.2 and multibyte-locale
substring extraction made the scrub loop quadratic: a clean 10 KB paste
froze the editor ~5 s on stock macOS bash. Fast path now uses one glob
probe against a printf-built C0 class under LC_ALL=C — 200 KB clean
instants, dirty 48 KB ~7 s (was >40 s).
Also applied two review nits: doubled ESC re-enters escape state instead
of leaking the CSI body; MOUSE_ACTION is set only after field validation.
Perf-guard unit test bounds a clean 50 KB sanitize at 10 s on every
matrix leg so neither regression class can land silently.
Round 3 — perf patch applied (
|
Round-4 verdict: approved — merge-ready (
|
Summary
Three M-severity items from the Phase 8 review, following the merged launch-blockers PR (#59):
shellframe_read_keygains an optional timeout argument settingSHELLFRAME_KEY_TIMEOUT(bash ≥4 distinguishes timeout/EOF by rc; 3.2 conservatively reports TIMEOUT)shellframe_sanitizestrips ANSI escapes (CSI, OSC/DCS string forms, Fe) and C0 controls except\n/\tSHELLFRAME_PASTE_SILENCE_LIMITseconds of silence (default 5) and inserts sanitized bytes_shellframe_parse_sgr_mouse; both readers call it. Malformed events (wrong field count / non-numeric) are discarded; motion reports (bit 32) are flagged viaSHELLFRAME_MOUSE_MOTIONand dropped instead of leaking into the button value (hover/drag remains [Feature] Mouse motion support — 1002 drag / 1003 hover behind an opt-in flag (post-#51 feasible) #57 scope)Also fixes the local Docker matrix blocker: run-matrix mounts a sibling ptyunit checkout under
/Usersinstead of the unshared/opt/homebrewpath.Test plan