fix(coding-agent): sanitized pasted image path before splicing into TUI status
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
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user