Files
gstack/test/hermetic-wiring.test.ts
T
garrytan 2e1dff825d Merge capy/audit-fix-wave (#2994) into the harness branch
#2994 deletes the plan-*-finding-count evals, ceo-payment-findings.ts and
design-count-review.ts. Drop the CEO throw diagnostics and Design boundary
work with them, and drop the structured completion predicate, stopReason,
review-log binding and plan/review-log evidence copy: no surviving
runPlanSkillCounting caller passes expectedPlanPath, so they would be dead
code. Keep idleFor in timeout summaries (every counting caller can time
out), asserted in the existing timeout test. W7 and W8 are unchanged.
2026-09-29 15:14:06 +00:00

192 lines
8.8 KiB
TypeScript

/**
* Static-grep tripwire for the hermetic E2E wiring. Free tier — no API.
*
* Every E2E runner spawns its child through hermeticChildEnv(); if a refactor
* reverts any spawn site to a raw `...process.env` spread (or a callsite
* smuggles the operator env back in through the overrides parameter), local
* evals silently re-contaminate and nothing fails until a human notices
* weird results again — which took three burned suites last time.
*
* Pattern mirrors browse/test/terminal-agent-pid-identity.test.ts and
* browse/test/server-embedder-terminal-port.test.ts: read source files as
* text, assert invariants on their contents. Brittle by design — renaming
* the helper must force the author to look here.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { buildHermeticEnv, getHermeticDirs, hermeticSkillsConfigDir } from './helpers/hermetic-env';
const ROOT = path.resolve(import.meta.path, '..', '..');
const RUNNERS = [
'test/helpers/session-runner.ts',
'test/helpers/claude-pty-runner.ts',
'test/helpers/codex-session-runner.ts',
'test/helpers/agent-sdk-runner.ts',
];
function read(rel: string): string {
return fs.readFileSync(path.join(ROOT, rel), 'utf-8');
}
describe('hermetic wiring tripwire', () => {
test('every runner builds its child env via hermeticChildEnv()', () => {
for (const rel of RUNNERS) {
const src = read(rel);
expect(src.includes('hermeticChildEnv(') ).toBe(true);
expect(src.includes("from './hermetic-env'")).toBe(true);
}
});
test('no runner spawns a child with a raw process.env spread', () => {
// `...process.env` inside an env object is the exact pre-hermetic leak.
// hermetic-env.ts itself legitimately READS process.env (call-time
// snapshot); the runners must not SPREAD it into a child env.
for (const rel of RUNNERS) {
const offenders = read(rel)
.split('\n')
.map((line, i) => ({ line, n: i + 1 }))
.filter(({ line }) => line.includes('...process.env'));
expect(
offenders,
`${rel} spreads raw process.env into a child env at line(s) ` +
offenders.map((o) => o.n).join(', ') +
' — route through hermeticChildEnv() instead',
).toEqual([]);
}
});
test('feature prompt acknowledgements are seeded in GSTACK_HOME everywhere', () => {
const markers = [
'.feature-prompted-model-overlay',
];
// CI seeding lives in the composite action (v1.77 moved it out of the
// inline workflow steps) — the workflows call the action, so one seeding
// site covers every lane.
const sources: Array<[string, number]> = [
['test/helpers/hermetic-env.ts', 1],
['test/helpers/e2e-helpers.ts', 1],
['.github/actions/register-gstack-skills/action.yml', 1],
];
for (const [rel, expectedCount] of sources) {
const src = read(rel);
for (const marker of markers) {
expect(src.split(marker).length - 1, `${rel}: ${marker}`).toBe(expectedCount);
}
}
for (const rel of ['.github/actions/register-gstack-skills/action.yml']) {
const src = read(rel);
expect(src).not.toContain('$SKILLS_DIR/gstack/.feature-prompted-');
for (const marker of markers) expect(src).toContain(`$HOME/.gstack/${marker}`);
}
});
test('both EVALS_HERMETIC branches pin DISABLE_AUTOUPDATER=1 over the workflow env', () => {
// The allowlist scrubs the workflow's own copy; without this pin every PTY
// screen carries "Auto-update failed: no write permission to npm prefix".
for (const EVALS_HERMETIC of ['1', '0']) {
const base = { PATH: '/usr/bin', EVALS_HERMETIC, DISABLE_AUTOUPDATER: '0' };
expect(buildHermeticEnv(base, {}).DISABLE_AUTOUPDATER, `EVALS_HERMETIC=${EVALS_HERMETIC}`).toBe('1');
expect(buildHermeticEnv(base, {}, { DISABLE_AUTOUPDATER: '0' }).DISABLE_AUTOUPDATER, 'per-test override stays last').toBe('0');
}
expect(read('test/helpers/hermetic-env.ts')).toContain('DISABLE_AUTOUPDATER=1 (pinned in both branches');
});
test('claude runners gate --strict-mcp-config on isHermeticEnabled()', () => {
// Zero MCP servers for hermetic children; EVALS_HERMETIC=0 must restore
// operator MCP along with the operator env (the flag may not be
// unconditional, or the escape hatch lies).
for (const rel of ['test/helpers/session-runner.ts', 'test/helpers/claude-pty-runner.ts']) {
const src = read(rel);
expect(src.includes('--strict-mcp-config')).toBe(true);
const gated =
/if\s*\(\s*isHermeticEnabled\(\)\s*\)\s*(args\.push\(\s*)?['"]--strict-mcp-config['"]/.test(src) ||
/const hermetic = isHermeticEnabled\(\);[\s\S]{0,200}if\s*\(hermetic\)\s*args\.push\(\s*['"]--strict-mcp-config['"]/.test(src);
expect(gated, `${rel}: --strict-mcp-config must be gated on isHermeticEnabled()`).toBe(true);
}
});
test('no test callsite passes the whole operator env as a RUNNER override', () => {
// Overrides merge last by design (per-test GSTACK_HOME etc.) — passing
// process.env itself through that hole defeats the entire scrub. Scoped
// to OUR runner calls: unit tests that spawnSync gstack bin scripts with
// `...process.env` are test-process spawns, not eval children, and are
// legitimately the test's own business.
const RUNNER_CALL =
/\b(runSkillTest|launchClaudePty|runPlanSkillObservation|runPlanSkillCounting|runPlanSkillFloorCheck|runAgentSdkTest|runCodexSkillTest|runGeminiSkillTest)\s*\(/;
const DIRECT_SPAWN = /\b(spawnSync|spawn|execSync|exec|Bun\.spawn|Bun\.spawnSync)\s*\(/;
const testDir = path.join(ROOT, 'test');
const offenders: string[] = [];
const walk = (dir: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { walk(full); continue; }
if (!entry.name.endsWith('.test.ts')) continue;
if (entry.name === 'hermetic-wiring.test.ts') continue;
const lines = fs.readFileSync(full, 'utf-8').split('\n');
for (let i = 0; i < lines.length; i++) {
if (!/env:\s*(\{\s*\.\.\.\s*process\.env|process\.env\b(?!\.))/.test(lines[i])) continue;
// Walk backwards to the nearest enclosing call: runner vs direct spawn.
for (let j = i; j >= Math.max(0, i - 25); j--) {
if (DIRECT_SPAWN.test(lines[j])) break; // test's own spawn — fine
if (RUNNER_CALL.test(lines[j])) {
offenders.push(`${path.relative(ROOT, full)}:${i + 1}`);
break;
}
}
}
}
};
walk(testDir);
expect(
offenders,
'These callsites pass the operator env into an eval child, defeating the hermetic scrub: ' +
offenders.join(', '),
).toEqual([]);
});
test('skill seeding stays under runRoot and reads the live repo tree, never operator ~/.claude', () => {
// hermeticSkillsConfigDir() is a BLESSED non-hermetic edge: it registers
// the LIVE repo tree's skills (the skills are the subject under test).
// What it must never do is hand children the operator's ~/.claude — the
// seeded CLAUDE_CONFIG_DIR lives under the hermetic runRoot, while its
// registered documents link directly to the live checkout under test.
const configDir = hermeticSkillsConfigDir();
const { runRoot } = getHermeticDirs();
const operatorClaude = path.join(os.homedir(), '.claude') + path.sep;
expect(configDir.startsWith(runRoot + path.sep)).toBe(true);
expect(configDir.startsWith(operatorClaude)).toBe(false);
const skillsDir = path.join(configDir, 'skills');
const repoRootReal = fs.realpathSync(ROOT) + path.sep;
for (const entry of fs.readdirSync(skillsDir)) {
if (entry === 'gstack') {
const verifyRuntime = (directory: string) => {
expect(fs.lstatSync(directory).isDirectory()).toBe(true);
for (const name of fs.readdirSync(directory)) {
const file = path.join(directory, name);
if (fs.statSync(file).isDirectory()) verifyRuntime(file);
else {
expect(fs.lstatSync(file).isSymbolicLink()).toBe(true);
expect(fs.realpathSync(file).startsWith(repoRootReal), file).toBe(true);
}
}
};
verifyRuntime(path.join(skillsDir, entry));
continue;
}
const link = path.join(skillsDir, entry, 'SKILL.md');
expect(fs.lstatSync(path.dirname(link)).isDirectory()).toBe(true);
expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
const target = fs.readlinkSync(link);
const resolved = fs.realpathSync(link);
expect(resolved.startsWith(operatorClaude), `${entry}: symlink escapes to ${target}`).toBe(false);
expect(resolved.startsWith(repoRootReal), `${entry}: symlink outside checkout: ${target}`).toBe(true);
}
});
});