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 — cloudflared is configured 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. The tunnel config is deliberately not git-tracked (it lives in the operator's~/.cloudflared/); setup is documented in docs/contributing/billing.md §7.
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.
- docs/contributing/billing.md — tunnel ingress filter setup (the config itself is untracked by design).
- 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), native-provider OAuth credentials
(GRIDA-SEC-008), local file paths, chat sessions, and AI agent loops.
Electron main listens on an ephemeral
127.0.0.1 port and transfers only accepted connected sockets to the
socketless sidecar, whose authenticated daemon protocol 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, and only the
main-owned provider transport may carry the daemon's provider HTTP outside
its sandbox. Electron main owns the exact loopback listener; AgentSidecar
receives no listener, and the macOS/Linux sandbox grants it no generic
bind/connect authority. Windows does not yet satisfy that containment
clause; its current nonconformance is recorded below.
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 mutually reinforcing controls; any single control 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. Path eligibility is not capability admission by itself:desktop/src/main/desktop-entry-window.tsresolves an exact sender window to its current native role only while that role is stable, anddesktop/src/main/ipc-admission.tsapplies a closed role + pathname + channel policy inside the common guarded-IPC wrapper. Main rejects auth and onboarding paths even before a navigation observer runs; sign-in receives only browser launch + read-only title-bar state. Onboarding receives only ChatGPT controls, completion, and two purpose-scoped main-owned workspace operations (read the default or choose/register one folder). It never receives the daemon connection tuple or a generic filesystem dialog. A transitioning, stale, foreign, or post-sign-out auxiliary renderer resolves to no role and receives no IPC capability. -
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 this control 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 in the private, versioned stdin bootstrap frame and serves it to preload only through guarded IPC; it is never placed on argv, env, disk, orwindow.grida.Electron main owns the exact
127.0.0.1:<ephemeral>listener. Each loopback connection is accepted paused and transferred as an already-connected socket over the per-spawn Node IPC descriptor. AgentSidecar startsDaemonServerwithout a listener and can serve only that transferred connection: it receives no listener, destination field, bind operation, or connect operation. The descriptor is therefore a socket capability channel, not a second daemon protocol. This keepsallow_local_binding: falsein the sidecar'ssrtprofile and preserves the main-owned loopback port across Bubblewrap's private Linux network namespace.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 preceding controls were bypassed. -
Host-routed provider HTTP (#974) —
@grida/agentaccepts two explicit, construction-time HTTP operations: authenticated provider requests and credential-free provider-asset downloads. Desktop implements them over the sidecar's inherited stdin/stdout using a strict length-prefixed protocol; stdout is protocol-only and stderr is logs. Electron main executes requests through a dedicated, non-persistent ChromiumSessionin system-proxy mode. It revalidates the host-issued grant, method, exact/suffix origin, headers, body bounds, and every redirect; response bytes move only against explicit credit and aborts propagate both ways. The download lane contains only enumerated provider-owned namespaces — there is no arbitrary public-URL grant. There is no renderer broker method, loopback proxy, socket path, or environment credential. The ChatGPT-subscription lane registered by GRIDA-SEC-008 adds only exactauth.openai.comtoken andchatgpt.comResponses destinations; those grants are never eligible for the asset lane. When this transport is enabled, the outersrtpolicy omits BYOK/GG destinations, so missing provider wiring cannot fall back to direct sidecar egress. Electron main transiently observes provider request headers and bodies while transporting them, but never persists, returns, or logs credentials or bodies; credential ownership and injection remain in the sidecar's provider layer.Chromium supplies the operating system's effective proxy/PAC, VPN, DNS, and platform-trust behavior. Cached or integrated proxy authentication remains eligible; Desktop does not collect proxy usernames/passwords, so an interactive proxy challenge fails closed with a specific diagnostic. This is route compatibility, not a censorship-circumvention tunnel: no route is created when Chromium and the operating system have none.
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 the secrets
discipline 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 with no URL userinfo, bounded sizes, unknown fields dropped — so a
config write cannot smuggle credentials or blobs into the readable store.
base_url is user-owned egress by design, but renderer configuration is not
network authority: Desktop routes set/delete/probe through guarded main IPC,
shows the canonical exact origin and route posture in a native confirmation,
and mints a memory-only grant only after approval. Only localhost, subdomains
of .localhost, and IP literals are currently eligible, always as exact
origins. Remote hostnames — including .local names — remain withheld because
the current Chromium connector cannot atomically bind proxy/PAC selection and
DNS resolution to one authorization decision. Existing configured endpoints
require the same approval on each
launch; changing or deleting an endpoint revokes its old grant. 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. The host transport permits an eligible local endpoint
only through that explicit exact-origin grant; it does not turn endpoint
config into a generic proxy.
Agent providers (external agents, #813). The reusable agent package can
drive 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. Desktop explicitly sets external_agent_execution: "disabled": the ACP subprocess cannot consume the host-routed provider
transport or prove system-route compatibility, so it is unavailable even on a
sandboxed macOS/Linux launch. Reusable-package hosts choose an explicit
external_agent_execution posture: "sandboxed" requires
sandbox_enforced === true at HTTP preflight and immediately before spawn;
"enabled" makes no containment claim; and "disabled" withholds ACP.
Omission resolves to "disabled", so reusable hosts must explicitly select
"enabled" or "sandboxed" to permit external-agent execution.
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): read-only/inspection commands auto-run (permissions.tsisReadOnlyCommand). The only mutating exception is a plain, two-pathcpormvwhose existing regular-file source and absent destination both canonicalize inside the same session scratch root; flags, symlinks, overwrites, missing parents, or any path outside scratch fail closed to the ordinary supervised Allow/Deny approval. This exception lets the agent organize pre-authorized ephemeral working bytes without treating promotion into the workspace as pre-authorized. Every other mutating/executing command pauses for 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 = !isReadOnlyCommand && !isScratchLocalCopyOrMoveinaccept-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, pre-authorized scratch-local operation, 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.commitApprovalContinuation, which atomically flips a persisted part to
approval-responded and binds its exact consuming run 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 or
other human-input tool is unanswered (HTTP 409 human-input-pending) — the same
fail-closed invariant the queue drain enforces through the persisted
pending_human_input_kind classifier. Before filling a client-resolved
human-input result, the handler requires the exact visible message, tool-call
id, and persisted tool type and rejects any request that carries an unpersisted
caller-owned user or system message, so a forged result type or an
answer-plus-follow-up cannot bypass queue order in one body. A client-carried
human-input continuation resolves exactly one correlated block; a batch naming
multiple pending blocks is rejected before mutation rather than partially
consumed, conflicting duplicate copies fail closed, and a question or
design-search result must satisfy that tool's canonical output schema before
the pending block can be consumed. Approval/question resolution is a two-phase
commit: the read-only match happens first, but the conditional persisted-state
mutation waits until scratch staging and incoming persistence succeed. A
rejected resume therefore leaves the human interaction actionable and safely
retryable. For an approval or question/design-search result, that conditional
mutation atomically stamps a host-only continuation_run_id on the exact
session/message/tool-call row. The marker remains through the resumed turn and
is cleared only after the recorder settles inside the stream's
finish/error/abort barrier, before the scheduler can observe the terminal edge.
A synchronous failure before stream reservation rolls that exact run's answer
back to approval-requested or input-available; a failed rollback or
settlement leaves the marker fail-closed in the human-input classifier, so
queued work cannot overtake it. On host restart, marked continuations are
converted to output-error and cleared before queue recovery because replaying
their model/tool side effects would be unsafe. Historical completed
human-interaction outputs have a null marker and are never selected by that
repair.
Ordinary desktop submissions use
queue-aware admission while this state is open; if a stale client races the
low-level run endpoint, it preserves the same user-message id through durable
queue admission and rehydrates rather than converting text into an approval
answer. So neither a forged answer nor a typed-ahead follow-up can bypass or
orphan the block. On host restart, an approval-responded call with no terminal
output is converted to output-error before queue recovery: the host cannot
prove whether its side effect completed before the crash, so it must not replay
the approved operation.
Three structural checks hold regardless of mode: cwd must be inside the exact
current workspace or the current session's scratch (never another registered
workspace or session), command args receive a defense-in-depth secret-root
check, and fs-edit tools retain their no-clobber protected-path guard
(fs/scope.ts: .git, rc/env files, lockfiles, agent config).
The long-lived sidecar's outer sandbox is only a coarse backstop. It does not
raw-spawn model commands: @grida/agent calls a host-injected ShellExecutor,
the sidecar sends one bounded command.request over its inherited private
channel, and Electron main independently canonicalizes the exact workspace,
session scratch, cwd, and host-owned scratch base. Main then asks srt for a
fresh kernel profile for that finite command. The profile denies the entire
shared scratch base and the daemon's secret userData, re-allows only this
session's scratch, and grants writes only to the exact workspace, exact
scratch, and a private per-command temp directory. It also denies SRT's shared
compatibility temp/log write defaults and gives the command no direct network
destination or local-bind authority. An interpreter that computes a sibling
scratch or userData path at runtime is therefore denied by the kernel, not
merely by argv inspection.
-
Abort is a cleanup barrier, not just a UI edge. A turn abort propagates through the AI SDK tool signal as
command.abort. Main keeps that command active until the executor has terminated the ordinary process group andAgentCommandHosthas removed its private temp and released SRT bookkeeping; only then does it return the terminalcommand.abortedacknowledgement. The agent runtime tracks both that command promise and the model-stream pump in the turn's settlement barrier, so replacement admission and session deletion remain HTTP 409 until the acknowledgement has been consumed and the aborted pump has settled. This orders cleanup for the authority the host actually owns; it does not strengthen the macOSsetsid(2)limitation below. -
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." Desktop passesdirect_network_access: "none"plushost_routed_provider_http: true. The first omits the daemon development baseline and agent/external-vendor destinations; the second documents that in-process provider/GG traffic uses the host transport. In the enforced macOS/Linux profile, pinned srt 0.0.65 receives an empty direct external allowlist andallow_local_bindingis false. The daemon remains reachable because Electron main owns the loopback listener and transfers only accepted connected sockets; the wrapped sidecar receives no generic local-connect authority. Windows currently lacks that kernel egress fence. CLI and other hosts retain the package's allowlisted defaults unless they explicitly choose the same strict construction mode. -
Fail-closed exposure (no executor ⇒ no shell).
sandbox_enforcedis an attestation about the coarse sidecar boundary, not command authority. The tenant registersrun_commandonly when its host injects aShellExecutor; omission is the default. Desktop injects the private main-owned executor only on platforms where SRT is enabled, so Windows gets fs/todos/skills but norun_commandand no external ACP agent. Thegrida-agentCLI — a local, user-invoked tool with no OS sandbox — uses the separately namedallow_unsandboxed_shellopt-in, which explicitly injects the raw runner and logs a warning. A boolean claim alone can never cause raw execution. -
Secret-dir containment (per command). The daemon's own secret dir — its
userData, where BYOKauth.json,workspaces.json,recent.json, and the sessions db live — is deliberately not in thesrtouter policy, because the sidecar itself must readauth.jsonfor provider calls. Electron main does not need that authority to execute a command, so every finite-command profile adds a kerneldeny_readanddeny_writeforuserData.validateShellRequeststill rejects an explicit arg resolving there as defense in depth, but computed interpreter paths are covered as well. HOME secrets (~/.ssh,~/.aws, shell rc files) remain denied in both outer and command profiles. The fs-edit tools (read_file) remain workspace/scratch-scoped and never serveuserData. -
autois informed-consent.autoremoves command-identity gating; the sandbox still bounds the blast radius (writes confined to writable roots, direct external network denied), 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-editsrequires approval for mutations except the narrow scratch-local copy/move operation described above.
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 + hosted AI. 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. The hosted "included" AI provider — Grida
Gateway (GG), the gg provider, GRIDA-SEC-006 — amends the old
"sidecar holds no cloud credentials" invariant to its precise form:
the sidecar may hold ONLY the purpose-scoped, short-lived AI token
(memory-only, pushed by the renderer over /auth/gg/set, never
auth.json, never a refresh token) — the durable session stays in the
webview cookie jar. Files registered under this record for that work
(all marked GRIDA-GG):
packages/grida-ai-agent/src/providers/{gg-session,gg,gg-media}.ts,
src/http/routes/gg-auth.ts, the gg arms in
src/providers/{index,resolve-image,resolve-video}.ts, and the
gg_host egress option in src/sandbox/policy.ts (used only by hosts without
host-routed provider HTTP). Electron main transports GG requests and can
transiently observe the scoped bearer header, but does not retain, persist,
return, or log it; steady-state token custody remains in the sidecar.
Entitlement enforcement stays server-side (the hosted endpoints gate + meter).
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 empty project directory (just the folder — no .canvas,
no manifest, no document of any kind). 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 three 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. There is
no seed field anymore: the route body is { name? } only, and the
registry writes nothing but the directory, so the earlier manifest-injection
surface is removed outright rather than field-constrained — whatever document
the workspace eventually holds is created by the AGENT through its own
already-bound (and separately-gated) fs write capability, not by this route.
The sidecar's structured fs writes remain inside its coarse outer srt
profile. A created project becomes an in-process workspace root for structured
tools; a shell command receives that exact canonical root again through the
main-owned per-command SRT profile rather than inheriting the union of every
opened workspace.
workspaces.create.test.ts pins traversal-name containment,
that the created project is empty (an unexpected seed body is inert), and the
no-managed-root refusal.
Read-only directory references. A native folder drop adds a deliberately
narrow host capability without registering the folder as a workspace or copying
its descendants into session scratch. Preload accepts only an OS-backed File
whose path Electron can resolve, and sends that raw path once to the authenticated
POST /directory-scopes route; the renderer receives only an opaque descriptor
with a virtual /__references__/dir_<uuid> path. The in-memory registry
canonicalizes the selected root, rejects either-direction overlap with the
daemon secret root and every sensitive-read root in the outer sandbox policy,
and bounds unclaimed grants by count and TTL. A run can atomically claim an exact
descriptor for one session only, before any incoming message or scratch mutation
is persisted. Persisted descriptors, copied/forked messages, expired grants, and
host restarts therefore cannot recreate authority; session deletion revokes the
live grant.
The agent filesystem exposes each claimed root only through lazy
list_files/read_file/grep_files traversal. It never hydrates the tree, adds
the root to shell cwd authority, or admits it to scratch/workspace writes;
write_file and edit_file return read_only. Every descendant operation uses
the daemon filesystem scope's realpath containment, so an escaping symlink is
rejected. Raw host paths never enter chat IR, model context, logs, or transport
responses. directory-scopes.test.ts, run-input.test.ts,
workspace-agent-bindings.test.ts, and agent.test.ts pin acquisition,
descriptor/session ownership, claim-before-persistence, no-path-persistence,
lazy reads, read-only mutation, and symlink escape 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 — native/BYOK/hosted/endpoint provider resolver; never exposes credentials to the renderer. The ChatGPT arm is additionally bound by GRIDA-SEC-008.
- 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 and binds a consumed human-input result to the exact resumed run through terminal recorder settlement.
- packages/grida-ai-agent/src/runtime/stream-registry.ts — in-flight run replay/abort registry; async model producers append and finish only through their exact
StreamEntrygeneration, so a late response or error from aborted turn A cannot mutate queued replacement B under the same session id. Explicit human abort remains session-keyed so it targets whichever turn is current. - packages/grida-ai-agent/src/runtime/session-scheduler.ts, status-sse.ts, and the scheduler contract tests — authoritative per-session run-state machine and its observation channel; classifies persisted approvals/questions, projects explicit waiting states after restart, and pauses/rechecks queue drain so an ordinary queued turn cannot run ahead of unresolved human input. Status-SSE hydration is deliberately read-only: only trusted lifecycle/mutation edges, host-start recovery, and provider-ready retries can schedule a queued turn.
- packages/grida-ai-agent/src/runtime/command-backend.ts — agent
run_commandadapter: validates cwd against only the current canonical workspace/own scratch, flushes structured writes, and delegates the exact immutable scope to a host-injected executor. It never raw-spawns. - 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 into BOTH surfaces from one source (deps.scratch_dir): the shell executor's exact scope 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): derives a host-namespaced base under a host-injected temp root and rejects lexical or physical overlap withuserDatain either direction before mutation. Authority creation is non-recursive; every predictable base/session level must be a non-symlink current-uid-owned directory and is tightened/verified to0700on POSIX, while an unsafe parent fails closed. Reclamation holds session admission, revalidates the authority before listing, and unlinks child symlinks rather than following them (per-session delete + synchronous host-start sweep). Desktop resolves that temp root in main before SRT can replace the sidecar'sTMPDIRwith a shared compatibility directory.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 and tools/human-input-result.ts — wire-message normalization +
coerceApprovalAnswer/applyApprovalAnswer(shape-gates the explicitapproval_answerbody field and atomically binds a valid answer to its exact consuming run), plus exact correlation and canonical output-schema validation before a renderer-authored question/design-search result can consume a pending interaction. - packages/grida-ai-agent/src/protocol/context.ts — renderer-safe, persistable directory-reference descriptor vocabulary; the virtual path and read-only access are fixed by the host contract, while the descriptor itself carries no authority.
- packages/grida-ai-agent/src/session/directory-scopes.ts and its contract tests — in-memory pending/session grant registry: realpath canonicalization, protected-root overlap refusal, bounded one-shot acquisition, exact atomic claim, exclusive session ownership, and lifecycle revocation.
- packages/grida-ai-agent/src/http/routes/directory-scopes.ts and its perimeter tests — sole raw-path ingress, mounted behind the composed daemon's Auth/Origin/Referer perimeter; returns only an opaque descriptor.
- packages/grida-ai-agent/src/session/store.ts, schema.ts, and db.ts — sessions store and durable schema; supervised approvals and client-resolved question/design-search results atomically receive an exact-run continuation marker only after exact server-authoritative correlation, and the startup-only orphan repair terminalizes abandoned non-human/answered approval calls and marked human interactions before queue recovery while preserving unanswered waits and unmarked completed history.
- 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; whole-file reads remain bounded separately at 1 MiB for source text/default byte reads and 8 MiB for the buffered workspace-resource route. This 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) in socketless mode and accepts only main-transferred daemon sockets.desktop/src/agent-sidecar-daemon-sockets.ts— injects only validated, already-connected socket capabilities into the unbound HTTP server; it exposes no listen, bind, connect, or target-selection operation.desktop/src/agent-sidecar-channel.ts,agent-network-policy.ts, andagent-sidecar-network.ts— strict private stdio framing, destination/header policy, the sidecar's explicit provider/provider-asset transport client, and the bounded finite-command request/result client. Command output is sequenced and capped; abort remains pending until main returnscommand.abortedafter worker cleanup; malformed or unknown frames fail the channel.desktop/src/main/agent-daemon-socket-host.ts— owns the exact loopback listener, pauses accepted sockets, rejects non-loopback peers, and transfers the bounded connected capability over per-spawn Node IPC.desktop/src/main/agent-network-host.tsandagent-network-authority.ts— main-owned Chromium network execution, bounded response streaming, redirect/route reauthorization, per-spawn built-in/custom grant state, and the capped private dispatch seam into the command host.desktop/src/main/agent-command-host.tsandmain/sandbox/manager.ts— main-owned finite-command execution: canonical exact-root validation, shared-scratch/secret denies, own-scratch/workspace/private-temp grants, a fixed no-network policy, fresh per-command SRT wrapping, raw spawn only after the wrapper succeeds, and terminal cleanup before abort acknowledgement.desktop/src/main/agent-sandbox-policy.ts— binds Desktop's strict sandbox posture: empty direct external egress, host-routed provider HTTP, and no generic local bind/connect authority.desktop/src/main/agent-sidecar-supervisor.ts— generates the per-spawn password; spawns/supervises the daemon sidecar; initializes the OS sandbox wrapper when supported; owns both private channels and removes direct provider hosts from the sidecar policy (Desktop deliberately withholds srt's alpha Windows backend pending a supported lifecycle).desktop/src/main/desktop-entry-window.ts— owns the exact bridge-attached entry window and admits auxiliary native windows only while the authenticated main role is active; the Grida-account transition is additionally bound by GRIDA-SEC-005.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,desktop/src/main/ipc-sender.ts, anddesktop/src/main/ipc-admission.ts— validate every native IPC sender frame, exact controller-owned window role, pathname, and channel before executing OS capabilities; redact query/fragment/userinfo from denied-sender diagnostics; and give sign-in/onboarding only their closed entry-role allowlists. Custom endpoint set/probe/delete additionally owns the native exact-origin grant ceremony.packages/grida-daemon/src/daemon-server.ts— lifecycle owner for the same guarded Hono app in either loopback-listening or socketless host-deliveredfetch(Request)mode; shutdown cancels and joins active response streams.packages/grida-daemon/src/http/server.ts— 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 and an optional development-network baseline);packages/grida-ai-agent/src/sandbox/policy.ts— the agent tenant's external-agent/provider policy and the Desktop construction switches that remove direct external networking.- 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). The fixed http://localhost callback for the native
ChatGPT model provider is a separate main-owned ceremony registered under
GRIDA-SEC-008; it must not reuse or widen this Grida-account deep-link arm.
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 by itself. Its only
deep-link-controlled navigation is the fixed same-origin
/desktop/auth/callback handoff to the one controller-owned entry window;
the cookie-held PKCE verifier remains the authority for exchange.
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. - Pure, stateless, fixed-target parser —
desktop/src/main/protocol-router.ts
has no Electron dependency, performs no code exchange, holds no auth state,
and never searches for or navigates a window. It reduces untrusted protocol
input to a closed native intent whose target is the constant
/desktop/auth/callbackpath on the configured editor origin, forwarding only the knowncode/error*params. Main then adds exactly one fixednative_entry=1provenance marker so the hosted callback can distinguish the current entry controller from legacy Desktop 0.0.13; custom-scheme input cannot supply, clear, or duplicate it. The marker is compatibility routing only, never authentication authority. Nothing else from the deep link crosses the boundary, and every branch consumes the URL (no re-queue loop). - Exact entry-window ownership —
desktop/src/main/desktop-entry-window.ts
owns the one canonical sign-in → onboarding → main
BrowserWindow. desktop/src/main.ts gives the parser's closed callback intent only to that controller—never a focused-window, first-window, or URL-matched fallback. The controller re-checks the exact configured origin and/desktop/auth/callbackpath before it hides and navigates its own window. Auth callbacks are control-plane events: while the entry role is booting or sign-in they bypass onboarding and work-file admission. Once onboarding or main is active, stale/world-invokable account callbacks are ignored so they cannot navigate a live workstation away from user work. - Exchange only at the same-origin callback; fixed compatibility handoff —
editor/app/desktop/auth/callback/route.ts
runs
exchangeCodeForSessionwith the cookie client (identical mechanism to the web(auth)/auth/callback). A successful marked callback redirects to the inert fixed completion route, while an unmarked callback preserves Desktop 0.0.13's fixed/desktop/welcomehandoff. Malformed markers fail to the legacy handoff; failure always redirects to the fixed desktop sign-in route. These are the only success destinations: neither route chooses onboarding, a workspace, or a caller-supplied destination. After a current-client exchange, the controller re-probes the cookie session through desktop/src/main/account-session.ts and the fixed/desktop/auth/meroute, combines only the resultingsigned-in/signed-outstate with native non-secret Desktop preferences, and chooses the next role. Before its first authenticated role decision, the current controller may load the fixed, hidden legacy onboarding migration surface once, read only the exact former onboarding boolean, persist that result and a durable migration marker in native preferences, and remove the renderer key best-effort. Renderer storage is never ongoing authority and is never consulted again. A transport, redirect, migration-probe, upstream-availability, or schema failure fails closed; none is evidence that the user is signed out or that migration completed. - Redirect containment; non-navigating sign-out — 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. The desktop route accepts only Electron main's explicitSec-Grida-Desktop-Account-Session: sign-outintent accompanied by browserless Fetch Metadata (Sec-Fetch-Site: none,Sec-Fetch-Mode: no-cors,Sec-Fetch-Dest: empty, and noOrigin). ThoseSec-headers cannot be authored by renderer fetch/XHR/form/service worker code, so a renderer cannot mutate the shared cookie around the controller. The route returns only a non-redirecting204on success; an HTTP response never selects the next native role. - One serialized global sign-out — desktop/src/main/ipc-handlers.ts accepts the account sign-out capability only from the exact Settings path, then delegates to the entry controller. The controller requires the main role and closes every other registered auxiliary window before mutating the shared cookie session. If an existing dirty-window close handler keeps any auxiliary window alive, sign-out aborts while credentials are still intact. Before the cookie mutation starts, the controller synchronously revokes IPC admission from every sender. Only after that close phase succeeds does desktop/src/main/account-session.ts POST to the fixed sign-out endpoint, reject redirects or non-success, treat that success as the irreversible cookie-mutation authority, and transition the same entry window to sign-in. An auxiliary Settings sender may stay hidden only long enough for Electron to serialize its successful invoke reply; it has no admitted IPC role in that interval and main then destroys it. A later account probe cannot roll back cookie deletion or leave the UI presenting a confirmed main role. Reconciliation that independently discovers signed-out state also clears the sidecar's short-lived hosted-account capacity before it hides auxiliary surfaces and presents sign-in.
- 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 the…://auth/callbackredirect is allowlisted in Supabase (supabase/config.toml locally; the hosted project's dashboard in production). The scheme is per-environment (#955): production returns togrida://, while local dev/insiders builds register and return togrida-dev://— so a machine running BOTH a dev build and the installed production Grida never has the two fight over one OS handler. The target is chosen from the build channel (process.env.NODE_ENVin the editor's auth-deeplink.ts;app.isPackaged/insiders in the desktop'senv.ts), never from request input, so it stays a fixed, non-attacker-controllable constant. The router (protocol-router.ts) accepts either scheme with byte-identical fixed-target behavior; the OS only ever delivers a build its own declared scheme.
Electron main holds no durable desktop account or provider credential.
Chromium's default session owns the HttpOnly cookie jar;
the entry account client invokes session.defaultSession.fetch only for the
two fixed same-origin account routes. Main never reads or exports cookie/token
material, and DesktopAccountSession returns only the three-state projection
needed by the entry controller—not the route's account payload. Main persists
only non-secret Desktop preferences separately from every credential store.
Its provider
broker does transiently route BYOK/GG request headers and bodies; the sidecar
owns persisted BYOK material and may hold the purpose-scoped, short-lived
hosted-AI token (GRIDA-SEC-006 — memory-only, renderer-pushed, never a refresh
token). The durable Grida account session is refreshed by the same
@supabase/ssr middleware machinery as the web app. The /desktop/* CSP keeps
connect-src closed, so renderer 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 — redirect allowlist entries:
grida://auth/callback(production) +grida-dev://auth/callback(local dev/insiders, #955). The hosted dashboard allowlists only the productiongrida://target. - editor/lib/desktop/auth-deeplink.ts — the single, per-environment
…://auth/callbackredirect-target constant (build-time, never request input). Must stay aligned with the desktop'sDEEP_LINK_SCHEME(desktop/src/env.ts). - 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; routes the fixed native-owned provenance marker to completion and preserves the fixed unmarked Desktop 0.0.13 welcome handoff, always within
/desktop/*(pinned by itsroute.test.ts). - editor/app/desktop/auth/complete/page.tsx — inert, fixed success handoff; contains no routing choice.
- editor/app/desktop/auth/migrate-onboarding/page.tsx — inert, fixed hidden surface on which main performs the one-time exact-key renderer-to-native onboarding migration; contains no migration logic or native capability.
- editor/app/desktop/auth/me/route.ts — no-store, same-origin account projection; distinguishes upstream unavailability from signed-out state (pinned by
route.test.ts). - editor/lib/desktop/account-session-state.ts — shared signed-in/signed-out/unavailable classification for the account projection and protected Desktop server routes; retryable or unclassified auth failures fail closed instead of rendering sign-in (pinned by
account-session-state.test.ts). - editor/app/desktop/_components/account-required.tsx — defense-in-depth projection of that classification onto authenticated Desktop routes; unavailable fails closed instead of rendering an extra sign-in surface (pinned by
account-required.test.tsx). - editor/app/desktop/auth/sign-out/route.ts — native-Fetch-Metadata-gated, non-redirecting sign-out (never renderer-callable and never the web
/sign-out; pinned byroute.test.ts). - desktop/src/deep-link.ts, desktop/src/env.ts, desktop/forge.config.ts, and desktop/scripts/prepare-dev-electron-branding.mjs — closed per-environment scheme vocabulary and registration; production and local/insiders never contend for one handler.
- desktop/src/main/open-handoff.ts + desktop/src/main.ts — collect OS/secondary-instance callback URLs, re-validate them through the parser, and deliver the closed intent only to the canonical entry controller.
- desktop/src/main/protocol-router.ts — stateless fixed-target auth arm; forwards only the closed callback allowlist and then adds the constant native-entry provenance marker (pinned by
protocol-router.test.ts). - desktop/src/main/account-session.ts — fixed-route, redirect-refusing account projection and native-intent sign-out over Chromium's cookie-owning session (pinned by
account-session.test.ts). - desktop/src/main/desktop-preferences.ts — non-secret, versioned native onboarding authority; owns the durable one-time renderer-migration marker, writes are serialized and atomic, future schemas are read-only, and no generic renderer preferences capability exists (pinned by
desktop-preferences.test.ts). - desktop/src/main/desktop-entry-window.ts — exact entry-window ownership, serialized role transitions, one-time fixed-surface onboarding migration, callback re-probe, role-aware blocked-navigation recovery, auxiliary-window admission, and close-before-sign-out ordering (pinned by
desktop-entry-window.test.ts). - desktop/src/main/ipc-handlers.ts + desktop/src/main/ipc-admission.ts — exact sender/role/path/channel validation for the one native account-sign-out transition and narrow sign-in/onboarding IPC capability sets (pinned by
ipc-admission.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, forwards params
beyond code/error*, or treats the native-added provenance marker as
authentication authority. A desktop webview navigation to
(auth) routes or the web /sign-out. Sidecar- or main-held SESSION
tokens (the scoped hosted-AI token is the one sanctioned exception,
registered under GRIDA-SEC-006 and the GRIDA-SEC-004 hosted-AI
paragraph). 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). Provider OAuth for
the native ChatGPT model provider: its fixed localhost callback, sidecar-held
credential, and model egress are the distinct GRIDA-SEC-008 boundary and never
produce a Grida webview cookie session.
Surface. This is the security half of Grida Gateway (GG) — the
tokensurface. Every file here carries bothGRIDA-SEC-006andGRIDA-GG: token; touching it runs thesecurityreview first, then theggsurface skill. The gateway endpoints, theggclient provider, and the desktop token wiring are the other GG surfaces (GRIDA-GG: gateway|provider|desktop), governed by the same skill. Domain spec: Hosted AI.
What it protects. The desktop app's hosted ("no-BYOK") AI calls are
authenticated by a purpose-scoped, short-lived, org-bound JWT — and
by nothing else. The /api/v1/ai/* endpoints never accept a Supabase
access token, a cookie session, or an API key; the mint route
(/desktop/auth/token) is the only place the token is created, and it
requires a live cookie session with verified org membership. The
boundary is the rule that the credential a native process holds for
AI must be worth at most 15 minutes of AI calls billed to an org the
user was a member of — and nothing more.
Vulnerable scenario (prevented). A desktop process credential leaks — a log line, a crash dump, a local proxy, an exfiltrated memory snapshot. If that credential were the user's Supabase access token, the attacker gets the user's whole API surface: RLS-scoped database reads, storage, profile — the account, for up to an hour, renewable if the refresh token ever traveled with it. With the scoped token the blast radius is: AI calls, on one org's credit, for ≤15 minutes, and the token is structurally useless everywhere else (audience check) — Supabase never even sees it.
Why it's specifically risky here. The sidecar is a long-lived local daemon that makes third-party network calls with credentials in memory; it is exactly the process class where credentials leak. And the desktop webview session (GRIDA-SEC-005) is a full first-class login — handing that to the daemon would collapse two carefully separated trust levels into one.
How the code prevents it.
- Mint only from a live cookie session, same-origin —
editor/app/desktop/auth/token/route.ts
requires
auth.getUser()and resolves the org through the GRIDA-SEC-003 verified resolver (session fallback, or explicitorg_idthroughrequireOrganizationId→assertOrgMember). The route accepts no org header — no new org-id trust input. CSRF is bounded by SameSite=Lax auth cookies + same-origin-only readability (no CORS on/desktop/*). - Scope is cryptographic, not conventional —
editor/lib/auth/gg-token.ts signs
HS256 with a dedicated server-only secret (
GG_TOKEN_SECRET), pinsalgorithms: ["HS256"](no alg-swap) andaud: "gg:ai". A Supabase token fails verification structurally (different keys), and this token fails everywhere Supabase tokens are accepted. - 15-minute expiry, 60s clock tolerance — the token is the unit of revocation; abandoning a leaked token requires no server state.
- Fail-closed secret handling — unset or <32-byte secret →
not_configured→ 503. There is no fallback path to a weaker credential. Rotation viaGG_TOKEN_SECRET_PREVIOUS(verify-only; signing always uses current; drop previous after15 min).
- Verification is local and exclusive — every
/api/v1/ai/*handler authenticates viaverifyGgTokenonly; none construct a Supabase client from the bearer value (createClientFromBeareris the other pattern, for first-party Supabase tokens on/private/*routes — grep-able invariant). - Custody doctrine (client half) — the daemon holds the token in
memory only: never
auth.json, never disk, never a refresh token. The webview session remains the only durable credential (GRIDA-SEC-005); the renderer re-mints and re-pushes. Provider requests use the GRIDA-SEC-004 host transport: Electron main necessarily observes the scoped bearer header and request body in transit, but does not retain, persist, return, or log them and never receives the durable webview session. - Mint rate limit —
rl:v1-ai:mintper-user sliding window (fail-open when Upstash is unconfigured; the billing gate on the AI endpoints is the actual spend control).
Residual risks (accepted, documented). Org-membership revocation is not re-checked within a token's ≤15-minute lifetime. The mint rate limit fails open without Upstash. In-flight AI requests at sign-out complete on their token rather than being aborted — expiry is the revocation mechanism. Electron main and any OS-trusted TLS-inspection proxy can observe the short-lived bearer while transporting a request; neither is a durable account-credential holder.
Files bound by this id. Run grep -rn GRIDA-SEC-006 . to enumerate.
Today:
Every file below also carries the GRIDA-GG surface marker (token /
gateway / provider); the gg skill
governs the surface, this record governs its security half.
- editor/lib/auth/gg-token.ts — sign/verify + secret handling (
GG_TOKEN_SECRET; pinned bygg-token.test.ts). - editor/app/desktop/auth/token/route.ts — the mint route (pinned by its
route.test.ts). editor/app/(api)/(public)/api/v1/ai/**— the hosted GG endpoints: OpenAI-compat chat/completions + models, Grida-native images/videos generations. Verify withverifyGgTokenEXCLUSIVELY; billed through the seam (pinned by contract tests driving the real@ai-sdk/openai-compatibleclient).- editor/lib/ai/openai-compat/ — the wire codec + error envelope + allowlist + rate limits.
- packages/grida-ai-agent/src/providers/gg-session.ts — the daemon's in-memory custody (30s expiry slack;
status()never returns the token; pinned by its test). - packages/grida-ai-agent/src/http/routes/gg-auth.ts —
/auth/gg/set|clear|statusbehind the daemon perimeter; token never logged (pinned by its test). - packages/grida-ai-agent/src/providers/gg.ts + gg-media.ts — hosted text/image/video adapters: per-request token reads, editor-origin-only egress, code-led typed errors (401→
gg_token_expired, 402→insufficient_credits), no upstream body text in thrown messages. - The
ggresolver arms (providers/index.ts, resolve-image, resolve-video) — precedence: explicit wins; implicit BYOK →gg→ endpoints. The/secrets/*allowlist keeps REJECTING theggid (no key may be stored under it; pinned bygg-auth.test.ts). - packages/grida-ai-agent/src/sandbox/policy.ts —
gg_hostegress for ambient-fetch hosts and its omission when provider HTTP is host-routed. - desktop/src/main/agent-network-host.ts — destination-bound Chromium transport; transiently carries the scoped Authorization header without persistence or renderer exposure.
What does NOT belong here. An AI endpoint that accepts Supabase
access tokens or cookies. A mint path without a live session or without
membership verification. The token (or a refresh token) persisted by
the daemon, main process, or auth.json. A signing fallback when the
secret is unset. A second minting location.
What it protects. The agent discovers skills by scanning directories
that may be attacker-controlled — a checked-out repo's .claude/skills /
.agents/skills, the user home, and the host-bundled skills/ tree — and
the skill tool then MATERIALIZES a chosen skill's directory into the
per-session scratch so its files are reachable by the workspace-scoped fs and
shell. The boundary is the rule that a skill lookup or load can only ever
touch files inside a skill directory it legitimately resolved, and can only
ever write into the already-sanctioned scratch root — never anywhere else on
disk, no matter what a hostile skill dir names or links to.
Vulnerable scenario (prevented). A user opens (or clones) a repo whose
.claude/skills/ contains a directory named to escape (../../../etc), or a
skill dir that is a symlink to /, or a SKILL.md symlinked to
~/.ssh/id_rsa, or a metadata.also_in_load: ["../../../../etc/passwd"], or a
skill tree with a symlink pointing at the user's home. Without the boundary,
discovery or load_skill would read those files into the model's context, or
the materialize copy would follow a link out of the tree and duplicate secrets
into scratch (where the shell can then exfiltrate them).
How the code prevents it.
- Name validation before any path is built —
packages/grida-ai-agent/src/skills/discovery.ts
rejects any directory/file entry whose basename is not
/^[a-z][a-z0-9-]*$/(the agentskills.io name grammar), so.., absolute paths, and path separators never become a skill name. - Realpath containment on every resolved
SKILL.md—isContainedcanonicalises the resolved path and the layer root and rejects anything that escapes the root, so a symlinked skill dir/file that points outside its layer is dropped from the listing (never read). - Materialize copies into scratch only, and never follows symlinks —
packages/grida-ai-agent/src/skills/materialize.ts
copies into
<scratch>/skills/<name>/(already the only sanctioned writable root) and itscopyTreehandles ONLY regular files and directories — symlinks, sockets, and fifos are skipped by omission, so a link inside a skill tree cannot smuggle an out-of-tree file into scratch. also_in_loadcompanions are containment-checked — a declared companion path that resolves outside the materialized dir (.., absolute) is skipped, not inlined.- Re-validation at LOAD time (discovery→load TOCTOU) — rule 2 runs when
the index is built, but the
skilltool loads a body later, and the filesystem can change in between (a checkout or a shell command swaps.agents/skills/foo, or the.agents/skillslayer itself, for a symlink).resolveSkillLoadPaths(discovery.ts) re-realpaths the skill dir + itsSKILL.md(or a flat<name>.md) and re-contains them against the discovery-time layer root (layer_root, captured + realpath'd when the index was built) — not a root recomputed at load, which a layer-dir swap would move in lockstep with the target. ThrowsSkillPathEscapeErroron escape and returns the canonical paths to read/copy. BOTH load paths — the materializing loader andnodeSkillBodyLoader— go through it, so no loader trusts a stale discovered string path.
Files bound by this id.
- packages/grida-ai-agent/src/skills/frontmatter.ts
—
SKILL_NAME_RE(rule 1's name grammar), imported by discovery. - packages/grida-ai-agent/src/skills/discovery.ts
- packages/grida-ai-agent/src/skills/materialize.ts
- packages/grida-ai-agent/src/skills/skills-fs.test.ts
- packages/grida-ai-agent/src/skills/materialize.test.ts
What does NOT belong here. Following a symlink out of a skill tree.
Accepting a skill name with a path separator. Materializing anywhere but the
session scratch. Reading a SKILL.md whose realpath escapes its layer root.
What it protects. Grida Desktop can use an eligible ChatGPT subscription as a native text-model provider. Grida still owns the agent loop, prompts, tools, approvals, sessions, and persistence; this path does not launch Codex, Codex app-server, or an ACP agent. The boundary is the rule that the world-invokable localhost OAuth callback can complete only the exact main/sidecar attempt that opened it, ChatGPT credentials never cross into the renderer, provider traffic can reach only its fixed auth/inference destinations, and a stored conversation never changes provider merely because ambient provider readiness changed.
Vulnerable scenario (prevented). A webpage or local process races a forged request into the fixed callback port and consumes the legitimate user's one-time attempt; a compromised renderer asks the bridge for access/refresh tokens; a late refresh for account A overwrites a newly connected account B; sign-out loses a race and a late exchange resurrects the deleted credential; an upstream error body containing provider details is serialized into the agent stream; or connecting ChatGPT silently moves an existing BYOK conversation onto a different cost/privacy boundary.
Why it is specifically risky here. Native-app OAuth client ids and loopback redirect URLs are public, localhost HTTP endpoints are reachable by webpages and local processes, the refresh token is long-lived, and the privileged renderer is hosted at a web origin. Electron main must also transiently transport token and inference bytes because the sandboxed sidecar has no direct provider egress. None of those components may be treated as trusted merely because it is “local.”
How the code prevents it.
- Two-owner OAuth ceremony. Electron main binds the callback before
browser navigation, on only the approved ports (
1455, then1457) and both available loopback families. The sidecar independently generates the one-use attempt id, random state, and PKCE S256 verifier/challenge for that exact redirect URI. Main accepts only boundedGETrequests for the exact/auth/callbackpath andlocalhost:<bound-port>Host, constant-time compares state, leaves the attempt usable after a mismatched callback, atomically claims one valid callback, and closes every listener/socket on success, denial, timeout, cancellation, or failure. Browser success is not rendered until token exchange and durable persistence complete. - Exact browser-open validation. Before
shell.openExternal, main requires the exacthttps://auth.openai.com/oauth/authorizeorigin/path, client, scope, response type, S256 challenge shape, configured product parameters, and the state and redirect URI of the currently bound attempt. Reserved parameters must occur exactly once; credentials, fragments, an alternate approved port, unknown query keys, and lookalike origins fail closed. - Secret custody and race-safe persistence. The sidecar alone owns code
exchange, access/refresh lifecycle, account metadata, and request-time
credential injection. One daemon
AuthStoreinstance serializes BYOK and OAuth writes into owner-readableauth.json(0600, atomic replacement). Refresh is single-flight and rotating refresh tokens are persisted before use. Compare-and-replace/remove guards prevent a late refresh or cancelled exchange from overwriting/removing a newer account. Exact attempt cancellation and a generation invalidation make sign-out win over in-flight exchange/refresh work. - Narrow renderer capability. Guarded IPC and the optional
window.grida.chatgptnamespace expose only connect, cancel, status, and sign-out. Electron main reconstructs the returned status DTO field by field; authorization URLs, codes, PKCE material, access tokens, refresh tokens, and raw token claims have no renderer type or route. Renderer status reads are epoch-ordered so an old read cannot visually restore a signed-out or previous account. - Pinned provider traffic. Desktop grants only the exact OpenAI token
endpoint and ChatGPT Responses endpoint to the authenticated provider lane;
neither is a provider-asset/download grant. The model adapter rejects any
other URL, injects bearer/account/product headers only at request time,
forces stateless Responses posture, performs at most one refresh/replay
after
401, consumes every non-success body, and maps it to a bounded code-led error before the AI SDK or agent stream can observe it. - Session/provider identity is sticky. A new provider-unqualified
compatible conversation may prefer a ready ChatGPT connection. Once
persisted, the provider/model pair is reused for continuations and queued
turns. Titling and compaction stay on that provider and credential/cost
boundary but may use its lower-cost auxiliary model tier; unavailability
returns
provider_downrather than falling through. An explicit provider, or a request-supplied different model, is the intentional switch seam. - No credential borrowing and no ACP fallback. Grida never reads
~/.codex/auth.json, browser cookies, or another app's credential store. ChatGPT auth failure never launches or selects Codex ACP.
Current experimental trust posture. The implementation follows the public
Codex/Zed-compatible native flow: unless
GRIDA_CHATGPT_OAUTH_CLIENT_ID supplies a replacement, Desktop uses the
public Codex native client id, with originator=grida, and exposes a closed
static model allowlist. That client/backend identity and the allowlist are not
a documented general third-party OpenAI contract; support, terms, model
availability, and a stable Grida registration remain release gates outside
this repository.
Account metadata is accepted only from the successful response of the exact pinned HTTPS token endpoint (or from a token payload delivered in that response), after state, PKCE, and code exchange. Token payload parsing checks shape and expiry but does not independently perform JOSE signature, issuer, audience, or nonce validation. The security claim therefore rests on the authenticated token-endpoint response, not on generic OIDC JWT verification. A future claim source outside that response must add full cryptographic OIDC validation before use.
Long-lived OAuth material currently uses the daemon's owner-only atomic file store, not an operating-system keychain. This prevents renderer/network exposure and cross-write races but does not protect against another process already running as the same OS user. Moving refresh-token at-rest custody to a platform credential store is a separate hardening step.
Files bound by this id. Run grep -rn GRIDA-SEC-008 . to enumerate.
The load-bearing groups are:
desktop/src/chatgpt-configuration.ts,desktop/src/main/{oauth-loopback-callback,chatgpt-oauth}.ts,desktop/src/{agent-network-policy,agent-sidecar,preload}.ts,desktop/src/bridge/contract.ts,desktop/src/main/ipc-handlers.ts, anddesktop/src/main.ts— fixed configuration, callback/orchestration, exact network grants, sidecar construction, guarded bridge, and shutdown.packages/grida-ai-agent/src/protocol/{chatgpt,provider-ids,endpoints}.ts,providers/{chatgpt-credentials,chatgpt,index}.ts,http/routes/chatgpt-auth.ts,server.ts,runtime/{index,run-input}.ts, andsrc/index.ts— safe vocabulary, OAuth lifecycle, model adapter, private route, provider/session resolution, and public type projection.packages/grida-daemon/src/auth/file.tsandpackages/grida-daemon/src/http/server.ts— shared race-safe credential persistence.packages/grida-desktop-bridge/src/index.ts,editor/lib/desktop/{bridge,chatgpt-subscription}.ts, andeditor/lib/agent-chat/bridge-transport.ts— secret-free renderer contract, ordered status cache, and explicit provider carriage.- The adjacent
*.test.tsfiles tagged with this id pin callback replay and bounds, authorization correlation, cancellation/sign-out races, credential rotation/reauth races, safe error mapping, model wire shape and tool loops, provider stickiness/compaction, bridge DTOs, and renderer ordering. docs/wg/ai/agent/chatgpt-subscription-provider.md,docs/wg/desktop/{agent-security,process-model}.md,desktop/docs/chatgpt-subscription-oauth.md, andpackages/grida-ai-agent/docs/chatgpt-subscription-provider.md— normative and implementation bindings.
What does NOT belong here. A renderer method that accepts or returns token, code, verifier, raw claim, or authorization-URL material. A generic localhost listener, callback path, redirect, provider origin, download grant, or arbitrary fetch. Cookie scraping, importing Codex credentials, direct sidecar egress, silent provider fallback, unbounded/replayed callbacks, upstream response bodies in errors, or treating ChatGPT sign-in as Grida account sign-in, an OpenAI API key, Codex app-server, or ACP.
What it protects. The optional Library importer holds a Supabase
service-role key and executes SQL through psql. It may target only the
Supabase stack started for this checkout: exact host 127.0.0.1, the API and
database ports declared in supabase/config.toml, and each endpoint's expected
scheme and shape. Environment variables, command-line URLs, and caller-supplied
keys are never destination authority.
Vulnerable scenario (prevented). A developer has production Supabase values in the shell or copies a hosted DSN and key into a seed command. The fixture importer then writes hundreds of assets and catalog/vector rows into production with RLS-bypassing authority.
Why it's specifically risky here. This is a bulk, idempotent-by-content-hash fixture importer, not an application request. It uses service-role Storage calls and direct SQL, so an accidental remote destination would have a large blast radius and ordinary RLS would not contain it.
How the code prevents it.
- Destination credentials and endpoints come only from
supabase status -o json, run at the discovered repository root. - The expected API and database ports come from that checkout's
supabase/config.toml; missing or malformed configuration fails closed. - API and database URLs are validated independently against canonical
127.0.0.1endpoint shapes.localhost, IPv6 and alternate loopback addresses, wrong schemes/ports/paths, URL overrides, queries/fragments, and remote or lookalike hosts are rejected. - Local service-role HTTP requests use a proxy-disabled opener and reject
redirects. Direct SQL strips inherited libpq
PG*connection settings, so neither environment can redirect a validated loopback URL. - The destination is validated before archive resolution or download and before the Storage/SQL writer accepts it.
- Adjacent tests pin the accepted local shape, negative cases, write-layer revalidation, transport isolation, and fail-before-download ordering.
Files bound by this id. Run grep -rn GRIDA-SEC-009 . to enumerate.
Today:
- .agents/skills/opt-library/SKILL.md — operator contract.
- .agents/skills/opt-library/scripts/seed.py — configuration parsing, exact endpoint checks, command ordering, proxy/redirect isolation, libpq environment isolation, and write-layer revalidation.
- .agents/skills/opt-library/scripts/test_seed.py — positive, negative, and ordering regression tests.
- Allocate the next sequential id (
GRIDA-SEC-010for 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.