CB-592: every member inherits herdr's whole environment, so workers carry the admin GITEA_ACCESS_TOKEN #77

Closed
opened 2026-08-15 18:20:34 +02:00 by ltms · 2 comments
Owner

Confirmed live on 2026-08-15 by a probe, not inferred. Operator's rule, stated the same day: "only
leader/arch allow to use GITEA_ACCESS_TOKEN. others use WORKER_GITEA_TOKEN."
We do not enforce that.

What was measured

A local member was spawned and asked to report which credential names are set — never values:

GITEA_ACCESS_TOKEN=SET      <-- admin forge token, must never reach a worker
WORKER_GITEA_TOKEN=SET
GITEA_TOKEN=SET             <-- the correct repo-scoped one, injected by CB-302
GITEA_HOST=SET
AI_GATEWAY_TOKEN=SET
CONTEXT7_TOKEN=SET
ANTHROPIC_BASE_URL=SET      <-- correct: bridge-injected for an off-subscription profile
ANTHROPIC_AUTH_TOKEN=SET    <-- correct: same
ANTHROPIC_API_KEY=unset
---
env | wc -l  ->  108

The primary's own pane reports 99 variables and ANTHROPIC_* all unset.

Why it happens

HerdrPeerLauncher.baseEnv builds a fresh LinkedHashMap holding only PATH and the profile's
env:. Reading our code alone, a member looks like it gets a small, explicit environment.

It does not. That map is an overlay, not the environment. herdr spawns the pane from its own
process environment and layers our map on top. herdr was started from a login shell, so it carries
everything ${SHARED_ENV}/tools/secrets.sh exports — including the admin GITEA_ACCESS_TOKEN. The
99-vs-108 gap is exactly the bridge's explicit additions on top of an inherited base.

Nothing in the launcher accounts for what sits underneath, because from inside the launcher there is
nothing to see.

Impact

  1. A worker can do anything the admin token can: merge its own PR, delete branches, close issues,
    and reach every repo the token reaches. That defeats CLAUDE.md's "the lead never delegates the
    merge" and the member contract's "Never merge" — those are currently honour-system, not enforced.
  2. CB-302's whole design is bypassed. We carefully inject a repo-scoped GITEA_TOKEN via
    gitTokenEnv so a worker gets the least privilege it needs. The admin token is simply sitting
    beside it.
  3. opencode.json turns it into a tool. That file is tracked (H, so it ships in every
    worktree) and mounts the gitea MCP with "GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}". Since
    the variable resolves, an opencode member gets admin forge tools, not just a string it would have
    to think to use.

Contrast: .mcp.json is S (skip-worktree, the primary's local copy) and references
GITEA_ACCESS_TOKEN — which is correct, because the primary is the lead.

Not affected

The subscription boundary holds. The primary's pane has no ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN,
so herdr's environment carries none, so a subscription: true member cannot inherit one and be pushed
off-subscription. SubscriptionGuard and the BridgedConfig hard-strip are not defeated by this.

Acceptance criteria

  1. A spawned member's environment does not carry a usable GITEA_ACCESS_TOKEN. Proven by the same
    probe: spawn, report names only, GITEA_ACCESS_TOKEN must not be usable.
  2. The fix lives in one place in the launcher, not per-profile in bridged.yaml. A per-profile
    env: entry is exactly the silent-default shape this repo has shipped nine times: add a sixth
    profile, forget the line, and the hole is back with nothing failing.
  3. GITEA_TOKEN (the CB-302 repo-scoped grant) still reaches members and still works — the point is
    least privilege, not no privilege. A worker must still be able to open its own PR.
  4. The tracked opencode.json names WORKER_GITEA_TOKEN, not GITEA_ACCESS_TOKEN. A shared file that
    ships in every worktree must grant the least privilege; anything needing more overrides locally,
    the way .mcp.json already does by being skip-worktree.
  5. Decide and document what happens to the other inherited credentials — CONTEXT7_TOKEN and
    AI_GATEWAY_TOKEN are also currently readable by every member. They are far less dangerous than an
    admin forge token, but "we chose to allow it" and "we never noticed" must not look the same.
  6. A test pins the behaviour, so a future refactor of baseEnv cannot quietly reopen it.
  7. CLAUDE.md's claim that a member mounts "only the bridge MCP" is corrected — it is already false
    for opencode members, which get context7 and gitea from the tracked opencode.json.

Note for whoever takes this

Verify how herdr merges the map before assuming a fix works. Setting a variable to the empty string in
the overlay is the obvious approach, but "does an empty value override an inherited one, or is it
skipped as blank?" is exactly the kind of thing that silently does nothing. Prove it with the probe
above, on a real spawn — not with a unit test alone.

**Confirmed live on 2026-08-15 by a probe, not inferred.** Operator's rule, stated the same day: *"only leader/arch allow to use GITEA_ACCESS_TOKEN. others use WORKER_GITEA_TOKEN."* We do not enforce that. ## What was measured A `local` member was spawned and asked to report which credential **names** are set — never values: ``` GITEA_ACCESS_TOKEN=SET <-- admin forge token, must never reach a worker WORKER_GITEA_TOKEN=SET GITEA_TOKEN=SET <-- the correct repo-scoped one, injected by CB-302 GITEA_HOST=SET AI_GATEWAY_TOKEN=SET CONTEXT7_TOKEN=SET ANTHROPIC_BASE_URL=SET <-- correct: bridge-injected for an off-subscription profile ANTHROPIC_AUTH_TOKEN=SET <-- correct: same ANTHROPIC_API_KEY=unset --- env | wc -l -> 108 ``` The primary's own pane reports **99** variables and `ANTHROPIC_*` all `unset`. ## Why it happens `HerdrPeerLauncher.baseEnv` builds a **fresh** `LinkedHashMap` holding only `PATH` and the profile's `env:`. Reading our code alone, a member looks like it gets a small, explicit environment. It does not. That map is an **overlay**, not the environment. herdr spawns the pane from its own process environment and layers our map on top. herdr was started from a login shell, so it carries everything `${SHARED_ENV}/tools/secrets.sh` exports — including the admin `GITEA_ACCESS_TOKEN`. The 99-vs-108 gap is exactly the bridge's explicit additions on top of an inherited base. Nothing in the launcher accounts for what sits underneath, because from inside the launcher there is nothing to see. ## Impact 1. **A worker can do anything the admin token can**: merge its own PR, delete branches, close issues, and reach every repo the token reaches. That defeats `CLAUDE.md`'s "the lead never delegates the merge" and the member contract's "Never merge" — those are currently honour-system, not enforced. 2. **CB-302's whole design is bypassed.** We carefully inject a repo-scoped `GITEA_TOKEN` via `gitTokenEnv` so a worker gets the least privilege it needs. The admin token is simply sitting beside it. 3. **`opencode.json` turns it into a tool.** That file is **tracked** (`H`, so it ships in every worktree) and mounts the gitea MCP with `"GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}"`. Since the variable resolves, an opencode member gets admin forge *tools*, not just a string it would have to think to use. Contrast: `.mcp.json` is `S` (skip-worktree, the primary's local copy) and references `GITEA_ACCESS_TOKEN` — which is **correct**, because the primary is the lead. ## Not affected The subscription boundary holds. The primary's pane has no `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN`, so herdr's environment carries none, so a `subscription: true` member cannot inherit one and be pushed off-subscription. `SubscriptionGuard` and the `BridgedConfig` hard-strip are not defeated by this. ## Acceptance criteria 1. A spawned member's environment does **not** carry a usable `GITEA_ACCESS_TOKEN`. Proven by the same probe: spawn, report names only, `GITEA_ACCESS_TOKEN` must not be usable. 2. The fix lives in **one place in the launcher**, not per-profile in `bridged.yaml`. A per-profile `env:` entry is exactly the silent-default shape this repo has shipped nine times: add a sixth profile, forget the line, and the hole is back with nothing failing. 3. `GITEA_TOKEN` (the CB-302 repo-scoped grant) still reaches members and still works — the point is least privilege, not no privilege. A worker must still be able to open its own PR. 4. The tracked `opencode.json` names `WORKER_GITEA_TOKEN`, not `GITEA_ACCESS_TOKEN`. A shared file that ships in every worktree must grant the least privilege; anything needing more overrides locally, the way `.mcp.json` already does by being skip-worktree. 5. Decide and document what happens to the other inherited credentials — `CONTEXT7_TOKEN` and `AI_GATEWAY_TOKEN` are also currently readable by every member. They are far less dangerous than an admin forge token, but "we chose to allow it" and "we never noticed" must not look the same. 6. A test pins the behaviour, so a future refactor of `baseEnv` cannot quietly reopen it. 7. `CLAUDE.md`'s claim that a member mounts "only the bridge MCP" is corrected — it is already false for opencode members, which get context7 and gitea from the tracked `opencode.json`. ## Note for whoever takes this Verify how herdr merges the map before assuming a fix works. Setting a variable to the empty string in the overlay is the obvious approach, but "does an empty value override an inherited one, or is it skipped as blank?" is exactly the kind of thing that silently does nothing. Prove it with the probe above, on a real spawn — not with a unit test alone.
Author
Owner

Live verification: the shadow did NOT hold. Root cause found.

I redeployed onto 831a918 (pid 31615, jar 7e981ec19df4), spawned a real local member and
probed its pane. GITEA_ACCESS_TOKEN inside the member was still the real admin token, not the
sentinel.

Why — measured, not guessed

The overlay works. It is the step after it that undoes the work.

  1. A herdr pane runs a login shell.
  2. ~/.zprofile line 41 sources ${SHARED_ENV}/tools/secrets.sh.
  3. That file does a plain, unconditional export GITEA_ACCESS_TOKEN=....
  4. A login shell overwrites a value that is already in the environment.

Confirmed directly:

GITEA_ACCESS_TOKEN=cb592-sentinel zsh -lc '...'
-> RESULT: sentinel was OVERWRITTEN by the login shell

So the real token is put back over our sentinel before the member process starts.

The overlay itself is fine. GITEA_TOKEN is injected the same way, is exported by no shell file,
and was observed set inside the live pane. The rule is narrow and clear:

A launcher env overlay survives for every name, except the names secrets.sh re-exports.
For those, no change in this repo can win.

Today that exception is exactly GITEA_ACCESS_TOKEN and WORKER_GITEA_TOKEN.

The 403 was my bad test, not a regression

I had the probe call /api/v1/user. A minimal write:repository token cannot read that endpoint,
so 403 is the correct answer. On a repo endpoint the same token is fine:

GITEA_ACCESS_TOKEN: /user=200  /repos/lms/claude-bridge=200
WORKER_GITEA_TOKEN: /user=403  /repos/lms/claude-bridge=200

CB-302 is intact. I withdraw that half of the report.

What shipped now — 0331ecd

Added BRIDGED_MEMBER=1 to every member's env. It is a name secrets.sh never exports, so nothing
overwrites it — the same mechanism GITEA_TOKEN already proves. Tests 805 → 807; both new tests
were shown to fail when the marker is reverted.

The sentinel stays. It is correct for any peer kind whose pane does not start a login shell, and it
keeps the intent visible at the one place every adapter passes through. The javadoc and test javadoc
no longer claim a protection that was measured not to hold.

What is left — one line, and it is not ours to write

${SHARED_ENV}/tools/secrets.sh is the operator's file. The whole remaining fix is:

[ -n "${BRIDGED_MEMBER:-}" ] || export GITEA_ACCESS_TOKEN=...

Waiting on the operator. Until then, the admin token is still readable by any member from a shell,
and the enforced boundary is the MCP mount only (6939e0c, opencode.json → WORKER_GITEA_TOKEN).

Wider lesson

This kills a whole class of fix. "Set the variable at spawn" is not a security control on this host
for any name the login shell re-exports. Anything that must not reach a member has to be either
absent from secrets.sh, guarded by the marker, or enforced somewhere other than the environment.

## Live verification: the shadow did NOT hold. Root cause found. I redeployed onto `831a918` (pid 31615, jar `7e981ec19df4`), spawned a real `local` member and probed its pane. `GITEA_ACCESS_TOKEN` inside the member was still the **real admin token**, not the sentinel. ### Why — measured, not guessed The overlay works. It is the step after it that undoes the work. 1. A herdr pane runs a **login shell**. 2. `~/.zprofile` line 41 sources `${SHARED_ENV}/tools/secrets.sh`. 3. That file does a plain, unconditional `export GITEA_ACCESS_TOKEN=...`. 4. A login shell **overwrites** a value that is already in the environment. Confirmed directly: ``` GITEA_ACCESS_TOKEN=cb592-sentinel zsh -lc '...' -> RESULT: sentinel was OVERWRITTEN by the login shell ``` So the real token is put back over our sentinel **before the member process starts**. **The overlay itself is fine.** `GITEA_TOKEN` is injected the same way, is exported by no shell file, and *was* observed set inside the live pane. The rule is narrow and clear: > A launcher env overlay survives for every name, **except** the names `secrets.sh` re-exports. > For those, no change in this repo can win. Today that exception is exactly `GITEA_ACCESS_TOKEN` and `WORKER_GITEA_TOKEN`. ### The 403 was my bad test, not a regression I had the probe call `/api/v1/user`. A minimal `write:repository` token cannot read that endpoint, so 403 is the *correct* answer. On a repo endpoint the same token is fine: ``` GITEA_ACCESS_TOKEN: /user=200 /repos/lms/claude-bridge=200 WORKER_GITEA_TOKEN: /user=403 /repos/lms/claude-bridge=200 ``` **CB-302 is intact.** I withdraw that half of the report. ### What shipped now — `0331ecd` Added `BRIDGED_MEMBER=1` to every member's env. It is a name `secrets.sh` never exports, so nothing overwrites it — the same mechanism `GITEA_TOKEN` already proves. Tests 805 → 807; both new tests were shown to fail when the marker is reverted. The sentinel stays. It is correct for any peer kind whose pane does not start a login shell, and it keeps the intent visible at the one place every adapter passes through. The javadoc and test javadoc no longer claim a protection that was measured not to hold. ### What is left — one line, and it is not ours to write `${SHARED_ENV}/tools/secrets.sh` is the operator's file. The whole remaining fix is: ```sh [ -n "${BRIDGED_MEMBER:-}" ] || export GITEA_ACCESS_TOKEN=... ``` Waiting on the operator. Until then, the admin token is still readable by any member from a shell, and the enforced boundary is the MCP mount only (`6939e0c`, `opencode.json` → `WORKER_GITEA_TOKEN`). ### Wider lesson This kills a whole class of fix. "Set the variable at spawn" is not a security control on this host for any name the login shell re-exports. Anything that must not reach a member has to be either absent from `secrets.sh`, guarded by the marker, or enforced somewhere other than the environment.
Author
Owner

VERIFIED ON A LIVE MEMBER PANE — the leak is closed

Operator added the guard to ${SHARED_ENV}/tools/secrets.sh:33:

[ -n "${BRIDGED_MEMBER:-}" ] || export GITEA_ACCESS_TOKEN=<admin>   # admin

Daemon redeployed onto 0331ecd — pid 75467, jar 4cb34bc1348d, 807 tests, /healthz 200,
herdr protocol 19, fresh bridged listening line, no ERROR lines, bridge_whoami still primary.

Then spawned a real local member and probed its pane. Raw output:

GITEA_ACCESS_TOKEN=PRESENT
WORKER_GITEA_TOKEN=PRESENT
GITEA_TOKEN=PRESENT
GITEA_HOST=PRESENT
BRIDGED_MEMBER=PRESENT

ADMIN=SENTINEL

worker-token repo endpoint: 200
admin-token  repo endpoint: 401

What each line proves

observation meaning
BRIDGED_MEMBER=PRESENT the marker survives the login shell — the mechanism works
ADMIN=SENTINEL the guard held; the real admin token never reached the pane
worker token → 200 CB-302 is intact, workers can still open PRs
admin token → 401 what the member holds is dead at the forge

The operator's rule — "only leader/arch use GITEA_ACCESS_TOKEN, others use
WORKER_GITEA_TOKEN"
— is now enforced at the environment, not only at the MCP mount.

Why it needed both halves

Neither half works alone:

  • The launcher sentinel alone was overwritten by the pane's login shell re-sourcing
    secrets.sh (previous comment).
  • The secrets.sh guard alone would leave GITEA_ACCESS_TOKEN simply unset, and an unset
    variable is inherited straight through from herdr's own process environment — which carries the
    real admin token.

Together the launcher writes a dead value and the guard stops the shell replacing it. That is why
ADMIN=SENTINEL rather than ADMIN being empty.

Standing constraint for anyone touching this

secrets.sh is outside this repo and is not covered by our tests. A future edit that drops the
guard re-opens the leak silently, and mvn will stay green. The pinned tests
(everySpawnShadowsTheAdminGiteaAccessToken, everySpawnMarksThePaneAsAMember,
aProfileEnvEntryCannotClearTheMemberMarker) protect only the launcher's half.

Closing.

## VERIFIED ON A LIVE MEMBER PANE — the leak is closed Operator added the guard to `${SHARED_ENV}/tools/secrets.sh:33`: ```sh [ -n "${BRIDGED_MEMBER:-}" ] || export GITEA_ACCESS_TOKEN=<admin> # admin ``` Daemon redeployed onto `0331ecd` — pid **75467**, jar `4cb34bc1348d`, 807 tests, `/healthz` 200, herdr protocol 19, fresh `bridged listening` line, no ERROR lines, `bridge_whoami` still `primary`. Then spawned a real `local` member and probed its pane. Raw output: ``` GITEA_ACCESS_TOKEN=PRESENT WORKER_GITEA_TOKEN=PRESENT GITEA_TOKEN=PRESENT GITEA_HOST=PRESENT BRIDGED_MEMBER=PRESENT ADMIN=SENTINEL worker-token repo endpoint: 200 admin-token repo endpoint: 401 ``` ### What each line proves | observation | meaning | |---|---| | `BRIDGED_MEMBER=PRESENT` | the marker survives the login shell — the mechanism works | | `ADMIN=SENTINEL` | the guard held; the real admin token never reached the pane | | worker token → **200** | CB-302 is intact, workers can still open PRs | | admin token → **401** | what the member holds is dead at the forge | The operator's rule — *"only leader/arch use `GITEA_ACCESS_TOKEN`, others use `WORKER_GITEA_TOKEN`"* — is now **enforced at the environment**, not only at the MCP mount. ### Why it needed both halves Neither half works alone: - The launcher sentinel alone was **overwritten** by the pane's login shell re-sourcing `secrets.sh` (previous comment). - The `secrets.sh` guard alone would leave `GITEA_ACCESS_TOKEN` simply **unset**, and an unset variable is inherited straight through from herdr's own process environment — which carries the real admin token. Together the launcher writes a dead value and the guard stops the shell replacing it. That is why `ADMIN=SENTINEL` rather than `ADMIN` being empty. ### Standing constraint for anyone touching this `secrets.sh` is outside this repo and is not covered by our tests. A future edit that drops the guard re-opens the leak silently, and `mvn` will stay green. The pinned tests (`everySpawnShadowsTheAdminGiteaAccessToken`, `everySpawnMarksThePaneAsAMember`, `aProfileEnvEntryCannotClearTheMemberMarker`) protect only the launcher's half. Closing.
ltms closed this issue 2026-08-15 18:49:05 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#77