Files
gstack/bin/gstack-office-hours-review
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

125 lines
9.9 KiB
TypeScript
Executable File

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
import * as fs from 'node:fs';
import * as path from 'node:path';
import { atomicWriteSync } from '../lib/fs-atomic.ts';
import { assessOfficeHoursReviews, extractOfficeHoursReviewBlock, loadOfficeHoursReviews, officeHoursDesignChanges, officeHoursMaxRounds, officeHoursSnapshotChanges, officeHoursVerdictReceipt, parseOfficeHoursVerdictReceipt, renderOfficeHoursReview, renderOfficeHoursReviewerPrompt, replaceOfficeHoursReviewBlock, verifyOfficeHoursCitations, verifyOfficeHoursVerdictReceipt } from '../lib/office-hours-review.ts';
import { parseSpecFixes, verifySpecFixesAtCap } from '../lib/spec-review-cap.ts';
const shellQuote = value => `'${value.replaceAll("'", `'\\''`)}'`;
function main(args) {
const command = args.shift();
if (!['check', 'prepare', 'seal', 'finalize'].includes(command)) throw new Error('usage: gstack-office-hours-review check --receipt <reviewer response> <round.json...> | prepare --design <doc> --out-dir <reviewdir> [round.json...] | seal --design <doc> --verdict <round-N.json> [preceding round.json...] | finalize --design <doc> [--report <report>] [--unreviewed <reason>] [--fixes <fixes.json>] <round.json...> (each also takes --max-rounds 1|2|3, default 3)');
const options = {};
const files = [];
while (args.length) {
const value = args.shift();
if (value.startsWith('--')) {
const allowed = [...{ check: ['--receipt'], prepare: ['--design', '--out-dir'], seal: ['--design', '--verdict'], finalize: ['--design', '--report', '--unreviewed', '--fixes'] }[command], '--max-rounds'];
if (!allowed.includes(value)
|| options[value] !== undefined || !args.length) throw new Error(`invalid option: ${value}`);
options[value] = args.shift();
} else files.push(value);
}
const maxRounds = officeHoursMaxRounds(options['--max-rounds']);
const rounds = loadOfficeHoursReviews(files, maxRounds);
if (command === 'check') {
// The receipt binds the reviewer's response to the last round's exact bytes.
if (!options['--receipt'] || !files.length) throw new Error('check requires --receipt with the reviewer response and every completed round');
const last = files.at(-1);
const named = parseOfficeHoursVerdictReceipt(options['--receipt']).path;
const same = fs.existsSync(named) && fs.realpathSync(named) === fs.realpathSync(last);
verifyOfficeHoursVerdictReceipt(options['--receipt'], { round: files.length, verdictPath: same ? named : last, bytes: fs.readFileSync(last) });
console.log(JSON.stringify(assessOfficeHoursReviews(rounds, maxRounds)));
return;
}
if (!options['--design']) throw new Error(`${command} requires --design`);
const designPath = path.resolve(options['--design']);
if (rounds.some(review => path.resolve(review.document) !== designPath)) throw new Error('artifact document does not match --design');
if (command === 'seal') {
if (!options['--verdict']) throw new Error('seal requires --verdict');
const verdictPath = path.resolve(options['--verdict']);
const round = rounds.length + 1;
if (path.basename(verdictPath) !== `round-${round}.json`) throw new Error(`verdict must be round-${round}.json after the supplied history`);
const bytes = fs.readFileSync(verdictPath);
let value;
try { value = JSON.parse(bytes.toString('utf8')); }
catch (error) { throw new Error(`saved verdict is invalid JSON: ${error instanceof Error ? error.message : String(error)}`); }
const review = assessOfficeHoursReviews([...rounds, value], maxRounds).rounds.at(-1);
if (path.resolve(review.document) !== designPath) throw new Error('verdict document does not match --design');
if (round > 1) verifyOfficeHoursCitations(review, officeHoursSnapshotChanges(verdictPath, round).changed);
console.log(officeHoursVerdictReceipt(round, verdictPath, bytes));
return;
}
if (command === 'prepare') {
if (!options['--out-dir']) throw new Error('prepare requires --out-dir');
if (!fs.statSync(designPath).isFile()) throw new Error('design must be an existing file');
const outDir = fs.realpathSync(options['--out-dir']);
if (!fs.statSync(outDir).isDirectory()) throw new Error('out-dir must be an existing directory');
const round = rounds.length + 1;
const promptPath = path.join(outDir, `round-${round}.prompt.md`);
const verdictPath = path.join(outDir, `round-${round}.json`);
// Each round records the exact design it reviewed; later rounds see the diff since then.
const snapshotPath = path.join(outDir, `round-${round}.design.md`);
const design = fs.readFileSync(designPath, 'utf8');
let changes;
if (round > 1 && assessOfficeHoursReviews(rounds, maxRounds).stop !== 'CONTINUE') throw new Error('cannot prepare another round after a terminal review');
if (round > 1) {
const before = path.join(outDir, `round-${round - 1}.design.md`);
if (!fs.existsSync(before)) throw new Error(`out-dir lacks round-${round - 1}.design.md; prepare every round in the same review directory`);
changes = officeHoursDesignChanges(fs.readFileSync(before, 'utf8'), design).diff;
}
const prompt = renderOfficeHoursReviewerPrompt({ document: designPath, verdictPath, previous: rounds.at(-1), changes, maxRounds });
const inputs = new Set([fs.realpathSync(designPath), ...files.map(file => fs.realpathSync(file))]);
for (const destination of [promptPath, verdictPath, snapshotPath]) {
if (fs.existsSync(destination) && inputs.has(fs.realpathSync(destination))) throw new Error('output destination overlaps input evidence');
}
if (fs.lstatSync(verdictPath, { throwIfNoEntry: false })) throw new Error('next verdict already exists; check it before preparing another attempt');
const captured = fs.lstatSync(snapshotPath, { throwIfNoEntry: false });
if (captured && (!captured.isFile() || fs.readFileSync(snapshotPath, 'utf8') !== design)) throw new Error(`the design changed after round ${round} was prepared`);
const existing = fs.lstatSync(promptPath, { throwIfNoEntry: false });
if (existing && (!existing.isFile() || fs.readFileSync(promptPath, 'utf8') !== prompt)) throw new Error('existing review prompt differs from the complete saved evidence');
if (!captured) atomicWriteSync(snapshotPath, design, { mode: 0o600 });
if (!existing) atomicWriteSync(promptPath, prompt, { mode: 0o600 });
const seal = [shellQuote(fs.realpathSync(process.argv[1])), 'seal', ...(maxRounds === 3 ? [] : [`--max-rounds ${maxRounds}`]), '--design', shellQuote(designPath), '--verdict', shellQuote(verdictPath), ...files.map(file => shellQuote(fs.realpathSync(file)))].join(' ');
const dispatch = `Read ${JSON.stringify(promptPath)} and execute the independent spec review as instructed.\nDocument: ${designPath}\nPrompt: ${promptPath}\nVerdict: ${verdictPath}\nSeal: ${seal}`;
console.log(JSON.stringify({ round, promptPath, verdictPath, dispatch }));
return;
}
const reportPath = options['--report'] ? path.resolve(options['--report']) : null;
if (reportPath === designPath) throw new Error('design and report must be different files');
const inputs = new Set(files.map(file => fs.realpathSync(file)));
for (const destination of [designPath, reportPath].filter(Boolean)) {
if (inputs.has(fs.realpathSync(destination))) throw new Error('output destination overlaps a review artifact');
}
if (reportPath && fs.realpathSync(reportPath) === fs.realpathSync(designPath)) throw new Error('design and report must be different files');
// At the cap (blocking findings remain) the finalizer verifies listed fixes deterministically; it never launches a reviewer.
const stop = rounds.length && options['--unreviewed'] === undefined ? assessOfficeHoursReviews(rounds, maxRounds).stop : null;
let cap = null;
if (stop === 'MAX_ITERATIONS' || stop === 'CONVERGENCE') {
const last = rounds.at(-1)!;
const snapshotPath = path.join(path.dirname(files.at(-1)!), `round-${last.round}.design.md`);
if (!fs.existsSync(snapshotPath)) throw new Error(`round ${last.round} lacks its captured design snapshot ${snapshotPath}`);
const fixes = options['--fixes'] ? parseSpecFixes(JSON.parse(fs.readFileSync(options['--fixes'], 'utf8'))) : null;
// The finalizer's own owned block is not a design change: compare the documents without it.
const withoutConcerns = (text) => { const block = extractOfficeHoursReviewBlock(text, 'concerns'); return block ? text.replace(block, '') : text; };
cap = verifySpecFixesAtCap(last, withoutConcerns(fs.readFileSync(snapshotPath, 'utf8')), withoutConcerns(fs.readFileSync(designPath, 'utf8')), fixes);
} else if (options['--fixes']) throw new Error('--fixes applies only when the review stopped at the cap with blocking findings');
const rendered = renderOfficeHoursReview(rounds, options['--unreviewed'], maxRounds, cap);
// Validate/read/render both destinations before publishing either. Each rename
// is atomic; any publication error fails the command and final verification.
const design = fs.readFileSync(designPath, 'utf8');
const report = reportPath ? fs.readFileSync(reportPath, 'utf8') : '';
const nextDesign = replaceOfficeHoursReviewBlock(design, 'concerns', rendered.concerns);
const nextReport = reportPath ? replaceOfficeHoursReviewBlock(report, 'report', rendered.report) : null;
if (nextDesign !== design) atomicWriteSync(designPath, nextDesign, { mode: fs.statSync(designPath).mode & 0o777 });
if (reportPath && nextReport !== null && nextReport !== report) atomicWriteSync(reportPath, nextReport, { mode: fs.statSync(reportPath).mode & 0o777 });
console.log(JSON.stringify({ stop: rendered.stop, metrics: rendered.metrics, report: rendered.report, cap: cap && { line: cap.line, decision: cap.decision, verified: cap.verified, unconfirmed: cap.unconfirmed } }));
}
try { main(process.argv.slice(2)); }
catch (error) {
console.error(`gstack-office-hours-review: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}