You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Turn the security invariants into guard tests that fail when a new route, tool, or connector forgets them #306
Every security invariant in this Worker is enforced at one correct choke point, and every one of them has at some point been quietly bypassed by the next thing added next to it. All of the following shipped looking right and were found by accident:
Suspension was described as enforced in requireUser. It had three holes — GET /v1/auth/me, the WS chat upgrade (a spend path), and the MCP repo/scaffold tools, which call GitHub with the worker's own token and never reach the API at all.
The pattern is always the same: a correct mechanism exists, and the seventeenth call site does not go through it. A reviewer cannot catch that by eye.
What to build
Guard tests in the shape this repo already uses — instance-config.test.ts ("no source file issues UPDATE agent_instances SET config = ?"), lib/import-graph.ts + its tests, scripts/check-*.mjs, store/console/src/lib/control-labels.ts. Walk the tree, fail with the offender list and the reason, and prefer a ratcheting budget over a hardcoded allowlist.
Invariants to guard (verify each is actually true before asserting it):
Every authenticated route resolves identity through requireUser/requireAdmin — nothing verifies a session inline without applying the suspension gate.
Every outbound fetch to a user-supplied host goes through safeFetch, with no bypass path, including test/probe endpoints.
Every mutating registry tool passes the consent gate before the network or the vault is touched.
Stored credentials go through the envelope helpers in lib/crypto.ts — no second scheme, no plaintext column.
Untrusted remote content is fenced via lib/untrusted-fence.ts, and the fence neutralizes its own closing marker.
Destructiveness is judged on the tool name WE send, never a server-supplied destructiveHint.
Each guard must be verified to go red when the invariant is broken.
Six guards in workers/api/src/lib/security-invariants.test.ts, over a pure lexer in lib/source-guard.ts (comments, string literals and regex literals blanked, line numbers preserved) with its own 23 unit tests:
Suspension reaches every authenticated surface — a file that calls verifySession must also callisSuspended, with two documented non-authorizing exceptions (error attribution in index.ts, bucket keying in rate-limit.ts), each ratcheted so a stale exemption fails.
The connector layer never bypasses safeFetch — no bare fetch( under lib/connectors/**, lib/steps.ts, lib/tools.ts, agent-do-knowledge.ts.
Mutating registry tools pass the write-consent gate — a connector tool whose name reads as mutating is scope:"write"; a tool that lets the caller choose the HTTP method cannot be scope:"read"; a write-scoped tool names its connector.
Credentials have one envelope scheme — the credential tables are derived from the migrations (a ciphertext column + a wrapped-DEK column), and every INSERT/UPDATE writer must import lib/crypto.ts; crypto.subtle.{wrapKey,unwrapKey,encrypt,decrypt} exists in exactly two named places.
The untrusted fence is not re-implemented — the marker string lives only in lib/untrusted-fence.ts.
Destructiveness is judged on our own name — no code reads destructiveHint/readOnlyHint/idempotentHint/openWorldHint, and isDestructiveToolName is still what the grant check calls.
Mutation-tested: 13 mutations, one per assertion, each confirmed red then reverted. Two first drafts stayed green under mutation and were wrong — one tested for the isSuspended identifier (which the import line satisfies on its own), the other scanned comment-stripped code for SQL that only ever lives inside string literals and had been finding zero writers. Both now carry explicit non-vacuity assertions.
Violations found and fixed here
routes/public.ts — BOTH authenticated routes (POST /v1/public/webhook/:instanceId/ingest, POST /v1/public/agents/:id/import-gdoc) verified sessions inline, so the [Admin] MCP repo/scaffold tools bypass the requireUser suspension gate #273 suspension fix never reached them. Now requireUser. Regression test added.
routes/agents.ts — same on GET /v1/agents/:id; auth is optional there so it applies isSuspended by hand, like the WS upgrade. Two regression tests added.
connectorClient.fetch() — its URL is the caller's, so it was a standing SSRF bypass carrying the owner's minted Bearer. Now safeFetch. Unused today, which is the only reason it was not already one.
CI on ff372a1 passed every step through pnpm test (4211 tests). It failed on three console e2e specs that were already red three commits earlier — tracked as #309.
Activity
test(security): make the invariants fail the build when the next call…
serge-ivo commentedon Aug 6, 2026
Shipped in ff372a1.
Six guards in
workers/api/src/lib/security-invariants.test.ts, over a pure lexer inlib/source-guard.ts(comments, string literals and regex literals blanked, line numbers preserved) with its own 23 unit tests:verifySessionmust also callisSuspended, with two documented non-authorizing exceptions (error attribution inindex.ts, bucket keying inrate-limit.ts), each ratcheted so a stale exemption fails.safeFetch— no barefetch(underlib/connectors/**,lib/steps.ts,lib/tools.ts,agent-do-knowledge.ts.scope:"write"; a tool that lets the caller choose the HTTP method cannot bescope:"read"; a write-scoped tool names its connector.lib/crypto.ts;crypto.subtle.{wrapKey,unwrapKey,encrypt,decrypt}exists in exactly two named places.lib/untrusted-fence.ts.destructiveHint/readOnlyHint/idempotentHint/openWorldHint, andisDestructiveToolNameis still what the grant check calls.Mutation-tested: 13 mutations, one per assertion, each confirmed red then reverted. Two first drafts stayed green under mutation and were wrong — one tested for the
isSuspendedidentifier (which the import line satisfies on its own), the other scanned comment-stripped code for SQL that only ever lives inside string literals and had been finding zero writers. Both now carry explicit non-vacuity assertions.Violations found and fixed here
routes/public.ts— BOTH authenticated routes (POST /v1/public/webhook/:instanceId/ingest,POST /v1/public/agents/:id/import-gdoc) verified sessions inline, so the [Admin] MCP repo/scaffold tools bypass the requireUser suspension gate #273 suspension fix never reached them. NowrequireUser. Regression test added.routes/agents.ts— same onGET /v1/agents/:id; auth is optional there so it appliesisSuspendedby hand, like the WS upgrade. Two regression tests added.connectorClient.fetch()— its URL is the caller's, so it was a standing SSRF bypass carrying the owner's minted Bearer. NowsafeFetch. Unused today, which is the only reason it was not already one.Violations found and filed, not fixed
http_requestisscope:"read"while acceptingmethod:"DELETE"with the owner's vault key, so it never reaches the write-consent gate. A scope flip would break every read-only GET in the pipelines; it needs a per-call gate. Recorded as a ratcheted exception: guard 3 fails on a second such tool and fails when this one is fixed.fetch_url/http_request/web_searchreturn remote text unfenced; the fence covers RAG and MCP resources only.CI on ff372a1 passed every step through
pnpm test(4211 tests). It failed on three console e2e specs that were already red three commits earlier — tracked as #309.fix(security): gate the mutating http call, fence the remote text it …
fix(security): give raw HTML one door and one mint, and count both (#297