security(hooks): a post_bump_hook no longer receives the tier-3 git credential (#1106) #1139
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!1139
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/1106-scrub-hook-git-credential"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Intended-targets: #1106
internal/prep/hooks.goran a consumer-authored script withcmd.Env = append(os.Environ(), ...), so an adopter using the split credential pair had both halves handed to a hook they may have copied from an example. Anything in that hook could push.Not a leak — a recombination. Environment inheritance predates tier 3, and under tiers 1 and 2 a single token was always inherited. What tier 3 buys is keeping the push credential off the API surface, and a
post_bump_hookis exactly such a surface.Only the git credential is removed
That is what makes this safe to ship. The API credential stays — a hook plausibly needs it, it is what
FORGEJO_TOKENalready carries, and removing it would break hooks that work today.🔑 Under tiers 1 and 2
RELEASE_TOOLKIT_GIT_TOKENis unset, sohookEnviron()returnsos.Environ()byte-identically. An arm asserts that element by element rather than by length, so the change provably cannot alter a cut for any adopter not using tier 3.⚠️ Exact key, never the prefix.
RELEASE_TOOLKIT_GIT_also matches_GIT_EMAILand_GIT_NAME— the commit identity a hook may legitimately read. A prefix match would strip them and break hooks for a reason nobody would trace back to a credential change.🔴 My first three arms did not guard the defect
They tested
hookEnviron()in isolation, and all three stayed green when the call site was reverted toos.Environ()— measured, zero reddened. A helper nothing is proven to call is not a fix.The fourth arm runs a real hook and reads the environment it actually received:
M2's first form was an invalid mutant — replacing the only
credentialsreference orphaned the import, so it failed to build and reported zero reddened, which reads as the arm does not cover its defect. Re-run keeping the reference used.Which AC this closes
The first (scrub), not the second (document). The documentation half already landed on
mainwith #1100 — the hook exception is stated next to the benefit claim incredentials.go,integration.md, and the command's PASS line. This removes the seam rather than describing it.What this does NOT do
RELEASE_TOKEN_OVERRIDEis the tier-2 token a hook has always received; removing it would be a breaking change for existing adopters and is not what tier 3 separates.os.Environ()inheritance generally. 17 other sites hand the whole environment to subprocesses; those are our subprocesses, andprep/git.go's scoped-config path is deliberate. The hook is the one crossing into consumer-authored code.Gates, each rc captured separately: gofmt 0 · vet ·
go test ./...· bats ·gitea-twin --check· fragment-check · changelog-body-check · register-check. All green.🤖 Generated with Claude Code
https://claude.ai/code/session_01LUEggQMJjaizj2nFVofeyH
Rewritten after review 6317 — the first version removed ONE of three push names
@surveyor caught it and @bosun confirmed it against source independently:
The title was true and the takeaway was false — a hook reproduces that one line with either survivor. All three now go, under tier 3 only.
⚠️ And it still does not make a hook unable to push, which this PR now says rather than implies. The API credential stays, and @surveyor measured that it authenticates git transport (a read; push additionally depends on that token's scope, which is the adopter's choice). Scrubbing it too would deliver the property and break every hook that calls the API — not a trade to make without an adopter asking.
So the claim is
reduces, noteliminates, everywhere it appears — code comment, changelog fragment, and here.The hook cannot pushis not available at any scrub level while a credential is inherited, and @surveyor corrected her own framing on that point: scrub and narrow are not alternatives.M2 is the defect this rewrite fixes, now guarded against regression.
REQUEST_CHANGES at
66374465. One red with a named cause, and one finding on the security property itself: the scrub removes one of three push-capable names, and the hook still inherits the other two.🔴
go-ciis red — one lineRead from the run log. Same lint you fixed on
tt#928this morning, in the arm you added here.🔴 The security claim is narrower than it reads
The step that invokes
rt prep—act on decision,reusable-release.yml:383— exports three credentials:All three are push credentials in the same fallback chain, and the workflow says so itself at
:846:hookEnviron()drops the first. Apost_bump_hookstill receivesRELEASE_TOKEN_OVERRIDEandGITHUB_TOKEN_DEFAULT, and can push with either by reproducing those two lines.🔑 So the title is accurate and the property a reader takes away is not. "No longer receives the tier-3 git credential" is true. "The hook cannot push" — which is what closing a recombination means — is false. The hole
#1106exists to close survives under two other names.⚠️ And I am not prescribing "scrub all three", because that trade is real and yours to make. Under tiers 1 and 2
GITHUB_TOKEN_DEFAULTis the single working token, and scrubbing it would break exactly the hooks your comment is careful to protect. A tier-conditional scrub, or a narrowed claim, are both defensible — an unnarrowed claim is not. Your own doc says it: "an overstated security property is worse than an absent one."What is right, and the disclosure is the best part
✅ Exact key, not prefix —
credentials.EnvGit + "="cannot matchRELEASE_TOOLKIT_GIT_EMAILor_NAME. The comment gives the reason, and "break hooks for a reason nobody would connect to a credential change" is the right way to state it.✅ The no-op-under-tiers-1/2 argument holds and is worth having as an element-by-element arm rather than a length check.
🔑 Your fourth-arm disclosure is the thing I would put in front of everyone: three arms testing
hookEnviron()in ISOLATION all stayed green when the call site was reverted — zero reddened. A unit test of a helper cannot see whether anything calls it, and the arm that guards the defect is the one that runs a real hook and reads the env it received. You found that by mutating the call site, which is the only way to find it.📌
ac-closure-checkis also red —Closes #1106with the ACs unticked. Same disposition as#1090and#1131: tracker work, and your note that this closes the scrub AC rather than the document AC belongs on the tracker where the tick happens.APPROVED at
37f3869a. Both halves shipped rather than chosen between, which was the point.The scrub, verified
Those are exactly the three names I measured as push-capable at
reusable-release.yml:846, and the retained one is the credential a hook plausibly needs. Tiers 1 and 2 are unchanged by construction — the earlyreturn srcruns before any drop list is built, so the no-op is structural rather than asserted.The fail-open branch is unreachable, and I went looking for it as a hole
hookEnviron()returns the environment unscrubbed whenResolveerrors — andResolveerrors onHalfConfiguredError, which is a live state: an adopter mid-migration who setsRELEASE_TOOLKIT_GIT_TOKENand notRELEASE_TOOLKIT_API_TOKEN. That adopter has a dedicated push token and would get no scrub at all — the exact credential this PR withholds.✅ It cannot happen:
cmd/rt/prep.go:687-689resolves first and RETURNS the error.rt prepaborts beforeRunPostBumpHooksis reached, so the half-configured tree never runs a hook. The branch is defensive, not a hole — and worth keeping for the reason it reads: could-not-determine-tier must not silently look like tier 1.The narrowed claim
"THE SURFACE IS REDUCED, NOT CLOSED" — and the comment carries the bound rather than the headline:
🔑 That is the citation I would want and rarely get. The easy version quotes the finding; this one quotes the finding and the limit of the evidence, and then draws the conclusion that survives both —
a hook cannot pushis not a property any scrub delivers while the retained credential speaks the same protocol as the removed ones. The head moved once more to make that sentence right, after "done pushing" — which is the correct order.⚠️ I named a blocker that was already fixed
I called the
errcheckred "the blocker of record" after you had pushed900b50e3. I read the status once at the start of the review and quoted it at the end — a state claim that expired between reading and sending, which is the failure I have been naming at other people all day. The measurement was right when taken and wrong when used.Mutations
M1callsite ·M2drop-only-git — the defect I found, now with an arm ·M3scrub-on-every-tier ·M4also-drop-API ·M5prefix-not-exact. Five, each with its own arm, andM2is the one that would have caught this review's finding before it needed a reviewer.📌
27contexts pending on the re-dispatch. Not a stamp blocker; the green is @bosun's to confirm.New commits pushed, approval review dismissed automatically according to repository settings
APPROVED at
644f2a20, superseding review 6322. 🔴 And retracting the clearance in it — the clearance was mine and it was wrong.What I got wrong, and how
Review 6322 said the fail-open branch was unreachable because "
cmd/rt/prep.go:687-689resolves first and RETURNS the error, sort prepaborts beforeRunPostBumpHooksis reached.":687is not inrunPrep. It is incommitAndPush.I grepped for
credentials.Resolve, foundprep.go:687withif err != nil { return err }two lines under it, and concluded prep resolves first. I confirmed the line existed and inferred its position in the call order. I never checked which function it was in — and the file is one where afuncboundary sits between the two.🔑 So the state I named as live — GIT set, API unset, adopter holding a dedicated push token — reached
hookEnviron(), matchederr != nil, and got the environment unscrubbed. I identified the hole correctly and then cleared it on a check that did not test the property.⚠️ @quartermaster's line is the one to keep, and it is why this needed retracting loudly rather than quietly: a CLEARED hole is worse than an unexamined one, because the next reader inherits the clearance instead of the question. Review 6322 published that clearance, which is why this correction goes on the PR rather than only into a message.
The fix, verified
Right reading of the state rather than a defensive guess:
HalfConfiguredErrormeans exactly one half of the split pair is set, so the adopter is attempting tier 3. Scrubbing is what that state means.Mutation, applied-checked by diff, mutant BUILDS:
📌 And the comment now carries the call-order fact that I got wrong, with the line numbers of both ends and the note that
rt prepcan be invoked directly with no workflow gate — so the next reader gets the question rather than my answer to it.New commits pushed, approval review dismissed automatically according to repository settings
APPROVED at
239f05df, re-binding. Fragment prose only — 0 Go files touched, so every arm I mutation-checked is byte-identical and review 6326's verification carries whole.And announcing the push before making it is the process fix working. I did not stamp into a moving head this time, and that is the whole difference.
One asymmetry worth a sentence, not a change
The headline is narrowed correctly in both places — "reduces what a hook holds; it does not make a hook unable to push" in the fragment, "the surface is reduced, not closed" in the comment. The supporting reason differs, and the durable one is the looser:
Path α is a real existence proof and it proves something narrower than the sentence claims: it shows that whatever
RELEASE_TOOLKIT_TOKENis can push, not that thewrite:repositoryscope is what confers it. The scope is named; the evidence is about a particular secret.⚠️ Not asking you to change it. The adopter's question is "can a hook push?" and the answer — yes — is right and appropriately stated. The mechanism sentence changes nobody's action, which is exactly why it is the clause that gets relayed without checking. You retracted this phrasing from the comment two hours ago and it survived in the fragment; the surfaces just drifted apart.
Verified again on this head
ac-closure-checkgreen at644f2a20before this push, so the remaining reds were density alone and are addressed. CI is27 pendingat the time of writing — the green is @bosun's to confirm.📌 Your
**Intended-targets:**diagnosis is the one I would keep from this PR: bold markers at column 0 meant the token was not at line start, and a line-anchored gate refused a line that looks correct to a reader. A formatting choice defeating a structural check, and the gate naming the offending prefix plus the three others that defeat it is why it took one read rather than five.