mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-16 01:45:29 +02:00
v1.87.0.0 feat: add verified CSO audits and replayable repair bundles (#2852)
* feat(cso): add verified audits and replayable repair bundles * fix(cso): harden qualification and setup boundaries * fix(cso): assemble security canaries at runtime * fix(cso): bound release proof and maintenance work Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): require complete evaluation reports Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): replay expired snapshots from supplied source Co-Authored-By: OpenAI Codex <noreply@openai.com> * test(cso): synchronize DNS cancellation assertion Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore(ship): exempt repository owner from liveness proof Co-Authored-By: OpenAI Codex <noreply@openai.com> * test(cso): make recheck retention overlap deterministic Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore: bump version and changelog (v1.85.0.0) Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): pass native release gates Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore: move release to v1.86.0.0 Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): resolve rechecks by finding Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore: move release to v1.87.0.0 Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): pass macOS and Windows release gates Normalize BSD wc output, compare Windows paths by filesystem identity, preserve portable snapshot race coverage, and narrow POSIX-only Windows fixtures. Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): harden native verification gates * fix(cso): refine Windows native diagnostics * test(cso): isolate Windows Git startup failure * test(cso): stabilize Windows native diagnostics * fix(cso): support hardened Git on Windows * fix(cso): close final verification gaps * test(cso): bound cold Docker fixture setup * fix(cso): restore cross-platform free-suite gates --------- Co-authored-by: OpenAI Codex <noreply@openai.com>
This commit is contained in:
co-authored by
OpenAI Codex
parent
9f81911136
commit
4a3c6a8a3c
@@ -296,7 +296,10 @@ describe('web research ({{ASIDE_RESEARCH}})', () => {
|
||||
});
|
||||
|
||||
test('every template carrying {{ASIDE_RESEARCH}} renders the section exactly once', () => {
|
||||
expect(carriers).toEqual(expect.arrayContaining(['cso', 'design-consultation', 'investigate', 'office-hours', 'plan-ceo-review', 'plan-devex-review', 'plan-eng-review', 'review']));
|
||||
// CSO's private startup keeps advisory queries inside its stricter public-ID
|
||||
// policy and intentionally does not import the generic Aside research block.
|
||||
expect(carriers).not.toContain('cso');
|
||||
expect(carriers).toEqual(expect.arrayContaining(['design-consultation', 'investigate', 'office-hours', 'plan-ceo-review', 'plan-devex-review', 'plan-eng-review', 'review']));
|
||||
for (const skill of carriers) {
|
||||
const md = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8');
|
||||
expect({ skill, count: md.split('## Web research runs in Aside').length - 1 }).toEqual({ skill, count: 1 });
|
||||
|
||||
@@ -107,7 +107,8 @@ const EXPECTED_INTERACTIVE = [
|
||||
'qa-only',
|
||||
'codex',
|
||||
'autoplan',
|
||||
'cso',
|
||||
// CSO uses a private startup and intentionally omits the shared PREAMBLE,
|
||||
// including its generic AskUserQuestion formatting block.
|
||||
'investigate',
|
||||
'retro',
|
||||
'design-review',
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { afterEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readBoundedStable } from '../lib/cso/bounded-file';
|
||||
|
||||
const roots:string[]=[];afterEach(()=>{for(const root of roots.splice(0))fs.rmSync(root,{recursive:true,force:true});});
|
||||
|
||||
describe('CSO caller control-file reader',()=>{
|
||||
test.skipIf(process.platform==='win32')('cannot block on a FIFO raced over a validated regular file',()=>{
|
||||
const root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-control-file-'));roots.push(root);const input=path.join(root,'request.json'),original=path.join(root,'request.original'),fifo=path.join(root,'request.fifo');fs.writeFileSync(input,'{}\n');expect(spawnSync('/usr/bin/mkfifo',[fifo],{timeout:5_000}).status).toBe(0);const open=fs.openSync;let checked=false;
|
||||
const patched=spyOn(fs,'openSync').mockImplementation(((candidate:any,flags:any,mode?:any)=>{if(String(candidate)===input){checked=true;if((Number(flags)&(fs.constants.O_NONBLOCK??0))===0)throw new Error('reader would block on raced FIFO');fs.renameSync(input,original);fs.renameSync(fifo,input);}return mode===undefined?open(candidate,flags):open(candidate,flags,mode);}) as typeof fs.openSync);
|
||||
try{expect(()=>readBoundedStable(input,1024,'Input file')).toThrow('changed before it could be read');}finally{patched.mockRestore();}
|
||||
expect(checked).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { afterEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { PublicArchiveCache } from '../lib/cso/cache';
|
||||
import { CsoError } from '../lib/cso/contracts';
|
||||
|
||||
const roots: string[] = [];
|
||||
function fixture(maxBytes = 1024, now: () => number = Date.now) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cso-cache-'));
|
||||
roots.push(root);
|
||||
const staging = path.join(root, 'staging'), cacheRoot = path.join(root, 'cache');
|
||||
fs.mkdirSync(staging, { mode: 0o700 });
|
||||
return { root, staging, cacheRoot, cache: new PublicArchiveCache({ root: cacheRoot, stagingRoot: staging, maxBytes, now }) };
|
||||
}
|
||||
function stage(staging: string, name: string, value: string | Buffer): { path: string; digest: string } {
|
||||
const file = path.join(staging, name);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(file, value, { mode: 0o600 });
|
||||
return { path: name, digest: createHash('sha256').update(value).digest('hex') };
|
||||
}
|
||||
function code(fn: () => unknown): string | undefined {
|
||||
try { fn(); return undefined; }
|
||||
catch (error) { return error instanceof CsoError ? error.code : undefined; }
|
||||
}
|
||||
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
describe('CSO immutable public archive cache', () => {
|
||||
test('promotes only matching bytes and verifies the full hash on every hit', () => {
|
||||
let tick = 100;
|
||||
const { staging, cacheRoot, cache } = fixture(1024, () => tick++);
|
||||
const archive = stage(staging, 'pkg.tgz', 'verified-public-archive');
|
||||
const entry = cache.promote(archive.path, archive.digest);
|
||||
expect(fs.readFileSync(entry.path, 'utf8')).toBe('verified-public-archive');
|
||||
expect(fs.statSync(cacheRoot).mode & 0o777).toBe(0o700);
|
||||
expect(fs.statSync(entry.path).mode & 0o777).toBe(0o400);
|
||||
expect(fs.statSync(path.join(cacheRoot, 'metadata', `${archive.digest}.json`)).mode & 0o777).toBe(0o600);
|
||||
expect(cache.get(archive.digest)?.lastAccessedAt).toBeGreaterThan(entry.lastAccessedAt);
|
||||
|
||||
fs.chmodSync(entry.path, 0o600);
|
||||
fs.writeFileSync(entry.path, 'poisoned-public-archive');
|
||||
fs.chmodSync(entry.path, 0o400);
|
||||
expect(code(() => cache.get(archive.digest))).toBe('INCOMPATIBLE_INPUT');
|
||||
});
|
||||
|
||||
test('rejects traversal, symlinks, hard links, directories, oversized files, and hash mismatches', () => {
|
||||
const { root, staging, cache } = fixture(16);
|
||||
const good = stage(staging, 'good', 'good');
|
||||
expect(code(() => cache.promote('../good', good.digest))).toBe('UNSAFE_PATH');
|
||||
|
||||
fs.symlinkSync(path.join(staging, 'good'), path.join(staging, 'link'));
|
||||
expect(code(() => cache.promote('link', good.digest))).toBe('UNSAFE_PATH');
|
||||
fs.linkSync(path.join(staging, 'good'), path.join(staging, 'hard'));
|
||||
expect(code(() => cache.promote('hard', good.digest))).toBe('UNSAFE_PATH');
|
||||
fs.unlinkSync(path.join(staging, 'hard'));
|
||||
fs.mkdirSync(path.join(staging, 'directory'));
|
||||
expect(code(() => cache.promote('directory', good.digest))).toBe('UNSAFE_PATH');
|
||||
|
||||
const large = stage(staging, 'large', Buffer.alloc(17, 1));
|
||||
expect(code(() => cache.promote(large.path, large.digest))).toBe('INSUFFICIENT_CAPACITY');
|
||||
expect(code(() => cache.promote(good.path, '0'.repeat(64)))).toBe('INCOMPATIBLE_INPUT');
|
||||
expect(code(() => cache.promote('/absolute', good.digest))).toBe('UNSAFE_PATH');
|
||||
expect(code(() => cache.promote(good.path, 'SHA256:bad'))).toBe('INVALID_ARGUMENT');
|
||||
expect(fs.readdirSync(path.join(root, 'cache', 'entries'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('evicts least-recently-used entries within a configurable byte ceiling', () => {
|
||||
let now = 0;
|
||||
const { staging, cache } = fixture(8, () => ++now);
|
||||
const first = stage(staging, 'first', '1111');
|
||||
const second = stage(staging, 'second', '2222');
|
||||
const third = stage(staging, 'third', '3333');
|
||||
cache.promote(first.path, first.digest);
|
||||
cache.promote(second.path, second.digest);
|
||||
cache.get(first.digest);
|
||||
cache.promote(third.path, third.digest);
|
||||
expect(cache.get(first.digest)).toBeDefined();
|
||||
expect(cache.get(second.digest)).toBeUndefined();
|
||||
expect(cache.get(third.digest)).toBeDefined();
|
||||
expect(cache.stats()).toEqual({ entries: 2, bytes: 8, maxBytes: 8 });
|
||||
});
|
||||
|
||||
test('a hash-mismatched staging object cannot evict verified LRU entries', () => {
|
||||
const { staging, cache } = fixture(8);
|
||||
const first = stage(staging, 'first-kept', '1111');
|
||||
const second = stage(staging, 'second-kept', '2222');
|
||||
cache.promote(first.path, first.digest);
|
||||
cache.promote(second.path, second.digest);
|
||||
const invalid = stage(staging, 'invalid', '3333');
|
||||
expect(code(() => cache.promote(invalid.path, '0'.repeat(64)))).toBe('INCOMPATIBLE_INPUT');
|
||||
expect(cache.get(first.digest)).toBeDefined();
|
||||
expect(cache.get(second.digest)).toBeDefined();
|
||||
expect(cache.stats()).toEqual({ entries: 2, bytes: 8, maxBytes: 8 });
|
||||
});
|
||||
|
||||
test('materializes one immutable run-owned set atomically before later eviction', () => {
|
||||
const { root, staging, cache } = fixture(8);
|
||||
const first = stage(staging, 'first-pin', '1111'), second = stage(staging, 'second-pin', '2222');
|
||||
cache.promote(first.path, first.digest); cache.promote(second.path, second.digest);
|
||||
const destination = path.join(root, 'run-owned'); fs.mkdirSync(destination, { mode: 0o700 });
|
||||
const copies = cache.materialize([first.digest, second.digest], destination);
|
||||
expect(copies.map(item => item.sha256)).toEqual([first.digest, second.digest].sort());
|
||||
const third = stage(staging, 'third-pin', '3333'); cache.promote(third.path, third.digest);
|
||||
expect(copies.every(item => fs.existsSync(item.path) && (fs.statSync(item.path).mode & 0o777) === 0o400)).toBe(true);
|
||||
expect(copies.map(item => createHash('sha256').update(fs.readFileSync(item.path)).digest('hex')).sort()).toEqual([first.digest, second.digest].sort());
|
||||
});
|
||||
|
||||
test('bounds every public operation by an optional absolute deadline', () => {
|
||||
const { root, staging, cache } = fixture(1024);
|
||||
const archive = stage(staging, 'deadline', 'bounded');
|
||||
const expired = Date.now() - 1;
|
||||
expect(code(() => cache.promote(archive.path, archive.digest, expired))).toBe('DEADLINE');
|
||||
const entry = cache.promote(archive.path, archive.digest);
|
||||
expect(code(() => cache.get(entry.sha256, { deadline: expired }))).toBe('DEADLINE');
|
||||
expect(code(() => cache.stats({ deadline: expired }))).toBe('DEADLINE');
|
||||
const destination = path.join(root, 'deadline-materialization'); fs.mkdirSync(destination, { mode: 0o700 });
|
||||
expect(code(() => cache.materialize([entry.sha256], destination, { deadline: expired }))).toBe('DEADLINE');
|
||||
expect(fs.readdirSync(destination)).toEqual([]);
|
||||
});
|
||||
|
||||
test('cancels between archive-copy chunks and removes the partial incoming object', () => {
|
||||
const { staging, cacheRoot, cache } = fixture(512 * 1024);
|
||||
const archive = stage(staging, 'cancel-copy', Buffer.alloc(192 * 1024, 0x61));
|
||||
const controller = new AbortController(), original = fs.writeSync.bind(fs); let archiveWrites = 0;
|
||||
const writer = spyOn(fs, 'writeSync').mockImplementation(((fd: number, buffer: string | NodeJS.ArrayBufferView,
|
||||
offsetOrPosition?: number | null, lengthOrEncoding?: number | BufferEncoding, position?: number | null) => {
|
||||
const written = (original as any)(fd, buffer, offsetOrPosition, lengthOrEncoding, position);
|
||||
if (Buffer.isBuffer(buffer) && lengthOrEncoding === 64 * 1024 && written > 0) { archiveWrites++; controller.abort(); }
|
||||
return written;
|
||||
}) as typeof fs.writeSync);
|
||||
try {
|
||||
expect(code(() => cache.promote(archive.path, archive.digest, { deadline: Date.now() + 60_000, signal: controller.signal }))).toBe('CANCELLED');
|
||||
} finally { writer.mockRestore(); }
|
||||
expect(archiveWrites).toBe(1);
|
||||
expect(fs.readdirSync(path.join(cacheRoot, 'incoming'))).toEqual([]);
|
||||
expect(fs.readdirSync(path.join(cacheRoot, 'entries'))).toEqual([]);
|
||||
expect(fs.readdirSync(path.join(cacheRoot, 'metadata'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('cancels between cache-hit read chunks without damaging the immutable entry', () => {
|
||||
const { staging, cacheRoot, cache } = fixture(512 * 1024);
|
||||
const archive = stage(staging, 'cancel-read', Buffer.alloc(192 * 1024, 0x62)), entry = cache.promote(archive.path, archive.digest);
|
||||
const controller = new AbortController(), original = fs.readSync.bind(fs); let archiveReads = 0;
|
||||
const reader = spyOn(fs, 'readSync').mockImplementation(((fd: number, buffer: NodeJS.ArrayBufferView,
|
||||
offset: number, length: number, position: number | null) => {
|
||||
const read = original(fd, buffer, offset, length, position);
|
||||
if (length === 64 * 1024 && read > 0) { archiveReads++; controller.abort(); }
|
||||
return read;
|
||||
}) as typeof fs.readSync);
|
||||
try {
|
||||
expect(code(() => cache.get(entry.sha256, { signal: controller.signal }))).toBe('CANCELLED');
|
||||
} finally { reader.mockRestore(); }
|
||||
expect(archiveReads).toBe(1);
|
||||
expect(fs.existsSync(entry.path)).toBe(true);
|
||||
expect(fs.readdirSync(path.join(cacheRoot, 'metadata'))).toEqual([`${entry.sha256}.json`]);
|
||||
});
|
||||
|
||||
test('never overwrites an immutable entry and recovers interrupted entry publication', () => {
|
||||
const { staging, cacheRoot, cache } = fixture();
|
||||
const archive = stage(staging, 'one', 'same bytes');
|
||||
const first = cache.promote(archive.path, archive.digest);
|
||||
const inode = fs.statSync(first.path).ino;
|
||||
stage(staging, 'two', 'same bytes');
|
||||
const again = cache.promote('two', archive.digest);
|
||||
expect(fs.statSync(again.path).ino).toBe(inode);
|
||||
|
||||
fs.unlinkSync(path.join(cacheRoot, 'metadata', `${archive.digest}.json`));
|
||||
expect(cache.get(archive.digest)).toBeUndefined();
|
||||
expect(fs.existsSync(first.path)).toBe(false);
|
||||
expect(cache.promote(archive.path, archive.digest).sha256).toBe(archive.digest);
|
||||
});
|
||||
|
||||
test('recovers dead immutable leases, never expires a live owner by age, and fences legacy directories', () => {
|
||||
const stale = fixture();
|
||||
stale.cache.stats();const staleLeases=path.join(stale.cacheRoot,'.mutation-lock-leases'),staleToken='a'.repeat(32),staleLease=path.join(staleLeases,`${staleToken}.json`);
|
||||
fs.writeFileSync(staleLease,JSON.stringify({pid:2147483647,processIdentity:'linux:1',token:staleToken,createdAt:1})+'\n',{mode:0o600});
|
||||
expect(stale.cache.stats()).toEqual({ entries: 0, bytes: 0, maxBytes: 1024 });
|
||||
expect(fs.existsSync(staleLease)).toBe(false);expect(fs.lstatSync(path.join(stale.cacheRoot,'.lock')).isFile()).toBe(true);
|
||||
|
||||
const ownerless = fixture();
|
||||
fs.mkdirSync(path.join(ownerless.cacheRoot, '.lock'), { mode: 0o700 });
|
||||
const old = new Date(Date.now() - 60_000);
|
||||
fs.utimesSync(path.join(ownerless.cacheRoot, '.lock'), old, old);
|
||||
expect(code(()=>ownerless.cache.stats())).toBe('INSUFFICIENT_CAPACITY');expect(fs.lstatSync(path.join(ownerless.cacheRoot,'.lock')).isDirectory()).toBe(true);
|
||||
|
||||
const live = fixture();
|
||||
live.cache.stats();const liveLeases=path.join(live.cacheRoot,'.mutation-lock-leases'),liveToken='b'.repeat(32),liveLease=path.join(liveLeases,`${liveToken}.json`);
|
||||
fs.writeFileSync(liveLease,JSON.stringify({pid:process.pid,token:liveToken,createdAt:1})+'\n',{mode:0o600});fs.utimesSync(liveLease,old,old);
|
||||
expect(code(() => live.cache.stats())).toBe('INSUFFICIENT_CAPACITY');
|
||||
});
|
||||
|
||||
test('recovers an exact cache-protocol publication link and rejects an unrecognized one',()=>{
|
||||
const recovered=fixture(),lock=path.join(recovered.cacheRoot,'.lock'),temporary=`${lock}.tmp.2147483647.deadbeef`;
|
||||
fs.writeFileSync(lock,JSON.stringify({protocol:'immutable-cache-lease-set-v3'})+'\n',{mode:0o600,flag:'wx'});fs.linkSync(lock,temporary);
|
||||
expect(recovered.cache.stats()).toEqual({entries:0,bytes:0,maxBytes:1024});expect(fs.statSync(lock).nlink).toBe(1);expect(fs.existsSync(temporary)).toBe(false);
|
||||
const poisoned=fixture(),poisonedLock=path.join(poisoned.cacheRoot,'.lock'),unrecognized=`${poisonedLock}.tmp.dead.bad`;
|
||||
fs.writeFileSync(poisonedLock,JSON.stringify({protocol:'immutable-cache-lease-set-v3'})+'\n',{mode:0o600,flag:'wx'});fs.linkSync(poisonedLock,unrecognized);
|
||||
expect(code(()=>poisoned.cache.stats())).toBe('UNSAFE_PATH');expect(fs.statSync(poisonedLock).nlink).toBe(2);
|
||||
});
|
||||
|
||||
test('recovers orphan metadata and transaction tombs but refuses poisoned recovery objects', () => {
|
||||
const { root, cacheRoot, cache } = fixture();
|
||||
const digest = 'c'.repeat(64);
|
||||
fs.writeFileSync(path.join(cacheRoot, 'metadata', `${digest}.json`), JSON.stringify({
|
||||
version: 1, sha256: digest, bytes: 1, createdAt: 1, lastAccessedAt: 1,
|
||||
}), { mode: 0o600 });
|
||||
const tomb = `.recovery-entry-${digest}-${process.pid}-${'d'.repeat(24)}`;
|
||||
fs.writeFileSync(path.join(cacheRoot, 'recovery', tomb), 'interrupted', { mode: 0o400 });
|
||||
expect(cache.stats().entries).toBe(0);
|
||||
expect(fs.readdirSync(path.join(cacheRoot, 'metadata'))).toEqual([]);
|
||||
expect(fs.readdirSync(path.join(cacheRoot, 'recovery'))).toEqual([]);
|
||||
|
||||
const outside = path.join(root, 'outside-recovery');
|
||||
fs.writeFileSync(outside, 'untouched');
|
||||
fs.symlinkSync(outside, path.join(cacheRoot, 'entries', digest));
|
||||
expect(code(() => cache.get(digest))).toBe('UNSAFE_PATH');
|
||||
expect(fs.readFileSync(outside, 'utf8')).toBe('untouched');
|
||||
});
|
||||
|
||||
test('recovers atomic-write and publication hard links left by process death', () => {
|
||||
const { staging, cacheRoot, cache } = fixture();
|
||||
const archive = stage(staging, 'archive', 'crash-safe');
|
||||
const entry = cache.promote(archive.path, archive.digest);
|
||||
const metadata = path.join(cacheRoot, 'metadata', `${archive.digest}.json`);
|
||||
fs.linkSync(metadata, `${metadata}.tmp.999.${'e'.repeat(8)}`);
|
||||
expect(fs.statSync(metadata).nlink).toBe(2);
|
||||
expect(cache.get(archive.digest)?.sha256).toBe(archive.digest);
|
||||
expect(fs.statSync(metadata).nlink).toBe(1);
|
||||
|
||||
const incoming = path.join(cacheRoot, 'incoming', `.incoming-999-${'f'.repeat(24)}`);
|
||||
fs.linkSync(entry.path, incoming);
|
||||
fs.unlinkSync(metadata);
|
||||
expect(fs.statSync(entry.path).nlink).toBe(2);
|
||||
expect(cache.get(archive.digest)).toBeUndefined();
|
||||
expect(fs.existsSync(incoming)).toBe(false);
|
||||
expect(fs.existsSync(entry.path)).toBe(false);
|
||||
});
|
||||
|
||||
test('a paused immutable lease publisher cannot be age-reclaimed or overlap a contender', () => {
|
||||
const { cacheRoot, cache } = fixture(),lock=path.join(cacheRoot,'.lock'),leases=path.join(cacheRoot,'.mutation-lock-leases'),originalMkdir=fs.mkdirSync,originalUnlink=fs.unlinkSync;
|
||||
let paused=false,contenderResult:string|undefined;
|
||||
const contend=(published:string)=>{paused=true;const old=new Date(Date.now()-60_000);fs.utimesSync(published,old,old);contenderResult=code(()=>cache.stats());};
|
||||
// The mkdir hook deterministically reproduces the former empty-directory
|
||||
// publication. The unlink hook pauses the immutable replacement after its
|
||||
// complete inode is visible and its temporary hard link is gone.
|
||||
const mkdir=spyOn(fs,'mkdirSync').mockImplementation(((target:fs.PathLike,options?:fs.MakeDirectoryOptions & {recursive?:false})=>{const result=originalMkdir(target,options as any);if(!paused&&String(target)===lock)contend(lock);return result;}) as typeof fs.mkdirSync);
|
||||
const unlink=spyOn(fs,'unlinkSync').mockImplementation(((target:fs.PathLike)=>{const value=String(target),result=originalUnlink(target);if(!paused&&value.startsWith(`${leases}${path.sep}`)&&/^[a-f0-9]{32}\.json\.tmp\.\d+\.[a-f0-9]{8}$/.test(path.basename(value)))contend(value.replace(/\.tmp\.\d+\.[a-f0-9]{8}$/,''));return result;}) as typeof fs.unlinkSync);
|
||||
let result;try{result=cache.stats();}finally{unlink.mockRestore();mkdir.mockRestore();}
|
||||
expect(paused).toBe(true);expect(contenderResult).toBe('INSUFFICIENT_CAPACITY');expect(result).toEqual({entries:0,bytes:0,maxBytes:1024});expect(fs.readdirSync(leases)).toEqual([]);expect(fs.lstatSync(lock).isFile()).toBe(true);
|
||||
});
|
||||
|
||||
test('LRU eviction refuses poisoned links without following or deleting their targets', () => {
|
||||
const { root, staging, cacheRoot, cache } = fixture(4);
|
||||
const outside = path.join(root, 'outside');
|
||||
fs.writeFileSync(outside, 'do-not-touch', { mode: 0o600 });
|
||||
const digest = 'a'.repeat(64);
|
||||
fs.symlinkSync(outside, path.join(cacheRoot, 'entries', digest));
|
||||
fs.writeFileSync(path.join(cacheRoot, 'metadata', `${digest}.json`), JSON.stringify({ version: 1, sha256: digest, bytes: 12, createdAt: 1, lastAccessedAt: 1 }), { mode: 0o600 });
|
||||
const fresh = stage(staging, 'fresh', '1234');
|
||||
expect(code(() => cache.promote(fresh.path, fresh.digest))).toBe('UNSAFE_PATH');
|
||||
expect(fs.readFileSync(outside, 'utf8')).toBe('do-not-touch');
|
||||
});
|
||||
|
||||
test('fails closed on cache locks and poisoned incoming paths', () => {
|
||||
const { staging, cacheRoot, cache } = fixture();
|
||||
const archive = stage(staging, 'one', 'archive');
|
||||
const lockTarget = path.join(cacheRoot, 'outside-lock');
|
||||
fs.writeFileSync(lockTarget, 'lock-target');
|
||||
fs.symlinkSync(lockTarget, path.join(cacheRoot, '.lock'));
|
||||
expect(code(() => cache.promote(archive.path, archive.digest))).toBe('UNSAFE_PATH');
|
||||
fs.unlinkSync(path.join(cacheRoot, '.lock'));
|
||||
|
||||
const outside = path.join(cacheRoot, 'outside-incoming');
|
||||
fs.writeFileSync(outside, 'outside');
|
||||
fs.symlinkSync(outside, path.join(cacheRoot, 'incoming', `.incoming-${process.pid}-${'b'.repeat(24)}`));
|
||||
expect(code(() => cache.promote(archive.path, archive.digest))).toBe('UNSAFE_PATH');
|
||||
expect(fs.readFileSync(outside, 'utf8')).toBe('outside');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { canonical, sha256, type VerificationObservation } from '../lib/cso/contracts';
|
||||
import { ISOLATION_POLICY_HASH } from '../lib/cso/docker';
|
||||
import { saveReport, withLock } from '../lib/cso/state';
|
||||
import { canonicalStartPlan, canonicalTestPlan, patchHash, verifyRepair } from '../lib/cso/verification';
|
||||
|
||||
const ROOT=path.resolve(import.meta.dir,'..'),launcher=path.join(ROOT,'bin',process.platform==='win32'?'gstack-cso-launcher.exe':'gstack-cso-launcher');
|
||||
const tap=`TAP version 13
|
||||
# Subtest: legitimate control remains available
|
||||
ok 1 - legitimate control remains available
|
||||
---
|
||||
duration_ms: 1
|
||||
...
|
||||
1..1
|
||||
# tests 1
|
||||
# suites 0
|
||||
# pass 1
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
`;
|
||||
let root='',repo='',state='';
|
||||
|
||||
function command(args:string[]){return spawnSync(launcher,args,{cwd:repo,encoding:'utf8',env:{HOME:root,GSTACK_HOME:state,PATH:'/usr/bin:/bin'},timeout:30_000});}
|
||||
function git(...args:string[]){const result=spawnSync('/usr/bin/git',['-C',repo,...args],{encoding:'utf8',env:{HOME:root,PATH:'/usr/bin:/bin'},timeout:30_000});if(result.status)throw new Error(result.stderr);return result.stdout;}
|
||||
function runDir(run:any){return path.join(state,'security','cso',run.repoId,run.runId);}
|
||||
function writeInput(name:string,value:unknown){const file=path.join(root,name);fs.writeFileSync(file,JSON.stringify(value));return file;}
|
||||
|
||||
beforeEach(()=>{
|
||||
root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-cli-lifecycle-'));repo=path.join(root,'repo');state=path.join(root,'state');fs.mkdirSync(repo);
|
||||
git('init','-q');git('config','user.email','fixture@example.test');git('config','user.name','Fixture');
|
||||
fs.writeFileSync(path.join(repo,'package.json'),JSON.stringify({name:'cso-cli-lifecycle',version:'1.0.0',private:true,scripts:{start:'node app.js',test:'node --test'}})+'\n');
|
||||
fs.writeFileSync(path.join(repo,'package-lock.json'),JSON.stringify({name:'cso-cli-lifecycle',version:'1.0.0',lockfileVersion:3,requires:true,packages:{'':{name:'cso-cli-lifecycle',version:'1.0.0'}}})+'\n');
|
||||
fs.writeFileSync(path.join(repo,'app.js'),'module.exports = "vulnerable"\n');
|
||||
fs.writeFileSync(path.join(repo,'app.test.js'),"const test=require('node:test');test('legitimate control remains available',()=>{});\n");
|
||||
git('add','.');git('commit','-qm','fixture');
|
||||
});
|
||||
afterEach(()=>fs.rmSync(root,{recursive:true,force:true}));
|
||||
|
||||
describe('CSO interrupted-run and replay commands',()=>{
|
||||
test('resume reports watchdog recovery without replenishing the original policy or deadline',()=>{
|
||||
const run=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline','--budget','120']).stdout),dir=runDir(run),reportPath=path.join(dir,'report.json'),original=JSON.parse(fs.readFileSync(reportPath,'utf8'));
|
||||
const recovery='supervisor-death execution-copy cleanup complete',dockerRecovery='deadline cleanup complete',control=path.join(dir,'supervision','repair-attempt'),preparationControl=path.join(dir,'preparation-execution','offline-attempt');fs.mkdirSync(control,{recursive:true});fs.mkdirSync(preparationControl,{recursive:true});fs.writeFileSync(path.join(control,'attempt.event'),`${recovery}\n`);fs.writeFileSync(path.join(preparationControl,'watchdog.event'),`${dockerRecovery}\n`);
|
||||
original.status='interrupted';saveReport(dir,original);
|
||||
|
||||
const resumed=command(['resume',run.runId]);expect(resumed.status).toBe(0);const result=JSON.parse(resumed.stdout),active=JSON.parse(fs.readFileSync(reportPath,'utf8'));
|
||||
expect(result).toMatchObject({runId:run.runId,deadline:original.deadline,policy:original.policy,recovery:[recovery,dockerRecovery]});expect(active.status).toBe('running');expect(active.deadline).toBe(original.deadline);expect(active.policy).toEqual(original.policy);
|
||||
expect(active.events.filter((item:any)=>item.kind==='watchdog-recovery'&&[recovery,dockerRecovery].includes(item.message))).toHaveLength(2);expect(active.events.at(-1)).toMatchObject({kind:'resume',message:'Continued retained snapshot under original policy'});
|
||||
|
||||
active.status='interrupted';active.deadline=new Date(Date.now()-1_000).toISOString();const exhaustedDeadline=active.deadline;saveReport(dir,active);
|
||||
const expired=command(['resume',run.runId]);expect(expired.status).not.toBe(0);expect(expired.stderr).toContain('DEADLINE');expect(expired.stderr).toContain('Original run budget is exhausted');
|
||||
const retained=JSON.parse(fs.readFileSync(reportPath,'utf8'));expect(retained.status).toBe('interrupted');expect(retained.deadline).toBe(exhaustedDeadline);expect(retained.policy).toEqual(original.policy);expect(retained.events.at(-1)).toMatchObject({kind:'deadline',message:'Original budget is exhausted; resume did not replenish it'});
|
||||
expect(retained.events.filter((item:any)=>item.kind==='watchdog-recovery'&&[recovery,dockerRecovery].includes(item.message))).toHaveLength(2);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform==='win32')('replay accepts retained or exactly matching supplied source and rejects expired or changed inputs before Docker admission',async()=>{
|
||||
const started=command(['start','--repo',repo,'--comprehensive','--offline']);expect(started.status).toBe(0);const run=JSON.parse(started.stdout),dir=runDir(run),snapshot=path.join(dir,'snapshot'),manifest=JSON.parse(fs.readFileSync(path.join(dir,'snapshot.json'),'utf8'));
|
||||
const finding={title:'Unprotected record read',rootCause:'Record lookup omits the caller authorization predicate',location:{path:'app.js',line:1,symbol:'recordLookup'},advisoryIds:[],severity:'high',confidence:'high',confidenceRationale:'The caller-to-record trace directly establishes the missing authorization predicate',evidence:'supported',attackerControl:'Authenticated caller chooses the record identifier',impact:'Another tenant record is returned',scenario:'A tenant supplies another tenant record identifier and receives the record',trace:['GET /records/:id','recordLookup','record response'],references:['app.js:1','OWASP API1:2023'],recommendation:'Bind the lookup to the authenticated caller before returning the record',challenge:{reviewer:'independent-reviewer',independent:true,mode:'independent_agent',callers:'Authenticated route forwards the record identifier',controls:'Authentication exists without record authorization',counterevidence:'Opaque identifiers reduce guessing but do not authorize known identifiers',conclusion:'The record lookup lacks a caller authorization predicate'}};
|
||||
expect(command(['submit',run.runId,writeInput('replay-finding.json',{findings:[finding]})]).status).toBe(0);const reportPath=path.join(dir,'report.json'),report=JSON.parse(fs.readFileSync(reportPath,'utf8')),findingId=report.findings[0].id,start=canonicalStartPlan(snapshot,'node',3456),tests=canonicalTestPlan(snapshot,'node');
|
||||
const request:any={findingId,runtimeProfile:`node-24.4.0-${process.arch==='arm64'?'arm64':'amd64'}`,port:3456,start:start.command,legitimate:[{name:'legitimate control',path:'/control',method:'GET',expected:{status:200,includes:'CONTROL_OK'}}],security:{name:'unauthorized record is denied',path:'/records/other',method:'GET',expected:{status:403,includes:'DENIED'},vulnerable:{status:200,includes:'RECORD'}},existingTests:tests.commands,fixtures:{},boundaryFiles:['app.js'],testFiles:tests.files,changes:[{path:'app.js',beforeSha256:sha256(fs.readFileSync(path.join(snapshot,'app.js'))),after:'module.exports = "fixed"\n',effect:'source'}],review:{reviewer:'independent-reviewer',independent:true,rootCauseRepaired:true,featurePreserved:true,boundaryMocks:false,rationale:'The caller predicate is added while the legitimate control and existing test remain unchanged.',reviewedPatchHash:''}};request.review.reviewedPatchHash=patchHash(request);
|
||||
const platform=process.arch==='arm64'?'linux/arm64':'linux/amd64',runtime:any={id:request.runtimeProfile,stack:'node',platform,image:`ghcr.io/garrytan/gstack/cso-staging/node-${process.arch==='arm64'?'arm64':'amd64'}@sha256:${'b'.repeat(64)}`},executor={observe:async(_source:string,phase:'before'|'after',received:any,_runtime:any,_verifier:any,_work:string,_control:string,_execution:any,evidence:any,witness:any)=>{const observation:VerificationObservation={booted:true,legitimate:true,security:phase==='before'?'intended_failure':'pass',existingTests:false,output:`external ${phase} assertions passed`,inputHash:''},receipt=await witness.attest(observation,[{command:received.existingTests[0],code:0,output:tap,minimumPassingTests:evidence.minimumPassingTests[0]}]);return{observation:{...observation,existingTests:receipt.diagnosticTestsPassed,inputHash:witness.binding.harnessHash},witness:receipt};}};
|
||||
const verified=await verifyRepair({runId:run.runId,runDir:dir,manifest,rawRequest:request,runtime,verifier:runtime,policyHash:ISOLATION_POLICY_HASH,auditPolicyHash:sha256(canonical(report.policy)),archives:[],executor});
|
||||
report.findings[0].reproduction='reproduced';report.findings[0].repair='runtime_tested';report.findings[0].verificationId=verified.bundle.id;report.findings[0].verificationAssurance={assertions:'authenticated_out_of_process',testCompletion:'self_reported',review:'self_attested'};saveReport(dir,report);
|
||||
|
||||
const leased=withLock(dir,()=>command(['replay',verified.bundle.id])) as ReturnType<typeof command>;expect(leased.status).not.toBe(0);expect(leased.stderr).toContain('INSUFFICIENT_CAPACITY');expect(leased.stderr).toContain('Another helper is updating this run');
|
||||
const retained=command(['replay',verified.bundle.id]);expect(retained.status).not.toBe(0);expect(retained.stderr).toContain('PREREQUISITE');expect(retained.stderr).toContain('MISSING_QUALIFIED_RUNTIME');
|
||||
const manifestPath=path.join(dir,'snapshot.json'),expiredManifest={...manifest,expiresAt:new Date(Date.now()-1_000).toISOString()};fs.writeFileSync(manifestPath,`${JSON.stringify(expiredManifest,null,2)}\n`);
|
||||
const expiredPresent=command(['replay',verified.bundle.id]);expect(expiredPresent.status).not.toBe(0);expect(expiredPresent.stderr).toContain('MISSING_INPUT');expect(expiredPresent.stderr).toContain('Retained source expired');
|
||||
const suppliedExpired=command(['replay',verified.bundle.id,'--source',repo]);expect(suppliedExpired.status).not.toBe(0);expect(suppliedExpired.stderr).toContain('PREREQUISITE');expect(suppliedExpired.stderr).toContain('MISSING_QUALIFIED_RUNTIME');expect(suppliedExpired.stderr).not.toContain('Retained source expired');
|
||||
const noncanonicalExpiry=new Date(Date.now()+86400_000).toISOString().replace(/\.\d{3}Z$/,'Z');fs.writeFileSync(manifestPath,`${JSON.stringify({...manifest,expiresAt:noncanonicalExpiry},null,2)}\n`);
|
||||
const invalidExpiry=command(['replay',verified.bundle.id,'--source',repo]);expect(invalidExpiry.status).not.toBe(0);expect(invalidExpiry.stderr).toContain('INCOMPATIBLE_INPUT');expect(invalidExpiry.stderr).toContain('Retained snapshot expiry is invalid');expect(invalidExpiry.stderr).not.toContain('MISSING_QUALIFIED_RUNTIME');
|
||||
fs.writeFileSync(manifestPath,`${JSON.stringify({...manifest,expiresAt:new Date(Date.now()+86400_000).toISOString()},null,2)}\n`);fs.writeFileSync(path.join(snapshot,'app.js'),'module.exports = "tampered"\n');
|
||||
const corruptRetained=command(['replay',verified.bundle.id,'--source',repo]);expect(corruptRetained.status).not.toBe(0);expect(corruptRetained.stderr).toContain('INCOMPATIBLE_INPUT');expect(corruptRetained.stderr).toContain('Retained snapshot changed');expect(corruptRetained.stderr).not.toContain('MISSING_QUALIFIED_RUNTIME');
|
||||
fs.rmSync(snapshot,{recursive:true,force:true});fs.rmSync(path.join(dir,'readable'),{recursive:true,force:true});
|
||||
const expired=command(['replay',verified.bundle.id]);expect(expired.status).not.toBe(0);expect(expired.stderr).toContain('MISSING_INPUT');expect(expired.stderr).toContain('Retained source expired');
|
||||
fs.writeFileSync(path.join(repo,'app.js'),'module.exports = "changed"\n');const changed=command(['replay',verified.bundle.id,'--source',repo]);expect(changed.status).not.toBe(0);expect(changed.stderr).toContain('INCOMPATIBLE_INPUT');expect(changed.stderr).toContain('does not match the bundle input hashes');
|
||||
fs.writeFileSync(path.join(repo,'app.js'),'module.exports = "vulnerable"\n');const supplied=command(['replay',verified.bundle.id,'--source',repo]);expect(supplied.status).not.toBe(0);expect(supplied.stderr).toContain('PREREQUISITE');expect(supplied.stderr).toContain('MISSING_QUALIFIED_RUNTIME');expect(supplied.stderr).not.toContain('does not match the bundle input hashes');
|
||||
},30_000);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
const ROOT=path.resolve(import.meta.dir,'..'),launcher=path.join(ROOT,'bin',process.platform==='win32'?'gstack-cso-launcher.exe':'gstack-cso-launcher');
|
||||
let root='',repo='',state='';
|
||||
const application={actors:['tenant user'],assets:['tenant records'],entrypoints:['GET /users/:id'],tenantBoundaries:['tenant id'],sensitiveOperations:['record read'],invariants:['tenant isolation']};
|
||||
const finding={title:'Cross-tenant user read',rootCause:'Tenant query omits caller tenant predicate',location:{path:'src/users.ts',line:1,symbol:'tenantQuery'},advisoryIds:[],severity:'high',confidence:'high',confidenceRationale:'The caller-to-query trace and missing tenant predicate directly support the finding',evidence:'supported',attackerControl:'Authenticated caller chooses the record ID',impact:'Another tenant record is returned',scenario:'A tenant supplies a known record ID owned by another tenant and receives that record',trace:['GET /users/:id','tenantQuery','findUnique by id'],references:['src/users.ts:1','OWASP API1:2023'],recommendation:'Bind the lookup predicate to the authenticated tenant identifier',challenge:{reviewer:'independent-2',independent:true,mode:'independent_agent',callers:'Authenticated route forwards the ID',controls:'Authentication does not bind tenant',counterevidence:'Opaque IDs reduce guessing but do not authorize',conclusion:'The authorization invariant is absent'}};
|
||||
function command(args:string[]){return spawnSync(launcher,args,{cwd:repo,encoding:'utf8',env:{HOME:root,GSTACK_HOME:state,PATH:'/usr/bin:/bin'},timeout:30_000});}
|
||||
function git(...args:string[]){const result=spawnSync('/usr/bin/git',['-C',repo,...args],{encoding:'utf8',env:{HOME:root,PATH:'/usr/bin:/bin'},timeout:30_000});if(result.status)throw new Error(result.stderr);return result.stdout;}
|
||||
function reportPath(run:any){return path.join(state,'security','cso',run.repoId,run.runId,'report.json');}
|
||||
function completeEvidence(report:any,findings:any[]=[]){return{application,findings,coverage:report.coverage.filter((item:any)=>!['snapshot-inputs','history-inputs'].includes(item.domain)).map((item:any)=>({...item,status:'assessed',method:'fresh caller and boundary trace',gaps:[],evidence:['current captured source']})),gaps:[]};}
|
||||
function writeInput(name:string,value:unknown){const file=path.join(root,name);fs.writeFileSync(file,JSON.stringify(value));return file;}
|
||||
function recheckEvidence(source='src/users.ts'){return[
|
||||
{kind:'caller',path:source,line:1,observation:'Fresh caller trace reaches the authorization predicate'},
|
||||
{kind:'security_boundary',path:source,line:1,observation:'Fresh boundary trace confirms cross-tenant access is denied'},
|
||||
];}
|
||||
function finishedOriginal(name:string,candidate:any){
|
||||
const run=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),initial=JSON.parse(fs.readFileSync(reportPath(run),'utf8'));
|
||||
expect(command(['submit',run.runId,writeInput(`${name}.json`,completeEvidence(initial,[candidate]))]).status).toBe(0);
|
||||
expect(command(['finish',run.runId]).status).toBe(0);
|
||||
return{run,findingId:JSON.parse(fs.readFileSync(reportPath(run),'utf8')).findings[0].id};
|
||||
}
|
||||
beforeAll(()=>{root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-cli-recheck-'));repo=path.join(root,'repo');state=path.join(root,'state');fs.mkdirSync(repo);git('init','-q');git('config','user.email','fixture@example.test');git('config','user.name','Fixture');fs.mkdirSync(path.join(repo,'src'));fs.writeFileSync(path.join(repo,'src','users.ts'),'export const tenantQuery = (id:string) => db.user.findUnique({where:{id}})\n');fs.writeFileSync(path.join(repo,'src','other.ts'),'export const unrelated = true\n');git('add','src/users.ts','src/other.ts');git('commit','-qm','fixture');});
|
||||
afterAll(()=>fs.rmSync(root,{recursive:true,force:true}));
|
||||
|
||||
describe('CSO recheck persistence',()=>{
|
||||
test('resolves a unique finished original audit from the current repository',()=>{const candidate={...finding,rootCause:'Unique fixture query omits its tenant predicate',location:{...finding.location,symbol:'uniqueTenantQuery'}},original=finishedOriginal('unique-original',candidate),rechecked=command(['recheck',original.findingId,'--repo',repo]);expect(rechecked.status).toBe(0);expect(rechecked.stderr).toBe('');const child=JSON.parse(rechecked.stdout);expect(child.parent).toEqual({runId:original.run.runId,findingId:original.findingId,kind:'recheck'});},30_000);
|
||||
test('reports a missing original finding without requiring an internal run ID',()=>{const missing=command(['recheck','0'.repeat(32),'--repo',repo]);expect(missing.status).not.toBe(0);expect(missing.stderr).toContain('MISSING_INPUT');expect(missing.stderr).toContain('No finished original audit contains this finding');},30_000);
|
||||
test('rejects ambiguous originals and accepts --run as an explicit disambiguator',()=>{const candidate={...finding,rootCause:'Ambiguous fixture query omits its tenant predicate',location:{...finding.location,symbol:'ambiguousTenantQuery'}},first=finishedOriginal('ambiguous-original-first',candidate),second=finishedOriginal('ambiguous-original-second',candidate);expect(second.findingId).toBe(first.findingId);const ambiguous=command(['recheck',first.findingId,'--repo',repo]);expect(ambiguous.status).not.toBe(0);expect(ambiguous.stderr).toContain('INVALID_ARGUMENT');expect(ambiguous.stderr).toContain('--run RUN');const selected=command(['recheck',first.findingId,'--repo',repo,'--run',second.run.runId]);expect(selected.status).toBe(0);expect(JSON.parse(selected.stdout).parent.runId).toBe(second.run.runId);},30_000);
|
||||
test('requires an immutable finished original audit',()=>{const run=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),report=JSON.parse(fs.readFileSync(reportPath(run),'utf8'));expect(command(['submit',run.runId,writeInput('unfinished.json',completeEvidence(report,[finding]))]).status).toBe(0);const id=JSON.parse(fs.readFileSync(reportPath(run),'utf8')).findings[0].id,recheck=command(['recheck',id,'--run',run.runId,'--repo',repo]);expect(recheck.status).not.toBe(0);expect(recheck.stderr).toContain('finished original audit');},30_000);
|
||||
test('resolved closure rejects free-form or unrelated boundary evidence',()=>{const original=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),originalPath=reportPath(original),initial=JSON.parse(fs.readFileSync(originalPath,'utf8'));expect(command(['submit',original.runId,writeInput('bound-original.json',completeEvidence(initial,[finding]))]).status).toBe(0);expect(command(['finish',original.runId]).status).toBe(0);const findingId=JSON.parse(fs.readFileSync(originalPath,'utf8')).findings[0].id,started=JSON.parse(command(['recheck',findingId,'--run',original.runId,'--repo',repo]).stdout);
|
||||
const freeForm=command(['submit',started.runId,writeInput('free-form-claim.json',{recheck:{findingId,outcome:'resolved',evidence:['caller words','boundary words'],rootCause:finding.rootCause}})]);expect(freeForm.status).not.toBe(0);expect(freeForm.stderr).toContain('recheck evidence[0]');
|
||||
const unrelated=command(['submit',started.runId,writeInput('unrelated-boundary-claim.json',{recheck:{findingId,outcome:'resolved',evidence:[{kind:'caller',path:'src/users.ts',line:1,observation:'Fresh caller trace reaches the predicate'},{kind:'security_boundary',path:'src/other.ts',line:1,observation:'Unrelated source says nothing about authorization'}],rootCause:finding.rootCause}})]);expect(unrelated.status).not.toBe(0);expect(unrelated.stderr).toContain('original finding location');
|
||||
const phantomLine=command(['submit',started.runId,writeInput('phantom-line-claim.json',{recheck:{findingId,outcome:'resolved',evidence:[{kind:'caller',path:'src/users.ts',line:2,observation:'A trailing newline must not create a source line'},{kind:'security_boundary',path:'src/users.ts',line:1,observation:'Fresh boundary trace checks authorization'}],rootCause:finding.rootCause}})]);expect(phantomLine.status).not.toBe(0);expect(phantomLine.stderr).toContain('line is outside the fresh source file');
|
||||
},30_000);
|
||||
test('a failed report write cannot leave a closure claim behind',()=>{const original=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),originalPath=reportPath(original),initial=JSON.parse(fs.readFileSync(originalPath,'utf8'));expect(command(['submit',original.runId,writeInput('original.json',completeEvidence(initial,[finding]))]).status).toBe(0);expect(command(['finish',original.runId]).status).toBe(0);const originalReport=JSON.parse(fs.readFileSync(originalPath,'utf8')),findingId=originalReport.findings[0].id,started=command(['recheck',findingId,'--run',original.runId,'--repo',repo]);expect(started.status).toBe(0);const child=JSON.parse(started.stdout),childPath=reportPath({...child,repoId:original.repoId}),childDir=path.dirname(childPath),childInitial=JSON.parse(fs.readFileSync(childPath,'utf8'));expect(command(['submit',child.runId,writeInput('child-baseline.json',completeEvidence(childInitial))]).status).toBe(0);const complete=JSON.parse(fs.readFileSync(childPath,'utf8'));expect(complete.completeness).toBe('complete');complete.events.push({at:new Date().toISOString(),kind:'padding',message:''});const empty=Buffer.byteLength(JSON.stringify(complete,null,2)+'\n'),target=1024*1024-8;complete.events.at(-1).message='x'.repeat(target-empty);const encoded=JSON.stringify(complete,null,2)+'\n';expect(Buffer.byteLength(encoded)).toBe(target);fs.writeFileSync(childPath,encoded);const failed=command(['submit',child.runId,writeInput('failed-claim.json',{modelUsage:{source:'host',tokens:0},recheck:{findingId,outcome:'resolved',evidence:recheckEvidence(),rootCause:finding.rootCause}})]);expect(failed.status).not.toBe(0);expect(failed.stderr).toContain('PERSISTENCE_FAILED');expect(fs.existsSync(path.join(childDir,'recheck-claim.json'))).toBe(false);},30_000);
|
||||
test.skipIf(process.platform==='win32')('retention and recheck share the lease that protects an expiring parent',async()=>{const original=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),currentDir=path.dirname(reportPath(original)),initial=JSON.parse(fs.readFileSync(reportPath(original),'utf8'));expect(command(['submit',original.runId,writeInput('leased-original.json',completeEvidence(initial,[finding]))]).status).toBe(0);expect(command(['finish',original.runId]).status).toBe(0);const finished=JSON.parse(fs.readFileSync(reportPath(original),'utf8')),findingId=finished.findings[0].id,created=Date.now()-30*86400_000+60_000,oldRun=`${created}-${'8'.repeat(16)}`,oldDir=path.join(path.dirname(currentDir),oldRun);finished.runId=oldRun;fs.writeFileSync(path.join(currentDir,'report.json'),JSON.stringify(finished,null,2)+'\n');fs.renameSync(currentDir,oldDir);original.runId=oldRun;
|
||||
const leases=path.join(oldDir,'.mutation-lock-leases'),active=/^[a-f0-9]{32}\.active\.[a-f0-9]{16}$/,ready=path.join(root,'lease-ready'),release=path.join(root,'lease-release'),holderSource=`import * as fs from 'node:fs';import {withLock} from ${JSON.stringify(path.join(ROOT,'lib/cso/state.ts'))};await withLock(${JSON.stringify(oldDir)},async()=>{fs.writeFileSync(${JSON.stringify(ready)},'ready',{flag:'wx'});while(!fs.existsSync(${JSON.stringify(release)}))await Bun.sleep(5);});`;
|
||||
let holderStderr='';const holder=spawn(process.execPath,['--eval',holderSource],{env:{HOME:root,GSTACK_HOME:state,PATH:process.env.PATH},stdio:['ignore','ignore','pipe']}),holderClosed=new Promise<number|null>(resolve=>holder.on('close',resolve));holder.stderr!.setEncoding('utf8');holder.stderr!.on('data',chunk=>holderStderr+=chunk);let holderCode:number|null|'timeout'='timeout';
|
||||
try{const readyOutcome=await Promise.race([(async()=>{const deadline=Date.now()+10_000;while(Date.now()<deadline){if(fs.existsSync(ready))return'ready' as const;await Bun.sleep(5);}return'timeout' as const;})(),holderClosed.then(code=>`closed:${code}` as const)]);expect(readyOutcome).toBe('ready');expect(fs.readdirSync(leases).some(name=>active.test(name))).toBe(true);const sweepNow=created+30*86400_000+1,sweep=spawnSync(process.execPath,['--eval',`import {retention} from ${JSON.stringify(path.join(ROOT,'lib/cso/state.ts'))};retention(${sweepNow});`],{encoding:'utf8',env:{HOME:root,GSTACK_HOME:state,PATH:process.env.PATH},timeout:30_000});expect(sweep.status).toBe(0);expect(fs.existsSync(path.join(oldDir,'report.json'))).toBe(true);const blocked=command(['recheck',findingId,'--run',oldRun,'--repo',repo]);expect(blocked.status).not.toBe(0);expect(blocked.stderr).toContain('INSUFFICIENT_CAPACITY');expect(blocked.stderr).toContain('Another helper is updating this run');}finally{fs.writeFileSync(release,'release');holderCode=await Promise.race([holderClosed,Bun.sleep(10_000).then(()=>'timeout' as const)]);if(holderCode==='timeout'){holder.kill('SIGKILL');holderCode=await holderClosed;}}
|
||||
expect(holderCode).toBe(0);expect(holderStderr).toBe('');expect(fs.readdirSync(leases)).toEqual([]);const completed=command(['recheck',findingId,'--run',oldRun,'--repo',repo]);expect(completed.status).toBe(0);expect(completed.stderr).toBe('');const result=JSON.parse(completed.stdout);expect(fs.existsSync(path.join(oldDir,'report.json'))).toBe(true);expect(fs.existsSync(path.join(state,'security','cso',original.repoId,result.runId,'report.json'))).toBe(true);
|
||||
},30_000);
|
||||
test('closure cannot survive mutation of the fresh recheck snapshot',()=>{const original=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),originalPath=reportPath(original),initial=JSON.parse(fs.readFileSync(originalPath,'utf8'));expect(command(['submit',original.runId,writeInput('source-bound-original.json',completeEvidence(initial,[finding]))]).status).toBe(0);expect(command(['finish',original.runId]).status).toBe(0);const originalReport=JSON.parse(fs.readFileSync(originalPath,'utf8')),findingId=originalReport.findings[0].id,started=command(['recheck',findingId,'--run',original.runId,'--repo',repo]);expect(started.status).toBe(0);const child=JSON.parse(started.stdout),childPath=reportPath({...child,repoId:original.repoId}),childDir=path.dirname(childPath),childInitial=JSON.parse(fs.readFileSync(childPath,'utf8')),claim={findingId,outcome:'resolved',evidence:recheckEvidence(),rootCause:finding.rootCause};expect(command(['submit',child.runId,writeInput('source-bound-claim.json',{...completeEvidence(childInitial),recheck:claim})]).status).toBe(0);fs.writeFileSync(path.join(childDir,'snapshot','src','users.ts'),'mutated retained evidence\n');const finished=command(['finish',child.runId]);expect(finished.status).not.toBe(0);expect(finished.stderr).toContain('Retained snapshot changed');expect(JSON.parse(fs.readFileSync(originalPath,'utf8')).findings[0].closure).toBe('open');},30_000);
|
||||
test('complete fresh-source evidence resolves only the targeted original finding',()=>{const second={...finding,title:'Unprotected audit export',rootCause:'Audit export omits the caller authorization predicate',location:{...finding.location,symbol:'auditExport'},trace:['GET /audit/export','auditExport','record response'],references:['src/users.ts:1'],scenario:'A tenant requests another tenant audit export and receives it'},original=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),originalPath=reportPath(original),initial=JSON.parse(fs.readFileSync(originalPath,'utf8'));expect(command(['submit',original.runId,writeInput('closure-original.json',completeEvidence(initial,[finding,second]))]).status).toBe(0);expect(command(['finish',original.runId]).status).toBe(0);const finishedOriginal=JSON.parse(fs.readFileSync(originalPath,'utf8')),findingId=finishedOriginal.findings.find((item:any)=>item.rootCause===finding.rootCause).id,otherId=finishedOriginal.findings.find((item:any)=>item.rootCause===second.rootCause).id,originalHash=finishedOriginal.source.originalHash,source=path.join(repo,'src','users.ts'),before=fs.readFileSync(source,'utf8');
|
||||
try{
|
||||
fs.writeFileSync(source,'export const tenantQuery = (tenantId:string,id:string) => db.user.findUnique({where:{id,tenantId}})\n');const started=command(['recheck',findingId,'--run',original.runId,'--repo',repo]);expect(started.status).toBe(0);const child=JSON.parse(started.stdout),childPath=reportPath({...child,repoId:original.repoId}),childInitial=JSON.parse(fs.readFileSync(childPath,'utf8')),claim={findingId,outcome:'resolved',evidence:recheckEvidence(),rootCause:finding.rootCause};expect(childInitial.source.originalHash).not.toBe(originalHash);expect(JSON.parse(fs.readFileSync(originalPath,'utf8')).findings.every((item:any)=>item.closure==='open')).toBe(true);
|
||||
expect(command(['submit',child.runId,writeInput('closure-claim-first.json',{recheck:claim})]).status).toBe(0);expect(JSON.parse(fs.readFileSync(originalPath,'utf8')).findings.find((item:any)=>item.id===findingId).closure).toBe('open');const incomplete=command(['finish',child.runId]);expect(incomplete.status).not.toBe(0);expect(incomplete.stderr).toContain('Partial or incompatible rechecks cannot establish closure');expect(JSON.parse(fs.readFileSync(originalPath,'utf8')).findings.find((item:any)=>item.id===findingId).closure).toBe('open');
|
||||
expect(command(['submit',child.runId,writeInput('closure-fresh-evidence.json',completeEvidence(childInitial))]).status).toBe(0);const closed=command(['finish',child.runId]);expect(closed.status).toBe(0);expect(JSON.parse(closed.stdout)).toMatchObject({status:'finished',completeness:'complete'});const updated=JSON.parse(fs.readFileSync(originalPath,'utf8'));expect(updated.findings.find((item:any)=>item.id===findingId).closure).toBe('resolved');expect(updated.findings.find((item:any)=>item.id===otherId).closure).toBe('open');expect(updated.events.at(-1)).toMatchObject({kind:'closure'});expect(updated.events.at(-1).message).toContain(`resolved ${findingId}`);
|
||||
}finally{fs.writeFileSync(source,before);}
|
||||
},30_000);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';import * as os from 'node:os';import * as path from 'node:path';import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fingerprint, sha256 } from '../lib/cso/contracts';
|
||||
import { patchHash } from '../lib/cso/verification';
|
||||
import { redact } from '../lib/cso/process';
|
||||
const ROOT=path.resolve(import.meta.dir,'..'),launcher=path.join(ROOT,'bin',process.platform==='win32'?'gstack-cso-launcher.exe':'gstack-cso-launcher');let root='',repo='',state='';
|
||||
const CREDENTIAL_CANARY=['ghp_','abcdefghijklmnopqrstuvwxyz1234567890'].join('');
|
||||
function command(args:string[],extra:Record<string,string>={},file=launcher){const r=spawnSync(file,args,{cwd:repo,encoding:'utf8',env:{HOME:root,GSTACK_HOME:state,PATH:`${repo}/hostile-bin:/usr/bin:/bin`,BUN_OPTIONS:`--preload=${repo}/preload.ts`,BUN_BE_BUN:'1',NODE_OPTIONS:`--require=${repo}/preload.cjs`,RUBYOPT:`-r${repo}/preload.rb`,PYTHONPATH:repo,LD_PRELOAD:path.join(repo,'missing.so'),BASH_ENV:path.join(repo,'bashenv'),...extra},timeout:30_000});return r;}
|
||||
function git(...args:string[]){const r=spawnSync('/usr/bin/git',['-C',repo,...args],{encoding:'utf8',env:{HOME:root,PATH:'/usr/bin:/bin'},timeout:30_000});if(r.status)throw new Error(r.stderr);return r.stdout;}
|
||||
beforeAll(()=>{root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-cli-'));repo=path.join(root,'repo');state=path.join(root,'state');fs.mkdirSync(repo);git('init','-q');git('config','user.email','fixture@example.test');git('config','user.name','Fixture');fs.mkdirSync(path.join(repo,'src'));fs.writeFileSync(path.join(repo,'src','users.ts'),'export const tenantQuery = (id:string) => db.user.findUnique({where:{id}})\n');fs.writeFileSync(path.join(repo,'src','control.test.ts'),'export const control = true\n');fs.writeFileSync(path.join(repo,'.env'),`TOKEN=${CREDENTIAL_CANARY}\n`);git('add','src/users.ts','src/control.test.ts','.env');git('commit','-qm','fixture');fs.mkdirSync(path.join(repo,'hostile-bin'));for(const tool of ['git','dirname'])fs.writeFileSync(path.join(repo,'hostile-bin',tool),`#!/bin/sh\ntouch '${path.join(repo,'path-shim-ran')}'\nexit 99\n`,{mode:0o755});for(const f of ['preload.ts','preload.cjs','preload.rb','bashenv'])fs.writeFileSync(path.join(repo,f),`require('fs').writeFileSync('${path.join(repo,'autoload-ran')}','yes')\n`);});
|
||||
afterAll(()=>fs.rmSync(root,{recursive:true,force:true}));
|
||||
|
||||
describe('compiled CSO command workflow',()=>{
|
||||
test('trusted launcher resists runtime autoload variables and repository PATH shims',()=>{const r=command(['--version']);expect(r.status).toBe(0);expect(JSON.parse(r.stdout)).toEqual({version:'3.0.0',abi:3});expect(fs.existsSync(path.join(repo,'autoload-ran'))).toBe(false);expect(fs.existsSync(path.join(repo,'path-shim-ran'))).toBe(false);});
|
||||
test('trusted launcher resolves relative caller paths without trusting an inherited cwd override',()=>{const hostile=path.join(root,'missing-caller-cwd'),diagnosed=command(['doctor','--repo','.'],{GSTACK_CSO_CALLER_CWD:hostile,DOCKER_HOST:'tcp://127.0.0.1:2375'});expect(diagnosed.status).toBe(0);expect(JSON.parse(diagnosed.stdout).checks.find((item:any)=>item.capability==='static-snapshot')).toMatchObject({status:'ready'});const started=command(['start','--repo','.','--scope','auth','--offline'],{GSTACK_CSO_CALLER_CWD:hostile});expect(started.status).toBe(0);const run=JSON.parse(started.stdout),input=path.join(repo,'relative-submission.json');try{fs.writeFileSync(input,'{}');expect(command(['submit',run.runId,'relative-submission.json'],{GSTACK_CSO_CALLER_CWD:hostile}).status).toBe(0);}finally{fs.rmSync(input,{force:true});}});
|
||||
test.skipIf(process.platform!=='linux')('static launcher prevents loader injection before environment scrubbing',()=>{const source=path.join(root,'preload.c'),library=path.join(root,'preload.so'),marker=path.join(root,'loader-ran');fs.writeFileSync(source,'#include <fcntl.h>\n#include <stdlib.h>\n#include <unistd.h>\n__attribute__((constructor)) static void mark(void){const char*p=getenv("CSO_PRELOAD_MARKER");if(p){int f=open(p,O_WRONLY|O_CREAT,0600);if(f>=0)close(f);}}\n');const built=spawnSync('/usr/bin/cc',['-shared','-fPIC',source,'-o',library],{encoding:'utf8',timeout:30_000});expect(built.status).toBe(0);const r=command(['--version'],{LD_PRELOAD:library,CSO_PRELOAD_MARKER:marker});expect(r.status).toBe(0);expect(fs.existsSync(marker)).toBe(false);});
|
||||
test('package-manager launcher symlinks resolve the trusted sibling core',()=>{const npmBin=path.join(root,'npm-bin');fs.mkdirSync(npmBin,{recursive:true});const link=path.join(npmBin,'gstack-cso');fs.symlinkSync(launcher,link);const r=command(['--version'],{},link);expect(r.status).toBe(0);expect(JSON.parse(r.stdout)).toEqual({version:'3.0.0',abi:3});});
|
||||
test.skipIf(process.platform==='win32')('compiled core starts in the canonical trusted launcher directory',()=>{const install=path.join(root,'trusted launcher'),hostile=path.join(repo,'hostile cwd');fs.mkdirSync(install);fs.mkdirSync(hostile);const publicLauncher=path.join(install,'gstack-cso-launcher'),fixtureCore=path.join(install,'gstack-cso-core'),source=path.join(root,'cwd-fixture.c');fs.writeFileSync(source,'#include <stdio.h>\n#include <unistd.h>\n#include <limits.h>\nint main(void){char cwd[PATH_MAX];if(!getcwd(cwd,sizeof cwd))return 2;puts(cwd);return 0;}\n');const built=spawnSync('/usr/bin/cc',[source,'-o',fixtureCore],{encoding:'utf8',timeout:30_000});expect(built.status).toBe(0);const digest=createHash('sha256').update(fs.readFileSync(fixtureCore)).digest('hex'),launcherBuild=spawnSync('/usr/bin/cc',['-std=c11','-D_POSIX_C_SOURCE=200809L','-O2',`-DGSTACK_CSO_CORE_SHA256=\"${digest}\"`,path.join(ROOT,'lib/cso/launcher.c'),'-o',publicLauncher],{encoding:'utf8',timeout:30_000});expect(launcherBuild.status).toBe(0);fs.writeFileSync(path.join(install,'.gstack-cso-generation'),`${digest}\n`);const r=spawnSync(publicLauncher,[],{cwd:hostile,encoding:'utf8',env:{...process.env,HOME:root,GSTACK_HOME:state},timeout:30_000});expect(r.status).toBe(0);expect(r.stdout.trim()).toBe(fs.realpathSync(install));expect(r.stdout.trim()).not.toBe(fs.realpathSync(hostile));});
|
||||
test('schema advertises only caller-owned verification input',()=>{const r=command(['schema']);expect(r.status).toBe(0);const schema=JSON.parse(r.stdout);expect(schema.verification).not.toHaveProperty('result');expect(schema.helperOwned).toContain('repair result and label');});
|
||||
test('v2 imports remain readable and immutable without upgrading legacy proof',()=>{
|
||||
const source=path.join(root,'legacy-v2.json'),legacy={schemaVersion:2,findings:[{status:'VERIFIED',title:'Historical authorization finding',description:`Captured credential ${CREDENTIAL_CANARY}`,fingerprint:'legacy-fingerprint',location:{path:'src/legacy.ts',line:17,symbol:'legacyAuthorization'},impact:'Cross-tenant disclosure',recommendation:'Bind the lookup to the authenticated tenant',repair:'VERIFIED'}]};
|
||||
fs.writeFileSync(source,JSON.stringify(legacy));
|
||||
const imported=command(['import-v2',source]);expect(imported.status).toBe(0);expect(imported.stdout+imported.stderr).not.toContain(CREDENTIAL_CANARY);const result=JSON.parse(imported.stdout),finding=result.report.findings[0];
|
||||
expect(result.id).toMatch(/^[a-f0-9]{64}$/);expect(result.warning).toContain('does not establish reproduction, tested repair, or closure');expect(result.report).toMatchObject({schemaVersion:2,readOnly:true});
|
||||
expect(finding).toMatchObject({status:'VERIFIED',evidence:'legacy_review',reproduction:'not_attempted',repair:'not_attempted',closure:'unknown',legacy:{fingerprint:'legacy-fingerprint',location:{path:'src/legacy.ts',line:17,symbol:'legacyAuthorization'},impact:'Cross-tenant disclosure',recommendation:'Bind the lookup to the authenticated tenant',repair:'VERIFIED'}});
|
||||
fs.writeFileSync(source,JSON.stringify({schemaVersion:2,findings:[]}));
|
||||
const inspected=command(['inspect-v2',result.id]);expect(inspected.status).toBe(0);expect(inspected.stdout+inspected.stderr).not.toContain(CREDENTIAL_CANARY);expect(JSON.parse(inspected.stdout)).toEqual({id:result.id,report:result.report});
|
||||
const stored=path.join(state,'security','cso','legacy-imports',`${result.id}.json`);expect(fs.existsSync(stored)).toBe(true);expect(fs.readFileSync(stored,'utf8')).not.toContain(CREDENTIAL_CANARY);
|
||||
const invalid=command(['inspect-v2','../legacy-v2.json']);expect(invalid.status).not.toBe(0);expect(invalid.stderr).toContain('64-character import ID');
|
||||
const missing=command(['inspect-v2','0'.repeat(64)]);expect(missing.status).not.toBe(0);expect(missing.stderr).toContain('MISSING_INPUT');
|
||||
const beforeUnsafe=fs.readdirSync(path.dirname(stored)).length,unsafeKey=`severity\u202eHIGH`;fs.writeFileSync(source,JSON.stringify({schemaVersion:2,findings:[{status:'OPEN',title:'unsafe',nested:{[unsafeKey]:'forged'}}]}));const unsafe=command(['import-v2',source]);expect(unsafe.status).not.toBe(0);expect(unsafe.stderr).toContain('unsafe property');expect(unsafe.stdout+unsafe.stderr).not.toContain(unsafeKey);expect(fs.readdirSync(path.dirname(stored))).toHaveLength(beforeUnsafe);
|
||||
const changed=JSON.parse(fs.readFileSync(stored,'utf8'));changed.findings[0].status='CHANGED';fs.writeFileSync(stored,JSON.stringify(changed,null,2)+'\n');const tampered=command(['inspect-v2',result.id]);expect(tampered.status).not.toBe(0);expect(tampered.stderr).toContain('identity is inconsistent');
|
||||
});
|
||||
test('wallet-shaped source paths remain addressable through stable opaque handles',()=>{
|
||||
const walletRepo=path.join(root,'wallet-path-repo'),wallet='0x1234567890abcdef1234567890abcdef12345678',sourcePath=`src/${wallet}.ts`;
|
||||
fs.mkdirSync(path.join(walletRepo,'src'),{recursive:true});
|
||||
const runGit=(...args:string[])=>{const result=spawnSync('/usr/bin/git',['-C',walletRepo,...args],{encoding:'utf8',env:{HOME:root,PATH:'/usr/bin:/bin'},timeout:30_000});if(result.status)throw new Error(result.stderr);};
|
||||
runGit('init','-q');runGit('config','user.email','fixture@example.test');runGit('config','user.name','Fixture');
|
||||
fs.writeFileSync(path.join(walletRepo,sourcePath),`export const value = true\n// TOKEN=${CREDENTIAL_CANARY}\n`);fs.writeFileSync(path.join(walletRepo,'control.test.js'),'test(\"control\",()=>{})\n');runGit('add','.');runGit('commit','-qm','wallet-shaped path');
|
||||
const started=command(['start','--repo',walletRepo,'--comprehensive','--offline']);expect(started.status).toBe(0);
|
||||
const run=JSON.parse(started.stdout),dir=path.join(state,'security','cso',run.repoId,run.runId),privateManifest=JSON.parse(fs.readFileSync(path.join(dir,'snapshot.json'),'utf8'));
|
||||
const privateEntry=privateManifest.entries.find((item:any)=>item.path===sourcePath);expect(privateEntry.path).toBe(sourcePath);expect(privateEntry.pathId).toMatch(/^[a-f0-9]{32}$/);
|
||||
const inspected=command(['inspect',run.runId]);expect(inspected.status).toBe(0);const view=JSON.parse(inspected.stdout),entry=view.manifest.entries.find((item:any)=>item.displayPath);
|
||||
expect(JSON.stringify(view)).not.toContain(wallet);expect(JSON.stringify(view)).not.toContain('ghp_');expect(entry.path).toMatch(/^@cso-path\/\/[a-f0-9]{32}$/);expect(entry.displayPath).toContain('<REDACTED-pii.wallet>');expect(entry).not.toHaveProperty('pathId');expect(view.sensitiveEvidence.find((item:any)=>item.path===entry.path)).toBeDefined();
|
||||
const read=command(['read',run.runId,entry.path]);expect(read.status).toBe(0);expect(read.stdout).toContain('export const value = true');
|
||||
const history=command(['history',run.runId,entry.path]);expect(history.status).toBe(0);expect(history.stdout).toContain('<REDACTED-pii.wallet>');expect(history.stdout).toContain('export const value = true');expect(history.stdout).not.toContain('No retained patch hunks');expect(history.stdout).not.toContain(wallet);
|
||||
const input=path.join(root,`wallet-path-${run.runId}.json`),finding={title:'Sensitive-shaped source path',rootCause:'Public route omits an authorization predicate',location:{path:entry.path,line:1,symbol:'value'},advisoryIds:[],severity:'high',confidence:'high',confidenceRationale:'A caller-to-sink trace establishes the missing predicate',evidence:'supported',attackerControl:'Caller chooses the identifier',impact:'Unauthorized record disclosure',scenario:'A caller requests another tenant record',trace:['route','value','response'],references:[`${entry.displayPath}:1`],recommendation:'Bind access to the authenticated tenant',challenge:{reviewer:'reviewer-session',independent:true,mode:'independent_agent',callers:'route caller checked',controls:'authorization is absent',counterevidence:'none found',conclusion:'the tenant predicate is absent'}};
|
||||
fs.writeFileSync(input,JSON.stringify({findings:[finding]}));const submitted=command(['submit',run.runId,input]);expect(submitted.status).toBe(0);
|
||||
const report=JSON.parse(fs.readFileSync(path.join(dir,'report.json'),'utf8'));expect(report.findings).toHaveLength(1);expect(report.findings[0].location.path).toBe(entry.path);expect(report.findings[0].id).toBe(fingerprint(report.findings[0]));expect(JSON.stringify(report)).not.toContain(wallet);
|
||||
const before=fs.readFileSync(path.join(dir,'snapshot',sourcePath),'utf8'),request:any={findingId:report.findings[0].id,runtimeProfile:'node-v1',port:3456,start:{executable:'/usr/local/bin/node',args:['app.js']},legitimate:[{name:'control',path:'/health',method:'GET',expected:{status:200}}],security:{name:'authorization',path:'/records/other',method:'GET',expected:{status:403},vulnerable:{status:200}},existingTests:[{executable:'/usr/local/bin/node',args:['--test']}],testFiles:['control.test.js'],fixtures:{},boundaryFiles:[entry.path],changes:[{path:entry.path,beforeSha256:sha256(before),after:'export const value = false\n',effect:'source'}],review:{reviewer:'reviewer-session',independent:true,rootCauseRepaired:true,featurePreserved:true,boundaryMocks:false,rationale:'The authorization decision replaces the vulnerable behavior while preserving the control',reviewedPatchHash:''}};request.review.reviewedPatchHash=patchHash(request);fs.writeFileSync(input,JSON.stringify(request));const reviewed=command(['record-review',run.runId,input,'--producer','producer-session']);expect(reviewed.status).toBe(0);request.review.artifactId=JSON.parse(reviewed.stdout).reviewArtifactId;fs.writeFileSync(input,JSON.stringify(request));const verified=command(['verify',run.runId,input]);expect(verified.status).not.toBe(0);expect(verified.stderr).toContain('PREREQUISITE');expect(verified.stderr).not.toContain('Boundary files');expect(verified.stderr).not.toContain('contained relative path');
|
||||
expect(report.coverage.find((item:any)=>item.domain==='snapshot-inputs')).toMatchObject({status:'assessed',gaps:[]});
|
||||
},15_000);
|
||||
test('daily audits expose staged tracked deletions as replay-stable handles and material gaps',()=>{
|
||||
const deletedRepo=path.join(root,'deleted-path-repo'),wallet='0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',sourcePath=`src/${wallet}.ts`;
|
||||
fs.mkdirSync(path.join(deletedRepo,'src'),{recursive:true});
|
||||
const runGit=(...args:string[])=>{const result=spawnSync('/usr/bin/git',['-C',deletedRepo,...args],{encoding:'utf8',env:{HOME:root,PATH:'/usr/bin:/bin'},timeout:30_000});if(result.status)throw new Error(result.stderr);return result.stdout;};
|
||||
runGit('init','-q');runGit('config','user.email','fixture@example.test');runGit('config','user.name','Fixture');fs.writeFileSync(path.join(deletedRepo,sourcePath),'export const authorization = true\n');fs.writeFileSync(path.join(deletedRepo,'app.js'),'export const app = true\n');runGit('add','.');runGit('commit','-qm','tracked authorization source');runGit('rm','-q',sourcePath);
|
||||
const started=command(['start','--repo',deletedRepo,'--scope','auth','--offline']);expect(started.status).toBe(0);const run=JSON.parse(started.stdout),dir=path.join(state,'security','cso',run.repoId,run.runId),privateManifest=JSON.parse(fs.readFileSync(path.join(dir,'snapshot.json'),'utf8'));
|
||||
expect(privateManifest.entries.some((item:any)=>item.path===sourcePath)).toBe(false);expect(privateManifest.deletedPaths).toHaveLength(1);expect(privateManifest.deletedPaths[0]).toMatchObject({path:sourcePath});expect(privateManifest.deletedPaths[0].pathId).toMatch(/^[a-f0-9]{32}$/);
|
||||
const inspected=command(['inspect',run.runId]);expect(inspected.status).toBe(0);const view=JSON.parse(inspected.stdout),deleted=view.manifest.deletedPaths[0],coverage=view.report.coverage.find((item:any)=>item.domain==='snapshot-inputs');
|
||||
expect(JSON.stringify(view)).not.toContain(wallet);expect(deleted.path).toMatch(/^@cso-path\/\/[a-f0-9]{32}$/);expect(deleted.displayPath).toContain('<REDACTED-pii.wallet>');expect(deleted).not.toHaveProperty('pathId');expect(coverage.status).toBe('partial');expect(coverage.gaps).toEqual([`${deleted.path}: tracked source is deleted from the worktree; only retained history is available for assessment`]);expect(view.report.source.transformations).toContainEqual({path:deleted.path,handling:'tracked source deleted; retained history only'});
|
||||
const history=command(['history',run.runId,deleted.path]);expect(history.status).toBe(0);expect(history.stdout).toContain('<REDACTED-pii.wallet>');expect(history.stdout).not.toContain(wallet);const read=command(['read',run.runId,deleted.path]);expect(read.status).not.toBe(0);expect(read.stderr).toContain('outside the retained inventory');
|
||||
const tampered=structuredClone(privateManifest);delete tampered.deletedPaths;fs.writeFileSync(path.join(dir,'snapshot.json'),JSON.stringify(tampered,null,2)+'\n');const rejected=command(['inspect',run.runId]);expect(rejected.status).not.toBe(0);expect(rejected.stderr).toContain('Snapshot original identity is inconsistent');
|
||||
});
|
||||
test('runtime plans encode sensitive canonical test argv with snapshot handles',()=>{
|
||||
const planRepo=path.join(root,'sensitive-plan-repo'),wallet='0x1111111111111111111111111111111111111112',testPath=`${wallet}.test.js`;fs.mkdirSync(planRepo);
|
||||
const runGit=(...args:string[])=>{const result=spawnSync('/usr/bin/git',['-C',planRepo,...args],{encoding:'utf8',env:{HOME:root,PATH:'/usr/bin:/bin'},timeout:30_000});if(result.status)throw new Error(result.stderr);};
|
||||
runGit('init','-q');runGit('config','user.email','fixture@example.test');runGit('config','user.name','Fixture');fs.writeFileSync(path.join(planRepo,'package.json'),JSON.stringify({name:'sensitive-plan',version:'1.0.0',scripts:{start:'node app.js',test:'node --test'}}));fs.writeFileSync(path.join(planRepo,'package-lock.json'),JSON.stringify({name:'sensitive-plan',version:'1.0.0',lockfileVersion:3,packages:{'':{name:'sensitive-plan',version:'1.0.0'}}}));fs.writeFileSync(path.join(planRepo,'app.js'),'export const app = true\n');fs.writeFileSync(path.join(planRepo,testPath),'const test=require("node:test");test("control",()=>{})\n');runGit('add','.');runGit('commit','-qm','sensitive test path');
|
||||
const started=command(['start','--repo',planRepo,'--comprehensive','--offline']);expect(started.status).toBe(0);const run=JSON.parse(started.stdout),planned=command(['runtime-plan',run.runId,'node','--port','3456']);expect(planned.status).toBe(0);const plan=JSON.parse(planned.stdout),serialized=JSON.stringify(plan);
|
||||
expect(serialized).not.toContain(wallet);const testHandle=plan.tests.files.find((value:string)=>value.startsWith('@cso-path//'));expect(testHandle).toMatch(/^@cso-path\/\/[a-f0-9]{32}$/);expect(plan.tests.commands[0].args).toContain(`./${testHandle}`);expect(plan.start.command).toEqual({executable:'/usr/local/bin/node',args:['app.js']});
|
||||
});
|
||||
test('wallet-shaped helper finding IDs survive submit, review, and verification admission',()=>{const started=command(['start','--repo',repo,'--comprehensive','--offline']);expect(started.status).toBe(0);const run=JSON.parse(started.stdout),base:any={title:'Wallet-shaped helper ID',rootCause:'Tenant query omits caller tenant predicate 0',location:{path:'src/users.ts',line:1,symbol:'tenantQuery'},advisoryIds:[],severity:'high',confidence:'high',confidenceRationale:'Caller-to-sink trace directly establishes the missing predicate',evidence:'supported',attackerControl:'Tenant chooses the record ID',impact:'Cross-tenant record disclosure',scenario:'A tenant requests another tenant record by ID',trace:['route','tenantQuery','database'],references:['src/users.ts:1'],recommendation:'Bind the query to the authenticated tenant',challenge:{reviewer:'reviewer-session',independent:true,mode:'independent_agent',callers:'route caller checked',controls:'authentication lacks authorization',counterevidence:'none found',conclusion:'tenant predicate is absent'}};let helperId='';for(let i=0;i<10_000;i++){base.rootCause=`Tenant query omits caller tenant predicate ${i}`;helperId=fingerprint(base);if(redact(helperId)!==helperId)break;}expect(redact(helperId)).not.toBe(helperId);const input=path.join(root,`wallet-id-${run.runId}.json`);fs.writeFileSync(input,JSON.stringify({findings:[base]}));expect(command(['submit',run.runId,input]).status).toBe(0);const reportPath=path.join(state,'security','cso',run.repoId,run.runId,'report.json'),stored=JSON.parse(fs.readFileSync(reportPath,'utf8'));expect(stored.findings[0].id).toBe(helperId);const before=fs.readFileSync(path.join(repo,'src','users.ts'),'utf8'),request:any={findingId:helperId,runtimeProfile:'node-v1',port:3456,start:{executable:'/usr/local/bin/node',args:['src/users.ts']},legitimate:[{name:'control',path:'/health',method:'GET',expected:{status:200}}],security:{name:'tenant boundary',path:'/users/other',method:'GET',expected:{status:403},vulnerable:{status:200}},existingTests:[{executable:'/usr/local/bin/node',args:['--test']}],testFiles:['src/control.test.ts'],fixtures:{},boundaryFiles:['src/users.ts'],changes:[{path:'src/users.ts',beforeSha256:sha256(before),after:before+'// tenant predicate\n',effect:'source'}],review:{reviewer:'reviewer-session',independent:true,rootCauseRepaired:true,featurePreserved:true,boundaryMocks:false,rationale:'The tenant predicate is added while the control remains intact',reviewedPatchHash:''}};request.review.reviewedPatchHash=patchHash(request);fs.writeFileSync(input,JSON.stringify(request));const reviewed=command(['record-review',run.runId,input,'--producer','producer-session']);expect(reviewed.status).toBe(0);request.review.artifactId=JSON.parse(reviewed.stdout).reviewArtifactId;fs.writeFileSync(input,JSON.stringify(request));const verified=command(['verify',run.runId,input]);expect(verified.status).not.toBe(0);expect(verified.stderr).toContain('PREREQUISITE');expect(verified.stderr).not.toContain('REDACTION_FAILED');const review=JSON.parse(fs.readFileSync(path.join(path.dirname(reportPath),'reviews',`${request.review.artifactId}.json`),'utf8'));expect(review.findingId).toBe(helperId);});
|
||||
test('daily find, early submit, and finish persist a truthful report without changing source',()=>{const before=git('status','--porcelain=v1','-z'),started=command(['start','--repo',repo,'--scope','auth','--offline']);expect(started.status).toBe(0);const run=JSON.parse(started.stdout),runId=run.runId;expect(run.completeness).toBe('not assessed');const reportPath=path.join(state,'security','cso',run.repoId,runId,'report.json');expect(fs.existsSync(reportPath)).toBe(true);const current=JSON.parse(fs.readFileSync(reportPath,'utf8'));
|
||||
const wallet='0x1234567890abcdef1234567890abcdef12345678',submission={application:{actors:['authenticated tenant user'],assets:['tenant records'],entrypoints:['GET /users/:id'],tenantBoundaries:['record.tenant_id equals session.tenant_id'],sensitiveOperations:['read tenant record'],invariants:['a tenant cannot read another tenant record']},findings:[{title:'Cross-tenant user read',rootCause:`Tenant query ${wallet} omits caller tenant predicate`,location:{path:'src/users.ts',line:1,symbol:'tenantQuery'},advisoryIds:[],severity:'high',confidence:'high',confidenceRationale:'The captured caller-to-sink trace and control review directly support the finding',evidence:'supported',attackerControl:'Authenticated caller chooses the record ID',impact:'Another tenant record is returned',scenario:'A tenant supplies a known record ID owned by another tenant and receives that record',trace:['GET /users/:id','tenantQuery','findUnique by id'],references:['src/users.ts:1','OWASP API1:2023'],recommendation:'Bind the lookup predicate to the authenticated tenant identifier before returning the record',challenge:{reviewer:'independent-2',independent:true,mode:'independent_agent',callers:'Authenticated route forwards path ID',controls:'Authentication exists; authorization predicate does not',counterevidence:'Opaque IDs reduce guessing but do not authorize',conclusion:'The caller can provide a known cross-tenant ID'}}],coverage:current.coverage.filter((c:any)=>!['snapshot-inputs','history-inputs'].includes(c.domain)).map((c:any)=>({...c,status:'assessed',method:'caller and middleware trace',gaps:[],evidence:[`${c.domain} inspected against captured source`]})),gaps:[]};const file=path.join(root,'submission.json');fs.writeFileSync(file,JSON.stringify(submission));const submitted=command(['submit',runId,file]);expect(submitted.status).toBe(0);expect(JSON.parse(fs.readFileSync(reportPath,'utf8')).findings).toHaveLength(1);const finished=command(['finish',runId]);expect(finished.status).toBe(0);const final=JSON.parse(fs.readFileSync(reportPath,'utf8'));expect(final.completeness).toBe('complete');expect(final.findings[0]).toMatchObject({reproduction:'not_attempted',repair:'not_attempted',closure:'open'});expect(final.findings[0].id).toBe(fingerprint(final.findings[0]));expect(JSON.stringify(final)).not.toContain(wallet);const markdown=fs.readFileSync(path.join(path.dirname(reportPath),'report.md'),'utf8');expect(markdown.startsWith('complete — domain:auth')).toBe(true);expect(markdown).toContain('.env: excluded: credential or execution configuration');expect(markdown).not.toContain('ghp_');expect(git('status','--porcelain=v1','-z')).toBe(before);});
|
||||
test('credential originals are withheld rather than printed',()=>{const run=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout);const result=command(['read',run.runId,'.env']);expect(result.status).not.toBe(0);expect(result.stderr).toContain('MISSING_INPUT');expect(result.stdout+result.stderr).not.toContain('ghp_');});
|
||||
test('bounded submission and snapshot readers reject multiply-linked pathnames',()=>{const run=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),dir=path.join(state,'security','cso',run.repoId,run.runId),inputSource=path.join(root,`linked-input-source-${run.runId}.json`),input=path.join(root,`linked-input-${run.runId}.json`);fs.writeFileSync(inputSource,'{}');fs.linkSync(inputSource,input);const submitted=command(['submit',run.runId,input]);expect(submitted.status).not.toBe(0);expect(submitted.stderr).toContain('one bounded regular file');const readable=path.join(dir,'readable','src','users.ts'),outside=path.join(root,`linked-readable-${run.runId}`);fs.linkSync(readable,outside);const read=command(['read',run.runId,'src/users.ts']);expect(read.status).not.toBe(0);expect(read.stderr).toContain('one bounded regular file');});
|
||||
test.skipIf(process.platform==='win32')('rejects a SARIF FIFO without blocking on open',()=>{const run=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),fifo=path.join(root,`sarif-${run.runId}.pipe`);expect(spawnSync('/usr/bin/mkfifo',[fifo],{encoding:'utf8',timeout:5_000}).status).toBe(0);const started=Date.now(),imported=command(['import-sarif',run.runId,fifo]);expect(Date.now()-started).toBeLessThan(2_000);expect(imported.status).not.toBe(0);expect(imported.stderr).toContain('bounded regular file');});
|
||||
test('model submissions cannot overwrite helper-owned readiness or scanner coverage',()=>{const run=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),file=path.join(root,'helper-coverage.json');fs.writeFileSync(file,JSON.stringify({coverage:[{domain:'runtime-readiness',scope:'node',status:'assessed',method:'claimed by model',gaps:[],exclusions:[],evidence:['untrusted claim']}]}));const result=command(['submit',run.runId,file]);expect(result.status).not.toBe(0);expect(result.stderr).toContain('helper-owned');const report=JSON.parse(fs.readFileSync(path.join(state,'security','cso',run.repoId,run.runId,'report.json'),'utf8'));expect(report.coverage.some((x:any)=>x.domain==='runtime-readiness')).toBe(false);});
|
||||
test('submission typos are rejected instead of silently weakening evidence',()=>{const run=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),file=path.join(root,'typo-submission.json');for(const value of [{finding:[]},{modelUsage:{source:'host',tokens:1,tokenz:1}},{application:{actors:['a'],assets:['b'],entrypoints:['c'],tenantBoundaries:['d'],sensitiveOperations:['e'],invariants:['f'],unknown:['g']}}]){fs.writeFileSync(file,JSON.stringify(value));const result=command(['submit',run.runId,file]);expect(result.status).not.toBe(0);expect(result.stderr).toContain('Unexpected');}});
|
||||
test('submission collections and numeric usage fail with precise schema errors',()=>{const run=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),file=path.join(root,'invalid-submission-shape.json');for(const [body,message] of [['{"findings":{}}','submission.findings must be an array'],['{"coverage":{}}','submission.coverage must be an array'],['{"modelUsage":{"source":"host","tokens":0,"cost":1e400}}','finite nonnegative']]){fs.writeFileSync(file,body);const result=command(['submit',run.runId,file]);expect(result.status).not.toBe(0);expect(result.stderr).toContain(message);}});
|
||||
test('the reserved reporting minute accepts evidence until the actual deadline',()=>{const run=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),reportPath=path.join(state,'security','cso',run.repoId,run.runId,'report.json'),report=JSON.parse(fs.readFileSync(reportPath,'utf8')),file=path.join(root,`reporting-reserve-${run.runId}.json`);fs.writeFileSync(file,'{}');report.deadline=new Date(Date.now()+30_000).toISOString();fs.writeFileSync(reportPath,JSON.stringify(report,null,2)+'\n');expect(command(['submit',run.runId,file]).status).toBe(0);report.deadline=new Date(Date.now()-1_000).toISOString();fs.writeFileSync(reportPath,JSON.stringify(report,null,2)+'\n');const expired=command(['submit',run.runId,file]);expect(expired.status).not.toBe(0);expect(expired.stderr).toContain('Audit deadline reached');});
|
||||
test('explicit base pins history even when finding scope is not diff-only',()=>{const head=git('rev-parse','HEAD').trim(),started=command(['start','--repo',repo,'--scope','auth','--offline','--base','HEAD']);expect(started.status).toBe(0);const run=JSON.parse(started.stdout),dir=path.join(state,'security','cso',run.repoId,run.runId),report=JSON.parse(fs.readFileSync(path.join(dir,'report.json'),'utf8')),history=JSON.parse(fs.readFileSync(path.join(dir,'history-status.json'),'utf8'));expect(report.policy.diff).toBe(false);expect(report.source.baseCommit).toBe(head);expect(history.range).toBe(`${head}..${head}`);const invalid=command(['start','--repo',repo,'--scope','auth','--offline','--base','missing-ref']);expect(invalid.status).not.toBe(0);expect(invalid.stderr).toContain('MISSING_INPUT');});
|
||||
test('an optional scanner prerequisite does not turn completed manual coverage partial',()=>{
|
||||
const run=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout);
|
||||
const scanned=command(['scan',run.runId,'gitleaks']);expect(scanned.status).toBe(0);expect(JSON.parse(scanned.stdout).status).toBe('not_assessed');
|
||||
const reportPath=path.join(state,'security','cso',run.repoId,run.runId,'report.json'),report=JSON.parse(fs.readFileSync(reportPath,'utf8'));
|
||||
const input=path.join(root,`manual-${run.runId}.json`);fs.writeFileSync(input,JSON.stringify({application:{actors:['tenant user'],assets:['tenant records'],entrypoints:['GET /users/:id'],tenantBoundaries:['tenant id'],sensitiveOperations:['record read'],invariants:['tenant isolation']},coverage:report.coverage.filter((c:any)=>!c.domain.startsWith('scanner:')&&!['snapshot-inputs','history-inputs'].includes(c.domain)).map((c:any)=>({...c,status:'assessed',method:'manual source and caller review',gaps:[],evidence:['fresh captured-source trace']})),gaps:[]}));
|
||||
expect(command(['submit',run.runId,input]).status).toBe(0);expect(command(['finish',run.runId]).status).toBe(0);const final=JSON.parse(fs.readFileSync(reportPath,'utf8'));expect(final.completeness).toBe('complete');expect(final.coverage.find((c:any)=>c.domain==='scanner:gitleaks').status).toBe('not_assessed');
|
||||
});
|
||||
test('unread tracked source is a disclosed helper-owned material gap',()=>{
|
||||
const binaryRepo=path.join(root,'binary-repo'),runGit=(...args:string[])=>{const result=spawnSync('/usr/bin/git',['-C',binaryRepo,...args],{encoding:'utf8',env:{HOME:root,PATH:'/usr/bin:/bin'},timeout:30_000});if(result.status)throw new Error(result.stderr);};
|
||||
fs.mkdirSync(binaryRepo);runGit('init','-q');runGit('config','user.email','fixture@example.test');runGit('config','user.name','Fixture');
|
||||
fs.writeFileSync(path.join(binaryRepo,'app.js'),'export const ready = true\n');fs.writeFileSync(path.join(binaryRepo,'auth.wasm'),Buffer.from([0,97,115,109,1,0,0,0]));
|
||||
runGit('add','app.js','auth.wasm');runGit('commit','-qm','binary security boundary');
|
||||
const started=command(['start','--repo',binaryRepo,'--scope','auth','--offline']);expect(started.status).toBe(0);
|
||||
const run=JSON.parse(started.stdout),reportPath=path.join(state,'security','cso',run.repoId,run.runId,'report.json'),initial=JSON.parse(fs.readFileSync(reportPath,'utf8'));
|
||||
expect(initial.coverage.find((item:any)=>item.domain==='snapshot-inputs')).toMatchObject({status:'partial',gaps:['auth.wasm: in-scope source payload was unread and withheld from static and runtime assessment']});
|
||||
expect(initial.source.transformations).toContainEqual({path:'auth.wasm',handling:'withheld: binary or redaction failed'});
|
||||
const override=path.join(root,`snapshot-override-${run.runId}.json`);fs.writeFileSync(override,JSON.stringify({coverage:[{domain:'snapshot-inputs',scope:'domain:auth',status:'assessed',method:'model claim',gaps:[],exclusions:[],evidence:['claimed complete']}]}));
|
||||
const rejected=command(['submit',run.runId,override]);expect(rejected.status).not.toBe(0);expect(rejected.stderr).toContain('helper-owned');
|
||||
const evidence=path.join(root,`binary-evidence-${run.runId}.json`);fs.writeFileSync(evidence,JSON.stringify({application:{actors:['authenticated user'],assets:['authorization policy'],entrypoints:['application request'],tenantBoundaries:['authorization boundary'],sensitiveOperations:['protected operation'],invariants:['authorization is enforced']},coverage:initial.coverage.filter((item:any)=>!['snapshot-inputs','history-inputs'].includes(item.domain)).map((item:any)=>({...item,status:'assessed',method:'captured source review',gaps:[],evidence:['reviewed readable captured source']})),gaps:[]}));
|
||||
expect(command(['submit',run.runId,evidence]).status).toBe(0);expect(command(['finish',run.runId]).status).toBe(0);
|
||||
const final=JSON.parse(fs.readFileSync(reportPath,'utf8')),markdown=fs.readFileSync(path.join(path.dirname(reportPath),'report.md'),'utf8');
|
||||
expect(final.completeness).toBe('partial');expect(final.coverage.find((item:any)=>item.domain==='snapshot-inputs').status).toBe('partial');
|
||||
expect(markdown.startsWith('partial — domain:auth')).toBe(true);expect(markdown).toContain('auth.wasm: in-scope source payload was unread');expect(markdown).toContain('auth.wasm: withheld: binary or redaction failed');
|
||||
});
|
||||
test('a surviving root-cause variant leaves the fresh recheck open for correction',()=>{const original=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),rootCause='Tenant query omits caller tenant predicate',finding={title:'Cross-tenant user read',rootCause,location:{path:'src/users.ts',line:1,symbol:'tenantQuery'},advisoryIds:[],severity:'high',confidence:'high',confidenceRationale:'The captured caller-to-sink trace and control review directly support the finding',evidence:'supported',attackerControl:'Authenticated caller chooses the record ID',impact:'Another tenant record is returned',scenario:'A tenant supplies a known record ID owned by another tenant and receives that record',trace:['GET /users/:id','tenantQuery','findUnique by id'],references:['src/users.ts:1','OWASP API1:2023'],recommendation:'Bind the lookup predicate to the authenticated tenant identifier before returning the record',challenge:{reviewer:'independent-2',independent:true,mode:'independent_agent',callers:'Authenticated route forwards path ID',controls:'Authentication exists; authorization predicate does not',counterevidence:'Opaque IDs reduce guessing but do not authorize',conclusion:'The caller can provide a known cross-tenant ID'}},application={actors:['tenant user'],assets:['records'],entrypoints:['GET /users/:id'],tenantBoundaries:['tenant id'],sensitiveOperations:['record read'],invariants:['tenant isolation']},reportPath=path.join(state,'security','cso',original.repoId,original.runId,'report.json'),report=JSON.parse(fs.readFileSync(reportPath,'utf8')),submission=path.join(root,'recheck-original.json');fs.writeFileSync(submission,JSON.stringify({application,findings:[finding],coverage:report.coverage.filter((c:any)=>!['snapshot-inputs','history-inputs'].includes(c.domain)).map((c:any)=>({...c,status:'assessed',gaps:[],method:'fresh trace',evidence:['captured source']})),gaps:[]}));expect(command(['submit',original.runId,submission]).status).toBe(0);expect(command(['finish',original.runId]).status).toBe(0);const findingId=JSON.parse(fs.readFileSync(reportPath,'utf8')).findings[0].id,rechecked=command(['recheck',findingId,'--run',original.runId,'--repo',repo]);expect(rechecked.status).toBe(0);const child=JSON.parse(rechecked.stdout),childPath=path.join(state,'security','cso',original.repoId,child.runId,'report.json'),childReport=JSON.parse(fs.readFileSync(childPath,'utf8')),claim=path.join(root,'recheck-claim.json'),variant={...finding,title:'Moved cross-tenant user read',location:{...finding.location,symbol:'movedTenantQuery'}};fs.writeFileSync(claim,JSON.stringify({application,findings:[variant],coverage:childReport.coverage.filter((c:any)=>!['snapshot-inputs','history-inputs'].includes(c.domain)).map((c:any)=>({...c,status:'assessed',gaps:[],method:'fresh current-source trace',evidence:['new snapshot']})),gaps:[],recheck:{findingId,outcome:'resolved',evidence:[{kind:'caller',path:'src/users.ts',line:1,observation:'Fresh caller trace reaches the tenant predicate'},{kind:'security_boundary',path:'src/users.ts',line:1,observation:'Fresh boundary trace checks cross-tenant denial'}],rootCause}}));expect(command(['submit',child.runId,claim]).status).toBe(0);const finished=command(['finish',child.runId]);expect(finished.status).not.toBe(0);expect(finished.stderr).toContain('surviving root-cause');expect(JSON.parse(fs.readFileSync(childPath,'utf8')).status).toBe('running');});
|
||||
test('doctor performs no downloads and rejects a remote Docker endpoint',()=>{const r=command(['doctor','--repo',repo],{DOCKER_HOST:'tcp://127.0.0.1:2375'});expect(r.status).toBe(0);const value=JSON.parse(r.stdout);expect(value.downloads).toBe(false);expect(value.checks.find((x:any)=>x.capability==='local-docker-isolation')).toMatchObject({status:'missing'});expect(JSON.stringify(value)).toContain('Remote TCP');});
|
||||
test('doctor returns exact missing capabilities for an unavailable repository',()=>{const r=command(['doctor','--repo',path.join(root,'missing-repository')],{DOCKER_HOST:'tcp://127.0.0.1:2375'});expect(r.status).toBe(0);const value=JSON.parse(r.stdout);expect(value.elapsedMs).toBeLessThan(30_000);expect(value.checks.find((x:any)=>x.capability==='static-snapshot')).toMatchObject({status:'missing'});expect(value.checks.find((x:any)=>x.capability==='application-preparation')).toMatchObject({status:'missing'});expect(value.checks.find((x:any)=>x.capability==='qualified-runtimes')).toMatchObject({status:'missing'});});
|
||||
test('audits nonignored source in a newly initialized repository before its first commit',()=>{
|
||||
const unborn=path.join(root,'unborn-repo');fs.mkdirSync(unborn);const runGit=(...args:string[])=>{const result=spawnSync('/usr/bin/git',['-C',unborn,...args],{encoding:'utf8',env:{HOME:root,PATH:'/usr/bin:/bin'},timeout:30_000});if(result.status)throw new Error(result.stderr);};runGit('init','-q');fs.writeFileSync(path.join(unborn,'app.js'),'export const newProject = true\n');
|
||||
const diagnosed=command(['doctor','--repo',unborn],{DOCKER_HOST:'tcp://127.0.0.1:2375'});expect(diagnosed.status).toBe(0);expect(JSON.parse(diagnosed.stdout).checks.find((item:any)=>item.capability==='static-snapshot')).toMatchObject({status:'ready'});
|
||||
const started=command(['start','--repo',unborn,'--scope','auth','--offline']);expect(started.status).toBe(0);const run=JSON.parse(started.stdout),dir=path.join(state,'security','cso',run.repoId,run.runId),manifest=JSON.parse(fs.readFileSync(path.join(dir,'snapshot.json'),'utf8')),history=JSON.parse(fs.readFileSync(path.join(dir,'history-status.json'),'utf8'));
|
||||
expect(manifest).not.toHaveProperty('headCommit');expect(manifest.entries.find((item:any)=>item.path==='app.js')?.executionHash).toMatch(/^[a-f0-9]{64}$/);expect(history).toEqual({status:'captured',range:'unborn HEAD',commits:0,bytes:0});
|
||||
});
|
||||
test('state configured inside the audited repository is rejected before changing it',()=>{const before=git('status','--porcelain=v1','-z'),r=command(['start','--repo',repo],{GSTACK_HOME:path.join(repo,'.private-state')});expect(r.status).not.toBe(0);expect(r.stderr).toContain('UNSAFE_PATH');expect(fs.existsSync(path.join(repo,'.private-state'))).toBe(false);expect(git('status','--porcelain=v1','-z')).toBe(before);});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,471 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { imageBuildMatrix } from '../scripts/cso-image-matrix';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const temps: string[] = [];
|
||||
afterEach(() => { for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }); });
|
||||
const quote = (s: string) => "'" + s.replaceAll("'", "'\\''") + "'";
|
||||
const CREDENTIAL_IMAGE = ['https://user:', 'pass@example.test/node'].join('');
|
||||
|
||||
function buildFixture() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gstack-cso-build-')); temps.push(dir);
|
||||
for (const sub of ['scripts', 'bin', 'lib/cso']) mkdirSync(join(dir, sub), { recursive: true });
|
||||
copyFileSync(join(ROOT, 'scripts/build-cso.sh'), join(dir, 'scripts/build-cso.sh'));
|
||||
const publisher = [
|
||||
'#!/bin/sh',
|
||||
'set -eu',
|
||||
'directory=$1',
|
||||
'shift',
|
||||
'if [ "$(uname -s)" = Linux ]; then',
|
||||
" exec /usr/bin/flock -n -E 73 \"$directory\" /bin/sh -c '",
|
||||
' if [ -n "${CSO_PUBLISH_BARRIER:-}" ]; then',
|
||||
' : > "${CSO_PUBLISH_BARRIER}.ready"',
|
||||
' while [ ! -f "${CSO_PUBLISH_BARRIER}.go" ]; do sleep .01; done',
|
||||
' fi',
|
||||
' export GSTACK_CSO_PUBLISH_LOCKED=1',
|
||||
' exec "$@"',
|
||||
" ' cso-publish \"$@\"",
|
||||
'fi',
|
||||
'export GSTACK_CSO_PUBLISH_LOCKED=1',
|
||||
'exec "$@"',
|
||||
'',
|
||||
].join('\n');
|
||||
writeFileSync(join(dir, 'publish lock wrapper'), publisher); chmodSync(join(dir, 'publish lock wrapper'), 0o755);
|
||||
// The recorder substitutes compilation only. The build command itself is the
|
||||
// real script, so quoting, flag propagation and failed-compiler behavior run.
|
||||
const recorder = '#!/bin/sh\nset -eu\nprintf "%s\\n" "$@" >> "$CSO_BUILD_LOG"\ncount_file="$CSO_BUILD_LOG.count"\ncount=0\n[ ! -f "$count_file" ] || count=$(cat "$count_file")\ncount=$((count+1))\nprintf "%s\\n" "$count" > "$count_file"\nif [ -n "${CSO_BUILD_BARRIER:-}" ] && [ "$count" = 1 ]; then touch "$CSO_BUILD_BARRIER.ready"; while [ ! -f "$CSO_BUILD_BARRIER.go" ]; do sleep .01; done; fi\nif [ "${CSO_FAIL_COMPILER:-0}" = 1 ] || [ "${CSO_FAIL_COMPILER_N:-0}" = "$count" ]; then exit 42; fi\noutput=""\nwhile [ "$#" -gt 0 ]; do\n case "$1" in --outfile|-o) shift; output="$1" ;; esac\n shift\ndone\nif [ -n "$output" ]; then\n case "$output" in\n *gstack-cso-publish-lock*) cp "$CSO_FAKE_LOCKER_TEMPLATE" "$output" ;;\n *) generation="${CSO_BUILD_GENERATION:-NEW}"; cat > "$output" <<EOF\n#!/bin/sh\nprintf "%s\\n" "$generation"\nEOF\n ;;\n esac\n chmod +x "$output"\nfi\n';
|
||||
writeFileSync(join(dir, 'compiler recorder'), recorder); chmodSync(join(dir, 'compiler recorder'), 0o755);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function seedBundle(dir:string,generation='OLD'){
|
||||
for(const name of ['gstack-cso-launcher','gstack-cso-core','gstack-cso-watchdog']){const file=join(dir,'bin',name);writeFileSync(file,`#!/bin/sh\nprintf '%s\\n' '${generation}'\n`);chmodSync(file,0o755);}
|
||||
writeFileSync(join(dir,'bin','.gstack-cso-generation'),'0'.repeat(64)+'\n');
|
||||
}
|
||||
function bundleContents(dir:string){return ['gstack-cso-launcher','gstack-cso-core','gstack-cso-watchdog','.gstack-cso-generation'].map(name=>readFileSync(join(dir,'bin',name),'utf8'));}
|
||||
function expectPublishedBundle(dir:string,generation='NEW'){
|
||||
const contents=bundleContents(dir);
|
||||
expect(contents.slice(0,3).every(content=>content.includes(generation))).toBe(true);
|
||||
expect(contents[3]).toMatch(/^[a-f0-9]{64}\n$/);
|
||||
}
|
||||
function fakeBuildCommand(dir:string,extra=''){const log=join(dir,'compiler.log');return `${extra} BUN_CMD=${quote(join(dir,'compiler recorder'))} CSO_CC=${quote(join(dir,'compiler recorder'))} CSO_BUILD_LOG=${quote(log)} CSO_FAKE_LOCKER_TEMPLATE=${quote(join(dir,'publish lock wrapper'))} bash ${quote(join(dir,'scripts/build-cso.sh'))}`;}
|
||||
function fakeBuild(dir:string,extra=''){return runBashScript(fakeBuildCommand(dir,extra),{timeout:10_000});}
|
||||
|
||||
function reviewedInputs(): any {
|
||||
const pinned = (repository: string, fill: string) => `${repository}@sha256:${fill.repeat(64)}`;
|
||||
const family = (repository: string, tag: string, index: string, amd64: string, arm64: string) => ({
|
||||
source: `${repository}:${tag}`,
|
||||
indexImage: pinned(repository, index),
|
||||
images: { 'linux/amd64': pinned(repository, amd64), 'linux/arm64': pinned(repository, arm64) },
|
||||
});
|
||||
const sbom = family('docker.io/tool/sbom', '1.0.0', '1', '2', '3');
|
||||
const base = (repository: string, tag: string, index: string, amd64: string, arm64: string) => {
|
||||
const value = family(repository, tag, index, amd64, arm64);
|
||||
return { source: value.source, indexImage: value.indexImage, baseImages: value.images };
|
||||
};
|
||||
return {
|
||||
schemaVersion: 1, helperAbi: 3, state: 'reviewed', revision: 'fixture-runtime-inputs',
|
||||
reviewedAt: '2026-09-10T00:00:00.000Z',
|
||||
reviewMethod: 'Fixture review metadata long enough to exercise the strict source-controlled build input contract.',
|
||||
sbomGenerator: sbom,
|
||||
profiles: [
|
||||
{ id: 'node-24.4.0', stack: 'node', ...base('docker.io/library/node', '24.4.0', '4', '5', '6'), versions: { node: '24.4.0', npm: '11.4.2', 'cso-preparation': '1.0.0' } },
|
||||
{ id: 'bun-1.3.10', stack: 'bun', ...base('docker.io/oven/bun', '1.3.10', '7', '8', '9'), versions: { bun: '1.3.10', 'cso-preparation': '1.0.0' } },
|
||||
{ id: 'python-3.13.4-uv-0.8.0', stack: 'python', ...base('docker.io/library/python', '3.13.4', 'a', 'b', 'c'),
|
||||
uvSource: 'ghcr.io/astral-sh/uv:0.8.0', uvIndexImage: pinned('ghcr.io/astral-sh/uv', 'd'),
|
||||
uvImages: { 'linux/amd64': pinned('ghcr.io/astral-sh/uv', 'e'), 'linux/arm64': pinned('ghcr.io/astral-sh/uv', 'f') },
|
||||
versions: { python: '3.13.4', uv: '0.8.0', 'cso-preparation': '1.0.0' } },
|
||||
{ id: 'rails-ruby-3.4.4', stack: 'rails', ...base('docker.io/library/ruby', '3.4.4', '0', 'a', 'b'), versions: { ruby: '3.4.4', bundler: '2.6.7', 'cso-preparation': '1.0.0' } },
|
||||
{ id: 'postgresql-17.2', stack: 'postgresql', ...base('docker.io/library/postgres', '17.2', 'c', 'd', 'e'), versions: { postgresql: '17.2' } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('CSO build and distribution wiring', () => {
|
||||
test('POSIX helpers expose Darwin no-follow flags before system headers', () => {
|
||||
for (const relative of ['lib/cso/launcher.c', 'lib/cso/watchdog.c', 'lib/cso/publish-lock.c']) {
|
||||
const source = readFileSync(join(ROOT, relative), 'utf8');
|
||||
const darwinFeature = source.indexOf('#define _DARWIN_C_SOURCE 1');
|
||||
const fileFlags = source.indexOf('#include <fcntl.h>');
|
||||
expect(darwinFeature, relative).toBeGreaterThanOrEqual(0);
|
||||
expect(fileFlags, relative).toBeGreaterThan(darwinFeature);
|
||||
expect(source, relative).toContain('O_NOFOLLOW');
|
||||
}
|
||||
});
|
||||
|
||||
test('the cached eval image proves the static C toolchain required by direct builds', () => {
|
||||
const dockerfile = readFileSync(join(ROOT, '.github/docker/Dockerfile.ci'), 'utf8');
|
||||
expect(dockerfile).toMatch(/\bgcc libc6-dev\b/);
|
||||
expect(dockerfile).toContain('cc -std=c11 -static /tmp/gstack-cso-cc-probe.c');
|
||||
expect(dockerfile).toContain('/tmp/gstack-cso-cc-probe');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('real build script supplies all startup-hardening flags and compiles its watchdog', () => {
|
||||
const dir = buildFixture(), log = join(dir, 'compiler.log');
|
||||
const r = fakeBuild(dir);
|
||||
expect(r.status).toBe(0);
|
||||
const args = readFileSync(log, 'utf8').split('\n');
|
||||
for (const flag of ['--no-compile-autoload-dotenv', '--no-compile-autoload-bunfig', '--no-compile-autoload-tsconfig', '--no-compile-autoload-package-json']) expect(args.filter(a => a === flag)).toHaveLength(1);
|
||||
expect(args).toContain('lib/cso/cli.ts');
|
||||
expect(args.some(arg=>/bin\/\.gstack-cso-stage\.[^/]+\/gstack-cso-core$/.test(arg))).toBe(true);
|
||||
expect(args).toContain('lib/cso/launcher.c');
|
||||
expect(args.some(arg=>/bin\/\.gstack-cso-stage\.[^/]+\/gstack-cso-launcher$/.test(arg))).toBe(true);
|
||||
expect(args).toContain('lib/cso/watchdog.c');
|
||||
expect(args.some(arg=>/bin\/\.gstack-cso-stage\.[^/]+\/gstack-cso-watchdog$/.test(arg))).toBe(true);
|
||||
expect(args).toContain('lib/cso/publish-lock.c');
|
||||
expect(args.some(arg=>/bin\/\.gstack-cso-stage\.[^/]+\/gstack-cso-publish-lock$/.test(arg))).toBe(true);
|
||||
expect(spawnSync(join(dir,'bin/gstack-cso-launcher'),['--version'],{encoding:'utf8',timeout:5000}).stdout.trim()).toBe('NEW');
|
||||
expectPublishedBundle(dir);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('generation validation accepts BSD wc padding', () => {
|
||||
const dir = buildFixture(), tools = join(dir, 'bsd-tools'), wc = join(tools, 'wc');
|
||||
mkdirSync(tools);
|
||||
writeFileSync(wc, '#!/bin/sh\nset -eu\ncount=$(/usr/bin/wc -c "$@")\nprintf " %s\\n" "$count"\n');
|
||||
chmodSync(wc, 0o755);
|
||||
const result = fakeBuild(dir, `PATH=${quote(`${tools}:/usr/bin:/bin`)}`);
|
||||
expect(result.status).toBe(0);
|
||||
expectPublishedBundle(dir);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32').each([1,2,3,4])('compiler failure at stage %i preserves the exact runnable old bundle', failure => {
|
||||
const dir=buildFixture();seedBundle(dir);const before=bundleContents(dir);
|
||||
const r=fakeBuild(dir,`CSO_FAIL_COMPILER_N=${failure}`);
|
||||
expect(r.status).toBe(42);
|
||||
expect(bundleContents(dir)).toEqual(before);
|
||||
expect(spawnSync(join(dir,'bin/gstack-cso-launcher'),{encoding:'utf8',timeout:5000}).stdout.trim()).toBe('OLD');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32').each(['withdraw-launcher','publish-core','publish-watchdog','before-publish-launcher','publish-launcher'])('publication failure after %s rolls back to the exact old bundle', checkpoint=>{
|
||||
const dir=buildFixture();seedBundle(dir);const before=bundleContents(dir);
|
||||
const r=fakeBuild(dir,`GSTACK_CSO_BUILD_TESTING=1 GSTACK_CSO_BUILD_TEST_FAIL_AFTER=${checkpoint}`);
|
||||
expect(r.status).toBe(86);expect(bundleContents(dir)).toEqual(before);
|
||||
expect(spawnSync(join(dir,'bin/gstack-cso-launcher'),{encoding:'utf8',timeout:5000}).stdout.trim()).toBe('OLD');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('SIGKILL before launcher publication leaves no runnable mixed bundle',()=>{
|
||||
const dir=buildFixture();seedBundle(dir);const r=fakeBuild(dir,'GSTACK_CSO_BUILD_TESTING=1 GSTACK_CSO_BUILD_TEST_KILL_AFTER=publish-core');
|
||||
expect(r.status).not.toBe(0);expect(() => readFileSync(join(dir,'bin/gstack-cso-launcher'))).toThrow();
|
||||
expect(fakeBuild(dir).status).toBe(0);
|
||||
expectPublishedBundle(dir);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('SIGKILL after launcher-last publication leaves a complete new bundle',()=>{
|
||||
const dir=buildFixture();seedBundle(dir);const r=fakeBuild(dir,'GSTACK_CSO_BUILD_TESTING=1 GSTACK_CSO_BUILD_TEST_KILL_AFTER=publish-launcher');
|
||||
expect(r.status).not.toBe(0);expectPublishedBundle(dir);
|
||||
expect(spawnSync(join(dir,'bin/gstack-cso-launcher'),{encoding:'utf8',timeout:5000}).stdout.trim()).toBe('NEW');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('a failed rollback withholds the launcher and retains recovery material',()=>{
|
||||
const dir=buildFixture();seedBundle(dir);
|
||||
const r=fakeBuild(dir,'GSTACK_CSO_BUILD_TESTING=1 GSTACK_CSO_BUILD_TEST_FAIL_AFTER=publish-core GSTACK_CSO_BUILD_TEST_FAIL_RESTORE=core');
|
||||
expect(r.status).not.toBe(0);expect(() => readFileSync(join(dir,'bin/gstack-cso-launcher'))).toThrow();
|
||||
expect(r.stderr).toContain('recovery files remain');
|
||||
expect(readFileSync(join(dir,'bin/gstack-cso-watchdog'),'utf8')).toContain('OLD');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'linux')('publisher lock rejects a competing OS lock without mutating the installed bundle',async()=>{
|
||||
const dir=buildFixture(),ready=join(dir,'lock.ready'),release=join(dir,'lock.release');seedBundle(dir);const before=bundleContents(dir);
|
||||
const holder=Bun.spawn(['/usr/bin/flock','-n',join(dir,'bin'),'/bin/sh','-c',`: > ${quote(ready)}; while [ ! -f ${quote(release)} ]; do sleep .01; done`],{stdout:'pipe',stderr:'pipe'});
|
||||
const deadline=Date.now()+3000;while(!existsSync(ready)&&Date.now()<deadline)await Bun.sleep(10);
|
||||
expect(existsSync(ready)).toBe(true);
|
||||
expect(fakeBuild(dir).status).toBe(73);
|
||||
expect(bundleContents(dir)).toEqual(before);
|
||||
writeFileSync(release,'go');expect(await holder.exited).toBe(0);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('an ownerless legacy mkdir lock cannot block publication',()=>{
|
||||
const dir=buildFixture();seedBundle(dir);mkdirSync(join(dir,'bin/.gstack-cso-build.lock'));
|
||||
expect(fakeBuild(dir).status).toBe(0);
|
||||
expectPublishedBundle(dir);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'linux')('concurrent publishers cannot interleave generations under the exclusive publication lock',async()=>{
|
||||
const dir=buildFixture(),barrier=join(dir,'barrier');seedBundle(dir);
|
||||
const first=Bun.spawn(['/bin/bash','-c',fakeBuildCommand(dir,`CSO_PUBLISH_BARRIER=${quote(barrier)} CSO_BUILD_GENERATION=A`)],{stdout:'pipe',stderr:'pipe'});
|
||||
const deadline=Date.now()+3000;while(!existsSync(`${barrier}.ready`)&&Date.now()<deadline)await Bun.sleep(10);
|
||||
expect(existsSync(`${barrier}.ready`)).toBe(true);
|
||||
const second=fakeBuild(dir,'CSO_BUILD_GENERATION=B');expect(second.status).toBe(73);
|
||||
writeFileSync(`${barrier}.go`,'go');expect(await first.exited).toBe(0);
|
||||
expectPublishedBundle(dir,'A');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('a signal delivered after mv but before it returns cannot expose a mixed bundle',()=>{
|
||||
const dir=buildFixture(),tools=join(dir,'tools'),marker=join(dir,'signal-after-mv');seedBundle(dir);mkdirSync(tools);
|
||||
const realMv=Bun.which('mv');expect(realMv).toBeTruthy();
|
||||
writeFileSync(join(tools,'mv'),`#!/bin/sh\n${quote(realMv!)} "$@"\nstatus=$?\nif [ "$status" -eq 0 ] && [ ! -f "$CSO_SIGNAL_AFTER_MV_MARKER" ]; then\n : > "$CSO_SIGNAL_AFTER_MV_MARKER"\n kill -TERM "$PPID"\n sleep .05\nfi\nexit "$status"\n`);
|
||||
chmodSync(join(tools,'mv'),0o755);
|
||||
const r=fakeBuild(dir,`PATH=${quote(`${tools}:/usr/bin:/bin`)} CSO_SIGNAL_AFTER_MV_MARKER=${quote(marker)}`);
|
||||
expect(r.status).toBe(0);expect(existsSync(marker)).toBe(true);expectPublishedBundle(dir);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'linux')('a macOS build fails closed when hardened-runtime signing is unavailable', () => {
|
||||
const dir = buildFixture(), log = join(dir, 'compiler.log'), tools = join(dir, 'tools');
|
||||
seedBundle(dir);const before=bundleContents(dir);
|
||||
mkdirSync(tools);
|
||||
writeFileSync(join(tools, 'uname'), '#!/bin/sh\nprintf "Darwin\\n"\n');
|
||||
chmodSync(join(tools, 'uname'), 0o755);
|
||||
const r = runBashScript(`PATH=${quote(`${tools}:/usr/bin:/bin`)} BUN_CMD=${quote(join(dir, 'compiler recorder'))} CSO_CC=${quote(join(dir, 'compiler recorder'))} CSO_BUILD_LOG=${quote(log)} bash ${quote(join(dir, 'scripts/build-cso.sh'))}`, { timeout: 10_000 });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('requires macOS codesign');
|
||||
expect(bundleContents(dir)).toEqual(before);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'linux')('a macOS signing failure cannot mutate the installed generation',()=>{
|
||||
const dir=buildFixture(),log=join(dir,'compiler.log'),tools=join(dir,'tools');seedBundle(dir);const before=bundleContents(dir);mkdirSync(tools);
|
||||
writeFileSync(join(tools,'uname'),'#!/bin/sh\nprintf "Darwin\\n"\n');writeFileSync(join(tools,'codesign'),'#!/bin/sh\nexit 1\n');
|
||||
chmodSync(join(tools,'uname'),0o755);chmodSync(join(tools,'codesign'),0o755);
|
||||
const r=runBashScript(`PATH=${quote(`${tools}:/usr/bin:/bin`)} BUN_CMD=${quote(join(dir,'compiler recorder'))} CSO_CC=${quote(join(dir,'compiler recorder'))} CSO_BUILD_LOG=${quote(log)} bash ${quote(join(dir,'scripts/build-cso.sh'))}`,{timeout:10_000});
|
||||
expect(r.status).not.toBe(0);expect(bundleContents(dir)).toEqual(before);
|
||||
});
|
||||
|
||||
test('setup distributes the helper pair without promising an incomplete npm bin', () => {
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
||||
const build = readFileSync(join(ROOT, 'scripts/build.sh'), 'utf8');
|
||||
const setup = readFileSync(join(ROOT, 'setup'), 'utf8');
|
||||
const ignore = readFileSync(join(ROOT, '.gitignore'), 'utf8').split('\n');
|
||||
expect(pkg.bin).not.toHaveProperty('gstack-cso');
|
||||
expect(pkg.scripts['build:cso']).toBe('bash scripts/build-cso.sh');
|
||||
expect(pkg.scripts['test:cso:macos']).toBe('bun test test/cso-macos-launcher.test.ts test/cso-registry-socket.test.ts');
|
||||
expect(pkg.scripts['test:cso:docker']).toContain('test/cso-node-lifecycle-integration.test.ts');
|
||||
expect(pkg.scripts['test:cso:docker']).toContain('test/cso-stack-cold-integration.test.ts');
|
||||
expect(pkg.scripts['test:cso:docker']).toContain('--max-concurrency 1');
|
||||
expect(build).toContain('bash scripts/build-cso.sh');
|
||||
const signed = setup.match(/for _bin in ([^;]+);/)![1].split(/\s+/);
|
||||
for (const name of ['bin/gstack-cso-launcher','bin/gstack-cso-core', 'bin/gstack-cso-watchdog']) {
|
||||
expect(signed).not.toContain(name); expect(ignore).toContain(name); expect(ignore).toContain(`${name}.exe`);
|
||||
}
|
||||
expect(readFileSync(join(ROOT,'scripts/build-cso.sh'),'utf8')).toContain('cso_sign_macos_artifact "$CSO_STAGE_LAUNCHER" 1');
|
||||
for (const name of ['lib/cso/images/gstack-cso-verifier', 'lib/cso/images/gstack-cso-preparation']) expect(ignore).toContain(name);
|
||||
expect(setup).toContain('gstack-cso-launcher$_EXE" provision-images --setup-summary');
|
||||
expect(setup).toContain('anonymous exact-digest pulls');
|
||||
expect(setup).toContain('static audits remain available');
|
||||
// Host skill installers already distribute bin and lib together.
|
||||
expect(/for asset in bin lib browse review qa/.test(setup)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO runtime staging gates', () => {
|
||||
test('runtime workflow shell blocks parse after GitHub expressions are substituted', () => {
|
||||
for (const relative of [
|
||||
'.github/workflows/cso-runtime-images.yml',
|
||||
'.github/workflows/cso-runtime-qualification.yml',
|
||||
'.github/workflows/cso-runtime-promote.yml',
|
||||
]) {
|
||||
const raw = readFileSync(join(ROOT, relative), 'utf8');
|
||||
const workflow = Bun.YAML.parse(raw) as any;
|
||||
expect(raw).not.toMatch(/\s\+\s+--(?:no-|name|arg|evidence|output|security)/);
|
||||
for (const job of Object.values(workflow.jobs) as any[]) for (const step of job.steps ?? []) {
|
||||
if (typeof step.run !== 'string') continue;
|
||||
const script = step.run.replace(/\$\{\{[\s\S]*?\}\}/g, 'GH_EXPR');
|
||||
const parsed = spawnSync('/bin/bash', ['-n'], { input: script, encoding: 'utf8', timeout: 30_000 });
|
||||
expect(parsed.status, `${relative}: ${step.name ?? step.run}\n${parsed.stderr}`).toBe(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('reviewed inputs produce every application and database stack on each native Linux architecture', () => {
|
||||
const matrix = imageBuildMatrix(reviewedInputs());
|
||||
expect(matrix.include).toHaveLength(10);
|
||||
for (const stack of ['node', 'bun', 'python', 'rails', 'postgresql']) {
|
||||
const rows = matrix.include.filter(r => r.stack === stack);
|
||||
expect(rows.map(r => r.platform)).toEqual(['linux/amd64', 'linux/arm64']);
|
||||
expect(rows.map(r => r.runner)).toEqual(['ubuntu-24.04', 'ubuntu-24.04-arm']);
|
||||
}
|
||||
});
|
||||
|
||||
test('unreviewed or incomplete inputs cannot silently publish a partial matrix', () => {
|
||||
const inputs = reviewedInputs(); inputs.state = 'pending';
|
||||
expect(() => imageBuildMatrix(inputs)).toThrow('MISSING_REVIEWED_BUILD_INPUTS');
|
||||
inputs.state = 'reviewed'; inputs.profiles.pop();
|
||||
expect(() => imageBuildMatrix(inputs)).toThrow('INCOMPLETE_STACK_MATRIX');
|
||||
});
|
||||
|
||||
test.each(['node:latest', 'docker.io/library/node:24', CREDENTIAL_IMAGE, 'docker.io/library/node@sha256:bad', 'docker.io/library/node@sha256:' + 'A'.repeat(64), 'docker.io/library/node@sha256:' + 'a'.repeat(64) + '\nBAD=value'])('untrusted base input %s cannot enter the workflow matrix', image => {
|
||||
const inputs = reviewedInputs(); inputs.profiles[0].baseImages['linux/amd64'] = image;
|
||||
expect(() => imageBuildMatrix(inputs)).toThrow('UNPINNED_BASE_IMAGE');
|
||||
});
|
||||
|
||||
test('SBOM generator and toolchain inputs must be pinned too', () => {
|
||||
const inputs = reviewedInputs(); inputs.sbomGenerator.images['linux/amd64'] = 'docker.io/tool/sbom:latest';
|
||||
expect(() => imageBuildMatrix(inputs)).toThrow('UNPINNED_SBOM_GENERATOR');
|
||||
const versions = reviewedInputs(); versions.profiles[0].versions.node = '^24';
|
||||
expect(() => imageBuildMatrix(versions)).toThrow('UNPINNED_TOOL_VERSION');
|
||||
});
|
||||
|
||||
test('duplicate stacks and missing Python uv images fail precisely', () => {
|
||||
const duplicate = reviewedInputs(); duplicate.profiles[1].stack = 'node';
|
||||
expect(() => imageBuildMatrix(duplicate)).toThrow('INVALID_STACK');
|
||||
const python = reviewedInputs(); python.profiles[2].uvImages!['linux/arm64'] = '';
|
||||
expect(() => imageBuildMatrix(python)).toThrow('UNPINNED_UV_IMAGE');
|
||||
});
|
||||
|
||||
test('dedicated Docker CI is secretless and mandatory when its prerequisites are missing', () => {
|
||||
const raw = readFileSync(join(ROOT, '.github/workflows/free-tests.yml'), 'utf8');
|
||||
const workflow = Bun.YAML.parse(raw) as any;
|
||||
const job = workflow.jobs['cso-docker-integration'];
|
||||
expect(job).toBeTruthy();
|
||||
const gate = job.steps.find((step: any) => step.run === 'bun run test:cso:docker');
|
||||
expect(gate.env.GSTACK_CSO_DOCKER_TESTS).toBe('1');
|
||||
expect(raw).not.toContain('secrets.');
|
||||
expect(job.steps.map((s: any) => s.run ?? '').join('\n')).toContain('docker --host unix:///var/run/docker.sock info');
|
||||
expect(job['continue-on-error']).not.toBe(true);
|
||||
expect(gate['continue-on-error']).not.toBe(true);
|
||||
const required = workflow.jobs['free-tests'];
|
||||
expect(required.if).toBe('always()');
|
||||
expect(required.needs).toEqual(['free-suite', 'cso-macos-launcher', 'cso-windows-launcher', 'cso-docker-integration']);
|
||||
expect(required.steps[0].run).toContain('test "$CSO_DOCKER_RESULT" = success');
|
||||
for (const current of Object.values(workflow.jobs) as any[]) for (const step of current.steps) {
|
||||
if (step.uses?.startsWith('oven-sh/setup-bun')) expect(step.uses).toBe('oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6');
|
||||
}
|
||||
});
|
||||
|
||||
test('macOS runs the actual signed launcher gate on a native hosted runner', () => {
|
||||
const raw = readFileSync(join(ROOT, '.github/workflows/free-tests.yml'), 'utf8');
|
||||
const workflow = Bun.YAML.parse(raw) as any;
|
||||
const job = workflow.jobs['cso-macos-launcher'];
|
||||
expect(job['runs-on']).toBe('macos-latest');
|
||||
expect(job.steps.some((step: any) => step.run === 'bun run build:cso')).toBe(true);
|
||||
const gate = job.steps.find((step: any) => step.run === 'bun run test:cso:macos');
|
||||
expect(gate.env.GSTACK_CSO_MACOS_TESTS).toBe('1');
|
||||
expect(gate['continue-on-error']).not.toBe(true);
|
||||
});
|
||||
|
||||
test('publication requires manual protected-main review, signed evidence, and native containment checks', () => {
|
||||
const raw = readFileSync(join(ROOT, '.github/workflows/cso-runtime-images.yml'), 'utf8');
|
||||
const workflow = Bun.YAML.parse(raw) as any;
|
||||
expect(Object.keys(workflow.on)).toEqual(['pull_request', 'workflow_dispatch']);
|
||||
expect(workflow.jobs['validate-native'].if).toContain("github.event_name == 'pull_request'");
|
||||
expect(workflow.jobs['validate-native'].permissions).toEqual({ contents: 'read' });
|
||||
expect(workflow.jobs['validate-native'].steps.some((s: any) => s.run?.includes('cso-verify-runtime-base.ts'))).toBe(true);
|
||||
expect(workflow.jobs['validate-native'].steps.some((s: any) => s.with?.push === false && s.with?.load === true)).toBe(true);
|
||||
expect(workflow.jobs.stage.if).toContain("github.ref == 'refs/heads/main'");
|
||||
expect(workflow.jobs.stage.environment).toBe('cso-runtime-release');
|
||||
const steps = workflow.jobs.stage.steps;
|
||||
expect(steps.some((s: any) => s.with?.provenance === 'mode=max')).toBe(true);
|
||||
expect(steps.some((s: any) => s.with?.attests?.includes('generator=${{ matrix.sbomGeneratorImage }}'))).toBe(true);
|
||||
expect(steps.filter((s: any) => s.uses?.startsWith('actions/attest@'))).toHaveLength(2);
|
||||
expect(raw).toContain('--source-digest "$GITHUB_SHA"');
|
||||
expect(raw).toContain('--cert-identity "$signer"');
|
||||
expect(raw).toContain('--predicate-type https://slsa.dev/provenance/v1');
|
||||
expect(raw).toContain('--predicate-type https://spdx.dev/Document/v2.3');
|
||||
expect(raw).toContain('scripts/cso-public-ghcr.ts verify');
|
||||
expect(raw).toContain('--repository "$GITHUB_REPOSITORY" --output public-image.json');
|
||||
expect(raw).toContain('sha256sum public-image.json');
|
||||
expect(raw).toContain('GSTACK_CSO_TEST_IMAGE:');
|
||||
expect(raw).toContain('GSTACK_CSO_TEST_STACK:');
|
||||
expect(raw).toContain('bun run test:cso:docker');
|
||||
expect(workflow.jobs['qualify-native'].needs).toEqual(['reviewed-inputs', 'stage']);
|
||||
expect(raw).toContain('cso-staged-postgresql-${{ matrix.arch }}');
|
||||
expect(raw).toContain('GSTACK_CSO_TEST_POSTGRES_IMAGE');
|
||||
expect(raw).toContain('acquisitionPublicOnlyPassed:true');
|
||||
expect(raw).toContain('positiveNegativeAssertionsPassed:true');
|
||||
expect(raw).toContain('heldOutRepairPassed:"pending"');
|
||||
expect(raw).toContain('railsSqlitePassed:true');
|
||||
expect(raw).toContain('railsPostgresqlPassed:true');
|
||||
expect(raw).toContain('nativeExtensionsPassed:true');
|
||||
expect(raw).toContain('qualified:false');
|
||||
expect(raw).not.toContain('setup-qemu');
|
||||
expect(raw).not.toContain('git push');
|
||||
expect(raw).not.toContain('gh pr create');
|
||||
expect(raw).not.toContain('contents: write');
|
||||
const bunDockerfile=readFileSync(join(ROOT,'lib/cso/images/bun.Dockerfile'),'utf8');
|
||||
const bunPolicy=readFileSync(join(ROOT,'lib/cso/images/bun-no-auto-install.toml'),'utf8');
|
||||
expect(bunDockerfile).toContain('bun-no-auto-install.toml /opt/cso/no-auto-install.toml');
|
||||
expect(bunPolicy).toBe('[install]\nauto = "disable"\n');
|
||||
for (const job of Object.values(workflow.jobs) as any[]) for (const step of job.steps) {
|
||||
if (step.uses) expect(step.uses).toMatch(/@[a-f0-9]{40}$/);
|
||||
if (step.uses?.startsWith('oven-sh/setup-bun')) expect(step.uses).toBe('oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6');
|
||||
}
|
||||
});
|
||||
|
||||
test('catalog promotion authenticates one successful main run and opens an exact attested candidate PR', () => {
|
||||
const raw = readFileSync(join(ROOT, '.github/workflows/cso-runtime-promote.yml'), 'utf8');
|
||||
const workflow = Bun.YAML.parse(raw) as any;
|
||||
const job = workflow.jobs.propose;
|
||||
expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']);
|
||||
expect(job.if).toContain("github.ref == 'refs/heads/main'");
|
||||
expect(job.environment).toBe('cso-runtime-release');
|
||||
expect(job.permissions).toMatchObject({ contents: 'write', 'pull-requests': 'write', actions: 'read', packages: 'read', 'id-token': 'write', attestations: 'write' });
|
||||
expect(raw).toContain('.head_branch == "main"');
|
||||
expect(raw).toContain('.path == ".github/workflows/cso-runtime-qualification.yml"');
|
||||
expect(raw).toContain('.event == "repository_dispatch"');
|
||||
expect(raw).toContain('--name cso-qualified-runtime-statements');
|
||||
expect(raw).toContain('scripts/cso-runtime-promotion.ts');
|
||||
expect(raw).toContain('runtime-catalog.candidate.json');
|
||||
expect(raw).toContain('subject-path: runtime-catalog.candidate.json');
|
||||
expect(raw).toContain('gh attestation verify runtime-catalog.candidate.json');
|
||||
expect(raw).toContain('--cert-identity "$signer"');
|
||||
expect(raw).toContain('--source-digest "$GITHUB_SHA"');
|
||||
expect(raw).toContain('cso-attestation-evidence.ts digest');
|
||||
expect(raw).toContain('candidate-attestation-evidence.json');
|
||||
expect(raw).toContain('scripts/cso-public-ghcr.ts verify');
|
||||
expect(raw).toContain('--remove-after');
|
||||
expect(raw).toContain('cso-runtime-promotion.ts validate-transition');
|
||||
expect(raw).toContain('cmp runtime-catalog.candidate.json committed-runtime-catalog.json');
|
||||
expect(raw).toContain('git push');
|
||||
expect(raw).toContain('gh pr create --base main');
|
||||
expect(raw).toContain('branch="cso-runtime-catalog-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"');
|
||||
expect(raw).not.toContain('branch="cso-runtime-catalog-$GITHUB_RUN_ID"');
|
||||
const attested = raw.indexOf('subject-path: runtime-catalog.candidate.json');
|
||||
const publicPull = raw.indexOf('scripts/cso-public-ghcr.ts verify');
|
||||
const verified = raw.indexOf('gh attestation verify runtime-catalog.candidate.json');
|
||||
const committed = raw.indexOf('git add lib/cso/runtime-catalog.json');
|
||||
expect(publicPull).toBeGreaterThanOrEqual(0);
|
||||
expect(attested).toBeGreaterThan(publicPull);
|
||||
expect(verified).toBeGreaterThan(attested);
|
||||
expect(committed).toBeGreaterThan(verified);
|
||||
for (const step of job.steps) if (step.uses) expect(step.uses).toMatch(/@[a-f0-9]{40}$/);
|
||||
});
|
||||
|
||||
test('private qualification enters only through an actor-restricted protected environment and re-verifies staged attestations', () => {
|
||||
const raw = readFileSync(join(ROOT, '.github/workflows/cso-runtime-qualification.yml'), 'utf8');
|
||||
const workflow = Bun.YAML.parse(raw) as any;
|
||||
const job = workflow.jobs.qualify;
|
||||
expect(Object.keys(workflow.on)).toEqual(['repository_dispatch']);
|
||||
expect(job.environment).toBe('cso-runtime-release');
|
||||
expect(job.permissions).toEqual({ contents: 'read', packages: 'read', attestations: 'read' });
|
||||
expect(raw).toContain('test "$GITHUB_ACTOR" = "$CSO_QUALIFICATION_ACTOR"');
|
||||
expect(raw).toContain('.client_payload.statements | type == "array" and length == 10');
|
||||
expect(raw).toContain('--cert-identity "$signer"');
|
||||
expect(raw).toContain('--source-digest "$source_commit"');
|
||||
expect(raw).toContain('--deny-self-hosted-runners');
|
||||
expect(raw).toContain('Recheck public visibility and anonymous pulls for every qualified digest');
|
||||
expect(raw).toContain('scripts/cso-public-ghcr.ts verify');
|
||||
expect(raw).toContain('public-image-evidence');
|
||||
expect(raw).toContain('--remove-after');
|
||||
expect(raw).toContain('scripts/cso-runtime-promotion.ts');
|
||||
expect(raw).toContain('name: cso-qualified-runtime-statements');
|
||||
expect(raw).not.toContain('contents: write');
|
||||
for (const step of job.steps) if (step.uses) expect(step.uses).toMatch(/@[a-f0-9]{40}$/);
|
||||
});
|
||||
|
||||
test('staged images enter the daemon only after signed evidence checks and before no-pull execution', () => {
|
||||
const workflow = Bun.YAML.parse(readFileSync(join(ROOT, '.github/workflows/cso-runtime-images.yml'), 'utf8')) as any;
|
||||
const steps = workflow.jobs.stage.steps;
|
||||
const verified = steps.findIndex((s: any) => s.run?.includes('--predicate-type https://spdx.dev/Document/v2.3'));
|
||||
const pulled = steps.findIndex((s: any) => s.run?.includes('scripts/cso-public-ghcr.ts verify'));
|
||||
const executed = steps.findIndex((s: any) => s.run?.includes('test/cso-docker-integration.test.ts'));
|
||||
expect(verified).toBeGreaterThanOrEqual(0);
|
||||
expect(pulled).toBeGreaterThan(verified);
|
||||
expect(executed).toBeGreaterThan(pulled);
|
||||
expect(steps[pulled].env.CSO_IMAGE).toBe('${{ steps.image.outputs.name }}@${{ steps.build.outputs.digest }}');
|
||||
expect(steps[pulled].env.GH_TOKEN).toBe('${{ github.token }}');
|
||||
expect(steps[pulled].run).toContain('--image "$CSO_IMAGE" --platform "$CSO_PLATFORM"');
|
||||
expect(steps[pulled].run).toContain('--repository "$GITHUB_REPOSITORY" --output public-image.json');
|
||||
expect(steps[pulled]['continue-on-error']).not.toBe(true);
|
||||
const qualify=workflow.jobs['qualify-native'];
|
||||
const loaded=qualify.steps.findIndex((s:any)=>s.run?.includes('GSTACK_CSO_TEST_IMAGE'));
|
||||
const cold=qualify.steps.findIndex((s:any)=>s.run?.includes('bun run test:cso:docker'));
|
||||
expect(loaded).toBeGreaterThanOrEqual(0);expect(cold).toBeGreaterThan(loaded);
|
||||
expect(qualify.needs).toEqual(['reviewed-inputs','stage']);
|
||||
expect(qualify.steps.some((s:any)=>s.uses?.startsWith('docker/login-action@'))).toBe(false);
|
||||
expect(qualify.steps[loaded].run).toContain('scripts/cso-public-ghcr.ts verify');
|
||||
expect(qualify.steps[loaded].env.GH_TOKEN).toBe('${{ github.token }}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';import * as os from 'node:os';import * as path from 'node:path';import { spawn, spawnSync } from 'node:child_process';
|
||||
import { DockerGroup, dockerEndpoint, dockerProbe } from '../lib/cso/docker';import { admit, release } from '../lib/cso/admission';
|
||||
const enabled=process.env.GSTACK_CSO_DOCKER_TESTS==='1',suite=enabled?describe:describe.skip;let root='',state='',image='',volumeImage='',endpoint:any,watchdog='';
|
||||
function exec(file:string,args:string[],env:Record<string,string>={}){const r=spawnSync(file,args,{encoding:'utf8',env:{PATH:'/usr/local/bin:/usr/bin:/bin',HOME:root,GSTACK_HOME:state,DOCKER_HOST:'unix:///var/run/docker.sock',...env},timeout:120_000});if(r.status!==0)throw new Error(`${file} failed: ${r.stderr}`);return r.stdout.trim();}
|
||||
beforeAll(()=>{if(!enabled)return;root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-docker-'));state=path.join(root,'state');process.env.GSTACK_HOME=state;watchdog=path.resolve(import.meta.dir,'../bin/gstack-cso-watchdog');if(!fs.existsSync(watchdog))throw new Error('GSTACK_CSO_DOCKER_TESTS=1 requires the compiled watchdog');
|
||||
const source=`#include <arpa/inet.h>\n#include <errno.h>\n#include <fcntl.h>\n#include <netdb.h>\n#include <netinet/in.h>\n#include <stdio.h>\n#include <stdlib.h>\n#include <string.h>\n#include <sys/socket.h>\n#include <sys/stat.h>\n#include <sys/types.h>\n#include <unistd.h>\nstatic int blocked4(const char *ip,int port){int s=socket(AF_INET,SOCK_STREAM,0);struct sockaddr_in a={.sin_family=AF_INET,.sin_port=htons(port)};inet_pton(AF_INET,ip,&a.sin_addr);int r=connect(s,(void*)&a,sizeof a);close(s);return r!=0;}\nstatic int blocked6(void){int s=socket(AF_INET6,SOCK_STREAM,0);struct sockaddr_in6 a={.sin6_family=AF_INET6,.sin6_port=htons(443)};inet_pton(AF_INET6,"2606:4700:4700::1111",&a.sin6_addr);int r=connect(s,(void*)&a,sizeof a);close(s);return r!=0;}\nint main(int argc,char**argv){if(strstr(argv[0],"entrypoint")){if(argc<2||argv[1][0]!='/')return 64;execv(argv[1],argv+1);return 69;}if(strstr(argv[0],"sleep")){for(;;)sleep(3600);}if(strstr(argv[0],"reader")){char a[16]={0},b[16]={0};FILE*x=fopen("/source/source.txt","r"),*y=fopen("/policy/verification.json","r");if(!x||!y||!fgets(a,sizeof a,x)||!fgets(b,sizeof b,y))return 12;fclose(x);fclose(y);if(strcmp(a,"source\\n")||strcmp(b,"policy\\n"))return 13;puts("INPUTS_OK");return 0;}if(strstr(argv[0],"server")){int s=socket(AF_INET,SOCK_STREAM,0),c;struct sockaddr_in a={.sin_family=AF_INET,.sin_port=htons(34567),.sin_addr.s_addr=htonl(INADDR_LOOPBACK)};int one=1;setsockopt(s,SOL_SOCKET,SO_REUSEADDR,&one,sizeof one);if(bind(s,(void*)&a,sizeof a)||listen(s,2))return 2;for(;;){c=accept(s,0,0);if(c>=0){write(c,"ok",2);close(c);}}}int fd=open("/should-not-write",O_WRONLY|O_CREAT,0600);if(fd>=0)return 10;FILE*f=fopen("/proc/self/status","r");char line[256];int caps=1,nnp=0;while(f&&fgets(line,sizeof line,f)){if(!strncmp(line,"CapEff:",7))caps=strtoull(line+7,0,16)!=0;if(!strncmp(line,"NoNewPrivs:",11))nnp=atoi(line+11);}if(f)fclose(f);int s=socket(AF_INET,SOCK_STREAM,0);struct sockaddr_in a={.sin_family=AF_INET,.sin_port=htons(34567),.sin_addr.s_addr=htonl(INADDR_LOOPBACK)};int loop=connect(s,(void*)&a,sizeof a)==0;close(s);struct addrinfo*h=0;int dns=getaddrinfo("example.com",0,0,&h);if(h)freeaddrinfo(h);if(getuid()==0||caps||!nnp||!loop||!blocked4("8.8.8.8",443)||!blocked4("169.254.169.254",80)||!blocked6()||dns==0)return 11;puts("CONTAINMENT_OK");return 0;}\n`;
|
||||
fs.writeFileSync(path.join(root,'probe.c'),source);exec('/usr/bin/cc',['-static','-O2',path.join(root,'probe.c'),'-o',path.join(root,'probe')]);exec('/usr/bin/cc',['-static','-O2',path.join(import.meta.dir,'fixtures/cso-http-smoke.c'),'-o',path.join(root,'http-server')]);fs.writeFileSync(path.join(root,'Dockerfile'),'FROM scratch\nCOPY probe /opt/cso/entrypoint\nCOPY probe /bin/sleep\nCOPY probe /server\nCOPY probe /client\nCOPY probe /reader\nCOPY probe /policy/.keep\nCOPY probe /source/.keep\nCOPY http-server /http-server\nUSER 10001:10001\nENTRYPOINT ["/opt/cso/entrypoint"]\n');const tag=`gstack-cso-fixture:${process.pid}`;exec('/usr/bin/docker',['--host','unix:///var/run/docker.sock','build','--network=none','--tag',tag,root]);image=exec('/usr/bin/docker',['--host','unix:///var/run/docker.sock','image','inspect','--format','{{.Id}}',tag]);fs.writeFileSync(path.join(root,'VolumeDockerfile'),`FROM ${tag}\nVOLUME ["/unbounded"]\n`);const volumeTag=`gstack-cso-volume-fixture:${process.pid}`;exec('/usr/bin/docker',['--host','unix:///var/run/docker.sock','build','--network=none','--file',path.join(root,'VolumeDockerfile'),'--tag',volumeTag,root]);volumeImage=exec('/usr/bin/docker',['--host','unix:///var/run/docker.sock','image','inspect','--format','{{.Id}}',volumeTag]);},300_000);
|
||||
afterAll(()=>{if(root){for(const candidate of [volumeImage,image])try{if(candidate)spawnSync('/usr/bin/docker',['--host','unix:///var/run/docker.sock','image','rm','--force',candidate],{stdio:'ignore',timeout:30_000});}catch{}fs.rmSync(root,{recursive:true,force:true});}delete process.env.GSTACK_HOME;},120_000);
|
||||
|
||||
suite('CSO Docker containment integration',()=>{
|
||||
test('hard fails when local daemon enforcement prerequisites are absent',async()=>{endpoint=await dockerEndpoint(root,{HOME:root,DOCKER_HOST:'unix:///var/run/docker.sock'});const info=await dockerProbe(endpoint,root);expect(info.security.some((x:string)=>x.includes('seccomp'))).toBe(true);});
|
||||
test('rejects image-declared writable volumes before container creation',async()=>{const dir=path.join(root,'volume-rejection');fs.mkdirSync(dir);const group=await DockerGroup.create(endpoint,`volume-${Date.now()}`,dir,Date.now()+60_000,image,watchdog);try{const before=exec('/usr/bin/docker',['--host',endpoint.uri,'volume','ls','--quiet']);await expect(group.createContainer({role:'app',image:volumeImage,command:['/bin/sleep','1']})).rejects.toThrow('declares writable volumes');expect(exec('/usr/bin/docker',['--host',endpoint.uri,'volume','ls','--quiet'])).toBe(before);}finally{await group.cleanup();}},120_000);
|
||||
test('shares only loopback while denying egress, privileges, and daemon logs, and reads private source/policy mounts',async()=>{const dir=path.join(root,'group');fs.mkdirSync(dir);const group=await DockerGroup.create(endpoint,`integration-${Date.now()}`,dir,Date.now()+60_000,image,watchdog);let server='',client='';try{server=await group.createContainer({role:'app',image,command:['/server']});await group.start(server);client=await group.createContainer({role:'verifier',image,command:['/client']});const result=await group.startAttach(client);expect(result).toEqual({code:0,output:'CONTAINMENT_OK\n'});const sourceDir=path.join(dir,'private-source'),policy=path.join(dir,'verification.json');fs.mkdirSync(sourceDir,{mode:0o700});fs.writeFileSync(path.join(sourceDir,'source.txt'),'source\n',{mode:0o600});fs.writeFileSync(policy,'policy\n',{mode:0o600});const reader=await group.createContainer({role:'browser',image,source:sourceDir,command:['/reader'],readonlyFiles:[{host:policy,container:'/policy/verification.json'}]});expect(await group.startAttach(reader)).toEqual({code:0,output:'INPUTS_OK\n'});const raw=exec('/usr/bin/docker',['--host',endpoint.uri,'inspect',client]),inspect=JSON.parse(raw)[0];expect(inspect.HostConfig).toMatchObject({ReadonlyRootfs:true,NetworkMode:`container:${group.anchor}`,PidsLimit:32,ShmSize:8*1024*1024,LogConfig:{Type:'none',Config:{}}});expect(inspect.Config.User).toBe(`${process.getuid?.()}:${process.getgid?.()}`);expect(inspect.HostConfig.CapDrop).toEqual(['ALL']);expect(inspect.HostConfig.SecurityOpt).toContain('no-new-privileges:true');expect(inspect.HostConfig.PortBindings).toEqual({});expect(inspect.Mounts.every((m:any)=>m.Destination!=='/var/run/docker.sock')).toBe(true);}finally{await group.cleanup();}expect(spawnSync('/usr/bin/docker',['--host',endpoint.uri,'inspect',client],{timeout:30_000}).status).not.toBe(0);});
|
||||
test('machine-wide admission allows only two groups per endpoint',()=>{const a=admit(endpoint.uri,'a',Date.now()+60_000),b=admit(endpoint.uri,'b',Date.now()+60_000);try{expect(()=>admit(endpoint.uri,'c',Date.now()+60_000)).toThrow('Two reproduction groups');}finally{release(a);release(b);}});
|
||||
test('staged runtime executes its trusted verifier and checks every declared tool version', async () => {
|
||||
const staged = process.env.GSTACK_CSO_TEST_IMAGE;
|
||||
if (!staged) return;
|
||||
expect(staged).toMatch(/@sha256:[a-f0-9]{64}$/);
|
||||
expect(process.env.GSTACK_CSO_TEST_PLATFORM).toBe(process.arch === 'arm64' ? 'linux/arm64' : 'linux/amd64');
|
||||
const versions = JSON.parse(process.env.GSTACK_CSO_EXPECTED_VERSIONS || '{}');
|
||||
const commands: Record<string, Record<string, string[]>> = {
|
||||
node: {node: ['/usr/local/bin/node', '--version'], npm: ['/usr/local/bin/npm', '--version'], 'cso-preparation': ['/opt/cso/preparation', '--version']},
|
||||
bun: {bun: ['/usr/local/bin/bun', '--version'], 'cso-preparation': ['/opt/cso/preparation', '--version']},
|
||||
python: {python: ['/usr/local/bin/python', '--version'], uv: ['/usr/local/bin/uv', '--version'], 'cso-preparation': ['/opt/cso/preparation', '--version']},
|
||||
rails: {ruby: ['/usr/local/bin/ruby', '--version'], bundler: ['/usr/local/bin/bundle', '--version'], 'cso-preparation': ['/opt/cso/preparation', '--version']},
|
||||
postgresql: {postgresql: ['/opt/cso/bin/postgres', '--version']},
|
||||
};
|
||||
const prefixes: Record<string, string> = {node: 'v', npm: '', bun: '', python: 'Python ', uv: 'uv ', ruby: 'ruby ', bundler: 'Bundler version ', postgresql: 'postgres (PostgreSQL) ', 'cso-preparation': ''};
|
||||
const stack = commands[process.env.GSTACK_CSO_TEST_STACK || ''];
|
||||
expect(stack).toBeDefined();
|
||||
expect(Object.keys(versions).sort()).toEqual(Object.keys(stack).sort());
|
||||
const dir = path.join(root, 'staged'); fs.mkdirSync(dir, {mode: 0o700});
|
||||
const policy = {
|
||||
phase: 'after', port: 34568,
|
||||
legitimate: [{name: 'available feature', path: '/control', method: 'GET', expected: {status: 200, includes: 'CONTROL_OK'}}],
|
||||
security: {name: 'denied access', path: '/security', method: 'GET', expected: {status: 403, includes: 'DENIED'}, vulnerable: {status: 200, includes: 'SECRET'}},
|
||||
};
|
||||
const positivePath = path.join(dir, 'positive.json'), negativePath = path.join(dir, 'broken-control.json');
|
||||
fs.writeFileSync(positivePath, JSON.stringify(policy), {mode: 0o600});
|
||||
fs.writeFileSync(negativePath, JSON.stringify({...policy, legitimate: [{...policy.legitimate[0], expected: {status: 201, includes: 'CONTROL_OK'}}]}), {mode: 0o600});
|
||||
const group = await DockerGroup.create(endpoint, `staged-${Date.now()}`, dir, Date.now() + 90_000, staged, watchdog);
|
||||
try {
|
||||
for (const [tool, version] of Object.entries(versions)) {
|
||||
const result = await group.execAttach(group.anchor, stack[tool]);
|
||||
expect(result.code).toBe(0);
|
||||
const expected = `${prefixes[tool]}${version}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
expect(result.output.trim()).toMatch(new RegExp(`^${expected}(?:$|\\s)`));
|
||||
}
|
||||
if(process.env.GSTACK_CSO_TEST_STACK==='postgresql'){
|
||||
const databases=path.join(dir,'postgresql.databases');fs.writeFileSync(databases,'cso_primary\ncso_queue\n',{mode:0o444});
|
||||
const postgres=await group.createContainer({role:'postgres',image:staged,command:['/opt/cso/run-postgresql','/policy/postgresql.databases'],postgresDatabasePolicy:databases});await group.start(postgres);
|
||||
let ready=false;for(let attempt=0;attempt<100&&!ready;attempt++){const checked=await group.execAttach(postgres,['/opt/cso/postgresql-ready','/policy/postgresql.databases']);ready=checked.code===0;if(!ready)await Bun.sleep(50);}expect(ready).toBe(true);
|
||||
const raw=exec('/usr/bin/docker',['--host',endpoint.uri,'inspect',postgres]),inspect=JSON.parse(raw)[0];expect(inspect.Config.User).toBe('10001:10001');
|
||||
await group.removeContainer(postgres);
|
||||
}
|
||||
const server = await group.createContainer({role: 'app', image, command: ['/http-server']});
|
||||
await group.start(server);
|
||||
const verifier = await group.createContainer({
|
||||
role: 'verifier', image: staged, command: ['/bin/sleep', '2147483647'],
|
||||
readonlyFiles: [{host: positivePath, container: '/policy/positive.json'}, {host: negativePath, container: '/policy/broken-control.json'}],
|
||||
});
|
||||
await group.start(verifier);
|
||||
const positive = await group.execAttach(verifier, ['/opt/cso/verifier', '/policy/positive.json']);
|
||||
expect(positive.code).toBe(0);
|
||||
expect(JSON.parse(positive.output)).toMatchObject({booted: true, legitimate: true, security: 'pass'});
|
||||
const negative = await group.execAttach(verifier, ['/opt/cso/verifier', '/policy/broken-control.json']);
|
||||
expect(negative.code).toBe(0);
|
||||
expect(JSON.parse(negative.output)).toMatchObject({booted: true, legitimate: false, security: 'pass'});
|
||||
} finally { await group.cleanup(); }
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { GROUP_LIMITS, ROLE_LIMITS, type Role } from '../lib/cso/admission';
|
||||
import { CONTAINER_SHM_BYTES, writableAllocation } from '../lib/cso/docker';
|
||||
|
||||
describe('CSO Docker writable-storage policy',()=>{
|
||||
test('every default role includes the explicitly bounded shm allocation',()=>{
|
||||
for(const role of Object.keys(ROLE_LIMITS) as Role[]){
|
||||
const allocation=writableAllocation(role);
|
||||
expect(allocation.shmBytes).toBe(CONTAINER_SHM_BYTES);
|
||||
expect(allocation.totalBytes).toBe(ROLE_LIMITS[role].writableMiB*1024*1024);
|
||||
expect(allocation.temporaryBytes).toBeGreaterThan(0);
|
||||
expect(allocation.workBytes).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('the Rails PostgreSQL group stays within two GiB including every shm mount',()=>{
|
||||
const roles:Role[]=['anchor','postgres','app','verifier'];
|
||||
const bytes=roles.reduce((sum,role)=>sum+writableAllocation(role).totalBytes,0);
|
||||
expect(bytes).toBe(GROUP_LIMITS.writableMiB*1024*1024);
|
||||
});
|
||||
|
||||
test('dependency acquisition accounts for metadata, archives, and shm together',()=>{
|
||||
const mib=1024*1024,allocation=writableAllocation('app',{
|
||||
temporaryTmpfsBytes:64*mib,workTmpfsBytes:64*mib,metadataTmpfsBytes:1024*mib,archiveTmpfsBytes:384*mib,
|
||||
});
|
||||
expect(allocation.totalBytes).toBe((64+64+1024+384)*mib+CONTAINER_SHM_BYTES);
|
||||
expect(writableAllocation('anchor').totalBytes+allocation.totalBytes).toBeLessThan(GROUP_LIMITS.writableMiB*mib);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,812 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { collectProducerReceipts, createEvalMatrix, createPortableSkillPayload, loadPortableSkillPayload, prepareEvalJobs, REQUIRED_CONTAINMENT, scoreCollectedEval, scoreEval, validateMatrix, validatePortableSkillPayload, type EvalCell, type EvalResult, type PreparedEvalSchedule } from '../scripts/cso-eval';
|
||||
import { PRODUCER_PROVIDER_POLICY, producerFailureMessage, producerInstallationIdentity, resolveProducerHelperBinding, runProducerCell, validateProductionProducerInstallation } from '../scripts/cso-eval-producer';
|
||||
import { producerArtifactInventoryHash, producerInstallationIdentityHash, producerProviderIdentityHash, producerReceiptHash, sha256, type ProducerArtifactInventory, type ProducerInstallationIdentity, type ProducerProviderIdentity, type ProducerReceipt } from '../scripts/cso-eval-protocol';
|
||||
import type { Family, ProviderAdapter, RunOpts, RunResult } from './helpers/providers/types';
|
||||
import { CORPUS_VERSION, FAMILIES, STACKS, loadCorpusManifest, materializeCase, sourceFiles, sourceHash } from './fixtures/cso-eval/materialize';
|
||||
import { judgeRepair, oracleFor, type PrivateEvidence } from './helpers/cso-eval-oracles';
|
||||
import { inspectPreparation } from '../lib/cso/preparation';
|
||||
import { assertRuntimeCompatible, RUNTIME_CATALOG } from '../lib/cso/runtime-catalog';
|
||||
import { canonicalStartPlan, canonicalTestPlan } from '../lib/cso/verification';
|
||||
import { CsoError } from '../lib/cso/contracts';
|
||||
import { geminiProducerPaths, geminiProducerSystemSettings } from './helpers/providers/gemini';
|
||||
|
||||
const temporary: string[] = [];
|
||||
const root = () => { const path = mkdtempSync(join(tmpdir(), 'cso-eval-')); temporary.push(path); return path; };
|
||||
function unlockTemporary(path: string): void {
|
||||
if (!existsSync(path)) return;
|
||||
const stat = lstatSync(path);
|
||||
if (stat.isSymbolicLink()) return;
|
||||
if (!stat.isDirectory()) { try { chmodSync(path, 0o600); } catch {} return; }
|
||||
try { chmodSync(path, 0o700); } catch {}
|
||||
for (const entry of readdirSync(path)) unlockTemporary(join(path, entry));
|
||||
}
|
||||
afterEach(() => { for (const path of temporary.splice(0)) { unlockTemporary(path); rmSync(path, { recursive: true, force: true }); } });
|
||||
const corpus = loadCorpusManifest();
|
||||
const matrix = createEvalMatrix({ model: 'matched-test-model', host: 'codex', skillHashes: { v2: 'a'.repeat(64), v3: 'b'.repeat(64) } });
|
||||
const qualification = { containment: Object.fromEntries(REQUIRED_CONTAINMENT.map(name => [name, 'passed' as const])) };
|
||||
|
||||
function portableSkill(version: 'v2' | 'v3', sectionMarker: string): string {
|
||||
const manifest = JSON.stringify({ $schema: 'https://gstack.dev/schemas/section-manifest.json', skill: 'cso', version: 1, sections: [{ id: 'audit-phases', file: 'audit-phases.md', title: 'Audit phases', trigger: 'during audit phases' }] }, null, 2) + '\n';
|
||||
return createPortableSkillPayload(version, [
|
||||
{ path: 'SKILL.md', contents: `---\nname: cso\nversion: ${version.slice(1)}.0.0\n---\n# CSO ${version}\nRead cso/sections/audit-phases.md before auditing.\n` },
|
||||
{ path: 'sections/manifest.json', contents: manifest },
|
||||
{ path: 'sections/audit-phases.md', contents: `# Complete ${version} phases\n${sectionMarker}\n` },
|
||||
]);
|
||||
}
|
||||
|
||||
/** Synthetic accounting records exercise the scorer. These are not measured agent results. */
|
||||
function syntheticResult(cell: EvalCell): EvalResult {
|
||||
const runtime = cell.mode === 'comprehensive';
|
||||
const positive = cell.variant === 'vulnerable';
|
||||
const repairEvidenceHash = 'c'.repeat(64), recheckEvidenceHash = 'd'.repeat(64);
|
||||
return { cellId: cell.id, sourceHash: cell.sourceHash, skillHash: cell.skillHash, model: cell.model, host: cell.host, budgetSeconds: cell.budgetSeconds,
|
||||
reportPresent: true, reportComplete: true,
|
||||
findings: positive ? [{ id: 'finding-1', evidence: cell.version === 'v2' ? 'legacy_review' : 'supported', claimedTested: runtime && cell.version === 'v3', judgment: 'correct', matchedCaseId: cell.caseId,
|
||||
...(runtime && cell.version === 'v3' ? { trustedVerification: { repair: 'passed' as const, repairEvidenceHash, recheck: 'passed' as const, recheckEvidenceHash } } : {}) }] : [],
|
||||
setup: runtime ? 'passed' : 'not_attempted', reproduction: runtime && positive ? 'passed' : 'not_attempted', repair: runtime && positive ? 'passed' : 'not_attempted', recheck: runtime && positive ? 'passed' : 'not_attempted',
|
||||
...(runtime && positive ? { oracleEvidenceHash: repairEvidenceHash, oracleVersion: CORPUS_VERSION, currentSourceHash: corpus.cases.find(fixture => fixture.id === cell.caseId)!.filesHash.fixed, recheckEvidenceHash } : {}),
|
||||
heldOutAssertionsPassed: runtime && positive, freshRecheck: runtime && positive, latencyMs: 1500, firstUsefulResultMs: positive ? 500 : null,
|
||||
};
|
||||
}
|
||||
const group = (score: ReturnType<typeof scoreEval>, version: string, mode: string) => score.groups.find(item => item.version === version && item.mode === mode)!;
|
||||
function syntheticInstallationIdentity(seed = '1'): ProducerInstallationIdentity {
|
||||
const artifact = (offset: number) => ({ sha256: sha256(`${seed}:artifact:${offset}`), bytes: 100 + offset });
|
||||
const core = artifact(3);
|
||||
const withoutHash: Omit<ProducerInstallationIdentity, 'identityHash'> = {
|
||||
schemaVersion: 1,
|
||||
producer: artifact(1), launcher: artifact(2), core, watchdog: artifact(4),
|
||||
generation: { coreSha256: core.sha256, manifest: { sha256: sha256(`${core.sha256}\n`), bytes: 65 } },
|
||||
embeddedCatalogs: {
|
||||
runtimeRevision: 'runtime-test', runtimeBuildRevision: 'runtime-build-test', runtimeSha256: sha256(`${seed}:runtime`),
|
||||
scannerRevision: 'scanner-test', scannerSha256: sha256(`${seed}:scanner`),
|
||||
},
|
||||
};
|
||||
return { ...withoutHash, identityHash: producerInstallationIdentityHash(withoutHash) };
|
||||
}
|
||||
function syntheticProviderIdentity(family: Family = 'gpt', seed = '1'): ProducerProviderIdentity {
|
||||
const withoutHash: Omit<ProducerProviderIdentity, 'identityHash'> = {
|
||||
schemaVersion: 1, family, policyRevision: `${family}-test-policy`,
|
||||
executable: { sha256: sha256(`${seed}:${family}:provider`), bytes: 200 }, argsPrefix: [], version: `${family}-test-version`,
|
||||
};
|
||||
return { ...withoutHash, identityHash: producerProviderIdentityHash(withoutHash) };
|
||||
}
|
||||
function syntheticArtifactInventory(): ProducerArtifactInventory {
|
||||
const base = { schemaVersion: 1 as const, root: 'security/cso' as const, entries: [], totalBytes: 0 };
|
||||
return { ...base, identityHash: producerArtifactInventoryHash(base) };
|
||||
}
|
||||
function syntheticReceipt(cell: EvalCell): ProducerReceipt {
|
||||
const withoutHash: Omit<ProducerReceipt, 'receiptHash'> = { schemaVersion: 1, cell, inputHash: 'e'.repeat(64), installationIdentity: syntheticInstallationIdentity(), providerIdentity: syntheticProviderIdentity(cell.host === 'codex' ? 'gpt' : cell.host), artifacts: syntheticArtifactInventory(), startedAt: '2026-01-01T00:00:00.000Z', finishedAt: '2026-01-01T00:00:01.000Z', status: 'succeeded', requestedModel: cell.model, modelUsed: `resolved-${cell.model}`, modelIdentitySource: 'provider_reported', durationMs: 1000, firstUsefulResultMs: null, toolCalls: 1, output: 'synthetic producer transcript', outputHash: sha256('synthetic producer transcript'), usage: { inputTokens: 10, outputTokens: 5, cachedTokens: 0, estimatedCostUSD: 0.001 } };
|
||||
return { ...withoutHash, receiptHash: producerReceiptHash(withoutHash) };
|
||||
}
|
||||
function syntheticSchedule(): PreparedEvalSchedule {
|
||||
return { schemaVersion: 1, matrixHash: createHash('sha256').update(JSON.stringify(matrix)).digest('hex'), scheduledCells: matrix.cells.length, preparedCells: matrix.cells.length, jobs: matrix.cells.map(cell => ({ cellId: cell.id, relativePath: `jobs/${cell.id}`, inputHash: 'e'.repeat(64) })) };
|
||||
}
|
||||
function rehashReceipt(receipt: ProducerReceipt): ProducerReceipt {
|
||||
const { receiptHash: _receiptHash, ...withoutHash } = receipt;
|
||||
return { ...withoutHash, receiptHash: producerReceiptHash(withoutHash) };
|
||||
}
|
||||
function syntheticBatch() {
|
||||
return collectProducerReceipts(matrix, syntheticSchedule(), matrix.cells.map(syntheticReceipt));
|
||||
}
|
||||
|
||||
describe('CSO immutable evaluation corpus', () => {
|
||||
test('contains forty distinct vulnerable/fixed pairs across ten families and four stacks', () => {
|
||||
expect(corpus.cases).toHaveLength(40);
|
||||
for (const stack of STACKS) expect(corpus.cases.filter(fixture => fixture.stack === stack).map(fixture => fixture.family)).toEqual([...FAMILIES]);
|
||||
for (const fixture of corpus.cases) {
|
||||
expect(fixture.filesHash.vulnerable).not.toBe(fixture.filesHash.fixed);
|
||||
for (const variant of ['vulnerable', 'fixed'] as const) expect(sourceHash(sourceFiles(fixture.id, variant))).toBe(fixture.filesHash[variant]);
|
||||
}
|
||||
});
|
||||
test('producer snapshots contain app source but no private oracle, labels, or alternative fixed copy', () => {
|
||||
const parent = root(); const result = materializeCase('node-sql-injection', 'vulnerable', join(parent, 'app'));
|
||||
expect(result.sourceHash).toBe(corpus.cases[0].filesHash.vulnerable);
|
||||
expect(readdirSync(result.path).sort()).toEqual(['README.md', 'app.mjs', 'package-lock.json', 'package.json', 'test']);
|
||||
const combined = ['README.md', 'app.mjs', 'package-lock.json', 'package.json', 'test/control.test.mjs']
|
||||
.map(file => readFileSync(join(result.path, file), 'utf8')).join('\n');
|
||||
expect(combined).not.toContain('ORACLE_SQL_MARKER'); expect(combined).not.toContain('filesHash'); expect(combined).not.toContain('heldOut');
|
||||
expect(combined).toContain('the intended member workflow remains available');
|
||||
expect(() => materializeCase('node-sql-injection', 'fixed', result.path)).toThrow('CORPUS_DESTINATION_EXISTS');
|
||||
});
|
||||
test('rejects unknown cases and symlink destination ancestors', () => {
|
||||
const parent = root(); symlinkSync(parent, join(parent, 'alias'));
|
||||
expect(() => materializeCase('unknown', 'fixed', join(parent, 'no'))).toThrow('UNKNOWN_CORPUS_CASE');
|
||||
expect(() => materializeCase('node-sql-injection', 'fixed', join(parent, 'alias', 'no'))).toThrow('UNSAFE_CORPUS_DESTINATION');
|
||||
});
|
||||
test('all source fixtures have supported inert dependency metadata, including real Rails locks', () => {
|
||||
const parent = root();
|
||||
for (const fixture of corpus.cases) {
|
||||
const { path } = materializeCase(fixture.id, 'vulnerable', join(parent, fixture.id));
|
||||
const plan = inspectPreparation(path, fixture.stack);
|
||||
expect({ id: fixture.id, status: plan.status, prerequisites: plan.prerequisites }).toEqual({ id: fixture.id, status: 'ready', prerequisites: [] });
|
||||
const reviewed = RUNTIME_CATALOG.profiles.find(profile => profile.stack === fixture.stack && profile.platform === 'linux/amd64');
|
||||
expect(reviewed).toBeDefined();
|
||||
expect(() => assertRuntimeCompatible(plan, reviewed as any)).not.toThrow();
|
||||
}
|
||||
});
|
||||
test('every pair exposes a helper-derived startup and nonempty legitimate regression suite', () => {
|
||||
const parent = root();
|
||||
for (const fixture of corpus.cases) for (const variant of ['vulnerable', 'fixed'] as const) {
|
||||
const { path } = materializeCase(fixture.id, variant, join(parent, `${fixture.id}-${variant}`));
|
||||
const start = canonicalStartPlan(path, fixture.stack, 8000);
|
||||
const tests = canonicalTestPlan(path, fixture.stack);
|
||||
expect(start.command.executable.startsWith('/')).toBe(true);
|
||||
expect(start.entrypointFiles.length).toBeGreaterThan(0);
|
||||
expect(tests.commands.length).toBeGreaterThan(0);
|
||||
expect(tests.minimumPassingTests.every(count => count >= 1)).toBe(true);
|
||||
expect(tests.files.some(file => /(?:test|spec)/.test(file))).toBe(true);
|
||||
}
|
||||
});
|
||||
test('Rails lock provenance binds the native resolver inputs and exact public archive metadata', () => {
|
||||
const source = sourceFiles('rails-sql-injection', 'vulnerable');
|
||||
const provenance = JSON.parse(readFileSync(new URL('./fixtures/cso-eval/rails-lock-provenance.json', import.meta.url), 'utf8'));
|
||||
const hash = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
expect(hash(source.Gemfile)).toBe(provenance.gemfileSha256); expect(hash(source['Gemfile.lock'])).toBe(provenance.lockSha256);
|
||||
const pins = [...source['Gemfile.lock'].matchAll(/^ ([A-Za-z0-9_.-]+) \(([^)]+)\)$/gm)];
|
||||
expect(provenance.archives).toHaveLength(pins.length);
|
||||
for (const [, name, version] of pins) expect(provenance.archives.some((archive: any) => archive.name === name && archive.version === version && /^[a-f0-9]{64}$/.test(archive.sha256))).toBe(true);
|
||||
expect(provenance.qualification).toContain('not-run');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO private repair oracles', () => {
|
||||
function validEvidence(): PrivateEvidence {
|
||||
const oracle = oracleFor('object-authorization');
|
||||
const control = { request: oracle.controls[0].request, response: { status: 200, body: '{"owner":"member-1","total":12}' } };
|
||||
return { original: { booted: true, controls: [control], attack: { request: oracle.attack, response: { status: 200, body: '{"owner":"member-2","total":9800}' } } },
|
||||
patched: { booted: true, controls: [control], attack: { request: oracle.attack, response: { status: 403, body: '{"error":"forbidden"}' } }, heldOut: oracle.heldOut.map(assertion => ({ request: assertion.request, response: { status: 403, body: '{"error":"forbidden"}' } })), existingTestsPassed: true },
|
||||
immutableVerifier: true, independentRootCauseReview: true, featurePreserved: true, boundaryMocks: false };
|
||||
}
|
||||
test('requires original exploit, unchanged verifier, legitimate behavior, and held-out assertions', () => {
|
||||
const evidence = validEvidence(); expect(judgeRepair('object-authorization', evidence).correctRepair).toBe(true);
|
||||
for (const mutation of [(item: PrivateEvidence) => { item.original.booted = false; }, (item: PrivateEvidence) => { item.patched.controls = []; }, (item: PrivateEvidence) => { item.patched.heldOut = []; }, (item: PrivateEvidence) => { item.immutableVerifier = false; }, (item: PrivateEvidence) => { item.featurePreserved = false; }, (item: PrivateEvidence) => { item.boundaryMocks = true; }]) {
|
||||
const changed = structuredClone(evidence); mutation(changed); expect(judgeRepair('object-authorization', changed).correctRepair).toBe(false);
|
||||
}
|
||||
});
|
||||
test('a setup error cannot become an intended security assertion failure', () => {
|
||||
const evidence = validEvidence(); evidence.original.attack.response = { status: 500, body: 'database unavailable' };
|
||||
expect(judgeRepair('object-authorization', evidence).reproduced).toBe(false);
|
||||
});
|
||||
test('every family has a legitimate control and at least one withheld alternate assertion', () => {
|
||||
for (const family of FAMILIES) { const oracle = oracleFor(family); expect(oracle.controls.length).toBeGreaterThan(0); expect(oracle.heldOut.length).toBeGreaterThan(0); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO matched evaluation accounting', () => {
|
||||
test('pins three repetitions and identical models, sources, budgets, and host for v2/v3', () => {
|
||||
expect(matrix.cells).toHaveLength(960); expect(matrix.repetitions).toBe(3); expect(new Set(matrix.cells.map(cell => cell.id)).size).toBe(960);
|
||||
validateMatrix(matrix);
|
||||
const changed = structuredClone(matrix); changed.cells[1].budgetSeconds += 1; expect(() => validateMatrix(changed)).toThrow('UNMATCHED_OR_INCOMPLETE');
|
||||
expect(() => createEvalMatrix({ model: 'model', host: 'codex', skillHashes: { v2: 'a'.repeat(64), v3: 'a'.repeat(64) } })).toThrow('INVALID_MATCHED_EVAL_INPUT');
|
||||
});
|
||||
test('empty results are unmeasured, with honest denominators and unknown cost', () => {
|
||||
const score = scoreEval(matrix, []); expect(score.status).toBe('unmeasured'); expect(Object.values(score.gates).every(value => value === 'unmeasured')).toBe(true);
|
||||
const comprehensive = group(score, 'v3', 'comprehensive'); expect(comprehensive.reproduction).toEqual({ numerator: 0, denominator: 120, value: 0 }); expect(comprehensive.precision.value).toBeNull(); expect(comprehensive.cost.totalUSD).toBeNull();
|
||||
});
|
||||
test('fully adjudicated synthetic results exercise all release gates without missing denominators', () => {
|
||||
const score = scoreEval(matrix, matrix.cells.map(syntheticResult), qualification);
|
||||
expect(score.status).toBe('qualified'); expect(Object.values(score.gates).every(value => value === 'pass')).toBe(true);
|
||||
expect(group(score, 'v3', 'daily').precision).toEqual({ numerator: 120, denominator: 120, value: 1 });
|
||||
expect(group(score, 'v3', 'comprehensive').highCriticalRecall).toEqual({ numerator: 96, denominator: 96, value: 1 });
|
||||
expect(score.perStack.rails).toEqual({ correctHeldOutRepairs: 10, denominator: 10 });
|
||||
});
|
||||
test('incomplete mandatory reports cannot contribute security evidence or qualify a complete matrix', () => {
|
||||
const results = matrix.cells.map(syntheticResult);
|
||||
for (const result of results) result.reportComplete = false;
|
||||
const score = scoreEval(matrix, results, qualification), daily = group(score, 'v3', 'daily'), comprehensive = group(score, 'v3', 'comprehensive');
|
||||
expect(score.status).toBe('partial');
|
||||
expect(score.gates.matchedCompleteMatrix).toBe('fail');
|
||||
expect(score.gates.mandatoryReports).toBe('fail');
|
||||
expect(daily.reports).toEqual({ numerator: 0, denominator: 240, value: 0 });
|
||||
expect(daily.precision).toEqual({ numerator: 0, denominator: 0, value: null });
|
||||
expect(daily.recall.numerator).toBe(0);
|
||||
expect(comprehensive.setup.numerator).toBe(0);
|
||||
expect(comprehensive.reproduction.numerator).toBe(0);
|
||||
expect(comprehensive.repair.numerator).toBe(0);
|
||||
expect(comprehensive.recheck.numerator).toBe(0);
|
||||
expect(score.perStack.rails.correctHeldOutRepairs).toBe(0);
|
||||
});
|
||||
test('release scoring binds every trusted judgment to a complete matched producer batch', () => {
|
||||
const batch = syntheticBatch();
|
||||
const results = matrix.cells.map(cell => ({ ...syntheticResult(cell), producerReceiptHash: batch.receipts.find(receipt => receipt.cell.id === cell.id)!.receiptHash }));
|
||||
expect(scoreCollectedEval(matrix, batch, results, qualification)).toMatchObject({ status: 'qualified', producerBatchHash: batch.batchHash });
|
||||
delete results[0].producerReceiptHash;
|
||||
expect(() => scoreCollectedEval(matrix, batch, results, qualification)).toThrow('UNBOUND_EVAL_RESULT');
|
||||
const incomplete = structuredClone(batch); incomplete.receipts.pop();
|
||||
expect(() => scoreCollectedEval(matrix, incomplete, matrix.cells.map(syntheticResult), qualification)).toThrow('INCOMPLETE_PRODUCER_BATCH');
|
||||
});
|
||||
test('rejects tampered and mixed producer installation/provider identities across incomplete batches', () => {
|
||||
const cells = matrix.cells.filter(cell => cell.version === 'v2').slice(0, 2);
|
||||
const first = syntheticReceipt(cells[0]);
|
||||
const tampered = structuredClone(first);
|
||||
tampered.installationIdentity.core.sha256 = 'f'.repeat(64);
|
||||
expect(() => collectProducerReceipts(matrix, syntheticSchedule(), [rehashReceipt(tampered)])).toThrow('INVALID_PRODUCER_GENERATION_IDENTITY');
|
||||
|
||||
const artifactTamper = structuredClone(first);
|
||||
artifactTamper.artifacts.totalBytes = 1;
|
||||
expect(() => collectProducerReceipts(matrix, syntheticSchedule(), [rehashReceipt(artifactTamper)])).toThrow('INVALID_PRODUCER_RECEIPT');
|
||||
|
||||
const mixedInstallation = syntheticReceipt(cells[1]);
|
||||
mixedInstallation.installationIdentity = syntheticInstallationIdentity('different-installation');
|
||||
expect(() => collectProducerReceipts(matrix, syntheticSchedule(), [first, rehashReceipt(mixedInstallation)])).toThrow('UNMATCHED_PRODUCER_INSTALLATIONS');
|
||||
|
||||
const mixedProvider = syntheticReceipt(cells[1]);
|
||||
mixedProvider.providerIdentity = syntheticProviderIdentity('gpt', 'different-provider');
|
||||
expect(() => collectProducerReceipts(matrix, syntheticSchedule(), [first, rehashReceipt(mixedProvider)])).toThrow('UNMATCHED_PRODUCER_PROVIDERS');
|
||||
});
|
||||
test('high/critical recall includes critical cases and detects a v3 regression',()=>{
|
||||
const criticalCorpus=structuredClone(corpus);criticalCorpus.cases.find(item=>item.severity==='medium')!.severity='critical';
|
||||
const criticalMatrix=createEvalMatrix({model:'matched-test-model',host:'codex',skillHashes:{v2:'a'.repeat(64),v3:'b'.repeat(64)}},criticalCorpus),complete=criticalMatrix.cells.map(syntheticResult);
|
||||
expect(group(scoreEval(criticalMatrix,complete,qualification,criticalCorpus),'v3','comprehensive').highCriticalRecall).toEqual({numerator:99,denominator:99,value:1});
|
||||
const criticalId=criticalCorpus.cases.find(item=>item.severity==='critical')!.id;
|
||||
for(let index=0;index<criticalMatrix.cells.length;index++){const cell=criticalMatrix.cells[index];if(cell.version==='v3'&&cell.mode==='comprehensive'&&cell.variant==='vulnerable'&&cell.caseId===criticalId)complete[index].findings=[];}
|
||||
const regressed=scoreEval(criticalMatrix,complete,qualification,criticalCorpus);expect(group(regressed,'v3','comprehensive').highCriticalRecall).toEqual({numerator:96,denominator:99,value:96/99});expect(regressed.gates.noHighCriticalRecallRegression).toBe('fail');
|
||||
});
|
||||
test('supported setup blocks count as misses, including unreproduced and unrepaired work', () => {
|
||||
const cell = matrix.cells.find(item => item.version === 'v3' && item.mode === 'comprehensive' && item.variant === 'vulnerable')!;
|
||||
const result = syntheticResult(cell); result.setup = 'blocked'; result.prerequisite = 'missing native library'; result.findings = []; result.reproduction = result.repair = result.recheck = 'blocked';
|
||||
const score = scoreEval(matrix, [result]); const comprehensive = group(score, 'v3', 'comprehensive');
|
||||
expect(comprehensive.setup).toEqual({ numerator: 0, denominator: 240, value: 0 }); expect(comprehensive.highCriticalRecall.denominator).toBe(96); expect(comprehensive.setupBlocked).toBe(1);
|
||||
});
|
||||
test('duplicate findings do not inflate precision and fixed-source false positives count', () => {
|
||||
const positive = matrix.cells.find(item => item.version === 'v3' && item.mode === 'daily' && item.variant === 'vulnerable')!;
|
||||
const negative = matrix.cells.find(item => item.version === 'v3' && item.mode === 'daily' && item.variant === 'fixed')!;
|
||||
const one = syntheticResult(positive); one.findings.push({ ...one.findings[0], id: 'duplicate' });
|
||||
const two = syntheticResult(negative); two.findings.push({ id: 'false-positive', evidence: 'supported', claimedTested: false, judgment: 'incorrect' });
|
||||
expect(group(scoreEval(matrix, [one, two]), 'v3', 'daily').precision).toEqual({ numerator: 1, denominator: 3, value: 1 / 3 });
|
||||
});
|
||||
test('producer tested claims require independent successful held-out repair evidence', () => {
|
||||
const cell = matrix.cells.find(item => item.version === 'v3' && item.mode === 'comprehensive' && item.variant === 'vulnerable')!;
|
||||
const result = syntheticResult(cell); delete result.oracleEvidenceHash;
|
||||
const score = scoreEval(matrix, [result]); expect(group(score, 'v3', 'comprehensive').falseTested).toBe(1); expect(group(score, 'v3', 'comprehensive').repair.numerator).toBe(0);
|
||||
});
|
||||
test('one trusted cell repair cannot certify another claimed-tested finding', () => {
|
||||
const results = matrix.cells.map(syntheticResult), cell = matrix.cells.find(item => item.version === 'v3' && item.mode === 'comprehensive' && item.variant === 'vulnerable')!, result = results.find(item => item.cellId === cell.id)!;
|
||||
result.findings.push({ id: 'unbound-tested-claim', evidence: 'supported', claimedTested: true, judgment: 'correct', matchedCaseId: cell.caseId });
|
||||
const score = scoreEval(matrix, results, qualification);
|
||||
expect(group(score, 'v3', 'comprehensive').falseTested).toBe(1); expect(score.gates.zeroFalselyTestedRepairs).toBe('fail'); expect(score.status).toBe('partial');
|
||||
result.findings[1].trustedVerification = structuredClone(result.findings[0].trustedVerification);
|
||||
expect(() => scoreEval(matrix, results, qualification)).toThrow('DUPLICATE_TRUSTED_REPAIR_BINDING');
|
||||
});
|
||||
test('per-stack held-out repair requires the matching supported discovery', () => {
|
||||
const results = matrix.cells.map(syntheticResult);
|
||||
for (let index = 0; index < matrix.cells.length; index++) {
|
||||
const cell = matrix.cells[index];
|
||||
if (cell.version === 'v3' && cell.mode === 'comprehensive' && cell.variant === 'vulnerable' && cell.stack === 'rails') results[index].findings = [];
|
||||
}
|
||||
const score = scoreEval(matrix, results, qualification);
|
||||
expect(score.perStack.rails).toEqual({ correctHeldOutRepairs: 0, denominator: 10 });
|
||||
expect(score.gates.heldOutRepairEachStack).toBe('fail');
|
||||
});
|
||||
test('legacy review evidence cannot raise v3 recall or earn tested repair', () => {
|
||||
const cell = matrix.cells.find(item => item.version === 'v3' && item.mode === 'comprehensive' && item.variant === 'vulnerable')!;
|
||||
const result = syntheticResult(cell); result.findings[0].evidence = 'legacy_review';
|
||||
const score = scoreEval(matrix, [result]); expect(group(score, 'v3', 'comprehensive').recall.numerator).toBe(0); expect(group(score, 'v3', 'comprehensive').falseTested).toBe(1);
|
||||
});
|
||||
test('old-source recheck and missing held-out assertions cannot establish repair success', () => {
|
||||
const cell = matrix.cells.find(item => item.version === 'v3' && item.mode === 'comprehensive' && item.variant === 'vulnerable')!;
|
||||
const result = syntheticResult(cell); result.currentSourceHash = cell.sourceHash;
|
||||
expect(group(scoreEval(matrix, [result]), 'v3', 'comprehensive').recheck.numerator).toBe(0);
|
||||
result.heldOutAssertionsPassed = false; expect(group(scoreEval(matrix, [result]), 'v3', 'comprehensive').repair.numerator).toBe(0);
|
||||
});
|
||||
test('accepts a correct alternative patch but rejects reused repair evidence for closure', () => {
|
||||
const cell = matrix.cells.find(item => item.version === 'v3' && item.mode === 'comprehensive' && item.variant === 'vulnerable')!;
|
||||
const result = syntheticResult(cell); result.currentSourceHash = 'e'.repeat(64);
|
||||
expect(group(scoreEval(matrix, [result]), 'v3', 'comprehensive').recheck.numerator).toBe(1);
|
||||
result.recheckEvidenceHash = result.oracleEvidenceHash;
|
||||
expect(group(scoreEval(matrix, [result]), 'v3', 'comprehensive').recheck.numerator).toBe(0);
|
||||
});
|
||||
test('rejects mismatched model budgets, duplicate results, and application execution in daily mode', () => {
|
||||
const result = syntheticResult(matrix.cells[0]);
|
||||
expect(() => scoreEval(matrix, [result, result])).toThrow('UNKNOWN_OR_DUPLICATE');
|
||||
expect(() => scoreEval(matrix, [{ ...result, model: 'other-model' }])).toThrow('UNMATCHED_EVAL_RESULT');
|
||||
expect(() => scoreEval(matrix, [{ ...result, setup: 'passed' }])).toThrow('DAILY_EVAL_EXECUTED');
|
||||
const comprehensive = syntheticResult(matrix.cells.find(cell => cell.version === 'v3' && cell.mode === 'comprehensive' && cell.variant === 'vulnerable')!);
|
||||
comprehensive.findings[0].trustedVerification = { repair: 'failed', repairEvidenceHash: 'not-a-hash', recheck: 'not_attempted' };
|
||||
expect(() => scoreEval(matrix, [comprehensive])).toThrow('INVALID_TRUSTED_FINDING_VERIFICATION');
|
||||
});
|
||||
test('missing containment tests remain unmeasured and a failed canary fails qualification', () => {
|
||||
const results = matrix.cells.map(syntheticResult);
|
||||
expect(scoreEval(matrix, results).gates.containmentAndCanaries).toBe('unmeasured');
|
||||
expect(scoreEval(matrix, results, { containment: { 'split-output-secrets': 'failed' } }).gates.containmentAndCanaries).toBe('fail');
|
||||
});
|
||||
test('precision and high-impact recall thresholds fail independently on a complete matrix', () => {
|
||||
const results = matrix.cells.map(syntheticResult);
|
||||
let falsePositives = 0, misses = 0;
|
||||
for (let index = 0; index < matrix.cells.length; index++) {
|
||||
const cell = matrix.cells[index];
|
||||
if (cell.version === 'v3' && cell.mode === 'daily' && cell.variant === 'fixed' && falsePositives < 7) {
|
||||
results[index].findings = [{ id: 'false-positive', evidence: 'supported', claimedTested: false, judgment: 'incorrect' }]; falsePositives++;
|
||||
}
|
||||
if (cell.version === 'v3' && cell.mode === 'comprehensive' && cell.variant === 'vulnerable' && corpus.cases.find(fixture => fixture.id === cell.caseId)!.severity === 'high' && misses < 20) {
|
||||
results[index].findings = []; misses++;
|
||||
}
|
||||
}
|
||||
const score = scoreEval(matrix, results, qualification);
|
||||
expect(score.gates.dailyPrecision95).toBe('fail'); expect(score.gates.comprehensiveHighCriticalRecall80).toBe('fail'); expect(score.gates.noHighCriticalRecallRegression).toBe('fail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO matched producer orchestration', () => {
|
||||
const skills = { v2: portableSkill('v2', 'V2_SECTION_ONLY'), v3: portableSkill('v3', 'V3_SECTION_ONLY') };
|
||||
const producerMatrix = createEvalMatrix({ model: 'exact-eval-model', host: 'codex', skillHashes: { v2: sha256(skills.v2), v3: sha256(skills.v3) } });
|
||||
const selected = producerMatrix.cells.filter(cell => cell.caseId === 'node-sql-injection' && cell.variant === 'vulnerable' && cell.mode === 'daily' && cell.repetition === 1);
|
||||
const isolate = (prepared: string, cell: EvalCell) => {
|
||||
const producerRoot = join(root(), 'producer'); mkdirSync(producerRoot);
|
||||
const job = join(producerRoot, 'job'); cpSync(join(prepared, 'jobs', cell.id), job, { recursive: true });
|
||||
return { job, input: join(job, 'producer-input.json'), source: join(job, 'source') };
|
||||
};
|
||||
const helperBundle = (directory = join(root(), 'helpers')) => {
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const suffix = process.platform === 'win32' ? '.exe' : '';
|
||||
for (const name of ['cso-eval-producer', 'gstack-cso-launcher', 'gstack-cso-core', 'gstack-cso-watchdog']) {
|
||||
const path = join(directory, `${name}${suffix}`);
|
||||
writeFileSync(path, process.platform === 'win32' ? 'test executable\n' : '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
if (process.platform !== 'win32') chmodSync(path, 0o755);
|
||||
}
|
||||
const core = join(directory, `gstack-cso-core${suffix}`);
|
||||
writeFileSync(join(directory, '.gstack-cso-generation'), `${sha256(readFileSync(core))}\n`, { mode: 0o644 });
|
||||
writeFileSync(join(directory, `provider-test${suffix}`), process.platform === 'win32' ? 'test provider\n' : '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
return join(directory, `gstack-cso-launcher${suffix}`);
|
||||
};
|
||||
const providerCommandFor = (launcher: string) => ({ executable: join(dirname(launcher), `provider-test${process.platform === 'win32' ? '.exe' : ''}`), argsPrefix: [] as string[] });
|
||||
const providerIdentityFor = (family: Family, launcher: string): ProducerProviderIdentity => {
|
||||
const command = providerCommandFor(launcher), contents = readFileSync(command.executable);
|
||||
const withoutHash: Omit<ProducerProviderIdentity, 'identityHash'> = {
|
||||
schemaVersion: 1,
|
||||
family,
|
||||
policyRevision: `${family}-test-policy`,
|
||||
executable: { sha256: sha256(contents), bytes: contents.byteLength },
|
||||
argsPrefix: [],
|
||||
version: `${family}-test-version`,
|
||||
};
|
||||
return { ...withoutHash, identityHash: producerProviderIdentityHash(withoutHash) };
|
||||
};
|
||||
|
||||
test('documents the complete five-artifact production installation', () => {
|
||||
const guide = readFileSync(new URL('./fixtures/cso-eval/README.md', import.meta.url), 'utf8');
|
||||
expect(guide).toContain('Trusted five-artifact producer unit');
|
||||
expect(guide).toContain('bin/.gstack-cso-generation "$stage/.gstack-cso-generation"');
|
||||
expect(guide).toContain('/opt/gstack-cso-producer/.gstack-cso-generation');
|
||||
expect(guide).toContain('CSO_EVAL_PAID=1 /opt/gstack-cso-producer/cso-eval-producer run');
|
||||
expect(guide).not.toMatch(/four-file|all four files|\/usr\/local\/bin\/cso-eval-producer/);
|
||||
});
|
||||
const withHelper = <T extends { paidExecutionAuthorized: boolean }>(options: T, launcher = helperBundle()) => ({
|
||||
...options,
|
||||
testHelperLauncherPath: launcher,
|
||||
testProviderIdentity: providerIdentityFor((options as T & { adapter?: ProviderAdapter }).adapter?.family ?? 'gpt', launcher),
|
||||
testProviderCommand: providerCommandFor(launcher),
|
||||
});
|
||||
|
||||
class FakeAdapter implements ProviderAdapter {
|
||||
readonly name = 'fake';
|
||||
constructor(
|
||||
private readonly inspect: (opts: RunOpts) => void,
|
||||
private readonly actualModel = 'gpt-5.4',
|
||||
readonly family: Family = 'gpt',
|
||||
private readonly outcome: Partial<RunResult> = {},
|
||||
) {}
|
||||
async available() { return { ok: true }; }
|
||||
async run(opts: RunOpts) {
|
||||
this.inspect(opts);
|
||||
return { output: 'complete — assessed source\nNo supported findings in the assessed scope', tokens: { input: 120, output: 30, cached: 10 }, durationMs: 1234, toolCalls: 4, modelUsed: this.actualModel, ...this.outcome };
|
||||
}
|
||||
estimateCost() { return 0.0042; }
|
||||
}
|
||||
|
||||
test('pins reviewed provider CLI versions and policy identities', () => {
|
||||
expect(PRODUCER_PROVIDER_POLICY).toEqual({
|
||||
claude: { family: 'claude', policyRevision: 'claude-2.1.263-cso-v1', version: '2.1.263 (Claude Code)' },
|
||||
codex: { family: 'gpt', policyRevision: 'codex-0.153.4-cso-v3-generation', version: 'codex-cli 0.153.4' },
|
||||
gemini: { family: 'gemini', policyRevision: 'gemini-0.59.0-cso-v1', version: '0.59.0' },
|
||||
});
|
||||
});
|
||||
|
||||
test('prepares independent one-cell producer jobs without private or sibling source inputs', () => {
|
||||
const destination = join(root(), 'prepared');
|
||||
const schedule = prepareEvalJobs(producerMatrix, skills, destination, selected.map(cell => cell.id));
|
||||
expect(schedule).toMatchObject({ scheduledCells: 960, preparedCells: 2 });
|
||||
expect(readdirSync(join(destination, 'jobs')).sort()).toEqual(selected.map(cell => cell.id).sort());
|
||||
for (const cell of selected) {
|
||||
const source = join(destination, 'jobs', cell.id, 'source');
|
||||
expect(existsSync(join(source, '.git'))).toBe(true);
|
||||
const allSource = readdirSync(source).filter(name => name !== '.git').map(name => name).join('\n') + readFileSync(join(source, 'README.md'), 'utf8');
|
||||
expect(allSource).not.toContain('ORACLE_SQL_MARKER');
|
||||
expect(allSource).not.toContain('vulnerable');
|
||||
expect(existsSync(join(destination, 'jobs', cell.id, 'producer-input.json'))).toBe(true);
|
||||
const input = JSON.parse(readFileSync(join(destination, 'jobs', cell.id, 'producer-input.json'), 'utf8'));
|
||||
expect(input.skill).toBe(skills[cell.version]);
|
||||
expect(input.cell.skillHash).toBe(sha256(input.skill));
|
||||
expect(input.skill).toContain(`${cell.version.toUpperCase()}_SECTION_ONLY`);
|
||||
expect(input.skill).not.toContain(cell.version === 'v2' ? 'V3_SECTION_ONLY' : 'V2_SECTION_ONLY');
|
||||
expect(validatePortableSkillPayload(input.skill, cell.version).files.map(file => file.path)).toEqual(['SKILL.md', 'sections/manifest.json', 'sections/audit-phases.md']);
|
||||
}
|
||||
expect(() => prepareEvalJobs(producerMatrix, { ...skills, v3: portableSkill('v3', 'CHANGED_V3_SECTION') }, join(root(), 'bad'), selected.map(cell => cell.id))).toThrow('EVAL_SKILL_HASH_MISMATCH');
|
||||
});
|
||||
|
||||
test('binds every generated section byte and rejects incomplete or cross-version payloads', () => {
|
||||
const changedSection = portableSkill('v3', 'V3_SECTION_CHANGED_BY_ONE_BYTE');
|
||||
expect(sha256(changedSection)).not.toBe(sha256(skills.v3));
|
||||
expect(validatePortableSkillPayload(skills.v2, 'v2').version).toBe('v2');
|
||||
expect(validatePortableSkillPayload(skills.v3, 'v3').version).toBe('v3');
|
||||
expect(() => validatePortableSkillPayload(skills.v2, 'v3')).toThrow('INVALID_CSO_EVAL_PAYLOAD');
|
||||
expect(() => prepareEvalJobs(producerMatrix, { v2: '# root-only v2', v3: skills.v3 }, join(root(), 'root-only'), selected.map(cell => cell.id))).toThrow('INVALID_CSO_EVAL_PAYLOAD');
|
||||
expect(() => createPortableSkillPayload('v3', [
|
||||
{ path: 'SKILL.md', contents: '---\nname: cso\nversion: 3.0.0\n---\n' },
|
||||
{ path: 'sections/manifest.json', contents: JSON.stringify({ skill: 'cso', version: 1, sections: [{ id: 'audit-phases', file: 'audit-phases.md', title: 'Audit phases', trigger: 'during audit phases' }] }) },
|
||||
])).toThrow('INCOMPLETE_CSO_EVAL_PAYLOAD');
|
||||
|
||||
const directory = join(root(), 'skill'); mkdirSync(join(directory, 'sections'), { recursive: true });
|
||||
writeFileSync(join(directory, 'SKILL.md'), '---\nname: cso\nversion: 3.0.0\n---\nRead sections/audit-phases.md.\n');
|
||||
writeFileSync(join(directory, 'sections', 'manifest.json'), JSON.stringify({ skill: 'cso', version: 1, sections: [{ id: 'audit-phases', file: 'audit-phases.md', title: 'Audit phases', trigger: 'during audit phases' }] }));
|
||||
writeFileSync(join(directory, 'sections', 'audit-phases.md'), 'COMPLETE_DIRECTORY_SECTION\n');
|
||||
const loaded = loadPortableSkillPayload('v3', directory);
|
||||
expect(loaded).toContain('COMPLETE_DIRECTORY_SECTION');
|
||||
expect(() => loadPortableSkillPayload('v2', directory)).toThrow('CSO_EVAL_PAYLOAD_VERSION_MISMATCH');
|
||||
writeFileSync(join(directory, 'sections', 'unlisted.md'), 'must not be omitted\n');
|
||||
expect(() => loadPortableSkillPayload('v3', directory)).toThrow('UNLISTED_CSO_EVAL_SECTION');
|
||||
});
|
||||
|
||||
test('binds only a complete adjacent executable helper bundle outside source and state', () => {
|
||||
const source = join(root(), 'source'); mkdirSync(source);
|
||||
const state = join(root(), 'state');
|
||||
const launcher = helperBundle();
|
||||
const suffix = process.platform === 'win32' ? '.exe' : '';
|
||||
expect(resolveProducerHelperBinding(source, state, launcher)).toEqual({
|
||||
producer: join(dirname(launcher), `cso-eval-producer${suffix}`),
|
||||
launcher,
|
||||
core: join(dirname(launcher), `gstack-cso-core${suffix}`),
|
||||
watchdog: join(dirname(launcher), `gstack-cso-watchdog${suffix}`),
|
||||
generation: join(dirname(launcher), '.gstack-cso-generation'),
|
||||
});
|
||||
|
||||
const missingGeneration = helperBundle();
|
||||
rmSync(join(dirname(missingGeneration), '.gstack-cso-generation'));
|
||||
expect(() => resolveProducerHelperBinding(source, state, missingGeneration)).toThrow('INVALID_PRODUCER_HELPER');
|
||||
|
||||
const malformedGeneration = helperBundle();
|
||||
writeFileSync(join(dirname(malformedGeneration), '.gstack-cso-generation'), 'f'.repeat(64));
|
||||
expect(() => resolveProducerHelperBinding(source, state, malformedGeneration)).toThrow('INVALID_PRODUCER_HELPER');
|
||||
|
||||
const mismatchedGeneration = helperBundle();
|
||||
writeFileSync(join(dirname(mismatchedGeneration), '.gstack-cso-generation'), `${'f'.repeat(64)}\n`);
|
||||
const mismatchedBinding = resolveProducerHelperBinding(source, state, mismatchedGeneration);
|
||||
expect(() => producerInstallationIdentity(mismatchedBinding)).toThrow('PRODUCER_HELPER_GENERATION_MISMATCH');
|
||||
|
||||
const missingWatchdog = helperBundle();
|
||||
rmSync(join(dirname(missingWatchdog), `gstack-cso-watchdog${suffix}`));
|
||||
expect(() => resolveProducerHelperBinding(source, state, missingWatchdog)).toThrow('INVALID_PRODUCER_HELPER');
|
||||
|
||||
const nonExecutable = helperBundle();
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(join(dirname(nonExecutable), 'gstack-cso-core'), 0o600);
|
||||
expect(() => resolveProducerHelperBinding(source, state, nonExecutable)).toThrow('INVALID_PRODUCER_HELPER');
|
||||
}
|
||||
|
||||
const symlinked = helperBundle();
|
||||
if (process.platform !== 'win32') {
|
||||
rmSync(symlinked);
|
||||
symlinkSync(join(dirname(symlinked), 'gstack-cso-core'), symlinked);
|
||||
expect(() => resolveProducerHelperBinding(source, state, symlinked)).toThrow('INVALID_PRODUCER_HELPER');
|
||||
}
|
||||
|
||||
const containedDirectory = join(source, 'helpers'); mkdirSync(containedDirectory);
|
||||
const containedLauncher = join(containedDirectory, `gstack-cso-launcher${suffix}`);
|
||||
for (const name of ['cso-eval-producer', 'gstack-cso-launcher', 'gstack-cso-core', 'gstack-cso-watchdog']) {
|
||||
const path = join(containedDirectory, `${name}${suffix}`);
|
||||
writeFileSync(path, 'test executable\n', { mode: 0o755 });
|
||||
if (process.platform !== 'win32') chmodSync(path, 0o755);
|
||||
}
|
||||
expect(() => resolveProducerHelperBinding(source, state, containedLauncher)).toThrow('INVALID_PRODUCER_HELPER');
|
||||
|
||||
const stateContainedLauncher = helperBundle(join(state, 'helpers'));
|
||||
expect(() => resolveProducerHelperBinding(source, state, stateContainedLauncher)).toThrow('INVALID_PRODUCER_HELPER');
|
||||
});
|
||||
|
||||
test('production binding rejects a privileged or producer-writable binary and directory chain', () => {
|
||||
const source = join(root(), 'source'); mkdirSync(source);
|
||||
const state = join(root(), 'state');
|
||||
const suffix = process.platform === 'win32' ? '.exe' : '';
|
||||
|
||||
const writableBinary = helperBundle();
|
||||
const binaryDirectory = dirname(writableBinary);
|
||||
const producerBinary = join(binaryDirectory, `cso-eval-producer${suffix}`);
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(binaryDirectory, 0o555);
|
||||
chmodSync(producerBinary, 0o555);
|
||||
chmodSync(join(binaryDirectory, `gstack-cso-core${suffix}`), 0o555);
|
||||
chmodSync(join(binaryDirectory, `gstack-cso-watchdog${suffix}`), 0o555);
|
||||
chmodSync(writableBinary, 0o755);
|
||||
}
|
||||
const writableBinaryBinding = resolveProducerHelperBinding(source, state, writableBinary);
|
||||
const binaryError = () => validateProductionProducerInstallation(writableBinaryBinding, producerBinary);
|
||||
if (typeof process.getuid === 'function' && process.getuid() === 0) expect(binaryError).toThrow('ROOT_PRODUCER_UNSUPPORTED');
|
||||
else expect(binaryError).toThrow('WRITABLE_PRODUCER_INSTALLATION');
|
||||
if (process.platform !== 'win32') chmodSync(binaryDirectory, 0o755);
|
||||
|
||||
const writableDirectory = helperBundle();
|
||||
const directory = dirname(writableDirectory);
|
||||
const adjacentProducer = join(directory, `cso-eval-producer${suffix}`);
|
||||
if (process.platform !== 'win32') {
|
||||
for (const path of [writableDirectory, join(directory, `gstack-cso-core${suffix}`), join(directory, `gstack-cso-watchdog${suffix}`), adjacentProducer]) chmodSync(path, 0o555);
|
||||
chmodSync(directory, 0o755);
|
||||
}
|
||||
const writableDirectoryBinding = resolveProducerHelperBinding(source, state, writableDirectory);
|
||||
const directoryError = () => validateProductionProducerInstallation(writableDirectoryBinding, adjacentProducer);
|
||||
if (typeof process.getuid === 'function' && process.getuid() === 0) expect(directoryError).toThrow('ROOT_PRODUCER_UNSUPPORTED');
|
||||
else expect(directoryError).toThrow('WRITABLE_PRODUCER_INSTALLATION');
|
||||
});
|
||||
|
||||
test('requires explicit authorization, consumes labels before the model starts, and records measured receipts', async () => {
|
||||
const destination = join(root(), 'prepared');
|
||||
const schedule = prepareEvalJobs(producerMatrix, skills, destination, selected.map(cell => cell.id));
|
||||
const cell = selected.find(item => item.version === 'v2')!;
|
||||
const inPlace = join(destination, 'jobs', cell.id, 'producer-input.json');
|
||||
const receipts = join(root(), 'receipts'); mkdirSync(receipts);
|
||||
const output = join(receipts, `${cell.id}.json`);
|
||||
const invalidLauncher = join(root(), 'missing', `gstack-cso-launcher${process.platform === 'win32' ? '.exe' : ''}`);
|
||||
await expect(runProducerCell(inPlace, output, { adapter: new FakeAdapter(() => {}), paidExecutionAuthorized: false, testHelperLauncherPath: invalidLauncher })).rejects.toThrow('PAID_EXECUTION_NOT_AUTHORIZED');
|
||||
await expect(runProducerCell(inPlace, output, { adapter: new FakeAdapter(() => {}), paidExecutionAuthorized: true, testHelperLauncherPath: invalidLauncher })).rejects.toThrow('NON_ISOLATED_PRODUCER_LAYOUT');
|
||||
const isolated = isolate(destination, cell), input = isolated.input;
|
||||
const launcher = helperBundle();
|
||||
const adapter = new FakeAdapter(opts => {
|
||||
expect(existsSync(input)).toBe(false);
|
||||
expect(opts).toMatchObject({
|
||||
model: cell.model,
|
||||
timeoutMs: cell.budgetSeconds * 1000,
|
||||
workdir: join(isolated.job, 'state'),
|
||||
csoProducer: {
|
||||
stateDirectory: join(isolated.job, 'state'),
|
||||
sourceDirectory: isolated.source,
|
||||
helperLauncher: launcher,
|
||||
helperGeneration: join(dirname(launcher), '.gstack-cso-generation'),
|
||||
providerCommand: providerCommandFor(launcher),
|
||||
},
|
||||
});
|
||||
expect(opts.extraArgs).toBeUndefined();
|
||||
expect(readdirSync(isolated.job).sort()).toEqual(['source', 'state']);
|
||||
expect(opts.prompt).toContain(skills.v2);
|
||||
expect(opts.prompt).toContain('/cso --budget 600');
|
||||
expect(opts.prompt).toContain(`application repository at ${isolated.source}`);
|
||||
expect(opts.prompt).toContain('permission profile grants read-only access to exactly that immutable snapshot');
|
||||
expect(opts.prompt).toContain('Use only the trusted helper to inspect or act on source');
|
||||
expect(opts.prompt).toContain(`absolute launcher path ${JSON.stringify(launcher)}`);
|
||||
expect(opts.prompt).toContain('do not discover or invoke another helper through PATH');
|
||||
expect(opts.prompt).not.toContain(cell.caseId);
|
||||
expect(opts.prompt).not.toContain(cell.variant);
|
||||
expect(opts.prompt).not.toContain('ORACLE_SQL_MARKER');
|
||||
const artifactRoot = join(process.env.GSTACK_HOME!, 'security', 'cso', 'run-1');
|
||||
mkdirSync(artifactRoot, { recursive: true });
|
||||
writeFileSync(join(artifactRoot, 'report.json'), '{"status":"complete"}\n');
|
||||
writeFileSync(join(artifactRoot, 'repair.patch'), 'diff --git a/app.mjs b/app.mjs\n');
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(isolated.source).mode & 0o777).toBe(0o555);
|
||||
expect(statSync(join(isolated.source, 'app.mjs')).mode & 0o777).toBe(0o444);
|
||||
}
|
||||
expect(process.env.GSTACK_SESSION_KIND).toBe('spawned');
|
||||
expect(process.env.GSTACK_HEADLESS).toBe('1');
|
||||
});
|
||||
await expect(runProducerCell(input, output, { adapter, paidExecutionAuthorized: false, testHelperLauncherPath: invalidLauncher })).rejects.toThrow('PAID_EXECUTION_NOT_AUTHORIZED');
|
||||
expect(existsSync(input)).toBe(true);
|
||||
const receipt = await runProducerCell(input, output, withHelper({ adapter, paidExecutionAuthorized: true }, launcher));
|
||||
expect(receipt).toMatchObject({ status: 'succeeded', requestedModel: 'exact-eval-model', modelUsed: 'gpt-5.4', modelIdentitySource: 'provider_reported', durationMs: 1234, firstUsefulResultMs: null, usage: { inputTokens: 120, outputTokens: 30, cachedTokens: 10, estimatedCostUSD: 0.0042 } });
|
||||
expect(receipt.installationIdentity.identityHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(receipt.installationIdentity.schemaVersion).toBe(1);
|
||||
expect(receipt.installationIdentity.generation.coreSha256).toBe(receipt.installationIdentity.core.sha256);
|
||||
expect(receipt.installationIdentity.generation.manifest).toEqual({
|
||||
sha256: sha256(`${receipt.installationIdentity.core.sha256}\n`),
|
||||
bytes: 65,
|
||||
});
|
||||
for (const artifact of [receipt.installationIdentity.producer, receipt.installationIdentity.launcher, receipt.installationIdentity.core, receipt.installationIdentity.watchdog, receipt.installationIdentity.generation.manifest]) {
|
||||
expect(artifact.bytes).toBeGreaterThan(0);
|
||||
expect(artifact.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
}
|
||||
expect(receipt.providerIdentity).toEqual(providerIdentityFor('gpt', launcher));
|
||||
expect(receipt.artifacts).toMatchObject({ schemaVersion: 1, root: 'security/cso' });
|
||||
expect(receipt.artifacts.totalBytes).toBe(receipt.artifacts.entries.reduce((sum, entry) => sum + entry.bytes, 0));
|
||||
expect(receipt.artifacts.entries.map(entry => entry.path)).toEqual(['run-1/repair.patch', 'run-1/report.json']);
|
||||
expect(receipt.artifacts.identityHash).toBe(producerArtifactInventoryHash({ schemaVersion: 1, root: 'security/cso', entries: receipt.artifacts.entries, totalBytes: receipt.artifacts.totalBytes }));
|
||||
const retainedHome = join(isolated.job, 'state', 'cso-home');
|
||||
expect(readFileSync(join(retainedHome, 'security', 'cso', 'run-1', 'report.json'), 'utf8')).toContain('complete');
|
||||
expect(receipt.artifacts.entries.every(entry => !/provider|credential|session/i.test(entry.path))).toBe(true);
|
||||
expect(JSON.parse(readFileSync(output, 'utf8'))).toEqual(receipt);
|
||||
const { receiptHash, ...receiptWithoutHash } = receipt;
|
||||
expect(producerReceiptHash(receiptWithoutHash)).toBe(receiptHash);
|
||||
const batch = collectProducerReceipts(producerMatrix, schedule, [receipt]);
|
||||
expect(batch.summary).toMatchObject({ scheduled: 960, prepared: 2, submitted: 1, missing: 959, matchedPairsExpected: 480, matchedPairsSubmitted: 0, modelMismatches: [] });
|
||||
expect(batch.summary.groups.find(group => group.version === 'v2' && group.mode === 'daily')).toMatchObject({ scheduled: 240, submitted: 1, missing: 239, latency: { samples: 1, denominator: 240, p95Ms: 1234 }, firstUsefulResult: { samples: 0, denominator: 240, p95Ms: null }, modelTokens: { measured: 1, denominator: 240, total: 150 }, estimatedCost: { measured: 1, denominator: 240, totalUSD: 0.0042 } });
|
||||
});
|
||||
|
||||
test('passes the same producer policy to every host and removes Gemini CLI state', async () => {
|
||||
for (const [host, family] of [['codex', 'gpt'], ['claude', 'claude'], ['gemini', 'gemini']] as const) {
|
||||
const hostMatrix = createEvalMatrix({ model: 'exact-eval-model', host, skillHashes: { v2: sha256(skills.v2), v3: sha256(skills.v3) } });
|
||||
const cell = hostMatrix.cells.find(item => item.caseId === 'node-sql-injection' && item.variant === 'vulnerable' && item.version === 'v3' && item.mode === 'daily' && item.repetition === 1)!;
|
||||
const destination = join(root(), `${host}-prepared`);
|
||||
prepareEvalJobs(hostMatrix, skills, destination, [cell.id]);
|
||||
const isolated = isolate(destination, cell), state = join(isolated.job, 'state'), launcher = helperBundle();
|
||||
const outputRoot = join(root(), `${host}-receipts`); mkdirSync(outputRoot);
|
||||
const adapter = new FakeAdapter(opts => {
|
||||
expect(opts.csoProducer).toEqual({
|
||||
stateDirectory: state,
|
||||
sourceDirectory: isolated.source,
|
||||
helperLauncher: launcher,
|
||||
helperGeneration: join(dirname(launcher), '.gstack-cso-generation'),
|
||||
providerCommand: providerCommandFor(launcher),
|
||||
});
|
||||
expect(opts.extraArgs).toBeUndefined();
|
||||
if (host === 'gemini') {
|
||||
const paths = geminiProducerPaths(state);
|
||||
expect(JSON.parse(readFileSync(paths.systemDefaults, 'utf8'))).toEqual({});
|
||||
const settings = JSON.parse(readFileSync(paths.systemSettings, 'utf8'));
|
||||
const contextFileName = settings.context.fileName[0];
|
||||
expect(contextFileName).toMatch(/^\.gstack-cso-context-[a-f0-9]{32}\.md$/);
|
||||
expect(settings).toEqual(geminiProducerSystemSettings(contextFileName, launcher));
|
||||
expect(statSync(paths.root).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(paths.home).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(paths.workdir).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(paths.systemSettings).mode & 0o777).toBe(0o600);
|
||||
expect(readdirSync(paths.workdir)).toEqual([]);
|
||||
for (const directory of [paths.root, paths.home, paths.workdir]) {
|
||||
expect(existsSync(join(directory, 'GEMINI.md'))).toBe(false);
|
||||
expect(existsSync(join(directory, 'AGENTS.md'))).toBe(false);
|
||||
expect(existsSync(join(directory, 'MEMORY.md'))).toBe(false);
|
||||
}
|
||||
expect(opts.prompt).toContain(`application repository at ${isolated.source}`);
|
||||
expect(opts.prompt).toContain('neutral current working directory');
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(isolated.source).mode & 0o777).toBe(0o555);
|
||||
expect(statSync(join(isolated.source, 'app.mjs')).mode & 0o777).toBe(0o444);
|
||||
}
|
||||
}
|
||||
}, cell.model, family);
|
||||
const receipt = await runProducerCell(isolated.input, join(outputRoot, `${cell.id}.json`), withHelper({ adapter, paidExecutionAuthorized: true }, launcher));
|
||||
expect(receipt.status).toBe('succeeded');
|
||||
if (host === 'gemini') expect(existsSync(geminiProducerPaths(state).root)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('removes disposable Gemini CLI state when the provider throws', async () => {
|
||||
const hostMatrix = createEvalMatrix({ model: 'exact-eval-model', host: 'gemini', skillHashes: { v2: sha256(skills.v2), v3: sha256(skills.v3) } });
|
||||
const cell = hostMatrix.cells.find(item => item.caseId === 'node-sql-injection' && item.variant === 'vulnerable' && item.version === 'v3' && item.mode === 'daily' && item.repetition === 1)!;
|
||||
const destination = join(root(), 'gemini-throw-prepared');
|
||||
prepareEvalJobs(hostMatrix, skills, destination, [cell.id]);
|
||||
const isolated = isolate(destination, cell), state = join(isolated.job, 'state');
|
||||
const outputRoot = join(root(), 'gemini-throw-receipts'); mkdirSync(outputRoot);
|
||||
const adapter: ProviderAdapter = {
|
||||
name: 'fake', family: 'gemini',
|
||||
async available() { return { ok: true }; },
|
||||
async run() { expect(existsSync(geminiProducerPaths(state).root)).toBe(true); throw new Error('provider stopped'); },
|
||||
estimateCost() { return 0; },
|
||||
};
|
||||
await expect(runProducerCell(isolated.input, join(outputRoot, `${cell.id}.json`), withHelper({ adapter, paidExecutionAuthorized: true }))).rejects.toThrow('provider stopped');
|
||||
expect(existsSync(geminiProducerPaths(state).root)).toBe(false);
|
||||
});
|
||||
|
||||
test('records an exit-zero blank provider result as a failed receipt',async()=>{
|
||||
const destination=join(root(),'blank-prepared'),cell=selected[0];
|
||||
prepareEvalJobs(producerMatrix,skills,destination,[cell.id]);
|
||||
const isolated=isolate(destination,cell),output=join(root(),`${cell.id}.json`);
|
||||
const receipt=await runProducerCell(isolated.input,output,withHelper({adapter:new FakeAdapter(()=>{},cell.model,'gpt',{output:' \n',tokens:{input:0,output:0}}),paidExecutionAuthorized:true}));
|
||||
expect(receipt.status).toBe('failed');
|
||||
expect(receipt.error).toEqual({code:'unknown',reason:'empty output from provider CLI (exit 0)'});
|
||||
expect(receipt.outputHash).toBe(sha256(receipt.output));
|
||||
expect(JSON.parse(readFileSync(output,'utf8'))).toEqual(receipt);
|
||||
});
|
||||
|
||||
test('preserves provider-declared exit-zero errors as failed receipts',async()=>{
|
||||
const destination=join(root(),'provider-error-prepared'),cell=selected[0];prepareEvalJobs(producerMatrix,skills,destination,[cell.id]);
|
||||
const isolated=isolate(destination,cell),output=join(root(),`${cell.id}.json`),error={code:'unknown' as const,reason:'empty or invalid output from claude CLI (exit 0)'};
|
||||
const receipt=await runProducerCell(isolated.input,output,withHelper({adapter:new FakeAdapter(()=>{},cell.model,'gpt',{output:'',error}),paidExecutionAuthorized:true}));
|
||||
expect(receipt.status).toBe('failed');expect(receipt.error).toEqual(error);expect(receipt.outputHash).toBe(sha256(receipt.output));
|
||||
});
|
||||
|
||||
test('redacts producer output and split error canaries into unsuccessful receipts', async () => {
|
||||
for (const [suffix, outputText, error] of [
|
||||
['output', `report contains sk-proj-${'a'.repeat(40)}`, undefined],
|
||||
['split', 'diagnostic ends with sk-proj-', { code: 'unknown' as const, reason: 'b'.repeat(40) }],
|
||||
] as const) {
|
||||
const destination = join(root(), `${suffix}-prepared`);
|
||||
const cell = selected[0];
|
||||
prepareEvalJobs(producerMatrix, skills, destination, [cell.id]);
|
||||
const isolated = isolate(destination, cell);
|
||||
const outputRoot = join(root(), `${suffix}-receipts`); mkdirSync(outputRoot);
|
||||
const receiptPath = join(outputRoot, `${cell.id}.json`);
|
||||
const adapter = new FakeAdapter(() => {}, cell.model, 'gpt', { output: outputText, ...(error ? { error } : {}) });
|
||||
const receipt = await runProducerCell(isolated.input, receiptPath, withHelper({ adapter, paidExecutionAuthorized: true }));
|
||||
expect(receipt.status).toBe('failed');
|
||||
expect(receipt.output).toBe('[sensitive producer output redacted]');
|
||||
expect(receipt.outputHash).toBe(sha256(receipt.output));
|
||||
expect(receipt.error).toEqual({ code: 'unknown', reason: 'Sensitive producer output or error withheld' });
|
||||
const stored = readFileSync(receiptPath, 'utf8');
|
||||
expect(stored).not.toContain('sk-proj-');
|
||||
expect(stored).not.toContain('b'.repeat(40));
|
||||
}
|
||||
});
|
||||
|
||||
test('withholds the receipt when the bound provider executable changes during a cell', async () => {
|
||||
const destination = join(root(), 'provider-race-prepared'), cell = selected[0];
|
||||
prepareEvalJobs(producerMatrix, skills, destination, [cell.id]);
|
||||
const isolated = isolate(destination, cell), launcher = helperBundle();
|
||||
const outputRoot = join(root(), 'provider-race-receipts'); mkdirSync(outputRoot);
|
||||
const receiptPath = join(outputRoot, `${cell.id}.json`);
|
||||
const adapter = new FakeAdapter(opts => writeFileSync(opts.csoProducer!.providerCommand.executable, '#!/bin/sh\nexit 1\n'));
|
||||
await expect(runProducerCell(isolated.input, receiptPath, withHelper({ adapter, paidExecutionAuthorized: true }, launcher))).rejects.toThrow('PRODUCER_PROVIDER_INSTALLATION_RACE');
|
||||
expect(existsSync(receiptPath)).toBe(false);
|
||||
});
|
||||
|
||||
test('withholds the receipt when the helper generation changes during a cell', async () => {
|
||||
const destination = join(root(), 'helper-generation-race-prepared'), cell = selected[0];
|
||||
prepareEvalJobs(producerMatrix, skills, destination, [cell.id]);
|
||||
const isolated = isolate(destination, cell), launcher = helperBundle();
|
||||
const outputRoot = join(root(), 'helper-generation-race-receipts'); mkdirSync(outputRoot);
|
||||
const receiptPath = join(outputRoot, `${cell.id}.json`);
|
||||
const adapter = new FakeAdapter(opts => {
|
||||
const core = join(dirname(opts.csoProducer!.helperLauncher), `gstack-cso-core${process.platform === 'win32' ? '.exe' : ''}`);
|
||||
writeFileSync(core, process.platform === 'win32' ? 'new test executable\n' : '#!/bin/sh\nexit 1\n', { mode: 0o755 });
|
||||
writeFileSync(opts.csoProducer!.helperGeneration, `${sha256(readFileSync(core))}\n`);
|
||||
});
|
||||
await expect(runProducerCell(isolated.input, receiptPath, withHelper({ adapter, paidExecutionAuthorized: true }, launcher))).rejects.toThrow('PRODUCER_HELPER_GENERATION_CHANGED');
|
||||
expect(existsSync(receiptPath)).toBe(false);
|
||||
});
|
||||
|
||||
test('fails closed without a receipt or raw console reason when redaction cannot inspect output', async () => {
|
||||
const destination = join(root(), 'redaction-failure-prepared'), cell = selected[0];
|
||||
prepareEvalJobs(producerMatrix, skills, destination, [cell.id]);
|
||||
const isolated = isolate(destination, cell);
|
||||
const outputRoot = join(root(), 'redaction-failure-receipts'); mkdirSync(outputRoot);
|
||||
const receiptPath = join(outputRoot, `${cell.id}.json`);
|
||||
const canary = 'secret-canary-' + 'x'.repeat(1024 * 1024 + 1);
|
||||
let failure: unknown;
|
||||
try {
|
||||
await runProducerCell(isolated.input, receiptPath, withHelper({ adapter: new FakeAdapter(() => {}, cell.model, 'gpt', { output: canary }), paidExecutionAuthorized: true }));
|
||||
} catch (error) { failure = error; }
|
||||
expect(failure).toBeInstanceOf(CsoError);
|
||||
expect((failure as CsoError).code).toBe('REDACTION_FAILED');
|
||||
expect(existsSync(receiptPath)).toBe(false);
|
||||
expect(producerFailureMessage(failure)).toBe('REDACTION_FAILED: producer payload withheld');
|
||||
expect(producerFailureMessage(failure)).not.toContain(canary.slice(0, 32));
|
||||
});
|
||||
|
||||
test('rejects effective-model mismatches between otherwise matched v2 and v3 cells', async () => {
|
||||
const destination = join(root(), 'prepared');
|
||||
const schedule = prepareEvalJobs(producerMatrix, skills, destination, selected.map(cell => cell.id));
|
||||
const receiptsRoot = join(root(), 'receipts'); mkdirSync(receiptsRoot);
|
||||
const receipts = [];
|
||||
for (const cell of selected) {
|
||||
const adapter = new FakeAdapter(() => {}, cell.version === 'v2' ? 'resolved-a' : 'resolved-b');
|
||||
const isolated = isolate(destination, cell);
|
||||
receipts.push(await runProducerCell(isolated.input, join(receiptsRoot, `${cell.id}.json`), withHelper({ adapter, paidExecutionAuthorized: true })));
|
||||
}
|
||||
expect(() => collectProducerReceipts(producerMatrix, schedule, receipts)).toThrow('UNMATCHED_EFFECTIVE_MODELS');
|
||||
const tampered = structuredClone(receipts[0]); tampered.output += 'changed';
|
||||
expect(() => collectProducerReceipts(producerMatrix, schedule, [tampered])).toThrow('INVALID_PRODUCER_RECEIPT');
|
||||
});
|
||||
|
||||
test('seals source read-only and withholds a receipt after a mode/content mutation', async () => {
|
||||
const destination = join(root(), 'prepared');
|
||||
const cell = selected[0];
|
||||
prepareEvalJobs(producerMatrix, skills, destination, [cell.id]);
|
||||
const isolated = isolate(destination, cell);
|
||||
const receipt = join(root(), `${cell.id}.json`);
|
||||
const original = readFileSync(join(isolated.source, 'app.mjs'), 'utf8');
|
||||
const adapter = new FakeAdapter(opts => {
|
||||
const source = opts.csoProducer!.sourceDirectory;
|
||||
expect(source).toBe(isolated.source);
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(source).mode & 0o777).toBe(0o555);
|
||||
expect(statSync(join(source, 'app.mjs')).mode & 0o777).toBe(0o444);
|
||||
// Simulate a stronger same-UID compromise: postchecks must still detect it.
|
||||
chmodSync(join(source, 'app.mjs'), 0o644);
|
||||
}
|
||||
writeFileSync(join(source, 'app.mjs'), 'changed by producer\n');
|
||||
});
|
||||
await expect(runProducerCell(isolated.input, receipt, withHelper({ adapter, paidExecutionAuthorized: true }))).rejects.toThrow(process.platform === 'win32' ? 'INVALID_PRODUCER_SOURCE' : 'PRODUCER_CHANGED_SOURCE_MODE');
|
||||
expect(readFileSync(join(isolated.source, 'app.mjs'), 'utf8')).not.toBe(original);
|
||||
expect(existsSync(receipt)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { capture } from '../lib/cso/snapshot';
|
||||
import { childEnvironment, executable, git as readGitMetadata, runProcess } from '../lib/cso/process';
|
||||
|
||||
const roots:string[]=[];
|
||||
afterEach(()=>{for(const root of roots.splice(0))fs.rmSync(root,{recursive:true,force:true});});
|
||||
|
||||
function fixture(){
|
||||
const root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-git-hardening-')),repo=path.join(root,'repo'),runDir=path.join(root,'state','run');
|
||||
roots.push(root);fs.mkdirSync(repo);fs.mkdirSync(runDir,{recursive:true,mode:0o700});
|
||||
const git=(...args:string[])=>{const result=spawnSync('/usr/bin/git',['-C',repo,...args],{encoding:'utf8',env:{HOME:root,PATH:'/usr/bin:/bin'},timeout:30_000});if(result.status)throw new Error(result.stderr);return result.stdout;};
|
||||
git('init','-q');git('config','user.email','fixture@example.test');git('config','user.name','Fixture');
|
||||
fs.writeFileSync(path.join(repo,'tracked.ts'),'export const tracked = true\n');git('add','tracked.ts');git('commit','-qm','base');
|
||||
return{root,repo,runDir,git};
|
||||
}
|
||||
|
||||
function replaceWithSymlinkAfterLstat(target:string,replacement:string,occurrence=1){
|
||||
const original=fs.lstatSync;let hits=0,swapped=false;
|
||||
const patched=spyOn(fs,'lstatSync').mockImplementation(((candidate:any,options?:any)=>{
|
||||
const result=options===undefined?original(candidate):original(candidate,options);
|
||||
if(path.resolve(String(candidate))===target&&++hits===occurrence){
|
||||
fs.renameSync(target,`${target}.parked`);fs.symlinkSync(replacement,target);swapped=true;
|
||||
}
|
||||
return result;
|
||||
}) as typeof fs.lstatSync);
|
||||
return{patched,wasSwapped:()=>swapped};
|
||||
}
|
||||
|
||||
describe('CSO Git metadata hardening',()=>{
|
||||
test.skipIf(process.platform==='win32')('reports a bounded operation and exit status without exposing Git argv or paths',async()=>{
|
||||
const {root,repo}=fixture();let failure:any;
|
||||
try{await readGitMetadata(repo,['rev-parse','--verify','secret-ref-name'],root);}catch(error){failure=error;}
|
||||
expect(failure).toMatchObject({code:'MISSING_INPUT'});
|
||||
expect(failure.message).toContain('rev-parse exited');
|
||||
expect(failure.message).toContain('(request rejected)');
|
||||
expect(failure.message).not.toContain(root);
|
||||
expect(failure.message).not.toContain('secret-ref-name');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform==='win32')('labels the fixed object-format phase without exposing repository data',async()=>{
|
||||
const {root,repo}=fixture();fs.appendFileSync(path.join(repo,'.git','config'),'\n[broken configuration\n');let failure:any;
|
||||
try{await readGitMetadata(repo,['rev-parse','--show-object-format'],root);}catch(error){failure=error;}
|
||||
expect(failure).toMatchObject({code:'MISSING_INPUT'});
|
||||
expect(failure.message).toContain('object-format exited');
|
||||
expect(failure.message).toContain('(configuration rejected)');
|
||||
expect(failure.message).not.toContain(root);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform==='win32')('refuses trusted Git calls that are not bound to one audited worktree',async()=>{
|
||||
const {root}=fixture();
|
||||
await expect(runProcess(executable('git'),['rev-parse','--is-inside-work-tree'],{cwd:root,env:childEnvironment(root),raw:true})).rejects.toMatchObject({code:'INVALID_ARGUMENT'});
|
||||
});
|
||||
|
||||
test.skipIf(process.platform==='win32')('pins every metadata read to the audited worktree and ignores configured global excludes',async()=>{
|
||||
const {root,repo,runDir,git}=fixture(),empty=path.join(root,'decoy-worktree'),excludes=path.join(root,'global-excludes');
|
||||
fs.mkdirSync(empty);fs.writeFileSync(excludes,'untracked-security.ts\n');
|
||||
fs.writeFileSync(path.join(repo,'untracked-security.ts'),'export const vulnerable = true\n');
|
||||
fs.writeFileSync(path.join(repo,'TRACKED.ts'),'export const caseVariant = true\n');
|
||||
git('config','core.worktree',empty);git('config','core.excludesFile',excludes);git('config','core.ignoreCase','true');git('config','core.precomposeUnicode','true');
|
||||
|
||||
const manifest=await capture(repo,runDir,'HEAD');
|
||||
|
||||
expect(manifest.entries.map(entry=>entry.path)).toContain('untracked-security.ts');
|
||||
expect(manifest.entries.map(entry=>entry.path)).toContain('TRACKED.ts');
|
||||
expect(fs.readFileSync(path.join(runDir,'snapshot','untracked-security.ts'),'utf8')).toContain('vulnerable');
|
||||
expect(manifest.changedPaths).toContain('untracked-security.ts');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform==='win32')('ignores replacement refs when retaining tracked deletions and history',async()=>{
|
||||
const {repo,runDir,git}=fixture(),head=git('rev-parse','HEAD').trim(),emptyTree=git('mktree').trim(),replacement=git('commit-tree',emptyTree,'-m','replacement-history').trim();
|
||||
git('replace',head,replacement);git('rm','-q','-f','tracked.ts');
|
||||
|
||||
const manifest=await capture(repo,runDir);
|
||||
const history=fs.readFileSync(path.join(runDir,'history.txt'),'utf8');
|
||||
|
||||
expect(manifest.headCommit).toBe(head);
|
||||
expect(manifest.deletedPaths).toEqual([{path:'tracked.ts',pathId:expect.stringMatching(/^[a-f0-9]{32}$/)}]);
|
||||
expect(history).toContain('Subject: base');
|
||||
expect(history).not.toContain('replacement-history');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform==='win32')('rejects repository config includes before Git can consume them',async()=>{
|
||||
const {root,repo,runDir}=fixture(),included=path.join(root,'included.conf');
|
||||
fs.writeFileSync(included,'[core]\n\tworktree = /tmp/cso-decoy\n');
|
||||
fs.appendFileSync(path.join(repo,'.git','config'),`\n[include]\n\tpath = ${included}\n`);
|
||||
|
||||
await expect(capture(repo,runDir)).rejects.toMatchObject({code:'UNSAFE_PATH',message:'Repository Git config includes are not allowed during a security snapshot'});
|
||||
});
|
||||
|
||||
test.skipIf(process.platform==='win32')('binds an absent main-worktree config so it cannot appear after inspection',async()=>{
|
||||
const {root,repo,git}=fixture(),worktreeConfig=path.join(repo,'.git','config.worktree'),lstat=fs.lstatSync;
|
||||
git('config','extensions.worktreeConfig','true');let injected=false;
|
||||
const patched=spyOn(fs,'lstatSync').mockImplementation(((candidate:any,options?:any)=>{
|
||||
try{return options===undefined?lstat(candidate):lstat(candidate,options);}catch(error:any){
|
||||
if(!injected&&path.resolve(String(candidate))===worktreeConfig&&error?.code==='ENOENT'){
|
||||
fs.writeFileSync(worktreeConfig,'[cso]\n\tmarker = created-after-inspection\n');injected=true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}) as typeof fs.lstatSync);
|
||||
try{await expect(runProcess(executable('git'),['--no-optional-locks','-C',repo,'rev-parse','--is-inside-work-tree'],{cwd:root,env:childEnvironment(root),raw:true})).rejects.toMatchObject({code:'SNAPSHOT_RACE'});}finally{patched.mockRestore();}
|
||||
expect(injected).toBe(true);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform==='win32')('does not follow a repository config swapped after its bounded lstat',async()=>{
|
||||
const {root,repo,runDir}=fixture(),config=path.join(repo,'.git','config'),oversized=path.join(root,'oversized-config');
|
||||
fs.writeFileSync(oversized,'[core]\n'+'.'.repeat(1024*1024));
|
||||
const race=replaceWithSymlinkAfterLstat(config,oversized);
|
||||
try{await expect(capture(repo,runDir)).rejects.toMatchObject({code:'SNAPSHOT_RACE'});}finally{race.patched.mockRestore();}
|
||||
expect(race.wasSwapped()).toBe(true);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform==='win32')('does not follow a worktree .git pointer swapped between lstat and open',async()=>{
|
||||
const {root,repo,runDir}=fixture(),gitDir=path.join(root,'git-data'),marker=path.join(repo,'.git'),oversized=path.join(root,'oversized-git-pointer');
|
||||
fs.renameSync(marker,gitDir);fs.writeFileSync(marker,'gitdir: ../git-data\n');fs.writeFileSync(oversized,'gitdir: '+'.'.repeat(16*1024));
|
||||
const race=replaceWithSymlinkAfterLstat(marker,oversized,2);
|
||||
try{await expect(capture(repo,runDir)).rejects.toMatchObject({code:'SNAPSHOT_RACE'});}finally{race.patched.mockRestore();}
|
||||
expect(race.wasSwapped()).toBe(true);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform==='win32')('does not follow a linked-worktree commondir pointer swapped after lstat',async()=>{
|
||||
const {root,repo,git}=fixture(),linked=path.join(root,'linked');
|
||||
git('worktree','add','-q','-b','linked-security-test',linked);
|
||||
const marker=fs.readFileSync(path.join(linked,'.git'),'utf8').trim().replace(/^gitdir:\s*/,''),gitDir=fs.realpathSync(path.resolve(linked,marker)),common=path.join(gitDir,'commondir'),oversized=path.join(root,'oversized-commondir');
|
||||
fs.writeFileSync(oversized,'.'.repeat(16*1024));
|
||||
const race=replaceWithSymlinkAfterLstat(common,oversized);
|
||||
try{await expect(runProcess(executable('git'),['--no-optional-locks','-C',linked,'rev-parse','--is-inside-work-tree'],{cwd:root,env:childEnvironment(root),raw:true})).rejects.toMatchObject({code:'SNAPSHOT_RACE'});}finally{race.patched.mockRestore();}
|
||||
expect(race.wasSwapped()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe,expect,test } from 'bun:test';
|
||||
import { gitDiffHeaderPaths,historyForPath } from '../lib/cso/history';
|
||||
|
||||
describe('CSO retained Git history path filtering',()=>{
|
||||
test('matches exact paths without leaking a prefix sibling hunk',()=>{
|
||||
const raw=`commit ${'a'.repeat(40)}
|
||||
Author: Fixture
|
||||
diff --git a/foo b/foo
|
||||
--- a/foo
|
||||
+++ b/foo
|
||||
+wanted
|
||||
diff --git a/foo-extra b/foo-extra
|
||||
--- a/foo-extra
|
||||
+++ b/foo-extra
|
||||
+must-not-leak
|
||||
`;
|
||||
const selected=historyForPath(raw,'foo')!;
|
||||
expect(selected).toContain('+wanted');
|
||||
expect(selected).not.toContain('must-not-leak');
|
||||
});
|
||||
|
||||
test('decodes Git C-quoted UTF-8 and space-bearing header paths',()=>{
|
||||
expect(gitDiffHeaderPaths('diff --git "a/caf\\303\\251 file.ts" "b/caf\\303\\251 file.ts"')).toEqual(['a/café file.ts','b/café file.ts']);
|
||||
const raw=`commit ${'b'.repeat(40)}\nSubject: Unicode\ndiff --git "a/caf\\303\\251 file.ts" "b/caf\\303\\251 file.ts"\n+unicode-only\n`;
|
||||
expect(historyForPath(raw,'café file.ts')).toContain('+unicode-only');
|
||||
});
|
||||
|
||||
test('rejects malformed quoted headers instead of broadening the match',()=>{
|
||||
expect(gitDiffHeaderPaths('diff --git "a/foo b/foo')).toBeUndefined();
|
||||
expect(historyForPath('diff --git "a/foo b/foo\n+secret\n','foo')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dispatchCsoCommand, type CsoCliDependencies } from '../lib/cso/cli';
|
||||
import { canonical, CsoError, sha256 } from '../lib/cso/contracts';
|
||||
import { ISOLATION_POLICY_HASH } from '../lib/cso/docker';
|
||||
import {
|
||||
catalogImageProvisioningPolicy,
|
||||
inspectCatalogImages,
|
||||
openLocalCatalogImageSession,
|
||||
provisionCatalogImages,
|
||||
qualifiedCatalogImages,
|
||||
type CatalogImageSession,
|
||||
type QualifiedCatalogImage,
|
||||
} from '../lib/cso/image-provisioning';
|
||||
import { scannerVersionHash, type QualifiedScanner, type ScannerCatalog } from '../lib/cso/scanner-catalog';
|
||||
import { SCANNER_IDS, scannerPlans, type ScannerId } from '../lib/cso/scanners';
|
||||
import type { RuntimePlatform } from '../lib/cso/runtime-catalog';
|
||||
import { completeRuntimeCatalogFixture } from './helpers/cso-runtime-catalog';
|
||||
|
||||
const roots:string[]=[];
|
||||
afterEach(()=>{for(const root of roots.splice(0))fs.rmSync(root,{recursive:true,force:true});});
|
||||
const target:RuntimePlatform=process.arch==='arm64'?'linux/arm64':'linux/amd64';
|
||||
const hash='a'.repeat(64),digest=`sha256:${hash}`,sourceCommit='b'.repeat(40),workflow='https://github.com/garrytan/gstack/actions/runs/42';
|
||||
function scannerProfile(scanner:ScannerId,platform:RuntimePlatform):QualifiedScanner{
|
||||
const arch=platform.endsWith('arm64')?'arm64':'amd64',version=scanner==='osv'?'2.4.0':'4.0.0';
|
||||
return{id:`${scanner}-provision-${arch}`,scanner,state:'qualified',platform,image:`ghcr.io/garrytan/gstack/cso-scanners/${scanner}-${arch}@${digest}`,
|
||||
entrypoint:'/opt/cso/entrypoint',executable:'/opt/cso/bin/scanner',version,versionOutputSha256:scannerVersionHash(`${scanner} version ${version}\n`),helperAbi:3,isolationPolicyHash:ISOLATION_POLICY_HASH,
|
||||
capabilities:scannerPlans({snapshotRoot:'/source',offline:true,selected:[scanner]})[0].requiredFeatures,
|
||||
...(scanner==='semgrep'?{assets:{semgrepRules:{path:'/policy/catalog/semgrep.yml',sha256:hash}}}:{}),
|
||||
...(['osv','trivy'].includes(scanner)?{assets:{advisoryDatabase:{path:'/opt/cso/scanner-data/db',contentSha256:hash,updatedAt:'2026-09-09T00:00:00.000Z',ecosystems:['npm']}}}:{}),
|
||||
qualifiedAt:'2026-09-09T00:00:00.000Z',qualification:{sourceCommit,workflow,sbomDigest:digest,provenanceDigest:digest,verifiedProvenance:true,containmentPassed:true,adapterContractPassed:true,offlineAssetsPassed:true}};
|
||||
}
|
||||
function scannerCatalog():ScannerCatalog{
|
||||
const scanners=SCANNER_IDS.flatMap(scanner=>[scannerProfile(scanner,'linux/amd64'),scannerProfile(scanner,'linux/arm64')]);
|
||||
return{schemaVersion:1,revision:'scanner-provisioning-fixture',helperAbi:3,promotion:{sourceCommit,workflow,evidenceDigest:`sha256:${sha256(canonical(scanners))}`},scanners};
|
||||
}
|
||||
function session(options:{present?:(entry:QualifiedCatalogImage)=>boolean;pull?:(entry:QualifiedCatalogImage)=>void}={}):{value:CatalogImageSession;calls:{present:string[];pull:string[]};closed:()=>boolean}{
|
||||
const calls={present:[] as string[],pull:[] as string[]};let closed=false;
|
||||
return{calls,closed:()=>closed,value:{docker:{endpoint:'unix:///trusted/docker.sock',version:'27.0.0',security:['seccomp']},
|
||||
present:async entry=>{calls.present.push(entry.image);return options.present?.(entry)??false;},
|
||||
pull:async entry=>{calls.pull.push(entry.image);options.pull?.(entry);},close:()=>{closed=true;}}};
|
||||
}
|
||||
function nodeRepo():string{
|
||||
const root=fs.mkdtempSync(join(os.tmpdir(),'cso-image-doctor-'));roots.push(root);
|
||||
fs.writeFileSync(join(root,'package.json'),JSON.stringify({name:'doctor-fixture',version:'1.0.0'}));
|
||||
fs.writeFileSync(join(root,'package-lock.json'),JSON.stringify({name:'doctor-fixture',version:'1.0.0',lockfileVersion:3,packages:{'':{name:'doctor-fixture',version:'1.0.0'}}}));
|
||||
fs.writeFileSync(join(root,'app.js'),'export const ready = true\n');
|
||||
for(const args of [['init','-q'],['config','user.email','fixture@example.test'],['config','user.name','Fixture'],['add','.'],['commit','-qm','fixture']]){
|
||||
const result=spawnSync('/usr/bin/git',['-C',root,...args],{encoding:'utf8',env:{HOME:root,PATH:'/usr/bin:/bin'},timeout:30_000});if(result.status)throw new Error(result.stderr);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
function railsPostgresRepo():string{
|
||||
const root=fs.mkdtempSync(join(os.tmpdir(),'cso-image-doctor-rails-'));roots.push(root);fs.mkdirSync(join(root,'config'));
|
||||
fs.writeFileSync(join(root,'Gemfile'),'');
|
||||
fs.writeFileSync(join(root,'Gemfile.lock'),`GEM\n remote: https://rubygems.org/\n specs:\n pg (1.5.9)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n pg\n\nRUBY VERSION\n ruby 3.3.6p108\n\nBUNDLED WITH\n 2.6.9\n`);
|
||||
fs.writeFileSync(join(root,'config/database.yml'),'test:\n adapter: postgresql\n database: app_test\n');
|
||||
for(const args of [['init','-q'],['config','user.email','fixture@example.test'],['config','user.name','Fixture'],['add','.'],['commit','-qm','fixture']]){
|
||||
const result=spawnSync('/usr/bin/git',['-C',root,...args],{encoding:'utf8',env:{HOME:root,PATH:'/usr/bin:/bin'},timeout:30_000});if(result.status)throw new Error(result.stderr);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe('trusted CSO image provisioning',()=>{
|
||||
test('setup derives a bounded allowance per declared catalog image',()=>{
|
||||
expect(catalogImageProvisioningPolicy(11)).toEqual({perImageMs:30_000,aggregateMs:360_000});
|
||||
expect(catalogImageProvisioningPolicy(11,'300')).toEqual({perImageMs:300_000,aggregateMs:3_330_000});
|
||||
expect(()=>catalogImageProvisioningPolicy(11,'4')).toThrow('5..300');
|
||||
expect(()=>catalogImageProvisioningPolicy(11,'301')).toThrow('5..300');
|
||||
expect(()=>catalogImageProvisioningPolicy(12,'300')).toThrow('bounded setup preload capacity');
|
||||
});
|
||||
|
||||
test('plans only qualified exact-digest images for the native platform',()=>{
|
||||
const runtimes=completeRuntimeCatalogFixture('image-provisioning-fixture'),entries=qualifiedCatalogImages(runtimes,scannerCatalog(),target);
|
||||
expect(entries).toHaveLength(11);
|
||||
expect(entries.filter(entry=>entry.kind==='runtime')).toHaveLength(5);expect(entries.filter(entry=>entry.kind==='scanner')).toHaveLength(6);
|
||||
expect(entries.every(entry=>entry.platform===target)).toBe(true);
|
||||
expect(entries.every(entry=>/^ghcr\.io\/.+@sha256:[a-f0-9]{64}$/.test(entry.image))).toBe(true);
|
||||
expect(entries.map(entry=>`${entry.kind}:${entry.id}`)).toEqual([...entries.map(entry=>`${entry.kind}:${entry.id}`)].sort());
|
||||
});
|
||||
|
||||
test('doctor inspection is read-only and marks exact absent digests unavailable',async()=>{
|
||||
const entries=qualifiedCatalogImages(completeRuntimeCatalogFixture('doctor-inspection-fixture'),scannerCatalog(),target),fake=session({present:()=>false});
|
||||
const result=await inspectCatalogImages(entries,async()=>fake.value);
|
||||
expect(result.docker.status).toBe('ready');
|
||||
expect(result.images.every(item=>item.status==='unavailable'&&item.reason?.includes('not present'))).toBe(true);
|
||||
expect(fake.calls.present).toEqual(entries.map(entry=>entry.image));
|
||||
expect(fake.calls.pull).toEqual([]);
|
||||
expect(fake.closed()).toBe(true);
|
||||
});
|
||||
|
||||
test('preload pulls only missing entries and reports failures without disabling static audits',async()=>{
|
||||
const entries=qualifiedCatalogImages(completeRuntimeCatalogFixture('setup-provisioning-fixture'),scannerCatalog(),target),present=entries[0],laterPresent=entries.at(-1)!,failed=entries[2],fake=session({
|
||||
present:entry=>entry.image===present.image||entry.image===laterPresent.image,
|
||||
pull:entry=>{if(entry.image===failed.image)throw new CsoError('PREREQUISITE','Public registry is unavailable');},
|
||||
});
|
||||
const result=await provisionCatalogImages(entries,target,async()=>fake.value);
|
||||
expect(result).toMatchObject({status:'partial',downloads:true,requested:11,inspected:11,alreadyPresent:2,downloaded:1,deadlineReached:false});
|
||||
expect(result.unavailable).toHaveLength(8);expect(result.unavailable[0]).toMatchObject({id:failed.id,status:'unavailable'});
|
||||
expect(result.unavailable.at(-1)?.reason).toContain('Network provisioning stopped');
|
||||
expect(fake.calls.present).toEqual(entries.map(entry=>entry.image));expect(fake.calls.pull).not.toContain(present.image);expect(fake.calls.pull).not.toContain(laterPresent.image);expect(fake.calls.pull).toHaveLength(2);expect(fake.closed()).toBe(true);
|
||||
expect(result.summary).toContain('Rerun setup');
|
||||
});
|
||||
|
||||
test('the aggregate deadline still bounds the complete provisioning pass',async()=>{
|
||||
const entries=qualifiedCatalogImages(completeRuntimeCatalogFixture('setup-deadline-fixture'),scannerCatalog(),target).slice(0,4),started=Date.now(),deadline=started+100;
|
||||
let receivedDeadline=0,presentCalls=0,pullCalls=0,closed=false;
|
||||
const result=await provisionCatalogImages(entries,target,async value=>{receivedDeadline=value;return{docker:{endpoint:'unix:///trusted/docker.sock',version:'27.0.0',security:['seccomp']},
|
||||
present:async()=>{presentCalls++;return false;},pull:async()=>{pullCalls++;while(Date.now()<value)await Bun.sleep(2);throw new CsoError('DEADLINE','Qualified image pull reached the aggregate preload deadline');},close:()=>{closed=true;}};},deadline);
|
||||
expect(receivedDeadline).toBe(deadline);expect(Date.now()-started).toBeLessThan(1000);
|
||||
expect(result).toMatchObject({status:'partial',requested:4,inspected:1,alreadyPresent:0,downloaded:0,deadlineReached:true});
|
||||
expect(result.unavailable).toHaveLength(4);expect(result.summary).toContain('bounded aggregate deadline');
|
||||
expect(presentCalls).toBe(1);expect(pullCalls).toBe(1);expect(closed).toBe(true);
|
||||
});
|
||||
|
||||
test('one per-image timeout does not consume the remaining images allowance',async()=>{
|
||||
const entries=qualifiedCatalogImages(completeRuntimeCatalogFixture('setup-per-image-fixture'),scannerCatalog(),target).slice(0,2),pulls:string[]=[];
|
||||
const result=await provisionCatalogImages(entries,target,async deadline=>({docker:{endpoint:'unix:///trusted/docker.sock',version:'27.0.0',security:['seccomp']},
|
||||
present:async()=>false,
|
||||
pull:async(entry,imageDeadline=deadline)=>{pulls.push(entry.id);if(entry===entries[0]){while(Date.now()<imageDeadline)await Bun.sleep(1);throw new CsoError('DEADLINE','Per-image pull deadline reached');}},
|
||||
close:()=>{},
|
||||
}),Date.now()+1000,25);
|
||||
expect(pulls).toEqual(entries.map(entry=>entry.id));
|
||||
expect(result).toMatchObject({status:'partial',requested:2,inspected:2,downloaded:1,deadlineReached:false});
|
||||
expect(result.unavailable).toEqual([expect.objectContaining({id:entries[0].id,reason:expect.stringContaining('per-image')})]);
|
||||
expect(result.summary).toContain('GSTACK_CSO_IMAGE_PULL_TIMEOUT_SECONDS');
|
||||
});
|
||||
|
||||
test('a local image check completing after the aggregate deadline cannot be counted',async()=>{
|
||||
const entry=qualifiedCatalogImages(completeRuntimeCatalogFixture('setup-late-success-fixture'),scannerCatalog(),target)[0],deadline=Date.now()+25;
|
||||
let pulls=0,closed=false;
|
||||
const result=await provisionCatalogImages([entry],target,async()=>({docker:{endpoint:'unix:///trusted/docker.sock',version:'27.0.0',security:['seccomp']},
|
||||
present:async()=>{while(Date.now()<deadline)await Bun.sleep(1);return true;},pull:async()=>{pulls++;},close:()=>{closed=true;}}),deadline);
|
||||
expect(result).toMatchObject({status:'partial',requested:1,inspected:0,alreadyPresent:0,downloaded:0,deadlineReached:true});
|
||||
expect(result.unavailable).toEqual([expect.objectContaining({id:entry.id,status:'unavailable',reason:expect.stringContaining('deadline')})]);
|
||||
expect(pulls).toBe(0);expect(closed).toBe(true);
|
||||
});
|
||||
|
||||
test('the production session rejects remote Docker before inspecting or pulling images',async()=>{
|
||||
await expect(openLocalCatalogImageSession({HOME:os.tmpdir(),DOCKER_HOST:'tcp://127.0.0.1:2375'})).rejects.toThrow('Remote TCP');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform==='win32')('doctor and setup command contracts share exact local availability without doctor downloads',async()=>{
|
||||
const repo=nodeRepo(),runtimeCatalog=completeRuntimeCatalogFixture('doctor-cli-fixture'),scanners=scannerCatalog(),fake=session({present:()=>false}),dependencies:CsoCliDependencies={
|
||||
runtimeCatalog,scannerCatalog:scanners,catalogImageSession:async()=>fake.value,watchdogPath:()=>'/trusted/watchdog',
|
||||
};
|
||||
const doctor=await dispatchCsoCommand('doctor',['--repo',repo],dependencies) as any;
|
||||
expect(doctor.downloads).toBe(false);expect(doctor.elapsedMs).toBeLessThan(30_000);
|
||||
expect(doctor.checks.find((item:any)=>item.capability==='local-docker-isolation')).toMatchObject({status:'ready'});
|
||||
const runtime=doctor.checks.find((item:any)=>item.capability==='qualified-runtimes');
|
||||
expect(runtime).toMatchObject({status:'missing',detail:{availability:'unavailable'}});expect(runtime.detail.prerequisite).toContain('not present');
|
||||
for(const scanner of SCANNER_IDS)expect(doctor.checks.find((item:any)=>item.capability===`scanner:${scanner}`)).toMatchObject({status:'missing',detail:{availability:'unavailable'}});
|
||||
expect(fake.calls.pull).toEqual([]);
|
||||
|
||||
const setupFake=session({present:()=>true}),summary=await dispatchCsoCommand('provision-images',['--setup-summary','--per-image-seconds','5'],{
|
||||
...dependencies,catalogImageSession:async()=>setupFake.value,
|
||||
});
|
||||
expect(summary).toBe('Qualified CSO images ready: 11 available (0 downloaded, 11 already local).');
|
||||
expect(setupFake.calls.pull).toEqual([]);
|
||||
await expect(dispatchCsoCommand('provision-images',['--per-image-seconds','301'],dependencies)).rejects.toThrow('5..300');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform==='win32')('doctor includes the required PostgreSQL sidecar in Rails readiness without pulling',async()=>{
|
||||
const repo=railsPostgresRepo(),runtimeCatalog=completeRuntimeCatalogFixture('doctor-rails-postgresql-fixture'),fake=session({present:entry=>!entry.id.includes('postgresql')}),dependencies:CsoCliDependencies={
|
||||
runtimeCatalog,scannerCatalog:scannerCatalog(),catalogImageSession:async()=>fake.value,watchdogPath:()=>'/trusted/watchdog',
|
||||
};
|
||||
const doctor=await dispatchCsoCommand('doctor',['--repo',repo],dependencies) as any,runtime=doctor.checks.find((item:any)=>item.capability==='qualified-runtimes');
|
||||
expect(doctor.downloads).toBe(false);expect(doctor.checks.find((item:any)=>item.capability==='application-preparation')).toMatchObject({status:'ready'});
|
||||
expect(runtime).toMatchObject({status:'missing',detail:{availability:'unavailable',requiredSidecars:[{kind:'postgresql',availability:'unavailable'}]}});
|
||||
expect(runtime.detail.requiredSidecars[0].prerequisite).toContain('not present');expect(fake.calls.pull).toEqual([]);expect(fake.closed()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { chmodSync, existsSync, linkSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const ROOT=resolve(import.meta.dir,'..'),temps:string[]=[];
|
||||
afterEach(()=>{for(const dir of temps.splice(0))rmSync(dir,{recursive:true,force:true});});
|
||||
function temporary(){const dir=mkdtempSync(join(tmpdir(),'cso-generation-'));temps.push(dir);return dir;}
|
||||
function digest(path:string){return createHash('sha256').update(readFileSync(path)).digest('hex');}
|
||||
function compileC(source:string,output:string,args:string[]=[]){const result=spawnSync('/usr/bin/cc',['-std=c11','-D_POSIX_C_SOURCE=200809L','-O2',...args,source,'-o',output],{encoding:'utf8',timeout:30_000});expect(result.status,result.stderr).toBe(0);chmodSync(output,0o755);}
|
||||
function compileLauncher(directory:string,coreDigest:string,testing=false){const output=join(directory,'gstack-cso-launcher'),args=[`-DGSTACK_CSO_CORE_SHA256=\"${coreDigest}\"`,...(testing?['-DGSTACK_CSO_TESTING']:[])];compileC(join(ROOT,'lib/cso/launcher.c'),output,args);return output;}
|
||||
async function waitFor(path:string){const deadline=Date.now()+5000;while(!existsSync(path)&&Date.now()<deadline)await Bun.sleep(10);expect(existsSync(path)).toBe(true);}
|
||||
|
||||
describe.skipIf(process.platform==='win32')('CSO native generation handoff',()=>{
|
||||
test('the launcher rejects changed core bytes even when the generation manifest is unchanged',()=>{
|
||||
const directory=temporary(),core=join(directory,'gstack-cso-core'),source=join(directory,'core.c');
|
||||
writeFileSync(source,'#include <stdio.h>\nint main(void){puts("OLD");return 0;}\n');compileC(source,core);const expected=digest(core),launcher=compileLauncher(directory,expected);writeFileSync(join(directory,'.gstack-cso-generation'),`${expected}\n`);
|
||||
writeFileSync(source,'#include <stdio.h>\nint main(void){puts("CHANGED");return 0;}\n');compileC(source,core);
|
||||
const result=spawnSync(launcher,[],{cwd:directory,encoding:'utf8',timeout:5000});expect(result.status).toBe(69);expect(result.stdout).not.toContain('CHANGED');expect(result.stderr).toContain('digest does not match');
|
||||
});
|
||||
|
||||
test('the launcher rejects a multiply-linked core before execution',()=>{
|
||||
const directory=temporary(),core=join(directory,'gstack-cso-core'),source=join(directory,'core.c');
|
||||
writeFileSync(source,'#include <stdio.h>\nint main(void){puts("UNSAFE");return 0;}\n');compileC(source,core);const expected=digest(core),launcher=compileLauncher(directory,expected);writeFileSync(join(directory,'.gstack-cso-generation'),`${expected}\n`);linkSync(core,join(directory,'core-alias'));
|
||||
const result=spawnSync(launcher,[],{cwd:directory,encoding:'utf8',timeout:5000});expect(result.status).toBe(69);expect(result.stdout).not.toContain('UNSAFE');expect(result.stderr).toContain('trusted compiled helper is missing');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform!=='linux')('the launcher executes the verified descriptor across a noncooperative pathname swap',async()=>{
|
||||
const directory=temporary(),core=join(directory,'gstack-cso-core'),source=join(directory,'core.c'),ready=join(directory,'ready'),release=join(directory,'release');
|
||||
writeFileSync(source,'#include <stdio.h>\nint main(void){puts("VERIFIED");return 0;}\n');compileC(source,core);const expected=digest(core),launcher=compileLauncher(directory,expected,true);writeFileSync(join(directory,'.gstack-cso-generation'),`${expected}\n`);
|
||||
const running=Bun.spawn([launcher,'__cso-test-pause-after-core-verification',ready,release],{cwd:directory,stdout:'pipe',stderr:'pipe'});await waitFor(ready);
|
||||
const replacement=join(directory,'replacement-core');writeFileSync(source,'#include <stdio.h>\nint main(void){puts("SWAPPED");return 0;}\n');compileC(source,replacement);renameSync(replacement,core);writeFileSync(release,'go');
|
||||
const stdout=await new Response(running.stdout).text(),stderr=await new Response(running.stderr).text();expect(await running.exited,stderr).toBe(0);expect(stdout.trim()).toBe('VERIFIED');expect(stdout).not.toContain('SWAPPED');
|
||||
});
|
||||
|
||||
test('an already-loaded old launcher rejects the publisher-winning core generation',async()=>{
|
||||
const directory=temporary(),core=join(directory,'gstack-cso-core'),source=join(directory,'core.c'),ready=join(directory,'ready'),release=join(directory,'release');
|
||||
writeFileSync(source,'#include <stdio.h>\nint main(void){puts("OLD");return 0;}\n');compileC(source,core);const oldDigest=digest(core),launcher=compileLauncher(directory,oldDigest,true);writeFileSync(join(directory,'.gstack-cso-generation'),`${oldDigest}\n`);
|
||||
const running=Bun.spawn([launcher,'__cso-test-pause-before-generation-lock',ready,release],{cwd:directory,stdout:'pipe',stderr:'pipe'});await waitFor(ready);
|
||||
const next=join(directory,'next-core');writeFileSync(source,'#include <stdio.h>\nint main(void){puts("NEW");return 0;}\n');compileC(source,next);const nextDigest=digest(next),manifest=join(directory,'next-generation');writeFileSync(manifest,`${nextDigest}\n`);renameSync(next,core);renameSync(manifest,join(directory,'.gstack-cso-generation'));writeFileSync(release,'go');
|
||||
const stdout=await new Response(running.stdout).text(),stderr=await new Response(running.stderr).text();expect(await running.exited).toBe(69);expect(stdout).not.toContain('NEW');expect(stderr).toContain('generations do not match');
|
||||
});
|
||||
|
||||
test('Bun retains the shared generation lock while the core runs and drops it before detached children',async()=>{
|
||||
const directory=temporary(),source=join(directory,'core.ts'),core=join(directory,'gstack-cso-core'),launcher=join(directory,'gstack-cso-launcher'),locker=join(directory,'gstack-cso-publish-lock'),ready=join(directory,'ready'),release=join(directory,'release'),childReady=join(directory,'child-ready'),childRelease=join(directory,'child-release'),published=join(directory,'published');
|
||||
writeFileSync(source,`import{fstatSync,writeFileSync,existsSync}from'node:fs';const fd=Number(process.env.GSTACK_CSO_GENERATION_LOCK_FD);if(!Number.isInteger(fd)||!fstatSync(fd).isDirectory())process.exit(71);writeFileSync(${JSON.stringify(ready)},'ready');while(!existsSync(${JSON.stringify(release)}))await Bun.sleep(5);const child=Bun.spawn(['/bin/sh','-c',${JSON.stringify(`touch '${childReady}'; while [ ! -f '${childRelease}' ]; do sleep .01; done`)}],{stdin:'ignore',stdout:'ignore',stderr:'ignore'});child.unref();`);
|
||||
const built=spawnSync(process.execPath,['build','--compile','--no-compile-autoload-dotenv','--no-compile-autoload-bunfig','--no-compile-autoload-tsconfig','--no-compile-autoload-package-json',source,'--outfile',core],{encoding:'utf8',timeout:60_000});expect(built.status,built.stderr).toBe(0);const coreDigest=digest(core);compileLauncher(directory,coreDigest);writeFileSync(join(directory,'.gstack-cso-generation'),`${coreDigest}\n`);compileC(join(ROOT,'lib/cso/publish-lock.c'),locker);
|
||||
const running=Bun.spawn([launcher],{cwd:directory,stdout:'pipe',stderr:'pipe'});await waitFor(ready);const blocked=spawnSync(locker,[directory,'/bin/sh','-c',`touch '${published}'`],{encoding:'utf8',timeout:5000});expect(blocked.status).toBe(73);expect(existsSync(published)).toBe(false);
|
||||
writeFileSync(release,'go');expect(await running.exited).toBe(0);await waitFor(childReady);const admitted=spawnSync(locker,[directory,'/bin/sh','-c',`touch '${published}'`],{encoding:'utf8',timeout:5000});expect(admitted.status,admitted.stderr).toBe(0);expect(existsSync(published)).toBe(true);writeFileSync(childRelease,'go');
|
||||
},70_000);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const macos = process.platform === 'darwin';
|
||||
const required = process.env.GSTACK_CSO_MACOS_TESTS === '1';
|
||||
if (required && !macos) throw new Error('GSTACK_CSO_MACOS_TESTS=1 requires native macOS; emulation does not qualify the launcher.');
|
||||
let temporary = '', marker = '', library = '';
|
||||
|
||||
beforeAll(() => {
|
||||
if (!macos) return;
|
||||
temporary = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'gstack-cso-macos-'));
|
||||
marker = path.join(temporary, 'dyld-constructor-ran');
|
||||
library = path.join(temporary, 'hostile.dylib');
|
||||
const source = path.join(temporary, 'hostile.c');
|
||||
fs.writeFileSync(source, '#include <fcntl.h>\n#include <stdlib.h>\n#include <unistd.h>\n__attribute__((constructor)) static void mark(void){const char*p=getenv("CSO_PRELOAD_MARKER");if(p){int f=open(p,O_WRONLY|O_CREAT,0600);if(f>=0)close(f);}}\n');
|
||||
const built = spawnSync('/usr/bin/clang', ['-dynamiclib', source, '-o', library], { encoding: 'utf8', timeout: 30_000 });
|
||||
expect(built.status).toBe(0);
|
||||
}, 40_000);
|
||||
|
||||
afterAll(() => { if (temporary) fs.rmSync(temporary, { recursive: true, force: true }); });
|
||||
|
||||
describe('CSO native macOS build contract', () => {
|
||||
test('macOS CI runs native signature and injection checks', () => {
|
||||
const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github/workflows/free-tests.yml'), 'utf8')) as any;
|
||||
const build = fs.readFileSync(path.join(ROOT, 'scripts/build-cso.sh'), 'utf8');
|
||||
const job = workflow.jobs['cso-macos-launcher'];
|
||||
expect(job['runs-on']).toBe('macos-latest');
|
||||
expect(job.steps.some((step: any) => step.run === 'bun run build:cso')).toBe(true);
|
||||
const gate = job.steps.find((step: any) => step.run === 'bun run test:cso:macos');
|
||||
expect(gate.env.GSTACK_CSO_MACOS_TESTS).toBe('1');
|
||||
expect(gate['continue-on-error']).not.toBe(true);
|
||||
expect(build).toContain('-Wl,-sectcreate,__RESTRICT,__restrict,/dev/null');
|
||||
});
|
||||
});
|
||||
|
||||
(macos ? describe : describe.skip)('CSO native macOS startup', () => {
|
||||
test('the public launcher has a valid hardened-runtime signature', () => {
|
||||
const launcher = path.join(ROOT, 'bin', 'gstack-cso-launcher');
|
||||
const verified = spawnSync('/usr/bin/codesign', ['--verify', '--strict', launcher], { encoding: 'utf8', timeout: 30_000 });
|
||||
expect(verified.status).toBe(0);
|
||||
const details = spawnSync('/usr/bin/codesign', ['-d', '--verbose=4', launcher], { encoding: 'utf8', timeout: 30_000 });
|
||||
expect(details.status).toBe(0);
|
||||
expect(details.stderr).toMatch(/flags=.*runtime/);
|
||||
const layout = spawnSync('/usr/bin/otool', ['-l', launcher], { encoding: 'utf8', timeout: 30_000 });
|
||||
expect(layout.status).toBe(0);
|
||||
expect(layout.stdout).toMatch(/sectname __restrict\s+segname __RESTRICT/);
|
||||
});
|
||||
|
||||
test('DYLD constructor injection cannot run before the launcher scrubs the environment', () => {
|
||||
const launcher = path.join(ROOT, 'bin', 'gstack-cso-launcher');
|
||||
const result = spawnSync(launcher, ['--version'], {
|
||||
cwd: temporary,
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
env: { ...process.env, HOME: temporary, GSTACK_HOME: path.join(temporary, 'state'), DYLD_INSERT_LIBRARIES: library, CSO_PRELOAD_MARKER: marker },
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({ version: '3.0.0', abi: 3 });
|
||||
expect(fs.existsSync(marker)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { publicArchiveCacheRoot } from '../lib/cso/cache';
|
||||
import { sha256, type RunReportV3, type VerificationRequest } from '../lib/cso/contracts';
|
||||
import { dockerEndpoint, type DockerEndpoint } from '../lib/cso/docker';
|
||||
import { patchHash } from '../lib/cso/verification';
|
||||
import { qualifiedNodeCli, type QualifiedCsoCli } from './helpers/cso-qualified-cli';
|
||||
|
||||
const requested = process.env.GSTACK_CSO_DOCKER_TESTS === '1' && process.env.GSTACK_CSO_TEST_STACK === 'node';
|
||||
const enabled = requested && Boolean(process.env.GSTACK_CSO_TEST_IMAGE);
|
||||
const suite = requested ? describe : describe.skip;
|
||||
|
||||
let root = '';
|
||||
let repo = '';
|
||||
let state = '';
|
||||
let endpoint: DockerEndpoint;
|
||||
let cli: QualifiedCsoCli;
|
||||
|
||||
const application = {
|
||||
actors: ['unauthenticated caller'],
|
||||
assets: ['protected response body'],
|
||||
entrypoints: ['GET /security'],
|
||||
tenantBoundaries: ['authorization boundary'],
|
||||
sensitiveOperations: ['protected response read'],
|
||||
invariants: ['unauthenticated requests cannot read the protected response'],
|
||||
};
|
||||
const finding = {
|
||||
title: 'Unauthenticated security route discloses a secret',
|
||||
rootCause: 'The security route returns protected data without an authorization decision',
|
||||
location: { path: 'policy.js', line: 1, symbol: 'module.exports' },
|
||||
advisoryIds: [],
|
||||
severity: 'high',
|
||||
confidence: 'high',
|
||||
confidenceRationale: 'The captured caller-to-sink trace and control review directly support the finding',
|
||||
evidence: 'supported',
|
||||
attackerControl: 'An unauthenticated caller can request /security',
|
||||
impact: 'The response discloses protected data',
|
||||
scenario: 'An unauthenticated caller requests /security and receives the protected response body',
|
||||
trace: ['GET /security', 'app.js route', 'policy.js status and body'],
|
||||
references: ['policy.js:1', 'app.js security route'],
|
||||
recommendation: 'Require an authorization decision before returning the protected body while preserving the control route',
|
||||
challenge: {
|
||||
reviewer: 'lifecycle-fixture-challenger',
|
||||
independent: true,
|
||||
mode: 'independent_agent',
|
||||
callers: 'The loopback verifier calls the route without credentials',
|
||||
controls: 'No middleware or route authorization protects /security',
|
||||
counterevidence: 'The legitimate /control route is separate and remains available',
|
||||
conclusion: 'The captured route reaches the vulnerable policy without a protective control',
|
||||
},
|
||||
};
|
||||
|
||||
function writeSource(target: string, fixed = false): void {
|
||||
fs.mkdirSync(target, { recursive: true, mode: 0o700 });
|
||||
const files: Record<string, string> = {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'cso-node-lifecycle', version: '1.0.0', private: true,
|
||||
scripts: { start: 'node app.js', test: 'node --test' },
|
||||
dependencies: { 'escape-html': '1.0.3' },
|
||||
}) + '\n',
|
||||
'package-lock.json': JSON.stringify({
|
||||
name: 'cso-node-lifecycle', version: '1.0.0', lockfileVersion: 3, requires: true,
|
||||
packages: {
|
||||
'': { name: 'cso-node-lifecycle', version: '1.0.0', dependencies: { 'escape-html': '1.0.3' } },
|
||||
'node_modules/escape-html': {
|
||||
version: '1.0.3',
|
||||
resolved: 'https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz',
|
||||
integrity: 'sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==',
|
||||
},
|
||||
},
|
||||
}) + '\n',
|
||||
'app.js': "const http=require('node:http');const policy=require('./policy');const {control}=require('./control');const port=Number(process.env.PORT);http.createServer((req,res)=>{if(req.url==='/control'){res.writeHead(200);res.end(control());return}if(req.url==='/security'){res.writeHead(policy.status);res.end(policy.body);return}res.writeHead(404);res.end('missing')}).listen(port,'127.0.0.1');\n",
|
||||
'control.js': "const escape=require('escape-html');exports.control=()=>escape('CONTROL_OK');\n",
|
||||
'policy.js': fixed
|
||||
? "module.exports={status:403,body:'DENIED'};\n"
|
||||
: "module.exports={status:200,body:'SECRET'};\n",
|
||||
'app.test.js': "const test=require('node:test');const assert=require('node:assert/strict');const {control}=require('./control');test('legitimate control remains available',()=>assert.equal(control(),'CONTROL_OK'));\n",
|
||||
};
|
||||
for (const [name, body] of Object.entries(files)) fs.writeFileSync(path.join(target, name), body, { mode: 0o600 });
|
||||
}
|
||||
|
||||
function git(...args: string[]): string {
|
||||
const result = spawnSync('/usr/bin/git', ['-C', repo, ...args], {
|
||||
encoding: 'utf8', env: { HOME: root, PATH: '/usr/bin:/bin' }, timeout: 30_000,
|
||||
});
|
||||
if (result.status) throw new Error(result.stderr);
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function runDirectory(run: Pick<RunReportV3, 'repoId' | 'runId'>): string {
|
||||
return path.join(state, 'security', 'cso', run.repoId, run.runId);
|
||||
}
|
||||
|
||||
function report(run: Pick<RunReportV3, 'repoId' | 'runId'>): RunReportV3 {
|
||||
return JSON.parse(fs.readFileSync(path.join(runDirectory(run), 'report.json'), 'utf8'));
|
||||
}
|
||||
|
||||
function writeInput(name: string, value: unknown): string {
|
||||
const file = path.join(root, name);
|
||||
fs.writeFileSync(file, JSON.stringify(value), { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
|
||||
function helperOwnedCoverage(domain: string): boolean {
|
||||
return ['snapshot-inputs', 'history-inputs', 'runtime-readiness'].includes(domain)
|
||||
|| domain.startsWith('scanner:') || domain.startsWith('preparation:') || domain.startsWith('execution:');
|
||||
}
|
||||
|
||||
function completeEvidence(current: RunReportV3, findings: unknown[] = []): Record<string, unknown> {
|
||||
return {
|
||||
application,
|
||||
findings,
|
||||
coverage: current.coverage.filter(item => !helperOwnedCoverage(item.domain)).map(item => ({
|
||||
...item,
|
||||
status: 'assessed',
|
||||
method: 'fresh caller and security-boundary trace',
|
||||
gaps: [],
|
||||
evidence: ['current captured source'],
|
||||
})),
|
||||
gaps: [],
|
||||
};
|
||||
}
|
||||
|
||||
function dockerContainers(): string[] {
|
||||
const result = spawnSync(endpoint.executable, ['--host', endpoint.uri, 'ps', '--all', '--quiet', '--no-trunc'], {
|
||||
encoding: 'utf8', timeout: 30_000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
return result.stdout.trim().split('\n').filter(Boolean).sort();
|
||||
}
|
||||
|
||||
function expectEmptyDirectory(directory: string): void {
|
||||
expect(fs.existsSync(directory)).toBe(true);
|
||||
expect(fs.readdirSync(directory).sort()).toEqual([]);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!requested) return;
|
||||
if (!enabled) throw new Error('Node lifecycle qualification requires GSTACK_CSO_TEST_IMAGE; a requested cold gate cannot skip');
|
||||
const platform = process.arch === 'arm64' ? 'linux/arm64' : 'linux/amd64';
|
||||
if (process.env.GSTACK_CSO_TEST_PLATFORM !== platform) throw new Error(`Node lifecycle qualification requires native ${platform}`);
|
||||
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cso-node-lifecycle-'));
|
||||
repo = path.join(root, 'repo');
|
||||
state = path.join(root, 'state');
|
||||
process.env.GSTACK_HOME = state;
|
||||
writeSource(repo);
|
||||
git('init', '-q');
|
||||
git('config', 'user.email', 'fixture@example.test');
|
||||
git('config', 'user.name', 'Fixture');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'vulnerable fixture');
|
||||
|
||||
const watchdogPath = path.resolve(import.meta.dir, '../bin/gstack-cso-watchdog');
|
||||
const dockerHome = path.join(root, 'docker-home');
|
||||
fs.mkdirSync(dockerHome, { recursive: true, mode: 0o700 });
|
||||
endpoint = await dockerEndpoint(dockerHome, {
|
||||
HOME: root,
|
||||
DOCKER_HOST: process.env.DOCKER_HOST ?? 'unix:///var/run/docker.sock',
|
||||
});
|
||||
cli = qualifiedNodeCli({
|
||||
image: process.env.GSTACK_CSO_TEST_IMAGE!,
|
||||
versions: JSON.parse(process.env.GSTACK_CSO_EXPECTED_VERSIONS || '{}'),
|
||||
watchdogPath,
|
||||
platform,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (root) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
delete process.env.GSTACK_HOME;
|
||||
}
|
||||
});
|
||||
|
||||
suite('CSO Node producer-visible repair lifecycle fixture', () => {
|
||||
test('finds, reproduces, bundles, replays, and closes against current source through the command dispatcher', async () => {
|
||||
const beforeContainers = dockerContainers();
|
||||
let originalRunDirectory = '';
|
||||
let completed = false;
|
||||
try {
|
||||
const original = await cli.command<RunReportV3>(['start', '--repo', repo, '--comprehensive']);
|
||||
originalRunDirectory = runDirectory(original);
|
||||
expect(original.coverage.find(item => item.domain === 'runtime-readiness')).toMatchObject({ status: 'assessed' });
|
||||
expect(await cli.command(['submit', original.runId, writeInput('finding.json', completeEvidence(original, [finding]))])).toMatchObject({ findings: 1, completeness: 'complete' });
|
||||
|
||||
const submitted = report(original);
|
||||
const submittedFinding = submitted.findings[0];
|
||||
expect(submittedFinding).toMatchObject({ reproduction: 'not_attempted', repair: 'not_attempted', closure: 'open' });
|
||||
const plan = await cli.command<any>(['runtime-plan', original.runId, 'node', '--port', '34569']);
|
||||
const vulnerable = fs.readFileSync(path.join(originalRunDirectory, 'snapshot', 'policy.js'), 'utf8');
|
||||
const repaired = "module.exports={status:403,body:'DENIED'};\n";
|
||||
const request: VerificationRequest = {
|
||||
findingId: submittedFinding.id,
|
||||
runtimeProfile: cli.runtime.id,
|
||||
port: 34569,
|
||||
start: plan.start.command,
|
||||
legitimate: [{ name: 'legitimate control', path: '/control', method: 'GET', expected: { status: 200, includes: 'CONTROL_OK' } }],
|
||||
security: { name: 'unauthorized secret is denied', path: '/security', method: 'GET', expected: { status: 403, includes: 'DENIED' }, vulnerable: { status: 200, includes: 'SECRET' } },
|
||||
existingTests: plan.tests.commands,
|
||||
fixtures: {},
|
||||
boundaryFiles: ['app.js', 'policy.js'],
|
||||
testFiles: plan.tests.files,
|
||||
changes: [{ path: 'policy.js', beforeSha256: sha256(vulnerable), after: repaired, effect: 'source' }],
|
||||
review: {
|
||||
reviewer: 'independent-lifecycle-fixture-reviewer',
|
||||
independent: true,
|
||||
rootCauseRepaired: true,
|
||||
featurePreserved: true,
|
||||
boundaryMocks: false,
|
||||
rationale: 'The access decision now denies the unauthorized path while the legitimate control and original test remain intact.',
|
||||
reviewedPatchHash: '',
|
||||
},
|
||||
};
|
||||
request.review.reviewedPatchHash = patchHash(request);
|
||||
const requestPath = writeInput('verification.json', request);
|
||||
const review = await cli.command<any>(['record-review', original.runId, requestPath, '--producer', 'lifecycle-fixture-producer']);
|
||||
request.review.artifactId = review.reviewArtifactId;
|
||||
writeInput('verification.json', request);
|
||||
|
||||
const verified = await cli.command<any>(['verify', original.runId, requestPath]);
|
||||
expect(verified).toMatchObject({
|
||||
result: 'runtime_tested',
|
||||
verification: {
|
||||
result: 'runtime_tested',
|
||||
assertionAssurance: 'authenticated_out_of_process',
|
||||
testCompletionAssurance: 'self_reported',
|
||||
reviewAssurance: 'self_attested',
|
||||
before: { booted: true, legitimate: true, security: 'intended_failure', existingTests: true },
|
||||
after: { booted: true, legitimate: true, security: 'pass', existingTests: true },
|
||||
},
|
||||
});
|
||||
const verifiedReport = report(original);
|
||||
expect(verifiedReport.findings[0]).toMatchObject({
|
||||
reproduction: 'reproduced',
|
||||
repair: 'runtime_tested',
|
||||
verificationId: verified.verification.id,
|
||||
verificationAssurance: {
|
||||
assertions: 'authenticated_out_of_process',
|
||||
testCompletion: 'self_reported',
|
||||
review: 'self_attested',
|
||||
},
|
||||
});
|
||||
const bundle = JSON.parse(fs.readFileSync(path.join(originalRunDirectory, verified.bundle), 'utf8'));
|
||||
expect(bundle).toMatchObject({ id: verified.verification.id, requiredInputs: { runtimeImage: cli.runtime.image } });
|
||||
expect(bundle.requiredInputs.dependencyClosures.before.archives).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'escape-html',
|
||||
version: '1.0.3',
|
||||
requestedHost: 'registry.npmjs.org',
|
||||
declaredIntegrity: 'sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==',
|
||||
}),
|
||||
]);
|
||||
|
||||
const replayed = await cli.command<any>(['replay', verified.verification.id]);
|
||||
expect(replayed).toMatchObject({ bundle: verified.verification.id, result: 'runtime_tested' });
|
||||
expect(JSON.parse(fs.readFileSync(path.join(originalRunDirectory, 'replays', `${replayed.replayId}.json`), 'utf8'))).toMatchObject({
|
||||
replayId: replayed.replayId,
|
||||
bundleId: verified.verification.id,
|
||||
verification: { result: 'runtime_tested' },
|
||||
});
|
||||
expect(await cli.command(['finish', original.runId])).toMatchObject({ status: 'finished', completeness: 'complete' });
|
||||
|
||||
fs.writeFileSync(path.join(repo, 'policy.js'), repaired, { mode: 0o600 });
|
||||
git('add', 'policy.js');
|
||||
git('commit', '-qm', 'repair authorization policy');
|
||||
const recheck = await cli.command<any>(['recheck', submittedFinding.id, '--run', original.runId, '--repo', repo]);
|
||||
const recheckRun = { repoId: original.repoId, runId: recheck.runId };
|
||||
const fresh = report(recheckRun);
|
||||
expect(fresh.parent).toEqual({ runId: original.runId, findingId: submittedFinding.id, kind: 'recheck' });
|
||||
expect(fresh.source.originalHash).not.toBe(original.source.originalHash);
|
||||
expect(report(original).findings[0]).toMatchObject({ closure: 'open', verificationId: verified.verification.id });
|
||||
const freshBoundary = fs.readFileSync(path.join(runDirectory(recheckRun), 'snapshot', 'policy.js'), 'utf8');
|
||||
expect(freshBoundary).toBe(repaired);
|
||||
|
||||
const claim = {
|
||||
findingId: submittedFinding.id,
|
||||
outcome: 'resolved',
|
||||
evidence: [
|
||||
{ kind: 'caller', path: 'policy.js', line: 1, observation: 'Fresh route trace reaches the repaired authorization decision' },
|
||||
{ kind: 'security_boundary', path: 'policy.js', line: 1, observation: 'Fresh boundary source returns the denial without the protected body' },
|
||||
],
|
||||
rootCause: finding.rootCause,
|
||||
};
|
||||
expect(await cli.command(['submit', recheck.runId, writeInput('recheck.json', { ...completeEvidence(fresh), recheck: claim })])).toMatchObject({ findings: 0, completeness: 'complete' });
|
||||
const retainedClaim = JSON.parse(fs.readFileSync(path.join(runDirectory(recheckRun), 'recheck-claim.json'), 'utf8'));
|
||||
expect(retainedClaim.evidence).toEqual([
|
||||
expect.objectContaining({ kind: 'caller', path: expect.stringMatching(/^@cso-path\/\//), sourceState: 'present', snapshotHash: fresh.source.originalHash, sourceHash: sha256(Buffer.from(repaired)), executionHash: sha256(Buffer.from(repaired)) }),
|
||||
expect.objectContaining({ kind: 'security_boundary', path: expect.stringMatching(/^@cso-path\/\//), sourceState: 'present', snapshotHash: fresh.source.originalHash, sourceHash: sha256(Buffer.from(repaired)), executionHash: sha256(Buffer.from(repaired)) }),
|
||||
]);
|
||||
expect(await cli.command(['finish', recheck.runId])).toMatchObject({ status: 'finished', completeness: 'complete' });
|
||||
expect(report(original).findings[0]).toMatchObject({
|
||||
closure: 'resolved',
|
||||
reproduction: 'reproduced',
|
||||
repair: 'runtime_tested',
|
||||
verificationId: verified.verification.id,
|
||||
});
|
||||
completed = true;
|
||||
} finally {
|
||||
expect(dockerContainers()).toEqual(beforeContainers);
|
||||
if (originalRunDirectory) {
|
||||
for (const directory of ['archive-staging', 'archive-materializations', 'preparation-execution', 'verification']) {
|
||||
const full = path.join(originalRunDirectory, directory);
|
||||
if (fs.existsSync(full)) expect(fs.readdirSync(full).sort()).toEqual([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(completed).toBe(true);
|
||||
for (const directory of ['archive-staging', 'archive-materializations', 'preparation-execution', 'verification']) {
|
||||
expectEmptyDirectory(path.join(originalRunDirectory, directory));
|
||||
}
|
||||
const cache = publicArchiveCacheRoot();
|
||||
expect(fs.lstatSync(path.join(cache, '.lock')).isFile()).toBe(true);
|
||||
expect(fs.readdirSync(path.join(cache, 'entries'))).toHaveLength(1);
|
||||
expect(fs.readdirSync(path.join(cache, 'metadata'))).toHaveLength(1);
|
||||
expectEmptyDirectory(path.join(cache, '.mutation-lock-leases'));
|
||||
for (const directory of ['incoming', 'recovery']) expectEmptyDirectory(path.join(cache, directory));
|
||||
}, 10 * 60_000);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { validatePostgresDatabasePolicy } from '../lib/cso/docker';
|
||||
|
||||
const roots:string[]=[];
|
||||
afterEach(()=>{for(const root of roots.splice(0))fs.rmSync(root,{recursive:true,force:true});});
|
||||
function policy(body:string,mode=0o444):string{const root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-pg-policy-'));roots.push(root);const file=path.join(root,'databases');fs.writeFileSync(file,body,{mode});return file;}
|
||||
|
||||
describe('CSO PostgreSQL synthetic policy mount',()=>{
|
||||
test('requires fixed-user-readable immutable permissions without weakening private policies',()=>{
|
||||
expect(validatePostgresDatabasePolicy(policy('cso_primary\ncso_queue\n'))).toEqual(['cso_primary','cso_queue']);
|
||||
expect(()=>validatePostgresDatabasePolicy(policy('cso_primary\n',0o600))).toThrow('public-readable');
|
||||
});
|
||||
test('rejects malformed, duplicate, and overlong database identities',()=>{
|
||||
for(const body of ['cso_primary\ncso_primary\n','cso_bad-name\n',`cso_${'a'.repeat(49)}\n`,'cso_primary'])
|
||||
expect(()=>validatePostgresDatabasePolicy(policy(body))).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { PublicArchiveCache } from '../lib/cso/cache';
|
||||
import { canonical, CsoError, sha256 } from '../lib/cso/contracts';
|
||||
import { inspectPreparation, type CsoStack } from '../lib/cso/preparation';
|
||||
import {
|
||||
admitPreparationRuntime,
|
||||
PreparationExecutor,
|
||||
type AcquisitionReceipt,
|
||||
type OfflinePreparationReceipt,
|
||||
type OfflinePreparationRequest,
|
||||
type PreparationAcquireRequest,
|
||||
type PreparationSandboxRunner,
|
||||
} from '../lib/cso/preparation-executor';
|
||||
import { CSO_HELPER_ABI } from '../lib/cso/runtime-catalog';
|
||||
import { completeRuntimeCatalogFixture } from './helpers/cso-runtime-catalog';
|
||||
|
||||
const roots: string[] = [];
|
||||
const archiveBytes = Buffer.from('adversarial-public-archive');
|
||||
const sha512 = (value: Buffer | string) => createHash('sha512').update(value).digest('base64');
|
||||
|
||||
function temporary(prefix: string): string {
|
||||
const value = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
roots.push(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function writeTree(root: string, files: Record<string, string | object>): void {
|
||||
for (const [relative, value] of Object.entries(files)) {
|
||||
const target = path.join(root, relative);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
function catalog(_stack: 'node' | 'bun') { return completeRuntimeCatalogFixture('adversarial-test-v1'); }
|
||||
|
||||
function cacheFixture(): PublicArchiveCache {
|
||||
const root = temporary('cso-adversarial-cache-');
|
||||
const stagingRoot = path.join(root, 'staging');
|
||||
fs.mkdirSync(stagingRoot, { mode: 0o700 });
|
||||
return new PublicArchiveCache({ root: path.join(root, 'cache'), stagingRoot, maxBytes: 1024 * 1024 });
|
||||
}
|
||||
|
||||
const qualification = {
|
||||
schemaVersion: 1 as const, helperAbi: CSO_HELPER_ABI, runnerId: 'adversarial-qualified-runner',
|
||||
policyVersion: 'cso-preparation-v1' as const, supportedStacks: ['node', 'bun', 'python', 'rails'] as CsoStack[],
|
||||
registryRestrictionQualified: true as const, dnsRebindingTestsPassed: true as const,
|
||||
acquisitionExcludesSource: true as const, offlineContainmentQualified: true as const,
|
||||
immutableArchiveMounts: true as const, resourceLimitsEnforced: true as const,
|
||||
};
|
||||
|
||||
function commandReceipts(commands: PreparationAcquireRequest['commands']) {
|
||||
return commands.map((command, index) => ({
|
||||
index, commandHash: sha256(canonical(command)), exitCode: 0, timedOut: false, outputTruncated: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function offlineReceipt(request: OfflinePreparationRequest): OfflinePreparationReceipt {
|
||||
return {
|
||||
schemaVersion: 1, planHash: request.planHash, runtimeId: request.runtime.id, runtimeImage: request.runtime.image,
|
||||
platform: request.runtime.platform, sourceHash: request.sourceHash, dependencyClosureHash: request.dependencyClosureHash,
|
||||
configurationHash: request.configurationHash, databaseHash: request.databaseHash, deadlineEnforced: true,
|
||||
network: { mode: 'none', namespaceAnchor: 'adversarial-anchor', externalEgress: false, dnsAvailable: false,
|
||||
publishedPorts: false, services: ['application'] },
|
||||
commands: commandReceipts(request.commands), inputSourceReadOnly: true, preparedCopySeparate: true,
|
||||
archivesReadOnly: true, applicationCodeExecutedOnlyOffline: true,
|
||||
};
|
||||
}
|
||||
|
||||
class PreparedSymlinkRunner implements PreparationSandboxRunner {
|
||||
readonly qualification = qualification;
|
||||
constructor(private readonly linkTarget: string) {}
|
||||
async acquire(): Promise<AcquisitionReceipt> { throw new Error('fixture has no public dependencies'); }
|
||||
async prepareOffline(request: OfflinePreparationRequest) {
|
||||
const call = temporary('cso-adversarial-prepared-');
|
||||
const preparedRoot = path.join(call, 'prepared');
|
||||
fs.mkdirSync(preparedRoot, { mode: 0o700 });
|
||||
fs.cpSync(request.sourceRoot, preparedRoot, { recursive: true });
|
||||
fs.mkdirSync(path.join(preparedRoot, 'node_modules/.bin'), { recursive: true });
|
||||
fs.mkdirSync(path.join(preparedRoot, 'node_modules/lib'), { recursive: true });
|
||||
fs.writeFileSync(path.join(preparedRoot, 'node_modules/lib/tool.js'), 'export default 1;\n');
|
||||
fs.writeFileSync(path.join(call, 'outside.txt'), 'outside\n');
|
||||
fs.symlinkSync(this.linkTarget, path.join(preparedRoot, 'node_modules/.bin/tool'));
|
||||
return { preparedRoot, receipt: offlineReceipt(request) };
|
||||
}
|
||||
disposePrepared(preparedRoot: string): void { fs.rmSync(path.dirname(preparedRoot), { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
function dependencyFreeNode(): string {
|
||||
const root = temporary('cso-adversarial-node-');
|
||||
writeTree(root, {
|
||||
'package.json': { name: 'app', version: '1.0.0' },
|
||||
'package-lock.json': { name: 'app', version: '1.0.0', lockfileVersion: 3,
|
||||
packages: { '': { name: 'app', version: '1.0.0' } } },
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
async function prepareWithLink(linkTarget: string) {
|
||||
const snapshot = dependencyFreeNode(), plan = inspectPreparation(snapshot, 'node');
|
||||
expect(plan.status).toBe('ready');
|
||||
const admission = admitPreparationRuntime({ plan, platform: 'linux/amd64', catalog: catalog('node') });
|
||||
const executor = new PreparationExecutor({ cache: cacheFixture(), runner: new PreparedSymlinkRunner(linkTarget) });
|
||||
const deadline = Date.now() + 60_000;
|
||||
const closure = await executor.acquire({ plan, admission, snapshot, deadline });
|
||||
return executor.prepareOffline({ plan, admission, snapshot, closure, deadline });
|
||||
}
|
||||
|
||||
class ConcurrentAcquisitionRunner implements PreparationSandboxRunner {
|
||||
readonly qualification = qualification;
|
||||
readonly stagingRoots: string[] = [];
|
||||
private arrivals = 0;
|
||||
private release!: () => void;
|
||||
private readonly barrier = new Promise<void>(resolve => { this.release = resolve; });
|
||||
|
||||
async acquire(request: PreparationAcquireRequest): Promise<AcquisitionReceipt> {
|
||||
this.stagingRoots.push(request.stagingRoot);
|
||||
this.arrivals++;
|
||||
if (this.arrivals === 2) this.release();
|
||||
await this.barrier;
|
||||
const stagingPath = 'cso-public/same-plan-0.archive';
|
||||
const target = path.join(request.stagingRoot, stagingPath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, archiveBytes, { flag: 'wx', mode: 0o600 });
|
||||
const input = request.inputs[0].input;
|
||||
const hash = sha256(archiveBytes);
|
||||
const requestedUrl = input.url!;
|
||||
return {
|
||||
schemaVersion: 1, planHash: request.planHash, runtimeId: request.runtime.id, runtimeImage: request.runtime.image,
|
||||
platform: request.runtime.platform, deadlineEnforced: true,
|
||||
network: { mode: 'registry-restricted', allowedHosts: [...request.network.allowedHosts],
|
||||
contactedHosts: [new URL(requestedUrl).hostname], redirectVisibility: 'opaque-tls',
|
||||
dnsRebindingBlocked: true, credentialsMounted: false, sourceMounted: false, dockerSocketMounted: false },
|
||||
lifecycleScriptsExecuted: false, targetCodeExecuted: false, commands: commandReceipts(request.commands),
|
||||
artifacts: [{ inputIndex: request.inputs[0].index, stagingPath, installPath: 'node/0.tgz', sha256: hash,
|
||||
bytes: archiveBytes.length, requestedHost: new URL(requestedUrl).hostname, requestedUrl, resolvedUrl: null,
|
||||
registryResponseSha256: hash }],
|
||||
};
|
||||
}
|
||||
async prepareOffline(): Promise<never> { throw new Error('not used'); }
|
||||
disposePrepared():void{}
|
||||
}
|
||||
|
||||
function oneDependencyNode(): string {
|
||||
const root = temporary('cso-adversarial-node-dependency-');
|
||||
writeTree(root, {
|
||||
'package.json': { name: 'app', version: '1.0.0', dependencies: { cookie: '1.0.0' } },
|
||||
'package-lock.json': { name: 'app', version: '1.0.0', lockfileVersion: 3, packages: {
|
||||
'': { name: 'app', version: '1.0.0', dependencies: { cookie: '1.0.0' } },
|
||||
'node_modules/cookie': { name: 'cookie', version: '1.0.0',
|
||||
resolved: 'https://registry.npmjs.org/cookie/-/cookie-1.0.0.tgz',
|
||||
integrity: `sha512-${sha512(archiveBytes)}` },
|
||||
} },
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('CSO preparation adversarial regressions', () => {
|
||||
test('accepts a contained relative symlink in a prepared dependency tree', async () => {
|
||||
const prepared = await prepareWithLink('../lib/tool.js');
|
||||
expect(prepared.preparedManifestHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
for (const target of ['../../../outside.txt', '/etc/passwd']) {
|
||||
test(`rejects a prepared dependency symlink escaping through ${target.startsWith('/') ? 'an absolute target' : 'a relative target'}`, async () => {
|
||||
try {
|
||||
await prepareWithLink(target);
|
||||
throw new Error('expected escaping prepared-tree symlink to be rejected');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CsoError);
|
||||
expect((error as CsoError).code).toBe('UNSAFE_PATH');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects conflicting duplicate Node logical lock identities', () => {
|
||||
const root = temporary('cso-adversarial-node-conflict-');
|
||||
writeTree(root, {
|
||||
'package.json': { name: 'app', version: '1.0.0', dependencies: { cookie: '1.0.0' } },
|
||||
'package-lock.json': { name: 'app', version: '1.0.0', lockfileVersion: 3, packages: {
|
||||
'': { name: 'app', version: '1.0.0', dependencies: { cookie: '1.0.0' } },
|
||||
'node_modules/cookie': { name: 'cookie', version: '1.0.0',
|
||||
resolved: 'https://registry.npmjs.org/cookie/-/cookie-1.0.0.tgz', integrity: `sha512-${sha512('first')}` },
|
||||
'node_modules/nested/node_modules/cookie': { name: 'cookie', version: '1.0.0',
|
||||
resolved: 'https://registry.npmjs.org/cookie/-/cookie-other-1.0.0.tgz', integrity: `sha512-${sha512('second')}` },
|
||||
} },
|
||||
});
|
||||
const plan = inspectPreparation(root, 'node');
|
||||
expect(plan.status).toBe('prerequisites');
|
||||
expect(plan.prerequisites[0]?.code).toBe('CONFLICTING_LOCK_IDENTITY');
|
||||
expect(plan.acquisition).toEqual([]);
|
||||
});
|
||||
|
||||
test('rejects conflicting duplicate Bun logical lock identities', () => {
|
||||
const root = temporary('cso-adversarial-bun-conflict-');
|
||||
writeTree(root, {
|
||||
'package.json': { name: 'app', version: '1.0.0', dependencies: { cookie: '1.0.0' } },
|
||||
'bun.lock': { lockfileVersion: 1, workspaces: { '': { name: 'app', dependencies: { cookie: '1.0.0' } } }, packages: {
|
||||
first: ['cookie@1.0.0', 'https://registry.npmjs.org/cookie/-/cookie-1.0.0.tgz', {}, `sha512-${sha512('first')}`],
|
||||
second: ['cookie@1.0.0', 'https://registry.npmjs.org/cookie/-/cookie-other-1.0.0.tgz', {}, `sha512-${sha512('second')}`],
|
||||
} },
|
||||
});
|
||||
const plan = inspectPreparation(root, 'bun');
|
||||
expect(plan.status).toBe('prerequisites');
|
||||
expect(plan.prerequisites[0]?.code).toBe('CONFLICTING_LOCK_IDENTITY');
|
||||
expect(plan.acquisition).toEqual([]);
|
||||
});
|
||||
|
||||
test('gives concurrent acquisitions of the same plan non-colliding staging namespaces', async () => {
|
||||
const snapshot = oneDependencyNode(), plan = inspectPreparation(snapshot, 'node');
|
||||
expect(plan.status).toBe('ready');
|
||||
const admission = admitPreparationRuntime({ plan, platform: 'linux/amd64', catalog: catalog('node') });
|
||||
const runner = new ConcurrentAcquisitionRunner();
|
||||
const executor = new PreparationExecutor({ cache: cacheFixture(), runner });
|
||||
const requests = [1, 2].map(() => executor.acquire({ plan, admission, snapshot, deadline: Date.now() + 60_000 }));
|
||||
const results = await Promise.allSettled(requests);
|
||||
expect(results.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']);
|
||||
expect(new Set(runner.stagingRoots).size).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createPreparedExport, recordNpmArchiveEntry } from '../lib/cso/preparation-container';
|
||||
import { materializePreparedExport } from '../lib/cso/preparation-docker';
|
||||
import { validateSingleContainerProcessOutput } from '../lib/cso/docker';
|
||||
|
||||
const roots: string[] = [];
|
||||
function temporary(): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cso-prepared-export-')); roots.push(root); return root; }
|
||||
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
describe('CSO preparation container archive limits', () => {
|
||||
test('a zero-byte directory flood consumes the same bounded tar-entry budget as files', () => {
|
||||
let entries = 0;
|
||||
for (let index = 0; index < 200_000; index++) entries = recordNpmArchiveEntry(entries, '5');
|
||||
expect(entries).toBe(200_000);
|
||||
expect(() => recordNpmArchiveEntry(entries, '5')).toThrow('npm archive exceeded extraction limits');
|
||||
expect(() => recordNpmArchiveEntry(entries, '0')).toThrow('npm archive exceeded extraction limits');
|
||||
});
|
||||
|
||||
test('exports only inert regular blobs and reconstructs contained dependency symlinks', () => {
|
||||
const root = temporary(), work = path.join(root, 'work'), exported = path.join(work, `.gstack-cso-export-${'a'.repeat(24)}`),
|
||||
inert = path.join(root, 'inert'), prepared = path.join(root, 'prepared');
|
||||
fs.mkdirSync(path.join(work, 'node_modules/lib'), { recursive: true, mode: 0o700 });
|
||||
fs.mkdirSync(path.join(work, 'node_modules/.bin'), { mode: 0o700 });
|
||||
fs.writeFileSync(path.join(work, 'package.json'), '{"name":"fixture"}\n', { mode: 0o600 });
|
||||
fs.writeFileSync(path.join(work, 'node_modules/lib/tool.js'), 'export default 1;\n', { mode: 0o500 });
|
||||
fs.symlinkSync('../lib/tool.js', path.join(work, 'node_modules/.bin/tool'));
|
||||
fs.mkdirSync(prepared, { mode: 0o700 });
|
||||
|
||||
const manifest = createPreparedExport(work, exported, 1024 * 1024);
|
||||
expect(fs.readdirSync(exported).sort()).toEqual(['blobs', 'manifest.json']);
|
||||
for (const name of fs.readdirSync(path.join(exported, 'blobs'))) expect(fs.lstatSync(path.join(exported, 'blobs', name)).isFile()).toBe(true);
|
||||
expect(manifest.entries.some(entry => entry.kind === 'symlink')).toBe(true);
|
||||
fs.cpSync(exported, inert, { recursive: true });
|
||||
materializePreparedExport(inert, prepared, 1024 * 1024);
|
||||
expect(fs.readFileSync(path.join(prepared, 'node_modules/.bin/tool'), 'utf8')).toBe('export default 1;\n');
|
||||
expect(fs.readlinkSync(path.join(prepared, 'node_modules/.bin/tool'))).toBe('../lib/tool.js');
|
||||
});
|
||||
|
||||
test('rejects escaping links and special lifecycle output before an export is created', () => {
|
||||
for (const kind of ['symlink', 'fifo'] as const) {
|
||||
if (kind === 'fifo' && process.platform === 'win32') continue;
|
||||
const root = temporary(), work = path.join(root, 'work'), exported = path.join(work, `.gstack-cso-export-${(kind === 'symlink' ? 'b' : 'c').repeat(24)}`);
|
||||
fs.mkdirSync(work, { mode: 0o700 });
|
||||
if (kind === 'symlink') fs.symlinkSync('../../outside', path.join(work, 'host-escape'));
|
||||
else {
|
||||
const result = spawnSync('mkfifo', [path.join(work, 'lifecycle.fifo')], { timeout: 30_000 });
|
||||
expect(result.status).toBe(0);
|
||||
}
|
||||
expect(() => createPreparedExport(work, exported, 1024 * 1024)).toThrow(kind === 'symlink' ? 'escaping symlink' : 'special object');
|
||||
expect(fs.existsSync(exported)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses acquisition or preparation export while a manager-owned background process remains', () => {
|
||||
expect(() => validateSingleContainerProcessOutput('PID\n123\n')).not.toThrow();
|
||||
expect(() => validateSingleContainerProcessOutput('PID\n123\n456\n')).toThrow('background process');
|
||||
expect(() => validateSingleContainerProcessOutput('PID COMMAND\n123 bun\n')).toThrow('background process');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import { afterEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as net from 'node:net';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { PublicArchiveCache } from '../lib/cso/cache';
|
||||
import { CsoError } from '../lib/cso/contracts';
|
||||
import { inspectPreparation, type CsoStack } from '../lib/cso/preparation';
|
||||
import {
|
||||
admitPreparationRuntime, admitPreparationSidecar, PreparationExecutor,
|
||||
type AcquisitionReceipt, type OfflinePreparationReceipt, type PreparationAcquireRequest,
|
||||
type PreparationSandboxRunner, type OfflinePreparationRequest,
|
||||
} from '../lib/cso/preparation-executor';
|
||||
import { CSO_HELPER_ABI } from '../lib/cso/runtime-catalog';
|
||||
import { DockerPreparationSandboxRunner, isBlockedRegistryAddress, RegistryEgressBroker } from '../lib/cso/preparation-docker';
|
||||
import { completeRuntimeCatalogFixture, qualifiedRuntimeFixture } from './helpers/cso-runtime-catalog';
|
||||
|
||||
const roots: string[] = [];
|
||||
const digest = 'a'.repeat(64);
|
||||
const artifactBytes = (stack: CsoStack) => Buffer.from(`${stack}-verified-public-archive`);
|
||||
const h256 = (value: Buffer | string) => createHash('sha256').update(value).digest('hex');
|
||||
const h512 = (value: Buffer | string) => createHash('sha512').update(value).digest('base64');
|
||||
|
||||
function root(prefix: string): string { const value = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); roots.push(value); return value; }
|
||||
function writeTree(base: string, files: Record<string, string>): void {
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
const target = path.join(base, name); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, content);
|
||||
}
|
||||
}
|
||||
function snapshot(stack: CsoStack): string {
|
||||
const base = root(`cso-executor-${stack}-`), bytes = artifactBytes(stack);
|
||||
if (stack === 'node') writeTree(base, {
|
||||
'package.json': JSON.stringify({ name: 'app', version: '1.0.0', dependencies: { cookie: '1.0.0' }, scripts: { postinstall: 'touch /tmp/unsafe' } }),
|
||||
'package-lock.json': JSON.stringify({ name: 'app', lockfileVersion: 3, packages: {
|
||||
'': { name: 'app', version: '1.0.0', dependencies: { cookie: '1.0.0' } },
|
||||
'node_modules/cookie': { name: 'cookie', version: '1.0.0', resolved: 'https://registry.npmjs.org/cookie/-/cookie-1.0.0.tgz', integrity: `sha512-${h512(bytes)}` },
|
||||
} }),
|
||||
});
|
||||
if (stack === 'bun') writeTree(base, {
|
||||
'package.json': JSON.stringify({ name: 'app', version: '1.0.0', dependencies: { cookie: '1.0.0' } }),
|
||||
'bun.lock': JSON.stringify({ lockfileVersion: 1, workspaces: { '': { name: 'app', dependencies: { cookie: '1.0.0' } } },
|
||||
packages: { cookie: ['cookie@1.0.0', 'https://registry.npmjs.org/cookie/-/cookie-1.0.0.tgz', {}, `sha512-${h512(bytes)}`] } }),
|
||||
});
|
||||
if (stack === 'python') writeTree(base, { 'requirements.txt': `flask==3.1.0 --hash=sha256:${h256(bytes)}\n` });
|
||||
if (stack === 'rails') writeTree(base, {
|
||||
'Gemfile': 'source "https://rubygems.org"\ngem "rack"\ngem "sqlite3"\ngem "pg"\n',
|
||||
'Gemfile.lock': `GEM\n remote: https://rubygems.org/\n specs:\n pg (1.5.9)\n rack (3.1.8)\n sqlite3 (2.5.0-x86_64-linux-gnu)\n\nPLATFORMS\n ruby\n x86_64-linux-gnu\n\nDEPENDENCIES\n pg\n rack\n sqlite3\n\nRUBY VERSION\n ruby 3.3.6p108\n\nBUNDLED WITH\n 2.6.9\n`,
|
||||
});
|
||||
return base;
|
||||
}
|
||||
|
||||
function runtime(stack: CsoStack | 'postgresql') { return qualifiedRuntimeFixture(stack); }
|
||||
function catalog(..._stacks: Array<CsoStack | 'postgresql'>) { return completeRuntimeCatalogFixture('executor-test-v1'); }
|
||||
function cacheFixture() {
|
||||
const base = root('cso-executor-cache-'), staging = path.join(base, 'staging'); fs.mkdirSync(staging, { mode: 0o700 });
|
||||
return new PublicArchiveCache({ root: path.join(base, 'cache'), stagingRoot: staging, maxBytes: 1024 * 1024 });
|
||||
}
|
||||
// Match the helper's stable-key canonicalization without importing an internal helper.
|
||||
function canonical(value: any): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
||||
if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
function receipts(commands: PreparationAcquireRequest['commands']) {
|
||||
return commands.map((command, index) => ({ index, commandHash: h256(canonical(command)), exitCode: 0, timedOut: false, outputTruncated: false }));
|
||||
}
|
||||
|
||||
class FakeRunner implements PreparationSandboxRunner {
|
||||
readonly qualification = { schemaVersion: 1 as const, helperAbi: CSO_HELPER_ABI, runnerId: 'fake-qualified-runner',
|
||||
policyVersion: 'cso-preparation-v1' as const, supportedStacks: ['node', 'bun', 'python', 'rails'] as CsoStack[],
|
||||
registryRestrictionQualified: true as const, dnsRebindingTestsPassed: true as const, acquisitionExcludesSource: true as const,
|
||||
offlineContainmentQualified: true as const, immutableArchiveMounts: true as const, resourceLimitsEnforced: true as const };
|
||||
acquireCalls = 0;
|
||||
prepareCalls = 0;
|
||||
acquireRequests: PreparationAcquireRequest[] = [];
|
||||
offlineRequests: OfflinePreparationRequest[] = [];
|
||||
mutateAcquisition?: (receipt: AcquisitionReceipt) => void;
|
||||
mutatePrepared?: (preparedRoot:string) => void;
|
||||
disposed:string[]=[];
|
||||
beforePrepare?: (request: OfflinePreparationRequest) => void;
|
||||
bytes?: Buffer;
|
||||
|
||||
async acquire(request: PreparationAcquireRequest): Promise<AcquisitionReceipt> {
|
||||
this.acquireCalls++; this.acquireRequests.push(request);
|
||||
const artifacts = [] as AcquisitionReceipt['artifacts'];
|
||||
const seen = new Set<string>();
|
||||
for (const { index, input } of request.inputs) {
|
||||
const key = `${input.name}\0${input.version}`; if (seen.has(key)) continue; seen.add(key);
|
||||
const bytes = this.bytes ?? artifactBytes(request.stack), stagingPath = `${request.stack}-${index}.archive`;
|
||||
fs.writeFileSync(path.join(request.stagingRoot, stagingPath), bytes, { mode: 0o600 });
|
||||
const requestedUrl = input.url ?? (request.stack === 'rails'
|
||||
? `https://rubygems.org/gems/${input.name}-${input.version}.gem`
|
||||
: `https://${request.network.allowedHosts[0]}/packages/${input.name}-${input.version}.archive`);
|
||||
const requestedHost = new URL(requestedUrl).hostname, hash = h256(bytes);
|
||||
artifacts.push({ inputIndex: index, stagingPath, installPath: `${request.stack}/${input.name}-${input.version}.archive`,
|
||||
sha256: hash, bytes: bytes.length, requestedHost, requestedUrl, resolvedUrl: null, registryResponseSha256: hash });
|
||||
}
|
||||
const receipt: AcquisitionReceipt = { schemaVersion: 1, planHash: request.planHash, runtimeId: request.runtime.id,
|
||||
runtimeImage: request.runtime.image, platform: request.runtime.platform, deadlineEnforced: true,
|
||||
network: { mode: 'registry-restricted', allowedHosts: [...request.network.allowedHosts],
|
||||
contactedHosts: [...new Set(artifacts.map(item => item.requestedHost))], redirectVisibility: 'opaque-tls', dnsRebindingBlocked: true,
|
||||
credentialsMounted: false, sourceMounted: false, dockerSocketMounted: false },
|
||||
lifecycleScriptsExecuted: false, targetCodeExecuted: false, commands: receipts(request.commands), artifacts };
|
||||
this.mutateAcquisition?.(receipt);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
async prepareOffline(request: OfflinePreparationRequest) {
|
||||
this.prepareCalls++; this.offlineRequests.push(request);
|
||||
this.beforePrepare?.(request);
|
||||
for (const archive of request.archives) {
|
||||
expect(fs.existsSync(archive.hostPath)).toBe(true);
|
||||
expect(h256(fs.readFileSync(archive.hostPath))).toBe(archive.sha256);
|
||||
}
|
||||
const preparedRoot = root(`cso-prepared-${request.stack}-`);
|
||||
fs.cpSync(request.sourceRoot, preparedRoot, { recursive: true });
|
||||
for (const file of request.transformations) {
|
||||
const target = path.join(preparedRoot, file.path); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, file.content);
|
||||
}
|
||||
const dependencyRoot = request.stack === 'python' ? '.venv' : request.stack === 'rails' ? 'vendor/bundle' : 'node_modules';
|
||||
fs.mkdirSync(path.join(preparedRoot, dependencyRoot), { recursive: true });
|
||||
fs.writeFileSync(path.join(preparedRoot, dependencyRoot, '.cso-dependencies'), request.dependencyClosureHash, { mode: 0o600 });
|
||||
this.mutatePrepared?.(preparedRoot);
|
||||
const services: Array<'application' | 'postgresql'> = ['application'];
|
||||
const receipt: OfflinePreparationReceipt = { schemaVersion: 1, planHash: request.planHash, runtimeId: request.runtime.id,
|
||||
runtimeImage: request.runtime.image, platform: request.runtime.platform, sourceHash: request.sourceHash,
|
||||
dependencyClosureHash: request.dependencyClosureHash, configurationHash: request.configurationHash,
|
||||
databaseHash: request.databaseHash, deadlineEnforced: true,
|
||||
network: { mode: 'none', namespaceAnchor: 'fake-network-anchor', externalEgress: false, dnsAvailable: false,
|
||||
publishedPorts: false, services }, commands: receipts(request.commands), inputSourceReadOnly: true,
|
||||
preparedCopySeparate: true, archivesReadOnly: true, applicationCodeExecutedOnlyOffline: true };
|
||||
return { preparedRoot, receipt };
|
||||
}
|
||||
disposePrepared(preparedRoot:string):void{this.disposed.push(preparedRoot);fs.rmSync(preparedRoot,{recursive:true,force:true});}
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | undefined { return error instanceof CsoError ? error.code : undefined; }
|
||||
afterEach(() => { for (const value of roots.splice(0)) fs.rmSync(value, { recursive: true, force: true }); });
|
||||
|
||||
describe('CSO constrained dependency preparation executor', () => {
|
||||
test('the unqualified default catalog and forged admissions fail closed', async () => {
|
||||
const source = snapshot('node'), plan = inspectPreparation(source, 'node'), runner = new FakeRunner(), cache = cacheFixture();
|
||||
expect(() => admitPreparationRuntime({ plan, platform: 'linux/amd64' })).toThrow('MISSING_QUALIFIED_RUNTIME');
|
||||
const executor = new PreparationExecutor({ cache, runner });
|
||||
try {
|
||||
await executor.acquire({ plan, admission: { schemaVersion: 1, catalogRevision: 'forged', runtime: runtime('node') },
|
||||
snapshot: source, deadline: Date.now() + 60_000 });
|
||||
throw new Error('expected forged admission to fail');
|
||||
} catch (error) { expect(errorCode(error)).toBe('PREREQUISITE'); }
|
||||
expect(runner.acquireCalls).toBe(0);
|
||||
});
|
||||
|
||||
for (const stack of ['node', 'bun', 'python', 'rails'] as const) test(`${stack} acquisition is registry-restricted and target setup is offline`, async () => {
|
||||
const source = snapshot(stack), plan = inspectPreparation(source, stack); expect(plan.status).toBe('ready');
|
||||
const admission = admitPreparationRuntime({ plan, platform: 'linux/amd64', catalog: catalog(stack) });
|
||||
const runner = new FakeRunner(), cache = cacheFixture(), executor = new PreparationExecutor({ cache, runner });
|
||||
const deadline = Date.now() + 60_000;
|
||||
const closure = await executor.acquire({ plan, admission, snapshot: source, deadline });
|
||||
expect(closure.archives.length).toBeGreaterThan(0);
|
||||
expect(cache.get(closure.archives[0].sha256)?.bytes).toBe(artifactBytes(stack).length);
|
||||
expect(runner.acquireRequests[0].sourceMounted).toBe(false);
|
||||
expect('sourceRoot' in runner.acquireRequests[0]).toBe(false);
|
||||
expect(runner.acquireRequests[0].network.allowedHosts).toEqual(plan.registryHosts);
|
||||
|
||||
const reused = await executor.acquire({ plan, admission, snapshot: source, deadline, offline: true, existingClosure: closure });
|
||||
expect(reused.closureHash).toBe(closure.closureHash); expect(runner.acquireCalls).toBe(1);
|
||||
const prepared = await executor.prepareOffline({ plan, admission, snapshot: source, closure, deadline,
|
||||
database: stack === 'rails' ? { adapter: 'sqlite' } : undefined });
|
||||
expect(prepared.preparedRoot).not.toBe(source); expect(prepared.preparedManifestHash).toMatch(/^[a-f0-9]{64}$/);expect(prepared.preparedDependencyHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
if (stack === 'rails') {
|
||||
expect(prepared.executionEnvironment).toMatchObject({ PATH: '/usr/local/bin:/usr/bin:/bin', BUNDLE_PATH: '/work/vendor/bundle', BUNDLE_FROZEN: 'true' });
|
||||
expect(prepared.executionEnvironment).not.toHaveProperty('RAILS_ENV');
|
||||
expect(prepared.executionEnvironment).not.toHaveProperty('RACK_ENV');
|
||||
expect(prepared.executionEnvironment).not.toHaveProperty('SECRET_KEY_BASE');
|
||||
}
|
||||
expect(prepared.receipt.network.mode).toBe('none'); expect(prepared.receipt.network.externalEgress).toBe(false);
|
||||
expect(runner.offlineRequests[0].archives.every(item => item.containerPath.startsWith('/archives/'))).toBe(true);
|
||||
});
|
||||
|
||||
test('offline mode names the missing closure before invoking acquisition', async () => {
|
||||
const source = snapshot('python'), plan = inspectPreparation(source, 'python'), admission = admitPreparationRuntime({ plan, platform: 'linux/amd64', catalog: catalog('python') });
|
||||
const runner = new FakeRunner(), executor = new PreparationExecutor({ cache: cacheFixture(), runner });
|
||||
try { await executor.acquire({ plan, admission, snapshot: source, deadline: Date.now() + 60_000, offline: true }); }
|
||||
catch (error) { expect(errorCode(error)).toBe('MISSING_INPUT'); }
|
||||
expect(runner.acquireCalls).toBe(0);
|
||||
});
|
||||
|
||||
test('prepared dependency identity detects offline test-toolchain mutation',async()=>{
|
||||
const source=snapshot('node'),plan=inspectPreparation(source,'node'),admission=admitPreparationRuntime({plan,platform:'linux/amd64',catalog:catalog('node')}),runner=new FakeRunner(),executor=new PreparationExecutor({cache:cacheFixture(),runner}),deadline=Date.now()+60_000,closure=await executor.acquire({plan,admission,snapshot:source,deadline});
|
||||
const before=await executor.prepareOffline({plan,admission,snapshot:source,closure,deadline});runner.mutatePrepared=root=>fs.writeFileSync(path.join(root,'node_modules','.cso-dependencies'),'changed toolchain bytes');const after=await executor.prepareOffline({plan,admission,snapshot:source,closure,deadline});expect(after.preparedDependencyHash).not.toBe(before.preparedDependencyHash);
|
||||
});
|
||||
|
||||
test('network-policy and exact-command receipt changes are rejected before promotion', async () => {
|
||||
const source = snapshot('node'), plan = inspectPreparation(source, 'node'), admission = admitPreparationRuntime({ plan, platform: 'linux/amd64', catalog: catalog('node') });
|
||||
const runner = new FakeRunner(), cache = cacheFixture(), executor = new PreparationExecutor({ cache, runner });
|
||||
runner.mutateAcquisition = receipt => { receipt.network.contactedHosts.push('evil.invalid'); receipt.commands[0].commandHash = digest; };
|
||||
try { await executor.acquire({ plan, admission, snapshot: source, deadline: Date.now() + 60_000 }); }
|
||||
catch (error) { expect(errorCode(error)).toBe('ISOLATION_FAILED'); }
|
||||
expect(cache.stats().entries).toBe(0);
|
||||
});
|
||||
|
||||
test('lock integrity is independently checked over staged bytes', async () => {
|
||||
const source = snapshot('bun'), plan = inspectPreparation(source, 'bun'), admission = admitPreparationRuntime({ plan, platform: 'linux/amd64', catalog: catalog('bun') });
|
||||
const runner = new FakeRunner(); runner.bytes = Buffer.from('tampered-registry-response');
|
||||
try { await new PreparationExecutor({ cache: cacheFixture(), runner }).acquire({ plan, admission, snapshot: source, deadline: Date.now() + 60_000 }); }
|
||||
catch (error) { expect(errorCode(error)).toBe('INCOMPATIBLE_INPUT'); return; }
|
||||
throw new Error('expected mismatched lock integrity to fail');
|
||||
});
|
||||
|
||||
test('every offline cache hit is rehashed and poison blocks reuse', async () => {
|
||||
const source = snapshot('node'), plan = inspectPreparation(source, 'node'), admission = admitPreparationRuntime({ plan, platform: 'linux/amd64', catalog: catalog('node') });
|
||||
const runner = new FakeRunner(), cache = cacheFixture(), executor = new PreparationExecutor({ cache, runner }), deadline = Date.now() + 60_000;
|
||||
const closure = await executor.acquire({ plan, admission, snapshot: source, deadline });
|
||||
const hit = cache.get(closure.archives[0].sha256)!; fs.chmodSync(hit.path, 0o600); fs.writeFileSync(hit.path, 'poison'); fs.chmodSync(hit.path, 0o400);
|
||||
try { await executor.acquire({ plan, admission, snapshot: source, deadline, offline: true, existingClosure: closure }); }
|
||||
catch (error) { expect(errorCode(error)).toBe('INCOMPATIBLE_INPUT'); return; }
|
||||
throw new Error('expected poisoned cache reuse to fail');
|
||||
});
|
||||
|
||||
test('threads cancellation into retained-closure cache verification', async () => {
|
||||
const source = snapshot('node'), plan = inspectPreparation(source, 'node'), admission = admitPreparationRuntime({ plan, platform: 'linux/amd64', catalog: catalog('node') });
|
||||
const runner = new FakeRunner(), cache = cacheFixture(), executor = new PreparationExecutor({ cache, runner }), deadline = Date.now() + 60_000;
|
||||
const closure = await executor.acquire({ plan, admission, snapshot: source, deadline });
|
||||
const controller = new AbortController(), original = fs.readSync.bind(fs); let cacheReads = 0;
|
||||
const reader = spyOn(fs, 'readSync').mockImplementation(((fd: number, buffer: NodeJS.ArrayBufferView,
|
||||
offset: number, length: number, position: number | null) => {
|
||||
const read = original(fd, buffer, offset, length, position);
|
||||
if (length === 64 * 1024 && read > 0) { cacheReads++; controller.abort(); }
|
||||
return read;
|
||||
}) as typeof fs.readSync);
|
||||
try {
|
||||
await executor.acquire({ plan, admission, snapshot: source, deadline, signal: controller.signal, offline: true, existingClosure: closure });
|
||||
throw new Error('expected retained closure verification to be cancelled');
|
||||
} catch (error) { expect(errorCode(error)).toBe('CANCELLED'); }
|
||||
finally { reader.mockRestore(); }
|
||||
expect(cacheReads).toBe(1); expect(runner.acquireCalls).toBe(1);
|
||||
});
|
||||
|
||||
test('cancels acquisition while independently hashing staged archive bytes', async () => {
|
||||
const source = snapshot('node'), plan = inspectPreparation(source, 'node'), admission = admitPreparationRuntime({ plan, platform: 'linux/amd64', catalog: catalog('node') });
|
||||
const runner = new FakeRunner(), cache = cacheFixture(), executor = new PreparationExecutor({ cache, runner });
|
||||
const controller = new AbortController(), original = fs.readSync.bind(fs); let stagedReads = 0;
|
||||
const reader = spyOn(fs, 'readSync').mockImplementation(((fd: number, buffer: NodeJS.ArrayBufferView,
|
||||
offset: number, length: number, position: number | null) => {
|
||||
const read = original(fd, buffer, offset, length, position);
|
||||
if (length === 64 * 1024 && read > 0) { stagedReads++; controller.abort(); }
|
||||
return read;
|
||||
}) as typeof fs.readSync);
|
||||
try {
|
||||
await executor.acquire({ plan, admission, snapshot: source, deadline: Date.now() + 60_000, signal: controller.signal });
|
||||
throw new Error('expected staged archive verification to be cancelled');
|
||||
} catch (error) { expect(errorCode(error)).toBe('CANCELLED'); }
|
||||
finally { reader.mockRestore(); }
|
||||
expect(stagedReads).toBe(1); expect(runner.acquireCalls).toBe(1); expect(cache.stats().entries).toBe(0);
|
||||
});
|
||||
|
||||
test('run-owned archive copies survive a cache eviction between validation and runner start', async () => {
|
||||
const source = snapshot('node'), plan = inspectPreparation(source, 'node'), admission = admitPreparationRuntime({ plan, platform: 'linux/amd64', catalog: catalog('node') });
|
||||
const runner = new FakeRunner(), base = root('cso-eviction-race-'), staging = path.join(base, 'staging'); fs.mkdirSync(staging, { mode: 0o700 });
|
||||
const bytes = artifactBytes('node'), cache = new PublicArchiveCache({ root: path.join(base, 'cache'), stagingRoot: staging, maxBytes: bytes.length });
|
||||
const executor = new PreparationExecutor({ cache, runner }), deadline = Date.now() + 60_000;
|
||||
const closure = await executor.acquire({ plan, admission, snapshot: source, deadline });
|
||||
runner.beforePrepare = request => {
|
||||
expect(request.archives[0].hostPath).not.toBe(cache.get(closure.archives[0].sha256)?.path);
|
||||
const replacement = Buffer.alloc(bytes.length, 0x78), replacementPath = path.join(staging, 'replacement'); fs.writeFileSync(replacementPath, replacement, { mode: 0o600 });
|
||||
cache.promote('replacement', h256(replacement));
|
||||
expect(cache.get(closure.archives[0].sha256)).toBeUndefined();
|
||||
};
|
||||
const prepared = await executor.prepareOffline({ plan, admission, snapshot: source, closure, deadline });
|
||||
expect(prepared.dependencyClosureHash).toBe(closure.closureHash);
|
||||
});
|
||||
|
||||
test('offline lifecycle source mutation fails closed and disposes the returned execution copy', async () => {
|
||||
const source=snapshot('node'),plan=inspectPreparation(source,'node'),admission=admitPreparationRuntime({plan,platform:'linux/amd64',catalog:catalog('node')}),runner=new FakeRunner(),executor=new PreparationExecutor({cache:cacheFixture(),runner}),deadline=Date.now()+60_000;
|
||||
const closure=await executor.acquire({plan,admission,snapshot:source,deadline});runner.mutatePrepared=prepared=>fs.writeFileSync(path.join(prepared,'package.json'),'{}');
|
||||
try{await executor.prepareOffline({plan,admission,snapshot:source,closure,deadline});throw new Error('expected lifecycle source mutation to fail');}
|
||||
catch(error){expect(errorCode(error)).toBe('ISOLATION_FAILED');}
|
||||
expect(runner.disposed).toHaveLength(1);expect(fs.existsSync(runner.disposed[0])).toBe(false);
|
||||
});
|
||||
|
||||
test('Rails PostgreSQL requires and records a same-catalog qualified sidecar', async () => {
|
||||
const source = snapshot('rails'), plan = inspectPreparation(source, 'rails'), runtimes = catalog('rails', 'postgresql');
|
||||
const admission = admitPreparationRuntime({ plan, platform: 'linux/amd64', catalog: runtimes });
|
||||
const sidecar = admitPreparationSidecar({ platform: 'linux/amd64', catalog: runtimes });
|
||||
const runner = new FakeRunner(), executor = new PreparationExecutor({ cache: cacheFixture(), runner }), deadline = Date.now() + 60_000;
|
||||
const closure = await executor.acquire({ plan, admission, snapshot: source, deadline });
|
||||
const prepared = await executor.prepareOffline({ plan, admission, snapshot: source, closure, deadline, database: { adapter: 'postgresql', sidecar } });
|
||||
expect(prepared.receipt.network.services).toEqual(['application']);
|
||||
expect(prepared.database).toEqual({ adapter: 'postgresql', connections: ['primary'],
|
||||
sidecar: { id: sidecar.runtime.id, image: sidecar.runtime.image } });
|
||||
expect(prepared.databaseHash).toBe(runner.offlineRequests[0].databaseHash);
|
||||
expect(runner.offlineRequests[0].database?.sidecar?.image).toContain('@sha256:');
|
||||
});
|
||||
|
||||
test('the host registry broker rejects unallowlisted and private-address CONNECT requests', async () => {
|
||||
for (const [allowed, requested] of [['registry.npmjs.org', 'evil.invalid'], ['localhost', 'localhost']] as const) {
|
||||
let verified = false;
|
||||
// Bun 1.3.10 can transiently report ENOENT on the first Unix-socket
|
||||
// connect immediately after a verified listen when many shard processes
|
||||
// are creating sockets. Retry only that runtime transient against a new
|
||||
// private socket; every policy error and repeated ENOENT still fails.
|
||||
for (let attempt = 0; attempt < 3 && !verified; attempt++) {
|
||||
const base = root('cso-broker-'), socketPath = path.join(base, 'registry.sock');
|
||||
const broker = new RegistryEgressBroker(socketPath, [allowed], Date.now() + 30_000, 1024 * 1024);
|
||||
await broker.start();
|
||||
try {
|
||||
let reply: string;
|
||||
try {
|
||||
reply = await new Promise<string>((resolveReply, reject) => {
|
||||
const socket = net.createConnection({ path: socketPath }); let output = '';
|
||||
socket.setTimeout(5_000, () => socket.destroy(new Error('registry broker test connection timed out')));
|
||||
socket.once('connect', () => socket.write(`CONNECT ${requested}:443 HTTP/1.1\r\nHost: ${requested}:443\r\n\r\n`));
|
||||
socket.on('data', chunk => { output += chunk.toString(); }); socket.once('end', () => resolveReply(output)); socket.once('error', reject);
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'ENOENT' && attempt < 2) continue;
|
||||
throw error;
|
||||
}
|
||||
expect(reply).toContain('403 Forbidden');
|
||||
expect(() => broker.assertClean()).toThrow();
|
||||
expect(broker.contactedHosts.size).toBe(0);
|
||||
verified = true;
|
||||
} finally { await broker.close(); }
|
||||
}
|
||||
expect(verified).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('registry broker close cancels and joins a pending DNS task before removing its socket', async () => {
|
||||
const base = root('cso-broker-close-'), socketPath = path.join(base, 'registry.sock');
|
||||
let completeLookup!: (addresses: Array<{ address: string; family: 4 | 6 }>) => void, markLookup!: () => void, cancellations = 0;
|
||||
const lookupStarted = new Promise<void>(resolve => { markLookup = resolve; });
|
||||
const lookup = new Promise<Array<{ address: string; family: 4 | 6 }>>(resolve => { completeLookup = resolve; });
|
||||
const broker = new RegistryEgressBroker(socketPath, ['registry.npmjs.org'], Date.now() + 30_000, 1024 * 1024, () => {
|
||||
markLookup(); return { promise: lookup, cancel: () => { cancellations++; } };
|
||||
});
|
||||
await broker.start();
|
||||
const socket = net.createConnection({ path: socketPath }); socket.on('error', () => {});
|
||||
const socketClosed = new Promise<void>(resolve => socket.once('close', () => resolve()));
|
||||
await new Promise<void>((resolveConnect, reject) => { socket.once('connect', resolveConnect); socket.once('error', reject); });
|
||||
socket.write('CONNECT registry.npmjs.org:443 HTTP/1.1\r\nHost: registry.npmjs.org:443\r\n\r\n');
|
||||
await lookupStarted;
|
||||
await Promise.race([broker.close(), Bun.sleep(1_000).then(() => { throw new Error('broker close did not join cancelled DNS work'); })]);
|
||||
await Promise.race([socketClosed, Bun.sleep(1_000).then(() => { throw new Error('broker client socket remained open after close'); })]);
|
||||
expect(cancellations).toBe(1); expect(socket.destroyed).toBe(true); expect(fs.existsSync(socketPath)).toBe(false);
|
||||
completeLookup([{ address: '8.8.8.8', family: 4 }]); await Bun.sleep(10);
|
||||
expect(broker.contactedHosts.size).toBe(0);
|
||||
});
|
||||
|
||||
test('registry broker bounds DNS resolution by the connection deadline', async () => {
|
||||
const base = root('cso-broker-deadline-'), socketPath = path.join(base, 'registry.sock'); let cancellations = 0, markCancelled!: () => void;
|
||||
const cancelled = new Promise<void>(resolve => { markCancelled = resolve; });
|
||||
const broker = new RegistryEgressBroker(socketPath, ['registry.npmjs.org'], Date.now() + 250, 1024 * 1024,
|
||||
() => ({ promise: new Promise(() => {}), cancel: () => { cancellations++; markCancelled(); } }));
|
||||
await broker.start();
|
||||
try {
|
||||
const reply = await new Promise<string>((resolveReply, reject) => {
|
||||
const socket = net.createConnection({ path: socketPath }); let output = '';
|
||||
socket.once('connect', () => socket.write('CONNECT registry.npmjs.org:443 HTTP/1.1\r\nHost: registry.npmjs.org:443\r\n\r\n'));
|
||||
socket.on('data', chunk => { output += chunk.toString(); }); socket.once('end', () => resolveReply(output)); socket.once('error', reject);
|
||||
});
|
||||
expect(reply).toContain('403 Forbidden');
|
||||
await Promise.race([cancelled, Bun.sleep(500).then(() => { throw new Error('registry DNS cancellation did not settle after the deadline response'); })]);
|
||||
expect(cancellations).toBe(1); expect(() => broker.assertClean()).toThrow();
|
||||
} finally { await broker.close(); }
|
||||
});
|
||||
|
||||
test('registry address classification rejects mapped, translated, private, and unspecified destinations', () => {
|
||||
for (const address of ['127.0.0.1', '169.254.169.254', '10.2.3.4', '100.64.0.1'])
|
||||
expect(isBlockedRegistryAddress(address, 4)).toBe(true);
|
||||
for (const address of ['::', '::1', '::ffff:127.0.0.1', '::ffff:169.254.169.254', '::ffff:7f00:1',
|
||||
'64:ff9b::7f00:1', '64:ff9b:1::a9fe:a9fe', '100::1', '2001:db8::1', '2002:7f00:1::',
|
||||
'3fff::1', 'fc00::1', 'fe80::1', 'fec0::1', 'ff02::1'])
|
||||
expect(isBlockedRegistryAddress(address, 6)).toBe(true);
|
||||
expect(isBlockedRegistryAddress('8.8.8.8', 4)).toBe(false);
|
||||
expect(isBlockedRegistryAddress('2606:4700:4700::1111', 6)).toBe(false);
|
||||
expect(isBlockedRegistryAddress('not-an-address', 4)).toBe(true);
|
||||
expect(isBlockedRegistryAddress('127.0.0.1', 6)).toBe(true);
|
||||
});
|
||||
|
||||
test('the concrete Docker runner requires the catalog-qualified container helper contract', () => {
|
||||
const source = snapshot('node'), plan = inspectPreparation(source, 'node'), withoutHelper = catalog('node');
|
||||
delete withoutHelper.runtimes[0].versions['cso-preparation'];
|
||||
expect(() => admitPreparationRuntime({ plan, platform: 'linux/amd64', catalog: withoutHelper })).toThrow('MISSING_RUNTIME_TOOL_VERSION');
|
||||
const withHelper = catalog('node');
|
||||
const admitted = admitPreparationRuntime({ plan, platform: 'linux/amd64', catalog: withHelper });
|
||||
const runner = new DockerPreparationSandboxRunner({ endpoint: {} as any, watchdogPath: '/missing', controlRoot: root('cso-docker-runner-'), admission: admitted });
|
||||
expect(runner.qualification).toMatchObject({ runnerId: 'docker-registry-broker-v1', registryRestrictionQualified: true, offlineContainmentQualified: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { inspectPreparation, railsTestConfiguration } from '../lib/cso/preparation';
|
||||
import { assertRuntimeCompatible, RUNTIME_CATALOG, rollbackCatalog, selectRuntime, validateRuntimeCatalog } from '../lib/cso/runtime-catalog';
|
||||
import { completeRuntimeCatalogFixture, qualifiedRuntimeFixture } from './helpers/cso-runtime-catalog';
|
||||
|
||||
const roots: string[] = [];
|
||||
const hash = 'a'.repeat(64);
|
||||
const sri = `sha512-${Buffer.alloc(64, 1).toString('base64')}`;
|
||||
const CREDENTIAL_ARCHIVE_URL = ['https://user:', 'secret@registry.npmjs.org/a.tgz'].join('');
|
||||
function fixture(files: Record<string, unknown>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cso-preparation-')); roots.push(root);
|
||||
for (const [path, contents] of Object.entries(files)) {
|
||||
const full = join(root, path); mkdirSync(join(full, '..'), { recursive: true });
|
||||
writeFileSync(full, typeof contents === 'string' ? contents : JSON.stringify(contents));
|
||||
}
|
||||
return root;
|
||||
}
|
||||
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
||||
const nodeFiles = (version = 3) => ({
|
||||
'package.json': { name: 'app', version: '1.0.0', dependencies: { cookie: '1.0.0' }, scripts: { postinstall: 'touch /tmp/CSO_UNSAFE' } },
|
||||
'package-lock.json': { name: 'app', lockfileVersion: version, packages: { '': { name: 'app', version: '1.0.0', dependencies: { cookie: '1.0.0' } }, 'node_modules/cookie': { version: '1.0.0', resolved: 'https://registry.npmjs.org/cookie/-/cookie-1.0.0.tgz', integrity: sri, hasInstallScript: true } } },
|
||||
});
|
||||
const gemLock = `GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
rack (3.1.8)
|
||||
sqlite3 (2.5.0-x86_64-linux-gnu)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
x86_64-linux-gnu
|
||||
|
||||
DEPENDENCIES
|
||||
rack
|
||||
sqlite3
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.3.6p108
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.9
|
||||
`;
|
||||
|
||||
describe('CSO inert Node preparation', () => {
|
||||
for (const version of [2, 3]) test(`accepts npm lock v${version} and separates lifecycle execution`, () => {
|
||||
const plan = inspectPreparation(fixture(nodeFiles(version)));
|
||||
expect(plan.status).toBe('ready'); expect(plan.stack).toBe('node');
|
||||
expect(plan.inputs).toHaveLength(1); expect(plan.inputs[0].integrity).toBe(sri);
|
||||
expect(plan.metadata.find(m => m.path === 'package.json')!.content).not.toContain('postinstall');
|
||||
expect(plan.acquisition[0].args).toContain('--ignore-scripts');
|
||||
expect(plan.acquisition[0].args).toContain('--no-audit');
|
||||
expect(plan.offline[0].args).toContain('--offline');
|
||||
expect(plan.offline[1].args[0]).toBe('rebuild');
|
||||
});
|
||||
test('rejects lock v1 without emitting executable fragments', () => {
|
||||
const plan = inspectPreparation(fixture(nodeFiles(1)));
|
||||
expect(plan.prerequisites[0].code).toBe('UNSUPPORTED_LOCK'); expect(plan.acquisition).toEqual([]); expect(plan.metadata).toEqual([]);
|
||||
});
|
||||
for (const url of [CREDENTIAL_ARCHIVE_URL, 'http://registry.npmjs.org/a.tgz', 'https://registry.npmjs.org.evil.invalid/a.tgz', 'https://127.0.0.1/a.tgz', 'git+ssh://github.com/a/b', 'https://registry.npmjs.org/a.tgz?token=secret']) test(`rejects non-public archive ${url.split('@').at(-1)}`, () => {
|
||||
const files = nodeFiles(); files['package-lock.json'].packages['node_modules/cookie'].resolved = url;
|
||||
const plan = inspectPreparation(fixture(files)); expect(plan.status).toBe('prerequisites'); expect(plan.acquisition).toEqual([]);
|
||||
});
|
||||
test('rejects archive without cryptographic integrity', () => {
|
||||
const files = nodeFiles(); files['package-lock.json'].packages['node_modules/cookie'].integrity = 'sha1-weak';
|
||||
expect(inspectPreparation(fixture(files)).prerequisites[0].code).toBe('UNPINNED_ARCHIVE');
|
||||
});
|
||||
test('rejects hostile workspace paths before package-manager invocation', () => {
|
||||
const files: any = nodeFiles(); files['package.json'].workspaces = ['../../host/*'];
|
||||
expect(inspectPreparation(fixture(files)).prerequisites[0].code).toBe('EXTERNAL_PATH');
|
||||
});
|
||||
test('contains and sanitizes declared workspaces', () => {
|
||||
const files: any = nodeFiles();
|
||||
files['package.json'].workspaces = ['packages/*'];
|
||||
files['package-lock.json'].packages['packages/local'] = { name: 'local', version: '1.0.0' };
|
||||
files['package-lock.json'].packages['node_modules/local'] = { resolved: 'packages/local', link: true };
|
||||
files['packages/local/package.json'] = { name: 'local', version: '1.0.0', scripts: { install: 'false' } };
|
||||
const plan = inspectPreparation(fixture(files)); expect(plan.status).toBe('ready');
|
||||
expect(plan.inputs.some(i => i.kind === 'local')).toBe(true);
|
||||
expect(plan.metadata.find(m => m.path === 'packages/local/package.json')!.content).not.toContain('scripts');
|
||||
});
|
||||
test('never follows metadata symlinks', () => {
|
||||
const root = fixture({ 'secret.json': nodeFiles()['package.json'] }); symlinkSync('secret.json', join(root, 'package.json'));
|
||||
const plan = inspectPreparation(root); expect(plan.status).toBe('prerequisites'); expect(plan.prerequisites[0].code).toBe('UNSAFE_METADATA');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO inert Bun preparation', () => {
|
||||
test('accepts text locks with trailing commas and ignores scripts online', () => {
|
||||
const plan = inspectPreparation(fixture({
|
||||
'package.json': nodeFiles()['package.json'],
|
||||
'bun.lock': `{"lockfileVersion":1,"workspaces":{"":{"name":"app","dependencies":{"cookie":"1.0.0",},},},"packages":{"cookie":["cookie@1.0.0","",{},"${sri}"],},}`,
|
||||
'bunfig.toml': 'preload = ["./hostile.ts"]',
|
||||
}));
|
||||
expect(plan.status).toBe('ready'); expect(plan.stack).toBe('bun');
|
||||
expect(plan.acquisition[0].args).toContain('--frozen-lockfile'); expect(plan.acquisition[0].args).toContain('--ignore-scripts');
|
||||
expect(plan.acquisition[0].args).toContain('--config=/opt/cso/empty-config'); expect(plan.acquisition[0].args).toContain('--registry=https://registry.npmjs.org');
|
||||
expect(plan.offline[0].args).toContain('--backend=copyfile');
|
||||
expect(plan.metadata.some(m => m.path === 'bunfig.toml')).toBe(false);
|
||||
});
|
||||
test('does not treat lock text as JavaScript', () => {
|
||||
const plan = inspectPreparation(fixture({ 'bun.lock': '({workspaces: (()=>{throw Error("EXECUTED")})()})' }));
|
||||
expect(plan.prerequisites[0].code).toBe('INVALID_METADATA');
|
||||
});
|
||||
test('reports binary lock prerequisite', () => {
|
||||
expect(inspectPreparation(fixture({ 'bun.lockb': 'binary', 'package.json': {} })).prerequisites[0].code).toBe('UNSUPPORTED_LOCK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO inert Python preparation', () => {
|
||||
test('requires exact hashed wheels and excludes target config', () => {
|
||||
const plan = inspectPreparation(fixture({ 'requirements.txt': `# generated pins\nFlask==3.1.0 \\\n --hash=sha256:${hash}\n`, 'pip.conf': '[global]\nextra-index-url=https://evil.invalid' }));
|
||||
expect(plan.status).toBe('ready'); expect(plan.inputs[0].name).toBe('Flask');
|
||||
expect(plan.acquisition[0].args).toContain('--only-binary=:all:'); expect(plan.acquisition[0].args).toContain('--require-hashes');
|
||||
expect(plan.metadata).toHaveLength(1); expect(plan.offline[1].args).toContain('--no-index');
|
||||
});
|
||||
for (const requirement of ['flask>=3', '-e .', 'flask @ https://evil.invalid/x.whl', '--index-url https://evil.invalid', '-r other.txt', 'flask==3.1.0']) test(`rejects unpinned or executable requirement ${requirement}`, () => {
|
||||
expect(inspectPreparation(fixture({ 'requirements.txt': requirement })).prerequisites[0].code).toBe('UNPINNED_REQUIREMENTS');
|
||||
});
|
||||
const uvFixture = () => ({
|
||||
'uv.lock': `version = 1\nrequires-python = ">=3.12"\n[[package]]\nname = "app"\nversion = "1.0.0"\nsource = { editable = "." }\n[[package]]\nname = "flask"\nversion = "3.1.0"\nsource = { registry = "https://pypi.org/simple" }\nwheels = [{url = "https://files.pythonhosted.org/packages/flask.whl", hash = "sha256:${hash}"}]\n`,
|
||||
'pyproject.toml': '[project]\nname="app"\nversion="1.0.0"\ndependencies=["flask==3.1.0"]\n[build-system]\nrequires=[]\nbuild-backend="evil_backend"\n',
|
||||
});
|
||||
test('uv explicitly omits first-party packages during online export', () => {
|
||||
const plan = inspectPreparation(fixture(uvFixture())); expect(plan.status).toBe('ready');
|
||||
expect(plan.acquisition[0].args).toContain('--no-emit-local'); expect(plan.acquisition[0].args).toContain('--frozen');
|
||||
expect(plan.metadata.find(m => m.path === 'pyproject.toml')!.content).not.toContain('evil_backend');
|
||||
expect(plan.offline[0].args).toContain('--offline');
|
||||
expect(plan.offline.some(command => command.args.includes('--require-hashes'))).toBe(true);
|
||||
expect(plan.offline.find(c => c.args.includes('--no-build-isolation'))!.args).toContain('/work/.');
|
||||
expect(plan.inputs.some(i => i.kind === 'local' && i.path === '.')).toBe(true);
|
||||
});
|
||||
test('uv rejects local packages outside the snapshot', () => {
|
||||
const files = uvFixture(); files['uv.lock'] = files['uv.lock'].replace('editable = "."', 'editable = "../private"');
|
||||
expect(inspectPreparation(fixture(files)).prerequisites[0].code).toBe('EXTERNAL_PATH');
|
||||
});
|
||||
test('uv rejects public dependencies with only source distributions', () => {
|
||||
const files = uvFixture(); files['uv.lock'] = files['uv.lock'].replace(/wheels = .*/, 'wheels = []');
|
||||
expect(inspectPreparation(fixture(files)).prerequisites[0].code).toBe('MISSING_PUBLIC_WHEEL');
|
||||
});
|
||||
test('uv reports cross-platform marker locks as a readiness prerequisite', () => {
|
||||
const files = uvFixture();
|
||||
files['uv.lock'] = files['uv.lock'].replace('[[package]]\nname = "flask"', `[[package]]\nname = "colorama"\nversion = "0.4.6"\nsource = { registry = "https://pypi.org/simple" }\nwheels = [{url = "https://files.pythonhosted.org/packages/colorama.whl", hash = "sha256:${hash}"}]\ndependencies = [{ name = "win32", marker = "sys_platform == 'win32'" }]\n[[package]]\nname = "flask"`);
|
||||
const plan = inspectPreparation(fixture(files));
|
||||
expect(plan.status).toBe('prerequisites');
|
||||
expect(plan.prerequisites.some(item => item.code === 'UNSUPPORTED_MARKER')).toBe(true);
|
||||
});
|
||||
test('uv requires exact public wheels for every local build dependency', () => {
|
||||
const files = uvFixture(); files['pyproject.toml'] = files['pyproject.toml'].replace('requires=[]', 'requires=["setuptools>=40.8.0"]');
|
||||
expect(inspectPreparation(fixture(files)).prerequisites[0].code).toBe('MISSING_BUILD_DEPENDENCY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO inert Rails preparation', () => {
|
||||
test('does not silently choose Node in a multi-stack repository',()=>{const plan=inspectPreparation(fixture({...nodeFiles(),'Gemfile.lock':gemLock,'Gemfile':''}));expect(plan.status).toBe('prerequisites');expect(plan.prerequisites[0].code).toBe('MULTIPLE_STACKS');expect(inspectPreparation(fixture({...nodeFiles(),'Gemfile.lock':gemLock,'Gemfile':''}),'rails').stack).toBe('rails');});
|
||||
test('fetches exact gems without evaluating Gemfiles or building native extensions', () => {
|
||||
const plan = inspectPreparation(fixture({ 'Gemfile.lock': gemLock, 'Gemfile': 'system("touch /tmp/CSO_UNSAFE")\nsource "https://rubygems.org"' }));
|
||||
expect(plan.status).toBe('ready'); expect(plan.stack).toBe('rails'); expect(plan.metadata.some(m => m.path === 'Gemfile')).toBe(false);
|
||||
expect(plan.acquisition).toHaveLength(2); expect(plan.acquisition.every(c => c.args[0] === 'fetch')).toBe(true);
|
||||
expect(plan.inputs[1].platform).toBe('x86_64-linux-gnu'); expect(plan.inputs[0].integritySource).toBe('registry-on-acquisition');
|
||||
expect(plan.runtimeRequirements).toEqual({ ruby: '3.3.6', bundler: '2.6.9' });
|
||||
expect(plan.offline[0].args).toContain('--local'); expect(plan.offline[0].env.BUNDLE_IGNORE_CONFIG).toBe('true');
|
||||
expect(plan.offline[0].env.GEM_HOME).toBeUndefined(); expect(plan.offline[0].env.GEM_PATH).toBeUndefined();
|
||||
});
|
||||
test('records lock checksums separately from newly acquired archive hashes', () => {
|
||||
const plan = inspectPreparation(fixture({ 'Gemfile.lock': gemLock + `\nCHECKSUMS\n rack (3.1.8) sha256=${hash}\n`, 'Gemfile': '' }));
|
||||
expect(plan.status).toBe('ready'); expect(plan.inputs[0].integrity).toBe(`sha256:${hash}`); expect(plan.inputs[0].integritySource).toBe('lock');
|
||||
});
|
||||
for (const section of ['GIT', 'PATH', 'PLUGIN']) test(`rejects ${section} sources`, () => {
|
||||
expect(inspectPreparation(fixture({ 'Gemfile.lock': gemLock + `\n${section}\n remote: https://evil.invalid\n`, 'Gemfile': '' })).prerequisites[0].code).toBe('UNSUPPORTED_SOURCE');
|
||||
});
|
||||
test('enumerates every static connection without evaluating ERB', () => {
|
||||
const plan = inspectPreparation(fixture({ 'Gemfile.lock': gemLock, 'Gemfile': '', 'config/database.yml': `test:\n primary:\n adapter: sqlite3\n database: <%= ENV["DATABASE_URL"] %>\n queue:\n adapter: postgresql\nproduction:\n analytics:\n adapter: postgresql\n` }));
|
||||
expect(plan.status).toBe('ready'); expect(plan.database?.connections).toEqual(['analytics', 'primary', 'queue']);
|
||||
for (const adapter of ['sqlite', 'postgresql'] as const) {
|
||||
const files = railsTestConfiguration(plan.database!.connections, adapter);
|
||||
const database = JSON.parse(files[0].content); expect(Object.keys(database.test)).toHaveLength(3); expect(database.production).toBeUndefined();
|
||||
expect(files.map(f => f.content).join('')).not.toContain('ENV["DATABASE_URL"]');
|
||||
}
|
||||
});
|
||||
test('accepts the stock Rails default anchor without expanding arbitrary YAML aliases',()=>{
|
||||
const database=`default: &default\n adapter: sqlite3\n pool: 5\ntest:\n <<: *default\n database: storage/test.sqlite3\nproduction:\n primary:\n <<: *default\n database: storage/primary.sqlite3\n queue:\n <<: *default\n database: storage/queue.sqlite3\n`;
|
||||
const plan=inspectPreparation(fixture({'Gemfile.lock':gemLock,'Gemfile':'','config/database.yml':database}),'rails');
|
||||
expect(plan.status).toBe('ready');expect(plan.database).toMatchObject({supported:['sqlite'],selected:'sqlite',connections:['primary','queue']});
|
||||
const hostile=database.replace('primary:\n','primary: &primary\n').replace('queue:\n','queue:\n <<: *primary\n');
|
||||
expect(inspectPreparation(fixture({'Gemfile.lock':gemLock,'Gemfile':'','config/database.yml':hostile}),'rails').prerequisites[0].code).toBe('DYNAMIC_DATABASE_CONFIG');
|
||||
});
|
||||
test('advertises and selects only database adapters actually present in the lock',()=>{
|
||||
const pgLock=gemLock.replace('sqlite3 (2.5.0-x86_64-linux-gnu)','pg (1.5.9)').replace(/sqlite3/g,'pg');
|
||||
const pg=inspectPreparation(fixture({'Gemfile.lock':pgLock,'Gemfile':'','config/database.yml':'test:\n adapter: postgresql\n database: app_test\n'}),'rails');
|
||||
expect(pg.status).toBe('ready');expect(pg.database).toMatchObject({supported:['postgresql'],selected:'postgresql'});
|
||||
const noAdapter=gemLock.replace(' sqlite3 (2.5.0-x86_64-linux-gnu)\n','').replace(' sqlite3\n','');
|
||||
expect(inspectPreparation(fixture({'Gemfile.lock':noAdapter,'Gemfile':''}),'rails').prerequisites[0].code).toBe('MISSING_DATABASE_ADAPTER');
|
||||
});
|
||||
test('structural ERB requires explicit synthetic connection names', () => {
|
||||
const plan = inspectPreparation(fixture({ 'Gemfile.lock': gemLock, 'Gemfile': '', 'config/database.yml': '<% dynamic_config %>\n' }));
|
||||
expect(plan.prerequisites[0].code).toBe('DYNAMIC_DATABASE_CONFIG'); expect(plan.acquisition).toEqual([]);
|
||||
});
|
||||
test('rejects prototype-mutating synthetic connection names', () => {
|
||||
expect(() => railsTestConfiguration(['__proto__'], 'sqlite')).toThrow('Invalid Rails connection names');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO runtime catalog admission', () => {
|
||||
const runtime = () => qualifiedRuntimeFixture('node');
|
||||
const catalog = () => completeRuntimeCatalogFixture('test-1');
|
||||
test('default catalog cannot execute unqualified images', () => {
|
||||
expect(RUNTIME_CATALOG.profiles).toHaveLength(10);
|
||||
expect(RUNTIME_CATALOG.runtimes).toEqual([]);
|
||||
expect(() => selectRuntime('node', 'linux/amd64')).toThrow('Reviewed build profile node-24.4.0-amd64 is awaiting a qualified image promotion');
|
||||
});
|
||||
test('selects exact qualified digest and platform', () => {
|
||||
expect(selectRuntime('node', 'linux/amd64', catalog()).image).toContain('@sha256:');
|
||||
expect(selectRuntime('node', 'linux/arm64', catalog()).platform).toBe('linux/arm64');
|
||||
expect(() => selectRuntime('missing', 'linux/arm64', catalog())).toThrow('MISSING_QUALIFIED_RUNTIME');
|
||||
});
|
||||
test('runtime stack and declared tool versions must match preparation',()=>{const files:any=nodeFiles();files['package.json'].engines={node:'>=24 <25'};files['package.json'].packageManager='npm@^11.0.0';const plan=inspectPreparation(fixture(files),'node'),good=runtime();expect(()=>assertRuntimeCompatible(plan,good)).not.toThrow();good.versions.node='23.9.0';expect(()=>assertRuntimeCompatible(plan,good)).toThrow('does not satisfy');const wrong={...runtime(),stack:'python' as const,versions:{python:'3.12.1',uv:'0.8.0'}};expect(()=>assertRuntimeCompatible(plan,wrong)).toThrow('cannot run');});
|
||||
test('a Bun-backed node fallback cannot satisfy a declared Node engine', () => {
|
||||
const plan = inspectPreparation(fixture({
|
||||
'package.json': { name: 'app', version: '1.0.0', engines: { node: '>=24' } },
|
||||
'bun.lock': `{"lockfileVersion":1,"workspaces":{"":{"name":"app"}},"packages":{}}`,
|
||||
}), 'bun');
|
||||
expect(plan.status).toBe('ready');
|
||||
const bunRuntime = {
|
||||
...runtime(), id: 'bun-qualified-test', stack: 'bun' as const,
|
||||
versions: { bun: '1.3.10', 'cso-preparation': '1.0.0' },
|
||||
};
|
||||
expect(() => assertRuntimeCompatible(plan, bunRuntime)).toThrow('does not declare a real node release');
|
||||
});
|
||||
test('rejects tags and missing qualification evidence', () => {
|
||||
const c = catalog(); c.runtimes[0].image = 'node:latest'; expect(() => validateRuntimeCatalog(c)).toThrow('UNQUALIFIED_RUNTIME');
|
||||
c.runtimes[0] = runtime(); (c.runtimes[0].qualification as any).heldOutRepairPassed = false; expect(() => validateRuntimeCatalog(c)).toThrow('MISSING_APPLICATION_QUALIFICATION');
|
||||
});
|
||||
test('qualification evidence is exact and specific to application and PostgreSQL roles', () => {
|
||||
const missingVerifierEvidence=catalog();delete (missingVerifierEvidence.runtimes[0].qualification as any).positiveNegativeAssertionsPassed;
|
||||
expect(()=>validateRuntimeCatalog(missingVerifierEvidence)).toThrow('MISSING_APPLICATION_QUALIFICATION');
|
||||
const application=catalog();(application.runtimes[0].qualification as any).multiDatabasePassed=true;
|
||||
expect(()=>validateRuntimeCatalog(application)).toThrow('MISSING_APPLICATION_QUALIFICATION');
|
||||
const valid=catalog();expect(()=>validateRuntimeCatalog(valid)).not.toThrow();
|
||||
const postgresql=valid.runtimes.find(item=>item.stack==='postgresql'&&item.platform==='linux/amd64')!;
|
||||
(postgresql.qualification as any).heldOutRepairPassed=true;
|
||||
expect(()=>validateRuntimeCatalog(valid)).toThrow('MISSING_POSTGRESQL_QUALIFICATION');
|
||||
const standaloneVerifier=catalog();Object.assign(standaloneVerifier.runtimes[0],{stack:'verifier',versions:{verifier:'1.0.0'}});
|
||||
expect(()=>validateRuntimeCatalog(standaloneVerifier)).toThrow('UNSUPPORTED_RUNTIME_PLATFORM');
|
||||
});
|
||||
test('rejects partial nonempty catalogs and images outside the promoted gstack namespace', () => {
|
||||
const partial = catalog(); partial.runtimes = [partial.runtimes[0]];
|
||||
expect(() => validateRuntimeCatalog(partial)).toThrow('INCOMPLETE_QUALIFIED_RUNTIME_MATRIX');
|
||||
const arbitrary = catalog(); arbitrary.runtimes[0].image = `ghcr.io/attacker/forged@sha256:${hash}`;
|
||||
expect(() => validateRuntimeCatalog(arbitrary)).toThrow('UNQUALIFIED_RUNTIME');
|
||||
});
|
||||
test('rollback requires the previous compatible helper/catalog pair', () => {
|
||||
const previous = catalog(); const current = completeRuntimeCatalogFixture('test-2', 'test-1');
|
||||
expect(rollbackCatalog(current, previous)).toBe(previous);
|
||||
expect(() => rollbackCatalog({ ...current, previousRevision: 'other' }, previous)).toThrow('INCOMPATIBLE_RUNTIME_ROLLBACK');
|
||||
});
|
||||
});
|
||||
+92
-89
@@ -1,107 +1,110 @@
|
||||
/**
|
||||
* cso security-guidance preservation test.
|
||||
*
|
||||
* cso carries load-bearing security prose: OWASP Top 10 mappings, STRIDE
|
||||
* threat-model phrasing, mode dispatch, and false-positive-filtering exceptions
|
||||
* that must NOT be auto-discarded.
|
||||
*
|
||||
* cso is now carved (skeleton SKILL.md + sections/audit-phases.md). The
|
||||
* scope-dependent audit phases (2-11) moved to the section; the mode dispatch
|
||||
* (## Arguments, ## Mode Resolution), the always-run phases (0, 1), and the
|
||||
* FP-filtering exceptions (Phase 12) stay always-loaded in the skeleton.
|
||||
*
|
||||
* Two distinct guarantees (codex outside-voice #5 — earliest-use, not loose
|
||||
* substrings):
|
||||
* 1. PRESERVATION — the security phrases survive somewhere in the union
|
||||
* (skeleton + sections); a carve relocates, it never drops.
|
||||
* 2. ALWAYS-LOADED CONTRACT — dispatch + FP-filtering directives stay in the
|
||||
* skeleton, and mode dispatch precedes any STOP-Read (a directive that
|
||||
* decides which sections to read can't sit behind the STOP that reads them).
|
||||
*/
|
||||
|
||||
/** Security-critical CSO routing/proof policy must remain in the always-loaded controller. */
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dir, '..');
|
||||
const CSO_DIR = path.join(REPO_ROOT, 'cso');
|
||||
const CSO_SKELETON = path.join(CSO_DIR, 'SKILL.md');
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const controller = () => fs.readFileSync(path.join(ROOT, 'cso/SKILL.md'), 'utf8');
|
||||
const template = () => fs.readFileSync(path.join(ROOT, 'cso/SKILL.md.tmpl'), 'utf8');
|
||||
const domain = () => fs.readFileSync(path.join(ROOT, 'cso/sections/audit-phases.md'), 'utf8');
|
||||
|
||||
function readSkeleton(): string {
|
||||
return fs.readFileSync(CSO_SKELETON, 'utf-8');
|
||||
}
|
||||
function readUnion(): string {
|
||||
let text = readSkeleton();
|
||||
const dir = path.join(CSO_DIR, 'sections');
|
||||
if (fs.existsSync(dir)) {
|
||||
for (const f of fs.readdirSync(dir).sort()) {
|
||||
if (f.endsWith('.md') && !f.endsWith('.md.tmpl')) {
|
||||
text += '\n' + fs.readFileSync(path.join(dir, f), 'utf-8');
|
||||
}
|
||||
describe('CSO v3 always-loaded policy', () => {
|
||||
test('dispatch and execution boundaries are available before a host section read', () => {
|
||||
const text = controller();
|
||||
const stop = text.indexOf('> **STOP.**');
|
||||
for (const directive of ['## Arguments', '## Mode Resolution', '**Private startup.**', 'untrusted evidence']) {
|
||||
expect(text.indexOf(directive)).toBeGreaterThan(-1);
|
||||
if (stop >= 0) expect(text.indexOf(directive)).toBeLessThan(stop);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// Security content that must survive the carve (checked against the UNION).
|
||||
const MUST_PRESERVE_PHRASES = ['OWASP', 'STRIDE', 'daily', 'comprehensive', 'confidence', 'verif'];
|
||||
|
||||
describe('cso skill preserves load-bearing security guidance', () => {
|
||||
test('cso skeleton exists and is non-trivial', () => {
|
||||
expect(fs.existsSync(CSO_SKELETON)).toBe(true);
|
||||
// Skeleton stays substantial: dispatch + always-run phases + FP filtering +
|
||||
// report phases are all always-loaded. Under 30 KB means too much moved out.
|
||||
expect(readSkeleton().length).toBeGreaterThan(30_000);
|
||||
for (const phase of [0, 1, 12, 13, 14]) expect(text).toContain(`### Phase ${phase}:`);
|
||||
});
|
||||
|
||||
test('security phrases survive in the union (skeleton + sections)', () => {
|
||||
const union = readUnion().toLowerCase();
|
||||
const missing = MUST_PRESERVE_PHRASES.filter((p) => !union.includes(p.toLowerCase()));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`cso union is missing required security phrases: ${missing.join(', ')}. ` +
|
||||
`These are load-bearing. A carve relocates them; it must not drop them.`,
|
||||
);
|
||||
test('every scope and lifecycle entrypoint survives host generation', () => {
|
||||
const text = controller();
|
||||
for (const flag of ['--infra', '--code', '--skills', '--supply-chain', '--owasp', '--scope', '--diff', '--base', '--budget', '--offline', '--comprehensive', '--doctor', '--resume', '--replay', '--recheck']) {
|
||||
expect(text).toContain(flag);
|
||||
}
|
||||
expect(text).toContain('mutually exclusive');
|
||||
for (const command of ['start', 'doctor', 'resume', 'replay', 'recheck', 'inspect', 'read', 'history', 'scan', 'import-sarif', 'submit', 'verify', 'finish', 'import-v2', 'inspect-v2', 'schema']) {
|
||||
expect(text).toContain(`gstack-cso ${command}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ALWAYS-LOADED: mode dispatch + FP-filtering stay in the skeleton', () => {
|
||||
const skeleton = readSkeleton();
|
||||
// Dispatch must be always-loaded — the agent resolves scope before reading sections.
|
||||
expect(skeleton).toContain('## Arguments');
|
||||
expect(skeleton).toContain('## Mode Resolution');
|
||||
// FP-filtering with its critical exceptions is mandatory and must not be on-demand.
|
||||
expect(skeleton).toContain('Phase 12');
|
||||
// The "SKILL.md files are NOT documentation" exception is a must-not-miss
|
||||
// security directive (skill supply-chain findings); it stays always-loaded.
|
||||
expect(skeleton).toContain('NOT documentation');
|
||||
});
|
||||
|
||||
test('EARLIEST-USE: mode dispatch precedes any STOP-Read directive (codex #6)', () => {
|
||||
const skeleton = readSkeleton();
|
||||
const stop = skeleton.indexOf('> **STOP.**');
|
||||
const modeRes = skeleton.indexOf('## Mode Resolution');
|
||||
const args = skeleton.indexOf('## Arguments');
|
||||
expect(modeRes).toBeGreaterThan(-1);
|
||||
expect(args).toBeGreaterThan(-1);
|
||||
if (stop >= 0) {
|
||||
// A dispatch directive stranded after the STOP can't govern which sections to read.
|
||||
expect(args).toBeLessThan(stop);
|
||||
expect(modeRes).toBeLessThan(stop);
|
||||
test('CSO cannot acquire shared preamble execution or content-export hooks', () => {
|
||||
for (const forbidden of ['{{PREAMBLE}}', '{{GBRAIN_CONTEXT_LOAD}}', '{{GBRAIN_SAVE_RESULTS}}', '{{LEARNINGS_SEARCH}}', '{{LEARNINGS_LOG}}', '{{CONFIDENCE_CALIBRATION}}']) {
|
||||
expect(template()).not.toContain(forbidden);
|
||||
}
|
||||
const text = controller();
|
||||
expect(text).not.toContain('gstack-skill-start');
|
||||
expect(text).not.toContain('gstack-telemetry-log');
|
||||
expect(text).not.toContain('gstack-review-log');
|
||||
expect(text).toContain('Do not send findings, source, secrets, harnesses, or bundles');
|
||||
expect(text).toContain('trusted installed gstack distribution');
|
||||
});
|
||||
|
||||
test('cso catalog trim landed (frontmatter description ≤ 200 chars)', () => {
|
||||
const content = readSkeleton();
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
expect(fmMatch).not.toBeNull();
|
||||
const desc = fmMatch![1].match(/^description:\s+(.+)$/m);
|
||||
expect(desc).not.toBeNull();
|
||||
expect(desc![1].trim().length).toBeLessThanOrEqual(200);
|
||||
expect(desc![1]).toContain('(gstack)');
|
||||
test('static review cannot acquire tested or current-source closure labels', () => {
|
||||
const text = controller();
|
||||
for (const proof of ['CSO evidence rubric', 'identical security assertion', 'legitimate control passes', 'pristine second copy', 'before/after configuration and dependency closures', 'boundary-replacing mocks', 'legacy review evidence', 'new evidence covering the same root cause']) {
|
||||
expect(text).toContain(proof);
|
||||
}
|
||||
expect(text).toContain('Keep finding evidence, reproduction outcome, patch validation, test-completion assurance, review assurance, and current-source closure separate');
|
||||
expect(text).toContain('cannot establish that an application booted or a repair passed tests');
|
||||
});
|
||||
|
||||
test('cso routing prose moved to "## When to invoke" body section', () => {
|
||||
expect(readSkeleton()).toContain('## When to invoke this skill');
|
||||
test('runtime and scanner execution claims require qualified catalog profiles', () => {
|
||||
const text = controller();
|
||||
expect(text).toContain('Static assessment remains available without runtime or scanner profiles');
|
||||
expect(text).toContain('matching qualified runtime catalog profile');
|
||||
expect(text).toContain('Project-test completion remains `self_reported`');
|
||||
expect(text).toContain('target code shares that process and can forge reporter output or terminate the runner');
|
||||
expect(text).toContain('The `tested` state remains reserved until a target-independent completion witness exists');
|
||||
expect(text).toContain('show assertion, test-completion, and review assurance exactly as recorded');
|
||||
expect(domain()).toContain('matching qualified scanner catalog profile');
|
||||
});
|
||||
|
||||
test('sensitive helper control files stay out of the audited working tree', () => {
|
||||
const text = controller();
|
||||
for (const proof of ['Private control files', 'umask 077', 'mode-`0700`', 'mode-`0600`', 'outside the audited repository', 'remove each control file immediately']) expect(text).toContain(proof);
|
||||
for (const proof of ['Audited-source access invariant', "only with that run's `inspect`, `read`, and `history`", 'exact `path` from `inspect`', '`displayPath` is only a redacted label', 'Never use host `Read`/`Glob`/`Grep`', 'direct reads bypass']) expect(text).toContain(proof);
|
||||
for (const proof of ["copy every record's `domain` and `scope` exactly", 'a new scope leaves the planned scope unassessed', 'Only helper commands may update helper-owned records']) expect(text).toContain(proof);
|
||||
});
|
||||
|
||||
test('supported findings are persisted and surfaced before the final report', () => {
|
||||
const text = controller();
|
||||
expect(text).toContain('Invoke `start` exactly once');
|
||||
expect(text).toContain('never call `start` again');
|
||||
expect(text).toContain('same ID');
|
||||
expect(text).toContain('submit it to the helper **and surface it to the user immediately**');
|
||||
expect(text).toContain('do not wait for the final report');
|
||||
expect(text).toContain('if the run is interrupted');
|
||||
});
|
||||
|
||||
test('empty, partial, redaction failure, persistence failure and expiry stay truthful', () => {
|
||||
const text = controller();
|
||||
for (const contract of ['**complete**, **partial**, or **not assessed**', 'No supported findings in the assessed scope.', 'PERSISTENCE_FAILED', 'MISSING_INPUT', 'withhold the payload entirely', 'one bounded correction attempt', 'seven days', 'thirty days']) {
|
||||
expect(text).toContain(contract);
|
||||
}
|
||||
expect(text).toContain('Completeness is independent of finding count');
|
||||
expect(text).toContain('outside synchronization allowlists');
|
||||
});
|
||||
|
||||
test('obsolete blanket exclusions and certainty scores are absent', () => {
|
||||
const text = controller() + domain();
|
||||
for (const obsolete of ['8/10 confidence gate', '2/10 confidence gate', 'devDependency CVEs are MEDIUM max', "gstack's own skills are trusted", 'User content in the user-message position of an AI conversation is NOT prompt injection', 'pull_request_target` without PR ref checkout is safe', 'Hard exclusions — automatically discard']) {
|
||||
expect(text).not.toContain(obsolete);
|
||||
}
|
||||
expect(text).toContain('Unknown reachability remains');
|
||||
expect(text).toContain('sequential challenge; independent agent unavailable');
|
||||
});
|
||||
|
||||
test('controller stays compact without moving proof policy behind a section read', () => {
|
||||
expect(Buffer.byteLength(template())).toBeLessThan(18_000);
|
||||
expect(Buffer.byteLength(controller())).toBeLessThan(20_000);
|
||||
expect(Buffer.byteLength(controller() + domain())).toBeLessThan(36_000);
|
||||
const frontmatter = controller().match(/^---\n([\s\S]*?)\n---/)![1];
|
||||
const description = frontmatter.match(/^description:\s+(.+)$/m)![1].trim();
|
||||
expect(description.length).toBeLessThanOrEqual(200);
|
||||
expect(description).toContain('(gstack)');
|
||||
expect(controller()).toContain('## When to invoke this skill');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
assertPublicPackageMetadata,
|
||||
parsePublicGhcrTarget,
|
||||
verifyPublicGhcrImage,
|
||||
type PublicGhcrDependencies,
|
||||
} from '../scripts/cso-public-ghcr';
|
||||
|
||||
const HASH = 'a'.repeat(64);
|
||||
const IMAGE = `ghcr.io/garrytan/gstack/cso-staging/node-amd64@sha256:${HASH}`;
|
||||
|
||||
describe('CSO public GHCR release proof', () => {
|
||||
test('binds an exact CSO package and digest to its declared platform', () => {
|
||||
expect(parsePublicGhcrTarget(IMAGE, 'linux/amd64', 'GarryTan/GStack')).toEqual({
|
||||
image: IMAGE,
|
||||
owner: 'garrytan',
|
||||
repository: 'gstack',
|
||||
packageName: 'gstack/cso-staging/node-amd64',
|
||||
digest: `sha256:${HASH}`,
|
||||
platform: 'linux/amd64',
|
||||
});
|
||||
expect(parsePublicGhcrTarget(
|
||||
`ghcr.io/garrytan/gstack/cso-scanners/semgrep-arm64@sha256:${HASH}`,
|
||||
'linux/arm64',
|
||||
'garrytan/gstack',
|
||||
).packageName).toBe('gstack/cso-scanners/semgrep-arm64');
|
||||
});
|
||||
|
||||
test('rejects tags, foreign repositories, unknown packages, and platform substitution', () => {
|
||||
for (const [image, platform, repository] of [
|
||||
['ghcr.io/garrytan/gstack/cso-staging/node-amd64:latest', 'linux/amd64', 'garrytan/gstack'],
|
||||
[IMAGE.replace('garrytan/gstack', 'attacker/gstack'), 'linux/amd64', 'garrytan/gstack'],
|
||||
[IMAGE.replace('node-amd64', 'unknown-amd64'), 'linux/amd64', 'garrytan/gstack'],
|
||||
[IMAGE, 'linux/arm64', 'garrytan/gstack'],
|
||||
]) expect(() => parsePublicGhcrTarget(image, platform, repository)).toThrow();
|
||||
});
|
||||
|
||||
test('accepts only exact public container metadata for the expected owner', () => {
|
||||
const target = parsePublicGhcrTarget(IMAGE, 'linux/amd64', 'garrytan/gstack');
|
||||
const valid = { name: target.packageName, package_type: 'container', visibility: 'public', owner: { login: 'GarryTan' } };
|
||||
expect(() => assertPublicPackageMetadata(valid, target)).not.toThrow();
|
||||
for (const mutation of [
|
||||
{ ...valid, visibility: 'private' },
|
||||
{ ...valid, name: 'gstack/cso-staging/other-amd64' },
|
||||
{ ...valid, package_type: 'npm' },
|
||||
{ ...valid, owner: { login: 'attacker' } },
|
||||
]) expect(() => assertPublicPackageMetadata(mutation, target)).toThrow('GHCR_PACKAGE_IS_NOT_PUBLIC');
|
||||
});
|
||||
|
||||
test('pulls through an isolated empty Docker config and strips ambient credentials', async () => {
|
||||
const calls: Array<{ args: string[]; env: Record<string, string> }> = [];
|
||||
const fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
expect((init?.headers as Record<string, string>).Authorization).toBe('Bearer metadata-token');
|
||||
const value = String(url).includes('/packages/container/')
|
||||
? { name: 'gstack/cso-staging/node-amd64', package_type: 'container', visibility: 'public', owner: { login: 'garrytan' } }
|
||||
: { login: 'garrytan', type: 'User' };
|
||||
return new Response(JSON.stringify(value), { status: 200 });
|
||||
}) as typeof globalThis.fetch;
|
||||
const runDocker: PublicGhcrDependencies['runDocker'] = async (args, env) => {
|
||||
calls.push({ args, env });
|
||||
const configRoot = args[args.indexOf('--config') + 1];
|
||||
expect(fs.readFileSync(`${configRoot}/config.json`, 'utf8')).toBe('{"auths":{}}\n');
|
||||
expect(env).not.toHaveProperty('GH_TOKEN');
|
||||
expect(env).not.toHaveProperty('GITHUB_TOKEN');
|
||||
expect(env.HOME).toBe(configRoot);
|
||||
expect(env.DOCKER_CONFIG).toBe(configRoot);
|
||||
if (args.includes('inspect')) return { exitCode: 0, stdout: JSON.stringify([IMAGE]), stderr: '' };
|
||||
return { exitCode: 0, stdout: IMAGE, stderr: '' };
|
||||
};
|
||||
const proof = await verifyPublicGhcrImage(
|
||||
{ image: IMAGE, platform: 'linux/amd64', githubRepository: 'garrytan/gstack', removeAfter: true },
|
||||
{ fetch, githubToken: 'metadata-token', dockerPath: '/usr/bin/docker', runDocker, now: () => '2026-09-11T00:00:00.000Z', sleep: async () => {} },
|
||||
);
|
||||
expect(proof).toMatchObject({ image: IMAGE, packageVisibility: 'public', anonymousPull: 'passed', dockerConfig: 'isolated-empty-auths' });
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[0].args).toContain('pull');
|
||||
expect(calls[0].args).toContain('--quiet');
|
||||
expect(calls[2].args).toContain('rm');
|
||||
});
|
||||
|
||||
test('fails closed before pulling a non-public package or accepting another digest', async () => {
|
||||
let dockerCalls = 0;
|
||||
const privateFetch = (async (url: string | URL | Request) => new Response(JSON.stringify(
|
||||
String(url).includes('/packages/container/')
|
||||
? { name: 'gstack/cso-staging/node-amd64', package_type: 'container', visibility: 'private', owner: { login: 'garrytan' } }
|
||||
: { login: 'garrytan', type: 'User' },
|
||||
), { status: 200 })) as typeof globalThis.fetch;
|
||||
await expect(verifyPublicGhcrImage(
|
||||
{ image: IMAGE, platform: 'linux/amd64', githubRepository: 'garrytan/gstack' },
|
||||
{ fetch: privateFetch, githubToken: 'metadata-token', dockerPath: '/usr/bin/docker', runDocker: async () => { dockerCalls++; return { exitCode: 0, stdout: '', stderr: '' }; }, sleep: async () => {} },
|
||||
)).rejects.toThrow('GHCR_PACKAGE_IS_NOT_PUBLIC');
|
||||
expect(dockerCalls).toBe(0);
|
||||
|
||||
const publicFetch = (async (url: string | URL | Request) => new Response(JSON.stringify(
|
||||
String(url).includes('/packages/container/')
|
||||
? { name: 'gstack/cso-staging/node-amd64', package_type: 'container', visibility: 'public', owner: { login: 'garrytan' } }
|
||||
: { login: 'garrytan', type: 'User' },
|
||||
), { status: 200 })) as typeof globalThis.fetch;
|
||||
await expect(verifyPublicGhcrImage(
|
||||
{ image: IMAGE, platform: 'linux/amd64', githubRepository: 'garrytan/gstack' },
|
||||
{ fetch: publicFetch, githubToken: 'metadata-token', dockerPath: '/usr/bin/docker', runDocker: async args => args.includes('inspect')
|
||||
? { exitCode: 0, stdout: JSON.stringify([IMAGE.replace(HASH, 'b'.repeat(64))]), stderr: '' }
|
||||
: { exitCode: 0, stdout: '', stderr: '' }, sleep: async () => {} },
|
||||
)).rejects.toThrow('ANONYMOUS_IMAGE_DIGEST_MISMATCH');
|
||||
});
|
||||
|
||||
test('requires scoped GitHub authentication for visibility metadata without passing it to Docker', async () => {
|
||||
let fetched = false, pulled = false;
|
||||
await expect(verifyPublicGhcrImage(
|
||||
{ image: IMAGE, platform: 'linux/amd64', githubRepository: 'garrytan/gstack' },
|
||||
{
|
||||
githubToken: '',
|
||||
dockerPath: '/usr/bin/docker',
|
||||
fetch: (async () => { fetched = true; return new Response('{}'); }) as typeof globalThis.fetch,
|
||||
runDocker: async () => { pulled = true; return { exitCode: 0, stdout: '', stderr: '' }; },
|
||||
},
|
||||
)).rejects.toThrow('GITHUB_PACKAGE_METADATA_TOKEN_REQUIRED');
|
||||
expect(fetched).toBe(false);
|
||||
expect(pulled).toBe(false);
|
||||
});
|
||||
|
||||
// The public-image release verifier runs in Linux CI. This case exercises
|
||||
// its process-kill path with a POSIX shebang fixture; Windows CreateProcess
|
||||
// cannot execute that fixture, while the metadata contract above remains
|
||||
// portable and continues to run in the curated Windows lane.
|
||||
test.skipIf(process.platform === 'win32')('kills a Docker client as soon as bounded output exceeds the release limit', async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cso-public-docker-test-'));
|
||||
const docker = path.join(directory, 'docker');
|
||||
fs.writeFileSync(docker, '#!/bin/sh\npython3 -c "import sys; sys.stdout.write(chr(120) * 70000)"\n', { mode: 0o700 });
|
||||
const fetch = (async (url: string | URL | Request) => new Response(JSON.stringify(
|
||||
String(url).includes('/packages/container/')
|
||||
? { name: 'gstack/cso-staging/node-amd64', package_type: 'container', visibility: 'public', owner: { login: 'garrytan' } }
|
||||
: { login: 'garrytan', type: 'User' },
|
||||
), { status: 200 })) as typeof globalThis.fetch;
|
||||
try {
|
||||
await expect(verifyPublicGhcrImage(
|
||||
{ image: IMAGE, platform: 'linux/amd64', githubRepository: 'garrytan/gstack' },
|
||||
{ fetch, githubToken: 'metadata-token', dockerPath: docker, sleep: async () => {} },
|
||||
)).rejects.toThrow('ANONYMOUS_DOCKER_OUTPUT_LIMIT');
|
||||
} finally { fs.rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { canonicalStartPlan, canonicalTestPlan, testExecutionPassed } from '../lib/cso/verification';
|
||||
|
||||
const python = process.platform === 'win32' ? undefined : Bun.which('python3');
|
||||
|
||||
function write(root:string, relative:string, body:string):void {
|
||||
const file=path.join(root,relative);fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,body);
|
||||
}
|
||||
|
||||
test.skipIf(!python)('Python test runners are imported before the application root without breaking app imports',()=>{
|
||||
const root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-python-shadow-'));
|
||||
try{
|
||||
const venv=path.join(root,'venv'),created=spawnSync(python!,['-m','venv',venv],{encoding:'utf8',timeout:120_000});
|
||||
expect(created.status,created.stderr).toBe(0);
|
||||
const venvPython=path.join(venv,'bin','python'),located=spawnSync(venvPython,['-c','import sysconfig;print(sysconfig.get_paths()["purelib"])'],{encoding:'utf8',timeout:30_000});
|
||||
expect(located.status,located.stderr).toBe(0);
|
||||
const fakePytest=path.join(located.stdout.trim(),'pytest','__init__.py');
|
||||
fs.mkdirSync(path.dirname(fakePytest),{recursive:true});
|
||||
fs.writeFileSync(fakePytest,`def main(args):
|
||||
import pathlib
|
||||
from app import VALUE
|
||||
config=pathlib.Path('.pytest.ini').read_text()
|
||||
pathlib.Path('pytest-observation').write_text(repr([VALUE,args,config]))
|
||||
return 0
|
||||
`);
|
||||
fs.writeFileSync(path.join(located.stdout.trim(),'hostile-startup.pth'),"import pathlib;pathlib.Path('pth-startup-ran').write_text('unsafe')\n");
|
||||
|
||||
const pytestRoot=path.join(root,'pytest-project');fs.mkdirSync(pytestRoot);
|
||||
write(pytestRoot,'app.py','VALUE = 42\n');
|
||||
write(pytestRoot,'.pytest.ini','[pytest]\naddopts = -q\n');
|
||||
write(pytestRoot,'tests/test_app.py','from app import VALUE\ndef test_value(): assert VALUE == 42\n');
|
||||
const pytestBefore=canonicalTestPlan(pytestRoot,'python');
|
||||
write(pytestRoot,'pytest.py','raise RuntimeError("application shadow replaced trusted pytest")\n');
|
||||
const pytestAfter=canonicalTestPlan(pytestRoot,'python');
|
||||
expect(pytestAfter).toEqual(pytestBefore);
|
||||
const pytestCommand=pytestBefore.commands[0];
|
||||
expect(pytestCommand.executable).toBe('/work/.venv/bin/python');
|
||||
expect(pytestCommand.args.slice(0,3)).toEqual(['-I','-S','-c']);
|
||||
expect(pytestCommand.args[3]).toContain('import pytest;sys.path.insert');
|
||||
expect(pytestCommand.args.slice(4)).toEqual(['-q','--color=no','--','./tests/test_app.py']);
|
||||
const ranPytest=spawnSync(venvPython,pytestCommand.args,{cwd:pytestRoot,encoding:'utf8',timeout:30_000});
|
||||
expect(ranPytest.status,ranPytest.stderr).toBe(0);
|
||||
expect(fs.readFileSync(path.join(pytestRoot,'pytest-observation'),'utf8')).toContain("[42, ['-q', '--color=no', '--', './tests/test_app.py'], '[pytest]\\naddopts = -q\\n']");
|
||||
expect(fs.existsSync(path.join(pytestRoot,'pth-startup-ran'))).toBe(false);
|
||||
|
||||
const unittestRoot=path.join(root,'unittest-project');fs.mkdirSync(unittestRoot);
|
||||
write(unittestRoot,'app.py','VALUE = 42\n');
|
||||
write(unittestRoot,'tests/test_app.py',`import unittest
|
||||
from app import VALUE
|
||||
class AppTest(unittest.TestCase):
|
||||
def test_value(self): self.assertEqual(VALUE, 42)
|
||||
`);
|
||||
const unittestBefore=canonicalTestPlan(unittestRoot,'python');
|
||||
write(unittestRoot,'unittest.py','raise RuntimeError("application shadow replaced standard unittest")\n');
|
||||
const unittestAfter=canonicalTestPlan(unittestRoot,'python');
|
||||
expect(unittestAfter).toEqual(unittestBefore);
|
||||
const unittestCommand=unittestBefore.commands[0];
|
||||
expect(unittestCommand.executable).toBe('/work/.venv/bin/python');
|
||||
expect(unittestCommand.args.slice(0,3)).toEqual(['-I','-S','-c']);
|
||||
expect(unittestCommand.args[3]).toContain("import os,sys,unittest;sys.path.append");
|
||||
expect(unittestCommand.args.slice(4)).toEqual(['./tests/test_app.py']);
|
||||
const ranUnittest=spawnSync(venvPython,unittestCommand.args,{cwd:unittestRoot,encoding:'utf8',timeout:30_000}),output=ranUnittest.stdout+ranUnittest.stderr;
|
||||
expect(ranUnittest.status,output).toBe(0);
|
||||
expect(output).toContain('Ran 1 test');
|
||||
expect(testExecutionPassed(unittestCommand,ranUnittest.status??-1,output)).toBe(true);
|
||||
expect(fs.existsSync(path.join(unittestRoot,'pth-startup-ran'))).toBe(false);
|
||||
|
||||
const fakeDjango=path.join(located.stdout.trim(),'django','__init__.py');fs.mkdirSync(path.dirname(fakeDjango),{recursive:true});fs.writeFileSync(fakeDjango,"ORIGIN = 'trusted-site-package'\n");
|
||||
const djangoRoot=path.join(root,'django-project');fs.mkdirSync(djangoRoot);write(djangoRoot,'requirements.txt',`Django==1.0.0 --hash=sha256:${'a'.repeat(64)}\n`);write(djangoRoot,'manage.py',"import pathlib,django\npathlib.Path('django-observation').write_text(django.ORIGIN)\n");
|
||||
const djangoCommand=canonicalStartPlan(djangoRoot,'python',3456).command;expect(djangoCommand.args.slice(0,3)).toEqual(['-I','-S','-c']);const ranDjango=spawnSync(venvPython,djangoCommand.args,{cwd:djangoRoot,encoding:'utf8',timeout:30_000});expect(ranDjango.status,ranDjango.stderr).toBe(0);expect(fs.readFileSync(path.join(djangoRoot,'django-observation'),'utf8')).toBe('trusted-site-package');expect(fs.existsSync(path.join(djangoRoot,'pth-startup-ran'))).toBe(false);
|
||||
}finally{fs.rmSync(root,{recursive:true,force:true});}
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { DockerGroup } from '../lib/cso/docker';
|
||||
import { DockerVerificationExecutor } from '../lib/cso/verification';
|
||||
|
||||
const roots:string[]=[];
|
||||
afterEach(()=>{for(const root of roots.splice(0))fs.rmSync(root,{recursive:true,force:true});});
|
||||
|
||||
describe('CSO Rails database verification lifecycle',()=>{
|
||||
test('creates fresh multi-database PostgreSQL per phase and prepares every private work tree',async()=>{
|
||||
const root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-rails-verification-'));roots.push(root);
|
||||
const source=path.join(root,'source'),work=path.join(root,'work'),control=path.join(root,'control');for(const dir of [source,work,control])fs.mkdirSync(dir);
|
||||
const events:string[]=[],policies:string[][]=[];let groupNumber=0;
|
||||
const create=spyOn(DockerGroup,'create').mockImplementation(async()=>{
|
||||
const group=++groupNumber;let next=0;
|
||||
return {
|
||||
anchor:`anchor-${group}`,
|
||||
createContainer:async(spec:any)=>{const id=`${group}-${spec.role}-${++next}`;events.push(`create:${id}:${spec.command.join(' ')}`);if(spec.postgresDatabasePolicy){policies.push(fs.readFileSync(spec.postgresDatabasePolicy,'utf8').trim().split('\n'));expect(fs.statSync(spec.postgresDatabasePolicy).mode&0o777).toBe(0o444);}return id;},
|
||||
start:async(id:string)=>{events.push(`start:${id}`);},
|
||||
execCapture:async(id:string,command:string[])=>{events.push(`exec:${id}:${command.join(' ')}`);return{code:0,stdout:'',stderr:''};},
|
||||
execDetached:async(id:string,command:string[])=>{events.push(`detach:${id}:${command.join(' ')}`);},
|
||||
startAttach:async(id:string)=>{events.push(`attach:${id}`);return{code:0,output:JSON.stringify({booted:true,legitimate:true,security:'pass',existingTests:false,output:'ok',inputHash:''})};},
|
||||
removeContainer:async(id:string)=>{events.push(`remove:${id}`);},cleanup:async()=>{events.push(`cleanup:${group}`);},
|
||||
} as any;
|
||||
});
|
||||
try{
|
||||
const runtime:any={id:'rails',stack:'rails',image:`runtime@sha256:${'a'.repeat(64)}`,platform:'linux/amd64'};
|
||||
const request:any={findingId:'b'.repeat(32),port:3456,start:{executable:'/usr/local/bin/bundle',args:['exec','rails','server']},
|
||||
legitimate:[],security:{},fixtures:{},existingTests:[{executable:'/usr/local/bin/bundle',args:['exec','rspec']},{executable:'/usr/local/bin/bundle',args:['exec','rails','test']}]};
|
||||
const executor=new DockerVerificationExecutor({} as any,'/watchdog',Date.now()+60_000);
|
||||
const execution:any={environment:{BUNDLE_PATH:'/work/vendor/bundle'},database:{adapter:'postgresql',connections:['primary','queue'],sidecar:{id:'postgresql-17',image:`postgres@sha256:${'c'.repeat(64)}`}}};
|
||||
await executor.observe(source,'before',request,runtime,runtime,work,control,execution);
|
||||
await executor.observe(source,'after',request,runtime,runtime,work,control,execution);
|
||||
}finally{create.mockRestore();}
|
||||
expect(policies).toEqual([['cso_primary','cso_queue'],['cso_primary','cso_queue']]);
|
||||
expect(events.filter(event=>event.includes('/opt/cso/postgresql-ready'))).toHaveLength(2);
|
||||
expect(events.filter(event=>event.includes('/usr/local/bin/bundle exec rails db:prepare'))).toHaveLength(6);
|
||||
for(const group of [1,2]){
|
||||
const removeApp=events.findIndex(event=>event===`remove:${group}-app-2`),createFirstTest=events.findIndex(event=>event.startsWith(`create:${group}-tests-4:`));
|
||||
expect(removeApp).toBeGreaterThan(-1);expect(createFirstTest).toBeGreaterThan(removeApp);
|
||||
}
|
||||
});
|
||||
|
||||
test('SQLite prepares the app and each test copy without a sidecar',async()=>{
|
||||
const root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-rails-sqlite-'));roots.push(root);for(const name of ['source','work','control'])fs.mkdirSync(path.join(root,name));
|
||||
const commands:string[]=[];let next=0;
|
||||
const create=spyOn(DockerGroup,'create').mockResolvedValue({
|
||||
createContainer:async(spec:any)=>{commands.push(`create:${spec.role}`);return`${spec.role}-${++next}`;},start:async()=>{},
|
||||
execCapture:async(_id:string,command:string[])=>{commands.push(command.join(' '));return{code:0,stdout:'',stderr:''};},execDetached:async()=>{},
|
||||
startAttach:async()=>({code:0,output:JSON.stringify({booted:true,legitimate:true,security:'pass',existingTests:false,output:'ok',inputHash:''})}),removeContainer:async()=>{},cleanup:async()=>{},
|
||||
} as any);
|
||||
try{const runtime:any={stack:'rails',image:`runtime@sha256:${'a'.repeat(64)}`},request:any={findingId:'d'.repeat(32),port:3456,start:{executable:'/usr/local/bin/bundle',args:['exec','rails','server']},legitimate:[],security:{},fixtures:{},existingTests:[{executable:'/usr/local/bin/bundle',args:['exec','rails','test']}]};await new DockerVerificationExecutor({} as any,'/watchdog',Date.now()+60_000).observe(path.join(root,'source'),'before',request,runtime,runtime,path.join(root,'work'),path.join(root,'control'),{environment:{},database:{adapter:'sqlite',connections:['primary']}});}finally{create.mockRestore();}
|
||||
expect(commands.some(command=>command==='create:postgres')).toBe(false);expect(commands.filter(command=>command.includes('exec rails db:prepare'))).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as net from 'node:net';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import {
|
||||
REGISTRY_SOCKET_PATH_MAX_BYTES,
|
||||
RegistryEgressBroker,
|
||||
superviseRegistrySocket,
|
||||
type SupervisedRegistrySocket,
|
||||
} from '../lib/cso/preparation-docker';
|
||||
|
||||
const posixDescribe = process.platform === 'win32' ? describe.skip : describe;
|
||||
|
||||
posixDescribe('short supervised CSO registry sockets', () => {
|
||||
let fixtureRoot = '';
|
||||
let watchdogPath = '';
|
||||
|
||||
beforeAll(() => {
|
||||
fixtureRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cso-registry-socket-')));
|
||||
watchdogPath = path.join(fixtureRoot, 'watchdog');
|
||||
const result = spawnSync('/usr/bin/cc', ['-std=c11', '-D_POSIX_C_SOURCE=200809L', '-O2', '-Wall', '-Wextra',
|
||||
path.resolve(import.meta.dir, '../lib/cso/watchdog.c'), '-o', watchdogPath], { encoding: 'utf8', timeout: 30_000 });
|
||||
expect(result.status).toBe(0); expect(result.stderr).toBe('');
|
||||
});
|
||||
|
||||
afterAll(() => { if (fixtureRoot) fs.rmSync(fixtureRoot, { recursive: true, force: true }); });
|
||||
|
||||
async function close(handle: SupervisedRegistrySocket | undefined): Promise<void> {
|
||||
if (!handle) return;
|
||||
await handle.dispose();
|
||||
}
|
||||
|
||||
test('binds and connects through a private collision-resistant path below the Darwin limit', async () => {
|
||||
const first = await superviseRegistrySocket({ watchdogPath, ownerPid: process.pid, deadline: Date.now() + 10_000 });
|
||||
const second = await superviseRegistrySocket({ watchdogPath, ownerPid: process.pid, deadline: Date.now() + 10_000 });
|
||||
const broker = new RegistryEgressBroker(first.socketPath, ['registry.npmjs.org'], Date.now() + 10_000, 1024 * 1024);
|
||||
try {
|
||||
expect(first.root).not.toBe(second.root);
|
||||
expect(Buffer.byteLength(first.socketPath)).toBeLessThanOrEqual(REGISTRY_SOCKET_PATH_MAX_BYTES);
|
||||
expect(Buffer.byteLength(second.socketPath)).toBeLessThanOrEqual(REGISTRY_SOCKET_PATH_MAX_BYTES);
|
||||
for (const handle of [first, second]) {
|
||||
expect(path.basename(handle.root)).toMatch(/^gscso-\d+-[a-f0-9]{32}$/);
|
||||
const stat = fs.lstatSync(handle.root);
|
||||
expect(stat.isDirectory()).toBe(true); expect(stat.isSymbolicLink()).toBe(false);
|
||||
expect(stat.mode & 0o077).toBe(0); if (process.getuid) expect(stat.uid).toBe(process.getuid());
|
||||
expect(fs.realpathSync(handle.root)).toBe(handle.root);
|
||||
}
|
||||
await broker.start();
|
||||
const socketStat = fs.lstatSync(first.socketPath);
|
||||
expect(socketStat.isSocket()).toBe(true); expect(socketStat.isSymbolicLink()).toBe(false);
|
||||
expect(socketStat.mode & 0o077).toBe(0); if (process.getuid) expect(socketStat.uid).toBe(process.getuid());
|
||||
const reply = await new Promise<string>((resolveReply, reject) => {
|
||||
const socket = net.createConnection({ path: first.socketPath }); let output = '';
|
||||
socket.once('connect', () => socket.write('CONNECT denied.example:443 HTTP/1.1\r\nHost: denied.example:443\r\n\r\n'));
|
||||
socket.on('data', chunk => { output += chunk.toString(); }); socket.once('end', () => resolveReply(output)); socket.once('error', reject);
|
||||
});
|
||||
expect(reply).toContain('403 Forbidden');
|
||||
} finally {
|
||||
await broker.close(); await close(first); await close(second);
|
||||
}
|
||||
expect(fs.existsSync(first.root)).toBe(false); expect(fs.existsSync(second.root)).toBe(false);
|
||||
});
|
||||
|
||||
test('owner-death cleanup removes the exact socket root while preserving an unrelated concurrent root', async () => {
|
||||
const owner = spawn('/bin/sleep', ['30'], { stdio: 'ignore' });
|
||||
const doomed = await superviseRegistrySocket({ watchdogPath, ownerPid: owner.pid!, deadline: Date.now() + 10_000 });
|
||||
const survivor = await superviseRegistrySocket({ watchdogPath, ownerPid: process.pid, deadline: Date.now() + 10_000 });
|
||||
const broker = new RegistryEgressBroker(doomed.socketPath, ['registry.npmjs.org'], Date.now() + 10_000, 1024 * 1024);
|
||||
try {
|
||||
await broker.start(); owner.kill('SIGKILL'); await new Promise(resolve => owner.once('close', resolve));
|
||||
for (let attempt = 0; attempt < 50 && fs.existsSync(doomed.root); attempt++) await Bun.sleep(100);
|
||||
expect(fs.existsSync(doomed.root)).toBe(false);
|
||||
expect(fs.existsSync(survivor.root)).toBe(true);
|
||||
} finally {
|
||||
try { owner.kill('SIGKILL'); } catch {}
|
||||
await broker.close(); await close(doomed); await close(survivor);
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
test('deadline cleanup removes the whole per-call socket root without a global temp sweep', async () => {
|
||||
const doomed = await superviseRegistrySocket({ watchdogPath, ownerPid: process.pid, deadline: Date.now() + 150 });
|
||||
const survivor = await superviseRegistrySocket({ watchdogPath, ownerPid: process.pid, deadline: Date.now() + 10_000 });
|
||||
const broker = new RegistryEgressBroker(doomed.socketPath, ['registry.npmjs.org'], Date.now() + 10_000, 1024 * 1024);
|
||||
try {
|
||||
await broker.start(); fs.writeFileSync(path.join(doomed.root, 'owned-fixture'), 'bounded');
|
||||
for (let attempt = 0; attempt < 50 && fs.existsSync(doomed.root); attempt++) await Bun.sleep(100);
|
||||
expect(fs.existsSync(doomed.root)).toBe(false);
|
||||
expect(fs.existsSync(survivor.root)).toBe(true);
|
||||
} finally {
|
||||
await broker.close(); await close(doomed); await close(survivor);
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
test('the detached cleaner refuses a same-path replacement instead of deleting it', async () => {
|
||||
const base = fs.realpathSync('/tmp'), root = fs.mkdtempSync(path.join(base, 'gscso-identity-')),
|
||||
control = path.join(root, 'control'), moved = `${root}.original`;
|
||||
fs.chmodSync(root, 0o700); fs.mkdirSync(control, { mode: 0o700 });
|
||||
const owner = spawn('/bin/sleep', ['30'], { stdio: 'ignore' });
|
||||
const child = spawn(watchdogPath, ['--ephemeral-owner', String(owner.pid), '--deadline', String(Math.ceil((Date.now() + 10_000) / 1000)),
|
||||
'--control-dir', control, '--work-root', root, '--run-root', base], { cwd: control, env: { PATH: '/usr/bin:/bin' }, stdio: 'ignore' });
|
||||
const ownerClosed = new Promise(resolve => owner.once('close', resolve)), childClosed = new Promise(resolve => child.once('close', resolve));
|
||||
try {
|
||||
for (let attempt = 0; attempt < 100 && !fs.existsSync(path.join(control, 'attempt.ready')); attempt++) await Bun.sleep(10);
|
||||
expect(fs.existsSync(path.join(control, 'attempt.ready'))).toBe(true);
|
||||
fs.renameSync(root, moved); fs.mkdirSync(root, { mode: 0o700 }); fs.mkdirSync(control, { mode: 0o700 });
|
||||
owner.kill('SIGKILL'); await ownerClosed;
|
||||
const event = path.join(control, 'attempt.event');
|
||||
for (let attempt = 0; attempt < 50 && !fs.existsSync(event); attempt++) await Bun.sleep(100);
|
||||
expect(fs.readFileSync(event, 'utf8')).toContain('identity changed; refusing removal');
|
||||
expect(fs.existsSync(root)).toBe(true); expect(fs.existsSync(moved)).toBe(true);
|
||||
} finally {
|
||||
try { owner.kill('SIGKILL'); } catch {} child.kill('SIGKILL');
|
||||
await childClosed;
|
||||
if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true });
|
||||
if (fs.existsSync(moved)) fs.rmSync(moved, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import committedCatalog from '../lib/cso/runtime-catalog.json';
|
||||
import buildInputs from '../lib/cso/images/build-inputs.json';
|
||||
import { validateRuntimeCatalog } from '../lib/cso/runtime-catalog';
|
||||
import { imageBuildMatrix, type ImageBuildRow } from '../scripts/cso-image-matrix';
|
||||
import { catalogPromotionCandidate, validateRuntimeCatalogTransition, type RuntimeQualificationStatement } from '../scripts/cso-runtime-promotion';
|
||||
import { assertVersionOutput, probesForBuildRow } from '../scripts/cso-verify-runtime-base';
|
||||
|
||||
const digest = (value: string) => `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
||||
|
||||
function checks(row: ImageBuildRow): Record<string, true> {
|
||||
if (row.stack === 'postgresql') return {
|
||||
containmentPassed: true,
|
||||
coldStartPassed: true,
|
||||
multiDatabasePassed: true,
|
||||
readinessPassed: true,
|
||||
secretCanaryPassed: true,
|
||||
watchdogCleanupPassed: true,
|
||||
};
|
||||
const value: Record<string, true> = {
|
||||
accuracyGatesPassed: true,
|
||||
acquisitionPublicOnlyPassed: true,
|
||||
coldStartPassed: true,
|
||||
containmentPassed: true,
|
||||
heldOutRepairPassed: true,
|
||||
offlineLifecyclePassed: true,
|
||||
positiveNegativeAssertionsPassed: true,
|
||||
secretCanaryPassed: true,
|
||||
watchdogCleanupPassed: true,
|
||||
};
|
||||
if (row.stack === 'rails') {
|
||||
value.nativeExtensionsPassed = true;
|
||||
value.railsPostgresqlPassed = true;
|
||||
value.railsSqlitePassed = true;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function statements(): RuntimeQualificationStatement[] {
|
||||
return imageBuildMatrix(buildInputs).include.map(row => ({
|
||||
schemaVersion: 1,
|
||||
helperAbi: 3,
|
||||
state: 'qualified',
|
||||
buildRevision: row.inputRevision,
|
||||
runtimeId: row.runtimeId,
|
||||
stack: row.stack,
|
||||
platform: row.platform,
|
||||
image: `ghcr.io/garrytan/gstack/cso-staging/${row.stack}-${row.arch}@${digest(row.runtimeId)}`,
|
||||
versions: row.versions,
|
||||
sourceCommit: 'a'.repeat(40),
|
||||
workflow: 'https://github.com/garrytan/gstack/actions/runs/123456',
|
||||
qualifiedAt: '2026-09-10T12:00:00.000Z',
|
||||
sbomDigest: digest(`sbom:${row.runtimeId}`),
|
||||
provenanceDigest: digest(`provenance:${row.runtimeId}`),
|
||||
checks: checks(row),
|
||||
}));
|
||||
}
|
||||
|
||||
describe('CSO runtime catalog promotion', () => {
|
||||
test('committed build review declares both native profiles without admitting unpublished images', () => {
|
||||
const matrix = imageBuildMatrix(buildInputs);
|
||||
expect(matrix.include).toHaveLength(10);
|
||||
expect(committedCatalog.profiles).toHaveLength(10);
|
||||
expect(committedCatalog.runtimes).toEqual([]);
|
||||
expect(committedCatalog.buildRevision).toBe(buildInputs.revision);
|
||||
expect(matrix.include.find(row => row.stack === 'bun')!.versions).not.toHaveProperty('node');
|
||||
validateRuntimeCatalog(committedCatalog);
|
||||
});
|
||||
|
||||
test('complete same-run evidence generates a validated review candidate bound to its evidence', () => {
|
||||
const candidate = catalogPromotionCandidate(committedCatalog, buildInputs, statements());
|
||||
expect(candidate.previousRevision).toBe(committedCatalog.revision);
|
||||
expect(candidate.runtimes).toHaveLength(10);
|
||||
expect(candidate.promotion).toMatchObject({
|
||||
sourceCommit: 'a'.repeat(40),
|
||||
workflow: 'https://github.com/garrytan/gstack/actions/runs/123456',
|
||||
});
|
||||
expect(candidate.promotion!.evidenceDigest).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
expect(candidate.promotion!.qualificationEvidenceDigest).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
expect(candidate.runtimes.every(runtime => runtime.image.includes('@sha256:'))).toBe(true);
|
||||
validateRuntimeCatalog(candidate);
|
||||
});
|
||||
|
||||
test('missing release checks, mutable images, and split qualification runs cannot be promoted', () => {
|
||||
const missing = statements();
|
||||
delete missing[0].checks.heldOutRepairPassed;
|
||||
expect(() => catalogPromotionCandidate(committedCatalog, buildInputs, missing)).toThrow('MISSING_RELEASE_GATES');
|
||||
|
||||
const mutable = statements();
|
||||
mutable[0].image = 'ghcr.io/garrytan/gstack/cso-staging/node-amd64:latest';
|
||||
expect(() => catalogPromotionCandidate(committedCatalog, buildInputs, mutable)).toThrow('INVALID_QUALIFIED_IMAGE');
|
||||
|
||||
const split = statements();
|
||||
split[0].workflow = 'https://github.com/garrytan/gstack/actions/runs/999999';
|
||||
expect(() => catalogPromotionCandidate(committedCatalog, buildInputs, split)).toThrow('SPLIT_QUALIFICATION_RUN');
|
||||
|
||||
const extra = statements() as Array<RuntimeQualificationStatement & { finding?: string }>;
|
||||
extra[0].finding = 'must not enter a public release artifact';
|
||||
expect(() => catalogPromotionCandidate(committedCatalog, buildInputs, extra)).toThrow('INVALID_QUALIFICATION_STATEMENT_FIELDS');
|
||||
});
|
||||
|
||||
test('qualification statements must match reviewed versions and every expected platform row', () => {
|
||||
const drift = statements();
|
||||
drift[0].versions = { ...drift[0].versions, node: '24.5.0' };
|
||||
expect(() => catalogPromotionCandidate(committedCatalog, buildInputs, drift)).toThrow('QUALIFICATION_BUILD_MISMATCH');
|
||||
expect(() => catalogPromotionCandidate(committedCatalog, buildInputs, statements().slice(1))).toThrow('INCOMPLETE_QUALIFICATION_MATRIX');
|
||||
});
|
||||
|
||||
test('promotion is a compare-and-swap against the exact reviewed catalog revision', () => {
|
||||
const candidate = catalogPromotionCandidate(committedCatalog, buildInputs, statements());
|
||||
expect(() => validateRuntimeCatalogTransition(committedCatalog, candidate)).not.toThrow();
|
||||
expect(() => validateRuntimeCatalogTransition(
|
||||
{ ...committedCatalog, revision: 'catalog-advanced-concurrently' },
|
||||
candidate,
|
||||
)).toThrow('RUNTIME_CATALOG_BASE_REVISION_MISMATCH');
|
||||
expect(() => validateRuntimeCatalogTransition(committedCatalog, {
|
||||
...candidate,
|
||||
profiles: candidate.profiles.map((profile, index) => index === 0
|
||||
? { ...profile, reviewedAt: '2026-09-11T00:00:00.000Z' }
|
||||
: profile),
|
||||
})).toThrow('RUNTIME_CATALOG_PROFILE_TRANSITION_MISMATCH');
|
||||
});
|
||||
|
||||
test('retained promotion evidence rejects a post-generation runtime image substitution', () => {
|
||||
const candidate = catalogPromotionCandidate(committedCatalog, buildInputs, statements());
|
||||
const altered = structuredClone(candidate);
|
||||
altered.runtimes[0].image = altered.runtimes[0].image.replace(/[a-f0-9]{64}$/, 'e'.repeat(64));
|
||||
expect(altered.promotion!.evidenceDigest).toBe(candidate.promotion!.evidenceDigest);
|
||||
expect(() => validateRuntimeCatalog(altered)).toThrow('RUNTIME_PROMOTION_EVIDENCE_MISMATCH');
|
||||
expect(() => validateRuntimeCatalogTransition(committedCatalog, altered)).toThrow('RUNTIME_PROMOTION_EVIDENCE_MISMATCH');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO reviewed base version probes', () => {
|
||||
test('uses only real executables declared by each base profile', () => {
|
||||
const rows = imageBuildMatrix(buildInputs).include.filter(row => row.platform === 'linux/amd64');
|
||||
expect(probesForBuildRow(rows.find(row => row.stack === 'node')!).map(item => item.name)).toEqual(['node', 'npm']);
|
||||
expect(probesForBuildRow(rows.find(row => row.stack === 'bun')!).map(item => item.name)).toEqual(['bun']);
|
||||
expect(probesForBuildRow(rows.find(row => row.stack === 'python')!).map(item => item.name)).toEqual(['python', 'uv']);
|
||||
expect(probesForBuildRow(rows.find(row => row.stack === 'rails')!).map(item => item.name)).toEqual(['ruby', 'bundler']);
|
||||
expect(probesForBuildRow(rows.find(row => row.stack === 'postgresql')!).map(item => item.name)).toEqual(['postgresql']);
|
||||
});
|
||||
|
||||
test('rejects a probe that reports any different release', () => {
|
||||
expect(() => assertVersionOutput('node', '24.4.0', 'v24.4.0\n')).not.toThrow();
|
||||
expect(() => assertVersionOutput('node', '24.4.0', 'v24.5.0\n')).toThrow('RUNTIME_VERSION_MISMATCH');
|
||||
expect(() => assertVersionOutput('bundler', '2.6.7', 'Bundler version 2.6.7\n')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { withLock } from '../lib/cso/state';
|
||||
|
||||
const launcher = resolve(import.meta.dir, '../bin/gstack-cso-launcher');
|
||||
let root = '', repo = '', state = '', sarif = '';
|
||||
const env = () => ({ HOME: root, GSTACK_HOME: state, PATH: '/usr/bin:/bin' });
|
||||
function command(args: string[]) { return spawnSync(launcher, args, { cwd: repo, env: env(), encoding: 'utf8', timeout: 30000 }); }
|
||||
function start() {
|
||||
const r = command(['start', '--repo', repo, '--offline']); expect(r.status).toBe(0);
|
||||
const report = JSON.parse(r.stdout), dir = join(state, 'security/cso', report.repoId, report.runId);
|
||||
return { runId: report.runId as string, dir, report: () => JSON.parse(fs.readFileSync(join(dir, 'report.json'), 'utf8')), artifacts: () => fs.existsSync(join(dir, 'scanner-outcomes')) ? fs.readdirSync(join(dir, 'scanner-outcomes')) : [] };
|
||||
}
|
||||
beforeAll(() => {
|
||||
root = fs.mkdtempSync(join(fs.realpathSync(os.tmpdir()), 'cso-scanner-cli-')); repo = join(root, 'repo'); state = join(root, 'state'); fs.mkdirSync(repo);
|
||||
for (const args of [['init', '-q'], ['config', 'user.email', 'test@example.test'], ['config', 'user.name', 'Test']]) { const r = spawnSync('/usr/bin/git', ['-C', repo, ...args], { env: env(), encoding: 'utf8', timeout: 30000 }); expect(r.status).toBe(0); }
|
||||
fs.writeFileSync(join(repo, 'app.js'), 'export const example = true;\n');
|
||||
for (const args of [['add', 'app.js'], ['commit', '-qm', 'fixture']]) expect(spawnSync('/usr/bin/git', ['-C', repo, ...args], { env: env(), encoding: 'utf8', timeout: 30000 }).status).toBe(0);
|
||||
sarif = join(root, 'results.sarif'); fs.writeFileSync(sarif, JSON.stringify({ version: '2.1.0', runs: [{ tool: { driver: { name: 'CodeQL', version: '2.22.0' } }, results: [{ ruleId: 'js/test', message: { text: 'Candidate defect' }, locations: [{ physicalLocation: { artifactLocation: { uri: 'app.js' }, region: { startLine: 1 } } }] }] }] }));
|
||||
});
|
||||
afterAll(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('compiled scanner evidence persistence', () => {
|
||||
test('scan uses the empty qualified catalog and records helper-owned coverage without host execution', () => {
|
||||
const run = start(), r = command(['scan', run.runId, 'gitleaks']); expect(r.status).toBe(0);
|
||||
const out = JSON.parse(r.stdout); expect(out.status).toBe('not_assessed'); expect(out.gaps[0].message).toContain('No qualified gitleaks');
|
||||
expect(out.artifactId).toMatch(/^gitleaks-[a-f0-9]{16}-[a-f0-9]{16}$/);
|
||||
expect(run.artifacts()).toEqual([`${out.artifactId}.json`]);
|
||||
const stored = JSON.parse(fs.readFileSync(join(run.dir,out.artifact), 'utf8')); expect(stored.provenance.image).toBeNull(); expect(stored.outcome).not.toHaveProperty('repair');
|
||||
expect(run.report().coverage.find((x: any) => x.domain === 'scanner:gitleaks')).toMatchObject({ status: 'not_assessed' });
|
||||
});
|
||||
test('repeated same-content SARIF imports get distinct immutable artifacts and coverage records', () => {
|
||||
const run = start(), first = command(['import-sarif', run.runId, sarif]); expect(first.status).toBe(0);
|
||||
const firstOut = JSON.parse(first.stdout), before = fs.readFileSync(join(run.dir,firstOut.artifact), 'utf8');
|
||||
const second = command(['import-sarif', run.runId, sarif]); expect(second.status).toBe(0); const secondOut = JSON.parse(second.stdout);
|
||||
expect(firstOut.artifactId).not.toBe(secondOut.artifactId); expect(run.artifacts()).toHaveLength(2); expect(fs.readFileSync(join(run.dir,firstOut.artifact), 'utf8')).toBe(before);
|
||||
const coverage = run.report().coverage.filter((x: any) => x.domain === 'scanner:sarif'); expect(coverage).toHaveLength(2);
|
||||
expect(coverage.every((x: any) => x.evidence.some((e: string) => e.startsWith('Immutable outcome:')))).toBe(true);
|
||||
});
|
||||
test('model submissions cannot erase a scanner failure or replace imported scanner evidence', () => {
|
||||
const run = start(); expect(command(['scan', run.runId, 'gitleaks']).status).toBe(0);
|
||||
const reportBefore = fs.readFileSync(join(run.dir, 'report.json'), 'utf8');
|
||||
const file = join(root, 'scanner-coverage.json'); fs.writeFileSync(file, JSON.stringify({ coverage: [{ domain: 'scanner:gitleaks', scope: 'default', status: 'assessed', method: 'model says clean', gaps: [], exclusions: [], evidence: ['claim'] }] }));
|
||||
const r = command(['submit', run.runId, file]); expect(r.status).not.toBe(0); expect(r.stderr).toContain('helper-owned'); expect(fs.readFileSync(join(run.dir, 'report.json'), 'utf8')).toBe(reportBefore);
|
||||
});
|
||||
test('finished reports reject scanner execution and SARIF persistence without changing artifacts', () => {
|
||||
const run = start(); expect(command(['finish', run.runId]).status).toBe(0); const before = fs.readFileSync(join(run.dir, 'report.json'), 'utf8');
|
||||
for (const args of [['scan', run.runId, 'gitleaks'], ['import-sarif', run.runId, sarif]]) { const r = command(args); expect(r.status).not.toBe(0); expect(r.stderr).toContain('running audit'); }
|
||||
expect(run.artifacts()).toEqual([]); expect(fs.readFileSync(join(run.dir, 'report.json'), 'utf8')).toBe(before);
|
||||
});
|
||||
test('a live mutation lock protects both scan and SARIF writes', () => {
|
||||
const run = start();
|
||||
const before = fs.readFileSync(join(run.dir, 'report.json'), 'utf8');
|
||||
withLock(run.dir,()=>{for (const args of [['scan', run.runId, 'gitleaks'], ['import-sarif', run.runId, sarif]]) { const r = command(args); expect(r.status).not.toBe(0); expect(r.stderr).toContain('INSUFFICIENT_CAPACITY'); }});
|
||||
expect(run.artifacts()).toEqual([]); expect(fs.readFileSync(join(run.dir, 'report.json'), 'utf8')).toBe(before);
|
||||
});
|
||||
test('concurrent imports preserve every successful artifact and never lose a coverage update', async () => {
|
||||
const run = start();
|
||||
const attempt = async () => { const child = Bun.spawn([launcher, 'import-sarif', run.runId, sarif], { cwd: repo, env: env(), stdout: 'pipe', stderr: 'pipe' }); const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); return { code, stdout, stderr }; };
|
||||
const results = await Promise.all([attempt(), attempt()]), successes = results.filter(r => r.code === 0);
|
||||
expect(successes.length).toBeGreaterThan(0); for (const failure of results.filter(r => r.code !== 0)) expect(failure.stderr).toContain('INSUFFICIENT_CAPACITY');
|
||||
expect(run.artifacts()).toHaveLength(successes.length); expect(run.report().coverage.filter((c: any) => c.domain === 'scanner:sarif')).toHaveLength(successes.length);
|
||||
expect(new Set(successes.map(r => JSON.parse(r.stdout).artifactId)).size).toBe(successes.length);
|
||||
});
|
||||
test('malformed SARIF remains a persisted coverage failure', () => {
|
||||
const run = start(), malformed = join(root, 'broken.sarif'); fs.writeFileSync(malformed, '{');
|
||||
const r = command(['import-sarif', run.runId, malformed]); expect(r.status).toBe(0); expect(JSON.parse(r.stdout).status).toBe('not_assessed');
|
||||
expect(run.report().coverage.find((c: any) => c.domain === 'scanner:sarif')).toMatchObject({ status: 'not_assessed' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { canonical, sha256 } from '../lib/cso/contracts';
|
||||
import { dockerEndpoint, ISOLATION_POLICY_HASH } from '../lib/cso/docker';
|
||||
import { createDockerScannerRunner, ScannerRunInput } from '../lib/cso/scanner-executor';
|
||||
import { QualifiedRuntime } from '../lib/cso/runtime-catalog';
|
||||
import { assertScannerVersionOutput, QualifiedScanner, scannerVersionHash } from '../lib/cso/scanner-catalog';
|
||||
import { ScannerId, parseScannerOutput, scannerPlans } from '../lib/cso/scanners';
|
||||
import { secureDirectory } from '../lib/cso/state';
|
||||
|
||||
const requested = process.env.GSTACK_CSO_SCANNER_DOCKER_TESTS === '1';
|
||||
const suite = requested ? describe : describe.skip;
|
||||
const HASH = 'a'.repeat(64), DIGEST = `sha256:${HASH}`;
|
||||
let root = '', source = '', watchdog = '', staged: any;
|
||||
|
||||
describe('scanner qualification version evidence',()=>{
|
||||
test.each([
|
||||
['gitleaks','gitleaks version 8.30.1','8.30.1'],
|
||||
['osv','osv-scanner version: 2.4.0','2.4.0'],
|
||||
['semgrep','1.136.0','1.136.0'],
|
||||
['zizmor','zizmor 1.11.2','1.11.2'],
|
||||
['trivy','Version: 0.67.2','0.67.2'],
|
||||
['schemathesis','schemathesis, version 4.5.2','4.5.2'],
|
||||
] as const)('accepts a supported real version layout: %s %s',(scanner,output,version)=>{
|
||||
expect(()=>assertScannerVersionOutput(scanner,version,`${output}\n`)).not.toThrow();
|
||||
});
|
||||
test.each(['11.2.3','1.2.30','1.2.3-dev','prefix1.2.3','1.2.3suffix'])('rejects a substring version match: %s',output=>{
|
||||
expect(()=>assertScannerVersionOutput('gitleaks','1.2.3',output)).toThrow('exact catalog version');
|
||||
});
|
||||
test('rejects an expected version that appears only in secondary metadata',()=>{
|
||||
expect(()=>assertScannerVersionOutput('gitleaks','1.2.3','gitleaks version 9.9.9\nruntime 1.2.3\n')).toThrow('primary version');
|
||||
});
|
||||
test('uses the same 8192-byte output boundary as production execution',()=>{
|
||||
const prefix='gitleaks version 1.2.3\n',within=prefix+'x'.repeat(8192-Buffer.byteLength(prefix));
|
||||
expect(()=>assertScannerVersionOutput('gitleaks','1.2.3',within)).not.toThrow();
|
||||
expect(()=>assertScannerVersionOutput('gitleaks','1.2.3',within+'x')).toThrow('bounded output');
|
||||
});
|
||||
});
|
||||
|
||||
function write(name: string, body: string): void {
|
||||
const target = path.join(source, name); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, body, { mode: 0o600 });
|
||||
}
|
||||
function fixture(): void {
|
||||
write('app.js', 'export const scannerQualification = true;\n');
|
||||
write('package.json', '{"name":"cso-scanner-qualification","version":"1.0.0"}\n');
|
||||
write('package-lock.json', '{"name":"cso-scanner-qualification","version":"1.0.0","lockfileVersion":3,"packages":{"":{"name":"cso-scanner-qualification","version":"1.0.0"}}}\n');
|
||||
write('.github/workflows/qualification.yml', 'name: qualification\non: [push]\npermissions: {}\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo qualification\n');
|
||||
write('server.py', [
|
||||
'from http.server import BaseHTTPRequestHandler, HTTPServer',
|
||||
'class H(BaseHTTPRequestHandler):',
|
||||
' def do_GET(self):',
|
||||
" body = b'CONTROL_OK' if self.path == '/control' else b'[]'",
|
||||
' self.send_response(200)',
|
||||
" self.send_header('Content-Type', 'application/json')",
|
||||
" self.send_header('Content-Length', str(len(body)))",
|
||||
' self.end_headers(); self.wfile.write(body)',
|
||||
' def log_message(self, *args): pass',
|
||||
"HTTPServer(('127.0.0.1', 34620), H).serve_forever()",
|
||||
].join('\n') + '\n');
|
||||
}
|
||||
function request() {
|
||||
return { api: { runtimeProfile: 'qualification-runtime', port: 34620,
|
||||
start: { executable: staged.applicationExecutable, args: ['server.py'] },
|
||||
control: { name: 'qualification control', path: '/control', method: 'GET' as const, expected: { status: 200, includes: 'CONTROL_OK' } },
|
||||
boundaryFiles: ['server.py'], operationIds: ['listItems'],
|
||||
schema: { openapi: '3.1.0', info: { title: 'CSO scanner qualification', version: '1.0.0' }, paths: { '/items': { get: { operationId: 'listItems', responses: { '200': { description: 'items' } } } } } } } };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!requested) return;
|
||||
const file = process.env.GSTACK_CSO_SCANNER_PROFILE;
|
||||
if (!file) throw new Error('Scanner Docker qualification requires GSTACK_CSO_SCANNER_PROFILE');
|
||||
staged = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
if (!['gitleaks', 'osv', 'semgrep', 'zizmor', 'trivy', 'schemathesis'].includes(staged.scanner) || !/^.+@sha256:[a-f0-9]{64}$/.test(staged.image) || !/^[0-9][A-Za-z0-9.+_-]{0,100}$/.test(staged.version) || !Array.isArray(staged.capabilities)) throw new Error('Scanner Docker qualification profile is invalid');
|
||||
if (staged.isolationPolicyHash !== ISOLATION_POLICY_HASH) throw new Error('Scanner Docker qualification profile does not match the helper isolation policy');
|
||||
if (!process.env.GSTACK_CSO_SCANNER_VERSION_HASH) throw new Error('Scanner Docker qualification requires GSTACK_CSO_SCANNER_VERSION_HASH');
|
||||
const platform = process.arch === 'arm64' ? 'linux/arm64' : 'linux/amd64';
|
||||
if (staged.platform !== platform) throw new Error(`Scanner Docker qualification requires native ${platform}`);
|
||||
if (staged.scanner === 'schemathesis' && (typeof staged.applicationExecutable !== 'string' || !staged.applicationExecutable.startsWith('/'))) throw new Error('Schemathesis qualification requires its reviewed fixture runtime executable');
|
||||
root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'cso-scanner-docker-'));
|
||||
source = secureDirectory(path.join(root, 'source')); fixture();
|
||||
process.env.GSTACK_HOME = path.join(root, 'state');
|
||||
watchdog = path.resolve(import.meta.dir, '../bin/gstack-cso-watchdog');
|
||||
if (!fs.existsSync(watchdog)) throw new Error('Scanner Docker qualification requires the compiled watchdog');
|
||||
await dockerEndpoint(secureDirectory(path.join(root, 'docker-home')), { HOME: root, DOCKER_HOST: process.env.DOCKER_HOST ?? 'unix:///var/run/docker.sock' });
|
||||
});
|
||||
afterAll(() => { if (root) fs.rmSync(root, { recursive: true, force: true }); delete process.env.GSTACK_HOME; });
|
||||
|
||||
suite('qualified CSO scanner image', () => {
|
||||
test('runs the exact adapter through DockerGroup with network-none inputs and bounded normalization', async () => {
|
||||
const id = staged.scanner as ScannerId, api = id === 'schemathesis' ? request().api : undefined;
|
||||
const plan = scannerPlans({ snapshotRoot: '/source', offline: true, selected: [id], deadlineSeconds: 120,
|
||||
tools: { [id]: { available: true, version: staged.version, capabilities: staged.capabilities } },
|
||||
semgrepRules: staged.assets?.semgrepRules?.path, advisoryCache: staged.assets?.advisoryDatabase?.path,
|
||||
...(api ? { schemaPath: '/policy/openapi.json', baseUrl: `http://127.0.0.1:${api.port}/`, operationIds: api.operationIds, seed: 1, maxExamples: 3 } : {}) })[0];
|
||||
expect(plan.prerequisites).toEqual([]);
|
||||
expect(plan.network).toBe(id === 'schemathesis' ? 'loopback' : 'none');
|
||||
const profile: QualifiedScanner = { id: `qualification-${id}`, scanner: id, state: 'qualified', platform: staged.platform, image: staged.image,
|
||||
entrypoint: '/opt/cso/entrypoint', executable: '/opt/cso/bin/scanner', version: staged.version, versionOutputSha256: HASH, helperAbi: 3,
|
||||
isolationPolicyHash: staged.isolationPolicyHash, capabilities: staged.capabilities, ...(staged.assets ? { assets: staged.assets } : {}),
|
||||
qualifiedAt: '2026-01-01T00:00:00.000Z', qualification: { sourceCommit: 'a'.repeat(40), workflow: 'https://github.com/example/example/actions/runs/1', sbomDigest: DIGEST, provenanceDigest: DIGEST, verifiedProvenance: true, containmentPassed: true, adapterContractPassed: true, offlineAssetsPassed: true } };
|
||||
const manifest = { version: 3 as const, root: source, createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), entries: [], headCommit: 'a'.repeat(40), originalHash: sha256(canonical([])), executionHash: sha256(canonical([])) };
|
||||
const input: ScannerRunInput = { id, runId: `scanner-qualification-${Date.now()}`, runDir: root, manifest, policy: { mode: 'comprehensive', scope: 'qualification', diff: false, base: 'HEAD', offline: true, budgetSeconds: 600, maxWorkers: 3, maxRepairs: 3 }, executionDeadline: Date.now() + 120_000, platform: staged.platform, watchdogPath: watchdog, ...(api ? { request: { api } } : {}) };
|
||||
let runtime: QualifiedRuntime | undefined, application: any;
|
||||
if (api) {
|
||||
runtime = { id: 'qualification-runtime', stack: 'python', platform: staged.platform, state: 'qualified', image: staged.image, entrypoint: '/opt/cso/entrypoint', helperAbi: 3, versions: { python: '3.14.0', uv: '1.0.0', 'cso-preparation': '1.0.0' }, policyVersion: 'cso-isolation-v1', qualifiedAt: '2026-01-01T00:00:00.000Z', qualification: { kind: 'application', sourceCommit: 'a'.repeat(40), workflow: 'https://github.com/example/example/actions/runs/1', sbomDigest: DIGEST, provenanceDigest: DIGEST, verifiedProvenance: true, containmentPassed: true, coldStartPassed: true, positiveNegativeAssertionsPassed: true, heldOutRepairPassed: true } };
|
||||
application = { sourceRoot: source, environment: { PATH: '/usr/local/bin:/usr/bin:/bin', PYTHONUNBUFFERED: '1' }, proof: { dependencyClosureHash: HASH, preparedManifestHash: HASH, sourceProjectionHash: HASH, receiptHash: HASH, executionEnvironmentHash: HASH, databaseHash: HASH }, cleanup: async () => {} };
|
||||
}
|
||||
const runner = await createDockerScannerRunner({ input, plan, profile, runtime, application, deadline: input.executionDeadline });
|
||||
try {
|
||||
const version = await runner.version();
|
||||
expect(version.exitCode).toBe(0); expect(version.timedOut).not.toBe(true); expect(version.truncated).not.toBe(true);
|
||||
assertScannerVersionOutput(id,staged.version,version.stdout,version.stderr);
|
||||
profile.versionOutputSha256 = scannerVersionHash(version.stdout, version.stderr);
|
||||
const execution = await runner.scan(), outcome = parseScannerOutput(plan, { ...execution, version: staged.version, databaseUpdatedAt: staged.assets?.advisoryDatabase?.updatedAt });
|
||||
expect(outcome.status).toBe('complete'); expect(outcome.gaps).toEqual([]); expect(outcome.version).toBe(staged.version);
|
||||
fs.writeFileSync(path.resolve(process.env.GSTACK_CSO_SCANNER_VERSION_HASH!), `${profile.versionOutputSha256}\n`, { flag: 'wx', mode: 0o600 });
|
||||
} finally { await runner.cleanup(); }
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { CsoError, SnapshotManifest, canonical, sha256, snapshotPathHandle, snapshotPathId } from '../lib/cso/contracts';
|
||||
import { ISOLATION_POLICY_HASH } from '../lib/cso/docker';
|
||||
import { QualifiedScanner, SCANNER_CATALOG, ScannerCatalog, scannerVersionHash, selectScanner, validateScannerCatalog } from '../lib/cso/scanner-catalog';
|
||||
import { ScannerRequest, ScannerRunInput, ScannerRunnerContext, executeScanner, prepareDockerScannerApplication, schemathesisControlRole, validateScannerRequest } from '../lib/cso/scanner-executor';
|
||||
import { total } from '../lib/cso/admission';
|
||||
import { SCANNER_IDS, ScannerExecution, ScannerId, scannerPlans } from '../lib/cso/scanners';
|
||||
import { type PreparationSandboxRunner } from '../lib/cso/preparation-executor';
|
||||
import { canonicalStartPlan } from '../lib/cso/verification';
|
||||
import { completeRuntimeCatalogFixture } from './helpers/cso-runtime-catalog';
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
|
||||
const HASH = 'a'.repeat(64), DIGEST = `sha256:${HASH}`;
|
||||
const version = (id: ScannerId) => `${id} version ${id === 'osv' ? '2.4.0' : '4.0.0'}\n`;
|
||||
function profile(id: ScannerId, platform: 'linux/amd64' | 'linux/arm64' = 'linux/amd64'): QualifiedScanner {
|
||||
const arch = platform.endsWith('arm64') ? 'arm64' : 'amd64';
|
||||
return { id: `${id}-test-${arch}`, scanner: id, state: 'qualified', platform, image: `ghcr.io/garrytan/gstack/cso-scanners/${id}-${arch}@${DIGEST}`,
|
||||
entrypoint: '/opt/cso/entrypoint', executable: '/opt/cso/bin/scanner', version: id === 'osv' ? '2.4.0' : '4.0.0', versionOutputSha256: scannerVersionHash(version(id)), helperAbi: 3, isolationPolicyHash: ISOLATION_POLICY_HASH,
|
||||
capabilities: scannerPlans({ snapshotRoot: '/source', offline: true, selected: [id] })[0].requiredFeatures,
|
||||
...(id === 'semgrep' ? { assets: { semgrepRules: { path: '/policy/catalog/semgrep.yml', sha256: HASH } } } : {}),
|
||||
...(['osv', 'trivy'].includes(id) ? { assets: { advisoryDatabase: { path: '/opt/cso/scanner-data/db', contentSha256: HASH, updatedAt: '2026-09-09T00:00:00.000Z', ecosystems: ['npm'] } } } : {}),
|
||||
qualifiedAt: '2026-09-09T00:00:00.000Z', qualification: { sourceCommit: 'b'.repeat(40), workflow: 'https://github.com/garrytan/gstack/actions/runs/42', sbomDigest: DIGEST, provenanceDigest: DIGEST, verifiedProvenance: true, containmentPassed: true, adapterContractPassed: true, offlineAssetsPassed: true } };
|
||||
}
|
||||
function catalog(..._ids: ScannerId[]): ScannerCatalog {
|
||||
const scanners = SCANNER_IDS.flatMap(id => [profile(id), profile(id, 'linux/arm64')]);
|
||||
return { schemaVersion: 1, helperAbi: 3, revision: 'unit-fixture-only', promotion: { sourceCommit: 'b'.repeat(40), workflow: 'https://github.com/garrytan/gstack/actions/runs/42', evidenceDigest: `sha256:${sha256(canonical(scanners))}` }, scanners };
|
||||
}
|
||||
const scannerProfile = (c: ScannerCatalog, id: ScannerId, platform: 'linux/amd64' | 'linux/arm64' = 'linux/amd64') => c.scanners.find(item => item.scanner === id && item.platform === platform)!;
|
||||
const runtimes = completeRuntimeCatalogFixture('scanner-runtime-fixture');
|
||||
function request(): ScannerRequest {
|
||||
return { api: { runtimeProfile: 'node', port: 3100, start: { executable: '/usr/local/bin/node', args: ['app.js'] }, control: { name: 'legitimate user', path: '/users', method: 'GET', expected: { status: 200, includes: 'users' } }, boundaryFiles: ['app.js'], operationIds: ['readUsers'], schema: { openapi: '3.1.0', info: { title: 'Unit fixture', version: '1.0.0' }, paths: { '/users': { get: { operationId: 'readUsers', responses: { '200': { description: 'Users' } } } } } } } };
|
||||
}
|
||||
function input(id: ScannerId): ScannerRunInput {
|
||||
const dir = fs.mkdtempSync(join(fs.realpathSync(os.tmpdir()), 'cso-scanner-executor-')); roots.push(dir);
|
||||
const root = join(dir, 'snapshot'); fs.mkdirSync(root, { mode: 0o700 });
|
||||
const files = { 'app.js': 'export const app = true;\n', 'package.json': '{"name":"fixture","version":"1.0.0"}\n', 'package-lock.json': '{"name":"fixture","version":"1.0.0","lockfileVersion":3,"packages":{"":{"name":"fixture","version":"1.0.0"}}}\n' };
|
||||
const entries = Object.entries(files).map(([path, body]) => { fs.writeFileSync(join(root, path), body, { mode: 0o600 }); return { path, pathId: snapshotPathId(root,path), originalHash: sha256(body), executionHash: sha256(body), mode: 0o600, bytes: Buffer.byteLength(body) }; }).sort((a, b) => a.path.localeCompare(b.path));
|
||||
const manifest: SnapshotManifest = { version: 3, root, createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 86400000).toISOString(), entries, headCommit: 'b'.repeat(40), originalHash: sha256(canonical(entries.map(e => [e.path, e.originalHash, e.mode]))), executionHash: sha256(canonical(entries.map(e => [e.path, e.executionHash, e.mode]))) };
|
||||
return { id, runId: 'unit-scanner-run', runDir: dir, manifest, policy: { mode: id === 'schemathesis' ? 'comprehensive' : 'daily', scope: 'default', diff: false, base: 'origin/main', offline: true, budgetSeconds: 600, maxWorkers: 3, maxRepairs: 3 }, executionDeadline: Date.now() + 300000, platform: 'linux/amd64', watchdogPath: '/trusted/test-watchdog', ...(id === 'schemathesis' ? { request: request() } : {}) };
|
||||
}
|
||||
function preparedApplication(spec: ScannerRunInput, cleanup: () => void = () => {}) {
|
||||
return { sourceRoot: join(spec.runDir, 'snapshot'), environment: { PATH: '/usr/local/bin:/usr/bin:/bin' },
|
||||
proof: { dependencyClosureHash: HASH, preparedManifestHash: HASH, sourceProjectionHash: HASH, receiptHash: HASH, executionEnvironmentHash: HASH, databaseHash: HASH },
|
||||
cleanup: async () => { cleanup(); } };
|
||||
}
|
||||
function result(id: ScannerId): ScannerExecution {
|
||||
const data: Record<ScannerId, unknown> = {
|
||||
gitleaks: [{ RuleID: 'test-rule', Description: 'Candidate secret', File: '/source/app.js', StartLine: 1 }],
|
||||
osv: { results: [{ source: { path: '/source/package-lock.json' }, packages: [{ package: { name: 'fixture-package', version: '1.0.0', ecosystem: 'npm' }, vulnerabilities: [{ id: 'CVE-2026-12345', summary: 'Candidate dependency defect' }] }] }] },
|
||||
semgrep: { results: [{ check_id: 'test-rule', path: '/source/app.js', start: { line: 1, col: 1 }, extra: { message: 'Candidate code defect', severity: 'ERROR' } }], errors: [], paths: { scanned: ['/source/app.js'] } },
|
||||
zizmor: { version: '2.1.0', runs: [{ tool: { driver: { name: 'zizmor', version: '4.0.0' } }, results: [{ ruleId: 'test-rule', message: { text: 'Candidate workflow defect' }, locations: [{ physicalLocation: { artifactLocation: { uri: 'app.js' }, region: { startLine: 1 } } }] }] }] },
|
||||
trivy: { SchemaVersion: 2, Results: [{ Target: 'app.js', Secrets: [{ RuleID: 'test-rule', Title: 'Candidate secret', Severity: 'HIGH', StartLine: 1 }] }] },
|
||||
schemathesis: { schemathesis_version: '4.0.0', complete: true, stop_reason: 'completed', operations: { selected: 1, tested: 1, errored: 0, skipped: 0 }, errors: [], failures: [{ type: 'ServerError', title: 'Candidate API failure', severity: 'high', operations: ['GET /users'] }] },
|
||||
};
|
||||
return { stdout: JSON.stringify(data[id]), stderr: '', exitCode: id === 'gitleaks' ? 10 : ['osv', 'schemathesis'].includes(id) ? 1 : 0 };
|
||||
}
|
||||
|
||||
describe('reviewed scanner catalog contract', () => {
|
||||
test('the shipped catalog has no executable unqualified fallback', () => { expect(SCANNER_CATALOG.scanners).toEqual([]); expect(() => selectScanner('gitleaks', 'linux/amd64')).toThrow('No qualified'); });
|
||||
test.each(SCANNER_IDS)('selects a qualified immutable %s profile', id => { const c = catalog(id); validateScannerCatalog(c); expect(selectScanner(id, 'linux/amd64', undefined, c)).toEqual(scannerProfile(c, id)); });
|
||||
test.each(['tag', 'entrypoint', 'policy', 'abi', 'qualification', 'executable', 'version', 'capabilities'])('rejects unreviewed %s', field => {
|
||||
const c = catalog('gitleaks'), p = scannerProfile(c, 'gitleaks') as any;
|
||||
if (field === 'tag') p.image = 'gitleaks:latest';
|
||||
if (field === 'entrypoint') p.entrypoint = '/bin/sh';
|
||||
if (field === 'policy') p.isolationPolicyHash = HASH;
|
||||
if (field === 'abi') p.helperAbi = 2;
|
||||
if (field === 'qualification') p.qualification.verifiedProvenance = false;
|
||||
if (field === 'executable') p.executable = '/source/scanner';
|
||||
if (field === 'version') p.versionOutputSha256 = 'unknown';
|
||||
if (field === 'capabilities') p.capabilities = [];
|
||||
expect(() => validateScannerCatalog(c)).toThrow();
|
||||
});
|
||||
test('missing IDs, duplicates, and platform substitution are rejected', () => {
|
||||
const c = catalog('gitleaks'); delete (scannerProfile(c, 'gitleaks') as any).id; expect(() => validateScannerCatalog(c)).toThrow();
|
||||
const duplicate = catalog('gitleaks'); duplicate.scanners[1].id = duplicate.scanners[0].id; expect(() => validateScannerCatalog(duplicate)).toThrow();
|
||||
expect(() => selectScanner('gitleaks', 'linux/arm64', 'missing-profile', catalog('gitleaks'))).toThrow('No qualified');
|
||||
});
|
||||
test('rules and databases cannot point into repository source or mutable work space', () => {
|
||||
const rules = catalog('semgrep'); scannerProfile(rules, 'semgrep').assets!.semgrepRules!.path = '/source/rules.yml'; expect(() => validateScannerCatalog(rules)).toThrow();
|
||||
const db = catalog('osv'); scannerProfile(db, 'osv').assets!.advisoryDatabase!.path = '/work/cache'; expect(() => validateScannerCatalog(db)).toThrow();
|
||||
});
|
||||
test('rejects a self-asserted partial catalog even when its one profile is otherwise plausible', () => {
|
||||
const c = catalog('gitleaks'); c.scanners = [scannerProfile(c, 'gitleaks')]; expect(() => validateScannerCatalog(c)).toThrow('Incompatible scanner catalog');
|
||||
});
|
||||
});
|
||||
|
||||
describe('all six helper-owned scanner execution paths', () => {
|
||||
test.each(SCANNER_IDS)('%s reaches version, scan, normalization, and cleanup through an injected runner', async id => {
|
||||
const calls: string[] = []; let captured: ScannerRunnerContext | undefined;
|
||||
const spec = input(id), record = await executeScanner(spec, { catalog: catalog(id), runtimes,
|
||||
...(id === 'schemathesis' ? { applicationPreparer: async () => preparedApplication(spec) } : {}),
|
||||
runnerFactory: async context => { captured = context; return { version: async () => { calls.push('version'); return { stdout: version(id), exitCode: 0 }; }, scan: async () => { calls.push('scan'); return result(id); }, cleanup: async () => { calls.push('cleanup'); } }; } });
|
||||
expect(calls).toEqual(['version', 'scan', 'cleanup']);
|
||||
expect(captured!.plan.execution).toBe('sandbox');
|
||||
expect(captured!.plan.network).toBe(id === 'schemathesis' ? 'loopback' : 'none');
|
||||
expect(captured!.plan.args.join(' ')).not.toMatch(/download|--network|docker.sock|--metrics=on/);
|
||||
expect(captured!.deadline).toBeLessThanOrEqual(Date.now() + 300000);
|
||||
expect(record.outcome.status).toBe('complete'); expect(record.outcome.candidates).toHaveLength(1);
|
||||
expect(record.outcome.candidates[0]).toMatchObject({ evidence: 'scanner-candidate', trust: 'untrusted' });
|
||||
expect(record.outcome.candidates[0]).not.toHaveProperty('reproduced'); expect(record.outcome.candidates[0]).not.toHaveProperty('repair');
|
||||
expect(record.coverage).toMatchObject({ domain: `scanner:${id}`, status: 'assessed' });
|
||||
expect(record.provenance.versionOutputSha256).toBe(profile(id).versionOutputSha256);
|
||||
expect(record.provenance.image).toBe(profile(id).image);
|
||||
if (id === 'schemathesis') { expect(captured!.application?.sourceRoot).toBe(join(spec.runDir, 'snapshot')); expect(record.provenance.preparation?.dependencyClosureHash).toBe(HASH); }
|
||||
else expect(record.provenance.preparation).toBeNull();
|
||||
if (id === 'osv' || id === 'trivy') expect(record.outcome.databaseUpdatedAt).toBe('2026-09-09T00:00:00.000Z');
|
||||
});
|
||||
test('missing catalog never creates a runner and records the exact prerequisite', async () => {
|
||||
let calls = 0; const out = await executeScanner(input('gitleaks'), { runnerFactory: async () => { calls++; throw new Error('must not run'); } });
|
||||
expect(calls).toBe(0); expect(out.coverage.status).toBe('not_assessed'); expect(out.outcome.gaps[0].message).toContain('No qualified gitleaks');
|
||||
});
|
||||
test.each(['semgrep', 'osv', 'trivy'] as ScannerId[])('%s missing baked assets cannot launch a scanner or download replacements', async id => {
|
||||
let calls = 0; const c = catalog(id); delete scannerProfile(c, id).assets;
|
||||
const out = await executeScanner(input(id), { catalog: c, runnerFactory: async () => { calls++; throw new Error('must not run'); } });
|
||||
expect(calls).toBe(0); expect(out.outcome.status).toBe('not_assessed'); expect(out.outcome.gaps[0].code).toBe('PREREQUISITE');
|
||||
});
|
||||
test('version mismatch cleans up without scanning', async () => {
|
||||
const calls: string[] = [];
|
||||
const out = await executeScanner(input('gitleaks'), { catalog: catalog('gitleaks'), runnerFactory: async () => ({ version: async () => ({ stdout: 'different version', exitCode: 0 }), scan: async () => { calls.push('scan'); return result('gitleaks'); }, cleanup: async () => { calls.push('cleanup'); } }) });
|
||||
expect(calls).toEqual(['cleanup']); expect(out.outcome.status).toBe('not_assessed'); expect(out.outcome.version).toBeNull(); expect(out.outcome.gaps[0].message).toContain('version output');
|
||||
});
|
||||
test('an exact output hash cannot launder a substring version mismatch',async()=>{
|
||||
const c=catalog('gitleaks'),p=scannerProfile(c,'gitleaks'),reported='gitleaks version 14.0.0\n',calls:string[]=[];
|
||||
p.versionOutputSha256=scannerVersionHash(reported);c.promotion!.evidenceDigest=`sha256:${sha256(canonical(c.scanners))}`;
|
||||
const out=await executeScanner(input('gitleaks'),{catalog:c,runnerFactory:async()=>({version:async()=>({stdout:reported,exitCode:0}),scan:async()=>{calls.push('scan');return result('gitleaks');},cleanup:async()=>{calls.push('cleanup');}})});
|
||||
expect(calls).toEqual(['cleanup']);expect(out.outcome.status).toBe('not_assessed');expect(out.outcome.gaps[0].message).toContain('exact catalog version');
|
||||
});
|
||||
test.each([['timeout', 'TIMEOUT'], ['redaction', 'REDACTION_FAILED'], ['invalid-output', 'INVALID_OUTPUT'], ['tool', 'TOOL_FAILED'], ['cleanup', 'ISOLATION_FAILED']] as const)('%s preserves truthful scanner coverage', async (failure, code) => {
|
||||
let cleaned = false;
|
||||
const out = await executeScanner(input('gitleaks'), { catalog: catalog('gitleaks'), runnerFactory: async () => ({ version: async () => ({ stdout: version('gitleaks'), exitCode: 0 }), scan: async () => { if (failure === 'timeout') throw new CsoError('DEADLINE', 'Scanner timed out'); if (failure === 'redaction') throw new CsoError('REDACTION_FAILED', 'Scanner output withheld'); if (failure === 'tool') throw new CsoError('TOOL_FAILED', 'Scanner process failed'); return failure === 'invalid-output' ? { stdout: '{', exitCode: 0 } : result('gitleaks'); }, cleanup: async () => { cleaned = true; if (failure === 'cleanup') throw new CsoError('ISOLATION_FAILED', 'Exact cleanup failed'); } }) });
|
||||
expect(cleaned).toBe(true); expect(out.outcome.status).toBe('not_assessed'); expect(out.outcome.candidates).toEqual([]); expect(out.outcome.gaps[0].code).toBe(code);
|
||||
});
|
||||
test('online policy still supplies only offline scanner plans', async () => {
|
||||
const spec = input('osv'); spec.policy.offline = false;
|
||||
await executeScanner(spec, { catalog: catalog('osv'), runnerFactory: async c => { expect(c.plan.network).toBe('none'); expect(c.plan.args).toContain('--offline'); return { version: async () => ({ stdout: version('osv'), exitCode: 0 }), scan: async () => result('osv'), cleanup: async () => {} }; } });
|
||||
});
|
||||
test('expired or changed retained source cannot enter the runner', async () => {
|
||||
for (const expired of [true, false]) {
|
||||
const spec = input('gitleaks'); if (expired) spec.manifest.expiresAt = '2000-01-01T00:00:00.000Z'; else fs.writeFileSync(join(spec.runDir, 'snapshot/app.js'), 'changed');
|
||||
let calls = 0; const out = await executeScanner(spec, { catalog: catalog('gitleaks'), runnerFactory: async () => { calls++; throw new Error('must not run'); } });
|
||||
expect(calls).toBe(0); expect(out.outcome.status).toBe('not_assessed');
|
||||
}
|
||||
});
|
||||
test('source changed during collection invalidates the candidate evidence', async () => {
|
||||
const spec = input('gitleaks');
|
||||
const out = await executeScanner(spec, { catalog: catalog('gitleaks'), runnerFactory: async () => ({ version: async () => ({ stdout: version('gitleaks'), exitCode: 0 }), scan: async () => { fs.writeFileSync(join(spec.runDir, 'snapshot/app.js'), 'racing change'); return result('gitleaks'); }, cleanup: async () => {} }) });
|
||||
expect(out.outcome.candidates).toEqual([]); expect(out.outcome.gaps[0].message).toContain('snapshot changed');
|
||||
});
|
||||
test('deadline is subordinate to the reporting reserve', async () => {
|
||||
const spec = input('gitleaks'); spec.executionDeadline = Date.now() - 1; let calls = 0;
|
||||
const out = await executeScanner(spec, { catalog: catalog('gitleaks'), runnerFactory: async () => { calls++; throw new Error('must not run'); } });
|
||||
expect(calls).toBe(0); expect(out.outcome.gaps[0].code).toBe('TIMEOUT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Schemathesis application admission', () => {
|
||||
test('opaque boundary and startup handles resolve only against the retained manifest',async()=>{
|
||||
const spec=input('schemathesis'),raw=request(),app=spec.manifest.entries.find(entry=>entry.path==='app.js')!,handle=snapshotPathHandle(app.pathId);raw.api!.boundaryFiles=[handle];raw.api!.start.args=[handle];spec.request=raw;let captured:ScannerRunnerContext|undefined;
|
||||
const record=await executeScanner(spec,{catalog:catalog('schemathesis'),runtimes,applicationPreparer:async()=>preparedApplication(spec),runnerFactory:async context=>{captured=context;return{version:async()=>({stdout:version('schemathesis'),exitCode:0}),scan:async()=>result('schemathesis'),cleanup:async()=>{}};}});
|
||||
expect(record.outcome.status).toBe('complete');expect(captured!.input.request!.api).toMatchObject({boundaryFiles:['app.js'],start:{args:['app.js']}});expect(record.provenance.requestHash).toBe(sha256(canonical(validateScannerRequest(raw,'schemathesis'))));
|
||||
});
|
||||
test('Rails with PostgreSQL and its control verifier fits the aggregate group admission', () => {
|
||||
expect(schemathesisControlRole()).toBe('verifier');
|
||||
expect(total(['anchor', 'postgres', 'app', schemathesisControlRole()]).memoryMiB).toBe(3904);
|
||||
expect(() => total(['anchor', 'postgres', 'app', 'tests'])).toThrow('aggregate reproduction-group limit');
|
||||
});
|
||||
test('daily mode blocks application execution before creating a runner', async () => {
|
||||
const spec = input('schemathesis'); spec.policy.mode = 'daily'; let calls = 0;
|
||||
const out = await executeScanner(spec, { catalog: catalog('schemathesis'), runtimes, runnerFactory: async () => { calls++; throw new Error('must not run'); } });
|
||||
expect(calls).toBe(0); expect(out.outcome.gaps[0].message).toContain('comprehensive mode');
|
||||
});
|
||||
test('transformed security boundaries block API execution', async () => {
|
||||
const spec = input('schemathesis'); spec.manifest.entries.find(e => e.path === 'app.js')!.transformation = 'sanitized'; let calls = 0;
|
||||
const out = await executeScanner(spec, { catalog: catalog('schemathesis'), runtimes, runnerFactory: async () => { calls++; throw new Error('must not run'); } });
|
||||
expect(calls).toBe(0); expect(out.outcome.gaps[0].message).toContain('boundary is missing or transformed');
|
||||
});
|
||||
test('no runtime profile means a prerequisite, not a scanner success', async () => {
|
||||
const out = await executeScanner(input('schemathesis'), { catalog: catalog('schemathesis') });
|
||||
expect(out.outcome.status).toBe('not_assessed'); expect(out.outcome.gaps[0].message).toContain('runtime is unavailable');
|
||||
});
|
||||
test.each(['remote-ref', 'server', 'hook', 'callback', 'operation', 'unbounded', 'source-command'])('rejects hostile or ambiguous %s harness input', kind => {
|
||||
const raw = request() as any;
|
||||
if (kind === 'remote-ref') raw.api.schema.components = { schemas: { User: { $ref: 'https://example.test/user.json' } } };
|
||||
if (kind === 'server') raw.api.schema.servers = [{ url: 'https://example.test' }];
|
||||
if (kind === 'hook') raw.api.schema['x-hooks'] = 'module.py';
|
||||
if (kind === 'callback') raw.api.schema.callbacks = {};
|
||||
if (kind === 'operation') raw.api.operationIds = ['not-declared'];
|
||||
if (kind === 'unbounded') raw.api.maxExamples = 100000;
|
||||
if (kind === 'source-command') raw.api.start = { executable: 'node', args: [] };
|
||||
expect(() => validateScannerRequest(raw, 'schemathesis')).toThrow();
|
||||
});
|
||||
test('rejects an absolute synthetic server and startup boundaries that omit the canonical entrypoint', async () => {
|
||||
for (const kind of ['synthetic', 'missing-entrypoint'] as const) {
|
||||
const spec = input('schemathesis'), api = spec.request!.api!; let prepared = 0, runners = 0;
|
||||
if (kind === 'synthetic') api.start = { executable: '/usr/local/bin/node', args: ['-e', "require('http').createServer((q,s)=>s.end('synthetic')).listen(3100)"] };
|
||||
else api.boundaryFiles = ['package.json'];
|
||||
const out = await executeScanner(spec, { catalog: catalog('schemathesis'), runtimes,
|
||||
applicationPreparer: async () => { prepared++; return preparedApplication(spec); },
|
||||
runnerFactory: async () => { runners++; throw new Error('must not run'); } });
|
||||
expect(prepared).toBe(0); expect(runners).toBe(0); expect(out.outcome.status).toBe('not_assessed');
|
||||
expect(out.outcome.gaps[0].message).toMatch(/helper-derived|canonical startup input/);
|
||||
}
|
||||
});
|
||||
test('materialized application cleanup failure withholds otherwise complete API coverage', async () => {
|
||||
const spec = input('schemathesis');
|
||||
const out = await executeScanner(spec, { catalog: catalog('schemathesis'), runtimes,
|
||||
applicationPreparer: async () => ({ ...preparedApplication(spec), cleanup: async () => { throw new CsoError('ISOLATION_FAILED', 'prepared source cleanup failed'); } }),
|
||||
runnerFactory: async () => ({ version: async () => ({ stdout: version('schemathesis'), exitCode: 0 }), scan: async () => result('schemathesis'), cleanup: async () => {} }) });
|
||||
expect(out.outcome.status).toBe('not_assessed'); expect(out.outcome.candidates).toEqual([]); expect(out.outcome.gaps[0].message).toContain('cleanup failed');
|
||||
});
|
||||
test('scanner preparation construction failure preserves Docker watchdog journals for detached recovery',async()=>{const spec=input('schemathesis'),snapshot=join(spec.runDir,'snapshot'),integrity=Buffer.alloc(64,7).toString('base64');spec.policy.offline=false;fs.writeFileSync(join(snapshot,'package.json'),JSON.stringify({name:'fixture',version:'1.0.0',dependencies:{cookie:'1.0.0'}}));fs.writeFileSync(join(snapshot,'package-lock.json'),JSON.stringify({name:'fixture',lockfileVersion:3,packages:{'':{name:'fixture',version:'1.0.0',dependencies:{cookie:'1.0.0'}},'node_modules/cookie':{name:'cookie',version:'1.0.0',resolved:'https://registry.npmjs.org/cookie/-/cookie-1.0.0.tgz',integrity:`sha512-${integrity}`}}}));const runtime=runtimes.runtimes.find(item=>item.stack==='node'&&item.platform==='linux/amd64')!,qualification={schemaVersion:1 as const,helperAbi:3,runnerId:'scanner-construction-fault',policyVersion:'cso-preparation-v1' as const,supportedStacks:['node','bun','python','rails'] as any,registryRestrictionQualified:true as const,dnsRebindingTestsPassed:true as const,acquisitionExcludesSource:true as const,offlineContainmentQualified:true as const,immutableArchiveMounts:true as const,resourceLimitsEnforced:true as const};let control='';const runnerFactory=(options:any):PreparationSandboxRunner=>({qualification,acquire:async()=>{control=join(options.controlRoot,'acquisition-fault');fs.mkdirSync(control,{recursive:true});fs.writeFileSync(join(control,'watchdog.ready'),'ready\n',{mode:0o600});fs.writeFileSync(join(control,'watchdog.event'),'cleanup incomplete; retrying exact journaled resources\n',{mode:0o600});fs.writeFileSync(join(control,'resources.journal'),`container:${'a'.repeat(64)}\n`,{mode:0o600});throw new CsoError('ISOLATION_FAILED','Exact preparation cleanup failed; detached watchdog remains responsible');},prepareOffline:async()=>{throw new Error('must not prepare');},disposePrepared:()=>{}});await expect(prepareDockerScannerApplication({input:spec,runtime,stack:'node',startPlan:canonicalStartPlan(snapshot,'node',3100),deadline:Date.now()+30_000,catalog:runtimes},{endpoint:{} as any,runnerFactory,cacheRoot:join(spec.runDir,'cache')})).rejects.toThrow('detached watchdog remains responsible');expect(fs.existsSync(join(control,'resources.journal'))).toBe(true);expect(fs.existsSync(join(control,'watchdog.ready'))).toBe(true);expect(fs.existsSync(control)).toBe(true);});
|
||||
test('static scanners cannot accept application commands or arbitrary extra runner fields', () => {
|
||||
expect(() => validateScannerRequest(request(), 'semgrep')).toThrow('Only Schemathesis');
|
||||
expect(() => validateScannerRequest({ image: 'evil:latest' }, 'gitleaks')).toThrow('Unexpected');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { ISOLATION_POLICY_HASH } from '../lib/cso/docker';
|
||||
import { sha256 } from '../lib/cso/contracts';
|
||||
import { SCANNER_CATALOG, ScannerCatalog, scannerVersionHash } from '../lib/cso/scanner-catalog';
|
||||
import { SCANNER_IDS, ScannerId, scannerPlans } from '../lib/cso/scanners';
|
||||
import { scannerCatalogProposal, scannerAssetHash, validateScannerCatalogTransition } from '../scripts/cso-scanner-catalog';
|
||||
import { scannerBuildMatrix } from '../scripts/cso-scanner-matrix';
|
||||
import { verifiedStatementSetDigest } from '../scripts/cso-attestation-evidence';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..'), HASH = 'a'.repeat(64), DIGEST = `sha256:${HASH}`;
|
||||
const temps: string[] = [];
|
||||
afterEach(() => { for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
const image = (id: string) => `ghcr.io/garrytan/gstack/cso-scanners/${id}@sha256:${HASH}`;
|
||||
const attested=(id:string,repository=`https://github.com/example/${id}`)=>({image:image(id),repository,sourceCommit:'b'.repeat(40),release:`v${id}-1.2.3`,signerWorkflow:`example/${id}/.github/workflows/release.yml`,signerDigest:'c'.repeat(40),provenanceStatementDigest:DIGEST,sbomStatementDigest:DIGEST});
|
||||
function reviewedInputs() {
|
||||
return { schemaVersion: 1, helperAbi: 3, state: 'reviewed', sbomGenerator: attested('sbom'), profiles: SCANNER_IDS.map(scanner => ({
|
||||
scanner, version: scanner === 'gitleaks' ? '8.30.1' : '1.2.3', executable: `/usr/local/bin/${scanner === 'osv' ? 'osv-scanner' : scanner}`,
|
||||
baseImages: { 'linux/amd64': attested(`${scanner}-base-amd64`, `https://github.com/example/${scanner}`), 'linux/arm64': attested(`${scanner}-base-arm64`, `https://github.com/example/${scanner}`) },
|
||||
...(scanner === 'semgrep' ? { assets: { semgrepRules: { path: '/policy/catalog/semgrep.yml', sha256: HASH } } }
|
||||
: ['osv', 'trivy'].includes(scanner) ? { assets: { advisoryDatabase: { path: `/opt/cso/scanner-data/${scanner}`, contentSha256: HASH, updatedAt: '2026-09-10T00:00:00.000Z', ecosystems: ['npm', 'PyPI'] } } } : {}),
|
||||
...(scanner === 'schemathesis' ? { applicationExecutable: '/usr/local/bin/python' } : {}),
|
||||
})) };
|
||||
}
|
||||
function qualified(scanner: ScannerId, platform: 'linux/amd64' | 'linux/arm64') {
|
||||
const arch = platform.endsWith('arm64') ? 'arm64' : 'amd64', version = scanner === 'gitleaks' ? '8.30.1' : '1.2.3';
|
||||
return { id: `${scanner}-${version}-${arch}`, scanner, state: 'qualified' as const, platform, image: image(`${scanner}-${arch}`), entrypoint: '/opt/cso/entrypoint' as const, executable: '/opt/cso/bin/scanner', version,
|
||||
versionOutputSha256: scannerVersionHash(`${scanner} ${version}\n`), helperAbi: 3 as const, isolationPolicyHash: ISOLATION_POLICY_HASH,
|
||||
capabilities: scannerPlans({ snapshotRoot: '/source', offline: true, selected: [scanner] })[0].requiredFeatures,
|
||||
...(scanner === 'semgrep' ? { assets: { semgrepRules: { path: '/policy/catalog/semgrep.yml', sha256: HASH } } }
|
||||
: ['osv', 'trivy'].includes(scanner) ? { assets: { advisoryDatabase: { path: `/opt/cso/scanner-data/${scanner}`, contentSha256: HASH, updatedAt: '2026-09-10T00:00:00.000Z', ecosystems: ['npm'] } } } : {}),
|
||||
qualifiedAt: '2026-09-10T00:00:00.000Z', qualification: { sourceCommit: 'c'.repeat(40), workflow: 'https://github.com/garrytan/gstack/actions/runs/42', sbomDigest: DIGEST, provenanceDigest: DIGEST, verifiedProvenance: true as const, containmentPassed: true as const, adapterContractPassed: true as const, offlineAssetsPassed: true as const } };
|
||||
}
|
||||
|
||||
describe('CSO scanner release inputs', () => {
|
||||
test('requires all six scanners on both native architectures with derived adapter capabilities', () => {
|
||||
const matrix = scannerBuildMatrix(reviewedInputs()); expect(matrix.include).toHaveLength(12);
|
||||
for (const scanner of SCANNER_IDS) {
|
||||
const rows = matrix.include.filter(row => row.scanner === scanner);
|
||||
expect(rows.map(row => row.platform)).toEqual(['linux/amd64', 'linux/arm64']);
|
||||
expect(rows.map(row => row.runner)).toEqual(['ubuntu-24.04', 'ubuntu-24.04-arm']);
|
||||
expect(rows[0].capabilities).toEqual([...scannerPlans({ snapshotRoot: '/source', offline: true, selected: [scanner] })[0].requiredFeatures].sort());
|
||||
}
|
||||
});
|
||||
test('pending, partial, mutable, assetless, or unverified input cannot publish', () => {
|
||||
const pending = reviewedInputs() as any; pending.state = 'pending'; expect(() => scannerBuildMatrix(pending)).toThrow('MISSING_REVIEWED_SCANNER_INPUTS');
|
||||
const partial = reviewedInputs() as any; partial.profiles.pop(); expect(() => scannerBuildMatrix(partial)).toThrow('INCOMPLETE_SCANNER_MATRIX');
|
||||
const tagged = reviewedInputs() as any; tagged.profiles[0].baseImages['linux/amd64'].image = 'ghcr.io/example/gitleaks:latest'; expect(() => scannerBuildMatrix(tagged)).toThrow('INVALID_UPSTREAM_EVIDENCE');
|
||||
const assetless = reviewedInputs() as any; delete assetless.profiles.find((p: any) => p.scanner === 'osv').assets; expect(() => scannerBuildMatrix(assetless)).toThrow('MISSING_OFFLINE_ASSET');
|
||||
const unverified = reviewedInputs() as any; delete unverified.profiles[0].baseImages['linux/amd64'].signerDigest; expect(() => scannerBuildMatrix(unverified)).toThrow('INVALID_UPSTREAM_EVIDENCE');
|
||||
const generator = reviewedInputs() as any; generator.sbomGenerator.provenanceStatementDigest = 'unreviewed'; expect(() => scannerBuildMatrix(generator)).toThrow('UNVERIFIED_SBOM_GENERATOR');
|
||||
});
|
||||
test('binds verified attestation statements to the reviewed subject and predicate',()=>{
|
||||
const predicate='https://slsa.dev/provenance/v1',statement={_type:'https://in-toto.io/Statement/v1',subject:[{name:'image',digest:{sha256:HASH}}],predicateType:predicate,predicate:{buildType:'https://example.test/builder'}};
|
||||
const value=[{attestation:{},verificationResult:{statement}}],digest=verifiedStatementSetDigest(value,predicate,HASH);
|
||||
expect(digest).toMatch(/^sha256:[a-f0-9]{64}$/);expect(verifiedStatementSetDigest(value,predicate,HASH)).toBe(digest);
|
||||
expect(()=>verifiedStatementSetDigest(value,'https://spdx.dev/Document/v2.3',HASH)).toThrow('VERIFIED_ATTESTATION_IDENTITY_MISMATCH');
|
||||
expect(()=>verifiedStatementSetDigest(value,predicate,'d'.repeat(64))).toThrow('VERIFIED_ATTESTATION_IDENTITY_MISMATCH');
|
||||
});
|
||||
test('paths and matrix strings cannot inject build commands', () => {
|
||||
for (const executable of ['/usr/bin/scanner;id', '../../scanner', '/usr/bin/scan ner', '/usr/bin/scanner\nBAD=1']) {
|
||||
const input = reviewedInputs() as any; input.profiles[0].executable = executable; expect(() => scannerBuildMatrix(input)).toThrow('INVALID_SCANNER_EXECUTABLE');
|
||||
}
|
||||
const extra = reviewedInputs() as any; extra.profiles[0].buildArgs = ['EVIL=1']; expect(() => scannerBuildMatrix(extra)).toThrow('INVALID_SCANNER_PROFILE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO scanner catalog promotion', () => {
|
||||
const expected = { sourceCommit: 'c'.repeat(40), workflow: 'https://github.com/garrytan/gstack/actions/runs/42', imagePrefix: 'ghcr.io/garrytan/gstack/cso-scanners/' };
|
||||
const fragments = () => SCANNER_IDS.flatMap(scanner => ['linux/amd64', 'linux/arm64'].map(platform => qualified(scanner, platform as any)));
|
||||
test('assembles only a complete qualified matrix and records rollback identity', () => {
|
||||
const proposal = scannerCatalogProposal(SCANNER_CATALOG, fragments(), 'cso-scanners-qualified-42', expected);
|
||||
expect(proposal.scanners).toHaveLength(12); expect(proposal.previousRevision).toBe(SCANNER_CATALOG.revision);
|
||||
expect(proposal.promotion).toMatchObject({ sourceCommit: expected.sourceCommit, workflow: expected.workflow });
|
||||
expect(proposal.promotion!.evidenceDigest).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
expect(proposal.scanners.map(profile => `${profile.scanner}:${profile.platform}`)).toEqual([...proposal.scanners.map(profile => `${profile.scanner}:${profile.platform}`)].sort());
|
||||
});
|
||||
test('rejects missing platforms, claimed capabilities, provenance substitution, and foreign image repositories', () => {
|
||||
const missing = fragments(); missing.pop(); expect(() => scannerCatalogProposal(SCANNER_CATALOG, missing, 'missing-profile', expected)).toThrow('INCOMPLETE_SCANNER_CATALOG_MATRIX');
|
||||
const capabilities = fragments() as any[]; capabilities[0].capabilities = ['invented']; expect(() => scannerCatalogProposal(SCANNER_CATALOG, capabilities, 'bad-capability', expected)).toThrow('capabilities do not match');
|
||||
const source = fragments() as any[]; source[0].qualification.sourceCommit = 'd'.repeat(40); expect(() => scannerCatalogProposal(SCANNER_CATALOG, source, 'wrong-source', expected)).toThrow('SOURCE_COMMIT_MISMATCH');
|
||||
const foreign = fragments() as any[]; foreign[0].image = image('foreign').replace('garrytan/gstack', 'other/repo'); expect(() => scannerCatalogProposal(SCANNER_CATALOG, foreign, 'foreign-image', expected)).toThrow('Scanner profile is not qualified');
|
||||
});
|
||||
test('promotion is a compare-and-swap against the currently reviewed revision', () => {
|
||||
const proposal = scannerCatalogProposal(SCANNER_CATALOG, fragments(), 'cso-scanners-qualified-43', expected);
|
||||
expect(() => validateScannerCatalogTransition(SCANNER_CATALOG, proposal)).not.toThrow();
|
||||
expect(() => validateScannerCatalogTransition({ ...SCANNER_CATALOG, revision: 'catalog-advanced-concurrently' }, proposal)).toThrow('SCANNER_CATALOG_BASE_REVISION_MISMATCH');
|
||||
});
|
||||
test('hashes files and trees deterministically and rejects linked asset payloads', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cso-scanner-assets-')); temps.push(root); fs.mkdirSync(path.join(root, 'nested')); fs.writeFileSync(path.join(root, 'nested', 'b'), 'two'); fs.writeFileSync(path.join(root, 'a'), 'one');
|
||||
const first = scannerAssetHash(root); expect(first).toMatch(/^[a-f0-9]{64}$/); expect(scannerAssetHash(root)).toBe(first); expect(scannerAssetHash(path.join(root, 'a'))).toBe(sha256('one'));
|
||||
fs.symlinkSync(path.join(root, 'a'), path.join(root, 'linked')); expect(() => scannerAssetHash(root)).toThrow('UNSAFE_SCANNER_ASSET');
|
||||
const top = path.join(path.dirname(root), `${path.basename(root)}-link`); temps.push(top); fs.symlinkSync(root, top); expect(() => scannerAssetHash(top)).toThrow('UNSAFE_SCANNER_ASSET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO scanner qualification workflow', () => {
|
||||
test('keeps branch validation read-only while qualification and promotion are protected-main and review gated', () => {
|
||||
const raw = fs.readFileSync(path.join(ROOT, '.github/workflows/cso-scanner-images.yml'), 'utf8'), workflow = Bun.YAML.parse(raw) as any;
|
||||
expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']); expect(workflow.permissions).toEqual({ contents: 'read' }); expect(workflow.jobs['reviewed-inputs'].if).toBeUndefined();
|
||||
const stage = workflow.jobs['stage-and-qualify'];
|
||||
expect(stage.if).toContain("github.ref == 'refs/heads/main'"); expect(stage.if).toContain("github.event_name == 'workflow_dispatch'"); expect(stage.environment).toBe('cso-scanner-release');
|
||||
expect(stage.permissions).toMatchObject({ contents: 'read', packages: 'write', 'id-token': 'write', attestations: 'write', 'artifact-metadata': 'write' });
|
||||
const privileged = Object.entries(workflow.jobs).filter(([, job]: any) => ['packages', 'id-token', 'attestations', 'artifact-metadata'].some(permission => job.permissions?.[permission] === 'write'));
|
||||
expect(privileged.map(([name]) => name)).toEqual(['stage-and-qualify']);
|
||||
for (const permission of ['packages', 'id-token', 'attestations', 'artifact-metadata']) expect(workflow.jobs['reviewed-inputs'].permissions?.[permission]).toBeUndefined();
|
||||
expect(workflow.jobs['promote-catalog'].if).toContain("github.ref == 'refs/heads/main'"); expect(workflow.jobs['promote-catalog'].if).toContain('inputs.promote_catalog == true'); expect(workflow.jobs['promote-catalog'].environment).toBe('cso-scanner-release');
|
||||
expect(workflow.jobs['promote-catalog'].permissions.packages).toBe('read');
|
||||
expect(raw).toContain('test/cso-scanners.test.ts test/cso-scanner-executor.test.ts test/cso-scanner-release.test.ts');
|
||||
expect(raw).toContain('test/cso-scanner-docker-integration.test.ts'); expect(raw).toContain('GSTACK_CSO_SCANNER_VERSION_HASH'); expect(raw).toContain('hash-asset scanner-asset');
|
||||
expect(raw).toContain('Require a public package and anonymously load the verified immutable image');
|
||||
expect(raw).toContain('scripts/cso-public-ghcr.ts verify');
|
||||
expect(raw).toContain('--repository "$GITHUB_REPOSITORY" --output public-image.json');
|
||||
expect(raw).toContain('sha256sum "$output" staged-profile.json version.sha256 declared-assets.json public-image.json');
|
||||
const anonymousStage = workflow.jobs['stage-and-qualify'].steps.find((step: any) => step.run?.includes('scripts/cso-public-ghcr.ts verify'));
|
||||
expect(anonymousStage.env.GH_TOKEN).toBe('${{ github.token }}');
|
||||
for(const value of ['--signer-workflow "$signer_workflow"','--signer-digest "$signer_digest"','--source-digest "$source_commit"','cso-attestation-evidence.ts digest','provenanceStatementDigest','sbomStatementDigest'])expect(raw).toContain(value);
|
||||
expect(raw).not.toContain('cso-scanner-staging');
|
||||
const docker = fs.readFileSync(path.join(ROOT, 'lib/cso/docker.ts'), 'utf8');
|
||||
for (const flag of ["'--pull=never'", "'--read-only'", "'--cap-drop','ALL'", "'no-new-privileges:true'", "'seccomp=builtin'", "'--log-driver=none'", "'--network'"]) expect(docker).toContain(flag);
|
||||
expect(docker).toContain("['rm','--force','--volumes',id]"); expect(docker).toContain('Pinned runtime image declares writable volumes');
|
||||
expect(raw).toContain('cso-scanner-catalog.ts assemble'); expect(raw).toContain('cso-scanner-catalog.ts validate-transition lib/cso/scanner-images/catalog.json promotion/catalog-proposal.json'); expect(raw).toContain('gh pr create --base main');
|
||||
expect(raw).toContain('branch="cso-scanner-catalog-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"'); expect(raw).not.toContain('branch="cso-scanner-catalog-$GITHUB_RUN_ID"');
|
||||
const publicPromotion = raw.indexOf('Recheck public visibility and anonymous pulls before promotion');
|
||||
const sourcePromotion = raw.indexOf('Revalidate and open the reviewable source catalog PR');
|
||||
expect(publicPromotion).toBeGreaterThanOrEqual(0); expect(sourcePromotion).toBeGreaterThan(publicPromotion);
|
||||
expect(raw.slice(publicPromotion, sourcePromotion)).toContain('--remove-after');
|
||||
for (const job of Object.values(workflow.jobs) as any[]) for (const step of job.steps) if (step.uses) expect(step.uses).toMatch(/@[a-f0-9]{40}$/);
|
||||
});
|
||||
test('commits no invented release input and normalizes scanner images to the constrained helper contract', () => {
|
||||
const inputs = JSON.parse(fs.readFileSync(path.join(ROOT, 'lib/cso/scanner-images/build-inputs.json'), 'utf8'));
|
||||
expect(inputs).toMatchObject({ state: 'pending', profiles: [], sbomGenerator: null });
|
||||
const dockerfile = fs.readFileSync(path.join(ROOT, 'lib/cso/scanner-images/scanner.Dockerfile'), 'utf8');
|
||||
for (const value of ['/opt/cso/entrypoint', '/opt/cso/bin/scanner', 'USER 10001:10001', 'test -x /bin/sleep', 'test -x /bin/cat']) expect(dockerfile).toContain(value);
|
||||
expect(fs.readFileSync(path.join(ROOT, 'lib/cso/scanner-executor.ts'), 'utf8')).toContain("capture(['/bin/cat', plan.outputPath])");
|
||||
const postgres = fs.readFileSync(path.join(ROOT, 'lib/cso/images/postgresql.Dockerfile'), 'utf8');
|
||||
for (const value of ['FROM ${BASE_IMAGE} AS upstream', 'FROM scratch', 'COPY --from=upstream / /']) expect(postgres).toContain(value);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
importSarif, MAX_SCANNER_OUTPUT_BYTES, parseScannerOutput, scannerLocation,
|
||||
scannerPlans, validateScannerBaseUrl, type ScannerId, type ScannerPlan,
|
||||
} from '../lib/cso/scanners';
|
||||
|
||||
function plan(id: ScannerId): ScannerPlan {
|
||||
return scannerPlans({
|
||||
snapshotRoot: '/src', offline: true, selected: [id], semgrepRules: '/policy/rules.yml', advisoryCache: '/advisories',
|
||||
schemaPath: '/policy/openapi.json', baseUrl: 'http://127.0.0.1:3000/', operationIds: ['readDocument'],
|
||||
})[0];
|
||||
}
|
||||
|
||||
function parse(id: ScannerId, data: unknown, exitCode = 0) {
|
||||
return parseScannerOutput(plan(id), { stdout: JSON.stringify(data), exitCode, version: 'scanner 2.3.0', databaseUpdatedAt: '2026-09-09T00:00:00.000Z' });
|
||||
}
|
||||
|
||||
function sarif(result?: Record<string, unknown>) {
|
||||
return { version: '2.1.0', runs: [{ tool: { driver: { name: 'CodeQL', version: '2.22.0', rules: [{ id: 'js/sql-injection', defaultConfiguration: { level: 'error' } }] } }, results: result ? [result] : [] }] };
|
||||
}
|
||||
function sarifResult(uri = 'src/routes.ts') {
|
||||
return { ruleIndex: 0, message: { text: 'Untrusted input reaches a query.' }, locations: [{ physicalLocation: { artifactLocation: { uri }, region: { startLine: 12, startColumn: 3 } } }] };
|
||||
}
|
||||
|
||||
describe('CSO scanner execution plans', () => {
|
||||
test('all six plans require containment and never acquire dependencies or inherit credentials', () => {
|
||||
const plans = scannerPlans({ snapshotRoot: '/src', offline: false });
|
||||
expect(plans.map(p => p.id)).toEqual(['gitleaks', 'osv', 'semgrep', 'zizmor', 'trivy', 'schemathesis']);
|
||||
for (const p of plans) {
|
||||
expect(p.execution).toBe('sandbox');
|
||||
expect(p.network).toBe(p.id === 'schemathesis' ? 'loopback' : 'none');
|
||||
expect(p.timeoutSeconds).toBeLessThanOrEqual(300);
|
||||
expect(p.maxOutputBytes).toBe(MAX_SCANNER_OUTPUT_BYTES);
|
||||
expect(Object.keys(p.env)).not.toContain('GITHUB_TOKEN');
|
||||
expect(Object.keys(p.env)).not.toContain('PATH');
|
||||
expect(p.args.join(' ')).not.toMatch(/npx|uvx|--allow-local-builds|--call-analysis=|--autofix|download-offline/);
|
||||
expect(p.provenanceSources.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('OSV uses full offline v2 scanning with code analysis disabled', () => {
|
||||
const p = plan('osv');
|
||||
expect(p.args).toContain('--offline');
|
||||
expect(p.args).not.toContain('--offline-vulnerabilities');
|
||||
expect(p.args).toContain('--no-call-analysis=all');
|
||||
expect(p.args.slice(0, 2)).toEqual(['scan', 'source']);
|
||||
const old = scannerPlans({ snapshotRoot: '/src', offline: true, selected: ['osv'], advisoryCache: '/advisories', tools: { osv: { available: true, version: '1.9.0' } } })[0];
|
||||
expect(old.prerequisites.join(' ')).toContain('major version 2');
|
||||
});
|
||||
|
||||
test('Gitleaks scans current files and bypasses project suppressions with full redaction', () => {
|
||||
const p = plan('gitleaks');
|
||||
expect(p.args[0]).toBe('dir');
|
||||
expect(p.args).toContain('--redact=100');
|
||||
expect(p.args).toContain('--report-path=-');
|
||||
expect(p.args).toContain('--ignore-gitleaks-allow');
|
||||
expect(p.trustedFiles.some(f => f.content.includes('useDefault = true'))).toBe(true);
|
||||
const history = scannerPlans({ snapshotRoot: '/src', offline: true, selected: ['gitleaks'], gitHistory: '/history' })[0];
|
||||
expect(history.args[0]).toBe('git');
|
||||
expect(history.prerequisites.join(' ')).toContain('no hooks');
|
||||
});
|
||||
|
||||
test('Semgrep cannot select hosted rules, upload findings, or enable code builds', () => {
|
||||
const p = plan('semgrep');
|
||||
expect(p.args[0]).toBe('scan');
|
||||
expect(p.args).toContain('--metrics=off');
|
||||
expect(p.args).toContain('--disable-version-check');
|
||||
expect(p.args).toContain('--oss-only');
|
||||
expect(() => scannerPlans({ snapshotRoot: '/src', offline: true, semgrepRules: 'p/security-audit' })).toThrow();
|
||||
expect(() => scannerPlans({ snapshotRoot: '/src', offline: true, semgrepRules: '/src/rules.yml' })).toThrow();
|
||||
});
|
||||
|
||||
test('zizmor ignores environment online defaults and repo config', () => {
|
||||
const p = plan('zizmor');
|
||||
expect(p.args).toContain('--offline');
|
||||
expect(p.args).toContain('--no-config');
|
||||
expect(p.args).toContain('--no-ignores');
|
||||
expect(p.env.ZIZMOR_OFFLINE).toBe('1');
|
||||
});
|
||||
|
||||
test('Trivy suppresses telemetry, metadata calls, version checks, and every database update', () => {
|
||||
const p = plan('trivy');
|
||||
for (const flag of ['--disable-telemetry', '--offline-scan', '--skip-db-update', '--skip-java-db-update', '--skip-check-update', '--skip-version-check', '--skip-vex-repo-update']) expect(p.args).toContain(flag);
|
||||
expect(p.args).toContain('/policy/trivy.yaml');
|
||||
expect(p.args).toContain('/policy/trivyignore');
|
||||
});
|
||||
|
||||
test('Schemathesis restricts operation count, redirects, seed, time, and report location', () => {
|
||||
const p = plan('schemathesis');
|
||||
expect(p.prerequisites).toEqual([]);
|
||||
for (const arg of ['--workers=1', '--phases=fuzzing', '--max-redirects=0', '--seed', '--max-examples', '--max-time', '--report-json-path', '--include-operation-id']) expect(p.args).toContain(arg);
|
||||
expect(p.outputPath).toBe('/work/schemathesis.json');
|
||||
expect(p.coverage.scope).toEqual(['operation:readDocument']);
|
||||
expect(() => scannerPlans({ snapshotRoot: '/src', offline: true, schemaPath: 'https://example.test/api.json' })).toThrow();
|
||||
});
|
||||
|
||||
test.each(['http://example.com', 'http://localhost:3000', 'http://169.254.169.254/', 'http://127.1/', 'http://2130706433/', 'http://0x7f000001/', 'http://127.0.0.1.evil.test/', ['http://user:', 'pass@127.0.0.1/'].join(''), 'file:///tmp/app', 'http://[::ffff:127.0.0.1]/', 'http://127.0.0.1/?x=secret'])('rejects non-canonical or credential-bearing target %s', url => {
|
||||
expect(() => validateScannerBaseUrl(url)).toThrow();
|
||||
});
|
||||
|
||||
test('numeric IPv4 and IPv6 loopback are accepted', () => {
|
||||
expect(validateScannerBaseUrl('http://127.0.0.1:8080/api')).toBe('http://127.0.0.1:8080/api');
|
||||
expect(validateScannerBaseUrl('http://[::1]:8080/api')).toBe('http://[::1]:8080/api');
|
||||
});
|
||||
|
||||
test('invalid bounds and source-controlled policy are rejected before execution', () => {
|
||||
expect(() => scannerPlans({ snapshotRoot: '/src', offline: true, deadlineSeconds: 301 })).toThrow();
|
||||
expect(() => scannerPlans({ snapshotRoot: '/src', offline: true, policyRoot: '/src/policy' })).toThrow();
|
||||
expect(() => scannerPlans({ snapshotRoot: '/src', offline: true, advisoryCache: '/src/cache' })).toThrow();
|
||||
expect(() => scannerPlans({ snapshotRoot: '/src/../etc', offline: true })).toThrow();
|
||||
expect(() => scannerPlans({ snapshotRoot: '/src', offline: true, selected: ['osv', 'osv'] })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO scanner candidate normalization', () => {
|
||||
test('Gitleaks retains location and rule while discarding secret-bearing fields', () => {
|
||||
const secret = 'ghp_' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8';
|
||||
const result = parse('gitleaks', [{ RuleID: 'github-pat', Description: 'Possible token', File: '/src/config.ts', StartLine: 2, StartColumn: 5, Secret: secret, Match: secret, Line: secret, Author: 'person@example.test' }], 10);
|
||||
expect(result.status).toBe('complete');
|
||||
expect(result.candidates[0].location?.path).toBe('config.ts');
|
||||
expect(result.candidates[0].evidence).toBe('scanner-candidate');
|
||||
expect(result.candidates[0].trust).toBe('untrusted');
|
||||
expect(JSON.stringify(result)).not.toContain(secret);
|
||||
expect(JSON.stringify(result)).not.toContain('person@example.test');
|
||||
});
|
||||
|
||||
test('OSV aliases and affected version never imply reachability or production exposure', () => {
|
||||
const result = parse('osv', { results: [{ source: { path: '/src/package-lock.json' }, packages: [{ package: { name: 'vulnerable-package', version: '1.0.0', ecosystem: 'npm' }, vulnerabilities: [{ id: 'GHSA-abcd-efgh-ijkl', aliases: ['CVE-2026-12345'], summary: 'Unsafe parsing' }] }] }] }, 1);
|
||||
expect(result.status).toBe('complete');
|
||||
expect(result.candidates[0].dependency).toEqual({ name: 'vulnerable-package', version: '1.0.0', ecosystem: 'npm', reachability: 'unknown', exposure: 'unknown' });
|
||||
expect(result.candidates[0].advisoryIds).toContain('CVE-2026-12345');
|
||||
});
|
||||
|
||||
test('Semgrep keeps independent errors even with useful candidates', () => {
|
||||
const result = parse('semgrep', { results: [{ check_id: 'sql-injection', path: 'api.ts', start: { line: 7, col: 3 }, extra: { message: 'Query input', severity: 'ERROR' } }], errors: [{ type: 'ParseError' }], paths: { scanned: ['api.ts'], skipped: ['legacy.rb'] } });
|
||||
expect(result.status).toBe('partial');
|
||||
expect(result.candidates).toHaveLength(1);
|
||||
expect(result.gaps.map(g => g.code)).toEqual(['TOOL_FAILED', 'SKIPPED_INPUT']);
|
||||
});
|
||||
|
||||
test('zizmor SARIF becomes candidate evidence and retains suppression', () => {
|
||||
const data = sarif({ ...sarifResult('.github/workflows/deploy.yml'), ruleId: 'dangerous-triggers', suppressions: [{ kind: 'inSource' }] });
|
||||
const result = parse('zizmor', data);
|
||||
expect(result.status).toBe('complete');
|
||||
expect(result.candidates[0].suppressed).toBe(true);
|
||||
expect(result.candidates[0].tool).toBe('zizmor');
|
||||
});
|
||||
|
||||
test('Trivy normalizes dependency, secret, and infrastructure candidates separately', () => {
|
||||
const result = parse('trivy', { SchemaVersion: 2, Results: [
|
||||
{ Target: 'package-lock.json', Type: 'npm', Vulnerabilities: [{ VulnerabilityID: 'CVE-2026-12345', PkgName: 'lib', InstalledVersion: '2.0.0', Severity: 'HIGH' }] },
|
||||
{ Target: 'Dockerfile', Misconfigurations: [{ ID: 'DS002', Title: 'Root user', Severity: 'MEDIUM', CauseMetadata: { StartLine: 2 } }] },
|
||||
{ Target: 'config.rb', Secrets: [{ RuleID: 'generic-api-key', Title: 'Key', Severity: 'CRITICAL', StartLine: 1 }] },
|
||||
] });
|
||||
expect(result.status).toBe('complete');
|
||||
expect(result.candidates).toHaveLength(3);
|
||||
expect(result.candidates[0].dependency?.reachability).toBe('unknown');
|
||||
expect(result.candidates[1].location?.line).toBe(2);
|
||||
});
|
||||
|
||||
test('Schemathesis assertion failures do not become reproduced vulnerabilities', () => {
|
||||
const result = parse('schemathesis', { schemathesis_version: '4.0.0', complete: true, stop_reason: 'completed', operations: { selected: 1, tested: 1, errored: 0, skipped: 0 }, errors: [], failures: [{ type: 'ServerError', title: 'Server error', severity: 'critical', operations: ['GET /documents'] }] }, 1);
|
||||
expect(result.status).toBe('complete');
|
||||
expect(result.candidates[0].operation).toBe('GET /documents');
|
||||
expect(result.candidates[0].evidence).toBe('scanner-candidate');
|
||||
expect(result.candidates[0]).not.toHaveProperty('reproduced');
|
||||
});
|
||||
|
||||
test('startup failure and zero exercised operations remain not covered', () => {
|
||||
const result = parse('schemathesis', { schemathesis_version: '4.0.0', complete: false, stop_reason: 'interrupted', operations: null, errors: [{ title: 'Schema load failed' }], failures: [] }, 1);
|
||||
expect(result.status).toBe('partial');
|
||||
expect(result.candidates).toHaveLength(0);
|
||||
expect(result.gaps.some(g => g.message.includes('no operations'))).toBe(true);
|
||||
});
|
||||
|
||||
test('fingerprints do not depend on generated descriptions', () => {
|
||||
const row = { RuleID: 'hardcoded-token', Description: 'Original title', File: 'config.ts', StartLine: 4 };
|
||||
const a = parse('gitleaks', [row], 10), b = parse('gitleaks', [{ ...row, Description: 'New generated wording' }], 10);
|
||||
expect(a.candidates[0].id).toBe(b.candidates[0].id);
|
||||
expect(a.planSha256).toBe(b.planSha256);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO scanner failure and hostile-input handling', () => {
|
||||
test('missing optional scanners do not return a clean assessment', () => {
|
||||
const result = parseScannerOutput(plan('gitleaks'), { stdout: '', exitCode: null, unavailable: true });
|
||||
expect(result.status).toBe('not_assessed');
|
||||
expect(result.gaps[0].code).toBe('UNAVAILABLE');
|
||||
});
|
||||
|
||||
test('missing local rules/databases become precise prerequisites without downloads', () => {
|
||||
const p = scannerPlans({ snapshotRoot: '/src', offline: true, selected: ['osv'] })[0];
|
||||
const result = parseScannerOutput(p, { stdout: '{"results":[]}', exitCode: 0 });
|
||||
expect(result.status).toBe('not_assessed');
|
||||
expect(result.gaps[0].code).toBe('PREREQUISITE');
|
||||
expect(result.gaps[0].message).toContain('offline OSV');
|
||||
});
|
||||
|
||||
test('an OSV extraction failure on stderr cannot produce empty-clean JSON coverage', () => {
|
||||
const result = parseScannerOutput(plan('osv'), { stdout: '{"results":[]}', stderr: 'Error during extraction: no offline version of the OSV database is available', exitCode: 0, databaseUpdatedAt: '2026-09-09T00:00:00.000Z' });
|
||||
expect(result.status).toBe('partial');
|
||||
expect(result.gaps.some(g => g.code === 'TOOL_FAILED')).toBe(true);
|
||||
});
|
||||
|
||||
test.each(['', '{', 'null', '[]', '{"results":[]}'])('malformed Semgrep output %s never passes', raw => {
|
||||
const result = parseScannerOutput(plan('semgrep'), { stdout: raw, exitCode: 0 });
|
||||
expect(result.status).toBe('not_assessed');
|
||||
expect(result.gaps[0].code).toBe('INVALID_OUTPUT');
|
||||
});
|
||||
|
||||
test('unrecognized exit code and timeout remain gaps even with syntactically complete output', () => {
|
||||
const result = parseScannerOutput(plan('gitleaks'), { stdout: '[]', exitCode: 2, timedOut: true });
|
||||
expect(result.status).toBe('partial');
|
||||
expect(result.gaps.map(g => g.code)).toEqual(['TIMEOUT', 'TOOL_FAILED']);
|
||||
});
|
||||
|
||||
test('truncation withholds the entire payload including a secret split across capture chunks', () => {
|
||||
const chunks = ['[{"RuleID":"x","Description":"gh', 'p_' + 'A'.repeat(36) + '","File":"x"}]'];
|
||||
const result = parseScannerOutput(plan('gitleaks'), { stdout: chunks.join(''), exitCode: 10, truncated: true });
|
||||
expect(result.status).toBe('not_assessed');
|
||||
expect(result.gaps[0].code).toBe('OUTPUT_LIMIT');
|
||||
expect(result.candidates).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('capture budget applies to UTF-8 bytes and stderr together', () => {
|
||||
const result = parseScannerOutput(plan('gitleaks'), { stdout: '[]', stderr: 'λ'.repeat(MAX_SCANNER_OUTPUT_BYTES / 2), exitCode: 0 });
|
||||
expect(result.gaps[0].code).toBe('OUTPUT_LIMIT');
|
||||
});
|
||||
|
||||
test('marker-only private keys withhold the entire decoded document', () => {
|
||||
const data = [{ RuleID: 'key', Description: ['-----BEGIN ', 'PRIVATE KEY-----\nsecretbody\n-----END ', 'PRIVATE KEY-----'].join(''), File: 'config' }];
|
||||
const result = parse('gitleaks', data, 10);
|
||||
expect(result.status).toBe('not_assessed');
|
||||
expect(result.gaps[0].code).toBe('REDACTION_FAILED');
|
||||
expect(JSON.stringify(result)).not.toContain('secretbody');
|
||||
});
|
||||
|
||||
test('JSON unicode escapes cannot bypass the decoded-string redactor', () => {
|
||||
const secret = 'ghp_' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8';
|
||||
const raw = JSON.stringify([{ RuleID: 'key', Description: secret, File: 'config' }]).replace('ghp_', '\\u0067hp_');
|
||||
const result = parseScannerOutput(plan('gitleaks'), { stdout: raw, exitCode: 10 });
|
||||
expect(result.status).toBe('complete');
|
||||
expect(JSON.stringify(result)).not.toContain(secret);
|
||||
expect(result.candidates[0].message).toContain('REDACTED');
|
||||
});
|
||||
|
||||
test('prototype keys and extreme nesting are rejected without leaking raw content', () => {
|
||||
for (const raw of ['{"__proto__":{"polluted":true}}', '['.repeat(70) + '[]' + ']'.repeat(70)]) {
|
||||
const result = parseScannerOutput(plan('gitleaks'), { stdout: raw, exitCode: 0 });
|
||||
expect(result.status).toBe('not_assessed');
|
||||
expect(result.gaps[0].code).toBe('INVALID_OUTPUT');
|
||||
}
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unknown database freshness is explicit even when there are no advisories', () => {
|
||||
const result = parseScannerOutput(plan('osv'), { stdout: '{"results":[]}', exitCode: 0 });
|
||||
expect(result.status).toBe('partial');
|
||||
expect(result.databaseUpdatedAt).toBeNull();
|
||||
expect(result.gaps[0].code).toBe('UNKNOWN_FRESHNESS');
|
||||
});
|
||||
|
||||
test('finding exit codes cannot certify an empty-clean report', () => {
|
||||
const result = parse('gitleaks', [], 10);
|
||||
expect(result.status).toBe('partial');
|
||||
expect(result.gaps[0].code).toBe('INVALID_OUTPUT');
|
||||
});
|
||||
|
||||
test('a schema version alone is not evidence of a completed Trivy scan', () => {
|
||||
const result = parse('trivy', { SchemaVersion: 2 });
|
||||
expect(result.status).toBe('not_assessed');
|
||||
expect(result.gaps[0].code).toBe('INVALID_OUTPUT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO SARIF import boundary', () => {
|
||||
test('CodeQL SARIF imports read-only candidates with artifact-index locations', () => {
|
||||
const data = sarif(sarifResult());
|
||||
const run = data.runs[0] as Record<string, any>;
|
||||
run.artifacts = [{ location: { uri: 'src/routes.ts' } }];
|
||||
run.results[0].locations[0].physicalLocation.artifactLocation = { index: 0 };
|
||||
const result = importSarif(JSON.stringify(data), { sourceRoot: '/src' });
|
||||
expect(result.status).toBe('complete');
|
||||
expect(result.tool).toBe('sarif');
|
||||
expect(result.candidates[0].tool).toBe('sarif');
|
||||
expect(result.candidates[0].ruleId).toBe('js/sql-injection');
|
||||
expect(result.candidates[0].location).toEqual({ path: 'src/routes.ts', line: 12, column: 3 });
|
||||
});
|
||||
|
||||
test.each(['../.ssh/id_rsa', '%2e%2e/.ssh/id_rsa', '%252e%252e/.ssh/id_rsa', 'file:///etc/passwd', 'file://remote/src/file.ts', 'https://evil.test/collect', 'javascript:alert(1)', '//evil.test/file', 'C:\\Users\\secret', 'src/../../secret', 'src/%00file'])('rejects unsafe artifact URI %s', uri => {
|
||||
const result = importSarif(JSON.stringify(sarif(sarifResult(uri))), { sourceRoot: '/src' });
|
||||
expect(result.status).toBe('partial');
|
||||
expect(result.candidates).toHaveLength(0);
|
||||
expect(result.gaps.length).toBeGreaterThan(0);
|
||||
expect(() => scannerLocation(uri, '/src')).toThrow();
|
||||
});
|
||||
|
||||
test('failed invocation does not erase independently useful candidates or imply complete coverage', () => {
|
||||
const data = sarif(sarifResult());
|
||||
(data.runs[0] as Record<string, unknown>).invocations = [{ executionSuccessful: false }];
|
||||
const result = importSarif(JSON.stringify(data), { sourceRoot: '/src' });
|
||||
expect(result.status).toBe('partial');
|
||||
expect(result.candidates).toHaveLength(1);
|
||||
expect(result.gaps[0].code).toBe('TOOL_FAILED');
|
||||
});
|
||||
|
||||
test('SARIF passing checks are not vulnerability candidates', () => {
|
||||
const result = importSarif(JSON.stringify(sarif({ ...sarifResult(), kind: 'pass' })), { sourceRoot: '/src' });
|
||||
expect(result.status).toBe('complete');
|
||||
expect(result.candidates).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('an untrusted originalUriBaseIds map cannot change the assessment root', () => {
|
||||
const data = sarif(sarifResult('secret.ts'));
|
||||
const run = data.runs[0] as Record<string, any>;
|
||||
run.originalUriBaseIds = { ROOT: { uri: 'file:///etc/' } };
|
||||
run.results[0].locations[0].physicalLocation.artifactLocation.uriBaseId = 'ROOT';
|
||||
const result = importSarif(JSON.stringify(data), { sourceRoot: '/src' });
|
||||
expect(result.candidates).toHaveLength(0);
|
||||
expect(result.status).toBe('partial');
|
||||
});
|
||||
|
||||
test('SARIF external properties are not fetched and missing runs are not a clean scan', () => {
|
||||
const result = importSarif(JSON.stringify({ version: '2.1.0', runs: [], externalProperties: [{ uri: 'https://evil.test/' }] }), { sourceRoot: '/src' });
|
||||
expect(result.status).toBe('partial');
|
||||
expect(result.candidates).toHaveLength(0);
|
||||
expect(result.gaps[0].code).toBe('SKIPPED_INPUT');
|
||||
});
|
||||
|
||||
test('external SARIF result references are an explicit coverage gap', () => {
|
||||
const data = sarif();
|
||||
(data.runs[0] as Record<string, unknown>).externalPropertyFileReferences = { results: [{ location: { uri: 'https://evil.test/results.json' } }] };
|
||||
const result = importSarif(JSON.stringify(data), { sourceRoot: '/src' });
|
||||
expect(result.status).toBe('partial');
|
||||
expect(result.gaps[0].message).toContain('External SARIF');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { capture } from '../lib/cso/snapshot';
|
||||
|
||||
const roots:string[]=[];
|
||||
afterEach(()=>{for(const root of roots.splice(0))fs.rmSync(root,{recursive:true,force:true});});
|
||||
|
||||
function trustedGit():string{
|
||||
const executable=Bun.which('git');
|
||||
if(!executable)throw new Error('git is required for the snapshot disappearance fixture');
|
||||
return executable;
|
||||
}
|
||||
function gitEnvironment(home:string):NodeJS.ProcessEnv{
|
||||
return{...process.env,HOME:home,GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:process.platform==='win32'?'NUL':'/dev/null',GIT_TERMINAL_PROMPT:'0'};
|
||||
}
|
||||
type FileIdentity={path:string;dev:bigint;ino:bigint};
|
||||
function canonicalFile(file:string):string{
|
||||
const resolved=fs.realpathSync.native(file);
|
||||
return process.platform==='win32'?resolved.replace(/^\\\\\?\\UNC\\/i,'\\\\').replace(/^\\\\\?\\/,'').toLowerCase():resolved;
|
||||
}
|
||||
function fileIdentity(file:string):FileIdentity{const stat=fs.statSync(file,{bigint:true});return{path:canonicalFile(file),dev:stat.dev,ino:stat.ino};}
|
||||
function isFileIdentity(candidate:unknown,expected:FileIdentity):boolean{
|
||||
if(path.basename(String(candidate)).toLowerCase()!=='tracked.txt')return false;
|
||||
try{const file=String(candidate),current=fs.statSync(file,{bigint:true});return canonicalFile(file)===expected.path&¤t.dev===expected.dev&¤t.ino===expected.ino;}catch{return false;}
|
||||
}
|
||||
|
||||
function fixture(){
|
||||
const root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-snapshot-disappearance-'));roots.push(root);
|
||||
const repo=path.join(root,'repo'),runDir=path.join(root,'run');
|
||||
fs.mkdirSync(repo);fs.mkdirSync(runDir,{mode:0o700});
|
||||
const git=(...args:string[])=>{const result=spawnSync(trustedGit(),['-C',repo,...args],{encoding:'utf8',env:gitEnvironment(root),timeout:30_000});if(result.status)throw new Error(result.stderr);return result.stdout.trim();};
|
||||
git('init','-q');git('config','user.email','fixture@example.test');git('config','user.name','Fixture');
|
||||
const tracked=path.join(repo,'tracked.txt');fs.writeFileSync(tracked,'security-relevant source\n');git('add','tracked.txt');git('commit','-qm','base');
|
||||
// Match the file itself instead of one pathname spelling. Windows may hand
|
||||
// capture() an 8.3, long, or \\?\-namespaced spelling for this same inode.
|
||||
return{repo,runDir,tracked,trackedIdentity:fileIdentity(tracked),parked:path.join(repo,'.tracked.txt.parked')};
|
||||
}
|
||||
|
||||
describe('CSO snapshot source-disappearance races',()=>{
|
||||
// Bun on Windows does not patch the node:fs binding imported by snapshot.ts,
|
||||
// so these syscall-choreography fixtures cannot activate there. The native
|
||||
// Windows launcher suite instead mutates real source after capture history is
|
||||
// recorded and requires start to fail closed without a snapshot or report.
|
||||
test.skipIf(process.platform==='win32')('rejects a tracked path that disappears at its capture lstat and is restored before final membership validation',async()=>{
|
||||
const {repo,runDir,tracked,trackedIdentity,parked}=fixture(),lstat=fs.lstatSync;
|
||||
let trackedLstats=0,injected=false,failure:unknown;
|
||||
const patched=spyOn(fs,'lstatSync').mockImplementation(((candidate:any,options?:any)=>{
|
||||
if(isFileIdentity(candidate,trackedIdentity)&&++trackedLstats===2){
|
||||
// The first lstat is rejectSpecialFiles' directory walk. The second is
|
||||
// capture's per-entry existence check. Move the tracked file for that
|
||||
// exact syscall, then restore it before any final Git membership check.
|
||||
injected=true;fs.renameSync(tracked,parked);
|
||||
try{return options===undefined?lstat(candidate):lstat(candidate,options);}
|
||||
finally{fs.renameSync(parked,tracked);}
|
||||
}
|
||||
return options===undefined?lstat(candidate):lstat(candidate,options);
|
||||
}) as typeof fs.lstatSync);
|
||||
try{await capture(repo,runDir);}catch(error){failure=error;}finally{patched.mockRestore();}
|
||||
expect(injected).toBe(true);
|
||||
expect(fs.readFileSync(tracked,'utf8')).toBe('security-relevant source\n');
|
||||
expect(failure).toMatchObject({code:'SNAPSHOT_RACE'});
|
||||
});
|
||||
test.skipIf(process.platform==='win32')('rejects a nonignored source file introduced during the final content validation',async()=>{
|
||||
const {repo,runDir,tracked,trackedIdentity}=fixture(),late=path.join(path.dirname(tracked),'late-vulnerable.js'),lstat=fs.lstatSync;let injected=false,failure:unknown;
|
||||
const patched=spyOn(fs,'lstatSync').mockImplementation(((candidate:any,options?:any)=>{
|
||||
if(!injected&&isFileIdentity(candidate,trackedIdentity)&&fs.existsSync(path.join(runDir,'history-status.json'))){injected=true;fs.writeFileSync(late,'export const vulnerable = true\n');}
|
||||
return options===undefined?lstat(candidate):lstat(candidate,options);
|
||||
}) as typeof fs.lstatSync);
|
||||
try{await capture(repo,runDir);}catch(error){failure=error;}finally{patched.mockRestore();}
|
||||
expect(injected).toBe(true);expect(fs.readFileSync(late,'utf8')).toContain('vulnerable');expect(failure).toMatchObject({code:'SNAPSHOT_RACE'});expect(fs.existsSync(path.join(runDir,'snapshot.json'))).toBe(false);
|
||||
});
|
||||
test.skipIf(process.platform==='win32')('binds the audited repository root while source is copied',async()=>{
|
||||
const root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-snapshot-root-swap-'));roots.push(root);const repo=path.join(root,'repo'),parked=path.join(root,'parked'),decoy=path.join(root,'decoy'),gitDir=path.join(root,'gitdir'),runDir=path.join(root,'run'),tracked=path.join(repo,'tracked.txt');fs.mkdirSync(repo);fs.mkdirSync(runDir,{mode:0o700});
|
||||
const run=(...args:string[])=>{const result=spawnSync(trustedGit(),args,{encoding:'utf8',env:gitEnvironment(root),timeout:30_000});if(result.status)throw new Error(result.stderr);};
|
||||
run('init','-q',`--separate-git-dir=${gitDir}`,repo);run('-C',repo,'config','user.email','fixture@example.test');run('-C',repo,'config','user.name','Fixture');fs.writeFileSync(tracked,'secure original source\n');run('-C',repo,'add','tracked.txt');run('-C',repo,'commit','-qm','base');fs.mkdirSync(decoy);fs.writeFileSync(path.join(decoy,'.git'),`gitdir: ${gitDir}\n`);fs.writeFileSync(path.join(decoy,'tracked.txt'),'vulnerable decoy source\n');
|
||||
const lstat=fs.lstatSync;let seen=0,injected=false,failure:unknown;
|
||||
const patched=spyOn(fs,'lstatSync').mockImplementation(((candidate:any,options?:any)=>{if(path.resolve(String(candidate))===tracked&&++seen===2){injected=true;fs.renameSync(repo,parked);fs.renameSync(decoy,repo);}return options===undefined?lstat(candidate):lstat(candidate,options);}) as typeof fs.lstatSync);
|
||||
try{await capture(repo,runDir);}catch(error){failure=error;}finally{patched.mockRestore();if(injected){fs.renameSync(repo,decoy);fs.renameSync(parked,repo);}}
|
||||
expect(injected).toBe(true);expect(failure).toMatchObject({code:'SNAPSHOT_RACE'});expect(fs.readFileSync(path.join(repo,'tracked.txt'),'utf8')).toBe('secure original source\n');expect(fs.existsSync(path.join(runDir,'snapshot.json'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { assertOpenedFileContained, assertSnapshot, capture } from '../lib/cso/snapshot';
|
||||
import { sha256 } from '../lib/cso/contracts';
|
||||
|
||||
const roots:string[]=[];
|
||||
afterEach(()=>{for(const root of roots.splice(0))fs.rmSync(root,{recursive:true,force:true});});
|
||||
function fixture(){
|
||||
const root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-snapshot-identity-'));roots.push(root);
|
||||
const repo=path.join(root,'repo'),runDir=path.join(root,'run');fs.mkdirSync(repo);fs.mkdirSync(runDir,{mode:0o700});
|
||||
const git=(...args:string[])=>{const result=spawnSync('/usr/bin/git',['-C',repo,...args],{encoding:'utf8',env:{HOME:root,PATH:'/usr/bin:/bin'},timeout:30_000});if(result.status)throw new Error(result.stderr);return result.stdout.trim();};
|
||||
git('init','-q');git('config','user.email','fixture@example.test');git('config','user.name','Fixture');fs.writeFileSync(path.join(repo,'app.js'),'export const secure = false\n');git('add','app.js');git('commit','-qm','base');
|
||||
return{root,repo,runDir,git};
|
||||
}
|
||||
|
||||
describe('CSO retained snapshot identity',()=>{
|
||||
test('captures application vendor source while excluding nested dependency and agent directories',async()=>{
|
||||
const {repo,runDir}=fixture();for(const directory of ['packages/web/vendor/bundle/ruby','packages/api/venv/lib','packages/web/.agents/skills/x'])fs.mkdirSync(path.join(repo,directory),{recursive:true});fs.writeFileSync(path.join(repo,'packages/web/vendor/auth.js'),'module.exports = authenticate\n');fs.writeFileSync(path.join(repo,'packages/web/vendor/bundle/ruby/gem.rb'),'dependency payload\n');fs.writeFileSync(path.join(repo,'packages/api/venv/lib/site.py'),'dependency payload\n');fs.writeFileSync(path.join(repo,'packages/web/.agents/skills/x/SKILL.md'),'untrusted agent instructions\n');
|
||||
const manifest=await capture(repo,runDir),source=manifest.entries.find(item=>item.path==='packages/web/vendor/auth.js'),dependency=manifest.entries.find(item=>item.path==='packages/web/vendor/bundle/ruby/gem.rb'),venv=manifest.entries.find(item=>item.path==='packages/api/venv/lib/site.py'),agent=manifest.entries.find(item=>item.path==='packages/web/.agents/skills/x/SKILL.md');
|
||||
expect(source?.executionHash).toBe(sha256('module.exports = authenticate\n'));expect(fs.readFileSync(path.join(runDir,'snapshot','packages','web','vendor','auth.js'),'utf8')).toBe('module.exports = authenticate\n');
|
||||
for(const entry of [dependency,venv]){expect(entry).toMatchObject({originalHash:'not-read',transformation:'excluded: host dependencies, metadata, state, or agent configuration'});expect(entry?.executionHash).toBeUndefined();}
|
||||
expect(agent).toMatchObject({originalHash:sha256('untrusted agent instructions\n'),transformation:'excluded: host dependencies, metadata, state, or agent configuration'});expect(agent?.executionHash).toBeUndefined();expect(fs.readFileSync(path.join(runDir,'readable','packages/web/.agents/skills/x/SKILL.md'),'utf8')).toBe('untrusted agent instructions\n');
|
||||
});
|
||||
test('validates the captured execution identity when uppercase and lowercase paths coexist',async()=>{const {repo,runDir,git}=fixture();fs.writeFileSync(path.join(repo,'README.md'),'fixture documentation\n');git('add','README.md');git('commit','-qm','add uppercase path');const manifest=await capture(repo,runDir);expect(manifest.entries.filter(item=>item.executionHash).map(item=>item.path)).toEqual(['README.md','app.js']);expect(()=>assertSnapshot(runDir,manifest)).not.toThrow();});
|
||||
test('binds original, sanitized, and opaque path identities',async()=>{const {repo,runDir}=fixture(),manifest=await capture(repo,runDir);assertSnapshot(runDir,manifest);const changedEntry=structuredClone(manifest);changedEntry.entries[0].originalHash='a'.repeat(64);expect(()=>assertSnapshot(runDir,changedEntry)).toThrow('original identity');const changedRoot=structuredClone(manifest);changedRoot.originalHash='b'.repeat(64);expect(()=>assertSnapshot(runDir,changedRoot)).toThrow('original identity');const changedPathId=structuredClone(manifest);changedPathId.entries[0].pathId='c'.repeat(32);expect(()=>assertSnapshot(runDir,changedPathId)).toThrow('invalid source entry');});
|
||||
test.skipIf(process.platform==='win32')('preserves retained source modes under the required restrictive umask',async()=>{const {repo,runDir,git}=fixture(),script=path.join(repo,'tool.sh');fs.writeFileSync(script,'#!/bin/sh\nexit 0\n');fs.chmodSync(path.join(repo,'app.js'),0o644);fs.chmodSync(script,0o755);git('add','app.js','tool.sh');git('commit','-qm','add executable input');const previous=process.umask(0o077);let manifest;try{manifest=await capture(repo,runDir);}finally{process.umask(previous);}expect(manifest.entries.find(item=>item.path==='app.js')?.mode).toBe(0o644);expect(manifest.entries.find(item=>item.path==='tool.sh')?.mode).toBe(0o755);expect(fs.statSync(path.join(runDir,'snapshot','app.js')).mode&0o777).toBe(0o644);expect(fs.statSync(path.join(runDir,'snapshot','tool.sh')).mode&0o777).toBe(0o755);assertSnapshot(runDir,manifest);});
|
||||
test('rejects a replaced snapshot root before following it',async()=>{const {repo,runDir}=fixture(),manifest=await capture(repo,runDir),snapshot=path.join(runDir,'snapshot'),replacement=path.join(runDir,'snapshot-replacement');fs.renameSync(snapshot,replacement);fs.symlinkSync(replacement,snapshot);expect(()=>assertSnapshot(runDir,manifest)).toThrow('private owned directory');});
|
||||
test.skipIf(process.platform==='win32')('detects an ancestor symlink swap after a source file is opened',()=>{const {root,repo}=fixture(),outside=path.join(root,'outside'),alias=path.join(repo,'swapped'),secret=path.join(outside,'secret.txt');fs.mkdirSync(outside);fs.writeFileSync(secret,'outside source must not be captured\n');fs.symlinkSync(outside,alias);const full=path.join(alias,'secret.txt'),fd=fs.openSync(full,fs.constants.O_RDONLY);try{expect(()=>assertOpenedFileContained(repo,full,fd,fs.fstatSync(fd))).toThrow('escaped the audited root');}finally{fs.closeSync(fd);}});
|
||||
test.skipIf(process.platform==='win32')('rejects an untracked FIFO before opening it',async()=>{const {repo,runDir}=fixture(),fifo=path.join(repo,'request.pipe'),created=spawnSync('/usr/bin/mkfifo',[fifo],{encoding:'utf8',timeout:5_000});expect(created.status).toBe(0);const started=Date.now();await expect(capture(repo,runDir)).rejects.toThrow('Symlink or special source file');expect(Date.now()-started).toBeLessThan(2_000);});
|
||||
test('withholds a large regular source file without aborting the bounded snapshot',async()=>{const {repo,runDir}=fixture(),large=Buffer.alloc(1024*1024+1,0x61);fs.writeFileSync(path.join(repo,'large.txt'),large);const manifest=await capture(repo,runDir),entry=manifest.entries.find(item=>item.path==='large.txt');expect(entry).toMatchObject({bytes:large.length,originalHash:sha256(large),transformation:'withheld: exceeds the 1 MiB redacting-reader limit'});expect(entry?.executionHash).toBeUndefined();expect(fs.existsSync(path.join(runDir,'snapshot','large.txt'))).toBe(false);assertSnapshot(runDir,manifest);});
|
||||
test('binds a recheck snapshot to the exact captured descendant commit',async()=>{const {repo,runDir,git}=fixture(),ancestor=git('rev-parse','HEAD');git('checkout','--orphan','unrelated');fs.writeFileSync(path.join(repo,'app.js'),'export const unrelated = true\n');git('add','app.js');git('commit','-qm','unrelated root');await expect(capture(repo,runDir,undefined,ancestor)).rejects.toThrow('not a descendant');});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { afterAll, afterEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';import * as os from 'node:os';import * as path from 'node:path';import { spawn, spawnSync } from 'node:child_process';
|
||||
import { assertSnapshot, capture } from '../lib/cso/snapshot';import { CsoError } from '../lib/cso/contracts';import { runProcess, sanitizeForJson, sanitizeHelperForJson } from '../lib/cso/process';import { discardAtomicNoReplaceTemp, finalizeReplayTemporary, loadReport, newRun, privateRoot, readJson, retention, saveReport, secureDirectory, stateRoot, withLock, writeHelperJson, writeJson } from '../lib/cso/state';
|
||||
const roots:string[]=[];const tmp=()=>{const p=fs.mkdtempSync(path.join(os.tmpdir(),'cso-snapshot-'));roots.push(p);return p;};afterEach(()=>{for(const p of roots.splice(0))fs.rmSync(p,{recursive:true,force:true});});
|
||||
const originalState=process.env.GSTACK_HOME,state=fs.mkdtempSync(path.join(os.tmpdir(),'cso-state-'));process.env.GSTACK_HOME=state;afterAll(()=>{if(originalState===undefined)delete process.env.GSTACK_HOME;else process.env.GSTACK_HOME=originalState;fs.rmSync(state,{recursive:true,force:true});});
|
||||
function git(repo:string,...args:string[]){const r=spawnSync('/usr/bin/git',['-C',repo,...args],{encoding:'utf8',env:{PATH:'/usr/bin:/bin',HOME:repo},timeout:30_000});if(r.status)throw new Error(r.stderr);return r.stdout;}
|
||||
function repo(){const p=tmp();git(p,'init','-q');git(p,'config','user.email','fixture@example.test');git(p,'config','user.name','Fixture');fs.writeFileSync(path.join(p,'tracked.txt'),'base\n');git(p,'add','tracked.txt');git(p,'commit','-qm','base');return p;}
|
||||
async function waitForPath(file:string,timeout=5000):Promise<void>{const deadline=Date.now()+timeout;while(!fs.existsSync(file)){if(Date.now()>=deadline)throw new Error(`Timed out waiting for ${path.basename(file)}`);await Bun.sleep(5);}}
|
||||
function childExit(child:ReturnType<typeof spawn>,timeout=5000):Promise<number|null>{return new Promise((resolve,reject)=>{if(child.exitCode!==null){resolve(child.exitCode);return;}const timer=setTimeout(()=>reject(new Error(`Child ${child.pid} did not exit`)),timeout);child.once('error',error=>{clearTimeout(timer);reject(error);});child.once('close',code=>{clearTimeout(timer);resolve(code);});});}
|
||||
function stopChildren(children:Array<ReturnType<typeof spawn>>):void{for(const child of children){if(!child.pid||child.exitCode!==null)continue;try{process.kill(child.pid,'SIGCONT');}catch{}try{process.kill(child.pid,'SIGKILL');}catch{}}}
|
||||
function leaseContenderSource():string{return `import fs from 'node:fs';import * as fsModule from 'node:fs';import * as cryptoModule from 'node:crypto';import path from 'node:path';import {spyOn} from 'bun:test';
|
||||
const dir=process.env.RACE_DIR!,barrier=process.env.BARRIER!,id=process.env.RACE_ID!,mode=process.env.RACE_MODE??'normal',token=process.env.RACE_TOKEN,leases=path.join(dir,'.mutation-lock-leases'),wait=new Int32Array(new SharedArrayBuffer(4));let triggered=false;
|
||||
const write=fs.writeFileSync.bind(fs),mark=(name,value='ready')=>write(path.join(barrier,name),value,{flag:'wx'}),waitFor=(name)=>{const deadline=Date.now()+5000;while(!fs.existsSync(path.join(barrier,name))){if(Date.now()>=deadline)throw new Error('barrier timeout: '+name);Atomics.wait(wait,0,0,1);}};
|
||||
if(token){const random=cryptoModule.randomBytes.bind(cryptoModule);spyOn(cryptoModule,'randomBytes').mockImplementation(((size,...args)=>size===16?Buffer.from(token,'hex'):random(size,...args)) as typeof cryptoModule.randomBytes);}
|
||||
const read=fs.readdirSync.bind(fs);spyOn(fsModule,'readdirSync').mockImplementation(((candidate,...args)=>{const result=read(candidate,...args),names=result as any[];if(mode==='pause-candidate'&&!triggered&&String(candidate)===leases&&names.some(name=>token?String(name)===token+'.json':/^[a-f0-9]{32}\.json$/.test(String(name)))){triggered=true;mark('paused-'+id);process.kill(process.pid,'SIGSTOP');}if(mode==='pause-later-scan'&&!triggered&&String(candidate)===leases&&names.filter(name=>String(name).endsWith('.decision')).length>=2&&!names.some(name=>String(name).includes('.active.'))){triggered=true;mark('paused-'+id);process.kill(process.pid,'SIGSTOP');}return result;}) as typeof fs.readdirSync);
|
||||
spyOn(fsModule,'writeFileSync').mockImplementation(((target,data,...args)=>{const name=path.basename(String(target));if(name.includes('.decision.tmp.')){let decision;try{decision=JSON.parse(String(data));}catch{}if(decision?.kind==='ticket'&&decision.token===token){try{mark('choice-'+id,decision.ticket);}catch{}if(mode==='pause-before-decision'&&!triggered){triggered=true;mark('paused-'+id);process.kill(process.pid,'SIGSTOP');}}if(decision?.kind==='withdraw'&&mode==='pause-before-withdraw'&&!triggered){triggered=true;mark('paused-'+id);process.kill(process.pid,'SIGSTOP');}}return write(target,data,...args);}) as typeof fs.writeFileSync);
|
||||
const link=fs.linkSync.bind(fs);spyOn(fsModule,'linkSync').mockImplementation(((source,target)=>{const name=path.basename(String(target));if(mode==='wait-active'&&!triggered&&name.includes('.active.')){triggered=true;mark('doorway-'+id);waitFor('paused-H');}return link(source,target);}) as typeof fs.linkSync);
|
||||
const unlink=fs.unlinkSync.bind(fs);spyOn(fsModule,'unlinkSync').mockImplementation(((target)=>{const result=unlink(target),name=path.basename(String(target)),candidateToken=name.endsWith('.json')?name.slice(0,-5):'';if((mode==='pause-decision'||mode==='pause-ticket')&&!triggered&&name.includes('.decision.tmp.')&&(!token||name.startsWith(token+'.decision.tmp.'))){triggered=true;mark('paused-'+id);process.kill(process.pid,'SIGSTOP');}if((mode==='pause-after-candidate-cleanup'||mode==='pause-withdraw-cleanup')&&!triggered&&/^[a-f0-9]{32}$/.test(candidateToken)&&(!token||candidateToken===token)&&fs.existsSync(path.join(leases,candidateToken+'.decision'))){triggered=true;mark('paused-'+id);process.kill(process.pid,'SIGSTOP');}return result;}) as typeof fs.unlinkSync);
|
||||
const {withLock}=await import(${JSON.stringify(path.resolve(import.meta.dir,'../lib/cso/state.ts'))});let outcome='success';try{withLock(dir,()=>{const active=path.join(barrier,'work-active');try{write(active,id,{flag:'wx'});}catch{fs.appendFileSync(path.join(barrier,'overlap'),'overlap\\n');}mark('entered-'+id);if(process.env.RACE_HOLD==='1')waitFor('release-'+id);try{if(fs.readFileSync(active,'utf8')===id)fs.unlinkSync(active);}catch{}});}catch(error){outcome=String(error?.code??error);}write(path.join(barrier,'result-'+id),outcome);`;}
|
||||
describe('CSO dirty snapshot boundary',()=>{
|
||||
test('captures tracked modifications and nonignored untracked source without changing Git state',async()=>{const p=repo();fs.writeFileSync(path.join(p,'tracked.txt'),'dirty\n');fs.writeFileSync(path.join(p,'new.txt'),'untracked\n');fs.writeFileSync(path.join(p,'.gitignore'),'ignored.txt\n');fs.writeFileSync(path.join(p,'ignored.txt'),'ignored\n');const before=git(p,'status','--porcelain=v1','-z'),run=newRun(p),m=await capture(p,run.dir,'HEAD');expect(fs.readFileSync(path.join(run.dir,'snapshot','tracked.txt'),'utf8')).toBe('dirty\n');expect(fs.readFileSync(path.join(run.dir,'snapshot','new.txt'),'utf8')).toBe('untracked\n');expect(fs.existsSync(path.join(run.dir,'snapshot','ignored.txt'))).toBe(false);expect(git(p,'status','--porcelain=v1','-z')).toBe(before);expect(m.changedPaths).toContain('new.txt');});
|
||||
test('admits source membership and time before an unbounded startup walk',async()=>{const p=repo();fs.writeFileSync(path.join(p,'second.txt'),'second\n');const tooMany=newRun(p);await expect(capture(p,tooMany.dir,undefined,undefined,{maxEntries:1})).rejects.toMatchObject({code:'MISSING_INPUT'});expect(fs.existsSync(path.join(tooMany.dir,'snapshot'))).toBe(false);const expired=newRun(p);await expect(capture(p,expired.dir,undefined,undefined,{deadlineMs:Date.now()-1})).rejects.toMatchObject({code:'DEADLINE'});expect(fs.existsSync(path.join(expired.dir,'snapshot'))).toBe(false);});
|
||||
test('includes chmod-only changes in diff scope',async()=>{const p=repo();fs.chmodSync(path.join(p,'tracked.txt'),0o755);const manifest=await capture(p,newRun(p).dir,'HEAD');expect(manifest.changedPaths).toContain('tracked.txt');});
|
||||
test('does not invoke configured clean filters, hooks, fsmonitor, or PATH shims',async()=>{const p=repo(),marker=path.join(p,'marker');fs.writeFileSync(path.join(p,'.gitattributes'),'*.txt filter=hostile\n');git(p,'config','filter.hostile.clean',`/bin/sh -c 'touch ${marker}; cat'`);git(p,'config','core.fsmonitor',`/bin/sh -c 'touch ${marker}'`);const fake=path.join(p,'bin');fs.mkdirSync(fake);fs.writeFileSync(path.join(fake,'git'),`#!/bin/sh\ntouch '${marker}'\nexit 99\n`,{mode:0o755});const old=process.env.PATH;process.env.PATH=`${fake}:${old}`;try{await capture(p,newRun(p).dir,'HEAD');}finally{process.env.PATH=old;}expect(fs.existsSync(marker)).toBe(false);});
|
||||
test('rejects symlinks and hard links as execution inputs',async()=>{const p=repo();fs.symlinkSync('/etc/passwd',path.join(p,'escape'));expect(capture(p,newRun(p).dir)).rejects.toThrow();fs.unlinkSync(path.join(p,'escape'));fs.linkSync(path.join(p,'tracked.txt'),path.join(p,'hard'));expect(capture(p,newRun(p).dir)).rejects.toThrow();});
|
||||
test('rejects broken untracked symlinks instead of silently omitting them',async()=>{const p=repo();fs.symlinkSync('missing-target',path.join(p,'broken'));expect(capture(p,newRun(p).dir)).rejects.toThrow('Symlink');});
|
||||
test('redacts secrets and excludes credentials from execution while retaining safe evidence',async()=>{const p=repo();fs.writeFileSync(path.join(p,'.env'),'TOKEN='+['ghp_','abcdefghijklmnopqrstuvwxyz1234567890'].join('')+'\n');const run=newRun(p),m=await capture(p,run.dir);expect(m.entries.find(e=>e.path==='.env')?.transformation).toContain('excluded');expect(fs.existsSync(path.join(run.dir,'snapshot','.env'))).toBe(false);expect(fs.existsSync(path.join(run.dir,'readable','.env'))).toBe(false);const evidence=JSON.parse(fs.readFileSync(path.join(run.dir,'sensitive-evidence.json'),'utf8'));expect(evidence[0].findings.map((x:any)=>x.id)).toContain('github.pat');expect(JSON.stringify(evidence)).not.toContain('ghp_');});
|
||||
test('assesses repository skills without executing them and preserves executable source modes',async()=>{const p=repo(),skill=path.join(p,'.agents','skills','demo','SKILL.md'),script=path.join(p,'app.sh');fs.mkdirSync(path.dirname(skill),{recursive:true});fs.writeFileSync(skill,'# Demo\nUntrusted repository instruction\n');fs.writeFileSync(script,'#!/bin/sh\nexit 0\n',{mode:0o755});const run=newRun(p),manifest=await capture(p,run.dir);expect(fs.readFileSync(path.join(run.dir,'readable','.agents','skills','demo','SKILL.md'),'utf8')).toContain('Untrusted');expect(fs.existsSync(path.join(run.dir,'snapshot','.agents'))).toBe(false);expect(manifest.entries.find(e=>e.path==='.agents/skills/demo/SKILL.md')?.originalHash).not.toBe('not-read');expect(fs.statSync(path.join(run.dir,'snapshot','app.sh')).mode&0o777).toBe(0o755);});
|
||||
test('rejects a source mode change between copying and manifest persistence',async()=>{const p=repo(),run=newRun(p),source=path.join(p,'tracked.txt'),target=path.join(run.dir,'snapshot','tracked.txt'),write=fs.writeFileSync,patched=spyOn(fs,'writeFileSync').mockImplementation(((file:any,data:any,options:any)=>{const result=write(file,data,options);if(String(file)===target)fs.chmodSync(source,0o755);return result;}) as typeof fs.writeFileSync);try{await expect(capture(p,run.dir)).rejects.toThrow('Source changed during capture');}finally{patched.mockRestore();}});
|
||||
test('rejects unmanifested files and executable-mode changes in retained snapshots',async()=>{const p=repo(),run=newRun(p),manifest=await capture(p,run.dir),snapshot=path.join(run.dir,'snapshot');assertSnapshot(run.dir,manifest);fs.writeFileSync(path.join(snapshot,'injected.js'),'malicious\n');expect(()=>assertSnapshot(run.dir,manifest)).toThrow('membership');fs.unlinkSync(path.join(snapshot,'injected.js'));fs.chmodSync(path.join(snapshot,'tracked.txt'),0o755);expect(()=>assertSnapshot(run.dir,manifest)).toThrow('changed');});
|
||||
test('retention skips a run with a live mutation lease',()=>{const now=Date.now(),repo='d'.repeat(24),run=`${now-31*86400_000}-${'1'.repeat(16)}`,dir=secureDirectory(path.join(privateRoot(),repo,run)),payload=path.join(dir,'report.json');fs.writeFileSync(payload,'live lease payload\n',{mode:0o600});withLock(dir,()=>{retention(now);expect(fs.existsSync(payload)).toBe(true);});retention(now);expect(fs.existsSync(dir)).toBe(false);});
|
||||
test('bounded retention stops before destructive cleanup when its maintenance budget is exhausted',()=>{const now=Date.now(),repo='e'.repeat(24),run=`${now-31*86400_000}-${'8'.repeat(16)}`,dir=secureDirectory(path.join(privateRoot(),repo,run));fs.writeFileSync(path.join(dir,'report.json'),'expired private state\n',{mode:0o600});const result=retention(now,{deadlineMs:0,maxEntries:1});expect(result).toEqual({complete:false,visited:0});expect(fs.existsSync(dir)).toBe(true);retention(now);expect(fs.existsSync(dir)).toBe(false);});
|
||||
test('bounded retention completes discovery before deleting any run',()=>{const now=Date.now(),repo='f'.repeat(24),run=`${now-31*86400_000}-${'9'.repeat(16)}`,dir=secureDirectory(path.join(privateRoot(),repo,run));fs.writeFileSync(path.join(dir,'report.json'),'expired private state\n',{mode:0o600});const result=retention(now,{deadlineMs:now+60_000,maxEntries:6});expect(result).toEqual({complete:false,visited:6});expect(fs.existsSync(dir)).toBe(true);retention(now);expect(fs.existsSync(dir)).toBe(false);});
|
||||
test('bounded retention resumes an interrupted tree deletion without one recursive walk',()=>{const previous=process.env.GSTACK_HOME,isolate=tmp();process.env.GSTACK_HOME=isolate;try{const now=Date.now(),repo='1'.repeat(24),run=`${now-31*86400_000}-${'a'.repeat(16)}`,repoDir=path.join(privateRoot(),repo),dir=secureDirectory(path.join(repoDir,run)),snapshot=secureDirectory(path.join(dir,'snapshot'));for(let index=0;index<20;index++)fs.writeFileSync(path.join(snapshot,`${index}.txt`),'private state\n',{mode:0o600});const result=retention(now,{deadlineMs:now+60_000,maxEntries:30});expect(result).toEqual({complete:false,visited:30});expect(fs.existsSync(dir)).toBe(false);expect(fs.readdirSync(repoDir).some(name=>name.startsWith(`.retired-${run}-`))).toBe(true);retention(now);expect(fs.readdirSync(repoDir).some(name=>name.startsWith(`.retired-${run}-`))).toBe(false);}finally{if(previous===undefined)delete process.env.GSTACK_HOME;else process.env.GSTACK_HOME=previous;}});
|
||||
test('repair bundles expire from their authenticated bundle time, independent of run age',()=>{const now=Date.now(),repo='c'.repeat(24),run=`${now-31*86400_000}-${'2'.repeat(16)}`,dir=secureDirectory(path.join(privateRoot(),repo,run)),bundles=secureDirectory(path.join(dir,'bundles')),expiredId='3'.repeat(32),retainedId='4'.repeat(32),record=(id:string,created:number)=>{const createdAt=new Date(created).toISOString();return{schemaVersion:3,id,runId:run,createdAt,expiresAt:new Date(created+30*86400_000).toISOString(),verification:{id,runId:run,createdAt}};};writeHelperJson(path.join(bundles,`${expiredId}.json`),record(expiredId,now-31*86400_000));writeHelperJson(path.join(bundles,`${retainedId}.json`),record(retainedId,now-86400_000));retention(now);expect(fs.existsSync(path.join(bundles,`${expiredId}.json`))).toBe(false);expect(fs.existsSync(path.join(bundles,`${retainedId}.json`))).toBe(true);expect(fs.existsSync(dir)).toBe(true);retention(now+30*86400_000);expect(fs.existsSync(dir)).toBe(false);});
|
||||
test('replay temporary state survives cleanup failure until every detached watchdog acknowledges cleanup',()=>{const now=Date.now(),repo='b'.repeat(24),run=`${now}-${'5'.repeat(16)}`,dir=secureDirectory(path.join(privateRoot(),repo,run)),control=secureDirectory(path.join(dir,'supervision','repair-attempt')),docker=secureDirectory(path.join(dir,'preparation-execution','offline-attempt'));fs.writeFileSync(path.join(control,'attempt.ready'),'ready\n',{mode:0o600});fs.writeFileSync(path.join(control,'attempt.terminal'),'normal cleanup complete\n',{mode:0o600});fs.writeFileSync(path.join(docker,'watchdog.ready'),'ready\n',{mode:0o600});fs.writeFileSync(path.join(docker,'watchdog.event'),'cleanup incomplete; retrying exact journaled resources\n',{mode:0o600});finalizeReplayTemporary(dir);expect(fs.existsSync(path.join(dir,'.ephemeral-replay.json'))).toBe(true);retention(now);expect(fs.existsSync(control)).toBe(true);expect(fs.existsSync(docker)).toBe(true);fs.writeFileSync(path.join(control,'attempt.stopped'),'normal cleanup acknowledged\n',{mode:0o600});retention(now);expect(fs.existsSync(dir)).toBe(true);fs.writeFileSync(path.join(docker,'watchdog.stopped'),'normal cleanup acknowledged\n',{mode:0o600});retention(now);expect(fs.existsSync(dir)).toBe(false);});
|
||||
test('retention recovers a hidden run retirement left by a dead helper',()=>{const now=Date.now(),repo='a'.repeat(24),run=`${now-31*86400_000}-${'6'.repeat(16)}`,retired=secureDirectory(path.join(privateRoot(),repo,`.retired-${run}-${'7'.repeat(32)}`));fs.writeFileSync(path.join(retired,'payload'),'private state\n',{mode:0o600});retention(now);expect(fs.existsSync(retired)).toBe(false);});
|
||||
});
|
||||
describe('private state and process output',()=>{
|
||||
test('state root mirrors the plugin convention and private writes use restrictive modes',()=>{const base=tmp(),root=stateRoot({HOME:base,GSTACK_HOME:'',CLAUDE_PLUGIN_ROOT:'',CLAUDE_PLUGIN_DATA:''});expect(root).toBe(path.join(base,'.gstack'));const dir=secureDirectory(path.join(base,'state')),file=path.join(dir,'x.json');writeJson(file,{token:'safe'});expect(fs.statSync(dir).mode&0o777).toBe(0o700);expect(fs.statSync(file).mode&0o777).toBe(0o600);});
|
||||
test('private root preserves caller-owned container modes and secures only its own boundary',()=>{if(process.platform==='win32')return;const old=process.env.GSTACK_HOME,base=tmp(),selected=path.join(base,'selected'),security=path.join(selected,'security'),sentinel=path.join(selected,'keep');fs.mkdirSync(security,{recursive:true});fs.chmodSync(selected,0o755);fs.chmodSync(security,0o751);fs.writeFileSync(sentinel,'kept');const selectedInode=fs.statSync(selected).ino,securityInode=fs.statSync(security).ino;process.env.GSTACK_HOME=selected;try{expect(privateRoot()).toBe(path.join(security,'cso'));expect(privateRoot()).toBe(path.join(security,'cso'));expect(fs.statSync(selected).mode&0o777).toBe(0o755);expect(fs.statSync(security).mode&0o777).toBe(0o751);expect(fs.statSync(selected).ino).toBe(selectedInode);expect(fs.statSync(security).ino).toBe(securityInode);expect(fs.readFileSync(sentinel,'utf8')).toBe('kept');expect(fs.statSync(path.join(security,'cso')).mode&0o777).toBe(0o700);}finally{if(old===undefined)delete process.env.GSTACK_HOME;else process.env.GSTACK_HOME=old;}});
|
||||
test('private root creates a missing selected container privately',()=>{const old=process.env.GSTACK_HOME,base=tmp(),selected=path.join(base,'missing');process.env.GSTACK_HOME=selected;try{expect(privateRoot()).toBe(path.join(selected,'security','cso'));for(const directory of [selected,path.join(selected,'security'),path.join(selected,'security','cso')])expect(fs.statSync(directory).mode&0o777).toBe(0o700);}finally{if(old===undefined)delete process.env.GSTACK_HOME;else process.env.GSTACK_HOME=old;}});
|
||||
test('private root rejects unsafe or filesystem-root containers without repairing them',()=>{if(process.platform==='win32')return;const old=process.env.GSTACK_HOME,base=tmp(),selected=path.join(base,'unsafe');fs.mkdirSync(selected);fs.chmodSync(selected,0o770);const inode=fs.statSync(selected).ino;try{process.env.GSTACK_HOME=selected;expect(()=>privateRoot()).toThrow('group- or world-writable ancestor');expect(fs.statSync(selected).mode&0o777).toBe(0o770);expect(fs.statSync(selected).ino).toBe(inode);expect(fs.existsSync(path.join(selected,'security'))).toBe(false);process.env.GSTACK_HOME=path.parse(selected).root;const chmod=spyOn(fs,'chmodSync');try{expect(()=>privateRoot()).toThrow('filesystem root');expect(chmod.mock.calls.some(call=>path.resolve(String(call[0]))===path.parse(selected).root)).toBe(false);}finally{chmod.mockRestore();}}finally{if(old===undefined)delete process.env.GSTACK_HOME;else process.env.GSTACK_HOME=old;}});
|
||||
test('concurrent first-run directory creation accepts only the expected EEXIST race',()=>{const base=tmp(),target=path.join(base,'first','second'),first=path.join(base,'first'),mkdir=fs.mkdirSync;let raced=false;const patched=spyOn(fs,'mkdirSync').mockImplementation(((candidate:any,options:any)=>{if(String(candidate)===first&&!raced){raced=true;mkdir(candidate,options);const error:any=new Error('created concurrently');error.code='EEXIST';throw error;}return mkdir(candidate,options);}) as typeof fs.mkdirSync);try{expect(secureDirectory(target)).toBe(target);expect(raced).toBe(true);expect(fs.statSync(target).mode&0o777).toBe(0o700);}finally{patched.mockRestore();}});
|
||||
test('private state rejects a non-sticky group-writable ancestor',()=>{if(process.platform==='win32')return;const base=tmp();fs.chmodSync(base,0o770);expect(()=>secureDirectory(path.join(base,'private'))).toThrow('group- or world-writable ancestor');});
|
||||
test('split process output is joined and redacted before return',async()=>{const dir=tmp(),r=await runProcess('/bin/sh',['-c',"printf 'ghp_abcdefghijklmnop'; printf 'qrstuvwxyz1234567890' >&2"],{cwd:dir,env:{PATH:'/usr/bin:/bin'}});expect(r.stdout+r.stderr).not.toContain('ghp_');expect(r.stdout.toLowerCase()).toContain('redacted');});
|
||||
test('alternating stdout and stderr chunks cannot split a secret marker',async()=>{const dir=tmp(),r=await runProcess('/bin/sh',['-c',"printf g; sleep .02; printf hp_abcdefghijklmnop >&2; sleep .02; printf qrstuvwxyz1234567890"],{cwd:dir,env:{PATH:'/usr/bin:/bin'}});expect(r.stdout+r.stderr).not.toContain('abcdefghijklmnop');expect(r.stdout.toLowerCase()).toContain('redacted');});
|
||||
test('marker-only private keys withhold the whole payload',async()=>{const dir=tmp();expect(runProcess('/bin/sh',['-c',"printf '%s' \"$1\"",'sh',['-----BEGIN ','PRIVATE KEY-----\nraw\n-----END ','PRIVATE KEY-----'].join('')],{cwd:dir,env:{PATH:'/usr/bin:/bin'}})).rejects.toMatchObject({code:'REDACTION_FAILED'});});
|
||||
test('untrusted hash-like keys do not bypass structured redaction',()=>{const dir=tmp(),file=path.join(dir,'value.json');writeJson(file,{payloadHash:['ghp_','abcdefghijklmnopqrstuvwxyz1234567890'].join('')});expect(fs.readFileSync(file,'utf8')).not.toContain('ghp_');});
|
||||
test('only schema-bound helper metadata bypasses content redaction',()=>{const token='1abcdefabcdefabcdefabcdefabcdefa',hash='a'.repeat(64),untrusted=sanitizeForJson({id:token,findingId:token,sourceHash:hash}) as any;expect(untrusted.id).not.toBe(token);expect(untrusted.findingId).not.toBe(token);const trusted=sanitizeHelperForJson({id:token,findingId:token,artifactId:token,reviewArtifactId:token,bundleId:token,sourceHash:hash,preparedManifestHash:hash}) as any;expect(trusted).toEqual({id:token,findingId:token,artifactId:token,reviewArtifactId:token,bundleId:token,sourceHash:hash,preparedManifestHash:hash});const dir=tmp(),file=path.join(dir,'claim.json');writeHelperJson(file,{findingId:token,outcome:'open'});expect(readJson(file)).toEqual({findingId:token,outcome:'open'});});
|
||||
test('oversized report growth is rejected before replacing the last readable report',()=>{const dir=tmp(),report:any={schemaVersion:3,runId:'1'.repeat(13)+'-'+'a'.repeat(16),repoId:'b'.repeat(24),createdAt:new Date().toISOString(),deadline:new Date(Date.now()+60_000).toISOString(),status:'running',completeness:'not assessed',policy:{mode:'daily',scope:'default',diff:false,base:'origin/main',offline:true,budgetSeconds:600,maxWorkers:3,maxRepairs:3},source:{root:'/source',snapshotHash:'c'.repeat(64),originalHash:'d'.repeat(64)},application:{actors:[],assets:[],entrypoints:[],tenantBoundaries:[],sensitiveOperations:[],invariants:[]},coverage:[],findings:[],gaps:[],events:[]};saveReport(dir,report);const original=fs.readFileSync(path.join(dir,'report.json'),'utf8');report.events=Array.from({length:4000},(_,i)=>({at:new Date().toISOString(),kind:'evidence',message:`${i}:`+'x'.repeat(300)}));expect(()=>saveReport(dir,report)).toThrow('previous artifact was preserved');expect(fs.readFileSync(path.join(dir,'report.json'),'utf8')).toBe(original);expect(loadReport(dir).status).toBe('running');});
|
||||
test('a fully published dead legacy owner migrates to the immutable lease protocol',()=>{const dir=tmp(),lock=path.join(dir,'.mutation-lock');fs.mkdirSync(lock);fs.writeFileSync(path.join(lock,'owner.json'),JSON.stringify({pid:2147483647,token:'old',createdAt:0,expiresAt:0}));expect(withLock(dir,()=>42)).toBe(42);expect(fs.lstatSync(lock).isFile()).toBe(true);expect(JSON.parse(fs.readFileSync(lock,'utf8')).protocol).toBe('immutable-lease-set-v3');expect(fs.readdirSync(path.join(dir,'.mutation-lock-leases'))).toEqual([]);});
|
||||
test('a dead legacy migration claim is recovered after an interrupted migration',()=>{const dir=tmp(),lock=path.join(dir,'.mutation-lock'),claim=path.join(lock,'.v3-migration'),token='b'.repeat(32);fs.mkdirSync(lock);fs.writeFileSync(path.join(lock,'owner.json'),JSON.stringify({pid:2147483647,token:'old',createdAt:0}));fs.writeFileSync(claim,JSON.stringify({pid:2147483647,token,createdAt:0})+'\n',{mode:0o600,flag:'wx'});expect(withLock(dir,()=>42)).toBe(42);expect(fs.lstatSync(lock).isFile()).toBe(true);expect(fs.readdirSync(path.join(dir,'.mutation-lock-leases'))).toEqual([]);expect(fs.readdirSync(dir).some(name=>name.includes('.legacy-'))).toBe(false);});
|
||||
test('an expired timestamp cannot steal a lock from a live owner',()=>{const dir=tmp(),lock=path.join(dir,'.mutation-lock');fs.mkdirSync(lock);fs.writeFileSync(path.join(lock,'owner.json'),JSON.stringify({pid:process.pid,token:'live',createdAt:0,expiresAt:0}));expect(()=>withLock(dir,()=>42)).toThrow('Another helper');});
|
||||
test('an incomplete legacy lock is never age-reclaimed from a paused initializer',()=>{const dir=tmp(),lock=path.join(dir,'.mutation-lock'),entered=path.join(dir,'entered');fs.mkdirSync(lock);const old=new Date(Date.now()-60_000);fs.utimesSync(lock,old,old);expect(()=>withLock(dir,()=>fs.writeFileSync(entered,'unsafe'))).toThrow('may still be initializing');expect(fs.existsSync(entered)).toBe(false);expect(fs.lstatSync(lock).isDirectory()).toBe(true);fs.writeFileSync(path.join(lock,'owner.json'),JSON.stringify({pid:process.pid,token:'paused',createdAt:0,expiresAt:0}));expect(()=>withLock(dir,()=>fs.writeFileSync(entered,'unsafe'))).toThrow('Another helper');expect(fs.existsSync(entered)).toBe(false);});
|
||||
test('a dead immutable lease is recovered without replacing its inode',()=>{const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);const leases=path.join(dir,'.mutation-lock-leases'),token='a'.repeat(32),lease=path.join(leases,`${token}.json`);fs.writeFileSync(lease,JSON.stringify({pid:2147483647,token,createdAt:0})+'\n',{mode:0o600,flag:'wx'});expect(withLock(dir,()=>2)).toBe(2);expect(fs.existsSync(lease)).toBe(false);});
|
||||
test('an in-flight cooperating lease publication is contention rather than poisoned state',()=>{const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);const leases=path.join(dir,'.mutation-lock-leases'),token='9'.repeat(32),lease=path.join(leases,`${token}.json`),temporary=`${lease}.tmp.${process.pid}.deadbeef`,write=fs.writeFileSync,lstat=fs.lstatSync;let injected=false,transitioned=false,entered=false,caught:unknown;const writer=spyOn(fs,'writeFileSync').mockImplementation(((file:any,data:any,options:any)=>{const result=write(file,data,options);const candidate=String(file);if(!injected&&path.dirname(candidate)===leases&&/^[a-f0-9]{32}\.json\.tmp\.\d+\.[a-f0-9]{8}$/.test(path.basename(candidate))){injected=true;write(temporary,JSON.stringify({pid:process.pid,token,createdAt:Date.now()})+'\n',{mode:0o600,flag:'wx'});}return result;}) as typeof fs.writeFileSync),reader=spyOn(fs,'lstatSync').mockImplementation(((file:any,options?:any)=>{if(!transitioned&&String(file)===temporary){transitioned=true;fs.linkSync(temporary,lease);fs.unlinkSync(temporary);}return options===undefined?lstat(file):lstat(file,options);}) as typeof fs.lstatSync);try{withLock(dir,()=>{entered=true;});}catch(error){caught=error;}finally{reader.mockRestore();writer.mockRestore();}expect(injected).toBe(true);expect(transitioned).toBe(true);expect(entered).toBe(false);expect(caught).toMatchObject({code:'INSUFFICIENT_CAPACITY'});expect(fs.readdirSync(leases)).toEqual([path.basename(lease)]);});
|
||||
test('a live publisher unlinking its temp after target observation remains ordinary contention',()=>{
|
||||
const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);
|
||||
const leases=path.join(dir,'.mutation-lock-leases'),token='7'.repeat(32),lease=path.join(leases,`${token}.json`),temporary=`${lease}.tmp.${process.pid}.abcdef12`;
|
||||
fs.writeFileSync(temporary,JSON.stringify({pid:process.pid,token,createdAt:Date.now()})+'\n',{mode:0o600,flag:'wx'});fs.linkSync(temporary,lease);
|
||||
const lstat=fs.lstatSync;let targetReads=0,transitioned=false,entered=false,caught:unknown;
|
||||
const reader=spyOn(fs,'lstatSync').mockImplementation(((file:any,options?:any)=>{
|
||||
if(String(file)===lease&&++targetReads===2){fs.unlinkSync(temporary);transitioned=true;}
|
||||
return options===undefined?lstat(file):lstat(file,options);
|
||||
}) as typeof fs.lstatSync);
|
||||
try{withLock(dir,()=>{entered=true;});}catch(error){caught=error;}finally{reader.mockRestore();}
|
||||
expect(transitioned).toBe(true);expect(entered).toBe(false);expect(caught).toMatchObject({code:'INSUFFICIENT_CAPACITY'});expect(fs.statSync(lease).nlink).toBe(1);
|
||||
});
|
||||
test('a live publisher unlinking its temp after canonical validation is rescanned as contention',()=>{
|
||||
const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);
|
||||
const leases=path.join(dir,'.mutation-lock-leases'),token='5'.repeat(32),lease=path.join(leases,`${token}.json`),temporary=`${lease}.tmp.${process.pid}.a1b2c3d4`;
|
||||
fs.writeFileSync(temporary,JSON.stringify({pid:process.pid,token,createdAt:Date.now()})+'\n',{mode:0o600,flag:'wx'});fs.linkSync(temporary,lease);
|
||||
const readdir=fs.readdirSync;let leaseReads=0,transitioned=false,entered=false,caught:unknown;
|
||||
const reader=spyOn(fs,'readdirSync').mockImplementation(((directory:any,options?:any)=>{
|
||||
if(String(directory)===leases&&++leaseReads===2){fs.unlinkSync(temporary);transitioned=true;}
|
||||
return options===undefined?readdir(directory):readdir(directory,options);
|
||||
}) as typeof fs.readdirSync);
|
||||
try{withLock(dir,()=>{entered=true;});}catch(error){caught=error;}finally{reader.mockRestore();}
|
||||
expect(transitioned).toBe(true);expect(entered).toBe(false);expect(caught).toMatchObject({code:'INSUFFICIENT_CAPACITY'});expect(fs.statSync(lease).nlink).toBe(1);
|
||||
});
|
||||
test('a live temp linked after the absent-target check remains ordinary contention',()=>{
|
||||
const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);
|
||||
const leases=path.join(dir,'.mutation-lock-leases'),token='8'.repeat(32),lease=path.join(leases,`${token}.json`),temporary=`${lease}.tmp.${process.pid}.1234abcd`;
|
||||
fs.writeFileSync(temporary,JSON.stringify({pid:process.pid,token,createdAt:Date.now()})+'\n',{mode:0o600,flag:'wx'});
|
||||
const exists=fs.existsSync;let transitioned=false,entered=false,caught:unknown;
|
||||
const observer=spyOn(fs,'existsSync').mockImplementation(((file:any)=>{
|
||||
const result=exists(file);if(!transitioned&&String(file)===lease&&!result){fs.linkSync(temporary,lease);transitioned=true;}return result;
|
||||
}) as typeof fs.existsSync);
|
||||
try{withLock(dir,()=>{entered=true;});}catch(error){caught=error;}finally{observer.mockRestore();}
|
||||
expect(transitioned).toBe(true);expect(entered).toBe(false);expect(caught).toMatchObject({code:'INSUFFICIENT_CAPACITY'});expect(fs.statSync(lease).nlink).toBe(2);
|
||||
});
|
||||
test('an unrecognized multiply-linked lease temp remains poisoned state',()=>{
|
||||
const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);
|
||||
const leases=path.join(dir,'.mutation-lock-leases'),token='6'.repeat(32),temporary=path.join(leases,`${token}.json.tmp.2147483647.1234abcd`),outside=path.join(dir,'outside-link');
|
||||
fs.writeFileSync(temporary,JSON.stringify({pid:2147483647,token,createdAt:0})+'\n',{mode:0o600,flag:'wx'});fs.linkSync(temporary,outside);
|
||||
let entered=false,caught:unknown;try{withLock(dir,()=>{entered=true;});}catch(error){caught=error;}
|
||||
expect(entered).toBe(false);expect(caught).toMatchObject({code:'UNSAFE_PATH'});expect(fs.existsSync(temporary)).toBe(true);expect(fs.existsSync(outside)).toBe(true);
|
||||
});
|
||||
test('a same-inode same-size content rewrite is not treated as publication progress',()=>{
|
||||
const dir=tmp(),token='0'.repeat(32),publisherPid=2147483647,target=path.join(dir,`${token}.json`),temporary=`${target}.tmp.${publisherPid}.abcdef12`,original=JSON.stringify({pid:publisherPid,token,createdAt:0})+'\n',replacement=JSON.stringify({pid:publisherPid,token,createdAt:1})+'\n';expect(replacement.length).toBe(original.length);fs.writeFileSync(temporary,original,{mode:0o600,flag:'wx'});fs.linkSync(temporary,target);
|
||||
const fstat=fs.fstatSync;let reads=0,rewritten=false;const reader=spyOn(fs,'fstatSync').mockImplementation(((fd:any,options?:any)=>{if(++reads===2){fs.writeFileSync(target,replacement);rewritten=true;}return options===undefined?fstat(fd):fstat(fd,options);}) as typeof fs.fstatSync);
|
||||
let caught:unknown;try{discardAtomicNoReplaceTemp(temporary,publisherPid,{label:'Test publication',maxBytes:4096});}catch(error){caught=error;}finally{reader.mockRestore();}
|
||||
expect(rewritten).toBe(true);expect(caught).toMatchObject({code:'SNAPSHOT_RACE'});expect(fs.existsSync(target)).toBe(true);expect(fs.existsSync(temporary)).toBe(true);
|
||||
});
|
||||
test('a live publisher identity cannot launder a stable same-size rewrite as contention',()=>{
|
||||
const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);const leases=path.join(dir,'.mutation-lock-leases'),token='f'.repeat(32),publisherPid=process.pid,target=path.join(leases,`${token}.json`),temporary=`${target}.tmp.${publisherPid}.abcdef12`,original=JSON.stringify({pid:publisherPid,token,createdAt:0})+'\n',replacement=JSON.stringify({pid:publisherPid,token,createdAt:1})+'\n';expect(replacement.length).toBe(original.length);fs.writeFileSync(temporary,original,{mode:0o600,flag:'wx'});fs.linkSync(temporary,target);
|
||||
const fstat=fs.fstatSync;let reads=0,rewritten=false,entered=false,caught:unknown;const reader=spyOn(fs,'fstatSync').mockImplementation(((fd:any,options?:any)=>{if(++reads===2){fs.writeFileSync(target,replacement);rewritten=true;}return options===undefined?fstat(fd):fstat(fd,options);}) as typeof fs.fstatSync);try{withLock(dir,()=>{entered=true;});}catch(error){caught=error;}finally{reader.mockRestore();}
|
||||
expect(rewritten).toBe(true);expect(entered).toBe(false);expect(caught).toMatchObject({code:'SNAPSHOT_RACE'});expect(fs.existsSync(target)).toBe(true);expect(fs.existsSync(temporary)).toBe(true);
|
||||
});
|
||||
test('a stable malformed hard-link publication stays unsafe even when its named PID is live',()=>{
|
||||
const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);const leases=path.join(dir,'.mutation-lock-leases'),token='9'.repeat(32),target=path.join(leases,`${token}.json`),temporary=`${target}.tmp.${process.pid}.deadbeef`;fs.writeFileSync(temporary,'{"malformed":\n',{mode:0o600,flag:'wx'});fs.linkSync(temporary,target);let entered=false,caught:unknown;try{withLock(dir,()=>{entered=true;});}catch(error){caught=error;}expect(entered).toBe(false);expect(caught).toMatchObject({code:'UNSAFE_PATH'});expect(fs.existsSync(target)).toBe(true);expect(fs.existsSync(temporary)).toBe(true);
|
||||
});
|
||||
test('a zero-byte hard-linked publication stays unsafe even when its named PID is live',()=>{
|
||||
const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);const leases=path.join(dir,'.mutation-lock-leases'),token='7'.repeat(32),target=path.join(leases,`${token}.json`),temporary=`${target}.tmp.${process.pid}.deadbeef`;fs.writeFileSync(temporary,'',{mode:0o600,flag:'wx'});fs.linkSync(temporary,target);let entered=false,caught:unknown;try{withLock(dir,()=>{entered=true;});}catch(error){caught=error;}expect(entered).toBe(false);expect(caught).toMatchObject({code:'UNSAFE_PATH'});expect(fs.statSync(target).nlink).toBe(2);expect(fs.statSync(temporary).nlink).toBe(2);
|
||||
});
|
||||
test('settled publication contention cannot launder a stable incoherent lease phase',()=>{
|
||||
const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);const leases=path.join(dir,'.mutation-lock-leases'),poisonToken='a'.repeat(32),liveToken='b'.repeat(32),poison=path.join(leases,`${poisonToken}.active.0000000000000001`),temporary=path.join(leases,`${liveToken}.json.tmp.${process.pid}.abcdef12`);fs.writeFileSync(poison,JSON.stringify({pid:process.pid,token:poisonToken,createdAt:0})+'\n',{mode:0o600,flag:'wx'});
|
||||
const write=fs.writeFileSync;let injected=false;const publisher=spyOn(fs,'writeFileSync').mockImplementation(((file:any,data:any,options:any)=>{const result=write(file,data,options),name=path.basename(String(file));if(!injected&&path.dirname(String(file))===leases&&/^[a-f0-9]{32}\.json\.tmp\.\d+\.[a-f0-9]{8}$/.test(name)&&!name.startsWith(liveToken)){injected=true;write(temporary,JSON.stringify({pid:process.pid,token:liveToken,createdAt:0})+'\n',{mode:0o600,flag:'wx'});}return result;}) as typeof fs.writeFileSync);
|
||||
const wait=Atomics.wait.bind(Atomics);let settled=false,entered=false,caught:unknown;const sleeper=spyOn(Atomics,'wait').mockImplementation(((array:any,index:any,value:any,timeout?:any)=>{if(!settled&&fs.existsSync(temporary)){settled=true;fs.unlinkSync(temporary);}return wait(array,index,value,timeout);}) as typeof Atomics.wait);try{withLock(dir,()=>{entered=true;});}catch(error){caught=error;}finally{sleeper.mockRestore();publisher.mockRestore();}
|
||||
expect(injected).toBe(true);expect(settled).toBe(true);expect(entered).toBe(false);expect(caught).toMatchObject({code:'UNSAFE_PATH'});expect(fs.existsSync(poison)).toBe(true);
|
||||
});
|
||||
test('successful recovery on the final bounded rescan remains transient capacity',()=>{
|
||||
const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);const leases=path.join(dir,'.mutation-lock-leases'),liveToken='c'.repeat(32),temporary=path.join(leases,`${liveToken}.json.tmp.${process.pid}.abcdef12`),write=fs.writeFileSync;let injected=false;const publisher=spyOn(fs,'writeFileSync').mockImplementation(((file:any,data:any,options:any)=>{const result=write(file,data,options),name=path.basename(String(file));if(!injected&&path.dirname(String(file))===leases&&/^[a-f0-9]{32}\.json\.tmp\.\d+\.[a-f0-9]{8}$/.test(name)&&!name.startsWith(liveToken)){injected=true;write(temporary,JSON.stringify({pid:process.pid,token:liveToken,createdAt:0})+'\n',{mode:0o600,flag:'wx'});}return result;}) as typeof fs.writeFileSync);
|
||||
const now=spyOn(Date,'now').mockReturnValue(1000),kill=process.kill.bind(process);let probes=0,entered=false,caught:unknown;const liveness=spyOn(process,'kill').mockImplementation(((pid:any,signal?:any)=>{if(Number(pid)===process.pid&&signal===0&&++probes===532){const error:any=new Error('gone');error.code='ESRCH';throw error;}return Number(pid)===process.pid&&signal===0?true:(signal===undefined?kill(pid):kill(pid,signal));}) as typeof process.kill);try{withLock(dir,()=>{entered=true;});}catch(error){caught=error;}finally{liveness.mockRestore();now.mockRestore();publisher.mockRestore();}
|
||||
expect(injected).toBe(true);expect(probes).toBe(532);expect(entered).toBe(false);expect(caught).toMatchObject({code:'INSUFFICIENT_CAPACITY'});expect(fs.existsSync(temporary)).toBe(false);expect(fs.readdirSync(leases)).toEqual([]);
|
||||
});
|
||||
test('a settled publication remains bound to the originally observed inode',()=>{
|
||||
const dir=tmp(),token='4'.repeat(32),publisherPid=2147483647,target=path.join(dir,`${token}.json`),temporary=`${target}.tmp.${publisherPid}.abcdef12`,parked=`${target}.replaced`,owner=JSON.stringify({pid:publisherPid,token,createdAt:0})+'\n';
|
||||
fs.writeFileSync(temporary,owner,{mode:0o600,flag:'wx'});fs.linkSync(temporary,target);const original=fs.statSync(target).ino,lstat=fs.lstatSync;let targetReads=0,replaced=false,caught:unknown;
|
||||
const reader=spyOn(fs,'lstatSync').mockImplementation(((file:any,options?:any)=>{
|
||||
if(String(file)===target&&++targetReads===2){fs.unlinkSync(target);fs.renameSync(temporary,parked);fs.writeFileSync(target,owner,{mode:0o600,flag:'wx'});replaced=true;}
|
||||
return options===undefined?lstat(file):lstat(file,options);
|
||||
}) as typeof fs.lstatSync);
|
||||
try{discardAtomicNoReplaceTemp(temporary,publisherPid,{label:'Test publication',maxBytes:4096,validate:(value,pid)=>{expect((value as any).token).toBe(token);expect(pid).toBe(publisherPid);}});}catch(error){caught=error;}finally{reader.mockRestore();}
|
||||
expect(replaced).toBe(true);expect(caught).toMatchObject({code:'UNSAFE_PATH'});expect(fs.statSync(parked).ino).toBe(original);expect(fs.statSync(target).ino).not.toBe(original);
|
||||
});
|
||||
test('a link transition on an open inode cannot launder a replacement pathname',()=>{
|
||||
const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);
|
||||
const leases=path.join(dir,'.mutation-lock-leases'),token='3'.repeat(32),publisherPid=2147483647,target=path.join(leases,`${token}.json`),temporary=`${target}.tmp.${publisherPid}.1234abcd`,owner=JSON.stringify({pid:publisherPid,token,createdAt:0})+'\n';
|
||||
fs.writeFileSync(temporary,owner,{mode:0o600,flag:'wx'});fs.linkSync(temporary,target);const original=fs.statSync(target).ino,fstat=fs.fstatSync;let reads=0,replaced=false,entered=false,caught:unknown;
|
||||
const reader=spyOn(fs,'fstatSync').mockImplementation(((fd:any,options?:any)=>{
|
||||
if(++reads===2){fs.unlinkSync(target);fs.writeFileSync(target,owner,{mode:0o600,flag:'wx'});replaced=true;}
|
||||
return options===undefined?fstat(fd):fstat(fd,options);
|
||||
}) as typeof fs.fstatSync);
|
||||
try{withLock(dir,()=>{entered=true;});}catch(error){caught=error;}finally{reader.mockRestore();}
|
||||
expect(replaced).toBe(true);expect(entered).toBe(false);expect(caught).toMatchObject({name:'CsoError',code:'SNAPSHOT_RACE'});expect(fs.statSync(target).ino).not.toBe(original);expect(fs.existsSync(temporary)).toBe(true);
|
||||
});
|
||||
test('a cooperating lease released between open and fstat is rescanned',()=>{
|
||||
const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);
|
||||
const leases=path.join(dir,'.mutation-lock-leases'),token='2'.repeat(32),lease=path.join(leases,`${token}.json`);fs.writeFileSync(lease,JSON.stringify({pid:process.pid,token,createdAt:Date.now()})+'\n',{mode:0o600,flag:'wx'});
|
||||
const fstat=fs.fstatSync;let released=false,entered=false;const reader=spyOn(fs,'fstatSync').mockImplementation(((fd:any,options?:any)=>{if(!released){fs.unlinkSync(lease);released=true;}return options===undefined?fstat(fd):fstat(fd,options);}) as typeof fs.fstatSync);
|
||||
try{withLock(dir,()=>{entered=true;});}finally{reader.mockRestore();}
|
||||
expect(released).toBe(true);expect(entered).toBe(true);expect(fs.existsSync(lease)).toBe(false);
|
||||
});
|
||||
test('recovers the exact no-replace lease hard link left by a dead publisher',()=>{const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);const leases=path.join(dir,'.mutation-lock-leases'),token='1'.repeat(32),lease=path.join(leases,`${token}.json`),temporary=`${lease}.tmp.2147483647.deadbeef`;fs.writeFileSync(lease,JSON.stringify({pid:2147483647,token,createdAt:0})+'\n',{mode:0o600,flag:'wx'});fs.linkSync(lease,temporary);expect(fs.statSync(lease).nlink).toBe(2);expect(withLock(dir,()=>2)).toBe(2);expect(fs.existsSync(lease)).toBe(false);expect(fs.existsSync(temporary)).toBe(false);});
|
||||
test('private immutable reads recover only a recognized same-inode no-replace temp',()=>{const dir=tmp(),artifact=path.join(dir,'artifact.json'),temporary=`${artifact}.tmp.2147483647.cafebabe`;fs.writeFileSync(artifact,JSON.stringify({value:'bound'})+'\n',{mode:0o600,flag:'wx'});fs.linkSync(artifact,temporary);expect(readJson(artifact)).toEqual({value:'bound'});expect(fs.statSync(artifact).nlink).toBe(1);expect(fs.existsSync(temporary)).toBe(false);const poisoned=path.join(dir,'poisoned.json'),unrecognized=`${poisoned}.tmp.dead.bad`;fs.writeFileSync(poisoned,'{}\n',{mode:0o600,flag:'wx'});fs.linkSync(poisoned,unrecognized);expect(()=>readJson(poisoned)).toThrow('hard link does not match');expect(fs.existsSync(unrecognized)).toBe(true);});
|
||||
test('private JSON reads reject rename and symlink substitutions between validation and open',()=>{for(const kind of ['rename','symlink'] as const){const dir=tmp(),artifact=path.join(dir,'artifact.json'),original=path.join(dir,'original.json'),poison=path.join(dir,'poison.json');fs.writeFileSync(artifact,JSON.stringify({value:'bound'})+'\n',{mode:0o600});fs.writeFileSync(poison,JSON.stringify({value:'poison'})+'\n',{mode:0o600});const open=fs.openSync,patched=spyOn(fs,'openSync').mockImplementation(((candidate:any,flags:any,mode?:any)=>{if(String(candidate)===artifact){fs.renameSync(artifact,original);if(kind==='rename')fs.renameSync(poison,artifact);else fs.symlinkSync(poison,artifact);}return mode===undefined?open(candidate,flags):open(candidate,flags,mode);}) as typeof fs.openSync);try{expect(()=>readJson(artifact)).toThrow(/changed while it was opened/);}finally{patched.mockRestore();}}});
|
||||
test('mismatched lease publisher identity fails closed',()=>{const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);const leases=path.join(dir,'.mutation-lock-leases'),token='2'.repeat(32),lease=path.join(leases,`${token}.json`),temporary=`${lease}.tmp.2147483646.abcdef12`;fs.writeFileSync(lease,JSON.stringify({pid:2147483647,token,createdAt:0})+'\n',{mode:0o600,flag:'wx'});fs.linkSync(lease,temporary);expect(()=>withLock(dir,()=>2)).toThrow('does not match its publisher');expect(fs.statSync(lease).nlink).toBe(2);});
|
||||
test('malformed and multiply-linked immutable owners fail closed instead of being reclaimed',()=>{for(const kind of ['schema','hardlink'] as const){const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);const leases=path.join(dir,'.mutation-lock-leases'),token=(kind==='schema'?'c':'d').repeat(32),lease=path.join(leases,`${token}.json`),value=JSON.stringify(kind==='schema'?{pid:'dead',token,createdAt:0}:{pid:2147483647,token,createdAt:0})+'\n';fs.writeFileSync(lease,value,{mode:0o600,flag:'wx'});if(kind==='hardlink')fs.linkSync(lease,path.join(dir,'outside-link'));let entered=false;expect(()=>withLock(dir,()=>{entered=true;})).toThrow('Run mutation lease');expect(entered).toBe(false);expect(fs.existsSync(lease)).toBe(true);}});
|
||||
test('PID reuse is distinguished by the Linux process-start identity',()=>{if(process.platform!=='linux')return;const raw=fs.readFileSync(`/proc/${process.pid}/stat`,'utf8'),tail=raw.slice(raw.lastIndexOf(')')+2).trim().split(/\s+/),identity=BigInt(tail[19]),dir=tmp();expect(withLock(dir,()=>1)).toBe(1);const leases=path.join(dir,'.mutation-lock-leases'),token='e'.repeat(32),lease=path.join(leases,`${token}.json`);fs.writeFileSync(lease,JSON.stringify({pid:process.pid,processIdentity:`linux:${identity+1n}`,token,createdAt:0})+'\n',{mode:0o600,flag:'wx'});expect(withLock(dir,()=>2)).toBe(2);expect(fs.existsSync(lease)).toBe(false);});
|
||||
test('interrupted lease publications use process identity to distinguish PID reuse',()=>{if(process.platform!=='linux')return;const raw=fs.readFileSync(`/proc/${process.pid}/stat`,'utf8'),tail=raw.slice(raw.lastIndexOf(')')+2).trim().split(/\s+/),identity=BigInt(tail[19]);for(const linked of [false,true]){const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);const leases=path.join(dir,'.mutation-lock-leases'),token=(linked?'b':'a').repeat(32),lease=path.join(leases,`${token}.json`),temporary=`${lease}.tmp.${process.pid}.abcdef12`,owner=JSON.stringify({pid:process.pid,processIdentity:`linux:${identity+1n}`,token,createdAt:0})+'\n';fs.writeFileSync(temporary,owner,{mode:0o600,flag:'wx'});if(linked)fs.linkSync(temporary,lease);expect(withLock(dir,()=>2)).toBe(2);expect(fs.existsSync(temporary)).toBe(false);expect(fs.existsSync(lease)).toBe(false);}});
|
||||
test('EPERM liveness keeps an active cross-process lease intact',()=>{const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);const leases=path.join(dir,'.mutation-lock-leases'),token='d'.repeat(32),pid=2147483647,createdAt=0,candidate=path.join(leases,`${token}.json`),decision=path.join(leases,`${token}.decision`),ticket='1'.padStart(16,'0'),active=path.join(leases,`${token}.active.${ticket}`);fs.writeFileSync(candidate,JSON.stringify({pid,token,createdAt})+'\n',{mode:0o600,flag:'wx'});const stat=fs.statSync(candidate);fs.writeFileSync(decision,JSON.stringify({schemaVersion:1,token,kind:'ticket',ticket,candidateDev:String(stat.dev),candidateIno:String(stat.ino),ownerPid:pid,ownerCreatedAt:createdAt,publisherPid:pid,createdAt})+'\n',{mode:0o600,flag:'wx'});fs.linkSync(candidate,active);const kill=process.kill.bind(process),probe=spyOn(process,'kill').mockImplementation(((target:any,signal?:any)=>{if(Number(target)===pid){const error:any=new Error('not permitted');error.code='EPERM';throw error;}return signal===undefined?kill(target):kill(target,signal);}) as typeof process.kill);let entered=false,caught:unknown;try{withLock(dir,()=>{entered=true;});}catch(error){caught=error;}finally{probe.mockRestore();}expect(entered).toBe(false);expect(caught).toMatchObject({code:'INSUFFICIENT_CAPACITY'});expect([candidate,decision,active].every(file=>fs.existsSync(file))).toBe(true);expect(fs.statSync(candidate).nlink).toBe(2);expect(fs.statSync(decision).nlink).toBe(1);});
|
||||
test('an active lease binds a separate decision to its candidate and active inode',()=>{const dir=tmp();withLock(dir,()=>{const leases=path.join(dir,'.mutation-lock-leases'),names=fs.readdirSync(leases);expect(names.length).toBe(3);const candidate=names.find(name=>name.endsWith('.json'))!,decision=names.find(name=>name.endsWith('.decision'))!,active=names.find(name=>name.includes('.active.'))!,candidateStat=fs.statSync(path.join(leases,candidate)),decisionStat=fs.statSync(path.join(leases,decision)),activeStat=fs.statSync(path.join(leases,active)),record=JSON.parse(fs.readFileSync(path.join(leases,decision),'utf8'));expect(`${candidateStat.dev}:${candidateStat.ino}`).toBe(`${activeStat.dev}:${activeStat.ino}`);expect(candidateStat.nlink).toBe(2);expect(activeStat.nlink).toBe(2);expect(`${decisionStat.dev}:${decisionStat.ino}`).not.toBe(`${candidateStat.dev}:${candidateStat.ino}`);expect(decisionStat.nlink).toBe(1);expect(record.kind).toBe('ticket');expect(record.candidateDev).toBe(String(candidateStat.dev));expect(record.candidateIno).toBe(String(candidateStat.ino));});expect(fs.readdirSync(path.join(dir,'.mutation-lock-leases'))).toEqual([]);});
|
||||
test('post-link verification failure cleans every recorded lease phase',()=>{const dir=tmp();expect(withLock(dir,()=>1)).toBe(1);const leases=path.join(dir,'.mutation-lock-leases'),fakeToken='f'.repeat(32),fakeTemp=path.join(leases,`${fakeToken}.json.tmp.${process.pid}.deadbeef`),link=fs.linkSync,injectedOwner=JSON.stringify({pid:process.pid,token:fakeToken,createdAt:Date.now()})+'\n';let injected=false,caught:unknown;const publisher=spyOn(fs,'linkSync').mockImplementation(((source:any,target:any)=>{const result=link(source,target);if(!injected&&String(target).includes('.active.')){injected=true;fs.writeFileSync(fakeTemp,injectedOwner,{mode:0o600,flag:'wx'});}return result;}) as typeof fs.linkSync);try{withLock(dir,()=>1);}catch(error){caught=error;}finally{publisher.mockRestore();}expect(injected).toBe(true);expect(caught).toMatchObject({code:'INSUFFICIENT_CAPACITY'});expect(fs.readdirSync(leases)).toEqual([path.basename(fakeTemp)]);fs.unlinkSync(fakeTemp);expect(withLock(dir,()=>2)).toBe(2);expect(fs.readdirSync(leases)).toEqual([]);});
|
||||
test('an async callback retains its lease until settlement without blocking its own event loop',async()=>{const dir=tmp();let release!:()=>void;const pending=new Promise<void>(resolve=>{release=resolve;}),outer=withLock(dir,async()=>{await pending;return 7;}),started=Date.now();expect(()=>withLock(dir,()=>99)).toThrow('Another operation in this helper');expect(Date.now()-started).toBeLessThan(200);release();expect(await outer).toBe(7);expect(withLock(dir,()=>9)).toBe(9);});
|
||||
test('a synchronous exact-release failure is attempted only once',()=>{const dir=tmp(),lstat=fs.lstatSync;let observed=0,reader:any;try{expect(()=>withLock(dir,()=>{const leases=path.join(dir,'.mutation-lock-leases'),names=fs.readdirSync(leases),candidate=path.join(leases,names.find(name=>name.endsWith('.json'))!),active=path.join(leases,names.find(name=>name.includes('.active.'))!),value=fs.readFileSync(active);fs.unlinkSync(active);fs.writeFileSync(active,value,{mode:0o600,flag:'wx'});reader=spyOn(fs,'lstatSync').mockImplementation(((file:any,options?:any)=>{if(String(file)===candidate)observed++;return options===undefined?lstat(file):lstat(file,options);}) as typeof fs.lstatSync);})).toThrow('active phase changed before cleanup');expect(observed).toBe(1);}finally{reader?.mockRestore();}});
|
||||
test('exact release rejects active or decision inode substitution and overrides callback success or failure',()=>{for(const phase of ['active','decision'] as const){const dir=tmp();let replacement='',parked='';expect(()=>withLock(dir,()=>{const leases=path.join(dir,'.mutation-lock-leases'),name=fs.readdirSync(leases).find(value=>phase==='active'?value.includes('.active.'):value.endsWith('.decision'))!,lease=path.join(leases,name),value=fs.readFileSync(lease);parked=`${lease}.replaced`;fs.renameSync(lease,parked);fs.writeFileSync(lease,value,{mode:0o600,flag:'wx'});replacement=lease;if(phase==='active')throw new Error('callback failed');return 1;})).toThrow(/changed before (cleanup|exact release)/);expect(fs.existsSync(replacement)).toBe(true);expect(fs.existsSync(parked)).toBe(true);expect(fs.statSync(replacement).ino).not.toBe(fs.statSync(parked).ino);fs.rmSync(dir,{recursive:true,force:true});}});
|
||||
test('concurrent stale-lock recovery never admits overlapping report writers',async()=>{const dir=tmp(),lock=path.join(dir,'.mutation-lock'),script=path.join(dir,'racer.ts');fs.mkdirSync(lock);fs.writeFileSync(path.join(lock,'owner.json'),JSON.stringify({pid:2147483647,token:'stale',createdAt:0,expiresAt:0}));fs.writeFileSync(script,`import fs from 'node:fs';import path from 'node:path';import {withLock} from ${JSON.stringify(path.resolve(import.meta.dir,'../lib/cso/state.ts'))};const dir=process.env.RACE_DIR!;try{await withLock(dir,async()=>{const active=path.join(dir,'active');try{fs.writeFileSync(active,String(process.pid),{flag:'wx'});}catch{fs.appendFileSync(path.join(dir,'overlap'),'yes\\n');}await Bun.sleep(50);try{if(fs.readFileSync(active,'utf8')===String(process.pid))fs.unlinkSync(active);}catch{}});}catch{}`);const children=Array.from({length:8},()=>spawn(process.execPath,[script],{env:{...process.env,RACE_DIR:dir},stdio:'ignore'}));await Promise.all(children.map(child=>new Promise<void>(resolve=>child.on('close',()=>resolve()))));expect(fs.existsSync(path.join(dir,'overlap'))).toBe(false);});
|
||||
test('concurrent recovery of one dead hard-link publication has a winner and no raw race errors',async()=>{
|
||||
const dir=tmp(),barrier=path.join(dir,'barrier'),script=path.join(dir,'recovery-racer.ts');fs.mkdirSync(barrier);expect(withLock(dir,()=>1)).toBe(1);
|
||||
const leases=path.join(dir,'.mutation-lock-leases'),token='c'.repeat(32),lease=path.join(leases,`${token}.json`),temporary=`${lease}.tmp.2147483647.deadbeef`;
|
||||
fs.writeFileSync(temporary,JSON.stringify({pid:2147483647,token,createdAt:0})+'\n',{mode:0o600,flag:'wx'});fs.linkSync(temporary,lease);
|
||||
fs.writeFileSync(script,`import fs from 'node:fs';import path from 'node:path';import {withLock} from ${JSON.stringify(path.resolve(import.meta.dir,'../lib/cso/state.ts'))};const dir=process.env.RACE_DIR!,barrier=process.env.BARRIER!,id=process.env.RACE_ID!,wait=new Int32Array(new SharedArrayBuffer(4));fs.writeFileSync(path.join(barrier,'ready-'+id),'ready',{flag:'wx'});while(!fs.existsSync(path.join(barrier,'go')))Atomics.wait(wait,0,0,1);let outcome='success';try{withLock(dir,()=>{const active=path.join(barrier,'work-active');try{fs.writeFileSync(active,id,{flag:'wx'});}catch{fs.appendFileSync(path.join(barrier,'overlap'),'yes\\n');}Atomics.wait(wait,0,0,20);try{if(fs.readFileSync(active,'utf8')===id)fs.unlinkSync(active);}catch{}});}catch(error){outcome=String(error?.code??error)+':'+String(error?.message??'');}fs.writeFileSync(path.join(barrier,'result-'+id),outcome);`);
|
||||
const children=Array.from({length:8},(_,index)=>spawn(process.execPath,[script],{env:{...process.env,RACE_DIR:dir,BARRIER:barrier,RACE_ID:String(index)},stdio:'ignore'})),done=children.map(child=>childExit(child));
|
||||
try{
|
||||
for(let index=0;index<children.length;index++)await waitForPath(path.join(barrier,`ready-${index}`));fs.writeFileSync(path.join(barrier,'go'),'go');await Promise.all(done);
|
||||
const outcomes=children.map((_,index)=>fs.readFileSync(path.join(barrier,`result-${index}`),'utf8'));
|
||||
expect(outcomes).toContain('success');expect(outcomes.filter(value=>value!=='success'&&!value.startsWith('INSUFFICIENT_CAPACITY:'))).toEqual([]);
|
||||
expect(fs.existsSync(path.join(barrier,'overlap'))).toBe(false);expect(fs.readdirSync(leases)).toEqual([]);
|
||||
}finally{stopChildren(children);}
|
||||
});
|
||||
test('two synchronized cooperating publishers admit work without overlap or an all-loser outcome',async()=>{
|
||||
const dir=tmp(),barrier=path.join(dir,'barrier'),script=path.join(dir,'contender.ts');fs.mkdirSync(barrier);expect(withLock(dir,()=>1)).toBe(1);
|
||||
fs.writeFileSync(script,`import fs from 'node:fs';import path from 'node:path';import {withLock} from ${JSON.stringify(path.resolve(import.meta.dir,'../lib/cso/state.ts'))};
|
||||
const dir=process.env.RACE_DIR!,barrier=process.env.BARRIER!,id=process.env.RACE_ID!,leases=path.join(dir,'.mutation-lock-leases'),read=fs.readdirSync;let reads=0;
|
||||
fs.readdirSync=((candidate,...args)=>{if(String(candidate)===leases&&++reads===2){fs.writeFileSync(path.join(barrier,'ready-'+id),'ready',{flag:'wx'});const deadline=Date.now()+5000;while(fs.readdirSync(barrier).filter(name=>name.startsWith('ready-')).length<2){if(Date.now()>deadline)throw new Error('barrier timeout');Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,1);}}return read.call(fs,candidate,...args);});
|
||||
let outcome='success';try{withLock(dir,()=>{const active=path.join(barrier,'active');try{fs.writeFileSync(active,id,{flag:'wx'});}catch{fs.writeFileSync(path.join(barrier,'overlap'),'overlap');}Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,25);try{if(fs.readFileSync(active,'utf8')===id)fs.unlinkSync(active);}catch{}});}catch(error){outcome=String(error?.code??error);}fs.writeFileSync(path.join(barrier,'result-'+id),outcome);`);
|
||||
const children=['a','b'].map(id=>spawn(process.execPath,[script],{env:{...process.env,RACE_DIR:dir,BARRIER:barrier,RACE_ID:id},stdio:'ignore'}));
|
||||
await Promise.all(children.map(child=>new Promise<void>((resolve,reject)=>child.on('error',reject).on('close',code=>code===0?resolve():reject(new Error(`contender exited ${code}`))))));
|
||||
const outcomes=['a','b'].map(id=>fs.readFileSync(path.join(barrier,`result-${id}`),'utf8'));
|
||||
expect(outcomes.filter(value=>value==='success').length).toBeGreaterThan(0);expect(outcomes.every(value=>value==='success'||value==='INSUFFICIENT_CAPACITY')).toBe(true);expect(fs.existsSync(path.join(barrier,'overlap'))).toBe(false);
|
||||
});
|
||||
test('equal ticket decisions use token order and a late withdrawal cannot revoke the active winner',async()=>{
|
||||
if(process.platform==='win32')return;
|
||||
const dir=tmp(),barrier=path.join(dir,'barrier'),script=path.join(dir,'contender.ts'),lowToken='1'.repeat(32),highToken='e'.repeat(32);fs.mkdirSync(barrier);expect(withLock(dir,()=>1)).toBe(1);fs.writeFileSync(script,leaseContenderSource());
|
||||
const env={...process.env,RACE_DIR:dir,BARRIER:barrier},children:Array<ReturnType<typeof spawn>>=[];
|
||||
try{
|
||||
const low=spawn(process.execPath,[script],{env:{...env,RACE_ID:'L',RACE_MODE:'pause-before-decision',RACE_TOKEN:lowToken,RACE_HOLD:'1'},stdio:'ignore'});children.push(low);const lowDone=childExit(low);await waitForPath(path.join(barrier,'paused-L'));expect(fs.readFileSync(path.join(barrier,'choice-L'),'utf8')).toBe('0000000000000001');
|
||||
const high=spawn(process.execPath,[script],{env:{...env,RACE_ID:'H',RACE_MODE:'pause-before-withdraw',RACE_TOKEN:highToken},stdio:'ignore'});children.push(high);const highDone=childExit(high);await waitForPath(path.join(barrier,'paused-H'),2500);expect(fs.readFileSync(path.join(barrier,'choice-H'),'utf8')).toBe('0000000000000001');
|
||||
process.kill(low.pid!,'SIGCONT');await waitForPath(path.join(barrier,'entered-L'));const decision=JSON.parse(fs.readFileSync(path.join(dir,'.mutation-lock-leases',lowToken+'.decision'),'utf8'));expect(decision.kind).toBe('ticket');expect(decision.ticket).toBe('0000000000000001');
|
||||
process.kill(high.pid!,'SIGCONT');await waitForPath(path.join(barrier,'result-H'));expect(fs.readFileSync(path.join(barrier,'result-H'),'utf8')).toBe('INSUFFICIENT_CAPACITY');expect(await highDone).toBe(0);
|
||||
fs.writeFileSync(path.join(barrier,'release-L'),'release');await waitForPath(path.join(barrier,'result-L'));expect(fs.readFileSync(path.join(barrier,'result-L'),'utf8')).toBe('success');expect(await lowDone).toBe(0);expect(fs.existsSync(path.join(barrier,'overlap'))).toBe(false);expect(fs.readdirSync(path.join(dir,'.mutation-lock-leases'))).toEqual([]);
|
||||
}finally{stopChildren(children);}
|
||||
});
|
||||
test('a paused candidate is fenced so a progressing ticket wins within the bound',async()=>{if(process.platform==='win32')return;const dir=tmp(),barrier=path.join(dir,'barrier'),script=path.join(dir,'contender.ts');fs.mkdirSync(barrier);expect(withLock(dir,()=>1)).toBe(1);fs.writeFileSync(script,leaseContenderSource());const env={...process.env,RACE_DIR:dir,BARRIER:barrier},children:Array<ReturnType<typeof spawn>>=[];try{const earlier=spawn(process.execPath,[script],{env:{...env,RACE_ID:'L',RACE_MODE:'pause-candidate'},stdio:'ignore'});children.push(earlier);const earlierDone=childExit(earlier);await waitForPath(path.join(barrier,'paused-L'));const started=Date.now(),later=spawn(process.execPath,[script],{env:{...env,RACE_ID:'H',RACE_MODE:'normal'},stdio:'ignore'});children.push(later);const laterDone=childExit(later);await waitForPath(path.join(barrier,'result-H'),2500);expect(Date.now()-started).toBeLessThan(2000);expect(fs.readFileSync(path.join(barrier,'result-H'),'utf8')).toBe('success');expect(await laterDone).toBe(0);process.kill(earlier.pid!,'SIGCONT');await waitForPath(path.join(barrier,'result-L'));expect(fs.readFileSync(path.join(barrier,'result-L'),'utf8')).toBe('INSUFFICIENT_CAPACITY');expect(await earlierDone).toBe(0);expect(fs.existsSync(path.join(barrier,'overlap'))).toBe(false);expect(fs.readdirSync(path.join(dir,'.mutation-lock-leases'))).toEqual([]);}finally{stopChildren(children);}});
|
||||
test('a paused earlier ticket bounds later contenders without withdrawing the winner',async()=>{if(process.platform==='win32')return;const dir=tmp(),barrier=path.join(dir,'barrier'),script=path.join(dir,'contender.ts');fs.mkdirSync(barrier);expect(withLock(dir,()=>1)).toBe(1);fs.writeFileSync(script,leaseContenderSource());const env={...process.env,RACE_DIR:dir,BARRIER:barrier},children:Array<ReturnType<typeof spawn>>=[];try{const earlier=spawn(process.execPath,[script],{env:{...env,RACE_ID:'L',RACE_MODE:'pause-ticket'},stdio:'ignore'});children.push(earlier);const earlierDone=childExit(earlier);await waitForPath(path.join(barrier,'paused-L'));const started=Date.now(),later=spawn(process.execPath,[script],{env:{...env,RACE_ID:'H',RACE_MODE:'normal'},stdio:'ignore'});children.push(later);const laterDone=childExit(later);await waitForPath(path.join(barrier,'result-H'),2500);expect(Date.now()-started).toBeLessThan(2000);expect(fs.readFileSync(path.join(barrier,'result-H'),'utf8')).toBe('INSUFFICIENT_CAPACITY');expect(await laterDone).toBe(0);process.kill(earlier.pid!,'SIGCONT');await waitForPath(path.join(barrier,'result-L'));expect(fs.readFileSync(path.join(barrier,'result-L'),'utf8')).toBe('success');expect(await earlierDone).toBe(0);expect(fs.existsSync(path.join(barrier,'overlap'))).toBe(false);expect(fs.readdirSync(path.join(dir,'.mutation-lock-leases'))).toEqual([]);}finally{stopChildren(children);}});
|
||||
test('a paused loser cleanup leaves only an ineligible decision and cannot block the winner',async()=>{if(process.platform==='win32')return;const dir=tmp(),barrier=path.join(dir,'barrier'),script=path.join(dir,'contender.ts');fs.mkdirSync(barrier);expect(withLock(dir,()=>1)).toBe(1);fs.writeFileSync(script,leaseContenderSource());const env={...process.env,RACE_DIR:dir,BARRIER:barrier},children:Array<ReturnType<typeof spawn>>=[];try{const winner=spawn(process.execPath,[script],{env:{...env,RACE_ID:'L',RACE_MODE:'pause-ticket'},stdio:'ignore'});children.push(winner);const winnerDone=childExit(winner);await waitForPath(path.join(barrier,'paused-L'));const loser=spawn(process.execPath,[script],{env:{...env,RACE_ID:'H',RACE_MODE:'pause-withdraw-cleanup'},stdio:'ignore'});children.push(loser);const loserDone=childExit(loser);await waitForPath(path.join(barrier,'paused-H'));const phases=fs.readdirSync(path.join(dir,'.mutation-lock-leases'));expect(phases.filter(name=>name.endsWith('.decision')).length).toBe(2);expect(phases.filter(name=>name.endsWith('.json')).length).toBe(1);process.kill(winner.pid!,'SIGCONT');await waitForPath(path.join(barrier,'result-L'));expect(fs.readFileSync(path.join(barrier,'result-L'),'utf8')).toBe('success');expect(await winnerDone).toBe(0);process.kill(loser.pid!,'SIGCONT');await waitForPath(path.join(barrier,'result-H'));expect(fs.readFileSync(path.join(barrier,'result-H'),'utf8')).toBe('INSUFFICIENT_CAPACITY');expect(await loserDone).toBe(0);expect(fs.existsSync(path.join(barrier,'overlap'))).toBe(false);expect(fs.readdirSync(path.join(dir,'.mutation-lock-leases'))).toEqual([]);}finally{stopChildren(children);}});
|
||||
test('a paused later contender cannot turn a cached lower ticket into an all-loser outcome',async()=>{if(process.platform==='win32')return;const dir=tmp(),barrier=path.join(dir,'barrier'),script=path.join(dir,'contender.ts');fs.mkdirSync(barrier);expect(withLock(dir,()=>1)).toBe(1);fs.writeFileSync(script,leaseContenderSource());const env={...process.env,RACE_DIR:dir,BARRIER:barrier},children:Array<ReturnType<typeof spawn>>=[];try{const earlier=spawn(process.execPath,[script],{env:{...env,RACE_ID:'L',RACE_MODE:'wait-active',RACE_HOLD:'1'},stdio:'ignore'});children.push(earlier);const earlierDone=childExit(earlier);await waitForPath(path.join(barrier,'doorway-L'));const later=spawn(process.execPath,[script],{env:{...env,RACE_ID:'H',RACE_MODE:'pause-later-scan'},stdio:'ignore'});children.push(later);const laterDone=childExit(later);await waitForPath(path.join(barrier,'paused-H'));await waitForPath(path.join(barrier,'entered-L'));process.kill(later.pid!,'SIGCONT');await waitForPath(path.join(barrier,'result-H'),2500);expect(fs.readFileSync(path.join(barrier,'result-H'),'utf8')).toBe('INSUFFICIENT_CAPACITY');expect(await laterDone).toBe(0);fs.writeFileSync(path.join(barrier,'release-L'),'release');await waitForPath(path.join(barrier,'result-L'));expect(fs.readFileSync(path.join(barrier,'result-L'),'utf8')).toBe('success');expect(await earlierDone).toBe(0);expect(fs.existsSync(path.join(barrier,'overlap'))).toBe(false);expect(fs.readdirSync(path.join(dir,'.mutation-lock-leases'))).toEqual([]);}finally{stopChildren(children);}});
|
||||
test('legacy report imports expire after thirty days without following links',()=>{const old=process.env.GSTACK_HOME,base=tmp();process.env.GSTACK_HOME=base;try{const archive=secureDirectory(path.join(base,'security','cso','legacy-imports')),expired=path.join(archive,'a'.repeat(64)+'.json'),retained=path.join(archive,'b'.repeat(64)+'.json');fs.writeFileSync(expired,'{}');fs.writeFileSync(retained,'{}');const now=Date.now();fs.utimesSync(expired,new Date(now-31*86400_000),new Date(now-31*86400_000));retention(now);expect(fs.existsSync(expired)).toBe(false);expect(fs.existsSync(retained)).toBe(true);const unsafe=path.join(archive,'c'.repeat(64)+'.json');fs.symlinkSync(retained,unsafe);expect(()=>retention(now)).toThrow('unsafe artifact');expect(fs.readFileSync(retained,'utf8')).toBe('{}');}finally{if(old===undefined)delete process.env.GSTACK_HOME;else process.env.GSTACK_HOME=old;}});
|
||||
test('an active recheck pins only its expired parent report, never expired repair material',()=>{const now=Date.now(),repo='f'.repeat(24),oldRun=`${now-31*86400_000}-${'a'.repeat(16)}`,childRun=`${now}-${'b'.repeat(16)}`,findingId='c'.repeat(32),repoDir=secureDirectory(path.join(privateRoot(),repo)),parent=secureDirectory(path.join(repoDir,oldRun)),child=secureDirectory(path.join(repoDir,childRun)),bundles=secureDirectory(path.join(parent,'bundles')),reviews=secureDirectory(path.join(parent,'reviews')),attempts=secureDirectory(path.join(parent,'verification-attempts'));writeHelperJson(path.join(parent,'report.json'),{schemaVersion:3,runId:oldRun,repoId:repo,status:'finished',coverage:[],findings:[{id:findingId}]});writeJson(path.join(bundles,'bundle.json'),{schemaVersion:3});writeJson(path.join(reviews,'review.json'),{schemaVersion:3});writeJson(path.join(attempts,'attempt.json'),{schemaVersion:3});writeHelperJson(path.join(child,'report.json'),{schemaVersion:3,runId:childRun,repoId:repo,status:'running',deadline:new Date(now+60_000).toISOString(),coverage:[],findings:[],parent:{runId:oldRun,findingId,kind:'recheck'}});retention(now);expect(fs.existsSync(path.join(parent,'report.json'))).toBe(true);expect(fs.existsSync(bundles)).toBe(false);expect(fs.existsSync(reviews)).toBe(false);expect(fs.existsSync(attempts)).toBe(false);fs.rmSync(child,{recursive:true});retention(now);expect(fs.existsSync(parent)).toBe(false);});
|
||||
test('finished, expired, and malformed child reports cannot extend parent retention',()=>{const now=Date.now(),repo='e'.repeat(24),repoDir=secureDirectory(path.join(privateRoot(),repo)),findingId='d'.repeat(32);for(const [index,childValue] of [[0,{status:'finished',repoId:repo,deadline:new Date(now+60_000).toISOString()}],[1,{status:'running',repoId:repo,deadline:new Date(now-1).toISOString()}],[2,{status:'running',repoId:'0'.repeat(24),deadline:new Date(now+60_000).toISOString()}]] as const){const parentRun=`${now-(31+index)*86400_000}-${String(index+1).repeat(16)}`,childRun=`${now-index}-${String(index+4).repeat(16)}`,parent=secureDirectory(path.join(repoDir,parentRun)),child=secureDirectory(path.join(repoDir,childRun));writeHelperJson(path.join(parent,'report.json'),{schemaVersion:3,runId:parentRun,repoId:repo,status:'finished',coverage:[],findings:[{id:findingId}]});writeHelperJson(path.join(child,'report.json'),{schemaVersion:3,runId:childRun,repoId:childValue.repoId,status:childValue.status,deadline:childValue.deadline,coverage:[],findings:[],parent:{runId:parentRun,findingId,kind:'recheck'}});}retention(now);for(const name of fs.readdirSync(repoDir).filter(name=>Number(name.split('-')[0])<now-30*86400_000))expect(fs.existsSync(path.join(repoDir,name))).toBe(false);});
|
||||
});
|
||||
@@ -1,44 +1,48 @@
|
||||
/**
|
||||
* Cross-skill taxonomy alignment. The canonical taxonomy lives in
|
||||
* lib/redact-patterns.ts (single source of truth). /spec and /cso both reference
|
||||
* it by pointer rather than inlining the full catalog (size discipline). This
|
||||
* test guards that the recognizable HIGH-tier prefixes stay present in /cso's
|
||||
* archaeology prose. (A fourth test covered the resolver-generated taxonomy
|
||||
* table; that generator was deleted as dead code — no template ever used it.)
|
||||
*/
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
/** CSO uses versioned domain mappings and the shared fail-closed secret taxonomy. */
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
// cso is carved (skeleton + sections/audit-phases.md). The Secrets Archaeology
|
||||
// prose + secret prefixes moved into the section; check the union so relocated
|
||||
// content still counts.
|
||||
function unionSkill(skill: string): string {
|
||||
let t = fs.readFileSync(path.join(ROOT, skill, "SKILL.md"), "utf-8");
|
||||
const dir = path.join(ROOT, skill, "sections");
|
||||
if (fs.existsSync(dir)) {
|
||||
for (const f of fs.readdirSync(dir).sort()) {
|
||||
if (f.endsWith(".md") && !f.endsWith(".md.tmpl")) t += "\n" + fs.readFileSync(path.join(dir, f), "utf-8");
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
const CSO = unionSkill("cso");
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const CSO = fs.readFileSync(path.join(ROOT, 'cso/SKILL.md'), 'utf8') + '\n'
|
||||
+ fs.readFileSync(path.join(ROOT, 'cso/sections/audit-phases.md'), 'utf8');
|
||||
|
||||
describe("cso/spec taxonomy alignment", () => {
|
||||
test("cso archaeology names the recognizable HIGH-tier prefixes", () => {
|
||||
for (const s of ["AKIA", "ghp_", "sk-ant-", "BEGIN"]) {
|
||||
expect(CSO).toContain(s);
|
||||
}
|
||||
describe('CSO domain source and redaction contracts', () => {
|
||||
test('credential recognition uses the shared taxonomy without raw history commands', () => {
|
||||
for (const prefix of ['AKIA', 'ghp_', 'sk-ant-', 'BEGIN']) expect(CSO).toContain(prefix);
|
||||
expect(CSO).toContain('lib/redact-patterns.ts');
|
||||
expect(CSO).toContain('Secrets Archaeology');
|
||||
expect(CSO).toContain('Never print raw `git log -p --all`');
|
||||
expect(CSO).toContain('Do not call live provider APIs');
|
||||
});
|
||||
|
||||
test("cso points to lib/redact-patterns.ts as the single source of truth", () => {
|
||||
expect(CSO).toContain("lib/redact-patterns.ts");
|
||||
test('OWASP 2025 mapping does not retain obsolete 2021 category numbers', () => {
|
||||
const rows = CSO.split('\n').filter(line => /^\| A\d\d \|/.test(line));
|
||||
expect(rows).toHaveLength(10);
|
||||
const categories = new Map(rows.map(line => {
|
||||
const [, id, name] = line.split('|').map(part => part.trim());
|
||||
return [id, name];
|
||||
}));
|
||||
expect(categories.get('A02')).toBe('Security Misconfiguration');
|
||||
expect(categories.get('A03')).toBe('Software Supply Chain Failures');
|
||||
expect(categories.get('A10')).toBe('Mishandling of Exceptional Conditions');
|
||||
expect(rows.find(line => line.startsWith('| A01 |'))).toContain('SSRF');
|
||||
});
|
||||
|
||||
test("cso keeps its git-history archaeology (different use case, not replaced)", () => {
|
||||
expect(CSO).toContain("git log -p --all");
|
||||
expect(CSO).toContain("Secrets Archaeology");
|
||||
test('domain standards carry inspected versions and no inferred compliance', () => {
|
||||
for (const version of ['OWASP Top 10:2025', 'OWASP API Security Top 10:2023', 'ASVS version: 5.0.0', 'v5.0.0-1.2.5', 'LLM Top 10 2026', 'Agentic Applications Top 10 2026', 'MCP security guidance version: 2026-07-28']) expect(CSO).toContain(version);
|
||||
expect(CSO).toContain('artifact 56857');
|
||||
expect(CSO).toContain('unset publication-date field');
|
||||
expect(CSO).toContain('artifact 52117');
|
||||
expect(CSO).toContain('Do not invent IDs');
|
||||
});
|
||||
|
||||
test('all declared scanners retain execution and evidence boundaries', () => {
|
||||
for (const scanner of ['Gitleaks', 'OSV-Scanner', 'Semgrep', 'zizmor', 'Trivy', 'Schemathesis']) expect(CSO).toContain(scanner);
|
||||
expect(CSO).toContain('Import existing SARIF');
|
||||
expect(CSO).toContain('public wheels');
|
||||
expect(CSO).toContain('Gemfile.lock` parsed as inert data');
|
||||
expect(CSO).toContain('Python `--no-build` alone');
|
||||
expect(CSO).toContain('every database connection');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { PublicArchiveCache } from '../lib/cso/cache';
|
||||
import type { VerificationRequest } from '../lib/cso/contracts';
|
||||
import { dockerEndpoint, type DockerEndpoint } from '../lib/cso/docker';
|
||||
import { DockerPreparationSandboxRunner } from '../lib/cso/preparation-docker';
|
||||
import {
|
||||
admitPreparationRuntime, admitPreparationSidecar, PreparationExecutor,
|
||||
type PreparedApplication, type PreparationRuntimeAdmission, type RailsDatabaseSelection,
|
||||
} from '../lib/cso/preparation-executor';
|
||||
import { inspectPreparation, type CsoStack, type PreparationPlan } from '../lib/cso/preparation';
|
||||
import { CSO_HELPER_ABI, type QualifiedRuntime, type RuntimeCatalog } from '../lib/cso/runtime-catalog';
|
||||
import { completeRuntimeCatalogFixture } from './helpers/cso-runtime-catalog';
|
||||
import { secureDirectory } from '../lib/cso/state';
|
||||
import { canonicalStartPlan, canonicalTestPlan, DockerVerificationExecutor } from '../lib/cso/verification';
|
||||
|
||||
const requestedStack = process.env.GSTACK_CSO_TEST_STACK;
|
||||
const requested = process.env.GSTACK_CSO_DOCKER_TESTS === '1' && ['bun', 'python', 'rails'].includes(requestedStack ?? '');
|
||||
const suite = requested ? describe : describe.skip;
|
||||
const platform = process.arch === 'arm64' ? 'linux/arm64' : 'linux/amd64';
|
||||
let root = '', watchdog = '', endpoint: DockerEndpoint, runtime: QualifiedRuntime, catalog: RuntimeCatalog;
|
||||
|
||||
function qualification(kind: 'application' | 'postgresql') {
|
||||
const common = { sourceCommit: 'b'.repeat(40), workflow: 'https://github.com/garrytan/gstack/actions/runs/1',
|
||||
sbomDigest: `sha256:${'b'.repeat(64)}`, provenanceDigest: `sha256:${'c'.repeat(64)}`, verifiedProvenance: true as const };
|
||||
return kind === 'application'
|
||||
? { ...common, kind, containmentPassed: true as const, coldStartPassed: true as const, positiveNegativeAssertionsPassed: true as const, heldOutRepairPassed: true as const }
|
||||
: { ...common, kind, containmentPassed: true as const, coldStartPassed: true as const, multiDatabasePassed: true as const, readinessPassed: true as const };
|
||||
}
|
||||
function qualified(stack: CsoStack | 'postgresql', image: string, versions: Record<string, string>): QualifiedRuntime {
|
||||
return { id: `${stack}-staged-cold-${process.arch}`, stack, platform, state: 'qualified', image,
|
||||
entrypoint: '/opt/cso/entrypoint', helperAbi: CSO_HELPER_ABI, versions, policyVersion: 'cso-isolation-v1',
|
||||
qualifiedAt: '2026-09-09T00:00:00.000Z', qualification: qualification(stack === 'postgresql' ? 'postgresql' : 'application') };
|
||||
}
|
||||
function writeFiles(directory: string, files: Record<string, string>): void {
|
||||
for (const [relative, body] of Object.entries(files)) {
|
||||
const file = path.join(directory, relative); fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(file, body, { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
function bunSource(directory: string): void {
|
||||
writeFiles(directory, {
|
||||
'package.json': JSON.stringify({ name: 'cso-bun-cold', version: '1.0.0', private: true,
|
||||
scripts: { start: 'bun app.js', test: 'bun test' }, dependencies: { 'escape-html': '1.0.3' } }) + '\n',
|
||||
'bun.lock': `{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": { "": { "name": "cso-bun-cold", "dependencies": { "escape-html": "1.0.3" } } },
|
||||
"packages": { "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="] }
|
||||
}\n`,
|
||||
'app.js': `import escape from "escape-html";
|
||||
const port=Number(process.env.PORT);Bun.serve({hostname:"127.0.0.1",port,fetch(request){const route=new URL(request.url).pathname;if(route==="/control")return new Response("CONTROL_OK:"+escape("<dependency>"));if(route==="/security")return new Response("DENIED",{status:403});return new Response("missing",{status:404})}});
|
||||
`,
|
||||
'app.test.js': `import {expect,test} from "bun:test";import escape from "escape-html";test("cold dependency is executable",()=>expect(escape("<dependency>")).toBe("<dependency>"));\n`,
|
||||
});
|
||||
}
|
||||
function pythonSource(directory: string): void {
|
||||
writeFiles(directory, {
|
||||
'requirements.txt': `Django==4.2.30 --hash=sha256:4d07aaf1c62f9984842b67c2874ebbf7056a17be253860299b93ae1881faad65
|
||||
asgiref==3.11.1 --hash=sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133
|
||||
sqlparse==0.5.5 --hash=sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba
|
||||
typing_extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8
|
||||
`,
|
||||
'manage.py': `#!/usr/bin/env python3
|
||||
import os,sys
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE","settings")
|
||||
from django.core.management import execute_from_command_line
|
||||
execute_from_command_line(sys.argv)
|
||||
`,
|
||||
'settings.py': `SECRET_KEY="cso-cold-fixture"\nDEBUG=False\nALLOWED_HOSTS=["127.0.0.1"]\nROOT_URLCONF="urls"\nINSTALLED_APPS=[]\nMIDDLEWARE=[]\n`,
|
||||
'urls.py': `from django.http import HttpResponse
|
||||
from django.urls import path
|
||||
def control(_request): return HttpResponse("CONTROL_OK:Django")
|
||||
def security(_request): return HttpResponse("DENIED",status=403)
|
||||
urlpatterns=[path("control",control),path("security",security)]
|
||||
`,
|
||||
'test_app.py': `import os,unittest
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE","settings")
|
||||
import django
|
||||
django.setup()
|
||||
from django.test import Client
|
||||
class ColdStartTest(unittest.TestCase):
|
||||
def test_dependency_and_control(self): self.assertContains(Client().get("/control"),"CONTROL_OK",status_code=200)
|
||||
`,
|
||||
});
|
||||
}
|
||||
function railsSource(directory: string, bundlerVersion: string): void {
|
||||
let lock = fs.readFileSync(path.join(import.meta.dir, 'fixtures/cso-eval/rails.Gemfile.lock'), 'utf8');
|
||||
lock = lock.replace(' pp (0.6.4)\n', ' pg (1.6.2)\n pp (0.6.4)\n')
|
||||
.replace(' puma (= 7.2.0)\n', ' pg (= 1.6.2)\n puma (= 7.2.0)\n')
|
||||
.replace(/(BUNDLED WITH\n)\s+[^\n]+/, `$1 ${bundlerVersion}`);
|
||||
writeFiles(directory, {
|
||||
'Gemfile': `source "https://rubygems.org"\ngem "rails", "= 8.1.2"\ngem "puma", "= 7.2.0"\ngem "sqlite3", "= 2.9.0"\ngem "pg", "= 1.6.2"\n`,
|
||||
'Gemfile.lock': lock,
|
||||
'config/boot.rb': `ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)\nrequire "bundler/setup"\n`,
|
||||
'config/application.rb': `require_relative "boot"\nrequire "rails"\nrequire "active_record/railtie"\nrequire "action_controller/railtie"\nmodule CsoCold;class Application < Rails::Application;config.load_defaults 8.1;config.eager_load=false;config.hosts.clear;end;end\n`,
|
||||
'config/environment.rb': `require_relative "application"\nRails.application.initialize!\n`,
|
||||
'config/routes.rb': `Rails.application.routes.draw do\n get "/control",to:"probe#control"\n get "/security",to:"probe#security"\nend\n`,
|
||||
'config/environments/test.rb': `Rails.application.configure do\n config.eager_load=false\n config.consider_all_requests_local=true\nend\n`,
|
||||
'config/database.yml': `default: &default\n pool: 3\ntest:\n primary:\n <<: *default\n adapter: sqlite3\n database: storage/test.sqlite3\n queue:\n <<: *default\n adapter: postgresql\n database: cso_queue\n`,
|
||||
'app/controllers/application_controller.rb': `class ApplicationController < ActionController::Base;end\n`,
|
||||
'app/controllers/probe_controller.rb': `class ProbeController < ApplicationController\n def control;render plain:"CONTROL_OK:Rails";end\n def security;render plain:"DENIED",status: :forbidden;end\nend\n`,
|
||||
'Rakefile': `require_relative "config/application"\nRails.application.load_tasks\n`,
|
||||
'config.ru': `require_relative "config/environment"\nrun Rails.application\n`,
|
||||
'db/schema.rb': `ActiveRecord::Schema[8.1].define(version: 1) do\n create_table :cold_records, force: true do |t|\n t.string :name\n end\nend\n`,
|
||||
'test/test_helper.rb': `ENV["RAILS_ENV"] ||= "test"\nrequire_relative "../config/environment"\nrequire "rails/test_help"\n`,
|
||||
'test/cold_start_test.rb': `require "test_helper"\nclass ColdStartTest < ActionDispatch::IntegrationTest\n test "control boots" do\n get "/control"\n assert_response :success\n assert_includes response.body,"CONTROL_OK"\n end\nend\n`,
|
||||
});
|
||||
}
|
||||
function readyPlan(source: string, stack: CsoStack): PreparationPlan {
|
||||
const plan = inspectPreparation(source, stack);
|
||||
if (plan.status !== 'ready') throw new Error(`${stack} cold fixture is not ready: ${plan.prerequisites.map(item => `${item.code}: ${item.message}`).join('; ')}`);
|
||||
return plan;
|
||||
}
|
||||
function preparation(runDir: string, admission: PreparationRuntimeAdmission): PreparationExecutor {
|
||||
const staging = secureDirectory(path.join(runDir, 'archive-staging'));
|
||||
const cache = new PublicArchiveCache({ root: path.join(runDir, 'public-cache'), stagingRoot: staging, maxBytes: 512 * 1024 * 1024 });
|
||||
const runner = new DockerPreparationSandboxRunner({ endpoint, watchdogPath: watchdog, runRoot: runDir,
|
||||
controlRoot: secureDirectory(path.join(runDir, 'preparation-execution')), admission });
|
||||
return new PreparationExecutor({ cache, runner, materializationRoot: secureDirectory(path.join(runDir, 'archive-materializations')) });
|
||||
}
|
||||
function requestFor(source: string, stack: CsoStack, port: number): VerificationRequest {
|
||||
const start = canonicalStartPlan(source, stack, port), tests = canonicalTestPlan(source, stack);
|
||||
return { findingId: 'd'.repeat(32), runtimeProfile: runtime.id, port, start: start.command,
|
||||
legitimate: [{ name: 'cold-start control', path: '/control', method: 'GET', expected: { status: 200, includes: 'CONTROL_OK' } }],
|
||||
security: { name: 'fixture denial', path: '/security', method: 'GET', expected: { status: 403, includes: 'DENIED' }, vulnerable: { status: 200, includes: 'SECRET' } },
|
||||
existingTests: tests.commands, fixtures: {}, boundaryFiles: start.entrypointFiles, testFiles: tests.files, changes: [],
|
||||
review: { reviewer: 'cold-start-gate', independent: true, rootCauseRepaired: true, featurePreserved: true,
|
||||
boundaryMocks: false, rationale: 'Cold-start execution only.', reviewedPatchHash: 'e'.repeat(64) } };
|
||||
}
|
||||
async function executeCold(stack: CsoStack, source: string, runDir: string, port: number, database?: RailsDatabaseSelection): Promise<PreparedApplication> {
|
||||
const plan = readyPlan(source, stack), admission = admitPreparationRuntime({ plan, platform, profile: runtime.id, catalog });
|
||||
const prep = preparation(runDir, admission), deadline = Date.now() + 15 * 60_000;
|
||||
const closure = await prep.acquire({ plan, admission, snapshot: source, deadline });
|
||||
expect(closure.archives.length).toBeGreaterThan(0);
|
||||
expect(closure.archives.every(item => item.requestedUrl.startsWith('https://'))).toBe(true);
|
||||
if (stack === 'bun') expect(closure.archives.every(item => item.resolvedUrl?.startsWith('https://'))).toBe(true);
|
||||
else expect(closure.archives.every(item => item.resolvedUrl === null)).toBe(true);
|
||||
const prepared = await prep.prepareOffline({ plan, admission, snapshot: source, closure, deadline, database });
|
||||
try {
|
||||
const observation = await new DockerVerificationExecutor(endpoint, watchdog, deadline).observe(
|
||||
prepared.preparedRoot, 'after', requestFor(prepared.preparedRoot, stack, port), runtime, runtime,
|
||||
secureDirectory(path.join(runDir, 'observations')), secureDirectory(path.join(runDir, 'verification-controls')),
|
||||
{ environment: prepared.executionEnvironment, database: prepared.database },
|
||||
);
|
||||
expect(observation).toMatchObject({ booted: true, legitimate: true, security: 'pass', existingTests: true });
|
||||
return prepared;
|
||||
} finally { await prep.dispose(prepared); }
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!requested) return;
|
||||
const image = process.env.GSTACK_CSO_TEST_IMAGE;
|
||||
if (!image) throw new Error(`${requestedStack} cold-start qualification requires GSTACK_CSO_TEST_IMAGE; a requested gate cannot skip`);
|
||||
if (process.env.GSTACK_CSO_TEST_PLATFORM !== platform) throw new Error(`${requestedStack} cold-start qualification requires native ${platform}`);
|
||||
const versions = JSON.parse(process.env.GSTACK_CSO_EXPECTED_VERSIONS || '{}') as Record<string, string>;
|
||||
for (const key of ({ bun: ['bun','cso-preparation'], python: ['python','uv','cso-preparation'], rails: ['ruby','bundler','cso-preparation'] } as const)[requestedStack as 'bun'|'python'|'rails'])
|
||||
if (!/^\d+\.\d+\.\d+$/.test(versions[key] ?? '')) throw new Error(`${requestedStack} cold-start qualification requires exact ${key} metadata`);
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), `cso-${requestedStack}-cold-`)); process.env.GSTACK_HOME = path.join(root, 'state');
|
||||
watchdog = path.resolve(import.meta.dir, '../bin/gstack-cso-watchdog');
|
||||
if (!fs.existsSync(watchdog)) throw new Error(`${requestedStack} cold-start qualification requires the compiled CSO watchdog`);
|
||||
endpoint = await dockerEndpoint(secureDirectory(path.join(root, 'docker-home')), { HOME: root, DOCKER_HOST: process.env.DOCKER_HOST ?? 'unix:///var/run/docker.sock' });
|
||||
catalog = completeRuntimeCatalogFixture(`${requestedStack}-staged-cold`);
|
||||
const installRuntime = (value: QualifiedRuntime): QualifiedRuntime => {
|
||||
const runtimeIndex = catalog.runtimes.findIndex(item => item.stack === value.stack && item.platform === value.platform);
|
||||
const profile = catalog.profiles.find(item => item.stack === value.stack && item.platform === value.platform)!;
|
||||
const installed = { ...value, id: profile.id };
|
||||
catalog.runtimes[runtimeIndex] = installed;
|
||||
profile.versions = { ...installed.versions };
|
||||
return installed;
|
||||
};
|
||||
runtime = installRuntime(qualified(requestedStack as CsoStack, image, versions));
|
||||
if (requestedStack === 'rails') {
|
||||
const postgresImage = process.env.GSTACK_CSO_TEST_POSTGRES_IMAGE;
|
||||
const postgresVersion = process.env.GSTACK_CSO_TEST_POSTGRES_VERSION;
|
||||
if (!postgresImage || !/^\d+\.\d+(?:\.\d+)?$/.test(postgresVersion ?? '')) throw new Error('Rails cold-start qualification requires a staged PostgreSQL digest and exact version');
|
||||
installRuntime(qualified('postgresql', postgresImage, { postgresql: postgresVersion! }));
|
||||
}
|
||||
});
|
||||
afterAll(() => { if (root) fs.rmSync(root, { recursive: true, force: true }); delete process.env.GSTACK_HOME; });
|
||||
|
||||
suite('CSO staged Bun, Python, and Rails cold-start journeys', () => {
|
||||
test('acquires, prepares, boots, controls, and tests the requested staged stack with no target egress', async () => {
|
||||
const source = secureDirectory(path.join(root, 'source'));
|
||||
if (requestedStack === 'bun') bunSource(source);
|
||||
else if (requestedStack === 'python') pythonSource(source);
|
||||
else railsSource(source, runtime.versions.bundler);
|
||||
if (requestedStack !== 'rails') {
|
||||
const prepared = await executeCold(requestedStack as CsoStack, source, secureDirectory(path.join(root, 'run')), requestedStack === 'bun' ? 34610 : 34611);
|
||||
expect(prepared.database).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
const plan = readyPlan(source, 'rails');
|
||||
expect(plan.database).toMatchObject({ supported: ['sqlite', 'postgresql'], connections: ['primary', 'queue'] });
|
||||
const appAdmission = admitPreparationRuntime({ plan, platform, profile: runtime.id, catalog });
|
||||
const sidecar = admitPreparationSidecar({ platform, catalog });
|
||||
const sqlite = await executeCold('rails', source, secureDirectory(path.join(root, 'run-sqlite')), 34612, { adapter: 'sqlite' });
|
||||
expect(sqlite.database).toEqual({ adapter: 'sqlite', connections: ['primary', 'queue'] });
|
||||
const postgres = await executeCold('rails', source, secureDirectory(path.join(root, 'run-postgresql')), 34613, { adapter: 'postgresql', sidecar });
|
||||
expect(postgres.database).toMatchObject({ adapter: 'postgresql', connections: ['primary', 'queue'], sidecar: { id: sidecar.runtime.id } });
|
||||
expect(appAdmission.catalogRevision).toBe(sidecar.catalogRevision);
|
||||
}, 25 * 60_000);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { sha256 } from '../lib/cso/contracts';
|
||||
import { patchHash, treeHash, verifyRepair } from '../lib/cso/verification';
|
||||
|
||||
const roots:string[]=[];
|
||||
afterEach(()=>{for(const root of roots.splice(0))fs.rmSync(root,{recursive:true,force:true});});
|
||||
|
||||
describe('CSO verification persistence ordering',()=>{
|
||||
test('cleanup acknowledgement failure leaves no certified bundle on disk',async()=>{
|
||||
const runDir=fs.mkdtempSync(path.join(os.tmpdir(),'cso-cleanup-order-'));roots.push(runDir);
|
||||
const snapshot=path.join(runDir,'snapshot');fs.mkdirSync(snapshot);
|
||||
const files:Record<string,string>={
|
||||
'package.json':JSON.stringify({scripts:{test:'node --test'}}),
|
||||
'app.js':'vulnerable\n',
|
||||
'app.test.js':'test("ok",()=>{})\n',
|
||||
};
|
||||
for(const [name,body] of Object.entries(files))fs.writeFileSync(path.join(snapshot,name),body);
|
||||
const request:any={findingId:'a'.repeat(32),runtimeProfile:'node',port:3456,start:{executable:'/usr/local/bin/node',args:['app.js']},
|
||||
legitimate:[{name:'health',path:'/health',method:'GET',expected:{status:200,includes:'ok'}}],
|
||||
security:{name:'tenant isolation',path:'/user?id=2',method:'GET',expected:{status:403},vulnerable:{status:200,includes:'tenant-b'}},
|
||||
existingTests:[{executable:'/usr/local/bin/node',args:['--test','--test-reporter=tap','./app.test.js']}],testFiles:['app.test.js'],fixtures:{},boundaryFiles:['app.js'],
|
||||
changes:[{path:'app.js',beforeSha256:sha256(files['app.js']),after:'fixed\n',effect:'source'}],
|
||||
review:{reviewer:'independent-reviewer',independent:true,rootCauseRepaired:true,featurePreserved:true,boundaryMocks:false,
|
||||
rationale:'The tenant predicate is restored without changing the feature',reviewedPatchHash:''}};
|
||||
request.review.reviewedPatchHash=patchHash(request);
|
||||
const entries=Object.entries(files).map(([name,body])=>({path:name,originalHash:sha256(body),executionHash:sha256(body),bytes:Buffer.byteLength(body),mode:0o600}));
|
||||
const manifest:any={version:3,createdAt:'2026-01-01T00:00:00Z',expiresAt:'2026-01-08T00:00:00Z',root:'/repo',headCommit:'b'.repeat(40),
|
||||
originalHash:'c'.repeat(64),executionHash:treeHash(snapshot),entries};
|
||||
const watchdog=path.join(runDir,'no-ack-watchdog');
|
||||
fs.writeFileSync(watchdog,'#!/bin/sh\nset -eu\ntouch "$6/attempt.ready"\nwhile test ! -f "$6/attempt.terminal"; do sleep 0.01; done\nexit 0\n',{mode:0o755});
|
||||
const runtime:any={id:'node',stack:'node',image:`runtime@sha256:${'d'.repeat(64)}`,platform:'linux/amd64'};
|
||||
const executor:any={observe:async(_source:string,phase:'before'|'after')=>({booted:true,legitimate:true,
|
||||
security:phase==='before'?'intended_failure':'pass',existingTests:true,output:'ok',inputHash:''})};
|
||||
await expect(verifyRepair({runId:'run',runDir,manifest,rawRequest:request,runtime,verifier:runtime,policyHash:'e'.repeat(64),archives:[],executor,
|
||||
watchdogPath:watchdog,attemptDeadline:Date.now()+10_000})).rejects.toThrow('did not acknowledge');
|
||||
const bundles=path.join(runDir,'bundles');expect(fs.existsSync(bundles)?fs.readdirSync(bundles):[]).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { boundedResponseBody } from '../lib/cso/verifier';
|
||||
|
||||
describe('CSO bounded loopback verifier response reader',()=>{
|
||||
test('accepts a response exactly at the configured byte limit',async()=>{
|
||||
const body='x'.repeat(65_536);
|
||||
expect(await boundedResponseBody(new Response(body))).toBe(body);
|
||||
});
|
||||
|
||||
test('cancels an endless chunked response immediately after the byte limit',async()=>{
|
||||
let pulls=0,cancelled=false;
|
||||
const stream=new ReadableStream<Uint8Array>({
|
||||
pull(controller){pulls++;controller.enqueue(new Uint8Array(8192));},
|
||||
cancel(){cancelled=true;},
|
||||
});
|
||||
await expect(boundedResponseBody(new Response(stream))).rejects.toThrow('response too large');
|
||||
expect(cancelled).toBe(true);
|
||||
expect(pulls).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
test('rejects an oversized declared body before consuming it',async()=>{
|
||||
let cancelled=false;
|
||||
const stream=new ReadableStream<Uint8Array>({pull(controller){controller.enqueue(new Uint8Array(1));},cancel(){cancelled=true;}});
|
||||
const response=new Response(stream,{headers:{'content-length':'65537'}});
|
||||
await expect(boundedResponseBody(response)).rejects.toThrow('response too large');
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';import * as fs from 'node:fs';import * as os from 'node:os';import * as path from 'node:path';import { spawn, spawnSync } from 'node:child_process';
|
||||
import { supervisePreparedCall } from '../lib/cso/preparation-docker';
|
||||
const dirs:string[]=[];const tmp=()=>{const p=fs.mkdtempSync(path.join(os.tmpdir(),'csowatchdog-'));dirs.push(p);return p;};afterEach(()=>{for(const p of dirs.splice(0))fs.rmSync(p,{recursive:true,force:true});});
|
||||
function compile(dir:string){const out=path.join(dir,'watchdog'),r=spawnSync('/usr/bin/cc',['-std=c11','-D_POSIX_C_SOURCE=200809L','-O2','-Wall','-Wextra',path.resolve(import.meta.dir,'../lib/cso/watchdog.c'),'-o',out],{encoding:'utf8',timeout:30_000});expect(r.status).toBe(0);expect(r.stderr).toBe('');return out;}
|
||||
function pinnedEndpoint(dir:string){const socket=path.join(dir,'daemon.sock'),made=spawnSync('/usr/bin/python3',['-c','import socket,sys; s=socket.socket(socket.AF_UNIX); s.bind(sys.argv[1]); s.close()',socket],{encoding:'utf8'});expect(made.status).toBe(0);const stat=fs.lstatSync(socket);expect(stat.isSocket()).toBe(true);return{uri:`unix://${socket}`,device:stat.dev,inode:stat.ino};}
|
||||
describe('detached CSO watchdog',()=>{
|
||||
test('prepared-call guard acknowledges normal exact cleanup',async()=>{const run=tmp(),watchdog=compile(run),call=path.join(run,'preparation-execution','offline-call'),control=path.join(run,'supervision','prepared-call');fs.mkdirSync(call,{recursive:true});fs.mkdirSync(control,{recursive:true});fs.writeFileSync(path.join(call,'prepared-source'),'copy');const guard=await supervisePreparedCall({watchdogPath:watchdog,ownerPid:process.pid,deadline:Date.now()+10_000,runRoot:run,callRoot:call,controlRoot:control});await guard.dispose();expect(fs.existsSync(call)).toBe(false);expect(fs.existsSync(control)).toBe(false);});
|
||||
test('prepared-call guard removes retained output after owner death and deadline',async()=>{for(const mode of ['owner','deadline'] as const){const run=tmp(),watchdog=compile(run),call=path.join(run,'preparation-execution',mode),control=path.join(run,'supervision',mode),event=path.join(control,'attempt.event');fs.mkdirSync(call,{recursive:true});fs.mkdirSync(control,{recursive:true});fs.writeFileSync(path.join(call,'prepared-source'),'copy');const owner=mode==='owner'?spawn('/bin/sleep',['30'],{stdio:'ignore'}):undefined,guard=await supervisePreparedCall({watchdogPath:watchdog,ownerPid:owner?.pid??process.pid,deadline:Date.now()+(mode==='owner'?10_000:50),runRoot:run,callRoot:call,controlRoot:control});if(owner)owner.kill('SIGKILL');for(let attempt=0;attempt<40&&(!fs.existsSync(event)||fs.existsSync(call));attempt++)await Bun.sleep(100);expect(fs.existsSync(call)).toBe(false);expect(fs.readFileSync(event,'utf8')).toContain(`${mode==='owner'?'supervisor-death':'deadline'} execution-copy cleanup complete`);await guard.dispose();expect(fs.existsSync(control)).toBe(false);}});
|
||||
test('survives supervisor death and cleans journaled plus label-race resources',async()=>{const dir=tmp(),watchdog=compile(dir),endpoint=pinnedEndpoint(dir),log=path.join(dir,'docker.log'),docker=path.join(dir,'docker'),id='a'.repeat(64),race='e'.repeat(64),lease=path.join(dir,'lease'),token='c'.repeat(32);fs.mkdirSync(lease);fs.writeFileSync(path.join(lease,'lease.json'),'{}');fs.writeFileSync(path.join(lease,'lease.token'),token+'\n');fs.writeFileSync(docker,`#!/bin/sh\nif [ "$3" = ps ]; then if [ "$8" = "id=${id}" ]; then printf '%s\\n' '${id}'; else printf '%s\\n' '${race}'; fi; exit 0; fi\nprintf '%s\\n' "$*" >> '${log}'\n`,{mode:0o755});fs.writeFileSync(path.join(dir,'resources.journal'),`container:${id}\n`,{mode:0o600});const owner=spawn('/bin/sleep',['1'],{stdio:'ignore'}),child=spawn(watchdog,['--owner',String(owner.pid),'--deadline',String(Math.ceil((Date.now()+10_000)/1000)),'--run-dir',dir,'--docker',docker,'--endpoint',endpoint.uri,'--socket-device',String(endpoint.device),'--socket-inode',String(endpoint.inode),'--run-label','test-label','--lease-path',lease,'--lease-token',token],{stdio:'ignore'}),closed=new Promise<number|null>(resolve=>child.on('close',resolve));expect(await closed).toBe(0);const calls=fs.readFileSync(log,'utf8');expect(calls).toContain(`rm --force --volumes ${id}`);expect(calls).toContain(`rm --force --volumes ${race}`);expect(fs.readFileSync(path.join(dir,'watchdog.event'),'utf8')).toContain('supervisor-death cleanup complete');expect(fs.existsSync(lease)).toBe(false);});
|
||||
test('quarantines an authenticated lease before a concurrent TS recovery claim can strand release',async()=>{const dir=tmp(),watchdog=compile(dir),endpoint=pinnedEndpoint(dir),docker=path.join(dir,'docker'),lease=path.join(dir,'lease'),token='1'.repeat(32),claim='2'.repeat(32);fs.mkdirSync(lease,{mode:0o700});fs.writeFileSync(path.join(lease,'lease.json'),JSON.stringify({token}),{mode:0o600});fs.writeFileSync(path.join(lease,'lease.token'),token+'\n',{mode:0o600});fs.writeFileSync(path.join(lease,'.recovery'),JSON.stringify({pid:process.pid,processIdentity:null,token:claim,createdAt:Date.now()})+'\n',{mode:0o600});fs.writeFileSync(docker,'#!/bin/sh\nif [ "$3" = ps ]; then exit 0; fi\nexit 0\n',{mode:0o755});const owner=spawn('/bin/sleep',['0.05'],{stdio:'ignore'}),child=spawn(watchdog,['--owner',String(owner.pid),'--deadline',String(Math.ceil((Date.now()+10_000)/1000)),'--run-dir',dir,'--docker',docker,'--endpoint',endpoint.uri,'--socket-device',String(endpoint.device),'--socket-inode',String(endpoint.inode),'--run-label','release-race','--lease-path',lease,'--lease-token',token],{stdio:'ignore'}),closed=new Promise<number|null>(resolve=>child.on('close',resolve));const outcome=await Promise.race([closed,Bun.sleep(5000).then(()=>-999)]);if(outcome===-999)child.kill('SIGKILL');expect(outcome).toBe(0);expect(fs.existsSync(lease)).toBe(false);expect(fs.readdirSync(dir).some(name=>name.includes('.watchdog-release-'))).toBe(false);expect(fs.readFileSync(path.join(dir,'watchdog.event'),'utf8')).toContain('cleanup complete');});
|
||||
test('attempt mode removes a patched execution copy after supervisor death',async()=>{const run=tmp(),watchdog=compile(run),control=path.join(run,'supervision','attempt'),work=path.join(run,'verification','attempt');fs.mkdirSync(control,{recursive:true});fs.mkdirSync(work,{recursive:true});fs.writeFileSync(path.join(work,'patched-source'),'sensitive execution copy');const owner=spawn('/bin/sleep',['1'],{stdio:'ignore'}),child=spawn(watchdog,['--attempt-owner',String(owner.pid),'--deadline',String(Math.ceil((Date.now()+10_000)/1000)),'--control-dir',control,'--work-root',work,'--run-root',run],{stdio:'ignore'}),closed=new Promise<number|null>(resolve=>child.on('close',resolve));expect(await closed).toBe(0);expect(fs.existsSync(work)).toBe(false);expect(fs.readFileSync(path.join(control,'attempt.event'),'utf8')).toContain('execution-copy cleanup complete');});
|
||||
test('attempt cleanup cannot remove the concurrent Docker watchdog journal',async()=>{const run=tmp(),watchdog=compile(run),endpoint=pinnedEndpoint(run),attemptControl=path.join(run,'supervision','attempt'),dockerControl=path.join(attemptControl,'docker-groups','before'),work=path.join(run,'verification','attempt'),lease=path.join(run,'lease'),log=path.join(dockerControl,'docker.log'),docker=path.join(dockerControl,'docker'),id='a'.repeat(64),race='e'.repeat(64),token='c'.repeat(32);for(const dir of [attemptControl,dockerControl,work,lease])fs.mkdirSync(dir,{recursive:true});fs.writeFileSync(path.join(work,'patched-source'),'sensitive execution copy');fs.writeFileSync(path.join(lease,'lease.json'),'{}');fs.writeFileSync(path.join(lease,'lease.token'),token+'\n');fs.writeFileSync(path.join(dockerControl,'resources.journal'),`container:${id}\n`,{mode:0o600});fs.writeFileSync(docker,`#!/bin/sh\nif [ "$3" = ps ]; then if [ "$8" = "id=${id}" ]; then printf '%s\\n' '${id}'; else printf '%s\\n' '${race}'; fi; exit 0; fi\nprintf '%s\\n' "$*" >> '${log}'\n`,{mode:0o755});const owner=spawn('/bin/sleep',['1'],{stdio:'ignore'}),attempt=spawn(watchdog,['--attempt-owner',String(owner.pid),'--deadline',String(Math.ceil((Date.now()+10_000)/1000)),'--control-dir',attemptControl,'--work-root',work,'--run-root',run],{stdio:'ignore'}),containers=spawn(watchdog,['--owner',String(owner.pid),'--deadline',String(Math.ceil((Date.now()+10_000)/1000)),'--run-dir',dockerControl,'--docker',docker,'--endpoint',endpoint.uri,'--socket-device',String(endpoint.device),'--socket-inode',String(endpoint.inode),'--run-label','test-label','--lease-path',lease,'--lease-token',token],{stdio:'ignore'});const closed=(child:any)=>new Promise<number|null>(resolve=>child.on('close',resolve));expect(await Promise.all([closed(attempt),closed(containers)])).toEqual([0,0]);expect(fs.existsSync(work)).toBe(false);expect(fs.readFileSync(log,'utf8')).toContain(`rm --force --volumes ${id}`);expect(fs.existsSync(lease)).toBe(false);expect(fs.readFileSync(path.join(dockerControl,'watchdog.event'),'utf8')).toContain('cleanup complete');});
|
||||
test('a terminal marker prevents any cleanup call',async()=>{const dir=tmp(),watchdog=compile(dir),endpoint=pinnedEndpoint(dir),log=path.join(dir,'docker.log'),docker=path.join(dir,'docker'),lease=path.join(dir,'lease'),token='d'.repeat(32);fs.mkdirSync(lease);fs.writeFileSync(path.join(lease,'lease.json'),'{}');fs.writeFileSync(path.join(lease,'lease.token'),token+'\n');fs.writeFileSync(docker,`#!/bin/sh\ntouch '${log}'\n`,{mode:0o755});fs.writeFileSync(path.join(dir,'resources.journal'),`container:${'b'.repeat(64)}\n`);fs.writeFileSync(path.join(dir,'watchdog.terminal'),'done\n');const r=spawnSync(watchdog,['--owner',String(process.pid),'--deadline',String(Math.ceil((Date.now()+10_000)/1000)),'--run-dir',dir,'--docker',docker,'--endpoint',endpoint.uri,'--socket-device',String(endpoint.device),'--socket-inode',String(endpoint.inode),'--run-label','test-label','--lease-path',lease,'--lease-token',token],{timeout:30_000});expect(r.status).toBe(0);expect(fs.existsSync(log)).toBe(false);});
|
||||
test('acknowledges terminal cleanup well inside the caller deadline',async()=>{const dir=tmp(),watchdog=compile(dir),endpoint=pinnedEndpoint(dir),docker=path.join(dir,'docker'),lease=path.join(dir,'lease'),token='9'.repeat(32),ready=path.join(dir,'watchdog.ready'),stopped=path.join(dir,'watchdog.stopped');fs.mkdirSync(lease);fs.writeFileSync(path.join(lease,'lease.json'),'{}');fs.writeFileSync(path.join(lease,'lease.token'),token+'\n');fs.writeFileSync(docker,'#!/bin/sh\nexit 0\n',{mode:0o755});const child=spawn(watchdog,['--owner',String(process.pid),'--deadline',String(Math.ceil((Date.now()+10_000)/1000)),'--run-dir',dir,'--docker',docker,'--endpoint',endpoint.uri,'--socket-device',String(endpoint.device),'--socket-inode',String(endpoint.inode),'--run-label','ack-latency','--lease-path',lease,'--lease-token',token],{stdio:'ignore'}),closed=new Promise<number|null>(resolve=>child.once('close',resolve));for(let i=0;i<100&&!fs.existsSync(ready);i++)await Bun.sleep(10);expect(fs.existsSync(ready)).toBe(true);await Bun.sleep(50);const started=Date.now();fs.writeFileSync(path.join(dir,'watchdog.terminal'),'done\n');for(let i=0;i<100&&!fs.existsSync(stopped);i++)await Bun.sleep(10);expect(fs.existsSync(stopped)).toBe(true);expect(Date.now()-started).toBeLessThan(500);expect(await closed).toBe(0);});
|
||||
test('a torn final journal row cannot retain the machine lease after exact label sweeps',async()=>{const dir=tmp(),watchdog=compile(dir),endpoint=pinnedEndpoint(dir),docker=path.join(dir,'docker'),lease=path.join(dir,'lease'),token='e'.repeat(32),id='a'.repeat(64);fs.mkdirSync(lease);fs.writeFileSync(path.join(lease,'lease.json'),'{}');fs.writeFileSync(path.join(lease,'lease.token'),token+'\n');fs.writeFileSync(docker,'#!/bin/sh\nif [ "$3" = ps ]; then exit 0; fi\nexit 0\n',{mode:0o755});fs.writeFileSync(path.join(dir,'resources.journal'),`container:${id}\ncontainer:abcd`,{mode:0o600});const owner=spawn('/bin/sleep',['1'],{stdio:'ignore'}),child=spawn(watchdog,['--owner',String(owner.pid),'--deadline',String(Math.ceil((Date.now()+10_000)/1000)),'--run-dir',dir,'--docker',docker,'--endpoint',endpoint.uri,'--socket-device',String(endpoint.device),'--socket-inode',String(endpoint.inode),'--run-label','test-label','--lease-path',lease,'--lease-token',token],{stdio:'ignore'});expect(await new Promise<number|null>(resolve=>child.on('close',resolve))).toBe(0);expect(fs.existsSync(lease)).toBe(false);expect(fs.readFileSync(path.join(dir,'watchdog.event'),'utf8')).toContain('malformed journal ignored after two exact label sweeps');});
|
||||
test('a replaced Docker socket blocks cleanup and lease release',async()=>{const dir=tmp(),watchdog=compile(dir),endpoint=pinnedEndpoint(dir),docker=path.join(dir,'docker'),lease=path.join(dir,'lease'),token='f'.repeat(32),log=path.join(dir,'docker.log');fs.mkdirSync(lease);fs.writeFileSync(path.join(lease,'lease.json'),'{}');fs.writeFileSync(path.join(lease,'lease.token'),token+'\n');fs.writeFileSync(docker,`#!/bin/sh\ntouch '${log}'\nexit 0\n`,{mode:0o755});const owner=spawn('/bin/sleep',['30'],{stdio:'ignore'}),child=spawn(watchdog,['--owner',String(owner.pid),'--deadline',String(Math.ceil((Date.now()+30_000)/1000)),'--run-dir',dir,'--docker',docker,'--endpoint',endpoint.uri,'--socket-device',String(endpoint.device),'--socket-inode',String(endpoint.inode),'--run-label','test-label','--lease-path',lease,'--lease-token',token],{stdio:'ignore'});for(let i=0;i<100&&!fs.existsSync(path.join(dir,'watchdog.ready'));i++)await Bun.sleep(10);expect(fs.existsSync(path.join(dir,'watchdog.ready'))).toBe(true);const socketPath=endpoint.uri.slice('unix://'.length);fs.renameSync(socketPath,`${socketPath}.old`);const replacement=pinnedEndpoint(dir);expect(replacement.inode).not.toBe(endpoint.inode);owner.kill('SIGKILL');for(let i=0;i<50&&!fs.existsSync(path.join(dir,'watchdog.event'));i++)await Bun.sleep(100);expect(fs.readFileSync(path.join(dir,'watchdog.event'),'utf8')).toContain('socket identity changed');expect(fs.existsSync(lease)).toBe(true);expect(fs.existsSync(log)).toBe(false);child.kill('SIGKILL');await new Promise(resolve=>child.once('close',resolve));});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const windows = process.platform === 'win32';
|
||||
const required = process.env.GSTACK_CSO_WINDOWS_TESTS === '1';
|
||||
if (required && !windows) throw new Error('GSTACK_CSO_WINDOWS_TESTS=1 requires native Windows; emulation does not qualify the launcher.');
|
||||
let temporary = '', invocationCwd = '', launcher = '', core = '', marker = '', preload = '', publisher = '', buildStage = '';
|
||||
beforeAll(() => {
|
||||
if (!windows) return;
|
||||
const installed = path.join(ROOT, 'bin', 'gstack-cso-launcher.exe');
|
||||
if (!fs.existsSync(installed)) throw new Error('Native CSO launcher is missing; run bun run build:cso with MSVC first.');
|
||||
temporary = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'CSO launcher 空間 '));
|
||||
launcher = path.join(temporary, 'gstack-cso-launcher.exe');
|
||||
core = path.join(temporary, 'gstack-cso-core.exe');
|
||||
invocationCwd = path.join(temporary, 'audited cwd');
|
||||
fs.mkdirSync(invocationCwd);
|
||||
marker = path.join(temporary, 'preload-ran');
|
||||
preload = path.join(temporary, 'preload.ts');
|
||||
fs.writeFileSync(preload, `import {writeFileSync} from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'startup injection');`);
|
||||
const source = path.join(temporary, 'core-fixture.ts');
|
||||
fs.writeFileSync(source, `import {existsSync,writeFileSync} from 'node:fs';
|
||||
const hold=process.argv.indexOf('--hold-until');
|
||||
if(hold>=0){writeFileSync(process.argv[hold+1],String(process.pid));while(!existsSync(process.argv[hold+2]))await Bun.sleep(10);writeFileSync(process.argv[hold+3],'exited');}
|
||||
process.stdout.write(JSON.stringify({argv:process.argv.slice(2),env:process.env,cwd:process.cwd()}));
|
||||
if(process.argv.includes('--exit-23'))process.exit(23);`);
|
||||
const compiled = spawnSync(process.execPath, ['build', '--compile', '--no-compile-autoload-dotenv', '--no-compile-autoload-bunfig', '--no-compile-autoload-tsconfig', '--no-compile-autoload-package-json', source, '--outfile', core], { encoding: 'utf8', timeout: 60_000 });
|
||||
expect(compiled.status).toBe(0);
|
||||
const digest = createHash('sha256').update(fs.readFileSync(core)).digest('hex');
|
||||
buildStage = fs.mkdtempSync(path.join(ROOT, 'bin', '.gstack-cso-stage.windows-launcher-test.'));
|
||||
const stagedLauncher = path.join(buildStage, 'gstack-cso-launcher.exe');
|
||||
const stagedPublisher = path.join(buildStage, 'gstack-cso-publish-lock.exe');
|
||||
const native = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
||||
'-File', path.join(ROOT, 'scripts', 'build-cso-windows.ps1'), '-RepoRoot', ROOT,
|
||||
'-OutputPath', stagedLauncher, '-LockOutputPath', stagedPublisher, '-CoreSha256', digest, '-GitExePath', Bun.which('git')!],
|
||||
{ encoding: 'utf8', timeout: 60_000 });
|
||||
if (native.status !== 0) throw new Error(`Fixture launcher compilation failed: ${native.stdout}${native.stderr}`);
|
||||
fs.copyFileSync(stagedLauncher, launcher);
|
||||
publisher = path.join(temporary, 'gstack-cso-publish-lock.exe');
|
||||
fs.copyFileSync(stagedPublisher, publisher);
|
||||
fs.writeFileSync(path.join(temporary, '.gstack-cso-generation.lock'), '');
|
||||
fs.writeFileSync(path.join(temporary, '.gstack-cso-generation'), `${digest}\n`);
|
||||
fs.rmSync(buildStage, { recursive: true, force: true }); buildStage = '';
|
||||
}, 140_000);
|
||||
afterAll(() => {
|
||||
if (buildStage) fs.rmSync(buildStage, { recursive: true, force: true });
|
||||
if (temporary) fs.rmSync(temporary, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function launchEnvironment(extra: Record<string, string> = {}) {
|
||||
return { ...process.env, HOME: temporary, GSTACK_HOME: path.join(temporary, 'state'), PATH: temporary,
|
||||
NODE_OPTIONS: '--require=hostile', RUBYOPT: '-rhostile', PYTHONPATH: temporary,
|
||||
GSTACK_CSO_SECRET_CANARY: 'must-not-cross-startup', ...extra };
|
||||
}
|
||||
|
||||
function launch(args: string[], extra: Record<string, string> = {}, file = launcher) {
|
||||
return spawnSync(file, args, {
|
||||
cwd: invocationCwd, encoding: 'utf8', timeout: 30_000,
|
||||
env: launchEnvironment(extra),
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForFile(file: string, timeout = 10_000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (!fs.existsSync(file) && Date.now() < deadline) await Bun.sleep(10);
|
||||
expect(fs.existsSync(file)).toBe(true);
|
||||
}
|
||||
|
||||
function publishProbe() {
|
||||
return spawnSync(publisher, [temporary, process.execPath, '--version'], { encoding: 'utf8', timeout: 10_000 });
|
||||
}
|
||||
|
||||
function pathIdentity(value: string) {
|
||||
const stat = fs.statSync(value, { bigint: true });
|
||||
return { device: stat.dev, file: stat.ino };
|
||||
}
|
||||
|
||||
function expectSuccessfulProcess(result: ReturnType<typeof spawnSync>, label: string) {
|
||||
if (result.status === 0) return;
|
||||
const bounded = (value: unknown) => String(value ?? '').slice(0, 8192);
|
||||
throw new Error(`${label} failed: ${JSON.stringify({
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
error: result.error?.message,
|
||||
stdout: bounded(result.stdout),
|
||||
stderr: bounded(result.stderr),
|
||||
})}`);
|
||||
}
|
||||
|
||||
function filesNamed(root: string, name: string): string[] {
|
||||
const found: string[] = [];
|
||||
const walk = (directory: string) => {
|
||||
let entries: fs.Dirent[];
|
||||
try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return; }
|
||||
for (const entry of entries) {
|
||||
const candidate = path.join(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
if (entry.isDirectory()) walk(candidate);
|
||||
else if (entry.isFile() && entry.name === name) found.push(candidate);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('CSO native Windows build contract', () => {
|
||||
test('Windows builds use MSVC with a static CRT and no Bun-hosted public launcher', () => {
|
||||
const build = fs.readFileSync(path.join(ROOT, 'scripts/build-cso.sh'), 'utf8');
|
||||
const msvc = fs.readFileSync(path.join(ROOT, 'scripts/build-cso-windows.ps1'), 'utf8');
|
||||
expect(build).toContain('powershell.exe -NoProfile -NonInteractive');
|
||||
expect(build).toContain('build-cso-windows.ps1');
|
||||
expect(build).toContain('-OutputPath "$(cygpath -w "$CSO_STAGE_LAUNCHER")"');
|
||||
expect(build).toContain('-LockOutputPath "$(cygpath -w "$CSO_STAGE_LOCKER")"');
|
||||
expect(build).toContain('-CoreSha256 "$CSO_CORE_SHA256"');
|
||||
expect(build).toContain('-GitExePath "$(cygpath -aw "$CSO_WINDOWS_GIT")"');
|
||||
expect(build).toContain('CSO_PUBLISH_SHELL="$(cygpath -aw /usr/bin/bash.exe)"');
|
||||
expect(build).not.toContain('launcher-windows.ts');
|
||||
expect(fs.existsSync(path.join(ROOT, 'lib/cso/launcher-windows.ts'))).toBe(false);
|
||||
expect(msvc).toContain('Launch-VsDevShell.ps1');
|
||||
expect(msvc).toContain('[switch]$CheckOnly');
|
||||
expect(msvc).toContain('Normal CSO Windows builds require staged outputs and a lowercase SHA-256 core binding.');
|
||||
expect(msvc).toContain("[System.IO.FileAttributes]::ReparsePoint");
|
||||
expect(msvc).not.toContain("Join-Path $RepoRoot 'bin\\gstack-cso-launcher.exe'");
|
||||
expect(msvc).toContain("Set-Content -LiteralPath $source");
|
||||
expect(msvc).toContain('-Arch amd64 -HostArch amd64 -SkipAutomaticLocation');
|
||||
expect(msvc).toContain('StartsWith($installationPrefix');
|
||||
expect(msvc).toContain('/MT');
|
||||
expect(msvc).toContain('/W4 /WX');
|
||||
expect(msvc).toContain('GSTACK_CSO_CORE_SHA256');
|
||||
expect(msvc).toContain('GSTACK_CSO_GIT_PATH');
|
||||
expect(msvc).toContain('/FI$binding');
|
||||
expect(msvc).toContain('if ($LASTEXITCODE -ne 0)');
|
||||
const processSource=fs.readFileSync(path.join(ROOT,'lib','cso','process.ts'),'utf8');
|
||||
expect(processSource).toContain("includeNullPath=process.platform==='win32'?'/dev/null':nullPath");
|
||||
const launcherSource = fs.readFileSync(path.join(ROOT, 'lib/cso/launcher-windows.c'), 'utf8');
|
||||
expect(launcherSource).toContain('.gstack-cso-generation.lock');
|
||||
expect(launcherSource).toContain('.gstack-cso-generation');
|
||||
expect(launcherSource).toContain('GSTACK_CSO_CORE_SHA256');
|
||||
expect(launcherSource).toContain('BCryptOpenAlgorithmProvider');
|
||||
expect(launcherSource).toContain('MS_PRIMITIVE_PROVIDER');
|
||||
expect(launcherSource).toContain('sha256_handle(pinned_core');
|
||||
expect(launcherSource).toContain('#pragma comment(lib, "bcrypt.lib")');
|
||||
expect(launcherSource).toContain('PROC_THREAD_ATTRIBUTE_HANDLE_LIST');
|
||||
expect(launcherSource).toContain('joined_path');
|
||||
const childWait = launcherSource.indexOf('WaitForSingleObject(child.hProcess, INFINITE)');
|
||||
expect(childWait).toBeGreaterThan(0);
|
||||
expect(launcherSource.lastIndexOf('CloseHandle(pinned_core)')).toBeGreaterThan(childWait);
|
||||
expect(launcherSource.lastIndexOf('CloseHandle(generation_gate)')).toBeGreaterThan(childWait);
|
||||
const publisherSource = fs.readFileSync(path.join(ROOT, 'lib/cso/publish-lock.c'), 'utf8');
|
||||
expect(publisherSource).toContain('joined_path');
|
||||
expect(publisherSource).toContain('GENERIC_READ | GENERIC_WRITE, 0');
|
||||
});
|
||||
|
||||
test('Windows CI runs the actual launcher tests rather than marking the platform supported from a source check', () => {
|
||||
const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github/workflows/free-tests.yml'), 'utf8')) as any;
|
||||
const job = workflow.jobs['cso-windows-launcher'];
|
||||
expect(job['runs-on']).toBe('windows-latest');
|
||||
expect(job.steps.some((s: any) => s.run === 'bun run build:cso' && s.shell === 'bash')).toBe(true);
|
||||
const smoke = job.steps.find((s: any) => s.run === 'bun run test:cso:windows');
|
||||
expect(smoke.env.GSTACK_CSO_WINDOWS_TESTS).toBe('1');
|
||||
expect(smoke['continue-on-error']).not.toBe(true);
|
||||
});
|
||||
|
||||
test.skipIf(!windows)('Windows build refuses output path escapes before invoking MSVC',()=>{
|
||||
const script=path.join(ROOT,'scripts/build-cso-windows.ps1'),outside=fs.mkdtempSync(path.join(os.tmpdir(),'cso-bin-evil-'));
|
||||
const stage=fs.mkdtempSync(path.join(ROOT,'bin','.gstack-cso-stage.path-test.'));
|
||||
const validOutput=path.join(stage,'gstack-cso-launcher.exe'),validLock=path.join(stage,'gstack-cso-publish-lock.exe'),digest='a'.repeat(64);
|
||||
try{
|
||||
for(const [output,lock] of [[path.join(outside,'gstack-cso-launcher.exe'),validLock],[path.join(stage,'..','gstack-cso-launcher.exe'),validLock],[path.join(stage,'wrong.exe'),validLock],[validOutput,path.join(outside,'gstack-cso-publish-lock.exe')],[validOutput,path.join(stage,'wrong-lock.exe')]]){
|
||||
const result=spawnSync('powershell.exe',['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',script,'-RepoRoot',ROOT,'-OutputPath',output,'-LockOutputPath',lock,'-CoreSha256',digest,'-GitExePath',Bun.which('git')!],{encoding:'utf8',timeout:30_000});
|
||||
expect(result.status).not.toBe(0);expect(`${result.stdout}${result.stderr}`).toContain('direct, non-reparse staging directory');
|
||||
}
|
||||
}finally{fs.rmSync(stage,{recursive:true,force:true});fs.rmSync(outside,{recursive:true,force:true});}
|
||||
});
|
||||
});
|
||||
|
||||
(windows ? describe : describe.skip)('CSO native Windows startup', () => {
|
||||
test('BUN_OPTIONS cannot execute a preload before the environment is scrubbed', () => {
|
||||
const result = launch(['--version'], { BUN_OPTIONS: `--preload=${preload}` });
|
||||
expect(result.status).toBe(0);
|
||||
expect(fs.existsSync(marker)).toBe(false);
|
||||
const value = JSON.parse(result.stdout);
|
||||
expect(value.argv).toEqual(['--version']);
|
||||
expect(value.env.BUN_OPTIONS).toBeUndefined();
|
||||
expect(value.env.GSTACK_CSO_SECRET_CANARY).toBeUndefined();
|
||||
expect(value.env.NODE_OPTIONS).toBeUndefined();
|
||||
expect(value.env.GSTACK_HOME).toBe(path.join(temporary, 'state'));
|
||||
expect(value.env.PATH).not.toBe(temporary);
|
||||
expect(value.env.SystemRoot.toLowerCase()).toBe(process.env.SystemRoot!.toLowerCase());
|
||||
expect(pathIdentity(value.cwd)).toEqual(pathIdentity(temporary));
|
||||
expect(pathIdentity(value.cwd)).not.toEqual(pathIdentity(invocationCwd));
|
||||
});
|
||||
|
||||
test('BUN_BE_BUN cannot turn the public command into the Bun runtime', () => {
|
||||
const result = launch(['--version'], { BUN_BE_BUN: '1' });
|
||||
expect(result.status).toBe(0);
|
||||
const value = JSON.parse(result.stdout);
|
||||
expect(value.argv).toEqual(['--version']);
|
||||
expect(value.env.BUN_BE_BUN).toBeUndefined();
|
||||
});
|
||||
|
||||
test('preserves Unicode, empty, quoted, trailing-backslash and shell-looking arguments', () => {
|
||||
const args = ['', '空間', 'with spaces', 'a"b', 'C:\\directory with spaces\\', '\\"', '& whoami', '%PATH%', 'line\nbreak'];
|
||||
const result = launch(args);
|
||||
expect(result.status).toBe(0);
|
||||
expect(JSON.parse(result.stdout).argv).toEqual(args);
|
||||
});
|
||||
|
||||
test('forwards child exit status and fails closed on oversized inherited input', () => {
|
||||
expect(launch(['--exit-23']).status).toBe(23);
|
||||
const result = launch([], { GSTACK_HOME: 'x'.repeat(8193) });
|
||||
expect(result.status).toBe(69);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toContain('environment input is too large');
|
||||
});
|
||||
|
||||
test('resolves launcher installation aliases before locating its sibling core', () => {
|
||||
const alias = path.join(temporary, 'alias');
|
||||
fs.symlinkSync(temporary, alias, 'junction');
|
||||
const result = launch(['--version'], {}, path.join(alias, 'gstack-cso-launcher.exe'));
|
||||
expect(result.status).toBe(0);
|
||||
expect(JSON.parse(result.stdout).argv).toEqual(['--version']);
|
||||
fs.unlinkSync(alias);
|
||||
});
|
||||
|
||||
test('rejects a launcher/manifest generation mismatch before executing the core', () => {
|
||||
const mismatched = path.join(temporary, 'mismatched generation');
|
||||
fs.mkdirSync(mismatched);
|
||||
fs.copyFileSync(launcher, path.join(mismatched, 'gstack-cso-launcher.exe'));
|
||||
fs.copyFileSync(core, path.join(mismatched, 'gstack-cso-core.exe'));
|
||||
fs.writeFileSync(path.join(mismatched, '.gstack-cso-generation.lock'), '');
|
||||
fs.writeFileSync(path.join(mismatched, '.gstack-cso-generation'), `${'0'.repeat(64)}\n`);
|
||||
const result = launch(['--version'], {}, path.join(mismatched, 'gstack-cso-launcher.exe'));
|
||||
expect(result.status).toBe(69);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toContain('generations do not match');
|
||||
});
|
||||
|
||||
test('rejects changed core bytes even when the generation manifest is unchanged', () => {
|
||||
const tampered = path.join(temporary, 'tampered core');
|
||||
fs.mkdirSync(tampered);
|
||||
const tamperedLauncher = path.join(tampered, 'gstack-cso-launcher.exe');
|
||||
const tamperedCore = path.join(tampered, 'gstack-cso-core.exe');
|
||||
fs.copyFileSync(launcher, tamperedLauncher);
|
||||
fs.copyFileSync(core, tamperedCore);
|
||||
fs.appendFileSync(tamperedCore, Buffer.from([0]));
|
||||
fs.writeFileSync(path.join(tampered, '.gstack-cso-generation.lock'), '');
|
||||
fs.copyFileSync(path.join(temporary, '.gstack-cso-generation'), path.join(tampered, '.gstack-cso-generation'));
|
||||
const result = launch(['--version'], {}, tamperedLauncher);
|
||||
expect(result.status).toBe(69);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toContain('digest does not match');
|
||||
});
|
||||
|
||||
test('the inherited generation gate survives launcher termination until the core exits', async () => {
|
||||
const ready = path.join(temporary, 'held-core.ready'), release = path.join(temporary, 'held-core.release'), exited = path.join(temporary, 'held-core.exited');
|
||||
const running = spawn(launcher, ['--hold-until', ready, release, exited], { cwd: invocationCwd, env: launchEnvironment(), stdio: 'ignore' });
|
||||
try {
|
||||
await waitForFile(ready);
|
||||
const launcherExited = new Promise<void>(resolveExit => running.once('exit', () => resolveExit()));
|
||||
running.kill('SIGKILL');
|
||||
await launcherExited;
|
||||
expect(publishProbe().status).toBe(73);
|
||||
fs.writeFileSync(release, 'release');
|
||||
const deadline = Date.now() + 10_000; let result = publishProbe();
|
||||
while (result.status === 73 && Date.now() < deadline) { await Bun.sleep(20); result = publishProbe(); }
|
||||
expect(result.status).toBe(0);
|
||||
} finally {
|
||||
fs.writeFileSync(release, 'release');
|
||||
if (!running.killed) running.kill('SIGKILL');
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (!fs.existsSync(exited) && Date.now() < deadline) await Bun.sleep(10);
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test('a missing core never falls back to a PATH executable or Bun', () => {
|
||||
const missing = path.join(temporary, 'missing core');
|
||||
fs.mkdirSync(missing);
|
||||
const copy = path.join(missing, 'gstack-cso-launcher.exe');
|
||||
fs.copyFileSync(launcher, copy);
|
||||
fs.writeFileSync(path.join(missing, '.gstack-cso-generation.lock'), '');
|
||||
fs.copyFileSync(path.join(temporary, '.gstack-cso-generation'), path.join(missing, '.gstack-cso-generation'));
|
||||
const result = launch(['--version'], {}, copy);
|
||||
expect(result.status).toBe(69);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toContain('trusted compiled helper is missing');
|
||||
});
|
||||
|
||||
test('the actual helper finds trusted Git and stores state under USERPROFILE without HOME', () => {
|
||||
const repository=path.join(temporary,'actual repository'),profile=path.join(temporary,'profile');
|
||||
fs.mkdirSync(repository);fs.mkdirSync(profile);
|
||||
const git='C:\\Program Files\\Git\\cmd\\git.exe',gitEnv={...process.env,HOME:profile};
|
||||
for(const args of [['init','-q'],['config','user.email','fixture@example.test'],['config','user.name','Fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repository,encoding:'utf8',env:gitEnv,timeout:10_000});expect(result.status).toBe(0);}
|
||||
fs.writeFileSync(path.join(repository,'app.js'),'console.log("safe")\n');
|
||||
for(const args of [['add','app.js'],['commit','-qm','fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repository,encoding:'utf8',env:gitEnv,timeout:10_000});expect(result.status).toBe(0);}
|
||||
const actual=path.join(ROOT,'bin','gstack-cso-launcher.exe'),env={...process.env,HOME:'',GSTACK_HOME:'',CLAUDE_PLUGIN_ROOT:'',CLAUDE_PLUGIN_DATA:'',USERPROFILE:profile,PATH:temporary,NODE_OPTIONS:'--require=hostile'};
|
||||
const doctor=spawnSync(actual,['doctor','--repo',repository],{cwd:repository,encoding:'utf8',env,timeout:30_000});expect(doctor.status).toBe(0);expect(JSON.parse(doctor.stdout).downloads).toBe(false);
|
||||
const started=spawnSync(actual,['start','--repo',repository,'--offline'],{cwd:repository,encoding:'utf8',env,timeout:30_000});expectSuccessfulProcess(started,'gstack-cso start');expect(JSON.parse(started.stdout).schemaVersion).toBe(3);expect(fs.existsSync(path.join(profile,'.gstack','security','cso'))).toBe(true);
|
||||
});
|
||||
|
||||
test('the actual helper rejects source mutation during snapshot capture without certifying a report', async () => {
|
||||
const repository=path.join(temporary,'racing repository'),profile=path.join(temporary,'race profile'),padding=path.join(repository,'padding'),target=path.join(repository,'zzzz-race-target.js');
|
||||
fs.mkdirSync(repository);fs.mkdirSync(profile);fs.mkdirSync(padding);
|
||||
const git='C:\\Program Files\\Git\\cmd\\git.exe',gitEnv={...process.env,HOME:profile};
|
||||
for(const args of [['init','-q'],['config','user.email','fixture@example.test'],['config','user.name','Fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repository,encoding:'utf8',env:gitEnv,timeout:10_000});expect(result.status).toBe(0);}
|
||||
const sourceBytes=128*1024,stable=Buffer.alloc(sourceBytes,0x61),changed=Buffer.alloc(sourceBytes,0x62);
|
||||
fs.writeFileSync(target,stable);
|
||||
for(let index=0;index<192;index++)fs.writeFileSync(path.join(padding,`${String(index).padStart(4,'0')}.js`),stable);
|
||||
for(const args of [['add',path.basename(target)],['commit','-qm','fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repository,encoding:'utf8',env:gitEnv,timeout:30_000});expect(result.status).toBe(0);}
|
||||
|
||||
const actual=path.join(ROOT,'bin','gstack-cso-launcher.exe'),env={...process.env,HOME:'',GSTACK_HOME:'',CLAUDE_PLUGIN_ROOT:'',CLAUDE_PLUGIN_DATA:'',USERPROFILE:profile,PATH:temporary},state=path.join(profile,'.gstack','security','cso');
|
||||
const child=spawn(actual,['start','--repo',repository,'--offline'],{cwd:repository,env,stdio:['ignore','pipe','pipe']});
|
||||
let stdout='',stderr='',closed=false;
|
||||
child.stdout.on('data',chunk=>{stdout=(stdout+String(chunk)).slice(-8192);});
|
||||
child.stderr.on('data',chunk=>{stderr=(stderr+String(chunk)).slice(-8192);});
|
||||
const terminal=new Promise<{code:number|null;error?:string}>(resolve=>{
|
||||
child.once('error',error=>{closed=true;resolve({code:null,error:error.message});});
|
||||
child.once('close',code=>{closed=true;resolve({code});});
|
||||
});
|
||||
const markerDeadline=Date.now()+30_000;let mutations=0,outcome:{code:number|null;error?:string}|undefined;
|
||||
try{
|
||||
while(!closed&&!filesNamed(state,'history-status.json').length&&Date.now()<markerDeadline)await Bun.sleep(2);
|
||||
if(!filesNamed(state,'history-status.json').length)throw new Error('gstack-cso exited or timed out before reaching the bounded snapshot mutation point');
|
||||
while(!closed&&Date.now()<markerDeadline+15_000){
|
||||
try{fs.writeFileSync(target,changed);mutations++;}catch(error:any){if(!['EBUSY','EACCES','EPERM'].includes(error?.code))throw error;}
|
||||
await Bun.sleep(1);
|
||||
}
|
||||
if(!closed)throw new Error('gstack-cso did not finish after the injected snapshot mutation');
|
||||
outcome=await terminal;
|
||||
}finally{
|
||||
if(!closed){spawnSync('taskkill',['/PID',String(child.pid),'/T','/F'],{encoding:'utf8',timeout:10_000});await Promise.race([terminal,Bun.sleep(10_000)]);}
|
||||
}
|
||||
expect(mutations).toBeGreaterThan(0);
|
||||
expect(outcome?.error).toBeUndefined();
|
||||
expect(outcome?.code).not.toBe(0);
|
||||
expect(stderr).toContain('SNAPSHOT_RACE');
|
||||
expect(stdout).toBe('');
|
||||
expect(filesNamed(state,'snapshot.json')).toEqual([]);
|
||||
expect(filesNamed(state,'report.json')).toEqual([]);
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { AssertionWitnessBinding, CsoError, VerificationObservation, canonical, sha256 } from '../lib/cso/contracts';
|
||||
import { canonicalStartPlan, canonicalTestPlan, patchHash, treeHash, validateRepairBundle, verifyRepair } from '../lib/cso/verification';
|
||||
import { AssertionWitnessSession, assertionWitnessReplayHash, testExecutionPassed, validateStoredAssertionWitnessReceipt } from '../lib/cso/witness';
|
||||
|
||||
const roots:string[]=[];
|
||||
const temporary=()=>{const root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-witness-'));roots.push(root);return root;};
|
||||
const H=(value:string)=>sha256(value);
|
||||
const tap=`TAP version 13
|
||||
# Subtest: legitimate control remains available
|
||||
ok 1 - legitimate control remains available
|
||||
---
|
||||
duration_ms: 1
|
||||
...
|
||||
1..1
|
||||
# tests 1
|
||||
# suites 0
|
||||
# pass 1
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
`;
|
||||
afterEach(()=>{for(const root of roots.splice(0))fs.rmSync(root,{recursive:true,force:true});});
|
||||
|
||||
function stable(phase:'before'|'after'):Omit<AssertionWitnessBinding,'schemaVersion'|'protocol'|'nonce'|'issuedAt'|'expiresAt'>{
|
||||
return{phase,runId:'witness-run',findingId:'a'.repeat(32),policyHash:H('policy'),auditPolicyHash:H('audit-policy'),runtime:{image:`runtime@sha256:${'b'.repeat(64)}`,verifierImage:`runtime@sha256:${'b'.repeat(64)}`,platform:'linux/amd64',profile:'node'},runner:{testToolchain:'runtime',startPlanHash:H('start'),testPlanHash:H('tests'),commandsHash:H(canonical([{executable:'/usr/local/bin/node',args:['--test','--test-reporter=tap','./app.test.js']}])),minimumPassingTestsHash:H(canonical([1]))},sourceHash:H(`source-${phase}`),dependencyHash:H(`dependency-${phase}`),configurationHash:H(`configuration-${phase}`),requestHash:H('request'),patchHash:H('patch'),harnessHash:H('harness'),assertionHash:H('assertions'),fixturesHash:H('fixtures')};
|
||||
}
|
||||
|
||||
describe('CSO authenticated external assertion witness',()=>{
|
||||
test('rejects forged, stale, and mismatched receipts after a valid out-of-process attestation',async()=>{
|
||||
const work=temporary(),session=new AssertionWitnessSession(work,Date.now()+60_000),handle=session.handle(stable('before')),observation:VerificationObservation={booted:true,legitimate:true,security:'intended_failure',existingTests:false,output:'external verifier passed',inputHash:''},command={executable:'/usr/local/bin/node',args:['--test','--test-reporter=tap','./app.test.js']};
|
||||
const receipt=await handle.attest(observation,[{command,code:0,output:tap,minimumPassingTests:1}]);
|
||||
expect(receipt).toMatchObject({externalAssertionsPassed:true,diagnosticTestsPassed:true,binding:{phase:'before',policyHash:H('policy'),runner:{testToolchain:'runtime'}}});expect(validateStoredAssertionWitnessReceipt(receipt).keyId).toBe(session.keyId);
|
||||
expect(()=>handle.validate({...receipt,signature:`${receipt.signature.slice(0,-1)}${receipt.signature.endsWith('0')?'1':'0'}`},observation)).toThrow('signature');
|
||||
const other=session.handle(stable('after'));expect(()=>other.validate(receipt,observation)).toThrow('does not bind');
|
||||
expect(()=>handle.validate(receipt,observation,Date.parse(receipt.binding.expiresAt)+1)).toThrow('stale');
|
||||
expect(()=>handle.validate(receipt,{...observation,legitimate:false})).toThrow('observation');
|
||||
});
|
||||
|
||||
test('the native launcher routes the private compiled witness child protocol',()=>{
|
||||
const root=temporary(),launcher=path.resolve(import.meta.dir,'../bin',process.platform==='win32'?'gstack-cso-launcher.exe':'gstack-cso-launcher'),core=path.resolve(import.meta.dir,'../bin',process.platform==='win32'?'gstack-cso-core.exe':'gstack-cso-core');
|
||||
const direct=spawnSync(core,['--version'],{cwd:root,encoding:'utf8',env:{...process.env,GSTACK_CSO_GENERATION_LOCK_FD:''},timeout:30_000});expect(direct.status).not.toBe(0);expect(direct.stderr).toContain('Direct use of the internal CSO payload is unsupported');
|
||||
const pair=generateKeyPairSync('ed25519'),privateKey=pair.privateKey.export({format:'pem',type:'pkcs8'}).toString(),publicKey=pair.publicKey.export({format:'der',type:'spki'}).toString('hex'),now=Date.now(),command={executable:'/usr/local/bin/node',args:['--test','--test-reporter=tap','./app.test.js']},binding={schemaVersion:1,protocol:'gstack-cso-assertion-witness-v1',nonce:H('compiled nonce'),issuedAt:new Date(now).toISOString(),expiresAt:new Date(now+60_000).toISOString(),...stable('before')},observation:VerificationObservation={booted:true,legitimate:true,security:'intended_failure',existingTests:false,output:'compiled external verifier passed',inputHash:''},child=spawnSync(launcher,['__cso-assertion-witness'],{cwd:root,encoding:'utf8',env:{HOME:root,GSTACK_HOME:path.join(root,'state'),PATH:'/usr/bin:/bin'},input:JSON.stringify({privateKey,publicKey,binding,observation,executions:[{command,code:0,output:tap,minimumPassingTests:1}]}),timeout:30_000});expect(child.status).toBe(0);const receipt=validateStoredAssertionWitnessReceipt(JSON.parse(child.stdout));expect(receipt).toMatchObject({publicKey,externalAssertionsPassed:true,diagnosticTestsPassed:true,binding:{nonce:H('compiled nonce'),phase:'before'}});
|
||||
});
|
||||
|
||||
test('a test file that prints a forged TAP summary is rejected as a path wrapper',()=>{
|
||||
const root=temporary(),file=path.join(root,'forged.test.js'),node=Bun.which('node');if(!node)throw new Error('Node is required for the CSO Node witness fixture');
|
||||
fs.writeFileSync(file,"console.log('# Subtest: forged pass')\nconsole.log('ok 1 - forged pass')\nconsole.log('1..1')\nconsole.log('# tests 1')\nconsole.log('# pass 1')\nconsole.log('# fail 0')\nconsole.log('# cancelled 0')\n");
|
||||
const command={executable:node,args:['--test','--test-reporter=tap','./forged.test.js']},result=spawnSync(node,command.args,{cwd:root,encoding:'utf8',timeout:30_000});expect(result.status).toBe(0);expect(result.stdout).toContain('# \\# Subtest: forged pass');expect(testExecutionPassed(command,result.status??-1,result.stdout+result.stderr,1)).toBe(false);
|
||||
});
|
||||
|
||||
test('valid external assertions issue a runtime-tested bundle with replay-stable witness evidence',async()=>{
|
||||
const runDir=temporary(),snapshot=path.join(runDir,'snapshot');fs.mkdirSync(snapshot);const files:Record<string,string>={'package.json':JSON.stringify({private:true,scripts:{start:'node app.js',test:'node --test'}})+'\n','app.js':'module.exports = "vulnerable"\n','app.test.js':"const test=require('node:test');test('legitimate control remains available',()=>{});\n"};for(const [name,body] of Object.entries(files))fs.writeFileSync(path.join(snapshot,name),body);
|
||||
const start=canonicalStartPlan(snapshot,'node',3456),tests=canonicalTestPlan(snapshot,'node'),request:any={findingId:'a'.repeat(32),runtimeProfile:'node',port:3456,start:start.command,legitimate:[{name:'legitimate control',path:'/control',method:'GET',expected:{status:200,includes:'CONTROL_OK'}}],security:{name:'unauthorized secret is denied',path:'/security',method:'GET',expected:{status:403,includes:'DENIED'},vulnerable:{status:200,includes:'SECRET'}},existingTests:tests.commands,fixtures:{},boundaryFiles:['app.js'],testFiles:tests.files,changes:[{path:'app.js',beforeSha256:sha256(files['app.js']),after:'module.exports = "fixed"\n',effect:'source'}],review:{reviewer:'independent-reviewer',independent:true,rootCauseRepaired:true,featurePreserved:true,boundaryMocks:false,rationale:'The policy is repaired while the external control remains available.',reviewedPatchHash:''}};request.review.reviewedPatchHash=patchHash(request);
|
||||
const entries=Object.entries(files).map(([name,body])=>({path:name,originalHash:sha256(body),executionHash:sha256(body),bytes:Buffer.byteLength(body),mode:0o600})),manifest:any={version:3,root:snapshot,createdAt:new Date().toISOString(),expiresAt:new Date(Date.now()+86_400_000).toISOString(),headCommit:'c'.repeat(40),originalHash:H('original'),executionHash:treeHash(snapshot),entries},runtime:any={id:'node',stack:'node',image:`runtime@sha256:${'b'.repeat(64)}`,platform:'linux/amd64'};
|
||||
let executionCount=0;const executor={observe:async(_source:string,phase:'before'|'after',received:any,_runtime:any,_verifier:any,_work:string,_control:string,_execution:any,evidence:any,witness:any)=>{const observation:VerificationObservation={booted:true,legitimate:true,security:phase==='before'?'intended_failure':'pass',existingTests:false,output:`external ${phase} assertions passed`,inputHash:''},output=tap.replace('duration_ms: 1',`duration_ms: ${++executionCount}`),receipt=await witness.attest(observation,[{command:received.existingTests[0],code:0,output,minimumPassingTests:evidence.minimumPassingTests[0]}]);return{observation:{...observation,existingTests:receipt.diagnosticTestsPassed,inputHash:witness.binding.harnessHash},witness:receipt};}};
|
||||
const first=await verifyRepair({runId:'witness-run',runDir,manifest,rawRequest:request,runtime,verifier:runtime,policyHash:H('policy'),auditPolicyHash:H('audit-policy'),archives:[],executor});expect(first.manifest).toMatchObject({result:'runtime_tested',assertionAssurance:'authenticated_out_of_process',testCompletionAssurance:'self_reported',reviewAssurance:'self_attested'});expect(first.bundle.witness?.before.publicKey).toBe(first.bundle.witness?.after.publicKey);expect(validateRepairBundle(first.bundle,first.bundle.id,snapshot,manifest).id).toBe(first.bundle.id);expect(fs.existsSync(path.join(runDir,'bundles',`${first.bundle.id}.json`))).toBe(true);const forged=structuredClone(first.bundle),signature=forged.witness!.before.signature;forged.witness!.before.signature=`${signature.slice(0,-1)}${signature.endsWith('0')?'1':'0'}`;expect(()=>validateRepairBundle(forged,forged.id,snapshot,manifest)).toThrow('signature');const reused=structuredClone(first.bundle);reused.witness!.after=reused.witness!.before;expect(()=>validateRepairBundle(reused,reused.id,snapshot,manifest)).toThrow('identity');
|
||||
const replay=await verifyRepair({runId:'witness-run',runDir,manifest,rawRequest:request,runtime,verifier:runtime,policyHash:H('policy'),auditPolicyHash:H('audit-policy'),archives:[],executor,persist:false});expect(replay.manifest.witnessHash).not.toBe(first.manifest.witnessHash);expect(assertionWitnessReplayHash(replay.bundle.witness!)).toBe(assertionWitnessReplayHash(first.bundle.witness!));expect(replay.bundle.witness?.before.binding.nonce).not.toBe(first.bundle.witness?.before.binding.nonce);const immutable=(value:any)=>{const {id:_,createdAt:__,before:___,after:____,witnessHash:______,...rest}=value;return rest;};expect(canonical(immutable(replay.manifest))).toBe(canonical(immutable(first.manifest)));
|
||||
});
|
||||
|
||||
test('a signed forged reporter diagnostic cannot mint a runtime-tested bundle',async()=>{
|
||||
const root=temporary(),session=new AssertionWitnessSession(root,Date.now()+60_000),handle=session.handle(stable('before')),observation:VerificationObservation={booted:true,legitimate:true,security:'intended_failure',existingTests:false,output:'external verifier passed',inputHash:''},command={executable:'/usr/local/bin/node',args:['--test','--test-reporter=tap','./app.test.js']},forged="# Subtest: app.test.js\nok 1 - app.test.js\n1..1\n# tests 1\n# pass 1\n# fail 0\n# cancelled 0\n";
|
||||
const receipt=await handle.attest(observation,[{command,code:0,output:forged,minimumPassingTests:1}]);expect(receipt.externalAssertionsPassed).toBe(true);expect(receipt.diagnosticTestsPassed).toBe(false);expect(receipt.executions[0].reportedPassed).toBe(false);
|
||||
});
|
||||
});
|
||||
Vendored
+43
-43
@@ -1,65 +1,65 @@
|
||||
{
|
||||
"_comment": "Context-budget ratchet ceilings (~tokens). Regenerate: bun test/helpers/capture-context-budget.ts. Headroom: alwaysOnTotal x1.05, eagerPerInvocation x1.1. Graded by test/context-budget-ratchet.test.ts via lib/context-bill.ts checkBudget.",
|
||||
"alwaysOnTotal": 6372,
|
||||
"alwaysOnTotal": 6397,
|
||||
"eagerPerInvocation": {
|
||||
"autoplan": 17697,
|
||||
"benchmark": 7403,
|
||||
"autoplan": 18022,
|
||||
"benchmark": 7657,
|
||||
"benchmark-models": 3829,
|
||||
"browse": 8003,
|
||||
"browser-skills/hackernews-frontpage": 371,
|
||||
"canary": 13522,
|
||||
"canary": 13914,
|
||||
"careful": 919,
|
||||
"codex": 15490,
|
||||
"context-restore": 9618,
|
||||
"context-save": 10234,
|
||||
"cso": 16110,
|
||||
"design-consultation": 18392,
|
||||
"design-html": 13767,
|
||||
"design-review": 34483,
|
||||
"design-shotgun": 13828,
|
||||
"devex-review": 20302,
|
||||
"codex": 15479,
|
||||
"context-restore": 9607,
|
||||
"context-save": 10224,
|
||||
"cso": 4700,
|
||||
"design-consultation": 18595,
|
||||
"design-html": 14429,
|
||||
"design-review": 34471,
|
||||
"design-shotgun": 13856,
|
||||
"devex-review": 20292,
|
||||
"diagram": 4279,
|
||||
"document-generate": 12362,
|
||||
"document-release": 10602,
|
||||
"document-generate": 12352,
|
||||
"document-release": 10683,
|
||||
"freeze": 990,
|
||||
"gstack": 3976,
|
||||
"gstack-upgrade": 4201,
|
||||
"gstack-upgrade": 4586,
|
||||
"guard": 889,
|
||||
"health": 10816,
|
||||
"investigate": 12286,
|
||||
"ios-clean": 8721,
|
||||
"ios-design-review": 8902,
|
||||
"ios-fix": 8674,
|
||||
"ios-qa": 11414,
|
||||
"ios-sync": 8845,
|
||||
"land-and-deploy": 18971,
|
||||
"landing-report": 9527,
|
||||
"learn": 9197,
|
||||
"health": 10805,
|
||||
"investigate": 12276,
|
||||
"ios-clean": 8710,
|
||||
"ios-design-review": 8891,
|
||||
"ios-fix": 8664,
|
||||
"ios-qa": 11403,
|
||||
"ios-sync": 8834,
|
||||
"land-and-deploy": 19043,
|
||||
"landing-report": 9517,
|
||||
"learn": 9187,
|
||||
"make-pdf": 5314,
|
||||
"office-hours": 22420,
|
||||
"office-hours": 22374,
|
||||
"open-gstack-browser": 4510,
|
||||
"openclaw/skills/gstack-openclaw-ceo-review": 2764,
|
||||
"openclaw/skills/gstack-openclaw-investigate": 1429,
|
||||
"openclaw/skills/gstack-openclaw-office-hours": 4433,
|
||||
"openclaw/skills/gstack-openclaw-retro": 2542,
|
||||
"pair-agent": 11622,
|
||||
"plan-ceo-review": 20550,
|
||||
"plan-design-review": 20315,
|
||||
"plan-devex-review": 17799,
|
||||
"plan-eng-review": 14750,
|
||||
"plan-tune": 14771,
|
||||
"qa": 15857,
|
||||
"qa-only": 16910,
|
||||
"retro": 18974,
|
||||
"review": 16021,
|
||||
"pair-agent": 11612,
|
||||
"plan-ceo-review": 20824,
|
||||
"plan-design-review": 20648,
|
||||
"plan-devex-review": 17884,
|
||||
"plan-eng-review": 14930,
|
||||
"plan-tune": 14761,
|
||||
"qa": 15847,
|
||||
"qa-only": 17218,
|
||||
"retro": 19122,
|
||||
"review": 16010,
|
||||
"scrape": 6904,
|
||||
"setup-browser-cookies": 3194,
|
||||
"setup-deploy": 11272,
|
||||
"setup-gbrain": 15565,
|
||||
"ship": 20518,
|
||||
"skillify": 12206,
|
||||
"spec": 15011,
|
||||
"sync-gbrain": 13985,
|
||||
"setup-deploy": 11557,
|
||||
"setup-gbrain": 15554,
|
||||
"ship": 20347,
|
||||
"skillify": 12196,
|
||||
"spec": 14993,
|
||||
"sync-gbrain": 13975,
|
||||
"unfreeze": 393
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+254
@@ -0,0 +1,254 @@
|
||||
# CSO vulnerable/fixed evaluation corpus
|
||||
|
||||
This corpus contains **40 immutable source pairs**: ten each for Node, Bun,
|
||||
Python, and Rails. `manifest.json` pins both source hashes, expected root cause,
|
||||
location, severity, and evaluation eligibility. `materialize.ts` deterministically
|
||||
creates either member of each pair. Changing source requires an explicit corpus
|
||||
manifest update; the loader rejects silent drift.
|
||||
|
||||
The ten families are SQL injection, command injection, path traversal, SSRF,
|
||||
object authorization, tenant isolation, HTML injection, open redirects, mass
|
||||
assignment, and resource exhaustion. Every app exposes `/action` and `/health` on
|
||||
loopback port 8000. The SSRF fixtures additionally create a disposable loopback
|
||||
status service on port 8001. Exhaustion assertions request 250 small records to
|
||||
prove missing admission; they do not attempt to exhaust the evaluator.
|
||||
|
||||
Node uses standard modules, including `node:sqlite`; Bun uses `bun:sqlite`; Python
|
||||
uses its standard library. Rails fixtures contain an actual Rails application
|
||||
and controller, with native SQLite and Puma dependencies. Their common lock was
|
||||
generated by Ruby 3.2.8 / Bundler 2.6.7 from public registry metadata only. The
|
||||
exact command, lock hash, and 68 public archive hashes are recorded in
|
||||
`rails-lock-provenance.json`. No fixture application was run to generate this
|
||||
lock. The recorded archive hashes must be checked by runtime qualification; a
|
||||
successful resolver run does not establish cold-start support.
|
||||
|
||||
Authentication is an explicit fixture precondition: the test adapter establishes
|
||||
member-1 in tenant-a. The assessment scope is the selected endpoint's behavior.
|
||||
This prevents an intentionally constant test identity from being mistaken for a
|
||||
production authentication design. Application README files state legitimate
|
||||
behavior; they never include attack payloads or expected finding labels.
|
||||
|
||||
## Preparing matched evaluations
|
||||
|
||||
From the repository root, export the v2 generated skill tree and build a
|
||||
canonical portable payload for each version before pinning an exact model ID:
|
||||
|
||||
```sh
|
||||
mkdir -p .context/cso-v2-source
|
||||
git archive origin/main cso/SKILL.md cso/sections | \
|
||||
tar -x -C .context/cso-v2-source --strip-components=1
|
||||
bun scripts/cso-eval.ts payload --version v2 \
|
||||
--skill-dir .context/cso-v2-source --output .context/cso-v2.payload.md
|
||||
bun scripts/cso-eval.ts payload --version v3 \
|
||||
--skill-dir cso --output .context/cso-v3.payload.md
|
||||
bun scripts/cso-eval.ts matrix \
|
||||
--model EXACT_MODEL_ID --host codex \
|
||||
--v2-payload .context/cso-v2.payload.md \
|
||||
--v3-payload .context/cso-v3.payload.md \
|
||||
--output .context/cso-eval-matrix.json
|
||||
bun scripts/cso-eval.ts materialize node-sql-injection vulnerable .context/cso-eval-app
|
||||
```
|
||||
|
||||
The full matrix contains 960 cells: 40 pairs × two source variants × two modes ×
|
||||
two skill versions × three repetitions. Each matched pair uses identical source,
|
||||
model, host, and per-mode wall-clock budget. Daily runs have 600 seconds;
|
||||
comprehensive runs have 1800 seconds. Each portable payload embeds the exact
|
||||
generated `SKILL.md`, `sections/manifest.json`, and every generated section
|
||||
listed by that manifest. The payload builder rejects missing or unlisted
|
||||
generated sections and a mismatched major version. The matrix hashes the whole
|
||||
payload, so a change to any section byte changes the pinned skill identity. The
|
||||
commands above make no model calls and execute no fixture application.
|
||||
|
||||
Prepare consumable one-cell jobs and compile the generic producer runner:
|
||||
|
||||
```sh
|
||||
bun build --compile \
|
||||
--no-compile-autoload-dotenv \
|
||||
--no-compile-autoload-bunfig \
|
||||
--no-compile-autoload-tsconfig \
|
||||
--no-compile-autoload-package-json \
|
||||
scripts/cso-eval-producer.ts --outfile .context/cso-eval-producer
|
||||
bun scripts/cso-eval.ts prepare .context/cso-eval-matrix.json \
|
||||
--v2-payload .context/cso-v2.payload.md \
|
||||
--v3-payload .context/cso-v3.payload.md \
|
||||
--output .context/cso-eval-jobs
|
||||
```
|
||||
|
||||
### Trusted five-artifact producer unit
|
||||
|
||||
The `.context/cso-eval-producer` command above creates a preparation artifact;
|
||||
ordinary `bun run build` does not distribute the private producer. For a paid
|
||||
run, start from one clean checkout and build a five-artifact staging unit in one
|
||||
session:
|
||||
|
||||
```sh
|
||||
stage="$(mktemp -d)"
|
||||
bun run build:cso
|
||||
bun build --compile \
|
||||
--no-compile-autoload-dotenv \
|
||||
--no-compile-autoload-bunfig \
|
||||
--no-compile-autoload-tsconfig \
|
||||
--no-compile-autoload-package-json \
|
||||
scripts/cso-eval-producer.ts --outfile "$stage/cso-eval-producer"
|
||||
install -m 0555 bin/gstack-cso-launcher bin/gstack-cso-core \
|
||||
bin/gstack-cso-watchdog "$stage/"
|
||||
install -m 0444 bin/.gstack-cso-generation "$stage/.gstack-cso-generation"
|
||||
(cd "$stage" && (sha256sum cso-eval-producer gstack-cso-launcher \
|
||||
gstack-cso-core gstack-cso-watchdog .gstack-cso-generation 2>/dev/null || \
|
||||
shasum -a 256 cso-eval-producer gstack-cso-launcher gstack-cso-core \
|
||||
gstack-cso-watchdog .gstack-cso-generation))
|
||||
sudo install -d -o root -g root -m 0755 /opt/gstack-cso-producer
|
||||
sudo install -o root -g root -m 0555 "$stage/cso-eval-producer" \
|
||||
"$stage/gstack-cso-launcher" "$stage/gstack-cso-core" \
|
||||
"$stage/gstack-cso-watchdog" /opt/gstack-cso-producer/
|
||||
sudo install -o root -g root -m 0444 "$stage/.gstack-cso-generation" \
|
||||
/opt/gstack-cso-producer/.gstack-cso-generation
|
||||
```
|
||||
|
||||
Run `/opt/gstack-cso-producer/cso-eval-producer` as an unprivileged account.
|
||||
Its startup check rejects missing, linked, writable, or non-root-owned members
|
||||
and a writable or linked ancestor directory. Every receipt records the SHA-256
|
||||
and byte length of all five artifacts in one installation identity; collection
|
||||
rejects a batch if those identities differ. Preserve the printed hashes with
|
||||
the release evidence and move or replace the complete unit together.
|
||||
|
||||
`prepare` creates a trusted `schedule.json` and 960 independent directories.
|
||||
It initializes each generated application as a one-commit Git repository. Each
|
||||
job has exactly one source variant and one versioned, complete instruction
|
||||
payload. Root-skill pointers resolve only to sections embedded in that payload;
|
||||
the producer must never load CSO instructions from `~/.claude`, another host
|
||||
install, this source checkout, or the other version's payload. The generic runner
|
||||
is compiled separately and contains no corpus generator, fixed alternative,
|
||||
case oracle, or schedule. These preparation commands still make zero model calls.
|
||||
|
||||
Run each job on a fresh producer filesystem that contains only that job, the
|
||||
compiled runner, the authenticated provider CLI, and the reviewed executable
|
||||
gstack helper/runtime files. Do not install or copy any CSO `SKILL.md`, carved
|
||||
section, generated skill tree, schedule, this repository checkout,
|
||||
sibling jobs, fixed alternatives, or evaluator code to that filesystem. The
|
||||
producer runner reads its opaque control file into memory and deletes it before
|
||||
the agent process starts, so the case identifier and vulnerable/fixed label are
|
||||
not agent inputs. Copy the selected hash-named directory as the literal path
|
||||
`/producer/job`; `/producer` must contain only `job`. Put the runner in a system
|
||||
tool directory and use a separate receipt mount. The runner rejects the full
|
||||
prepared batch layout, extra files beside `job`, a reused job with prior state,
|
||||
and receipt paths under `/producer`. The dedicated VM/container must ensure
|
||||
other host paths do not contain evaluator inputs; a local directory layout does
|
||||
not constrain a tool-using agent's absolute filesystem access.
|
||||
|
||||
The receipt must be written outside the isolated producer root:
|
||||
|
||||
```sh
|
||||
mkdir -m 700 /receipts
|
||||
CSO_EVAL_PAID=1 /opt/gstack-cso-producer/cso-eval-producer run \
|
||||
/producer/job/producer-input.json /receipts/CELL_ID.json --execute-paid
|
||||
```
|
||||
|
||||
Both the environment variable and flag are required because this is the only
|
||||
command in the workflow that makes a paid model call. It reuses the repository's
|
||||
Claude, Codex, and Gemini provider adapters and starts a fresh CLI process for
|
||||
each cell. The requested model, normalized effective model, identity source,
|
||||
timeout, output, token counts, pricing-table cost estimate, tool-call count, and
|
||||
latency are bound into a hashed receipt. `provider_reported` means the CLI
|
||||
resolved a different concrete ID; `requested_pin` means the adapter returned
|
||||
the exact requested ID or had to fall back to that exact CLI pin. Use a concrete
|
||||
model ID rather than an alias. The current adapters return only completed runs,
|
||||
so first-useful-result latency is explicitly unmeasured rather than copied from
|
||||
total latency. If a job is interrupted after its input is consumed, restore it
|
||||
from the trusted prepared copy; never synthesize a receipt.
|
||||
|
||||
After returning receipts to the trusted evaluator, collect them against the
|
||||
matrix and schedule:
|
||||
|
||||
```sh
|
||||
bun scripts/cso-eval.ts collect .context/cso-eval-matrix.json \
|
||||
.context/cso-eval-jobs/schedule.json .context/cso-eval-receipts \
|
||||
.context/cso-eval-producer-batch.json
|
||||
```
|
||||
|
||||
Collection makes no model calls. It rejects unknown, duplicate, changed, or
|
||||
wrong-input receipts and reports scheduled/submitted/missing denominators for
|
||||
every v2/v3 and daily/comprehensive group. It rejects an effective-model mismatch
|
||||
between matched v2/v3 cells, leaving the source receipts available for diagnosis
|
||||
and a clean rerun. The collected batch stores a compact receipt index and hashes;
|
||||
raw transcripts remain in the private receipt directory for adjudication. Cost
|
||||
in this batch is clearly labeled a
|
||||
pricing-table estimate; only a host-billed amount may populate `EvalResult`'s
|
||||
host-cost field.
|
||||
|
||||
The producer host needs Git, the chosen authenticated CLI, and reviewed gstack
|
||||
executables and catalogs without their skill source tree. Its CSO instruction
|
||||
bytes come only from the cell payload, never the installed skill or installed
|
||||
carved sections. Comprehensive cells additionally need a local Docker daemon,
|
||||
the exact qualified helper/runtime catalog pair, public dependency inputs or
|
||||
verified cache hits, and the authenticated out-of-process assertion witness. The runtime
|
||||
image workflow currently emits native staging evidence with private accuracy,
|
||||
held-out repair, canary, and watchdog gates still pending. Such evidence can
|
||||
measure setup failures, but it cannot support a release-qualifying tested repair
|
||||
until those gates and catalog promotion are complete.
|
||||
|
||||
## Private oracle boundary
|
||||
|
||||
`test/helpers/cso-eval-oracles.ts` belongs to the **trusted evaluator**. It contains
|
||||
the legitimate controls, initial security assertions, alternate held-out
|
||||
assertions, and non-vacuous repair judge. It is never copied into materialized
|
||||
source. The fixed alternative, expected manifest, and this evaluator module must
|
||||
remain unavailable to producing agents throughout their sessions.
|
||||
|
||||
A separate directory alone does not restrict a host agent's file access. The
|
||||
evaluation host must enforce this boundary with scoped tools or an isolated
|
||||
producer filesystem and test it with the `held-out-oracle-visibility` gate. If it
|
||||
cannot do that, report the gate as `not_run` and do not claim held-out
|
||||
qualification. Product execution containment does not automatically isolate the
|
||||
hosted producing agent.
|
||||
|
||||
Only trusted runtime observations enter `judgeRepair`. Boot and legitimate
|
||||
controls must pass on the original; the exact security assertion must fail for
|
||||
the intended reason. The patched app must pass that assertion, all held-out
|
||||
assertions, controls, and existing tests with unchanged verification inputs.
|
||||
Independent root-cause/feature review is mandatory; boundary-replacing mocks or
|
||||
disabled functionality fail certification. A producer's `tested` or
|
||||
`runtime_tested` label is never an oracle result.
|
||||
|
||||
`collect` preserves producer claims and performance receipts; it does not turn
|
||||
them into `EvalResult`. The independent evaluator must inspect the retained CSO
|
||||
report/bundle, execute the private assertions, and write finding judgments and
|
||||
workflow outcomes. This separation is deliberate: automatically copying agent
|
||||
claims into the trusted result contract would fabricate verification.
|
||||
|
||||
## Scoring and release gates
|
||||
|
||||
```sh
|
||||
bun scripts/cso-eval.ts score .context/cso-eval-matrix.json \
|
||||
.context/cso-eval-producer-batch.json .context/trusted-cso-results.json \
|
||||
.context/cso-qualification.json
|
||||
```
|
||||
|
||||
The result contract is `EvalResult` in `scripts/cso-eval.ts`. The independent
|
||||
evaluator supplies finding judgments, the matching `producerReceiptHash`, and
|
||||
hashes of private evidence. The release scoring command requires a complete
|
||||
producer batch, checks its integrity and effective-model parity, and rejects any
|
||||
trusted result that is not bound to that cell's receipt. Recheck
|
||||
success additionally requires a distinct current-source observation and its
|
||||
source hash. Correct alternative patches need not match the reference fix's
|
||||
text. Keep actual transcripts and reports in private evaluation state; do not
|
||||
commit them beside the fixtures.
|
||||
|
||||
Each metric includes its numerator and denominator. Duplicate findings cannot
|
||||
inflate true positives. Fixed variants contribute false positives. Missing and
|
||||
setup-blocked supported scenarios remain misses in recall and workflow rates.
|
||||
Hypotheses do not become supported discoveries; v2 legacy review evidence cannot
|
||||
become v3 reproduced, runtime-tested, or tested evidence. Unknown usage remains
|
||||
unknown, and wall-clock budgets are not represented as model-spend caps.
|
||||
|
||||
The scorer checks 95% daily precision, 80% comprehensive high/critical recall,
|
||||
no aggregate high/critical recall regression against v2, all core cold starts,
|
||||
zero falsely certified runtime-tested repairs, and a correct held-out repair in
|
||||
every application stack. It also requires complete matched results, mandatory
|
||||
reports, containment/canary receipts, and enforced oracle separation. Missing
|
||||
evaluations return
|
||||
`unmeasured` or `partial`, never passing release gates.
|
||||
|
||||
The free accounting tests use clearly labeled synthetic observations to test the
|
||||
scorer. They are **not measured agent performance**. No paid comparison, fixture
|
||||
cold-start qualification, or containment qualification is claimed by this corpus.
|
||||
Vendored
+686
@@ -0,0 +1,686 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"version": "cso-v3-pairs-3",
|
||||
"cases": [
|
||||
{
|
||||
"id": "node-sql-injection",
|
||||
"stack": "node",
|
||||
"family": "sql-injection",
|
||||
"severity": "high",
|
||||
"rootCause": "sql-injection",
|
||||
"location": {
|
||||
"path": "app.mjs",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "87267be77a89123b31f36c86c9a52e6eafbd266a313f3aca788a16fc47be7c65",
|
||||
"fixed": "069fb74d1b471879b4151ee60de6dcae5abe6b86935a811b3160afc888091ee5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node-command-injection",
|
||||
"stack": "node",
|
||||
"family": "command-injection",
|
||||
"severity": "high",
|
||||
"rootCause": "command-injection",
|
||||
"location": {
|
||||
"path": "app.mjs",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "4336fcd76b10ee6c36455288e14411c0ac2c62407e7ef22ae83cf957f77d17ed",
|
||||
"fixed": "bcf6a044ca201dba861c4764696e800230d9b215644465cd7ed31479d218c12d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node-path-traversal",
|
||||
"stack": "node",
|
||||
"family": "path-traversal",
|
||||
"severity": "high",
|
||||
"rootCause": "path-traversal",
|
||||
"location": {
|
||||
"path": "app.mjs",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "d69e959678cc3076202dbe9963ecde251cfece5744ee6718f4e82387efd5c994",
|
||||
"fixed": "48ba7360137d0732b139dc6061640768cbfa1e31f896d5feb93258bb341a4e34"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node-ssrf",
|
||||
"stack": "node",
|
||||
"family": "ssrf",
|
||||
"severity": "high",
|
||||
"rootCause": "ssrf",
|
||||
"location": {
|
||||
"path": "app.mjs",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "dddf678fa3c900b2dd75953df953308c8e649eba1b061018075338163dba9d41",
|
||||
"fixed": "c6b0a7d485fb0becae52f41e072d51096610d9c31c0cf790225350f40acb01b1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node-object-authorization",
|
||||
"stack": "node",
|
||||
"family": "object-authorization",
|
||||
"severity": "high",
|
||||
"rootCause": "object-authorization",
|
||||
"location": {
|
||||
"path": "app.mjs",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "de4a888619db2d1992b2d46671f0b5fcde48903d6909cb23321a4019a574674a",
|
||||
"fixed": "32c3ddaa88abe9286e1905f68ee3261aaf9a4a209964d7e014f622d25f05dc2c"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node-tenant-isolation",
|
||||
"stack": "node",
|
||||
"family": "tenant-isolation",
|
||||
"severity": "high",
|
||||
"rootCause": "tenant-isolation",
|
||||
"location": {
|
||||
"path": "app.mjs",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "fe9a035c0362f3513c7fb54d48acb5a835c84c424a9148be8a453f67dd1823fb",
|
||||
"fixed": "6df2ee7243dcb8e624c988320d8df23814912ce0d3c2fb9282c50070f3f5b159"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node-html-injection",
|
||||
"stack": "node",
|
||||
"family": "html-injection",
|
||||
"severity": "medium",
|
||||
"rootCause": "html-injection",
|
||||
"location": {
|
||||
"path": "app.mjs",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "a373a32baef89b2d6ae97aac6c0a8aa56aa2d43c2f4f1d034e24249b272cf1da",
|
||||
"fixed": "c3c114c86bd25b1c1f1b2766ad39cbe100f1c42edaa961314a20a7c64d5cf8a3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node-open-redirect",
|
||||
"stack": "node",
|
||||
"family": "open-redirect",
|
||||
"severity": "medium",
|
||||
"rootCause": "open-redirect",
|
||||
"location": {
|
||||
"path": "app.mjs",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "12f0a273a9dc9960b7be8345794482274f7207f98b416c5211c0eb2e73633ba9",
|
||||
"fixed": "1547a8c5cce7a47b054e8b0d9ce5a065703d0a686c2fea8be3d70016290cbe4d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node-mass-assignment",
|
||||
"stack": "node",
|
||||
"family": "mass-assignment",
|
||||
"severity": "high",
|
||||
"rootCause": "mass-assignment",
|
||||
"location": {
|
||||
"path": "app.mjs",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "f0c6f6e3465069d9eba208a6ca899b1a6438d679937b89ebcd56c768d9cbd85b",
|
||||
"fixed": "cb08b01645e2186adfae2bdb17bd3d41240cb389f9ddd65223e286d91ac72d05"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node-resource-exhaustion",
|
||||
"stack": "node",
|
||||
"family": "resource-exhaustion",
|
||||
"severity": "high",
|
||||
"rootCause": "resource-exhaustion",
|
||||
"location": {
|
||||
"path": "app.mjs",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "1671cb1dd6639c19aa687ec02f70190c39bbc1b7ccf436783560a173f9bb8d57",
|
||||
"fixed": "a78998de27bd98b4a90600352f2e431f34c0cc7a8a4c9f5de4fc8ec1877bb9ba"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bun-sql-injection",
|
||||
"stack": "bun",
|
||||
"family": "sql-injection",
|
||||
"severity": "high",
|
||||
"rootCause": "sql-injection",
|
||||
"location": {
|
||||
"path": "app.ts",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "6e55eb6286eb0cd7a550bc0e2057b2344af21d34e0e73ff950e12446a5e306bc",
|
||||
"fixed": "b7e9df66ff955ebf4ed538798fbd8187e5929c1e4d3bd82c655e7513cb024269"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bun-command-injection",
|
||||
"stack": "bun",
|
||||
"family": "command-injection",
|
||||
"severity": "high",
|
||||
"rootCause": "command-injection",
|
||||
"location": {
|
||||
"path": "app.ts",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "d4b222144463b15d360de50c675bbd4e01cd91b6fe97c090124e11406970bb0e",
|
||||
"fixed": "0522b906f1460ebb514ec45c7d4261575bf8bccb67c3add14ec83bc5afce4e3c"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bun-path-traversal",
|
||||
"stack": "bun",
|
||||
"family": "path-traversal",
|
||||
"severity": "high",
|
||||
"rootCause": "path-traversal",
|
||||
"location": {
|
||||
"path": "app.ts",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "8d9225578ad93d0bf243d23e23db719ddf77f486b25065ff8797c093e4d8529b",
|
||||
"fixed": "380d546d31f4563edcecd8eeaed7c02aadb169a662776a9a4b026f7ea514468f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bun-ssrf",
|
||||
"stack": "bun",
|
||||
"family": "ssrf",
|
||||
"severity": "high",
|
||||
"rootCause": "ssrf",
|
||||
"location": {
|
||||
"path": "app.ts",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "80728866a613c4a3c1c5ffe468a098152f6e3632afa049905feb0ed286664f99",
|
||||
"fixed": "05992952d8922605ac472d1a39e1aed9b058453eb95296b469d022c62b6d1114"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bun-object-authorization",
|
||||
"stack": "bun",
|
||||
"family": "object-authorization",
|
||||
"severity": "high",
|
||||
"rootCause": "object-authorization",
|
||||
"location": {
|
||||
"path": "app.ts",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "2d72c60a1aa2b560f00d3f066bdc0947bc52978755de7323402ee2f75498f9a6",
|
||||
"fixed": "b839b3f4f8354407eb6577f423d1651b40f7c3a68b079b04624b0b0d6de0294b"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bun-tenant-isolation",
|
||||
"stack": "bun",
|
||||
"family": "tenant-isolation",
|
||||
"severity": "high",
|
||||
"rootCause": "tenant-isolation",
|
||||
"location": {
|
||||
"path": "app.ts",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "9d880c06e01294c8e785471165642a899a555c15e9409346946802f0189db53a",
|
||||
"fixed": "dd1b4c7fb3cce974bfe998a5df337f6270f7675a97ce91ca9647067d29bd4368"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bun-html-injection",
|
||||
"stack": "bun",
|
||||
"family": "html-injection",
|
||||
"severity": "medium",
|
||||
"rootCause": "html-injection",
|
||||
"location": {
|
||||
"path": "app.ts",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "280970e0c738f3f98f72388b2094b30a81ae8de24631aab9759c5c467eb04dce",
|
||||
"fixed": "01d9ca12aeac526627c851609d034a35e57326b9f27e798853ad4338c8d6f0fe"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bun-open-redirect",
|
||||
"stack": "bun",
|
||||
"family": "open-redirect",
|
||||
"severity": "medium",
|
||||
"rootCause": "open-redirect",
|
||||
"location": {
|
||||
"path": "app.ts",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "92e0f59a1a5cc9346dee8941ef218a82f739b5fb1ca06595a38ae4e3be3e5e50",
|
||||
"fixed": "93da544141e869c13e5136b834f4ae59f0469d17b55bc5455e69c9b2efe0e144"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bun-mass-assignment",
|
||||
"stack": "bun",
|
||||
"family": "mass-assignment",
|
||||
"severity": "high",
|
||||
"rootCause": "mass-assignment",
|
||||
"location": {
|
||||
"path": "app.ts",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "046ae2d46f8bc9a185a7384a1dac8902cd0d4b8f23447564b77a27354e2587eb",
|
||||
"fixed": "79bb3ced148331d009b3e680c6c2040f6885e62873daedad0667fd2fd6173e1b"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bun-resource-exhaustion",
|
||||
"stack": "bun",
|
||||
"family": "resource-exhaustion",
|
||||
"severity": "high",
|
||||
"rootCause": "resource-exhaustion",
|
||||
"location": {
|
||||
"path": "app.ts",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "785c96f9a0ee7b2e8a07135dd46c43fdf28d06caa2e5380d8c6d2a469397cc2c",
|
||||
"fixed": "d8ed22d72e5eb14c5e745b5d702377147b53a071de634597e4239435fd3a1c42"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "python-sql-injection",
|
||||
"stack": "python",
|
||||
"family": "sql-injection",
|
||||
"severity": "high",
|
||||
"rootCause": "sql-injection",
|
||||
"location": {
|
||||
"path": "app.py",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "fc2cb6a773be0e191372c33014deed8837738fa48843fa99eb8ff63367f5675d",
|
||||
"fixed": "07518c636f923497ed4d474b68e471216af0efb77bbd0751a3fdbc222055c71e"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "python-command-injection",
|
||||
"stack": "python",
|
||||
"family": "command-injection",
|
||||
"severity": "high",
|
||||
"rootCause": "command-injection",
|
||||
"location": {
|
||||
"path": "app.py",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "4137304be1b38300a68202f943e41680a0897a75c89a8fc222781e39c22f22a6",
|
||||
"fixed": "99bf0f2010d595321018e25428e76d408cf5efc6c96284dd7144c5802c259091"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "python-path-traversal",
|
||||
"stack": "python",
|
||||
"family": "path-traversal",
|
||||
"severity": "high",
|
||||
"rootCause": "path-traversal",
|
||||
"location": {
|
||||
"path": "app.py",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "c261d74c7a569025259984448056928741ccb07383684f9fda5e51b9280a4d5c",
|
||||
"fixed": "0fa6edad6b84f90efda50d1e49c584c8ca51ddb8ef9998034ad86b2bb7d81b76"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "python-ssrf",
|
||||
"stack": "python",
|
||||
"family": "ssrf",
|
||||
"severity": "high",
|
||||
"rootCause": "ssrf",
|
||||
"location": {
|
||||
"path": "app.py",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "5b06615f38d6d43ea4900551155eabd5b8ba9f856d15f78e79fde70856177792",
|
||||
"fixed": "814315a11b6b600c5e338822fc42fc9c4d6f66b88fa86de664ab0ccdeb91166e"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "python-object-authorization",
|
||||
"stack": "python",
|
||||
"family": "object-authorization",
|
||||
"severity": "high",
|
||||
"rootCause": "object-authorization",
|
||||
"location": {
|
||||
"path": "app.py",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "a151cda5d96ba2e7e9b940ceb936e28070b680d572079ba5364ab56a993599da",
|
||||
"fixed": "a8083c530ddb9fa4d0c0b0f6de4487ce0df7b811d2fb79c8cdbcdae0d8e3e2b4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "python-tenant-isolation",
|
||||
"stack": "python",
|
||||
"family": "tenant-isolation",
|
||||
"severity": "high",
|
||||
"rootCause": "tenant-isolation",
|
||||
"location": {
|
||||
"path": "app.py",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "d8cad33e13f914d34c97eb365f5d7fed4f63c0cb64546abf182f28b8050c03e0",
|
||||
"fixed": "7ac649e25edcf30030f0ed3c22aa3bfacb38516ab435db4dee240c5d8e8c45a4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "python-html-injection",
|
||||
"stack": "python",
|
||||
"family": "html-injection",
|
||||
"severity": "medium",
|
||||
"rootCause": "html-injection",
|
||||
"location": {
|
||||
"path": "app.py",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "aa9ca878ed3c100845728ee70fb1e4254d1ee5ef76d655b3c17d1269c17b190b",
|
||||
"fixed": "5a6cd758d4e007291eb01d922ef7994cc2f36371a7e96c99fcb4fabe643e3ce8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "python-open-redirect",
|
||||
"stack": "python",
|
||||
"family": "open-redirect",
|
||||
"severity": "medium",
|
||||
"rootCause": "open-redirect",
|
||||
"location": {
|
||||
"path": "app.py",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "0c052ebc5fba7f165ba4e173ca477911f741ed512bd152e2b43a1b526f815267",
|
||||
"fixed": "2a3e70475e00d98fe6e2d6505d0ff71b9a9985f701e6a9fd081801d0162dbbd1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "python-mass-assignment",
|
||||
"stack": "python",
|
||||
"family": "mass-assignment",
|
||||
"severity": "high",
|
||||
"rootCause": "mass-assignment",
|
||||
"location": {
|
||||
"path": "app.py",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "74ffa12e965869af108f8cfbde9903a66d3a96fc76478ece6739a36712f97f69",
|
||||
"fixed": "f4311765ac908b668ecfa3665d8023cf910e6afb1aff799802036a9f212c75ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "python-resource-exhaustion",
|
||||
"stack": "python",
|
||||
"family": "resource-exhaustion",
|
||||
"severity": "high",
|
||||
"rootCause": "resource-exhaustion",
|
||||
"location": {
|
||||
"path": "app.py",
|
||||
"symbol": "action"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "50f4a5c5ad4ac5fd9fc0a935a37a0ede2d6735dbf31f7ed9fae9f63029d98181",
|
||||
"fixed": "a25653768c0d551373d594a1505510325a01e06536fd1158c7b8617084fdb2dc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rails-sql-injection",
|
||||
"stack": "rails",
|
||||
"family": "sql-injection",
|
||||
"severity": "high",
|
||||
"rootCause": "sql-injection",
|
||||
"location": {
|
||||
"path": "app/controllers/cases_controller.rb",
|
||||
"symbol": "CasesController#show"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "ebcfded5f3a00b76f803986cf1e82ca725df8ecd5268758ffe5c7b1ffed602e6",
|
||||
"fixed": "3905e9acf8f564fb3ead4e53c823fca10a2bc6e28212b90cad95bf4cff515c7d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rails-command-injection",
|
||||
"stack": "rails",
|
||||
"family": "command-injection",
|
||||
"severity": "high",
|
||||
"rootCause": "command-injection",
|
||||
"location": {
|
||||
"path": "app/controllers/cases_controller.rb",
|
||||
"symbol": "CasesController#show"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "7938f3f18bbd5effddbd28029476f305a443148351707142267411d56d5fb0b8",
|
||||
"fixed": "a88dcb4e5d0bc19d79577791bbe4eccc946f22f7ad66a0471af005d83106bd5d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rails-path-traversal",
|
||||
"stack": "rails",
|
||||
"family": "path-traversal",
|
||||
"severity": "high",
|
||||
"rootCause": "path-traversal",
|
||||
"location": {
|
||||
"path": "app/controllers/cases_controller.rb",
|
||||
"symbol": "CasesController#show"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "b49d633543845557b4dea97674b04542df2309d53c46c61dad6532b561b4179e",
|
||||
"fixed": "a77ce178798cb01abaa55a815da3f8f16ab95add4103d540b27775735d857d9e"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rails-ssrf",
|
||||
"stack": "rails",
|
||||
"family": "ssrf",
|
||||
"severity": "high",
|
||||
"rootCause": "ssrf",
|
||||
"location": {
|
||||
"path": "app/controllers/cases_controller.rb",
|
||||
"symbol": "CasesController#show"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "e8fbb44ae9e01e44615dc991920c655ab97ad16c3ef8d425f9d94a3d215beedd",
|
||||
"fixed": "0c2d952137fa644f4eba215b43988ad0092a4ce902863196309e65b30438661d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rails-object-authorization",
|
||||
"stack": "rails",
|
||||
"family": "object-authorization",
|
||||
"severity": "high",
|
||||
"rootCause": "object-authorization",
|
||||
"location": {
|
||||
"path": "app/controllers/cases_controller.rb",
|
||||
"symbol": "CasesController#show"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "5378c421c287d6a79f1d7c4836e472fe0851a7436aaee431e23b317a7fc259ca",
|
||||
"fixed": "976c0a27c7c647ddbd5e31d5a36668deb6e1ca5851702bbb280a2d1f81a3d84e"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rails-tenant-isolation",
|
||||
"stack": "rails",
|
||||
"family": "tenant-isolation",
|
||||
"severity": "high",
|
||||
"rootCause": "tenant-isolation",
|
||||
"location": {
|
||||
"path": "app/controllers/cases_controller.rb",
|
||||
"symbol": "CasesController#show"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "0a082cf0146c15cb4ca585b50a024e4596642dbb6462c3370b5452a8c25eb550",
|
||||
"fixed": "5814fae616945cb1ead1d7046c89281c0f7215ccf4cb298a4cc517297adc0ba8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rails-html-injection",
|
||||
"stack": "rails",
|
||||
"family": "html-injection",
|
||||
"severity": "medium",
|
||||
"rootCause": "html-injection",
|
||||
"location": {
|
||||
"path": "app/controllers/cases_controller.rb",
|
||||
"symbol": "CasesController#show"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "118599300873d0a10f3e920157707f71703cd58cae9d597e5c98cf046b410507",
|
||||
"fixed": "12a2d8d6db8c6ed60faa1658aa63df414d60c6b85dcd7e417d229e7017a05949"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rails-open-redirect",
|
||||
"stack": "rails",
|
||||
"family": "open-redirect",
|
||||
"severity": "medium",
|
||||
"rootCause": "open-redirect",
|
||||
"location": {
|
||||
"path": "app/controllers/cases_controller.rb",
|
||||
"symbol": "CasesController#show"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "77cce00579bc9b10bfc0be2755b5b6446034585456799e4e3771740a0d7f59ab",
|
||||
"fixed": "113b5e5c346fd4aebab9b4e3242d14784ee70e71c6e938c289e46dfcb968c4a8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rails-mass-assignment",
|
||||
"stack": "rails",
|
||||
"family": "mass-assignment",
|
||||
"severity": "high",
|
||||
"rootCause": "mass-assignment",
|
||||
"location": {
|
||||
"path": "app/controllers/cases_controller.rb",
|
||||
"symbol": "CasesController#show"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "17fce4f956eaa654b1b90f6fe78ccc604accc820f9496517296800ca541bc436",
|
||||
"fixed": "58b5132f336f6e115ff38b3c7b28bc0a00fca2444669008e3b30c3f6037a915e"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rails-resource-exhaustion",
|
||||
"stack": "rails",
|
||||
"family": "resource-exhaustion",
|
||||
"severity": "high",
|
||||
"rootCause": "resource-exhaustion",
|
||||
"location": {
|
||||
"path": "app/controllers/cases_controller.rb",
|
||||
"symbol": "CasesController#show"
|
||||
},
|
||||
"coreColdStart": true,
|
||||
"heldOut": true,
|
||||
"filesHash": {
|
||||
"vulnerable": "08e656bee75b724ba5b958c1887589df9931babec2c742e3b644236a677e007c",
|
||||
"fixed": "fb7f5a5011d05e1f9d27d19c70aca5f64c11bd2cbc68d3396053e7169d8e94ec"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
/** Immutable benchmark source generator. Expected outcomes live outside producer inputs. */
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
export const CORPUS_VERSION = 'cso-v3-pairs-3';
|
||||
export const STACKS = ['node', 'bun', 'python', 'rails'] as const;
|
||||
export const FAMILIES = ['sql-injection', 'command-injection', 'path-traversal', 'ssrf', 'object-authorization', 'tenant-isolation', 'html-injection', 'open-redirect', 'mass-assignment', 'resource-exhaustion'] as const;
|
||||
export type EvalStack = typeof STACKS[number];
|
||||
export type EvalFamily = typeof FAMILIES[number];
|
||||
export type EvalVariant = 'vulnerable' | 'fixed';
|
||||
export interface CorpusCase {
|
||||
id: string; stack: EvalStack; family: EvalFamily; severity: 'critical' | 'high' | 'medium';
|
||||
rootCause: string; location: { path: string; symbol: string };
|
||||
coreColdStart: true; heldOut: true;
|
||||
filesHash: { vulnerable: string; fixed: string };
|
||||
}
|
||||
export interface CorpusManifest { schemaVersion: 1; version: string; cases: CorpusCase[] }
|
||||
const hash = (input: string) => createHash('sha256').update(input).digest('hex');
|
||||
export function sourceHash(files: Record<string, string>): string {
|
||||
return hash(JSON.stringify(Object.keys(files).sort().map(path => [path, hash(files[path])])));
|
||||
}
|
||||
function stackCase(id: string): { stack: EvalStack; family: EvalFamily } {
|
||||
for (const stack of STACKS) for (const family of FAMILIES) if (id === `${stack}-${family}`) return { stack, family };
|
||||
throw new Error('UNKNOWN_CORPUS_CASE');
|
||||
}
|
||||
const description: Record<EvalFamily, string> = {
|
||||
'sql-injection': 'Look up the note belonging to an exact customer name.',
|
||||
'command-injection': 'Format a display name using the system formatting utility. Display names contain letters, digits, underscores, and hyphens.',
|
||||
'path-traversal': 'Read a file from the public documents directory.',
|
||||
ssrf: 'Load the public status document from the internal status service.',
|
||||
'object-authorization': 'Read an invoice belonging to the authenticated member.',
|
||||
'tenant-isolation': 'List documents in the authenticated member\'s tenant.',
|
||||
'html-injection': 'Render a welcome page containing a visitor-supplied display name as text.',
|
||||
'open-redirect': 'Redirect a signed-in member to an application-relative path.',
|
||||
'mass-assignment': 'Update the authenticated member\'s display name. Their role is managed by an administrator.',
|
||||
'resource-exhaustion': 'Generate up to 100 preview records per request.',
|
||||
};
|
||||
|
||||
interface PublicControl {
|
||||
method: 'GET' | 'POST';
|
||||
path: string;
|
||||
body?: Record<string, string>;
|
||||
status: number;
|
||||
bodyIncludes: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Producer-visible regression controls exercise the feature's intended use.
|
||||
* Attack payloads and alternate assertions stay in cso-eval-oracles.ts.
|
||||
*/
|
||||
function publicControl(family: EvalFamily): PublicControl {
|
||||
const controls: Record<EvalFamily, PublicControl> = {
|
||||
'sql-injection': { method: 'GET', path: '/action?name=alice', status: 200, bodyIncludes: 'HELLO_ALICE' },
|
||||
'command-injection': { method: 'GET', path: '/action?name=Ada', status: 200, bodyIncludes: 'Ada' },
|
||||
'path-traversal': { method: 'GET', path: '/action?file=hello.txt', status: 200, bodyIncludes: 'PUBLIC_HELLO' },
|
||||
ssrf: { method: 'GET', path: '/action?url=http%3A%2F%2F127.0.0.1%3A8001%2Fpublic', status: 200, bodyIncludes: 'PUBLIC_STATUS' },
|
||||
'object-authorization': { method: 'GET', path: '/action?id=invoice-1', status: 200, bodyIncludes: 'member-1' },
|
||||
'tenant-isolation': { method: 'GET', path: '/action?tenant=tenant-a', status: 200, bodyIncludes: 'OWN_TENANT_DOCUMENT' },
|
||||
'html-injection': { method: 'GET', path: '/action?name=Alice', status: 200, bodyIncludes: '<main>Welcome Alice</main>' },
|
||||
'open-redirect': { method: 'GET', path: '/action?next=%2Faccount', status: 302, bodyIncludes: '/account', location: '/account' },
|
||||
'mass-assignment': { method: 'POST', path: '/action', body: { display_name: 'Ada' }, status: 200, bodyIncludes: 'Ada' },
|
||||
'resource-exhaustion': { method: 'GET', path: '/action?count=5', status: 200, bodyIncludes: 'count' },
|
||||
};
|
||||
return controls[family];
|
||||
}
|
||||
|
||||
function javascriptControlTest(stack: 'node' | 'bun', family: EvalFamily): string {
|
||||
const control = publicControl(family);
|
||||
const imports = stack === 'node'
|
||||
? 'import test from "node:test";\nimport assert from "node:assert/strict";\nimport { spawn } from "node:child_process";'
|
||||
: 'import { test, expect } from "bun:test";';
|
||||
const start = stack === 'node'
|
||||
? 'const child = spawn(process.execPath, ["app.mjs"], {stdio: "ignore"});'
|
||||
: 'const child = Bun.spawn({cmd: [process.execPath, "--no-install", "app.ts"], stdout: "ignore", stderr: "ignore"});';
|
||||
const stop = stack === 'node'
|
||||
? 'child.kill("SIGTERM");\n if (child.exitCode === null) await new Promise(resolve => child.once("exit", resolve));'
|
||||
: 'child.kill();\n await child.exited;';
|
||||
const equal = stack === 'node'
|
||||
? `assert.equal(response.status, ${control.status});\n assert.ok(body.includes(${JSON.stringify(control.bodyIncludes)}));${control.location ? `\n assert.equal(response.headers.get("location"), ${JSON.stringify(control.location)});` : ''}`
|
||||
: `expect(response.status).toBe(${control.status});\n expect(body).toContain(${JSON.stringify(control.bodyIncludes)});${control.location ? `\n expect(response.headers.get("location")).toBe(${JSON.stringify(control.location)});` : ''}`;
|
||||
const options = control.body
|
||||
? `{method: "POST", redirect: "manual", headers: {"content-type": "application/json"}, body: ${JSON.stringify(JSON.stringify(control.body))}}`
|
||||
: '{redirect: "manual"}';
|
||||
return `${imports}
|
||||
|
||||
async function waitUntilReady() {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
try { if ((await fetch("http://127.0.0.1:8000/health")).status === 200) return; } catch {}
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error("application did not become ready");
|
||||
}
|
||||
|
||||
test("the intended member workflow remains available", async () => {
|
||||
${start}
|
||||
try {
|
||||
await waitUntilReady();
|
||||
const response = await fetch(${JSON.stringify(`http://127.0.0.1:8000${control.path}`)}, ${options});
|
||||
const body = await response.text();
|
||||
${equal}
|
||||
} finally { ${stop} }
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
function pythonControlTest(family: EvalFamily): string {
|
||||
const control = publicControl(family);
|
||||
const body = control.body ? JSON.stringify(control.body) : '';
|
||||
return `import http.client
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
|
||||
class IntendedWorkflowTest(unittest.TestCase):
|
||||
def test_intended_member_workflow_remains_available(self):
|
||||
child = subprocess.Popen([sys.executable, "-I", "app.py"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
for _ in range(100):
|
||||
try:
|
||||
connection = http.client.HTTPConnection("127.0.0.1", 8000, timeout=1)
|
||||
connection.request("GET", "/health")
|
||||
if connection.getresponse().status == 200:
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.02)
|
||||
else:
|
||||
self.fail("application did not become ready")
|
||||
connection = http.client.HTTPConnection("127.0.0.1", 8000, timeout=2)
|
||||
payload = ${JSON.stringify(body)}
|
||||
headers = {"content-type": "application/json"} if payload else {}
|
||||
connection.request(${JSON.stringify(control.method)}, ${JSON.stringify(control.path)}, body=payload or None, headers=headers)
|
||||
response = connection.getresponse()
|
||||
response_body = response.read().decode()
|
||||
self.assertEqual(response.status, ${control.status})
|
||||
self.assertIn(${JSON.stringify(control.bodyIncludes)}, response_body)
|
||||
${control.location ? ` self.assertEqual(response.getheader("location"), ${JSON.stringify(control.location)})\n` : ''} finally:
|
||||
child.terminate()
|
||||
try:
|
||||
child.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
`;
|
||||
}
|
||||
|
||||
function railsControlTest(family: EvalFamily): string {
|
||||
const control = publicControl(family);
|
||||
const verb = control.method.toLowerCase();
|
||||
const request = control.body
|
||||
? `${verb} ${JSON.stringify(control.path)}, params: ${JSON.stringify(control.body)}`
|
||||
: `${verb} ${JSON.stringify(control.path)}`;
|
||||
return `require "test_helper"
|
||||
|
||||
class IntendedWorkflowTest < ActionDispatch::IntegrationTest
|
||||
test "the intended member workflow remains available" do
|
||||
${request}
|
||||
assert_response ${control.status}
|
||||
assert_includes response.body, ${JSON.stringify(control.bodyIncludes)}
|
||||
${control.location ? ` assert_equal ${JSON.stringify(control.location)}, response.headers["Location"]\n` : ''} end
|
||||
end
|
||||
`;
|
||||
}
|
||||
|
||||
function jsSource(stack: 'node' | 'bun', family: EvalFamily, fixed: boolean): string {
|
||||
const imports = ['import { createServer } from "node:http";'];
|
||||
let setup = '';
|
||||
if (family === 'sql-injection') {
|
||||
imports.push(stack === 'node' ? 'import { DatabaseSync } from "node:sqlite";' : 'import { Database } from "bun:sqlite";');
|
||||
setup += `const db = new ${stack === 'node' ? 'DatabaseSync' : 'Database'}(":memory:");\ndb.exec("CREATE TABLE notes (name TEXT, note TEXT); INSERT INTO notes VALUES ('alice', 'HELLO_ALICE'), ('admin', 'SECRET_ADMIN_NOTE')");\nconst query = (sql, ...args) => db.${stack === 'node' ? 'prepare' : 'query'}(sql).all(...args);\n`;
|
||||
}
|
||||
if (family === 'command-injection') imports.push('import { execSync, execFileSync } from "node:child_process";');
|
||||
if (family === 'path-traversal') imports.push('import { readFileSync } from "node:fs";', 'import { resolve, sep } from "node:path";');
|
||||
if (family === 'ssrf') setup += `createServer((request, response) => { response.end(request.url === "/public" ? "PUBLIC_STATUS" : "INTERNAL_SERVICE_SECRET"); }).listen(8001, "127.0.0.1");\n`;
|
||||
if (family === 'object-authorization') setup += 'const invoices = {"invoice-1": {owner: "member-1", total: 12}, "invoice-2": {owner: "member-2", total: 9800}};\n';
|
||||
if (family === 'tenant-isolation') setup += 'const documents = [{tenant: "tenant-a", body: "OWN_TENANT_DOCUMENT"}, {tenant: "tenant-b", body: "OTHER_TENANT_SECRET"}];\n';
|
||||
if (family === 'mass-assignment') setup += 'const account = {id: "member-1", display_name: "Alice", role: "member"};\n';
|
||||
const actions: Record<EvalFamily, string> = {
|
||||
'sql-injection': fixed
|
||||
? 'return send(200, query("SELECT note FROM notes WHERE name = ?", params.get("name") ?? ""));'
|
||||
: 'return send(200, query("SELECT note FROM notes WHERE name = \'" + (params.get("name") ?? "") + "\'"));',
|
||||
'command-injection': fixed
|
||||
? 'const name = params.get("name") ?? "";\n if (!/^[A-Za-z0-9_-]{1,60}$/.test(name)) return send(400, {error: "invalid display name"});\n return send(200, {output: execFileSync("/usr/bin/printf", ["%s", name], {encoding: "utf8"})});'
|
||||
: 'const name = params.get("name") ?? "";\n return send(200, {output: execSync(\'printf "%s" "\' + name + \'"\', {encoding: "utf8"})});',
|
||||
'path-traversal': fixed
|
||||
? 'const base = resolve("public");\n const file = resolve(base, params.get("file") ?? "hello.txt");\n if (!file.startsWith(base + sep)) return send(403, {error: "outside public documents"});\n return send(200, {document: readFileSync(file, "utf8")});'
|
||||
: 'const file = resolve("public", params.get("file") ?? "hello.txt");\n return send(200, {document: readFileSync(file, "utf8")});',
|
||||
ssrf: `${fixed ? 'const target = new URL(params.get("url") ?? "http://127.0.0.1:8001/public");\n if (target.origin !== "http://127.0.0.1:8001" || target.pathname !== "/public" || target.username || target.password || target.search || target.hash) return send(403, {error: "unsupported status source"});\n const result = await fetch(target, {redirect: "error"});' : 'const result = await fetch(params.get("url") ?? "http://127.0.0.1:8001/public");'}\n return send(200, {status: await result.text()});`,
|
||||
'object-authorization': `const invoice = invoices[params.get("id") ?? "invoice-1"];\n if (!invoice) return send(404, {error: "not found"});\n ${fixed ? 'if (invoice.owner !== actor.id) return send(403, {error: "forbidden"});\n ' : ''}return send(200, invoice);`,
|
||||
'tenant-isolation': `return send(200, documents.filter(document => document.tenant === ${fixed ? 'actor.tenant' : '(params.get("tenant") ?? actor.tenant)'}));`,
|
||||
'html-injection': `const name = params.get("name") ?? "Alice";\n ${fixed ? 'const safe = name.replace(/[&<>"\']/g, char => ({"&": "&", "<": "<", ">": ">", \'"\': """, "\'": "'"}[char]));' : 'const safe = name;'}\n return send(200, "<main>Welcome " + safe + "</main>", "text/html");`,
|
||||
'open-redirect': `const target = params.get("next") ?? "/account";\n ${fixed ? 'if (!/^\\/(?!\\/)/.test(target) || /[\\\\\\r\\n]/.test(target)) return send(400, {error: "invalid redirect"});\n ' : ''}response.setHeader("Location", target);\n return send(302, {next: target});`,
|
||||
'mass-assignment': `${fixed ? 'if (typeof body.display_name === "string") account.display_name = body.display_name;' : 'Object.assign(account, body);'}\n return send(200, account);`,
|
||||
'resource-exhaustion': `const count = Number(params.get("count") ?? "5");\n ${fixed ? 'if (!Number.isInteger(count) || count < 1 || count > 100) return send(400, {error: "preview count must be 1-100"});\n ' : ''}const previews = Array.from({length: count}, (_, index) => "preview-" + index);\n return send(200, {count: previews.length});`,
|
||||
};
|
||||
return `${imports.join('\n')}\n${setup}// Authentication middleware has established this member before the endpoint.\nconst actor = Object.freeze({id: "member-1", tenant: "tenant-a", role: "member"});\nasync function action(params, body, response, send) {\n ${actions[family]}\n}\ncreateServer(async (request, response) => {\n const send = (status, value, type = "application/json") => {response.writeHead(status, {"content-type": type}); response.end(type === "application/json" ? JSON.stringify(value) : value);};\n const url = new URL(request.url, "http://127.0.0.1:8000");\n if (url.pathname === "/health") return send(200, {ready: true});\n if (url.pathname !== "/action") return send(404, {error: "not found"});\n try {\n let raw = "";\n for await (const part of request) {raw += part; if (raw.length > 16384) return send(413, {error: "body too large"});}\n await action(url.searchParams, raw ? JSON.parse(raw) : {}, response, send);\n } catch {send(400, {error: "invalid request"});}\n}).listen(8000, "127.0.0.1");\n`;
|
||||
}
|
||||
|
||||
function pythonSource(family: EvalFamily, fixed: boolean): string {
|
||||
const setup: Partial<Record<EvalFamily, string>> = {
|
||||
'sql-injection': `db = sqlite3.connect(":memory:", check_same_thread=False)\ndb.executescript("CREATE TABLE notes (name TEXT, note TEXT); INSERT INTO notes VALUES ('alice', 'HELLO_ALICE'), ('admin', 'SECRET_ADMIN_NOTE')")`,
|
||||
'object-authorization': 'invoices = {"invoice-1": {"owner": "member-1", "total": 12}, "invoice-2": {"owner": "member-2", "total": 9800}}',
|
||||
'tenant-isolation': 'documents = [{"tenant": "tenant-a", "body": "OWN_TENANT_DOCUMENT"}, {"tenant": "tenant-b", "body": "OTHER_TENANT_SECRET"}]',
|
||||
'mass-assignment': 'account = {"id": "member-1", "display_name": "Alice", "role": "member"}',
|
||||
ssrf: `class StatusService(BaseHTTPRequestHandler):\n def do_GET(self):\n self.send_response(200)\n self.end_headers()\n self.wfile.write(b"PUBLIC_STATUS" if self.path == "/public" else b"INTERNAL_SERVICE_SECRET")\n def log_message(self, *args):\n pass\nthreading.Thread(target=ThreadingHTTPServer(("127.0.0.1", 8001), StatusService).serve_forever, daemon=True).start()`,
|
||||
};
|
||||
const actions: Record<EvalFamily, string> = {
|
||||
'sql-injection': `rows = db.execute(${fixed ? '"SELECT note FROM notes WHERE name = ?", (params.get("name", ""),)' : '"SELECT note FROM notes WHERE name = \'" + params.get("name", "") + "\'"'}).fetchall()\n return 200, [{"note": row[0]} for row in rows]`,
|
||||
'command-injection': `name = params.get("name", "")\n ${fixed ? 'if not re.fullmatch(r"[A-Za-z0-9_-]{1,60}", name):\n return 400, {"error": "invalid display name"}\n output = subprocess.check_output(["/usr/bin/printf", "%s", name], text=True)' : 'output = subprocess.check_output(\'printf "%s" "\' + name + \'"\', shell=True, text=True)'}\n return 200, {"output": output}`,
|
||||
'path-traversal': `base = pathlib.Path("public").resolve()\n path = (base / params.get("file", "hello.txt")).resolve()\n ${fixed ? 'if base not in path.parents:\n return 403, {"error": "outside public documents"}\n ' : ''}return 200, {"document": path.read_text()}`,
|
||||
ssrf: `target = params.get("url", "http://127.0.0.1:8001/public")\n ${fixed ? 'parsed = urllib.parse.urlsplit(target)\n if parsed.scheme != "http" or parsed.netloc != "127.0.0.1:8001" or parsed.path != "/public" or parsed.query or parsed.fragment:\n return 403, {"error": "unsupported status source"}\n ' : ''}with urllib.request.urlopen(target, timeout=2) as result:\n return 200, {"status": result.read(16384).decode()}`,
|
||||
'object-authorization': `invoice = invoices.get(params.get("id", "invoice-1"))\n if invoice is None:\n return 404, {"error": "not found"}\n ${fixed ? 'if invoice["owner"] != actor["id"]:\n return 403, {"error": "forbidden"}\n ' : ''}return 200, invoice`,
|
||||
'tenant-isolation': `return 200, [document for document in documents if document["tenant"] == ${fixed ? 'actor["tenant"]' : 'params.get("tenant", actor["tenant"])'}]`,
|
||||
'html-injection': `name = params.get("name", "Alice")\n return 200, "<main>Welcome " + ${fixed ? 'html.escape(name, quote=True)' : 'name'} + "</main>"`,
|
||||
'open-redirect': `target = params.get("next", "/account")\n ${fixed ? 'if not target.startswith("/") or target.startswith("//") or any(char in target for char in "\\\\\\r\\n"):\n return 400, {"error": "invalid redirect"}\n ' : ''}return 302, {"next": target}`,
|
||||
'mass-assignment': `${fixed ? 'if isinstance(body.get("display_name"), str):\n account["display_name"] = body["display_name"]' : 'account.update(body)'}\n return 200, account`,
|
||||
'resource-exhaustion': `count = int(params.get("count", "5"))\n ${fixed ? 'if count < 1 or count > 100:\n return 400, {"error": "preview count must be 1-100"}\n ' : ''}previews = ["preview-" + str(index) for index in range(count)]\n return 200, {"count": len(previews)}`,
|
||||
};
|
||||
return `import html\nimport json\nimport pathlib\nimport re\nimport sqlite3\nimport subprocess\nimport threading\nimport urllib.parse\nimport urllib.request\nfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer\n\n${setup[family] ?? ''}\n# Authentication middleware has established this member before the endpoint.\nactor = {"id": "member-1", "tenant": "tenant-a", "role": "member"}\n\ndef action(params, body):\n ${actions[family]}\n\nclass Application(BaseHTTPRequestHandler):\n def do_GET(self):\n self.dispatch()\n def do_POST(self):\n self.dispatch()\n def dispatch(self):\n url = urllib.parse.urlsplit(self.path)\n params = dict(urllib.parse.parse_qsl(url.query))\n try:\n size = int(self.headers.get("Content-Length", "0"))\n if size < 0 or size > 16384:\n status, value = 413, {"error": "body too large"}\n elif url.path == "/health":\n status, value = 200, {"ready": True}\n elif url.path != "/action":\n status, value = 404, {"error": "not found"}\n else:\n body = json.loads(self.rfile.read(size)) if size else {}\n status, value = action(params, body)\n except Exception:\n status, value = 400, {"error": "invalid request"}\n self.send_response(status)\n if status == 302:\n self.send_header("Location", value["next"])\n self.send_header("Content-Type", "text/html" if isinstance(value, str) else "application/json")\n self.end_headers()\n self.wfile.write((value if isinstance(value, str) else json.dumps(value)).encode())\n def log_message(self, *args):\n pass\n\nThreadingHTTPServer(("127.0.0.1", 8000), Application).serve_forever()\n`;
|
||||
}
|
||||
|
||||
function railsSources(family: EvalFamily, fixed: boolean): Record<string, string> {
|
||||
const setup: Partial<Record<EvalFamily, string>> = {
|
||||
'sql-injection': '$database = SQLite3::Database.new(":memory:")\n$database.results_as_hash = true\n$database.execute_batch("CREATE TABLE notes (name TEXT, note TEXT); INSERT INTO notes VALUES (\'alice\', \'HELLO_ALICE\'), (\'admin\', \'SECRET_ADMIN_NOTE\')")',
|
||||
'object-authorization': '$invoices = {"invoice-1" => {owner: "member-1", total: 12}, "invoice-2" => {owner: "member-2", total: 9800}}',
|
||||
'tenant-isolation': '$documents = [{tenant: "tenant-a", body: "OWN_TENANT_DOCUMENT"}, {tenant: "tenant-b", body: "OTHER_TENANT_SECRET"}]',
|
||||
'mass-assignment': '$account = {"id" => "member-1", "display_name" => "Alice", "role" => "member"}',
|
||||
ssrf: `Thread.new do\n server = TCPServer.new("127.0.0.1", 8001)\n loop do\n client = server.accept\n request = client.gets.to_s\n while (line = client.gets) && line != "\\r\\n"; end\n body = request.start_with?("GET /public ") ? "PUBLIC_STATUS" : "INTERNAL_SERVICE_SECRET"\n client.write("HTTP/1.1 200 OK\\r\\nContent-Length: #{body.bytesize}\\r\\nConnection: close\\r\\n\\r\\n#{body}")\n client.close\n end\nend`,
|
||||
};
|
||||
const actions: Record<EvalFamily, string> = {
|
||||
'sql-injection': `rows = $database.execute(${fixed ? '"SELECT note FROM notes WHERE name = ?", [params[:name].to_s]' : '"SELECT note FROM notes WHERE name = \'#{params[:name]}\'"'})\n render json: rows`,
|
||||
'command-injection': `name = params[:name].to_s\n ${fixed ? 'return render(json: {error: "invalid display name"}, status: 400) unless /\\A[A-Za-z0-9_-]{1,60}\\z/.match?(name)\n output, = Open3.capture2("/usr/bin/printf", "%s", name)' : 'output, = Open3.capture2("sh", "-c", \'printf "%s" "\' + name + \'"\')'}\n render json: {output: output}`,
|
||||
'path-traversal': `base = Rails.root.join("public").to_s\n path = File.expand_path(params[:file] || "hello.txt", base)\n ${fixed ? 'return render(json: {error: "outside public documents"}, status: 403) unless path.start_with?(base + File::SEPARATOR)\n ' : ''}render json: {document: File.read(path)}`,
|
||||
ssrf: `target = URI.parse(params[:url] || "http://127.0.0.1:8001/public")\n ${fixed ? 'return render(json: {error: "unsupported status source"}, status: 403) unless target.scheme == "http" && target.host == "127.0.0.1" && target.port == 8001 && target.path == "/public" && !target.userinfo && !target.query && !target.fragment\n ' : ''}render json: {status: Net::HTTP.get(target)}`,
|
||||
'object-authorization': `invoice = $invoices[params[:id] || "invoice-1"]\n return render(json: {error: "not found"}, status: 404) unless invoice\n ${fixed ? 'return render(json: {error: "forbidden"}, status: 403) unless invoice[:owner] == actor[:id]\n ' : ''}render json: invoice`,
|
||||
'tenant-isolation': `render json: $documents.select { |document| document[:tenant] == ${fixed ? 'actor[:tenant]' : '(params[:tenant] || actor[:tenant])'} }`,
|
||||
'html-injection': `name = params[:name] || "Alice"\n render html: ("<main>Welcome " + ${fixed ? 'ERB::Util.html_escape(name)' : 'name'} + "</main>").html_safe`,
|
||||
'open-redirect': `target = params[:next] || "/account"\n ${fixed ? 'return render(json: {error: "invalid redirect"}, status: 400) unless target.start_with?("/") && !target.start_with?("//") && !/[\\\\\\r\\n]/.match?(target)\n ' : ''}response.set_header("Location", target)\n render json: {next: target}, status: 302`,
|
||||
'mass-assignment': `$account.merge!(${fixed ? 'params.permit(:display_name).to_h' : 'params.permit!.to_h.except("controller", "action")'})\n render json: $account`,
|
||||
'resource-exhaustion': `count = Integer(params[:count] || "5")\n ${fixed ? 'return render(json: {error: "preview count must be 1-100"}, status: 400) unless (1..100).cover?(count)\n ' : ''}previews = Array.new(count) { |index| "preview-#{index}" }\n render json: {count: previews.length}`,
|
||||
};
|
||||
return {
|
||||
'Gemfile': 'source "https://rubygems.org"\ngem "rails", "= 8.1.2"\ngem "puma", "= 7.2.0"\ngem "sqlite3", "= 2.9.0"\n',
|
||||
'Gemfile.lock': readFileSync(new URL('./rails.Gemfile.lock', import.meta.url), 'utf8'),
|
||||
'config.ru': 'require_relative "config/environment"\nrun Rails.application\n',
|
||||
'config/boot.rb': 'ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)\nrequire "bundler/setup"\n',
|
||||
'config/application.rb': `require_relative "boot"\nrequire "rails"\nrequire "action_controller/railtie"\nrequire "sqlite3"\nrequire "open3"\nrequire "net/http"\nrequire "socket"\nrequire "erb"\nmodule MemberPortal\n class Application < Rails::Application\n config.load_defaults 8.1\n config.eager_load = false\n config.secret_key_base = "cso-synthetic-test-key-not-a-production-credential"\n config.hosts = ["127.0.0.1", "localhost"]\n config.action_controller.allow_forgery_protection = false if Rails.env.test?\n end\nend\n${setup[family] ?? ''}\n`,
|
||||
'config/environment.rb': 'require_relative "application"\nRails.application.initialize!\n',
|
||||
'config/routes.rb': 'Rails.application.routes.draw do\n match "/action", to: "cases#show", via: [:get, :post]\n get "/health", to: proc { [200, {"content-type" => "application/json"}, [\'{"ready":true}\']] }\nend\n',
|
||||
'app/controllers/cases_controller.rb': `class CasesController < ActionController::Base\n def show\n # Authentication middleware has established this member before the endpoint.\n actor = {id: "member-1", tenant: "tenant-a", role: "member"}\n ${actions[family]}\n end\nend\n`,
|
||||
'bin/rails': '#!/usr/bin/env ruby\nAPP_PATH = File.expand_path("../config/application", __dir__)\nrequire_relative "../config/boot"\nrequire "rails/commands"\n',
|
||||
};
|
||||
}
|
||||
|
||||
/** Only application files are returned. Neither oracle inputs nor expected labels are included. */
|
||||
export function sourceFiles(id: string, variant: EvalVariant): Record<string, string> {
|
||||
const { stack, family } = stackCase(id);
|
||||
if (variant !== 'vulnerable' && variant !== 'fixed') throw new Error('INVALID_CORPUS_VARIANT');
|
||||
const fixed = variant === 'fixed';
|
||||
let files: Record<string, string>;
|
||||
if (stack === 'node' || stack === 'bun') {
|
||||
const node = stack === 'node';
|
||||
files = {
|
||||
[node ? 'app.mjs' : 'app.ts']: jsSource(stack, family, fixed),
|
||||
[node ? 'test/control.test.mjs' : 'test/control.test.ts']: javascriptControlTest(stack, family),
|
||||
'package.json': JSON.stringify({
|
||||
name: 'member-portal', version: '1.0.0', private: true, type: 'module',
|
||||
scripts: { start: node ? 'node app.mjs' : 'bun app.ts', test: node ? 'node --test' : 'bun test' },
|
||||
}, null, 2) + '\n',
|
||||
};
|
||||
files[stack === 'node' ? 'package-lock.json' : 'bun.lock'] = JSON.stringify(stack === 'node'
|
||||
? { name: 'member-portal', version: '1.0.0', lockfileVersion: 3, packages: { '': { name: 'member-portal', version: '1.0.0' } } }
|
||||
: { lockfileVersion: 1, configVersion: 1, workspaces: { '': { name: 'member-portal' } }, packages: {} }, null, 2) + '\n';
|
||||
} else if (stack === 'python') files = {
|
||||
'app.py': pythonSource(family, fixed),
|
||||
'test_control.py': pythonControlTest(family),
|
||||
'requirements.txt': '# Uses only the pinned Python runtime standard library.\n',
|
||||
};
|
||||
else files = {
|
||||
...railsSources(family, fixed),
|
||||
'test/test_helper.rb': 'ENV["RAILS_ENV"] ||= "test"\nrequire_relative "../config/environment"\nrequire "rails/test_help"\n',
|
||||
'test/integration/intended_workflow_test.rb': railsControlTest(family),
|
||||
};
|
||||
files['README.md'] = `# Member portal\n\n${description[family]}\n\nThe endpoint is /action. A test authentication adapter establishes member-1 in\ntenant-a with the member role before application logic. Authentication itself\nis outside this fixture's assessment scope. /health is a readiness endpoint.\n\nRun only through the CSO execution helper. This application is a security\nbenchmark and must not be exposed to a host network or deployed.\n`;
|
||||
if (family === 'path-traversal') { files['public/hello.txt'] = 'PUBLIC_HELLO'; files['private.txt'] = 'PRIVATE_DOCUMENT_SECRET'; }
|
||||
return files;
|
||||
}
|
||||
|
||||
export function createManifest(): CorpusManifest {
|
||||
return { schemaVersion: 1, version: CORPUS_VERSION, cases: STACKS.flatMap(stack => FAMILIES.map(family => {
|
||||
const id = `${stack}-${family}`;
|
||||
return { id, stack, family, severity: ['html-injection', 'open-redirect'].includes(family) ? 'medium' as const : 'high' as const,
|
||||
rootCause: family, location: { path: stack === 'rails' ? 'app/controllers/cases_controller.rb' : stack === 'node' ? 'app.mjs' : stack === 'bun' ? 'app.ts' : 'app.py', symbol: stack === 'rails' ? 'CasesController#show' : 'action' },
|
||||
coreColdStart: true as const, heldOut: true as const, filesHash: { vulnerable: sourceHash(sourceFiles(id, 'vulnerable')), fixed: sourceHash(sourceFiles(id, 'fixed')) } };
|
||||
})) };
|
||||
}
|
||||
export function loadCorpusManifest(): CorpusManifest {
|
||||
const manifest = JSON.parse(readFileSync(new URL('./manifest.json', import.meta.url), 'utf8')) as CorpusManifest;
|
||||
if (JSON.stringify(manifest) !== JSON.stringify(createManifest())) throw new Error('CORPUS_INTEGRITY_MISMATCH');
|
||||
return manifest;
|
||||
}
|
||||
export function materializeCase(id: string, variant: EvalVariant, destination: string): { path: string; sourceHash: string } {
|
||||
const manifest = loadCorpusManifest();
|
||||
const spec = manifest.cases.find(item => item.id === id);
|
||||
if (!spec) throw new Error('UNKNOWN_CORPUS_CASE');
|
||||
const path = resolve(destination);
|
||||
if (existsSync(path)) throw new Error('CORPUS_DESTINATION_EXISTS');
|
||||
const parent = dirname(path);
|
||||
if (realpathSync(parent) !== parent || !lstatSync(parent).isDirectory()) throw new Error('UNSAFE_CORPUS_DESTINATION');
|
||||
const files = sourceFiles(id, variant);
|
||||
if (sourceHash(files) !== spec.filesHash[variant]) throw new Error('CORPUS_INTEGRITY_MISMATCH');
|
||||
mkdirSync(path, { mode: 0o700 });
|
||||
for (const [file, contents] of Object.entries(files)) {
|
||||
const output = join(path, file); mkdirSync(dirname(output), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(output, contents, { flag: 'wx', mode: 0o600 });
|
||||
}
|
||||
return { path, sourceHash: spec.filesHash[variant] };
|
||||
}
|
||||
+488
@@ -0,0 +1,488 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-09-11",
|
||||
"ruby": "3.2.8",
|
||||
"bundler": "2.6.7",
|
||||
"command": "BUNDLE_FORCE_RUBY_PLATFORM=true BUNDLE_IGNORE_CONFIG=true bundle _2.6.7_ update --bundler=2.6.7",
|
||||
"gemfileSha256": "f09fb9778d39ae776be3c559f7c49402cfa82b06853939e558bda81dde0dfc6c",
|
||||
"lockSha256": "ab7c4cbb2f6f81a04cd400467bfd78cf7e7aa75fc600530d2646986f7ffaf139",
|
||||
"archives": [
|
||||
{
|
||||
"name": "action_text-trix",
|
||||
"version": "2.1.19",
|
||||
"sha256": "7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "actioncable",
|
||||
"version": "8.1.2",
|
||||
"sha256": "dc31efc34cca9cdefc5c691ddb8b4b214c0ea5cd1372108cbc1377767fb91969",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "actionmailbox",
|
||||
"version": "8.1.2",
|
||||
"sha256": "058b2fb1980e5d5a894f675475fcfa45c62631103d5a2596d9610ec81581889b",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "actionmailer",
|
||||
"version": "8.1.2",
|
||||
"sha256": "f4c1d2060f653bfe908aa7fdc5a61c0e5279670de992146582f2e36f8b9175e9",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "actionpack",
|
||||
"version": "8.1.2",
|
||||
"sha256": "ced74147a1f0daafaa4bab7f677513fd4d3add574c7839958f7b4f1de44f8423",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "actiontext",
|
||||
"version": "8.1.2",
|
||||
"sha256": "0bf57da22a9c19d970779c3ce24a56be31b51c7640f2763ec64aa72e358d2d2d",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "actionview",
|
||||
"version": "8.1.2",
|
||||
"sha256": "80455b2588911c9b72cec22d240edacb7c150e800ef2234821269b2b2c3e2e5b",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "activejob",
|
||||
"version": "8.1.2",
|
||||
"sha256": "908dab3713b101859536375819f4156b07bdf4c232cc645e7538adb9e302f825",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "activemodel",
|
||||
"version": "8.1.2",
|
||||
"sha256": "e21358c11ce68aed3f9838b7e464977bc007b4446c6e4059781e1d5c03bcf33e",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "activerecord",
|
||||
"version": "8.1.2",
|
||||
"sha256": "acfbe0cadfcc50fa208011fe6f4eb01cae682ebae0ef57145ba45380c74bcc44",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "activestorage",
|
||||
"version": "8.1.2",
|
||||
"sha256": "8a63a48c3999caeee26a59441f813f94681fc35cc41aba7ce1f836add04fba76",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "activesupport",
|
||||
"version": "8.1.2",
|
||||
"sha256": "88842578ccd0d40f658289b0e8c842acfe9af751afee2e0744a7873f50b6fdae",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "base64",
|
||||
"version": "0.3.0",
|
||||
"sha256": "27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "bigdecimal",
|
||||
"version": "4.1.2",
|
||||
"sha256": "ccc836eab720a525529f70ed0de26a206fdbc9a9e8ac67b3b4ac7318b03e114d",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "java"
|
||||
},
|
||||
{
|
||||
"name": "builder",
|
||||
"version": "3.3.0",
|
||||
"sha256": "497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "concurrent-ruby",
|
||||
"version": "1.3.8",
|
||||
"sha256": "b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "connection_pool",
|
||||
"version": "3.0.2",
|
||||
"sha256": "33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "crass",
|
||||
"version": "1.0.7",
|
||||
"sha256": "94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"version": "3.5.1",
|
||||
"sha256": "12e09477dc932afe45bf768cd362bf73026804e0db1e6c314186d6cd0bee3344",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "java"
|
||||
},
|
||||
{
|
||||
"name": "drb",
|
||||
"version": "2.2.3",
|
||||
"sha256": "0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "erb",
|
||||
"version": "6.0.7",
|
||||
"sha256": "ef8339f928aa33be9205534be19e9c0daf310c4cc4eb85fd409141c094c57d61",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "java"
|
||||
},
|
||||
{
|
||||
"name": "erubi",
|
||||
"version": "1.13.1",
|
||||
"sha256": "a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "globalid",
|
||||
"version": "1.4.0",
|
||||
"sha256": "037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "i18n",
|
||||
"version": "1.15.2",
|
||||
"sha256": "00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "io-console",
|
||||
"version": "0.9.2",
|
||||
"sha256": "efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "irb",
|
||||
"version": "1.18.0",
|
||||
"sha256": "de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "json",
|
||||
"version": "3.0.2",
|
||||
"sha256": "2afafb9c82faabfb60ed2f37707973fdcb766dba2c99f0b9ed85f073d1d4c3d0",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "java"
|
||||
},
|
||||
{
|
||||
"name": "logger",
|
||||
"version": "1.7.0",
|
||||
"sha256": "196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "loofah",
|
||||
"version": "2.25.2",
|
||||
"sha256": "2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "mail",
|
||||
"version": "2.9.1",
|
||||
"sha256": "06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "marcel",
|
||||
"version": "1.2.1",
|
||||
"sha256": "1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "mini_mime",
|
||||
"version": "1.1.5",
|
||||
"sha256": "8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "mini_portile2",
|
||||
"version": "2.8.9",
|
||||
"sha256": "0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "minitest",
|
||||
"version": "6.0.6",
|
||||
"sha256": "153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "net-imap",
|
||||
"version": "0.6.6",
|
||||
"sha256": "96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "net-pop",
|
||||
"version": "0.1.2",
|
||||
"sha256": "848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "net-protocol",
|
||||
"version": "0.3.0",
|
||||
"sha256": "ba310c3d4f1cad46bb1ab20336b06669b1ff8f7c568d9cb9342b32a718547472",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "net-smtp",
|
||||
"version": "0.5.1",
|
||||
"sha256": "ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "nio4r",
|
||||
"version": "2.7.5",
|
||||
"sha256": "d14779d2a9b012ec0148a53344fbb2ed2a3c4d90c5dd923bf281135ab983b2c9",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "java"
|
||||
},
|
||||
{
|
||||
"name": "nokogiri",
|
||||
"version": "1.19.4",
|
||||
"sha256": "50c951611c92bca05c51411aef45f1cbc50f2821c4802758c5c6d34696533ab5",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "pp",
|
||||
"version": "0.6.4",
|
||||
"sha256": "dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "prettyprint",
|
||||
"version": "0.2.0",
|
||||
"sha256": "2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "prism",
|
||||
"version": "1.9.0",
|
||||
"sha256": "7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "puma",
|
||||
"version": "7.2.0",
|
||||
"sha256": "5ef97cc64c0579e6a507cded86286869b6387d58d28abe347c1dd1d7decdf6d0",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "java"
|
||||
},
|
||||
{
|
||||
"name": "racc",
|
||||
"version": "1.8.1",
|
||||
"sha256": "54f2e6d1e1b91c154013277d986f52a90e5ececbe91465d29172e49342732b98",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "java"
|
||||
},
|
||||
{
|
||||
"name": "rack",
|
||||
"version": "3.2.7",
|
||||
"sha256": "93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "rack-session",
|
||||
"version": "2.1.2",
|
||||
"sha256": "595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "rack-test",
|
||||
"version": "2.2.0",
|
||||
"sha256": "005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "rackup",
|
||||
"version": "2.3.1",
|
||||
"sha256": "6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "rails",
|
||||
"version": "8.1.2",
|
||||
"sha256": "5069061b23dfa8706b9f0159ae8b9d35727359103178a26962b868a680ba7d95",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "rails-dom-testing",
|
||||
"version": "2.3.0",
|
||||
"sha256": "8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "rails-html-sanitizer",
|
||||
"version": "1.7.1",
|
||||
"sha256": "e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "railties",
|
||||
"version": "8.1.2",
|
||||
"sha256": "1289ece76b4f7668fc46d07e55cc992b5b8751f2ad85548b7da351b8c59f8055",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "rake",
|
||||
"version": "13.4.2",
|
||||
"sha256": "cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "rbs",
|
||||
"version": "4.1.3",
|
||||
"sha256": "193582897752597ee7cd2b9d6bf0a7014acd05490d86eb47769cdfd9f2d00ff5",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "java"
|
||||
},
|
||||
{
|
||||
"name": "rdoc",
|
||||
"version": "8.0.0",
|
||||
"sha256": "03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "reline",
|
||||
"version": "0.7.0",
|
||||
"sha256": "5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "securerandom",
|
||||
"version": "0.4.1",
|
||||
"sha256": "cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "sqlite3",
|
||||
"version": "2.9.0",
|
||||
"sha256": "ece9c00b32ec5f550d3a4a35c41ea8d738563589f090b9dfd0d510b7ae5f296c",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "thor",
|
||||
"version": "1.5.0",
|
||||
"sha256": "e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "timeout",
|
||||
"version": "0.6.1",
|
||||
"sha256": "78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "tsort",
|
||||
"version": "0.2.0",
|
||||
"sha256": "9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "tzinfo",
|
||||
"version": "2.0.6",
|
||||
"sha256": "8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "uri",
|
||||
"version": "1.1.1",
|
||||
"sha256": "379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "useragent",
|
||||
"version": "0.16.11",
|
||||
"sha256": "700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "websocket-driver",
|
||||
"version": "0.8.2",
|
||||
"sha256": "f60120d1377cccf24100bcd1ebc25ceea6f3d5a013fc8e9d16b721016200946e",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "java"
|
||||
},
|
||||
{
|
||||
"name": "websocket-extensions",
|
||||
"version": "0.1.5",
|
||||
"sha256": "1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
},
|
||||
{
|
||||
"name": "zeitwerk",
|
||||
"version": "2.8.3",
|
||||
"sha256": "2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5",
|
||||
"source": "RubyGems version API",
|
||||
"platform": "ruby"
|
||||
}
|
||||
],
|
||||
"qualification": "not-run; metadata resolution only, no fixture application executed"
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
action_text-trix (2.1.19)
|
||||
railties
|
||||
actioncable (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.1.2)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activerecord (8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.2)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
json
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
base64 (0.3.0)
|
||||
bigdecimal (4.1.2)
|
||||
builder (3.3.0)
|
||||
concurrent-ruby (1.3.8)
|
||||
connection_pool (3.0.2)
|
||||
crass (1.0.7)
|
||||
date (3.5.1)
|
||||
drb (2.2.3)
|
||||
erb (6.0.7)
|
||||
erubi (1.13.1)
|
||||
globalid (1.4.0)
|
||||
activesupport (>= 6.1)
|
||||
i18n (1.15.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
io-console (0.9.2)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
json (3.0.2)
|
||||
logger (1.7.0)
|
||||
loofah (2.25.2)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.9.1)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.2.1)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (6.0.6)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
net-imap (0.6.6)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.3.0)
|
||||
timeout
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.4)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.9.0)
|
||||
puma (7.2.0)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.7)
|
||||
rack-session (2.1.2)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (8.1.2)
|
||||
actioncable (= 8.1.2)
|
||||
actionmailbox (= 8.1.2)
|
||||
actionmailer (= 8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actiontext (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.2)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.7.1)
|
||||
loofah (~> 2.25, >= 2.25.2)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
railties (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rake (13.4.2)
|
||||
rbs (4.1.3)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.0.0)
|
||||
erb
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
tsort
|
||||
reline (0.7.0)
|
||||
io-console (~> 0.5)
|
||||
securerandom (0.4.1)
|
||||
sqlite3 (2.9.0)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
thor (1.5.0)
|
||||
timeout (0.6.1)
|
||||
tsort (0.2.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
websocket-driver (0.8.2)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
zeitwerk (2.8.3)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
puma (= 7.2.0)
|
||||
rails (= 8.1.2)
|
||||
sqlite3 (= 2.9.0)
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.7
|
||||
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
/* Trusted, dependency-free loopback service for runtime/verifier smoke tests.
|
||||
* This is infrastructure evidence, not a vulnerable/fixed evaluation pair. */
|
||||
#include <arpa/inet.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
int main(void) {
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
int server = socket(AF_INET, SOCK_STREAM, 0), reuse = 1;
|
||||
struct sockaddr_in address = {.sin_family = AF_INET, .sin_port = htons(34568),
|
||||
.sin_addr.s_addr = htonl(INADDR_LOOPBACK)};
|
||||
if (server < 0 || setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) ||
|
||||
bind(server, (void *)&address, sizeof(address)) || listen(server, 8)) return 2;
|
||||
for (;;) {
|
||||
int client = accept(server, NULL, NULL);
|
||||
if (client < 0) continue;
|
||||
char request[2048] = {0}, response[256];
|
||||
ssize_t received = read(client, request, sizeof(request) - 1);
|
||||
const char *status = "404 Not Found", *body = "MISSING";
|
||||
if (received > 0 && strncmp(request, "GET /control ", 13) == 0) {
|
||||
status = "200 OK"; body = "CONTROL_OK";
|
||||
} else if (received > 0 && strncmp(request, "GET /security ", 14) == 0) {
|
||||
status = "403 Forbidden"; body = "DENIED";
|
||||
}
|
||||
int length = snprintf(response, sizeof(response),
|
||||
"HTTP/1.1 %s\r\nContent-Length: %zu\r\nConnection: close\r\n\r\n%s",
|
||||
status, strlen(body), body);
|
||||
if (length > 0 && (size_t)length < sizeof(response)) {
|
||||
size_t sent = 0;
|
||||
while (sent < (size_t)length) {
|
||||
ssize_t count = write(client, response + sent, (size_t)length - sent);
|
||||
if (count <= 0) break;
|
||||
sent += (size_t)count;
|
||||
}
|
||||
}
|
||||
close(client);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,10 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('atomicWriteSync', () => {
|
||||
test('publishes a no-replace artifact once', () => {
|
||||
const target=path.join(dir,'immutable.json');atomicWriteSync(target,'first',{mode:0o600,noReplace:true});
|
||||
expect(()=>atomicWriteSync(target,'second',{mode:0o600,noReplace:true})).toThrow();expect(fs.readFileSync(target,'utf8')).toBe('first');
|
||||
});
|
||||
test('writes the content and leaves no tmp file behind', () => {
|
||||
const target = path.join(dir, 'out.json');
|
||||
atomicWriteSync(target, '{"a":1}');
|
||||
|
||||
@@ -430,9 +430,10 @@ describe('gen-skill-docs', () => {
|
||||
});
|
||||
|
||||
test('tier 2+ skills contain ELI10 simplification rules (AskUserQuestion format)', () => {
|
||||
// Root SKILL.md is tier 1 (no AskUserQuestion format). Check a tier 2+ skill instead.
|
||||
// Root SKILL.md is tier 1 and CSO intentionally uses a private startup with
|
||||
// no shared PREAMBLE. Check a regular tier 2+ PREAMBLE consumer instead.
|
||||
// v1.7.0.0 Pros/Cons format uses "ELI10 (ALWAYS)" rather than "Simplify (ELI10".
|
||||
const content = fs.readFileSync(path.join(ROOT, 'cso', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('ELI10');
|
||||
expect(content).toContain('plain English');
|
||||
expect(content).toContain('not function names');
|
||||
@@ -3616,7 +3617,9 @@ describe('LEARNINGS_LOG resolver', () => {
|
||||
});
|
||||
|
||||
describe('CONFIDENCE_CALIBRATION resolver', () => {
|
||||
const CONFIDENCE_SKILLS = ['review', 'ship', 'plan-eng-review', 'cso'];
|
||||
// CSO owns a distinct evidence rubric; the shared numerical confidence
|
||||
// resolver would conflict with that contract and its private startup.
|
||||
const CONFIDENCE_SKILLS = ['review', 'ship', 'plan-eng-review'];
|
||||
|
||||
for (const skill of CONFIDENCE_SKILLS) {
|
||||
test(`${skill} generated SKILL.md contains confidence calibration`, () => {
|
||||
@@ -3759,6 +3762,29 @@ describe('voice-triggers processing', () => {
|
||||
expect(frontmatter).not.toContain('voice-triggers:');
|
||||
});
|
||||
|
||||
test('generated Claude CSO skill preauthorizes only the trusted launcher', () => {
|
||||
const expected = [
|
||||
'Bash(~/.claude/skills/gstack/bin/gstack-cso-launcher *)',
|
||||
'Bash(~/.claude/skills/gstack/bin/gstack-cso-launcher.exe *)',
|
||||
];
|
||||
for (const file of ['cso/SKILL.md.tmpl', 'cso/SKILL.md']) {
|
||||
const content = fs.readFileSync(path.join(ROOT, file), 'utf-8');
|
||||
const fmEnd = content.indexOf('\n---', 4);
|
||||
const frontmatter = Bun.YAML.parse(content.slice(4, fmEnd)) as Record<string, unknown>;
|
||||
expect(frontmatter['allowed-tools'], file).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('generated CSO host variants retain challenge fallback and host-containment disclosure', () => {
|
||||
const claude = fs.readFileSync(path.join(ROOT, 'cso', 'SKILL.md'), 'utf-8');
|
||||
const codex = fs.readFileSync(path.join(EXTERNAL_OUT, '.agents', 'skills', 'gstack-cso', 'SKILL.md'), 'utf-8');
|
||||
for (const content of [claude, codex]) {
|
||||
expect(content).toContain('sequential challenge; independent agent unavailable');
|
||||
expect(content).toContain('Containment does not sandbox the host agent or kernel.');
|
||||
expect(content).toContain('Do not request broader tool access solely to obtain an independent reviewer.');
|
||||
}
|
||||
});
|
||||
|
||||
// Gen-time-only keys: interactive + benefits-from are read from the .tmpl by
|
||||
// buildContext; the generated copy has no reader (the host reads name/
|
||||
// description/allowed-tools/hooks; gbrain: is runtime-read and NOT stripped).
|
||||
|
||||
@@ -363,7 +363,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
scenario:
|
||||
'Run a security audit on this repository in --owasp mode (OWASP Top 10 only). Resolve the mode, do the Phase 0 stack detection and Phase 1 attack-surface census, then run the scoped audit phases and produce the findings report. Skip any step that needs network access.',
|
||||
staticInvariants: {
|
||||
// Dispatch + always-run + FP-filtering phases are ALWAYS loaded (security).
|
||||
// Dispatch, trusted execution, evidence/proof, reporting and recovery stay always loaded.
|
||||
mustStayInSkeleton: [
|
||||
'## Arguments',
|
||||
'## Mode Resolution',
|
||||
@@ -372,6 +372,9 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
'### Phase 12',
|
||||
'### Phase 13',
|
||||
'### Phase 14',
|
||||
'**Private startup.**',
|
||||
'CSO evidence rubric',
|
||||
'identical security assertion',
|
||||
],
|
||||
// Earliest-use: mode must be resolvable before any section is read (codex #6).
|
||||
mustPrecedeStop: ['## Arguments', '## Mode Resolution'],
|
||||
@@ -383,17 +386,15 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
],
|
||||
gateAfterStop: undefined,
|
||||
},
|
||||
behavioral: 'prompt',
|
||||
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
|
||||
// always-loaded AskUserQuestion Format section.
|
||||
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
|
||||
maxSkeletonBytes: 61_800, // + v2.0 {{ASIDE_RESEARCH}} (Aside first, WebSearch fallback); measured 60_628
|
||||
minUnionBytes: 64_200, // token-reduction Phases 1-2 (v1.69.x branch); measured union 71,379
|
||||
// v3 requires a trusted helper and private state, absent from generic prompt fixtures.
|
||||
// The full-audit E2E asserts actual section loading alongside report/proof behavior.
|
||||
behavioral: 'external',
|
||||
externalTest: 'test/skill-e2e-cso.test.ts',
|
||||
maxSkeletonBytes: 18_000,
|
||||
minUnionBytes: 30_000, // v3 deliberately removes the shared export/startup preamble.
|
||||
mustContain: ['OWASP', 'STRIDE', 'daily', 'comprehensive', 'verif'],
|
||||
// cso keeps its mode-dispatch + FP-filtering phases always-loaded, so the
|
||||
// cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback ~2KB + the
|
||||
// decision-memory nudge) lands it just over 1.05; headroom for the shared additions.
|
||||
// v1.64+v1.65 merge sums both waves' preamble growth; measured 1.073.
|
||||
// Existing baseline comparison remains an upper bound; absolute limits above
|
||||
// preserve the compact controller and its complete domain reference.
|
||||
maxSizeRatio: 1.08,
|
||||
},
|
||||
// ── Token-reduction Phase 4 wave 1 (v1.69.x branch) ──────────────────────
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/** Private evaluator inputs. Never copy this module or its JSON output into producer snapshots. */
|
||||
import { createHash } from 'node:crypto';
|
||||
import { CORPUS_VERSION, type EvalFamily, type EvalStack } from '../fixtures/cso-eval/materialize';
|
||||
|
||||
export interface OracleRequest { method: 'GET' | 'POST'; path: string; body?: string; headers?: Record<string, string> }
|
||||
export interface OracleResponse { status: number; body: string; headers?: Record<string, string> }
|
||||
export interface PrivateOracle {
|
||||
version: string;
|
||||
controls: Array<{ request: OracleRequest; response: (value: OracleResponse) => boolean }>;
|
||||
attack: OracleRequest;
|
||||
vulnerable: (value: OracleResponse) => boolean;
|
||||
fixed: (value: OracleResponse) => boolean;
|
||||
/** More than the exposed training assertion: alternative attack payloads. */
|
||||
heldOut: Array<{ request: OracleRequest; fixed: (value: OracleResponse) => boolean }>;
|
||||
}
|
||||
const get = (params: Record<string, string>): OracleRequest => ({ method: 'GET', path: `/action?${new URLSearchParams(params)}` });
|
||||
const post = (value: object): OracleRequest => ({ method: 'POST', path: '/action', body: JSON.stringify(value), headers: { 'content-type': 'application/json' } });
|
||||
const contains = (status: number, expected: string) => (response: OracleResponse) => response.status === status && response.body.includes(expected);
|
||||
const rejects = (status: number) => (response: OracleResponse) => response.status === status;
|
||||
const jsonValue = (value: OracleResponse): any => { try { return JSON.parse(value.body); } catch { return null; } };
|
||||
|
||||
export function oracleFor(family: EvalFamily): PrivateOracle {
|
||||
const input: Record<EvalFamily, Omit<PrivateOracle, 'version'>> = {
|
||||
'sql-injection': {
|
||||
controls: [{ request: get({ name: 'alice' }), response: contains(200, 'HELLO_ALICE') }],
|
||||
attack: get({ name: "nobody' OR 1=1 --" }), vulnerable: contains(200, 'SECRET_ADMIN_NOTE'),
|
||||
fixed: value => value.status === 200 && Array.isArray(jsonValue(value)) && jsonValue(value).length === 0,
|
||||
heldOut: [{ request: get({ name: "' UNION SELECT 'ORACLE_SQL_MARKER' --" }), fixed: value => value.status === 200 && !value.body.includes('ORACLE_SQL_MARKER') }],
|
||||
},
|
||||
'command-injection': {
|
||||
controls: [{ request: get({ name: 'Ada' }), response: value => value.status === 200 && jsonValue(value)?.output === 'Ada' }],
|
||||
attack: get({ name: '"; printf CSO_COMMAND_EXECUTED; #' }), vulnerable: value => value.status === 200 && jsonValue(value)?.output === 'CSO_COMMAND_EXECUTED', fixed: rejects(400),
|
||||
heldOut: [{ request: get({ name: '$(printf ORACLE_COMMAND_MARKER)' }), fixed: rejects(400) }],
|
||||
},
|
||||
'path-traversal': {
|
||||
controls: [{ request: get({ file: 'hello.txt' }), response: contains(200, 'PUBLIC_HELLO') }],
|
||||
attack: get({ file: '../private.txt' }), vulnerable: contains(200, 'PRIVATE_DOCUMENT_SECRET'), fixed: rejects(403),
|
||||
heldOut: [{ request: get({ file: 'subdir/../../private.txt' }), fixed: rejects(403) }],
|
||||
},
|
||||
ssrf: {
|
||||
controls: [{ request: get({ url: 'http://127.0.0.1:8001/public' }), response: contains(200, 'PUBLIC_STATUS') }],
|
||||
attack: get({ url: 'http://127.0.0.1:8001/private' }), vulnerable: contains(200, 'INTERNAL_SERVICE_SECRET'), fixed: rejects(403),
|
||||
heldOut: [{ request: get({ url: 'http://localhost:8001/private' }), fixed: rejects(403) }],
|
||||
},
|
||||
'object-authorization': {
|
||||
controls: [{ request: get({ id: 'invoice-1' }), response: value => value.status === 200 && jsonValue(value)?.total === 12 }],
|
||||
attack: get({ id: 'invoice-2' }), vulnerable: value => value.status === 200 && jsonValue(value)?.total === 9800, fixed: rejects(403),
|
||||
heldOut: [{ request: get({ id: 'invoice-2', owner: 'member-1' }), fixed: rejects(403) }],
|
||||
},
|
||||
'tenant-isolation': {
|
||||
controls: [{ request: get({ tenant: 'tenant-a' }), response: contains(200, 'OWN_TENANT_DOCUMENT') }],
|
||||
attack: get({ tenant: 'tenant-b' }), vulnerable: contains(200, 'OTHER_TENANT_SECRET'),
|
||||
fixed: value => value.status === 200 && value.body.includes('OWN_TENANT_DOCUMENT') && !value.body.includes('OTHER_TENANT_SECRET'),
|
||||
heldOut: [{ request: get({ tenant: 'tenant-b', actor_tenant: 'tenant-b' }), fixed: value => value.status === 200 && !value.body.includes('OTHER_TENANT_SECRET') }],
|
||||
},
|
||||
'html-injection': {
|
||||
controls: [{ request: get({ name: 'Alice' }), response: contains(200, '<main>Welcome Alice</main>') }],
|
||||
attack: get({ name: '<img src=x onerror="window.CSO_XSS=1">' }), vulnerable: contains(200, '<img src=x onerror='),
|
||||
fixed: value => value.status === 200 && !value.body.includes('<img') && value.body.includes('<img'),
|
||||
heldOut: [{ request: get({ name: '</main><svg onload="window.ORACLE_XSS=1">' }), fixed: value => value.status === 200 && !value.body.includes('<svg') && value.body.includes('<svg') }],
|
||||
},
|
||||
'open-redirect': {
|
||||
controls: [{ request: get({ next: '/account' }), response: value => value.status === 302 && jsonValue(value)?.next === '/account' && value.headers?.location === '/account' }],
|
||||
attack: get({ next: 'https://attacker.invalid/collect' }), vulnerable: value => value.status === 302 && value.headers?.location === 'https://attacker.invalid/collect', fixed: rejects(400),
|
||||
heldOut: [{ request: get({ next: '//attacker.invalid/collect' }), fixed: rejects(400) }, { request: get({ next: '/\\attacker.invalid/collect' }), fixed: rejects(400) }],
|
||||
},
|
||||
'mass-assignment': {
|
||||
controls: [{ request: post({ display_name: 'Ada' }), response: value => value.status === 200 && jsonValue(value)?.display_name === 'Ada' }],
|
||||
attack: post({ role: 'admin' }), vulnerable: value => value.status === 200 && jsonValue(value)?.role === 'admin', fixed: value => value.status === 200 && jsonValue(value)?.role === 'member',
|
||||
heldOut: [{ request: post({ id: 'member-2', role: 'owner', display_name: 'Grace' }), fixed: value => value.status === 200 && jsonValue(value)?.id === 'member-1' && jsonValue(value)?.role === 'member' && jsonValue(value)?.display_name === 'Grace' }],
|
||||
},
|
||||
'resource-exhaustion': {
|
||||
controls: [{ request: get({ count: '5' }), response: value => value.status === 200 && jsonValue(value)?.count === 5 }],
|
||||
// Bounded proof of missing admission, not an attempt to exhaust the runner.
|
||||
attack: get({ count: '250' }), vulnerable: value => value.status === 200 && jsonValue(value)?.count === 250, fixed: rejects(400),
|
||||
heldOut: [{ request: get({ count: '101' }), fixed: rejects(400) }, { request: get({ count: '0' }), fixed: rejects(400) }],
|
||||
},
|
||||
};
|
||||
return { version: CORPUS_VERSION, ...input[family] };
|
||||
}
|
||||
|
||||
export interface Observation { request: OracleRequest; response: OracleResponse }
|
||||
export interface PrivateEvidence {
|
||||
original: { booted: boolean; controls: Observation[]; attack: Observation };
|
||||
patched: { booted: boolean; controls: Observation[]; attack: Observation; heldOut: Observation[]; existingTestsPassed: boolean };
|
||||
immutableVerifier: boolean;
|
||||
independentRootCauseReview: boolean;
|
||||
featurePreserved: boolean;
|
||||
boundaryMocks: boolean;
|
||||
}
|
||||
const sameRequest = (left: OracleRequest, right: OracleRequest) => JSON.stringify(left) === JSON.stringify(right);
|
||||
/** Only trusted runner observations may enter this function; producer claims are not observations. */
|
||||
export function judgeRepair(family: EvalFamily, evidence: PrivateEvidence): { reproduced: boolean; correctRepair: boolean; evidenceHash: string } {
|
||||
const oracle = oracleFor(family);
|
||||
const controls = (observations: Observation[]) => oracle.controls.every(control => observations.some(observation => sameRequest(control.request, observation.request) && control.response(observation.response)));
|
||||
const original = evidence.original, patched = evidence.patched;
|
||||
const reproduced = original.booted && controls(original.controls) && sameRequest(original.attack.request, oracle.attack) && oracle.vulnerable(original.attack.response);
|
||||
const correctRepair = reproduced && patched.booted && controls(patched.controls) && sameRequest(patched.attack.request, oracle.attack) && oracle.fixed(patched.attack.response)
|
||||
&& oracle.heldOut.every(assertion => patched.heldOut.some(observation => sameRequest(assertion.request, observation.request) && assertion.fixed(observation.response)))
|
||||
&& patched.existingTestsPassed && evidence.immutableVerifier && evidence.independentRootCauseReview && evidence.featurePreserved && !evidence.boundaryMocks;
|
||||
return { reproduced, correctRepair, evidenceHash: createHash('sha256').update(JSON.stringify({ version: oracle.version, family, evidence })).digest('hex') };
|
||||
}
|
||||
export function runtimeStart(stack: EvalStack): { executable: string; args: string[]; port: 8000; environment: Record<string, string> } {
|
||||
return stack === 'node' ? { executable: '/usr/local/bin/node', args: ['app.mjs'], port: 8000, environment: {} }
|
||||
: stack === 'bun' ? { executable: '/usr/local/bin/bun', args: ['--no-install', 'app.ts'], port: 8000, environment: {} }
|
||||
: stack === 'python' ? { executable: '/usr/local/bin/python', args: ['-I', 'app.py'], port: 8000, environment: {} }
|
||||
: { executable: '/usr/local/bin/ruby', args: ['bin/rails', 'server', '-e', 'test', '-b', '127.0.0.1', '-p', '8000'], port: 8000, environment: { RAILS_ENV: 'test', RACK_ENV: 'test' } };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { dispatchCsoCommand, type CsoCliDependencies } from '../../lib/cso/cli';
|
||||
import { canonical, sha256 } from '../../lib/cso/contracts';
|
||||
import { validateRuntimeCatalog, type QualifiedRuntime, type RuntimeCatalog, type RuntimePlatform } from '../../lib/cso/runtime-catalog';
|
||||
import { completeRuntimeCatalogFixture } from './cso-runtime-catalog';
|
||||
|
||||
export interface QualifiedCsoCli {
|
||||
readonly catalog: RuntimeCatalog;
|
||||
readonly runtime: QualifiedRuntime;
|
||||
command<T = unknown>(args: string[]): Promise<T>;
|
||||
}
|
||||
|
||||
/** Bind a staged Node image to the real command dispatcher without adding a production override. */
|
||||
export function qualifiedNodeCli(options: {
|
||||
image: string;
|
||||
versions: Record<string, string>;
|
||||
watchdogPath: string;
|
||||
platform: RuntimePlatform;
|
||||
}): QualifiedCsoCli {
|
||||
if (!path.isAbsolute(options.watchdogPath) || !fs.statSync(options.watchdogPath).isFile()) {
|
||||
throw new Error('Node lifecycle qualification requires an absolute compiled watchdog path');
|
||||
}
|
||||
for (const key of ['node', 'npm', 'cso-preparation']) {
|
||||
if (!/^\d+\.\d+\.\d+$/.test(options.versions[key] ?? '')) {
|
||||
throw new Error(`Node lifecycle qualification requires exact ${key} version metadata`);
|
||||
}
|
||||
}
|
||||
if (options.versions['cso-preparation'] !== '1.0.0') {
|
||||
throw new Error('Node lifecycle qualification requires cso-preparation 1.0.0');
|
||||
}
|
||||
|
||||
const catalog = completeRuntimeCatalogFixture('node-lifecycle-fixture');
|
||||
const runtimeIndex = catalog.runtimes.findIndex(item => item.stack === 'node' && item.platform === options.platform);
|
||||
const profile = catalog.profiles.find(item => item.stack === 'node' && item.platform === options.platform);
|
||||
if (runtimeIndex < 0 || !profile || !catalog.promotion) throw new Error('Node lifecycle catalog fixture is incomplete');
|
||||
const runtime: QualifiedRuntime = {
|
||||
...catalog.runtimes[runtimeIndex],
|
||||
image: options.image,
|
||||
versions: { ...options.versions },
|
||||
};
|
||||
catalog.runtimes[runtimeIndex] = runtime;
|
||||
profile.versions = { ...runtime.versions };
|
||||
catalog.promotion.evidenceDigest = `sha256:${sha256(canonical(catalog.runtimes))}`;
|
||||
validateRuntimeCatalog(catalog);
|
||||
|
||||
const dependencies: CsoCliDependencies = Object.freeze({
|
||||
runtimeCatalog: catalog,
|
||||
watchdogPath: () => options.watchdogPath,
|
||||
});
|
||||
return Object.freeze({
|
||||
catalog,
|
||||
runtime,
|
||||
async command<T = unknown>(args: string[]): Promise<T> {
|
||||
const [command, ...commandArgs] = args;
|
||||
if (!command) throw new Error('CSO qualification command is required');
|
||||
return await dispatchCsoCommand(command, commandArgs, dependencies) as T;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { CsoStack } from '../../lib/cso/preparation';
|
||||
import { canonical, sha256 } from '../../lib/cso/contracts';
|
||||
import {
|
||||
CSO_HELPER_ABI,
|
||||
type QualifiedRuntime,
|
||||
type RuntimeCatalog,
|
||||
type RuntimePlatform,
|
||||
} from '../../lib/cso/runtime-catalog';
|
||||
|
||||
const STACKS = ['node', 'bun', 'python', 'rails', 'postgresql'] as const;
|
||||
const PLATFORMS = ['linux/amd64', 'linux/arm64'] as const;
|
||||
const SOURCE_COMMIT = 'b'.repeat(40);
|
||||
const WORKFLOW = 'https://github.com/garrytan/gstack/actions/runs/1';
|
||||
const DIGEST = `sha256:${'a'.repeat(64)}`;
|
||||
|
||||
const VERSIONS: Record<CsoStack | 'postgresql', Record<string, string>> = {
|
||||
node: { node: '24.1.0', npm: '11.3.0', 'cso-preparation': '1.0.0' },
|
||||
bun: { bun: '1.3.10', 'cso-preparation': '1.0.0' },
|
||||
python: { python: '3.12.9', uv: '0.8.0', 'cso-preparation': '1.0.0' },
|
||||
rails: { ruby: '3.3.6', bundler: '2.6.9', 'cso-preparation': '1.0.0' },
|
||||
postgresql: { postgresql: '17.2' },
|
||||
};
|
||||
|
||||
function runtimeId(stack: CsoStack | 'postgresql', platform: RuntimePlatform): string {
|
||||
return `${stack}-qualified-test-${platform === 'linux/amd64' ? 'amd64' : 'arm64'}`;
|
||||
}
|
||||
|
||||
export function qualifiedRuntimeFixture(
|
||||
stack: CsoStack | 'postgresql',
|
||||
platform: RuntimePlatform = 'linux/amd64',
|
||||
): QualifiedRuntime {
|
||||
const arch = platform === 'linux/amd64' ? 'amd64' : 'arm64';
|
||||
const qualification = stack === 'postgresql'
|
||||
? {
|
||||
kind: 'postgresql' as const,
|
||||
sourceCommit: SOURCE_COMMIT,
|
||||
workflow: WORKFLOW,
|
||||
sbomDigest: DIGEST,
|
||||
provenanceDigest: DIGEST,
|
||||
verifiedProvenance: true as const,
|
||||
containmentPassed: true as const,
|
||||
coldStartPassed: true as const,
|
||||
multiDatabasePassed: true as const,
|
||||
readinessPassed: true as const,
|
||||
}
|
||||
: {
|
||||
kind: 'application' as const,
|
||||
sourceCommit: SOURCE_COMMIT,
|
||||
workflow: WORKFLOW,
|
||||
sbomDigest: DIGEST,
|
||||
provenanceDigest: DIGEST,
|
||||
verifiedProvenance: true as const,
|
||||
containmentPassed: true as const,
|
||||
coldStartPassed: true as const,
|
||||
positiveNegativeAssertionsPassed: true as const,
|
||||
heldOutRepairPassed: true as const,
|
||||
};
|
||||
|
||||
return {
|
||||
id: runtimeId(stack, platform),
|
||||
stack,
|
||||
platform,
|
||||
state: 'qualified',
|
||||
image: `ghcr.io/garrytan/gstack/cso-staging/${stack}-${arch}@${DIGEST}`,
|
||||
entrypoint: '/opt/cso/entrypoint',
|
||||
helperAbi: CSO_HELPER_ABI,
|
||||
versions: { ...VERSIONS[stack] },
|
||||
policyVersion: 'cso-isolation-v1',
|
||||
qualifiedAt: '2026-09-09T00:00:00Z',
|
||||
qualification,
|
||||
};
|
||||
}
|
||||
|
||||
/** A fresh catalog satisfying the complete reviewed and promoted runtime matrices. */
|
||||
export function completeRuntimeCatalogFixture(
|
||||
revision = 'runtime-test-v1',
|
||||
previousRevision: string | null = null,
|
||||
): RuntimeCatalog {
|
||||
const runtimes = STACKS.flatMap(stack => PLATFORMS.map(platform => qualifiedRuntimeFixture(stack, platform)));
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
revision,
|
||||
previousRevision,
|
||||
helperAbi: CSO_HELPER_ABI,
|
||||
buildRevision: 'runtime-test-build-v1',
|
||||
profiles: runtimes.map(runtime => ({
|
||||
id: runtime.id,
|
||||
stack: runtime.stack,
|
||||
platform: runtime.platform,
|
||||
state: 'build_reviewed' as const,
|
||||
versions: { ...runtime.versions },
|
||||
reviewedAt: '2026-09-08T00:00:00Z',
|
||||
})),
|
||||
promotion: {
|
||||
sourceCommit: SOURCE_COMMIT,
|
||||
workflow: WORKFLOW,
|
||||
evidenceDigest: `sha256:${sha256(canonical(runtimes))}`,
|
||||
qualificationEvidenceDigest: DIGEST,
|
||||
},
|
||||
runtimes,
|
||||
};
|
||||
}
|
||||
@@ -241,7 +241,9 @@ const CARVED_INVARIANTS: ParityInvariant[] = Object.values(CARVE_GUARDS).map((g)
|
||||
maxSkeletonBytes: g.maxSkeletonBytes,
|
||||
minBytes: g.minUnionBytes,
|
||||
mustContain: g.mustContain,
|
||||
mustHaveHeadings: ['## Preamble', '## When to invoke'],
|
||||
// CSO's helper trust boundary requires its private startup; demanding the
|
||||
// shared Preamble here would silently reintroduce conflicting policy.
|
||||
mustHaveHeadings: g.skill === 'cso' ? ['## When to invoke'] : ['## Preamble', '## When to invoke'],
|
||||
maxSizeRatio: g.maxSizeRatio ?? 1.05,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import type { ProviderAdapter, RunOpts, RunResult, AvailabilityCheck } from './types';
|
||||
import {
|
||||
csoProducerChildEnvironment,
|
||||
csoProducerHelperHome,
|
||||
csoProducerHelperLauncher,
|
||||
csoProducerProviderCommand,
|
||||
csoProducerSourceDirectory,
|
||||
csoProducerStateDirectory,
|
||||
type ProviderAdapter,
|
||||
type RunOpts,
|
||||
type RunResult,
|
||||
type AvailabilityCheck,
|
||||
} from './types';
|
||||
import { estimateCostUsd } from '../pricing';
|
||||
import { execFileSync, spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
@@ -19,10 +30,11 @@ export class ClaudeAdapter implements ProviderAdapter {
|
||||
readonly name = 'claude';
|
||||
readonly family = 'claude' as const;
|
||||
|
||||
async available(): Promise<AvailabilityCheck> {
|
||||
async available(opts?: RunOpts): Promise<AvailabilityCheck> {
|
||||
// Binary on PATH (or GSTACK_CLAUDE_BIN override). Routes through the shared
|
||||
// resolver so Windows + override paths behave the same as production sites.
|
||||
const resolved = resolveClaudeCommand();
|
||||
const producerCommand = csoProducerProviderCommand(opts ?? { prompt: '', workdir: '/', timeoutMs: 1 });
|
||||
const resolved = producerCommand ? { command: producerCommand.executable, argsPrefix: producerCommand.argsPrefix } : resolveClaudeCommand();
|
||||
if (!resolved) {
|
||||
return { ok: false, reason: 'claude CLI not found on PATH. Install from https://claude.ai/download or npm i -g @anthropic-ai/claude-code (or set GSTACK_CLAUDE_BIN)' };
|
||||
}
|
||||
@@ -35,8 +47,8 @@ export class ClaudeAdapter implements ProviderAdapter {
|
||||
// secret), and any failure of `security` itself falls through to the
|
||||
// not-found reason rather than throwing.
|
||||
const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
const hasCreds = fs.existsSync(credsPath);
|
||||
const hasKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
const hasCreds = !opts?.csoProducer && fs.existsSync(credsPath);
|
||||
const hasKey = !!(process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN);
|
||||
let hasKeychain = false;
|
||||
if (!hasCreds && !hasKey && process.platform === 'darwin') {
|
||||
try {
|
||||
@@ -50,41 +62,40 @@ export class ClaudeAdapter implements ProviderAdapter {
|
||||
}
|
||||
}
|
||||
if (!hasCreds && !hasKey && !hasKeychain) {
|
||||
return { ok: false, reason: 'No Claude auth found. Log in via `claude` interactive session, or export ANTHROPIC_API_KEY.' };
|
||||
return { ok: false, reason: opts?.csoProducer
|
||||
? 'No Claude producer auth found. Export ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN; macOS Keychain auth is also supported.'
|
||||
: 'No Claude auth found. Log in via `claude` interactive session, or export ANTHROPIC_API_KEY.' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async run(opts: RunOpts): Promise<RunResult> {
|
||||
const start = Date.now();
|
||||
const resolved = resolveClaudeCommand();
|
||||
const producerCommand = csoProducerProviderCommand(opts);
|
||||
const resolved = producerCommand ? { command: producerCommand.executable, argsPrefix: producerCommand.argsPrefix } : resolveClaudeCommand();
|
||||
if (!resolved) {
|
||||
throw new Error('claude CLI not resolvable (set GSTACK_CLAUDE_BIN or install)');
|
||||
}
|
||||
const model = opts.model ?? process.env.EVALS_MODEL ?? resolveEvalModel('capture');
|
||||
const args = [...resolved.argsPrefix, '-p', '--output-format', 'json'];
|
||||
args.push('--model', model);
|
||||
if (opts.extraArgs) args.push(...opts.extraArgs);
|
||||
const stateDirectory = csoProducerStateDirectory(opts);
|
||||
if (stateDirectory) {
|
||||
assertClaudeProducerPrerequisites();
|
||||
}
|
||||
|
||||
try {
|
||||
if (stateDirectory) prepareClaudeProducerState(stateDirectory);
|
||||
const args = claudeExecArgs(opts, model, resolved.argsPrefix);
|
||||
const out = execFileSync(resolved.command, args, {
|
||||
input: opts.prompt,
|
||||
cwd: opts.workdir,
|
||||
cwd: claudeExecWorkingDirectory(opts),
|
||||
timeout: opts.timeoutMs,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
// Default GSTACK_HEADLESS=1 so a benchmark run classifies as headless (an
|
||||
// AskUserQuestion failure BLOCKs rather than emitting unanswerable prose).
|
||||
env: { ...process.env, GSTACK_HEADLESS: '1' },
|
||||
env: claudeExecEnvironment(opts, process.env),
|
||||
});
|
||||
const parsed = this.parseOutput(out);
|
||||
return {
|
||||
output: parsed.output,
|
||||
tokens: parsed.tokens,
|
||||
durationMs: Date.now() - start,
|
||||
toolCalls: parsed.toolCalls,
|
||||
modelUsed: parsed.modelUsed || model,
|
||||
};
|
||||
return resultFromClaudeOutput(out,{model,durationMs:Date.now()-start,producer:!!opts.csoProducer});
|
||||
} catch (err: unknown) {
|
||||
const durationMs = Date.now() - start;
|
||||
const e = err as { code?: string; stderr?: Buffer; signal?: string; message?: string };
|
||||
@@ -99,6 +110,8 @@ export class ClaudeAdapter implements ProviderAdapter {
|
||||
return this.emptyResult(durationMs, { code: 'rate_limit', reason: stderr.slice(0, 400) }, model);
|
||||
}
|
||||
return this.emptyResult(durationMs, { code: 'unknown', reason: (e.message ?? stderr ?? 'unknown').slice(0, 400) }, model);
|
||||
} finally {
|
||||
if (stateDirectory) removeClaudeProducerState(stateDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,33 +119,6 @@ export class ClaudeAdapter implements ProviderAdapter {
|
||||
return estimateCostUsd(tokens, model ?? resolveEvalModel('capture'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse claude -p --output-format json output. Shape (as of 2026-04):
|
||||
* { type: "result", result: "<assistant text>", usage: { input_tokens, output_tokens, ... },
|
||||
* num_turns, session_id, ... }
|
||||
* Older formats may differ — adapter is best-effort.
|
||||
*/
|
||||
private parseOutput(raw: string): { output: string; tokens: { input: number; output: number; cached?: number }; toolCalls: number; modelUsed?: string } {
|
||||
try {
|
||||
const obj = JSON.parse(raw);
|
||||
const result = typeof obj.result === 'string' ? obj.result : String(obj.result ?? '');
|
||||
const u = obj.usage ?? {};
|
||||
return {
|
||||
output: result,
|
||||
tokens: {
|
||||
input: u.input_tokens ?? 0,
|
||||
output: u.output_tokens ?? 0,
|
||||
cached: u.cache_read_input_tokens,
|
||||
},
|
||||
toolCalls: obj.num_turns ?? 0,
|
||||
modelUsed: obj.model,
|
||||
};
|
||||
} catch {
|
||||
// Non-JSON output: treat as plain text.
|
||||
return { output: raw, tokens: { input: 0, output: 0 }, toolCalls: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
private emptyResult(durationMs: number, error: RunResult['error'], model?: string): RunResult {
|
||||
return {
|
||||
output: '',
|
||||
@@ -144,3 +130,99 @@ export class ClaudeAdapter implements ProviderAdapter {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Map Claude JSON output; paid producers require the documented nonblank `result` envelope. */
|
||||
export function resultFromClaudeOutput(raw:string,opts:{model?:string;durationMs?:number;producer?:boolean}={}):RunResult{
|
||||
const durationMs=opts.durationMs??0,defaultModel=opts.model??resolveEvalModel('capture');
|
||||
try{
|
||||
const obj=JSON.parse(raw),explicitError=obj?.is_error===true||(typeof obj?.subtype==='string'&&obj.subtype!=='success'),validResult=!explicitError&&typeof obj?.result==='string'&&obj.result.trim().length>0;
|
||||
if(opts.producer&&!validResult)return{output:'',tokens:{input:0,output:0},durationMs,toolCalls:0,modelUsed:typeof obj?.model==='string'&&obj.model?obj.model:defaultModel,error:{code:'unknown',reason:'empty or invalid output from claude CLI (exit 0)'}};
|
||||
const output=typeof obj?.result==='string'?obj.result:String(obj?.result??''),usage=obj?.usage??{};
|
||||
return{output,tokens:{input:usage.input_tokens??0,output:usage.output_tokens??0,cached:usage.cache_read_input_tokens},durationMs,toolCalls:obj?.num_turns??0,modelUsed:typeof obj?.model==='string'&&obj.model?obj.model:defaultModel};
|
||||
}catch{
|
||||
if(opts.producer)return{output:'',tokens:{input:0,output:0},durationMs,toolCalls:0,modelUsed:defaultModel,error:{code:'unknown',reason:'empty or invalid output from claude CLI (exit 0)'}};
|
||||
return{output:raw,tokens:{input:0,output:0},durationMs,toolCalls:0,modelUsed:defaultModel};
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClaudeProducerPaths { root: string; home: string; workdir: string }
|
||||
|
||||
export function assertClaudeProducerPrerequisites(): void {
|
||||
if (process.platform !== 'linux') return;
|
||||
const executable = '/usr/bin/bwrap';
|
||||
let stat: fs.Stats;
|
||||
try { stat = fs.lstatSync(executable); } catch { throw new Error('CLAUDE_PRODUCER_REQUIRES_TRUSTED_BWRAP'); }
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || fs.realpathSync(executable) !== executable || stat.uid !== 0 || (stat.mode & 0o022) !== 0 || (stat.mode & 0o111) === 0) {
|
||||
throw new Error('CLAUDE_PRODUCER_REQUIRES_TRUSTED_BWRAP');
|
||||
}
|
||||
}
|
||||
|
||||
export function claudeProducerPaths(stateDirectory: string): ClaudeProducerPaths {
|
||||
const root = path.join(stateDirectory, 'claude-provider');
|
||||
return { root, home: path.join(root, 'home'), workdir: path.join(root, 'work') };
|
||||
}
|
||||
|
||||
export function prepareClaudeProducerState(stateDirectory: string): ClaudeProducerPaths {
|
||||
const paths = claudeProducerPaths(stateDirectory);
|
||||
if (fs.existsSync(paths.root)) throw new Error('CLAUDE_PRODUCER_STATE_EXISTS');
|
||||
fs.mkdirSync(paths.root, { mode: 0o700 });
|
||||
fs.mkdirSync(paths.home, { mode: 0o700 });
|
||||
fs.mkdirSync(paths.workdir, { mode: 0o700 });
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function removeClaudeProducerState(stateDirectory: string): void {
|
||||
fs.rmSync(claudeProducerPaths(stateDirectory).root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export function claudeExecWorkingDirectory(opts: RunOpts): string {
|
||||
const stateDirectory = csoProducerStateDirectory(opts);
|
||||
return stateDirectory ? claudeProducerPaths(stateDirectory).workdir : opts.workdir;
|
||||
}
|
||||
|
||||
export function claudeProducerTools(opts: RunOpts): string {
|
||||
const launcher = csoProducerHelperLauncher(opts);
|
||||
if (!launcher) throw new Error('CSO producer helper launcher is required');
|
||||
return [`Bash(${launcher})`, `Bash(${launcher} *)`, 'Write'].join(',');
|
||||
}
|
||||
|
||||
export function claudeExecArgs(opts: RunOpts, model: string, argsPrefix: readonly string[] = []): string[] {
|
||||
const args = [...argsPrefix, '-p', '--output-format', 'json', '--model', model];
|
||||
const stateDirectory = csoProducerStateDirectory(opts);
|
||||
if (stateDirectory) {
|
||||
if (opts.extraArgs?.length) throw new Error('CSO producer does not accept extra provider arguments');
|
||||
const tools = claudeProducerTools(opts);
|
||||
const sourceDirectory = csoProducerSourceDirectory(opts)!;
|
||||
const helperHome = csoProducerHelperHome(opts)!;
|
||||
args.push(
|
||||
'--restricted',
|
||||
'--safe-mode',
|
||||
'--no-session-persistence',
|
||||
'--permission-prompts', 'none',
|
||||
'--permission-mode', 'dontAsk',
|
||||
'--tools', 'Bash,Write',
|
||||
'--allowed-tools', tools,
|
||||
'--add-dir', claudeProducerPaths(stateDirectory).workdir,
|
||||
'--add-dir', sourceDirectory,
|
||||
'--add-dir', helperHome,
|
||||
'--strict-mcp-config',
|
||||
'--no-chrome',
|
||||
);
|
||||
}
|
||||
if (opts.extraArgs) args.push(...opts.extraArgs);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function claudeExecEnvironment(
|
||||
opts: RunOpts,
|
||||
source: NodeJS.ProcessEnv = process.env,
|
||||
): Record<string, string> {
|
||||
const stateDirectory = csoProducerStateDirectory(opts);
|
||||
const paths = stateDirectory ? claudeProducerPaths(stateDirectory) : undefined;
|
||||
return {
|
||||
...(opts.csoProducer ? csoProducerChildEnvironment('claude', source) : source),
|
||||
...(paths ? { HOME: paths.home, GSTACK_HOME: csoProducerHelperHome(opts)! } : {}),
|
||||
...(stateDirectory ? { CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1' } : {}),
|
||||
GSTACK_HEADLESS: '1',
|
||||
} as Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import type { ProviderAdapter, RunOpts, RunResult, AvailabilityCheck } from './types';
|
||||
import {
|
||||
csoProducerChildEnvironment,
|
||||
csoProducerHelperHome,
|
||||
csoProducerHelperLauncher,
|
||||
csoProducerProviderCommand,
|
||||
csoProducerStateDirectory,
|
||||
validateCsoProducerStateDirectory,
|
||||
type ProviderAdapter,
|
||||
type RunOpts,
|
||||
type RunResult,
|
||||
type AvailabilityCheck,
|
||||
} from './types';
|
||||
import { estimateCostUsd } from '../pricing';
|
||||
import { execFileSync, spawnSync } from 'child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { atomicWriteSync } from '../../../lib/fs-atomic';
|
||||
|
||||
export type GeminiStreamParse = {
|
||||
output: string;
|
||||
@@ -112,15 +125,16 @@ export class GeminiAdapter implements ProviderAdapter {
|
||||
readonly name = 'gemini';
|
||||
readonly family = 'gemini' as const;
|
||||
|
||||
async available(): Promise<AvailabilityCheck> {
|
||||
const res = spawnSync('sh', ['-c', 'command -v gemini'], { timeout: 2000 });
|
||||
async available(opts?: RunOpts): Promise<AvailabilityCheck> {
|
||||
const producerCommand = csoProducerProviderCommand(opts ?? { prompt: '', workdir: '/', timeoutMs: 1 });
|
||||
const res = producerCommand ? { status: 0 } : spawnSync('sh', ['-c', 'command -v gemini'], { timeout: 2000 });
|
||||
if (res.status !== 0) {
|
||||
return { ok: false, reason: 'gemini CLI not found on PATH. Install per https://github.com/google-gemini/gemini-cli' };
|
||||
}
|
||||
const legacyCfgDir = path.join(os.homedir(), '.config', 'gemini');
|
||||
const newCfgDir = path.join(os.homedir(), '.gemini');
|
||||
const newOauth = path.join(newCfgDir, 'oauth_creds.json');
|
||||
const hasCfg = fs.existsSync(legacyCfgDir) || fs.existsSync(newOauth);
|
||||
const hasCfg = !opts?.csoProducer && (fs.existsSync(legacyCfgDir) || fs.existsSync(newOauth));
|
||||
// CLI accepts either name; Google AI Studio keys are usually GEMINI_API_KEY.
|
||||
const hasKey = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY);
|
||||
if (!hasCfg && !hasKey) {
|
||||
@@ -138,23 +152,15 @@ export class GeminiAdapter implements ProviderAdapter {
|
||||
// Default to --yolo (non-interactive) and stream-json output so we can parse
|
||||
// tokens + tool calls. Callers can override via extraArgs. (--skip-trust was
|
||||
// removed in gemini-cli 0.34; passing it errors at argv parse.)
|
||||
const args = ['-p', opts.prompt, '--output-format', 'stream-json', '--yolo'];
|
||||
if (opts.model) args.push('--model', opts.model);
|
||||
if (opts.extraArgs) args.push(...opts.extraArgs);
|
||||
|
||||
try {
|
||||
const out = execFileSync('gemini', args, {
|
||||
cwd: opts.workdir,
|
||||
const args = geminiExecArgs(opts);
|
||||
const command = csoProducerProviderCommand(opts);
|
||||
const out = execFileSync(command?.executable ?? 'gemini', [...(command?.argsPrefix ?? []), ...args], {
|
||||
cwd: geminiExecWorkingDirectory(opts),
|
||||
timeout: opts.timeoutMs,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
// Prefer GEMINI_API_KEY when only that is set (CLI reads both).
|
||||
...(process.env.GEMINI_API_KEY && !process.env.GOOGLE_API_KEY
|
||||
? { GOOGLE_API_KEY: process.env.GEMINI_API_KEY }
|
||||
: {}),
|
||||
},
|
||||
env: geminiExecEnvironment(opts, process.env),
|
||||
});
|
||||
return resultFromGeminiStream(out, { model: opts.model, durationMs: Date.now() - start });
|
||||
} catch (err: unknown) {
|
||||
@@ -189,3 +195,124 @@ export class GeminiAdapter implements ProviderAdapter {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface GeminiProducerPaths {
|
||||
root: string;
|
||||
home: string;
|
||||
workdir: string;
|
||||
systemDefaults: string;
|
||||
systemSettings: string;
|
||||
}
|
||||
|
||||
export function geminiProducerPaths(stateDirectory: string): GeminiProducerPaths {
|
||||
validateCsoProducerStateDirectory(stateDirectory);
|
||||
const root = path.join(stateDirectory, 'gemini-provider');
|
||||
return {
|
||||
root,
|
||||
home: path.join(root, 'home'),
|
||||
workdir: path.join(root, 'work'),
|
||||
systemDefaults: path.join(root, 'system-defaults.json'),
|
||||
systemSettings: path.join(root, 'system-settings.json'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Highest-precedence Gemini policy for a clean, one-cell producer host. */
|
||||
const GEMINI_PRODUCER_BLOCKED_ENVIRONMENT = [
|
||||
'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION',
|
||||
] as const;
|
||||
|
||||
export function geminiProducerSystemSettings(contextFileName: string, helperLauncher: string) {
|
||||
if (!/^\.gstack-cso-context-[a-f0-9]{32}\.md$/.test(contextFileName)) throw new Error('INVALID_GEMINI_CONTEXT_FILENAME');
|
||||
const helper = csoProducerHelperLauncher({ prompt: '', workdir: '/', timeoutMs: 1, csoProducer: { stateDirectory: '/', sourceDirectory: '/source', helperLauncher, helperGeneration: path.join(path.dirname(helperLauncher), '.gstack-cso-generation'), providerCommand: { executable: '/provider', argsPrefix: [] } } });
|
||||
if (!helper) throw new Error('CSO producer helper launcher is required');
|
||||
const tools = [`run_shell_command(${helper})`, 'write_file'];
|
||||
return {
|
||||
advanced: { ignoreLocalEnv: true },
|
||||
admin: {
|
||||
extensions: { enabled: false },
|
||||
mcp: { enabled: false },
|
||||
skills: { enabled: false },
|
||||
},
|
||||
context: {
|
||||
fileName: [contextFileName],
|
||||
includeDirectoryTree: false,
|
||||
loadMemoryFromIncludeDirectories: false,
|
||||
memoryBoundaryMarkers: [],
|
||||
},
|
||||
hooksConfig: { enabled: false },
|
||||
privacy: { usageStatisticsEnabled: false },
|
||||
security: {
|
||||
environmentVariableRedaction: { allowed: [], blocked: [...GEMINI_PRODUCER_BLOCKED_ENVIRONMENT], enabled: true },
|
||||
folderTrust: { enabled: false },
|
||||
toolSandboxing: false,
|
||||
},
|
||||
skills: { enabled: false },
|
||||
telemetry: { enabled: false, logPrompts: false },
|
||||
tools: { allowed: tools, core: tools, sandbox: false },
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareGeminiProducerState(stateDirectory: string, helperLauncher: string): GeminiProducerPaths {
|
||||
const paths = geminiProducerPaths(stateDirectory);
|
||||
if (fs.existsSync(paths.root)) throw new Error('GEMINI_PRODUCER_STATE_EXISTS');
|
||||
fs.mkdirSync(paths.root, { mode: 0o700 });
|
||||
fs.mkdirSync(paths.home, { mode: 0o700 });
|
||||
fs.mkdirSync(paths.workdir, { mode: 0o700 });
|
||||
const contextFileName = `.gstack-cso-context-${randomBytes(16).toString('hex')}.md`;
|
||||
atomicWriteSync(paths.systemDefaults, '{}\n', { mode: 0o600, noReplace: true });
|
||||
atomicWriteSync(paths.systemSettings, `${JSON.stringify(geminiProducerSystemSettings(contextFileName, helperLauncher), null, 2)}\n`, { mode: 0o600, noReplace: true });
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function removeGeminiProducerState(stateDirectory: string): void {
|
||||
const paths = geminiProducerPaths(stateDirectory);
|
||||
fs.rmSync(paths.root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export function geminiExecArgs(opts: RunOpts): string[] {
|
||||
const stateDirectory = csoProducerStateDirectory(opts);
|
||||
const args = ['-p', opts.prompt, '--output-format', 'stream-json'];
|
||||
if (stateDirectory) {
|
||||
if (opts.extraArgs?.length) throw new Error('CSO producer does not accept extra provider arguments');
|
||||
args.push(
|
||||
'--approval-mode', 'yolo',
|
||||
'--include-directories', geminiProducerPaths(stateDirectory).workdir,
|
||||
'-e', 'none',
|
||||
);
|
||||
} else {
|
||||
args.push('--yolo');
|
||||
}
|
||||
if (opts.model) args.push('--model', opts.model);
|
||||
if (opts.extraArgs) args.push(...opts.extraArgs);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function geminiExecWorkingDirectory(opts: RunOpts): string {
|
||||
const stateDirectory = csoProducerStateDirectory(opts);
|
||||
return stateDirectory ? geminiProducerPaths(stateDirectory).workdir : opts.workdir;
|
||||
}
|
||||
|
||||
export function geminiExecEnvironment(
|
||||
opts: RunOpts,
|
||||
source: NodeJS.ProcessEnv = process.env,
|
||||
): Record<string, string> {
|
||||
const stateDirectory = csoProducerStateDirectory(opts);
|
||||
const paths = stateDirectory ? geminiProducerPaths(stateDirectory) : undefined;
|
||||
const env = paths ? csoProducerChildEnvironment('gemini', source) : { ...source } as Record<string, string>;
|
||||
// Prefer GEMINI_API_KEY when only that is set (CLI reads both).
|
||||
if (env.GEMINI_API_KEY && !env.GOOGLE_API_KEY) env.GOOGLE_API_KEY = env.GEMINI_API_KEY;
|
||||
if (paths) {
|
||||
env.HOME = paths.home;
|
||||
env.GSTACK_HOME = csoProducerHelperHome(opts)!;
|
||||
env.GEMINI_SANDBOX = 'false';
|
||||
env.GEMINI_TELEMETRY_ENABLED = 'false';
|
||||
env.GEMINI_TELEMETRY_LOG_PROMPTS = 'false';
|
||||
env.GEMINI_CLI_TRUST_WORKSPACE = 'true';
|
||||
env.GEMINI_SYSTEM_MD = 'false';
|
||||
env.GEMINI_WRITE_SYSTEM_MD = 'false';
|
||||
env.GEMINI_CLI_HOME = paths.home;
|
||||
env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH = paths.systemDefaults;
|
||||
env.GEMINI_CLI_SYSTEM_SETTINGS_PATH = paths.systemSettings;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
+154
-60
@@ -1,10 +1,23 @@
|
||||
import type { ProviderAdapter, RunOpts, RunResult, AvailabilityCheck } from './types';
|
||||
import {
|
||||
csoProducerChildEnvironment,
|
||||
csoProducerHelperGeneration,
|
||||
csoProducerHelperHome,
|
||||
csoProducerProviderCommand,
|
||||
csoProducerSourceDirectory,
|
||||
csoProducerStateDirectory,
|
||||
CSO_PRODUCER_SHELL_ENV,
|
||||
type ProviderAdapter,
|
||||
type RunOpts,
|
||||
type RunResult,
|
||||
type AvailabilityCheck,
|
||||
} from './types';
|
||||
import { estimateCostUsd } from '../pricing';
|
||||
import { execFileSync, spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { CODEX_FRONTIER_MODEL } from '../../../scripts/resolvers/constants';
|
||||
import { atomicWriteSync } from '../../../lib/fs-atomic';
|
||||
|
||||
/**
|
||||
* GPT adapter — wraps the OpenAI `codex` CLI (codex exec with --json output).
|
||||
@@ -17,45 +30,40 @@ export class GptAdapter implements ProviderAdapter {
|
||||
readonly name = 'gpt';
|
||||
readonly family = 'gpt' as const;
|
||||
|
||||
async available(): Promise<AvailabilityCheck> {
|
||||
const res = spawnSync('sh', ['-c', 'command -v codex'], { timeout: 2000 });
|
||||
async available(opts?: RunOpts): Promise<AvailabilityCheck> {
|
||||
const producerCommand = csoProducerProviderCommand(opts ?? { prompt: '', workdir: '/', timeoutMs: 1 });
|
||||
const res = producerCommand ? { status: 0 } : spawnSync('sh', ['-c', 'command -v codex'], { timeout: 2000 });
|
||||
if (res.status !== 0) {
|
||||
return { ok: false, reason: 'codex CLI not found on PATH. Install: npm i -g @openai/codex' };
|
||||
}
|
||||
// Auth sniff: ~/.codex/ should contain auth state after `codex login`
|
||||
const codexDir = path.join(os.homedir(), '.codex');
|
||||
if (!fs.existsSync(codexDir)) {
|
||||
return { ok: false, reason: 'No ~/.codex/ found. Run `codex login` to authenticate via ChatGPT.' };
|
||||
const hasFileAuth = !opts?.csoProducer && fs.existsSync(codexDir);
|
||||
if (!hasFileAuth && !process.env.OPENAI_API_KEY) {
|
||||
return { ok: false, reason: 'No Codex auth found. Paid CSO producers require OPENAI_API_KEY; other evals may use `codex login`.' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async run(opts: RunOpts): Promise<RunResult> {
|
||||
const start = Date.now();
|
||||
// `-s read-only` is load-bearing safety. With `--skip-git-repo-check` we
|
||||
// bypass codex's interactive trust prompt for unknown directories (benchmarks
|
||||
// often run in temp dirs / non-git paths), so the read-only sandbox is now
|
||||
// the only boundary preventing codex from mutating the workdir. If you ever
|
||||
// remove `-s read-only`, drop `--skip-git-repo-check` too.
|
||||
// Existing callers retain `-s read-only`. CSO producer calls use an isolated
|
||||
// CODEX_HOME with a reviewed permission profile written below.
|
||||
const model = opts.model ?? process.env.GSTACK_CODEX_MODEL ?? CODEX_FRONTIER_MODEL;
|
||||
const args = ['exec', opts.prompt, '-C', opts.workdir, '-s', 'read-only', '--skip-git-repo-check', '--json', '-m', model];
|
||||
if (opts.extraArgs) args.push(...opts.extraArgs);
|
||||
const stateDirectory = csoProducerStateDirectory(opts);
|
||||
|
||||
try {
|
||||
const out = execFileSync('codex', args, {
|
||||
cwd: opts.workdir,
|
||||
if (stateDirectory) prepareCodexProducerState(opts);
|
||||
const args = codexExecArgs(opts, model);
|
||||
const command = csoProducerProviderCommand(opts);
|
||||
const out = execFileSync(command?.executable ?? 'codex', [...(command?.argsPrefix ?? []), ...args], {
|
||||
cwd: codexExecWorkingDirectory(opts),
|
||||
timeout: opts.timeoutMs,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
...(opts.csoProducer ? { env: codexExecEnvironment(opts, process.env) } : {}),
|
||||
});
|
||||
const parsed = this.parseJsonl(out);
|
||||
return {
|
||||
output: parsed.output,
|
||||
tokens: parsed.tokens,
|
||||
durationMs: Date.now() - start,
|
||||
toolCalls: parsed.toolCalls,
|
||||
modelUsed: parsed.modelUsed || model,
|
||||
};
|
||||
return resultFromCodexStream(out,{model,durationMs:Date.now()-start,producer:!!opts.csoProducer});
|
||||
} catch (err: unknown) {
|
||||
const durationMs = Date.now() - start;
|
||||
const e = err as { code?: string; stderr?: Buffer; signal?: string; message?: string };
|
||||
@@ -70,6 +78,8 @@ export class GptAdapter implements ProviderAdapter {
|
||||
return this.emptyResult(durationMs, { code: 'rate_limit', reason: stderr.slice(0, 400) }, model);
|
||||
}
|
||||
return this.emptyResult(durationMs, { code: 'unknown', reason: (e.message ?? stderr ?? 'unknown').slice(0, 400) }, model);
|
||||
} finally {
|
||||
if (stateDirectory) removeCodexProducerState(stateDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,44 +87,6 @@ export class GptAdapter implements ProviderAdapter {
|
||||
return estimateCostUsd(tokens, model ?? CODEX_FRONTIER_MODEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse codex exec --json JSONL stream.
|
||||
* Key events:
|
||||
* - item.completed with item.type === 'agent_message' → text output
|
||||
* - item.completed with item.type === 'command_execution' → tool call
|
||||
* - turn.completed → usage.input_tokens, usage.output_tokens
|
||||
* - thread.started → session id (not used here)
|
||||
*/
|
||||
private parseJsonl(raw: string): { output: string; tokens: { input: number; output: number }; toolCalls: number; modelUsed?: string } {
|
||||
let output = '';
|
||||
let input = 0;
|
||||
let out = 0;
|
||||
let toolCalls = 0;
|
||||
let modelUsed: string | undefined;
|
||||
for (const line of raw.split('\n')) {
|
||||
const s = line.trim();
|
||||
if (!s) continue;
|
||||
try {
|
||||
const obj = JSON.parse(s);
|
||||
if (obj.type === 'item.completed' && obj.item) {
|
||||
if (obj.item.type === 'agent_message' && typeof obj.item.text === 'string') {
|
||||
output += (output ? '\n' : '') + obj.item.text;
|
||||
} else if (obj.item.type === 'command_execution') {
|
||||
toolCalls += 1;
|
||||
}
|
||||
} else if (obj.type === 'turn.completed') {
|
||||
const u = obj.usage ?? {};
|
||||
input += u.input_tokens ?? 0;
|
||||
out += u.output_tokens ?? 0;
|
||||
if (obj.model) modelUsed = obj.model;
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines — codex stderr can leak in
|
||||
}
|
||||
}
|
||||
return { output, tokens: { input, output: out }, toolCalls, modelUsed };
|
||||
}
|
||||
|
||||
private emptyResult(durationMs: number, error: RunResult['error'], model?: string): RunResult {
|
||||
return {
|
||||
output: '',
|
||||
@@ -126,3 +98,125 @@ export class GptAdapter implements ProviderAdapter {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Map `codex exec --json` output while requiring evidence-bearing text for paid producers. */
|
||||
export function resultFromCodexStream(raw:string,opts:{model?:string;durationMs?:number;producer?:boolean}={}):RunResult{
|
||||
let output='',input=0,out=0,toolCalls=0,modelUsed:string|undefined;
|
||||
for(const line of raw.split('\n')){
|
||||
const text=line.trim();if(!text)continue;
|
||||
try{
|
||||
const obj=JSON.parse(text);
|
||||
if(obj.type==='item.completed'&&obj.item){
|
||||
if(obj.item.type==='agent_message'&&typeof obj.item.text==='string')output+=(output?'\n':'')+obj.item.text;
|
||||
else if(obj.item.type==='command_execution')toolCalls++;
|
||||
}else if(obj.type==='turn.completed'){
|
||||
const usage=obj.usage??{};input+=usage.input_tokens??0;out+=usage.output_tokens??0;
|
||||
if(typeof obj.model==='string'&&obj.model)modelUsed=obj.model;
|
||||
}
|
||||
}catch{/* Codex can mix diagnostic text into the JSONL stream. */}
|
||||
}
|
||||
const durationMs=opts.durationMs??0,resolvedModel=modelUsed||opts.model||CODEX_FRONTIER_MODEL;
|
||||
if(opts.producer&&!output.trim())return{output:'',tokens:{input:0,output:0},durationMs,toolCalls:0,modelUsed:resolvedModel,error:{code:'unknown',reason:'empty output from codex CLI (exit 0)'}};
|
||||
return{output,tokens:{input,output:out},durationMs,toolCalls,modelUsed:resolvedModel};
|
||||
}
|
||||
|
||||
export interface CodexProducerPaths {
|
||||
root: string;
|
||||
home: string;
|
||||
workdir: string;
|
||||
config: string;
|
||||
}
|
||||
|
||||
export function codexProducerPaths(stateDirectory: string): CodexProducerPaths {
|
||||
const root = path.join(stateDirectory, 'codex-provider');
|
||||
return { root, home: path.join(root, 'home'), workdir: path.join(root, 'work'), config: path.join(root, 'home', 'config.toml') };
|
||||
}
|
||||
|
||||
export function codexProducerConfig(opts: RunOpts): string {
|
||||
const launcher = opts.csoProducer?.helperLauncher;
|
||||
if (!launcher) throw new Error('CSO producer helper launcher is required');
|
||||
const sourceDirectory = csoProducerSourceDirectory(opts);
|
||||
if (!sourceDirectory) throw new Error('CSO producer source directory is required');
|
||||
const helperHome = csoProducerHelperHome(opts);
|
||||
if (!helperHome) throw new Error('CSO producer helper home is required');
|
||||
const generation = csoProducerHelperGeneration(opts);
|
||||
if (!generation) throw new Error('CSO producer helper generation is required');
|
||||
const directory = path.dirname(launcher), suffix = process.platform === 'win32' ? '.exe' : '';
|
||||
const trustedArtifacts = [
|
||||
path.join(directory, `cso-eval-producer${suffix}`), launcher,
|
||||
path.join(directory, `gstack-cso-core${suffix}`), path.join(directory, `gstack-cso-watchdog${suffix}`),
|
||||
generation,
|
||||
];
|
||||
const grants = [...trustedArtifacts, sourceDirectory].map(file => `${JSON.stringify(file)} = "read"`).join('\n');
|
||||
return `approval_policy = "never"
|
||||
default_permissions = "cso-producer"
|
||||
allow_login_shell = false
|
||||
check_for_update_on_startup = false
|
||||
|
||||
[shell_environment_policy]
|
||||
inherit = "all"
|
||||
include_only = ${JSON.stringify(CSO_PRODUCER_SHELL_ENV)}
|
||||
ignore_default_excludes = false
|
||||
experimental_use_profile = false
|
||||
|
||||
[permissions.cso-producer]
|
||||
description = "CSO producer: private state plus one immutable source snapshot"
|
||||
|
||||
[permissions.cso-producer.filesystem]
|
||||
":root" = "deny"
|
||||
":minimal" = "read"
|
||||
${grants}
|
||||
${JSON.stringify(helperHome)} = "write"
|
||||
|
||||
[permissions.cso-producer.filesystem.":workspace_roots"]
|
||||
"." = "write"
|
||||
|
||||
[permissions.cso-producer.network]
|
||||
enabled = false
|
||||
`;
|
||||
}
|
||||
|
||||
export function prepareCodexProducerState(opts: RunOpts): CodexProducerPaths {
|
||||
const stateDirectory = csoProducerStateDirectory(opts);
|
||||
if (!stateDirectory) throw new Error('CSO producer state directory is required');
|
||||
const paths = codexProducerPaths(stateDirectory);
|
||||
if (fs.existsSync(paths.root)) throw new Error('CODEX_PRODUCER_STATE_EXISTS');
|
||||
fs.mkdirSync(paths.root, { mode: 0o700 });
|
||||
fs.mkdirSync(paths.home, { mode: 0o700 });
|
||||
fs.mkdirSync(paths.workdir, { mode: 0o700 });
|
||||
atomicWriteSync(paths.config, codexProducerConfig(opts), { mode: 0o600, noReplace: true });
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function removeCodexProducerState(stateDirectory: string): void {
|
||||
fs.rmSync(codexProducerPaths(stateDirectory).root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** Exported so free tests can inspect the exact paid-CLI boundary without running it. */
|
||||
export function codexExecArgs(opts: RunOpts, model: string): string[] {
|
||||
const stateDirectory = csoProducerStateDirectory(opts);
|
||||
const args = ['exec', opts.prompt, '-C', stateDirectory ? codexProducerPaths(stateDirectory).workdir : opts.workdir];
|
||||
if (stateDirectory) {
|
||||
if (opts.extraArgs?.length) throw new Error('CSO producer does not accept extra provider arguments');
|
||||
args.push(
|
||||
'--strict-config', '--ephemeral', '--ignore-rules', '--skip-git-repo-check',
|
||||
);
|
||||
} else {
|
||||
args.push('-s', 'read-only', '--skip-git-repo-check');
|
||||
}
|
||||
args.push('--json', '-m', model);
|
||||
if (opts.extraArgs) args.push(...opts.extraArgs);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function codexExecWorkingDirectory(opts: RunOpts): string {
|
||||
const stateDirectory = csoProducerStateDirectory(opts);
|
||||
return stateDirectory ? codexProducerPaths(stateDirectory).workdir : opts.workdir;
|
||||
}
|
||||
|
||||
export function codexExecEnvironment(opts: RunOpts, source: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
||||
if (!opts.csoProducer) return source as Record<string, string>;
|
||||
const stateDirectory = csoProducerStateDirectory(opts)!;
|
||||
const paths = codexProducerPaths(stateDirectory);
|
||||
return { ...csoProducerChildEnvironment('gpt', source), HOME: paths.home, CODEX_HOME: paths.home, GSTACK_HOME: csoProducerHelperHome(opts)! };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as path from 'node:path';
|
||||
|
||||
/**
|
||||
* Provider adapter interface — uniform contract for Claude, GPT, Gemini.
|
||||
*
|
||||
@@ -18,6 +20,19 @@ export interface RunOpts {
|
||||
model?: string;
|
||||
/** Extra flags per-provider (escape hatch for rare cases). Prefer staying generic. */
|
||||
extraArgs?: string[];
|
||||
/** Producer-only execution policy. Omit to preserve each adapter's defaults. */
|
||||
csoProducer?: {
|
||||
/** The one explicit state directory exposed outside the source worktree. */
|
||||
stateDirectory: string;
|
||||
/** Exact validated per-cell source root. Providers may grant only this path read-only. */
|
||||
sourceDirectory: string;
|
||||
/** Absolute immutable launcher path admitted by the producer. */
|
||||
helperLauncher: string;
|
||||
/** Exact adjacent generation manifest read by the launcher. */
|
||||
helperGeneration: string;
|
||||
/** Exact provider command whose bytes/version are bound into the receipt. */
|
||||
providerCommand: { executable: string; argsPrefix: string[] };
|
||||
};
|
||||
}
|
||||
|
||||
export interface TokenUsage {
|
||||
@@ -57,6 +72,89 @@ export interface AvailabilityCheck {
|
||||
|
||||
export type Family = 'claude' | 'gpt' | 'gemini';
|
||||
|
||||
export const CSO_PRODUCER_SHELL_ENV = [
|
||||
'PATH', 'HOME', 'LANG', 'LC_ALL', 'TZ',
|
||||
'GSTACK_HOME', 'GSTACK_SESSION_KIND', 'GSTACK_HEADLESS',
|
||||
] as const;
|
||||
const CSO_AUTH_ENV: Record<Family, readonly string[]> = {
|
||||
claude: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'],
|
||||
gpt: ['OPENAI_API_KEY'],
|
||||
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'],
|
||||
};
|
||||
|
||||
export function validateCsoProducerStateDirectory(stateDirectory: string): string {
|
||||
if (!path.isAbsolute(stateDirectory) || path.normalize(stateDirectory) !== stateDirectory) {
|
||||
throw new Error('CSO producer state directory must be an absolute normalized path');
|
||||
}
|
||||
return stateDirectory;
|
||||
}
|
||||
|
||||
export function csoProducerStateDirectory(opts: RunOpts): string | undefined {
|
||||
if (!opts.csoProducer) return undefined;
|
||||
csoProducerHelperLauncher(opts);
|
||||
csoProducerHelperGeneration(opts);
|
||||
csoProducerSourceDirectory(opts);
|
||||
csoProducerProviderCommand(opts);
|
||||
return validateCsoProducerStateDirectory(opts.csoProducer.stateDirectory);
|
||||
}
|
||||
|
||||
export function csoProducerSourceDirectory(opts: RunOpts): string | undefined {
|
||||
if (!opts.csoProducer) return undefined;
|
||||
return validateCsoProducerStateDirectory(opts.csoProducer.sourceDirectory);
|
||||
}
|
||||
|
||||
export function csoProducerHelperHome(opts: RunOpts): string | undefined {
|
||||
const stateDirectory = csoProducerStateDirectory(opts);
|
||||
return stateDirectory ? path.join(stateDirectory, 'cso-home') : undefined;
|
||||
}
|
||||
|
||||
export function csoProducerProviderCommand(opts: RunOpts): { executable: string; argsPrefix: string[] } | undefined {
|
||||
if (!opts.csoProducer) return undefined;
|
||||
const command = opts.csoProducer.providerCommand;
|
||||
if (!command || !path.isAbsolute(command.executable) || path.normalize(command.executable) !== command.executable ||
|
||||
!Array.isArray(command.argsPrefix) || command.argsPrefix.some(value => typeof value !== 'string' || value.includes('\0'))) {
|
||||
throw new Error('CSO producer provider command must be an absolute normalized trusted path');
|
||||
}
|
||||
return { executable: command.executable, argsPrefix: [...command.argsPrefix] };
|
||||
}
|
||||
|
||||
export function csoProducerHelperLauncher(opts: RunOpts): string | undefined {
|
||||
if (!opts.csoProducer) return undefined;
|
||||
const launcher = opts.csoProducer.helperLauncher;
|
||||
if (typeof launcher !== 'string') throw new Error('CSO producer helper launcher must be an absolute normalized trusted path');
|
||||
const suffix = process.platform === 'win32' ? '.exe' : '';
|
||||
if (!path.isAbsolute(launcher) || path.normalize(launcher) !== launcher || path.basename(launcher) !== `gstack-cso-launcher${suffix}` ||
|
||||
!/^[A-Za-z0-9_./:\\-]+$/.test(launcher)) {
|
||||
throw new Error('CSO producer helper launcher must be an absolute normalized trusted path');
|
||||
}
|
||||
return launcher;
|
||||
}
|
||||
|
||||
export function csoProducerHelperGeneration(opts: RunOpts): string | undefined {
|
||||
if (!opts.csoProducer) return undefined;
|
||||
const generation = opts.csoProducer.helperGeneration;
|
||||
const launcher = csoProducerHelperLauncher(opts);
|
||||
if (typeof generation !== 'string' || !path.isAbsolute(generation) || path.normalize(generation) !== generation ||
|
||||
path.basename(generation) !== '.gstack-cso-generation' || path.dirname(generation) !== path.dirname(launcher!) ||
|
||||
!/^[A-Za-z0-9_./:\\-]+$/.test(generation)) {
|
||||
throw new Error('CSO producer helper generation must be the exact adjacent absolute normalized trusted path');
|
||||
}
|
||||
return generation;
|
||||
}
|
||||
|
||||
/** Copy only execution inputs needed by the selected producer host. */
|
||||
export function csoProducerChildEnvironment(
|
||||
family: Family,
|
||||
source: NodeJS.ProcessEnv = process.env,
|
||||
): Record<string, string> {
|
||||
const output: Record<string, string> = {};
|
||||
for (const key of [...CSO_PRODUCER_SHELL_ENV, ...CSO_AUTH_ENV[family]]) {
|
||||
const value = source[key];
|
||||
if (typeof value === 'string' && !value.includes('\0')) output[key] = value;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export interface ProviderAdapter {
|
||||
/** Stable name used in output tables and config (e.g., 'claude', 'gpt', 'gemini'). */
|
||||
readonly name: string;
|
||||
@@ -66,7 +164,7 @@ export interface ProviderAdapter {
|
||||
* Check whether the provider's CLI binary is present and authenticated.
|
||||
* Should never block >2s. Non-throwing: returns { ok: false, reason } on failure.
|
||||
*/
|
||||
available(): Promise<AvailabilityCheck>;
|
||||
available(opts?: RunOpts): Promise<AvailabilityCheck>;
|
||||
/** Run a prompt and return normalized RunResult. Non-throwing. Errors go in result.error. */
|
||||
run(opts: RunOpts): Promise<RunResult>;
|
||||
/** Estimate USD cost for the reported token usage and model. */
|
||||
|
||||
@@ -1,16 +1,245 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { ClaudeAdapter } from './helpers/providers/claude';
|
||||
import { GptAdapter } from './helpers/providers/gpt';
|
||||
import { ClaudeAdapter, claudeExecArgs, claudeExecEnvironment, claudeExecWorkingDirectory, claudeProducerPaths, resultFromClaudeOutput } from './helpers/providers/claude';
|
||||
import { codexExecArgs, codexExecEnvironment, codexExecWorkingDirectory, codexProducerConfig, codexProducerPaths, GptAdapter, resultFromCodexStream } from './helpers/providers/gpt';
|
||||
import { geminiExecArgs, geminiExecEnvironment, geminiExecWorkingDirectory, geminiProducerPaths, geminiProducerSystemSettings } from './helpers/providers/gemini';
|
||||
import { CSO_PRODUCER_SHELL_ENV, csoProducerChildEnvironment } from './helpers/providers/types';
|
||||
|
||||
const ENV_KEYS = ['PATH', 'GSTACK_CLAUDE_BIN', 'GSTACK_CLAUDE_BIN_ARGS',
|
||||
'GSTACK_CODEX_MODEL', 'EVALS_MODEL', 'GSTACK_EVAL_MODEL', 'GSTACK_EVAL_MODEL_CAPTURE'];
|
||||
let saved: Record<string, string | undefined>;
|
||||
let workdir: string;
|
||||
|
||||
describe('CSO producer provider policies', () => {
|
||||
const suffix = process.platform === 'win32' ? '.exe' : '';
|
||||
const helperAt = (directory: string) => join(directory, `gstack-cso-launcher${suffix}`);
|
||||
const providerAt = (directory: string) => ({ executable: join(directory, `provider${suffix}`), argsPrefix: [] as string[] });
|
||||
const policy = (stateDirectory: string, sourceDirectory: string, helperLauncher: string) => ({
|
||||
stateDirectory, sourceDirectory, helperLauncher, helperGeneration: join(dirname(helperLauncher), '.gstack-cso-generation'), providerCommand: providerAt(dirname(helperLauncher)),
|
||||
});
|
||||
|
||||
test('keeps exact Codex read-only defaults and gives the producer a custom least-privilege profile', () => {
|
||||
const providerWorkdir = join(tmpdir(), 'gstack-provider-work');
|
||||
const common = { prompt: 'Reply OK', workdir: providerWorkdir, timeoutMs: 5000 };
|
||||
expect(codexExecArgs(common, 'gpt-test')).toEqual([
|
||||
'exec', 'Reply OK', '-C', providerWorkdir,
|
||||
'-s', 'read-only', '--skip-git-repo-check', '--json', '-m', 'gpt-test',
|
||||
]);
|
||||
|
||||
const state = join(providerWorkdir, 'state');
|
||||
const source = join(providerWorkdir, 'source');
|
||||
const helper = helperAt(join(providerWorkdir, 'installed'));
|
||||
const producer = { ...common, csoProducer: policy(state, source, helper) };
|
||||
expect(codexExecArgs(producer, 'gpt-test')).toEqual([
|
||||
'exec', 'Reply OK', '-C', codexProducerPaths(state).workdir,
|
||||
'--strict-config', '--ephemeral', '--ignore-rules', '--skip-git-repo-check',
|
||||
'--json', '-m', 'gpt-test',
|
||||
]);
|
||||
expect(codexExecWorkingDirectory(common)).toBe(providerWorkdir);
|
||||
expect(codexExecWorkingDirectory(producer)).toBe(codexProducerPaths(state).workdir);
|
||||
|
||||
const config = codexProducerConfig(producer);
|
||||
expect(config).toContain('default_permissions = "cso-producer"');
|
||||
expect(config).toContain('":root" = "deny"\n":minimal" = "read"');
|
||||
expect(config).toContain('[permissions.cso-producer.filesystem.":workspace_roots"]\n"." = "write"');
|
||||
expect(config).toContain('[permissions.cso-producer.network]\nenabled = false');
|
||||
expect(config).toContain(`include_only = ${JSON.stringify(CSO_PRODUCER_SHELL_ENV)}`);
|
||||
expect(config).toContain(`${JSON.stringify(join(state, 'cso-home'))} = "write"`);
|
||||
for (const admitted of [
|
||||
join(dirname(helper), `cso-eval-producer${suffix}`), helper,
|
||||
join(dirname(helper), `gstack-cso-core${suffix}`), join(dirname(helper), `gstack-cso-watchdog${suffix}`),
|
||||
join(dirname(helper), '.gstack-cso-generation'), source,
|
||||
]) expect(config).toContain(`${JSON.stringify(admitted)} = "read"`);
|
||||
expect(config).not.toContain(`${JSON.stringify(dirname(helper))} = "read"`);
|
||||
expect(config).not.toContain(`${JSON.stringify(dirname(source))} = "read"`);
|
||||
expect(config).not.toContain('OPENAI_API_KEY');
|
||||
expect(() => codexExecArgs({ ...common, csoProducer: policy('relative-state', source, helper) }, 'gpt-test')).toThrow('absolute normalized path');
|
||||
expect(() => codexExecArgs({ ...common, csoProducer: policy(state, 'relative-source', helper) }, 'gpt-test')).toThrow('absolute normalized path');
|
||||
expect(() => codexExecArgs({ ...common, csoProducer: { ...policy(state, source, helper), helperGeneration: join(dirname(helper), 'wrong-generation') } }, 'gpt-test')).toThrow('exact adjacent');
|
||||
expect(codexExecArgs(producer, 'gpt-test').join(' ')).not.toMatch(/(?:^|\s)(?:-s|--sandbox)(?:\s|$)|danger|bypass|approve-for-me/);
|
||||
});
|
||||
|
||||
test('keeps exact Claude defaults and enables its producer-only noninteractive safe policy', () => {
|
||||
const work = join(tmpdir(), 'gstack-claude-work');
|
||||
const state = join(work, 'state');
|
||||
const source = join(work, 'source');
|
||||
const helper = helperAt(join(work, 'installed'));
|
||||
const common = { prompt: 'Reply OK', workdir: work, timeoutMs: 5000 };
|
||||
expect(claudeExecArgs(common, 'claude-test', ['wrapper'])).toEqual([
|
||||
'wrapper', '-p', '--output-format', 'json', '--model', 'claude-test',
|
||||
]);
|
||||
const producer = { ...common, csoProducer: policy(state, source, helper) };
|
||||
expect(claudeExecArgs(producer, 'claude-test')).toEqual([
|
||||
'-p', '--output-format', 'json', '--model', 'claude-test',
|
||||
'--restricted', '--safe-mode', '--no-session-persistence',
|
||||
'--permission-prompts', 'none', '--permission-mode', 'dontAsk',
|
||||
'--tools', 'Bash,Write',
|
||||
'--allowed-tools', `Bash(${helper}),Bash(${helper} *),Write`,
|
||||
'--add-dir', claudeProducerPaths(state).workdir,
|
||||
'--add-dir', source,
|
||||
'--add-dir', join(state, 'cso-home'),
|
||||
'--strict-mcp-config', '--no-chrome',
|
||||
]);
|
||||
expect(claudeExecWorkingDirectory(common)).toBe(work);
|
||||
expect(claudeExecWorkingDirectory(producer)).toBe(claudeProducerPaths(state).workdir);
|
||||
expect(claudeExecArgs(producer, 'claude-test').join(' ')).not.toMatch(/bypassPermissions|danger/);
|
||||
});
|
||||
|
||||
test('keeps exact Gemini defaults and replaces deprecated yolo only for the producer', () => {
|
||||
const work = join(tmpdir(), 'gstack-gemini-work');
|
||||
const state = join(work, 'state');
|
||||
const source = join(work, 'source');
|
||||
const helper = helperAt(join(work, 'installed'));
|
||||
const common = { prompt: 'Reply OK', workdir: work, timeoutMs: 5000, model: 'gemini-test' };
|
||||
expect(geminiExecArgs(common)).toEqual([
|
||||
'-p', 'Reply OK', '--output-format', 'stream-json', '--yolo', '--model', 'gemini-test',
|
||||
]);
|
||||
const producer = { ...common, csoProducer: policy(state, source, helper) };
|
||||
const producerArgs = geminiExecArgs(producer);
|
||||
expect(producerArgs).toEqual([
|
||||
'-p', 'Reply OK', '--output-format', 'stream-json',
|
||||
'--approval-mode', 'yolo', '--include-directories', geminiProducerPaths(state).workdir, '-e', 'none',
|
||||
'--model', 'gemini-test',
|
||||
]);
|
||||
expect(producerArgs).not.toContain(work);
|
||||
expect(producerArgs).not.toContain(source);
|
||||
expect(geminiExecWorkingDirectory(common)).toBe(work);
|
||||
expect(geminiExecWorkingDirectory(producer)).toBe(geminiProducerPaths(state).workdir);
|
||||
const contextFileName = `.gstack-cso-context-${'a'.repeat(32)}.md`;
|
||||
expect(geminiProducerSystemSettings(contextFileName, helper)).toEqual({
|
||||
advanced: { ignoreLocalEnv: true },
|
||||
admin: { extensions: { enabled: false }, mcp: { enabled: false }, skills: { enabled: false } },
|
||||
context: { fileName: [contextFileName], includeDirectoryTree: false, loadMemoryFromIncludeDirectories: false, memoryBoundaryMarkers: [] },
|
||||
hooksConfig: { enabled: false },
|
||||
privacy: { usageStatisticsEnabled: false },
|
||||
security: {
|
||||
environmentVariableRedaction: { allowed: [], blocked: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'], enabled: true },
|
||||
folderTrust: { enabled: false }, toolSandboxing: false,
|
||||
},
|
||||
skills: { enabled: false },
|
||||
telemetry: { enabled: false, logPrompts: false },
|
||||
tools: { allowed: [`run_shell_command(${helper})`, 'write_file'], core: [`run_shell_command(${helper})`, 'write_file'], sandbox: false },
|
||||
});
|
||||
expect(() => geminiProducerSystemSettings('GEMINI.md', helper)).toThrow('INVALID_GEMINI_CONTEXT_FILENAME');
|
||||
});
|
||||
|
||||
test('passes only selected provider auth plus safe execution inputs and strips Docker credentials', () => {
|
||||
const fixtureRoot = join(tmpdir(), 'gstack-provider-environment-policy');
|
||||
const state = join(fixtureRoot, 'state');
|
||||
const source = join(fixtureRoot, 'source');
|
||||
const helpers = join(fixtureRoot, 'helpers');
|
||||
const work = join(fixtureRoot, 'work');
|
||||
const sourceEnv = {
|
||||
PATH: '/usr/bin', HOME: '/home/eval', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', TZ: 'UTC',
|
||||
GSTACK_HOME: state, GSTACK_SESSION_KIND: 'spawned', GSTACK_HEADLESS: '1',
|
||||
OPENAI_API_KEY: 'openai-auth', ANTHROPIC_API_KEY: 'anthropic-auth', CLAUDE_CODE_OAUTH_TOKEN: 'claude-auth',
|
||||
GEMINI_API_KEY: 'gemini-auth', GOOGLE_CLOUD_PROJECT: 'project',
|
||||
CSO_EVAL_PAID: '1', AWS_SECRET_ACCESS_KEY: 'unrelated',
|
||||
DOCKER_HOST: 'tcp://remote.example:2376', DOCKER_CONFIG: '/credentials', DOCKER_CERT_PATH: '/certs',
|
||||
};
|
||||
const safe = { PATH: '/usr/bin', HOME: '/home/eval', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', TZ: 'UTC', GSTACK_HOME: state, GSTACK_SESSION_KIND: 'spawned', GSTACK_HEADLESS: '1' };
|
||||
expect(csoProducerChildEnvironment('gpt', sourceEnv)).toEqual({ ...safe, OPENAI_API_KEY: 'openai-auth' });
|
||||
expect(csoProducerChildEnvironment('claude', sourceEnv)).toEqual({ ...safe, ANTHROPIC_API_KEY: 'anthropic-auth', CLAUDE_CODE_OAUTH_TOKEN: 'claude-auth' });
|
||||
expect(csoProducerChildEnvironment('gemini', sourceEnv)).toEqual({ ...safe, GEMINI_API_KEY: 'gemini-auth', GOOGLE_CLOUD_PROJECT: 'project' });
|
||||
const helper = helperAt(helpers);
|
||||
const producer = policy(state, source, helper);
|
||||
const common = { prompt: '', workdir: work, timeoutMs: 1, csoProducer: producer };
|
||||
const codexPaths = codexProducerPaths(state);
|
||||
expect(codexExecEnvironment(common, sourceEnv)).toEqual({
|
||||
...safe, HOME: codexPaths.home, GSTACK_HOME: join(state, 'cso-home'), CODEX_HOME: codexPaths.home, OPENAI_API_KEY: 'openai-auth',
|
||||
});
|
||||
const claudePaths = claudeProducerPaths(state);
|
||||
expect(claudeExecEnvironment(common, sourceEnv)).toEqual({
|
||||
...safe, HOME: claudePaths.home, GSTACK_HOME: join(state, 'cso-home'),
|
||||
ANTHROPIC_API_KEY: 'anthropic-auth', CLAUDE_CODE_OAUTH_TOKEN: 'claude-auth', CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1',
|
||||
});
|
||||
const geminiPaths = geminiProducerPaths(state);
|
||||
expect(geminiExecEnvironment(common, sourceEnv)).toEqual({
|
||||
...safe, HOME: geminiPaths.home, GSTACK_HOME: join(state, 'cso-home'),
|
||||
GEMINI_API_KEY: 'gemini-auth', GOOGLE_API_KEY: 'gemini-auth', GOOGLE_CLOUD_PROJECT: 'project',
|
||||
GEMINI_SANDBOX: 'false', GEMINI_TELEMETRY_ENABLED: 'false', GEMINI_TELEMETRY_LOG_PROMPTS: 'false',
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true', GEMINI_SYSTEM_MD: 'false', GEMINI_WRITE_SYSTEM_MD: 'false',
|
||||
GEMINI_CLI_HOME: geminiPaths.home,
|
||||
GEMINI_CLI_SYSTEM_DEFAULTS_PATH: geminiPaths.systemDefaults,
|
||||
GEMINI_CLI_SYSTEM_SETTINGS_PATH: geminiPaths.systemSettings,
|
||||
});
|
||||
for (const env of [codexExecEnvironment(common, sourceEnv), claudeExecEnvironment(common, sourceEnv), geminiExecEnvironment(common, sourceEnv)]) {
|
||||
expect(env.CSO_EVAL_PAID).toBeUndefined();
|
||||
expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined();
|
||||
expect(env.DOCKER_HOST).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves default provider environments and rejects producer escape-hatch arguments', () => {
|
||||
const fixtureRoot = join(tmpdir(), 'gstack-provider-default-policy');
|
||||
const state = join(fixtureRoot, 'state');
|
||||
const source = join(fixtureRoot, 'source');
|
||||
const helpers = join(fixtureRoot, 'helpers');
|
||||
const work = join(fixtureRoot, 'work');
|
||||
const sourceEnv = { PATH: '/bin', CSO_EVAL_PAID: '1', UNRELATED_SECRET: 'kept-by-default', GEMINI_API_KEY: 'key' };
|
||||
const common = { prompt: '', workdir: work, timeoutMs: 1 };
|
||||
const producer = policy(state, source, helperAt(helpers));
|
||||
expect(codexExecEnvironment(common, sourceEnv)).toBe(sourceEnv);
|
||||
expect(claudeExecEnvironment(common, sourceEnv)).toEqual({ ...sourceEnv, GSTACK_HEADLESS: '1' });
|
||||
expect(geminiExecEnvironment(common, sourceEnv)).toEqual({ ...sourceEnv, GOOGLE_API_KEY: 'key' });
|
||||
for (const invoke of [
|
||||
() => codexExecArgs({ ...common, csoProducer: producer, extraArgs: ['--unsafe'] }, 'model'),
|
||||
() => claudeExecArgs({ ...common, csoProducer: producer, extraArgs: ['--unsafe'] }, 'model'),
|
||||
() => geminiExecArgs({ ...common, csoProducer: producer, extraArgs: ['--unsafe'] }),
|
||||
]) expect(invoke).toThrow('does not accept extra provider arguments');
|
||||
});
|
||||
|
||||
test('fails paid Codex producers closed on empty or malformed success output',()=>{
|
||||
for(const raw of ['', ' \n', 'not json\n{bad', '{"type":"turn.completed","usage":{"input_tokens":9,"output_tokens":2}}']){
|
||||
expect(resultFromCodexStream(raw,{model:'gpt-test',producer:true})).toMatchObject({output:'',error:{code:'unknown',reason:'empty output from codex CLI (exit 0)'}});
|
||||
}
|
||||
expect(resultFromCodexStream('diagnostic\n{"type":"item.completed","item":{"type":"agent_message","text":"OK"}}',{producer:true}).error).toBeUndefined();
|
||||
expect(resultFromCodexStream('',{producer:false}).error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('requires Claude producer JSON with a nonblank string result',()=>{
|
||||
for(const raw of ['', 'plain text', '{}', '{"result":42}', '{"result":" "}', '{"type":"result","subtype":"success","is_error":true,"result":"API Error: connection failed"}', '{"type":"result","subtype":"error_during_execution","result":"partial"}']){
|
||||
expect(resultFromClaudeOutput(raw,{model:'claude-test',producer:true})).toMatchObject({output:'',error:{code:'unknown',reason:'empty or invalid output from claude CLI (exit 0)'}});
|
||||
}
|
||||
const valid=resultFromClaudeOutput('{"result":"OK"}',{producer:true});expect(valid.output).toBe('OK');expect(valid.error).toBeUndefined();
|
||||
const legacy=resultFromClaudeOutput('plain text');expect(legacy.output).toBe('plain text');expect(legacy.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('executes the exact receipt-bound provider command despite a hostile PATH', async () => {
|
||||
const fixture = mkdtempSync(join(tmpdir(), 'cso-bound-provider-'));
|
||||
const previousPath = process.env.PATH;
|
||||
try {
|
||||
const state = join(fixture, 'state');
|
||||
const source = join(fixture, 'source');
|
||||
const installed = join(fixture, 'installed');
|
||||
const hostile = join(fixture, 'hostile');
|
||||
for (const directory of [state, source, installed, hostile]) mkdirSync(directory);
|
||||
const bound = join(installed, 'codex');
|
||||
const boundMarker = join(fixture, 'bound-ran');
|
||||
const hostileMarker = join(fixture, 'hostile-ran');
|
||||
writeFileSync(bound, `#!/bin/sh\ntouch ${JSON.stringify(boundMarker)}\nprintf '%s\\n' '{"type":"item.completed","item":{"type":"agent_message","text":"BOUND"}}'\n`, { mode: 0o755 });
|
||||
writeFileSync(join(hostile, 'codex'), `#!/bin/sh\ntouch ${JSON.stringify(hostileMarker)}\nprintf '%s\\n' '{"type":"item.completed","item":{"type":"agent_message","text":"HOSTILE"}}'\n`, { mode: 0o755 });
|
||||
process.env.PATH = `${hostile}:${previousPath ?? ''}`;
|
||||
const helper = helperAt(installed);
|
||||
const result = await new GptAdapter().run({
|
||||
prompt: 'Reply OK', workdir: fixture, timeoutMs: 5000, model: 'gpt-test',
|
||||
csoProducer: { ...policy(state, source, helper), providerCommand: { executable: bound, argsPrefix: [] } },
|
||||
});
|
||||
expect(result.output).toBe('BOUND');
|
||||
expect(existsSync(boundMarker)).toBe(true);
|
||||
expect(existsSync(hostileMarker)).toBe(false);
|
||||
expect(existsSync(codexProducerPaths(state).root)).toBe(false);
|
||||
} finally {
|
||||
if (previousPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = previousPath;
|
||||
rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Both adapters execute these stubs, so a regression can never launch a paid CLI.
|
||||
describe.skipIf(process.platform === 'win32')('provider model selection', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -78,7 +78,8 @@ describe("#1539 confidence resolver — pre-emit verification gate present", ()
|
||||
describe("#1539 generated SKILL.md files — gate propagated to all consumers", () => {
|
||||
const consumers = [
|
||||
"review/SKILL.md",
|
||||
"cso/SKILL.md",
|
||||
// CSO's private startup uses its dedicated evidence rubric rather than
|
||||
// the shared numerical confidence resolver guarded by this regression.
|
||||
"plan-eng-review/SKILL.md",
|
||||
"ship/SKILL.md",
|
||||
];
|
||||
|
||||
@@ -40,7 +40,8 @@ const GENERATED_WITH_GUIDANCE = [
|
||||
'autoplan/sections/design-phase.md',
|
||||
'autoplan/sections/eng-phase.md',
|
||||
'autoplan/sections/dx-phase.md',
|
||||
'cso/SKILL.md',
|
||||
// CSO's private startup does not import the shared synchronous-dispatch
|
||||
// guidance and its bounded worker policy is specified in its own skeleton.
|
||||
'design-consultation/SKILL.md',
|
||||
'design-review/SKILL.md',
|
||||
'design-shotgun/SKILL.md',
|
||||
|
||||
@@ -73,11 +73,13 @@ esac
|
||||
if [ "$1" = run ] && [ "$2" = build ]; then
|
||||
printf 'build\\n' >> "$FIXTURE_EVENTS"
|
||||
"$FIXTURE_REAL_BUN" run scripts/gen-skill-docs.ts --host all
|
||||
for target in browse/dist/browse design/dist/design make-pdf/dist/pdf; do
|
||||
for target in browse/dist/browse design/dist/design make-pdf/dist/pdf bin/gstack-cso-core bin/gstack-cso-launcher bin/gstack-cso-watchdog; do
|
||||
mkdir -p "$(dirname "$target")"
|
||||
printf '#!/usr/bin/env bash\\nexit 0\\n' > "$target"
|
||||
chmod +x "$target"
|
||||
done
|
||||
printf '%064d\\n' 0 > bin/.gstack-cso-generation
|
||||
printf 'complete\\n' > browse/dist/.build-complete
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = run ] && [ "$2" = gen:skill-docs ]; then
|
||||
|
||||
@@ -35,6 +35,9 @@ const BUILT_ARTIFACT_ALLOWLIST = [
|
||||
'design/dist/',
|
||||
'make-pdf/dist/',
|
||||
'bin/gstack-global-discover', // compiled from bin/gstack-global-discover.ts at build time
|
||||
'bin/gstack-cso-core',
|
||||
'bin/gstack-cso-launcher',
|
||||
'bin/gstack-cso-watchdog',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* setup: the NEEDS_BUILD decision ("# 1. Build browse binary if needed").
|
||||
*
|
||||
* One `bun run build` produces every binary (browse, design, make-pdf), so a
|
||||
* missing or stale one of ANY of them must trigger the whole build. Before,
|
||||
* Direct `bun run build` produces every binary. Setup includes CSO when its
|
||||
* host capability probe succeeds, and otherwise builds the general binaries
|
||||
* while removing CSO artifacts so /cso fails closed. Before,
|
||||
* only the browse binary's existence was checked and lib/ was not in the
|
||||
* staleness set: a missing design/dist/design or make-pdf/dist/pdf, or an edit
|
||||
* to lib/ (the canonical claude-bin / error-handling / aside-render sources the
|
||||
@@ -22,12 +23,16 @@ import { runBashScript } from './helpers/bash-script';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
const BUILD_SRC = fs.readFileSync(path.join(ROOT, 'scripts/build.sh'), 'utf-8');
|
||||
const CSO_BUILD_SRC = fs.readFileSync(path.join(ROOT, 'scripts/build-cso.sh'), 'utf-8');
|
||||
|
||||
// From the $_EXE suffix derivation through the `fi` that closes the staleness
|
||||
// chain. The statement that follows (the build itself) is the end anchor and is
|
||||
// NOT included, so the harness never tries to run `bun run build`.
|
||||
const BLOCK_START = '_EXE=""';
|
||||
const BLOCK_END = '\nif [ "$NEEDS_BUILD" -eq 1 ]; then';
|
||||
const CSO_SWITCH_START = 'if [ "${GSTACK_SETUP_RUNNING:-0}" = "1" ] && [ "${GSTACK_SETUP_SKIP_CSO_BUILD:-0}" = "1" ]; then';
|
||||
const CSO_SWITCH_END = '\nbash browse/scripts/build-node-server.sh';
|
||||
|
||||
function needsBuildBlock(): string {
|
||||
const start = SETUP_SRC.indexOf(BLOCK_START);
|
||||
@@ -36,9 +41,17 @@ function needsBuildBlock(): string {
|
||||
return SETUP_SRC.slice(start, end + 1);
|
||||
}
|
||||
|
||||
function csoBuildSwitchBlock(): string {
|
||||
const start = BUILD_SRC.indexOf(CSO_SWITCH_START);
|
||||
const end = BUILD_SRC.indexOf(CSO_SWITCH_END, start);
|
||||
if (start < 0 || end < 0) throw new Error('Could not locate the setup-private CSO build switch');
|
||||
return BUILD_SRC.slice(start, end);
|
||||
}
|
||||
|
||||
// Fixed instants, far apart, so coarse filesystem timestamps and clock skew
|
||||
// can never blur "older than the binary" into "newer".
|
||||
const BIN_T = new Date('2024-06-01T12:00:00Z');
|
||||
const STAMP_T = new Date('2024-07-01T12:00:00Z');
|
||||
const OLD_T = new Date('2024-01-01T12:00:00Z');
|
||||
const NEW_T = new Date('2024-12-01T12:00:00Z');
|
||||
|
||||
@@ -50,6 +63,10 @@ const SOURCE_FILES = [
|
||||
'lib/claude-bin.ts',
|
||||
'package.json',
|
||||
'bun.lock',
|
||||
'scripts/build.sh',
|
||||
'scripts/build-cso.sh',
|
||||
'scripts/build-cso-windows.ps1',
|
||||
'lib/cso/launcher.c',
|
||||
];
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
@@ -76,7 +93,19 @@ function makeTree(opts: { exe?: string } = {}): string {
|
||||
writeAt(path.join(dir, 'browse/dist/browse'), BIN_T, 0o755);
|
||||
writeAt(path.join(dir, `design/dist/design${exe}`), BIN_T, 0o755);
|
||||
writeAt(path.join(dir, `make-pdf/dist/pdf${exe}`), BIN_T, 0o755);
|
||||
writeAt(path.join(dir, `bin/gstack-cso-core${exe}`), BIN_T, 0o755);
|
||||
writeAt(path.join(dir, `bin/gstack-cso-launcher${exe}`), BIN_T, 0o755);
|
||||
const generation = path.join(dir, 'bin/.gstack-cso-generation');
|
||||
fs.writeFileSync(generation, `${'a'.repeat(64)}\n`, { mode: 0o600 });
|
||||
fs.utimesSync(generation, BIN_T, BIN_T);
|
||||
if (exe) {
|
||||
const generationLock = path.join(dir, 'bin/.gstack-cso-generation.lock');
|
||||
fs.writeFileSync(generationLock, '', { mode: 0o600 });
|
||||
fs.utimesSync(generationLock, BIN_T, BIN_T);
|
||||
}
|
||||
if (!exe) writeAt(path.join(dir, 'bin/gstack-cso-watchdog'), BIN_T, 0o755);
|
||||
for (const f of SOURCE_FILES) writeAt(path.join(dir, f), OLD_T);
|
||||
writeAt(path.join(dir, 'browse/dist/.build-complete'), STAMP_T);
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -84,12 +113,13 @@ function touchNewer(dir: string, rel: string): void {
|
||||
writeAt(path.join(dir, rel), NEW_T);
|
||||
}
|
||||
|
||||
function decide(dir: string, opts: { isWindows?: '0' | '1' } = {}): number {
|
||||
function decide(dir: string, opts: { isWindows?: '0' | '1'; csoAvailable?: '0' | '1' } = {}): number {
|
||||
const script = [
|
||||
'set -e',
|
||||
`SOURCE_GSTACK_DIR="${dir}"`,
|
||||
'BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse"',
|
||||
`IS_WINDOWS=${opts.isWindows ?? '0'}`,
|
||||
`CSO_BUILD_AVAILABLE=${opts.csoAvailable ?? '1'}`,
|
||||
needsBuildBlock(),
|
||||
'echo "NEEDS_BUILD=$NEEDS_BUILD"',
|
||||
].join('\n');
|
||||
@@ -113,12 +143,62 @@ describe('setup: NEEDS_BUILD static invariants', () => {
|
||||
expect(block).not.toContain('bun_cmd run build');
|
||||
});
|
||||
|
||||
test('all three binaries are existence-checked with -x and the $_EXE suffix', () => {
|
||||
test('all required binaries are existence-checked with -x and the $_EXE suffix', () => {
|
||||
const block = needsBuildBlock();
|
||||
expect(block).toContain('[ ! -x "$BROWSE_BIN" ]');
|
||||
expect(block).toContain('[ ! -x "$SOURCE_GSTACK_DIR/design/dist/design$_EXE" ]');
|
||||
expect(block).toContain('[ ! -x "$SOURCE_GSTACK_DIR/make-pdf/dist/pdf$_EXE" ]');
|
||||
expect(block).toContain('if [ "$IS_WINDOWS" -eq 1 ]; then _EXE=".exe"; fi');
|
||||
expect(block).toContain('if [ "$CSO_BUILD_AVAILABLE" -eq 1 ]; then');
|
||||
});
|
||||
|
||||
test('setup owns the only CSO build escape hatch and probes Bun hardening flags', () => {
|
||||
for (const flag of ['dotenv', 'bunfig', 'tsconfig', 'package-json']) {
|
||||
expect(SETUP_SRC).toContain(`--no-compile-autoload-${flag}`);
|
||||
}
|
||||
expect(SETUP_SRC).toContain('probe_cso_build_prerequisites');
|
||||
expect(BUILD_SRC).toContain('[ "${GSTACK_SETUP_RUNNING:-0}" = "1" ]');
|
||||
expect(BUILD_SRC).toContain('[ "${GSTACK_SETUP_SKIP_CSO_BUILD:-0}" = "1" ]');
|
||||
expect(BUILD_SRC).toContain('BUN_CMD="$BUN_CMD" bash scripts/build-cso.sh');
|
||||
});
|
||||
|
||||
test('the skip flag alone cannot weaken a direct build; setup can omit and purge CSO', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cso-build-switch-'));
|
||||
tmpDirs.push(dir);
|
||||
for (const sub of ['bin', 'scripts']) fs.mkdirSync(path.join(dir, sub), { recursive: true });
|
||||
for (const name of ['gstack-cso-launcher', 'gstack-cso-launcher.exe', 'gstack-cso-core', 'gstack-cso-core.exe', 'gstack-cso-watchdog']) {
|
||||
writeAt(path.join(dir, 'bin', name), BIN_T, 0o755);
|
||||
}
|
||||
const strict = path.join(dir, 'scripts/build-cso.sh');
|
||||
fs.writeFileSync(strict, '#!/bin/sh\nprintf called > cso-called\n');
|
||||
fs.chmodSync(strict, 0o755);
|
||||
|
||||
let result = runBashScript(`set -e\n${csoBuildSwitchBlock()}`, {
|
||||
cwd: dir, env: { ...process.env, GSTACK_SETUP_SKIP_CSO_BUILD: '1' },
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(fs.existsSync(path.join(dir, 'cso-called'))).toBe(true);
|
||||
|
||||
fs.unlinkSync(path.join(dir, 'cso-called'));
|
||||
result = runBashScript(`set -e\n${csoBuildSwitchBlock()}`, {
|
||||
cwd: dir, env: { ...process.env, GSTACK_SETUP_RUNNING: '1', GSTACK_SETUP_SKIP_CSO_BUILD: '1' },
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(fs.existsSync(path.join(dir, 'cso-called'))).toBe(false);
|
||||
expect(fs.readdirSync(path.join(dir, 'bin')).filter(name => name.startsWith('gstack-cso-'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('the whole-build stamp is invalidated before compilation and published only after every output succeeds', () => {
|
||||
const invalidate = BUILD_SRC.indexOf('rm -f "$BUILD_STAMP" "$BUILD_STAMP_TMP"');
|
||||
const firstBuild = BUILD_SRC.indexOf('"$BUN_CMD" run vendor:xterm');
|
||||
const csoBuild = BUILD_SRC.indexOf('BUN_CMD="$BUN_CMD" bash scripts/build-cso.sh');
|
||||
const publish = BUILD_SRC.indexOf('mv -f "$BUILD_STAMP_TMP" "$BUILD_STAMP"');
|
||||
expect(invalidate).toBeGreaterThan(-1);
|
||||
expect(firstBuild).toBeGreaterThan(invalidate);
|
||||
expect(csoBuild).toBeGreaterThan(firstBuild);
|
||||
expect(publish).toBeGreaterThan(csoBuild);
|
||||
expect(BUILD_SRC.slice(publish + 1)).not.toContain('"$BUN_CMD" build');
|
||||
expect(CSO_BUILD_SRC).toContain('rm -f "$CSO_BUILD_ROOT/browse/dist/.build-complete"');
|
||||
});
|
||||
|
||||
test('the staleness find walks every embedded source root, lib/ included', () => {
|
||||
@@ -126,9 +206,9 @@ describe('setup: NEEDS_BUILD static invariants', () => {
|
||||
for (const root of ['browse/src', 'make-pdf/src', 'design/src', 'lib']) {
|
||||
expect(block).toContain(`"$SOURCE_GSTACK_DIR/${root}"`);
|
||||
}
|
||||
expect(block).toContain('-type f -newer "$BROWSE_BIN"');
|
||||
expect(block).toContain('"$SOURCE_GSTACK_DIR/package.json" -nt "$BROWSE_BIN"');
|
||||
expect(block).toContain('[ -f "$SOURCE_GSTACK_DIR/bun.lock" ] && [ "$SOURCE_GSTACK_DIR/bun.lock" -nt "$BROWSE_BIN" ]');
|
||||
expect(block).toContain('-type f -newer "$BUILD_STAMP"');
|
||||
expect(block).toContain('"$SOURCE_GSTACK_DIR/package.json" -nt "$BUILD_STAMP"');
|
||||
expect(block).toContain('[ -f "$SOURCE_GSTACK_DIR/bun.lock" ] && [ "$SOURCE_GSTACK_DIR/bun.lock" -nt "$BUILD_STAMP" ]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,6 +217,16 @@ describe('setup: NEEDS_BUILD decision executes', () => {
|
||||
expect(decide(makeTree())).toBe(0);
|
||||
});
|
||||
|
||||
test('an interrupted partial build cannot let a refreshed browse binary hide stale CSO outputs', () => {
|
||||
const dir = makeTree();
|
||||
fs.unlinkSync(path.join(dir, 'browse/dist/.build-complete'));
|
||||
writeAt(path.join(dir, 'browse/dist/browse'), NEW_T, 0o755);
|
||||
expect(fs.statSync(path.join(dir, 'bin/gstack-cso-core')).mtimeMs).toBeLessThan(
|
||||
fs.statSync(path.join(dir, 'browse/dist/browse')).mtimeMs,
|
||||
);
|
||||
expect(decide(dir)).toBe(1);
|
||||
});
|
||||
|
||||
test('make-pdf/dist/pdf missing → 1 (was: not checked at all)', () => {
|
||||
const dir = makeTree();
|
||||
fs.unlinkSync(path.join(dir, 'make-pdf/dist/pdf'));
|
||||
@@ -155,6 +245,46 @@ describe('setup: NEEDS_BUILD decision executes', () => {
|
||||
expect(decide(dir)).toBe(1);
|
||||
});
|
||||
|
||||
test.each(['bin/gstack-cso-launcher','bin/gstack-cso-core', 'bin/gstack-cso-watchdog'])('%s missing → 1', binary => {
|
||||
const dir = makeTree();
|
||||
fs.unlinkSync(path.join(dir, binary));
|
||||
expect(decide(dir)).toBe(1);
|
||||
});
|
||||
|
||||
test('CSO generation manifest missing → 1', () => {
|
||||
const dir = makeTree();
|
||||
fs.unlinkSync(path.join(dir, 'bin/.gstack-cso-generation'));
|
||||
expect(decide(dir)).toBe(1);
|
||||
});
|
||||
|
||||
test('Windows CSO generation lock missing → 1', () => {
|
||||
const dir = makeTree({ exe: '.exe' });
|
||||
fs.unlinkSync(path.join(dir, 'bin/.gstack-cso-generation.lock'));
|
||||
expect(decide(dir, { isWindows: '1' })).toBe(1);
|
||||
});
|
||||
|
||||
test('unavailable CSO removes stale helpers and does not force a repeat build', () => {
|
||||
const dir = makeTree();
|
||||
expect(decide(dir, { csoAvailable: '0' })).toBe(0);
|
||||
for (const binary of ['bin/gstack-cso-launcher', 'bin/gstack-cso-core', 'bin/gstack-cso-watchdog', 'bin/.gstack-cso-generation']) {
|
||||
expect(fs.existsSync(path.join(dir, binary))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('unavailable CSO ignores its build-script freshness but still rebuilds missing general binaries', () => {
|
||||
const dir = makeTree();
|
||||
touchNewer(dir, 'scripts/build-cso.sh');
|
||||
expect(decide(dir, { csoAvailable: '0' })).toBe(0);
|
||||
fs.unlinkSync(path.join(dir, 'design/dist/design'));
|
||||
expect(decide(dir, { csoAvailable: '0' })).toBe(1);
|
||||
});
|
||||
|
||||
test.each(['scripts/build.sh', 'scripts/build-cso.sh', 'scripts/build-cso-windows.ps1', 'lib/cso/launcher.c'])('%s changed → 1', source => {
|
||||
const dir = makeTree();
|
||||
touchNewer(dir, source);
|
||||
expect(decide(dir)).toBe(1);
|
||||
});
|
||||
|
||||
// MSYS bash has no execute bit: `[ -x file ]` is true for any regular file, so
|
||||
// this case is POSIX-only.
|
||||
test.skipIf(process.platform === 'win32')('a binary that exists but is not executable counts as missing → 1', () => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const template = fs.readFileSync(
|
||||
path.join(import.meta.dir, '..', '.github', 'PULL_REQUEST_TEMPLATE.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('gstack PR liveness policy', () => {
|
||||
test('remembers the authenticated repository-owner exemption', () => {
|
||||
expect(template).toContain('Repository owner @garrytan is explicitly exempt');
|
||||
expect(template).toContain('gh api user --jq .login');
|
||||
expect(template).toMatch(/Git author metadata\s+alone is not sufficient/);
|
||||
expect(template).toContain('PR author is @garrytan (owner exemption)');
|
||||
});
|
||||
|
||||
test('retains live GSTACK PR proof for every other contributor', () => {
|
||||
expect(template).toContain('required for external contributors');
|
||||
expect(template).toContain('All other contributors');
|
||||
expect(template).toContain('`GSTACK PR` typed LIVE');
|
||||
expect(template).toContain('not drawn,\noverlaid, or edited onto the image');
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,9 @@
|
||||
* AUQ behavioral matrix — drive each AUQ-heavy skill to its first
|
||||
* AskUserQuestion and grade it to plan-ceo's bar (periodic, paid, SDK capture).
|
||||
*
|
||||
* Layer 0 (auq-format-always-loaded.test.ts) deterministically guarantees every
|
||||
* skill SHIPS the format spec in its always-loaded skeleton. This test proves
|
||||
* each skill's model OBEYS it: that the first real AUQ each skill fires is a
|
||||
* Layer 0 (auq-format-always-loaded.test.ts) deterministically guarantees each
|
||||
* listed skill SHIPS the format spec in its always-loaded skeleton. This test
|
||||
* proves each skill's model OBEYS it: that the first real AUQ it fires is a
|
||||
* compliant decision brief (all 7 format elements) with a substantive
|
||||
* recommendation (>= 4). One parametrized case per skill so a single weak skill
|
||||
* is an isolated failure, not a blocker for the rest.
|
||||
@@ -20,7 +20,9 @@
|
||||
* choices) are intentionally OUT of this matrix; Layer 0 covers their format
|
||||
* spec, and a fixture can't fairly trigger their AUQ.
|
||||
*
|
||||
* Run a subset in the foreground with AUQ_MATRIX_ONLY="plan-eng-review,cso".
|
||||
* CSO is intentionally absent: its private startup omits the shared AUQ block,
|
||||
* and its dedicated E2E grades evidence, reporting, and proof behavior.
|
||||
* Run a subset in the foreground with AUQ_MATRIX_ONLY="plan-eng-review,spec".
|
||||
*/
|
||||
import { test } from 'bun:test';
|
||||
import { CAPTURE_MS } from './helpers/eval-budgets';
|
||||
@@ -53,17 +55,6 @@ pricing page, a Postgres entitlements table, and a Redis cache — no tests
|
||||
mentioned, no rollout plan, no auth check on the upgrade endpoint.
|
||||
`;
|
||||
|
||||
const VULN_CODE = `export function login(req, res) {
|
||||
// builds SQL by string concat; sets a session cookie with no flags
|
||||
const user = db.query("SELECT * FROM users WHERE name = '" + req.body.name + "'");
|
||||
if (user && user.password === req.body.password) {
|
||||
res.cookie('session', user.id); // no HttpOnly, Secure, SameSite, or expiry
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
return res.status(401).json({ ok: false });
|
||||
}
|
||||
`;
|
||||
|
||||
interface MatrixSkill {
|
||||
skill: string;
|
||||
fixtures: Record<string, string>;
|
||||
@@ -93,11 +84,6 @@ const MATRIX: MatrixSkill[] = [
|
||||
fixtures: {},
|
||||
scenario: 'The founder says: "I am building an AI tool that auto-writes unit tests for any repo. I think it is a great idea but I have zero users. Should I build it, and how do I get my first users?" Run the office-hours diagnostic until the first AskUserQuestion.',
|
||||
},
|
||||
{
|
||||
skill: 'cso',
|
||||
fixtures: { 'server/auth.js': VULN_CODE },
|
||||
scenario: 'Audit the code in this repo (server/auth.js) for security issues. Walk the audit until the first AskUserQuestion (scope/stack confirmation or first finding).',
|
||||
},
|
||||
{
|
||||
skill: 'spec',
|
||||
fixtures: {},
|
||||
|
||||
+220
-271
@@ -1,289 +1,238 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { CAPTURE_MS, CAPTURE_LONG_MS } from './helpers/eval-budgets';
|
||||
import { test, expect, afterAll } from 'bun:test';
|
||||
import { CAPTURE_LONG_MS } from './helpers/eval-budgets';
|
||||
import { runSkillTest } from './helpers/session-runner';
|
||||
import {
|
||||
ROOT, runId, evalsEnabled,
|
||||
describeIfSelected, logCost, recordE2E,
|
||||
createEvalCollector, finalizeEvalCollector,
|
||||
} from './helpers/e2e-helpers';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { ROOT, runId, describeIfSelected, logCost, recordE2E, createEvalCollector, finalizeEvalCollector } from './helpers/e2e-helpers';
|
||||
import { validateCoverage, validateFinding, completeness, type RunReportV3 } from '../lib/cso/contracts';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
const evalCollector = createEvalCollector('e2e-cso');
|
||||
// runSkillTest can drain stderr for 5s after its unchanged CLI deadline.
|
||||
// Let cleanup and failure recording finish before Bun starts a retry.
|
||||
const CAPTURE_CLEANUP_MS = 6_000;
|
||||
let captureSequence = 0;
|
||||
afterAll(() => finalizeEvalCollector(evalCollector));
|
||||
|
||||
afterAll(() => {
|
||||
finalizeEvalCollector(evalCollector);
|
||||
});
|
||||
|
||||
// --- CSO v2 E2E Tests ---
|
||||
|
||||
describeIfSelected('CSO v2 — full audit', ['cso-full-audit'], () => {
|
||||
let csoDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
csoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-cso-'));
|
||||
|
||||
const run = (cmd: string, args: string[]) =>
|
||||
spawnSync(cmd, args, { cwd: csoDir, stdio: 'pipe', timeout: 5000 });
|
||||
|
||||
run('git', ['init', '-b', 'main']);
|
||||
run('git', ['config', 'user.email', 'test@test.com']);
|
||||
run('git', ['config', 'user.name', 'Test']);
|
||||
|
||||
// Create a minimal app with a planted vulnerability
|
||||
fs.writeFileSync(path.join(csoDir, 'package.json'), JSON.stringify({
|
||||
name: 'cso-test-app',
|
||||
version: '1.0.0',
|
||||
dependencies: { express: '4.18.0' },
|
||||
}, null, 2));
|
||||
|
||||
// Planted vuln: hardcoded API key
|
||||
fs.writeFileSync(path.join(csoDir, 'server.ts'), `
|
||||
import express from 'express';
|
||||
const app = express();
|
||||
const API_KEY = "sk-1234567890abcdef1234567890abcdef";
|
||||
app.get('/api/data', (req, res) => {
|
||||
const id = req.query.id;
|
||||
res.json({ data: \`result for \${id}\` });
|
||||
});
|
||||
app.listen(3000);
|
||||
`);
|
||||
|
||||
// Planted vuln: .env tracked by git
|
||||
fs.writeFileSync(path.join(csoDir, '.env'), 'DATABASE_URL=postgres://admin:secretpass@prod.db.example.com:5432/myapp\n');
|
||||
|
||||
run('git', ['add', '.']);
|
||||
run('git', ['commit', '-m', 'initial']);
|
||||
// Synthetic credential-shaped canary: its bytes must never appear in agent output or saved reports.
|
||||
const CANARY = ['ghp_', 'f7K3mV9nT2qR8sL4xP6dW0bH5jY1cZ3aF9uE'].join('');
|
||||
interface Fixture { repo: string; state: string; original: Map<string, string>; }
|
||||
function git(repo: string, ...args: string[]): string {
|
||||
const result = spawnSync('git', args, { cwd: repo, encoding: 'utf8', timeout: 5000 });
|
||||
if (result.status !== 0) throw new Error(`Fixture git ${args[0]} failed: ${result.stderr}`);
|
||||
return result.stdout;
|
||||
}
|
||||
function fixture(files: Record<string, string>, diff?: Record<string, string>): Fixture {
|
||||
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-cso-v3-'));
|
||||
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-cso-state-'));
|
||||
git(repo, 'init', '-b', 'main');
|
||||
git(repo, 'config', 'user.email', 'test@example.invalid');
|
||||
git(repo, 'config', 'user.name', 'CSO Test');
|
||||
const write = (inputs: Record<string, string>) => {
|
||||
for (const [name, content] of Object.entries(inputs)) {
|
||||
fs.mkdirSync(path.dirname(path.join(repo, name)), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, name), content);
|
||||
}
|
||||
git(repo, 'add', '.');
|
||||
git(repo, 'commit', '-m', 'CSO fixture');
|
||||
};
|
||||
write(files);
|
||||
if (diff) { git(repo, 'checkout', '-b', 'fixture-change'); write(diff); }
|
||||
return { repo, state, original: new Map(Object.entries({ ...files, ...diff })) };
|
||||
}
|
||||
function removeFixture(f: Fixture): void {
|
||||
fs.rmSync(f.repo, { recursive: true, force: true });
|
||||
fs.rmSync(f.state, { recursive: true, force: true });
|
||||
}
|
||||
async function withFixture<T>(files:Record<string,string>,diff:Record<string,string>|undefined,run:(fixture:Fixture)=>Promise<T>):Promise<T>{const f=fixture(files,diff);try{return await run(f);}finally{removeFixture(f);}}
|
||||
function reportsUnder(dir: string): string[] {
|
||||
// Bounded to the exact private namespace; no report in source or old v2 namespace is accepted.
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
||||
const child = path.join(dir, entry.name);
|
||||
return entry.isDirectory() ? reportsUnder(child) : entry.name === 'report.json' ? [child] : [];
|
||||
});
|
||||
}
|
||||
function requireReport(f: Fixture): RunReportV3 {
|
||||
const reports = reportsUnder(path.join(f.state, 'security', 'cso'));
|
||||
expect(reports).toHaveLength(1); // A mandatory assertion: absent reports used to silently pass.
|
||||
const raw = fs.readFileSync(reports[0], 'utf8');
|
||||
expect(raw).not.toContain(CANARY);
|
||||
expect(raw).not.toContain(fs.realpathSync(f.repo));
|
||||
const report: RunReportV3 = JSON.parse(raw);
|
||||
expect(report.schemaVersion).toBe(3);
|
||||
expect(report.status).toBe('finished');
|
||||
expect(report.source.root).toBe('<REDACTED-internal.user_path>');
|
||||
expect(report.policy.mode).toBe('daily');
|
||||
expect(report.policy.offline).toBe(true);
|
||||
expect(report.coverage.length).toBeGreaterThan(0);
|
||||
report.coverage.forEach(validateCoverage);
|
||||
expect(report.completeness).toBe(completeness(report));
|
||||
for (const finding of report.findings) {
|
||||
const { id, fingerprint, reproduction, repair, closure, verificationId, reproductionAttemptId, ...submitted } = finding;
|
||||
validateFinding(submitted);
|
||||
expect(id).toBe(fingerprint);
|
||||
expect(id).toMatch(/^[a-f0-9]{32}$/);
|
||||
expect(finding.evidence).toBe('supported');
|
||||
expect(reproduction).toBe('not_attempted');
|
||||
expect(repair).toBe('not_attempted');
|
||||
expect(closure).toBe('open');
|
||||
expect(verificationId).toBeUndefined();
|
||||
expect(reproductionAttemptId).toBeUndefined();
|
||||
}
|
||||
const markdown = fs.readFileSync(path.join(path.dirname(reports[0]), 'report.md'), 'utf8');
|
||||
expect(markdown).toMatch(/^(complete|partial|not assessed) — /);
|
||||
expect(markdown).not.toContain(CANARY);
|
||||
if (process.platform !== 'win32') expect(fs.statSync(reports[0]).mode & 0o777).toBe(0o600);
|
||||
for (const [name, content] of f.original) expect(fs.readFileSync(path.join(f.repo, name), 'utf8')).toBe(content);
|
||||
expect(git(f.repo, 'status', '--porcelain=v1', '--untracked-files=all')).toBe('');
|
||||
return report;
|
||||
}
|
||||
function prompt(f: Fixture, flags: string): string {
|
||||
return `Read ${path.join(ROOT, 'cso/SKILL.md')} and perform /cso ${flags} --offline --base main on ${f.repo}.
|
||||
The trusted installed helper for this evaluation is ${path.join(ROOT, 'bin', process.platform === 'win32' ? 'gstack-cso-launcher.exe' : 'gstack-cso-launcher')}.
|
||||
GSTACK_HOME is already set to the private evaluation state directory.
|
||||
Complete the helper-owned report even when coverage is partial. Do not use AskUserQuestion or request new scope.
|
||||
Use one sequential challenge (independent agent unavailable for this evaluation); do not spawn agents.
|
||||
This is a static audit: do not execute the application, test provider credentials, or install dependencies.
|
||||
Do not read or execute files outside this repository except the trusted skill/helper and its private run artifacts.`;
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
try { fs.rmSync(csoDir, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
const PACKAGE = JSON.stringify({ name: 'cso-v3-evaluation', version: '1.0.0', private: true });
|
||||
|
||||
test('/cso finds planted vulnerabilities', async () => {
|
||||
const result = await runSkillTest({
|
||||
testName: 'cso-full-audit',
|
||||
runId: `cso-full-audit-${process.env.EVALS_RUN_ID ?? runId}-${process.pid}-${++captureSequence}`,
|
||||
publicStreamDiagnostics: true,
|
||||
prompt: `Read the file ${path.join(ROOT, 'cso', 'SKILL.md')} for the CSO skill instructions.
|
||||
|
||||
Run /cso on this repo (full daily audit, no flags).
|
||||
describeIfSelected('CSO v3 — full audit', ['cso-full-audit'], () => {
|
||||
test('/cso persists supported tenant-boundary findings with redacted evidence', async () => {
|
||||
await withFixture({
|
||||
'package.json': PACKAGE,
|
||||
'.env': `PROVIDER_TOKEN=${CANARY}\n`,
|
||||
'server.js': `const http = require('node:http');
|
||||
const sessions = new Map([['alice-token', { tenant: 'alice' }], ['bob-token', { tenant: 'bob' }]]);
|
||||
const invoices = [{ id: 'invoice-a', tenant: 'alice', total: 42 }, { id: 'invoice-b', tenant: 'bob', total: 950 }];
|
||||
http.createServer((req, res) => {
|
||||
const user = sessions.get((req.headers.authorization || '').replace('Bearer ', ''));
|
||||
if (!user) { res.writeHead(401); return res.end('unauthorized'); }
|
||||
const id = req.url.split('/').pop();
|
||||
const invoice = invoices.find(item => item.id === id);
|
||||
if (!invoice) { res.writeHead(404); return res.end('not found'); }
|
||||
res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(invoice));
|
||||
}).listen(3000);
|
||||
`,
|
||||
},undefined,async f=>{
|
||||
const result = await runSkillTest({
|
||||
testName: 'cso-full-audit',
|
||||
runId: `cso-full-audit-${process.env.EVALS_RUN_ID ?? runId}-${process.pid}-${++captureSequence}`,
|
||||
publicStreamDiagnostics: true,
|
||||
prompt: prompt(f, ''), workingDirectory: f.repo,
|
||||
env: { GSTACK_HOME: f.state }, maxTurns: 40, allowedTools: ['Bash', 'Read', 'Write', 'Grep', 'Glob'], timeout: CAPTURE_LONG_MS,
|
||||
});
|
||||
logCost('cso', result);
|
||||
let passed = false;
|
||||
try {
|
||||
expect(result.exitReason).toBe('success');
|
||||
expect(result.output).not.toContain(CANARY);
|
||||
expect(JSON.stringify(result.transcript)).not.toContain(CANARY);
|
||||
// Dedicated carve behavioral guard: a real audit must actually load its domain section.
|
||||
expect(result.toolCalls.some(call => /Read|Bash/.test(call.tool)
|
||||
&& JSON.stringify(call.input).includes('sections/audit-phases.md'))).toBe(true);
|
||||
const report = requireReport(f);
|
||||
const tenant = report.findings.filter(item => item.location.path === 'server.js' && /tenant|owner|authoriz|access control/i.test(`${item.rootCause} ${item.impact}`));
|
||||
expect(tenant.length).toBeGreaterThan(0);
|
||||
expect(tenant.every(item => item.trace.length > 0 && item.challenge.controls.length > 0)).toBe(true);
|
||||
passed = result.browseErrors.length === 0;
|
||||
} finally {
|
||||
recordE2E(evalCollector, 'cso-full-audit', 'e2e-cso', result, { passed });
|
||||
}
|
||||
});
|
||||
}, CAPTURE_LONG_MS + CAPTURE_CLEANUP_MS);
|
||||
});
|
||||
|
||||
IMPORTANT:
|
||||
- Do NOT use AskUserQuestion — skip any interactive prompts.
|
||||
- Focus on finding the planted vulnerabilities in this small repo.
|
||||
- Produce the SECURITY FINDINGS table.
|
||||
- Save the report to .gstack/security-reports/.`,
|
||||
workingDirectory: csoDir,
|
||||
maxTurns: 30,
|
||||
allowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob', 'Agent'],
|
||||
timeout: CAPTURE_MS,
|
||||
describeIfSelected('CSO v3 — diff mode', ['cso-diff-mode'], () => {
|
||||
test('/cso --diff records its base and investigates changed security paths', async () => {
|
||||
await withFixture({ 'package.json': PACKAGE, 'app.js': 'console.log("fixture baseline");\n' }, {
|
||||
'webhook.js': `const http = require('node:http');
|
||||
const payments = new Map();
|
||||
http.createServer((req, res) => {
|
||||
if (req.method !== 'POST' || req.url !== '/webhook/payment') { res.writeHead(404); return res.end(); }
|
||||
let body = ''; req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
const event = JSON.parse(body);
|
||||
payments.set(event.accountId, { plan: 'paid', amount: event.amount });
|
||||
res.end('payment applied');
|
||||
});
|
||||
}).listen(3000);
|
||||
`,
|
||||
},async f=>{
|
||||
const result = await runSkillTest({
|
||||
testName: 'cso-diff-mode',
|
||||
runId: `cso-diff-mode-${process.env.EVALS_RUN_ID ?? runId}-${process.pid}-${++captureSequence}`,
|
||||
publicStreamDiagnostics: true,
|
||||
prompt: prompt(f, '--diff'), workingDirectory: f.repo,
|
||||
env: { GSTACK_HOME: f.state }, maxTurns: 40, allowedTools: ['Bash', 'Read', 'Write', 'Grep', 'Glob'], timeout: CAPTURE_LONG_MS,
|
||||
});
|
||||
logCost('cso', result);
|
||||
let passed = false;
|
||||
try {
|
||||
expect(result.exitReason).toBe('success');
|
||||
const report = requireReport(f);
|
||||
expect(report.policy.diff).toBe(true);
|
||||
expect(report.policy.base).toBe('main');
|
||||
expect(report.source.baseCommit).toBe(git(f.repo, 'rev-parse', 'main').trim());
|
||||
expect(report.findings.some(item => item.location.path === 'webhook.js' && /signature|authenticat|forg/i.test(`${item.rootCause} ${item.impact}`))).toBe(true);
|
||||
expect(report.findings.every(item => item.location.path === 'webhook.js')).toBe(true);
|
||||
passed = result.browseErrors.length === 0;
|
||||
} finally {
|
||||
recordE2E(evalCollector, 'cso-diff-mode', 'e2e-cso', result, { passed });
|
||||
}
|
||||
});
|
||||
|
||||
logCost('cso', result);
|
||||
let passed = false;
|
||||
try {
|
||||
expect(result.exitReason).toBe('success');
|
||||
|
||||
// Should detect hardcoded API key
|
||||
const output = result.output.toLowerCase();
|
||||
expect(
|
||||
output.includes('sk-') || output.includes('hardcoded') || output.includes('api key') || output.includes('api_key')
|
||||
).toBe(true);
|
||||
|
||||
// Should detect .env tracked by git
|
||||
expect(
|
||||
output.includes('.env') && (output.includes('tracked') || output.includes('gitignore'))
|
||||
).toBe(true);
|
||||
|
||||
// Should produce a findings table
|
||||
expect(
|
||||
output.includes('security findings') || output.includes('SECURITY FINDINGS')
|
||||
).toBe(true);
|
||||
}, CAPTURE_LONG_MS + CAPTURE_CLEANUP_MS);
|
||||
});
|
||||
|
||||
// Should save a report
|
||||
const reportDir = path.join(csoDir, '.gstack', 'security-reports');
|
||||
const reportExists = fs.existsSync(reportDir);
|
||||
if (reportExists) {
|
||||
const reports = fs.readdirSync(reportDir).filter(f => f.endsWith('.json'));
|
||||
expect(reports.length).toBeGreaterThanOrEqual(1);
|
||||
describeIfSelected('CSO v3 — infra scope', ['cso-infra-scope'], () => {
|
||||
test('/cso --infra finds an attacker-to-credential execution path', async () => {
|
||||
await withFixture({ 'package.json': PACKAGE,
|
||||
'.github/workflows/comment.yml': `name: comment automation
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
reply:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Handle untrusted comment
|
||||
env:
|
||||
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
||||
run: echo "\${{ github.event.comment.body }}"
|
||||
`,
|
||||
'Dockerfile': 'FROM node:22\nWORKDIR /app\nCOPY . .\nCMD ["node", "server.js"]\n',
|
||||
},undefined,async f=>{
|
||||
const result = await runSkillTest({
|
||||
testName: 'cso-infra-scope',
|
||||
runId: `cso-infra-scope-${process.env.EVALS_RUN_ID ?? runId}-${process.pid}-${++captureSequence}`,
|
||||
publicStreamDiagnostics: true,
|
||||
prompt: prompt(f, '--infra'), workingDirectory: f.repo,
|
||||
env: { GSTACK_HOME: f.state }, maxTurns: 40, allowedTools: ['Bash', 'Read', 'Write', 'Grep', 'Glob'], timeout: CAPTURE_LONG_MS,
|
||||
});
|
||||
logCost('cso', result);
|
||||
let passed = false;
|
||||
try {
|
||||
expect(result.exitReason).toBe('success');
|
||||
const report = requireReport(f);
|
||||
expect(report.policy.scope).toBe('infra');
|
||||
const workflow=report.findings.find(item=>item.location.path==='.github/workflows/comment.yml');
|
||||
expect(workflow).toBeDefined();
|
||||
const chain=[workflow!.title,workflow!.rootCause,workflow!.attackerControl,workflow!.impact,workflow!.scenario,...workflow!.trace].join(' ');
|
||||
expect(chain).toMatch(/github\.event\.comment\.body|issue comment body|comment body/i);
|
||||
expect(chain).toMatch(/run(?: step|:)|shell|bash/i);
|
||||
expect(chain).toMatch(/GITHUB_TOKEN|contents:\s*write|repository write/i);
|
||||
// A missing USER directive is only a hardening lead without demonstrated attacker impact.
|
||||
expect(report.findings.some(item => item.location.path === 'Dockerfile' && /critical|high/.test(item.severity))).toBe(false);
|
||||
passed = result.browseErrors.length === 0;
|
||||
} finally {
|
||||
recordE2E(evalCollector, 'cso-infra-scope', 'e2e-cso', result, { passed });
|
||||
}
|
||||
passed = true;
|
||||
} finally {
|
||||
recordE2E(evalCollector, 'cso-full-audit', 'e2e-cso', result, { passed: passed && result.browseErrors.length === 0 });
|
||||
}
|
||||
}, CAPTURE_MS + CAPTURE_CLEANUP_MS);
|
||||
});
|
||||
|
||||
describeIfSelected('CSO v2 — diff mode', ['cso-diff-mode'], () => {
|
||||
let csoDiffDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
csoDiffDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-cso-diff-'));
|
||||
|
||||
const run = (cmd: string, args: string[]) =>
|
||||
spawnSync(cmd, args, { cwd: csoDiffDir, stdio: 'pipe', timeout: 5000 });
|
||||
|
||||
run('git', ['init', '-b', 'main']);
|
||||
run('git', ['config', 'user.email', 'test@test.com']);
|
||||
run('git', ['config', 'user.name', 'Test']);
|
||||
|
||||
// Clean initial commit
|
||||
fs.writeFileSync(path.join(csoDiffDir, 'package.json'), JSON.stringify({
|
||||
name: 'cso-diff-test', version: '1.0.0',
|
||||
}, null, 2));
|
||||
fs.writeFileSync(path.join(csoDiffDir, 'app.ts'), 'console.log("hello");\n');
|
||||
run('git', ['add', '.']);
|
||||
run('git', ['commit', '-m', 'initial']);
|
||||
|
||||
// Feature branch with a vuln
|
||||
run('git', ['checkout', '-b', 'feat/add-webhook']);
|
||||
fs.writeFileSync(path.join(csoDiffDir, 'webhook.ts'), `
|
||||
import express from 'express';
|
||||
const app = express();
|
||||
// No signature verification!
|
||||
app.post('/webhook/stripe', (req, res) => {
|
||||
const event = req.body;
|
||||
processPayment(event);
|
||||
res.sendStatus(200);
|
||||
});
|
||||
`);
|
||||
run('git', ['add', '.']);
|
||||
run('git', ['commit', '-m', 'feat: add webhook']);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try { fs.rmSync(csoDiffDir, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
|
||||
test('/cso --diff scopes to branch changes', async () => {
|
||||
const result = await runSkillTest({
|
||||
testName: 'cso-diff-mode',
|
||||
runId: `cso-diff-mode-${process.env.EVALS_RUN_ID ?? runId}-${process.pid}-${++captureSequence}`,
|
||||
publicStreamDiagnostics: true,
|
||||
prompt: `Read the file ${path.join(ROOT, 'cso', 'SKILL.md')} for the CSO skill instructions.
|
||||
|
||||
Run /cso --diff on this repo. The base branch is "main".
|
||||
|
||||
IMPORTANT:
|
||||
- Do NOT use AskUserQuestion — skip any interactive prompts.
|
||||
- Focus on changes in the current branch vs main.
|
||||
- The webhook.ts file was added on this branch — it should be analyzed.`,
|
||||
workingDirectory: csoDiffDir,
|
||||
maxTurns: 40,
|
||||
allowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob', 'Agent'],
|
||||
// 360s/40 turns: the v1.67 wave grew the audit session legitimately —
|
||||
// transcript-verified, the agent finds the webhook vuln, spawns the
|
||||
// verification subagent, and writes the report, then gets killed at
|
||||
// ~215s in its CLOSING telemetry under the old 240s/25-turn budget.
|
||||
// The full-audit sibling already runs at 300s.
|
||||
timeout: 360_000,
|
||||
});
|
||||
|
||||
logCost('cso', result);
|
||||
let passed = false;
|
||||
try {
|
||||
expect(result.exitReason).toBe('success');
|
||||
|
||||
const output = result.output.toLowerCase();
|
||||
// Should mention webhook and missing signature verification
|
||||
expect(
|
||||
output.includes('webhook') && (output.includes('signature') || output.includes('verify'))
|
||||
).toBe(true);
|
||||
passed = true;
|
||||
} finally {
|
||||
recordE2E(evalCollector, 'cso-diff-mode', 'e2e-cso', result, { passed: passed && result.browseErrors.length === 0 });
|
||||
}
|
||||
}, CAPTURE_LONG_MS);
|
||||
});
|
||||
|
||||
describeIfSelected('CSO v2 — infra scope', ['cso-infra-scope'], () => {
|
||||
let csoInfraDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
csoInfraDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-cso-infra-'));
|
||||
|
||||
const run = (cmd: string, args: string[]) =>
|
||||
spawnSync(cmd, args, { cwd: csoInfraDir, stdio: 'pipe', timeout: 5000 });
|
||||
|
||||
run('git', ['init', '-b', 'main']);
|
||||
run('git', ['config', 'user.email', 'test@test.com']);
|
||||
run('git', ['config', 'user.name', 'Test']);
|
||||
|
||||
// CI workflow with unpinned action
|
||||
fs.mkdirSync(path.join(csoInfraDir, '.github', 'workflows'), { recursive: true });
|
||||
fs.writeFileSync(path.join(csoInfraDir, '.github', 'workflows', 'ci.yml'), `
|
||||
name: CI
|
||||
on: [push]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: some-third-party/action@main
|
||||
- run: echo "Building..."
|
||||
`);
|
||||
|
||||
// Dockerfile running as root
|
||||
fs.writeFileSync(path.join(csoInfraDir, 'Dockerfile'), `
|
||||
FROM node:20
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN npm install
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
`);
|
||||
|
||||
run('git', ['add', '.']);
|
||||
run('git', ['commit', '-m', 'initial']);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try { fs.rmSync(csoInfraDir, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
|
||||
test('/cso --infra runs infrastructure phases only', async () => {
|
||||
const result = await runSkillTest({
|
||||
testName: 'cso-infra-scope',
|
||||
runId: `cso-infra-scope-${process.env.EVALS_RUN_ID ?? runId}-${process.pid}-${++captureSequence}`,
|
||||
publicStreamDiagnostics: true,
|
||||
prompt: `Read the file ${path.join(ROOT, 'cso', 'SKILL.md')} for the CSO skill instructions.
|
||||
|
||||
Run /cso --infra on this repo. This should run infrastructure-only phases (0-6, 12-14).
|
||||
|
||||
IMPORTANT:
|
||||
- Do NOT use AskUserQuestion — skip any interactive prompts.
|
||||
- This is a TINY repo with only 3 files: .github/workflows/ci.yml, Dockerfile, and package.json. Do NOT waste turns exploring — just read those files directly and audit them.
|
||||
- The Dockerfile has no USER directive (runs as root). The CI workflow uses an unpinned third-party GitHub Action (some-third-party/action@main).
|
||||
- Focus on infrastructure findings, NOT code-level OWASP scanning.
|
||||
- Skip the preamble (gstack-update-check, telemetry, etc.) — go straight to the audit.
|
||||
- Do NOT use the Agent tool for exploration or verification — read the files yourself. This repo is too small to need subagents.`,
|
||||
workingDirectory: csoInfraDir,
|
||||
maxTurns: 30,
|
||||
allowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob'],
|
||||
timeout: CAPTURE_LONG_MS,
|
||||
});
|
||||
|
||||
logCost('cso', result);
|
||||
let passed = false;
|
||||
try {
|
||||
expect(result.exitReason).toBe('success');
|
||||
|
||||
const output = result.output.toLowerCase();
|
||||
// Should mention unpinned action or Dockerfile issues
|
||||
expect(
|
||||
output.includes('unpinned') || output.includes('third-party') ||
|
||||
output.includes('user directive') || output.includes('root')
|
||||
).toBe(true);
|
||||
passed = true;
|
||||
} finally {
|
||||
recordE2E(evalCollector, 'cso-infra-scope', 'e2e-cso', result, { passed: passed && result.browseErrors.length === 0 });
|
||||
}
|
||||
}, CAPTURE_LONG_MS + CAPTURE_CLEANUP_MS);
|
||||
});
|
||||
|
||||
@@ -49,6 +49,28 @@ function readShipUnion(): string {
|
||||
return readSkillUnion('ship');
|
||||
}
|
||||
|
||||
describe('CSO host permission boundary', () => {
|
||||
test('grants no raw source, mutation, search, question, or Agent tools', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'cso', 'SKILL.md'), 'utf-8');
|
||||
const fmEnd = content.indexOf('\n---', 4);
|
||||
const frontmatter = Bun.YAML.parse(content.slice(4, fmEnd)) as Record<string, unknown>;
|
||||
const allowed = frontmatter['allowed-tools'];
|
||||
expect(allowed).toEqual([
|
||||
'Bash(~/.claude/skills/gstack/bin/gstack-cso-launcher *)',
|
||||
'Bash(~/.claude/skills/gstack/bin/gstack-cso-launcher.exe *)',
|
||||
]);
|
||||
for (const broad of ['Bash', 'Read', 'Grep', 'Glob', 'Write', 'Agent', 'WebSearch', 'AskUserQuestion']) expect(allowed).not.toContain(broad);
|
||||
});
|
||||
|
||||
test('retains helper-only source access, sequential challenge, and honest host containment', () => {
|
||||
const content = readSkillUnion('cso');
|
||||
expect(content).toContain('Never use host `Read`/`Glob`/`Grep`');
|
||||
expect(content).toContain('sequential challenge; independent agent unavailable');
|
||||
expect(content).toContain('Do not request broader tool access solely to obtain an independent reviewer.');
|
||||
expect(content).toContain('Containment does not sandbox the host agent or kernel.');
|
||||
});
|
||||
});
|
||||
|
||||
function readCodexSkillUnion(skill: string): string {
|
||||
const dir = path.join(CODEX_OUT, '.agents', 'skills', `gstack-${skill}`);
|
||||
const sections = path.join(dir, 'sections');
|
||||
@@ -58,7 +80,6 @@ function readCodexSkillUnion(skill: string): string {
|
||||
.map(file => '\n' + fs.readFileSync(path.join(sections, file), 'utf-8')).join('') : '');
|
||||
}
|
||||
|
||||
|
||||
describe('SKILL.md command validation', () => {
|
||||
// P2 (v1.2.0): the top-level gstack skill is a pure ROUTER, not the browse
|
||||
// skill. The browse body lives only in browse/SKILL.md now. This regression
|
||||
@@ -333,7 +354,8 @@ describe('Update check preamble', () => {
|
||||
'benchmark/SKILL.md',
|
||||
'land-and-deploy/SKILL.md',
|
||||
'setup-deploy/SKILL.md',
|
||||
'cso/SKILL.md',
|
||||
// CSO intentionally uses a private startup instead of the shared update,
|
||||
// session, learning, checkpoint, and telemetry PREAMBLE.
|
||||
];
|
||||
|
||||
for (const skill of skillsWithUpdateCheck) {
|
||||
@@ -698,7 +720,8 @@ describe('v0.4.1 preamble features', () => {
|
||||
'canary/SKILL.md',
|
||||
'land-and-deploy/SKILL.md',
|
||||
'setup-deploy/SKILL.md',
|
||||
'cso/SKILL.md',
|
||||
// CSO's private startup intentionally omits the generic AUQ/session/
|
||||
// escalation PREAMBLE; its helper owns readiness and terminal state.
|
||||
];
|
||||
|
||||
const skillsWithPreamble = [...tier1Skills, ...tier2PlusSkills];
|
||||
@@ -964,7 +987,9 @@ describe('Completeness Principle in generated SKILL.md files', () => {
|
||||
'design-review/SKILL.md',
|
||||
'design-consultation/SKILL.md',
|
||||
'document-release/SKILL.md',
|
||||
'cso/SKILL.md', ];
|
||||
// CSO reports complete/partial/not-assessed from its own evidence contract
|
||||
// and must not inherit the shared numerical completeness rubric.
|
||||
];
|
||||
|
||||
for (const skill of skillsWithPreamble) {
|
||||
test(`${skill} contains Completeness Principle section`, () => {
|
||||
@@ -975,7 +1000,8 @@ describe('Completeness Principle in generated SKILL.md files', () => {
|
||||
}
|
||||
|
||||
test('Completeness Principle keeps compact scoring guidance in tier 2+ skills', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'cso', 'SKILL.md'), 'utf-8');
|
||||
// CSO is intentionally exempt; use a regular tier 2+ PREAMBLE consumer.
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Completeness: X/10');
|
||||
expect(content).toContain('10 = all edge cases');
|
||||
expect(content).toContain('Note: options differ in kind, not coverage');
|
||||
|
||||
@@ -123,6 +123,22 @@ describe('test-free-shards: Windows curation', () => {
|
||||
expect(reason.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('excludes POSIX CSO helper suites while retaining portable image metadata coverage', () => {
|
||||
const posixOnly = [
|
||||
'test/cso-preparation-adversarial.test.ts',
|
||||
'test/cso-preparation-container.test.ts',
|
||||
'test/cso-preparation-executor.test.ts',
|
||||
'test/cso-scanner-cli.test.ts',
|
||||
'test/cso-verification-cleanup.test.ts',
|
||||
'test/cso-witness.test.ts',
|
||||
];
|
||||
const portable = ['test/cso-image-provisioning.test.ts', 'test/cso-public-ghcr.test.ts'];
|
||||
const result = curateWindowsSafe([...posixOnly, ...portable], ROOT);
|
||||
expect(result.safe).toEqual(portable);
|
||||
expect(result.excluded.map(({ file }) => file).sort()).toEqual(posixOnly.sort());
|
||||
for (const { reason } of result.excluded) expect(reason).toMatch(/Linux|POSIX|Windows/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('test-free-shards: sharding', () => {
|
||||
|
||||
Reference in New Issue
Block a user