If you find a vulnerability in Invincible, please open a private security advisory on GitHub (or email the maintainer) rather than a public issue with exploit details.
| Never commit | Where it lives |
|---|---|
AI_GATEWAY_API_KEY |
Vercel project env only |
HARNESS_ARTIFACT_TOKEN |
Vercel (Actions: Read PAT for artifact download) |
VERCEL_DEPLOY_HOOK_URL |
GitHub Actions secrets |
SANDBOX_TOKEN |
BYO daemon / local bootstrap only — not a Vercel product-routing secret (product tool turns resolve credentials from DB grants); sandbox process env; never client/Wasm |
DATABASE_URL |
Vercel / local only (prefer pooled Neon/PgBouncer URL) |
REDIS_URL |
Vercel project env only (optional BYO multi-session Redis; node-redis RESP — the URL embeds the credential redis://default:<secret>@<host>:<port>). Never log it; never NEXT_PUBLIC_*. Old SESSION_REDIS_* / UPSTASH_REDIS_REST_* names are removed — if present, the store logs a one-time (value-free) deprecation hint then 503s until REDIS_URL is set |
BLOB_READ_WRITE_TOKEN |
Vercel project env only (phase 0 #515 — Vercel Blob transcript store; BYO S3/R2 creds behind the same seam). Server mints short-lived scoped upload URLs; the client never holds the credential. Never log it; never NEXT_PUBLIC_* |
CREDENTIALS_ENCRYPTION_KEY |
Vercel / local only — base64 32-byte AES-256-GCM AMK (wraps per-tenant DEKs; tokens encrypt under DEK) |
AUTH_SECRET |
Auth.js session secret — set on Vercel after migrate + the first-run sign-up bootstrap |
AUTH_OIDC_CLIENT_SECRET |
Optional OIDC client secret — Vercel/server only; never NEXT_PUBLIC_* |
SCIM_BEARER_TOKEN |
Optional SCIM shared bearer — Vercel/server only; IdP → /api/scim/v2; never client/Wasm |
| Runner registration tokens, DO API tokens | Operator machines only |
VERCEL_TOKEN (GHA secret for dev-image-build VCR push) |
GitHub Actions only — docker login password to vcr.vercel.com; never commit; never echo in logs/summaries |
VERCEL_TEAM_ID / VCR_IMAGE_PREFIX (GHA vars for dogfood image) |
Identifiers for VCR push; not app runtime env; never put production DB/Gateway secrets in the dogfood image |
Session blobs and Wasm must never contain API keys or sandbox tokens.
Never use NEXT_PUBLIC_SANDBOX_* (or any client-exposed sandbox secret).
| Surface | Trust rule |
|---|---|
Browser localStorage / memory |
UX convenience only; same-origin; not multi-tenant isolation |
| Cloud store (Redis multi-session) | id-shaped /api/sessions*; one record per {tenant,user,sessionId} in the harness:session:{tenant}:{user}:{id} keyspace; ownership always server-derived from the authenticated user — never client-supplied tenant/user |
| Envelope + Blob transcript (phase 0 #515) | Redis keeps only the small envelope (harness:envelope:{tenant}:{user}:{id}: ownership/LWW/createdAt/reserved meta incl. meta.transcriptPointer); the transcript lives in Vercel Blob (or BYO S3/R2) objects. Server mints short-lived, scoped, credential-checked upload URLs via POST /api/sessions/:id/transcript; the client PUTs objects directly to Blob (never the server credential). Wasm never talks to Blob or Redis |
| API | GET/POST /api/sessions + GET/PUT/DELETE /api/sessions/:id + PUT/GET /api/sessions/:id/envelope + POST/GET /api/sessions/:id/transcript — signed in + middleware-protected; write key = the path :id, body id must equal the path id |
| Unauthenticated | 401 — client disables cloud sync for the page load |
| No such session / other user | 404 NOT_FOUND (no existence leak — never 403) |
| Store unavailable | 503 SESSION_STORE_UNAVAILABLE — local continues; response never includes host/port/REDIS_URL |
| Cross-user isolation | Tenant via loadSoleMembership (server); other-user id → 404; ids/fn restricted to Redis-safe ^[A-Za-z0-9_-]{1,512}$ so no glob/key bleed |
| Minting | Server mints UUID session ids; a brand-new session seeds updatedAt: 0 |
| Conflict | LWW on updatedAt (epoch ms); stale PUT → 409 + server record |
| Caps (abuse / size) | No message-count cap; ≤262 144 UTF-8 bytes per message text; Function-carried full-record body ≤2 MiB (HARNESS_SESSION_MAX_FUNCTION_BODY_BYTES — the primary wire bound; never a one-shot >4.5 MB Function payload), with an 8 MiB Blob transcript-object ceiling only on the client→Blob path (HARNESS_SESSION_MAX_BODY_BYTES); record id ≤512; meta is schema-typed reserved (title/legacySnapshotId/logicalCwd/activeSandboxId/personaId/personaSnapshot/transcriptPointer/attachedSkills) + 1 MiB serialized size cap |
| Blob contents | Message roles/text/ids/timestamps + reserved meta scalars only — never Gateway keys, sandbox tokens, MCP secrets, PATs, or host absolute paths |
| Transcript object security (phase 0 #515) | Transcript object ids are Redis-safe opaque (meta.transcriptPointer); envelope/object read trust-but-verifies (identity mismatch fails closed); minted upload URL is short-lived, scoped, credential-checked (never a static token in client/Wasm); full-record GET stays only for legacy roll-forward while those blobs remain small |
cwd |
Session-owned workspace-relative field (P1/GAP-1, #452) — stored on the cloud record as meta.logicalCwd; the host-absolute path is never stored (shared predicate re-sanitizes on parse) |
REDIS_URL |
Single RESP wire URL (redis:///rediss://) embeds the credential — never log/echo it or NEXT_PUBLIC_*; dual-store REDIS_URL == Vercel Production env == GHA secret |
| Backfill | One-shot Postgres harness_sessions → Redis via GHA sessions-redis-backfill (per-{tenant,user} marker, idempotent); Postgres becomes a read-only archive; legacy /api/session write route removed |
| Client bundle | Session repository is client-safe (lib/sessionRepository.ts); must not import server db / Drizzle modules |
Product behavior: docs/session-model.md.
When BUILTIN_HTTP_FETCH=sandbox, agent tools may fetch public HTTPS URLs via a
durable HTTP/curl Vercel Sandbox instance (hop B) — attach-only to a name the
user created under Settings → Sandbox. App-side SSRF
policy runs first (https-only; no private/metadata hosts; redirects only after
re-check of each Location). Never put Gateway, BYO sandbox, or MCP secrets into
the Sandbox child env. No NEXT_PUBLIC_* for this feature. Instance names and
control-plane credentials never enter client/Wasm. See docs/builtin-http.md.
Residual (v1): Policy is preflight-only on the app (literal + DNS at check
time). Hop B re-resolves the hostname under Sandbox networkPolicy: allow-all.
A short-TTL / DNS-rebinding name could flip to a private or link-local address
between preflight and curl. Redirects are followed hop-by-hop with the same
policy on each Location (curl still --max-redirs 0 so the microVM never
blind-follows). Accept residual DNS rebinding for v1; harden later (IP pin /
egress deny) if product needs stronger guarantees.
Zig builds run on a self-hosted GitHub Actions runner (default labels: self-hosted, invincible, zig).
To reduce abuse risk when this repository is public:
- Default triggers on self-hosted workflows:
pushtomain(path-filtered) andworkflow_dispatch. build-harnessonly also runs onpull_request→mainwhen all of:- head branch is in this repository (
head.repo.full_name == github.repository) — not forks; author_associationis one ofOWNER/MEMBER/COLLABORATOR/CONTRIBUTOR;- path filter matches harness sources / this workflow.
- Never
pull_request_target. Never Vercel deploy hook on PR. Other self-hosted workflows stay main/workflow_dispatchonly.
- head branch is in this repository (
- Jobs include
if:guards:- Opt-in:
vars.SELF_HOSTED_BUILDS == 'true'(repository Actions variable, not a secret), or - Origin grandfather:
github.repository == 'btipling/invincible'(maintainer continuity if the variable is unset) - and event is
workflow_dispatch,pushtomain, or the same-repo contributorpull_requestrules above (build-harness).
- Opt-in:
- Clones / forks must set
SELF_HOSTED_BUILDS=trueafter attaching their own runner. Without that variable, self-hosted jobs skip (safe default). - Optional:
vars.RUNNER_LABELSas a JSON array (e.g.["self-hosted","invincible","zig"]). If unset, workflows use that default list viafromJSON. - Do not add fork PR or
pull_request_targetbuilds on self-hosted without a deliberate design review. Expanding PR CI beyond same-repo contributor heads is a security change. - Prefer GitHub setting: require approval for first-time contributors’ workflows; keep fork PR workflows off the self-hosted pool.
- Host inventory (IPs, droplet IDs) is not published in this repo — keep private notes offline.
| Setting | Kind | Purpose |
|---|---|---|
SELF_HOSTED_BUILDS |
Actions variable (true) |
Enable self-hosted jobs on this repository |
RUNNER_LABELS |
Actions variable (JSON array) | Optional runs-on labels override |
VERCEL_DEPLOY_HOOK_URL |
Actions secret | Post-artifact redeploy (origin; main / dispatch only) |
Maintainers: still harden the VM (SSH keys, firewall, unattended upgrades) using private runbooks; public docs stay abstract.
The agent sandbox is an optional remote workspace for model tools
(list_dir / read_file / write_file / exec). It is not the
self-hosted GHA runner that compiles Zig.
Each sandbox row may use backend=byo (URL + DEK-encrypted
token) or backend=vercel (host Vercel project OIDC; optional image ref).
Users Create durable Workspace/HTTP instances in Settings; the agent only
attaches (never Sandbox.create / getOrCreate on a turn). Destroy removes
the platform VM and the DB row. There is no product host env
SANDBOX_BACKEND. Registry credentials for custom images stay on the host
Vercel/CI side — never in the DB. Token rotate applies to byo only. Per-user
instance names are server-generated and never exposed as client secrets.
Dogfood image push (GHA dev-image-build): builds a toolchain OCI image
from dev/Dockerfile and pushes to Vercel Container Registry. Uses Actions
VERCEL_TOKEN + team/prefix identifiers only. Never bake
AI_GATEWAY_API_KEY, DATABASE_URL, sandbox tokens, or AMK/DEK material into
image layers. The dogfood image is not the self-hosted Zig build-harness
runner.
| Rule | Detail |
|---|---|
| Separate process | Dedicated OS user/unit; do not share Actions credentials with the sandbox env |
| Server-only calls | Only the BYO daemon holds SANDBOX_TOKEN (its bearer-gated /v1/*); product tool turns call the daemon via DB-grant credentials, not env SANDBOX_TOKEN |
| Path jail | Workspace root + symlink-safe resolve; argv-only exec with timeouts |
| Public inventory | Host IPs / droplet IDs stay offline (docs/sandbox.md) |
| No PR trigger surface | Sandbox is not executed by untrusted PR workflows |
| Daemon version gate | BYO daemons behind the expected daemonVersion return 426 out-of-date (exact string + code); never mapped to Sandbox access denied. 403. Client probes /health once per instance; missing daemonVersion = 0 |
/health discloses the jail root |
Since daemon v2, GET /health returns the low-sensitivity workspaceRoot (per-binding jail root) without bearer auth, alongside version/daemonVersion. It is the resolved root (resolveWorkspaceRoot, realpath — not the raw env string), read fail-closed by the client (absolute, control-char-free, no bare /, no ///trailing slash, no ..; everything else → null). Liveness is not coupled to realpath — if the jail root cannot be resolved, /health still returns 200 + version/daemonVersion with workspaceRoot omitted (a missing-jail-root boot race is an FS-ops concern, never a liveness/version blank). It is reachable only on the token-private daemon port and is already visible to any bearer-token holder via exec pwd, so this is not a new secret class. All FS mutation stays /v1/* token-gated; keep other host paths/IDs private and do not expose the daemon port publicly |
| Auto-update trust | Opt-in SANDBOX_AUTO_UPDATE runs git fetch + ff-only merge on SANDBOX_GIT_DIR, then exits for supervisor restart. Fails closed on divergent/dirty checkouts (stays up, keeps serving 426). Uses a local repo checkout / optional read-only deploy key — never Actions or GitHub write credentials in the sandbox unit env |
Inference is server-side only (POST /api/chat, POST /api/agent). Report
client-side key or sandbox-token exposure immediately.
Agent SSE: when Accept: text/event-stream, event string fields (text,
summary, error, tool names, etc.) are redacted with the same secret list as
JSON responses before they hit the wire. Never put Gateway keys, sandbox tokens,
provider/MCP secrets, or raw DEK material in stream payloads. See
docs/agent-stream.md.
Sandbox inventory / tool surface (GET /api/sandboxes): non-secret,
auth-gated (middleware + in-route requireSessionUser; 401 unauth).
Returns the user's allowed sandboxes { id, name, slug, backend, status, image, canRead, canWrite, usable, granted } and, when ?sandboxId= (the session-owned
active id, Redis-safe) is supplied, an active bind with its permission-aware
tool-surface descriptor (lib/tenancy/sandboxTools.ts). Always omits
base_url / token_ciphertext / host inventory. A provided-but-unusable active
id is 403 (mirrors resolve) — never a stubbed active; a present-but-non-Redis-safe ?sandboxId= is 400 (matches parseAgentBody, not silently active: null). The session binding
itself is server-authoritative: switching routes tools via
resolveAgentSandbox's init.requestedSandboxId, never via a client-chosen
secret.
Agent personas (GET /api/personas + server injection): persona bodies are
non-secret user content (plaintext in user_personas, no DEK). Operator
rule: never store API keys, tokens, or credentials inside a persona — put them in
the real secrets surface (per-user MCP, GitHub PAT, sandbox env). The picker only
receives summaries (id/name/slug/isDefault/updatedAt); the body is resolved
server-side by id and injected as a system-preamble snapshot
(meta.personaSnapshot, ≤ 512 KiB). Nothing secret is added to the client bundle
or the harness Wasm. See docs/personas.md.
| Rule | Detail |
|---|---|
| Triple-env gate | Tenancy is always required: DATABASE_URL and AUTH_SECRET and CREDENTIALS_ENCRYPTION_KEY must be set — no separate AUTH_ENABLED |
| Tokens at rest | Envelope: env AMK (CREDENTIALS_ENCRYPTION_KEY) wraps each per-tenant DEK; sandbox bearer secrets AES-256-GCM under that tenant’s DEK only. Decrypt server-side for agent tools / admin mask only |
| Provider secrets (BYOK) | Ciphertext under tenant DEK only (no AMK dual-read path). Admin mask only; never plaintext in client/Wasm/logs. Schema migrate: GHA db-migrate |
| Per-user MCP API keys | Ciphertext under tenant DEK on user_mcp_servers. Settings mask only; never plaintext in client/Wasm/logs. HTTPS-only URL policy + no redirect follow (SSRF). Schema: GHA db-migrate. Ops: docs/mcp.md |
| Per-user GitHub PAT | Ciphertext under tenant DEK on user_github_tokens. Settings mask only; decrypt server-side for sandbox exec inject as GH_TOKEN + GITHUB_TOKEN (omit when unset). Never client/Wasm/image/host env. Schema: GHA db-migrate. Ops: docs/sandbox.md |
| Inference (BYOK) | Chat/agent always attach request-scoped providerOptions.gateway.byok + only for a granted model. Never route via a host env-model. Unauthorized / empty grants → 4xx |
| Residual (platform) | Invincible does not fall back to host env-model routing. Vercel AI Gateway remains a third party: (1) BYOK requires paid AI Gateway credits on the Vercel team — free tier does not allow request-scoped BYOK even with valid provider keys (pricing); (2) misconfigured BYOK / provider errors still surface from the platform. Mitigate with always-send BYOK, only: [provider], top up credits, surface errors, redact secret material from error JSON |
| Redaction | Inference error paths redact provider secret material via resolve redact lists |
| Dual-read cutover | TENANT_TOKEN_DECRYPT_MODE: default dual (DEK then AMK) until backfill verified; then dek-only. Order: dual-read app live → GHA db-tenancy-backfill-deks (confirm=backfill, job sets ALLOW_TENANT_DEK_BACKFILL=1) → verify → dek-only. Never backfill under AMK-only runtime |
| DEK rotate | Owner-only (rotateTenantDek / /admin); re-encrypts that tenant’s sandbox tokens, provider secrets, MCP header ciphertexts, and user GitHub PAT ciphertext; never shows DEK/token/plaintext. Other tenants untouched |
| AMK rotate | Not automated. Changing Production AMK without a re-wrap tool breaks all DEK unwraps. Keep GHA CREDENTIALS_ENCRYPTION_KEY === Vercel Production AMK (dual-store). Re-wrap is a future sequel |
| Never client | No NEXT_PUBLIC_* for DB, Auth.js secret, AMK/DEK, sandbox token, provider API keys, MCP API keys, user GitHub PATs, OIDC client secret, or SCIM bearer |
| Preview isolation | Use a separate DB on public previews; avoid reusing Production AMK, OIDC client secret, or SCIM_BEARER_TOKEN casually |
| Bootstrap vs backfill vs schema | Bootstrap = the app's first-run sign-up on /login (no env, no seed script). Existing Production data / AMK→DEK cutover = GHA db-tenancy-backfill-deks only. Schema-only = GHA db-migrate. Never re-seed a bootstrapped DB — it would add a second tenant and break the sole-tenant join |
| Ops surface | Schema/backfill via GitHub Actions: db-migrate (schema), db-tenancy-backfill-deks (AMK→DEK data), sessions-redis-backfill — or a cloud agent workspace. Not personal-laptop primary ops |
| OIDC (optional) | AUTH_OIDC_ISSUER + AUTH_OIDC_CLIENT_ID + AUTH_OIDC_CLIENT_SECRET (+ optional AUTH_OIDC_LABEL); provider id oidc; callback /api/auth/callback/oidc; email auto-link requires verified email_verified claim |
| SCIM (optional) | SCIM_BEARER_TOKEN (feature-env only, no tenancy-gate); base /api/scim/v2; off → 404; bad Bearer → 401; DELETE = suspend |
| Hybrid roster | SCIM is additive — non-SCIM users remain; /admin lists all provision sources; SCIM list = SCIM-managed only |
| Break-glass | Credentials login always remains; SCIM must not suspend break-glass credentials owner |
Unauthenticated API returns 401 with JSON
{ "error": "Authentication required." } (stable error constant
AUTH_REQUIRED_ERROR). Sandbox grant failures return 403
{ "error": "Sandbox access denied." } (SANDBOX_FORBIDDEN_ERROR). Multiple usable sandboxes without a Settings preference → 403 selection-required message.
Inference grant / model failures return 403 / 400
(INFERENCE_FORBIDDEN_ERROR / INFERENCE_MODEL_REQUIRED_ERROR); temporary
resolve/catalog failures return 503 (INFERENCE_UNAVAILABLE_ERROR).
Cutover: docs/bring-your-own.md §4a.
OIDC / SCIM operator notes: docs/bring-your-own.md §4b.