CB-596: the credential probe, as one auditable command
CI / contract (push) Successful in 48s
CI / build (push) Successful in 1m39s

Issue #82 step 1 is a measurement, and the classifier refuses an ad-hoc pipeline
that enumerates credential names inside a member — correctly. This is the seam:
one file the operator reads once and then runs, instead of approving a shell
pipeline they have to take on trust.

It never prints a credential value or any part of one. #82's criterion 1 asked
for a 6-character prefix; this prints a truncated SHA-256 instead. A prefix of a
short secret is most of the secret and would end up pasted into a ticket, while
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.

Refuses to run unless BRIDGED_MEMBER=1, since the finding is what a MEMBER holds;
--allow-outside-member takes the comparison reading and labels it as such.

I have not run the reading path. That is the operator's call, which is the whole
point of the ticket.
This commit is contained in:
Dai Ha
2026-08-16 19:01:42 +02:00
parent aa4ee64a34
commit 837fed7690
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
#
# CB-596 step 1: measure which credentials a live member actually holds.
#
# The claim under test is that a member's herdr pane starts a LOGIN shell, that shell sources
# ${SHARED_ENV}/tools/secrets.sh, and so a member inherits every name that file exports — while
# CB-592 blocks exactly one of them (GITEA_ACCESS_TOKEN). That is an inference from the code, not a
# measurement, and issue #82 says plainly: do not build a fix on the inference. This is the
# measurement.
#
# WHY THIS IS A SCRIPT AND NOT A COMMAND SOMEONE TYPES
#
# Enumerating credential names inside a member is exactly the action that should need the operator's
# explicit approval, and the command classifier refuses it. That refusal is correct. This script is
# the seam: it is one auditable file the operator can read once, top to bottom, and then run — rather
# than approving an ad-hoc shell pipeline whose behaviour they have to take on trust.
#
# WHAT IT WILL NOT DO
#
# * It never prints a credential value, and never any prefix or suffix of one. Not one character.
# Issue #82's criterion 1 asked for a 6-character prefix; this prints a truncated SHA-256 instead.
# 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.
#
# HOW TO RUN IT
#
# 1. As the operator, in a member's pane (a spawned worker's terminal):
# bash scripts/probe-member-credentials.sh
# 2. For the comparison row, in your OWN shell — a lead, not a member:
# bash scripts/probe-member-credentials.sh --allow-outside-member
#
# 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
)
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 ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
esac
done
if [ "${BRIDGED_MEMBER:-}" != "1" ] && [ "$allow_outside" -eq 0 ]; then
cat >&2 <<'EOF'
refusing to run: BRIDGED_MEMBER is not 1, so this is not a member's shell.
The finding this probe exists for is what a MEMBER holds. Run it in a spawned worker's pane. If you
meant to take the comparison reading from your own shell, pass --allow-outside-member and the output
will be labelled as such.
EOF
exit 1
fi
# Prefer sha256sum (Linux), fall back to shasum (macOS). If neither exists, report presence and
# length only — degraded, but never a value.
hasher=""
if command -v sha256sum >/dev/null 2>&1; then
hasher="sha256sum"
elif command -v shasum >/dev/null 2>&1; then
hasher="shasum -a 256"
fi
digest() { # value -> first 12 hex chars of its sha256, or "-" when no hasher is available
[ -z "$hasher" ] && { printf '%s' "-"; return; }
printf '%s' "$1" | $hasher | cut -c1-12
}
if [ "${BRIDGED_MEMBER:-}" = "1" ]; then
where="MEMBER (BRIDGED_MEMBER=1)"
else
where="NOT a member — comparison reading only"
fi
echo "CB-596 credential probe"
echo "reading from : $where"
echo "shell : ${SHELL:-unknown}"
echo "hash : ${hasher:-none available — lengths only}"
# Only printed so the two readings can be told apart when they are pasted side by side.
echo "host : $(hostname 2>/dev/null || echo unknown)"
echo
printf '%-30s %-7s %6s %s\n' "NAME" "STATE" "LEN" "SHA256-12"
printf '%-30s %-7s %6s %s\n' "------------------------------" "-------" "------" "------------"
set_count=0
for name in "${NAMES[@]}"; do
value="${!name:-}"
if [ -z "$value" ]; then
printf '%-30s %-7s %6s %s\n' "$name" "unset" "-" "-"
else
set_count=$((set_count + 1))
printf '%-30s %-7s %6s %s\n' "$name" "SET" "${#value}" "$(digest "$value")"
fi
done
echo
echo "$set_count of ${#NAMES[@]} names are set in this shell."
echo
cat <<'EOF'
How to read this:
* Take the MEMBER reading and the comparison reading, and line them up. A name whose SHA256-12
matches on both sides is a credential the member holds in full. That is the finding.
* GITEA_ACCESS_TOKEN is the control. CB-592 replaces it with a blocked sentinel, so its hash
should DIFFER between the two readings. If it matches, CB-592 is not working and that is the
most urgent thing on this page.
* AI_GATEWAY_TOKEN matching is expected and correct, not a leak: bridged.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.
EOF