CB-596: the credential probe, as one auditable command
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:
Executable
+135
@@ -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
|
||||
Reference in New Issue
Block a user