feat(sandbox-control): per-workspace tool egress hosts for synced remote MCP servers (PUT/GET /v1/sandboxes/:id/egress/hosts, migration 0032)

This commit is contained in:
Neil
2026-11-09 07:46:04 -08:00
parent 2fd06245e7
commit 6c2a37c83b
7 changed files with 106 additions and 5 deletions
@@ -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;
@@ -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<string>()
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<SandboxRow, 'egress_user_hosts'>): 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<SandboxRow | null> {
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
}
+16 -1
View File
@@ -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.
+1 -1
View File
@@ -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([
@@ -84,6 +84,7 @@ export async function sessionSecretEnv(
return { env: swapped.env, egressSecretHosts: encodeEgressSecretHosts(swapped.targets), egressAllowHosts }
}
export function egressAllowHosts(row: Pick<SandboxRow, 'egress_allow_hosts'>): 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<SandboxRow, 'egress_allow_hosts'> & Partial<Pick<SandboxRow, 'egress_user_hosts'>>): string[] {
return [...(row.egress_allow_hosts ?? '').split(','), ...(row.egress_user_hosts ?? '').split(',')].filter(Boolean)
}
+3 -1
View File
@@ -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<Omit<SandboxRow, 'id' | 'account_id' | 'generation' | 'crea
const PATCHABLE = new Set<string>([
'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'
])
@@ -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'] })
})
})