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 <xu.jincheng@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
jincheng-xydt
2026-07-18 23:33:40 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent ee0e3a4d47
commit 5a81e9fa81
7 changed files with 401 additions and 15 deletions
@@ -1,6 +1,8 @@
export const ANSI_OSC_INTRODUCER_PATTERN = "(?:\\x1b\\]|\\x9d)";
export const ANSI_STRING_TERMINATOR_PATTERN = "(?:\\x1b\\\\|\\x07|\\x9c)";
const ANSI_OSC_PATTERN = `${ANSI_OSC_INTRODUCER_PATTERN}[^\\x07\\x1b\\x9c]*${ANSI_STRING_TERMINATOR_PATTERN}`;
export const ANSI_COMPAT_CONTROL_SEQUENCE_PATTERN =
"[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]";
const ansiOscAtIndexRegex = new RegExp(ANSI_OSC_PATTERN, "y");
@@ -27,6 +29,229 @@ type AnsiCsiScan = {
value: string;
};
type AnsiStripState = "text" | "escape" | "osc" | "osc-escape" | "csi" | "compat";
function isCompatPrefixCode(code: number): boolean {
return (
code === 0x5b ||
code === 0x5d ||
code === 0x28 ||
code === 0x29 ||
code === 0x23 ||
code === 0x3b ||
code === 0x3f
);
}
function isCompatParameterCode(code: number): boolean {
return (code >= 0x30 && code <= 0x39) || code === 0x3a || code === 0x3b;
}
function isDigitCode(code: number): boolean {
return code >= 0x30 && code <= 0x39;
}
function isCompatFinalCode(code: number): boolean {
return (
(code >= 0x30 && code <= 0x39) ||
(code >= 0x40 && code <= 0x5a) ||
code === 0x63 ||
(code >= 0x66 && code <= 0x6e) ||
(code >= 0x71 && code <= 0x75) ||
code === 0x79 ||
code === 0x3d ||
code === 0x3e ||
code === 0x3c ||
code === 0x7e
);
}
/**
* Incrementally strip the ANSI grammar accepted by the agent output sanitizer.
* Parser state stays constant-size so unterminated OSC payloads cannot escape
* or accumulate outside the caller's output limits.
*/
export class AnsiSequenceStripper {
private state: AnsiStripState = "text";
private csiCompatPrefixOnly = false;
private compatInParameters = false;
private compatParameterDigits = 0;
write(input: string): string {
if (typeof input !== "string") {
throw new TypeError(`Expected a \`string\`, got \`${typeof input}\``);
}
if (
this.state === "text" &&
!input.includes("\u001B") &&
!input.includes("\u009B") &&
!input.includes("\u009D")
) {
return input;
}
const output: string[] = [];
let index = 0;
while (index < input.length) {
const code = input.charCodeAt(index);
if (this.state === "text") {
if (code === 0x1b) {
this.state = "escape";
} else if (code === 0x9b) {
this.state = "csi";
this.csiCompatPrefixOnly = true;
} else if (code === 0x9d) {
this.state = "osc";
} else {
output.push(input.charAt(index));
}
index += 1;
continue;
}
if (this.state === "osc") {
if (code === 0x07 || code === 0x9c) {
this.state = "text";
} else if (code === 0x1b) {
this.state = "osc-escape";
}
index += 1;
continue;
}
if (this.state === "osc-escape") {
if (code === 0x5c || code === 0x07 || code === 0x9c) {
this.state = "text";
} else if (code !== 0x1b) {
this.state = "osc";
}
index += 1;
continue;
}
if (this.state === "csi") {
if (code === 0x18 || code === 0x1a) {
this.state = "text";
index += 1;
} else if (code === 0x1b) {
this.state = "escape";
index += 1;
} else if (code === 0x9b) {
this.csiCompatPrefixOnly = true;
index += 1;
} else if (code === 0x9d) {
this.state = "osc";
index += 1;
} else if (code <= 0x1f || code === 0x7f) {
output.push(input.charAt(index));
index += 1;
} else if (code >= 0x20 && code <= 0x3f) {
if (!isCompatPrefixCode(code)) {
this.csiCompatPrefixOnly = false;
}
index += 1;
} else if ((code === 0x5b || code === 0x5d) && this.csiCompatPrefixOnly) {
// The compatibility grammar accepts bracket runs before parameters.
// Keep them pending so a chunk split cannot expose the final byte.
this.state = "compat";
this.compatInParameters = false;
this.compatParameterDigits = 0;
index += 1;
} else if (code >= 0x40 && code <= 0x7e) {
this.state = "text";
index += 1;
} else {
this.state = "text";
}
continue;
}
if (this.state === "escape") {
if (code === 0x5d) {
this.state = "osc";
index += 1;
} else if (code === 0x5b) {
this.state = "csi";
this.csiCompatPrefixOnly = true;
index += 1;
} else if (code === 0x1b) {
index += 1;
} else if (code === 0x9b) {
this.state = "csi";
this.csiCompatPrefixOnly = true;
index += 1;
} else if (code === 0x9d) {
this.state = "osc";
index += 1;
} else if (isCompatPrefixCode(code)) {
this.state = "compat";
this.compatInParameters = false;
this.compatParameterDigits = 0;
index += 1;
} else if (isDigitCode(code)) {
this.state = "compat";
this.compatInParameters = true;
this.compatParameterDigits = 1;
index += 1;
} else if (isCompatFinalCode(code)) {
this.state = "text";
index += 1;
} else {
this.state = "text";
}
continue;
}
if (code === 0x18 || code === 0x1a) {
this.state = "text";
index += 1;
} else if (code === 0x1b) {
this.state = "escape";
index += 1;
} else if (code === 0x9b) {
this.state = "csi";
this.csiCompatPrefixOnly = true;
index += 1;
} else if (code === 0x9d) {
this.state = "osc";
index += 1;
} else if (!this.compatInParameters && isCompatPrefixCode(code)) {
index += 1;
} else if (!this.compatInParameters && isDigitCode(code)) {
this.compatInParameters = true;
this.compatParameterDigits = 1;
index += 1;
} else if (this.compatInParameters && isCompatParameterCode(code)) {
if (code === 0x3a || code === 0x3b) {
this.compatParameterDigits = 0;
index += 1;
} else if (this.compatParameterDigits < 4) {
this.compatParameterDigits += 1;
index += 1;
} else {
this.state = "text";
index += 1;
}
} else if (isCompatFinalCode(code)) {
this.state = "text";
index += 1;
} else {
this.state = "text";
}
}
return output.join("");
}
finish(): string {
this.state = "text";
this.csiCompatPrefixOnly = false;
this.compatInParameters = false;
this.compatParameterDigits = 0;
return "";
}
}
/** Scan one CSI parser pass, retaining independently executed C0 controls. */
export function scanAnsiCsiAt(input: string, index: number): AnsiCsiScan | undefined {
const introducerLength = csiIntroducerLength(input, index);
+71
View File
@@ -1,5 +1,6 @@
// Terminal Core tests cover ansi behavior.
import { describe, expect, it } from "vitest";
import { AnsiSequenceStripper } from "./ansi-sequences.js";
import {
sanitizeForLog,
splitGraphemes,
@@ -27,6 +28,76 @@ describe("terminal ansi helpers", () => {
expect(stripAnsi("\u001B]unterminated")).toBe("\u001B]unterminated");
});
it.each([
["ESC OSC with BEL", ["A\u001B]0;title", "\u0007B"]],
["ESC OSC with ESC ST", ["A\u001B]0;title", "\u001B\\B"]],
["C1 OSC with C1 ST", ["A\u009D0;title", "\u009CB"]],
["C1 OSC with ESC ST", ["A\u009D0;title", "\u001B\\B"]],
["ESC CSI", ["A\u001B[31", "mB"]],
["C1 CSI", ["A\u009B31", "mB"]],
["ESC compatibility charset", ["A\u001B(", "BB"]],
["ESC compatibility bracket prefix", ["A\u001B[", "[AB"]],
["ESC compatibility mixed prefixes", ["A\u001B(", "[31mB"]],
])("strips chunked %s sequences incrementally", (_label, chunks) => {
const stripper = new AnsiSequenceStripper();
const split = chunks.map((chunk) => stripper.write(chunk)).join("") + stripper.finish();
const joined = stripAnsiSequences(chunks.join(""));
expect(split).toBe("AB");
expect(split).toBe(joined);
});
it("keeps a trailing ESC pending until the next chunk can identify OSC", () => {
const chunks = ["A\u001B", "]0;title\u0007B"];
const stripper = new AnsiSequenceStripper();
const split = chunks.map((chunk) => stripper.write(chunk)).join("") + stripper.finish();
const joined = stripAnsiSequences(chunks.join(""));
expect(split).toBe("AB");
expect(split).toBe(joined);
});
it("drops unterminated chunked OSC payload without retaining it until finish", () => {
const stripper = new AnsiSequenceStripper();
const split =
stripper.write("line\n\t🙂\u001B]unter") + stripper.write("minated") + stripper.finish();
expect(split).toBe("line\n\t🙂");
});
it("does not retain large unterminated OSC payloads", () => {
const stripper = new AnsiSequenceStripper();
const payload = "x".repeat(1024 * 1024);
const split = stripper.write("safe\u001B]0;") + stripper.write(payload) + stripper.finish();
expect(split).toBe("safe");
});
it("accepts every standard CSI final byte after a chunk boundary", () => {
for (let final = 0x40; final <= 0x7e; final += 1) {
const stripper = new AnsiSequenceStripper();
const split = stripper.write("A\u001B[31") + stripper.write(`${String.fromCharCode(final)}B`);
expect(split).toBe("AB");
}
});
it.each([
["BEL", "\u0007"],
["C1 ST", "\u009C"],
])("terminates OSC after stray ESC before %s", (_label, terminator) => {
const stripper = new AnsiSequenceStripper();
const input = `A\u001B]0;title\u001B${terminator}B`;
const split = stripper.write(input.slice(0, -1)) + stripper.write(input.slice(-1));
expect(stripAnsiSequences(input)).toBe("AB");
expect(split).toBe("AB");
});
it("strips the agent output escape grammar without changing text policy", () => {
expect(stripAnsiSequences("\u001B[38:5:196mred\u001B[0m")).toBe("red");
expect(stripAnsiSequences("\u009B31mred\u009B0m")).toBe("red");
+2 -3
View File
@@ -1,4 +1,5 @@
import {
ANSI_COMPAT_CONTROL_SEQUENCE_PATTERN,
ANSI_OSC_INTRODUCER_PATTERN,
ANSI_STRING_TERMINATOR_PATTERN,
matchAnsiOscAt,
@@ -32,10 +33,8 @@ import {
* SOFTWARE.
*/
const ANSI_OSC_SEQUENCE_PATTERN = `${ANSI_OSC_INTRODUCER_PATTERN}[\\s\\S]*?${ANSI_STRING_TERMINATOR_PATTERN}`;
const ANSI_CONTROL_SEQUENCE_PATTERN =
"[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]";
const ANSI_COMPAT_SEQUENCE_AT_INDEX_REGEX = new RegExp(
`${ANSI_OSC_SEQUENCE_PATTERN}|${ANSI_CONTROL_SEQUENCE_PATTERN}`,
`${ANSI_OSC_SEQUENCE_PATTERN}|${ANSI_COMPAT_CONTROL_SEQUENCE_PATTERN}`,
"y",
);
const graphemeSegmenter =
+73 -6
View File
@@ -48,22 +48,89 @@ describe("executeBashWithOperations", () => {
await rm(result.fullOutputPath!, { force: true });
});
it("does not consume a pending CSI at an output chunk boundary", async () => {
it("sanitizes ANSI and OSC sequences split across output chunks", async () => {
const chunks: string[] = [];
const operations: BashOperations = {
exec: async (_command, _cwd, options) => {
options.onData(Buffer.from("\u001b["));
options.onData(Buffer.from("Ksecret"));
for (const chunk of [
"A\u001B]0;title",
"\u0007B",
"C\u001B[31",
"mD",
"E\u009D0;title",
"\u001B\\F",
"G\u009B31",
"mH",
]) {
options.onData(Buffer.from(chunk));
}
return { exitCode: 0 };
},
};
const result = await executeBashWithOperations("printf output", "/tmp", operations, {
const result = await executeBashWithOperations("printf styled", "/tmp", operations, {
onChunk: (chunk) => chunks.push(chunk),
});
expect(result.output).toBe("\\x1b[Ksecret");
expect(chunks.join("")).toBe("\\x1b[Ksecret");
expect(chunks.join("")).toBe("ABCDEFGH");
expect(result.output).toBe("ABCDEFGH");
});
it("stores sanitized split ANSI output in spilled full output files", async () => {
const chunks = ["A\u001B]0;title", "\u0007B\n"];
const repeatedChunks = Array.from({ length: 9000 }, (_, index) => chunks[index % 2] ?? "");
const expectedOutput = "AB\n".repeat(4500);
const operations: BashOperations = {
exec: async (_command, _cwd, options) => {
for (const chunk of repeatedChunks) {
options.onData(Buffer.from(chunk));
}
return { exitCode: 0 };
},
};
const result = await executeBashWithOperations("printf styled", "/tmp", operations);
expect(result.truncated).toBe(true);
expect(result.fullOutputPath).toBeDefined();
expect(await readFile(result.fullOutputPath!, "utf8")).toBe(expectedOutput);
await rm(result.fullOutputPath!, { force: true });
});
it("sanitizes split compatibility escape grammar sequences", async () => {
const chunks: string[] = [];
const operations: BashOperations = {
exec: async (_command, _cwd, options) => {
options.onData(Buffer.from("A\u001B("));
options.onData(Buffer.from("BB"));
return { exitCode: 0 };
},
};
const result = await executeBashWithOperations("printf styled", "/tmp", operations, {
onChunk: (chunk) => chunks.push(chunk),
});
expect(chunks.join("")).toBe("AB");
expect(result.output).toBe("AB");
});
it("does not retain unterminated OSC payload outside accumulator limits", async () => {
const operations: BashOperations = {
exec: async (_command, _cwd, options) => {
options.onData(Buffer.from("safe\n\u001B]0;"));
options.onData(Buffer.from("x".repeat(DEFAULT_MAX_BYTES * 3)));
return { exitCode: 0 };
},
};
const result = await executeBashWithOperations("printf styled", "/tmp", operations);
expect(result.output).toBe("safe\n");
expect(result.truncated).toBe(false);
expect(result.fullOutputPath).toBeDefined();
expect(await readFile(result.fullOutputPath!, "utf8")).toBe("safe\n");
await rm(result.fullOutputPath!, { force: true });
});
it.each([
+3 -3
View File
@@ -6,7 +6,7 @@
* - Direct calls from modes that need bash execution
*/
import { sanitizeBinaryOutput } from "../shell-utils.js";
import { createStreamingBinaryOutputSanitizer } from "../shell-utils.js";
import type { BashOperations } from "./tools/bash-operations.js";
import { OutputAccumulator } from "./tools/output-accumulator.js";
@@ -48,10 +48,10 @@ export async function executeBashWithOperations(
operations: BashOperations,
options?: BashExecutorOptions,
): Promise<BashResult> {
const sanitizeOutput = createStreamingBinaryOutputSanitizer();
const output = new OutputAccumulator({
tempFilePrefix: "openclaw-bash",
transformDecodedText: (text) =>
sanitizeBinaryOutput(text, { ansiMode: "compat" }).replace(/\r/g, ""),
transformDecodedText: (text) => sanitizeOutput(text).replace(/\r/g, ""),
});
const onData = (data: Buffer) => {
+11
View File
@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import {
buildShellCommandInvocation,
createStreamingBinaryOutputSanitizer,
detectRuntimeShell,
getBashShellConfig,
getBashShellEnv,
@@ -42,6 +43,16 @@ describe("sanitizeBinaryOutput", () => {
});
});
describe("createStreamingBinaryOutputSanitizer", () => {
it("carries ANSI state across process-output chunks", () => {
const sanitize = createStreamingBinaryOutputSanitizer();
expect(sanitize("A\u001b]0;title")).toBe("A");
expect(sanitize("\u0007B\u001b[31")).toBe("B");
expect(sanitize("mC")).toBe("C");
});
});
function createTempCommandDir(
tempDirs: string[],
files: Array<{ name: string; executable?: boolean }>,
+16 -3
View File
@@ -6,6 +6,7 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { AnsiSequenceStripper } from "../../packages/terminal-core/src/ansi-sequences.js";
import { stripAnsiForStreamChunk } from "../../packages/terminal-core/src/ansi.js";
import {
killProcessTree as killProcessTreeGracefully,
@@ -361,9 +362,21 @@ export function sanitizeBinaryOutput(
): string {
// Output callbacks are stream chunks, not true EOF. Preserve a pending CSI
// visibly so a split final byte cannot leak from the following chunk.
const scrubbed = stripAnsiForStreamChunk(text, {
compatibilityGrammar: options?.ansiMode === "compat",
}).replace(/[\p{Format}\p{Surrogate}]/gu, "");
return sanitizeStrippedBinaryOutput(
stripAnsiForStreamChunk(text, {
compatibilityGrammar: options?.ansiMode === "compat",
}),
);
}
/** Keep one ANSI parser per process stream so control sequences can span callbacks. */
export function createStreamingBinaryOutputSanitizer(): (text: string) => string {
const ansiStripper = new AnsiSequenceStripper();
return (text) => sanitizeStrippedBinaryOutput(ansiStripper.write(text));
}
function sanitizeStrippedBinaryOutput(text: string): string {
const scrubbed = text.replace(/[\p{Format}\p{Surrogate}]/gu, "");
if (!scrubbed) {
return scrubbed;
}