Skip to content

Collect Secrets ​

Get a secret value from a human into a destination (a GitHub repo secret, a Cloudflare Worker secret, or a gitignored .dev.vars file) without leaking it anywhere along the way. Use the collocated collect-secrets.sh tool instead of hand-rolling a paste script per setup.

When to use ​

Any setup flow that needs a human-held secret to reach a destination: GitHub Actions auth tokens, R2 / GCS keys, Cloudflare Worker secrets, OAuth tokens, database URLs. The problem of handing a secret to automation safely recurs with the same shape every time, so solve it once here and call it from everywhere.

The obvious-but-wrong paths this replaces:

  • Pasting into the chat → the value lands in the transcript forever.
  • Letting the agent read ~/.config/gcloud/, .env, or shell history → credential-exploration, correctly blocked by the permission classifier.
  • gh secret set --body "$VAL" → the value goes on the command line, where it leaks into ps output and shell history. Always pipe over stdin instead.

The pattern ​

sh
# GitHub repo secret(s)
./skills/security/collect-secrets.sh \
  --destination gh --repo OWNER/REPO NAME [NAME...]

# Cloudflare Worker secret(s)
./skills/security/collect-secrets.sh \
  --destination wrangler [--env ENV] NAME [NAME...]

# Gitignored .dev.vars (the file is the intended on-disk destination)
./skills/security/collect-secrets.sh \
  --destination dev-vars [--file PATH] NAME [NAME...]

# macOS Keychain item (service = NAME)
./skills/security/collect-secrets.sh \
  --destination keychain [--account ACCOUNT] NAME [NAME...]

For each NAME the tool prompts once with hidden input (read -rs), reads the value from stdin, and pipes it straight to the destination. It then prints a length-only confirmation (set NAME (N chars)) so you can sanity-check a paste without revealing it.

Guarantees ​

  • Hidden input, one secret at a time; empty input re-prompts, end-of-input aborts.
  • Never echoed, never placed on a command line (so it stays out of ps and shell history), never written to disk beyond the intended destination.
  • stdin-piped to the destination: the value reaches gh / wrangler / security over stdin, never as an argument. The destination reads its own stdin from the internal pipe, so it never competes with the hidden read.
  • Length-only confirmation: the only feedback is the character count.
  • Idempotent and re-runnable: gh / wrangler overwrite the secret and keychain updates the item in place (-U, so no duplicate entries); dev-vars replaces the key in place (no duplicate lines). The .dev.vars file is written with mode 600, and the tool warns if it is not gitignored.

Pluggable destinations ​

  • gh: a GitHub repo secret, via gh secret set NAME --repo OWNER/REPO reading the value over stdin.
  • wrangler: a Cloudflare Worker secret, via wrangler secret put NAME (optionally --env ENV) reading the value over stdin.
  • dev-vars: a gitignored .dev.vars file, with NAME=value appended or replaced in place at mode 600.
  • keychain: a macOS Keychain generic-password item with service NAME, via security add-generic-password -U. --account overrides the default ${USER:-$(id -un)}; the fallback matters because USER is unset under cron and launchd, and a secret stored under one account is invisible to a lookup using the other.

Add a destination by writing one function that reads the value from stdin and must not place it on argv.

The keychain destination shows what that can take. security has no stdin-value flag: -w with an argument would put the secret on argv, and -w with none prompts twice (value, then retype) reading both from stdin. So the function feeds the value in twice rather than passing -w "$value". A destination whose tool "has no stdin option" usually has one in disguise.

Keychain as a credential source ​

Storing a credential in the Keychain is only half a setup; something has to read it back. The pattern that works in every context (interactive shells, scripts, services, cron, and agents) is a small wrapper executable early on PATH that reads the item with security find-generic-password, exports it into only that tool's environment, and execs the real binary by absolute path. A shell function in ~/.bashrc looks equivalent and is not: it covers interactive bash only.

Two things to check when adopting it, both of which silently disable the wrapper rather than failing loudly:

  • The wrapper's directory must come before the real binary's on PATH, or the real binary shadows the wrapper and every call bypasses auth.
  • The wrapper must exec the real binary by absolute path, or it re-enters itself.