From dd4baa98ed824bf9b0bc217685e3842017eea19d Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 02:37:29 -0800 Subject: [PATCH] fix(cloud): security review of A6: offer and save only keys the proxy swaps; no desktop keys over SSH into a cloud workspace; orcad on a cloud VM drops carried keys - 'Save as personal Cloud secret' is offered, and accepted by main, only for names the egress proxy swaps (a shared list the control-plane test holds equal to the proxy's); anything else would sit on the VM in full, so main answers not_swappable. - SSH launches into an Orca Cloud workspace (orca-* aliases or an 'orca ssh-proxy' ProxyCommand surfaced through ~/.ssh/config Includes) get no desktop keys. - Defense in depth on the VM: orcad with ORCA_IS_CLOUD=1 drops a carried keyEnv. --- .../test/agent-credentials-more.test.ts | 4 +- src/main/cloud-secrets/cloud-secrets-ipc.ts | 4 ++ .../hosted-sandbox/cloud-desktop-keys.test.ts | 40 +++++++++++++++++++ src/main/hosted-sandbox/cloud-desktop-keys.ts | 39 +++++++++++++++++- src/main/ipc/pty/spawn-options-assembly.ts | 9 +---- .../terminal/agent-session-key-env.test.ts | 25 ++++++++++++ .../rpc/methods/terminal/agent-session.ts | 7 +++- .../cloud-agent-readiness.test.ts | 28 +++++++++++++ .../cloud-agent-readiness.ts | 9 ++++- .../cloud-secrets/cloud-secrets-copy.ts | 5 +++ src/renderer/src/i18n/locales/en.json | 15 +++++-- .../cloud-secrets/cloud-secret-rules.ts | 22 ++++++++++ .../cloud-secrets/cloud-secrets-wire.ts | 2 + 13 files changed, 194 insertions(+), 15 deletions(-) create mode 100644 src/main/runtime/rpc/methods/terminal/agent-session-key-env.test.ts diff --git a/cloud/apps/sandbox-control/test/agent-credentials-more.test.ts b/cloud/apps/sandbox-control/test/agent-credentials-more.test.ts index f0234531168..9eb50a5b13a 100644 --- a/cloud/apps/sandbox-control/test/agent-credentials-more.test.ts +++ b/cloud/apps/sandbox-control/test/agent-credentials-more.test.ts @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { compileNetworkPolicy } from '../src/network-policy' import { SWAPPED_CREDENTIALS, credentialTargets } from '../src/provider-env' import { AGENT_KEY_NAMES } from '../src/secrets' -import { cloudSecretStoredOnVm } from '../../../../src/shared/cloud-secrets/cloud-secret-rules' +import { CLOUD_SWAPPED_CREDENTIALS, cloudSecretStoredOnVm } from '../../../../src/shared/cloud-secrets/cloud-secret-rules' import { egressSecretHosts, encodeEgressSecretHosts } from '../src/secret-egress' import { ENV_COMMAND } from '../src/sandbox-vm-commands' import { createHarness } from './harness' @@ -286,5 +286,7 @@ describe('every agent credential is either swapped or marked "stored on workspac expect(swapped !== cloudSecretStoredOnVm(name), `${name}: swapped=${swapped}`).toBe(true) } expect(SWAPPED_CREDENTIALS.includes('COPILOT_GITHUB_TOKEN')).toBe(true) + // The desktop's mirror (what it offers to save from this computer) matches the proxy exactly. + expect([...CLOUD_SWAPPED_CREDENTIALS].sort()).toEqual([...SWAPPED_CREDENTIALS].sort()) }) }) diff --git a/src/main/cloud-secrets/cloud-secrets-ipc.ts b/src/main/cloud-secrets/cloud-secrets-ipc.ts index 39ed3a9bdc6..09bf13d8784 100644 --- a/src/main/cloud-secrets/cloud-secrets-ipc.ts +++ b/src/main/cloud-secrets/cloud-secrets-ipc.ts @@ -160,6 +160,10 @@ export function registerCloudSecretsHandlers(userDataPath: string): void { if (nameError) { return { ok: false, error: nameError } } + // Only keys the workspace gets as a stand-in: anything else would sit there in full. + if (!cloudSecretSwapped(envName)) { + return { ok: false, error: 'not_swappable' } + } const value = await desktopKeyValueForCloud(agent, envName) if (!value) { return { ok: false, error: 'not_found' } diff --git a/src/main/hosted-sandbox/cloud-desktop-keys.test.ts b/src/main/hosted-sandbox/cloud-desktop-keys.test.ts index 1eb2cb41ab7..96aec6e1168 100644 --- a/src/main/hosted-sandbox/cloud-desktop-keys.test.ts +++ b/src/main/hosted-sandbox/cloud-desktop-keys.test.ts @@ -21,8 +21,19 @@ vi.mock('../agent-api-keys/agent-api-key-launch-env', () => ({ resolveAgentApiKeyLaunchEnv: async (...args: unknown[]) => { launchCalls.push(args) return launchEnv + }, + withAgentApiKeyLaunchEnv: async (env: Record | undefined, ...args: unknown[]) => { + launchCalls.push(args) + return { ...env, ...launchEnv } } })) +const sshTargets = new Map< + string, + { host: string; label: string; configHost?: string; proxyCommand?: string } +>() +vi.mock('../ssh/targets/ssh-target-registry', () => ({ + getSshTargetRegistryStore: () => ({ getSshTarget: (id: string) => sshTargets.get(id) }) +})) const { desktopKeyValueForCloud, isOrcaCloudRuntimeEnvironment } = await import('./cloud-desktop-keys') @@ -32,6 +43,7 @@ beforeEach(() => { environments.clear() launchEnv = {} launchCalls.length = 0 + sshTargets.clear() }) describe('isOrcaCloudRuntimeEnvironment', () => { @@ -90,3 +102,31 @@ describe('withHostAgentApiKeyEnv', () => { expect(readStatus).not.toHaveBeenCalled() }) }) + +describe('withHostAgentApiKeyLaunchEnv (SSH)', () => { + it('sends no desktop keys over SSH into an Orca Cloud workspace, and does elsewhere', async () => { + const { withHostAgentApiKeyLaunchEnv } = await import('./cloud-desktop-keys') + launchEnv = { CURSOR_API_KEY: 'key_real' } + sshTargets.set('t-cloud', { + host: 'orca-api-7f3a', + label: 'orca-api-7f3a', + configHost: 'orca-api-7f3a' + }) + sshTargets.set('t-proxy', { + host: 'box', + label: 'box', + proxyCommand: '/Applications/Orca.app/bin/orca ssh-proxy env-1' + }) + sshTargets.set('t-devbox', { host: 'devbox.lan', label: 'devbox' }) + expect(await withHostAgentApiKeyLaunchEnv({ A: '1' }, 'cursor', 't-cloud')).toEqual({ A: '1' }) + expect(await withHostAgentApiKeyLaunchEnv({ A: '1' }, 'cursor', 't-proxy')).toEqual({ A: '1' }) + expect(launchCalls).toEqual([]) + expect(await withHostAgentApiKeyLaunchEnv({ A: '1' }, 'cursor', 't-devbox')).toEqual({ + A: '1', + CURSOR_API_KEY: 'key_real' + }) + expect(await withHostAgentApiKeyLaunchEnv(undefined, 'cursor', null)).toEqual({ + CURSOR_API_KEY: 'key_real' + }) + }) +}) diff --git a/src/main/hosted-sandbox/cloud-desktop-keys.ts b/src/main/hosted-sandbox/cloud-desktop-keys.ts index 4837108eb5f..95db8e5f1f3 100644 --- a/src/main/hosted-sandbox/cloud-desktop-keys.ts +++ b/src/main/hosted-sandbox/cloud-desktop-keys.ts @@ -2,7 +2,13 @@ // host those keys ride each agent launch as plain env. An Orca Cloud VM must never hold a model key, // so they are not sent there at all: the user saves a key as a personal Cloud secret instead (one key // per click), and the VM then gets a placeholder the egress proxy swaps. -import { resolveAgentApiKeyLaunchEnv } from '../agent-api-keys/agent-api-key-launch-env' +import { + resolveAgentApiKeyLaunchEnv, + withAgentApiKeyLaunchEnv +} from '../agent-api-keys/agent-api-key-launch-env' +import { isCloudSshAlias } from '../cloud-ssh/cloud-ssh-config' +import { getSshTargetRegistryStore } from '../ssh/targets/ssh-target-registry' +import { getConnectionExecutionHostId } from '../../shared/execution-host' import { withAgentApiKeyRuntimeEnv } from '../agent-api-keys/agent-api-key-runtime-call' import { listEphemeralVmRuntimes } from '../../shared/ephemeral-vm-runtime-store' import { resolveEnvironment } from '../../shared/remote-runtime/host-store/runtime-environment-store' @@ -78,3 +84,34 @@ export async function withHostAgentApiKeyEnv( } return withAgentApiKeyRuntimeEnv(environmentId, method, params, readStatus) } + +/** + * An SSH target that reaches an Orca Cloud workspace (`orca ssh` writes `orca-*` aliases that proxy + * through the Orca CLI; OpenSSH config Includes can surface them as targets). + */ +export function isOrcaCloudSshConnection(connectionId: string | null | undefined): boolean { + if (!connectionId) { + return false + } + const target = getSshTargetRegistryStore()?.getSshTarget(connectionId) + if (!target) { + return false + } + return ( + [target.configHost, target.host, target.label].some( + (value) => value !== undefined && isCloudSshAlias(value) + ) || /\bssh-proxy\b/.test(target.proxyCommand ?? '') + ) +} + +/** A local or SSH launch env with this computer's Agent API Keys, except over SSH into an Orca Cloud workspace. */ +export async function withHostAgentApiKeyLaunchEnv( + env: Record | undefined, + agent: TuiAgent | undefined, + connectionId: string | null | undefined +): Promise | undefined> { + if (isOrcaCloudSshConnection(connectionId)) { + return env + } + return withAgentApiKeyLaunchEnv(env, agent, getConnectionExecutionHostId(connectionId)) +} diff --git a/src/main/ipc/pty/spawn-options-assembly.ts b/src/main/ipc/pty/spawn-options-assembly.ts index ef3279a6f77..e0dcc651271 100644 --- a/src/main/ipc/pty/spawn-options-assembly.ts +++ b/src/main/ipc/pty/spawn-options-assembly.ts @@ -1,6 +1,5 @@ import { withClaudeAgentViewPlugin } from '../../../shared/claude-agent-view-launch' -import { getConnectionExecutionHostId } from '../../../shared/execution-host' -import { withAgentApiKeyLaunchEnv } from '../../agent-api-keys/agent-api-key-launch-env' +import { withHostAgentApiKeyLaunchEnv } from '../../hosted-sandbox/cloud-desktop-keys' import { resolveStartupShell } from '../../../shared/agent-launch/startup/tui-agent-startup-shell' import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' import { getAppEnvironment } from '../../../shared/app-environment' @@ -156,11 +155,7 @@ export async function assemblePtySpawnLaunchOptions(input: PtySpawnLaunchOptions const env = input.preAdoptedStablePane || input.launchCommand === undefined ? openCodeLaunch.env - : await withAgentApiKeyLaunchEnv( - openCodeLaunch.env, - launchAgent, - getConnectionExecutionHostId(connectionId) - ) + : await withHostAgentApiKeyLaunchEnv(openCodeLaunch.env, launchAgent, connectionId) const pluginDir = !connectionId ? env?.ORCA_CLAUDE_AGENT_VIEW_PLUGIN_DIR : undefined const launchCommand = pluginDir && openCodeLaunch.command diff --git a/src/main/runtime/rpc/methods/terminal/agent-session-key-env.test.ts b/src/main/runtime/rpc/methods/terminal/agent-session-key-env.test.ts new file mode 100644 index 00000000000..7f5e6f0e7b5 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/agent-session-key-env.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { withKeyEnvFolded } from './agent-session' + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('carried agent keys on the host', () => { + it('folds them into the launch env on an ordinary host', () => { + expect(withKeyEnvFolded({ keyEnv: { GEMINI_API_KEY: 'AIzaSy-real' } })).toEqual({ + keyEnv: undefined, + accountEnv: { GEMINI_API_KEY: 'AIzaSy-real' } + }) + }) + + it('drops them on an Orca Cloud VM', () => { + vi.stubEnv('ORCA_IS_CLOUD', '1') + expect( + withKeyEnvFolded({ keyEnv: { GEMINI_API_KEY: 'AIzaSy-real' }, accountEnv: { X: '1' } }) + ).toEqual({ + keyEnv: undefined, + accountEnv: { X: '1' } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal/agent-session.ts b/src/main/runtime/rpc/methods/terminal/agent-session.ts index 1c4f49cfa16..aee3b21890f 100644 --- a/src/main/runtime/rpc/methods/terminal/agent-session.ts +++ b/src/main/runtime/rpc/methods/terminal/agent-session.ts @@ -34,12 +34,17 @@ function withExecutionHostAgentPresentation; keyEnv?: Record } >(params: T): T { if (!params.keyEnv) { return params } + // An Orca Cloud VM never takes a desktop's keys (they would sit here in plaintext); its agents + // get Cloud secrets as egress placeholders instead. The desktop already withholds them. + if (process.env.ORCA_IS_CLOUD === '1') { + return { ...params, keyEnv: undefined } + } const accountEnv = withAgentKeyLaunchEnv(params.accountEnv, params.keyEnv) const folded: T = { ...params, keyEnv: undefined, ...(accountEnv ? { accountEnv } : {}) } return folded diff --git a/src/renderer/src/components/cloud-agent-setup/cloud-agent-readiness.test.ts b/src/renderer/src/components/cloud-agent-setup/cloud-agent-readiness.test.ts index 156479eac72..04645ea729e 100644 --- a/src/renderer/src/components/cloud-agent-setup/cloud-agent-readiness.test.ts +++ b/src/renderer/src/components/cloud-agent-setup/cloud-agent-readiness.test.ts @@ -52,3 +52,31 @@ describe('offerableDesktopKeys', () => { ).toEqual([{ id: 'k-0002', agent: 'gemini', envName: 'GEMINI_API_KEY', hint: '0002' }]) }) }) + +describe('offerableDesktopKeys: only keys a workspace gets as a stand-in', () => { + it('never offers a key the proxy cannot swap (it would sit on the VM in full)', () => { + const all = { kind: 'all' } as const + expect( + offerableDesktopKeys( + [ + { + id: 'a', + agent: 'claude', + envName: 'AWS_BEARER_TOKEN_BEDROCK', + hint: 'aaaa', + hostScope: all + }, + { + id: 'b', + agent: 'opencode', + envName: 'OPENROUTER_API_KEY', + hint: 'bbbb', + hostScope: all + }, + { id: 'c', agent: 'cursor', envName: 'CURSOR_API_KEY', hint: 'cccc', hostScope: all } + ], + [] + ).map((key) => key.envName) + ).toEqual(['CURSOR_API_KEY']) + }) +}) diff --git a/src/renderer/src/components/cloud-agent-setup/cloud-agent-readiness.ts b/src/renderer/src/components/cloud-agent-setup/cloud-agent-readiness.ts index 4d07f9aa173..7f068de7b5f 100644 --- a/src/renderer/src/components/cloud-agent-setup/cloud-agent-readiness.ts +++ b/src/renderer/src/components/cloud-agent-setup/cloud-agent-readiness.ts @@ -1,4 +1,5 @@ import type { AgentApiKeySummary } from '../../../../shared/agent-api-keys/agent-api-key-catalog' +import { cloudSecretSwapped } from '../../../../shared/cloud-secrets/cloud-secret-rules' import type { CloudEffectiveSecret } from '../../../../shared/cloud-secrets/cloud-secrets-wire' export type CloudSetupAgent = 'claude' | 'codex' @@ -58,7 +59,13 @@ export function offerableDesktopKeys( const seen = new Set() const offers: CloudOfferableDesktopKey[] = [] for (const key of desktopKeys) { - if (key.hostScope.kind !== 'all' || inCloud.has(key.envName) || seen.has(key.envName)) { + // Only keys a workspace gets as a stand-in; anything else would sit on the VM in full. + if ( + key.hostScope.kind !== 'all' || + !cloudSecretSwapped(key.envName) || + inCloud.has(key.envName) || + seen.has(key.envName) + ) { continue } seen.add(key.envName) diff --git a/src/renderer/src/components/settings/cloud-secrets/cloud-secrets-copy.ts b/src/renderer/src/components/settings/cloud-secrets/cloud-secrets-copy.ts index d3da00c6023..dd971fd9507 100644 --- a/src/renderer/src/components/settings/cloud-secrets/cloud-secrets-copy.ts +++ b/src/renderer/src/components/settings/cloud-secrets/cloud-secrets-copy.ts @@ -31,6 +31,11 @@ export function cloudSecretsErrorCopy( t: Translate ): string { switch (code) { + case 'not_swappable': + return t( + 'cloudSecrets.error.notSwappable', + 'A workspace would get this key in full, so Orca doesn’t save it from here. Add it in Cloud secrets if you accept that.' + ) case 'invalid_name': return t( 'cloudSecrets.error.invalidName', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 11bf894ca4c..0308f5fccc3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -26640,13 +26640,11 @@ "shareDetail": "Every teammate’s cloud agents use it.", "status": { "team": "provided by your team", - "desktopKey": "API key from Orca on this computer", "cloudSecret": "your cloud secret", "none": "not set up" }, "source": { "both": "Claude and Codex", - "desktopKey": "{{agent}} uses the API key from Orca on this computer", "team": "Your team provides {{agent}} access", "cloudSecret": "{{agent}} uses your cloud secret" }, @@ -26690,7 +26688,15 @@ "discardQuestion": "Discard the key you typed?", "discard": "Discard", "keepEditing": "Keep editing", - "signInHandedOff": "Continues in the Agent sign-in row below." + "signInHandedOff": "Continues in the Agent sign-in row below.", + "desktopKeys": { + "title": "Keys on this computer", + "detail": "Cloud workspaces don’t get the API keys saved in Orca on this computer. Save one as a personal Cloud secret and the workspace only ever sees a stand-in for it.", + "saved": "Saved as a Cloud secret", + "saveLabel": "Save {{name}} as a personal Cloud secret", + "save": "Save as personal Cloud secret", + "retry": "Try again" + } }, "cloudSecrets": { "editor": { @@ -26837,7 +26843,8 @@ "syntax": "Not a NAME=value line.", "unterminatedQuote": "The closing quote is missing.", "overridden": "Set again further down; the later line is used.", - "unknown": "Something went wrong in Orca Cloud. Nothing was changed. Try again." + "unknown": "Something went wrong in Orca Cloud. Nothing was changed. Try again.", + "notSwappable": "A workspace would get this key in full, so Orca doesn’t save it from here. Add it in Cloud secrets if you accept that." }, "vm": { "badge": "Stored on workspace", diff --git a/src/shared/cloud-secrets/cloud-secret-rules.ts b/src/shared/cloud-secrets/cloud-secret-rules.ts index b40e0351dca..843d5a77f95 100644 --- a/src/shared/cloud-secrets/cloud-secret-rules.ts +++ b/src/shared/cloud-secrets/cloud-secret-rules.ts @@ -169,3 +169,25 @@ export function isPublicHttpsUrl(value: string): boolean { !host.startsWith('[') ) } + +/** + * Variables the Orca Cloud egress proxy swaps (cloud/apps/sandbox-control/src/provider-env.ts, + * checked equal by a control-plane test): a workspace only ever holds a stand-in for these. + */ +export const CLOUD_SWAPPED_CREDENTIALS: readonly string[] = [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'AI_GATEWAY_API_KEY', + 'OPENAI_API_KEY', + 'GEMINI_API_KEY', + 'GOOGLE_GENERATIVE_AI_API_KEY', + 'CURSOR_API_KEY', + 'GOOGLE_API_KEY', + 'COPILOT_GITHUB_TOKEN', + 'COPILOT_PROVIDER_API_KEY', + 'COPILOT_PROVIDER_BEARER_TOKEN' +] + +export function cloudSecretSwapped(name: string): boolean { + return CLOUD_SWAPPED_CREDENTIALS.includes(name) +} diff --git a/src/shared/cloud-secrets/cloud-secrets-wire.ts b/src/shared/cloud-secrets/cloud-secrets-wire.ts index bcc8482ef5c..6d48c2943eb 100644 --- a/src/shared/cloud-secrets/cloud-secrets-wire.ts +++ b/src/shared/cloud-secrets/cloud-secrets-wire.ts @@ -86,6 +86,8 @@ export type CloudSecretsErrorCode = | 'secret_limit' | 'unauthorized' | 'not_found' + /** Desktop only: a key the workspace would get in full (no egress stand-in), so not saved from here. */ + | 'not_swappable' | 'unavailable' | 'timeout' | 'not_configured'