Overview
Recipe 04 stored dev secrets in the macOS Keychain and injected them into a subprocess with envchain. No .env on disk. The catch was in the last line of that recipe: macOS only. I rebuilt my main workstation on Debian, and the Keychain went with it.
The replacement is the Proton Pass CLI (pass-cli). The vault I already use in the browser and the desktop app is end-to-end encrypted and syncs across machines; the CLI reads from that same vault and injects secrets directly into a process. Same pattern as envchain: run reads from the vault, sets the environment, starts your server, nothing written to disk. Except the store is cross-platform and already populated.
bashpass-cli run --env-file .env.pass -- next dev # vault → process env → Next.js
One thing envchain can't do: Proton Pass has first-class agent tokens, short-lived vault-scoped credentials you can hand to an AI agent without giving it your whole vault. More on that at the end.
Prerequisites
- A Proton Pass account (the free tier is fine)
- Debian / any Linux (the same CLI runs on macOS and Windows)
- The Proton Pass desktop app or browser extension, for putting secrets into the vault
Install
pass-cli ships as a single binary. Grab the latest release and drop it on your PATH:
bash# downloads the linux build to ~/.local/bin
curl -fsSL -o ~/.local/bin/pass-cli \
https://proton.me/download/pass/linux/pass-cli-x86_64-unknown-linux-gnu
chmod +x ~/.local/bin/pass-cli
pass-cli --version
Check the Proton Pass CLI docs for the current download URL and your platform's binary — Proton publishes macOS and Windows builds from the same tool.
Authenticate
bashpass-cli login # opens a browser to authorize this session
pass-cli test # confirms the authenticated connection works
pass-cli info # shows the current session
The session is cached locally, so you log in once per machine. For headless boxes (CI, a server, a remote dev container) there's no browser. Mint a Personal Access Token from the Proton Pass settings and log in with it instead:
bashpass-cli login --pat "pst_<token>::<key>"
Organize a vault
I keep one vault per concern. Project secrets live in a vault literally named Project Secrets, one login item per credential. The item title is the lookup key, and the secret sits in a field (usually password):
bashpass-cli vault list
pass-cli item list "Project Secrets"
You can create items from the CLI (pass-cli item create login), but in practice I add them from the desktop app or browser extension. It's faster, and it's the same vault the CLI reads. The naming convention mirrors the env var the app expects:
| Item title | Used by |
|---|---|
ROSTERSYNC_DATABASE_URL | rostersync-app |
ROSTERSYNC_GEMINI_API_KEY | rostersync-app |
CAMPUS_PIPELINE_R2_SECRET_ACCESS_KEY | campus-data-pipeline |
AUTH_SECRET | rostersync-auth |
FMBRIDGE_DATABASE_URL | fmbridge-api |
To read a single value back (the building block everything else depends on):
bashpass-cli item view --vault-name "Project Secrets" \
--item-title "ROSTERSYNC_DATABASE_URL" --field password
Wire up your dev command
pass-cli run is the envchain equivalent: it resolves secrets and injects them into the subprocess you pass after --. The cleanest way to declare which secrets a project needs is a committed template file whose values are secret references, not secrets:
bash# .env.pass — safe to commit. These are pointers, not values.
ROSTERSYNC_DATABASE_URL=pass://SHARE_ID/ITEM_ID/password
ROSTERSYNC_GEMINI_API_KEY=pass://SHARE_ID/ITEM_ID/password
ROSTERSYNC_NVIDIA_API_KEY=pass://SHARE_ID/ITEM_ID/password
Each pass://SHARE_ID/ITEM_ID[/FIELD] reference is the item's URI. Copy it from the Proton Pass app ("Copy secret reference") or read it off pass-cli item view. Then run the dev server through it:
bashpass-cli run --env-file .env.pass -- next dev
At launch the CLI resolves every reference from the vault, sets them as environment variables for that one process, and starts next dev. Nothing plaintext touches disk. As in Recipe 04, only this wiring line changes per stack:
Node.js / package.json
json{
"scripts": {
"dev": "pass-cli run --env-file .env.pass -- next dev"
}
}
Python / Makefile
makefiledev:
pass-cli run --env-file .env.pass -- python manage.py runserver
Go, Ruby, anything else
bashpass-cli run --env-file .env.pass -- go run main.go
pass-cli run --env-file .env.pass -- rails server
To add a new key: create the item in the vault, add one KEY=pass://... line to .env.pass, and it's available on the next run.
When a tool insists on a real .env
Some tools (a migration runner, a one-off pipeline, wrangler) read a .env file directly instead of inheriting the environment. For those, render the references into a file on demand and keep it short-lived. pass-cli inject writes a resolved file with 0600 permissions:
bashpass-cli inject -i .env.pass -o .env --file-mode 0600 -f
This is what my pipeline projects do at the top of a run. The script below checks that the required secrets resolved, writes a locked-down .env, and is meant to be invoked through pass-cli run so the values are already in the environment:
bash#!/usr/bin/env bash
# setup-secrets.sh — materialize a .env for tools that won't read the process env.
# Run it as: pass-cli run --env-file .env.pass -- ./setup-secrets.sh
set -euo pipefail
cd "$(dirname "$0")"
ENV_FILE=".env"
REQUIRED=(
CAMPUS_PIPELINE_DATABASE_URL
CAMPUS_PIPELINE_R2_ACCOUNT_ID
CAMPUS_PIPELINE_R2_ACCESS_KEY_ID
CAMPUS_PIPELINE_R2_SECRET_ACCESS_KEY
)
echo "━━━ checking required env vars ━━━"
missing=()
for var in "${REQUIRED[@]}"; do
[[ -n "${!var:-}" ]] || missing+=("$var")
done
if ((${#missing[@]})); then
echo "✗ missing: ${missing[*]}"
echo " inject them via pass-cli run --env-file .env.pass before running this"
exit 1
fi
echo "✓ all ${#REQUIRED[@]} required vars present"
{
for var in "${REQUIRED[@]}"; do
printf '%s=%s\n' "$var" "${!var}"
done
} > "$ENV_FILE"
chmod 600 "$ENV_FILE"
echo "✓ wrote $ENV_FILE (0600)"
Treat the rendered
.envas disposable. Add it to.gitignore, and delete it after the run if you can. The reference file (.env.pass) is the source of truth. It carries no secrets and is safe in the repo.
Ad-hoc access from the shell
For credentials I reach for interactively, I wrap the lookup in a shell function so the secret only ever lives in the subshell that consumes it. These sit in my ~/.bashrc:
bash# MySQL connections via Proton Pass
fmbridge-db-read() {
MYSQL_PWD=$(pass-cli item view --vault-name "Project Secrets" \
--item-title "fmbridge-db-read" --field password) \
mysql -h fmbridge.example.us-east-1.rds.amazonaws.com -u fmbridge_ro "$@"
}
fmbridge-db-admin() {
MYSQL_PWD=$(pass-cli item view --vault-name "Project Secrets" \
--item-title "fmbridge-db-admin" --field password) \
mysql -h fmbridge.example.us-east-1.rds.amazonaws.com -u fmbridge_admin "$@"
}
fmbridge-db-read and the admin role are now one word each, and the password is never typed, never in shell history, never in a file.
Agent access
For now this works the same way Recipe 04 did: deny rules in ~/.claude/settings.json block the agent from calling pass-cli directly.
json{
"permissions": {
"deny": [
"Bash(pass-cli item view*)",
"Bash(pass-cli inject*)",
"Bash(pass-cli run*)"
]
}
}
That's a fence, not a lock. The agent still inherits whatever secrets the dev server resolved at startup — it just can't go back and read more on its own.
Next step: pass-cli agent
pass-cli has an agent subcommand I haven't wired up yet. Instead of your own session credentials being the only thing in play, you mint a scoped, expiring token specifically for the agent:
bashpass-cli agent create dev-agent --expiration 1w --vault "Project Secrets"
The agent uses that token and can only see the vault you specified — not your mail logins, not your personal vault. You can narrow it further to specific items, revoke it early, or pull an audit trail of what it accessed:
bashpass-cli agent access grant dev-agent ...
pass-cli agent access revoke dev-agent ...
pass-cli agent monitor dev-agent
That's the right shape for this problem. The deny list is a workaround; agent tokens are the actual solution.
Trade-offs
Cross-platform: the reason I switched
This is the line Recipe 04 couldn't write. The same pass-cli and the same vault work on Debian, macOS, and Windows. Moving workstations no longer means re-storing every secret.
It talks to the network
Unlike the Keychain, the vault is fetched from Proton. First resolve of a session needs connectivity, and a Proton outage means a failed pass-cli run. For a workstation that's online anyway it's a non-issue; for an offline build box, render a .env ahead of time.
CI/CD
Recipe 04 was workstation-only because CI can't reach a local Keychain. pass-cli can run in CI: log in with a Personal Access Token (--pat) or a scoped agent token and resolve .env.pass at build time. Keep that token in your CI platform's own secret store. It's the one credential that bootstraps the rest.
Rotating a key
Update the value in the Proton Pass app. The reference in .env.pass is unchanged, so nothing in the repo moves. The next pass-cli run picks up the new value automatically.
