@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/bin/agentic-security.js +11 -2
  3. package/dist/4113.index.js +59 -8
  4. package/dist/5613.index.js +500 -0
  5. package/dist/6239.index.js +469 -0
  6. package/dist/7838.index.js +38 -2
  7. package/dist/8798.index.js +1 -1
  8. package/dist/9207.index.js +5 -0
  9. package/dist/9560.index.js +36 -22
  10. package/dist/{1905.index.js → 9999.index.js} +265 -8
  11. package/dist/agentic-security.mjs +2 -2
  12. package/dist/agentic-security.mjs.sha256 +1 -1
  13. package/package.json +9 -4
  14. package/src/discovery/CLAUDE.md +9 -0
  15. package/src/discovery/index.js +28 -0
  16. package/src/discovery/memory.js +7 -5
  17. package/src/fix/apply-fix-service.js +33 -3
  18. package/src/mcp/CLAUDE.md +9 -1
  19. package/src/mcp/tools.js +17 -4
  20. package/src/posture/CLAUDE.md +174 -0
  21. package/src/posture/assurance/baseline-inventory.js +218 -0
  22. package/src/posture/assurance/baseline.js +322 -0
  23. package/src/posture/assurance/bounded-io.js +145 -0
  24. package/src/posture/assurance/config.js +299 -0
  25. package/src/posture/assurance/contracts.js +315 -0
  26. package/src/posture/assurance/identity.js +62 -0
  27. package/src/posture/assurance/migrations.js +259 -0
  28. package/src/posture/assurance/schema-kit.js +111 -0
  29. package/src/posture/assurance/verification-record.js +258 -0
  30. package/src/posture/attestation.js +5 -0
  31. package/src/posture/autopilot.js +28 -1
  32. package/src/posture/evidence-bundle.js +93 -7
  33. package/src/posture/execution-proof.js +11 -1
  34. package/src/posture/fix-verify-loop.js +20 -6
  35. package/src/posture/fix-verify.js +37 -2
  36. package/src/posture/oracles/adapters.js +360 -0
  37. package/src/posture/oracles/conformance.js +275 -0
  38. package/src/posture/oracles/oracle.js +433 -0
  39. package/src/posture/oracles/registry.js +76 -0
  40. package/src/posture/oracles/scenario-classes.js +223 -0
  41. package/src/posture/replay/replay.js +249 -0
  42. package/src/posture/verification/advisory-state.js +71 -0
  43. package/src/posture/verification/emit.js +257 -0
  44. package/src/posture/verification/hypothesis-promotion.js +211 -0
  45. package/src/posture/verification/patch-negative.js +214 -0
  46. package/src/posture/verification/patch-promotion.js +105 -0
  47. package/src/posture/verification/projection.js +183 -0
  48. package/src/posture/verification/repair-records.js +124 -0
  49. package/src/posture/verifier.js +16 -0
  50. package/src/report/index.js +49 -0
  51. package/src/sandbox/CLAUDE.md +41 -0
  52. package/src/sandbox/backend-namespace.js +7 -0
  53. package/src/sandbox/backend-userspace.js +68 -29
  54. package/src/sandbox/control-probes.js +215 -0
  55. package/src/sandbox/supervise.js +201 -0
  56. package/src/sandbox/trust-boundary.js +113 -0
  57. 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;
@@ -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
- /** Sign a bundle. Returns a new object; the input is not mutated. */
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 sig = crypto.sign(null, canonicalBytes(bundle), privateKeyPem);
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
- ...bundle,
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
- ? { ok: true, reason: null }
281
- : { ok: false, reason: 'signature does not match the bundle contents — it was modified after signing' };
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
- export async function proveFinding(finding, { timeoutMs = DEFAULT_PROOF_TIMEOUT_MS, force, files } = {}) {
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
- if (!legs.scan.ok || !legs.lint.ok) {
142
- return {
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
- const ok = rescan.ok && (lint.ok || lint.skipped) && testsOk && pocOk && (honesty ? honesty.ok : true);
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
- return { ok, verifiedFull, degradedLegs, rescan, lint, tests, testedPrePatch: _testedPrePatch, honesty, poc: pocLeg, durations, summary };
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
  }