SysDeck/klanker-gate/permissions.md

226 lines
13 KiB
Markdown
Executable File

# 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=<deno>`)
`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).