secrets/CLAUDE.md
Brian Majewski 7c3a76e8c1
v0.1.1.0 feat: optional remote URL in .secrets-store (EGB-282) (#2)
* chore: ignore .gstack/ (per-project local state)

* feat: optional remote URL in .secrets-store (EGB-282)

A second whitespace-separated token after the store name in .secrets-store
is treated as the store's git remote URL. When a teammate clones a project
bound to a store they don't have locally yet, the directed missing-store
error now fills in `git clone <url> <path>` so they can copy-paste instead
of asking the original setter for the URL.

Backward compatible: single-token .secrets-store files (the v0.1.0.x
format) continue to work and produce the existing `<their-store-remote>`
placeholder.

Security hardening (caught by adversarial review during /ship):
- The rendered git clone line is meant to be copy-pasted by a teammate.
  Without sanitization, `work evil.git;rm -rf ~` would render verbatim
  and execute `rm -rf ~` on paste. The parser now rejects URLs containing
  shell metacharacters (;&|<>$`(){}*?!"'\\), control characters (incl.
  ANSI escape sequences that could spoof terminal output), and embedded
  whitespace. Rejected URLs are dropped with a stderr warning; the error
  falls back to the safe placeholder.
- Switched from `set -- $line` to `read -r spec rest` so the URL field
  isn't glob-expanded or word-split — important so `work *` from a
  populated directory doesn't leak filenames into the URL field.

Tests 72 → 80. New: backward compat, SSH+HTTPS+~/-prefix URL forms,
comment-and-URL form, four named injection vectors (shell metachar,
backtick, $(), ANSI escape), multi-token URL, glob char, and a positive
test asserting standard git URL chars round-trip unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.1.1.0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:54:45 -07:00

3.8 KiB

secrets

Encrypted env file sync between machines using age key-file encryption + a private git repo.

Quick Start

brew install age
./secrets init                    # Create ~/.secrets repo + generate age key
cd ~/my-project && ./secrets push # Encrypt .env* files, commit, push
# On other machine:
cd ~/my-project && ./secrets pull # Pull + decrypt .env* files

Testing

brew install bats-core
bats test/secrets.bats

Architecture

Single bash script (secrets) with subcommands: init, push, pull, list, rm, rekey.

  • Encryption: age with key files (not passphrases — age passphrases are non-scriptable)
  • Storage: Private git repo at ~/.secrets/
  • Convention: Tracks .env, .env.*, and .dev.vars (not .envrc, .environment-*)
  • Workspaces: --workspaces flag reads package.json workspaces, requires jq
  • Safety: Pre-commit hook rejects plaintext secret files

Project Structure

secrets              # CLI script (~300 lines bash)
hooks/pre-commit     # Pre-commit hook template
test/
  secrets.bats       # bats-core test suite (25 tests)
  test_helper.bash   # Shared setup/teardown
README.md            # User-facing documentation
CLAUDE.md            # This file

Key file

~/.secrets/key.txt is the age identity (private key). It is gitignored and must be copied manually to each machine once.

Multi-store resolution

The active store directory is picked by resolve_store() using these rules, highest precedence first:

  1. --store <dir> flag (parsed in the main pre-pass into STORE_OVERRIDE).
  2. .secrets-store file in cwd or any ancestor, walk-up bounded by $HOME (never reads $HOME/.secrets-store itself or anything above).
  3. SECRETS_DIR env var (legacy escape hatch).
  4. ~/.secrets default.

resolve_store mutates BOTH SECRETS_DIR and KEY_FILE so the existing single-store code paths just work. STORE_SOURCE reports which rule won. _LAST_FOUND_AT (when rule 2 fires) holds the path of the file that was read.

.secrets-store parsing is deliberately conservative: first non-empty non-comment line wins, no shell expansion (no $VAR, $(), backticks). Bare names map via _expand_store_path: work$HOME/.secrets-work, default$HOME/.secrets. An optional remote URL after the spec on the same line is captured as _REMOTE_URL and passed through to check_initialized, which uses it to fill in a runnable git clone <url> <path> in the missing-store error (EGB-282). The URL is parsed via read -r spec rest (no set -- $line, no glob expansion) and then sanitized: any URL containing shell metacharacters (;&|<>$\(){}*?!"'\), control characters (incl. ANSI escapes), or whitespace is dropped with a stderr warning. The directed error then falls back to the placeholder. This matters because the renderedgit cloneline is meant to be copy-pasted by a teammate — without sanitization,work evil.git;rm -rf ~would render verbatim and execute the payload on paste. Internal flow:_parse_secrets_store_filereturns\t; _find_secrets_store_filereturns

\t\t; resolve_storesplits the 3-tuple viaIFS=$'\t' read -r ...`.

Deploy Configuration

  • Platform: NONE (distributed via git clone from GitHub)
  • Production URL: N/A (no live service)
  • Release model: merge to main is the release. Optionally tagged with v<X.Y.Z.W>.
  • Verification after merge: a fresh git clone should produce a working secrets which against an isolated $HOME. No canary URL.
  • Staging: none.
  • Rollback: revert the merge commit on main (and delete the tag) to roll back.

Environment variable

SECRETS_DIR overrides the default ~/.secrets location (useful for testing). Per-project bindings via .secrets-store file beat this env var; use --store <dir> for one-shot overrides that beat everything.