@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,299 @@
1
+ // Unified configuration for the assurance features (CORE-004).
2
+ //
3
+ // Extends the configuration mechanism the scanner already has instead of adding
4
+ // a second one:
5
+ // - environment variables, `AGENTIC_SECURITY_*` (docs/reference/configuration.md),
6
+ // including the existing `AGENTIC_SECURITY_NO_<NAME>=1` kill-switch shape;
7
+ // - a per-project YAML file under `.agentic-security/` read through
8
+ // posture/state-dir.js and util/yaml.js, exactly as egress-policy.yml is.
9
+ //
10
+ // Precedence, highest first. A higher level always wins over a lower one, and the
11
+ // result says which level decided:
12
+ // 1. kill switch AGENTIC_SECURITY_NO_ASSURANCE=1 (all) or AGENTIC_SECURITY_NO_<FEATURE>=1
13
+ // 2. overrides explicit options from the caller (CLI flags)
14
+ // 3. environment AGENTIC_SECURITY_ASSURANCE_<FEATURE>=1|0
15
+ // 4. project file .agentic-security/assurance.yml
16
+ // 5. default every new feature defaults to OFF
17
+ //
18
+ // Two rules that follow from the trust model:
19
+ // - HIGH-RISK EXECUTION IS OPERATOR-ONLY. The project file lives inside the
20
+ // scanned repository, which is hostile input. A project file can never switch
21
+ // on a feature whose risk class is `high-risk-execution`; only the
22
+ // environment or an explicit override can. The refusal is disclosed, not silent.
23
+ // - INVALID CONFIGURATION FAILS CLOSED. An unknown feature, a non-boolean, or a
24
+ // limit that is not a finite number within its ceiling leaves that feature
25
+ // disabled and is reported in `errors`. It never falls through to a default
26
+ // that is more permissive than what the operator wrote.
27
+ //
28
+ // A disabled feature is inert: nothing in this module touches the scan or report
29
+ // pipeline, so default behaviour is unchanged.
30
+
31
+ import * as fs from 'node:fs';
32
+ import { statePath } from '../state-dir.js';
33
+ import { load as loadYaml } from '../../util/yaml.js';
34
+
35
+ export const CONFIG_FILE = 'assurance.yml';
36
+ export const CONFIG_VERSION = 1;
37
+
38
+ export const RISK_CLASSES = Object.freeze(['passive', 'model-network', 'high-risk-execution']);
39
+
40
+ // Every new feature. `platforms` is the set where the feature is supported at all;
41
+ // off it the result is a typed `unsupported`, with the note disclosed.
42
+ export const FEATURES = Object.freeze({
43
+ 'verification-oracles': {
44
+ risk: 'high-risk-execution', platforms: ['linux', 'darwin'],
45
+ platformNote: 'runtime oracles run through the confinement sandbox; isolation-required checks are only advertised on Linux',
46
+ },
47
+ 'patch-negative-verification': {
48
+ risk: 'high-risk-execution', platforms: ['linux', 'darwin'],
49
+ platformNote: 'replays the exploit against the original and the patched revision through the confinement sandbox, so it needs verification-oracles as well; an unproved isolation control blocks it',
50
+ },
51
+ 'deployment-boundaries': { risk: 'passive', platforms: ['linux', 'darwin', 'win32'], platformNote: null },
52
+ 'invariant-scenarios': {
53
+ risk: 'high-risk-execution', platforms: ['linux', 'darwin'],
54
+ platformNote: 'stateful scenarios execute target code and need the confinement sandbox',
55
+ },
56
+ 'capability-enforcement': {
57
+ risk: 'high-risk-execution', platforms: ['linux'],
58
+ platformNote: 'runner-level filesystem, network and process enforcement is only advertised on Linux; macOS can supervise but not enforce',
59
+ },
60
+ 'model-routing': { risk: 'model-network', platforms: ['linux', 'darwin', 'win32'], platformNote: null },
61
+ 'portfolio-assurance': { risk: 'passive', platforms: ['linux', 'darwin', 'win32'], platformNote: null },
62
+ });
63
+
64
+ // Finite limits with hard ceilings. `enforcedBy` names the helper in THIS module
65
+ // that actually enforces it; `null` means this layer only carries the number for
66
+ // a runner to enforce, and the disclosure says so rather than claiming otherwise.
67
+ export const LIMITS = Object.freeze({
68
+ timeoutMs: { default: 15_000, min: 1, max: 120_000, enforcedBy: 'withDeadline' },
69
+ retries: { default: 2, min: 0, max: 5, enforcedBy: 'retryBounded' },
70
+ maxOutputBytes: { default: 262_144, min: 1, max: 16 * 1024 * 1024, enforcedBy: 'capOutput' },
71
+ maxRequestBytes: { default: 262_144, min: 1, max: 16 * 1024 * 1024, enforcedBy: 'guardedModelCall' },
72
+ maxFileBytes: { default: 5 * 1024 * 1024, min: 1, max: 64 * 1024 * 1024, enforcedBy: 'readFileBounded' },
73
+ maxMemoryMiB: { default: 6144, min: 64, max: 65_536, enforcedBy: null },
74
+ });
75
+
76
+ const FEATURE_IDS = Object.keys(FEATURES);
77
+ const envName = (id) => id.toUpperCase().replace(/-/g, '_');
78
+
79
+ function parseBool(raw) {
80
+ if (raw === true || raw === '1' || raw === 'true') return true;
81
+ if (raw === false || raw === '0' || raw === 'false') return false;
82
+ return undefined;
83
+ }
84
+
85
+ function checkLimits(limits, where, errors) {
86
+ const out = {};
87
+ if (limits === undefined || limits === null) return out;
88
+ if (typeof limits !== 'object' || Array.isArray(limits)) { errors.push({ code: 'invalid-config', path: where, message: 'limits must be a mapping' }); return null; }
89
+ let bad = false;
90
+ for (const [k, v] of Object.entries(limits)) {
91
+ const spec = LIMITS[k];
92
+ if (!spec) { errors.push({ code: 'invalid-config', path: `${where}.${k}`, message: `unknown limit '${k}'` }); bad = true; continue; }
93
+ if (typeof v !== 'number' || !Number.isFinite(v) || !Number.isInteger(v)) { errors.push({ code: 'invalid-config', path: `${where}.${k}`, message: 'must be a finite integer' }); bad = true; continue; }
94
+ if (v < spec.min || v > spec.max) { errors.push({ code: 'invalid-config', path: `${where}.${k}`, message: `must be between ${spec.min} and ${spec.max}` }); bad = true; continue; }
95
+ out[k] = v;
96
+ }
97
+ return bad ? null : out;
98
+ }
99
+
100
+ function readProjectFile(scanRoot, readFile, errors) {
101
+ let fp;
102
+ try { fp = statePath(scanRoot, CONFIG_FILE); } catch { return null; }
103
+ let raw;
104
+ try { raw = readFile(fp); } catch { return null; } // absent file is the normal case
105
+ let doc;
106
+ try { doc = loadYaml(raw); } catch (e) {
107
+ errors.push({ code: 'invalid-config', path: CONFIG_FILE, message: `not valid YAML: ${String(e.message).split('\n')[0]}` });
108
+ return { broken: true };
109
+ }
110
+ if (doc === undefined) return { features: {}, limits: {} };
111
+ if (typeof doc !== 'object' || Array.isArray(doc)) { errors.push({ code: 'invalid-config', path: CONFIG_FILE, message: 'must be a mapping' }); return { broken: true }; }
112
+ for (const k of Object.keys(doc)) {
113
+ if (!['version', 'features', 'limits'].includes(k)) errors.push({ code: 'invalid-config', path: `${CONFIG_FILE}.${k}`, message: `unknown key '${k}'` });
114
+ }
115
+ if (doc.version !== undefined && doc.version !== CONFIG_VERSION) {
116
+ errors.push({ code: 'invalid-config', path: `${CONFIG_FILE}.version`, message: `unsupported version ${JSON.stringify(doc.version)} (supported: ${CONFIG_VERSION})` });
117
+ return { broken: true };
118
+ }
119
+ return doc;
120
+ }
121
+
122
+ /**
123
+ * Resolve the effective configuration.
124
+ *
125
+ * @param {object} [opts]
126
+ * @param {string} [opts.scanRoot]
127
+ * @param {object} [opts.env] defaults to process.env
128
+ * @param {object} [opts.overrides] `{ features: {id: bool}, limits: {...} }` from explicit flags
129
+ * @param {string} [opts.platform] defaults to process.platform
130
+ * @param {(p:string)=>string} [opts.readFile]
131
+ */
132
+ export function resolveAssuranceConfig({ scanRoot, env = process.env, overrides = {}, platform = process.platform, readFile = (p) => fs.readFileSync(p, 'utf8') } = {}) {
133
+ const errors = [];
134
+ const file = readProjectFile(scanRoot, readFile, errors);
135
+ const fileOk = file && !file.broken ? file : null;
136
+ const globalKill = env.AGENTIC_SECURITY_NO_ASSURANCE === '1';
137
+
138
+ const fileFeatures = (fileOk && fileOk.features && typeof fileOk.features === 'object' && !Array.isArray(fileOk.features)) ? fileOk.features : {};
139
+ if (fileOk && fileOk.features !== undefined && fileFeatures !== fileOk.features) errors.push({ code: 'invalid-config', path: `${CONFIG_FILE}.features`, message: 'must be a mapping' });
140
+ for (const id of Object.keys(fileFeatures)) {
141
+ if (!FEATURES[id]) errors.push({ code: 'invalid-config', path: `${CONFIG_FILE}.features.${id}`, message: `unknown feature '${id}'` });
142
+ }
143
+
144
+ const features = {};
145
+ for (const id of FEATURE_IDS) {
146
+ const spec = FEATURES[id];
147
+ const entry = { id, risk: spec.risk, enabled: false, source: 'default', reason: 'default: off', killed: false, notes: [] };
148
+ let invalid = false;
149
+
150
+ // 5/4: project file
151
+ const fe = fileFeatures[id];
152
+ if (fe !== undefined) {
153
+ if (typeof fe !== 'object' || fe === null || Array.isArray(fe)) { errors.push({ code: 'invalid-config', path: `${CONFIG_FILE}.features.${id}`, message: 'must be a mapping' }); invalid = true; }
154
+ else {
155
+ for (const k of Object.keys(fe)) if (!['enabled', 'limits'].includes(k)) { errors.push({ code: 'invalid-config', path: `${CONFIG_FILE}.features.${id}.${k}`, message: `unknown key '${k}'` }); invalid = true; }
156
+ if (fe.enabled !== undefined) {
157
+ const b = parseBool(fe.enabled);
158
+ if (typeof fe.enabled !== 'boolean') { errors.push({ code: 'invalid-config', path: `${CONFIG_FILE}.features.${id}.enabled`, message: 'must be true or false' }); invalid = true; }
159
+ else if (b && spec.risk === 'high-risk-execution') {
160
+ entry.notes.push('project file requested enablement; refused: high-risk execution can only be enabled by the operator through the environment or an explicit option');
161
+ entry.source = 'project-file-refused'; entry.reason = 'high-risk execution cannot be enabled by a project file';
162
+ } else { entry.enabled = b; entry.source = 'project-file'; entry.reason = `project file sets enabled=${b}`; }
163
+ }
164
+ }
165
+ }
166
+ // 3: environment
167
+ const rawEnv = env[`AGENTIC_SECURITY_ASSURANCE_${envName(id)}`];
168
+ if (rawEnv !== undefined && rawEnv !== '') {
169
+ const b = parseBool(rawEnv);
170
+ if (b === undefined) { errors.push({ code: 'invalid-config', path: `AGENTIC_SECURITY_ASSURANCE_${envName(id)}`, message: `must be 1/true or 0/false, got ${JSON.stringify(rawEnv)}` }); invalid = true; }
171
+ else { entry.enabled = b; entry.source = 'env'; entry.reason = `environment sets enabled=${b}`; }
172
+ }
173
+ // 2: explicit overrides
174
+ const ov = overrides.features?.[id];
175
+ if (ov !== undefined) {
176
+ if (typeof ov !== 'boolean') { errors.push({ code: 'invalid-config', path: `overrides.features.${id}`, message: 'must be a boolean' }); invalid = true; }
177
+ else { entry.enabled = ov; entry.source = 'override'; entry.reason = `explicit option sets enabled=${ov}`; }
178
+ }
179
+ // invalid configuration fails closed
180
+ if (invalid) { entry.enabled = false; entry.source = 'invalid-config'; entry.reason = 'configuration for this feature is invalid; disabled'; }
181
+ // 1: kill switch
182
+ if (globalKill || env[`AGENTIC_SECURITY_NO_${envName(id)}`] === '1') {
183
+ entry.enabled = false; entry.killed = true; entry.source = 'kill-switch';
184
+ entry.reason = globalKill ? 'AGENTIC_SECURITY_NO_ASSURANCE=1' : `AGENTIC_SECURITY_NO_${envName(id)}=1`;
185
+ }
186
+ // platform disclosure
187
+ const supported = spec.platforms.includes(platform);
188
+ entry.platform = { supported, platform, note: spec.platformNote, supportedPlatforms: [...spec.platforms] };
189
+ features[id] = entry;
190
+ }
191
+
192
+ // limits: file -> overrides, each validated; a bad level is dropped whole
193
+ const limits = {};
194
+ for (const [k, spec] of Object.entries(LIMITS)) limits[k] = { value: spec.default, source: 'default', enforcedBy: spec.enforcedBy };
195
+ const apply = (obj, source, where) => {
196
+ const ok = checkLimits(obj, where, errors);
197
+ if (!ok) return;
198
+ for (const [k, v] of Object.entries(ok)) limits[k] = { value: v, source, enforcedBy: LIMITS[k].enforcedBy };
199
+ };
200
+ if (fileOk) apply(fileOk.limits, 'project-file', `${CONFIG_FILE}.limits`);
201
+ apply(overrides.limits, 'override', 'overrides.limits');
202
+
203
+ return { version: CONFIG_VERSION, features, limits, errors, killSwitch: globalKill };
204
+ }
205
+
206
+ /** Plain numeric limits for a caller that only wants the numbers. */
207
+ export function limitValues(config) {
208
+ return Object.fromEntries(Object.entries(config.limits).map(([k, v]) => [k, v.value]));
209
+ }
210
+
211
+ // ---------------------------------------------------------------- typed results
212
+
213
+ export const RESULT_STATUSES = Object.freeze(['ok', 'disabled', 'blocked', 'unsupported', 'degraded']);
214
+ export const RESULT_CODES = Object.freeze([
215
+ 'disabled', 'kill-switch', 'platform-unsupported', 'invalid-config', 'egress-denied', 'limit-exceeded', 'timeout',
216
+ 'missing-provider', 'missing-credential', 'missing-collector', 'missing-execution-backend', 'missing-dependency',
217
+ ]);
218
+
219
+ export function typed(status, code, reason, extra = {}) {
220
+ return { status, code, reason, ...extra };
221
+ }
222
+
223
+ /** The gate every new feature passes through. Never throws, never prompts, never reaches the network. */
224
+ export function featureStatus(config, id) {
225
+ const f = config?.features?.[id];
226
+ if (!f) return typed('blocked', 'invalid-config', `unknown feature '${id}'`);
227
+ if (f.killed) return typed('blocked', 'kill-switch', f.reason, { feature: id });
228
+ if (f.source === 'invalid-config') return typed('blocked', 'invalid-config', f.reason, { feature: id });
229
+ if (!f.enabled) return typed('disabled', 'disabled', f.reason, { feature: id });
230
+ if (!f.platform.supported) {
231
+ return typed('unsupported', 'platform-unsupported', `${id} is not supported on ${f.platform.platform}${f.platform.note ? `: ${f.platform.note}` : ''}`, { feature: id, supportedPlatforms: f.platform.supportedPlatforms });
232
+ }
233
+ return typed('ok', null, f.reason, { feature: id });
234
+ }
235
+
236
+ const REQUIREMENT_KINDS = Object.freeze({
237
+ provider: 'missing-provider',
238
+ credential: 'missing-credential',
239
+ collector: 'missing-collector',
240
+ 'execution-backend': 'missing-execution-backend',
241
+ dependency: 'missing-dependency',
242
+ });
243
+
244
+ /** A credential requirement that only checks that an environment variable is set. Never prompts, never reads its value into a result. */
245
+ export function envPresent(name, env = process.env) {
246
+ return () => typeof env[name] === 'string' && env[name].length > 0;
247
+ }
248
+
249
+ /**
250
+ * Evaluate declared requirements. A missing REQUIRED one blocks; a missing
251
+ * OPTIONAL one degrades. A probe that throws counts as missing. No requirement is
252
+ * ever satisfied by a fallback, a prompt or a cloud default.
253
+ *
254
+ * requirements: [{ kind, name, optional?, present: () => boolean }]
255
+ */
256
+ export function evaluateRequirements(requirements = []) {
257
+ const missingRequired = [];
258
+ const missingOptional = [];
259
+ for (const r of requirements) {
260
+ let present = false;
261
+ try { present = !!r.present(); } catch { present = false; }
262
+ if (present) continue;
263
+ const item = { kind: r.kind, name: r.name, code: REQUIREMENT_KINDS[r.kind] || 'missing-dependency' };
264
+ (r.optional ? missingOptional : missingRequired).push(item);
265
+ }
266
+ return { missingRequired, missingOptional };
267
+ }
268
+
269
+ /**
270
+ * Run `fn` only when the feature is enabled, supported here and its required
271
+ * dependencies are present. Returns a typed result either way; `fn` is never
272
+ * called otherwise. Optional gaps yield `degraded` and `fn` receives them so it
273
+ * can skip that part of its work.
274
+ */
275
+ export async function runFeature(config, id, { requirements = [] } = {}, fn) {
276
+ const gate = featureStatus(config, id);
277
+ if (gate.status !== 'ok') return gate;
278
+ const { missingRequired, missingOptional } = evaluateRequirements(requirements);
279
+ if (missingRequired.length) {
280
+ const first = missingRequired[0];
281
+ return typed('blocked', first.code, `${id} needs ${first.kind} '${first.name}', which is not available`, { feature: id, missing: missingRequired });
282
+ }
283
+ const value = await fn({ limits: limitValues(config), missingOptional });
284
+ if (missingOptional.length) return typed('degraded', missingOptional[0].code, `${id} ran without optional ${missingOptional.map(m => `${m.kind} '${m.name}'`).join(', ')}`, { feature: id, missing: missingOptional, value });
285
+ return typed('ok', null, gate.reason, { feature: id, value });
286
+ }
287
+
288
+ /** Operator-readable disclosure of every feature, its state and its platform support. */
289
+ export function describeAssuranceConfig(config) {
290
+ return {
291
+ killSwitch: config.killSwitch,
292
+ features: Object.values(config.features).map(f => ({
293
+ id: f.id, risk: f.risk, enabled: f.enabled, source: f.source, reason: f.reason,
294
+ platformSupported: f.platform.supported, platformNote: f.platform.supported ? null : f.platform.note, notes: f.notes,
295
+ })),
296
+ limits: Object.fromEntries(Object.entries(config.limits).map(([k, v]) => [k, { value: v.value, source: v.source, enforced: v.enforcedBy ? `by ${v.enforcedBy}` : 'not enforced by this layer (carried for the runner)' }])),
297
+ errors: config.errors,
298
+ };
299
+ }
@@ -0,0 +1,315 @@
1
+ // Observation bindings, capability decisions, routing labels and release
2
+ // evidence (CORE-002), plus cross-record validation (duplicate ids, dangling
3
+ // references). Verification records live in verification-record.js.
4
+ //
5
+ // Lineage separation: an observation binding refers to a lineage runtime
6
+ // observation BY ID ONLY. It never embeds, imports or mutates lineage state;
7
+ // lineage owns the graph and this package owns the vulnerability-evidence claim
8
+ // that a given observation bears on a given hypothesis.
9
+
10
+ import {
11
+ SCHEMA_VERSION, makeCtx, result, guardObject, checkHeader, checkFields, checkEnum, checkDigest, checkCommit,
12
+ checkString, checkId, isPlainObject, isDigest,
13
+ } from './schema-kit.js';
14
+ import { semanticId, ID_PREFIXES } from './identity.js';
15
+ import { validateVerificationRecord, verificationClaimKey } from './verification-record.js';
16
+
17
+ // ---------------------------------------------------------------- observation binding
18
+
19
+ export const OBSERVATION_BINDING_SCHEMA = 'agentic-security/observation-binding';
20
+ export const BINDING_PROVENANCE = Object.freeze(['static-config', 'runtime-observation', 'inferred']);
21
+ export const PATH_STATES = Object.freeze(['possible', 'blocked', 'unresolved', 'runtime-supported']);
22
+ export const OBSERVATION_COMPLETENESS = Object.freeze(['complete', 'sampled', 'unknown']);
23
+
24
+ const OB_ALLOWED = [
25
+ 'schema', 'schemaVersion', 'id', 'hypothesisId', 'observationRef', 'boundaryRef', 'provenance', 'pathState',
26
+ 'completeness', 'window', 'eventDiscriminator', 'migration', 'createdAt',
27
+ ];
28
+ const OB_REQUIRED = ['schema', 'schemaVersion', 'id', 'hypothesisId', 'observationRef', 'provenance', 'pathState', 'completeness'];
29
+
30
+ // The semantic identity of a binding excludes the event discriminator.
31
+ export const OBSERVATION_BINDING_KEY_FIELDS = Object.freeze([
32
+ 'hypothesisId', 'observationRef', 'boundaryRef', 'provenance', 'pathState', 'completeness', 'window',
33
+ ]);
34
+
35
+ /** Identity of WHAT the binding says, independent of which event delivered it. */
36
+ export function observationBindingKey(b) {
37
+ return semanticId('obkey', b, OBSERVATION_BINDING_KEY_FIELDS);
38
+ }
39
+
40
+ /** Id of this binding instance: the key plus the optional explicit event discriminator. */
41
+ export function observationBindingId(b) {
42
+ return semanticId(ID_PREFIXES.observationBinding, b, [...OBSERVATION_BINDING_KEY_FIELDS, 'eventDiscriminator']);
43
+ }
44
+
45
+ export function validateObservationBinding(b) {
46
+ const g = guardObject(b);
47
+ const ctx = g.ctx;
48
+ if (!g.ok) return result(ctx);
49
+ if (!checkHeader(ctx, b, OBSERVATION_BINDING_SCHEMA)) return result(ctx);
50
+ checkFields(ctx, b, OB_ALLOWED, OB_REQUIRED);
51
+ checkString(ctx, 'hypothesisId', b.hypothesisId);
52
+ if (!isPlainObject(b.observationRef)) ctx.err('BAD_TYPE', 'observationRef', 'must be an object');
53
+ else {
54
+ for (const k of Object.keys(b.observationRef)) if (!['observationId', 'adapter'].includes(k)) ctx.err('UNKNOWN_FIELD', `observationRef.${k}`, 'not part of observationRef');
55
+ checkString(ctx, 'observationRef.observationId', b.observationRef.observationId);
56
+ }
57
+ if (b.boundaryRef !== undefined && b.boundaryRef !== null) checkString(ctx, 'boundaryRef', b.boundaryRef);
58
+ const provOk = checkEnum(ctx, 'provenance', b.provenance, BINDING_PROVENANCE);
59
+ const stateOk = checkEnum(ctx, 'pathState', b.pathState, PATH_STATES);
60
+ const compOk = checkEnum(ctx, 'completeness', b.completeness, OBSERVATION_COMPLETENESS);
61
+ if (b.window !== undefined && b.window !== null) {
62
+ if (!isPlainObject(b.window) || !Object.keys(b.window).every(k => ['start', 'end'].includes(k))) ctx.err('BAD_TYPE', 'window', 'must be {start, end}');
63
+ else { checkString(ctx, 'window.start', b.window.start); checkString(ctx, 'window.end', b.window.end); }
64
+ }
65
+ if (b.eventDiscriminator !== undefined && b.eventDiscriminator !== null) checkString(ctx, 'eventDiscriminator', b.eventDiscriminator);
66
+ if (provOk && stateOk && compOk) {
67
+ if (b.pathState === 'runtime-supported' && b.provenance !== 'runtime-observation') {
68
+ ctx.err('RULE_VIOLATION', 'pathState', `'runtime-supported' requires provenance 'runtime-observation'`);
69
+ }
70
+ if (b.pathState === 'blocked' && b.provenance === 'runtime-observation' && b.completeness !== 'complete') {
71
+ ctx.err('RULE_VIOLATION', 'pathState', `absence in a ${b.completeness} observation cannot establish 'blocked'`);
72
+ }
73
+ if (b.provenance === 'runtime-observation' && !(isPlainObject(b.window) && b.window.start && b.window.end)) {
74
+ ctx.err('RULE_VIOLATION', 'window', `a runtime observation binding must state its observation window`);
75
+ }
76
+ }
77
+ checkId(ctx, b, observationBindingId(b));
78
+ return result(ctx);
79
+ }
80
+
81
+ // ---------------------------------------------------------------- capability decision
82
+
83
+ export const CAPABILITY_DECISION_SCHEMA = 'agentic-security/capability-decision';
84
+ export const CAPABILITY_KINDS = Object.freeze(['filesystem-read', 'filesystem-write', 'command', 'network', 'tool', 'delegation']);
85
+ export const CAPABILITY_DECISIONS = Object.freeze(['allow', 'deny', 'blocked', 'unsupported']);
86
+ export const CAPABILITY_MEDIATIONS = Object.freeze(['runner', 'proxy', 'in-process-policy', 'hook-advisory', 'none']);
87
+
88
+ const CD_ALLOWED = [
89
+ 'schema', 'schemaVersion', 'id', 'taskId', 'capability', 'subject', 'decision', 'mediation', 'enforced', 'backend',
90
+ 'probeDigest', 'reason', 'migration', 'createdAt',
91
+ ];
92
+ const CD_REQUIRED = ['schema', 'schemaVersion', 'id', 'taskId', 'capability', 'subject', 'decision', 'mediation', 'enforced', 'backend', 'reason'];
93
+
94
+ export const CAPABILITY_DECISION_ID_FIELDS = Object.freeze([
95
+ 'taskId', 'capability', 'subject', 'decision', 'mediation', 'enforced', 'backend', 'probeDigest', 'reason',
96
+ ]);
97
+
98
+ export function capabilityDecisionId(d) {
99
+ return semanticId(ID_PREFIXES.capabilityDecision, d, CAPABILITY_DECISION_ID_FIELDS);
100
+ }
101
+
102
+ export function validateCapabilityDecision(d) {
103
+ const g = guardObject(d);
104
+ const ctx = g.ctx;
105
+ if (!g.ok) return result(ctx);
106
+ if (!checkHeader(ctx, d, CAPABILITY_DECISION_SCHEMA)) return result(ctx);
107
+ checkFields(ctx, d, CD_ALLOWED, CD_REQUIRED);
108
+ checkString(ctx, 'taskId', d.taskId);
109
+ checkEnum(ctx, 'capability', d.capability, CAPABILITY_KINDS);
110
+ checkString(ctx, 'subject', d.subject);
111
+ const decOk = checkEnum(ctx, 'decision', d.decision, CAPABILITY_DECISIONS);
112
+ const medOk = checkEnum(ctx, 'mediation', d.mediation, CAPABILITY_MEDIATIONS);
113
+ if (typeof d.enforced !== 'boolean') ctx.err('BAD_TYPE', 'enforced', 'must be a boolean');
114
+ if (d.backend !== null && d.backend !== undefined) checkString(ctx, 'backend', d.backend);
115
+ if (d.probeDigest !== undefined && d.probeDigest !== null) checkDigest(ctx, 'probeDigest', d.probeDigest);
116
+ checkString(ctx, 'reason', d.reason);
117
+ if (decOk && medOk && d.enforced === true) {
118
+ // A hook response or an in-process check explains a decision; it does not
119
+ // prove isolation. Only the runner or a proxy, with a named backend and an
120
+ // active probe, may claim enforcement.
121
+ if (!['runner', 'proxy'].includes(d.mediation)) ctx.err('RULE_VIOLATION', 'enforced', `mediation '${d.mediation}' cannot claim enforcement`);
122
+ if (!(typeof d.backend === 'string' && d.backend)) ctx.err('RULE_VIOLATION', 'backend', 'an enforced decision must name its backend');
123
+ if (!isDigest(d.probeDigest)) ctx.err('RULE_VIOLATION', 'probeDigest', 'an enforced decision must carry the digest of its active probe evidence');
124
+ if (!['allow', 'deny'].includes(d.decision)) ctx.err('RULE_VIOLATION', 'enforced', `decision '${d.decision}' cannot be enforced`);
125
+ }
126
+ checkId(ctx, d, capabilityDecisionId(d));
127
+ return result(ctx);
128
+ }
129
+
130
+ // ---------------------------------------------------------------- routing label
131
+
132
+ export const ROUTING_LABEL_SCHEMA = 'agentic-security/routing-label';
133
+ export const ROUTING_TASK_KINDS = Object.freeze(['discovery', 'triage', 'verification-planning', 'repair']);
134
+ export const ROUTING_OUTCOMES = Object.freeze(['correct', 'incorrect', 'unknown', 'delayed']);
135
+ export const ROUTING_LABEL_SOURCES = Object.freeze(['adjudication', 'trusted-execution', 'none']);
136
+
137
+ const RL_ALLOWED = [
138
+ 'schema', 'schemaVersion', 'id', 'taskId', 'taskKind', 'stratum', 'model', 'outcome', 'labelSource', 'verificationRecordId', 'migration', 'createdAt',
139
+ ];
140
+ const RL_REQUIRED = ['schema', 'schemaVersion', 'id', 'taskId', 'taskKind', 'stratum', 'model', 'outcome', 'labelSource'];
141
+ export const ROUTING_LABEL_ID_FIELDS = Object.freeze(['taskId', 'taskKind', 'stratum', 'model', 'outcome', 'labelSource', 'verificationRecordId']);
142
+
143
+ export function routingLabelId(l) {
144
+ return semanticId(ID_PREFIXES.routingLabel, l, ROUTING_LABEL_ID_FIELDS);
145
+ }
146
+
147
+ export function validateRoutingLabel(l) {
148
+ const g = guardObject(l);
149
+ const ctx = g.ctx;
150
+ if (!g.ok) return result(ctx);
151
+ if (!checkHeader(ctx, l, ROUTING_LABEL_SCHEMA)) return result(ctx);
152
+ checkFields(ctx, l, RL_ALLOWED, RL_REQUIRED);
153
+ checkString(ctx, 'taskId', l.taskId);
154
+ checkEnum(ctx, 'taskKind', l.taskKind, ROUTING_TASK_KINDS);
155
+ checkString(ctx, 'stratum', l.stratum);
156
+ checkString(ctx, 'model', l.model);
157
+ const outOk = checkEnum(ctx, 'outcome', l.outcome, ROUTING_OUTCOMES);
158
+ const srcOk = checkEnum(ctx, 'labelSource', l.labelSource, ROUTING_LABEL_SOURCES);
159
+ if (l.verificationRecordId !== undefined && l.verificationRecordId !== null) {
160
+ if (typeof l.verificationRecordId !== 'string' || !l.verificationRecordId.startsWith('vrec:')) ctx.err('BAD_ID', 'verificationRecordId', `must be a verification record id ('vrec:...')`);
161
+ }
162
+ if (outOk && srcOk) {
163
+ // Delayed and unknown outcomes are retained, but they cannot carry a source
164
+ // that implies they were adjudicated; a decided label must name its source.
165
+ if (['correct', 'incorrect'].includes(l.outcome) && l.labelSource === 'none') {
166
+ ctx.err('RULE_VIOLATION', 'labelSource', `a '${l.outcome}' label needs an adjudication or trusted-execution source`);
167
+ }
168
+ if (['unknown', 'delayed'].includes(l.outcome) && l.labelSource !== 'none') {
169
+ ctx.err('RULE_VIOLATION', 'labelSource', `an '${l.outcome}' label has no decided source`);
170
+ }
171
+ }
172
+ checkId(ctx, l, routingLabelId(l));
173
+ return result(ctx);
174
+ }
175
+
176
+ // ---------------------------------------------------------------- release evidence
177
+
178
+ export const RELEASE_EVIDENCE_SCHEMA = 'agentic-security/release-evidence';
179
+ export const CLAIM_STATUSES = Object.freeze(['verified', 'failed', 'skipped', 'incomplete', 'unverified']);
180
+
181
+ const RE_ALLOWED = ['schema', 'schemaVersion', 'id', 'subject', 'claims', 'complete', 'migration', 'createdAt'];
182
+ const RE_REQUIRED = ['schema', 'schemaVersion', 'id', 'subject', 'claims', 'complete'];
183
+ export const RELEASE_EVIDENCE_ID_FIELDS = Object.freeze(['subject', 'claims', 'complete']);
184
+
185
+ export function releaseEvidenceId(r) {
186
+ return semanticId(ID_PREFIXES.releaseEvidence, r, RELEASE_EVIDENCE_ID_FIELDS);
187
+ }
188
+
189
+ /** `complete` as the claims support it: every claim verified, no gaps, exact commit and bundle bound. */
190
+ export function deriveReleaseComplete(r) {
191
+ if (!isPlainObject(r?.subject) || !r.subject.commit || !r.subject.bundleDigest) return false;
192
+ if (!Array.isArray(r.claims) || r.claims.length === 0) return false;
193
+ return r.claims.every(c => isPlainObject(c) && c.status === 'verified' && (!Array.isArray(c.gaps) || c.gaps.length === 0));
194
+ }
195
+
196
+ export function validateReleaseEvidence(r) {
197
+ const g = guardObject(r);
198
+ const ctx = g.ctx;
199
+ if (!g.ok) return result(ctx);
200
+ if (!checkHeader(ctx, r, RELEASE_EVIDENCE_SCHEMA)) return result(ctx);
201
+ checkFields(ctx, r, RE_ALLOWED, RE_REQUIRED);
202
+ if (!isPlainObject(r.subject)) ctx.err('BAD_TYPE', 'subject', 'must be an object');
203
+ else {
204
+ for (const k of Object.keys(r.subject)) if (!['commit', 'bundleDigest', 'policyDigest'].includes(k)) ctx.err('UNKNOWN_FIELD', `subject.${k}`, 'not part of subject');
205
+ checkCommit(ctx, 'subject.commit', r.subject.commit ?? null);
206
+ checkDigest(ctx, 'subject.bundleDigest', r.subject.bundleDigest ?? null, { nullable: true });
207
+ if (r.subject.policyDigest !== undefined && r.subject.policyDigest !== null) checkDigest(ctx, 'subject.policyDigest', r.subject.policyDigest);
208
+ }
209
+ if (typeof r.complete !== 'boolean') ctx.err('BAD_TYPE', 'complete', 'must be a boolean');
210
+ if (!Array.isArray(r.claims)) ctx.err('BAD_TYPE', 'claims', 'must be an array');
211
+ else {
212
+ const seen = new Set();
213
+ r.claims.forEach((c, i) => {
214
+ const p = `claims[${i}]`;
215
+ if (!isPlainObject(c)) { ctx.err('BAD_TYPE', p, 'claim must be an object'); return; }
216
+ for (const k of Object.keys(c)) if (!['id', 'statement', 'status', 'evidenceRefs', 'gaps'].includes(k)) ctx.err('UNKNOWN_FIELD', `${p}.${k}`, 'not part of a claim');
217
+ if (checkString(ctx, `${p}.id`, c.id)) {
218
+ if (seen.has(c.id)) ctx.err('DUPLICATE_ID', `${p}.id`, `duplicate claim id '${c.id}'`);
219
+ seen.add(c.id);
220
+ }
221
+ checkString(ctx, `${p}.statement`, c.statement);
222
+ const stOk = checkEnum(ctx, `${p}.status`, c.status, CLAIM_STATUSES);
223
+ if (!Array.isArray(c.evidenceRefs)) ctx.err('BAD_TYPE', `${p}.evidenceRefs`, 'must be an array');
224
+ else c.evidenceRefs.forEach((ref, j) => {
225
+ if (typeof ref !== 'string' || !(ref.startsWith('vrec:') || isDigest(ref))) ctx.err('BAD_ID', `${p}.evidenceRefs[${j}]`, `must be a verification record id or a sha256 digest`);
226
+ });
227
+ if (!Array.isArray(c.gaps) || !c.gaps.every(x => typeof x === 'string' && x.trim())) ctx.err('BAD_TYPE', `${p}.gaps`, 'must be an array of non-empty strings');
228
+ if (stOk && Array.isArray(c.evidenceRefs) && Array.isArray(c.gaps)) {
229
+ if (c.status === 'verified' && c.evidenceRefs.length === 0) ctx.err('RULE_VIOLATION', `${p}.evidenceRefs`, `a 'verified' claim must cite evidence`);
230
+ if (c.status === 'verified' && c.gaps.length > 0) ctx.err('RULE_VIOLATION', `${p}.gaps`, `a 'verified' claim cannot list gaps`);
231
+ if (c.status !== 'verified' && c.gaps.length === 0) ctx.err('RULE_VIOLATION', `${p}.gaps`, `a '${c.status}' claim must say what is missing`);
232
+ }
233
+ });
234
+ if (typeof r.complete === 'boolean' && r.complete !== deriveReleaseComplete(r)) {
235
+ ctx.err('RULE_VIOLATION', 'complete', `claimed complete=${r.complete} but the claims and subject support complete=${deriveReleaseComplete(r)}`);
236
+ }
237
+ }
238
+ checkId(ctx, r, releaseEvidenceId(r));
239
+ return result(ctx);
240
+ }
241
+
242
+ // ---------------------------------------------------------------- record set
243
+
244
+ const VALIDATORS = Object.freeze({
245
+ verificationRecords: validateVerificationRecord,
246
+ observationBindings: validateObservationBinding,
247
+ capabilityDecisions: validateCapabilityDecision,
248
+ routingLabels: validateRoutingLabel,
249
+ releaseEvidence: validateReleaseEvidence,
250
+ });
251
+
252
+ /**
253
+ * Validate a whole set: every record individually, then duplicate ids within and
254
+ * across types, dangling references, and the cross-record honesty rules.
255
+ * Input is `{ verificationRecords, observationBindings, capabilityDecisions, routingLabels, releaseEvidence }`,
256
+ * each an optional array.
257
+ */
258
+ export function validateRecordSet(set) {
259
+ const ctx = makeCtx();
260
+ if (!isPlainObject(set)) { ctx.err('NOT_AN_OBJECT', '', 'record set must be an object'); return result(ctx); }
261
+ for (const k of Object.keys(set)) if (!VALIDATORS[k]) ctx.err('UNKNOWN_FIELD', k, `'${k}' is not a record collection`);
262
+ const allIds = new Map();
263
+ for (const [name, validate] of Object.entries(VALIDATORS)) {
264
+ const list = set[name];
265
+ if (list === undefined) continue;
266
+ if (!Array.isArray(list)) { ctx.err('BAD_TYPE', name, 'must be an array'); continue; }
267
+ list.forEach((rec, i) => {
268
+ const r = validate(rec);
269
+ for (const e of r.errors) ctx.err(e.code, `${name}[${i}]${e.path ? '.' + e.path : ''}`, e.message);
270
+ if (isPlainObject(rec) && typeof rec.id === 'string') {
271
+ if (allIds.has(rec.id)) ctx.err('DUPLICATE_ID', `${name}[${i}].id`, `id '${rec.id}' is already used by ${allIds.get(rec.id)}`);
272
+ else allIds.set(rec.id, `${name}[${i}]`);
273
+ }
274
+ });
275
+ }
276
+
277
+ const vrecs = new Map((Array.isArray(set.verificationRecords) ? set.verificationRecords : []).filter(isPlainObject).map(r => [r.id, r]));
278
+
279
+ // two records with the same claim key but different outcomes are a conflict, not two facts
280
+ const byClaim = new Map();
281
+ for (const [id, r] of vrecs) {
282
+ const key = verificationClaimKey(r);
283
+ const prior = byClaim.get(key);
284
+ if (prior && prior.outcome !== r.outcome) ctx.err('RULE_VIOLATION', `verificationRecords`, `records '${prior.id}' and '${id}' make the same claim with different outcomes ('${prior.outcome}' vs '${r.outcome}')`);
285
+ byClaim.set(key, r);
286
+ }
287
+
288
+ for (const [id, r] of vrecs) {
289
+ const rid = r.repair?.replayRecordId;
290
+ if (rid && !vrecs.has(rid)) ctx.err('DANGLING_REF', `verificationRecords.${id}.repair.replayRecordId`, `replay record '${rid}' is not in the set`);
291
+ }
292
+ (Array.isArray(set.observationBindings) ? set.observationBindings : []).filter(isPlainObject).forEach((b, i) => {
293
+ // hypothesis ids are findings' stable ids, not record ids; a binding is dangling only against explicit vrec refs
294
+ if (typeof b.hypothesisId === 'string' && b.hypothesisId.startsWith('vrec:') && !vrecs.has(b.hypothesisId)) {
295
+ ctx.err('DANGLING_REF', `observationBindings[${i}].hypothesisId`, `'${b.hypothesisId}' is not in the set`);
296
+ }
297
+ });
298
+ (Array.isArray(set.routingLabels) ? set.routingLabels : []).filter(isPlainObject).forEach((l, i) => {
299
+ if (l.verificationRecordId && !vrecs.has(l.verificationRecordId)) ctx.err('DANGLING_REF', `routingLabels[${i}].verificationRecordId`, `'${l.verificationRecordId}' is not in the set`);
300
+ });
301
+ (Array.isArray(set.releaseEvidence) ? set.releaseEvidence : []).filter(isPlainObject).forEach((rev, ri) => {
302
+ (Array.isArray(rev.claims) ? rev.claims : []).filter(isPlainObject).forEach((c, ci) => {
303
+ for (const ref of Array.isArray(c.evidenceRefs) ? c.evidenceRefs : []) {
304
+ if (typeof ref === 'string' && ref.startsWith('vrec:')) {
305
+ const v = vrecs.get(ref);
306
+ if (!v) ctx.err('DANGLING_REF', `releaseEvidence[${ri}].claims[${ci}].evidenceRefs`, `'${ref}' is not in the set`);
307
+ else if (c.status === 'verified' && !['confirmed', 'refuted'].includes(v.outcome)) {
308
+ ctx.err('RULE_VIOLATION', `releaseEvidence[${ri}].claims[${ci}]`, `claim is 'verified' but cites '${ref}' whose outcome is '${v.outcome}'`);
309
+ }
310
+ }
311
+ }
312
+ });
313
+ });
314
+ return result(ctx);
315
+ }