Files
gstack/test/helpers/qa-evidence-producer.ts
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

113 lines
6.9 KiB
TypeScript

import * as fs from 'node:fs';
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import { readQaCapture } from '../../lib/qa-evidence';
export const QA_EVIDENCE_RUNTIME = [
'bin/gstack-qa-evidence', 'bin/gstack-qa-deadline', 'lib/qa-evidence.ts', 'lib/qa-deadline.ts', 'lib/bun-isolation.ts',
'lib/claude-code-windows-job.ts', 'lib/fs-atomic.ts', 'lib/redact-engine.ts', 'lib/redact-patterns.ts',
];
export function qaEvidenceRuntimeFiles(): Record<string, string> {
return Object.fromEntries(QA_EVIDENCE_RUNTIME.map(file => [file, fs.readFileSync(path.resolve(import.meta.dir, '../..', file), 'utf8')]));
}
export interface QaEvidenceContext {
cwd: string;
reportRoot: string;
executable: string;
}
export interface QaEvidenceCommand {
action: 'capture' | 'checkpoint' | 'materialize';
id?: string;
source?: string;
nativeCommand?: string;
argv?: string[];
deadline?: string;
timeoutMs?: number;
publicOutput?: boolean;
intent?: { capture: string; observationCommand: string; hypothesis: string; nextCommand: string };
after?: { capture: string; hypothesis: string };
}
export function qaEvidenceCommand(command: string, context?: QaEvidenceContext): QaEvidenceCommand | undefined {
if (!context || typeof command !== 'string' || /[\r\n]/.test(command)) return;
const matches = [...command.matchAll(/'[^'\r\n]*'|"[^"\\$`\r\n]*"|[^\s'"\\;&|<>`$(){}*?\[\]~#]+/g)];
if (!matches.length || command.slice(0, matches[0].index).trim() || command.slice(matches.at(-1)!.index! + matches.at(-1)![0].length).trim()) return;
for (let index = 1; index < matches.length; index++) {
if (!/^ +$/.test(command.slice(matches[index - 1].index! + matches[index - 1][0].length, matches[index].index))) return;
}
const tokens = matches.map(match => /^['"]/.test(match[0]) ? match[0].slice(1, -1) : match[0]);
if ([tokens[1], tokens[3]].some(value => value?.split(/[\\/]/).includes('..'))) return;
if (tokens[0] !== 'bun' || path.resolve(context.cwd, tokens[1] ?? '') !== context.executable
|| path.resolve(context.cwd, tokens[3] ?? '') !== context.reportRoot) return;
const source = (value: string | undefined) => value && !value.split(/[\\/]/).includes('..')
&& path.resolve(context.reportRoot, value).startsWith(context.reportRoot + path.sep) ? path.resolve(context.reportRoot, value) : undefined;
if (tokens[2] === 'materialize' && tokens.length === 5 && source(tokens[4])) return { action: 'materialize', source: source(tokens[4]) };
if (!/^\d{3}$/.test(tokens[4] ?? '')) return;
if (tokens[2] === 'checkpoint' && tokens.length === 6 && source(tokens[5])) return { action: 'checkpoint', id: tokens[4], source: source(tokens[5]) };
if (tokens[2] === 'checkpoint' && tokens.length === 9 && /^\d{3}$/.test(tokens[5])) return { action: 'checkpoint', id: tokens[4],
intent: { capture: tokens[5], observationCommand: tokens[6], hypothesis: tokens[7], nextCommand: tokens[8] } };
const publicOutput = tokens[5] === '--public';
const option = publicOutput ? 6 : 5;
const after = tokens[option + 2] === '--after' && /^\d{3}$/.test(tokens[option + 3] ?? '') && tokens[option + 4] === '--hypothesis'
? { capture: tokens[option + 3], hypothesis: tokens[option + 5] ?? '' } : undefined;
if (after) {
tokens.splice(option + 2, 4);
matches.splice(option + 2, 4);
}
if (tokens[2] !== 'capture' || tokens[option + 2] !== '--' || tokens.length < option + 4) return;
const deadline = tokens[option] === '--deadline' ? source(path.resolve(context.cwd, tokens[option + 1])) : undefined;
if (tokens[option] === '--deadline' && !deadline) return;
if (tokens[option] === '--timeout-ms' && (!/^[1-9]\d*$/.test(tokens[option + 1]) || Number(tokens[option + 1]) > 2_147_483_647)) return;
if (!['--deadline', '--timeout-ms'].includes(tokens[option])) return;
return { action: 'capture', id: tokens[4], publicOutput, argv: tokens.slice(option + 3), nativeCommand: command.slice(matches[option + 3].index).trim(),
...(tokens[option] === '--deadline' ? { deadline } : { timeoutMs: Number(tokens[option + 1]) }), ...(after ? { after } : {}) };
}
export type QaProducerCall = { name: string; input: Record<string, any>; output: string; failed: boolean; start: number; end: number };
export function qaProducerReceipt(call: QaProducerCall, context?: QaEvidenceContext, status: 'complete' | 'incomplete' = 'complete') {
if (call.name !== 'Bash' || call.end <= call.start) return;
const command = qaEvidenceCommand(call.input.command, context);
if (!command) return;
try {
const lines = call.output.split('\n').filter(line => line.startsWith('QA_EVIDENCE '));
if (lines.length !== 1) return;
const receipt = JSON.parse(lines[0].slice('QA_EVIDENCE '.length));
const exitCode = call.failed ? Number(/^Exit code (\d+)\n/.exec(call.output)?.[1]) : 0;
if (receipt.producer !== 'gstack-qa-evidence' || receipt.version !== 1 || receipt.action !== command.action
|| receipt.status !== status || !/^[a-f0-9]{64}$/.test(receipt.sha256)
|| receipt.exitCode !== exitCode
|| (command.id !== undefined && receipt.id !== command.id)
|| (command.after ? receipt.checkpoint !== command.id || !/^[a-f0-9]{64}$/.test(receipt.checkpointSha256) : receipt.checkpoint !== undefined)
|| (call.failed && (command.action !== 'capture' || receipt.exitCode === 0))) return;
return { command, receipt };
} catch { return; }
}
export function qaNativeCapture(call: QaProducerCall, context?: QaEvidenceContext) {
const producer = qaProducerReceipt(call, context);
if (!context || producer?.command.action !== 'capture') return;
try {
const captured = readQaCapture(context.reportRoot, producer.command.id!, producer.receipt.sha256);
if (captured.receipt.cwd !== context.cwd || !isDeepStrictEqual(captured.receipt.argv, producer.command.argv)
|| captured.receipt.exitCode !== producer.receipt.exitCode || captured.receipt.signal !== producer.receipt.signal
|| captured.receipt.publicOutput !== producer.command.publicOutput || captured.receipt.publicOutput !== producer.receipt.publicOutput
|| (producer.command.deadline !== undefined && captured.receipt.deadline !== producer.command.deadline)) return;
if (producer.command.publicOutput && call.output.split('\n').filter(line => line === JSON.stringify(captured.observed)).length !== 1) return;
if (producer.command.timeoutMs !== undefined) {
const deadline = JSON.parse(fs.readFileSync(path.join(context.reportRoot, `.qa-evidence/${producer.command.id}/deadline.json`), 'utf8'));
if (captured.receipt.deadline !== path.join(context.reportRoot, `.qa-evidence/${producer.command.id}/deadline.json`)
|| deadline.budgetMs !== producer.command.timeoutMs) return;
}
return { ...producer, captured };
} catch { return; }
}
export function qaEvidenceHash(bytes: string): string {
return createHash('sha256').update(bytes).digest('hex');
}