mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
v1.91.7.0 feat: add functional QA and pre-publication docs checks (#2983)
* feat: add surface-aware exploratory QA and ship documentation gates * test: preserve delegated QA setup authority after main integration * fix(qa): clarify exploration order and preserve report artifacts * test(qa): follow the shared setup reference directly * refactor(ship): make verification and recovery routes explicit * test(ship): align evidence and review guards with explicit routes * fix(workflows): clarify ship recovery and functional QA evidence * fix(workflows): clarify approval recovery and full QA coverage * refactor(workflows): order review transactions and clarify ship state * fix(ship): clarify final verification and fail closed at publication * fix(evals): attribute native atomic documentation writes * fix(ship): clarify recovery and documentation lifecycle guidance * fix(test): preserve observed native placeholder styling in CI * fix(codex): report watchdog timeouts without a process-exit race * Checkpoint functional QA implementation and workflow validation repairs * Fix documentation and shared-review fixture contracts * docs: clarify judge reuse and evaluation supervision * test: align review evidence and selected case contracts * test: verify append-only documentation checkpoints and recovery * fix: qualify QA workflows and CI validation repairs * fix: launch shared-libs fixture scripts on Windows * fix: qualify QA deadlines, fixture isolation, and shard cleanup * fix: preserve qualified QA and cancellation repairs * fix: enforce functional fixture authority and share strict event decoding * fix: retain free-test evidence and explain recovery * fix: reject malformed native evidence after decoder consolidation * test: use reliable capture for telemetry privacy filters * test: refresh measured quick coverage and document validation costs * Fix native fixture receipts and preserve VM validation evidence * Align negative judge controls with upstream clarity policy * Fix report-only QA preparation and public evidence handling * Clarify QA-only preparation and current-report preservation * Stream Ship quality judgments with an explicit 64k response contract * Validate compact judge reasoning locally with supported wire schema * Align functional QA fixture instructions with evidence acceptance * Bind native browser diagnostics to execution evidence and align review verdicts * Preserve native diagnostic line boundaries * Serialize functional QA evidence from native captures * Keep large QA evidence fixture payload out of Windows argv
This commit is contained in:
1 parent
65bfb0ce49
commit
dcaea52800
333 files changed
+41755
-7357
No files matched your search
+6
-6
@@ -210,20 +210,20 @@ export class DockerGroup {
|
||||
'--network',joinAnchor?`container:${this.anchor}`:'none','--platform',process.arch==='arm64'?'linux/arm64':'linux/amd64'];
|
||||
const expectedTmpfs=new Set(['/tmp','/work']),expectedMounts=new Set<string>();
|
||||
for(const [k,v] of Object.entries(spec.env??{})){if(!/^[A-Z][A-Z0-9_]{0,63}$/.test(k)||v.includes('\0'))throw new CsoError('INVALID_SCHEMA','Invalid explicit container environment');args.push('--env',`${k}=${v}`);}
|
||||
if(spec.source){const stat=fs.lstatSync(spec.source),real=fs.realpathSync(spec.source);if(!stat.isDirectory()||stat.isSymbolicLink()||real.includes(','))throw new CsoError('UNSAFE_PATH','Execution source must be one unambiguous private directory');args.push('--mount',`type=bind,src=${real},dst=/source,readonly,bind-nonrecursive`);expectedMounts.add('/source');}
|
||||
for(const f of spec.readonlyFiles??[]){const stat=fs.lstatSync(f.host),real=fs.realpathSync(f.host);if(!stat.isFile()||stat.isSymbolicLink()||real.includes(',')||!f.container.startsWith('/policy/'))throw new CsoError('UNSAFE_PATH','Trusted policy mounts must be regular files under /policy');args.push('--mount',`type=bind,src=${real},dst=${f.container},readonly,bind-nonrecursive`);expectedMounts.add(f.container);}
|
||||
if(spec.postgresDatabasePolicy){if(spec.role!=='postgres')throw new CsoError('INVALID_SCHEMA','PostgreSQL database policy can only be mounted into the fixed database role');validatePostgresDatabasePolicy(spec.postgresDatabasePolicy);args.push('--mount',`type=bind,src=${fs.realpathSync(spec.postgresDatabasePolicy)},dst=/policy/postgresql.databases,readonly,bind-nonrecursive`);expectedMounts.add('/policy/postgresql.databases');}
|
||||
for(const d of spec.readonlyDirectories??[]){const stat=fs.lstatSync(d.host),real=fs.realpathSync(d.host);if(!stat.isDirectory()||stat.isSymbolicLink()||real.includes(',')||d.container!=='/fixtures')throw new CsoError('UNSAFE_PATH','Fixture mounts must be private directories at /fixtures');args.push('--mount',`type=bind,src=${real},dst=${d.container},readonly,bind-nonrecursive`);expectedMounts.add(d.container);}
|
||||
if(spec.source){const stat=fs.lstatSync(spec.source),real=fs.realpathSync(spec.source);if(!stat.isDirectory()||stat.isSymbolicLink()||real.includes(','))throw new CsoError('UNSAFE_PATH','Execution source must be one unambiguous private directory');args.push('--mount',`type=bind,src=${real},dst=/source,readonly,bind-recursive=disabled`);expectedMounts.add('/source');}
|
||||
for(const f of spec.readonlyFiles??[]){const stat=fs.lstatSync(f.host),real=fs.realpathSync(f.host);if(!stat.isFile()||stat.isSymbolicLink()||real.includes(',')||!f.container.startsWith('/policy/'))throw new CsoError('UNSAFE_PATH','Trusted policy mounts must be regular files under /policy');args.push('--mount',`type=bind,src=${real},dst=${f.container},readonly,bind-recursive=disabled`);expectedMounts.add(f.container);}
|
||||
if(spec.postgresDatabasePolicy){if(spec.role!=='postgres')throw new CsoError('INVALID_SCHEMA','PostgreSQL database policy can only be mounted into the fixed database role');validatePostgresDatabasePolicy(spec.postgresDatabasePolicy);args.push('--mount',`type=bind,src=${fs.realpathSync(spec.postgresDatabasePolicy)},dst=/policy/postgresql.databases,readonly,bind-recursive=disabled`);expectedMounts.add('/policy/postgresql.databases');}
|
||||
for(const d of spec.readonlyDirectories??[]){const stat=fs.lstatSync(d.host),real=fs.realpathSync(d.host);if(!stat.isDirectory()||stat.isSymbolicLink()||real.includes(',')||d.container!=='/fixtures')throw new CsoError('UNSAFE_PATH','Fixture mounts must be private directories at /fixtures');args.push('--mount',`type=bind,src=${real},dst=${d.container},readonly,bind-recursive=disabled`);expectedMounts.add(d.container);}
|
||||
if(Boolean(spec.readonlyArchiveDirectory)&&Boolean(spec.archiveTmpfsBytes))throw new CsoError('INVALID_SCHEMA','Preparation requires exactly one archive storage policy');
|
||||
if(Boolean(spec.readonlyMetadata)&&Boolean(spec.metadataTmpfsBytes))throw new CsoError('INVALID_SCHEMA','Preparation requires exactly one metadata storage policy');
|
||||
if(Boolean(spec.readonlyInputMetadata)!==Boolean(spec.metadataTmpfsBytes))throw new CsoError('INVALID_SCHEMA','Writable metadata tmpfs requires a separate read-only metadata input');
|
||||
const directoryMount=(host:string,destination:string,readonly:boolean)=>{const stat=fs.lstatSync(host),real=fs.realpathSync(host);if(!stat.isDirectory()||stat.isSymbolicLink()||real.includes(',')||(process.getuid&&stat.uid!==process.getuid())||(stat.mode&0o022)!==0)throw new CsoError('UNSAFE_PATH',`Preparation ${destination} mount must be one private owned directory`);args.push('--mount',`type=bind,src=${real},dst=${destination}${readonly?',readonly':''},bind-nonrecursive`);expectedMounts.add(destination);};
|
||||
const directoryMount=(host:string,destination:string,readonly:boolean)=>{const stat=fs.lstatSync(host),real=fs.realpathSync(host);if(!stat.isDirectory()||stat.isSymbolicLink()||real.includes(',')||(process.getuid&&stat.uid!==process.getuid())||(stat.mode&0o022)!==0)throw new CsoError('UNSAFE_PATH',`Preparation ${destination} mount must be one private owned directory`);args.push('--mount',`type=bind,src=${real},dst=${destination}${readonly?',readonly':''},bind-recursive=disabled`);expectedMounts.add(destination);};
|
||||
if(spec.readonlyMetadata)directoryMount(spec.readonlyMetadata,'/metadata',true);
|
||||
if(spec.readonlyInputMetadata)directoryMount(spec.readonlyInputMetadata,'/input-metadata',true);
|
||||
if(spec.metadataTmpfsBytes){if(!Number.isSafeInteger(spec.metadataTmpfsBytes)||spec.metadataTmpfsBytes<=0||spec.metadataTmpfsBytes>1024*1024*1024)throw new CsoError('INVALID_SCHEMA','Preparation metadata tmpfs exceeds the 1 GiB policy');args.push('--tmpfs',`/metadata:rw,noexec,nosuid,nodev,size=${spec.metadataTmpfsBytes},mode=700,uid=${uid},gid=${gid}`);expectedTmpfs.add('/metadata');}
|
||||
if(spec.archiveTmpfsBytes){if(!Number.isSafeInteger(spec.archiveTmpfsBytes)||spec.archiveTmpfsBytes<=0||spec.archiveTmpfsBytes>2*1024*1024*1024)throw new CsoError('INVALID_SCHEMA','Preparation archive tmpfs exceeds the 2 GiB group storage policy');args.push('--tmpfs',`/archives:rw,noexec,nosuid,nodev,size=${spec.archiveTmpfsBytes},mode=700,uid=${uid},gid=${gid}`);expectedTmpfs.add('/archives');}
|
||||
if(spec.readonlyArchiveDirectory)directoryMount(spec.readonlyArchiveDirectory,'/archives',true);
|
||||
if(spec.registrySocket){const stat=fs.lstatSync(spec.registrySocket),real=fs.realpathSync(spec.registrySocket);if(!stat.isSocket()||stat.isSymbolicLink()||real.includes(',')||(process.getuid&&stat.uid!==process.getuid()))throw new CsoError('UNSAFE_PATH','Registry broker mount must be one owned Unix socket');args.push('--mount',`type=bind,src=${real},dst=/run/cso-registry.sock,readonly,bind-nonrecursive`);expectedMounts.add('/run/cso-registry.sock');}
|
||||
if(spec.registrySocket){const stat=fs.lstatSync(spec.registrySocket),real=fs.realpathSync(spec.registrySocket);if(!stat.isSocket()||stat.isSymbolicLink()||real.includes(',')||(process.getuid&&stat.uid!==process.getuid()))throw new CsoError('UNSAFE_PATH','Registry broker mount must be one owned Unix socket');args.push('--mount',`type=bind,src=${real},dst=/run/cso-registry.sock,readonly,bind-recursive=disabled`);expectedMounts.add('/run/cso-registry.sock');}
|
||||
args.push('--entrypoint','/opt/cso/entrypoint',spec.image,...spec.command);
|
||||
const id=await this.docker(args,8192); if(!/^[a-f0-9]{64}$/.test(id))throw new CsoError('ISOLATION_FAILED','Docker did not return a stable container ID');
|
||||
let inspectedContainer:any;try{inspectedContainer=JSON.parse(await this.docker(['inspect','--format','{{json .}}',id],64*1024));}catch{throw new CsoError('ISOLATION_FAILED','Docker did not return valid admitted-container configuration');}const hostConfig=inspectedContainer?.HostConfig,mounts=inspectedContainer?.Mounts;
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { constants as osConstants } from 'node:os';
|
||||
import { initializeWindowsReviewJob } from './claude-code-windows-job';
|
||||
|
||||
const MAX_MS = 2_147_483_647;
|
||||
class QaDeadlineError extends Error {}
|
||||
type QaCommandResult = { exitCode: number; signal: NodeJS.Signals | null; completed: boolean };
|
||||
type Emit = (stream: 'stdout' | 'stderr', receipt: Record<string, unknown>, completion?: QaCommandResult) => void;
|
||||
|
||||
export interface QaDeadline {
|
||||
version: 1;
|
||||
startedAt: string;
|
||||
deadlineAt: string;
|
||||
budgetMs: number;
|
||||
}
|
||||
|
||||
function utc(value: unknown): number {
|
||||
if (typeof value !== 'string') throw new QaDeadlineError('Invalid UTC timestamp');
|
||||
const ms = Date.parse(value);
|
||||
if (!Number.isFinite(ms)) throw new QaDeadlineError('Invalid UTC timestamp');
|
||||
const canonical = new Date(ms).toISOString();
|
||||
if (value !== canonical && value !== canonical.replace('.000Z', 'Z')) throw new QaDeadlineError('Invalid UTC timestamp');
|
||||
return ms;
|
||||
}
|
||||
|
||||
function checkedPath(file: string): string {
|
||||
if (!file || file.includes('\0') || file.split(/[\\/]/).some(part => part === '..')) {
|
||||
throw new QaDeadlineError('Invalid deadline path');
|
||||
}
|
||||
const absolute = path.resolve(file);
|
||||
const root = path.parse(absolute).root;
|
||||
const parts = absolute.slice(root.length).split(path.sep);
|
||||
if (!parts.at(-1)) throw new QaDeadlineError('Invalid deadline path');
|
||||
let current = root;
|
||||
for (const [index, part] of parts.entries()) {
|
||||
if (process.platform === 'win32' && (/[<>:"|?*\x00-\x1f]/.test(part) || /[. ]$/.test(part))) {
|
||||
throw new QaDeadlineError('Invalid deadline path');
|
||||
}
|
||||
current = path.join(current, part);
|
||||
let stat: fs.Stats;
|
||||
try { stat = fs.lstatSync(current); } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT' && index === parts.length - 1) break;
|
||||
throw new QaDeadlineError('Deadline parent directory is unavailable');
|
||||
}
|
||||
if (stat.isSymbolicLink()) throw new QaDeadlineError('Symlinked deadline paths are forbidden');
|
||||
if (index < parts.length - 1 && !stat.isDirectory()) throw new QaDeadlineError('Invalid deadline parent directory');
|
||||
}
|
||||
return absolute;
|
||||
}
|
||||
|
||||
export function startQaDeadline(file: string, seconds: string, earlierUtc?: string): QaDeadline {
|
||||
if (!/^(?:0|[1-9]\d*)(?:\.\d{1,3})?$/.test(seconds)) throw new QaDeadlineError('Invalid deadline duration');
|
||||
const [whole, fraction = ''] = seconds.split('.');
|
||||
const budgetMs = Number(whole) * 1000 + Number(fraction.padEnd(3, '0'));
|
||||
if (!Number.isSafeInteger(budgetMs) || budgetMs <= 0 || budgetMs > MAX_MS) throw new QaDeadlineError('Invalid deadline duration');
|
||||
const started = Date.now();
|
||||
const earlier = earlierUtc === undefined ? Infinity : utc(earlierUtc);
|
||||
const state: QaDeadline = {
|
||||
version: 1,
|
||||
startedAt: new Date(started).toISOString(),
|
||||
deadlineAt: new Date(Math.min(started + budgetMs, earlier)).toISOString(),
|
||||
budgetMs,
|
||||
};
|
||||
const target = checkedPath(file);
|
||||
const temporary = path.join(path.dirname(target), `.qa-deadline-${randomUUID()}`);
|
||||
let fd: number | undefined;
|
||||
let created = false;
|
||||
try {
|
||||
fd = fs.openSync(temporary, 'wx', 0o600);
|
||||
created = true;
|
||||
fs.writeFileSync(fd, JSON.stringify(state) + '\n');
|
||||
fs.fsyncSync(fd);
|
||||
fs.fchmodSync(fd, 0o400);
|
||||
fs.closeSync(fd);
|
||||
fd = undefined;
|
||||
fs.linkSync(temporary, target);
|
||||
} catch {
|
||||
throw new QaDeadlineError('Cannot create deadline receipt; it must not already exist');
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
if (created) fs.rmSync(temporary, { force: true });
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function readQaDeadline(file: string): QaDeadline {
|
||||
const target = checkedPath(file);
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
|
||||
const stat = fs.fstatSync(fd);
|
||||
if (!stat.isFile() || stat.size > 4096 || stat.nlink !== 1) throw new Error();
|
||||
const state = JSON.parse(fs.readFileSync(fd, 'utf8'));
|
||||
if (!state || Object.keys(state).sort().join(',') !== 'budgetMs,deadlineAt,startedAt,version'
|
||||
|| state.version !== 1 || !Number.isSafeInteger(state.budgetMs) || state.budgetMs <= 0 || state.budgetMs > MAX_MS
|
||||
|| utc(state.deadlineAt) > utc(state.startedAt) + state.budgetMs) throw new Error();
|
||||
return state;
|
||||
} catch {
|
||||
throw new QaDeadlineError('Missing or malformed deadline receipt');
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
export function qaDeadlineStatus(state: QaDeadline) {
|
||||
const now = Date.now();
|
||||
if (now < utc(state.startedAt)) throw new QaDeadlineError('Clock moved before deadline start; refusing dispatch');
|
||||
const remainingMs = Math.max(0, utc(state.deadlineAt) - now);
|
||||
return { ...state, observedAt: new Date(now).toISOString(), remainingMs, expired: remainingMs === 0 };
|
||||
}
|
||||
|
||||
export interface QaCommandCapture {
|
||||
write(stream: 'stdout' | 'stderr', chunk: Buffer): void;
|
||||
complete(result: QaCommandResult): void;
|
||||
}
|
||||
|
||||
export async function runQaDeadlineCommand(file: string, command: string, args: string[], emit: Emit, capture?: QaCommandCapture): Promise<number> {
|
||||
if (!['linux', 'darwin', 'win32'].includes(process.platform)) throw new QaDeadlineError('Process containment is unavailable on this platform');
|
||||
let status = qaDeadlineStatus(readQaDeadline(file));
|
||||
if (status.expired) {
|
||||
emit('stderr', { event: 'expired', ...status });
|
||||
return 124;
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
try { await initializeWindowsReviewJob(); } catch { throw new QaDeadlineError('Windows process containment is unavailable; no command was started'); }
|
||||
}
|
||||
status = qaDeadlineStatus(readQaDeadline(file));
|
||||
if (status.expired) {
|
||||
emit('stderr', { event: 'expired', ...status });
|
||||
return 124;
|
||||
}
|
||||
return new Promise<number>(resolve => {
|
||||
status = qaDeadlineStatus(status);
|
||||
if (status.expired) {
|
||||
emit('stderr', { event: 'expired', ...status });
|
||||
resolve(124);
|
||||
return;
|
||||
}
|
||||
let outcome: number | undefined;
|
||||
let settled = false;
|
||||
const child = spawn(command, args, { detached: process.platform !== 'win32', stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', windowsHide: true });
|
||||
const kill = () => {
|
||||
if (!child.pid) return;
|
||||
try {
|
||||
if (process.platform === 'win32') child.kill('SIGKILL');
|
||||
else process.kill(-child.pid, 'SIGKILL');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') outcome = 2;
|
||||
}
|
||||
};
|
||||
const finish = (code: number, signal: NodeJS.Signals | null = null, completed = false) => {
|
||||
const finishedAt = Date.now();
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (outcome === undefined && finishedAt >= utc(status.deadlineAt)) outcome = 124;
|
||||
clearTimeout(timer);
|
||||
kill();
|
||||
process.off('SIGINT', interrupt);
|
||||
process.off('SIGTERM', terminate);
|
||||
process.off('SIGHUP', hangup);
|
||||
process.off('exit', kill);
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
const completion = { exitCode: outcome ?? code, signal, completed: completed && outcome === undefined && signal === null };
|
||||
emit('stderr', { event: 'finished', observedAt: new Date(finishedAt).toISOString(),
|
||||
deadlineAt: status.deadlineAt, timedOut: outcome === 124, exitCode: outcome ?? code }, completion);
|
||||
capture?.complete(completion);
|
||||
resolve(outcome ?? code);
|
||||
};
|
||||
const stop = (code: number) => { if (settled) return; outcome ??= code; kill(); finish(code); };
|
||||
const interrupt = () => stop(130);
|
||||
const terminate = () => stop(143);
|
||||
const hangup = () => stop(129);
|
||||
process.on('SIGINT', interrupt);
|
||||
process.on('SIGTERM', terminate);
|
||||
process.on('SIGHUP', hangup);
|
||||
process.on('exit', kill);
|
||||
const timer = setTimeout(() => stop(124), Math.max(1, utc(status.deadlineAt) - Date.now()));
|
||||
child.once('error', () => finish(127));
|
||||
child.once('exit', (code, signal) => {
|
||||
if (!capture) finish(code ?? (signal ? 128 + (osConstants.signals[signal] ?? 1) : 1), signal, true);
|
||||
else if (!settled) kill();
|
||||
});
|
||||
if (capture) {
|
||||
for (const stream of ['stdout', 'stderr'] as const) {
|
||||
child[stream]!.on('data', chunk => {
|
||||
if (settled) return;
|
||||
try { capture.write(stream, chunk); } catch { stop(2); }
|
||||
});
|
||||
child[stream]!.once('error', () => stop(2));
|
||||
}
|
||||
child.once('close', (code, signal) => finish(code ?? (signal ? 128 + (osConstants.signals[signal] ?? 1) : 1), signal,
|
||||
child.stdout!.readableEnded && child.stderr!.readableEnded));
|
||||
}
|
||||
emit('stderr', { event: 'started', ...status });
|
||||
});
|
||||
}
|
||||
|
||||
async function runWindowsWorker(args: string[], emit: Emit): Promise<number> {
|
||||
const status = qaDeadlineStatus(readQaDeadline(args[1]));
|
||||
if (status.expired) {
|
||||
emit('stderr', { event: 'expired', ...status });
|
||||
return 124;
|
||||
}
|
||||
return runQaWindowsWorker(args, emit, path.resolve(import.meta.dir, '../bin/gstack-qa-deadline'), 'qa-deadline-receipt');
|
||||
}
|
||||
|
||||
export async function runQaWindowsWorker(args: string[], emit: Emit, entrypoint: string, messageType: string,
|
||||
captureFiles?: { stdout: number; stderr: number }): Promise<number> {
|
||||
try { await initializeWindowsReviewJob(); } catch { throw new QaDeadlineError('Windows process containment is unavailable; no command was started'); }
|
||||
return new Promise<number>(resolve => {
|
||||
const worker = spawn(process.execPath, [...process.execArgv, entrypoint, '--receipt-worker', ...args], {
|
||||
stdio: ['inherit', captureFiles?.stdout ?? 'inherit', captureFiles?.stderr ?? 'inherit', 'ipc'], windowsHide: true,
|
||||
});
|
||||
const kill = () => { worker.kill('SIGKILL'); };
|
||||
process.on('exit', kill);
|
||||
worker.on('message', (message: any) => {
|
||||
if (message?.type === messageType && ['stdout', 'stderr'].includes(message.stream)
|
||||
&& message.receipt && typeof message.receipt === 'object') emit(message.stream, message.receipt, message.completion);
|
||||
});
|
||||
worker.once('error', () => {
|
||||
process.off('exit', kill);
|
||||
emit('stderr', { event: 'error', message: 'Cannot start deadline worker' });
|
||||
resolve(2);
|
||||
});
|
||||
worker.once('close', (code, signal) => {
|
||||
process.off('exit', kill);
|
||||
resolve(code ?? (signal ? 128 + (osConstants.signals[signal] ?? 1) : 2));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function withQaReceiptOutput(receiptWorker: boolean, messageType: string, format: (receipt: Record<string, unknown>) => string,
|
||||
run: (emit: Emit) => Promise<number>): Promise<number> {
|
||||
const output = {
|
||||
stdout: fs.createWriteStream('', { fd: 1, autoClose: false }),
|
||||
stderr: fs.createWriteStream('', { fd: 2, autoClose: false }),
|
||||
};
|
||||
const writes: Promise<void>[] = [];
|
||||
let writeFailed = false;
|
||||
const failed = () => { writeFailed = true; };
|
||||
output.stdout.on('error', failed);
|
||||
output.stderr.on('error', failed);
|
||||
const emit: Emit = (stream, receipt, completion) => {
|
||||
writes.push(new Promise<void>(resolve => {
|
||||
const done = (error?: Error | null) => { if (error) writeFailed = true; resolve(); };
|
||||
try {
|
||||
if (receiptWorker) process.send!({ type: messageType, stream, receipt, ...(completion ? { completion } : {}) }, done);
|
||||
else output[stream].write(format(receipt), done);
|
||||
} catch { writeFailed = true; resolve(); }
|
||||
}));
|
||||
};
|
||||
try { return await run(emit); }
|
||||
finally {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
await Promise.race([
|
||||
Promise.all(writes),
|
||||
new Promise<void>(resolve => { timer = setTimeout(() => { writeFailed = true; resolve(); }, 5000); }),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
if (writeFailed) return 2;
|
||||
}
|
||||
}
|
||||
|
||||
export async function qaDeadlineMain(args: string[], receiptWorker = false): Promise<number> {
|
||||
return withQaReceiptOutput(receiptWorker, 'qa-deadline-receipt', receipt => '\nQA_DEADLINE ' + JSON.stringify({ guard: 'qa-deadline', ...receipt }) + '\n', async emit => {
|
||||
try {
|
||||
const [action, file, ...rest] = args;
|
||||
if (action === 'start' && file && (rest.length === 1 || rest.length === 2)) {
|
||||
const status = qaDeadlineStatus(startQaDeadline(file, rest[0], rest[1]));
|
||||
emit('stdout', { event: 'start', ...status });
|
||||
return status.expired ? 124 : 0;
|
||||
}
|
||||
if (action === 'status' && file && rest.length === 0) {
|
||||
const status = qaDeadlineStatus(readQaDeadline(file));
|
||||
emit('stdout', { event: 'status', ...status });
|
||||
return status.expired ? 124 : 0;
|
||||
}
|
||||
if (action === 'run' && file && rest[0] === '--' && rest[1]) {
|
||||
if (process.platform === 'win32' && !receiptWorker) return await runWindowsWorker(args, emit);
|
||||
return await runQaDeadlineCommand(file, rest[1], rest.slice(2), emit);
|
||||
}
|
||||
throw new QaDeadlineError('Usage: gstack-qa-deadline start FILE SECONDS [EARLIER_UTC] | status FILE | run FILE -- COMMAND ARGS...');
|
||||
} catch (error) {
|
||||
emit('stderr', { event: 'error', message: error instanceof QaDeadlineError ? error.message : 'Deadline guard failed' });
|
||||
return 2;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { atomicWriteSync } from './fs-atomic';
|
||||
import { runQaDeadlineCommand, runQaWindowsWorker, startQaDeadline, withQaReceiptOutput } from './qa-deadline';
|
||||
import { scan } from './redact-engine';
|
||||
|
||||
const object = (value: unknown): value is Record<string, any> => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const hash = (value: string | Buffer) => createHash('sha256').update(value).digest('hex');
|
||||
const exact = (value: unknown, keys: string[]) => object(value) && Object.keys(value).sort().join(',') === keys.sort().join(',');
|
||||
class QaEvidenceError extends Error {}
|
||||
|
||||
function id(value: string): string {
|
||||
if (!/^\d{3}$/.test(value)) throw new QaEvidenceError('Capture and checkpoint IDs must be three digits');
|
||||
return value;
|
||||
}
|
||||
|
||||
export function qaEvidenceRoot(value: string): string {
|
||||
const root = path.resolve(value);
|
||||
if (!value || value.includes('\0') || value.split(/[\\/]/).includes('..') || root === path.parse(root).root
|
||||
|| fs.realpathSync(root) !== root || !fs.lstatSync(root).isDirectory()
|
||||
|| (process.getuid && fs.lstatSync(root).uid !== process.getuid())) throw new QaEvidenceError('Invalid report root');
|
||||
let current = root;
|
||||
while (current !== path.parse(current).root) {
|
||||
if (fs.lstatSync(current).isSymbolicLink()) throw new QaEvidenceError('Linked report root');
|
||||
current = path.dirname(current);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function owned(root: string, value: string): string {
|
||||
const target = path.resolve(root, value);
|
||||
if (!value || value.includes('\0') || value.split(/[\\/]/).includes('..') || !target.startsWith(root + path.sep)) throw new QaEvidenceError('Source must be inside the report root');
|
||||
let current = root;
|
||||
for (const part of path.relative(root, target).split(path.sep)) {
|
||||
current = path.join(current, part);
|
||||
const stat = fs.lstatSync(current, { throwIfNoEntry: false });
|
||||
if (stat && (stat.isSymbolicLink() || (!stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1)))) throw new QaEvidenceError('Linked or nonregular evidence path');
|
||||
if (stat && process.getuid && stat.uid !== process.getuid()) throw new QaEvidenceError('Evidence path has a different owner');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function read(root: string, name: string): Buffer {
|
||||
const target = owned(root, name);
|
||||
const fd = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
|
||||
try {
|
||||
const stat = fs.fstatSync(fd);
|
||||
const current = fs.lstatSync(target);
|
||||
if (!stat.isFile() || stat.nlink !== 1 || stat.ino !== current.ino || stat.dev !== current.dev) throw new QaEvidenceError('Changed evidence source');
|
||||
return fs.readFileSync(fd);
|
||||
} finally { fs.closeSync(fd); }
|
||||
}
|
||||
|
||||
function decode(bytes: Buffer): string {
|
||||
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
||||
}
|
||||
|
||||
function privateDirectory(root: string, name: string, exclusive = false): string {
|
||||
const target = owned(root, name);
|
||||
try { fs.mkdirSync(target, { mode: 0o700 }); }
|
||||
catch (error) { if (exclusive || (error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; }
|
||||
const stat = fs.lstatSync(target);
|
||||
if (!stat.isDirectory() || (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o700)) throw new QaEvidenceError('Evidence directory must be private');
|
||||
return target;
|
||||
}
|
||||
|
||||
function publish(root: string, name: string, value: unknown): string {
|
||||
const bytes = JSON.stringify(value, null, 2) + '\n';
|
||||
atomicWriteSync(owned(root, name), bytes, { mode: 0o600, noReplace: true });
|
||||
return hash(bytes);
|
||||
}
|
||||
|
||||
export function readQaCaptureRecord(reportRoot: string, captureId: string, expectedHash?: string) {
|
||||
const root = qaEvidenceRoot(reportRoot);
|
||||
const directory = `.qa-evidence/${id(captureId)}`;
|
||||
const receiptBytes = read(root, `${directory}/receipt.json`);
|
||||
if (expectedHash !== undefined && hash(receiptBytes) !== expectedHash) throw new QaEvidenceError('Capture differs from completed producer receipt');
|
||||
const receipt = JSON.parse(decode(receiptBytes));
|
||||
if (!exact(receipt, ['version', 'id', 'cwd', 'argv', 'deadline', 'timing', 'observation', 'publicOutput', 'startedAt', 'completedAt', 'exitCode', 'signal', 'status', 'stdout', 'stderr'])
|
||||
|| receipt.version !== 1 || receipt.id !== captureId || !['complete', 'incomplete', 'sensitive'].includes(receipt.status)
|
||||
|| receipt.signal !== null && typeof receipt.signal !== 'string' || typeof receipt.publicOutput !== 'boolean'
|
||||
|| !Number.isInteger(receipt.exitCode) || receipt.exitCode < 0 || receipt.exitCode > 255
|
||||
|| !path.isAbsolute(receipt.cwd) || !path.isAbsolute(receipt.deadline) || !Array.isArray(receipt.timing)
|
||||
|| !Array.isArray(receipt.argv) || !receipt.argv.length || !receipt.argv.every((arg: unknown) => typeof arg === 'string')
|
||||
|| !Number.isFinite(Date.parse(receipt.startedAt)) || !Number.isFinite(Date.parse(receipt.completedAt))
|
||||
|| Date.parse(receipt.completedAt) < Date.parse(receipt.startedAt)) throw new QaEvidenceError('Incomplete or invalid capture');
|
||||
const stdout = read(root, `${directory}/stdout`);
|
||||
const stderr = read(root, `${directory}/stderr`);
|
||||
for (const [stream, bytes] of [['stdout', stdout], ['stderr', stderr]] as const) {
|
||||
if (!exact(receipt[stream], ['sha256', 'bytes']) || receipt[stream].sha256 !== hash(bytes) || receipt[stream].bytes !== bytes.length) throw new QaEvidenceError('Captured output changed');
|
||||
}
|
||||
return { receipt, sha256: hash(receiptBytes), stdout, stderr };
|
||||
}
|
||||
|
||||
export function readQaCapture(reportRoot: string, captureId: string, expectedHash?: string) {
|
||||
const root = qaEvidenceRoot(reportRoot);
|
||||
const { receipt, sha256, stdout, stderr } = readQaCaptureRecord(root, captureId, expectedHash);
|
||||
if (receipt.status !== 'complete' || receipt.signal !== null) throw new QaEvidenceError('Incomplete capture cannot be published');
|
||||
const out = decode(stdout), err = decode(stderr);
|
||||
if (scan(out + '\n' + err + '\n' + JSON.stringify(receipt.argv)).findings.some(finding => finding.tier === 'HIGH')) throw new QaEvidenceError('Sensitive capture cannot be published');
|
||||
let observed: unknown = out;
|
||||
try { observed = JSON.parse(out); } catch {}
|
||||
const observationText = JSON.stringify(observed, null, 2) + '\n';
|
||||
if (!exact(receipt.observation, ['sha256', 'bytes']) || receipt.observation.sha256 !== hash(observationText)
|
||||
|| receipt.observation.bytes !== Buffer.byteLength(observationText)
|
||||
|| !read(root, `.qa-evidence/${id(captureId)}/observation.json`).equals(Buffer.from(observationText))) throw new QaEvidenceError('Observation view differs from captured output');
|
||||
return { receipt, sha256, stdout: out, stderr: err, observed, observationText };
|
||||
}
|
||||
|
||||
async function capture(root: string, captureId: string, publicOutput: boolean, option: string, budget: string, command: string, args: string[]) {
|
||||
id(captureId);
|
||||
if (!command || !['--deadline', '--timeout-ms'].includes(option)) throw new QaEvidenceError('Capture requires a deadline or finite command timeout');
|
||||
if (option === '--timeout-ms' && (!/^[1-9]\d*$/.test(budget) || !Number.isSafeInteger(Number(budget)) || Number(budget) > 2_147_483_647)) throw new QaEvidenceError('Invalid command timeout');
|
||||
privateDirectory(root, '.qa-evidence');
|
||||
const directory = privateDirectory(root, `.qa-evidence/${captureId}`, true);
|
||||
const deadline = option === '--deadline' ? owned(root, path.resolve(budget)) : path.join(directory, 'deadline.json');
|
||||
if (option === '--timeout-ms') startQaDeadline(deadline, (Number(budget) / 1000).toFixed(3));
|
||||
const startedAt = new Date().toISOString();
|
||||
const fds = { stdout: fs.openSync(path.join(directory, 'stdout'), 'wx', 0o600), stderr: fs.openSync(path.join(directory, 'stderr'), 'wx', 0o600) };
|
||||
const digests = { stdout: createHash('sha256'), stderr: createHash('sha256') };
|
||||
const lengths = { stdout: 0, stderr: 0 };
|
||||
const timing: Record<string, unknown>[] = [];
|
||||
let result = { exitCode: 2, signal: null as NodeJS.Signals | null, completed: false };
|
||||
let exitCode: number;
|
||||
try {
|
||||
const emit = (_stream: 'stdout' | 'stderr', value: Record<string, unknown>, completion?: typeof result) => {
|
||||
timing.push({ guard: 'qa-deadline', ...value });
|
||||
if (completion) result = completion;
|
||||
};
|
||||
exitCode = process.platform === 'win32'
|
||||
? await runQaWindowsWorker(['run', deadline, '--', command, ...args], emit, path.resolve(import.meta.dir, '../bin/gstack-qa-deadline'), 'qa-deadline-receipt', fds)
|
||||
: await runQaDeadlineCommand(deadline, command, args, emit, {
|
||||
write: (stream, chunk) => {
|
||||
fs.writeFileSync(fds[stream], chunk);
|
||||
digests[stream].update(chunk);
|
||||
lengths[stream] += chunk.length;
|
||||
},
|
||||
complete: value => { result = value; },
|
||||
});
|
||||
for (const stream of ['stdout', 'stderr'] as const) {
|
||||
const stat = fs.fstatSync(fds[stream]);
|
||||
const current = fs.lstatSync(owned(root, `.qa-evidence/${captureId}/${stream}`));
|
||||
if (stat.nlink !== 1 || stat.dev !== current.dev || stat.ino !== current.ino) throw new QaEvidenceError('Capture output was replaced');
|
||||
if (process.platform === 'win32') {
|
||||
const bytes = read(root, `.qa-evidence/${captureId}/${stream}`);
|
||||
digests[stream].update(bytes);
|
||||
lengths[stream] = bytes.length;
|
||||
}
|
||||
}
|
||||
fs.fsyncSync(fds.stdout);
|
||||
fs.fsyncSync(fds.stderr);
|
||||
} finally {
|
||||
fs.closeSync(fds.stdout);
|
||||
fs.closeSync(fds.stderr);
|
||||
}
|
||||
const stdout = read(root, `.qa-evidence/${captureId}/stdout`), stderr = read(root, `.qa-evidence/${captureId}/stderr`);
|
||||
const streams = {
|
||||
stdout: { sha256: digests.stdout.digest('hex'), bytes: lengths.stdout },
|
||||
stderr: { sha256: digests.stderr.digest('hex'), bytes: lengths.stderr },
|
||||
};
|
||||
let status = result.completed && result.exitCode === exitCode ? 'complete' : 'incomplete';
|
||||
if (streams.stdout.sha256 !== hash(stdout) || streams.stderr.sha256 !== hash(stderr)) status = 'incomplete';
|
||||
if (status === 'complete') {
|
||||
try {
|
||||
if (scan(decode(stdout) + '\n' + decode(stderr) + '\n' + JSON.stringify([command, ...args])).findings.some(finding => finding.tier === 'HIGH')) status = 'sensitive';
|
||||
} catch { status = 'incomplete'; }
|
||||
}
|
||||
let observation: { sha256: string; bytes: number } | null = null;
|
||||
if (status === 'complete') {
|
||||
let value: unknown = decode(stdout);
|
||||
try { value = JSON.parse(value as string); } catch {}
|
||||
const bytes = JSON.stringify(value, null, 2) + '\n';
|
||||
fs.writeFileSync(owned(root, `.qa-evidence/${captureId}/observation.json`), bytes, { flag: 'wx', mode: 0o600 });
|
||||
observation = { sha256: hash(bytes), bytes: Buffer.byteLength(bytes) };
|
||||
}
|
||||
const receipt = { version: 1, id: captureId, cwd: process.cwd(), argv: [command, ...args], deadline, timing, startedAt,
|
||||
completedAt: new Date().toISOString(), exitCode, signal: result.signal, status, observation, publicOutput,
|
||||
...streams };
|
||||
const sha256 = publish(root, `.qa-evidence/${captureId}/receipt.json`, receipt);
|
||||
return { action: 'capture', id: captureId, status, sha256, exitCode, signal: result.signal, publicOutput };
|
||||
}
|
||||
|
||||
function checkpoint(root: string, checkpointId: string, source: string | Record<string, string>) {
|
||||
id(checkpointId);
|
||||
const bytes = typeof source === 'string' ? read(root, source) : Buffer.from(JSON.stringify(source));
|
||||
const intent = JSON.parse(decode(bytes));
|
||||
if (!exact(intent, ['capture', 'observationCommand', 'hypothesis', 'nextCommand'])
|
||||
|| typeof intent.capture !== 'string' || typeof intent.observationCommand !== 'string' || !intent.observationCommand.trim()
|
||||
|| typeof intent.hypothesis !== 'string' || intent.hypothesis.trim().length <= 20 || !/[a-z]{3}/i.test(intent.hypothesis)
|
||||
|| typeof intent.nextCommand !== 'string' || !intent.nextCommand.trim()) throw new QaEvidenceError('Invalid causal intent');
|
||||
if (scan(decode(bytes)).findings.some(finding => finding.tier === 'HIGH')) throw new QaEvidenceError('Sensitive intent cannot be published');
|
||||
const captured = readQaCapture(root, intent.capture);
|
||||
const value = { observationCommand: intent.observationCommand, observed: captured.observed, hypothesis: intent.hypothesis, nextCommand: intent.nextCommand };
|
||||
const sha256 = publish(root, `exploration-${checkpointId}.json`, value);
|
||||
return { action: 'checkpoint', id: checkpointId, status: 'complete', sha256, capture: intent.capture, captureSha256: captured.sha256, intentSha256: hash(bytes), exitCode: 0 };
|
||||
}
|
||||
|
||||
function materialize(root: string, source: string) {
|
||||
const bytes = read(root, source);
|
||||
if (scan(decode(bytes)).findings.some(finding => finding.tier === 'HIGH')) throw new QaEvidenceError('Sensitive annotations cannot be published');
|
||||
const annotations = JSON.parse(decode(bytes));
|
||||
if (!exact(annotations, ['revision', 'runtime', 'cwd', 'limits', 'evidence', 'learning'])
|
||||
|| !['revision', 'runtime', 'cwd'].every(key => typeof annotations[key] === 'string' && annotations[key].trim())
|
||||
|| !Array.isArray(annotations.limits) || !annotations.limits.length || !annotations.limits.every((limit: unknown) => typeof limit === 'string' && limit.trim())
|
||||
|| !Array.isArray(annotations.evidence) || !Array.isArray(annotations.learning)) throw new QaEvidenceError('Invalid report annotations');
|
||||
const captures = new Set<string>();
|
||||
const evidence = annotations.evidence.map((row: any) => {
|
||||
if (!exact(row, ['capture', 'command', 'contract', 'expected', 'classification'])
|
||||
|| !Object.values(row).every(value => typeof value === 'string' && value.trim()) || captures.has(row.capture)) throw new QaEvidenceError('Invalid evidence annotation');
|
||||
captures.add(row.capture);
|
||||
const captured = readQaCapture(root, row.capture);
|
||||
return { command: row.command, contract: row.contract, expected: row.expected, classification: row.classification, observed: captured.observed };
|
||||
});
|
||||
const learning = annotations.learning.map((name: unknown) => {
|
||||
if (typeof name !== 'string') throw new QaEvidenceError('Invalid checkpoint reference');
|
||||
const note = JSON.parse(decode(read(root, `exploration-${id(name)}.json`)));
|
||||
if (!exact(note, ['observationCommand', 'observed', 'hypothesis', 'nextCommand'])) throw new QaEvidenceError('Invalid referenced checkpoint');
|
||||
return { observationCommand: note.observationCommand, hypothesis: note.hypothesis, nextCommand: note.nextCommand };
|
||||
});
|
||||
const sha256 = publish(root, 'evidence.json', { ...annotations, evidence, learning });
|
||||
return { action: 'materialize', status: 'complete', sha256, annotationsSha256: hash(bytes), exitCode: 0 };
|
||||
}
|
||||
|
||||
export async function qaEvidenceMain(args: string[]): Promise<number> {
|
||||
return withQaReceiptOutput(false, 'qa-evidence-receipt', value => value.event === 'observation'
|
||||
? JSON.stringify(value.observed) + '\n' : value.event === 'diagnostic' ? String(value.stderr)
|
||||
: '\nQA_EVIDENCE ' + JSON.stringify({ producer: 'gstack-qa-evidence', version: 1, ...value }) + '\n', async emit => {
|
||||
try {
|
||||
const [action, reportRoot, ...rest] = args;
|
||||
const root = qaEvidenceRoot(reportRoot);
|
||||
let receipt: Record<string, any>;
|
||||
const publicOutput = action === 'capture' && rest[1] === '--public';
|
||||
if (publicOutput) rest.splice(1, 1);
|
||||
if (action === 'capture' && rest.length >= 5 && rest[3] === '--') {
|
||||
receipt = await capture(root, rest[0], publicOutput, rest[1], rest[2], rest[4], rest.slice(5));
|
||||
if (publicOutput && receipt.status === 'complete') {
|
||||
const captured = readQaCapture(root, rest[0], receipt.sha256);
|
||||
emit('stdout', { event: 'observation', observed: captured.observed });
|
||||
if (captured.stderr) emit('stderr', { event: 'diagnostic', stderr: captured.stderr });
|
||||
}
|
||||
} else if (action === 'checkpoint' && rest.length === 2) receipt = checkpoint(root, rest[0], rest[1]);
|
||||
else if (action === 'checkpoint' && rest.length === 5) receipt = checkpoint(root, rest[0], { capture: rest[1], observationCommand: rest[2], hypothesis: rest[3], nextCommand: rest[4] });
|
||||
else if (action === 'materialize' && rest.length === 1) receipt = materialize(root, rest[0]);
|
||||
else throw new QaEvidenceError('Usage: capture ROOT ID [--public] --deadline FILE|--timeout-ms MS -- COMMAND ARGS | checkpoint ROOT ID CAPTURE OBSERVATION_COMMAND HYPOTHESIS NEXT_COMMAND | checkpoint ROOT ID INTENT_FILE | materialize ROOT ANNOTATIONS');
|
||||
emit('stdout', receipt);
|
||||
return receipt.status === 'complete' ? receipt.exitCode : receipt.status === 'incomplete' ? receipt.exitCode || 2 : 2;
|
||||
} catch (error) {
|
||||
emit('stderr', { action: 'error', message: error instanceof QaEvidenceError ? error.message : 'Evidence operation failed' });
|
||||
return 2;
|
||||
}
|
||||
});
|
||||
}
|
||||
+114
-2
@@ -1,8 +1,10 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const DIFF_REVIEWS = new Set(['review', 'adversarial-review', 'codex-review', 'design-review-lite', 'ship']);
|
||||
const SHARED_LIBS_COVERAGE_VERSION = 1;
|
||||
|
||||
function record(value: unknown): value is Record<string, any> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
@@ -62,6 +64,103 @@ export function canReuseSharedLibsAdvisory(
|
||||
return currentFinding.evidence_paths.every((path: string) => priorCovered.has(path) && covered.has(path));
|
||||
}
|
||||
|
||||
export function sharedLibsSnapshotCoverage(repo: string, wtree: string, paths: unknown, env = process.env): string[] {
|
||||
if (!repo || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(wtree) || !Array.isArray(paths) ||
|
||||
!Array.from(paths).every(relativeSourcePath)) return [];
|
||||
const git = (...args: string[]) => {
|
||||
const result = spawnSync('git', ['--no-replace-objects', '-c', 'core.fsmonitor=false',
|
||||
'-c', 'core.untrackedCache=false', ...args], {
|
||||
cwd: repo, env: { ...env, GIT_OPTIONAL_LOCKS: '0', GIT_LITERAL_PATHSPECS: '1', GIT_NO_LAZY_FETCH: '1' },
|
||||
timeout: 10_000, maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (result.status !== 0 || result.error) throw new Error('Git snapshot inspection failed');
|
||||
return result.stdout;
|
||||
};
|
||||
try {
|
||||
const config = new Map(git('config', '--list', '-z').toString().split('\0').filter(Boolean).map(item => {
|
||||
const split = item.indexOf('\n');
|
||||
return split < 0 ? [item.toLowerCase(), 'true'] as const
|
||||
: [item.slice(0, split).toLowerCase(), item.slice(split + 1)] as const;
|
||||
}));
|
||||
if (config.has('core.autocrlf') && config.get('core.autocrlf')?.toLowerCase() !== 'false') return [];
|
||||
if ([...config.keys()].some(key => key === 'extensions.partialclone' || /^remote\..*\.promisor$/.test(key))) return [];
|
||||
if (git('cat-file', '-t', wtree).toString().trim() !== 'tree') return [];
|
||||
} catch { return []; }
|
||||
|
||||
return [...new Set(paths as string[])].filter(path => {
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
let absolute = repo;
|
||||
for (const component of path.split('/')) {
|
||||
absolute = join(absolute, component);
|
||||
const stat = lstatSync(absolute);
|
||||
if (stat.isSymbolicLink() || (!stat.isDirectory() && absolute !== join(repo, path))) return false;
|
||||
}
|
||||
const before = lstatSync(absolute);
|
||||
if (!before.isFile()) return false;
|
||||
const ignored = spawnSync('git', ['-c', 'core.fsmonitor=false', 'check-ignore', '--no-index', '-q', '--', path], {
|
||||
cwd: repo, env: { ...env, GIT_OPTIONAL_LOCKS: '0', GIT_LITERAL_PATHSPECS: '0' }, timeout: 10_000,
|
||||
});
|
||||
if (ignored.status !== 1 || ignored.error) return false;
|
||||
const tracked = git('ls-files', '-v', '-z', '--', path).toString();
|
||||
if (tracked ? tracked !== `H ${path}\0`
|
||||
: git('ls-files', '--others', '--exclude-standard', '-z', '--', path).toString() !== `${path}\0`) return false;
|
||||
if (tracked) {
|
||||
const stage = git('ls-files', '--stage', '--sparse', '-z', '--', path).toString();
|
||||
if (!/^(?:100644|100755) [0-9a-f]+ 0\t/.test(stage) || stage.split('\0').filter(Boolean).length !== 1) return false;
|
||||
}
|
||||
const names = ['filter', 'working-tree-encoding', 'ident', 'text', 'eol', 'crlf'];
|
||||
const attrs = git('check-attr', '-z', ...names, '--', path).toString().split('\0');
|
||||
if (attrs.length !== names.length * 3 + 1) return false;
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
if (attrs[i * 3] !== path || attrs[i * 3 + 1] !== names[i] ||
|
||||
!['unspecified', 'unset'].includes(attrs[i * 3 + 2])) return false;
|
||||
}
|
||||
const entry = git('ls-tree', '-z', wtree, '--', path).toString();
|
||||
const match = /^(100644|100755) blob ([0-9a-f]+)\t([^\0]+)\0$/.exec(entry);
|
||||
if (!match || match[3] !== path) return false;
|
||||
if (process.platform !== 'win32' && (Boolean(before.mode & 0o111) !== (match[1] === '100755'))) return false;
|
||||
fd = openSync(absolute, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
const bytes = readFileSync(fd);
|
||||
const after = fstatSync(fd);
|
||||
if ((['dev', 'ino', 'mode', 'size', 'mtimeMs', 'ctimeMs'] as const).some(key => before[key] !== after[key])) return false;
|
||||
return bytes.equals(git('cat-file', 'blob', match[2]));
|
||||
} catch { return false; }
|
||||
finally { if (fd !== undefined) closeSync(fd); }
|
||||
});
|
||||
}
|
||||
|
||||
export function checkSharedLibsReuse(finding: unknown, token: string, env = process.env): Record<string, any> {
|
||||
const fingerprint = sharedLibsFingerprint(finding);
|
||||
const result: Record<string, any> = { reusable: false, fingerprint };
|
||||
if (!fingerprint || !record(finding) || !/^[0-9a-f-]{36}$/.test(token) ||
|
||||
!env.GSTACK_REVIEW_REPO || !env.GSTACK_REVIEW_BRANCH || !env.GSTACK_STAMP_WTREE ||
|
||||
!env.GSTACK_REVIEW_DIR || !env.GSTACK_REVIEW_LOG) return result;
|
||||
try {
|
||||
const start = JSON.parse(readFileSync(join(env.GSTACK_REVIEW_DIR, '.review-starts', `${token}.json`), 'utf8'));
|
||||
result.review_start = start;
|
||||
if (start.skill !== 'review' || start.repo !== env.GSTACK_REVIEW_REPO ||
|
||||
start.branch !== env.GSTACK_REVIEW_BRANCH || start.wtree !== env.GSTACK_STAMP_WTREE) return result;
|
||||
const snapshot = {
|
||||
wtree: start.wtree, branch_id: sha256(start.branch),
|
||||
covered_paths: sharedLibsSnapshotCoverage(start.repo, start.wtree, finding.evidence_paths, env),
|
||||
};
|
||||
result.snapshot = snapshot;
|
||||
const rows = readFileSync(env.GSTACK_REVIEW_LOG, 'utf8').split('\n').filter(Boolean);
|
||||
for (const row of rows.reverse()) {
|
||||
let prior;
|
||||
try { prior = JSON.parse(row); } catch { continue; }
|
||||
if (!record(prior) || prior.shared_libs_coverage_version !== SHARED_LIBS_COVERAGE_VERSION ||
|
||||
!Array.isArray(prior.findings)) continue;
|
||||
if (prior.findings.some(value => canReuseSharedLibsAdvisory(value, finding, prior, snapshot))) {
|
||||
result.reusable = true;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} catch { return result; }
|
||||
return result;
|
||||
}
|
||||
|
||||
export function captureReviewStart(skill: string, env = process.env): string {
|
||||
if (!DIFF_REVIEWS.has(skill) || !env.GSTACK_STAMP_WTREE || !env.GSTACK_REVIEW_REPO) {
|
||||
throw new Error('cannot capture a diff review without a working-tree fingerprint');
|
||||
@@ -77,7 +176,7 @@ export function captureReviewStart(skill: string, env = process.env): string {
|
||||
}
|
||||
|
||||
export function bindReview(rec: Record<string, any>, token: string, env = process.env): Record<string, any> {
|
||||
for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness']) delete rec[key];
|
||||
for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness', 'shared_libs_coverage_version']) delete rec[key];
|
||||
if (env.GSTACK_STAMP_COMMIT_FULL) rec.commit_full = env.GSTACK_STAMP_COMMIT_FULL;
|
||||
if (env.GSTACK_STAMP_TREE) rec.tree = env.GSTACK_STAMP_TREE;
|
||||
if (env.GSTACK_STAMP_DIRTY) rec.dirty = env.GSTACK_STAMP_DIRTY === 'true';
|
||||
@@ -108,6 +207,19 @@ export function bindReview(rec: Record<string, any>, token: string, env = proces
|
||||
...(typeof start?.branch === 'string' && start.branch.length > 0 ? { branch_id: sha256(start.branch) } : {}),
|
||||
};
|
||||
if (state === 'verified') rec.wtree = end;
|
||||
if (rec.skill === 'review' && Array.isArray(rec.findings)) {
|
||||
rec.shared_libs_coverage_version = SHARED_LIBS_COVERAGE_VERSION;
|
||||
for (const finding of rec.findings) {
|
||||
if (!record(finding)) continue;
|
||||
delete finding.snapshot_covered_paths;
|
||||
if (finding.advisory !== true || finding.severity !== 'INFORMATIONAL') continue;
|
||||
const fingerprint = sharedLibsFingerprint(finding);
|
||||
if (!fingerprint) continue;
|
||||
finding.fingerprint = fingerprint;
|
||||
finding.snapshot_covered_paths = state === 'verified' && finding.action === 'skipped'
|
||||
? sharedLibsSnapshotCoverage(env.GSTACK_REVIEW_REPO!, end!, finding.evidence_paths, env) : [];
|
||||
}
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user