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}