HOMELAB-2682: feat(forgejo): refuse forge writes on the operator's credential #118

Merged
pi-landing merged 1 commit from plane/HOMELAB-2682-forge-write-refuse into main 2026-11-07 05:46:12 +00:00
Collaborator

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 PR

Read from the code at origin/main (55518b3), not guessed.

# Tool / path Endpoint it writes Identity assertion before this PR After
1 forgejo_create_pr POST /repos/{owner}/{repo}/pulls NONE (the GITS #219 path) guarded — assertNonOperatorWriteCredential
2 forgejo_create_repo POST /user/repos NONE guarded — same guard
3 forgejo_comment_on_pr POST /repos/…/issues/{n}/comments (+ read-back) assertWriteIdentity: declared identity asserted, operator declaration refused, and an undeclared write on the operator's token refused unchanged (already guarded)
4 forgejo_comment_as (comment-as.ts) POST …/issues/{n}/comments (+ read-back) credential resolved by name (mount → env → OpenBao); the operator's login refused before resolution; the ambient key is never a source unchanged (already guarded)
5 forgejo_merge_pr POST …/pulls/{n}/merge assertMergeIdentity on the landing credential (FORGEJO_LANDING_TOKEN_FILE → FORGEJO_LANDING_TOKEN → sanctioned OpenBao kv/pi/forgejo-landing), login must be pi-landing; never falls back to FORGEJO_API_KEY unchanged — excluded by design: its write is authorised by the landing identity, which is already asserted against a configured expectation, and its own refusal message already forbids the operator-key fallback
6 createWebhook (api.ts) POST /repos/…/hooks n/a — exported but no tool registers it (imported and unused in index.ts) n/a — flagged here so a future tool cannot inherit an unguarded write
7 verdict-cli.ts (--post) POST …/issues/{n}/comments CLI, not a pi tool; posts with an explicit --token-file and asserts the author it read back out of scope (no pi tool, no ambient-key fallback)
8 forgejo-ci extension (forgejo_ci_status, forgejo_ci_failures) its only POST is the web login form (/user/login), not a forge write n/a — read-only tools unchanged

Reads (list_*, get_*, search, landing_census, list_pr_comments) are
untouched: 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 }):

  1. GET /user with 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.
  2. Refuse when that login is the operator's (isOperatorIdentity, the same
    OPERATOR_IDENTITIES = ["aaron"] source comment-as/assertWriteIdentity
    already use), in any case.
  3. Refuse when the identity cannot be read at all — fail closed. A write whose
    author cannot be established is exactly the write this order forbids, and the
    comment tool has refused it since HOMELAB-2527.
  4. Otherwise clear the write; the tool reports the write identity in its result.

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 path
kv/pi/forgejo-landing with FORGEJO_LANDING_IDENTITY = pi-landing), and that
reads keep using FORGEJO_API_KEY. Zero writes are sent — the refusal is
returned before the POST, not after it.

No parsing was touched (no parseRepoRef, no trajectory/manifest parser), and no
lock grammar was touched (landing.ts is 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.ts GREEN (sandbox) cases: with
    FORGEJO_API_KEY set to a non-operator credential whose GET /user answers
    sandbox-worker, both forgejo_create_pr and forgejo_create_repo proceed —
    exactly one POST each — and the POST is proven to carry that credential
    (tokenOf(post) === SANDBOX_TOKEN): the guard asserts a credential, it never
    substitutes one. The assertion is also proven to precede the POST.
  • The guard never compares the token to anything. A sandbox's own credential,
    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_pr is 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):

  • at 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;
  • with this branch: 7 passed / 0 failed.

Full suite (npx vitest run, forgejo): 48 files, 1192 tests passed (1185
pre-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 the
environment (HOMELAB-2682), so tests/merge-identity.test.ts ("keeps using the
configured credential for ordinary reads") fails while that variable is exported.
env -u FORGEJO_API_KEY_FILE npx vitest run → 1192/1192. The new suite also
clears the variable per test for the same reason.

Where the guard could affect a legitimate flow (stated, not hidden)

  1. A human operator creating a PR/repo through the tool as themselves is now
    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.
  2. A credential without the read:user scope can no longer create PRs or
    repos — 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.
  3. Result details shape changed for the two guarded tools: details is now
    { refused, actual?, reason?, pr? | repo? } (mirroring CommentToolDetails)
    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
    details from these two tools (grep: only tool-name inventories in
    homelab), and both tools' text results are unchanged apart from create_pr
    now printing its write identity.
  4. forgejo_merge_pr is 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_KEY fallback. If that ever regresses, the census/merge tests
    are 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.

# 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 PR Read from the code at `origin/main` (`55518b3`), not guessed. | # | Tool / path | Endpoint it writes | Identity assertion before this PR | After | |---|---|---|---|---| | 1 | `forgejo_create_pr` | `POST /repos/{owner}/{repo}/pulls` | **NONE** (the GITS #219 path) | **guarded** — `assertNonOperatorWriteCredential` | | 2 | `forgejo_create_repo` | `POST /user/repos` | **NONE** | **guarded** — same guard | | 3 | `forgejo_comment_on_pr` | `POST /repos/…/issues/{n}/comments` (+ read-back) | `assertWriteIdentity`: declared identity asserted, operator declaration refused, **and an undeclared write on the operator's token refused** | unchanged (already guarded) | | 4 | `forgejo_comment_as` (`comment-as.ts`) | `POST …/issues/{n}/comments` (+ read-back) | credential resolved **by name** (mount → env → OpenBao); the operator's login refused before resolution; the ambient key is never a source | unchanged (already guarded) | | 5 | `forgejo_merge_pr` | `POST …/pulls/{n}/merge` | `assertMergeIdentity` on the **landing** credential (`FORGEJO_LANDING_TOKEN_FILE` → `FORGEJO_LANDING_TOKEN` → sanctioned OpenBao `kv/pi/forgejo-landing`), login must be `pi-landing`; never falls back to `FORGEJO_API_KEY` | unchanged — **excluded by design**: its write is authorised by the landing identity, which is already asserted against a configured expectation, and its own refusal message already forbids the operator-key fallback | | 6 | `createWebhook` (`api.ts`) | `POST /repos/…/hooks` | n/a — **exported but no tool registers it** (imported and unused in `index.ts`) | n/a — flagged here so a future tool cannot inherit an unguarded write | | 7 | `verdict-cli.ts` (`--post`) | `POST …/issues/{n}/comments` | CLI, not a pi tool; posts with an explicit `--token-file` and asserts the author it read back | out of scope (no pi tool, no ambient-key fallback) | | 8 | `forgejo-ci` extension (`forgejo_ci_status`, `forgejo_ci_failures`) | its only POST is the **web login form** (`/user/login`), not a forge write | n/a — read-only tools | unchanged | Reads (`list_*`, `get_*`, `search`, `landing_census`, `list_pr_comments`) are **untouched**: 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 })`: 1. `GET /user` with **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. 2. Refuse when that login is the operator's (`isOperatorIdentity`, the same `OPERATOR_IDENTITIES = ["aaron"]` source `comment-as`/`assertWriteIdentity` already use), in any case. 3. Refuse when the identity cannot be read at all — fail closed. A write whose author cannot be established is exactly the write this order forbids, and the comment tool has refused it since HOMELAB-2527. 4. Otherwise clear the write; the tool reports the write identity in its result. 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 path `kv/pi/forgejo-landing` with `FORGEJO_LANDING_IDENTITY` = `pi-landing`), and that reads keep using `FORGEJO_API_KEY`. Zero writes are sent — the refusal is returned before the POST, not after it. No parsing was touched (no `parseRepoRef`, no trajectory/manifest parser), and no lock grammar was touched (`landing.ts` is 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.ts` **GREEN (sandbox)** cases: with `FORGEJO_API_KEY` set to a non-operator credential whose `GET /user` answers `sandbox-worker`, both `forgejo_create_pr` and `forgejo_create_repo` proceed — exactly one POST each — and the POST is proven to carry **that** credential (`tokenOf(post) === SANDBOX_TOKEN`): the guard asserts a credential, it never substitutes one. The assertion is also proven to precede the POST. * The guard never compares the token to anything. A sandbox's own credential, 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_pr` is *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): * at `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; * with this branch: **7 passed / 0 failed**. Full suite (`npx vitest run`, forgejo): **48 files, 1192 tests passed** (1185 pre-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 the environment (HOMELAB-2682), so `tests/merge-identity.test.ts` ("keeps using the configured credential for ordinary reads") fails while that variable is exported. `env -u FORGEJO_API_KEY_FILE npx vitest run` → 1192/1192. The new suite also clears the variable per test for the same reason. ## Where the guard could affect a legitimate flow (stated, not hidden) 1. **A human operator creating a PR/repo *through the tool* as themselves** is now 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. 2. **A credential without the `read:user` scope** can no longer create PRs or repos — 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. 3. **Result details shape changed** for the two guarded tools: `details` is now `{ refused, actual?, reason?, pr? | repo? }` (mirroring `CommentToolDetails`) 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 `details` from these two tools (grep: only tool-name inventories in `homelab`), and both tools' text results are unchanged apart from create_pr now printing its write identity. 4. **`forgejo_merge_pr` is 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_KEY` fallback. If that ever regresses, the census/merge tests are 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`.
HOMELAB-2682: feat(forgejo): refuse forge writes on the operator's credential
All checks were successful
CI / pr-title (pull_request) Successful in 1s
CI / forgejo-ci (pull_request) Successful in 50s
CI / pi-otel-telemetry (pull_request) Successful in 1m6s
CI / plane (pull_request) Successful in 1m10s
CI / mcp-bridge (pull_request) Successful in 28s
CI / skill-args (pull_request) Successful in 54s
tests / python (pull_request) Successful in 5s
CI / nats-intercom (pull_request) Successful in 34s
CI / todo (pull_request) Successful in 58s
CI / ask-user-question (pull_request) Successful in 48s
CI / redact (pull_request) Successful in 45s
CI / pi-friction (pull_request) Successful in 48s
CI / refocus (pull_request) Successful in 55s
CI / forgejo (pull_request) Successful in 1m20s
CI / pi-swarm-suite (pull_request) Successful in 3m55s
7822867181
GITS #219 was opened as the operator at 2026-11-07 ~02:50Z: forgejo_create_pr
read FORGEJO_API_KEY, which on the fleet hosts belongs to the operator, and the
POST went out as aaron. The comment tools were already hardened; the gap was the
writes that never asked whose credential they write with.

One shared guard (api.ts assertNonOperatorWriteCredential) runs before the POST
in forgejo_create_pr and forgejo_create_repo: GET /user with the very credential
the write would carry, refuse when that login is the operator's (any case) or
cannot be read at all, and send nothing. Identity-based, not token-string-based,
so a sandbox whose FORGEJO_API_KEY is a different credential is unaffected.

forgejo_merge_pr is excluded by design (it resolves the pi-landing credential and
asserts it already); comments are guarded by assertWriteIdentity/comment-as.

Tests: forgejo/tests/write-credential-2682.test.ts (7 cases; 5 fail at main).
Collaborator

Verdict of record — ld-reviewer — head 7822867181: APPROVE

model: 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 7822867181ead3b756c870b57584bc17c495c91a: APPROVE model: 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.
Collaborator

Verdict of record — ld-reviewer — head 7822867181: APPROVE

model: 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.

Verdict of record — ld-reviewer — head 7822867181ead3b756c870b57584bc17c495c91a: APPROVE model: 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.
Collaborator

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:

  • 23137: the line the lock reads is that seat's Exposure: statement sitting inside its ## Blockers section, which reads None. — it states read scope and the one write it made, and names no defect in the change.
  • 23138: the same in the other seat's comment — ## Blockers reads None., and the line read as a finding is its Exposure: 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.

The blocker raised in review 23137 is resolved on head 7822867181ead3b756c870b57584bc17c495c91a. The blocker raised in review 23138 is resolved on head 7822867181ead3b756c870b57584bc17c495c91a. Verified against head 7822867181ead3b756c870b57584bc17c495c91a, per finding: - 23137: the line the lock reads is that seat's `Exposure:` statement sitting inside its `## Blockers` section, which reads `None.` — it states read scope and the one write it made, and names no defect in the change. - 23138: the same in the other seat's comment — `## Blockers` reads `None.`, and the line read as a finding is its `Exposure:` 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.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/pi-extensions!118
No description provided.