From 179c51249368754e7885d95a023b725d79328d0f Mon Sep 17 00:00:00 2001 From: Sudip Date: Wed, 5 Aug 2026 10:47:12 +0100 Subject: [PATCH] fix: sanitize terminal control sequences in streamed Markdown Prevent model/repo-influenced CLI output from injecting OSC/CSI/C0 sequences (including OSC 52) into developer terminals. --- .changeset/sanitize-terminal-controls.md | 5 + src/cli.tsx | 3 +- src/terminal-sanitize.ts | 173 +++++++++++++++++++++++ test/terminal-sanitize.test.ts | 64 +++++++++ 4 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 .changeset/sanitize-terminal-controls.md create mode 100644 src/terminal-sanitize.ts create mode 100644 test/terminal-sanitize.test.ts diff --git a/.changeset/sanitize-terminal-controls.md b/.changeset/sanitize-terminal-controls.md new file mode 100644 index 00000000..f9b18e30 --- /dev/null +++ b/.changeset/sanitize-terminal-controls.md @@ -0,0 +1,5 @@ +--- +"openwiki": patch +--- + +Sanitize streamed Markdown output so model text cannot inject terminal control sequences (including OSC 52) into the CLI. diff --git a/src/cli.tsx b/src/cli.tsx index 5d93634e..2c6cdf7a 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -46,6 +46,7 @@ import { sanitizeDiagnosticText, } from "./diagnostics.js"; import { stripHtmlTags } from "./utils.js"; +import { stripUnsafeTerminalSequences } from "./terminal-sanitize.js"; import { type OpenWikiRunEvent, type OpenWikiRunResult, @@ -1573,7 +1574,7 @@ function getSpinnerFrame(frame: number): string { } function MarkdownText({ markdown }: { markdown: string }) { - const tokens = marked.lexer(markdown, { + const tokens = marked.lexer(stripUnsafeTerminalSequences(markdown), { async: false, gfm: true, }); diff --git a/src/terminal-sanitize.ts b/src/terminal-sanitize.ts new file mode 100644 index 00000000..9c7042ba --- /dev/null +++ b/src/terminal-sanitize.ts @@ -0,0 +1,173 @@ +/** + * Strip terminal control sequences from untrusted text before it is rendered + * to a developer's terminal (for example through Ink). + * + * Preserves newline (`\n`) and tab (`\t`). Removes ESC/CSI/OSC/DCS-family + * sequences, BEL, CR, other C0 controls, DEL, and C1 controls. + */ +export function stripUnsafeTerminalSequences(value: string): string { + let remaining = value; + let sanitized = ""; + + while (remaining.length > 0) { + const escapeIndex = findControlIntroducerIndex(remaining); + + if (escapeIndex === -1) { + sanitized += stripResidualControls(remaining); + break; + } + + sanitized += stripResidualControls(remaining.slice(0, escapeIndex)); + const sequenceStart = remaining.slice(escapeIndex); + const consumed = consumeControlSequence(sequenceStart); + remaining = sequenceStart.slice(consumed); + } + + return sanitized; +} + +function findControlIntroducerIndex(value: string): number { + for (let index = 0; index < value.length; index += 1) { + const codePoint = value.codePointAt(index); + + if ( + codePoint === 0x1b || + codePoint === 0x9b || + codePoint === 0x9d || + codePoint === 0x90 || + codePoint === 0x98 || + codePoint === 0x9e || + codePoint === 0x9f + ) { + return index; + } + } + + return -1; +} + +function stripResidualControls(value: string): string { + let sanitized = ""; + + for (const character of value) { + const codePoint = character.codePointAt(0); + + if (codePoint === undefined) { + continue; + } + + if (codePoint === 9 || codePoint === 10) { + sanitized += character; + continue; + } + + if ( + codePoint <= 31 || + codePoint === 127 || + (codePoint >= 128 && codePoint <= 159) + ) { + continue; + } + + sanitized += character; + } + + return sanitized; +} + +function consumeControlSequence(value: string): number { + if (value.length === 0) { + return 0; + } + + const firstCodePoint = value.codePointAt(0); + + if (firstCodePoint === 0x9b) { + return 1 + consumeCsiParameters(value.slice(1)); + } + + if (firstCodePoint === 0x9d) { + return 1 + consumeOscPayload(value.slice(1)); + } + + if ( + firstCodePoint === 0x90 || + firstCodePoint === 0x98 || + firstCodePoint === 0x9e || + firstCodePoint === 0x9f + ) { + return 1 + consumeStringTerminatedPayload(value.slice(1)); + } + + if (firstCodePoint !== 0x1b) { + return 1; + } + + if (value.length === 1) { + return 1; + } + + const introducer = value[1]; + + if (introducer === "[") { + return 2 + consumeCsiParameters(value.slice(2)); + } + + if (introducer === "]") { + return 2 + consumeOscPayload(value.slice(2)); + } + + if ( + introducer === "P" || + introducer === "X" || + introducer === "^" || + introducer === "_" + ) { + return 2 + consumeStringTerminatedPayload(value.slice(2)); + } + + // Two-character escape (or lone ESC when nothing follows to form a longer sequence). + return 2; +} + +function consumeCsiParameters(value: string): number { + for (let index = 0; index < value.length; index += 1) { + const codePoint = value.codePointAt(index); + + if (codePoint === undefined) { + return index; + } + + if (codePoint >= 0x40 && codePoint <= 0x7e) { + return index + 1; + } + } + + return value.length; +} + +function consumeOscPayload(value: string): number { + for (let index = 0; index < value.length; index += 1) { + const codePoint = value.codePointAt(index); + + if (codePoint === 0x07) { + return index + 1; + } + + if (codePoint === 0x1b && value[index + 1] === "\\") { + return index + 2; + } + } + + return value.length; +} + +function consumeStringTerminatedPayload(value: string): number { + for (let index = 0; index < value.length; index += 1) { + if (value.codePointAt(index) === 0x1b && value[index + 1] === "\\") { + return index + 2; + } + } + + return value.length; +} diff --git a/test/terminal-sanitize.test.ts b/test/terminal-sanitize.test.ts new file mode 100644 index 00000000..40fcc9bd --- /dev/null +++ b/test/terminal-sanitize.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "vitest"; +import { stripUnsafeTerminalSequences } from "../src/terminal-sanitize.ts"; + +describe("stripUnsafeTerminalSequences", () => { + test("preserves ordinary text, newlines, and tabs", () => { + const input = "hello\n\tworld"; + expect(stripUnsafeTerminalSequences(input)).toBe(input); + }); + + test("removes OSC 52 clipboard sequences terminated with BEL", () => { + const payload = "SGVsbG8="; + const input = `before\u001b]52;c;${payload}\u0007after`; + expect(stripUnsafeTerminalSequences(input)).toBe("beforeafter"); + expect(stripUnsafeTerminalSequences(input)).not.toContain("\u001b"); + expect(stripUnsafeTerminalSequences(input)).not.toContain("\u0007"); + expect(stripUnsafeTerminalSequences(input)).not.toContain(payload); + }); + + test("removes OSC 52 sequences terminated with ST", () => { + const input = "before\u001b]52;c;SGVsbG8=\u001b\\after"; + expect(stripUnsafeTerminalSequences(input)).toBe("beforeafter"); + }); + + test("removes OSC 8 hyperlink sequences", () => { + const input = + "click \u001b]8;;https://evil.example\u0007here\u001b]8;;\u0007 please"; + expect(stripUnsafeTerminalSequences(input)).toBe("click here please"); + }); + + test("removes CSI clear and cursor sequences", () => { + const input = "keep\u001b[2J\u001b[H\u001b[0mmore"; + expect(stripUnsafeTerminalSequences(input)).toBe("keepmore"); + }); + + test("removes BEL and carriage return", () => { + const input = "a\u0007b\rc"; + expect(stripUnsafeTerminalSequences(input)).toBe("abc"); + }); + + test("removes C1 controls including 8-bit CSI and OSC introducers", () => { + const input = `safe\u009b2J\u009d52;c;QQ==\u0007text`; + expect(stripUnsafeTerminalSequences(input)).toBe("safetext"); + }); + + test("strips incomplete OSC sequences at end of input", () => { + const input = "prefix\u001b]52;c;partial"; + expect(stripUnsafeTerminalSequences(input)).toBe("prefix"); + }); + + test("sanitizes sequences inside markdown code fences and inline code", () => { + const fenced = ["```", "echo hi\u001b]52;c;QQ==\u0007", "```"].join("\n"); + const inline = "use `x\u001b[2Jy` carefully"; + + expect(stripUnsafeTerminalSequences(fenced)).toBe( + ["```", "echo hi", "```"].join("\n"), + ); + expect(stripUnsafeTerminalSequences(inline)).toBe("use `xy` carefully"); + }); + + test("keeps printable unicode outside the control ranges", () => { + const input = "café 你好 🙂"; + expect(stripUnsafeTerminalSequences(input)).toBe(input); + }); +});