diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index 8d4cae3..c17db37 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -51,6 +51,7 @@ import dev.ltms.fleet.session.SessionReaper; import dev.ltms.fleet.member.ClaudeCodeLauncher; import dev.ltms.fleet.member.CompositePeerLauncher; import dev.ltms.fleet.member.HerdrPeerLauncher; +import dev.ltms.fleet.member.MemberCredentialPolicyView; import dev.ltms.fleet.member.OpenCodeLauncher; import dev.ltms.fleet.placement.BackendOutagePolicy; import dev.ltms.fleet.placement.BackendQuarantine; @@ -708,8 +709,11 @@ public final class Fleetd { // CB-185: give FleetApp both daemons — /healthz must require both to answer and // GET /sessions must merge across both, or a down/unpolled member daemon is invisible. + // fleetd #111: live (re-read-per-request) memberCredentials view for GET /member-credentials — + // same hot-reload shape as the memberCredentials supplier passed to ClaudeCodeLauncher above. Javalin app = new FleetApp(herdr, memberHerdr, workers, sessions, messages, presence, mcp.servlet(), - callers, metrics, deliverable).build(); + callers, metrics, deliverable, + () -> MemberCredentialPolicyView.of(config.get().memberCredentials())).build(); app.start(cfg.bind().host(), cfg.bind().port()); log.info("fleetd listening on {}:{}, herdr socket {}", cfg.bind().host(), cfg.bind().port(), socket); @@ -1082,12 +1086,14 @@ public final class Fleetd { * #requiredSecretEnvVars} is exposed for {@link #reportRequiredSecrets}'s own test. */ static void reportMemberCredentialsGap(FleetConfig cfg) { - FleetConfig.MemberCredentials creds = cfg.memberCredentials(); - if (creds != null && !creds.known().isEmpty()) { + // fleetd #111: the counts below come from MemberCredentialPolicyView, the same class the + // live GET /member-credentials endpoint reads — one place computes them, not two. + MemberCredentialPolicyView view = MemberCredentialPolicyView.of(cfg.memberCredentials()); + if (view.present()) { log.info("memberCredentials: policy={}, {} known name(s), {} allowed — blocking {} on " + "every spawn{}", - creds.policy(), creds.known().size(), creds.allow().size(), creds.blockedSet().size(), - creds.isAllowList() + view.policy(), view.knownCount(), view.allowedCount(), view.blockedCount(), + cfg.memberCredentials().isAllowList() ? " (allow-list: known/allow are reporting only — the control is the derived ZDOTDIR scrub)" : ""); return; diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/MemberCredentialPolicyView.java b/fleetd/src/main/java/dev/ltms/fleet/member/MemberCredentialPolicyView.java new file mode 100644 index 0000000..fec13cd --- /dev/null +++ b/fleetd/src/main/java/dev/ltms/fleet/member/MemberCredentialPolicyView.java @@ -0,0 +1,75 @@ +package dev.ltms.fleet.member; + +import dev.ltms.fleet.config.FleetConfig; + +import java.util.List; + +/** + * fleetd #111 (CB-608): a single, testable read of the {@code memberCredentials:} policy — names + * and counts only, never a value. The daemon never holds a credential's value in the + * first place (only the names configured under {@code known:}/{@code allow:}), so there is + * nothing here to redact by construction; the point of this class is that it is the ONE place + * that turns a policy into names-and-counts, so nothing else hand-counts a second time. + * + *

Before this class, {@link dev.ltms.fleet.Fleetd#reportMemberCredentialsGap} computed these + * same counts inline for the startup log line, and {@code scripts/probe-member-credentials.sh} + * carried its own hardcoded {@code NAMES} array that the live policy could grow past silently + * (#111) — the exact "hand-maintained second copy drifts" shape #114 fixed for the tool + * catalogue. Both now read this class: the startup log via {@link + * dev.ltms.fleet.Fleetd#reportMemberCredentialsGap}, and a live daemon via the {@code + * GET /member-credentials} REST endpoint ({@link dev.ltms.fleet.rest.FleetApp}), which the probe + * script fetches instead of carrying its own list. + * + * @param present policy configured with at least one {@code known} name. {@code false} for an + * absent or empty {@code memberCredentials:} block — represented honestly as "no + * policy", never as "nothing blocked" (an empty {@link #blocked} could otherwise be + * misread as a clean bill of health). + * @param policy the normalized policy mode ({@link FleetConfig.MemberCredentials#policy()}), or + * {@code null} when {@link #present} is {@code false}. + * @param known every name the policy declares, in configured order. Names only, never a value. + * @param allowed the subset of {@link #known} explicitly let through. Names only. + * @param blocked {@link #known} minus {@link #allowed} — the names an actual spawn shadows. Names + * only. + */ +public record MemberCredentialPolicyView(boolean present, String policy, List known, + List allowed, List blocked) { + + private static final MemberCredentialPolicyView ABSENT = + new MemberCredentialPolicyView(false, null, List.of(), List.of(), List.of()); + + public MemberCredentialPolicyView { + known = known == null ? List.of() : List.copyOf(known); + allowed = allowed == null ? List.of() : List.copyOf(allowed); + blocked = blocked == null ? List.of() : List.copyOf(blocked); + } + + /** The honest "no policy configured" view. */ + public static MemberCredentialPolicyView absent() { + return ABSENT; + } + + /** + * Build the view straight from the live config. {@code creds} may be {@code null} (no {@code + * memberCredentials:} block at all) — treated the same as a present-but-empty block, exactly + * like {@link dev.ltms.fleet.Fleetd#reportMemberCredentialsGap} already did. + */ + public static MemberCredentialPolicyView of(FleetConfig.MemberCredentials creds) { + if (creds == null || creds.known().isEmpty()) { + return ABSENT; + } + return new MemberCredentialPolicyView(true, creds.policy(), creds.known(), creds.allow(), + List.copyOf(creds.blockedSet())); + } + + public int knownCount() { + return known.size(); + } + + public int allowedCount() { + return allowed.size(); + } + + public int blockedCount() { + return blocked.size(); + } +} diff --git a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java index 5cb89db..9c7c343 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java @@ -13,6 +13,7 @@ import dev.ltms.fleet.herdr.Agent; import dev.ltms.fleet.herdr.HerdrClient; import dev.ltms.fleet.herdr.HerdrException; import dev.ltms.fleet.inject.MemberPresence; +import dev.ltms.fleet.member.MemberCredentialPolicyView; import dev.ltms.fleet.peer.PeerUnreachableException; import dev.ltms.fleet.placement.PlacementException; import dev.ltms.fleet.msg.MessageService; @@ -32,6 +33,7 @@ import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.function.Predicate; +import java.util.function.Supplier; import java.util.stream.Collectors; /** @@ -64,6 +66,10 @@ public final class FleetApp { private final HttpServlet mcpServlet; // MCP Streamable-HTTP endpoint, mounted at /mcp (nullable) private final CallerResolver auth; // CB-501: null → authz not enforced (legacy behaviour) private final Metrics metrics; // CB-502: null → /metrics not exposed + // fleetd #111: re-read per request, same hot-reload shape as every other live config read — + // absent() (the honest "no policy configured" view) for every constructor that does not wire + // a real one, so existing legacy call sites keep building without knowing this field exists. + private final Supplier memberCredentials; private final ObjectMapper mapper = new ObjectMapper(); /** @@ -111,6 +117,19 @@ public final class FleetApp { MessageService messages, MemberPresence presence, HttpServlet mcpServlet, CallerResolver auth, Metrics metrics, Predicate deliverable) { + this(herdr, memberHerdr, workers, sessions, messages, presence, mcpServlet, auth, metrics, + deliverable, MemberCredentialPolicyView::absent); + } + + /** + * @param memberCredentials live {@code memberCredentials:} policy view (fleetd #111), re-read + * per request for {@code GET /member-credentials}; production wiring + * passes the same hot-reload shape as every other live config read + */ + public FleetApp(HerdrClient herdr, HerdrClient memberHerdr, PeerLauncher workers, SessionManager sessions, + MessageService messages, MemberPresence presence, + HttpServlet mcpServlet, CallerResolver auth, Metrics metrics, + Predicate deliverable, Supplier memberCredentials) { this.herdr = herdr; this.memberHerdr = memberHerdr != null ? memberHerdr : herdr; this.workers = workers; @@ -120,6 +139,7 @@ public final class FleetApp { this.mcpServlet = mcpServlet; this.auth = auth; this.metrics = metrics; + this.memberCredentials = memberCredentials != null ? memberCredentials : MemberCredentialPolicyView::absent; } /** Wire routes onto a fresh, unstarted Javalin instance. Caller starts it. */ @@ -148,6 +168,7 @@ public final class FleetApp { app.get("/agents", this::agents); app.get("/members", this::listMembers); // CB-304: registry roster + live herdr status app.get("/profiles", this::profiles); // configured backend profiles + app.get("/member-credentials", this::memberCredentials); // fleetd #111: policy names + counts, never a value app.post("/members", this::spawnMember); // optional ?role=&profile= or {"role":…,"profile":…} app.delete("/members/{paneId}", this::stopMember); app.post("/sessions/{id}/message", this::sendMessage); // fleet_send (primary; blocking, wait:false, or answer via turnId) @@ -346,6 +367,29 @@ public final class FleetApp { "default", workers.defaultProfile() == null ? "" : workers.defaultProfile())); } + /** + * fleetd #111 (CB-608): the live {@code memberCredentials:} policy as names and counts — + * NEVER a value. The daemon does not hold a credential's value in the first place (only the + * name it is configured under), so there is nothing to redact here beyond what {@link + * MemberCredentialPolicyView} already omits by construction. This is the source + * {@code scripts/probe-member-credentials.sh} reads instead of carrying its own hardcoded + * name list, which is exactly what let the list drift silently behind the real policy. + */ + private void memberCredentials(Context ctx) { + if (!allow(ctx, Authz.Action.READ, null)) { + return; + } + MemberCredentialPolicyView view = memberCredentials.get(); + ctx.status(200).json(Map.of( + "present", view.present(), + "policy", view.policy() == null ? "" : view.policy(), + "known", view.known(), + "allowed", view.allowed(), + "knownCount", view.knownCount(), + "allowedCount", view.allowedCount(), + "blockedCount", view.blockedCount())); + } + /** * Spawn a guard-checked worker. An optional {@code profile} (query param or {@code {"profile":…}} * body) picks which configured profile; omitted → the default. 403 if the base_url would breach diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/MemberCredentialPolicyViewTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/MemberCredentialPolicyViewTest.java new file mode 100644 index 0000000..0bbd2d8 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/member/MemberCredentialPolicyViewTest.java @@ -0,0 +1,99 @@ +package dev.ltms.fleet.member; + +import dev.ltms.fleet.config.FleetConfig; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * fleetd #111 (CB-608): {@link MemberCredentialPolicyView} is the one place that turns a {@code + * memberCredentials:} policy into names-and-counts, so the startup log line and {@code + * GET /member-credentials} cannot drift apart. These tests pin: the counts always match the + * policy that produced them, an absent/empty policy is represented honestly (never as "nothing + * blocked"), and the view carries names only — no value ever flows through it, because it is + * built only from {@link FleetConfig.MemberCredentials}, which itself never holds a value. + */ +class MemberCredentialPolicyViewTest { + + @Test + void nullPolicyIsAbsentNotClean() { + MemberCredentialPolicyView view = MemberCredentialPolicyView.of(null); + + assertFalse(view.present(), "a null policy must be reported as absent"); + assertEquals(0, view.knownCount()); + assertEquals(0, view.allowedCount()); + assertEquals(0, view.blockedCount()); + assertTrue(view.known().isEmpty()); + assertTrue(view.allowed().isEmpty()); + assertTrue(view.blocked().isEmpty()); + } + + @Test + void emptyKnownListIsAbsentEvenWithAPolicyModeSet() { + // A memberCredentials: block can be present in YAML with policy: set but known: empty — + // that must still read as "no policy configured", the same as a fully absent block, + // because zero known names means the daemon blocks nothing either way. + FleetConfig.MemberCredentials creds = + new FleetConfig.MemberCredentials("deny-by-default", List.of(), List.of()); + + MemberCredentialPolicyView view = MemberCredentialPolicyView.of(creds); + + assertFalse(view.present()); + assertEquals(0, view.knownCount()); + } + + @Test + void countsMatchARealPolicyExactly() { + FleetConfig.MemberCredentials creds = new FleetConfig.MemberCredentials( + "deny-by-default", + List.of("AI_GATEWAY_TOKEN"), + List.of("AI_GATEWAY_TOKEN", "GITEA_ACCESS_TOKEN", "WORKER_GITEA_TOKEN")); + + MemberCredentialPolicyView view = MemberCredentialPolicyView.of(creds); + + assertTrue(view.present()); + assertEquals("deny-by-default", view.policy()); + assertEquals(List.of("AI_GATEWAY_TOKEN", "GITEA_ACCESS_TOKEN", "WORKER_GITEA_TOKEN"), view.known()); + assertEquals(List.of("AI_GATEWAY_TOKEN"), view.allowed()); + assertEquals(3, view.knownCount()); + assertEquals(1, view.allowedCount()); + // known minus allowed — the two names actually shadowed on a spawn. + assertEquals(2, view.blockedCount()); + assertTrue(view.blocked().containsAll(List.of("GITEA_ACCESS_TOKEN", "WORKER_GITEA_TOKEN"))); + } + + @Test + void presentPolicyThatBlocksNothingIsStillDistinctFromAbsent() { + // known == allow => blockedCount is 0, exactly like an absent policy's blockedCount — the + // two must still be told apart by `present`, or a reader cannot tell "policy configured, + // nothing currently blocked" from "no policy at all". + FleetConfig.MemberCredentials creds = new FleetConfig.MemberCredentials( + "deny-by-default", List.of("X"), List.of("X")); + + MemberCredentialPolicyView view = MemberCredentialPolicyView.of(creds); + + assertTrue(view.present()); + assertEquals(1, view.knownCount()); + assertEquals(0, view.blockedCount()); + assertFalse(MemberCredentialPolicyView.absent().present()); + } + + @Test + void namesPassThroughUnchangedNeverAValue() { + // The view is built only from FleetConfig.MemberCredentials, which itself carries names, + // never values (see its javadoc) — so there is no code path here that could substitute a + // secret's value for its name. This pins the identity: what goes into `known`/`allow` is + // exactly what comes out, character for character. + List known = List.of("SOME_TOKEN_NAME", "ANOTHER_NAME"); + FleetConfig.MemberCredentials creds = + new FleetConfig.MemberCredentials("deny-by-default", List.of(), known); + + MemberCredentialPolicyView view = MemberCredentialPolicyView.of(creds); + + assertEquals(known, view.known()); + } +} diff --git a/scripts/probe-member-credentials.sh b/scripts/probe-member-credentials.sh index 209a94b..11f26e5 100755 --- a/scripts/probe-member-credentials.sh +++ b/scripts/probe-member-credentials.sh @@ -22,8 +22,34 @@ # A prefix of a short secret is most of the secret, and it would end up pasted into a ticket. The # hash answers every question the prefix was for — is it set, is it the same value as over there, # is it the CB-592 sentinel — and answers none of the ones it should not. -# * It never writes anywhere, never contacts the network, and never touches secrets.sh, which is -# the operator's file. +# * It never writes anywhere, never contacts the network except the daemon's own REST port (see +# below), and never touches secrets.sh, which is the operator's file. +# +# WHERE THE NAME LIST COMES FROM (fleetd #111 / CB-608) +# +# Earlier versions of this script carried their own hardcoded NAMES array, recorded by hand on +# 2026-08-16. The live memberCredentials: policy in fleetd.yaml grew past that list, and this probe +# never noticed — it kept checking the same 31 names, printed a clean-looking table, and exited 0. +# A verification tool that silently under-reports the thing it verifies is worse than no tool at +# all, because its "clean" output gets taken as proof rather than treated with the suspicion an +# absent tool would get. +# +# The fix is the same one #114 used for the drifted tool catalogue: delete the hand-maintained copy +# rather than update it. This script now fetches the policy's name list from the daemon itself, at +# `GET /member-credentials` (dev.ltms.fleet.member.MemberCredentialPolicyView via FleetApp) — names +# and counts only, the same way the daemon's own startup log line is computed, from the SAME class. +# If fleetd adds a name to memberCredentials.known tomorrow, this probe checks it tomorrow too, +# with no edit here required. There is no local fallback list. See fetch_policy() below for what +# happens when the daemon cannot be reached — it is a hard failure, on purpose (see next section). +# +# WHY AN UNREACHABLE DAEMON IS A HARD FAILURE, NOT A DEGRADED RUN +# +# An empty (or short) name list passes every subset check trivially — a probe that checked zero +# names would print "0 of 0 names are set" and look identical to a clean bill of health. That trap +# has bitten this project twice in one week (see docs/memory — "silent defaults disable features" +# and "a test on the seam does not prove the caller"). So the denominator is guarded explicitly: +# this script refuses to proceed unless it got a policy with at least one known name, and it refuses +# just as hard if the count it fetched does not match the count it is about to check. # # HOW TO RUN IT # @@ -32,34 +58,22 @@ # 2. For the comparison row, in your OWN shell — a lead, not a member: # bash scripts/probe-member-credentials.sh --allow-outside-member # +# Both readings need the daemon's REST port reachable (default http://127.0.0.1:8765; override with +# FLEETD_HOST). That is normally true in every pane this script is meant to run in. +# # The two outputs side by side are the finding: any name whose hash matches between them is a # credential the member holds in full. # set -uo pipefail -# The names ${SHARED_ENV}/tools/secrets.sh exports, recorded on 2026-08-16 (issue #82). Names only — -# this list contains no values and never should. If secrets.sh gains a name, this list goes stale and -# the probe silently stops asking about it; that staleness is itself part of what #82's criterion 4 -# has to solve, so it is called out in the summary rather than hidden. -NAMES=( - AI_GATEWAY_TOKEN BESZEL_ADMIN_EMAIL BESZEL_ADMIN_PASSWORD - BESZEL_HUB_URL BESZEL_KEY BESZEL_UNIVERSAL_TOKEN - BRAIN_MCP_TOKEN CF_ACCOUNT_ID CF_API_TOKEN - CF_USER_TOKEN CONFLUENCE_API_TOKEN CONFLUENCE_USERNAME - CONTEXT7_TOKEN GITEA_HOST GITLAB_OAUTH_CLIENT_SECRET - GITLAB_PERSONAL_ACCESS_TOKEN GRAFANA_ADMIN_PASSWORD GRAFANA_ADMIN_USER - HASS_TOKEN HW_PASSWORD HW_USER - LTMS_API_KEY MEMORY_MCP_TOKEN METRICS_PUSH_TOKEN - OPENCODE_AUTOMODE_MODEL TELEGRAM_BOT_TOKEN TELEGRAM_CHAT_ID - TS_API_KEY TS_AUTHKEY WORKER_GITEA_TOKEN - GITEA_ACCESS_TOKEN -) +FLEETD_HOST="${FLEETD_HOST:-http://127.0.0.1:8765}" +POLICY_URL="${FLEETD_HOST%/}/member-credentials" allow_outside=0 for arg in "$@"; do case "$arg" in --allow-outside-member) allow_outside=1 ;; - -h|--help) sed -n '2,40p' "$0"; exit 0 ;; + -h|--help) sed -n '2,60p' "$0"; exit 0 ;; *) echo "unknown argument: $arg" >&2; exit 2 ;; esac done @@ -75,6 +89,107 @@ EOF exit 1 fi +# --- fetch the policy from the daemon (fleetd #111) — no local fallback, ever ------------------ +# +# Prefer jq (a real JSON parser); fall back to python3 (present on every host this has run on so +# far); if neither exists, fail loudly rather than guess at the JSON with grep/sed, which is exactly +# the kind of "looks like it worked" degradation this ticket exists to remove. +# +# NOTE: jq's `//` alternative operator treats `false` AND `0` as "missing" and substitutes the +# default — so `.present // empty` silently turns a real `"present": false` into an empty string +# ("unknown"), not the false it actually is. Every extraction below reads its field directly +# instead, so a genuine false/0 is reported as exactly that, not swallowed into "unknown". +if ! command -v jq >/dev/null 2>&1 && ! command -v python3 >/dev/null 2>&1; then + echo "refusing to run: neither jq nor python3 is on PATH, and this probe will not guess at JSON" \ + "with grep/sed. Install one of them, or run from a shell that has one." >&2 + exit 1 +fi + +POLICY_JSON="$(curl -fsS --max-time 5 "$POLICY_URL" 2>/dev/null)" +CURL_STATUS=$? +if [ "$CURL_STATUS" -ne 0 ] || [ -z "$POLICY_JSON" ]; then + cat >&2 </dev/null 2>&1; then + mapfile -t _FIELDS < <(printf '%s' "$POLICY_JSON" | jq -r ' + (.present | tostring), + (.policy // ""), + (.knownCount // 0 | tostring), + (.allowedCount // 0 | tostring), + (.blockedCount // 0 | tostring), + (.known[]? // empty)') +else + mapfile -t _FIELDS < <(printf '%s' "$POLICY_JSON" | python3 - <<'PY' +import json, sys +data = json.load(sys.stdin) +print(str(data.get("present"))) +print(data.get("policy") or "") +print(data.get("knownCount") if data.get("knownCount") is not None else 0) +print(data.get("allowedCount") if data.get("allowedCount") is not None else 0) +print(data.get("blockedCount") if data.get("blockedCount") is not None else 0) +for n in (data.get("known") or []): + print(n) +PY + ) +fi + +PRESENT="${_FIELDS[0]:-null}" +POLICY_MODE="${_FIELDS[1]:-}" +KNOWN_COUNT_REPORTED="${_FIELDS[2]:-0}" +ALLOWED_COUNT_REPORTED="${_FIELDS[3]:-0}" +BLOCKED_COUNT_REPORTED="${_FIELDS[4]:-0}" +NAMES=("${_FIELDS[@]:5}") + +# knownCount must be a plain non-negative integer for the arithmetic guard below — a malformed or +# unparseable response must fail loudly, not be coerced into a number that happens to compare true. +case "$KNOWN_COUNT_REPORTED" in + ''|*[!0-9]*) + echo "refusing to run: knownCount in the response ('$KNOWN_COUNT_REPORTED') is not a plain" \ + "non-negative integer — the response could not be parsed as expected." >&2 + exit 1 + ;; +esac + +# --- guard the denominator explicitly — never proceed on a zero/short count --------------------- +# +# This is the exact trap named in the ticket: an empty (or truncated) NAMES array passes every +# subsequent "is it set" check vacuously and prints a table that LOOKS complete. So this is checked +# before anything else runs, with a message that says why, not just that it failed. +if [ "${#NAMES[@]}" -eq 0 ] || [ "$KNOWN_COUNT_REPORTED" -eq 0 ]; then + cat >&2 <&2 </dev/null || echo unknown)" +echo "policy : mode=${POLICY_MODE:-unknown} known=$KNOWN_COUNT_REPORTED allowed=${ALLOWED_COUNT_REPORTED:-?} blocked=${BLOCKED_COUNT_REPORTED:-?}" echo printf '%-30s %-7s %6s %s\n' "NAME" "STATE" "LEN" "SHA256-12" printf '%-30s %-7s %6s %s\n' "------------------------------" "-------" "------" "------------" @@ -118,6 +234,7 @@ done echo echo "$set_count of ${#NAMES[@]} names are set in this shell." +echo "policy contains $KNOWN_COUNT_REPORTED name(s); this run checked ${#NAMES[@]} — they match." echo cat <<'EOF' How to read this: @@ -129,7 +246,8 @@ How to read this: most urgent thing on this page. * AI_GATEWAY_TOKEN matching is expected and correct, not a leak: fleetd.yaml names it in `tokenEnv:` for the local and gx profiles, so a member reaching the gateway is by design. - * A name that is set here but is NOT in the list above will not appear at all. The list was - recorded on 2026-08-16 and does not update itself. Anything added to secrets.sh since then is - invisible to this probe — which is the same gap issue #82 criterion 4 asks to close properly. + * The name list above is fetched live from the running daemon's memberCredentials: policy + (fleetd #111) — it is never hand-maintained here, so it cannot go stale the way the old + hardcoded list did. If the daemon's policy changes, the next run of this script reflects it + with no edit to this file. EOF