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 <noreply@anthropic.com>
(cherry picked from commit 3c60f699acceaf1c92a218874711e05fc17dca5d)
This commit is contained in:
Garry Tan 2026-08-12 11:14:28 -07:00
parent 1f5282f402
commit 244f1b47d3
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
4 changed files with 163 additions and 16 deletions

View File

@ -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"

View File

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

View File

@ -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 <url>
# 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:]')"

View File

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