feat(security): receipt TS module sinks + tunnel

writeReceipt (fail-closed, sha256:null — a subprocess or SDK owns the
wire bytes) before every TS-module network-bearing operation:

- bin/gstack-gbrain-sync.ts: before the gbrain code walk that ships
  repo content to the user's gbrain DB (may be remote Postgres). A
  refused receipt fails the stage with status refused-egress-receipt.
- bin/gstack-memory-ingest.ts: before the gbrain batch import of
  transcript pages. A refused receipt returns a system_error verdict
  without spawning the import.
- browse/src/server.ts: before both ngrok.forward call sites (start-up
  BROWSE_TUNNEL=1 path and the /tunnel/start endpoint). A receipt
  failure lands in the existing catch that tears the tunnel listener
  back down and refuses the start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 5677d618a48fcd0ae2b068bf868781d90f809cb5)
This commit is contained in:
Garry Tan
2026-08-12 15:31:48 -07:00
parent 244f1b47d3
commit a1282c78d4
3 changed files with 86 additions and 1 deletions
+31
View File
@@ -65,6 +65,7 @@ import {
withErrorContext,
} from "../lib/gstack-memory-helpers";
import { execGbrainText, spawnGbrainAsync } from "../lib/gbrain-exec";
import { writeReceipt } from "../lib/egress-receipt";
import { checkOwnedStagingDir, STAGING_MARKER } from "../lib/staging-guard";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -1690,6 +1691,36 @@ async function ingestPass(args: CliArgs): Promise<BulkResult> {
// spawn, parent termination orphans the gbrain process (observed
// during 2026-05-10 cold-run testing — gbrain kept running 15 min
// after the orchestrator timed out).
//
// Egress receipt BEFORE the import (fail-closed): the gbrain DB may be a
// remote Postgres, so the ingest is a potential off-machine send. The
// gbrain subprocess owns the wire bytes (content-free receipt, sha256
// null). The remote-http branch above stages locally only — its egress
// happens in gstack-brain-sync, which writes its own receipt at the push.
try {
writeReceipt({
sink: "memory-ingest",
host: "gbrain-db (user-configured DATABASE_URL)",
payloadClass: `transcript-pages count=${staging.written} (sent by gbrain subprocess)`,
bytes: 0,
sha256: null,
consent: "gbrain setup consent (/setup-gbrain)",
});
} catch (err) {
const msg = `EGRESS_RECEIPT_FAILED: ${(err as Error).message} — ingest refused`;
console.error(`[memory-ingest] ERR: ${msg}`);
failed += prep.prepared.length;
return {
written: 0,
skipped_secret: prep.skippedSecret,
skipped_dedup: prep.skippedDedup,
skipped_unattributed: prep.skippedUnattributed,
failed,
duration_ms: Date.now() - t0,
partial_pages: prep.partialPages,
system_error: msg,
};
}
const importResult = await runGbrainImport(stagingDir, resolveImportTimeoutMs());
const stdout = importResult.stdout || "";