guard SGR 38/48 lookahead; parse colon sub-parameters (T-369)

printf '\e[38m' was a RangeError inside Terminal.write — the
extended-color branches indexed params[i+1..i+4] unguarded. An
emulator must never throw on hostile bytes. Both branches now share a
bounds-checked helper that ignores truncated sequences.

Colons were silently dropped mid-CSI, fusing 38:2:255:0:0 into one
bogus parameter; the consumer now records ECMA-48 sub-parameter
links, so ITU T.416 colon-form truecolor/256-color (with or without
the colorspace slot) parses identically to the semicolon form, and a
malformed colon group is dropped whole instead of bleeding into
neighbouring SGR codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:30:41 +02:00
co-authored by Claude Fable 5
parent 390ab2b64e
commit 34821fbc39
5 changed files with 149 additions and 30 deletions
@@ -3943,3 +3943,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBV0465906BY3QFAY9F1YM', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:24:07', '2026-06-11 22:24:07', '2026-06-11 22:24:07', NULL, '6e0986eb4b0c15223d7b373bbbc421cb', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:24:34', '2026-06-11 22:24:34', '2026-06-11 22:24:34', NULL, '151c44c4c697f83900b1cc003c6a94f6', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:26:15', '2026-06-11 22:26:15', '2026-06-11 22:26:15', NULL, '05386705854fd48ee75d7a3dfbfd5bc9', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:26:38', '2026-06-11 22:26:38', '2026-06-11 22:26:38', NULL, '6d14a673d6733b5024726bfc227f6711', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:30:24', '2026-06-11 22:30:24', '2026-06-11 22:30:24', NULL, 'aa1fe975e147be83905f24c63662254d', 2) ON CONFLICT(hash) DO NOTHING;
+10
View File
@@ -3991,3 +3991,13 @@ Fix: gate the new-item auto-scroll on _atBottom (one-line) and add the missing t
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Conversation auto-scroll yanks a scrolled-up reader to the bottom on every streamed token', 'lib/builtin/claude/src/conversation_view.dart:268-277 — the _atBottom pin exists but is only consulted on viewport resize, not on new items. Anyone reading earlier output during a long streaming reply is dragged to the bottom continuously.
Fix: gate the new-item auto-scroll on _atBottom (one-line) and add the missing twin test: scrolled-up viewport stays put when items stream in; at-bottom viewport follows.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:33', '2026-06-11 22:26:15', NULL, '1340fb7aac3cdf06476ce74248fda6c0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal throws RangeError on truncated SGR 38/48; colon sub-parameters mangled', 'lib/src/terminal/src/core/escape/parser.dart:501-516 — SGR 38/48 extended-color parsing does unguarded params[i + 1] lookahead, so a truncated sequence like printf ''\e[38m'' throws RangeError inside Terminal.write. An emulator must never throw on hostile bytes. Secondary, same code path: colon-form SGR sub-parameters (e.g. 38:2:r:g:b, emitted by modern terminfo) are not split out and get mangled into bogus params.
Fix: bounds-check the lookahead (ignore incomplete 38/48 sequences), and parse colon-form sub-parameters per ECMA-48/ITU T.416 treat 38:2::r:g:b and 38;2;r;g;b equivalently. Note T-123 (parser split) touches the same file; coordinate but do not block on it.
Acceptance: feeding any truncated/garbled SGR byte sequence never throws (fuzz-style test over partial sequences); colon-form truecolor sets the same fg/bg as semicolon form; existing SGR tests stay green.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:56:47', '2026-06-11 22:26:38', NULL, '8cc428e286b918b46744a649ab1979f0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal throws RangeError on truncated SGR 38/48; colon sub-parameters mangled', 'lib/src/terminal/src/core/escape/parser.dart:501-516 — SGR 38/48 extended-color parsing does unguarded params[i + 1] lookahead, so a truncated sequence like printf ''\e[38m'' throws RangeError inside Terminal.write. An emulator must never throw on hostile bytes. Secondary, same code path: colon-form SGR sub-parameters (e.g. 38:2:r:g:b, emitted by modern terminfo) are not split out and get mangled into bogus params.
Fix: bounds-check the lookahead (ignore incomplete 38/48 sequences), and parse colon-form sub-parameters per ECMA-48/ITU T.416 treat 38:2::r:g:b and 38;2;r;g;b equivalently. Note T-123 (parser split) touches the same file; coordinate but do not block on it.
Acceptance: feeding any truncated/garbled SGR byte sequence never throws (fuzz-style test over partial sequences); colon-form truecolor sets the same fg/bg as semicolon form; existing SGR tests stay green.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:47', '2026-06-11 22:30:24', NULL, 'b64b55f7d40149848fc687efe1cf8f32', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+6
View File
@@ -46,6 +46,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
scroll position, dragging a reader back to the bottom; it now follows only
while already pinned there. (T-368)
- **The terminal no longer crashes on truncated SGR color sequences.**
`ESC[38m` and friends threw a RangeError inside the emulator; incomplete
38/48 sequences are now ignored, and colon-form truecolor/256-color
sub-parameters (`38:2:r:g:b`, ITU T.416) parse like the semicolon form
instead of being mangled. (T-369)
- **Accepting ExitPlanMode now leaves plan mode in the conversation panel.**
Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying
session out of plan mode, but clide's tracked permission mode didn't follow,
+81 -30
View File
@@ -220,6 +220,7 @@ class EscapeParser {
}
_csi.params.clear();
_csi.subParam.clear();
// test whether the csi is a `CSI ? Ps ...` or `CSI Ps ...`
final prefix = _queue.peek();
@@ -232,6 +233,11 @@ class EscapeParser {
var param = 0;
var hasParam = false;
// Whether the value being accumulated was attached to its predecessor
// with a colon (ECMA-48 sub-parameter separator, ITU T.416 SGR colors).
// Before T-369 colons were silently dropped mid-sequence, fusing
// `38:2:255:0:0` into one bogus parameter.
var linkedToPrev = false;
while (true) {
// The sequence isn't completed, just ignore it.
if (_queue.isEmpty) {
@@ -243,8 +249,21 @@ class EscapeParser {
if (char == Ascii.semicolon) {
if (hasParam) {
_csi.params.add(param);
_csi.subParam.add(linkedToPrev);
}
param = 0;
linkedToPrev = false;
continue;
}
if (char == Ascii.colon) {
// Push the current value even when empty — `38:2::r:g:b` carries an
// empty colorspace slot that must keep its position in the group.
_csi.params.add(hasParam ? param : 0);
_csi.subParam.add(linkedToPrev);
hasParam = true;
param = 0;
linkedToPrev = true;
continue;
}
@@ -263,6 +282,7 @@ class EscapeParser {
if (char >= Ascii.atSign && char <= Ascii.tilde) {
if (hasParam) {
_csi.params.add(param);
_csi.subParam.add(linkedToPrev);
}
_csi.finalByte = char;
@@ -499,21 +519,7 @@ class EscapeParser {
handler.setForegroundColor16(NamedColor.white);
continue;
case 38:
final mode = params[i + 1];
switch (mode) {
case 2:
final r = params[i + 2];
final g = params[i + 3];
final b = params[i + 4];
handler.setForegroundColorRgb(r, g, b);
i += 4;
break;
case 5:
final index = params[i + 2];
handler.setForegroundColor256(index);
i += 2;
break;
}
i = _csiHandleExtendedColor(i, foreground: true);
continue;
case 39:
handler.resetForeground();
@@ -544,21 +550,7 @@ class EscapeParser {
handler.setBackgroundColor16(NamedColor.white);
continue;
case 48:
final mode = params[i + 1];
switch (mode) {
case 2:
final r = params[i + 2];
final g = params[i + 3];
final b = params[i + 4];
handler.setBackgroundColorRgb(r, g, b);
i += 4;
break;
case 5:
final index = params[i + 2];
handler.setBackgroundColor256(index);
i += 2;
break;
}
i = _csiHandleExtendedColor(i, foreground: false);
continue;
case 49:
handler.resetBackground();
@@ -621,6 +613,61 @@ class EscapeParser {
}
}
/// Extended fg/bg color (SGR 38/48), semicolon or colon form.
///
/// Returns the index of the last parameter consumed. Never reads past the
/// end of the parameter list — a truncated sequence (`ESC [38m`,
/// `ESC [38;2;255m`) is ignored instead of throwing; an emulator must never
/// throw on hostile bytes (T-369). Colon-form sub-parameters per ITU T.416
/// (`38:2:r:g:b`, `38:2:<colorspace>:r:g:b`, `38:5:n`) are treated as one
/// logical group: parsed equivalently to the semicolon form, and dropped
/// whole when malformed so they never spill into neighbouring parameters.
int _csiHandleExtendedColor(int i, {required bool foreground}) {
final params = _csi.params;
final sub = _csi.subParam;
// End of the colon-linked group starting at params[i] (exclusive).
var end = i + 1;
while (end < params.length && sub[end]) {
end++;
}
if (end > i + 1) {
// Colon form. Group is params[i..end-1]; n includes the 38/48 itself.
final n = end - i;
final mode = params[i + 1];
if (mode == 5 && n >= 3) {
foreground ? handler.setForegroundColor256(params[i + 2]) : handler.setBackgroundColor256(params[i + 2]);
} else if (mode == 2) {
// A 6+ element group carries the T.416 colorspace id slot — skip it.
final base = n >= 6 ? i + 3 : i + 2;
if (base + 2 < end) {
foreground
? handler.setForegroundColorRgb(params[base], params[base + 1], params[base + 2])
: handler.setBackgroundColorRgb(params[base], params[base + 1], params[base + 2]);
}
}
return end - 1;
}
// Semicolon form (legacy).
if (i + 1 >= params.length) return i; // bare 38/48 — ignore
switch (params[i + 1]) {
case 2:
if (i + 4 >= params.length) return params.length - 1; // truncated — ignore
foreground
? handler.setForegroundColorRgb(params[i + 2], params[i + 3], params[i + 4])
: handler.setBackgroundColorRgb(params[i + 2], params[i + 3], params[i + 4]);
return i + 4;
case 5:
if (i + 2 >= params.length) return params.length - 1; // truncated — ignore
foreground ? handler.setForegroundColor256(params[i + 2]) : handler.setBackgroundColor256(params[i + 2]);
return i + 2;
}
// Unknown mode — consume it so it isn't re-interpreted as an SGR code.
return i + 1;
}
/// `ESC [ Ps n` Device Status Report [Dispatch] (DSR)
///
/// https://terminalguide.namepad.de/seq/csi_sn/
@@ -1121,6 +1168,10 @@ class _Csi {
List<int> params;
/// Parallel to [params]: true when that parameter was attached to its
/// predecessor with a colon (ECMA-48 sub-parameter, ITU T.416 — T-369).
final List<bool> subParam = [];
int finalByte;
// final List<int> intermediates;
+50
View File
@@ -676,6 +676,56 @@ void main() {
f.parser.write('\x1b[123m');
expect(f.h.named('unsupportedStyle').first.args, [123]);
});
// T-369: an emulator must never throw on hostile bytes. The old code did
// unguarded params[i+1] lookahead in 38/48 — `\x1b[38m` was a RangeError
// inside Terminal.write.
test('truncated 38/48 sequences are ignored, never throw', () {
final f = _newParser();
for (final s in ['\x1b[38m', '\x1b[48m', '\x1b[38;2m', '\x1b[38;2;255m', '\x1b[38;2;255;10m', '\x1b[38;5m', '\x1b[48;5m', '\x1b[38:2m', '\x1b[38:5m', '\x1b[48:2:255m']) {
f.parser.write(s);
}
expect(f.h.named('setForegroundColorRgb'), isEmpty);
expect(f.h.named('setBackgroundColorRgb'), isEmpty);
expect(f.h.named('setForegroundColor256'), isEmpty);
expect(f.h.named('setBackgroundColor256'), isEmpty);
});
test('colon-form truecolor matches semicolon form (ITU T.416)', () {
final f = _newParser();
f.parser.write('\x1b[38:2:10:20:30m\x1b[48:2:100:150:200m');
expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]);
expect(f.h.named('setBackgroundColorRgb').first.args, [100, 150, 200]);
});
test('colon-form with empty colorspace slot — 38:2::r:g:b', () {
final f = _newParser();
f.parser.write('\x1b[38:2::10:20:30m');
expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]);
});
test('colon-form 256-colour — 38:5:n', () {
final f = _newParser();
f.parser.write('\x1b[38:5:200m\x1b[48:5:42m');
expect(f.h.named('setForegroundColor256').first.args, [200]);
expect(f.h.named('setBackgroundColor256').first.args, [42]);
});
test('a malformed colon group is dropped whole, neighbours still apply', () {
final f = _newParser();
// The bogus 38:2:255 group must not bleed into the following bold.
f.parser.write('\x1b[38:2:255;1m');
expect(f.h.named('setForegroundColorRgb'), isEmpty);
expect(f.h.named('setCursorBold').length, 1);
});
test('extended color followed by more SGR params keeps positions', () {
final f = _newParser();
f.parser.write('\x1b[1;38;2;10;20;30;4m');
expect(f.h.named('setCursorBold').length, 1);
expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]);
expect(f.h.named('setCursorUnderline').length, 1);
});
});
group('EscapeParser — OSC sequences', () {