EGB-1230: `cmd_pull` synced the store with `git pull >/dev/null 2>&1` under `set -euo pipefail`. A store that couldn't fast-forward killed the script at that line with git's exit 128 and nothing on stdout or stderr — a banner, no restored files, no reason, and invisible in a pipeline. The sync now routes through `_store_sync_pull`, which guards the pull, captures git's output as the diagnosis, and dies naming the store path and `secrets sync`. It is now `--ff-only` to match the push path, so a plain pull can no longer manufacture a merge commit in the store. EGB-1231: once a store diverged there was no way out — push demanded a fast-forward and pointed at pull, which couldn't fast-forward either, so the advice looped and recovery meant hand-running git next to encrypted blobs. Adds `secrets sync`: fetch, stash, rebase onto the remote, restore the stash, then a confirmation-gated push of local commits (`--yes` to skip the prompt, `--dry-run` to report only). Non-destructive by construction — no merge, no force-push, no `reset --hard`, no `stash drop`; a rebase conflict names the conflicting files and leaves the store exactly as found. `secrets which` gains a `remote:` line reporting ahead/behind/dirty, and push's dead-end message now points at `sync`. test/sync.bats: 25 new tests. Full suite 353/353 green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrUoYuUMoTj91rzV4vxGPB
22 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 + migrate.bats + upgrade.bats + recipients.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, migrate, recipients, reencrypt, sync, upgrade.
- 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). Store layout: nested dotenv entries land at<project>/<relpath>.age(relpath preserved — the store self-describes where a file restores). 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. - Store format (EGB-677 stage 2 / EGB-703): the store is self-describing via a committed one-line
$SECRETS_DIR/.secrets-formatfile (2). Absence ⇒ v1 (every store predating EGB-703). v2's only on-disk change vs v1 is the externalpropertiesblob suffix:.gradle-properties.age→.properties.age(matching the manifesttype); dotenv andfileblobs are unchanged._store_format()reads the marker. Additive v2 (EGB-712): reads resolve apropertiesblob by trying.properties.agethen falling back to.gradle-properties.age(_resolve_external_blob_read); writes dual-write apropertiesexternal only when a v1 twin already exists in the store (_external_blob_write_targets), so existing externals keep old clients fresh while brand-new externals are written v2-only (a gentle forcing function). Blob location no longer depends on the marker — the old_external_blob_suffixis gone.initstamps a fresh store v2 (born-v2).secrets whichprints the store-format lineformat: vN, and (EGB-700) when a.secrets.jsonis present the manifest header line also carries its schema version (manifest (.secrets.json at <path>, version N):). Migration is copy-forward and non-destructive:secrets migrate --dry-run(per project, reports old→new, writes nothing) →secrets migrate(per project, manifest-free: enumerates the store's*.gradle-properties.ageblobs directly — same source of truth as--finalize— and writes their.properties.agetwins, so a legacy.secrets-files-only project with no.secrets.jsonmigrates cleanly and no store blob is left un-twinned; idempotent; EGB-710) →secrets migrate --finalize(store-wide; the ONLY destructive step — gates onverify --allgreen + every v1 blob having a v2 twin, cuts apre-v2-migrate-<sha>recovery tag, stamps the marker, then drops v1 blobs; refuses without--yes/operator confirmation since a lagging v1 client against a finalized store stops seeingpropertiesexternals until it upgrades).secrets migrate --statusis a read-only survey that walks every project in the store and reports each one's v2 readiness (v2-ready / migrated / NEEDS MIGRATE, plus av2-onlycount of externals old clients can't read), exiting non-zero while any v1 blob is un-twinned so it gates the path to--finalize(EGB-710/EGB-712). Under additive v2 (EGB-712)--finalizeis now OPTIONAL GC, not a required milestone: because upgraded clients dual-write existing externals and read-fall-back, not finalizing never cuts anyone off — finalize only reclaims the duplicate v1 blobs and stays deferrable indefinitely (defusing the cross-machine coordination gate). dotenv andfileblobs are identical across formats, so they always propagate to old clients; only a brand-newpropertiesexternal is v2-only. Version-skew nudge (EGB-713): a committed$SECRETS_DIR/.secrets-writer-versionrecords the highest clientVERSIONthat has written to the store (monotonic; stamped via_stamp_writer_versionright before each store-committinggit add -A— push/rekey/migrate/finalize — never on read paths, so it always rides a commit and never dangles to breakpull --ff-only).check_initializedcalls_check_store_version_skew, which warns once per invocation (stderr, non-fatal,set -e-safe) when the store's stamp is numerically greater than_client_version(read from$SCRIPT_DIR/VERSION);secrets whichprints thewritten-by:line. Stores with no stamp (pre-EGB-713) are silent. The deliberate flatten-to-basename naming the EGB-677 CEO plan sketched was dropped as lossy (it discards the restore relpath that makes the store self-describing) — see the EGB-703 eureka. Upgrade verb (EGB-716):secrets upgradeis the fix path paired with the EGB-713 skew warning — itgit -C "$SCRIPT_DIR" pull --ff-onlys the tool's own checkout (fast-forward only, never merges/rewrites local commits), reportsvOLD -> vNEW, then best-effort re-checks_store_writer_versionagainst the new on-disk version so the operator sees whether the nudge is cleared (the new code takes effect next invocation).secrets upgrade --checkdoesgit fetch+rev-list --count HEAD..@{u}and reports availability without pulling. Deliberately thin: no auto-update, no background polling (security tool). Directed errors for not-a-git-checkout / no-upstream / diverged / offline.cmd_upgradenever callscheck_initialized(it's about the tool, not the store); the skew re-check is silent unless a store with a writer-version resolves. - Store sync + divergence (EGB-1230/EGB-1231): the store is a git repo, so a clone can end up ahead of and behind its remote at once. EGB-1230:
cmd_pull's sync used to begit pull >/dev/null 2>&1underset -euo pipefail— a store that couldn't fast-forward killed the script there with git's exit 128 and nothing on either stream (a banner, no files, no reason; invisible in a pipeline). It now routes through_store_sync_pull, which guards the pull, captures git's output as the diagnosis, and dies naming the store path andsecrets sync. That sync is--ff-only, matching the push path — a plaingit pullcould quietly manufacture a merge commit in the store, and divergence is now resolved in exactly one place. EGB-1231:_store_git_stateemitsahead\tbehind\tdirty(fromrev-list --left-right --count @{u}...HEADplusstatus --porcelain) and_format_store_staterenders it;cmd_whichprints aremote:line from them — offline-safe (reports against the last fetch), silent with no remote/upstream.cmd_syncis the reconcile verb the CLI was missing: fetch → report state → stash (push -u) →rebase @{u}→ restore stash →ensure_store_protections(rebased-in history may lack.gitignore, and a store missing thekey.txtline would stage the private key — same reasoning as push) → confirmation-gatedgit pushof local commits. The gate (_sync_confirm_push) reads/dev/ttyand requires a tty, so it stays CLOSED in scripts/CI rather than publishing to a shared store by default;--yesopens it,--dry-runreports and returns before any mutation. Non-destructive by construction: no merge, no--force, noreset --hard, nostash drop. A rebase conflict collects the conflicting paths BEFORErebase --abort(the abort clears them), restores the stash, and dies — store byte-identical to how it was found._sync_restore_stashnever drops the stash on a failed pop; it tells the operator where their only copy lives.cmd_syncdoes not_stamp_writer_version: it replays existing commits rather than authoring content, and the stamp is specified to ride a store-committinggit add -A. Test suite:test/sync.bats(25 tests), including a grep over thecmd_syncbody asserting the destructive git verbs never appear in it. - 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) - Multi-recipient (EGB-283): a store-scoped, committed
recipients.txt(age-Rformat,# namecomments) lets one store encrypt every blob to N age keys — one per team member. Managed viasecrets recipients add/rm/list; absence of the file ⇒ legacy single-key behavior (recipients = the pubkey derived fromkey.txt). The file is parsed by us (neverage -R <path>) into a validatedRECIPIENT_ARGSarray (native age X25519 only,age1[0-9a-z]{58}; SSH recipients rejected; symlinked file refused) — same conservative posture as.secrets-store/.secrets-files._load_recipientspopulates the array; every encrypt site routes through it. Any recipient change re-encrypts the WHOLE store in one commit via the shared_reencrypt_allengine (also used by the newsecrets reencryptand byrekeyon a multi-recipient store, where rekey re-encrypts to the set with NO new keypair; legacy stores keep rekey's generate-new-keypair behavior).initseedsrecipients.txtborn-multi.whichprintsrecipients: N;verify/verify --allassert each blob's age recipient-stanza count equalsrecipients.txt's length. Removal takes effect going forward — git history stays readable by an old key, so rotate genuinely-sensitive values. Decryption is unchanged (each member uses their ownkey.txt). - 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 (140 tests)
manifest.bats # EGB-677 .secrets.json manifest tests (83 tests)
migrate.bats # EGB-703 store-format-v2 migration tests (35 tests)
upgrade.bats # EGB-716 `secrets upgrade` self-update tests (8 tests)
sync.bats # EGB-1230/1231 store sync + divergence reconcile tests (25 tests)
recipients.bats # EGB-283 multi-recipient age encryption tests (34 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"). EGB-701 cleanups: (1) the legacy (manifest-less)pullkeeps its non-recursive globs but now warns when nested<project>/<relpath>.ageblobs exist that those globs can't see (it excludesexternal/, whichpull_external_fileshandles) — so a manifest-less pull never silently under-restores; the fix the warning points at is committing a.secrets.json. (2)cmd_which, push, and pull share one external extractor (_json_external_entries), sowhichapplies the sameproperties→gradle-propertiesnormalization and skip-with-warning rules the sync path does (it shows exactly what will sync, not a stale raw projection). (3) the two external-manifest read guards are factored into_json_readable(plain regular file, silent) /_legacy_readable(warn-and-skip on a symlinked legacy manifest).<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).cmd_list --json(EGB-699) emits the same recursive walk as a machine-readable object ({store, projects[].entries[]}, each entrydotenv→pathorexternal→subtype+path) for tooling/CI (feeds EGB-671); jq assembles it so paths escape correctly and stdout stays pure JSON (the human store hint is suppressed; jq is a hard dep only in--jsonmode). - 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