Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public struct RemoteRelayAuthorizationPolicy: Sendable {
"surface.clear_git_branch",
"surface.report_shell_state",
"surface.ports_kick",
"terminal.paste",
"workspace.equalize_splits",
]

Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions cmuxTests/RemoteRelayTmuxCompatAuthorizationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/cli-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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. |
Expand Down
Loading