From 5248d3a1ccc7c67bc4d386ac4401d512aa338b05 Mon Sep 17 00:00:00 2001 From: Santiago Casas Date: Tue, 15 Sep 2026 11:30:34 +0200 Subject: [PATCH 1/2] Harden SSH command execution --- src/index.ts | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index f521960..26616f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ -import { execSync } from "child_process" +import { execFileSync } from "child_process" import path from "path" import os from "os" -import { writeFileSync } from "fs" +import { mkdtempSync, writeFileSync } from "fs" +import { randomBytes } from "crypto" import { z } from "zod" type SessionState = { @@ -15,12 +16,23 @@ function sock(host: string) { return path.join(os.homedir(), ".ssh", `cm-${host}`) } +function validHost(host: string) { + return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(host) +} + +function shellQuote(value: string) { + return `'${value.replaceAll("'", `'"'"'`)}'` +} + +const tempDir = mkdtempSync(path.join(os.tmpdir(), "opencode-ssh-")) + function ensureSentinel(): string { - const p = path.join(os.tmpdir(), "__opencode_remote_mode__") + const p = path.join(tempDir, "remote-mode") try { writeFileSync( p, "This tool is not available in remote SSH mode.\nUse the bash tool with cat/tee/grep/find over SSH instead.\n", + { flag: "wx", mode: 0o600 }, ) } catch {} return p @@ -43,17 +55,20 @@ export default { }, async execute(args, ctx) { const host = args.host + if (!validHost(host)) { + return "Invalid SSH host. Use a host alias from ~/.ssh/config." + } const socketPath = sock(host) const existing = sessionMap.get(ctx.sessionID) if (existing) { try { - execSync(`ssh -O stop -S "${existing.socketPath}" "${existing.host}"`, { stdio: "pipe" }) + execFileSync("ssh", ["-O", "stop", "-S", existing.socketPath, existing.host], { stdio: "pipe" }) } catch {} } try { - execSync(`ssh -MNf -S "${socketPath}" "${host}" 2>&1`, { stdio: "pipe", timeout: 15000 }) + execFileSync("ssh", ["-MNf", "-S", socketPath, host], { stdio: "pipe", timeout: 15000 }) } catch (e) { const msg = e instanceof Error ? e.message : String(e) return `Failed to connect to ${host}: ${msg}` @@ -80,7 +95,7 @@ export default { if (!state) return "Not currently connected." try { - execSync(`ssh -O stop -S "${state.socketPath}" "${state.host}"`, { stdio: "pipe" }) + execFileSync("ssh", ["-O", "stop", "-S", state.socketPath, state.host], { stdio: "pipe" }) } catch {} sessionMap.delete(ctx.sessionID) @@ -95,8 +110,9 @@ export default { if (input.tool === "bash") { const cmd = output.args.command - if (cmd.startsWith(`ssh -S "${state.socketPath}"`)) return - output.args.command = `ssh -S "${state.socketPath}" "${state.host}" ${cmd}` + const wrappedPrefix = ["ssh", "-S", state.socketPath, state.host].map(shellQuote).join(" ") + if (cmd.startsWith(`${wrappedPrefix} `)) return + output.args.command = ["ssh", "-S", state.socketPath, state.host, cmd].map(shellQuote).join(" ") if (output.args.description) { output.args.description = `[remote ${state.host}] ${output.args.description}` } @@ -111,7 +127,7 @@ export default { } if (input.tool === "write") { - output.args.filePath = path.join(os.tmpdir(), `__opencode_remote_write__`) + output.args.filePath = path.join(tempDir, `remote-write-${randomBytes(16).toString("hex")}`) output.args.content = "This tool is not available in remote mode." return } From f75053cc8a8ba35ac7bb66930b2908f87c9002d7 Mon Sep 17 00:00:00 2001 From: Santiago Casas Date: Tue, 15 Sep 2026 11:33:04 +0200 Subject: [PATCH 2/2] Fail closed when remote SSH is unavailable --- README.md | 20 +++++++ src/index.ts | 155 +++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 153 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index e9279cd..e6bab24 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,30 @@ When they say "local", use `ssh_disconnect`. 3. Ask anything — `bash` commands automatically run on the remote server. 4. Say **"local"** to disconnect. +Remote mode is fail-closed. Before every Bash command, the plugin verifies that the +matching SSH control connection is active. If verification fails, the requested command +is blocked rather than executed locally. Reconnect with `ssh_connect`, or say **"local"** +to explicitly return to local execution. Remote intent is retained when a session is +resumed, so restarting OpenCode does not silently restore local Bash execution. + +## Hosts with interactive authentication + +The plugin cannot receive interactive SSH prompts. Authenticate a control connection in +a separate terminal, replacing `myHost` with an alias from `~/.ssh/config`: + +```sh +ssh -M -S "$HOME/.ssh/cm-myHost" myHost +``` + +Keep that session connected, then say **"ssh myHost"** in OpenCode. The plugin detects +and reuses the authenticated connection. Automatic connection attempts use SSH batch +mode, so TOTP or password prompts are never displayed inside OpenCode. + ## How it works - Opens SSH ControlMaster (`ssh -MNf ...`) for a persistent connection. - Hooks into `tool.execute.before` to wrap `bash` calls with SSH at runtime. +- Verifies the control connection before every Bash call and blocks on failure. - Disables local file tools (`read`, `write`, `edit`, `glob`, `grep`) in remote mode. - Injects remote-mode instructions into the system prompt. diff --git a/src/index.ts b/src/index.ts index 26616f3..9014920 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,14 @@ import { execFileSync } from "child_process" import path from "path" import os from "os" -import { mkdtempSync, writeFileSync } from "fs" -import { randomBytes } from "crypto" +import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs" +import { createHash, randomBytes } from "crypto" import { z } from "zod" type SessionState = { host: string socketPath: string + status: "connecting" | "connected" | "unavailable" } const sessionMap = new Map() @@ -24,7 +25,65 @@ function shellQuote(value: string) { return `'${value.replaceAll("'", `'"'"'`)}'` } +function controlMasterActive(socketPath: string, host: string) { + try { + execFileSync("ssh", ["-O", "check", "-S", socketPath, host], { stdio: "pipe" }) + return true + } catch { + return false + } +} + +function blockedBashCommand(host: string) { + const message = `opencode-ssh: remote connection to ${host} is unavailable; command blocked to prevent local execution. Run ssh_connect again or say "local".\n` + return `printf '%s' ${shellQuote(message)} >&2; exit 125` +} + const tempDir = mkdtempSync(path.join(os.tmpdir(), "opencode-ssh-")) +const stateDir = path.join(os.homedir(), ".cache", "opencode-ssh", "sessions") +mkdirSync(stateDir, { recursive: true, mode: 0o700 }) + +function statePath(sessionID: string) { + return path.join(stateDir, createHash("sha256").update(sessionID).digest("hex")) +} + +function saveSessionState(sessionID: string, state: SessionState) { + const target = statePath(sessionID) + const temporary = `${target}.${randomBytes(16).toString("hex")}` + try { + writeFileSync(temporary, JSON.stringify(state), { flag: "wx", mode: 0o600 }) + renameSync(temporary, target) + } finally { + rmSync(temporary, { force: true }) + } +} + +function getSessionState(sessionID: string) { + const current = sessionMap.get(sessionID) + if (current) return current + + try { + const saved = JSON.parse(readFileSync(statePath(sessionID), "utf8")) as Partial + const valid = + typeof saved.host === "string" && + validHost(saved.host) && + saved.socketPath === sock(saved.host) && + ["connecting", "connected", "unavailable"].includes(saved.status ?? "") + + if (!valid) return undefined + + const state = saved as SessionState + sessionMap.set(sessionID, state) + return state + } catch { + return undefined + } +} + +function deleteSessionState(sessionID: string) { + sessionMap.delete(sessionID) + rmSync(statePath(sessionID), { force: true }) +} function ensureSentinel(): string { const p = path.join(tempDir, "remote-mode") @@ -49,7 +108,7 @@ export default { tool: { ssh_connect: { description: - "Open a persistent SSH connection to a remote server. Call this when the user says 'ssh ' or asks to connect to a server. The host must be defined in ~/.ssh/config.", + "Open or reuse a persistent SSH connection. Never retry automatically after failure. Interactive authentication must first be completed in a separate terminal using the control-socket command returned by this tool.", args: { host: z.string().describe("SSH host name from ~/.ssh/config, e.g. 'myHost'"), }, @@ -60,21 +119,55 @@ export default { } const socketPath = sock(host) - const existing = sessionMap.get(ctx.sessionID) + const existing = getSessionState(ctx.sessionID) if (existing) { + if (existing.host === host && controlMasterActive(existing.socketPath, existing.host)) { + existing.status = "connected" + saveSessionState(ctx.sessionID, existing) + return `Already connected to ${host}.` + } try { execFileSync("ssh", ["-O", "stop", "-S", existing.socketPath, existing.host], { stdio: "pipe" }) } catch {} } + const state: SessionState = { host, socketPath, status: "connecting" } + sessionMap.set(ctx.sessionID, state) + saveSessionState(ctx.sessionID, state) + + if (controlMasterActive(socketPath, host)) { + state.status = "connected" + saveSessionState(ctx.sessionID, state) + return { + title: `Connected to ${host}`, + output: [ + `Reused the existing SSH connection to **${host}**. All bash commands will now run remotely.`, + `Say "local" to disconnect.`, + ].join("\n"), + } + } + try { - execFileSync("ssh", ["-MNf", "-S", socketPath, host], { stdio: "pipe", timeout: 15000 }) + execFileSync("ssh", ["-o", "BatchMode=yes", "-MNf", "-S", socketPath, host], { + stdio: "pipe", + timeout: 15000, + }) } catch (e) { + state.status = "unavailable" + saveSessionState(ctx.sessionID, state) const msg = e instanceof Error ? e.message : String(e) - return `Failed to connect to ${host}: ${msg}` + return [ + `Failed to connect to ${host}: ${msg}`, + `Do not retry automatically. Interactive authentication is disabled inside OpenCode.`, + `Remote mode remains fail-closed: bash commands are blocked until connection succeeds or the user says "local".`, + `In a separate terminal, run this exact command and complete authentication there:`, + `ssh -M -S ${shellQuote(socketPath)} ${shellQuote(host)}`, + `Keep that terminal connected, then ask the user to run "ssh ${host}" again in OpenCode.`, + ].join("\n") } - sessionMap.set(ctx.sessionID, { host, socketPath }) + state.status = "connected" + saveSessionState(ctx.sessionID, state) return { title: `Connected to ${host}`, @@ -91,13 +184,13 @@ export default { description: "Close the persistent SSH connection and return to local mode. Call this when the user says 'local' or asks to disconnect.", args: {}, async execute(_args, ctx) { - const state = sessionMap.get(ctx.sessionID) + const state = getSessionState(ctx.sessionID) if (!state) return "Not currently connected." try { execFileSync("ssh", ["-O", "stop", "-S", state.socketPath, state.host], { stdio: "pipe" }) } catch {} - sessionMap.delete(ctx.sessionID) + deleteSessionState(ctx.sessionID) return "Disconnected. Commands now run locally." }, @@ -105,10 +198,18 @@ export default { }, "tool.execute.before": async (input, output) => { - const state = sessionMap.get(input.sessionID) + const state = getSessionState(input.sessionID) if (!state) return if (input.tool === "bash") { + if (state.status !== "connected" || !controlMasterActive(state.socketPath, state.host)) { + state.status = "unavailable" + saveSessionState(input.sessionID, state) + output.args.command = blockedBashCommand(state.host) + output.args.description = `[blocked remote ${state.host}] SSH connection unavailable` + return + } + const cmd = output.args.command const wrappedPrefix = ["ssh", "-S", state.socketPath, state.host].map(shellQuote).join(" ") if (cmd.startsWith(`${wrappedPrefix} `)) return @@ -155,21 +256,31 @@ export default { }, "experimental.chat.system.transform": async (input, output) => { - const state = input.sessionID ? sessionMap.get(input.sessionID) : undefined + const state = input.sessionID ? getSessionState(input.sessionID) : undefined if (!state) return output.system.push( - [ - "", - "## Remote SSH Mode", - "", - `You are connected to **${state.host}** via persistent SSH.`, - "", - "1. The **bash** tool is automatically wrapped with SSH. Do NOT add SSH yourself.", - "2. Use bash with `cat`, `tee`, `grep`, `find` for all remote file ops.", - "3. Native `read`/`write`/`edit`/`glob`/`grep` tools are disabled in remote mode.", - "4. Say \"local\" to disconnect.", - ].join("\n"), + state.status === "connected" + ? [ + "", + "## Remote SSH Mode", + "", + `You are connected to **${state.host}** via persistent SSH.`, + "Local platform and working-directory metadata describe the OpenCode host, not the remote host.", + "", + "1. The **bash** tool verifies the connection and is automatically wrapped with SSH. Do NOT add SSH yourself.", + "2. Use bash with `cat`, `tee`, `grep`, `find` for all remote file ops.", + "3. Native `read`/`write`/`edit`/`glob`/`grep` tools are disabled in remote mode.", + "4. Say \"local\" to disconnect.", + ].join("\n") + : [ + "", + "## Remote SSH Mode Unavailable", + "", + `Remote mode was requested for **${state.host}**, but its SSH connection is unavailable.`, + "All bash and native file-tool operations are blocked to prevent accidental local execution.", + "Use ssh_connect to reconnect, or say \"local\" to explicitly return to local mode.", + ].join("\n"), ) }, }