Features: the credential scrub aborted, and a missing report means more than one thing

Two updates to the memberCredentials entry, both from fleetd #388 and #394.

#388: the scrub now runs from the generated `.zshenv` as well as `.zshrc` and
`.zlogin`. `.zshenv` is the only file zsh reads unconditionally, so the control
no longer depends on enumerating which kinds of shell exist.

#394: a missing `scrub-report.txt` had been read as evidence about which shell
ran. It is not. The report block is the last statement in the script, so its
absence proves only that the script did not reach the end. The real cause was
`export UID=`, a fatal zsh parameter error that terminates the whole sourced
file, with the message swallowed by the loop's `2>/dev/null`.

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
half and died immediately before the operator's own exports.

Also added, because both cost real time:

- do not infer shell kind from argv[0], and never from a probe run inside an
  agent — opencode forks a fresh `zsh -c` per command, so the probe answers
  about itself
- plant a decoy when auditing this: allow-listed names come back blank whether
  or not the scrub ran, so their blankness proves nothing
- `.zcompdump` present plus `scrub-report.txt` absent is only explained by
  "sourced, then aborted partway"

Root cause found by the fleet01 lead; the inverted-selection wording is theirs.
Dai Ha
2026-09-10 08:31:41 +07:00
parent 803726a8c3
commit 216191277c
+38 -2
@@ -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/<pid>/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.