Skip to content
Closed
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
34 changes: 33 additions & 1 deletion apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Platform, Pressable, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import {
KeyboardController,
KeyboardEvents,
Expand Down Expand Up @@ -66,6 +67,7 @@ import {
type TerminalMenuSession,
} from "./terminalMenu";
import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";
import { encodeTerminalPaste } from "./encodeTerminalPaste";

const DEFAULT_TERMINAL_COLS = 80;
const DEFAULT_TERMINAL_ROWS = 24;
Expand All @@ -78,6 +80,7 @@ type HostPlatform = "mac" | "linux" | "windows" | "unknown";
type TerminalToolbarAction =
| { readonly kind: "send"; readonly key: string; readonly label: string; readonly data: string }
| { readonly kind: "clear"; readonly key: string; readonly label: string }
| { readonly kind: "paste"; readonly key: string; readonly label: string }
| {
readonly kind: "modifier";
readonly key: string;
Expand Down Expand Up @@ -488,6 +491,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
{ kind: "send", key: "esc", label: "esc", data: "\u001b" },
...modifierActions,
{ kind: "send", key: "tab", label: "tab", data: "\t" },
{ kind: "paste", key: "paste", label: "paste" },
{ kind: "clear", key: "clear", label: "clear" },
{ kind: "send", key: "up", label: "↑", data: "\u001b[A" },
{ kind: "send", key: "down", label: "↓", data: "\u001b[B" },
Expand Down Expand Up @@ -1003,6 +1007,29 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
});
}, [clearTerminal, selectedThread, terminalId]);

const handlePasteFromClipboard = useCallback(() => {
setPendingModifierState({ terminalId, value: null });
void (async () => {
let text: string;
try {
text = await Clipboard.getStringAsync();
} catch {
return;
}
if (text.length === 0) {
return;
}
// Bracketed paste (DECSET 2004) is not exposed from the mobile native
// surface yet, so encode like Ghostty with bracketed=false: sanitize and
// map newlines to CR without wrapping ESC[200~/ESC[201~.
const encoded = encodeTerminalPaste(text);
if (encoded.length === 0) {
return;
}
writeInput(encoded);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High terminal/ThreadTerminalRouteScreen.tsx:1031

handlePasteFromClipboard can write clipboard contents to the previously selected terminal after the user switches terminals or leaves the screen. The asynchronous Clipboard.getStringAsync() continuation always calls the captured writeInput without checking that the route/session is still current, so a delayed paste can execute commands in an unintended shell. Invalidate the pending read on terminal-key/navigation changes and only call writeInput while the request still belongs to the active terminal.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx around line 1031:

`handlePasteFromClipboard` can write clipboard contents to the previously selected terminal after the user switches terminals or leaves the screen. The asynchronous `Clipboard.getStringAsync()` continuation always calls the captured `writeInput` without checking that the route/session is still current, so a delayed paste can execute commands in an unintended shell. Invalidate the pending read on terminal-key/navigation changes and only call `writeInput` while the request still belongs to the active terminal.

})();
}, [terminalId, writeInput]);

const handleToolbarActionPress = useCallback(
(action: TerminalToolbarAction) => {
if (action.kind === "modifier") {
Expand All @@ -1021,6 +1048,11 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
return;
}

if (action.kind === "paste") {
handlePasteFromClipboard();
return;
}

setPendingModifierState({ terminalId, value: null });
if (pendingModifier === "ctrl") {
writeInput(applyCtrlModifier(action.data));
Expand All @@ -1030,7 +1062,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
writeInput(action.data);
}
},
[handleClearTerminal, pendingModifier, terminalId, writeInput],
[handleClearTerminal, handlePasteFromClipboard, pendingModifier, terminalId, writeInput],
);

const handleDismissKeyboard = useCallback(() => {
Expand Down
32 changes: 32 additions & 0 deletions apps/mobile/src/features/terminal/encodeTerminalPaste.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vite-plus/test";

import { encodeTerminalPaste } from "./encodeTerminalPaste";

describe("encodeTerminalPaste", () => {
it("returns empty for empty input", () => {
expect(encodeTerminalPaste("")).toBe("");
});

it("passes through plain text when not bracketed", () => {
expect(encodeTerminalPaste("hello")).toBe("hello");
});

it("replaces newlines with carriage returns when not bracketed", () => {
expect(encodeTerminalPaste("one\ntwo\r\nthree")).toBe("one\rtwo\rthree");
});

it("strips bracketed-paste end sequences to block injection", () => {
expect(encodeTerminalPaste("safe\u001b[201~; rm -rf /\n")).toBe("safe; rm -rf /\r");
expect(encodeTerminalPaste("\u001b[201~\u001b[201~")).toBe("");
});

it("wraps only when bracketed is explicitly enabled", () => {
expect(encodeTerminalPaste("a\nb", { bracketed: true })).toBe("\u001b[200~a\nb\u001b[201~");
});

it("still strips embedded end sequences before wrapping", () => {
expect(encodeTerminalPaste("x\u001b[201~y", { bracketed: true })).toBe(
"\u001b[200~xy\u001b[201~",
);
});
});
33 changes: 33 additions & 0 deletions apps/mobile/src/features/terminal/encodeTerminalPaste.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Encode clipboard text for PTY write, matching web Ghostty `encodePaste`
* semantics as closely as we can without a native mode query.
*
* Web reads DECSET 2004 (bracketed paste) from libghostty and only wraps when
* active. Mobile's native surface does not expose that mode yet, so callers
* default to `bracketed: false` — never emit literal ESC[200~/ESC[201~ into
* shells that have not enabled the mode.
*
* Always strips ESC[201~ from the payload to block paste-end injection.
*/

const BRACKETED_PASTE_START = "\u001b[200~";
const BRACKETED_PASTE_END = "\u001b[201~";

export function encodeTerminalPaste(
text: string,
options: { readonly bracketed?: boolean } = {},
): string {
// Split/join so every occurrence is removed, including adjacent repeats.
const sanitized = text.split(BRACKETED_PASTE_END).join("");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High terminal/encodeTerminalPaste.ts:21

encodeTerminalPaste forwards control bytes such as ESC, NUL, and DEL unchanged, so clipboard content like \u0003 sends Ctrl-C to the active PTY instead of remaining paste data. Strip or replace unsafe control bytes with spaces using the repository's Ghostty paste-encoder semantics before writing the payload.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/terminal/encodeTerminalPaste.ts around line 21:

`encodeTerminalPaste` forwards control bytes such as `ESC`, `NUL`, and `DEL` unchanged, so clipboard content like `\u0003` sends `Ctrl-C` to the active PTY instead of remaining paste data. Strip or replace unsafe control bytes with spaces using the repository's Ghostty paste-encoder semantics before writing the payload.

if (sanitized.length === 0) {
return "";
}

if (options.bracketed === true) {
return `${BRACKETED_PASTE_START}${sanitized}${BRACKETED_PASTE_END}`;
}

// Ghostty replaces newlines with CR when bracketed paste is off so each
// line is a carriage return rather than a bare LF (Ctrl+J in raw mode).
return sanitized.replace(/\r\n/g, "\r").replace(/\n/g, "\r");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Paste skips Ghostty control sanitization

High Severity

encodeTerminalPaste claims to match web Ghostty encodePaste but only strips ESC[201~ and remaps newlines. ghostty_paste_encode also replaces unsafe control bytes (NUL, ESC, DEL, Ctrl+C, and other TTY signals) with spaces. Those bytes go straight to the PTY, so hidden clipboard controls can interrupt the session and inject commands.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8a2f76476778ff8ccde01cf375d4a3176ef3f403. Configure here.

Loading