@clear-capabilities/agentic-security-scanner 0.160.0 → 0.162.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/bin/agentic-security.js +11 -2
- package/dist/1919.index.js +1 -1
- package/dist/2432.index.js +10 -1
- package/dist/4113.index.js +60 -9
- package/dist/5613.index.js +500 -0
- package/dist/6239.index.js +469 -0
- package/dist/7033.index.js +4 -1
- package/dist/7838.index.js +39 -3
- package/dist/8714.index.js +1 -1
- package/dist/8798.index.js +2 -2
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +205 -62
- package/dist/{1905.index.js → 9999.index.js} +265 -8
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +14 -9
- package/src/dataflow/engine.js +20 -4
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +28 -0
- package/src/discovery/memory.js +7 -5
- package/src/engine.js +29 -8
- package/src/fix/apply-fix-service.js +33 -3
- package/src/language/CLAUDE.md +6 -1
- package/src/language/aibom.js +4 -1
- package/src/language/fix-lifecycle.js +9 -0
- package/src/language/haskell-ir.js +47 -7
- package/src/language/haskell-sca.js +87 -7
- package/src/language/haskell-supply.js +175 -6
- package/src/language/haskell-web.js +200 -18
- package/src/language/nix-fix.js +1 -1
- package/src/language/resolved-pass.js +4 -1
- package/src/language/witness.js +1 -1
- package/src/mcp/CLAUDE.md +43 -4
- package/src/mcp/tools.js +185 -43
- package/src/posture/CLAUDE.md +174 -0
- package/src/posture/assurance/baseline-inventory.js +218 -0
- package/src/posture/assurance/baseline.js +322 -0
- package/src/posture/assurance/bounded-io.js +145 -0
- package/src/posture/assurance/config.js +299 -0
- package/src/posture/assurance/contracts.js +315 -0
- package/src/posture/assurance/identity.js +62 -0
- package/src/posture/assurance/migrations.js +259 -0
- package/src/posture/assurance/schema-kit.js +111 -0
- package/src/posture/assurance/verification-record.js +258 -0
- package/src/posture/attestation.js +5 -0
- package/src/posture/autopilot.js +28 -1
- package/src/posture/blast-radius.js +15 -1
- package/src/posture/evidence-bundle.js +93 -7
- package/src/posture/execution-proof.js +11 -1
- package/src/posture/fix-verify-loop.js +20 -6
- package/src/posture/fix-verify.js +37 -2
- package/src/posture/oracles/adapters.js +360 -0
- package/src/posture/oracles/conformance.js +275 -0
- package/src/posture/oracles/oracle.js +433 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/remediation-ledger.js +10 -1
- package/src/posture/replay/replay.js +249 -0
- package/src/posture/verification/advisory-state.js +71 -0
- package/src/posture/verification/emit.js +257 -0
- package/src/posture/verification/hypothesis-promotion.js +211 -0
- package/src/posture/verification/patch-negative.js +214 -0
- package/src/posture/verification/patch-promotion.js +105 -0
- package/src/posture/verification/projection.js +183 -0
- package/src/posture/verification/repair-records.js +124 -0
- package/src/posture/verifier.js +16 -0
- package/src/report/index.js +49 -0
- package/src/sandbox/CLAUDE.md +41 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +68 -29
- package/src/sandbox/control-probes.js +215 -0
- package/src/sandbox/supervise.js +201 -0
- package/src/sandbox/trust-boundary.js +113 -0
- package/src/sandbox/trust-domains.js +171 -0
- package/src/sast/pipeline.js +51 -4
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
|
-
//
|
|
1083
|
-
if (prev.ok && prev.edits)
|
|
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 */ }
|
package/src/posture/CLAUDE.md
CHANGED
|
@@ -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
|