@clear-capabilities/agentic-security-scanner 0.161.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.
- package/CHANGELOG.md +20 -0
- package/bin/agentic-security.js +11 -2
- package/dist/4113.index.js +59 -8
- package/dist/5613.index.js +500 -0
- package/dist/6239.index.js +469 -0
- package/dist/7838.index.js +38 -2
- package/dist/8798.index.js +1 -1
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +36 -22
- package/dist/{1905.index.js → 9999.index.js} +265 -8
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +9 -4
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +28 -0
- package/src/discovery/memory.js +7 -5
- package/src/fix/apply-fix-service.js +33 -3
- package/src/mcp/CLAUDE.md +9 -1
- package/src/mcp/tools.js +17 -4
- package/src/posture/CLAUDE.md +174 -0
- package/src/posture/assurance/baseline-inventory.js +218 -0
- package/src/posture/assurance/baseline.js +322 -0
- package/src/posture/assurance/bounded-io.js +145 -0
- package/src/posture/assurance/config.js +299 -0
- package/src/posture/assurance/contracts.js +315 -0
- package/src/posture/assurance/identity.js +62 -0
- package/src/posture/assurance/migrations.js +259 -0
- package/src/posture/assurance/schema-kit.js +111 -0
- package/src/posture/assurance/verification-record.js +258 -0
- package/src/posture/attestation.js +5 -0
- package/src/posture/autopilot.js +28 -1
- package/src/posture/evidence-bundle.js +93 -7
- package/src/posture/execution-proof.js +11 -1
- package/src/posture/fix-verify-loop.js +20 -6
- package/src/posture/fix-verify.js +37 -2
- package/src/posture/oracles/adapters.js +360 -0
- package/src/posture/oracles/conformance.js +275 -0
- package/src/posture/oracles/oracle.js +433 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/replay/replay.js +249 -0
- package/src/posture/verification/advisory-state.js +71 -0
- package/src/posture/verification/emit.js +257 -0
- package/src/posture/verification/hypothesis-promotion.js +211 -0
- package/src/posture/verification/patch-negative.js +214 -0
- package/src/posture/verification/patch-promotion.js +105 -0
- package/src/posture/verification/projection.js +183 -0
- package/src/posture/verification/repair-records.js +124 -0
- package/src/posture/verifier.js +16 -0
- package/src/report/index.js +49 -0
- package/src/sandbox/CLAUDE.md +41 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +68 -29
- package/src/sandbox/control-probes.js +215 -0
- package/src/sandbox/supervise.js +201 -0
- package/src/sandbox/trust-boundary.js +113 -0
- package/src/sandbox/trust-domains.js +171 -0
|
@@ -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 };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Small validation primitives shared by the assurance evidence contracts
|
|
2
|
+
// (CORE-002). Pure: no fs, no clock, no network. Every validator returns
|
|
3
|
+
// `{ ok, errors }` where each error is `{ code, path, message }`; nothing throws
|
|
4
|
+
// on bad input, because a validator that throws on hostile input is a denial of
|
|
5
|
+
// service on whatever called it.
|
|
6
|
+
|
|
7
|
+
export const ERROR_CODES = Object.freeze([
|
|
8
|
+
'NOT_AN_OBJECT', 'SCHEMA_MISMATCH', 'UNSUPPORTED_MAJOR', 'BAD_VERSION', 'MISSING_FIELD', 'UNKNOWN_FIELD',
|
|
9
|
+
'BAD_TYPE', 'UNKNOWN_ENUM', 'BAD_DIGEST', 'BAD_ID', 'ID_MISMATCH', 'DUPLICATE_ID', 'DANGLING_REF', 'RULE_VIOLATION',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export const SUPPORTED_MAJOR = 1;
|
|
13
|
+
export const SCHEMA_VERSION = '1.0.0';
|
|
14
|
+
|
|
15
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
16
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
17
|
+
const COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
18
|
+
|
|
19
|
+
export function makeCtx() {
|
|
20
|
+
const errors = [];
|
|
21
|
+
return {
|
|
22
|
+
errors,
|
|
23
|
+
err(code, path, message) { errors.push({ code, path, message }); },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isPlainObject(v) {
|
|
28
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isDigest(v) { return typeof v === 'string' && DIGEST.test(v); }
|
|
32
|
+
export function isCommit(v) { return typeof v === 'string' && COMMIT.test(v); }
|
|
33
|
+
export function isNonEmptyString(v) { return typeof v === 'string' && v.trim().length > 0; }
|
|
34
|
+
|
|
35
|
+
/** Header check: `schema` must match and the major version must be supported. */
|
|
36
|
+
export function checkHeader(ctx, rec, schemaName) {
|
|
37
|
+
if (rec.schema !== schemaName) {
|
|
38
|
+
ctx.err('SCHEMA_MISMATCH', 'schema', `expected schema '${schemaName}', got ${JSON.stringify(rec.schema)}`);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const m = typeof rec.schemaVersion === 'string' ? SEMVER.exec(rec.schemaVersion) : null;
|
|
42
|
+
if (!m) {
|
|
43
|
+
ctx.err('BAD_VERSION', 'schemaVersion', `schemaVersion must be a semantic version, got ${JSON.stringify(rec.schemaVersion)}`);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (Number(m[1]) !== SUPPORTED_MAJOR) {
|
|
47
|
+
ctx.err('UNSUPPORTED_MAJOR', 'schemaVersion', `major version ${m[1]} is not supported (supported: ${SUPPORTED_MAJOR})`);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Closed world: any field outside `allowed` is rejected, never silently carried. */
|
|
54
|
+
export function checkFields(ctx, rec, allowed, required) {
|
|
55
|
+
for (const k of Object.keys(rec)) {
|
|
56
|
+
if (!allowed.includes(k)) ctx.err('UNKNOWN_FIELD', k, `field '${k}' is not part of this schema`);
|
|
57
|
+
}
|
|
58
|
+
for (const k of required) {
|
|
59
|
+
if (rec[k] === undefined) ctx.err('MISSING_FIELD', k, `required field '${k}' is missing`);
|
|
60
|
+
}
|
|
61
|
+
// `createdAt` is informational and never enters an id; it must still be a string.
|
|
62
|
+
if (rec.createdAt !== undefined && typeof rec.createdAt !== 'string') ctx.err('BAD_TYPE', 'createdAt', 'must be a string');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function checkEnum(ctx, path, value, allowed) {
|
|
66
|
+
if (typeof value !== 'string' || !allowed.includes(value)) {
|
|
67
|
+
ctx.err('UNKNOWN_ENUM', path, `${JSON.stringify(value)} is not one of: ${allowed.join(', ')}`);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function checkDigest(ctx, path, value, { nullable = false } = {}) {
|
|
74
|
+
if (value === null && nullable) return true;
|
|
75
|
+
if (!isDigest(value)) { ctx.err('BAD_DIGEST', path, `must be 'sha256:' followed by 64 lowercase hex characters`); return false; }
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function checkCommit(ctx, path, value, { nullable = true } = {}) {
|
|
80
|
+
if (value === null && nullable) return true;
|
|
81
|
+
if (!isCommit(value)) { ctx.err('BAD_TYPE', path, 'must be a 40 or 64 character lowercase hex commit id'); return false; }
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function checkString(ctx, path, value) {
|
|
86
|
+
if (!isNonEmptyString(value)) { ctx.err('BAD_TYPE', path, 'must be a non-empty string'); return false; }
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function checkId(ctx, rec, expectedId) {
|
|
91
|
+
const prefix = expectedId.split(':')[0];
|
|
92
|
+
if (typeof rec.id !== 'string' || !rec.id.startsWith(`${prefix}:`)) {
|
|
93
|
+
ctx.err('BAD_ID', 'id', `id must start with '${prefix}:'`);
|
|
94
|
+
} else if (rec.id !== expectedId) {
|
|
95
|
+
ctx.err('ID_MISMATCH', 'id', `id does not match the record's semantic content (expected ${expectedId})`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function result(ctx) {
|
|
100
|
+
return { ok: ctx.errors.length === 0, errors: ctx.errors };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Reject non-object input with one typed error instead of throwing downstream. */
|
|
104
|
+
export function guardObject(rec) {
|
|
105
|
+
const ctx = makeCtx();
|
|
106
|
+
if (!isPlainObject(rec)) {
|
|
107
|
+
ctx.err('NOT_AN_OBJECT', '', 'record must be a plain object');
|
|
108
|
+
return { ctx, ok: false };
|
|
109
|
+
}
|
|
110
|
+
return { ctx, ok: true };
|
|
111
|
+
}
|