# Deno Permission Policy The gateway runs least-privileged. The canonical flag set (used by the tasks, Dockerfile, and `deno compile`) is: ``` --unstable-net --unstable-worker-options --allow-net --allow-env --allow-read --allow-write=data ``` Each granted flag, and why: - **`--allow-net`**: Listening port, provider egress, MCP server calls, and the PostgreSQL connection. Tighten to a host allowlist (`--allow-net=api.openai.com,api.anthropic.com,…,0.0.0.0:8080,db-host:5432`) when the provider set is fixed. - **`--allow-env`**: Provider keys and `FROSTY_*` configuration. - **`--allow-read`**: `deno.jsonc` and the built UI under `apps/control-ui/dist`. - **`--allow-write=data`**: process-local scratch only — no other write access. Durable state lives in PostgreSQL, so this no longer covers a database file. - **`--unstable-net`**: `Deno.serve({ reusePort })`, which is how N worker processes share one port (decision-log 62). Read `FROSTY_WORKERS` to decide whether it is used at all; a single-process deployment never sets `reusePort` and the flag then grants nothing that is exercised. - **`--unstable-worker-options`**: **NARROWS, never grants.** Enables per-`Worker` permission descriptors so the Code Mode sandbox worker can be spawned with **every** permission denied (a strict subset of the parent). This flag hands the process **no new capability** — it is the mechanism by which permissions are taken **away** from a child worker, the opposite of `--allow-run`. Without it, `new Worker(..., { deno: { permissions } })` is rejected and the Code Mode capability probe fails **closed** (executor refuses). Note: a standalone `deno compile` binary must be built **with** `--unstable-worker-options` for the enabled Code Mode path to work; a default-off binary lacking it fails the probe closed (safe). ## Flag-set history The set changed once, in the PostgreSQL state consolidation (decision-log 62): **`--unstable-kv` was removed** and **`--unstable-net` added**. Net count is unchanged, and the process is strictly LESS capable than before - it can no longer open a Deno KV database at all. `--unstable-kv` survives in exactly one place: the `migrate:kv-pg` task, which runs the one-time KV-to-PostgreSQL migration. That task also carries `--allow-write=data`, which is unavoidable rather than intended - Deno KV opens its SQLite file read-write even for a pure read, so read-only intent cannot be expressed at the permission layer. The script enforces it instead, by only ever calling `kv.list()`. ## Runtime module resolution (not a permission) The container runtime image passes `--node-modules-dir=none` alongside the flags above, so the gateway resolves its npm deps (`zod`, `postgres`) from Deno's global module cache and bakes no `node_modules` into the image. It is a module-resolution flag, not a permission, and grants no capability. ## Opt-in: stdio MCP servers (`--allow-run`) stdio (subprocess) MCP servers are **disabled by default** (wave-4, decision D10). Enabling them takes two deliberate steps, both required: 1. `FROSTY_MCP_ALLOW_STDIO=1` — application-level gate; without it the registry rejects stdio configs with a 400 and skips any persisted ones at boot (with a warning). 2. `--allow-run` appended to the run flags — permission-level gate. Tighten to an executable allowlist (`--allow-run=/usr/bin/my-mcp`) when the tool set is fixed. Every HTTP transport works without either. The test task keeps `--allow-run` for the stdio test fixture only. ## Opt-in: multi-process serving (`--allow-run=`) `FROSTY_WORKERS>1` makes the entry process a supervisor that re-execs the Deno binary once per worker, which needs run permission. Two properties keep that from being a general escalation: - **Scoped, not blanket.** `deploy/docker-entrypoint.sh` grants `--allow-run=$(command -v deno)` and nothing else, and only when `FROSTY_WORKERS>1`. The default single-process container has no run permission at all. - **Supervisor only.** Children are spawned with the flag set in `apps/gateway/cluster.ts`, which excludes `--allow-run`. A worker therefore cannot spawn anything. The supervisor serves no traffic and holds no state, so the process that can spawn is not the process that touches request data. `planCluster()` checks the permission before fanning out and degrades to single-process with a stated reason when it is absent, so a missing capability is a log line rather than an uncaught `NotCapable` at the first spawn (decision-log 67). From source, multi-process needs the flag added by hand: `deno run --allow-run=$(which deno) ... apps/gateway/main.ts`. ## Opt-in: MCP Code Mode executor (experimental, DEFAULT-OFF) Code Mode has two surfaces with very different risk: - **VFS / codegen metadata (LIVE).** `GET /api/mcp/codemode/vfs` deterministically generates inert tool-stub source from the MCP catalog (the same metadata `GET /api/mcp/tools` already exposes) — a Python **display** preview plus a TypeScript SDK the model authors against. It performs **no execution and reads no secrets** — it is a pure string transform over the catalog. Live by default; set `FROSTY_CODE_MODE_VFS=off` to hide even this metadata. - **Sandboxed executor (BUILT, default-off).** Running LLM-authored code is a Tier-3 trust boundary (the model is treated as adversarial under prompt injection). The enabled path is now built — the program runs as TypeScript inside a **deny-all Deno Worker** (`new Function` async IIFE; no `import`), and its ONLY egress is the broker RPC on the main isolate — but it stays **off by default**, behind the same two-independent-gates doctrine as stdio (D10): 1. `FROSTY_CODE_MODE=on` — application gate (**default `off`**; the executor is refused otherwise). 2. A **real** boot worker-permission enforceability probe must pass — capability gate. At boot (only when the app gate is on) one deny-all probe worker attempts `Deno.env.get`, `Deno.readFile("data/frosty.kv")`, and `fetch("http://169.254.169.254/…")`; the executor enables **only** if the worker spawned with the descriptor **and** all three were denied. Any access that succeeds, a timeout, or an error ⇒ the executor is refused **even when `FROSTY_CODE_MODE=on`** (fail-closed, analogous to `--allow-run` for stdio). (Deno 2.9.x reports worker-scoped denials as `NotCapable`; the probe accepts that and `PermissionDenied` both.) **`--unstable-worker-options` is MANDATORY when `FROSTY_CODE_MODE=on`.** Per-worker permission scoping requires it. The top-level `"unstable": ["net", "worker-options"]` array in `deno.jsonc` now makes this flag **structurally present for every config-respecting launch** — `deno run`, `deno task dev`/`start`/`test`, and (baked into the binary) `deno compile` — so a normal app-gate-on launch can no longer omit it and abort at the probe's `new Worker`. The redundant `--unstable-*` CLI flags in the shipped tasks and the Docker CMD are kept as idempotent defense-in-depth. **Residual (still fail-_stopped_, never fail-open):** the boot abort remains reachable only by deliberately bypassing the project config — launching with `--no-config`, pointing `--config` at a file lacking the `worker-options` entry, or running a binary compiled against such a config. On current Deno there is no in-process way to detect the missing flag before the fatal `new Worker` (the abort is an uncatchable process exit), so the fix is structural (make the flag non-omittable via config), not a graceful in-process degrade. A default-off binary never runs the probe, so it starts normally regardless. **Reachability (both behind the two gates + probe):** - Admin route `POST /api/mcp/codemode/run` — state-changing `/api/*` behind the admin token + origin guard. - Inference opt-in `x-frosty-code-mode: run` on `/v1/chat/completions` and `/v1/responses` — advertises ONE gateway-owned meta-tool `frosty_code_mode_run({ program })` (non-streaming only). The header alone never enables anything; with the executor disabled the request is returned byte-unchanged (the meta-tool is simply not advertised — no mid-inference 403). A process-wide concurrent-run cap (`CODE_MODE_MAX_CONCURRENT_RUNS`, default 4) bounds worker spawns from this plane, backed by a **bounded wait queue** (`CODE_MODE_MAX_RUN_QUEUE`, default 8): once all run slots AND the queue are full a run is fast-rejected — **429 `code_mode_busy`** on the admin route, or a "retry shortly" **tool-error** fed back to the model on the inference plane (chat continues; never a mid-inference 429/403). Total outstanding runs are capped at 4 + 8 = 12, so held-open requests and waiter closures can no longer accumulate unbounded. When disabled, `POST /api/mcp/codemode/run` returns **HTTP 403 `code_mode_disabled`** — no worker spawned, no code parsed, no tool executed. The `x-frosty-confirm-side-effects` gate still governs every side-effecting tool a sandbox program calls: it is **request-sourced** (never from the model or a worker message) and re-checked per brokered call; an unconfirmed side-effect aborts the run as **403 `side_effect_denied`** on both planes. **Client-disconnect abort.** The HTTP request's own `AbortSignal` (`req.signal`) is threaded into the run harness, so a run is aborted the moment its client disconnects mid-flight, in each of the three run states: (1) **already aborted at entry** — the acquire rejects before any slot, worker, or queue entry; (2) **aborted while queued** — the waiter is spliced out of the wait queue and rejected **without** consuming a run slot (no semaphore leak); (3) **aborted while running** — the worker is `terminate()`d, the deadline timer cleared, and the run rejected via the same single-settle guard the timeout uses (whichever fires first wins; the other is a no-op). The abort path only terminates/rejects/cleans up — it never spawns without a slot, forges a tool call, bypasses the confirm gate, or leaks a secret; it strictly _reduces_ outstanding work, so a burst of disconnects frees slots and queue positions promptly instead of holding them to the 5s deadline. On the admin route an abort maps to **HTTP 499 `client_closed_request`** (distinct from the 403/429/ 504 cases, each guarded by a disjoint `instanceof`); on the inference plane the meta-tool run rejects and the tool loop unwinds normally (no 403/429). `signal` is optional — a caller that omits it simply never arms abort. The worker `net` permission stays **`false`** (no SSRF, no sandbox outbound); no subprocess and no new `--allow-run`; no Python/Pyodide execution (Python output remains a display artifact). There is **no per-worker memory cap** in Deno today — runs are bounded by the wall-clock timeout plus the call/concurrency/result/output caps and the global-run cap, not by memory. Flipping the **production default** to `on` remains a separate operator go-decision. ## Not granted - Unscoped `--allow-write`: writes are confined to `data/`. - `--allow-ffi`, `--allow-sys`: never needed. - The Code Mode sandbox worker receives **no** permissions (`env/read/write/net/run/ffi/sys/import=false`), proven enforceable by the boot probe. `net` stays `false` (no sandbox outbound); the executor remains **default-off** and gated on the app flag + probe. ## Build-time vs runtime The least-privilege policy above governs the **gateway runtime**. The control-UI build tasks (`deno task build-ui | dev-ui | preview-ui | test-ui`) run with `-A` because the Vite / Tailwind v4 toolchain needs broad build-time fs/env/run/ffi access (Tailwind's oxide binary loads through FFI). That is a throwaway build/dev surface: it never runs in the production image, which launches the gateway with only the canonical least-privilege flag set above. In Docker the UI is built in a separate, discarded builder stage before the gateway image is assembled. ## Application-level controls - Provider secrets never reach the browser: admin APIs return `hasApiKey` / `hasCloudCredentials` flags and token hints, and config export redacts keys unless `include_secrets=true` is requested explicitly. - Side-effect MCP tools require the `x-frosty-confirm-side-effects: true` header per request — on the inference tool loop AND on the gateway's own `/mcp` server surface; unknown tools fail closed. - stdio MCP servers only run commands an operator explicitly configured via the admin API (itself gateable with `FROSTY_ADMIN_TOKEN`). - `/api/*` can be gated with `FROSTY_ADMIN_TOKEN`; `/v1/*` and `/mcp` with virtual keys (plus team/customer hierarchy budgets).