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 76c8f40..113fb22 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java
@@ -1008,6 +1008,13 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
* applied before the login shell runs and a sourced file can (and did) undo it. The control is
* the ZDOTDIR scrub ({@link #applyEnvironmentAllowListPolicy}); {@code known}/{@code allow}
* remain as reporting only via {@link #logCredentialGap}.
+ *
+ *
CB-633 follow-up (#192): under {@code allow-list} this method does NOT call {@link
+ * #logCredentialGap} itself — at this point (called from {@link #baseEnv}, before {@link
+ * #applyEnvironmentAllowListPolicy} runs) we do not yet know whether the pane's shell is zsh, so
+ * we cannot yet pick correct wording. That decision, and the call, are deferred entirely to
+ * {@link #applyEnvironmentAllowListPolicy}, which knows by then whether the scrub will actually
+ * run.
*/
private void applyMemberCredentialPolicy(Map workerEnv) {
FleetConfig.MemberCredentials creds = memberCredentials == null ? null : memberCredentials.get();
@@ -1016,8 +1023,8 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
}
if (!creds.isAllowList()) {
overlayBlockedCredentials(workerEnv, creds);
+ logCredentialGap(creds, null);
}
- logCredentialGap(creds);
}
/** Put {@link #BLOCKED_CREDENTIAL_SENTINEL} over every blocked name in the pane-creation env map. */
@@ -1067,12 +1074,15 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
// logCredentialGap's WARN (below) is the only signal for this path.
warnNonZsh(loginShell);
overlayBlockedCredentials(launch.env(), creds);
- logCredentialGap(creds);
+ logCredentialGap(creds, null);
return null;
}
// Only reached when the scrub is actually about to run — the count below describes that
- // scrub, so it must not be logged before this gate (see the non-zsh branch above).
+ // scrub, so it must not be logged before this gate (see the non-zsh branch above). Same
+ // reasoning gates logCredentialGap's wording: passing the derived `allowed` set (non-null)
+ // here, and ONLY here, is what tells it the scrub will really blank an unkept name — #192.
logAllowListCoverage(allowed);
+ logCredentialGap(creds, allowed);
Path dir = EnvAllowListScrub.generate(Path.of(System.getProperty("java.io.tmpdir")), allowed);
launch.env().put("ZDOTDIR", dir.toAbsolutePath().toString());
log.info("memberCredentials policy=allow-list: profile={} generated ZDOTDIR {} — derived "
@@ -1177,18 +1187,57 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
private static final Pattern CREDENTIAL_SHAPED_NAME =
Pattern.compile("(?i).*(TOKEN|SECRET|_KEY|APIKEY|PASSWORD|CREDENTIAL|AUTH).*");
- /** Guards {@link #logCredentialGap} to one WARN per launcher instance, not one per spawn. */
- private final AtomicBoolean credentialGapLogged = new AtomicBoolean();
+ /**
+ * Guards the {@code effectiveAllowed == null} branch of {@link #logCredentialGap} — the
+ * genuinely-unprotected report (deny-by-default, and the allow-list non-zsh fallback) — to one
+ * WARN per launcher instance, not one per spawn.
+ *
+ * CB-633 follow-up (#192): kept SEPARATE from {@link #allowListGapLogged} on purpose.
+ * {@code memberCredentials} is a live, re-read-per-spawn supplier, so the policy can change
+ * between two spawns on the same launcher. A single shared flag would let a harmless allow-list
+ * INFO on spawn 1 permanently suppress the real deny-by-default WARN a later spawn deserves —
+ * the report that matters most getting hidden by the report that doesn't. Two flags mean each
+ * report kind fires exactly once, independent of what the other kind already logged.
+ */
+ private final AtomicBoolean unprotectedGapLogged = new AtomicBoolean();
+
+ /**
+ * Guards the {@code effectiveAllowed != null} branch of {@link #logCredentialGap} — the
+ * allow-list-scrub-covered report — to one INFO per launcher instance. See {@link
+ * #unprotectedGapLogged}'s javadoc for why this is a separate flag rather than a shared one.
+ */
+ private final AtomicBoolean allowListGapLogged = new AtomicBoolean();
/**
* CB-596 criterion 4: a credential-shaped host env var name on neither {@code known} nor
* {@code allow} is not silently allowed — it is reported. {@link #hostEnvNames} enumerates the
* daemon's own environment (see that field's javadoc for why the daemon's env is read rather
* than the spawned pane's, which the daemon has no channel to inspect at spawn time); this logs
- * every such NAME, at WARN, at most once per launcher instance — never a value, a prefix of a
- * value, or a hash of a value, so the log itself cannot leak anything.
+ * every such NAME — never a value, a prefix of a value, or a hash of a value, so the log itself
+ * cannot leak anything.
+ *
+ *
CB-633 follow-up (#192): {@code effectiveAllowed} picks the wording, and it must NOT be
+ * picked from {@code creds.isAllowList()} — see {@link #applyEnvironmentAllowListPolicy}'s
+ * javadoc for the reasoning this mirrors. {@code null} means no scrub-derived allow-list was
+ * computed for this call — true on the deny-by-default path AND on the allow-list non-zsh
+ * fallback, where nothing is ever scrubbed — so the whole gap is real and gets the WARN,
+ * unchanged from before this fix. Non-null means this call came from the zsh branch of {@link
+ * #applyEnvironmentAllowListPolicy}, reachable ONLY after that method's own zsh gate — but
+ * {@code effectiveAllowed} is a SUPERSET of {@code known ∪ allow}: {@link MemberEnvAllowList#derive}
+ * also unions in every profile's {@code gitTokenEnv}/{@code gitHostEnv}/{@code tokenEnv}/
+ * {@code env:} keys, and {@link #derivedAllowedNames} further unions in this very spawn's own
+ * env keys — so a name can be in the gap (uncovered by {@code known}/{@code allow}) AND still be
+ * kept by the derived allow-list, in which case the scrub does NOT blank it and the member DOES
+ * inherit it. Lead review on #192 caught this: the first cut of this fix reported the WHOLE gap
+ * as scrub-blanked without checking that, which reported a real leak as safe — the exact
+ * inversion #192 exists to remove. So on this path the gap is split with {@link
+ * MemberEnvAllowList#keeps}, the SAME predicate the generated scrub itself evaluates, so this
+ * split cannot drift from what the scrub actually does: the names it says are kept get the WARN
+ * (same severity, and same guard, as the deny-by-default case — a name genuinely reaching a
+ * member unprotected is equally serious whichever path put it there), and the names it says are
+ * blanked keep the INFO.
*/
- private void logCredentialGap(FleetConfig.MemberCredentials creds) {
+ private void logCredentialGap(FleetConfig.MemberCredentials creds, Set effectiveAllowed) {
Set covered = new HashSet<>(creds.known());
covered.addAll(creds.allow());
List gap = hostEnvNames.get().stream()
@@ -1199,7 +1248,37 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
if (gap.isEmpty()) {
return;
}
- if (credentialGapLogged.compareAndSet(false, true)) {
+ if (effectiveAllowed == null) {
+ warnGapUnprotected(gap);
+ return;
+ }
+ List keptByDerivedList = gap.stream()
+ .filter(name -> MemberEnvAllowList.keeps(effectiveAllowed, name))
+ .toList();
+ List blankedByScrub = gap.stream()
+ .filter(name -> !MemberEnvAllowList.keeps(effectiveAllowed, name))
+ .toList();
+ if (!keptByDerivedList.isEmpty() && unprotectedGapLogged.compareAndSet(false, true)) {
+ log.warn("memberCredentials gap: {} credential-shaped env var name(s) are on neither "
+ + "known: nor allow: — the derived allow-list keeps them anyway (a profile's "
+ + "gitTokenEnv/gitHostEnv/tokenEnv/env: names one, or this spawn injects it), "
+ + "so every member pane inherits them UNBLOCKED — {}. Add each to "
+ + "memberCredentials.known (or .allow if a member legitimately needs it), or "
+ + "remove it from whatever profile setting derives it in.",
+ keptByDerivedList.size(), keptByDerivedList);
+ }
+ if (!blankedByScrub.isEmpty() && allowListGapLogged.compareAndSet(false, true)) {
+ log.info("memberCredentials gap: {} credential-shaped env var name(s) are on neither "
+ + "known: nor allow: — {}. The allow-list scrub blanks them anyway (they "
+ + "are not on the derived allow-list), so no member pane keeps them; add "
+ + "each to memberCredentials.known or .allow to make that explicit.",
+ blankedByScrub.size(), blankedByScrub);
+ }
+ }
+
+ /** The deny-by-default (and allow-list non-zsh fallback) WARN — unchanged byte-for-byte by #192. */
+ private void warnGapUnprotected(List gap) {
+ if (unprotectedGapLogged.compareAndSet(false, true)) {
log.warn("memberCredentials gap: {} credential-shaped env var name(s) are on neither "
+ "known: nor allow: — every member pane inherits them UNBLOCKED — {}. "
+ "Add each to memberCredentials.known (blocked by default) or .allow "
diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java
index 12508ec..6b6be77 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java
@@ -1,5 +1,6 @@
package dev.ltms.fleet.member;
+import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
@@ -1055,6 +1056,288 @@ class ClaudeCodeLauncherTest {
"a name already on allow: is covered, not a gap");
}
+ /**
+ * CB-633 follow-up (#192): the deny-by-default WARN wording is a promise an operator relies on —
+ * pinned byte-for-byte so a future edit cannot drift it (e.g. while picking wording for the
+ * allow-list path) without a test noticing.
+ */
+ @Test
+ void denyByDefaultKeepsTheExactCredentialGapWarn() {
+ FakeHerdr herdr = new FakeHerdr();
+ FleetConfig.Profile cfg = new FleetConfig.Profile(
+ "ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN",
+ List.of("claude"), "tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null);
+ ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
+ _ -> null, 0, System::currentTimeMillis, () -> {}, null, () -> TEST_MEMBER_CREDENTIALS,
+ () -> Set.of("A_BRAND_NEW_SECRET_TOKEN"));
+
+ Logger logger = (Logger) LoggerFactory.getLogger(HerdrPeerLauncher.class);
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ logger.addAppender(appender);
+ try {
+ svc.spawn();
+ } finally {
+ logger.detachAppender(appender);
+ }
+
+ assertTrue(appender.list.stream().anyMatch(e ->
+ ("memberCredentials gap: 1 credential-shaped env var name(s) are on neither "
+ + "known: nor allow: — every member pane inherits them UNBLOCKED — "
+ + "[A_BRAND_NEW_SECRET_TOKEN]. Add each to memberCredentials.known "
+ + "(blocked by default) or .allow (if a member legitimately needs it).")
+ .equals(e.getFormattedMessage())),
+ "the deny-by-default WARN text must not drift — got: "
+ + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
+ }
+
+ /**
+ * CB-633 follow-up (#192), defect 1: under {@code policy: allow-list} on a zsh login shell the
+ * generated ZDOTDIR scrub genuinely blanks an unkept credential-shaped name, so the report must
+ * not say the pane inherits it UNBLOCKED — that claim is exactly what PR #174 got wrong. This
+ * goes through the real spawn path (not {@code HerdrPeerLauncherAllowListWiringTest}'s fixture,
+ * which overrides {@code buildLaunch} and bypasses none of the logic under test here — the
+ * shell-dependent branch lives in {@code applyEnvironmentAllowListPolicy}, which every spawn
+ * still passes through).
+ */
+ @Test
+ void allowListPolicyOnZshReportsTheGapWithoutClaimingItIsUnblocked() {
+ FakeHerdr herdr = new FakeHerdr();
+ FleetConfig.Profile cfg = new FleetConfig.Profile(
+ "ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN",
+ List.of("claude"), "tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null);
+ FleetConfig.MemberCredentials creds = new FleetConfig.MemberCredentials(
+ FleetConfig.MemberCredentials.POLICY_ALLOW_LIST,
+ List.of("AI_GATEWAY_TOKEN"), List.of());
+ ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
+ name -> "SHELL".equals(name) ? "/bin/zsh" : null,
+ 0, System::currentTimeMillis, () -> {}, null, () -> creds,
+ () -> Set.of("AI_GATEWAY_TOKEN", "A_BRAND_NEW_SECRET_TOKEN"));
+
+ Logger logger = (Logger) LoggerFactory.getLogger(HerdrPeerLauncher.class);
+ Level original = logger.getLevel();
+ logger.setLevel(Level.INFO);
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ logger.addAppender(appender);
+ try {
+ svc.spawn();
+ } finally {
+ logger.detachAppender(appender);
+ logger.setLevel(original);
+ }
+
+ assertTrue(appender.list.stream().anyMatch(e ->
+ e.getFormattedMessage().contains("memberCredentials gap")
+ && e.getFormattedMessage().contains("A_BRAND_NEW_SECRET_TOKEN")),
+ "the unkept name must still be reported — got: "
+ + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
+ assertFalse(appender.list.stream().anyMatch(e ->
+ e.getFormattedMessage().contains("memberCredentials gap")
+ && e.getFormattedMessage().contains("UNBLOCKED")),
+ "on zsh the scrub genuinely blanks the name, so the report must not claim it is "
+ + "inherited unblocked — got: "
+ + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
+ }
+
+ /**
+ * CB-633 follow-up (#192): the mirror of the zsh test above. On a non-zsh login shell {@code
+ * ZDOTDIR} is ignored, so no scrub ever runs — the report must keep the WARN wording (a name here
+ * really is inherited unblocked) rather than claiming a scrub protects it. This is the trap PR
+ * #174 fell into the other direction: keying the wording on the shell, not on {@code
+ * creds.isAllowList()}, is what keeps this branch correct.
+ */
+ @Test
+ void allowListPolicyOnNonZshKeepsTheWarnWording() {
+ FakeHerdr herdr = new FakeHerdr();
+ FleetConfig.Profile cfg = new FleetConfig.Profile(
+ "ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN",
+ List.of("claude"), "tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null);
+ FleetConfig.MemberCredentials creds = new FleetConfig.MemberCredentials(
+ FleetConfig.MemberCredentials.POLICY_ALLOW_LIST,
+ List.of("AI_GATEWAY_TOKEN"), List.of());
+ ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
+ name -> "SHELL".equals(name) ? "/bin/bash" : null,
+ 0, System::currentTimeMillis, () -> {}, null, () -> creds,
+ () -> Set.of("AI_GATEWAY_TOKEN", "A_BRAND_NEW_SECRET_TOKEN"));
+
+ Logger logger = (Logger) LoggerFactory.getLogger(HerdrPeerLauncher.class);
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ logger.addAppender(appender);
+ try {
+ svc.spawn();
+ } finally {
+ logger.detachAppender(appender);
+ }
+
+ assertTrue(appender.list.stream().anyMatch(e ->
+ e.getFormattedMessage().contains("memberCredentials gap")
+ && e.getFormattedMessage().contains("UNBLOCKED")
+ && e.getFormattedMessage().contains("A_BRAND_NEW_SECRET_TOKEN")),
+ "no scrub runs on a non-zsh shell, so the WARN wording must be kept — got: "
+ + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
+ assertFalse(appender.list.stream().anyMatch(e ->
+ e.getFormattedMessage().contains("memberCredentials gap")
+ && e.getFormattedMessage().toLowerCase(java.util.Locale.ROOT).contains("scrub")),
+ "nothing is scrubbed on this path, so the report must not claim otherwise — got: "
+ + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
+ }
+
+ /**
+ * CB-633 follow-up (#192), defect 2: {@code memberCredentials} is a live, re-read-per-spawn
+ * supplier, so the policy can change between two spawns on the same launcher. Before this fix a
+ * single {@code AtomicBoolean} guarded both report kinds, so the harmless allow-list INFO on the
+ * first spawn would permanently suppress the real deny-by-default WARN a later spawn deserves.
+ * This goes through {@link ClaudeCodeLauncher#buildLaunch}'s real {@code baseEnv()} path — the
+ * WARN this test pins fires from {@code applyMemberCredentialPolicy}, which {@code
+ * HerdrPeerLauncherAllowListWiringTest}'s fixture never reaches at all (see its class javadoc).
+ */
+ @Test
+ void secondSpawnStillWarnsAfterPolicyChangesFromAllowListToDenyByDefault() {
+ FakeHerdr herdr = new FakeHerdr();
+ FleetConfig.Profile cfg = new FleetConfig.Profile(
+ "ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN",
+ List.of("claude"), "tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null);
+ AtomicReference creds = new AtomicReference<>(
+ new FleetConfig.MemberCredentials(FleetConfig.MemberCredentials.POLICY_ALLOW_LIST,
+ List.of("AI_GATEWAY_TOKEN"), List.of()));
+ ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
+ name -> "SHELL".equals(name) ? "/bin/zsh" : null,
+ 0, System::currentTimeMillis, () -> {}, null, creds::get,
+ () -> Set.of("AI_GATEWAY_TOKEN", "A_BRAND_NEW_SECRET_TOKEN"));
+
+ Logger logger = (Logger) LoggerFactory.getLogger(HerdrPeerLauncher.class);
+ Level original = logger.getLevel();
+ logger.setLevel(Level.INFO);
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ try {
+ logger.addAppender(appender);
+ svc.spawn(); // allow-list + zsh: harmless INFO, sets the allow-list guard only
+
+ creds.set(new FleetConfig.MemberCredentials(FleetConfig.MemberCredentials.POLICY_DENY_BY_DEFAULT,
+ List.of("AI_GATEWAY_TOKEN"), List.of()));
+ svc.spawn(); // policy reloaded to deny-by-default: this WARN must NOT be suppressed
+ } finally {
+ logger.detachAppender(appender);
+ logger.setLevel(original);
+ }
+
+ assertTrue(appender.list.stream().anyMatch(e ->
+ e.getFormattedMessage().contains("memberCredentials gap")
+ && e.getFormattedMessage().contains("UNBLOCKED")),
+ "the second spawn's deny-by-default WARN must still fire even though the first "
+ + "spawn's allow-list INFO already logged the same underlying gap — got: "
+ + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
+ }
+
+ /**
+ * CB-633 follow-up (#192), lead-review fix: {@code effectiveAllowed} is a SUPERSET of
+ * {@code known ∪ allow} — {@code MemberEnvAllowList.derive} also unions in every profile's
+ * {@code tokenEnv} (among other fields), so a credential-shaped name can be uncovered by
+ * {@code known:}/{@code allow:} and STILL survive the scrub because a profile's own
+ * {@code tokenEnv} names it. Here {@code tokenEnv} is deliberately set to a credential-shaped
+ * name the operator forgot to list — the misconfiguration this report exists to catch. The scrub
+ * genuinely keeps it, so the report must WARN, not claim (as the pre-lead-review cut of this fix
+ * did) that "no member pane keeps them".
+ */
+ @Test
+ void allowListWarnsWhenTheDerivedAllowListKeepsAnUncoveredName() {
+ FakeHerdr herdr = new FakeHerdr();
+ FleetConfig.Profile cfg = new FleetConfig.Profile(
+ "ltms-local", "http://gx00.gw:8000", "coder", null, "SOME_LEAKY_TOKEN",
+ List.of("claude"), "tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null);
+ FleetConfig.MemberCredentials creds = new FleetConfig.MemberCredentials(
+ FleetConfig.MemberCredentials.POLICY_ALLOW_LIST, List.of(), List.of());
+ ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
+ name -> "SHELL".equals(name) ? "/bin/zsh" : null,
+ 0, System::currentTimeMillis, () -> {}, null, () -> creds,
+ () -> Set.of("SOME_LEAKY_TOKEN"));
+
+ Logger logger = (Logger) LoggerFactory.getLogger(HerdrPeerLauncher.class);
+ Level original = logger.getLevel();
+ logger.setLevel(Level.INFO);
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ logger.addAppender(appender);
+ try {
+ svc.spawn();
+ } finally {
+ logger.detachAppender(appender);
+ logger.setLevel(original);
+ }
+
+ assertTrue(appender.list.stream().anyMatch(e ->
+ e.getLevel() == Level.WARN
+ && e.getFormattedMessage().contains("memberCredentials gap")
+ && e.getFormattedMessage().contains("SOME_LEAKY_TOKEN")
+ && e.getFormattedMessage().contains("UNBLOCKED")),
+ "a name kept by the derived allow-list (via this profile's tokenEnv) must still WARN "
+ + "— got: " + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
+ assertFalse(appender.list.stream().anyMatch(e ->
+ e.getFormattedMessage().contains("memberCredentials gap")
+ && e.getFormattedMessage().contains("SOME_LEAKY_TOKEN")
+ && e.getFormattedMessage().contains("blanks them anyway")),
+ "the scrub does NOT blank this name, so the INFO wording must not claim it does — got: "
+ + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
+ }
+
+ /**
+ * CB-633 follow-up (#192), lead-review fix: a mixed gap — one name the derived allow-list keeps
+ * (this profile's {@code tokenEnv}), one it does not — must split cleanly: the WARN names only
+ * the kept one, the INFO names only the blanked one. Proves the split uses {@code
+ * MemberEnvAllowList.keeps} per-name rather than an all-or-nothing decision for the whole gap.
+ */
+ @Test
+ void allowListSplitsAMixedGapBetweenTheWarnAndTheInfo() {
+ FakeHerdr herdr = new FakeHerdr();
+ FleetConfig.Profile cfg = new FleetConfig.Profile(
+ "ltms-local", "http://gx00.gw:8000", "coder", null, "SOME_LEAKY_TOKEN",
+ List.of("claude"), "tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null);
+ FleetConfig.MemberCredentials creds = new FleetConfig.MemberCredentials(
+ FleetConfig.MemberCredentials.POLICY_ALLOW_LIST, List.of(), List.of());
+ ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
+ name -> "SHELL".equals(name) ? "/bin/zsh" : null,
+ 0, System::currentTimeMillis, () -> {}, null, () -> creds,
+ () -> Set.of("SOME_LEAKY_TOKEN", "A_BRAND_NEW_SECRET_TOKEN"));
+
+ Logger logger = (Logger) LoggerFactory.getLogger(HerdrPeerLauncher.class);
+ Level original = logger.getLevel();
+ logger.setLevel(Level.INFO);
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ logger.addAppender(appender);
+ try {
+ svc.spawn();
+ } finally {
+ logger.detachAppender(appender);
+ logger.setLevel(original);
+ }
+
+ boolean warnNamesOnlyKept = appender.list.stream().anyMatch(e ->
+ e.getLevel() == Level.WARN
+ && e.getFormattedMessage().contains("memberCredentials gap")
+ && e.getFormattedMessage().contains("SOME_LEAKY_TOKEN")
+ && !e.getFormattedMessage().contains("A_BRAND_NEW_SECRET_TOKEN"));
+ boolean infoNamesOnlyBlanked = appender.list.stream().anyMatch(e ->
+ e.getLevel() == Level.INFO
+ && e.getFormattedMessage().contains("memberCredentials gap")
+ && e.getFormattedMessage().contains("A_BRAND_NEW_SECRET_TOKEN")
+ && !e.getFormattedMessage().contains("SOME_LEAKY_TOKEN"));
+
+ assertTrue(warnNamesOnlyKept, "the WARN must name the derived-list-kept variable and only it "
+ + "— got: " + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
+ assertTrue(infoNamesOnlyBlanked, "the INFO must name the scrub-blanked variable and only it "
+ + "— got: " + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
+ }
+
/**
* The half of CB-592 that can actually survive the pane's login shell. BRIDGED_MEMBER is a name
* secrets.sh never exports, so nothing overwrites it — measured: GITEA_TOKEN is injected the