# Secrets in Cloudflare Computer

[Cloudflare Computer](https://github.com/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`](/docs/guides/agent-proxy) does elsewhere, minus everything
that makes the proxy work to set up: no sidecar to run, no `HTTPS_PROXY` to
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:

```ts
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:

```ts
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:

```ts
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](/docs/guides/agent-proxy/policy)
carries, so a verified bundle's `rules` array can be passed straight in:

```ts
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.

> **Warning:** 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](/docs/guides/agent-proxy/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:

```ts
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:

```ts
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 |

```ts
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:

```ts
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. 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:

```ts
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](/docs/guides/sandboxes#pick-names-not-the-whole-environment).

## 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](/docs/guides/sandboxes) — the two shapes and when each is right
- [Cloudflare Sandbox](/docs/guides/sandboxes/cloudflare-sandbox) — the sibling product, with outbound handlers instead of one gateway
- [Agent proxy](/docs/guides/agent-proxy) — the same boundary for every runtime that has no such hook
- [Agent access policy](/docs/guides/agent-proxy/policy) — publishing the signed bundles this gateway verifies
- [In-process injection](/docs/guides/agent-proxy/in-process) — the weaker shim, for code you do trust
