Secret agent: serving secrets to a sandbox

secreq can serve secret:// references to a guest (a VM sandbox) over a forwarded unix socket, instead of copying tokens into it. The guest asks per use; the host prompts, resolves fresh, and audits. Nothing is persisted in the guest.

This is ssh-agent.md's pattern applied to secret resolution, down to the convention: an env var names a socket (SECREQ_SOCK, mirroring SSH_AUTH_SOCK), the socket is the capability, and having it lets you ask, not decide.

The two halves

WhereCommandWhat it does
Hostsecreq agent open --scope <name> --allow <ref>… --sock <path>Binds a scoped, ephemeral socket. The scope name and the allowlist are declared here and are immutable for the socket's life.
Guestsecreq resolve <ref>Dials $SECREQ_SOCK and asks. Prints the value on stdout.

Between them: ssh -R, exactly as ssh -A forwards SSH_AUTH_SOCK. There is no network listener and no new auth surface; SSH is the auth.

# On the host: open the socket, then forward it in.
secreq agent open --scope my-vm \
  --allow secret://op/Dev/gh/token \
  --allow secret://op/Dev/linear/token \
  --sock "$HOME/.secreq/run/my-vm.sock" &

ssh -R /run/secreq.sock:"$HOME/.secreq/run/my-vm.sock" my-vm
# In the guest:
export SECREQ_SOCK=/run/secreq.sock
export GH_TOKEN="$(secreq resolve secret://op/Dev/gh/token)"

Inside a brain --vm sandbox, both sides are wired for you: SECREQ_SOCK is already set in the guest profile.

secreq resolve (the guest side)

secreq resolve <REF>      # print one secret's value on stdout
secreq resolve --list     # print the refs this socket may resolve

<REF> is a full secret://provider/locator or the bare provider/locator shorthand.

The value, and only the value, goes to stdout. Every diagnostic, error, and denial goes to stderr. That's what makes the command substitutable:

export GH_TOKEN="$(secreq resolve secret://op/Dev/gh/token)"

The value is printed with a trailing newline (op read's convention); $(…) strips it, so the variable holds the value exactly.

--list prints the scope's allowed ref names, one per line, never values. Listing is free: it prompts for nothing and releases nothing the host didn't already declare to this very socket.

Exit codes

CodeMeaning
0Released. The value is on stdout.
3Denied by the host: you said no, a rule denied it, or the ref is outside this socket's declared scope. The reason is on stderr; stdout is empty.
1Error: $SECREQ_SOCK unset, the agent unreachable, a malformed ref, or resolution failed on the host after approval.

3 and 1 are distinct. A denial is a normal, final answer, so don't retry it; retrying is how a user gets trained to click through prompts. A 1 means something is broken and may be worth fixing and retrying.

When it doesn't work

  • $SECREQ_SOCK is not set: you're on the host, or in a sandbox with no forward (brain's container tier has no sshd, so it can't be served this way; it still uses seeded env). Open and forward a socket as above.
  • cannot reach the scoped secret agent on …: the socket path exists in your guest but nothing answers. Either the host's secreq agent open stopped, or the ssh -R forward is down. Check both; from inside the guest you can't tell which.
  • denied by the host: reference is outside this socket's declared scope: the ref isn't in the --allow list this socket was opened with. This is refused without a prompt (and audited), so nobody on the host saw a window. Re-open the socket with the ref in its allowlist.

Behavior on the host

The allowlist is the coarse bound. A ref outside it is denied without a prompt and audited as deny+out-of-scope, which is distinct from a deny you chose, because nobody was asked. A run of these rows is what a probing sandbox looks like, and it is why the refusal is silent: a compromised guest can neither train you to click through nor enumerate your vault one prompt at a time.

A guest asked for a secret outside its declared scope. The agent refused without prompting you, and the attempt is on the record.

Every allowed request is gated. The prompt shows the scope as the principal: "sandbox my-vm wants secret://op/Dev/gh/token".

A request from a guest VM. The headline is the sandbox, because a guest has no host process tree — the scope you declared is the principal, and there is deliberately no caller chain to read. DECLARED BY is the local process that opened the socket, which here is the secreq agent open you started.

A guest may volunteer a caller chain. It is shown, disclaimed, and audited as unverified_guest_chain. It never reaches the decision or the grant cache:

The same request when the guest volunteers a caller chain. It renders dimmed under GUEST SAYS and marked not verifiable — recorded as a claim, never used to make the decision.

The audit view draws it under the same caveat, so a claim never reads back as ancestry secreq established. A gh row's tree is what the host walked; a guest says line is what a sandbox sent over a socket.

A sandbox can volunteer what it says was running inside it. secreq writes the claim down and marks it, because nothing on the host can check it: a guest that wanted to name a different ancestry would say exactly this. The gh row below it carries the other kind of chain, the one secreq walked itself. Never read the marked one as the unmarked one.

Three shorter rules apply to every socket:

  • Approving for 5 minutes anchors the decision to that (scope, ref), and requests inside the window are silent. The decision is cached; the secret is resolved fresh and zeroized every time.
  • The socket lives as long as the agent open process. Kill it and the grants die with it; there is nothing to revoke.
  • Every release is audited: scope, ref, decision, and the local process the daemon saw naming the scope. Never the value.

Trust-model note: granularity is downgraded

For guest callers, secreq cannot see what is asking, only which sandbox. Consent normally rests on a kernel fact: secreq reads the asking process's pid and walks its parent tree, so you see node → pnpm → postinstall and know it is true. A guest VM has no host pid, and over a forwarded socket the peer is the tunnel (sshd), not the asker. There is nothing to check.

So the sandbox is the principal. Approving a ref for a sandbox approves everything running in that sandbox for the TTL, not one process. That is weaker than the local wrap and SSH paths. If a workload needs per-process consent, it does not belong in a VM the host cannot inspect.