Secrets in Cloudflare Computer
Cloudflare Computer is a durable workspace for agents: a SQLite-backed filesystem in a Durable Object, with three execution backends over the same files — a Linux container, a shell in a Dynamic Worker, and a JavaScript isolate.
It is also the easiest place to run the pattern this whole section is about.
A Workspace takes an egress policy, and in http-gateway mode every
backend's outbound traffic — the container's curl, the worker shell's curl,
and fetch in the JavaScript isolate — arrives at one Fetcher you supply, as
an ordinary parsed Request. That is exactly what
seekrit-proxy does elsewhere, minus everything
that makes the proxy work to set up: no sidecar to run, no HTTPS_PROXY to
export, and no locally generated CA, because the platform has already terminated
TLS by the time the request reaches your code.
Cloudflare Computer is published as a preview: its APIs are unstable and
it is not yet meant for production. The seekrit side of this page is ordinary
@seekrit/sdk, so what changes under you is the workspace wiring, not the
substitution.
The gateway
@seekrit/sdk/cloudflare-computer builds the handler. It runs in your Worker,
so it is the one place the real values exist:
import { WorkerEntrypoint } from 'cloudflare:workers';
import { seekritEgress } from '@seekrit/sdk/cloudflare-computer';
export class SeekritGateway extends WorkerEntrypoint<Env> {
#egress = seekritEgress({
token: this.env.SEEKRIT_TOKEN,
allow: {
'api.openai.com': ['OPENAI_API_KEY'],
'api.github.com': ['GITHUB_TOKEN'],
},
});
override fetch(request: Request) {
return this.#egress.fetch(request);
}
}
Attach it to the Workspace as the egress policy, and give it a revision —
Cloudflare Computer keys its Dynamic Worker cache on that string, so without one
every exec pays for a cold worker:
import { withWorkspace } from '@cloudflare/computer';
import { WorkerShellBackend } from '@cloudflare/computer/backends/worker-shell';
import curl from '@cloudflare/computer/shell/curl';
import { seekritEgressPolicy } from '@seekrit/sdk/cloudflare-computer';
import { DurableObject } from 'cloudflare:workers';
class AgentBase extends DurableObject<Env> {
readonly egress = seekritEgressPolicy(this.ctx.exports.SeekritGateway({}), 'v1');
}
export class Agent extends withWorkspace(AgentBase, (self) => ({
storage: self.ctx.storage,
backends: [
new WorkerShellBackend({
loader: self.env.LOADER,
workspace: { binding: 'Agent', id: self.ctx.id.toString() },
ctx: self.ctx,
commands: [curl],
egress: self.egress,
}),
],
})) {}
Then run something that names a credential it does not have:
using ws = await getWorkspace(env.Agent.get(id));
using run = await ws.runtime.exec(
'curl -sS -H "Authorization: Bearer $OPENAI_API_KEY" https://api.openai.com/v1/models',
{ env: seekritEnv(['OPENAI_API_KEY']) },
);
const { stdout } = await run.result();
seekritEnv sets OPENAI_API_KEY to the literal string
{{seekrit:OPENAI_API_KEY}}. The request leaves the sandbox with that marker in
its Authorization header and reaches OpenAI with the real key, substituted in
the gateway. A printenv inside the workspace — or a prompt injection that
exfiltrates the whole environment — yields markers.
The Workspace's own environment is never merged into an exec, on any of the
three backends, so the only variables a command sees are the ones you passed.
There is no SEEKRIT_TOKEN in there to find.
What the rules mean
The allowlist is checked twice, and the first check is what makes this an egress firewall rather than only a credential shim:
- The operation. Host, method, and path, before anything is read or
resolved. A request no rule covers is answered
403and never sent. - Each placeholder it carries. A name that host may not receive is answered
403and never sent either.
allow is shorthand for "any method, any path". Use rules for the full shape
— the same shape a signed ap1. policy bundle
carries, so a verified bundle's rules array can be passed straight in:
seekritEgress({
token: env.SEEKRIT_TOKEN,
rules: [
{ host: 'api.openai.com', methods: ['POST'], paths: ['/v1/**'], allow: ['OPENAI_API_KEY'] },
{ host: 'pypi.org', allow: [] }, // may reach it, may carry nothing to it
{ host: 'files.pythonhosted.org', allow: [] },
],
});
That allow: [] rule is the one people miss. It is how an agent gets to
pip install without being able to carry a credential to PyPI — reachability
and injection are separate decisions.
Rules authored here live in the gateway Worker's own source, which is what
makes them trustworthy: the Worker is code you deployed, the role
seekrit-proxy's local TOML file plays. Never fetch a plain rules list from an
API and enforce it — that hands whoever serves it the decision about where your
credentials go. To change rules without a redeploy, use a signed bundle, below.
Signed policy, without a redeploy
Rules in source mean a deploy every time they change. The alternative is the
same one seekrit-proxy has: publish an
agent access policy from the dashboard and let
the gateway fetch it. That is safe for one reason — the bundle is signed in
the browser with an admin's key, so the API stores and serves bytes it cannot
forge, and nothing is enforced until the signature checks out against signers
you pinned:
seekritEgress({
token: this.env.SEEKRIT_TOKEN,
policy: {
// The trust anchor. From your own config — never from the API, which is
// what it is checking.
signers: this.env.POLICY_SIGNERS.split(','),
agent: 'nova',
org: 'org_2fVectorOrg',
refreshSeconds: 300,
},
});
policy and allow/rules are mutually exclusive: pick where rules come from.
Every check the proxy makes, the gateway makes:
| Check | What it stops |
|---|---|
| Signature over the transported bytes | A bundle edited in flight or at rest |
kid recomputed from the key beside it | A bundle naming a trusted thumbprint while carrying another key |
Thumbprint in policy.signers | A bundle signed by a key you never trusted |
agent, org claims | A different agent's policy served to this one |
expires_at | A revoked policy working forever |
policy.ceiling, if set | A published bundle naming a host or secret this deployment never permits |
The ceiling is the one worth setting for a fleet. It is a local map of host → the secret names ever permissible here, and a bundle that exceeds it is refused wholesale rather than quietly narrowed — a policy that means less than what was published is a policy nobody authored:
policy: {
signers: [...],
agent: 'nova',
ceiling: { 'api.openai.com': ['OPENAI_API_KEY'] },
}
If there is no policy to trust — none published, expired, unverifiable, over the
ceiling — the gateway permits nothing and answers 403 with
x-seekrit-refusal: no_policy. There is no fallback to the last good rules;
that is precisely the behaviour a revocation exists to prevent.
Keeping policy current
A Worker has no start-up and no timer — it is a swarm of short-lived isolates — so the proxy's "fetch on boot, re-fetch every 10s" does not translate. Three layers stack, each optional above the first:
| Layer | Refresh granularity | Costs |
|---|---|---|
| In-isolate memory (always on) | Per isolate, every refreshSeconds (default 300) | Nothing. Each isolate polls on its own, but the request is If-None-Match and the steady state is a 304 |
A store — KV, caches.default, a Durable Object | Per colo or per account, on the store's own TTL | One binding |
waitUntil | Unchanged — but nobody waits for it | One line |
policy: {
signers: [...],
agent: 'nova',
refreshSeconds: 300,
store: {
get: (key) => this.env.POLICY_KV.get(key),
put: (key, envelope, ttl) =>
this.env.POLICY_KV.put(key, envelope, { expirationTtl: Math.max(60, ttl) }),
},
waitUntil: (p) => this.ctx.waitUntil(p),
}
The store is consulted before the API, so it is what stops a hundred isolates
each polling on their own schedule; its TTL is the real cross-isolate refresh
interval. waitUntil decides who pays for a refresh that comes due while a
still-valid bundle is in hand: with it, the refresh runs in the background and
the request is served immediately; without it, that request waits, because a
promise left running after a Worker responds may simply be cancelled.
None of these layers is the safety boundary — expires_at is. A bundle is
refused once it passes, whatever any cache says, so the blast radius of a stale
cache is bounded by the TTL you published with, not by how often anything
polls. Refresh cadence decides how fast a narrowing lands; bundle TTL decides
how long a revocation can be ignored. Tune them separately: a short bundle
TTL is the security control, and a short refresh interval is the convenience
one.
You can also push instead of pull. fetchBundle replaces the default
GET /v1/agents/:agent/policy entirely, so a control plane of your own that
writes the envelope into KV on publish turns the gateway into a pure reader:
policy: {
signers: [...],
agent: 'nova',
fetchBundle: async () => ({ envelope: await this.env.POLICY_KV.get('nova') }),
}
The envelope is still verified. Nothing about where it came from is trusted — which is the whole reason it is signed.
Refusals
Every refusal is a 403 (or 400) that never reached the upstream, with a
machine-checkable header. The wording is identical to seekrit-proxy's, so
moving a workload between them does not change its error handling:
x-seekrit-refusal | Meaning |
|---|---|
no_rule | No rule covers this host |
path_not_allowed | The host is allowed, this path is not |
method_not_allowed | The host and path are allowed, this method is not |
denied | A placeholder named a secret this host may not receive |
unresolved | The name is allowed but resolved to nothing |
body_too_large | The body could not be buffered to scan it |
no_policy | There is no signed policy to trust, so nothing is permitted |
denied and unresolved also carry x-seekrit-secret with the name — never
the value. Pass onRefuse and onInject to log both; they are given names,
hosts, and constraints only, so the log is safe to keep.
Bodies, redirects, and the parts that bite
- Bodies are scanned by default, which means buffered, capped at 2 MiB
(
maxBodyBytes). A body over the cap is refused rather than forwarded unscanned — silently skipping the scan would send a literal placeholder to the upstream. Setbody: falseto stream every body through untouched and keep your placeholders in headers and URLs. - A body that is not valid UTF-8 is left alone, byte for byte. It cannot contain a placeholder, and decoding it to scan it would corrupt the upload.
- Redirects are never followed by the gateway. Following one would carry a
substituted credential to a location no rule authorised. The
3xxgoes back to the sandbox; if it follows, that request arrives here on its own account and is checked again. - A request carrying no placeholder never triggers a resolve. A workspace can use the gateway purely as an allowlist and hold no seekrit token at all.
- Resolved values are cached for 60 seconds by default (
ttlSeconds), in the gateway isolate. They are never written to the workspace filesystem.
When the value has to be real
An egress gateway can only inject into HTTP. A psql connection, a git push
over SSH, or a tool that reads a credential file needs the actual value — and
if the code doing that is yours (a build, a migration, a test run), injecting
it is fine:
import { Seekrit } from '@seekrit/sdk';
import { resolveEnv } from '@seekrit/sdk/cloudflare-computer';
const client = new Seekrit({ token: env.SEEKRIT_TOKEN });
using run = await ws.runtime.exec('npm run migrate', {
backend: 'container-shell',
env: await resolveEnv(client, ['DATABASE_URL']),
});
resolveEnv takes named secrets only and throws on a name that did not resolve.
Do not hand it the whole resolved map: a command should not get a credential
because it happens to live in the same environment. The narrowing that survives
someone editing this code is an environment scoped to the
job.
Which egress mode
| Workspace policy | The sandbox can reach | Use when |
|---|---|---|
{ mode: 'none' } | Nothing | The workspace only needs its own files |
{ mode: 'http-gateway', gateway } | What your rules allow, with credentials injected | Anything you did not write — this is the default answer |
{ mode: 'direct' } | The whole internet, with whatever you injected | You trust the code and it needs a value the gateway cannot inject |
Under direct, a {{seekrit:NAME}} placeholder reaches the upstream verbatim
and the call fails. That is the right way for the mistake to surface: loudly,
rather than as a credential that quietly was not there.
See also
- Agent sandboxes — the two shapes and when each is right
- Cloudflare Sandbox — the sibling product, with outbound handlers instead of one gateway
- Agent proxy — the same boundary for every runtime that has no such hook
- Agent access policy — publishing the signed bundles this gateway verifies
- In-process injection — the weaker shim, for code you do trust