@clear-capabilities/agentic-security-scanner 0.161.0 → 0.163.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/bin/agentic-security.js +98 -2
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +59 -8
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -0
- package/dist/7838.index.js +38 -2
- package/dist/8798.index.js +1 -1
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +2336 -27
- package/dist/957.index.js +1 -1
- package/dist/{1905.index.js → 9999.index.js} +306 -13
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +39 -7
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +29 -0
- package/src/dataflow/catalog.js +21 -0
- package/src/dataflow/engine.js +20 -0
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +18 -0
- package/src/discovery/index.js +43 -1
- package/src/discovery/lenses.js +18 -0
- package/src/discovery/memory.js +7 -5
- package/src/engine.js +80 -15
- package/src/fix/apply-fix-service.js +33 -3
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +14 -3
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +30 -5
- package/src/posture/CLAUDE.md +505 -0
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/baseline-inventory.js +218 -0
- package/src/posture/assurance/baseline.js +322 -0
- package/src/posture/assurance/bounded-io.js +145 -0
- package/src/posture/assurance/config.js +299 -0
- package/src/posture/assurance/contracts.js +315 -0
- package/src/posture/assurance/identity.js +62 -0
- package/src/posture/assurance/migrations.js +259 -0
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- package/src/posture/assurance/schema-kit.js +111 -0
- package/src/posture/assurance/verification-record.js +258 -0
- package/src/posture/attestation.js +5 -0
- package/src/posture/autopilot.js +28 -1
- package/src/posture/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- package/src/posture/evidence-bundle.js +93 -7
- package/src/posture/execution-proof.js +11 -1
- package/src/posture/fix-verify-loop.js +20 -6
- package/src/posture/fix-verify.js +37 -2
- package/src/posture/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +531 -0
- package/src/posture/oracles/conformance.js +276 -0
- package/src/posture/oracles/oracle.js +453 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +250 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -0
- package/src/posture/verification/advisory-state.js +71 -0
- package/src/posture/verification/emit.js +257 -0
- package/src/posture/verification/hypothesis-promotion.js +211 -0
- package/src/posture/verification/patch-negative.js +226 -0
- package/src/posture/verification/patch-promotion.js +113 -0
- package/src/posture/verification/projection.js +183 -0
- package/src/posture/verification/repair-records.js +124 -0
- package/src/posture/verifier.js +16 -0
- package/src/report/index.js +100 -1
- package/src/sandbox/CLAUDE.md +51 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +151 -34
- package/src/sandbox/control-probes.js +215 -0
- package/src/sandbox/supervise.js +201 -0
- package/src/sandbox/trust-boundary.js +113 -0
- package/src/sandbox/trust-domains.js +171 -0
|
@@ -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,214 @@
|
|
|
1
|
+
// Rollback and compatibility for the assurance features (REL-002.AC02, AC03).
|
|
2
|
+
//
|
|
3
|
+
// Four things live here, and the line between them is the point:
|
|
4
|
+
//
|
|
5
|
+
// 1. READER_POLICY: for every record kind, whether a PREVIOUS reader exists, so a rollback that
|
|
6
|
+
// restores old consumers is supported, or does not, in which case the policy says so and
|
|
7
|
+
// a rollback flags the new records instead of pretending the old consumers can read them.
|
|
8
|
+
// 2. The one known hard compatibility break. A verifier older than CORE-003 rejects every newly
|
|
9
|
+
// signed evidence bundle, because the new `issuance` block is a top-level key its strict
|
|
10
|
+
// unknown-key rule (which exists to stop unsigned fields being stapled on) does not know.
|
|
11
|
+
// `verifyWithPreCore003Reader` reproduces that reader exactly so the break is tested rather than
|
|
12
|
+
// asserted; `exportLegacyBundle` is the migration path for a consumer that cannot upgrade.
|
|
13
|
+
// 3. planRollback / applyRollback: restore the last known-good rollout position and FLAG, never
|
|
14
|
+
// delete or rewrite, the recorded evidence and the assurance claims that rest on it.
|
|
15
|
+
// 4. EvidenceStore / guardedFeatureRun: an append-only record, so a feature that fails cannot take
|
|
16
|
+
// earlier evidence down with it.
|
|
17
|
+
//
|
|
18
|
+
// Nothing here reaches the network or a model. Nothing here edits configuration: a rollback that needs
|
|
19
|
+
// a feature off returns the configuration change for the operator to apply.
|
|
20
|
+
|
|
21
|
+
import * as fs from 'node:fs';
|
|
22
|
+
import * as crypto from 'node:crypto';
|
|
23
|
+
import { canonicalBytes, verifyEvidenceBundle, keyFingerprint } from '../evidence-bundle.js';
|
|
24
|
+
import { digestOfBytes } from './identity.js';
|
|
25
|
+
import { STAGES } from './rollout.js';
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------- reader policy
|
|
28
|
+
|
|
29
|
+
export const READER_POLICY = Object.freeze({
|
|
30
|
+
verification: { rollbackSupported: true, previousReader: 'toLegacyVerificationView (migrations.js)', note: 'a legacy consumer sees status/verified/reason; verified is true only for confirmed and false only for refuted' },
|
|
31
|
+
evidenceBundle: { rollbackSupported: true, previousReader: 'verifyWithPreCore003Reader', note: 'a pre-CORE-003 verifier can read only bundles re-exported without the issuance block (exportLegacyBundle)' },
|
|
32
|
+
observationBinding: { rollbackSupported: false, previousReader: null, note: 'no previous reader exists: lineage keeps its own observation; the binding is new. Rollback keeps the records and flags them incompatible for any older consumer' },
|
|
33
|
+
capabilityDecision: { rollbackSupported: false, previousReader: null, note: 'the egress decision it adapts has no inverse; a decision is never turned back into a bare allow/deny' },
|
|
34
|
+
routingLabel: { rollbackSupported: false, previousReader: null, note: 'a boolean label has no place for a label source; the inverse would invent one' },
|
|
35
|
+
releaseEvidence: { rollbackSupported: false, previousReader: null, note: 'a legacy check list cannot carry evidence references; the inverse would drop them' },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------- the known compatibility break
|
|
39
|
+
|
|
40
|
+
// Top-level keys a bundle could carry before CORE-003 added `issuance`. Verbatim from the verifier of that time.
|
|
41
|
+
export const PRE_CORE_003_BUNDLE_KEYS = Object.freeze(['schema', 'finding', 'evidence', 'engine', 'proves', 'doesNotProve', 'signature']);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The bundle verifier as it stood before CORE-003: strict unknown top-level keys, signature over the
|
|
45
|
+
* canonical bytes, nothing about issuers. Kept as a faithful copy so the break is a test, not a belief.
|
|
46
|
+
*/
|
|
47
|
+
export function verifyWithPreCore003Reader(bundle, publicKeyPem) {
|
|
48
|
+
if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
|
|
49
|
+
const unknownKeys = Object.keys(bundle).filter((k) => !PRE_CORE_003_BUNDLE_KEYS.includes(k));
|
|
50
|
+
if (unknownKeys.length) return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
|
|
51
|
+
const sig = bundle.signature;
|
|
52
|
+
if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
|
|
53
|
+
if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
|
|
54
|
+
try {
|
|
55
|
+
return crypto.verify(null, canonicalBytes(bundle), publicKeyPem, Buffer.from(sig.value, 'base64')) ? { ok: true, reason: null } : { ok: false, reason: 'signature does not match the bundle contents' };
|
|
56
|
+
} catch (e) { return { ok: false, reason: `verification error: ${e.message}` }; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Migration path for a consumer that cannot upgrade past a pre-CORE-003 verifier: re-sign the SAME content
|
|
61
|
+
* without the issuance block. Three honesty rules:
|
|
62
|
+
* - the original must verify under the key that matches `privateKeyPem` first. A tampered or foreign
|
|
63
|
+
* bundle is refused, not laundered into a fresh valid signature;
|
|
64
|
+
* - the export loses the signed trust-basis statement; a current verifier reports it `none-declared`.
|
|
65
|
+
* Because an old verifier rejects any extra key, that disclosure cannot ride in the bundle, so it is returned
|
|
66
|
+
* as a SIDECAR record the operator keeps beside it;
|
|
67
|
+
* - the export is a new signature by the same local key, so it is still self-issued, still not third-party certification.
|
|
68
|
+
*/
|
|
69
|
+
export function exportLegacyBundle(bundle, privateKeyPem) {
|
|
70
|
+
if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
|
|
71
|
+
let publicKeyPem;
|
|
72
|
+
try { publicKeyPem = crypto.createPublicKey(privateKeyPem).export({ type: 'spki', format: 'pem' }); } catch (e) { return { ok: false, reason: `unusable signing key: ${e.message}` }; }
|
|
73
|
+
const original = verifyEvidenceBundle(bundle, publicKeyPem);
|
|
74
|
+
if (!original.ok) return { ok: false, reason: `the original bundle does not verify under this key (${original.reason}); it is not re-signed` };
|
|
75
|
+
const { issuance, signature, ...content } = bundle;
|
|
76
|
+
void signature;
|
|
77
|
+
const sig = crypto.sign(null, canonicalBytes(content), privateKeyPem);
|
|
78
|
+
const exported = { ...content, signature: { algorithm: 'ed25519', canonicalisation: bundle.signature.canonicalisation, value: sig.toString('base64') } };
|
|
79
|
+
return {
|
|
80
|
+
ok: true, bundle: exported,
|
|
81
|
+
sidecar: {
|
|
82
|
+
kind: 'legacy-bundle-export', from: 'core-003', originalBundleDigest: digestOfBytes(canonicalBytes(bundle)), exportedBundleDigest: digestOfBytes(canonicalBytes(exported)),
|
|
83
|
+
droppedIssuance: issuance ?? null, issuerKeyFingerprint: keyFingerprint(publicKeyPem),
|
|
84
|
+
note: 'Re-signed without the issuance block so a pre-CORE-003 verifier accepts it. The trust basis (self-issued local key, not independent certification) is no longer inside the signed bytes; it is recorded only here. Upgrade the verifier and keep the original bundle.',
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------- rollback planning
|
|
90
|
+
|
|
91
|
+
const clone = (x) => JSON.parse(JSON.stringify(x));
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What a rollback of `feature` would restore and what it would flag. Pure: nothing is changed.
|
|
95
|
+
* store: [{ id, kind, digest, producedUnder: { feature, policyVersion } }]
|
|
96
|
+
* claims: [{ id, evidenceRefs: [store ids] }]
|
|
97
|
+
*/
|
|
98
|
+
export function planRollback({ ledger, feature, store = [], claims = [] }) {
|
|
99
|
+
const cur = ledger?.features?.[feature];
|
|
100
|
+
if (!cur) return { ok: false, reason: `unknown feature '${feature}'` };
|
|
101
|
+
const here = STAGES.indexOf(cur.stage);
|
|
102
|
+
// The most recent known-good entry at an EARLIER stage; the first stage rolls back to "off".
|
|
103
|
+
let target = null;
|
|
104
|
+
for (let i = cur.history.length - 1; i >= 0 && !target; i--) {
|
|
105
|
+
const h = cur.history[i];
|
|
106
|
+
if (h.knownGood && h.to && STAGES.indexOf(h.to) < here) target = h;
|
|
107
|
+
}
|
|
108
|
+
const restore = target
|
|
109
|
+
? { stage: target.to, policyVersion: target.policyVersion, source: `history[${target.seq}]` }
|
|
110
|
+
: { stage: cur.stage, policyVersion: cur.history[0].policyVersion, source: 'history[0]' };
|
|
111
|
+
const configChange = target ? null : { feature, enabled: false, why: 'already at the first stage; the only known-good behaviour below it is the feature being off' };
|
|
112
|
+
|
|
113
|
+
const annotations = {};
|
|
114
|
+
for (const item of store) {
|
|
115
|
+
if (item?.producedUnder?.feature !== feature) continue;
|
|
116
|
+
const flags = []; const reasons = [];
|
|
117
|
+
if ((item.producedUnder.policyVersion ?? 0) > restore.policyVersion) {
|
|
118
|
+
flags.push('stale'); reasons.push(`produced under policy ${item.producedUnder.policyVersion}, newer than the restored policy ${restore.policyVersion}`);
|
|
119
|
+
}
|
|
120
|
+
const policy = READER_POLICY[item.kind];
|
|
121
|
+
if (!policy) { flags.push('incompatible'); reasons.push(`no reader policy is recorded for kind '${item.kind}'`); }
|
|
122
|
+
else if (!policy.rollbackSupported) { flags.push('incompatible'); reasons.push(`kind '${item.kind}' has no previous reader (${policy.note})`); }
|
|
123
|
+
if (flags.length) annotations[item.id] = { flags, reasons };
|
|
124
|
+
}
|
|
125
|
+
const byId = new Map(store.map((i) => [i.id, i]));
|
|
126
|
+
const claimStatus = {};
|
|
127
|
+
for (const c of claims) {
|
|
128
|
+
const reasons = [];
|
|
129
|
+
for (const ref of c.evidenceRefs || []) {
|
|
130
|
+
if (!byId.has(ref)) reasons.push(`${ref}: not in the evidence store (dangling)`);
|
|
131
|
+
else if (annotations[ref]) reasons.push(`${ref}: ${annotations[ref].flags.join('+')}`);
|
|
132
|
+
}
|
|
133
|
+
claimStatus[c.id] = reasons.length ? { status: 'flagged', reasons } : { status: 'valid', reasons: [] };
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
ok: true, feature, from: { stage: cur.stage, policyVersion: cur.policyVersion }, restore, configChange, annotations, claims: claimStatus,
|
|
137
|
+
// The plan never removes anything: every store id is retained.
|
|
138
|
+
retained: store.map((i) => i.id),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Apply a plan to the ledger. Returns a NEW ledger with a `rollback` event appended; history is never
|
|
144
|
+
* rewritten. The evidence store and claims are not touched at all: annotations are returned for the caller to
|
|
145
|
+
* keep beside them.
|
|
146
|
+
*/
|
|
147
|
+
export function applyRollback(ledger, plan, { reason = 'rollback' } = {}) {
|
|
148
|
+
if (!plan?.ok) return { ok: false, ledger, reason: plan?.reason ?? 'no plan' };
|
|
149
|
+
const next = clone(ledger);
|
|
150
|
+
const f = next.features[plan.feature];
|
|
151
|
+
f.history.push({ seq: f.history.length, event: 'rollback', from: f.stage, to: plan.restore.stage, policyVersion: plan.restore.policyVersion, knownGood: true, restoredFrom: plan.restore.source, reason: String(reason), evidence: null });
|
|
152
|
+
f.stage = plan.restore.stage; f.policyVersion = plan.restore.policyVersion;
|
|
153
|
+
return { ok: true, ledger: next, annotations: plan.annotations, claims: plan.claims, configChange: plan.configChange };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------- evidence that survives failure
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Append-only evidence store. An existing id can be re-appended only with the identical digest (idempotent
|
|
160
|
+
* redelivery); the same id with a different digest is refused, because overwriting evidence is erasing it.
|
|
161
|
+
* Optionally backed by a JSONL file written with append-only semantics: a torn last line (a crash mid-write)
|
|
162
|
+
* (or any unreadable line) is counted in `unreadable` and skipped on read, and never costs the other lines.
|
|
163
|
+
*/
|
|
164
|
+
export class EvidenceStore {
|
|
165
|
+
constructor({ file = null } = {}) {
|
|
166
|
+
this.file = file; this.items = new Map(); this.failures = []; this.unreadable = 0;
|
|
167
|
+
if (file && fs.existsSync(file)) this._load();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_load() {
|
|
171
|
+
const lines = fs.readFileSync(this.file, 'utf8').split('\n');
|
|
172
|
+
lines.forEach((line, i) => {
|
|
173
|
+
if (!line.trim()) return;
|
|
174
|
+
try {
|
|
175
|
+
const rec = JSON.parse(line);
|
|
176
|
+
if (rec.type === 'item') this.items.set(rec.item.id, rec.item);
|
|
177
|
+
else if (rec.type === 'failure') this.failures.push(rec.failure);
|
|
178
|
+
} catch { this.unreadable += 1; }
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
append(item) {
|
|
183
|
+
if (!item || typeof item.id !== 'string' || typeof item.digest !== 'string') return { ok: false, reason: 'an evidence item needs a string id and digest' };
|
|
184
|
+
const have = this.items.get(item.id);
|
|
185
|
+
if (have) return have.digest === item.digest ? { ok: true, duplicate: true } : { ok: false, reason: `evidence '${item.id}' already exists with a different digest; evidence is never overwritten` };
|
|
186
|
+
if (this.file) fs.appendFileSync(this.file, `${JSON.stringify({ type: 'item', item })}\n`);
|
|
187
|
+
this.items.set(item.id, clone(item));
|
|
188
|
+
return { ok: true, duplicate: false };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
recordFailure(failure) {
|
|
192
|
+
this.failures.push(failure);
|
|
193
|
+
if (this.file) fs.appendFileSync(this.file, `${JSON.stringify({ type: 'failure', failure })}\n`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
all() { return [...this.items.values()].map(clone); }
|
|
197
|
+
|
|
198
|
+
/** Digest over every item in id order; changes if any item is removed or altered. */
|
|
199
|
+
digest() { return digestOfBytes(Buffer.from(JSON.stringify([...this.items.keys()].sort().map((k) => this.items.get(k))))); }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Run a feature body. A throw becomes a typed `blocked` result and a recorded failure event; the store's
|
|
204
|
+
* earlier evidence is untouched, because nothing here has a path that removes an item.
|
|
205
|
+
*/
|
|
206
|
+
export async function guardedFeatureRun(store, feature, fn) {
|
|
207
|
+
try {
|
|
208
|
+
return { status: 'ok', code: null, feature, value: await fn(store) };
|
|
209
|
+
} catch (e) {
|
|
210
|
+
const failure = { feature, error: String(e?.message ?? e).slice(0, 500) };
|
|
211
|
+
store.recordFailure(failure);
|
|
212
|
+
return { status: 'blocked', code: 'feature-failed', reason: `${feature} failed: ${failure.error}`, feature };
|
|
213
|
+
}
|
|
214
|
+
}
|