Merge branch 'fix/init-second-machine-guard' (v0.2.1.0)

Second-machine init guard, store-protection self-heal (gitignore + hook +
key untrack, post-pull ordering), and the rekey dotfile data-loss fix.
This commit is contained in:
Brian Majewski 2026-06-05 10:49:54 -07:00
commit 6ab22c2b95
6 changed files with 306 additions and 37 deletions

View file

@ -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/), 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. 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 ## [0.2.0.0] - 2026-05-26
### Added ### Added

View file

@ -37,7 +37,7 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek
secrets # CLI script (~600 lines bash) secrets # CLI script (~600 lines bash)
hooks/pre-commit # Pre-commit hook template hooks/pre-commit # Pre-commit hook template
test/ test/
secrets.bats # bats-core test suite (113 tests) secrets.bats # bats-core test suite (126 tests)
test_helper.bash # Shared setup/teardown test_helper.bash # Shared setup/teardown
README.md # User-facing documentation README.md # User-facing documentation
CLAUDE.md # This file CLAUDE.md # This file
@ -85,3 +85,22 @@ Key design decisions (all driven by /autoplan review):
## Environment variable ## 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. `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

View file

@ -404,13 +404,15 @@ If you suspect your key has been compromised, or a teammate leaves the team:
secrets rekey 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 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) 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. 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 ## Environment variables
| Variable | Default | Purpose | | 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. **"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. **"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. **"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 ## Development
```bash ```bash
# Run the test suite (113 tests) # Run the test suite (126 tests)
brew install bats-core brew install bats-core
bats test/secrets.bats bats test/secrets.bats
``` ```

View file

@ -1 +1 @@
0.2.0.0 0.2.1.0

115
secrets
View file

@ -701,26 +701,9 @@ HOOKEOF
fi fi
} }
# ─── Subcommands ─────────────────────────────────────────────────────── # Write the store-level .gitignore. Critical: the `key.txt` line is what
# keeps the private key out of `git add -A` during push/rekey.
cmd_init() { write_store_gitignore() {
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
cat > "$SECRETS_DIR/.gitignore" << 'EOF' cat > "$SECRETS_DIR/.gitignore" << 'EOF'
# Never commit the private key # Never commit the private key
key.txt key.txt
@ -735,6 +718,70 @@ key.txt
!**/.env.*.age !**/.env.*.age
!**/.dev.vars.age !**/.dev.vars.age
EOF 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() {
# 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
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
# Render a runnable clone command when .secrets-store carried a remote
# URL (already sanitized by resolve_store), mirroring check_initialized.
local clone_src="<your-secrets-remote>"
[ -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 $clone_src $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 # Install pre-commit hook
mkdir -p "$SECRETS_DIR/.git/hooks" mkdir -p "$SECRETS_DIR/.git/hooks"
@ -787,6 +834,11 @@ commit_and_push_secrets() {
fi fi
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 git -C "$SECRETS_DIR" add -A
if git -C "$SECRETS_DIR" diff --cached --quiet 2>/dev/null; then if git -C "$SECRETS_DIR" diff --cached --quiet 2>/dev/null; then
info "No changes to push (secrets unchanged)" info "No changes to push (secrets unchanged)"
@ -917,11 +969,8 @@ cmd_pull() {
# Merge any external files (.secrets-files) declared in this project. # Merge any external files (.secrets-files) declared in this project.
pull_external_files "$PWD" "$project" pull_external_files "$PWD" "$project"
# Reinstall hook if missing # Reinstall hook / store .gitignore if missing
if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then ensure_store_protections
install_hook
info "Reinstalled pre-commit hook"
fi
} }
# Pull and decrypt .age files from a project path into a target directory. # Pull and decrypt .age files from a project path into a target directory.
@ -1004,11 +1053,8 @@ cmd_pull_workspaces() {
info "Decrypted $total file(s) total" info "Decrypted $total file(s) total"
# Reinstall hook if missing # Reinstall hook / store .gitignore if missing
if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then ensure_store_protections
install_hook
info "Reinstalled pre-commit hook"
fi
} }
cmd_list() { cmd_list() {
@ -1139,13 +1185,15 @@ cmd_rekey() {
info "Re-encrypting all files with new key..." 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 for dir in "$tmpdir"/*/; do
[ -d "$dir" ] || continue [ -d "$dir" ] || continue
local project local project
project=$(basename "$dir") project=$(basename "$dir")
mkdir -p "$SECRETS_DIR/$project" mkdir -p "$SECRETS_DIR/$project"
for f in "$dir"*; do for f in "$dir"* "$dir".*; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
local name local name
name=$(basename "$f") name=$(basename "$f")
@ -1162,7 +1210,8 @@ cmd_rekey() {
fi fi
done 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" add -A
git -C "$SECRETS_DIR" commit -m "rekey all secrets" >/dev/null 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 if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then

View file

@ -1476,3 +1476,184 @@ gradle_project() {
# definition must, and the '!' comment must be ignored. # definition must, and the '!' comment must be ignored.
grep -q '^beaconClerkPkTest=realkey$' "$HOME/.gradle/gradle.properties" 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
# Guard against a vacuous '' = '' comparison if age-keygen failed
[ -s "$SECRETS_DIR/key.txt" ]
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 ]
[[ "$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)
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" ]
}
@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 ]
[[ "$output" == *"Reinstalled pre-commit hook"* ]]
[ -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"
}
@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"* ]]
}