Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed assistant text, thinking, tool output and link targets being able to send terminal control sequences (clipboard writes, screen clears, title changes) to the terminal ([ENG-5344](https://linear.app/primeintellect/issue/ENG-5344)).
5 changes: 3 additions & 2 deletions packages/coding-agent/src/core/tools/bash.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync } from "node:fs";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
import { Container, sanitizeTerminalText, Text, truncateToWidth } from "@earendil-works/pi-tui";
import { type Static, Type } from "typebox";
import { expandCollapseHint } from "../../modes/interactive/components/keybinding-hints.js";
import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.js";
Expand Down Expand Up @@ -175,7 +175,8 @@ function formatDuration(ms: number): string {
}

function formatBashCall(args: { command?: string; timeout?: number } | undefined): string {
const command = str(args?.command);
const rawCommand = str(args?.command);
const command = rawCommand === null ? null : sanitizeTerminalText(rawCommand);
const timeout = args?.timeout as number | undefined;
const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
let commandDisplay: string;
Expand Down
14 changes: 11 additions & 3 deletions packages/coding-agent/src/core/tools/edit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { Box, type Component, Container, Spacer, Text, wrapTextWithAnsi } from "@earendil-works/pi-tui";
import {
Box,
type Component,
Container,
Spacer,
sanitizeTerminalText,
Text,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
import { type Static, Type } from "typebox";
Expand Down Expand Up @@ -201,7 +209,7 @@ function formatEditCall(
): string {
const invalidArg = invalidArgText(theme);
const rawPath = str(args?.file_path ?? args?.path);
const path = rawPath !== null ? shortenPath(rawPath) : null;
const path = rawPath !== null ? sanitizeTerminalText(shortenPath(rawPath)) : null;
const pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
return `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
}
Expand All @@ -224,7 +232,7 @@ function formatEditResult(
if (!errorText || errorText === previewError) {
return undefined;
}
return theme.fg("error", errorText);
return theme.fg("error", sanitizeTerminalText(errorText));
}

const resultDiff = result.details?.diff;
Expand Down
6 changes: 4 additions & 2 deletions packages/coding-agent/src/core/tools/render-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as os from "node:os";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import { getImageDimensions, imageFallback } from "@earendil-works/pi-tui";
import { getImageDimensions, imageFallback, sanitizeTerminalText } from "@earendil-works/pi-tui";
import stripAnsi from "strip-ansi";
import { sanitizeBinaryOutput } from "../../utils/shell.js";

Expand Down Expand Up @@ -38,7 +38,9 @@ export function getTextOutput(
const textBlocks = result.content.filter((c) => c.type === "text");
const imageBlocks = result.content.filter((c) => c.type === "image");

let output = textBlocks.map((c) => sanitizeBinaryOutput(stripAnsi(c.text || "")).replace(/\r/g, "")).join("\n");
let output = textBlocks
.map((c) => sanitizeTerminalText(sanitizeBinaryOutput(stripAnsi(c.text || "")).replace(/\r/g, "")))
.join("\n");

const includeImageDimensions = options.includeImageDimensions ?? true;
if (imageBlocks.length > 0 && !showImages) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Container,
type MarkdownTheme,
Spacer,
sanitizeTerminalText,
Text,
truncateToWidth,
visibleWidth,
Expand All @@ -13,7 +14,7 @@ import { getMarkdownTheme, theme } from "../theme/theme.js";
import { expandCollapseHint } from "./keybinding-hints.js";

function collapseText(text: string): string {
return text.replace(/\s+/g, " ").trim();
return sanitizeTerminalText(text).replace(/\s+/g, " ").trim();
}

/** `◆ <label> · <participant>[ · <preview>]` summary line shared by received and sent agent-message UI. */
Expand All @@ -34,10 +35,12 @@ export function agentMessagePreview(prefixWidth: number, message: string): strin
export function agentMessageBodyLines(message: string, width: number): string[] {
const safeWidth = Math.max(1, width);
const textWidth = Math.max(1, safeWidth - 4);
const bodyLines = message.split("\n").flatMap((line) => {
const wrapped = wrapTextWithAnsi(line, textWidth);
return wrapped.length > 0 ? wrapped : [""];
});
const bodyLines = sanitizeTerminalText(message)
.split("\n")
.flatMap((line) => {
const wrapped = wrapTextWithAnsi(line, textWidth);
return wrapped.length > 0 ? wrapped : [""];
});
return bodyLines.map((line, index) => {
const prefix = index === 0 ? theme.fg("dim", "╰─ ") : " ";
return truncateToWidth(` ${prefix}${theme.fg("customMessageText", line)}`, safeWidth, "");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Markdown,
type MarkdownTheme,
Spacer,
sanitizeTerminalText,
Text,
truncateToWidth,
visibleWidth,
Expand Down Expand Up @@ -79,7 +80,7 @@ class CollapsedThinkingRow implements Component {
* non-empty line, stripped of markdown emphasis and truncated.
*/
export function thinkingRecap(thinking: string, fallback: string, maxWidth = 120): string {
const lines = thinking
const lines = sanitizeTerminalText(thinking)
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
Expand Down Expand Up @@ -365,7 +366,8 @@ export class AssistantMessageComponent extends Container {
}
}

private createErrorComponent(message: string, prefix?: string): Component {
private createErrorComponent(rawMessage: string, prefix?: string): Component {
const message = sanitizeTerminalText(rawMessage);
const inlineLoginRecovery = formatInlineLoginRecoveryMessage(message);
if (inlineLoginRecovery) {
const text = prefix ? `${prefix}: ${inlineLoginRecovery}` : inlineLoginRecovery;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Container, Loader, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import { Container, Loader, Spacer, sanitizeTerminalText, Text, type TUI } from "@earendil-works/pi-tui";
import stripAnsi from "strip-ansi";
import {
DEFAULT_MAX_BYTES,
Expand Down Expand Up @@ -70,7 +70,7 @@ export class BashExecutionComponent extends Container {

appendOutput(chunk: string): void {
// Note: binary data is already sanitized in tui-renderer.ts executeBashCommand
const clean = stripAnsi(chunk).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const clean = sanitizeTerminalText(stripAnsi(chunk).replace(/\r\n/g, "\n").replace(/\r/g, "\n"));

const newLines = clean.split("\n");
if (this.outputLines.length > 0 && newLines.length > 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { type Component, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
import {
type Component,
sanitizeTerminalText,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import stripAnsi from "strip-ansi";
import { theme } from "../theme/theme.js";
import { expandCollapseHint } from "./keybinding-hints.js";
Expand All @@ -12,7 +18,7 @@ export interface CollapsibleErrorOptions {
}

export function normalizeErrorDetails(text: string): string {
return stripAnsi(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n").trimEnd();
return sanitizeTerminalText(stripAnsi(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n")).trimEnd();
}

interface ErrorDetailLine {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
import { sanitizeTerminalText, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
import * as Diff from "diff";
import { highlightCode, theme } from "../theme/theme.js";

Expand Down Expand Up @@ -78,7 +78,7 @@ export interface RenderDiffOptions {
* - Added lines: green, with inverse on changed tokens
*/
export function renderDiff(diffText: string, _options: RenderDiffOptions = {}): string {
const lines = diffText.split("\n");
const lines = sanitizeTerminalText(diffText).split("\n");
const result: string[] = [];

let i = 0;
Expand Down Expand Up @@ -233,7 +233,7 @@ export function renderRichDiff(diffText: string, contentWidth: number, options:
const useBlocks = theme.colorMode === "truecolor";
const rows: string[] = [];

for (const rawLine of diffText.split("\n")) {
for (const rawLine of sanitizeTerminalText(diffText).split("\n")) {
const parsed = parseDiffLine(rawLine);
if (!parsed) {
rows.push(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
type Component,
sanitizeTerminalText,
truncateToWidth,
VersionedRenderCache,
visibleWidth,
Expand Down Expand Up @@ -132,7 +133,7 @@ export function getIpythonCodeFromArgs(args: unknown): string {
return "";
}
const code = (args as { code?: unknown }).code;
return typeof code === "string" ? code : "";
return typeof code === "string" ? sanitizeTerminalText(code) : "";
}

function readDetails(details: unknown): IpythonDetails {
Expand Down Expand Up @@ -211,7 +212,7 @@ function readDiffDisplays(value: unknown): DiffDisplay[] | undefined {
}
return [
{
path: record.path,
path: sanitizeTerminalText(record.path),
oldStr: record.oldStr,
newStr: record.newStr,
startLine: typeof record.startLine === "number" ? record.startLine : undefined,
Expand Down Expand Up @@ -268,10 +269,12 @@ function readErrorDetails(value: unknown): IpythonErrorDetails | undefined {
return undefined;
}
return {
ename: record.ename,
ename: sanitizeTerminalText(record.ename),
evalue: typeof record.evalue === "string" ? record.evalue : "",
traceback: Array.isArray(record.traceback)
? record.traceback.filter((line): line is string => typeof line === "string")
? record.traceback
.filter((line): line is string => typeof line === "string")
.map((line) => normalizeErrorDetails(line))
: [],
};
}
Expand Down Expand Up @@ -333,17 +336,23 @@ function formatIpythonErrorSummary(error: IpythonErrorDetails): string {
return visibleWidth(value) <= 48 ? `${error.ename}: ${value}` : error.ename;
}

// Model-written code is untrusted; sanitize once per state update, not per render.
function sanitizeState(state: IPythonCellState): IPythonCellState {
const code = sanitizeTerminalText(state.code);
return code === state.code ? state : { ...state, code };
}

export class IPythonCellComponent implements Component {
private readonly renderCache = new VersionedRenderCache();
private state: IPythonCellState;
private stateVersion = 0;

constructor(state: IPythonCellState) {
this.state = state;
this.state = sanitizeState(state);
}

update(state: IPythonCellState): void {
this.state = state;
this.state = sanitizeState(state);
this.stateVersion += 1;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
import { type Component, Container, Image, Text, type TUI } from "@earendil-works/pi-tui";
import { type Component, Container, Image, sanitizeTerminalText, Text, type TUI } from "@earendil-works/pi-tui";
import type { ToolDefinition, ToolRenderContext, ToolRenderResultOptions } from "../../../core/extensions/types.js";
import type { KernelSentAgentMessage } from "../../../core/kernel/index.js";
import { createBashToolDefinition } from "../../../core/tools/bash.js";
Expand Down Expand Up @@ -507,7 +507,8 @@ export class ToolExecutionComponent extends Container {

private formatToolExecution(): string {
const parts: string[] = [];
const content = JSON.stringify(this.args, null, 2);
// JSON.stringify escapes C0 controls but leaves DEL and C1 controls intact.
const content = sanitizeTerminalText(JSON.stringify(this.args, null, 2) ?? "");
if (content) {
parts.push(content);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,20 @@ describe("assistant Markdown file links", () => {

test.each([
["audit-out/report.md", reportUrl],
["#overview", "#overview"],
["./audit-out/report.md", reportUrl],
["../report.md", pathToFileURL(resolve(cwd, "../report.md")).href],
["/tmp/report.md", pathToFileURL("/tmp/report.md").href],
["<audit-out/my report.md>", pathToFileURL(resolve(cwd, "audit-out/my report.md")).href],
["audit-out/my%20report.md", pathToFileURL(resolve(cwd, "audit-out/my report.md")).href],
["audit-out/report%23draft%25.md", pathToFileURL(resolve(cwd, "audit-out/report#draft%.md")).href],
["audit-out/report.md#findings", `${reportUrl}#findings`],
["file:///tmp/report.md", "file:///tmp/report.md"],
["C:/repo/report.md", "file:///C:/repo/report.md"],
[String.raw`C:\repo\report.md`, "file:///C:/repo/report.md"],
["<D:/repo/my report.md>", "file:///D:/repo/my%20report.md"],
["D:/repo/report%23draft.md#findings", "file:///D:/repo/report%23draft.md#findings"],
["https://example.com/report?q=1#findings", "https://example.com/report?q=1#findings"],
["http://example.com/report", "http://example.com/report"],
["mailto:reader@example.com", "mailto:reader@example.com"],
["https://[invalid", "https://[invalid"],
])("resolves %s without changing the label", (href, expected) => {
const component = new AssistantMessageComponent(
{ ...message, content: [{ type: "text", text: `[Audit report](${href})` }] },
Expand All @@ -71,6 +68,25 @@ describe("assistant Markdown file links", () => {
expect(stripAnsi(lines.join("\n")).trim()).toBe("Audit report");
});

// ENG-5344: OSC 8 targets are allowlisted (http, https, mailto, and file only
// when resolved from a local path via the session cwd). Anything else renders
// like a terminal without hyperlink support.
test.each(["#overview", "file:///tmp/report.md", "https://[invalid", "javascript:alert(1)"])(
"shows %s as text instead of an OSC 8 target",
(href) => {
const component = new AssistantMessageComponent(
{ ...message, content: [{ type: "text", text: `[Audit report](${href})` }] },
false,
undefined,
undefined,
{ cwd },
);
const lines = component.render(80);
expect(linkTargets(lines)).toEqual([]);
expect(stripAnsi(lines.join("\n")).trim()).toBe(`Audit report (${href})`);
},
);

test.each(["addMessageToChat", "startAssistantStreamingMessage"] as const)(
"%s uses the attached session cwd and produces an openable target",
(method) => {
Expand Down
Loading
Loading