the key in the PROMPT
You want an agent or a workbook to call Stripe, OpenAI, or your own API. Everywhere else, that has meant one thing: hand the credential to the code. And every conventional way of doing that is a way of leaving it lying around.
- A
.envfile the agent cancatthe moment it decides to. - An environment variable any dependency — yours or one five levels down your tree — can read.
- A key pasted into a prompt, which is now in a context window, a transcript, and a log file, forever.
Prompt injection turned this from a hygiene problem into an active attack. Anyone whose text reaches your agent — a web page it reads, an email it summarizes, a comment in a file it opens — is partially steering a process that holds your keys. The parent Nexus lesson already made the claim: secrets stay in the engine, so there's nothing to leak. This page is the receipts. It shows you exactly where the bytes live at every stage, what an agent sees when it tries to peek, and why the answer is enforced by construction rather than promised by policy.
the DEFINITION
1. a variable the guest can name, count,
and use — never read. The placeholder {{secret:KEY}} is
the whole of the guest-side API; the value crosses the membrane as an
effect, never as data.
The design has three pieces, and the discipline is in keeping them apart. The schema layer holds names and kinds — what secrets exist — and no values. The backend holds the values, host-side, where the guest has no reach. And the membrane rule — the one that makes it a guarantee rather than a hope — is that a secret's value may cross into the guest only as the effect of a host-brokered operation, never as a string the guest can hold. You write the name; the engine supplies the value, on its own side of the wall.
marking and handling REFS
On the engine, a secret is just a tenant variable with one flag flipped.
The placeholder grammar is a single pattern —
{{(var|secret):KEY}}, where KEY is letters,
digits, and underscores. Mark a variable secret and the store stops handing
back its bytes; it hands back facts about the bytes instead. Here is
a real session against the local CLI:
$ wbx var set STRIPE_KEY sk_live_51M… --secret
set STRIPE_KEY (secret)
$ wbx var get STRIPE_KEY
<secret: 32 bytes — ref it with {{secret:STRIPE_KEY}}, cannot read>
$ wbx var list
STRIPE_KEY secret
brand plain
$ wbx var ref "POST for {{var:brand}} auth {{secret:STRIPE_KEY}}"
POST for acme auth {{secret:STRIPE_KEY}}
Read the last command carefully, because it is the model in miniature.
The plain variable resolved — brand became acme.
The secret did not; its placeholder came back intact. That is not the
CLI being cautious. The runtime's var ref runs in guest mode by
construction — an agent calling through it physically cannot ask for the
resolved value. A get on a secret returns its byte count and
existence; a list shows names and kinds. Never the bytes.
--secret<secret: N bytes — cannot read> — length and existence, never bytesThat last figcaption matters more than it looks. An unknown
{{var:…}} or {{secret:…}} is left exactly as
written — never replaced with an empty string. A broken config fails loudly
at the API that rejects the bad request, instead of succeeding quietly with
a blank where the credential should have been.
guest mode vs HOST mode
There is one branch in the resolver that is the security model. Resolution runs in one of two modes. In guest mode, vars resolve and secrets stay literal — the placeholder survives untouched. In host mode, everything resolves — but host mode runs only inside host-side operations, at egress, outside the guest's reach. An agent gets guest mode; the engine reaches for host mode only after the template has already left the sandbox, on its way to the socket.
The live demo makes the pair concrete. A token is set secret; the same template is resolved twice:
-- the template the agent writes:
"POST for {{var:brand}} auth {{secret:api_token}}"
guest_ref → POST for acme auth {{secret:api_token}} (secret intact)
host_ref → POST for acme auth sk_live_SECRET (egress only)
Same template, same store, two readings. The agent sees the first line — the brand filled in, the secret still a placeholder it can read as text and do nothing with. The second line only ever exists on the host's side of the boundary, inside the operation that opens the connection. Follow the secret's lane through the whole trip:
sequenceDiagram
participant A as the agent (guest)
participant H as the host (engine)
participant S as the upstream API
rect rgb(251,250,246)
Note over A: inside the sandbox
A->>A: build template — auth {{secret:KEY}}
A->>A: var ref (guest) — placeholder stays literal
A->>H: hand the template to a host-brokered op
end
rect rgb(232,243,250)
Note over H,S: host side of the wall — the guest cannot observe this
H->>H: inject (host) — {{secret:KEY}} → real value
H->>S: open socket · send the request with the key
S-->>H: response
end
H-->>A: the result — never the key
Read the diagram as a relay where the baton never enters the first runner's hand. The agent assembles the request with the placeholder still in it, hands the whole template across the dock, and the host — and only the host — swaps the placeholder for the value and opens the socket. The response comes back; the key does not. The secret's lane never enters the top box.
enforcement, not ETIQUETTE
depth rung · skippable — why this is physics, not a filter
Credit where it's owed: this design takes its schema-and-values split, and its redaction discipline, from Varlock — a genuinely good secret manager, and the reference the design doc names. So it's worth being precise about the delta, because it's the entire point of this page.
Varlock, like every careful secret manager, redacts what a process could still read. The value is in the process; the tooling works hard to keep it from being printed, logged, or leaked. That is real engineering, and it is a discipline — a wall of checks around bytes the process can, in principle, address.
Here the bytes are never written into the guest's linear memory at all. The host writes the secret into the outbound request buffer on its own side of the WebAssembly boundary — after the template leaves the sandbox, before the socket. The value is never allocated into the address space the guest can reach, so no guest instruction — intended, buggy, or hostile — can point at it. There is nothing to redact because there is nothing there. A redaction filter can have a bug. The absence of an address cannot.
And this isn't asserted — it's tested at the hardest level. The sandbox security suite plants a real file on the host disk containing a marker string, then runs guest programs in Rust, Go, C, and JavaScript that try every trick to exfiltrate it — file reads, shell spawns — and asserts that all of them fail. The secret model rides on that same wall. The guest can't read your secret for precisely the reason it can't read a file you didn't grant it: the path to the bytes does not resolve from inside the sandbox.
the missing IMPORT
depth rung · skippable — the guarantee is something that doesn't exist
The dock — the membrane between guest and host — exposes a fixed set of
imports: the specific operations a guest may ask the host to perform. The
guarantee here is the shape of that set. There is deliberately
no secret-get(name) → string import. Adding one would
defeat the whole model. Its absence is the guarantee.
What a guest can learn about a secret is exactly two facts — whether it exists, and how many bytes it is — and it learns them only because those facts are useless for theft and necessary for sane behavior. Everything else is closed:
| what a guest asks about a secret | can it? | how |
|---|---|---|
| does it exist? | yes | existence is the only secret-shaped fact a guest may observe |
| how long is it? | yes | byte count, from a redacted get |
| read its value | no | there is no import that returns it — the path does not exist |
| use it in a request | yes | by reference — the host injects it at egress, guest never holds it |
The pattern already ships and runs. The model-completion import is the
proof: a guest asks the host to call a language model, the host holds the
OPENROUTER_API_KEY and reads it on its own side, and the
component never sees the key. The design doc calls this the existing proof of
the pattern — the same rule, generalized. Egress is host-brokered everywhere
for the same reason: the component never opens a socket, never reads the key,
never touches the network directly. And because a missing name stays literal,
a misconfigured secret doesn't silently send a blank credential — it fails at
the API, loudly, where you can see it.
use without an EGRESS point
depth rung · skippable — the strongest form of use-without-read
Injection-at-egress covers the common case: a credential that rides in a request header. But there's a stronger move, and it's the cleanest proof that use and read are genuinely different operations. The SecretBroker lets a guest use a key without there being any injection point at all.
The host registers a named secret for a tenant. The only guest-reachable
operation is sign: the guest sends some data across the dock, the
host applies the key with HMAC-SHA256, and the guest gets back a 32-byte
signature. The guest gets the signature, never the key. There's no template,
no egress, no header — the credential does real cryptographic work and the
worker never possesses it.
sequenceDiagram participant G as the guest (with the "secrets" cap) participant H as the host — holds the key G->>H: host_sign(name, payload) Note over H: apply HMAC-SHA256 with the named key H-->>G: 32-byte signature Note over G: signs a webhook / a JWT — never touches the credential
Read it as a stamp on the other side of a slot. The guest pushes a payload
through; the host presses the key against it and pushes back a signature.
The key stays bolted to the host's wall. And the broker is built for a
multi-tenant world: it is tenant-scoped and revocation-aware. A revoked tenant
asking to sign gets {:error, :revoked}; a tenant reaching for
another tenant's secret gets {:error, :unknown_secret}. The
tests prove the shape directly — they assert the module's export list contains
the sign operation and no getter at all: there is no way to retrieve a
secret's value, because the function that would do it was never written. This
is the sharpest instance of the whole lesson — a credential doing work, with
the worker that triggered it never able to see it.
names in the file, values in the VAULT
So far this is the runtime. Deployment is where you, the operator, put the real keys in — and it keeps the same split: names are committable, values never are. You declare the names of the secrets your engine needs in a deployment file, in plain text you can check into git:
#+TITLE: Workbooks deployment #+DEPLOY_TARGET: fly #+DEPLOY_APP: my-workbooks-engine #+DEPLOY_SECRETS: OPENROUTER_API_KEY STRIPE_KEY
The values go nowhere near that file. You stage them separately, and the kit tells you what's still missing before it ever tries to deploy:
$ wbx deploy validate ok — deployment.org (target=fly app=my-workbooks-engine …) warn — declared secrets unset: STRIPE_KEY (wbx deploy secrets set …) $ wbx deploy secrets set STRIPE_KEY=sk_live_51M… staged 1 secret(s) (~/.workbooks/secrets.env) — applied on next apply (or push) $ wbx deploy secrets list OPENROUTER_API_KEY STRIPE_KEY
Notice what secrets list shows: names only, never values —
the same discipline as the runtime, one layer out. The staged store is a
file locked to owner-only permissions (0600 on unix). And the
two checks differ in temperament on purpose: validate
warns on a declared-but-unset secret, but apply hard
fails — it will not deploy an engine that's missing a credential it
declared it needs. The whole round-trip, in one picture:
flowchart TD org["deployment.org — declares NAMES
#+DEPLOY_SECRETS: …
committable to git"] env["secrets.env — holds VALUES
chmod 0600 · stays on your machine"] apply["wbx deploy apply"] spawn["env -i … WB_SECRET_KEYS=… KEY=val … bash bootstrap.sh"] hook["provider secrets hook"] img["the runtime image"] org --> apply env --> apply apply --> spawn --> hook org -. "names only — never values" .-x img env -. "never baked into the image" .-x img style org fill:#aee5c2,stroke:#121316 style env fill:#f2ddb0,stroke:#121316 style apply fill:#fbfaf6,stroke:#121316,stroke-width:2px style spawn fill:#fbfaf6,stroke:#121316 style hook fill:#a8d4f0,stroke:#121316 style img fill:#d9dbd3,stroke:#121316
Trace the two inputs to where they meet. Names come from the org file —
the one you commit. Values come from the locked secrets.env — the
one that stays home. They converge only at apply, which spawns
the provider recipe in a clean environment and hands the keys to its secrets
hook. The two dotted lines are the load-bearing part: neither the names nor
the values are ever baked into the image. The image
(ghcr.io/workbooks-sh/runtime:latest) is byte-identical for
everyone who runs it — the secrets arrive at deploy time, through the
provider, and live in the provider's own secret store.
delivery HOOKS
depth rung · skippable — how each target actually receives the secrets
The spawn is deliberate about not leaking. The kit invokes the provider
recipe in a clean environment — an env -i-style launch that
starts from nothing and passes each secret as an explicit
KEY=value argument, plus a WB_SECRET_KEYS list of
every name in play. The staged secrets reach the hook without ever touching
the deploying process's own environment. From there, each target delivers them
its own way:
| target | delivery mechanism | caveat |
|---|---|---|
| fly | fly secrets set --stage — released by the deploy that follows | the whole hook is three lines |
| docker / podman | --env-file secrets.env — the container's env, never baked into the image | delivered at run, not build |
| krunvm | not yet — the kit's secrets env can't be delivered | use docker/podman locally, or a cloud provider |
| your own PaaS | write a five-hook recipe — secrets get their own hook | a behaviour seam, not a fork |
The verdict of that table: every shipped path delivers secrets as the
workload's environment at run time, never as something baked into the
artifact — with one honest gap, krunvm, which can't carry the env-file yet and
says so out loud rather than failing in some confusing way. You can also stage
credentials without a deploy — wbx deploy secrets push
runs just the secrets hook — and wbx deploy doctor reports
whether every declared secret is present.
One secret the kit manages itself deserves a note: the public bearer token. On your first cloud apply, the kit generates a 256-bit token from a secure random source, persists it alongside your other secrets, and then never rotates it — because rotating a live token would 401 every client already talking to your control plane. You export it to authenticate to the engine; the kit treats it as set-once on purpose.
what this ISN'T
Honesty section, because the model is strong enough that it's easy to oversell. Four real edges:
- The host holds plaintext. Somewhere on your engine, the values
sit in cleartext — the variables database, the staged
secrets.env, the provider's store. This model protects you from the workload — the agent, its dependencies, the text steering it. It does not protect you from someone who owns your engine box. That's a different threat, defended by a different layer. - The general fetch lane is the design's endgame, not all wired today. What ships and runs right now is injection through specific host-brokered operations — model completion is the proof — and the SecretBroker for signing. A guest passing a secret-templated header through a generic, arbitrary fetch import is the design's destination, landing as the network capability's policy is implemented. Use injection-at-egress as the mechanism, with model-completion and signing as the shipped proof — not as a thing every fetch can do today.
- Local
wbx var refresolves secrets. On your own machine the local CLI substitutes both vars and secrets with real values — because that file is already readable on your disk; it's your trust domain. The guest/host split is enforced by the runtime's resolver, not by the local CLI. Don't read the local convenience as a hole in the runtime model — they're different rooms. - Env is the only shipped backend. The backend is a clean behaviour seam — 1Password, Vault, AWS, Varlock all slot in with the same behaviour and no core change — but today the shipped implementation reads from configured values and the environment. The others are a swap you can make, not a feature you flip on.
questions people actually ASK
Can the agent ever print my key?
No. There is no read path — no import returns a secret's value — and the sandbox suite proves it: guest programs that try to exfiltrate a planted secret all fail. The most an agent can get is the byte count and the fact that the secret exists.
What if a secret name doesn't exist?
The placeholder stays literal — it is never replaced with a blank. The
downstream request goes out with {{secret:KEY}} still in it and
the API rejects it. A missing secret fails loudly at auth, never silently
succeeds with an empty credential.
Are secrets stored in the workbook file or bundle?
No. Secrets live in the engine-side store; a bundle carries references — placeholders — not values. You can send the artifact without sending a credential, because the credential was never in it.
How do I rotate a secret?
Re-set it and re-stage: wbx deploy secrets set KEY=new then
wbx deploy secrets push (or the next apply). The
one exception is the public bearer token, which is generated once and never
auto-rotated, because rotating it would 401 your live clients.
Where exactly do the bytes live, at every stage?
At rest on the runtime: the variables store (a small SQLite database).
On deploy: the staged secrets.env, locked 0600.
In production: the provider's own secret store (fly secrets, the container's
env-file). Never in the workbook, never in the image, never in the guest's
memory.
If the host holds plaintext, what's actually protected?
The workload. The agent, its dependencies, and anyone whose text reaches it can never address the bytes — that's the whole sandbox-side guarantee. Protecting the host box itself from its operator is a different layer; this model doesn't claim to do both, and says so.
keep GOING
This page proves one claim the parent lesson made. The neighbors below show the walls it rides on.