@clear-capabilities/agentic-security-scanner 0.161.0 → 0.162.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/bin/agentic-security.js +11 -2
- package/dist/4113.index.js +59 -8
- package/dist/5613.index.js +500 -0
- package/dist/6239.index.js +469 -0
- package/dist/7838.index.js +38 -2
- package/dist/8798.index.js +1 -1
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +36 -22
- package/dist/{1905.index.js → 9999.index.js} +265 -8
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +9 -4
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +28 -0
- package/src/discovery/memory.js +7 -5
- package/src/fix/apply-fix-service.js +33 -3
- package/src/mcp/CLAUDE.md +9 -1
- package/src/mcp/tools.js +17 -4
- package/src/posture/CLAUDE.md +174 -0
- package/src/posture/assurance/baseline-inventory.js +218 -0
- package/src/posture/assurance/baseline.js +322 -0
- package/src/posture/assurance/bounded-io.js +145 -0
- package/src/posture/assurance/config.js +299 -0
- package/src/posture/assurance/contracts.js +315 -0
- package/src/posture/assurance/identity.js +62 -0
- package/src/posture/assurance/migrations.js +259 -0
- package/src/posture/assurance/schema-kit.js +111 -0
- package/src/posture/assurance/verification-record.js +258 -0
- package/src/posture/attestation.js +5 -0
- package/src/posture/autopilot.js +28 -1
- package/src/posture/evidence-bundle.js +93 -7
- package/src/posture/execution-proof.js +11 -1
- package/src/posture/fix-verify-loop.js +20 -6
- package/src/posture/fix-verify.js +37 -2
- package/src/posture/oracles/adapters.js +360 -0
- package/src/posture/oracles/conformance.js +275 -0
- package/src/posture/oracles/oracle.js +433 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/replay/replay.js +249 -0
- package/src/posture/verification/advisory-state.js +71 -0
- package/src/posture/verification/emit.js +257 -0
- package/src/posture/verification/hypothesis-promotion.js +211 -0
- package/src/posture/verification/patch-negative.js +214 -0
- package/src/posture/verification/patch-promotion.js +105 -0
- package/src/posture/verification/projection.js +183 -0
- package/src/posture/verification/repair-records.js +124 -0
- package/src/posture/verifier.js +16 -0
- package/src/report/index.js +49 -0
- package/src/sandbox/CLAUDE.md +41 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +68 -29
- package/src/sandbox/control-probes.js +215 -0
- package/src/sandbox/supervise.js +201 -0
- package/src/sandbox/trust-boundary.js +113 -0
- package/src/sandbox/trust-domains.js +171 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// The one verification record (CORE-002; X-201 builds every surface on it).
|
|
2
|
+
//
|
|
3
|
+
// Shape: hypothesis id, commit, detector origin, oracle, attempt, outcome,
|
|
4
|
+
// reason, evidence items + references, applicable scope, and a SEPARATE repair
|
|
5
|
+
// status.
|
|
6
|
+
//
|
|
7
|
+
// Structural honesty rules, enforced by the validator (not by convention):
|
|
8
|
+
// 1. The six outcomes are distinct: not-run, unsupported, inconclusive, error,
|
|
9
|
+
// refuted, confirmed. Nothing here collapses them to a boolean.
|
|
10
|
+
// 2. Evidence kinds are distinct: observation, inference, independent
|
|
11
|
+
// adjudication, trusted runtime proof. Each kind constrains who may have
|
|
12
|
+
// produced it: a model can only ever produce an inference; trusted runtime
|
|
13
|
+
// proof only comes from a trusted runner; an adjudication only from a human
|
|
14
|
+
// or an independent verifier.
|
|
15
|
+
// 3. `confirmed` needs referenced evidence of runtime proof or independent
|
|
16
|
+
// adjudication, so a model-generated verdict alone cannot yield it, and the
|
|
17
|
+
// record carries a `confirmationLevel` that must equal the level derived from
|
|
18
|
+
// its evidence ('runtime-confirmed' needs trusted-runtime-proof AND a runtime
|
|
19
|
+
// oracle). A forged level fails the derivation check.
|
|
20
|
+
// 4. `refuted` needs an applicable (non-model) oracle, valid preconditions and
|
|
21
|
+
// proving evidence: a missing taint path is not refutation of an
|
|
22
|
+
// authorization or workflow claim.
|
|
23
|
+
// 5. Repair status is a different axis from the outcome. `replay-verified`
|
|
24
|
+
// needs the id of the patched-negative verification record.
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
SCHEMA_VERSION, makeCtx, result, guardObject, checkHeader, checkFields, checkEnum, checkDigest,
|
|
28
|
+
checkCommit, checkString, checkId, isPlainObject,
|
|
29
|
+
} from './schema-kit.js';
|
|
30
|
+
import { semanticId, ID_PREFIXES } from './identity.js';
|
|
31
|
+
|
|
32
|
+
export const VERIFICATION_SCHEMA = 'agentic-security/verification-record';
|
|
33
|
+
|
|
34
|
+
export const VERIFICATION_OUTCOMES = Object.freeze(['not-run', 'unsupported', 'inconclusive', 'error', 'refuted', 'confirmed']);
|
|
35
|
+
export const REPAIR_STATUSES = Object.freeze(['none', 'proposed', 'applied', 'replay-verified', 'rolled-back']);
|
|
36
|
+
export const EVIDENCE_KINDS = Object.freeze(['observation', 'inference', 'independent-adjudication', 'trusted-runtime-proof']);
|
|
37
|
+
export const EVIDENCE_PRODUCERS = Object.freeze(['model', 'tool', 'human', 'independent-verifier', 'trusted-runner']);
|
|
38
|
+
export const ORACLE_KINDS = Object.freeze(['runtime-replay', 'differential', 'static-proof', 'model', 'human', 'none']);
|
|
39
|
+
export const CONFIRMATION_LEVELS = Object.freeze(['runtime-confirmed', 'adjudicated', 'none']);
|
|
40
|
+
|
|
41
|
+
const RUNTIME_ORACLES = ['runtime-replay', 'differential'];
|
|
42
|
+
const APPLICABLE_FOR_REFUTATION = ['runtime-replay', 'differential', 'static-proof', 'human'];
|
|
43
|
+
const NO_EXECUTION_OUTCOMES = ['not-run', 'unsupported'];
|
|
44
|
+
|
|
45
|
+
const ALLOWED = [
|
|
46
|
+
'schema', 'schemaVersion', 'id', 'hypothesisId', 'commit', 'detectorOrigin', 'oracle', 'attempt', 'outcome', 'reason',
|
|
47
|
+
'evidence', 'evidenceRefs', 'scope', 'preconditions', 'confirmationLevel', 'repair', 'migration', 'createdAt',
|
|
48
|
+
];
|
|
49
|
+
const REQUIRED = [
|
|
50
|
+
'schema', 'schemaVersion', 'id', 'hypothesisId', 'commit', 'detectorOrigin', 'oracle', 'attempt', 'outcome', 'reason',
|
|
51
|
+
'evidence', 'evidenceRefs', 'scope', 'preconditions', 'confirmationLevel', 'repair',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// Fields that define WHAT was claimed and how it was checked. `createdAt` (a
|
|
55
|
+
// clock) and `migration` (how the record was represented) are deliberately not
|
|
56
|
+
// here, so neither can move an id.
|
|
57
|
+
export const VERIFICATION_ID_FIELDS = Object.freeze([
|
|
58
|
+
'hypothesisId', 'commit', 'detectorOrigin', 'oracle', 'attempt', 'outcome', 'reason', 'evidence', 'evidenceRefs',
|
|
59
|
+
'scope', 'preconditions', 'confirmationLevel', 'repair',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
export function verificationRecordId(record) {
|
|
63
|
+
return semanticId(ID_PREFIXES.verification, record, VERIFICATION_ID_FIELDS);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The identity of the CLAIM (which hypothesis, at which commit, by which oracle, on which attempt), excluding its result. */
|
|
67
|
+
export function verificationClaimKey(record) {
|
|
68
|
+
return semanticId('vclaim', record, ['hypothesisId', 'commit', 'oracle', 'attempt']);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function referencedEvidence(record) {
|
|
72
|
+
const refs = new Set(Array.isArray(record.evidenceRefs) ? record.evidenceRefs : []);
|
|
73
|
+
return (Array.isArray(record.evidence) ? record.evidence : []).filter(e => isPlainObject(e) && refs.has(e.id));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The confirmation level the evidence actually supports. Never read from the record's own claim. */
|
|
77
|
+
export function deriveConfirmationLevel(record) {
|
|
78
|
+
if (!record || record.outcome !== 'confirmed') return 'none';
|
|
79
|
+
const ev = referencedEvidence(record);
|
|
80
|
+
const runtime = ev.some(e => e.kind === 'trusted-runtime-proof' && e.producer === 'trusted-runner');
|
|
81
|
+
if (runtime && RUNTIME_ORACLES.includes(record.oracle?.kind)) return 'runtime-confirmed';
|
|
82
|
+
const adjudicated = ev.some(e => e.kind === 'independent-adjudication' && ['human', 'independent-verifier'].includes(e.producer));
|
|
83
|
+
return adjudicated ? 'adjudicated' : 'none';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function validateEvidenceItem(ctx, e, i) {
|
|
87
|
+
const p = `evidence[${i}]`;
|
|
88
|
+
if (!isPlainObject(e)) { ctx.err('BAD_TYPE', p, 'evidence item must be an object'); return; }
|
|
89
|
+
for (const k of Object.keys(e)) {
|
|
90
|
+
if (!['id', 'kind', 'producer', 'digest', 'source'].includes(k)) ctx.err('UNKNOWN_FIELD', `${p}.${k}`, `field '${k}' is not part of an evidence item`);
|
|
91
|
+
}
|
|
92
|
+
checkString(ctx, `${p}.id`, e.id);
|
|
93
|
+
const kindOk = checkEnum(ctx, `${p}.kind`, e.kind, EVIDENCE_KINDS);
|
|
94
|
+
const prodOk = checkEnum(ctx, `${p}.producer`, e.producer, EVIDENCE_PRODUCERS);
|
|
95
|
+
checkDigest(ctx, `${p}.digest`, e.digest);
|
|
96
|
+
if (e.source !== undefined && e.source !== null) checkString(ctx, `${p}.source`, e.source);
|
|
97
|
+
if (!kindOk || !prodOk) return;
|
|
98
|
+
if (e.producer === 'model' && e.kind !== 'inference') {
|
|
99
|
+
ctx.err('RULE_VIOLATION', p, `a model-produced item can only be 'inference', not '${e.kind}'`);
|
|
100
|
+
}
|
|
101
|
+
if (e.kind === 'trusted-runtime-proof' && e.producer !== 'trusted-runner') {
|
|
102
|
+
ctx.err('RULE_VIOLATION', p, `'trusted-runtime-proof' can only be produced by a 'trusted-runner', not '${e.producer}'`);
|
|
103
|
+
}
|
|
104
|
+
if (e.kind === 'independent-adjudication' && !['human', 'independent-verifier'].includes(e.producer)) {
|
|
105
|
+
ctx.err('RULE_VIOLATION', p, `'independent-adjudication' can only be produced by a 'human' or 'independent-verifier', not '${e.producer}'`);
|
|
106
|
+
}
|
|
107
|
+
if (e.producer === 'trusted-runner' && !['trusted-runtime-proof', 'observation'].includes(e.kind)) {
|
|
108
|
+
ctx.err('RULE_VIOLATION', p, `a 'trusted-runner' produces 'trusted-runtime-proof' or 'observation', not '${e.kind}'`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function validateVerificationRecord(record) {
|
|
113
|
+
const g = guardObject(record);
|
|
114
|
+
const ctx = g.ctx;
|
|
115
|
+
if (!g.ok) return result(ctx);
|
|
116
|
+
if (!checkHeader(ctx, record, VERIFICATION_SCHEMA)) return result(ctx);
|
|
117
|
+
checkFields(ctx, record, ALLOWED, REQUIRED);
|
|
118
|
+
|
|
119
|
+
checkString(ctx, 'hypothesisId', record.hypothesisId);
|
|
120
|
+
checkCommit(ctx, 'commit', record.commit);
|
|
121
|
+
|
|
122
|
+
const origin = record.detectorOrigin;
|
|
123
|
+
if (!isPlainObject(origin)) ctx.err('BAD_TYPE', 'detectorOrigin', 'must be an object with a detector name');
|
|
124
|
+
else {
|
|
125
|
+
for (const k of Object.keys(origin)) if (!['detector', 'family', 'parser'].includes(k)) ctx.err('UNKNOWN_FIELD', `detectorOrigin.${k}`, 'not part of detectorOrigin');
|
|
126
|
+
checkString(ctx, 'detectorOrigin.detector', origin.detector);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const oracle = record.oracle;
|
|
130
|
+
let oracleKind = null;
|
|
131
|
+
if (oracle === null) oracleKind = 'none';
|
|
132
|
+
else if (!isPlainObject(oracle)) ctx.err('BAD_TYPE', 'oracle', 'must be an object or null');
|
|
133
|
+
else {
|
|
134
|
+
for (const k of Object.keys(oracle)) if (!['id', 'kind', 'version'].includes(k)) ctx.err('UNKNOWN_FIELD', `oracle.${k}`, 'not part of oracle');
|
|
135
|
+
checkString(ctx, 'oracle.id', oracle.id);
|
|
136
|
+
if (checkEnum(ctx, 'oracle.kind', oracle.kind, ORACLE_KINDS)) oracleKind = oracle.kind;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!Number.isInteger(record.attempt) || record.attempt < 0) ctx.err('BAD_TYPE', 'attempt', 'must be a non-negative integer');
|
|
140
|
+
const outcomeOk = checkEnum(ctx, 'outcome', record.outcome, VERIFICATION_OUTCOMES);
|
|
141
|
+
checkString(ctx, 'reason', record.reason);
|
|
142
|
+
|
|
143
|
+
// evidence items
|
|
144
|
+
const ids = new Set();
|
|
145
|
+
if (!Array.isArray(record.evidence)) ctx.err('BAD_TYPE', 'evidence', 'must be an array');
|
|
146
|
+
else {
|
|
147
|
+
record.evidence.forEach((e, i) => {
|
|
148
|
+
validateEvidenceItem(ctx, e, i);
|
|
149
|
+
if (isPlainObject(e) && typeof e.id === 'string') {
|
|
150
|
+
if (ids.has(e.id)) ctx.err('DUPLICATE_ID', `evidence[${i}].id`, `duplicate evidence id '${e.id}'`);
|
|
151
|
+
ids.add(e.id);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (!Array.isArray(record.evidenceRefs)) ctx.err('BAD_TYPE', 'evidenceRefs', 'must be an array of evidence ids');
|
|
156
|
+
else {
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
record.evidenceRefs.forEach((r, i) => {
|
|
159
|
+
if (!ids.has(r)) ctx.err('DANGLING_REF', `evidenceRefs[${i}]`, `evidence reference '${r}' does not resolve to an evidence item in this record`);
|
|
160
|
+
if (seen.has(r)) ctx.err('DUPLICATE_ID', `evidenceRefs[${i}]`, `evidence reference '${r}' listed twice`);
|
|
161
|
+
seen.add(r);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// scope
|
|
166
|
+
const scope = record.scope;
|
|
167
|
+
if (!isPlainObject(scope)) ctx.err('BAD_TYPE', 'scope', 'must be an object');
|
|
168
|
+
else {
|
|
169
|
+
for (const k of Object.keys(scope)) if (!['description', 'platform', 'backend'].includes(k)) ctx.err('UNKNOWN_FIELD', `scope.${k}`, 'not part of scope');
|
|
170
|
+
checkString(ctx, 'scope.description', scope.description);
|
|
171
|
+
checkString(ctx, 'scope.platform', scope.platform);
|
|
172
|
+
if (scope.backend !== undefined && scope.backend !== null) checkString(ctx, 'scope.backend', scope.backend);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// preconditions
|
|
176
|
+
const pre = record.preconditions;
|
|
177
|
+
if (!isPlainObject(pre) || typeof pre.valid !== 'boolean') ctx.err('BAD_TYPE', 'preconditions', 'must be an object with a boolean `valid`');
|
|
178
|
+
|
|
179
|
+
// repair: a separate axis
|
|
180
|
+
const repair = record.repair;
|
|
181
|
+
if (!isPlainObject(repair)) ctx.err('BAD_TYPE', 'repair', 'must be an object');
|
|
182
|
+
else {
|
|
183
|
+
for (const k of Object.keys(repair)) if (!['status', 'patchDigest', 'replayRecordId'].includes(k)) ctx.err('UNKNOWN_FIELD', `repair.${k}`, 'not part of repair');
|
|
184
|
+
if (checkEnum(ctx, 'repair.status', repair.status, REPAIR_STATUSES)) {
|
|
185
|
+
if (['applied', 'replay-verified', 'rolled-back'].includes(repair.status)) {
|
|
186
|
+
if (repair.patchDigest === undefined || repair.patchDigest === null) ctx.err('RULE_VIOLATION', 'repair.patchDigest', `repair status '${repair.status}' requires a patchDigest`);
|
|
187
|
+
}
|
|
188
|
+
if (repair.patchDigest !== undefined && repair.patchDigest !== null) checkDigest(ctx, 'repair.patchDigest', repair.patchDigest);
|
|
189
|
+
if (repair.status === 'replay-verified' && !(typeof repair.replayRecordId === 'string' && repair.replayRecordId.startsWith('vrec:'))) {
|
|
190
|
+
ctx.err('RULE_VIOLATION', 'repair.replayRecordId', `repair status 'replay-verified' requires the id of the patched-negative verification record`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// confirmation level must be what the evidence supports
|
|
196
|
+
const levelOk = checkEnum(ctx, 'confirmationLevel', record.confirmationLevel, CONFIRMATION_LEVELS);
|
|
197
|
+
if (levelOk) {
|
|
198
|
+
const derived = deriveConfirmationLevel(record);
|
|
199
|
+
if (record.confirmationLevel !== derived) {
|
|
200
|
+
ctx.err('RULE_VIOLATION', 'confirmationLevel', `claimed '${record.confirmationLevel}' but the referenced evidence supports '${derived}'`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// outcome rules
|
|
205
|
+
if (outcomeOk) {
|
|
206
|
+
const refEv = referencedEvidence(record);
|
|
207
|
+
const hasProof = refEv.some(e => ['trusted-runtime-proof', 'independent-adjudication'].includes(e.kind));
|
|
208
|
+
const attempt = record.attempt;
|
|
209
|
+
if (!NO_EXECUTION_OUTCOMES.includes(record.outcome) && Number.isInteger(attempt) && attempt < 1) {
|
|
210
|
+
ctx.err('RULE_VIOLATION', 'attempt', `outcome '${record.outcome}' requires attempt >= 1`);
|
|
211
|
+
}
|
|
212
|
+
if (NO_EXECUTION_OUTCOMES.includes(record.outcome)) {
|
|
213
|
+
if (Number.isInteger(attempt) && attempt !== 0) ctx.err('RULE_VIOLATION', 'attempt', `outcome '${record.outcome}' means nothing ran, so attempt must be 0`);
|
|
214
|
+
if (refEv.some(e => e.kind === 'trusted-runtime-proof')) ctx.err('RULE_VIOLATION', 'evidence', `outcome '${record.outcome}' cannot cite runtime proof: nothing executed`);
|
|
215
|
+
}
|
|
216
|
+
if (['confirmed', 'refuted'].includes(record.outcome)) {
|
|
217
|
+
if (record.commit === null) ctx.err('RULE_VIOLATION', 'commit', `outcome '${record.outcome}' must be bound to an exact commit`);
|
|
218
|
+
if (oracleKind === 'none') ctx.err('RULE_VIOLATION', 'oracle', `outcome '${record.outcome}' requires an oracle`);
|
|
219
|
+
if (oracleKind === 'model') ctx.err('RULE_VIOLATION', 'oracle', `a model oracle can never yield '${record.outcome}'`);
|
|
220
|
+
if (!hasProof) ctx.err('RULE_VIOLATION', 'evidenceRefs', `outcome '${record.outcome}' requires referenced trusted-runtime-proof or independent-adjudication evidence`);
|
|
221
|
+
}
|
|
222
|
+
if (record.outcome === 'confirmed' && record.confirmationLevel === 'none') {
|
|
223
|
+
ctx.err('RULE_VIOLATION', 'confirmationLevel', `a 'confirmed' record must carry a confirmation level above 'none'`);
|
|
224
|
+
}
|
|
225
|
+
if (record.outcome === 'refuted') {
|
|
226
|
+
if (oracleKind && !APPLICABLE_FOR_REFUTATION.includes(oracleKind)) ctx.err('RULE_VIOLATION', 'oracle', `oracle kind '${oracleKind}' is not applicable for refutation`);
|
|
227
|
+
if (!(isPlainObject(pre) && pre.valid === true)) ctx.err('RULE_VIOLATION', 'preconditions', `'refuted' requires valid preconditions: a check that could not run is not a refutation`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
checkId(ctx, record, verificationRecordId(record));
|
|
232
|
+
return result(ctx);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Build a complete, valid-by-construction record from the semantic fields; derives the level and the id. */
|
|
236
|
+
export function buildVerificationRecord(fields) {
|
|
237
|
+
const rec = {
|
|
238
|
+
schema: VERIFICATION_SCHEMA,
|
|
239
|
+
schemaVersion: SCHEMA_VERSION,
|
|
240
|
+
hypothesisId: fields.hypothesisId,
|
|
241
|
+
commit: fields.commit ?? null,
|
|
242
|
+
detectorOrigin: fields.detectorOrigin,
|
|
243
|
+
oracle: fields.oracle ?? null,
|
|
244
|
+
attempt: fields.attempt ?? 0,
|
|
245
|
+
outcome: fields.outcome,
|
|
246
|
+
reason: fields.reason,
|
|
247
|
+
evidence: fields.evidence ?? [],
|
|
248
|
+
evidenceRefs: fields.evidenceRefs ?? (fields.evidence ?? []).map(e => e.id),
|
|
249
|
+
scope: fields.scope,
|
|
250
|
+
preconditions: fields.preconditions ?? { valid: false },
|
|
251
|
+
repair: fields.repair ?? { status: 'none' },
|
|
252
|
+
};
|
|
253
|
+
rec.confirmationLevel = deriveConfirmationLevel(rec);
|
|
254
|
+
if (fields.migration) rec.migration = fields.migration;
|
|
255
|
+
if (fields.createdAt) rec.createdAt = fields.createdAt;
|
|
256
|
+
rec.id = verificationRecordId(rec);
|
|
257
|
+
return rec;
|
|
258
|
+
}
|
|
@@ -143,6 +143,11 @@ export function computeRunAttestation({
|
|
|
143
143
|
try {
|
|
144
144
|
att.signature = signLastScan(digest);
|
|
145
145
|
att.signatureScope = 'per-install-hmac';
|
|
146
|
+
// CORE-003.AC03: say plainly who vouches. A symmetric install HMAC can be
|
|
147
|
+
// produced by anyone who can verify it, so it is self-issued by
|
|
148
|
+
// construction and can never be third-party certification.
|
|
149
|
+
att.trustBasis = 'self-issued-local-key';
|
|
150
|
+
att.independentlyCertified = false;
|
|
146
151
|
} catch { /* signing is best-effort; an unsigned attestation is still valid */ }
|
|
147
152
|
}
|
|
148
153
|
return att;
|
package/src/posture/autopilot.js
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
|
|
27
27
|
import fs from 'node:fs';
|
|
28
28
|
import path from 'node:path';
|
|
29
|
+
import { emitVerification } from './verification/emit.js';
|
|
30
|
+
import { verificationFields } from './verification/projection.js';
|
|
29
31
|
|
|
30
32
|
const SCHEMA = 'agentic-security/autopilot@1';
|
|
31
33
|
|
|
@@ -73,8 +75,15 @@ function _save(stateFile, state) {
|
|
|
73
75
|
*/
|
|
74
76
|
export async function runAutopilot({
|
|
75
77
|
stages = {}, stateFile = null, resume = true, apply = false,
|
|
76
|
-
onStage = () => {}, severities = ['critical', 'high'], maxFindings = Infinity,
|
|
78
|
+
onStage = () => {}, severities = ['critical', 'high'], maxFindings = Infinity, commit = null,
|
|
77
79
|
} = {}) {
|
|
80
|
+
// X-201: every settled finding carries the one version-1 verification record, ADDITIVELY (`outcome`, `reason` and
|
|
81
|
+
// every other field are unchanged). The chain's own proof and re-verify stages run under the older sandbox path, so
|
|
82
|
+
// the record never says `confirmed` or `refuted` here: it records what was observed and what remains unproven.
|
|
83
|
+
const seal = (rec, finding, patch) => {
|
|
84
|
+
const e = emitVerification('autopilot', rec, { finding, patch: patch?.patch, commit });
|
|
85
|
+
if (e.ok) rec.verificationRecord = e.record;
|
|
86
|
+
};
|
|
78
87
|
const required = ['scan'];
|
|
79
88
|
for (const r of required) {
|
|
80
89
|
if (typeof stages[r] !== 'function') return { ok: false, reason: `no ${r} stage supplied` };
|
|
@@ -138,6 +147,7 @@ export async function runAutopilot({
|
|
|
138
147
|
if (rec.validation === 'refuted') {
|
|
139
148
|
rec.outcome = 'NEEDS_REVIEW';
|
|
140
149
|
rec.reason = 'an independent verifier refuted this finding; not fixed automatically';
|
|
150
|
+
seal(rec, f, null);
|
|
141
151
|
results.push(rec); state.findings[key] = rec; _save(stateFile, state);
|
|
142
152
|
onStage({ stage: 'validate', key, verdict: 'refuted' });
|
|
143
153
|
continue;
|
|
@@ -149,6 +159,7 @@ export async function runAutopilot({
|
|
|
149
159
|
if (!isProven) {
|
|
150
160
|
rec.outcome = 'UNPROVEN';
|
|
151
161
|
rec.reason = proved?.proofEvidence?.reason || 'no proof-of-concept demonstrated this finding';
|
|
162
|
+
seal(rec, f, null);
|
|
152
163
|
results.push(rec); state.findings[key] = rec; _save(stateFile, state);
|
|
153
164
|
continue;
|
|
154
165
|
}
|
|
@@ -161,6 +172,7 @@ export async function runAutopilot({
|
|
|
161
172
|
if (!patch || !patch.patch) {
|
|
162
173
|
rec.outcome = 'NO_FIX';
|
|
163
174
|
rec.reason = 'no patch was synthesised';
|
|
175
|
+
seal(rec, f, null);
|
|
164
176
|
results.push(rec); state.findings[key] = rec; _save(stateFile, state);
|
|
165
177
|
continue;
|
|
166
178
|
}
|
|
@@ -171,6 +183,7 @@ export async function runAutopilot({
|
|
|
171
183
|
if (typeof stages.verifyFix !== 'function') {
|
|
172
184
|
rec.outcome = 'NEEDS_REVIEW';
|
|
173
185
|
rec.reason = 'no verifyFix stage supplied — a patch that cannot be re-verified is never applied';
|
|
186
|
+
seal(rec, f, patch);
|
|
174
187
|
results.push(rec); state.findings[key] = rec; _save(stateFile, state);
|
|
175
188
|
continue;
|
|
176
189
|
}
|
|
@@ -194,6 +207,7 @@ export async function runAutopilot({
|
|
|
194
207
|
: (!rec.testsPass ? 'the project test suite fails with this patch'
|
|
195
208
|
: (v?.reason || 'the patch did not re-verify'));
|
|
196
209
|
}
|
|
210
|
+
seal(rec, f, patch);
|
|
197
211
|
results.push(rec);
|
|
198
212
|
state.findings[key] = rec;
|
|
199
213
|
_save(stateFile, state);
|
|
@@ -203,6 +217,19 @@ export async function runAutopilot({
|
|
|
203
217
|
return { ok: true, results, skipped, outOfScope, capped, summary: summarizeAutopilot(results, outOfScope, capped) };
|
|
204
218
|
}
|
|
205
219
|
|
|
220
|
+
/**
|
|
221
|
+
* X-206: the autopilot response as it is serialized for a caller. Every field of every result is kept (the native `outcome`
|
|
222
|
+
* vocabulary and its meaning are unchanged); a result that carries a verification record additionally gets `verificationView`, the
|
|
223
|
+
* shared projection every other interface shows for the same finding. Results without a record are returned as they were.
|
|
224
|
+
*/
|
|
225
|
+
export function serializeAutopilotResult(res) {
|
|
226
|
+
if (!res || !Array.isArray(res.results)) return res;
|
|
227
|
+
return {
|
|
228
|
+
...res,
|
|
229
|
+
results: res.results.map((r) => (r && r.verificationRecord ? { ...r, ...verificationFields(r.verificationRecord, { replay: r.verificationReplay }) } : r)),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
206
233
|
export function summarizeAutopilot(results, outOfScope = 0, capped = 0) {
|
|
207
234
|
const byOutcome = Object.fromEntries(OUTCOMES.map(o => [o, 0]));
|
|
208
235
|
for (const r of results) if (r.outcome in byOutcome) byOutcome[r.outcome]++;
|
|
@@ -47,6 +47,61 @@ import * as crypto from 'node:crypto';
|
|
|
47
47
|
|
|
48
48
|
export const BUNDLE_SCHEMA = 'agentic-security/finding-evidence@1';
|
|
49
49
|
|
|
50
|
+
// ---- issuer and trust basis (CORE-003.AC03) --------------------------------
|
|
51
|
+
//
|
|
52
|
+
// A signature says WHO signed and nothing about whether anyone independent
|
|
53
|
+
// vouches for them. This install generates its own key, so every bundle it
|
|
54
|
+
// signs is SELF-ISSUED. The `issuance` block says so inside the signed bytes,
|
|
55
|
+
// so a summariser cannot drop it, and the verifier reports it back. The only
|
|
56
|
+
// trust basis this build can verify is the local one; a third-party basis needs
|
|
57
|
+
// a trust-root policy that does not exist here, so an unknown basis is
|
|
58
|
+
// REJECTED rather than being displayed as if it were certification.
|
|
59
|
+
//
|
|
60
|
+
// Backward compatible: `issuance` is absent from bundles signed before this
|
|
61
|
+
// field existed. Those still verify exactly as before and are reported as
|
|
62
|
+
// having NO DECLARED trust basis (not as trusted, not as invalid).
|
|
63
|
+
export const TRUST_BASES = Object.freeze({
|
|
64
|
+
'self-issued-local-key': Object.freeze({
|
|
65
|
+
independentlyCertified: false,
|
|
66
|
+
statement:
|
|
67
|
+
'Signed by a key this install generated for itself. It proves the bundle is unmodified since ' +
|
|
68
|
+
'this key signed it. It is NOT independent third-party certification: whoever controls this ' +
|
|
69
|
+
'install controls the key.',
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** SHA-256 of the SPKI DER of a public key: the stable identity of an issuer. */
|
|
74
|
+
export function keyFingerprint(publicKeyPem) {
|
|
75
|
+
const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
|
|
76
|
+
return crypto.createHash('sha256').update(der).digest('hex');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Issuance block for a locally generated key. */
|
|
80
|
+
function buildLocalIssuance(publicKeyPem) {
|
|
81
|
+
const fp = keyFingerprint(publicKeyPem);
|
|
82
|
+
return {
|
|
83
|
+
issuer: { id: `local-install:${fp.slice(0, 16)}`, kind: 'local-install', keyFingerprint: fp },
|
|
84
|
+
trustBasis: 'self-issued-local-key',
|
|
85
|
+
independentlyCertified: false,
|
|
86
|
+
statement: TRUST_BASES['self-issued-local-key'].statement,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Group/world access to key material is a defect, reported by path. */
|
|
91
|
+
export function keyPermissionIssues(dir = keyDir()) {
|
|
92
|
+
const p = keyPaths(dir);
|
|
93
|
+
const issues = [];
|
|
94
|
+
try {
|
|
95
|
+
const d = fs.statSync(dir).mode & 0o077;
|
|
96
|
+
if (d) issues.push(`${dir} is accessible to group/other (mode ${(fs.statSync(dir).mode & 0o777).toString(8)})`);
|
|
97
|
+
} catch { /* absent: nothing to report */ }
|
|
98
|
+
try {
|
|
99
|
+
const m = fs.statSync(p.privateKey).mode & 0o777;
|
|
100
|
+
if (m & 0o077) issues.push(`${p.privateKey} is accessible to group/other (mode ${m.toString(8)})`);
|
|
101
|
+
} catch { /* absent */ }
|
|
102
|
+
return issues;
|
|
103
|
+
}
|
|
104
|
+
|
|
50
105
|
/** Where the Ed25519 signing key lives. Mirrors integrity.js's key handling. */
|
|
51
106
|
function keyDir() {
|
|
52
107
|
const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
@@ -142,6 +197,9 @@ export function canonicalBytes(bundle) {
|
|
|
142
197
|
engine: bundle.engine,
|
|
143
198
|
proves: bundle.proves,
|
|
144
199
|
doesNotProve: bundle.doesNotProve,
|
|
200
|
+
// Only when present, so the canonical bytes (and signatures) of every
|
|
201
|
+
// bundle signed before this field existed are unchanged.
|
|
202
|
+
...(bundle.issuance !== undefined ? { issuance: bundle.issuance } : {}),
|
|
145
203
|
};
|
|
146
204
|
return Buffer.from(canonicalJson(signed), 'utf8');
|
|
147
205
|
}
|
|
@@ -226,11 +284,17 @@ export function buildEvidenceBundle(finding, { engineVersion, rulesetVersion, bu
|
|
|
226
284
|
};
|
|
227
285
|
}
|
|
228
286
|
|
|
229
|
-
/**
|
|
287
|
+
/**
|
|
288
|
+
* Sign a bundle. Returns a new object; the input is not mutated. The issuer and
|
|
289
|
+
* trust basis are derived from the signing key and signed with the bundle; a
|
|
290
|
+
* caller cannot supply a stronger basis than the one this build can verify.
|
|
291
|
+
*/
|
|
230
292
|
export function signEvidenceBundle(bundle, privateKeyPem) {
|
|
231
|
-
const
|
|
293
|
+
const publicKeyPem = crypto.createPublicKey(privateKeyPem).export({ type: 'spki', format: 'pem' });
|
|
294
|
+
const withIssuance = { ...bundle, issuance: buildLocalIssuance(publicKeyPem) };
|
|
295
|
+
const sig = crypto.sign(null, canonicalBytes(withIssuance), privateKeyPem);
|
|
232
296
|
return {
|
|
233
|
-
...
|
|
297
|
+
...withIssuance,
|
|
234
298
|
signature: {
|
|
235
299
|
algorithm: 'ed25519',
|
|
236
300
|
canonicalisation: BUNDLE_SCHEMA,
|
|
@@ -256,7 +320,7 @@ export function signEvidenceBundle(bundle, privateKeyPem) {
|
|
|
256
320
|
// what buildEvidenceBundle+signEvidenceBundle actually produce, or a
|
|
257
321
|
// legitimate bundle would start failing verification.
|
|
258
322
|
const BUNDLE_TOP_LEVEL_KEYS = new Set([
|
|
259
|
-
'schema', 'finding', 'evidence', 'engine', 'proves', 'doesNotProve', 'signature',
|
|
323
|
+
'schema', 'finding', 'evidence', 'engine', 'proves', 'doesNotProve', 'issuance', 'signature',
|
|
260
324
|
]);
|
|
261
325
|
|
|
262
326
|
export function verifyEvidenceBundle(bundle, publicKeyPem) {
|
|
@@ -276,7 +340,29 @@ export function verifyEvidenceBundle(bundle, publicKeyPem) {
|
|
|
276
340
|
} catch (e) {
|
|
277
341
|
return { ok: false, reason: `verification error: ${e.message}` };
|
|
278
342
|
}
|
|
279
|
-
return ok
|
|
280
|
-
|
|
281
|
-
|
|
343
|
+
if (!ok) return { ok: false, reason: 'signature does not match the bundle contents — it was modified after signing' };
|
|
344
|
+
|
|
345
|
+
// Trust basis. Checked AFTER the signature so the issuance block is already
|
|
346
|
+
// known to be unmodified.
|
|
347
|
+
if (bundle.issuance === undefined) {
|
|
348
|
+
return {
|
|
349
|
+
ok: true, reason: null, trustBasis: 'none-declared', independentlyCertified: false, issuer: null,
|
|
350
|
+
trustNote: 'This bundle predates issuer declarations: no trust basis is declared. Treat it as unattested provenance, not as certification.',
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const iss = bundle.issuance;
|
|
354
|
+
const basis = iss && typeof iss === 'object' ? TRUST_BASES[iss.trustBasis] : undefined;
|
|
355
|
+
if (!basis) return { ok: false, reason: `unsupported trust basis: ${JSON.stringify(iss?.trustBasis ?? null)}` };
|
|
356
|
+
if (iss.independentlyCertified !== false) {
|
|
357
|
+
return { ok: false, reason: 'a self-issued bundle may not claim independent certification' };
|
|
358
|
+
}
|
|
359
|
+
let fp;
|
|
360
|
+
try { fp = keyFingerprint(publicKeyPem); } catch (e) { return { ok: false, reason: `verification error: ${e.message}` }; }
|
|
361
|
+
if (iss.issuer?.keyFingerprint !== fp) {
|
|
362
|
+
return { ok: false, reason: 'the declared issuer key does not match the key that verified the signature' };
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
ok: true, reason: null, trustBasis: iss.trustBasis, independentlyCertified: false,
|
|
366
|
+
issuer: iss.issuer, trustNote: basis.statement,
|
|
367
|
+
};
|
|
282
368
|
}
|
|
@@ -8,6 +8,7 @@ import os from 'node:os';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { runConfined, sandboxAvailable, detectBackend } from '../sandbox/index.js';
|
|
10
10
|
import { attachProofTier, proofTierOf } from './proof-tier.js';
|
|
11
|
+
import { emitVerification } from './verification/emit.js';
|
|
11
12
|
|
|
12
13
|
const PROOF_MARKER = 'PROVEN';
|
|
13
14
|
|
|
@@ -75,7 +76,16 @@ export const DEFAULT_PROOF_TIMEOUT_MS =
|
|
|
75
76
|
? Number(process.env.AGENTIC_SECURITY_PROOF_TIMEOUT_MS)
|
|
76
77
|
: 45000;
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
// X-201: the proof result also carries the one verification record, ADDITIVELY (`proofTier` and `proofEvidence` are
|
|
80
|
+
// unchanged). A PoC run under this older sandbox path is not a trusted runner, so the record says `inconclusive` for a
|
|
81
|
+
// run that demonstrated the effect and for one that did not; `confirmed` takes a trusted oracle adapter.
|
|
82
|
+
export async function proveFinding(finding, opts = {}) {
|
|
83
|
+
const proved = await _proveFinding(finding, opts);
|
|
84
|
+
const emitted = emitVerification('execution-proof', proved, { finding: proved, commit: opts.commit ?? null });
|
|
85
|
+
return emitted.ok ? { ...proved, verificationRecord: emitted.record } : proved;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function _proveFinding(finding, { timeoutMs = DEFAULT_PROOF_TIMEOUT_MS, force, files } = {}) {
|
|
79
89
|
const poc = finding?.poc;
|
|
80
90
|
if (!poc?.code) {
|
|
81
91
|
return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: 'no proof-of-concept attached' }));
|
|
@@ -23,6 +23,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
23
23
|
import * as fs from 'node:fs';
|
|
24
24
|
import * as path from 'node:path';
|
|
25
25
|
import { verifyFix } from './fix-verify.js';
|
|
26
|
+
import { emitVerification } from './verification/emit.js';
|
|
26
27
|
|
|
27
28
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
28
29
|
|
|
@@ -131,20 +132,32 @@ export async function verifyFixWithTests({
|
|
|
131
132
|
runTests = true,
|
|
132
133
|
testRunnerOverride,
|
|
133
134
|
testTimeoutMs,
|
|
135
|
+
patchNegative,
|
|
136
|
+
assuranceConfig,
|
|
134
137
|
} = {}) {
|
|
135
|
-
const scanLint = await verifyFix({ scanRoot, originalFindingStableId, files, depFileContents });
|
|
138
|
+
const scanLint = await verifyFix({ scanRoot, originalFindingStableId, files, depFileContents, patchNegative, assuranceConfig });
|
|
136
139
|
const legs = {
|
|
137
140
|
scan: { ok: scanLint.rescan?.ok ?? scanLint.ok, detail: scanLint.rescan ?? scanLint },
|
|
138
141
|
lint: { ok: scanLint.lint?.ok ?? true, detail: scanLint.lint ?? null },
|
|
139
142
|
tests: { ok: true, detail: null, skipped: true, reason: 'not-run' },
|
|
140
143
|
};
|
|
141
|
-
|
|
142
|
-
|
|
144
|
+
// X-201: the closed loop's own record (additive). It runs the detector, linter and project tests, never an exploit
|
|
145
|
+
// oracle, so it can say `not-run` or `error` and nothing stronger.
|
|
146
|
+
const withRecord = (res) => {
|
|
147
|
+
const e = emitVerification('fix-verify-loop', res, { originalFindingStableId, files, scanRoot });
|
|
148
|
+
return { ...res, verificationRecord: e.ok ? e.record : null };
|
|
149
|
+
};
|
|
150
|
+
// X-204: the patch-negative leg is present only when it was requested and its feature is on. A patch the oracle did not verify
|
|
151
|
+
// is never `verified-clean`, whatever the other legs say.
|
|
152
|
+
const pn = scanLint.patchNegative && scanLint.patchNegative.status !== 'disabled' ? scanLint.patchNegative : null;
|
|
153
|
+
if (pn) legs.patchNegative = { ok: pn.verifiedFix === true, detail: { status: pn.status, incompleteStep: pn.incompleteStep, failureCode: pn.failureCode, reason: pn.reason } };
|
|
154
|
+
if (!legs.scan.ok || !legs.lint.ok || (pn && !legs.patchNegative.ok)) {
|
|
155
|
+
return withRecord({
|
|
143
156
|
ok: false,
|
|
144
157
|
verdict: 'verification-failed',
|
|
145
158
|
legs,
|
|
146
159
|
summary: _summarize(legs, 'verification-failed'),
|
|
147
|
-
};
|
|
160
|
+
});
|
|
148
161
|
}
|
|
149
162
|
if (runTests) {
|
|
150
163
|
const tests = runProjectTests(scanRoot, { runnerOverride: testRunnerOverride, timeoutMs: testTimeoutMs });
|
|
@@ -153,8 +166,8 @@ export async function verifyFixWithTests({
|
|
|
153
166
|
const allOk = legs.scan.ok && legs.lint.ok && legs.tests.ok;
|
|
154
167
|
const verdict = !allOk
|
|
155
168
|
? 'verification-failed'
|
|
156
|
-
: (legs.tests.skipped ? 'untested-but-passes' : 'verified-clean');
|
|
157
|
-
return { ok: allOk, verdict, legs, summary: _summarize(legs, verdict) };
|
|
169
|
+
: pn ? 'verified-fix' : (legs.tests.skipped ? 'untested-but-passes' : 'verified-clean');
|
|
170
|
+
return withRecord({ ok: allOk, verdict, legs, summary: _summarize(legs, verdict) });
|
|
158
171
|
}
|
|
159
172
|
|
|
160
173
|
function _summarize(legs, verdict) {
|
|
@@ -162,5 +175,6 @@ function _summarize(legs, verdict) {
|
|
|
162
175
|
bits.push(`scan: ${legs.scan.ok ? 'pass' : 'fail'}`);
|
|
163
176
|
bits.push(`lint: ${legs.lint.skipped ? 'skip' : legs.lint.ok ? 'pass' : 'fail'}`);
|
|
164
177
|
bits.push(`tests: ${legs.tests.skipped ? 'skip' : legs.tests.ok ? 'pass' : 'fail'}`);
|
|
178
|
+
if (legs.patchNegative) bits.push(`patch-negative: ${legs.patchNegative.ok ? 'pass' : `fail (${legs.patchNegative.detail.incompleteStep})`}`);
|
|
165
179
|
return `${verdict} (${bits.join(' · ')})`;
|
|
166
180
|
}
|
|
@@ -22,6 +22,7 @@ import { runFullScan } from '../engine.js';
|
|
|
22
22
|
import { gateFixOutput } from './fix-honesty-gate.js';
|
|
23
23
|
import { runProjectTests } from './test-runner.js';
|
|
24
24
|
import { recordFixAttempt } from './fix-metrics.js';
|
|
25
|
+
import { emitVerification, headCommit } from './verification/emit.js';
|
|
25
26
|
|
|
26
27
|
const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
27
28
|
|
|
@@ -187,6 +188,8 @@ export async function verifyFix({
|
|
|
187
188
|
testTimeoutMs,
|
|
188
189
|
recordMetrics = true,
|
|
189
190
|
poc,
|
|
191
|
+
patchNegative,
|
|
192
|
+
assuranceConfig,
|
|
190
193
|
} = {}) {
|
|
191
194
|
// R5 (reporting half) — time each stage as it runs. Measured here rather
|
|
192
195
|
// than inside each stage because only this function knows the boundaries of
|
|
@@ -269,7 +272,26 @@ export async function verifyFix({
|
|
|
269
272
|
}
|
|
270
273
|
_lap('honesty');
|
|
271
274
|
|
|
272
|
-
|
|
275
|
+
// X-204: patch-negative verification, only when the caller asked for it AND the `patch-negative-verification` feature is
|
|
276
|
+
// on. `patchNegative` is the requester's exploit scenario and functional cases ({ original, functional, commit?, ... }); the
|
|
277
|
+
// candidate `files` are the proposed diff. With the flag off, or no request, nothing here runs and every field below is
|
|
278
|
+
// exactly what it was before.
|
|
279
|
+
let pn = null;
|
|
280
|
+
if (patchNegative && typeof patchNegative === 'object') {
|
|
281
|
+
try {
|
|
282
|
+
const { verifyPatchNegative } = await import('./verification/patch-negative.js');
|
|
283
|
+
pn = await verifyPatchNegative({
|
|
284
|
+
hypothesisId: originalFindingStableId, ...patchNegative, patch: { files },
|
|
285
|
+
commit: patchNegative.commit ?? headCommit(scanRoot),
|
|
286
|
+
}, { config: assuranceConfig });
|
|
287
|
+
} catch (e) {
|
|
288
|
+
pn = { status: 'error', verifiedFix: false, incompleteStep: 'patch-negative', failureCode: 'harness-error', reason: `patch-negative verification failed to run: ${String(e?.message || e).slice(0, 200)}`, summary: '' };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const pnActive = pn !== null && pn.status !== 'disabled';
|
|
292
|
+
_lap('patchNegative');
|
|
293
|
+
|
|
294
|
+
const ok = rescan.ok && (lint.ok || lint.skipped) && testsOk && pocOk && (honesty ? honesty.ok : true) && (pnActive ? pn.verifiedFix === true : true);
|
|
273
295
|
|
|
274
296
|
// FR-305 (assurance-hardening PRD): `ok` alone conflates "every leg
|
|
275
297
|
// genuinely ran and passed" with "passed, but a required leg was skipped
|
|
@@ -311,6 +333,7 @@ export async function verifyFix({
|
|
|
311
333
|
: pocLeg.status === 'no-longer-proven' ? 'poc: PASS (ran against the patch and no longer demonstrates the vulnerability)'
|
|
312
334
|
: `poc: inconclusive — not counted either way (${pocLeg.reason || 'no detail reported'})`,
|
|
313
335
|
// FR-305: never let a degraded pass read the same as a full one.
|
|
336
|
+
pnActive ? `patch-negative: ${pn.verifiedFix ? 'PASS (verified-fix)' : `FAIL - not verified-fix, incomplete step '${pn.incompleteStep}' (${pn.failureCode}): ${pn.reason}`}` : null,
|
|
314
337
|
ok && !verifiedFull ? `NOTE: PASSED, but NOT fully verified — ${degradedLegs.join('; ')}` : null,
|
|
315
338
|
].filter(Boolean).join('\n');
|
|
316
339
|
// Persist the attempt so the distribution can be reported from real runs.
|
|
@@ -337,5 +360,17 @@ export async function verifyFix({
|
|
|
337
360
|
});
|
|
338
361
|
}
|
|
339
362
|
|
|
340
|
-
|
|
363
|
+
// X-201: the one verification record, ADDITIVE. `ok` and every field above stay exactly as they were; the record
|
|
364
|
+
// says what was actually established (a static re-scan is not an exploit oracle, so without a PoC leg it is
|
|
365
|
+
// `not-run`, and nothing here can reach `confirmed`/`refuted`: that takes a trusted oracle adapter).
|
|
366
|
+
const emitted = emitVerification('fix-verify', { rescan, lint, tests, poc: pocLeg }, {
|
|
367
|
+
originalFindingStableId, files, scanRoot, finding: poc?.finding,
|
|
368
|
+
});
|
|
369
|
+
const verificationRecord = emitted.ok ? emitted.record : null;
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
ok, verifiedFull, degradedLegs, rescan, lint, tests, testedPrePatch: _testedPrePatch, honesty, poc: pocLeg, durations, summary, verificationRecord,
|
|
373
|
+
// additive, present only when patch-negative verification was requested (X-204)
|
|
374
|
+
...(pn ? { patchNegative: pn, ...(pnActive ? { fixStatus: pn.verifiedFix ? 'verified-fix' : 'not-verified-fix' } : {}) } : {}),
|
|
375
|
+
};
|
|
341
376
|
}
|