mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
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:
@@ -39,6 +39,7 @@ import "../lib/conductor-env-shim";
|
|||||||
import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers";
|
import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers";
|
||||||
import { ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleCompleted, type CycleStatus } from "../lib/gbrain-sources";
|
import { ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleCompleted, type CycleStatus } from "../lib/gbrain-sources";
|
||||||
import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards";
|
import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards";
|
||||||
|
import { writeReceipt } from "../lib/egress-receipt";
|
||||||
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
|
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
|
||||||
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
|
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
|
||||||
import { checkOwnedStagingDir } from "../lib/staging-guard";
|
import { checkOwnedStagingDir } from "../lib/staging-guard";
|
||||||
@@ -67,7 +68,13 @@ interface CodeStageDetail {
|
|||||||
source_path?: string;
|
source_path?: string;
|
||||||
page_count?: number | null;
|
page_count?: number | null;
|
||||||
last_imported?: string;
|
last_imported?: string;
|
||||||
status?: "ok" | "skipped" | "failed" | "refused-autopilot" | "refused-reclone";
|
status?:
|
||||||
|
| "ok"
|
||||||
|
| "skipped"
|
||||||
|
| "failed"
|
||||||
|
| "refused-autopilot"
|
||||||
|
| "refused-reclone"
|
||||||
|
| "refused-egress-receipt";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StageResult {
|
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], {
|
const walkResult = spawnGbrain(["sync", "--strategy", "code", "--source", sourceId], {
|
||||||
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
|
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
|
||||||
timeout: codeTimeoutMs,
|
timeout: codeTimeoutMs,
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
withErrorContext,
|
withErrorContext,
|
||||||
} from "../lib/gstack-memory-helpers";
|
} from "../lib/gstack-memory-helpers";
|
||||||
import { execGbrainText, spawnGbrainAsync } from "../lib/gbrain-exec";
|
import { execGbrainText, spawnGbrainAsync } from "../lib/gbrain-exec";
|
||||||
|
import { writeReceipt } from "../lib/egress-receipt";
|
||||||
import { checkOwnedStagingDir, STAGING_MARKER } from "../lib/staging-guard";
|
import { checkOwnedStagingDir, STAGING_MARKER } from "../lib/staging-guard";
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
@@ -1690,6 +1691,36 @@ async function ingestPass(args: CliArgs): Promise<BulkResult> {
|
|||||||
// spawn, parent termination orphans the gbrain process (observed
|
// spawn, parent termination orphans the gbrain process (observed
|
||||||
// during 2026-05-10 cold-run testing — gbrain kept running 15 min
|
// during 2026-05-10 cold-run testing — gbrain kept running 15 min
|
||||||
// after the orchestrator timed out).
|
// 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 importResult = await runGbrainImport(stagingDir, resolveImportTimeoutMs());
|
||||||
|
|
||||||
const stdout = importResult.stdout || "";
|
const stdout = importResult.stdout || "";
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { isProcessAlive } from './error-handling';
|
|||||||
import { sanitizeBody, stripLoneSurrogateEscapes } from './sanitize';
|
import { sanitizeBody, stripLoneSurrogateEscapes } from './sanitize';
|
||||||
import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge';
|
import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge';
|
||||||
import { parseProxyConfig, toUpstreamConfig, ProxyConfigError } from './proxy-config';
|
import { parseProxyConfig, toUpstreamConfig, ProxyConfigError } from './proxy-config';
|
||||||
|
import { writeReceipt } from '../../lib/egress-receipt';
|
||||||
import { redactProxyUrl } from './proxy-redact';
|
import { redactProxyUrl } from './proxy-redact';
|
||||||
import { shouldSpawnXvfb, pickFreeDisplay, spawnXvfb, xvfbInstallHint, type XvfbHandle } from './xvfb';
|
import { shouldSpawnXvfb, pickFreeDisplay, spawnXvfb, xvfbInstallHint, type XvfbHandle } from './xvfb';
|
||||||
import { logTunnelDenial } from './tunnel-denial-log';
|
import { logTunnelDenial } from './tunnel-denial-log';
|
||||||
@@ -2375,6 +2376,19 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||||||
const forwardOpts: any = { addr: tunnelPort, authtoken };
|
const forwardOpts: any = { addr: tunnelPort, authtoken };
|
||||||
if (domain) forwardOpts.domain = domain;
|
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);
|
tunnelListener = await ngrok.forward(forwardOpts);
|
||||||
tunnelUrl = tunnelListener.url();
|
tunnelUrl = tunnelListener.url();
|
||||||
tunnelServer = boundTunnel;
|
tunnelServer = boundTunnel;
|
||||||
@@ -3084,6 +3098,18 @@ export async function start() {
|
|||||||
const forwardOpts: any = { addr: tunnelPort, authtoken };
|
const forwardOpts: any = { addr: tunnelPort, authtoken };
|
||||||
if (domain) forwardOpts.domain = domain;
|
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);
|
tunnelListener = await ngrok.forward(forwardOpts);
|
||||||
tunnelUrl = tunnelListener.url();
|
tunnelUrl = tunnelListener.url();
|
||||||
tunnelServer = boundTunnel;
|
tunnelServer = boundTunnel;
|
||||||
|
|||||||
Reference in New Issue
Block a user