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