mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* 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>
85 lines
3.2 KiB
TypeScript
Executable File
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 ...');
|
|
}
|