Files
gstack/bin/gstack-state-matrix
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

102 lines
5.9 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-state-matrix — the round-trip matrix for proposal, hold and approval state machines (field list 2, W3).
*
* gstack-state-matrix detect [--base <ref>] [--diff-base <sha>]
* gstack-state-matrix check <specialist-output|-> [--repo <dir>] [--expect <expectation.json>] [--json]
* gstack-state-matrix outfile
*
* `detect` prints `SCOPE_STATE_MACHINE=true|false` (shell-safe for
* `source <(...)`, with `# state-machine:` comment lines naming the hits) from
* the lines the branch adds since its merge base with origin/<base>.
* `check` reads the state-machines specialist's output and prints
* `STATE_MATRIX_GAP:` per gap and `STATE_MATRIX_CHECK: ok | incomplete gaps=<n>`
* last; exit 0 ok, 1 incomplete (STATE_MATRIX_INCOMPLETE). `outfile` creates and
* prints a new empty 0600 file in the repo's private `.gstack/tmp/` (0700, kept
* out of git through info/exclude) for the specialist's output, so a later
* review never checks or commits an earlier run's file. Logic: lib/state-matrix.ts.
*/
import { spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { addedLines } from '../lib/banned-terms';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { resultLine } from '../lib/result-codes';
import { resolveBaseBranch } from '../lib/ship-policy';
import { checkStateMatrix, detectStateMachine, renderStateMatrixCheck, type Expectation } from '../lib/state-matrix';
const HELP = `gstack-state-matrix — kind × owner action × resulting row for proposal, hold and approval state machines
detect [--base <ref>] [--diff-base <sha>] SCOPE_STATE_MACHINE=true|false for the branch's added code lines
check <file|-> [--repo <dir>] [--expect <json>] [--json]
gaps in the state-machines specialist's output (review/specialists/state-machines.md)
outfile print a new empty private file for that output
${exitTableHelp()}`;
function flag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
function git(args: string[], cwd?: string): { ok: boolean; out: string } {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
return { ok: r.status === 0, out: r.stdout ?? '' };
}
function detect(rest: string[]): number {
const root = git(['rev-parse', '--show-toplevel']);
if (!root.ok) { process.stdout.write('SCOPE_STATE_MACHINE=false\n# not inside a git repository\n'); return EXIT.usage; }
const repoRoot = root.out.trim();
const base = resolveBaseBranch(repoRoot, flag(rest, '--base'));
const mb = flag(rest, '--diff-base') ?? git(['merge-base', `origin/${base}`, 'HEAD'], repoRoot).out.trim();
const diff = mb ? git(['-c', 'core.quotePath=false', 'diff', '--no-ext-diff', '--no-textconv', '--unified=0', '--no-color', mb], repoRoot) : { ok: false, out: '' };
if (!diff.ok) { process.stdout.write(`SCOPE_STATE_MACHINE=false\n# no merge base with origin/${base}; run: git fetch origin ${base}\n`); return EXIT.usage; }
const { detected, hits } = detectStateMachine(addedLines(diff.out));
const lines = [`SCOPE_STATE_MACHINE=${detected}`, ...hits.slice(0, 12).map(h => `# state-machine: ${h.file}:${h.line} ${[...h.subject, ...h.action].join(',')}`)];
process.stdout.write(lines.join('\n') + '\n');
return EXIT.ok;
}
function check(rest: string[]): number {
const input = rest.find((a, i) => !a.startsWith('--') && !['--repo', '--expect'].includes(rest[i - 1] ?? ''));
if (!input) { process.stderr.write(`gstack-state-matrix check: a specialist output file (or -) is required\n${HELP}\n`); return EXIT.usage; }
const text = input === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(input, 'utf8');
const repoRoot = flag(rest, '--repo') ?? (git(['rev-parse', '--show-toplevel']).out.trim() || process.cwd());
const expectFile = flag(rest, '--expect');
const expect = expectFile ? JSON.parse(fs.readFileSync(expectFile, 'utf8')) as Expectation : undefined;
const result = checkStateMatrix(text, repoRoot, expect);
process.stdout.write(rest.includes('--json') ? JSON.stringify(result) + '\n' : renderStateMatrixCheck(result));
if (result.gaps.length) { process.stderr.write(resultLine('gstack-state-matrix check', 'STATE_MATRIX_INCOMPLETE', `${result.gaps.length} gap(s)`) + '\n'); return EXIT.fail; }
return EXIT.ok;
}
function outfile(): number {
const root = git(['rev-parse', '--show-toplevel']);
const exclude = git(['rev-parse', '--git-path', 'info/exclude']);
if (!root.ok || !exclude.ok) { process.stderr.write('gstack-state-matrix outfile: not inside a git repository\n'); return EXIT.usage; }
const dir = path.join(root.out.trim(), '.gstack', 'tmp');
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
fs.chmodSync(dir, 0o700);
const excludeFile = path.resolve(exclude.out.trim());
fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
const excluded = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, 'utf8') : '';
if (!excluded.split('\n').includes('/.gstack/tmp/')) fs.appendFileSync(excludeFile, `${excluded && !excluded.endsWith('\n') ? '\n' : ''}/.gstack/tmp/\n`);
const file = path.join(dir, `state-machines.${randomBytes(6).toString('hex')}.md`);
fs.closeSync(fs.openSync(file, 'wx', 0o600));
process.stdout.write(file + '\n');
return EXIT.ok;
}
function main(argv: string[]): number {
const [cmd, ...rest] = argv;
if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
if (cmd === 'detect') return detect(rest);
if (cmd === 'check') return check(rest);
if (cmd === 'outfile') return outfile();
process.stderr.write(`gstack-state-matrix: unknown command ${cmd}\n${HELP}\n`);
return EXIT.usage;
}
process.exitCode = main(process.argv.slice(2));