mirror of https://github.com/garrytan/gstack.git
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:
parent
244f1b47d3
commit
a1282c78d4
|
|
@ -39,6 +39,7 @@ import "../lib/conductor-env-shim";
|
|||
import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers";
|
||||
import { ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleCompleted, type CycleStatus } from "../lib/gbrain-sources";
|
||||
import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards";
|
||||
import { writeReceipt } from "../lib/egress-receipt";
|
||||
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
|
||||
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
|
||||
import { checkOwnedStagingDir } from "../lib/staging-guard";
|
||||
|
|
@ -67,7 +68,13 @@ interface CodeStageDetail {
|
|||
source_path?: string;
|
||||
page_count?: number | null;
|
||||
last_imported?: string;
|
||||
status?: "ok" | "skipped" | "failed" | "refused-autopilot" | "refused-reclone";
|
||||
status?:
|
||||
| "ok"
|
||||
| "skipped"
|
||||
| "failed"
|
||||
| "refused-autopilot"
|
||||
| "refused-reclone"
|
||||
| "refused-egress-receipt";
|
||||
}
|
||||
|
||||
interface StageResult {
|
||||
|
|
@ -902,6 +909,27 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
|
|||
};
|
||||
}
|
||||
|
||||
// Egress receipt BEFORE the code walk (fail-closed): the walk ships repo
|
||||
// content to the user's gbrain DB, which may be a remote Postgres. The
|
||||
// gbrain subprocess owns the wire bytes, so the receipt is content-free
|
||||
// (destination + payload class only; sha256 null).
|
||||
try {
|
||||
writeReceipt({
|
||||
sink: "gbrain-sync",
|
||||
host: "gbrain-db (user-configured DATABASE_URL)",
|
||||
payloadClass: `repo-code-index source=${sourceId} (sent by gbrain subprocess)`,
|
||||
bytes: 0,
|
||||
sha256: null,
|
||||
consent: "gbrain setup consent + per-repo policy chokepoint (repoPolicyTier)",
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
name: "code", ran: true, ok: false, duration_ms: Date.now() - t0,
|
||||
summary: `EGRESS_RECEIPT_FAILED: ${(err as Error).message} — code sync refused`,
|
||||
detail: { source_id: sourceId, source_path: root, status: "refused-egress-receipt" },
|
||||
};
|
||||
}
|
||||
|
||||
const walkResult = spawnGbrain(["sync", "--strategy", "code", "--source", sourceId], {
|
||||
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
|
||||
timeout: codeTimeoutMs,
|
||||
|
|
|
|||
|
|
@ -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 || "";
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import { isProcessAlive } from './error-handling';
|
|||
import { sanitizeBody, stripLoneSurrogateEscapes } from './sanitize';
|
||||
import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge';
|
||||
import { parseProxyConfig, toUpstreamConfig, ProxyConfigError } from './proxy-config';
|
||||
import { writeReceipt } from '../../lib/egress-receipt';
|
||||
import { redactProxyUrl } from './proxy-redact';
|
||||
import { shouldSpawnXvfb, pickFreeDisplay, spawnXvfb, xvfbInstallHint, type XvfbHandle } from './xvfb';
|
||||
import { logTunnelDenial } from './tunnel-denial-log';
|
||||
|
|
@ -2375,6 +2376,19 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||
const forwardOpts: any = { addr: tunnelPort, authtoken };
|
||||
if (domain) forwardOpts.domain = domain;
|
||||
|
||||
// Egress receipt BEFORE the tunnel session opens, fail-closed: a
|
||||
// writeReceipt failure lands in this catch, which tears the tunnel
|
||||
// listener back down and refuses the start. One receipt per session
|
||||
// open; browse command behavior over the tunnel is unchanged.
|
||||
writeReceipt({
|
||||
sink: 'browse-tunnel',
|
||||
host: domain || 'connect.ngrok-agent.com',
|
||||
payloadClass: 'tunnel-session-open (scoped-token browser-command surface)',
|
||||
bytes: 0,
|
||||
sha256: null,
|
||||
consent: 'pair_agent=on',
|
||||
});
|
||||
|
||||
tunnelListener = await ngrok.forward(forwardOpts);
|
||||
tunnelUrl = tunnelListener.url();
|
||||
tunnelServer = boundTunnel;
|
||||
|
|
@ -3084,6 +3098,18 @@ export async function start() {
|
|||
const forwardOpts: any = { addr: tunnelPort, authtoken };
|
||||
if (domain) forwardOpts.domain = domain;
|
||||
|
||||
// Egress receipt BEFORE the tunnel session opens, fail-closed: a
|
||||
// writeReceipt failure lands in this catch, which cleans up the
|
||||
// listener and skips the tunnel (same as any other startup failure).
|
||||
writeReceipt({
|
||||
sink: 'browse-tunnel',
|
||||
host: domain || 'connect.ngrok-agent.com',
|
||||
payloadClass: 'tunnel-session-open (scoped-token browser-command surface)',
|
||||
bytes: 0,
|
||||
sha256: null,
|
||||
consent: 'pair_agent=on (BROWSE_TUNNEL=1)',
|
||||
});
|
||||
|
||||
tunnelListener = await ngrok.forward(forwardOpts);
|
||||
tunnelUrl = tunnelListener.url();
|
||||
tunnelServer = boundTunnel;
|
||||
|
|
|
|||
Loading…
Reference in New Issue