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 // ---------------------------------------------------------------