diff --git a/scripts/probe-member-credentials.sh b/scripts/probe-member-credentials.sh new file mode 100755 index 0000000..8dabaea --- /dev/null +++ b/scripts/probe-member-credentials.sh @@ -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