Proton Pass CLI: Replacing Shell-Wide Secret Exports with Process-Isolated Injection
How I moved API key exports from eager shell startup to lazy, process-isolated injection using pass-cli run.
I had API key exports in my .zshrc that ran on every shell startup. Three pass-cli item view calls added 3-9 seconds of latency and leaked secrets to all child processes. I replaced them with pass-cli run wrapper functions that inject secrets only when a command actually needs them. The functions live in my shared .commonrc so they work in both bash and zsh on macOS and Linux.
Exposure and latency
My .zshrc had three eager exports:
# ~/.zshrc (ran on EVERY shell startup)
export ZAI_API_KEY=$(pass-cli item view pass://dev/zai/key)
export TAVILY_API_KEY=$(pass-cli item view pass://dev/tavily/key)
export URUKY_ACCOUNT_NUMBER=$(pass-cli item view pass://dev/URUKY_ACCOUNT_NUMBER/key)
Two issues:
-
Latency. Each
pass-cli item viewcall fetches from the Proton Pass API. Three calls added 3-9 seconds to every new terminal window. -
Security. Exporting secrets globally means every child process (scripts, subshells, background tasks) can read them. A compromised npm postinstall script could exfiltrate API keys without triggering any alarm.
The Proton Pass CLI docs recommend pass-cli run for secret injection. I hadn’t used it because the eager export pattern was simpler to set up.
Process-isolated design
The pass-cli run Pattern
The run command wraps a process and injects secrets as environment variables:
pass-cli run --env-file .env.template -- command [args...]
How it works:
- Collects env vars from current process and
.envfiles (multiple--env-filefiles processed in order, later override earlier) - Scans for
pass://URIs in variable values - Resolves secrets from Proton Pass
- Replaces URIs with actual values
- Masks secrets in stdout/stderr by default (
--no-maskingto disable) - Executes command with resolved environment
- Forwards stdin/stdout/stderr (always as pipes, per the source code, even though the docs just say “forwarded”)
- Handles SIGTERM/SIGINT for graceful shutdown (handled at the
pass-clilevel, then sent to the child)
The template file uses bare pass:// URIs:
# ~/.env.shell-secrets.template
ZAI_API_KEY=pass://dev/zai/key
TAVILY_API_KEY=pass://dev/tavily/key
URUKY_ACCOUNT_NUMBER=pass://dev/URUKY_ACCOUNT_NUMBER/key
Two Patterns, Because TUIs Are Special
The direct pass-cli run wrapper is the ideal pattern. There’s one problem: pass-cli run does not pass the TTY through. The child’s stdin/stdout become pipes, which is fine for batch commands but fatal for interactive TUIs like pi, opencode, vim, or fzf. They hang indefinitely because they can’t initialize the screen without a real TTY.
I verified this in tmux: pass-cli run -- vim -c q hangs, while pass-cli run -- sh -c 'true' works fine. The hang is a TTY issue
So two patterns are needed:
Pattern 1: pass-env for non-interactive commands (full isolation):
pass-env() {
local template="$HOME/.env.shell-secrets.template"
if [[ -f "$template" ]] && _have pass-cli; then
pass-cli run --env-file "$template" -- "$@"
else
"$@"
fi
}
The secret lives only in the child’s env. This is the gold standard: no env-var exposure, no persistence risk, no child-process leak.
Pattern 2: pass-run-tui for interactive TUIs (env-injection):
The key insight: you don’t need to export secrets to the parent shell to get them into the child. The standard Unix env command accepts VAR=value arguments and execs the child with a custom envp. Because env is just a thin execve wrapper and does NOT proxy stdio, the child inherits the real TTY. You get the isolation of Pattern 1 with the TTY of Pattern 2.
pass-run-tui() {
local template="$HOME/.env.shell-secrets.template"
if [[ ! -f "$template" ]] || ! _have pass-cli; then
"$@"; return $?
fi
# Collect template var names
local -a names=()
local line name resolved
while IFS= read -r line; do
name="${line%%=*}"
[[ -z "$name" || "$name" == "#"* ]] && continue
names+=("$name")
done <"$template"
# Fetch each secret and build VAR=value args for env.
# --no-masking is REQUIRED: without it, the subshell captures
# "<concealed by Proton Pass>" as the literal value, causing silent 401
# auth failures. Verified the hard way.
local -a env_args=()
for name in "${names[@]}"; do
resolved="$(pass-cli run --no-masking --env-file "$template" -- printenv "$name" 2>/dev/null)"
[[ -n "$resolved" ]] && env_args+=("$name=$resolved")
done
# Exec the child via env. Parent shell is never polluted; TTY is inherited.
if [[ ${#env_args[@]} -gt 0 ]]; then
env "${env_args[@]}" "$@"
else
"$@"
fi
}
pi() { pass-run-tui "$HOME/.env.pi.template" command pi "$@"; }
opencode(){ pass-run-tui "$HOME/.env.opencode.template" command opencode "$@"; }
The parent shell never sees the secrets. Each wrapper uses a per-app template: pi gets only the two secrets it needs (ZAI_API_KEY, URUKY_ACCOUNT_NUMBER), opencode gets those plus TAVILY_API_KEY.
Why env Instead of export + unset
The first version of Pattern 2 exported the secrets to the parent shell, ran the TUI, then unset them in a cleanup loop. That design had a real vulnerability: if a Ctrl+C (SIGINT) aborted the function mid-cleanup (e.g. the user hammering Ctrl+C on a hung TUI), the secret would leak into the parent shell and stay there for the rest of the session, visible to any subsequent command.
The env approach fixes this by construction. The secrets never enter the parent shell’s environment; they live in the env process’s envp, which is gone the instant execve hands off to the child. No cleanup loop to abort, no leak window. Verified empirically: killing the function with SIGKILL mid-run leaves the parent shell clean.
| Pattern | Use case | Isolation | Works with TUI |
|---|---|---|---|
pass-env | scripts, CI/CD, non-interactive | Full (child only) | N/A |
pass-run-tui | pi, opencode, any TUI | Full (child only; parent untouched) | Yes (inherits real TTY) |
Template File Location
I keep three templates that we can safely commit as dotfiles, since they just contain pass:// URIs, not actual secrets:
~/.env.shell-secrets.template: the default, contains all secrets (used bypass-envwith no explicit template)~/.env.pi.template: onlyZAI_API_KEY+URUKY_ACCOUNT_NUMBER(minimal privilege forpi)~/.env.opencode.template: addsTAVILY_API_KEYforopencode
Verification
- Shell startup latency: 3-9 seconds to 0 seconds (secrets fetched lazily on demand)
- Network dependency: No API calls on shell startup, only when a tool is launched
- TUI support: pi and opencode work correctly because they inherit the real TTY
- Secret isolation: Both patterns keep secrets in the child process only. The parent shell is never polluted, so a Ctrl+C that aborts a TUI cannot leak them.
- Per-app minimal privilege:
pisees onlyZAI_API_KEY+URUKY_ACCOUNT_NUMBER;opencodealso seesTAVILY_API_KEY. - No visible behavior change:
piandopencodework exactly as before
Tradeoffs
The remaining residual gap is intrinsic to env-var-based secret injection: the TUI process (and any child processes it spawns) can read the secrets from their own environment while the TUI is running. This is visible via ps -wwwE on macOS and /proc/PID/environ on Linux, scoped to the TUI’s PID tree. The parent shell is never exposed.
For a single-user dev workstation handling dev-tier API keys, this tradeoff is acceptable. For production secrets or multi-user systems, the secret should never touch environment variables at all; a tool like Proton Pass’s native SDK integration, or a secrets sidecar (HashiCorp Vault agent, AWS Secrets Manager with a local cache), would be more appropriate.
I also considered lazy-loading via a custom env hook (resolve the secret on first access to the variable), but the wrapper pattern is simpler and more explicit.
References
- Proton Pass CLI Documentation: official docs site
- run command reference: official reference for the command used in this post
- Proton Pass CLI Announcement: official product announcement
- pass-cli source on GitHub: the authoritative source for command behavior, especially
pass-cli/src/commands/run.rs - env(1) Linux manual page: confirms
envsetsname=valueoperands and execs the command (its source does no stdio redirection) - environ(7) Linux manual page: describes the process environment inherited across program execution
- execve(2) Linux manual page: confirms the
envparray passed byexecveis the child’s environment - proc_pid_environ(5) Linux manual page: confirms
/proc/PID/environexposes a process’s initial environment (set atexecvetime) - ps(1) macOS manual page: confirms
-Edisplays the environment and-w/wwwwidens output columns - Using Proton Pass CLI to Keep Linux Scripts Secure: Danny’s blog post on the
runandinjectpatterns, which inspired this approach - Do Not Use Secrets in Environment Variables (Node.js Security): detailed analysis of env-var exposure risks, including child-process inheritance and
/proc/PID/environleaks - Storing secrets in env vars considered harmful (Arcjet): concise summary of the env-var risk surface (lateral movement, logging leaks, management challenges)
This post was written with AI assistance.