From ba077165be22a160725588166a027ea7429798bd Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 12 Aug 2026 11:43:37 -0700 Subject: [PATCH] feat(cli): gstack-egress reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bin/gstack-egress (bun) — the auditor's view of the receipts ledger: - list: one row per receipt (what gstack ATTEMPTED to send), with --since/--host/--sink filters and --json. - verify: recompute the hash chain; exit 3 on tamper naming the first broken line; prints the sizeWarning when the ledger passes 25MB. - grants: what CAN leave, built on the upstream config keys only (telemetry, artifacts_sync_mode, redact_repo_visibility, redact_prepush_hook via gstack-config get) — each grant names its file, key, and the exact revoke command. CLI smoke tests spawn the real bin against a temp GSTACK_HOME, including a broken-chain fixture asserting exit 3. Co-Authored-By: Claude Fable 5 (cherry picked from commit 9e24eca0f1069fea2ea69e7df4e9b256e93d59a3) --- bin/gstack-egress | 186 +++++++++++++++++++++++++++++++++ test/gstack-egress-cli.test.ts | 151 ++++++++++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100755 bin/gstack-egress create mode 100644 test/gstack-egress-cli.test.ts diff --git a/bin/gstack-egress b/bin/gstack-egress new file mode 100755 index 000000000..a1443b8b3 --- /dev/null +++ b/bin/gstack-egress @@ -0,0 +1,186 @@ +#!/usr/bin/env bun +/** + * gstack-egress — the auditor's view of the receipts ledger. + * + * list what gstack ATTEMPTED to send off this machine (one row per receipt) + * grants what CAN leave: every consent grant in force, where it lives, + * and the exact command that revokes it (pure config reads) + * verify recompute the hash chain; exit 3 on tamper + * + * THREAT MODEL: the ledger is forensic observability — it records ATTEMPTED + * egress so accidents are auditable; it is not an exfiltration control. + * + * The ledger is written by lib/egress-receipt.ts at every enumerated sink + * (see test/egress-receipt-wiring.test.ts for the pinned list). + * + * Home: GSTACK_HOME, legacy GSTACK_STATE_DIR, else ~/.gstack. + */ + +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { + egressLedgerPath, + listReceipts, + resolveEgressHome, + verifyLedger, +} from '../lib/egress-receipt'; + +const BIN_DIR = path.dirname(new URL(import.meta.url).pathname); + +function usage(message: string): never { + process.stderr.write(`gstack-egress: ${message}\n`); + process.stderr.write( + 'Usage: gstack-egress list [--since ] [--host ] [--sink ] [--json]\n' + + ' gstack-egress verify [--json]\n' + + ' gstack-egress grants [--json]\n', + ); + process.exit(2); +} + +function configGet(key: string): string { + const result = spawnSync(path.join(BIN_DIR, 'gstack-config'), ['get', key], { + encoding: 'utf-8', + }); + return (result.stdout || '').trim(); +} + +function egressList(args: string[], home: string): number { + const values = new Map(); + let json = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (['--since', '--host', '--sink'].includes(arg)) { + const value = args[++index]; + if (value == null || value.startsWith('--')) usage(`${arg} requires a value`); + values.set(arg, value); + } else if (arg === '--json') { + json = true; + } else { + usage(`unknown option: ${arg}`); + } + } + const since = values.get('--since'); + if (since != null && !Number.isFinite(Date.parse(since))) usage('--since must be an ISO timestamp'); + + let receipts = listReceipts(home); + if (since) receipts = receipts.filter((r) => Date.parse(r.ts) >= Date.parse(since)); + if (values.has('--host')) receipts = receipts.filter((r) => r.host === values.get('--host')); + if (values.has('--sink')) receipts = receipts.filter((r) => r.sink === values.get('--sink')); + + if (json) { + process.stdout.write(`${JSON.stringify(receipts, null, 2)}\n`); + return 0; + } + if (!receipts.length) { + process.stdout.write(`no receipts\nledger: ${egressLedgerPath(home)}\n`); + return 0; + } + for (const r of receipts) { + process.stdout.write( + `${r.ts} ${r.sink} -> ${r.host} ${r.payload_class} ${r.bytes}B ` + + `sha256=${r.sha256 ?? '(subprocess-owned)'} consent=${r.consent} status=${r.status ?? '-'}\n`, + ); + } + process.stdout.write(`${receipts.length} receipt(s) ledger: ${egressLedgerPath(home)}\n`); + return 0; +} + +function egressVerify(args: string[], home: string): number { + for (const arg of args) if (arg !== '--json') usage(`unknown option: ${arg}`); + const result = verifyLedger(home); + if (args.includes('--json')) { + process.stdout.write(`${JSON.stringify({ ...result, ledger: egressLedgerPath(home) }, null, 2)}\n`); + } else { + if (result.ok) { + process.stdout.write(`chain intact: ${result.count} line(s) verified\n`); + } else { + process.stdout.write(`TAMPER: chain broken at line ${result.brokenLine} (${result.reason})\n`); + } + if (result.sizeWarning) process.stdout.write(`${result.sizeWarning}\n`); + } + return result.ok ? 0 : 3; +} + +interface Grant { + grant: string; + value: string; + granted: boolean; + detail: string; + file: string; + key: string; + revoke: string; +} + +function egressGrants(args: string[], home: string): number { + for (const arg of args) if (arg !== '--json') usage(`unknown option: ${arg}`); + const configFile = path.join(home, 'config.yaml'); + + const telemetry = configGet('telemetry') || 'off'; + const syncMode = configGet('artifacts_sync_mode') || 'off'; + const repoVisibility = configGet('redact_repo_visibility') || 'unknown'; + const prepushHook = configGet('redact_prepush_hook') || 'false'; + + const grants: Grant[] = [ + { + grant: 'telemetry', + value: telemetry, + granted: telemetry !== 'off', + detail: 'anonymous and community tiers upload usage events to Supabase; off stays local-only', + file: configFile, + key: 'telemetry', + revoke: 'gstack-config set telemetry off', + }, + { + grant: 'brain-sync', + value: syncMode, + granted: syncMode !== 'off' && syncMode !== '', + detail: 'git push of curated allowlisted memory to the user-configured artifacts remote', + file: configFile, + key: 'artifacts_sync_mode', + revoke: 'gstack-config set artifacts_sync_mode off', + }, + { + grant: 'redact_repo_visibility', + value: repoVisibility, + granted: repoVisibility === 'public', + detail: 'redaction strictness assumption for external sinks; public gets per-finding confirmation', + file: configFile, + key: 'redact_repo_visibility', + revoke: 'gstack-config set redact_repo_visibility unknown (unknown = public-strict)', + }, + { + grant: 'redact_prepush_hook', + value: prepushHook, + granted: prepushHook === 'true', + detail: 'opt-in git pre-push redaction scan; granted here means the guard is ON', + file: configFile, + key: 'redact_prepush_hook', + revoke: 'gstack-config set redact_prepush_hook false (disables the guard)', + }, + ]; + + if (args.includes('--json')) { + process.stdout.write(`${JSON.stringify(grants, null, 2)}\n`); + return 0; + } + for (const grant of grants) { + process.stdout.write( + `${grant.granted ? '[GRANTED]' : '[off] '} ${grant.grant}: ${grant.value}\n` + + ` ${grant.detail}\n` + + ` lives in: ${grant.file} (${grant.key})\n` + + ` revoke: ${grant.revoke}\n`, + ); + } + process.stdout.write("What was ATTEMPTED: 'gstack-egress list'. Chain check: 'gstack-egress verify'.\n"); + return 0; +} + +const [action, ...rest] = process.argv.slice(2); +const home = resolveEgressHome(); + +let code: number; +if (action === 'list') code = egressList(rest, home); +else if (action === 'verify') code = egressVerify(rest, home); +else if (action === 'grants') code = egressGrants(rest, home); +else usage(`unknown subcommand: ${action ?? '(none)'}`); +process.exit(code); diff --git a/test/gstack-egress-cli.test.ts b/test/gstack-egress-cli.test.ts new file mode 100644 index 000000000..4881de80f --- /dev/null +++ b/test/gstack-egress-cli.test.ts @@ -0,0 +1,151 @@ +/** + * gstack-egress CLI — list | verify | grants smoke tests. Free tier. + * + * Spawns the real bin against a temp GSTACK_HOME: list filters, verify + * exit-3-on-tamper (naming the first broken line), sizeWarning surfacing, + * and grants against the upstream config keys (telemetry, + * artifacts_sync_mode, redact_repo_visibility, redact_prepush_hook). + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; +import { + LEDGER_WARN_BYTES, + egressLedgerPath, + sha256Hex, + writeReceipt, +} from '../lib/egress-receipt'; + +const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..'); +const BIN = path.join(ROOT, 'bin', 'gstack-egress'); + +let home: string; + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-egress-cli-')); +}); + +afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); +}); + +function run(args: string[]) { + const result = spawnSync(BIN, args, { + encoding: 'utf-8', + env: { ...process.env, GSTACK_HOME: home }, + }); + return { code: result.status ?? -1, stdout: result.stdout || '', stderr: result.stderr || '' }; +} + +describe('gstack-egress list', () => { + test('fresh home prints "no receipts" and the ledger path, exit 0', () => { + const r = run(['list']); + expect(r.code).toBe(0); + expect(r.stdout).toContain('no receipts'); + expect(r.stdout).toContain(egressLedgerPath(home)); + }); + + test('--json returns receipts and honors --sink/--host/--since filters', () => { + writeReceipt({ home, sink: 'telemetry-sync', host: '10.0.0.1:8399', payloadClass: 'telemetry-events', bytes: 2, sha256: sha256Hex('[]'), consent: 'telemetry=community' }); + writeReceipt({ home, sink: 'design-openai', host: 'api.openai.com', payloadClass: 'generate-image-request', consent: 'user ran design command' }); + const all = run(['list', '--json']); + expect(all.code).toBe(0); + expect(JSON.parse(all.stdout).length).toBe(2); + const filtered = run(['list', '--json', '--sink', 'telemetry-sync']); + const rows = JSON.parse(filtered.stdout); + expect(rows.length).toBe(1); + expect(rows[0].host).toBe('10.0.0.1:8399'); + expect(rows[0].sha256).toBe(sha256Hex('[]')); + const none = run(['list', '--json', '--since', '2999-01-01T00:00:00Z']); + expect(JSON.parse(none.stdout).length).toBe(0); + }); + + test('unknown option exits 2 with usage', () => { + const r = run(['list', '--bogus']); + expect(r.code).toBe(2); + expect(r.stderr).toContain('Usage'); + }); +}); + +describe('gstack-egress verify', () => { + test('exits 0 on an intact chain and 3 naming the first broken line on tamper', () => { + writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'telemetry=community' }); + writeReceipt({ home, sink: 'b', host: 'h', payloadClass: 'c', consent: 'telemetry=community' }); + const ok = run(['verify']); + expect(ok.code).toBe(0); + expect(ok.stdout).toContain('chain intact: 2'); + + const ledger = egressLedgerPath(home); + const lines = fs.readFileSync(ledger, 'utf-8').trim().split('\n'); + lines[0] = lines[0].replace('community', 'communitX'); + fs.writeFileSync(ledger, `${lines.join('\n')}\n`); + const tampered = run(['verify']); + expect(tampered.code).toBe(3); + expect(tampered.stdout).toContain('line 2'); + }); + + test('prints the sizeWarning when the ledger exceeds the threshold', () => { + writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' }); + const ledger = egressLedgerPath(home); + // verify keys the warning off file size only — pad with a trailing + // comment-free blank region by appending to a side channel is not + // possible in JSONL, so grow via many valid-shaped junk lines and + // assert on sizeWarning presence (chain will break; both surface). + const filler = `${JSON.stringify({ type: 'junk', pad: 'x'.repeat(1024) })}\n`.repeat(1024); + while (fs.statSync(ledger).size <= LEDGER_WARN_BYTES) fs.appendFileSync(ledger, filler); + const r = run(['verify', '--json']); + expect(r.code).toBe(3); // filler breaks the chain — expected + const parsed = JSON.parse(r.stdout); + expect(parsed.sizeWarning).toContain('egress ledger is large'); + expect(parsed.sizeWarning).toContain('gstack-egress list'); + const human = run(['verify']); + expect(human.stdout).toContain('egress ledger is large'); + }); +}); + +describe('gstack-egress grants', () => { + test('fresh home shows the four upstream grants off, each naming file and revoke command', () => { + const r = run(['grants']); + expect(r.code).toBe(0); + for (const grant of ['telemetry', 'brain-sync', 'redact_repo_visibility', 'redact_prepush_hook']) { + expect(r.stdout).toContain(grant); + } + expect(r.stdout).not.toContain('[GRANTED]'); + expect(r.stdout).toContain(path.join(home, 'config.yaml')); + expect(r.stdout).toContain('revoke:'); + }); + + test('--json flips granted=true when telemetry and sync mode are enabled', () => { + const config = spawnSync(path.join(ROOT, 'bin', 'gstack-config'), ['set', 'telemetry', 'community'], { + encoding: 'utf-8', + env: { ...process.env, GSTACK_HOME: home }, + }); + expect(config.status).toBe(0); + spawnSync(path.join(ROOT, 'bin', 'gstack-config'), ['set', 'artifacts_sync_mode', 'full'], { + encoding: 'utf-8', + env: { ...process.env, GSTACK_HOME: home }, + }); + const r = run(['grants', '--json']); + expect(r.code).toBe(0); + const grants = JSON.parse(r.stdout); + const telemetry = grants.find((g: any) => g.grant === 'telemetry'); + expect(telemetry.granted).toBe(true); + expect(telemetry.revoke).toContain('telemetry off'); + const sync = grants.find((g: any) => g.grant === 'brain-sync'); + expect(sync.granted).toBe(true); + expect(sync.value).toBe('full'); + const hook = grants.find((g: any) => g.grant === 'redact_prepush_hook'); + expect(hook.granted).toBe(false); + }); +}); + +describe('gstack-egress usage', () => { + test('no subcommand exits 2 with usage', () => { + const r = run([]); + expect(r.code).toBe(2); + expect(r.stderr).toContain('Usage'); + }); +});