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.
+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
|
||||
|
||||
Reference in New Issue
Block a user