@clear-capabilities/agentic-security-scanner 0.160.0 → 0.162.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/bin/agentic-security.js +11 -2
  3. package/dist/1919.index.js +1 -1
  4. package/dist/2432.index.js +10 -1
  5. package/dist/4113.index.js +60 -9
  6. package/dist/5613.index.js +500 -0
  7. package/dist/6239.index.js +469 -0
  8. package/dist/7033.index.js +4 -1
  9. package/dist/7838.index.js +39 -3
  10. package/dist/8714.index.js +1 -1
  11. package/dist/8798.index.js +2 -2
  12. package/dist/9207.index.js +5 -0
  13. package/dist/9560.index.js +205 -62
  14. package/dist/{1905.index.js → 9999.index.js} +265 -8
  15. package/dist/agentic-security.mjs +12 -12
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +14 -9
  18. package/src/dataflow/engine.js +20 -4
  19. package/src/discovery/CLAUDE.md +9 -0
  20. package/src/discovery/index.js +28 -0
  21. package/src/discovery/memory.js +7 -5
  22. package/src/engine.js +29 -8
  23. package/src/fix/apply-fix-service.js +33 -3
  24. package/src/language/CLAUDE.md +6 -1
  25. package/src/language/aibom.js +4 -1
  26. package/src/language/fix-lifecycle.js +9 -0
  27. package/src/language/haskell-ir.js +47 -7
  28. package/src/language/haskell-sca.js +87 -7
  29. package/src/language/haskell-supply.js +175 -6
  30. package/src/language/haskell-web.js +200 -18
  31. package/src/language/nix-fix.js +1 -1
  32. package/src/language/resolved-pass.js +4 -1
  33. package/src/language/witness.js +1 -1
  34. package/src/mcp/CLAUDE.md +43 -4
  35. package/src/mcp/tools.js +185 -43
  36. package/src/posture/CLAUDE.md +174 -0
  37. package/src/posture/assurance/baseline-inventory.js +218 -0
  38. package/src/posture/assurance/baseline.js +322 -0
  39. package/src/posture/assurance/bounded-io.js +145 -0
  40. package/src/posture/assurance/config.js +299 -0
  41. package/src/posture/assurance/contracts.js +315 -0
  42. package/src/posture/assurance/identity.js +62 -0
  43. package/src/posture/assurance/migrations.js +259 -0
  44. package/src/posture/assurance/schema-kit.js +111 -0
  45. package/src/posture/assurance/verification-record.js +258 -0
  46. package/src/posture/attestation.js +5 -0
  47. package/src/posture/autopilot.js +28 -1
  48. package/src/posture/blast-radius.js +15 -1
  49. package/src/posture/evidence-bundle.js +93 -7
  50. package/src/posture/execution-proof.js +11 -1
  51. package/src/posture/fix-verify-loop.js +20 -6
  52. package/src/posture/fix-verify.js +37 -2
  53. package/src/posture/oracles/adapters.js +360 -0
  54. package/src/posture/oracles/conformance.js +275 -0
  55. package/src/posture/oracles/oracle.js +433 -0
  56. package/src/posture/oracles/registry.js +76 -0
  57. package/src/posture/oracles/scenario-classes.js +223 -0
  58. package/src/posture/remediation-ledger.js +10 -1
  59. package/src/posture/replay/replay.js +249 -0
  60. package/src/posture/verification/advisory-state.js +71 -0
  61. package/src/posture/verification/emit.js +257 -0
  62. package/src/posture/verification/hypothesis-promotion.js +211 -0
  63. package/src/posture/verification/patch-negative.js +214 -0
  64. package/src/posture/verification/patch-promotion.js +105 -0
  65. package/src/posture/verification/projection.js +183 -0
  66. package/src/posture/verification/repair-records.js +124 -0
  67. package/src/posture/verifier.js +16 -0
  68. package/src/report/index.js +49 -0
  69. package/src/sandbox/CLAUDE.md +41 -0
  70. package/src/sandbox/backend-namespace.js +7 -0
  71. package/src/sandbox/backend-userspace.js +68 -29
  72. package/src/sandbox/control-probes.js +215 -0
  73. package/src/sandbox/supervise.js +201 -0
  74. package/src/sandbox/trust-boundary.js +113 -0
  75. package/src/sandbox/trust-domains.js +171 -0
  76. package/src/sast/pipeline.js +51 -4
@@ -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
+ }
@@ -0,0 +1,62 @@
1
+ // Deterministic identities for the assurance evidence contracts (CORE-002).
2
+ //
3
+ // Reuses, does not duplicate:
4
+ // - canonicalJson (posture/evidence-bundle.js): sorted keys at every depth, so
5
+ // two records that differ only in key order serialize to the same bytes.
6
+ // - computeStableId (posture/stable-id.js): the refactor-stable finding id.
7
+ // A hypothesis id is that same id; this module never invents a second way to
8
+ // identify a finding.
9
+ //
10
+ // An identity is a hash over an ALLOWLIST of semantic fields, never over the
11
+ // whole record. That is what keeps clocks, nonces and migration bookkeeping out
12
+ // of an id: a field that is not named in the allowlist cannot reach the hash,
13
+ // however it is added later. The lineage package (scanner/src/lineage) has its
14
+ // own id namespace for graph entities; vulnerability evidence ids use distinct
15
+ // prefixes (vrec/obind/capd/rlab/rev) so the two can never be confused, and this
16
+ // module imports nothing from lineage.
17
+
18
+ import * as crypto from 'node:crypto';
19
+ import { canonicalJson } from '../evidence-bundle.js';
20
+ import { computeStableId } from '../stable-id.js';
21
+
22
+ export const ID_HEX_LEN = 16;
23
+
24
+ export const ID_PREFIXES = Object.freeze({
25
+ verification: 'vrec',
26
+ observationBinding: 'obind',
27
+ capabilityDecision: 'capd',
28
+ routingLabel: 'rlab',
29
+ releaseEvidence: 'rev',
30
+ });
31
+
32
+ /** Canonical bytes of a value: key-order independent, no clock, no randomness. */
33
+ export function canonicalize(value) {
34
+ return canonicalJson(value);
35
+ }
36
+
37
+ /** `sha256:<64 hex>` digest of the canonical form of a value. */
38
+ export function digestOf(value) {
39
+ return `sha256:${crypto.createHash('sha256').update(canonicalJson(value)).digest('hex')}`;
40
+ }
41
+
42
+ export function digestOfBytes(bytes) {
43
+ return `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`;
44
+ }
45
+
46
+ /**
47
+ * Prefixed id over an allowlisted projection of a record.
48
+ * `fields` is the allowlist; a missing field projects to null so an absent field
49
+ * and an explicit null produce the same id (canonicalJson already treats them so).
50
+ */
51
+ export function semanticId(prefix, record, fields) {
52
+ const material = {};
53
+ for (const f of fields) material[f] = record?.[f] === undefined ? null : record[f];
54
+ const hex = crypto.createHash('sha256').update(canonicalJson({ kind: prefix, material })).digest('hex');
55
+ return `${prefix}:${hex.slice(0, ID_HEX_LEN)}`;
56
+ }
57
+
58
+ /** The hypothesis id of a finding: its refactor-stable id, reused unchanged. */
59
+ export function hypothesisIdFromFinding(finding) {
60
+ if (!finding || typeof finding !== 'object') return null;
61
+ return finding.stableId || computeStableId(finding);
62
+ }
@@ -0,0 +1,259 @@
1
+ // Explicit version-1 migration adapters (CORE-002).
2
+ //
3
+ // Two directions, both conservative:
4
+ // - FROM legacy shapes the repository already produces (a proof tier, a
5
+ // boolean "verified", an egress decision, a legacy attestation) INTO version-1
6
+ // records.
7
+ // - FROM a version-1 record INTO a legacy view for consumers that still expect a
8
+ // status string or a boolean.
9
+ //
10
+ // The rule that governs every mapping: evidence can be lost in translation but
11
+ // never invented. Unknown, skipped, unconfirmed, failed-to-run and incomplete
12
+ // inputs land on the matching non-positive state. A legacy "true" with no oracle
13
+ // evidence is `inconclusive`, never `confirmed`; a legacy "proof-failed" is
14
+ // `inconclusive`, never `refuted` (the old tier's own comment says absence of
15
+ // proof is not proof of absence). The only path to `confirmed` from a legacy
16
+ // shape is an explicit `trustedRunner` assertion by the caller, which supplies
17
+ // the trusted-runtime-proof evidence the new contract requires.
18
+ //
19
+ // What the adapter cannot recover is recorded in `migration` rather than
20
+ // dropped: the legacy value and the shape it came from stay on the record.
21
+
22
+ import {
23
+ SCHEMA_VERSION, isPlainObject, isDigest,
24
+ } from './schema-kit.js';
25
+ import {
26
+ VERIFICATION_SCHEMA, VERIFICATION_OUTCOMES, buildVerificationRecord, validateVerificationRecord,
27
+ } from './verification-record.js';
28
+ import {
29
+ OBSERVATION_BINDING_SCHEMA, CAPABILITY_DECISION_SCHEMA, ROUTING_LABEL_SCHEMA, RELEASE_EVIDENCE_SCHEMA,
30
+ observationBindingId, capabilityDecisionId, routingLabelId, releaseEvidenceId, deriveReleaseComplete,
31
+ validateObservationBinding, validateCapabilityDecision, validateRoutingLabel, validateReleaseEvidence,
32
+ } from './contracts.js';
33
+ import { digestOf } from './identity.js';
34
+
35
+ const SCHEMAS = Object.freeze({
36
+ verification: [VERIFICATION_SCHEMA, validateVerificationRecord],
37
+ observationBinding: [OBSERVATION_BINDING_SCHEMA, validateObservationBinding],
38
+ capabilityDecision: [CAPABILITY_DECISION_SCHEMA, validateCapabilityDecision],
39
+ routingLabel: [ROUTING_LABEL_SCHEMA, validateRoutingLabel],
40
+ releaseEvidence: [RELEASE_EVIDENCE_SCHEMA, validateReleaseEvidence],
41
+ });
42
+
43
+ function fail(code, message) { return { ok: false, record: null, errors: [{ code, path: '', message }] }; }
44
+
45
+ /**
46
+ * Entry point. A record carrying a `schema` marker is a current-format record: its
47
+ * major version must be supported, and it is validated, never rewritten. A record
48
+ * without one is treated as legacy and routed to the matching adapter.
49
+ *
50
+ * @param {'verification'|'observationBinding'|'capabilityDecision'|'routingLabel'|'releaseEvidence'} kind
51
+ * @param {object} input
52
+ * @param {object} [ctx] adapter context for legacy input (see each adapter)
53
+ */
54
+ export function migrateRecord(kind, input, ctx = {}) {
55
+ const entry = SCHEMAS[kind];
56
+ if (!entry) return fail('UNKNOWN_ENUM', `unknown record kind '${kind}'`);
57
+ if (!isPlainObject(input)) return fail('NOT_AN_OBJECT', 'input must be an object');
58
+ const [schemaName, validate] = entry;
59
+ if (input.schema !== undefined) {
60
+ const v = validate(input);
61
+ return { ok: v.ok, record: v.ok ? input : null, errors: v.errors };
62
+ }
63
+ const adapter = ADAPTERS[kind];
64
+ const out = adapter(input, ctx);
65
+ if (!out.ok) return out;
66
+ const v = validate(out.record);
67
+ return v.ok ? out : { ok: false, record: null, errors: v.errors };
68
+ }
69
+
70
+ // ---------------------------------------------------------------- verification
71
+
72
+
73
+ /**
74
+ * Map a legacy status to a version-1 outcome. Returns `{ outcome, reason }`.
75
+ * Understands: the execution-proof evidence object ({tier, ran, witnessStatus,
76
+ * timedOut, reason}), a boolean `verified`, and a status string.
77
+ */
78
+ export function legacyVerificationOutcome(legacy, { trustedRunner = false } = {}) {
79
+ const pe = isPlainObject(legacy.proofEvidence) ? legacy.proofEvidence : (legacy.tier !== undefined ? legacy : null);
80
+ if (pe) {
81
+ const ran = pe.ran === true;
82
+ if (!ran) {
83
+ const reason = pe.reason || 'the proof-of-concept did not execute';
84
+ if (/unsupported|no confinement primitive/i.test(String(pe.reason || ''))) return { outcome: 'unsupported', reason };
85
+ if (pe.witnessStatus === 'error' || /could not start/i.test(String(pe.reason || ''))) return { outcome: 'error', reason };
86
+ if (pe.timedOut) return { outcome: 'inconclusive', reason: pe.reason || 'the proof-of-concept timed out' };
87
+ return { outcome: 'not-run', reason };
88
+ }
89
+ if (pe.tier === 'execution-proven') {
90
+ return trustedRunner
91
+ ? { outcome: 'confirmed', reason: pe.observed || 'legacy execution proof, asserted by a trusted runner' }
92
+ : { outcome: 'inconclusive', reason: 'legacy execution proof predates trusted-runtime-proof classification; re-verify under the trusted verifier to confirm' };
93
+ }
94
+ if (pe.tier === 'proof-failed') return { outcome: 'inconclusive', reason: 'the proof-of-concept ran but did not demonstrate the effect; absence of proof is not refutation' };
95
+ return { outcome: 'inconclusive', reason: `legacy proof tier '${pe.tier}' carries no execution evidence` };
96
+ }
97
+ const raw = legacy.status !== undefined ? legacy.status : legacy.verified;
98
+ if (raw === true) return { outcome: 'inconclusive', reason: 'legacy boolean true carries no oracle evidence' };
99
+ if (raw === false) return { outcome: 'inconclusive', reason: 'legacy boolean false is not a refutation: no applicable oracle or preconditions are recorded' };
100
+ if (raw === null || raw === undefined) return { outcome: 'not-run', reason: 'legacy record has no verification status' };
101
+ if (typeof raw === 'string') {
102
+ const s = raw.toLowerCase();
103
+ if (VERIFICATION_OUTCOMES.includes(s)) {
104
+ if (s === 'confirmed' || s === 'refuted') {
105
+ return { outcome: 'inconclusive', reason: `legacy status '${s}' carries no evidence references, so it is not carried forward as '${s}'` };
106
+ }
107
+ return { outcome: s, reason: `legacy status '${s}'` };
108
+ }
109
+ if (['skipped', 'pending', 'queued', 'disabled'].includes(s)) return { outcome: 'not-run', reason: `legacy status '${s}'` };
110
+ if (['timeout', 'timed-out', 'unconfirmed', 'partial', 'incomplete', 'unknown'].includes(s)) return { outcome: 'inconclusive', reason: `legacy status '${s}'` };
111
+ if (['failed', 'failure', 'errored', 'crash'].includes(s)) return { outcome: 'error', reason: `legacy status '${s}'` };
112
+ }
113
+ return { outcome: 'inconclusive', reason: `unrecognized legacy status ${JSON.stringify(raw)}` };
114
+ }
115
+
116
+ /**
117
+ * Legacy verification result -> version-1 verification record.
118
+ * ctx: { hypothesisId, commit, detectorOrigin, scope, trustedRunner?, oracle? }.
119
+ * Missing context is not invented: without a hypothesisId the adapter refuses.
120
+ */
121
+ export function fromLegacyVerification(legacy, ctx = {}) {
122
+ if (!ctx.hypothesisId) return fail('MISSING_FIELD', 'a legacy verification needs a hypothesisId from the caller; it is not guessed');
123
+ const { outcome, reason } = legacyVerificationOutcome(legacy, { trustedRunner: ctx.trustedRunner === true });
124
+ const evidence = [];
125
+ if (outcome === 'confirmed') {
126
+ evidence.push({
127
+ id: 'legacy-runtime-proof', kind: 'trusted-runtime-proof', producer: 'trusted-runner',
128
+ digest: digestOf(legacy.proofEvidence || legacy), source: 'legacy-execution-proof',
129
+ });
130
+ }
131
+ const pe = isPlainObject(legacy.proofEvidence) ? legacy.proofEvidence : legacy;
132
+ const rec = buildVerificationRecord({
133
+ hypothesisId: ctx.hypothesisId,
134
+ commit: ctx.commit ?? null,
135
+ detectorOrigin: ctx.detectorOrigin || { detector: 'legacy-unknown' },
136
+ oracle: outcome === 'confirmed' ? (ctx.oracle || { id: 'legacy-execution-proof', kind: 'runtime-replay' }) : (ctx.oracle ?? null),
137
+ attempt: ['not-run', 'unsupported'].includes(outcome) ? 0 : 1,
138
+ outcome, reason,
139
+ evidence,
140
+ scope: ctx.scope || { description: 'unspecified legacy scope', platform: pe.backend ? String(pe.backend) : 'unknown' },
141
+ migration: { from: 'legacy-verification', legacyValue: JSON.stringify(legacy.status ?? legacy.verified ?? pe.tier ?? null) },
142
+ });
143
+ return { ok: true, record: rec, errors: [] };
144
+ }
145
+
146
+ /**
147
+ * Version-1 record -> view for a legacy consumer. `verified` is true ONLY for a
148
+ * confirmed record and false ONLY for a refuted one; every other state is null, so
149
+ * a consumer that tests `=== true` cannot be told a skipped check passed, and one
150
+ * that tests `=== false` cannot be told an unrun check failed.
151
+ */
152
+ export function toLegacyVerificationView(record) {
153
+ return {
154
+ status: record.outcome,
155
+ verified: record.outcome === 'confirmed' ? true : record.outcome === 'refuted' ? false : null,
156
+ confirmationLevel: record.confirmationLevel,
157
+ repairStatus: record.repair?.status ?? 'none',
158
+ reason: record.reason,
159
+ };
160
+ }
161
+
162
+ // ---------------------------------------------------------------- other kinds
163
+
164
+ function fromEgressDecision(legacy) {
165
+ // posture/egress evaluateEgress(): { allowed, decision, reason, provider, policySource, purpose }.
166
+ // It is an in-process policy check before a call: it explains a decision and is
167
+ // not OS-level isolation, so it can never be recorded as enforced.
168
+ const decision = legacy.decision === 'allow' ? 'allow' : legacy.decision === 'deny' ? 'deny' : 'unsupported';
169
+ const rec = {
170
+ schema: CAPABILITY_DECISION_SCHEMA, schemaVersion: SCHEMA_VERSION,
171
+ taskId: String(legacy.purpose || 'unknown'),
172
+ capability: 'network',
173
+ subject: String(legacy.provider || 'unknown'),
174
+ decision,
175
+ mediation: 'in-process-policy',
176
+ enforced: false,
177
+ backend: null,
178
+ reason: String(legacy.reason || (decision === 'allow' ? 'egress policy allowed the call' : 'no reason recorded')),
179
+ migration: { from: 'egress-decision', legacyValue: JSON.stringify(legacy.decision ?? null) },
180
+ };
181
+ rec.id = capabilityDecisionId(rec);
182
+ return { ok: true, record: rec, errors: [] };
183
+ }
184
+
185
+ function fromLegacyRoutingLabel(legacy) {
186
+ if (!legacy.taskId || !legacy.model) return fail('MISSING_FIELD', 'a legacy routing label needs taskId and model');
187
+ const decided = legacy.correct === true || legacy.correct === false;
188
+ const source = ['adjudication', 'trusted-execution'].includes(legacy.labelSource) ? legacy.labelSource : 'none';
189
+ // A boolean without a named source is not an adjudicated label.
190
+ const outcome = decided && source !== 'none' ? (legacy.correct ? 'correct' : 'incorrect') : (legacy.delayed ? 'delayed' : 'unknown');
191
+ const rec = {
192
+ schema: ROUTING_LABEL_SCHEMA, schemaVersion: SCHEMA_VERSION,
193
+ taskId: String(legacy.taskId),
194
+ taskKind: legacy.taskKind || 'triage',
195
+ stratum: String(legacy.stratum || 'unstratified'),
196
+ model: String(legacy.model),
197
+ outcome,
198
+ labelSource: outcome === 'correct' || outcome === 'incorrect' ? source : 'none',
199
+ migration: { from: 'legacy-routing-label', legacyValue: JSON.stringify(legacy.correct ?? null) },
200
+ };
201
+ rec.id = routingLabelId(rec);
202
+ return { ok: true, record: rec, errors: [] };
203
+ }
204
+
205
+ function fromLegacyRelease(legacy) {
206
+ const claims = (Array.isArray(legacy.checks) ? legacy.checks : []).map((c, i) => {
207
+ const refs = (Array.isArray(c.evidence) ? c.evidence : (c.evidence ? [c.evidence] : [])).filter(r => typeof r === 'string' && (r.startsWith('vrec:') || isDigest(r)));
208
+ const s = String(c.status || '').toLowerCase();
209
+ let status; let gaps = [];
210
+ if (['pass', 'passed', 'ok'].includes(s) && refs.length) status = 'verified';
211
+ else if (['pass', 'passed', 'ok'].includes(s)) { status = 'unverified'; gaps = ['legacy check reported a pass but cites no evidence']; }
212
+ else if (['fail', 'failed'].includes(s)) { status = 'failed'; gaps = [`legacy check '${c.id ?? i}' failed`]; }
213
+ else if (['skip', 'skipped'].includes(s)) { status = 'skipped'; gaps = [`legacy check '${c.id ?? i}' was skipped`]; }
214
+ else { status = 'incomplete'; gaps = [`legacy check '${c.id ?? i}' has status ${JSON.stringify(c.status ?? null)}`]; }
215
+ return { id: String(c.id ?? `check-${i}`), statement: String(c.statement || c.id || `check ${i}`), status, evidenceRefs: refs, gaps };
216
+ });
217
+ const rec = {
218
+ schema: RELEASE_EVIDENCE_SCHEMA, schemaVersion: SCHEMA_VERSION,
219
+ subject: { commit: legacy.commit ?? null, bundleDigest: isDigest(legacy.bundleDigest) ? legacy.bundleDigest : null },
220
+ claims,
221
+ complete: false,
222
+ migration: { from: 'legacy-attestation', legacyValue: JSON.stringify(legacy.verified ?? null) },
223
+ };
224
+ rec.complete = deriveReleaseComplete(rec);
225
+ rec.id = releaseEvidenceId(rec);
226
+ return { ok: true, record: rec, errors: [] };
227
+ }
228
+
229
+ function fromLineageObservation(legacy, ctx) {
230
+ // A lineage RuntimeObservation (id, adapter, windowStart/End, matched*Ids). The
231
+ // observation stays in lineage; only its id is referenced.
232
+ if (!ctx.hypothesisId) return fail('MISSING_FIELD', 'an observation binding needs a hypothesisId from the caller');
233
+ if (!legacy.id) return fail('MISSING_FIELD', 'legacy observation has no id');
234
+ const matched = ['matchedNodeIds', 'matchedEdgeIds', 'matchedFlowIds'].some(k => Array.isArray(legacy[k]) && legacy[k].length > 0);
235
+ const rec = {
236
+ schema: OBSERVATION_BINDING_SCHEMA, schemaVersion: SCHEMA_VERSION,
237
+ hypothesisId: ctx.hypothesisId,
238
+ observationRef: { observationId: String(legacy.id), ...(legacy.adapter ? { adapter: String(legacy.adapter) } : {}) },
239
+ provenance: 'runtime-observation',
240
+ // Matching an observation never shows the sample was complete.
241
+ pathState: matched ? 'runtime-supported' : 'unresolved',
242
+ completeness: 'unknown',
243
+ window: legacy.windowStart && legacy.windowEnd ? { start: String(legacy.windowStart), end: String(legacy.windowEnd) } : undefined,
244
+ migration: { from: 'lineage-runtime-observation', legacyValue: JSON.stringify(legacy.matchMethod ?? null) },
245
+ };
246
+ if (rec.window === undefined) delete rec.window;
247
+ rec.id = observationBindingId(rec);
248
+ return { ok: true, record: rec, errors: [] };
249
+ }
250
+
251
+ const ADAPTERS = Object.freeze({
252
+ verification: (legacy, ctx) => fromLegacyVerification(legacy, ctx),
253
+ observationBinding: fromLineageObservation,
254
+ capabilityDecision: (legacy) => fromEgressDecision(legacy),
255
+ routingLabel: (legacy) => fromLegacyRoutingLabel(legacy),
256
+ releaseEvidence: (legacy) => fromLegacyRelease(legacy),
257
+ });
258
+
259
+ export { fromEgressDecision, fromLegacyRoutingLabel, fromLegacyRelease, fromLineageObservation };