Skip to content

Manage a secret

An agent secret is a secret in the broker’s own store - AWS Secrets Manager or GCP Secret Manager, whichever the broker’s deployment uses - under the broker’s namespace, encrypted with its key and tagged with its owner and tier (Concepts). agent-secrets secret manages them. It reads and writes the store itself, under your own cloud sign-in, so that cloud’s own IAM decides what you may do; the broker never writes a secret. After every write the CLI asks the broker to reread that one secret, so the change is served at once rather than at the broker’s next read of the namespace. The examples on this page are AWS’s; GCP’s backend differs only where this page calls it out.

You need three things:

  • agent-secrets on your PATH. Each legion-envoy-v* GitHub release ships it for Linux in agent-secrets-amd64.tar.gz and agent-secrets-arm64.tar.gz, beside the helper, and for macOS in agent-secrets-darwin-amd64.tar.gz and agent-secrets-darwin-arm64.tar.gz, which hold the CLI alone. Managing secrets needs no helper, no machine login and no agent session.
  • AGENT_SECRETS_URL, the broker’s address. The CLI asks the broker for the namespace, the agent-secrets key, and that key’s store and account/project (GET /v1/settings), so you configure none of them.
  • A cloud sign-in in the broker’s account or project. AWS: every form takes it from --profile, else AWS_PROFILE, else the AWS SDK’s default credential chain; a write needs your own IAM Identity Center sign-in. GCP: every form uses your Application Default Credentials (gcloud auth application-default login); a write needs that to be a person’s sign-in, never a service account’s.
Terminal window
export AGENT_SECRETS_URL=https://secrets.internal.example
aws sso login --profile <profile> # AWS
export AWS_PROFILE=<profile>
gcloud auth application-default login # GCP, instead
Terminal window
$ agent-secrets secret create DEMO_DEPLOY_TOKEN --owner me --tier agent
Value for DEMO_DEPLOY_TOKEN:
created DEMO_DEPLOY_TOKEN (owner=ada@example.com, tier=agent)
broker: serving DEMO_DEPLOY_TOKEN
  • The name is the one agents ask for, and the environment variable their command gets it in: uppercase letters, digits and single underscores, starting with a letter. In Secrets Manager it is <namespace>demo-deploy-token. Any other name is refused at once (exit 2).
  • --owner me writes your sign-in’s email in lowercase: your Identity Center session name (AWS) or your Application Default Credentials’ Google account (GCP). It must be an email the broker accepts, or the command refuses before writing. The broker matches it against the email a person signs in to Dispatch with, so me names you where that session name or Google account is the same email. --owner shared makes the secret everyone’s. GCP stores owner and tier as the secret’s annotations, never labels, so they never collide with a label someone else applies.
  • --tier agent lets the owner’s own sessions use it without asking, and --tier human sends every use to a person for approval. Both flags are required.
  • The value. At a terminal the CLI reads one hidden line. Enter or Ctrl-D ends it; Backspace, Ctrl-W and the terminal’s kill-line character edit it. An unhandled control byte, including Ctrl-S, Ctrl-Q, and Ctrl-C, Ctrl-\ or Ctrl-Z inside a bracketed paste, refuses the value. The prompt keeps discarding through Enter or Ctrl-D and the paste’s end before reporting the error and naming the pipe command. Anywhere else the CLI reads standard input to its end, less one trailing newline. Empty values and invalid UTF-8 are refused (exit 2) and write nothing.

The CLI checks your sign-in before it asks for the value, so a refused sign-in never has you type a secret for nothing. The secret is created on the broker’s key with both tags, and the broker serves it from the next request. Secrets Manager refuses a name that is already taken, including one scheduled for deletion: restore that one instead.

The prompt takes one line. A bracketed paste of more than one line, such as a key, is read through its closing mark and refused (exit 2), however long its writes take, as long as no more than 10 seconds of quiet pass between them: the bound counts the quiet since the last input, not the time since the paste began. A paste that falls quiet for 10 seconds before its closing mark comes is given up, and the command exits 1, storing nothing; input that arrives after it is given up reaches your shell. Most terminal emulators and tmux support bracketed paste.

Without brackets the CLI drains input until 200 ms of quiet pass after the line, with no total limit, and Ctrl-C ends the drain. A paste-start mark split so that only its first bytes arrive before the line ends holds the drain for one second for the rest of the mark, so a late paste still opens and is read through rather than reaching your shell. That quiet window is not a guarantee about an entire paste: bytes arriving later can reach your shell. If those bytes are a second line, the first line may already have been accepted as the value. Pipe multi-line values in rather than pasting them, and use secret set NAME < FILE to replace a value. The refusal names the command that pipes the value in:

Terminal window
$ agent-secrets secret set DEMO_DEPLOY_TOKEN
Value for DEMO_DEPLOY_TOKEN:
agent-secrets secret set: a value of more than one line must be piped in: agent-secrets secret set DEMO_DEPLOY_TOKEN < FILE
$ agent-secrets secret set DEMO_DEPLOY_TOKEN < key.pem
set a new value of DEMO_DEPLOY_TOKEN
broker: serving DEMO_DEPLOY_TOKEN

Ctrl-C, Ctrl-\, SIGTERM or SIGHUP at the prompt restores the foreground terminal and writes nothing, then ends the CLI by that signal. A shell reports 130, 131, 143 or 129 respectively. Ctrl-C also stops a shell’s ; list. On Unix, core dumps are disabled before the value is read and stay disabled for the rest of the process. On other platforms, crash-dump policy is OS-managed.

Ctrl-Z discards the entry. The CLI discards what it holds of the value and whatever the terminal holds unread when it stops, which is everything that arrived with Ctrl-Z or before it, so none of that reaches your shell, and it refuses the entry rather than storing a partial secret. Keys typed after the stop has taken effect go to your shell, which then holds the terminal. After fg, input stays hidden only while the remainder is discarded: press Enter, then run the command shown in the message and type the whole value again. The command exits 2 and stores nothing. After bg, the reader waits without touching the terminal until fg gives it back, then discards the entry in the same way. SIGTERM or SIGHUP while it waits there ends it at once by that signal, leaving the terminal to your shell. If the shell that ran it exits first, it exits 2, naming the command that pipes the value in, and leaves the terminal untouched. A wrapper that ignores SIGTSTP keeps it ignored, without discarding the entry.

A command started with & waits for fg. It shows no prompt and reads nothing while your shell holds the terminal. fg brings it forward, the label appears, and the value reads hidden. When the command ends, the terminal is left the way fg handed it over. A stop before the label appears discards nothing, since you have typed nothing for it yet. If no shell can ever bring it forward, for example because it was started from a subshell that has since exited, it shows nothing and exits 2, naming the command that pipes the value in.

The hidden prompt is available on Linux and macOS for amd64 and arm64. Builds for other targets refuse an interactive value with exit 2, naming the command that pipes the value in. None of the prompt’s macOS-specific code has run on a macOS machine: that includes the stop and resume path, the discard of unread input at Ctrl-Z, the two checks behind the refusal of a command started with & (an orphaned process group, and a shell that has exited), keeping an inherited ignored signal ignored, and disabling core dumps. It is built and checked for macOS, and read from its kernel source, but not exercised.

On macOS, poll(2) may not report /dev/tty ready, so reading the value from it directly (agent-secrets secret set NAME < /dev/tty) can leave the prompt waiting after you press Enter; Ctrl-C ends it and restores the terminal. Run the command from the terminal’s own standard input.

Before anything is read or written, every form asks AWS whose sign-in it holds (sts:GetCallerIdentity, which needs no permission). A sign-in in any account but the broker’s is refused, and the refusal names both accounts:

Terminal window
$ agent-secrets secret list
agent-secrets secret list: your AWS sign-in arn:aws:sts::999999999999:assumed-role/AWSReservedSSO_User_abc123/Ada@Example.com is in account 999999999999, but the agent secrets are in account 111122223333: sign in to account 111122223333 (--profile or AWS_PROFILE names the sign-in)

A write (create, set, retag, delete, restore) also refuses any sign-in that is not a person’s own Identity Center sign-in in that account, naming what it found, so a machine’s role, an assumed service role or an IAM user writes no secret:

Terminal window
$ agent-secrets secret create DEMO_DEPLOY_TOKEN --owner me --tier agent
agent-secrets secret create: arn:aws:sts::111122223333:assumed-role/example-devbox-instance-role/i-0abc is not a person's Identity Center sign-in: a secret is written under your own sign-in to account 111122223333 (aws sso login, then --profile or AWS_PROFILE), never a machine's role

GCP write checks are the same shape, with one difference: a write refuses Application Default Credentials that are not a person’s gcloud auth application-default login (a service account key file, or a GCE/GKE metadata-server or workload identity, writes no secret). GCP has no wrong-account read check: every call already names projects/<project number>/... explicitly in the resource, so IAM on that resource, not this CLI, is what decides whether a read succeeds.

A read (list, show) takes any sign-in the cloud’s own IAM lets read the namespace’s secrets, a machine’s role or service account included. These checks are the CLI’s own; what a sign-in may actually read or change is each cloud’s IAM to decide, and Operating the broker lists the calls each form makes.

list shows every agent secret, including one scheduled for deletion:

Terminal window
$ agent-secrets secret list
NAME OWNER TIER VALUE DELETED EARLIEST_PURGE
DEMO_DEPLOY_TOKEN ada@example.com agent yes - -
DEMO_EMPTY_KEY ada@example.com agent no - -
DEMO_SHARED_KEY shared human yes - -

VALUE says whether a version of the secret carries AWSCURRENT, the label Secrets Manager reads a value from. The broker serves a secret only while one does, so a secret another tool created without a value shows no until someone runs secret set on it. A missing tag shows as -. show prints one secret’s tags, dates and versions:

Terminal window
$ agent-secrets secret show DEMO_DEPLOY_TOKEN
name: DEMO_DEPLOY_TOKEN
secret_name: example/agent-secrets/demo-deploy-token
owner: ada@example.com
tier: agent
created: 2026-10-06T20:31:08Z
last_changed: 2026-10-06T20:31:08Z
version: 8ae2ea20-8e93-4f1a-b3d1-2e5396ecef72 AWSCURRENT

Neither ever prints a value, and both take --json.

Terminal window
$ agent-secrets secret set DEMO_DEPLOY_TOKEN
Value for DEMO_DEPLOY_TOKEN:
set a new value of DEMO_DEPLOY_TOKEN
broker: serving DEMO_DEPLOY_TOKEN

set reads the value as create does: one line at a prompt with echo off, or all of standard input, which is how a value of more than one line goes in. The broker never keeps a value; it reads it from Secrets Manager each time a session reads a grant, so the next read of a live grant gets the new one.

Terminal window
$ agent-secrets secret retag DEMO_DEPLOY_TOKEN --tier human
retagged DEMO_DEPLOY_TOKEN (owner=ada@example.com, tier=human)
broker: serving DEMO_DEPLOY_TOKEN

Name --owner, --tier or both. retag reads the tags the secret holds and sends both in one request, the unchanged one as it is: on AWS, so an IAM condition on the request’s tags (one that lets a person tag only their own secret, say) sees both. GCP has no such condition (an IAM condition cannot test an annotation’s value the way it can an AWS tag), so GCP’s access is coarser: anyone granted secretmanager.secrets.update on the namespace can retag any secret under it, shared ones included, and anyone granted secretmanager.versions.add (the permission set needs) can replace any secret’s value under it the same way. For a secret missing a tag, name that one too.

You can retag your own secret, to --owner shared too. Once a secret is shared, its owner and tier are an administrator’s to change: on AWS, by the IAM condition above; on GCP, because a deployment that wants this protection grants secretmanager.secrets.update on a shared secret only to administrators. When the cloud refuses a retag of a shared secret, the CLI prints that refusal, then a shared secret's owner and tier are an administrator's to change, and exits 1. It prints the same line when the cloud refuses a retag to --owner shared; for your own secret, that refusal means your access does not include the retag (Troubleshooting).

New tags decide what sessions get from the next request, and reach some grants already given: Approvals says which. Revoke a session or a grant uses a retag to end every session’s automatic access to a secret.

Terminal window
$ agent-secrets secret delete DEMO_DEPLOY_TOKEN
deleted DEMO_DEPLOY_TOKEN
broker: DEMO_DEPLOY_TOKEN is deleted (restorable until 2026-11-05T20:22:33Z)

delete schedules the deletion with a 30-day recovery window, the longest Secrets Manager allows, and never forces it. The date it prints is the one Secrets Manager answered for the deletion: the end of that window. The broker stops serving the secret at once, so a grant that held it releases nothing (GRANT_NOT_LIVE). Until you restore it, Secrets Manager keeps the secret but refuses to read its value, to change its value or tags (set, retag), and to create another of the same name. delete and restore are permissions of their own (secretsmanager:DeleteSecret, secretsmanager:RestoreSecret): where your access does not include one, AWS refuses it with AccessDeniedException, your own secret included. GCP has no separate recovery window: delete sets the secret’s expire_time to 30 days out in one UpdateSecret call and prints it; the secret and every version stay readable and restorable until that moment, and GCP deletes them for good, irreversibly, once it passes. restore clears expire_time the same way.

list and show keep showing a deleted secret, with when it was deleted (AWS) or GCP’s expire_time, the future moment it purges for good (GCP - no separate EARLIEST_PURGE there, since that moment is both), and the earliest it can be purged:

Terminal window
$ agent-secrets secret list
NAME OWNER TIER VALUE DELETED EARLIEST_PURGE
DEMO_DEPLOY_TOKEN ada@example.com human yes 2026-10-06T20:22:33Z 2026-10-13T20:22:33Z
DEMO_EMPTY_KEY ada@example.com human yes - -
DEMO_SHARED_KEY shared human yes - -
EARLIEST_PURGE is the deletion plus Secrets Manager's 7-day minimum recovery window: restore works at least until then; the window the delete chose may be longer.

EARLIEST_PURGE is the deletion plus 7 days, Secrets Manager’s shortest recovery window: Secrets Manager answers when a secret was deleted, but not the window its delete chose. A delete through this CLI stays restorable for 30 days, and one from the AWS console or another tool for between 7 and 30, so restore works at least until that date and perhaps well after it. The legend goes to standard error, so standard output stays one row per secret. show prints the same two dates as deleted: and earliest_purge:.

restore cancels the deletion, and the broker serves the secret again:

Terminal window
$ agent-secrets secret restore DEMO_DEPLOY_TOKEN
restored DEMO_DEPLOY_TOKEN
broker: serving DEMO_DEPLOY_TOKEN

After each write, the CLI asks the broker to reread the secret (POST /v1/secrets/{name}/reread) and prints its answer on a line of its own:

Line What it means
broker: serving NAME The broker serves the secret as just written.
broker: NAME is deleted (restorable until <date>) After a delete: the broker no longer serves it.
broker: refusing NAME (reason=<reason>) The broker leaves the secret out: absent (it found no such secret, or one scheduled for deletion), or one of the refusals Operating the broker lists, such as no-current-value.

The command exits 0 when the answer is what the write meant. It exits 1 when the answer contradicts the write, and says so on standard error: the broker refuses NAME after the write (reason=…) after a create, set, retag or restore, or the broker still serves NAME after its delete. The write itself stands in Secrets Manager either way, so fix what the reason names rather than repeating the write:

Terminal window
$ agent-secrets secret retag DEMO_EMPTY_KEY --tier human
retagged DEMO_EMPTY_KEY (owner=ada@example.com, tier=human)
broker: refusing DEMO_EMPTY_KEY (reason=no-current-value)
agent-secrets secret retag: the broker refuses DEMO_EMPTY_KEY after the write (reason=no-current-value)

Here the secret has no value; secret set DEMO_EMPTY_KEY gives it one, and the broker serves it.

When the broker cannot be asked at all (it is unreachable, or its reread limit refuses the call), the write still stands and the command exits 1:

Terminal window
$ agent-secrets secret set DEMO_DEPLOY_TOKEN < token.txt
set a new value of DEMO_DEPLOY_TOKEN
agent-secrets secret set: the write to the secret store stands, but the broker could not be asked to reread DEMO_DEPLOY_TOKEN, so it serves the change only from its next reload: too many secret rereads; try again later (RATE_LIMITED)

Do not repeat the write. A new value is read at the next grant read anyway, and a create, a restore, or a first value for a secret that had none is served on the first request naming it, since the broker rereads a name it does not serve before refusing it. A retag or a delete of a secret the broker serves takes effect at its next read of the namespace, within about ten minutes, or at once when you ask for the reread yourself:

Terminal window
curl -s -X POST "$AGENT_SECRETS_URL/v1/secrets/DEMO_DEPLOY_TOKEN/reread"

Troubleshooting lists the messages the secret forms print and what to do about each.