HOMELAB-2682: feat(forgejo): refuse forge writes on the operator's credential #118
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-2682-forge-write-refuse"
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?
HOMELAB-2682 — every forge write refuses on the operator's credential
The incident this closes
A worker opened GITS #219 at 2026-11-07 ~02:50Z through
forgejo_create_pr.The tool read
FORGEJO_API_KEY, which on the fleet hosts is the operator's key,and the POST went out as
aaron. The PR was closed and kept as the record.The comment tools had already been hardened (HOMELAB-2527 assert-the-declared-
identity; HOMELAB-2682's follower: an undeclared write on the operator's token
refuses). The defect was therefore narrower than "no writer asserts identity": it
was the write tools that carry no identity of their own and never asked.
Inventory — every write-capable path in
forgejo/, and its state BEFORE this PRRead from the code at
origin/main(55518b3), not guessed.forgejo_create_prPOST /repos/{owner}/{repo}/pullsassertNonOperatorWriteCredentialforgejo_create_repoPOST /user/reposforgejo_comment_on_prPOST /repos/…/issues/{n}/comments(+ read-back)assertWriteIdentity: declared identity asserted, operator declaration refused, and an undeclared write on the operator's token refusedforgejo_comment_as(comment-as.ts)POST …/issues/{n}/comments(+ read-back)forgejo_merge_prPOST …/pulls/{n}/mergeassertMergeIdentityon the landing credential (FORGEJO_LANDING_TOKEN_FILE→FORGEJO_LANDING_TOKEN→ sanctioned OpenBaokv/pi/forgejo-landing), login must bepi-landing; never falls back toFORGEJO_API_KEYcreateWebhook(api.ts)POST /repos/…/hooksindex.ts)verdict-cli.ts(--post)POST …/issues/{n}/comments--token-fileand asserts the author it read backforgejo-ciextension (forgejo_ci_status,forgejo_ci_failures)/user/login), not a forge writeReads (
list_*,get_*,search,landing_census,list_pr_comments) areuntouched: this order is writes only. One test pins that a read on the
operator's credential still answers.
The guard (one, shared)
forgejo/api.ts→assertNonOperatorWriteCredential(cfg, { action }):GET /userwith the very credential the write would carry — identity,not the token string, so a rotated operator token is refused identically and
a sandbox credential that merely occupies the same variable is permitted.
isOperatorIdentity, the sameOPERATOR_IDENTITIES = ["aaron"]sourcecomment-as/assertWriteIdentityalready use), in any case.
author cannot be established is exactly the write this order forbids, and the
comment tool has refused it since HOMELAB-2527.
The refusal says the credential resolves to the operator's login, names
GITS #219 and the ambient-key fallback, and states that the write must go
through the pi-landing route (
FORGEJO_LANDING_TOKEN_FILE, a 0600 file;FORGEJO_LANDING_TOKEN; or the sanctioned OpenBao landing pathkv/pi/forgejo-landingwithFORGEJO_LANDING_IDENTITY=pi-landing), and thatreads keep using
FORGEJO_API_KEY. Zero writes are sent — the refusal isreturned before the POST, not after it.
No parsing was touched (no
parseRepoRef, no trajectory/manifest parser), and nolock grammar was touched (
landing.tsis not in the diff).Sandboxes: checked, and it does not break them
The order's condition was "check nothing breaks in sandboxes, where that variable
is a different credential". Two ways, both in the diff's tests and in the code:
write-credential-2682.test.tsGREEN (sandbox) cases: withFORGEJO_API_KEYset to a non-operator credential whoseGET /useranswerssandbox-worker, bothforgejo_create_prandforgejo_create_repoproceed —exactly one POST each — and the POST is proven to carry that credential
(
tokenOf(post) === SANDBOX_TOKEN): the guard asserts a credential, it neversubstitutes one. The assertion is also proven to precede the POST.
however it is rotated or named, is cleared as long as the forge says it is not
the operator's login.
The GITS sandbox loadtest only asserts that
forgejo_create_pris registered(
scripts/gits-sbx-forgejo-ext-loadtest.sh,EXPECTED=(…)), which is unchanged.Test counts: red → green
forgejo/tests/write-credential-2682.test.ts(new, 7 tests):origin/main(55518b3), same test file: 5 failed / 2 passed— create_pr refused-on-aaron, create_pr case-insensitive, create_pr
unreadable-identity, create_pr GET-before-POST ordering, create_repo
refused-on-aaron;
Full suite (
npx vitest run, forgejo): 48 files, 1192 tests passed (1185pre-existing + 7 new).
npx tsc --noEmit: clean.Operational note on that suite run (not caused by this diff): the mgmt VM shell
exports
FORGEJO_API_KEY_FILE, and the read path prefers a file over theenvironment (HOMELAB-2682), so
tests/merge-identity.test.ts("keeps using theconfigured credential for ordinary reads") fails while that variable is exported.
env -u FORGEJO_API_KEY_FILE npx vitest run→ 1192/1192. The new suite alsoclears the variable per test for the same reason.
Where the guard could affect a legitimate flow (stated, not hidden)
refused. That is the order's intent ("refuses when the token is the
operator's"); the operator's route is the Forgejo UI, or a non-operator
credential. Not a silent break: the refusal names both the reason and the route.
read:userscope can no longer create PRs orrepos — fail closed. The comment tool has had exactly this property since
HOMELAB-2527, so no seat that can post a verdict loses a write it could still
perform.
detailsis now{ refused, actual?, reason?, pr? | repo? }(mirroringCommentToolDetails)instead of the bare API object, so the tool's inferred result type stays
stable across the refusal and success branches. No consumer in the repo reads
detailsfrom these two tools (grep: only tool-name inventories inhomelab), and both tools' text results are unchanged apart from create_prnow printing its write identity.
forgejo_merge_pris not covered by this guard and does not need to be:it already refuses anything but the landing login and explicitly forbids the
FORGEJO_API_KEYfallback. If that ever regresses, the census/merge testsare what would catch it, not this guard.
Not done here (deliberately)
No merge, no deploy, no verdict, no Plane write, no images. Branch only:
plane/HOMELAB-2682-forge-write-refuse.Verdict of record — ld-reviewer — head
7822867181: APPROVEmodel: deepseek-flash
session: 01a1b475-c9c6-702c-a118-437721592fc1
Blockers
None.
Exposure: git objects of Homelab/pi-extensions at 55518b35..78228671 only; no PR comments, census, or ticket thread read; my only write is this verdict posted as ld-reviewer.
Verdict of record — ld-reviewer — head
7822867181: APPROVEmodel: deepseek-flash
session: 01a1b475-c997-702c-a118-4374c365aa4f
Blockers
None.
Exposure: git objects of Homelab/pi-extensions at 55518b35..78228671 only; no PR comments, census, or ticket thread read; my only write is this verdict posted as ld-reviewer.
The blocker raised in review 23137 is resolved on head
7822867181.The blocker raised in review 23138 is resolved on head
7822867181.Verified against head
7822867181, per finding:Exposure:statement sitting inside its## Blockerssection, which readsNone.— it states read scope and the one write it made, and names no defect in the change.## BlockersreadsNone., and the line read as a finding is itsExposure:statement, not a defect.Exposure
Read: Homelab/pi-extensions git objects at 55518b35..78228671, PR 118's comment set and commit status through the ld-reviewer credential, and the deployed lock compiled from the drop's forgejo/landing.ts; no edit, no commit, no push, no Plane write; my only write is this comment, posted as ld-reviewer.