@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.1

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 (153) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/bin/agentic-security.js +87 -0
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +1 -1
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +1 -1
  15. package/dist/9560.index.js +2318 -23
  16. package/dist/957.index.js +1 -1
  17. package/dist/9999.index.js +43 -7
  18. package/dist/agentic-security.mjs +12 -12
  19. package/dist/agentic-security.mjs.sha256 +1 -1
  20. package/package.json +33 -6
  21. package/src/capabilities/CLAUDE.md +114 -0
  22. package/src/capabilities/address.js +73 -0
  23. package/src/capabilities/attack-coverage.js +93 -0
  24. package/src/capabilities/decide.js +256 -0
  25. package/src/capabilities/delegate.js +69 -0
  26. package/src/capabilities/egress.js +20 -0
  27. package/src/capabilities/hook-advice.js +50 -0
  28. package/src/capabilities/index.js +32 -0
  29. package/src/capabilities/manifest.js +344 -0
  30. package/src/capabilities/outbound.js +189 -0
  31. package/src/capabilities/paths.js +57 -0
  32. package/src/capabilities/probes.js +211 -0
  33. package/src/capabilities/proxy.js +182 -0
  34. package/src/capabilities/reasons.js +67 -0
  35. package/src/capabilities/receipts.js +356 -0
  36. package/src/capabilities/records.js +58 -0
  37. package/src/capabilities/recovery.js +367 -0
  38. package/src/capabilities/report.js +81 -0
  39. package/src/capabilities/runner.js +236 -0
  40. package/src/capabilities/secrets.js +26 -0
  41. package/src/capabilities/tool-gate.js +119 -0
  42. package/src/capabilities/tool-registry.js +60 -0
  43. package/src/dataflow/CLAUDE.md +30 -0
  44. package/src/dataflow/catalog.js +42 -1
  45. package/src/dataflow/engine.js +67 -2
  46. package/src/dataflow/guard-dominance.js +352 -0
  47. package/src/discovery/CLAUDE.md +9 -0
  48. package/src/discovery/index.js +15 -1
  49. package/src/discovery/lenses.js +18 -0
  50. package/src/engine.js +80 -15
  51. package/src/integrations/index.js +5 -2
  52. package/src/ir/CLAUDE.md +14 -0
  53. package/src/ir/implicit-handler-params.js +74 -0
  54. package/src/ir/parser-cs.js +20 -0
  55. package/src/ir/parser-go.js +30 -13
  56. package/src/ir/parser-java.js +11 -0
  57. package/src/language/nix-eval-isolation.js +33 -5
  58. package/src/lineage/CLAUDE.md +31 -0
  59. package/src/lineage/deployment/adapter-cloud.js +202 -0
  60. package/src/lineage/deployment/adapter-compose.js +90 -0
  61. package/src/lineage/deployment/adapter-kit.js +142 -0
  62. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  63. package/src/lineage/deployment/attack-paths.js +443 -0
  64. package/src/lineage/deployment/boundaries-run.js +223 -0
  65. package/src/lineage/deployment/boundary-graph.js +567 -0
  66. package/src/lineage/deployment/cross-repo.js +253 -0
  67. package/src/lineage/deployment/drift.js +383 -0
  68. package/src/lineage/deployment/ingest.js +221 -0
  69. package/src/lineage/deployment/projection.js +244 -0
  70. package/src/lineage/deployment/trace-correlation.js +541 -0
  71. package/src/lineage/source-registry.js +19 -0
  72. package/src/mcp/CLAUDE.md +5 -2
  73. package/src/mcp/invariant-tools.js +64 -0
  74. package/src/mcp/portfolio-tools.js +79 -0
  75. package/src/mcp/redact.js +15 -0
  76. package/src/mcp/server.js +24 -3
  77. package/src/mcp/tools.js +13 -1
  78. package/src/posture/CLAUDE.md +332 -1
  79. package/src/posture/accuracy-scorecard.js +95 -4
  80. package/src/posture/assurance/rollback.js +214 -0
  81. package/src/posture/assurance/rollout.js +200 -0
  82. package/src/posture/business-logic.js +26 -4
  83. package/src/posture/evaluation/bakeoff.js +148 -0
  84. package/src/posture/evaluation/custody.js +200 -0
  85. package/src/posture/evaluation/deployment-ablation.js +416 -0
  86. package/src/posture/evaluation/dev-cases.js +65 -0
  87. package/src/posture/evaluation/economics.js +85 -0
  88. package/src/posture/evaluation/gates.js +161 -0
  89. package/src/posture/evaluation/grouping.js +101 -0
  90. package/src/posture/evaluation/interval.js +132 -0
  91. package/src/posture/evaluation/invariant-ablation.js +325 -0
  92. package/src/posture/evaluation/labels.js +200 -0
  93. package/src/posture/evaluation/protocol.js +308 -0
  94. package/src/posture/evaluation/release-gate.js +209 -0
  95. package/src/posture/evaluation/report.js +158 -0
  96. package/src/posture/evaluation/runner.js +273 -0
  97. package/src/posture/evaluation/scan-child.js +80 -0
  98. package/src/posture/evaluation/score.js +174 -0
  99. package/src/posture/evaluation/synthetic.js +90 -0
  100. package/src/posture/evaluation/why-missed.js +219 -0
  101. package/src/posture/fleet.js +11 -2
  102. package/src/posture/invariants/cli.js +95 -0
  103. package/src/posture/invariants/coverage.js +164 -0
  104. package/src/posture/invariants/export.js +178 -0
  105. package/src/posture/invariants/expressions.js +46 -0
  106. package/src/posture/invariants/lifecycle.js +289 -0
  107. package/src/posture/invariants/project-input.js +55 -0
  108. package/src/posture/invariants/repair.js +340 -0
  109. package/src/posture/invariants/run.js +45 -0
  110. package/src/posture/invariants/scenarios.js +311 -0
  111. package/src/posture/invariants/schema.js +167 -0
  112. package/src/posture/invariants/shrink.js +407 -0
  113. package/src/posture/invariants/state-assertions.js +165 -0
  114. package/src/posture/logic-claims.js +12 -0
  115. package/src/posture/model-routing.js +40 -0
  116. package/src/posture/oracles/adapters.js +172 -1
  117. package/src/posture/oracles/conformance.js +3 -2
  118. package/src/posture/oracles/oracle.js +23 -3
  119. package/src/posture/oracles/registry.js +1 -1
  120. package/src/posture/portfolio/backend.js +279 -0
  121. package/src/posture/portfolio/bundle.js +314 -0
  122. package/src/posture/portfolio/cli.js +142 -0
  123. package/src/posture/portfolio/manifest.js +263 -0
  124. package/src/posture/portfolio/progress.js +267 -0
  125. package/src/posture/portfolio/resume.js +130 -0
  126. package/src/posture/portfolio/retention.js +207 -0
  127. package/src/posture/portfolio/scheduler.js +488 -0
  128. package/src/posture/portfolio/signing.js +155 -0
  129. package/src/posture/portfolio/wording.js +59 -0
  130. package/src/posture/portfolio/work-units.js +405 -0
  131. package/src/posture/replay/replay.js +1 -0
  132. package/src/posture/routing/adapter.js +140 -0
  133. package/src/posture/routing/calibration.js +278 -0
  134. package/src/posture/routing/canary.js +141 -0
  135. package/src/posture/routing/control.js +35 -0
  136. package/src/posture/routing/decide.js +211 -0
  137. package/src/posture/routing/drift.js +137 -0
  138. package/src/posture/routing/economics.js +185 -0
  139. package/src/posture/routing/feedback.js +207 -0
  140. package/src/posture/routing/index.js +31 -0
  141. package/src/posture/routing/outcomes.js +281 -0
  142. package/src/posture/routing/promotion.js +291 -0
  143. package/src/posture/routing/receipts.js +59 -0
  144. package/src/posture/routing/report.js +142 -0
  145. package/src/posture/routing/shadow.js +53 -0
  146. package/src/posture/specification-mining.js +26 -0
  147. package/src/posture/verification/patch-negative.js +16 -4
  148. package/src/posture/verification/patch-promotion.js +10 -2
  149. package/src/report/index.js +51 -1
  150. package/src/sandbox/CLAUDE.md +10 -0
  151. package/src/sandbox/backend-userspace.js +91 -13
  152. package/dist/5613.index.js +0 -500
  153. package/dist/6239.index.js +0 -469
@@ -0,0 +1,65 @@
1
+ // A DEVELOPMENT case suite for engine work (QA-005.AC03, QA-006), scored by the same frozen policy as everything else.
2
+ //
3
+ // `test/fixtures/engine-mechanisms/` holds small projects, each a vulnerable `pre/` tree and a patched `post/` tree, plus a `cases.json`
4
+ // naming the reviewed location of each defect. This module turns them into a protocol, labels and a resolver so that
5
+ // `runEvaluation` + `scoreRun` (same matching policy, same end-to-end rules) can answer "which of these defects does the engine
6
+ // recover, and which patched trees stay silent" for ANY engine checkout. That makes a before/after comparison a measurement and not
7
+ // an anecdote: run it once per engine revision and compare.
8
+ //
9
+ // What this is NOT: an independent population. The projects and the labels were written by the people who wrote the fix, so every
10
+ // label is flagged `synthetic: true` and gates.js will never count them toward a real-code minimum. They exist to show that a
11
+ // change recovers the mechanism it was made for, and that its patched twin stays quiet. They say nothing about real-code accuracy.
12
+
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import { digestOf } from '../assurance/identity.js';
16
+ import { digestTree, targetDigestOf } from './runner.js';
17
+ import { freezeProtocol, CORE_LANGUAGES, PREREGISTERED_THRESHOLDS, DEFAULT_MATCHING } from './protocol.js';
18
+ import { buildDefectLabel, buildNegativeLabel } from './labels.js';
19
+
20
+ const LICENSE = 'development-fixture';
21
+ const hex = (n) => n.toString(16).padStart(40, '0');
22
+ const adjudication = (tag) => ({ status: 'adjudicated', independent: true, reviewers: [{ reviewerId: `dev-reviewer-a-${tag}`, verdict: 'confirmed' }, { reviewerId: `dev-reviewer-b-${tag}`, verdict: 'confirmed' }] });
23
+
24
+ function readDevCases(fixturesDir) {
25
+ return JSON.parse(fs.readFileSync(path.join(fixturesDir, 'cases.json'), 'utf8')).cases;
26
+ }
27
+
28
+ /** @returns {{protocol, defects, negatives, freezeErrors}} everything flagged synthetic; every target in the development split. */
29
+ export function buildDevSuite({ fixturesDir, cases = readDevCases(fixturesDir) }) {
30
+ const targets = cases.map((c, i) => ({
31
+ id: c.id, language: c.language, upstream: `example.invalid/dev/${c.id}`, pairId: `pair-${c.id}`, preCommit: hex(2 * i + 1), postCommit: hex(2 * i + 2), advisoryIds: [], license: LICENSE,
32
+ digest: targetDigestOf({ pre: digestTree(path.join(fixturesDir, c.id, 'pre')), post: digestTree(path.join(fixturesDir, c.id, 'post')) }),
33
+ }));
34
+ const draft = {
35
+ synthetic: true,
36
+ engine: { version: '0.0.0-dev-cases', bundleDigest: digestOf('dev-cases-engine') },
37
+ measurement: { commit: hex(0xabc), cleanTree: true },
38
+ tools: { node: 'dev' }, models: [], datasetLicenses: { [LICENSE]: 'authored for engine development; not real-world code' },
39
+ scope: { languages: [...CORE_LANGUAGES], families: [...new Set(cases.map((c) => c.family))].sort() },
40
+ matching: { ...DEFAULT_MATCHING },
41
+ limits: { perTargetTimeoutMs: 120000, spendCeilingUsd: 0, replicates: 3 },
42
+ thresholds: { ...PREREGISTERED_THRESHOLDS },
43
+ targets, splits: { dev: targets.map((t) => t.id).sort(), sealed: [] },
44
+ grouping: { method: 'union-find', keys: ['pair', 'upstream', 'advisory', 'commit', 'template'], salt: 'dev-cases' },
45
+ };
46
+ const frozen = freezeProtocol(draft);
47
+ const defects = []; const negatives = [];
48
+ cases.forEach((c, i) => {
49
+ const base = (tag) => ({ synthetic: true, adjudication: adjudication(`${tag}${i}`), proposedBy: { role: 'worker', id: 'dev-proposer' } });
50
+ const d = buildDefectLabel({
51
+ ...base('d'), targetId: c.id, rootCauseId: `development-root-cause-${c.id}`, affected: { commit: hex(2 * i + 1) },
52
+ language: c.language, family: c.family, cwe: c.cwe, location: { file: c.file, startLine: c.startLine, endLine: c.endLine },
53
+ evidence: [{ ref: `development-review-note-${c.id}`, producer: 'human' }],
54
+ });
55
+ defects.push(d);
56
+ negatives.push(buildNegativeLabel({
57
+ ...base('n'), targetId: c.id, variant: 'post', kind: 'patched', language: c.language, family: c.family,
58
+ scope: { files: [c.file] }, pairedDefectId: d.id, rationale: `development: the patched ${c.id} was reviewed as not exploitable`,
59
+ }));
60
+ });
61
+ return { protocol: frozen.ok ? frozen.protocol : null, freezeErrors: frozen.errors, defects, negatives, cases };
62
+ }
63
+
64
+ /** resolveTarget for the runner over the dev fixtures. */
65
+ export const devResolver = (fixturesDir) => (target, variant) => ({ dir: path.join(fixturesDir, target.id, variant) });
@@ -0,0 +1,85 @@
1
+ // Security economics (QA-004.AC03): what finding and fixing defects cost, with the cost kinds kept apart.
2
+ //
3
+ // A ledger is a list of entries, each one thing that was spent:
4
+ // { id, kind, amountUsd | minutes, targetId, attemptId?, rootCauseId?, outcome? }
5
+ // `kind` is one of
6
+ // model model inference charged to the evaluation
7
+ // tool tool and infrastructure compute (scanner time, sandbox, runner)
8
+ // cache cache charges (writes and reads priced separately from fresh inference)
9
+ // human-review reviewer MINUTES, never converted to dollars here (a rate is a policy choice, not a measurement)
10
+ // `outcome` classifies the work the spend bought: confirmed | validated-fix | refuted | inconclusive | failed | duplicate.
11
+ //
12
+ // The rules that keep these figures honest:
13
+ // - the kinds are reported side by side and never silently summed; human minutes are not dollars;
14
+ // - an entry with no amount is UNMEASURED and counted as such, never read as zero;
15
+ // - cost per confirmed defect divides ALL machine spend (successful, failed, refuted and duplicate attempts alike) by the number
16
+ // of DISTINCT root causes independently confirmed, so failed attempts raise the price and a defect found twice is one defect;
17
+ // - cost per validated fix divides the same spend by distinct root causes with an independently validated repair;
18
+ // - with no confirmed defect the ratio is `null` (undefined), not 0 and not the total.
19
+ //
20
+ // Pure: no fs, no clock.
21
+
22
+ import { SCHEMA_VERSION } from '../assurance/schema-kit.js';
23
+ import { digestOf } from '../assurance/identity.js';
24
+
25
+ const COST_KINDS = Object.freeze(['model', 'tool', 'cache', 'human-review']);
26
+ export const OUTCOMES = Object.freeze(['confirmed', 'validated-fix', 'refuted', 'inconclusive', 'failed', 'duplicate']);
27
+ const ECON_SCHEMA = 'agentic-security/evaluation-economics';
28
+
29
+ function validateEntry(e, i) {
30
+ const errs = [];
31
+ if (!e || typeof e !== 'object') return [{ code: 'BAD_TYPE', path: `entries[${i}]`, message: 'must be an object' }];
32
+ if (!COST_KINDS.includes(e.kind)) errs.push({ code: 'BAD_ENUM', path: `entries[${i}].kind`, message: `kind must be one of ${COST_KINDS.join(', ')}` });
33
+ if (e.outcome !== undefined && !OUTCOMES.includes(e.outcome)) errs.push({ code: 'BAD_ENUM', path: `entries[${i}].outcome`, message: `outcome must be one of ${OUTCOMES.join(', ')}` });
34
+ const field = e.kind === 'human-review' ? 'minutes' : 'amountUsd';
35
+ const other = e.kind === 'human-review' ? 'amountUsd' : 'minutes';
36
+ if (e[other] !== undefined && e[other] !== null) errs.push({ code: 'RULE_VIOLATION', path: `entries[${i}].${other}`, message: `a ${e.kind} entry is measured in ${field}; ${other} would mix units` });
37
+ if (e[field] !== undefined && e[field] !== null && !(typeof e[field] === 'number' && Number.isFinite(e[field]) && e[field] >= 0)) errs.push({ code: 'BAD_VALUE', path: `entries[${i}].${field}`, message: `${field} must be a non-negative number or absent (unmeasured)` });
38
+ if ((e.outcome === 'confirmed' || e.outcome === 'validated-fix') && !e.rootCauseId) errs.push({ code: 'RULE_VIOLATION', path: `entries[${i}].rootCauseId`, message: 'a confirmed or validated outcome must name its root cause so duplicates cannot be counted twice' });
39
+ return errs;
40
+ }
41
+
42
+ /** @param {object[]} entries */
43
+ export function accountCosts(entries) {
44
+ const errors = (entries || []).flatMap(validateEntry);
45
+ if (errors.length) return { ok: false, errors };
46
+
47
+ const totals = { model: { usd: 0, entries: 0, unmeasured: 0 }, tool: { usd: 0, entries: 0, unmeasured: 0 }, cache: { usd: 0, entries: 0, unmeasured: 0 }, humanReview: { minutes: 0, entries: 0, unmeasured: 0 } };
48
+ const slot = { model: 'model', tool: 'tool', cache: 'cache', 'human-review': 'humanReview' };
49
+ const confirmed = new Set(); const validated = new Set();
50
+ const byOutcome = {};
51
+ for (const e of entries || []) {
52
+ const t = totals[slot[e.kind]];
53
+ t.entries++;
54
+ const v = e.kind === 'human-review' ? e.minutes : e.amountUsd;
55
+ if (v === undefined || v === null) t.unmeasured++;
56
+ else if (e.kind === 'human-review') t.minutes += v; else t.usd += v;
57
+ const o = e.outcome || 'unclassified';
58
+ byOutcome[o] = byOutcome[o] || { usd: 0, minutes: 0, entries: 0 };
59
+ byOutcome[o].entries++;
60
+ if (v !== undefined && v !== null) { if (e.kind === 'human-review') byOutcome[o].minutes += v; else byOutcome[o].usd += v; }
61
+ if (e.outcome === 'confirmed') confirmed.add(e.rootCauseId);
62
+ if (e.outcome === 'validated-fix') { validated.add(e.rootCauseId); confirmed.add(e.rootCauseId); }
63
+ }
64
+ // Machine spend: every kind except reviewer minutes, INCLUDING failed, refuted, inconclusive and duplicate work.
65
+ const machineUsd = totals.model.usd + totals.tool.usd + totals.cache.usd;
66
+ const unmeasuredMachine = totals.model.unmeasured + totals.tool.unmeasured + totals.cache.unmeasured;
67
+ const div = (n, d) => (d > 0 ? n / d : null);
68
+ const out = {
69
+ schema: ECON_SCHEMA, schemaVersion: SCHEMA_VERSION, ok: true, errors: [],
70
+ totals,
71
+ separation: 'model, tool, cache and human-review figures are never combined; human review is in minutes, the rest in USD',
72
+ machineSpendUsd: machineUsd,
73
+ unmeasuredMachineEntries: unmeasuredMachine,
74
+ confirmedDefects: confirmed.size, validatedFixes: validated.size,
75
+ costPerConfirmedDefectUsd: div(machineUsd, confirmed.size),
76
+ costPerValidatedFixUsd: div(machineUsd, validated.size),
77
+ humanMinutesPerConfirmedDefect: div(totals.humanReview.minutes, confirmed.size),
78
+ humanMinutesPerValidatedFix: div(totals.humanReview.minutes, validated.size),
79
+ byOutcome,
80
+ note: 'ratios divide ALL machine spend (failed, refuted and duplicate attempts included) by DISTINCT independently confirmed root causes; a ratio over an incomplete ledger is a lower bound',
81
+ lowerBound: unmeasuredMachine > 0,
82
+ };
83
+ out.economicsHash = digestOf({ ...out, economicsHash: undefined });
84
+ return out;
85
+ }
@@ -0,0 +1,161 @@
1
+ // Real-code quality gates over a frozen protocol (section 5 of the programme PRD).
2
+ //
3
+ // This module can answer `pass` ONLY when every population minimum is met by
4
+ // real, independently adjudicated, non-synthetic labels inside the sealed split,
5
+ // AND the measured value clears the threshold registered in the protocol. In
6
+ // every other case it answers `insufficient-population` or `unmeasured`, with the
7
+ // reason. It never answers `pass` for a smaller convenient sample, for synthetic
8
+ // data, or for a threshold it was handed by the caller: thresholds are read from
9
+ // the protocol, and a protocol that fails its hash check is rejected outright.
10
+ //
11
+ // The statuses are deliberately not boolean: `unmeasured` is not `fail`, and
12
+ // neither is a pass.
13
+
14
+ import { validateProtocol, CORE_LANGUAGES } from './protocol.js';
15
+ import { isScoreable } from './labels.js';
16
+ import { groupTargets } from './grouping.js';
17
+ import { groupedBootstrap, f1Of } from './interval.js';
18
+
19
+ export const GATE_STATUSES = Object.freeze(['pass', 'fail', 'insufficient-population', 'unmeasured']);
20
+
21
+ const gate = (id, status, extra = {}) => ({ id, status, ...extra });
22
+
23
+ /**
24
+ * @param {object} o
25
+ * @param {object} o.protocol frozen protocol (thresholds and splits come from here)
26
+ * @param {object} o.score a score (score.js) of the SEALED split of a run bound to this protocol
27
+ * @param {object[]} o.defects all defect labels
28
+ * @param {object[]} o.negatives all negative labels
29
+ * @param {object} [o.run] the sealed-split run record the score came from; enables the integrity gates (leaks, denominator)
30
+ * @param {object} [o.baseline] the last verified release score; enables the no-regression gate
31
+ */
32
+ export function evaluateGates({ protocol, score, defects = [], negatives = [], run = null, baseline = null }) {
33
+ const v = validateProtocol(protocol);
34
+ if (!v.ok) return { ok: false, rejected: true, errors: v.errors, overall: 'rejected', gates: [] };
35
+ if (score && score.protocolHash !== protocol.protocolHash) {
36
+ return { ok: false, rejected: true, errors: [{ code: 'PROTOCOL_MISMATCH', path: 'score.protocolHash', message: 'the score was produced under a different protocol; it cannot be judged against these thresholds' }], overall: 'rejected', gates: [] };
37
+ }
38
+ const t = protocol.thresholds;
39
+ const gates = [];
40
+ const sealed = new Set(protocol.splits.sealed);
41
+
42
+ const realD = defects.filter((l) => isScoreable(l) && !l.synthetic && sealed.has(l.targetId));
43
+ const realN = negatives.filter((l) => isScoreable(l) && !l.synthetic && sealed.has(l.targetId));
44
+ const count = (xs, key, val) => xs.filter((x) => x[key] === val).length;
45
+ const shortfalls = [];
46
+ for (const lang of CORE_LANGUAGES) {
47
+ const p = count(realD, 'language', lang); const n = count(realN, 'language', lang);
48
+ if (p < t.minPositivesPerCoreLanguage) shortfalls.push(`${lang}: ${p}/${t.minPositivesPerCoreLanguage} positives`);
49
+ if (n < t.minNegativesPerCoreLanguage) shortfalls.push(`${lang}: ${n}/${t.minNegativesPerCoreLanguage} negatives`);
50
+ }
51
+ const familyShort = [...new Set(protocol.scope.families)].filter((f) => count(realD, 'family', f) < t.minPositivesPerFamily);
52
+ const populationOk = shortfalls.length === 0 && familyShort.length === 0;
53
+ const popDetail = {
54
+ reason: protocol.synthetic
55
+ ? 'the protocol is synthetic: a synthetic population can exercise the machinery and can never satisfy a real-code gate'
56
+ : `population minimums not met: ${shortfalls.slice(0, 6).join('; ')}${shortfalls.length > 6 ? `; and ${shortfalls.length - 6} more` : ''}${familyShort.length ? `; ${familyShort.length} famil${familyShort.length === 1 ? 'y' : 'ies'} below ${t.minPositivesPerFamily} sealed positives` : ''}`,
57
+ realSealedPositives: realD.length, realSealedNegatives: realN.length,
58
+ };
59
+ const blocked = protocol.synthetic || !populationOk || !score;
60
+ const unmet = (id, threshold) => gate(id, score ? 'insufficient-population' : 'unmeasured', {
61
+ threshold, measured: null, ...(score ? popDetail : { reason: 'no sealed-split score exists' }),
62
+ });
63
+
64
+ const e2e = score?.endToEnd;
65
+ const decide = (id, threshold, measured, cmp = (m, th) => m >= th) => {
66
+ if (blocked) return unmet(id, threshold);
67
+ if (measured === null || measured === undefined) return gate(id, 'unmeasured', { threshold, measured: null, reason: 'value undefined on this population' });
68
+ return gate(id, cmp(measured, threshold) ? 'pass' : 'fail', { threshold, measured });
69
+ };
70
+
71
+ gates.push(decide('overall-micro-f1', t.overallMicroF1, e2e?.micro?.f1));
72
+ gates.push(decide('overall-macro-f1', t.overallMacroF1, e2e?.macroF1));
73
+ gates.push(decide('pooled-precision', t.pooledPrecision, e2e?.micro?.precision));
74
+ gates.push(decide('pooled-recall', t.pooledRecall, e2e?.micro?.recall));
75
+ gates.push(decide('completion', t.completion, score?.completion?.rate));
76
+ for (const lang of CORE_LANGUAGES) gates.push(decide(`per-language-f1:${lang}`, t.perLanguageF1, e2e?.byLanguage?.[lang]?.f1 ?? null));
77
+ gates.push(lowerBoundGate({ protocol, score, blocked, popDetail }));
78
+ for (const g of integrityGates({ protocol, score, run, defects, negatives })) gates.push(g);
79
+ if (baseline) gates.push(regressionGate({ score, baseline, blocked, popDetail }));
80
+
81
+ const overall = gates.every((g) => g.status === 'pass') ? 'pass'
82
+ : gates.some((g) => g.status === 'fail') ? 'fail'
83
+ : gates.some((g) => g.status === 'insufficient-population') ? 'insufficient-population' : 'unmeasured';
84
+ return { ok: true, rejected: false, errors: [], protocolHash: protocol.protocolHash, synthetic: protocol.synthetic, overall, gates, population: popDetail };
85
+ }
86
+
87
+ // ---------------------------------------------------------------- the interval gate
88
+
89
+ /** The lowest per-language F1 lower bound (grouped bootstrap) against the registered bound. Unmeasured, never assumed. */
90
+ function lowerBoundGate({ protocol, score, blocked, popDetail }) {
91
+ const id = 'per-language-f1-lower-bound';
92
+ const threshold = protocol.thresholds.perLanguageF1LowerBound;
93
+ if (!score) return gate(id, 'unmeasured', { threshold, measured: null, reason: 'no sealed-split score exists' });
94
+ if (blocked) return gate(id, 'insufficient-population', { threshold, measured: null, reason: popDetail.reason });
95
+ if (!score.perTarget || typeof score.perTarget !== 'object') return gate(id, 'unmeasured', { threshold, measured: null, reason: 'the score carries no per-target tallies, so no grouped interval can be computed' });
96
+ const { groupOf } = groupTargets(protocol.targets);
97
+ const units = Object.entries(score.perTarget).map(([tid, c]) => ({ id: tid, ...c }));
98
+ const perLanguage = {};
99
+ for (const lang of CORE_LANGUAGES) {
100
+ perLanguage[lang] = groupedBootstrap({
101
+ units: units.filter((u) => u.language === lang), groupOf, statistic: (a) => f1Of(a.total), name: `lang:${lang}:f1`, protocolHash: protocol.protocolHash,
102
+ });
103
+ }
104
+ const measured = Object.values(perLanguage).filter((iv) => iv.status === 'measured');
105
+ const unmeasured = Object.entries(perLanguage).filter(([, iv]) => iv.status !== 'measured');
106
+ const lows = measured.map((iv) => iv.low);
107
+ const min = lows.length ? Math.min(...lows) : null;
108
+ const detail = Object.fromEntries(Object.entries(perLanguage).map(([l, iv]) => [l, { status: iv.status, low: iv.low, high: iv.high, groups: iv.groups }]));
109
+ if (min !== null && min < threshold) return gate(id, 'fail', { threshold, measured: min, method: protocol.thresholds.intervalMethod, perLanguage: detail, reason: 'at least one language has an F1 lower bound under the registered bound' });
110
+ if (unmeasured.length) return gate(id, 'unmeasured', { threshold, measured: min, method: protocol.thresholds.intervalMethod, perLanguage: detail, reason: `${unmeasured.length} language${unmeasured.length === 1 ? '' : 's'} without enough independent groups for an interval: ${unmeasured.slice(0, 4).map(([l]) => l).join(', ')}` });
111
+ return gate(id, 'pass', { threshold, measured: min, method: protocol.thresholds.intervalMethod, perLanguage: detail });
112
+ }
113
+
114
+ // ---------------------------------------------------------------- integrity gates
115
+
116
+ const LEAK_REASON = /leakage control/i;
117
+
118
+ /** Gates that judge the evaluation's own integrity. They are measurable on any population, so they never read insufficient-population. */
119
+ function integrityGates({ protocol, score, run, defects, negatives }) {
120
+ // Without a run record there is nothing to check; release-gate.js refuses to produce a release verdict without one.
121
+ if (!run) return [];
122
+ const leaks = (run.outcomes || []).filter((o) => o.status === 'quarantined' && LEAK_REASON.test(o.failureReason || ''));
123
+ const out = [gate('answer-key-leaks', leaks.length === 0 ? 'pass' : 'fail', { threshold: 0, measured: leaks.length, ...(leaks.length ? { reason: `${leaks.length} target workspace(s) carried label or answer-key material`, targets: leaks.map((o) => o.targetId).sort() } : {}) })];
124
+
125
+ // The denominator is the frozen population, not whatever the run happened to cover.
126
+ const problems = [];
127
+ if (run.protocolHash !== protocol.protocolHash) problems.push('run is bound to a different protocol');
128
+ const have = new Set((run.outcomes || []).map((o) => `${o.targetId}|${o.variant}`));
129
+ for (const t of protocol.targets.filter((x) => protocol.splits.sealed.includes(x.id))) {
130
+ if (!have.has(`${t.id}|pre`)) problems.push(`sealed target ${t.id} has no pre outcome`);
131
+ if (t.postCommit && !have.has(`${t.id}|post`)) problems.push(`sealed target ${t.id} has no post outcome`);
132
+ }
133
+ const sealed = new Set(protocol.splits.sealed);
134
+ const positives = defects.filter((l) => isScoreable(l) && sealed.has(l.targetId)).length;
135
+ const negs = negatives.filter((l) => isScoreable(l) && sealed.has(l.targetId)).length;
136
+ if (score?.endToEnd?.micro && Number.isFinite(score.endToEnd.micro.tp)) {
137
+ const m = score.endToEnd.micro;
138
+ if (m.tp + m.fn !== positives) problems.push(`scored positives ${m.tp + m.fn} differ from the ${positives} adjudicated sealed positives`);
139
+ const negScored = m.tn + m.fp;
140
+ const failed = (score.negativeFailures || []).length;
141
+ if (Array.isArray(score.negativeFailures) && negScored + failed < negs && negScored + failed !== negs) problems.push(`scored negatives ${negScored + failed} differ from the ${negs} adjudicated sealed negatives`);
142
+ }
143
+ out.push(gate('denominator-intact', problems.length === 0 ? 'pass' : 'fail', { threshold: null, measured: problems.length, ...(problems.length ? { reason: problems.slice(0, 5).join('; '), problems: problems.slice(0, 20) } : {}) }));
144
+ return out;
145
+ }
146
+
147
+ /** Compare to the last VERIFIED release score; a drop in micro F1 or in any language's F1 fails. */
148
+ function regressionGate({ score, baseline, blocked, popDetail }) {
149
+ const id = 'no-regression-vs-baseline';
150
+ const cur = score?.endToEnd; const base = baseline?.endToEnd;
151
+ if (!cur || !base) return gate(id, 'unmeasured', { threshold: 'baseline', measured: null, reason: 'a score or a baseline is missing' });
152
+ const regressions = [];
153
+ const cmp = (name, c, b) => { if (typeof c === 'number' && typeof b === 'number' && c < b) regressions.push({ name, baseline: b, current: c }); };
154
+ cmp('micro-f1', cur.micro?.f1, base.micro?.f1);
155
+ cmp('micro-precision', cur.micro?.precision, base.micro?.precision);
156
+ cmp('micro-recall', cur.micro?.recall, base.micro?.recall);
157
+ for (const [lang, b] of Object.entries(base.byLanguage || {})) cmp(`f1:${lang}`, cur.byLanguage?.[lang]?.f1, b?.f1);
158
+ if (regressions.length) return gate(id, 'fail', { threshold: 'baseline', measured: regressions.length, regressions, reason: 'the current engine scores below the recorded baseline' });
159
+ if (blocked) return gate(id, 'insufficient-population', { threshold: 'baseline', measured: null, reason: popDetail.reason });
160
+ return gate(id, 'pass', { threshold: 'baseline', measured: 0 });
161
+ }
@@ -0,0 +1,101 @@
1
+ // Leakage-safe grouping and splitting (QA-001.AC02).
2
+ //
3
+ // Related samples must land on the same side of the development/sealed line, or
4
+ // the sealed set is not independent of what detector work already saw. Two
5
+ // samples are related when they share any of: a vulnerable/fixed pair, an
6
+ // upstream project (a fork resolves to its parent), an advisory id (duplicate
7
+ // advisories), a commit, or a near-identical template fingerprint. Relations are
8
+ // transitive (union-find), so a chain A~B~C is one group.
9
+ //
10
+ // Pure and deterministic: group ids and split assignment depend only on the
11
+ // targets and an explicit salt, never on iteration order or a clock.
12
+
13
+ import * as crypto from 'node:crypto';
14
+
15
+ /** `https://GitHub.com/Org/Repo.git/` and `git@github.com:org/repo` normalise to `github.com/org/repo`. */
16
+ export function normalizeUpstream(url) {
17
+ if (typeof url !== 'string' || !url.trim()) return null;
18
+ let u = url.trim().toLowerCase();
19
+ u = u.replace(/^[a-z+]+:\/\//, '').replace(/^git@/, '').replace(/^[^@/]+@/, '');
20
+ u = u.replace(':', '/').replace(/\/+$/, '').replace(/\.git$/, '');
21
+ return u || null;
22
+ }
23
+
24
+ class UnionFind {
25
+ constructor(ids) { this.parent = new Map(ids.map((i) => [i, i])); }
26
+ find(x) { let r = x; while (this.parent.get(r) !== r) r = this.parent.get(r); this.parent.set(x, r); return r; }
27
+ union(a, b) { const ra = this.find(a); const rb = this.find(b); if (ra !== rb) this.parent.set(ra < rb ? rb : ra, ra < rb ? ra : rb); }
28
+ }
29
+
30
+ const KEYS = (t) => {
31
+ const k = [];
32
+ if (t.pairId) k.push(['pair', t.pairId]);
33
+ const up = normalizeUpstream(t.upstream);
34
+ if (up) k.push(['upstream', up]);
35
+ const fork = normalizeUpstream(t.forkOf);
36
+ if (fork) k.push(['upstream', fork]);
37
+ for (const a of t.advisoryIds || []) k.push(['advisory', String(a).toUpperCase()]);
38
+ for (const c of [t.preCommit, t.postCommit]) if (c) k.push(['commit', c]);
39
+ if (t.templateFingerprint) k.push(['template', t.templateFingerprint]);
40
+ return k;
41
+ };
42
+
43
+ /**
44
+ * Group targets by every relation above. Returns `{ groups, groupOf }` where each
45
+ * group is `{ groupId, members[], reasons[] }`, members sorted, groups sorted.
46
+ */
47
+ export function groupTargets(targets) {
48
+ const list = (targets || []).filter((t) => t && t.id);
49
+ const uf = new UnionFind(list.map((t) => t.id));
50
+ const byKey = new Map();
51
+ for (const t of list) {
52
+ for (const [kind, val] of KEYS(t)) {
53
+ const key = `${kind}:${val}`;
54
+ if (byKey.has(key)) uf.union(byKey.get(key).id, t.id);
55
+ else byKey.set(key, t);
56
+ }
57
+ }
58
+ const members = new Map();
59
+ for (const t of list) { const r = uf.find(t.id); if (!members.has(r)) members.set(r, []); members.get(r).push(t.id); }
60
+ // Which relation kinds actually linked two or more members of each group.
61
+ const kindCount = new Map();
62
+ for (const t of list) {
63
+ for (const [kind, val] of KEYS(t)) {
64
+ const gk = `${uf.find(t.id)}|${kind}:${val}`;
65
+ kindCount.set(gk, (kindCount.get(gk) || 0) + 1);
66
+ }
67
+ }
68
+ const groups = [];
69
+ const groupOf = {};
70
+ for (const [root, ids] of members) {
71
+ ids.sort();
72
+ const reasons = new Set();
73
+ for (const [gk, n] of kindCount) if (n > 1 && gk.startsWith(`${root}|`)) reasons.add(gk.slice(root.length + 1).split(':')[0]);
74
+ const groupId = `grp:${crypto.createHash('sha256').update(ids.join('\n')).digest('hex').slice(0, 16)}`;
75
+ groups.push({ groupId, members: ids, reasons: [...reasons].sort() });
76
+ for (const id of ids) groupOf[id] = groupId;
77
+ }
78
+ groups.sort((a, b) => a.groupId.localeCompare(b.groupId));
79
+ return { groups, groupOf };
80
+ }
81
+
82
+ /**
83
+ * Deterministically assign whole groups to development or sealed. A group's side
84
+ * is a function of its id and the salt only, so adding an unrelated target never
85
+ * moves an existing group, and no group can straddle the line.
86
+ */
87
+ export function assignSplits(groups, { sealedFraction = 0.3, salt = '' } = {}) {
88
+ const dev = []; const sealed = [];
89
+ for (const g of groups) {
90
+ const h = crypto.createHash('sha256').update(`${salt}|${g.groupId}`).digest();
91
+ const u = h.readUInt32BE(0) / 0x100000000;
92
+ (u < sealedFraction ? sealed : dev).push(...g.members);
93
+ }
94
+ return { dev: dev.sort(), sealed: sealed.sort() };
95
+ }
96
+
97
+ /** Groups whose members are spread over both sides. Empty means the split is leakage-safe. */
98
+ export function splitStraddles(groups, splits) {
99
+ const dev = new Set(splits?.dev || []); const sealed = new Set(splits?.sealed || []);
100
+ return (groups || []).filter((g) => g.members.some((m) => dev.has(m)) && g.members.some((m) => sealed.has(m)));
101
+ }
@@ -0,0 +1,132 @@
1
+ // Documented 95% interval methods (QA-004.AC01).
2
+ //
3
+ // Two methods, each registered by name in the protocol (`thresholds.intervalMethod`) before any result is observed:
4
+ //
5
+ // grouped-bootstrap-95 For F1, precision and recall, and for the micro and macro aggregates. Resamples GROUPS, not cases:
6
+ // targets that share a pair, upstream, advisory, commit or template (grouping.js) are one resampling
7
+ // unit, because treating near-duplicates as independent draws makes the interval narrower than the
8
+ // evidence supports. The percentile method over `replicates` resamples (default 2000), driven by a
9
+ // seeded generator, so the same score and protocol produce the same interval to the last digit.
10
+ // The seed is derived from the protocol hash: nobody picks a seed after seeing a result.
11
+ // wilson-95 For a single proportion (the completion rate), where there is no group structure to resample.
12
+ //
13
+ // When there are too few groups for resampling to mean anything (`minGroups`, default 10), the interval is `unmeasured` with the
14
+ // reason, never a degenerate zero-width or a made-up range. An undefined statistic in a resample (no positives drawn) is skipped
15
+ // and counted, and a method that skips too many resamples is itself reported `unmeasured`.
16
+ //
17
+ // Pure: no fs, no clock, no Math.random.
18
+
19
+ import * as crypto from 'node:crypto';
20
+
21
+ export const INTERVAL_METHODS = Object.freeze({
22
+ 'grouped-bootstrap-95': Object.freeze({
23
+ level: 0.95,
24
+ unit: 'target group (grouping.js union-find over pair, upstream, advisory, commit, template)',
25
+ estimator: 'percentile',
26
+ replicates: 2000,
27
+ minGroups: 10,
28
+ maxUndefinedFraction: 0.1,
29
+ seed: 'first 32 bits of sha256(protocolHash + "|" + statisticName)',
30
+ }),
31
+ 'wilson-95': Object.freeze({ level: 0.95, unit: 'case', estimator: 'wilson-score', z: 1.959964 }),
32
+ });
33
+
34
+ /** mulberry32: a small, well-distributed 32-bit generator. Deterministic for a given seed. */
35
+ export function seededRng(seed) {
36
+ let a = seed >>> 0;
37
+ return () => {
38
+ a = (a + 0x6D2B79F5) >>> 0;
39
+ let t = a;
40
+ t = Math.imul(t ^ (t >>> 15), t | 1);
41
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
42
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
43
+ };
44
+ }
45
+
46
+ /** A 32-bit seed that depends only on the frozen protocol and the statistic's name. */
47
+ export function seedFor(protocolHash, statisticName) {
48
+ return crypto.createHash('sha256').update(`${protocolHash}|${statisticName}`).digest().readUInt32BE(0);
49
+ }
50
+
51
+ const quantile = (sorted, q) => {
52
+ if (!sorted.length) return null;
53
+ const pos = (sorted.length - 1) * q;
54
+ const lo = Math.floor(pos); const hi = Math.ceil(pos);
55
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
56
+ };
57
+
58
+ const ZERO = () => ({ tp: 0, fn: 0, fp: 0, tn: 0 });
59
+ const add = (a, b) => { a.tp += b.tp; a.fn += b.fn; a.fp += b.fp; a.tn += b.tn; };
60
+
61
+ export const f1Of = (c) => (c.tp + c.fp + c.fn === 0 ? null : (2 * c.tp) / (2 * c.tp + c.fp + c.fn));
62
+ export const precisionOf = (c) => (c.tp + c.fp > 0 ? c.tp / (c.tp + c.fp) : null);
63
+ export const recallOf = (c) => (c.tp + c.fn > 0 ? c.tp / (c.tp + c.fn) : null);
64
+
65
+ /**
66
+ * Grouped percentile bootstrap.
67
+ * @param {object} o
68
+ * @param {{id: string, language: string, tp: number, fn: number, fp: number, tn: number}[]} o.units per-target tallies
69
+ * @param {Object<string,string>} o.groupOf target id -> group id (a target with no entry is its own group)
70
+ * @param {(agg: {total: object, byLanguage: Object<string,object>}) => (number|null)} o.statistic
71
+ * @param {string} o.name the statistic's name (feeds the seed)
72
+ * @param {string} o.protocolHash
73
+ */
74
+ export function groupedBootstrap({ units, groupOf = {}, statistic, name, protocolHash, replicates = INTERVAL_METHODS['grouped-bootstrap-95'].replicates, minGroups = INTERVAL_METHODS['grouped-bootstrap-95'].minGroups, level = 0.95 }) {
75
+ const method = 'grouped-bootstrap-95';
76
+ const groups = new Map();
77
+ for (const u of units || []) {
78
+ const g = groupOf[u.id] || `solo:${u.id}`;
79
+ if (!groups.has(g)) groups.set(g, []);
80
+ groups.get(g).push(u);
81
+ }
82
+ const G = [...groups.keys()].sort();
83
+ const aggOf = (members) => {
84
+ const total = ZERO(); const byLanguage = {};
85
+ for (const u of members) {
86
+ add(total, u);
87
+ byLanguage[u.language] = byLanguage[u.language] || ZERO();
88
+ add(byLanguage[u.language], u);
89
+ }
90
+ return { total, byLanguage };
91
+ };
92
+ const all = aggOf(units || []);
93
+ const point = statistic(all);
94
+ const base = { method, level, replicates, groups: G.length, units: (units || []).length, point };
95
+ if (G.length < minGroups) {
96
+ return { ...base, status: 'unmeasured', low: null, high: null, reason: `${G.length} independent group${G.length === 1 ? '' : 's'}, fewer than the ${minGroups} needed for a resampling interval to mean anything` };
97
+ }
98
+ const rng = seededRng(seedFor(protocolHash, name));
99
+ const perGroup = G.map((g) => aggOf(groups.get(g)));
100
+ const draws = [];
101
+ let undefinedDraws = 0;
102
+ for (let r = 0; r < replicates; r++) {
103
+ const total = ZERO(); const byLanguage = {};
104
+ for (let i = 0; i < G.length; i++) {
105
+ const pick = perGroup[Math.floor(rng() * G.length)];
106
+ add(total, pick.total);
107
+ for (const [lang, c] of Object.entries(pick.byLanguage)) { byLanguage[lang] = byLanguage[lang] || ZERO(); add(byLanguage[lang], c); }
108
+ }
109
+ const v = statistic({ total, byLanguage });
110
+ if (v === null || v === undefined || Number.isNaN(v)) undefinedDraws++; else draws.push(v);
111
+ }
112
+ const maxUndef = INTERVAL_METHODS[method].maxUndefinedFraction;
113
+ if (undefinedDraws / replicates > maxUndef) {
114
+ return { ...base, status: 'unmeasured', low: null, high: null, undefinedDraws, reason: `the statistic was undefined in ${undefinedDraws} of ${replicates} resamples (more than ${maxUndef * 100}%), so the interval would describe a different population` };
115
+ }
116
+ draws.sort((a, b) => a - b);
117
+ const alpha = (1 - level) / 2;
118
+ return { ...base, status: 'measured', low: quantile(draws, alpha), high: quantile(draws, 1 - alpha), undefinedDraws, seed: seedFor(protocolHash, name) };
119
+ }
120
+
121
+ /** Wilson score interval for a proportion. n = 0 is unmeasured. */
122
+ export function wilsonInterval(successes, n, z = INTERVAL_METHODS['wilson-95'].z) {
123
+ const method = 'wilson-95';
124
+ if (!Number.isInteger(successes) || !Number.isInteger(n) || n <= 0 || successes < 0 || successes > n) {
125
+ return { method, level: 0.95, status: 'unmeasured', point: null, low: null, high: null, successes, n, reason: 'no cases' };
126
+ }
127
+ const p = successes / n; const z2 = z * z;
128
+ const denom = 1 + z2 / n;
129
+ const centre = (p + z2 / (2 * n)) / denom;
130
+ const half = (z * Math.sqrt((p * (1 - p)) / n + z2 / (4 * n * n))) / denom;
131
+ return { method, level: 0.95, status: 'measured', point: p, low: Math.max(0, centre - half), high: Math.min(1, centre + half), successes, n };
132
+ }