Run the broker locally
packages/envoy/scripts/dev-broker.sh creates a Postgres database of its own, builds the broker,
its clients, dev-oidc-issuer and dev-airlock-stands, writes a fake secrets file, which stands
in for Secrets Manager, starts dev-airlock-stands, whose GitHub stand is the GitHub the two
github-app secrets mint at, starts dev-oidc-issuer, which stands in both for a cluster’s
ServiceAccount token issuer and for the identity provider a person signs in to the broker at, and
starts the broker on a free port, trusting that issuer for both. It needs Go, jq, curl and a
Postgres server: by default it
starts one in Docker through packages/envoy/scripts/dev-postgres.sh (a container named
dispatch-pg, reused when it is there; one made from another image than that script’s is
refused, with the commands that move its data onto the right one). Without Docker, run Postgres
yourself and set DEV_BROKER_POSTGRES_URL to postgres://<user>@<host>:<port>/<database>, for a
role that may create databases; the script then creates and drops its database there with psql.
Start the stack
Section titled “Start the stack”With Docker the command is the script alone; this run used a Postgres of its own:
$ DEV_BROKER_POSTGRES_URL='postgres://broker:<password>@127.0.0.1:5433/postgres?sslmode=disable' packages/envoy/scripts/dev-broker.shdev-broker: workdir /tmp/agent-secrets-dev.LsGorYdev-broker: created isolated database dev_broker_lsgorydev-broker: building broker, dev-oidc-issuer, dev-airlock-stands, agent-secrets, agent-secrets-helper and agent-secrets-devrelay...dev-broker: starting dev-airlock-stands (its GitHub stand mints the github-app secrets)...dev-airlock-stands: stands listening records=http://127.0.0.1:50479dev-broker: starting dev-oidc-issuer...dev-oidc-issuer: /tmp/agent-secrets-dev.LsGorY/account-token holds system:serviceaccount:example-ns:example-dispatch's token for audience legion-broker-pod (pod 7c13b48e-70f7-465e-84ca-8377fab03474)dev-oidc-issuer: /tmp/agent-secrets-dev.LsGorY/listener-token holds system:serviceaccount:example-ns:example-listener's token for audience legion-broker-pod (pod 69babc12-ca99-4803-aea7-ebb31beff28e)dev-oidc-issuer: /tmp/agent-secrets-dev.LsGorY/exchange-token holds system:serviceaccount:example-ns:example-worker's token for audience legion-broker-exchange (pod 6d75548a-6373-4837-a09f-84aa2f3b2921)dev-oidc-issuer: /tmp/agent-secrets-dev.LsGorY/pod-token holds system:serviceaccount:example-ns:example-worker's token for audience legion-broker-pod (pod 6d75548a-6373-4837-a09f-84aa2f3b2921)dev-oidc-issuer: /tmp/agent-secrets-dev.LsGorY/registrar-token holds system:serviceaccount:example-ns:example-daemon's token for audience legion-broker-pod (pod b47e2ad2-ffb3-4397-b803-2c9353ed2bc8)dev-oidc-issuer: code flow serves client dev-broker; an authorization with none queued at /_dev/sign-in-as signs in ada@example.comdev-oidc-issuer: issuer listening url=http://127.0.0.1:49971dev-broker: starting broker...2026/11/11 01:30:15 INFO broker stop budget drain=0s shutdown=60s total=60s2026/11/11 01:30:15 INFO database sign-in method=password2026/11/11 01:30:15 INFO approval sign-in on issuer=http://127.0.0.1:49971 client_id=dev-broker domain=example.com2026/11/11 01:30:15 INFO secret store store=aws-secrets-manager credentials="fake secrets file"2026/11/11 01:30:16 INFO broker: listening for the other pods' secret rereads channel=broker_secret_reread2026/11/11 01:30:16 INFO broker listening addr=127.0.0.1:646332026/11/11 01:30:16 INFO model judge state=off detail="none of BROKER_JUDGE_MODEL_URL, BROKER_JUDGE_MODEL, BROKER_JUDGE_MODEL_AUDIENCE, BROKER_JUDGE_DISPATCH_URL, BROKER_JUDGE_DISPATCH_TOKEN_FILE is set, so every human-tier request waits for the person who decides it today"2026/11/11 01:30:16 INFO token exchange state=on kubernetes=true github=false accounts=1 workflows=0 machines=02026/11/11 01:30:16 INFO machine login device grant state=on verification_uri=http://127.0.0.1/credentials/machine2026/11/11 01:30:16 INFO broker reread rate replicas=2 every=1s burst=102026/11/11 01:30:16 INFO broker: registered a pod to write under a session name service=legion-daemon account=system:serviceaccount:example-ns:example-worker pod=6d75548a-6373-4837-a09f-84aa2f3b2921 session=example-worker-sessiondev-broker: registered the exchange pod to write under example-worker-session
dev-broker: ready.
export AGENT_SECRETS_URL=http://127.0.0.1:64633 export AGENT_SECRETS_ACCOUNT_TOKEN_FILE=/tmp/agent-secrets-dev.LsGorY/account-token export AGENT_SECRETS_APPROVER=ada@example.com export AGENT_SECRETS_HELPER_SOCK=/tmp/agent-secrets-dev.LsGorY/helper.sock export AGENT_SECRETS_OPERATOR_FILE=/tmp/agent-secrets-dev.LsGorY/operator export AGENT_SECRETS_MACHINE_FILE=/tmp/agent-secrets-dev.LsGorY/machine.json export AGENT_SECRETS_STATE_FILE=/tmp/agent-secrets-dev.LsGorY/sessions.json export DEV_BROKER_DIR=/tmp/agent-secrets-dev.LsGorY export DEV_BROKER_POD_UID=6d75548a-6373-4837-a09f-84aa2f3b2921 export DEV_BROKER_SERVICE=legion-daemon export DEV_BROKER_GITHUB_URL=http://127.0.0.1:44686 export DEV_BROKER_GITHUB_RECORDS_URL=http://127.0.0.1:50479/records/github export DEV_BROKER_GITHUB_ANSWER_URL=http://127.0.0.1:50479/answers/github export DEV_BROKER_GITHUB_SECRET=DEMO_GITHUB_APP export DEV_BROKER_GITHUB_OWNER=acme-org export DEV_BROKER_GITHUB_CASE_SECRET=DEMO_GITHUB_APP_CASE_PAIR export DEV_BROKER_GITHUB_CASE_OWNER=ada export PATH=/tmp/agent-secrets-dev.LsGorY/bin:$PATH...With Docker, the script says dispatch-pg is already running, reusing it (or starts it) before it
creates the database.
Leave it running, and paste the exports into a second shell. They point the clients at this broker
and name the caller token, the ServiceAccount token with which Dispatch’s server, and here
agent-secrets-devrelay in its place, decides a machine login for an approver
(Concepts). No token decides a secret request: that is
decided on the broker’s own page, by the person signed in to the broker. The broker lists that
token’s account, system:serviceaccount:example-ns:example-dispatch, as its caller dispatch, and
dev-oidc-issuer rewrites the file before its token lapses, as a kubelet rewrites a pod’s
projected token, so the exports stay good for as long as the stack runs. They also keep the
helper’s socket, its operator file (the Dispatch login the helper’s machine login names,
ada@example.com) and its
machine file (the login the helper keeps across restarts) in the stack’s workdir, so the helper
never touches one already running for your user, or your own machine’s login, and nothing lands in
your current directory. Check that type agent-secrets names the workdir’s bin/: a tool manager
that puts its own directories first on PATH at every prompt runs an installed agent-secrets
instead.
The broker lists a second caller too, and a caller’s name decides which of its named-caller routes
that account reaches. dispatch reaches all of them: the approval, denial and listing routes
Dispatch’s server calls, the machine-login and grant revocations beside them, and the two OAuth
routes below. listener, whose token is $DEV_BROKER_DIR/listener-token, reaches
POST /v1/oauth/introspect alone, since the Envoy listener reads a token back and relays nobody’s
decision. On any other of those routes it is refused by name:
$ curl -s -H "Authorization: Bearer $(cat "$DEV_BROKER_DIR/listener-token")" "$AGENT_SECRETS_URL/v1/pending?approver=$AGENT_SECRETS_APPROVER"{"code":"ACCOUNT_LACKS_POWER","error":"the caller \"listener\" may not call GET /v1/pending"}An account BROKER_API_ACCOUNTS does not list at all is 403 ACCOUNT_NOT_LISTED instead, and an
entry naming a caller the broker does not know refuses the boot, naming that name and the names it
knows, so a typo never becomes a caller that reaches nothing.
The fake secrets file holds two secrets of ada@example.com’s, under the namespace
example/agent-secrets/ and on its example key: DEMO_READ_TOKEN, tagged tier=agent, which her
own sessions (the kinds of session) get without
asking, and DEMO_API_KEY, tagged tier=human, which she approves. Three more are the
registered service’s own, each tagged owner=legion-daemon tier=agent: a pod session enrolled
under that service gets each without asking, and no person’s session sees any of them at all.
They are DEMO_SERVICE_TOKEN, a stored value, and two github-app secrets minted at the GitHub
stand this stack starts: DEMO_GITHUB_APP, whose github-owners tag names acme-org, and
DEMO_GITHUB_APP_CASE_PAIR, whose tag names ada, on which that stand’s App is installed twice
under logins that differ only in case (ada and Ada), so a release of it holds two
installations and agent-secrets --github-token refuses it. The exports name both secrets, the
stand’s address, the route that reads back every call it was sent, and the route that sets what
it answers the next token request (ordinary, refuse or widen). They also name the pod and
the registered service of the enrolment command the banner prints (DEV_BROKER_POD_UID and
DEV_BROKER_SERVICE), so a script enrols that same session from the exports alone. A grant lasts
for the --ttl its request names; with none, 15 minutes when any secret it names is human tier
and 12 hours otherwise
(until its session ends for a secret tagged default-grant=session), and never past its session. Every command below runs in that second shell.
Run a helper and log it in
Section titled “Run a helper and log it in”Start the helper in the background, with its log in the workdir, and log it in. It runs from this
shell rather than through agent-secrets-helper install, since installing would rewrite your own
helper’s systemd --user unit to point at the development broker:
agent-secrets-helper serve 2>"$DEV_BROKER_DIR/helper.log" &agent-secrets machine loginmachine login prints a code and waits. In a third shell with the same exports, approve it as
Dispatch would:
$ agent-secrets-devrelay machine-approve --code YE9L-5FM5{"credential_id":"3bc28cd1-d04b-4481-92a8-ff10350fe795","grant_id":null,"state":"approved"}and machine login exits 0. The helper’s log, $DEV_BROKER_DIR/helper.log, says
machine login issued; the helper holds a launcher credential, and a helper you stop and start
again with the same exports resumes that login from $AGENT_SECRETS_MACHINE_FILE.
Use secrets from a session
Section titled “Use secrets from a session”Start a shell as a registered session and use the secrets:
$ agent-secrets register --wait 10 --exec -- bash$ agent-secrets selfenrollment_id: 2b8627f4-9a2a-4c12-85bf-910144bc0b5ckind: sessionoperator: ada@example.comlease_expires_at: 2026-10-03T03:36:40Zsource: the broker AGENT_SECRETS_URL names (AWS Secrets Manager)$ agent-secrets DEMO_READ_TOKEN -- printenv DEMO_READ_TOKENagent-secrets: DEMO_READ_TOKEN from the broker AGENT_SECRETS_URL names (AWS Secrets Manager)demo-read-token-value$ agent-secrets DEMO_API_KEY --reason "Deploy the example service" -- sh -c 'echo "deploying with a ${#DEMO_API_KEY}-character key"'Every line about a secret names where it came from: the broker this stack runs, and the kind of
store that broker reads. The local stack’s store is BROKER_FAKE_SECRETS_FILE, which stands in for
AWS Secrets Manager. No session’s line names the broker’s host or the store’s account or region,
since an agent’s output is often quoted; agent-secrets secret show names the account and region
its key ARN carries.
The last command waits for approval, and names who approves it and where (waiting for ada@example.com to approve it on the secrets broker: <broker>/approve/<record>). Open that
address in a browser on this machine: the broker sends you to dev-oidc-issuer, which signs you
in as ada@example.com with no page to fill in, and brings you back to the request, where
Approve grants it (approving a request
covers the page), and the command runs:
deploying with a 18-character keyA request has two ids: the request id agent-secrets prints and takes, and the record id the
approval page’s address ends in. agent-secrets request NAME --json prints both. Deny on the
same page denies the request, and agent-secrets then exits 77.
Each grant so far lasts as long as the default for a request with no --ttl: 15 minutes for
DEMO_API_KEY, which is human tier, and 12 hours for the agent-tier secrets (agent-secrets self
lists each expiry). A request may ask for another lifetime with --ttl, and the grant then ends on its own once that has
passed, while the session lives on:
$ agent-secrets DEMO_READ_TOKEN --ttl 2m -- printenv DEMO_READ_TOKENagent-secrets: DEMO_READ_TOKEN from the broker AGENT_SECRETS_URL names (AWS Secrets Manager)demo-read-token-value$ agent-secrets selfenrollment_id: 7cf72ec3-d10f-46fd-984f-ddd856b229c2kind: sessionoperator: ada@example.comlease_expires_at: 2026-10-11T01:51:13Zsource: the broker AGENT_SECRETS_URL names (AWS Secrets Manager)grant: f81251ab-6552-450c-9eca-12a793a29f4c (request 86237327-1f78-4c79-82a5-4f71174e71c5, expires 2026-10-11T01:38:14Z)Two minutes later that grant releases nothing (GRANT_NOT_LIVE), self no longer lists it, and
the same command asks again. --ttl is a whole number of seconds, 2m or 90s; anything else
exits 2 before asking. When the broker or the machine’s helper is from before --ttl, which would
grant for longer, agent-secrets ends the grant it got (or cancels the request still waiting),
says so, runs nothing and exits 1.
List and revoke from your own shell
Section titled “List and revoke from your own shell”In the third shell, which is outside every session, list your machine logins and the live grants as Dispatch’s pages would, under the helper’s machine login (revoke a session or a grant):
$ agent-secrets machine listCREDENTIAL_ID HOST APPROVED_BY ISSUED EXPIRES STATEa0324632-0cc0-4113-bb26-d4123fab9da6 example-host-devbox ada@example.com 2026-10-10T15:01:31Z 2026-10-17T15:01:31Z okEvery row is a login to the broker AGENT_SECRETS_URL names.$ agent-secrets grant listGRANT_ID SECRETS GRANTED APPROVER DECIDED_BY SESSION OPERATOR EXPIRES8fe92a26-b355-4c28-8679-60e8ce98b1d9 DEMO_READ_TOKEN automatic - - session/example-host-devbox:2856922:35395441 ada@example.com session-endEvery row is a grant from the broker AGENT_SECRETS_URL names (AWS Secrets Manager).Each list says on standard error which broker and store its rows are from, so the table on standard output stays one row per login or grant for a script.
machine list shows your own machines’ logins alone; Dispatch’s machine-login page also lists
every service’s. agent-secrets grant revoke <grant id> ends a grant there, and
agent-secrets machine revoke <credential id> one of your logins. Run in the session’s shell, they
are refused IN_SESSION, so the session’s own commands cannot act as you:
$ agent-secrets machine listagent-secrets machine list: IN_SESSION: pid 2857619 is inside a registered machine session, which acts on itself alone; run machine and grant commands from your own shellThe refusal covers the session’s process tree only: a process the session sends out of it (with
( cmd & ), setsid -f or a tmux server it started) passes, and any process running as your user
can stop the helper. Through these commands such a process can list and revoke your own machine
logins, never a service’s machine login, and your grants, which include every grant whose
approver is you — those you approved and those the model judge approved in your stead — and
every grant the judge approved in anyone’s stead. agent-secrets enroll --helper is open to any
process of your user, so such a process can also enroll a box and read your agent secrets.
Write a secret and see it served
Section titled “Write a secret and see it served”The broker reads the fake secrets file again each time it lists the secrets, describes one or
reads a value, so an edit to the file is what a write to Secrets Manager is in production, where a
person writes with agent-secrets secret (manage a secret);
that CLI calls AWS itself, so it cannot write this file. Add a secret of ada@example.com’s to it,
here with jq:
jq '.secrets += [{"name": "example/agent-secrets/demo-new-token", "kms_key_id": "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", "tags": {"owner": "ada@example.com", "tier": "agent"}, "value": "demo-new-token-value"}]' \ "$DEV_BROKER_DIR/fake-secrets.json" >"$DEV_BROKER_DIR/fake-secrets.json.new"mv "$DEV_BROKER_DIR/fake-secrets.json.new" "$DEV_BROKER_DIR/fake-secrets.json"and ask the broker to reread it, as agent-secrets secret does right after each write
(Concepts). The same call
answered {"name":"DEMO_NEW_TOKEN","served":false,"reason":"absent"} before the edit:
$ curl -s -X POST "$AGENT_SECRETS_URL/v1/secrets/DEMO_NEW_TOKEN/reread"{"name":"DEMO_NEW_TOKEN","served":true}The registered session gets it at once:
$ agent-secrets DEMO_NEW_TOKEN -- printenv DEMO_NEW_TOKENdemo-new-token-valueWriting the file whole, as the mv does, keeps it readable throughout: while it does not parse,
each of those calls fails, naming the file.
Exchange a pod’s token
Section titled “Exchange a pod’s token”The broker’s RFC 8693 token exchange is on, for one account,
system:serviceaccount:example-ns:example-worker, writing the project EXAMPLE for
ada@example.com. $DEV_BROKER_DIR/exchange-token holds that account’s pod token for the
exchange’s own audience, legion-broker-exchange.
That account’s entry fixes no session name, so its pods name their own — and a pod writes under a
name its deployment registered for it and no other. The registration is a route of its own,
POST /v1/oauth/exchange/pod-sessions, which the deployment’s daemon calls as the service
BROKER_SERVICE_LOGINS lets it log in as, here legion-daemon
($DEV_BROKER_DIR/registrar-token); its body carries that pod’s own token for the broker’s
audience ($DEV_BROKER_DIR/pod-token, the token a daemon enrolls the pod with) and the name the
pod may write under. The script makes that call before it prints ready., which is the
registered the exchange pod to write under example-worker-session line above. Making it again
extends the registration, which lasts 24 hours from each write, and the same call with another
name is how you add one:
$ curl -s -X POST -H "Authorization: Bearer $(cat "$DEV_BROKER_DIR/registrar-token")" -H 'Content-Type: application/json' -d "{\"pod_token\": \"$(cat "$DEV_BROKER_DIR/pod-token")\", \"session\": \"example-worker-session\"}" "$AGENT_SECRETS_URL/v1/oauth/exchange/pod-sessions"{"pod_uid":"87f7f873-65a4-47a7-b54d-abe68ac9db83","session":"example-worker-session","expires_at":"2026-10-31T00:20:03.764291Z"}A POST /v1/oauth/token with grant_type=urn:ietf:params:oauth:grant-type:token-exchange,
client_id=dispatch-token-exchange, that pod’s exchange token as subject_token,
subject_token_type=urn:envoy:params:oauth:token-type:kubernetes-service-account and
session=example-worker-session, signed with a DPoP proof of a key of the caller’s own, mints a
DPoP-bound access token. A session no registration names is refused, and the refusal names it:
{"error":"invalid_grant","error_description":"The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client. No registration says this pod may write under the session name 'other-session'. A pod writes under a session name its deployment registered for it."}Either listed caller reads a minted token back (POST /v1/oauth/introspect with the token in the
form). Here the listener token, which may do that and nothing else:
$ curl -s -H "Authorization: Bearer $(cat "$DEV_BROKER_DIR/listener-token")" -d "token=<access token>" "$AGENT_SECRETS_URL/v1/oauth/introspect"{"active":true,"session":"example-worker-session","person":"ada@example.com","project_scope":"named","project":"EXAMPLE","exp":1793320811,"client_id":"dispatch-token-exchange","jkt":"oufCNV-MCX2NLGXXB5yj7Pdnwumhbp1dGcxoxpSTyWk"}POST /v1/oauth/revoke (RFC 7009) ends one before its expiry, and is the only thing that can: an
exchanged token names no machine login for a credential revocation to reach. Its form names the
token, the client it was issued to and, optionally, which kind to look for first. Ending one of the
broker’s tokens relays a decision, so it takes the caller token, and the listener token is
403 ACCOUNT_LACKS_POWER here as on every other such route. The answer is empty, and
introspection says active: false from the moment it commits:
$ curl -s -H "Authorization: Bearer $(cat "$AGENT_SECRETS_ACCOUNT_TOKEN_FILE")" -d "token=<access token>&token_type_hint=access_token&client_id=dispatch-token-exchange" "$AGENT_SECRETS_URL/v1/oauth/revoke"$ curl -s -H "Authorization: Bearer $(cat "$AGENT_SECRETS_ACCOUNT_TOKEN_FILE")" -d "token=<access token>" "$AGENT_SECRETS_URL/v1/oauth/introspect"{"active":false}A token the broker never issued is answered the same empty 200, since the request’s purpose —
that the token not work — holds either way. A token issued to another client than client_id
names is 400:
$ curl -s -H "Authorization: Bearer $(cat "$AGENT_SECRETS_ACCOUNT_TOKEN_FILE")" -d "token=<access token>&client_id=agent-secrets-helper" "$AGENT_SECRETS_URL/v1/oauth/revoke"{"error":"unauthorized_client","error_description":"The client is not authorized to request a token using this method. Make sure that client id and secret are correctly specified and that the client exists."}Ending an access token ends that token alone: only a caller presenting a machine login’s own refresh token ends that login and every token of its family.
Reach a secret behind the airlock
Section titled “Reach a secret behind the airlock”packages/envoy/scripts/dev-airlock.sh starts the whole airlock, in a stack of its own. It runs
dev-broker.sh first, for Postgres, dev-oidc-issuer and the binaries, and then adds what the
airlock needs: cmd/dev-airlock-stands, which serves the stand-in services and records every
request each one is sent; a rules file (BROKER_AIRLOCK_RULES_FILE) and a fake secrets file that
name the same secrets; two broker processes on one database, each with an API port and a proxy
port of its own, behind one TCP front over both API ports and one over both proxy ports; one
enrolled pod session, whose key directory the exports name; and the model judge on both brokers
(let the judge decide a request). Run it in place of
dev-broker.sh, from packages/envoy, and set the same DEV_BROKER_POSTGRES_URL where you have
no Docker. Each bound host listens on port 443 of a loopback address of its own (127.0.0.2 and
up), so the machine must let this user bind 443, and one such stack runs at a time.
This run read that URL from a variable the pod sets, which names the same Postgres server as the
first section. The lines dev-broker.sh prints are the ones that section shows, and are cut here:
$ DEV_BROKER_POSTGRES_URL="$LEGION_TEST_PG_DSN" bash scripts/dev-airlock.shdev-airlock: workdir /legion/scratch/tmp/agent-secrets-airlock.eGQ8I8dev-airlock: starting dev-broker.sh (Postgres, dev-oidc-issuer, its broker)......dev-broker: ready....dev-airlock: created isolated database dev_airlock_egq8i8dev-airlock: starting dev-airlock-stands...dev-airlock-stands: stands listening records=http://127.0.0.1:16053dev-airlock: starting the API and proxy fronts...dev-airlock-stands: front listening addr=127.0.0.1:49793dev-airlock-stands: front listening addr=127.0.0.1:32100dev-airlock: starting broker 1 of 2...dev-airlock: starting broker 2 of 2......dev-airlock: enrolling a pod session...dev-airlock: enrolled pod session 43353ee9-a37a-4531-b082-0b247a9d1662 (pod e9d053df-9faf-4b33-82fc-0b069708961d, service legion-daemon)
dev-airlock: ready.
export AGENT_SECRETS_URL=http://127.0.0.1:49793 export AGENT_SECRETS_KEY_DIR=/legion/scratch/tmp/agent-secrets-airlock.eGQ8I8/session-key export AIRLOCK_PROXY_URL=http://127.0.0.1:32100 export AIRLOCK_RULES_FILE=/legion/scratch/tmp/agent-secrets-airlock.eGQ8I8/airlock.json export AIRLOCK_FAKE_SECRETS_FILE=/legion/scratch/tmp/agent-secrets-airlock.eGQ8I8/fake-secrets.json export AIRLOCK_S_URL=https://s.airlock.test export AIRLOCK_S_RECORDS=http://127.0.0.1:16053/records/s... broker 1: api 127.0.0.1:34621, proxy 127.0.0.1:62559, pid 46403, log /legion/scratch/tmp/agent-secrets-airlock.eGQ8I8/broker1.log broker 2: api 127.0.0.1:27522, proxy 127.0.0.1:20097, pid 46404, log /legion/scratch/tmp/agent-secrets-airlock.eGQ8I8/broker2.log secrets: AIRLOCK_DEMO_TOKEN and AIRLOCK_SCOPED_TOKEN (stored, bound to s.airlock.test; AIRLOCK_SCOPED_TOKEN in authorization and x-key, GET /read alone), AIRLOCK_GITHUB_APP (github-app, installation 7 on acme-org), AIRLOCK_GOOGLE_LOGIN (google-login), AIRLOCK_AZURE_LOGIN (kind azure, human tier) and AIRLOCK_AWS_KEY (kind aws)...Paste these exports into a second shell, as above, and check that type agent-secrets names the
stack’s bin/. The rules file binds AIRLOCK_DEMO_TOKEN to the stand-in service S,
s.airlock.test, and names no headers for it, so its value travels in authorization, the one
header an entry with none takes. The command gets a placeholder of 58 characters in place of the
value, and its HTTPS_PROXY names the proxy front with that grant’s proxy password, so the
command’s call to S goes through the broker:
$ agent-secrets AIRLOCK_DEMO_TOKEN --reason 'read the example service behind the airlock' -- sh -c 'printf "AIRLOCK_DEMO_TOKEN: %s characters, starting %s\n" "${#AIRLOCK_DEMO_TOKEN}" "${AIRLOCK_DEMO_TOKEN%%-*}"; curl -sS https://s.airlock.test/read -H "Authorization: Bearer $AIRLOCK_DEMO_TOKEN"'agent-secrets: AIRLOCK_DEMO_TOKEN from the broker AGENT_SECRETS_URL names (AWS Secrets Manager)AIRLOCK_DEMO_TOKEN: 58 characters, starting agentthe s stand-in answeredWhat S was sent is at its recording route, $AIRLOCK_S_RECORDS. The header it read is not the
placeholder the command held: it is the value the fake secrets file holds for that secret, which
the proxy swapped in. The two SHA-256 digests below are of the whole Authorization header S
recorded and of Bearer and that stored value, and they are the same. Neither command prints
either string:
$ curl -sS "$AIRLOCK_S_RECORDS" | jq -r '.[-1] | .method + " " + .path + " host=" + .host'GET /read host=s.airlock.test$ curl -sS "$AIRLOCK_S_RECORDS" | jq -j '.[-1].header.Authorization[0]' | sha256suma2ecdfa9af6567f28055293e907aaec873a545fa2b0ee30c14e8e2a7b27aa149 -$ jq -j '.secrets[] | select(.name | endswith("airlock-demo-token")) | "Bearer " + .value' "$AIRLOCK_FAKE_SECRETS_FILE" | sha256suma2ecdfa9af6567f28055293e907aaec873a545fa2b0ee30c14e8e2a7b27aa149 -The rules file’s second entry, AIRLOCK_SCOPED_TOKEN, is bound to the same host for GET /read
alone, so the proxy refuses a POST /write with it and names the rule that refused:
$ agent-secrets AIRLOCK_SCOPED_TOKEN --reason 'a path its rule does not allow' -- sh -c 'curl -sS -o /dev/stdout -w " (%{http_code})\n" -X POST https://s.airlock.test/write -H "Authorization: Bearer $AIRLOCK_SCOPED_TOKEN"'agent-secrets: AIRLOCK_SCOPED_TOKEN from the broker AGENT_SECRETS_URL names (AWS Secrets Manager)airlock: not allowed by rule (403)Each of those two calls wrote one line in the log of whichever broker the proxy front sent it to
($AIRLOCK_DIR/broker1.log and broker2.log): airlock proxy: a call to a bound host with the
enrollment, the grant, the secret, the host, the method, the path, the answer’s status, and
allowed=true or allowed=false with reason="not allowed by rule". No line of it holds a
value. Ctrl-C in the first shell stops every process this script started, drops its database and
prints the workdir it kept.
Let the judge decide a request
Section titled “Let the judge decide a request”The stack of the first section runs no model judge. The airlock stack runs one, on both broker
processes. Three more stand-ins of cmd/dev-airlock-stands serve it: a model endpoint, a
Dispatch holding one open issue and one done one, and a Compute Engine metadata server, where
the broker mints the identity token it presents to the model. Three human-tier secrets tell the
judge’s class from everything outside it:
| Secret | Its rules entry and owner | A request for it |
|---|---|---|
AIRLOCK_JUDGED_TOKEN |
judge: 900, owned by ada@example.com |
The judge decides it. |
AIRLOCK_SHARED_TOKEN |
judge: 900, owner=shared |
The judge decides it, in the place of anyone who signs in. |
AIRLOCK_UNJUDGED_TOKEN |
no judge, owned by ada@example.com |
It waits for her, as every human-tier request does without a judge. |
Ask for one with a lifetime inside that entry’s seconds and a reason naming the Dispatch stand’s open issue. The exports name the secret and the issue:
agent-secrets request "$AIRLOCK_JUDGED_SECRET" --ttl 10m \ --reason "read the example service for $AIRLOCK_DISPATCH_OPEN_ISSUE" --wait 60sagent-secrets status <request id> then names the judge as the decider,
decided_by: judge:$AIRLOCK_JUDGE_MODEL, and the broker that decided it logs one line,
broker judge: wrote a verdict, with the verdict, the model’s reason and, where a check stopped
the run, the check that failed. The same verdict is one request.judged row in the audit table
(Concepts). What the model was sent is at its
recording route, $AIRLOCK_MODEL_RECORDS, and what Dispatch was asked at
$AIRLOCK_DISPATCH_RECORDS, which holds two GETs for each run that reached the check, and no
write at all.
The model stand approves until something tells it otherwise. A POST to
$AIRLOCK_MODEL_ANSWER_URL sets what it answers from then on:
{"verdict": "refer", "reason": "…"} for the other verdict, {"text": "…"} for a reply that is
no such object, and {"delay": "30s"} for an answer that never arrives inside the judge’s 20
seconds. Each of those leaves the request pending for ada@example.com, which is what the judge
does with everything it does not approve.
Starting the stack with --judge-skip-checks sets BROKER_JUDGE_TEST_SKIP_CHECKS: the judge
still makes every check and records each, and none of them holds a request back, so the model is
asked about a request a check would have stopped. It is the negative control of those checks, and
a broker refuses it on a production start. packages/envoy/scripts/judge-check.sh --help lists
the checks that script drives end to end, each on a stack of its own, printing what it observed
beside what the check expected.
- In the first shell, press Ctrl-C: the script stops the broker and
dev-oidc-issuer, drops its database, and prints the workdir it kept. A token the issuer minted verifies nowhere else, and no longer verifies once it stops. - In the second shell, leave the registered session (
exit), then stop the helper:kill %1. - Remove the workdir, which holds the binaries, the fake secrets, the token files and the logs:
rm -rf "$DEV_BROKER_DIR".
What is real and what is not
Section titled “What is real and what is not”The broker, its database, its policy, its approval page and every client are the real ones. Four
things stand in for production: agent-secrets-devrelay plays Dispatch’s machine-login relay
alone (it presents the caller token and names the decider itself; it cannot decide a secret
request, and neither can Dispatch); dev-oidc-issuer plays both the cluster’s ServiceAccount
token issuer and the Workspace a person signs in to the broker at (the Go tests’ own local issuer,
internal/oidc/oidctest, served on a loopback port: it publishes a key it holds in memory, mints
the stack’s five token files, one pod uid per service account, so the exchange pod’s two — one for
each audience — are one pod, and signs every authorization in as ada@example.com with no page to
fill in); the pod-session registration the script makes before it is ready stands in for the
deployment’s own daemon, which makes that call as it starts a pod; and the fake secrets file
(BROKER_FAKE_SECRETS_FILE: each secret’s name, key, tags and value, as JSON) plays Secrets
Manager and KMS. The broker reads that file again on every call it makes to it, so editing it is
how you write a secret here.
The airlock’s proxy, its rules, its decision and its swap are the real ones too, and so is each
broker process the airlock stack runs. Five more things stand in there. cmd/dev-airlock-stands
plays every host the rules bind: S and T, which answer one line and record what they were sent,
the GitHub and Google endpoints a github-app and a google-login secret mint at, the Microsoft
token endpoint the azure kind exchanges at, which also plays the resource host that token is
spent at, and an AWS service that verifies Signature Version 4. Each is a .test name no
resolver answers, which BROKER_AIRLOCK_TEST_RESOLVE and BROKER_AIRLOCK_TEST_DIAL_ALLOW point
at one loopback address each, so the proxy reaches those five and refuses every other private
address, as it does in production. The judge’s own run — its six checks, its one model call and
what it writes — is real as well, and three more of that command’s stands serve it, on loopback
addresses the broker reaches directly rather than through the proxy: a model endpoint that
answers whatever was last set for it, a Dispatch holding one open issue and one done one, and a
metadata server that mints the broker’s identity token. The rules file the script writes stands
in for the deployment’s own, which lives in the deployment’s repository. The proxy’s certificate
authority is made at each start, where a deployment keeps it in a Kubernetes Secret both broker
pods mount. The two brokers behind one TCP front stand in for two pods behind a Service, so a
call reaches either process. And the pod session the script enrols stands in for the Legion
daemon’s enrollment of a worker pod, which is why agent-secrets self answers kind: pod there.