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:
agewith 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-filesmanifest tracks designated keys from files outside the project (e.g.~/.gradle/gradle.properties) — merged, not overwritten (EGB-531, see below) - Workspaces:
--workspacesflag readspackage.jsonworkspaces, requiresjq - 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:
--store <dir>flag (parsed in the main pre-pass intoSTORE_OVERRIDE)..secrets-storefile in cwd or any ancestor, walk-up bounded by$HOME(never reads$HOME/.secrets-storeitself or anything above).SECRETS_DIRenv var (legacy escape hatch).~/.secretsdefault.
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
; 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), viapush_external_files/pull_external_files, not insidepush_dir_to_project/pull_project_to_dir(those loop per-workspace andpull_project_to_diruses stdout as a data channel). - Storage: blobs live in
$SECRETS_DIR/<project>/external/<slug>.gradle-properties.age. Theexternal/subdir keeps them out of the existing non-recursive*.age/.*.ageglobs (pull, list, rekey), so the old dotenv path can never decrypt a blob into cwd.cmd_rekeyandcmd_listrecurse intoexternal/explicitly (rekey MUST, or the blob is orphaned after rotation = data loss).<slug>= manifest path token with non-[A-Za-z0-9._-]chars →_, plus acksumsuffix of the original path so paths that clean to the same string (a/bvsa_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 (avoidsbeaconClerkPkvsbeaconClerkPkTestsubstring 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 →chmodto match (or600on create) →mv. Backs up to<target>.secrets-bakbefore each merge. - Properties separator parsing (
_props_get): key ends at the first=,:, or whitespace (after lstrip); handleskey=value,key = value,key:value,key value; last definition wins. - Security: the write target comes from a committed file, so
_validate_external_target_pathlocks it down — basename must begradle.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_manifestrejects 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-storeposture (no shell expansion, symlinked manifest skipped). - Plaintext tradeoff (accepted, documented): merged keys are permanent plaintext in the target;
secrets cleardoes not remove them. Fine for the Clerk publishable keys this was built for; not for high-value secrets (usesecrets run+.env).
Deploy Configuration
- Platform: NONE (distributed via
git clonefrom Codeberg) - Production URL: N/A (no live service)
- Release model: merge to
mainis the release. Optionally tagged withv<X.Y.Z.W>. - Verification after merge: a fresh
git cloneshould produce a workingsecrets whichagainst 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