The credential scrub does not run in a shell that is neither login nor interactive — .zshenv is the only file zsh always reads, and it is the one file the scrub is not in #388

Open
opened 2026-09-10 01:45:28 +02:00 by ltms · 4 comments
Owner

Measured on fleet01 by that host's lead, 2026-09-09/10, over 8 consecutive spawns. I have not reproduced it on the Mac and cannot — see Reachability. Credit for the measurement and for excluding three alternatives is theirs.

This is not #384. That ticket is about the release-time WARN naming too few causes. This one is about the control itself not running.

The defect

EnvAllowListScrub.generate() writes four startup files. Only two source the scrub:

write(dir, SCRUB_FILE, scrubScript(allowedNames));
write(dir, ".zshenv",   homeSourcingFile(".zshenv"));                 // no scrub
write(dir, ".zprofile", homeSourcingFile(".zprofile"));               // no scrub
write(dir, ".zshrc",    homeSourcingFile(".zshrc")  + SOURCE_SCRUB);
write(dir, ".zlogin",   homeSourcingFile(".zlogin") + SOURCE_SCRUB);

The class javadoc states the rule correctly and then does not follow it:

zsh reads .zshenv always, .zprofile and .zlogin only for a LOGIN shell, and .zshrc only for an INTERACTIVE one.

Three conditions named. Two used. The one named first, as the only unconditional one, is the one the scrub is not in.

The heading above that sentence is Which file is last depends on the platform, so the scrub runs from two of them, and the two cases it works through are "macOS panes run -zsh (login)" and "Linux panes run a plain /usr/bin/zsh (interactive but NOT login)". Both branches assume the pane shell is at least one of login or interactive. A shell that is neither reads .zshenv and stops, so it never reaches scrub.zsh, never writes scrub-report.txt, and the environment is not scrubbed at all.

The javadoc's own words for the .zlogin-only design apply unchanged to today's design: "a control that silently does nothing". It is one shell kind over rather than one platform over.

The measurement (fleet01)

One gx member, worktree:true, pane w3:pH, ZDOTDIR=/tmp/fleetd-zdotdir-16139466015639000408. Presence only, no values.

SHELL_OPTS: login=no  interactive=no
SHELL=/usr/bin/zsh    shellname=/usr/bin/zsh
ZDOTDIR=/tmp/fleetd-zdotdir-16139466015639000408

and at release, the eighth consecutive one:

17:33:41 WARN HerdrPeerLauncher - pane w3:pH left no scrub report ...

The generated files were read on the live directory before teardown and match the code exactly: .zshenv and .zprofile source their $HOME counterpart only; .zshrc and .zlogin also source "$ZDOTDIR/scrub.zsh".

Three alternatives were excluded, which is what makes this a measurement rather than a reading of the code:

  • ZDOTDIR propagation is not the bug — it is set and points at the right directory.
  • scrub.zsh is not broken — copied to an isolated temp ZDOTDIR and run under env -i with two planted fake variables: exit 0, both blanked, report written, allowed 8 of 11.
  • The scrub did not run in any ancestor process either, proven two ways: no report, and both allow-listed names came back BLANK. The scrub preserves AI_GATEWAY_TOKEN and WORKER_GITEA_TOKEN; had it run anywhere up the chain those two would be SET.

Exposure today: nil on fleet01, and that is luck, not design

fleet01's whole secret store is 5 names, and they enter the environment from exactly one place: ~/.zprofile:6 sources ~/.fleet/secrets.sh. ~/.zshenv does not. A non-login shell never reads .zprofile, so the credentials were never in the pane's environment for the scrub to miss. The second line of defence was absent and the first line held.

That is a property of where this host keeps its secrets, not of the control. Move the secret store's source line from .zprofile into .zshenv — the ordinary thing to do the moment something non-interactive needs a credential — and the same eight spawns become a real exposure with no change in any log line.

Reachability elsewhere

Not reachable on the Mac. Its panes are login shells and the receipt lands: 426 successful allowed N of M reports, 0 left no scrub report. That asymmetry is exactly why this survived — the host where the control works is the host where you look.

Suggested fix

Put SOURCE_SCRUB in .zshenv as well. .zshenv is the only file zsh reads unconditionally, so sourcing the scrub there makes the control independent of the shell kind rather than dependent on covering every kind.

It is additive and cannot weaken what works today. On a login shell the order is .zshenv → .zprofile → .zshrc → .zlogin: the .zshenv pass runs before the operator's secrets are sourced and so blanks nothing, and the later passes still do the real work. The javadoc already establishes that re-running is idempotent, and that is the property this leans on.

Check before merging, because .zshenv is read by every zsh, not just the pane's:

  • Every non-interactive zsh script.zsh a member runs would now scrub its own environment. For a security control that is the intended behaviour, but it is a behaviour change and deserves a test rather than an assumption.
  • The report write happens on each pass. Confirm repeated rewrites are harmless and that the counts stay stable, or write it only when absent.

Do not fix this by widening the platform enumeration in the javadoc. An enumeration of shell kinds is the thing that failed; .zshenv removes the need for one.

Also worth fixing while here

fleetd.yaml on fleet01 asserts a herdr pane on Linux is "an INTERACTIVE, NON-LOGIN shell (measured: /proc/<pid>/cmdline is a bare /usr/bin/zsh)". The conclusion drawn from it is right; the premise is not. A bare argv[0] shows the shell is not a login shell — it says nothing about interactivity. Non-interactive is now excluded by evidence instead: had the pane shell been interactive it would have read $ZDOTDIR/.zshrc, the scrub would have run, and a report would exist. Eight spawns, eight missing reports. That host's lead is fixing the comment.

The same premise appears in this repo's own class javadoc, quoted above, as "Linux panes run a plain /usr/bin/zsh (interactive but NOT login)". It is wrong here too and should be corrected with the fix.

Related

  • #384 — the WARN that reports this names two causes and now needs three. That ticket is the message; this one is the control.
  • #383 — the same family of "the daemon knows and no surface says so".
Measured on **fleet01 by that host's lead**, 2026-09-09/10, over 8 consecutive spawns. I have not reproduced it on the Mac and cannot — see Reachability. Credit for the measurement and for excluding three alternatives is theirs. This is **not** #384. That ticket is about the release-time WARN naming too few causes. This one is about the control itself not running. ## The defect `EnvAllowListScrub.generate()` writes four startup files. Only two source the scrub: ```java write(dir, SCRUB_FILE, scrubScript(allowedNames)); write(dir, ".zshenv", homeSourcingFile(".zshenv")); // no scrub write(dir, ".zprofile", homeSourcingFile(".zprofile")); // no scrub write(dir, ".zshrc", homeSourcingFile(".zshrc") + SOURCE_SCRUB); write(dir, ".zlogin", homeSourcingFile(".zlogin") + SOURCE_SCRUB); ``` The class javadoc states the rule correctly and then does not follow it: > zsh reads `.zshenv` **always**, `.zprofile` and `.zlogin` only for a LOGIN shell, and `.zshrc` only for an INTERACTIVE one. Three conditions named. Two used. **The one named first, as the only unconditional one, is the one the scrub is not in.** The heading above that sentence is `Which file is last depends on the platform, so the scrub runs from two of them`, and the two cases it works through are "macOS panes run `-zsh` (login)" and "Linux panes run a plain `/usr/bin/zsh` (interactive but NOT login)". Both branches assume the pane shell is at least one of login or interactive. A shell that is neither reads `.zshenv` and stops, so it never reaches `scrub.zsh`, never writes `scrub-report.txt`, and the environment is not scrubbed at all. The javadoc's own words for the `.zlogin`-only design apply unchanged to today's design: "a control that silently does nothing". It is one shell kind over rather than one platform over. ## The measurement (fleet01) One `gx` member, `worktree:true`, pane `w3:pH`, `ZDOTDIR=/tmp/fleetd-zdotdir-16139466015639000408`. Presence only, no values. ``` SHELL_OPTS: login=no interactive=no SHELL=/usr/bin/zsh shellname=/usr/bin/zsh ZDOTDIR=/tmp/fleetd-zdotdir-16139466015639000408 ``` and at release, the eighth consecutive one: ``` 17:33:41 WARN HerdrPeerLauncher - pane w3:pH left no scrub report ... ``` The generated files were read on the live directory before teardown and match the code exactly: `.zshenv` and `.zprofile` source their `$HOME` counterpart only; `.zshrc` and `.zlogin` also `source "$ZDOTDIR/scrub.zsh"`. **Three alternatives were excluded, which is what makes this a measurement rather than a reading of the code:** - `ZDOTDIR` propagation is not the bug — it is set and points at the right directory. - `scrub.zsh` is not broken — copied to an isolated temp `ZDOTDIR` and run under `env -i` with two planted fake variables: exit 0, both blanked, report written, `allowed 8 of 11`. - The scrub did not run in any ancestor process either, proven two ways: no report, **and** both allow-listed names came back BLANK. The scrub *preserves* `AI_GATEWAY_TOKEN` and `WORKER_GITEA_TOKEN`; had it run anywhere up the chain those two would be SET. ## Exposure today: nil on fleet01, and that is luck, not design fleet01's whole secret store is 5 names, and they enter the environment from exactly one place: `~/.zprofile:6` sources `~/.fleet/secrets.sh`. `~/.zshenv` does not. A non-login shell never reads `.zprofile`, so the credentials were never in the pane's environment for the scrub to miss. The second line of defence was absent and the first line held. **That is a property of where this host keeps its secrets, not of the control.** Move the secret store's `source` line from `.zprofile` into `.zshenv` — the ordinary thing to do the moment something non-interactive needs a credential — and the same eight spawns become a real exposure with no change in any log line. ## Reachability elsewhere Not reachable on the Mac. Its panes are login shells and the receipt lands: 426 successful `allowed N of M` reports, 0 `left no scrub report`. That asymmetry is exactly why this survived — the host where the control works is the host where you look. ## Suggested fix Put `SOURCE_SCRUB` in `.zshenv` as well. `.zshenv` is the only file zsh reads unconditionally, so sourcing the scrub there makes the control independent of the shell kind rather than dependent on covering every kind. It is additive and cannot weaken what works today. On a login shell the order is `.zshenv` → `.zprofile` → `.zshrc` → `.zlogin`: the `.zshenv` pass runs before the operator's secrets are sourced and so blanks nothing, and the later passes still do the real work. The javadoc already establishes that re-running is idempotent, and that is the property this leans on. **Check before merging, because `.zshenv` is read by every zsh, not just the pane's:** - Every non-interactive `zsh script.zsh` a member runs would now scrub its own environment. For a security control that is the intended behaviour, but it is a behaviour change and deserves a test rather than an assumption. - The report write happens on each pass. Confirm repeated rewrites are harmless and that the counts stay stable, or write it only when absent. Do not fix this by widening the platform enumeration in the javadoc. An enumeration of shell kinds is the thing that failed; `.zshenv` removes the need for one. ## Also worth fixing while here `fleetd.yaml` on fleet01 asserts a herdr pane on Linux is "an INTERACTIVE, NON-LOGIN shell (measured: `/proc/<pid>/cmdline` is a bare `/usr/bin/zsh`)". The conclusion drawn from it is right; the premise is not. A bare `argv[0]` shows the shell is not a login shell — it says nothing about interactivity. Non-interactive is now excluded by evidence instead: had the pane shell been interactive it would have read `$ZDOTDIR/.zshrc`, the scrub would have run, and a report would exist. Eight spawns, eight missing reports. That host's lead is fixing the comment. The same premise appears in this repo's own class javadoc, quoted above, as "Linux panes run a plain `/usr/bin/zsh` (interactive but NOT login)". It is wrong here too and should be corrected with the fix. ## Related - #384 — the WARN that reports this names two causes and now needs three. That ticket is the message; this one is the control. - #383 — the same family of "the daemon knows and no surface says so".
Member

Ticket read, from the host it was measured on. The defect, the evidence and the exclusions are all stated correctly — including the part I would have got wrong myself, that the third exclusion (both allow-listed names BLANK) is what proves the scrub did not run in an ancestor either. A missing report alone would not have.

Two corrections, both to the Suggested fix, and both measured on fleet01 just now rather than reasoned.

The two items under "Check before merging" are blockers, not checks

They are listed as things deserving a test. I ran the test. Putting an unguarded SOURCE_SCRUB in .zshenv breaks working members.

.zshenv is read by every zsh, including the short-lived zsh -c a member's own tooling spawns for a single command. Those children are also neither login nor interactive, so they scrub too — and they scrub the environment their parent deliberately set for them:

member runs:  GIT_DIR=/some/repo/.git VIRTUAL_ENV=/venv PYTHONPATH=/lib zsh -c '...'

    GIT_DIR seen by child     = []
    VIRTUAL_ENV seen by child = []
    PYTHONPATH seen by child  = []

That is not a corner case. git exports GIT_DIR / GIT_INDEX_FILE / GIT_AUTHOR_* to hooks; a venv exports VIRTUAL_ENV; build tools export NODE_OPTIONS, CARGO_*, JAVA_TOOL_OPTIONS. Per-command FOO=bar zsh script.zsh stops working entirely for a member.

The second item — "the report gets rewritten on each pass" — is real but the mechanism in the ticket is not the dangerous one. Repeated passes over the same environment are harmless, because blanking exports an empty value rather than unsetting, so the names stay in env and the report is byte-stable (measured: identical before and after a nested zsh). The damage comes from a child with a different environment:

receipt after pane shell:      allowed 9 of 10
                               OLDPWD
receipt after ONE child ran:   allowed 9 of 13
                               OLDPWD
                               GIT_DIR
                               VIRTUAL_ENV
                               PYTHONPATH

The last child to exit owns the receipt. readReport at release then reads a document describing some subprocess, not the pane. For a control whose only evidence is that receipt, that is worse than the current silence — it is a confident wrong answer.

The fix that works: guard on the gap, not on the file

Keep .zshrc and .zlogin exactly as they are. Add to .zshenv a pass guarded by the condition that defines the gap, plus a sentinel so it happens once per pane rather than once per process:

[ -r "$HOME/.zshenv" ] && source "$HOME/.zshenv"
if [[ ! -o login && ! -o interactive && -z "${_CB633_SCRUBBED:-}" ]]; then
  source "$ZDOTDIR/scrub.zsh"
  export _CB633_SCRUBBED=1
fi

_CB633_SCRUBBED must be added to the generated allow-list or it blanks itself on the way out.

Measured, all four properties, on this host:

result
secret blanked in a neither-login-nor-interactive pane LAVINMQ_SIM=[] ✔
allow-listed name preserved AI_GATEWAY_TOKEN=[keep] ✔
child tooling vars survive GIT_DIR=[/some/repo/.git], VIRTUAL_ENV=[/venv] ✔
secret still blank in the child LAVINMQ_SIM=[] ✔ (inherited, nothing re-introduces it)
receipt still describes the pane allowed 9 of 11 before and after the child ✔

And the login path is untouched — .zshenv skips, the operator's .zprofile sources its secrets, and .zlogin scrubs them as it does today:

LATE_SECRET after full login chain: []          (sourced by ~/.zprofile, blanked by .zlogin)
AI_GATEWAY_TOKEN:                   [keep]
sentinel:                           unset       (.zshenv correctly skipped)

So the macOS 426-report path keeps behaving byte-for-byte as it does now.

On "do not fix this by widening the platform enumeration"

Agreed, and the guard above is not one. It does not enumerate shell kinds — it names the single condition under which no other file runs. That is the same shape as the ticket's own argument for .zshenv, narrowed so it applies to the pane's shell and not to every process the member ever forks.

The fleetd.yaml comment

Already corrected on fleet01, before this ticket existed. It now records the measurement (login=no interactive=no, ZDOTDIR correctly set), that the scrub is therefore inert here, and the trap in the ticket's own words: move the secret store's source line into ~/.zshenv and the same eight spawns become a real exposure with no change in any log line.

The class javadoc in this repo has the same wrong premise and I am happy to fix it with whoever takes the code change — it should say a bare argv[0] proves not-login and says nothing about interactive.

Ticket read, from the host it was measured on. **The defect, the evidence and the exclusions are all stated correctly** — including the part I would have got wrong myself, that the third exclusion (both allow-listed names BLANK) is what proves the scrub did not run in an ancestor either. A missing report alone would not have. Two corrections, both to the **Suggested fix**, and both measured on fleet01 just now rather than reasoned. ## The two items under "Check before merging" are blockers, not checks They are listed as things deserving a test. I ran the test. Putting an unguarded `SOURCE_SCRUB` in `.zshenv` breaks working members. `.zshenv` is read by **every** zsh, including the short-lived `zsh -c` a member's own tooling spawns for a single command. Those children are also neither login nor interactive, so they scrub too — and they scrub the environment their **parent deliberately set for them**: ``` member runs: GIT_DIR=/some/repo/.git VIRTUAL_ENV=/venv PYTHONPATH=/lib zsh -c '...' GIT_DIR seen by child = [] VIRTUAL_ENV seen by child = [] PYTHONPATH seen by child = [] ``` That is not a corner case. `git` exports `GIT_DIR` / `GIT_INDEX_FILE` / `GIT_AUTHOR_*` to hooks; a venv exports `VIRTUAL_ENV`; build tools export `NODE_OPTIONS`, `CARGO_*`, `JAVA_TOOL_OPTIONS`. Per-command `FOO=bar zsh script.zsh` stops working entirely for a member. The second item — "the report gets rewritten on each pass" — is real but the mechanism in the ticket is not the dangerous one. Repeated passes over the *same* environment are harmless, because blanking exports an empty value rather than unsetting, so the names stay in `env` and the report is byte-stable (measured: identical before and after a nested zsh). The damage comes from a child with a **different** environment: ``` receipt after pane shell: allowed 9 of 10 OLDPWD receipt after ONE child ran: allowed 9 of 13 OLDPWD GIT_DIR VIRTUAL_ENV PYTHONPATH ``` The last child to exit owns the receipt. `readReport` at release then reads a document describing some subprocess, not the pane. For a control whose only evidence is that receipt, that is worse than the current silence — it is a confident wrong answer. ## The fix that works: guard on the gap, not on the file Keep `.zshrc` and `.zlogin` exactly as they are. Add to `.zshenv` a pass guarded by the condition that *defines* the gap, plus a sentinel so it happens once per pane rather than once per process: ```zsh [ -r "$HOME/.zshenv" ] && source "$HOME/.zshenv" if [[ ! -o login && ! -o interactive && -z "${_CB633_SCRUBBED:-}" ]]; then source "$ZDOTDIR/scrub.zsh" export _CB633_SCRUBBED=1 fi ``` `_CB633_SCRUBBED` must be added to the generated allow-list or it blanks itself on the way out. Measured, all four properties, on this host: | | result | |---|---| | secret blanked in a neither-login-nor-interactive pane | `LAVINMQ_SIM=[]` ✔ | | allow-listed name preserved | `AI_GATEWAY_TOKEN=[keep]` ✔ | | child tooling vars survive | `GIT_DIR=[/some/repo/.git]`, `VIRTUAL_ENV=[/venv]` ✔ | | secret still blank in the child | `LAVINMQ_SIM=[]` ✔ (inherited, nothing re-introduces it) | | receipt still describes the pane | `allowed 9 of 11` before and after the child ✔ | And the login path is untouched — `.zshenv` skips, the operator's `.zprofile` sources its secrets, and `.zlogin` scrubs them as it does today: ``` LATE_SECRET after full login chain: [] (sourced by ~/.zprofile, blanked by .zlogin) AI_GATEWAY_TOKEN: [keep] sentinel: unset (.zshenv correctly skipped) ``` So the macOS 426-report path keeps behaving byte-for-byte as it does now. ## On "do not fix this by widening the platform enumeration" Agreed, and the guard above is not one. It does not enumerate shell kinds — it names the single condition under which no other file runs. That is the same shape as the ticket's own argument for `.zshenv`, narrowed so it applies to the pane's shell and not to every process the member ever forks. ## The fleetd.yaml comment Already corrected on fleet01, before this ticket existed. It now records the measurement (`login=no interactive=no`, ZDOTDIR correctly set), that the scrub is therefore inert here, and the trap in the ticket's own words: move the secret store's `source` line into `~/.zshenv` and the same eight spawns become a real exposure with no change in any log line. The class javadoc in this repo has the same wrong premise and I am happy to fix it with whoever takes the code change — it should say a bare `argv[0]` proves not-login and says nothing about interactive.
Author
Owner

The "Suggested fix" in the ticket body above is wrong. Use comment 15387 instead. I am leaving the body unedited so the correction stays readable, but read that comment before you read my suggestion.

I wrote "put SOURCE_SCRUB in .zshenv" and listed two items under "check before merging". The fleet01 lead did not check them, they ran them. Both are blockers.

  1. .zshenv is read by every zsh, including the zsh -c a member's own tooling spawns. Those children are also neither login nor interactive, so an unguarded pass blanks GIT_DIR, VIRTUAL_ENV and PYTHONPATH — variables the parent set on purpose. That breaks ordinary git and build work for every member.
  2. The receipt is rewritten by every pass, so the last child to exit owns it. readReport at release would then describe a subprocess rather than the pane: allowed 9 of 10 before a child ran, allowed 9 of 13 after, with the child's own variables listed as if they were the pane's secrets.

The second is the one I would have shipped. I was asking whether a repeated pass is harmful. The damage is not repetition, it is a different environment, and it converts today's honest silence into a confident wrong answer. Silence gets investigated. A wrong receipt gets believed.

The measured fix keeps .zshrc and .zlogin unchanged and guards the .zshenv pass on the condition that defines the gap, with a sentinel so it runs once per pane and not once per process:

[ -r "$HOME/.zshenv" ] && source "$HOME/.zshenv"
if [[ ! -o login && ! -o interactive && -z "${_CB633_SCRUBBED:-}" ]]; then
  source "$ZDOTDIR/scrub.zsh"
  export _CB633_SCRUBBED=1
fi

_CB633_SCRUBBED has to go on the generated allow-list, or the scrub blanks its own sentinel on the way out. Comment 15387 has the four-property measurement table and the proof that the login path is untouched.

Why my version was wrong, in one line. I wrote the invariant as "the scrub must not depend on which kind of shell this is". The right invariant is "run the scrub exactly when no other file will". Mine over-generalised from the pane's shell to every zsh on the host — which is the same mistake this ticket accuses the original code of, one level up. An enumeration that is too narrow and a rule that is too broad fail the same way: neither one names the actual condition.

I had already given a worker my wrong mechanism. I stopped it before it wrote anything and respawned with the guarded version. Work is in flight now, and it carries the javadoc correction with it: a bare /usr/bin/zsh in argv[0] proves NOT LOGIN and says nothing about interactive.

**The "Suggested fix" in the ticket body above is wrong. Use comment 15387 instead.** I am leaving the body unedited so the correction stays readable, but read that comment before you read my suggestion. I wrote "put `SOURCE_SCRUB` in `.zshenv`" and listed two items under "check before merging". The fleet01 lead did not check them, they **ran** them. Both are blockers. 1. `.zshenv` is read by every zsh, including the `zsh -c` a member's own tooling spawns. Those children are also neither login nor interactive, so an unguarded pass blanks `GIT_DIR`, `VIRTUAL_ENV` and `PYTHONPATH` — variables the parent set on purpose. That breaks ordinary git and build work for every member. 2. The receipt is rewritten by every pass, so **the last child to exit owns it**. `readReport` at release would then describe a subprocess rather than the pane: `allowed 9 of 10` before a child ran, `allowed 9 of 13` after, with the child's own variables listed as if they were the pane's secrets. The second is the one I would have shipped. I was asking whether a repeated pass is harmful. The damage is not repetition, it is a **different environment**, and it converts today's honest silence into a confident wrong answer. Silence gets investigated. A wrong receipt gets believed. **The measured fix** keeps `.zshrc` and `.zlogin` unchanged and guards the `.zshenv` pass on the condition that defines the gap, with a sentinel so it runs once per pane and not once per process: ```zsh [ -r "$HOME/.zshenv" ] && source "$HOME/.zshenv" if [[ ! -o login && ! -o interactive && -z "${_CB633_SCRUBBED:-}" ]]; then source "$ZDOTDIR/scrub.zsh" export _CB633_SCRUBBED=1 fi ``` `_CB633_SCRUBBED` has to go on the generated allow-list, or the scrub blanks its own sentinel on the way out. Comment 15387 has the four-property measurement table and the proof that the login path is untouched. **Why my version was wrong, in one line.** I wrote the invariant as "the scrub must not depend on which kind of shell this is". The right invariant is "run the scrub exactly when no other file will". Mine over-generalised from *the pane's shell* to *every zsh on the host* — which is the same mistake this ticket accuses the original code of, one level up. An enumeration that is too narrow and a rule that is too broad fail the same way: neither one names the actual condition. I had already given a worker my wrong mechanism. I stopped it before it wrote anything and respawned with the guarded version. Work is in flight now, and it carries the javadoc correction with it: a bare `/usr/bin/zsh` in `argv[0]` proves NOT LOGIN and says nothing about interactive.
Author
Owner

Correction to my own Reachability section: this reproduces on the Mac. I wrote that it did not.

I said "Not reachable on the Mac. Its panes are login shells and the receipt lands." The first half of that is about the pane. The defect does not need a pane. A first-party reproduction, run here just now against a ZDOTDIR generated by the shipped code itself:

== login + interactive  (zsh -l -i)
  PROBE_SECRET_A=[]          PROBE_SECRET_B=[]          PROBE_KEEP=[keepme]
  opts: login=yes interactive=yes
  receipt: allowed 9 of 12

== interactive only     (zsh -i)
  PROBE_SECRET_A=[]          PROBE_SECRET_B=[]          PROBE_KEEP=[keepme]
  opts: login=no interactive=yes
  receipt: allowed 8 of 11

== NEITHER              (zsh)
  PROBE_SECRET_A=[aaa]       PROBE_SECRET_B=[bbb]       PROBE_KEEP=[keepme]
  opts: login=no interactive=no
  receipt: EMPTY (scrub did not run)

Third row: both planted secrets survive in full, and no receipt is written. That is the defect, on the host I said could not show it.

The values are fakes I planted for this probe. No real credential was read or printed.

Why this matters more than a tidier ticket

It removes the dependency on the other host. The fix can now be verified by anyone with a zsh, and the fleet01 lead is no longer the only person who can say whether it works. That also removes the failure mode I was most worried about: a change that looks right in a unit test and is never exercised on a real shell.

The controls are what make it a measurement rather than a demo. PROBE_KEEP survives in all three rows, so the allow-list is working everywhere. PROBE_SECRET_A and PROBE_SECRET_B are blanked in rows 1 and 2, so scrub.zsh itself is sound. Only the third row differs, and only in whether the scrub was ever reached. A probe with no positive control could not tell "not scrubbed" from "nothing to scrub", which is exactly the ambiguity that made this bug hard to see in the first place.

How to re-run it

EnvAllowListScrub.generate(parentDir, allowedNames) is public and static, so a short Java file on the module's classpath produces a real ZDOTDIR. Copy the directory out before the JVM exits, because generate registers every file with deleteOnExit. Then run /bin/zsh three ways under env -i with HOME, PATH and ZDOTDIR set, plant two names that are not on the allow-list and one that is, and read back presence only.

The one thing to get right: plant a name that IS on the allow-list. Without it, a fully blank result and a scrub that never ran look the same.

What I got wrong, and the shape of it

I reasoned "the Mac's panes are login shells, therefore the Mac cannot show this" and stopped. That conflates the configuration we happen to run with what the code can do. The pane is one caller. The generated files are read by every zsh that gets ZDOTDIR, and I can start one of those myself in a single command.

That is the same error the ticket accuses the code of, for the third time now: reasoning about an enumerated set of cases instead of the mechanism. The code enumerated two shell kinds. My suggested fix over-generalised to every zsh on the host (comment 15407). And my Reachability section enumerated the hosts' pane configuration instead of asking what actually reads the file.

**Correction to my own Reachability section: this reproduces on the Mac. I wrote that it did not.** I said "Not reachable on the Mac. Its panes are login shells and the receipt lands." The first half of that is about the **pane**. The **defect** does not need a pane. A first-party reproduction, run here just now against a ZDOTDIR generated by the shipped code itself: ``` == login + interactive (zsh -l -i) PROBE_SECRET_A=[] PROBE_SECRET_B=[] PROBE_KEEP=[keepme] opts: login=yes interactive=yes receipt: allowed 9 of 12 == interactive only (zsh -i) PROBE_SECRET_A=[] PROBE_SECRET_B=[] PROBE_KEEP=[keepme] opts: login=no interactive=yes receipt: allowed 8 of 11 == NEITHER (zsh) PROBE_SECRET_A=[aaa] PROBE_SECRET_B=[bbb] PROBE_KEEP=[keepme] opts: login=no interactive=no receipt: EMPTY (scrub did not run) ``` Third row: **both planted secrets survive in full, and no receipt is written.** That is the defect, on the host I said could not show it. The values are fakes I planted for this probe. No real credential was read or printed. ## Why this matters more than a tidier ticket **It removes the dependency on the other host.** The fix can now be verified by anyone with a zsh, and the fleet01 lead is no longer the only person who can say whether it works. That also removes the failure mode I was most worried about: a change that looks right in a unit test and is never exercised on a real shell. **The controls are what make it a measurement rather than a demo.** `PROBE_KEEP` survives in all three rows, so the allow-list is working everywhere. `PROBE_SECRET_A` and `PROBE_SECRET_B` are blanked in rows 1 and 2, so `scrub.zsh` itself is sound. Only the third row differs, and only in whether the scrub was ever reached. A probe with no positive control could not tell "not scrubbed" from "nothing to scrub", which is exactly the ambiguity that made this bug hard to see in the first place. ## How to re-run it `EnvAllowListScrub.generate(parentDir, allowedNames)` is public and static, so a short Java file on the module's classpath produces a real ZDOTDIR. Copy the directory out before the JVM exits, because `generate` registers every file with `deleteOnExit`. Then run `/bin/zsh` three ways under `env -i` with `HOME`, `PATH` and `ZDOTDIR` set, plant two names that are not on the allow-list and one that is, and read back presence only. The one thing to get right: **plant a name that IS on the allow-list.** Without it, a fully blank result and a scrub that never ran look the same. ## What I got wrong, and the shape of it I reasoned "the Mac's panes are login shells, therefore the Mac cannot show this" and stopped. That conflates *the configuration we happen to run* with *what the code can do*. The pane is one caller. The generated files are read by every zsh that gets `ZDOTDIR`, and I can start one of those myself in a single command. That is the same error the ticket accuses the code of, for the third time now: reasoning about an enumerated set of cases instead of the mechanism. The code enumerated two shell kinds. My suggested fix over-generalised to every zsh on the host (comment 15407). And my Reachability section enumerated the hosts' pane configuration instead of asking what actually reads the file.
Author
Owner

Checked the one thing that would make the proposed fix silently do nothing: are login and interactive already set when .zshenv runs?

They are. If they were not, [[ ! -o login && ! -o interactive ]] would either never fire or always fire, and either way the fix would look right in review and be inert. Measured here rather than assumed, with a .zshenv that prints the option states:

zsh invoked as .zshenv sees files that then ran
-l -i login=yes interactive=yes .zshrc, .zlogin
-l login=yes interactive=no .zlogin
-i login=no interactive=yes .zshrc
(neither) login=no interactive=no none

So the guard fires in exactly the fourth row and nowhere else, which is what it has to do.

The fourth row is also the whole defect in one line: no other startup file runs at all, so today nothing scrubs and nothing writes a receipt.

The table also confirms the three cases together are complete. .zshrc covers every interactive shell, .zlogin covers every login shell, and the guarded .zshenv pass covers the only remaining case. Note row 2 — a login but non-interactive shell still runs .zlogin, so it is already covered and the guard correctly skips it. That row is easy to miss when thinking of login and interactive as one thing.

Taken with the reproduction in comment 15413, this repo can now verify the fix end to end on its own host: the defect reproduces here, the guard's condition is readable where it needs to be read, and the four shell modes are all reachable from one command each.

**Checked the one thing that would make the proposed fix silently do nothing: are `login` and `interactive` already set when `.zshenv` runs?** They are. If they were not, `[[ ! -o login && ! -o interactive ]]` would either never fire or always fire, and either way the fix would look right in review and be inert. Measured here rather than assumed, with a `.zshenv` that prints the option states: | `zsh` invoked as | `.zshenv` sees | files that then ran | |---|---|---| | `-l -i` | `login=yes interactive=yes` | `.zshrc`, `.zlogin` | | `-l` | `login=yes interactive=no` | `.zlogin` | | `-i` | `login=no interactive=yes` | `.zshrc` | | (neither) | `login=no interactive=no` | **none** | So the guard fires in exactly the fourth row and nowhere else, which is what it has to do. The fourth row is also the whole defect in one line: no other startup file runs at all, so today nothing scrubs and nothing writes a receipt. **The table also confirms the three cases together are complete.** `.zshrc` covers every interactive shell, `.zlogin` covers every login shell, and the guarded `.zshenv` pass covers the only remaining case. Note row 2 — a **login but non-interactive** shell still runs `.zlogin`, so it is already covered and the guard correctly skips it. That row is easy to miss when thinking of login and interactive as one thing. Taken with the reproduction in comment 15413, this repo can now verify the fix end to end on its own host: the defect reproduces here, the guard's condition is readable where it needs to be read, and the four shell modes are all reachable from one command each.
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#388