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
| Where | Command | What it does |
|---|---|---|
| Host | secreq 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. |
| Guest | secreq 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
| Code | Meaning |
|---|---|
| 0 | Released. The value is on stdout. |
| 3 | Denied 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. |
| 1 | Error: $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'ssecreq agent openstopped, or thessh -Rforward 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--allowlist 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.
Every allowed request is gated. The prompt shows the scope as the principal:
"sandbox my-vm wants secret://op/Dev/gh/token".
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 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.
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 openprocess. 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.