@clear-capabilities/agentic-security-scanner 0.161.0 → 0.162.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/bin/agentic-security.js +11 -2
- package/dist/4113.index.js +59 -8
- package/dist/5613.index.js +500 -0
- package/dist/6239.index.js +469 -0
- package/dist/7838.index.js +38 -2
- package/dist/8798.index.js +1 -1
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +36 -22
- package/dist/{1905.index.js → 9999.index.js} +265 -8
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +9 -4
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +28 -0
- package/src/discovery/memory.js +7 -5
- package/src/fix/apply-fix-service.js +33 -3
- package/src/mcp/CLAUDE.md +9 -1
- package/src/mcp/tools.js +17 -4
- package/src/posture/CLAUDE.md +174 -0
- package/src/posture/assurance/baseline-inventory.js +218 -0
- package/src/posture/assurance/baseline.js +322 -0
- package/src/posture/assurance/bounded-io.js +145 -0
- package/src/posture/assurance/config.js +299 -0
- package/src/posture/assurance/contracts.js +315 -0
- package/src/posture/assurance/identity.js +62 -0
- package/src/posture/assurance/migrations.js +259 -0
- package/src/posture/assurance/schema-kit.js +111 -0
- package/src/posture/assurance/verification-record.js +258 -0
- package/src/posture/attestation.js +5 -0
- package/src/posture/autopilot.js +28 -1
- package/src/posture/evidence-bundle.js +93 -7
- package/src/posture/execution-proof.js +11 -1
- package/src/posture/fix-verify-loop.js +20 -6
- package/src/posture/fix-verify.js +37 -2
- package/src/posture/oracles/adapters.js +360 -0
- package/src/posture/oracles/conformance.js +275 -0
- package/src/posture/oracles/oracle.js +433 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/replay/replay.js +249 -0
- package/src/posture/verification/advisory-state.js +71 -0
- package/src/posture/verification/emit.js +257 -0
- package/src/posture/verification/hypothesis-promotion.js +211 -0
- package/src/posture/verification/patch-negative.js +214 -0
- package/src/posture/verification/patch-promotion.js +105 -0
- package/src/posture/verification/projection.js +183 -0
- package/src/posture/verification/repair-records.js +124 -0
- package/src/posture/verifier.js +16 -0
- package/src/report/index.js +49 -0
- package/src/sandbox/CLAUDE.md +41 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +68 -29
- package/src/sandbox/control-probes.js +215 -0
- package/src/sandbox/supervise.js +201 -0
- package/src/sandbox/trust-boundary.js +113 -0
- package/src/sandbox/trust-domains.js +171 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// The oracle conformance suite (X-208).
|
|
2
|
+
//
|
|
3
|
+
// One reusable contract that every registered adapter must satisfy, used three ways: by the test suite (all registered
|
|
4
|
+
// adapters plus deliberately bad fakes), by the release gate (`scripts/verification-conformance-check.mjs`), and by a
|
|
5
|
+
// contributor adding an oracle (docs/guides/verification-oracle-conformance.md). It checks an adapter, it never grants one
|
|
6
|
+
// anything: it imports no registration function and adds no execution permission.
|
|
7
|
+
//
|
|
8
|
+
// Two layers, because they have different costs and different host requirements:
|
|
9
|
+
//
|
|
10
|
+
// STATIC (`checkAdapterContract`, `checkFixturePins`): no execution, any host. An adapter must declare
|
|
11
|
+
// - CLASS SCOPE: a known class, a stated status and note per platform (Linux never `supported`: no Linux enforcement is
|
|
12
|
+
// verified by this build), stated limitations, and an existing fixture directory;
|
|
13
|
+
// - RESOURCE BUDGETS: all five budgets finite, positive and under the hard ceilings below;
|
|
14
|
+
// - NEGATIVE CONTROLS: at least one, each expecting `refuted`, and a negative fixture on disk that is a real file;
|
|
15
|
+
// - AUTHORITATIVE EVIDENCE: verifier-authored `harnessSource`, `prepare` and `interpret` (the verdict is formed by the
|
|
16
|
+
// verifier from what the run left behind, never by the target), and a `logicDigest` that matches its own logic;
|
|
17
|
+
// - PINNED FIXTURES: the positive, negative and inconclusive fixtures and the scenario hash to the values committed in
|
|
18
|
+
// `test/fixtures/oracles/conformance-pins.json`, together with the adapter's logic digest. A changed fixture or changed
|
|
19
|
+
// adapter logic fails until someone re-pins it on purpose.
|
|
20
|
+
//
|
|
21
|
+
// EXECUTION (`runAdapterConformance`): the adapter is run through `runOracle` and so through the trust boundary only.
|
|
22
|
+
// - STATE MAPPING: positive confirms, negative refutes with proven preconditions, inconclusive stays open;
|
|
23
|
+
// - RECEIPTS: the positive run's receipt is one `runOracle` issued (a copy is not), frozen, bound to the record id and the
|
|
24
|
+
// logic digest, with trusted-runtime-proof evidence from the trusted runner and sanitized environment metadata;
|
|
25
|
+
// - TAMPER: a target that rewrites the adapter harness yields `error` and no receipt; a target printing verdict-looking text
|
|
26
|
+
// cannot move a refutation; an unavailable prerequisite is `unsupported`, executes nothing and issues no receipt;
|
|
27
|
+
// - CANCELLATION: an aborted run is `inconclusive`, has no receipt and leaves no harness process behind;
|
|
28
|
+
// - REPLAY: running the positive request again reproduces the outcome and the record id.
|
|
29
|
+
// Where the boundary cannot run, execution is reported `not-run` with the reason; it is never reported as passed.
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { runOracle, validateOracleSpec, oracleLogicDigest, isIssuedReceipt, ORACLE_CLASSES, HARNESS_FILE, FEATURE_ID } from './oracle.js';
|
|
33
|
+
import { SCENARIO_CLASSES } from './scenario-classes.js';
|
|
34
|
+
import { digestOf } from '../assurance/identity.js';
|
|
35
|
+
import { validateVerificationRecord } from '../assurance/verification-record.js';
|
|
36
|
+
import { resolveAssuranceConfig } from '../assurance/config.js';
|
|
37
|
+
import { isPlainObject } from '../assurance/schema-kit.js';
|
|
38
|
+
|
|
39
|
+
const CONFORMANCE_SCHEMA = 'agentic-security/oracle-conformance';
|
|
40
|
+
export const PINS_FILE = 'test/fixtures/oracles/conformance-pins.json';
|
|
41
|
+
|
|
42
|
+
/** Hard ceilings the gate enforces on an adapter's declared budgets. A bigger budget is a bigger default execution permission. */
|
|
43
|
+
export const BUDGET_CEILINGS = Object.freeze({ timeoutMs: 60_000, graceMs: 10_000, maxOutputBytes: 1_048_576, maxFileBytes: 1_048_576, maxFiles: 64 });
|
|
44
|
+
const FIXTURE_KINDS = ['positive', 'negative', 'inconclusive'];
|
|
45
|
+
const CONFORMANCE_COMMIT = 'd'.repeat(40);
|
|
46
|
+
|
|
47
|
+
const entry = (check, ok, detail) => ({ check, ok: !!ok, detail });
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------- static
|
|
50
|
+
|
|
51
|
+
/** Static contract problems for one adapter; `ok` only when every check passes. Never throws. */
|
|
52
|
+
export function checkAdapterContract(adapter, { scannerRoot } = {}) {
|
|
53
|
+
const checks = [];
|
|
54
|
+
try {
|
|
55
|
+
if (!isPlainObject(adapter)) return { id: null, ok: false, checks: [entry('spec', false, 'adapter must be an object')] };
|
|
56
|
+
const spec = { ...adapter }; delete spec.logicDigest;
|
|
57
|
+
const specProblems = validateOracleSpec(spec);
|
|
58
|
+
checks.push(entry('spec', specProblems.length === 0, specProblems.join('; ') || 'the adapter satisfies the adapter contract'));
|
|
59
|
+
|
|
60
|
+
// class scope
|
|
61
|
+
const scopeProblems = [];
|
|
62
|
+
if (!ORACLE_CLASSES.includes(adapter.class)) scopeProblems.push(`class '${adapter.class}' is not a known oracle class`);
|
|
63
|
+
if (!isPlainObject(adapter.platforms) || !['darwin', 'linux', 'win32'].every((p) => isPlainObject(adapter.platforms[p]) && adapter.platforms[p].status && adapter.platforms[p].note)) scopeProblems.push('every platform needs a stated status and note');
|
|
64
|
+
if (adapter.platforms?.linux?.status === 'supported') scopeProblems.push('Linux cannot be claimed supported');
|
|
65
|
+
if (!Array.isArray(adapter.limitations) || adapter.limitations.length === 0) scopeProblems.push('limitations must be stated');
|
|
66
|
+
if (!Array.isArray(adapter.prerequisites) || adapter.prerequisites.length === 0) scopeProblems.push('prerequisites must be declared');
|
|
67
|
+
const dir = scannerRoot && adapter.fixtures?.dir ? path.join(scannerRoot, adapter.fixtures.dir) : null;
|
|
68
|
+
if (scannerRoot && !(dir && fs.existsSync(dir))) scopeProblems.push('the fixture directory does not exist');
|
|
69
|
+
checks.push(entry('class-scope', scopeProblems.length === 0, scopeProblems.join('; ') || `class '${adapter.class}' with stated platforms, prerequisites and limitations`));
|
|
70
|
+
|
|
71
|
+
// resource budgets
|
|
72
|
+
const budgetProblems = [];
|
|
73
|
+
if (!isPlainObject(adapter.budgets)) budgetProblems.push('budgets are required');
|
|
74
|
+
else for (const [k, ceiling] of Object.entries(BUDGET_CEILINGS)) {
|
|
75
|
+
const v = adapter.budgets[k];
|
|
76
|
+
if (!Number.isInteger(v) || v <= 0) budgetProblems.push(`budgets.${k} must be a positive integer`);
|
|
77
|
+
else if (v > ceiling) budgetProblems.push(`budgets.${k} (${v}) exceeds the ceiling ${ceiling}`);
|
|
78
|
+
}
|
|
79
|
+
checks.push(entry('resource-budgets', budgetProblems.length === 0, budgetProblems.join('; ') || 'all five budgets are declared and under their ceilings'));
|
|
80
|
+
|
|
81
|
+
// negative controls
|
|
82
|
+
const negProblems = [];
|
|
83
|
+
if (!Array.isArray(adapter.negativeControls) || adapter.negativeControls.length === 0) negProblems.push('at least one negative control is required');
|
|
84
|
+
else if (!adapter.negativeControls.every((n) => isPlainObject(n) && n.id && n.description && n.expectedOutcome === 'refuted')) negProblems.push('each negative control needs an id, a description and expectedOutcome refuted');
|
|
85
|
+
if (scannerRoot && dir && adapter.fixtures?.negative) {
|
|
86
|
+
const f = path.join(dir, adapter.fixtures.negative, 'target.mjs');
|
|
87
|
+
if (!(fs.existsSync(f) && fs.statSync(f).isFile())) negProblems.push('the negative fixture is not a file on disk');
|
|
88
|
+
}
|
|
89
|
+
checks.push(entry('negative-controls', negProblems.length === 0, negProblems.join('; ') || 'a negative control is declared and its fixture exists'));
|
|
90
|
+
|
|
91
|
+
// authoritative evidence receipts
|
|
92
|
+
const evProblems = [];
|
|
93
|
+
if (typeof adapter.harnessSource !== 'string' || !adapter.harnessSource.includes('__INPUT__')) evProblems.push('a verifier-authored harness is required');
|
|
94
|
+
for (const fn of ['prepare', 'interpret']) if (typeof adapter[fn] !== 'function') evProblems.push(`${fn} (verifier-side) is required`);
|
|
95
|
+
if (typeof adapter.logicDigest !== 'string') evProblems.push('no logic digest');
|
|
96
|
+
else if (!specProblems.length && adapter.logicDigest !== oracleLogicDigest(spec)) evProblems.push('the logic digest does not match the adapter logic');
|
|
97
|
+
checks.push(entry('evidence-receipts', evProblems.length === 0, evProblems.join('; ') || 'the verdict is formed verifier-side from observed output and bound by a logic digest'));
|
|
98
|
+
} catch (e) {
|
|
99
|
+
checks.push(entry('contract', false, `the contract check failed: ${String(e?.message || e).slice(0, 160)}`));
|
|
100
|
+
}
|
|
101
|
+
return { id: adapter?.id ?? null, ok: checks.every((c) => c.ok), checks };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readFixture(scannerRoot, adapter, kind) {
|
|
105
|
+
const dir = path.join(scannerRoot, adapter.fixtures.dir);
|
|
106
|
+
return {
|
|
107
|
+
target: fs.readFileSync(path.join(dir, adapter.fixtures[kind], 'target.mjs'), 'utf8'),
|
|
108
|
+
scenario: fs.readFileSync(path.join(dir, 'scenario.json'), 'utf8'),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The pin record for one adapter, from its fixtures and logic digest. Used to write and to check pins. */
|
|
113
|
+
export function computePins(adapter, scannerRoot) {
|
|
114
|
+
const pins = { logicDigest: adapter.logicDigest, scenario: null, fixtures: {} };
|
|
115
|
+
for (const kind of FIXTURE_KINDS) {
|
|
116
|
+
const f = readFixture(scannerRoot, adapter, kind);
|
|
117
|
+
pins.scenario = digestOf(f.scenario);
|
|
118
|
+
pins.fixtures[kind] = digestOf(f.target);
|
|
119
|
+
}
|
|
120
|
+
return pins;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function loadPins(scannerRoot) {
|
|
124
|
+
try { return JSON.parse(fs.readFileSync(path.join(scannerRoot, PINS_FILE), 'utf8')); } catch { return null; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The fixture pins for one adapter against the committed pin file. A missing pin is a failure, not a skip. */
|
|
128
|
+
export function checkFixturePins(adapter, scannerRoot, pins = loadPins(scannerRoot)) {
|
|
129
|
+
const id = adapter?.id;
|
|
130
|
+
const want = pins && isPlainObject(pins.adapters) ? pins.adapters[id] : null;
|
|
131
|
+
if (!want) return entry('pinned-fixtures', false, `no pinned fixtures for '${id}' in ${PINS_FILE}; pin them deliberately (node scripts/verification-conformance-check.mjs --update-pins)`);
|
|
132
|
+
let have;
|
|
133
|
+
try { have = computePins(adapter, scannerRoot); } catch (e) { return entry('pinned-fixtures', false, `the fixtures could not be read: ${String(e?.message || e).slice(0, 120)}`); }
|
|
134
|
+
const moved = [];
|
|
135
|
+
if (want.logicDigest !== have.logicDigest) moved.push('adapter logic digest');
|
|
136
|
+
if (want.scenario !== have.scenario) moved.push('scenario');
|
|
137
|
+
for (const kind of FIXTURE_KINDS) if (want.fixtures?.[kind] !== have.fixtures[kind]) moved.push(`${kind} fixture`);
|
|
138
|
+
return entry('pinned-fixtures', moved.length === 0, moved.length ? `changed since pinned: ${moved.join(', ')}` : 'adapter logic and the three fixtures match their pins');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------- execution
|
|
142
|
+
|
|
143
|
+
const enabledConfig = () => resolveAssuranceConfig({ env: {}, overrides: { features: { [FEATURE_ID]: true } } });
|
|
144
|
+
|
|
145
|
+
function requestFor(adapter, scannerRoot, kind, over = {}) {
|
|
146
|
+
const f = readFixture(scannerRoot, adapter, kind);
|
|
147
|
+
return {
|
|
148
|
+
oracleId: adapter.id, hypothesisId: `conformance-${adapter.id}`, commit: CONFORMANCE_COMMIT, entry: 'target.mjs',
|
|
149
|
+
files: { 'target.mjs': f.target }, inputs: JSON.parse(f.scenario),
|
|
150
|
+
// the parser oracle's positive case is ended by the supervisor deadline; keep it short
|
|
151
|
+
...(adapter.class === 'parser-resource' ? { budgets: { timeoutMs: Math.min(2500, adapter.budgets.timeoutMs) } } : {}), ...over,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Run the execution contract for one adapter.
|
|
157
|
+
* @param {object} adapter
|
|
158
|
+
* @param {object} o
|
|
159
|
+
* @param {string} o.scannerRoot
|
|
160
|
+
* @param {function} [o.run] the runner (default `runOracle`); a stand-in lets a test model a misbehaving runner
|
|
161
|
+
* @param {object} [o.runOptions] extra options for the runner (`probeEnv`, `deps`)
|
|
162
|
+
* @param {function} [o.harnessProcesses] returns the number of leftover harness processes (cancellation check)
|
|
163
|
+
*/
|
|
164
|
+
export async function runAdapterConformance(adapter, { scannerRoot, run = runOracle, runOptions = {}, harnessProcesses = null } = {}) {
|
|
165
|
+
const checks = [];
|
|
166
|
+
const registry = { getOracle: (id) => (id === adapter.id ? adapter : null) };
|
|
167
|
+
const go = (req, extra = {}) => run(req, { config: enabledConfig(), registry, ...runOptions, ...extra });
|
|
168
|
+
try {
|
|
169
|
+
const pos = await go(requestFor(adapter, scannerRoot, 'positive'));
|
|
170
|
+
if (pos.status === 'unsupported' || pos.status === 'blocked' || pos.status === 'disabled') {
|
|
171
|
+
return { id: adapter.id, ok: false, executed: false, checks: [entry('execution-available', false, `the oracle could not run on this host: ${String(pos.reason).slice(0, 200)}`)] };
|
|
172
|
+
}
|
|
173
|
+
// state mapping
|
|
174
|
+
checks.push(entry('state-positive', pos.outcome === 'confirmed' && pos.record?.confirmationLevel === 'runtime-confirmed', `positive fixture settled '${pos.outcome}'`));
|
|
175
|
+
const neg = await go(requestFor(adapter, scannerRoot, 'negative'));
|
|
176
|
+
checks.push(entry('state-negative', neg.outcome === 'refuted' && neg.record?.preconditions?.valid === true, `negative fixture settled '${neg.outcome}' (preconditions ${neg.record?.preconditions?.valid})`));
|
|
177
|
+
const inc = await go(requestFor(adapter, scannerRoot, 'inconclusive'));
|
|
178
|
+
checks.push(entry('state-inconclusive', inc.outcome === 'inconclusive' && inc.record?.preconditions?.valid === false && inc.record?.confirmationLevel === 'none', `inconclusive fixture settled '${inc.outcome}'`));
|
|
179
|
+
checks.push(entry('records-valid', [pos, neg, inc].every((r) => r.record && validateVerificationRecord(r.record).ok), 'every record validates against the version-1 schema'));
|
|
180
|
+
|
|
181
|
+
// authoritative evidence receipt
|
|
182
|
+
const r = pos.receipt;
|
|
183
|
+
const ev = pos.record?.evidence?.find((e) => e.kind === 'trusted-runtime-proof');
|
|
184
|
+
const receiptProblems = [];
|
|
185
|
+
if (!isIssuedReceipt(r)) receiptProblems.push('no receipt issued by the runner');
|
|
186
|
+
else {
|
|
187
|
+
if (isIssuedReceipt({ ...r })) receiptProblems.push('a copy of the receipt is accepted as issued');
|
|
188
|
+
if (!Object.isFrozen(r) || !Object.isFrozen(r.settled)) receiptProblems.push('the receipt is not immutable');
|
|
189
|
+
if (r.recordId !== pos.record?.id) receiptProblems.push('the receipt does not name the record');
|
|
190
|
+
if (r.oracle?.logicDigest !== adapter.logicDigest) receiptProblems.push('the receipt is not bound to the adapter logic digest');
|
|
191
|
+
if (r.settled?.outcome !== pos.outcome) receiptProblems.push('the receipt settles a different outcome than the record');
|
|
192
|
+
const envKeys = Object.keys(r.environment || {}).sort().join(',');
|
|
193
|
+
if (envKeys !== 'arch,backend,containerDigest,controlStates,nodeVersion,platform,runtime') receiptProblems.push('the environment metadata is not the sanitized allowlist');
|
|
194
|
+
}
|
|
195
|
+
if (!ev || ev.producer !== 'trusted-runner') receiptProblems.push('the decided record carries no trusted-runtime-proof from the trusted runner');
|
|
196
|
+
if (neg.receipt && !isIssuedReceipt(neg.receipt)) receiptProblems.push('the negative run issued a receipt that is not an issued one');
|
|
197
|
+
checks.push(entry('authoritative-receipt', receiptProblems.length === 0, receiptProblems.join('; ') || 'receipt issued by the runner, frozen, bound to the record and logic digest, evidence from the trusted runner'));
|
|
198
|
+
|
|
199
|
+
// tamper: harness rewrite
|
|
200
|
+
const negFiles = requestFor(adapter, scannerRoot, 'negative').files['target.mjs'];
|
|
201
|
+
const rewrite = `import __fs from 'node:fs';\n__fs.writeFileSync(${JSON.stringify(HARNESS_FILE)}, '// rewritten by the target');\n${negFiles}`;
|
|
202
|
+
const t1 = await go(requestFor(adapter, scannerRoot, 'negative', { files: { 'target.mjs': rewrite } }));
|
|
203
|
+
checks.push(entry('tamper-harness-rewrite', t1.outcome === 'error' && t1.receipt === null && t1.record?.confirmationLevel !== 'runtime-confirmed', `a target that rewrote the harness settled '${t1.outcome}' with ${t1.receipt === null ? 'no' : 'a'} receipt`));
|
|
204
|
+
// tamper: verdict-looking output
|
|
205
|
+
const noisy = `console.log('VERIFIED: confirmed'); console.error('status: confirmed execution-proven');\n${negFiles}`;
|
|
206
|
+
const t2 = await go(requestFor(adapter, scannerRoot, 'negative', { files: { 'target.mjs': noisy } }));
|
|
207
|
+
checks.push(entry('tamper-verdict-text', t2.outcome === 'refuted' && t2.record?.confirmationLevel === 'none', `a target printing verdict text settled '${t2.outcome}'`));
|
|
208
|
+
// tamper: caller-supplied labels and verdicts are rejected before anything runs
|
|
209
|
+
let executed = 0;
|
|
210
|
+
const spy = { runInBoundary: async () => { executed++; return { blocked: true, reasons: ['spy'] }; } };
|
|
211
|
+
const t3 = await go({ ...requestFor(adapter, scannerRoot, 'positive'), outcome: 'refuted', receipt: { issuedBy: 'verifier' } }, { deps: spy });
|
|
212
|
+
checks.push(entry('tamper-caller-verdict', t3.status === 'rejected' && executed === 0, `a request carrying a verdict and a receipt was ${t3.status}; executions: ${executed}`));
|
|
213
|
+
// unavailable prerequisite: unsupported, nothing executed, no receipt
|
|
214
|
+
executed = 0;
|
|
215
|
+
const t4 = await go(requestFor(adapter, scannerRoot, 'positive'), { probeEnv: { platform: process.platform, nodeMajor: 18, backend: 'userspace', hasPosixShell: true }, deps: spy });
|
|
216
|
+
checks.push(entry('unavailable-prerequisite', t4.outcome === 'unsupported' && t4.executed === false && t4.receipt === null && executed === 0, `an unmet prerequisite settled '${t4.outcome}', executed ${executed} time(s)`));
|
|
217
|
+
|
|
218
|
+
// cancellation
|
|
219
|
+
const ac = new AbortController();
|
|
220
|
+
const hang = `setInterval(() => {}, 1000);\nawait new Promise(() => {});\n`;
|
|
221
|
+
setTimeout(() => ac.abort(), 700);
|
|
222
|
+
const c1 = await go(requestFor(adapter, scannerRoot, 'positive', { files: { 'target.mjs': hang }, budgets: { timeoutMs: Math.min(5000, adapter.budgets.timeoutMs) } }), { signal: ac.signal });
|
|
223
|
+
let leftover = 0;
|
|
224
|
+
if (typeof harnessProcesses === 'function') {
|
|
225
|
+
for (let i = 0; i < 50; i++) { leftover = harnessProcesses(); if (leftover === 0) break; await new Promise((res) => setTimeout(res, 100)); }
|
|
226
|
+
}
|
|
227
|
+
checks.push(entry('cancellation', c1.status === 'cancelled' && c1.outcome === 'inconclusive' && c1.receipt === null && leftover === 0, `an aborted run settled '${c1.outcome}' (${c1.status}) with ${c1.receipt === null ? 'no' : 'a'} receipt; leftover harness processes: ${leftover}`));
|
|
228
|
+
|
|
229
|
+
// replay
|
|
230
|
+
const again = await go(requestFor(adapter, scannerRoot, 'positive'));
|
|
231
|
+
checks.push(entry('replay', again.outcome === pos.outcome && again.record?.id === pos.record?.id, `a second run of the pinned positive fixture settled '${again.outcome}' with ${again.record?.id === pos.record?.id ? 'the same' : 'a different'} record id`));
|
|
232
|
+
} catch (e) {
|
|
233
|
+
checks.push(entry('execution', false, `the execution contract failed to run: ${String(e?.message || e).slice(0, 160)}`));
|
|
234
|
+
}
|
|
235
|
+
return { id: adapter.id, ok: checks.every((c) => c.ok), executed: true, checks };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------- whole registry
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The gate over a set of adapters. Static checks and pins always run. Execution runs only when `execute` is true; a host that
|
|
242
|
+
* cannot run the boundary reports `execution: 'not-run'` with the reason, and `requireExecution` turns that into a failure.
|
|
243
|
+
*/
|
|
244
|
+
export async function checkConformance(adapters, { scannerRoot, execute = true, requireExecution = false, pins, run, runOptions, harnessProcesses } = {}) {
|
|
245
|
+
const results = [];
|
|
246
|
+
const ids = new Set();
|
|
247
|
+
for (const a of adapters) {
|
|
248
|
+
const staticResult = checkAdapterContract(a, { scannerRoot });
|
|
249
|
+
const checks = [...staticResult.checks];
|
|
250
|
+
if (scannerRoot) checks.push(checkFixturePins(a, scannerRoot, pins));
|
|
251
|
+
if (ids.has(a?.id)) checks.push(entry('unique-id', false, `duplicate adapter id '${a?.id}'`));
|
|
252
|
+
ids.add(a?.id);
|
|
253
|
+
let execution = 'not-run'; let executionReason = 'execution conformance was not requested';
|
|
254
|
+
if (execute && checks.every((c) => c.ok) && scannerRoot) {
|
|
255
|
+
const ex = await runAdapterConformance(a, { scannerRoot, run, runOptions, harnessProcesses });
|
|
256
|
+
if (ex.executed) { execution = ex.ok ? 'passed' : 'failed'; executionReason = null; checks.push(...ex.checks); }
|
|
257
|
+
else { execution = 'not-run'; executionReason = ex.checks[0]?.detail ?? 'unavailable'; }
|
|
258
|
+
} else if (execute && !checks.every((c) => c.ok)) {
|
|
259
|
+
executionReason = 'not attempted: the static contract failed';
|
|
260
|
+
}
|
|
261
|
+
const staticOk = checks.every((c) => c.ok);
|
|
262
|
+
const ok = staticOk && (execution === 'passed' || (execution === 'not-run' && !requireExecution));
|
|
263
|
+
results.push({ id: a?.id ?? null, class: a?.class ?? null, ok, execution, executionReason, checks });
|
|
264
|
+
}
|
|
265
|
+
// every advertised class must be served by a registered adapter
|
|
266
|
+
const missing = ORACLE_CLASSES.filter((c) => !adapters.some((a) => a?.class === c));
|
|
267
|
+
const unmapped = SCENARIO_CLASSES.filter((c) => !adapters.some((a) => a?.id === c.adapterId)).map((c) => c.id);
|
|
268
|
+
const registryProblems = [...missing.map((c) => `no adapter serves class '${c}'`), ...unmapped.map((c) => `non-taint class '${c}' names an adapter that is not registered`)];
|
|
269
|
+
return {
|
|
270
|
+
schema: CONFORMANCE_SCHEMA, schemaVersion: '1.0.0',
|
|
271
|
+
ok: results.every((r) => r.ok) && registryProblems.length === 0,
|
|
272
|
+
adapters: results, registryProblems,
|
|
273
|
+
executionRan: results.some((r) => r.execution === 'passed' || r.execution === 'failed'),
|
|
274
|
+
};
|
|
275
|
+
}
|