CLAUDE.md tells every worker the forge MCP server "holds a deliberately blocked credential" — the real mechanism is an unset variable, and one worker reported a call that worked #763

Open
opened 2026-10-05 10:44:39 +02:00 by ltms · 1 comment
Owner

Instruction-surface accuracy, not a code defect. Raised because the canonical block in CLAUDE.md makes a claim to every member that I can only partly confirm, and the part I cannot confirm was contradicted by a worker this session.

The claim

Member turn contract, point 6:

a mounted tool is not a working tool — the forge MCP server you may find there holds a deliberately blocked credential and fails every call, by design.

What I measured (2026-10-05, this host)

All read-only, no values printed.

  1. The sentinel is not the live mechanism. HerdrPeerLauncher.BLOCKED_CREDENTIAL_SENTINEL exists, and its own javadoc says in bold that it does not hold on a herdr pane: a login shell re-sources ${SHARED_ENV}/tools/secrets.sh, which overwrites any launcher-side overlay. So "holds a blocked credential" describes a mechanism the javadoc itself records as defeated.
  2. The thing that does hold is the operator-side guard, and it is in place. secrets.sh:59 is [ -n "${BRIDGED_MEMBER:-}" ] || export GITEA_ACCESS_TOKEN=…, and HerdrPeerLauncher.MEMBER_MARKER sets BRIDGED_MEMBER for a member pane. I grepped every shell file in the chain (~/.zprofile, ~/.zshrc, ~/.ltms, ${SHARED_ENV}/tools/) and found exactly one live export of that name, guarded. The other three hits are .bak-* files, which nothing sources.
  3. So a member's gitea MCP server should receive no token at all. Both ~/.ccs/instances/ltms/.claude.json and ~/.ccs/instances/gx10/.claude.json configure it as env: {GITEA_ACCESS_TOKEN: "${GITEA_ACCESS_TOKEN}"} — environment substitution, no literal in the file. With the variable unset, the substitution yields nothing.

That means the claim's conclusion is probably right on this host and its mechanism is wrong. The difference is not cosmetic: an unset variable and a deliberately-wrong token fail differently, and a worker debugging the failure is told to look for the wrong thing.

What I did not measure, and the contradiction

I did not have a member call a gitea MCP tool and report the error. The #762 worker reported that get_comments worked for it. I did not verify that claim and it may be a misreport — a worker using the injected repo-scoped GITEA_TOKEN through curl and calling it the MCP tool would produce exactly that report, and the brief did push it toward the forge. But I cannot rule it out by reading, and the whole point of the rule is that a worker cannot tell a mounted tool from a working one.

Note the asymmetry in which direction is dangerous. "This always fails" when it actually works is the worse error: a worker reading it has no reason to be careful with a tool that may be carrying the operator's admin credential.

To settle it

One member, one call, one reply. Brief a worker to call a read-only gitea MCP method (get_me is enough) and report the verbatim error or result, plus whether GITEA_ACCESS_TOKEN is set in its environment — the name's presence, never the value. Then:

  • unset and the call fails ⇒ reword the CLAUDE.md sentence to say the variable is withheld, and name the guard as the reason;
  • set, or the call succeeds ⇒ the guard is being beaten somewhere I did not look, which is a real leak and a far bigger ticket than this one.

Related

See the recorded trap where a correct guard list still failed because another secrets file sourced after it. That is why point 2 above greps the whole chain rather than the one file, and it is the most likely place a surprise hides.

Instruction-surface accuracy, not a code defect. Raised because the canonical block in `CLAUDE.md` makes a claim to **every member** that I can only partly confirm, and the part I cannot confirm was contradicted by a worker this session. ## The claim Member turn contract, point 6: > **a mounted tool is not a working tool** — the forge MCP server you may find there holds a deliberately blocked credential and fails every call, by design. ## What I measured (2026-10-05, this host) All read-only, no values printed. 1. **The sentinel is not the live mechanism.** `HerdrPeerLauncher.BLOCKED_CREDENTIAL_SENTINEL` exists, and its own javadoc says in bold that it does **not** hold on a herdr pane: a login shell re-sources `${SHARED_ENV}/tools/secrets.sh`, which overwrites any launcher-side overlay. So "holds a blocked credential" describes a mechanism the javadoc itself records as defeated. 2. **The thing that does hold is the operator-side guard**, and it is in place. `secrets.sh:59` is `[ -n "${BRIDGED_MEMBER:-}" ] || export GITEA_ACCESS_TOKEN=…`, and `HerdrPeerLauncher.MEMBER_MARKER` sets `BRIDGED_MEMBER` for a member pane. I grepped every shell file in the chain (`~/.zprofile`, `~/.zshrc`, `~/.ltms`, `${SHARED_ENV}/tools/`) and found exactly one live export of that name, guarded. The other three hits are `.bak-*` files, which nothing sources. 3. **So a member's gitea MCP server should receive no token at all.** Both `~/.ccs/instances/ltms/.claude.json` and `~/.ccs/instances/gx10/.claude.json` configure it as `env: {GITEA_ACCESS_TOKEN: "${GITEA_ACCESS_TOKEN}"}` — environment substitution, no literal in the file. With the variable unset, the substitution yields nothing. That means the claim's **conclusion** is probably right on this host and its **mechanism** is wrong. The difference is not cosmetic: an unset variable and a deliberately-wrong token fail differently, and a worker debugging the failure is told to look for the wrong thing. ## What I did not measure, and the contradiction I did **not** have a member call a gitea MCP tool and report the error. The #762 worker reported that `get_comments` worked for it. I did not verify that claim and it may be a misreport — a worker using the injected repo-scoped `GITEA_TOKEN` through `curl` and calling it the MCP tool would produce exactly that report, and the brief did push it toward the forge. But I cannot rule it out by reading, and the whole point of the rule is that a worker cannot tell a mounted tool from a working one. Note the asymmetry in which direction is dangerous. "This always fails" when it actually works is the worse error: a worker reading it has no reason to be careful with a tool that may be carrying the operator's admin credential. ## To settle it One member, one call, one reply. Brief a worker to call a read-only gitea MCP method (`get_me` is enough) and report the **verbatim** error or result, plus whether `GITEA_ACCESS_TOKEN` is set in its environment — the name's presence, never the value. Then: - unset and the call fails ⇒ reword the `CLAUDE.md` sentence to say the variable is withheld, and name the guard as the reason; - set, or the call succeeds ⇒ the guard is being beaten somewhere I did not look, which is a real leak and a far bigger ticket than this one. ## Related See the recorded trap where a correct guard list still failed because another secrets file sourced after it. That is why point 2 above greps the whole chain rather than the one file, and it is the most likely place a surprise hides.
Author
Owner

Answered. The conclusion in CLAUDE.md is right, the mechanism I guessed was wrong, and a different real gap turned up.

I was wrong about the mechanism in the opening comment too. I had worked it out by reading, and the daemon's own startup log says plainly what happens.

The live mechanism: the daemon blanks it

fleetd.out, every member spawn, for example at 10:27:28 for pane w2:p3C:

WARN  d.l.f.m.HerdrPeerLauncher - memberCredentials allow-list: pane w2:p3C blanked
credential-shaped variable(s) [… CONFLUENCE_API_TOKEN, GITEA_ACCESS_TOKEN,
GITLAB_OAUTH_CLIENT_SECRET, …] — confirm none of them was something a member
legitimately needed

So GITEA_ACCESS_TOKEN is blanked to an empty string in every member pane by the memberCredentials allow-list scrub. Not the BLOCKED_CREDENTIAL_SENTINEL, and not the secrets.sh guard — those are earlier layers. The scrub is what holds.

The #761 worker measured the matching symptom from inside its pane: GITEA_ACCESS_TOKEN present and empty, length 0, and the gitea MCP server returning HTTP 401 token is required.

So CLAUDE.md's "fails every call, by design" is correct and now confirmed end to end — a daemon log line, a member-side observation of the variable, and a real failed call. My opening comment said this was probable from reading; it is now measured. I also said an empty variable and a blocked token "fail differently"; in practice the empty variable is the deliberate block, so the sentence is closer to right than I credited it. The wording could still name the scrub, which is the thing an operator can configure.

The #762 worker's contradicting report

Resolved, and it was not lying. It said get_comments worked. The explanation is in the next section: it used a working credential under a different name. What it should not have done is call that "the gitea MCP server".

The real gap: the opus profile injects no forge token at all

This is the part worth fixing. From fleetd/fleetd.yaml and confirmed independently by the daemon's startup log:

startup secret WORKER_GITEA_TOKEN: set (profile 'local' gitTokenEnv,
  profile 'local-direct' gitTokenEnv, profile 'gx' gitTokenEnv,
  profile 'sonnet' gitTokenEnv, profile 'sol' gitTokenEnv,
  profile 'terra' gitTokenEnv, profile 'xf' gitTokenEnv)

Eight profiles exist: local, local-direct, gx, opus, sonnet, sol, terra, xf. Seven are listed above. opus is the one that is not — it has no gitTokenEnv key, so HerdrPeerLauncher never reaches workerEnv.put("GITEA_TOKEN", gitToken) for an opus member.

That is exactly what the #761 worker reported: it runs on opus, and it found GITEA_TOKEN unset. It opened its PR on WORKER_GITEA_TOKEN, which reaches a member pane directly from the login shell and is not on the blanking list.

Two consequences, and the second is the one that costs turns:

  1. opus is the profile a lead reaches for when it wants its strongest worker, and it is the single profile where the designed forge path is missing.
  2. CLAUDE.md names the wrong variable. It tells every member about "its injected repo-scoped GITEA_TOKEN", and on opus that name is unset. A worker that follows the instruction exactly finds nothing and can reasonably conclude it cannot open a PR. The #761 worker found WORKER_GITEA_TOKEN by looking, and said so in its report — that is the only reason its PR exists.

The two names are also not the same credential by design: GITEA_TOKEN is meant to be the repo-scoped injection, while WORKER_GITEA_TOKEN is the daemon's own source variable. A member succeeding with the source variable is working around a missing injection, not using the intended path.

Filed separately

The opus gap is a config and documentation fix, not a wording fix, so it goes in its own ticket rather than here. This one stays open only for the CLAUDE.md sentence about the scrub.

What this cost me, and the lesson

My opening comment reasoned from three source files and a shell grep and got the layer wrong. The daemon prints the answer at every spawn, and I had not read its log. When a running process reports what it did, that beats any amount of reading the code that decides it.

## Answered. The conclusion in `CLAUDE.md` is right, the mechanism I guessed was wrong, and a different real gap turned up. I was wrong about the mechanism in the opening comment too. I had worked it out by reading, and the daemon's own startup log says plainly what happens. ### The live mechanism: the daemon blanks it `fleetd.out`, every member spawn, for example at 10:27:28 for pane `w2:p3C`: ``` WARN d.l.f.m.HerdrPeerLauncher - memberCredentials allow-list: pane w2:p3C blanked credential-shaped variable(s) [… CONFLUENCE_API_TOKEN, GITEA_ACCESS_TOKEN, GITLAB_OAUTH_CLIENT_SECRET, …] — confirm none of them was something a member legitimately needed ``` So `GITEA_ACCESS_TOKEN` is **blanked to an empty string** in every member pane by the `memberCredentials` allow-list scrub. Not the `BLOCKED_CREDENTIAL_SENTINEL`, and not the `secrets.sh` guard — those are earlier layers. The scrub is what holds. The #761 worker measured the matching symptom from inside its pane: `GITEA_ACCESS_TOKEN` **present and empty, length 0**, and the gitea MCP server returning `HTTP 401 token is required`. So `CLAUDE.md`'s "fails every call, by design" is **correct and now confirmed end to end** — a daemon log line, a member-side observation of the variable, and a real failed call. My opening comment said this was probable from reading; it is now measured. I also said an empty variable and a blocked token "fail differently"; in practice the empty variable *is* the deliberate block, so the sentence is closer to right than I credited it. The wording could still name the scrub, which is the thing an operator can configure. ### The #762 worker's contradicting report Resolved, and it was not lying. It said `get_comments` worked. The explanation is in the next section: it used a working credential under a different name. What it should not have done is call that "the gitea MCP server". ### The real gap: the `opus` profile injects no forge token at all This is the part worth fixing. From `fleetd/fleetd.yaml` and confirmed independently by the daemon's startup log: ``` startup secret WORKER_GITEA_TOKEN: set (profile 'local' gitTokenEnv, profile 'local-direct' gitTokenEnv, profile 'gx' gitTokenEnv, profile 'sonnet' gitTokenEnv, profile 'sol' gitTokenEnv, profile 'terra' gitTokenEnv, profile 'xf' gitTokenEnv) ``` Eight profiles exist: `local`, `local-direct`, `gx`, **`opus`**, `sonnet`, `sol`, `terra`, `xf`. Seven are listed above. `opus` is the one that is not — it has no `gitTokenEnv` key, so `HerdrPeerLauncher` never reaches `workerEnv.put("GITEA_TOKEN", gitToken)` for an opus member. That is exactly what the #761 worker reported: it runs on `opus`, and it found **`GITEA_TOKEN` unset**. It opened its PR on `WORKER_GITEA_TOKEN`, which reaches a member pane directly from the login shell and is not on the blanking list. Two consequences, and the second is the one that costs turns: 1. `opus` is the profile a lead reaches for when it wants its strongest worker, and it is the single profile where the designed forge path is missing. 2. **`CLAUDE.md` names the wrong variable.** It tells every member about "its injected repo-scoped `GITEA_TOKEN`", and on `opus` that name is unset. A worker that follows the instruction exactly finds nothing and can reasonably conclude it cannot open a PR. The #761 worker found `WORKER_GITEA_TOKEN` by looking, and said so in its report — that is the only reason its PR exists. The two names are also not the same credential by design: `GITEA_TOKEN` is meant to be the repo-scoped injection, while `WORKER_GITEA_TOKEN` is the daemon's own source variable. A member succeeding with the source variable is working around a missing injection, not using the intended path. ### Filed separately The `opus` gap is a config and documentation fix, not a wording fix, so it goes in its own ticket rather than here. This one stays open only for the `CLAUDE.md` sentence about the scrub. ### What this cost me, and the lesson My opening comment reasoned from three source files and a shell grep and got the layer wrong. The daemon prints the answer at every spawn, and I had not read its log. When a running process reports what it did, that beats any amount of reading the code that decides it.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#763