mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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.
This commit is contained in:
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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<string, string> | 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, string> | undefined,
|
||||
agent: TuiAgent | undefined,
|
||||
connectionId: string | null | undefined
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
if (isOrcaCloudSshConnection(connectionId)) {
|
||||
return env
|
||||
}
|
||||
return withAgentApiKeyLaunchEnv(env, agent, getConnectionExecutionHostId(connectionId))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -34,12 +34,17 @@ function withExecutionHostAgentPresentation<T extends { presentation?: 'backgrou
|
||||
}
|
||||
|
||||
/** Carried keys ride the account env the launch already merges over its plan; nothing downstream keeps the request. */
|
||||
function withKeyEnvFolded<
|
||||
export function withKeyEnvFolded<
|
||||
T extends { accountEnv?: Record<string, string>; keyEnv?: Record<string, string> }
|
||||
>(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
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>()
|
||||
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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user