From 8babcdcbd2418117abd685dc03464b979d5c7188 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 12 Jun 2026 09:05:07 +0000 Subject: [PATCH] fix(coding-agent): sanitized pasted image path before splicing into TUI status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer flagged that the bracketed-paste path is untrusted terminal input — ANSI escapes, control chars, newlines/tabs, or a multi-hundred-char path would corrupt the status line (per AGENTS.md TUI sanitization rules) and leak the absolute home-dir path. The new ENOENT diagnostic now feeds the path through sanitizeText (strip ANSI/C0/C1 controls), collapses CR/LF/TAB to single spaces, runs it through shortenPath (collapse home → '~'), and truncateToWidth-clamps it to TRUNCATE_LENGTHS.CONTENT (80) before interpolating into either the SSH or local status string. Added a third assertion to the repro test defending the contract: ANSI/control bytes never reach the status, and the displayed path is bounded below the input length. Refs PR #2376 --- .../src/modes/controllers/input-controller.ts | 14 ++++++++--- .../test/issue-2375-repro.test.ts | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index f6340783b..7e1e246fc 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -18,6 +18,7 @@ import { isTinyTitleLocalModelKey } from "../../tiny/models"; import { isLowSignalTitleInput } from "../../tiny/text"; import { tinyTitleClient } from "../../tiny/title-client"; import type { TinyTitleProgressEvent } from "../../tiny/title-protocol"; +import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils"; import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard"; import { EnhancedPasteController } from "../../utils/enhanced-paste"; import { getEditorCommand, openInEditor } from "../../utils/external-editor"; @@ -885,13 +886,20 @@ export class InputController { // path on the *local* filesystem. When omp itself runs over SSH, that // path is unreachable here; pasting it as text would look like the // image was attached when in fact nothing was sent. Refuse the silent - // degrade and tell the user how to send the bytes for real. + // degrade and tell the user how to send the bytes for real. The + // pasted path is untrusted terminal input — strip control/ANSI/ + // newlines, collapse home to `~`, and bound the displayed length + // before splicing it into the status string. + const displayPath = truncateToWidth( + shortenPath(sanitizeText(path).replace(/[\r\n\t]+/g, " ").trim()), + TRUNCATE_LENGTHS.CONTENT, + ); const env = process.env; const overSsh = Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT); this.ctx.showStatus( overSsh - ? `Image not found at ${path}. Over SSH this path is local to your terminal — paste the image directly (clipboard image-paste shortcut) to send its bytes.` - : `Image not found at ${path}`, + ? `Image not found at ${displayPath}. Over SSH this path is local to your terminal — paste the image directly (clipboard image-paste shortcut) to send its bytes.` + : `Image not found at ${displayPath}`, ); return; } diff --git a/packages/coding-agent/test/issue-2375-repro.test.ts b/packages/coding-agent/test/issue-2375-repro.test.ts index eaa547c41..cc89afe20 100644 --- a/packages/coding-agent/test/issue-2375-repro.test.ts +++ b/packages/coding-agent/test/issue-2375-repro.test.ts @@ -80,4 +80,29 @@ describe("InputController.handleImagePathPaste (issue #2375)", () => { const status = String(spies.showStatus.mock.calls[0]?.[0] ?? ""); expect(status).toMatch(/not found|could not|unreadable/i); }); + + it("sanitizes untrusted pasted-path characters and bounds length before splicing into status", async () => { + const { ctx, spies } = createContext(); + const controller = new InputController(ctx); + // Path carrying ANSI, control chars, a CR/LF, and a tab — all of which + // would corrupt the TUI status line if interpolated verbatim. Long + // enough to exceed the status-line truncation budget (TRUNCATE_LENGTHS + // .CONTENT = 80) without tripping ENAMETOOLONG so the ENOENT branch + // keeps firing. + const hostile = `/tmp/\x1b[31mevil\x1b[0m\r\nname\twith-${"x".repeat(100)}.png`; + + await controller.handleImagePathPaste(hostile); + + expect(spies.pasteText).not.toHaveBeenCalled(); + expect(spies.showStatus).toHaveBeenCalledTimes(1); + const status = String(spies.showStatus.mock.calls[0]?.[0] ?? ""); + // No ANSI escape, no raw control bytes, no embedded newlines/tabs. + expect(status).not.toMatch(/\x1b/); + expect(status).not.toMatch(/[\x00-\x08\x0B-\x1F\x7F]/); + expect(status).not.toContain("\n"); + expect(status).not.toContain("\t"); + // The hostile path runs well past the status truncation budget; the + // displayed path must be clamped strictly inside that budget. + expect(status.length).toBeLessThan(hostile.length); + }); });