feat(terminal): opt-in OSC 52 clipboard writes from TUIs (#952)

xterm.js ships with no OSC 52 handler, so tmux, Neovim, fzf, and ripgrep
running inside Orca — locally or over SSH — silently fail to copy to the
system clipboard. Add an opt-in handler gated behind a new setting
`terminalAllowOsc52Clipboard` (default off because untrusted output
piped into the terminal can silently overwrite the clipboard).

Implementation mirrors the DEC 2031 wiring from PR #896: extract parsing
into a pure helper (parseOsc52) with a full unit-test suite, register
the handler in onPaneCreated and dispose it symmetrically in
onPaneClosed, and read the gate from settingsRef at fire time so the
toggle takes effect without recreating panes.

Clipboard *queries* (Pd = "?") are intentionally dropped — answering
them would leak the user's clipboard to any process writing to the
PTY — along with malformed payloads, oversized payloads (>128KB),
and payloads using unknown selection letters.
This commit is contained in:
Neil 2026-04-22 14:45:26 -07:00 committed by GitHub
parent d56fcff469
commit 2819cdc081
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 240 additions and 0 deletions

View File

@ -57,6 +57,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalRightClickToPaste: false,
terminalFocusFollowsMouse: false,
terminalClipboardOnSelect: false,
terminalAllowOsc52Clipboard: false,
setupScriptLaunchMode: 'split-vertical',
terminalScrollbackBytes: 10_000_000,
openLinksInApp: false,

View File

@ -51,6 +51,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalRightClickToPaste: false,
terminalFocusFollowsMouse: false,
terminalClipboardOnSelect: false,
terminalAllowOsc52Clipboard: false,
setupScriptLaunchMode: 'split-vertical',
terminalScrollbackBytes: 10_000_000,
openLinksInApp: false,

View File

@ -444,6 +444,51 @@ export function TerminalPane({
/>
</button>
</SearchableSetting>
<SearchableSetting
title="Allow TUI Clipboard Writes (OSC 52)"
description="Let terminal programs like tmux, Neovim, and fzf copy to the system clipboard over the PTY (including over SSH). Off by default because untrusted output piped into the terminal could silently overwrite your clipboard."
keywords={[
'osc 52',
'osc52',
'clipboard',
'tmux',
'neovim',
'nvim',
'fzf',
'ssh',
'remote',
'copy',
'paste'
]}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Allow TUI Clipboard Writes (OSC 52)</Label>
<p className="text-xs text-muted-foreground">
Let programs running inside the terminal (tmux, Neovim, fzf, ssh sessions) copy to
your system clipboard. Disabled by default for safety.
</p>
</div>
<button
role="switch"
aria-checked={settings.terminalAllowOsc52Clipboard}
onClick={() =>
updateSettings({
terminalAllowOsc52Clipboard: !settings.terminalAllowOsc52Clipboard
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.terminalAllowOsc52Clipboard ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.terminalAllowOsc52Clipboard ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
</section>
) : null,
matchesSettingsSearch(searchQuery, TERMINAL_DARK_THEME_SEARCH_ENTRIES) ? (

View File

@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { parseOsc52 } from './osc52-clipboard'
function b64(s: string): string {
return Buffer.from(s, 'utf-8').toString('base64')
}
describe('parseOsc52', () => {
it('decodes the canonical clipboard write payload', () => {
const result = parseOsc52(`c;${b64('hello world')}`)
expect(result).toEqual({ kind: 'write', selections: 'c', text: 'hello world' })
})
it('preserves multi-byte UTF-8', () => {
const result = parseOsc52(`c;${b64('café — 日本語')}`)
expect(result).toEqual({ kind: 'write', selections: 'c', text: 'café — 日本語' })
})
it('accepts combined selection letters (e.g. primary + clipboard)', () => {
const result = parseOsc52(`pc;${b64('dual')}`)
expect(result).toEqual({ kind: 'write', selections: 'pc', text: 'dual' })
})
it('accepts numeric select-buffer indices', () => {
const result = parseOsc52(`s0;${b64('buffered')}`)
expect(result).toEqual({ kind: 'write', selections: 's0', text: 'buffered' })
})
it('flags clipboard queries without decoding — we must not answer them', () => {
// Why: answering would leak the user's clipboard to any process writing
// to the PTY. The lifecycle handler drops queries on the floor.
expect(parseOsc52('c;?')).toEqual({ kind: 'query' })
})
it('tolerates whitespace in the base64 payload', () => {
const encoded = b64('multi-line data that got wrapped')
const wrapped = `${encoded.slice(0, 10)}\n${encoded.slice(10)}`
const result = parseOsc52(`c;${wrapped}`)
expect(result).toEqual({
kind: 'write',
selections: 'c',
text: 'multi-line data that got wrapped'
})
})
it('rejects missing separator', () => {
expect(parseOsc52(b64('no-semicolon'))).toMatchObject({ kind: 'invalid' })
})
it('rejects empty selection list', () => {
// Why: the spec defaults to "s0" on empty, but treating malformed
// payloads as clipboard writes would let buggy/malicious emitters
// mutate the clipboard unintentionally.
expect(parseOsc52(`;${b64('x')}`)).toMatchObject({ kind: 'invalid' })
})
it('rejects unknown selection letters', () => {
expect(parseOsc52(`x;${b64('x')}`)).toMatchObject({ kind: 'invalid' })
})
it('rejects non-base64 garbage', () => {
expect(parseOsc52('c;!!!not-base64!!!')).toMatchObject({ kind: 'invalid' })
})
it('rejects payloads larger than the size cap', () => {
const huge = 'A'.repeat(128 * 1024 + 100) // valid base64 alphabet char
expect(parseOsc52(`c;${huge}`)).toMatchObject({ kind: 'invalid' })
})
})

View File

@ -0,0 +1,83 @@
// OSC 52 — "Manipulate Selection Data". xterm.js does not implement this
// handler itself; applications register it to let TUIs (tmux, neovim, fzf,
// ripgrep) copy to the host clipboard over SSH or through the PTY.
//
// Wire format (xterm.js strips the leading `\x1b]52;` and trailing BEL/ST
// before handing us the payload string):
//
// Pc ; Pd
//
// Pc is one or more selection-kind letters ("c"=clipboard, "p"=primary,
// "q"=secondary, "s"=select); Pd is base64-encoded UTF-8. If Pd is "?" the
// TUI is *querying* the clipboard — we deliberately ignore that case to
// avoid leaking clipboard contents to any process writing to the PTY.
//
// Safety: OSC 52 is a classic data-exfil / overwrite vector — piping an
// attacker-controlled log into the terminal could silently replace the
// user's clipboard. Callers must gate on the user-opt-in setting
// `terminalAllowOsc52Clipboard` before invoking the handler.
export type Osc52ParseResult =
| { kind: 'write'; selections: string; text: string }
| { kind: 'query' }
| { kind: 'invalid'; reason: string }
const MAX_OSC52_BYTES = 128 * 1024
export function parseOsc52(data: string): Osc52ParseResult {
const semi = data.indexOf(';')
if (semi === -1) {
return { kind: 'invalid', reason: 'missing selection/data separator' }
}
const selections = data.slice(0, semi)
const payload = data.slice(semi + 1)
// Why reject empty selections: the spec allows it (defaults to "s0"), but
// every TUI we care about emits at least one letter, and treating empty
// as "apply to clipboard" would let malformed payloads mutate the
// clipboard by accident.
if (selections.length === 0) {
return { kind: 'invalid', reason: 'empty selection list' }
}
if (!/^[cpqs0-7]+$/.test(selections)) {
return { kind: 'invalid', reason: 'unknown selection kind' }
}
if (payload === '?') {
return { kind: 'query' }
}
// Why guard size: xterm's own parser caps OSC payloads at ~10 MB; we cap
// tighter because a legitimate clipboard write is rarely more than a
// screenful and any multi-MB payload is almost certainly a bug or abuse.
if (payload.length > MAX_OSC52_BYTES) {
return { kind: 'invalid', reason: 'payload exceeds size limit' }
}
const decoded = decodeBase64Utf8(payload)
if (decoded === null) {
return { kind: 'invalid', reason: 'payload is not valid base64' }
}
return { kind: 'write', selections, text: decoded }
}
function decodeBase64Utf8(b64: string): string | null {
// Why tolerate whitespace: some TUIs line-wrap the base64 payload. The
// WHATWG `atob` rejects whitespace, so strip it first. Reject anything
// else that doesn't match the base64 alphabet so we don't silently
// accept garbage.
const stripped = b64.replace(/\s+/g, '')
if (!/^[A-Za-z0-9+/=]*$/.test(stripped)) {
return null
}
try {
const binary = atob(stripped)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return new TextDecoder('utf-8', { fatal: false }).decode(bytes)
} catch {
return null
}
}

View File

@ -24,6 +24,7 @@ import {
} from './layout-serialization'
import { applyExpandedLayoutTo, restoreExpandedLayoutFrom } from './expand-collapse'
import { applyTerminalAppearance, mode2031SequenceFor } from './terminal-appearance'
import { parseOsc52 } from './osc52-clipboard'
import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt'
import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { connectPanePty } from './pty-connection'
@ -177,6 +178,7 @@ export function useTerminalPaneLifecycle({
// effect without recreating panes.
const selectionDisposablesRef = useRef(new Map<number, IDisposable>())
const mode2031DisposablesRef = useRef(new Map<number, IDisposable[]>())
const osc52DisposablesRef = useRef(new Map<number, IDisposable>())
const applyAppearance = (manager: PaneManager): void => {
const currentSettings = settingsRef.current
@ -335,6 +337,31 @@ export function useTerminalPaneLifecycle({
]
mode2031DisposablesRef.current.set(pane.id, mode2031Disposables)
// OSC 52 — TUI-initiated clipboard writes (tmux/nvim/fzf/ssh).
// Why read settingsRef at fire time (not capture): the user may
// toggle the gate mid-session and we want that to take effect
// immediately without recreating panes. Return true ("handled") in
// both the enabled and disabled paths so xterm doesn't fall
// through to any other OSC 52 handler and so our intentional drop
// in the disabled path is explicit.
const osc52Disposable = pane.terminal.parser.registerOscHandler(52, (data) => {
if (!settingsRef.current?.terminalAllowOsc52Clipboard) {
return true
}
const parsed = parseOsc52(data)
if (parsed.kind !== 'write') {
// Queries and malformed payloads are intentionally dropped —
// answering a query would leak the user's clipboard to any
// process writing to the PTY.
return true
}
void window.api.ui.writeClipboardText(parsed.text).catch(() => {
/* ignore clipboard write failures */
})
return true
})
osc52DisposablesRef.current.set(pane.id, osc52Disposable)
const linkProviderDisposable = pane.terminal.registerLinkProvider(
createFilePathLinkProvider(pane.id, linkDeps, pane.linkTooltip, fileOpenLinkHint)
)
@ -411,6 +438,11 @@ export function useTerminalPaneLifecycle({
}
paneMode2031Ref.current.delete(paneId)
paneLastThemeModeRef.current.delete(paneId)
const osc52Disposable = osc52DisposablesRef.current.get(paneId)
if (osc52Disposable) {
osc52Disposable.dispose()
osc52DisposablesRef.current.delete(paneId)
}
const transport = paneTransportsRef.current.get(paneId)
const panePtyBinding = panePtyBindings.get(paneId)
if (panePtyBinding) {

View File

@ -126,6 +126,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
// focus-follows-mouse never happens unexpectedly.
terminalFocusFollowsMouse: false,
terminalClipboardOnSelect: false,
terminalAllowOsc52Clipboard: false,
setupScriptLaunchMode: 'new-tab',
terminalScrollbackBytes: 10_000_000,
openLinksInApp: true,

View File

@ -622,6 +622,14 @@ export type GlobalSettings = {
* paste with Cmd/Ctrl+V without an intervening Cmd/Ctrl+Shift+C. Defaults
* to false so existing users keep the explicit-copy behavior. */
terminalClipboardOnSelect: boolean
/** Why: lets TUIs like tmux, nvim, and fzf copy to the system clipboard via
* the OSC 52 escape sequence essential for SSH-hosted workflows where
* the terminal is the only bridge to the local clipboard. Defaults to
* false because OSC 52 is a classic data-exfiltration vector (any
* process piping untrusted output into the terminal `cat attacker.log`
* can silently rewrite the user's clipboard). Opt-in preserves the
* conservative default while making the capability one toggle away. */
terminalAllowOsc52Clipboard: boolean
/** Where the repo setup script runs on workspace create. Defaults to a
* background "Setup" tab so the user's main terminal stays immediately
* usable without the setup output crowding the initial pane. */