fix(tui): sanitize untrusted terminal text and allowlist hyperlink targets - #2171
fix(tui): sanitize untrusted terminal text and allowlist hyperlink targets#2171kevinjosethomas wants to merge 2 commits into
Conversation
…rgets Model and tool text reached the terminal unfiltered: Markdown and the assistant message component passed OSC/CSI/DCS/APC and C1 control sequences through to rendered lines, and Markdown link hrefs became OSC 8 targets verbatim (javascript:, explicit file:, injected ESC). Add sanitizeTerminalText() to pi-tui and apply it once per source update in Markdown and at every coding-agent boundary where model or tool text becomes a rendered line. Restrict OSC 8 targets to http, https, mailto, and file links the renderer resolved from a local path via baseUrl; rejected links fall back to the hyperlinks-off rendering. Linear: ENG-5344
Prime Agent performance — completedPR
Python runtime
Sandbox cost: ~$0.1025 — no inference calls. Methodology and samplesMain resolved at 2026-09-09T23:53:46.670727+00:00. Harness
|
Context
Linear: ENG-5344 — https://linear.app/primeintellect/issue/ENG-5344
Model and tool text reached the terminal unfiltered.
Markdown(pi-tui) andAssistantMessageComponentpassed raw control sequences from assistant text, thinking and code blocks straight into rendered lines: OSC 52 clipboard writes, CSI2J/H/3J, OSC 0 titles, kitty APC, and C1 controls (0x9b CSI, 0x90 DCS). With hyperlinks enabled, Markdown link hrefs became OSC 8 targets verbatim (javascript:, explicitfile:, unresolvable relative paths), and an href containingESC \closed the OSC 8 early so the remainder executed as its own sequence.Root cause: no sanitization step exists between untrusted text and the renderer; the renderer's ANSI-aware helpers only account for renderer-owned styling.
Changes
packages/tui/src/utils.ts: newsanitizeTerminalText()— strips C0 controls (except\n/\t), DEL and C1 controls, and replaces ESC with a visible␛so every ESC-initiated sequence (CSI, OSC, DCS, APC, PM, SOS, SS2/SS3, two-byte escapes) degrades to plain text. Fast path returns the input untouched when no such byte is present. Exported from@earendil-works/pi-tui.Markdown: sanitizes the source once persetText()/construction (not per frame), before the optional transform so renderer-owned styling (mermaid, theme SGR, OSC 8) is unaffected. OSC 8 hrefs are now allowlisted:http:,https:,mailto:always;file:only when this instance resolved a local path viabaseUrl(never from an explicitfile:href, never with a host). Unparseable or rejected hrefs fall back to the hyperlinks-off rendering (URL shown as text).ProcessTerminal.setTitle(): sanitizes the title so a session name cannot terminate the OSC 0 early.normalizeErrorDetails, tool result text output, ipython cell code/traceback/exception name/diff paths, agent-message previews and bodies, bash tool command display, edit tool path/error text, diff renderers, generic tool fallback,!bash output.packages/tui/test/markdown-sanitize.test.ts(node --test) andpackages/coding-agent/test/suite/regressions/eng-5344-terminal-output-sanitize.test.ts(vitest) cover each sequence class and the href allowlist while asserting renderer-owned styling still renders (bold SGR in the tui test; theme SGR and OSC 133 zone markers in the coding-agent test, since chalk bold is off underTERM=dumb).2108-assistant-message-links.test.tsupdated for the allowlist (#anchor, explicitfile:, unparseable URL now fall back to text).Validation
Local (worktree,
npm run checkclean):packages/tui:node --test --import tsx test/markdown-sanitize.test.ts test/markdown.test.ts test/markdown-latex.test.ts test/hyperlink-at-column.test.ts test/wrap-ansi.test.ts test/tui-render.test.ts test/terminal.test.ts test/truncated-text.test.ts test/truncate-to-width.test.ts test/visible-content-span.test.ts test/selection-metadata.test.ts— 206 pass, 0 fail.packages/coding-agent: vitest oversuite/regressions/eng-5344-terminal-output-sanitize,2108-assistant-message-links,marquee-components,interactive-mode-streaming,assistant-message,tool-execution-component,ipython-cell-*,bash-execution-width,edit-tool-*,code-preview,tools,interactive-mode-statusand related regressions — 20 files, 462 tests pass.Prime Sandbox (
node:24-bookworm, usertester, fresh HOME,TERM=dumb,git archiveof both trees, oneHUSKY=0 npm ci):eng5344-render6/6 pass, coding-agenteng5344-assistant3/3 pass. Recorded: OSC 52, CSI 2J/H/3J, OSC 0, kitty APC and C1 CSI/DCS allpassedThrough: true;javascript:,file:///etc/passwd,../../etc/passwdemitted as OSC 8 targets; injectedESC \href terminated the OSC 8 and emitted the trailing OSC 52 as its own sequence.passedThrough: false, rendered text shows␛]52;c;U0VOVElORUw=;javascript:/file:/relative fall back tolabel (url)text; the injected href renders ashttps://good.example/%E2%90%9B%E2%90%9B]52;c;QUFBwith no embedded ESC/BEL;https://good.example/pathstill an OSC 8 target; OSC 133 zone markers still present.Not validated: terminal-side effects against a live terminal (clipboard actually set, title changed) — the rendering boundary is the finding and is what the tests assert.
ProcessTerminal.setTitlesanitization has no dedicated test (it writes toprocess.stdout); the sharedsanitizeTerminalTextit uses is unit-tested.Model-facing surface (tool names, system prompt, kernel recursion API) is unchanged.