diff --git a/cloud/apps/sandbox-control/migrations/0032_egress_user_hosts.sql b/cloud/apps/sandbox-control/migrations/0032_egress_user_hosts.sql new file mode 100644 index 00000000000..89cc3a8702e --- /dev/null +++ b/cloud/apps/sandbox-control/migrations/0032_egress_user_hosts.sql @@ -0,0 +1,4 @@ +-- Hosts the workspace's own agent tools need (remote MCP servers synced from the desktop), added to +-- the egress allowlist on every boot (comma list). Set by PUT /v1/sandboxes/:id/egress/hosts; unlike +-- egress_allow_hosts it is not recomputed from secrets. +ALTER TABLE sandboxes ADD COLUMN egress_user_hosts TEXT; diff --git a/cloud/apps/sandbox-control/src/egress-hosts.ts b/cloud/apps/sandbox-control/src/egress-hosts.ts new file mode 100644 index 00000000000..7fb5201a181 --- /dev/null +++ b/cloud/apps/sandbox-control/src/egress-hosts.ts @@ -0,0 +1,55 @@ +// Hosts a workspace's own agent tools need beyond the default allowlist — today the remote MCP +// servers the desktop syncs into the sandbox (MCP config sync). The desktop declares them with +// PUT /v1/sandboxes/:id/egress/hosts {hosts}; they persist on the row (every boot re-applies them) +// and a running sandbox's policy is rewritten at once. Public hostnames only: no wildcards, IPs, +// localhost or private suffixes, so the fail-closed allowlist can't be turned into "open". +import { z } from 'zod' +import { egressPolicy, refreshGitCredentials, type LifecycleDeps } from './lifecycle' +import { transition, type SandboxRow } from './store' + +export const MAX_USER_HOSTS = 32 +const HOST = /^([a-z0-9-]+\.)+[a-z]{2,63}$/ +const PRIVATE_SUFFIXES = ['.local', '.localhost', '.internal', '.lan', '.home', '.corp', '.intranet'] + +export const EgressHostsSchema = z + .object({ hosts: z.array(z.string().min(1).max(253)).max(MAX_USER_HOSTS) }) + .strict() + +/** Normalized, deduplicated, sorted; null when any entry is not an acceptable public hostname. */ +export function normalizeUserHosts(hosts: readonly string[]): string[] | null { + const out = new Set() + for (const raw of hosts) { + const host = raw.trim().toLowerCase().replace(/\.$/, '') + if (!HOST.test(host) || PRIVATE_SUFFIXES.some((suffix) => host.endsWith(suffix))) { + return null + } + out.add(host) + } + return [...out].sort() +} + +export function egressUserHosts(row: Pick): string[] { + return (row.egress_user_hosts ?? '').split(',').filter(Boolean) +} + +/** Records the hosts and, for a running sandbox, rewrites its policy now. */ +export async function setEgressUserHosts(deps: LifecycleDeps, row: SandboxRow, hosts: string[]): Promise { + const value = hosts.join(',') || null + if (value === row.egress_user_hosts) { + return row + } + const next = await transition(deps.db, row, { egress_user_hosts: value }, deps.now()) + if (!next) { + return null + } + if (next.status === 'running' && next.session_id) { + // A policy write replaces the whole policy, so the git route is re-minted alongside. + const git = await refreshGitCredentials(deps, next, next.session_id) + if (!git) { + await deps.provider.updateNetworkPolicy(next.session_id, egressPolicy(deps, next)) + } + } + // Counts only; hostnames are not secrets but stay out of logs anyway (they can name private tools). + console.log(JSON.stringify({ event: 'egress_user_hosts_set', sandboxId: row.id, count: hosts.length })) + return next +} diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index 91dcaf9e9a6..165de0ad033 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -85,6 +85,7 @@ import { reconcile } from './reconcile' import { egressSecretHosts, secretPlaceholder } from './secret-egress' import { SecretError, handleSecretsRoute, keyringFor, type SecretChange } from './secrets' import { refreshAfterSecretChange, refreshSandboxEnv } from './env-refresh' +import { EgressHostsSchema, egressUserHosts, normalizeUserHosts, setEgressUserHosts } from './egress-hosts' import { cachedProviderEnv } from './session-env' import { credentialTargets } from './provider-env' import type { Keyring } from './secrets-crypto' @@ -972,7 +973,7 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {}) return await workspaceRoute(request, url, env, deps, caller) } const match = - /^\/v1\/sandboxes(?:\/([A-Za-z0-9_]+)(?:\/(suspend|resume|lease|rollover|wake|restart|status|archive|unarchive|env\/refresh|ssh-keys|desktop))?)?$/.exec( + /^\/v1\/sandboxes(?:\/([A-Za-z0-9_]+)(?:\/(suspend|resume|lease|rollover|wake|restart|status|archive|unarchive|env\/refresh|egress\/hosts|ssh-keys|desktop))?)?$/.exec( url.pathname ) if (!match) { @@ -1073,6 +1074,20 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {}) } case 'POST env/refresh': return json({ applied: await refreshSandboxEnv(life, row) }) + case 'GET egress/hosts': + return json({ hosts: egressUserHosts(row) }) + case 'PUT egress/hosts': { + const body = EgressHostsSchema.safeParse(await readJson(request)) + const hosts = body.success ? normalizeUserHosts(body.data.hosts) : null + if (!hosts) { + return fail('invalid_request', 400, 'invalid_host') + } + const next = await setEgressUserHosts(life, row, hosts) + if (!next) { + return fail('conflict', 409, 'concurrent_transition') + } + return json({ hosts: egressUserHosts(next) }) + } case 'POST desktop': { // Runs only the image's fixed entry point; the desktop listens on the VM's loopback and is // reached through the paired runtime's port forward, never a public route. diff --git a/cloud/apps/sandbox-control/src/lifecycle.ts b/cloud/apps/sandbox-control/src/lifecycle.ts index 2749dbe6618..33821170262 100644 --- a/cloud/apps/sandbox-control/src/lifecycle.ts +++ b/cloud/apps/sandbox-control/src/lifecycle.ts @@ -300,7 +300,7 @@ async function boot( // Nothing in the VM uses git or model keys before orcad answers and the desktop attaches. const needsPolicy = !options.policyApplied && - (git || routed.egress_secret_hosts || row.egress_secret_hosts || routed.egress_allow_hosts || row.egress_allow_hosts) + (git || routed.egress_secret_hosts || row.egress_secret_hosts || routed.egress_allow_hosts || row.egress_allow_hosts || row.egress_user_hosts) // Members revoked while the VM slept must be gone before orcad accepts its first sign-in. await applyQueuedRevokes(deps.db, deps.provider, row.id, state.session.id, deps.now()) const [, connection] = await Promise.all([ diff --git a/cloud/apps/sandbox-control/src/session-env.ts b/cloud/apps/sandbox-control/src/session-env.ts index f704b4941b4..e039f8cd2b1 100644 --- a/cloud/apps/sandbox-control/src/session-env.ts +++ b/cloud/apps/sandbox-control/src/session-env.ts @@ -84,6 +84,7 @@ export async function sessionSecretEnv( return { env: swapped.env, egressSecretHosts: encodeEgressSecretHosts(swapped.targets), egressAllowHosts } } -export function egressAllowHosts(row: Pick): string[] { - return (row.egress_allow_hosts ?? '').split(',').filter(Boolean) +/** Provider hosts from the session's secrets plus the workspace's declared tool hosts (MCP). */ +export function egressAllowHosts(row: Pick & Partial>): string[] { + return [...(row.egress_allow_hosts ?? '').split(','), ...(row.egress_user_hosts ?? '').split(',')].filter(Boolean) } diff --git a/cloud/apps/sandbox-control/src/store.ts b/cloud/apps/sandbox-control/src/store.ts index e95ea8997d2..99d08aeda19 100644 --- a/cloud/apps/sandbox-control/src/store.ts +++ b/cloud/apps/sandbox-control/src/store.ts @@ -40,6 +40,8 @@ export type SandboxRow = { egress_secret_hosts: string | null /** Comma list of custom model-provider hosts added to this session's egress allowlist. */ egress_allow_hosts: string | null + /** Comma list of hosts the workspace's own tools need (synced remote MCP servers); set by the desktop. */ + egress_user_hosts: string | null retention_warned_at: number | null /** Environment build this sandbox first booted from (null: the golden image). Never changes. */ environment_id: string | null @@ -178,7 +180,7 @@ type Patch = Partial([ 'status', 'session_id', 'session_started_at', 'session_deadline_at', 'lease_expires_at', 'usage_settled_at', 'last_error', 'destroyed_at', 'github_token_expires_at', - 'egress_secret_hosts', 'egress_allow_hosts', 'repo_url', 'retention_warned_at', 'orcad_endpoint', 'ready_at', + 'egress_secret_hosts', 'egress_allow_hosts', 'egress_user_hosts', 'repo_url', 'retention_warned_at', 'orcad_endpoint', 'ready_at', 'archived_at', 'archive_expires_at' ]) diff --git a/cloud/apps/sandbox-control/test/agent-providers.test.ts b/cloud/apps/sandbox-control/test/agent-providers.test.ts index 32ef3849816..f564e04c6b1 100644 --- a/cloud/apps/sandbox-control/test/agent-providers.test.ts +++ b/cloud/apps/sandbox-control/test/agent-providers.test.ts @@ -327,3 +327,27 @@ describe('staged egress placeholder rollout', () => { expect(row!.egress_secret_hosts).toBe('ANTHROPIC_AUTH_TOKEN@llm.example.com') }) }) + +describe('workspace tool hosts (synced remote MCP servers)', () => { + it('opens declared hosts on the running sandbox now and on every later boot; refuses wildcards and private names', async () => { + const { h, as } = await setup() + const mia = await as('mia') + const created = await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: mia }) + const id: string = created.body.sandbox.id + const box = () => [...h.vercel.boxes.values()][0]! + expect(box().policy.allow['mcp.linear.app']).toBeUndefined() + const put = await h.call('PUT', `/v1/sandboxes/${id}/egress/hosts`, { bearer: mia, body: { hosts: ['MCP.linear.app', 'mcp.notion.com.', 'mcp.linear.app'] } }) + expect(put.body).toEqual({ hosts: ['mcp.linear.app', 'mcp.notion.com'] }) + expect(box().policy.allow['mcp.linear.app']).toEqual([]) + expect(box().policy.allow['*']).toBeUndefined() + for (const bad of ['*', '*.example.com', '10.0.0.1', 'localhost', 'box.internal', 'http://x.example.com']) { + expect((await h.call('PUT', `/v1/sandboxes/${id}/egress/hosts`, { bearer: mia, body: { hosts: [bad] } })).status).toBe(400) + } + expect((await h.call('PUT', `/v1/sandboxes/${id}/egress/hosts`, { bearer: await as('bob'), body: { hosts: ['x.example.com'] } })).status).toBe(404) + // Sleep and wake: the hosts come back with the new session's policy. + await h.call('POST', `/v1/sandboxes/${id}/suspend`, { bearer: mia }) + await h.call('POST', `/v1/sandboxes/${id}/resume`, { bearer: mia }) + expect(box().policy.allow['mcp.notion.com']).toEqual([]) + expect((await h.call('GET', `/v1/sandboxes/${id}/egress/hosts`, { bearer: mia })).body).toEqual({ hosts: ['mcp.linear.app', 'mcp.notion.com'] }) + }) +})