diff --git a/11-Features.md b/11-Features.md index 7a9ebd0..ba6c80a 100644 --- a/11-Features.md +++ b/11-Features.md @@ -1918,14 +1918,50 @@ That is weaker, so put members on a zsh account. `.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. +on Linux, with no error anywhere. The scrub now runs from the generated `.zshrc`, `.zlogin` **and +`.zshenv`** (#388) — `.zshenv` is the only file zsh reads unconditionally, so the control no longer +depends on enumerating which kinds of shell exist. If you port this to another terminal backend, +check what kind of shell it opens before trusting it. + +**Do not infer the shell kind from `argv[0]`.** A bare `/usr/bin/zsh` proves *not login* and says +nothing about interactive. Both this repo's javadoc and a host's `fleetd.yaml` once claimed +"interactive but NOT login" on that evidence alone, and neither had measured it. Worse, a probe run +from *inside* an agent measures the agent's own `zsh -c` child, not the pane — opencode forks a +fresh non-interactive shell per command, so it reports truthfully about the wrong process. Read the +pane shell's own `/proc//environ`, or replicate the shape with `script -qec zsh /dev/null`. **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. +**Gotcha — a missing report has more than one meaning, and reading it as one cost a day (#394).** +The report block is the *last* statement in `scrub.zsh`, so its absence proves only that the script +did not reach the end. It does **not** tell you which shell ran. On one host the missing report was +read as evidence that the pane shell was neither login nor interactive; the real cause was that +`export UID=` in zsh is a **fatal parameter error** which terminates the whole sourced file. The +blanking loop is wrapped in `{ ... } 2>/dev/null`, so the message was swallowed too. + +The severity was the *selection*, not the count. `env` lists inherited names first and a startup +file's own exports last, so the loop blanked the harmless inherited half and died immediately +before the operator's own exports — the credentials the policy exists to remove. Measured there: +`UID` was name 42 of 57, and a `~/.zshrc` decoy at 58 survived on 8 of 8 spawns. A partial scrub +got precisely the wrong half. + +Fixed by routing each attempt through `eval "export ${n}=" 2>/dev/null`, which contains the error +to one iteration, rather than by skipping the known-fatal names (`UID EUID GID EGID PPID LINENO`). +A skip-list has to be complete forever; this is a security control, so it must not depend on an +enumeration being right. The report's first line now reads `allowed N of M failed F`, unblankable +names are listed with a `!` prefix, and the daemon WARNs naming them — so "could not blank this +one" is now visible instead of being an abort you learn about from a missing file. + +**Two things to plant when you audit this yourself.** First, a **decoy**: a non-secret variable the +operator's chain exports that is not on the allow-list is the only thing that separates "scrub +skipped" from "nothing was there to scrub" — allow-listed names come back blank either way, so +their blankness proves nothing. Second, check for `.zcompdump` in the pane's `ZDOTDIR`: its +presence proves an interactive zsh ran `compinit` there. A `.zcompdump` present *and* +`scrub-report.txt` absent is only explained by "sourced, then aborted partway". + **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.