From 25cd1feb43119a139fc311cbf826df84fb6bfe38 Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Fri, 5 Jun 2026 09:38:16 -0700 Subject: [PATCH 1/6] fix: init second-machine guard, store .gitignore self-heal, rekey dotfile loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes, found from a real Ubuntu second-machine setup failure: 1. cmd_init: if key.txt exists but the store has no .git, die BEFORE git init with directed guidance to clone the existing secrets repo instead. Previously init ran git init, then age-keygen died on the existing key, leaving a half-initialized store (.git but no .gitignore or hook). 2. ensure_store_protections: push/pull/rekey now restore a missing store .gitignore (and pre-commit hook) before any `git add -A`. Without the .gitignore, add -A would commit key.txt to the remote. 3. cmd_rekey: the re-encrypt loop used a bare "$dir"* glob, which never matches dotfiles — .env blobs were decrypted to the tmpdir but never re-encrypted, leaving them on the OLD key (undecryptable) while the new key overwrote key.txt. Glob now matches the decrypt loop ("$dir"* "$dir".*). Exposed by the self-heal test: with nothing staged, the empty commit failed mid-rekey. Tests: 5 new bats tests (118 total) — init guard, .gitignore self-heal on push/pull/rekey, and a rekey round-trip that survives key rotation. --- CLAUDE.md | 2 +- secrets | 100 +++++++++++++++++++++++++++++++--------------- test/secrets.bats | 76 +++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e31f76a..59473fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek secrets # CLI script (~600 lines bash) hooks/pre-commit # Pre-commit hook template test/ - secrets.bats # bats-core test suite (113 tests) + secrets.bats # bats-core test suite (118 tests) test_helper.bash # Shared setup/teardown README.md # User-facing documentation CLAUDE.md # This file diff --git a/secrets b/secrets index 76eb7c4..b50122f 100755 --- a/secrets +++ b/secrets @@ -701,26 +701,9 @@ HOOKEOF fi } -# ─── Subcommands ─────────────────────────────────────────────────────── - -cmd_init() { - check_cmd age - check_cmd git - resolve_store - - if [ -d "$SECRETS_DIR/.git" ]; then - die "Already initialized at $SECRETS_DIR. Key file preserved." - fi - - info "Initializing secrets repo at $SECRETS_DIR" - mkdir -p "$SECRETS_DIR" - git init "$SECRETS_DIR" >/dev/null - - # Generate age key pair - info "Generating age key pair" - age-keygen -o "$KEY_FILE" 2>&1 - - # Write .gitignore +# Write the store-level .gitignore. Critical: the `key.txt` line is what +# keeps the private key out of `git add -A` during push/rekey. +write_store_gitignore() { cat > "$SECRETS_DIR/.gitignore" << 'EOF' # Never commit the private key key.txt @@ -735,6 +718,56 @@ key.txt !**/.env.*.age !**/.dev.vars.age EOF +} + +# Restore store-level protections if missing. A cloned store has no +# pre-commit hook (hooks aren't cloned), and a half-initialized store may +# lack .gitignore — without it, `git add -A` would commit key.txt. +ensure_store_protections() { + if [ ! -f "$SECRETS_DIR/.gitignore" ]; then + write_store_gitignore + info "Restored store .gitignore" + fi + if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then + mkdir -p "$SECRETS_DIR/.git/hooks" + install_hook + info "Reinstalled pre-commit hook" + fi +} + +# ─── Subcommands ─────────────────────────────────────────────────────── + +cmd_init() { + check_cmd age + check_cmd git + resolve_store + + if [ -d "$SECRETS_DIR/.git" ]; then + die "Already initialized at $SECRETS_DIR. Key file preserved." + fi + + # Second-machine trap: a copied key.txt without a repo means the user + # should clone their existing secrets repo, not init a fresh one. + # Catch it BEFORE git init so we don't leave a half-initialized store. + if [ -f "$KEY_FILE" ]; then + die "Found an existing key at $KEY_FILE but no repo at $SECRETS_DIR. +If this is a second machine, don't run 'secrets init' — clone your existing secrets repo instead: + + git clone $SECRETS_DIR + +Your key file has been left untouched." + fi + + info "Initializing secrets repo at $SECRETS_DIR" + mkdir -p "$SECRETS_DIR" + git init "$SECRETS_DIR" >/dev/null + + # Generate age key pair + info "Generating age key pair" + age-keygen -o "$KEY_FILE" 2>&1 + + # Write .gitignore + write_store_gitignore # Install pre-commit hook mkdir -p "$SECRETS_DIR/.git/hooks" @@ -781,6 +814,10 @@ push_dir_to_project() { commit_and_push_secrets() { local message="$1" + # Must run before `git add -A`: a store missing its .gitignore would + # otherwise stage and push key.txt. + ensure_store_protections + if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then if ! git -C "$SECRETS_DIR" pull --ff-only 2>/dev/null; then die "Fast-forward pull failed. Run 'secrets pull' first, then retry push." @@ -917,11 +954,8 @@ cmd_pull() { # Merge any external files (.secrets-files) declared in this project. pull_external_files "$PWD" "$project" - # Reinstall hook if missing - if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then - install_hook - info "Reinstalled pre-commit hook" - fi + # Reinstall hook / store .gitignore if missing + ensure_store_protections } # Pull and decrypt .age files from a project path into a target directory. @@ -1004,11 +1038,8 @@ cmd_pull_workspaces() { info "Decrypted $total file(s) total" - # Reinstall hook if missing - if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then - install_hook - info "Reinstalled pre-commit hook" - fi + # Reinstall hook / store .gitignore if missing + ensure_store_protections } cmd_list() { @@ -1139,13 +1170,15 @@ cmd_rekey() { info "Re-encrypting all files with new key..." - # Re-encrypt all files + # Re-encrypt all files. The ".*" glob is required: dotenv files decrypt + # to dotfiles ("$tmpdir/p/.env") that a bare "*" would silently skip, + # leaving their blobs on the old key (undecryptable after rotation). for dir in "$tmpdir"/*/; do [ -d "$dir" ] || continue local project project=$(basename "$dir") mkdir -p "$SECRETS_DIR/$project" - for f in "$dir"*; do + for f in "$dir"* "$dir".*; do [ -f "$f" ] || continue local name name=$(basename "$f") @@ -1162,7 +1195,8 @@ cmd_rekey() { fi done - # Commit and push + # Commit and push (heal .gitignore first so add -A can't stage key.txt) + ensure_store_protections git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "rekey all secrets" >/dev/null if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then diff --git a/test/secrets.bats b/test/secrets.bats index 2119c40..ad77ac2 100644 --- a/test/secrets.bats +++ b/test/secrets.bats @@ -1476,3 +1476,79 @@ gradle_project() { # definition must, and the '!' comment must be ignored. grep -q '^beaconClerkPkTest=realkey$' "$HOME/.gradle/gradle.properties" } + +# ─── init second-machine guard + store .gitignore self-heal ──────────── + +@test "init with existing key but no repo dies with clone guidance" { + # Second-machine trap: user copies key.txt into ~/.secrets, then runs + # `secrets init` instead of cloning their secrets repo. + mkdir -p "$SECRETS_DIR" + age-keygen -o "$SECRETS_DIR/key.txt" 2>/dev/null + local key_before + key_before=$(cat "$SECRETS_DIR/key.txt") + + run "$SECRETS_BIN" init + [ "$status" -eq 1 ] + [[ "$output" == *"git clone"* ]] + # Must not leave a half-initialized store behind + [ ! -d "$SECRETS_DIR/.git" ] + # Key untouched + [ "$(cat "$SECRETS_DIR/key.txt")" = "$key_before" ] +} + +@test "push restores missing store .gitignore and never commits key.txt" { + init_with_remote + rm "$SECRETS_DIR/.gitignore" + create_project_dir + + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + [ -f "$SECRETS_DIR/.gitignore" ] + grep -q "key.txt" "$SECRETS_DIR/.gitignore" + # key.txt must never be tracked (push does `git add -A` in the store) + run git -C "$SECRETS_DIR" ls-files + [[ "$output" != *"key.txt"* ]] +} + +@test "pull restores missing store .gitignore" { + init_with_remote + create_project_dir + "$SECRETS_BIN" push >/dev/null 2>&1 + rm "$SECRETS_DIR/.gitignore" + rm .env .env.staging + + run "$SECRETS_BIN" pull + [ "$status" -eq 0 ] + [ -f "$SECRETS_DIR/.gitignore" ] +} + +@test "rekey restores missing store .gitignore and never commits key.txt" { + init_with_remote + create_project_dir + "$SECRETS_BIN" push >/dev/null 2>&1 + rm "$SECRETS_DIR/.gitignore" + + run "$SECRETS_BIN" rekey + [ "$status" -eq 0 ] + [ -f "$SECRETS_DIR/.gitignore" ] + run git -C "$SECRETS_DIR" ls-files + [[ "$output" != *"key.txt"* ]] +} + +@test "rekey re-encrypts dotenv blobs (round-trip survives key rotation)" { + # The decrypt loop matches dotfiles (".*.age") but a re-encrypt glob of + # "$dir"* would silently skip them — leaving .env.age on the OLD key + # after rotation, i.e. undecryptable. Guard the full round-trip. + init_with_remote + create_project_dir + "$SECRETS_BIN" push >/dev/null 2>&1 + + run "$SECRETS_BIN" rekey + [ "$status" -eq 0 ] + + rm .env .env.staging + run "$SECRETS_BIN" pull + [ "$status" -eq 0 ] + [ "$(cat .env)" = "SECRET_KEY=abc123" ] + [ "$(cat .env.staging)" = "DB_HOST=staging.db.example.com" ] +} From 6db4f2b2174045b61e2b9cbdfe8e16676fed784e Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Fri, 5 Jun 2026 09:40:39 -0700 Subject: [PATCH 2/6] chore: add gstack skill routing rules to CLAUDE.md --- CLAUDE.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 59473fe..32ec405 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,3 +85,22 @@ Key design decisions (all driven by /autoplan review): ## 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 ` 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 From 5865c40d77379cceb08b76b07916e0c40f5b717a Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Fri, 5 Jun 2026 09:52:51 -0700 Subject: [PATCH 3/6] test: coverage for store protections self-heal (hook on push/rekey, no-op, gitignore content) --- test/secrets.bats | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/secrets.bats b/test/secrets.bats index ad77ac2..03c8138 100644 --- a/test/secrets.bats +++ b/test/secrets.bats @@ -1552,3 +1552,48 @@ gradle_project() { [ "$(cat .env)" = "SECRET_KEY=abc123" ] [ "$(cat .env.staging)" = "DB_HOST=staging.db.example.com" ] } + +@test "push reinstalls missing pre-commit hook" { + init_with_remote + rm "$SECRETS_DIR/.git/hooks/pre-commit" + create_project_dir + + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + [ -x "$SECRETS_DIR/.git/hooks/pre-commit" ] +} + +@test "rekey reinstalls missing pre-commit hook" { + init_with_remote + create_project_dir + "$SECRETS_BIN" push >/dev/null 2>&1 + rm "$SECRETS_DIR/.git/hooks/pre-commit" + + run "$SECRETS_BIN" rekey + [ "$status" -eq 0 ] + [ -x "$SECRETS_DIR/.git/hooks/pre-commit" ] +} + +@test "store protections heal is a silent no-op when nothing is missing" { + init_with_remote + create_project_dir + + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + [[ "$output" != *"Restored store .gitignore"* ]] + [[ "$output" != *"Reinstalled pre-commit hook"* ]] +} + +@test "restored store .gitignore carries the full block/allow globs" { + init_with_remote + rm "$SECRETS_DIR/.gitignore" + create_project_dir + "$SECRETS_BIN" push >/dev/null 2>&1 + + grep -q "^key.txt$" "$SECRETS_DIR/.gitignore" + grep -qF '**/.env' "$SECRETS_DIR/.gitignore" + grep -qF '**/.dev.vars' "$SECRETS_DIR/.gitignore" + grep -qF '!**/.env.age' "$SECRETS_DIR/.gitignore" + grep -qF '!**/.env.*.age' "$SECRETS_DIR/.gitignore" + grep -qF '!**/.dev.vars.age' "$SECRETS_DIR/.gitignore" +} From 2a7afc34dd11796186d7ca01a6830d2d1124ce17 Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Fri, 5 Jun 2026 10:14:46 -0700 Subject: [PATCH 4/6] fix: pre-landing review fixes (heal ordering, key untrack, content-aware gitignore) Red-team + specialist findings from /ship pre-landing review: - CRITICAL: ensure_store_protections ran BEFORE `git pull --ff-only` in commit_and_push_secrets; a pull bringing history without .gitignore left the post-pull window unprotected and `git add -A` committed AND pushed key.txt (reproduced end-to-end). Heal now runs after the pull, immediately before staging. - CRITICAL: .gitignore can't untrack an already-tracked key.txt. Heal now defensively `git rm --cached`s a tracked key with a history-scrub warning. - Content-aware heal: a present .gitignore missing the key.txt line is rewritten, not skipped (security specialist). - Init guard now renders the real sanitized clone URL from .secrets-store when available, mirroring EGB-282's check_initialized. - Tests: positive heal-message assertions, non-empty key guard in the init test, 4 new regression tests (126 total). --- CLAUDE.md | 2 +- secrets | 27 ++++++++++++++++----- test/secrets.bats | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6882141..0a1a49f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek secrets # CLI script (~600 lines bash) hooks/pre-commit # Pre-commit hook template test/ - secrets.bats # bats-core test suite (118 tests) + secrets.bats # bats-core test suite (126 tests) test_helper.bash # Shared setup/teardown README.md # User-facing documentation CLAUDE.md # This file diff --git a/secrets b/secrets index b50122f..97d9640 100755 --- a/secrets +++ b/secrets @@ -724,10 +724,20 @@ EOF # pre-commit hook (hooks aren't cloned), and a half-initialized store may # lack .gitignore — without it, `git add -A` would commit key.txt. ensure_store_protections() { - if [ ! -f "$SECRETS_DIR/.gitignore" ]; then + # Content-aware: a present-but-corrupted .gitignore missing the key.txt + # line is just as dangerous as a missing one. + if [ ! -f "$SECRETS_DIR/.gitignore" ] || ! grep -qx 'key.txt' "$SECRETS_DIR/.gitignore"; then write_store_gitignore info "Restored store .gitignore" fi + # .gitignore can't untrack an already-tracked key (legacy damage, or a + # past window where .gitignore was missing). Remove it from the index so + # the next commit drops it from the tip. + if git -C "$SECRETS_DIR" ls-files --error-unmatch key.txt >/dev/null 2>&1; then + git -C "$SECRETS_DIR" rm --cached --quiet key.txt + echo "WARNING: key.txt was tracked in the store repo — untracked it now." >&2 + echo "It may still exist in git history; consider 'secrets rekey' and scrubbing history." >&2 + fi if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then mkdir -p "$SECRETS_DIR/.git/hooks" install_hook @@ -750,10 +760,14 @@ cmd_init() { # should clone their existing secrets repo, not init a fresh one. # Catch it BEFORE git init so we don't leave a half-initialized store. if [ -f "$KEY_FILE" ]; then + # Render a runnable clone command when .secrets-store carried a remote + # URL (already sanitized by resolve_store), mirroring check_initialized. + local clone_src="" + [ -n "${_REMOTE_URL:-}" ] && clone_src="$_REMOTE_URL" die "Found an existing key at $KEY_FILE but no repo at $SECRETS_DIR. If this is a second machine, don't run 'secrets init' — clone your existing secrets repo instead: - git clone $SECRETS_DIR + git clone $clone_src $SECRETS_DIR Your key file has been left untouched." fi @@ -814,16 +828,17 @@ push_dir_to_project() { commit_and_push_secrets() { local message="$1" - # Must run before `git add -A`: a store missing its .gitignore would - # otherwise stage and push key.txt. - ensure_store_protections - if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then if ! git -C "$SECRETS_DIR" pull --ff-only 2>/dev/null; then die "Fast-forward pull failed. Run 'secrets pull' first, then retry push." fi fi + # Must run AFTER the pull and immediately before `git add -A`: the pull + # can remove or alter .gitignore (remote history that lacks it), and a + # store missing the key.txt line would stage and push the private key. + ensure_store_protections + git -C "$SECRETS_DIR" add -A if git -C "$SECRETS_DIR" diff --cached --quiet 2>/dev/null; then info "No changes to push (secrets unchanged)" diff --git a/test/secrets.bats b/test/secrets.bats index 03c8138..f5565aa 100644 --- a/test/secrets.bats +++ b/test/secrets.bats @@ -1484,6 +1484,8 @@ gradle_project() { # `secrets init` instead of cloning their secrets repo. mkdir -p "$SECRETS_DIR" age-keygen -o "$SECRETS_DIR/key.txt" 2>/dev/null + # Guard against a vacuous '' = '' comparison if age-keygen failed + [ -s "$SECRETS_DIR/key.txt" ] local key_before key_before=$(cat "$SECRETS_DIR/key.txt") @@ -1503,6 +1505,7 @@ gradle_project() { run "$SECRETS_BIN" push [ "$status" -eq 0 ] + [[ "$output" == *"Restored store .gitignore"* ]] [ -f "$SECRETS_DIR/.gitignore" ] grep -q "key.txt" "$SECRETS_DIR/.gitignore" # key.txt must never be tracked (push does `git add -A` in the store) @@ -1560,6 +1563,7 @@ gradle_project() { run "$SECRETS_BIN" push [ "$status" -eq 0 ] + [[ "$output" == *"Reinstalled pre-commit hook"* ]] [ -x "$SECRETS_DIR/.git/hooks/pre-commit" ] } @@ -1597,3 +1601,59 @@ gradle_project() { grep -qF '!**/.env.*.age' "$SECRETS_DIR/.gitignore" grep -qF '!**/.dev.vars.age' "$SECRETS_DIR/.gitignore" } + +@test "push heals .gitignore removed by remote history before staging (key never pushed)" { + init_with_remote + create_project_dir + "$SECRETS_BIN" push >/dev/null 2>&1 + # Remote history drops .gitignore (e.g. an old machine committed without it) + git clone -q "$REMOTE_DIR" "$TEST_TMPDIR/other" + git -C "$TEST_TMPDIR/other" rm -q .gitignore + git -C "$TEST_TMPDIR/other" -c user.email=t@t -c user.name=t commit -qm "drop gitignore" + git -C "$TEST_TMPDIR/other" push -q + + echo "B=2" >> .env + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + [ -f "$SECRETS_DIR/.gitignore" ] + run git -C "$SECRETS_DIR" ls-files + [[ "$output" != *"key.txt"* ]] +} + +@test "push untracks a previously committed key.txt with a warning" { + init_with_remote + # Simulate legacy damage: key.txt got committed in the past + git -C "$SECRETS_DIR" add -f key.txt + git -C "$SECRETS_DIR" -c user.email=t@t -c user.name=t commit -qm "oops" + create_project_dir + + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + [[ "$output" == *"key.txt was tracked"* ]] + run git -C "$SECRETS_DIR" ls-files + [[ "$output" != *"key.txt"* ]] +} + +@test "push rewrites a store .gitignore that is missing the key.txt line" { + init_with_remote + printf '%s\n' '**/.env' > "$SECRETS_DIR/.gitignore" + create_project_dir + + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + grep -qx 'key.txt' "$SECRETS_DIR/.gitignore" + run git -C "$SECRETS_DIR" ls-files + [[ "$output" != *"key.txt"* ]] +} + +@test "init guard renders the real clone URL when .secrets-store carries a remote" { + mkdir -p "$HOME/.secrets-work" + age-keygen -o "$HOME/.secrets-work/key.txt" 2>/dev/null + [ -s "$HOME/.secrets-work/key.txt" ] + cd "$WORK_DIR" + echo "work git@example.com:me/secrets-work.git" > .secrets-store + + run "$SECRETS_BIN" init + [ "$status" -eq 1 ] + [[ "$output" == *"git clone git@example.com:me/secrets-work.git"* ]] +} From 8d9e9a3bfced4d0218a6eb3d6485f230ffb997b1 Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Fri, 5 Jun 2026 10:26:07 -0700 Subject: [PATCH 5/6] chore: bump version and changelog (v0.2.1.0) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 16 ++++++++++++++++ VERSION | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 214b95c..46ab88e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme. +## [0.2.1.0] - 2026-06-05 + +### Fixed + +- **`secrets rekey` no longer bricks dotenv stores.** The re-encrypt loop used a bare `"$dir"*` glob, which never matches dotfiles — so `.env` blobs were decrypted to the temp dir but never re-encrypted, leaving them on the **old** key while the new key overwrote `key.txt`. After a rotation, every dotenv file in the store was undecryptable. The glob now mirrors the decrypt loop (`"$dir"* "$dir".*`), and a round-trip test (push → rekey → pull) pins it. If you ran `rekey` on an earlier version and `pull` now fails with `no identity matched any of the recipients`, your blobs are on a pre-rotation key — recover with an old `key.txt` from another machine. +- **`secrets init` on a second machine now fails helpfully instead of half-initializing.** Copying `key.txt` into `~/.secrets` and then running `init` (instead of cloning your secrets repo) used to run `git init`, crash on the existing key, and leave a store with no `.gitignore` — a state where a later `push` would commit the private key. The guard now fires *before* `git init`, leaves the key untouched, and prints the exact `git clone` command to run — using the real remote URL when your `.secrets-store` file declares one. + +### Security + +- **The private key can no longer be committed by a store missing its `.gitignore`.** `push`, `pull`, and `rekey` now self-heal store protections immediately before any `git add -A`: a missing *or corrupted* `.gitignore` (one without the `key.txt` line) is rewritten, and the pre-commit hook is reinstalled if absent. The heal runs *after* the fast-forward pull, closing a window where remote history without a `.gitignore` could strip protection mid-push. +- **An already-tracked `key.txt` is now untracked automatically.** `.gitignore` can't untrack a file that was committed in the past; the heal now removes a tracked key from the index with a warning that history may need scrubbing and the key may warrant rotation. + +### Changed + +- Project `CLAUDE.md` gained agent skill-routing guidance and an updated test-suite count (126 bats tests, up from 113). + ## [0.2.0.0] - 2026-05-26 ### Added diff --git a/VERSION b/VERSION index e396b40..123b55d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0.0 +0.2.1.0 From ba4c53a160f72c666ab79b411fc5644be3a243bf Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Fri, 5 Jun 2026 10:29:07 -0700 Subject: [PATCH 6/6] docs: update README for v0.2.1.0 Co-Authored-By: Claude Opus 4.8 --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2e23b41..d594f57 100644 --- a/README.md +++ b/README.md @@ -404,13 +404,15 @@ If you suspect your key has been compromised, or a teammate leaves the team: secrets rekey ``` -This generates a new key and re-encrypts all secrets. After rekeying: +This generates a new key and re-encrypts all secrets (including `.env` and other dotfiles). After rekeying: 1. Copy the new `~/.secrets/key.txt` to every machine and teammate 2. Old encrypted files remain in git history (encrypted with the old key, which should be discarded) For complete rotation with no historical exposure, create a fresh `~/.secrets/` repo. +> **Recovering from a broken rekey (pre-0.2.1.0):** Older versions of `rekey` skipped dotfiles (`.env`, `.dev.vars`) when re-encrypting, leaving their blobs on the *old* key while `key.txt` was replaced. If `secrets pull` now fails with `no identity matched any of the recipients`, those blobs are still encrypted to a key you no longer have. Restore the **old** `key.txt` from another machine that hasn't rekeyed, `secrets pull` to recover the plaintext, then `secrets rekey` again on 0.2.1.0 or later. + ## Environment variables | Variable | Default | Purpose | @@ -423,6 +425,8 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/` **"Not initialized"** — Run `secrets init` to create the `~/.secrets/` directory. +**"Found an existing key ... but no repo"** — You copied `key.txt` into `~/.secrets` and then ran `secrets init`. On a second machine you should *clone* your existing secrets repo, not re-initialize it (`init` is only for the very first machine). The error prints the exact `git clone` command to run — copy-paste it, or see [Additional machines](#additional-machines). When your project's `.secrets-store` file declares a remote URL, the command is filled in with the real URL. + **"No secret files found"** — You're in a directory that doesn't have `.env`, `.env.*`, or `.dev.vars` files. Make sure you're in the right project directory. **"Project not found"** — The project name doesn't match anything in `~/.secrets/`. Run `secrets list` to see what's stored. The name is usually derived from your directory name or git remote. @@ -432,7 +436,7 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/` ## Development ```bash -# Run the test suite (113 tests) +# Run the test suite (126 tests) brew install bats-core bats test/secrets.bats ```