Skip to content

feat(bin): add Bitwarden rollout runbook and no-secret ceremony validator - #13

Open
rega10 wants to merge 10 commits into
mainfrom
fm/production-bitwarden-team-rollout
Open

feat(bin): add Bitwarden rollout runbook and no-secret ceremony validator#13
rega10 wants to merge 10 commits into
mainfrom
fm/production-bitwarden-team-rollout

Conversation

@rega10

@rega10 rega10 commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Intent

Prepare the captain-selected staged adoption of Bitwarden as the canonical managed store for production and team credentials, as a preparation-only deliverable: the smallest practical operator-usable rollout package moving from temporary local-only custody toward organization-owned Bitwarden custody, while preserving current access until each migration batch is explicitly approved and verified. Deliverables chosen: (1) docs/bitwarden-rollout.md, classified operator-current in docs/documentation-audiences.json, as the authoritative rollout runbook with prerequisites, phase gates (phases 0-4), human roles, recovery/drill procedures, and a no-secret drill evidence template; (2) bin/fm-bitwarden-ceremony.sh, a deliberately small helper justified by the repeated per-batch ceremony steps - it validates ceremony-record structure/status ONLY and must never accept, echo, log, persist, or transform secret values (it refuses secret-shaped input with redacted messages, reports corrupt lines by number with content withheld, and never contacts Bitwarden or any credential store); (3) colocated behavior tests tests/fm-bitwarden-ceremony.test.sh exercising malicious paths/filenames, redaction, no-secret logging, idempotent retries, partial ceremony recovery, and refusal before destructive retirement; (4) pointer-only updates (documentation-audiences.json inventory entry, one docs/scripts.md row, one README Documentation link) rather than duplicating contracts across README, AGENTS, skills, and docs - AGENTS.md is deliberately untouched per the repo's size discipline, and the record format/step gates are owned solely by the script header per the one-owner rule. Hard boundaries the captain set, all deliberate: do NOT create Bitwarden accounts or organizations, choose or purchase a subscription, invite users, alter billing, move/copy/read/print/export any secret value, change production access, rotate credentials, modify Automic Vault custody (Automic Vault keeps local secrets - this rollout must not weaken hardened local access), or run a real migration ceremony - those need the captain's direct login/billing participation and later approval. Bitwarden Password Manager (human/team) and Secrets Manager (machine/unattended) are treated as separate custody models; Secrets Manager is deliberately NOT selected or enabled, and the runbook surfaces that as an open captain decision with its service-account/access-token consequences and a safe interim boundary (machine/unattended and CI/CD secrets stay on current paths, no Password Manager workaround copies). The runbook requires at least two independent owner/admin recovery paths, hardware-backed MFA for privileged accounts, least-privilege collections/groups, joiner/mover/leaver procedures, and no single-person recovery dependency; encrypted export and recovery drills are designed (offline custody, encryption, split ownership, restore verification, rotation after exposure, drill cadence, deletion/retention evidence) WITHOUT creating any export or plaintext staging step; credential classes are inventoried by label/owner only, never values, and the classes staying outside the initial migration (local development, machine/unattended, CI/CD) are named with reasons. The phased ceremony has explicit preflight, per-item ownership/collection target, captain approval, dual control where appropriate, post-move access verification, rollback, old-custody retirement only after verification, and an auditable no-secret completion record; the helper enforces that ordering (retirement refused before verification and recorded approval). Genuinely unresolved product/security decisions (subscription, legal/compliance retention, owner identities, machine-access architecture, irreversible retirement) are surfaced to the captain, not chosen. A private task report with inspected boundaries, open decisions, exact tests, and skipped real-world evidence was written to the firstmate home's data directory (gitignored, deliberately not part of this diff). No real Bitwarden account, subscription, organization, invite, export, secret, production credential, or access path was exercised by this work. Repo conventions followed: one sentence per line in tracked Markdown, plain dashes, shellcheck-clean bin script via bin/fm-lint.sh, colocated .test.sh behavior tests asserting executable behavior (not source bytes), and bin/fm-doc-audience-check.sh green.

What Changed

  • Adds bin/fm-bitwarden-ceremony.sh, a validator for per-batch Bitwarden migration ceremony records under $FM_HOME/data/bitwarden/<batch-id>.ceremony. It offers init, add-item, mark, status, and check, enforces the strict preflight -> approval -> moved -> verified -> retired step order on every read and write (refusing retired until verified plus a recorded approved-by approval exists), validates record structure, batch identity, item placement, and calendar dates (rejecting future, backwards, pre-creation, and truncated records), and makes identical replays idempotent while refusing conflicting ones. It never contacts Bitwarden or any credential store, refuses secret-shaped labels and batch ids, and reports corrupt lines by number with content withheld.
  • Adds docs/bitwarden-rollout.md, the operator runbook covering custody boundaries (local secrets stay in Automic Vault; Secrets Manager deliberately not enabled), roles, open captain decisions, a label-only credential class inventory, phase 0-4 gates, the per-batch migration ceremony, encrypted export and recovery drills, and what the runbook never authorizes.
  • Adds tests/fm-bitwarden-ceremony.test.sh covering malicious batch ids and filenames, redaction of secret-shaped input, tampered and truncated record histories, date validation, partial-ceremony recovery, idempotent replay, and retirement refusal; registers the doc as operator-current in docs/documentation-audiences.json and links it from docs/scripts.md and the README.

Risk Assessment

✅ Low: The change is a self-contained, additive, no-secret local text validator plus documentation with no production or credential-store reach; every prior round's fix was independently reproduced as working, the parser refuses all adversarial record shapes I could construct without producing a wrong result, and the added tests are real behavioral regressions rather than source-text assertions.

Testing

Ran the two targeted behavior suites (ceremony validator and documentation-audience inventory) - both green - then drove the ceremony script end-to-end as an operator would from a shell and captured the transcript as the primary evidence: a pilot batch goes from init through per-item owner/collection registration to a completed auditable no-secret record, with retirement refused before verification and captain approval, check naming the resume point on a partial record, moved refused with no registered items, idempotent replay succeeding while a conflicting-approver replay is refused with the record unchanged, and tampered, truncated, future-dated, and misfiled records refused by line number with content withheld. Adversarial checks confirm the safety contract: path-traversal and secret-shaped batch ids are refused with nothing written anywhere under the data root, pasted secret-shaped values are refused with redacted messages and grep-proven absent from every persisted file, and a PATH-shimmed run with curl/wget/bw/op/vault/ssh stubbed to abort produced no forbidden calls, so the tool never contacts Bitwarden or any credential store. Diff-level boundaries hold too (AGENTS.md untouched, pointer-only doc updates, data/ gitignored). No screenshot or rendered-UI artifact: this change ships a CLI helper and a Markdown runbook, and there is no rendered end-user surface to capture, so the CLI transcript is the faithful product-level evidence. Worktree left clean; all evidence is in the dedicated evidence directory.

Evidence: Operator CLI walkthrough transcript (full ceremony, gates, refusals, redaction, no-network proof)

Source: Operator CLI walkthrough transcript (full ceremony, gates, refusals, redaction, no-network proof)


=== 0. The record-format + gate contract the operator reads (--help) ===

$ fm-bitwarden-ceremony --help
fm-bitwarden-ceremony.sh - structure/status validator for Bitwarden migration
ceremony records (docs/bitwarden-rollout.md owns the ceremony itself).

Usage:
  bin/fm-bitwarden-ceremony.sh init <batch-id>
  bin/fm-bitwarden-ceremony.sh add-item <batch-id> <item-label> --owner <label> --collection <label>
  bin/fm-bitwarden-ceremony.sh mark <batch-id> <step> [--approved-by <label>]
  bin/fm-bitwarden-ceremony.sh status <batch-id>
  bin/fm-bitwarden-ceremony.sh check <batch-id>
  bin/fm-bitwarden-ceremony.sh --help

One record per migration batch at $FM_HOME/data/bitwarden/<batch-id>.ceremony
(FM_HOME defaults to the repo root; FM_DATA_OVERRIDE overrides the data root
for tests). The record is the auditable no-secret completion evidence for one
batch of the phased migration ceremony in docs/bitwarden-rollout.md.

HARD SAFETY CONTRACT - this tool never touches secrets:
  - It never reads from, writes to, or talks to Bitwarden, Automic Vault, or
    any credential store. It validates a local text record, nothing else.
  - Every free-text argument is a short reference LABEL (an item name, a
    person, a collection). Values that look like secret material - wrong
    charset, excessive length, long hex runs, or well-known token prefixes -
    are refused, and the refusal message never echoes the offending value,
    so a mistakenly pasted secret is neither persisted nor logged.
  - A structurally invalid record line is reported by line number only,
    never by content, for the same reason.

Record format (v1, line-based, append-only):
  fm-bitwarden-ceremony v1
  batch: <batch-id>
  created: <YYYY-MM-DD>
  item: <label> owner=<label> collection=<label>
  step: <name> date=<YYYY-MM-DD> [approved-by=<label>]
The batch and created headers appear exactly once each, before any item or
step line, and the batch header must name the batch being read - a record
copied or renamed to another batch id is refused rather than reported as that
batch's evidence. Every line must carry exactly the fields shown above, every
label value must be a valid reference label, and every date must be a real
YYYY-MM-DD calendar date that is not in the future, no earlier than the
created header, and no earlier than the previous step's date, so the record
cannot certify a history the ceremony could not have produced. The accepted
dates are exactly the ones this tool can stamp, so it never appends a line
its own readers would then refuse. Every line ends with a newline; a record
whose final line does not is treated as truncated and refused, because
appending to it would fuse two record lines into one. An item may not be
registered after the moved step, and the moved step requires at least one
item already registered - the same rules `add-item` and `mark` apply when
they write.

Steps are batch-level and strictly ordered:
  preflight -> approval -> moved -> verified -> retired
Every command that reads a record requires its recorded steps to be exactly
that order with nothing skipped, repeated, or added after `retired`, so a
hand-edited or tampered history is refused by `check` and `status` instead of
being reported as progress. A refused record is corrected back to its last
valid prefix, or quarantined and replaced by a new batch, per the recovery
procedure in docs/bitwarden-rollout.md; this tool never normalizes one.
Further gates on `mark`:
  - approval requires --approved-by (the captain's recorded identity label);
    no other step accepts it.
  - moved requires at least one registered item, so a batch cannot be
    "moved" with no recorded per-item ownership/collection target.
  - retired is the destructive gate: it is refused unless verified is marked
    AND an approval line with approved-by exists, so old-custody retirement
    can never be recorded before post-move verification and captain approval.
Every command is idempotent: re-running init on an initialized batch,
re-adding an identical item, or re-marking a recorded step is a no-op
success, so an interrupted ceremony can be resumed by replaying commands.
`check` re-validates any partial record and prints the next required step,
which is the recovery entry point after an interruption.
[exit 0]

=== 1. Operator refuses to be tricked: malicious and secret-shaped batch ids ===

$ fm-bitwarden-ceremony init ../../etc/evil
fm-bitwarden-ceremony: refused: batch id must match [a-z0-9][a-z0-9-]* (max 64 chars)
[exit 1]

$ fm-bitwarden-ceremony init a b
fm-bitwarden-ceremony: refused: batch id must match [a-z0-9][a-z0-9-]* (max 64 chars)
[exit 1]

$ fm-bitwarden-ceremony init ghp_AAAABBBBCCCCDDDDEEEEFFFF
fm-bitwarden-ceremony: refused: batch id must match [a-z0-9][a-z0-9-]* (max 64 chars)
[exit 1]

$ fm-bitwarden-ceremony init deadbeefdeadbeefdeadbeefdeadbeef
fm-bitwarden-ceremony: refused: value for batch id contains a long hexadecimal run; secret values must never be passed to this tool
[exit 1]

files created anywhere under the data root by those attempts:
(none above = nothing escaped or was persisted)

=== 2. Phase 2 pilot batch: init ===

$ fm-bitwarden-ceremony init pilot-shared-team
fm-bitwarden-ceremony: batch 'pilot-shared-team' initialized at <tmp-data-root>/data/bitwarden/pilot-shared-team.ceremony
[exit 0]

--- record on disk (pilot-shared-team.ceremony) ---
fm-bitwarden-ceremony v1
batch: pilot-shared-team
created: 2026-08-29
--- end record (70 bytes, ends with newline: \n) ---

=== 3. Register per-item ownership/collection targets (labels only, never values) ===

$ fm-bitwarden-ceremony add-item pilot-shared-team status-page-shared --owner ops-captain --collection team-ops
fm-bitwarden-ceremony: batch 'pilot-shared-team': item 'status-page-shared' registered (owner=ops-captain collection=team-ops)
[exit 0]

$ fm-bitwarden-ceremony add-item pilot-shared-team monitoring-shared --owner ops-captain --collection team-ops
fm-bitwarden-ceremony: batch 'pilot-shared-team': item 'monitoring-shared' registered (owner=ops-captain collection=team-ops)
[exit 0]

=== 3b. A pasted secret value is refused, redacted, and never persisted ===

$ fm-bitwarden-ceremony add-item pilot-shared-team billing-login --owner ops-captain --collection ghp_ZZZZYYYYXXXXWWWWVVVVUUUU
fm-bitwarden-ceremony: refused: value for --collection matches a well-known credential shape; secret values must never be passed to this tool
[exit 1]

$ fm-bitwarden-ceremony add-item pilot-shared-team billing-login --owner ops-captain --collection a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
fm-bitwarden-ceremony: refused: value for --collection contains a long hexadecimal run; secret values must never be passed to this tool
[exit 1]

grep the whole data root for those pasted values:
(no match - the secret-shaped value was neither logged nor persisted)

=== 4. Destructive gate: retirement is refused before verification and approval ===

$ fm-bitwarden-ceremony mark pilot-shared-team preflight
fm-bitwarden-ceremony: batch 'pilot-shared-team': step 'preflight' recorded (2026-08-29)
[exit 0]

$ fm-bitwarden-ceremony mark pilot-shared-team retired
fm-bitwarden-ceremony: batch 'pilot-shared-team': refused to record retirement before post-move verification
[exit 1]

$ fm-bitwarden-ceremony status pilot-shared-team
batch: pilot-shared-team
record: <tmp-data-root>/data/bitwarden/pilot-shared-team.ceremony
items: 2
  [x] preflight
  [ ] approval
  [ ] moved
  [ ] verified
  [ ] retired
next: approval
[exit 0]

(the recovery entry point after an interruption: `check` re-validates the partial record and names the next required step)

$ fm-bitwarden-ceremony check pilot-shared-team
next: approval
[exit 0]

=== 4b. A batch with no registered item cannot be marked moved ===

$ fm-bitwarden-ceremony init empty-batch
fm-bitwarden-ceremony: batch 'empty-batch' initialized at <tmp-data-root>/data/bitwarden/empty-batch.ceremony
[exit 0]

$ fm-bitwarden-ceremony mark empty-batch preflight
fm-bitwarden-ceremony: batch 'empty-batch': step 'preflight' recorded (2026-08-29)
[exit 0]

$ fm-bitwarden-ceremony mark empty-batch approval --approved-by captain-rega
fm-bitwarden-ceremony: batch 'empty-batch': step 'approval' recorded (2026-08-29)
[exit 0]

$ fm-bitwarden-ceremony mark empty-batch moved
fm-bitwarden-ceremony: batch 'empty-batch': refused to mark moved with no registered items; every moved credential needs a recorded owner/collection target
[exit 1]

=== 5. Out-of-order and unapproved moves are refused ===

$ fm-bitwarden-ceremony mark pilot-shared-team moved
fm-bitwarden-ceremony: batch 'pilot-shared-team': next required step is 'approval', not 'moved' (steps in order are: preflight approval moved verified retired)
[exit 1]

$ fm-bitwarden-ceremony mark pilot-shared-team approval
fm-bitwarden-ceremony: refused: approval requires --approved-by with the captain's recorded identity label
[exit 1]

$ fm-bitwarden-ceremony mark pilot-shared-team approval --approved-by captain-rega
fm-bitwarden-ceremony: batch 'pilot-shared-team': step 'approval' recorded (2026-08-29)
[exit 0]

=== 5b. Interrupted ceremony resumed by replay (idempotent), but a conflicting replay is refused ===

$ fm-bitwarden-ceremony mark pilot-shared-team approval --approved-by captain-rega
fm-bitwarden-ceremony: batch 'pilot-shared-team': step 'approval' already recorded; nothing to do
[exit 0]

$ fm-bitwarden-ceremony mark pilot-shared-team approval --approved-by mallory
fm-bitwarden-ceremony: batch 'pilot-shared-team': approval is already recorded for a different approver; resolve the conflict in the record before continuing
[exit 1]

approver still recorded as:
step: approval date=2026-08-29 approved-by=captain-rega

=== 6. Finish the batch: moved -> verified -> retired ===

$ fm-bitwarden-ceremony mark pilot-shared-team moved
fm-bitwarden-ceremony: batch 'pilot-shared-team': step 'moved' recorded (2026-08-29)
[exit 0]

$ fm-bitwarden-ceremony mark pilot-shared-team verified
fm-bitwarden-ceremony: batch 'pilot-shared-team': step 'verified' recorded (2026-08-29)
[exit 0]

$ fm-bitwarden-ceremony mark pilot-shared-team retired
fm-bitwarden-ceremony: batch 'pilot-shared-team': step 'retired' recorded (2026-08-29)
[exit 0]

$ fm-bitwarden-ceremony status pilot-shared-team
batch: pilot-shared-team
record: <tmp-data-root>/data/bitwarden/pilot-shared-team.ceremony
items: 2
  [x] preflight
  [x] approval
  [x] moved
  [x] verified
  [x] retired
next: complete
[exit 0]

=== 7. The auditable, no-secret completion record ===

--- record on disk (pilot-shared-team.ceremony) ---
fm-bitwarden-ceremony v1
batch: pilot-shared-team
created: 2026-08-29
item: status-page-shared owner=ops-captain collection=team-ops
item: monitoring-shared owner=ops-captain collection=team-ops
step: preflight date=2026-08-29
step: approval date=2026-08-29 approved-by=captain-rega
step: moved date=2026-08-29
step: verified date=2026-08-29
step: retired date=2026-08-29
--- end record (372 bytes, ends with newline: \n) ---

=== 8. A tampered record is refused by line number, content withheld ===

$ fm-bitwarden-ceremony check pilot-shared-team
fm-bitwarden-ceremony: record for batch 'pilot-shared-team' is corrupt at line 9 (step date is in the future, so the ceremony cannot have reached it; content withheld in case it holds secret material); correct the record back to its last valid prefix, or quarantine it and start a new batch (see docs/bitwarden-rollout.md); this tool never repairs a record for you
[exit 1]

$ fm-bitwarden-ceremony status pilot-shared-team
fm-bitwarden-ceremony: record for batch 'pilot-shared-team' is corrupt at line 7 (steps out of order, expected 'approval' at this point; content withheld in case it holds secret material); correct the record back to its last valid prefix, or quarantine it and start a new batch (see docs/bitwarden-rollout.md); this tool never repairs a record for you
[exit 1]

(truncating the record: stripping only the final newline byte)
last byte is now: 9

$ fm-bitwarden-ceremony check pilot-shared-team
fm-bitwarden-ceremony: record for batch 'pilot-shared-team' is corrupt at line 10 (final line has no terminating newline, so the record is truncated; content withheld in case it holds secret material); correct the record back to its last valid prefix, or quarantine it and start a new batch (see docs/bitwarden-rollout.md); this tool never repairs a record for you
[exit 1]

=== 9. Misfiled evidence: a record copied under another batch id is refused ===

$ fm-bitwarden-ceremony status prod-break-glass
fm-bitwarden-ceremony: record for batch 'prod-break-glass' is corrupt at line 2 (batch header names a different batch, so this record is not evidence for 'prod-break-glass'; content withheld in case it holds secret material); correct the record back to its last valid prefix, or quarantine it and start a new batch (see docs/bitwarden-rollout.md); this tool never repairs a record for you
[exit 1]

=== 10. Proof the tool never reaches a credential store: no network/vault calls in a full run ===

$ (PATH shimmed so curl/wget/bw/op/nc/ssh/security abort loudly) full ceremony replay
  batch: pilot-shared-team
  record: <tmp-data-root>/data/bitwarden/pilot-shared-team.ceremony
  items: 2
    [x] preflight
    [x] approval
    [x] moved
    [x] verified
    [x] retired
  next: complete
  fm-bitwarden-ceremony: batch 'drill-batch' initialized at <tmp-data-root>/data/bitwarden/drill-batch.ceremony
  fm-bitwarden-ceremony: batch 'drill-batch': item 'drill-item' registered (owner=ops-captain collection=team-ops)
(no FORBIDDEN CALL lines above = no credential store or network binary was invoked)

=== walkthrough complete ===
Evidence: Reproducible walkthrough script used to produce the transcript

Source: Reproducible walkthrough script used to produce the transcript

#!/usr/bin/env bash
# End-to-end operator walkthrough of bin/fm-bitwarden-ceremony.sh, exactly as a
# batch operator would drive it from a shell. No Bitwarden, no network, no secrets.
set -u
ROOT="$1"
WORK=$(mktemp -d /tmp/bw-e2e.XXXXXX)
export FM_DATA_OVERRIDE="$WORK/data"
CEREMONY="$ROOT/bin/fm-bitwarden-ceremony.sh"
REC="$FM_DATA_OVERRIDE/bitwarden/pilot-shared-team.ceremony"

step() { printf '\n\033[1m=== %s ===\033[0m\n' "$*"; }
cmd() {
  printf '\n$ fm-bitwarden-ceremony %s\n' "$*"
  ( "$CEREMONY" "$@" 2>&1 ); printf '[exit %s]\n' "$?"
}
showrec() { printf '\n--- record on disk (%s) ---\n' "${REC##*/}"; cat "$REC"; printf -- '--- end record (%s bytes, ends with newline: %s) ---\n' "$(wc -c < "$REC" | tr -d ' ')" "$(tail -c1 "$REC" | od -An -c | tr -d ' ')"; }

step "0. The record-format + gate contract the operator reads (--help)"
cmd --help

step "1. Operator refuses to be tricked: malicious and secret-shaped batch ids"
cmd init ../../etc/evil
cmd init 'a b'
cmd init ghp_AAAABBBBCCCCDDDDEEEEFFFF
cmd init deadbeefdeadbeefdeadbeefdeadbeef
printf '\nfiles created anywhere under the data root by those attempts:\n'
find "$WORK" -type f 2>/dev/null | sed "s|$WORK|<data>|" ; printf '(none above = nothing escaped or was persisted)\n'

step "2. Phase 2 pilot batch: init"
cmd init pilot-shared-team
showrec

step "3. Register per-item ownership/collection targets (labels only, never values)"
cmd add-item pilot-shared-team status-page-shared --owner ops-captain --collection team-ops
cmd add-item pilot-shared-team monitoring-shared --owner ops-captain --collection team-ops

step "3b. A pasted secret value is refused, redacted, and never persisted"
cmd add-item pilot-shared-team billing-login --owner ops-captain --collection 'ghp_ZZZZYYYYXXXXWWWWVVVVUUUU'
cmd add-item pilot-shared-team billing-login --owner ops-captain --collection 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6'
printf '\ngrep the whole data root for those pasted values:\n'
grep -R -F -e 'ghp_ZZZZYYYYXXXXWWWWVVVVUUUU' -e 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6' "$WORK" 2>/dev/null && printf 'LEAKED!\n' || printf '(no match - the secret-shaped value was neither logged nor persisted)\n'

step "4. Destructive gate: retirement is refused before verification and approval"
cmd mark pilot-shared-team preflight
cmd mark pilot-shared-team retired
cmd status pilot-shared-team
printf '\n(the recovery entry point after an interruption: `check` re-validates the partial record and names the next required step)\n'
cmd check pilot-shared-team

step "4b. A batch with no registered item cannot be marked moved"
cmd init empty-batch
cmd mark empty-batch preflight
cmd mark empty-batch approval --approved-by captain-rega
cmd mark empty-batch moved

step "5. Out-of-order and unapproved moves are refused"
cmd mark pilot-shared-team moved
cmd mark pilot-shared-team approval
cmd mark pilot-shared-team approval --approved-by captain-rega

step "5b. Interrupted ceremony resumed by replay (idempotent), but a conflicting replay is refused"
cmd mark pilot-shared-team approval --approved-by captain-rega
cmd mark pilot-shared-team approval --approved-by mallory
printf '\napprover still recorded as:\n'; grep '^step: approval' "$REC"

step "6. Finish the batch: moved -> verified -> retired"
cmd mark pilot-shared-team moved
cmd mark pilot-shared-team verified
cmd mark pilot-shared-team retired
cmd status pilot-shared-team

step "7. The auditable, no-secret completion record"
showrec

step "8. A tampered record is refused by line number, content withheld"
cp "$REC" "$WORK/backup.ceremony"
sed -i.bak 's/^step: verified.*$/step: verified date=2099-01-01/' "$REC"; rm -f "$REC.bak"
cmd check pilot-shared-team
cp "$WORK/backup.ceremony" "$REC"
sed -i.bak '/^step: approval/d' "$REC"; rm -f "$REC.bak"
cmd status pilot-shared-team
cp "$WORK/backup.ceremony" "$REC"
printf '\n(truncating the record: stripping only the final newline byte)\n'
perl -0pe 's/\n\z//' "$WORK/backup.ceremony" > "$REC"
printf 'last byte is now: %s\n' "$(tail -c1 "$REC" | od -An -c | tr -d ' ')"
cmd check pilot-shared-team
cp "$WORK/backup.ceremony" "$REC"

step "9. Misfiled evidence: a record copied under another batch id is refused"
cp "$WORK/backup.ceremony" "$FM_DATA_OVERRIDE/bitwarden/prod-break-glass.ceremony"
cmd status prod-break-glass

step "10. Proof the tool never reaches a credential store: no network/vault calls in a full run"
printf '\n$ (PATH shimmed so curl/wget/bw/op/nc/ssh/security abort loudly) full ceremony replay\n'
SHIM="$WORK/shim"; mkdir -p "$SHIM"
for b in curl wget bw op nc ssh security aws gcloud vault openssl; do
  printf '#!/bin/sh\necho "FORBIDDEN CALL: %s $*" >&2\nexit 99\n' "$b" > "$SHIM/$b"; chmod +x "$SHIM/$b"
done
PATH="$SHIM:$PATH" "$CEREMONY" status pilot-shared-team 2>&1 | sed 's/^/  /'
PATH="$SHIM:$PATH" "$CEREMONY" init drill-batch 2>&1 | sed 's/^/  /'
PATH="$SHIM:$PATH" "$CEREMONY" add-item drill-batch drill-item --owner ops-captain --collection team-ops 2>&1 | sed 's/^/  /'
printf '(no FORBIDDEN CALL lines above = no credential store or network binary was invoked)\n'

printf '\n\033[1m=== walkthrough complete ===\033[0m\n'
rm -rf "$WORK"
Evidence: Targeted behavior test output

Source: Targeted behavior test output

ok - fm-bitwarden-ceremony behavior
ceremony tests rc=0
ok - documentation inventory classifies every maintained prose surface exactly once
ok - classification, setup routing, and maintained-prose scope fail safely
ok - required documentation owner pointers cannot silently disappear
ok - local links resolve while dates, versions, commands, and incident prose remain semantically reviewed
doc-audience tests rc=0
Evidence: Destructive-gate refusal and partial-ceremony recovery (transcript excerpt)
$ fm-bitwarden-ceremony mark pilot-shared-team preflight
fm-bitwarden-ceremony: batch 'pilot-shared-team': step 'preflight' recorded (2026-08-29)
[exit 0]

$ fm-bitwarden-ceremony mark pilot-shared-team retired
fm-bitwarden-ceremony: batch 'pilot-shared-team': refused to record retirement before post-move verification
[exit 1]

$ fm-bitwarden-ceremony status pilot-shared-team
batch: pilot-shared-team
record: <tmp-data-root>/data/bitwarden/pilot-shared-team.ceremony
items: 2
[x] preflight
[ ] approval
[ ] moved
[ ] verified
[ ] retired
next: approval
[exit 0]

(the recovery entry point after an interruption: `check` re-validates the partial record and names the next required step)

$ fm-bitwarden-ceremony check pilot-shared-team
next: approval
[exit 0]
Evidence: Redacted secret refusal with grep proof of non-persistence (transcript excerpt)
$ fm-bitwarden-ceremony add-item pilot-shared-team billing-login --owner ops-captain --collection ghp_ZZZZYYYYXXXXWWWWVVVVUUUU
fm-bitwarden-ceremony: refused: value for --collection matches a well-known credential shape; secret values must never be passed to this tool
[exit 1]

$ fm-bitwarden-ceremony add-item pilot-shared-team billing-login --owner ops-captain --collection a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
fm-bitwarden-ceremony: refused: value for --collection contains a long hexadecimal run; secret values must never be passed to this tool
[exit 1]

grep the whole data root for those pasted values:
(no match - the secret-shaped value was neither logged nor persisted)
Evidence: Final auditable no-secret completion record on disk (transcript excerpt)
--- record on disk (pilot-shared-team.ceremony) ---
fm-bitwarden-ceremony v1
batch: pilot-shared-team
created: 2026-08-29
item: status-page-shared owner=ops-captain collection=team-ops
item: monitoring-shared owner=ops-captain collection=team-ops
step: preflight date=2026-08-29
step: approval date=2026-08-29 approved-by=captain-rega
step: moved date=2026-08-29
step: verified date=2026-08-29
step: retired date=2026-08-29
--- end record (372 bytes, ends with newline: \n) ---
Evidence: Tampered/truncated/misfiled record refusals: line number only, content withheld (transcript excerpt)
$ fm-bitwarden-ceremony check pilot-shared-team
fm-bitwarden-ceremony: record for batch 'pilot-shared-team' is corrupt at line 9 (step date is in the future, so the ceremony cannot have reached it; content withheld in case it holds secret material); correct the record back to its last valid prefix, or quarantine it and start a new batch (see docs/bitwarden-rollout.md); this tool never repairs a record for you
[exit 1]

(truncating the record: stripping only the final newline byte)
$ fm-bitwarden-ceremony check pilot-shared-team
fm-bitwarden-ceremony: record for batch 'pilot-shared-team' is corrupt at line 10 (final line has no terminating newline, so the record is truncated; content withheld ...)
[exit 1]

$ fm-bitwarden-ceremony status prod-break-glass
fm-bitwarden-ceremony: record for batch 'prod-break-glass' is corrupt at line 2 (batch header names a different batch, so this record is not evidence for 'prod-break-glass'; content withheld ...)
[exit 1]
Evidence: No credential-store or network call under PATH shim (transcript excerpt)
$ (PATH shimmed so curl/wget/bw/op/nc/ssh/security abort loudly) full ceremony replay
batch: pilot-shared-team
record: <tmp-data-root>/data/bitwarden/pilot-shared-team.ceremony
items: 2
[x] preflight
[x] approval
[x] moved
[x] verified
[x] retired
next: complete
fm-bitwarden-ceremony: batch 'drill-batch' initialized at <tmp-data-root>/data/bitwarden/drill-batch.ceremony
fm-bitwarden-ceremony: batch 'drill-batch': item 'drill-item' registered (owner=ops-captain collection=team-ops)
(no FORBIDDEN CALL lines above = no credential store or network binary was invoked)

Pipeline

Updates from git push no-mistakes

... (3 earlier update rounds omitted to keep the PR body within GitHub's 65536-char limit; full history is in the run log.)

⚠️ **Review** - 1 info

🔧 Fix: refuse secret-shaped batch ids and fix grep option parsing
2 warnings still open:

  • ⚠️ bin/fm-bitwarden-ceremony.sh:155 - parse_record records step names into PARSED_STEPS but never checks that they appear in the declared order, so the retirement invariant is enforced only on the mark write path, never on the check/status validation path. Verified by direct invocation: a record containing step: preflight, step: moved, step: retired (no approval, no verified) passes check with exit 0 and prints next: approval; status shows [x] preflight [ ] approval [x] moved [ ] verified [x] retired. Replaying mark &lt;b&gt; approval --approved-by captain and mark &lt;b&gt; verified then succeeds, mark &lt;b&gt; retired reports "already recorded; nothing to do", and the final status prints all five gates [x] and next: complete - while the record on disk plainly shows step: retired written before step: approval and step: verified. So the tool certifies as a compliant completion record an artifact whose own contents prove old-custody retirement preceded captain approval and post-move verification, with no error at any point. This is reachable without corruption: the add-item conflict refusal at line 217 explicitly instructs the operator to "resolve the conflict in the record before continuing", i.e. to hand-edit the file, and docs/bitwarden-rollout.md:93 designates the record as the completion evidence. It contradicts the script header at lines 43-45 and the intent's requirement that "the helper enforces that ordering (retirement refused before verification and recorded approval)". Fix: have parse_record (the shared boundary all four commands already go through) reject a record whose recorded steps are not a prefix-ordered subsequence of $STEPS, reporting the offending line number only, so check and status refuse an out-of-order record instead of blessing it.
  • ⚠️ tests/fm-bitwarden-ceremony.test.sh:134 - The negative tests for the two gates the intent marks required are satisfied by the ordering check, not by the gates they name, so those gates are untested. Verified by running the same commands the test runs: mark batch-a approval at line 134 (labeled 'approval requires --approved-by') fails with "next required step is 'preflight', not 'approval'" because batch-a has no recorded steps at that point - the [ -n &#34;$approved_by&#34; ] gate at bin/fm-bitwarden-ceremony.sh:249 is never reached. Recording preflight first and re-running does produce the real refusal ("refused: approval requires --approved-by..."), so the gate works but nothing asserts it. Same pattern for lines 131, 142 and 145 ('retirement is refused before verification'): all three fail with "next required step is ...", so the retirement gates at bin/fm-bitwarden-ceremony.sh:258-259 are never exercised - I had to hand-write a record with an empty approved-by= value to make line 259 fire. These tests would still pass with lines 249 and 258-259 deleted. Fix: record the prerequisite steps first so each command reaches the gate under test, and assert the gate's specific refusal text (assert_contains &#34;$OUT&#34; &#39;approval requires --approved-by&#39;, &#39;before post-move verification&#39;, &#39;without a recorded captain approval&#39;) instead of exit status alone.

🔧 Fix: validate ceremony step order on every record read
5 issues (2 warnings, 3 infos) still open:

  • ⚠️ bin/fm-bitwarden-ceremony.sh:147 - parse_record accepts batch: and created: lines without ever comparing them to the batch being read, so a copied or renamed record is certified as a completed ceremony for the wrong batch with no error. Verified by direct invocation: complete batch b1 (preflight through retired), then cp b1.ceremony b2.ceremony - an ordinary operator copy, no body edit. init b2 reports "batch 'b2' already initialized; nothing to do" (exit 0); status b2 prints batch: b2, items: 1, all five gates [x], next: complete; check b2 prints next: complete - while the file body plainly reads batch: b1. The tool therefore presents b1's approval, verification and retirement evidence as b2's completion record, and prints the batch label from argv rather than from the record, so the reported label is wrong without any failure. Line 147 also accepts repeated and conflicting header lines: a record containing batch: dup + batch: other + created: 2026-01-01 + created: 2030-01-01 passes check with exit 0. This is the same class the round-2 fix closed for step lines (the fix's own scope was "refuse malformed, duplicate, out-of-order ... and impossible histories before reporting progress"), left open on the one field that names what the evidence is for, and docs/bitwarden-rollout.md:93 designates the record as the completion evidence. Fix at the same shared boundary the step-order check already uses: have parse_record require exactly one batch: line whose value equals $batch and exactly one created: line, reporting the offending line number only, so no reader blesses a record that belongs to a different batch.
  • ⚠️ bin/fm-bitwarden-ceremony.sh:148 - parse_record enforces ordering among step lines but never constrains item lines relative to them, so an item registered after the batch moved is counted as a pre-move ownership target that add-item itself refuses to create. Verified by direct invocation: a record with step: preflight, step: approval date=... approved-by=cap, step: moved, followed by item: added-after-move owner=o collection=c passes status with exit 0, reports items: 1, and mark &lt;batch&gt; verified then succeeds and appends. Lines 249-251 exist precisely to refuse this on the write path ("items cannot be added after the batch is marked moved; start a new batch"), and the moved gate at lines 282-284 exists so "every moved credential needs a recorded owner/collection target", but neither is checked on read, so the auditable completion record can show an ownership/collection target recorded after the credential had already moved and every reader reports it as valid progress. Fix at the same shared boundary: have parse_record reject an item: line that appears after any step: line, reporting the line number only.
  • ℹ️ bin/fm-bitwarden-ceremony.sh:266 - The step-name validation in cmd_mark uses a substring match against " $STEPS ", so a multi-word argument that is a contiguous run of step names is accepted as a step name and can exit 0 claiming a nonexistent step is already recorded. Verified by direct invocation on a batch with preflight and approval recorded: mark b1 &#39;preflight approval&#39; passes the line 266 guard (" preflight approval " is a substring of " preflight approval moved verified retired "), then step_recorded at line 272 matches the same run inside PARSED_STEPS, and the command prints "batch 'b1': step 'preflight approval' already recorded; nothing to do" and exits 0. Nothing is written, so no record is corrupted, but any caller that reads exit 0 as "this step is satisfied" gets a true answer for a step that does not exist. Fix: match exactly, e.g. for s in $STEPS; do [ &#34;$s&#34; = &#34;$step&#34; ] &amp;&amp; found=1; done, instead of substring-testing the concatenated list.
  • ℹ️ bin/fm-bitwarden-ceremony.sh:287 - The [ -n &#34;$PARSED_APPROVED_BY&#34; ] retirement guard is now unreachable. For control to reach line 287, step_recorded verified must be true at line 286; the new prefix-ordering check then guarantees approval is recorded, and lines 178-183 guarantee its approved-by is present and non-empty, so PARSED_APPROVED_BY is always non-empty at that point. The retirement-vs-approval invariant the intent requires is still enforced, just by parse_record rather than here, so this is not a defect. Worth noting only because tests/fm-bitwarden-ceremony.test.sh:252, labelled 'retirement cannot be recorded against an empty approver', now exits non-zero through the corrupt-record path at line 183 rather than through this gate - so nothing reaches line 287 and nothing can. Keep it as defence in depth or drop it, but do not read it as the enforcing gate.
  • ℹ️ bin/fm-bitwarden-ceremony.sh:311 - cmd_status:311-315 and cmd_check:322-326 are byte-identical blocks, and each invokes next_step twice (once for the branch test, once for the value). A single helper - report_next() { local n; if n=$(next_step); then printf &#39;next: %s\n&#39; &#34;$n&#34;; else printf &#39;next: complete\n&#39;; fi; } - replaces both and halves the traversals. Related mechanical simplification: line 282's [ -z &#34;$(printf &#39;%s&#39; &#34;$PARSED_ITEMS&#34;)&#34; ] is equivalent to [ -z &#34;$PARSED_ITEMS&#34; ], since PARSED_ITEMS is a plain string. No behavior change in either case.

🔧 Fix: validate record batch identity and item placement
2 warnings still open:

  • ⚠️ bin/fm-bitwarden-ceremony.sh:211 - parse_record never mirrors the write-path gate that moved requires at least one registered item, so a record that records the move with no item lines at all is certified as valid progress and can be driven to the destructive retirement gate. Verified by direct invocation: a record containing only the v1/batch/created headers plus step: preflight, step: approval date=... approved-by=cap, step: moved date=... and no item: line makes status exit 0 printing items: 0, all three gates [x], next: verified; mark &lt;batch&gt; verified then mark &lt;batch&gt; retired both succeed, and check prints next: complete (exit 0). The same batch built through the tool is refused: mark z2 moved on an itemless batch dies with "refused to mark moved with no registered items; every moved credential needs a recorded owner/collection target" (line 313). This is the identical read/write asymmetry the round-3 fix closed for post-move item lines at line 176, left open on the gate that guarantees the completion record names what was actually moved - so docs/bitwarden-rollout.md:93 ("the completed batch record ... is the completion evidence") and the ceremony's per-item ownership/collection target requirement are satisfied by a record that names no credential at all. Fix at the same shared boundary: at line 211, where the parser observes the moved step and post-move items are already refused, require PARSED_ITEMS to be non-empty and otherwise corrupt with the line number only, so no reader blesses a move with no recorded ownership target.
  • ⚠️ bin/fm-bitwarden-ceremony.sh:179 - parse_record checks only that an item line contains the substrings owner= and collection=, never that the label, owner, or collection actually has a value, so two readers of the same record disagree and the moved gate is satisfied by an item that names nothing. Verified by direct invocation: a record whose body is item: owner=ops collection=prod (two spaces, i.e. an empty label) parses with name="" at line 182, appends a bare newline to PARSED_ITEMS at line 186, and then status prints items: 0 (line 337's grep -c . sees no non-empty line) while mark &lt;batch&gt; moved succeeds, because line 313's [ -z &#34;$PARSED_ITEMS&#34; ] sees the lone newline as non-empty - the count says there are no items and the gate says there is one, for the same file. Separately, item: db owner= collection= is accepted and counted as items: 1, and I drove that batch through moved, verified and retired to completion, so the record certifies retirement of a credential with no recorded owner or collection - the exact target the gate at line 313 exists to require. step: approval date= approved-by=cap likewise passes the line 193 date check with an empty date. add-item and mark refuse all of these on the write path via require_label. Fix in parse_record: after extracting the item fields, require a non-empty label and non-empty owner/collection values (and a non-empty step date), reporting the offending line number only, so PARSED_ITEMS can never hold an entry that the item count and the moved gate read differently.

🔧 Fix: validate record field values on every read
3 issues (1 warning, 2 infos) still open:

  • ⚠️ bin/fm-bitwarden-ceremony.sh:192 - parse_record accepts a record whose final line carries no trailing newline (the || [ -n &#34;$line&#34; ] clause at line 192), and both writers then append directly onto that last line, merging two record lines into one while reporting success. Verified by direct invocation: a record written as fm-bitwarden-ceremony v1\nbatch: nl\ncreated: 2026-01-01\nitem: db owner=ops collection=prod with no final newline passes check (exit 0, next: preflight) and status (items: 1); mark nl preflight then prints "batch 'nl': step 'preflight' recorded (2026-08-29)" and exits 0, but the file becomes item: db owner=ops collection=prodstep: preflight date=2026-08-29 - the item line's collection value is destroyed, the step was never recorded as its own line, and a subsequent check dies with "corrupt at line 4 (item collection contains characters outside...)". add-item corrupts identically (step: preflight date=2026-01-01item: web owner=ops collection=prod). This is a wrong result that does not error: the operator is told the ceremony advanced while the auditable no-secret completion record - the deliverable's whole point per docs/bitwarden-rollout.md:93 - has silently lost its recorded ownership/collection target, and the damage is unrecoverable by the documented remedy because the surviving line is a fusion of two, not a truncated prefix. It is reachable through the recovery path the runbook itself prescribes at docs/bitwarden-rollout.md:95 ("correct it back to its last valid prefix (delete only the trailing lines that are not yet true)"), since an operator hand-editing or rewriting the file with a tool that omits the final newline lands exactly here. Fix at the shared boundary both writers already pass through: in parse_record, detect that the last line was not newline-terminated and corrupt at that line number (a truncated record, reported by number only, consistent with "this tool never repairs a record for you"), so no reader blesses it and neither append site can reach it. Add a test writing a newline-less record and asserting that check/status/mark all refuse it and that the file is left unmodified.
  • ℹ️ bin/fm-bitwarden-ceremony.sh:128 - is_date validates only the YYYY-MM-DD character shape, never the field ranges or the ordering between recorded dates, so a record can certify a history that the write path can never produce and every reader reports it as valid progress. Verified by direct invocation: created: 9999-99-99 with step: preflight date=0000-00-00 passes check with exit 0 printing next: approval; and a record with created: 2030-01-01, step: preflight date=2026-12-31, step: approval date=2020-01-01 approved-by=cap, step: moved date=1999-01-01 passes status with exit 0, all three gates [x] and next: verified - an approval dated six years before the preflight it must follow, and a move dated before the batch was created. cmd_mark always stamps $(today), so tool-written records are necessarily non-decreasing and never predate created; this is the same read/write asymmetry the earlier rounds closed for every other field, left open on the dates. The step-order safety invariant still holds (retirement remains behind verification and approval), which is why this is informational rather than a gate failure - but the record is the audit evidence, and its timestamps are the only evidence of when each gate was passed. Fix in parse_record where the dates are already extracted: reject out-of-range month/day values, and require each step date to be no earlier than the created header and no earlier than the previous step's date, reporting the offending line number only.
  • ℹ️ bin/fm-bitwarden-ceremony.sh:384 - cmd_status re-derives the item count with $(printf &#39;%s&#39; &#34;$PARSED_ITEMS&#34; | grep -c -- . || true) - two forks plus an exit-status mask - to count something parse_record already enumerated line by line. The grep -c . filter and the || true were both defences against a PARSED_ITEMS entry that is empty or absent, and the empty-label case that made them necessary is now refused outright at line 216, so the guard has no remaining input to guard against. Maintaining a plain PARSED_ITEM_COUNT counter alongside PARSED_ITEMS in parse_record (incremented at line 227) and printing it directly removes both subprocesses and the masked status, with no behavior change: the count is already exactly the number of accumulated entries.

🔧 Fix: refuse truncated records and validate recorded dates
2 issues (1 warning, 1 info) still open:

  • ⚠️ bin/fm-bitwarden-ceremony.sh:380 - cmd_mark's idempotency early-return at line 380 runs before every argument gate (384-389) and before any conflict check, so a replayed mark silently discards the arguments the operator actually passed and reports success. Verified by direct invocation against a record already carrying step: approval date=2026-08-29 approved-by=alice: mark b approval --approved-by mallory exits 0 printing "batch 'b': step 'approval' already recorded; nothing to do", and the record still reads approved-by=alice. The operator is told the approval they asserted is recorded while the auditable evidence names a different captain - a wrong result with no error, on the one field the destructive retirement gate at line 395 depends on. Same root cause, two more symptoms: mark b approval --approved-by ghp_AAAA... on an already-approved batch exits 0 with no refusal, because require_label at line 386 is never reached (the intent requires the tool to refuse secret-shaped input with a redacted message; nothing is echoed or persisted here, but nothing is refused either), and mark b preflight --approved-by captain on a recorded preflight exits 0, bypassing the "--approved-by is only valid for the approval step" guard at line 388. cmd_add_item already implements the correct shape for exactly this case: identical re-add is a no-op (353-356), conflicting re-add is refused (357-359), and require_label runs at 347-349 before parse_record. Fix at the same boundary in cmd_mark: run the --approved-by validation block (384-389) before the step_recorded early return, and when the step is already recorded and it is approval with a supplied --approved-by that differs from PARSED_APPROVED_BY, refuse with the add-item-style conflict message instead of reporting a no-op. Identical replay stays a no-op, so the documented idempotency contract (header lines 65-67) is preserved. Add a test replaying approval with a different approver and asserting exit 1 plus an unchanged record.
  • ℹ️ bin/fm-bitwarden-ceremony.sh:145 - is_date validates month 01-12 and day 01-31 independently, so it accepts days that do not exist in the given month. Verified by direct invocation: a record with created: 2026-02-30 and step: preflight date=2026-02-31 passes status with exit 0, printing items: 1, [x] preflight, next: approval. 2026-04-31 and 2025-02-29 pass the same way. The script header at line 40 states "every date must be a real YYYY-MM-DD calendar date" and both refusal messages (lines 231 and 266) say "is not a YYYY-MM-DD calendar date", so the published record-format contract - which per the intent is owned solely by this header - overclaims what is enforced. This is informational rather than a gate failure: the step-ordering and non-decreasing-date invariants still hold, so retirement stays behind verification and approval; only the timestamp evidence can name a day that never occurred. Fix in is_date where the fields are already sliced: bound the day by the month (30 for 04/06/09/11, and 28/29 for February with the usual leap rule), or, if calendar-exact validation is deliberately out of scope, change the header sentence and the two refusal messages to say "in-range" rather than "real calendar" date. Either way, add a case asserting the chosen behavior for 2026-02-30.

🔧 Fix: validate mark arguments before idempotent replay
2 issues (1 warning, 1 info) still open:

  • ⚠️ bin/fm-bitwarden-ceremony.sh:408 - cmd_mark stamps the appended step line with $(today) but never checks that line against the date invariants parse_record enforces on read (lines 272-277), so on any record whose created header or last step date is in the future, mark reports success and permanently bricks the record. Verified by direct invocation, twice. (1) Record created: 2026-01-01 with step: preflight date=2026-12-31 and one item: check exits 0 printing next: approval; mark fut approval --approved-by captain exits 0 printing "step 'approval' recorded (2026-08-29)"; the very next check and status both exit 1 with "corrupt at line 6 (step date is earlier than the previous step, so the recorded history runs backwards)". (2) Record created: 2030-01-01: mark fc preflight exits 0, then check exits 1 with "step date is earlier than the created header, so the step predates the batch". In both cases the operator is told the gate was recorded while the auditable no-secret completion record - the deliverable's whole point per docs/bitwarden-rollout.md:93 - is destroyed, and the documented remedy (docs/bitwarden-rollout.md:97, and RECOVERY_HINT at line 91: "this tool never repairs a record for you") requires deleting the line the tool itself just wrote. Root cause is a read/write asymmetry introduced by the round-6 date fix: parse_record accepts dates the write path can never produce. A record dated entirely in the future is accepted as finished evidence - created: 2099-12-31 with all five steps at 2099-12-31 passes status with exit 0, items: 1, all five gates [x], next: complete - certifying a completed retirement on a day that has not happened. Fix at the shared boundary that already owns the date rules: in parse_record, also refuse a created header or step date later than $(today) ('date is in the future, so the ceremony cannot have reached it'), which makes the accepted set exactly the set the writer can produce and closes the future-created, future-step, and future-completion cases at once; every tool-written record stays valid forever because past dates stay past. (A narrower alternative is to compare $(today) against the record's latest date in cmd_mark before the append at 407-411, but that leaves the future-dated 'complete' record readable as valid evidence.) The added date tests (tests/fm-bitwarden-ceremony.test.sh:479-536) only exercise refusals on read; add a regression that builds a record with a future step date, runs mark, and asserts exit 1 plus a byte-identical record file, mirroring the truncated-record regression at tests/fm-bitwarden-ceremony.test.sh:474-477.
  • ℹ️ bin/fm-bitwarden-ceremony.sh:362 - cmd_add_item detects a conflicting re-add with printf &#39;%s\n&#39; &#34;$PARSED_ITEMS&#34; | grep -Fqx -- &#34;$label&#34; - a fork plus a pipe - to answer exactly the question parse_record already answers in-shell at line 251 with case $&#39;\n&#39;&#34;$PARSED_ITEMS&#34; in *$&#39;\n&#39;&#34;$name&#34;$&#39;\n&#39;*). The two are equivalent here: PARSED_ITEMS is newline-terminated, and $label has already passed require_label at line 352, so it holds no glob metacharacter (the charset at line 128 is A-Za-z0-9 . _ @ / -). Replacing the pipeline with the same case glob removes both subprocesses, drops the -F/-x/-- option-safety dance that the dash-leading-label test at tests/fm-bitwarden-ceremony.test.sh:119-127 exists to guard, and leaves one definition of 'this label is already registered' instead of two. No behavior change.

🔧 Fix: refuse future-dated ceremony records
1 info still open:

  • ℹ️ bin/fm-bitwarden-ceremony.sh:240 - The round-8 fix makes parse_record refuse any created or step date later than today(), which correctly closes the future-dated-evidence hole, but it also means a record the tool itself wrote while the host clock was ahead becomes permanently unreadable once the clock corrects. Verified by direct invocation: a record whose only content is the v1 magic, a batch header, and created: &lt;today+1&gt; (exactly what init writes on a fast clock) makes both init and check exit 1 with "corrupt at line 3 (created header is dated in the future...)" plus the RECOVERY_HINT at line 93 saying "this tool never repairs a record for you". Because the offending line is the created header, there is no "last valid prefix" to trim back to, so the only remedy is the quarantine-and-restart branch already documented at docs/bitwarden-rollout.md:97. This is a fail-closed refusal rather than a wrong result, it needs an incorrect system clock to reach, and the alternative (checking only in cmd_mark) would leave a fully future-dated record readable as next: complete evidence - so the tradeoff looks like the right one. Recording it as informational only: no change requested, and the documented quarantine path already covers the recovery.
✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-bitwarden-ceremony.test.sh - colocated behavior suite (malicious batch ids, redaction, no-secret logging, step ordering, idempotent retries, partial recovery, retirement refusal, calendar/future date validation, truncated and misfiled records)
  • bash tests/fm-documentation-audiences.test.sh - validates the new docs/bitwarden-rollout.md operator-current inventory entry through the real audience consumer
  • Manual end-to-end operator walkthrough of bin/fm-bitwarden-ceremony.sh (init -> add-item -> preflight -> approval -> moved -> verified -> retired) against an isolated FM_DATA_OVERRIDE data root, captured as a CLI transcript
  • fm-bitwarden-ceremony init ../../etc/evil / init &#39;a b&#39; / init ghp_... / init deadbeef... then find over the data root - malicious and secret-shaped batch ids refused, nothing created or escaped
  • fm-bitwarden-ceremony add-item ... --collection ghp_ZZZZ... and --collection a1b2c3d4... then grep -R -F over the whole data root - refusal is redacted and the value is neither logged nor persisted
  • fm-bitwarden-ceremony mark &lt;batch&gt; retired before verification, and mark &lt;empty-batch&gt; moved with no items - both destructive/ordering gates refused
  • fm-bitwarden-ceremony mark &lt;batch&gt; approval --approved-by mallory replayed over a recorded approval - refused, recorded approver unchanged
  • fm-bitwarden-ceremony check/status against a future-dated step, a deleted approval line, a newline-truncated record, and a record copied under another batch id - each refused by line number with content withheld
  • PATH-shimmed run (curl/wget/bw/op/nc/ssh/security/aws/gcloud/vault/openssl replaced with abort stubs) across status, init, and add-item - no credential-store or network binary invoked
  • git diff --name-only &lt;base&gt;..HEAD -- AGENTS.md, grep -nE &#39;curl|wget|http|bw |vault|token=&#39; bin/fm-bitwarden-ceremony.sh, git check-ignore -v data/bitwarden - hard boundaries verified
  • git status --porcelain - worktree clean, no transient artifacts left behind
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Rene Garza Jr. added 10 commits August 29, 2026 02:41
…remony-record validator

Add docs/bitwarden-rollout.md (operator-current): the staged,
gate-approved path from temporary local-only custody to
organization-owned Bitwarden custody for production and team
credentials. It preserves the hardened local-vault boundary, keeps
Bitwarden Secrets Manager an explicit undecided later choice, requires
two independent owner recovery paths and hardware-backed MFA before any
credential moves, inventories credential classes without values, defines
the per-batch migration ceremony with captain approval and
verify-before-retire ordering, and designs encrypted export and recovery
drills with split ownership and no plaintext staging.

Add bin/fm-bitwarden-ceremony.sh: a no-secret structure/status validator
for per-batch ceremony records. It never touches any credential store,
refuses secret-shaped input without echoing it, enforces the
preflight -> approval -> moved -> verified -> retired order so
retirement can never be recorded before verification and approval, is
idempotent for interrupted-ceremony replay, and reports corrupt record
lines by number with content withheld.

Behavior coverage in tests/fm-bitwarden-ceremony.test.sh: malicious
batch ids, secret-shape redaction and non-persistence, step-order and
retirement gates, idempotent retries, partial-ceremony recovery, and
corrupt-record withholding.

Pointers: inventory entry in docs/documentation-audiences.json, a
toolbelt row in docs/scripts.md, and a README documentation link.

Claude-Session: https://claude.ai/code/session_01ABrW9CXMaE5jmeYYwEA6wo
tests/fm-public-followup.test.sh pinned the repro fixture's thread window to
an absolute 2026-08-28T01:12:00Z. Once the wall clock passed that instant the
rechain tests started refusing with "the thread can no longer be reached",
failing the portable serial shard on calendar date alone.

Derive received_at/followup_expires_at/reservation_expires_at and both
obligation --expires-at stamps from the run's own clock, and let the expiry
escalation test read the seeded deadline instead of repeating the constant, so
the window is always live and the expiry cases stay pinned by FMX_NOW_OVERRIDE.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant