CB-596: a member keeps only the credentials you name
+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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user