Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1da4285
Security review round 1: token leak, edge scoping, CSRF, signing secr…
robdmac Oct 12, 2026
5bc7b00
Security review step B: dashboard-scoped sandbox credentials for /int…
robdmac Oct 12, 2026
8fb9582
Security review step C: per-machine signed control plane → sandbox re…
robdmac Oct 12, 2026
3ac5681
Desktop: remove baked VM tokens (per-boot signing key, no INTERNAL_AP…
robdmac Oct 12, 2026
bd792cf
vz-helper: write all bytes to vsock (fix >8KB transfers being truncated)
robdmac Oct 12, 2026
69e4705
Security review round 2: OAuth state binding, policy arg bypasses, ro…
robdmac Oct 12, 2026
3087050
Fix round 2 regressions: desktop OAuth host mismatch, GitHub filenames
robdmac Oct 12, 2026
816235a
Security review round 3: unprivileged browser stack, owner-only dashb…
robdmac Oct 12, 2026
74bc00b
Security review round 3 follow-up: verified browser ports, trusted pr…
robdmac Oct 13, 2026
60b8de3
Fix round 3 review findings: Drive/Calendar arg checks, full approval…
robdmac Oct 13, 2026
729f47f
Fix round 3 validation findings: guest clock sync, browser profile/sc…
robdmac Oct 13, 2026
6e520e9
Per-dashboard browser profiles; token cache prefers newest sandbox ge…
robdmac Oct 13, 2026
c0854ae
Token cache follows the session that won provisioning
robdmac Oct 13, 2026
c32c099
Desktop: deliver EGRESS_PROXY_ENABLED to the VM on the kernel command…
robdmac Oct 15, 2026
fccd224
Sandbox: Chromium managed policy disables component updates and local…
robdmac Oct 15, 2026
a72b160
Terminal: drop the "Connected to sandbox" banner
robdmac Oct 15, 2026
e69d0ad
Edges to newly created blocks use real item IDs
robdmac Oct 15, 2026
25fb084
e2e: run against the desktop app; tolerate a wrapped shell prompt
robdmac Oct 15, 2026
50c5751
Fix round 3 review: loopback guard, fail-closed egress, browser stack…
robdmac Oct 15, 2026
1d98005
Fix round 3 review: Discord guild binding, no user credentials to VMs…
robdmac Oct 15, 2026
7708cf4
Fix round 3 review: desktop CSPRNG fail-closed, non-blocking vz bridg…
robdmac Oct 15, 2026
c2910fb
Fix Codex review: legacy sandbox secret revocation, delayed refresh r…
robdmac Oct 15, 2026
206c068
Sandbox: E79 error codes, copyright headers, identifier renames
robdmac Oct 15, 2026
7259771
Control plane: E79 error codes, copyright headers, identifier renames
robdmac Oct 15, 2026
857ef86
Desktop, frontend: copyright headers, vz-helper identifier renames
robdmac Oct 15, 2026
b285b09
Sandbox: stop browser processes with SIGTERM so x11vnc frees shared m…
robdmac Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ npx wrangler dev
### Auth Flow
- Frontend never talks directly to sandbox; all traffic goes through the control plane
- Cloudflare control plane uses dev auth via headers/query params when `DEV_AUTH_ENABLED=true`
- Internal sandbox auth uses `SANDBOX_INTERNAL_TOKEN` (do not reuse Cloudflare API keys)
- Control plane → sandbox requests are **signed per machine** (Ed25519, `SANDBOX_AUTH_SIGNING_KEY` in the control plane; VMs get only `SANDBOX_AUTH_PUBLIC_KEY`). The legacy static `SANDBOX_INTERNAL_TOKEN` is accepted only while configured — do not reuse Cloudflare API keys. Desktop generates the key pair per boot and passes the public key on the VM's kernel command line; its VM holds no static token and no `INTERNAL_API_TOKEN`
- **Personal access tokens (PATs)** authenticate the `orcabot` CLI / scripts against a remote control plane: prefix `orca_pat_`, sent as `Authorization: Bearer`, only the SHA-256 hash is stored (`api_tokens` table). Middleware is prefix-gated (no collision with PTY JWT bearers) and **method-gated** — a PAT cannot mint/list/revoke other PATs (`rejectPatAuth`). See `controlplane/CLAUDE.md`.

### Secrets Protection (Security-Critical)
Expand All @@ -108,6 +108,12 @@ Security invariants (non-negotiable):
- Broker URLs include session ID: `/broker/{sessionID}/{provider}/...`
- Approved domains keyed by `sessionID:secretName` — never just secret name
- Empty `INTERNAL_API_TOKEN` = all PTY tokens rejected (fail-closed)
- PTY + dashboard tokens are signed with `PTY_TOKEN_SECRET`, a control-plane-only secret — never `INTERNAL_API_TOKEN`, which every sandbox VM holds (root can read it from `/proc`). Without `PTY_TOKEN_SECRET` set the control plane falls back to `INTERNAL_API_TOKEN` and logs a SECURITY WARNING
- Sandbox → control plane `/internal/*` calls authenticate with the VM's **dashboard-scoped token** (`X-Dashboard-Token`, `sandbox/internal/cpauth`), and every VM-reachable route checks the resource belongs to that dashboard (`controlplane/src/auth/sandbox-auth.ts`). `X-Internal-Token` is full trust for control-plane-side callers only; with `WITHHOLD_INTERNAL_TOKEN_FROM_SANDBOX=true` new VMs never receive `INTERNAL_API_TOKEN`. New VM-called routes must use `authorizeSandboxForDashboard`, never `requireInternalAuth`
- Every control plane → sandbox request goes through `sandboxFetch` / `sandboxHeaders` / `SandboxClient` so it carries `X-Sandbox-Auth`: an Ed25519 signature over target machine (`FLY_MACHINE_ID`), method and path+query, valid 30s plus the VM's 30s skew allowance (60s replay window at most), plus `fly-force-instance-id` so no other VM is the first hop. Never call a sandbox with a hand-built `fetch`. A bad signature never falls back to the static token
- The dashboard browser runs unprivileged, never root: Chromium as `orcabot-browser`, Xvfb/x11vnc/websockify as `orcabot-vnc`, on loopback ports 6100-6299 that pool UIDs **and `orcabot-browser`** (page JS) are blocked from, so one dashboard's page can't reach another's VNC/DevTools; the X display requires its auth cookie. See `sandbox/CLAUDE.md` (Browser Block)
- A dashboard VM only ever receives its **owner's** secrets (`dashboardSecretsOwner`, `controlplane/src/secrets/handler.ts`): one VM is shared by every member's terminals, so pushing whoever acted last would put their keys behind every terminal's broker URL
- Pool UIDs (2000-2099) are egress default-deny at the kernel: only loopback (incl. the 80/443 REDIRECT), established replies, DNS to resolv.conf nameservers, and `EGRESS_EXTRA_TCP_PORTS` (opt-in, e.g. `22`) are allowed
- Localhost MCP/event endpoints require valid `X-MCP-Secret` (no fail-open)
- Egress proxy fail-closed: 60s timeout = deny; proxy crash = connections fail
- Localhost always bypasses egress proxy (NO_PROXY + server-side check)
Expand Down
13 changes: 11 additions & 2 deletions controlplane/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,10 @@ Fly.io (execution plane – separate repo)
- Domain approval workflow for custom secrets
- **Integration policy enforcement** (gateway execute, rate limits, audit)
- **OAuth token lifecycle** (connect, refresh, encrypt at rest)
- **PTY token issuance** (HMAC-SHA256 JWT for terminal-level auth — **fail-closed**: empty `INTERNAL_API_TOKEN` rejects all tokens)
- **Control plane → sandbox auth** (`src/sandbox/request-signing.ts`): `sandboxFetch`, `sandboxHeaders(env, headers, machineId, method, url)` (async) and `SandboxClient` (3rd ctor arg `env.SANDBOX_AUTH_SIGNING_KEY`) sign each request for its target machine. `sandboxHeaders` strips client credentials (Cookie, Authorization, X-Dashboard-Token, Cf-Access-Jwt-Assertion, dev-auth `X-User-*` / `X-Orcabot-Surface`) from proxied browser headers — the VM is shared by every member and must never see them. New VMs get `sandboxAuthEnv(env)`: the derived public key, plus `SANDBOX_INTERNAL_TOKEN` only while that secret exists. Generate the key with `openssl genpkey -algorithm ed25519 -outform DER | base64`
- **Sandbox `/internal/*` auth** (`src/auth/sandbox-auth.ts`): routes the sandbox calls accept `X-Internal-Token` (full trust) or the VM's dashboard token (`X-Dashboard-Token`) and scope-check the resource's dashboard via `authorizeSandboxForDashboard` / `requireSandboxDashboard` (sessions resolve through `dashboardIdForSandboxSession`). The sandbox refreshes its 7-day token at `POST /internal/sandbox/dashboard-token/refresh` (30-day grace for VMs that were suspended), only while the token's `sbx_gen` matches the dashboard's current `dashboard_sandboxes` row (its `created_at`): retiring or replacing the sandbox ends refresh for every token minted for the old one. Mint VM tokens with `sandboxGeneration(now)` and claim the row with that same `now`. `WITHHOLD_INTERNAL_TOKEN_FROM_SANDBOX=true` stops new VMs receiving `INTERNAL_API_TOKEN` (`sandboxInternalTokenEnv`)
- **PTY token issuance** (HMAC-SHA256 JWT for terminal-level auth, signed with the control-plane-only `PTY_TOKEN_SECRET` via `tokenSigningSecret(env)` — never with `INTERNAL_API_TOKEN`, which sandboxes hold — **fail-closed**: an empty secret rejects all tokens)
- **`PTY_TOKEN_SECRET` rollout**: set the secret *before or together with* the control-plane deploy that relies on it (`wrangler secret put PTY_TOKEN_SECRET -c wrangler.production.toml`, then deploy). Once it is set, tokens signed with the old `INTERNAL_API_TOKEN` fallback are rejected — by design, since VMs can read that key — so live VMs' dashboard tokens stop working (refresh → E79395, internal calls → E79393). The sandbox drops a refused token, and the control plane sends a fresh one (bound to the current `dashboard_sandboxes` generation, `mintCurrentSandboxDashboardToken`) as `mcp_token` with every new PTY, so the next terminal opened on that dashboard restores it; running terminals' MCP/integration calls fail until then. Rotating the secret later has the same effect. Never re-add an `INTERNAL_API_TOKEN` verification fallback
- **Personal access token (PAT) issuance** (CLI / scripted auth — only the SHA-256 hash is stored; **method-gated** so a PAT cannot mint/list/revoke other PATs)
- **File proxy** (read/write/import a session workspace, proxied to the sandbox; write + import are owner-only)
- **Response filtering** (policy-based filtering before LLM sees data)
Expand Down Expand Up @@ -208,7 +211,7 @@ curl "http://localhost:8787/internal/sessions/{sessionId}/approved-domains" \
```

### Auto-Apply Mechanism
When secrets change, `autoApplySecretsToSessions()` pushes updates to all active sandbox sessions for that dashboard via the sandbox's `updateEnv()` endpoint.
When secrets change, `autoApplySecretsToSessions()` pushes updates to the dashboard's **current** sandbox session (`dashboard_sandboxes`, via `pushOwnerSecretsToCurrentSandbox` — never a `sessions` row, which can point at a session a reprovision replaced) through the sandbox's `updateEnv()` endpoint. Secret names must be env var names (`^[A-Z_][A-Z0-9_]*$`, E79732): the sandbox rejects a whole update over one bad name, so stored names that don't match are skipped. Only the dashboard owner's secrets are applied. Before starting a terminal, `applyDashboardOwnerSecrets()` reconciles previously applied names, unsetting legacy collaborator secrets (and their `_BROKER` variables), even if the owner has no secrets. Cleanup must succeed before terminal startup and before replacing the tracked names (a stale/unreachable sandbox instead takes the normal reprovision path; the fresh VM's pre-apply is fail-closed too). Loading the owner's own secrets is best-effort: if it fails (e.g. no `SECRETS_ENCRYPTION_KEY`), every tracked name is unset and the terminal starts without them. Existing processes retain their inherited environment until restarted.

### Encryption
- Secrets encrypted at rest using configured encryption key
Expand Down Expand Up @@ -265,6 +268,9 @@ Sandbox MCP Server
- `src/integration-policies/api-clients/` — Gmail, GitHub, Drive, Calendar, Twitter API wrappers
- `src/auth/pty-token.ts` — HMAC-SHA256 JWT token creation/verification

### OAuth connect/callback
- `state` is bound to the browser that started the flow: connect sets an HttpOnly `orcabot_oauth_<state>` cookie (`SameSite=Lax`, `Path=/integrations`) whose SHA-256 is stored with the state; the callback requires it, or a session for the user that started the flow (`getState` in `src/integrations/handler.ts`). New connect handlers must return `oauthRedirect(...)`, and the callback host must be the connect host (`OAUTH_REDIRECT_BASE`)

### Database Tables
- `user_integrations` — OAuth tokens (encrypted), provider, account email
- `terminal_integrations` — Which integrations are attached to which terminals
Expand All @@ -277,6 +283,9 @@ Sandbox MCP Server
4. **Fail-closed** — Missing policy, expired token, unknown action → deny
5. **Audit before response** — Every request logged via `logAuditEntry()` before returning
6. **Context derived server-side** — Recipient, domain, etc. extracted from args in control plane
7. **Check what is sent** — `validateGatewayArgs` (`src/integration-policies/arg-safety.ts`) runs before `enforcePolicy`: IDs that clients put in URL paths must be single safe segments (no `/`, `..`, `?`, `#`, `%`), email headers can't contain CR/LF, and each recipient entry is exactly one address. Messaging targets are derived only from the field each client sends to (`MESSAGING_TARGET_FIELD`); channel names are resolved server-side, never taken from args. Routing values (WhatsApp `phone_number_id`, Matrix `homeserver`) come from the stored integration. Empty path IDs (`folderId: ""`) are dropped as omitted (`dropEmptyPathArgs`)
8. **Policy belongs to the account holder** — only the member who attached an integration can change its policy; they or the dashboard owner can detach it
9. **Discord is pinned to the stored guild** — every integration uses the shared platform bot, which is in every customer's guild. The gateway forces `guild_id` to the integration's stored guild (a different one → E79625, none stored → E79624), looks up every channel-taking action's channel (`GET /channels/{id}`) and denies DMs / other guilds (E79628) or failed lookups (E79627); actions outside `DISCORD_*_ACTIONS` are denied (E79629). The connect callback binds a guild only with the `bot` scope, from the token response, or from the `guild_id` param only if the user owns it or has MANAGE_GUILD / ADMINISTRATOR there

### Providers
- **Gmail**: search, get, send, archive, trash, mark_read/unread, add/remove_label
Expand Down
8 changes: 4 additions & 4 deletions controlplane/src/asr/ASRStreamProxy.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Copyright 2026 Rob Macrae. All rights reserved.
// SPDX-License-Identifier: LicenseRef-Proprietary
// REVISION: asr-stream-proxy-v2-auth-fix
// REVISION: asr-stream-proxy-v3-error-codes

const proxyRevision = "asr-stream-proxy-v2-auth-fix";
const proxyRevision = "asr-stream-proxy-v3-error-codes";
console.log(`[ASRStreamProxy] REVISION: ${proxyRevision} loaded at ${new Date().toISOString()}`);

/**
Expand All @@ -20,12 +20,12 @@ export class ASRStreamProxy implements DurableObject {

async fetch(request: Request): Promise<Response> {
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
return new Response('Expected WebSocket', { status: 426 });
return new Response('E79176: Expected WebSocket', { status: 426 });
}

const apiKey = request.headers.get('X-ASR-Api-Key');
if (!apiKey) {
return new Response('Missing API key', { status: 401 });
return new Response('E79177: Missing API key', { status: 401 });
}

// Create the client-facing WebSocket pair
Expand Down
Loading
Loading