diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dfa0a6e9a..679805e643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.91.123.0] - 2026-10-17 + +**/cso comprehensive repairs run again.** In the first paid runtime qualification, the agent recorded 148 repair reviews across 42 comprehensive runs, but not one verification attempt started. Every runtime scored 0/24 on held-out repairs. Two helper bugs stacked: the patch write failed on the snapshot's read-only files, and `runtime-plan` handed the agent a profile name that `verify` refused. + +### Fixed +- **`verify` can patch read-only snapshot files.** Snapshot copies keep their 0444 modes, so writing the patched file failed with EACCES for any non-root user, which is how real users and the evaluator run. The error surfaced as a generic "unexpected or unsafe input". The helper now makes the file owner-writable for the write and restores its mode afterwards. +- **`runtime-plan` returns the qualified runtime ID** (for example `node-24.4.0-amd64`) instead of the stack alias, and the skill copies that value into the verification request. `verify` rejects any other profile with an `INVALID_ARGUMENT` that names the expected ID, before an attempt is counted. + +### If you… +- **…build a verification request by hand:** use the `runtimeProfile` that `runtime-plan` printed. The stack alias is refused. + ## [1.91.122.0] - 2026-10-17 **Main no longer goes red on 2026-11-16.** All seven `.osv-scanner.toml` suppressions expired on 2026-11-30, and `test/osv-ignore-expiry.test.ts` fails 14 days early. Six are gone and the seventh is re-dated with an upstream link. Dependabot's grouped dev bump (#3228) is replaced by hand, and Version Gate stops failing every dependabot package.json PR. diff --git a/VERSION b/VERSION index e0b7c3105a..48617770e4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.122.0 +1.91.123.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 47072b96d6..957e4470ad 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.122.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.123.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/lib/cso/cli.ts b/lib/cso/cli.ts index 6061afbd05..6722727426 100644 --- a/lib/cso/cli.ts +++ b/lib/cso/cli.ts @@ -2056,7 +2056,7 @@ function testPlan(args: string[]) { ...publicTestPlan(manifest, canonicalTestPlan(join(dir, 'snapshot'), stack)), }; } -function runtimePlan(args: string[]) { +function runtimePlan(args: string[], dependencies: CsoCliDependencies) { const { dir } = run(args); if (!args.length || !['node', 'bun', 'python', 'rails'].includes(args[0])) throw new CsoError('INVALID_ARGUMENT', 'runtime-plan requires one supported stack and --port PORT'); @@ -2075,9 +2075,13 @@ function runtimePlan(args: string[]) { preparation.prerequisites.map((item) => item.message).join('; ') || `${stack} preparation metadata is incomplete`, ); + let runtimeProfile = preparation.runtimeProfile; + try { + runtimeProfile = selectRuntime(runtimeProfile, platform(), dependencies.runtimeCatalog).id; + } catch {} return { stack, - runtimeProfile: preparation.runtimeProfile, + runtimeProfile, start: publicStartPlan(manifest, canonicalStartPlan(join(dir, 'snapshot'), stack, port)), tests: publicTestPlan(manifest, canonicalTestPlan(join(dir, 'snapshot'), stack)), }; @@ -2339,6 +2343,11 @@ async function verify(args: string[], dependencies: CsoCliDependencies) { } catch (error: any) { throw new CsoError('PREREQUISITE', error?.message || 'Qualified runtime is unavailable'); } + if (request.runtimeProfile !== runtime.id) + throw new CsoError( + 'INVALID_ARGUMENT', + `runtimeProfile must be the qualified runtime ID ${runtime.id} returned by runtime-plan`, + ); const verifier = runtime; if (!['node', 'bun', 'python', 'rails'].includes(runtime.stack)) throw new CsoError( @@ -2848,7 +2857,7 @@ export async function dispatchCsoCommand( result = testPlan(args); break; case 'runtime-plan': - result = runtimePlan(args); + result = runtimePlan(args, dependencies); break; case 'recheck': result = await recheck(args, dependencies); diff --git a/lib/cso/verification.ts b/lib/cso/verification.ts index 5b0eda27b5..6ccf9ef83d 100644 --- a/lib/cso/verification.ts +++ b/lib/cso/verification.ts @@ -1035,7 +1035,9 @@ export function preparePatchedSource(snapshot: string, target: string, request: ); const mode = exists ? fs.statSync(file).mode & 0o777 : 0o600; secureDirectory(dirname(file)); + if (exists) fs.chmodSync(file, mode | 0o200); fs.writeFileSync(file, change.after, { mode }); + fs.chmodSync(file, mode); } } export function certify(params: { diff --git a/package.json b/package.json index 41bab4c69a..a130d53823 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.122", + "version": "1.91.123", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/cso-contracts.test.ts b/test/cso-contracts.test.ts index e9513eddd5..a3434a54fb 100644 --- a/test/cso-contracts.test.ts +++ b/test/cso-contracts.test.ts @@ -189,6 +189,7 @@ describe('tested repair certificate gate',()=>{ test('review is a separate content-bound but explicitly self-attested artifact',()=>{const raw:any=request('vulnerable\n','fixed\n');raw.review.reviewedPatchHash=patchHash(raw);const req=validateVerificationRequest(raw),artifact=makeReviewArtifact('run',req,'producer-session');raw.review.artifactId=artifact.id;const bound=validateVerificationRequest(raw);expect(validateReviewArtifact(artifact,'run',bound)).toMatchObject({producer:'producer-session',assurance:'self_attested'});expect(()=>makeReviewArtifact('run',req,req.review.reviewer)).toThrow('distinct');expect(()=>validateReviewArtifact({...artifact,rationale:'changed'},'run',bound)).toThrow('does not bind');}); test('executor observations have a strict helper-owned shape',()=>{expect(()=>validateVerificationObservation({booted:true,legitimate:true,security:'pass',existingTests:true,output:'ok',inputHash:'',tested:true})).toThrow('Unexpected');expect(()=>validateVerificationObservation({booted:'yes',legitimate:true,security:'pass',existingTests:true,output:'ok',inputHash:''})).toThrow('booleans');}); test('secret-bearing fixtures cannot enter a replayable verification bundle',async()=>{const runDir=tmp(),snapshot=path.join(runDir,'snapshot');fs.mkdirSync(snapshot);fs.writeFileSync(path.join(snapshot,'app.js'),'vulnerable\n');const raw:any=request('vulnerable\n','fixed\n');raw.review.reviewedPatchHash=patchHash(raw);raw.fixtures={'secret.txt':CREDENTIAL_CANARY};const manifest:any={entries:[{path:'app.js',transformation:undefined}]};const runtime:any={id:'node',image:'runtime@sha256:'+'a'.repeat(64),platform:'linux/amd64'};expect(verifyRepair({runId:'run',runDir,manifest,rawRequest:raw,runtime,verifier:runtime,policyHash:'c'.repeat(64),archives:[],executor:{observe:async()=>{throw new Error('must not execute');}},persist:false})).rejects.toMatchObject({code:'REDACTION_FAILED'});}); + test('a non-root producer patches a read-only snapshot file and keeps its mode',()=>{const root=tmp(),before=path.join(root,'before'),after=path.join(root,'after');fs.mkdirSync(before);fs.writeFileSync(path.join(before,'app.js'),'vulnerable\n');fs.chmodSync(path.join(before,'app.js'),0o444);const raw:any=request('vulnerable\n','fixed\n');raw.review.reviewedPatchHash=patchHash(raw);preparePatchedSource(before,after,validateVerificationRequest(raw));expect(fs.readFileSync(path.join(after,'app.js'),'utf8')).toBe('fixed\n');expect(fs.statSync(path.join(after,'app.js')).mode&0o777).toBe(0o444);}); test('declared patches require exact pristine preimages',()=>{const root=tmp(),before=path.join(root,'before'),after=path.join(root,'after');fs.mkdirSync(before);fs.writeFileSync(path.join(before,'app.js'),'changed\n');const raw:any=request('vulnerable\n','fixed\n');raw.review.reviewedPatchHash=patchHash(raw);expect(()=>preparePatchedSource(before,after,validateVerificationRequest(raw))).toThrow('preimage');}); test('helper derives dependency and configuration patch effects',()=>{expect(fileEffect('package.json')).toBe('dependency');expect(fileEffect('apps/x/pyproject.toml')).toBe('dependency');expect(fileEffect('config/environments/test.rb')).toBe('configuration');const root=tmp(),before=path.join(root,'before'),after=path.join(root,'after');fs.mkdirSync(before);fs.writeFileSync(path.join(before,'package.json'),'{}');const raw:any=request('vulnerable\n','fixed\n');raw.changes=[{path:'package.json',beforeSha256:sha256('{}'),after:'{"dependencies":{}}',effect:'configuration'}];raw.boundaryFiles=['package.json'];raw.review.reviewedPatchHash=patchHash(raw);expect(()=>preparePatchedSource(before,after,validateVerificationRequest(raw))).toThrow('must be declared as dependency');}); test('verification evidence stays outside both source trees and temporary copies are deleted on errors',async()=>{const runDir=tmp(),snapshot=path.join(runDir,'snapshot');fs.mkdirSync(snapshot);fs.writeFileSync(path.join(snapshot,'app.js'),'vulnerable\n',{mode:0o755});const testBody='test("ok",()=>{})\n';fs.writeFileSync(path.join(snapshot,'app.test.js'),testBody);const packageJson='{"scripts":{"test":"node --test"}}';fs.writeFileSync(path.join(snapshot,'package.json'),packageJson);const raw:any=request('vulnerable\n','fixed\n');raw.findingId='1abcdefabcdefabcdefabcdefabcdefa';raw.existingTests=[{executable:'/usr/local/bin/node',args:['--test','--test-reporter=tap','./app.test.js']}];raw.testFiles=['app.test.js'];raw.changes.push({path:'security/policy.js',beforeSha256:null,after:'export const enforced = true\n',effect:'source'});raw.review.reviewedPatchHash=patchHash(raw);const manifest:any={version:3,createdAt:'2026-01-01T00:00:00Z',expiresAt:'2026-01-08T00:00:00Z',root:'/repo',headCommit:'a'.repeat(40),originalHash:'b'.repeat(64),executionHash:treeHash(snapshot),entries:[{path:'app.js',originalHash:sha256('vulnerable\n'),executionHash:sha256('vulnerable\n'),bytes:11,mode:0o755},{path:'app.test.js',originalHash:sha256(testBody),executionHash:sha256(testBody),bytes:Buffer.byteLength(testBody),mode:0o600},{path:'package.json',originalHash:sha256(packageJson),executionHash:sha256(packageJson),bytes:packageJson.length,mode:0o600}]};const runtime:any={id:'node',stack:'node',image:'runtime@sha256:'+'a'.repeat(64),platform:'linux/amd64'};const executor:any={observe:async(source:string,phase:string,_request:any,_runtime:any,_verifier:any,work:string)=>{expect(work.startsWith(source+path.sep)).toBe(false);fs.mkdirSync(path.join(work,phase),{recursive:true});fs.writeFileSync(path.join(work,phase,'policy.json'),'evidence');return{booted:true,legitimate:true,security:phase==='before'?'intended_failure':'pass',existingTests:true,output:'ok',inputHash:''};}};let unattested:any;try{await verifyRepair({runId:'run',runDir,manifest,rawRequest:raw,runtime,verifier:runtime,policyHash:'c'.repeat(64),archives:[],executor,persist:false});}catch(error){unattested=error;}expect(unattested).toMatchObject({code:'PREREQUISITE',attempt:{artifactKind:'repair_candidate',findingId:raw.findingId,reproduction:'reproduced',repair:'proposed',bundleIssued:false,testCompletionAssurance:'self_reported'}});expect(unattested.message).toContain('out-of-process external assertion witness');expect(fs.existsSync(path.join(runDir,'bundles'))).toBe(false);expect(fs.readdirSync(path.join(runDir,'verification'))).toEqual([]);expect(fs.statSync(path.join(snapshot,'app.js')).mode&0o777).toBe(0o755);const failing:any={observe:async()=>{throw new Error('stop');}};expect(verifyRepair({runId:'run',runDir,manifest,rawRequest:raw,runtime,verifier:runtime,policyHash:'c'.repeat(64),archives:[],executor:failing,persist:false})).rejects.toThrow('stop');expect(fs.readdirSync(path.join(runDir,'verification'))).toEqual([]);const afterFailure:any={observe:async(_source:string,phase:string)=>{if(phase==='after')throw new CsoError('TOOL_FAILED','after setup failed');return{booted:true,legitimate:true,security:'intended_failure',existingTests:true,output:'reproduced safely',inputHash:''};}};let failed:any;try{await verifyRepair({runId:'run',runDir,manifest,rawRequest:raw,runtime,verifier:runtime,policyHash:'c'.repeat(64),archives:[],executor:afterFailure});}catch(error){failed=error;}expect(failed).toMatchObject({code:'TOOL_FAILED',attempt:{findingId:raw.findingId,reproduction:'reproduced',repair:'failed',bundleIssued:false}});const attempt=failed.attempt,artifact=JSON.parse(fs.readFileSync(path.join(runDir,'verification-attempts',`${attempt.id}.json`),'utf8'));expect(artifact).toMatchObject({id:attempt.id,findingId:raw.findingId,reproduction:'reproduced',repair:'failed',bundleIssued:false});expect(fs.existsSync(path.join(runDir,'bundles'))).toBe(false);expect(fs.readdirSync(path.join(runDir,'verification'))).toEqual([]);const assertionFailure:any={observe:async(_source:string,phase:string)=>({booted:true,legitimate:true,security:'intended_failure',existingTests:true,output:phase==='before'?'reproduced safely':'repair remained vulnerable',inputHash:''})};let notRepaired:any;try{await verifyRepair({runId:'run',runDir,manifest,rawRequest:raw,runtime,verifier:runtime,policyHash:'c'.repeat(64),archives:[],executor:assertionFailure});}catch(error){notRepaired=error;}expect(notRepaired).toMatchObject({code:'ASSERTION_FAILED',attempt:{reproduction:'reproduced',repair:'failed',bundleIssued:false,after:{security:'intended_failure'}}});expect(fs.existsSync(path.join(runDir,'bundles'))).toBe(false);expect(fs.readdirSync(path.join(runDir,'verification-attempts'))).toHaveLength(2);const brokenStartup:any={observe:async()=>({booted:false,legitimate:false,security:'pass',existingTests:false,output:'application never started',inputHash:''})};let blocked:any;try{await verifyRepair({runId:'run',runDir,manifest,rawRequest:raw,runtime,verifier:runtime,policyHash:'c'.repeat(64),archives:[],executor:brokenStartup});}catch(error){blocked=error;}expect(blocked).toMatchObject({attempt:{reproduction:'blocked',repair:'failed',bundleIssued:false}});expect(fs.readdirSync(path.join(runDir,'verification-attempts'))).toHaveLength(3);fs.writeFileSync(path.join(snapshot,'unmanifested.txt'),'changed after snapshot identity\n');let executed=false;const mustNotExecute:any={observe:async()=>{executed=true;return{booted:true,legitimate:true,security:'pass',existingTests:true,output:'',inputHash:''};}};await expect(verifyRepair({runId:'run',runDir,manifest,rawRequest:raw,runtime,verifier:runtime,policyHash:'c'.repeat(64),archives:[],executor:mustNotExecute,persist:false})).rejects.toMatchObject({code:'INCOMPATIBLE_INPUT'});expect(executed).toBe(false);}); diff --git a/test/cso-node-lifecycle-integration.test.ts b/test/cso-node-lifecycle-integration.test.ts index 69c6165263..9f73c26fa3 100644 --- a/test/cso-node-lifecycle-integration.test.ts +++ b/test/cso-node-lifecycle-integration.test.ts @@ -191,11 +191,12 @@ suite('CSO Node producer-visible repair lifecycle fixture', () => { const submittedFinding = submitted.findings[0]; expect(submittedFinding).toMatchObject({ reproduction: 'not_attempted', repair: 'not_attempted', closure: 'open' }); const plan = await cli.command(['runtime-plan', original.runId, 'node', '--port', '34569']); + expect(plan.runtimeProfile).toBe(cli.runtime.id); const vulnerable = fs.readFileSync(path.join(originalRunDirectory, 'snapshot', 'policy.js'), 'utf8'); const repaired = "module.exports={status:403,body:'DENIED'};\n"; const request: VerificationRequest = { findingId: submittedFinding.id, - runtimeProfile: cli.runtime.id, + runtimeProfile: plan.runtimeProfile, port: 34569, start: plan.start.command, legitimate: [{ name: 'legitimate control', path: '/control', method: 'GET', expected: { status: 200, includes: 'CONTROL_OK' } }],