CB-596: a member keeps only the credentials you name

Dai Ha
2026-08-17 13:32:14 +02:00
parent 46c50110c8
commit ef84c3dd42
+79 -4
@@ -1583,10 +1583,85 @@ per-role is now a one-line guard in the secret store. The option is cheap and av
exactly why the decision has to be explicit rather than implied by nobody having done it. The
original leak (CB-592) was found by accident. The same accident should not have to happen twice.
**Gotcha.** This decision covers **two names out of about thirty**. The rest have not been reviewed
one by one; `CB-596` tracks enumerating them, and it needs the operator because the probe that would
list them is refused by the command classifier. So read this table as *"these two were decided"*, not
as *"everything else was checked and cleared"*.
**Gotcha.** This decision covers **two names out of about thirty**. The rest were enumerated later by
CB-596 — see *A member keeps only the credentials you name* below, which supersedes this entry's
scope. Read this table as the two that were decided **first**, not as the whole policy.
---
## A member keeps only the credentials you name
**What.** A `memberCredentials:` block in `bridged.yaml` lists every credential-shaped variable on
the host, says which ones a member may keep, and blocks the rest. A blocked name is not unset — it is
overwritten with a fixed sentinel string, `blocked-by-bridged-cb596-see-gitea-issue-82`, so a member
that reads it sees *"deliberately blocked"* rather than an empty variable it might quietly work
around. Before this, a member pane inherited the operator's **whole** secret store and exactly one
name was blocked.
```yaml
memberCredentials:
policy: deny-by-default # the only policy today; named so a future allow-by-default is a change
allow: # names a member MAY keep
- AI_GATEWAY_TOKEN
- WORKER_GITEA_TOKEN
- CONTEXT7_TOKEN
known: # every credential-shaped name on this host
- GITEA_ACCESS_TOKEN
- ...
```
The blocked set is `known` minus `allow`, computed at load. A name in both is an error you cannot
make by accident — the intersection is empty by construction, because `allow` wins.
**On.** Add the block to `bridged.yaml` (there is a commented template in `bridged.example.yaml`) and
add the matching guarded export to the operator's secret store. **Both halves are needed** — see the
gotcha. The block is re-read on every spawn, so editing it takes effect without a restart; only the
startup summary line needs one.
**Why it exists.** CB-592 found that a member inherits the operator's credentials, and blocked one
name — `GITEA_ACCESS_TOKEN` — with a hardcoded string in the launcher. CB-593 then decided two more
by hand. That does not scale and, worse, it hides the shape of the problem: a hardcoded list of one
looks finished. Naming every variable in config turns *"which secrets does a member hold?"* from a
question nobody can answer into a list you can read, review and diff.
**The daemon says so when the block is missing.** With no `memberCredentials:` the daemon still
starts — refusing to boot would strand an operator who has not migrated — but logs a WARN saying
every member pane inherits the whole secret store unblocked. With the block present it logs the
counts instead:
```
memberCredentials: 34 known name(s), 5 allowed — blocking 29 on every spawn
```
**It also reports what you forgot.** On every spawn the launcher scans the host environment for
names *shaped* like credentials (`TOKEN`, `SECRET`, `_KEY`, `APIKEY`, `PASSWORD`, `CREDENTIAL`,
`AUTH`) and warns about any that are on neither list. This is the part that pays for itself: the
first spawn after it deployed named two variables 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 the interesting one, and it is **allowed on purpose**: a worktree's remote is
`ssh://git@git.ltms.dev`, so without the agent socket a member cannot push at all. But that socket
lets a member sign with every key the operator's agent holds — strictly more power than the
repo-scoped forge token CB-302 built to avoid exactly this. Tracked as **CB-607**; the fix is to push
over HTTPS with `WORKER_GITEA_TOKEN` and then block it. Do not block it first, or members stop
pushing.
**Gotcha — the config half alone does not hold.** The launcher writes the member's environment at
spawn, and then the member's pane runs a **login shell**, which re-sources the operator's secret store
and overwrites it. So `memberCredentials:` must be paired with a `BRIDGED_MEMBER`-guarded block at the
end of the secret store that re-applies the same sentinel. Two lists that must agree — which is why
the gap detector exists, and why the daemon logs its counts at startup. If a member ever reports
holding a name you blocked, the secret-store half is what is missing.
**Second gotcha — `allow` silences the warning.** The detector treats `known ∪ allow` as covered, so
adding a name to `allow` makes its warning go away *and* lets the value through. That is correct
behaviour, but it means the quiet way to dismiss a gap warning is also the permissive one. Prefer
adding to `known`.
---