mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 19:20:26 +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>
113 lines
6.9 KiB
TypeScript
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');
|
|
}
|