@clear-capabilities/agentic-security-scanner 0.161.0 → 0.163.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 +46 -0
- package/bin/agentic-security.js +98 -2
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +59 -8
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -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 +2336 -27
- package/dist/957.index.js +1 -1
- package/dist/{1905.index.js → 9999.index.js} +306 -13
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +39 -7
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +29 -0
- package/src/dataflow/catalog.js +21 -0
- package/src/dataflow/engine.js +20 -0
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +18 -0
- package/src/discovery/index.js +43 -1
- package/src/discovery/lenses.js +18 -0
- package/src/discovery/memory.js +7 -5
- package/src/engine.js +80 -15
- package/src/fix/apply-fix-service.js +33 -3
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +14 -3
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +30 -5
- package/src/posture/CLAUDE.md +505 -0
- package/src/posture/accuracy-scorecard.js +95 -4
- 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/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -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/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- 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/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +531 -0
- package/src/posture/oracles/conformance.js +276 -0
- package/src/posture/oracles/oracle.js +453 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +250 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -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 +226 -0
- package/src/posture/verification/patch-promotion.js +113 -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 +100 -1
- package/src/sandbox/CLAUDE.md +51 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +151 -34
- 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
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { applyMeasuredTrust } from './model-trust.js';
|
|
2
|
+
import { featureStatus } from './assurance/config.js';
|
|
3
|
+
import { buildRoutingTask } from './routing/outcomes.js';
|
|
4
|
+
import { routeConstrained } from './routing/decide.js';
|
|
5
|
+
import { resolveRoutingControl } from './routing/control.js';
|
|
2
6
|
// Capability-based model routing for cost-sensitive subagent dispatch.
|
|
3
7
|
//
|
|
4
8
|
// A declarative CWE/severity → model policy. When the orchestrator is about to
|
|
@@ -131,6 +135,42 @@ export function routeModelWithTrust(finding, ledger = null) {
|
|
|
131
135
|
);
|
|
132
136
|
}
|
|
133
137
|
|
|
138
|
+
// X-604: constrained, calibrated routing, behind the `model-routing` feature (off by default).
|
|
139
|
+
//
|
|
140
|
+
// With the feature off (or no configuration given) this returns EXACTLY what `routeModelWithTrust` returns, the same object shape, so
|
|
141
|
+
// nothing existing changes (test/routing/unchanged-when-off.test.js pins the outputs). With it on, the capability route above becomes
|
|
142
|
+
// the BASELINE: capability, privacy, context, minimum-quality and budget constraints are applied first (routing/decide.js), cost or
|
|
143
|
+
// latency is optimised only among compliant candidates, and the decision carries its explanation. A blocked decision selects nothing:
|
|
144
|
+
// `model` is null and `blocked` is true, so a caller can never mistake it for a route.
|
|
145
|
+
const EXT_LANGUAGE = { js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python', java: 'java', kt: 'kotlin', go: 'go', rb: 'ruby', php: 'php', cs: 'csharp', rs: 'rust', hs: 'haskell', nix: 'nix' };
|
|
146
|
+
function languageOfFinding(finding) {
|
|
147
|
+
const m = typeof finding?.file === 'string' ? finding.file.match(/\.([A-Za-z0-9]+)$/) : null;
|
|
148
|
+
return (m && EXT_LANGUAGE[m[1].toLowerCase()]) || 'unknown';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function routeModelWithPolicy(finding, ctx = {}) {
|
|
152
|
+
const baseline = routeModelWithTrust(finding, ctx.ledger || null);
|
|
153
|
+
if (!ctx.config || featureStatus(ctx.config, 'model-routing').status !== 'ok') return baseline;
|
|
154
|
+
// X-608.AC03: the operator can pin or disable adaptive routing. Disabled leaves the capability route exactly as it was; a pin chooses
|
|
155
|
+
// that model without optimisation (sending a prompt still passes the egress policy in the guarded adapter). A bad value fails closed.
|
|
156
|
+
const control = ctx.routingControl || resolveRoutingControl({ env: ctx.env || process.env, options: ctx.routingOptions || {} });
|
|
157
|
+
if (control.mode === 'disabled') return { ...baseline, routingControl: control };
|
|
158
|
+
if (control.mode === 'pinned') return { model: control.pin, effort: baseline.effort, reason: control.reason, trust: baseline.trust, baseline, pinned: true, routingControl: control };
|
|
159
|
+
const built = buildRoutingTask({
|
|
160
|
+
taskId: ctx.taskId || finding?.stableId || finding?.id || 'unidentified-task', taskKind: ctx.taskKind || 'repair',
|
|
161
|
+
language: ctx.language || languageOfFinding(finding), vulnClass: parseCwe(finding?.cwe) || 'no-cwe',
|
|
162
|
+
contextTokens: ctx.contextTokens ?? 0, requiredCapabilities: ctx.requiredCapabilities || [], dataClass: ctx.dataClass || 'source-code',
|
|
163
|
+
synthetic: ctx.synthetic === true,
|
|
164
|
+
});
|
|
165
|
+
if (!built.ok) return { model: null, effort: null, blocked: true, reason: `routing task is invalid: ${built.errors.map((e) => e.path).join(', ')}`, trust: baseline.trust, baseline };
|
|
166
|
+
const decision = routeConstrained({
|
|
167
|
+
task: built.task, candidates: ctx.candidates || [], calibration: ctx.calibration || null, policy: ctx.policy, now: ctx.now,
|
|
168
|
+
baselineRoute: baseline, egress: ctx.egress, scanRoot: ctx.scanRoot || null, manifestEgress: ctx.manifestEgress || null, allowSynthetic: ctx.allowSynthetic === true,
|
|
169
|
+
});
|
|
170
|
+
if (decision.status === 'blocked') return { model: null, effort: null, blocked: true, reason: decision.reason, trust: baseline.trust, baseline, decision };
|
|
171
|
+
return { model: decision.selected.model, effort: baseline.effort, reason: decision.reason, trust: baseline.trust, baseline, decision, constrained: decision.status === 'routed' };
|
|
172
|
+
}
|
|
173
|
+
|
|
134
174
|
// Route a list of findings. Returns [{ finding, model, effort, reason }, …].
|
|
135
175
|
export function routeModelForFindings(findings) {
|
|
136
176
|
const list = Array.isArray(findings) ? findings : [];
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
// The five class-specific oracle adapters (X-202.AC01).
|
|
2
|
+
//
|
|
3
|
+
// Each adapter is data plus three small functions, all verifier-authored:
|
|
4
|
+
// harnessSource the script the boundary runs. It imports the target, drives it
|
|
5
|
+
// with a benign CONTROL input first and the hostile input second,
|
|
6
|
+
// and writes a result document. It never decides a verdict.
|
|
7
|
+
// prepare builds the harness input from the request inputs plus a
|
|
8
|
+
// per-run token the target cannot know in advance.
|
|
9
|
+
// interpret forms `{ satisfied, preconditionsHeld, observed, reason }` from
|
|
10
|
+
// what the run left behind, read by the verifier after the whole
|
|
11
|
+
// process tree is dead. `satisfied: true` means the hypothesised
|
|
12
|
+
// effect was observed; `false` with `preconditionsHeld: true`
|
|
13
|
+
// means it was not observed although the control proved the
|
|
14
|
+
// target was exercised (a refutation); anything else leaves the
|
|
15
|
+
// verdict open.
|
|
16
|
+
//
|
|
17
|
+
// Changing what an adapter asserts means bumping its `version`: the version and
|
|
18
|
+
// the harness text feed `logicDigest`, which replay manifests pin.
|
|
19
|
+
//
|
|
20
|
+
// Platform statements are about what this repository's tests have executed. The
|
|
21
|
+
// development host is macOS, so Linux is declared `unverified` everywhere: on
|
|
22
|
+
// Linux the boundary currently refuses to run (read denial and supervised
|
|
23
|
+
// tree termination are not implemented on the namespace backend), which the
|
|
24
|
+
// runner reports as `unsupported`, and no Linux outcome is claimed.
|
|
25
|
+
import { defineOracle } from './oracle.js';
|
|
26
|
+
import crypto from 'node:crypto';
|
|
27
|
+
import { digestOf } from '../assurance/identity.js';
|
|
28
|
+
import { expressionProblems } from '../invariants/expressions.js';
|
|
29
|
+
import { evaluateAssertions, sanitizeSnapshot, eventSequence, hasDurableEffect } from '../invariants/state-assertions.js';
|
|
30
|
+
|
|
31
|
+
const PLATFORMS = Object.freeze({
|
|
32
|
+
darwin: { status: 'supported', note: 'executed by this repository\'s tests on the macOS development host (userspace confinement backend)' },
|
|
33
|
+
linux: { status: 'unverified', note: 'no Linux outcome is claimed: the boundary refuses to run on the namespace backend until read denial and supervised tree termination are implemented and executed there; the run reports unsupported' },
|
|
34
|
+
win32: { status: 'unsupported', note: 'no confinement backend exists for Windows' },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const COMMON_BUDGETS = Object.freeze({ timeoutMs: 8000, graceMs: 1000, maxOutputBytes: 65536, maxFileBytes: 262144, maxFiles: 32 });
|
|
38
|
+
|
|
39
|
+
const PROLOGUE = `import fs from 'node:fs';
|
|
40
|
+
const INPUT = __INPUT__;
|
|
41
|
+
const ENTRY = __ENTRY__;
|
|
42
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
43
|
+
const text = (e) => String((e && e.message) || e).slice(0, 160);
|
|
44
|
+
const out = {};
|
|
45
|
+
function finish(extra) {
|
|
46
|
+
Object.assign(out, extra);
|
|
47
|
+
try { fs.writeFileSync('__oracle_result.json', JSON.stringify(out)); } catch {}
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
let mod;
|
|
51
|
+
try { mod = await import('./' + ENTRY); } catch (e) { finish({ loadFailed: true }); }
|
|
52
|
+
const fn = mod && mod[INPUT.export];
|
|
53
|
+
if (typeof fn !== 'function') finish({ loadFailed: true });
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const isId = (s) => typeof s === 'string' && /^[A-Za-z_$][\w$]{0,63}$/.test(s);
|
|
57
|
+
const isStr = (s, max = 2000) => typeof s === 'string' && s.length > 0 && s.length <= max;
|
|
58
|
+
|
|
59
|
+
// ------------------------------------------------------------------ injection execution
|
|
60
|
+
|
|
61
|
+
const injection = defineOracle({
|
|
62
|
+
id: 'injection-execution', class: 'injection-execution', version: '1',
|
|
63
|
+
description: 'Does attacker-controlled input reach a command interpreter and run? The harness calls the target with a benign control, then with a payload whose effect is a token-named file; the verifier looks for that file after the process tree is dead.',
|
|
64
|
+
prerequisites: ['node-runtime', 'confinement-backend', 'posix-shell'],
|
|
65
|
+
platforms: PLATFORMS, budgets: COMMON_BUDGETS,
|
|
66
|
+
negativeControls: [{ id: 'argv-array-call', description: 'the same handler passing the input as a separate argv element (no shell) must not produce the effect', expectedOutcome: 'refuted' }],
|
|
67
|
+
fixtures: { dir: 'test/fixtures/oracles/injection-execution', positive: 'positive', negative: 'negative', inconclusive: 'inconclusive' },
|
|
68
|
+
limitations: ['covers command injection through a shell only; SQL, template and code injection are not asserted here', 'the target is imported into the harness process, so it runs with the harness; a target written to recognise the payload and forge the effect file is not distinguishable from a real injection', 'asynchronous handlers that spawn without being awaited are given settleMs to finish, not guaranteed to'],
|
|
69
|
+
harnessSource: `${PROLOGUE}
|
|
70
|
+
try { await Promise.resolve(fn(INPUT.benign)); out.controlOk = true; } catch (e) { out.controlOk = false; }
|
|
71
|
+
if (out.controlOk) {
|
|
72
|
+
try { await Promise.resolve(fn(INPUT.attack)); out.attackReturned = true; } catch (e) { out.attackReturned = false; }
|
|
73
|
+
await sleep(INPUT.settleMs);
|
|
74
|
+
}
|
|
75
|
+
finish({});
|
|
76
|
+
`,
|
|
77
|
+
validateInputs(i) {
|
|
78
|
+
if (!isId(i.export)) return 'export must be an identifier';
|
|
79
|
+
if (!isStr(i.benign, 500)) return 'benign must be a short string';
|
|
80
|
+
if (!isStr(i.attackTemplate, 500) || !i.attackTemplate.includes('{TOKEN}')) return 'attackTemplate must be a short string containing {TOKEN}';
|
|
81
|
+
if (i.settleMs !== undefined && !(Number.isInteger(i.settleMs) && i.settleMs >= 0 && i.settleMs <= 2000)) return 'settleMs must be 0 to 2000';
|
|
82
|
+
return null;
|
|
83
|
+
},
|
|
84
|
+
prepare: (i, token) => ({ export: i.export, benign: i.benign, attack: i.attackTemplate.split('{TOKEN}').join(token), settleMs: i.settleMs ?? 300 }),
|
|
85
|
+
interpret({ token, result, read }) {
|
|
86
|
+
const effect = read(`effect-${token}`, 256) === token;
|
|
87
|
+
const controlOk = result?.controlOk === true;
|
|
88
|
+
const observed = { effectObserved: effect, controlCompleted: controlOk, targetLoaded: result ? result.loadFailed !== true : null };
|
|
89
|
+
if (effect) return { satisfied: true, preconditionsHeld: true, observed, reason: 'the injected command ran: its token-named effect file exists' };
|
|
90
|
+
if (controlOk) return { satisfied: false, preconditionsHeld: true, observed, reason: 'the handler ran its benign control and the injected command left no effect' };
|
|
91
|
+
return { satisfied: null, preconditionsHeld: false, observed, reason: result?.loadFailed ? 'the target failed to load, so the payload was never delivered' : 'the benign control did not complete, so the payload was never delivered' };
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ------------------------------------------------------------------ authorization decision
|
|
96
|
+
|
|
97
|
+
const DECISIONS = ['allow', 'deny', 'error', 'indeterminate'];
|
|
98
|
+
|
|
99
|
+
const authorization = defineOracle({
|
|
100
|
+
id: 'authorization-decision', class: 'authorization-decision', version: '1',
|
|
101
|
+
description: 'Does an authorization function allow an actor it should deny? The harness evaluates declared control cases (the owner, expected allow) and attack cases (another tenant or a lower role, expected deny); the verifier recomputes the verdict from the raw decisions.',
|
|
102
|
+
prerequisites: ['node-runtime', 'confinement-backend'],
|
|
103
|
+
platforms: PLATFORMS, budgets: COMMON_BUDGETS,
|
|
104
|
+
negativeControls: [{ id: 'tenant-checked', description: 'the same function comparing tenant ids must deny the cross-tenant attack case', expectedOutcome: 'refuted' }],
|
|
105
|
+
fixtures: { dir: 'test/fixtures/oracles/authorization-decision', positive: 'positive', negative: 'negative', inconclusive: 'inconclusive' },
|
|
106
|
+
limitations: ['asserts the decision function in isolation; a route or middleware that enforces authorization elsewhere is not exercised', 'the cases describe the scenario; the oracle does not infer which actors should be denied', 'a thrown error is not read as a denial'],
|
|
107
|
+
harnessSource: `${PROLOGUE}
|
|
108
|
+
const norm = (r) => (r === true || r === 'allow' || (r && r.allowed === true)) ? 'allow'
|
|
109
|
+
: (r === false || r === 'deny' || (r && r.allowed === false)) ? 'deny' : 'indeterminate';
|
|
110
|
+
const decisions = {};
|
|
111
|
+
for (const c of INPUT.cases) {
|
|
112
|
+
try { decisions[c.id] = norm(await Promise.resolve(fn(...c.args))); } catch (e) { decisions[c.id] = 'error'; }
|
|
113
|
+
}
|
|
114
|
+
finish({ decisions });
|
|
115
|
+
`,
|
|
116
|
+
validateInputs(i) {
|
|
117
|
+
if (!isId(i.export)) return 'export must be an identifier';
|
|
118
|
+
if (!Array.isArray(i.cases) || i.cases.length < 2 || i.cases.length > 16) return 'cases must hold 2 to 16 entries';
|
|
119
|
+
const ids = new Set();
|
|
120
|
+
for (const c of i.cases) {
|
|
121
|
+
if (!c || !isId(String(c.id).replace(/-/g, '_')) || ids.has(c.id)) return 'every case needs a unique id';
|
|
122
|
+
ids.add(c.id);
|
|
123
|
+
if (!['control', 'attack'].includes(c.role)) return 'case role must be control or attack';
|
|
124
|
+
if (!Array.isArray(c.args) || c.args.length > 6) return 'case args must be an array of at most 6';
|
|
125
|
+
}
|
|
126
|
+
if (!i.cases.some((c) => c.role === 'control') || !i.cases.some((c) => c.role === 'attack')) return 'at least one control and one attack case are required';
|
|
127
|
+
return null;
|
|
128
|
+
},
|
|
129
|
+
prepare: (i) => ({ export: i.export, cases: i.cases.map((c) => ({ id: c.id, args: c.args })) }),
|
|
130
|
+
interpret({ inputs, result }) {
|
|
131
|
+
const got = result && result.decisions && typeof result.decisions === 'object' ? result.decisions : null;
|
|
132
|
+
if (!got || result.loadFailed) return { satisfied: null, preconditionsHeld: false, observed: { targetLoaded: false }, reason: 'the target did not report decisions' };
|
|
133
|
+
const decisions = {};
|
|
134
|
+
for (const c of inputs.cases) decisions[c.id] = DECISIONS.includes(got[c.id]) ? got[c.id] : 'indeterminate';
|
|
135
|
+
const controls = inputs.cases.filter((c) => c.role === 'control');
|
|
136
|
+
const attacks = inputs.cases.filter((c) => c.role === 'attack');
|
|
137
|
+
const controlsHeld = controls.every((c) => decisions[c.id] === 'allow');
|
|
138
|
+
const allowed = attacks.filter((c) => decisions[c.id] === 'allow').map((c) => c.id);
|
|
139
|
+
const allDenied = attacks.every((c) => decisions[c.id] === 'deny');
|
|
140
|
+
const observed = { decisions, controlsAllowed: controlsHeld, attackCasesAllowed: allowed };
|
|
141
|
+
if (allowed.length) return { satisfied: true, preconditionsHeld: controlsHeld, observed, reason: `the function allowed attack case(s): ${allowed.join(', ')}` };
|
|
142
|
+
if (allDenied && controlsHeld) return { satisfied: false, preconditionsHeld: true, observed, reason: 'every attack case was denied and every control case was allowed' };
|
|
143
|
+
return { satisfied: null, preconditionsHeld: false, observed, reason: controlsHeld ? 'an attack case returned neither allow nor deny' : 'a control case was not allowed, so the function was not shown to be deciding' };
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ------------------------------------------------------------------ state transition
|
|
148
|
+
|
|
149
|
+
const stateTransition = defineOracle({
|
|
150
|
+
id: 'state-transition', class: 'state-transition', version: '1',
|
|
151
|
+
description: 'Does a state machine accept an event sequence that must be refused? The harness drives a fresh machine through the legal sequence (control) and a fresh one through the attack sequence; the verifier compares the final states.',
|
|
152
|
+
prerequisites: ['node-runtime', 'confinement-backend'],
|
|
153
|
+
platforms: PLATFORMS, budgets: COMMON_BUDGETS,
|
|
154
|
+
negativeControls: [{ id: 'payment-required', description: 'the same machine refusing shipment before payment must not reach the forbidden state', expectedOutcome: 'refuted' }],
|
|
155
|
+
fixtures: { dir: 'test/fixtures/oracles/state-transition', positive: 'positive', negative: 'negative', inconclusive: 'inconclusive' },
|
|
156
|
+
limitations: ['covers one factory and finite event lists; it is not a model checker', 'durable effects outside the machine object (a database row, a queued job) are not observed', 'the forbidden state is stated by the scenario; the oracle does not infer business rules'],
|
|
157
|
+
harnessSource: `${PROLOGUE}
|
|
158
|
+
async function drive(events) {
|
|
159
|
+
const m = await Promise.resolve(fn());
|
|
160
|
+
const accepted = [];
|
|
161
|
+
for (const e of events) {
|
|
162
|
+
try { const r = await Promise.resolve(m.send(e)); accepted.push(r !== false); } catch (err) { accepted.push(false); }
|
|
163
|
+
}
|
|
164
|
+
return { state: String(m.state).slice(0, 80), accepted };
|
|
165
|
+
}
|
|
166
|
+
try { const c = await drive(INPUT.legal); out.controlState = c.state; } catch (e) { out.controlState = null; }
|
|
167
|
+
try { const a = await drive(INPUT.attack); out.attackState = a.state; out.attackAccepted = a.accepted; } catch (e) { out.attackState = null; }
|
|
168
|
+
finish({});
|
|
169
|
+
`,
|
|
170
|
+
validateInputs(i) {
|
|
171
|
+
if (!isId(i.factory)) return 'factory must be an identifier';
|
|
172
|
+
const evs = (a) => Array.isArray(a) && a.length >= 1 && a.length <= 16 && a.every((e) => isStr(e, 64));
|
|
173
|
+
if (!evs(i.legal) || !evs(i.attack)) return 'legal and attack must be lists of 1 to 16 short event names';
|
|
174
|
+
if (!isStr(i.expectedFinal, 64) || !isStr(i.forbiddenState, 64)) return 'expectedFinal and forbiddenState are required';
|
|
175
|
+
return null;
|
|
176
|
+
},
|
|
177
|
+
prepare: (i) => ({ export: i.factory, legal: i.legal, attack: i.attack }),
|
|
178
|
+
interpret({ inputs, result }) {
|
|
179
|
+
if (!result || result.loadFailed) return { satisfied: null, preconditionsHeld: false, observed: { targetLoaded: false }, reason: 'the target failed to load' };
|
|
180
|
+
const controlHeld = result.controlState === inputs.expectedFinal;
|
|
181
|
+
const reached = result.attackState === inputs.forbiddenState;
|
|
182
|
+
const observed = { controlState: result.controlState ?? null, controlReachedExpected: controlHeld, attackState: result.attackState ?? null, attackAccepted: Array.isArray(result.attackAccepted) ? result.attackAccepted.slice(0, 16).map((b) => b === true) : [] };
|
|
183
|
+
if (reached) return { satisfied: true, preconditionsHeld: controlHeld, observed, reason: `the attack sequence reached the forbidden state '${inputs.forbiddenState}'` };
|
|
184
|
+
if (controlHeld) return { satisfied: false, preconditionsHeld: true, observed, reason: 'the legal sequence reached its expected state and the attack sequence did not reach the forbidden one' };
|
|
185
|
+
return { satisfied: null, preconditionsHeld: false, observed, reason: 'the legal sequence did not reach its expected state, so the machine was not shown to be working' };
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ------------------------------------------------------------------ side-effect reachability
|
|
190
|
+
|
|
191
|
+
const sideEffect = defineOracle({
|
|
192
|
+
id: 'side-effect-reachability', class: 'side-effect-reachability', version: '1',
|
|
193
|
+
description: 'Does attacker-chosen input reach a privileged side-effecting operation? The harness injects a recording stand-in for the sink, calls the handler with a benign value (control) and an attacker value, and the verifier checks which values reached the sink.',
|
|
194
|
+
prerequisites: ['node-runtime', 'confinement-backend'],
|
|
195
|
+
platforms: PLATFORMS, budgets: COMMON_BUDGETS,
|
|
196
|
+
negativeControls: [{ id: 'allowlisted-target', description: 'the same handler with a destination allowlist must not pass the attacker value to the sink', expectedOutcome: 'refuted' }],
|
|
197
|
+
fixtures: { dir: 'test/fixtures/oracles/side-effect-reachability', positive: 'positive', negative: 'negative', inconclusive: 'inconclusive' },
|
|
198
|
+
limitations: ['the sink is a recording stand-in injected as the handler\'s second argument; handlers that import their sink directly are not covered', 'reaching the sink shows the operation would be invoked with the attacker value, not that the real operation would succeed', 'no network or privileged call is ever made'],
|
|
199
|
+
harnessSource: `${PROLOGUE}
|
|
200
|
+
const calls = [];
|
|
201
|
+
const deps = { [INPUT.sink]: (...args) => { try { calls.push(JSON.stringify(args).slice(0, 600)); } catch {} return Promise.resolve({ ok: true, status: 200, text: async () => '' }); } };
|
|
202
|
+
const reached = (v) => calls.some((c) => c.includes(JSON.stringify(v).slice(1, -1)));
|
|
203
|
+
try { await Promise.resolve(fn(INPUT.benignValue, deps)); } catch (e) {}
|
|
204
|
+
out.benignReached = reached(INPUT.benignValue);
|
|
205
|
+
calls.length = 0;
|
|
206
|
+
try { await Promise.resolve(fn(INPUT.attackerValue, deps)); } catch (e) {}
|
|
207
|
+
out.attackReached = reached(INPUT.attackerValue);
|
|
208
|
+
finish({});
|
|
209
|
+
`,
|
|
210
|
+
validateInputs(i) {
|
|
211
|
+
if (!isId(i.export) || !isId(i.sink)) return 'export and sink must be identifiers';
|
|
212
|
+
if (!isStr(i.benignValue, 500) || !isStr(i.attackerValue, 500) || i.benignValue === i.attackerValue) return 'benignValue and attackerValue must be distinct short strings';
|
|
213
|
+
return null;
|
|
214
|
+
},
|
|
215
|
+
prepare: (i) => ({ export: i.export, sink: i.sink, benignValue: i.benignValue, attackerValue: i.attackerValue }),
|
|
216
|
+
interpret({ result }) {
|
|
217
|
+
if (!result || result.loadFailed) return { satisfied: null, preconditionsHeld: false, observed: { targetLoaded: false }, reason: 'the target failed to load' };
|
|
218
|
+
const benign = result.benignReached === true;
|
|
219
|
+
const attack = result.attackReached === true;
|
|
220
|
+
const observed = { benignReachedSink: benign, attackerValueReachedSink: attack };
|
|
221
|
+
if (attack) return { satisfied: true, preconditionsHeld: benign, observed, reason: 'the attacker-chosen value reached the side-effecting sink' };
|
|
222
|
+
if (benign) return { satisfied: false, preconditionsHeld: true, observed, reason: 'the benign value reached the sink and the attacker value did not' };
|
|
223
|
+
return { satisfied: null, preconditionsHeld: false, observed, reason: 'even the benign value never reached the sink, so the handler was not shown to use it' };
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ------------------------------------------------------------------ parser / resource behaviour
|
|
228
|
+
|
|
229
|
+
const parserResource = defineOracle({
|
|
230
|
+
id: 'parser-resource', class: 'parser-resource', version: '1',
|
|
231
|
+
description: 'Does a parser or matcher exhaust its budget on a bounded hostile input? The harness completes a benign control (marker file), then feeds the hostile input; the verifier\'s own supervisor deadline is the measurement, so no self-reported timing is trusted.',
|
|
232
|
+
prerequisites: ['node-runtime', 'confinement-backend'],
|
|
233
|
+
platforms: PLATFORMS, budgets: { ...COMMON_BUDGETS, timeoutMs: 5000 },
|
|
234
|
+
negativeControls: [{ id: 'linear-pattern', description: 'the same matcher written without nested quantifiers must finish the hostile input inside the budget', expectedOutcome: 'refuted' }],
|
|
235
|
+
fixtures: { dir: 'test/fixtures/oracles/parser-resource', positive: 'positive', negative: 'negative', inconclusive: 'inconclusive' },
|
|
236
|
+
limitations: ['the hostile input is bounded (at most 64 KiB) and the run is cut at the supervisor deadline; this shows a budget was exceeded, not how large the blow-up is', 'a hard crash without a deadline or output-cap hit is left inconclusive rather than called exhaustion', 'memory is not measured: address-space limits are not enforceable on the macOS backend'],
|
|
237
|
+
harnessSource: `${PROLOGUE}
|
|
238
|
+
const mark = (name) => { try { fs.writeFileSync(name, INPUT.token); } catch {} };
|
|
239
|
+
try { await Promise.resolve(fn(INPUT.benign)); mark('__oracle_benign_done'); } catch (e) { finish({ benignFailed: true }); }
|
|
240
|
+
try { await Promise.resolve(fn(INPUT.hostile)); } catch (e) {}
|
|
241
|
+
mark('__oracle_hostile_done');
|
|
242
|
+
finish({});
|
|
243
|
+
`,
|
|
244
|
+
validateInputs(i) {
|
|
245
|
+
if (!isId(i.export)) return 'export must be an identifier';
|
|
246
|
+
if (!isStr(i.benign, 500)) return 'benign must be a short string';
|
|
247
|
+
const h = i.hostile;
|
|
248
|
+
if (!h || !isStr(h.unit, 16) || !Number.isInteger(h.count) || h.count < 1 || h.count > 65536 || (h.suffix !== undefined && !isStr(h.suffix, 16))) return 'hostile needs unit (short), count (1 to 65536) and an optional short suffix';
|
|
249
|
+
return null;
|
|
250
|
+
},
|
|
251
|
+
prepare: (i, token) => ({ export: i.export, benign: i.benign, hostile: i.hostile.unit.repeat(i.hostile.count) + (i.hostile.suffix || ''), token }),
|
|
252
|
+
interpret({ token, run, read }) {
|
|
253
|
+
const benignDone = read('__oracle_benign_done', 128) === token;
|
|
254
|
+
const hostileDone = read('__oracle_hostile_done', 128) === token;
|
|
255
|
+
const observed = { benignCompleted: benignDone, hostileCompleted: hostileDone, deadlineHit: run.timedOut, outputCapHit: run.outputCapped };
|
|
256
|
+
if (!benignDone) return { satisfied: null, preconditionsHeld: false, observed, reason: 'the benign control did not complete, so the target was not shown to be working' };
|
|
257
|
+
if (hostileDone) return { satisfied: false, preconditionsHeld: true, observed, reason: 'the hostile input was handled inside the budget' };
|
|
258
|
+
if (run.timedOut || run.outputCapped) return { satisfied: true, preconditionsHeld: true, observed, reason: run.timedOut ? 'the hostile input exhausted the time budget (the supervisor deadline fired)' : 'the hostile input exhausted the output budget' };
|
|
259
|
+
return { satisfied: null, preconditionsHeld: true, observed, reason: 'the run ended without finishing the hostile input and without hitting a budget, so the cause is unknown' };
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ------------------------------------------------------------------ replay / idempotency (X-205)
|
|
264
|
+
|
|
265
|
+
const replayIdempotency = defineOracle({
|
|
266
|
+
id: 'replay-idempotency', class: 'replay-idempotency', version: '1',
|
|
267
|
+
description: 'Does delivering the same request again repeat its side effect? The harness builds a fresh handler around a recording stand-in for the effect, delivers a request and then a DISTINCT request (control: one effect each), then on a second fresh handler delivers the same request repeatedly; the verifier counts the effects.',
|
|
268
|
+
prerequisites: ['node-runtime', 'confinement-backend'],
|
|
269
|
+
platforms: PLATFORMS, budgets: COMMON_BUDGETS,
|
|
270
|
+
negativeControls: [{ id: 'idempotency-key-honoured', description: 'the same handler remembering the idempotency key must produce exactly one effect for the repeated request', expectedOutcome: 'refuted' }],
|
|
271
|
+
fixtures: { dir: 'test/fixtures/oracles/replay-idempotency', positive: 'positive', negative: 'negative', inconclusive: 'inconclusive' },
|
|
272
|
+
limitations: ['the target is a factory that receives a recording stand-in for its effect and returns a handler; code that imports its effect directly is not covered', 'deliveries are sequential: a race between concurrent deliveries is not exercised', 'durable deduplication in an external store is not observed, only the handler instance the factory returns', 'reaching the effect twice shows the handler would repeat it, not that the real operation would succeed'],
|
|
273
|
+
harnessSource: `${PROLOGUE}
|
|
274
|
+
const mk = () => { const calls = []; return { calls, deps: { [INPUT.sink]: (...a) => { calls.push(1); return Promise.resolve({ ok: true }); } } }; };
|
|
275
|
+
async function build() { const m = mk(); const h = await Promise.resolve(fn(m.deps)); if (typeof h !== 'function') throw new Error('not a handler'); return { m, h }; }
|
|
276
|
+
try {
|
|
277
|
+
const c = await build();
|
|
278
|
+
try { await Promise.resolve(c.h(INPUT.request)); } catch (e) {}
|
|
279
|
+
out.firstEffects = c.m.calls.length;
|
|
280
|
+
try { await Promise.resolve(c.h(INPUT.distinctRequest)); } catch (e) {}
|
|
281
|
+
out.distinctEffects = c.m.calls.length;
|
|
282
|
+
} catch (e) { out.controlFailed = true; }
|
|
283
|
+
try {
|
|
284
|
+
const a = await build();
|
|
285
|
+
for (let i = 0; i < INPUT.deliveries; i++) { try { await Promise.resolve(a.h(INPUT.request)); } catch (e) {} }
|
|
286
|
+
out.replayEffects = a.m.calls.length;
|
|
287
|
+
} catch (e) { out.replayFailed = true; }
|
|
288
|
+
finish({});
|
|
289
|
+
`,
|
|
290
|
+
validateInputs(i) {
|
|
291
|
+
if (!isId(i.export) || !isId(i.sink)) return 'export and sink must be identifiers';
|
|
292
|
+
const small = (v) => { try { const s = JSON.stringify(v); return typeof s === 'string' && s.length <= 2000 && v !== null && typeof v === 'object' && !Array.isArray(v); } catch { return false; } };
|
|
293
|
+
if (!small(i.request) || !small(i.distinctRequest)) return 'request and distinctRequest must be small JSON objects';
|
|
294
|
+
if (digestOf(i.request) === digestOf(i.distinctRequest)) return 'distinctRequest must differ from request';
|
|
295
|
+
if (!(Number.isInteger(i.deliveries) && i.deliveries >= 2 && i.deliveries <= 6)) return 'deliveries must be 2 to 6';
|
|
296
|
+
return null;
|
|
297
|
+
},
|
|
298
|
+
prepare: (i) => ({ export: i.export, sink: i.sink, request: i.request, distinctRequest: i.distinctRequest, deliveries: i.deliveries }),
|
|
299
|
+
interpret({ result }) {
|
|
300
|
+
if (!result || result.loadFailed) return { satisfied: null, preconditionsHeld: false, observed: { targetLoaded: false }, reason: 'the target failed to load' };
|
|
301
|
+
const first = Number.isInteger(result.firstEffects) ? result.firstEffects : null;
|
|
302
|
+
const distinct = Number.isInteger(result.distinctEffects) ? result.distinctEffects : null;
|
|
303
|
+
const replay = Number.isInteger(result.replayEffects) ? result.replayEffects : null;
|
|
304
|
+
const controlHeld = first === 1 && distinct === 2;
|
|
305
|
+
const observed = { targetLoaded: true, firstDeliveryEffects: first, distinctDeliveryTotalEffects: distinct, repeatedDeliveryTotalEffects: replay, controlHeld };
|
|
306
|
+
if (replay !== null && replay > 1) return { satisfied: true, preconditionsHeld: controlHeld, observed, reason: `the same request delivered repeatedly produced ${replay} effects` };
|
|
307
|
+
if (controlHeld && replay === 1) return { satisfied: false, preconditionsHeld: true, observed, reason: 'a distinct request produced its own effect and the repeated request produced exactly one' };
|
|
308
|
+
return { satisfied: null, preconditionsHeld: false, observed, reason: controlHeld ? 'the repeated deliveries did not report' : 'the handler did not produce exactly one effect per first delivery, so it was not shown to be working' };
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// ------------------------------------------------------------------ functional regression (X-204)
|
|
313
|
+
|
|
314
|
+
const functionalRegression = defineOracle({
|
|
315
|
+
id: 'functional-regression', class: 'functional-regression', version: '1',
|
|
316
|
+
description: 'Does a revision still behave as intended? The harness calls the exported function with declared cases and records each result; the verifier compares them with the declared expectations. The hypothesis is that the behaviour REGRESSED, so a patch is clean when this is refuted.',
|
|
317
|
+
prerequisites: ['node-runtime', 'confinement-backend'],
|
|
318
|
+
platforms: PLATFORMS, budgets: COMMON_BUDGETS,
|
|
319
|
+
negativeControls: [{ id: 'behaviour-preserved', description: 'a revision that returns the expected value for every declared case must show no regression', expectedOutcome: 'refuted' }],
|
|
320
|
+
fixtures: { dir: 'test/fixtures/oracles/functional-regression', positive: 'positive', negative: 'negative', inconclusive: 'inconclusive' },
|
|
321
|
+
limitations: ['only the declared cases are checked: behaviour outside them is not observed, and a patch that breaks an undeclared path is not detected', 'results are compared as JSON values, so identity, ordering of unordered data and timing are not compared', 'the expectations are supplied by the requester and are not inferred; expectations that are wrong for the ORIGINAL revision are reported by the baseline check, not hidden'],
|
|
322
|
+
harnessSource: `${PROLOGUE}
|
|
323
|
+
const results = {};
|
|
324
|
+
for (const c of INPUT.cases) {
|
|
325
|
+
try { const v = await Promise.resolve(fn(...c.args)); results[c.id] = { ok: true, value: v === undefined ? null : JSON.parse(JSON.stringify(v)) }; }
|
|
326
|
+
catch (e) { results[c.id] = { ok: false }; }
|
|
327
|
+
}
|
|
328
|
+
finish({ results });
|
|
329
|
+
`,
|
|
330
|
+
validateInputs(i) {
|
|
331
|
+
if (!isId(i.export)) return 'export must be an identifier';
|
|
332
|
+
if (!Array.isArray(i.cases) || i.cases.length < 1 || i.cases.length > 16) return 'cases must hold 1 to 16 entries';
|
|
333
|
+
const ids = new Set();
|
|
334
|
+
for (const c of i.cases) {
|
|
335
|
+
if (!c || typeof c.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(c.id) || ids.has(c.id)) return 'every case needs a unique slug id';
|
|
336
|
+
ids.add(c.id);
|
|
337
|
+
if (!Array.isArray(c.args) || c.args.length > 6) return 'case args must be an array of at most 6';
|
|
338
|
+
if (!('expected' in c)) return 'every case needs an expected value';
|
|
339
|
+
try { if (JSON.stringify(c.expected) === undefined) return 'expected must be JSON'; } catch { return 'expected must be JSON'; }
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
},
|
|
343
|
+
prepare: (i) => ({ export: i.export, cases: i.cases.map((c) => ({ id: c.id, args: c.args })) }),
|
|
344
|
+
interpret({ inputs, result }) {
|
|
345
|
+
const got = result && result.results && typeof result.results === 'object' ? result.results : null;
|
|
346
|
+
if (!got || result.loadFailed) return { satisfied: null, preconditionsHeld: false, observed: { targetLoaded: false }, reason: 'the target did not report results' };
|
|
347
|
+
const regressions = [];
|
|
348
|
+
let reported = 0;
|
|
349
|
+
for (const c of inputs.cases) {
|
|
350
|
+
const r = got[c.id];
|
|
351
|
+
if (!r || typeof r !== 'object') continue;
|
|
352
|
+
reported++;
|
|
353
|
+
if (r.ok !== true || digestOf(r.value) !== digestOf(c.expected)) regressions.push(c.id);
|
|
354
|
+
}
|
|
355
|
+
const complete = reported === inputs.cases.length;
|
|
356
|
+
const observed = { targetLoaded: true, casesDeclared: inputs.cases.length, casesReported: reported, regressedCases: regressions };
|
|
357
|
+
if (regressions.length) return { satisfied: true, preconditionsHeld: complete, observed, reason: `behaviour differs from the declared expectation in case(s): ${regressions.join(', ')}` };
|
|
358
|
+
if (complete) return { satisfied: false, preconditionsHeld: true, observed, reason: 'every declared case returned its expected value' };
|
|
359
|
+
return { satisfied: null, preconditionsHeld: false, observed, reason: 'not every declared case reported a result' };
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ------------------------------------------------------------------ business state (X-404)
|
|
364
|
+
|
|
365
|
+
const BS_MAX_OPS = 16;
|
|
366
|
+
const BS_MAX_ITEMS = 12;
|
|
367
|
+
|
|
368
|
+
function bsStepProblem(step, actorIds) {
|
|
369
|
+
if (!step || typeof step !== 'object' || Array.isArray(step)) return 'a step must be an object';
|
|
370
|
+
if (!actorIds.has(step.actor)) return 'a step actor must be a declared actor';
|
|
371
|
+
if (!isId(step.action)) return 'a step action must be an identifier';
|
|
372
|
+
if (step.args !== undefined) {
|
|
373
|
+
if (!Array.isArray(step.args) || step.args.length > 4) return 'step args must be an array of at most 4';
|
|
374
|
+
try { if (Buffer.byteLength(JSON.stringify(step.args)) > 400) return 'step args are too large'; } catch { return 'step args must be JSON'; }
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Problems with one sequence of items (steps or deterministic parallel groups), counting operations toward the shared ceiling. */
|
|
380
|
+
function bsSequenceProblem(items, actorIds, label) {
|
|
381
|
+
if (!Array.isArray(items) || items.length < 1 || items.length > BS_MAX_ITEMS) return `${label} must hold 1 to ${BS_MAX_ITEMS} items`;
|
|
382
|
+
let ops = 0;
|
|
383
|
+
for (const it of items) {
|
|
384
|
+
if (it && Array.isArray(it.parallel)) {
|
|
385
|
+
if (it.parallel.length < 2 || it.parallel.length > 4) return 'a parallel group holds 2 to 4 steps';
|
|
386
|
+
const sched = it.schedule;
|
|
387
|
+
if (!Array.isArray(sched) || sched.length !== it.parallel.length || [...sched].sort().join() !== it.parallel.map((_, i) => i).join()) return 'a parallel group needs a schedule that is a permutation of its steps';
|
|
388
|
+
for (const s of it.parallel) { const why = bsStepProblem(s, actorIds); if (why) return why; }
|
|
389
|
+
ops += it.parallel.length;
|
|
390
|
+
} else {
|
|
391
|
+
const why = bsStepProblem(it, actorIds);
|
|
392
|
+
if (why) return why;
|
|
393
|
+
ops += 1;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return ops > BS_MAX_OPS ? `${label} exceeds ${BS_MAX_OPS} operations` : null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const sha256hex = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
|
400
|
+
|
|
401
|
+
const businessState = defineOracle({
|
|
402
|
+
id: 'business-state', class: 'business-state', version: '1',
|
|
403
|
+
requiresFeature: 'invariant-scenarios',
|
|
404
|
+
description: 'Does a bounded stateful scenario drive an application into a state its invariant forbids? The harness gives the application a durable store and an effect recorder, plays a control sequence and an adversarial sequence (twice, to detect nondeterministic scheduling), and logs every durable write and emitted effect. The verifier evaluates the invariant\'s forbidden outcomes over that log; the response status is recorded but never decides.',
|
|
405
|
+
prerequisites: ['node-runtime', 'confinement-backend'],
|
|
406
|
+
platforms: PLATFORMS, budgets: COMMON_BUDGETS,
|
|
407
|
+
negativeControls: [{ id: 'tenant-scoped-writes', description: 'the same application scoping every write to the caller\'s tenant must leave the forbidden state unreached while its control flow still changes durable state', expectedOutcome: 'refuted' }],
|
|
408
|
+
fixtures: { dir: 'test/fixtures/oracles/business-state', positive: 'positive', negative: 'negative', inconclusive: 'inconclusive' },
|
|
409
|
+
limitations: [
|
|
410
|
+
'the application is a factory taking no arguments that returns action functions (ctx, ...args); ctx.store and ctx.emit are in-memory stand-ins, so durable state held anywhere else (a real database, a queue, the file system) is not observed',
|
|
411
|
+
'concurrency is cooperative scheduling inside one process with a fixed start order: it exercises interleavings at await points, not parallel execution, and a scenario whose two runs disagree is reported inconclusive rather than guessed',
|
|
412
|
+
'only the declared forbidden outcomes are checked, over the declared actors, tenants and steps; a clean result is a statement about this bounded scenario, not about the application',
|
|
413
|
+
'a target that recognises the oracle and forges its own log entries is not distinguishable from a real violation (the same limit every adapter states)',
|
|
414
|
+
],
|
|
415
|
+
harnessSource: `${PROLOGUE}
|
|
416
|
+
import crypto from 'node:crypto';
|
|
417
|
+
const clone = (v) => (v === undefined ? null : JSON.parse(JSON.stringify(v)));
|
|
418
|
+
const actorOf = (id) => INPUT.actors.find((a) => a.id === id);
|
|
419
|
+
async function play(items, phase) {
|
|
420
|
+
const data = new Map();
|
|
421
|
+
for (const s of INPUT.seed) data.set(s.key, clone(s.value));
|
|
422
|
+
const initial = Object.fromEntries(data);
|
|
423
|
+
const log = [];
|
|
424
|
+
const api = await Promise.resolve(fn());
|
|
425
|
+
async function runOp(opIndex, itemIndex, step) {
|
|
426
|
+
const a = actorOf(step.actor);
|
|
427
|
+
const entry = { item: itemIndex, op: opIndex, phase, actor: step.actor, action: step.action, ok: false, status: null, response: null, writes: [], events: [] };
|
|
428
|
+
log.push(entry);
|
|
429
|
+
const raw = [];
|
|
430
|
+
const ctx = {
|
|
431
|
+
id: a.id, tenant: a.tenant, role: a.role,
|
|
432
|
+
store: {
|
|
433
|
+
read: (k) => (data.has(String(k)) ? clone(data.get(String(k))) : null),
|
|
434
|
+
list: (p) => [...data.keys()].filter((k) => k.startsWith(String(p))).sort(),
|
|
435
|
+
write: (k, v) => { k = String(k); raw.push({ key: k, before: data.has(k) ? clone(data.get(k)) : null, after: clone(v) }); data.set(k, clone(v)); },
|
|
436
|
+
remove: (k) => { k = String(k); if (!data.has(k)) return; raw.push({ key: k, before: clone(data.get(k)), after: null }); data.delete(k); },
|
|
437
|
+
},
|
|
438
|
+
emit: (name, payload) => { entry.events.push({ name: String(name).slice(0, 64), key: payload && payload.key !== undefined ? String(payload.key).slice(0, 64) : null }); },
|
|
439
|
+
};
|
|
440
|
+
try {
|
|
441
|
+
const f = api && api[step.action];
|
|
442
|
+
if (typeof f !== 'function') throw new Error('no such action');
|
|
443
|
+
const r = await Promise.resolve(f.call(api, ctx, ...(step.args || [])));
|
|
444
|
+
entry.ok = true;
|
|
445
|
+
entry.status = r && typeof r.status === 'number' ? r.status : null;
|
|
446
|
+
try { entry.response = JSON.stringify(r === undefined ? null : r).slice(0, 300); } catch (e) { entry.response = null; }
|
|
447
|
+
} catch (e) { entry.error = text(e).slice(0, 80); }
|
|
448
|
+
const net = new Map();
|
|
449
|
+
for (const w of raw) { const p = net.get(w.key); net.set(w.key, p ? { key: w.key, before: p.before, after: w.after } : w); }
|
|
450
|
+
entry.writes = [...net.values()].filter((w) => JSON.stringify(w.before) !== JSON.stringify(w.after));
|
|
451
|
+
}
|
|
452
|
+
let i = 0;
|
|
453
|
+
for (const item of items) {
|
|
454
|
+
if (item.parallel) await Promise.allSettled(item.schedule.map((idx) => runOp(idx, i, item.parallel[idx])));
|
|
455
|
+
else await runOp(0, i, item);
|
|
456
|
+
i++;
|
|
457
|
+
}
|
|
458
|
+
const final = Object.fromEntries(data);
|
|
459
|
+
data.clear();
|
|
460
|
+
return { initial, steps: log, final, cleaned: data.size === 0 };
|
|
461
|
+
}
|
|
462
|
+
let c = null; let a1 = null; let a2 = null;
|
|
463
|
+
try { c = await play(INPUT.control, 'control'); } catch (e) { out.controlFailed = true; }
|
|
464
|
+
try { a1 = await play(INPUT.attack, 'attack'); a2 = await play(INPUT.attack, 'attack'); } catch (e) { out.attackFailed = true; }
|
|
465
|
+
out.control = c; out.attack = a1;
|
|
466
|
+
if (a1 && a2) out.replayDigest = crypto.createHash('sha256').update(JSON.stringify(a2)).digest('hex');
|
|
467
|
+
if (JSON.stringify(out).length > 15000) { delete out.control; delete out.attack; out.oversize = true; }
|
|
468
|
+
finish({});
|
|
469
|
+
`,
|
|
470
|
+
validateInputs(i) {
|
|
471
|
+
if (!isId(i.export)) return 'export must be an identifier';
|
|
472
|
+
const inv = i.invariant;
|
|
473
|
+
if (!inv || typeof inv.id !== 'string' || !inv.id.startsWith('inv:') || !isStr(inv.key, 64) || !Number.isInteger(inv.revision) || !isStr(inv.class, 64)) return 'invariant needs id, key, revision and class';
|
|
474
|
+
if (!Array.isArray(i.actors) || i.actors.length < 1 || i.actors.length > 8 || !i.actors.every((a) => a && isStr(a.id, 64) && isStr(a.tenant, 64) && isStr(a.role, 64))) return 'actors must hold 1 to 8 entries with id, tenant and role';
|
|
475
|
+
const actorIds = new Set(i.actors.map((a) => a.id));
|
|
476
|
+
if (actorIds.size !== i.actors.length) return 'actor ids must be unique';
|
|
477
|
+
if (i.markers !== undefined && (!Array.isArray(i.markers) || i.markers.length > 16 || !i.markers.every((m) => m && isStr(m.tenant, 64) && isStr(m.marker, 64)))) return 'markers must be at most 16 entries of tenant and marker';
|
|
478
|
+
if (!Array.isArray(i.seed) || i.seed.length > 16) return 'seed must hold at most 16 records';
|
|
479
|
+
for (const s of i.seed) {
|
|
480
|
+
if (!s || !isStr(s.key, 64) || !s.value || typeof s.value !== 'object' || Array.isArray(s.value)) return 'every seed record needs a key and an object value';
|
|
481
|
+
try { if (Buffer.byteLength(JSON.stringify(s.value)) > 400) return 'a seed record is too large'; } catch { return 'seed values must be JSON'; }
|
|
482
|
+
}
|
|
483
|
+
const c = bsSequenceProblem(i.control, actorIds, 'control'); if (c) return c;
|
|
484
|
+
const a = bsSequenceProblem(i.attack, actorIds, 'attack'); if (a) return a;
|
|
485
|
+
if (!Array.isArray(i.forbidden) || i.forbidden.length < 1 || i.forbidden.length > 8) return 'forbidden must hold 1 to 8 expressions';
|
|
486
|
+
for (const f of i.forbidden) { const p = expressionProblems(f); if (p.length) return `forbidden: ${p[0]}`; }
|
|
487
|
+
if (!Array.isArray(i.cleanup) || i.cleanup.length !== 1 || i.cleanup[0]?.action !== 'discard-world') return 'cleanup must be exactly [{ action: "discard-world" }]';
|
|
488
|
+
return null;
|
|
489
|
+
},
|
|
490
|
+
prepare: (i) => ({ export: i.export, actors: i.actors.map((a) => ({ id: a.id, tenant: a.tenant, role: a.role })), seed: i.seed, control: i.control, attack: i.attack }),
|
|
491
|
+
interpret({ inputs, result }) {
|
|
492
|
+
if (!result || result.loadFailed) return { satisfied: null, preconditionsHeld: false, observed: { targetLoaded: false }, reason: 'the target failed to load' };
|
|
493
|
+
if (result.oversize) return { satisfied: null, preconditionsHeld: false, observed: { logWithinCap: false }, reason: 'the scenario log exceeded the size cap, so no assertion was evaluated' };
|
|
494
|
+
if (result.controlFailed || result.attackFailed || !result.control || !result.attack) return { satisfied: null, preconditionsHeld: false, observed: { applicationStarted: false }, reason: 'the application factory failed to start for the scenario' };
|
|
495
|
+
const actors = Object.fromEntries(inputs.actors.map((a) => [a.id, { tenant: a.tenant, role: a.role }]));
|
|
496
|
+
const common = { invariantId: inputs.invariant.id, forbidden: inputs.forbidden, actors, markers: inputs.markers || [] };
|
|
497
|
+
const controlResults = evaluateAssertions({ ...common, phaseLog: result.control });
|
|
498
|
+
const attackResults = evaluateAssertions({ ...common, phaseLog: result.attack });
|
|
499
|
+
const deterministic = typeof result.replayDigest === 'string' && result.replayDigest === sha256hex(JSON.stringify(result.attack));
|
|
500
|
+
const preconditions = {
|
|
501
|
+
controlRan: result.control.steps.length > 0 && result.control.steps.every((s) => s.ok === true),
|
|
502
|
+
controlDurableEffect: hasDurableEffect(result.control),
|
|
503
|
+
controlClean: controlResults.every((r) => !r.violated),
|
|
504
|
+
cleanedUp: result.control.cleaned === true && result.attack.cleaned === true,
|
|
505
|
+
deterministic,
|
|
506
|
+
};
|
|
507
|
+
const controlHeld = preconditions.controlRan && preconditions.controlDurableEffect && preconditions.controlClean && preconditions.cleanedUp;
|
|
508
|
+
// A forbidden outcome reached by the CONTROL flow is a violation too (the legitimate path itself breaks the contract); it is
|
|
509
|
+
// reported under its own phase and cannot be mistaken for an adversarial finding.
|
|
510
|
+
const assertionsAll = [...attackResults.map((r) => ({ ...r, phase: 'attack' })), ...controlResults.filter((r) => r.violated).map((r) => ({ ...r, phase: 'control' }))];
|
|
511
|
+
const violated = assertionsAll.filter((r) => r.violated);
|
|
512
|
+
const report = {
|
|
513
|
+
schema: 'agentic-security/business-state-report', invariant: { id: inputs.invariant.id, key: inputs.invariant.key, revision: inputs.invariant.revision, class: inputs.invariant.class },
|
|
514
|
+
preconditions, snapshots: { initial: sanitizeSnapshot(result.attack.initial), final: sanitizeSnapshot(result.attack.final) },
|
|
515
|
+
events: eventSequence(result.attack),
|
|
516
|
+
assertions: assertionsAll.map((r) => ({ id: r.id, phase: r.phase, op: r.op, violated: r.violated, ops: r.ops, detail: r.detail, evidenceId: r.evidenceId })),
|
|
517
|
+
};
|
|
518
|
+
const observed = {
|
|
519
|
+
preconditions, violatedAssertions: violated.map((r) => r.id), assertions: assertionsAll.length,
|
|
520
|
+
evidenceIds: assertionsAll.map((r) => r.evidenceId), reportDigest: digestOf(report),
|
|
521
|
+
};
|
|
522
|
+
const evidenceItems = assertionsAll.map((r) => ({ id: r.evidenceId, digest: digestOf({ id: r.id, phase: r.phase, op: r.op, violated: r.violated, ops: r.ops }) }));
|
|
523
|
+
const base = { observed, report, evidenceItems };
|
|
524
|
+
if (!deterministic) return { ...base, satisfied: null, preconditionsHeld: false, reason: 'the adversarial sequence did not reproduce itself across two fresh runs (nondeterministic scheduling), so no verdict is formed' };
|
|
525
|
+
if (violated.length) return { ...base, satisfied: true, preconditionsHeld: controlHeld, reason: `forbidden durable state reached: ${violated.map((r) => `${r.id} in the ${r.phase} sequence (${r.detail})`).join('; ').slice(0, 300)}` };
|
|
526
|
+
if (controlHeld) return { ...base, satisfied: false, preconditionsHeld: true, reason: 'the control flow changed durable state as intended and no forbidden outcome was observed in the adversarial sequence' };
|
|
527
|
+
return { ...base, satisfied: null, preconditionsHeld: false, reason: 'the control flow did not show the application working (no durable effect, an error, or a violation in the legitimate flow), so the absence of a violation proves nothing' };
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
export const ADAPTERS = Object.freeze([injection, authorization, stateTransition, sideEffect, parserResource, replayIdempotency, functionalRegression, businessState]);
|