@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.
- package/CHANGELOG.md +38 -0
- package/bin/agentic-security.js +11 -2
- package/dist/1919.index.js +1 -1
- package/dist/2432.index.js +10 -1
- package/dist/4113.index.js +60 -9
- package/dist/5613.index.js +500 -0
- package/dist/6239.index.js +469 -0
- package/dist/7033.index.js +4 -1
- package/dist/7838.index.js +39 -3
- package/dist/8714.index.js +1 -1
- package/dist/8798.index.js +2 -2
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +205 -62
- package/dist/{1905.index.js → 9999.index.js} +265 -8
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +14 -9
- package/src/dataflow/engine.js +20 -4
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +28 -0
- package/src/discovery/memory.js +7 -5
- package/src/engine.js +29 -8
- package/src/fix/apply-fix-service.js +33 -3
- package/src/language/CLAUDE.md +6 -1
- package/src/language/aibom.js +4 -1
- package/src/language/fix-lifecycle.js +9 -0
- package/src/language/haskell-ir.js +47 -7
- package/src/language/haskell-sca.js +87 -7
- package/src/language/haskell-supply.js +175 -6
- package/src/language/haskell-web.js +200 -18
- package/src/language/nix-fix.js +1 -1
- package/src/language/resolved-pass.js +4 -1
- package/src/language/witness.js +1 -1
- package/src/mcp/CLAUDE.md +43 -4
- package/src/mcp/tools.js +185 -43
- 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/blast-radius.js +15 -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/remediation-ledger.js +10 -1
- 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
- package/src/sast/pipeline.js +51 -4
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Patch-negative verification (X-204).
|
|
2
|
+
//
|
|
3
|
+
// A re-scan that no longer fires proves the DETECTOR went quiet; a cosmetic edit can do that. A repair is `verified-fix` here
|
|
4
|
+
// only when three things are shown, each by an executed oracle run in an equivalent disposable environment:
|
|
5
|
+
//
|
|
6
|
+
// 1. original-positive the original revision reproduces the exploit (oracle outcome: confirmed)
|
|
7
|
+
// 2. patched-negative the patched revision no longer does, same oracle (oracle outcome: refuted)
|
|
8
|
+
// 3. functional-regression declared legitimate behaviour is intact on the patched revision, with the same expectations
|
|
9
|
+
// holding on the original first (a baseline), so a wrong expectation is reported rather than blamed on the patch.
|
|
10
|
+
//
|
|
11
|
+
// All four runs (the baseline is one) go through `replayManifest`, so each is pinned by commit, fixture hash, patch hash,
|
|
12
|
+
// toolchain, oracle version and inputs, executes inside the trust boundary, and issues a verifier receipt. The result is then
|
|
13
|
+
// handed to `promotePatch`, which re-derives the diff and revision from the proposal and refuses any receipt that does not
|
|
14
|
+
// match them. `verified-fix` needs the promotion; nothing else grants it.
|
|
15
|
+
//
|
|
16
|
+
// A failure never degrades to a vague "not verified": it names the SPECIFIC step that is incomplete and a typed failure code:
|
|
17
|
+
// original-not-reproduced the original did not reproduce under the oracle (nothing to fix, or the scenario is wrong)
|
|
18
|
+
// patched-still-exploitable the exploit still works on the patched revision
|
|
19
|
+
// patched-build-broken the patched revision did not load or its benign control failed
|
|
20
|
+
// patched-negative-inconclusive the patched run decided nothing
|
|
21
|
+
// functional-omitted no functional check was supplied
|
|
22
|
+
// functional-baseline-invalid the declared expectations do not hold on the ORIGINAL revision
|
|
23
|
+
// functional-regression-detected the patched revision behaves differently from the declared expectations
|
|
24
|
+
// oracle-changed the oracle, its version or its inputs differ between the original and patched runs
|
|
25
|
+
// revision-unbound no exact commit: a verdict cannot be tied to a revision
|
|
26
|
+
// prerequisite-unmet a toolchain, feature or isolation prerequisite is missing; resumable where stated
|
|
27
|
+
// environment-mismatch the runs were not in equivalent environments
|
|
28
|
+
// promotion-refused the receipts do not match the exact proposed diff and revision
|
|
29
|
+
//
|
|
30
|
+
// Scope, stated so no caller overclaims: the exploit scenario and the functional cases are the REQUESTER's; this verifies the
|
|
31
|
+
// declared scenario and cases, not that the patch is correct elsewhere. Linux is not claimed (the oracle platform statements
|
|
32
|
+
// stay `unverified`). Receipts are valid inside this process only (see patch-promotion.js).
|
|
33
|
+
import { digestOf } from '../assurance/identity.js';
|
|
34
|
+
import { isCommit, isPlainObject } from '../assurance/schema-kit.js';
|
|
35
|
+
import { resolveAssuranceConfig, featureStatus } from '../assurance/config.js';
|
|
36
|
+
import { createReplayManifest, replayManifest } from '../replay/replay.js';
|
|
37
|
+
import * as registry from '../oracles/registry.js';
|
|
38
|
+
import { promotePatch } from './patch-promotion.js';
|
|
39
|
+
import { appendRepairRecord, recordPromotion, verificationRepairFor, withRepairStatus } from './repair-records.js';
|
|
40
|
+
|
|
41
|
+
const PATCH_NEGATIVE_FEATURE = 'patch-negative-verification';
|
|
42
|
+
const FUNCTIONAL_ORACLE = 'functional-regression';
|
|
43
|
+
|
|
44
|
+
const STEPS = Object.freeze(['original-positive', 'patched-negative', 'functional-baseline', 'functional-regression']);
|
|
45
|
+
|
|
46
|
+
const ROLE_OF_STEP = Object.freeze({ 'original-positive': 'original', 'patched-negative': 'patched', 'functional-baseline': 'functionalBaseline', 'functional-regression': 'functional' });
|
|
47
|
+
const EXPECTED = Object.freeze({ 'original-positive': 'confirmed', 'patched-negative': 'refuted', 'functional-baseline': 'refuted', 'functional-regression': 'refuted' });
|
|
48
|
+
|
|
49
|
+
const stepResult = (step, status, extra = {}) => ({ step, status, expected: EXPECTED[step], outcome: null, code: null, reason: null, recordId: null, receiptDigest: null, ...extra });
|
|
50
|
+
|
|
51
|
+
function replayCode(run) {
|
|
52
|
+
const first = (run.errors || [])[0];
|
|
53
|
+
if (!first) return 'manifest-rejected';
|
|
54
|
+
if (first.code === 'ORACLE_MISMATCH') return 'oracle-changed';
|
|
55
|
+
if (first.code === 'HASH_MISMATCH') return 'content-hash-mismatch';
|
|
56
|
+
return 'manifest-rejected';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function classify(step, run) {
|
|
60
|
+
if (run.status === 'rejected') return { status: 'incomplete', code: replayCode(run), reason: (run.errors || []).map((e) => e.message).join('; ').slice(0, 300) || 'the replay manifest was rejected' };
|
|
61
|
+
if (run.status === 'awaiting-prerequisites') {
|
|
62
|
+
const resumable = (run.prerequisites || []).every((p) => p.resumable);
|
|
63
|
+
return { status: 'incomplete', code: 'prerequisite-unmet', outcome: run.outcome ?? null, resumable, reason: `${(run.prerequisites || []).map((p) => `${p.kind} ${p.id} (${p.state})`).join('; ') || 'a prerequisite is unmet'}; nothing was executed${resumable ? ' (resumable)' : ''}` };
|
|
64
|
+
}
|
|
65
|
+
const outcome = run.outcome;
|
|
66
|
+
const observed = run.receipt?.observed || {};
|
|
67
|
+
const loadBroken = observed.targetLoaded === false || observed.benignCompleted === false;
|
|
68
|
+
if (outcome === 'error') return { status: 'incomplete', code: 'harness-error', reason: String(run.record?.reason || 'the oracle run failed').slice(0, 300), outcome };
|
|
69
|
+
if (outcome === EXPECTED[step]) return { status: 'passed', code: null, reason: run.record?.reason ?? null, outcome };
|
|
70
|
+
const reason = String(run.record?.reason || '').slice(0, 300);
|
|
71
|
+
switch (step) {
|
|
72
|
+
case 'original-positive':
|
|
73
|
+
return { status: 'failed', code: 'original-not-reproduced', reason: `the original revision did not reproduce the exploit (outcome '${outcome}'): ${reason}`, outcome };
|
|
74
|
+
case 'patched-negative':
|
|
75
|
+
if (outcome === 'confirmed') return { status: 'failed', code: 'patched-still-exploitable', reason: `the exploit still works on the patched revision: ${reason}`, outcome };
|
|
76
|
+
return loadBroken
|
|
77
|
+
? { status: 'failed', code: 'patched-build-broken', reason: `the patched revision did not load or its benign control failed: ${reason}`, outcome }
|
|
78
|
+
: { status: 'failed', code: 'patched-negative-inconclusive', reason: `the patched run decided nothing (outcome '${outcome}'): ${reason}`, outcome };
|
|
79
|
+
case 'functional-baseline':
|
|
80
|
+
return { status: 'failed', code: 'functional-baseline-invalid', reason: `the declared functional expectations do not hold on the original revision (outcome '${outcome}'): ${reason}`, outcome };
|
|
81
|
+
default:
|
|
82
|
+
if (outcome === 'confirmed') return { status: 'failed', code: 'functional-regression-detected', reason: `the patched revision behaves differently from the declared expectations: ${reason}`, outcome };
|
|
83
|
+
return loadBroken
|
|
84
|
+
? { status: 'failed', code: 'patched-build-broken', reason: `the patched revision did not load for the functional check: ${reason}`, outcome }
|
|
85
|
+
: { status: 'failed', code: 'functional-inconclusive', reason: `the functional check decided nothing (outcome '${outcome}'): ${reason}`, outcome };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function summaryOf(steps, verified, incomplete, code) {
|
|
90
|
+
const label = (s) => ({ passed: 'PASS', failed: 'FAIL', incomplete: 'INCOMPLETE', 'not-run': 'not run' })[s.status];
|
|
91
|
+
const lines = steps.map((s) => `${s.step}: ${label(s)}${s.code ? ` (${s.code})` : ''}${s.reason && s.status !== 'passed' ? ` - ${s.reason}` : ''}`);
|
|
92
|
+
lines.push(verified
|
|
93
|
+
? 'verified-fix: the exploit reproduced on the original revision, did not reproduce on the patched revision, and the declared functional cases still hold. Only the declared scenario and cases were exercised.'
|
|
94
|
+
: `NOT verified-fix: incomplete step '${incomplete}'${code ? ` (${code})` : ''}.`);
|
|
95
|
+
return lines.join('\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Run the three-part patch-negative verification for one proposed patch. Never throws.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} req
|
|
102
|
+
* @param {string} req.hypothesisId the finding's stable id
|
|
103
|
+
* @param {string} req.commit exact revision the patch is proposed against (40 or 64 hex)
|
|
104
|
+
* @param {object} req.original { files, entry, oracleId, inputs, budgets? } the exploit scenario on the original files
|
|
105
|
+
* @param {object} req.patch { files } the proposed diff: the new content of each changed file
|
|
106
|
+
* @param {object} [req.patched] { oracleId?, inputs?, entry? }: must equal the original's; anything else is `oracle-changed`
|
|
107
|
+
* @param {object} [req.functional] { inputs, budgets? } for the functional-regression oracle; omitting it prevents verified-fix
|
|
108
|
+
* @param {object} [req.pinnedOracle] { id, version, logicDigest } the oracle identity the requester recorded earlier
|
|
109
|
+
* @param {object[]} [req.ledger] earlier repair records to extend
|
|
110
|
+
* @param {object} [o] { config, runOptions } config is the assurance config; runOptions go to the oracle runner (test seams)
|
|
111
|
+
*/
|
|
112
|
+
export async function verifyPatchNegative(req, o = {}) {
|
|
113
|
+
const config = o.config || resolveAssuranceConfig({ env: process.env });
|
|
114
|
+
const gate = featureStatus(config, PATCH_NEGATIVE_FEATURE);
|
|
115
|
+
const steps = STEPS.map((s) => stepResult(s, 'not-run', { reason: 'not reached' }));
|
|
116
|
+
const base = {
|
|
117
|
+
status: 'not-verified-fix', verifiedFix: false, incompleteStep: null, failureCode: null, reason: null, steps,
|
|
118
|
+
patchDigest: null, promotion: null, receipts: null, records: {}, verificationRecord: null, repairRecords: Array.isArray(req?.ledger) ? req.ledger : [], summary: '',
|
|
119
|
+
};
|
|
120
|
+
if (gate.status !== 'ok') {
|
|
121
|
+
return { ...base, status: 'disabled', incompleteStep: 'feature-gate', failureCode: gate.code || 'disabled', reason: `${PATCH_NEGATIVE_FEATURE} is not available: ${gate.reason}`, summary: `patch-negative verification did not run: ${gate.reason}` };
|
|
122
|
+
}
|
|
123
|
+
const refuse = (step, code, reason, extra = {}) => {
|
|
124
|
+
const rejected = req && isPlainObject(req.patch) && isPlainObject(req.patch.files) && Object.keys(req.patch.files).length && typeof req.hypothesisId === 'string'
|
|
125
|
+
? ledgerFor(base.repairRecords, req, base.patchDigest ?? digestOf(req.patch.files), { rejected: { step, code, reason } }) : { records: base.repairRecords };
|
|
126
|
+
return { ...base, ...extra, patchDigest: req?.patch?.files ? digestOf(req.patch.files) : null, incompleteStep: step, failureCode: code, reason, repairRecords: rejected.records, summary: summaryOf(steps, false, step, code) };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// ---- shape and preconditions that need no execution
|
|
130
|
+
const patchFiles = req?.patch?.files;
|
|
131
|
+
if (!isPlainObject(req) || typeof req.hypothesisId !== 'string' || !req.hypothesisId || !isPlainObject(req.original) || !isPlainObject(req.original.files)
|
|
132
|
+
|| typeof req.original.entry !== 'string' || typeof req.original.oracleId !== 'string' || !isPlainObject(req.original.inputs)
|
|
133
|
+
|| !isPlainObject(patchFiles) || Object.keys(patchFiles).length === 0) {
|
|
134
|
+
return refuse('input-validation', 'bad-request', 'a request needs a hypothesis id, the original files, entry, oracle and inputs, and a non-empty patch');
|
|
135
|
+
}
|
|
136
|
+
if (!isCommit(req.commit)) return refuse('input-validation', 'revision-unbound', 'no exact commit was given: a verdict cannot be tied to a revision');
|
|
137
|
+
const original = req.original;
|
|
138
|
+
const oracle = registry.getOracle(original.oracleId);
|
|
139
|
+
if (!oracle) return refuse('input-validation', 'unknown-oracle', `the exploit oracle '${String(original.oracleId).slice(0, 60)}' is not registered`);
|
|
140
|
+
if (oracle.id === FUNCTIONAL_ORACLE) return refuse('input-validation', 'bad-request', 'the exploit oracle cannot be the functional-regression oracle');
|
|
141
|
+
const patched = isPlainObject(req.patched) ? req.patched : {};
|
|
142
|
+
if ((patched.oracleId !== undefined && patched.oracleId !== original.oracleId) || (patched.inputs !== undefined && digestOf(patched.inputs) !== digestOf(original.inputs)) || (patched.entry !== undefined && patched.entry !== original.entry)) {
|
|
143
|
+
return refuse('oracle-consistency', 'oracle-changed', 'the patched run names a different oracle, entry or inputs than the original run: a changed oracle cannot show the exploit is closed');
|
|
144
|
+
}
|
|
145
|
+
const pin = req.pinnedOracle;
|
|
146
|
+
if (pin !== undefined && (!isPlainObject(pin) || pin.id !== oracle.id || pin.version !== oracle.version || pin.logicDigest !== oracle.logicDigest)) {
|
|
147
|
+
return refuse('oracle-consistency', 'oracle-changed', 'the oracle differs from the identity the requester pinned (id, version or logic digest)');
|
|
148
|
+
}
|
|
149
|
+
const fn = req.functional;
|
|
150
|
+
if (!isPlainObject(fn) || !isPlainObject(fn.inputs)) {
|
|
151
|
+
return refuse('functional-regression', 'functional-omitted', 'no functional regression check was supplied: a patch that was never checked for behaviour cannot be a verified fix');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const patchDigest = digestOf(patchFiles);
|
|
155
|
+
base.patchDigest = patchDigest;
|
|
156
|
+
let ledger = ledgerFor(base.repairRecords, req, patchDigest, {}).records; // proposed
|
|
157
|
+
const common = { hypothesisId: req.hypothesisId, commit: req.commit };
|
|
158
|
+
const plan = [
|
|
159
|
+
['original-positive', { oracleId: original.oracleId, entry: original.entry, inputs: original.inputs, patchFiles: null, budgets: original.budgets }],
|
|
160
|
+
['patched-negative', { oracleId: original.oracleId, entry: original.entry, inputs: original.inputs, patchFiles, budgets: original.budgets }],
|
|
161
|
+
['functional-baseline', { oracleId: FUNCTIONAL_ORACLE, entry: original.entry, inputs: fn.inputs, patchFiles: null, budgets: fn.budgets }],
|
|
162
|
+
['functional-regression', { oracleId: FUNCTIONAL_ORACLE, entry: original.entry, inputs: fn.inputs, patchFiles, budgets: fn.budgets }],
|
|
163
|
+
];
|
|
164
|
+
const runs = {};
|
|
165
|
+
let failed = null;
|
|
166
|
+
for (const [i, [step, spec]] of plan.entries()) {
|
|
167
|
+
if (failed) continue;
|
|
168
|
+
let run;
|
|
169
|
+
try {
|
|
170
|
+
const manifest = createReplayManifest({ ...common, fixtureFiles: original.files, patchFiles: spec.patchFiles, oracleId: spec.oracleId, entry: spec.entry, inputs: spec.inputs, budgets: spec.budgets || {}, expected: { outcome: EXPECTED[step] } });
|
|
171
|
+
run = await replayManifest({ manifest, fixtureFiles: original.files, patchFiles: spec.patchFiles }, { config: o.config || config, runOptions: o.runOptions });
|
|
172
|
+
} catch (e) {
|
|
173
|
+
run = { status: 'rejected', errors: [{ code: 'RUN_FAILED', message: String(e?.message || e).slice(0, 200) }] };
|
|
174
|
+
}
|
|
175
|
+
const c = classify(step, run);
|
|
176
|
+
steps[i] = stepResult(step, c.status, { ...c, recordId: run.record?.id ?? null, receiptDigest: run.receipt?.receiptDigest ?? null });
|
|
177
|
+
if (run.executed && run.receipt) runs[ROLE_OF_STEP[step]] = run;
|
|
178
|
+
if (c.status !== 'passed') failed = { step, ...c };
|
|
179
|
+
}
|
|
180
|
+
base.records = Object.fromEntries(Object.entries(runs).map(([k, r]) => [k, r.record]));
|
|
181
|
+
if (failed) {
|
|
182
|
+
ledger = ledgerFor(ledger, req, patchDigest, { rejected: { step: failed.step, code: failed.code, reason: failed.reason } }).records;
|
|
183
|
+
const original0 = base.records.original ? withRepairStatus(base.records.original, verificationRepairFor(ledger, req.hypothesisId, patchDigest)) : null;
|
|
184
|
+
return { ...base, incompleteStep: failed.step, failureCode: failed.code, reason: failed.reason, resumable: failed.resumable ?? false, repairRecords: ledger, verificationRecord: original0, summary: summaryOf(steps, false, failed.step, failed.code) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---- the receipts must match the exact proposed diff and revision before anything is promoted
|
|
188
|
+
const receipts = Object.fromEntries(Object.entries(runs).map(([k, r]) => [k, r.receipt]));
|
|
189
|
+
const promotion = promotePatch({ proposal: { hypothesisId: req.hypothesisId, revision: req.commit, originalFiles: original.files, patchFiles }, receipts });
|
|
190
|
+
if (!promotion.ok) {
|
|
191
|
+
const envBroken = promotion.reasons.some((r) => r.code === 'environment-mismatch');
|
|
192
|
+
const code = envBroken ? 'environment-mismatch' : promotion.reasons.some((r) => r.code === 'oracle-changed') ? 'oracle-changed' : 'promotion-refused';
|
|
193
|
+
const step = envBroken ? 'environment-equivalence' : code === 'oracle-changed' ? 'oracle-consistency' : 'promotion';
|
|
194
|
+
const reason = promotion.reasons.map((r) => r.message).join('; ').slice(0, 400);
|
|
195
|
+
ledger = ledgerFor(ledger, req, patchDigest, { rejected: { step, code, reason } }).records;
|
|
196
|
+
return { ...base, promotion, incompleteStep: step, failureCode: code, reason, repairRecords: ledger, verificationRecord: base.records.original ? withRepairStatus(base.records.original, verificationRepairFor(ledger, req.hypothesisId, patchDigest)) : null, summary: summaryOf(steps, false, step, code) };
|
|
197
|
+
}
|
|
198
|
+
const promoted = recordPromotion(ledger, promotion, { verificationRecordId: base.records.patched?.id ?? null });
|
|
199
|
+
ledger = promoted.records;
|
|
200
|
+
const verificationRecord = withRepairStatus(base.records.original, verificationRepairFor(ledger, req.hypothesisId, patchDigest, base.records.patched?.id));
|
|
201
|
+
return {
|
|
202
|
+
...base, status: 'verified-fix', verifiedFix: true, promotion, receipts, repairRecords: ledger, verificationRecord,
|
|
203
|
+
reason: 'verified', summary: summaryOf(steps, true, null, null),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The ledger for one verification: always starts with a `proposed` record, and records a rejection when verification stopped.
|
|
208
|
+
function ledgerFor(records, req, patchDigest, { rejected } = {}) {
|
|
209
|
+
let list = Array.isArray(records) ? records : [];
|
|
210
|
+
const fields = { hypothesisId: req.hypothesisId, revision: isCommit(req.commit) ? req.commit : null, diffDigest: patchDigest };
|
|
211
|
+
if (!list.some((r) => r.hypothesisId === fields.hypothesisId && r.diffDigest === patchDigest)) list = appendRepairRecord(list, { ...fields, kind: 'proposed', reason: 'a patch was proposed for verification' }).records;
|
|
212
|
+
if (rejected) list = appendRepairRecord(list, { ...fields, kind: 'rejected', step: rejected.step, reason: `${rejected.code}: ${String(rejected.reason || '').slice(0, 300)}` }).records;
|
|
213
|
+
return { records: list };
|
|
214
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Promoting a patch on independent verifier receipts (X-204.AC03).
|
|
2
|
+
//
|
|
3
|
+
// A patch may be PROMOTED (made eligible to be applied) only after the verifier domain has issued receipts for the exact
|
|
4
|
+
// diff it is being asked to promote, at the exact revision it was proposed against:
|
|
5
|
+
//
|
|
6
|
+
// original the original files reproduce the exploit -> confirmed
|
|
7
|
+
// patched original files with the proposed diff applied -> refuted
|
|
8
|
+
// functionalBaseline the functional expectations on the original files -> refuted (no regression)
|
|
9
|
+
// functional the same expectations on the patched files -> refuted (no regression)
|
|
10
|
+
//
|
|
11
|
+
// Every receipt is checked against the PROPOSAL, not against itself: the digest of the files a receipt covers must equal the
|
|
12
|
+
// digest this module recomputes from the proposed diff, and the commit it was issued for must be the proposal's revision. A
|
|
13
|
+
// receipt for a different diff, a different revision or a different hypothesis does not promote, and neither does a receipt
|
|
14
|
+
// the verifier did not issue (a copy, a hand-built object, a receipt whose content was edited: `isIssuedReceipt` is true only
|
|
15
|
+
// for the frozen object `runOracle` itself created).
|
|
16
|
+
//
|
|
17
|
+
// What this proves and what it does not: the receipts are unsigned objects valid inside the verifier's own process. A receipt
|
|
18
|
+
// that crossed a process boundary (JSON) is no longer an issued receipt and cannot promote; carrying promotion across
|
|
19
|
+
// processes needs a signing domain, which belongs to the signer, not to this module. Receipts show that the declared scenario
|
|
20
|
+
// and the declared functional cases behaved as stated; they do not show the patch is correct beyond them.
|
|
21
|
+
import { digestOf } from '../assurance/identity.js';
|
|
22
|
+
import { isCommit, isPlainObject } from '../assurance/schema-kit.js';
|
|
23
|
+
import { isIssuedReceipt } from '../oracles/oracle.js';
|
|
24
|
+
|
|
25
|
+
const PROMOTION_ROLES = Object.freeze(['original', 'patched', 'functionalBaseline', 'functional']);
|
|
26
|
+
|
|
27
|
+
const ISSUED_PROMOTIONS = new WeakSet();
|
|
28
|
+
|
|
29
|
+
/** True only for a result `promotePatch` itself returned with `ok: true`. A copy or a hand-built object is not. */
|
|
30
|
+
export function isIssuedPromotion(p) { return isPlainObject(p) && ISSUED_PROMOTIONS.has(p); }
|
|
31
|
+
|
|
32
|
+
// What each receipt role must have settled to.
|
|
33
|
+
const EXPECTED_OUTCOME = Object.freeze({ original: 'confirmed', patched: 'refuted', functionalBaseline: 'refuted', functional: 'refuted' });
|
|
34
|
+
|
|
35
|
+
const same = (a, b) => digestOf(a ?? null) === digestOf(b ?? null);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} args
|
|
39
|
+
* @param {object} args.proposal { hypothesisId, revision, originalFiles, patchFiles }
|
|
40
|
+
* @param {object} args.receipts { original, patched, functionalBaseline, functional }, each issued by runOracle
|
|
41
|
+
* @returns {{ ok: boolean, diffDigest: string|null, revision: string|null, reasons: {code,role,message}[], receiptDigests: object }}
|
|
42
|
+
*/
|
|
43
|
+
export function promotePatch({ proposal, receipts } = {}) {
|
|
44
|
+
const reasons = [];
|
|
45
|
+
const why = (code, role, message) => reasons.push({ code, role, message });
|
|
46
|
+
const out = (extra = {}) => {
|
|
47
|
+
const result = { ok: reasons.length === 0, diffDigest: null, revision: null, hypothesisId: null, reasons, receiptDigests: {}, ...extra };
|
|
48
|
+
result.ok = reasons.length === 0;
|
|
49
|
+
if (result.ok) ISSUED_PROMOTIONS.add(result);
|
|
50
|
+
return result;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (!isPlainObject(proposal) || typeof proposal.hypothesisId !== 'string' || !proposal.hypothesisId
|
|
54
|
+
|| !isPlainObject(proposal.originalFiles) || !isPlainObject(proposal.patchFiles) || Object.keys(proposal.patchFiles).length === 0) {
|
|
55
|
+
why('bad-proposal', null, 'a proposal needs a hypothesis id, the original files and a non-empty diff');
|
|
56
|
+
return out();
|
|
57
|
+
}
|
|
58
|
+
if (!isCommit(proposal.revision)) {
|
|
59
|
+
why('revision-unbound', null, 'a promotion is bound to one exact 40 or 64 character commit; none was given');
|
|
60
|
+
return out();
|
|
61
|
+
}
|
|
62
|
+
const diffDigest = digestOf(proposal.patchFiles);
|
|
63
|
+
const originalDigest = digestOf(proposal.originalFiles);
|
|
64
|
+
const patchedDigest = digestOf({ ...proposal.originalFiles, ...proposal.patchFiles });
|
|
65
|
+
const wantFiles = { original: originalDigest, functionalBaseline: originalDigest, patched: patchedDigest, functional: patchedDigest };
|
|
66
|
+
const base = { diffDigest, revision: proposal.revision, hypothesisId: proposal.hypothesisId };
|
|
67
|
+
|
|
68
|
+
const given = isPlainObject(receipts) ? receipts : {};
|
|
69
|
+
const digests = {};
|
|
70
|
+
for (const role of PROMOTION_ROLES) {
|
|
71
|
+
const r = given[role];
|
|
72
|
+
if (!r) { why('receipt-missing', role, `no ${role} receipt was supplied`); continue; }
|
|
73
|
+
if (!isIssuedReceipt(r) || r.issuedBy !== 'verifier' || r.schema !== 'agentic-security/oracle-receipt') {
|
|
74
|
+
why('receipt-not-issued', role, `the ${role} receipt was not issued by the verifier domain (a copy or constructed receipt is not valid)`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
digests[role] = r.receiptDigest;
|
|
78
|
+
if (r.request.hypothesisId !== proposal.hypothesisId) why('receipt-hypothesis', role, `the ${role} receipt is for a different hypothesis`);
|
|
79
|
+
if (r.request.commit !== proposal.revision) why('receipt-revision', role, `the ${role} receipt was issued for a different revision than the proposal`);
|
|
80
|
+
if (r.request.filesDigest !== wantFiles[role]) why('receipt-diff', role, `the ${role} receipt covers different content than the exact proposed diff on the original revision`);
|
|
81
|
+
if (r.settled.outcome !== EXPECTED_OUTCOME[role]) why('receipt-outcome', role, `the ${role} receipt settled '${r.settled.outcome}', not '${EXPECTED_OUTCOME[role]}'`);
|
|
82
|
+
if (role !== 'original' && r.settled.outcome === 'refuted' && r.settled.preconditionsHeld !== true) why('receipt-preconditions', role, `the ${role} receipt did not prove its preconditions`);
|
|
83
|
+
}
|
|
84
|
+
if (reasons.length) return out({ ...base, receiptDigests: digests });
|
|
85
|
+
|
|
86
|
+
const { original, patched, functionalBaseline, functional } = given;
|
|
87
|
+
// the exploit oracle is the SAME oracle on the SAME inputs for both revisions
|
|
88
|
+
if (!same(original.oracle, patched.oracle) || original.request.inputsDigest !== patched.request.inputsDigest || original.request.entry !== patched.request.entry) {
|
|
89
|
+
why('oracle-changed', 'patched', 'the patched-negative run did not use the same oracle, entry and inputs as the original-positive run');
|
|
90
|
+
}
|
|
91
|
+
// the functional check is one oracle on one set of inputs for both revisions
|
|
92
|
+
if (!same(functionalBaseline.oracle, functional.oracle) || functionalBaseline.request.inputsDigest !== functional.request.inputsDigest) {
|
|
93
|
+
why('oracle-changed', 'functional', 'the functional check on the patched revision did not use the same oracle and cases as its baseline');
|
|
94
|
+
}
|
|
95
|
+
if (functional.oracle.class !== 'functional-regression') why('oracle-changed', 'functional', 'the functional check is not a functional-regression oracle');
|
|
96
|
+
if (original.oracle.class === 'functional-regression') why('oracle-changed', 'original', 'the exploit oracle must not be the functional-regression oracle');
|
|
97
|
+
// equivalent disposable environments: one sanitized environment identity across every run
|
|
98
|
+
const envDigest = digestOf(original.environment);
|
|
99
|
+
for (const role of PROMOTION_ROLES) {
|
|
100
|
+
if (digestOf(given[role].environment) !== envDigest) why('environment-mismatch', role, `the ${role} run executed in a different environment than the original run`);
|
|
101
|
+
}
|
|
102
|
+
// four runs are four distinct receipts: one receipt cannot be presented in two roles
|
|
103
|
+
if (new Set(Object.values(digests)).size !== PROMOTION_ROLES.length) why('receipt-reused', null, 'the same receipt was presented in more than one role');
|
|
104
|
+
return out({ ...base, receiptDigests: digests });
|
|
105
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// One projection of the verification record for every interface (X-206).
|
|
2
|
+
//
|
|
3
|
+
// The CLI JSON report, the MCP tools, the human-readable report and the autopilot response all show the SAME verification of
|
|
4
|
+
// the SAME finding. They do that by calling this one module: nothing else renders, summarises or re-derives a state, a scope,
|
|
5
|
+
// an evidence id or a replay prerequisite. A surface that formats its own text would eventually disagree with the others,
|
|
6
|
+
// which is the defect this file exists to prevent.
|
|
7
|
+
//
|
|
8
|
+
// Rules the projection keeps:
|
|
9
|
+
// - It is a VIEW. It reads a validated version-1 record (`assurance/verification-record.js`) and invents nothing: an
|
|
10
|
+
// invalid record yields `{ ok: false, errors }`, never a guessed state.
|
|
11
|
+
// - Nothing the record holds is dropped. Every record field has a counterpart in the view (the test enumerates them), so a
|
|
12
|
+
// richer consumer loses nothing and a legacy consumer keeps its own boolean view under `legacy`.
|
|
13
|
+
// - The text never labels a partial result "safe" or "fixed". It states what the check established and what it did not,
|
|
14
|
+
// in words chosen per state, and a repair is described by its own separate status.
|
|
15
|
+
// - Output is deterministic: no clock, no randomness, a fixed key order and sorted prerequisites.
|
|
16
|
+
import { validateVerificationRecord } from '../assurance/verification-record.js';
|
|
17
|
+
import { toLegacyVerificationView } from '../assurance/migrations.js';
|
|
18
|
+
import { isPlainObject } from '../assurance/schema-kit.js';
|
|
19
|
+
import { trustedNegativeDenominator } from '../oracles/scenario-classes.js';
|
|
20
|
+
|
|
21
|
+
export const VIEW_SCHEMA = 'agentic-security/verification-view';
|
|
22
|
+
const VIEW_VERSION = '1.0.0';
|
|
23
|
+
|
|
24
|
+
const RUNTIME_ORACLE_KINDS = ['runtime-replay', 'differential'];
|
|
25
|
+
|
|
26
|
+
// What a reader is told for each state. Chosen so no partial state reads as a pass: only `confirmed` and `refuted` say anything
|
|
27
|
+
// was decided, and both are limited to the declared scenario.
|
|
28
|
+
const STATE_LABEL = Object.freeze({
|
|
29
|
+
confirmed: 'CONFIRMED (the declared scenario reproduced the effect)',
|
|
30
|
+
refuted: 'NOT REPRODUCED (the declared scenario did not reproduce the effect)',
|
|
31
|
+
inconclusive: 'INCONCLUSIVE (nothing was decided)',
|
|
32
|
+
'not-run': 'NOT RUN (no check was executed)',
|
|
33
|
+
unsupported: 'UNSUPPORTED (no applicable check exists here)',
|
|
34
|
+
error: 'ERROR (the check failed to run)',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const REPAIR_LINE = Object.freeze({
|
|
38
|
+
none: 'no patch is recorded for this finding',
|
|
39
|
+
proposed: 'a patch was proposed; it has not been replay-verified',
|
|
40
|
+
applied: 'a patch was applied; its effect has not been replay-verified',
|
|
41
|
+
'replay-verified': 'a patch was replay-verified against the declared scenario and functional cases only',
|
|
42
|
+
'rolled-back': 'a patch was rolled back',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function verifiedStatement(rec) {
|
|
46
|
+
const how = rec.oracle ? `oracle '${rec.oracle.id}'${rec.oracle.version ? ` v${rec.oracle.version}` : ''}` : 'no oracle';
|
|
47
|
+
const at = rec.commit ? ` at commit ${rec.commit.slice(0, 12)}` : ' without an exact commit';
|
|
48
|
+
switch (rec.outcome) {
|
|
49
|
+
case 'confirmed': return `${how} reproduced the effect${at}, within: ${rec.scope.description}`;
|
|
50
|
+
case 'refuted': return `${how} ran with valid preconditions and did not reproduce the effect${at}, within: ${rec.scope.description}`;
|
|
51
|
+
case 'inconclusive': return `nothing was established${rec.attempt > 0 ? ` (${how} ran but did not decide)` : ''}`;
|
|
52
|
+
case 'not-run': return 'nothing was established; no check was executed';
|
|
53
|
+
case 'unsupported': return 'nothing was established; no applicable check exists in this environment';
|
|
54
|
+
default: return 'nothing was established; the check did not complete';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function untestedStatements(rec) {
|
|
59
|
+
const out = [];
|
|
60
|
+
if (rec.outcome === 'confirmed' || rec.outcome === 'refuted') {
|
|
61
|
+
out.push('behaviour outside the declared scenario and inputs');
|
|
62
|
+
if (rec.outcome === 'refuted') out.push('other inputs and code paths that could still reach the same effect');
|
|
63
|
+
} else if (rec.outcome === 'inconclusive') {
|
|
64
|
+
out.push('whether the effect is reachable: the check did not decide');
|
|
65
|
+
} else {
|
|
66
|
+
out.push('the hypothesis itself: it has not been exercised');
|
|
67
|
+
}
|
|
68
|
+
if (rec.repair.status !== 'replay-verified') out.push('the effect of any proposed patch under a trusted exploit-negative replay');
|
|
69
|
+
if (rec.confirmationLevel === 'none' && rec.outcome !== 'confirmed') out.push('independent adjudication: none is recorded');
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The replay prerequisites for this record. Derived ones come from the record itself, so every surface computes the same
|
|
75
|
+
* list. Typed prerequisites a replay attempt reported (`replay/replay.js`) are merged in by id.
|
|
76
|
+
*/
|
|
77
|
+
function replayPrerequisites(rec, supplied) {
|
|
78
|
+
const items = new Map();
|
|
79
|
+
items.set('exact-commit', {
|
|
80
|
+
id: 'exact-commit', state: rec.commit ? 'met' : 'unmet',
|
|
81
|
+
detail: rec.commit ? 'the record is bound to one exact commit' : 'no exact commit is recorded; a replay cannot be pinned',
|
|
82
|
+
});
|
|
83
|
+
const runtime = !!rec.oracle && RUNTIME_ORACLE_KINDS.includes(rec.oracle.kind);
|
|
84
|
+
items.set('runtime-oracle', {
|
|
85
|
+
id: 'runtime-oracle', state: runtime ? 'met' : 'unmet',
|
|
86
|
+
detail: runtime ? `oracle '${rec.oracle.id}' is a runtime oracle` : 'no runtime oracle produced this record, so there is nothing to replay',
|
|
87
|
+
});
|
|
88
|
+
items.set('execution-boundary', {
|
|
89
|
+
id: 'execution-boundary', state: 'declared',
|
|
90
|
+
detail: `recorded on ${rec.scope.platform}${rec.scope.backend ? ` (${rec.scope.backend} backend)` : ''}; a replay needs an equivalent host`,
|
|
91
|
+
});
|
|
92
|
+
for (const p of Array.isArray(supplied) ? supplied : []) {
|
|
93
|
+
if (!isPlainObject(p) || typeof p.id !== 'string' || !p.id) continue;
|
|
94
|
+
const state = ['met', 'unmet', 'declared'].includes(p.state) ? p.state : 'unmet';
|
|
95
|
+
const item = { id: p.kind ? `${p.kind}:${p.id}` : p.id, state, detail: typeof p.reason === 'string' && p.reason ? p.reason.slice(0, 200) : 'reported by the replay attempt' };
|
|
96
|
+
if (typeof p.resumable === 'boolean') item.resumable = p.resumable;
|
|
97
|
+
items.set(item.id, item);
|
|
98
|
+
}
|
|
99
|
+
const list = [...items.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
100
|
+
const replayable = runtime && !!rec.commit && !['not-run', 'unsupported'].includes(rec.outcome) && !list.some((p) => p.state === 'unmet');
|
|
101
|
+
return { replayable, prerequisites: list };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function textLines(view) {
|
|
105
|
+
const lines = [];
|
|
106
|
+
lines.push(`Verification: ${STATE_LABEL[view.state]}`);
|
|
107
|
+
lines.push(` Record: ${view.recordId}${view.oracle ? ` oracle: ${view.oracle.id}${view.oracle.version ? `@${view.oracle.version}` : ''}` : ' oracle: none'}`);
|
|
108
|
+
lines.push(` Scope: ${view.scope.description} [${view.scope.platform}${view.scope.backend ? `/${view.scope.backend}` : ''}]`);
|
|
109
|
+
lines.push(` Verified: ${view.summary.verified}`);
|
|
110
|
+
lines.push(` Not verified: ${view.summary.untested.join('; ')}`);
|
|
111
|
+
lines.push(` Repair: ${REPAIR_LINE[view.repair.status]}`);
|
|
112
|
+
lines.push(` Reason: ${view.reason}`);
|
|
113
|
+
lines.push(` Evidence: ${view.evidenceIds.length ? view.evidenceIds.join(', ') : 'none'}`);
|
|
114
|
+
lines.push(` Replay prerequisites: ${view.replay.prerequisites.map((p) => `${p.id} (${p.state})`).join(', ')}${view.replay.replayable ? '' : ' [not replayable as recorded]'}`);
|
|
115
|
+
return lines;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Project a version-1 verification record.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} record a record from `emitVerification` / `runOracle`
|
|
122
|
+
* @param {object} [o]
|
|
123
|
+
* @param {object[]} [o.replay] typed prerequisites a replay attempt reported (`attempt.prerequisites`)
|
|
124
|
+
* @returns {{ ok: boolean, view: object|null, errors: object[] }} never throws
|
|
125
|
+
*/
|
|
126
|
+
export function projectVerification(record, o = {}) {
|
|
127
|
+
try {
|
|
128
|
+
const v = validateVerificationRecord(record);
|
|
129
|
+
if (!v.ok) return { ok: false, view: null, errors: v.errors };
|
|
130
|
+
const replay = replayPrerequisites(record, o.replay);
|
|
131
|
+
const view = {
|
|
132
|
+
schema: VIEW_SCHEMA, schemaVersion: VIEW_VERSION,
|
|
133
|
+
recordId: record.id, recordSchemaVersion: record.schemaVersion,
|
|
134
|
+
hypothesisId: record.hypothesisId, commit: record.commit, detectorOrigin: record.detectorOrigin,
|
|
135
|
+
oracle: record.oracle, attempt: record.attempt,
|
|
136
|
+
state: record.outcome, reason: record.reason,
|
|
137
|
+
confirmationLevel: record.confirmationLevel, preconditions: record.preconditions,
|
|
138
|
+
scope: record.scope, repair: record.repair,
|
|
139
|
+
evidence: record.evidence.map((e) => ({ id: e.id, kind: e.kind, producer: e.producer, digest: e.digest, source: e.source ?? null })),
|
|
140
|
+
evidenceIds: record.evidence.map((e) => e.id),
|
|
141
|
+
referencedEvidenceIds: [...record.evidenceRefs],
|
|
142
|
+
replay,
|
|
143
|
+
summary: { verified: verifiedStatement(record), untested: untestedStatements(record) },
|
|
144
|
+
legacy: toLegacyVerificationView(record),
|
|
145
|
+
migration: record.migration ?? null, createdAt: record.createdAt ?? null,
|
|
146
|
+
};
|
|
147
|
+
view.text = textLines(view);
|
|
148
|
+
return { ok: true, view, errors: [] };
|
|
149
|
+
} catch (e) {
|
|
150
|
+
return { ok: false, view: null, errors: [{ code: 'RULE_VIOLATION', path: '', message: `verification projection failed: ${String(e?.message || e).slice(0, 160)}` }] };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The fields every surface adds to an output that carries a verification record. The key is `verificationView`, NOT
|
|
156
|
+
* `verification`: a finding's `verification` field is the producer/verifier separation record
|
|
157
|
+
* (`posture/verification-separation.js`) and keeps its meaning. ADDITIVE: an output without a record gets
|
|
158
|
+
* `{}` and is byte-identical to what it was. A record that does not validate is reported, not dropped and not guessed.
|
|
159
|
+
*/
|
|
160
|
+
export function verificationFields(record, o = {}) {
|
|
161
|
+
if (!isPlainObject(record)) return {};
|
|
162
|
+
const p = projectVerification(record, o);
|
|
163
|
+
return p.ok ? { verificationView: p.view } : { verificationView: null, verificationViewErrors: p.errors };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The trusted-negative accounting for a set of verification records, for every interface that summarises a run
|
|
168
|
+
* (`oracles/scenario-classes.js` owns the rule): only a decided result of an executed oracle of an advertised non-taint class,
|
|
169
|
+
* with proven preconditions and trusted runtime proof, is counted. Every other record is listed with why it was left out, so an
|
|
170
|
+
* unsupported or unrun case can never read as "checked and clean". Records that are not valid are reported as excluded.
|
|
171
|
+
*/
|
|
172
|
+
export function verificationCoverage(records) {
|
|
173
|
+
const list = (Array.isArray(records) ? records : []).filter(isPlainObject);
|
|
174
|
+
const valid = list.filter((r) => validateVerificationRecord(r).ok);
|
|
175
|
+
const d = trustedNegativeDenominator(valid);
|
|
176
|
+
const invalid = list.length - valid.length;
|
|
177
|
+
const excluded = [...d.excluded, ...Array.from({ length: invalid }, () => ({ id: null, outcome: null, reason: 'malformed' }))];
|
|
178
|
+
const reasons = {};
|
|
179
|
+
for (const e of excluded) reasons[e.reason] = (reasons[e.reason] || 0) + 1;
|
|
180
|
+
const why = Object.entries(reasons).sort(([a], [b]) => a.localeCompare(b)).map(([k, n]) => `${k}: ${n}`).join(', ');
|
|
181
|
+
const lines = [`Trusted non-taint verification: ${d.denominator} decided (${d.trustedNegatives} not reproduced, ${d.trustedPositives} confirmed) of ${list.length} record(s); ${excluded.length} not counted${excluded.length ? ` (${why})` : ''}`];
|
|
182
|
+
return { schema: 'agentic-security/verification-coverage', schemaVersion: '1.0.0', total: list.length, denominator: d.denominator, trustedNegatives: d.trustedNegatives, trustedPositives: d.trustedPositives, excluded, lines };
|
|
183
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Separate records for patch application, rejection and rollback (X-204.AC03).
|
|
2
|
+
//
|
|
3
|
+
// The CORE-002 verification record carries ONE repair status (`none`, `proposed`, `applied`, `replay-verified`,
|
|
4
|
+
// `rolled-back`). That field answers "where is this repair now"; it cannot also be the history, and it has no value for a
|
|
5
|
+
// rejection. So the history is kept here as an append-only ledger of small records, one per event, each with its own id and
|
|
6
|
+
// a link to the record before it. A rollback is therefore a NEW record after an application, not an edit that erases it, and
|
|
7
|
+
// a rejection is a record of its own, not a status that vanishes when the patch is dropped.
|
|
8
|
+
//
|
|
9
|
+
// Rules the ledger enforces, each with a test (the state is per hypothesis and exact diff):
|
|
10
|
+
// proposed first event for a diff.
|
|
11
|
+
// rejected from proposed or promoted. Needs a reason; carries the incomplete step when verification caused it. Terminal.
|
|
12
|
+
// promoted from proposed, and ONLY through `recordPromotion`, which accepts nothing but a result `promotePatch` itself
|
|
13
|
+
// returned `ok: true` for. The generic append refuses this kind, so a caller cannot write a promotion by hand.
|
|
14
|
+
// applied from promoted only: nothing is applied that was not promoted for that exact diff.
|
|
15
|
+
// rolled-back from applied or promoted (a partial application that was undone). Terminal.
|
|
16
|
+
// `verificationRepairFor` maps the ledger onto the CORE-002 repair field; a rejection has no value there, so the record keeps
|
|
17
|
+
// `proposed` and the ledger holds the rejection (the enum is a foundation contract this change does not widen).
|
|
18
|
+
import { semanticId } from '../assurance/identity.js';
|
|
19
|
+
import { buildVerificationRecord, validateVerificationRecord } from '../assurance/verification-record.js';
|
|
20
|
+
import { isCommit, isDigest, isPlainObject } from '../assurance/schema-kit.js';
|
|
21
|
+
import { isIssuedPromotion } from './patch-promotion.js';
|
|
22
|
+
|
|
23
|
+
const REPAIR_RECORD_SCHEMA = 'agentic-security/repair-record';
|
|
24
|
+
const REPAIR_RECORD_VERSION = '1.0.0';
|
|
25
|
+
const REPAIR_KINDS = Object.freeze(['proposed', 'rejected', 'promoted', 'applied', 'rolled-back']);
|
|
26
|
+
|
|
27
|
+
const ID_FIELDS = ['kind', 'hypothesisId', 'revision', 'diffDigest', 'reason', 'step', 'receiptDigests', 'verificationRecordId', 'previous'];
|
|
28
|
+
const TRANSITIONS = Object.freeze({
|
|
29
|
+
proposed: [null],
|
|
30
|
+
rejected: ['proposed', 'promoted'],
|
|
31
|
+
promoted: ['proposed'],
|
|
32
|
+
applied: ['promoted'],
|
|
33
|
+
'rolled-back': ['applied', 'promoted'],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function build(fields) {
|
|
37
|
+
const rec = {
|
|
38
|
+
schema: REPAIR_RECORD_SCHEMA, schemaVersion: REPAIR_RECORD_VERSION,
|
|
39
|
+
kind: fields.kind, hypothesisId: fields.hypothesisId, revision: fields.revision ?? null, diffDigest: fields.diffDigest,
|
|
40
|
+
reason: fields.reason ?? null, step: fields.step ?? null, receiptDigests: fields.receiptDigests ?? null,
|
|
41
|
+
verificationRecordId: fields.verificationRecordId ?? null, previous: fields.previous ?? null,
|
|
42
|
+
};
|
|
43
|
+
rec.id = semanticId('rrec', rec, ID_FIELDS);
|
|
44
|
+
return Object.freeze(rec);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The latest event kind recorded for a hypothesis and exact diff, or null. */
|
|
48
|
+
function stateOf(records, hypothesisId, diffDigest) {
|
|
49
|
+
for (let i = records.length - 1; i >= 0; i--) {
|
|
50
|
+
if (records[i].hypothesisId === hypothesisId && records[i].diffDigest === diffDigest) return records[i];
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function check(records, fields) {
|
|
56
|
+
const errs = [];
|
|
57
|
+
if (!REPAIR_KINDS.includes(fields.kind)) errs.push(`unknown repair kind '${fields.kind}'`);
|
|
58
|
+
if (typeof fields.hypothesisId !== 'string' || !fields.hypothesisId) errs.push('hypothesisId is required');
|
|
59
|
+
if (!isDigest(fields.diffDigest)) errs.push('diffDigest must be sha256:<64 hex>');
|
|
60
|
+
if (fields.revision !== undefined && fields.revision !== null && !isCommit(fields.revision)) errs.push('revision must be an exact commit or null');
|
|
61
|
+
if (['rejected', 'rolled-back'].includes(fields.kind) && !(typeof fields.reason === 'string' && fields.reason.trim())) errs.push(`a '${fields.kind}' record needs a reason`);
|
|
62
|
+
if (errs.length) return errs;
|
|
63
|
+
const last = stateOf(records, fields.hypothesisId, fields.diffDigest);
|
|
64
|
+
const from = last ? last.kind : null;
|
|
65
|
+
if (!TRANSITIONS[fields.kind].includes(from)) errs.push(`'${fields.kind}' cannot follow ${from ? `'${from}'` : 'no earlier record'} for this diff`);
|
|
66
|
+
return errs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Append one event. Returns a NEW list (the input is never changed) and the record. `promoted` is refused here: use
|
|
71
|
+
* `recordPromotion`. Never throws.
|
|
72
|
+
* @returns {{ ok: boolean, records: object[], record: object|null, errors: string[] }}
|
|
73
|
+
*/
|
|
74
|
+
export function appendRepairRecord(records, fields) {
|
|
75
|
+
const list = Array.isArray(records) ? records : [];
|
|
76
|
+
if (!isPlainObject(fields)) return { ok: false, records: list, record: null, errors: ['fields must be an object'] };
|
|
77
|
+
if (fields.kind === 'promoted') return { ok: false, records: list, record: null, errors: ['a promotion is recorded only through recordPromotion, from a promotePatch result'] };
|
|
78
|
+
const errors = check(list, fields);
|
|
79
|
+
if (errors.length) return { ok: false, records: list, record: null, errors };
|
|
80
|
+
const last = stateOf(list, fields.hypothesisId, fields.diffDigest);
|
|
81
|
+
const record = build({ ...fields, previous: last ? last.id : null });
|
|
82
|
+
return { ok: true, records: [...list, record], record, errors: [] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Record a promotion. Accepts only a result `promotePatch` returned with `ok: true`. */
|
|
86
|
+
export function recordPromotion(records, promotion, { verificationRecordId = null } = {}) {
|
|
87
|
+
const list = Array.isArray(records) ? records : [];
|
|
88
|
+
if (!isIssuedPromotion(promotion)) return { ok: false, records: list, record: null, errors: ['not a promotion issued by promotePatch'] };
|
|
89
|
+
const fields = {
|
|
90
|
+
kind: 'promoted', hypothesisId: promotion.hypothesisId, revision: promotion.revision, diffDigest: promotion.diffDigest,
|
|
91
|
+
reason: 'independent verifier receipts match the exact proposed diff and revision', receiptDigests: promotion.receiptDigests, verificationRecordId,
|
|
92
|
+
};
|
|
93
|
+
const errors = check(list, fields);
|
|
94
|
+
if (errors.length) return { ok: false, records: list, record: null, errors };
|
|
95
|
+
const last = stateOf(list, fields.hypothesisId, fields.diffDigest);
|
|
96
|
+
const record = build({ ...fields, previous: last ? last.id : null });
|
|
97
|
+
return { ok: true, records: [...list, record], record, errors: [] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The current CORE-002 `repair` object for a diff, derived from the ledger. */
|
|
101
|
+
export function verificationRepairFor(records, hypothesisId, diffDigest, patchedRecordId = null) {
|
|
102
|
+
const last = stateOf(Array.isArray(records) ? records : [], hypothesisId, diffDigest);
|
|
103
|
+
if (!last) return { status: 'none' };
|
|
104
|
+
if (last.kind === 'applied') return { status: 'applied', patchDigest: diffDigest };
|
|
105
|
+
if (last.kind === 'rolled-back') return { status: 'rolled-back', patchDigest: diffDigest };
|
|
106
|
+
if (last.kind === 'promoted' && typeof patchedRecordId === 'string' && patchedRecordId.startsWith('vrec:')) {
|
|
107
|
+
return { status: 'replay-verified', patchDigest: diffDigest, replayRecordId: patchedRecordId };
|
|
108
|
+
}
|
|
109
|
+
return { status: 'proposed', patchDigest: diffDigest };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A copy of a verification record with its repair status replaced, rebuilt and re-validated (the id is recomputed, the
|
|
114
|
+
* confirmation level stays what its evidence supports). Returns null if the result is not a valid record.
|
|
115
|
+
*/
|
|
116
|
+
export function withRepairStatus(record, repair) {
|
|
117
|
+
if (!isPlainObject(record)) return null;
|
|
118
|
+
const rebuilt = buildVerificationRecord({
|
|
119
|
+
hypothesisId: record.hypothesisId, commit: record.commit, detectorOrigin: record.detectorOrigin, oracle: record.oracle,
|
|
120
|
+
attempt: record.attempt, outcome: record.outcome, reason: record.reason, evidence: record.evidence, evidenceRefs: record.evidenceRefs,
|
|
121
|
+
scope: record.scope, preconditions: record.preconditions, repair,
|
|
122
|
+
});
|
|
123
|
+
return validateVerificationRecord(rebuilt).ok ? rebuilt : null;
|
|
124
|
+
}
|