diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/EnvAllowListScrub.java b/fleetd/src/main/java/dev/ltms/fleet/member/EnvAllowListScrub.java index d56105b..7b328c8 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/EnvAllowListScrub.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/EnvAllowListScrub.java @@ -331,6 +331,22 @@ public final class EnvAllowListScrub { (( _cb633_total += 1 )) [[ -n "${_cb633_allowed[$_cb633_n]-}" ]] && continue case "$_cb633_n" in %s) continue ;; esac + # zsh SPECIAL PARAMETERS. `export UID=` is not a failed command: it is a fatal + # parameter error ("failed to change user ID: operation not permitted") that + # aborts this entire sourced file mid-loop. Everything after it in `env` order + # is then left unscrubbed and the report below is never written — silently, + # because the blanking loop is wrapped in `2>/dev/null`. Guarding the call site + # does NOT help; the abort happens however the assignment is wrapped. The only + # repair is to never attempt these names. + # + # Why this was invisible: `env` lists inherited names first and the names a + # startup file exports last, so the loop blanked the harmless inherited half and + # died immediately before the operator's own exports — exactly the credentials + # this policy exists to remove. Measured on a fleet01 member pane: UID is name + # 42 of 57, and a decoy exported from ~/.zshrc sat at 58, untouched on 8 of 8 + # spawns. EnvAllowListScrubTest starts zsh from a CLEARED parent environment, + # where UID is not exported at all, so the whole suite was green throughout. + case "$_cb633_n" in UID|EUID|GID|EGID|PPID|LINENO) continue ;; esac _cb633_blank+=("$_cb633_n") done diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/EnvAllowListScrubTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/EnvAllowListScrubTest.java index 6d16bba..55a44b3 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/member/EnvAllowListScrubTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/member/EnvAllowListScrubTest.java @@ -18,6 +18,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -118,6 +119,43 @@ class EnvAllowListScrubTest { "allowed N of M with N <= M — the denominator is always reported"); } + /** + * A pane inherits {@code UID}; a cleared test parent does not. The scrub must survive it. + * + *

Every other test here starts zsh from a CLEARED environment, so {@code UID} is never an + * exported name and never reaches the blanking loop. In a real member pane it is exported and + * it IS reached — and {@code export UID=} is a fatal zsh parameter error that aborts the whole + * sourced file, leaving every later name unscrubbed and writing no report at all. The abort is + * silent: the loop is wrapped in {@code 2>/dev/null}. + * + *

The assertion is deliberately "a report exists" rather than "the canary is blanked". The + * report is written by the last statement in the file, so its presence proves the script ran + * to completion; the canary alone would depend on where it happens to sit in {@code env} order. + * Both are checked, but only the first one fails deterministically without the fix. + */ + @Test + void scrubSurvivesAnInheritedUidTheWayARealPaneHasIt(@TempDir Path tmp) throws Exception { + assumeTrue(Files.isExecutable(ZSH), "/bin/zsh not present — nothing to prove here"); + Set allowed = MemberEnvAllowList.derive(List.of()); + Path zdotdir = EnvAllowListScrub.generate(tmp, allowed); + + // The production shape: UID present and exported, as every pane shell inherits it. + Map paneLikeParent = Map.of( + "HOME", System.getProperty("user.home"), + "PATH", "/usr/bin:/bin", + "SHELL", "/bin/zsh", + "UID", "1000", + "CB633_CANARY", "must-not-survive-the-scrub"); + Set survivors = exportedNamesFromCleanParent(paneLikeParent, zdotdir); + + EnvAllowListScrub.ScrubReport report = EnvAllowListScrub.readReport(zdotdir); + assertNotNull(report, + "an inherited UID must not abort the scrub — no report means the file died mid-loop " + + "and every name after UID in `env` order was left unscrubbed"); + assertFalse(survivors.contains("CB633_CANARY"), + "a non-allow-listed name must still be blanked when UID is in the environment"); + } + /** A group-shared ZDOTDIR still lets the member truncate and write its pre-created receipt. */ @Test void groupSharedScrubWritesAndReadsItsReport(@TempDir Path tmp) throws Exception {