diff --git a/README.md b/README.md index d4d6db4..243a6fa 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,10 @@ Versioning is **per package**, automated with [release-please](https://github.co - Keep each PR scoped to **no more than one package or app** — for packages, release routing is by changed file path, not the scope text. - Each package/app is self-contained (its own dependency manifest); develop within its folder. +## Security + +Security policy, vulnerability reporting, and the SSH gateway host key fingerprints are maintained in the organization-wide [Daytona security policy](https://github.com/daytona/.github/blob/main/SECURITY.md). Please report vulnerabilities through the channels described there — not through public issues. + ## License [Apache-2.0](LICENSE), unless a package declares otherwise — each package includes its own `LICENSE`. diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/gateway-host-key.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/gateway-host-key.ts new file mode 100644 index 0000000..de765e0 --- /dev/null +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/gateway-host-key.ts @@ -0,0 +1,240 @@ +/** + * Copyright Daytona Platforms Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs' +import { createHash } from 'crypto' +import { join } from 'path' +import { logger } from '../core/logger' + +export const DEFAULT_GATEWAY_HOST = 'ssh.app.daytona.io' +const DEFAULT_API_URL = 'https://app.daytona.io/api' +const SECURITY_POLICY_URL = 'https://github.com/daytona/.github/blob/main/SECURITY.md#ssh-host-key-verification' +const CONFIG_FETCH_TIMEOUT_MS = 5_000 + +export type GatewayEndpoint = { host: string; port: number } + +/** + * Which known_hosts file (if any) is the trust root for sandbox git transfers in this + * process, and where it came from. Always the OUTPUT of resolution, never a user + * input: there is no mode that disables host verification. The weakest outcome, + * 'inherited', is the SSH client's own verification - the behavior before this existed. + */ +export type HostKeyVerification = + | { mode: 'manual'; knownHostsFile: string; endpoint: GatewayEndpoint } + | { mode: 'pinned'; knownHostsFile: string; endpoint: GatewayEndpoint; fingerprints: string[] } + | { mode: 'inherited'; endpoint: GatewayEndpoint; reason: string } + +type PublishedGateway = { host: string; port: number; hostKeys: string[] } + +/** + * Decides, once per process, how transfers to the SSH gateway verify its host key: + * + * 1. DAYTONA_SSH_KNOWN_HOSTS set -> that file is the only trust root ("manual"). + * 2. Otherwise, unless DAYTONA_SSH_AUTO_PIN=false, the gateway host key published by the + * Daytona API (/config: sshGatewayHostKeys) is written to a plugin-managed known_hosts + * file and used as the only trust root ("pinned"). The API is consulted once per + * process; the pin file is what connections use, so an API outage after the first pin + * never weakens verification. + * 3. If nothing can be pinned (older API, API unreachable, auto-pin disabled), transfers + * fall back to the SSH client's normal host verification ("inherited") - exactly the + * behavior before this feature existed. + * + * Pinning is fail-closed against change: if the API later publishes a key set that no + * longer contains the pinned key, the pin is NOT replaced. Either the gateway rotated its + * key (the security policy publishes old and new keys concurrently during a rotation, so + * this should not happen for a healthy client) or something between the client and the + * API is lying; only a human can tell, so transfers are refused until the pin file is + * verified against the policy and removed. + */ +export class GatewayHostKeyPin { + private resolved?: Promise + + constructor( + private readonly storageDir: string, + private readonly apiUrl: string = process.env.DAYTONA_API_URL?.trim() || DEFAULT_API_URL, + ) {} + + get pinFile(): string { + return join(this.storageDir, 'gateway_known_hosts') + } + + resolve(): Promise { + this.resolved ??= this.resolveOnce() + return this.resolved + } + + private async resolveOnce(): Promise { + const manual = process.env.DAYTONA_SSH_KNOWN_HOSTS?.trim() + const published = await this.fetchPublishedGateway() + const endpoint: GatewayEndpoint = published + ? { host: published.host, port: published.port } + : { host: DEFAULT_GATEWAY_HOST, port: 22 } + + if (manual) { + this.warnIfManualDisagrees(manual, published) + return { mode: 'manual', knownHostsFile: manual, endpoint } + } + if (process.env.DAYTONA_SSH_AUTO_PIN?.trim().toLowerCase() === 'false') { + return { mode: 'inherited', endpoint, reason: 'DAYTONA_SSH_AUTO_PIN=false' } + } + + const existing = this.readPin() + if (!published && existing) { + // The API is unavailable: the pin file's own host field carries the endpoint it was + // pinned for, so a non-default host or port keeps working instead of the URL + // silently reverting to the default gateway while the pin names another. + const recovered = endpointOfEntry(existing.entries[0]) + if (recovered) Object.assign(endpoint, recovered) + } + if (published) { + const publishedEntries = published.hostKeys.map((key) => knownHostsEntry(endpoint, key)) + if (existing && !existing.entries.some((entry) => publishedEntries.includes(entry))) { + const message = + `The SSH gateway host key published by ${this.apiUrl}/config no longer matches the key pinned in ${this.pinFile}. ` + + `Either the gateway rotated its key and this machine has not synced since before the rotation's overlap window ` + + `(the security policy publishes old and new keys together for at least 30 days), or something between this machine and the API is not Daytona. ` + + `Verify the published fingerprint against ${SECURITY_POLICY_URL}; if it matches, run \`rm ${this.pinFile}\` and sync again to pin the new key.` + logger.error(`[host-key] ${message}`) + throw new Error(message) + } + if (!existing || existing.entries.length !== publishedEntries.length || publishedEntries.some((e) => !existing.entries.includes(e))) { + this.writePin(publishedEntries) + logger.info( + `[host-key] pinned ${published.host}:${published.port} host key(s) ${fingerprintsOf(published.hostKeys).join(', ')} from ${this.apiUrl}/config`, + ) + } + return { mode: 'pinned', knownHostsFile: this.pinFile, endpoint, fingerprints: fingerprintsOf(published.hostKeys) } + } + + if (existing) { + logger.warn(`[host-key] ${this.apiUrl}/config did not provide a gateway host key; using the existing pin in ${this.pinFile}`) + return { mode: 'pinned', knownHostsFile: this.pinFile, endpoint, fingerprints: fingerprintsOf(existing.entries.map(keyOfEntry)) } + } + logger.warn( + `[host-key] no gateway host key is published by ${this.apiUrl}/config and none is pinned; falling back to the SSH client's own host verification. ` + + `For strict noninteractive verification set DAYTONA_SSH_KNOWN_HOSTS (see ${SECURITY_POLICY_URL}).`, + ) + return { mode: 'inherited', endpoint, reason: 'no published or pinned host key' } + } + + private async fetchPublishedGateway(): Promise { + try { + const response = await fetch(`${this.apiUrl.replace(/\/+$/, '')}/config`, { + signal: AbortSignal.timeout(CONFIG_FETCH_TIMEOUT_MS), + headers: { accept: 'application/json' }, + }) + if (!response.ok) { + logger.warn(`[host-key] ${this.apiUrl}/config returned HTTP ${response.status}`) + return undefined + } + const body = (await response.json()) as Record + const hostKeys = Array.isArray(body.sshGatewayHostKeys) ? body.sshGatewayHostKeys.filter(isValidKeyLine) : [] + if (hostKeys.length === 0) return undefined + const host = typeof body.sshGatewayHost === 'string' && /^[A-Za-z0-9.\-[\]:]+$/.test(body.sshGatewayHost) ? body.sshGatewayHost : DEFAULT_GATEWAY_HOST + const port = + Number.isInteger(body.sshGatewayPort) && (body.sshGatewayPort as number) >= 1 && (body.sshGatewayPort as number) <= 65535 + ? (body.sshGatewayPort as number) + : 22 + return { host, port, hostKeys } + } catch (err) { + logger.warn(`[host-key] could not fetch ${this.apiUrl}/config: ${err}`) + return undefined + } + } + + private warnIfManualDisagrees(manualFile: string, published: PublishedGateway | undefined): void { + if (!published || !existsSync(manualFile)) return + try { + const manualKeys = readFileSync(manualFile, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + .map(keyOfEntry) + if (!manualKeys.some((key) => published.hostKeys.includes(key))) { + logger.warn( + `[host-key] DAYTONA_SSH_KNOWN_HOSTS (${manualFile}) contains none of the host keys published by ${this.apiUrl}/config (${fingerprintsOf(published.hostKeys).join(', ')}). ` + + `The manual file is still used as configured; verify both against ${SECURITY_POLICY_URL}.`, + ) + } + } catch (err) { + logger.warn(`[host-key] could not read DAYTONA_SSH_KNOWN_HOSTS for comparison: ${err}`) + } + } + + private readPin(): { entries: string[] } | undefined { + if (!existsSync(this.pinFile)) return undefined + const entries = readFileSync(this.pinFile, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + return entries.length ? { entries } : undefined + } + + private writePin(entries: string[]): void { + mkdirSync(this.storageDir, { recursive: true }) + const tmp = `${this.pinFile}.${process.pid}.tmp` + writeFileSync(tmp, entries.join('\n') + '\n', { mode: 0o600 }) + try { + renameSync(tmp, this.pinFile) + } catch { + writeFileSync(this.pinFile, entries.join('\n') + '\n', { mode: 0o600 }) + } finally { + try { + rmSync(tmp, { force: true }) + } catch {} + } + } +} + +/** OpenSSH known_hosts host field: bare host on port 22, `[host]:port` otherwise. */ +export function knownHostsHost(endpoint: GatewayEndpoint): string { + return endpoint.port === 22 ? endpoint.host : `[${endpoint.host}]:${endpoint.port}` +} + +function knownHostsEntry(endpoint: GatewayEndpoint, keyLine: string): string { + const [type, blob] = keyLine.trim().split(/\s+/) + return `${knownHostsHost(endpoint)} ${type} ${blob}` +} + +/** Inverse of knownHostsHost(): `[host]:port` -> {host, port}; bare host -> port 22. */ +function endpointOfEntry(entry: string): GatewayEndpoint | undefined { + const hostField = entry.trim().split(/\s+/)[0] + if (!hostField) return undefined + const bracketed = /^\[(.+)\]:(\d{1,5})$/.exec(hostField) + if (bracketed) { + const port = Number(bracketed[2]) + return port >= 1 && port <= 65535 ? { host: bracketed[1], port } : undefined + } + return { host: hostField, port: 22 } +} + +function keyOfEntry(entry: string): string { + const parts = entry.trim().split(/\s+/) + return `${parts[1]} ${parts[2]}` +} + +// Accept only well-formed OpenSSH public key lines of host-key types. Validation is done +// in-process: it must not depend on an optional executable, because a missing or failing +// `ssh-keygen` would otherwise silently downgrade every API response to inherited +// verification. The API validates the full wire encoding on its side; the blob will be +// rejected by ssh at connect time if it is not a usable key, which fails closed. +function isValidKeyLine(value: unknown): value is string { + if (typeof value !== 'string') return false + const parts = value.trim().split(/\s+/) + if (parts.length < 2 || !/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521))$/.test(parts[0])) return false + if (!/^[A-Za-z0-9+/]+=*$/.test(parts[1])) return false + const blob = Buffer.from(parts[1], 'base64') + if (blob.length < 4 || blob.toString('base64') !== parts[1]) return false + const typeLength = blob.readUInt32BE(0) + if (typeLength === 0 || typeLength > 64 || blob.length < 4 + typeLength) return false + return blob.subarray(4, 4 + typeLength).toString('ascii') === parts[0] +} + +function fingerprintsOf(keyLines: string[]): string[] { + return keyLines.map((line) => { + const blob = Buffer.from(line.trim().split(/\s+/)[1], 'base64') + return `SHA256:${createHash('sha256').update(blob).digest('base64').replace(/=+$/, '')}` + }) +} diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts index 47a8696..4506a30 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts @@ -4,6 +4,8 @@ */ import { logger } from '../core/logger' +import type { HostKeyVerification } from './gateway-host-key' +import { knownHostsHost } from './gateway-host-key' import { spawnSync } from 'child_process' import { realpathSync } from 'fs' import { isAbsolute, resolve as pathResolve } from 'path' @@ -55,32 +57,47 @@ function shellQuote(value: string): string { // DAYTONA_SSH_BINARY, an absolute path with no arguments, which keeps the option set // under plugin control. // +// Host verification follows the resolved HostKeyVerification: a manual or auto-pinned +// known_hosts file becomes the ONLY verification root (GlobalKnownHostsFile=/dev/null, so a +// matching entry in /etc/ssh/ssh_known_hosts cannot satisfy verification either); +// "inherited" leaves the SSH client's normal verification in place. +// // Values cross two parsers. sh splits GIT_SSH_COMMAND into ssh's argv (outer single // quotes), then OpenSSH splits the UserKnownHostsFile VALUE on whitespace as a file // list (inner double quotes keep a spaced path as one file). OpenSSH's config grammar // has no escape for a literal double quote inside a quoted value, so such paths are // rejected instead of being silently mis-pinned. -function transferEnv(token: string): NodeJS.ProcessEnv { +function transferEnv(token: string, verification: HostKeyVerification): NodeJS.ProcessEnv { const binary = process.env.DAYTONA_SSH_BINARY?.trim() || 'ssh' if (binary !== 'ssh' && !isAbsolute(binary)) { throw new Error('DAYTONA_SSH_BINARY must be an absolute path to an OpenSSH client binary') } const parts = [binary === 'ssh' ? 'ssh' : shellQuote(binary), '-o', shellQuote(`User=${token}`)] - const knownHosts = process.env.DAYTONA_SSH_KNOWN_HOSTS?.trim() - if (knownHosts) { - if (knownHosts.includes('"')) { - throw new Error('DAYTONA_SSH_KNOWN_HOSTS must not contain a double quote (") character') + if (verification.mode !== 'inherited') { + if (verification.knownHostsFile.includes('"')) { + throw new Error('The known_hosts path for sandbox transfers must not contain a double quote (") character') } - // GlobalKnownHostsFile=/dev/null: otherwise a matching entry in the system-wide - // /etc/ssh/ssh_known_hosts would also be accepted and verification would not be - // pinned to the configured file alone. + // The pin file must be the ONLY trust root, so every ssh_config directive that can + // add or redirect host-key trust is neutralized here (first value wins, and we own + // the command): KnownHostsCommand can supply extra accepted keys from a program; + // UpdateHostKeys lets a connected server append further keys to the pin file; + // VerifyHostKeyDNS admits SSHFP records as a trust source; HostKeyAlias changes + // which name is looked up, so it is fixed to the pinned host. parts.push( '-o', - shellQuote(`UserKnownHostsFile="${knownHosts}"`), + shellQuote(`UserKnownHostsFile="${verification.knownHostsFile}"`), '-o', 'GlobalKnownHostsFile=/dev/null', '-o', 'StrictHostKeyChecking=yes', + '-o', + 'KnownHostsCommand=none', + '-o', + 'UpdateHostKeys=no', + '-o', + 'VerifyHostKeyDNS=no', + '-o', + shellQuote(`HostKeyAlias=${knownHostsHost(verification.endpoint)}`), ) } // GIT_SSH_VARIANT=ssh: git otherwise applies the user's ssh.variant to OUR command - @@ -281,6 +298,7 @@ export class HostGitManager { * @param remoteName Numbered remote (e.g. sandbox-2) matching opencode/N. * @param remoteUrl Credential-free SSH URL of the sandbox repository. * @param token Short-lived access token, supplied to the git invocation only. + * @param verification How the gateway host key is verified for this transfer. * @param branch The branch to push to. * @param cwd Worktree path to run git in. * @returns true if push succeeded, false if no repo exists. Throws if the push fails. @@ -289,6 +307,7 @@ export class HostGitManager { remoteName: string, remoteUrl: string, token: string, + verification: HostKeyVerification, branch: string, cwd: string, ): Promise { @@ -309,7 +328,7 @@ export class HostGitManager { this.setRemote(remoteName, remoteUrl, cwd) let attempts = 0 while (attempts < 3) { - const pushRes = execGit(['push', remoteName, `HEAD:${branch}`], { cwd, env: transferEnv(token) }) + const pushRes = execGit(['push', remoteName, `HEAD:${branch}`], { cwd, env: transferEnv(token, verification) }) if (pushRes.ok) { logger.info(`✓ Pushed local changes to ${remoteName}`) return @@ -370,6 +389,7 @@ export class HostGitManager { remoteName: string, remoteUrl: string, token: string, + verification: HostKeyVerification, branch: string, cwd: string, localBranch?: string, @@ -384,7 +404,7 @@ export class HostGitManager { if (localBranch) { // Fetch into FETCH_HEAD only (never into refs/heads) so we don't hit // "refusing to fetch into branch checked out" when this branch is checked out. - const fetchRes = execGit(['fetch', remoteName, branch], { cwd, env: transferEnv(token) }) + const fetchRes = execGit(['fetch', remoteName, branch], { cwd, env: transferEnv(token, verification) }) if (!fetchRes.ok) throw new Error(fetchRes.stderr) const updateRefRes = execGit(['update-ref', `refs/heads/${localBranch}`, 'FETCH_HEAD'], { cwd }) @@ -400,7 +420,7 @@ export class HostGitManager { logger.info(`✓ Force pulled latest changes from sandbox into ${localBranch}`) } else { - const pullRes = execGit(['pull', remoteName, branch], { cwd, env: transferEnv(token) }) + const pullRes = execGit(['pull', remoteName, branch], { cwd, env: transferEnv(token, verification) }) if (!pullRes.ok) throw new Error(pullRes.stderr) logger.info('✓ Pulled latest changes from sandbox') } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts index 19aafe2..3cecaed 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts @@ -9,8 +9,9 @@ import { toast } from '../core/toast' import { DaytonaSandboxGitManager } from './sandbox-git-manager' import { HostGitManager } from './host-git-manager' import type { PluginInput } from '@opencode-ai/plugin' +import type { GatewayHostKeyPin, HostKeyVerification } from './gateway-host-key' +import { knownHostsHost } from './gateway-host-key' -export const SSH_GATEWAY_HOST = 'ssh.app.daytona.io' /** * SessionGitManager: Combines DaytonaSandboxGitManager and HostGitManager for session lifecycle git operations. @@ -116,15 +117,41 @@ export class SessionGitManager { * failure) instead of living out its expiry. The token is handed to `fn` separately * from the URL so it is never embedded in anything git persists. */ - private async withSshAccess(fn: (access: { url: string; token: string }) => Promise): Promise { + private async withSshAccess( + fn: (access: { url: string; token: string; verification: HostKeyVerification }) => Promise, + ): Promise { + // Resolved BEFORE the token is minted: a fail-closed pin mismatch must not leave a + // live token behind, and the endpoint decides the URL. + const verification = await SessionGitManager.hostKeyVerification() + const { host, port } = verification.endpoint + const url = `ssh://${host}${port === 22 ? '' : `:${port}`}${this.repoPath}` const sshAccess = await this.sandbox.createSshAccess(10) try { - return await fn({ url: `ssh://${SSH_GATEWAY_HOST}${this.repoPath}`, token: sshAccess.token }) + return await fn({ url, token: sshAccess.token, verification }) } finally { await this.revokeWithRetry(sshAccess.token) } } + private static pin?: GatewayHostKeyPin + + /** Installed once at plugin load; every transfer resolves host-key verification through it. */ + static useHostKeyPin(pin: GatewayHostKeyPin): void { + SessionGitManager.pin = pin + } + + static hostKeyVerification(): Promise { + if (!SessionGitManager.pin) { + throw new Error('Gateway host key pin is not configured; SessionGitManager.useHostKeyPin was not called at plugin load') + } + return SessionGitManager.pin.resolve() + } + + /** The known_hosts host field for the resolved gateway endpoint, e.g. for documentation or diagnostics. */ + static async gatewayKnownHostsHost(): Promise { + return knownHostsHost((await SessionGitManager.hostKeyVerification()).endpoint) + } + // Revocation shortens the exposure window; the token still expires on its own, so a // revocation that keeps failing is logged loudly rather than failing a sync whose // git work already completed. @@ -175,8 +202,8 @@ export class SessionGitManager { } await this.sandboxGit.ensureRepo() - const pushed = await this.withSshAccess(({ url, token }) => - this.hostGit.pushLocalToSandboxRemote(this.remoteName, url, token, this.branch, this.worktree), + const pushed = await this.withSshAccess(({ url, token, verification }) => + this.hostGit.pushLocalToSandboxRemote(this.remoteName, url, token, verification, this.branch, this.worktree), ) if (pushed) { await this.sandboxGit.resetToRemote(this.branch) @@ -226,8 +253,8 @@ export class SessionGitManager { // Pull the branch the sandbox actually committed to, which may differ from the // initial 'opencode' branch, so commits are never left unsynced. const sandboxBranch = await this.sandboxGit.getCurrentBranch() - await this.withSshAccess(({ url, token }) => - this.hostGit.pull(this.remoteName, url, token, sandboxBranch, this.worktree, this.localBranch), + await this.withSshAccess(({ url, token, verification }) => + this.hostGit.pull(this.remoteName, url, token, verification, sandboxBranch, this.worktree, this.localBranch), ) toast.show({ title: 'Changes synced', diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/index.ts b/packages/opencode-plugin/.opencode/plugin/daytona/index.ts index 6755428..ae90675 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/index.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/index.ts @@ -31,6 +31,7 @@ import type { PluginInput } from '@opencode-ai/plugin' import { logger, setLogFilePath } from './core/logger' import { DaytonaSessionManager } from './core/session-manager' import { SessionGitManager } from './git/session-git-manager' +import { GatewayHostKeyPin } from './git/gateway-host-key' import { toast } from './core/toast' import { customTools } from './plugins/custom-tools' import { eventHandlers } from './plugins/session-events' @@ -53,6 +54,11 @@ const sessionManager = new DaytonaSessionManager( async function daytonaPlugin(ctx: PluginInput) { toast.initialize(ctx.client?.tui) + SessionGitManager.useHostKeyPin(new GatewayHostKeyPin(STORAGE_DIR)) + // Resolve eagerly so the outcome (pinned / manual / inherited / key mismatch) is logged + // at startup rather than surfacing only on the first sync; failures here are logged, + // and the first transfer re-raises them where they can be reported to the user. + SessionGitManager.hostKeyVerification().catch((err) => logger.error(`[host-key] ${err}`)) return { tool: await customTools(ctx, sessionManager), event: await eventHandlers(ctx, sessionManager, REPO_PATH), diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index 899665a..4d2276f 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -58,19 +58,40 @@ The snapshot must already exist and be active in your organization; create one v Leave `DAYTONA_SNAPSHOT` unset to keep the default behavior. The plugin still creates `/home/daytona/project` and syncs your git branch into it. -#### Pinning the sandbox SSH host key +#### Verifying the sandbox SSH gateway host key -Git syncing transfers commits between your machine and the sandbox over SSH (`ssh.app.daytona.io`). By default, host verification follows your normal SSH configuration — on a machine that has never connected before, this means an interactive trust-on-first-use prompt, which blocks noninteractive environments such as CI or supervised agent runs. +Git syncing transfers commits between your machine and the sandbox over SSH through the Daytona SSH gateway (`ssh.app.daytona.io`). Like any SSH server, the gateway identifies itself with a host key, and the plugin verifies it before any transfer. Verification works in one of three modes, checked in this order: -To make syncing noninteractive and independently verifiable, point `DAYTONA_SSH_KNOWN_HOSTS` at a `known_hosts` file containing the `ssh.app.daytona.io` host keys: +| Mode | When | What is trusted | +| --- | --- | --- | +| **Manual pin** | `DAYTONA_SSH_KNOWN_HOSTS` is set | Only the keys in that file | +| **Auto-pin** (default) | Otherwise, unless `DAYTONA_SSH_AUTO_PIN=false` | Only the gateway host key published by the Daytona API (`/api/config`), pinned on first use into a plugin-managed file (`storage/daytona/gateway_known_hosts`) | +| **Inherited** | Auto-pin is disabled, or no key is published and none is pinned | Your SSH client's normal host verification (`~/.ssh/known_hosts`, trust-on-first-use prompts) | + +In the two pinned modes the pin file is the *only* trust root for sandbox transfers: system-wide known hosts are ignored and `StrictHostKeyChecking=yes` is set. SSH behavior for every other remote is unaffected. + +**Auto-pin is fail-closed against change.** The API is consulted once per plugin start; the pin file is what connections use, so once a key has been pinned an API outage never weakens verification. (On a machine that has never pinned anything, an unreachable API means there is nothing to pin yet and the plugin falls back to inherited verification — set `DAYTONA_SSH_KNOWN_HOSTS` if a first run must already be strict.) If the API later publishes a host key that does not include the pinned one, transfers are refused with a message pointing here. That means either the gateway rotated its key — the [security policy](https://github.com/daytona/.github/blob/main/SECURITY.md#ssh-host-key-verification) publishes old and new keys together during a rotation, so a healthy client should not hit this — or something between you and the API is not Daytona. Verify the published key against the security policy; if it is legitimate, delete the pin file to pin the new key. + +**Manual pin** is for environments that want a human-verified trust root independent of the API — supervised agent runs, CI, compliance-driven setups. The gateway host key is published as a ready-to-use `known_hosts` line in Daytona's [security policy](https://github.com/daytona/.github/blob/main/SECURITY.md#ssh-host-key-verification); copy it into a file and point the plugin at it: ```bash mkdir -p ~/.config/daytona -ssh-keyscan ssh.app.daytona.io > ~/.config/daytona/known_hosts +# paste the `ssh.app.daytona.io ssh-ed25519 ...` line from the security policy into: +$EDITOR ~/.config/daytona/known_hosts export DAYTONA_SSH_KNOWN_HOSTS=~/.config/daytona/known_hosts ``` -Verify the collected fingerprints out of band (`ssh-keygen -lf ~/.config/daytona/known_hosts`) before trusting the file. When `DAYTONA_SSH_KNOWN_HOSTS` is set, sandbox git transfers use that file as the only host-key database (`StrictHostKeyChecking=yes`, system-wide known hosts ignored); SSH behavior for every other remote is unaffected. When unset, behavior is unchanged. Paths containing spaces are supported; a literal `"` in the path is rejected. +To cross-check that the live gateway presents the published key, compare fingerprints: `ssh-keyscan ssh.app.daytona.io 2>/dev/null | ssh-keygen -lf -` must print the fingerprint listed in the security policy. Do not build the file from `ssh-keyscan` alone — that trusts whatever answered on first connection, which is exactly what pinning is meant to avoid. If the manual file disagrees with the key the API publishes, the plugin logs a warning but keeps using your file. + +The example above is for the shared gateway on the default SSH port. For a gateway on another host or port (self-hosted or dedicated regions — check `sshGatewayHost` and `sshGatewayPort` in `GET /api/config`), the `known_hosts` host field must use OpenSSH's `[host]:port` form, or the entry will never match: + +``` +[gateway.example.com]:2222 ssh-ed25519 AAAA... +``` + +and the cross-check becomes `ssh-keyscan -p 2222 gateway.example.com | ssh-keygen -lf -`. Auto-pin produces the correct form automatically. + +Paths containing spaces are supported; a literal `"` in the path is rejected. ### Running OpenCode