CB-633: document memberCredentials.policy: allow-list

The page told operators the control needs a BRIDGED_MEMBER-guarded block at the
end of the secret store. That is now only true for deny-by-default. The
allow-list policy owns the seam itself, so no operator file is involved.

Records the two things that cost the most to learn: a control inside a sourced
file can always be undone by a file sourced later (a correct block list still
leaked seven credentials), and the scrub in .zlogin alone was dead on Linux
because a herdr pane there is not a login shell.
Dai Ha
2026-08-23 08:23:54 +02:00
parent 569a917ba6
commit 118be51c07
+51 -6
@@ -1651,16 +1651,61 @@ repo-scoped forge token CB-302 built to avoid exactly this. Tracked as **CB-607*
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.
**Gotcha — under `deny-by-default` 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 `deny-by-default` 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.
That block must be the **last** thing in the secret store. It overwrites the blocked names, so
anything that re-exports them afterwards silently undoes it.
**`policy: allow-list` removes that whole problem (CB-633).** It is the setting to prefer.
```yaml
memberCredentials:
policy: allow-list # default is "deny-by-default"
sshAuthSock: block # "allow" only if a member must use the operator's ssh-agent
```
**Why it exists.** A control that lives inside a sourced file can always be undone by a file sourced
later, and that is not a hypothetical: on this host `.ltms` sources `mgnlSecrets.sh` one line *after*
the guarded `secrets.sh`, so seven credentials reached every member in full — including an AWS key
with `AdministratorAccess`. Four of the seven were already on the block list. The list was correct
and it still failed. Making the list longer fixes nothing.
**How it works.** The daemon generates a throwaway `ZDOTDIR` directory per spawn and passes it in the
pane-creation env map, before the shell starts. Each generated startup file sources its `$HOME`
counterpart first and then runs the scrub, so the scrub happens *after* the operator's whole chain
and nothing sourced later can undo it. The kept-name set is **derived, never typed**: every profile's
`tokenEnv`/`gitTokenEnv`/`gitHostEnv` values and `env:` keys, plus an infrastructure set (`PATH`
`HOME` `SHELL` `TERM` `LANG` `LC_*` `TMPDIR` `USER` `LOGNAME` `PWD` `SHLVL` `EDITOR` `PAGER`
`ZDOTDIR` `JAVA_HOME` `XDG_*`), plus the exact keys this spawn's own env overlay carries. Adding a
profile can only widen the set, so it can never break another spawn's scrub. Under this policy
`known:`/`allow:` stop being a control and become reporting only — they still feed the gap warning.
**Gotcha — it is zsh only.** `ZDOTDIR` means nothing to bash. If the member's shell is not zsh the
daemon logs a loud WARN saying protection is off and falls back to the `deny-by-default` overlay.
That is weaker, so put members on a zsh account.
**Gotcha — the platform nearly made this a dead control.** The scrub first lived in the generated
`.zlogin`, and zsh reads `.zlogin` only for a *login* shell. herdr does not open the same kind of
shell everywhere: a macOS pane runs `-zsh` (login), a Linux pane runs a plain `/usr/bin/zsh`
(interactive, not login). So it protected the developer's Mac and would have protected nothing at all
on Linux, with no error anywhere. The scrub now runs from both the generated `.zshrc` and `.zlogin`.
If you port this to another terminal backend, check what kind of shell it opens before trusting it.
**How to tell it actually ran.** Each pane writes a `scrub-report.txt`, and the daemon logs
`allowed N of M environment variables` when that pane stops — the denominator is the point. A
**missing** report is logged at WARN: the scrub then cannot be confirmed to have run at all, and a
silently dead control is the failure this policy exists to remove.
**Measured, not assumed.** A real login zsh started from a clean parent kept **15 of 78** names with
zero profiles configured; an earlier prototype run kept 3 of 28. The test asserts *equality* between
the survivors and `baseline ∩ derived allow-list`, not a spot check of a few blocked names.
**Verified, not assumed.** Measured on 2026-08-17 inside a live member pane, once both halves were in
place: **29 of 29 blocked names hold the sentinel**, and the allow-listed names present in that pane
keep their real values. The operator's own login shell is unchanged, because the whole block sits