@cat-factory/gatekeeper-worker
v0.6.26
Published
The Cloudflare OS Gatekeeper machinery for cat-factory as an installable library: policy-compiled Cap'n Web capabilities over the /api/v1 operation table, per-actor API-key minting, a verified outbound-webhook receiver and the approval inbox that answers
Maintainers
Readme
@cat-factory/gatekeeper-worker
What it is
The Cloudflare Worker machinery behind a cat-factory Gatekeeper: a credential-holding front end that lets a Cloudflare OS workspace drive cat-factory without an agent ever seeing a credential. Agents hold an object-capability whose methods are exactly what policy granted; the keys stay in Worker secrets and Durable Object storage.
It serves TWO doors onto the same rooms, and which one a caller comes in by decides how it is authorized:
| Door | Who comes in by it | Authorization |
| --------------------------------- | ---------------------------------------------------------------- | ------------------- |
| The GatekeeperVendor entrypoint | A Cloudflare OS workspace, over a GATEKEEPER_* service binding | Holding the binding |
| ALL /rpc (Cap'n Web over HTTP) | Any other agent runtime that speaks Cap'n Web | OS_SHARED_TOKEN |
The published Cloudflare OS contract reaches a gatekeeper over native Workers RPC to a
WorkerEntrypoint export named GatekeeperVendor; Cap'n Web is that workspace's browser-to-backend
and gadget-side protocol, which shares the semantics and not the wire. So /rpc is the door for
everything that is NOT a Cloudflare OS deployment, and nothing here is Cloudflare-OS-only.
It is the machinery half of the Gatekeeper family:
| Piece | What it is | How you take it |
| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------- |
| @cat-factory/gatekeeper-bindings | the generated operation table this package compiles policy against | comes in as a dependency |
| @cat-factory/gatekeeper-worker (this package) | the capability surface, key broker, webhook receiver, approval inbox, state DO | install, and write only a policy |
| deploy/gatekeeper | the deployment template: policy, wrangler bindings, three lines of wiring | copy, and edit src/policy.config.ts |
You install this package and write one file. Everything a deployment differs by is the policy; everything else, from the capability surface down to the Durable Object the minted keys live in, comes from here. That split is the goal: upgrading the machinery is a version bump rather than a merge against files you have edited, and "did you get the security-relevant fix" is answerable from a version number.
Purpose and goal
Its purpose is to put cat-factory behind an organization's own governance pane: per-actor credentials so every run traces back to a person, per-tier operation grants so an agent can reach only what its operator decided, field masking, and the platform's parked decisions surfaced as an approval inbox instead of a polling loop.
It is a consumer of the stable public surface: it rides /api/v1 and the outbound webhook
delivery contract through @cat-factory/sdk and
reaches nothing else. A cat-factory deployment that has never heard of it is byte-for-byte
unchanged.
How to use it
The starting point is the template at
deploy/gatekeeper: copy
it, point it at your workspace, and edit its src/policy.config.ts. A deployment's whole Worker
is:
import { createGatekeeperWorker } from '@cat-factory/gatekeeper-worker'
import { POLICY } from './policy.config'
// wrangler resolves `class_name` against the Worker's OWN exports, so the Durable Object class has
// to be named here even though it is implemented in this package.
export { GatekeeperState } from '@cat-factory/gatekeeper-worker'
export default createGatekeeperWorker({ policy: POLICY })// policy.config.ts: the `/policy` entry point carries the vocabulary without the Worker runtime,
// so this file and its tests load anywhere.
import { DECISION_BINDINGS, type GatekeeperPolicy } from '@cat-factory/gatekeeper-worker/policy'
export const POLICY: GatekeeperPolicy = {
defaultTier: null, // no implicit access: an ungranted actor gets `unknown_actor`, not a capability
tiers: {
observer: { description: 'Read the board and runs.', keyScope: 'read', allow: '*' },
approver: {
description: 'Answer a run’s parked decisions.',
keyScope: 'decide',
// Derived from the answerer table, never transcribed: a run parks on thirteen different
// things and the surface carries more than forty operations for answering them.
allow: ['tasks_get_run', ...DECISION_BINDINGS],
mask: ['run.pullRequestUrl'],
},
},
grants: { '[email protected]': 'approver' },
}@cloudflare/workers-types is a required peer, not an optional one. Every type this package
publishes is stated in terms of the Worker globals (ExportedHandler, DurableObjectNamespace,
Request) and the Durable Object base class comes from cloudflare:workers, so a consumer without
those types cannot compile the three lines above, let alone anything else. It is a peer rather than
a dependency because the globals are ambient: two copies in one tree redeclare each other, so the
version has to be the consumer's.
What it does
- Object-capability bindings. An agent holds an object whose METHODS are the operations policy granted it. There is no allow-list consulted per call, because there is nothing to consult: an operation the tier does not carry is not a method that refuses, it is absent.
- The Cloudflare OS object model, as a facade over all of it.
GatekeeperVendor→CatFactoryAccount→CatFactoryResource→ session, withdescribe(),getSupportedResources()andgetTypeScriptTypes()all PROJECTIONS of the operation table rather than transcriptions of it. A resource is the paired cat-factory workspace, named by a URLPattern over the deployment origin: one Gatekeeper serves one workspace, because the provisioning key it holds is scoped to one. - The workspace's approval queue, in front of every call. On the entrypoint path each read is authorized before it is MADE, and each write is SUBMITTED and performed only when the workspace applies it. Reads that serve captured agent text are marked unshareable, actions carry the consequence the table states, and nothing is offered for unattended auto-approval while the surface annotates no write as safe. The tier policy underneath stays the floor.
- Per-actor credentials. Each caller gets their own cat-factory key, minted through
POST /api/v1/keysat the tier's scope and stamped with your identity for that person (externalIdentity), so a run traces back to a human and role-scoped merge policy stays real. Minting is claimed before it runs and re-mints once on a 401, so concurrent first calls mint once and rotating the provisioning key heals instead of wedging. - Approvals as an inbox, for every park. The platform's outbound webhook delivers
parked-decision cards; the Worker verifies the HMAC over the raw bytes, dedupes on
deliveryId, and raises a card. A run can stop on thirteen different things and each has an answerer keyed on the SDK's own kind union, so a park the platform adds fails this package's build rather than reportingstaleforever. Answering re-reads the run's live decisions and posts through the caller's own key. - Run lifecycle without polling.
run.started/run.completed/run.failedland as aruns_watched()projection, and a terminal event settles that run's open cards. - Hooks, so the workspace is pushed rather than asked. A session binds a callback through the
workspace's own
bindHook, the workspace enables it when it is ready to, and each card or run transition is then pushed: a fresh callback per delivery, authorized as the observation it is. What a hook pushes is exactly whatapprovals_list()andruns_watched()answer, which stay the truth: a delivery a hook missed is still readable there, andhooks_bound()reports the miss rather than leaving it to be inferred from a quiet inbox. The fan-out runs BEHIND the delivery's acknowledgement and each push is bounded by its own deadline, so a workspace that hangs costs a notification and never the platform's retry budget. - Sharing that is verified, not assumed. A workspace user shared onto a bound resource is admitted only when their OWN account's tier reaches everything the resource's tier reaches, and masks no more; a tier that can read captured agent text is never shareable at all. The observer has to hold an account THIS deployment minted, checked before any tier is resolved: an unknown id would otherwise resolve to the auto-provisioned tier, which is the tier every account here holds, so a viewer from another vendor entirely measured up as identical to the owner. Anything the Gatekeeper cannot answer (an observer with no verifier, an account it never minted) is a refusal that says which it is.
- Arguments checked against what each operation declares. An argument no operation reads used to be dropped on the way through, so a filter nobody applied came back as an answer shaped like a filtered one. It is now a refusal naming what the operation does take, made before a key is minted or an approval is spent.
- Self-enrolment and offboarding. The endpoint registers itself under a caller-chosen webhook
id, hourly and idempotently.
POST /admin/retire?actorId=…revokes every key minted for one person, upstream first and then here.
One thing to tell whoever writes the agent on the other side: a task filed with only a title
parks immediately, before any agent runs. cat-factory reduces a task's own authored fields before
the first dispatch, and a missing description is a blocking finding, so tasks_create +
tasks_start with a bare title yields a run stopped on an input-gate decision rather than one
that is working. That park is answerable from here like any other, but the cheaper fix is filing
work that says what it wants.
What to configure
Everything operational comes from the Worker's environment, through two mechanisms that are not
interchangeable: the vars and the Durable Object binding are written in the template's
wrangler.toml, and the three credentials are secrets, put with wrangler secret put into the
platform's secret store. A credential in a config file is a credential in a repository, so a
refusal names the mechanism its binding actually takes rather than offering both.
| Binding | Kind | What it is |
| ---------------------- | -------------- | ----------------------------------------------------------------------------- |
| CAT_FACTORY_BASE_URL | var | The cat-factory deployment this Gatekeeper is paired with. |
| PUBLIC_URL | var | This Worker's own public origin; deliveries arrive at <PUBLIC_URL>/webhook. |
| WEBHOOK_ID | var | The outbound-webhook id to enrol under. Caller-chosen; keep it stable. |
| PROVISIONING_KEY | secret | An admin cat-factory API key. Mints per-actor keys; nothing else. |
| WEBHOOK_SECRET | secret | 16-200 chars. Registered with the endpoint and verified on every delivery. |
| OS_SHARED_TOKEN | secret | The bearer the paired OS deployment presents on every RPC call. |
| STATE | Durable Object | A namespace bound to GatekeeperState: cards, dedupe log, minted keys. |
A missing binding is answered as a 503 naming it and how it is set, never defaulted: there is no
safe stand-in for a credential or for the identity of the deployment it talks to. GET /health
asks the whole table at once rather than the bindings a given request path happens to read, so a
deployment that is wired for liveness and unwired for traffic reads as what it is.
The Worker serves five routes:
| Route | Auth | What it is |
| ------------------------------ | ----------------- | ------------------------------------------------------------- |
| POST /webhook | delivery HMAC | The platform's outbound deliveries. Verified over raw bytes. |
| ALL /rpc | OS_SHARED_TOKEN | Cap'n Web, for an agent runtime that is not a Cloudflare OS. |
| POST /admin/enroll | OS_SHARED_TOKEN | Re-assert the webhook registration. Also runs hourly on cron. |
| POST /admin/retire?actorId=… | OS_SHARED_TOKEN | Offboarding: revoke every key minted for one OS user. |
| GET /health | none | Green when every binding is set and the policy compiles. |
/rpc is bearer-gated because a Worker with a route attached is reachable by anyone who finds it,
and a capability surface whose only defence is obscurity is not one. The Cloudflare OS path does not
come through here at all: it arrives on a service binding, which never traverses the internet and
which only that deployment's operator can write, so holding it IS the authorization and a second
secret in front of it would protect nothing.
/health also reports whether a Cloudflare OS could install this
Two things decide that, and neither has a request path of its own: the entry module must export the
four names the object model resolves (GatekeeperVendor, CatFactoryAccount,
CatFactoryResource, CatFactoryVerifier), and the policy must name an autoProvisionedTier. A
workspace that finds either missing does not get an error anyone monitors; it never finishes
installing. So a green response carries the answer beside ok:
{ "ok": true, "os": { "discoverable": true, "blockers": [], "limitations": [] } }limitations is the same report for what does NOT stop an install. CatFactoryHookController is
reached only when a session binds a hook, so a deployment missing it installs, serves and answers
exactly as before and refuses approvals_subscribe(); calling that a blocker would turn a working
deployment red, and omitting it would leave the gap to be discovered from an agent's failed call.
It is REPORTED, never folded into the status, because a Gatekeeper serving /rpc and nothing else
is a supported deployment: this package promises that door to consumers that are not a Cloudflare
OS, and turning their liveness red on a version bump would be this route answering a question
nobody asked it. A deployment that wants discovery keys a monitor on os.discoverable; each entry
in blockers carries a reason (missing_exports, no_auto_provisioned_tier) and a detail
naming the fix, and both are reported in one pass so a half-wired deployment is not wired one
redeploy at a time.
What to customize: the policy
The policy is the ONE thing a deployment writes, and it is an argument
(createGatekeeperWorker({ policy })), never a file this package reads. A GatekeeperPolicy has
three fields:
defaultTier: the tier an actor with no explicit grant receives, ornullto refuse unknown actors (unknown_actor).nullis the shipped default and the safe one: adding a person is then a deliberate edit.tiers: namedTierPolicyentries, each carrying:description: prose the OS shows beside the tier.keyScope: the scope of the per-actor key minted for this tier (read/write/decide). It is also the ceiling on the grants;adminis refused outright, becausePOST /api/v1/keyscannot mint it and a tier asking for it is asking for the Gatekeeper's own provisioning secret.allow: binding names to grant, or'*'for everything withinkeyScope.deny: binding names to subtract fromallow. Applied last, so a deny always wins; the template uses it to keep the debug surface (model prompts, captured output) away from a'*'read tier.mask: dotted paths redacted from every result before it reaches the caller (see below).
grants: OS actor identity (whatever the OS authenticates and passes toconnect()) to tier name.
A policy is compiled against the LIVE operation table and only ever SUBTRACTS from
bindingsWithinScope(tier.keyScope), so a tier cannot grant above the key backing it, and a
retired or misspelled operation is a refusal to serve (PolicyError) rather than a method that
403s on every call. Two rules are worth keeping whatever else you change:
- Grant by name above
read.'*'is honest for a read-only tier and dangerous above it: a deployment that adds an operation ships it to every'*'tier on upgrade with nobody deciding to. - Keep
keyScopeas low as the grants allow. It is the scope of the key minted for each actor, so it is the blast radius of that actor's credential.
Masking replaces, never deletes. A masked leaf becomes the exported MASKED sentinel
([masked by gatekeeper policy]) rather than disappearing, because a removed key and a key the
platform had no value for read identically to the consuming agent, and they are different facts.
Paths are dotted and traverse arrays element-wise (steps.status masks every step's status); a
path that matches nothing is not an error, because result shapes legitimately vary by operation.
What a caller holds
connect({ actorId, label? }) on the /rpc session resolves the actor's tier and returns the
capability. actorId is the OS's own authenticated identity for the person, and it is the ONLY
claim the Gatekeeper trusts: nothing the caller sends picks a tier. Beyond the granted operation
methods, every capability carries ten reserved methods:
tier(): who the caller is acting as (actor, tier name, description, key scope).bindings(): the granted operations, each with its scope floor, consequence (cautious default applied) and argument shape, so the OS can run its own approval governance per call.withheld(): every binding the deployment serves that this capability does NOT carry, with the reason. The four reasons are kept apart on purpose:not_in_policyanddenied_by_policyare questions for the policy's author,above_key_scopeis a different one (raise the tier's key, or accept the ceiling), andnot_relayableis neither: an SSE stream or a binary blob cannot cross a Cap'n Web call, so the fix is to ask another way (polltasks_get_runinstead of the event stream).approvals_list(),approvals_inspect(cardId),approvals_answer(cardId, input): the approval inbox; see the template README for the flow and the three answer outcomes.runs_watched(): the run-lifecycle projection built from therun.*webhook events.approvals_subscribe(callback),runs_subscribe(callback): bind a hook, so the two projections above are PUSHED. The callback needs one method (onApprovalCard(card)/onRunEvent(state)), and a card is pushed on every transition it makes, settlement by a terminal run event included. Both refuse on the/rpcdoor, which brings no approval queue to register a hook with and nothing to authorize a delivery against; the refusal names the two reads that answer the same question. A workspace that does not take the binding (its queue serves no hooks, or a person declined) is ahook_bind_refusedcarrying the cause verbatim, because those two need opposite fixes and nothing at this seam can tell them apart.hooks_bound(): what this account has enabled, with what each hook has been pushed (deliveries), what it could not be pushed (missed), what the workspace refused (failures) and whether it is stilllive. A hook goes quiet when the durable object is evicted between deliveries, because the workspace's callback source is a stub and cannot be stored; that reads aslive: falsewith a risingmissed, and the remedy is to bind again. Re-binding from the same gadget RE-ARMS the same registration rather than adding a second: a hook is identified by where its deliveries land, and its counters carry over, since they are the history that prompted the re-arm.
Refusals from a live Gatekeeper are GatekeeperErrors carrying a machine-readable reason
(unknown_actor, card_not_found, ambiguous_park, …), the same role the platform's own
details.reason plays: an OS Gadget maps it to copy and a remedy. Operator mistakes are
PolicyErrors raised at compile time, before any capability exists, so a misconfigured Gatekeeper
serves nothing rather than serving methods that fail.
Custody, and what it does not promise
The provisioning key is a Worker secret and never leaves the platform's secret store. The per-actor keys it mints live in the Durable Object's storage: outside every agent's reach, but at rest in your account. If that is not acceptable, mint per call and revoke after, at the cost of a key row per operation.
What this enforces is which operations an actor may reach and on whose credential. What a run then does inside cat-factory is governed by cat-factory's own merge policy and approvals.
Upgrading
Upgrade this package. @cat-factory/gatekeeper-bindings arrives as its dependency, pinned to an
exact version, so bumping this package IS how the operation table moves and there is nothing to
keep in step by hand. Do not add a direct dependency on the bindings to keep them "together": an
exact pin plus a second range installs a SECOND copy of the table, while policy still compiles
against the one resolved here. Everything a policy names is re-exported from
@cat-factory/gatekeeper-worker/policy, so a deployment never needs that dependency.
Version skew is reported rather than absorbed. A policy naming an operation newer than the
installed table fails with a PolicyError telling you to upgrade, and the ladder helpers throw on
a scope rung they do not carry, so a deployment ahead of your packages reads as skew, never as a
key with no permissions.
Tests
The suite runs inside real workerd under @cloudflare/vitest-pool-workers, against a Worker
built from this package's own factory with a real Durable Object, real WebCrypto and a real Cap'n
Web client, talking to a scripted cat-factory origin bound as the pool's outbound service. The
credential-custody story IS "the key is a Worker secret", so a Node mock of a Worker would prove
nothing about it.
pnpm --filter @cat-factory/gatekeeper-worker test:runtest/live/ is the same Worker with the scripted origin taken away. A fixture agrees with this
package by construction, so a request shape the bindings and the SDK both consider correct can only
be wrong against a real deployment: the live specs enrol on the real webhook collection, mint a real
per-actor key (and recover from its revocation), forward the everyday loop, and answer a run that
really parked, off the card the platform's own notification raises. They are run by
@cat-factory/sdk-smoketest, which owns the deployment they need, so this package carries no
Postgres-shaped devDependency:
DATABASE_URL=... pnpm --filter @cat-factory/sdk-smoketest run smoketest -- --only=gatekeeperWhat that deliberately does not cover is a delivery that TRAVELLED: the platform refuses to register a loopback endpoint, so the receiver is driven with an envelope the suite signs around the platform's own notification object.
test/os-live/ is the third leg, and it takes away the fake the other two share: the workspace.
Cloudflare OS's own @gadgets/integration-tests toolkit boots the real workshop-backend beside
this Worker under wrangler's test harness, so a real workspace discovers the vendor off a service
binding, mints an account, binds the paired deployment as a resource and shares it. Nightly, pinned
to a partner commit by GATEKEEPER_OS_REF, and non-blocking by living in a workflow of its own
(.github/workflows/gatekeeper-os.yml). To run it yourself, clone the partner repository inside
this one and point the suite at it, from the repository root:
git clone https://github.com/cloudflare/cloudflare-os.git .cloudflare-os
# The commit the nightly pins is GATEKEEPER_OS_REF in .github/workflows/gatekeeper-os.yml.
# Running against a different one is fine; it is then your run that is unpinned, not the lane's.
git -C .cloudflare-os checkout <that commit>
(cd .cloudflare-os && pnpm install --frozen-lockfile --ignore-scripts \
&& pnpm --filter @gadgets/workshop-backend run build:format-blueprints)
GATEKEEPER_OS_DIR="$PWD/.cloudflare-os" pnpm --filter @cat-factory/gatekeeper-worker test:osThe clone goes inside this repository because wrangler's test harness boots the partner's Worker and
this one under a single root, and the root the suite hands it is this repository: a checkout beside
it cannot be booted, and the config refuses one rather than letting the harness report a Worker it
could not find. .cloudflare-os is gitignored for this.
It exists for the three seams no other suite here structurally reaches: the entrypoint NAMES (the
workspace resolves them and never asks this package what they are called), the stubs handed over
(createAccount() returns something the workspace persists, and getGatekeeperClassFor() a class
only the workspace's own machinery can instantiate), and the transcribed protocol in
src/os/protocol.ts, which a hermetic suite cannot tell from a shape that has fallen behind. Its
first run found a real one: without the allow_irrevocable_stub_storage compatibility flag, a
workspace cannot store the account stub, so every account anyone connected failed. What it does NOT
reach is a session: the harness runs no gadget code, so startSession is never called and the
approval queue, the argument checks and the answerers stay with the hermetic suite. Two legs, two
questions.
References
deploy/gatekeeper: the template you copy, its configuration walkthrough and the OS-side usage example.- ADR 0052: the design record, the decisions behind each half, and what each suite deliberately does not cover.
backend/docs/public-api.md: the API this rides (keys, scopes, webhooks, endpoint semantics).@cat-factory/gatekeeper-bindings: the generated operation table policy is compiled against.
