@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/bin/agentic-security.js +11 -2
  3. package/dist/1919.index.js +1 -1
  4. package/dist/2432.index.js +10 -1
  5. package/dist/4113.index.js +60 -9
  6. package/dist/5613.index.js +500 -0
  7. package/dist/6239.index.js +469 -0
  8. package/dist/7033.index.js +4 -1
  9. package/dist/7838.index.js +39 -3
  10. package/dist/8714.index.js +1 -1
  11. package/dist/8798.index.js +2 -2
  12. package/dist/9207.index.js +5 -0
  13. package/dist/9560.index.js +205 -62
  14. package/dist/{1905.index.js → 9999.index.js} +265 -8
  15. package/dist/agentic-security.mjs +12 -12
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +14 -9
  18. package/src/dataflow/engine.js +20 -4
  19. package/src/discovery/CLAUDE.md +9 -0
  20. package/src/discovery/index.js +28 -0
  21. package/src/discovery/memory.js +7 -5
  22. package/src/engine.js +29 -8
  23. package/src/fix/apply-fix-service.js +33 -3
  24. package/src/language/CLAUDE.md +6 -1
  25. package/src/language/aibom.js +4 -1
  26. package/src/language/fix-lifecycle.js +9 -0
  27. package/src/language/haskell-ir.js +47 -7
  28. package/src/language/haskell-sca.js +87 -7
  29. package/src/language/haskell-supply.js +175 -6
  30. package/src/language/haskell-web.js +200 -18
  31. package/src/language/nix-fix.js +1 -1
  32. package/src/language/resolved-pass.js +4 -1
  33. package/src/language/witness.js +1 -1
  34. package/src/mcp/CLAUDE.md +43 -4
  35. package/src/mcp/tools.js +185 -43
  36. package/src/posture/CLAUDE.md +174 -0
  37. package/src/posture/assurance/baseline-inventory.js +218 -0
  38. package/src/posture/assurance/baseline.js +322 -0
  39. package/src/posture/assurance/bounded-io.js +145 -0
  40. package/src/posture/assurance/config.js +299 -0
  41. package/src/posture/assurance/contracts.js +315 -0
  42. package/src/posture/assurance/identity.js +62 -0
  43. package/src/posture/assurance/migrations.js +259 -0
  44. package/src/posture/assurance/schema-kit.js +111 -0
  45. package/src/posture/assurance/verification-record.js +258 -0
  46. package/src/posture/attestation.js +5 -0
  47. package/src/posture/autopilot.js +28 -1
  48. package/src/posture/blast-radius.js +15 -1
  49. package/src/posture/evidence-bundle.js +93 -7
  50. package/src/posture/execution-proof.js +11 -1
  51. package/src/posture/fix-verify-loop.js +20 -6
  52. package/src/posture/fix-verify.js +37 -2
  53. package/src/posture/oracles/adapters.js +360 -0
  54. package/src/posture/oracles/conformance.js +275 -0
  55. package/src/posture/oracles/oracle.js +433 -0
  56. package/src/posture/oracles/registry.js +76 -0
  57. package/src/posture/oracles/scenario-classes.js +223 -0
  58. package/src/posture/remediation-ledger.js +10 -1
  59. package/src/posture/replay/replay.js +249 -0
  60. package/src/posture/verification/advisory-state.js +71 -0
  61. package/src/posture/verification/emit.js +257 -0
  62. package/src/posture/verification/hypothesis-promotion.js +211 -0
  63. package/src/posture/verification/patch-negative.js +214 -0
  64. package/src/posture/verification/patch-promotion.js +105 -0
  65. package/src/posture/verification/projection.js +183 -0
  66. package/src/posture/verification/repair-records.js +124 -0
  67. package/src/posture/verifier.js +16 -0
  68. package/src/report/index.js +49 -0
  69. package/src/sandbox/CLAUDE.md +41 -0
  70. package/src/sandbox/backend-namespace.js +7 -0
  71. package/src/sandbox/backend-userspace.js +68 -29
  72. package/src/sandbox/control-probes.js +215 -0
  73. package/src/sandbox/supervise.js +201 -0
  74. package/src/sandbox/trust-boundary.js +113 -0
  75. package/src/sandbox/trust-domains.js +171 -0
  76. package/src/sast/pipeline.js +51 -4
package/src/mcp/tools.js CHANGED
@@ -26,6 +26,8 @@ import { withStateWritesDisabled } from '../posture/state-dir.js';
26
26
  import { analyzeTranscript, formatCacheReport, renderCacheStatusLine } from '../posture/cache-economics.js';
27
27
  import { redactString, redactFinding } from './redact.js';
28
28
  import { _remediationOf, normalizeFindings } from '../report/index.js';
29
+ // X-206: the one projection of the verification record, shared with the JSON report, the text report and the autopilot response.
30
+ import { verificationFields } from '../posture/verification/projection.js';
29
31
  // Git-origin provenance (Finding Provenance M0/M1). Distinct from
30
32
  // `finding.provenance` (AI-authorship) and from an SCA entry's `provenance`
31
33
  // (Sigstore/SLSA attestation) — see report/index.js's import comment.
@@ -349,7 +351,7 @@ function _maybeOffload(sessionRoot, toolName, items) {
349
351
  //
350
352
  // Exported under the `_internals` convention this codebase already uses
351
353
  // (see posture/poc-inprocess.js). Not part of the MCP tool surface.
352
- export const _internals = { _confine, isReservedWrite: _isReservedWritePath };
354
+ export const _internals = { _confine, isReservedWrite: _isReservedWritePath, checkMultiFileTargets: _checkMultiFileTargets, multiFilePlanDigest: _multiFilePlanDigest };
353
355
 
354
356
  export const scan_diff = {
355
357
  name: 'scan_diff',
@@ -599,14 +601,161 @@ export const explain_finding = {
599
601
  findingProvenance: f.findingProvenance ? redactFindingProvenance(f.findingProvenance, {
600
602
  pseudonymize: process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS === '1',
601
603
  }) : null,
604
+ // X-206: the same verification projection the JSON and text reports carry; absent when the finding has no record.
605
+ ...(f.verificationRecord && typeof f.verificationRecord === 'object'
606
+ ? { verificationRecord: f.verificationRecord, ...verificationFields(f.verificationRecord, { replay: f.verificationReplay }) }
607
+ : {}),
602
608
  };
603
609
  },
604
610
  };
605
611
 
612
+ // FR-307/FR-1002/FR-1003: the high-impact-change approval gate (approval evidence, approver identity, separation of duties),
613
+ // shared by the caller-patch branch and the multi-file plan branch so the two cannot drift. Returns a refusal result, or null
614
+ // when the change is not high-impact or is properly approved.
615
+ function _highImpactApprovalRefusal(ctx, materialClassification, fixMeta) {
616
+ if (!materialClassification.highImpactCategories.length) return null;
617
+ const cats = materialClassification.highImpactCategories.join(', ');
618
+ const approval = fixMeta && typeof fixMeta === 'object' ? fixMeta.approval : null;
619
+ const hasApprovalEvidence = !!(approval && typeof approval === 'object' &&
620
+ typeof approval.approvedBy === 'string' && approval.approvedBy.trim().length > 0 &&
621
+ typeof approval.reason === 'string' && approval.reason.trim().length > 0);
622
+ if (!hasApprovalEvidence) {
623
+ return {
624
+ _meta: META, applied: false,
625
+ reason: `high-impact change (${cats}) requires approval evidence — pass fixMeta.approval: {approvedBy, reason} — before it can be applied`,
626
+ materialClassification,
627
+ };
628
+ }
629
+ const approverRegistry = loadApproverRegistry(ctx.sessionRoot);
630
+ const requiredRoles = requiredRolesFor(approverRegistry, materialClassification.highImpactCategories);
631
+ const identityCheck = verifyApprover(approverRegistry, approval.approvedBy, requiredRoles);
632
+ if (!identityCheck.verified) {
633
+ return { _meta: META, applied: false, reason: `high-impact change (${cats}) approval rejected: ${identityCheck.reason}`, materialClassification };
634
+ }
635
+ // FR-1003: separation-of-duties, same no-op-unless-configured gate as apply-fix-service.js's own copy; see approver-registry.js.
636
+ const sodCheck = checkSeparationOfDuties(approverRegistry, fixMeta?.author, approval.approvedBy);
637
+ if (!sodCheck.ok) {
638
+ return { _meta: META, applied: false, reason: `high-impact change (${cats}) approval rejected: ${sodCheck.reason}`, materialClassification };
639
+ }
640
+ return null;
641
+ }
642
+
643
+ // ─── Multi-file plans (apply_fix with plan_digest) ───────────────────────────
644
+ // A NixOS option fix can edit several files at once (every definition at the winning priority). apply_fix never accepts file
645
+ // contents for this: it RECOMPUTES the plan from the signed finding and the live tree, and writes only if that plan's digest is
646
+ // the one synthesize_fix returned. The digest binds the finding, every file path, the exact pre-image and the exact post-image
647
+ // of each edit, so a changed tree, a changed finding, or an altered digest all refuse. The write itself is the language
648
+ // lifecycle's all-or-nothing writeManyWithBackup (one backup per file, one history group, honest rollback).
649
+ const MULTI_FILE_MAX = 8;
650
+ const _sha256 = (t) => crypto.createHash('sha256').update(String(t), 'utf8').digest('hex');
651
+ function _multiFilePlanDigest(finding, edits) {
652
+ const canon = edits.map((e) => ({ file: String(e.file), before: _sha256(e.before), after: _sha256(e.after) })).sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
653
+ return _sha256(JSON.stringify({ v: 'mf-plan/1', id: finding.id || null, stableId: finding.stableId || null, edits: canon }));
654
+ }
655
+ // Every file of a multi-file plan gets the SAME checks a single-file apply_fix target gets: root-relative lexical form,
656
+ // confinement (no symlink leaf, no symlinked parent, no escape), reserved-write list, and existence. Returns {ok, reason, abs}.
657
+ function _checkMultiFileTargets(sessionRoot, edits) {
658
+ if (!Array.isArray(edits) || edits.length < 2) return { ok: false, reason: 'a multi-file plan needs at least two edits' };
659
+ if (edits.length > MULTI_FILE_MAX) return { ok: false, reason: `a multi-file plan may edit at most ${MULTI_FILE_MAX} files` };
660
+ const rootReal = fs.realpathSync(path.resolve(sessionRoot));
661
+ const seen = new Set();
662
+ const abs = {};
663
+ for (const e of edits) {
664
+ const rel = e && e.file;
665
+ if (typeof rel !== 'string' || !rel || rel.includes('\0') || rel.includes('\\') || path.isAbsolute(rel) || rel.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')) {
666
+ return { ok: false, reason: `path-escape refused: plan file "${path.isAbsolute(String(rel)) ? '(absolute path)' : String(rel).slice(0, 120)}" is not a clean root-relative path` };
667
+ }
668
+ if (seen.has(rel)) return { ok: false, reason: `the same file appears twice in the plan: ${rel}` };
669
+ seen.add(rel);
670
+ let real;
671
+ try { real = _confine(sessionRoot, rel, 'plan file'); }
672
+ catch (err) { return { ok: false, reason: `path-escape refused: ${String(err.message).split(rootReal).join('<root>')}` }; }
673
+ if (path.relative(rootReal, real).split(path.sep).join('/') !== rel) return { ok: false, reason: `path-escape refused: plan file "${rel}" resolves through a symbolic link` };
674
+ if (_isReservedWritePath(sessionRoot, real)) return { ok: false, reason: `reserved path refused: ${rel}` };
675
+ if (!fs.existsSync(real)) return { ok: false, reason: `plan file not found: ${rel}` };
676
+ abs[rel] = real;
677
+ }
678
+ return { ok: true, abs };
679
+ }
680
+ const _scrubRoot = (sessionRoot, text) => {
681
+ let out = String(text);
682
+ const roots = new Set([path.resolve(sessionRoot)]);
683
+ try { roots.add(fs.realpathSync(path.resolve(sessionRoot))); } catch { /* root vanished: the plain path is still scrubbed */ }
684
+ for (const r of roots) out = out.split(r).join('<root>');
685
+ return redactString(out);
686
+ };
687
+ const _gateSummary = (g) => (g ? {
688
+ path: g.path ? g.path.ok : undefined, syntax: g.syntax ? g.syntax.ok : undefined,
689
+ rescan: g.rescan ? { ok: g.rescan.ok, originalGone: g.rescan.originalGone, newMediumOrHigher: g.rescan.newMediumOrHigher } : undefined,
690
+ effective: g.effective ? { ok: g.effective.ok, ran: g.effective.ran === true, detail: g.effective.detail } : undefined,
691
+ compile: g.compile ? { ran: g.compile.ran === true, ok: g.compile.ok } : undefined,
692
+ } : null);
693
+
694
+ async function _applyMultiFilePlan({ f, planDigest, dryRun, fixMeta, ctx, status }) {
695
+ const refuse = (reason, extra = {}) => ({ _meta: META, applied: false, multiFile: true, reason, ...extra });
696
+ if (typeof planDigest !== 'string' || !/^[0-9a-f]{64}$/.test(planDigest)) return refuse('plan_digest must be the 64-character hex digest returned by synthesize_fix');
697
+ if (!f.stableId) return refuse('finding has no stableId, so a plan cannot be verified against it');
698
+ const lc = await import('../language/context.js');
699
+ if (lc.languageOfFinding(f) !== 'nix') return refuse('multi-file plans are produced for NixOS findings only');
700
+ const nf = await import('../language/nix-fix.js');
701
+ const proj = lc.loadLanguageProject(ctx.sessionRoot);
702
+ const files = proj.files;
703
+ // The plan is derived here, from the signed finding and the live tree. Nothing the caller sends can supply file content.
704
+ const plan = nf.planNixFix(f, files);
705
+ if (!plan || plan.ok === false) return refuse(`no applicable plan: ${_scrubRoot(ctx.sessionRoot, (plan && plan.reason) || 'no deterministic fix')}`);
706
+ if (!Array.isArray(plan.edits) || plan.edits.length < 2) return refuse('this finding has a single-file plan; use apply_fix without plan_digest');
707
+ if (_multiFilePlanDigest(f, plan.edits) !== planDigest) {
708
+ return refuse('plan_digest does not match the plan computed now for this finding: the project files or the finding changed since synthesize_fix, or the digest was altered. Run synthesize_fix again and review the new plan.', { stale: true });
709
+ }
710
+ const targets = _checkMultiFileTargets(ctx.sessionRoot, plan.edits);
711
+ if (!targets.ok) return refuse(targets.reason);
712
+ const classification = classifyFixMaterialRisk(Object.fromEntries(plan.edits.map((e) => [e.file, { before: e.before, after: e.after }])));
713
+ if (!dryRun) {
714
+ const approvalRefusal = _highImpactApprovalRefusal(ctx, classification, fixMeta);
715
+ if (approvalRefusal) return { ...approvalRefusal, multiFile: true };
716
+ }
717
+ // The verification gates (syntax, rescan with no new finding, effective value changed as intended) run inside the lifecycle,
718
+ // and the pre-write hook re-checks the digest of the VERIFIED edits and the targets immediately before the first byte is written.
719
+ const preWrite = (verifiedPlan) => {
720
+ const vedits = Array.isArray(verifiedPlan.edits) ? verifiedPlan.edits : [];
721
+ if (_multiFilePlanDigest(f, vedits) !== planDigest) return { ok: false, detail: 'the verified plan differs from the previewed plan' };
722
+ const again = _checkMultiFileTargets(ctx.sessionRoot, vedits);
723
+ return again.ok ? { ok: true } : { ok: false, detail: again.reason };
724
+ };
725
+ let res;
726
+ try { res = await nf.validateNixFix(f, { files, apply: !dryRun, root: ctx.sessionRoot, preWrite }); }
727
+ catch (e) { return refuse(`plan verification failed: ${_scrubRoot(ctx.sessionRoot, e.message)}`); }
728
+ const gates = _gateSummary(res.gates);
729
+ const touched = plan.edits.map((e) => e.file);
730
+ if (res.status === 'verified' && dryRun) {
731
+ return { _meta: META, applied: false, dryRun: true, verified: true, multiFile: true, files: touched, planDigest, gates, materialClassification: classification, diff: res.preview };
732
+ }
733
+ if (res.status === 'applied') {
734
+ let acceptance = null;
735
+ try { acceptance = fixAcceptanceRate(ctx.sessionRoot); } catch { /* best-effort */ }
736
+ const hist = Array.isArray(res.history) ? res.history : [];
737
+ const allRecorded = hist.length === touched.length && hist.every(Boolean);
738
+ return {
739
+ _meta: META, applied: true, verified: true, multiFile: true, files: touched, planDigest,
740
+ groupId: res.backup && res.backup.id, historyIds: hist.map((h) => (h ? h.id : null)),
741
+ ...(allRecorded ? {} : { warning: 'the files were written and backed up, but the fix history could not record every entry; restore with the group backup under .agentic-security/fix-backups' }),
742
+ gates, integrity: status, acceptance, materialClassification: classification,
743
+ };
744
+ }
745
+ // Blocked or failed: nothing is left changed unless rollbackIncomplete says otherwise.
746
+ const incomplete = res.rolledBack === false;
747
+ return {
748
+ _meta: META, applied: false, multiFile: true, files: touched,
749
+ reason: _scrubRoot(ctx.sessionRoot, res.reason || `plan not applied (${res.status})`),
750
+ gates,
751
+ ...(res.rolledBack !== undefined ? { rolledBack: res.rolledBack === true, rollbackIncomplete: incomplete, ...(incomplete ? { restoreManually: (res.rollbackFailed || []).map(String) } : {}) } : {}),
752
+ };
753
+ }
754
+
606
755
  // ─── apply_fix ───────────────────────────────────────────────────────────────
607
756
  export const apply_fix = {
608
757
  name: 'apply_fix',
609
- description: 'Apply a fix for a finding. Two modes: (1) the stored fix.replacement, or (2) a caller-supplied `patch` (a files map) which is RE-VERIFIED inline (rescan-clean + no new ≥medium + lint) before any write — this unblocks findings that ship only a template or description. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if a path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing. On success, `verified:true` means verification passed but `verifiedFull:true` is the honest signal that every required leg (lint when configured, tests when a runner exists) genuinely ran — a false `verifiedFull` with `verified:true` means the pass is real but degraded (see `verify.degradedLegs`), not a full verification.',
758
+ description: 'Apply a fix for a finding. Two modes: (1) the stored fix.replacement, or (2) a caller-supplied `patch` (a files map) which is RE-VERIFIED inline (rescan-clean + no new ≥medium + lint) before any write — this unblocks findings that ship only a template or description. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if a path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing. On success, `verified:true` means verification passed but `verifiedFull:true` is the honest signal that every required leg (lint when configured, tests when a runner exists) genuinely ran — a false `verifiedFull` with `verified:true` means the pass is real but degraded (see `verify.degradedLegs`), not a full verification. MULTI-FILE NixOS FIX: when synthesize_fix returns `languageFix.planDigest` (an option defined in several files, edited together), pass it back as `plan_digest`. apply_fix then RECOMPUTES the plan itself from the signed finding and the live files, refuses unless its digest matches (so a changed tree, a changed finding or an altered digest all refuse; you never send file content), applies the same confinement, reserved-path, shadow and high-impact-approval checks to EVERY file, runs the verification gates (syntax, rescan with no new finding, the option resolves to the intended value on the patched configuration) before writing, and writes all files or none with one backup per file and one history group that `undo` reverts together. A rollback that could not complete is reported as `rollbackIncomplete` with the files to restore. `plan_digest` cannot be combined with `patch`.',
610
759
  inputSchema: {
611
760
  type: 'object',
612
761
  additionalProperties: false,
@@ -614,6 +763,7 @@ export const apply_fix = {
614
763
  finding_id: { type: 'string', minLength: 1, maxLength: 256 },
615
764
  confirm: { type: 'boolean' },
616
765
  dry_run: { type: 'boolean' },
766
+ plan_digest: { type: 'string', minLength: 64, maxLength: 64 },
617
767
  patch: {
618
768
  type: 'object',
619
769
  additionalProperties: { type: 'string', maxLength: 500_000 },
@@ -673,7 +823,7 @@ export const apply_fix = {
673
823
  },
674
824
  required: ['finding_id', 'confirm'],
675
825
  },
676
- async handler({ finding_id, confirm, dry_run = false, patch = null, fixMeta = null }, ctx) {
826
+ async handler({ finding_id, confirm, dry_run = false, patch = null, fixMeta = null, plan_digest = null }, ctx) {
677
827
  if (confirm !== true) {
678
828
  return { _meta: META, applied: false, reason: 'apply_fix requires confirm: true.' };
679
829
  }
@@ -687,6 +837,15 @@ export const apply_fix = {
687
837
  return { _meta: META, applied: false, reason: 'shadow findings cannot be auto-applied' };
688
838
  }
689
839
 
840
+ // Multi-file plan (a NixOS option defined in several files). The plan is recomputed here and bound by plan_digest; the
841
+ // caller can never supply file content on this path, and it cannot be combined with `patch`.
842
+ if (plan_digest !== null && plan_digest !== undefined) {
843
+ if (patch && typeof patch === 'object' && Object.keys(patch).length) {
844
+ return { _meta: META, applied: false, reason: 'plan_digest and patch cannot be combined: a multi-file plan is computed by the scanner, not supplied by the caller' };
845
+ }
846
+ return _applyMultiFilePlan({ f, planDigest: plan_digest, dryRun: dry_run === true, fixMeta, ctx, status });
847
+ }
848
+
690
849
  // #3 — verifier-approved patch path. When the caller supplies `patch` (a
691
850
  // files map, same shape as verify_fix), apply_fix re-runs the verifier
692
851
  // INLINE and writes only if it passes: the original finding's stableId is
@@ -722,7 +881,7 @@ export const apply_fix = {
722
881
  // default. Normalized to the scan+lint verdict shape used below.
723
882
  const { verifyFixWithTests } = await import('../posture/fix-verify-loop.js');
724
883
  const t = await verifyFixWithTests({ scanRoot: ctx.sessionRoot, originalFindingStableId: f.stableId, files: _files });
725
- verdict = { ok: t.ok, summary: t.summary, rescan: t.legs?.scan?.detail, lint: t.legs?.lint?.detail, tests: t.legs?.tests, testVerdict: t.verdict };
884
+ verdict = { ok: t.ok, summary: t.summary, rescan: t.legs?.scan?.detail, lint: t.legs?.lint?.detail, tests: t.legs?.tests, testVerdict: t.verdict, verificationRecord: t.verificationRecord ?? null };
726
885
  } else {
727
886
  const verifyFixCore = await getVerifyFixCore();
728
887
  verdict = await verifyFixCore({
@@ -740,6 +899,9 @@ export const apply_fix = {
740
899
  _meta: META, applied: false,
741
900
  reason: `patch rejected by verifier: ${verdict.summary || verdict.rescan?.reason || 'did not verify'}`,
742
901
  verify: { rescan: verdict.rescan, lint: { runner: verdict.lint?.runner, ok: verdict.lint?.ok }, honesty: verdict.honesty || null },
902
+ // X-201: the same version-1 verification record every surface emits (additive; null when none could be formed).
903
+ verificationRecord: verdict.verificationRecord ?? null,
904
+ ...verificationFields(verdict.verificationRecord),
743
905
  };
744
906
  }
745
907
  // FR-307/FR-1002/D-0024: this caller-supplied-patch branch writes via
@@ -760,41 +922,10 @@ export const apply_fix = {
760
922
  }
761
923
  const materialClassification = classifyFixMaterialRisk(filesForMaterialClassification);
762
924
  if (dry_run) {
763
- return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, materialClassification };
764
- }
765
- if (materialClassification.highImpactCategories.length) {
766
- const approval = fixMeta && typeof fixMeta === 'object' ? fixMeta.approval : null;
767
- const hasApprovalEvidence = !!(approval && typeof approval === 'object' &&
768
- typeof approval.approvedBy === 'string' && approval.approvedBy.trim().length > 0 &&
769
- typeof approval.reason === 'string' && approval.reason.trim().length > 0);
770
- if (!hasApprovalEvidence) {
771
- return {
772
- _meta: META, applied: false,
773
- reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) requires approval evidence — pass fixMeta.approval: {approvedBy, reason} — before it can be applied`,
774
- materialClassification,
775
- };
776
- }
777
- const approverRegistry = loadApproverRegistry(ctx.sessionRoot);
778
- const requiredRoles = requiredRolesFor(approverRegistry, materialClassification.highImpactCategories);
779
- const identityCheck = verifyApprover(approverRegistry, approval.approvedBy, requiredRoles);
780
- if (!identityCheck.verified) {
781
- return {
782
- _meta: META, applied: false,
783
- reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${identityCheck.reason}`,
784
- materialClassification,
785
- };
786
- }
787
- // FR-1003: separation-of-duties, same no-op-unless-configured gate
788
- // as apply-fix-service.js's own copy — see approver-registry.js.
789
- const sodCheck = checkSeparationOfDuties(approverRegistry, fixMeta?.author, approval.approvedBy);
790
- if (!sodCheck.ok) {
791
- return {
792
- _meta: META, applied: false,
793
- reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${sodCheck.reason}`,
794
- materialClassification,
795
- };
796
- }
925
+ return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord), materialClassification };
797
926
  }
927
+ const approvalRefusal = _highImpactApprovalRefusal(ctx, materialClassification, fixMeta);
928
+ if (approvalRefusal) return approvalRefusal;
798
929
  const written = [];
799
930
  try {
800
931
  for (const [rel, v] of Object.entries(confinedAbs)) {
@@ -822,7 +953,7 @@ export const apply_fix = {
822
953
  }
823
954
  let acceptance = null;
824
955
  try { acceptance = fixAcceptanceRate(ctx.sessionRoot); } catch { /* best-effort */ }
825
- return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary }, acceptance, materialClassification };
956
+ return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord) }, acceptance, materialClassification };
826
957
  }
827
958
 
828
959
  if (typeof f.fix?.replacement !== 'string') {
@@ -911,7 +1042,7 @@ export const apply_fix = {
911
1042
  // proceed with apply_fix.
912
1043
  export const verify_fix = {
913
1044
  name: 'verify_fix',
914
- description: 'Verify a proposed patch before applying. Re-scans the patched files in memory, runs the project linter, runs the project test suite, checks fix honesty (FULL/MITIGATION/WORKAROUND) when fixMeta is supplied, and re-runs the PoC when one exists. Returns { ok, rescan, lint, tests, honesty, poc, summary }. Does not write to the target project’s own files, but DOES append one record per attempt to .agentic-security/fix-metrics.jsonl for the measured fix-loop.',
1045
+ description: 'Verify a proposed patch before applying. Re-scans the patched files in memory, runs the project linter, runs the project test suite, checks fix honesty (FULL/MITIGATION/WORKAROUND) when fixMeta is supplied, and re-runs the PoC when one exists. Returns { ok, rescan, lint, tests, honesty, poc, summary, verificationRecord } (verificationRecord is the shared version-1 record: outcome is one of not-run, unsupported, inconclusive, error, refuted, confirmed, and a static re-scan alone is never reported as confirmed; verificationView is the shared projection every interface shows for it, with state, scope, evidence ids, replay prerequisites and what was and was not verified). Does not write to the target project’s own files, but DOES append one record per attempt to .agentic-security/fix-metrics.jsonl for the measured fix-loop.',
915
1046
  inputSchema: {
916
1047
  type: 'object',
917
1048
  additionalProperties: false,
@@ -1012,6 +1143,10 @@ export const verify_fix = {
1012
1143
  honesty: r.honesty,
1013
1144
  poc: r.poc ? { ...r.poc, reason: r.poc.reason ? redactString(r.poc.reason) : r.poc.reason } : r.poc,
1014
1145
  summary: r.summary,
1146
+ // X-201: the one version-1 verification record (additive). It states what was actually established, e.g.
1147
+ // `not-run` when no exploit oracle executed against the patch, never a bare pass/fail.
1148
+ verificationRecord: r.verificationRecord ?? null,
1149
+ ...verificationFields(r.verificationRecord),
1015
1150
  };
1016
1151
  } catch (e) {
1017
1152
  return { _meta: META, ok: false, reason: `verify_fix failed: ${e.message}` };
@@ -1025,7 +1160,7 @@ export const verify_fix = {
1025
1160
  // sequence with the returned blob.
1026
1161
  export const synthesize_fix = {
1027
1162
  name: 'synthesize_fix',
1028
- description: 'Return the stored fix replacement for a finding (replacement text + remediation + plan if the patch is too large). Read-only; never writes to disk. Use verify_fix → apply_fix to deploy.',
1163
+ description: 'Return the stored fix replacement for a finding (replacement text + remediation + plan if the patch is too large). Read-only; never writes to disk. Use verify_fix → apply_fix to deploy. For a NixOS option defined in several files, `languageFix.multiFile` lists every file, `languageFix.edits` gives each file\'s before/after SHA-256 (no file content) and `languageFix.planDigest` is the value to pass to apply_fix as `plan_digest`; the preview is verified but nothing is written.',
1029
1164
  inputSchema: {
1030
1165
  type: 'object',
1031
1166
  additionalProperties: false,
@@ -1079,8 +1214,15 @@ export const synthesize_fix = {
1079
1214
  const proj = lc.loadLanguageProject(ctx.sessionRoot);
1080
1215
  const prev = await lc.languageFixPreview(f, proj.files);
1081
1216
  languageFix = { status: prev.status, ok: prev.ok, label: prev.label || null, tier: prev.tier || null, reason: prev.reason || null, diff: prev.diff || null, explanation: prev.explanation || null, consequences: prev.consequences || [] };
1082
- // apply_fix writes ONE file; a fix that spans several files must go through the CLI lifecycle (atomic, grouped undo).
1083
- if (prev.ok && prev.edits) languageFix.multiFile = prev.edits.map((e) => e.file);
1217
+ // A fix that spans several files is applied with apply_fix + plan_digest (atomic, one history group); see _applyMultiFilePlan.
1218
+ if (prev.ok && prev.edits) {
1219
+ languageFix.multiFile = prev.edits.map((e) => e.file);
1220
+ // The digest binds finding + every file + exact pre/post image. Pass it to apply_fix as plan_digest; apply_fix
1221
+ // recomputes the plan itself and refuses on any difference. No file content is ever sent back to apply_fix.
1222
+ languageFix.planDigest = _multiFilePlanDigest(f, prev.edits);
1223
+ languageFix.edits = prev.edits.map((e) => ({ file: e.file, beforeSha256: _sha256(e.before), afterSha256: _sha256(e.after) }));
1224
+ languageFix.applyWith = { finding_id: f.id, confirm: true, plan_digest: languageFix.planDigest };
1225
+ }
1084
1226
  else if (prev.ok) autofix = { deterministic: true, ruleId: f.rule || f.family || null, patch: prev.after, file: prev.file, label: prev.label || null, verified: true };
1085
1227
  }
1086
1228
  } catch { /* best-effort: the preview is advisory */ }
@@ -96,6 +96,8 @@ Wired in `bin/agentic-security.js` after every filter and after `makeDeterminist
96
96
 
97
97
  **`verifyRunAttestation` now has two real callers.** `agentic-security verify-attestation <file>` auto-detects whether the given JSON is an evidence bundle (`.finding`+`.signature`, verified via `evidence-bundle.js`'s Ed25519 path, unchanged) or a run attestation (`.digest`+`.canonicalisation`, either bare or embedded under a full `last-scan.json`'s `.attestation` field) and dispatches accordingly. A run attestation isn't self-contained the way a bundle is — verifying it means re-scanning the project (`--against <path>`, default `.`) and confirming the fresh scan reproduces the attested digest, which is the actual, meaningful claim this artifact makes ("does this codebase, scanned now, match what was attested earlier"). Separately, `scripts/release-check.mjs`'s `attestation-self-check` gate round-trips a synthetic finding set through compute→verify (and a mutated copy through verify, which must fail) on every release, catching a broken canonicalisation or signing path before it ships — independent of whether any project ever calls `verify-attestation` on a real artifact.
98
98
 
99
+ **Issuer and trust basis (CORE-003.AC03).** `evidence-bundle.js` signs an `issuance` block inside the canonical bytes (only when present, so pre-existing bundles keep their signatures): issuer id and key fingerprint, `trustBasis: 'self-issued-local-key'`, `independentlyCertified: false`, and a plain statement. `verifyEvidenceBundle` returns `trustBasis`/`issuer`/`trustNote`; a bundle with no `issuance` verifies and is reported `none-declared` (never as trusted); an unknown basis, an `independentlyCertified: true` claim, or an issuer fingerprint that is not the verifying key is rejected even when validly signed. The only basis this build can verify is the local one: a third-party basis needs a trust-root policy that does not exist here. A signed run attestation carries the same self-issued label. `verify-attestation` exit codes are unchanged. Signing keys are protected from workers by `sandbox/trust-boundary.js` (see `sandbox/CLAUDE.md`). Suite: `test/evidence-issuer.test.js`.
100
+
99
101
  **Integrity + signing** — `integrity.js` (per-install HMAC for `last-scan.json`), `rule-pack-signing.js`. The HMAC key lives at `$XDG_CONFIG_HOME/agentic-security/scan-key`; override via `$AGENTIC_SECURITY_HMAC_KEY`. Premortem-derived; do not regress to hostname-derived.
100
102
 
101
103
  **Rule lifecycle** — `custom-rules.js` (YAML pattern DSL), `rule-overrides.js` (`disable:` gated on signature), `rule-packs.js`, `rule-synthesis.js` (proposes suppressions from triage feedback), `ruleset-version.js`.
@@ -139,6 +141,178 @@ one was caught by the module's own test, not in review.
139
141
  - **No throwing.** Every annotation in `engine.js` is wrapped `try { … } catch (_) {}`. Your annotator must degrade gracefully — set `null` on the field and continue.
140
142
  - **Dead-module test.** `npm run test:lifecycle` fails the build if you export a public symbol from a posture module that no other source file imports. Wire it in `engine.js` (or allowlist it with a written reason in `test/no-dead-modules.test.js`).
141
143
 
144
+ ## Assurance contracts, baseline and rollout config — `assurance/` (9 modules)
145
+
146
+ Foundation for the differentiation work (CORE-001, CORE-002, CORE-004). All of it is pure or read-only, and **nothing in it is wired into `engine.js` or the scan/report path**: a default scan is byte-for-byte unaffected, which `test/posture/assurance-config.test.js` pins. Consumers (X-201 and later) import it directly.
147
+
148
+ - **`identity.js`**: ids for evidence records. Reuses `canonicalJson` (`evidence-bundle.js`) and `computeStableId` (`stable-id.js`, a hypothesis id is a finding's stable id). An id is a hash over an **allowlist** of semantic fields, so clocks (`createdAt`) and migration bookkeeping can never reach it. Prefixes `vrec/obind/capd/rlab/rev` are distinct from lineage's, and nothing here imports `lineage/` (a test checks that).
149
+ - **`schema-kit.js`**: validator primitives. Closed-world (unknown fields rejected), typed error codes, supported major version 1 only, `sha256:<64 hex>` digests.
150
+ - **`verification-record.js`**: the one verification record. Six distinct outcomes, a SEPARATE repair status, four evidence kinds. Structural rules: a model can only produce `inference`; `confirmed` needs referenced trusted-runtime-proof or independent-adjudication evidence; `confirmationLevel` must equal the level derived from the evidence (a forged level fails); `refuted` needs an applicable non-model oracle, valid preconditions and proving evidence.
151
+ - **`contracts.js`**: observation binding (references a lineage observation by id only; absence in a sampled observation cannot establish `blocked`), capability decision (only runner/proxy mediation with a backend and an active-probe digest may claim `enforced`; a hook or in-process check never can), routing label (a decided label must name an adjudication or trusted-execution source), release evidence (`complete` is derived, never trusted), and `validateRecordSet` for duplicate ids, dangling references and conflicting claims.
152
+ - **`migrations.js`**: explicit v1 adapters from legacy shapes (proof tier, boolean verified, egress decision, attestation, lineage observation) and a legacy view back out. Evidence can be lost, never invented: a legacy `true` is `inconclusive`, `proof-failed` is `inconclusive` not `refuted`, and the only route to `confirmed` is an explicit `trustedRunner` assertion bound to a commit. The legacy view's `verified` is `true` only for confirmed and `false` only for refuted, otherwise `null`.
153
+ - **`config.js`**: the unified feature configuration. Reuses the env/`.agentic-security/*.yml` mechanism. Precedence: kill switch (`AGENTIC_SECURITY_NO_ASSURANCE` or `AGENTIC_SECURITY_NO_<FEATURE>`) > explicit override > `AGENTIC_SECURITY_ASSURANCE_<FEATURE>` > `.agentic-security/assurance.yml` > default (everything off). **A project file can never enable a `high-risk-execution` feature** (the file lives in the scanned repo); invalid configuration fails closed; unsupported platforms return a typed `unsupported` with the disclosure. `runFeature` returns typed `blocked`/`unsupported`/`degraded`/`disabled` results and never prompts.
154
+ - **`bounded-io.js`**: `guardedModelCall` is the only route for new model/network calls. It owns no HTTP client (the transport is injected): feature gate, then `evaluateEgress` BEFORE the payload exists, then `redactPayload`, then the request-size limit, then deadline plus bounded retries, then output cap, with every decision appended to the existing egress audit chain. `readFileBounded`, `capOutput`, `withDeadline`, `retryBounded` enforce the limits `config.js` claims; `maxMemoryMiB` is carried for a runner and disclosed as not enforced here.
155
+ - **`baseline.js`, `baseline-inventory.js`**: baseline capture (`scripts/baseline-capture.mjs`, `npm run baseline:capture`). Read-only; records HEAD, dirty paths, exact source and bundle digests, tool versions, entry points, the seven mappings and an 18-entry capability inventory (implemented / partial / unsupported / unmeasured, every path and exported symbol verified at capture time, a missing one is a reported problem). Unknown facts are `{status:'unknown', reason}` with no value. `evaluateBaseline` invalidates exactly the capabilities whose cited files changed and lists unrelated user changes as retained/introduced. The manifest is machine-specific, so it is generated on demand and not committed. When you change a capability's source or tests, update its inventory entry in the same change.
156
+
157
+ Tests: `test/posture/assurance-{baseline,contracts,config}.test.js`, all in `test:posture`.
158
+
159
+ ## One verification record, trusted oracles and replay (X-201, X-202, X-203)
160
+
161
+ Built on `assurance/` (the record schema, migrations, config) and `sandbox/trust-boundary.js` (the boundary). Three
162
+ directories, all consumed rather than wired into the default scan: a default scan is unchanged, and every addition to an
163
+ existing output is an additive field.
164
+
165
+ **`verification/emit.js`: the one emit function (X-201).** `emitVerification(surface, source, ctx)` is what every
166
+ verification surface calls to get the version-1 record (`{ ok, record, legacy, errors }`, never throws, validated before it
167
+ returns). Surfaces and where they call it: `fix-verify.js` (`verificationRecord` on the result, which the MCP `verify_fix` and
168
+ `apply_fix` tools forward), `fix-verify-loop.js`, `execution-proof.js` (`proveFinding` result), `verifier.js`
169
+ (`verifierVerificationRecords`, called by the CLI `verify` command into its `verifier-runs/` run record, never onto the findings that
170
+ are written back to `last-scan.json`), `autopilot.js` (`rec.verificationRecord`, `commit` option), `discovery/index.js` (hunt:
171
+ `verificationRecords`, advisory) and `oracles/oracle.js` (the `oracle` surface). The rule for every mapping: evidence is lost,
172
+ never invented, so no legacy surface can reach `confirmed` or `refuted`. A PoC run under the older sandbox path is `inconclusive`
173
+ (not a trusted runner; `proof-failed` is a triage signal, not a refutation), a static re-scan with no exploit oracle is `not-run`,
174
+ a family with no oracle is `unsupported`, a harness failure is `error`, a model verdict is `inference` evidence only. Only
175
+ `oracle` emissions carry trusted-runner evidence, because only `oracles/oracle.js` builds them.
176
+
177
+ Legacy consumers: nothing existing was renamed or removed. For a consumer that wants a status or a boolean, `toLegacyVerificationView`
178
+ (`assurance/migrations.js`, also returned as `legacy`) maps `verified` to `true` only for `confirmed` and `false` only for `refuted`,
179
+ `null` for the other four, and carries `status` with the real outcome name, so a skipped check can never read as a pass and an unrun
180
+ check never as a failure. Native vocabularies keep their own fields (`proofTier`, `verifier_verdict`, autopilot `outcome`, fix-verify
181
+ `ok`); the record sits beside them as `verificationRecord`. A boolean `true` migrated from a legacy record is `inconclusive`.
182
+
183
+ **`oracles/` (X-202).** `oracle.js` is the adapter contract (`validateOracleSpec`, `defineOracle`) and the runner (`runOracle`).
184
+ `adapters.js` holds the five class adapters (injection-execution, authorization-decision, state-transition,
185
+ side-effect-reachability, parser-resource), `registry.js` the frozen registry and `oracleManifest()`, the machine-readable
186
+ description X-208 gates on (class, prerequisites, platforms with an honest per-platform status, budgets, negative controls,
187
+ fixture locations, limitations, logic digest). Fixtures: `test/fixtures/oracles/<class>/{positive,negative,inconclusive}`.
188
+ - A run is: feature gate (`verification-oracles`, off by default, operator-only), request validation (closed-world: labels,
189
+ verdicts and receipts are never accepted; reserved `__oracle_*` names and path escapes are refused), prerequisite check
190
+ (an unmet one is `unsupported` with a typed reason and the target is never executed), a fresh workspace plus a verifier-owned
191
+ evidence directory outside it, the target through `runInBoundary` ONLY (a test pins that `oracle.js` imports no other way to
192
+ run code), a digest check of the adapter's own harness (a target that rewrote it makes the run `error`), then verifier-side
193
+ interpretation of what the run left behind, read after the whole process tree is dead. The settled status comes from
194
+ `settleVerification` with `observedBy: 'verifier'` evidence; a decided result (`confirmed`/`refuted`) additionally needs an
195
+ exact commit, else it is held at `inconclusive`.
196
+ - `refuted` needs the adapter's own precondition (a benign control passed); a target that failed to load, exited early or never
197
+ reported is `inconclusive`. A run cut by its deadline decides nothing except for the parser/resource class, where the supervisor's
198
+ own deadline observation IS the measurement.
199
+ - Receipts are issued only by `runOracle` (a WeakSet records them; they are deep-frozen; a copy or hand-built one is not
200
+ `isIssuedReceipt`). They carry the observed output (capped), the logic digest, sanitized environment metadata (an allowlist: no
201
+ hostname, paths or environment values) and the record id. Unsigned: signing belongs to the signer domain.
202
+ - Platforms: macOS (userspace backend) is the only host these have been executed on. Linux is declared `unverified` in the
203
+ manifest and cannot be `supported`; today the boundary refuses to run on the namespace backend, which the runner reports as
204
+ `unsupported`. Changing what an adapter asserts means bumping its `version` (the harness text and version feed `logicDigest`).
205
+ - KNOWN LIMIT: the target is imported into the harness process. A target written to recognise the oracle's payload and forge the
206
+ effect artifact is not distinguishable from a real injection; the oracle bounds the effect it tests, it is not a defence
207
+ against a target built to fool it. Tamper controls cover what a worker or target can do to adapter logic, labels, receipts and
208
+ status text.
209
+
210
+ **`replay/replay.js` (X-203).** A replay manifest pins the repository commit, the patch hash, the fixture hash, the toolchain
211
+ (runtime version, platform, architecture, and a container digest when there is one), the oracle id, version and logic digest, the
212
+ inputs and the budgets. It is a description of an oracle run, not a second runner: `replayManifest` ends in `runOracle`, so the
213
+ supervised process tree, output cap and cleanup are the existing ones. It rejects, and nothing executes, on a content hash that does
214
+ not match, an oracle that changed since the manifest was made, a scope wider than "exactly the named oracle, no network,
215
+ workspace-only writes", a budget over the oracle's ceiling, an unsafe file path or an unknown field. Missing toolchain, container
216
+ runtime or image, a denied network (acquisition is never attempted: this build fetches nothing), a disabled feature or a boundary
217
+ that cannot run are typed prerequisites with `resumable` set honestly; the attempt is parked (`awaiting-prerequisites`) and
218
+ `resume` re-evaluates the prerequisites against the real host (a claim that they are met does nothing). Outcome while parked is
219
+ `not-run` (or `unsupported` when this build can never meet it, such as container execution, which is not implemented), with no
220
+ evidence; nothing is substituted for the execution that did not happen. Toolchain identity is exact. Persistence of attempts is
221
+ the caller's (the attempt object is plain data). Suites: `test/verification/{verification-record,oracle-adapters,replay-manifest}.test.js`
222
+ (`npm run test:verification`).
223
+
224
+ ## Patch-negative verification and non-taint classes (X-204, X-205)
225
+
226
+ Same posture as the three directories above: consumed, not wired into the default scan, every output addition additive, and
227
+ nothing changes with the feature flags off. Suites: `test/verification/{patch-negative,non-taint-classes}.test.js`
228
+ (`npm run test:verification`).
229
+
230
+ **`verification/patch-negative.js` (X-204).** `verifyPatchNegative(req, { config, runOptions })` is `verified-fix` only when four
231
+ `replayManifest` runs (so each is pinned, boundary-confined and receipted) show: the exploit oracle CONFIRMS on the original
232
+ revision, REFUTES on original-plus-diff, and the `functional-regression` oracle (the requester's declared cases) is clean on the
233
+ original (a baseline: wrong expectations are reported, not blamed on the patch) and on the patched revision. It stops at the first
234
+ non-passing step and names it: the result carries `incompleteStep` and a typed `failureCode` (`original-not-reproduced`,
235
+ `patched-still-exploitable`, `patched-build-broken`, `functional-omitted`, `functional-baseline-invalid`,
236
+ `functional-regression-detected`, `oracle-changed`, `revision-unbound`, `prerequisite-unmet` (resumable where stated),
237
+ `environment-mismatch`, `promotion-refused`) and a `summary` that says what was and was not exercised. Gate: the
238
+ `patch-negative-verification` feature (high-risk execution, operator-only, off by default) plus `verification-oracles`.
239
+ Wiring: `fix-verify.js` (`verifyFix({ patchNegative, assuranceConfig })` adds `patchNegative` and `fixStatus`, and `ok` requires
240
+ `verified-fix`), `fix-verify-loop.js` (a `patchNegative` leg and the `verified-fix` verdict) and `fix/apply-fix-service.js`
241
+ (`applyVerifiedFix({ patchNegative })` writes only a promoted patch and returns `repairRecords`). With the flag off or no request,
242
+ those return exactly what they did before.
243
+
244
+ **`verification/patch-promotion.js`.** `promotePatch({ proposal, receipts })` re-derives the diff digest and revision from the
245
+ PROPOSAL and refuses any receipt that was not issued by `runOracle` (`isIssuedReceipt`: copies and hand-built receipts fail), that
246
+ covers different content than the exact diff on the original revision, that was issued for another commit or hypothesis, whose
247
+ oracle or inputs differ between the original and patched runs, whose environment identity differs, or that is reused in two roles.
248
+ Receipts are valid inside the verifier's process only; carrying a promotion across processes needs a signing domain (not here).
249
+
250
+ **`verification/repair-records.js`.** The CORE-002 record has one repair status and no value for a rejection, so the history is an
251
+ append-only ledger of separate, frozen, chained records: `proposed`, `rejected`, `promoted` (only through `recordPromotion`, which
252
+ accepts nothing but a result `promotePatch` returned `ok`), `applied` (only after a promotion for that exact diff) and
253
+ `rolled-back`. `verificationRepairFor`/`withRepairStatus` map the ledger onto the CORE-002 `repair` field (`replay-verified` carries the
254
+ patched-negative record id); a rejection leaves it at `proposed` because the foundation enum is not widened here. The ledger is
255
+ returned to the caller and not persisted: persisting it would need an artifact-registry classification.
256
+
257
+ **New adapters.** `replay-idempotency` (a factory receiving a recording stand-in for the effect: a request and a distinct request
258
+ are delivered as a control, then the same request is redelivered, and the verifier counts effects) and `functional-regression`
259
+ (declared cases against expected JSON values; the hypothesis is that behaviour REGRESSED, so a clean patch is `refuted`). Both have
260
+ the usual positive, negative and inconclusive fixtures under `test/fixtures/oracles/`.
261
+
262
+ **`oracles/scenario-classes.js` (X-205).** The supported-class manifest for non-taint defects, surfaced as
263
+ `oracleManifest().nonTaint`: tenant authorization and privileged action (REUSE `authorization-decision`), workflow order (REUSES
264
+ `state-transition`), bounded resource exhaustion (REUSES `parser-resource`) and replay/idempotency (the new adapter). Each class
265
+ lists what the requester must supply, its fixtures (`test/fixtures/non-taint/<class>/{positive,negative,scenario.json}`, application-style
266
+ code), its limitations and the report lines a reader sees; every `runOracle` result additionally carries a `disclosure`
267
+ (prerequisites, platform statement, limitations, class report). Unsupported non-taint classes (concurrent races, CSRF, authentication
268
+ throttling) are named with a reason. `judgeNonTaintHypothesis` is the rule that taint absence is never an input to a refutation:
269
+ without an executed oracle of the class's own adapter with proven preconditions the outcome is `not-run` (or `unsupported`); the hunt
270
+ mapper in `verification/emit.js` uses it. `trustedNegativeDenominator` counts only decided results of an executed oracle of an
271
+ advertised class with proven preconditions and trusted runtime proof, and lists every exclusion with its reason (its runtime consumer is `verificationCoverage` in
272
+ `verification/projection.js`, which the JSON report, the CLI report and the MCP/autopilot surfaces use for the trusted-negative line).
273
+
274
+ Not verified: Linux (the oracle platform statements stay `unverified`); only the declared exploit scenario and functional cases are
275
+ exercised, never the patch's correctness elsewhere.
276
+
277
+ ## One projection for every interface, advisory/gating separation, conformance (X-206, X-207, X-208)
278
+
279
+ Same posture as the sections above: consumed, not wired into the default scan, every output addition additive and present only
280
+ when a verification record is. Suites: `test/verification/{interface-equivalence,advisory-gating,oracle-conformance}.test.js`
281
+ (`npm run test:verification`). Docs: `docs/guides/verification-schema-migration.md`, `docs/guides/verification-oracle-conformance.md`.
282
+
283
+ **`verification/projection.js` (X-206).** `projectVerification(record, { replay })` is the ONE place a record becomes something a
284
+ person or another tool reads: state, scope, oracle, evidence ids, replay prerequisites (derived from the record, plus the typed
285
+ ones a replay attempt reported), a what-was-verified / what-was-not summary, the legacy boolean view, and `text` lines. The JSON
286
+ report (`report/index.js` `normalizeFindings`), the CLI and Markdown reports (`verificationBlock`), MCP `explain_finding`,
287
+ `verify_fix` and `apply_fix`, and the autopilot response (`serializeAutopilotResult`, used by `scripts/autopilot.mjs --json`) all
288
+ call it; none formats its own verification text. The key is **`verificationView`**, never `verification`: a finding's existing
289
+ `verification` field is the producer/verifier separation record (`verification-separation.js`) and a first draft of this work
290
+ collided with it. An invalid record yields `verificationView: null` plus `verificationViewErrors`, never a guessed state. The text
291
+ never says "safe" or "fixed" for a partial result (a lint-style test covers every state, every repair status and the wording the real
292
+ surfaces emit; the fix-verify leg's native status word `fixed` is deliberately not echoed into a record reason). `verificationCoverage`
293
+ wraps `trustedNegativeDenominator`. Previous output schemas are pinned in `test/fixtures/verification-compat/`.
294
+
295
+ **`verification/advisory-state.js`, `verification/hypothesis-promotion.js` (X-207).** Hunt (`discovery/`) is advisory.
296
+ `writeAdvisoryState` is the only way advisory code writes: a closed file-name allowlist (the one hunt memory file), authoritative names
297
+ refused, write to a temp file and rename (a symlink or hard link planted at the target is replaced, never written through), the state
298
+ directory must resolve inside the project root. `discovery/memory.js` `saveMemory` uses it. `normalizeFindings` excludes any hunt
299
+ hypothesis (`parser: DISCOVERY` or a `discovery` object) from every finding list and exit code and records `advisoryExcluded`.
300
+ `promoteHypothesis({ hypothesis, result, policy })` is the only crossing: it verifies (not just checks presence of) a receipt issued by
301
+ `runOracle` (`isIssuedReceipt`), bound to the hypothesis and an exact commit and to a valid record settled `confirmed` at the
302
+ `runtime-confirmed` level, applies an EXPLICIT policy (fail-closed on an unknown key; the receipt rules are mandatory and cannot be
303
+ waived; `minConfidence`/`minAgreement` can only add a requirement), reuses `verification-separation.js` for hunter != verifier, and
304
+ returns a frozen audit record for every decision (`promoted`, `rejected`, `unsupported`, `inconclusive`) with no clock in it.
305
+ `runDiscovery` calls it only when `opts.promote = { policy, verify }` is given. The audit record is returned, not persisted:
306
+ persisting needs an artifact-registry classification and a signing domain, which are not decided here.
307
+
308
+ **`oracles/conformance.js` and `scripts/verification-conformance-check.mjs` (X-208).** One contract over every registered adapter:
309
+ static (class scope, budgets under `BUDGET_CEILINGS`, negative controls with an on-disk fixture, verifier-side evidence logic bound by
310
+ a digest, fixtures and logic pinned in `test/fixtures/oracles/conformance-pins.json`) and execution (state mappings, issued and frozen
311
+ receipts, three tamper attempts, an unavailable prerequisite, cancellation, replay). Wired into `scripts/release-check.mjs`
312
+ (`verification-conformance-gate`, in `RELEASE_GROUPS['benches-b']`) and, static half only, the pre-push gate
313
+ (`verification-conformance-static`). Where the boundary cannot run, execution is reported `not-run`, never passed; `--require-execution`
314
+ makes that a failure. A new adapter must be pinned deliberately (`--update-pins`).
315
+
142
316
  ## Execution-proof tiers (R2)
143
317
 
144
318
  `proof-tier.js` + `execution-proof.js` add a fourth axis to a finding's