passbox
A password store for machines that run agents. An agent asks for a secret, macOS raises a Touch ID prompt naming the agent and the secret, and the value goes into one child process rather than into the agent.
Secrets are ordinary age files. The broker decides who may open them. See docs/content/design.md for why it is built this way and what it costs.
It runs without an Apple Developer Program membership.
This is a new way to store passwords, a fingerprint releases one or more secrets into one process. It is young and may have bugs. Keep another copy of anything you cannot lose.
Install
Recent Homebrew asks you to trust a third party tap before it will build from one. If the install stops and says so, run brew trust --formula gabrielkoerich/tap/passbox and try again.
That downloads a prebuilt binary. Nothing is compiled, so Rust is not needed.
Or build it from crates.io, which needs Rust and the Command Line Tools for swiftc. A full Xcode is not needed.
init binds the store to this Mac's Secure Enclave and asks for nothing else. There is no passphrase, so there is no file anyone can carry off and grind at.
The cost: the store opens on this Mac only. Lose the Mac and the secrets are gone. Sync adds a way back, and it is off until you turn it on.
On Linux
Take the binary from a release:
&&
Or build it with cargo install passbox --no-default-features.
The release binary is statically linked against musl, so it runs on any distribution regardless of its glibc. Both are the client build: no Secure Enclave, no broker and no sync. On its own it opens a store with a passphrase. To have a Mac approve each read with its fingerprint instead, see Another machine.
Use
|
get writes the secret to stdout, so it refuses when stdout is not a terminal, where the value would land in a pipe, a file or an agent's context. A deliberate capture passes --reveal. For the clipboard use copy, which marks the entry so clipboard managers skip it and clears it after a few seconds. For a command use exec.
add refuses a name that already exists. To change a value, passbox rm it and add it again. The old value stays in its versions, so passbox restore brings it back.
Alfred
The Alfred workflow copies a secret from Alfred: type pbx, press ⏎ and touch the sensor, Alfred reopens with your names, pick one, and ⏎ copies the password or ⌘⏎ the username, through passbox copy. It needs the Powerpack.
Mac app
The menu-bar app opens a search panel on ⌘⇧Space. ⏎ copies the password and ⌘⏎ the username, through passbox copy. It lists the names once and keeps them in memory, so later searches need no fingerprint. It builds with the Command Line Tools alone:
SSH keys
The broker is also an SSH agent. Add a key as a secret, point SSH_AUTH_SOCK at the broker, and every signature asks for your fingerprint. The private key never leaves the broker.
# in ~/.zshrc
Unencrypted Ed25519 and P-256 keys only. Remove a passphrase with ssh-keygen -p -f KEY, then delete the file once the key is in the store. The prompt says what is signed and with which key, such as "sign an SSH login as git with SSH key you@mac for ssh", and a key synced from another Mac is offered once passbox ls has run there. Git commit signing with gpg.format ssh goes through the same agent. The agent needs the broker running, which the LaunchAgent keeps up.
Coming from pass
Each entry is decrypted through GPG and re-encrypted to the store key. The whole body is kept, so the key: value lines many pass entries carry under the password survive. Entries already in the store are skipped, and nothing in pass is changed or removed.
Giving a secret to an agent
The value goes into one child process and never into the agent.
Prefer --stdin where the tool accepts it. Any process running as you can read a child's environment with ps eww.
Set PASSBOX_AGENT to name the caller in the prompt. Without it, passbox reads the process chain and uses the first ancestor that is not a shell, such as claude or codex.
PASSBOX_AGENT=claude-code
The prompt then reads passbox is trying to release the password for github/token to claude-code. macOS writes the opening clause from the binary name, and passbox writes the rest.
Through MCP
An agent asks through tools rather than a shell, and the tools cannot hand it a value at all.
Any client that speaks MCP over stdio works, and the command is always passbox mcp. For one configured by file:
That file is ~/.claude.json for Claude Code, ~/.codex/config.toml for Codex in TOML form, and ~/.cursor/mcp.json for Cursor. Check it took with claude mcp list.
The server exposes two tools, and neither returns a secret.
| Tool | Returns |
|---|---|
list_secrets |
names only |
run_with_secret |
the command's output, with the value scrubbed out of it |
There is deliberately no get_secret. A tool result lands in the model's context, where it is logged and sent onward, so the tools run the command for the agent. If the command prints the secret anyway, passbox replaces it in the output before returning.
The agent name in the prompt comes from the MCP handshake, so it is the client's own clientInfo.name rather than a guess up the process tree.
Grants and MCP
The MCP server is a client of the broker like anything else, so a token works the same way: PASSBOX_TOKEN in the server's environment covers the secrets it was granted, and those reads stop prompting.
The server inherits the environment of whatever launched it, which for an editor is the editor itself. So the token has to be in the MCP entry rather than your shell:
The cost is a bearer token in a config file. Worth it for an agent that reads the same few secrets all day and otherwise prompts on every call. For an agent that reads a secret now and then, take the prompt instead.
examples/mcp speaks the protocol to passbox mcp from Python with no SDK, and holds a project .mcp.json. Models that need the whole MCP guide in one place can read llms.txt.
A token cannot widen what the tools already refuse. list_secrets still returns names only, run_with_secret still runs the command, and there is still no get_secret. A token only changes whether you are asked.
Tokens live in the broker's memory, so restarting it revokes every one of them and the MCP server goes back to prompting. It does not need restarting itself.
Approving a project once
A project declares what it needs in .passbox.toml:
= ["github/token", "npm/token"]
= 3600
The first time an agent working in that directory asks for one of them, passbox shows a single prompt covering the whole list, and does not ask again for the window. The default is an hour and the cap is twelve.
The file is a request, not a grant. An agent can write one itself, so nothing is approved until you put your finger on the sensor. The approval is bound to the file's SHA-256: add a secret to the list and the hash changes, so it asks again.
The project is the caller's working directory, read from the kernel rather than taken from the request, so a caller cannot point at a manifest somewhere else. Grants live in grants-<host>.age, encrypted, and never sync.
.passbox.toml and .env.passbox are different files
The names are similar and the jobs are unrelated. They work together, and a project can have both.
.passbox.toml |
.env.passbox |
|
|---|---|---|
| Answers | may this project ask for these | which variable gets which secret |
| Read by | the broker, when deciding | your program, at startup |
| Effect | one prompt covers the list, for a window | populates process.env or equivalent |
| Approval | yes, bound to the file's hash | none, it is a lookup table |
| Safe to commit | yes, it names secrets and grants nothing | yes, same |
The .toml is a request: it changes how often you are asked. The .env is a mapping: it says where a value lands once you have it. Neither holds a value, which is why both can be committed, and neither is a grant on its own.
If a project uses both, the .toml covers the same names the .env maps, so the one prompt covers the whole startup.
Seeing what was released
Every decision is logged, encrypted, one record per line, with the agent, the secret and the caller's process chain. A granted, delegated or by token record also carries grant, the id of the approval behind it, so every read through a token or its children traces back to one fingerprint.
| Decision | Meaning |
|---|---|
released |
A prompt was approved and the value went out, to a caller or a plugin's stdin |
by token |
A grant token covered the read, no prompt |
granted |
A grant was approved for this name |
delegated |
A child token was minted from a live token, no prompt |
project grant |
A .passbox.toml grant covered the read |
approved |
A listing or a plugin run was approved |
unknown |
The prompt was approved, and no secret has that name |
denied |
The prompt was refused |
prompt failed |
No prompt could be shown, such as with the lid closed, or it did not complete |
Plugins
A plugin adds gated actions on a local app, such as Mail or Things 3. Each action is a tool, so an agent runs it behind the same Touch ID prompt and audit log as a secret read.
The same tools appear over MCP as mail.recent, mail.send and so on, so an agent calls them through the server. Every call prompts. The broker approves and audits, the caller runs the command, so it keeps your GUI session. A value is passed as a whole argument to the command, never spliced into a script, so it cannot become code.
First-party plugins:
| Plugin | Needs | Tools |
|---|---|---|
mail |
Mail.app, nothing to install | recent, search, attachments, send |
calendar |
the icalBuddy CLI |
today, agenda |
things3 |
the things CLI |
add, inbox, today, search, and for jobs that parse JSON today_json, inbox_json, search_json, upcoming, logbook, completed, plus all and trash |
imap |
python3, an account entry in the store |
recent, search, read, attachments |
git |
git | push |
gh |
the gh CLI |
pr, issue |
mail drives Mail through osascript, which ships with macOS. The first read raises the macOS automation prompt for Mail once.
calendar and things3 shell out to a CLI, so each has to be installed:
calendar uses icalBuddy rather than raw AppleScript, because AppleScript reports a recurring event's series start instead of the occurrence date.
imap reads any IMAP server, such as Proton Mail through Bridge. It is one Python 3 script that uses only the standard library. Its account is one store entry, mail/imap. The broker writes it to the script's stdin, so the password is never a parameter, an argument, an environment variable or a file, and it never leaves the broker:
The entry's first line is the password, then fields:
bridge-password-here
host: 127.0.0.1
port: 1143
user: me@example.com
tls: starttls
fingerprint: 3a:1f:...:9c
tls is starttls or ssl, and defaults to ssl on port 993. Plain IMAP is refused. Certificates are verified against the system roots. A server with a self-signed certificate, such as Bridge, is trusted through one pinned certificate instead: fingerprint is its SHA-256, compared before the password is sent, or cafile is the path to its exported PEM. Every tool takes an optional --mailbox, INBOX by default. attachments returns the message's attachments as one JSON array of name, type, size and base64, up to 10 MB a call, so nothing is written on the Mac and a remote caller gets the bytes itself. Each name keeps only its base name, so a caller that saves it under that name stays in its own directory.
For Proton Mail, install and sign in to Proton Mail Bridge. Bridge shows the IMAP host, port, user and a Bridge password for the account, which is not your Proton password. Read the certificate fingerprint from Bridge itself:
|
Put those values in the mail/imap entry as above, the fingerprint as openssl prints it or as bare hex. The prompt for each run names mail/imap, and the audit log records it as released. Every run, from the CLI, MCP or a client on the tailnet, happens inside the broker on the Mac, and only the mail comes back. The broker runs only the copy bundled in the binary: if the installed imap.py or plugin.toml differs from it, the run is refused before the prompt until you run passbox plugin update imap.
git and gh run in the directory you call them from, not the plugin directory, so they act on the current repository. Both prompt on every call, so an agent cannot push or open a PR without your fingerprint.
A tool whose command is missing is refused before it prompts, with the install line above.
Write your own plugin as a directory with a plugin.toml, then passbox plugin add <dir>. A tool declares its params and a run command where {param} is one whole argument.
A first-party plugin can name one store secret with stdin = "<secret name>". Such a plugin runs only inside the broker and only as its bundled copy. Each run prompts naming the secret, audits it as released, writes it to the tool's stdin and closes it, and returns the tool's output with the secret scrubbed out. A request that asks only for approval is refused, so the value never goes back to a caller. A plugin you write yourself cannot read a secret: one with stdin is refused. Run it under passbox exec instead.
Each plugin carries a version. passbox plugin update <name|dir> refreshes an installed plugin, and plugin list flags a first-party plugin whose bundled version is newer than the installed copy, which is what happens after a brew upgrade.
Backup and sync
git carries the encrypted secrets and nothing that opens them.
After that every write commits on its own, the way pass does: add, rm, restore and import-pass. Once the repo has a remote, every write also pushes, so no change lives on this Mac alone. A failed push warns and the write still succeeds. Nothing happens until you run git init.
A second Mac clones instead of running git init, so both share one history: git clone <remote> ~/.passbox, then passbox machine add. passbox git pull fetches the other Mac's writes.
A write also updates the copy. Once sync --enable has chosen a directory, every write copies to it, because nobody remembers to run sync, and a secret that exists only on this Mac can be lost. A delete travels too, as a tombstone.
This applies only to a directory, where the copy takes a few milliseconds. An rclone remote is a slow round trip and stays on passbox sync. It is best effort: the value is already written and encrypted, so an unplugged USB stick warns rather than failing the command. PASSBOX_NO_AUTOSYNC=1 turns it off for a script that writes in a loop.
Commit subjects name the operation and never the entry, because a subject naming a secret would undo the work of keeping names out of filenames.
What history costs. passbox rm writes a tombstone and prunes after 90 days, so a secret really goes. Git keeps it: the old .age blob stays in the history, still readable by the store key. With a repo, rm no longer removes the secret from history. pass makes the same trade, so make it knowingly.
Git and sync are not alternatives. With both set up a write does both, and they answer different questions: the repo is this machine's history, the copy is what survives the machine. The repo is never copied to the mirror, because a shared directory holding two machines' git internals would be a mess, and the remote is a mirror rather than a clone.
The Secure Enclave wraps are excluded, so the remote holds opaque age files that only this Mac can read. That protects you from deleting a secret by accident. It does not protect you from losing the Mac, because nothing in the backup can open it. For that, turn on sync.
Sync
Sync is off, and nothing leaves the Mac until passbox sync --enable chooses a destination. After that a write copies to a directory destination, as above, and passbox sync pulls and pushes. There is no timer.
--enable asks two questions.
The first is how a second machine gets in, a YubiKey or a passphrase. See If you lose the Mac.
The second is where the copy goes:
Where should the copy go?
1) iCloud Drive, a folder on this Mac that Apple replicates
2) A directory you name, such as a USB stick or Dropbox
3) A git remote, which also gives you history
4) An rclone remote, for S3, B2, Drive and the rest
The answer is written to ~/.passbox/config.toml, so later runs of bare passbox sync go to the same place. Running --enable again shows the current destination and offers to change it. PASSBOX_REMOTE or --remote overrides one run without changing the file.
[]
= "icloud" # icloud | directory | git | rclone
= ".passbox" # icloud: one folder in iCloud Drive, .passbox when left out
# path = "/Volumes/usb/passbox" directory: an absolute path
# remote = "git@github.com:you/s.git" git, or "b2:passbox" for rclone
To move the copy, edit one line. The file stays on this Mac and is never synced, since a USB path on one Mac means nothing on another. The older one-line ~/.passbox/remote is deprecated. It is still read when there is no config, with a warning, and stops being read in v2. passbox sync --enable moves it to the config, with the same destination.
A destination that makes no sense is refused. passbox checks the config on every read and stops with the reason, instead of copying somewhere new:
- the file is not valid TOML, names an unknown engine or key, or misses the key its engine needs;
- a value is empty, has a space at either end, or holds a control character such as a newline;
- the iCloud
directoryis not one folder name, or apathis not absolute; - the folder does not exist. Only
--enablecreates it, so a path changed by accident or an unplugged USB stick never gets a fresh copy; - the folder overlaps the store, or holds other files and no passbox copy.
A write that cannot reach its copy still succeeds and says why it was not copied.
| Answer | Goes to | Needs |
|---|---|---|
| iCloud Drive | ~/Library/Mobile Documents/com~apple~CloudDocs/.passbox, hidden in Finder until cmd-shift-. |
nothing |
| a directory | wherever you say | nothing |
| a git remote | ~/.passbox made a repo, pushed on each sync |
git |
| an rclone remote | any of rclone's backends | rclone |
iCloud Drive is an ordinary directory on macOS, so the default path is a directory to directory copy. It needs no account, no rclone and no network code.
The config names the kind with engine. A destination given with --remote or PASSBOX_REMOTE has no engine, so passbox tells it apart by shape. A remote starting git@, ssh:// or https://, or ending .git, is git. One starting /, ~ or . is a directory. Anything else holding a colon goes to rclone bisync. The rest is a directory.
Git commits after the copy, which gives you history and an off-site remote.
On a second Mac, passbox sync then passbox machine add, the same steps as recovering onto a replacement.
If you lose the Mac
By default there is no way back. init binds the store to this Mac's Secure Enclave and writes no other wrap, so the key exists in one place and cannot leave it.
Time Machine does not help. It copies wraps/se-<host>.json faithfully, and the file is inert anywhere else. The private half never leaves the Enclave. Erasing the Mac destroys the Enclave keys, so a restore onto a wiped machine finds files it cannot open. Only a restore to the same Mac, not erased, still works.
To have a way back, turn on sync. It asks how the copy should be opened, writes that wrap, then asks where the copy goes.
That copy needs a way in. Two choices:
1) A YubiKey. Nothing in the copy can be attacked, and you keep the token.
2) A passphrase. Works anywhere, and anyone holding the copy can grind it.
Choosing the token handles the rest: it offers to install age-plugin-yubikey, and asks before provisioning a slot, because that changes the hardware.
If you pick the passphrase, anyone holding the copy can attack that wrap offline at their own pace. passbox refuses a passphrase under 12 characters. Use five or six diceware words.
A wrap only helps where the copy reaches. A USB stick in the same bag as the Mac survives neither a fire nor a theft.
A YubiKey instead of a passphrase
A passphrase wrap is the one file in a synced copy worth attacking. A YubiKey wrap has nothing to grind, because the private half stays in the token.
passbox sync --enable does the whole setup. One token is one way back, so add a second as a backup. A YubiKey cannot be copied, so the backup is a second token with its own identity, added to the same wrap. Plug it in and run:
With no recipient, passbox lists every plugged token and drops the ones the store already holds. A token with no identity gets the same setup as sync --enable, but only when no plugged token has one, so unplug the old token first. If several new tokens are plugged in, it shows each serial and asks. It then offers to check the new token by opening the wrap with that token alone.
passbox uses the PIV applet. OpenPGP keys live in a separate applet on the same chip and are never touched. Before provisioning a slot it reads what PIV already holds and shows you, using ykman when it is installed.
When the YubiKey will not cooperate
Five failures give an error that names neither the cause nor the fix. They are listed in the order you are likely to meet them.
Failed to authenticate with the PIN-protected management key
Firmware 5.7 sets the PIV management key algorithm to AES192, and age-plugin-yubikey supports TDES only. A factory reset does not help, because the reset sets AES192 too, so a brand new token arrives in this state.
|
passbox checks this before it asks the plugin for anything, so it says which algorithm is set rather than failing inside the plugin.
Error while communicating with YubiKey: authentication error, after the touch
GPG's scdaemon opens the smart card exclusively and holds it. It lands between the plugin's key generation and its certificate write, and the write comes back as an authentication error. Anything that calls pass starts it again, including a scheduled job, so it can reappear mid-operation.
passbox offers to do this before generating. If something on a timer keeps restarting it, stop that first.
The touch never registers
On a 5C Nano the contact sits flush in the port and is hard to reach. The plugin waits, gets nothing, and reports it as an authentication error.
For a Nano this is the right setting, since the form factor is made to stay in a port. The PIN still applies, so a stolen token is useless without it.
A prompt appears and the PIN is refused
If generation failed after the plugin's PIN-change step, your PIN is already the new one even though nothing was provisioned. Retrying with the old PIN burns attempts, and three wrong tries locks the applet. Check ykman piv info for PIN tries remaining before retrying, and reset rather than guess:
A reset leaves your OpenPGP keys and their PIN untouched. Set TDES again afterwards.
A half-provisioned slot
A failed generation can leave a private key with no certificate. ykman piv info shows the slot; ykman piv keys delete <slot> clears it, or ykman piv reset clears everything.
Recovering on a replacement Mac
machine add asks the recovery passphrase, then binds the new Mac's Enclave, so reads go back to asking for a fingerprint.
Rejected: backing up the Enclave key itself. It cannot be exported, which is the property that makes a stolen copy of the store useless.
Unattended
A daemon cannot answer a fingerprint prompt at 3am, so the goal is that it never has to read passbox at all after it starts.
Inject at launch, and stop reading
Try this first. One approval, and the value lands in the process environment where every child inherits it:
Several --env and --stdin secrets share one approval: exec grants them together for 30 seconds and reads each through that token, and if the grant is refused it falls back to one prompt per secret.
Nothing shells out to passbox again for the life of that process, so it runs for weeks on one fingerprint and needs you only when you restart it. This works with no code change wherever a credential layer already prefers an environment variable over its providers.
Cost: the value sits in the environment for the process's lifetime, and anything running as you can read it with ps eww. Use --stdin where the child takes a secret that way.
A grant, for what cannot be injected
Some secrets have no usable variable name, and some jobs pick what they read at runtime. For those, one approval mints a token that opens exactly the secrets it was granted, to whoever holds it, until it lapses:
The token goes to stdout only and the covered names to stderr, so $(...) captures the token alone. It never goes to stderr, which lands in scrollback, CI logs and agent transcripts. Reads that present it are audited as by token.
A namespace is refused. grant acme would hand over everything under it to save one prompt, and it scopes on how the store happens to be laid out rather than on what the job reads: put everything at the root and a namespace rule protects nothing.
The refusal lists them, so you can copy the names from it. When the broker's key is still warm from a recent read, the refusal comes before any prompt. Otherwise it comes after one.
24 hours is the ceiling and it cannot be raised. A token that never lapses would work like a password. A job that must run unattended with no end date should use injection.
Restarting the broker revokes every outstanding token:
A supervisor that starts its own workers
A daemon that starts workers holds one parent token and gives each worker a child for that worker's names only. A child mints with no prompt when every name is in the parent and it ends no later than the parent. Without --for it ends when the parent does. Anything wider prompts like any grant.
parent= # one fingerprint a day
child=
|
--token-stdin reads the token from the first line of stdin, so it is never in an environment or a file, where docker inspect and ps eww would read it. exec leaves the rest of stdin to its child. Each mint is audited as delegated, with the parent's grant id.
Cost: a leaked child opens its worker's names until the parent lapses, and the supervisor's memory holds the parent, which opens every name.
Mint once per process
A token caches on the client that minted it. A factory that builds a fresh client per call mints a fresh grant per call, and each one is a fingerprint. Memoise it. The symptom is repeated granted for one secret and one agent in passbox audit.
Another machine
A Linux box with no Secure Enclave can read a secret held by a Mac. The fingerprint happens on the Mac; the client holds no store and no key, so a copy of it is worth nothing.
Verified from a Raspberry Pi on a LAN and a fly.io machine in another country, both over Tailscale.
On the Mac
The broker has to be already running, in your login session. One started by an incoming connection cannot raise a Touch ID prompt: whoever calls evaluatePolicy has to be somewhere a sensor is reachable. A LaunchAgent is how you get that.
~/Library/LaunchAgents/com.you.passbox.broker.plist:
Labelcom.you.passbox.broker
ProgramArguments
/opt/homebrew/bin/passbox
broker
EnvironmentVariables
PATH/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin
RunAtLoad
KeepAlive
StandardErrorPath/tmp/passbox-broker.log
Set PATH explicitly. A LaunchAgent gets a minimal one, tailscale is not on it, and the broker then starts with no tailnet listener and says nothing about why.
Why serve rather than binding the tailnet address
The broker binds 127.0.0.1:8787 and Tailscale forwards to it. Binding the 100.x address directly looks right and does not work: macOS runs Tailscale as a network extension, the socket accepts the connection, and the first read fails with ENOTCONN. Measured 2026-09-27, and reproduced with a plain Python server, so it is the platform rather than passbox.
Cost: loopback has none of the unix socket's 0600 protection, so any local user can reach that port. It is the only inbound path macOS actually delivers.
--proxy-protocol 1 makes Tailscale open each connection with the node's tailnet address, since through serve the broker only ever sees 127.0.0.1. The broker resolves that address with tailscale whois, so the prompt and the audit log name the host, as in claude on server (someone@example.com, 100.64.0.7). The name comes from the control plane, not from the client. The broker refuses a connection without the header, because a client could otherwise send its own and claim any host. Set PASSBOX_AGENT on the client to name the job as well, such as an orch job id.
Cost: a local user who connects to the loopback port directly can write a header that names another tailnet host. It does not get past the prompt, but the prompt can then name the wrong host.
On the client
&&
PASSBOX_HOST does the same for one run. A configured host wins over anything on the client's own disk, so a machine with no store still asks the Mac rather than falling back to a passphrase it does not have.
grant and exec go to the Mac too. passbox exec --env A=x/a --env B=x/b -- cmd raises one prompt on the Mac for both, and passbox grant on the client mints a token there and prints it here.
In a container
examples/linux-client has a Dockerfile with Tailscale and the client, and a fly.io deployment that was used to verify this.
Give the container a real tun device. tailscaled --tun=userspace-networking reaches the tailnet only through a SOCKS proxy, so an ordinary connect() to a 100.x address times out with no hint why. The entrypoint uses /dev/net/tun when the platform provides one.
What this does not give you
The job is not identified. The host comes from the control plane, but PASSBOX_AGENT is still whatever the caller claims.
The Mac must be awake, unlocked, and its lid open. Nothing can answer a prompt otherwise. A closed lid fails, and the error says so: Touch ID is unavailable, open the lid or use the recovery passphrase.
Nobody is there at 3am. A scheduled job wants injection or a grant, not a prompt. See Unattended.
Reference
Undoing a change
Every write keeps the last 5 versions, and rm keeps the file too.
What is on disk
~/.passbox/
store/<random-id>.age one self describing secret per file
store/<random-id>.tomb deletion marker
store/.versions/<id>/ earlier versions, kept local
wraps/recipient the store public key, no secret in it
wraps/recovery.age the store key under your passphrase, only once sync is on
wraps/se-<host>.json the store key under that Mac's Secure Enclave
audit-<host>.log one encrypted record per line
broker.sock the approval socket
The filename is a random id. Names live inside the ciphertext, so nothing on disk says what a secret is called. What still leaks: how many secrets exist, their sizes, and their modification times.
Environment
| Variable | Effect |
|---|---|
PASSBOX_DIR |
Where the store lives, default ~/.passbox |
PASSBOX_REMOTE |
Overrides the destination for one run, ahead of the one sync --enable recorded |
PASSBOX_AGENT |
Name shown in the prompt beside the secret |
PASSBOX_NO_AUTOSYNC |
Set to stop a write copying to the configured directory |
PASSBOX_PASSPHRASE |
Supplies the passphrase for CI and headless use, and turns off the Enclave path |
PASSBOX_TOKEN |
A grant token, used for reads it covers |
PASSBOX_NO_ENCLAVE |
Refuses any Secure Enclave operation. The tests and CI set it, so a bug fails them instead of asking for a fingerprint |
passbox moves PASSBOX_TOKEN and PASSBOX_PASSPHRASE out of its environment when it starts and overwrites their bytes, so ps eww cannot read them and nothing it runs inherits them: not the exec child, a plugin, an MCP command, git or the broker. A secret injected under one of those names with --env still reaches the child.
Building
mock-enclave swaps the Secure Enclave helper for a fake that prompts nobody and logs each prompt a person would have seen, so the tests can count them. It is for tests only: a release build with it does not compile, and a build with it aborts outside a cargo target directory. A plain cargo test skips the tests that need it.
build.rs compiles the Swift helper with swiftc from the Command Line Tools.
Fields in one entry
An entry can hold more than a password. The first line is the secret, later key: value lines are fields, which is the layout pass uses and import-pass keeps.
At a terminal, passbox add db/prod --field username --field url asks for the password hidden and twice, as usual, then asks for each field by name. -m then reads more fields and notes one per line until an empty line. Field values and notes show as you type. Without either, add asks for the password alone. Piped input is stored whole.
Reading one field hands a caller the password without the note beside it.
Using it from a program
In Rust, add the crate without its default features. They build the CLI's Secure Enclave helper, which the library does not use:
use Passbox;
// One approval covers both secrets for 24 hours
let mut pb = new;
// Preferred: the value goes from the broker into the child
let out = pb.run_with?;
// When a library needs the value in-process
let db = pb.fields?;
let password = &db;
The library runs the passbox binary on PATH, so it works on a Mac and on a Linux machine that asks one. examples/rust is a small project that depends on it.
examples/ also holds Python and TypeScript clients, each with a runnable self-check. The same shape works in any language: shell out to passbox exec to hand a secret to a child process, and mint one grant per process rather than one per read.
For agents specifically, see AGENTS.md, and the SKILL.md that teaches a coding agent to use passbox correctly.
Comparisons
pass
passbox was built next to pass, and both can stay installed. The table shows where each one is the better tool.
pass |
passbox | |
|---|---|---|
| Private key at rest | a portable file under a passphrase, attackable offline | sealed to the Secure Enclave, inert on any other machine |
| Entry names | plaintext filenames, and in git commit subjects | inside the ciphertext |
| Authorisation | one GPG passphrase, then the agent caches it | every read prompts, and a grant scopes named secrets for a time |
| Who asked for it | unknowable | named in the prompt and in the audit log |
| Giving one to an agent | prints to stdout | injected into one child, and scrubbed from its output |
| Asking from an agent | a shell command it composes | MCP tools that cannot return a value |
| A job at 3am | the agent holds the passphrase | injected at launch, or a token scoped to named secrets |
| Another machine | copy the key there | it asks the Mac, and the fingerprint happens there |
| Maturity | a decade old, packaged everywhere | young, unreviewed |
| Platforms | anywhere GPG runs | macOS holds the store, Linux asks it |
| Ecosystem | browser, mobile, dmenu, otp, import | Python, Rust and TypeScript clients, and an MCP server |
| Losing the machine | keys are portable and backed up by design | the store is gone unless sync is on, then a passphrase or a YubiKey opens it |
| Reading the source | 721 lines of shell | about 5,100 lines of Rust and Swift, plus a daemon |
The first eight rows are why passbox exists. In the rest, pass is the better tool: it is older, it runs everywhere, and you can lose your only machine without losing your secrets.
Security
What has been checked
The parts that came from elsewhere carry other people's review. age and scrypt come from the age crate. The Secure Enclave, HKDF and AES-GCM come from CryptoKit. The hardware guarantee is Apple's.
What is ours is the composition, and these are the checks on it:
- A second implementation, Python's
cryptography, opens what CryptoKit sealed. Two libraries agreeing is the evidence the construction is standard rather than something invented here. It also checks that a stranger's key, a flipped tag byte and a swapped ephemeral key are all refused, and that no ephemeral key repeats. Run it withtests/ecies.py. - The reference
ageCLI opens the secret files, so the store is standard age and stays readable with off the shelf tools if passbox goes away. - Tampered and truncated files are refused: secrets, the recovery wrap, audit records, and grants, which fail closed to no grants at all.
- The base64 the helper is fed matches the RFC 4648 vectors.
Disclaimer
This is not proven and it is not fault proof.
It is young, and no independent security review has happened yet. Cross-implementation tests raise confidence, but they do not replace an audit.
Use it for the secrets your agents reach for. Keep another copy of anything you cannot afford to lose. Findings are welcome.
Licence
MIT