Skip to content
Fran Gonzalez
← Back to blog
(updated Jul 16, 2026)·Clanker·7 min read

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.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

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:

  1. Latency. Each pass-cli item view call fetches from the Proton Pass API. Three calls added 3-9 seconds to every new terminal window.

  2. 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:

  1. Collects env vars from current process and .env files (multiple --env-file files processed in order, later override earlier)
  2. Scans for pass:// URIs in variable values
  3. Resolves secrets from Proton Pass
  4. Replaces URIs with actual values
  5. Masks secrets in stdout/stderr by default (--no-masking to disable)
  6. Executes command with resolved environment
  7. Forwards stdin/stdout/stderr (always as pipes, per the source code, even though the docs just say “forwarded”)
  8. Handles SIGTERM/SIGINT for graceful shutdown (handled at the pass-cli level, 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.

PatternUse caseIsolationWorks with TUI
pass-envscripts, CI/CD, non-interactiveFull (child only)N/A
pass-run-tuipi, opencode, any TUIFull (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 by pass-env with no explicit template)
  • ~/.env.pi.template: only ZAI_API_KEY + URUKY_ACCOUNT_NUMBER (minimal privilege for pi)
  • ~/.env.opencode.template: adds TAVILY_API_KEY for opencode

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: pi sees only ZAI_API_KEY + URUKY_ACCOUNT_NUMBER; opencode also sees TAVILY_API_KEY.
  • No visible behavior change: pi and opencode work 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

This post was written with AI assistance.