# Isolation and security model

fuast runs code that agents write, for many teams, on shared machines. The model assumes any capsule may be hostile and keeps it away from the platform, other apps, and other workspaces.

## The rule

No capsule code ever runs in the platform process. The platform never imports, evaluates, or string-splices capsule code or capsule arguments. This holds for top-level bundle code too, not only for handlers, and for code that would run at build time: the platform never bundles uploaded sources itself.

## Two processes

| Process | Runs | Holds |
| --- | --- | --- |
| Platform (Bun) | Accounts, workspaces, sharing, deploys, the app gateway, WebSockets, storage routes, email, schedules | Platform database, file storage credentials, email credentials |
| Runtime (Node 24 + isolated-vm) | Capsule server code inside V8 isolates, each app's SQLite database | App databases. No platform secrets. |

They talk over an internal HTTP API protected by a shared secret. In production the runtime refuses to start with the default secret. The runtime binds to `127.0.0.1` by default and never echoes the secret in its responses.

## Deploy path

1. The CLI uploads source files, not built code. The platform checks every file name before anything touches disk: no `..`, absolute paths, drive letters, backslashes, NUL or control characters, dot-directories, case-insensitive duplicates, or `.env*`/`bunfig.toml`-style files. Only `.ts .tsx .js .jsx .mjs .json .md .svg .txt .css .html` and `.gitignore` are accepted, at most 1 MiB per file, 1000 files and 20 MiB per deploy.
   `package.json`, `package-lock.json` and `jsconfig.json` are dropped from every upload: their `browser`, `imports` and `exports` fields are resolver configuration, and capsules need none.
2. Uploads that import with `with { type: "macro" }` or `assert { type: "macro" }` (any quoting, escapes, or comments) are refused with `422 macro_import`. Bun macros run code at bundle time. Every other import attribute except `type: "json"` (`text`, `file`, `base64`, `dataurl`, `binary`, `sqlite`, `toml`, `napi`, …) is refused with `422 import_attribute`: those loaders inline raw bytes.
3. The bundle is built and type checked with `tsc-rs` in a short-lived `bun` child process (`apps/platform/src/deploy/build-worker.ts`), never in the platform. The child gets a fresh temp directory holding only the uploaded files, an environment with only `PATH` and a private `HOME`/`TMPDIR` (no platform secrets), macros forced off (`Bun.build({ macros: false })`), a hard timeout (`FUAST_BUILD_TIMEOUT_MS`, default 120 s) that kills its whole process group, capped output, and a limit on concurrent builds (`FUAST_BUILD_CONCURRENCY`, default 2). The platform validates the shape and size of what comes back. Type errors block the deploy unless the owner passes `--no-typecheck`.
4. The build reads only what the capsule is allowed to import. The contract plugin resolves every capsule import itself (extensions, `index` files) and hands Bun the realpath, so no `package.json` mapping, `tsconfig` `paths` or `extends`, or other resolver feature applies. Containment is checked on the realpath, so a symlink cannot point outside the capsule. An `onLoad` guard refuses every module whose realpath is outside the capsule, the generated entry, the fuast runtime sources and the Preact package, reads the bytes itself, and picks the loader from the file extension (`.ts .tsx .js .jsx .mjs .json` only), so no import attribute can change how a file is read. `tsc-rs` runs with `noResolve` on a generated file list, so a type-only import of a file outside the capsule is never read.
5. In the Docker image the build child also runs as a separate unprivileged user. `FUAST_BUILD_USER=fuastbuild` (set by the Dockerfile) makes the platform, which runs as root, start the worker through `setpriv --reuid --regid --clear-groups --no-new-privs` (or spawn `uid`/`gid` when `setpriv` is missing) with only its temp directory chowned to it. `start.sh` makes `FUAST_DATA_DIR` mode `0700`, so app databases, deploy env files and `platform.sqlite` are unreadable to the build, and so is `/proc/<pid>/environ` of every other process. A configured but unusable build user fails builds closed. Without `FUAST_BUILD_USER` (macOS development, non-root installs) builds run as the platform user and steps 1 to 4 are the fence. Concurrent builds share the one build user. A mount namespace (bubblewrap) is not used, because Docker's default seccomp profile blocks the unprivileged user namespaces it needs.
6. The platform asks the runtime to describe the bundle. The runtime evaluates it inside an isolate and returns a manifest: schema, handler names and kinds, tools, endpoints, schedules, auth flags, name, and favicon. The platform only ever sees that JSON.
7. Schema changes are planned against stored rows and applied by the runtime.

## Inside an isolate

- One isolate per app deploy, cached (at most 64 at once, least recently used evicted). Each call gets a fresh context, so no state leaks between calls or users.
- No `process`, `require`, filesystem, sockets, Node built-ins, or platform memory. Only `ctx` and a guarded global `fetch`.
- Limits per call: 5 s CPU, 128 MB memory, 10 s wall clock.
- Values cross into the isolate as copies or function arguments. They are never pasted into source text, so arguments containing `$`-patterns, `__proto__`, or code cannot change what runs.
- Database operations are synchronous host calls into the runtime's SQLite. A whole handler runs inside one SQLite transaction: `BEGIN IMMEDIATE` for writers, with a per-app writer lock, and `BEGIN` for readers. A throw, timeout, or limit error rolls back.
- Asynchronous host calls (fetch, email, storage, team tables, workspace members) go through the platform bridge, which checks that the call belongs to the app and its workspace.

## Outbound fetch

`fetch` from a handler is SSRF-guarded: requests to loopback, private, link-local, and cloud metadata addresses are refused, including through redirects and DNS that resolves to them. The `onGuestUpgrade` hook cannot fetch.

## Data separation

- Each app has its own SQLite file owned by the runtime. Handlers can reach only their own app's tables.
- Team tables are scoped to the app's workspace by the platform, never by capsule input.
- Storage keys are scoped to the app. Quotas are per workspace.
- `ctx.workspace.members()` answers only for signed-in members of the app's workspace and for schedules.

## App origins

With `FUAST_APPS_DOMAIN` set, every app is served from its own origin, `https://<slug>.<apps domain>/`. The app origin gets its own session cookie through a one-time code redirect from the platform. Platform cookies are host-only and never reach app origins, so app JavaScript cannot call platform APIs as the viewer or read another app's session.

- The handshake is bound to the browser that started it. `/__fuast/auth/sign-in` on the app origin sets a short-lived state cookie, the state travels through the platform's authorize step and is stored with the one-time code, and the callback accepts the code only when the browser's state cookie matches. Codes are single use and expire after 60 seconds. A code minted for someone else's account cannot sign a victim in (login CSRF), so guest data is only ever moved into an account by the browser that signed in.
- On `https` app origins the app session, guest and state cookies use the `__Host-` prefix (Secure, `Path=/`, no `Domain`), so a sibling app cannot overwrite or plant them by setting a cookie for the whole apps domain. Plain `http://<slug>.localhost` keeps unprefixed names.
- Add the apps domain to the [Public Suffix List](https://publicsuffix.org/) (or use a registrable domain of its own, separate from the dashboard's), so browsers treat each `<slug>.<apps domain>` as its own site. Do not serve apps under a subdomain of the dashboard's domain.
- Production refuses to start without `FUAST_APPS_DOMAIN` (or with `FUAST_APPS_DOMAIN=off`). Path mode (`/a/<slug>/` on the platform origin) does not give this separation; enable it in production only with `FUAST_ALLOW_SAME_ORIGIN_APPS=1`, and only when every app on the platform is trusted. The platform prints a warning at start.
- When the web app proxies app subdomains to the platform (`x-fuast-base: root` plus `x-forwarded-host`), the platform believes those headers only if the request carries `x-fuast-proxy-secret: $FUAST_TRUSTED_PROXY_SECRET`. Without that variable they are accepted only from loopback peers. The secret header is stripped before endpoint handlers see the request.

## Identity

- Accounts verify their email before joining a domain workspace. Free-mail addresses (a list of several hundred providers in `apps/platform/src/data/free-email-domains.txt`, extendable with `FUAST_PERSONAL_DOMAINS`) always get a workspace of one.
- A member an admin removes is recorded. Verifying the address again does not put them back. An admin re-admits them with `DELETE /api/workspace/removed/<email>`.
- Google sign-in ID tokens are verified against Google's published keys, checking audience, issuer, and expiry. When a Google sign-in claims an account that was registered with a password but never verified, every session, app session and API token of that account is revoked and its password is replaced before linking, so whoever pre-registered the address loses access.
- Revoking a session or token, signing out, removing a member, and narrowing an app's sharing close the affected live WebSockets at once (close code 4403).
- Identity tokens (`getIdentity().token`) are bound to the credential they were minted from: the app session (app origins), the platform session, or the personal API token. They stop working the moment that credential ends (app sign-out, session revocation, token revocation), and a WebSocket opened with one closes with it.
- Signing out of an app is a `POST` with an origin check. A `GET` on the sign-out URL only shows a confirm button.
- Sign-in `returnTo` values must be a single-slash path with no whitespace, control characters or backslashes.
- App-scoped deploy tokens (`fuast token create --app`) can deploy, archive, restore and roll back their app and read its status (manifest, usage, logs, deploys). They are not an identity in the app and cannot change sharing, env, domains or data, or terminate the app.
- Guest sessions are HttpOnly per-app cookies issued and verified by the platform. Guest ids are opaque and are not credentials.
- `X-Fuast-Token` on endpoint requests is verified and stripped before the handler runs. `Authorization` passes through untouched for the app's own use.
- The CLI approval page is a POST with an origin check and an explicit confirm step.

## Storage hardening

- Uploads of executables, installers, scripts, HTML, and JavaScript are rejected with `415 blocked_type`, by declared type and by magic bytes.
- Every download carries `X-Content-Type-Options: nosniff` and a sandboxing `Content-Security-Policy`. HTML and JavaScript content is served as an `application/octet-stream` attachment.
- 25 MiB per file, 1 GiB per workspace. The quota is reserved atomically before the file is written, so concurrent uploads cannot overshoot it.

## Rate limits and quotas

- Per-app daily quotas on requests (100000), mutations (20000), and email (100), returning 429 with `fuast_quota_exceeded`. Email also has a per-workspace daily cap across all its apps (`FUAST_QUOTA_WORKSPACE_EMAIL`, default 300).
- Sharing invites: at most 50 new addresses per call (`FUAST_MAX_INVITES_PER_CALL`) and 200 invitation emails per workspace per day (`FUAST_QUOTA_INVITES`).
- Rate limits on `/api/call`, uploads, email, sign-in, sign-up, CLI login, deploys, and WebSocket upgrades (`FUAST_WS_UPGRADES_PER_MIN` per address, default 300).
- Live queries: identical subscriptions (same query, arguments and viewer) share one execution, re-runs after writes are coalesced to at most `FUAST_WS_RERUNS_PER_SEC` (20) per subscription, and re-runs count against a daily `reruns` bucket per app (`FUAST_QUOTA_RERUNS`, 1000000). At most `FUAST_WS_MAX_APP_SUBSCRIPTIONS` (5000) live queries per app and `FUAST_WS_MAX_PER_IP` (200) sockets per client address.
- Rate-limit keys use the socket address. Proxy headers are trusted only with `FUAST_TRUST_PROXY=1`.

## Inspection

`/__fuast/*` routes on hosted apps answer only to the app owner and workspace admins. `fuast dev` binds to `127.0.0.1` and leaves them open locally.

## Red-team checks

`scripts/ci-redteam.ts` deploys hostile capsules and must fail to leak on every CI run. It covers reading platform env and files, escaping the isolate from handlers and from top-level bundle code, prototype pollution and `$`-patterns through arguments, cross-app and cross-workspace data access, SSRF through `fetch`, the runtime echoing its secret, Bun macros at build time (six spellings, checked against both the upload pre-scan and the sandboxed build), hostile upload file names, and build-time host file reads (`package.json` `browser`/`imports`/`exports` mappings, loader import attributes such as `text` and `file`, `..` and absolute imports, a `tsconfig` `extends`, and type-only imports read by the typecheck), each checked against a canary file that must never appear in a bundle, error, or diagnostic.

`tests/security/` holds regression tests for the platform-level findings: macro uploads and file names, the app sign-in handshake, `__Host-` cookies, trusted proxy headers, Google sign-in over an unverified account (with a fake Google), removed members, socket revocation, app-token scope, invite and email caps, storage quota races, open redirects, sign-out, the production start checks, build-time file reads (`build-file-read.test.ts`), identity tokens bound to their credential (`identity-token-binding.test.ts`), and live-query re-run amplification (`realtime-amplification.test.ts`).

Report security issues to support@fuast.com with "security" in the subject.
