13 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/ # runs secrets.bats + manifest.bats
./test/run-security.sh # security regression subset + operator sign-off (see below)
bash 3.2 assertion gotcha: bats runs under system bash 3.2, where a
failing [[ ]] mid-test does NOT fail the test (the ERR trap skips [[
compound commands). Every standalone [[ ... ]] assertion MUST end with
|| false. Single-bracket [ ] assertions are unaffected.
Security review policy (mandatory for agents)
This repo contains defensive security regression tests with deliberate attack-payload fixtures (shell injection strings, symlink escape cases, path rails). Hosted models (including Opus) block AI-driven "think like an attacker" / red-team / security-specialist review of that material under Anthropic Usage Policy (real-time cyber safeguards).
Read .ship-policy.json before any /ship, /review, or /autoplan work.
When that file is present (it always is in this repo):
- Never dispatch red-team subagents, security specialist subagents, or adversarial review prompts ("think like an attacker", "find security holes", "chaos engineer", exploit hunting) against this codebase.
- Never run
./test/run-security.shor filter bats to SECURITY/adversarial tests on behalf of the user — that is operator-local work only. - Before ship/PR: ask the human operator to run
./test/run-security.shlocally and complete the SIGNOFF prompt. Record lives at.gstack/security-signoff.json(gitignored). Proceed with ship only after they confirm sign-off for the currentHEAD(or note in the PR that sign-off is pending). - Normal
./shipsteps may continue (tests viabats test/, eng review, changelog, PR) — only the AI security/red-team/adversarial passes are opted out.
Gstack /ship reads the same policy via gstack-ship-policy (sources
.ship-policy.json at repo root): Step 9 blocks when manual sign-off is missing,
skips security specialist + red team, and Step 11 skips adversarial review.
Architecture
Single bash script (secrets) with subcommands: init, push, pull, list, rm, rekey, verify.
- 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-*) - Manifest (EGB-677 stage 1): committed
.secrets.jsonis the source of truth for what syncs —dotenv[](project-relative, nested ok,@allowed; rail rejects../absolute/symlink) +external[](properties/file). Push discovery auto-adds (gated by committedoptions.autoAdd, default ON;--frozen/--dry-runoverrides), bootstraps the manifest on first push (written only after ≥1 blob encrypts), and absorbs a legacy.secrets-files(gradle-properties →properties; on pull the legacy file is superseded with a warning). v1 store layout unchanged in stage 1: nested entries land at<project>/<relpath>.age;propertiesblobs keep the legacy.gradle-properties.agesuffix until the stage-2 store migration. jq is a hard dep only when a manifest exists/is written; manifest-less projects run jq-free (manifest features skipped with a notice).check_cmdprints platform-aware install hints. - Verify (EGB-698):
secrets verifyis a read-only integrity check. Default mode (current project) cross-checks$PWD/.secrets.jsonagainst$SECRETS_DIR/<project>/both ways (declared-but-missing blobs + orphaned blobs) and decrypt-tests every blob (dotenv + external) by streaming plaintext to/dev/null(never written to disk).secrets verify --alldecrypt-tests every blob in every project (integrity only — the store carries no manifests, so consistency can't be checked store-wide). Both recurse the whole project tree (find -type f, same as rekey/list). Exits non-zero on any finding so it can gate the stage-2migrate --finalizeand CI. The store deliberately holds no manifest —.secrets.jsonis committed in each project's own repo and read from$PWD. - External files:
.secrets-filesmanifest tracks designated keys from files outside the project (e.g.~/.gradle/gradle.properties, merged not overwritten — EGB-531) and whole binary files (typefile, e.g. an Android upload keystore — EGB-652); 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 (~2000 lines bash)
hooks/pre-commit # Pre-commit hook template
test/
secrets.bats # bats-core test suite (133 tests)
manifest.bats # EGB-677 .secrets.json manifest tests (77 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>.... Two types: gradle-properties (named-key merge) and file (EGB-652 — whole-file verbatim sync, binary-safe, built for the Beacon Android upload keystore; no keys, restored at mode 600 with a .secrets-bak backup of a divergent existing target, basename restriction waived but all other path rails apply). Still no plugin-dispatch framework — each type is a concrete case branch (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 legacy non-recursive*.age/.*.ageglobs the dotenvpullpath uses, so a dotenv pull can never decrypt an external blob into cwd.cmd_rekeyandcmd_listinstead walk the entire project tree (find -type f), so they cover bothexternal/<slug>.ageand nested manifest dotenv blobs (<project>/<relpath>.age) — rekey MUST recurse, or any nested/external blob is orphaned under the old key after rotation = data loss (EGB-677 regression test: "rekey re-encrypts a nested manifest dotenv blob").<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.
Codeberg operations
The remote is Codeberg (Forgejo) — gh/glab do NOT work here. Use tea
(login name: codeberg, user egbt) for forge operations when a skill's
platform detection comes up "unknown":
- PRs:
tea pr create --base main --title ... --description .../tea pr merge <index> - Releases:
tea releases create --tag v<X.Y.Z.W> --title "v<X.Y.Z.W> — <YYYY-MM-DD>" --note ...(convention: one release per tag, titlev<X.Y.Z.W> — <date>) - Issues/status:
tea issues,tea pr list - No CI on this repo: the bats suite run locally is the merge gate.
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 (after reading
.ship-policy.json; no AI adversarial/red-team/security-specialist review in this repo) - Save progress → invoke /context-save
- Resume context → invoke /context-restore
- Author a backlog-ready spec/issue → invoke /spec