diff --git a/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Relay/RemoteRelayAuthorizationPolicy.swift b/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Relay/RemoteRelayAuthorizationPolicy.swift index 2313d005930f..302ed67c9e65 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Relay/RemoteRelayAuthorizationPolicy.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Relay/RemoteRelayAuthorizationPolicy.swift @@ -28,6 +28,7 @@ public struct RemoteRelayAuthorizationPolicy: Sendable { "surface.clear_git_branch", "surface.report_shell_state", "surface.ports_kick", + "terminal.paste", "workspace.equalize_splits", ] @@ -64,6 +65,7 @@ public struct RemoteRelayAuthorizationPolicy: Sendable { "surface.ports_kick", "surface.close", "surface.send_text", + "terminal.paste", "agent.hook.enqueue", "agent.message.poll", "agent.message.claim", @@ -79,6 +81,7 @@ public struct RemoteRelayAuthorizationPolicy: Sendable { "surface.clear_git_branch", "surface.report_shell_state", "surface.ports_kick", + "terminal.paste", "agent.hook.enqueue", "agent.message.poll", "agent.message.claim", @@ -262,10 +265,21 @@ public struct RemoteRelayAuthorizationPolicy: Sendable { !(parameters["surface_id"] is String) { return .denied( code: "remote_relay_surface_denied", - message: "Relay tmux-compat surface methods require an explicit surface_id selector" + message: "Relay method requires an explicit surface_id selector" ) } + if method == "terminal.paste" { + guard parameters["text"] is String, + let submitKey = parameters["submit_key"] as? String, + ["none", "return"].contains(submitKey) else { + return .denied( + code: "remote_relay_method_denied", + message: "Relay terminal paste requires text and submit_key none|return" + ) + } + } + if method == "notification.create_for_target", !(parameters["surface_id"] is String) { return .denied( diff --git a/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Relay/RemoteRelayCommandPolicy.swift b/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Relay/RemoteRelayCommandPolicy.swift index b938d2c49fe1..188a6bb6cee8 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Relay/RemoteRelayCommandPolicy.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Relay/RemoteRelayCommandPolicy.swift @@ -93,6 +93,13 @@ public struct RemoteRelayCommandPolicy: Sendable { return .deny(reason: "relay browser URLs are not permitted") } } + if method == "terminal.paste" { + guard params["text"] is String, + let submitKey = params["submit_key"] as? String, + ["none", "return"].contains(submitKey) else { + return .deny(reason: "terminal.paste requires text and submit_key none|return") + } + } if method == "agent.resolve_delivery_target" { if firstKey(in: params, matching: ["pid", "pid_resolution"]) != nil { return .deny(reason: "agent PID resolution is not permitted through a remote relay") diff --git a/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Relay/RemoteRelayRoutingSchema.swift b/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Relay/RemoteRelayRoutingSchema.swift index 6630a85bed99..e188ad6d0b65 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Relay/RemoteRelayRoutingSchema.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Relay/RemoteRelayRoutingSchema.swift @@ -18,6 +18,7 @@ struct RemoteRelayRoutingSchema { case "surface.read_selection": return terminal case "surface.close", "surface.clear_git_branch": return surface case "surface.send_text": return surface.union(["text"]) + case "terminal.paste": return surface.union(["text", "submit_key"]) case "surface.report_tty": return surface.union(["tty_name", "terminal_lifecycle_id", "attempt_id"]) case "surface.report_pwd": return surface.union(["path", "directory"]) diff --git a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteCLIRelayPolicyTests.swift b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteCLIRelayPolicyTests.swift index 4a9994f3c89c..550aa84e8fb0 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteCLIRelayPolicyTests.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteCLIRelayPolicyTests.swift @@ -285,6 +285,59 @@ struct RemoteCLIRelayPolicyTests { } } + @Test("terminal.paste to an owned remote surface is forwarded") + func allowsAliasedTerminalPaste() throws { + let workspace = UUID() + let surface = UUID() + try withServer( + workspaceAliases: [workspace: workspace], + surfaceAliases: [surface: surface] + ) { port, unixServer in + for submitKey in ["none", "return"] { + let exchange = try runPolicyRelayExchange( + port: port, + relayID: relayID, + tokenHex: tokenHex, + commandLine: """ + {"id":"paste-\(submitKey)","method":"terminal.paste","params":{"workspace_id":"\(workspace.uuidString)","surface_id":"\(surface.uuidString)","text":"line one\\nline two","submit_key":"\(submitKey)"}} + """ + ) + #expect(exchange.responseLines.first?["ok"] as? Bool == true, "\(submitKey): \(exchange.rawResponse)") + } + #expect(unixServer.requests.count == 2) + } + } + + @Test("terminal.paste with command params, fallback selectors, or other submit keys is denied") + func deniesUnsafeTerminalPaste() throws { + let workspace = UUID() + let surface = UUID() + try withServer( + workspaceAliases: [workspace: workspace], + surfaceAliases: [surface: surface] + ) { port, unixServer in + let target = #""workspace_id":"\#(workspace.uuidString)","surface_id":"\#(surface.uuidString)""# + for params in [ + #"{\#(target),"text":"x","submit_key":"none","command":"touch /tmp/pwned"}"#, + #"{\#(target),"text":"x","submit_key":"none","initial_command":"touch /tmp/pwned"}"#, + #"{\#(target),"text":"x","submit_key":"none","window_id":"window:1"}"#, + #"{\#(target),"text":"x","submit_key":"ctrl+enter"}"#, + #"{\#(target),"text":"x"}"#, + #"{\#(target),"text":7,"submit_key":"none"}"#, + #"{"workspace_id":"\#(workspace.uuidString)","surface_id":17,"text":"x","submit_key":"none"}"#, + ] { + let request = #"{"id":"paste-deny","method":"terminal.paste","params":\#(params)}"# + let exchange = try runPolicyRelayExchange( + port: port, + relayID: relayID, + tokenHex: tokenHex, + commandLine: request + ) + expectDenial(exchange, unixServer, request) + } + } + } + @Test("methods outside the relay allowlist are denied") func deniesNonAllowlistedMethod() throws { try withServer { port, unixServer in diff --git a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteRelayAuthorizationPolicyTests.swift b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteRelayAuthorizationPolicyTests.swift index ede66a311ce7..993cce1cb6b5 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteRelayAuthorizationPolicyTests.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteRelayAuthorizationPolicyTests.swift @@ -20,6 +20,56 @@ struct RemoteRelayAuthorizationPolicyTests { } } + @Test("terminal paste is scoped to one exact remote surface") + func terminalPasteScope() { + let policy = RemoteRelayAuthorizationPolicy() + let workspaceID = UUID() + let surfaceID = UUID() + #expect(policy.validate( + method: "terminal.paste", + parameters: [ + "workspace_id": workspaceID.uuidString, + "surface_id": surfaceID.uuidString, + "text": "first line\nsecond line", + "submit_key": "return", + ], + ownerWorkspaceID: workspaceID, + surfaceIDs: [surfaceID] + ) == .allowed) + + #expect(policy.validate( + method: "terminal.paste", + parameters: [ + "workspace_id": workspaceID.uuidString, + "surface_id": UUID().uuidString, + "text": "nope", + "submit_key": "none", + ], + ownerWorkspaceID: workspaceID, + surfaceIDs: [surfaceID] + ) == .denied( + code: "remote_relay_surface_denied", + message: "Relay request targets a surface outside its workspace" + )) + + for submitKey in ["enter", "ctrl+enter", ""] { + #expect(policy.validate( + method: "terminal.paste", + parameters: [ + "workspace_id": workspaceID.uuidString, + "surface_id": surfaceID.uuidString, + "text": "bounded", + "submit_key": submitKey, + ], + ownerWorkspaceID: workspaceID, + surfaceIDs: [surfaceID] + ) == .denied( + code: "remote_relay_method_denied", + message: "Relay terminal paste requires text and submit_key none|return" + )) + } + } + @Test("tmux surface mutations require exact in-workspace selectors") func tmuxSurfaceSelectors() { let policy = RemoteRelayAuthorizationPolicy() diff --git a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteRelayCoreRPCPolicyTests.swift b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteRelayCoreRPCPolicyTests.swift index 479aa9a2ab38..ea8f543c0c59 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteRelayCoreRPCPolicyTests.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteRelayCoreRPCPolicyTests.swift @@ -27,17 +27,44 @@ struct RemoteRelayCoreRPCPolicyTests { @Test("capabilities filter exact method names without adding unsupported grants") func capabilityDiscovery() { let methods = RemoteRelayCommandPolicy().permittedMethods(from: [ - "system.ping", "workspace.list", "surface.send_text", "system.capabilities", + "system.ping", "workspace.list", "surface.send_text", "terminal.paste", "system.capabilities", "system.exec", "system.command_spec", "workspace.create", "surface.respawn", "browser.open", "workspace.list.future", "ping", "capabilities" ]) - #expect(methods == ["system.ping", "workspace.list", "surface.send_text", "system.capabilities"]) + #expect(methods == ["system.ping", "workspace.list", "surface.send_text", "terminal.paste", "system.capabilities"]) #expect(decision("surface.send_text", [:]) != .allowed) #expect(decision("surface.send_text", [ "workspace_id": owner.uuidString, "surface_id": UUID().uuidString, "text": "id\n" ]) != .allowed) } + @Test("terminal paste syntax is narrower than generic terminal input") + func terminalPasteSyntax() throws { + let valid: [String: Any] = [ + "workspace_id": owner.uuidString, + "surface_id": surface.uuidString, + "text": "hello\nworld", + "submit_key": "return", + ] + #expect(decision("terminal.paste", valid) == .allowed) + let request = try JSONSerialization.data(withJSONObject: ["method": "terminal.paste", "params": valid]) + #expect(RemoteRelayCommandPolicy().evaluate( + commandLine: request, workspaceAliases: [:], surfaceAliases: [:] + ) == .allow) + + for params in [ + ["workspace_id": owner.uuidString, "surface_id": surface.uuidString, "text": "hello"], + ["workspace_id": owner.uuidString, "surface_id": surface.uuidString, "text": "hello", "submit_key": "enter"], + ["workspace_id": owner.uuidString, "surface_id": surface.uuidString, "text": 7, "submit_key": "none"], + ] as [[String: Any]] { + #expect(decision("terminal.paste", params) != .allowed) + let invalid = try JSONSerialization.data(withJSONObject: ["method": "terminal.paste", "params": params]) + #expect(RemoteRelayCommandPolicy().evaluate( + commandLine: invalid, workspaceAliases: [:], surfaceAliases: [:] + ) != .allow) + } + } + @Test("workspace discovery defaults only to authenticated provenance") func workspaceDiscovery() { #expect(decision("workspace.list", [:]) == .allowed) diff --git a/cmuxTests/RemoteRelayTmuxCompatAuthorizationTests.swift b/cmuxTests/RemoteRelayTmuxCompatAuthorizationTests.swift index 80e01c041f63..6b1e9ae97162 100644 --- a/cmuxTests/RemoteRelayTmuxCompatAuthorizationTests.swift +++ b/cmuxTests/RemoteRelayTmuxCompatAuthorizationTests.swift @@ -215,6 +215,10 @@ struct RemoteRelayTmuxCompatAuthorizationTests { let admitted: [(String, [String: Any])] = [ ("workspace.equalize_splits", ["workspace_id": workspaceID, "orientation": "vertical"]), ("surface.send_text", ["workspace_id": workspaceID, "surface_id": leaderSurfaceID, "text": "ls\n"]), + ("terminal.paste", [ + "workspace_id": workspaceID, "surface_id": leaderSurfaceID, + "text": "first line\nsecond line", "submit_key": "none", + ]), ("surface.close", ["workspace_id": workspaceID, "surface_id": leaderSurfaceID]), ("surface.list", ["workspace_id": workspaceID]), ] @@ -251,6 +255,13 @@ struct RemoteRelayTmuxCompatAuthorizationTests { "text": "echo foreign", ]) #expect(foreignSurface.errorResponse?.contains("remote_relay_surface_denied") == true) + let foreignPaste = try fixture.authorize(method: "terminal.paste", params: [ + "workspace_id": workspaceID, + "surface_id": UUID().uuidString, + "text": "foreign", + "submit_key": "none", + ]) + #expect(foreignPaste.errorResponse?.contains("remote_relay_surface_denied") == true) let missingSurface = try fixture.authorize(method: "surface.close", params: [ "workspace_id": workspaceID, @@ -323,9 +334,23 @@ struct RemoteRelayTmuxCompatAuthorizationTests { let admitted = try fixture.authorize(method: "surface.send_text", params: params) #expect(admitted.errorResponse == nil) + let paste = try fixture.authorize(method: "terminal.paste", params: [ + "workspace_id": fixture.workspace.id.uuidString, + "surface_id": fixture.panelID.uuidString, + "text": "scoped", + "submit_key": "none", + ]) + #expect(paste.errorResponse == nil) fixture.workspace.untrackRemoteTerminalSurface(fixture.panelID) let revoked = try fixture.authorize(method: "surface.send_text", params: params) #expect(revoked.errorResponse?.contains("remote_relay_surface_denied") == true) + let revokedPaste = try fixture.authorize(method: "terminal.paste", params: [ + "workspace_id": fixture.workspace.id.uuidString, + "surface_id": fixture.panelID.uuidString, + "text": "scoped", + "submit_key": "none", + ]) + #expect(revokedPaste.errorResponse?.contains("remote_relay_surface_denied") == true) } @Test diff --git a/docs/cli-contract.md b/docs/cli-contract.md index a9214e572797..d0796e9eb3cc 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -186,7 +186,7 @@ Environment: | `send-key` | Send one key to a terminal surface. Refuses to send into an open agent dialog unless `--force`. | | `agent message` | Send a message to the agent in another workspace or surface (`agent.message.send`). Delivered through the recipient's agent hooks, never as keystrokes. `--reply-to ` answers a received message; `-` reads the text from stdin. | | `agent inbox` | List agent messages newest first (`agent.message.list`); `--mark-read` marks the listed messages read. | -| `paste` | Paste text from an argument or stdin into a terminal surface through the Cmd+V paste path (`terminal.paste`). The CLI sends the text unchanged; Ghostty brackets it when the program enabled bracketed paste (otherwise newlines become Enter) and replaces unsafe control bytes with spaces. `--submit` presses the agent-aware submit key afterwards. Refuses to paste over an agent prompt draft or into an open dialog unless `--force`. Local socket only: `terminal.paste` is not on the `cmux ssh` relay allowlist. | +| `paste` | Paste text from an argument or stdin into a terminal surface through the Cmd+V paste path (`terminal.paste`). The CLI sends the text unchanged; Ghostty brackets it when the program enabled bracketed paste (otherwise newlines become Enter) and replaces unsafe control bytes with spaces. `--submit` presses the agent-aware submit key afterwards. Refuses to paste over an agent prompt draft or into an open dialog unless `--force`. Authenticated remote-workspace relays may use `terminal.paste` only with an exact owned workspace/surface and `submit_key` `none` or `return`; the relay cannot use window/focus fallback selectors or arbitrary submit keys. | | `send-panel` | Send text to a terminal surface. Same draft guard and `--force` as `send`. | | `send-key-panel` | Send one key to a terminal surface. Same dialog guard and `--force` as `send-key`. | | `notify` | Send a notification to a workspace/surface and return its notification id; `--clear` clears the resolved caller/target scope. Supports `--id-format refs\|uuids\|both` for human-readable handles. |