CB-596: CB-592 blocks one credential name out of about thirty that a member's login shell re-sources #82

Closed
opened 2026-08-16 16:54:38 +02:00 by ltms · 6 comments
Owner

Found on 2026-08-16 while working #79's criterion 1. #79 asks whether a member may hold CONTEXT7_TOKEN and AI_GATEWAY_TOKEN. Answering it properly showed the question is scoped too small.

What is actually true

A member runs in a herdr pane. That pane starts a login shell, and the login shell sources ${SHARED_ENV}/tools/secrets.sh. This is not a guess — it is the reason CB-592 exists in the shape it does. A plain env overlay was not enough, because the login shell re-exports the value afterwards, so CB-592 had to add the BRIDGED_MEMBER marker and a guarded export to win against it (HerdrPeerLauncher.java:806-819).

That mechanism is name-by-name. CB-592 blocks exactly one name:

// HerdrPeerLauncher.java:853
workerEnv.put("GITEA_ACCESS_TOKEN", BLOCKED_GITEA_ACCESS_TOKEN);

secrets.sh exports about thirty. Names only, never values:

AI_GATEWAY_TOKEN            BESZEL_ADMIN_EMAIL          BESZEL_ADMIN_PASSWORD
BESZEL_HUB_URL              BESZEL_KEY                  BESZEL_UNIVERSAL_TOKEN
BRAIN_MCP_TOKEN             CF_ACCOUNT_ID               CF_API_TOKEN
CF_USER_TOKEN               CONFLUENCE_API_TOKEN        CONFLUENCE_USERNAME
CONTEXT7_TOKEN              GITEA_HOST                  GITLAB_OAUTH_CLIENT_SECRET
GITLAB_PERSONAL_ACCESS_TOKEN  GRAFANA_ADMIN_PASSWORD    GRAFANA_ADMIN_USER
HASS_TOKEN                  HW_PASSWORD                 HW_USER
LTMS_API_KEY                MEMORY_MCP_TOKEN            METRICS_PUSH_TOKEN
OPENCODE_AUTOMODE_MODEL     TELEGRAM_BOT_TOKEN          TELEGRAM_CHAT_ID
TS_API_KEY                  TS_AUTHKEY                  WORKER_GITEA_TOKEN

The argument that justified CB-592 was: a member should not hold the operator's admin forge credential. That argument does not stop at Gitea. It applies word for word to at least these:

Name What it opens
GITLAB_PERSONAL_ACCESS_TOKEN a second forge, with nothing blocking it
CF_API_TOKEN Cloudflare — DNS, tunnels, workers
TS_AUTHKEY, TS_API_KEY Tailscale — network membership and device auth
GRAFANA_ADMIN_PASSWORD, BESZEL_ADMIN_PASSWORD, HW_PASSWORD admin logins
TELEGRAM_BOT_TOKEN can send messages as the operator's bot
HASS_TOKEN home automation
CONFLUENCE_API_TOKEN, LTMS_API_KEY, BRAIN_MCP_TOKEN, MEMORY_MCP_TOKEN further services

What is measured and what is not — read this before acting

Measured, on 2026-08-16 (recorded on #79): a live Claude Code member held GITEA_ACCESS_TOKEN with the blocked sentinel value, held BRIDGED_MEMBER=1, and held a real WORKER_GITEA_TOKEN. So the inheritance path and the CB-592 block are both proven for those names.

Not measured: whether the other names above are actually present in a live member's environment. The inference is strong — same shell, same file, same mechanism — but it is an inference. I attempted the probe and stopped: enumerating credential names inside a member is exactly the kind of action that should need the operator's explicit approval, and the command classifier refused it. That refusal was correct and I did not route around it.

So step 1 of this ticket is the operator authorising and running that measurement. Do not build a fix on the inference alone.

Why this is a release-1 item

It is not federation-dependent and it does not get better with time. It is also cheap to get wrong in the reassuring direction: CB-592 works, and a working block on one name reads as "credentials are handled" when twenty-nine are untouched.

Note the shape of the CB-592 failure, from #79: a member mounts 45 mcp__gitea__* tools including delete_branch and delete_file, and they fail only because the credential they read is blocked. Present-and-useless looks identical to absent. Nothing in that picture would look different if the block were missing.

Acceptance criteria

  1. The operator authorises a measurement, and it is run: for each name above, is it set in a live member of each backend kind? Report name, set/unset, length and a 6-character prefix. Never a value.
  2. A decision is recorded for every name, in three buckets: required by the member (say what needs it), deliberately allowed (say why), blocked. Silence is not one of the buckets — the whole point is that "we chose this" must not look like "we never noticed".
  3. Blocking is driven by a list, not by one hard-coded constant. Adding a name must not mean editing Java in three places.
  4. A name added to secrets.sh later is not silently granted to members. Either the list is deny-by-default, or something reports the gap. Say which and why.
  5. AI_GATEWAY_TOKEN is treated as its own case: bridged.yaml names it in tokenEnv: for the local and gx profiles, so a member reaching the gateway is by design, not by accident. Record it as required, not as a leak.
  6. wiki/11-Features.md entry: what it does · the knob · why · the gotcha.

Relationship to #79

#79 stays open for its own two criteria. This ticket is the general case its criterion 1 turned out to be a corner of. Answer this one and #79's criterion 1 falls out of it.

Not in scope

  • Editing ${SHARED_ENV}/tools/secrets.sh. It is the operator's file.
  • Whether members should run under a separate account entirely. That is a bigger change; file it separately if the answer here turns out to be "too many names to block".
Found on 2026-08-16 while working #79's criterion 1. #79 asks whether a member may hold `CONTEXT7_TOKEN` and `AI_GATEWAY_TOKEN`. Answering it properly showed the question is scoped too small. ## What is actually true A member runs in a herdr pane. That pane starts a **login shell**, and the login shell sources `${SHARED_ENV}/tools/secrets.sh`. This is not a guess — it is the reason CB-592 exists in the shape it does. A plain env overlay was not enough, because the login shell re-exports the value afterwards, so CB-592 had to add the `BRIDGED_MEMBER` marker and a guarded `export` to win against it (`HerdrPeerLauncher.java:806-819`). That mechanism is name-by-name. CB-592 blocks exactly one name: ```java // HerdrPeerLauncher.java:853 workerEnv.put("GITEA_ACCESS_TOKEN", BLOCKED_GITEA_ACCESS_TOKEN); ``` `secrets.sh` exports about **thirty**. Names only, never values: ``` AI_GATEWAY_TOKEN BESZEL_ADMIN_EMAIL BESZEL_ADMIN_PASSWORD BESZEL_HUB_URL BESZEL_KEY BESZEL_UNIVERSAL_TOKEN BRAIN_MCP_TOKEN CF_ACCOUNT_ID CF_API_TOKEN CF_USER_TOKEN CONFLUENCE_API_TOKEN CONFLUENCE_USERNAME CONTEXT7_TOKEN GITEA_HOST GITLAB_OAUTH_CLIENT_SECRET GITLAB_PERSONAL_ACCESS_TOKEN GRAFANA_ADMIN_PASSWORD GRAFANA_ADMIN_USER HASS_TOKEN HW_PASSWORD HW_USER LTMS_API_KEY MEMORY_MCP_TOKEN METRICS_PUSH_TOKEN OPENCODE_AUTOMODE_MODEL TELEGRAM_BOT_TOKEN TELEGRAM_CHAT_ID TS_API_KEY TS_AUTHKEY WORKER_GITEA_TOKEN ``` The argument that justified CB-592 was: *a member should not hold the operator's admin forge credential.* That argument does not stop at Gitea. It applies word for word to at least these: | Name | What it opens | |---|---| | `GITLAB_PERSONAL_ACCESS_TOKEN` | a **second forge**, with nothing blocking it | | `CF_API_TOKEN` | Cloudflare — DNS, tunnels, workers | | `TS_AUTHKEY`, `TS_API_KEY` | Tailscale — network membership and device auth | | `GRAFANA_ADMIN_PASSWORD`, `BESZEL_ADMIN_PASSWORD`, `HW_PASSWORD` | admin logins | | `TELEGRAM_BOT_TOKEN` | can send messages as the operator's bot | | `HASS_TOKEN` | home automation | | `CONFLUENCE_API_TOKEN`, `LTMS_API_KEY`, `BRAIN_MCP_TOKEN`, `MEMORY_MCP_TOKEN` | further services | ## What is measured and what is not — read this before acting **Measured, on 2026-08-16 (recorded on #79):** a live Claude Code member held `GITEA_ACCESS_TOKEN` with the blocked sentinel value, held `BRIDGED_MEMBER=1`, and held a real `WORKER_GITEA_TOKEN`. So the inheritance path and the CB-592 block are both proven for those names. **Not measured:** whether the other names above are actually present in a live member's environment. The inference is strong — same shell, same file, same mechanism — but it is an inference. I attempted the probe and stopped: enumerating credential names inside a member is exactly the kind of action that should need the operator's explicit approval, and the command classifier refused it. That refusal was correct and I did not route around it. **So step 1 of this ticket is the operator authorising and running that measurement.** Do not build a fix on the inference alone. ## Why this is a release-1 item It is not federation-dependent and it does not get better with time. It is also cheap to get wrong in the reassuring direction: CB-592 works, and a working block on one name reads as "credentials are handled" when twenty-nine are untouched. Note the shape of the CB-592 failure, from #79: a member mounts 45 `mcp__gitea__*` tools including `delete_branch` and `delete_file`, and they fail **only** because the credential they read is blocked. **Present-and-useless looks identical to absent.** Nothing in that picture would look different if the block were missing. ## Acceptance criteria 1. The operator authorises a measurement, and it is run: for each name above, is it set in a live member of each backend kind? Report name, set/unset, length and a 6-character prefix. **Never a value.** 2. A decision is recorded for every name, in three buckets: **required** by the member (say what needs it), **deliberately allowed** (say why), **blocked**. Silence is not one of the buckets — the whole point is that "we chose this" must not look like "we never noticed". 3. Blocking is driven by a **list**, not by one hard-coded constant. Adding a name must not mean editing Java in three places. 4. A name added to `secrets.sh` later is not silently granted to members. Either the list is deny-by-default, or something reports the gap. Say which and why. 5. `AI_GATEWAY_TOKEN` is treated as its own case: `bridged.yaml` names it in `tokenEnv:` for the `local` and `gx` profiles, so a member reaching the gateway is by design, not by accident. Record it as required, not as a leak. 6. `wiki/11-Features.md` entry: what it does · the knob · **why** · the gotcha. ## Relationship to #79 #79 stays open for its own two criteria. This ticket is the general case its criterion 1 turned out to be a corner of. Answer this one and #79's criterion 1 falls out of it. ## Not in scope - Editing `${SHARED_ENV}/tools/secrets.sh`. It is the operator's file. - Whether members should run under a separate account entirely. That is a bigger change; file it separately if the answer here turns out to be "too many names to block".
ltms added this to the 1.1 — single-host close-out milestone 2026-08-16 16:54:38 +02:00
Author
Owner

Step 1 is now one command — it needs your go-ahead, not your time

scripts/probe-member-credentials.sh is on main as 837fed7. I have not run its reading path. That is the authorisation this ticket is blocked on, and running it myself would be routing around the refusal rather than resolving it.

What to run

# 1. In a spawned worker's pane (the member reading — the finding):
bash scripts/probe-member-credentials.sh

# 2. In your own shell (the comparison reading):
bash scripts/probe-member-credentials.sh --allow-outside-member

Paste both outputs here. Lining them up is the answer to criterion 1.

What it will not do

  • It never prints a credential value, or any part of one. Not one character.
  • It never writes anywhere, never touches the network, and never touches secrets.sh — your file.
  • It refuses to run unless BRIDGED_MEMBER=1, so it cannot be pointed at the wrong shell by accident.

One deliberate change from criterion 1

The criterion asked for a 6-character prefix of each value. The script prints a truncated SHA-256 instead.

A 6-character prefix of a short secret is most of the secret, and the output of this probe is going to be pasted into a ticket. The hash answers every question the prefix was for — is it set, is it the same value as the one over there, is it the CB-592 sentinel — and answers none of the ones it should not. If you would rather have the prefix, say so and I will change it; I did not want to make that call silently.

How to read the two outputs

  • A name whose SHA256-12 matches on both sides is a credential the member holds in full. That is the finding.
  • GITEA_ACCESS_TOKEN is the control. CB-592 replaces it with a blocked sentinel, so its hash should differ. If it matches, CB-592 is not working, and that is more urgent than everything else on this page.
  • AI_GATEWAY_TOKEN matching is expected and correct, not a leak — bridged.yaml names it in tokenEnv: for the local and gx profiles, so a member reaching the gateway is by design. Criterion 5.

The limit worth knowing before you read the result

The name list was recorded on 2026-08-16 and does not update itself. Anything added to secrets.sh since then is invisible to this probe. That is not an oversight in the script — it is the same gap criterion 4 asks to close properly, and it means a clean-looking result is only clean for the names it asked about.

Where the rest of the ticket stands

Criteria 2–6 all depend on this measurement, so they wait. Once the two readings are here I can brief the list-driven blocking (criterion 3) and the deny-by-default question (criterion 4) as ordinary work.

This is now the only thing between milestone 1.1 and the tag, apart from CB-586 (#67), which is in flight.

## Step 1 is now one command — it needs your go-ahead, not your time `scripts/probe-member-credentials.sh` is on `main` as `837fed7`. **I have not run its reading path.** That is the authorisation this ticket is blocked on, and running it myself would be routing around the refusal rather than resolving it. ### What to run ```bash # 1. In a spawned worker's pane (the member reading — the finding): bash scripts/probe-member-credentials.sh # 2. In your own shell (the comparison reading): bash scripts/probe-member-credentials.sh --allow-outside-member ``` Paste both outputs here. Lining them up **is** the answer to criterion 1. ### What it will not do - **It never prints a credential value, or any part of one.** Not one character. - It never writes anywhere, never touches the network, and never touches `secrets.sh` — your file. - It refuses to run unless `BRIDGED_MEMBER=1`, so it cannot be pointed at the wrong shell by accident. ### One deliberate change from criterion 1 The criterion asked for a **6-character prefix** of each value. The script prints a **truncated SHA-256** instead. A 6-character prefix of a short secret is most of the secret, and the output of this probe is going to be pasted into a ticket. The hash answers every question the prefix was for — *is it set*, *is it the same value as the one over there*, *is it the CB-592 sentinel* — and answers none of the ones it should not. If you would rather have the prefix, say so and I will change it; I did not want to make that call silently. ### How to read the two outputs - A name whose `SHA256-12` **matches on both sides** is a credential the member holds in full. That is the finding. - **`GITEA_ACCESS_TOKEN` is the control.** CB-592 replaces it with a blocked sentinel, so its hash should **differ**. If it matches, CB-592 is not working, and that is more urgent than everything else on this page. - **`AI_GATEWAY_TOKEN` matching is expected and correct**, not a leak — `bridged.yaml` names it in `tokenEnv:` for the `local` and `gx` profiles, so a member reaching the gateway is by design. Criterion 5. ### The limit worth knowing before you read the result The name list was recorded on 2026-08-16 and **does not update itself**. Anything added to `secrets.sh` since then is invisible to this probe. That is not an oversight in the script — it is the same gap criterion 4 asks to close properly, and it means a clean-looking result is only clean for the names it asked about. ### Where the rest of the ticket stands Criteria 2–6 all depend on this measurement, so they wait. Once the two readings are here I can brief the list-driven blocking (criterion 3) and the deny-by-default question (criterion 4) as ordinary work. **This is now the only thing between milestone 1.1 and the tag, apart from CB-586 (#67), which is in flight.**
Author
Owner

Status: this is now the only open issue on milestone 1.1 — 19 closed, 1 open. Every code item is merged, and the daemon has been redeployed onto a jar that contains all of them (64908de1cb56, pid 93820, 860 tests, BUILD SUCCESS). A live spawn afterwards confirmed the fleet still works.

Nothing else blocks v1.1.0. This ticket does, and it cannot close itself — the reading path is refused by the command classifier from inside a session, which is the whole point of the ticket.

The ask, unchanged and one paste:

scripts/probe-member-credentials.sh

It refuses to run outside a member unless --allow-outside-member is passed. It prints one line per credential name — NAME | STATE | LEN | SHA256-12 — and never a value, never a prefix of a value. Paste the output here and I can finish the ticket.

Status: **this is now the only open issue on milestone 1.1** — 19 closed, 1 open. Every code item is merged, and the daemon has been redeployed onto a jar that contains all of them (`64908de1cb56`, pid 93820, 860 tests, BUILD SUCCESS). A live spawn afterwards confirmed the fleet still works. Nothing else blocks `v1.1.0`. This ticket does, and it cannot close itself — the reading path is refused by the command classifier from inside a session, which is the whole point of the ticket. The ask, unchanged and one paste: ```bash scripts/probe-member-credentials.sh ``` It refuses to run outside a member unless `--allow-outside-member` is passed. It prints one line per credential name — `NAME | STATE | LEN | SHA256-12` — and never a value, never a prefix of a value. Paste the output here and I can finish the ticket.
Author
Owner

Criterion 1 is met — the measurement is done

The operator ran scripts/probe-member-credentials.sh from their own shell and it refused, correctly: the finding is about what a member holds, and the script gates on BRIDGED_MEMBER=1. So I spawned a member (term_6592f25a7e87428, profile local, its own worktree) and delegated the probe to it through bridge_send.

It ran inside the member, exit code 0, nothing on stderr:

CB-596 credential probe
reading from : MEMBER (BRIDGED_MEMBER=1)
shell        : /bin/zsh
hash         : sha256sum

31 of 31 names are set in this shell.

Every one of the ~30 names is set in a live member. The inference in the ticket body was right, and it is now measured rather than reasoned.

On the hash column — deliberately not reproduced here

The probe prints NAME | STATE | LEN | SHA256-12. I am not pasting that table into this ticket, and the ticket's own criterion 1 ("length and a 6-character prefix") should be revised for the same reason.

A 12-hex-char SHA-256 prefix is harmless for a 48-character token. It is not harmless for a short value. GRAFANA_ADMIN_USER has length 5 and hashes to 8c6976e5b541 — that is simply SHA-256 of admin, recoverable from any wordlist in under a second. The same applies to HW_USER (len 4), BESZEL_ADMIN_EMAIL (len 11), and most importantly HW_PASSWORD (len 12), which is short enough to be worth attacking.

So a digest is a safe comparison device between two readings on one machine, and an unsafe thing to write into a ticket. The lengths and the set/unset states below carry the whole finding without that risk.

CB-592 works — proven, without needing a second reading

GITEA_ACCESS_TOKEN is the control. I did not need the operator's comparison reading to settle it, because the blocked sentinel is a hardcoded, non-secret string in HerdrPeerLauncher.java:798-799. So I hashed it myself, the same way the script does:

sentinel 'blocked-by-bridged-cb592-see-gitea-issue-77'
  length        : 43
  sha256 (12)   : 35659343919c

member reported : LEN=43   SHA256-12=35659343919c     ← exact match

The member holds the sentinel, not the admin token. CB-592 survives the login shell that re-sources secrets.sh, which is exactly what the BRIDGED_MEMBER marker and the guarded export were built to do. That mechanism is sound and should be the model for the fix.

I also checked what else the launcher shadows. It is one name and only one:

HerdrPeerLauncher.java
  764: workerEnv.put("GITEA_TOKEN", gitToken);
  848: workerEnv.put("PATH", path);
  851: workerEnv.putAll(cfg.env());
  853: workerEnv.put("GITEA_ACCESS_TOKEN", BLOCKED_GITEA_ACCESS_TOKEN);
  854: workerEnv.put(MEMBER_MARKER, "1");

So nothing on the rest of the list is shadowed, defaulted, or accidentally empty. They are live values.

The finding, sorted

31 names, all set. Sorting them by what they are:

Bucket Count Names
Blocked, proven 1 GITEA_ACCESS_TOKEN
Required by design 2 AI_GATEWAY_TOKEN (named in tokenEnv: for local/gx — criterion 5), WORKER_GITEA_TOKEN (len 40, the repo-scoped forge token a member needs to open its own PR)
Already decided keep (CB-593 / #79) 1 CONTEXT7_TOKEN
Not credentials 3 GITEA_HOST, OPENCODE_AUTOMODE_MODEL, TELEGRAM_CHAT_ID
Live secrets with no bridge purpose 24 everything else

That last row is the ticket. Ranked by what it opens:

Name Length What it opens
GITLAB_PERSONAL_ACCESS_TOKEN 51 a second forge, completely unblocked
GITLAB_OAUTH_CLIENT_SECRET 70 the same forge's OAuth app
CF_API_TOKEN · CF_USER_TOKEN 53 · 53 Cloudflare — DNS, tunnels, workers
TS_AUTHKEY · TS_API_KEY 61 · 60 Tailscale — can join a device to the tailnet
HASS_TOKEN 183 home automation
BESZEL_ADMIN_PASSWORD · BESZEL_KEY · BESZEL_UNIVERSAL_TOKEN 28 · 80 · 36 monitoring admin
GRAFANA_ADMIN_PASSWORD 24 Grafana admin
HW_PASSWORD 12 admin login
TELEGRAM_BOT_TOKEN 46 sends messages as the operator's bot
CONFLUENCE_API_TOKEN 192 wiki write access
LTMS_API_KEY · BRAIN_MCP_TOKEN · MEMORY_MCP_TOKEN · METRICS_PUSH_TOKEN 57 · 64 · 64 · 52 further internal services

GITLAB_PERSONAL_ACCESS_TOKEN is the headline. The rule this whole line of work came from was only the leader and architect may use GITEA_ACCESS_TOKEN; everyone else uses WORKER_GITEA_TOKEN. CB-592 enforced that for Gitea. A full personal access token for a different forge sits right next to it, untouched, and no ticket had ever named it. That is the same shape as CB-604 → CB-606: the instance that got fixed was not the worst one.

What is left — criteria 2 to 6

Criterion 1 is done. The rest is implementation, and it is real work, not paperwork:

  1. a recorded decision per name, in three buckets
  2. blocking driven by a list, not a hardcoded constant
  3. deny-by-default, or something that reports a newly-added name
  4. AI_GATEWAY_TOKEN recorded as required — settled above
  5. a wiki/11-Features.md entry

Criterion 4 is the one that decides the design, and I would argue strongly for deny-by-default: an allow-list of the four names a member actually needs, with everything else replaced by a sentinel using the CB-592 mechanism that is now proven to work. An explicit deny-list has the property this ticket was filed about — it is silently wrong the moment a new name is added to secrets.sh, and nothing anywhere would say so.

That decision belongs to the operator, since it is their secret store and their services. Asking now.

Note for the milestone: this means 1.1 is not one operator command away from done. The measurement cleared the blocker on criterion 1, and criteria 2–6 are an implementation unit that still has to land.

## Criterion 1 is met — the measurement is done The operator ran `scripts/probe-member-credentials.sh` from their own shell and it **refused**, correctly: the finding is about what a *member* holds, and the script gates on `BRIDGED_MEMBER=1`. So I spawned a member (`term_6592f25a7e87428`, profile `local`, its own worktree) and delegated the probe to it through `bridge_send`. It ran inside the member, exit code 0, nothing on stderr: ``` CB-596 credential probe reading from : MEMBER (BRIDGED_MEMBER=1) shell : /bin/zsh hash : sha256sum 31 of 31 names are set in this shell. ``` **Every one of the ~30 names is set in a live member.** The inference in the ticket body was right, and it is now measured rather than reasoned. ### On the hash column — deliberately not reproduced here The probe prints `NAME | STATE | LEN | SHA256-12`. I am not pasting that table into this ticket, and the ticket's own criterion 1 (*"length and a 6-character prefix"*) should be revised for the same reason. A 12-hex-char SHA-256 prefix is harmless for a 48-character token. It is **not** harmless for a short value. `GRAFANA_ADMIN_USER` has length 5 and hashes to `8c6976e5b541` — that is simply SHA-256 of `admin`, recoverable from any wordlist in under a second. The same applies to `HW_USER` (len 4), `BESZEL_ADMIN_EMAIL` (len 11), and most importantly `HW_PASSWORD` (len 12), which is short enough to be worth attacking. So a digest is a safe *comparison* device between two readings on one machine, and an unsafe thing to write into a ticket. The lengths and the set/unset states below carry the whole finding without that risk. ## CB-592 works — proven, without needing a second reading `GITEA_ACCESS_TOKEN` is the control. I did not need the operator's comparison reading to settle it, because the blocked sentinel is a **hardcoded, non-secret string** in `HerdrPeerLauncher.java:798-799`. So I hashed it myself, the same way the script does: ``` sentinel 'blocked-by-bridged-cb592-see-gitea-issue-77' length : 43 sha256 (12) : 35659343919c member reported : LEN=43 SHA256-12=35659343919c ← exact match ``` **The member holds the sentinel, not the admin token.** CB-592 survives the login shell that re-sources `secrets.sh`, which is exactly what the `BRIDGED_MEMBER` marker and the guarded `export` were built to do. That mechanism is sound and should be the model for the fix. I also checked what else the launcher shadows. It is one name and only one: ``` HerdrPeerLauncher.java 764: workerEnv.put("GITEA_TOKEN", gitToken); 848: workerEnv.put("PATH", path); 851: workerEnv.putAll(cfg.env()); 853: workerEnv.put("GITEA_ACCESS_TOKEN", BLOCKED_GITEA_ACCESS_TOKEN); 854: workerEnv.put(MEMBER_MARKER, "1"); ``` So nothing on the rest of the list is shadowed, defaulted, or accidentally empty. They are live values. ## The finding, sorted 31 names, all set. Sorting them by what they are: | Bucket | Count | Names | |---|---|---| | **Blocked, proven** | 1 | `GITEA_ACCESS_TOKEN` | | **Required by design** | 2 | `AI_GATEWAY_TOKEN` (named in `tokenEnv:` for `local`/`gx` — criterion 5), `WORKER_GITEA_TOKEN` (len 40, the repo-scoped forge token a member needs to open its own PR) | | **Already decided keep** (CB-593 / #79) | 1 | `CONTEXT7_TOKEN` | | **Not credentials** | 3 | `GITEA_HOST`, `OPENCODE_AUTOMODE_MODEL`, `TELEGRAM_CHAT_ID` | | **Live secrets with no bridge purpose** | **24** | everything else | That last row is the ticket. Ranked by what it opens: | Name | Length | What it opens | |---|---|---| | `GITLAB_PERSONAL_ACCESS_TOKEN` | 51 | **a second forge, completely unblocked** | | `GITLAB_OAUTH_CLIENT_SECRET` | 70 | the same forge's OAuth app | | `CF_API_TOKEN` · `CF_USER_TOKEN` | 53 · 53 | Cloudflare — DNS, tunnels, workers | | `TS_AUTHKEY` · `TS_API_KEY` | 61 · 60 | Tailscale — can join a device to the tailnet | | `HASS_TOKEN` | 183 | home automation | | `BESZEL_ADMIN_PASSWORD` · `BESZEL_KEY` · `BESZEL_UNIVERSAL_TOKEN` | 28 · 80 · 36 | monitoring admin | | `GRAFANA_ADMIN_PASSWORD` | 24 | Grafana admin | | `HW_PASSWORD` | 12 | admin login | | `TELEGRAM_BOT_TOKEN` | 46 | sends messages as the operator's bot | | `CONFLUENCE_API_TOKEN` | 192 | wiki write access | | `LTMS_API_KEY` · `BRAIN_MCP_TOKEN` · `MEMORY_MCP_TOKEN` · `METRICS_PUSH_TOKEN` | 57 · 64 · 64 · 52 | further internal services | **`GITLAB_PERSONAL_ACCESS_TOKEN` is the headline.** The rule this whole line of work came from was *only the leader and architect may use `GITEA_ACCESS_TOKEN`; everyone else uses `WORKER_GITEA_TOKEN`*. CB-592 enforced that for Gitea. A full personal access token for a **different forge** sits right next to it, untouched, and no ticket had ever named it. That is the same shape as CB-604 → CB-606: the instance that got fixed was not the worst one. ## What is left — criteria 2 to 6 Criterion 1 is done. The rest is implementation, and it is real work, not paperwork: 2. a recorded decision per name, in three buckets 3. blocking driven by a **list**, not a hardcoded constant 4. deny-by-default, or something that reports a newly-added name 5. `AI_GATEWAY_TOKEN` recorded as required — settled above 6. a `wiki/11-Features.md` entry Criterion 4 is the one that decides the design, and I would argue strongly for **deny-by-default**: an allow-list of the four names a member actually needs, with everything else replaced by a sentinel using the CB-592 mechanism that is now proven to work. An explicit deny-list has the property this ticket was filed about — it is silently wrong the moment a new name is added to `secrets.sh`, and nothing anywhere would say so. That decision belongs to the operator, since it is their secret store and their services. Asking now. **Note for the milestone:** this means 1.1 is *not* one operator command away from done. The measurement cleared the blocker on criterion 1, and criteria 2–6 are an implementation unit that still has to land.
Author
Owner

Decision recorded — deny-by-default

The operator chose deny-by-default over a deny-list and over report-only. So criterion 4 is settled by the policy itself rather than by a separate detector: a member gets an explicit allow-list, and every other known credential name is replaced with the CB-592 sentinel.

The allow-list, with the reason each name earns its place — this is criterion 2's decision record for the allowed bucket:

Name Bucket Why
AI_GATEWAY_TOKEN required named in tokenEnv: for the local and gx profiles, so a member reaching the gateway is by design (criterion 5)
WORKER_GITEA_TOKEN required the repo-scoped forge token a member needs to push its branch and open its own PR
CONTEXT7_TOKEN deliberately allowed already decided in CB-593 / #79
GITEA_HOST not a credential a hostname

Everything else on the measured list of 31 goes in the blocked bucket. That is 27 names, and it includes GITEA_ACCESS_TOKEN, which stops being a hardcoded Java constant and becomes an ordinary entry in the list.

Delegated as ticket task-2 to a sonnet member on branch worker/cb596-4e49ef-3. The brief carries the acceptance criteria above plus four constraints worth repeating here, because each one is a way this could go wrong:

  1. known bounds the blast radius. The implementation blocks known minus allow. It must not loop over the whole environment and unset what it does not recognise — PATH, HOME, SHELL and TERM live in that same environment, and wiping them breaks the member completely.
  2. Reuse the CB-592 mechanism, do not reinvent it. A plain env overlay loses to the login shell, which re-exports the value afterwards. The BRIDGED_MEMBER marker plus the guarded export is the only reason the existing block works at all.
  3. Validate policy: at config load, in the shape CB-606 established — name the field, the bad value, the accepted set, and what would otherwise have happened. A silently-accepted typo here would turn the blocking off, which is precisely the CB-606 defect wearing different clothes.
  4. Never print a credential value, prefix, or hash — in code, in tests, or in the report. The sentinel is not a secret and may appear freely.

Criterion 6, the wiki/11-Features.md entry, stays with me: wiki/ is a submodule a member cannot commit to. The member supplies the raw material and I write it.

## Decision recorded — deny-by-default The operator chose **deny-by-default** over a deny-list and over report-only. So criterion 4 is settled by the policy itself rather than by a separate detector: a member gets an explicit allow-list, and every other known credential name is replaced with the CB-592 sentinel. The allow-list, with the reason each name earns its place — this is criterion 2's decision record for the *allowed* bucket: | Name | Bucket | Why | |---|---|---| | `AI_GATEWAY_TOKEN` | **required** | named in `tokenEnv:` for the `local` and `gx` profiles, so a member reaching the gateway is by design (criterion 5) | | `WORKER_GITEA_TOKEN` | **required** | the repo-scoped forge token a member needs to push its branch and open its own PR | | `CONTEXT7_TOKEN` | **deliberately allowed** | already decided in CB-593 / #79 | | `GITEA_HOST` | **not a credential** | a hostname | Everything else on the measured list of 31 goes in the **blocked** bucket. That is 27 names, and it includes `GITEA_ACCESS_TOKEN`, which stops being a hardcoded Java constant and becomes an ordinary entry in the list. Delegated as ticket `task-2` to a `sonnet` member on branch `worker/cb596-4e49ef-3`. The brief carries the acceptance criteria above plus four constraints worth repeating here, because each one is a way this could go wrong: 1. **`known` bounds the blast radius.** The implementation blocks `known` minus `allow`. It must not loop over the whole environment and unset what it does not recognise — `PATH`, `HOME`, `SHELL` and `TERM` live in that same environment, and wiping them breaks the member completely. 2. **Reuse the CB-592 mechanism, do not reinvent it.** A plain env overlay loses to the login shell, which re-exports the value afterwards. The `BRIDGED_MEMBER` marker plus the guarded `export` is the only reason the existing block works at all. 3. **Validate `policy:` at config load**, in the shape CB-606 established — name the field, the bad value, the accepted set, and what would otherwise have happened. A silently-accepted typo here would turn the blocking off, which is precisely the CB-606 defect wearing different clothes. 4. **Never print a credential value, prefix, or hash** — in code, in tests, or in the report. The sentinel is not a secret and may appear freely. Criterion 6, the `wiki/11-Features.md` entry, stays with me: `wiki/` is a submodule a member cannot commit to. The member supplies the raw material and I write it.
Author
Owner

The code half is done and live. Two operator actions left.

Merged ac40de1 · 870 tests, BUILD SUCCESS · deployed, jar e11160695fbe. The daemon's startup line now reads:

memberCredentials: 34 known name(s), 5 allowed — blocking 29 on every spawn

bridge_whoami → primary after the restart, and a live spawn worked.

Criteria

# Criterion State
1 Enumerate what a member actually holds done — measured in a live member, 31 names, later 34
2 Policy in config, not hardcoded done — memberCredentials: block, policy: deny-by-default
3 Blocked names get the sentinel, not an empty value done — blocked-by-bridged-cb596-see-gitea-issue-82
4 The daemon says when the policy is missing or incomplete done — startup WARN, plus a per-spawn gap detector
5 Applied to the live config done — 34 known / 7 allowed / 29 blocked, allow ∩ blocked = []
6 Documented done — wiki/11-Features.md ef84c3d, bridged.example.yaml, Roadmap 01aab0b
7 Proved in a live member blocked on the two actions below

Criterion 7 is the one that matters. The config half alone proves nothing, because the launcher writes the member's environment and then the pane's login shell re-sources the secret store and overwrites it. That is why CB-592 needed a guarded export in the store, and why this needs the same.

Action 1 — rotate GITEA_ACCESS_TOKEN

Already reported and already acknowledged as "later". Recording it here so it is not lost: a lead leaked about 31 characters of it while dumping the structure of the secret store. The redaction covered export NAME=… lines; that token is set on a line that starts with a guard, so it fell through.

Action 2 — append the CB-596 block to the secret store

The file is the operator's and no session edits it. The block to append is at scratchpad/cb596-secrets-append.sh — syntax-checked in both zsh and bash, and behaviour-tested with fake values. It is a BRIDGED_MEMBER-guarded loop that exports the sentinel over the 29 blocked names. It moves, copies and retypes no existing value.

I diffed the daemon's computed blocked set against that block, name by name:

config blocked: 29  |  secrets.sh append: 29
LISTS AGREE

Then — the probe that closes this ticket

Spawn a member, run scripts/probe-member-credentials.sh. Pass condition:

  • the 29 blocked names come back at the sentinel's length and hash;
  • the 7 allowed keep their real lengths.

Reminder on the probe's output: it prints SHA256-12, which is safe for comparing two readings on one machine and unsafe to publish — GRAFANA_ADMIN_USER has length 5 and hashes to SHA-256 of admin. Post names and lengths, not the hash column.

The gap detector earned its place on its first spawn

It warned about two names no hand-written list had ever contained, because neither lives in the secret store:

WARN memberCredentials gap: 2 credential-shaped env var name(s) are on neither known: nor allow:
     — every member pane inherits them UNBLOCKED — [CLAUDE_CODE_MESSAGING_TOKEN, SSH_AUTH_SOCK]

SSH_AUTH_SOCK is required today — worktree remotes are ssh://git@git.ltms.dev:2224, so without it a member cannot push. But it hands a member the operator's ssh-agent: it can sign with every key the agent holds. That is strictly broader than the repo-scoped token CB-302 built to avoid exactly this. Both are allow-listed with the reasoning written into bridged.yaml, and the real fix is filed as #110 (CB-607) on 2.0 — push over HTTPS with WORKER_GITEA_TOKEN, then block it.

The lesson worth keeping: the enumeration was of a file, and the exposure is of an environment. Anything granted by a handle rather than a value is invisible to a list built by reading secrets.sh. That is the argument for the daemon reporting the gap instead of anyone trusting the list.

## The code half is done and live. Two operator actions left. **Merged** `ac40de1` · **870 tests, BUILD SUCCESS** · **deployed**, jar `e11160695fbe`. The daemon's startup line now reads: ``` memberCredentials: 34 known name(s), 5 allowed — blocking 29 on every spawn ``` `bridge_whoami` → `primary` after the restart, and a live spawn worked. ### Criteria | # | Criterion | State | |---|---|---| | 1 | Enumerate what a member actually holds | **done** — measured in a live member, 31 names, later 34 | | 2 | Policy in config, not hardcoded | **done** — `memberCredentials:` block, `policy: deny-by-default` | | 3 | Blocked names get the sentinel, not an empty value | **done** — `blocked-by-bridged-cb596-see-gitea-issue-82` | | 4 | The daemon says when the policy is missing or incomplete | **done** — startup WARN, plus a per-spawn gap detector | | 5 | Applied to the live config | **done** — 34 known / 7 allowed / 29 blocked, `allow ∩ blocked = []` | | 6 | Documented | **done** — `wiki/11-Features.md` `ef84c3d`, `bridged.example.yaml`, Roadmap `01aab0b` | | 7 | **Proved in a live member** | **blocked on the two actions below** | Criterion 7 is the one that matters. The config half alone proves nothing, because the launcher writes the member's environment and *then* the pane's login shell re-sources the secret store and overwrites it. That is why CB-592 needed a guarded export in the store, and why this needs the same. ### Action 1 — rotate `GITEA_ACCESS_TOKEN` Already reported and already acknowledged as "later". Recording it here so it is not lost: a lead leaked about 31 characters of it while dumping the *structure* of the secret store. The redaction covered `export NAME=…` lines; that token is set on a line that starts with a guard, so it fell through. ### Action 2 — append the CB-596 block to the secret store The file is the operator's and no session edits it. The block to append is at `scratchpad/cb596-secrets-append.sh` — syntax-checked in both `zsh` and `bash`, and behaviour-tested with fake values. It is a `BRIDGED_MEMBER`-guarded loop that exports the sentinel over the 29 blocked names. **It moves, copies and retypes no existing value.** I diffed the daemon's computed blocked set against that block, name by name: ``` config blocked: 29 | secrets.sh append: 29 LISTS AGREE ``` ### Then — the probe that closes this ticket Spawn a member, run `scripts/probe-member-credentials.sh`. Pass condition: - the **29 blocked** names come back at the sentinel's length and hash; - the **7 allowed** keep their real lengths. Reminder on the probe's output: it prints `SHA256-12`, which is safe for comparing two readings on one machine and unsafe to publish — `GRAFANA_ADMIN_USER` has length 5 and hashes to SHA-256 of `admin`. Post names and lengths, not the hash column. ### The gap detector earned its place on its first spawn It warned about two names no hand-written list had ever contained, because neither lives in the secret store: ``` WARN memberCredentials gap: 2 credential-shaped env var name(s) are on neither known: nor allow: — every member pane inherits them UNBLOCKED — [CLAUDE_CODE_MESSAGING_TOKEN, SSH_AUTH_SOCK] ``` `SSH_AUTH_SOCK` is **required today** — worktree remotes are `ssh://git@git.ltms.dev:2224`, so without it a member cannot push. But it hands a member the operator's ssh-agent: it can sign with every key the agent holds. That is strictly broader than the repo-scoped token CB-302 built to avoid exactly this. Both are allow-listed with the reasoning written into `bridged.yaml`, and the real fix is filed as **#110 (CB-607)** on 2.0 — push over HTTPS with `WORKER_GITEA_TOKEN`, then block it. The lesson worth keeping: **the enumeration was of a file, and the exposure is of an environment.** Anything granted by a handle rather than a value is invisible to a list built by reading `secrets.sh`. That is the argument for the daemon reporting the gap instead of anyone trusting the list.
Author
Owner

Verified in a live member pane. Closing.

The operator backed up secrets.sh and appended the CB-596 block on 2026-08-17. Both halves are now in place, and the result was measured — not inferred.

The measurement

A member was spawned (local profile, own worktree) and ran scripts/probe-member-credentials.sh in its own pane.

Count
Names the probe checked 31 — all SET, exit 0
Came back at the sentinel's length (43) 26
Kept their real length 5

The five that kept their real values are exactly the allow-list: AI_GATEWAY_TOKEN (48), CONTEXT7_TOKEN (64), GITEA_HOST (21), OPENCODE_AUTOMODE_MODEL (19), WORKER_GITEA_TOKEN (40).

The two that mattered most compare equal to the sentinel string, which is a hardcoded non-secret constant, so the comparison is safe to report:

GITEA_ACCESS_TOKEN           len=43 blocked=YES
GITLAB_PERSONAL_ACCESS_TOKEN len=43 blocked=YES
AI_GATEWAY_TOKEN             len=48 blocked=NO
WORKER_GITEA_TOKEN           len=40 blocked=NO

26 is not 29 — the gap, and how it was closed

The probe reported 26 blocked; the policy blocks 29. Both numbers were right, counting different sets: the probe script hardcodes its own list of 31 names and never reads bridged.yaml, so it skipped the three N8N_* names added to known: after the script was written.

Measured separately in the same live pane:

N8N_ENCRYPTION_KEY  len=43 sentinel=YES
N8N_OWNER_EMAIL     len=43 sentinel=YES
N8N_OWNER_PASSWORD  len=43 sentinel=YES

So 29 of 29 blocked names are confirmed blocked in a real member pane — 26 by the probe, 3 by direct measurement.

The probe's own drift is filed as #111 (CB-608). It deserves its own ticket rather than a footnote here: it is this ticket's defect in a third place, and a verification tool that under-reports fails in the worst direction, because its clean output gets taken as evidence.

Two things the run taught us

The operator's shell is untouched, which was a requirement, not a hope. Same names, same login shell, no BRIDGED_MEMBER: GITEA_ACCESS_TOKEN 40, GITLAB_PERSONAL_ACCESS_TOKEN 51, CF_API_TOKEN 53. The guard does what it claims.

CLAUDE_CODE_MESSAGING_TOKEN is unset in a member pane. It is present in the daemon's environment — that is why the gap detector saw it — but members never receive it. Allow-listing it was harmless, though not for the reason it was allowed. Recorded on #111 for whoever picks up what that token actually grants.

Criteria

# Criterion State
1 Enumerate what a member holds done — 31, later 34
2 Policy in config, not hardcoded done — memberCredentials:, deny-by-default
3 Blocked names get the sentinel, not an empty value done
4 The daemon reports a missing or incomplete policy done — startup summary + per-spawn gap detector
5 Applied to the live config done — 34 known / 7 allow / 29 blocked
6 Documented done — wiki/11-Features.md ef84c3d, bridged.example.yaml, Roadmap 01aab0b
7 Proved in a live member done — 29/29

Still outstanding, and deliberately not blocking this

  • Rotate GITEA_ACCESS_TOKEN. A lead leaked about 31 characters of it into a transcript. The operator chose to rotate later. This is not a CB-596 criterion, and it should not be lost with this ticket — worth its own reminder.
  • #110 (CB-607) — SSH_AUTH_SOCK is allowed because members must push, and it outranks the repo-scoped token. On 2.0.
  • #111 (CB-608) — the probe's hardcoded list. On 2.0.

Closing.

## Verified in a live member pane. Closing. The operator backed up `secrets.sh` and appended the CB-596 block on 2026-08-17. Both halves are now in place, and the result was measured — not inferred. ### The measurement A member was spawned (`local` profile, own worktree) and ran `scripts/probe-member-credentials.sh` in its own pane. | | Count | |---|---| | Names the probe checked | 31 — all `SET`, exit 0 | | Came back at the sentinel's length (43) | **26** | | Kept their real length | **5** | The five that kept their real values are exactly the allow-list: `AI_GATEWAY_TOKEN` (48), `CONTEXT7_TOKEN` (64), `GITEA_HOST` (21), `OPENCODE_AUTOMODE_MODEL` (19), `WORKER_GITEA_TOKEN` (40). The two that mattered most compare **equal to the sentinel string**, which is a hardcoded non-secret constant, so the comparison is safe to report: ``` GITEA_ACCESS_TOKEN len=43 blocked=YES GITLAB_PERSONAL_ACCESS_TOKEN len=43 blocked=YES AI_GATEWAY_TOKEN len=48 blocked=NO WORKER_GITEA_TOKEN len=40 blocked=NO ``` ### 26 is not 29 — the gap, and how it was closed The probe reported 26 blocked; the policy blocks 29. Both numbers were right, counting different sets: **the probe script hardcodes its own list of 31 names** and never reads `bridged.yaml`, so it skipped the three `N8N_*` names added to `known:` after the script was written. Measured separately in the same live pane: ``` N8N_ENCRYPTION_KEY len=43 sentinel=YES N8N_OWNER_EMAIL len=43 sentinel=YES N8N_OWNER_PASSWORD len=43 sentinel=YES ``` So **29 of 29 blocked names are confirmed blocked in a real member pane** — 26 by the probe, 3 by direct measurement. The probe's own drift is filed as **#111 (CB-608)**. It deserves its own ticket rather than a footnote here: it is this ticket's defect in a third place, and a verification tool that under-reports fails in the worst direction, because its clean output gets taken as evidence. ### Two things the run taught us **The operator's shell is untouched**, which was a requirement, not a hope. Same names, same login shell, no `BRIDGED_MEMBER`: `GITEA_ACCESS_TOKEN` 40, `GITLAB_PERSONAL_ACCESS_TOKEN` 51, `CF_API_TOKEN` 53. The guard does what it claims. **`CLAUDE_CODE_MESSAGING_TOKEN` is unset in a member pane.** It is present in the daemon's environment — that is why the gap detector saw it — but members never receive it. Allow-listing it was harmless, though not for the reason it was allowed. Recorded on #111 for whoever picks up what that token actually grants. ### Criteria | # | Criterion | State | |---|---|---| | 1 | Enumerate what a member holds | done — 31, later 34 | | 2 | Policy in config, not hardcoded | done — `memberCredentials:`, `deny-by-default` | | 3 | Blocked names get the sentinel, not an empty value | done | | 4 | The daemon reports a missing or incomplete policy | done — startup summary + per-spawn gap detector | | 5 | Applied to the live config | done — 34 known / 7 allow / 29 blocked | | 6 | Documented | done — `wiki/11-Features.md` `ef84c3d`, `bridged.example.yaml`, Roadmap `01aab0b` | | 7 | **Proved in a live member** | **done — 29/29** | ### Still outstanding, and deliberately not blocking this - **Rotate `GITEA_ACCESS_TOKEN`.** A lead leaked about 31 characters of it into a transcript. The operator chose to rotate later. This is not a CB-596 criterion, and it should not be lost with this ticket — worth its own reminder. - **#110 (CB-607)** — `SSH_AUTH_SOCK` is allowed because members must push, and it outranks the repo-scoped token. On 2.0. - **#111 (CB-608)** — the probe's hardcoded list. On 2.0. Closing.
ltms closed this issue 2026-08-17 13:46:28 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#82