secrets/CLAUDE.md
2026-06-05 09:40:39 -07:00

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-*)
  • External files: .secrets-files manifest tracks designated keys from files outside the project (e.g. ~/.gradle/gradle.properties) — merged, not overwritten (EGB-531, see below)
  • Workspaces: --workspaces flag reads package.json workspaces, requires jq
  • Safety: Pre-commit hook rejects plaintext secret files (.env, .dev.vars, gradle.properties)
  • Portability: must run on system bash 3.2 (macOS) — no associative arrays or bash-4 features

Project Structure

secrets              # CLI script (~600 lines bash)
hooks/pre-commit     # Pre-commit hook template
test/
  secrets.bats       # bats-core test suite (118 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 ...`.

External files (.secrets-files) — EGB-531

.secrets-files is a committed, project-root manifest declaring keys to sync from files outside the project (motivating case: ~/.gradle/gradle.properties, which Android Studio GUI builds read but terminal env vars can't reach). One entry per line: <type> <path> <key>.... Only type gradle-properties is supported; the type token leaves room for future types without a plugin-dispatch framework (build the concrete case — a deliberate scope cut).

Key design decisions (all driven by /autoplan review):

  • Wire-in is at command scope (cmd_push/cmd_pull), via push_external_files / pull_external_files, not inside push_dir_to_project / pull_project_to_dir (those loop per-workspace and pull_project_to_dir uses stdout as a data channel).
  • Storage: blobs live in $SECRETS_DIR/<project>/external/<slug>.gradle-properties.age. The external/ subdir keeps them out of the existing non-recursive *.age / .*.age globs (pull, list, rekey), so the old dotenv path can never decrypt a blob into cwd. cmd_rekey and cmd_list recurse into external/ explicitly (rekey MUST, or the blob is orphaned after rotation = data loss). <slug> = manifest path token with non-[A-Za-z0-9._-] chars → _, plus a cksum suffix of the original path so paths that clean to the same string (a/b vs a_b) don't collide. Machine-independent (derived from the committed manifest token, not the expanded path).
  • Merge is pure bash, no sed/regex (merge_gradle_keys): exact-string key comparison (avoids beaconClerkPk vs beaconClerkPkTest substring bug), value treated as opaque literal (survives & \ / in values). Updates a managed key in place at its first occurrence, collapses duplicates, appends new keys, preserves unrelated lines/comments/order. Continuation lines (trailing odd backslashes, tracked by _trailing_bs_odd) are never matched as keys. Atomic write: temp in the same dir → chmod to match (or 600 on create) → mv. Backs up to <target>.secrets-bak before each merge.
  • Properties separator parsing (_props_get): key ends at the first =, :, or whitespace (after lstrip); handles key=value, key = value, key:value, key value; last definition wins.
  • Security: the write target comes from a committed file, so _validate_external_target_path locks it down — basename must be gradle.properties, must resolve inside $HOME (deepest-existing-ancestor resolved, symlink target/parent refused, .. rejected). This blocks a malicious manifest from appending decrypted keys to ~/.gitconfig/~/.bashrc. _parse_secrets_files_manifest rejects shell metacharacters/control chars in path and keys (path allows [A-Za-z0-9/._~-] only; keys allow [A-Za-z0-9._-] + space), mirrors the .secrets-store posture (no shell expansion, symlinked manifest skipped).
  • Plaintext tradeoff (accepted, documented): merged keys are permanent plaintext in the target; secrets clear does not remove them. Fine for the Clerk publishable keys this was built for; not for high-value secrets (use secrets run + .env).

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.

Skill routing

When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.

Key routing rules:

  • Product ideas/brainstorming → invoke /office-hours
  • Strategy/scope → invoke /plan-ceo-review
  • Architecture → invoke /plan-eng-review
  • Design system/plan review → invoke /design-consultation or /plan-design-review
  • Full review pipeline → invoke /autoplan
  • Bugs/errors → invoke /investigate
  • QA/testing site behavior → invoke /qa or /qa-only
  • Code review/diff check → invoke /review
  • Visual polish → invoke /design-review
  • Ship/deploy/PR → invoke /ship or /land-and-deploy
  • Save progress → invoke /context-save
  • Resume context → invoke /context-restore
  • Author a backlog-ready spec/issue → invoke /spec