diff --git a/bin/gstack-egress-lib.sh b/bin/gstack-egress-lib.sh new file mode 100644 index 000000000..33e4edea8 --- /dev/null +++ b/bin/gstack-egress-lib.sh @@ -0,0 +1,113 @@ +# gstack-egress-lib.sh — shared egress-receipt helpers for bash sinks. +# +# This file is NOT executable; source it: +# +# . "$(dirname "$0")/gstack-egress-lib.sh" +# +# THREAT MODEL: the egress ledger is forensic observability — it records +# ATTEMPTED egress so accidents are auditable; it is not an exfiltration +# control. Receipts are written before send, outcomes are best-effort, and +# fail-open callers can send unrecorded with a warning. +# +# Provides: +# _receipted_curl \ +# (|--no-payload) [args...] +# — Writes an egress receipt BEFORE running the wrapped curl command. +# When a payload file is given, the receipt hashes that file and curl +# receives THE SAME FILE via an appended `--data-binary @file`, so the +# recorded sha256 matches the exact wire bytes (scan-at-sink precedent). +# The payload file is CONSUMED: the helper deletes it after the send +# (and on refusal) — callers need no cleanup trap for it. +# stdout is the wrapped command's stdout; returns its exit code. +# A best-effort outcome record (`exit:N`) is appended after the send; +# the receipt id is exported as _GSTACK_EGRESS_LAST_RECEIPT so callers +# can append a more specific outcome (e.g. the HTTP status). +# +# Fail policy (first argument, per call): +# closed — receipt failure REFUSES the send: nothing hits the network, +# return 3, problem/cause/fix message on stderr. +# open — receipt failure warns on stderr and proceeds unrecorded. +# +# _receipted_git [args...] +# — Same contract for git-class ops: sha256:null receipt (a subprocess +# owns the bytes), no payload file, command runs unmodified. +# +# NO EXIT traps in this file, ever: callers (gstack-telemetry-sync) own +# 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)" + +_gstack_egress_home() { + if [ -n "${GSTACK_HOME:-}" ]; then + printf '%s' "$GSTACK_HOME" + elif [ -n "${GSTACK_STATE_DIR:-}" ]; then + printf '%s' "$GSTACK_STATE_DIR" + else + printf '%s' "$HOME/.gstack" + fi +} + +# Problem + cause + fix, in plain language (DX contract for every +# fail-closed refusal). $1 = sink, $2 = cause text from the receipt bridge. +_gstack_egress_refusal() { + local home + home="$(_gstack_egress_home)" + echo "gstack: $1 NOT sent — the egress receipt could not be written (${2:-unknown cause}). Fix: chmod -R u+w $home/security (or check GSTACK_HOME). What this is: gstack records everything it ATTEMPTS to send off-machine; see gstack-egress." >&2 +} + +# Shared core. $6 is a payload file path or --no-payload; the rest is the +# command to run. Payload files are deleted here (immediate, no traps). +_gstack_egress_run() { + local policy="$1" sink="$2" host="$3" class="$4" consent="$5" payload="$6" + shift 6 + local bin="$_gstack_egress_lib_dir/gstack-egress-receipt" + + local payload_flag=(--no-payload) + [ "$payload" != "--no-payload" ] && payload_flag=(--payload-file "$payload") + + local receipt_id="" receipt_err="" err_file="" + err_file="$(mktemp "${TMPDIR:-/tmp}/gstack-egress-err-XXXXXX" 2>/dev/null)" || err_file="" + if [ -n "$err_file" ]; then + receipt_id="$("$bin" write --sink "$sink" --host "$host" --class "$class" \ + "${payload_flag[@]}" --consent "$consent" 2>"$err_file")" || receipt_id="" + receipt_err="$(head -c 500 "$err_file" 2>/dev/null | tr '\n' ' ')" + rm -f "$err_file" + else + receipt_id="$("$bin" write --sink "$sink" --host "$host" --class "$class" \ + "${payload_flag[@]}" --consent "$consent" 2>/dev/null)" || receipt_id="" + fi + _GSTACK_EGRESS_LAST_RECEIPT="$receipt_id" + + if [ -z "$receipt_id" ]; then + if [ "$policy" = "closed" ]; then + _gstack_egress_refusal "$sink" "$receipt_err" + [ "$payload" != "--no-payload" ] && rm -f "$payload" + return 3 + fi + echo "gstack: egress receipt could not be written for $sink (${receipt_err:-unknown cause}) — sending anyway (fail-open). gstack normally records everything it ATTEMPTS to send off-machine; see gstack-egress." >&2 + fi + + local status=0 + if [ "$payload" = "--no-payload" ]; then + "$@" || status=$? + else + "$@" --data-binary @"$payload" || status=$? + rm -f "$payload" + fi + + if [ -n "$receipt_id" ]; then + "$bin" outcome "$receipt_id" "exit:$status" 2>/dev/null || true + fi + return $status +} + +_receipted_curl() { + _gstack_egress_run "$@" +} + +_receipted_git() { + local policy="$1" sink="$2" host="$3" class="$4" consent="$5" + shift 5 + _gstack_egress_run "$policy" "$sink" "$host" "$class" "$consent" --no-payload "$@" +} diff --git a/test/egress-lib.test.ts b/test/egress-lib.test.ts new file mode 100644 index 000000000..2cfb51c45 --- /dev/null +++ b/test/egress-lib.test.ts @@ -0,0 +1,185 @@ +/** + * gstack-egress-lib.sh — shared shell receipt helpers, tested end-to-end + * against a local listener. Free tier, loopback only. + * + * Pins the shell-sink contract: + * - receipt sha256 == sha256 of the exact bytes the listener received + * (same file is hashed and handed to curl via --data-binary @file) + * - fail-closed refusal never touches the network and its stderr carries + * problem + cause + fix in plain language + * - fail-open warns on stderr and proceeds unrecorded + * - payload temp files are consumed immediately (no EXIT traps) + */ + +import { describe, test, expect, beforeEach, afterEach, afterAll } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { listReceipts, sha256Hex } from '../lib/egress-receipt'; + +const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..'); +const LIB = path.join(ROOT, 'bin', 'gstack-egress-lib.sh'); + +const received: string[] = []; +const server = Bun.serve({ + port: 0, + fetch: async (req) => { + received.push(await req.text()); + return new Response('ok'); + }, +}); +const URL_BASE = `http://127.0.0.1:${server.port}`; + +afterAll(() => { + server.stop(true); +}); + +let home: string; + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-egress-lib-')); + received.length = 0; +}); + +afterEach(() => { + try { fs.chmodSync(path.join(home, 'security'), 0o700); } catch {} + fs.rmSync(home, { recursive: true, force: true }); +}); + +// Async spawn: spawnSync would block Bun's event loop, deadlocking the +// in-process listener the script curls against. +async function runBash(script: string) { + const proc = Bun.spawn(['bash', '-c', script], { + env: { ...process.env, GSTACK_HOME: home }, + stdout: 'pipe', + stderr: 'pipe', + }); + const [stdout, stderr, status] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { status, stdout, stderr }; +} + +describe('_receipted_curl', () => { + test('receipt sha256 equals sha256 of the body the listener received; payload file consumed', async () => { + const body = '[{"event":"lib-test","v":1}]'; + const result = await runBash(` + set -uo pipefail + . "${LIB}" + payload="$(mktemp)" + printf '%s' '${body}' > "$payload" + echo "PAYLOAD_PATH=$payload" + _receipted_curl closed lib-test 127.0.0.1:${server.port} test-events "telemetry=community" "$payload" \\ + curl -s --max-time 5 -X POST "${URL_BASE}/ingest" + echo "CURL_EXIT=$?" + [ -e "$payload" ] && echo "PAYLOAD_STILL_EXISTS" || echo "PAYLOAD_GONE" + `); + expect(result.status).toBe(0); + expect(result.stdout).toContain('CURL_EXIT=0'); + expect(result.stdout).toContain('PAYLOAD_GONE'); + expect(received.length).toBe(1); + expect(received[0]).toBe(body); + const receipts = listReceipts(home); + expect(receipts.length).toBe(1); + expect(receipts[0].sha256).toBe(sha256Hex(received[0])); + expect(receipts[0].bytes).toBe(Buffer.byteLength(body)); + expect(receipts[0].sink).toBe('lib-test'); + expect(receipts[0].status).toBe('exit:0'); // best-effort outcome joined + }); + + test('fail-closed refusal never hits the network; stderr is problem + cause + fix', async () => { + if (process.platform === 'win32' || process.getuid?.() === 0) return; + fs.mkdirSync(path.join(home, 'security'), { recursive: true, mode: 0o500 }); + const result = await runBash(` + set -uo pipefail + . "${LIB}" + payload="$(mktemp)" + printf '%s' 'secret payload' > "$payload" + _receipted_curl closed lib-test 127.0.0.1:${server.port} test-events "telemetry=community" "$payload" \\ + curl -s --max-time 5 -X POST "${URL_BASE}/ingest" + status=$? + [ -e "$payload" ] && echo "PAYLOAD_STILL_EXISTS" || echo "PAYLOAD_GONE" + exit $status + `); + expect(result.status).toBe(3); + expect(received.length).toBe(0); // the send was refused, not attempted + expect(result.stdout).toContain('PAYLOAD_GONE'); // cleaned up on refusal too + // Problem: what did not happen. + expect(result.stderr).toContain('lib-test NOT sent'); + // Cause: the bridge's typed error is quoted. + expect(result.stderr).toContain('EGRESS_RECEIPT_FAILED'); + // Fix: an actionable command. + expect(result.stderr).toContain('Fix: chmod -R u+w'); + // What this is: the ledger explained. + expect(result.stderr).toContain('ATTEMPTS to send off-machine'); + expect(result.stderr).toContain('gstack-egress'); + }); + + test('fail-open warns and proceeds when the receipt cannot be written', async () => { + if (process.platform === 'win32' || process.getuid?.() === 0) return; + fs.mkdirSync(path.join(home, 'security'), { recursive: true, mode: 0o500 }); + const result = await runBash(` + set -uo pipefail + . "${LIB}" + _receipted_curl open lib-open 127.0.0.1:${server.port} version-fetch "user ran --force" --no-payload \\ + curl -s --max-time 5 "${URL_BASE}/VERSION" + `); + expect(result.status).toBe(0); + expect(received.length).toBe(1); // the send proceeded unrecorded + expect(result.stderr).toContain('sending anyway'); + expect(result.stderr).toContain('lib-open'); + }); + + test('--no-payload records sha256:null and does not append --data-binary', async () => { + const result = await runBash(` + set -uo pipefail + . "${LIB}" + _receipted_curl closed lib-get 127.0.0.1:${server.port} version-fetch "user ran --force" --no-payload \\ + curl -s --max-time 5 "${URL_BASE}/VERSION" + `); + expect(result.status).toBe(0); + expect(received.length).toBe(1); + expect(received[0]).toBe(''); // GET, no body + const receipts = listReceipts(home); + expect(receipts.length).toBe(1); + expect(receipts[0].sha256).toBeNull(); + expect(receipts[0].bytes).toBe(0); + }); +}); + +describe('_receipted_git', () => { + test('writes a git-class sha256:null receipt then runs the command unmodified', async () => { + const result = await runBash(` + set -uo pipefail + . "${LIB}" + _receipted_git closed brain-sync github.com curated-memory-git-push "artifacts_sync_mode!=off" \\ + bash -c 'echo GIT_RAN "$@"' _ + `); + expect(result.status).toBe(0); + expect(result.stdout).toContain('GIT_RAN'); + expect(result.stdout).not.toContain('--data-binary'); + const receipts = listReceipts(home); + expect(receipts.length).toBe(1); + expect(receipts[0].sha256).toBeNull(); + expect(receipts[0].payload_class).toBe('curated-memory-git-push'); + expect(receipts[0].status).toBe('exit:0'); + }); + + test('fail-closed git refusal returns 3 without running the command', async () => { + if (process.platform === 'win32' || process.getuid?.() === 0) return; + fs.mkdirSync(path.join(home, 'security'), { recursive: true, mode: 0o500 }); + const marker = path.join(os.tmpdir(), `gstack-egress-git-${process.pid}`); + fs.rmSync(marker, { force: true }); + const result = await runBash(` + set -uo pipefail + . "${LIB}" + _receipted_git closed brain-sync github.com curated-memory-git-push "artifacts_sync_mode!=off" \\ + touch "${marker}" + `); + expect(result.status).toBe(3); + expect(fs.existsSync(marker)).toBe(false); // command never ran + expect(result.stderr).toContain('brain-sync NOT sent'); + }); +});