Skip to content

Commit 5a81e9f

Browse files
fix(agents): preserve ANSI sanitizer state across bash chunks (#103706)
* fix(agents): preserve ANSI sanitizer state across bash chunks * fix(agents): harden streaming ANSI sanitization Keep incremental parser state in the canonical terminal owner, avoid a second sanitizer pass, and leave OutputAccumulator and public terminal APIs unchanged.\n\nCo-authored-by: Jicheng Xu <[email protected]> --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent ee0e3a4 commit 5a81e9f

7 files changed

Lines changed: 401 additions & 15 deletions

File tree

packages/terminal-core/src/ansi-sequences.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export const ANSI_OSC_INTRODUCER_PATTERN = "(?:\\x1b\\]|\\x9d)";
22
export const ANSI_STRING_TERMINATOR_PATTERN = "(?:\\x1b\\\\|\\x07|\\x9c)";
33
const ANSI_OSC_PATTERN = `${ANSI_OSC_INTRODUCER_PATTERN}[^\\x07\\x1b\\x9c]*${ANSI_STRING_TERMINATOR_PATTERN}`;
4+
export const ANSI_COMPAT_CONTROL_SEQUENCE_PATTERN =
5+
"[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]";
46

57
const ansiOscAtIndexRegex = new RegExp(ANSI_OSC_PATTERN, "y");
68

@@ -27,6 +29,229 @@ type AnsiCsiScan = {
2729
value: string;
2830
};
2931

32+
type AnsiStripState = "text" | "escape" | "osc" | "osc-escape" | "csi" | "compat";
33+
34+
function isCompatPrefixCode(code: number): boolean {
35+
return (
36+
code === 0x5b ||
37+
code === 0x5d ||
38+
code === 0x28 ||
39+
code === 0x29 ||
40+
code === 0x23 ||
41+
code === 0x3b ||
42+
code === 0x3f
43+
);
44+
}
45+
46+
function isCompatParameterCode(code: number): boolean {
47+
return (code >= 0x30 && code <= 0x39) || code === 0x3a || code === 0x3b;
48+
}
49+
50+
function isDigitCode(code: number): boolean {
51+
return code >= 0x30 && code <= 0x39;
52+
}
53+
54+
function isCompatFinalCode(code: number): boolean {
55+
return (
56+
(code >= 0x30 && code <= 0x39) ||
57+
(code >= 0x40 && code <= 0x5a) ||
58+
code === 0x63 ||
59+
(code >= 0x66 && code <= 0x6e) ||
60+
(code >= 0x71 && code <= 0x75) ||
61+
code === 0x79 ||
62+
code === 0x3d ||
63+
code === 0x3e ||
64+
code === 0x3c ||
65+
code === 0x7e
66+
);
67+
}
68+
69+
/**
70+
* Incrementally strip the ANSI grammar accepted by the agent output sanitizer.
71+
* Parser state stays constant-size so unterminated OSC payloads cannot escape
72+
* or accumulate outside the caller's output limits.
73+
*/
74+
export class AnsiSequenceStripper {
75+
private state: AnsiStripState = "text";
76+
private csiCompatPrefixOnly = false;
77+
private compatInParameters = false;
78+
private compatParameterDigits = 0;
79+
80+
write(input: string): string {
81+
if (typeof input !== "string") {
82+
throw new TypeError(`Expected a \`string\`, got \`${typeof input}\``);
83+
}
84+
if (
85+
this.state === "text" &&
86+
!input.includes("\u001B") &&
87+
!input.includes("\u009B") &&
88+
!input.includes("\u009D")
89+
) {
90+
return input;
91+
}
92+
93+
const output: string[] = [];
94+
let index = 0;
95+
while (index < input.length) {
96+
const code = input.charCodeAt(index);
97+
98+
if (this.state === "text") {
99+
if (code === 0x1b) {
100+
this.state = "escape";
101+
} else if (code === 0x9b) {
102+
this.state = "csi";
103+
this.csiCompatPrefixOnly = true;
104+
} else if (code === 0x9d) {
105+
this.state = "osc";
106+
} else {
107+
output.push(input.charAt(index));
108+
}
109+
index += 1;
110+
continue;
111+
}
112+
113+
if (this.state === "osc") {
114+
if (code === 0x07 || code === 0x9c) {
115+
this.state = "text";
116+
} else if (code === 0x1b) {
117+
this.state = "osc-escape";
118+
}
119+
index += 1;
120+
continue;
121+
}
122+
123+
if (this.state === "osc-escape") {
124+
if (code === 0x5c || code === 0x07 || code === 0x9c) {
125+
this.state = "text";
126+
} else if (code !== 0x1b) {
127+
this.state = "osc";
128+
}
129+
index += 1;
130+
continue;
131+
}
132+
133+
if (this.state === "csi") {
134+
if (code === 0x18 || code === 0x1a) {
135+
this.state = "text";
136+
index += 1;
137+
} else if (code === 0x1b) {
138+
this.state = "escape";
139+
index += 1;
140+
} else if (code === 0x9b) {
141+
this.csiCompatPrefixOnly = true;
142+
index += 1;
143+
} else if (code === 0x9d) {
144+
this.state = "osc";
145+
index += 1;
146+
} else if (code <= 0x1f || code === 0x7f) {
147+
output.push(input.charAt(index));
148+
index += 1;
149+
} else if (code >= 0x20 && code <= 0x3f) {
150+
if (!isCompatPrefixCode(code)) {
151+
this.csiCompatPrefixOnly = false;
152+
}
153+
index += 1;
154+
} else if ((code === 0x5b || code === 0x5d) && this.csiCompatPrefixOnly) {
155+
// The compatibility grammar accepts bracket runs before parameters.
156+
// Keep them pending so a chunk split cannot expose the final byte.
157+
this.state = "compat";
158+
this.compatInParameters = false;
159+
this.compatParameterDigits = 0;
160+
index += 1;
161+
} else if (code >= 0x40 && code <= 0x7e) {
162+
this.state = "text";
163+
index += 1;
164+
} else {
165+
this.state = "text";
166+
}
167+
continue;
168+
}
169+
170+
if (this.state === "escape") {
171+
if (code === 0x5d) {
172+
this.state = "osc";
173+
index += 1;
174+
} else if (code === 0x5b) {
175+
this.state = "csi";
176+
this.csiCompatPrefixOnly = true;
177+
index += 1;
178+
} else if (code === 0x1b) {
179+
index += 1;
180+
} else if (code === 0x9b) {
181+
this.state = "csi";
182+
this.csiCompatPrefixOnly = true;
183+
index += 1;
184+
} else if (code === 0x9d) {
185+
this.state = "osc";
186+
index += 1;
187+
} else if (isCompatPrefixCode(code)) {
188+
this.state = "compat";
189+
this.compatInParameters = false;
190+
this.compatParameterDigits = 0;
191+
index += 1;
192+
} else if (isDigitCode(code)) {
193+
this.state = "compat";
194+
this.compatInParameters = true;
195+
this.compatParameterDigits = 1;
196+
index += 1;
197+
} else if (isCompatFinalCode(code)) {
198+
this.state = "text";
199+
index += 1;
200+
} else {
201+
this.state = "text";
202+
}
203+
continue;
204+
}
205+
206+
if (code === 0x18 || code === 0x1a) {
207+
this.state = "text";
208+
index += 1;
209+
} else if (code === 0x1b) {
210+
this.state = "escape";
211+
index += 1;
212+
} else if (code === 0x9b) {
213+
this.state = "csi";
214+
this.csiCompatPrefixOnly = true;
215+
index += 1;
216+
} else if (code === 0x9d) {
217+
this.state = "osc";
218+
index += 1;
219+
} else if (!this.compatInParameters && isCompatPrefixCode(code)) {
220+
index += 1;
221+
} else if (!this.compatInParameters && isDigitCode(code)) {
222+
this.compatInParameters = true;
223+
this.compatParameterDigits = 1;
224+
index += 1;
225+
} else if (this.compatInParameters && isCompatParameterCode(code)) {
226+
if (code === 0x3a || code === 0x3b) {
227+
this.compatParameterDigits = 0;
228+
index += 1;
229+
} else if (this.compatParameterDigits < 4) {
230+
this.compatParameterDigits += 1;
231+
index += 1;
232+
} else {
233+
this.state = "text";
234+
index += 1;
235+
}
236+
} else if (isCompatFinalCode(code)) {
237+
this.state = "text";
238+
index += 1;
239+
} else {
240+
this.state = "text";
241+
}
242+
}
243+
return output.join("");
244+
}
245+
246+
finish(): string {
247+
this.state = "text";
248+
this.csiCompatPrefixOnly = false;
249+
this.compatInParameters = false;
250+
this.compatParameterDigits = 0;
251+
return "";
252+
}
253+
}
254+
30255
/** Scan one CSI parser pass, retaining independently executed C0 controls. */
31256
export function scanAnsiCsiAt(input: string, index: number): AnsiCsiScan | undefined {
32257
const introducerLength = csiIntroducerLength(input, index);

packages/terminal-core/src/ansi.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Terminal Core tests cover ansi behavior.
22
import { describe, expect, it } from "vitest";
3+
import { AnsiSequenceStripper } from "./ansi-sequences.js";
34
import {
45
sanitizeForLog,
56
splitGraphemes,
@@ -27,6 +28,76 @@ describe("terminal ansi helpers", () => {
2728
expect(stripAnsi("\u001B]unterminated")).toBe("\u001B]unterminated");
2829
});
2930

31+
it.each([
32+
["ESC OSC with BEL", ["A\u001B]0;title", "\u0007B"]],
33+
["ESC OSC with ESC ST", ["A\u001B]0;title", "\u001B\\B"]],
34+
["C1 OSC with C1 ST", ["A\u009D0;title", "\u009CB"]],
35+
["C1 OSC with ESC ST", ["A\u009D0;title", "\u001B\\B"]],
36+
["ESC CSI", ["A\u001B[31", "mB"]],
37+
["C1 CSI", ["A\u009B31", "mB"]],
38+
["ESC compatibility charset", ["A\u001B(", "BB"]],
39+
["ESC compatibility bracket prefix", ["A\u001B[", "[AB"]],
40+
["ESC compatibility mixed prefixes", ["A\u001B(", "[31mB"]],
41+
])("strips chunked %s sequences incrementally", (_label, chunks) => {
42+
const stripper = new AnsiSequenceStripper();
43+
44+
const split = chunks.map((chunk) => stripper.write(chunk)).join("") + stripper.finish();
45+
const joined = stripAnsiSequences(chunks.join(""));
46+
47+
expect(split).toBe("AB");
48+
expect(split).toBe(joined);
49+
});
50+
51+
it("keeps a trailing ESC pending until the next chunk can identify OSC", () => {
52+
const chunks = ["A\u001B", "]0;title\u0007B"];
53+
const stripper = new AnsiSequenceStripper();
54+
55+
const split = chunks.map((chunk) => stripper.write(chunk)).join("") + stripper.finish();
56+
const joined = stripAnsiSequences(chunks.join(""));
57+
58+
expect(split).toBe("AB");
59+
expect(split).toBe(joined);
60+
});
61+
62+
it("drops unterminated chunked OSC payload without retaining it until finish", () => {
63+
const stripper = new AnsiSequenceStripper();
64+
65+
const split =
66+
stripper.write("line\n\t🙂\u001B]unter") + stripper.write("minated") + stripper.finish();
67+
68+
expect(split).toBe("line\n\t🙂");
69+
});
70+
71+
it("does not retain large unterminated OSC payloads", () => {
72+
const stripper = new AnsiSequenceStripper();
73+
const payload = "x".repeat(1024 * 1024);
74+
75+
const split = stripper.write("safe\u001B]0;") + stripper.write(payload) + stripper.finish();
76+
77+
expect(split).toBe("safe");
78+
});
79+
80+
it("accepts every standard CSI final byte after a chunk boundary", () => {
81+
for (let final = 0x40; final <= 0x7e; final += 1) {
82+
const stripper = new AnsiSequenceStripper();
83+
const split = stripper.write("A\u001B[31") + stripper.write(`${String.fromCharCode(final)}B`);
84+
85+
expect(split).toBe("AB");
86+
}
87+
});
88+
89+
it.each([
90+
["BEL", "\u0007"],
91+
["C1 ST", "\u009C"],
92+
])("terminates OSC after stray ESC before %s", (_label, terminator) => {
93+
const stripper = new AnsiSequenceStripper();
94+
const input = `A\u001B]0;title\u001B${terminator}B`;
95+
const split = stripper.write(input.slice(0, -1)) + stripper.write(input.slice(-1));
96+
97+
expect(stripAnsiSequences(input)).toBe("AB");
98+
expect(split).toBe("AB");
99+
});
100+
30101
it("strips the agent output escape grammar without changing text policy", () => {
31102
expect(stripAnsiSequences("\u001B[38:5:196mred\u001B[0m")).toBe("red");
32103
expect(stripAnsiSequences("\u009B31mred\u009B0m")).toBe("red");

packages/terminal-core/src/ansi.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
ANSI_COMPAT_CONTROL_SEQUENCE_PATTERN,
23
ANSI_OSC_INTRODUCER_PATTERN,
34
ANSI_STRING_TERMINATOR_PATTERN,
45
matchAnsiOscAt,
@@ -32,10 +33,8 @@ import {
3233
* SOFTWARE.
3334
*/
3435
const ANSI_OSC_SEQUENCE_PATTERN = `${ANSI_OSC_INTRODUCER_PATTERN}[\\s\\S]*?${ANSI_STRING_TERMINATOR_PATTERN}`;
35-
const ANSI_CONTROL_SEQUENCE_PATTERN =
36-
"[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]";
3736
const ANSI_COMPAT_SEQUENCE_AT_INDEX_REGEX = new RegExp(
38-
`${ANSI_OSC_SEQUENCE_PATTERN}|${ANSI_CONTROL_SEQUENCE_PATTERN}`,
37+
`${ANSI_OSC_SEQUENCE_PATTERN}|${ANSI_COMPAT_CONTROL_SEQUENCE_PATTERN}`,
3938
"y",
4039
);
4140
const graphemeSegmenter =

0 commit comments

Comments
 (0)