secrets/docs/superpowers/plans/2026-06-24-multi-recipient-age-encryption.md
Brian Majewski 3a7eea5529 docs: multi-recipient age encryption implementation plan (EGB-283)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:20:32 -07:00

44 KiB

Multi-recipient age encryption Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let a single secrets store encrypt every blob to N age recipient public keys (a per-store team-key set) instead of one shared key, managed via secrets recipients add/rm/list, with full backward compatibility for existing single-key stores.

Architecture: A committed, store-scoped recipients.txt (age -R format) holds the recipient set. A new _load_recipients parses + validates it into a global RECIPIENT_ARGS=(-r k1 -r k2 …) array that every encrypt site uses; absence of the file means legacy single-key behavior (-r $(get_pubkey)). A shared _reencrypt_all engine (factored from today's rekey) decrypts the whole store with the local key and re-encrypts to the current set, and is called by recipients add/rm, the new reencrypt, and multi-recipient rekey. Decryption is unchanged — each member uses their own key.txt.

Tech Stack: Single POSIX-ish bash script (secrets), system bash 3.2 compatible (indexed arrays OK, NO associative arrays). age / age-keygen for crypto. git for the store. bats-core for tests. jq is NOT introduced anywhere in this feature (recipients.txt is plain text, keeping store ops jq-free).

Global Constraints

  • bash 3.2 only: no associative arrays, no bash-4 features. Indexed arrays (RECIPIENT_ARGS=(), arr+=(x), "${arr[@]}") are fine.
  • bats [[ ]] gotcha: every standalone [[ … ]] assertion in a test MUST end with || false. Single-bracket [ ] is unaffected.
  • age recipient format accepted: native age X25519 only — age1 + exactly 58 chars of [0-9a-z]. SSH recipients are out of scope (reject them). This regex/charset is also the injection rail: it cannot contain shell metacharacters, whitespace, or extra flags.
  • recipients.txt is committed, NOT gitignored (public keys are not secret). The store .gitignore only blocks key.txt and plaintext env files, so the file is tracked automatically — do not add it to .gitignore.
  • Security-review policy (.ship-policy.json, CLAUDE.md): adversarial fixtures in this plan are ordinary bats regression tests, NOT AI red-team passes. Do NOT run ./test/run-security.sh on the user's behalf. Before ship, the human operator runs it and signs off.
  • Re-encrypt invariant: any change to the recipient set re-encrypts the WHOLE store in one commit. RECIPIENT_ARGS is always populated by _load_recipients before any age "${RECIPIENT_ARGS[@]}" call (never reference the array empty under set -u).
  • Commit cadence: one commit per task (TDD: test → impl → green → commit).

File map

  • secrets — all code changes (helpers, recipients/reencrypt commands, encrypt-site refactor, init/which/verify/rekey edits, dispatch + help).
  • test/recipients.bats — NEW suite for all multi-recipient behavior + security fixtures.
  • CLAUDE.md, README.md — docs + test counts.

Conventions referenced

  • Constants like SECRETS_FILES_NAME=".secrets-files" live ~line 360; KEY_FILE is set both as a global default (~line 20) and re-set inside resolve_store (~line 301). Mirror this for RECIPIENTS_FILE.
  • Existing encrypt sites (all age -r "$pubkey" -o …): push_dir_to_project (~1207), cmd_push inline (~1357), push_external_files (~655 and ~684), cmd_rekey (~1778). get_pubkey (~98) derives the pubkey from key.txt.
  • Tests run via run "$SECRETS_BIN" <args> with isolated $HOME and $SECRETS_DIR; helpers init_with_remote, create_project_dir live in test/test_helper.bash.

Task 1: Recipient core (RECIPIENTS_FILE, validation, _load_recipients) wired into the push encrypt path

Files:

  • Modify: secrets (constants ~line 19-21; resolve_store ~301; new helpers after get_pubkey ~99; encrypt sites ~655, ~684, ~1207, ~1357; cmd_push ~1268; cmd_push_workspaces ~1428; push_dir_to_project/push_external_files signatures)
  • Test: test/recipients.bats (new)

Interfaces:

  • Produces: global RECIPIENT_ARGS (indexed array of -r <key> pairs); RECIPIENTS_FILE / RECIPIENTS_FILE_NAME; _validate_age_recipient <str> (0 = valid age1 key); _load_recipients (populates RECIPIENT_ARGS, dies on bad/symlinked/empty file).

  • Consumes: get_pubkey, SECRETS_DIR, KEY_FILE.

  • Step 1: Write failing tests in new test/recipients.bats:

#!/usr/bin/env bats
load test_helper

# A throwaway second identity for "another teammate".
make_second_identity() {
  age-keygen -o "$TEST_TMPDIR/bob.txt" 2>/dev/null
  BOB_PUB=$(age-keygen -y "$TEST_TMPDIR/bob.txt")
}

@test "push without recipients.txt stays single-key (legacy behavior)" {
  init_with_remote
  create_project_dir myproj
  run "$SECRETS_BIN" push
  [ "$status" -eq 0 ]
  # No recipients.txt was created by push.
  [ ! -e "$SECRETS_DIR/recipients.txt" ]
  # Blob decrypts with the store's own key.
  run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/work/myproj/.env.age"
  [ "$status" -eq 0 ]
}

@test "push with a hand-written recipients.txt encrypts to every listed key" {
  init_with_remote
  make_second_identity
  STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
  printf '# self\n%s\n# bob\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
  create_project_dir myproj
  run "$SECRETS_BIN" push
  [ "$status" -eq 0 ]
  # Bob (a recipient) can decrypt the pushed blob with HIS key.
  run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/work/myproj/.env.age"
  [ "$status" -eq 0 ]
  # And the store key still can too.
  run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/work/myproj/.env.age"
  [ "$status" -eq 0 ]
}

@test "push refuses a recipients.txt with an invalid key" {
  init_with_remote
  STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
  printf '%s\nnot-an-age-key\n' "$STORE_PUB" > "$SECRETS_DIR/recipients.txt"
  create_project_dir myproj
  run "$SECRETS_BIN" push
  [ "$status" -ne 0 ]
  [[ "$output" == *"Invalid recipient"* ]] || false
}

@test "push refuses a symlinked recipients.txt" {
  init_with_remote
  STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
  printf '%s\n' "$STORE_PUB" > "$TEST_TMPDIR/elsewhere.txt"
  ln -s "$TEST_TMPDIR/elsewhere.txt" "$SECRETS_DIR/recipients.txt"
  create_project_dir myproj
  run "$SECRETS_BIN" push
  [ "$status" -ne 0 ]
  [[ "$output" == *"symlink"* ]] || false
}
  • Step 2: Run to verify they fail

Run: bats test/recipients.bats Expected: FAIL (recipients.txt is ignored today; multi-recipient blob won't decrypt with bob's key; invalid/symlink cases don't error).

  • Step 3: Add the constant + RECIPIENTS_FILE plumbing

Near KEY_FILE="$SECRETS_DIR/key.txt" (~line 20), add the name constant just above it and the path just below:

RECIPIENTS_FILE_NAME="recipients.txt"
KEY_FILE="$SECRETS_DIR/key.txt"
RECIPIENTS_FILE="$SECRETS_DIR/$RECIPIENTS_FILE_NAME"

Inside resolve_store, right after the line that re-sets KEY_FILE="$SECRETS_DIR/key.txt" (~line 301), add:

  RECIPIENTS_FILE="$SECRETS_DIR/$RECIPIENTS_FILE_NAME"
  • Step 4: Add _validate_age_recipient and _load_recipients immediately after get_pubkey (~line 99):
# A native age X25519 recipient: "age1" + exactly 58 chars of [0-9a-z].
# This is also the injection rail — it cannot hold shell metacharacters,
# whitespace, control chars, or extra flags. SSH recipients are intentionally
# unsupported (EGB-283 scope cut).
_validate_age_recipient() {
  case "$1" in
    age1*) : ;;
    *) return 1 ;;
  esac
  local body="${1#age1}"
  [ "${#body}" -eq 58 ] || return 1
  case "$body" in
    *[!0-9a-z]*) return 1 ;;
  esac
  return 0
}

# Populate the global RECIPIENT_ARGS array with one "-r <key>" per store
# recipient. recipients.txt present -> validated keys from the file (the store
# is multi-recipient). Absent -> the single pubkey derived from key.txt (legacy
# single-key store, exactly today's behavior). We parse the file ourselves
# (never `age -R <path>`) because it is committed = an injection surface; every
# line is validated and the file is refused if symlinked. Dies on any problem.
RECIPIENT_ARGS=()
_load_recipients() {
  RECIPIENT_ARGS=()
  if [ ! -e "$RECIPIENTS_FILE" ]; then
    RECIPIENT_ARGS=(-r "$(get_pubkey)")
    return 0
  fi
  if [ -L "$RECIPIENTS_FILE" ]; then
    die "Refusing to read symlinked $RECIPIENTS_FILE_NAME (security)."
  fi
  local line trimmed n=0
  while IFS= read -r line || [ -n "$line" ]; do
    trimmed="${line#"${line%%[![:space:]]*}"}"     # lstrip
    trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" # rstrip
    [ -z "$trimmed" ] && continue
    case "$trimmed" in '#'*) continue ;; esac
    if ! _validate_age_recipient "$trimmed"; then
      die "Invalid recipient in $RECIPIENTS_FILE_NAME: '$trimmed' (expected a native age key: age1...)."
    fi
    RECIPIENT_ARGS+=(-r "$trimmed")
    n=$((n + 1))
  done < "$RECIPIENTS_FILE"
  if [ "$n" -eq 0 ]; then
    die "$RECIPIENTS_FILE_NAME has no recipients — a store must have at least one. Run 'secrets recipients add <age1...>'."
  fi
}
  • Step 5: Route every encrypt site through RECIPIENT_ARGS

In push_dir_to_project, change the encrypt line (~1207):

    age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${name}.age" "$f"

and delete its now-unused local pubkey="$3" line (~1192).

In cmd_push, change the inline encrypt (~1357):

    age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${rel}.age" "$PWD/$rel"

In push_external_files, change both encrypt lines (~655 and ~684) to age "${RECIPIENT_ARGS[@]}" -o … (keep the rest of each line identical) and delete its local pubkey="$3" from the signature line local root="$1" project="$2" pubkey="$3"local root="$1" project="$2".

  • Step 6: Load recipients in the push commands and drop the old pubkey threading

In cmd_push (~1268-1269) replace:

  local pubkey
  pubkey=$(get_pubkey)

with:

  _load_recipients

and change the external call (~1365) push_external_files "$PWD" "$project" (drop "$pubkey").

In cmd_push_workspaces (~1428) replace the pubkey=$(get_pubkey) pair with _load_recipients, and drop the "$pubkey" argument from the push_dir_to_project … (~1432, ~1443) and push_external_files … (~1450) calls.

  • Step 7: Run the tests

Run: bats test/recipients.bats Expected: PASS (4 tests).

  • Step 8: Run the full suite to confirm no regression

Run: bats test/ Expected: PASS (all existing tests still green — legacy push/pull unchanged).

  • Step 9: Commit
git add secrets test/recipients.bats
git commit -m "feat: multi-recipient encrypt core + recipients.txt (EGB-283)"

Task 2: secrets recipients list

Files:

  • Modify: secrets (new _recipients_dump, cmd_recipients, _recipients_list; dispatch + nothing in help yet)
  • Test: test/recipients.bats

Interfaces:

  • Produces: _recipients_dump (emits <key>\t<name> per recipient, name = nearest preceding # <name> comment or empty); cmd_recipients <sub> … (routes list); _recipients_list.

  • Consumes: _load_recipients, RECIPIENTS_FILE, get_pubkey.

  • Step 1: Write failing tests

@test "recipients list on a legacy store shows the single derived key" {
  init_with_remote
  run "$SECRETS_BIN" recipients list
  [ "$status" -eq 0 ]
  [[ "$output" == *"single-key"* ]] || false
  STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
  [[ "$output" == *"$STORE_PUB"* ]] || false
}

@test "recipients list shows names and keys from recipients.txt" {
  init_with_remote
  make_second_identity
  STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
  printf '# alice\n%s\n# bob\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
  run "$SECRETS_BIN" recipients list
  [ "$status" -eq 0 ]
  [[ "$output" == *"recipients: 2"* ]] || false
  [[ "$output" == *"alice"* ]] || false
  [[ "$output" == *"bob"* ]] || false
}
  • Step 2: Run to verify they fail

Run: bats test/recipients.bats -f "recipients list" Expected: FAIL ("Unknown command: recipients").

  • Step 3: Add _recipients_dump (place after _load_recipients):
# Emit "<key>\t<name>" for each recipient in recipients.txt. <name> is the most
# recent preceding "# <name>" comment, or empty. Read-only; no validation
# (callers that need rails call _load_recipients separately).
_recipients_dump() {
  [ -e "$RECIPIENTS_FILE" ] || return 0
  local line trimmed name=""
  while IFS= read -r line || [ -n "$line" ]; do
    trimmed="${line#"${line%%[![:space:]]*}"}"
    trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
    [ -z "$trimmed" ] && continue
    case "$trimmed" in
      '#'*)
        name="${trimmed#\#}"
        name="${name#"${name%%[![:space:]]*}"}"
        ;;
      *)
        printf '%s\t%s\n' "$trimmed" "$name"
        name=""
        ;;
    esac
  done < "$RECIPIENTS_FILE"
}
  • Step 4: Add cmd_recipients + _recipients_list (place near cmd_which):
cmd_recipients() {
  resolve_store
  local sub="${1:-list}"
  [ $# -gt 0 ] && shift
  case "$sub" in
    list) _recipients_list ;;
    *) die "Unknown recipients subcommand: '$sub'. Usage: secrets recipients [list]" ;;
  esac
}

_recipients_list() {
  check_initialized
  if [ ! -e "$RECIPIENTS_FILE" ]; then
    check_key
    echo "recipients: single-key (no $RECIPIENTS_FILE_NAME)"
    echo "  $(get_pubkey)"
    return 0
  fi
  _load_recipients   # validates the file (dies on bad key / symlink)
  local count=0 k n
  while IFS=$'\t' read -r k n; do count=$((count + 1)); done < <(_recipients_dump)
  echo "recipients: $count (from $RECIPIENTS_FILE_NAME)"
  while IFS=$'\t' read -r k n; do
    if [ -n "$n" ]; then echo "  $k  ($n)"; else echo "  $k"; fi
  done < <(_recipients_dump)
}
  • Step 5: Wire dispatch. In the case "${1:-help}" block, add above which|where|status:
  recipients) shift; cmd_recipients "$@" ;;
  • Step 6: Run the tests

Run: bats test/recipients.bats -f "recipients list" Expected: PASS.

  • Step 7: Commit
git add secrets test/recipients.bats
git commit -m "feat: secrets recipients list (EGB-283)"

Task 3: Shared _reencrypt_all engine + secrets reencrypt + dual rekey

Files:

  • Modify: secrets (new _reencrypt_all, cmd_reencrypt; rewrite cmd_rekey head to branch; dispatch)
  • Test: test/recipients.bats

Interfaces:

  • Produces: _reencrypt_all <commit-msg> (decrypt whole store with KEY_FILE, re-encrypt to RECIPIENT_ARGS, commit + push; aborts with store intact on decrypt failure; no-op on empty store); cmd_reencrypt.

  • Consumes: _load_recipients, RECIPIENT_ARGS, KEY_FILE, ensure_store_protections.

  • Step 1: Write failing tests

@test "reencrypt re-encrypts existing blobs to a newly added recipient line" {
  init_with_remote
  create_project_dir myproj
  run "$SECRETS_BIN" push           # single-key blob
  [ "$status" -eq 0 ]
  make_second_identity
  STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
  printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
  # Bob cannot read the old single-key blob yet.
  run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/work/myproj/.env.age"
  [ "$status" -ne 0 ]
  run "$SECRETS_BIN" reencrypt
  [ "$status" -eq 0 ]
  # Now he can.
  run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/work/myproj/.env.age"
  [ "$status" -eq 0 ]
}

@test "rekey on a multi-recipient store keeps recipients and the same key" {
  init_with_remote
  create_project_dir myproj
  run "$SECRETS_BIN" push
  before=$(cat "$SECRETS_DIR/key.txt")
  make_second_identity
  STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
  printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
  run "$SECRETS_BIN" rekey
  [ "$status" -eq 0 ]
  # No new keypair was generated.
  [ "$(cat "$SECRETS_DIR/key.txt")" = "$before" ]
  # Both recipients can decrypt.
  run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/work/myproj/.env.age"
  [ "$status" -eq 0 ]
}

@test "rekey on a legacy store still rotates to a new key (unchanged)" {
  init_with_remote
  create_project_dir myproj
  run "$SECRETS_BIN" push
  before=$(cat "$SECRETS_DIR/key.txt")
  run "$SECRETS_BIN" rekey
  [ "$status" -eq 0 ]
  [ "$(cat "$SECRETS_DIR/key.txt")" != "$before" ]
  run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/work/myproj/.env.age"
  [ "$status" -eq 0 ]
}
  • Step 2: Run to verify they fail

Run: bats test/recipients.bats -f "reencrypt|rekey on" Expected: FAIL ("Unknown command: reencrypt"; multi rekey generates a new key today).

  • Step 3: Add _reencrypt_all (place just before cmd_rekey):
# Decrypt every blob in the store with the local key and re-encrypt each to the
# currently-loaded RECIPIENT_ARGS, then commit + push. The caller MUST have run
# _load_recipients (or set RECIPIENT_ARGS) and check_key first. Aborts with the
# store untouched on any decrypt failure (you must be a current recipient).
# Shared by recipients add/rm, reencrypt, and multi-recipient rekey.
_reencrypt_all() {
  local commit_msg="$1"
  local tmpdir
  tmpdir=$(mktemp -d)
  trap 'rm -rf "${tmpdir:-}"' EXIT INT TERM

  info "Decrypting all blobs with your key..."
  local file_count=0 dir project f rel dest
  for dir in "$SECRETS_DIR"/*/; do
    [ -d "$dir" ] || continue
    project=$(basename "$dir")
    case "$project" in .*) continue ;; esac
    mkdir -p "$tmpdir/$project"
    while IFS= read -r f; do
      [ -f "$f" ] || continue
      rel=${f#"$dir"}; rel=${rel%.age}
      dest="$tmpdir/$project/$rel"
      mkdir -p "$(dirname "$dest")"
      if ! age -d -i "$KEY_FILE" -o "$dest" "$f"; then
        die "Decryption failed for $project/$rel (are you a current recipient?). Aborted; store unchanged."
      fi
      file_count=$((file_count + 1))
    done < <(find "$dir" -type f -name '*.age')
  done

  if [ "$file_count" -eq 0 ]; then
    rm -rf "$tmpdir"; trap - EXIT INT TERM
    info "No encrypted blobs in the store — nothing to re-encrypt."
    return 0
  fi

  local rc=$(( ${#RECIPIENT_ARGS[@]} / 2 ))
  info "Re-encrypting $file_count blob(s) to $rc recipient(s)..."
  for dir in "$tmpdir"/*/; do
    [ -d "$dir" ] || continue
    project=$(basename "$dir")
    mkdir -p "$SECRETS_DIR/$project"
    while IFS= read -r f; do
      [ -f "$f" ] || continue
      rel=${f#"$dir"}
      mkdir -p "$(dirname "$SECRETS_DIR/$project/$rel")"
      age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${rel}.age" "$f"
    done < <(find "$dir" -type f)
  done

  ensure_store_protections
  git -C "$SECRETS_DIR" add -A
  git -C "$SECRETS_DIR" commit -m "$commit_msg" >/dev/null
  if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
    git -C "$SECRETS_DIR" push >/dev/null 2>&1
    info "Pushed re-encrypted secrets to remote"
  else
    info "Committed re-encrypted secrets locally (no remote configured)"
  fi
  rm -rf "$tmpdir"; trap - EXIT INT TERM
}

cmd_reencrypt() {
  check_cmd age
  check_cmd git
  resolve_store
  check_initialized
  check_key
  _load_recipients
  _reencrypt_all "reencrypt: re-encrypt all to current recipients"
}
  • Step 4: Branch cmd_rekey. Replace the head of cmd_rekey — from its check_cmd age line down to and including the info "Decrypting all files with current key..." line — with the block below. Leave the rest of the existing legacy body (temp dir, decrypt loop, keygen, re-encrypt loop, commit/push) exactly as-is below this insertion:
cmd_rekey() {
  check_cmd age
  check_cmd git
  resolve_store
  check_initialized
  check_key

  # EGB-283: on a multi-recipient store, rekey means "re-encrypt every blob to
  # the current recipients.txt set" — NOT a new keypair (rotating an identity is
  # the member's own age-keygen + recipients rm/add). Legacy stores (no
  # recipients.txt) keep the original generate-new-keypair behavior below.
  if [ -e "$RECIPIENTS_FILE" ]; then
    _load_recipients
    info "Multi-recipient store — re-encrypting to $RECIPIENTS_FILE_NAME (no new key generated)."
    _reencrypt_all "rekey: re-encrypt all to current recipients"
    return 0
  fi

  # ── Legacy single-key rotation (unchanged) ──
  info "Decrypting all files with current key..."
  • Step 5: Wire dispatch. Add near rekey):
  reencrypt) cmd_reencrypt ;;
  • Step 6: Run the tests

Run: bats test/recipients.bats -f "reencrypt|rekey" Expected: PASS (3 tests).

  • Step 7: Run the full suite (the legacy rekey tests in secrets.bats must still pass)

Run: bats test/ Expected: PASS.

  • Step 8: Commit
git add secrets test/recipients.bats
git commit -m "feat: shared _reencrypt_all + reencrypt cmd + dual rekey (EGB-283)"

Task 4: secrets recipients add

Files:

  • Modify: secrets (_recipients_add, _validate_recipient_name; extend cmd_recipients case)
  • Test: test/recipients.bats

Interfaces:

  • Produces: _recipients_add <age1…> [--name <label>] (validate, bootstrap legacy store with self key, reject dup, append, re-encrypt all); _validate_recipient_name.

  • Consumes: _recipients_dump, _load_recipients, _reencrypt_all, get_pubkey, _validate_age_recipient.

  • Step 1: Write failing tests

@test "recipients add bootstraps a legacy store and re-encrypts" {
  init_with_remote
  create_project_dir myproj
  run "$SECRETS_BIN" push
  make_second_identity
  run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
  [ "$status" -eq 0 ]
  [ -e "$SECRETS_DIR/recipients.txt" ]
  # recipients.txt now has self + bob (2 keys).
  run "$SECRETS_BIN" recipients list
  [[ "$output" == *"recipients: 2"* ]] || false
  [[ "$output" == *"bob"* ]] || false
  # Existing blob re-encrypted: bob can read it.
  run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/work/myproj/.env.age"
  [ "$status" -eq 0 ]
}

@test "recipients add rejects a non-age key" {
  init_with_remote
  run "$SECRETS_BIN" recipients add "ssh-ed25519 AAAAfoo"
  [ "$status" -ne 0 ]
  [[ "$output" == *"valid age recipient"* ]] || false
}

@test "recipients add rejects a duplicate" {
  init_with_remote
  make_second_identity
  run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
  [ "$status" -eq 0 ]
  run "$SECRETS_BIN" recipients add "$BOB_PUB"
  [ "$status" -ne 0 ]
  [[ "$output" == *"already present"* ]] || false
}

@test "recipients add rejects an unsafe --name" {
  init_with_remote
  make_second_identity
  run "$SECRETS_BIN" recipients add "$BOB_PUB" --name 'bob; rm -rf ~'
  [ "$status" -ne 0 ]
  [[ "$output" == *"Invalid --name"* ]] || false
}
  • Step 2: Run to verify they fail

Run: bats test/recipients.bats -f "recipients add" Expected: FAIL ("Unknown recipients subcommand: 'add'").

  • Step 3: Add _validate_recipient_name (place near _validate_age_recipient):
# Recipient display names become "# <name>" comment lines in recipients.txt.
# Restrict to a safe charset so a name can't inject extra lines/metacharacters.
_validate_recipient_name() {
  case "$1" in
    *[!A-Za-z0-9\ ._-]*) return 1 ;;
    *) return 0 ;;
  esac
}
  • Step 4: Add _recipients_add (place after _recipients_list):
_recipients_add() {
  check_cmd age
  check_cmd git
  check_initialized
  check_key
  local key="" name=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --name) name="${2:-}"; shift 2 ;;
      -*) die "Unknown flag: $1. Usage: secrets recipients add <age1...> [--name <label>]" ;;
      *) if [ -z "$key" ]; then key="$1"; else die "Unexpected argument: $1"; fi; shift ;;
    esac
  done
  [ -n "$key" ] || die "Usage: secrets recipients add <age1...> [--name <label>]"
  _validate_age_recipient "$key" || die "Not a valid age recipient: '$key' (expected age1..., 62 chars; SSH keys unsupported)."
  if [ -n "$name" ]; then
    _validate_recipient_name "$name" || die "Invalid --name '$name' (allowed: letters, digits, space, . _ -)."
  fi
  if [ -L "$RECIPIENTS_FILE" ]; then
    die "Refusing to write symlinked $RECIPIENTS_FILE_NAME."
  fi
  # Bootstrap a legacy store: seed this machine's key first so the operator
  # stays a recipient (and can decrypt to re-encrypt).
  if [ ! -e "$RECIPIENTS_FILE" ]; then
    printf '# self\n%s\n' "$(get_pubkey)" > "$RECIPIENTS_FILE"
  fi
  local k _n
  while IFS=$'\t' read -r k _n; do
    [ "$k" = "$key" ] && die "Recipient already present: $key"
  done < <(_recipients_dump)
  { [ -n "$name" ] && printf '# %s\n' "$name"; printf '%s\n' "$key"; } >> "$RECIPIENTS_FILE"
  info "Added recipient${name:+ ($name)}: $key"
  _load_recipients
  _reencrypt_all "recipients: add ${name:-$key}; re-encrypt all"
}
  • Step 5: Extend cmd_recipients — add the add) arm:
    list) _recipients_list ;;
    add)  _recipients_add "$@" ;;
    *) die "Unknown recipients subcommand: '$sub'. Usage: secrets recipients [list|add <age1...> [--name N]]" ;;
  • Step 6: Run the tests

Run: bats test/recipients.bats -f "recipients add" Expected: PASS (4 tests).

  • Step 7: Commit
git add secrets test/recipients.bats
git commit -m "feat: secrets recipients add (EGB-283)"

Task 5: secrets recipients rm with last-recipient and self-lockout guards

Files:

  • Modify: secrets (_recipients_rm, _recipients_write_without; extend cmd_recipients case)
  • Test: test/recipients.bats

Interfaces:

  • Produces: _recipients_rm <age1…|name> [--yes]; _recipients_write_without <key> (canonical rewrite dropping one key + its name comment).

  • Consumes: _recipients_dump, _load_recipients, _reencrypt_all, get_pubkey.

  • Step 1: Write failing tests

@test "recipients rm removes a recipient and re-encrypts to the rest" {
  init_with_remote
  create_project_dir myproj
  run "$SECRETS_BIN" push
  make_second_identity
  run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
  run "$SECRETS_BIN" recipients rm bob
  [ "$status" -eq 0 ]
  run "$SECRETS_BIN" recipients list
  [[ "$output" == *"recipients: 1"* ]] || false
  # Store key still reads its own blobs.
  run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/work/myproj/.env.age"
  [ "$status" -eq 0 ]
}

@test "recipients rm refuses to remove the last recipient" {
  init_with_remote
  make_second_identity
  run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob   # store = self + bob
  STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
  run "$SECRETS_BIN" recipients rm bob                       # back to self only
  [ "$status" -eq 0 ]
  run "$SECRETS_BIN" recipients rm "$STORE_PUB"              # would be the last
  [ "$status" -ne 0 ]
  [[ "$output" == *"last recipient"* ]] || false
}

@test "recipients rm of your own key requires --yes" {
  init_with_remote
  make_second_identity
  run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
  STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
  run "$SECRETS_BIN" recipients rm "$STORE_PUB"
  [ "$status" -ne 0 ]
  [[ "$output" == *"your own key"* ]] || false
  run "$SECRETS_BIN" recipients rm "$STORE_PUB" --yes
  [ "$status" -eq 0 ]
}

@test "recipients rm of a non-existent target errors" {
  init_with_remote
  make_second_identity
  run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
  run "$SECRETS_BIN" recipients rm carol
  [ "$status" -ne 0 ]
  [[ "$output" == *"No recipient matches"* ]] || false
}
  • Step 2: Run to verify they fail

Run: bats test/recipients.bats -f "recipients rm" Expected: FAIL ("Unknown recipients subcommand: 'rm'").

  • Step 3: Add _recipients_write_without + _recipients_rm (after _recipients_add):
# Rewrite recipients.txt canonically (one "# name"? + key per entry), dropping
# the entry whose key == <drop>. Atomic-ish via temp file in the same dir.
_recipients_write_without() {
  local drop="$1" tmp k n
  tmp=$(mktemp "$SECRETS_DIR/.recipients.XXXXXX")
  while IFS=$'\t' read -r k n; do
    [ "$k" = "$drop" ] && continue
    [ -n "$n" ] && printf '# %s\n' "$n" >> "$tmp"
    printf '%s\n' "$k" >> "$tmp"
  done < <(_recipients_dump)
  mv "$tmp" "$RECIPIENTS_FILE"
}

_recipients_rm() {
  check_cmd age
  check_cmd git
  check_initialized
  check_key
  local target="" assume_yes=false
  while [ $# -gt 0 ]; do
    case "$1" in
      --yes|-y) assume_yes=true; shift ;;
      -*) die "Unknown flag: $1. Usage: secrets recipients rm <age1...|name> [--yes]" ;;
      *) if [ -z "$target" ]; then target="$1"; else die "Unexpected argument: $1"; fi; shift ;;
    esac
  done
  [ -n "$target" ] || die "Usage: secrets recipients rm <age1...|name> [--yes]"
  [ -e "$RECIPIENTS_FILE" ] || die "No $RECIPIENTS_FILE_NAME — store is single-key; nothing to remove."
  [ -L "$RECIPIENTS_FILE" ] && die "Refusing to write symlinked $RECIPIENTS_FILE_NAME."
  local k n match="" count=0 total=0
  while IFS=$'\t' read -r k n; do
    total=$((total + 1))
    if [ "$k" = "$target" ] || { [ -n "$n" ] && [ "$n" = "$target" ]; }; then
      match="$k"; count=$((count + 1))
    fi
  done < <(_recipients_dump)
  [ "$count" -eq 0 ] && die "No recipient matches '$target'."
  [ "$count" -gt 1 ] && die "'$target' matches $count recipients by name — remove by key (age1...) instead."
  [ "$total" -le 1 ] && die "Refusing to remove the last recipient — a store must have at least one."
  local self; self="$(get_pubkey)"
  if [ "$match" = "$self" ] && [ "$assume_yes" != true ]; then
    die "Refusing to remove your own key (you would lose access to future pushes). Re-run with --yes to confirm."
  fi
  _recipients_write_without "$match"
  info "Removed recipient: $match"
  _load_recipients
  _reencrypt_all "recipients: remove $match; re-encrypt all"
}
  • Step 4: Extend cmd_recipients — add the rm)/remove) arm:
    list) _recipients_list ;;
    add)  _recipients_add "$@" ;;
    rm|remove) _recipients_rm "$@" ;;
    *) die "Unknown recipients subcommand: '$sub'. Usage: secrets recipients [list|add <age1...> [--name N]|rm <age1...|name> [--yes]]" ;;
  • Step 5: Run the tests

Run: bats test/recipients.bats -f "recipients rm" Expected: PASS (4 tests).

  • Step 6: Commit
git add secrets test/recipients.bats
git commit -m "feat: secrets recipients rm with lockout guards (EGB-283)"

Task 6: init born-multi seeding + which recipients line

Files:

  • Modify: secrets (cmd_init ~1176; cmd_which ~1913)
  • Test: test/recipients.bats

Interfaces:

  • Consumes: RECIPIENTS_FILE, get_pubkey, _recipients_dump.

  • Produces: a recipients.txt seeded at init; a recipients: line in which output.

  • Step 1: Write failing tests

@test "init seeds recipients.txt with the new store key (born-multi)" {
  run "$SECRETS_BIN" init
  [ "$status" -eq 0 ]
  [ -e "$SECRETS_DIR/recipients.txt" ]
  STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
  run cat "$SECRETS_DIR/recipients.txt"
  [[ "$output" == *"$STORE_PUB"* ]] || false
}

@test "which reports the recipient count" {
  init_with_remote
  make_second_identity
  run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
  create_project_dir myproj
  run "$SECRETS_BIN" which
  [ "$status" -eq 0 ]
  [[ "$output" == *"recipients: 2"* ]] || false
  [[ "$output" == *"bob"* ]] || false
}

Note: the first test will change init's observable output — confirm no existing secrets.bats test asserts the absence of recipients.txt after init. If one does, update it in this task.

  • Step 2: Run to verify they fail

Run: bats test/recipients.bats -f "born-multi|which reports" Expected: FAIL.

  • Step 3: Seed in cmd_init. After pubkey=$(get_pubkey) (~1176) and before the info "Done!..." banner, add:
  # EGB-283: born-multi — seed recipients.txt with this store's public key so
  # the store is multi-recipient-ready from day one. Committed (not gitignored),
  # staged by the first push like .secrets-format.
  printf '# self\n%s\n' "$pubkey" > "$RECIPIENTS_FILE"
  • Step 4: Add the which line. In cmd_which, right after echo "format: v$(_store_format)" (~1913), add:
  # EGB-283: surface the recipient set (store-scoped; one key per team member).
  if [ -e "$RECIPIENTS_FILE" ] && [ ! -L "$RECIPIENTS_FILE" ]; then
    local rcount=0 rk rn rnames=""
    while IFS=$'\t' read -r rk rn; do
      rcount=$((rcount + 1))
      [ -n "$rn" ] && rnames="${rnames:+$rnames, }$rn"
    done < <(_recipients_dump)
    if [ -n "$rnames" ]; then
      echo "recipients: $rcount ($rnames)"
    else
      echo "recipients: $rcount"
    fi
  else
    echo "recipients: single-key (no $RECIPIENTS_FILE_NAME)"
  fi
  • Step 5: Run the tests + full suite

Run: bats test/recipients.bats -f "born-multi|which reports" then bats test/ Expected: PASS. (If a legacy secrets.bats/migrate.bats test broke on the new init output or the new which line, fix that test to expect the new line and re-run.)

  • Step 6: Commit
git add secrets test/
git commit -m "feat: init born-multi recipients.txt + which recipients line (EGB-283)"

Task 7: verify recipient-count invariant

Files:

  • Modify: secrets (_blob_recipient_count, _check_blob_recipient_count; _verify_project ~2017; _verify_all ~1970)
  • Test: test/recipients.bats

Interfaces:

  • Produces: _blob_recipient_count <blob> (count of -> stanzas in the age header); _check_blob_recipient_count <blob> <rel> <expected> (echoes a FINDING + returns 1 on mismatch, 0 if expected empty/legacy).

  • Consumes: _load_recipients, RECIPIENT_ARGS, RECIPIENTS_FILE.

  • Step 1: Write failing tests

@test "verify --all passes on a healthy multi-recipient store" {
  init_with_remote
  create_project_dir myproj
  run "$SECRETS_BIN" push
  make_second_identity
  run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob   # re-encrypts to 2
  run "$SECRETS_BIN" verify --all
  [ "$status" -eq 0 ]
}

@test "verify flags a blob whose recipient count drifted" {
  init_with_remote
  create_project_dir myproj
  run "$SECRETS_BIN" push                                   # single-key blob (1 stanza)
  make_second_identity
  STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
  # Declare 2 recipients but do NOT re-encrypt — the on-disk blob still has 1.
  printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
  run "$SECRETS_BIN" verify --all
  [ "$status" -ne 0 ]
  [[ "$output" == *"recipient"* ]] || false
}
  • Step 2: Run to verify they fail

Run: bats test/recipients.bats -f "verify" Expected: the drift test FAILs (verify is green today even with a stale blob).

  • Step 3: Add the helpers (place near _verify_blob_decrypts ~1961):
# Count age recipient stanzas ("-> ...") in a blob's header. The age v1 header
# is ASCII and ends at the "--- <mac>" line, so reading line-by-line stops
# before any binary body. Echoes the count.
_blob_recipient_count() {
  local f="$1" line count=0
  while IFS= read -r line; do
    case "$line" in
      '--- '*) break ;;
      '-> '*) count=$((count + 1)) ;;
    esac
  done < "$f"
  echo "$count"
}

# If the store is multi-recipient (expected non-empty), assert <blob> was
# encrypted to exactly <expected> recipients. Echoes a FINDING and returns 1 on
# mismatch; returns 0 otherwise (incl. legacy stores where expected is empty).
_check_blob_recipient_count() {
  local blob="$1" rel="$2" expected="$3"
  [ -n "$expected" ] || return 0
  local actual; actual=$(_blob_recipient_count "$blob")
  if [ "$actual" != "$expected" ]; then
    echo "FINDING: $rel is encrypted to $actual recipient(s) but $RECIPIENTS_FILE_NAME has $expected — run 'secrets reencrypt'." >&2
    return 1
  fi
  return 0
}
  • Step 4: Compute the expected count + check in _verify_all. Near the top of _verify_all (after local checked=0 failed=0 …), add:
  local rexpected=""
  if [ -e "$RECIPIENTS_FILE" ] && [ ! -L "$RECIPIENTS_FILE" ]; then
    _load_recipients   # validates; dies on a bad recipients.txt
    rexpected=$(( ${#RECIPIENT_ARGS[@]} / 2 ))
  fi

Then, inside its blob loop, right after the _verify_blob_decrypts block, add:

      if ! _check_blob_recipient_count "$f" "${f#"$SECRETS_DIR"/}" "$rexpected"; then
        failed=$((failed + 1))
      fi
  • Step 5: Same in _verify_project. After its local findings=0 checked=0 add the same rexpected block. Then after EACH of the two _verify_blob_decrypts checks (the dotenv blob loop and the external eblob loop), add a count check that increments findings:
    # dotenv loop, after the decrypt check:
    if ! _check_blob_recipient_count "$blob" "$project/$rel.age" "$rexpected"; then
      findings=$((findings + 1))
    fi
    # external loop, after the decrypt check:
    if ! _check_blob_recipient_count "$eblob" "$project/$erel" "$rexpected"; then
      findings=$((findings + 1))
    fi
  • Step 6: Run the tests + full suite

Run: bats test/recipients.bats -f "verify" then bats test/ Expected: PASS. (_verify_project tests in other suites run against single-key stores where rexpected is empty → checks are skipped, so they stay green.)

  • Step 7: Commit
git add secrets test/recipients.bats
git commit -m "feat: verify asserts blob recipient-count matches recipients.txt (EGB-283)"

Task 8: Security regression fixtures for recipients.txt

Files:

  • Modify: test/recipients.bats (adversarial fixtures only — no secrets changes expected; if a case slips through, fix the rail in secrets here)

Interfaces: none new — exercises _load_recipients / _recipients_add rails via the CLI.

Policy reminder: these are ordinary defensive regression tests. Do NOT dispatch red-team/adversarial-review subagents and do NOT run ./test/run-security.sh — that is operator-local.

  • Step 1: Write the fixtures
@test "SECURITY: recipients.txt with shell metacharacters is rejected, no execution" {
  init_with_remote
  STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
  printf '%s\nage1$(touch %s/pwned)\n' "$STORE_PUB" "$TEST_TMPDIR" > "$SECRETS_DIR/recipients.txt"
  create_project_dir myproj
  run "$SECRETS_BIN" push
  [ "$status" -ne 0 ]
  [ ! -e "$TEST_TMPDIR/pwned" ]
  [[ "$output" == *"Invalid recipient"* ]] || false
}

@test "SECURITY: recipients.txt line that looks like an extra age flag is rejected" {
  init_with_remote
  STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
  printf '%s\n-i /etc/passwd\n' "$STORE_PUB" > "$SECRETS_DIR/recipients.txt"
  create_project_dir myproj
  run "$SECRETS_BIN" push
  [ "$status" -ne 0 ]
  [[ "$output" == *"Invalid recipient"* ]] || false
}

@test "SECURITY: control/ANSI characters in recipients.txt are rejected" {
  init_with_remote
  STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
  printf '%s\nage1%b\n' "$STORE_PUB" 'aaaa\033[31mevil' > "$SECRETS_DIR/recipients.txt"
  create_project_dir myproj
  run "$SECRETS_BIN" push
  [ "$status" -ne 0 ]
}

@test "SECURITY: recipients add rejects a key with embedded whitespace" {
  init_with_remote
  run "$SECRETS_BIN" recipients add "age1aaaa bbbb"
  [ "$status" -ne 0 ]
  [[ "$output" == *"valid age recipient"* ]] || false
}

@test "SECURITY: a symlinked recipients.txt is refused on add and rm too" {
  init_with_remote
  make_second_identity
  STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
  printf '%s\n' "$STORE_PUB" > "$TEST_TMPDIR/elsewhere.txt"
  ln -s "$TEST_TMPDIR/elsewhere.txt" "$SECRETS_DIR/recipients.txt"
  run "$SECRETS_BIN" recipients add "$BOB_PUB"
  [ "$status" -ne 0 ]
  [[ "$output" == *"symlink"* ]] || false
}
  • Step 2: Run them

Run: bats test/recipients.bats -f "SECURITY" Expected: PASS. If any FAILS, the corresponding rail in secrets (_validate_age_recipient, the -L symlink guard in _load_recipients/_recipients_add/_recipients_rm) is too loose — tighten it, then re-run.

  • Step 3: Commit
git add test/recipients.bats
git commit -m "test: recipients.txt security regression fixtures (EGB-283)"

Task 9: Docs, help text, version bump

Files:

  • Modify: secrets (cmd_help); CLAUDE.md; README.md; VERSION/version constant if one exists

Interfaces: none.

  • Step 1: Add help text. In cmd_help, in the command list, add lines (match the existing column style):
  secrets recipients list           List the store's recipient keys
  secrets recipients add KEY [--name N]  Add a recipient and re-encrypt the store
  secrets recipients rm KEY|NAME [--yes] Remove a recipient and re-encrypt the store
  secrets reencrypt                 Re-encrypt every blob to the current recipients
  • Step 2: Update CLAUDE.md. In the Architecture section add a multi-recipient bullet:
- Multi-recipient (EGB-283): a store-scoped, committed `recipients.txt` (age `-R`
  format, `# name` comments) lets one store encrypt every blob to N age keys —
  one per team member. Managed via `secrets recipients add/rm/list`; absence of
  the file ⇒ legacy single-key behavior (recipients = the pubkey derived from
  `key.txt`). The file is parsed by us (never `age -R <path>`) into a validated
  `RECIPIENT_ARGS` array (native age X25519 only, `age1[0-9a-z]{58}`; SSH
  recipients rejected; symlinked file refused) — same conservative posture as
  `.secrets-store`/`.secrets-files`. `_load_recipients` populates the array;
  every encrypt site routes through it. Any recipient change re-encrypts the
  WHOLE store in one commit via the shared `_reencrypt_all` engine (also used by
  the new `secrets reencrypt` and by `rekey` on a multi-recipient store, where
  rekey re-encrypts to the set with NO new keypair; legacy stores keep rekey's
  generate-new-keypair behavior). `init` seeds `recipients.txt` born-multi.
  `which` prints `recipients: N`; `verify`/`verify --all` assert each blob's
  age recipient-stanza count equals `recipients.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
  own `key.txt`).

Also update the subcommand list line (init, push, pull, list, rm, rekey, verify, migrate) to include recipients, reencrypt, and bump the secrets.bats/suite test counts and bats test/ description in Project Structure (recipients.bats is the 4th suite).

  • Step 3: Update README.md — add an onboarding/offboarding section: a teammate runs age-keygen, sends their public key; an existing member runs secrets recipients add age1… --name them (store re-encrypts + pushes); the teammate clones the store, drops their own key.txt, and secrets pull works. Offboarding: secrets recipients rm them (+ rotate still-sensitive values, since history stays readable by the old key). Document recipients list and reencrypt.

  • Step 4: Version bump. If the repo carries a version constant/VERSION (recent commits bumped 0.6.1.0), bump it (e.g. 0.6.2.0) and add a CHANGELOG entry per the repo's convention.

  • Step 5: Run the full suite once more

Run: bats test/ Expected: PASS (all four suites).

  • Step 6: Commit
git add secrets CLAUDE.md README.md
git commit -m "docs: multi-recipient age encryption (recipients/reencrypt) + version bump (EGB-283)"

Self-Review

Spec coverage:

  • recipients.txt format + rails → Tasks 1, 8. ✓
  • _load_recipients / RECIPIENT_ARGS at every encrypt site → Task 1. ✓
  • _reencrypt_all shared engine + add/rm/rekey/reencrypt routing → Tasks 3, 4, 5. ✓
  • recipients list/add/rm with guards (last, self/--yes) → Tasks 2, 4, 5. ✓
  • Backward compat (absence = legacy; bootstrap on first add) → Tasks 1, 4. ✓
  • init born-multi → Task 6. ✓
  • rekey dual semantics → Task 3. ✓
  • which recipients line → Task 6. ✓
  • verify stanza-count invariant → Task 7. ✓
  • Security regression fixtures + policy note → Task 8. ✓
  • Docs/help/version → Task 9. ✓
  • Scope cuts (SSH, per-file subsets, key discovery) → enforced by _validate_age_recipient (rejects SSH) and simply not built. ✓

Placeholder scan: every code/test step carries real bash/bats. No TBD/TODO. ✓

Type/name consistency: RECIPIENT_ARGS, RECIPIENTS_FILE, RECIPIENTS_FILE_NAME, _load_recipients, _validate_age_recipient, _validate_recipient_name, _recipients_dump, _recipients_list, _recipients_add, _recipients_rm, _recipients_write_without, _reencrypt_all, cmd_reencrypt, cmd_recipients, _blob_recipient_count, _check_blob_recipient_count — used consistently across tasks. ✓

Known caveat to verify during execution: Task 6 changes init output (now writes recipients.txt) and adds a which line; if any existing test in secrets.bats/migrate.bats asserts on exact init/which output or the absence of store files, update it in Task 6 (called out in that task's steps).