passbox 2.1.0

A password store for agents, where a fingerprint releases one secret into one process
---
name: passbox
description: Read secrets from passbox, inject them into a child process, and stop Touch ID prompts in an unattended job using injection at launch or a scoped grant token. Use when a script, daemon or agent needs a credential, when repeated fingerprint prompts are blocking something long-running, or when working on the passbox store itself.
---

# passbox

An agentic password manager. 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. It never enters the agent.

The store lives at `~/.passbox` (`PASSBOX_DIR` overrides). Every secret is one age file with a random id; the name and policy are inside the ciphertext.

## Setup

Install passbox and create the store:

```bash
brew install gabrielkoerich/tap/passbox
passbox init
```

Let an agent reach it through MCP, which cannot hand back a value:

```bash
claude mcp add passbox -- passbox mcp        # Claude Code
codex mcp add passbox -- passbox mcp         # Codex
```

Install this skill where the agent looks for it:

```bash
passbox skill install      # writes the copy that matches this binary
passbox skill check        # exits 1 if the installed copy is older or differs
```

## Reading a secret

```bash
passbox get github/token                    # prints it, refuses unless stdout is a terminal
passbox copy github/token                   # to the clipboard, cleared after 20 seconds
passbox ls                                  # tree of names, needs a prompt
```

`get` writes to stdout, so it refuses a pipe, a file or an agent, where the value would leak, and no flag overrides that. Use `copy` for the clipboard and `exec` for a command.

**Prefer injection over reading.** The value goes broker to child and never through the caller, so a traceback or a crash dump cannot carry it.

```bash
passbox exec --env GITHUB_TOKEN=github/token -- gh api /user
passbox exec --stdin docker/token -- docker login -u me --password-stdin
passbox exec --file GOOGLE_APPLICATION_CREDENTIALS=gcp/key -- ./job   # a multi-line value, as a temporary file the child reads
```

`--stdin` is the safer of the two: any process running as you can read a child's environment with `ps eww`.

## Unattended: inject at launch

Use this first when a daemon runs 24/7. A token expires, and renewing one needs a human at the sensor. Injecting needs no renewal.

```bash
passbox exec --env SIGNING_KEY=acme/signing-key -- python -m your.daemon
```

It takes one approval at launch. The value lands in the process environment and every child inherits it, so nothing reads passbox again for the life of that process. A daemon runs for weeks on one fingerprint and needs you only when you restart it.

This works because a credential layer should check environment variables before any provider. If yours does, injecting needs no code change: the reads stop reaching passbox.

Cost: the value sits in the process environment for its lifetime, and any process running as you can read it with `ps eww`. Use `--stdin` instead where the child accepts a secret on stdin.

Limit: passbox passes a variable name with a hyphen (`HL_SUBACCOUNT_XSMOM-XYZ_ADDRESS`) to the child, but most shells and libraries cannot read it. Cover those with a token, and cache them in-process so it is one read per start.

## Grants and tokens

A **grant** is one approval that mints a **token**: a bearer string that opens exactly the secrets it was granted, for whoever holds it, until it lapses. A token lasts at most 24 hours.

```bash
export PASSBOX_TOKEN=$(passbox grant acme/signing-key acme/api-key --for 3600)
passbox get acme/signing-key        # no prompt, audited as `by token`
```

The token goes to stdout and the covered names to stderr, so `$(...)` captures the token alone.

**A namespace is refused.** `grant acme` would hand over everything under it to save one prompt, and it scopes on how the store is laid out rather than on what the job reads. Put everything at the root and a namespace rule protects nothing. The refusal lists the names so you can copy the ones you want. It costs no fingerprint when the broker's key is still warm from a recent read.

Restarting the broker revokes every outstanding token:

```bash
pkill -f "passbox broker"
```

### Child tokens for workers

A holder of a token mints a child for a subset of its names with no prompt. The child ends no later than the parent, and each mint is audited as `delegated`.

```bash
child=$(printf '%s\n' "$PASSBOX_TOKEN" | passbox grant --token-stdin w1/key)
printf '%s\n' "$child" | passbox exec --token-stdin --env KEY=w1/key -- ./work
```

`--token-stdin` reads the token from the first line of stdin, so it never sits in an environment or a file.

### There are no long-lived tokens, and that is deliberate

24 hours is the ceiling and it cannot be raised from the command line. A token that never lapses is the same as a password.

So a token is the wrong tool for something that must run untended indefinitely. Use injection, above, for that. Use a token when a process needs several secrets over a bounded run, or when the value must not sit in an environment where `ps eww` finds it.

### Mint once per process, not per call

A token caches on the provider instance, so a factory that builds a fresh provider on every call mints a fresh grant every call, and each one is a fingerprint.

```python
@lru_cache(maxsize=1)
def manager() -> CredentialManager: ...
```

Symptom: repeated prompts for the same secret in a loop or per request. Check the audit log: repeated `granted` for one secret and one agent means the provider is being rebuilt.

### A failed grant must never stop the job

Minting is an optimisation. If it fails, fall back to prompting or to whatever the job did before; never let it abort the start.

## Plugin calls in a scheduled job

Every plugin call prompts, and a token does not cover one. A job that calls a read tool on a schedule needs the owner to open a window for that tool in `~/.passbox/config.toml`. You cannot open one yourself: the broker asks the owner to approve every edit to the section.

```toml
[plugins.windows]
"things3.logbook" = "24h"
"imap.search" = "1h"
```

- Ask the owner for the exact tools the job calls, one key each. A plugin name or a pattern is refused.
- Every first-party tool is a read, and no plugin sends, adds or writes. A window covers any tool the owner lists.
- The first call after the window opens or the section changes prompts once. Later calls from the same scope skip the prompt until the window ends, at most 24 hours, and are audited as `by window`.
- A window covers the exact call its prompt showed. Repeat the same call with the same arguments to stay inside it, such as `imap.recent --count 20` every run. A new query, mailbox or message id prompts again. Omitting an optional and passing it empty are the same call.
- A value over a manifest limit, such as `count` above 50, is refused before the prompt.
- A window covers the scope that opened it: one tailnet host, or every caller on the Mac. A broker restart ends it.
- To send mail, call your own client under `passbox exec`, such as `passbox exec --env SMTP_PASSWORD=mail/smtp -- ./send-report`.

## SSH logins in a scheduled job

Every SSH signature prompts, so a job that connects to hosts while the owner is away fails with `agent refused operation`. The owner can list hosts in `~/.passbox/config.toml`, and the broker asks the owner to approve every edit to the section:

```toml
[ssh.windows]
"legion" = "12h"
```

- The first login to a listed host prompts once. Later logins with the same key to the same host key skip the prompt until the window ends, at most 24 hours, also with the screen locked, and are audited as `by window`.
- A host is named as `~/.ssh/known_hosts` lists it, or by its `SHA256:` host key fingerprint. A pattern is refused.
- It needs OpenSSH 8.9 or later and an Ed25519 or P-256 host key. A forwarded agent and commit signing always prompt. A broker restart ends every window.

## Fields in one entry

The first line is the secret, later `key: value` lines are fields. This is the `pass` layout, which `import pass` keeps.

```bash
passbox get db/prod                   # the whole thing
passbox get db/prod --field username  # just that one
```

Use `--field` when handing a value to something that needs only the password: it leaves the note and the username behind. `examples/python/passbox.py` in the repo parses the same layout.

## Listing names

`ls` goes through the broker like a read, so it raises a prompt on every call and is audited. Do not use it as a health check. Check the binary and that `~/.passbox/wraps` exists instead.

## Reading from another machine

A Linux box with no Enclave can ask a Mac. The client holds no store and no key.

```bash
echo "100.x.y.z" > ~/.passbox/host    # the Mac's tailnet address or MagicDNS name
passbox get github/token              # prompt appears on the Mac
```

Three things have to be true on the Mac, and each fails silently in its own way:

**The broker must be a LaunchAgent** in the login session. One started by an incoming connection blocks inside `evaluatePolicy` forever rather than prompting. `brew services start passbox` sets one up, without sudo.

**Give that LaunchAgent a PATH.** It gets a minimal one, `tailscale` is not on it, and the broker starts with no tailnet listener and says nothing. The Homebrew service sets one that finds `tailscale`, a plist you write yourself has to set it.

**Expose it with `tailscale serve`, not by binding the tailnet address.** macOS runs Tailscale as a network extension; a socket bound to the 100.x address accepts the connection and then fails the first read with `ENOTCONN`.

```bash
tailscale serve --bg --proxy-protocol 1 --tcp 8787 tcp://127.0.0.1:8787
```

`--proxy-protocol 1` is required. It tells the broker which tailnet host connected, so the prompt names it. Without it the broker refuses every remote request.

In a container, give tailscaled a real `/dev/net/tun`. Userspace networking reaches the tailnet only through a SOCKS proxy, so `connect()` to a 100.x address times out with no hint why.

The prompt names the host from `tailscale whois`, such as `claude on server (someone@example.com, 100.64.0.7)`. The agent part is still whatever the client sets in `PASSBOX_AGENT`.

## Gotchas

**An upgrade restarts the broker.** Within 30 seconds of `brew upgrade gabrielkoerich/tap/passbox` replacing the binary, an idle broker exits, and launchd or the next command starts the new one. Live tokens end with it, so mint them again. Until it exits, the old broker serves the old protocol, and the symptom is a parse error such as `unknown variant \`grant\``. `pkill -f "passbox broker"` restarts it at once.

**GPG steals the smart card.** If a YubiKey wrap is involved, `gpg-agent`'s `scdaemon` opens the card exclusively and breaks `age-plugin-yubikey` mid-operation with a misleading `authentication error`. Stop it first: `gpgconf --kill scdaemon`. Anything calling `pass` restarts it.

**Touch ID needs a real session.** A process spawned over SSH, or by an agent's shell, often cannot raise the prompt and will hang. Run those from a terminal.

**Backups.** `init` binds the store to one Mac's Secure Enclave and writes no other wrap, so a Time Machine restore onto a wiped Mac opens nothing. `passbox sync --enable` adds a passphrase or YubiKey wrap and a copy elsewhere. Check `~/.passbox/wraps/` holds more than `recipient` and `se-<host>.json`.