harness-everything 0.36.3 → 0.37.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +18 -0
- package/ci/fixtures/doc-obligations/providers-s3/cases.json +535 -0
- package/ci/fixtures/doc-obligations/read-boundary/boundary-controls.json +211 -0
- package/ci/fixtures/doc-obligations/read-boundary/boundary-policy.json +325 -0
- package/ci/fixtures/doc-obligations/read-boundary/cases.json +14058 -0
- package/ci/fixtures/doc-obligations/read-boundary/expected.json +1 -0
- package/ci/fixtures/doc-obligations/read-boundary/identity-cases.json +119 -0
- package/ci/fixtures/doc-obligations/read-boundary/identity-expected.json +1 -0
- package/ci/fixtures/doc-obligations/read-boundary/scope-cases.json +208 -0
- package/ci/fixtures/doc-obligations/read-boundary/scope-expected.json +1 -0
- package/ci/fixtures/doc-obligations/reconciliation-prep/cases.json +2751 -0
- package/ci/fixtures/doc-obligations/reconciliation-prep/flood-entrypoint.js +5 -0
- package/ci/fixtures/doc-obligations/reconciliation-prep/hang-entrypoint.js +5 -0
- package/ci/fixtures/doc-obligations/reconciliation-prep/pending-red.json +648 -0
- package/ci/fixtures/doc-obligations/reconciliation-prep/provider-contract.json +100 -0
- package/ci/fixtures/doc-obligations/reconciliation-prep/trust-contract.json +188 -0
- package/ci/lib/doc-evidence-boundary-check.js +1240 -0
- package/ci/lib/doc-evidence-fixtures.js +459 -0
- package/ci/lib/doc-evidence-trace.js +171 -0
- package/ci/lib/doc-reconciliation-prep-fixtures.js +502 -0
- package/ci/lib/snapshot-stdin.js +27 -0
- package/ci/mechanism-325-3b-preparation.test.js +382 -0
- package/ci/mechanism-325-3b-s3-providers.test.js +378 -0
- package/ci/mechanism-341-boundary-enforcement.test.js +660 -0
- package/ci/mechanism-341-capture-module.test.js +357 -0
- package/ci/mechanism-341-mutation-runner.test.js +413 -0
- package/ci/mechanism-341-read-boundary.test.js +388 -0
- package/ci/mechanism-341-snapshot-stdin.test.js +40 -0
- package/ci/mutation-check.js +805 -45
- package/ci/mutation-loader.js +544 -57
- package/ci/prep/doc-reconciliation-red.js +407 -0
- package/ci/stress-341-boundary-control.js +85 -0
- package/docs/doc-evidence-baseline-binding.md +105 -19
- package/docs/doc-evidence-boundary.md +135 -0
- package/docs/doc-workflow-reconciliation.md +338 -0
- package/docs/document-lifecycle.md +8 -1
- package/harness-everything/SKILL.md +1 -1
- package/harness-everything/scripts/doc-evidence-capture.js +331 -0
- package/harness-everything/scripts/doc-evidence.js +72 -223
- package/harness-everything/scripts/doc-providers.js +113 -0
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +5 -3
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-evidence-capture.js +331 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-evidence.js +72 -223
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-providers.js +113 -0
- package/sbom.cdx.json +43 -7
|
@@ -0,0 +1,1240 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// C1 is deliberately an isolation/API trial, not a JavaScript static-analysis engine. It
|
|
4
|
+
// compiles the exact caller-supplied source pair in a restricted VM, exercises the returned
|
|
5
|
+
// capture views with synthetic values, and checks the reviewed API/context/call-site policy.
|
|
6
|
+
// Dormant statements inside an already-authorized compatibility method are outside this
|
|
7
|
+
// method's proof and must be caught by the C2 structural supplement.
|
|
8
|
+
const vm = require('node:vm');
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const { spawnWithSnapshotInput } = require('./snapshot-stdin');
|
|
11
|
+
|
|
12
|
+
// C1 runs in CI across hosted runners; keep enough headroom for slower macOS startup and VM checks.
|
|
13
|
+
const C1_EXECUTION_TIMEOUT_MS = 10_000;
|
|
14
|
+
// Infinite-loop controls opt into a shorter deadline through the argv-gated test entry below.
|
|
15
|
+
const C1_TEST_EXECUTION_TIMEOUT_MS = 1_000;
|
|
16
|
+
const C1_EXECUTION_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
const COVERAGE_IDS = Object.freeze([
|
|
19
|
+
'source-pair',
|
|
20
|
+
'module-exports',
|
|
21
|
+
'evaluator-context-surface',
|
|
22
|
+
'invocation-capabilities',
|
|
23
|
+
'obligation-capabilities',
|
|
24
|
+
'capture-return-schemas',
|
|
25
|
+
'returned-view-ownership',
|
|
26
|
+
'capture-call-sites',
|
|
27
|
+
'compatibility-ledger'
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function checkBoundary(input) {
|
|
31
|
+
if (input && input.method === 'ast') {
|
|
32
|
+
try {
|
|
33
|
+
return runBoundaryCheck(input);
|
|
34
|
+
} catch {
|
|
35
|
+
return failClosedResult(input, 'CHECKER_FAIL_CLOSED');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return checkIsolationBoundary(input, C1_EXECUTION_TIMEOUT_MS);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function checkIsolationBoundary(input, timeoutMs) {
|
|
43
|
+
try {
|
|
44
|
+
// C1 calls VM-created functions and inspects their returned objects from host code; bound the
|
|
45
|
+
// entire isolation run so calls and proxy-backed ownership traversal are all killable.
|
|
46
|
+
// This production call selects only the fixed isolation entry; the timeout test entry is
|
|
47
|
+
// reachable only by direct CLI invocation and is never derived from caller input.
|
|
48
|
+
const child = spawnC1Child(['--c1-isolation-child'], JSON.stringify(input === undefined ? null : input), timeoutMs);
|
|
49
|
+
if (child.error && child.error.code === 'ETIMEDOUT') {
|
|
50
|
+
return failClosedResult(input, 'CHECKER_EXECUTION_TIMEOUT');
|
|
51
|
+
}
|
|
52
|
+
if (child.error || child.status !== 0 || child.signal) {
|
|
53
|
+
return failClosedResult(input, 'CHECKER_EXECUTION_FAILED');
|
|
54
|
+
}
|
|
55
|
+
return JSON.parse(child.stdout);
|
|
56
|
+
} catch {
|
|
57
|
+
return failClosedResult(input, 'CHECKER_FAIL_CLOSED');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function spawnC1Child(args, input, timeoutMs = C1_EXECUTION_TIMEOUT_MS) {
|
|
62
|
+
return spawnWithSnapshotInput(process.execPath, [__filename, ...args], {
|
|
63
|
+
input,
|
|
64
|
+
encoding: 'utf8',
|
|
65
|
+
timeout: timeoutMs,
|
|
66
|
+
// Windows maps SIGKILL to process termination; classify timeout by ETIMEDOUT below.
|
|
67
|
+
killSignal: 'SIGKILL',
|
|
68
|
+
maxBuffer: C1_EXECUTION_MAX_BUFFER_BYTES
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function failClosedResult(input, code) {
|
|
73
|
+
const method = input && input.method ? input.method : 'isolation';
|
|
74
|
+
const selected = input && input.policy && input.policy.distributions
|
|
75
|
+
&& input.policy.distributions[input.distribution];
|
|
76
|
+
const missing = [...COVERAGE_IDS];
|
|
77
|
+
if (method === 'ast') missing.push('ast-structure');
|
|
78
|
+
return {
|
|
79
|
+
method,
|
|
80
|
+
coverage: { complete: false, checked: [], missing },
|
|
81
|
+
diagnostics: [{
|
|
82
|
+
code,
|
|
83
|
+
file: selected && typeof selected.evaluator === 'string' ? selected.evaluator : '<input>',
|
|
84
|
+
location: 'checkBoundary',
|
|
85
|
+
ruleId: code
|
|
86
|
+
}]
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function runBoundaryCheck({ distribution, sources, policy, method = 'isolation' } = {}) {
|
|
91
|
+
if (method === 'ast') return runAstBoundaryCheck({ distribution, sources, policy });
|
|
92
|
+
|
|
93
|
+
const diagnostics = [];
|
|
94
|
+
const checked = [];
|
|
95
|
+
const missing = [];
|
|
96
|
+
const selected = policy && policy.distributions && policy.distributions[distribution];
|
|
97
|
+
|
|
98
|
+
const add = (code, file, location, ruleId, exceptionId) => {
|
|
99
|
+
diagnostics.push({
|
|
100
|
+
code,
|
|
101
|
+
file: typeof file === 'string' ? file : '<policy>',
|
|
102
|
+
location: typeof location === 'string' ? location : 'input',
|
|
103
|
+
ruleId,
|
|
104
|
+
...(exceptionId ? { exceptionId } : {})
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
const mark = id => {
|
|
108
|
+
if (!checked.includes(id)) checked.push(id);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
if (!policy || policy.schemaVersion !== 1 || !Array.isArray(policy.coverage)
|
|
112
|
+
|| !Array.isArray(policy.compatibilityLedger)) {
|
|
113
|
+
add('POLICY_INVALID', '<policy>', 'schemaVersion', 'POLICY_SCHEMA');
|
|
114
|
+
missing.push(...COVERAGE_IDS);
|
|
115
|
+
return { method: method || 'isolation', coverage: { complete: false, checked, missing }, diagnostics };
|
|
116
|
+
}
|
|
117
|
+
if (method !== 'isolation') {
|
|
118
|
+
add('METHOD_UNSUPPORTED', '<policy>', String(method), 'METHOD_SUPPORTED');
|
|
119
|
+
missing.push(...COVERAGE_IDS);
|
|
120
|
+
return { method: method || 'isolation', coverage: { complete: false, checked, missing }, diagnostics };
|
|
121
|
+
}
|
|
122
|
+
if (!selected || !sources || typeof sources.evaluator !== 'string' || !sources.evaluator
|
|
123
|
+
|| typeof sources.capture !== 'string' || !sources.capture) {
|
|
124
|
+
add('SOURCE_PAIR_MISSING', selected ? selected.evaluator : '<policy>', 'source-pair', 'SOURCE_PAIR_COMPLETE');
|
|
125
|
+
missing.push('source-pair', 'module-exports', 'evaluator-context-surface',
|
|
126
|
+
'invocation-capabilities', 'obligation-capabilities', 'capture-return-schemas',
|
|
127
|
+
'returned-view-ownership', 'capture-call-sites', 'compatibility-ledger');
|
|
128
|
+
return { method, coverage: { complete: false, checked, missing }, diagnostics };
|
|
129
|
+
}
|
|
130
|
+
mark('source-pair');
|
|
131
|
+
|
|
132
|
+
const fileFor = kind => selected[kind];
|
|
133
|
+
const sourceByKind = { evaluator: sources.evaluator, capture: sources.capture };
|
|
134
|
+
const policyProblems = validatePolicy(policy);
|
|
135
|
+
if (policyProblems.length) {
|
|
136
|
+
for (const problem of policyProblems) {
|
|
137
|
+
add('POLICY_INVALID', '<policy>', problem.location, 'POLICY_SCHEMA');
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
method,
|
|
141
|
+
coverage: { complete: false, checked, missing: [...COVERAGE_IDS] },
|
|
142
|
+
diagnostics
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let runtime;
|
|
147
|
+
try {
|
|
148
|
+
runtime = compilePair(sources, selected, policy, add);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
add('MODULE_LOAD_FAILED', fileFor('evaluator'), 'module-load', 'MODULE_LOADABLE');
|
|
151
|
+
missing.push('module-exports', 'invocation-capabilities', 'obligation-capabilities',
|
|
152
|
+
'capture-return-schemas', 'returned-view-ownership');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (runtime) {
|
|
156
|
+
const exportChecks = [
|
|
157
|
+
['evaluator', runtime.evaluator, policy.modules.evaluatorExports],
|
|
158
|
+
['capture', runtime.capture, policy.modules.captureExports]
|
|
159
|
+
];
|
|
160
|
+
for (const [kind, exportsObject, expected] of exportChecks) {
|
|
161
|
+
const actual = Reflect.ownKeys(exportsObject);
|
|
162
|
+
const expectedPrototype = vm.runInContext('Object.prototype', runtime.context);
|
|
163
|
+
if (!sameStringSet(actual, expected) || Object.getPrototypeOf(exportsObject) !== expectedPrototype
|
|
164
|
+
|| !hasOwnFunctionDataProperties(exportsObject, expected)) {
|
|
165
|
+
add('MODULE_EXPORT_SURFACE', fileFor(kind), 'module.exports', 'MODULE_EXPORTS_EXACT');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
mark('module-exports');
|
|
169
|
+
|
|
170
|
+
checkRequireSurface(sourceByKind, fileFor, policy, add);
|
|
171
|
+
|
|
172
|
+
const contextOk = checkContextSurface(sources.evaluator, policy.evaluatorContext, fileFor('evaluator'), add);
|
|
173
|
+
if (contextOk) mark('evaluator-context-surface');
|
|
174
|
+
else missing.push('evaluator-context-surface');
|
|
175
|
+
|
|
176
|
+
const invocationOk = checkCapabilities(runtime, policy, 'invocation', fileFor('capture'), add);
|
|
177
|
+
if (invocationOk) mark('invocation-capabilities');
|
|
178
|
+
else missing.push('invocation-capabilities');
|
|
179
|
+
|
|
180
|
+
const obligationOk = checkCapabilities(runtime, policy, 'obligation', fileFor('capture'), add);
|
|
181
|
+
if (obligationOk) mark('obligation-capabilities');
|
|
182
|
+
else missing.push('obligation-capabilities');
|
|
183
|
+
|
|
184
|
+
const viewResults = exerciseSyntheticViews(runtime, policy, fileFor('capture'), add);
|
|
185
|
+
if (viewResults.schemasComplete) mark('capture-return-schemas');
|
|
186
|
+
else missing.push('capture-return-schemas');
|
|
187
|
+
if (viewResults.ownershipComplete) mark('returned-view-ownership');
|
|
188
|
+
else missing.push('returned-view-ownership');
|
|
189
|
+
|
|
190
|
+
const evaluatorOk = exerciseSyntheticEvaluator(runtime, add, fileFor('evaluator'));
|
|
191
|
+
if (!evaluatorOk) missing.push('evaluator-context-surface');
|
|
192
|
+
} else {
|
|
193
|
+
for (const id of ['module-exports', 'evaluator-context-surface', 'invocation-capabilities',
|
|
194
|
+
'obligation-capabilities', 'capture-return-schemas', 'returned-view-ownership']) {
|
|
195
|
+
if (!missing.includes(id)) missing.push(id);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const callSitesOk = checkCallSites(sources.evaluator, policy.callSites, fileFor('evaluator'), add);
|
|
200
|
+
if (callSitesOk) mark('capture-call-sites');
|
|
201
|
+
else missing.push('capture-call-sites');
|
|
202
|
+
|
|
203
|
+
const ledgerOk = checkCompatibilityLedger(sourceByKind, policy.compatibilityLedger, fileFor, add);
|
|
204
|
+
if (ledgerOk) mark('compatibility-ledger');
|
|
205
|
+
else missing.push('compatibility-ledger');
|
|
206
|
+
|
|
207
|
+
for (const id of policy.coverage) {
|
|
208
|
+
if (!COVERAGE_IDS.includes(id)) {
|
|
209
|
+
add('POLICY_COVERAGE_UNKNOWN', '<policy>', id, 'POLICY_COVERAGE_DECLARED');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const id of COVERAGE_IDS) {
|
|
213
|
+
if (!policy.coverage.includes(id) || !checked.includes(id)) {
|
|
214
|
+
if (!missing.includes(id)) missing.push(id);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
method,
|
|
220
|
+
coverage: { complete: missing.length === 0, checked, missing: unique(missing) },
|
|
221
|
+
diagnostics
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function runAstBoundaryCheck({ distribution, sources, policy } = {}) {
|
|
226
|
+
const diagnostics = [];
|
|
227
|
+
const checked = [];
|
|
228
|
+
const missing = [];
|
|
229
|
+
const selected = policy && policy.distributions && policy.distributions[distribution];
|
|
230
|
+
const add = (code, file, location, ruleId, exceptionId) => {
|
|
231
|
+
appendDiagnostic(diagnostics, code, file, location, ruleId, exceptionId);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
if (!selected || !sources || typeof sources.evaluator !== 'string' || !sources.evaluator
|
|
235
|
+
|| typeof sources.capture !== 'string' || !sources.capture) {
|
|
236
|
+
add('SOURCE_PAIR_MISSING', selected ? selected.evaluator : '<policy>', 'source-pair', 'SOURCE_PAIR_COMPLETE');
|
|
237
|
+
return { method: 'ast', coverage: { complete: false, checked, missing: ['source-pair', 'ast-structure'] }, diagnostics };
|
|
238
|
+
}
|
|
239
|
+
checked.push('source-pair');
|
|
240
|
+
|
|
241
|
+
const astPolicy = policy && policy.astSupplement;
|
|
242
|
+
const policyProblems = validateAstPolicy(astPolicy);
|
|
243
|
+
if (policyProblems.length) {
|
|
244
|
+
for (const location of policyProblems) add('AST_POLICY_INVALID', '<policy>', location, 'AST_POLICY_COMPLETE');
|
|
245
|
+
missing.push('ast-structure');
|
|
246
|
+
return { method: 'ast', coverage: { complete: false, checked, missing }, diagnostics };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let parser;
|
|
250
|
+
try {
|
|
251
|
+
parser = loadAstParser(astPolicy.parser);
|
|
252
|
+
} catch {
|
|
253
|
+
add('AST_PARSER_UNAVAILABLE', '<policy>', 'parser', 'AST_PARSER_PINNED');
|
|
254
|
+
missing.push('ast-structure');
|
|
255
|
+
return { method: 'ast', coverage: { complete: false, checked, missing }, diagnostics };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const trees = {};
|
|
259
|
+
for (const kind of ['evaluator', 'capture']) {
|
|
260
|
+
try {
|
|
261
|
+
const ast = parser.acorn.parse(sources[kind], {
|
|
262
|
+
ecmaVersion: 'latest',
|
|
263
|
+
sourceType: 'commonjs',
|
|
264
|
+
allowHashBang: true
|
|
265
|
+
});
|
|
266
|
+
const nodeTypes = new Set();
|
|
267
|
+
parser.walk.full(ast, node => nodeTypes.add(node.type));
|
|
268
|
+
trees[kind] = ast;
|
|
269
|
+
} catch (error) {
|
|
270
|
+
const code = error && error.name === 'SyntaxError' ? 'AST_PARSE_FAILED' : 'AST_NODE_UNSUPPORTED';
|
|
271
|
+
const ruleId = code === 'AST_PARSE_FAILED' ? 'AST_PARSE_COMPLETE' : 'AST_SUPPORTED_NODE_SET';
|
|
272
|
+
add(code, selected[kind], error && error.message ? error.message : 'parse-or-walk', ruleId);
|
|
273
|
+
missing.push('ast-structure');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (missing.includes('ast-structure')) {
|
|
277
|
+
return { method: 'ast', coverage: { complete: false, checked, missing: ['ast-structure'] }, diagnostics };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (const kind of ['evaluator', 'capture']) {
|
|
281
|
+
const actual = astFingerprint(trees[kind], parser.walk);
|
|
282
|
+
if (actual !== astPolicy.moduleFingerprints[kind]) {
|
|
283
|
+
add('AST_PROGRAM_DRIFT', selected[kind], '@program', 'AST_SOURCE_STRUCTURE', `AST-${kind.toUpperCase()}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let targetsComplete = true;
|
|
288
|
+
for (const target of astPolicy.targets) {
|
|
289
|
+
const candidates = findAstTarget(trees[target.module], target.name, parser.walk);
|
|
290
|
+
if (candidates.length !== 1) {
|
|
291
|
+
add('AST_TARGET_INCOMPLETE', selected[target.module], target.name, 'AST_TARGETS_COMPLETE', target.exceptionId);
|
|
292
|
+
targetsComplete = false;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const actual = astFingerprint(candidates[0], parser.walk, trees[target.module]);
|
|
296
|
+
if (actual !== target.fingerprint) {
|
|
297
|
+
for (const ruleId of target.ruleIds) {
|
|
298
|
+
add('AST_TARGET_DRIFT', selected[target.module], target.name, ruleId, target.exceptionId);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (targetsComplete) checked.push('ast-structure');
|
|
303
|
+
else missing.push('ast-structure');
|
|
304
|
+
return {
|
|
305
|
+
method: 'ast',
|
|
306
|
+
coverage: { complete: missing.length === 0, checked, missing },
|
|
307
|
+
diagnostics
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function loadAstParser(expected) {
|
|
312
|
+
const acorn = require('acorn');
|
|
313
|
+
const walk = require('acorn-walk');
|
|
314
|
+
const acornVersion = require('acorn/package.json').version;
|
|
315
|
+
const walkVersion = require('acorn-walk/package.json').version;
|
|
316
|
+
if (acornVersion !== expected.acorn || walkVersion !== expected.acornWalk) {
|
|
317
|
+
throw new Error('installed parser version does not match the reviewed policy');
|
|
318
|
+
}
|
|
319
|
+
return { acorn, walk };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function appendDiagnostic(diagnostics, code, file, location, ruleId, exceptionId) {
|
|
323
|
+
diagnostics.push({
|
|
324
|
+
code,
|
|
325
|
+
file: typeof file === 'string' ? file : '<policy>',
|
|
326
|
+
location: typeof location === 'string' ? location : 'input',
|
|
327
|
+
ruleId,
|
|
328
|
+
...(exceptionId ? { exceptionId } : {})
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function validateAstPolicy(policy) {
|
|
333
|
+
const problems = [];
|
|
334
|
+
if (!policy || policy.schemaVersion !== 1 || policy.status !== 'frozen-for-c2'
|
|
335
|
+
|| !policy.parser || typeof policy.parser.acorn !== 'string'
|
|
336
|
+
|| typeof policy.parser.acornWalk !== 'string'
|
|
337
|
+
|| policy.canonicalization !== 'estree-alpha-target-v2'
|
|
338
|
+
|| !policy.moduleFingerprints || !isSha256(policy.moduleFingerprints.evaluator)
|
|
339
|
+
|| !isSha256(policy.moduleFingerprints.capture) || !Array.isArray(policy.targets)
|
|
340
|
+
|| policy.targets.length === 0) {
|
|
341
|
+
problems.push('astSupplement schema/status/parser/fingerprints/targets');
|
|
342
|
+
return problems;
|
|
343
|
+
}
|
|
344
|
+
const keys = new Set();
|
|
345
|
+
for (const target of policy.targets) {
|
|
346
|
+
const key = target && `${target.module}:${target.name}`;
|
|
347
|
+
if (!target || !['evaluator', 'capture'].includes(target.module)
|
|
348
|
+
|| typeof target.name !== 'string' || !target.name
|
|
349
|
+
|| !Array.isArray(target.ruleIds) || !target.ruleIds.length
|
|
350
|
+
|| target.ruleIds.some(ruleId => typeof ruleId !== 'string' || !ruleId)
|
|
351
|
+
|| !isSha256(target.fingerprint) || (target.exceptionId !== undefined && typeof target.exceptionId !== 'string')
|
|
352
|
+
|| keys.has(key)) {
|
|
353
|
+
problems.push(`astSupplement target ${key || '<unknown>'}`);
|
|
354
|
+
}
|
|
355
|
+
keys.add(key);
|
|
356
|
+
}
|
|
357
|
+
return problems;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function collectUniqueLocalNames(ast, walk) {
|
|
361
|
+
const counts = new Map();
|
|
362
|
+
const declarationOrder = [];
|
|
363
|
+
const addPattern = pattern => {
|
|
364
|
+
if (!pattern) return;
|
|
365
|
+
if (pattern.type === 'Identifier') {
|
|
366
|
+
counts.set(pattern.name, (counts.get(pattern.name) || 0) + 1);
|
|
367
|
+
if (!declarationOrder.includes(pattern.name)) declarationOrder.push(pattern.name);
|
|
368
|
+
} else if (pattern.type === 'RestElement') addPattern(pattern.argument);
|
|
369
|
+
else if (pattern.type === 'AssignmentPattern') addPattern(pattern.left);
|
|
370
|
+
else if (pattern.type === 'ArrayPattern') pattern.elements.forEach(addPattern);
|
|
371
|
+
else if (pattern.type === 'ObjectPattern') {
|
|
372
|
+
for (const property of pattern.properties) addPattern(property.type === 'RestElement' ? property.argument : property.value);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
walk.full(ast, node => {
|
|
376
|
+
if (node.type === 'VariableDeclarator') addPattern(node.id);
|
|
377
|
+
else if (['FunctionDeclaration', 'FunctionExpression'].includes(node.type)) {
|
|
378
|
+
if (node.id) addPattern(node.id);
|
|
379
|
+
node.params.forEach(addPattern);
|
|
380
|
+
} else if (node.type === 'ArrowFunctionExpression') node.params.forEach(addPattern);
|
|
381
|
+
else if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
|
|
382
|
+
if (node.id) addPattern(node.id);
|
|
383
|
+
} else if (node.type === 'CatchClause' && node.param) addPattern(node.param);
|
|
384
|
+
else if (node.type === 'ImportDeclaration') {
|
|
385
|
+
node.specifiers.forEach(specifier => addPattern(specifier.local));
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
const uniqueNames = new Map();
|
|
389
|
+
let index = 0;
|
|
390
|
+
for (const name of declarationOrder) {
|
|
391
|
+
if (counts.get(name) === 1) uniqueNames.set(name, `local-${index++}`);
|
|
392
|
+
}
|
|
393
|
+
return uniqueNames;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function astFingerprint(node, walk, moduleAst) {
|
|
397
|
+
const localNames = moduleAst
|
|
398
|
+
? collectTargetLocalNames(node, moduleAst, walk)
|
|
399
|
+
: collectUniqueLocalNames(node, walk);
|
|
400
|
+
const canonical = canonicalAstNode(node, localNames, null, null);
|
|
401
|
+
return require('node:crypto').createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function collectTargetLocalNames(node, moduleAst, walk) {
|
|
405
|
+
const moduleUniqueNames = collectUniqueLocalNames(moduleAst, walk);
|
|
406
|
+
const localNames = new Map();
|
|
407
|
+
const visit = (current, parent, key) => {
|
|
408
|
+
if (Array.isArray(current)) {
|
|
409
|
+
current.forEach(item => visit(item, parent, key));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (!current || typeof current !== 'object') return;
|
|
413
|
+
if (current.type === 'Identifier') {
|
|
414
|
+
if (!isStaticIdentifierName(parent, key) && moduleUniqueNames.has(current.name)
|
|
415
|
+
&& !localNames.has(current.name)) {
|
|
416
|
+
localNames.set(current.name, `local-${localNames.size}`);
|
|
417
|
+
}
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
for (const childKey of Object.keys(current).sort()) {
|
|
421
|
+
if (childKey === 'start' || childKey === 'end' || childKey === 'loc'
|
|
422
|
+
|| (childKey === 'raw' && current.type === 'Literal')) continue;
|
|
423
|
+
visit(current[childKey], current, childKey);
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
visit(node, null, null);
|
|
427
|
+
return localNames;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function canonicalAstNode(node, localNames, parent, key) {
|
|
431
|
+
if (Array.isArray(node)) return node.map(item => canonicalAstNode(item, localNames, parent, key));
|
|
432
|
+
if (!node || typeof node !== 'object') return node;
|
|
433
|
+
if (node.type === 'Identifier') {
|
|
434
|
+
if (isStaticIdentifierName(parent, key)) return { type: 'Identifier', name: node.name };
|
|
435
|
+
return { type: 'Identifier', name: localNames.has(node.name) ? `$${localNames.get(node.name)}` : node.name };
|
|
436
|
+
}
|
|
437
|
+
const result = {};
|
|
438
|
+
for (const childKey of Object.keys(node).sort()) {
|
|
439
|
+
if (childKey === 'start' || childKey === 'end' || childKey === 'loc'
|
|
440
|
+
|| (childKey === 'raw' && node.type === 'Literal')) continue;
|
|
441
|
+
result[childKey] = canonicalAstNode(node[childKey], localNames, node, childKey);
|
|
442
|
+
}
|
|
443
|
+
return result;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function isStaticIdentifierName(parent, key) {
|
|
447
|
+
if (!parent) return false;
|
|
448
|
+
if (parent.type === 'MemberExpression' && key === 'property' && !parent.computed) return true;
|
|
449
|
+
if ((parent.type === 'Property' || parent.type === 'MethodDefinition' || parent.type === 'PropertyDefinition')
|
|
450
|
+
&& key === 'key' && !parent.computed) return true;
|
|
451
|
+
if (parent.type === 'LabeledStatement' && key === 'label') return true;
|
|
452
|
+
if ((parent.type === 'BreakStatement' || parent.type === 'ContinueStatement') && key === 'label') return true;
|
|
453
|
+
if (parent.type === 'MetaProperty') return true;
|
|
454
|
+
if (parent.type === 'ImportSpecifier' && key === 'imported') return true;
|
|
455
|
+
if ((parent.type === 'ExportSpecifier' || parent.type === 'ExportDefaultSpecifier') && key === 'exported') return true;
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function findAstTarget(ast, name, walk) {
|
|
460
|
+
if (name === '@program') return [ast];
|
|
461
|
+
if (name === '@evaluateObligation-context-destructure') {
|
|
462
|
+
const evaluators = findAstTarget(ast, 'evaluateObligation', walk);
|
|
463
|
+
if (evaluators.length !== 1) return [];
|
|
464
|
+
const destructures = [];
|
|
465
|
+
walk.full(evaluators[0], node => {
|
|
466
|
+
if (node.type === 'VariableDeclarator' && node.id.type === 'ObjectPattern'
|
|
467
|
+
&& node.init && node.init.type === 'Identifier' && node.init.name === 'context') {
|
|
468
|
+
destructures.push(node);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
return destructures;
|
|
472
|
+
}
|
|
473
|
+
const matches = [];
|
|
474
|
+
walk.full(ast, node => {
|
|
475
|
+
if (node.type === 'FunctionDeclaration' && node.id && node.id.name === name) matches.push(node);
|
|
476
|
+
else if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier'
|
|
477
|
+
&& node.id.name === name && node.init
|
|
478
|
+
&& ['FunctionExpression', 'ArrowFunctionExpression'].includes(node.init.type)) matches.push(node.init);
|
|
479
|
+
else if (node.type === 'Property' && node.value
|
|
480
|
+
&& ['FunctionExpression', 'ArrowFunctionExpression'].includes(node.value.type)
|
|
481
|
+
&& !node.computed && ((node.key.type === 'Identifier' && node.key.name === name)
|
|
482
|
+
|| (node.key.type === 'Literal' && node.key.value === name))) matches.push(node.value);
|
|
483
|
+
if (name === '@exports' && node.type === 'AssignmentExpression' && node.left.type === 'MemberExpression'
|
|
484
|
+
&& !node.left.computed && node.left.object.type === 'Identifier' && node.left.object.name === 'module'
|
|
485
|
+
&& node.left.property.type === 'Identifier' && node.left.property.name === 'exports') matches.push(node);
|
|
486
|
+
});
|
|
487
|
+
return matches;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function compilePair(sources, selected, policy, add) {
|
|
491
|
+
const context = vm.createContext(Object.create(null), {
|
|
492
|
+
name: 'doc-evidence-boundary-isolation',
|
|
493
|
+
codeGeneration: { strings: false, wasm: false }
|
|
494
|
+
});
|
|
495
|
+
const allowedBuiltins = new Set(policy.modules.captureBuiltins || []);
|
|
496
|
+
// Keep the only needed host capability behind VM-realm methods. The captured host function
|
|
497
|
+
// receives and returns primitives only; capture source cannot reach its Function constructor.
|
|
498
|
+
const makeCryptoFacade = vm.runInContext(
|
|
499
|
+
'(digestText) => Object.freeze({ createHash(algorithm) {' +
|
|
500
|
+
' if (algorithm !== "sha256") throw new Error("unsupported hash algorithm");' +
|
|
501
|
+
' let text = "";' +
|
|
502
|
+
' const hash = {' +
|
|
503
|
+
' update(value) {' +
|
|
504
|
+
' if (typeof value !== "string") throw new TypeError("hash input must be a string");' +
|
|
505
|
+
' text += value;' +
|
|
506
|
+
' return hash;' +
|
|
507
|
+
' },' +
|
|
508
|
+
' digest(encoding) {' +
|
|
509
|
+
' if (encoding !== "hex") throw new TypeError("hash encoding must be hex");' +
|
|
510
|
+
' return digestText(text);' +
|
|
511
|
+
' }' +
|
|
512
|
+
' };' +
|
|
513
|
+
' return Object.freeze(hash);' +
|
|
514
|
+
'} })',
|
|
515
|
+
context,
|
|
516
|
+
{ timeout: 1000 }
|
|
517
|
+
);
|
|
518
|
+
const hostDigest = Object.freeze(text =>
|
|
519
|
+
require('node:crypto').createHash('sha256').update(text).digest('hex'));
|
|
520
|
+
const cryptoFacade = makeCryptoFacade(hostDigest);
|
|
521
|
+
const makeImportMap = vm.runInContext(
|
|
522
|
+
'(specifier, value) => {' +
|
|
523
|
+
' const imports = Object.create(null);' +
|
|
524
|
+
' if (typeof specifier === "string") imports[specifier] = value;' +
|
|
525
|
+
' return Object.freeze(imports);' +
|
|
526
|
+
' }',
|
|
527
|
+
context,
|
|
528
|
+
{ timeout: 1000 }
|
|
529
|
+
);
|
|
530
|
+
const makeRequireFacade = vm.runInContext(
|
|
531
|
+
'(allowed, reportDenied) => function requireInRealm(specifier) {' +
|
|
532
|
+
' if (typeof specifier === "string" && Object.prototype.hasOwnProperty.call(allowed, specifier)) {' +
|
|
533
|
+
' return allowed[specifier];' +
|
|
534
|
+
' }' +
|
|
535
|
+
' const label = typeof specifier === "string" ? specifier : "<non-string>";' +
|
|
536
|
+
' reportDenied(label);' +
|
|
537
|
+
' throw new Error("unsupported import: " + label);' +
|
|
538
|
+
' }',
|
|
539
|
+
context,
|
|
540
|
+
{ timeout: 1000 }
|
|
541
|
+
);
|
|
542
|
+
const deniedImportReporter = file => Object.freeze(specifier => {
|
|
543
|
+
add('MODULE_IMPORT_DENIED', file, `require(${specifier})`, 'MODULE_IMPORTS_CLOSED');
|
|
544
|
+
});
|
|
545
|
+
let captureModule;
|
|
546
|
+
const loaded = { evaluator: null, capture: null };
|
|
547
|
+
const compile = (kind, source, localRequire) => {
|
|
548
|
+
const moduleRecord = vm.runInContext('({ exports: {} })', context);
|
|
549
|
+
const prefix = `__boundary_${kind}`;
|
|
550
|
+
context[`${prefix}_module`] = moduleRecord;
|
|
551
|
+
context[`${prefix}_require`] = localRequire;
|
|
552
|
+
context[`${prefix}_filename`] = selected[kind];
|
|
553
|
+
context[`${prefix}_dirname`] = selected[kind].split('/').slice(0, -1).join('/');
|
|
554
|
+
context[`${prefix}_wrapper`] = vm.runInContext(
|
|
555
|
+
`(function(exports, require, module, __filename, __dirname) {\n${source}\n})`,
|
|
556
|
+
context,
|
|
557
|
+
{ filename: selected[kind], timeout: 1000 }
|
|
558
|
+
);
|
|
559
|
+
try {
|
|
560
|
+
vm.runInContext(
|
|
561
|
+
`${prefix}_wrapper(${prefix}_module.exports, ${prefix}_require, ${prefix}_module, ${prefix}_filename, ${prefix}_dirname)`,
|
|
562
|
+
context,
|
|
563
|
+
{ filename: selected[kind], timeout: 1000 }
|
|
564
|
+
);
|
|
565
|
+
} finally {
|
|
566
|
+
delete context[`${prefix}_module`];
|
|
567
|
+
delete context[`${prefix}_require`];
|
|
568
|
+
delete context[`${prefix}_filename`];
|
|
569
|
+
delete context[`${prefix}_dirname`];
|
|
570
|
+
delete context[`${prefix}_wrapper`];
|
|
571
|
+
}
|
|
572
|
+
return moduleRecord.exports;
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const captureImports = makeImportMap(allowedBuiltins.has('crypto') ? 'crypto' : null, cryptoFacade);
|
|
576
|
+
const captureRequire = makeRequireFacade(captureImports, deniedImportReporter(selected.capture));
|
|
577
|
+
captureModule = compile('capture', sources.capture, captureRequire);
|
|
578
|
+
loaded.capture = captureModule;
|
|
579
|
+
const evaluatorImports = makeImportMap('./doc-evidence-capture', captureModule);
|
|
580
|
+
const evaluatorRequire = makeRequireFacade(evaluatorImports, deniedImportReporter(selected.evaluator));
|
|
581
|
+
loaded.evaluator = compile('evaluator', sources.evaluator, evaluatorRequire);
|
|
582
|
+
loaded.context = context;
|
|
583
|
+
return loaded;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function checkRequireSurface(sourceByKind, fileFor, policy, add) {
|
|
587
|
+
let ok = true;
|
|
588
|
+
for (const kind of ['evaluator', 'capture']) {
|
|
589
|
+
const source = sourceByKind[kind];
|
|
590
|
+
const literal = [];
|
|
591
|
+
const pattern = /\brequire\s*\(\s*(['"])([^'"]+)\1\s*\)/g;
|
|
592
|
+
let match;
|
|
593
|
+
while ((match = pattern.exec(source))) literal.push(match[2]);
|
|
594
|
+
const allCalls = (source.match(/\brequire\s*(?:\?\.)?\s*\(/g) || []).length;
|
|
595
|
+
const requireReferences = (source.match(/\brequire\b/g) || []).length;
|
|
596
|
+
const alternateImport = /\bmodule\s*(?:\.\s*require|\[\s*['"]require['"]\s*\])\s*\(/.test(source)
|
|
597
|
+
|| /\bimport\s*(?:\?\.)?\s*\(/.test(source);
|
|
598
|
+
const expected = policy.modules[`${kind}Requires`];
|
|
599
|
+
if (!sameStringArray(literal.sort(), [...expected].sort()) || allCalls !== literal.length
|
|
600
|
+
|| requireReferences !== allCalls || alternateImport) {
|
|
601
|
+
add('MODULE_IMPORT_SURFACE', fileFor(kind), 'require', 'MODULE_IMPORTS_CLOSED');
|
|
602
|
+
ok = false;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return ok;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function checkContextSurface(source, contextPolicy, file, add) {
|
|
609
|
+
const call = /evaluateObligation\s*\(\s*obligation\s*,\s*\{\s*([^{}]*?)\s*\}\s*\)/gs;
|
|
610
|
+
const calls = [...source.matchAll(call)];
|
|
611
|
+
let ok = calls.length === 1;
|
|
612
|
+
if (calls.length !== 1) {
|
|
613
|
+
add('CONTEXT_HANDOFF_SHAPE', file, 'evaluateObligation', 'EVALUATOR_CONTEXT_EXACT');
|
|
614
|
+
} else {
|
|
615
|
+
const handoff = parseShorthandSurface(calls[0][1]);
|
|
616
|
+
if (!handoff || !sameStringArray(handoff, contextPolicy.handoffKeys)) {
|
|
617
|
+
add('CONTEXT_HANDOFF_SURFACE', file, 'evaluateObligation(context)', 'EVALUATOR_CONTEXT_EXACT');
|
|
618
|
+
ok = false;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const destructure = /function\s+evaluateObligation\s*\(\s*obligation\s*,\s*context\s*\)\s*\{[\s\S]{0,500}?const\s*\{\s*([^{}]+?)\s*\}\s*=\s*context\s*;/g;
|
|
622
|
+
const destructures = [...source.matchAll(destructure)];
|
|
623
|
+
if (destructures.length !== 1) {
|
|
624
|
+
add('CONTEXT_CONSUMER_SHAPE', file, 'evaluateObligation(context)', 'EVALUATOR_CONTEXT_EXACT');
|
|
625
|
+
ok = false;
|
|
626
|
+
} else {
|
|
627
|
+
const keys = parseShorthandSurface(destructures[0][1]);
|
|
628
|
+
if (!keys || !sameStringArray(keys, contextPolicy.consumerKeys)) {
|
|
629
|
+
add('CONTEXT_CONSUMER_SURFACE', file, 'evaluateObligation(context)', 'EVALUATOR_CONTEXT_EXACT');
|
|
630
|
+
ok = false;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const contextPropertyUses = (source.match(/\bcontext\s*\./g) || []).length;
|
|
634
|
+
const contextTokens = (source.match(/\bcontext\b/g) || []).length;
|
|
635
|
+
if (contextPropertyUses !== 0 || contextTokens !== 2) {
|
|
636
|
+
add('CONTEXT_ALIAS_OR_COMPUTED_USE', file, 'context', 'EVALUATOR_CONTEXT_EXACT');
|
|
637
|
+
ok = false;
|
|
638
|
+
}
|
|
639
|
+
return ok;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function parseShorthandSurface(text) {
|
|
643
|
+
const normalized = text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '').trim();
|
|
644
|
+
if (!normalized) return [];
|
|
645
|
+
const parts = normalized.split(',').map(part => part.trim());
|
|
646
|
+
if (parts.some(part => !/^[A-Za-z_$][\w$]*$/.test(part))) return null;
|
|
647
|
+
return parts;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function checkCapabilities(runtime, policy, scope, file, add) {
|
|
651
|
+
try {
|
|
652
|
+
const expected = policy.capabilities[scope];
|
|
653
|
+
const object = scope === 'invocation'
|
|
654
|
+
? runtime.capture.createInvocationCapture(makeSyntheticInvocation(runtime.context))
|
|
655
|
+
: runtime.capture.createInvocationCapture(makeSyntheticInvocation(runtime.context)).beginObligation('applicable');
|
|
656
|
+
const actual = Reflect.ownKeys(object);
|
|
657
|
+
const expectedPrototype = vm.runInContext('Object.prototype', runtime.context);
|
|
658
|
+
const exact = sameStringSet(actual, expected) && Object.isFrozen(object)
|
|
659
|
+
&& Object.getPrototypeOf(object) === expectedPrototype
|
|
660
|
+
&& hasOwnFunctionDataProperties(object, expected);
|
|
661
|
+
if (!exact) add('CAPABILITY_SURFACE', file, scope, scope === 'invocation'
|
|
662
|
+
? 'INVOCATION_CAPABILITIES_EXACT' : 'OBLIGATION_CAPABILITIES_EXACT');
|
|
663
|
+
return exact;
|
|
664
|
+
} catch {
|
|
665
|
+
add('CAPABILITY_SURFACE_UNREADABLE', file, scope, scope === 'invocation'
|
|
666
|
+
? 'INVOCATION_CAPABILITIES_EXACT' : 'OBLIGATION_CAPABILITIES_EXACT');
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function hasOwnFunctionDataProperties(object, expected) {
|
|
672
|
+
return expected.every(name => {
|
|
673
|
+
const descriptor = Object.getOwnPropertyDescriptor(object, name);
|
|
674
|
+
return Boolean(descriptor && Object.prototype.hasOwnProperty.call(descriptor, 'value')
|
|
675
|
+
&& typeof descriptor.value === 'function');
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function makeSyntheticInvocation(context) {
|
|
680
|
+
return vm.runInContext(`({
|
|
681
|
+
artifacts: { 'docs/prd/one.md': { digest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' } },
|
|
682
|
+
baseline: { digest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', artifacts: {} }
|
|
683
|
+
})`, context, { timeout: 1000 });
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function exerciseSyntheticViews(runtime, policy, file, add) {
|
|
687
|
+
let schemasComplete = true;
|
|
688
|
+
let ownershipComplete = true;
|
|
689
|
+
const context = runtime.context;
|
|
690
|
+
const samples = [];
|
|
691
|
+
try {
|
|
692
|
+
const fixture = vm.runInContext(`(() => {
|
|
693
|
+
const fixture = {
|
|
694
|
+
artifacts: {
|
|
695
|
+
'docs/ordinary.md': { digest: 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc' },
|
|
696
|
+
'docs/destination.md': { digest: 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd' },
|
|
697
|
+
'docs/prd/spec.md': { digest: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' }
|
|
698
|
+
},
|
|
699
|
+
baseline: {
|
|
700
|
+
digest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
701
|
+
artifacts: { 'docs/base.md': 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' }
|
|
702
|
+
},
|
|
703
|
+
nested: { marker: 'caller-owned' },
|
|
704
|
+
rawEntry: null
|
|
705
|
+
};
|
|
706
|
+
fixture.rawEntry = { path: 'docs/base.md', reason: 'added', renameTo: 'docs/destination.md', nested: fixture.nested };
|
|
707
|
+
return fixture;
|
|
708
|
+
})()`, context, { timeout: 1000 });
|
|
709
|
+
const defineSyntheticFault = vm.runInContext(
|
|
710
|
+
`(target, key) => Object.defineProperty(target, key, { get() { throw new Error('synthetic read fault'); } })`,
|
|
711
|
+
context,
|
|
712
|
+
{ timeout: 1000 }
|
|
713
|
+
);
|
|
714
|
+
const defineSyntheticUndefined = vm.runInContext(
|
|
715
|
+
`(target, key) => Object.defineProperty(target, key, { value: undefined, enumerable: true })`,
|
|
716
|
+
context,
|
|
717
|
+
{ timeout: 1000 }
|
|
718
|
+
);
|
|
719
|
+
const inputs = collectObjectGraph([fixture]);
|
|
720
|
+
const invocationArgs = vm.runInContext('({ artifacts: null, baseline: null })', context);
|
|
721
|
+
invocationArgs.artifacts = fixture.artifacts;
|
|
722
|
+
invocationArgs.baseline = fixture.baseline;
|
|
723
|
+
const invocation = runtime.capture.createInvocationCapture(invocationArgs);
|
|
724
|
+
invocation.initializeArtifactFallback();
|
|
725
|
+
const baselineDigest = invocation.captureBaselineIdentity();
|
|
726
|
+
addSample('captureBaselineIdentity', baselineDigest, 'string');
|
|
727
|
+
addSample('candidateInventoryKnown', invocation.candidateInventoryKnown(), 'boolean');
|
|
728
|
+
const obligation = invocation.beginObligation('applicable');
|
|
729
|
+
addSample('prepareBaselineContainer', obligation.prepareBaselineContainer(), 'boolean');
|
|
730
|
+
|
|
731
|
+
const captured = obligation.captureEntry(fixture.rawEntry);
|
|
732
|
+
const entrySample = addSample('captureEntry', captured, 'captureEntry');
|
|
733
|
+
const sourceAbsence = obligation.captureSourceAbsence(captured.token, 'docs/base.md');
|
|
734
|
+
addSample('captureSourceAbsence', sourceAbsence, 'captureSourceAbsence');
|
|
735
|
+
addSample('captureSourceAbsence.present', obligation.captureSourceAbsence(captured.token, 'docs/ordinary.md'), 'captureSourceAbsence');
|
|
736
|
+
const capturedAgain = obligation.captureEntry(fixture.rawEntry);
|
|
737
|
+
if (capturedAgain === captured || capturedAgain.token === captured.token) {
|
|
738
|
+
ownershipComplete = false;
|
|
739
|
+
add('RETURNED_VIEW_REUSE', file, 'captureEntry', 'RETURNED_VIEW_OWNERSHIP');
|
|
740
|
+
}
|
|
741
|
+
addSample('captureEntry.repeated', capturedAgain, 'captureEntry');
|
|
742
|
+
addSample('captureEntry.notObject', obligation.captureEntry(null), 'captureEntry');
|
|
743
|
+
const rawWithoutRename = vm.runInContext("({ path: 'docs/plain.md', reason: 'added' })", context);
|
|
744
|
+
inputs.add(rawWithoutRename);
|
|
745
|
+
addSample('captureEntry.noRenameTo', obligation.captureEntry(rawWithoutRename), 'captureEntry');
|
|
746
|
+
const failedEntry = vm.runInContext(`(() => {
|
|
747
|
+
const entry = {};
|
|
748
|
+
Object.defineProperty(entry, 'path', { get() { throw new Error('synthetic read fault'); } });
|
|
749
|
+
return entry;
|
|
750
|
+
})()`, context);
|
|
751
|
+
inputs.add(failedEntry);
|
|
752
|
+
addSample('captureEntry.inspectionFailed', obligation.captureEntry(failedEntry), 'captureEntry');
|
|
753
|
+
addSample('formatInvalidReason', obligation.formatInvalidReason(captured.token), 'string');
|
|
754
|
+
addSample('formatInvalidRenameTo', obligation.formatInvalidRenameTo(captured.token), 'string');
|
|
755
|
+
|
|
756
|
+
const acceptedDestination = obligation.captureDestination(captured.token, 'docs/destination.md');
|
|
757
|
+
addSample('captureDestination.accepted', acceptedDestination, 'captureDestination');
|
|
758
|
+
addSample('captureDestination.unusable', obligation.captureDestination(captured.token, 'docs/missing.md'), 'captureDestination');
|
|
759
|
+
fixture.artifacts['docs/destination.md'].digest = '9999999999999999999999999999999999999999999999999999999999999999';
|
|
760
|
+
addSample('captureDestination.conflict', obligation.captureDestination(captured.token, 'docs/destination.md'), 'captureDestination');
|
|
761
|
+
defineSyntheticFault(fixture.artifacts, 'docs/fault.md');
|
|
762
|
+
addSample('captureDestination.inspectionFailed', obligation.captureDestination(captured.token, 'docs/fault.md'), 'captureDestination');
|
|
763
|
+
|
|
764
|
+
addSample('captureBaselinePath.present', obligation.captureBaselinePath('docs/base.md'), 'captureBaselinePath');
|
|
765
|
+
addSample('captureBaselinePath.absent', obligation.captureBaselinePath('docs/missing.md'), 'captureBaselinePath');
|
|
766
|
+
defineSyntheticUndefined(fixture.baseline.artifacts, 'docs/undefined.md');
|
|
767
|
+
fixture.baseline.artifacts['docs/invalid.md'] = 42;
|
|
768
|
+
addSample('captureBaselinePath.presentUndefined', obligation.captureBaselinePath('docs/undefined.md'), 'captureBaselinePath');
|
|
769
|
+
addSample('captureBaselinePath.presentInvalid', obligation.captureBaselinePath('docs/invalid.md'), 'captureBaselinePath');
|
|
770
|
+
defineSyntheticFault(fixture.baseline.artifacts, 'docs/fault.md');
|
|
771
|
+
addSample('captureBaselinePath.unknown', obligation.captureBaselinePath('docs/fault.md'), 'captureBaselinePath');
|
|
772
|
+
|
|
773
|
+
addSample('baselineEntries.empty', obligation.baselineEntries(), 'baselineEntries');
|
|
774
|
+
addSample('baselineAuditPairs.empty', obligation.baselineAuditPairs(), 'baselineAuditPairs');
|
|
775
|
+
|
|
776
|
+
obligation.poisonDestination('docs/poisoned.md');
|
|
777
|
+
addSample('isDestinationPoisoned', obligation.isDestinationPoisoned('docs/poisoned.md'), 'boolean');
|
|
778
|
+
addSample('captureOrdinaryCandidates', obligation.captureOrdinaryCandidates(vm.runInContext("['docs/ordinary.md']", context)), 'boolean');
|
|
779
|
+
addSample('capturedCandidate.captured', obligation.capturedCandidate('docs/ordinary.md'), 'capturedCandidate');
|
|
780
|
+
addSample('capturedCandidate.uncaptured', obligation.capturedCandidate('docs/not-captured.md'), 'capturedCandidate');
|
|
781
|
+
addSample('candidateAuditValue', obligation.candidateAuditValue('docs/ordinary.md'), 'string');
|
|
782
|
+
|
|
783
|
+
const acceptedBaselineEntry = vm.runInContext(
|
|
784
|
+
'({ entryToken: null, path: null, reason: null, trustedBaselineDigest: null })', context);
|
|
785
|
+
acceptedBaselineEntry.entryToken = captured.token;
|
|
786
|
+
acceptedBaselineEntry.path = 'docs/base.md';
|
|
787
|
+
acceptedBaselineEntry.reason = 'added';
|
|
788
|
+
acceptedBaselineEntry.trustedBaselineDigest = 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
|
|
789
|
+
obligation.acceptBaselineEntry(acceptedBaselineEntry);
|
|
790
|
+
const renameRawEntry = vm.runInContext(
|
|
791
|
+
"({ path: 'docs/renamed.md', reason: 'rename-from', renameTo: 'docs/destination.md' })", context);
|
|
792
|
+
inputs.add(renameRawEntry);
|
|
793
|
+
const renameCapture = obligation.captureEntry(renameRawEntry);
|
|
794
|
+
obligation.captureSourceAbsence(renameCapture.token, 'docs/renamed.md');
|
|
795
|
+
const acceptedRenameEntry = vm.runInContext(
|
|
796
|
+
`({ entryToken: null, path: null, reason: null, renameTo: null,
|
|
797
|
+
renameDestinationDigest: null, trustedBaselineDigest: null })`, context);
|
|
798
|
+
acceptedRenameEntry.entryToken = renameCapture.token;
|
|
799
|
+
acceptedRenameEntry.path = 'docs/renamed.md';
|
|
800
|
+
acceptedRenameEntry.reason = 'rename-from';
|
|
801
|
+
acceptedRenameEntry.renameTo = 'docs/destination.md';
|
|
802
|
+
acceptedRenameEntry.renameDestinationDigest = acceptedDestination.digest;
|
|
803
|
+
acceptedRenameEntry.trustedBaselineDigest = 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
|
|
804
|
+
obligation.acceptBaselineEntry(acceptedRenameEntry);
|
|
805
|
+
const baselineEntries = obligation.baselineEntries();
|
|
806
|
+
addSample('baselineEntries', baselineEntries, 'baselineEntries');
|
|
807
|
+
const entryCopyA = obligation.baselineEntries();
|
|
808
|
+
const entryCopyB = obligation.baselineEntries();
|
|
809
|
+
entryCopyA.push(vm.runInContext("({ path: 'docs/injected.md' })", context));
|
|
810
|
+
const entryCopyC = obligation.baselineEntries();
|
|
811
|
+
if (entryCopyA === entryCopyB || entryCopyB.length !== entryCopyC.length
|
|
812
|
+
|| someArrayValue(entryCopyB, (entry, index) => entry === entryCopyC[index])) {
|
|
813
|
+
ownershipComplete = false;
|
|
814
|
+
add('RETURNED_ARRAY_REUSE', file, 'baselineEntries', 'RETURNED_VIEW_OWNERSHIP');
|
|
815
|
+
}
|
|
816
|
+
const baselinePairs = obligation.baselineAuditPairs();
|
|
817
|
+
addSample('baselineAuditPairs', baselinePairs, 'baselineAuditPairs');
|
|
818
|
+
const pairCopyA = obligation.baselineAuditPairs();
|
|
819
|
+
const pairCopyB = obligation.baselineAuditPairs();
|
|
820
|
+
pairCopyA.push(vm.runInContext("['docs/injected.md', '0000000000000000000000000000000000000000000000000000000000000000']", context));
|
|
821
|
+
const pairCopyC = obligation.baselineAuditPairs();
|
|
822
|
+
if (pairCopyA === pairCopyB || pairCopyB.length !== pairCopyC.length
|
|
823
|
+
|| someArrayValue(pairCopyB, (pair, index) => pair === pairCopyC[index])) {
|
|
824
|
+
ownershipComplete = false;
|
|
825
|
+
add('RETURNED_ARRAY_REUSE', file, 'baselineAuditPairs', 'RETURNED_VIEW_OWNERSHIP');
|
|
826
|
+
}
|
|
827
|
+
const compatibilityCandidate = vm.runInContext(
|
|
828
|
+
'({ baseDigest: null, artifactDigests: {}, paths: [] })', context);
|
|
829
|
+
compatibilityCandidate.baseDigest = baselineDigest;
|
|
830
|
+
compatibilityCandidate.artifactDigests['docs/ordinary.md'] = 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc';
|
|
831
|
+
compatibilityCandidate.paths.push('docs/ordinary.md');
|
|
832
|
+
addSample('compatibilityCandidateBound', obligation.compatibilityCandidateBound(
|
|
833
|
+
compatibilityCandidate, 'docs/ordinary.md', baselineDigest), 'boolean');
|
|
834
|
+
const staleArtifact = vm.runInContext('({ artifactDigests: {} })', context);
|
|
835
|
+
staleArtifact.artifactDigests['docs/ordinary.md'] = '0000000000000000000000000000000000000000000000000000000000000000';
|
|
836
|
+
addSample('compatibilityStaleArtifactMismatch', obligation.compatibilityStaleArtifactMismatch(
|
|
837
|
+
staleArtifact, 'docs/ordinary.md'), 'boolean');
|
|
838
|
+
addSample('compatibilityPrdSetDigest', invocation.compatibilityPrdSetDigest(), 'sha256');
|
|
839
|
+
|
|
840
|
+
for (const sample of samples) {
|
|
841
|
+
if (!validateSchema(sample.value, sample.schema, policy.returnSchemas, context)) {
|
|
842
|
+
schemasComplete = false;
|
|
843
|
+
add('RETURN_SCHEMA_MISMATCH', file, sample.name, 'CAPTURE_RETURN_SCHEMAS');
|
|
844
|
+
}
|
|
845
|
+
if (!validateOwnedGraph(sample.value, inputs, context)) {
|
|
846
|
+
ownershipComplete = false;
|
|
847
|
+
add('RETURNED_VIEW_ESCAPE', file, sample.name, 'RETURNED_VIEW_OWNERSHIP');
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (!entrySample) schemasComplete = false;
|
|
851
|
+
} catch (error) {
|
|
852
|
+
schemasComplete = false;
|
|
853
|
+
ownershipComplete = false;
|
|
854
|
+
add('SYNTHETIC_VIEW_EXERCISE_FAILED', file, 'synthetic-capture-graph', 'CAPTURE_SYNTHETIC_GRAPH');
|
|
855
|
+
}
|
|
856
|
+
return { schemasComplete, ownershipComplete };
|
|
857
|
+
|
|
858
|
+
function addSample(name, value, schema) {
|
|
859
|
+
// A missing policy schema must fail validation, never turn this sample into an opt-out.
|
|
860
|
+
samples.push({ name, value, schema });
|
|
861
|
+
return true;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function validateSchema(value, schemaName, schemas, context) {
|
|
866
|
+
const schema = schemas[schemaName];
|
|
867
|
+
if (!schema) return false;
|
|
868
|
+
if (schema.kind === 'primitive') return primitiveMatches(value, schema.type);
|
|
869
|
+
if (schema.kind === 'tagged') {
|
|
870
|
+
if (!isRecordObject(value, context)) return false;
|
|
871
|
+
const tag = value[schema.tag];
|
|
872
|
+
const variants = schema.variants.filter(item => item.value === tag);
|
|
873
|
+
return variants.some(variant => exactFields(value, variant.fields, context));
|
|
874
|
+
}
|
|
875
|
+
if (schema.kind === 'array') {
|
|
876
|
+
return everyArrayValue(value, item => validateInline(item, schema.item, schemas, context));
|
|
877
|
+
}
|
|
878
|
+
if (schema.kind === 'pair-array') {
|
|
879
|
+
return everyArrayValue(value, pair => Array.isArray(pair) && pair.length === 2
|
|
880
|
+
&& primitiveMatches(pair[0], 'string') && primitiveMatches(pair[1], 'string'));
|
|
881
|
+
}
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// These loops stay in the host realm. Calling value.every/some would pass checker callbacks into
|
|
886
|
+
// a VM-owned method that untrusted source could override to inspect their host Function constructor.
|
|
887
|
+
function everyArrayValue(values, predicate) {
|
|
888
|
+
if (!Array.isArray(values)) return false;
|
|
889
|
+
const length = values.length;
|
|
890
|
+
for (let index = 0; index < length; index++) {
|
|
891
|
+
if (Object.prototype.hasOwnProperty.call(values, index) && !predicate(values[index], index)) return false;
|
|
892
|
+
}
|
|
893
|
+
return true;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function someArrayValue(values, predicate) {
|
|
897
|
+
if (!Array.isArray(values)) return false;
|
|
898
|
+
const length = values.length;
|
|
899
|
+
for (let index = 0; index < length; index++) {
|
|
900
|
+
if (Object.prototype.hasOwnProperty.call(values, index) && predicate(values[index], index)) return true;
|
|
901
|
+
}
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function validateInline(value, inline, schemas, context) {
|
|
906
|
+
if (!isRecordObject(value, context) || !exactFields(value, inline, context)) return false;
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function exactFields(value, fields, context) {
|
|
911
|
+
const keys = Reflect.ownKeys(value);
|
|
912
|
+
const required = Object.entries(fields).filter(([, spec]) => !spec.endsWith('?')).map(([key]) => key);
|
|
913
|
+
const allowed = Object.keys(fields);
|
|
914
|
+
if (keys.some(key => typeof key !== 'string' || !allowed.includes(key))) return false;
|
|
915
|
+
if (required.some(key => !Object.prototype.hasOwnProperty.call(value, key))) return false;
|
|
916
|
+
for (const [key, originalSpec] of Object.entries(fields)) {
|
|
917
|
+
const optional = originalSpec.endsWith('?');
|
|
918
|
+
const spec = optional ? originalSpec.slice(0, -1) : originalSpec;
|
|
919
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
|
|
920
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
921
|
+
if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')
|
|
922
|
+
|| !fieldMatches(descriptor.value, spec, context)) return false;
|
|
923
|
+
}
|
|
924
|
+
return true;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function fieldMatches(value, spec, context) {
|
|
928
|
+
if (spec === 'string-or-undefined') return typeof value === 'string' || value === undefined;
|
|
929
|
+
if (spec === 'string') return typeof value === 'string';
|
|
930
|
+
if (spec === 'boolean') return typeof value === 'boolean';
|
|
931
|
+
if (spec === 'undefined') return value === undefined;
|
|
932
|
+
if (spec.startsWith('literal:')) {
|
|
933
|
+
const literal = spec.slice('literal:'.length);
|
|
934
|
+
if (literal === 'true') return value === true;
|
|
935
|
+
if (literal === 'false') return value === false;
|
|
936
|
+
return value === literal;
|
|
937
|
+
}
|
|
938
|
+
if (spec === 'opaque-token') {
|
|
939
|
+
return Boolean(value) && typeof value === 'object' && Object.getPrototypeOf(value) === null
|
|
940
|
+
&& Reflect.ownKeys(value).length === 0 && Object.isFrozen(value);
|
|
941
|
+
}
|
|
942
|
+
if (spec === 'string-array') return everyArrayValue(value, item => typeof item === 'string');
|
|
943
|
+
return false;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function primitiveMatches(value, type) {
|
|
947
|
+
if (type === 'string') return typeof value === 'string';
|
|
948
|
+
if (type === 'boolean') return typeof value === 'boolean';
|
|
949
|
+
if (type === 'undefined') return value === undefined;
|
|
950
|
+
if (type === 'sha256') return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value);
|
|
951
|
+
return false;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function isRecordObject(value, context) {
|
|
955
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
956
|
+
&& Object.getPrototypeOf(value) === vm.runInContext('Object.prototype', context);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function validateOwnedGraph(root, inputReferences, context) {
|
|
960
|
+
const seen = new Set();
|
|
961
|
+
const tokenObjects = new Set();
|
|
962
|
+
const visit = (value, depth = 0) => {
|
|
963
|
+
if (depth > 20) return false;
|
|
964
|
+
if (!value || (typeof value !== 'object' && typeof value !== 'function')) return true;
|
|
965
|
+
if (inputReferences.has(value) || typeof value === 'function' || seen.has(value)) return false;
|
|
966
|
+
seen.add(value);
|
|
967
|
+
if (Object.getPrototypeOf(value) === null && Object.isFrozen(value) && Reflect.ownKeys(value).length === 0) {
|
|
968
|
+
tokenObjects.add(value);
|
|
969
|
+
return true;
|
|
970
|
+
}
|
|
971
|
+
const expectedPrototype = Array.isArray(value)
|
|
972
|
+
? vm.runInContext('Array.prototype', context)
|
|
973
|
+
: vm.runInContext('Object.prototype', context);
|
|
974
|
+
if (Object.getPrototypeOf(value) !== expectedPrototype) return false;
|
|
975
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
976
|
+
if (typeof key !== 'string') return false;
|
|
977
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
978
|
+
if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')
|
|
979
|
+
|| !visit(descriptor.value, depth + 1)) return false;
|
|
980
|
+
}
|
|
981
|
+
return true;
|
|
982
|
+
};
|
|
983
|
+
return visit(root);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function collectObjectGraph(roots) {
|
|
987
|
+
const references = new Set();
|
|
988
|
+
const visit = value => {
|
|
989
|
+
if (!value || typeof value !== 'object' || references.has(value)) return;
|
|
990
|
+
references.add(value);
|
|
991
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
992
|
+
if (typeof key !== 'string') continue;
|
|
993
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
994
|
+
if (descriptor && Object.prototype.hasOwnProperty.call(descriptor, 'value')) visit(descriptor.value);
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
for (const root of roots) visit(root);
|
|
998
|
+
return references;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function exerciseSyntheticEvaluator(runtime, add, file) {
|
|
1002
|
+
try {
|
|
1003
|
+
const input = vm.runInContext(
|
|
1004
|
+
'({ obligations: [], artifacts: {}, baseline: {}, executionRecords: [], reviewRecords: [] })',
|
|
1005
|
+
runtime.context,
|
|
1006
|
+
{ timeout: 1000 }
|
|
1007
|
+
);
|
|
1008
|
+
const result = runtime.evaluator.checkDocObligations(input);
|
|
1009
|
+
if (!result || !Array.isArray(result.diagnostics) || !Array.isArray(result.results)) throw new Error('bad result shape');
|
|
1010
|
+
return true;
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
add('EVALUATOR_SYNTHETIC_LOAD_FAILED', file, 'checkDocObligations', 'EVALUATOR_SYNTHETIC_CALL');
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function checkCallSites(source, callSites, file, add) {
|
|
1018
|
+
// This is an exact inventory of direct dot calls. Alias assignment/argument transfer is not
|
|
1019
|
+
// resolved here; such a weakened boundary is a C1 survivor and is reserved for C2 analysis.
|
|
1020
|
+
let ok = true;
|
|
1021
|
+
const expected = new Map();
|
|
1022
|
+
for (const site of callSites) {
|
|
1023
|
+
const key = `${site.receiver}.${site.method}`;
|
|
1024
|
+
expected.set(key, site.expectedCount);
|
|
1025
|
+
}
|
|
1026
|
+
const actual = new Map();
|
|
1027
|
+
const pattern = /\b(capture|obligationCapture)\s*\.\s*([A-Za-z_$][\w$]*)\s*\(/g;
|
|
1028
|
+
for (const match of source.matchAll(pattern)) {
|
|
1029
|
+
const key = `${match[1]}.${match[2]}`;
|
|
1030
|
+
actual.set(key, (actual.get(key) || 0) + 1);
|
|
1031
|
+
}
|
|
1032
|
+
if (/\b(?:capture|obligationCapture)\s*(?:\?\s*\.\s*)?\[/.test(source)) {
|
|
1033
|
+
add('CAPTURE_CALL_SITE_COMPUTED', file, 'capture[...]', 'CAPTURE_CALL_SITES');
|
|
1034
|
+
ok = false;
|
|
1035
|
+
}
|
|
1036
|
+
if (/\b(?:capture|obligationCapture)\s*\?\s*\./.test(source)
|
|
1037
|
+
|| /\b(?:capture|obligationCapture)\s*\.\s*[A-Za-z_$][\w$]*\s*\?\s*\.\s*\(/.test(source)) {
|
|
1038
|
+
add('CAPTURE_CALL_SITE_OPTIONAL', file, 'capture?.method()', 'CAPTURE_CALL_SITES');
|
|
1039
|
+
ok = false;
|
|
1040
|
+
}
|
|
1041
|
+
for (const key of new Set([...expected.keys(), ...actual.keys()])) {
|
|
1042
|
+
const count = actual.get(key) || 0;
|
|
1043
|
+
const expectedCount = expected.get(key);
|
|
1044
|
+
const site = callSites.find(item => `${item.receiver}.${item.method}` === key);
|
|
1045
|
+
if (expectedCount === undefined || count !== expectedCount) {
|
|
1046
|
+
add('CAPTURE_CALL_SITE_DRIFT', file, key, 'CAPTURE_CALL_SITES', site && site.exceptionId);
|
|
1047
|
+
ok = false;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return ok;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function checkCompatibilityLedger(sourceByKind, ledger, fileFor, add) {
|
|
1054
|
+
const requiredIds = ['COMP-C1', 'COMP-C2-EVAL', 'COMP-C2-BOUND', 'COMP-C3', 'COMP-C4',
|
|
1055
|
+
'COMP-C5', 'COMP-CONTAINER', 'COMP-METADATA', 'COMP-REQ'];
|
|
1056
|
+
let ok = sameStringSet(ledger.map(item => item.id), requiredIds);
|
|
1057
|
+
if (!ok) add('COMPATIBILITY_LEDGER_INCOMPLETE', '<policy>', 'compatibilityLedger', 'COMPATIBILITY_LEDGER_COMPLETE');
|
|
1058
|
+
for (const entry of ledger) {
|
|
1059
|
+
if (!entry.id || !Array.isArray(entry.sites) || entry.sites.length === 0
|
|
1060
|
+
|| !Array.isArray(entry.allowed) || !entry.allowed.length
|
|
1061
|
+
|| !Array.isArray(entry.forbidden) || !entry.forbidden.length) {
|
|
1062
|
+
add('COMPATIBILITY_LEDGER_INCOMPLETE', '<policy>', String(entry.id), 'COMPATIBILITY_LEDGER_COMPLETE', entry.id);
|
|
1063
|
+
ok = false;
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
for (const site of entry.sites) {
|
|
1067
|
+
const source = sourceByKind[site.module];
|
|
1068
|
+
if (!source || typeof site.anchor !== 'string' || !site.anchor
|
|
1069
|
+
|| countSubstring(source, site.anchor) !== site.expectedCount) {
|
|
1070
|
+
add('COMPATIBILITY_ANCHOR_DRIFT', site.module ? fileFor(site.module) : '<policy>',
|
|
1071
|
+
site.anchor || String(entry.id), 'COMPATIBILITY_ANCHORS_STABLE', entry.id);
|
|
1072
|
+
ok = false;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
return ok;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function validatePolicy(policy) {
|
|
1080
|
+
const problems = [];
|
|
1081
|
+
const requiredSchemas = ['string', 'boolean', 'sha256', 'captureEntry', 'captureSourceAbsence',
|
|
1082
|
+
'captureDestination', 'captureBaselinePath', 'capturedCandidate', 'baselineEntries', 'baselineAuditPairs'];
|
|
1083
|
+
if (!policy.modules || !Array.isArray(policy.modules.evaluatorExports)
|
|
1084
|
+
|| !Array.isArray(policy.modules.captureExports)
|
|
1085
|
+
|| !Array.isArray(policy.modules.evaluatorRequires)
|
|
1086
|
+
|| !Array.isArray(policy.modules.captureRequires)
|
|
1087
|
+
|| !Array.isArray(policy.modules.captureBuiltins)
|
|
1088
|
+
|| !policy.evaluatorContext || !Array.isArray(policy.evaluatorContext.handoffKeys)
|
|
1089
|
+
|| !Array.isArray(policy.evaluatorContext.consumerKeys)
|
|
1090
|
+
|| !policy.capabilities || !Array.isArray(policy.capabilities.invocation)
|
|
1091
|
+
|| !Array.isArray(policy.capabilities.obligation) || !isPlainRecord(policy.returnSchemas)
|
|
1092
|
+
|| !sameStringSet(Object.keys(policy.returnSchemas), requiredSchemas)
|
|
1093
|
+
|| !requiredSchemas.every(key => isUsableSchema(policy.returnSchemas[key]))
|
|
1094
|
+
|| !Array.isArray(policy.callSites) || !Array.isArray(policy.excludedReturnBranches)
|
|
1095
|
+
|| !policy.ownership || policy.ownership.callerInputIdentityEscapes !== 'forbidden-recursively'
|
|
1096
|
+
|| policy.ownership.returnedArrays !== 'fresh-detached-arrays'
|
|
1097
|
+
|| !sameStringArray(policy.ownership.repeatAndMutationProbe, ['baselineEntries', 'baselineAuditPairs'])
|
|
1098
|
+
|| !Array.isArray(policy.knownC1Limitations) || policy.knownC1Limitations.length < 2) {
|
|
1099
|
+
problems.push({ location: 'modules/context/capabilities/returnSchemas/callSites' });
|
|
1100
|
+
}
|
|
1101
|
+
if (!sameStringSet(policy.coverage, COVERAGE_IDS)) problems.push({ location: 'coverage' });
|
|
1102
|
+
for (const [distribution, files] of Object.entries(policy.distributions || {})) {
|
|
1103
|
+
if (!['canonical', 'packaged'].includes(distribution) || !files.evaluator || !files.capture) {
|
|
1104
|
+
problems.push({ location: `distributions.${distribution}` });
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
if (!['canonical', 'packaged'].every(name => policy.distributions && policy.distributions[name])) {
|
|
1108
|
+
problems.push({ location: 'distributions must declare canonical and packaged' });
|
|
1109
|
+
}
|
|
1110
|
+
if (policy.callSites && policy.callSites.some(site => !site || typeof site.receiver !== 'string'
|
|
1111
|
+
|| typeof site.method !== 'string' || !Number.isInteger(site.expectedCount) || site.expectedCount < 0)) {
|
|
1112
|
+
problems.push({ location: 'callSites entry schema' });
|
|
1113
|
+
}
|
|
1114
|
+
if (policy.compatibilityLedger && policy.compatibilityLedger.some(entry => !entry
|
|
1115
|
+
|| typeof entry.id !== 'string' || !Array.isArray(entry.sites)
|
|
1116
|
+
|| !Array.isArray(entry.allowed) || !Array.isArray(entry.forbidden))) {
|
|
1117
|
+
problems.push({ location: 'compatibilityLedger entry schema' });
|
|
1118
|
+
}
|
|
1119
|
+
if (!policy.excludedReturnBranches || policy.excludedReturnBranches.length !== 1
|
|
1120
|
+
|| policy.excludedReturnBranches[0].api !== 'capturedCandidate'
|
|
1121
|
+
|| policy.excludedReturnBranches[0].tag !== 'unusable'
|
|
1122
|
+
|| typeof policy.excludedReturnBranches[0].reason !== 'string') {
|
|
1123
|
+
problems.push({ location: 'excludedReturnBranches' });
|
|
1124
|
+
}
|
|
1125
|
+
if (policy.status !== 'frozen-for-c1') problems.push({ location: 'status must be frozen-for-c1' });
|
|
1126
|
+
return problems;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function countSubstring(text, needle) {
|
|
1130
|
+
let count = 0;
|
|
1131
|
+
let offset = 0;
|
|
1132
|
+
while ((offset = text.indexOf(needle, offset)) !== -1) {
|
|
1133
|
+
count++;
|
|
1134
|
+
offset += needle.length;
|
|
1135
|
+
}
|
|
1136
|
+
return count;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function isUsableSchema(schema) {
|
|
1140
|
+
if (!isPlainRecord(schema)) return false;
|
|
1141
|
+
if (schema.kind === 'primitive') {
|
|
1142
|
+
return ['string', 'boolean', 'undefined', 'sha256'].includes(schema.type);
|
|
1143
|
+
}
|
|
1144
|
+
if (schema.kind === 'tagged') {
|
|
1145
|
+
return typeof schema.tag === 'string' && schema.tag.length > 0
|
|
1146
|
+
&& Array.isArray(schema.variants) && schema.variants.length > 0
|
|
1147
|
+
&& schema.variants.every(variant => isPlainRecord(variant)
|
|
1148
|
+
&& typeof variant.value === 'string' && isPlainRecord(variant.fields)
|
|
1149
|
+
&& Object.prototype.hasOwnProperty.call(variant.fields, schema.tag)
|
|
1150
|
+
&& variant.fields[schema.tag] === `literal:${variant.value}`
|
|
1151
|
+
&& Object.values(variant.fields).every(isUsableFieldSpec));
|
|
1152
|
+
}
|
|
1153
|
+
if (schema.kind === 'array') {
|
|
1154
|
+
return isPlainRecord(schema.item) && Object.keys(schema.item).length > 0
|
|
1155
|
+
&& Object.values(schema.item).every(isUsableFieldSpec);
|
|
1156
|
+
}
|
|
1157
|
+
return schema.kind === 'pair-array';
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function isPlainRecord(value) {
|
|
1161
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
1162
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1163
|
+
return prototype === Object.prototype || prototype === null;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function isSha256(value) {
|
|
1167
|
+
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function isUsableFieldSpec(spec) {
|
|
1171
|
+
if (typeof spec !== 'string' || !spec) return false;
|
|
1172
|
+
if (spec.endsWith('?')) return isUsableFieldSpec(spec.slice(0, -1));
|
|
1173
|
+
return ['string', 'boolean', 'undefined', 'string-or-undefined', 'opaque-token', 'string-array']
|
|
1174
|
+
.includes(spec) || spec.startsWith('literal:');
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function sameStringSet(left, right) {
|
|
1178
|
+
return Array.isArray(left) && Array.isArray(right) && left.length === right.length
|
|
1179
|
+
&& unique(left).length === left.length && unique(right).length === right.length
|
|
1180
|
+
&& left.every(value => right.includes(value));
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function sameStringArray(left, right) {
|
|
1184
|
+
return Array.isArray(left) && Array.isArray(right) && left.length === right.length
|
|
1185
|
+
&& left.every((value, index) => value === right[index]);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function unique(values) {
|
|
1189
|
+
return [...new Set(values)];
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function escapeRegExp(text) {
|
|
1193
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (require.main === module && process.argv[2] === '--c1-test-fast-isolation') {
|
|
1197
|
+
// Direct test-only selection: exercise checkBoundary's child path with a short deadline for loop controls.
|
|
1198
|
+
let input;
|
|
1199
|
+
let result;
|
|
1200
|
+
try {
|
|
1201
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
1202
|
+
result = checkIsolationBoundary(input, C1_TEST_EXECUTION_TIMEOUT_MS);
|
|
1203
|
+
} catch {
|
|
1204
|
+
result = failClosedResult(input, 'CHECKER_FAIL_CLOSED');
|
|
1205
|
+
}
|
|
1206
|
+
process.stdout.write(JSON.stringify(result));
|
|
1207
|
+
} else if (require.main === module && process.argv[2] === '--c1-isolation-child') {
|
|
1208
|
+
let input;
|
|
1209
|
+
let result;
|
|
1210
|
+
try {
|
|
1211
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
1212
|
+
result = runBoundaryCheck(input);
|
|
1213
|
+
} catch {
|
|
1214
|
+
result = failClosedResult(input, 'CHECKER_FAIL_CLOSED');
|
|
1215
|
+
}
|
|
1216
|
+
process.stdout.write(JSON.stringify(result));
|
|
1217
|
+
} else if (require.main === module && process.argv[2] === '--c1-timeout-kill-test') {
|
|
1218
|
+
// Test-only selection: checkBoundary never forwards caller data to this CLI argument.
|
|
1219
|
+
const child = spawnC1Child(['--c1-sigterm-resistant-test-child'], undefined, C1_TEST_EXECUTION_TIMEOUT_MS);
|
|
1220
|
+
const ignoredSigterm = typeof child.stdout === 'string' && child.stdout.includes('C1_SIGTERM_IGNORED');
|
|
1221
|
+
// POSIX reports the delivered signal. Windows maps SIGKILL to immediate process termination.
|
|
1222
|
+
const killedBySigkill = process.platform === 'win32' || child.signal === 'SIGKILL';
|
|
1223
|
+
const result = child.error && child.error.code === 'ETIMEDOUT' && killedBySigkill && !ignoredSigterm
|
|
1224
|
+
? failClosedResult(null, 'CHECKER_EXECUTION_TIMEOUT')
|
|
1225
|
+
: failClosedResult(null, 'CHECKER_EXECUTION_FAILED');
|
|
1226
|
+
process.stdout.write(JSON.stringify(result));
|
|
1227
|
+
} else if (require.main === module && process.argv[2] === '--c1-sigterm-resistant-test-child') {
|
|
1228
|
+
// Finite CPU work keeps this child alive beyond C1's timeout while allowing Node to deliver
|
|
1229
|
+
// and ignore SIGTERM between busy turns. It exits naturally if the kill-signal control regresses.
|
|
1230
|
+
process.on('SIGTERM', () => process.stdout.write('C1_SIGTERM_IGNORED\n'));
|
|
1231
|
+
const stopAt = Date.now() + C1_TEST_EXECUTION_TIMEOUT_MS + 1500;
|
|
1232
|
+
const busyTurn = () => {
|
|
1233
|
+
const turnEndsAt = Date.now() + 20;
|
|
1234
|
+
while (Date.now() < turnEndsAt) { /* intentional bounded busy loop */ }
|
|
1235
|
+
if (Date.now() < stopAt) setImmediate(busyTurn);
|
|
1236
|
+
};
|
|
1237
|
+
busyTurn();
|
|
1238
|
+
} else {
|
|
1239
|
+
module.exports = { checkBoundary };
|
|
1240
|
+
}
|