Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sanitize-terminal-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openwiki": patch
---

Sanitize streamed Markdown output so model text cannot inject terminal control sequences (including OSC 52) into the CLI.
3 changes: 2 additions & 1 deletion src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down
173 changes: 173 additions & 0 deletions src/terminal-sanitize.ts
Original file line number Diff line number Diff line change
@@ -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;
}
64 changes: 64 additions & 0 deletions test/terminal-sanitize.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});