Skip to main content

Ever Works — Threat Model

Status: Living document. First draft 2026-05-17, in response to the 2026-05-17 platform security audit (knowledge/security/audits/2026-05-17-ever-works-platform-security-audit.md in the operator workspace repository). Owner: Platform team.

The platform is unusually multi-surfaced: REST API, Next.js web app, MCP server, CLI, 39-plugin runtime, agentic CLI orchestrator (claude-code / codex / gemini / opencode), and multi-cluster k8s. The audit flagged that the codebase "oscillates between 'open-source self-hosted' assumptions and 'managed multi-tenant SaaS' assumptions, and the two are incompatible." This doc picks a posture and asks the rest of the code to converge.


1. Tenancy posture (chosen)​

Ever Works is a managed multi-tenant SaaS that also ships as open-source self-hostable. The trust model below describes the hosted SaaS at apps.ever.works; self-hosters inherit the same model plus whatever additional risks their own infra introduces.

Concretely, on the hosted SaaS:

  • Tenant = User (or anonymous user). Per-user secrets live in User.plugins.secretSettings and (after C-08) are AES-256-GCM encrypted at rest.
  • Cross-tenant isolation is enforced in application code (the API layer's WorkOwnershipService.ensureAccess), not at the DB or network layer. There is no Postgres row-level security and no per-tenant DB. A bug in ensureAccess is a cross-tenant breach; treat ownership checks as security-critical and test them with the same rigor as auth code.
  • Plugins are 1st-party only. The runtime technically loads any ESM package that ships an everworks.plugin manifest, but the hosted platform refuses to install community plugins until plugin signing / integrity verification ships (see §6 below). Self-hosters who load community plugins do so at their own risk and must trust the plugin author as if they had given them shell on their server.
  • The agentic CLI orchestrator (claude-code, codex, gemini, opencode) runs with the API process's filesystem permissions, not a sandbox. This is intentional — the entire value proposition is "AI that writes code on your behalf" — but it means prompt-injection inside the agent loop is equivalent to RCE on the API host. The envelope around the agent (env allow-list per C-10, workspace path enforcement per H-23, SSRF guard per H-09/H-10/H-11) is the mitigation.

2. Trust boundaries​

EdgeDirectionTrust
Public internet → api.ever.works / app.ever.worksinboundUntrusted. All input passes the global ValidationPipe { whitelist, transform, forbidNonWhitelisted }. Public endpoints additionally require a captcha / throttle (anonymous flow) or are read-only.
Public internet → mcp.ever.worksinboundAuthenticated, via shared EVER_WORKS_API_KEY (legacy) or per-user OAuth (H-21 — once the dual-mode lands). The shared-key path forwards the caller's JWT to the upstream API for tenant resolution; without the JWT, the call is rejected.
Trigger.dev worker → /internal/trigger/* on the APIinbound (from a third-party SaaS)Authenticated via TRIGGER_INTERNAL_SECRET (length-padded timingSafeEqual per C-05). RPC surface limited to an explicit per-{service, method} allow-list (C-05 RPC half).
API → external HTTP (extractors, screenshots, webhooks, OAuth)outboundSSRF-guarded via isSafeWebhookUrl (H-09 / H-10 / H-11). Re-resolution after redirects is on the roadmap (M-23).
API → claude-code / codex / gemini / opencode subprocessoutboundSubprocess env is a strict allow-list (C-10): PATH, HOME, TMPDIR, proxy/CA vars, and provider-specific ANTHROPIC_* / CLAUDE_CODE_* / equivalent. No DB creds, no AUTH_SECRET, no plugin keys cross the boundary.
API → managed-agent SaaS (Anthropic, OpenAI, Mistral)outboundTrust the SaaS to handle inputs safely; the platform must NOT echo unfiltered tenant data into the prompt without delimiters (queued for prompt-injection canary work).
API → PostgresoutboundTrusted, but the connection uses DATABASE_SSL_MODE=true in stage/prod. Migrations run via entrypoint.sh; synchronize is forced off (C-07).
Agent workspace /tmp/<userId>/<workId>/localThe agent can read/write inside the workspace freely. H-23 ensures it can't escape via absolute paths in tool calls; L-32 ensures cleanup can't rm outside the workspace; H-22 ensures <userId> / <workId> are UUIDs before they become path components.
Community GitHub PRsinbound (untrusted)LLM-extracted items pass cliItemSchema (M-26) before they're written; source_url requires http/https (rejects javascript:); per-PR author allow-list and item cap (C-11).
Plugin packages on disklocalFirst-party trust. Community plugins are explicitly not supported on the hosted SaaS until C-12 (signing / integrity verification) lands.
API → App Work repositories (fork, private copy, link)outbound (untrusted input)Untrusted content + a token acting for the user. Repository text is hostile input (rule 9); the platform writes one workflow file and EW_ Actions secrets, never overwriting a hand-edited one. See §B-1, §B-3.
API → App Work clusters (a user-pasted kubeconfig)outbound (untrusted credential)Refused before any connection. A kubeconfig that shells out, uses a token file or skipped verification is refused; https and public addresses only; dialled from an isolated worker, never the API. See §B-4.
Tenant workloads → Ever Works Apps zoneinbound (untrusted workload)Admitted only while the tier gate is green. One namespace per App Work, sandboxed runtime, non-root, no service-account token, deny-by-default networking and no platform credential in a tenant. See §B-5.
Ever Works → upstream maintainers (PRs)outbound in the member's nameThe member's own token and the member's own approval. Never a platform or installation token, never auto-approved or bulk-approved, rate-limited; the platform never merges or comments upstream. See §B-9.
Launcher hosts → API (delegated read)inboundRead-only, on marked routes only. @DelegatedRead(scope) handlers, audience ever-works, lifetime ≤ 3,600 s, and ≤ 50 https origins from an operator allow-list; anything else is refused. See §B-7.
Runtime catalogs → builds and launcherinbound (supply chain)Curated, but a supply chain. Rows are validated and sanitized, a red licence class drops a row whatever it claims, licence class gates hosting, and only verified Blueprints reach the managed tier. See §B-6.
Git provider base URL ← E2E fake switchlocal (test-only redirect)Non-production only. EVER_WORKS_E2E_FAKES is honoured only when NODE_ENV is not production and is set in the PR lanes alone, so no production path redirects Git provider calls. See §B-8.

3. Threat actors​

ActorCapabilityMitigation
Anonymous internet visitorCrawl the public site, submit anonymous-user flow, file community PRs, try password-reset on guessed emails.Captcha on /auth/anonymous (production); /auth/login and token-validity oracles throttled; tokens never returned in HTTP responses (C-01/C-02); SSRF guards block scanning internal services.
Registered tenantGenerate items in their own works, install plugins, configure git tokens, kick off agentic runs.Cross-tenant isolation via WorkOwnershipService; per-tenant plugin secrets encrypted (C-08); per-user cost / rate budgets (queued — currently per-plugin only).
Malicious tenantSame as above PLUS: feed prompt-injection through scraped content, PR text, PDF source URLs, screenshot APIs.Tenant data isolated to their own works; agentic env allow-list prevents host-secret exfil (C-10); CLI item schema validates LLM output (M-26); SSRF guards prevent extractors from reaching internal hosts. Residual risk: the agent can still emit attacker-chosen content into the tenant's own outputs — they can vandalize their own work but not others.
Compromised plugin author (1st-party)Add malicious code to a plugin that the runtime auto-loads.None currently — 1st-party trust model. Plugin signing (C-12) is on the roadmap; until then, code review on every plugin PR is the only gate.
Compromised CI / GitHub ActionA mutable-tagged action publishes a malicious version that exfiltrates secrets at deploy time.Minimum default permissions: contents: read on every workflow (M-12); secrets passed via env: indirection where shell metacharacters could break the step (M-13); pnpm audit blocks high/critical transitive CVEs in PRs (H-15). SHA-pinning of third-party actions is queued (M-11).
K8s cluster compromise (peer pod)A neighboring deployment in do-sfo2-k8s-gauzy reads our secrets via get deployments or pod env.Currently all secrets live inline in Deployment.spec.template.spec.containers[].env (H-13). Migration to Secret resources + secretKeyRef is queued. Pod-level securityContext (H-20) is queued — when it lands, the API pod will run as non-root, read-only-rootfs, with automountServiceAccountToken: false unless explicitly needed for the k8s-plugin deploy path.

4. Out of scope (explicitly)​

  • DDoS at the edge. Cloudflare / DigitalOcean LB handle volumetric L3/L4 attacks. The application's rate limiters are not designed to survive a true DDoS.
  • Compromised tenant device. If a tenant's laptop is compromised and their session cookie is stolen, the attacker has the tenant's privileges. This is true of every web app; the platform mitigates by setting httpOnly + secure + sameSite=lax (M-21) and short refresh windows.
  • Compromise of upstream SaaS (Better Auth, Trigger.dev, Vercel runtime, OpenRouter, Anthropic, etc.). Out of our control; we assume they handle our credentials correctly.
  • Side-channels in shared infrastructure (CPU cache attacks on a public cloud's hypervisor, etc.). We trust DO and Vercel's host isolation.

5. Residual risks (accepted)​

These are known risks the audit flagged that the platform team has accepted for now:

  • In-process plugin model. Even with C-12 signing, a vetted plugin can still misbehave. The platform team's decision: review every plugin PR like product code, and rely on the 1st-party-only posture above. Revisit when community plugins become a feature.
  • Agentic loop can spend tenant money. A prompt-injected agent can burn through the tenant's OpenAI / Mistral / Anthropic budget. Per-plugin maxBudgetUsd is the current mitigation; a platform-wide per-tenant cost budget is queued.
  • No platform-wide kill-switch. If a prompt-injection or runaway is in progress, ops can stop individual pipelines but there's no IS_AGENT_KILL_SWITCH_ON that halts all autonomous activity instantly. Queued.
  • Fake iat / iss / aud on AuthenticatedUser. Marked @deprecated; removal is a follow-up after consumers migrate. Low risk because the values are server-internal and don't sign anything.

The OAuth login flow crosses two origins:

  • app.ever.works — Next.js web app where the user clicks "Sign in with X".
  • api.ever.works — NestJS API that issues the OAuth URL and exchanges the code.

The OAuth provider's redirect_uri is configured as ${WEB_URL}/api/oauth/:p/callback — it lands the callback on the web host, so the api.ever.works-scoped ew_oauth_state cookie is not carried on the user-flow callback request.

The hardening therefore validates state in two complementary layers, both bound to the same server-minted value:

  1. GET /api/oauth/:p/url mints a 32-byte base64url nonce, sets it as ew_oauth_state (HttpOnly, Path=/api/oauth, SameSite=Lax, Secure in prod) on the API host, and returns the value in the response body.
  2. The web's connectProvider server action mirrors that returned value into oauth_state on app.ever.works (HttpOnly, Path=/, SameSite=Lax, Secure when WEB_URL is HTTPS).
  3. On callback to app.ever.works/api/oauth/:p/callback, handleOAuthCallback timingSafeEqual-compares oauth_state cookie ↔ URL state query.
  4. Anything hitting api.ever.works/api/oauth/:p/callback directly (CLI, future tooling, provider misconfigured to the API host) is validated against ew_oauth_state by the same constant-time check.

Properties:

  • Single source of truth. Both cookies hold the same value the API minted; there is no client-mintable input to the state.
  • Single-use. Each callback clears its cookie regardless of outcome.
  • Cross-origin safe. SameSite=Lax permits the OAuth provider's top-level GET redirect to attach the cookie on the way back; cross-site script can't read either cookie because both are HttpOnly.

A regression where the API substituted its own nonce without surfacing it to the web caused every Google/GitHub sign-in to fail the web check; the contract above pins both layers so a future-self can't reintroduce the same skew. Specs: API GET /api/oauth/:p/url returns { url, state }; both the URL query and the response body carry the same state.

6. Plugin trust posture (formal statement)​

The hosted SaaS at apps.ever.works will not load any plugin that has not been reviewed, merged, and pinned by the Ever Works team. Self-hosters who load plugins they did not author themselves are trusting those plugin authors with shell access to their server. Plugin signing / integrity verification will lift this restriction once it ships (C-12).

Concretely:

  • PluginLoaderService.discover() will load any everworks.plugin it finds on disk; the runtime does not currently distinguish 1st- and 3rd-party plugins.
  • The hosted SaaS image only bundles plugins from packages/plugins/ in this repo. There is no UI to upload a plugin, no API endpoint that fetches and loads one from npm.
  • Self-hosters bear the responsibility of vetting any external plugin they add to ./plugins or ./node_modules/@ever-works.

7. Convergence checklist (audit findings → posture above)​

Posture statementFindings that enforce itStatus
Tokens travel only via secondary channelC-01, C-02✅ Batch 1
Shared-secret comparisons are constant-timeC-05, H-08, L-09✅ Batch 1
API docs are not public in productionC-09✅ Batch 1
Agentic subprocesses don't inherit host secretsC-10✅ Batch 1
Cookie encryption key has real entropyH-14✅ Batch 1
Callback URLs go through host allow-listC-04✅ Batch 2
Postgres synchronize is off in every envC-07 PR-A + PR-B✅ Batch 2 / pending PR-B
Password complexity matches Better Auth runtimeH-02✅ Batch 2
Forgot-password is timing-uniformH-03✅ Batch 2
Email verification is required before loginH-07✅ Batch 2
Trigger.dev callback exposes only allow-listed fieldsH-06✅ Batch 2
Outbound HTTP from API is SSRF-guardedH-09 / H-10 / H-11 / M-23✅ Batch 2 (lexical guard; DNS-rebinding still on roadmap)
Plugin README renders are sanitizedH-12✅ Batch 2
CI fails on high/critical transitive CVEsH-15✅ Batch 2
Production fails fast on missing CORS allow-listH-19✅ Batch 2
Task-payload path components are validated UUIDsH-22✅ Batch 2
Agentic file tools can't escape the workspaceH-23✅ Batch 2
Plugin readme HTML is sanitizedH-12✅ Batch 2
OAuth state validated against signed cookieC-03✅ Batch 3 + EW-OAUTH-STATE-FIX (dual-host round-trip)
Trigger remote-call surface is allow-listedC-05 RPC half🚧 Batch 3
Plugin secret settings are encrypted at restC-08🚧 Batch 3
Community-PR auto-apply gated by Verified-org membershipC-11🚧 Batch 3
Bearer tokens hashed at restH-01🚧 Batch 3
Anonymous sessions bind to IP/UA + short TTLH-04 / H-05🚧 Batch 3
Distributed throttler with per-user lockoutH-17 / H-18🚧 Batch 3
K8s pod runs as non-root with capabilities droppedH-20🚧 Batch 3
MCP server has per-user identity (dual-mode)H-21🚧 Batch 3
Agentic boundaries are feature-flag toggledH-24 / H-25🚧 Batch 3
Plugin loader verifies integrity / signatureC-12❌ Deferred — see §6
K8s secrets live in Secret resourcesH-13❌ Deferred — operator opted out for now

8. Revision log​

DateAuthorChange
2026-05-17Platform team (drafted by audit-batch session)First draft, in response to the 2026-05-17 platform security audit.
2026-09-17App Works program (SK-06/XC-03)App Works trust boundaries added to §2 (B-1…B-11).