Skip to content

fix(tui): sanitize untrusted terminal text and allowlist hyperlink targets - #2171

Draft
kevinjosethomas wants to merge 2 commits into
mainfrom
eng-5344-terminal-output-sanitize
Draft

fix(tui): sanitize untrusted terminal text and allowlist hyperlink targets#2171
kevinjosethomas wants to merge 2 commits into
mainfrom
eng-5344-terminal-output-sanitize

Conversation

@kevinjosethomas

@kevinjosethomas kevinjosethomas commented Sep 9, 2026

Copy link
Copy Markdown
Member

Context

Linear: ENG-5344 — https://linear.app/primeintellect/issue/ENG-5344

Model and tool text reached the terminal unfiltered. Markdown (pi-tui) and AssistantMessageComponent passed raw control sequences from assistant text, thinking and code blocks straight into rendered lines: OSC 52 clipboard writes, CSI 2J/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:, explicit file:, unresolvable relative paths), and an href containing ESC \ 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: new sanitizeTerminalText() — 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 per setText()/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 via baseUrl (never from an explicit file: 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.
  • coding-agent boundaries where model/tool text becomes a rendered line: thinking recap, assistant error text, 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.
  • Regression tests: packages/tui/test/markdown-sanitize.test.ts (node --test) and packages/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 under TERM=dumb). 2108-assistant-message-links.test.ts updated for the allowlist (#anchor, explicit file:, unparseable URL now fall back to text).

Validation

Local (worktree, npm run check clean):

  • 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 over suite/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-status and related regressions — 20 files, 462 tests pass.

Prime Sandbox (node:24-bookworm, user tester, fresh HOME, TERM=dumb, git archive of both trees, one HUSKY=0 npm ci):

  • Before (main @ 427ea4c), original validation fixtures asserting the vulnerable behaviour: tui eng5344-render 6/6 pass, coding-agent eng5344-assistant 3/3 pass. Recorded: OSC 52, CSI 2J/H/3J, OSC 0, kitty APC and C1 CSI/DCS all passedThrough: true; javascript:, file:///etc/passwd, ../../etc/passwd emitted as OSC 8 targets; injected ESC \ href terminated the OSC 8 and emitted the trailing OSC 52 as its own sequence.
  • After (branch): the same fixtures now fail exactly where they assert pass-through (tui 5 pass / 1 fail on the link case; coding-agent 3/3 fail). Recorded: every passedThrough: false, rendered text shows ␛]52;c;U0VOVElORUw=; javascript:/file:/relative fall back to label (url) text; the injected href renders as https://good.example/%E2%90%9B%E2%90%9B]52;c;QUFB with no embedded ESC/BEL; https://good.example/path still an OSC 8 target; OSC 133 zone markers still present.
  • Branch test runs in the sandbox: tui 201 pass / 0 fail; coding-agent 10 files, 167 tests pass.
  • Sandbox deleted afterwards.

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.setTitle sanitization has no dedicated test (it writes to process.stdout); the shared sanitizeTerminalText it uses is unit-tested.

Model-facing surface (tool names, system prompt, kernel recursion API) is unchanged.

…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
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Prime Agent performance — completed

PR 04326998 compared with main f9c7e06b.
↓ improved · ↑ regressed · ≈ no clear change · — unavailable

Metric Main This PR Change Change % Result
Cold startup 3,722.3 ms 3,074.2 ms ≈ -648.1 ms -17.41% no clear change
Warm startup 2,256.9 ms 1,789.7 ms ↓ -467.3 ms -20.70% faster
Installation 34.20 s 29.76 s ≈ -4.45 s -13.00% no clear change
Compressed release artifacts 11.10 MB 11.11 MB ≈ +0.0074 MB +0.07% no clear change
Installed footprint 597.35 MB 597.37 MB ≈ +0.02 MB +0.00% no clear change
Idle memory, summed RSS 1,119.43 MB 1,091.49 MB ≈ -27.94 MB -2.50% no clear change

Python runtime

Metric Main This PR Change Change % Result
Python kernel startup 169.8 ms 130.7 ms ↓ -39.0 ms -23.00% faster
Python cell round trip 0.585 ms 0.585 ms ≈ -0.00062 ms -0.11% no clear change
Empty bash command 13.9 ms 13.0 ms ≈ -0.9 ms -6.79% no clear change
Bash git status 19.9 ms 18.8 ms ≈ -1.1 ms -5.72% no clear change
Bash 32 KiB output 13.7 ms 13.1 ms ≈ -0.6 ms -4.58% no clear change
35 cells / 9 shell calls 198.6 ms 194.7 ms ≈ -3.9 ms -1.98% no clear change
Python interrupt to done 1.597 ms 1.691 ms ≈ +0.094 ms +5.87% no clear change
Python state snapshot 27.6 ms 28.1 ms ≈ +0.5 ms +1.76% no clear change
Python state restore 400.5 ms 383.9 ms ≈ -16.5 ms -4.13% no clear change
Python idle RSS 34.02 MB 35.76 MB ≈ +1.74 MB +5.12% no clear change
Python RSS after pandas workload 96.13 MB 97.83 MB ≈ +1.70 MB +1.77% no clear change

Sandbox cost: ~$0.1025 — no inference calls.
Run, logs, and downloadable raw results

Methodology and samples

Main resolved at 2026-09-09T23:53:46.670727+00:00. Harness f9c7e06b.
Linux x64, 4 vCPU, 8 GB RAM, 20 GB disk; region us.
Image: node:24-bookworm@sha256:be23f54a88d34e8824c741b19b91064094f92c1c97b194144bfc8b50d67258e2.
Stock tools, skills, daemon, and Python bootstrap enabled; fresh homes and a fixed Git fixture.
Onboarding is dismissed; the editor starts without a selected model or submitted prompt.
Medians shown. Arrows require a 20% timing/memory change plus absolute floors and IQR.
These practical noise floors are not a statistical significance test.
Cold means stopped Prime processes; OS filesystem caches are not flushed.
No model requests or credentials. Installation excludes build/setup time.
Installer tarballs use loopback; npm/Python downloads use the network with fresh caches.
Artifact size counts release tarballs; footprint after first use includes registry packages.
MB is decimal. Summed RSS can double-count shared pages; PSS is recorded when available.
Provisioning, setup, and build durations are recorded separately in the raw results.
Kernel probes use the installed JSONL runtime, outside the TUI/TypeScript host.
Per trial: 50 Python cells, 5 calls per shell case, and one 35-cell mix (9 git status calls).
Cell/shell values are batch means; other runtime timings are single operations.
State fixture: a 10,000-row × 8-column integer DataFrame and a 10,000-integer list.
Restore runs in a fresh kernel, including pandas imports; kernel startup is excluded.
Kernel RSS covers the isolated Python process; loaded RSS follows the pandas workload.
Costs estimate full sandbox lifetimes at configured rates, including setup and build.
Budget target: $1; not a billing cap. Checks are informational.

Metric Main successful/attempted PR successful/attempted Main spread PR spread
Cold startup 10/10 10/10 IQR 235.3 ms IQR 146.4 ms
Warm startup 10/10 10/10 IQR 117.0 ms IQR 169.2 ms
Installation 3/3 3/3 range 7.24 s range 3.85 s
Compressed release artifacts 1/1 1/1
Installed footprint 1/1 1/1
Idle memory, summed RSS 10/10 10/10 IQR 30.81 MB IQR 112.53 MB
Python kernel startup 10/10 10/10 IQR 21.7 ms IQR 16.6 ms
Python cell round trip 10/10 10/10 IQR 0.082 ms IQR 0.090 ms
Empty bash command 10/10 10/10 IQR 1.2 ms IQR 0.5 ms
Bash git status 10/10 10/10 IQR 1.8 ms IQR 1.5 ms
Bash 32 KiB output 10/10 10/10 IQR 1.6 ms IQR 1.1 ms
35 cells / 9 shell calls 10/10 10/10 IQR 14.3 ms IQR 7.3 ms
Python interrupt to done 10/10 10/10 IQR 0.187 ms IQR 0.107 ms
Python state snapshot 10/10 10/10 IQR 3.1 ms IQR 1.6 ms
Python state restore 10/10 10/10 IQR 41.0 ms IQR 34.9 ms
Python idle RSS 10/10 10/10 IQR 4.11 MB IQR 2.94 MB
Python RSS after pandas workload 10/10 10/10 IQR 5.15 MB IQR 3.22 MB

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant