diff --git a/11-Features.md b/11-Features.md index 39441e0..7c03310 100644 --- a/11-Features.md +++ b/11-Features.md @@ -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