Integrate #177 follow-up stack: #179 #180 #181 #182 #183 #186 #188 - #193
Conversation
Consolidated landing of seven interdependent PRs whose shared credential and release/CI surfaces prevented merging in any order (see issue #192's conflict matrix). Merged in dependency order 179, 180, 181, 188, 186, 183, 182; the #186/#188 credential surface was reconciled once (single reserved-name guard; provenance + migrations + stale-disable combined with rotation + backend MCP wiring + threadpool). Squashed to one commit; per-PR history retained on the seven archived branches. Validated on the merged tree: ruff + mypy clean; 172 auth/credential/startup/ migration tests pass; single alembic head v2_155; openapi + frontend types fresh; helm lint/template and docker compose config green on all modes; version and detector self-tests green; commit-message lint clean. Closes #192. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
53c1242 to
b5a37aa
Compare
mwiget
left a comment
There was a problem hiding this comment.
Consolidation review — verified against the merged tree at b5a37aa
I checked out the integration branch and re-verified the consolidation rather than taking the description on trust. The merge itself is sound, and every validation claim in the PR body reproduces locally.
Fidelity — clean
- No file from any of the seven PRs is missing from the integration branch.
- #179 lands byte-identical (zero drift vs its own branch). The others drift only where they genuinely overlap.
auth_service.pycarries the full union of both credential surfaces (token_user_state,enforce_password_change,_rotate_known_default_admin,_persist_generated_passwordfrom #186;holds_known_default_password,disable_stale_service_user,is_service_accountfrom #188).- Exactly one test disappears vs #188 (
test_seeded_admin_can_login) — #186 removes it too, correctly, sinceDEFAULT_ADMIN_PASSWORDis nowNone. Not a loss.
Reproduced locally
209 auth/credential/startup/ws/migration tests pass (you claimed 172) · ruff clean · shellcheck clean over all 43 tracked scripts · commit-lint clean · compute_version_bump (19 assertions), extract-breaking-changes (28), registry-tag-probe self-tests all pass · INV-15 detector parity holds (both functions byte-identical) · sync-version-artifacts.sh --check OK at 3.1.6 across all 5 artifacts · helm lint/template OK, and --set secrets.mcpPassword=changeme correctly fails the render · docker compose config OK on all three compose files.
CI is fully green — all 29 jobs, including Migration Upgrade From Released Version (Postgres), which validates v2_155's backfill on the real upgrade path (my local run only covered SQLite).
Requesting changes on three items
All three are cheap. Two are seams this consolidation created that no prior review round has seen; the third is a merge-mechanics trap that becomes unrecoverable once merged.
1. The merge commit needs a BREAKING CHANGE: footer — this must happen at merge time
On this branch, scripts/extract-breaking-changes.sh v3.1.6 HEAD emits only the container-runner entry. The squashed commit carries no footer, so the generated 4.0.0 release notes will omit:
MCP_SERVICE_PASSWORDis now required —validate_productionSystemExits the backend underENVIRONMENT=staging|productionwhen it is unset or a known default- the shipped
admin/changemedefault is gone, and is actively overwritten on upgrade MCP_USERNAME/MCP_PASSWORD→MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORDin the dist bundle- Helm
secrets.mcpUsername: admin→mcp,mcpPassword: changeme→ generated
CHANGELOG.md documents these, but the generator reads commit footers, not the changelog. A follow-up commit cannot fix this — the tag would already be cut from a footerless range. Please add a canonical column-0 BREAKING CHANGE: <text> footer to the squash-merge message; lint-commit-markers.sh explicitly allows that exact form.
Worth flagging given this stack is the release-notes tooling.
2. backend/core/config.py contradicts itself on the unset-password behaviour
3. Dead branches in ensure_service_user
See the inline comments.
Non-blocking — follow-ups, not merge blockers
Inline below. Two of these (disable_stale_service_user's misleading warning, the WS event-loop blocking) are inherited from already-approved branches, not introduced here — flagging for the backlog, not for this PR.
One retraction: I initially saw 2 test_kube_context_threading failures in a local full-suite run. CI's Unit Tests · Backend is green and this PR touches no kube files — that was a local artifact, not a finding.
| # #186 BLOCKER 1: MCP_SERVICE_PASSWORD is the same class of shipped default as | ||
| # DEFAULT_ADMIN_PASSWORD above (the seeded 'mcp' account is role=admin and | ||
| # exempt from the must-change gate), so it must NEVER carry a published value. | ||
| # Defaults to None; when unset ensure_service_user generates a random secret |
There was a problem hiding this comment.
Blocking — the two comment blocks on this setting contradict each other.
This block (from #186) says that when MCP_SERVICE_PASSWORD is unset, ensure_service_user "generates a random secret and surfaces it once". The #188 block ~17 lines below, on the same setting, says the opposite: "when unset the backend simply doesn't seed the account (MCP is unavailable until it's configured)".
#188's is the correct one. startup_steps.py gates the ensure_service_user call on _mcp_pw_usable, so the generation path is never taken from startup.
A reader has no way to tell which describes reality. Please drop or rewrite the #186 block.
Minor, same place: this block is about MCP_SERVICE_PASSWORD but sits above MCP_SERVICE_USERNAME, so it reads as documenting the wrong field.
| f"'mcp' (a service account must not co-opt the human admin identity)" | ||
| ) | ||
|
|
||
| published_default = bool(password) and password in _KNOWN_DEFAULT_SERVICE_PASSWORDS |
There was a problem hiding this comment.
Blocking — the usable_password is None branches are unreachable in production.
seed_auth_step only calls ensure_service_user when _mcp_pw_usable is true, so password is never None and never a published default at runtime. That makes two documented paths dead:
- fresh row → generate + persist a secret
- existing row still holding a published default → rotate it out
Only tests reach them (test_auth_service.py:471,487,496,498,660,683), so coverage looks healthy while production never executes the code. The docstring above promises behaviour that cannot occur.
This has a user-visible consequence, not just a tidiness one: scripts/mcp_live_smoke.py now tells operators to read /app/keys/initial_mcp_password — a file _persist_generated_password(..., filename=f"initial_{username}_password") can never create.
Either delete the branches (and fix the docstring + the smoke-test hint), or route the unset case through them instead of short-circuiting in startup_steps. I'd lean toward deleting: #188's disable-stale is the deliberate choice for the unset case, per the integration note.
| "Set correct MCP_USERNAME/MCP_PASSWORD for the MCP container/service " | ||
| "(seeded backend default is admin/changeme unless rotated)." | ||
| "Set correct MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD for the MCP container/service " | ||
| "(MCP authenticates as the mcp service account, not admin; set MCP_SERVICE_PASSWORD or read /app/keys/initial_mcp_password) (#186)." |
There was a problem hiding this comment.
Follows from the dead-branch comment on auth_service.py: /app/keys/initial_mcp_password is never written on any reachable path, so this hint sends operators to a file that will not exist. Same text repeats at line 232.
| # authenticating with one of these holds a publicly-known admin credential. | ||
| # ``changeme`` is here too because the old shipped compose pointed the MCP client | ||
| # at admin/changeme. Refused as a seed value and rotated out of any existing row. | ||
| _KNOWN_DEFAULT_SERVICE_PASSWORDS = ("mcp-service-changeme", "changeme") |
There was a problem hiding this comment.
Non-blocking. This duplicates MCP_KNOWN_DEFAULT_PASSWORDS in core/config.py — identical values, two definitions. They feed different guards: the config one drives validate_production, the startup usability gate and holds_known_default_password (the re-enable refusal); this one drives the adopt/rotate decisions here. If they ever drift, the re-enable guard and the rotation guard disagree about what counts as a published default.
Worth collapsing to one import. (Helm's $mcpDefaults is an unavoidable third copy and is already commented as needing lockstep.)
| _log_generated_service_password(username, pw_path, "Rotated") | ||
|
|
||
|
|
||
| def disable_stale_service_user(db: Session) -> None: |
There was a problem hiding this comment.
Non-blocking, and inherited from #188 rather than introduced here — flagging for the backlog.
Two consequences of calling this unconditionally before the reconcile:
- The warning below hardcodes "no usable MCP_SERVICE_PASSWORD is set", but it now fires on every boot including the healthy path where one is set and the reconcile re-activates the row moments later. Operators will see a credential warning on every restart of a correctly-configured stack.
- The disable and the re-enable are two separately-committed transactions. On a rolling restart or with multiple
apireplicas, there is a window where themcpaccount is inactive — in-flight MCP calls 401, and the new auth-probe healthcheck can flip the container unhealthy.
Both would be addressed by skipping rows about to be reconciled, or doing disable+reconcile in one transaction.
| # Test/ephemeral environments (e2e) seed a KNOWN admin and skip the | ||
| # must-change gate so the suite can reach protected routes. Defaults True; | ||
| # never set false on a real deployment. | ||
| DEFAULT_ADMIN_MUST_CHANGE: bool = True |
There was a problem hiding this comment.
Non-blocking, worth its own issue. DEFAULT_ADMIN_MUST_CHANGE is a new escape hatch out of #184's must-change gate, but validate_production() says nothing about it — while it does fail-fast on MCP_SERVICE_PASSWORD in the same method.
So ENVIRONMENT=production + DEFAULT_ADMIN_MUST_CHANGE=false + a chosen DEFAULT_ADMIN_PASSWORD seeds a permanently un-gated admin, and nothing objects. The comment here and in values.yaml both say "never set false on a real deployment" — the #182 lesson was that documentation is not enforcement. Adding it to the validate_production issues list would close it in one line.
| # resolved (deleted/disabled account, DB error) OR still owes a password | ||
| # change, so a seed-credential admin never reaches pod exec / DPU console. | ||
| from services.auth_service import token_user_state | ||
| ws_user = token_user_state(token) |
There was a problem hiding this comment.
Non-blocking, inherited. token_user_state() opens a sync DB session and is awaited directly on the event-loop thread inside async def _validate_ws_token — the exact pattern auth_middleware.py wraps in run_in_threadpool after it was raised as a Major in #186 r5.
Handshake-only, so far lower traffic than the middleware path, but the two now handle the same call inconsistently. Same at dpus_websocket.py:65 and in benchmarks._agent_ws_authorized.
| > 2. **`MCP_SERVICE_PASSWORD` becomes required in 4.0.0 (via bonnyr-f5 #188):** | ||
| > starting with 4.0.0 the backend refuses to boot in staging/production if it | ||
| > is unset or still a shipped default (`changeme` / `mcp-service-changeme`). | ||
| > That boot-time check ships in #188 — it is *not* in the 3.1.x line and is |
There was a problem hiding this comment.
Non-blocking. This heads-up still reads as if #186/#188 are pending — "That boot-time check ships in #188 — it is not in the 3.1.x line", and further down "sequenced to merge with or after #186 + #188" plus "#186 will conflict in user-pack/install-guide.html — resolve by taking #186's credential model".
In a tree where all three have landed together, that is stale merge-ordering guidance shipping in a user-facing changelog. The upgrade instructions themselves are correct and worth keeping — it's the forward-looking framing that needs a pass.
| if grep -q -- '--self-test' scripts/extract-breaking-changes.sh; then | ||
| bash scripts/extract-breaking-changes.sh --self-test | ||
| else | ||
| echo "::warning::extract-breaking-changes.sh has no --self-test yet (it lands with #179); the parity gate above is still enforced this run" |
There was a problem hiding this comment.
Nit. This fallback branch (and the grep -q -- '--self-test' guard above it) exists for the pre-#179 tree. #179 is now in this same commit, so the warning path is dead and the condition always takes the true branch.
Same shape at Makefile:533, and three release.yml comment blocks still say "the fail-closed fix for that script lands with #179, which owns it — this PR does not touch scripts/extract-breaking-changes.sh". Worth a cleanup sweep now that the stack is one commit.
Self-review — max-effort code review of the consolidated tree (@bonnyr-f5 @mwiget)Ahead of your reviews, we ran an independent max-effort correctness pass focused on the auth/credential core (where the consolidation risk concentrates: Fixing nowCR-1 · HIGH · concurrent first-boot admin lockout — CR-5 · security · 0600 only enforced on create — CR-2 · consistency · WS validators block the event loop (also raised by @mwiget) Triaged out (with reasons — not fixing in #193)CR-4 · CR-3 · CR-6 · per-request Combined with @mwiget's three blockers (release-notes footer, the |
|
Thanks — CR-1 and CR-5 are both real and both things my pass missed. I verified them against the tree: on CR-1 I'd read the Two notes before you cut the follow-up commit. CR-1 — make sure the guard covers
|
…he credential seed Addresses mwiget's three CHANGES_REQUESTED blockers on the #177 integration PR (#193), plus follow-up findings from a max-effort review of the same credential surface. The merge chose #188's "unset MCP_SERVICE_PASSWORD -> disable" over #186's "unset -> generate", so the generate/rotate-on-unset code was left unreachable but still documented, and the release-notes footer was missing. BLOCKING 2 (core/config.py): the MCP_SERVICE_PASSWORD comment claimed ensure_service_user "generates a random secret and surfaces it once" when unset, contradicting the merged behaviour. Rewrote it to state the truth: when unset (or a known default) seed_auth_step calls disable_stale_service_user, leaving the mcp account disabled/unavailable until an operator configures a real password; a published default is refused and rotated out; the backend receives MCP_SERVICE_PASSWORD on every deploy mode and reconciles to it when set. Also removed the duplicate #186 block that sat above the wrong field. BLOCKING 3 (services/auth_service.py): removed the unreachable usable_password is None branches from ensure_service_user (the generate-on-create and rotate-on-unset paths). seed_auth_step only calls it under the _mcp_pw_usable gate, so password is never None/default in production. ensure_service_user now requires a usable password and only creates/reconciles with it (failing closed and loudly if handed an unusable one); the unset case is owned entirely by disable_stale_service_user. Dropped the now-dead _log_generated_service_password helper and the service-account token_urlsafe/_persist_generated_password calls (_persist_generated_password is still used by the admin seed). Kept the reserved-name guard, the provenance check, the adopt-a-published-default remediation, and disable_stale_service_user fully intact. Updated the affected unit tests (published-default/None now refused; added a reachable adopt-and-reconcile test; stale-row setup builds the legacy row directly) and fixed scripts/mcp_live_smoke.py, which pointed operators at /app/keys/initial_mcp_password, a file no reachable path writes. CR-1 (services/auth_service.py, seed_admin_user): fixed a concurrent-first-boot admin lockout. With DEFAULT_ADMIN_PASSWORD unset and 2+ api replicas, both generated different passwords and the loser overwrote the keys file while its INSERT rolled back, so the file and the committed row disagreed. The fresh seed now creates+flushes first (the loser's INSERT raises IntegrityError -> rollback, no file write) and persists the keys file only after winning but before commit, so the file can only ever hold the committed row's password. Added a losing-replica test. CR-5 (services/auth_service.py, _persist_generated_password): os.open's 0600 mode only applies on create, so a pre-existing 0644 file was truncated in place and kept 0644, writing the secret world-readable. Added os.fchmod(fd, 0o600) and a test that a pre-existing 0644 file is tightened to 0600. CR-2 (routes/k8s_websocket.py, dpus_websocket.py, benchmarks.py): the WS auth validators called the blocking sync token_user_state directly on the event loop. Moved it off the loop via run_in_threadpool, matching core/auth_middleware.py. Validation: make lint-backend clean; mypy core/ schemas/ unchanged; auth (57) + ws/benchmark/startup (79) suites pass; alembic heads single v2_155; helm lint/template OK and --set secrets.mcpPassword=changeme fails the render; docker compose config OK on all modes; extract-breaking-changes and compute_version_bump self-tests pass; lint-commit-markers clean. BREAKING CHANGE: MCP_SERVICE_PASSWORD is now required — the backend SystemExits under ENVIRONMENT=staging|production when it is unset or a known default; the shipped admin/changeme default is removed and rotated out on upgrade; the dist bundle renames MCP_USERNAME/MCP_PASSWORD to MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD; and the Helm chart ships mcpUsername=mcp with a generated mcpPassword instead of admin/changeme. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
@mwiget — all three blockers addressed in Your blockers:
Self-review finds also fixed: CR-1 (fresh-boot admin-lockout race — commit-then-persist + IntegrityError guard, regression test added), CR-5 ( 344 tests pass; ruff/mypy clean; helm + docker-compose green; self-tests green. Re-requesting review. |
mwiget
left a comment
There was a problem hiding this comment.
Re-review of d4d0011 — all three blockers resolved, approving
Verified against the tree rather than the commit message. CI is green on the new head: 29/29 jobs, zero non-success.
Blockers — closed
1 · BREAKING CHANGE: footer — closed, and verified at the point that actually matters.
extract-breaking-changes.sh v3.1.6 HEAD now emits the MCP entry alongside the container-runner one. More importantly, I simulated the squash-merge composition rather than just checking the branch, since that was the part a normal re-review can't reach:
- this repo's
squash_merge_commit_messageisCOMMIT_MESSAGES, so the squash body concatenates both commit bodies with only the subjects bullet-prefixed - I rebuilt that exact message shape and re-ran the detector against it — the footer stays at column 0, survives the composition, and is extracted correctly
So the 4.0.0 notes will carry it. One thing to protect: if anyone ever flips that repo setting to PR_BODY or BLANK, this silently regresses. Worth knowing.
2 · config.py contradiction — closed. One coherent block on the correct field, and it now states the merged reality: unset (or a published default) → seed_auth_step skips ensure_service_user and disable_stale_service_user leaves the account disabled; no generation, nothing surfaced.
3 · Dead branches — closed, and better than I asked for. The unreachable generate/rotate-on-unset paths are gone, _log_generated_service_password with them, and you added a fail-closed ValueError if the function is ever handed an unusable password — so the contract is now enforced rather than merely documented. mcp_live_smoke.py no longer points operators at a file nothing writes.
Your own findings — verified
CR-1 — I checked the reordering, not just the description. create_user + flush now runs first inside try, the keys file is written only after winning the race and before commit, and the handler catches both IntegrityError and ConflictError. That second one is what I flagged: the loser hits create_user's explicit pre-check (ConflictError) on the common path, and IntegrityError only in the narrower both-clear-the-pre-check window. Both are covered, so the file can only ever hold the committed row's password. The GeneratedCredentialPersistError → db.rollback() path also correctly keeps the fail-closed-with-nothing-committed property from #186.
CR-5 — os.fchmod(fd, 0o600) lands after os.open but before the write, and O_TRUNC has already emptied the file by then, so there's no window where a populated secret sits at 0644. Correct fix, correct ordering.
CR-2 — all three call sites moved off the event loop, including benchmarks.agent_websocket, which I'd named but not anchored. Wrapping the whole sync _agent_ws_authorized helper rather than just token_user_state is the right call there.
Local: ruff clean; 228 auth/credential/startup/ws/migration tests pass (up from 209); commit-lint clean over the 2-commit range; version derivation still resolves major → 4.0.0.
Non-blocking — for the follow-up, not this PR
CR-3's note is still scoped to the wrong thing. The new docstring says the momentary inactive window would matter "if more service accounts are ever added". Both issues I raised happen today, with exactly one account:
disable_stale_service_usercommits the deactivate in oneget_db_context()block andensure_service_userre-activates in a separate one — so the singlemcprow genuinely is inactive between those two commits on every boot- the warning still reads "no usable MCP_SERVICE_PASSWORD is set" and fires on every restart of a correctly-configured stack
Genuinely fine to defer — I'm not asking you to change behaviour here. But the note as written would lead the next reader to conclude there's nothing to fix until a second service account appears, and that's not right. A sentence correcting the scope costs nothing.
CR-1's test covers only IntegrityError. The code handles both, but test_losing_replica_does_not_clobber_keys_file monkeypatches create_user to raise IntegrityError only — leaving the more common ConflictError path unexercised. A parametrize over the two would pin the fix as written.
Carried over, unchanged and still fine to defer: the duplicated default-password tuples (config.py:21 / auth_service.py:225), DEFAULT_ADMIN_MUST_CHANGE still absent from validate_production, the stale forward-looking CHANGELOG framing, and the dead "#179 hasn't landed yet" guards in ci.yml / Makefile / release.yml comments.
Approving. The consolidation was already sound; what this round added is a concurrency lockout and a file-mode leak that neither the per-PR rounds nor my first pass caught — which is the case for #192's approach made in full.
One request at merge time: squash-merge (the footer depends on it) and don't hand-edit the composed message.
Review: PR #193 — integration of the #177 follow-up stackVerdict: BLOCK — 6 blockers, 8 majors. Audited at Method note, because it shapes everything below. This branch is a squashed re-application, not a merge: two flat commits on Credit where it is due: this is a careful integration. Verified author claims: single alembic head BlockersB1 ·
|
Follow-up to the #177 integration on pr177-integration, addressing the CREDENTIAL/BACKEND/HELM/DIST/DOCS half of bonnyr-f5's BLOCK on PR #193. B1 (SECURITY): ensure_service_user no longer adopts any human account whose password is a known default. The adoption exception is now scoped to v2_155's exact backfill fingerprint (username=='mcp' AND email=='mcp@bnk-forge.local'), matching the migration's own conservative rule, and must_change_password is no longer cleared on an adopted row. Adds tests proving a human operator/changeme row (and a wrong-email mcp row) is REFUSED, not taken over. B2/B2b: all five compose files (root + dist docker-compose{,.local}.yml and the IBM embedded compose) honor legacy MCP_USERNAME/MCP_PASSWORD as aliases for MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD on both backend and mcp env, so an existing customer .env keeps working after upgrade. Docs (dist/README.md, dist/.env.example, user-pack/install-guide.html) document MCP_SERVICE_PASSWORD as canonical with MCP_PASSWORD honored as a legacy alias. B3: ENVIRONMENT is plumbed to the backend in every compose file, so an operator who sets ENVIRONMENT=staging|production actually reaches config.py's MCP fail-fast. Helm already routes ENVIRONMENT=production onto api/worker/beat. M1: the unset-MCP behavior stays "disabled" (#188 over #186); added an explicit deliberate-consolidation comment at the decision point. M2: disable_stale_service_user skips the about-to-be-reconciled row and the "no usable MCP_SERVICE_PASSWORD" warning is conditional on a configured password, so a correctly-configured install no longer logs a false warning or commits an inactive MCP window on every boot. M7: the Helm mcp deployment and the IBM installer's mcp service now run the exec auth-probe healthcheck (python -m bnk_forge_mcp.healthcheck) instead of a bare tcpSocket probe. M8: the chart fails the render for a reserved mcpUsername (admin), mirroring the mcpPassword guard, and NOTES.txt/values.yaml call it out. Minors: deterministic checksum/secret via a shared helper (stable across renders, identical across api/worker/beat/mcp); vestigial _persist_generated_password filename docstring; false "backend generates its own secret" rationale corrected in compose/helpers/ibm; mcp_live_smoke.py #186/#188 attribution; e2e/config.py changeme default note; .env.example :58/:73/:94 fixes; unified BNK_FORGE_VERSION to latest across dist. Validation: ruff clean; typecheck-backend (core/ schemas/) Success 38 files; 199 auth/credential/startup/ws/migration tests pass incl. new B1 tests; helm lint + template stable checksums, --set secrets.mcpUsername=admin and secrets.mcpPassword=changeme both FAIL; docker compose config on all five modes shows the backend receiving MCP_SERVICE_PASSWORD (via either alias) and ENVIRONMENT. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…inors Blockers: - B4 registry-tag-probe.sh: replace GNU-only BRE alternation (\|) with portable `sed -nE (access_token|token)` so the token parse works on BSD/ macOS sed; on BSD the empty token classified every image `unknown` and routed the operator into FORCE_LATEST=1, overwriting immutable :VERSION manifests (INV-24). Reproduced under `sed --posix`, fix proven to parse T2. - B5 registry-tag-probe.test.sh had no caller and was red on BSD. Both the Makefile script-selftests target and ci.yml's script-selftests job now enumerate and run every scripts/tests/*.test.sh, failing on an empty enumeration or any non-zero rc. - B6 lint-commit-markers.sh: replace the spoofable committer-identity exemption (GitHub <noreply@github.com> + single parent) with an unspoofable "already reachable from origin/main|origin/staging" check; lint the PR title (PR_TITLE via env) on pull_request events; split the rules so machine/already-merged is exempt for the marker rule but the spurious-major rule always applies. Majors: - M3 release.yml overwrite guard: derive the vacuity floor from an independent source (docker-bake.hcl default group, sourced from the workflow-ref tooling) and assert the probe's exit status before trusting its output, so an unavailable probe fails closed instead of "safe". - M4 (INV-31): generate release notes and run the registry existence-probe BEFORE the irreversible push in release-final/release-manual (new shared scripts/registry-overwrite-guard.sh); release-publish keeps its own in-critical-section re-check. - M5 make script-selftests now runs the INV-15 detector-parity diff (extracted to scripts/tests/detector-parity.test.sh) so local == CI. - M6 extractor self-test runs unconditionally with anti-vacuity assertions (ok lines + END marker), no longer gated on grepping its own --self-test. Minors: stale cross-PR comments in release.yml and extract-breaking-changes.sh; removed the duplicate Makefile version-check target; `git add dist/VERSION` no longer swallows failures; first-ever-release notes range fixed; CHANGELOG insertion asserts a non-no-op before committing; refreshed .trivyignore CVE-2026-7598 review deadline; removed e2e-tests.yml dead `|| true`; documented the new Docker dependency in the pre-push hook. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Response to review — all 6 blockers + 8 majors + every nit addressed (@bonnyr-f5)Thank you for the reconstruction-based audit — recovering the ~1,771-line resolution surface my squash hid was exactly the right method, and B1/B2b/B3 were real. Fixes are in Blockers
Majors
Nits — all dispositionedFixed: checksum determinism (deterministic input-digest helper), the stale cross-PR comments in Two honest exceptions:
Two more design notes to surface, not hide: the checksum fix hashes inputs deterministically, so a genuine rotation rolls pods on the next sync (a Re-requesting review. |
Round 2 review —
|
| commit body line | detectors | rule 2 |
|---|---|---|
- BREAKING CHANGE: the --legacy flag was removed |
major + note | rc=1 rejected |
**BREAKING CHANGE:** boom. |
major + note | rc=1 rejected |
BREAKING CHANGE: indented, not a footer |
inert (:155 expect 0) |
rc=1 rejected |
Direction 2 — it rejects a shape the detectors ignore, and its message is false about it. On
4a52ed4 (current origin/staging tip, an already-merged GitHub squash, unamendable) rule 2 errors
on the body line - BREAKING CHANGE footer in the BODY of a fix-subject commit → major (non-,
claiming it "spuriously triggers a major release". Lead-executed on that exact commit in a detached
worktree:
scripts/compute_version_bump.sh --since-tag 4a52ed4~1 -> BUMP_TYPE=patch
scripts/extract-breaking-changes.sh 4a52ed4~1 4a52ed4 -> rc=0, empty
Neither a major bump nor a release note. #179's anchoring already removed the harm this rule claims
to prevent. A per-commit sweep of origin/main finds 1 of 68 commits tripping it, so the base
rate over a release-merge range is real.
The wedge is reachable: inputs.release_notes is linted nowhere (release.yml:61-66 →
interpolated into the release commit at :918, the tag message at :926). An operator typing a
bullet-form breaking note mints an unamendable commit on main that rule 2 refuses forever — any
range containing it (e.g. a hotfix cut from main, PR'd to staging) goes red. That is INV-28
reopened by the rule that was supposed to be the safe half.
Class fix: define rule 2 as the complement of what the detectors accept, from one shared
predicate rather than a second hand-written regex; add those three shapes as negative fixtures; give
rule 2 the same already-merged exemption (the unamendability argument is identical); and lint
inputs.release_notes through this script before it becomes a commit message.
M-2 · The overwrite guard's vacuity floor counts every bake group, so an ordinary docker-bake.hcl edit wedges the release
scripts/registry-overwrite-guard.sh:34-42 (duplicated at release.yml:1189-1193). The comment says
"count docker-bake.hcl's default group"; the code is unscoped —
sed -n 's/.*targets = \[\(.*\)\].*/\1/p' over the whole file. It works today only because
docker-bake.hcl:49-50 has exactly one targets = [ line.
current docker-bake.hcl -> IMAGES_N=7 guard rc=0
+ group "backend" { targets = ["api","worker","beat"] } -> IMAGES_N=10 guard rc=1
"classified 7 of 10 images; treating as inconclusive"
Adding any second group — an ordinary edit — makes release-final's pre-push gate refuse forever
with a message blaming the registry ("auth / network / rate-limit … Re-run once the registry is
reachable"), pointing away from the file just edited. The only escape offered is FORCE=true, which
skips the immutable-tag protection the guard exists for. This is the M3 fix opening a hole beside the
one it closed. Class fix: derive the floor from the tool —
docker buildx bake --print default | jq '.group.default.targets | length' — and separate
"bake-file parse mismatch" from "registry unreachable" in the remediation text.
M-3 · The new pre-push test uses mapfile, so git push fails on stock macOS
scripts/tests/registry-tag-probe.test.sh:93 — mapfile -t LIST < <(bash "$PROBE" --images).
mapfile is bash 4+. macOS ships bash 3.2.57. Lead-executed:
$ /bin/bash --version → GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25)
$ /bin/bash scripts/tests/registry-tag-probe.test.sh
probe.test.sh: line 93: mapfile: command not found rc=127
This PR is what wires that file into make script-selftests → ci-gates → pre-push →
.githooks/pre-push, and Makefile:13 is SHELL := /bin/bash. On a stock-macOS checkout the gate
fails and the push is blocked; a machine with homebrew bash first in PATH masks it entirely (mine
resolves to 5.3.9, which is why your local run was green), and CI is Linux so CI is green too. The
Makefile branches on Darwin, so macOS is a supported platform. Compounding, on bash 3.2
compute_version_bump.sh:260 (_b=${_b//\\n/$'\n'}) hits the O(n²) substitution cliff: the ~80 KB
Test-7 fixture took 134 s under 3.2 vs 0 s under 5.3, and the full self-test did not finish in
6 minutes. Class fix: the pre-push gate must run under the oldest interpreter the repo supports —
replace mapfile with while IFS= read -r, assert the bash version at the top of ci-gates, and
build the Test-7 tail with printf/awk.
M-4 · The mcp liveness probe now restarts pods for a dependency outage, on Kubernetes only
helm/bnk-forge/templates/mcp.yaml:79-84 changes mcp liveness from tcpSocket to
exec: python -m bnk_forge_mcp.healthcheck. healthcheck.py:57-62 returns 1 when the backend is
merely unreachable, and its comment ("Docker start_period handles this") is true for compose and
false for Kubernetes, which has no start_period for liveness. A backend rollout, a Postgres blip or
a credential drift lasting >90 s (3 × periodSeconds: 30) makes kubelet kill and restart every mcp
pod repeatedly; restarting mcp fixes neither cause, so they settle into CrashLoopBackOff. Compose
keeps the same probe as a healthcheck (marks unhealthy, never restarts) — so a change made to
unify the paths gave them opposite failure semantics. Secondary: readiness at periodSeconds: 10
performs a real /api/auth/login (bcrypt + last_login_at UPDATE + commit) every 10 s per replica,
forever, with a User logged in: mcp INFO line each time. Class fix: auth probes belong on
readiness only; liveness must test the process, not its dependency.
M-5 · checksum/secret lags the rotation it exists for, and offline-render GitOps regenerates everything
_helpers.tpl:197 hashes toYaml .Values.secrets plus the pre-render lookup of the live
Secret's .data. Determinism is fixed — two consecutive renders gave identical digests on all four
deployments, which was the point. But secrets.yaml:59-65's changeme → randAlphaNum 24 rotation
happens inside the render whose checksum was computed from the pre-rotation data, so on the upgrade
that rotates, the digest is unchanged and the pods do not roll — secretKeyRef env is injected at
pod start, so api/worker/beat/mcp keep the old credential. Masked in this release only because the
pod template changes anyway (tag 3.0.1→3.1.6, new env). Converse: on a fresh install $existing is
nil, so the first helm upgrade — even a no-op — rolls all four. Separately and PLAUSIBLE (no
cluster to prove it): under helm template-based GitOps lookup is inert, so every sync regenerates
every key, and this PR adds admin-password and mcp-password to that set while a rotating
encryption-key makes previously-encrypted data undecryptable. Class fix: hash the values
actually being written via a shared helper both files consume; stop generating secrets in-chart.
M-6 · The chart pins an image that predates the behaviour its NOTES promise
values.yaml:19 tag: "3.1.6" / appVersion: 3.1.6, kept in lockstep by
sync-version-artifacts.sh --check. NOTES.txt:32-38 tells the operator the secret's value is what
the backend "rotates it to if you upgraded from a build that shipped a default password" — but
git show v3.1.6:backend/services/auth_service.py has no _rotate_known_default_admin at all
(seed_admin_user returns immediately when users exist), and v3.1.6:core/config.py:101-103 still
carries DEFAULT_ADMIN_PASSWORD = "changeme". So helm upgrade leaves admin/changeme live while
NOTES directs the operator at a secret that does not authenticate. Same mismatch in
docs/DEPLOYMENT.md:60-66 and docs/INSTALLATION.md:129-141. Related: CHANGELOG.md:28-29 and
user-pack:321 place the boot-time check "from 4.0.0", but VERSION is 3.1.6 and no 4.0.0
version artifact exists. Same class as B-1: an artifact asserting behaviour absent from the image it
pins.
M-7 · The MCP rotation runbook instructs operators to set variables nothing reads (INV-17, again)
mcp-server/README.md:108 step 1 says "Update MCP runtime credentials in environment
(MCP_USERNAME, MCP_PASSWORD)"; :99 claims MCP_SERVICE_PASSWORD is "exposed to MCP as
MCP_PASSWORD". The container reads only BNK_FORGE_* (mcp-server/src/bnk_forge_mcp/config.py:19-22);
the backend reads only MCP_SERVICE_*. On Helm the mcp Deployment sources BNK_FORGE_* from the
release Secret, so the runbook does nothing; on compose it "works" only through B-1's poisoned alias —
the runbook actively instructs setting the variable that breaks the install. Makefile:630's
smoke-mcp-live banner has the same problem (exporting those in a shell changes nothing until
docker compose up -d). INV-17's Origin line is this series. Class fix: operator docs name only
variables that appear in a compose environment:/Helm env: for the process being configured;
freeze it with a script that extracts documented var names and fails when one has no binding.
MINORS
- Fresh admin seed accepts a published default.
auth_service.py:388-392takes
DEFAULT_ADMIN_PASSWORDverbatim with no known-default refusal, while the MCP path rejects
MCP_KNOWN_DEFAULT_PASSWORDS;helm/.../secrets.yamlfails formcpPassword(:76) and
mcpUsername(:89) but has noadminPasswordguard. The comment at:384("never seed a
known/published default") is false when the operator supplies one. Low reachability, and this PR
improved it —origin/staging:.env.example:89shipped# DEFAULT_ADMIN_PASSWORD=changemeand you
changed it to empty — so: minor, but the asymmetry is worth closing. - A gate reachable only through the
elseof a hardening flag.routes/benchmarks.py:1454-1490
returnsNoneas soon as the strictBENCHMARK_AGENT_AUTH_REQUIREDbranch passes, so the
must-change/user-resolution gate at:1519-1528runs only when the flag is off. Not exploitable
today (with the flag on a caller needs a matchingagent_idclaim, which no route mints for a human,
so a must-change admin gets 4401 rather than a pass) — but the comment at:1512calling this "the
one JWT-resolving entry point that skipped the gate" is wrong about its own other branch. Adminpasses the Python guard.auth_service.py:237,511is exact-match;secrets.yaml:88is
lower|trim.MCP_SERVICE_USERNAME=Adminmints a secondrole=admin,must_change=Falseaccount
on compose while Helm refuses it.disable_stale_service_userleaves the default hash in place (auth_service.py:640-647); only
is_activeflips, so the compensating route guard is load-bearing and complete only today.version-consistencymis-diagnoses a legal YAML comment. Inserting a column-0 comment after
^image:invalues.yamlmakes--checkrc=1 claiming "key renamed/removed? — vacuous check" and
print a remediation (--write 3.1.6) that also fails. INV-29's real defect is closed — both modes
now refuse — but the diagnosis and the fix it prints are both wrong.- The "single-sourced" overwrite policy exists in three divergent copies.
registry-overwrite-guard.shis called only fromrelease.yml:781,937; the actual publish gate
(:1142-1230) still open-codes ~60 lines, andMakefile:1290-1315open-codes a third variant that
omits thePROBE_N != IMAGES_Nfloor. The guard has no test and no local caller. - Two docstrings assert the INV-15 parity job does not exist.
compute_version_bump.sh:79-83and
extract-breaking-changes.sh:14-20say to "keep the two copies in lock-step by hand";
scripts/tests/detector-parity.test.shis in this tree, run by bothmake script-selftestsand
ci.yml:277-296, and goes red on injected drift. docker-bake.hcl:36-42says "BOTH paths that bake --push this file" — there are four;
Makefile:1334 push-customer-buildand:1386 push-customer-build-multiarchhave no probe.make pre-push≡ CI is still not true:artifact-network-self-test(ci.yml:506) and
migration-collision-checkhave no make target..githooks/pre-push:17-22— underset -etheif [ $? -ne 0 ]block is dead, so the
"PUSH BLOCKED" migration message is unreachable.publish-signed-images.sh:145sets provenancemetadata.buildStartedOnfromdate -uat
signing time — a knowingly wrong timestamp in the attestation.release.yml:266pollsgh run list --limit=20then filters by SHA; >20 newer runs inside the
45-minute window and the release times out claiming no run exists.secrets.yaml:88trimon a nilmcpUsernameraiseswrong type for valueinstead of the
guard message — guard withkindIs "invalid"asadminMustChangealready does.dist/install.sh:82auto-creates.envfrom an example shippingMCP_SERVICE_PASSWORD=, so
the mcp auth probe is guaranteed UNHEALTHY on every default dist install, yet the installer prints
"Installation complete!" with no mention of MCP..env.example:4still opens "No .env file is
needed!", contradicting its own line 58.- Coverage gap worth closing: no fixture drives
seed_auth_stepagainst a DB whereadminstill
holdschangeme—legacy_dbgives it"real-admin-secret"— which is the only shape a real
upgrade has.
Verdict
BLOCK at 43a3508. Round 1's findings are genuinely closed and the gate-vacuity work is solid.
The three blockers are all one shape — a fix whose reach was not traced: the alias reaches an image
without the guard, the ENVIRONMENT switch reaches a validator whose other requirements are
undeliverable, and the exemption reaches a ref state where it always fires. B-1 is the one I would fix
first: it re-creates the published-default admin credential this whole series exists to remove.
Tests/gates as run here: 106 + 124 backend and 12 mcp-server tests pass; ruff clean; make shellcheck
rc=0 over 45 files; single alembic head v2_155 with neither new revision mutated in place; the 2
mypy errors are in core/maintenance.py, untouched by this diff.
…username alias, make ENVIRONMENT=production satisfiable, and fix the mcp liveness probe
bonnyr-f5 round-2 BLOCK, credential/backend/helm/dist/docs half.
B-1 (INV-12): the compose files aliased the SERVICE username
(MCP_SERVICE_USERNAME:-${MCP_USERNAME:-mcp}); a legacy .env with MCP_USERNAME=admin
resolved it to `admin`, and against the guardless image `latest` still points at,
the old ensure_service_user rewrites the human admin row to `changeme` every boot.
Drop the username alias across all five compose files + the ibm embedded compose
(keep the harmless password alias), and default BNK_FORGE_VERSION to 4.0.0 (the
release this tree becomes, first image with the guards) instead of `latest`, so a
compose file can never hand the new credential contract to a pre-guard image.
B-2: ENVIRONMENT=production reaches validate_production, which also gates on
JWT_SECRET_KEY / ENCRYPTION_KEY / ALLOWED_ORIGINS — none of which were deliverable
from a compose install, so the switch bricked the backend. Plumb all three into
every x-backend-env anchor (four compose files + ibm) and document them in the
env examples; treat an empty ("" from ${VAR:-}) key as unset in config.py so the
plumbed empty default auto-generates rather than passing as a real empty key.
_persist_or_load_key now flags only keys WE generated as auto_generated (sidecar
.autogen marker), so an operator-provisioned key on the volume validates while a
fresh prod boot still fail-fasts permanently.
M-4: mcp liveness returned non-zero when the BACKEND was unreachable, so k8s
restarted the pod for a dependency outage. Move the auth-probe to readiness only;
liveness is tcpSocket. Fix mcp-server/README + mcp_live_smoke hints to name only
the vars each process actually reads (container: BNK_FORGE_*, backend: MCP_SERVICE_*).
Minors: refuse a known-default DEFAULT_ADMIN_PASSWORD on fresh seed + helm
adminPassword fail-guard; guard secrets.yaml mcpUsername nil with kindIs "invalid";
make the Python reserved-name check case-insensitive/trim to match Helm; neutralise
the hash when disabling a stale service account; correct the benchmarks.py
JWT-gate comment; surface an empty MCP_SERVICE_PASSWORD in install.sh; fix the
.env.example "No .env file is needed!" contradiction.
Tests: config B-2 satisfiability + provenance-marker tests; seed_auth_step against
an admin-still-holds-changeme upgrade DB; case-insensitive reserved-name and
disable-hash-neutralisation cases. ruff clean, mypy clean, 4831 unit pass, helm
lint/template green, docker compose config verified on all modes.
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…se minors B-3 (commit-lint exemptions): key the already-merged exemption on the range BASE (github.event.before), not the post-push tip, so a new skip-CI marker on a push to main/staging is caught while a genuinely-already-merged base commit stays exempt; replace the self-settable `^release: ` subject exemption with release.yml's own version+trailing-skip fingerprint. M-1 (spurious-major rule): redefine rule 2 as the exact complement of the detectors, sourced from the shared predicate, so it flags only a marker the detectors would MISS (never dash-bullet, markdown-bold or indented shapes); give it the same already-merged exemption; and lint inputs.release_notes through the script before it becomes a release commit/tag. M-2 (overwrite guard floor): derive the vacuity floor from `docker buildx bake --print default | jq '.group.default.targets | length'`, scoped to the default group, so a second bake group no longer wedges the release; separate bake-file parse failures from registry-unreachable in the messaging. Single-source the policy: release-publish and make push-images now call the one guard. M-3 (portability): drop bash-4 mapfile from the probe test; rebuild the compute self-test newline expansion with awk to avoid the bash-3.2 parameter-expansion cliff, so make script-selftests runs under stock macOS bash 3.2. Detector single-sourced into scripts/lib/breaking-change-detect.sh (compute, extract, lint all source it); detector-parity test asserts the wiring; added mutation tests for the lint rules and the overwrite guard. Minors: fix version-consistency misdiagnosis of a column-0 YAML comment; correct the compute/extract parity docstrings and the docker-bake four-push-paths note; wire artifact-network-self-test into ci-gates; make the pre-push hook migration message reachable under set -e; omit the false provenance buildStartedOn; filter the release CI-status poll by commit SHA. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Round-2 response — all 3 blockers + 4 majors + every nit addressed (@bonnyr-f5)You nailed the through-line: "a fix whose reach was not traced." Each round-1 fix reached one consumer further than I verified. Round-2 fixes are traced end-to-end (legacy Blockers
Majors
Nits — all dispositionedFixed: fresh-admin known-default refusal + helm Two honest flags carried forward: CI re-running now. Re-requesting on green. |
The round-2 disable_stale_service_user hash-scrub (bonnyr-f5 #193 minor) collided with the re-enable guard's own regression test: _seed_disabled_default_mcp built its "disabled while holding the published default" state BY CALLING disable_stale, which now scrubs the hash -- so holds_known_default_password was false and the PUT re-enable was allowed (200) instead of refused (400). The guard defends a row taken inactive by a path that LEAVES the credential intact (a manual operator PUT), not one disable_stale scrubbed. Seed that state directly (set is_active=False on the default-hash row) so the guard's real scenario is exercised; assert the default hash survives the seed. Corrected the now-stale guard comment in routes/auth.py that still claimed disable "only flips is_active". Neutralisation and its asserting tests are unchanged. Verified: TestServiceAccountReEnableGuard 2/2 pass; the three affected auth files (test_startup_seed_auth, component/test_auth_service, integration/test_routes_auth) 97/97 pass; ruff clean. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
@bonnyr-f5 — green, re-requesting as promised. The round-2 batch landed one follow-up ( Fix seeds that state directly ( Verified locally: |
Round 3 review —
|
| Path | Pinned image | Status |
|---|---|---|
Helm (values.yaml:19, Chart.yaml:6) |
3.1.6 |
exists; predates this PR's credential contract |
dist/ compose + .env.example + ibm_cloud_bnk_forge.sh |
4.0.0 |
has the contract; does not exist |
Both cannot be right, and today neither is. Run at this head:
HEAD=2e19719 VERSION=3.1.6
OK helm image.tag = 3.1.6
OK Chart appVersion = 3.1.6
OK frontend version = 3.1.6
OK operator image.tag = 3.1.6
OK operator appVersion = 3.1.6
exit=0
Green — five assertions, every one about 3.1.6, none able to see the 4.0.0 in dist/. The check
actively holds Helm at an image that lacks the code the chart assumes, while being structurally
blind to the path naming an image nobody published.
Fix. A deployment artifact must not name a version that does not exist yet. Bring
dist/docker-compose.yml, dist/.env.example and ibm_cloud_bnk_forge.sh's default under
sync-version-artifacts.sh (write and --check) so the pin is derived from VERSION and drift
is a red check forever; the header's dist/ disclaimer is the hole and should go. Worth adding
alongside it: a CI step that resolves every image: pin in every shipped compose file against the
registry and fails on manifest unknown — that catches this class with no reviewer involved.
B-2 · The .autogen marker makes SEC-006's key fail-fast fail open on every upgrade
backend/core/config.py:65-75 (read side) · :256-286 (validator) ·
backend/tests/unit/test_core_config.py:271-278 (test that enshrines it)
Round 2's B-2 fix distinguishes an operator-provisioned key from a platform-generated one by a
sidecar <key>.autogen marker, classifying on absence (config.py:74):
auto = os.path.exists(marker_path)The marker is new in this PR. Every keys volume created by a previously released version holds
jwt_secret.key / encryption.key with no marker, so both classify as operator-provided and
validate_production raises neither key issue.
Executed against the head's own _persist_or_load_key:
fresh boot 1: jwt_auto=True enc_auto=True -> SystemExit (fail-fast)
fresh boot 2: jwt_auto=True enc_auto=True -> SystemExit (fail-fast)
fresh boot 3: jwt_auto=True enc_auto=True -> SystemExit (fail-fast)
keys dir after 3 boots: ['encryption.key', 'encryption.key.autogen',
'jwt_secret.key', 'jwt_secret.key.autogen']
keys volume as written by the PREVIOUS release: ['encryption.key', 'jwt_secret.key']
jwt auto_generated flag : False
enc auto_generated flag : False
>>> validate_production PASSED <-- boots with an AUTO-GENERATED JWT secret
The affected population is not incidental — it is the whole population. The base commit passed
ENVIRONMENT to the backend in zero of four compose files (verified: 4a52ed4's
docker-compose.yml, docker-compose.local.yml, dist/docker-compose.yml,
dist/docker-compose.local.yml — no occurrences). This PR plumbs it precisely so operators can flip
the hardening switch. The operators who flip it are therefore, by construction, operators upgrading
an existing deployment — and bnk-forge-keys is a named volume that survives pull && up -d
(dist/docker-compose.yml:79,214,504); Helm mounts an equally persistent PVC.
This is a regression, not a pre-existing gap. At 4a52ed4, _persist_or_load_key returned:
return key, True # Loaded from file (still auto-generated, not user-provided)The fix inverted a deliberate, commented decision.
Second trigger, same root cause. The marker write shares a try with the key write
(config.py:83-96). If the key persists and the marker write fails — ENOSPC, a volume that goes
read-only between the two open()s — the next boot reads marker absent -> auto=False and the
fail-fast is gone on a fresh install too.
The test freezes the defect as intent. test_operator_provisioned_key_is_not_flagged
(test_core_config.py:271-278) writes a key file with no marker and asserts auto is False. That
fixture is the upgrade shape. There is no test for "keys volume from a previous release +
ENVIRONMENT=production must still fail fast."
Fix. Fail closed: absence of evidence of provisioning is not evidence of provisioning. The app
is the only writer of KEYS_DIR on every shipped path, so the correct default for a marker-less key
file is the old one — auto-generated. Invert the sentinel (an explicit <filename>.operator opt-out
for the pre-seed path, which is currently undocumented anyway), or keep the polarity and pair it
with a first-boot stamp that writes the marker beside any pre-existing marker-less key, plus a
separate try so a partial write cannot downgrade provenance.
Majors
Seven majors. Five of them (M-1..M-5) share one root cause with B-1: a shipped artifact depends
on code the image it pins does not contain. That is INV-32 for the third round running, which is
why the fix for B-1 needs to be structural rather than a value edit. M-6 and M-7 are independent
release/CI defects.
M-1 · The Helm chart pins the pre-guard image while shipping the post-guard contract and NOTES
helm/bnk-forge/values.yaml:19 · Chart.yaml:6 · templates/NOTES.txt:32-38
The chart moves to 3.1.6 — the exact image this diff's own dist/docker-compose.yml:23-28 calls
"the pre-guard 3.1.6 image" and refuses to default to — while adding DEFAULT_ADMIN_PASSWORD,
DEFAULT_ADMIN_MUST_CHANGE, MCP_SERVICE_USERNAME, MCP_SERVICE_PASSWORD to the pod env and
rewriting NOTES to describe behaviour that image lacks.
Traced from the registry rather than assumed. The 3.1.6 index's config blob carries
org.opencontainers.image.revision = 436d07db89d302aea3fc991bc29705196816bc8f, and at that revision:
$ git grep -c DEFAULT_ADMIN_MUST_CHANGE 436d07d -- backend -> 0 files
$ git grep -c _rotate_known_default_admin 436d07d -- backend -> 0 files
$ git grep -c disable_stale_service_user 436d07d -- backend -> 0 files
$ git grep -c _RESERVED_HUMAN_USERNAMES 436d07d -- backend -> 0 files
(same four at 2e19719: 2, 1, 8, 1 files)
436d07d:backend/core/config.py:101 DEFAULT_ADMIN_PASSWORD: str = "changeme"
436d07d:backend/services/auth_service.py:139 existing_users = db.query(User).count()
436d07d:backend/services/auth_service.py:140 if existing_users > 0:
436d07d:backend/services/auth_service.py:141 return None
class Config: extra = "ignore", so DEFAULT_ADMIN_MUST_CHANGE is silently discarded — an operator
setting secrets.adminMustChange: false gets must_change_password=True regardless.
NOTES tells the operator the minted Secret value "is what authenticates now" and the old one "no
longer authenticates". On the pinned image seed_admin_user early-returns with no rotation path at
all, so both statements are false and changeme still works. The chart tells an operator that a
live published credential is dead.
Fix: do not ship a chart whose image.tag predates the contract its templates emit — gate the
chart on the release carrying the guards, or add a fail keyed on appVersion.
M-2 · The bundle contradicts itself on BNK_FORGE_VERSION
dist/README.md:30-33 · user-pack/install-guide.html:164-168 (both bundled by make dist)
dist/.env.example:19-24 and dist/docker-compose.yml:21-28 warn emphatically "do NOT default to
latest, which currently resolves to the pre-guard 3.1.6 image". In the same tarball,
dist/README.md gives `latest` (or pin e.g. `3.1.6`) and the install guide gives latest
— "To pin a specific version, use its tag, e.g. 3.1.6." Both recommend the two values (verified
byte-identical digests) the compose file forbids.
The guide's next step is docker compose exec backend cat /app/keys/initial_admin_password —
_persist_generated_password does not exist at 436d07d, so that file is never written, the command
errors, and the real password is the changeme the guide says no longer ships.
M-3 · The MCP_PASSWORD alias is dead for the only legacy value that exists, and install.sh's new warning fails open on it
dist/.env.example:41-42 · dist/docker-compose.yml:63 · dist/install.sh:355-363
Round 2 kept the password alias on the reasoning that "aliasing a value is safe". The old published
dist/.env.example (4a52ed4:dist/.env.example:29-30) shipped exactly MCP_USERNAME=admin /
MCP_PASSWORD=changeme, and changeme is in MCP_KNOWN_DEFAULT_PASSWORDS. So the alias resolves to
a value the backend refuses:
== backend MCP_SERVICE_PASSWORD = 'changeme'
== mcp BNK_FORGE_PASSWORD = 'changeme'
→ startup_steps.py:236 _mcp_pw_usable = False → disable_stale_service_user → mcp disabled.
The alias "keeps working" only for a password no shipped artifact ever produced.
The new install.sh warning, added precisely to surface "MCP is not active", is suppressed on that
input. Executing the shipped snippet verbatim:
shipped dist/.env.example (MCP_SERVICE_PASSWORD=) WARNING SHOWN (MCP not active)
legacy .env (MCP_PASSWORD=changeme) no warning; MCP_PW=[changeme]
legacy .env (MCP_PASSWORD=mcp-service-changeme) no warning; MCP_PW=[mcp-service-changeme]
quoted empty MCP_SERVICE_PASSWORD="" no warning; MCP_PW=[""]
real value no warning; MCP_PW=[Sup3rSecret]
commented out only WARNING SHOWN (MCP not active)
Rows 2-4 are the fail-open cells: the upgrading operator gets ✅ Installation complete! while MCP
is permanently disabled. Row 4 is also a parser skew — compose strips the quotes and renders UNSET.
Fix: reject the known defaults and strip surrounding quotes in install.sh; correct
.env.example to say the alias works only for a non-default value. Siblings carrying the same
alias: docker-compose.yml:63, docker-compose.local.yml:57, dist/docker-compose.local.yml:33,
ibm_cloud_bnk_forge.sh:399.
M-4 · A default helm install cannot boot, and the new NOTES walks the operator into it
helm/bnk-forge/values.yaml:109,111 · backend/core/config.py:288-296
The default render sets ENVIRONMENT=production and ALLOWED_ORIGINS=https://localhost on
api/worker/beat; validate_production() treats that pair as fatal → SystemExit(1) →
CrashLoopBackOff on all three.
$ helm template rel helm/bnk-forge | <env dump>
rel-bnk-forge-api ALLOWED_ORIGINS = 'https://localhost'
rel-bnk-forge-api ENVIRONMENT = 'production'
Honestly scoped: this is pre-existing — base 4a52ed4 renders the identical pair, and the check
exists in the pinned 3.1.6 image too. Reported because this diff adds NOTES text handing the
operator a login procedure for a release that never comes up, with no mention of the required
override, and helm lint is clean so nothing flags it. --set api.env.ALLOWED_ORIGINS=https://forge.example.com is confirmed to reach all three.
M-5 · The "no MCP credentials → UNHEALTHY" guarantee is unshipped on every tag that exists
dist/docker-compose.yml:407-412 · dist/install.sh:369-375 · helm/templates/mcp.yaml:66-74
The diff's MCP-observability claim — "without this the container would report green while every
tool call 401s" — depends on the new probe() returning 1 when not config.has_credentials. At
436d07d (i.e. 3.1.6 and the byte-identical latest):
if not config.has_credentials:
# No credentials at all — can't probe; treat as healthy so we don't
# flip unhealthy on token-only deployments.
return 0So the container reports healthy/ready with no credentials, and the chart's comment that "a
credential drift or a disabled mcp row correctly takes the pod OUT OF SERVICE on Kubernetes too" is
false for the tag the chart pins. Same root cause as B-1/M-1.
M-6 · Rule 2 fires on unamendable history — and the escape hatch built for that case is dead code
These two are separate defects that compound into one release-blocking problem, so they are together.
M-6a — the already-merged exemption can never fire on any shipped path.
lint-commit-markers.sh:105-107,120-127
BEFORE defaults to RANGE's left-hand side (BEFORE="${RANGE%%..*}"), and _already_merged is
git merge-base --is-ancestor "$sha" "$BEFORE". But git rev-list A..B excludes by definition every
commit reachable from A, so for every sha the script iterates the test is necessarily false. No
production caller sets BEFORE independently — ci.yml:207-208 says so explicitly ("the script
derives it from RANGE's left-hand side"). The only non-prose BEFORE assignments in the tree are
lint-commit-markers.sh:105-107 and scripts/tests/lint-commit-markers.test.sh:43.
### production shape (RANGE only, BEFORE derived)
already-merged exemptions fired: 0
### can any sha in the range be an ancestor of the LHS?
2e19719f not ancestor bc0a36f5 not ancestor aed2fdcf not ancestor
Tests B3.2 and M1.5 pass only because they set BEFORE to a sha inside the range — a state no
caller produces. The header devotes 20 lines to this exemption ("a mis-anchored marker in an
already-merged squash body cannot be reworded"). It does not exist. It fails closed, so this is
not a security hole — it is dead code plus two decorative tests. I called this closed in my summary
above on the strength of the re-keying being correct; the cold audit caught that I had checked the
predicate and not its reachability.
M-6b — rule 2 rejects prose the detectors deliberately treat as inert, and already fails on a
real merged commit.
Three shapes are negative fixtures in the compute/extract self-tests yet are flagged by the gate.
Reproduced live against this branch:
$ RANGE="origin/main..HEAD" bash scripts/lint-commit-markers.sh
::error::commit 4a52ed4556c7b02d7f57c4850cc5505015a15b47 (Version derivation and release notes
must read commit bodies (→ 4.0.0) (#178)): line "- BREAKING CHANGE footer in the BODY of a
fix-subject commit → major (non-" is shaped like a BREAKING CHANGE marker but is not positioned
where the release detectors recognise a footer …
::error::commit-lint failed -- see markers above.
4a52ed4 is this PR's own merge base — already merged, unamendable, and (verified)
not an ancestor of origin/main. So the range for the eventual push to main
(github.event.before..github.sha, ci.yml:174-175) includes it, and commit-lint — an ALWAYS_RUN
gate — reds on the merge that cuts the release. It cannot go green without a history rewrite.
This is exactly the case M-6a's exemption was written for. Rule 2 fires on unamendable history while
the escape hatch for unamendable history is unreachable. Today's green CI does not contradict
this: on a PR the range is pull_request.base.sha..head.sha = 4a52ed4..2e19719, which excludes
4a52ed4 itself. The failure appears on the push event, not here.
Fix: make rule 2 the exact complement of the detector in both directions (the M-1 single-
sourcing went one way only), so a shape the detectors treat as inert is not flagged; and either give
the exemption a base it can fire against (e.g. anchor to the other protected branch on a push to
main) or delete it along with its two tests and the header claim.
M-7 · secret-scan.sh's "0 commits scanned" backstop false-fails on a delete-only range
gitleaks reports 0 commits scanned for a pure-deletion commit range; check 3 then exits 1 on the
grounds that it "would have passed blind". Reproduced with the shipped script. That blocks git push
through the hook and reds a required gate for a legitimate change that only removes files — the
anti-vacuity backstop is correct in intent but cannot distinguish "scanned nothing because something
broke" from "scanned nothing because there was nothing to scan". Gate on gitleaks' exit status and
the range being non-empty rather than on the commit count alone.
Minors
_persist_or_load_keywrites the secret through a 0644 window (config.py:85-87). Its
sibling in this same diff,_persist_generated_password(auth_service.py:294-302), was
deliberately rewritten toos.open(..., 0o600)+os.fchmodwith a docstring explaining that
open()+chmod"would create it 0644 under the usual umask, then narrow it". The higher-value
secret still does the unsafe thing. Observed by pollingstatduring the write:
modes: ['0o600', '0o644'], final0o600. Same construction fixes it.- Two copies of the known-default denylist gate the same decision —
config.py:21vsauth_service.py:225, identical tuples today. The drift failure is asymmetric:
a new default in the config copy only → account disabled (safe); in the service copy only →
ensure_service_userraisesValueError, whichstartup_steps.py:283-287swallows as a log line
→ MCP silently dead. Import the config copy; delete the local one. (A third copy lives in
helm/templates/secrets.yaml:78,95,105— all three agree today and all three chart guards fire,
but nothing asserts the lockstep the comment claims.) holds_known_default_password's docstring asserts the opposite of what this diff does
(auth_service.py:39-50): it states as fact thatdisable_stale_service_user"never touches the
hash", while:680in the same diff overwrites the hash withhash_password(token_urlsafe(32)).
ThePUT /api/auth/users/{id}guard it justifies can therefore never fire on the path it names.
routes/auth.py:376-379is honest about this; the service docstring is not. Docstring fix only.MCP_USERNAMEis read by nothing, and four docs still tell operators to set it —
mcp-server/README.md:59-62(changed here) says to set it, while:117of the same file says
"Do NOT setMCP_USERNAME: it is not read by either process". AlsoMakefile:643(changed here),
Makefile:667,docs/E2E-CRITICAL-004_MCP_SANITY.md:90(lists it as required).ENCRYPTION_KEYis consumed by nothing but its own gate. New.env.example:63-66text
describes it as the "Fernet key for stored secrets", butcore/encryption.pyreads
/app/keys/encryption.keydirectly and never consultssettings.ENCRYPTION_KEY. Relatedly the
chart generates it asrandAlphaNum 32, which is not a valid Fernet key (executed:
ValueError: Fernet key must be 32 url-safe base64-encoded bytes) — harmless only because
nothing consumes it, which is itself the finding.DEFAULT_ADMIN_PASSWORD/DEFAULT_ADMIN_MUST_CHANGEmissing from the IBM Cloud installer's
backend env (ibm_cloud_bnk_forge.sh:387-407) — plumbed into P1/P2/P4 with the explicit
"config.py has noenv_file" justification, skipped on P3.secrets.mcpUsername: nullrenders a null Secret value past the nil-guard
(helm/templates/secrets.yaml:105-110,127) →mcp-username:with no value, flowing to
MCP_SERVICE_USERNAME. Unverified whether the API server accepts it (no cluster).| default "mcp"
in the nil branch regardless.ibm_cloud_bnk_forge.sh:91still usesmapfile -t SSH_KEYS, which is bash 4+; the script is
#!/usr/bin/env bashand stock macOS is 3.2.57 →rc=127. Predates this PR, but the PR touches
this file and fixed exactly this class elsewhere.v2_155's documented remedy for a customMCP_SERVICE_USERNAMEdoes not revoke the stale
credential — pointing the var elsewhere leaves the legacy rowis_active=True,role=admin,
still authenticating with the published default, because the backfill keys on
('mcp','mcp@bnk-forge.local')anddisable_stale_service_userfiltersis_service_account.
Narrow: that var never reached the backend on any shipped path at base, so only a hand-edited
compose reaches it. Docstring fix.
Release/CI minors:
- The release-bot exemption remains self-settable (
lint-commit-markers.sh:41-43vs:131-134).
The header claims the exemptions are "keyed on properties the committing client cannot forge", but
exemption (a) reads the commit subject. Executed: a hand-written
release: v0.0.1 [skip ci]commit carrying[skip ci]in the body and a mis-anchored
BREAKING CHANGE:is exempted from both rules,rc=0. Exposure is low (a squash-merged PR is
linted throughPR_TITLE, and a skip-marked push to a protected branch produces no workflow run),
so this is a false claim in the header more than a live bypass — but either key it on committer
identity and the subject fingerprint, or drop the unforgeability claim. The same predicate is
duplicated atrelease.yml:131; move both. registry-overwrite-guard.shfails OPEN on an unrecognised probe status
(:73-79) — thecase "$status"has arms forexists/absent/unknownand no*)default,
so a malformed or empty status contributes to neither bucket and the guard prints "safe to publish",
exit 0. Executed:SCEN=malformed rc=0,SCEN=empty_status rc=0, controlSCEN=exists_all rc=1.
The header claims it refuses when "the probe is inconclusive" — a malformed classification is the
most inconclusive state there is. Low reachability today (the shipped probe always emits one of the
three), so defence-in-depth rather than a live hole. Add the*)arm and a test scenario.FORCE_LATEST=1now silences two independent guards at once inmake push-images— the
recency guard and the immutable-tag guard — and the printed remediation cannot rescue a missing-jq
failure.registry-tag-probe.sh's000)network-failure arm is dead code — real curl yields000000
for that shape, and the test fixture does not reproduce it, so the arm has never run.- INV-15 single-sourcing is partial: two predicates are single-sourced, but the marker regex
itself is still duplicated six times, and_looks_like_breaking_markerhas no parity assertion. _lint_under_detectedshort-circuits on the first valid footer, so a second mis-anchored
marker later in the same body is not reported.- A
publish_onlyv3.1.6 recovery would report 7 images having pushed 6 — the summary counts the
bake target list, not what was actually pushed. registry-tag-probe.test.shre-creates the unscopedtargets = [parse that M2 removed from
the guard itself, so the test and the code now disagree about how targets are enumerated.ibm_cloud_bnk_forge.shpins4.0.0outsidesync-version-artifacts.sh's ownership —
independently surfaced by the release audit; it is the third site of B-1.- Tentative, could not be executed: the pre-push overwrite guard runs in
release-final/
release-manual, which may not set up buildx orjq; the audit could not confirm runner-image
contents.
Verified sound in the release/CI surface (falsification attempted, held): full macOS bash-3.2 +
BSD-userland portability across all suites and both self-tests, with no mapfile and no -E litter
from -i.syncbak; release ordering — nothing irreversible precedes a fail-closed check on any of
the four release kinds; CI Gate covers 28 jobs and its elif … && case … esac is valid;
--max-archive-depth 2 genuinely finds a token inside a tracked tarball; image-set parity holds
three ways; and the .release-tooling sparse checkout is genuinely load-bearing (v3.1.6 lacks the
probe).
Test gaps worth closing regardless of the fixes
Named because a fix without one of these lets the same class back in:
- Keys volume from a previous release +
ENVIRONMENT=productionmust still fail fast. No test
exists; the only test in that shape asserts the opposite (B-2). - Marker write fails while the key write succeeds — the second trigger of B-2.
disable_stale_service_user(skip_username=<non-None>)— the Integrate #177 follow-up stack: #179 #180 #181 #182 #183 #186 #188 #193 M2 change has zero direct
coverage.grep -rn "skip_username" backend/tests/→ one call site, passingNone. The property
M2 claims ("never commits an inactive window for the live MCP account") is asserted nowhere; only
the end state is.db.commit()failing after_persist_generated_passwordhas written the file — the keys
file then holds a password that does not authenticate while the published default still does.
Self-heals next boot, but untested.DEFAULT_ADMIN_MUST_CHANGE=falsewith an unsetDEFAULT_ADMIN_PASSWORD— admin is seeded
with a generated password and no must-change gate, while the log still says "you must change it
on first login" (auth_service.py:473-478).MCP_SERVICE_USERNAMEas a case/whitespace variant of a non-reserved name (e.g." mcp ").
_is_reserved_human_usernamenormalises, but the lookup at:562and theskip_usernamefilter
at:667use the raw value, so" mcp "creates a second service row rather than reconciling
mcp.- Lockstep between the Python and Helm known-default lists — asserted by a comment, by no test.
Stated honestly: what the audits could not verify
- No Postgres was available, so every DB-backed assertion ran on SQLite where
with_for_update()is
a silent no-op. The two-replica serialisation conclusions for_rotate_known_default_adminand
ensure_service_userare reasoned from Postgres READ COMMITTED semantics, not executed. - No cluster: no live
helm install, and Kubernetes acceptance of a nullstringDatavalue is
unverified. - No stack was started; all compose/Helm conclusions come from rendered output plus direct execution
ofconfig.py. - Load-bearing for the release/CI slice and not established: whether GitHub's squash body can
turn clean commits into a rule-2 failure, and whether[skip ci]suppression reads only the head
commit (which decides whether the gate is vacuous on push events). Live GHCR probe behaviour and
the runner images' buildx/jqpresence were also not verified. - The runtime of the
v4.0.0-rc1image was not established. No shipped artifact references that
tag, so it changes no finding.
One pre-existing issue, outside every slice, flagged not filed
backend/services/backup_service.py:539-570 replaces /app/keys/encryption.key on restore, but
Settings.__init__ only consults that file when ENCRYPTION_KEY is falsy — and
validate_production requires that env var under staging|production. So on precisely the
deployments the validator hardens, restore's key replacement is a no-op and restored data stays
undecryptable. Base behaviour is identical, so this is not from #193 — it belongs to whoever owns
backup/restore, and I am noting it only because it surfaced while tracing the keys dir.
The through-line, and what I would change in the process
Round 2's lesson was "a fix whose reach was not traced." Round 3 is the same sentence with a
narrower subject: both blockers are round-2 fixes that are correct for the case the author tested
and wrong for the population that will actually meet them. 4.0.0 is right for the release that
has not happened; the .autogen marker is right for the install that does not exist yet. In both
cases the tested case was the fresh one and the broken case was the upgrade.
So the concrete suggestion for round 4 is narrower than "trace the reach": for every fix, run it
against state the previous release would have produced, not state this release creates. A keys
volume with no marker. A .env with the old variable names. A registry with only the tags that are
actually published. Each of the two blockers dies immediately under that one habit, and so does M-3.
Two of these belong in the repo rather than in a reviewer's head, and both are cheap:
- Extend
sync-version-artifacts.shto own every version-bearing deployment artifact and drop
thedist/disclaimer, plus a CI step resolving each shippedimage:pin against the registry.
That converts B-1/B-1b/M-1/M-2/M-5 into a red check. - A test that seeds the previous release's on-disk shape before asserting the new gate fires.
That converts B-2 into a red check.
On my own side: B-2 is a class the registry already had — a new classification signal with no
backfill for the pre-existing population, logged after #188 round 3 when users.is_service_account
shipped with no backfill. My sweep missed it here because the entry is written for database columns,
so a filesystem sidecar marker did not match its trigger. That is a class escaping twice, which is
the one thing this process is supposed to prevent, so I am generalising the entry from "column" to
any classification signal — column, marker file, cache entry, annotation, registry tag — with the
polarity rule stated explicitly: when a signal's absence coincides with its "safe" value, the
classification must fail closed.
Own the round-3 CREDENTIAL/AUTH findings. B-2 (BLOCKER): _persist_or_load_key classified a marker-less key file as operator-provided, so every upgrade keys volume (key present, no marker) let SEC-006's fail-fast pass OPEN on an auto-generated JWT/ENCRYPTION secret in production. Invert the sentinel: a marker-less key now classifies AUTO-GENERATED (fail closed); an operator asserts provenance with an explicit <filename>.operator opt-out marker. No marker is written on generation, which also removes the second trigger (a partial marker write can no longer downgrade provenance). Regression tests seed the PREVIOUS-RELEASE on-disk shape and assert validate_production raises under ENVIRONMENT=production. Minors: - Write the key via os.open(0o600)+fchmod so the secret is never briefly 0644. - Single-source the MCP known-default denylist: delete the local tuple in auth_service and use core.config.MCP_KNOWN_DEFAULT_PASSWORDS (comment notes the helm copy is deploy-owned). - Correct holds_known_default_password docstring (disable_stale now scrubs the hash; this guard covers the other disable paths). - Reconcile ENCRYPTION_KEY docs with reality: the keys-file is the source of truth for at-rest crypto; the env var only drives the production gate (encryption.py comment + .env.example). - Clarify the v2_155 custom-username remedy in disable_stale docstring. Test-gaps: - Normalise the service username (trim/casefold) at the reconcile lookup and the disable skip filter, so " mcp "/"MCP" reconciles the existing mcp row instead of minting a second service account and disabling the live one. - Honest seed_admin_user log/logic under DEFAULT_ADMIN_MUST_CHANGE=false (no more "must change on first login" when no gate was applied). - disable_stale_service_user(skip_username=...) leaves the live row wholly untouched (no inactive window), variant included. - db.commit() failure after the keys file is written leaves a retriable state (published default still authenticates, orphan file password does not). All owned-suite tests pass; ruff clean. Each fix reproduced then mutation-tested. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
… minor M-6 (blocker): commit-lint no longer reds unamendable merge history. - rule 2 now flags ONLY a mis-anchored DECLARATIVE marker (the colon form); a colonless marker-shaped PROSE line the detectors treat as inert (an already-merged body such as "- <MARKER> footer in the body ...") is no longer flagged, so the push-to-main range (before..head, which INCLUDES the PR merge-base) goes green without a history rewrite. Detection of a real mis-anchored marker is unchanged. - deleted the already-merged exemption as dead code: base..head excludes the base by construction, so no scanned commit can ever be an ancestor of it. Removed the BEFORE derivation, _already_merged, tests B3.2/M1.5, and the ~20-line header claim. The release-bot exemption stays. - rule 2 now scans the whole body via _under_detected_markers and reports EVERY mis-anchored marker, not just the first. M-7 (major): secret-scan no longer false-fails a delete-only range. A delete-only commit has rev-list count > 0 but gitleaks scans 0 (it scans added content), so the count-based backstop is replaced by a range- resolvability check plus gitleaks' exit status. Release/CI minors: - release-bot fingerprint single-sourced to scripts/lib/is-release-bot-subject.sh; release.yml's inline copy byte-locked by a parity self-test; dropped the false unforgeability claim and documented the residual honestly. - registry-overwrite-guard: added a fail-closed default arm for an unrecognised/empty probe status (+ malformed/empty test scenarios). - Makefile push-images: FORCE_LATEST now overrides ONLY the recency guard; a new FORCE_OVERWRITE overrides ONLY the immutable-tag guard; fixed the missing-jq remediation text. - registry-tag-probe: the network arm now matches the real doubled "000000" curl-failure shape (was dead code); test fixture reproduces it. - INV-15: single-sourced the marker regex (one canonical value + a detector-parity assertion that every embedded copy is byte-identical). - release.yml Publish summary counts what buildx actually pushed (bake --metadata-file), not the static target list. - registry-tag-probe test enumerates the bake DEFAULT group (scoped), matching the guard's enumeration. - added scripts/tests/secret-scan.test.sh (fake-docker mutation suite). release.yml: added a post-push step running scripts/verify-image-pins.sh so a release cannot complete while shipping an unpublished image pin (script owned by the deploy agent; referenced by path from .release-tooling). Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…ajors/minors
B-1/B-1b: dist/docker-compose{,.local}.yml, dist/.env.example and
ibm_cloud_bnk_forge.sh hard-pinned 4.0.0, a tag that has never been published,
while Helm pinned 3.1.6 -- two shipped paths naming two versions, one of which
does not exist. Bring all of them under sync-version-artifacts.sh (new PIN/
DISTENV readers+writers, --check, --list) so every pin derives from VERSION
(3.1.6, which exists) and the release re-stamps them atomically via the existing
--write $NEW; drop the dist/ disclaimer. Add scripts/verify-image-pins.sh (+
selftest) that resolves every shipped compose image: pin against the registry and
fails on manifest unknown, wired post-push in the release job.
M-1..M-5: reword NOTES/compose/chart comments that asserted post-guard behaviour
as already-true on the pre-guard pinned image (they land with the guard-carrying
release); NOTES now leads with the required ALLOWED_ORIGINS override; dist/README
and the install guide stop recommending latest/3.1.6 and the keys-file cat the
pinned image does not write; install.sh strips quotes and rejects the known-
default MCP passwords so the "MCP not active" warning fires instead of a green
lie; add deploy-version-lockstep + helm-known-defaults-lockstep selftests.
Deploy minors: MCP_USERNAME "do not set" made consistent across docs/Makefile;
chart ENCRYPTION_KEY now emits a valid Fernet key; DEFAULT_ADMIN_* added to the
ibm P3 backend env; secrets.mcpUsername defaults to "mcp" on null; ibm mapfile ->
portable while-read.
Verified: sync --check exit 0; --write round-trip moves every pin and restores;
helm lint/template clean (default + origin override); script selftests green;
bash -n + shellcheck clean.
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
@bonnyr-f5 — round 3 addressed, Three disjoint fixers, one per audit slice. Full local verification on the merged tree: backend 8056 passed; BlockersB-1 / B-1b — unpublished B-2 — Majors
Minors & test gapsAll addressed. Highlights: the known-default denylist is single-sourced to On the through-lineYou're right that rounds 2 and 3 were the same sentence — a fix correct for the fresh case, wrong for the upgrade. I've made "seed the previous release's on-disk/registry shape, then assert the new gate" a standing fixture pattern here (B-2's upgrade-shape test, the lockstep tests, |
Round 4 review —
|
…marker (M-1), row-matches-client (M-2) + credential minors B-3: validate_production gated ENCRYPTION_KEY while a second, independent generator in core/encryption.py produced the real at-rest Fernet key unchecked — setting ENCRYPTION_KEY (the documented remedy: token_hex(16), not even a Fernet key) turned the gate green while encryption auto-generated a different key. Unify: one key file (_encryption_key_path == core.encryption.ENCRYPTION_KEY_FILE), one generator. When ENCRYPTION_KEY is set it is VALIDATED as a real Fernet key (fail clearly if not), written to that file with a .operator marker, and consumed by core.encryption and services.backup_service; the provenance flag reflects the value that actually protects data. Never clobber an operator-marked key on a mismatch. config.py:319 and .env.example now print the Fernet recipe. M-1: _persist_or_load_key required a regular-FILE marker (os.path.isfile, not os.path.exists — a directory no longer counts) and treats "marker present, key file absent" as a provisioning error: generate but do NOT persist, so the stale-marker rotation gesture can never heal into auto=False on the next boot. M-2 (regression this PR introduced): ensure_service_user normalised the username before lookup/create, so MCP_SERVICE_USERNAME=MCP seeded 'mcp' while the client sends the raw 'MCP' and authenticate_user matched exactly -> login denied. Create/ reconcile under the RAW value (what the client sends); the disable_stale skip keys on the same raw value; only the reserved-name guard normalises. Fixed the false "Matches the Helm chart lower|trim" docstring. Credential minors: non-vacuous localhost-CORS test (valid MCP password so only the CORS branch fails) + wildcard is now an exact origin-list entry, not a substring; new DPU-websocket must-change/unresolvable-user tests mirroring the k8s twin; middleware unresolvable-JWT-subject-refused and exact-vs-suffix exempt-path tests; corrected the denylist copy count (4th copy in dist/install.sh); corrected v2_155's rationale (v2_154 is new in this diff, not "already shipped"); documented why ensure_service_user's adoption branch is kept. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…deploy minors
B-1: a fresh install of the pinned 3.1.6 bundle seeded an admin nobody could log
in as. `${DEFAULT_ADMIN_PASSWORD:-}` delivers the var present-and-empty; 3.1.6's
`DEFAULT_ADMIN_PASSWORD: str = "changeme"` is then overridden by "" and the login
schema rejects "" (422). The prescribed `${VAR}` form does NOT omit on this compose
(map interpolation still renders ""); the working omit-when-unset form is a map
entry with NO value (passthrough / `docker run -e KEY` semantics). Converted
DEFAULT_ADMIN_PASSWORD to passthrough across all four compose paths (dist base+local,
root base+local, IBM embedded). Swept the class: JWT_SECRET_KEY/ENCRYPTION_KEY also
converted (3.1.6 uses `if KEY is None`, so "" is used literally, not auto-generated);
MCP_SERVICE_PASSWORD kept as `${...:-...}` because omitting it restores 3.1.6's known
default "mcp-service-changeme". Added a commented DEFAULT_ADMIN_PASSWORD block to
dist/.env.example. Verified via `docker compose config` + real container env both
directions (unset -> omitted; set in .env -> forwarded).
M-7: chart checksum/secret did not change on mcp-password rotation (3 renders, 3
generated passwords, one identical checksum). Made all generate/rotate fallbacks
deterministic (deriveSecret, release-seeded) so the Secret is stable across renders
and includes, and hash the RENDERED Secret so the annotation tracks every resolved
value. Now stable across renders, identical across the 4 deployments, and it flips
when any resolved value changes.
M-8: dist/install.sh credential guard failed open on `changeme ` (whitespace). Trim
leading/trailing whitespace around the quote-strip before the known-default compare.
M-9: removed newly-added forward-dated 4.0.0 prose (install-guide.html x2, DOCKER.md).
M-10: default helm install crashlooped (production + localhost). Added a render-time
guard mirroring backend validate_production (fail on wildcard under staging/production,
localhost under production); defaulted ALLOWED_ORIGINS to empty so the bare render
boots (empty is neither wildcard nor localhost). `helm lint` and bare `helm template`
stay green; the guard fires with a clear message on a real fatal posture.
M-11: portable in-place sed in the IBM installer (`sed -i.bak … && rm`), both sites.
M-12: dist/ no longer ships published default DB/redis creds on host networking.
install.sh generates strong POSTGRES_PASSWORD/REDIS_PASSWORD on the fresh .env (like
Helm/IBM); .env.example ships them empty; a pre-existing default triggers a warning.
Deploy minors: extended deploy-version-lockstep.test.sh to the bnk-operator chart
(appVersion + image.tag) and dist/VERSION; brought dist/VERSION under
sync-version-artifacts.sh (--check/--list/--write green); reworded install.sh's MCP
UNHEALTHY assertion to match what the pinned image actually reports; added
scripts/tests/ibm-compose-drift.test.sh freezing the credential/hardening env so the
IBM embedded compose and dist/docker-compose.yml cannot silently diverge.
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
… parity/self-test harnesses, and close the release minors
B-2 (blocker): scripts/verify-image-pins.sh could never pass where the release job
runs it — from the 4-file sparse .release-tooling checkout that holds no compose
file (ROOT resolved there), with REGISTRY/VERSION handed in as env vars the script
reads only as flags, and the whole step wired AFTER the tag/Release/push/signing.
Fixes, end to end:
- add a consistency mode (--expect-version) that asserts every shipped first-party
pin already renders to $NEW without a registry probe, and run it as the PRIMARY
PRE-push gate in release-final and release-manual (before anything irreversible);
- fix the post-push existence step to pass REGISTRY/VERSION as FLAGS and the compose
files explicitly by --file (they live at the tag checkout at the workspace root),
keeping it as a secondary confirmation;
- widen the default file set to include the IBM Cloud installer's embedded compose;
- add a dryrun-release-tooling job that rebuilds the exact publish-job layout and
exercises both invocations against a fake probe, and gate release-publish on it,
so a step that cannot execute is caught before it is wired ahead of a signature.
The sibling registry-tag-probe.sh / registry-overwrite-guard.sh read nothing
$ROOT-relative outside the sparse set, so they are unaffected.
M-3: detector-parity.test.sh enumerated the marker copies with the very token that
drifts, so a copy that drifted in the token vanished from enumeration (drifting
:96-97 dropped the count 5->3 yet stayed green). Enumerate by position/count instead:
an exact per-file canonical count plus a stable-anchor site scan that flags any
drifted site even under a compensating add.
M-4: the filesystem self-test loop checked only a non-empty enumeration and each
file's exit 0, so a test gutted to a no-op passed and deleting 7 of 8 stayed green.
It now requires each file to emit PASS lines, no FAIL line, and an ALL PASS terminal
marker, plus a count floor derived from git's tracked *.test.sh set (detector-parity
was conformed to that output convention).
M-5: release-rc created and pushed the RC tag before the fail-closed notes step;
the tag is now created locally, notes generated, then the tag pushed.
M-6: added mutation-tested coverage for this PR's four previously-uncovered lint
fixes (the PR-title lint, the pending-message lint, the RANGE fail-closed branch,
and the skip-checks trailer rule).
LEAD: the anti-vacuity staging floor derived the count from a stale literal while
--list grew to 8 paths; both sites now derive it from --list and require every listed
path to stage, and the stale comments are corrected.
Release minors: scope the release-bot commit-lint exemption to the range tip (a
forged release subject buried mid-range is no longer exempt) and add a REACHABLE
published-history exemption anchored to the last release tag so a mis-anchored marker
in unamendable history cannot red the release; add fixtures for the untested registry
probe/guard arms (5xx, unexpected code, unknown status, bake-parse failure);
ancestry-filter the LAST_FINAL tag queries so a tag on another branch cannot skew the
notes range; reconcile the empty-RANGE handling (commit-lint now fails closed on an
explicit empty RANGE, ci.yml leaves it unset); give the pre-push hook a clear message
and a fetch fallback when the remote tip is absent locally; derive the cosign
verify-identity org from REGISTRY instead of hardcoding it.
Flagged: gate Makefile push-customer-build through registry-overwrite-guard.sh, the
last documented push path that was still unguarded.
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…ecrets The r4 deploy fix closed M-7 (checksum did not move on a secret rotation, so pods never rolled) by making the generated fallbacks deterministic -- deriveSecret = sha256(Release.Name|Namespace|fullname|purpose). Every input is public (they appear in resource labels and the chart source), so that made the JWT signing key, the at-rest Fernet key and the admin/mcp passwords COMPUTABLE by anyone who can read a label -- a forge-any-token / decrypt-all-secrets exposure, strictly worse than the cosmetic churn it fixed. Revert generation to randAlphaNum (unpredictable) and instead hash the DETERMINISTIC inputs that determine the Secret -- values.secrets, the persisted .data (reused via lookup), plus a per-credential "rotating-from-default" marker for admin/mcp whose persisted value is a known published default. That tracks every rotation (operator edit, persisted-value change, rotate-away-from-default) so the pods roll, is stable across renders including a bare no-cluster `helm template` (the hashed inputs carry no randomness), and never derives a secret from public identity. deriveSecret removed. New scripts/tests/helm-secret-checksum.test.sh locks all three: stable-across-renders, changes-on-rotation, and generated-value-is-random -- so the determinism cannot return. Verified: helm lint 0-failed; bare + override template OK; the M-10 render guard still fires on production+localhost; the new selftest ALL PASS. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
@bonnyr-f5 — round 4 addressed. Three blockers, twelve majors, every minor, and the two flagged-outside items are closed. I took your generalization — trace each fix to the consumer you did not write it for — as the working method, and it earned its keep: it caught a critical secret-predictability regression in one of our own round-4 fixes before it shipped (M-7, below). Flagging that one loudly because it is the clearest proof the method works. Verification on the merged tree: backend 8081 passed; BlockersB-1 — fresh install seeds an admin nobody can log in as. Fixed, and I owe you a correction on the prescribed form: Applied to all four sites. Sweep of the
Your assumption that JWT/ENCRYPTION were benign didn't hold on 3.1.6 (it uses B-2 — the pin verifier could never pass and ran after everything irreversible. All three defects fixed: it now runs from the workspace-root tag checkout (compose files present) with B-3 — SEC-006's encryption gate guarded a value that encrypts nothing. Unified to one key, one generator, one provenance signal: Majors
Minors, LEAD, flaggedAll closed. Highlights: the anti-vacuity floor is now derived from On the through-lineYour sentence for round 4 — correct on the branch it was written for, never traced to the branch the operator takes — is now the fixture discipline: B-1 asserts against the pinned 3.1.6 container, B-3 against the module that encrypts, M-2 against the client's raw login, B-2 against the sparse checkout the job actually uses. The sharpest evidence it works is M-7: the fix that closed your finding introduced a worse one, and tracing "what now consumes this generated value" — the derivation's public inputs — is exactly what surfaced it before it left the branch. Re-requesting. |
…R + deploy minors A cold adversarial self-review (three auditors mirroring the reviewer's method) of the r4 changeset found a data-loss BLOCKER we introduced this round, plus deploy minors. Fixing before it ships. B-3 (BLOCKER, data loss): the r4 encryption-key unification made ENCRYPTION_KEY env OVERWRITE an existing at-rest key file on boot. Traced only to the "operator freshly sets the key" consumer, never to (a) backup_service restore, which writes the backup's key to that file, or (b) the r3->r4 upgrade population, whose data was encrypted under a marker-less auto-gen FILE key (r3 core.encryption used the file regardless of env). The first r4 boot clobbered those -> restored/existing data undecryptable (or bricked the boot on a marker mismatch). A new test even LOCKED the clobber with a false premise. Fix: the at-rest key FILE is the single source of truth. ENCRYPTION_KEY only SEEDS the file when it is ABSENT and never overwrites an existing one; the gate reads the FILE's .operator provenance. backup restore now drops the .operator marker so a restored key passes the gate without a clobber. Rewrote the clobber-locking tests to lock the no-clobber invariant; added upgrade-shape, production-fail-without-data-loss, and restore-marker tests. M-7 (deploy self-review): removed the rotation-marker from secretsChecksum — the auditor proved it redundant (the same .data change already moves the digest; deleting it left the test green) and its admin branch dead. Kept the input-hash; documented the genuine trilemma (cluster-less-template-stable / tracks-generated-rotation / unpredictable-secrets — pick two; determinism is the predictable-secret hole). M-10: the render guard's wildcard check is now an exact comma-split entry, matching the backend's `"*" in cors_origins` since r4, so a legitimate `https://*.example.com` is no longer blocked; the localhost check stays a substring to match the backend. .env.example: the admin-password template was an empty assignment that uncomments into a lockout; it now carries a replace-me placeholder. Verified: backend 8082 passed; config/encryption 66, backup 13; script-selftests all pass; helm lint/template clean (subdomain-wildcard passes, bare '*'/prod+localhost fail); ruff clean. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
@bonnyr-f5 — before you spend a round 5 on the round-4 machinery, I ran your own method against it: three cold auditors on the disjoint slices (credential/auth, deploy, release/CI), each given only the head SHA and told to execute, not eyeread. They found — and BLOCKER (self-caught) — the r4 B-3 encryption-key unification destroyed the backup-restore and upgrade consumers
The r4 fix made
Fix — the invariant that was missing: the at-rest key file is the single source of truth. Deploy minors (self-caught)
Held under attack (worth stating precisely)The release/CI slice reproduced clean: the pre-push Verified on |
Round 5 review —
|
| Added | Traces to | About |
|---|---|---|
--expect-version + pre-push verify step (release.yml:885, :1067) |
B-2 | product — the verifier ran after git push origin, and could not pass |
dryrun-release-tooling job, 57 lines |
B-2 "bonnyr's suggestion" | apparatus proving apparatus |
lint-commit-markers.test.sh (72), registry-overwrite-guard.test.sh (21), registry-tag-probe.test.sh (23) |
r4 minors | apparatus testing apparatus |
helm-secret-checksum.test.sh (44), ibm-compose-drift.test.sh (63) |
M-7, deploy minor | product |
| backend tests (512 lines across 7 files) | r4 credential minors | product |
--expect-version is defensible: B-2 was a genuine release-safety blocker and the pre-push step
needs to assert against the incoming version, which VERSION does not yet hold at that point.
The 57-line dry-run job is the gate-on-gate — a job whose purpose is to prove the verify step can
execute. It answers a fair question. It is also the shape that generates the next round, and it
is the one piece here I would not have asked for.
2 · Closure table
| # | Verdict | Evidence |
|---|---|---|
| B-1 admin nobody can log in as | CLOSED | Null-map form verified on Compose v5.3.1: unset → key omitted from container env; .env value → delivered; shell value → delivered. Upgrade path (existing .env carrying DEFAULT_ADMIN_PASSWORD=) resolves correctly — that was your open question and it holds. |
| B-2 pin verifier can never pass, runs after irreversible | CLOSED | verify-image-pins.sh exits 0 against the live registry. Now at :885/:1067, immediately before git push origin at :890/:1072. |
| B-3 SEC-006 gates a value that encrypts nothing | PARTIAL | Unification is right in config.py and backup_service restore now drops the .operator marker. But the invariant B-3 writes down is violated in the module it unified with — see §3. |
| M-1 fail-open marker | CLOSED | os.path.isfile — a directory is no longer provenance; key-absent + marker is a provisioning error. |
| M-2 MCP username canonicalisation | CLOSED | Normalisation confined to the reserved-name guard; test parametrised on ["MCP", " mcp ", " Mcp "] asserting authenticate_user(db, variant, …) — the consumer, not the canonical form. |
| M-3 detector-parity vacuous | CLOSED | Now asserts an exact copy count (5 and 3), so a drifting copy drops the count and reds. Suite green. |
| M-4 self-test harness accepts no-op tests | CLOSED | Requires exit 0 + a PASS line + no FAIL + an ALL PASS terminal, plus a floor derived from git ls-files. |
| M-5 release-rc pushes tag before notes | CLOSED | Tag created local (:550), notes (:562), push (:613). |
| M-6 four lint fixes uncovered | CLOSED | lint-commit-markers.test.sh green on all four arms. |
| M-7 checksum/secret doesn't move on rotation | CLOSED | Three mcpPassword values → three distinct digests; same value twice → identical. The redundant rotation-marker you flagged was deleted, not extended. |
| M-8 install.sh whitespace fail-open | CLOSED | Trim → strip one quote layer → trim. |
M-9 forward-dated 4.0.0 in docs |
CLOSED | Zero hits. |
M-10 default helm install crashloops |
CLOSED | Default render exits 0 and generates every secret; ALLOWED_ORIGINS: "" boots. Guard mirrors the backend exactly — including the * test, which was changed on both sides to an exact-entry check ("*" in self.cors_origins). https://*.example.com under production now renders and boots on both. |
M-11 GNU-only sed -i |
CLOSED | sed -i.bak … && rm, both sites. |
| M-12 published default DB creds | CLOSED (residual accepted) | Fresh installs generate; a pre-existing .env warns rather than rotating, because postgres bakes the password into the data volume on first init. Correct call. |
LEAD anti-vacuity floor stuck at -lt 5 |
CLOSED | Both sites derive EXPECTED from --list | grep -c .; --list now yields 9 paths including dist/VERSION. |
3 · Invariants
I-1 — One at-rest key file; nothing overwrites it once it holds bytes. B-3 states this in its
own docstring: "that file may hold the key under which live data was already encrypted, and
clobbering it makes that data undecryptable."
VIOLATED —
backend/core/encryption.py:46-67· BLOCKER · data loss
config.pyhonours the invariant.get_encryption_key()does not: file present but under 32
bytes →logger.warning("Invalid key … regenerating")→ falls through and overwrites the file
with a fresh key. The original key bytes are destroyed on disk, so restoring them is no longer
possible even from a keys-volume snapshot taken after the boot.Trigger: the key file is truncated or partially written — container killed mid-write, disk
full, a bad restore. Executed end to end:file truncated to 30 bytes; .operator marker still present: True config effective ENCRYPTION_KEY == truncated file : True classified auto_generated : False validate_production : PASSED (boots) --- core.encryption.get_encryption_key() --- returned key == original : False file OVERWRITTEN : True original key recoverable : False live data decryptable : False -> InvalidTokenThe production gate passes because the
.operatormarker is intact, so this happens on a
green production boot, silently. The marker still claims operator provenance afterwards, so
the next boot passes too.Fix (~5 lines): in
get_encryption_key(), when the file exists and is non-empty but
unusable, raise instead of regenerating. Fail closed — a crashloop is recoverable, an
overwritten key is not. The generate-and-persist path stays correct for a genuinely absent file.
I-2 — A container env var is delivered with a real value or omitted; never delivered empty.
Holds for DEFAULT_ADMIN_PASSWORD across all five compose files and the IBM installer (verified
above). MCP_SERVICE_PASSWORD still uses ${MCP_SERVICE_PASSWORD:-${MCP_PASSWORD:-}}, which
delivers "" — the form the invariant forbids. No reachable harm: the backend treats "" and
unset identically (if not self.MCP_SERVICE_PASSWORD), and the failure mode is a disabled MCP
integration, not a lockout. Noting the asymmetry, not filing it.
I-3 — One writer for the release version; everything else derives. Holds. --list yields 9
paths, both anti-vacuity floors derive from it, --check is the gate.
I-4 — One definition of the breaking-change marker; consumers embed, never redefine. Holds,
and is now count-asserted rather than enumeration-asserted, which closes the drift-in-the-token
hole that made the old parity test vacuous.
4 · Recommendation
MERGE once I-1 is fixed. Every round-4 finding is closed except B-3, and B-3's residue is one
function in one file with a five-line remedy. Nothing else in the round-5 diff has a trigger I can
name. The fixes this round were, unusually, aimed at the consumer rather than the changed line —
backup_service got the marker write, the auth test got the variant assertion, the backend and the
Helm guard were changed together so the * semantics match. That is the round-4 lesson actually
applied.
Notes for you — not findings, no action implied.
- The 57-line
dryrun-release-toolingjob is apparatus verifying apparatus. It has never run in
real CI. Keep or drop; either is defensible, and it is your call, not review debt. - Compose null-map resolution was verified on Docker Compose v5.3.1 only. podman-compose and
docker-compose v1 are unverified. If either is a supported install path, that is worth knowing;
if not, it is nothing. core/encryption.pysilently accepts any file ≥32 bytes without validating it as a Fernet key,
so a hand-provisioned key file in the wrong format surfaces as a cipher error at import rather
than as a clear message. Cosmetic next to I-1, same neighbourhood.
Independent confirmation of round-5 I-1 — the blocker is real, reproduced end-to-endI ran @bonnyr-f5's scenario against this PR's head Every line of the review's trace checks out. The chain:
One nuance: the regenerate-on-invalid code is verbatim pre-existing on staging — this PR's diff to The proposed ~5-line fix is right: file exists and non-empty but unusable → raise (crashloop is recoverable, an overwritten key is not); keep generate-and-persist for a genuinely absent file. An empty file is safe to regenerate over — zero bytes destroyed, and config already does. Bonny's note 3 (≥32 bytes accepted without Fernet validation) is cosmetic as stated: a malformed ≥32-byte key crashes at Repro script (runs standalone against the branch checkout)import os
import sys
import tempfile
sys.path.insert(0, "<checkout>/backend")
keys_dir = tempfile.mkdtemp(prefix="i1-keys-")
os.environ["KEYS_DIR"] = keys_dir
os.environ.pop("ENCRYPTION_KEY_FILE", None)
os.environ.pop("ENCRYPTION_KEY", None)
os.environ["ENVIRONMENT"] = "production"
# satisfy unrelated production gates
os.environ["JWT_SECRET_KEY"] = "x" * 64
os.environ["MCP_SERVICE_PASSWORD"] = "a-real-secret-value-123"
os.environ["ALLOWED_ORIGINS"] = "https://example.com"
os.environ["DEFAULT_ADMIN_PASSWORD"] = "SomeStrongPass123!"
from cryptography.fernet import Fernet, InvalidToken
key_path = os.path.join(keys_dir, "encryption.key")
marker = key_path + ".operator"
# Boot 0: operator provisions a valid key; live data gets encrypted under it.
original = Fernet.generate_key()
with open(key_path, "wb") as f:
f.write(original)
with open(marker, "w") as f:
f.write("")
ciphertext = Fernet(original).encrypt(b"live secret encrypted before the incident")
# Incident: partial write / truncation leaves 30 bytes of the original key.
with open(key_path, "wb") as f:
f.write(original[:30])
print(f"file truncated to {os.path.getsize(key_path)} bytes; marker present: {os.path.isfile(marker)}")
# Boot 1: config loads first (as in main.py) ...
from core.config import Settings
s = Settings()
with open(key_path, "rb") as f:
file_now = f.read()
print("config effective ENCRYPTION_KEY == truncated file :", s.ENCRYPTION_KEY.encode() == file_now.strip())
print("classified auto_generated :", s._encryption_key_auto_generated)
try:
s.validate_production()
print("validate_production : PASSED (boots)")
except SystemExit:
print("validate_production : SystemExit (refused)")
# ... then core.encryption imports at module load.
print("--- import core.encryption (runs get_encryption_key at import) ---")
import core.encryption as enc
with open(key_path, "rb") as f:
after = f.read()
print("returned key == original :", enc.FERNET_KEY == original)
print("file OVERWRITTEN :", after != original[:30])
print("marker still present :", os.path.isfile(marker))
try:
Fernet(enc.FERNET_KEY).decrypt(ciphertext)
print("live data decryptable : True")
except InvalidToken:
print("live data decryptable : False -> InvalidToken")
print("original key bytes anywhere on keys volume:", original in after or any(
original in open(os.path.join(keys_dir, p), "rb").read() for p in os.listdir(keys_dir)
)) |
…-rest key (I-1) bonnyr-f5 #193 round-5 closure blocker. B-3 wrote down the invariant "the at-rest key file is the single source of truth; nothing overwrites it once it holds bytes" and config.py honoured it -- but core.encryption.get_encryption_key() did not. A file present but under 32 bytes (truncated / partial write / disk full / bad restore) logged "Invalid key, regenerating" and OVERWROTE it with a fresh key, permanently destroying the key any existing data was encrypted under -- silently, on a GREEN production boot, because the intact .operator marker keeps validate_production passing. Fix: the read path now fails CLOSED. A genuinely absent (or 0-byte) file still generates and persists a new key. A file that HOLDS bytes is validated as a real Fernet key: valid -> returned untouched; unusable -> SystemExit with a clear message, never regenerated. A crashloop is recoverable; an overwritten key is not. This also closes r5 note #3 -- the old `len >= 32` check accepted any blob and surfaced a mis-shaped key as a later cipher error; it now Fernet-validates and says so plainly. Locked by TestAtRestKeyFileNeverRegeneratedOverBytes: a 30-byte truncated key -> SystemExit AND the original bytes survive on disk (recoverable); a valid key -> returned untouched; an absent file -> generates a valid key. Verified: backend 8086 passed; encryption unit tests 19 passed; ruff clean. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
mwiget
left a comment
There was a problem hiding this comment.
Re-review of d30e258e — I-1 closed, verified by execution. APPROVE
Scope: the one commit since @bonnyr-f5's round-5 closure review, which was MERGE once I-1 is fixed. Round-5's closure table is unchanged by this diff (37 lines in backend/core/encryption.py, 42 lines of new unit tests — nothing else touched), so this pass is I-1 plus a regression sweep of the function it rewrites.
I-1 — closed
Same repro I used to confirm the blocker on 7c0b5564 (isolated KEYS_DIR, py3.11, pinned cryptography==50.0.0 / pydantic-settings==2.7.1), re-run against the new head:
file truncated to 30 bytes; marker present: True
config effective ENCRYPTION_KEY == truncated file : True
classified auto_generated : False
validate_production : PASSED (boots)
--- import core.encryption (runs get_encryption_key at import) ---
import : SystemExit -> FAILS CLOSED
FATAL: the at-rest encryption key at .../encryption.key exists but is not a
valid Fernet key (...). Refusing to regenerate over it -- ...
file PRESERVED (still the truncated original bytes): True
marker still present : True
--- recovery: operator restores the good key file ---
import after restore : SUCCEEDED
returned key == original : True
live data decryptable : True
The three properties the fix had to have all hold: the boot stops, the file on disk is not clobbered, and restoring a good key file recovers — the exact same process comes back up and decrypts data written before the incident. validate_production still passes on the truncated key (marker intact), which is fine: the defence is now at the layer that actually loads the key.
The SystemExit lands during a startup import — main.py:61 → routes.cloud_auth:14 → core.encryption:104 — so it is a real crashloop, not a lazy failure inside a request. Nothing in main.py or core/ catches it with a bare except:, and except Exception cannot.
Regression sweep — old vs. new, every key-file state
Loaded both revisions of the module side by side against a fresh key file per case:
| key file state | OLD 7c0b5564 |
NEW d30e258e |
|---|---|---|
| truncated 30B | booted; file overwritten | SystemExit; file preserved |
| garbage 40B | ValueError at module import |
SystemExit; file preserved, clear message |
| valid key | booted; untouched | booted; untouched |
| valid key + trailing newline | booted; untouched | booted; untouched |
| 0-byte file | regenerates | regenerates |
| whitespace-only | regenerates | regenerates |
| absent | generates + persists | generates + persists |
The truncated row is what makes the new test_truncated_key_fails_closed_and_is_not_overwritten non-vacuous — on the parent commit that path boots and overwrites, so the test reds there. The happy paths are byte-identical to before.
Round-5 note 3 is closed as a side effect (garbage 40B): a mis-shaped ≥32-byte key used to surface as ValueError: Fernet key must be 32 url-safe base64-encoded bytes from module-level Fernet(FERNET_KEY). It now surfaces as the named FATAL with recovery instructions.
The two paths that still regenerate — traced to production, not reasoned about
get_encryption_key() still falls through to generate-and-persist when the file is empty or unreadable (the new code routes both PermissionError and generic read errors to existing = b""). I chased both to their production behaviour rather than arguing from the code:
- 0-byte key file (the partial write that never got past
open(…, 'wb')) —_persist_or_load_keysees empty content, so with a.operatormarker it is M-1's provisioning error and without one it is a marker-less key: either wayauto_generated=True, and SEC-006's fail-fast exits the boot withENCRYPTION_KEY was not explicitly setbeforecore.encryptionis imported. Executed both arms; both refuse to boot. - Unreadable key file (the keys-volume ownership case the function's own warning cites) — same classification, same refusal;
import core.configexits 1. The file is not overwritten either, because the write is denied for the same reason the read was.
So in ENVIRONMENT=production there is no remaining path where a key file that ever held bytes gets regenerated over. In development both still regenerate silently, which is pre-existing and, with no production data at stake, not worth a gate.
CI
Green on d30e258e, rollup settled: 30/30, P3 Integration and both P4 jobs included. P1 · Unit Tests · Backend is where the three new tests run.
Nothing further from me. I-1 was the last open item and it is closed the way the review asked for: fail closed, because a crashloop is recoverable and an overwritten key is not.
|
@bonnyr-f5 — I-1 fixed, I-1 (BLOCKER) —
|
Round 6 review —
|
| machinery | product | ratio | |
|---|---|---|---|
r4 5886be3c |
3347 | 2456 | 1.36:1 |
r5 7c0b5564 |
3895 | 2929 | 1.33:1 |
r6 d30e258e |
3895 | 2959 | 1.32:1 |
Machinery is flat — no script, gate, job, flag or harness was added to fix I-1. The fix is 30
lines in the file that had the bug and 42 lines of test. Nothing here traces to a finding about
review apparatus, so there is nothing for the next round to review. That is the whole point.
2 · Closure table
| # | Verdict | Evidence |
|---|---|---|
| I-1 at-rest key regenerated over existing bytes (data loss) | CLOSED | Five cases, each in an isolated process against the real module (table below). |
| B-3 (was PARTIAL on I-1) | CLOSED | I-1 was its only residue. |
| r5 Note 3 — any file ≥32 bytes accepted without Fernet validation | CLOSED | Fixed in the same change; a mis-shaped 44-byte file now fails closed instead of surfacing later as a cipher error. |
| All other r4 findings | CLOSED at r5, untouched here | Delta is 2 files. |
case outcome key file
1 truncated (30B) SystemExit preserved <- the I-1 trigger
2 mis-shaped 44B SystemExit preserved <- r5 Note 3
3 valid key returned, decrypts live data untouched
4 absent file generated + persisted created
5 empty file (0B) generated + persisted created
Two details worth recording because they are the difference between a fix and a real fix:
- The failure lands at import, not at call.
SystemExitpropagates out of the module-level
cipher init, so the container refuses to start with the operator message rather than running and
failing per-request.SystemExitderives fromBaseException, so the oneexcept BaseException
in the tree (services/reachability/breaker.py:228,258) cannot swallow it — it is not on an
import path. - The test asserts the harm, not the mechanism.
test_truncated_key_fails_closed_and_is_not_overwritten
checksf.read() == truncated— the file still holds the original bytes — not merely that
something raised. Mutation-checked: neutering the validation to always pass produces exactly
1 failed, 18 passed, and that one failure is this test. It is not vacuous.
Regression check: the full backend unit suite is 4857 passed at this head, so an import-time
SystemExit on a bad key file breaks no existing consumer.
The error message also does the operationally right thing: it tells the operator to restore from a
keys-volume backup and states the one condition under which deleting the file is safe (no encrypted
data yet). That is the difference between a crashloop someone can recover from and one they guess at.
3 · Invariants
I-1 — One at-rest key file; nothing overwrites it once it holds bytes. Now enforced in both
modules, not just stated in one. config.py never overwrites; core/encryption.py fails closed
rather than regenerating. The two modules that B-3 unified now agree.
I-2 / I-3 / I-4 — unchanged and untouched by this delta (container env contract, release-version
single-sourcing, breaking-change marker single-definition). Still holding as recorded at r5.
4 · Recommendation
MERGE. Every blocker, major and minor from rounds 2–5 is closed, and the last one closed without
adding surface. #192 closes on merge.
For the record, since it is the reason this sequence ended: the two rounds that converged are the two
where the fix went to the consumer rather than the changed line — r5 put the .operator marker in
backup_service, the variant assertion in the auth test, and the * semantics in both the backend
and the Helm guard; r6 put the enforcement in the module that actually held the destructive write.
Rounds 2–4 fixed the line and grew a gate.
Nothing carried forward as review debt. The two r5 Notes that remain are yours to close or drop,
and neither blocks: the 57-line dryrun-release-tooling job is apparatus verifying apparatus, and
compose null-map resolution is verified on Docker Compose v5.3.1 only.
On merge, I-1..I-4 go into the invariant registry so the next feature's first-pass review sweeps for
them cheaply. The invariants are the durable output of these six rounds. The apparatus is not.
Coordinated integration of the seven #177 follow-up PRs
Per #192 (bonnyr-f5's cross-PR conflict analysis), these seven PRs share the credential surface and the release/CI surface and cannot merge in arbitrary order — the #186×#188 pair conflicts on ~17 files including both auth test modules, with a coupling where the reserved-name guard needed by #186 lives in #188. This branch resolves the whole stack once, in #192's order, and validates the merged tree.
Merge order:
#179 → #180 → #181 → #188 → #186 → #183 → #182Included (each fully reviewed across rounds 2–6):
:latestguards + HTTP-status registry probeis_service_account), v2_154/v2_155 migrations, remediationCredential-surface resolution (#186×#188): combined, not either-or — single
_RESERVED_HUMAN_USERNAMESguard, #188's provenance + unconditional stale-disable + migrations plus #186's published-default rotation + backendMCP_SERVICE_PASSWORDwiring + threadpool. The provenance guard only adopts/rotates a non-service row when it still holds a published default;adminis refused unconditionally.Validation (on the merged tree): ruff clean; mypy = staging baseline; 172 auth/credential/startup/ws/migration tests pass; single alembic head
v2_155; openapi + regenerated frontend types fresh (tscclean);helm lint/templategreen (shipped-defaultchangemecorrectly fails the render guard);docker compose configgreen on all modes (backend + mcp both getMCP_SERVICE_PASSWORD); version/detector/sync self-tests green; commit-lint clean over the range.Supersedes the seven individual PRs, which will be closed unmerged (branches + archive tags retained for history). Closes #192.