#185: the ZDOTDIR scrub decides on fleetd's own shell and writes to fleetd's own TMPDIR, so under memberHerdrSocket it silently protects nothing #213

Closed
opened 2026-08-31 17:19:02 +02:00 by ltms · 1 comment
Owner

Follow-up to #185. Stage 2 (merged in 457dc03) fixed the reporting — logCredentialGap no longer claims a clean or blanked gap when member panes run as another user. This issue is about the control itself, which has the same wrong-process assumption in two more places and, unlike the reporting, actually leaves the member unprotected.

A worker flagged the first half while doing stage 2. I checked both in the code.

Neither is live today: memberHerdrSocket: is absent on this Mac, so this is a blocker for the stage 4 rollout, not a current exposure.

1. The zsh gate reads fleetd's shell, not the member's

HerdrPeerLauncher.applyEnvironmentAllowListPolicy (~line 1081):

String loginShell = resolveEnv("SHELL");
boolean zsh = loginShell != null && (loginShell.endsWith("/zsh") || loginShell.equals("zsh"));
if (!zsh) {
    warnNonZsh(loginShell);
    overlayBlockedCredentials(launch.env(), creds);   // fallback protection
    logCredentialGap(creds, null);
    return null;
}
// zsh branch: ZDOTDIR scrub only — no sentinel overlay
logAllowListCoverage(allowed);
logCredentialGap(creds, allowed);
Path dir = EnvAllowListScrub.generate(...);
launch.env().put("ZDOTDIR", dir.toAbsolutePath().toString());

resolveEnv("SHELL") is fleetd's own $SHELL. Under memberHerdrSocket: the pane belongs to a different OS user with their own login shell.

The dangerous combination is fleetd on zsh (true on this host) and the member user on any other shell:

  • The zsh branch is taken, so EnvAllowListScrub generates a ZDOTDIR and sets it on the pane env.
  • The member's non-zsh login shell ignores ZDOTDIR entirely, so no scrub runs.
  • The zsh branch deliberately does not call overlayBlockedCredentials, because the scrub is supposed to be doing the work. So the sentinel fallback the non-zsh branch relies on never happens either.

Net effect: no protection at all, on the path fleetd believes is the protected one. The non-zsh branch's own comment explains exactly why this matters — "pretending otherwise would be worse than saying so" — and that is what the wrong-shell read causes.

This is the #192 shape again: a control that reports success while doing nothing.

2. The generated ZDOTDIR is written into fleetd's own TMPDIR

Same method:

Path dir = EnvAllowListScrub.generate(Path.of(System.getProperty("java.io.tmpdir")), allowed);

On macOS, java.io.tmpdir resolves to the per-user $TMPDIR under /var/folders/..., which is mode drwx------ (0700) and owned by the operator. A member running as a different uid cannot even traverse it, let alone read the generated .zshrc.

So even with the member user on zsh — the case part 1 does not cover — the scrub still cannot run, because the file it must source is unreachable. It fails closed in the sense that no credential is added, but the allow-list policy is silently not in force, and nothing reports that.

Suggested direction

Both parts need the member's facts, not fleetd's:

  • The shell. There is no channel to read the member user's $SHELL (same constraint as stage 2 — herdr exposes no such thing). So make it configuration rather than a guess: a memberLoginShell: key alongside memberHerdrSocket:, required whenever memberHerdrSocket: is set. Refuse to start, or refuse to spawn, rather than guessing from the daemon's own environment. An explicit wrong answer from an operator is recoverable; a silent wrong guess is what this issue is about.
  • The scrub directory. Generate into a location the member uid can read — a group-readable directory under the configured worktreeRoot, or a path given by a new key — and set its permissions deliberately. This pairs with worktreeGroup: from stage 3, which already establishes the shared group.
  • Fail closed and say so. If the scrub cannot be guaranteed to run for this member (unknown shell, unreachable ZDOTDIR), take the non-zsh branch's behaviour: warn loudly and apply overlayBlockedCredentials. Never take the zsh branch's silent path on an unverified assumption.

Acceptance

  • A test that with memberHerdrSocket: set and the member shell configured as non-zsh, the sentinel overlay is applied and the "generated ZDOTDIR" INFO does not appear.
  • A test that with memberHerdrSocket: set and no member shell configured, the spawn refuses (or falls back to the overlay) rather than reading fleetd's $SHELL.
  • A test that the generated scrub directory is not under java.io.tmpdir when memberHerdrSocket: is set.
  • Watch each fail before keeping it.

Related: #185 (parent), #155 (the scrub is zsh-only in the first place), #192 (the "reported a real leak as safe" precedent), #144.

Follow-up to #185. Stage 2 (merged in `457dc03`) fixed the *reporting* — `logCredentialGap` no longer claims a clean or blanked gap when member panes run as another user. This issue is about the **control itself**, which has the same wrong-process assumption in two more places and, unlike the reporting, actually leaves the member unprotected. A worker flagged the first half while doing stage 2. I checked both in the code. Neither is live today: `memberHerdrSocket:` is absent on this Mac, so this is a blocker for the stage 4 rollout, not a current exposure. ## 1. The zsh gate reads fleetd's shell, not the member's `HerdrPeerLauncher.applyEnvironmentAllowListPolicy` (~line 1081): ```java String loginShell = resolveEnv("SHELL"); boolean zsh = loginShell != null && (loginShell.endsWith("/zsh") || loginShell.equals("zsh")); if (!zsh) { warnNonZsh(loginShell); overlayBlockedCredentials(launch.env(), creds); // fallback protection logCredentialGap(creds, null); return null; } // zsh branch: ZDOTDIR scrub only — no sentinel overlay logAllowListCoverage(allowed); logCredentialGap(creds, allowed); Path dir = EnvAllowListScrub.generate(...); launch.env().put("ZDOTDIR", dir.toAbsolutePath().toString()); ``` `resolveEnv("SHELL")` is **fleetd's own** `$SHELL`. Under `memberHerdrSocket:` the pane belongs to a different OS user with their own login shell. The dangerous combination is fleetd on zsh (true on this host) and the member user on any other shell: - The zsh branch is taken, so `EnvAllowListScrub` generates a `ZDOTDIR` and sets it on the pane env. - The member's non-zsh login shell **ignores `ZDOTDIR` entirely**, so no scrub runs. - The zsh branch deliberately does **not** call `overlayBlockedCredentials`, because the scrub is supposed to be doing the work. So the sentinel fallback the non-zsh branch relies on never happens either. Net effect: **no protection at all**, on the path fleetd believes is the protected one. The non-zsh branch's own comment explains exactly why this matters — "pretending otherwise would be worse than saying so" — and that is what the wrong-shell read causes. This is the #192 shape again: a control that reports success while doing nothing. ## 2. The generated ZDOTDIR is written into fleetd's own TMPDIR Same method: ```java Path dir = EnvAllowListScrub.generate(Path.of(System.getProperty("java.io.tmpdir")), allowed); ``` On macOS, `java.io.tmpdir` resolves to the per-user `$TMPDIR` under `/var/folders/...`, which is mode `drwx------` (0700) and owned by the operator. A member running as a **different uid cannot even traverse it**, let alone read the generated `.zshrc`. So even with the member user on zsh — the case part 1 does not cover — the scrub still cannot run, because the file it must source is unreachable. It fails closed in the sense that no credential is *added*, but the allow-list policy is silently not in force, and nothing reports that. ## Suggested direction Both parts need the member's facts, not fleetd's: - **The shell.** There is no channel to read the member user's `$SHELL` (same constraint as stage 2 — herdr exposes no such thing). So make it configuration rather than a guess: a `memberLoginShell:` key alongside `memberHerdrSocket:`, required whenever `memberHerdrSocket:` is set. Refuse to start, or refuse to spawn, rather than guessing from the daemon's own environment. An explicit wrong answer from an operator is recoverable; a silent wrong guess is what this issue is about. - **The scrub directory.** Generate into a location the member uid can read — a group-readable directory under the configured `worktreeRoot`, or a path given by a new key — and set its permissions deliberately. This pairs with `worktreeGroup:` from stage 3, which already establishes the shared group. - **Fail closed and say so.** If the scrub cannot be guaranteed to run for this member (unknown shell, unreachable ZDOTDIR), take the non-zsh branch's behaviour: warn loudly and apply `overlayBlockedCredentials`. Never take the zsh branch's silent path on an unverified assumption. ## Acceptance - A test that with `memberHerdrSocket:` set and the member shell configured as non-zsh, the sentinel overlay is applied and the "generated ZDOTDIR" INFO does **not** appear. - A test that with `memberHerdrSocket:` set and no member shell configured, the spawn refuses (or falls back to the overlay) rather than reading fleetd's `$SHELL`. - A test that the generated scrub directory is not under `java.io.tmpdir` when `memberHerdrSocket:` is set. - Watch each fail before keeping it. Related: #185 (parent), #155 (the scrub is zsh-only in the first place), #192 (the "reported a real leak as safe" precedent), #144.
Author
Owner

Merged to main as e1eb50c.

I checked the part where the actual risk lives — the permissions on the shared scrub directory. The directory is rwxr-x--- and each generated file is rw-r-----: group read, no group write anywhere, no world bits. A member can source what it needs and cannot alter fleetd's own scrub.

memberHerdrSocket: is not configured on this daemon, so the new branch is inactive here; the non-socket path is byte-identical to before, which I verified in the diff rather than trusting the commit message. That also cleared it as the cause of the spawn regression that appeared in the same deploy — see #220.

Merged to main as `e1eb50c`. I checked the part where the actual risk lives — the permissions on the shared scrub directory. The directory is `rwxr-x---` and each generated file is `rw-r-----`: group read, **no group write anywhere, no world bits**. A member can source what it needs and cannot alter fleetd's own scrub. `memberHerdrSocket:` is not configured on this daemon, so the new branch is inactive here; the non-socket path is byte-identical to before, which I verified in the diff rather than trusting the commit message. That also cleared it as the cause of the spawn regression that appeared in the same deploy — see #220.
ltms closed this issue 2026-09-01 09:13:19 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#213