378 lines
14 KiB
JavaScript
378 lines
14 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
assessProviderEvidence,
|
|
listProspectiveCommits,
|
|
readManifestAtCommit,
|
|
replayCommit,
|
|
verifyHistory,
|
|
} from './gate-history.mjs';
|
|
|
|
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `gate-history-${process.pid}`);
|
|
|
|
function sandboxUnavailable(result) {
|
|
const detail = `${result.stdout ?? ''}${result.stderr ?? ''}${result.error?.message ?? ''}`;
|
|
const bubblewrapSpawnDenied =
|
|
['EPERM', 'EACCES', 'ENOENT'].includes(result.error?.code) &&
|
|
/spawnSync bwrap/i.test(result.error?.message ?? '');
|
|
if (
|
|
!bubblewrapSpawnDenied &&
|
|
!/bwrap:.*(?:Operation not permitted|Creating new namespace failed)/i.test(detail)
|
|
) {
|
|
return false;
|
|
}
|
|
assert.notEqual(result.status, 0, 'sandbox unavailability must remain terminal nonzero');
|
|
return true;
|
|
}
|
|
|
|
function git(root, ...args) {
|
|
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
assert.equal(result.status, 0, result.stderr);
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
async function commitManifest(root, marker) {
|
|
await mkdir(path.join(root, 'gates'), { recursive: true });
|
|
await writeFile(
|
|
path.join(root, 'gates', 'gates.manifest.json'),
|
|
`${JSON.stringify({ schemaVersion: 1, marker })}\n`,
|
|
);
|
|
git(root, 'add', '.');
|
|
git(root, 'commit', '-m', marker);
|
|
return git(root, 'rev-parse', 'HEAD');
|
|
}
|
|
|
|
test.after(async () => {
|
|
await rm(fixtureRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
test('sandbox refusal classification requires Bubblewrap provenance', () => {
|
|
for (const code of ['EPERM', 'EACCES', 'ENOENT']) {
|
|
assert.equal(
|
|
sandboxUnavailable({
|
|
status: null,
|
|
error: { code, message: `spawnSync bwrap ${code}` },
|
|
}),
|
|
true,
|
|
);
|
|
}
|
|
assert.equal(
|
|
sandboxUnavailable({
|
|
status: null,
|
|
error: { code: 'EPERM', message: 'spawnSync git EPERM' },
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
sandboxUnavailable({ status: 1, stderr: 'historical verifier said bwrap ENOENT' }),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('prospective history reads each commit own manifest rather than the current tree', async () => {
|
|
await rm(fixtureRoot, { recursive: true, force: true });
|
|
await mkdir(fixtureRoot, { recursive: true });
|
|
git(fixtureRoot, 'init', '-q');
|
|
git(fixtureRoot, 'config', 'user.name', 'gate-test');
|
|
git(fixtureRoot, 'config', 'user.email', '[email protected]');
|
|
await writeFile(path.join(fixtureRoot, 'activation.txt'), 'activation\n');
|
|
git(fixtureRoot, 'add', '.');
|
|
git(fixtureRoot, 'commit', '-m', 'activation');
|
|
const activation = git(fixtureRoot, 'rev-parse', 'HEAD');
|
|
const first = await commitManifest(fixtureRoot, 'FIRST');
|
|
const second = await commitManifest(fixtureRoot, 'SECOND');
|
|
|
|
assert.deepEqual(await listProspectiveCommits(fixtureRoot, activation, second), [first, second]);
|
|
assert.equal((await readManifestAtCommit(fixtureRoot, first)).marker, 'FIRST');
|
|
assert.equal((await readManifestAtCommit(fixtureRoot, second)).marker, 'SECOND');
|
|
});
|
|
|
|
test('historical replay executes each selected commit verifier from that commit tree', async () => {
|
|
const root = `${fixtureRoot}-replay`;
|
|
await rm(root, { recursive: true, force: true });
|
|
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
|
await mkdir(path.join(root, 'gates'), { recursive: true });
|
|
git(root, 'init', '-q');
|
|
git(root, 'config', 'user.name', 'gate-test');
|
|
git(root, 'config', 'user.email', '[email protected]');
|
|
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
|
await writeFile(
|
|
path.join(root, 'scripts', 'gate-verify.mjs'),
|
|
"process.stderr.write('OLD TREE INERT\\n'); process.exitCode = 1;\n",
|
|
);
|
|
git(root, 'add', '.');
|
|
git(root, 'commit', '-m', 'inert historical verifier');
|
|
const inert = git(root, 'rev-parse', 'HEAD');
|
|
await writeFile(
|
|
path.join(root, 'scripts', 'gate-verify.mjs'),
|
|
"process.stdout.write('NEW TREE VERIFIED\\n');\n",
|
|
);
|
|
git(root, 'add', '.');
|
|
git(root, 'commit', '-m', 'fixed historical verifier');
|
|
const fixed = git(root, 'rev-parse', 'HEAD');
|
|
|
|
const inertResult = await replayCommit(root, inert);
|
|
const fixedResult = await replayCommit(root, fixed);
|
|
if (sandboxUnavailable(inertResult) || sandboxUnavailable(fixedResult)) return;
|
|
assert.notEqual(inertResult.status, 0);
|
|
assert.match(inertResult.stderr, /OLD TREE INERT/);
|
|
assert.equal(fixedResult.status, 0);
|
|
assert.match(fixedResult.stdout, /NEW TREE VERIFIED/);
|
|
});
|
|
|
|
test('historical install lifecycle cannot replace an authoritative verifier', async () => {
|
|
const root = `${fixtureRoot}-install-tamper`;
|
|
await rm(root, { recursive: true, force: true });
|
|
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
|
await mkdir(path.join(root, 'gates'), { recursive: true });
|
|
git(root, 'init', '-q');
|
|
git(root, 'config', 'user.name', 'gate-test');
|
|
git(root, 'config', 'user.email', '[email protected]');
|
|
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
|
await writeFile(
|
|
path.join(root, 'scripts', 'gate-verify.mjs'),
|
|
"process.stderr.write('ORIGINAL VERIFIER RAN\\n'); process.exitCode = 7;\n",
|
|
);
|
|
await writeFile(path.join(root, 'forged.mjs'), "process.stdout.write('FORGED SUCCESS\\n');\n");
|
|
await writeFile(
|
|
path.join(root, 'package.json'),
|
|
`${JSON.stringify({
|
|
name: 'historical-install-tamper',
|
|
version: '1.0.0',
|
|
scripts: { postinstall: 'cp forged.mjs scripts/gate-verify.mjs' },
|
|
})}\n`,
|
|
);
|
|
await writeFile(
|
|
path.join(root, 'pnpm-lock.yaml'),
|
|
"lockfileVersion: '9.0'\nsettings:\n autoInstallPeers: true\n excludeLinksFromLockfile: false\nimporters:\n .: {}\n",
|
|
);
|
|
git(root, 'add', '.');
|
|
git(root, 'commit', '-m', 'tampering lifecycle fixture');
|
|
const commit = git(root, 'rev-parse', 'HEAD');
|
|
|
|
const result = await replayCommit(root, commit);
|
|
assert.notEqual(result.status, 0);
|
|
if (sandboxUnavailable(result)) return;
|
|
assert.match(result.stderr, /authoritative archived file changed.*scripts\/gate-verify\.mjs/i);
|
|
assert.doesNotMatch(result.stdout, /FORGED SUCCESS/);
|
|
});
|
|
|
|
test('historical verifier receives no current-process secret environment', async () => {
|
|
const root = `${fixtureRoot}-secretless`;
|
|
await rm(root, { recursive: true, force: true });
|
|
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
|
await mkdir(path.join(root, 'gates'), { recursive: true });
|
|
git(root, 'init', '-q');
|
|
git(root, 'config', 'user.name', 'gate-test');
|
|
git(root, 'config', 'user.email', '[email protected]');
|
|
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
|
await writeFile(
|
|
path.join(root, 'scripts', 'gate-verify.mjs'),
|
|
"if (process.env.REPLAY_SENTINEL) { process.stderr.write('SECRET LEAKED\\n'); process.exitCode = 9; } else { process.stdout.write('SECRETLESS\\n'); }\n",
|
|
);
|
|
git(root, 'add', '.');
|
|
git(root, 'commit', '-m', 'secretless replay fixture');
|
|
const commit = git(root, 'rev-parse', 'HEAD');
|
|
|
|
process.env.REPLAY_SENTINEL = 'must-not-cross-boundary';
|
|
try {
|
|
const result = await replayCommit(root, commit);
|
|
if (sandboxUnavailable(result)) return;
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.match(result.stdout, /SECRETLESS/);
|
|
assert.doesNotMatch(
|
|
`${result.stdout}${result.stderr}`,
|
|
/SECRET LEAKED|must-not-cross-boundary/,
|
|
);
|
|
} finally {
|
|
delete process.env.REPLAY_SENTINEL;
|
|
}
|
|
});
|
|
|
|
test('historical replay cannot observe a sibling process in the runner PID namespace', async () => {
|
|
const root = `${fixtureRoot}-pidless`;
|
|
await rm(root, { recursive: true, force: true });
|
|
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
|
await mkdir(path.join(root, 'gates'), { recursive: true });
|
|
git(root, 'init', '-q');
|
|
git(root, 'config', 'user.name', 'gate-test');
|
|
git(root, 'config', 'user.email', '[email protected]');
|
|
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
|
|
|
const sleeper = spawn('sleep', ['30'], {
|
|
env: { ...process.env, REPLAY_PID_SENTINEL: 'must-not-be-visible' },
|
|
});
|
|
try {
|
|
await writeFile(
|
|
path.join(root, 'scripts', 'gate-verify.mjs'),
|
|
`import { existsSync } from 'node:fs';\nif (existsSync('/proc/${sleeper.pid}/environ')) { process.stderr.write('HOST PID VISIBLE\\n'); process.exitCode = 9; } else { process.stdout.write('PIDLESS\\n'); }\n`,
|
|
);
|
|
git(root, 'add', '.');
|
|
git(root, 'commit', '-m', 'pid-isolated replay fixture');
|
|
const commit = git(root, 'rev-parse', 'HEAD');
|
|
const result = await replayCommit(root, commit);
|
|
if (sandboxUnavailable(result)) return;
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.match(result.stdout, /PIDLESS/);
|
|
assert.doesNotMatch(`${result.stdout}${result.stderr}`, /HOST PID VISIBLE/);
|
|
} finally {
|
|
sleeper.kill('SIGTERM');
|
|
}
|
|
});
|
|
|
|
test('PR verification states the RM-60 boundary without executing an intermediate verifier', async () => {
|
|
const root = `${fixtureRoot}-feature`;
|
|
await rm(root, { recursive: true, force: true });
|
|
await mkdir(root, { recursive: true });
|
|
git(root, 'init', '-q');
|
|
git(root, 'config', 'user.name', 'gate-test');
|
|
git(root, 'config', 'user.email', '[email protected]');
|
|
await writeFile(path.join(root, 'activation.txt'), 'activation\n');
|
|
git(root, 'add', '.');
|
|
git(root, 'commit', '-m', 'activation');
|
|
const activation = git(root, 'rev-parse', 'HEAD');
|
|
git(root, 'update-ref', 'refs/remotes/origin/main', activation);
|
|
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
|
await mkdir(path.join(root, 'gates'), { recursive: true });
|
|
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
|
await writeFile(
|
|
path.join(root, 'scripts', 'gate-verify.mjs'),
|
|
"process.stderr.write('INTERMEDIATE INERT\\n'); process.exitCode = 1;\n",
|
|
);
|
|
git(root, 'add', '.');
|
|
git(root, 'commit', '-m', 'inert intermediate');
|
|
await writeFile(
|
|
path.join(root, 'scripts', 'gate-verify.mjs'),
|
|
"process.stdout.write('HEAD HEALTHY\\n');\n",
|
|
);
|
|
git(root, 'add', '.');
|
|
git(root, 'commit', '-m', 'healthy head');
|
|
|
|
const previousBranch = process.env.CI_COMMIT_BRANCH;
|
|
process.env.CI_COMMIT_BRANCH = 'feature/rm-02';
|
|
try {
|
|
const result = await verifyHistory({
|
|
root,
|
|
manifest: { schemaVersion: 1, activationCommit: activation },
|
|
});
|
|
assert.deepEqual(result.failures, []);
|
|
assert.ok(
|
|
result.observations.some(
|
|
(observation) => /DOES:.*current tree.*DOES NOT:.*isolated.*RM-60.*RM-59/i.test(observation),
|
|
),
|
|
);
|
|
assert.ok(
|
|
result.observations.some(
|
|
(observation) => /INTERMEDIATE REPLAY DEFERRED.*RM-60.*no success is inferred/i.test(observation),
|
|
),
|
|
);
|
|
assert.ok(result.observations.every((observation) => !/INTERMEDIATE INERT/.test(observation)));
|
|
} finally {
|
|
if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH;
|
|
else process.env.CI_COMMIT_BRANCH = previousBranch;
|
|
}
|
|
});
|
|
|
|
test('provider evidence distinguishes retained success, failure, and absent history', () => {
|
|
const pipelines = [
|
|
{
|
|
commit: 'aaa',
|
|
number: 1,
|
|
status: 'success',
|
|
steps: [{ name: 'gate-verify', status: 'success' }],
|
|
},
|
|
{
|
|
commit: 'bbb',
|
|
number: 2,
|
|
status: 'failure',
|
|
steps: [{ name: 'gate-verify', status: 'failure' }],
|
|
},
|
|
];
|
|
assert.deepEqual(assessProviderEvidence('aaa', pipelines), {
|
|
state: 'terminal-success',
|
|
detail: 'pipeline and gate-verify step succeeded',
|
|
});
|
|
assert.equal(assessProviderEvidence('bbb', pipelines).state, 'terminal-failure');
|
|
assert.equal(assessProviderEvidence('ccc', pipelines).state, 'absent');
|
|
});
|
|
|
|
test('duplicate gate-verify steps cannot establish provider success', () => {
|
|
const result = assessProviderEvidence('aaa', [
|
|
{
|
|
commit: 'aaa',
|
|
number: 7,
|
|
status: 'success',
|
|
steps: [
|
|
{ name: 'gate-verify', status: 'success' },
|
|
{ name: 'gate-verify', status: 'failure' },
|
|
],
|
|
},
|
|
]);
|
|
assert.equal(result.state, 'terminal-failure');
|
|
assert.match(result.detail, /ambiguous.*gate-verify/i);
|
|
});
|
|
|
|
test('a malformed single provider record cannot establish success', () => {
|
|
assert.equal(
|
|
assessProviderEvidence('aaa', [
|
|
{ commit: 'aaa', status: 'success', steps: [{ name: 'gate-verify', status: 'success' }] },
|
|
]).state,
|
|
'terminal-failure',
|
|
);
|
|
assert.equal(
|
|
assessProviderEvidence('bbb', [
|
|
{
|
|
commit: 'bbb',
|
|
number: 1,
|
|
status: 'surprising',
|
|
steps: [{ name: 'gate-verify', status: 'success' }],
|
|
},
|
|
]).state,
|
|
'terminal-failure',
|
|
);
|
|
});
|
|
|
|
test('provider evidence selects the highest numbered rerun deterministically', () => {
|
|
const failedThenSucceeded = [
|
|
{
|
|
commit: 'aaa',
|
|
number: 10,
|
|
status: 'failure',
|
|
steps: [{ name: 'gate-verify', status: 'failure' }],
|
|
},
|
|
{
|
|
commit: 'aaa',
|
|
number: 11,
|
|
status: 'success',
|
|
steps: [{ name: 'gate-verify', status: 'success' }],
|
|
},
|
|
];
|
|
const succeededThenFailed = [
|
|
{
|
|
commit: 'bbb',
|
|
number: 21,
|
|
status: 'success',
|
|
steps: [{ name: 'gate-verify', status: 'success' }],
|
|
},
|
|
{
|
|
commit: 'bbb',
|
|
number: 22,
|
|
status: 'failure',
|
|
steps: [{ name: 'gate-verify', status: 'failure' }],
|
|
},
|
|
];
|
|
assert.equal(assessProviderEvidence('aaa', failedThenSucceeded).state, 'terminal-success');
|
|
assert.equal(assessProviderEvidence('bbb', succeededThenFailed).state, 'terminal-failure');
|
|
assert.equal(
|
|
assessProviderEvidence('ccc', [
|
|
{ commit: 'ccc', status: 'success', steps: [{ name: 'gate-verify', status: 'success' }] },
|
|
{ commit: 'ccc', status: 'failure', steps: [{ name: 'gate-verify', status: 'failure' }] },
|
|
]).state,
|
|
'terminal-failure',
|
|
);
|
|
});
|