seekrit
Docs/Cloudflare Computer

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.

note

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.

tip

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:

  1. The operation. Host, method, and path, before anything is read or resolved. A request no rule covers is answered 403 and never sent.
  2. Each placeholder it carries. A name that host may not receive is answered 403 and 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.

caution

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:

CheckWhat it stops
Signature over the transported bytesA bundle edited in flight or at rest
kid recomputed from the key beside itA bundle naming a trusted thumbprint while carrying another key
Thumbprint in policy.signersA bundle signed by a key you never trusted
agent, org claimsA different agent's policy served to this one
expires_atA revoked policy working forever
policy.ceiling, if setA 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:

LayerRefresh granularityCosts
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 ObjectPer colo or per account, on the store's own TTLOne binding
waitUntilUnchanged — but nobody waits for itOne 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.

note

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-refusalMeaning
no_ruleNo rule covers this host
path_not_allowedThe host is allowed, this path is not
method_not_allowedThe host and path are allowed, this method is not
deniedA placeholder named a secret this host may not receive
unresolvedThe name is allowed but resolved to nothing
body_too_largeThe body could not be buffered to scan it
no_policyThere 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. Set body: false to 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 3xx goes 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 policyThe sandbox can reachUse when
{ mode: 'none' }NothingThe workspace only needs its own files
{ mode: 'http-gateway', gateway }What your rules allow, with credentials injectedAnything you did not write — this is the default answer
{ mode: 'direct' }The whole internet, with whatever you injectedYou 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