Trust-boundary tracking for Grida. Every prevented vulnerability gets a stable id, the id appears in every file the boundary depends on, and this document is the central registry.
We use GRIDA-SEC-001, GRIDA-SEC-002, … as canonical ids for
security boundaries we have prevented. The format is deliberately
unlike CVE:
- A CVE describes a vulnerability that was discovered, often after exposure. The id implies "this was a problem."
- A GRIDA-SEC id describes a vulnerability that was structurally prevented from existing — and a contract with the codebase that it must stay prevented. The id is "this is a thing we keep safe."
Every GRIDA-SEC id has:
- An entry in this file with the threat model and the enforcement mechanism.
- A grep tag in every file bound by the contract — comments in source, callouts in READMEs, ingress filters in scripts.
- An auto-loaded skill (.agents/skills/security/SKILL.md) that triggers when an agent encounters the tag.
The grep is the index.
grep -r GRIDA-SEC-001 .returns every file in that contract.grep -r GRIDA-SEC .returns every security boundary in the repo.
Grida is open source. The threat model is public; the URLs an attacker might find are public; the fact that webhooks exist is public. Security in this repo is therefore structural, not secret. We make every boundary loud, named, and grep-able so that future work doesn't drift into opening new attack surface by accident.
A developer touching tagged code can't miss the marker; a code review of any tagged file naturally surfaces the others; an agent picks up the security skill the moment it sees "GRIDA-SEC" anywhere in context.
If you're adding a new boundary, allocate the next sequential id, add an entry below, and tag the relevant files. Don't reuse ids; don't renumber.
What it protects. Webhook receivers — endpoints invoked by external
machines on a publicly-reachable URL — are the only HTTP surface in
this app intentionally exposed to the public internet without
cookie-based authentication. Authority is established via the
provider's signed payload. The boundary is the rule that everything
reachable on /webhooks/* must verify a provider signature before
doing anything else. This applies to every current provider (Stripe,
Metronome, …) and every future one (Replicate, GitHub, etc.).
Vulnerable scenario (prevented). A developer adds an unsigned endpoint under the same path prefix — or removes the signature check from an existing receiver — and that path becomes reachable from the public internet (directly in production, via dev tunnel locally) with no authentication. An attacker who finds the URL triggers whatever logic lives there. State-changing endpoints (entitlement flips, record mutations, tenant-scoped queries) become open APIs.
Why it's specifically risky here. Webhook URLs in an open-source
repo eventually leak — into docs, scripts, screenshots, dashboards
that get linked, examples in PRs. Local dev typically uses a tunnel
(cloudflared, ngrok, etc.) to expose the dev server so external
providers can deliver webhooks; a naïvely-configured tunnel forwards
every path on the local server. If the tunnel URL becomes public —
and on an open-source project it does — every route including
/insiders/* becomes reachable on whatever box is currently tunneled.
The boundary contains the blast radius even when the URL is treated
as public.
How the code prevents it.
- Dedicated route group —
editor/app/(ingest)/. Every webhook receiver lives here. Nothing else does. The route group's README is the authoritative ruleset. - Path-based proxy bypass — editor/proxy.ts
short-circuits
/webhooks/*before tenant routing or session refresh runs. This makes the receivers reachable on arbitrary hosts (dev tunnels, future direct routes); it also makes the trust boundary path-aligned with the file system. - HMAC verification at the receiver — every receiver verifies a provider signature before any business logic. Fails closed (5xx) when the signing secret is missing in production.
- Replay protection — receivers dedup on event id and reject events older than 5 minutes (where applicable).
- Tunnel path filter at the edge —
editor/scripts/billing/tunnel.sh
configures cloudflared to forward only
/webhooks/*and reject everything else with 404. Defense-in-depth at the network layer: even if app code drifts, the tunnel cannot expose non-webhook paths.
Files bound by this id. Run grep -rn GRIDA-SEC-001 . to enumerate.
Today:
- editor/app/(ingest)/README.md — rules.
- editor/app/(ingest)/webhooks/stripe/route.ts — Stripe receiver.
- editor/app/(ingest)/webhooks/metronome/route.ts — Metronome receiver.
- editor/proxy.ts — path bypass.
- editor/scripts/billing/tunnel.sh — tunnel ingress filter.
- editor/scripts/billing/README.md — dev docs.
What does NOT belong under (ingest)/. Admin tools, internal RPC,
anything that authenticates via cookie/session/bearer-token — those go
under (api)/private/**. Anything user-facing goes under
(api)/(public)/v1/**. Mixing categories breaks the trust contract.
What it protects. The (insiders) route group hosts a developer
harness — pages and server actions used to drive Metronome/Stripe
lifecycle steps manually during development and QA. The actions there
intentionally omit org-membership / ownership checks and accept an
attacker-supplied organizationId as the first argument. That shape is
fine for a local-only debug surface; it would be a cross-org
compromise vector in any non-local environment. The boundary is the
rule that /insiders/* is reachable if and only if
NODE_ENV === "development".
Vulnerable scenario (prevented). A developer ships the
(insiders) route group as part of the production bundle without
gating it. Server actions like actionAddStripeChargedCommit(orgId, amountCents), actionIngest(orgId, costMills), and
actionGetInvoicePdf(orgId, invoiceId) become reachable on the public
internet. An attacker enumerates organization_id (sequential bigint),
then calls these actions to charge any org's saved Stripe card, zero
out any org's AI-credit balance via the optimistic-debit RPC (which
also flips customer_entitled = false), or read any org's billing
state and invoice PDFs.
Why it's specifically risky here. Next.js server actions are
HTTP RPC endpoints addressable from any browser via the
Next-Action header — the action hash is shipped in the client
bundle of any page that imports it. They are not protected by
"the page UI isn't linked anywhere"; whatever URL group the action
lives under is the only structural gate. An open-source repo means
the action source is public, so the hashes are too. Without a
proxy-level gate, a single accidentally-deployed harness action is a
production cross-org vulnerability.
How the code prevents it.
- Proxy-level gate — editor/proxy.ts returns
404 for
/insidersand/insiders/*wheneverNODE_ENV !== "development". The proxy runs before any handler, so this also stopsNext-ActionPOSTs to/insiders/*URLs. - Layout-level
notFound()— editor/app/(insiders)/layout.tsx throwsnotFound()when not in dev. Defense-in-depth: even if a future change accidentally weakens the proxy gate, the layout still renders 404 for every page in the group. - No imports across the boundary —
editor/app/(insiders)/insiders/billing/actions.ts
carries a
GRIDA-SEC-002header documenting that these actions must NOT be imported from production code paths. Importing them from a(site)page would re-emit the action hashes against that page's URL and bypass the proxy gate.
Files bound by this id. Run grep -rn GRIDA-SEC-002 . to enumerate.
Today:
- editor/proxy.ts — proxy gate.
- editor/app/(insiders)/layout.tsx — layout
notFound()fallback. - editor/app/(insiders)/insiders/billing/actions.ts — header callout, "no import from prod code".
What does NOT belong under (insiders)/. Anything that needs to
ship to production. If a feature in development outgrows the dev
harness, move it to (site)/... (with proper auth) or (api)/...
(with proper auth) — never relax the (insiders)/ gate to host it.
What it protects. Every call into the AI provider SDKs (Vercel AI
SDK, Replicate, OpenAI, Anthropic) is gated and billed against an
organizationId. If that id reaches the seam unverified, an attacker
who can choose the id drains another org's credit balance. The
boundary is the rule that every organizationId reaching
editor/lib/ai/server.ts has been verified as a member-org for the
calling user.
Vulnerable scenario (prevented). A developer adds a new AI route
handler that reads organizationId from the request body and forwards
it straight into the seam. An attacker enumerates organization_id
(sequential bigint) and submits requests with organizationId = <victim>. Each request bills the victim's balance, eventually flips
their customer_entitled = false, and locks them out of AI until
they top up. Worse, the attacker's free-tier user enjoys the victim's
credit for as long as it lasts. Mass automation makes this an
asymmetric DoS-by-billing attack.
Why it's specifically risky here. AI route handlers and server actions sit on internal/private surfaces, but they are still HTTP endpoints reachable by any authenticated user. Org membership is checked by RLS on data reads, not on AI-seam writes — the seam calls Metronome (an external service), not our own DB, so no RLS gate fires. Without a structural producer-side rule, every new AI endpoint is a fresh chance to forget the membership check.
How the code prevents it.
- One verified producer —
editor/lib/auth/organization.ts
exports
requireOrganizationId({ user_id, request, routeParams, inputOrgId }). It resolves from: route param slug → request headerX-Grida-Organization-Id→ explicit input. Every resolved id is verified viaassertOrgMember(user_id, org_id)before return. No "current org" is read from session blob / cookie. - Runtime contract in the seam —
editor/lib/ai/server.ts
withTransaction(and the AI SDK middleware that wraps it) throwMissingOrgIdErroriforganizationIdis missing, non-integer, or non-positive. This is unconditional on the billed path: the formerNEXT_PUBLIC_GRIDA_LOCALDEV_SUPERUSERexception (syntheticorganizationId:0, gate/ingest/auth skip) has been removed — no code path skips this check while billing. The only intentional bypass is the BYOK carve-out below, and it does not bill. - Single seam entry point —
editor/lib/ai/server.ts is the ONLY
file allowed to import
replicate,openai,@ai-sdk/*,@anthropic-ai/sdk. Enforced by oxlintno-restricted-imports(editor/.oxlintrc.jsonc) and the CI audit script (editor/scripts/audit-ai-seam.ts). A new file that bypasses the seam fails at lint or CI.
BYOK carve-out (intentional). When a contributor sets a BYOK_*
key (editor/lib/ai/models.ts —
BYOK_OPENROUTER_API_KEY, BYOK_AI_GATEWAY_API_KEY), grida/model
return a bare provider so the AI-SDK text/chat path bypasses
the billing seam: no gate, no Metronome ingest, and the
MissingOrgIdError runtime contract above does not fire (a bare
provider has no middleware). The contributor's own provider key is
charged directly — there is no Grida balance, hence no victim to
drain, so the billing trust boundary is moot for that path. Scope —
AI-SDK path only. BYOK only swaps the AI-SDK provider; Replicate-
backed actions (runPrediction/withTransaction — audio, image) are
not bypassed and still gate + ingest under BYOK. Accordingly the
withAiAuth balanceCents:0 short-circuit is opt-gated
(byokBypass, default false): only AI-SDK actions set it, so billed
actions still read the real balance and cannot silently drain credit
while reporting 0. BYOK bypasses billing only — never auth. requireOrganizationId and
route/action auth always run, so a logged-in user with no resolvable
org is still rejected. Gated solely by server-only, non-NEXT_PUBLIC_
env vars never set in the hosted product (same trust model as
OPENAI_API_KEY / REPLICATE_API_TOKEN). Fail-closed: byok is
null unless a key env var is a non-empty string, so any ambiguity
falls back to the billed path. Residual risk: byok is resolved
once at module load with no per-request guard — an accidental BYOK_*
on a hosted/preview deploy would make every org bypass billing and the
org-id sanity gate (auth still holds). Acceptable only because it is a
contributor/self-host switch under the existing server-env trust model.
Files bound by this id. Run grep -rn GRIDA-SEC-003 . to enumerate.
Today:
- editor/lib/auth/organization.ts —
requireOrganizationId. - editor/lib/ai/server.ts — single seam entry; unconditional runtime gate; BYOK layer switch.
- editor/lib/ai/models.ts — BYOK layer (bare provider, bypasses billing).
- editor/.oxlintrc.jsonc — import lint rule.
- editor/scripts/audit-ai-seam.ts — CI audit.
What does NOT belong here. Reading organizationId directly off a
request body in any AI-adjacent code. Even if you think you "trust"
the body — Next.js server-action hashes ship in the client bundle and
become public the moment they're shipped. Always go through
requireOrganizationId.
What it protects. The Grida Desktop V1 ships a local daemon
sidecar (Node subprocess of the Electron app) that owns the user's BYOK
keys (OpenRouter, Vercel AI Gateway), local file paths, chat sessions,
and AI agent loops. The daemon listens
on 127.0.0.1:<random-port> and is the canonical local capability
surface for the renderer. If anything other than the legitimate
Electron renderer reaches it — another browser tab on grida.co, a
local malware process, a same-origin XSS payload — that party can
exfiltrate secrets, read/write the user's files, and bill AI calls.
The boundary is the rule that only requests originating from the
desktop's privileged renderer at a /desktop/* path, signed with
the per-spawn Basic Auth token, may reach the daemon.
Package shape (#927). The perimeter and the host capability routes
(files, recents, workspaces, the secrets store) are owned by
packages/grida-daemon (@grida/daemon —
DaemonServer, http/auth.ts, http/origin.ts, http/server.ts).
The AI surface (/agent, /sessions, /secrets, /providers,
/images, /video, the run loop and tools) is a tenant —
packages/grida-ai-agent's
createAgentTenant — mounted behind that perimeter through the typed
DaemonTenant seam. Everything in this record applies to the composed
server (createAgentDaemon) that desktop and the CLI actually run;
the split moves code, not the wire contract.
Vulnerable scenario (prevented). A stored XSS lands on a marketing
page or blog post served from grida.co. The user has the desktop app
open. Without the boundary, the XSS calls
fetch('http://127.0.0.1:<port>/secrets/get?key=byok.openrouter') and
ships the key to an attacker-controlled host; or
fetch('http://127.0.0.1:<port>/files/read?docId=…') and exfiltrates
the user's design files. A parallel local-machine attack: an
unprivileged malware process scans 127.0.0.1:49152-65535, finds
the daemon, and hits its endpoints (a non-browser client doesn't honor
Origin checks). Both attacks defeat the "secrets in keychain"
intuition because the local network is a trust shortcut.
Why it's specifically risky here. The desktop V1 renderer URL-loads
https://grida.co/desktop/... (a literal path, distinct from the
universal-routing /_/... system).
That puts the privileged preload bridge on the same Chromium origin
as every other grida.co page. Without per-path preload scoping and
per-request agent-server auth, "XSS on grida.co" becomes "RCE-equivalent in
the desktop app" (the same failure class as the Discord 2021 Sketchfab
embed → context-isolation-disabled → RCE chain). Industry precedent
(Figma's FigmaAgent allowlisting only figma.com + Local Network
Access permission) confirms the threat is real and the mitigation
shape is standard.
How the code prevents it. Composed of five layers; any single layer is insufficient.
-
Path-scoped preload — the bridge in desktop/src/preload.ts installs
window.gridaonly whenlocation.pathnameis/desktopor starts with/desktop/at preload-run time. The preload fails closed when the current document is not a desktop route. A fresh document load that doesn't match the prefix gets no bridge, so XSS on/blog/foocannot see it. SPA navigation within an already-loaded document is constrained by preload's history guard and thewill-navigate/did-navigate-in-pageallowlist indesktop/src/window.ts—contextBridge.exposeInMainWorldhas no revocation API, so the navigation guards defend the post-mount surface. -
CSP-strict
/desktop/*routes —editor/proxy.tssets a per-request nonce-based CSP on every/desktop/*response, following the canonical Next.js pattern (nonce +'strict-dynamic'). Concretely:default-src 'self'; script-src 'self' 'nonce-<random>' 'strict-dynamic' 'wasm-unsafe-eval'; connect-src 'self' http://127.0.0.1:* http://localhost:*. The nonce is generated in the proxy, exposed to SSR via thex-noncerequest header, and Next.js attaches it to its own framework scripts automatically. No third-party analytics, Sentry, or marketing scripts run on these routes — eliminates the "Sentry input masking is fragile" exfil for BYOK keys. We chose nonce +'strict-dynamic'over'unsafe-inline'because/desktop/*was already dynamic-rendered (bridge gate is client-only) — the dynamic-rendering cost most Next.js teams pay for nonce CSP is a cost we already pay, so layer 5 stays load-bearing at zero additional maintenance.For maintainers: if you add inline scripts to a
/desktop/*layout or page, they must carry the nonce. Read it via(await headers()).get("x-nonce")and pass it to whatever you're rendering (e.g.<ThemeProvider nonce={nonce}>fornext-themes,<Script nonce={nonce}>fornext/script). Next.js handles framework scripts and<Script>components automatically when theContent-Security-Policyheader is present on the request. Inline<script>tags written by hand are your responsibility. -
Per-request Basic Auth — the daemon rejects any request without
Authorization: Basic <base64("agent:<password>")>. Password is a random 256-bit value generated per sidecar spawn. Electron main sends it to the sidecar over stdin and serves it to preload only through guarded IPC; it is never placed on argv, env, disk, orwindow.grida.Daemon mode (#798). When the daemon runs as a registered local daemon (
grida-agent serve --register; WG spec docs/wg/ai/agent/daemon.md), the per-spawn password gives way to a persistent credential stored owner-only (0600) at<state-dir>/daemon.credential, alongside thedaemon.jsonregistration record (also 0600, atomic temp+rename write;Daemon.readrefuses non-loopback URLs so a tampered record cannot redirect a credential-bearing client off-machine — packages/grida-daemon/src/daemon.ts). Liveness probing is the authenticated/handshake; there is deliberately no unauthenticated health route for local malware to port-scan against. Two carriages, one credential: theAuthorization: Basicheader everywhere, plus anauth_tokenquery parameter accepted ONLY on GET event-stream routes (/agent/stream/:id,/sessions/:id/status) for header-lessEventSourceattach (@grida/daemon'shttp/auth.ts; the route set is declared BY the agent tenant viasse_query_token_pathson theDaemonTenantseam —packages/grida-ai-agent/src/server.ts). A present header always wins — a wrong header never falls back to the token — and the token is never accepted on mutating routes, so a URL leak (proxy logs, history) can at worst read stream frames for the leaked session id; it cannot mutate state, run the agent, or touch secrets. CORS/Referer layers still apply unchanged to token-authed requests. -
Defense-in-depth
Referercheck — the daemon rejects any request whoseRefererpath is not under the host-declared desktop route root. Catches a same-origin XSS that somehow bypasses preload scoping (e.g. a future SPA-nav race condition). -
secrets.getdoes not exist — the bridge surface in desktop/src/preload.ts exposes onlysecrets.has/set/delete. Agent server code reads keys internally when calling the BYOK provider; key material never returns to renderer. Closes the exfil path even if all four layers above were bypassed.
Endpoint providers (local LLMs, #806). The agent tenant additionally
serves /providers/endpoints/* — CRUD over user-configured
OpenAI-compatible endpoints (Ollama preset, self-hosted gateways),
persisted at ${userData}/endpoints.json. The split that keeps layer 5
intact: an endpoint config (base URL + registered model list) is
plain readable config the renderer may list back, while an endpoint's
optional API key rides the /secrets/* surface under the endpoint's
id (the secrets-route allowlist admits configured endpoint ids) and is
never readable. The config validator
(packages/grida-ai-agent/src/protocol/endpoints.ts) pins the shape —
http(s) URL, bounded sizes, unknown fields dropped — so a config write
cannot smuggle credentials or blobs into the readable store. The
base_url is user-owned egress by design (the desktop user points their
own agent at their own endpoint — same trust model as BYOK), and the
routes sit behind the same CORS/Referer/Basic-Auth stack as everything
else. The /providers/endpoints/probe route makes the host GET a
user-supplied URL's model listing (the renderer's grida.co origin cannot
reach a local Ollama itself) — the same egress a configured run already
performs; responses are parsed and reduced to
{id, tool_call, contextWindow} rows with bounded reads (timeout + size
cap), never proxied raw. On sandboxed
platforms the srt network policy additionally bounds all of this
structurally: outbound to localhost is permitted via the
allowLocalBinding local-ip rule (how the user's own ollama serve is
reached), while a config pointing at an arbitrary remote host is
blocked unless that host is in the enumerated allowed_domains — a
hostile config cannot turn the sidecar into an open exfil channel.
Agent providers (external agents, #813). When the host drives an
EXTERNAL agent that owns its own loop (Claude Code via
@anthropic-ai/claude-agent-sdk), that agent makes its own outbound auth +
inference calls to its vendor. Those vendor hosts (Anthropic:
api.anthropic.com incl. /api/oauth/claude_cli/*, *.anthropic.com,
claude.ai) are added to the same enumerated allowed_domains allowlist as
the BYOK provider hosts (sandbox/policy.ts AGENT_PROVIDER_NETWORK_HOSTS) —
NOT a * opening. The external agent still runs tools/shell in the workspace
under the same srt confinement, so its egress stays bounded to its legitimate
vendor endpoints; this is the same trust model as a BYOK provider host (the
provider sees the conversation by design), not a new exfil class.
Electron-side hardening (mandatory; see the
Electron security checklist).
contextIsolation: true, nodeIntegration: false, sandbox: true,
webSecurity: true, allowRunningInsecureContent: false; release builds
load https://grida.co while dev loads http://localhost:3000;
will-navigate blocks navigation off EDITOR_BASE_URL;
setWindowOpenHandler denies and routes external links through
shell.openExternal after validation; will-attach-webview rejects;
every main-process IPC handler validates event.senderFrame.url.
Agent shell execution. The run_command agent tool spawns child
processes through @grida/daemon's shell/runner.ts with shell: false (no shell
interpolation). There is no command allowlist — the OS sandbox (srt,
see the supervisor) is the structural boundary, and a per-session
permission mode governs the surface (protocol/mode.ts):
accept-edits(default): only read-only/inspection commands auto-run (permissions.tsisReadOnlyCommand); a mutating/executing command pauses for a supervised Allow/Deny approval before it runs. The gate is the AI SDK's nativeneedsApprovalon the tool (tools/run-command.ts), wired from the session mode atworkspace-agent-bindings.ts(needs_approval = !isReadOnlyCommandinaccept-edits, absent inauto). The gate is the tool's, NOT the backend's: by the time the command backend'sexecuteruns, the call is already cleared (auto, or user-approved), so the backend cannot re-gate on mode without refusing an approved command.auto: every command runs; the OS sandbox is the sole guard. The semantic safety classifier that would judge intent is deferred —autois an opt-in, informed-consent posture.
Supervised-approval answer boundary. The approval pause/resume crosses the
trust boundary, so the answer is server-validated. The host owns message state
(it rebuilds the model view from the DB each turn), so the answer does NOT ride a
client-mutated assistant message — it travels as an explicit approval_answer
field on the run-request body ({tool_call_id, approval_id, approved}), exactly
like mode/model_id. parseRunBody shape-gates it (coerceApprovalAnswer;
malformed ⇒ no resume, never a 400), then applyApprovalAnswer
(runtime/run-input.ts) routes it through store.answerApproval, which flips a
persisted part to approval-responded only if it is currently
approval-requested with a matching approval id and session. A forged client
request therefore cannot inject a tool call, approve something never asked, or
rewrite assistant history — it can only supply the boolean the host is already
waiting on. The recorder persists the approval-requested state and the
model-view rebuild (message-view.ts) lowers approval-responded/output-denied
parts so the SDK resumes (runs) or skips (denies) the call. Symmetrically, a send
that does not answer the pending approval cannot run ahead of it: the run
handler (runtime/index.ts) refuses to start a new turn while an approval is
unanswered (HTTP 409 approval-pending) — the same fail-closed invariant the
queue drain enforces (session-scheduler.ts has_pending_approval). So neither a
forged answer nor a typed-ahead follow-up can bypass or orphan the block.
Three structural checks hold regardless of mode: the
cwd-must-be-inside-an-opened-workspace check, the in-process secret-arg
containment check (below), and a no-clobber protected-path guard on the
fs-edit tools (fs/scope.ts: .git, rc/env files, lockfiles, agent config).
The OS-level outer sandbox confines the whole sidecar; a per-command fs/net
sub-policy that would constrain each spawned child (the kernel-level finish of
the secret-dir guard below) does not exist yet and is the deferred hardening.
-
Network (allow-only, enumerated).
srtdenies all outbound except a host-set domain allowlist and forbids*/ broad patterns by design — its structural sandbox is also its network sandbox, so there is no "open network." The allowlist is composed along the #927 seam: the daemon frame contributes the curated dev-network set (package registries, git hosts —packages/grida-daemon/src/sandbox/policy.ts) and the agent tenant contributes the AI upstream hosts (BYOK providers + external-agent vendors —packages/grida-ai-agent/src/sandbox/policy.ts,buildAgentDaemonSandboxPolicy), so the agent can install deps, fetch code, and reach its providers. -
Fail-closed exposure (no sandbox ⇒ no shell). The shell tool is not registered at all unless the host affirms containment. The decision is computed once at the tenant boundary (
createAgentTenant,packages/grida-ai-agent/src/server.ts) assandbox_enforced || allow_unsandboxed_shelland threaded to the tool registry; the default is off. The desktop supervisor setssandbox_enforcedtrue only when it actually wrapped the sidecar spawn withsrt, so on platformssrtcannot wrap (Windows today) the agent gets fs/todos/skills but norun_command. Thegrida-agentCLI — a local, user-invoked tool with no OS sandbox — sets the explicitallow_unsandboxed_shellopt-in instead, which logs a warning. New privileged tools added later inherit the same gate: a capability that needs containment is born behind this switch, so the system's default posture is "no containment, no capability." -
Secret-dir containment (in-process). The daemon's own secret dir — its
userData, where BYOKauth.json,workspaces.json,recent.json, and the sessions db live — is deliberately not in thesrtdeny_readpolicy, because the host process itself must readauth.jsonfor provider calls. Denying it at the kernel level would break host auth. Instead the shell child is kept out of it in-process:validateShellRequestrejects any command arg that resolves (after realpath of the nearest existing ancestor, mirroring the cwd discipline so a symlink can't bypass it) inside that protected root. HOME secrets (~/.ssh,~/.aws, shell rc files) remain denied for the entire tree by thesrtpolicy, where the host has no legitimate read. This ownership split is the responsibility-and-reconciliation rule:srtowns HOME secrets, the in-process runner owns the host's ownuserData. Caveat (auto): the in-process arg check only inspects top-level argv, so an interpreter or shell (bash -c,python3 -c) reachable inautocan readuserDataby a computed path. Closing that for the shell child needs the kernel-level per-calldeny_read(the deferred per-command sub-policy); until then the network allowlist + the key being the user's own provider credential bound the exfil. The fs-edit tools (read_file) remain workspace-scoped and never serveuserData. -
autois informed-consent.autoremoves command-identity gating; the sandbox still bounds the blast radius (writes confined to writable roots, the enumerated network), but it does not judge intent — an injected or confused agent can read broadly and run anything within those bounds. Restoring intent judgment is the classifier/watchdog layer, named and deferred.autois opt-in; the defaultaccept-editskeeps a read-only-only shell.
Human terminal (deliberate contrast to the agent shell). The
workbench's Terminal pane (bridge.terminal.*) is a real, unsandboxed
login PTY — arbitrary code execution by design, accepted under the same
trust model as VSCode's integrated terminal: the human runs commands as
themselves, on their own machine, with their own privileges (no
escalation). It is deliberately NOT wrapped in srt and deliberately NOT
part of the agent's tool surface — the agent's run_command stays
confined behind the sandbox gates above, and no code path hands the agent
a handle to a human terminal. What makes the surface acceptable is that
only the legitimate desktop renderer can reach it: the four terminal IPC
channels are registered through the same sender-frame guarded() wrapper
as every other native capability (editor origin + /desktop/* path), the
preload exposes them only on desktop routes, and the PTY host
(desktop/src/main/terminal-host.ts) additionally (a) resolves the spawn
cwd from a workspace id through the sidecar registry — the renderer
never passes a raw path, (b) binds each terminal to the WebContents that
created it so one window cannot drive another window's shell, (c) caps
PTYs per window, and (d) kills every PTY on window close and app quit.
A contract test (desktop/src/main/terminal-host.test.ts) fails if a
terminal channel is ever registered outside guarded().
Hosted auth (sign-in only). The desktop signs the webview into a
first-class Supabase cookie session via the system browser + PKCE +
grida://auth/callback deep link — that flow is its own boundary,
GRIDA-SEC-005 below. Within THIS boundary the invariant is unchanged:
the Electron main process and the AgentHost sidecar hold no cloud
credentials, and there is still no entitlement polling and no
hosted-model routing. Future hosted-provider work (sidecar-held tokens,
metered AI) must re-register its files in this record before code lands.
Update channel. Release builds must be signed/notarized by platform policy. Security-sensitive runtime deps are reviewed as part of the desktop release checklist; do not treat broad semver ranges as acceptable for code running inside this boundary without an explicit review note.
Workspace media streaming (#924). The desktop media viewer renders
workspace images/videos from a custom privileged scheme,
grida-workspace://workspace/<workspaceId>/<relPath>, instead of inlining
bytes as base64 (which capped the viewer at 1 MiB). This adds a new
renderer-reachable file-read origin, so it is recorded here. The trust
model keeps the boundary intact: the Electron main-process handler
(desktop/src/main/workspace-media-protocol.ts) gains no filesystem
authority of its own — it only proxies the request to the sidecar's
streamed GET /workspaces/file route, injecting the same Basic-Auth the
renderer never sees and forwarding the Range header. Path containment is the
sidecar's existing workspaceFs.resolveInside realpath check, identical to
every other workspace read; the scheme is a transport for an already-exposed
capability, not a new reachable root. The renderer builds the URL as a pure
string (no credential crosses into it), and CSP scopes the scheme to
img-src/media-src only — it is not registered bypassCSP. A constant
host (workspace) carries no data so standard-URL host canonicalization can't
corrupt the id; both ids live in the path.
First-party library images (reference-first artwork). The artwork-station
gather step (design_search) shows the user images from the Grida Library —
the app's OWN Supabase storage bucket — and the picked references are kept as
URLs and rendered directly (never downloaded). So the desktop CSP allowlists the
one first-party library origin (NEXT_PUBLIC_SUPABASE_URL) in img-src,
image-only. This is distinct from the generated-media rule above: generation
provider CDNs (fal/openrouter/…) stay excluded — generated media is sidecar
bytes via data:/blob:/grida-workspace:. The origin is derived from env at
module load and omitted when unset (a malformed value cannot widen the policy);
proxy.test.ts pins both the allow (library origin in img-src only) and the
deny (provider CDNs still excluded). The alternative — proxying library images
through grida-workspace: like generated media — was rejected: the library is
first-party public read-only storage, and the product keeps its pins as URLs.
Auto-created projects (managed root). The reference-first home lets a
newcomer start without choosing a folder: it posts to POST /workspaces/create,
which mints a new project directory and seeds a .canvas bundle (a
<name>.canvas dir + manifest inside it). This adds a new
renderer-reachable write authority (previously the renderer could only
register a folder the user had already picked through the OS dialog), so it is
recorded here. The boundary holds by four rules, none of which trust the caller
for a path: (a) the managed root is host-injected — the supervisor passes
--projects-root=<~/Documents/Grida> (desktop/src/main/agent-sidecar-supervisor.ts),
never derived from the request; a host that wired no root refuses with a 400.
(b) The request's name is slugified to a single filesystem segment
(WorkspaceRegistry.createProject / slugifyProjectName): path separators,
.., NUL, and control chars cannot survive, so it can never be a path. (c) The
minted directory's realpath is asserted strictly under the managed root via
the shared containsPath (path-contains.ts — the same prefix+sep discipline
as the shell runner's root gates); an escape is removed and rejected. (d)
The seed is field-constrained (seedValidator in
http/routes/workspaces.ts) to { src, layout? } documents — a raw dotcanvas
manifest never reaches disk, so the route is not a manifest-injection vector.
The sidecar's own fs writes are not srt-confined (srt wraps only the
run_command shell child), and a created project registers as a workspace root
the shell fs-policy already unions — so this adds no new reachable root for the
sandboxed shell. workspaces.create.test.ts pins traversal-name containment,
seed field-constraining, and the no-managed-root refusal.
Files bound by this id. Run grep -rn GRIDA-SEC-004 . to enumerate.
Today:
- editor/lib/supabase/server.ts —
createClientFromBearer(bearer-auth shim for existing private editor routes that allow Desktop-originated calls without browser cookies). - editor/app/(api)/private/ai/design/chat/route.ts — legacy SVG/web whole-agent route; accepts bearer auth for existing Desktop SVG callers during migration.
- packages/grida-ai-agent/src/providers/index.ts — BYOK-only provider resolver; never exposes credentials to the renderer.
- packages/grida-daemon/src/daemon.ts — daemon discovery contract: owner-only atomic registration + persistent credential, loopback-only records, authenticated probe.
- packages/grida-ai-agent/src/runtime/index.ts — agent run orchestration; owns run / stream / abort behavior.
- packages/grida-ai-agent/src/runtime/stream-registry.ts — in-flight run replay/abort registry.
- packages/grida-ai-agent/src/runtime/command-backend.ts — agent
run_commandadapter through shell policy (structural gates only; the supervised mode gate is the tool'sneedsApproval). - packages/grida-ai-agent/src/tools/run-command.ts — the supervised-approval gate itself: the AI SDK
needsApprovalpredicate that pauses a mutating command beforeexecuteinaccept-edits(absent inauto). The decision lives on the tool, not the backend. - packages/grida-ai-agent/src/runtime/workspace-agent-bindings.ts — opened workspace to agent fs/todos/command bindings; wires the
accept-editssupervised-approval predicate. The session scratch dir is wired as an additional sanctioned root for BOTH surfaces from one source (deps.scratch_dir): the shell's allowed cwd roots AND the fs backend's reachable roots (soview_image/read_file/write_filereach scratch, not just the shell). Containment is preserved per root — a path under no reachable root falls back contained to the workspace, and the secrets root is never a reachable root. Also builds thegenerate_imagebinding: it reads BYOK keys viaSecretsStoreto call the image provider in-process and returns the saved scratch path + metadata + base64data(the bytes are for the CLIENT to render;AgentGen.toModelOutputis text-only, so they are NEVER lowered to the model — no context bloat, no perception claim). The complementaryview_imageperception path DOES deliver bytes to the model, but only ones already read under the agent's existing fs read capability:agent/hoist-tool-result-images.ts(wired atagent/index.tsprepareStep, #923) relocates an image tool-result into a synthetic user-message image part so the model can actually see it on the openai-compatible wire — a model-view lowering that moves bytes already inside the prompt, never persisted, with no new read, no new egress, and no boundary change. The key never leaves the host, and the call omitsproviderOptions.gridaso it is BYOK-paid, never Grida-billed (mirrors the/images/generateroute). - packages/grida-ai-agent/src/session/scratch.ts — per-session ephemeral scratch dir (WG
scratch.md): asserts the shell-writable scratch tree sits OUTSIDEuserData(the secret root), creates it owner-only (0700), and reclaims it (per-session delete + synchronous host-start sweep).writeScratchFilelands produced bytes (e.g.generate_image) owner-only (0600) within the session tree, rejecting any filename that is not a single safe path segment AND openingO_NOFOLLOWso a symlink planted at the basename (e.g. by an auto-approved scratch-cwdrun_command) fails the write instead of redirecting it outside the tree — closing the lexical-check TOCTOU. - packages/grida-daemon/src/path-contains.ts — shared
path.sep-prefix containment used by the shell runner's workspace/secret-root gates, the scratch containment assert, andcreateProject's managed-root assert (one source so the discipline can't drift). - packages/grida-ai-agent/src/runtime/run-input.ts — wire-message normalization +
coerceApprovalAnswer/applyApprovalAnswer(shape-gates the explicitapproval_answerbody field and routes it tostore.answerApproval). - packages/grida-ai-agent/src/session/store.ts — sessions store;
answerApprovalis the server-authoritative supervised-approval gate (answers only a real pending approval, never forges a call). - packages/grida-daemon/src/workspaces.ts — opened workspace registry and root canonicalization.
- packages/grida-daemon/src/workspaces/fs.ts — guarded file operations over a containment scope (a
{ id, root }: the workspace, or the session scratch dir — NOT tied to the workspace registry). Every read/write realpath-checks containment to that scope's root, so a symlink escaping the scope is rejected regardless of which scope it is. The streamed-media export (openFile, #924) goes through the sameresolveInsidecontainment (resolved once per request) and then pins the read to a contained file descriptor: it opens the realpath'd targetO_NOFOLLOWand fstat-streams from that handle, so a symlink swapped in after the check (the realpath→read TOCTOU) fails the open instead of escaping — the same defense as scratch writes. It is deliberately uncapped because streaming has constant memory (the 1 MiB cap exists only to bound the buffered text/base64 readers), which is also why the TOCTOU hardening matters more here. - packages/grida-daemon/src/http/routes/workspaces.ts —
/workspaces/*registry + fs routes, including the streamed, Range-awareGET /workspaces/file(#924) that thegrida-workspace://scheme proxies to. Same Auth/Origin/Referer guards as the base64 readers; containment viaworkspaceFs. - desktop/src/main/workspace-media-protocol.ts — the
grida-workspace://privileged-scheme handler (#924): proxies to the sidecar's/workspaces/filewith main-held Basic-Auth + forwarded Range; no independent fs authority; 503 before the sidecar is up. desktop/src/preload.ts— path-scopedcontextBridge; password fetched through guarded IPC and held in closure.- packages/grida-desktop-bridge/src/index.ts — renderer-safe bridge protocol and DTO vocabulary.
desktop/src/bridge/contract.ts— Desktop-local IPC channel vocabulary plus re-export of the renderer-safe bridge contract.desktop/src/window.ts— blocks exposed desktop windows from navigating outside/desktop/*; injects non-secret preload arguments.desktop/src/agent-sidecar.ts— sidecar entrypoint; constructs the composed agent daemon (createAgentDaemon).desktop/src/main/agent-sidecar-supervisor.ts— generates per-spawn password; spawns/supervises the daemon sidecar; initializes the OS sandbox wrapper when supported (srtis not available on Windows yet).desktop/src/main/protocol-router.ts— deep-link protocol guard; the auth callback arm is bound by GRIDA-SEC-005.desktop/src/main/ipc-handlers.ts— validates every native IPC sender frame before executing OS capabilities.packages/grida-daemon/src/http/server.ts— loopback HTTP app, daemon route registration, and theDaemonTenantseam behind shared guards;packages/grida-ai-agent/src/server.ts— the agent tenant that mounts the AI route groups through it.packages/grida-ai-agent/src/http/routes/secrets.ts— BYOK key presence/set/delete route group; no key-read route.packages/grida-daemon/src/transport.ts— Basic Auth signing, fetch/SSE plumbing, typed HTTP errors, and the daemon route methods;packages/grida-ai-agent/src/transport.ts— the agent tenant client extending it (run/stream/sessions/events, stream resume headers).packages/grida-daemon/src/http/auth.ts— Basic Auth middleware.packages/grida-daemon/src/http/origin.ts— Origin allowlist and host-declared Referer-path guard.packages/grida-daemon/src/auth/file.ts—auth.jsonchmod 0o600 read/write.packages/grida-daemon/src/secrets.ts—auth.json-backed BYOK key store; exposes onlyhas,set, anddeleteto routes.packages/grida-daemon/src/sandbox/policy.ts— daemon sandbox policy frame (secret-path denies, dev-network baseline);packages/grida-ai-agent/src/sandbox/policy.ts— the agent tenant's AI upstream hosts composed on top.- editor/proxy.ts — Next.js 16 proxy that sets the CSP +
X-Robots-Tag+Referrer-Policy+X-Content-Type-Optionsheaders on every/desktop/*response. - editor/lib/desktop/csp.ts — the desktop CSP template (
buildDesktopCsp), kept out ofproxy.tsper Next.js 16 route-export rules. Owns the directive set, thegrida-workspace:img/media scope (#924), and the first-party libraryimg-srccarve-out. Pinned byproxy.test.ts. - editor/app/desktop/layout.tsx — root layout for the desktop route group; gates all children through
DesktopBridgeGate. - editor/scaffolds/desktop/desktop-bridge-gate.tsx — server-rendering-safe gate that renders children only when
window.gridais present. - editor/scaffolds/desktop/open-in-desktop-cta.tsx — fallback shown to web visitors (capability boundary visible per doctrine rule 3).
- editor/lib/desktop/bridge.ts — typed client of
window.grida+ SSR-safe presence detector (useDesktopBridge). - desktop/src/main/host-apps.ts — private desktop UX registry for “Open in…” app detection/opening.
- desktop/src/main/workspace-files.ts — move-to-trash for a workspace entry (file or folder); re-validates that
relPathresolves inside the workspace root, and isn't the root itself, beforeshell.trashItem. - desktop/src/main/terminal-host.ts — human-terminal PTY host: workspace-id-resolved cwd, per-WebContents terminal ownership, per-window PTY cap, kill-on-close; the unsandboxed-by-design surface described under "Human terminal" above.
- editor/scaffolds/desktop/workbench/terminal-pane.tsx — xterm.js view over the
terminalbridge namespace; renderer side of the human terminal. desktop/src/main.ts— Electron main entry; acquires the single-instance lock (deferred toreadyso a secondary instance can forward a macOSopen-filepath viaadditionalDatabefore quitting — before any sidecar/window/IPC is created, preserving the one-sidecar invariant); routesopen-file/open-url/second-instanceopens.- desktop/src/main/open-handoff.ts — pure codec for the secondary→primary "open" forward; tolerant
decodeso a foreign or legacysecond-instancepayload is never mistaken for an open.
What does NOT belong here. A secrets.get method on the bridge.
A bridge installed unconditionally (without pathname scoping). A
daemon that binds 0.0.0.0. An app that loads grida.co's
non-(desktop) routes inside the desktop window without revoking the
bridge first. Any IPC handler in Electron main that acts without
checking event.senderFrame.url. A grida:// deep-link handler that
exchanges OAuth codes itself — the exchange belongs to the same-origin
/desktop/auth/callback route against the webview-held PKCE verifier
cookie (GRIDA-SEC-005).
What it protects. Desktop sign-in gives the Electron webview a
first-class Supabase cookie session — the same session shape as a
browser tab, so every existing cookie-gated route and middleware works
unchanged. The ceremony runs in the system browser (RFC 8252; embedded
webviews are blocked by providers) and returns through the
grida://auth/callback deep link. The boundary is the rule that a
grida:// deep link is untrusted, world-invokable input: it must never
be able to create, steal, or redirect a session. The only thing a deep
link may cause is a navigation of a desktop window to the fixed
same-origin /desktop/auth/callback route.
Vulnerable scenario (prevented). Custom-protocol URLs are invokable
by any webpage and any local process (open "grida://…"). Without the
boundary: (a) login-CSRF — an attacker mints their OWN authorization
code and fires grida://auth/callback?code=<attacker-code> at the
victim's app, silently signing the victim into the attacker's account so
later work is saved where the attacker can read it; (b) a phished or
replayed single-use code is redeemed by a party other than the app that
started the flow; (c) the deep link is used as a navigation primitive to
walk the privileged (bridge-attached) window to an attacker-chosen URL.
Why it's specifically risky here. The desktop window carries the
window.grida bridge (GRIDA-SEC-004), so a navigation primitive fed by
an unauthenticated OS-level input lands directly on the app's most
privileged surface. And the repo is open source — the exact flow shape,
paths, and params are public, so the design must not rely on obscurity.
How the code prevents it.
- PKCE verifier confined to the Electron cookie jar —
editor/app/desktop/auth/start/route.ts
mints the
@supabase/ssrcode-verifier cookie on a route-handler response (the supabase/ssr#55-safe shape) and returns the same-origin/desktop-authlaunch-page URL carrying thecode_challenge. The sign-in method is chosen on that web page (editor/app/(untracked)/desktop-auth/), and every method binds its GoTrue flow to the forwarded challenge (host/auth/desktop-auth-flow.ts, pinned by its test) — so the desktop never names a provider, and whatever the method, the resultingcodeis exchangeable ONLY with the jar-held verifier: a code minted against a different verifier is rejected by GoTrue (closes the naive login-CSRF); a phished victim code is single-use, expires in 5 minutes, and is useless off-machine without the jar. - The challenge is confidentiality-sensitive in transit — in this
design the
code_challengedoubles as the binding token between "the desktop that started this flow" and an acceptable code: an attacker who learns a victim's challenge can mint a code bound to it for the attacker's own account and fire the deep link at the victim, logging the victim into the attacker's account (login-CSRF via challenge replay — a random attacker challenge is harmless, but the victim's is not). It is unguessable (256-bit) and must not be disclosed, so the launch page lives in the analytics-free(untracked)route group (editor/app/(untracked)/layout.tsx) — no Google/Vercel pageview script ever sees its URL — withReferrer-Policy: no-referrerso the challenge never leaks via aRefererheader either. The insiders redirect target is likewise analytics-free (and dev-only). Address-bar / history exposure is inherent to any browser OAuth handoff and bounded by the 5-min, single-use code; the systematic third-party beacon is what is closed here. - Stateless, fixed-target router —
desktop/src/main/protocol-router.ts
performs no code exchange and holds no auth state. It navigates a
desktop window to the constant
/desktop/auth/callbackpath on the configured editor origin, forwarding only the knowncode/error*params; nothing else from the deep link crosses the boundary, and every branch consumes the URL (no re-queue loop). - Exchange only at the same-origin callback route —
editor/app/desktop/auth/callback/route.ts
runs
exchangeCodeForSessionwith the cookie client (identical mechanism to the web(auth)/auth/callback); success and failure both redirect inside/desktop/*. - Redirect containment — the
will-redirectguard in desktop/src/window.ts holds server 302s to the same same-origin/desktop/*allowlist as user navigations (will-navigatedoes not fire for server redirects, so without the hook a redirect chain could walk the bridge-attached window off-surface). Blocked redirects are NOT handed to the OS browser. Sign-out is the same-origin editor/app/desktop/auth/sign-out/route.ts: navigating the webview to the web/sign-outwould be blocked andshell.openExternal'd — logging the user out of their OS browser. - Ceremony in the system browser only — the launch URL travels
renderer →
shell.open_external(http/https-validated IPC); the webview never loads a provider page, and thegrida://auth/callbackredirect is allowlisted in Supabase (supabase/config.toml locally; the hosted project's dashboard in production).
The Electron main process and the sidecar remain credential-free: the
session lives in the webview's cookie jar and is refreshed by the same
@supabase/ssr middleware machinery as the web app. The /desktop/*
CSP keeps connect-src closed, so session reads go through the
same-origin /desktop/auth/me route rather than direct supabase-js
calls.
Files bound by this id. Run grep -rn GRIDA-SEC-005 . to enumerate.
Today:
- supabase/config.toml —
grida://auth/callbackredirect allowlist entry. - editor/app/desktop/auth/start/route.ts — PKCE start; verifier cookie + launch-page URL (method-neutral; the desktop never names a provider).
- editor/app/(untracked)/desktop-auth/page.tsx + editor/app/(untracked)/layout.tsx — the web launch page and its analytics-free root layout. Shares the sign-in shell (editor/components/auth/sign-in-shell.tsx) and Google button (
authorize_urlmode); validates the challenge, binds the offered method's GoTrue flow to it, mirrors the web sign-in's insiders routing (NEXT_PUBLIC_GRIDA_USE_INSIDERS_AUTH→ redirect to the insiders page with the challenge forwarded). Deliberately NOT a(site)sibling:(site)loads Google/Vercel analytics that would beacon the challenge-bearing URL. The(untracked)group must never gain a URL-reporting script. - editor/host/auth/desktop-auth-flow.ts — the flow vocabulary shared by the launch page and the insiders route: challenge validation, challenge-bound authorize/OTP builders, verify-link extraction pinned to the Supabase origin (pinned by
desktop-auth-flow.test.ts). - editor/app/(insiders)/insiders/auth/basic/sign-in/route.ts (+ the hidden
challengepassthrough in basic/page.tsx) — the insiders email+password desktop branch: verifies the password exactly like the web insiders flow, then mints the challenge-bound code by firing the GoTrue OTP and consuming the emailed verify link straight from the local Mailpit capture, so the developer keeps email+password and still traverses the production verify →grida://→ exchange path. A password grant alone can never produce a challenge-bound code (GoTrue returns sessions directly for passwords), which is why the mint rides the OTP-link machinery. Local-only by GRIDA-SEC-002 (/insiders/*404s outside development), which is what makes the Mailpit coupling acceptable (pinned by itsroute.test.ts). - editor/app/desktop/auth/callback/route.ts — the only code-exchange point;
/desktop/*-contained redirects (pinned by itsroute.test.ts). - editor/app/desktop/auth/sign-out/route.ts — same-origin sign-out (never the web
/sign-out). - desktop/src/main/protocol-router.ts — stateless fixed-target auth arm (pinned by
protocol-router.test.ts). - desktop/src/window.ts —
will-redirectguard;isAllowedNavigationpredicate (pinned bywindow.test.ts).
What does NOT belong here. A code exchange in the Electron main
process. A PKCE verifier carried on the deep link, the bridge, or argv.
A router that navigates to a path taken from deep-link input, or that
forwards params beyond code/error*. A desktop webview navigation to
(auth) routes or the web /sign-out. Sidecar- or main-held session
tokens — that is future hosted-provider work and must be registered
here first. The launch page in a route group that loads Google/Vercel
analytics (or any URL-reporting script) — the challenge in its URL is
confidentiality-sensitive, so it stays in (untracked).
- Allocate the next sequential id (
GRIDA-SEC-006for the next one). - Add an "Active boundaries" subsection here with the same shape as GRIDA-SEC-001: what it protects, vulnerable scenario, why it's risky here, how the code prevents it, files bound.
- Tag every relevant file with the new id (header comment for source, callout block for docs, comment in scripts).
- The skill at .agents/skills/security/SKILL.md auto-loads on any "GRIDA-SEC" mention; no need to register per-id with the skill.
Please email security@grida.co. We respond within 48 hours.
If you find a way to reach a non-webhook route via the cloudflared tunnel, that is in scope and considered a real bug — the tunnel filter is supposed to block it.