v1.88.1.0 fix: harden credential boundaries and owned state (#2942)

* fix(settings): preserve symlinked settings targets

Resolve the selected target for locking, mutation, backup, and rollback; refuse target changes and preserve private modes. Addresses #2830.

* fix(redact): bind masking to original detected spans

Inspired by #2929's anchored-span diagnosis; independently implemented using normalization offsets. Addresses #2930 and the relocation portion of #2912 without changing detection sensitivity.

* fix(evals): exclude operator credentials from prefix admission

Adapts the credential-suffix screen proposed in #2636, with real launched-child regression coverage and deliberate provider-auth exceptions.

* fix(artifacts): retain custom allowlist rules on reinitialization

Preserve the exact user-owned suffix and publish only a successfully assembled replacement. Independently implements the repair reported in #2907.

* test(cso): verify exact masked reads and unmaskable payload refusal

* fix(cso): preserve exact filesystem identities through lease recovery

Preserve 64-bit device/inode identity and nanosecond race checks. Add native NTFS lifecycle coverage for #2927; retain ambiguous legacy-state refusal without claiming Windows PID-reuse recovery is resolved.

* fix(redact): bind pre-push scans to destination and preserve seam context

Uses #2935 (bd07318) as source evidence for push-target range and slice-overlap defects. Independently implemented; no cherry-pick or release metadata adoption.

* test(ci): gate native agent ownership and settings links on macOS

* fix(browse): bind agent lifetimes and cleanup to owned generations

Uses #2931 by Chris Hutton / Claude Fable 5.1 as attributed design input; independently implemented without broad sweeps or copied code. Keep uncertain children and locks rather than deleting foreign state.

* test(ci): include concurrent shutdown controls in the native macOS gate

* v1.88.1.0 fix: harden credential boundaries and owned state

* fix(redact): preserve target provenance and scan boundary semantics

* test(artifacts): read managed rules from atomic allowlist assembly

* fix: preserve native exit observations and fixture prerequisites

* fix: preserve UTF-16 offsets through redaction normalization
This commit is contained in:
Garry Tan
2026-09-23 08:54:53 -04:00
committed by GitHub
parent 636175d349
commit b9706f3635
42 changed files with 2719 additions and 339 deletions
+5 -5
View File
@@ -5,9 +5,9 @@ import * as path from "path";
const ROOT = path.resolve(import.meta.dir, "..");
const INIT = fs.readFileSync(path.join(ROOT, "bin", "gstack-artifacts-init"), "utf-8");
/** Pull a quoted heredoc body out of gstack-artifacts-init by target filename. */
/** Pull a quoted heredoc body out of gstack-artifacts-init by destination. */
function heredoc(target: string): string {
const re = new RegExp(`cat > "\\$GSTACK_HOME/${target}" <<'EOF'\\n([\\s\\S]*?)\\nEOF\\n`);
const re = new RegExp(`cat > "${target}" <<'EOF'\\n([\\s\\S]*?)\\nEOF\\n`);
const m = INIT.match(re);
if (!m) throw new Error(`heredoc for ${target} not found in gstack-artifacts-init`);
return m[1];
@@ -39,7 +39,7 @@ const DECISION_PATHS = [
* Windows -- which is the platform where this bug bit.
*/
describe("the artifacts allowlist covers the decision store", () => {
const globs = heredoc("\\.brain-allowlist")
const globs = heredoc("\\$ALLOWLIST_TMP")
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"));
@@ -51,7 +51,7 @@ describe("the artifacts allowlist covers the decision store", () => {
});
test("decisions.* are class artifact, so they sync in artifacts-only mode too", () => {
const map = JSON.parse(heredoc("\\.brain-privacy-map\\.json"));
const map = JSON.parse(heredoc("\\$GSTACK_HOME/\\.brain-privacy-map\\.json"));
for (const p of DECISION_PATHS) {
const hit = map.find((e: { pattern: string; class: string }) => globToRe(e.pattern).test(p));
expect({ p, cls: hit?.class }).toEqual({ p, cls: "artifact" });
@@ -61,6 +61,6 @@ describe("the artifacts allowlist covers the decision store", () => {
test("the allowlist still ends with the user-additions marker", () => {
// Additions below it survive re-init; a glob added above would be silently
// overwritten the next time gstack-artifacts-init runs.
expect(heredoc("\\.brain-allowlist").trimEnd()).toMatch(/# ---- USER ADDITIONS BELOW ----/);
expect(heredoc("\\$ALLOWLIST_TMP").trimEnd()).toMatch(/# ---- USER ADDITIONS BELOW ----/);
});
});
+27 -1
View File
@@ -73,7 +73,33 @@ describe('compiled CSO command workflow',()=>{
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('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).toBe(0);expect(result.stdout.trim()).toBe('TOKEN=<REDACTED-env.kv+github.pat>');expect(result.stderr).toBe('');expect(result.stdout+result.stderr).not.toContain('ghp_');expect(result.stdout+result.stderr).not.toContain(CREDENTIAL_CANARY);expect(fs.existsSync(path.join(state,'security','cso',run.repoId,run.runId,'snapshot','.env'))).toBe(false);});
test('supplementary NFKC expansion cannot displace compiled reader redaction',()=>{
const file=path.join(repo,'.env');
expect(fs.realpathSync(file)).toBe(path.join(fs.realpathSync(repo),'.env'));
const original=fs.readFileSync(file),prefix='\uFA6C'.repeat(64)+'\n',suffix='\n'+'z'.repeat(256);
const value=['8Fk2pQ9vXz4wL7mN','3rT6yB1cD5eG0hJq'].join('');
try{
fs.writeFileSync(file,`${prefix}DB_PASSWORD=${value}${suffix}`);
const started=command(['start','--repo',repo,'--scope','auth','--offline']);expect(started.status).toBe(0);
const run=JSON.parse(started.stdout),result=command(['read',run.runId,'.env']);
expect(result.status).toBe(0);expect(result.stderr).toBe('');
expect(result.stdout.trimEnd()).toBe(`${prefix}DB_PASSWORD=<REDACTED-env.kv>${suffix}`);
expect(result.stdout+result.stderr).not.toContain(value);
expect(fs.existsSync(path.join(state,'security','cso',run.repoId,run.runId,'snapshot','.env'))).toBe(false);
}finally{fs.writeFileSync(file,original);}
});
test('marker-only credential payloads remain unavailable to the compiled reader',()=>{
const file=path.join(repo,'.env'),original=fs.readFileSync(file),body=['-----BEGIN ','PRIVATE KEY-----\n','synthetic-key-body\n','-----END ','PRIVATE KEY-----\n'].join('');
try{
fs.writeFileSync(file,body);
const started=command(['start','--repo',repo,'--scope','auth','--offline']);expect(started.status).toBe(0);
const run=JSON.parse(started.stdout),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('synthetic-key-body');
const dir=path.join(state,'security','cso',run.repoId,run.runId);
expect(fs.existsSync(path.join(dir,'readable','.env'))).toBe(false);expect(fs.existsSync(path.join(dir,'snapshot','.env'))).toBe(false);
}finally{fs.writeFileSync(file,original);}
});
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);});
+21
View File
@@ -336,6 +336,27 @@ describe('CSO runtime staging gates', () => {
expect(gate['continue-on-error']).not.toBe(true);
});
test('native macOS ownership and settings regressions are required by the free gate', () => {
const workflow = Bun.YAML.parse(readFileSync(join(ROOT, '.github/workflows/free-tests.yml'), 'utf8')) as any;
const job = workflow.jobs['cso-macos-launcher'];
const gate = job.steps.find((step: any) => step.name === 'Exercise native agent ownership and linked settings');
expect(job['runs-on']).toBe('macos-latest');
expect(gate.env.TMPDIR).toBe('/tmp');
expect(gate.run).toContain('test -f "$file"');
expect(gate.run).toContain('bun test "${files[@]}"');
for (const file of [
'browse/test/terminal-agent-lifecycle.test.ts',
'browse/test/terminal-agent-native-observation.test.ts',
'browse/test/terminal-agent-watchdog.test.ts',
'browse/test/server-embedder-terminal-port.test.ts',
'browse/test/server-factory.test.ts',
'test/gstack-settings-hook-symlink.test.ts',
'test/gstack-settings-hook-schema-aware.test.ts',
]) expect(gate.run).toContain(file);
expect(gate['continue-on-error']).not.toBe(true);
expect(workflow.jobs['free-tests'].needs).toContain('cso-macos-launcher');
});
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;
+182
View File
@@ -0,0 +1,182 @@
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 { recoverAtomicNoReplaceJson, withLock } from '../lib/cso/state';
const roots:string[]=[];
const tmp=()=>{const root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-lease-identity-'));roots.push(root);return root;};
afterEach(()=>{for(const root of roots.splice(0))fs.rmSync(root,{recursive:true,force:true});});
function syntheticIds(root:string,first:bigint,step=1n):()=>void{
const lstat=fs.lstatSync.bind(fs),fstat=fs.fstatSync.bind(fs),ids=new Map<string,bigint>();
const map=(stat:fs.Stats|fs.BigIntStats,bigint:boolean)=>{
const key=`${stat.dev}:${stat.ino}`;
if(!ids.has(key))return stat;
const ino=ids.get(key)!;
return Object.assign(Object.create(Object.getPrototypeOf(stat)),stat,{dev:bigint?first:Number(first),ino:bigint?ino:Number(ino)});
};
const pathSpy=spyOn(fs,'lstatSync').mockImplementation(((file:any,options?:any)=>{
const stat=lstat(file,options);
if(String(file).startsWith(root+path.sep)){
const key=`${stat.dev}:${stat.ino}`;
if(!ids.has(key))ids.set(key,first+step*BigInt(ids.size));
}
return map(stat,options?.bigint===true);
}) as typeof fs.lstatSync);
const fdSpy=spyOn(fs,'fstatSync').mockImplementation(((fd:any,options?:any)=>map(fstat(fd,options),options?.bigint===true)) as typeof fs.fstatSync);
return()=>{fdSpy.mockRestore();pathSpy.mockRestore();};
}
function leaseFixture(dir:string,ownerPid:number,token:string){
withLock(dir,()=>0);
const leases=path.join(dir,'.mutation-lock-leases'),candidate=path.join(leases,`${token}.json`),decision=path.join(leases,`${token}.decision`);
fs.writeFileSync(candidate,JSON.stringify({pid:ownerPid,token,createdAt:0})+'\n',{mode:0o600});
const stat=fs.lstatSync(candidate,{bigint:true});
fs.writeFileSync(decision,JSON.stringify({schemaVersion:1,token,kind:'ticket',ticket:'0000000000000001',candidateDev:String(stat.dev),candidateIno:String(stat.ino),ownerPid,ownerCreatedAt:0,publisherPid:ownerPid,createdAt:0})+'\n',{mode:0o600});
return {leases,candidate,decision,stat};
}
describe('CSO exact filesystem lease identity',()=>{
test('the last safe integer and its next inode remain distinct',()=>{
const dir=tmp(),restore=syntheticIds(dir,9007199254740991n);
try{
const first=path.join(dir,'first'),second=path.join(dir,'second');
fs.writeFileSync(first,'a');fs.writeFileSync(second,'b');
expect(fs.lstatSync(first,{bigint:true}).ino).toBe(9007199254740991n);
expect(fs.lstatSync(second,{bigint:true}).ino).toBe(9007199254740992n);
expect(withLock(dir,()=>1)).toBe(1);
}finally{restore();}
});
test.each([9007199254740991n,9007199254740992n,9007199254740993n])('serializes and releases identity starting at %s across consecutive mutations',(first)=>{
const dir=tmp(),restore=syntheticIds(dir,first);
try{
for(let i=0;i<4;i++)expect(withLock(dir,()=>{
const leases=path.join(dir,'.mutation-lock-leases'),files=fs.readdirSync(leases);
const candidate=path.join(leases,files.find(name=>name.endsWith('.json'))!),decision=path.join(leases,files.find(name=>name.endsWith('.decision'))!),record=JSON.parse(fs.readFileSync(decision,'utf8'));
expect(record.candidateDev).toBe(String(fs.lstatSync(candidate,{bigint:true}).dev));
expect(record.candidateIno).toBe(String(fs.lstatSync(candidate,{bigint:true}).ino));
expect(BigInt(record.candidateIno)).toBeGreaterThanOrEqual(first);
return i;
})).toBe(i);
expect(fs.readdirSync(path.join(dir,'.mutation-lock-leases'))).toEqual([]);
}finally{restore();}
});
test('recovers a dead owner and its exact high-ID decision, then admits the next operation',()=>{
const dir=tmp(),restore=syntheticIds(dir,9007199254740992n);
try{
const {leases,candidate,decision}=leaseFixture(dir,2147483647,'a'.repeat(32));
expect(withLock(dir,()=>1)).toBe(1);
expect(fs.existsSync(candidate)).toBe(false);expect(fs.existsSync(decision)).toBe(false);
expect(withLock(dir,()=>2)).toBe(2);expect(fs.readdirSync(leases)).toEqual([]);
}finally{restore();}
});
test('a decision left after exact candidate removal keeps its high ID until recovery',()=>{
const dir=tmp(),restore=syntheticIds(dir,9007199254740992n);
try{
const {leases,candidate,decision,stat}=leaseFixture(dir,2147483647,'e'.repeat(32));
expect(JSON.parse(fs.readFileSync(decision,'utf8')).candidateIno).toBe(String(stat.ino));
fs.unlinkSync(candidate);
expect(withLock(dir,()=>3)).toBe(3);
expect(fs.readdirSync(leases)).toEqual([]);
}finally{restore();}
});
test('a live foreign lease is not stolen, even when its timestamp is old',()=>{
const dir=tmp(),restore=syntheticIds(dir,9007199254740992n);
try{
const {candidate,decision}=leaseFixture(dir,process.pid,'b'.repeat(32));
expect(()=>withLock(dir,()=>1)).toThrow('Another operation in this helper');
expect(fs.existsSync(candidate)).toBe(true);expect(fs.existsSync(decision)).toBe(true);
}finally{restore();}
});
test('a legacy rounded high-ID decision is ambiguous and remains blocked',()=>{
const dir=tmp(),restore=syntheticIds(dir,9007199254740993n,2n);
try{
const token='c'.repeat(32),{candidate,decision,stat}=leaseFixture(dir,2147483647,token);
expect(stat.ino%2n).toBe(1n);
const record=JSON.parse(fs.readFileSync(decision,'utf8'));
record.candidateIno=String(Number(stat.ino));
fs.writeFileSync(decision,JSON.stringify(record)+'\n',{mode:0o600});
expect(record.candidateIno).not.toBe(String(stat.ino));
let failure:unknown;try{withLock(dir,()=>1);}catch(error){failure=error;}
expect(failure).toMatchObject({code:'UNSAFE_PATH'});
expect(fs.existsSync(candidate)).toBe(true);expect(fs.existsSync(decision)).toBe(true);
}finally{restore();}
});
test('a neighboring replacement inode is rejected during exact release',()=>{
const dir=tmp(),restore=syntheticIds(dir,9007199254740992n);
try{
let replaced='';
let failure:unknown;try{withLock(dir,()=>{
const leases=path.join(dir,'.mutation-lock-leases'),active=fs.readdirSync(leases).find(name=>name.includes('.active.'))!;
replaced=path.join(leases,active);const bytes=fs.readFileSync(replaced);
fs.renameSync(replaced,`${replaced}.original`);fs.writeFileSync(replaced,bytes,{mode:0o600});
});}catch(error){failure=error;}
expect(failure).toMatchObject({code:'PERSISTENCE_FAILED'});
expect(fs.existsSync(replaced)).toBe(true);
}finally{restore();}
});
test.skipIf(process.platform==='win32')('a symlink substituted for a dead owner is never reclaimed',()=>{
const dir=tmp(),restore=syntheticIds(dir,9007199254740992n);
try{
const {candidate,decision}=leaseFixture(dir,2147483647,'f'.repeat(32)),original=`${candidate}.original`;
fs.renameSync(candidate,original);fs.symlinkSync(original,candidate);
let failure:unknown;try{withLock(dir,()=>1);}catch(error){failure=error;}
expect(failure).toMatchObject({code:'UNSAFE_PATH'});
expect(fs.lstatSync(candidate).isSymbolicLink()).toBe(true);
expect(fs.existsSync(decision)).toBe(true);
}finally{restore();}
});
test('atomic recovery matches only the exact high-ID hard-link publication',()=>{
const dir=tmp(),restore=syntheticIds(dir,9007199254740992n);
try{
const target=path.join(dir,'artifact.json'),temporary=`${target}.tmp.2147483647.deadbeef`;
fs.writeFileSync(temporary,'{"value":"retained"}\n',{mode:0o600});fs.linkSync(temporary,target);
recoverAtomicNoReplaceJson(target,{label:'Synthetic publication',maxBytes:4096});
expect(fs.existsSync(temporary)).toBe(false);expect(JSON.parse(fs.readFileSync(target,'utf8'))).toEqual({value:'retained'});
}finally{restore();}
});
test('adjacent IDs that round to the same Number cannot impersonate a publication',()=>{
const dir=tmp(),restore=syntheticIds(dir,9007199254740992n);
try{
const target=path.join(dir,'artifact.json'),temporary=`${target}.tmp.2147483647.deadbeef`,time=new Date(1_700_000_000_000);
fs.writeFileSync(target,'{"value":"retained"}\n',{mode:0o600});
fs.writeFileSync(temporary,'{"value":"retained"}\n',{mode:0o600});
fs.utimesSync(target,time,time);fs.utimesSync(temporary,time,time);
fs.linkSync(target,path.join(dir,'other'));fs.linkSync(temporary,path.join(dir,'another'));
const original=fs.lstatSync(target,{bigint:true}),foreign=fs.lstatSync(temporary,{bigint:true});
expect(foreign.ino-original.ino).toBe(1n);
expect(Number(foreign.ino)).toBe(Number(original.ino));
let failure:unknown;try{recoverAtomicNoReplaceJson(target,{label:'Synthetic publication',maxBytes:4096});}catch(error){failure=error;}
expect(failure).toMatchObject({code:'UNSAFE_PATH'});
expect(fs.existsSync(temporary)).toBe(true);
expect(fs.readFileSync(target,'utf8')).toBe('{"value":"retained"}\n');
}finally{restore();}
});
test('atomic recovery rejects a same-millisecond timestamp change while the file is opened',()=>{
const dir=tmp(),target=path.join(dir,'artifact.json'),temporary=`${target}.tmp.2147483647.deadbeef`,fstat=fs.fstatSync;
fs.writeFileSync(temporary,'{"value":"retained"}\n',{mode:0o600});fs.linkSync(temporary,target);
fs.utimesSync(target,1_700_000_000.0001,1_700_000_000.0001);
let injected=false,failure:unknown;
const reader=spyOn(fs,'fstatSync').mockImplementation(((fd:any,options?:any)=>{
const stat=fstat(fd,options);
if(injected||options?.bigint!==true)return stat;
injected=true;
return Object.assign(Object.create(Object.getPrototypeOf(stat)),stat,{mtimeNs:stat.mtimeMs*1_000_000n+600_000n});
}) as typeof fs.fstatSync);
try{recoverAtomicNoReplaceJson(target,{label:'Synthetic publication',maxBytes:4096});}catch(error){failure=error;}finally{reader.mockRestore();}
expect(injected).toBe(true);
expect(failure).toMatchObject({code:'SNAPSHOT_RACE'});
expect(fs.existsSync(temporary)).toBe(true);
});
});
+1 -1
View File
@@ -24,7 +24,7 @@ describe('CSO dirty snapshot boundary',()=>{
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('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.readFileSync(path.join(run.dir,'readable','.env'),'utf8')).toBe('TOKEN=<REDACTED-env.kv+github.pat>\n');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');});
+66
View File
@@ -310,6 +310,72 @@ describe('CSO native Windows build contract', () => {
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);
}, 120_000);
test('NTFS high file IDs survive repeated native commands and ambiguous old decisions remain blocked', () => {
const repository=path.join(temporary,'lease lifecycle repository'),profile=path.join(temporary,'lease lifecycle 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("fixture")\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};
const command=(args:string[])=>spawnSync(actual,args,{cwd:repository,encoding:'utf8',env,timeout:30_000}),submission=path.join(profile,'submission.json');
fs.writeFileSync(submission,'{}\n');
for(let i=0;i<3;i++){
const started=command(['start','--repo',repository,'--offline']);expectSuccessfulProcess(started,'gstack-cso start');
const run=JSON.parse(started.stdout),dir=path.join(profile,'.gstack','security','cso',run.repoId,run.runId),leases=path.join(dir,'.mutation-lock-leases');
const initialized=command(['resume',run.runId]);expectSuccessfulProcess(initialized,'gstack-cso initialize mutation lease');
expect(fs.realpathSync(leases).startsWith(fs.realpathSync(profile)+path.sep)).toBe(true);
if(i===0){
const churn=path.join(leases,'churn');let inode=0n;
for(let attempt=0;attempt<1024;attempt++){
fs.writeFileSync(churn,'x');inode=fs.lstatSync(churn,{bigint:true}).ino;fs.unlinkSync(churn);
if(inode>BigInt(Number.MAX_SAFE_INTEGER))break;
}
expect(inode).toBeGreaterThan(BigInt(Number.MAX_SAFE_INTEGER));
}
const inspected=command(['inspect',run.runId]);expectSuccessfulProcess(inspected,'gstack-cso inspect');
expect(JSON.parse(inspected.stdout).report.status).toBe('running');
const read=command(['read',run.runId,'app.js']);expectSuccessfulProcess(read,'gstack-cso read');expect(read.stdout).toContain('fixture');
const history=command(['history',run.runId]);expectSuccessfulProcess(history,'gstack-cso history');
const submitted=command(['submit',run.runId,submission]);expectSuccessfulProcess(submitted,'gstack-cso submit');
const resumed=command(['resume',run.runId]);expectSuccessfulProcess(resumed,'gstack-cso resume');
const finished=command(['finish',run.runId]);expectSuccessfulProcess(finished,'gstack-cso finish');
expect(JSON.parse(finished.stdout).completeness).not.toBe('complete');
const report=JSON.parse(fs.readFileSync(path.join(dir,'report.json'),'utf8'));
expect(report.coverage.some((entry:any)=>entry.status==='not_assessed')).toBe(true);
expect(fs.readdirSync(leases)).toEqual([]);
if(i!==2)continue;
const token='d'.repeat(32),candidate=path.join(leases,`${token}.json`),decision=path.join(leases,`${token}.decision`);
for(const rounded of [false,true]){
let selected:string|undefined;
for(let batch=0;batch<16&&!selected;batch++){
const paths:string[]=[];
for(let index=0;index<64;index++){
const file=path.join(leases,`fixture-${batch}-${index}`);
fs.writeFileSync(file,JSON.stringify({pid:2147483647,token,createdAt:0})+'\n');paths.push(file);
const inode=fs.lstatSync(file,{bigint:true}).ino;
if(!selected&&inode>BigInt(Number.MAX_SAFE_INTEGER)&&String(Number(inode))!==String(inode))selected=file;
}
for(const file of paths){if(file===selected)fs.renameSync(file,candidate);else fs.unlinkSync(file);}
}
expect(selected).toBeDefined();
const stat=fs.lstatSync(candidate,{bigint:true}),record={schemaVersion:1,token,kind:'ticket',ticket:'0000000000000001',candidateDev:String(stat.dev),candidateIno:rounded?String(Number(stat.ino)):String(stat.ino),ownerPid:2147483647,ownerCreatedAt:0,publisherPid:2147483647,createdAt:0};
fs.writeFileSync(decision,JSON.stringify(record)+'\n');
const result=command(['resume',run.runId]);expect(result.status).not.toBe(0);
if(rounded){
expect(record.candidateIno).not.toBe(String(stat.ino));expect(result.stderr).toContain('UNSAFE_PATH');
expect(fs.existsSync(candidate)).toBe(true);expect(fs.existsSync(decision)).toBe(true);
fs.unlinkSync(decision);fs.unlinkSync(candidate);
}else{
expect(result.stderr).toContain('INVALID_SCHEMA');
expect(fs.existsSync(candidate)).toBe(false);expect(fs.existsSync(decision)).toBe(false);
}
}
const next=command(['start','--repo',repository,'--offline']);expectSuccessfulProcess(next,'gstack-cso start after legacy state');
expect(JSON.parse(next.stdout).runId).not.toBe(run.runId);
}
}, 180_000);
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);
+181 -7
View File
@@ -16,6 +16,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
import { canRevokeReads } from './helpers/fs-caps';
// Integration tests spawn real git/gh/glab subprocesses. The default 5s
// per-test timeout is tight on developer machines; raise to 30s to match
@@ -150,6 +151,7 @@ function run(argv: string[], opts: { env?: Record<string, string>; input?: strin
GSTACK_HOME: tmpHome,
USER: 'testuser',
HOME: tmpHome,
GIT_CONFIG_NOSYSTEM: '1',
...(opts.env || {}),
};
const res = spawnSync(INIT_BIN, argv, {
@@ -166,11 +168,23 @@ function run(argv: string[], opts: { env?: Record<string, string>; input?: strin
};
}
function gitInFixture(argv: string[]) {
return spawnSync('git', argv, {
encoding: 'utf-8',
timeout: 30_000,
env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1' },
});
}
function readCalls(file: string): string[] {
if (!fs.existsSync(file)) return [];
return fs.readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean);
}
function expectNoAllowlistTemps() {
expect(fs.readdirSync(tmpHome).filter((name) => name.startsWith('.brain-allowlist.'))).toEqual([]);
}
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'artifacts-init-'));
bareRemote = fs.mkdtempSync(path.join(os.tmpdir(), 'artifacts-bare-'));
@@ -239,6 +253,7 @@ describe('gstack-artifacts-init provider selection', () => {
});
test('only glab authed → defaults to gitlab (no prompt)', () => {
makeFakeGh({ authStatus: 'fail' });
makeFakeGlab({});
const r = run([]);
expect(r.status).toBe(0);
@@ -246,7 +261,8 @@ describe('gstack-artifacts-init provider selection', () => {
});
test('neither authed → falls through to manual URL paste', () => {
// No gh, no glab fakes.
makeFakeGh({ authStatus: 'fail' });
makeFakeGlab({ authStatus: 'fail' });
const r = run([], { input: 'https://github.com/testuser/gstack-artifacts-testuser\n' });
expect(r.status).toBe(0);
expect(r.stderr).toContain('Neither gh nor glab');
@@ -278,7 +294,7 @@ describe('gstack-artifacts-init canonical URL storage (codex Finding #10)', () =
makeFakeGh({ webUrl: 'https://github.com/testuser/gstack-artifacts-testuser' });
const r = run(['--host', 'github']);
expect(r.status).toBe(0);
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8', timeout: 30_000 });
const remote = gitInFixture(['-C', tmpHome, 'remote', 'get-url', 'origin']);
expect(remote.stdout.trim()).toBe('https://github.com/testuser/gstack-artifacts-testuser');
});
@@ -289,7 +305,7 @@ describe('gstack-artifacts-init canonical URL storage (codex Finding #10)', () =
});
const r = run(['--host', 'github']);
expect(r.status).toBe(0);
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8', timeout: 30_000 });
const remote = gitInFixture(['-C', tmpHome, 'remote', 'get-url', 'origin']);
expect(remote.stdout.trim()).toBe('git@github.com:testuser/gstack-artifacts-testuser.git');
});
@@ -297,7 +313,7 @@ describe('gstack-artifacts-init canonical URL storage (codex Finding #10)', () =
makeFakeGh({ gitProtocol: 'unset' });
const r = run(['--host', 'github']);
expect(r.status).toBe(0);
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8', timeout: 30_000 });
const remote = gitInFixture(['-C', tmpHome, 'remote', 'get-url', 'origin']);
expect(remote.stdout.trim()).toBe('https://github.com/testuser/gstack-artifacts-testuser');
});
@@ -305,7 +321,7 @@ describe('gstack-artifacts-init canonical URL storage (codex Finding #10)', () =
makeFakeGlab({ gitProtocol: 'ssh' });
const r = run(['--host', 'gitlab']);
expect(r.status).toBe(0);
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8', timeout: 30_000 });
const remote = gitInFixture(['-C', tmpHome, 'remote', 'get-url', 'origin']);
expect(remote.stdout.trim()).toBe('git@gitlab.com:testuser/gstack-artifacts-testuser.git');
});
});
@@ -360,7 +376,7 @@ describe('gstack-artifacts-init idempotency', () => {
makeFakeGh({ gitProtocol: 'ssh' });
const r = run(['--remote', 'https://github.com/testuser/gstack-artifacts-testuser']);
expect(r.status).toBe(0);
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8', timeout: 30_000 });
const remote = gitInFixture(['-C', tmpHome, 'remote', 'get-url', 'origin']);
expect(remote.stdout.trim()).toBe('https://github.com/testuser/gstack-artifacts-testuser');
});
@@ -373,7 +389,7 @@ describe('gstack-artifacts-init idempotency', () => {
'ssh',
]);
expect(r.status).toBe(0);
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8', timeout: 30_000 });
const remote = gitInFixture(['-C', tmpHome, 'remote', 'get-url', 'origin']);
expect(remote.stdout.trim()).toBe('git@github.com:testuser/gstack-artifacts-testuser.git');
});
@@ -392,6 +408,164 @@ describe('gstack-artifacts-init idempotency', () => {
expect(r2.status).toBe(0);
});
test('re-run preserves the user allowlist suffix byte-for-byte', () => {
makeFakeGh({});
const url = 'https://github.com/testuser/gstack-artifacts-testuser';
expect(run(['--remote', url]).status).toBe(0);
const allowlist = path.join(tmpHome, '.brain-allowlist');
const marker = '# ---- USER ADDITIONS BELOW ---- (survives re-init; above is managed)\n';
const suffix = '\n# user comment\n\ncustom/path-*.jsonl';
const original = fs.readFileSync(allowlist, 'utf-8');
fs.writeFileSync(allowlist, original.replace('projects/*/timeline.jsonl', 'projects/*/stale-managed-path.jsonl'));
fs.chmodSync(allowlist, 0o600);
fs.appendFileSync(allowlist, suffix);
expect(run(['--remote', url]).status).toBe(0);
const updated = fs.readFileSync(allowlist);
expect(updated.subarray(updated.indexOf(marker) + Buffer.byteLength(marker))).toEqual(Buffer.from(suffix));
expect(updated.toString().split(marker).length - 1).toBe(1);
expect(updated.toString()).toContain('projects/*/timeline.jsonl');
expect(updated.toString()).not.toContain('projects/*/stale-managed-path.jsonl');
expect(fs.statSync(allowlist).mode & 0o777).toBe(0o600);
expectNoAllowlistTemps();
});
test('empty allowlist is initialized with only the current managed rules', () => {
makeFakeGh({});
const url = 'https://github.com/testuser/gstack-artifacts-testuser';
expect(run(['--remote', url]).status).toBe(0);
const allowlist = path.join(tmpHome, '.brain-allowlist');
fs.writeFileSync(allowlist, '');
expect(run(['--remote', url]).status).toBe(0);
const updated = fs.readFileSync(allowlist, 'utf-8');
expect(updated).toContain('projects/*/timeline.jsonl');
expect(updated.split('# ---- USER ADDITIONS BELOW ----').length - 1).toBe(1);
expectNoAllowlistTemps();
});
test('marker-only file without a final newline is refreshed without duplication', () => {
makeFakeGh({});
const url = 'https://github.com/testuser/gstack-artifacts-testuser';
expect(run(['--remote', url]).status).toBe(0);
const allowlist = path.join(tmpHome, '.brain-allowlist');
const marker = '# ---- USER ADDITIONS BELOW ---- (survives re-init; above is managed)';
fs.writeFileSync(allowlist, marker);
expect(run(['--remote', url]).status).toBe(0);
const updated = fs.readFileSync(allowlist, 'utf-8');
expect(updated.split(marker).length - 1).toBe(1);
expect(updated.endsWith('\n')).toBe(true);
expectNoAllowlistTemps();
});
test('duplicate managed markers are refused without changing the file', () => {
makeFakeGh({});
const url = 'https://github.com/testuser/gstack-artifacts-testuser';
expect(run(['--remote', url]).status).toBe(0);
const allowlist = path.join(tmpHome, '.brain-allowlist');
const original = fs.readFileSync(allowlist);
fs.appendFileSync(allowlist, original.subarray(original.indexOf(Buffer.from('# ---- USER ADDITIONS BELOW ----'))));
const ambiguous = fs.readFileSync(allowlist);
const r = run(['--remote', url]);
expect(r.status).not.toBe(0);
expect(fs.readFileSync(allowlist)).toEqual(ambiguous);
expectNoAllowlistTemps();
});
test('markerless legacy allowlist is retained and re-init refuses ambiguity', () => {
makeFakeGh({});
const url = 'https://github.com/testuser/gstack-artifacts-testuser';
expect(run(['--remote', url]).status).toBe(0);
const allowlist = path.join(tmpHome, '.brain-allowlist');
const legacy = Buffer.from('# user rules\ncustom/legacy.jsonl');
fs.writeFileSync(allowlist, legacy);
const r = run(['--remote', url]);
expect(r.status).not.toBe(0);
expect(fs.readFileSync(allowlist)).toEqual(legacy);
expectNoAllowlistTemps();
});
test('failed allowlist replacement leaves the old file intact', () => {
makeFakeGh({});
const url = 'https://github.com/testuser/gstack-artifacts-testuser';
expect(run(['--remote', url]).status).toBe(0);
const allowlist = path.join(tmpHome, '.brain-allowlist');
fs.appendFileSync(allowlist, '\ncustom/failure-control.jsonl');
const previous = fs.readFileSync(allowlist);
const fakeMv = path.join(fakeBinDir, 'mv');
fs.writeFileSync(fakeMv, `#!/bin/bash\nlast=\"\"\nfor arg in \"$@\"; do last=\"$arg\"; done\nif [ \"$last\" = \"$GSTACK_HOME/.brain-allowlist\" ]; then exit 73; fi\nexec /bin/mv \"$@\"\n`, { mode: 0o755 });
const r = run(['--remote', url]);
expect(r.status).not.toBe(0);
expect(fs.readFileSync(allowlist)).toEqual(previous);
expectNoAllowlistTemps();
});
test('allowlist read failure leaves the old file intact', () => {
makeFakeGh({});
const url = 'https://github.com/testuser/gstack-artifacts-testuser';
expect(run(['--remote', url]).status).toBe(0);
const allowlist = path.join(tmpHome, '.brain-allowlist');
fs.appendFileSync(allowlist, '\ncustom/read-failure-control.jsonl');
const previous = fs.readFileSync(allowlist);
const fakeGrep = path.join(fakeBinDir, 'grep');
fs.writeFileSync(fakeGrep, `#!/bin/bash\nfor arg in \"$@\"; do if [ \"$arg\" = \"$GSTACK_HOME/.brain-allowlist\" ]; then exit 2; fi; done\nexec /bin/grep \"$@\"\n`, { mode: 0o755 });
const r = run(['--remote', url]);
expect(r.status).not.toBe(0);
expect(fs.readFileSync(allowlist)).toEqual(previous);
expectNoAllowlistTemps();
});
_test.skipIf(!canRevokeReads())('real unreadable allowlist is preserved when chmod blocks reads', () => {
makeFakeGh({});
const url = 'https://github.com/testuser/gstack-artifacts-testuser';
expect(run(['--remote', url]).status).toBe(0);
const allowlist = path.join(tmpHome, '.brain-allowlist');
fs.appendFileSync(allowlist, '\ncustom/unreadable-control.jsonl');
const previous = fs.readFileSync(allowlist);
fs.chmodSync(allowlist, 0);
try {
const r = run(['--remote', url]);
expect(r.status).not.toBe(0);
expectNoAllowlistTemps();
} finally {
fs.chmodSync(allowlist, 0o600);
}
expect(fs.readFileSync(allowlist)).toEqual(previous);
}, 30_000);
test('partial allowlist assembly failure preserves the original and cleans temporary files', () => {
makeFakeGh({});
const url = 'https://github.com/testuser/gstack-artifacts-testuser';
expect(run(['--remote', url]).status).toBe(0);
const allowlist = path.join(tmpHome, '.brain-allowlist');
fs.appendFileSync(allowlist, '\ncustom/assembly-failure-control.jsonl');
const previous = fs.readFileSync(allowlist);
const fakeCat = path.join(fakeBinDir, 'cat');
fs.writeFileSync(fakeCat, [
'#!/bin/bash',
'for arg in "$@"; do',
' case "$arg" in',
' "$GSTACK_HOME"/.brain-allowlist.*)',
' if /bin/grep -qF \'# Canonical allowlist of paths that gstack-brain-sync will publish.\' "$arg"; then /bin/head -c 32 "$arg"; exit 73; fi',
' ;;',
' esac',
'done',
'exec /bin/cat "$@"',
'',
].join('\n'), { mode: 0o755 });
const r = run(['--remote', url]);
expect(r.status).not.toBe(0);
expect(fs.readFileSync(allowlist)).toEqual(previous);
expectNoAllowlistTemps();
});
test('re-run with DIFFERENT --remote exits 1 with conflict message', () => {
makeFakeGh({});
run(['--remote', 'https://github.com/testuser/gstack-artifacts-testuser']);
+268
View File
@@ -0,0 +1,268 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { spawn, spawnSync } from 'node:child_process';
import { canRevokeReads } from './helpers/fs-caps';
const hook = path.resolve(import.meta.dir, '../bin/gstack-settings-hook');
let root: string;
let target: string;
let settings: string;
beforeEach(() => {
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-link-')));
fs.mkdirSync(path.join(root, 'config'));
fs.mkdirSync(path.join(root, 'dotfiles'));
target = path.join(root, 'dotfiles/settings.json');
settings = path.join(root, 'config/settings.json');
fs.writeFileSync(target, '{"theme":"dark","hooks":{}}\n', { mode: 0o600 });
fs.symlinkSync('../dotfiles/settings.json', settings);
expect(fs.realpathSync(settings)).toBe(target);
expect(target.startsWith(root + path.sep)).toBe(true);
});
afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
function env(file = settings) {
return { ...process.env, HOME: root, GSTACK_HOME: path.join(root, 'state'), GSTACK_SETTINGS_FILE: file };
}
function addArgs(source = 'link-test') {
return ['add-event', '--event', 'PostToolUse', '--command', '/fixture/hook', '--source', source];
}
function run(args: string[], file = settings) {
return spawnSync('bash', [hook, ...args], { env: env(file), encoding: 'utf8', timeout: 10_000 });
}
function runWithRealpathFailure(args: string[], code: string, afterTemp = false) {
const fakeBin = path.join(root, 'bin');
const preload = path.join(root, 'realpath-permission.ts');
fs.mkdirSync(fakeBin);
fs.writeFileSync(preload, `const fs = require('fs');
const original = fs.realpathSync;
fs.realpathSync = (file, ...args) => {
if (String(file) === process.env.GSTACK_SETTINGS_INPUT && (!${afterTemp} || (process.env.GSTACK_TMP_PATH && fs.existsSync(process.env.GSTACK_TMP_PATH)))) {
throw Object.assign(new Error('synthetic realpath permission failure'), { code: ${JSON.stringify(code)} });
}
return original(file, ...args);
};
`);
fs.writeFileSync(path.join(fakeBin, 'bun'), '#!/bin/sh\nexec "$ACTUAL_BUN" --preload "$FS_PROBE" "$@"\n', { mode: 0o755 });
return spawnSync('bash', [hook, ...args], {
env: { ...env(), PATH: fakeBin + path.delimiter + process.env.PATH, ACTUAL_BUN: process.execPath, FS_PROBE: preload },
encoding: 'utf8', timeout: 10_000,
});
}
describe('settings hook preserves the resolved settings target', () => {
test('add, ensure and remove preserve the link, real target and private mode', () => {
const add = run(addArgs());
expect(add.status, add.stderr).toBe(0);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(settings)).toBe('../dotfiles/settings.json');
expect(JSON.parse(fs.readFileSync(target, 'utf8')).hooks.PostToolUse[0]._gstack_source).toBe('link-test');
expect(fs.statSync(target).mode & 0o777).toBe(0o600);
const before = fs.readFileSync(target, 'utf8');
const ensure = run(['ensure-event', ...addArgs().slice(1)]);
expect(ensure.status, ensure.stderr).toBe(0);
expect(fs.readFileSync(target, 'utf8')).toBe(before);
const remove = run(['remove-source', '--source', 'link-test']);
expect(remove.status, remove.stderr).toBe(0);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(JSON.parse(fs.readFileSync(target, 'utf8')).theme).toBe('dark');
expect(fs.readFileSync(target, 'utf8')).not.toContain('link-test');
});
test('backup and rollback are siblings of the real target, not the link', () => {
const original = fs.readFileSync(target, 'utf8');
const add = run(addArgs());
expect(add.status, add.stderr).toBe(0);
expect(fs.existsSync(target + '.bak-latest')).toBe(true);
expect(fs.existsSync(settings + '.bak-latest')).toBe(false);
const rollback = run(['rollback']);
expect(rollback.status, rollback.stderr).toBe(0);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(target, 'utf8')).toBe(original);
expect(fs.statSync(target).mode & 0o777).toBe(0o600);
});
test('different aliases contend for the same target lock', () => {
fs.mkdirSync(target + '.lock');
fs.writeFileSync(target + '.lock/owner', 'fixture-owner');
const result = spawnSync('bash', [hook, ...addArgs()], {
env: { ...env(), GSTACK_SETTINGS_LOCK_TIMEOUT_MS: '100' },
encoding: 'utf8', timeout: 10_000,
});
expect(result.status).toBe(5);
expect(result.stderr).toContain('could not acquire lock');
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(target, 'utf8')).not.toContain('link-test');
expect(fs.existsSync(settings + '.lock')).toBe(false);
});
test('dangling and cyclic links are rejected without replacing them', () => {
fs.unlinkSync(target);
const dangling = run(addArgs());
expect(dangling.status).not.toBe(0);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.existsSync(target)).toBe(false);
fs.symlinkSync('../config/settings.json', target);
const cyclic = run(addArgs());
expect(cyclic.status).not.toBe(0);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.lstatSync(target).isSymbolicLink()).toBe(true);
});
test('malformed linked settings stay unchanged', () => {
fs.writeFileSync(target, '{not json');
const result = run(addArgs());
expect(result.status).toBe(3);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(target, 'utf8')).toBe('{not json');
});
test('a missing ordinary settings file can still be created', () => {
const fresh = path.join(root, 'config/fresh.json');
const result = run(addArgs(), fresh);
expect(result.status, result.stderr).toBe(0);
expect(fs.lstatSync(fresh).isFile()).toBe(true);
expect(JSON.parse(fs.readFileSync(fresh, 'utf8')).hooks.PostToolUse).toHaveLength(1);
expect(fs.statSync(fresh).mode & 0o777).toBe(0o600);
});
test('read-only commands and absent-parent no-ops do not create files', () => {
expect(run(addArgs()).status).toBe(0);
const original = fs.readFileSync(target, 'utf8');
const siblings = fs.readdirSync(path.dirname(target));
expect(run(['list-sources']).stdout).toContain('link-test');
expect(run(['list-items', '--event', 'PostToolUse']).stdout).toContain('/fixture/hook');
expect(run(['diff-event', ...addArgs().slice(1)]).status).toBe(0);
expect(fs.readFileSync(target, 'utf8')).toBe(original);
expect(fs.readdirSync(path.dirname(target))).toEqual(siblings);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
const missing = path.join(root, 'absent/config/settings.json');
expect(run(['list-sources'], missing).status).toBe(0);
expect(run(['remove-source', '--source', 'link-test'], missing).status).toBe(0);
expect(fs.existsSync(path.join(root, 'absent'))).toBe(false);
});
test('legacy add, remove and prune-stale use the real target', () => {
const command = path.join(root, 'missing/bin/gstack-session-update');
expect(run(['add', command]).status).toBe(0);
expect(fs.readFileSync(target, 'utf8')).toContain(command);
expect(run(['remove', command]).status).toBe(0);
expect(fs.readFileSync(target, 'utf8')).not.toContain(command);
expect(run(['add', command]).status).toBe(0);
expect(run(['prune-stale']).status).toBe(0);
expect(fs.readFileSync(target, 'utf8')).not.toContain(command);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(JSON.parse(fs.readFileSync(target, 'utf8')).theme).toBe('dark');
});
test.skipIf(!canRevokeReads())('an unreadable linked target stays untouched', () => {
const original = fs.readFileSync(target, 'utf8');
fs.chmodSync(target, 0o000);
try {
const result = run(addArgs());
expect(result.status, result.stderr).toBe(3);
expect(result.stderr).toContain('cannot read');
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.readdirSync(path.dirname(target))).toEqual(['settings.json']);
} finally {
fs.chmodSync(target, 0o600);
}
expect(fs.readFileSync(target, 'utf8')).toBe(original);
});
for (const code of ['EACCES', 'EPERM']) {
test(`a ${code} realpath failure keeps the unreadable-settings exit contract`, () => {
const original = fs.readFileSync(target, 'utf8');
const result = runWithRealpathFailure(addArgs(), code);
expect(result.status, result.stderr).toBe(3);
expect(result.stderr).toContain('cannot read');
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.readdirSync(path.dirname(target))).toEqual(['settings.json']);
expect(fs.readFileSync(target, 'utf8')).toBe(original);
});
}
for (const action of ['add-event', 'rollback']) {
test(`a late permission failure during ${action} removes its temporary settings copy`, () => {
if (action === 'rollback') expect(run(addArgs()).status).toBe(0);
const original = fs.readFileSync(target, 'utf8');
const result = runWithRealpathFailure(action === 'rollback' ? ['rollback'] : addArgs(), 'EACCES', true);
expect(result.status, result.stderr).toBe(3);
expect(result.stderr).toContain('cannot read');
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(target, 'utf8')).toBe(original);
expect(fs.readdirSync(path.dirname(target)).filter(name => name.startsWith('settings.json.tmp.'))).toEqual([]);
});
}
test('concurrent writes through file and directory aliases both survive', async () => {
const directoryAlias = path.join(root, 'linked-directory');
fs.symlinkSync('dotfiles', directoryAlias);
const directorySettings = path.join(directoryAlias, 'settings.json');
expect(fs.realpathSync(directorySettings)).toBe(target);
const first = Bun.spawn(['bash', hook, ...addArgs()], { env: env(), stdout: 'pipe', stderr: 'pipe' });
const second = Bun.spawn(['bash', hook, 'add-event', '--event', 'Stop', '--command', '/fixture/stop', '--source', 'second-source'], {
env: env(directorySettings), stdout: 'pipe', stderr: 'pipe',
});
try {
const statuses = await Promise.all([first.exited, second.exited]);
expect(statuses).toEqual([0, 0]);
const stored = JSON.parse(fs.readFileSync(target, 'utf8'));
expect(stored.hooks.PostToolUse[0]._gstack_source).toBe('link-test');
expect(stored.hooks.Stop[0]._gstack_source).toBe('second-source');
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.lstatSync(directoryAlias).isSymbolicLink()).toBe(true);
} finally {
if (first.exitCode === null) first.kill();
if (second.exitCode === null) second.kill();
await Promise.all([first.exited, second.exited]);
}
});
test('a link retargeted while waiting for its lock cannot mutate either target', async () => {
const other = path.join(root, 'dotfiles/other.json');
const original = fs.readFileSync(target, 'utf8');
fs.writeFileSync(other, original);
fs.mkdirSync(target + '.lock');
fs.writeFileSync(target + '.lock/owner', 'fixture-owner');
const bin = path.join(root, 'bin');
fs.mkdirSync(bin);
const realMkdir = spawnSync('which', ['mkdir'], { encoding: 'utf8', timeout: 5_000 }).stdout.trim();
expect(realMkdir).not.toBe('');
const observed = path.join(root, 'lock-observed');
fs.writeFileSync(path.join(bin, 'mkdir'), '#!/bin/bash\nif [ "$1" = "$LOCK_EXPECTED" ]; then printf seen > "$LOCK_OBSERVED"; fi\nexec "$REAL_MKDIR" "$@"\n', { mode: 0o755 });
const child = spawn('bash', [hook, ...addArgs()], {
env: { ...env(), PATH: `${bin}:${process.env.PATH}`, LOCK_EXPECTED: target + '.lock', LOCK_OBSERVED: observed, REAL_MKDIR: realMkdir },
stdio: ['ignore', 'pipe', 'pipe'], timeout: 5_000,
});
let stderr = '';
child.stdout.resume();
child.stderr.on('data', data => { stderr += data; });
const finished = new Promise<number | null>((resolve, reject) => {
child.once('error', reject);
child.once('close', resolve);
});
try {
const deadline = Date.now() + 2_000;
while (!fs.existsSync(observed) && Date.now() < deadline) await Bun.sleep(10);
expect(fs.existsSync(observed)).toBe(true);
fs.unlinkSync(settings);
fs.symlinkSync('../dotfiles/other.json', settings);
expect(fs.realpathSync(settings)).toBe(other);
fs.rmSync(target + '.lock', { recursive: true });
expect(await finished, stderr).not.toBe(0);
expect(fs.readFileSync(target, 'utf8')).toBe(original);
expect(fs.readFileSync(other, 'utf8')).toBe(original);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
} finally {
if (child.exitCode === null) child.kill();
await finished;
}
});
});
+43
View File
@@ -116,6 +116,49 @@ describe('buildHermeticEnv allowlist', () => {
expect(e.GH_TOKEN).toBeUndefined(); // not in extraAllow
});
test('prefixes keep CI metadata but do not admit credential-shaped operator names', () => {
const base = {
...CONTAMINATED,
GITHUB_TOKEN: 'synthetic-token',
GITHUB_PERSONAL_ACCESS_TOKEN: 'synthetic-pat',
GITHUB_APP_PRIVATE_KEY: 'synthetic-private-key',
GITHUB_CLIENT_SECRET: 'synthetic-client-secret',
GITHUB_PAT: 'synthetic-pat-short',
EVALS_API_KEY: 'synthetic-eval-key',
GITHUB_SHA: 'abc123',
GITHUB_PATH: '/tmp/actions-path',
GITHUB_TOKENIZER: 'metadata-tokenizer',
GITHUB_KEYRING: 'metadata-keyring',
EVALS_RUN_ID: 'run-123',
EVALS_SELECTION_JSON: '{}',
};
const result = buildHermeticEnv(base, HERMETIC_VARS);
for (const name of [
'GITHUB_TOKEN', 'GITHUB_PERSONAL_ACCESS_TOKEN', 'GITHUB_APP_PRIVATE_KEY',
'GITHUB_CLIENT_SECRET', 'GITHUB_PAT', 'EVALS_API_KEY', 'GH_TOKEN',
]) expect(result[name]).toBeUndefined();
for (const name of [
'GITHUB_ACTIONS', 'GITHUB_SHA', 'GITHUB_PATH', 'GITHUB_TOKENIZER',
'GITHUB_KEYRING', 'EVALS_MODEL', 'EVALS_RUN_ID', 'EVALS_SELECTION_JSON',
]) expect(result[name]).toBe(base[name]);
});
test('explicit provider auth, runner admissions, and overrides still win', () => {
const base = {
...CONTAMINATED,
GITHUB_TOKEN: 'synthetic-token',
GEMINI_API_KEY: 'synthetic-gemini',
};
const result = buildHermeticEnv(base, HERMETIC_VARS, {
GITHUB_APP_PRIVATE_KEY: 'synthetic-override',
}, { extraAllow: ['GEMINI_*', 'GITHUB_TOKEN'] });
expect(result.ANTHROPIC_API_KEY).toBe(base.ANTHROPIC_API_KEY);
expect(result.GEMINI_API_KEY).toBe(base.GEMINI_API_KEY);
expect(result.GITHUB_TOKEN).toBe(base.GITHUB_TOKEN);
expect(result.GITHUB_APP_PRIVATE_KEY).toBe('synthetic-override');
expect(buildHermeticEnv(base, HERMETIC_VARS).GITHUB_TOKEN).toBeUndefined();
});
test('TERM falls back when base omits it', () => {
const base = { ...CONTAMINATED } as NodeJS.ProcessEnv;
delete base.TERM;
+10 -3
View File
@@ -63,12 +63,18 @@ const ALLOW_EXACT = new Set([
/** Prefix rules: eval-harness knobs + CI metadata. Deliberately NOT here:
* CONDUCTOR_* / CLAUDE_* (incl. CLAUDECODE, CLAUDE_CODE_ENTRYPOINT) /
* GSTACK_* / MCP_* / GBRAIN_* — session-context contamination; and operator
* credentials (GH_TOKEN, SSH_AUTH_SOCK, GIT_*, OPENAI_API_KEY,
* credentials (GH_TOKEN, GITHUB_*_TOKEN, SSH_AUTH_SOCK, GIT_*, OPENAI_API_KEY,
* VOYAGE_API_KEY) — CI doesn't have them and eval children have no business
* using them. A test that legitimately needs one opts in via its own env
* override; a provider runner (codex/gemini) re-admits its auth vars via
* opts.extraAllow. */
* opts.extraAllow. Prefix matches reject credential-shaped suffixes; exact
* and explicit runner admissions still win. */
const ALLOW_PREFIXES = ['EVALS_', 'GITHUB_'];
const CREDENTIAL_SUFFIXES = new Set([
'KEY', 'KEYS', 'TOKEN', 'TOKENS', 'SECRET', 'SECRETS', 'PASSWORD', 'PASSWD',
'PASS', 'CREDENTIAL', 'CREDENTIALS', 'AUTH', 'PAT', 'DSN', 'COOKIE',
'SESSION', 'PRIVATE',
]);
export interface HermeticEnvOpts {
/** Per-runner additional allowed names (exact match) or prefixes (entries
@@ -115,7 +121,8 @@ export function buildHermeticEnv(
const allowed =
ALLOW_EXACT.has(k) ||
extraExact.has(k) ||
ALLOW_PREFIXES.some((p) => k.startsWith(p)) ||
(ALLOW_PREFIXES.some((p) => k.startsWith(p)) &&
!CREDENTIAL_SUFFIXES.has(k.slice(k.lastIndexOf('_') + 1).toUpperCase())) ||
extraPrefixes.some((p) => k.startsWith(p));
if (allowed) out[k] = v;
}
+54
View File
@@ -1,6 +1,60 @@
import { describe, test, expect } 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 { pathToFileURL } from 'node:url';
import { parseNDJSON } from './session-runner';
test('runSkillTest launches a child without operator credentials', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hermetic-session-'));
try {
const bin = path.join(root, 'claude');
fs.writeFileSync(bin, `#!/usr/bin/env node
const names = ['GITHUB_TOKEN', 'GITHUB_PERSONAL_ACCESS_TOKEN', 'GITHUB_APP_PRIVATE_KEY', 'GH_TOKEN', 'GITHUB_ACTIONS', 'GITHUB_PATH', 'GITHUB_TOKENIZER', 'EVALS_RUN_ID'];
const present = Object.fromEntries(names.map(name => [name, Object.hasOwn(process.env, name)]));
console.log(JSON.stringify({type: 'result', subtype: 'success', result: JSON.stringify(present)}));
`, { mode: 0o700 });
const script = `import { runSkillTest } from ${JSON.stringify(pathToFileURL(path.join(import.meta.dir, 'session-runner.ts')).href)};
const result = await runSkillTest({prompt: 'synthetic fixture', workingDirectory: ${JSON.stringify(root)}, model: 'fixture', timeout: 5000, startupGraceMs: 5000, allowedTools: []});
console.log(JSON.stringify({exitReason: result.exitReason, child: JSON.parse(result.output)}));`;
const result = spawnSync(process.execPath, ['-e', script], {
cwd: path.resolve(import.meta.dir, '..', '..'),
encoding: 'utf8',
timeout: 30_000,
env: {
PATH: `${root}${path.delimiter}${process.env.PATH ?? '/usr/bin:/bin'}`,
HOME: root,
TMPDIR: os.tmpdir(),
GITHUB_TOKEN: 'synthetic-token',
GITHUB_PERSONAL_ACCESS_TOKEN: 'synthetic-pat',
GITHUB_APP_PRIVATE_KEY: 'synthetic-private-key',
GH_TOKEN: 'synthetic-gh-token',
GITHUB_ACTIONS: 'true',
GITHUB_PATH: '/tmp/actions-path',
GITHUB_TOKENIZER: 'metadata-tokenizer',
EVALS_RUN_ID: 'synthetic-run',
},
});
expect(result.status, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({
exitReason: 'success',
child: {
GITHUB_TOKEN: false,
GITHUB_PERSONAL_ACCESS_TOKEN: false,
GITHUB_APP_PRIVATE_KEY: false,
GH_TOKEN: false,
GITHUB_ACTIONS: true,
GITHUB_PATH: true,
GITHUB_TOKENIZER: true,
EVALS_RUN_ID: true,
},
});
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
// Fixture: minimal NDJSON session (system init, assistant with tool_use, tool result, assistant text, result)
const FIXTURE_LINES = [
'{"type":"system","subtype":"init","session_id":"test-123"}',
+3 -13
View File
@@ -573,20 +573,10 @@ describe("redactFindingSpans — machine-egress masking (#1947)", () => {
expect(out).toBe("first <REDACTED-aws.access_key> then <REDACTED-github.pat> end");
});
test("fails closed (null) when a span cannot be relocated — never raw passthrough", () => {
// env.kv's span (the value) starts well past the regex match start (the
// var name), so locateSpan's rewind-2 re-exec misses it. The contract is
// null → caller drops the whole payload. The one thing that must never
// happen is the secret surviving in the output.
test("masks an anchored env.kv value rather than withholding the whole payload", () => {
const secret = "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJq";
const out = redactFindingSpans(`API_KEY=${secret}`, { repoVisibility: "private" });
if (out !== null) {
// If locateSpan ever learns to find context-prefixed spans, masking
// must actually mask.
expect(out).not.toContain(secret);
} else {
expect(out).toBeNull();
}
expect(out).toBe("API_KEY=<REDACTED-env.kv>");
});
test("line/col at boundaries: line start, after blank lines, first char, last unterminated line", () => {
@@ -603,7 +593,7 @@ describe("redactFindingSpans — machine-egress masking (#1947)", () => {
expect(redactFindingSpans(`a\nb\n${token} x`, { repoVisibility: "private" })).toBe("a\nb\n<REDACTED-github.pat> x");
});
test("multiline input redacts a finding past the first line (locateSpan line/col path)", () => {
test("multiline input redacts a finding past the first line (original span map)", () => {
const token = "ghp_" + "1234567890abcdefghijklmnopqrstuvwxyz";
const out = redactFindingSpans(`line one\nline two has ${token}\nline three`, {
repoVisibility: "private",
+5 -5
View File
@@ -224,7 +224,7 @@ describe("S1: exclusion scoped to the push-target remote", () => {
const head = run(["rev-parse", "HEAD"]).trim();
const { code, stderr } = runHook(
`refs/heads/feature ${head} refs/heads/feature ${originTip}\n`,
["origin", "file:///ignored"],
["origin", run(["remote", "get-url", "--push", "origin"]).trim()],
);
expect(code).toBe(1);
expect(stderr).toContain("BLOCKED");
@@ -242,7 +242,7 @@ describe("S1: exclusion scoped to the push-target remote", () => {
const head = run(["rev-parse", "HEAD"]).trim();
const { code, stderr } = runHook(
`refs/heads/feature ${head} refs/heads/feature ${originTip}\n`,
["origin", "file:///ignored"],
["origin", run(["remote", "get-url", "--push", "origin"]).trim()],
);
expect(stderr).not.toContain("BLOCKED");
expect(code).toBe(0);
@@ -263,7 +263,7 @@ describe("S1: exclusion scoped to the push-target remote", () => {
expect(code).toBe(0);
});
test("an unconfigured name (URL push) also falls back rather than erroring", () => {
test("an unconfigured URL push cannot borrow another remote's published history", () => {
const { originTip } = buildSecretOnSecondRemote();
const head = run(["rev-parse", "HEAD"]).trim();
const url = "file:///not-a-configured-remote";
@@ -271,7 +271,7 @@ describe("S1: exclusion scoped to the push-target remote", () => {
`refs/heads/feature ${head} refs/heads/feature ${originTip}\n`,
[url, url],
);
expect(stderr).not.toContain("could not");
expect(code).toBe(0);
expect(stderr).toContain("aws.access_key");
expect(code).toBe(1);
});
});
+393
View File
@@ -0,0 +1,393 @@
import { afterEach, describe, 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 { scan } from "../lib/redact-engine";
const REDACT = path.resolve(import.meta.dir, "../bin/gstack-redact");
const roots: string[] = [];
const key = ["AKIA", "1234567890ABCDEF"].join("");
function git(cwd: string, ...args: string[]): string {
const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000 });
if (result.status !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr}`);
return result.stdout.trim();
}
function fixture(initialSecret = true, sha256 = false): { repo: string; origin: string; publish: string; head: string } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-target-"));
roots.push(root);
const repo = path.join(root, "repo");
const origin = path.join(root, "origin.git");
const publish = path.join(root, "publish.git");
fs.mkdirSync(repo);
const format = sha256 ? ["--object-format=sha256"] : [];
git(root, "init", "--bare", "-q", "-b", "main", ...format, origin);
git(root, "init", "--bare", "-q", "-b", "main", ...format, publish);
git(repo, "init", "-q", "-b", "main", ...format);
git(repo, "config", "user.name", "Fixture");
git(repo, "config", "user.email", "fixture@example.test");
fs.writeFileSync(path.join(repo, "config.txt"), initialSecret ? `key ${key}\n` : "clean\n");
git(repo, "add", "config.txt");
git(repo, "commit", "-qm", "seed");
const head = git(repo, "rev-parse", "HEAD");
git(repo, "remote", "add", "origin", origin);
git(repo, "push", "-q", "-u", "origin", "main");
git(repo, "remote", "add", "publish", publish);
const install = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(install.status).toBe(0);
return { repo, origin, publish, head };
}
function seedIndependentRemote(repo: string, publish: string): string {
const producer = path.join(path.dirname(repo), "producer");
fs.mkdirSync(producer);
git(producer, "init", "-q", "-b", "main");
git(producer, "config", "user.name", "Fixture");
git(producer, "config", "user.email", "fixture@example.test");
fs.writeFileSync(path.join(producer, "clean.txt"), "different history\n");
git(producer, "add", "clean.txt");
git(producer, "commit", "-qm", "remote tip");
git(producer, "remote", "add", "publish", publish);
git(producer, "push", "-q", "publish", "main");
return git(publish, "rev-parse", "refs/heads/main");
}
afterEach(() => {
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});
describe("installed pre-push guard uses the actual destination", () => {
test("origin tracking at HEAD cannot excuse the same credential on first push to publish", () => {
const { repo, publish, head } = fixture();
expect(git(repo, "rev-parse", "origin/main")).toBe(head);
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
});
test("a configured remote's different push URL cannot reuse its fetch tracking tip", () => {
const { repo, publish } = fixture();
git(repo, "remote", "set-url", "--push", "origin", publish);
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
});
for (const [name, symbolicHead] of [
["a local branch cannot shadow the target's qualified remote-tracking base", true],
["the main fallback cannot resolve to a colliding local branch", false],
] as const) {
test(name, () => {
const { repo, publish, head: base } = fixture(false);
fs.writeFileSync(path.join(repo, "leak.txt"), `key ${key}\n`);
git(repo, "add", "leak.txt");
git(repo, "commit", "-qm", "secret after target base");
const tip = git(repo, "rev-parse", "HEAD");
git(repo, "update-ref", "refs/remotes/publish/main", base);
if (symbolicHead) git(repo, "symbolic-ref", "refs/remotes/publish/HEAD", "refs/remotes/publish/main");
git(repo, "update-ref", "refs/heads/publish/main", tip);
expect(git(repo, "rev-parse", "publish/main")).toBe(tip);
expect(git(repo, "rev-parse", "refs/remotes/publish/main")).toBe(base);
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
});
}
test("the second fetch URL is not represented by the first URL's tracking ref", () => {
const { repo, origin, publish } = fixture();
const second = path.join(path.dirname(repo), "second.git");
git(path.dirname(repo), "init", "--bare", "-q", "-b", "main", second);
git(publish, "fetch", "-q", origin, "main:refs/heads/main");
git(repo, "fetch", "-q", "publish");
expect(git(repo, "rev-parse", "refs/remotes/publish/main")).toBe(git(repo, "rev-parse", "HEAD"));
git(repo, "remote", "set-url", "--add", "publish", second);
expect(git(repo, "remote", "get-url", "publish")).toBe(publish);
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(second, "for-each-ref", "--format=%(refname)")).toBe("");
});
test("direct URL push cannot borrow origin's tracking history", () => {
const { repo, publish } = fixture();
const push = spawnSync("git", ["push", publish, "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
});
test("an advertised remote tip absent from local objects cannot justify a guessed base", () => {
const { repo, publish } = fixture();
const remoteTip = seedIndependentRemote(repo, publish);
expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000 }).status).not.toBe(0);
const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "rev-parse", "refs/heads/main")).toBe(remoteTip);
});
test("an absent advertised tip permits a clean forced update after conservative scanning", () => {
const { repo, publish, head } = fixture(false);
const remoteTip = seedIndependentRemote(repo, publish);
expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000 }).status).not.toBe(0);
const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(0);
expect(git(publish, "rev-parse", "refs/heads/main")).toBe(head);
});
test("new SHA-256 branch scans the correct empty tree and allows a clean push", () => {
const { repo, publish } = fixture(false, true);
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(0);
expect(git(publish, "rev-parse", "refs/heads/main")).toHaveLength(64);
fs.writeFileSync(path.join(repo, "leak.txt"), `key ${key}\n`);
git(repo, "add", "leak.txt");
git(repo, "commit", "-qm", "new secret");
const blocked = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(blocked.status).toBe(1);
expect(blocked.stderr).toContain("aws.access_key");
});
test("one leaky ref blocks a multi-ref push without updating its clean neighbor", () => {
const { repo, origin, head } = fixture(false);
git(repo, "checkout", "-q", "-b", "leak");
fs.writeFileSync(path.join(repo, "leak.txt"), `key ${key}\n`);
git(repo, "add", "leak.txt");
git(repo, "commit", "-qm", "leaky branch");
git(repo, "checkout", "-q", "main");
fs.writeFileSync(path.join(repo, "clean.txt"), "safe update\n");
git(repo, "add", "clean.txt");
git(repo, "commit", "-qm", "clean branch");
const push = spawnSync("git", ["push", "origin", "main", "leak"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
expect(git(origin, "for-each-ref", "--format=%(refname)", "refs/heads/leak")).toBe("");
});
test("a deleted ref alongside a clean update is permitted", () => {
const { repo, origin } = fixture(false);
git(repo, "checkout", "-q", "-b", "disposable");
fs.writeFileSync(path.join(repo, "disposable.txt"), "safe\n");
git(repo, "add", "disposable.txt");
git(repo, "commit", "-qm", "temporary branch");
git(repo, "push", "-q", "origin", "disposable");
git(repo, "checkout", "-q", "main");
fs.writeFileSync(path.join(repo, "clean.txt"), "safe update\n");
git(repo, "add", "clean.txt");
git(repo, "commit", "-qm", "clean update");
const head = git(repo, "rev-parse", "HEAD");
const push = spawnSync("git", ["push", "origin", "main", ":disposable"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(0);
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
expect(git(origin, "for-each-ref", "--format=%(refname)", "refs/heads/disposable")).toBe("");
});
test("installed wrapper forwards refs and rejects malformed input instead of passing it on", () => {
const { repo, origin, head } = fixture(false);
const hook = git(repo, "rev-parse", "--git-path", "hooks/pre-push");
const result = spawnSync("bash", [hook, "origin", origin], {
cwd: repo, input: "refs/heads/main malformed\n", encoding: "utf8", timeout: 30_000,
});
expect(result.status, result.stderr || result.error?.message).toBe(1);
expect(result.stderr).toContain("could not parse a pre-push ref line");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
});
test("a HIGH proximity finding across line-aligned scan slices cannot pass", () => {
const { repo } = fixture(false);
const value = ["AbCdEfGhIjKlMnOpQrStU", "vWxYz0123456789AbCd"].join("");
const payload = `${"x".repeat(768 * 1024 - 43)}\naws_secret_access_key=\n${value}\n`;
expect(scan(payload).findings.map((finding) => finding.id)).toContain("aws.secret_key");
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "near seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.secret_key");
});
test("an unlabeled high-entropy value near a seam stays clean", () => {
const { repo } = fixture(false);
const value = ["AbCdEfGhIjKlMnOpQrStU", "vWxYz0123456789AbCd"].join("");
const payload = `${"x".repeat(768 * 1024 - 43)}\nordinary description\n${value}\n`;
expect(scan(payload).findings.map((finding) => finding.id)).not.toContain("aws.secret_key");
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "unlabeled seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(0);
expect(push.stderr).not.toContain("aws.secret_key");
});
test("overlap does not count a single finding twice", () => {
const { repo } = fixture(false);
const payload = `${"x".repeat(768 * 1024 - 75)}\nkey ${key}\n${"x".repeat(100)}\n`;
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "one finding at seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr.match(/HIGH aws\.access_key/g)).toHaveLength(1);
});
test("supplementary NFKC expansion cannot move a seam finding out of its owning core", () => {
const { repo, origin, head } = fixture(false);
const prefix = "\uFA6C".repeat(64);
const credential = `key ${key}\n`;
const padding = "x".repeat(768 * 1024 - Buffer.byteLength(prefix) - credential.length - 1);
const payload = `${prefix}${padding}\n${credential}${"z".repeat(100)}\n`;
expect(scan(payload).findings.map((finding) => finding.id)).toContain("aws.access_key");
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "supplementary normalization seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr.match(/HIGH aws\.access_key/g)).toHaveLength(1);
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
});
test("a partial-line overlap cannot manufacture an anchored assignment finding", () => {
const { repo } = fixture(false);
const value = ["8Fk2pQ9vXz4wL7mN", "3rT6yB1cD5eG0hJq"].join("");
const assignment = `API_KEY=${value} `;
const suffix = "z".repeat(16 * 1024 - assignment.length - 6);
const first = "!" + "x".repeat(768 * 1024 - 61 - assignment.length - suffix.length) + assignment + suffix;
const payload = `${first}\nshort\n${"z".repeat(100)}\n`;
expect(scan(payload).findings.map((finding) => finding.id)).not.toContain("env.kv");
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "mid-line control");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(0);
expect(push.stderr).not.toContain("MEDIUM finding");
});
test("mid-line carry cannot create a HIGH word boundary around an embedded key", () => {
const { repo, origin, head } = fixture(false);
const first = "x".repeat(768 * 1024 - 1 - 16_384) + key + " ".repeat(16_384 - key.length);
const payload = `${first}\nordinary\n`;
expect(scan(payload).findings.map((finding) => finding.id)).not.toContain("aws.access_key");
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "embedded key control");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(0);
expect(push.stderr).not.toContain("aws.access_key");
expect(git(origin, "rev-parse", "refs/heads/main")).not.toBe(head);
});
test("a long Bearer span retains its later Authorization context across the seam", () => {
const { repo, origin } = fixture(false);
const alphabet = "7pFb4ZaCuG8wDsVk2EnHy6Qt9Jr5Lx0M";
const token = Array.from({ length: 20_480 }, (_, i) => alphabet[(i * 7 + Math.floor(i / 31)) % 32]).join("");
const bearerLine = `Bearer ${token}`;
const payload = `${"x".repeat(768 * 1024 - bearerLine.length - 2)}\n${bearerLine}\nAuthorization\n`;
expect(scan(payload).findings.map((finding) => finding.id)).toContain("auth.bearer");
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "long bearer advisory");
const tip = git(repo, "rev-parse", "HEAD");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(0);
expect(push.stderr).toContain("MEDIUM finding");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(tip);
});
test("zero-width padding cannot move normalized proximity context out of the overlap", () => {
const { repo } = fixture(false);
const value = ["AbCdEfGhIjKlMnOpQrStU", "vWxYz0123456789AbCd"].join("");
const payload = `${"x".repeat(768 * 1024 - 43)}\naws_secret_access_key=\n${"\u200b".repeat(35_000)}\n${value}\n`;
expect(scan(payload).findings.map((finding) => finding.id)).toContain("aws.secret_key");
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "invisible seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.secret_key");
});
test("context needing more than the engine byte cap blocks instead of scanning a truncated window", () => {
const { repo, origin, head } = fixture(false);
const value = ["AbCdEfGhIjKlMnOpQrStU", "vWxYz0123456789AbCd"].join("");
const payload = `${"x".repeat(768 * 1024 - 43)}\naws_secret_access_key=\n${"\u200b".repeat(450_000)}\n${value}\n`;
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "unscannable context");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("engine.input_too_large");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
});
for (const [name, label, expected] of [
["HTML entity", "aws&amp;secret_access_key=", true],
["fullwidth Unicode", "aws_secret_access_key=", true],
["broken entity separated by zero-width", "aws&\u200bamp;secret_access_key=", false],
] as const) {
test(`${name} near a seam keeps the detector's exact normalization semantics`, () => {
const { repo } = fixture(false);
const value = ["AbCdEfGhIjKlMnOpQrStU", "vWxYz0123456789AbCd"].join("");
const payload = `${"x".repeat(768 * 1024 - 43)}\n${label}\n${value}\n`;
expect(scan(payload).findings.some((finding) => finding.id === "aws.secret_key")).toBe(expected);
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "normalization seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(expected ? 1 : 0);
expect(push.stderr.includes("aws.secret_key")).toBe(expected);
});
}
test("a large clean diff and a sub-cap long line pass, while an over-cap line fails closed", () => {
const { repo, origin, head } = fixture(false);
fs.writeFileSync(path.join(repo, "large.txt"), "ordinary content\n".repeat(160_000));
git(repo, "add", "large.txt");
git(repo, "commit", "-qm", "large clean diff");
let push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 });
expect(push.status).toBe(0);
fs.writeFileSync(path.join(repo, "long.txt"), "x".repeat(900_000) + "\n");
git(repo, "add", "long.txt");
git(repo, "commit", "-qm", "long clean line");
push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 });
expect(push.status).toBe(0);
const priorTip = git(origin, "rev-parse", "refs/heads/main");
expect(priorTip).not.toBe(head);
fs.writeFileSync(path.join(repo, "too-long.txt"), "x".repeat(1_100_000) + "\n");
git(repo, "add", "too-long.txt");
git(repo, "commit", "-qm", "over cap line");
push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("engine.input_too_large");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(priorTip);
});
test("a detectable credential at the end of a long single line is still blocked", () => {
const { repo, origin, head } = fixture(false);
fs.writeFileSync(path.join(repo, "long.txt"), `${"x".repeat(900_000)} key ${key}\n`);
git(repo, "add", "long.txt");
git(repo, "commit", "-qm", "long line with key");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
});
test("neighboring individually scannable long clean lines do not force an oversize failure", () => {
const { repo, origin } = fixture(false);
fs.writeFileSync(path.join(repo, "long.txt"), `${"x".repeat(900_000)}\n${"y".repeat(900_000)}\n`);
git(repo, "add", "long.txt");
git(repo, "commit", "-qm", "neighboring long lines");
const tip = git(repo, "rev-parse", "HEAD");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(0);
expect(push.stderr).not.toContain("engine.input_too_large");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(tip);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";
import { redactFindingSpans, scan } from "../lib/redact-engine";
import { redact, sanitizeForJson } from "../lib/cso/process";
const secret = ["8Fk2pQ9vXz4wL7mN", "3rT6yB1cD5eG0hJq"].join("");
const marker = "<REDACTED-env.kv>";
describe("redaction binds each finding to its original span", () => {
test("never masks a later unflagged assignment instead of the flagged value", () => {
const input = `DB_PASSWORD=${secret}\nOTHER_API_KEY="your-api-key-here"`;
expect(scan(input).findings.map((f) => f.id)).toEqual(["env.kv"]);
expect(redactFindingSpans(input)).toBe(`DB_PASSWORD=${marker}\nOTHER_API_KEY="your-api-key-here"`);
});
test("literal example values are suppressed before redaction", () => {
const input = `DB_PASSWORD=synthetic-example-secret\nOTHER_API_KEY="your-api-key-here"`;
expect(scan(input).findings).toEqual([]);
expect(redactFindingSpans(input)).toBe(input);
});
for (const [name, prefix, suffix] of [
["first line", "", ""],
["later line", "unrelated line\n", "\nend"],
["indented", "header\n ", ""],
["CRLF", "header\r\n", "\r\nend"],
]) {
test(`${name}: masks the captured value and keeps surrounding text`, () => {
const input = `${prefix}DB_PASSWORD=${secret}${suffix}`;
expect(scan(input).findings.map((f) => f.id)).toEqual(["env.kv"]);
expect(redactFindingSpans(input)).toBe(`${prefix}DB_PASSWORD=${marker}${suffix}`);
});
}
test("repeated identical values map independently, preserving unflagged neighbors", () => {
const input = `DB_PASSWORD=${secret}\nOTHER_API_KEY="your-api-key-here"\nAPI_KEY=${secret}`;
expect(scan(input).findings.map((f) => f.id)).toEqual(["env.kv", "env.kv"]);
expect(redactFindingSpans(input)).toBe(`DB_PASSWORD=${marker}\nOTHER_API_KEY="your-api-key-here"\nAPI_KEY=${marker}`);
});
test("many findings retain exact order without per-finding raw rescans", () => {
const input = Array.from({ length: 300 }, (_, i) => `API_KEY=${secret}${i.toString(36)}`).join("\n");
const output = redactFindingSpans(input);
expect(scan(input).findings).toHaveLength(300);
expect(output).toBe(Array.from({ length: 300 }, () => `API_KEY=${marker}`).join("\n"));
});
test("normalization maps fullwidth Unicode, entity text, and zero-width bytes back to original span", () => {
const encoded = `${secret.slice(0, 9)}&${secret.slice(9)}`;
const entity = `${secret.slice(0, 9)}&amp;${secret.slice(9)}`;
const invisible = `${secret.slice(0, 9)}\u200b${secret.slice(9)}`;
for (const value of [encoded, entity, invisible]) {
const input = `DB_PASSWORD=${value}\nOTHER_API_KEY="your-api-key-here"`;
expect(scan(input).findings.map((f) => f.id)).toEqual(["env.kv"]);
expect(redactFindingSpans(input)).toBe(`DB_PASSWORD=${marker}\nOTHER_API_KEY="your-api-key-here"`);
}
});
test("overlapping JWT and Bearer findings coalesce; marker-only and unlocated oversize finding still withhold", () => {
const part = "Ab3dE6fGh8Ij9Kl0Mn1O";
const jwt = `eyJ${part}.eyJ${part}.${part}`;
expect(scan(`Authorization: Bearer ${jwt}`).findings.map((f) => f.id)).toEqual(["auth.bearer", "jwt"]);
expect(redactFindingSpans(`Authorization: Bearer ${jwt}`)).toMatch(/^Authorization: Bearer <REDACTED-[a-z.+]+>$/);
expect(redactFindingSpans("-----BEGIN " + "PRIVATE KEY-----\nbody")).toBeNull();
expect(redactFindingSpans(`DB_PASSWORD=${secret}`, { maxBytes: 10 })).toBeNull();
});
test("CSO process and JSON output use the exact span, without dropping safe context", () => {
const input = `DB_PASSWORD=${secret}\nOTHER_API_KEY="your-api-key-here"`;
expect(redact(input)).toBe(`DB_PASSWORD=${marker}\nOTHER_API_KEY="your-api-key-here"`);
expect(sanitizeForJson({ output: input })).toEqual({ output: `DB_PASSWORD=${marker}\nOTHER_API_KEY="your-api-key-here"` });
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, test } from "bun:test";
import * as path from "node:path";
import { applyRedactions, normalizeWithMap, redactFindingSpans, scan } from "../lib/redact-engine";
import { redact, sanitizeForJson } from "../lib/cso/process";
const secret = ["8Fk2pQ9vXz4wL7mN", "3rT6yB1cD5eG0hJq"].join("");
describe("normalized redaction offsets use UTF-16 units", () => {
test("every BMP input maps every emitted UTF-16 unit and the end sentinel", () => {
const mismatches: number[] = [];
for (let code = 0; code <= 0xffff; code++) {
const { normalized, map } = normalizeWithMap(String.fromCharCode(code));
if (map.length !== normalized.length + 1
|| map[normalized.length] !== 1
|| map.slice(0, -1).some((offset) => offset !== 0)) {
mismatches.push(code);
}
}
expect(mismatches).toEqual([]);
});
for (const count of [0, 1, 64]) {
test(`${count} supplementary NFKC expansions preserve the exact credential span`, () => {
const prefix = "\uFA6C".repeat(count) + "\n";
const suffix = "\n" + "z".repeat(256);
const input = `${prefix}DB_PASSWORD=${secret}${suffix}`;
const expected = `${prefix}DB_PASSWORD=<REDACTED-env.kv>${suffix}`;
expect(redactFindingSpans(input)).toBe(expected);
expect(scan(input).findings.map(({ id, line, col }) => ({ id, line, col })))
.toEqual([{ id: "env.kv", line: 2, col: 13 }]);
expect(redact(input)).toBe(expected);
expect(sanitizeForJson({ output: input })).toEqual({ output: expected });
});
}
test("supplementary source text, entities, and zero-width input retain their original bytes", () => {
const prefix = "\u{242EE} &amp; \u200b\n";
const value = `${secret.slice(0, 9)}\u200b${secret.slice(9)}`;
const input = `${prefix}DB_PASSWORD=${value}\r\nend`;
const { normalized, map } = normalizeWithMap(input);
expect(map).toHaveLength(normalized.length + 1);
expect(map[normalized.length]).toBe(input.length);
expect(redactFindingSpans(input)).toBe(`${prefix}DB_PASSWORD=<REDACTED-env.kv>\r\nend`);
});
for (const suffix of ["", " after"]) {
test(`both masking APIs preserve exact email boundaries with suffix ${JSON.stringify(suffix)}`, () => {
const prefix = "\uFA6C contact: ";
const input = prefix + "reviewer@audit.invalid" + suffix;
expect(redactFindingSpans(input)).toBe(prefix + "<REDACTED-pii.email>" + suffix);
const result = applyRedactions(input, ["pii.email"]);
expect(result.body).toBe(prefix + "<REDACTED-EMAIL>" + suffix);
expect(result.skipped).toEqual([]);
});
}
test("the actual auto-redact CLI masks a detected email at EOF after supplementary expansion", () => {
const prefix = "\uFA6C contact: ";
const input = prefix + "reviewer@audit.invalid";
const result = Bun.spawnSync([
process.execPath, path.resolve(import.meta.dir, "../bin/gstack-redact"), "--auto-redact", "pii.email",
], { stdin: Buffer.from(input), timeout: 30_000 });
expect(result.exitCode).toBe(0);
expect(result.stdout.toString()).toBe(prefix + "<REDACTED-EMAIL>");
expect(result.stderr.toString()).not.toContain("could not be auto-redacted");
});
});
+2 -6
View File
@@ -321,13 +321,9 @@ describe('gstack-telemetry-log', () => {
expect(events[0].error_message.length).toBeLessThanOrEqual(200);
});
test('fails closed: error_message becomes null when the engine cannot relocate a span (#1947)', () => {
test('redacts an anchored error_message value at its original offset (#1947, #2930)', () => {
setConfig('telemetry', 'anonymous');
const secret = '8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJq';
// env.kv-shaped finding (line-anchored, so the assignment leads the
// message): the span (value) starts past the regex match start,
// locateSpan misses it, redactFindingSpans returns null — the bin must
// drop the whole message, never pass it through raw.
run(
`${BIN}/gstack-telemetry-log --skill qa --duration 10 --outcome error --error-message 'API_KEY=${secret} rejected by daemon' --session-id red-4`,
);
@@ -335,7 +331,7 @@ describe('gstack-telemetry-log', () => {
const lines = readJsonl();
expect(lines).toHaveLength(1);
const event = JSON.parse(lines[0]);
expect(event.error_message).toBeNull();
expect(event.error_message).toBe('API_KEY=<REDACTED-env.kv> rejected by daemon');
expect(lines[0]).not.toContain(secret);
});