gstack/test/egress-receipt-wiring.test.ts

381 lines
16 KiB
TypeScript

/**
* Static-grep tripwire for egress-receipt wiring. Free tier — no API.
*
* 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 classes can send unrecorded with a warning.
*
* Every enumerated off-machine sink must route its send through the receipt
* ledger (lib/egress-receipt.ts), receipt BEFORE send. A future egress call
* site added without a receipt fails CI here instead of becoming a
* user-filed issue. The NEW-SINK SCANNER at the bottom sweeps the whole
* tree for outbound network ops and requires every hit to be either wired
* or in the REASONED exemption list — there is no KNOWN_UNWIRED bucket.
*
* Out of scope, documented here on purpose: the preamble-generated brain
* sync block (scripts/resolvers/preamble/generate-brain-sync-block.ts)
* renders a `git fetch` into skill PROSE that the agent executes — it is
* agent-executed instructions, not a gstack binary, so it is covered by the
* skill-prose exemption below rather than a receipt.
*
* Pattern mirrors test/hermetic-wiring.test.ts: read source files as text,
* assert invariants on their contents. Brittle by design — renaming a
* helper must force the author to look here.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..');
function read(rel: string): string {
return fs.readFileSync(path.join(ROOT, rel), 'utf-8');
}
function exists(rel: string): boolean {
return fs.existsSync(path.join(ROOT, rel));
}
// ── POLARITY TABLE (amendments T3/C8) ──────────────────────────────────────
// Pinned as data: which sinks refuse the send when the receipt cannot be
// written (fail-closed) vs warn and proceed (fail-open). Changing a sink's
// polarity is a security decision — update this table deliberately.
const POLARITY: Record<string, 'fail-closed' | 'fail-open'> = {
// fail-closed: gstack state leaving the machine unrecorded is worse than
// the operation failing.
'brain-sync': 'fail-closed',
'memory-ingest': 'fail-closed',
'gbrain-sync': 'fail-closed',
'telemetry-sync': 'fail-closed',
'browse-tunnel (ngrok)': 'fail-closed',
'gbrain-mcp-verify': 'fail-closed',
'supabase-provision': 'fail-closed',
// fail-open: user-facing operations that must not die over an audit-log
// hiccup; they warn on stderr and proceed.
'design-openai': 'fail-open',
'update-check': 'fail-open',
'security-dashboard': 'fail-open',
'community-dashboard': 'fail-open',
'git-class user ops (artifacts-init, brain-restore, session-update)': 'fail-open',
'context-bill --exact': 'fail-open',
};
/** TS sinks: must import the canonical helper and call writeReceipt(). */
const MODULE_SINKS = [
'bin/gstack-gbrain-sync.ts',
'bin/gstack-memory-ingest.ts',
'browse/src/server.ts',
// Unconditional: context-bill ships in the same tree as this tripwire. A
// missing file must fail loudly (a rename/move that drops its receipt wiring
// is exactly what this pins), not silently soften the assertion.
'lib/context-bill.ts',
];
/** Shell sinks: must source the shared lib; every network op receipted. */
const SHELL_SINKS = [
'bin/gstack-telemetry-sync',
'bin/gstack-update-check',
'bin/gstack-brain-sync',
'bin/gstack-gbrain-mcp-verify',
'bin/gstack-security-dashboard',
'bin/gstack-community-dashboard',
'bin/gstack-gbrain-supabase-provision',
'bin/gstack-artifacts-init',
'bin/gstack-brain-restore',
'bin/gstack-session-update',
];
/** design files that talk to api.openai.com — all must use receiptedFetch. */
const DESIGN_SINKS = [
'design/src/generate.ts',
'design/src/variants.ts',
'design/src/iterate.ts',
'design/src/evolve.ts',
'design/src/check.ts',
'design/src/diff.ts',
'design/src/design-to-code.ts',
'design/src/memory.ts',
];
// ── NEW-SINK SCANNER exemptions ────────────────────────────────────────────
// Every entry carries its reason. An unexplained network op anywhere in the
// swept tree fails the scanner — add real sinks to the wired lists above,
// not here.
const SCANNER_EXEMPT: Record<string, string> = {
'bin/gstack-team-init':
'every git clone is inside an echoed instruction string (install docs); the script executes no network ops',
'bin/gstack-gbrain-install':
'user-invoked installer: bodyless HEAD reachability probe to github.com + clone of the public gbrain repo (user-directed install; no gstack state leaves the machine)',
'bin/gstack-next-version':
'fetches the user\'s own repo\'s base branch for version-claim freshness — a user-repo dev-workflow op, not gstack-state egress',
'bin/gstack-version-bump':
'git fetch appears only in an error-message string',
'bin/gstack-redact-prepush':
'git push mentions are hook documentation strings (bypass instructions)',
'browse/src/security-classifier.ts':
'HF model download: bodyless GET of a public classifier model (variable URL)',
'browse/src/write-commands.ts':
'user-directed page fetch — the browser command surface fetches what the user asked for',
'browse/src/cli.ts':
'health probe of the user\'s own pair-agent tunnel URL (reachability probe)',
'browse/src/commands.ts':
'git pull appears only in an upgrade-hint message string',
'browse/src/cookie-picker-ui.ts':
'served-page JS talking to its own loopback server (same-origin relative fetch)',
'design/src/compare.ts':
'served-page JS talking to its own loopback server (relative ./api fetch)',
// Skill prose templates: these render agent-executed instructions (the
// agent runs git in the USER\'S repo at the user\'s direction), they are
// not gstack binaries. Includes the preamble-generated brain-sync block —
// see the header.
'scripts/resolvers':
'skill prose templates — agent-executed instructions rendered into SKILL.md, not gstack binaries',
};
function isExempt(rel: string): string | undefined {
for (const [key, reason] of Object.entries(SCANNER_EXEMPT)) {
if (rel === key || rel.startsWith(`${key}/`)) return reason;
}
return undefined;
}
// Receipt markers that make a nearby network op "wired".
const RECEIPT_MARKER =
/_receipted_(curl|git|version_fetch)\b|gstack-egress-receipt["']?\s+write\b|writeReceipt\(|receiptedFetch\(/;
/** Was a receipt marker present on this line or the 30 preceding lines? */
function guarded(lines: string[], i: number): boolean {
for (let j = i; j >= Math.max(0, i - 30); j--) {
if (RECEIPT_MARKER.test(lines[j])) return true;
}
return false;
}
// git as a COMMAND followed by a remote op. Local ops (rev-parse, remote
// get-url, add, commit, merge) never match; neither does prose like
// "curated-memory-git-push" (hyphenated) or "'git fetch'" (quoted).
const GIT_REMOTE_OP = /(^|[;|&`($!]|\s)git(\s+-C\s+\S+)?\s+(push|pull|fetch|clone|ls-remote)\b/;
// git spawn-array form in TS: spawn("git", ["push", ...]).
const GIT_SPAWN_OP = /["'`]git["'`]\s*,\s*\[\s*["'`](push|pull|fetch|clone|ls-remote)/;
// curl as a command token.
const CURL_OP = /(^|[|&;(`]|\s|\$\()curl\s/;
// fetch() with an absolute http(s) URL (loopback filtered separately).
const FETCH_ABS = /(^|[^A-Za-z])fetch(Fn|Impl)?\(\s*[`'"]https?:\/\//;
function isTextFile(full: string): boolean {
try {
const buf = fs.readFileSync(full);
return !buf.subarray(0, 1024).includes(0);
} catch {
return false;
}
}
function* walk(dir: string): Generator<string> {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full);
else if (entry.isFile()) yield full;
}
}
/**
* Collect un-receipted outbound network ops in a file. Skips comments,
* loopback lines, `command -v` probes, message-emitting lines, and shell
* heredoc bodies (echoed instructions are not executed ops).
*/
function scanFile(rel: string): string[] {
const src = read(rel);
const isTs = /\.(ts|js|mjs|tsx)$/.test(rel);
const lines = src.split('\n');
const offenders: string[] = [];
let heredocEnd: string | null = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (heredocEnd !== null) {
if (line.trim() === heredocEnd) heredocEnd = null;
continue;
}
if (!isTs) {
const heredoc = line.match(/<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/);
if (heredoc) heredocEnd = heredoc[1];
}
const trimmed = line.trimStart();
if (/^(#|\/\/|\*|\/\*)/.test(trimmed)) continue;
if (line.includes('127.0.0.1') || line.includes('localhost')) continue;
if (/command -v/.test(line)) continue;
if (/^(echo|printf|emit|die|fail|log)\b/.test(trimmed)) continue;
// TS: shell-style git ops only count on lines that actually execute
// something (spawn/exec markers) — template-literal prose does not.
const tsExecGit =
GIT_SPAWN_OP.test(line) ||
(GIT_REMOTE_OP.test(line) && /\b(spawn|spawnSync|exec|execSync|execFileSync|runCommand)\b/.test(line));
const isNetOp = isTs
? FETCH_ABS.test(line) || tsExecGit
: CURL_OP.test(line) || GIT_REMOTE_OP.test(line);
if (!isNetOp) continue;
if (guarded(lines, i)) continue;
offenders.push(`${rel}:${i + 1}: ${line.trim().slice(0, 100)}`);
}
return offenders;
}
describe('egress receipt wiring tripwire', () => {
test('every TS sink imports lib/egress-receipt and calls writeReceipt()', () => {
for (const rel of MODULE_SINKS) {
const src = read(rel);
expect(src.includes('egress-receipt'), `${rel}: must import lib/egress-receipt`).toBe(true);
expect(src.includes('writeReceipt('), `${rel}: must call writeReceipt() before its send`).toBe(true);
}
});
test('every shell sink sources gstack-egress-lib.sh', () => {
for (const rel of SHELL_SINKS) {
const src = read(rel);
expect(
src.includes('gstack-egress-lib.sh'),
`${rel}: must source bin/gstack-egress-lib.sh for _receipted_* helpers`,
).toBe(true);
}
});
test('every network op in a wired shell sink sits under a receipt', () => {
const offenders = SHELL_SINKS.flatMap((rel) => scanFile(rel));
expect(
offenders,
'un-receipted network call(s) — wrap in _receipted_curl/_receipted_git or write the receipt first:\n' +
offenders.join('\n'),
).toEqual([]);
});
test('browse tunnel: every ngrok.forward() has a writeReceipt in the 30 preceding lines', () => {
const lines = read('browse/src/server.ts').split('\n');
const offenders: string[] = [];
let sawForward = false;
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes('ngrok.forward(')) continue;
if (/^\s*(\/\/|\*)/.test(lines[i])) continue;
sawForward = true;
const context = lines.slice(Math.max(0, i - 30), i).join('\n');
if (!context.includes('writeReceipt(')) offenders.push(`browse/src/server.ts:${i + 1}`);
}
expect(sawForward, 'expected ngrok.forward call sites in server.ts').toBe(true);
expect(offenders, 'tunnel session opened without a receipt: ' + offenders.join(', ')).toEqual([]);
});
test('design: every api.openai.com call routes through receiptedFetch', () => {
for (const rel of DESIGN_SINKS) {
const src = read(rel);
expect(
src.includes('receipted-fetch'),
`${rel}: must import design/src/receipted-fetch`,
).toBe(true);
const lines = src.split('\n');
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes('api.openai.com')) continue;
if (/^\s*(\/\/|\*)/.test(lines[i])) continue;
if (/\bfetch(Fn|Impl)?\(/.test(lines[i])) {
throw new Error(
`${rel}:${i + 1}: raw fetch to api.openai.com — route it through receiptedFetch()`,
);
}
}
}
});
test('deprecated dead-endpoint brain consumer/reader scripts stay deleted', () => {
// lstat (not existsSync) so a dangling symlink also fails.
for (const rel of ['bin/gstack-brain-consumer', 'bin/gstack-brain-reader']) {
let present = true;
try {
fs.lstatSync(path.join(ROOT, rel));
} catch {
present = false;
}
expect(present, `${rel} was deleted (dead /ingest-repo egress sink) — do not resurrect`).toBe(false);
}
});
test('polarity table names every wired sink exactly once per polarity', () => {
const closed = Object.entries(POLARITY).filter(([, p]) => p === 'fail-closed').map(([s]) => s);
const open = Object.entries(POLARITY).filter(([, p]) => p === 'fail-open').map(([s]) => s);
expect(closed.sort()).toEqual([
'brain-sync',
'browse-tunnel (ngrok)',
'gbrain-mcp-verify',
'gbrain-sync',
'memory-ingest',
'supabase-provision',
'telemetry-sync',
]);
expect(open.sort()).toEqual([
'community-dashboard',
'context-bill --exact',
'design-openai',
'git-class user ops (artifacts-init, brain-restore, session-update)',
'security-dashboard',
'update-check',
]);
});
test('polarity spot-checks: closed sinks refuse, open sinks warn', () => {
// telemetry-sync (closed): the wrapped POST uses the `closed` policy.
expect(read('bin/gstack-telemetry-sync')).toMatch(/_receipted_curl closed telemetry-sync/);
// brain-sync (closed): refusal exits before the commit consumes the queue.
expect(read('bin/gstack-brain-sync')).toMatch(/gstack-egress-receipt["']? write/);
// update-check (open).
expect(read('bin/gstack-update-check')).toMatch(/_receipted_curl open update-check/);
// dashboards (open).
expect(read('bin/gstack-security-dashboard')).toMatch(/_receipted_curl open security-dashboard/);
expect(read('bin/gstack-community-dashboard')).toMatch(/_receipted_curl open community-dashboard/);
// mcp-verify + provision (closed).
expect(read('bin/gstack-gbrain-mcp-verify')).toMatch(/_receipted_curl closed gbrain-mcp-verify/);
expect(read('bin/gstack-gbrain-supabase-provision')).toMatch(/_receipted_curl closed supabase-provision/);
// design (open): the wrapper catches receipt errors and proceeds.
const rf = read('design/src/receipted-fetch.ts');
expect(rf).toContain('fail-open');
expect(rf.indexOf('writeReceipt(')).toBeLessThan(rf.indexOf('fetchImpl(url, init)'));
});
test('NEW-SINK SCANNER: every outbound network op in the tree is wired or reasoned-exempt', () => {
const SWEEP = ['bin', 'lib', 'scripts', 'design/src', 'browse/src'];
const offenders: string[] = [];
for (const dirRel of SWEEP) {
const dir = path.join(ROOT, dirRel);
if (!fs.existsSync(dir)) continue;
for (const full of walk(dir)) {
const rel = path.relative(ROOT, full).split(path.sep).join('/');
if (!/\.(ts|js|mjs|sh|tsx)$/.test(rel) && !isTextFile(full)) continue;
if (isExempt(rel)) continue;
offenders.push(...scanFile(rel));
}
}
expect(
offenders,
'unwired outbound network op(s). Wire each through the receipt helpers ' +
'(_receipted_curl/_receipted_git in shell, writeReceipt/receiptedFetch in TS) ' +
'or add a REASONED exemption with the honest why:\n' +
offenders.join('\n'),
).toEqual([]);
});
test('shebang tripwire: no bin/gstack-* file carries a node shebang (amendment 2A)', () => {
const offenders: string[] = [];
for (const entry of fs.readdirSync(path.join(ROOT, 'bin'))) {
if (!entry.startsWith('gstack-')) continue;
const full = path.join(ROOT, 'bin', entry);
if (!fs.lstatSync(full).isFile()) continue;
if (!isTextFile(full)) continue;
const firstLine = fs.readFileSync(full, 'utf-8').split('\n', 1)[0];
if (firstLine.startsWith('#!') && /\bnode\b/.test(firstLine)) {
offenders.push(`bin/${entry}: ${firstLine}`);
}
}
expect(offenders, 'node shebangs in bin/ (use #!/usr/bin/env bun): ' + offenders.join(', ')).toEqual([]);
});
});