Files
gstack/bin/gstack-egress-receipt
T
Garry Tanandcapy-ai[bot] 4c6fa89cdc v1.91.159.0 security: gstack's Bun entry points ignore the audited repo's .env and bunfig.toml (S1) (#3312)
* security: isolate every gstack Bun entry point from the audited checkout's .env and bunfig.toml

Bun auto-loads .env, .env.local and .env.<NODE_ENV> from its cwd and runs a
cwd bunfig.toml preload before the program starts. gstack runs inside the repo
it audits, so a committed .env could steer state roots, the claude binary or
spend caps, and a bunfig preload ran arbitrary code in every bin and hook.

Every bun shebang, shell and skill caller, TypeScript spawn and compiled build
now disables both (--no-env-file --config=/dev/null, bunIsolationArgs(),
--no-compile-autoload-dotenv/bunfig), with a ratchet and hostile-fixture
regressions.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: name the directly executed scripts in the isolation ratchet

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: follow the isolation flags in touchfiles, copy lists, recipe assertions and the harness pin

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: keep browse/src/bun-child-config.ts unchanged so the native cookie qualification still binds

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(windows): the empty bunfig outlives its process so the timeline hook's detached repair child can read it

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.159.0 security: gstack's Bun entry points ignore the audited repo's .env and bunfig.toml (S1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: plan-mode render pattern accepts the isolation flags

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-20 00:24:28 +00:00

85 lines
3.2 KiB
TypeScript
Executable File

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
// gstack-egress-receipt — bun script that BRIDGES shell callers (the bash
// egress sinks: gstack-telemetry-sync, gstack-update-check, gstack-brain-sync,
// and the sourced helpers in gstack-egress-lib.sh) into lib/egress-receipt.ts.
//
// Usage:
// gstack-egress-receipt write --sink S --host H --class C \
// (--payload-file F | --no-payload) [--consent "key=value"]
// → prints the receipt id on stdout, exit 0.
// → exit 3 + "EGRESS_RECEIPT_FAILED: ..." on stderr when the receipt
// cannot be written. Fail-closed callers MUST then refuse the send.
//
// gstack-egress-receipt outcome <receipt-id> <status>
// → best-effort response-status record; never blocks anything.
//
// Home: GSTACK_HOME, legacy GSTACK_STATE_DIR, else ~/.gstack.
// The payload file is hashed as-is: pass the SAME file to curl (`-d @file`)
// so the receipt hash matches the exact bytes sent (scan-at-sink precedent).
import fs from 'node:fs';
import { EGRESS_RECEIPT_FAILED, sha256Hex, writeOutcome, writeReceipt } from '../lib/egress-receipt';
function parseArgs(args: string[], valueFlags: string[], boolFlags: string[]) {
const values = new Map<string, string>();
const flags = new Set<string>();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (valueFlags.includes(arg)) {
const value = args[++index];
if (value == null) usage(`${arg} requires a value`);
values.set(arg, value);
} else if (boolFlags.includes(arg)) {
flags.add(arg);
} else {
usage(`unknown option: ${arg}`);
}
}
return { values, flags };
}
function usage(message: string): never {
process.stderr.write(`gstack-egress-receipt: ${message}\n`);
process.exit(2);
}
const [command, ...rest] = process.argv.slice(2);
if (command === 'write') {
const { values, flags } = parseArgs(rest,
['--sink', '--host', '--class', '--payload-file', '--consent'], ['--no-payload']);
const payloadFile = values.get('--payload-file');
if (!payloadFile && !flags.has('--no-payload')) usage('write requires --payload-file or --no-payload');
try {
let bytes = 0;
let sha256: string | null = null; // --no-payload = git-class op, subprocess owns the bytes
if (payloadFile) {
const payload = fs.readFileSync(payloadFile);
bytes = payload.byteLength;
sha256 = sha256Hex(payload);
}
const { id } = writeReceipt({
sink: values.get('--sink') as string,
host: values.get('--host') as string,
payloadClass: values.get('--class') as string,
bytes,
sha256,
consent: values.get('--consent') ?? 'unspecified',
});
process.stdout.write(`${id}\n`);
} catch (error) {
process.stderr.write(`${EGRESS_RECEIPT_FAILED}: ${(error as Error)?.message ?? error}\n`);
process.exit(3);
}
} else if (command === 'outcome') {
const [receipt, status, ...extra] = rest;
if (!receipt || !status || extra.length) usage('Usage: gstack-egress-receipt outcome <receipt-id> <status>');
try {
writeOutcome({ receipt, status });
} catch {
// Best-effort: the pre-send receipt is the invariant, the outcome is bookkeeping.
}
} else {
usage('Usage: gstack-egress-receipt write|outcome ...');
}