learn / 02·12 — under nexus · secrets

a key youUSEbut never read

A secret is a credential the agent can name, count, and put to work — but never read. The value joins the request on the host side of the sandbox wall, after your template leaves the guest and before the socket opens. So no instruction the agent can run — buggy, hostile, or prompt-injected — can address it. The guarantee isn't a filter. It's the absence of an API.

secrets12 min read
A tiny figure on a gantry feeds a punched key-card into a slot on the face of a colossal sealed vault-machine that hums and emits a beam of light from its far side — the card-reader is on the figure's wall, the glowing output is on the wall beyond, no opening between them — bright marigold and teal, monumental, 1970s sci-fi style

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 .env file the agent can cat the 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

se·cret /ˈsiː·krət/ noun

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.

the four var verbs — plain variable vs secret
setplain: stored as a value · secret: stored, flagged — same call, plus --secret
getplain: the value · secret: <secret: N bytes — cannot read> — length and existence, never bytes
listplain: name + kind · secret: name + kind — names you can see, values you can't
refplain: substituted · secret: placeholder left literal — guest mode, by construction
a missing key stays literal too — a failure you can see, never a silent blank

That 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 secretcan it?how
does it exist?yesexistence is the only secret-shaped fact a guest may observe
how long is it?yesbyte count, from a redacted get
read its valuenothere is no import that returns it — the path does not exist
use it in a requestyesby 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:

targetdelivery mechanismcaveat
flyfly secrets set --stage — released by the deploy that followsthe whole hook is three lines
docker / podman--env-file secrets.env — the container's env, never baked into the imagedelivered at run, not build
krunvmnot yet — the kit's secrets env can't be delivereduse docker/podman locally, or a cloud provider
your own PaaSwrite a five-hook recipe — secrets get their own hooka 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 ref resolves 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.