From 244f1b47d357ab58b05dec22d1b25e05ed70fc75 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 12 Aug 2026 11:14:28 -0700 Subject: [PATCH] feat(security): receipt core shell sinks Wire the three core bash egress sinks through gstack-egress-lib.sh: - gstack-telemetry-sync: the batch POST now writes the payload to a temp file, receipts those exact bytes fail-closed, and hands curl the SAME file. On refusal nothing is sent and the cursor does not advance, so the batch stays buffered for the next run. The HTTP status is recorded as the receipt outcome. - gstack-update-check: fail-open receipts (warn + proceed) on the Supabase ping POST, both VERSION curls (via a local _receipted_version_fetch helper that skips non-network schemes), and git ls-remote. The ping receipt is written inside the backgrounded subshell, so it can never block the script's exit. - gstack-brain-sync: fail-closed git-class receipts. The push receipt is written BEFORE the commit consumes the queue, so a refused receipt leaves the queue intact and the next run retries the whole drain (pinned by a new queue-intact-on-refusal test, including the problem/cause/fix refusal message shape). The retry-path fetch and retry push carry their own fail-closed receipts. Co-Authored-By: Claude Fable 5 (cherry picked from commit 3c60f699acceaf1c92a218874711e05fc17dca5d) --- bin/gstack-brain-sync | 36 ++++++++++++++++++++-- bin/gstack-telemetry-sync | 28 +++++++++++++++-- bin/gstack-update-check | 52 +++++++++++++++++++++++++------- test/brain-sync.test.ts | 63 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 16 deletions(-) diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index 2ad0fe773..2fa696869 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -32,6 +32,17 @@ DISCOVER_CURSOR="$GSTACK_HOME/.brain-discover-cursor" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIG_BIN="$SCRIPT_DIR/gstack-config" +# Egress receipt helpers (_receipted_git): receipt-before-send, fail-closed. +. "$SCRIPT_DIR/gstack-egress-lib.sh" + +# origin host for receipt records (github.com etc). +remote_host() { + local url host + url=$(git -C "$GSTACK_HOME" remote get-url origin 2>/dev/null || echo "") + host="${url#*://}"; host="${host#*@}"; host="${host%%[/:]*}" + echo "${host:-unknown}" +} + # Remote-specific hint for auth errors (branch on origin URL). remote_auth_hint() { local url @@ -276,6 +287,21 @@ subcmd_once() { exit 0 fi + # Egress receipt for the push, written BEFORE the commit consumes the + # queue (amendment C7 ordering): a refused receipt exits HERE, before any + # queue mutation or local commit, so the queue stays intact and the next + # run retries the whole drain. Content-free: git owns the bytes + # (sha256:null). Fail-closed. + local push_host receipt_err + push_host=$(remote_host) + if ! receipt_err=$(GSTACK_HOME="$GSTACK_HOME" "$SCRIPT_DIR/gstack-egress-receipt" write \ + --sink brain-sync --host "$push_host" --class curated-memory-git-push \ + --no-payload --consent "artifacts_sync_mode!=off" 2>&1 >/dev/null); then + write_status "push_failed" "EGRESS_RECEIPT_FAILED: receipt not writable; push refused (queue preserved)" + _gstack_egress_refusal "brain-sync push" "$(printf '%s' "$receipt_err" | head -c 300)" + exit 1 + fi + # Commit with template message. local n ts n=$(wc -l < "$paths_file" | tr -d ' ') @@ -303,12 +329,16 @@ subcmd_once() { exit 0 fi - # Try a fetch-and-merge + retry. - if git -C "$GSTACK_HOME" fetch origin 2>/dev/null; then + # Try a fetch-and-merge + retry. The fetch and the retry push are their + # own attempted-egress ops, each receipted fail-closed (a refusal falls + # through to the push_failed path below). + if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-fetch "artifacts_sync_mode!=off" \ + bash -c 'git -C "$1" fetch origin 2>/dev/null' _ "$GSTACK_HOME"; then local branch branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main) if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1; then - if git -C "$GSTACK_HOME" push origin HEAD 2>/dev/null; then + if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-push "artifacts_sync_mode!=off" \ + bash -c 'git -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then : > "$QUEUE" date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE" write_status "ok" "pushed $n file(s) after rebase" diff --git a/bin/gstack-telemetry-sync b/bin/gstack-telemetry-sync index 20f322043..172fed623 100755 --- a/bin/gstack-telemetry-sync +++ b/bin/gstack-telemetry-sync @@ -13,6 +13,9 @@ set -uo pipefail GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" + +# Egress receipt helpers (_receipted_curl): receipt-before-send, fail-closed. +. "$GSTACK_DIR/bin/gstack-egress-lib.sh" ANALYTICS_DIR="$STATE_DIR/analytics" JSONL_FILE="$ANALYTICS_DIR/skill-usage.jsonl" CURSOR_FILE="$ANALYTICS_DIR/.last-sync-line" @@ -114,12 +117,31 @@ RESP_FILE="$(mktemp "${TMPDIR:-/tmp}/gstack-sync-XXXXXX")" || { exit 0 } trap 'rm -f "$RESP_FILE"' EXIT -HTTP_CODE="$(curl -s -w '%{http_code}' --max-time 10 \ + +# Egress receipt BEFORE the send (fail-closed): the batch is written to a +# temp file, the receipt hashes those exact bytes, and curl sends the SAME +# file. On refusal nothing is sent and the cursor does not advance, so the +# batch stays buffered and the next run retries. The helper consumes the +# payload file. +PAYLOAD_FILE="$(mktemp "${TMPDIR:-/tmp}/gstack-sync-payload-XXXXXX")" || { + echo "gstack-telemetry-sync: mktemp failed — skipping this run" >&2 + exit 0 +} +printf '%s' "$BATCH" > "$PAYLOAD_FILE" +DEST_HOST="${SUPABASE_URL#*://}" +DEST_HOST="${DEST_HOST%%/*}" +HTTP_CODE="$(GSTACK_HOME="$STATE_DIR" _receipted_curl closed telemetry-sync "$DEST_HOST" telemetry-events "telemetry=$TIER" "$PAYLOAD_FILE" \ + curl -s -w '%{http_code}' --max-time 10 \ -X POST "${SUPABASE_URL}/functions/v1/telemetry-ingest" \ -H "Content-Type: application/json" \ -H "apikey: ${ANON_KEY}" \ - -o "$RESP_FILE" \ - -d "$BATCH" 2>/dev/null || echo "000")" + -o "$RESP_FILE" || echo "000")" + +# Best-effort response-status record for the receipt above (overrides the +# helper's generic exit-code outcome with the HTTP status). +if [ -n "${_GSTACK_EGRESS_LAST_RECEIPT:-}" ]; then + GSTACK_HOME="$STATE_DIR" "$GSTACK_DIR/bin/gstack-egress-receipt" outcome "$_GSTACK_EGRESS_LAST_RECEIPT" "$HTTP_CODE" 2>/dev/null || true +fi # ─── Update cursor on success (2xx) ───────────────────────── case "$HTTP_CODE" in diff --git a/bin/gstack-update-check b/bin/gstack-update-check index d0486cb4c..2d6d8af44 100755 --- a/bin/gstack-update-check +++ b/bin/gstack-update-check @@ -15,6 +15,29 @@ set -euo pipefail GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" + +# Egress receipt helpers (_receipted_curl / _receipted_git). Update checks +# are fail-OPEN: a receipt hiccup warns but never blocks the version check. +. "$GSTACK_DIR/bin/gstack-egress-lib.sh" + +# _receipted_version_fetch +# Receipted bodyless GET for a VERSION file (fail-open). Local reads +# (file://, or a URL with no network host) never leave the machine — they +# are not egress, so they get no receipt. +_receipted_version_fetch() { + local url="$1" host scheme + scheme="${url%%://*}" + host="${url#*://}"; host="${host%%/*}" + case "$scheme" in + http|https|git|ssh|ftp|ftps) + GSTACK_HOME="$STATE_DIR" _receipted_curl open update-check "$host" version-fetch "update_check!=false" --no-payload \ + curl -sf --max-time 5 "$url" || true + ;; + *) + curl -sf --max-time 5 "$url" 2>/dev/null || true + ;; + esac +} CACHE_FILE="$STATE_DIR/last-update-check" MARKER_FILE="$STATE_DIR/just-upgraded-from" SNOOZE_FILE="$STATE_DIR/update-snoozed" @@ -172,12 +195,19 @@ _SUPA_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" _TEL_TIER="$("$GSTACK_DIR/bin/gstack-config" get telemetry 2>/dev/null || true)" if [ -n "$_SUPA_URL" ] && [ -n "$_SUPA_KEY" ] && [ "${_TEL_TIER:-off}" != "off" ]; then _OS="$(uname -s | tr '[:upper:]' '[:lower:]')" - curl -sf --max-time 5 \ - -X POST "${_SUPA_URL}/functions/v1/update-check" \ - -H "Content-Type: application/json" \ - -H "apikey: ${_SUPA_KEY}" \ - -d "{\"version\":\"$LOCAL\",\"os\":\"$_OS\"}" \ - >/dev/null 2>&1 & + # Receipted (fail-open) and fully backgrounded: the receipt write happens + # inside the background subshell, so it can never block this script's exit. + _PING_FILE="$(mktemp "${TMPDIR:-/tmp}/gstack-update-ping-XXXXXX" 2>/dev/null)" || _PING_FILE="" + if [ -n "$_PING_FILE" ]; then + printf '{"version":"%s","os":"%s"}' "$LOCAL" "$_OS" > "$_PING_FILE" + _SUPA_HOST="${_SUPA_URL#*://}"; _SUPA_HOST="${_SUPA_HOST%%/*}" + GSTACK_HOME="$STATE_DIR" _receipted_curl open update-check "$_SUPA_HOST" update-check-ping "telemetry=$_TEL_TIER" "$_PING_FILE" \ + curl -sf --max-time 5 \ + -X POST "${_SUPA_URL}/functions/v1/update-check" \ + -H "Content-Type: application/json" \ + -H "apikey: ${_SUPA_KEY}" \ + >/dev/null 2>&1 & + fi fi # Resolve VERSION via a SHA-pinned raw URL. GitHub's branch-raw CDN @@ -193,12 +223,14 @@ REMOTE="" if [ -z "${GSTACK_REMOTE_URL:-}" ]; then # Disable credential prompts and apply a 5-second low-speed timeout so a # flaky network or captive portal can't hang every skill preamble. - _LSR_LINE="$(GIT_TERMINAL_PROMPT=0 GIT_HTTP_LOW_SPEED_LIMIT=1000 GIT_HTTP_LOW_SPEED_TIME=5 \ - git ls-remote "$REMOTE_REPO" refs/heads/main 2>/dev/null || true)" + _REPO_HOST="${REMOTE_REPO#*://}"; _REPO_HOST="${_REPO_HOST#*@}"; _REPO_HOST="${_REPO_HOST%%[/:]*}" + _LSR_LINE="$(GSTACK_HOME="$STATE_DIR" _receipted_git open update-check "${_REPO_HOST:-unknown}" version-ls-remote "update_check!=false" \ + bash -c 'GIT_TERMINAL_PROMPT=0 GIT_HTTP_LOW_SPEED_LIMIT=1000 GIT_HTTP_LOW_SPEED_TIME=5 \ + git ls-remote "$1" refs/heads/main 2>/dev/null' _ "$REMOTE_REPO" || true)" _REMOTE_SHA="$(echo "$_LSR_LINE" | awk '{print $1}')" if echo "$_REMOTE_SHA" | grep -qE '^[0-9a-f]{40}$'; then _SHA_URL="https://raw.githubusercontent.com/garrytan/gstack/${_REMOTE_SHA}/VERSION" - REMOTE="$(curl -sf --max-time 5 "$_SHA_URL" 2>/dev/null || true)" + REMOTE="$(_receipted_version_fetch "$_SHA_URL")" fi fi @@ -206,7 +238,7 @@ fi # network, mirror without refs/heads/main) or when GSTACK_REMOTE_URL was # explicitly overridden. if [ -z "$REMOTE" ]; then - REMOTE="$(curl -sf --max-time 5 "$REMOTE_URL" 2>/dev/null || true)" + REMOTE="$(_receipted_version_fetch "$REMOTE_URL")" fi REMOTE="$(echo "$REMOTE" | tr -d '[:space:]')" diff --git a/test/brain-sync.test.ts b/test/brain-sync.test.ts index 2e7c121d2..a2e73e666 100644 --- a/test/brain-sync.test.ts +++ b/test/brain-sync.test.ts @@ -330,6 +330,69 @@ describe('gstack-brain-sync secret scan', () => { }); }); +// --------------------------------------------------------------- +// Egress receipt gate: receipt-before-commit, queue intact on refusal +// --------------------------------------------------------------- +describe('gstack-brain-sync egress receipt gate', () => { + test('refused receipt leaves the queue intact, makes no commit, and next run retries', () => { + if (process.platform === 'win32' || process.getuid?.() === 0) return; // chmod is advisory there + run(['gstack-artifacts-init', '--remote', bareRemote]); + run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']); + fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), + '{"skill":"x","insight":"y","ts":"2026-04-22T10:00:00Z"}\n'); + run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']); + 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 }); + try { + const refused = run(['gstack-brain-sync', '--once']); + expect(refused.status).toBe(1); + // DX contract: problem + cause + fix, plain language. + expect(refused.stderr).toContain('NOT sent'); + expect(refused.stderr).toContain('EGRESS_RECEIPT_FAILED'); + expect(refused.stderr).toContain('Fix: chmod -R u+w'); + expect(refused.stderr).toContain('ATTEMPTS to send off-machine'); + // Queue intact (receipt is written BEFORE the commit consumes it). + const queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8'); + expect(queue).toContain('projects/p/learnings.jsonl'); + // No local commit was created. + expect(git(['rev-list', '--count', 'HEAD']).stdout.trim()).toBe(commitsBefore); + // Nothing reached the remote. + const remoteLog = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' }); + expect(remoteLog.stdout).not.toMatch(/sync: 1 file/); + const status = JSON.parse(fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8')); + expect(status.status).toBe('push_failed'); + expect(status.message).toContain('EGRESS_RECEIPT_FAILED'); + } finally { + fs.chmodSync(path.join(tmpHome, 'security'), 0o700); + } + + // Next run (ledger writable again) drains the intact queue and pushes. + const retry = run(['gstack-brain-sync', '--once']); + expect(retry.status).toBe(0); + const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' }); + expect(log.stdout).toMatch(/sync: 1 file/); + }); + + test('successful push writes a git-class receipt before the send', () => { + run(['gstack-artifacts-init', '--remote', bareRemote]); + run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']); + fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), + '{"skill":"x","insight":"y","ts":"2026-04-22T10:00:00Z"}\n'); + run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']); + const r = run(['gstack-brain-sync', '--once']); + expect(r.status).toBe(0); + const ledger = fs.readFileSync(path.join(tmpHome, 'security', 'egress.jsonl'), 'utf-8'); + const records = ledger.trim().split('\n').map((l) => JSON.parse(l)); + const pushReceipt = records.find((rec) => rec.sink === 'brain-sync' && rec.payload_class === 'curated-memory-git-push'); + expect(pushReceipt).toBeTruthy(); + expect(pushReceipt.sha256).toBeNull(); // git owns the bytes + }); +}); + // --------------------------------------------------------------- // Uninstall preserves user data // ---------------------------------------------------------------