From 1d1ce39b4e2beda4626c3f977b368077eb337a73 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 7 Oct 2026 23:06:43 -0700 Subject: [PATCH] v1.91.36.0 fix(cso): Python and Rails repairs replay, Rails verification fits its budget, killed attempts leave no scratch (#3074) * fix(cso): Python and Rails repairs verify and replay; killed attempts leave no scratch - Python offline preparation sets SOURCE_DATE_EPOCH so .pyc bytecode is reproducible. - Prepared identity hashes skip RubyGems/mini_portile build diagnostics that name random build dirs. - Rails verification attempts and replays get 900 s; the attempt watchdog, Docker verification executor and assertion witness share MAX_VERIFICATION_ATTEMPT_MS. - resume and finish reclaim a killed attempt's scratch once its watchdog recorded cleanup. v1.91.34.0 * Format lib/cso with prettier * Take v1.91.36.0 (1.91.35.0 is claimed by #3073) --- CHANGELOG.md | 32 +++++++++++++++++++++++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- lib/cso/cli.ts | 27 +++++++++++++++++----- lib/cso/contracts.ts | 2 ++ lib/cso/preparation-executor.ts | 18 +++++++++++++-- lib/cso/preparation.ts | 10 ++++++++- lib/cso/state.ts | 29 ++++++++++++++++++++++++ lib/cso/verification.ts | 10 ++++++--- lib/cso/witness.ts | 3 ++- package.json | 2 +- test/cso-cli-lifecycle.test.ts | 13 ++++++++++- test/cso-preparation-executor.test.ts | 19 ++++++++++++++-- test/cso-preparation.test.ts | 6 +++++ test/cso-snapshot-state.test.ts | 3 ++- test/cso-witness.test.ts | 12 ++++++++-- 16 files changed, 169 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efbdc9a13..d215eff5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## [1.91.36.0] - 2026-10-07 + +**Python and Rails repairs can now reach `runtime_tested` and replay, and a killed verification leaves nothing behind.** + +Runtime verification had four defects that the private evaluator's Lane R found on its Python, Rails and watchdog checks. First, a verified Python repair could never replay: offline preparation wrote timestamped `.pyc` bytecode, so every preparation hashed differently and `replay` reported "Replay changed verification outcomes, preparation, or provenance inputs". Second, Rails replays failed the same way, because RubyGems build logs record a random temporary directory. Third, no Rails verification could finish on a 4-vCPU host: an attempt prepares the application twice, each Rails preparation compiles native extensions for about four minutes, and the attempt watchdog, executor and assertion witness were each capped at five minutes. Fourth, after a helper was killed mid-verification, the watchdog removed every container, but `resume` and `finish` left the dead attempt's control directory and archive staging in the run directory. + +### The numbers that matter + +Measured on amd64 (4 vCPU) with `gstack-cso` built from 28f1385 plus these fixes, the staged runtime images from staging run 37555192492, and the evaluator's private corpus. + +| Check | Before | After | +|---|---|---| +| Two Python offline preparations of the same app | prepared dependency trees differ | 0 differing files | +| One Rails offline preparation | about 245 s | unchanged | +| Rails verification attempt | ended by the 300 s watchdog during its second preparation | certifies within the 900 s bound | +| Helper SIGKILL during preparation or with the app running, then `resume` and `finish` | `preparation-execution` (and `archive-staging`) not empty | empty | + +### What this means for you + +Python projects locked with `requirements.txt` or `uv.lock`, and Rails projects with native gems, can now be verified and replayed. Node and Bun keep their 300-second attempt. If a verification is interrupted, `resume` now reclaims its scratch once the watchdog has recorded cleanup. + +### Itemized changes + +#### Fixed +- Python offline preparation sets `SOURCE_DATE_EPOCH`, so `venv`, ensurepip and pip write checked-hash `.pyc` files and two preparations of the same app produce identical trees. +- The prepared manifest and dependency hashes leave out RubyGems and mini_portile build diagnostics (`extensions/**/gem_make.out`, `extensions/**/mkmf.log`, `gems/*/ext/**/tmp/**/*.log`). These logs name random build directories and are never loaded; the compiled extensions stay in the identity. +- A Rails verification attempt and a Rails replay get 900 seconds instead of 300, still capped by the run's reporting deadline. The attempt watchdog, the Docker verification executor and the assertion witness lifetime now share that one bound (`MAX_VERIFICATION_ATTEMPT_MS`) instead of each enforcing 300 seconds. Other stacks keep 300 seconds. +- `resume` and `finish` reclaim a killed attempt's `supervision`, `preparation-execution`, `archive-staging`, `archive-materializations` and `verification` scratch. They do this under the run lock and only when no detached watchdog cleanup is still pending. + +#### For contributors +- `preparedIdentityEntry()` in `lib/cso/preparation-executor.ts`, `verificationAttemptMs()` in `lib/cso/cli.ts`, `MAX_VERIFICATION_ATTEMPT_MS` in `lib/cso/contracts.ts`, and `reclaimDeadAttemptScratch()` in `lib/cso/state.ts`. They are pinned by `test/cso-preparation-executor.test.ts`, `test/cso-preparation.test.ts`, `test/cso-cli-lifecycle.test.ts`, `test/cso-witness.test.ts` and `test/cso-snapshot-state.test.ts`. + ## [1.91.34.0] - 2026-10-07 **/autoplan works again on current Claude Code, including long sessions and `claude --bg`.** diff --git a/VERSION b/VERSION index 4101e586d..fe477d414 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.34.0 +1.91.36.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index b8eaa0b15..291e0e985 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.34.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.36.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 f27b1db8d..81f4e4747 100644 --- a/lib/cso/cli.ts +++ b/lib/cso/cli.ts @@ -9,6 +9,7 @@ import { ApplicationModel, CoverageRecord, CsoError, + MAX_VERIFICATION_ATTEMPT_MS, FindingV3, PreparationProof, RunPolicy, @@ -45,6 +46,7 @@ import { publicReport, PUBLIC_SOURCE_ROOT, readJson, + reclaimDeadAttemptScratch, repoId, requireTime, retention, @@ -1401,6 +1403,7 @@ function finish(args: string[]) { const report = loadReport(dir), manifest = readJson(join(dir, 'snapshot.json')); assertSnapshot(dir, manifest); + reclaimDeadAttemptScratch(dir); for (const c of report.coverage) if ( c.status === 'not_assessed' && @@ -1541,9 +1544,12 @@ function resume(args: string[]) { if (report.status === 'finished') throw new CsoError('INVALID_SCHEMA', 'A finished audit cannot be resumed'); assertSnapshot(dir, manifest); - for (const message of recoveryEvents(dir)) + const recovery = recoveryEvents(dir); + for (const message of recovery) if (!report.events.some((e) => e.kind === 'watchdog-recovery' && e.message === message)) event(report, 'watchdog-recovery', message); + saveReport(dir, report); + reclaimDeadAttemptScratch(dir); if (Date.now() >= Date.parse(report.deadline)) { report.status = 'interrupted'; event(report, 'deadline', 'Original budget is exhausted; resume did not replenish it'); @@ -1557,7 +1563,7 @@ function resume(args: string[]) { runId: report.runId, deadline: report.deadline, policy: report.policy, - recovery: recoveryEvents(dir), + recovery, }; }); } @@ -2236,7 +2242,10 @@ async function verify(args: string[], dependencies: CsoCliDependencies) { writeJson(join(dir, `preparation-${runtime.stack}.json`), plan); const endpoint = await dockerEndpoint(secureDirectory(join(dir, 'home'))), watchdogPath = dependencies.watchdogPath(), - attemptDeadline = Math.min(Date.now() + 300_000, Date.parse(report.deadline) - 60_000); + attemptDeadline = Math.min( + Date.now() + verificationAttemptMs(runtime.stack), + Date.parse(report.deadline) - 60_000, + ); const admission = admitPreparationRuntime({ plan, platform: targetPlatform, @@ -2457,9 +2466,17 @@ function replayManifestValue(manifest: any): unknown { }; return { ...stable, before: observation(before), after: observation(after) }; } +/** + * One bounded verification attempt (and one replay) prepares the application twice, before and after the + * patch. Rails offline preparation compiles native extensions, which takes about four minutes per phase on + * a 4-vCPU host, so a five-minute attempt could never finish for a Rails application. + */ +export function verificationAttemptMs(stack: string): number { + return stack === 'rails' ? MAX_VERIFICATION_ATTEMPT_MS : 300_000; +} async function replay(args: string[], dependencies: CsoCliDependencies) { const replayStarted = Date.now(), - replayDeadline = replayStarted + 300_000; + replayDeadline = replayStarted + MAX_VERIFICATION_ATTEMPT_MS; retention(replayStarted, { deadlineMs: replayStarted + RETENTION_MAINTENANCE_MS, maxEntries: RETENTION_MAX_ENTRIES, @@ -2528,7 +2545,7 @@ async function replay(args: string[], dependencies: CsoCliDependencies) { throw new CsoError('INCOMPATIBLE_INPUT', 'Qualified runtime digest does not match the bundle'); const endpoint = await dockerEndpoint(secureDirectory(join(workDir, 'home'))), watchdogPath = dependencies.watchdogPath(), - attemptDeadline = replayDeadline, + attemptDeadline = Math.min(replayDeadline, replayStarted + verificationAttemptMs(runtime.stack)), delegate = new DockerVerificationExecutor(endpoint, watchdogPath, attemptDeadline); let executor: VerificationExecutor = delegate, archives: string[] = [], diff --git a/lib/cso/contracts.ts b/lib/cso/contracts.ts index 485b2918e..a6004114a 100644 --- a/lib/cso/contracts.ts +++ b/lib/cso/contracts.ts @@ -3,6 +3,8 @@ import { createHash } from 'node:crypto'; export const ABI = 3; export const MAX_OUTPUT = 1024 * 1024; +/** Longest bounded verification attempt (a Rails attempt prepares two dependency trees); other stacks use 300 s. */ +export const MAX_VERIFICATION_ATTEMPT_MS = 900_000; const UNSAFE_STRING_CONTROLS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u; const UNSAFE_PROPERTY_CONTROLS = /[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u; diff --git a/lib/cso/preparation-executor.ts b/lib/cso/preparation-executor.ts index e2ff177af..c43ecc318 100644 --- a/lib/cso/preparation-executor.ts +++ b/lib/cso/preparation-executor.ts @@ -908,6 +908,16 @@ function dependencyOutput(stack: CsoStack, path: string): boolean { return first === '.venv' || first === '.cso-uv-cache' || path === '.gstack-cso-public-requirements.txt'; return path.startsWith('vendor/bundle/') || first === '.cso-bundle' || first === '.cso-gems'; } +/** + * RubyGems and mini_portile build diagnostics name random temporary build directories, so they differ on + * every native-extension build. They are never loaded, and the built extensions themselves are + * reproducible, so the prepared identity covers everything except these logs. + */ +const RUBY_BUILD_DIAGNOSTIC = + /^vendor\/bundle\/ruby\/[^/]+\/(?:extensions\/[^/]+\/[^/]+\/[^/]+\/(?:gem_make\.out|mkmf\.log)|gems\/[^/]+\/ext\/(?:[^/]+\/)*tmp\/(?:[^/]+\/)*[^/]+\.log)$/; +export function preparedIdentityEntry(stack: CsoStack, path: string): boolean { + return !(stack === 'rails' && RUBY_BUILD_DIAGNOSTIC.test(path)); +} function installedDependencyOutput(stack: CsoStack, path: string): boolean { const first = path.split('/')[0]; return stack === 'node' || stack === 'bun' @@ -1019,9 +1029,13 @@ function provePreparedProjection( return { hash: sha256(canonical({ source, transformations: actualTransformations })), transformations: actualTransformations, - manifestHash: sha256(canonical(prepared)), + manifestHash: sha256(canonical(prepared.filter((entry) => preparedIdentityEntry(stack, entry.path)))), dependencyHash: sha256( - canonical(prepared.filter((entry) => installedDependencyOutput(stack, entry.path))), + canonical( + prepared.filter( + (entry) => installedDependencyOutput(stack, entry.path) && preparedIdentityEntry(stack, entry.path), + ), + ), ), }; } diff --git a/lib/cso/preparation.ts b/lib/cso/preparation.ts index 2ea5c6445..26a68bc73 100644 --- a/lib/cso/preparation.ts +++ b/lib/cso/preparation.ts @@ -539,7 +539,15 @@ function inspectPython(root: string, plan: PreparationPlan) { PIP_DISABLE_PIP_VERSION_CHECK: '1', PIP_NO_CACHE_DIR: '1', }; - const offlineEnv = { ...acquisitionEnv, UV_CACHE_DIR: '/work/.cso-uv-cache', UV_LINK_MODE: 'copy' }; + // py_compile reads SOURCE_DATE_EPOCH from os.environ even under `python -I` and then writes + // checked-hash .pyc files. venv/ensurepip and pip compile bytecode during offline preparation; with + // timestamp .pyc files every preparation differed, so a replay could never match its verification. + const offlineEnv = { + ...acquisitionEnv, + UV_CACHE_DIR: '/work/.cso-uv-cache', + UV_LINK_MODE: 'copy', + SOURCE_DATE_EPOCH: '315532800', + }; let requirementsPath = 'requirements.txt'; let publicRequirements = '/metadata/requirements.txt'; const localBuildPaths: string[] = []; diff --git a/lib/cso/state.ts b/lib/cso/state.ts index 017f95b67..ff50719b7 100644 --- a/lib/cso/state.ts +++ b/lib/cso/state.ts @@ -2182,6 +2182,35 @@ export function hasPendingWatchdogCleanup(dir: string, admit: () => void = () => return false; } +const ATTEMPT_SCRATCH = [ + 'supervision', + 'preparation-execution', + 'archive-staging', + 'archive-materializations', + 'verification', +] as const; +/** + * Reclaim the scratch a killed verification attempt left in its run directory. The caller holds the + * run lock, so no live attempt owns these trees; a watchdog that has not recorded cleanup still does, + * and then nothing is removed. Returns whether the scratch was reclaimed. + */ +export function reclaimDeadAttemptScratch(dir: string): boolean { + if (hasPendingWatchdogCleanup(dir)) return false; + for (const name of ATTEMPT_SCRATCH) { + const root = join(dir, name); + let stat: fs.Stats; + try { + stat = fs.lstatSync(root); + } catch { + continue; + } + if (stat.isSymbolicLink() || !stat.isDirectory()) + throw new CsoError('UNSAFE_PATH', 'Attempt scratch is not a private directory'); + for (const entry of fs.readdirSync(root)) fs.rmSync(join(root, entry), { recursive: true, force: true }); + } + return true; +} + const EPHEMERAL_REPLAY = '.ephemeral-replay.json'; /** Delete a replay-only snapshot unless detached cleanup still owns its control tree. */ export function finalizeReplayTemporary(dir: string): void { diff --git a/lib/cso/verification.ts b/lib/cso/verification.ts index d45394e7b..5b0eda27b 100644 --- a/lib/cso/verification.ts +++ b/lib/cso/verification.ts @@ -6,6 +6,7 @@ import { AssertionWitnessBinding, AssertionWitnessReceipt, CsoError, + MAX_VERIFICATION_ATTEMPT_MS, PreparationProof, RepairBundle, RepairReviewArtifact, @@ -1571,7 +1572,7 @@ export class DockerVerificationExecutor implements VerificationExecutor { private runExecutionDeadline = Date.now() + 300_000, private onExecutionStarted?: () => void | Promise, ) { - this.attemptDeadline = Math.min(Date.now() + 300_000, runExecutionDeadline); + this.attemptDeadline = Math.min(Date.now() + MAX_VERIFICATION_ATTEMPT_MS, runExecutionDeadline); } async observe( source: string, @@ -1898,7 +1899,10 @@ export async function verifyRepair(params: { } let guardedCleanup: (() => Promise) | undefined; if (params.watchdogPath) { - const deadline = Math.min(params.attemptDeadline ?? Date.now() + 300_000, Date.now() + 300_000); + const deadline = Math.min( + params.attemptDeadline ?? Date.now() + 300_000, + Date.now() + MAX_VERIFICATION_ATTEMPT_MS, + ); guardedCleanup = await attemptGuard(params.runDir, work, params.watchdogPath, deadline); } secureDirectory(observations); @@ -1957,7 +1961,7 @@ export async function verifyRepair(params: { }, session = new AssertionWitnessSession( observations, - Math.min(params.attemptDeadline ?? Date.now() + 300_000, Date.now() + 300_000), + Math.min(params.attemptDeadline ?? Date.now() + 300_000, Date.now() + MAX_VERIFICATION_ATTEMPT_MS), ), stable = ( phase: 'before' | 'after', diff --git a/lib/cso/witness.ts b/lib/cso/witness.ts index d8b6d3f5d..d3c14be52 100644 --- a/lib/cso/witness.ts +++ b/lib/cso/witness.ts @@ -14,6 +14,7 @@ import { Command, CsoError, MAX_OUTPUT, + MAX_VERIFICATION_ATTEMPT_MS, VerificationObservation, canonical, object, @@ -48,7 +49,7 @@ const HASH = /^[a-f0-9]{64}$/; const PUBLIC_KEY = /^[a-f0-9]{88}$/; const SIGNATURE = /^[a-f0-9]{128}$/; const PROTOCOL = 'gstack-cso-assertion-witness-v1' as const; -const MAX_RECEIPT_AGE = 300_000; +const MAX_RECEIPT_AGE = MAX_VERIFICATION_ATTEMPT_MS; const exact = (value: Record, allowed: readonly string[], name: string) => { for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new CsoError('INVALID_SCHEMA', `Unexpected ${name} field: ${key}`); diff --git a/package.json b/package.json index 61274078b..8b401f41a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.34", + "version": "1.91.36", "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-cli-lifecycle.test.ts b/test/cso-cli-lifecycle.test.ts index 67766900d..ef5f0eff6 100644 --- a/test/cso-cli-lifecycle.test.ts +++ b/test/cso-cli-lifecycle.test.ts @@ -45,12 +45,13 @@ afterEach(()=>fs.rmSync(root,{recursive:true,force:true})); describe('CSO interrupted-run and replay commands',()=>{ test('resume reports watchdog recovery without replenishing the original policy or deadline',()=>{ const run=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline','--budget','120']).stdout),dir=runDir(run),reportPath=path.join(dir,'report.json'),original=JSON.parse(fs.readFileSync(reportPath,'utf8')); - const recovery='supervisor-death execution-copy cleanup complete',dockerRecovery='deadline cleanup complete',control=path.join(dir,'supervision','repair-attempt'),preparationControl=path.join(dir,'preparation-execution','offline-attempt');fs.mkdirSync(control,{recursive:true});fs.mkdirSync(preparationControl,{recursive:true});fs.writeFileSync(path.join(control,'attempt.event'),`${recovery}\n`);fs.writeFileSync(path.join(preparationControl,'watchdog.event'),`${dockerRecovery}\n`); + const recovery='supervisor-death execution-copy cleanup complete',dockerRecovery='deadline cleanup complete',control=path.join(dir,'supervision','repair-attempt'),preparationControl=path.join(dir,'preparation-execution','offline-attempt');fs.mkdirSync(control,{recursive:true});fs.mkdirSync(preparationControl,{recursive:true});fs.writeFileSync(path.join(control,'attempt.event'),`${recovery}\n`);fs.writeFileSync(path.join(preparationControl,'watchdog.event'),`${dockerRecovery}\n`);const staging=path.join(dir,'archive-staging','acquire-partial');fs.mkdirSync(staging,{recursive:true});fs.writeFileSync(path.join(staging,'partial.tgz'),'partial'); original.status='interrupted';saveReport(dir,original); const resumed=command(['resume',run.runId]);expect(resumed.status).toBe(0);const result=JSON.parse(resumed.stdout),active=JSON.parse(fs.readFileSync(reportPath,'utf8')); expect(result).toMatchObject({runId:run.runId,deadline:original.deadline,policy:original.policy,recovery:[recovery,dockerRecovery]});expect(active.status).toBe('running');expect(active.deadline).toBe(original.deadline);expect(active.policy).toEqual(original.policy); expect(active.events.filter((item:any)=>item.kind==='watchdog-recovery'&&[recovery,dockerRecovery].includes(item.message))).toHaveLength(2);expect(active.events.at(-1)).toMatchObject({kind:'resume',message:'Continued retained snapshot under original policy'}); + for(const name of ['supervision','preparation-execution','archive-staging'])expect(fs.readdirSync(path.join(dir,name))).toEqual([]); active.status='interrupted';active.deadline=new Date(Date.now()-1_000).toISOString();const exhaustedDeadline=active.deadline;saveReport(dir,active); const expired=command(['resume',run.runId]);expect(expired.status).not.toBe(0);expect(expired.stderr).toContain('DEADLINE');expect(expired.stderr).toContain('Original run budget is exhausted'); @@ -82,3 +83,13 @@ describe('CSO interrupted-run and replay commands',()=>{ fs.writeFileSync(path.join(repo,'app.js'),'module.exports = "vulnerable"\n');const supplied=command(['replay',verified.bundle.id,'--source',repo]);expect(supplied.status).not.toBe(0);expect(supplied.stderr).toContain('PREREQUISITE');expect(supplied.stderr).toContain('MISSING_QUALIFIED_RUNTIME');expect(supplied.stderr).not.toContain('does not match the bundle input hashes'); },30_000); }); + +describe('bounded verification attempts', () => { + test('a Rails attempt can prepare both phases; other stacks keep the five-minute bound', async () => { + const { verificationAttemptMs } = await import('../lib/cso/cli'); + const { MAX_VERIFICATION_ATTEMPT_MS } = await import('../lib/cso/contracts'); + for (const stack of ['node', 'bun', 'python']) expect(verificationAttemptMs(stack)).toBe(300_000); + expect(verificationAttemptMs('rails')).toBe(MAX_VERIFICATION_ATTEMPT_MS); + expect(MAX_VERIFICATION_ATTEMPT_MS).toBe(900_000); + }); +}); diff --git a/test/cso-preparation-executor.test.ts b/test/cso-preparation-executor.test.ts index 39bcaa65b..5aa47d25e 100644 --- a/test/cso-preparation-executor.test.ts +++ b/test/cso-preparation-executor.test.ts @@ -10,8 +10,7 @@ import { inspectPreparation, type CsoStack } from '../lib/cso/preparation'; import { admitPreparationRuntime, admitPreparationSidecar, PreparationExecutor, type AcquisitionReceipt, type OfflinePreparationReceipt, type PreparationAcquireRequest, - type PreparationSandboxRunner, type OfflinePreparationRequest, -} from '../lib/cso/preparation-executor'; + type PreparationSandboxRunner, type OfflinePreparationRequest, preparedIdentityEntry } from '../lib/cso/preparation-executor'; import { CSO_HELPER_ABI } from '../lib/cso/runtime-catalog'; import { DockerPreparationSandboxRunner, isBlockedRegistryAddress, RegistryEgressBroker } from '../lib/cso/preparation-docker'; import { completeRuntimeCatalogFixture, qualifiedRuntimeFixture } from './helpers/cso-runtime-catalog'; @@ -191,6 +190,22 @@ describe('CSO constrained dependency preparation executor', () => { const before=await executor.prepareOffline({plan,admission,snapshot:source,closure,deadline});runner.mutatePrepared=root=>fs.writeFileSync(path.join(root,'node_modules','.cso-dependencies'),'changed toolchain bytes');const after=await executor.prepareOffline({plan,admission,snapshot:source,closure,deadline});expect(after.preparedDependencyHash).not.toBe(before.preparedDependencyHash); }); + test('Rails build diagnostics with random build directories stay out of the prepared identity', () => { + for (const path of [ + 'vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/date-3.5.1/gem_make.out', + 'vendor/bundle/ruby/3.4.0/extensions/aarch64-linux/3.4.0/json-3.0.2/mkmf.log', + 'vendor/bundle/ruby/3.4.0/gems/sqlite3-2.9.0/ext/sqlite3/tmp/x86_64-linux-gnu/ports/sqlite3/3.51.1/sqlite-autoconf-3510100/config.log', + ]) expect(preparedIdentityEntry('rails', path)).toBe(false); + for (const path of [ + 'vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/date-3.5.1/date_core.so', + 'vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/date-3.5.1/gem.build_complete', + 'vendor/bundle/ruby/3.4.0/gems/sqlite3-2.9.0/lib/sqlite3/sqlite3_native.so', + 'vendor/bundle/ruby/3.4.0/gems/rails-8.1.2/lib/rails.rb', + 'log/gem_make.out', + ]) expect(preparedIdentityEntry('rails', path)).toBe(true); + expect(preparedIdentityEntry('node', 'vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/date-3.5.1/gem_make.out')).toBe(true); + }); + test('network-policy and exact-command receipt changes are rejected before promotion', async () => { const source = snapshot('node'), plan = inspectPreparation(source, 'node'), admission = admitPreparationRuntime({ plan, platform: 'linux/amd64', catalog: catalog('node') }); const runner = new FakeRunner(), cache = cacheFixture(), executor = new PreparationExecutor({ cache, runner }); diff --git a/test/cso-preparation.test.ts b/test/cso-preparation.test.ts index 11bff5fd1..f9ca4271a 100644 --- a/test/cso-preparation.test.ts +++ b/test/cso-preparation.test.ts @@ -127,6 +127,12 @@ describe('CSO inert Python preparation', () => { expect(plan.acquisition[0].args).toContain('--only-binary=:all:'); expect(plan.acquisition[0].args).toContain('--require-hashes'); expect(plan.metadata).toHaveLength(1); expect(plan.offline[1].args).toContain('--no-index'); }); + test('offline Python preparation writes reproducible checked-hash bytecode', () => { + const plan = inspectPreparation(fixture({ 'requirements.txt': `Flask==3.1.0 --hash=sha256:${hash}\n` })); + expect(plan.status).toBe('ready'); + // venv/ensurepip and pip compile .pyc files; without a fixed epoch each preparation differs and replay cannot match. + for (const command of plan.offline) expect(command.env.SOURCE_DATE_EPOCH).toMatch(/^[0-9]+$/); + }); for (const requirement of ['flask>=3', '-e .', 'flask @ https://evil.invalid/x.whl', '--index-url https://evil.invalid', '-r other.txt', 'flask==3.1.0']) test(`rejects unpinned or executable requirement ${requirement}`, () => { expect(inspectPreparation(fixture({ 'requirements.txt': requirement })).prerequisites[0].code).toBe('UNPINNED_REQUIREMENTS'); }); diff --git a/test/cso-snapshot-state.test.ts b/test/cso-snapshot-state.test.ts index 9b6d376d8..deaa73018 100644 --- a/test/cso-snapshot-state.test.ts +++ b/test/cso-snapshot-state.test.ts @@ -1,6 +1,6 @@ import { afterAll, afterEach, describe, expect, spyOn, test } from 'bun:test'; import * as fs from 'node:fs';import * as os from 'node:os';import * as path from 'node:path';import { spawn, spawnSync } from 'node:child_process'; -import { assertSnapshot, capture } from '../lib/cso/snapshot';import { CsoError, snapshotPathHandle } from '../lib/cso/contracts';import { runProcess, sanitizeForJson, sanitizeHelperForJson } from '../lib/cso/process';import { discardAtomicNoReplaceTemp, finalizeReplayTemporary, loadReport, newRun, privateRoot, readJson, recoverAtomicNoReplaceJson, retention, saveReport, secureDirectory, stateRoot, withLock, writeHelperJson, writeJson } from '../lib/cso/state'; +import { assertSnapshot, capture } from '../lib/cso/snapshot';import { CsoError, snapshotPathHandle } from '../lib/cso/contracts';import { runProcess, sanitizeForJson, sanitizeHelperForJson } from '../lib/cso/process';import { discardAtomicNoReplaceTemp, finalizeReplayTemporary,reclaimDeadAttemptScratch, loadReport, newRun, privateRoot, readJson, recoverAtomicNoReplaceJson, retention, saveReport, secureDirectory, stateRoot, withLock, writeHelperJson, writeJson } from '../lib/cso/state'; const roots:string[]=[];const tmp=()=>{const p=fs.mkdtempSync(path.join(os.tmpdir(),'cso-snapshot-'));roots.push(p);return p;};afterEach(()=>{for(const p of roots.splice(0))fs.rmSync(p,{recursive:true,force:true});}); const originalState=process.env.GSTACK_HOME,state=fs.mkdtempSync(path.join(os.tmpdir(),'cso-state-'));process.env.GSTACK_HOME=state;afterAll(()=>{if(originalState===undefined)delete process.env.GSTACK_HOME;else process.env.GSTACK_HOME=originalState;fs.rmSync(state,{recursive:true,force:true});}); function git(repo:string,...args:string[]){const r=spawnSync('/usr/bin/git',['-C',repo,...args],{encoding:'utf8',env:{PATH:'/usr/bin:/bin',HOME:repo},timeout:30_000});if(r.status)throw new Error(r.stderr);return r.stdout;} @@ -51,6 +51,7 @@ describe('CSO dirty snapshot boundary',()=>{ test('bounded retention completes discovery before deleting any run',()=>{const now=Date.now(),repo='f'.repeat(24),run=`${now-31*86400_000}-${'9'.repeat(16)}`,dir=secureDirectory(path.join(privateRoot(),repo,run));fs.writeFileSync(path.join(dir,'report.json'),'expired private state\n',{mode:0o600});const result=retention(now,{deadlineMs:now+60_000,maxEntries:6});expect(result).toEqual({complete:false,visited:6});expect(fs.existsSync(dir)).toBe(true);retention(now);expect(fs.existsSync(dir)).toBe(false);}); test('bounded retention resumes an interrupted tree deletion without one recursive walk',()=>{const previous=process.env.GSTACK_HOME,isolate=tmp();process.env.GSTACK_HOME=isolate;try{const now=Date.now(),repo='1'.repeat(24),run=`${now-31*86400_000}-${'a'.repeat(16)}`,repoDir=path.join(privateRoot(),repo),dir=secureDirectory(path.join(repoDir,run)),snapshot=secureDirectory(path.join(dir,'snapshot'));for(let index=0;index<20;index++)fs.writeFileSync(path.join(snapshot,`${index}.txt`),'private state\n',{mode:0o600});const result=retention(now,{deadlineMs:now+60_000,maxEntries:30});expect(result).toEqual({complete:false,visited:30});expect(fs.existsSync(dir)).toBe(false);expect(fs.readdirSync(repoDir).some(name=>name.startsWith(`.retired-${run}-`))).toBe(true);retention(now);expect(fs.readdirSync(repoDir).some(name=>name.startsWith(`.retired-${run}-`))).toBe(false);}finally{if(previous===undefined)delete process.env.GSTACK_HOME;else process.env.GSTACK_HOME=previous;}}); test('repair bundles expire from their authenticated bundle time, independent of run age',()=>{const now=Date.now(),repo='c'.repeat(24),run=`${now-31*86400_000}-${'2'.repeat(16)}`,dir=secureDirectory(path.join(privateRoot(),repo,run)),bundles=secureDirectory(path.join(dir,'bundles')),expiredId='3'.repeat(32),retainedId='4'.repeat(32),record=(id:string,created:number)=>{const createdAt=new Date(created).toISOString();return{schemaVersion:3,id,runId:run,createdAt,expiresAt:new Date(created+30*86400_000).toISOString(),verification:{id,runId:run,createdAt}};};writeHelperJson(path.join(bundles,`${expiredId}.json`),record(expiredId,now-31*86400_000));writeHelperJson(path.join(bundles,`${retainedId}.json`),record(retainedId,now-86400_000));retention(now);expect(fs.existsSync(path.join(bundles,`${expiredId}.json`))).toBe(false);expect(fs.existsSync(path.join(bundles,`${retainedId}.json`))).toBe(true);expect(fs.existsSync(dir)).toBe(true);retention(now+30*86400_000);expect(fs.existsSync(dir)).toBe(false);}); + test('a killed attempt\'s scratch is reclaimed only after its detached watchdog records cleanup',()=>{const now=Date.now(),repo='c'.repeat(24),run=`${now}-${'6'.repeat(16)}`,dir=secureDirectory(path.join(privateRoot(),repo,run)),call=secureDirectory(path.join(dir,'preparation-execution','acquire-attempt')),staging=secureDirectory(path.join(dir,'archive-staging','acquire-partial')),kept=path.join(dir,'report.json');fs.writeFileSync(kept,'{}',{mode:0o600});fs.writeFileSync(path.join(staging,'partial.tgz'),'partial',{mode:0o600});fs.writeFileSync(path.join(call,'watchdog.ready'),'ready\n',{mode:0o600});fs.writeFileSync(path.join(call,'watchdog.event'),'cleanup incomplete; retrying exact journaled resources\n',{mode:0o600});expect(reclaimDeadAttemptScratch(dir)).toBe(false);expect(fs.existsSync(call)).toBe(true);expect(fs.existsSync(staging)).toBe(true);fs.writeFileSync(path.join(call,'watchdog.event'),'supervisor-death cleanup complete\n',{mode:0o600});expect(reclaimDeadAttemptScratch(dir)).toBe(true);for(const name of ['preparation-execution','archive-staging'])expect(fs.readdirSync(path.join(dir,name))).toEqual([]);expect(fs.existsSync(kept)).toBe(true);fs.rmSync(path.join(dir,'archive-staging'),{recursive:true});fs.symlinkSync(os.tmpdir(),path.join(dir,'archive-staging'));expect(()=>reclaimDeadAttemptScratch(dir)).toThrow('Attempt scratch is not a private directory');}); test('replay temporary state survives cleanup failure until every detached watchdog acknowledges cleanup',()=>{const now=Date.now(),repo='b'.repeat(24),run=`${now}-${'5'.repeat(16)}`,dir=secureDirectory(path.join(privateRoot(),repo,run)),control=secureDirectory(path.join(dir,'supervision','repair-attempt')),docker=secureDirectory(path.join(dir,'preparation-execution','offline-attempt'));fs.writeFileSync(path.join(control,'attempt.ready'),'ready\n',{mode:0o600});fs.writeFileSync(path.join(control,'attempt.terminal'),'normal cleanup complete\n',{mode:0o600});fs.writeFileSync(path.join(docker,'watchdog.ready'),'ready\n',{mode:0o600});fs.writeFileSync(path.join(docker,'watchdog.event'),'cleanup incomplete; retrying exact journaled resources\n',{mode:0o600});finalizeReplayTemporary(dir);expect(fs.existsSync(path.join(dir,'.ephemeral-replay.json'))).toBe(true);retention(now);expect(fs.existsSync(control)).toBe(true);expect(fs.existsSync(docker)).toBe(true);fs.writeFileSync(path.join(control,'attempt.stopped'),'normal cleanup acknowledged\n',{mode:0o600});retention(now);expect(fs.existsSync(dir)).toBe(true);fs.writeFileSync(path.join(docker,'watchdog.stopped'),'normal cleanup acknowledged\n',{mode:0o600});retention(now);expect(fs.existsSync(dir)).toBe(false);}); test('retention recovers a hidden run retirement left by a dead helper',()=>{const now=Date.now(),repo='a'.repeat(24),run=`${now-31*86400_000}-${'6'.repeat(16)}`,retired=secureDirectory(path.join(privateRoot(),repo,`.retired-${run}-${'7'.repeat(32)}`));fs.writeFileSync(path.join(retired,'payload'),'private state\n',{mode:0o600});retention(now);expect(fs.existsSync(retired)).toBe(false);}); }); diff --git a/test/cso-witness.test.ts b/test/cso-witness.test.ts index 91823d319..8a7a82f93 100644 --- a/test/cso-witness.test.ts +++ b/test/cso-witness.test.ts @@ -4,9 +4,9 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { spawnSync } from 'node:child_process'; import { generateKeyPairSync } from 'node:crypto'; -import { AssertionWitnessBinding, CsoError, VerificationObservation, canonical, sha256 } from '../lib/cso/contracts'; +import { AssertionWitnessBinding, CsoError, MAX_VERIFICATION_ATTEMPT_MS, VerificationObservation, canonical, sha256 } from '../lib/cso/contracts'; import { canonicalStartPlan, canonicalTestPlan, patchHash, treeHash, validateRepairBundle, verifyRepair } from '../lib/cso/verification'; -import { AssertionWitnessSession, assertionWitnessChildCommand, assertionWitnessReplayHash, testExecutionPassed, validateStoredAssertionWitnessReceipt } from '../lib/cso/witness'; +import { AssertionWitnessSession, assertionWitnessChildCommand, assertionWitnessReplayHash, testExecutionPassed, validateAssertionWitnessBinding, validateStoredAssertionWitnessReceipt } from '../lib/cso/witness'; const roots:string[]=[]; const temporary=()=>{const root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-witness-'));roots.push(root);return root;}; @@ -33,6 +33,14 @@ function stable(phase:'before'|'after'):Omit{ + test('a challenge lives as long as its attempt, never past the longest attempt bound',()=>{ + const now=Date.now(),lifetime=(deadline:number)=>{const binding=new AssertionWitnessSession(temporary(),deadline).handle(stable('before')).binding;return Date.parse(binding.expiresAt)-Date.parse(binding.issuedAt);}; + expect(lifetime(now+300_000)).toBeLessThanOrEqual(300_000); + expect(lifetime(now+MAX_VERIFICATION_ATTEMPT_MS)).toBeGreaterThan(300_000); + expect(lifetime(now+2*MAX_VERIFICATION_ATTEMPT_MS)).toBeLessThanOrEqual(MAX_VERIFICATION_ATTEMPT_MS); + const binding=new AssertionWitnessSession(temporary(),now+60_000).handle(stable('before')).binding; + expect(()=>validateAssertionWitnessBinding({...binding,expiresAt:new Date(Date.parse(binding.issuedAt)+MAX_VERIFICATION_ATTEMPT_MS+1).toISOString()})).toThrow('lifetime exceeds'); + }); test('rejects forged, stale, and mismatched receipts after a valid out-of-process attestation',async()=>{ const work=temporary(),session=new AssertionWitnessSession(work,Date.now()+60_000),handle=session.handle(stable('before')),observation:VerificationObservation={booted:true,legitimate:true,security:'intended_failure',existingTests:false,output:'external verifier passed',inputHash:''},command={executable:'/usr/local/bin/node',args:['--test','--test-reporter=tap','./app.test.js']}; const receipt=await handle.attest(observation,[{command,code:0,output:tap,minimumPassingTests:1}]);