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
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,29 @@ struct PasteboardTextContentsTests {
#expect(contents == "/tmp/with\\ space.png")
}

// A clipboard read the terminal program starts gets this flavor only, so
// it must never turn copied files into paths or images into saved files.
@Test func plainTextFlavorNeverReadsFilesOrImages() throws {
let scratchDir = try makeScratchDirectory()
defer { try? FileManager.default.removeItem(at: scratchDir) }
let service = TerminalPasteboardService(temporaryDirectory: scratchDir)

let files = ScratchPasteboard()
#expect(files.pasteboard.writeObjects([URL(fileURLWithPath: "/tmp/copied.png") as NSURL]))
#expect(service.fallbackPlainTextContents(from: files.pasteboard) == nil)

let image = ScratchPasteboard()
image.pasteboard.declareTypes([.png], owner: nil)
image.pasteboard.setData(try tinyPNGData(), forType: .png)
#expect(service.fallbackPlainTextContents(from: image.pasteboard) == nil)
#expect(try FileManager.default.contentsOfDirectory(atPath: scratchDir.path).isEmpty)

let text = ScratchPasteboard()
text.pasteboard.declareTypes([.string], owner: nil)
text.pasteboard.setString("copied text", forType: .string)
#expect(service.fallbackPlainTextContents(from: text.pasteboard) == "copied text")
}

@Test func imageOnlyHTMLWithNoVisibleTextReturnsNil() throws {
let scratch = ScratchPasteboard()
let service = TerminalPasteboardService()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
///
/// Ghostty asks for confirmation in two cases: a paste its
/// `clipboard-paste-protection` flags as unsafe, and an OSC 52 clipboard read
/// under `clipboard-read = ask`. cmux approves both without asking unless
/// `terminal.confirmUnsafePaste` is on, and that setting only covers pastes.
/// under `clipboard-read = ask`. cmux approves an unsafe paste without asking
/// unless `terminal.confirmUnsafePaste` is on. It always asks about a
/// clipboard read, because Ghostty only sends one here under `ask`;
/// `clipboard-read = allow` and `deny` never reach this policy.
public struct TerminalUnsafePasteConfirmationPolicy: Equatable, Sendable {
/// How to answer one confirmation request.
public enum Decision: Equatable, Sendable {
Expand Down Expand Up @@ -48,10 +50,10 @@ public struct TerminalUnsafePasteConfirmationPolicy: Equatable, Sendable {
/// OSC 52 read.
/// - hasWindow: Whether the terminal view is in a window a sheet can
/// attach to.
/// - Returns: The decision. A paste the user asked to confirm is rejected
/// when there is no window to ask in, rather than pasted unasked.
/// - Returns: The decision. A request that needs asking is rejected when
/// there is no window to ask in, rather than completed unasked.
public func decision(isPasteRequest: Bool, hasWindow: Bool) -> Decision {
guard isPasteRequest, confirmationEnabled else { return .approve }
if isPasteRequest, !confirmationEnabled { return .approve }
return hasWindow ? .askInWindowSheet : .reject
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/// What one native clipboard read may take from the pasteboard.
public enum RuntimeClipboardReadContent: Equatable, Sendable {
/// Everything a paste can use: text, file paths and images, which cmux
/// may save and upload for the pane.
case pasteboard

/// The pasteboard's plain-text flavor only. Files and images are never
/// read, saved or uploaded.
case plainText

/// The content a read with this input admission may take.
///
/// A reserved request comes from a native paste gesture: a key binding,
/// the Paste menu item or a middle click. An unsequenced one was started
/// by the terminal program, for example with OSC 52, so it gets plain
/// text only.
///
/// - Parameter admission: The request's input admission.
public init(admission: RuntimeClipboardInputAdmission) {
self = admission.reservesInput ? .pasteboard : .plainText
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import Foundation
import Testing
import CmuxTerminalCore
import GhosttyKit

private final class FakeSurfaceController: TerminalSurfaceControlling {
let surfaceId = UUID()
let owningTabId = UUID()
var runtimeSurfacePointer: ghostty_surface_t?
}

private final class FakeSurfaceHost: TerminalSurfaceHosting {
var hostedTabId: UUID?
var attachedSurfaceController: (any TerminalSurfaceControlling)?
}

@Suite struct RuntimeClipboardReadContentTests {
@Test @MainActor
func terminalInitiatedReadGetsPlainTextOnly() throws {
let context = try makeBoundContext()

// An OSC 52 read reaches the callback with no native paste gesture
// on the call stack.
let didRegister = context.registerRuntimeClipboardRequest(
id: 41,
onInvalidation: { _, _, _, _ in }
)
#expect(didRegister)
#expect(context.commitRuntimeClipboardRequest(41))
let admission = try #require(
context.markRuntimeClipboardRequestAdmitted(41)
)

#expect(RuntimeClipboardReadContent(admission: admission) == .plainText)
}

@Test @MainActor
func nativePasteGetsTheWholePasteboard() throws {
let context = try makeBoundContext()

// Cmd+V, the Paste menu item and a middle click all dispatch inside
// the paste intent.
let didRegister = context.withRuntimeClipboardPasteIntent {
context.registerRuntimeClipboardRequest(
id: 43,
onInvalidation: { _, _, _, _ in }
)
}
#expect(didRegister)
#expect(context.commitRuntimeClipboardRequest(43))
let admission = try #require(
context.markRuntimeClipboardRequestAdmitted(43)
)

#expect(RuntimeClipboardReadContent(admission: admission) == .pasteboard)
}

@MainActor
private func makeBoundContext() throws -> GhosttySurfaceCallbackContext {
let context = GhosttySurfaceCallbackContext(
surfaceHost: FakeSurfaceHost(),
surfaceController: FakeSurfaceController(),
terminalLifecycleID: UUID()
)
let surface = try #require(ghostty_surface_t(bitPattern: 0x51))
#expect(context.bindRuntimeClipboardSurface(surface, generation: 5))
return context
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,19 @@ struct TerminalUnsafePasteConfirmationPolicyTests {
#expect(policy.decision(isPasteRequest: true, hasWindow: false) == .reject)
}

@Test func leavesOSC52ReadsApprovedWhateverTheSetting() {
// Ghostty asks about a terminal clipboard read only under
// `clipboard-read = ask`; `allow` and `deny` never reach this policy.
@Test func asksInAWindowSheetBeforeATerminalReadsTheClipboardWhateverTheSetting() {
for enabled in [true, false] {
let policy = TerminalUnsafePasteConfirmationPolicy(confirmationEnabled: enabled)
for hasWindow in [true, false] {
#expect(policy.decision(isPasteRequest: false, hasWindow: hasWindow) == .approve)
}
#expect(policy.decision(isPasteRequest: false, hasWindow: true) == .askInWindowSheet)
}
}

@Test func rejectsATerminalClipboardReadItCannotAskAboutWhateverTheSetting() {
for enabled in [true, false] {
let policy = TerminalUnsafePasteConfirmationPolicy(confirmationEnabled: enabled)
#expect(policy.decision(isPasteRequest: false, hasWindow: false) == .reject)
}
}

Expand Down
177 changes: 177 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -570251,6 +570251,183 @@
}
}
}
},
"terminal.clipboardReadConfirmation.deny": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Deny"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "رفض"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Ablehnen"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Denegar"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Refuser"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "拒否"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "거부"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "拒绝"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "拒絕"
}
}
}
},
"terminal.clipboardReadConfirmation.message": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "A program in this terminal wants to read your clipboard. Allow it only if you trust the program."
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "يريد برنامج في هذه الطرفية قراءة الحافظة. اسمح بذلك فقط إذا كنت تثق بالبرنامج."
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Ein Programm in diesem Terminal möchte deine Zwischenablage lesen. Erlaube es nur, wenn du dem Programm vertraust."
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Un programa de esta terminal quiere leer tu portapapeles. Permítelo solo si confías en el programa."
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Un programme de ce terminal veut lire votre presse-papiers. Ne l’autorisez que si vous faites confiance à ce programme."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "このターミナルのプログラムがクリップボードを読み取ろうとしています。信頼できるプログラムの場合にのみ許可してください。"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "이 터미널의 프로그램이 클립보드를 읽으려고 합니다. 프로그램을 신뢰할 수 있을 때만 허용하세요."
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "此终端中的程序想要读取你的剪贴板。只有在你信任该程序时才允许。"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "此終端機中的程式想要讀取你的剪貼簿。只有在你信任該程式時才允許。"
}
}
}
},
"terminal.clipboardReadConfirmation.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Allow Clipboard Access?"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "السماح بالوصول إلى الحافظة؟"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Zugriff auf die Zwischenablage erlauben?"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "¿Permitir el acceso al portapapeles?"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Autoriser l’accès au presse-papiers ?"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "クリップボードへのアクセスを許可しますか?"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "클립보드 접근을 허용하시겠습니까?"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "允许访问剪贴板?"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "允許存取剪貼簿?"
}
}
}
}
},
"version": "1.0"
Expand Down
Loading
Loading