diff --git a/bin/gstack-artifacts-init b/bin/gstack-artifacts-init index b8bfe830c..8c8d32847 100755 --- a/bin/gstack-artifacts-init +++ b/bin/gstack-artifacts-init @@ -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 </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 diff --git a/bin/gstack-brain-restore b/bin/gstack-brain-restore index 21f7c1134..bab38f55f 100755 --- a/bin/gstack-brain-restore +++ b/bin/gstack-brain-restore @@ -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 diff --git a/bin/gstack-community-dashboard b/bin/gstack-community-dashboard index adb94cb33..2b40b25b7 100755 --- a/bin/gstack-community-dashboard +++ b/bin/gstack-community-dashboard @@ -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)" diff --git a/bin/gstack-egress-lib.sh b/bin/gstack-egress-lib.sh index 33e4edea8..37fe045a7 100644 --- a/bin/gstack-egress-lib.sh +++ b/bin/gstack-egress-lib.sh @@ -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 diff --git a/bin/gstack-gbrain-mcp-verify b/bin/gstack-gbrain-mcp-verify index 72129a866..b3459ca8f 100755 --- a/bin/gstack-gbrain-mcp-verify +++ b/bin/gstack-gbrain-mcp-verify @@ -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 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 diff --git a/bin/gstack-gbrain-supabase-provision b/bin/gstack-gbrain-supabase-provision index 2bec5384c..8498b6a3c 100755 --- a/bin/gstack-gbrain-supabase-provision +++ b/bin/gstack-gbrain-supabase-provision @@ -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" diff --git a/bin/gstack-security-dashboard b/bin/gstack-security-dashboard index 9cf524e37..864fd8f94 100755 --- a/bin/gstack-security-dashboard +++ b/bin/gstack-security-dashboard @@ -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)" diff --git a/bin/gstack-session-update b/bin/gstack-session-update index 66bd44028..7e21e5df0 100755 --- a/bin/gstack-session-update +++ b/bin/gstack-session-update @@ -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) diff --git a/test/brain-sync.test.ts b/test/brain-sync.test.ts index a2e73e666..140b008e5 100644 --- a/test/brain-sync.test.ts +++ b/test/brain-sync.test.ts @@ -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); diff --git a/test/gstack-gbrain-mcp-verify.test.ts b/test/gstack-gbrain-mcp-verify.test.ts index 3705230e1..4461d4dd5 100644 --- a/test/gstack-gbrain-mcp-verify.test.ts +++ b/test/gstack-gbrain-mcp-verify.test.ts @@ -51,13 +51,20 @@ function makeFakeCurl(opts: { printf 'CURL_CALL '"'"'%s'"'"' ' "$@" >> "${curlCallLog}" echo "" >> "${curlCallLog}" -# Walk argv to find -o and -d . +# Walk argv to find -o and the request body (-d or the +# receipted --data-binary @ 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', });