CB-592: shadow the admin GITEA_ACCESS_TOKEN in every member's herdr overlay #78

Closed
agent wants to merge 0 commits from worker/cb592-env-leak-3cbf9c-1 into main
Member

Fixes #77.

What changed

HerdrPeerLauncher.baseEnv now puts a non-blank sentinel value for GITEA_ACCESS_TOKEN into the env map handed to herdr, applied after the profile's own env: entries so no profile (present or future) can restore the real admin token by naming it in config. This is the one place both adapters (ClaudeCodeLauncher, OpenCodeLauncher) call before building their launch, so every profile — including ones that do not exist yet — gets the shadow for free.

applyGitToken (CB-302's GITEA_TOKEN grant) is untouched and still works.

Why a non-blank sentinel, not ""

herdr's env map is an overlay onto its own (login-shell) process environment, not a replacement of it. WorkspaceControl.createTab/splitPane only send the keys explicitly in the map (if (env != null && !env.isEmpty()) params.put("env", env)), so a key that never appears in it passes straight through from herdr's shell — which is exactly how the admin token leaks today.

I could not verify, by reading this codebase, whether an empty-string overlay value overrides an inherited variable at herdr's end, or is skipped as blank — herdr's merge logic is external to this repo, and I cannot spawn a live probe (bridge_spawn is lead-only). Rather than guess, I sidestepped the question: a non-blank replacement value is the one shape this codebase already relies on working, per baseEnv's own javadoc/history — the CB-511 PATH seeding exists specifically because a non-blank overlay entry does override an inherited one (that's the whole reason a worker's toolchain stopped depending on the daemon's own stale PATH). So the fix reuses that proven-reliable shape instead of the unverified one.

What I could NOT prove

  • Whether the fix actually neutralizes the token on a live spawn — I have no way to call bridge_spawn or run the gitea issue's probe from a worker. The lead's live spawn-and-probe is the closing verification for acceptance criterion 1.
  • Whether an empty-string overlay value would also have worked — deliberately untested since the fix avoids relying on it.

Explicitly out of scope (per the ticket)

  • CONTEXT7_TOKEN / AI_GATEWAY_TOKEN — left untouched, operator's call (criterion 5).
  • opencode.json — already fixed on main.
  • CLAUDE.md wording (criterion 7) — the lead is handling that.
  • bridged.yaml — not visible to me (gitignored) and not the fix per the ticket.

Tests

Added to both ClaudeCodeLauncherTest and OpenCodeLauncherTest, asserting on what tab.create's params actually carry (via the existing startEnv helper), not an internal map built in the test:

  • everySpawnShadowsTheAdminGiteaAccessToken — every spawn sends a non-blank GITEA_ACCESS_TOKEN overlay value.
  • aProfileEnvEntryCannotRestoreTheAdminGiteaAccessToken (Claude only) — a profile's own env: cannot smuggle the real token back in, pinning the shadow-applied-last ordering.

mvn clean install: BUILD SUCCESS, Tests run: 805, Failures: 0, Errors: 0, Skipped: 0.

Fixes #77. ## What changed `HerdrPeerLauncher.baseEnv` now puts a non-blank sentinel value for `GITEA_ACCESS_TOKEN` into the env map handed to herdr, applied *after* the profile's own `env:` entries so no profile (present or future) can restore the real admin token by naming it in config. This is the one place both adapters (`ClaudeCodeLauncher`, `OpenCodeLauncher`) call before building their launch, so every profile — including ones that do not exist yet — gets the shadow for free. `applyGitToken` (CB-302's `GITEA_TOKEN` grant) is untouched and still works. ## Why a non-blank sentinel, not `""` herdr's env map is an **overlay** onto its own (login-shell) process environment, not a replacement of it. `WorkspaceControl.createTab`/`splitPane` only send the keys explicitly in the map (`if (env != null && !env.isEmpty()) params.put("env", env)`), so a key that never appears in it passes straight through from herdr's shell — which is exactly how the admin token leaks today. I could not verify, by reading this codebase, whether an **empty-string** overlay value overrides an inherited variable at herdr's end, or is skipped as blank — herdr's merge logic is external to this repo, and I cannot spawn a live probe (`bridge_spawn` is lead-only). Rather than guess, I sidestepped the question: a **non-blank** replacement value is the one shape this codebase already relies on working, per `baseEnv`'s own javadoc/history — the CB-511 `PATH` seeding exists specifically because a non-blank overlay entry does override an inherited one (that's the whole reason a worker's toolchain stopped depending on the daemon's own stale `PATH`). So the fix reuses that proven-reliable shape instead of the unverified one. ## What I could NOT prove - Whether the fix actually neutralizes the token on a **live spawn** — I have no way to call `bridge_spawn` or run the gitea issue's probe from a worker. The lead's live spawn-and-probe is the closing verification for acceptance criterion 1. - Whether an empty-string overlay value would also have worked — deliberately untested since the fix avoids relying on it. ## Explicitly out of scope (per the ticket) - `CONTEXT7_TOKEN` / `AI_GATEWAY_TOKEN` — left untouched, operator's call (criterion 5). - `opencode.json` — already fixed on `main`. - `CLAUDE.md` wording (criterion 7) — the lead is handling that. - `bridged.yaml` — not visible to me (gitignored) and not the fix per the ticket. ## Tests Added to both `ClaudeCodeLauncherTest` and `OpenCodeLauncherTest`, asserting on what `tab.create`'s params actually carry (via the existing `startEnv` helper), not an internal map built in the test: - `everySpawnShadowsTheAdminGiteaAccessToken` — every spawn sends a non-blank `GITEA_ACCESS_TOKEN` overlay value. - `aProfileEnvEntryCannotRestoreTheAdminGiteaAccessToken` (Claude only) — a profile's own `env:` cannot smuggle the real token back in, pinning the shadow-applied-last ordering. `mvn clean install`: **BUILD SUCCESS**, `Tests run: 805, Failures: 0, Errors: 0, Skipped: 0`.
agent added 2 commits 2026-08-15 18:25:42 +02:00
Operator's rule, 2026-08-15: only the leader and architects may use GITEA_ACCESS_TOKEN;
everyone else uses WORKER_GITEA_TOKEN.

opencode.json is TRACKED, so it ships in every worker worktree, and it mounted the gitea
MCP with {env:GITEA_ACCESS_TOKEN}. A live probe confirmed that variable actually resolves
inside a member: herdr spawns each pane from its own login-shell environment and layers
the launcher's map on top, so a member sees 108 variables rather than the small explicit
set baseEnv appears to build. That gave an opencode member admin forge TOOLS — enough to
merge its own PR, which both CLAUDE.md and the member contract forbid.

This is the narrow half of the fix: it removes the tooling. The admin token is still
present as a string in every member's environment, which is the real defect and is
tracked as CB-592 (gitea #77) — that fix belongs in the launcher, in one place, not
per-profile in bridged.yaml where a sixth profile would silently reopen it.

.mcp.json keeps GITEA_ACCESS_TOKEN and is correct to: it is skip-worktree, the primary's
own local copy, and the primary is the lead. That is the pattern this change follows —
the shared tracked file grants least privilege, and anything needing more overrides
locally.
CB-592: shadow the admin GITEA_ACCESS_TOKEN in every member's herdr overlay
CI / contract (pull_request) Successful in 1m12s
CI / build (pull_request) Successful in 1m37s
3db5277ae8
herdr spawns a pane from its own login-shell process env and layers our map on
top, so any key baseEnv never mentions passes straight through — including the
admin forge token. baseEnv now puts a non-blank sentinel for
GITEA_ACCESS_TOKEN, applied after the profile's own env: so no profile can
restore it. One place, every adapter, every profile including future ones.
CB-302's GITEA_TOKEN grant (applyGitToken) is untouched.
Owner

Closing — already on main, merged locally by the lead, so Gitea never marked it merged.

831a918 Merge CB-592: shadow the admin GITEA_ACCESS_TOKEN in every member's environment
3db5277 CB-592: shadow the admin GITEA_ACCESS_TOKEN in every member's herdr overlay
0331ecd CB-592: add the BRIDGED_MEMBER marker — the sentinel alone cannot hold

Head sha 3db5277 is contained in main, hence the empty diff on this PR.

The two things this PR honestly said it could not prove are now proved, by a live spawn on 2026-08-16 (recorded on #79):

GITEA_ACCESS_TOKEN   len=43  prefix=blocke     <- the sentinel, live in a member
BRIDGED_MEMBER       len=1   value=1
WORKER_GITEA_TOKEN   len=40  prefix=7c682f     <- real, but gitea does not read this name

get_me       -> MCP error -32603: create gitea client err: invalid username, password or token
list_issues  -> MCP error -32603: (identical)

The failure is at client creation, not at scope — so acceptance criterion 1 holds on a live spawn, and the non-blank sentinel was the right call.

It held on a path nobody designed it for, too. A Claude Code member turns out to inherit the operator's user-scope ~/.claude.json servers, so it mounts 45 mcp__gitea__* tools including delete_branch and delete_file. They are all dead — and dead only because this shadow blocks the credential they read.

Criterion 5 (CONTEXT7_TOKEN / AI_GATEWAY_TOKEN) stayed out of scope here and is tracked on #79 in the 1.1 milestone.

Closing — **already on `main`**, merged locally by the lead, so Gitea never marked it merged. ``` 831a918 Merge CB-592: shadow the admin GITEA_ACCESS_TOKEN in every member's environment 3db5277 CB-592: shadow the admin GITEA_ACCESS_TOKEN in every member's herdr overlay 0331ecd CB-592: add the BRIDGED_MEMBER marker — the sentinel alone cannot hold ``` Head sha `3db5277` is contained in `main`, hence the empty diff on this PR. **The two things this PR honestly said it could not prove are now proved**, by a live spawn on 2026-08-16 (recorded on #79): ``` GITEA_ACCESS_TOKEN len=43 prefix=blocke <- the sentinel, live in a member BRIDGED_MEMBER len=1 value=1 WORKER_GITEA_TOKEN len=40 prefix=7c682f <- real, but gitea does not read this name get_me -> MCP error -32603: create gitea client err: invalid username, password or token list_issues -> MCP error -32603: (identical) ``` The failure is at **client creation**, not at scope — so acceptance criterion 1 holds on a live spawn, and the non-blank sentinel was the right call. It held on a path nobody designed it for, too. A Claude Code member turns out to inherit the operator's user-scope `~/.claude.json` servers, so it mounts 45 `mcp__gitea__*` tools including `delete_branch` and `delete_file`. They are all dead — and dead *only* because this shadow blocks the credential they read. Criterion 5 (`CONTEXT7_TOKEN` / `AI_GATEWAY_TOKEN`) stayed out of scope here and is tracked on **#79** in the 1.1 milestone.
ltms closed this pull request 2026-08-16 16:49:22 +02:00
Some checks are pending
CI / contract (pull_request) Successful in 1m12s
CI / build (pull_request) Successful in 1m37s

Pull request closed

Sign in to join this conversation.