CB-633: constrain a member's environment with a real allow-list (memberCredentials.policy) #147

Merged
ltms merged 2 commits from worker/cb-633-env-allowlist-31c312-8 into main 2026-08-23 08:22:06 +02:00
Member

What changed

A herdr pane runs a login shell that re-sources the operator's secret store, so every credential in it reaches members. CB-596's control was an env overlay applied BEFORE that shell runs — any sourced file could (and did) re-export over it. This moves the control into a file the daemon owns.

  • Seam: per-spawn generated ZDOTDIR directory (.zshenv/.zprofile/.zshrc/.zlogin). Each file sources its $HOME counterpart first; .zlogin runs LAST in the login order, after everything the operator sourced, and blanks every exported variable not on the allow-list. Nothing sourced later can undo it.
  • Derived, never typed: allow-list = union over ALL profiles of tokenEnv/gitTokenEnv/gitHostEnv values and env-map keys + an infrastructure passthrough set (PATH HOME SHELL TERM LANG LC_* TMPDIR USER LOGNAME PWD SHLVL EDITOR PAGER _ ZDOTDIR JAVA_HOME XDG_* etc.) + the exact keys of this spawn's own env overlay. Adding a profile only ever widens the set — it cannot break another spawn.
  • Config: memberCredentials.policy accepts deny-by-default (default, unchanged), deny-list (alias), allow-list. Under allow-list, known:/allow: are reporting only. New memberCredentials.sshAuthSock knob, BLOCKED by default; allowing it is an explicit decision because SSH_AUTH_SOCK is a handle to the operator's ssh-agent (gitea #110/CB-607). Documented in fleetd.example.yaml.
  • Non-zsh shell = no protection: WARN naming the shell, protection declared off, fallback to the old enumerated-name sentinel overlay. Chose warn+fallback over refusing the spawn: a refusal bricks all spawns on bash-only hosts with no recovery short of config surgery; the fallback keeps enumerated-name protection.
  • Denominator reported: the scrub writes an 'allowed N of M' report next to itself; read at pane release and logged ('memberCredentials allow-list: pane X allowed N of M environment variables'). Credential-shaped blanked NAMES go to WARN, never values anywhere.
  • Teardown: report-read + recursive delete in stop(), the one funnel every teardown exit already passes through (explicit DELETE, orphan reap, gate timeout); deleteOnExit backstops the spawn-failure path like existing charter/config temp files.

Tests

mvn clean install: BUILD SUCCESS — 896 tests, 0 failures, 0 errors, 0 skipped (totals summed from target/surefire-reports/*.xml).

The key test starts a real login zsh from a clean parent twice (baseline and scrubbed) and asserts surviving non-empty exports EQUAL baseline ∩ derived allow-list — equality, not blocked-name spot checks. Skips cleanly via assumeTrue when /bin/zsh or $HOME/.zshrc is absent. It caught two real things during development: zsh evaluates special-assoc subscripts arithmetically ('bad math expression', fixed by enumerating exports via env itself), and blanked variables stay exported-empty (survival must mean non-empty value).

Closes gitea fleet/fleetd#144.

## What changed A herdr pane runs a login shell that re-sources the operator's secret store, so every credential in it reaches members. CB-596's control was an env overlay applied BEFORE that shell runs — any sourced file could (and did) re-export over it. This moves the control into a file the daemon owns. - **Seam**: per-spawn generated ZDOTDIR directory (.zshenv/.zprofile/.zshrc/.zlogin). Each file sources its $HOME counterpart first; .zlogin runs LAST in the login order, after everything the operator sourced, and blanks every exported variable not on the allow-list. Nothing sourced later can undo it. - **Derived, never typed**: allow-list = union over ALL profiles of tokenEnv/gitTokenEnv/gitHostEnv values and env-map keys + an infrastructure passthrough set (PATH HOME SHELL TERM LANG LC_* TMPDIR USER LOGNAME PWD SHLVL EDITOR PAGER _ ZDOTDIR JAVA_HOME XDG_* etc.) + the exact keys of this spawn's own env overlay. Adding a profile only ever widens the set — it cannot break another spawn. - **Config**: memberCredentials.policy accepts deny-by-default (default, unchanged), deny-list (alias), allow-list. Under allow-list, known:/allow: are reporting only. New memberCredentials.sshAuthSock knob, BLOCKED by default; allowing it is an explicit decision because SSH_AUTH_SOCK is a handle to the operator's ssh-agent (gitea #110/CB-607). Documented in fleetd.example.yaml. - **Non-zsh shell = no protection**: WARN naming the shell, protection declared off, fallback to the old enumerated-name sentinel overlay. Chose warn+fallback over refusing the spawn: a refusal bricks all spawns on bash-only hosts with no recovery short of config surgery; the fallback keeps enumerated-name protection. - **Denominator reported**: the scrub writes an 'allowed N of M' report next to itself; read at pane release and logged ('memberCredentials allow-list: pane X allowed N of M environment variables'). Credential-shaped blanked NAMES go to WARN, never values anywhere. - **Teardown**: report-read + recursive delete in stop(), the one funnel every teardown exit already passes through (explicit DELETE, orphan reap, gate timeout); deleteOnExit backstops the spawn-failure path like existing charter/config temp files. ## Tests mvn clean install: BUILD SUCCESS — 896 tests, 0 failures, 0 errors, 0 skipped (totals summed from target/surefire-reports/*.xml). The key test starts a real login zsh from a clean parent twice (baseline and scrubbed) and asserts surviving non-empty exports EQUAL baseline ∩ derived allow-list — equality, not blocked-name spot checks. Skips cleanly via assumeTrue when /bin/zsh or $HOME/.zshrc is absent. It caught two real things during development: zsh evaluates special-assoc subscripts arithmetically ('bad math expression', fixed by enumerating exports via env itself), and blanked variables stay exported-empty (survival must mean non-empty value). Closes gitea fleet/fleetd#144.
agent added 1 commit 2026-08-23 07:44:09 +02:00
CB-633: memberCredentials policy=allow-list — derived ZDOTDIR env scrub
CI / build (pull_request) Failing after 1m4s
CI / contract (pull_request) Successful in 1m19s
d432df8e5c
Move member environment control out of the pane-creation env overlay
(defeated by any file the login shell sources) into a per-spawn ZDOTDIR
directory whose .zlogin runs LAST, after the operator's whole chain, and
blanks every exported variable not on an allow-list DERIVED from what the
launcher itself injects (profiles' tokenEnv/gitTokenEnv/gitHostEnv/env
keys + an infrastructure set) — never hand-typed.

- memberCredentials.policy: allow-list (deny-by-default/deny-list stay
  default and unchanged); known:/allow: become reporting only under it.
- memberCredentials.sshAuthSock knob, blocked by default; allowing it is
  an explicit decision (operator ssh-agent handle).
- Non-zsh login shell: loud WARN, protection off, fallback to the old
  enumerated-name overlay.
- Scrub writes an 'allowed N of M' denominator report, read at teardown;
  credential-shaped blanked names go to WARN (names only, never values).
- Equality test against a real login zsh from a clean parent: surviving
  non-empty exports EQUAL baseline ∩ derived allow-list.
ltms added 1 commit 2026-08-23 08:19:07 +02:00
CB-633 round 2: the scrub ran on macOS and did nothing on Linux
CI / build (pull_request) Failing after 1m8s
CI / contract (pull_request) Successful in 1m10s
6f968d59a4
Six review findings, from the peer lead `vms` and a reviewer worker. The first
one is a real defect that would have shipped as a dead control.

1. The scrub only ran in a login shell. It lived in the generated `.zlogin`,
   and zsh reads `.zlogin` only for a login shell. herdr does not open the same
   kind of shell everywhere: measured on herdr 0.8.0, a macOS pane runs `-zsh`
   (login) while a Linux pane runs a plain `/usr/bin/zsh`. So on the vhost this
   was being built for, `.zlogin` never ran and every member kept the whole
   secret store, in silence.

   The scrub body now lives in a generated `scrub.zsh` that BOTH `.zshrc` and
   `.zlogin` source, each after sourcing its own `$HOME` counterpart. Linux
   runs the first, macOS runs both, and the second pass is not merely harmless
   -- it re-scrubs anything the operator's `~/.zlogin` exported after `~/.zshrc`
   had finished. Re-running is idempotent.

2. `INFRASTRUCTURE_PASSTHROUGH` listed names that are not infrastructure:
   ANTHROPIC_AUTH_TOKEN, GITEA_TOKEN, GITEA_HOST, ANTHROPIC_BASE_URL,
   ANTHROPIC_MODEL, CLAUDE_CONFIG_DIR, OPENCODE_CONFIG, BRIDGED_MEMBER. I read
   each injection point and confirmed every one of them reaches `launch.env()`
   only when actually injected, so `allowed.addAll(launch.env().keySet())`
   already covers the legitimate case. As static entries they were pure leak
   surface: a host that happened to export ANTHROPIC_AUTH_TOKEN would have had
   it passed straight through.

3. A missing `scrub-report.txt` at teardown was logged at debug. The report is
   the only evidence the scrub ran at all. Its absence has an innocent reading
   and a serious one, and we cannot tell them apart from the daemon -- so it is
   now a WARN that says exactly that. Logging it at debug is how a control that
   quietly stopped working stays unnoticed.

4. Nothing tested that the control was wired in. Deleting the single
   `applyEnvironmentAllowListPolicy(cfg, launch)` line left all 896 tests green
   while turning the feature completely off -- the CB-586/CB-611 shape again.
   `HerdrPeerLauncherAllowListWiringTest` starts a real spawn and asserts on the
   env that reached herdr. Mutation-checked: unwiring that line fails it.

5. `EnvAllowListScrubTest` now also runs `zsh -i` with no `-l`, which is the
   Linux pane shape, so finding 1 is tested from a Mac. Mutation-checked:
   putting the scrub back in `.zlogin` alone fails that test alone, while the
   login-shell test still passes -- which is exactly the blind spot that let
   the bug through.

6. Two ZDOTDIR leaks closed. A failed spawn has no pane id, so its directory
   was never keyed for teardown; it is now removed on the way out. And
   `deleteOnExit` covers a clean shutdown and nothing else, so `generate` now
   reaps sibling directories older than 24h left by a killed daemon.

Also: `policy:` is lowercased with Locale.ROOT, and the `.zlogin`-only claim is
corrected in fleetd.example.yaml, FleetConfig and HerdrPeerLauncher.

901 tests, 0 failures, `mvn clean install` green.
ltms merged commit 83b50753fe into main 2026-08-23 08:22:06 +02:00
ltms deleted branch worker/cb-633-env-allowlist-31c312-8 2026-08-23 08:22:06 +02:00
Sign in to join this conversation.