cso-eval-producer.ts
714 lines · scripts/cso-eval-producer.ts at 3f79d81
1#!/usr/bin/env -S bun --no-env-file --config=/dev/null
2/** Generic producer runner. Compile this file before moving it to a clean producer host. */
3import * as fs from 'node:fs';
4import { createHash } from 'node:crypto';
5import { execFileSync } from 'node:child_process';
6import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7import { readBoundedStable } from '../lib/cso/bounded-file';
8import { CsoError } from '../lib/cso/contracts';
9import { EMPTY_GLOBAL_GIT_CONFIG, executable, redact } from '../lib/cso/process';
10import { atomicWriteSync } from '../lib/fs-atomic';
11import { redactProducerText, sourceCredentialValues, type OutputRedaction } from './cso-eval-output-redaction';
12import { resolveClaudeCommand } from '../lib/claude-bin';
13import runtimeCatalog from '../lib/cso/runtime-catalog.json';
14import scannerCatalog from '../lib/cso/scanner-images/catalog.json';
15import { ClaudeAdapter } from '../test/helpers/providers/claude';
16import { GptAdapter } from '../test/helpers/providers/gpt';
17import { GeminiAdapter, prepareGeminiProducerState, removeGeminiProducerState } from '../test/helpers/providers/gemini';
18import { PRICING } from '../test/helpers/pricing';
19import type { ProviderAdapter, RunOpts, RunResult } from '../test/helpers/providers/types';
20import {
21 PRODUCER_PLATFORMS,
22 producerHostPlatform,
23 producerInputHash,
24 producerArtifactInventoryHash,
25 producerInstallationIdentityHash,
26 producerProviderIdentityHash,
27 producerReceiptHash,
28 sha256,
29 type ProducerExecution,
30 type ProducerHost,
31 type ProducerInput,
32 type ProducerArtifactInventory,
33 type ProducerInstallationIdentity,
34 type ProducerProviderIdentity,
35 type ProducerReceipt,
36 type ProducerSourceEntry,
37} from './cso-eval-protocol';
39const HEX = /^[a-f0-9]{64}$/;
40const GENERATION_MANIFEST_BYTES = 65;
41const INPUT_LIMIT = 4 * 1024 * 1024;
42const OUTPUT_LIMIT = 32 * 1024 * 1024;
43const ARTIFACT_FILE_LIMIT = 32 * 1024 * 1024;
44const ARTIFACT_TOTAL_LIMIT = 128 * 1024 * 1024;
45const ARTIFACT_COUNT_LIMIT = 4096;
46/**
47 * `/cso --budget` bounds the helper run: it accepts evidence until its deadline (start + budget) and `finish` writes the
48 * report after it. A user's agent is never killed at the budget, so the provider gets the budget, the agent's time
49 * before `start`, and the reporting time after the deadline. Matches the evaluator's frozen `graceSeconds`.
50 */
51export const PRODUCER_REPORTING_GRACE_SECONDS = 300;
53export function producerProviderTimeoutMs(budgetSeconds: number): number {
54 return (budgetSeconds + PRODUCER_REPORTING_GRACE_SECONDS) * 1000;
55}
57function inside(parent: string, child: string): boolean {
58 const path = relative(parent, child);
59 return path === '' || (!path.startsWith(`..${sep}`) && path !== '..' && !isAbsolute(path));
60}
62function sourceError(path: string, rule: string): Error {
63 return new Error(`INVALID_PRODUCER_SOURCE: ${JSON.stringify(path.length > 200 ? `${path.slice(0, 200)}...` : path)} ${rule}`);
64}
66function regularFile(path: string, relativePath: string): fs.Stats {
67 const stat = fs.lstatSync(path);
68 if (!stat.isFile() || stat.isSymbolicLink()) throw sourceError(relativePath, 'is not a regular file');
69 if (stat.nlink !== 1) throw sourceError(relativePath, `has ${stat.nlink} hard links; expected 1`);
70 return stat;
71}
73function safeDirectory(path: string): void {
74 const stat = fs.lstatSync(path);
75 if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(path) !== path) throw new Error('NON_ISOLATED_PRODUCER_LAYOUT');
76}
78function assertIsolatedLayout(controlPath: string, receiptPath: string): { jobRoot: string; sourceRoot: string } {
79 const jobRoot = dirname(controlPath), producerRoot = dirname(jobRoot), sourceRoot = join(jobRoot, 'source');
80 safeDirectory(producerRoot); safeDirectory(jobRoot); safeDirectory(sourceRoot);
81 if (basename(jobRoot) !== 'job' || basename(controlPath) !== 'producer-input.json') throw new Error('NON_ISOLATED_PRODUCER_LAYOUT');
82 if (JSON.stringify(fs.readdirSync(producerRoot).sort()) !== JSON.stringify(['job'])) throw new Error('NON_ISOLATED_PRODUCER_LAYOUT');
83 if (JSON.stringify(fs.readdirSync(jobRoot).sort()) !== JSON.stringify(['producer-input.json', 'source'])) throw new Error('NON_ISOLATED_PRODUCER_LAYOUT');
84 if (inside(producerRoot, receiptPath)) throw new Error('NON_ISOLATED_PRODUCER_LAYOUT');
85 return { jobRoot, sourceRoot };
86}
88function assertReceiptDestination(path: string): void {
89 const parent = dirname(path), stat = fs.lstatSync(parent);
90 if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(parent) !== parent || fs.existsSync(path)) throw new Error('UNSAFE_RECEIPT_DESTINATION');
91}
93function walk(root: string, directory = root): string[] {
94 const output: string[] = [];
95 for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
96 const full = join(directory, entry.name), relativePath = relative(root, full).split(sep).join('/');
97 if (directory === root && entry.name === '.git') {
98 if (!entry.isDirectory() || entry.isSymbolicLink() || fs.realpathSync(join(root, '.git')) !== join(root, '.git')) throw sourceError('.git', 'is not a real directory');
99 continue;
100 }
101 if (entry.isSymbolicLink()) throw sourceError(relativePath, 'is a symbolic link');
102 if (entry.isDirectory()) output.push(...walk(root, full));
103 else if (entry.isFile()) output.push(relativePath);
104 else throw sourceError(relativePath, 'is not a regular file or directory');
105 }
106 return output.sort();
107}
109function validateSource(root: string, entries: ProducerSourceEntry[], expectedHash: string): void {
110 const seen = new Set<string>();
111 for (const entry of entries) {
112 if (!entry || typeof entry.path !== 'string' || !entry.path || entry.path.includes('\\') || entry.path.startsWith('/') || entry.path.split('/').some(part => !part || part === '.' || part === '..') || seen.has(entry.path) || !HEX.test(entry.sha256) || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0) throw new Error('INVALID_PRODUCER_INPUT');
113 seen.add(entry.path);
114 }
115 const actualPaths = walk(root), actual = new Set(actualPaths);
116 const expectedPaths = entries.map(entry => entry.path).sort(), expected = new Set(expectedPaths);
117 const firstMismatch = [
118 ...actualPaths.filter(path => !expected.has(path)).map(path => [path, 'is not in the expected source listing']),
119 ...expectedPaths.filter(path => !actual.has(path)).map(path => [path, 'is missing from the source']),
120 ].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)[0];
121 if (firstMismatch) throw sourceError(firstMismatch[0], firstMismatch[1]);
122 const hashed: Array<[string, string]> = [];
123 for (const entry of [...entries].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) {
124 const path = resolve(root, ...entry.path.split('/'));
125 if (!inside(root, path)) throw sourceError(entry.path, 'resolves outside the source root');
126 const stat = regularFile(path, entry.path);
127 const contents = readBoundedStable(path, 2 * 1024 * 1024, 'Producer source file');
128 if (stat.size !== entry.bytes || contents.byteLength !== entry.bytes) throw sourceError(entry.path, `is ${contents.byteLength} bytes; expected ${entry.bytes}`);
129 if (sha256(contents) !== entry.sha256) throw sourceError(entry.path, 'does not match its expected sha256');
130 hashed.push([entry.path, entry.sha256]);
131 }
132 if (sha256(JSON.stringify(hashed)) !== expectedHash) throw sourceError('.', 'listing does not match the cell sourceHash');
133}
135/** Seal the one-cell source copy before a provider starts. Production producers are macOS/Linux only. */
136export function sealProducerSource(root: string): void {
137 if (process.platform === 'win32') return;
138 const directories: string[] = [];
139 const files: string[] = [];
140 const visit = (directory: string): void => {
141 const directoryStat = fs.lstatSync(directory);
142 if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw sourceError(relative(root, directory) || '.', 'is not a real directory');
143 directories.push(directory);
144 for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
145 const full = join(directory, entry.name);
146 if (entry.isSymbolicLink()) throw sourceError(relative(root, full), 'is a symbolic link');
147 if (entry.isDirectory()) visit(full);
148 else if (entry.isFile()) files.push(full);
149 else throw sourceError(relative(root, full), 'is not a regular file or directory');
150 }
151 };
152 visit(root);
153 for (const file of files) fs.chmodSync(file, 0o444);
154 // Children first so traversal stays available while modes are changed.
155 for (const directory of directories.reverse()) fs.chmodSync(directory, 0o555);
156 assertProducerSourceSealed(root);
157}
159export function assertProducerSourceSealed(root: string): void {
160 if (process.platform === 'win32') return;
161 const visit = (directory: string): void => {
162 const modeError = (path: string, rule: string) => new Error(`PRODUCER_CHANGED_SOURCE_MODE: ${JSON.stringify(relative(root, path) || '.')} ${rule}`);
163 const directoryStat = fs.lstatSync(directory);
164 if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw modeError(directory, 'is not a real directory');
165 if ((directoryStat.mode & 0o777) !== 0o555) throw modeError(directory, `has mode ${(directoryStat.mode & 0o777).toString(8)}; expected 555`);
166 for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
167 const full = join(directory, entry.name), stat = fs.lstatSync(full);
168 if (entry.isSymbolicLink()) throw modeError(full, 'is a symbolic link');
169 if (entry.isDirectory()) visit(full);
170 else if (!entry.isFile()) throw modeError(full, 'is not a regular file or directory');
171 else if ((stat.mode & 0o777) !== 0o444) throw modeError(full, `has mode ${(stat.mode & 0o777).toString(8)}; expected 444`);
172 }
173 };
174 visit(root);
175}
177function emptyArtifactInventory(): ProducerArtifactInventory {
178 const base = { schemaVersion: 1 as const, root: 'security/cso' as const, entries: [], totalBytes: 0 };
179 return { ...base, identityHash: producerArtifactInventoryHash(base) };
180}
182/** A whole path component that is one of the helper's own identifiers: `<epoch ms>-<hex>` run and replay IDs, retired runs, hex artifact IDs with an optional lowercase-word prefix (`gitleaks-<hex>-<hex>.json`), and dependency-closure files (`<finding id>-before|after-<plan hash>.json`). */
183const HELPER_ID_COMPONENT = /^(?:\d{13}-[a-f0-9]{16}|\.retired-\d{13}-[a-f0-9]{16}-[a-f0-9]{32}|(?:[a-z]+-)*[a-f0-9]{16,64}(?:-[a-f0-9]{16})*(?:\.[a-z]+)?|(?:[a-f0-9]{32}){1,2}-(?:before|after)-[a-f0-9]{16}\.json)$/;
185/**
186 * Helper artifact paths are `<repoId>/<runId>/...` plus the helper's public-cache/ and legacy-imports/. Components that
187 * are wholly a helper identifier are checked by shape, because the secret redactor reads them as data (a 13-digit epoch
188 * as a phone number, some 32-hex IDs as wallets). Every other component (fixed helper names, and source paths mirrored
189 * under snapshot/ and readable/) still goes through the redactor.
190 */
191export function assertProducerArtifactPath(relativePath: string): void {
192 const parts = relativePath.split('/');
193 const reject = (rule: string): never => { throw new Error(`INVALID_PRODUCER_ARTIFACTS: ${rule}`); };
194 if (!relativePath || relativePath.length > 1024) reject('an artifact path is empty or longer than 1024 bytes');
195 if (!/^[A-Za-z0-9._/-]+$/.test(relativePath) || parts.some(part => !part || part === '.' || part === '..')) reject('an artifact path has a component outside [A-Za-z0-9._-]');
196 const inRun = /^[a-f0-9]{24}$/.test(parts[0]) && /^(?:\d{13}-[a-f0-9]{16}|\.retired-\d{13}-[a-f0-9]{16}-[a-f0-9]{32})$/.test(parts[1] ?? '');
197 if (parts.length < 2 || !(inRun ? parts.length >= 3 : ['public-cache', 'legacy-imports'].includes(parts[0]))) reject('an artifact lies outside <repoId>/<runId>/, public-cache/ and legacy-imports/');
198 const schemaId = (part: string, index: number) => index === 0 ? /^[a-f0-9]{24}$/.test(part) : HELPER_ID_COMPONENT.test(part);
199 for (const [index, part] of parts.entries()) {
200 if (schemaId(part, index)) continue;
201 let clean: boolean;
202 try { clean = redact(part) === part; } catch { clean = false; }
203 if (!clean) {
204 const parent = parts.slice(0, index).map((item, at) => schemaId(item, at) ? 'ID' : item).join('/');
205 throw new CsoError('REDACTION_FAILED', `artifact path component ${index + 1} under ${JSON.stringify(parent)} looks like a secret; the component is withheld`);
206 }
207 }
208}
210export function inventoryProducerArtifacts(helperHome: string): ProducerArtifactInventory {
211 const security = join(helperHome, 'security'), artifactRoot = join(security, 'cso');
212 for (const directory of [security, artifactRoot]) {
213 let stat: fs.Stats;
214 try { stat = fs.lstatSync(directory); }
215 catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return emptyArtifactInventory(); throw error; }
216 if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(directory) !== directory) throw new Error('INVALID_PRODUCER_ARTIFACTS');
217 }
218 const entries: ProducerSourceEntry[] = [];
219 let totalBytes = 0, visited = 0;
220 const visit = (directory: string, depth: number): void => {
221 if (depth > 32 || ++visited > ARTIFACT_COUNT_LIMIT * 2) throw new Error('INVALID_PRODUCER_ARTIFACTS');
222 for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
223 const full = join(directory, entry.name);
224 if (++visited > ARTIFACT_COUNT_LIMIT * 2) throw new Error('INVALID_PRODUCER_ARTIFACTS');
225 if (entry.isSymbolicLink()) throw new Error('INVALID_PRODUCER_ARTIFACTS');
226 if (entry.isDirectory()) { visit(full, depth + 1); continue; }
227 if (!entry.isFile() || entries.length >= ARTIFACT_COUNT_LIMIT) throw new Error('INVALID_PRODUCER_ARTIFACTS');
228 const relativePath = relative(artifactRoot, full).split(sep).join('/');
229 assertProducerArtifactPath(relativePath);
230 const contents = readBoundedStable(full, ARTIFACT_FILE_LIMIT, 'Producer artifact');
231 totalBytes += contents.byteLength;
232 if (totalBytes > ARTIFACT_TOTAL_LIMIT) throw new Error('PRODUCER_ARTIFACTS_TOO_LARGE');
233 entries.push({ path: relativePath, sha256: sha256(contents), bytes: contents.byteLength });
234 }
235 };
236 visit(artifactRoot, 0);
237 entries.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
238 const base = { schemaVersion: 1 as const, root: 'security/cso' as const, entries, totalBytes };
239 return { ...base, identityHash: producerArtifactInventoryHash(base) };
240}
242function repositoryIdentity(root: string): string {
243 const nullPath = process.platform === 'win32' ? 'NUL' : '/dev/null';
244 const env = { PATH: process.env.PATH ?? '', LANG: 'C', LC_ALL: 'C', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: EMPTY_GLOBAL_GIT_CONFIG, GIT_ATTR_NOSYSTEM: '1', GIT_OPTIONAL_LOCKS: '0' };
245 const git = executable('git');
246 const config = readBoundedStable(join(root, '.git', 'config'), 1024 * 1024, 'Producer Git configuration').toString('utf8');
247 if (/^\s*\[\s*include(?:if)?(?=[\s."\]])/im.test(config)) throw sourceError('.git/config', 'contains an include directive');
248 const common = ['-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', `core.hooksPath=${nullPath}`, '-c', `core.attributesFile=${nullPath}`, '-c', `core.excludesFile=${nullPath}`, '-c', 'core.pager=cat', '-C', root];
249 const read = (args: string[]) => execFileSync(git, [...common, ...args], { env, encoding: 'utf8', timeout: 10_000, maxBuffer: 1024 * 1024 });
250 return sha256(JSON.stringify({ config: sha256(config), head: read(['rev-parse', '--verify', 'HEAD']).trim(), branch: read(['symbolic-ref', '--short', 'HEAD']).trim(), status: read(['status', '--porcelain=v2', '--untracked-files=all']) }));
251}
253const INPUT_FIELDS = 'cellRef,execution,schemaVersion,skill,source';
254const EXECUTION_FIELDS = 'budgetSeconds,host,mode,model,skillHash,sourceHash';
255/** The input has exactly the opaque fields: no case, variant, version, or repetition can ride along. */
256function validateInput(input: ProducerInput): void {
257 const execution = input?.execution;
258 if (!input || typeof input !== 'object' || Object.keys(input).sort().join(',') !== INPUT_FIELDS || input.schemaVersion !== 2 || !HEX.test(input.cellRef) ||
259 typeof input.skill !== 'string' || Buffer.byteLength(input.skill) > INPUT_LIMIT || !Array.isArray(input.source) ||
260 !execution || typeof execution !== 'object' || Object.keys(execution).filter(key => key !== 'platform').sort().join(',') !== EXECUTION_FIELDS ||
261 !['daily', 'comprehensive'].includes(execution.mode) || typeof execution.model !== 'string' || !execution.model || !['claude', 'codex', 'gemini'].includes(execution.host) ||
262 !Number.isInteger(execution.budgetSeconds) || execution.budgetSeconds <= 60 || execution.budgetSeconds > 3600 || !HEX.test(execution.sourceHash) || !HEX.test(execution.skillHash) ||
263 ('platform' in execution && !PRODUCER_PLATFORMS.includes(execution.platform!))) throw new Error('INVALID_PRODUCER_INPUT');
264 if (execution.platform !== undefined && execution.platform !== producerHostPlatform()) throw new Error('UNMATCHED_PRODUCER_PLATFORM');
265}
267export interface ProducerHelperBinding {
268 producer: string;
269 launcher: string;
270 core: string;
271 watchdog: string;
272 generation: string;
273}
275function boundExecutable(path: string): void {
276 const stat = fs.lstatSync(path);
277 if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || fs.realpathSync(path) !== path ||
278 (process.platform !== 'win32' && ((stat.mode & 0o111) === 0 || (stat.mode & 0o6000) !== 0))) throw new Error('INVALID_PRODUCER_HELPER');
279}
281function readGenerationManifest(path: string): string {
282 const bytes = readBoundedStable(path, GENERATION_MANIFEST_BYTES, 'Producer helper generation manifest');
283 const value = bytes.toString('utf8');
284 if (bytes.byteLength !== GENERATION_MANIFEST_BYTES || !/^[a-f0-9]{64}\n$/.test(value)) {
285 throw new Error('INVALID_PRODUCER_GENERATION');
286 }
287 return value.slice(0, -1);
288}
290function boundGenerationManifest(path: string): void {
291 const stat = fs.lstatSync(path);
292 if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || fs.realpathSync(path) !== path ||
293 stat.size !== GENERATION_MANIFEST_BYTES || (process.platform !== 'win32' && ((stat.mode & 0o444) === 0 || (stat.mode & 0o6111) !== 0))) {
294 throw new Error('INVALID_PRODUCER_HELPER');
295 }
296 readGenerationManifest(path);
297}
299function writableByProducer(path: string): boolean {
300 try { fs.accessSync(path, fs.constants.W_OK); return true; }
301 catch { return false; }
302}
304/** Production bundles must remain immutable to the unprivileged producing agent. */
305export function validateProductionProducerInstallation(binding: ProducerHelperBinding, producerExecutable = process.execPath): void {
306 if (typeof process.getuid === 'function' && process.getuid() === 0) throw new Error('ROOT_PRODUCER_UNSUPPORTED');
307 if (producerExecutable !== binding.producer || !isAbsolute(producerExecutable) || resolve(producerExecutable) !== producerExecutable || dirname(producerExecutable) !== dirname(binding.launcher)) {
308 throw new Error('INVALID_PRODUCER_HELPER');
309 }
310 const executables = [producerExecutable, binding.launcher, binding.core, binding.watchdog];
311 for (const path of executables) boundExecutable(path);
312 boundGenerationManifest(binding.generation);
313 const files = [...executables, binding.generation];
315 const directories: string[] = [];
316 for (let current = dirname(producerExecutable);;) {
317 directories.push(current);
318 const parent = dirname(current);
319 if (parent === current) break;
320 current = parent;
321 }
322 for (const path of directories) {
323 const stat = fs.lstatSync(path);
324 if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(path) !== path) throw new Error('INVALID_PRODUCER_HELPER');
325 }
326 if ([...files, ...directories].some(writableByProducer)) throw new Error('WRITABLE_PRODUCER_INSTALLATION');
327 if (process.platform !== 'win32') {
328 for (const path of [...files, ...directories]) {
329 const stat = fs.lstatSync(path);
330 if (stat.uid !== 0 || (stat.mode & 0o022) !== 0) throw new Error('UNTRUSTED_PRODUCER_INSTALLATION');
331 }
332 }
333}
335export function resolveProducerHelperBinding(
336 sourceRoot: string,
337 stateRoot: string,
338 testLauncherPath?: string,
339): ProducerHelperBinding {
340 if (process.platform === 'win32' && testLauncherPath === undefined) throw new Error('UNSUPPORTED_PRODUCER_PLATFORM');
341 const executableSuffix = process.platform === 'win32' ? '.exe' : '';
342 const launcher = testLauncherPath ?? join(dirname(process.execPath), `gstack-cso-launcher${executableSuffix}`);
343 if (!isAbsolute(launcher) || resolve(launcher) !== launcher || basename(launcher) !== `gstack-cso-launcher${executableSuffix}`) {
344 throw new Error('INVALID_PRODUCER_HELPER');
345 }
346 const directory = dirname(launcher);
347 if (inside(sourceRoot, directory) || inside(directory, sourceRoot) || inside(stateRoot, directory) || inside(directory, stateRoot)) {
348 throw new Error('INVALID_PRODUCER_HELPER');
349 }
350 const binding = {
351 producer: testLauncherPath === undefined ? process.execPath : join(dirname(launcher), `cso-eval-producer${executableSuffix}`),
352 launcher,
353 core: join(directory, `gstack-cso-core${executableSuffix}`),
354 watchdog: join(directory, `gstack-cso-watchdog${executableSuffix}`),
355 generation: join(directory, '.gstack-cso-generation'),
356 };
357 try {
358 boundExecutable(binding.producer);
359 boundExecutable(binding.launcher);
360 boundExecutable(binding.core);
361 boundExecutable(binding.watchdog);
362 boundGenerationManifest(binding.generation);
363 } catch {
364 throw new Error('INVALID_PRODUCER_HELPER');
365 }
366 // The explicit path exists solely for source-level unit tests. The production
367 // CLI has no override and enforces an unprivileged, nonwritable installation.
368 if (testLauncherPath === undefined) validateProductionProducerInstallation(binding);
369 return binding;
370}
372const PRODUCER_ARTIFACT_LIMIT = 512 * 1024 * 1024;
374function artifactIdentity(path: string): { sha256: string; bytes: number } {
375 const named = fs.lstatSync(path);
376 if (!named.isFile() || named.isSymbolicLink() || named.nlink !== 1 || named.size <= 0 || named.size > PRODUCER_ARTIFACT_LIMIT) {
377 throw new Error('INVALID_PRODUCER_INSTALLATION');
378 }
379 let descriptor: number | undefined;
380 try {
381 descriptor = fs.openSync(path, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
382 const opened = fs.fstatSync(descriptor);
383 if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== named.dev || opened.ino !== named.ino || opened.mode !== named.mode || opened.size !== named.size) {
384 throw new Error('PRODUCER_INSTALLATION_RACE');
385 }
386 const hash = createHash('sha256'), buffer = Buffer.allocUnsafe(1024 * 1024);
387 let bytes = 0;
388 for (;;) {
389 const count = fs.readSync(descriptor, buffer, 0, buffer.length, null);
390 if (count === 0) break;
391 bytes += count;
392 if (bytes > PRODUCER_ARTIFACT_LIMIT) throw new Error('INVALID_PRODUCER_INSTALLATION');
393 hash.update(buffer.subarray(0, count));
394 }
395 const after = fs.fstatSync(descriptor), current = fs.lstatSync(path);
396 if (bytes !== opened.size || !current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || current.dev !== opened.dev || current.ino !== opened.ino || current.mode !== opened.mode || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) {
397 throw new Error('PRODUCER_INSTALLATION_RACE');
398 }
399 return { sha256: hash.digest('hex'), bytes };
400 } finally {
401 if (descriptor !== undefined) fs.closeSync(descriptor);
402 }
403}
405function generationIdentity(path: string, core: { sha256: string; bytes: number }): ProducerInstallationIdentity['generation'] {
406 const manifest = artifactIdentity(path);
407 const coreSha256 = readGenerationManifest(path);
408 if (manifest.bytes !== GENERATION_MANIFEST_BYTES || manifest.sha256 !== sha256(`${coreSha256}\n`)) {
409 throw new Error('PRODUCER_INSTALLATION_RACE');
410 }
411 if (coreSha256 !== core.sha256) throw new Error('PRODUCER_HELPER_GENERATION_MISMATCH');
412 return { coreSha256, manifest };
413}
415export function producerInstallationIdentity(binding: ProducerHelperBinding): ProducerInstallationIdentity {
416 const core = artifactIdentity(binding.core);
417 const withoutHash: Omit<ProducerInstallationIdentity, 'identityHash'> = {
418 schemaVersion: 1,
419 producer: artifactIdentity(binding.producer),
420 launcher: artifactIdentity(binding.launcher),
421 core,
422 watchdog: artifactIdentity(binding.watchdog),
423 generation: generationIdentity(binding.generation, core),
424 embeddedCatalogs: {
425 runtimeRevision: runtimeCatalog.revision,
426 runtimeBuildRevision: runtimeCatalog.buildRevision,
427 runtimeSha256: sha256(JSON.stringify(runtimeCatalog)),
428 scannerRevision: scannerCatalog.revision,
429 scannerSha256: sha256(JSON.stringify(scannerCatalog)),
430 },
431 };
432 return { ...withoutHash, identityHash: producerInstallationIdentityHash(withoutHash) };
433}
435export const PRODUCER_PROVIDER_POLICY: Record<ProducerHost, { family: ProducerProviderIdentity['family']; policyRevision: string; version: string }> = {
436 claude: { family: 'claude', policyRevision: 'claude-2.1.263-cso-v2', version: '2.1.263 (Claude Code)' },
437 codex: { family: 'gpt', policyRevision: 'codex-0.153.4-cso-v3-generation', version: 'codex-cli 0.153.4' },
438 gemini: { family: 'gemini', policyRevision: 'gemini-0.59.0-cso-v1', version: '0.59.0' },
439};
441function validateProviderIdentity(identity: ProducerProviderIdentity, family: ProducerProviderIdentity['family']): ProducerProviderIdentity {
442 if (!identity || identity.schemaVersion !== 1 || identity.family !== family || !identity.policyRevision || !identity.version ||
443 !Array.isArray(identity.argsPrefix) || identity.argsPrefix.some(value => typeof value !== 'string' || value.includes('\0')) ||
444 !identity.executable || !HEX.test(identity.executable.sha256) || !Number.isSafeInteger(identity.executable.bytes) || identity.executable.bytes <= 0 ||
445 !HEX.test(identity.identityHash)) throw new Error('INVALID_PRODUCER_PROVIDER_IDENTITY');
446 const { identityHash, ...withoutHash } = identity;
447 if (producerProviderIdentityHash(withoutHash) !== identityHash) throw new Error('INVALID_PRODUCER_PROVIDER_IDENTITY');
448 return identity;
449}
451export interface ProducerProviderResolution {
452 identity: ProducerProviderIdentity;
453 command: { executable: string; argsPrefix: string[] };
454}
456export function resolveProducerProviderIdentity(host: ProducerHost, stateRoot: string): ProducerProviderResolution {
457 const policy = PRODUCER_PROVIDER_POLICY[host];
458 const resolved = host === 'claude'
459 ? resolveClaudeCommand()
460 : { command: executable(host === 'codex' ? 'codex' : 'gemini'), argsPrefix: [] as string[] };
461 if (!resolved) throw new Error('PRODUCER_UNAVAILABLE: provider CLI not found');
462 const command = fs.realpathSync(resolved.command);
463 const versionOutput = execFileSync(resolved.command, [...resolved.argsPrefix, '--version'], {
464 cwd: dirname(command),
465 env: { PATH: process.env.PATH ?? '', HOME: stateRoot, LANG: 'C', LC_ALL: 'C', TZ: 'UTC' },
466 encoding: 'utf8', timeout: 10_000, maxBuffer: 64 * 1024,
467 }).trim();
468 if (policy.version !== versionOutput || redact(versionOutput) !== versionOutput) throw new Error('UNQUALIFIED_PRODUCER_PROVIDER_VERSION');
469 const withoutHash: Omit<ProducerProviderIdentity, 'identityHash'> = {
470 schemaVersion: 1,
471 family: policy.family,
472 policyRevision: policy.policyRevision,
473 executable: artifactIdentity(command),
474 argsPrefix: [...resolved.argsPrefix],
475 version: versionOutput,
476 };
477 return {
478 identity: { ...withoutHash, identityHash: producerProviderIdentityHash(withoutHash) },
479 command: { executable: command, argsPrefix: [...resolved.argsPrefix] },
480 };
481}
483function promptFor(cell: ProducerExecution, skill: string, sourceRoot: string, helper: ProducerHelperBinding): string {
484 const invocation = `/cso${cell.mode === 'comprehensive' ? ' --comprehensive' : ''} --budget ${cell.budgetSeconds}`;
485 const sourceAccess = cell.host === 'codex'
486 ? 'The Codex permission profile grants read-only access to exactly that immutable snapshot so the trusted helper can read it. Use only the trusted helper to inspect or act on source; direct provider file or shell access is outside this evaluation policy.'
487 : 'The application snapshot is available to the producing agent only through the trusted helper.';
488 const sourceLocation = `The neutral current working directory contains no application source. The application repository at ${sourceRoot} is your entire source scope. ${sourceAccess}`;
489 return `You are the producing agent in a matched security evaluation. ${sourceLocation} The only trusted gstack-cso for this run is the absolute launcher path ${JSON.stringify(helper.launcher)}. Interpret every \`gstack-cso\` command in the skill as that exact path; do not discover or invoke another helper through PATH. Do not inspect parent directories, process metadata, evaluation control files, sibling jobs, alternative source variants, expected labels, or evaluator assertions. Follow the exact CSO skill instructions below and perform this invocation: ${invocation}\n\nDo not ask questions. Leave the application branch unchanged. End with the normal CSO report. Producer statements are claims that an independent evaluator will challenge; never infer private assertions or call a result tested without the helper evidence required by the skill.\n\n<exact-cso-skill sha256="${cell.skillHash}">\n${skill}\n</exact-cso-skill>\n`;
490}
492export function adapterFor(host: ProducerHost): ProviderAdapter {
493 return host === 'claude' ? new ClaudeAdapter() : host === 'codex' ? new GptAdapter() : new GeminiAdapter();
494}
496function writeExclusiveAtomic(path: string, value: unknown): void {
497 const parent = dirname(path);
498 const stat = fs.lstatSync(parent);
499 if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('UNSAFE_RECEIPT_DESTINATION');
500 try { atomicWriteSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, noReplace: true }); }
501 catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('RECEIPT_EXISTS'); throw error; }
502}
504/**
505 * Redact both channels, and both concatenation orders, before a receipt can bind them. A credential-class match
506 * anywhere withholds the output and fails the run, naming the rules. PII, internal, legal and hygiene matches are
507 * replaced in place and leave the run's status alone: scoring reads the helper's report, the evaluator scans the
508 * receipt for its canaries separately, and the helper's own identifiers are not personal data. `sourceCredentials` are
509 * the source's credential-name values (see sourceCredentialValues), matched exactly because no rule sees them bare.
510 */
511export function sanitizeProducerRun(run: RunResult, sourceCredentials: string[] = []): RunResult {
512 if (typeof run.output !== 'string' || (run.error && typeof run.error.reason !== 'string')) {
513 throw new CsoError('REDACTION_FAILED', 'Producer output withheld because it was not valid text');
514 }
515 const reason = run.error?.reason ?? '';
516 let views: OutputRedaction[];
517 try {
518 views = [run.output, reason, run.output + reason, reason + run.output].map(view => redactProducerText(view, sourceCredentials));
519 } catch {
520 throw new CsoError('REDACTION_FAILED', 'Producer output withheld because redaction could not safely inspect it');
521 }
522 const secret = views.find(view => view.secret);
523 if (secret) {
524 return {
525 ...run,
526 output: '[sensitive producer output redacted]',
527 error: { code: run.error?.code ?? 'unknown', reason: `Sensitive producer output or error withheld: credential-class rule ${secret.rules.join(', ')}` },
528 };
529 }
530 const [output, error] = views;
531 return { ...run, output: output.text, ...(run.error ? { error: { ...run.error, reason: error.text } } : {}) };
532}
534export function producerFailureMessage(error: unknown): string {
535 if (error instanceof CsoError && error.code === 'REDACTION_FAILED') return 'REDACTION_FAILED: producer payload withheld';
536 const message = error instanceof Error ? error.message : 'CSO producer failed';
537 try { return redact(message); }
538 catch { return 'REDACTION_FAILED: producer error withheld'; }
539}
541/** A post-run check failed: the receipt records it as a failed cell with no output or artifacts, keeping the run's usage. */
542export function producerIntegrityFailure(error: unknown): { code: string; reason: string } {
543 if (error instanceof CsoError) {
544 let reason: string;
545 try { reason = redact(error.message); } catch { reason = 'detail withheld'; }
546 return { code: error.code, reason };
547 }
548 const message = producerFailureMessage(error);
549 const named = error instanceof Error && !(error as NodeJS.ErrnoException).code ? /^([A-Z][A-Z0-9_]+)(?::\s*([\s\S]*))?$/.exec(message) : null;
550 return named ? { code: named[1], reason: named[2] || named[1] } : { code: 'PRODUCER_POST_RUN_CHECK_FAILED', reason: message };
551}
553export async function runProducerCell(inputPath: string, receiptPath: string, options: {
554 adapter?: ProviderAdapter;
555 paidExecutionAuthorized: boolean;
556 /** Unit tests run this source file through Bun; production always uses the adjacent compiled bundle. */
557 testHelperLauncherPath?: string;
558 /** Unit tests never execute a provider CLI; production always measures the reviewed binary. */
559 testProviderIdentity?: ProducerProviderIdentity;
560 /** Test-only exact command paired with testProviderIdentity. */
561 testProviderCommand?: { executable: string; argsPrefix: string[] };
562}): Promise<ProducerReceipt> {
563 if (!options.paidExecutionAuthorized) throw new Error('PAID_EXECUTION_NOT_AUTHORIZED');
564 const controlPath = resolve(inputPath);
565 const receipt = resolve(receiptPath);
566 const { jobRoot, sourceRoot } = assertIsolatedLayout(controlPath, receipt);
567 assertReceiptDestination(receipt);
568 const bytes = readBoundedStable(controlPath, INPUT_LIMIT, 'Producer input');
569 let input: ProducerInput;
570 try { input = JSON.parse(bytes.toString('utf8')); } catch { throw new Error('INVALID_PRODUCER_INPUT'); }
571 validateInput(input);
572 const cell = input.execution;
573 if (basename(receipt) !== `${input.cellRef}.json`) throw new Error('UNMATCHED_RECEIPT_DESTINATION');
574 if (sha256(input.skill) !== cell.skillHash) throw new Error('INVALID_PRODUCER_SKILL');
575 validateSource(sourceRoot, input.source, cell.sourceHash);
576 sealProducerSource(sourceRoot);
577 validateSource(sourceRoot, input.source, cell.sourceHash);
578 const sourceCredentials = sourceCredentialValues(sourceRoot, input.source.map(entry => entry.path));
579 const originalRepositoryIdentity = repositoryIdentity(sourceRoot);
580 const stateRoot = join(jobRoot, 'state');
581 const helperHome = join(stateRoot, 'cso-home');
582 const helper = resolveProducerHelperBinding(sourceRoot, stateRoot, options.testHelperLauncherPath);
583 const installationIdentity = producerInstallationIdentity(helper);
584 const adapter = options.adapter ?? adapterFor(cell.host);
585 if ((cell.host === 'codex' ? 'gpt' : cell.host) !== adapter.family) throw new Error('UNMATCHED_PRODUCER_ADAPTER');
586 fs.mkdirSync(stateRoot, { recursive: false, mode: 0o700 });
587 fs.mkdirSync(helperHome, { recursive: false, mode: 0o700 });
588 const provider = options.testProviderIdentity
589 ? {
590 identity: validateProviderIdentity(options.testProviderIdentity, adapter.family),
591 command: options.testProviderCommand ?? { executable: process.execPath, argsPrefix: [] },
592 }
593 : resolveProducerProviderIdentity(cell.host, stateRoot);
594 const providerIdentity = provider.identity;
595 const runOptions = {
596 prompt: promptFor(cell, input.skill, sourceRoot, helper),
597 workdir: stateRoot,
598 timeoutMs: producerProviderTimeoutMs(cell.budgetSeconds),
599 model: cell.model,
600 csoProducer: {
601 stateDirectory: stateRoot,
602 sourceDirectory: sourceRoot,
603 helperLauncher: helper.launcher,
604 helperGeneration: helper.generation,
605 providerCommand: provider.command,
606 },
607 } satisfies RunOpts;
608 const availability = await adapter.available(runOptions);
609 if (!availability.ok) throw new Error(`PRODUCER_UNAVAILABLE: ${availability.reason ?? cell.host}`);
610 const inputHash = producerInputHash(input);
612 // Load the opaque metadata into memory, then remove it before starting the
613 // agent. A clean producer host exposes only source + installed product bits.
614 fs.unlinkSync(controlPath);
615 const previousHome = process.env.GSTACK_HOME;
616 const previousSessionKind = process.env.GSTACK_SESSION_KIND;
617 const previousHeadless = process.env.GSTACK_HEADLESS;
618 process.env.GSTACK_HOME = helperHome;
619 process.env.GSTACK_SESSION_KIND = 'spawned';
620 process.env.GSTACK_HEADLESS = '1';
621 const startedAt = new Date().toISOString();
622 let run: RunResult;
623 try {
624 if (cell.host === 'gemini') prepareGeminiProducerState(stateRoot, helper.launcher);
625 run = await adapter.run(runOptions);
626 } finally {
627 try {
628 if (cell.host === 'gemini') removeGeminiProducerState(stateRoot);
629 } finally {
630 if (previousHome === undefined) delete process.env.GSTACK_HOME; else process.env.GSTACK_HOME = previousHome;
631 if (previousSessionKind === undefined) delete process.env.GSTACK_SESSION_KIND; else process.env.GSTACK_SESSION_KIND = previousSessionKind;
632 if (previousHeadless === undefined) delete process.env.GSTACK_HEADLESS; else process.env.GSTACK_HEADLESS = previousHeadless;
633 }
634 }
635 const finishedAt = new Date().toISOString();
636 let artifacts = emptyArtifactInventory();
637 let integrityFailure: { code: string; reason: string } | undefined;
638 let check = '';
639 try {
640 check = 'helper installation';
641 const installationAfter = producerInstallationIdentity(helper);
642 if (installationAfter.identityHash !== installationIdentity.identityHash) {
643 throw new Error('PRODUCER_HELPER_GENERATION_CHANGED: installed helper identity changed during the run');
644 }
645 check = 'provider installation';
646 const providerAfter = artifactIdentity(provider.command.executable);
647 if (providerAfter.sha256 !== providerIdentity.executable.sha256 || providerAfter.bytes !== providerIdentity.executable.bytes) {
648 throw new Error('PRODUCER_PROVIDER_INSTALLATION_RACE: provider executable changed during the run');
649 }
650 check = 'source modes';
651 assertProducerSourceSealed(sourceRoot);
652 check = 'source content';
653 validateSource(sourceRoot, input.source, cell.sourceHash);
654 check = 'source Git state';
655 if (repositoryIdentity(sourceRoot) !== originalRepositoryIdentity) throw new Error('PRODUCER_CHANGED_SOURCE: Git HEAD, branch, config or status changed during the run');
656 check = 'artifact inventory';
657 artifacts = inventoryProducerArtifacts(helperHome);
658 check = 'output size';
659 const outputBytes = typeof run.output === 'string' ? Buffer.byteLength(run.output) : 0;
660 if (outputBytes > OUTPUT_LIMIT) throw new Error(`PRODUCER_OUTPUT_TOO_LARGE: output is ${outputBytes} bytes; the limit is ${OUTPUT_LIMIT}`);
661 } catch (error) {
662 const failure = producerIntegrityFailure(error);
663 integrityFailure = { code: failure.code, reason: `${check} check: ${failure.reason}${run.error ? `; the provider run also failed (${run.error.code})` : ''}` };
664 artifacts = emptyArtifactInventory();
665 }
666 if (!integrityFailure) {
667 run = sanitizeProducerRun(run, sourceCredentials);
668 if(!run.error&&!run.output.trim())run={...run,error:{code:'unknown',reason:'empty output from provider CLI (exit 0)'}};
669 }
670 const tokensReported = run.tokens.input > 0 || run.tokens.output > 0 || (run.tokens.cached ?? 0) > 0;
671 const estimatedCostUSD = tokensReported && PRICING[run.modelUsed] ? adapter.estimateCost(run.tokens, run.modelUsed) : null;
672 const failure = integrityFailure ?? run.error, output = integrityFailure ? '' : run.output;
673 const withoutHash: Omit<ProducerReceipt, 'receiptHash'> = {
674 schemaVersion: 2,
675 cellRef: input.cellRef,
676 inputHash,
677 installationIdentity,
678 providerIdentity,
679 artifacts,
680 startedAt,
681 finishedAt,
682 status: failure ? 'failed' : 'succeeded',
683 requestedModel: cell.model,
684 modelUsed: run.modelUsed,
685 modelIdentitySource: run.modelUsed === cell.model ? 'requested_pin' : 'provider_reported',
686 durationMs: run.durationMs,
687 firstUsefulResultMs: null,
688 toolCalls: run.toolCalls,
689 ...(run.toolTimeline ? { toolTimeline: run.toolTimeline } : {}),
690 output,
691 outputHash: sha256(output),
692 usage: {
693 inputTokens: tokensReported ? run.tokens.input : null,
694 outputTokens: tokensReported ? run.tokens.output : null,
695 cachedTokens: tokensReported && run.tokens.cached !== undefined ? run.tokens.cached : null,
696 estimatedCostUSD,
697 },
698 ...(failure ? { error: failure } : {}),
699 };
700 const result = { ...withoutHash, receiptHash: producerReceiptHash(withoutHash) };
701 writeExclusiveAtomic(receipt, result);
702 return result;
703}
705async function cli(args: string[]): Promise<void> {
706 if (args.length !== 4 || args[0] !== 'run' || args[3] !== '--execute-paid') throw new Error('Usage: cso-eval-producer run <consumable-input.json> <new-receipt.json> --execute-paid');
707 if (process.env.CSO_EVAL_PAID !== '1') throw new Error('PAID_EXECUTION_NOT_AUTHORIZED: also set CSO_EVAL_PAID=1 on the isolated producer host');
708 const receipt = await runProducerCell(args[1], args[2], { paidExecutionAuthorized: true });
709 console.log(JSON.stringify({ cellRef: receipt.cellRef, status: receipt.status, durationMs: receipt.durationMs, receiptHash: receipt.receiptHash }));
710}
712if (import.meta.main) {
713 cli(process.argv.slice(2)).catch(error => { console.error(producerFailureMessage(error)); process.exitCode = 1; });
714}