feat(security): receipt admin scripts + user git-ops (zero exceptions)

Wire the remaining shell egress through gstack-egress-lib.sh:

- gstack-gbrain-mcp-verify: both JSON-RPC probe POSTs (initialize +
  tools/list) receipted fail-closed via payload files (hash == wire
  bytes). A refused receipt lands in the NETWORK class — no send.
- gstack-security-dashboard / gstack-community-dashboard: the
  community-pulse GETs receipted fail-open (read-only stats must not
  break over an audit hiccup).
- gstack-gbrain-supabase-provision: api_call receipted fail-closed.
  Each retry attempt hands the helper a fresh copy of the body file
  (the helper consumes its payload). The receipt hashes the request
  body only — the PAT never reaches the ledger or any log. Refusal
  exits 8 without retrying.
- git-class sha256:null receipts, fail-open: gstack-artifacts-init
  (ls-remote, initial push, fetch/pull recovery, retry push),
  gstack-brain-restore (staging clone, existing-repo fetch),
  gstack-session-update (self-update pull).

gstack-team-init needs no wiring: every git clone in it is inside an
echoed instruction string, not an executed command.

The lib now self-locates with shell builtins only (no dirname), so
sourcing works under the whitelist-PATH test harnesses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit b8c5e2055b21ab72878b3e46f8047782ee65a11c)
This commit is contained in:
Garry Tan 2026-08-12 11:37:09 -07:00
parent 52288947ec
commit 97ace4a452
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
10 changed files with 111 additions and 24 deletions

View File

@ -40,6 +40,16 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
URL_BIN="$SCRIPT_DIR/gstack-artifacts-url"
REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt"
# Egress receipt helpers (_receipted_git): fail-open for user-directed
# git ops against the user's own artifacts remote.
. "$SCRIPT_DIR/gstack-egress-lib.sh"
# remote host for receipt records (github.com etc). Set once PUSH_URL exists.
_artifacts_host() {
local h="${PUSH_URL#*://}"; h="${h#*@}"; h="${h%%[/:]*}"
echo "${h:-unknown}"
}
REMOTE_URL=""
HOST_PREF=""
URL_FORM_SUPPORTED="false"
@ -185,7 +195,8 @@ PUSH_URL=$("$URL_BIN" --to ssh "$CANONICAL_HTTPS" 2>/dev/null || echo "$CANONICA
# ---- verify push URL is reachable ----
echo "Verifying remote connectivity: $PUSH_URL"
if ! git ls-remote "$PUSH_URL" >/dev/null 2>&1; then
if ! _receipted_git open artifacts-init "$(_artifacts_host)" artifacts-remote-ls-remote "user ran gstack-artifacts-init" \
bash -c 'git ls-remote "$1" >/dev/null 2>&1' _ "$PUSH_URL"; then
cat >&2 <<EOF
Remote not reachable via SSH: $PUSH_URL
This could mean:
@ -346,10 +357,15 @@ else
fi
# ---- initial push ----
if ! git push -q -u origin main 2>/dev/null; then
if ! _receipted_git open artifacts-init "$(_artifacts_host)" artifacts-initial-push "user ran gstack-artifacts-init" \
bash -c 'git push -q -u origin main 2>/dev/null'; then
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if git fetch origin 2>/dev/null && git pull --ff-only origin "$CURRENT_BRANCH" 2>/dev/null; then
git push -q -u origin "$CURRENT_BRANCH" || {
if _receipted_git open artifacts-init "$(_artifacts_host)" artifacts-fetch "user ran gstack-artifacts-init" \
bash -c 'git fetch origin 2>/dev/null' \
&& _receipted_git open artifacts-init "$(_artifacts_host)" artifacts-pull "user ran gstack-artifacts-init" \
bash -c 'git pull --ff-only origin "$1" 2>/dev/null' _ "$CURRENT_BRANCH"; then
_receipted_git open artifacts-init "$(_artifacts_host)" artifacts-initial-push "user ran gstack-artifacts-init" \
git push -q -u origin "$CURRENT_BRANCH" || {
echo "Push to $PUSH_URL failed. The remote may have divergent content." >&2
echo "Try: cd ~/.gstack && git pull --rebase origin $CURRENT_BRANCH && git push origin $CURRENT_BRANCH" >&2
exit 1

View File

@ -30,6 +30,10 @@ set -euo pipefail
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_BIN="$SCRIPT_DIR/gstack-config"
# Egress receipt helpers (_receipted_git): fail-open for user-directed
# git ops against the user's own artifacts remote.
. "$SCRIPT_DIR/gstack-egress-lib.sh"
# v1.27.0.0+ canonical name; brain-remote is the legacy fallback during the
# migration window. The migration script renames the file in place.
if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then
@ -78,7 +82,9 @@ STAGING=$(mktemp -d "${TMPDIR:-/tmp}/gstack-brain-restore.XXXXXX")
trap 'rm -rf "$STAGING" 2>/dev/null' EXIT
echo "Cloning $REMOTE_URL to staging..."
if ! git clone --quiet "$REMOTE_URL" "$STAGING/repo" 2>/dev/null; then
RESTORE_HOST="${REMOTE_URL#*://}"; RESTORE_HOST="${RESTORE_HOST#*@}"; RESTORE_HOST="${RESTORE_HOST%%[/:]*}"
if ! _receipted_git open brain-restore "${RESTORE_HOST:-unknown}" brain-restore-clone "user ran gstack-brain-restore" \
bash -c 'git clone --quiet "$1" "$2" 2>/dev/null' _ "$REMOTE_URL" "$STAGING/repo"; then
echo "Clone failed. Check:" >&2
echo " - URL is correct: $REMOTE_URL" >&2
echo " - Auth: gh auth status (github) / glab auth status (gitlab)" >&2
@ -160,7 +166,8 @@ done
# ---- move .git into place ----
if [ -d "$GSTACK_HOME/.git" ]; then
# Existing .git with matching remote — just fetch + fast-forward.
git -C "$GSTACK_HOME" fetch origin >/dev/null 2>&1 || true
_receipted_git open brain-restore "${RESTORE_HOST:-unknown}" brain-restore-fetch "user ran gstack-brain-restore" \
bash -c 'git -C "$1" fetch origin >/dev/null 2>&1' _ "$GSTACK_HOME" || true
else
mv "$STAGING/repo/.git" "$GSTACK_HOME/.git"
fi

View File

@ -12,6 +12,9 @@ set -uo pipefail
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
# Egress receipt helpers (_receipted_curl): fail-open for read-only stats.
. "$GSTACK_DIR/bin/gstack-egress-lib.sh"
# Source Supabase config if not overridden by env
if [ -z "${GSTACK_SUPABASE_URL:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then
. "$GSTACK_DIR/supabase/config.sh"
@ -35,10 +38,11 @@ fi
# never as a healthy "Weekly active installs: 0".
TMPBODY="$(mktemp)"
trap 'rm -f "$TMPBODY"' EXIT
HTTP_CODE="$(curl -s --max-time 15 -w '%{http_code}' -o "$TMPBODY" \
SUPA_HOST="${SUPABASE_URL#*://}"; SUPA_HOST="${SUPA_HOST%%/*}"
HTTP_CODE="$(_receipted_curl open community-dashboard "$SUPA_HOST" community-pulse-fetch "user-invoked dashboard" --no-payload \
curl -s --max-time 15 -w '%{http_code}' -o "$TMPBODY" \
"${SUPABASE_URL}/functions/v1/community-pulse" \
-H "apikey: ${ANON_KEY}" \
2>/dev/null || true)"
-H "apikey: ${ANON_KEY}" || true)"
# curl prints its own 000 before a non-zero exit — a `|| echo` here would
# double it to "000000" in user-facing output. Normalize to the last 3 chars.
HTTP_CODE="$(printf '%s' "$HTTP_CODE" | tr -d '[:space:]' | tail -c 3)"

View File

@ -36,7 +36,12 @@
# their own EXIT traps and a trap set by a sourced library would clobber
# the caller's. All temp handling is immediate, per call.
_gstack_egress_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Self-locate without dirname (builtins only), so the lib works even under
# a stripped test PATH.
case "${BASH_SOURCE[0]}" in
*/*) _gstack_egress_lib_dir="$(cd "${BASH_SOURCE[0]%/*}" && pwd)" ;;
*) _gstack_egress_lib_dir="$(pwd)" ;;
esac
_gstack_egress_home() {
if [ -n "${GSTACK_HOME:-}" ]; then

View File

@ -43,6 +43,10 @@ URL="$1"
command -v curl >/dev/null 2>&1 || { echo "gstack-gbrain-mcp-verify: curl is required" >&2; exit 2; }
command -v jq >/dev/null 2>&1 || { echo "gstack-gbrain-mcp-verify: jq is required (brew install jq)" >&2; exit 2; }
# Egress receipt helpers (_receipted_curl): receipt-before-send, fail-closed.
. "$(cd "$(dirname "$0")" && pwd)/gstack-egress-lib.sh"
MCP_HOST=$(echo "$URL" | sed -E 's|^[a-z]+://([^/]+).*|\1|')
emit() {
# emit <status> <server_name> <server_version> <error_class> <error_text> <url_supported> <raw_body>
jq -n \
@ -73,15 +77,20 @@ INIT_BODY='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVers
TMPBODY=$(mktemp -t gstack-mcp-verify.XXXXXX)
trap 'rm -f "$TMPBODY"' EXIT
# Receipted fail-closed: the payload file is hashed and handed to curl as
# the exact wire bytes. A refused receipt never hits the network — it lands
# in the NETWORK class below (curl never ran, no HTTP code).
INIT_PAYLOAD=$(mktemp -t gstack-mcp-init.XXXXXX)
printf '%s' "$INIT_BODY" > "$INIT_PAYLOAD"
set +e
HTTP_CODE=$(curl -s -o "$TMPBODY" -w '%{http_code}' \
HTTP_CODE=$(_receipted_curl closed gbrain-mcp-verify "$MCP_HOST" mcp-initialize-probe "user-invoked mcp verify" "$INIT_PAYLOAD" \
curl -s -o "$TMPBODY" -w '%{http_code}' \
--max-time 10 \
-X POST \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Authorization: Bearer $GBRAIN_MCP_TOKEN" \
-d "$INIT_BODY" \
"$URL" 2>/dev/null)
"$URL")
CURL_EXIT=$?
set -e
@ -150,14 +159,18 @@ URL_SUPPORTED=false
TOOLS_BODY_FILE=$(mktemp -t gstack-mcp-tools.XXXXXX)
TOOLS_REQ='{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
# Receipted fail-closed like the initialize probe. A refused receipt skips
# the probe (nonzero TOOLS_EXIT) and the field stays false — best-effort.
TOOLS_PAYLOAD=$(mktemp -t gstack-mcp-tools-req.XXXXXX)
printf '%s' "$TOOLS_REQ" > "$TOOLS_PAYLOAD"
set +e
curl -s -o "$TOOLS_BODY_FILE" \
_receipted_curl closed gbrain-mcp-verify "$MCP_HOST" mcp-tools-list-probe "user-invoked mcp verify" "$TOOLS_PAYLOAD" \
curl -s -o "$TOOLS_BODY_FILE" \
--max-time 10 \
-X POST \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Authorization: Bearer $GBRAIN_MCP_TOKEN" \
-d "$TOOLS_REQ" \
"$URL" >/dev/null 2>&1
TOOLS_EXIT=$?
set -e

View File

@ -69,6 +69,12 @@ set -euo pipefail
SUPABASE_API_BASE="${SUPABASE_API_BASE:-https://api.supabase.com}"
API_VERSION="v1"
# Egress receipt helpers (_receipted_curl): receipt-before-send, fail-closed.
# The receipt hashes the request body only — the PAT (Authorization header)
# is never receipted or logged.
. "$(cd "$(dirname "$0")" && pwd)/gstack-egress-lib.sh"
SUPABASE_API_HOST="${SUPABASE_API_BASE#*://}"; SUPABASE_API_HOST="${SUPABASE_API_HOST%%/*}"
DEFAULT_WAIT_TIMEOUT=180
POLL_INTERVAL=5
CURL_TIMEOUT=30
@ -135,11 +141,24 @@ api_call() {
-H "Content-Type: application/json"
-H "User-Agent: gstack-gbrain-supabase-provision"
)
# Receipted fail-closed. The retry loop reuses $body_file across
# attempts, but the helper consumes its payload file — so each attempt
# hands it a fresh copy (hash still equals the exact wire bytes; the
# helper appends --data-binary @copy). Bodyless calls use --no-payload.
local payload_arg="--no-payload"
if [ -n "$body_file" ]; then
curl_args+=(--data-binary "@$body_file")
payload_arg=$(mktemp)
cp "$body_file" "$payload_arg"
fi
local status
if ! status=$(curl "${curl_args[@]}" "$url" 2>/dev/null); then
local status rc=0
status=$(_receipted_curl closed supabase-provision "$SUPABASE_API_HOST" "provision-api-call ($method $apipath)" "user ran gstack-gbrain-supabase-provision" "$payload_arg" \
curl "${curl_args[@]}" "$url") || rc=$?
if [ "$rc" -eq 3 ] && [ -z "$status" ]; then
# Egress receipt refused — the send never happened (the helper's
# problem/cause/fix message is already on stderr). Don't retry.
exit 8
fi
if [ "$rc" -ne 0 ]; then
# curl itself failed (network, timeout, etc.). Retry.
if [ "$attempt" -ge "$max_attempts" ]; then
die_net "network failure calling $method $apipath after $attempt attempts"

View File

@ -17,6 +17,9 @@ set -uo pipefail
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
# Egress receipt helpers (_receipted_curl): fail-open for read-only stats.
. "$GSTACK_DIR/bin/gstack-egress-lib.sh"
# Source Supabase config
if [ -z "${GSTACK_SUPABASE_URL:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then
. "$GSTACK_DIR/supabase/config.sh"
@ -46,10 +49,11 @@ fi
# surface are indistinguishable from good news.
TMPBODY="$(mktemp)"
trap 'rm -f "$TMPBODY"' EXIT
HTTP_CODE="$(curl -s --max-time 15 -w '%{http_code}' -o "$TMPBODY" \
SUPA_HOST="${SUPABASE_URL#*://}"; SUPA_HOST="${SUPA_HOST%%/*}"
HTTP_CODE="$(_receipted_curl open security-dashboard "$SUPA_HOST" community-pulse-fetch "user-invoked dashboard" --no-payload \
curl -s --max-time 15 -w '%{http_code}' -o "$TMPBODY" \
"${SUPABASE_URL}/functions/v1/community-pulse" \
-H "apikey: ${ANON_KEY}" \
2>/dev/null || true)"
-H "apikey: ${ANON_KEY}" || true)"
# curl prints its own 000 before a non-zero exit — a `|| echo` here would
# double it to "000000" in user-facing output. Normalize to the last 3 chars.
HTTP_CODE="$(printf '%s' "$HTTP_CODE" | tr -d '[:space:]' | tail -c 3)"

View File

@ -11,6 +11,10 @@ set +e
GSTACK_DIR="${GSTACK_DIR:-$HOME/.claude/skills/gstack}"
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
# Egress receipt helpers (_receipted_git): fail-open — an update pull must
# never block a session over a receipt hiccup.
. "$(cd "$(dirname "$0")" && pwd)/gstack-egress-lib.sh"
THROTTLE_FILE="$STATE_DIR/.last-session-update"
LOCK_DIR="$STATE_DIR/.setup-lock"
LOG_FILE="$STATE_DIR/analytics/session-update.log"
@ -76,7 +80,10 @@ fi
# ── Pull latest ──
OLD_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
git -C "$GSTACK_DIR" pull --ff-only -q 2>/dev/null
UPDATE_URL=$(git -C "$GSTACK_DIR" remote get-url origin 2>/dev/null || echo "")
UPDATE_HOST="${UPDATE_URL#*://}"; UPDATE_HOST="${UPDATE_HOST#*@}"; UPDATE_HOST="${UPDATE_HOST%%[/:]*}"
GSTACK_HOME="$STATE_DIR" _receipted_git open session-update "${UPDATE_HOST:-unknown}" gstack-self-update-pull "auto_upgrade=true" \
bash -c 'git -C "$1" pull --ff-only -q 2>/dev/null' _ "$GSTACK_DIR"
PULL_EXIT=$?
NEW_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)

View File

@ -345,7 +345,10 @@ describe('gstack-brain-sync egress receipt gate', () => {
const commitsBefore = git(['rev-list', '--count', 'HEAD']).stdout.trim();
// Make the receipt unwritable: security dir exists but is read-only.
fs.mkdirSync(path.join(tmpHome, 'security'), { recursive: true, mode: 0o500 });
// (artifacts-init may have created it already — mkdirSync's mode is a
// no-op on an existing dir, so chmod explicitly.)
fs.mkdirSync(path.join(tmpHome, 'security'), { recursive: true });
fs.chmodSync(path.join(tmpHome, 'security'), 0o500);
try {
const refused = run(['gstack-brain-sync', '--once']);
expect(refused.status).toBe(1);

View File

@ -51,13 +51,20 @@ function makeFakeCurl(opts: {
printf 'CURL_CALL '"'"'%s'"'"' ' "$@" >> "${curlCallLog}"
echo "" >> "${curlCallLog}"
# Walk argv to find -o <out> and -d <data>.
# Walk argv to find -o <out> and the request body (-d <data> or the
# receipted --data-binary @<payload-file> shape).
out=""
data=""
while [ $# -gt 0 ]; do
case "$1" in
-o) out="$2"; shift 2 ;;
-d) data="$2"; shift 2 ;;
--data-binary)
case "$2" in
@*) data="$(cat "\${2#@}" 2>/dev/null)" ;;
*) data="$2" ;;
esac
shift 2 ;;
*) shift ;;
esac
done
@ -83,6 +90,8 @@ function runVerify(token: string, url: string): { code: number; stdout: string;
...process.env,
PATH: `${fakeBinDir}:${process.env.PATH}`,
GBRAIN_MCP_TOKEN: token,
// The probe writes egress receipts — keep them in the temp home.
GSTACK_HOME: tmpDir,
},
encoding: 'utf-8',
});