From 3916adc372719796af50398191de3882429fb943 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Thu, 3 Sep 2026 20:10:22 +0700 Subject: [PATCH] fleetd #184: stop claiming memberHerdrSocket proves a different OS user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HerdrPeerLauncher asserted, as established fact, that member panes run under a different OS user whenever memberHerdrSocket is configured. fleetd has no channel to see the uid at the other end of a herdr unix socket — an operator may point memberHerdrSocket at a second herdr under the SAME user for pane isolation, in which case members do inherit fleetd's environment and the count this WARN told them to disregard is the real gap. Reworded the class javadoc on hostEnvNames, the WARN in warnUnknownMemberEnvironment, the javadoc on memberHerdrSocketConfigured(), and warnCannotShareScrubDirectory's "unreadable by another uid" claim to say what is actually true: fleetd cannot confirm what OS user the second herdr runs as, so the member credential gap is UNKNOWN, not known-clean or known-dirty. No behaviour change — the fallback paths and the honest UNKNOWN conclusion stay the same, only the stated reason changes. Matches the framing already used by Fleetd.reportMemberTrustModel on main. Added unknownEnvironmentWarnStatesUncertaintyNotAnAssertedDifferentUser to HerdrPeerLauncherAllowListWiringTest asserting the new WARN wording and that it no longer claims a different OS user as fact. --- .../ltms/fleet/member/HerdrPeerLauncher.java | 72 ++++++++++--------- .../HerdrPeerLauncherAllowListWiringTest.java | 27 +++++++ 2 files changed, 67 insertions(+), 32 deletions(-) diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java index 990b076..2ca305c 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java @@ -117,9 +117,11 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { * *

fleetd #185 stage 2: that mirroring assumption holds only while the member pane runs under * the SAME OS user as the daemon. When {@code memberHerdrSocket:} is configured, member panes - * run on a second herdr owned by a different user — different {@code $HOME}, different {@code - * secrets.sh}, different environment entirely — so this field's data no longer describes what a - * member pane inherits. See {@link #logCredentialGap} for how that mode is handled. + * are routed to a second herdr, and fleetd has no channel to confirm what OS user that herdr + * runs as — it may be a different user with a different {@code $HOME} and {@code secrets.sh}, + * or the same one the daemon runs as. Either way this field's data can no longer be trusted to + * describe what a member pane inherits. See {@link #logCredentialGap} for how that mode is + * handled. */ private final Supplier> hostEnvNames; @@ -1533,21 +1535,24 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { /** * fleetd #213: {@code memberHerdrSocket} is configured and the member login shell IS zsh, but * {@code worktreeRoot} and/or {@code worktreeGroup} is missing, so the generated ZDOTDIR cannot - * be placed anywhere the member's OS user can reach — {@code java.io.tmpdir} is fleetd's own - * 0700 temp dir, unreadable by another uid, which is the exact gap this ticket exists to close. - * Say so once per launcher instance, instead of either generating a directory nothing can read - * (protection theatre) or refusing to spawn (turning a degraded credential control into an - * outage for an opt-in feature). + * be placed anywhere fleetd can be sure the member's OS user can reach — {@code java.io.tmpdir} + * is fleetd's own 0700 temp dir, which is unreadable if the member pane runs as a different OS + * user, and fleetd has no channel to confirm whether it does or not. Rather than gamble on that, + * this treats memberHerdrSocket as reason enough to require an explicitly shared location, which + * is the exact gap this ticket exists to close. Say so once per launcher instance, instead of + * either generating a directory that might not be readable (protection theatre) or refusing to + * spawn (turning a degraded credential control into an outage for an opt-in feature). */ private void warnCannotShareScrubDirectory() { if (cannotShareScrubDirWarned.compareAndSet(false, true)) { log.warn("memberCredentials policy=allow-list: memberHerdrSocket is configured and the " + "member login shell is zsh, but worktreeRoot and/or worktreeGroup is not " - + "configured — the generated ZDOTDIR cannot be placed where the member's OS " - + "user can read it (java.io.tmpdir is fleetd's own, unreadable by another uid), " - + "so the scrub cannot be guaranteed to run. Falling back to the CB-596 sentinel " - + "overlay. Configure both worktreeRoot and worktreeGroup to enable the " - + "allow-list scrub under memberHerdrSocket."); + + "configured — the generated ZDOTDIR cannot be placed where fleetd can be sure " + + "the member's OS user can read it (java.io.tmpdir is fleetd's own 0700 dir, " + + "unreadable if the member runs as a different OS user — fleetd has no channel " + + "to confirm whether it does), so the scrub cannot be guaranteed to run. Falling " + + "back to the CB-596 sentinel overlay. Configure both worktreeRoot and " + + "worktreeGroup to enable the allow-list scrub under memberHerdrSocket."); } } @@ -1626,10 +1631,12 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { private final AtomicBoolean unknownMemberEnvironmentWarned = new AtomicBoolean(); /** - * fleetd #185 stage 2: whether {@code memberHerdrSocket:} is configured, i.e. member panes run - * on a second herdr owned by a different OS user than the daemon's own process. Re-read from the - * live config on every call (same hot-reload shape as {@link #memberCredentials}), never cached, - * so a config reload takes effect on the next spawn without a restart. + * fleetd #185 stage 2: whether {@code memberHerdrSocket:} is configured, i.e. member panes are + * routed to a second herdr. This tests only that the config key is set — fleetd has no channel + * to confirm what OS user that second herdr runs as, so a {@code true} result means "member + * panes may run under a different OS user," not that they do. Re-read from the live config on + * every call (same hot-reload shape as {@link #memberCredentials}), never cached, so a config + * reload takes effect on the next spawn without a restart. * *

{@link #config} is {@code null} on any call site that never threaded the full config * through (every production {@code HerdrPeerLauncher} does; a handful of older tests do not) — @@ -1652,14 +1659,15 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { * fleetd #185 stage 2: the single replacement WARN for {@link #logCredentialGap}'s usual * conclusions when {@code memberHerdrSocket:} is configured. {@link #hostEnvNames} (and * everything derived from it — {@code known}/{@code allow} coverage, the allow-list scrub's - * derived set) describes the DAEMON's own environment; under this config key member panes run as - * a different OS user with a different environment entirely, so neither "every member pane - * inherits them UNBLOCKED" nor "the scrub blanks them" is evidence-backed here — both would be - * reporting on the wrong process. Logged once, names the config key, and states the honest - * conclusion: the gap for member panes is UNKNOWN, not clean, so {@code memberCredentials} cannot - * be verified from this daemon. The one count it does report is scoped explicitly to fleetd's own - * environment, never presented as if it said anything about the member's — see {@link - * #logCredentialGap}'s javadoc for why this branch exists. + * derived set) describes the DAEMON's own environment; under this config key member panes are + * routed to a second herdr, and fleetd has no channel to confirm what OS user that herdr runs + * as or to read its environment, so neither "every member pane inherits them UNBLOCKED" nor + * "the scrub blanks them" is evidence-backed here — both would be reporting on the wrong + * process. Logged once, names the config key, and states the honest conclusion: the gap for + * member panes is UNKNOWN, not clean, so {@code memberCredentials} cannot be verified from this + * daemon. The one count it does report is scoped explicitly to fleetd's own environment, never + * presented as if it said anything about the member's — see {@link #logCredentialGap}'s javadoc + * for why this branch exists. */ private void warnUnknownMemberEnvironment(FleetConfig.MemberCredentials creds) { if (!unknownMemberEnvironmentWarned.compareAndSet(false, true)) { @@ -1672,13 +1680,13 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { .filter(name -> CREDENTIAL_SHAPED_NAME.matcher(name).matches()) .filter(name -> !covered.contains(name)) .count(); - log.warn("memberCredentials gap: memberHerdrSocket is configured, so member panes run under " - + "a different OS user than fleetd's own process, with a different environment " - + "entirely — fleetd has no channel to read that user's environment. {} of the " - + "{} names in fleetd's OWN environment are credential-shaped and not on " - + "known:/allow:, but that count describes fleetd's process, not the member " - + "herdr's. The credential gap for member panes is UNKNOWN, not clean, and " - + "memberCredentials cannot be verified from here.", + log.warn("memberCredentials gap: memberHerdrSocket is configured, so member panes are routed " + + "to a second herdr — fleetd has no channel to confirm what OS user that herdr " + + "runs as, so it cannot tell whether those panes inherit its own environment or " + + "a different one entirely. {} of the {} names in fleetd's OWN environment are " + + "credential-shaped and not on known:/allow:, but that count describes fleetd's " + + "process, not the member herdr's. The credential gap for member panes is " + + "UNKNOWN, not clean, and memberCredentials cannot be verified from here.", gapInFleetdsOwnEnv, hostNames.size()); } diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/HerdrPeerLauncherAllowListWiringTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/HerdrPeerLauncherAllowListWiringTest.java index 75f883c..82c8b32 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/member/HerdrPeerLauncherAllowListWiringTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/member/HerdrPeerLauncherAllowListWiringTest.java @@ -387,6 +387,33 @@ class HerdrPeerLauncherAllowListWiringTest { + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList()); } + /** + * fleetd #184 item 5: the unknown-environment WARN must state the honest reason for the + * UNKNOWN conclusion — fleetd has no channel to confirm what OS user the second herdr runs + * as — and must NOT assert as fact that member panes run under a different OS user just + * because {@code memberHerdrSocket} is configured. An operator may point it at a second herdr + * running as the SAME user, for pane isolation alone; in that case members DO inherit fleetd's + * environment, and asserting otherwise would tell the operator to disregard a real, known gap. + */ + @Test + void unknownEnvironmentWarnStatesUncertaintyNotAnAssertedDifferentUser() { + FakeHerdr herdr = new FakeHerdr(); + Set hostEnvNames = Set.of("FLEETD_WORKER_TOKEN", "SOME_UNKNOWN_SECRET_TOKEN"); + WiringLauncher launcher = new WiringLauncher(herdr, allowList(), "/bin/zsh", () -> hostEnvNames, + () -> configWithMemberHerdrSocket("/tmp/other-user-herdr.sock", "/bin/zsh")); + + List messages = spawnAndCaptureLogs(launcher); + + assertTrue(messages.stream().anyMatch(m -> m.contains("memberHerdrSocket") + && m.contains("no channel to confirm what OS user that herdr runs as")), + "expected the WARN to name the actual uncertainty (no channel to confirm the " + + "herdr's uid), got: " + messages); + assertFalse(messages.stream().anyMatch(m -> m.contains("member panes run under a different OS " + + "user than fleetd's own process")), + "the WARN must not assert as fact that members run under a different OS user just " + + "because memberHerdrSocket is configured — got: " + messages); + } + /** * Hard constraint: the gap detector must never log an env var VALUE, only its NAME. {@code * SOME_UNKNOWN_SECRET_TOKEN} resolves to a distinctive canary value through the same {@code env}