@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.
Files changed (182) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/agentic-security.js +98 -2
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +59 -8
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +38 -2
  15. package/dist/8798.index.js +1 -1
  16. package/dist/9207.index.js +5 -0
  17. package/dist/9560.index.js +2336 -27
  18. package/dist/957.index.js +1 -1
  19. package/dist/{1905.index.js → 9999.index.js} +306 -13
  20. package/dist/agentic-security.mjs +12 -12
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +39 -7
  23. package/src/capabilities/CLAUDE.md +114 -0
  24. package/src/capabilities/address.js +73 -0
  25. package/src/capabilities/attack-coverage.js +93 -0
  26. package/src/capabilities/decide.js +256 -0
  27. package/src/capabilities/delegate.js +69 -0
  28. package/src/capabilities/egress.js +20 -0
  29. package/src/capabilities/hook-advice.js +50 -0
  30. package/src/capabilities/index.js +32 -0
  31. package/src/capabilities/manifest.js +344 -0
  32. package/src/capabilities/outbound.js +189 -0
  33. package/src/capabilities/paths.js +57 -0
  34. package/src/capabilities/probes.js +211 -0
  35. package/src/capabilities/proxy.js +182 -0
  36. package/src/capabilities/reasons.js +67 -0
  37. package/src/capabilities/receipts.js +356 -0
  38. package/src/capabilities/records.js +58 -0
  39. package/src/capabilities/recovery.js +367 -0
  40. package/src/capabilities/report.js +81 -0
  41. package/src/capabilities/runner.js +236 -0
  42. package/src/capabilities/secrets.js +26 -0
  43. package/src/capabilities/tool-gate.js +119 -0
  44. package/src/capabilities/tool-registry.js +60 -0
  45. package/src/dataflow/CLAUDE.md +29 -0
  46. package/src/dataflow/catalog.js +21 -0
  47. package/src/dataflow/engine.js +20 -0
  48. package/src/dataflow/guard-dominance.js +352 -0
  49. package/src/discovery/CLAUDE.md +18 -0
  50. package/src/discovery/index.js +43 -1
  51. package/src/discovery/lenses.js +18 -0
  52. package/src/discovery/memory.js +7 -5
  53. package/src/engine.js +80 -15
  54. package/src/fix/apply-fix-service.js +33 -3
  55. package/src/integrations/index.js +5 -2
  56. package/src/ir/CLAUDE.md +14 -0
  57. package/src/ir/implicit-handler-params.js +74 -0
  58. package/src/ir/parser-cs.js +20 -0
  59. package/src/ir/parser-go.js +30 -13
  60. package/src/ir/parser-java.js +11 -0
  61. package/src/language/nix-eval-isolation.js +33 -5
  62. package/src/lineage/CLAUDE.md +31 -0
  63. package/src/lineage/deployment/adapter-cloud.js +202 -0
  64. package/src/lineage/deployment/adapter-compose.js +90 -0
  65. package/src/lineage/deployment/adapter-kit.js +142 -0
  66. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  67. package/src/lineage/deployment/attack-paths.js +443 -0
  68. package/src/lineage/deployment/boundaries-run.js +223 -0
  69. package/src/lineage/deployment/boundary-graph.js +567 -0
  70. package/src/lineage/deployment/cross-repo.js +253 -0
  71. package/src/lineage/deployment/drift.js +383 -0
  72. package/src/lineage/deployment/ingest.js +221 -0
  73. package/src/lineage/deployment/projection.js +244 -0
  74. package/src/lineage/deployment/trace-correlation.js +541 -0
  75. package/src/lineage/source-registry.js +19 -0
  76. package/src/mcp/CLAUDE.md +14 -3
  77. package/src/mcp/invariant-tools.js +64 -0
  78. package/src/mcp/portfolio-tools.js +79 -0
  79. package/src/mcp/redact.js +15 -0
  80. package/src/mcp/server.js +24 -3
  81. package/src/mcp/tools.js +30 -5
  82. package/src/posture/CLAUDE.md +505 -0
  83. package/src/posture/accuracy-scorecard.js +95 -4
  84. package/src/posture/assurance/baseline-inventory.js +218 -0
  85. package/src/posture/assurance/baseline.js +322 -0
  86. package/src/posture/assurance/bounded-io.js +145 -0
  87. package/src/posture/assurance/config.js +299 -0
  88. package/src/posture/assurance/contracts.js +315 -0
  89. package/src/posture/assurance/identity.js +62 -0
  90. package/src/posture/assurance/migrations.js +259 -0
  91. package/src/posture/assurance/rollback.js +214 -0
  92. package/src/posture/assurance/rollout.js +200 -0
  93. package/src/posture/assurance/schema-kit.js +111 -0
  94. package/src/posture/assurance/verification-record.js +258 -0
  95. package/src/posture/attestation.js +5 -0
  96. package/src/posture/autopilot.js +28 -1
  97. package/src/posture/business-logic.js +26 -4
  98. package/src/posture/evaluation/bakeoff.js +148 -0
  99. package/src/posture/evaluation/custody.js +200 -0
  100. package/src/posture/evaluation/deployment-ablation.js +416 -0
  101. package/src/posture/evaluation/dev-cases.js +65 -0
  102. package/src/posture/evaluation/economics.js +85 -0
  103. package/src/posture/evaluation/gates.js +161 -0
  104. package/src/posture/evaluation/grouping.js +101 -0
  105. package/src/posture/evaluation/interval.js +132 -0
  106. package/src/posture/evaluation/invariant-ablation.js +325 -0
  107. package/src/posture/evaluation/labels.js +200 -0
  108. package/src/posture/evaluation/protocol.js +308 -0
  109. package/src/posture/evaluation/release-gate.js +209 -0
  110. package/src/posture/evaluation/report.js +158 -0
  111. package/src/posture/evaluation/runner.js +273 -0
  112. package/src/posture/evaluation/scan-child.js +80 -0
  113. package/src/posture/evaluation/score.js +174 -0
  114. package/src/posture/evaluation/synthetic.js +90 -0
  115. package/src/posture/evaluation/why-missed.js +219 -0
  116. package/src/posture/evidence-bundle.js +93 -7
  117. package/src/posture/execution-proof.js +11 -1
  118. package/src/posture/fix-verify-loop.js +20 -6
  119. package/src/posture/fix-verify.js +37 -2
  120. package/src/posture/fleet.js +11 -2
  121. package/src/posture/invariants/cli.js +95 -0
  122. package/src/posture/invariants/coverage.js +164 -0
  123. package/src/posture/invariants/export.js +178 -0
  124. package/src/posture/invariants/expressions.js +46 -0
  125. package/src/posture/invariants/lifecycle.js +289 -0
  126. package/src/posture/invariants/project-input.js +55 -0
  127. package/src/posture/invariants/repair.js +340 -0
  128. package/src/posture/invariants/run.js +45 -0
  129. package/src/posture/invariants/scenarios.js +311 -0
  130. package/src/posture/invariants/schema.js +167 -0
  131. package/src/posture/invariants/shrink.js +407 -0
  132. package/src/posture/invariants/state-assertions.js +165 -0
  133. package/src/posture/logic-claims.js +12 -0
  134. package/src/posture/model-routing.js +40 -0
  135. package/src/posture/oracles/adapters.js +531 -0
  136. package/src/posture/oracles/conformance.js +276 -0
  137. package/src/posture/oracles/oracle.js +453 -0
  138. package/src/posture/oracles/registry.js +76 -0
  139. package/src/posture/oracles/scenario-classes.js +223 -0
  140. package/src/posture/portfolio/backend.js +279 -0
  141. package/src/posture/portfolio/bundle.js +314 -0
  142. package/src/posture/portfolio/cli.js +142 -0
  143. package/src/posture/portfolio/manifest.js +263 -0
  144. package/src/posture/portfolio/progress.js +267 -0
  145. package/src/posture/portfolio/resume.js +130 -0
  146. package/src/posture/portfolio/retention.js +207 -0
  147. package/src/posture/portfolio/scheduler.js +488 -0
  148. package/src/posture/portfolio/signing.js +155 -0
  149. package/src/posture/portfolio/wording.js +59 -0
  150. package/src/posture/portfolio/work-units.js +405 -0
  151. package/src/posture/replay/replay.js +250 -0
  152. package/src/posture/routing/adapter.js +140 -0
  153. package/src/posture/routing/calibration.js +278 -0
  154. package/src/posture/routing/canary.js +141 -0
  155. package/src/posture/routing/control.js +35 -0
  156. package/src/posture/routing/decide.js +211 -0
  157. package/src/posture/routing/drift.js +137 -0
  158. package/src/posture/routing/economics.js +185 -0
  159. package/src/posture/routing/feedback.js +207 -0
  160. package/src/posture/routing/index.js +31 -0
  161. package/src/posture/routing/outcomes.js +281 -0
  162. package/src/posture/routing/promotion.js +291 -0
  163. package/src/posture/routing/receipts.js +59 -0
  164. package/src/posture/routing/report.js +142 -0
  165. package/src/posture/routing/shadow.js +53 -0
  166. package/src/posture/specification-mining.js +26 -0
  167. package/src/posture/verification/advisory-state.js +71 -0
  168. package/src/posture/verification/emit.js +257 -0
  169. package/src/posture/verification/hypothesis-promotion.js +211 -0
  170. package/src/posture/verification/patch-negative.js +226 -0
  171. package/src/posture/verification/patch-promotion.js +113 -0
  172. package/src/posture/verification/projection.js +183 -0
  173. package/src/posture/verification/repair-records.js +124 -0
  174. package/src/posture/verifier.js +16 -0
  175. package/src/report/index.js +100 -1
  176. package/src/sandbox/CLAUDE.md +51 -0
  177. package/src/sandbox/backend-namespace.js +7 -0
  178. package/src/sandbox/backend-userspace.js +151 -34
  179. package/src/sandbox/control-probes.js +215 -0
  180. package/src/sandbox/supervise.js +201 -0
  181. package/src/sandbox/trust-boundary.js +113 -0
  182. package/src/sandbox/trust-domains.js +171 -0
@@ -0,0 +1,453 @@
1
+ // The oracle interface and its runner (X-202).
2
+ //
3
+ // An ORACLE is a bounded, class-specific assertion about whether a hypothesis
4
+ // holds, executed against a target INSIDE the trust boundary (CORE-003) and judged
5
+ // by verifier-domain code. This module owns three things and nothing else:
6
+ //
7
+ // 1. The adapter contract (`validateOracleSpec`, `defineOracle`): every adapter
8
+ // declares its class, prerequisites, supported platforms, budgets and negative
9
+ // controls, or it is not an adapter. X-208 gates a release on this.
10
+ // 2. The runner (`runOracle`): feature gate, request validation, prerequisite
11
+ // check, a fresh workspace, the target run through `runInBoundary` ONLY, an
12
+ // integrity check of the adapter's own logic, verifier-side interpretation,
13
+ // and a verifier-produced receipt.
14
+ // 3. The receipt registry: receipts exist only if `runOracle` issued them.
15
+ //
16
+ // What makes the verdict trustworthy, and what does not:
17
+ // - The target runs in a workspace the target can write. The VERDICT is formed
18
+ // by `settleVerification` from evidence stamped `observedBy: 'verifier'`, and
19
+ // the effect it examines is read by the verifier AFTER the whole process tree
20
+ // is dead (marker files, the supervisor's own deadline observation). Target
21
+ // stdout and stderr are data: they are captured, capped and labelled, never
22
+ // parsed for a status.
23
+ // - A run alone can only be `inconclusive`. `confirmed` and `refuted` need the
24
+ // adapter's verifier-side assertion, and `refuted` additionally needs the
25
+ // adapter to show its own preconditions held (a benign control passed), so a
26
+ // target that failed to load or a harness that never reported is
27
+ // `inconclusive`, never a refutation.
28
+ // - An unavailable prerequisite is `unsupported` with a reason and the target is
29
+ // never executed. It never counts as a pass and never as a failure of the
30
+ // hypothesis.
31
+ // - Candidate workers cannot alter adapter logic, labels or receipts: the
32
+ // registry is frozen, labels (what counts as the effect) live in the adapter
33
+ // and in verifier-built tokens the request cannot set, the request rejects any
34
+ // field that looks like a verdict, the adapter's harness file is digest-checked
35
+ // after the run (a target that rewrote it makes the run `error`), the
36
+ // authoritative evidence directory is outside the workspace and read-denied by
37
+ // the boundary, and a receipt is only valid if this module issued it.
38
+ // - KNOWN LIMIT, not hidden: a target that recognises the oracle's payload and
39
+ // forges the effect artifact inside its own process is not distinguishable
40
+ // from one that was really exploited. The oracle bounds the effect it tests;
41
+ // it is not a defence against a target written to fool this oracle.
42
+ import crypto from 'node:crypto';
43
+ import fs from 'node:fs';
44
+ import os from 'node:os';
45
+ import path from 'node:path';
46
+ import { runInBoundary } from '../../sandbox/trust-boundary.js';
47
+ import { detectBackend } from '../../sandbox/capabilities.js';
48
+ import { DOMAINS, settleVerification } from '../../sandbox/trust-domains.js';
49
+ import { resolveAssuranceConfig, featureStatus, FEATURES } from '../assurance/config.js';
50
+ import { digestOf, digestOfBytes } from '../assurance/identity.js';
51
+ import { isCommit, isPlainObject } from '../assurance/schema-kit.js';
52
+ import { emitVerification } from '../verification/emit.js';
53
+ import { scenarioClassesForAdapter, scenarioClassReportLines } from './scenario-classes.js';
54
+
55
+ export const ORACLE_CLASSES = Object.freeze([
56
+ 'injection-execution', 'authorization-decision', 'state-transition', 'side-effect-reachability', 'parser-resource',
57
+ // X-205 and X-204: a replayed request that repeats its effect, and a differential behaviour check for a patch.
58
+ 'replay-idempotency', 'functional-regression',
59
+ // X-404: durable business and tenant state judged over a bounded stateful scenario.
60
+ 'business-state',
61
+ ]);
62
+ const PLATFORM_STATUSES = Object.freeze(['supported', 'unverified', 'unsupported']);
63
+ export const FEATURE_ID = 'verification-oracles';
64
+ export const HARNESS_FILE = '__oracle_harness.mjs';
65
+ export const RESULT_FILE = '__oracle_result.json';
66
+ const RECEIPT_SCHEMA = 'agentic-security/oracle-receipt';
67
+
68
+ const RESERVED_PREFIX = '__oracle_';
69
+ const MAX_INPUT_BYTES = 64 * 1024;
70
+ const MAX_OBSERVED_BYTES = 4 * 1024;
71
+ const MAX_REPORT_BYTES = 32 * 1024;
72
+ const REQUEST_KEYS = ['oracleId', 'hypothesisId', 'finding', 'commit', 'files', 'entry', 'inputs', 'attempt', 'budgets', 'detectorOrigin'];
73
+ const BUDGET_KEYS = ['timeoutMs', 'graceMs', 'maxOutputBytes', 'maxFileBytes', 'maxFiles'];
74
+
75
+ // ---------------------------------------------------------------- prerequisites
76
+
77
+ // Shared prerequisite checks. An adapter names the ones it needs by id; `check`
78
+ // reads only the injected environment snapshot, so any host can simulate a missing
79
+ // prerequisite and every host can read why a check failed.
80
+ export const PREREQUISITES = Object.freeze({
81
+ 'node-runtime': {
82
+ description: 'Node.js 24 or later (the version this scanner supports)',
83
+ check: (env) => (env.nodeMajor >= 24 ? { ok: true } : { ok: false, reason: `Node ${env.nodeMajor} is older than the supported 24` }),
84
+ },
85
+ 'confinement-backend': {
86
+ description: 'a confinement backend that passes its functional probe on this host',
87
+ check: (env) => (env.backend && env.backend !== 'disabled' ? { ok: true } : { ok: false, reason: 'no confinement backend works on this host' }),
88
+ },
89
+ 'posix-shell': {
90
+ description: 'a POSIX shell at /bin/sh (the injected command needs a shell to run)',
91
+ check: (env) => (env.hasPosixShell ? { ok: true } : { ok: false, reason: '/bin/sh is not present' }),
92
+ },
93
+ });
94
+
95
+ function probeEnvironment() {
96
+ return {
97
+ platform: process.platform,
98
+ nodeMajor: Number(String(process.versions.node).split('.')[0]),
99
+ backend: detectBackend(),
100
+ hasPosixShell: fs.existsSync('/bin/sh'),
101
+ };
102
+ }
103
+
104
+ // ---------------------------------------------------------------- adapter contract
105
+
106
+ const isPosInt = (n) => Number.isInteger(n) && n > 0;
107
+
108
+ /** Problems with an adapter spec; an empty list means it satisfies the contract. */
109
+ export function validateOracleSpec(spec) {
110
+ const errs = [];
111
+ if (!isPlainObject(spec)) return ['adapter spec must be an object'];
112
+ if (typeof spec.id !== 'string' || !/^[a-z][a-z0-9-]{2,63}$/.test(spec.id)) errs.push('id must be a lowercase slug');
113
+ if (!ORACLE_CLASSES.includes(spec.class)) errs.push(`class must be one of ${ORACLE_CLASSES.join(', ')}`);
114
+ if (typeof spec.version !== 'string' || !/^\d+$/.test(spec.version)) errs.push('version must be an integer string');
115
+ if (typeof spec.description !== 'string' || !spec.description.trim()) errs.push('description is required');
116
+ if (!Array.isArray(spec.prerequisites) || spec.prerequisites.length === 0) errs.push('declared prerequisites are required');
117
+ else for (const id of spec.prerequisites) if (!PREREQUISITES[id]) errs.push(`unknown prerequisite '${id}'`);
118
+ if (!isPlainObject(spec.platforms)) errs.push('platforms are required');
119
+ else {
120
+ for (const p of ['darwin', 'linux', 'win32']) {
121
+ const e = spec.platforms[p];
122
+ if (!isPlainObject(e) || !PLATFORM_STATUSES.includes(e.status) || typeof e.note !== 'string' || !e.note) errs.push(`platforms.${p} needs a status and a note`);
123
+ }
124
+ if (spec.platforms.linux?.status === 'supported') errs.push('platforms.linux cannot be "supported": Linux enforcement is not verified by this build');
125
+ }
126
+ const b = spec.budgets;
127
+ if (!isPlainObject(b)) errs.push('budgets are required');
128
+ else for (const k of BUDGET_KEYS) if (!isPosInt(b[k])) errs.push(`budgets.${k} must be a positive integer`);
129
+ if (!Array.isArray(spec.negativeControls) || spec.negativeControls.length === 0) errs.push('at least one negative control is required');
130
+ else for (const [i, n] of spec.negativeControls.entries()) {
131
+ if (!isPlainObject(n) || typeof n.id !== 'string' || typeof n.description !== 'string' || n.expectedOutcome !== 'refuted') errs.push(`negativeControls[${i}] needs id, description and expectedOutcome 'refuted'`);
132
+ }
133
+ if (!isPlainObject(spec.fixtures) || !['dir', 'positive', 'negative', 'inconclusive'].every((k) => typeof spec.fixtures[k] === 'string' && spec.fixtures[k])) errs.push('fixtures need dir, positive, negative and inconclusive');
134
+ if (!Array.isArray(spec.limitations) || spec.limitations.length === 0) errs.push('limitations must be stated');
135
+ if (spec.requiresFeature !== undefined && !(typeof spec.requiresFeature === 'string' && FEATURES[spec.requiresFeature])) errs.push('requiresFeature must name a known feature');
136
+ if (typeof spec.harnessSource !== 'string' || !spec.harnessSource.includes('__INPUT__')) errs.push('harnessSource (verifier-authored) is required');
137
+ for (const fn of ['prepare', 'validateInputs', 'interpret']) if (typeof spec[fn] !== 'function') errs.push(`${fn} must be a function`);
138
+ return errs;
139
+ }
140
+
141
+ /** Digest of everything that decides what this adapter asserts. A replay manifest pins it. */
142
+ export function oracleLogicDigest(spec) {
143
+ return digestOf({ id: spec.id, class: spec.class, version: spec.version, budgets: spec.budgets, harness: spec.harnessSource });
144
+ }
145
+
146
+ /** Validate, freeze and fingerprint an adapter. Throws on an invalid spec: registration is verifier-side code, at load. */
147
+ export function defineOracle(spec) {
148
+ const errs = validateOracleSpec(spec);
149
+ if (errs.length) throw new Error(`invalid oracle adapter '${spec?.id}': ${errs.join('; ')}`);
150
+ const deep = (o) => { Object.values(o).forEach((v) => { if (v && typeof v === 'object') deep(v); }); return Object.freeze(o); };
151
+ return Object.freeze({ ...spec, platforms: deep({ ...spec.platforms }), budgets: deep({ ...spec.budgets }), logicDigest: oracleLogicDigest(spec) });
152
+ }
153
+
154
+ // ---------------------------------------------------------------- receipts
155
+
156
+ const ISSUED = new WeakSet();
157
+
158
+ /** True only for a receipt object `runOracle` itself created. A forged lookalike is not. */
159
+ export function isIssuedReceipt(r) { return isPlainObject(r) && ISSUED.has(r); }
160
+
161
+ function deepFreeze(o) {
162
+ for (const v of Object.values(o)) if (v && typeof v === 'object' && !Object.isFrozen(v)) deepFreeze(v);
163
+ return Object.freeze(o);
164
+ }
165
+
166
+ function issueReceipt(body) {
167
+ const copy = JSON.parse(JSON.stringify(body));
168
+ const receipt = deepFreeze({ ...copy, receiptDigest: digestOf(copy) });
169
+ ISSUED.add(receipt);
170
+ return receipt;
171
+ }
172
+
173
+ // ---------------------------------------------------------------- environment metadata
174
+
175
+ /**
176
+ * Sanitized environment metadata: an allowlist of facts about the platform and the
177
+ * proved controls. No hostnames, user names, paths or environment variables.
178
+ */
179
+ export function sanitizedEnvironment({ backend, controls } = {}) {
180
+ const controlStates = {};
181
+ for (const [name, c] of Object.entries(isPlainObject(controls) ? controls : {})) {
182
+ if (c && typeof c.state === 'string') controlStates[name] = c.state;
183
+ }
184
+ return {
185
+ platform: process.platform, arch: os.arch(), runtime: 'node', nodeVersion: process.versions.node,
186
+ backend: backend || null, controlStates, containerDigest: null,
187
+ };
188
+ }
189
+
190
+ // ---------------------------------------------------------------- request validation
191
+
192
+ /** A relative path that stays inside a workspace and cannot name the adapter's own reserved files. */
193
+ export function isSafeRelativePath(rel) {
194
+ if (typeof rel !== 'string' || !rel || rel.length > 200 || rel.includes('\0') || rel.includes('\\')) return false;
195
+ if (path.isAbsolute(rel) || rel.split('/').some((seg) => seg === '..' || seg === '' || seg === '.')) return false;
196
+ if (path.basename(rel).startsWith(RESERVED_PREFIX)) return false;
197
+ return true;
198
+ }
199
+
200
+ function validateRequest(req, oracle) {
201
+ const errs = [];
202
+ if (!isPlainObject(req)) return ['request must be an object'];
203
+ for (const k of Object.keys(req)) if (!REQUEST_KEYS.includes(k)) errs.push(`field '${k}' is not part of an oracle request (verdicts, labels and receipts are never supplied by a caller)`);
204
+ if (typeof req.hypothesisId !== 'string' || !req.hypothesisId) errs.push('hypothesisId is required');
205
+ if (req.commit !== undefined && req.commit !== null && !isCommit(req.commit)) errs.push('commit must be a 40 or 64 character lowercase hex commit id or null');
206
+ if (!isPlainObject(req.files)) errs.push('files must be an object of relative path to text');
207
+ else {
208
+ const names = Object.keys(req.files);
209
+ if (names.length === 0 || names.length > oracle.budgets.maxFiles) errs.push(`files must hold 1 to ${oracle.budgets.maxFiles} entries`);
210
+ for (const n of names) {
211
+ if (!isSafeRelativePath(n)) errs.push(`file path ${JSON.stringify(String(n).slice(0, 60))} is not a safe relative path`);
212
+ else if (typeof req.files[n] !== 'string' || Buffer.byteLength(req.files[n]) > oracle.budgets.maxFileBytes) errs.push(`file '${n}' must be text of at most ${oracle.budgets.maxFileBytes} bytes`);
213
+ }
214
+ }
215
+ if (typeof req.entry !== 'string' || !isPlainObject(req.files) || !(req.entry in req.files)) errs.push('entry must name one of the supplied files');
216
+ if (!isPlainObject(req.inputs)) errs.push('inputs must be an object');
217
+ else {
218
+ let size = 0;
219
+ try { size = Buffer.byteLength(JSON.stringify(req.inputs)); } catch { errs.push('inputs must be JSON-serializable'); }
220
+ if (size > MAX_INPUT_BYTES) errs.push(`inputs exceed ${MAX_INPUT_BYTES} bytes`);
221
+ const why = errs.length ? null : oracle.validateInputs(req.inputs);
222
+ if (why) errs.push(`inputs: ${why}`);
223
+ }
224
+ if (req.attempt !== undefined && !isPosInt(req.attempt)) errs.push('attempt must be a positive integer');
225
+ if (req.budgets !== undefined) {
226
+ if (!isPlainObject(req.budgets)) errs.push('budgets must be an object');
227
+ else for (const [k, v] of Object.entries(req.budgets)) {
228
+ if (!BUDGET_KEYS.includes(k)) errs.push(`unknown budget '${k}'`);
229
+ else if (!isPosInt(v) || v > oracle.budgets[k]) errs.push(`budget ${k} must be a positive integer within the adapter ceiling of ${oracle.budgets[k]}`);
230
+ }
231
+ }
232
+ return errs;
233
+ }
234
+
235
+ // ---------------------------------------------------------------- workspace reads (verifier side)
236
+
237
+ function makeReader(root) {
238
+ return (rel, maxBytes = 4096) => {
239
+ try {
240
+ const abs = path.resolve(root, rel);
241
+ if (!abs.startsWith(root + path.sep)) return null;
242
+ const st = fs.lstatSync(abs);
243
+ if (!st.isFile() || st.isSymbolicLink()) return null;
244
+ const fd = fs.openSync(abs, 'r');
245
+ try {
246
+ const buf = Buffer.alloc(Math.min(st.size, maxBytes));
247
+ fs.readSync(fd, buf, 0, buf.length, 0);
248
+ return buf.toString('utf8');
249
+ } finally { fs.closeSync(fd); }
250
+ } catch { return null; }
251
+ };
252
+ }
253
+
254
+ function writeFiles(root, files) {
255
+ for (const [rel, content] of Object.entries(files)) {
256
+ const abs = path.resolve(root, rel);
257
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
258
+ fs.writeFileSync(abs, content, 'utf8');
259
+ }
260
+ }
261
+
262
+ function capObserved(obj) {
263
+ let s = JSON.stringify(obj ?? null);
264
+ if (Buffer.byteLength(s) <= MAX_OBSERVED_BYTES) return JSON.parse(s);
265
+ return { truncated: true, digest: digestOf(obj ?? null) };
266
+ }
267
+
268
+ // ---------------------------------------------------------------- the runner
269
+
270
+ function outcomeFields(oracle, req, { outcome, reason, attempt, evidence, preconditionsValid, backend }) {
271
+ const platformNote = oracle.platforms[process.platform]?.status === 'unverified' ? ' (platform unverified for this oracle)' : '';
272
+ return {
273
+ outcome, reason, attempt, evidence: evidence || [],
274
+ oracle: { id: oracle.id, kind: 'runtime-replay', version: oracle.version },
275
+ preconditions: { valid: !!preconditionsValid },
276
+ scope: { description: `${oracle.class} oracle '${oracle.id}' v${oracle.version}, bounded assertion in the trust boundary${platformNote}`, platform: process.platform, ...(backend ? { backend } : {}) },
277
+ };
278
+ }
279
+
280
+ /**
281
+ * What a reader of any result from this oracle must be shown next to it: the prerequisites it needed, the platform statement
282
+ * and its limitations, and, for a non-taint class it serves, that class's own report lines (X-205.AC02). Data only.
283
+ */
284
+ function disclosureOf(oracle) {
285
+ return {
286
+ oracle: oracle.id,
287
+ prerequisites: oracle.prerequisites.map((id) => ({ id, description: PREREQUISITES[id].description })),
288
+ platform: { name: process.platform, status: oracle.platforms[process.platform]?.status ?? 'unsupported' },
289
+ limitations: [...oracle.limitations],
290
+ scenarioClasses: scenarioClassesForAdapter(oracle.id).map((c) => ({ id: c.id, report: scenarioClassReportLines(c, oracle) })),
291
+ };
292
+ }
293
+
294
+ function finishWithRecord(oracle, req, status, fields, extra = {}) {
295
+ const emitted = emitVerification('oracle', null, {
296
+ hypothesisId: req.hypothesisId, commit: req.commit ?? null, finding: req.finding || null,
297
+ ...(req.detectorOrigin ? { detectorOrigin: req.detectorOrigin } : {}), fields,
298
+ });
299
+ return { status, outcome: fields.outcome, reason: fields.reason, record: emitted.ok ? emitted.record : null, recordErrors: emitted.errors, disclosure: disclosureOf(oracle), ...extra };
300
+ }
301
+
302
+ /**
303
+ * The record for an attempt that did not execute anything (replay prerequisites, a refused request). `not-run` and
304
+ * `unsupported` are the only outcomes available: with no execution there is nothing to settle.
305
+ */
306
+ export function recordWithoutExecution(oracleId, req, outcome, reason, registry) {
307
+ const oracle = (registry || { getOracle: () => null }).getOracle(oracleId);
308
+ if (!oracle || !['not-run', 'unsupported'].includes(outcome)) return null;
309
+ return finishWithRecord(oracle, req, outcome === 'unsupported' ? 'unsupported' : 'awaiting-prerequisites',
310
+ outcomeFields(oracle, req, { outcome, reason, attempt: 0, preconditionsValid: false })).record;
311
+ }
312
+
313
+ /**
314
+ * Run one oracle against one target. Never throws. The target is executed through
315
+ * `runInBoundary` and nowhere else.
316
+ *
317
+ * @param {object} request see REQUEST_KEYS; labels, verdicts and receipts are rejected
318
+ * @param {object} [o]
319
+ * @param {object} [o.config] resolved assurance config; default resolves the process environment (feature off by default)
320
+ * @param {object} [o.probeEnv] environment snapshot for prerequisite checks (test seam)
321
+ * @param {object} [o.deps] `{ runInBoundary }` test seam
322
+ * @param {AbortSignal} [o.signal]
323
+ * @param {object} [o.registry] `{ getOracle }` (defaults to the frozen registry)
324
+ */
325
+ export async function runOracle(request, o = {}) {
326
+ const registry = o.registry || (await import('./registry.js'));
327
+ const oracle = registry.getOracle(request?.oracleId);
328
+ if (!oracle) return { status: 'rejected', outcome: null, reason: `unknown oracle '${String(request?.oracleId).slice(0, 80)}'`, record: null, errors: ['unknown oracle'] };
329
+ const errs = validateRequest(request, oracle);
330
+ if (errs.length) return { status: 'rejected', outcome: null, reason: 'the request failed validation', record: null, errors: errs };
331
+ const req = request;
332
+ const attemptNo = req.attempt ?? 1;
333
+ const none = (outcome, reason, status, extra) => finishWithRecord(oracle, req, status, outcomeFields(oracle, req, { outcome, reason, attempt: 0, preconditionsValid: false }), { executed: false, receipt: null, ...extra });
334
+
335
+ // 1. feature gate: high-risk execution is off unless the operator enabled it
336
+ const config = o.config || resolveAssuranceConfig({ env: process.env });
337
+ const gate = featureStatus(config, FEATURE_ID);
338
+ if (gate.status !== 'ok') {
339
+ return none(gate.status === 'unsupported' ? 'unsupported' : 'not-run', `${FEATURE_ID} is not available: ${gate.reason}`, gate.status === 'unsupported' ? 'unsupported' : 'disabled', { gate });
340
+ }
341
+
342
+ // 1b. an adapter may require a second feature of its own (X-404: stateful invariant scenarios are separately opt-in)
343
+ if (oracle.requiresFeature) {
344
+ const g2 = featureStatus(config, oracle.requiresFeature);
345
+ if (g2.status !== 'ok') {
346
+ return none(g2.status === 'unsupported' ? 'unsupported' : 'not-run', `${oracle.requiresFeature} is not available: ${g2.reason}`, g2.status === 'unsupported' ? 'unsupported' : 'disabled', { gate: g2 });
347
+ }
348
+ }
349
+
350
+ // 2. prerequisites: an unmet one is `unsupported`, never a pass, and nothing executes
351
+ const env = o.probeEnv || probeEnvironment();
352
+ const unmet = [];
353
+ const plat = oracle.platforms[env.platform];
354
+ if (!plat || plat.status === 'unsupported') unmet.push({ id: 'supported-platform', reason: `${env.platform} is not a supported platform for this oracle${plat ? `: ${plat.note}` : ''}` });
355
+ for (const id of oracle.prerequisites) {
356
+ let r;
357
+ try { r = PREREQUISITES[id].check(env); } catch (e) { r = { ok: false, reason: `check failed: ${String(e?.message || e).slice(0, 100)}` }; }
358
+ if (!r.ok) unmet.push({ id, reason: r.reason || 'unavailable' });
359
+ }
360
+ if (unmet.length) {
361
+ return none('unsupported', `unmet prerequisite(s): ${unmet.map((u) => `${u.id} (${u.reason})`).join('; ')}`, 'unsupported', { prerequisites: unmet });
362
+ }
363
+
364
+ // 3. workspace + verifier-owned evidence directory (outside the workspace)
365
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'oracle-ws-')));
366
+ const evidenceDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'oracle-ev-')));
367
+ const token = crypto.randomBytes(12).toString('hex');
368
+ try {
369
+ const harnessInput = oracle.prepare(req.inputs, token);
370
+ // One pass, so a placeholder-looking string inside the input can never be substituted a second time.
371
+ const subst = { __INPUT__: JSON.stringify(harnessInput), __ENTRY__: JSON.stringify(req.entry) };
372
+ const harness = oracle.harnessSource.replace(/__INPUT__|__ENTRY__/g, (m) => subst[m]);
373
+ writeFiles(root, req.files);
374
+ fs.writeFileSync(path.join(root, HARNESS_FILE), harness, 'utf8');
375
+ const harnessDigest = digestOfBytes(Buffer.from(harness, 'utf8'));
376
+ const budgets = { ...oracle.budgets, ...(req.budgets || {}) };
377
+
378
+ // 4. the target runs through the trust boundary and nowhere else
379
+ const run = o.deps?.runInBoundary || runInBoundary;
380
+ const boundary = await run([process.execPath, HARNESS_FILE], {
381
+ root, evidenceDirs: [evidenceDir], env: {}, allowNetwork: false, signal: o.signal, force: o.force,
382
+ timeoutMs: budgets.timeoutMs, graceMs: budgets.graceMs, maxOutputBytes: budgets.maxOutputBytes,
383
+ limits: { maxFileSizeKb: 1024 },
384
+ });
385
+ if (boundary.blocked) {
386
+ // An unproved control or an unavailable backend: the isolation prerequisite is not met.
387
+ return none('unsupported', `the trust boundary refused to run the target: ${boundary.reasons.join('; ')}`, 'blocked', { boundary: { backend: boundary.backend ?? null, reasons: boundary.reasons }, prerequisites: [{ id: 'trust-boundary', reason: boundary.reasons.join('; ') }] });
388
+ }
389
+ const environment = sanitizedEnvironment({ backend: boundary.backend, controls: boundary.controls });
390
+ const runInfo = {
391
+ status: boundary.status, executed: boundary.executed, timedOut: !!boundary.timedOut, outputCapped: !!boundary.outputCapped,
392
+ cancelled: !!boundary.cancelled, exitCode: boundary.exitCode ?? null, survivors: boundary.termination?.survivors?.length ?? 0,
393
+ };
394
+
395
+ // 5. adapter-logic integrity: a target that rewrote the harness invalidates the run
396
+ const read = makeReader(root);
397
+ const harnessAfter = read(HARNESS_FILE, harness.length + 1024);
398
+ const tampered = harnessAfter === null || digestOfBytes(Buffer.from(harnessAfter, 'utf8')) !== harnessDigest;
399
+ const failed = (outcome, reason, status) => finishWithRecord(oracle, req, status, outcomeFields(oracle, req, {
400
+ outcome, reason, attempt: attemptNo, preconditionsValid: false, backend: boundary.backend,
401
+ evidence: [{ id: 'run-observation', kind: 'observation', producer: 'trusted-runner', digest: digestOf({ run: runInfo, environment }), source: `oracle:${oracle.id}@${oracle.version}` }],
402
+ }), { executed: boundary.executed, receipt: null, run: runInfo, environment });
403
+ if (tampered) return failed('error', 'the adapter harness was modified during the run; the run is invalid and no verdict is formed', 'error');
404
+ if (runInfo.cancelled) return failed('inconclusive', 'the run was cancelled before it finished', 'cancelled');
405
+ if (runInfo.survivors > 0) return failed('error', 'processes survived termination; the run cannot be trusted', 'error');
406
+
407
+ // 6. verifier-side interpretation of what the run left behind (all of it read after the tree is dead)
408
+ let resultDoc = null;
409
+ const rawResult = read(RESULT_FILE, 16 * 1024);
410
+ if (rawResult !== null) { try { const j = JSON.parse(rawResult); if (isPlainObject(j)) resultDoc = j; } catch { /* untrusted data that does not parse is absent */ } }
411
+ let judged;
412
+ try { judged = oracle.interpret({ inputs: req.inputs, token, result: resultDoc, run: runInfo, read }); } catch (e) { judged = { satisfied: null, preconditionsHeld: false, observed: {}, reason: `adapter interpretation failed: ${String(e?.message || e).slice(0, 100)}` }; }
413
+ const evidenceIn = { observedBy: DOMAINS.VERIFIER, satisfied: judged.satisfied, preconditionsHeld: judged.preconditionsHeld === true };
414
+ let outcome = settleVerification({ executed: boundary.executed, status: boundary.status }, evidenceIn);
415
+ let reason = judged.reason;
416
+ if ((outcome === 'confirmed' || outcome === 'refuted') && !req.commit) {
417
+ outcome = 'inconclusive';
418
+ reason = `${judged.reason}; not carried to '${evidenceIn.satisfied ? 'confirmed' : 'refuted'}' because the request is not bound to an exact commit`;
419
+ }
420
+ const observed = capObserved(judged.observed);
421
+ const decided = outcome === 'confirmed' || outcome === 'refuted';
422
+ const evidence = [{
423
+ id: 'oracle-observation', kind: decided ? 'trusted-runtime-proof' : 'observation', producer: 'trusted-runner',
424
+ // The harness text carries a per-run token, so it stays out of the record's digest (an identical replay must give an
425
+ // identical record id); it is bound into the receipt instead.
426
+ digest: digestOf({ oracle: oracle.logicDigest, observed }), source: `oracle:${oracle.id}@${oracle.version}`,
427
+ }];
428
+ // An adapter may add per-assertion evidence items (stable ids, derived from the observed log) and a bounded report (sanitized
429
+ // snapshots, event sequence, assertion results). Both come from verifier-side interpretation, never from the target.
430
+ if (Array.isArray(judged.evidenceItems)) {
431
+ for (const it of judged.evidenceItems.slice(0, 16)) {
432
+ if (it && typeof it.id === 'string' && typeof it.digest === 'string') evidence.push({ id: it.id, kind: 'observation', producer: 'trusted-runner', digest: it.digest, source: `oracle:${oracle.id}@${oracle.version}` });
433
+ }
434
+ }
435
+ const report = judged.report !== undefined && Buffer.byteLength(JSON.stringify(judged.report) ?? '') <= MAX_REPORT_BYTES ? JSON.parse(JSON.stringify(judged.report)) : undefined;
436
+ const out = finishWithRecord(oracle, req, 'completed', outcomeFields(oracle, req, {
437
+ outcome, reason, attempt: attemptNo, evidence, preconditionsValid: evidenceIn.preconditionsHeld, backend: boundary.backend,
438
+ }), { executed: true, run: runInfo, environment, targetOutput: boundary.targetOutput, ...(report !== undefined ? { report } : {}) });
439
+ out.receipt = issueReceipt({
440
+ schema: RECEIPT_SCHEMA, issuedBy: 'verifier',
441
+ oracle: { id: oracle.id, class: oracle.class, version: oracle.version, logicDigest: oracle.logicDigest },
442
+ request: { hypothesisId: req.hypothesisId, commit: req.commit ?? null, entry: req.entry, inputsDigest: digestOf(req.inputs), filesDigest: digestOf(req.files), attempt: attemptNo },
443
+ environment, run: runInfo, observed, harnessDigest, reportDigest: report !== undefined ? digestOf(report) : null,
444
+ settled: { outcome, satisfied: judged.satisfied, preconditionsHeld: evidenceIn.preconditionsHeld }, recordId: out.record?.id ?? null,
445
+ });
446
+ return out;
447
+ } catch (e) {
448
+ return none('error', `the oracle runner failed: ${String(e?.message || e).slice(0, 160)}`, 'error');
449
+ } finally {
450
+ try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ }
451
+ try { fs.rmSync(evidenceDir, { recursive: true, force: true }); } catch { /* best effort */ }
452
+ }
453
+ }
@@ -0,0 +1,76 @@
1
+ // The oracle registry and its manifest (X-202).
2
+ //
3
+ // The registry is built once, at module load, from the static adapter list and
4
+ // frozen. There is no registration function: a candidate worker (or anything at
5
+ // run time) cannot add, replace or edit an adapter, which is half of "workers
6
+ // cannot alter adapter logic". The other half is the harness digest check in
7
+ // `runOracle`.
8
+ //
9
+ // `oracleManifest()` is the machine-readable description X-208 gates a release on:
10
+ // per adapter, its class, declared prerequisites, platforms (with an honest status
11
+ // per platform), budgets, negative controls, fixture locations, limitations and
12
+ // the logic digest a replay manifest pins. It contains no function bodies and no
13
+ // host details, and its order is fixed, so it is byte-stable across runs.
14
+ import { ADAPTERS } from './adapters.js';
15
+ import { PREREQUISITES, ORACLE_CLASSES, validateOracleSpec } from './oracle.js';
16
+ import { SCENARIO_CLASSES, UNSUPPORTED_NON_TAINT, NON_TAINT_SCHEMA, scenarioClassReportLines } from './scenario-classes.js';
17
+
18
+ const BY_ID = new Map(ADAPTERS.map((a) => [a.id, a]));
19
+ if (BY_ID.size !== ADAPTERS.length) throw new Error('duplicate oracle adapter id');
20
+
21
+ export function getOracle(id) {
22
+ return typeof id === 'string' && BY_ID.has(id) ? BY_ID.get(id) : null;
23
+ }
24
+
25
+ export function listOracles() {
26
+ return [...ADAPTERS].sort((a, b) => a.id.localeCompare(b.id));
27
+ }
28
+
29
+ export const MANIFEST_SCHEMA = 'agentic-security/oracle-manifest';
30
+
31
+ /** The manifest entry for one adapter: data only. */
32
+ export function manifestEntry(a) {
33
+ return {
34
+ id: a.id, class: a.class, version: a.version, logicDigest: a.logicDigest, description: a.description,
35
+ prerequisites: a.prerequisites.map((id) => ({ id, description: PREREQUISITES[id].description })),
36
+ platforms: a.platforms, budgets: a.budgets, requiresFeature: a.requiresFeature ?? null,
37
+ negativeControls: a.negativeControls, fixtures: a.fixtures, limitations: a.limitations,
38
+ receipts: 'issued by the verifier domain only (runOracle); a caller-supplied receipt is not valid',
39
+ };
40
+ }
41
+
42
+ // X-205: the supported-class manifest for non-taint vulnerabilities. Each class names the adapter that executes it, whether
43
+ // that adapter was reused, its fixtures and the user-visible report text (prerequisites, platforms, limitations).
44
+ function scenarioClassEntries(adapters) {
45
+ return SCENARIO_CLASSES.map((c) => {
46
+ const a = adapters.find((x) => x.id === c.adapterId) || null;
47
+ return {
48
+ id: c.id, title: c.title, claim: c.claim, oracle: c.adapterId, adapterReuse: c.adapterReuse,
49
+ requires: c.requires, limitations: c.limitations, fixtures: c.fixtures,
50
+ prerequisites: a ? a.prerequisites.map((id) => ({ id, description: PREREQUISITES[id].description })) : [],
51
+ platforms: a ? a.platforms : null,
52
+ report: scenarioClassReportLines(c, a),
53
+ };
54
+ });
55
+ }
56
+
57
+ export function oracleManifest(adapters = listOracles()) {
58
+ const entries = adapters.map(manifestEntry);
59
+ return {
60
+ schema: MANIFEST_SCHEMA, schemaVersion: '1.0.0',
61
+ classes: [...ORACLE_CLASSES],
62
+ oracles: entries,
63
+ // Every class the PRD names must have an adapter; a missing one is stated, never implied.
64
+ uncoveredClasses: ORACLE_CLASSES.filter((c) => !adapters.some((a) => a.class === c)),
65
+ problems: [
66
+ ...adapters.flatMap((a) => validateOracleSpec(a).map((p) => `${a.id}: ${p}`)),
67
+ ...SCENARIO_CLASSES.filter((c) => !adapters.some((a) => a.id === c.adapterId)).map((c) => `scenario class ${c.id}: its oracle '${c.adapterId}' is not registered`),
68
+ ],
69
+ nonTaint: {
70
+ schema: NON_TAINT_SCHEMA, schemaVersion: '1.0.0',
71
+ classes: scenarioClassEntries(adapters),
72
+ unsupported: UNSUPPORTED_NON_TAINT.map((u) => ({ id: u.id, cwes: u.cwes, reason: u.reason })),
73
+ rules: ['taint absence never refutes a non-taint hypothesis', 'an unsupported or unrun case is never counted in the trusted-negative denominator'],
74
+ },
75
+ };
76
+ }