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