@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,308 @@
1
+ // Frozen current-engine evaluation protocol (QA-001).
2
+ //
3
+ // A protocol is the thing registered BEFORE any result is looked at: dataset
4
+ // scope, thresholds, matching rules, budgets and split membership. Its hash is
5
+ // computed over every semantic field, so a changed threshold or a moved target
6
+ // is a different protocol, never an edit of the old one.
7
+ //
8
+ // What this module does NOT do: it creates no population, no labels and no
9
+ // results. A protocol marked `synthetic` can exercise the machinery and can
10
+ // never satisfy a real-code gate (gates.js enforces that).
11
+ //
12
+ // Pure: no fs, no clock, no network. Validators return `{ ok, errors }` with the
13
+ // same error shape the assurance schema kit uses.
14
+
15
+ import {
16
+ SCHEMA_VERSION, makeCtx, result, guardObject, checkHeader, checkFields, checkDigest, checkCommit, checkString,
17
+ isPlainObject, isDigest,
18
+ } from '../assurance/schema-kit.js';
19
+ import { digestOf } from '../assurance/identity.js';
20
+ import { groupTargets, splitStraddles } from './grouping.js';
21
+
22
+ const PROTOCOL_SCHEMA = 'agentic-security/evaluation-protocol';
23
+
24
+ // The nine advertised core languages (docs/POSITIONING.md, PRD section 3).
25
+ export const CORE_LANGUAGES = Object.freeze([
26
+ 'javascript', 'python', 'java', 'kotlin', 'go', 'ruby', 'php', 'csharp', 'rust',
27
+ ]);
28
+
29
+ // PRD section 5. These are release TARGETS registered in advance, not claims
30
+ // about current performance. They live in code so a protocol that omits or
31
+ // loosens one is detectable (`validateProtocol` compares against this floor).
32
+ export const PREREGISTERED_THRESHOLDS = Object.freeze({
33
+ perLanguageF1: 0.80,
34
+ overallMicroF1: 0.80,
35
+ overallMacroF1: 0.80,
36
+ pooledPrecision: 0.90,
37
+ pooledRecall: 0.75,
38
+ perLanguageF1LowerBound: 0.70,
39
+ completion: 0.95,
40
+ minPositivesPerCoreLanguage: 100,
41
+ minNegativesPerCoreLanguage: 100,
42
+ minPositivesPerFamily: 30,
43
+ intervalMethod: 'grouped-bootstrap-95',
44
+ });
45
+
46
+ export const DEFAULT_MATCHING = Object.freeze({
47
+ policy: 'root-cause-location',
48
+ lineWindow: 3,
49
+ requireFamilyOrCwe: true,
50
+ cweAloneSufficient: false,
51
+ lineLessFindingsMatch: false,
52
+ });
53
+
54
+ const FIELDS = [
55
+ 'schema', 'schemaVersion', 'protocolVersion', 'supersedes', 'synthetic', 'engine', 'measurement', 'tools', 'models',
56
+ 'datasetLicenses', 'scope', 'matching', 'limits', 'thresholds', 'targets', 'originalTargetIds', 'retired', 'splits',
57
+ 'grouping', 'comparability', 'protocolHash', 'createdAt',
58
+ ];
59
+ const REQUIRED = [
60
+ 'schema', 'schemaVersion', 'protocolVersion', 'synthetic', 'engine', 'measurement', 'tools', 'models', 'datasetLicenses',
61
+ 'scope', 'matching', 'limits', 'thresholds', 'targets', 'originalTargetIds', 'splits', 'protocolHash',
62
+ ];
63
+ // Everything except the hash itself and the informational clock.
64
+ const HASHED = FIELDS.filter((f) => f !== 'protocolHash' && f !== 'createdAt');
65
+
66
+ export function protocolHashOf(p) {
67
+ const material = {};
68
+ for (const f of HASHED) material[f] = p?.[f] === undefined ? null : p[f];
69
+ return digestOf({ kind: 'evaluation-protocol', material });
70
+ }
71
+
72
+ function deepFreeze(v) {
73
+ if (v && typeof v === 'object' && !Object.isFrozen(v)) {
74
+ Object.freeze(v);
75
+ for (const k of Object.keys(v)) deepFreeze(v[k]);
76
+ }
77
+ return v;
78
+ }
79
+
80
+ const clone = (v) => JSON.parse(JSON.stringify(v));
81
+
82
+ /**
83
+ * Validate a protocol, frozen or not. A protocol whose hash does not match its
84
+ * content fails with HASH_MISMATCH: that is what "immutable" means here.
85
+ */
86
+ export function validateProtocol(p) {
87
+ const g = guardObject(p);
88
+ const ctx = g.ctx;
89
+ if (!g.ok) return result(ctx);
90
+ if (!checkHeader(ctx, p, PROTOCOL_SCHEMA)) return result(ctx);
91
+ checkFields(ctx, p, FIELDS, REQUIRED);
92
+
93
+ if (!Number.isInteger(p.protocolVersion) || p.protocolVersion < 1) ctx.err('BAD_TYPE', 'protocolVersion', 'must be a positive integer');
94
+ if (typeof p.synthetic !== 'boolean') ctx.err('BAD_TYPE', 'synthetic', 'must be a boolean');
95
+
96
+ // AC01: pinned versions, digests and a clean measurement tree.
97
+ if (!isPlainObject(p.engine)) ctx.err('BAD_TYPE', 'engine', 'must be an object');
98
+ else { checkString(ctx, 'engine.version', p.engine.version); checkDigest(ctx, 'engine.bundleDigest', p.engine.bundleDigest); }
99
+ if (!isPlainObject(p.measurement)) ctx.err('BAD_TYPE', 'measurement', 'must be an object');
100
+ else {
101
+ checkCommit(ctx, 'measurement.commit', p.measurement.commit, { nullable: false });
102
+ if (p.measurement.cleanTree !== true) ctx.err('RULE_VIOLATION', 'measurement.cleanTree', 'the measurement tree must be clean; a dirty tree cannot be pinned');
103
+ }
104
+ if (!isPlainObject(p.tools) || Object.keys(p.tools).length === 0) ctx.err('MISSING_FIELD', 'tools', 'tool versions must be pinned');
105
+ if (!Array.isArray(p.models)) ctx.err('BAD_TYPE', 'models', 'must be an array (empty when no model is used)');
106
+ else p.models.forEach((m, i) => { if (!isPlainObject(m) || !m.provider || !m.model || !m.version) ctx.err('BAD_TYPE', `models[${i}]`, 'must name provider, model and version'); });
107
+ if (!isPlainObject(p.datasetLicenses) || Object.keys(p.datasetLicenses).length === 0) ctx.err('MISSING_FIELD', 'datasetLicenses', 'dataset licenses must be declared');
108
+
109
+ if (!isPlainObject(p.scope) || !Array.isArray(p.scope.languages) || !p.scope.languages.length || !Array.isArray(p.scope.families) || !p.scope.families.length) {
110
+ ctx.err('MISSING_FIELD', 'scope', 'scope must declare languages[] and families[]');
111
+ }
112
+ if (!isPlainObject(p.matching)) ctx.err('BAD_TYPE', 'matching', 'must be an object');
113
+ else if (p.matching.cweAloneSufficient !== false) ctx.err('RULE_VIOLATION', 'matching.cweAloneSufficient', 'CWE agreement alone can never make a true positive');
114
+ if (!isPlainObject(p.limits)) ctx.err('BAD_TYPE', 'limits', 'must be an object');
115
+ else {
116
+ for (const k of ['perTargetTimeoutMs', 'spendCeilingUsd', 'replicates']) {
117
+ if (typeof p.limits[k] !== 'number' || !(p.limits[k] >= 0)) ctx.err('BAD_TYPE', `limits.${k}`, 'must be a non-negative number');
118
+ }
119
+ }
120
+
121
+ // Thresholds may be stricter than the floor, never looser or missing.
122
+ if (!isPlainObject(p.thresholds)) ctx.err('BAD_TYPE', 'thresholds', 'must be an object');
123
+ else {
124
+ for (const [k, floor] of Object.entries(PREREGISTERED_THRESHOLDS)) {
125
+ const v = p.thresholds[k];
126
+ if (typeof floor === 'string') { if (v !== floor) ctx.err('RULE_VIOLATION', `thresholds.${k}`, `must be '${floor}'`); continue; }
127
+ if (typeof v !== 'number') { ctx.err('MISSING_FIELD', `thresholds.${k}`, 'preregistered threshold is missing'); continue; }
128
+ if (v < floor) ctx.err('RULE_VIOLATION', `thresholds.${k}`, `${v} is looser than the preregistered ${floor}`);
129
+ }
130
+ }
131
+
132
+ // Targets: pairs, licenses, commits, digests.
133
+ const ids = new Set();
134
+ if (!Array.isArray(p.targets) || p.targets.length === 0) ctx.err('MISSING_FIELD', 'targets', 'a protocol needs at least one target');
135
+ else {
136
+ p.targets.forEach((t, i) => {
137
+ const at = `targets[${i}]`;
138
+ if (!isPlainObject(t)) { ctx.err('BAD_TYPE', at, 'must be an object'); return; }
139
+ if (!checkString(ctx, `${at}.id`, t.id)) return;
140
+ if (ids.has(t.id)) ctx.err('DUPLICATE_ID', `${at}.id`, `duplicate target id ${t.id}`);
141
+ ids.add(t.id);
142
+ checkString(ctx, `${at}.language`, t.language);
143
+ checkString(ctx, `${at}.license`, t.license);
144
+ checkCommit(ctx, `${at}.preCommit`, t.preCommit, { nullable: false });
145
+ checkCommit(ctx, `${at}.postCommit`, t.postCommit, { nullable: true });
146
+ checkDigest(ctx, `${at}.digest`, t.digest);
147
+ if (isPlainObject(p.datasetLicenses) && t.license && !(t.license in p.datasetLicenses)) ctx.err('RULE_VIOLATION', `${at}.license`, `license ${t.license} is not declared in datasetLicenses`);
148
+ if (isPlainObject(p.scope) && Array.isArray(p.scope.languages) && t.language && !p.scope.languages.includes(t.language)) ctx.err('RULE_VIOLATION', `${at}.language`, `${t.language} is outside the declared scope`);
149
+ });
150
+ }
151
+
152
+ // AC03: nothing silently disappears from the original denominator.
153
+ const retired = Array.isArray(p.retired) ? p.retired : [];
154
+ const retiredIds = new Set();
155
+ retired.forEach((r, i) => {
156
+ if (!isPlainObject(r) || !r.id || !r.reason) ctx.err('BAD_TYPE', `retired[${i}]`, 'a retired target must name id and reason');
157
+ else retiredIds.add(r.id);
158
+ });
159
+ if (!Array.isArray(p.originalTargetIds)) ctx.err('BAD_TYPE', 'originalTargetIds', 'must be an array');
160
+ else {
161
+ for (const id of p.originalTargetIds) {
162
+ if (!ids.has(id) && !retiredIds.has(id)) ctx.err('RULE_VIOLATION', 'originalTargetIds', `target ${id} left the population without a disclosed retirement`);
163
+ }
164
+ }
165
+
166
+ // Splits: immutable membership, a partition of the targets, no group straddling it.
167
+ if (!isPlainObject(p.splits) || !Array.isArray(p.splits.dev) || !Array.isArray(p.splits.sealed)) ctx.err('BAD_TYPE', 'splits', 'must be {dev[], sealed[]}');
168
+ else {
169
+ const dev = new Set(p.splits.dev); const sealed = new Set(p.splits.sealed);
170
+ for (const id of dev) if (sealed.has(id)) ctx.err('RULE_VIOLATION', 'splits', `target ${id} is in both development and sealed`);
171
+ for (const id of ids) if (!dev.has(id) && !sealed.has(id)) ctx.err('RULE_VIOLATION', 'splits', `target ${id} is in no split`);
172
+ for (const id of [...dev, ...sealed]) if (!ids.has(id)) ctx.err('DANGLING_REF', 'splits', `split names unknown target ${id}`);
173
+ if (Array.isArray(p.targets) && ctx.errors.length === 0) {
174
+ for (const grp of splitStraddles(groupTargets(p.targets).groups, p.splits)) {
175
+ ctx.err('RULE_VIOLATION', 'splits', `related samples ${grp.members.join(', ')} (${grp.reasons.join('; ')}) straddle development and sealed`);
176
+ }
177
+ }
178
+ }
179
+
180
+ if (typeof p.protocolHash !== 'string' || !isDigest(p.protocolHash)) ctx.err('BAD_DIGEST', 'protocolHash', 'must be a sha256 digest');
181
+ else if (ctx.errors.length === 0 && p.protocolHash !== protocolHashOf(p)) {
182
+ ctx.err('HASH_MISMATCH', 'protocolHash', 'content does not match protocolHash: the protocol was changed after it was frozen');
183
+ }
184
+ return result(ctx);
185
+ }
186
+
187
+ /**
188
+ * Freeze a draft. The draft must already carry its splits; use `assignSplits`
189
+ * (grouping.js) to produce them. Returns `{ ok, errors, protocol }`; on success
190
+ * the protocol is deeply frozen and carries its hash.
191
+ */
192
+ export function freezeProtocol(draft) {
193
+ const p = clone(draft || {});
194
+ p.schema = PROTOCOL_SCHEMA;
195
+ p.schemaVersion = SCHEMA_VERSION;
196
+ p.protocolVersion = Number.isInteger(p.protocolVersion) ? p.protocolVersion : 1;
197
+ if (p.supersedes === undefined) p.supersedes = null;
198
+ if (p.thresholds === undefined) p.thresholds = { ...PREREGISTERED_THRESHOLDS };
199
+ if (p.matching === undefined) p.matching = { ...DEFAULT_MATCHING };
200
+ if (!Array.isArray(p.originalTargetIds)) p.originalTargetIds = (p.targets || []).map((t) => t.id).sort();
201
+ if (!Array.isArray(p.retired)) p.retired = [];
202
+ if (p.comparability === undefined) p.comparability = { comparableWith: [] };
203
+ p.protocolHash = protocolHashOf(p);
204
+ const v = validateProtocol(p);
205
+ if (!v.ok) return { ok: false, errors: v.errors, protocol: null };
206
+ return { ok: true, errors: [], protocol: deepFreeze(p) };
207
+ }
208
+
209
+ // ---------------------------------------------------------------- versioned diff
210
+
211
+ function flatten(v, prefix, out) {
212
+ if (Array.isArray(v) || !isPlainObject(v)) { out[prefix] = digestOf(v === undefined ? null : v) + ':' + JSON.stringify(v ?? null).slice(0, 200); return; }
213
+ for (const k of Object.keys(v).sort()) flatten(v[k], prefix ? `${prefix}.${k}` : k, out);
214
+ }
215
+
216
+ /** Field-level diff of two protocols' hashed content. Empty `changes` means identical content. */
217
+ export function diffProtocols(a, b) {
218
+ const fa = {}; const fb = {};
219
+ for (const f of HASHED) { flatten(a?.[f], f, fa); flatten(b?.[f], f, fb); }
220
+ const paths = [...new Set([...Object.keys(fa), ...Object.keys(fb)])].sort();
221
+ const changes = [];
222
+ for (const path of paths) if (fa[path] !== fb[path]) changes.push({ path, change: !(path in fa) ? 'added' : !(path in fb) ? 'removed' : 'modified' });
223
+ return { from: a?.protocolHash || null, to: b?.protocolHash || null, changes, identical: changes.length === 0 };
224
+ }
225
+
226
+ // Fields that define the measurement. Once a result exists they can never be
227
+ // edited in place, only superseded by a new protocol and a new measurement.
228
+ const MEASUREMENT_DEFINING = ['thresholds', 'matching', 'scope', 'limits', 'targets', 'originalTargetIds', 'retired', 'splits', 'engine', 'measurement', 'tools', 'models', 'datasetLicenses', 'grouping'];
229
+
230
+ /**
231
+ * Amend a frozen protocol BEFORE any result was observed for it. The result is a
232
+ * new versioned protocol that names what it supersedes, carries the field diff,
233
+ * and is comparable with nothing earlier. A removed target must be disclosed as
234
+ * retired with a reason; it stays in `originalTargetIds`.
235
+ *
236
+ * `observedResults` is the list of result records known to the caller. If any is
237
+ * bound to this protocol's hash the amendment is refused with POST_RESULT_CHANGE:
238
+ * a threshold, matching rule or denominator cannot move once results exist. The
239
+ * only route forward is `deriveSuccessorProtocol`, which needs a fresh sealed set.
240
+ */
241
+ export function amendProtocol(frozen, patch, { observedResults = [], reason } = {}) {
242
+ const base = validateProtocol(frozen);
243
+ if (!base.ok) return { ok: false, errors: base.errors, protocol: null, diff: null };
244
+ const bound = (observedResults || []).filter((r) => r && r.protocolHash === frozen.protocolHash);
245
+ if (bound.length > 0) {
246
+ return {
247
+ ok: false, protocol: null, diff: null,
248
+ errors: [{ code: 'POST_RESULT_CHANGE', path: '', message: `${bound.length} result(s) already exist for protocol ${frozen.protocolHash}; its thresholds, matching rules, scope and denominator cannot change in place` }],
249
+ };
250
+ }
251
+ if (!reason || typeof reason !== 'string') return { ok: false, protocol: null, diff: null, errors: [{ code: 'MISSING_FIELD', path: 'reason', message: 'an amendment must state its reason' }] };
252
+ const next = clone(frozen);
253
+ for (const [k, v] of Object.entries(patch || {})) {
254
+ if (!MEASUREMENT_DEFINING.includes(k)) return { ok: false, protocol: null, diff: null, errors: [{ code: 'UNKNOWN_FIELD', path: k, message: `'${k}' is not an amendable protocol field` }] };
255
+ next[k] = clone(v);
256
+ }
257
+ // Removed targets are retired, never dropped.
258
+ const keep = new Set((next.targets || []).map((t) => t.id));
259
+ const retired = Array.isArray(next.retired) ? next.retired : [];
260
+ const known = new Set(retired.map((r) => r.id));
261
+ for (const t of frozen.targets) if (!keep.has(t.id) && !known.has(t.id)) retired.push({ id: t.id, reason: `removed by amendment: ${reason}`, retiredInVersion: frozen.protocolVersion + 1 });
262
+ next.retired = retired;
263
+ next.protocolVersion = frozen.protocolVersion + 1;
264
+ next.supersedes = frozen.protocolHash;
265
+ next.comparability = { comparableWith: [], invalidates: [frozen.protocolHash], reason };
266
+ next.protocolHash = protocolHashOf(next);
267
+ const v = validateProtocol(next);
268
+ if (!v.ok) return { ok: false, errors: v.errors, protocol: null, diff: null };
269
+ return { ok: true, errors: [], protocol: deepFreeze(next), diff: diffProtocols(frozen, next) };
270
+ }
271
+
272
+ /**
273
+ * After results exist, the only legal change is a SUCCESSOR protocol with a new
274
+ * measurement. Its sealed set must not overlap any sealed target already
275
+ * consumed by an observed result, so a consumed holdout is never reused.
276
+ */
277
+ export function deriveSuccessorProtocol(frozen, draft, { observedResults = [], consumedSealedIds = [] } = {}) {
278
+ const consumed = new Set(consumedSealedIds);
279
+ for (const r of observedResults || []) for (const id of r?.sealedTargetIds || []) consumed.add(id);
280
+ if (consumed.size === 0 && (observedResults || []).some((r) => r?.protocolHash === frozen?.protocolHash)) {
281
+ for (const id of frozen.splits.sealed) consumed.add(id);
282
+ }
283
+ const reused = (draft?.splits?.sealed || []).filter((id) => consumed.has(id));
284
+ if (reused.length) {
285
+ return { ok: false, protocol: null, errors: [{ code: 'SEALED_REUSE', path: 'splits.sealed', message: `sealed target(s) ${reused.join(', ')} were already consumed by an observed result; a successor needs a fresh sealed population` }] };
286
+ }
287
+ const r = freezeProtocol({
288
+ ...draft,
289
+ protocolVersion: (frozen?.protocolVersion || 0) + 1,
290
+ supersedes: frozen?.protocolHash || null,
291
+ comparability: { comparableWith: [], invalidates: [frozen?.protocolHash].filter(Boolean), reason: 'successor protocol with a new measurement' },
292
+ });
293
+ return { ...r, diff: r.ok ? diffProtocols(frozen, r.protocol) : null };
294
+ }
295
+
296
+ /**
297
+ * Does a result or run record belong to this exact protocol? A record produced
298
+ * under any other hash (or under a hash that no longer matches the protocol's
299
+ * content) is rejected: it cannot be re-scored under changed rules.
300
+ */
301
+ export function assertBoundToProtocol(record, protocol) {
302
+ const v = validateProtocol(protocol);
303
+ if (!v.ok) return { ok: false, errors: v.errors };
304
+ if (!record || record.protocolHash !== protocol.protocolHash) {
305
+ return { ok: false, errors: [{ code: 'PROTOCOL_MISMATCH', path: 'protocolHash', message: `record is bound to ${record?.protocolHash || 'no protocol'}, not ${protocol.protocolHash}` }] };
306
+ }
307
+ return { ok: true, errors: [] };
308
+ }
@@ -0,0 +1,209 @@
1
+ // Release evaluation gates, signed by the custodian, with a sealed-population ledger (QA-007).
2
+ //
3
+ // gates.js decides each gate; this module turns those decisions into a RELEASE verdict that a person can rely on and a tampering
4
+ // person cannot quietly improve:
5
+ //
6
+ // 1. The verdict is an aggregate. The custodian signs `{ overall, gates, population }` and nothing per-case: a development
7
+ // worker iterating on a detector sees the aggregate gate states, never which sealed case failed.
8
+ // 2. It is a verdict about ONE detector revision. Every sealed evaluation is appended to a hash-chained ledger together with a
9
+ // digest of the detector source it was run against and the sealed targets it consumed. A detector edit after that evaluation
10
+ // means the sealed population has been seen by the people who made the edit, so a new release accuracy claim needs a FRESH
11
+ // sealed population (`assessReleaseClaim`). The old result stays in the ledger as an evaluation-only artifact: retained,
12
+ // never deleted, never usable for a new claim.
13
+ // 3. Insufficient data is `blocked`, never a pass on a smaller sample. gates.js already refuses to pass a short or synthetic
14
+ // population; here a non-pass verdict also blocks the claim, whatever the reason.
15
+ // 4. A lower-quality engine, a failed-target spike, a changed denominator, an answer-key leak, a threshold weakened after
16
+ // freezing, and a baseline edited downward each produce a failing or rejected verdict (gates.js plus `verifyLedger`).
17
+ //
18
+ // Trust: the signature is Ed25519 over the canonical bytes with a custodian key the caller supplies. It is a SELF-ISSUED local
19
+ // signature (trustBasis `self-issued-local-key`, `independentlyCertified: false`), tamper evidence for whoever holds the public
20
+ // key, not independent certification. Verifying needs only the public key.
21
+ //
22
+ // Pure apart from `detectorDigestOf` (reads a source tree) and the key handling the caller does. No clock: ordering is the chain.
23
+
24
+ import * as crypto from 'node:crypto';
25
+ import * as fs from 'node:fs';
26
+ import * as path from 'node:path';
27
+ import { canonicalJson } from '../evidence-bundle.js';
28
+ import { digestOf } from '../assurance/identity.js';
29
+ import { STATE_DIR_NAME } from '../state-dir.js';
30
+ import { evaluateGates } from './gates.js';
31
+
32
+ const GATE_REPORT_SCHEMA = 'agentic-security/evaluation-release-gate';
33
+ const LEDGER_SCHEMA = 'agentic-security/sealed-evaluation-ledger';
34
+ const TRUST_BASIS = 'self-issued-local-key';
35
+
36
+ // ---------------------------------------------------------------- detector identity
37
+
38
+ const DETECTOR_SKIP_DIRS = new Set(['node_modules', '.git', STATE_DIR_NAME, 'vendor', 'dist']);
39
+
40
+ /**
41
+ * Digest of the detector source: every file under `srcRoot` EXCEPT the evaluation tooling itself (editing the measuring instrument
42
+ * is not editing the detector, and it is covered by the protocol hash). Paths are hashed relative and sorted, so the digest
43
+ * depends on content and layout only.
44
+ */
45
+ export function detectorDigestOf(srcRoot, { exclude = ['posture/evaluation'] } = {}) {
46
+ const h = crypto.createHash('sha256');
47
+ const skip = exclude.map((e) => e.replace(/\\/g, '/'));
48
+ const walk = (dir, rel) => {
49
+ for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
50
+ const r = rel ? `${rel}/${e.name}` : e.name;
51
+ if (skip.includes(r)) continue;
52
+ const full = path.join(dir, e.name);
53
+ if (e.isDirectory()) { if (!DETECTOR_SKIP_DIRS.has(e.name)) walk(full, r); continue; }
54
+ if (!e.isFile()) continue;
55
+ h.update(`F:${r}\n`); h.update(fs.readFileSync(full));
56
+ }
57
+ };
58
+ walk(srcRoot, '');
59
+ return `sha256:${h.digest('hex')}`;
60
+ }
61
+
62
+ // ---------------------------------------------------------------- the ledger
63
+
64
+ const entryHashOf = (e) => digestOf({ kind: 'sealed-ledger-entry', e: { ...e, entryHash: undefined } });
65
+
66
+ export const emptyLedger = () => ({ schema: LEDGER_SCHEMA, entries: [] });
67
+
68
+ /**
69
+ * Append one sealed evaluation. Returns a NEW ledger; the input is not modified. An entry records the detector digest, the sealed
70
+ * targets consumed and the aggregate outcome, and chains to the previous entry by hash.
71
+ */
72
+ export function appendSealedEvaluation(ledger, { protocolHash, detectorDigest, sealedTargetIds, overall, gateStatuses = {}, score = null, label = null }) {
73
+ if (!detectorDigest) throw new Error('a sealed evaluation is recorded against a detector digest');
74
+ const prev = ledger.entries.length ? ledger.entries[ledger.entries.length - 1].entryHash : null;
75
+ const entry = {
76
+ sequence: ledger.entries.length, prev, protocolHash, detectorDigest,
77
+ sealedTargetIds: [...sealedTargetIds].sort(), overall, gateStatuses,
78
+ // The aggregate only: this is what a later no-regression comparison reads. Per-case outcomes are never stored here.
79
+ baseline: score ? { endToEnd: { micro: { f1: score.endToEnd?.micro?.f1 ?? null, precision: score.endToEnd?.micro?.precision ?? null, recall: score.endToEnd?.micro?.recall ?? null }, byLanguage: Object.fromEntries(Object.entries(score.endToEnd?.byLanguage || {}).map(([l, c]) => [l, { f1: c.f1 ?? null }])) } } : null,
80
+ usage: 'evaluation-only', label,
81
+ };
82
+ entry.entryHash = entryHashOf(entry);
83
+ return { ...ledger, entries: [...ledger.entries, entry] };
84
+ }
85
+
86
+ /** The chain must be intact: a recomputed hash that disagrees, a broken link or a gap means an entry (a baseline, say) was edited. */
87
+ export function verifyLedger(ledger) {
88
+ const errors = [];
89
+ if (!ledger || ledger.schema !== LEDGER_SCHEMA || !Array.isArray(ledger.entries)) return { ok: false, errors: [{ code: 'BAD_LEDGER', message: 'not a sealed evaluation ledger' }] };
90
+ let prev = null;
91
+ ledger.entries.forEach((e, i) => {
92
+ if (e.sequence !== i) errors.push({ code: 'GAP', index: i, message: `entry ${i} claims sequence ${e.sequence}` });
93
+ if (e.prev !== prev) errors.push({ code: 'BROKEN_CHAIN', index: i, message: `entry ${i} does not chain to the entry before it` });
94
+ if (entryHashOf(e) !== e.entryHash) errors.push({ code: 'ENTRY_EDITED', index: i, message: `entry ${i} no longer matches its recorded hash` });
95
+ prev = e.entryHash;
96
+ });
97
+ return { ok: errors.length === 0, errors };
98
+ }
99
+
100
+ /**
101
+ * May a NEW release accuracy claim be made on this sealed evaluation of this detector?
102
+ * - the ledger must verify;
103
+ * - the evaluation must have been run against the CURRENT detector digest (an old result describes an old detector);
104
+ * - none of the sealed targets it used may have been consumed by an earlier evaluation of a DIFFERENT detector digest: those
105
+ * targets have been seen, so a claim on them is a claim on a holdout that was tuned against. A fresh sealed population is needed.
106
+ * Historical results stay in `retained`, flagged evaluation-only.
107
+ */
108
+ export function assessReleaseClaim({ ledger, detectorDigest, sealedTargetIds, overall }) {
109
+ const v = verifyLedger(ledger);
110
+ if (!v.ok) return { allowed: false, status: 'blocked', reasons: ['ledger-tampered'], errors: v.errors, retained: [] };
111
+ const sealed = new Set(sealedTargetIds);
112
+ const retained = ledger.entries.map((e) => ({ sequence: e.sequence, detectorDigest: e.detectorDigest, overall: e.overall, usage: 'evaluation-only', usableForNewClaim: false }));
113
+ const reasons = [];
114
+ const consumed = new Set();
115
+ const priorOtherDetector = ledger.entries.filter((e) => e.detectorDigest !== detectorDigest);
116
+ for (const e of priorOtherDetector) for (const t of e.sealedTargetIds) if (sealed.has(t)) consumed.add(t);
117
+ if (consumed.size) reasons.push('fresh-sealed-population-required');
118
+ if (!ledger.entries.some((e) => e.detectorDigest === detectorDigest)) reasons.push('no-sealed-evaluation-of-this-detector');
119
+ if (overall !== 'pass') reasons.push(overall === 'insufficient-population' || overall === 'unmeasured' ? `gate-${overall}` : 'gates-not-passing');
120
+ return {
121
+ allowed: reasons.length === 0, status: reasons.length === 0 ? 'claim-allowed' : 'blocked', reasons,
122
+ consumedSealedTargets: [...consumed].sort(), retained,
123
+ note: 'historical sealed results are retained as evaluation-only artifacts; they never support a new claim for a different detector revision',
124
+ };
125
+ }
126
+
127
+ /**
128
+ * The chain alone is not tamper-proof: hashes are unkeyed, so someone who edits a baseline can recompute every later hash. The
129
+ * custodian therefore signs the chain HEAD, and a ledger is trusted only through `verifySignedLedger`.
130
+ */
131
+ export function signLedger(ledger, privateKeyPem) {
132
+ const v = verifyLedger(ledger);
133
+ if (!v.ok) throw new Error('refusing to sign a ledger that does not verify');
134
+ const head = ledger.entries.length ? ledger.entries[ledger.entries.length - 1].entryHash : null;
135
+ const body = { ledger, head, length: ledger.entries.length };
136
+ return { ...body, signature: { algorithm: 'ed25519', value: crypto.sign(null, Buffer.from(canonicalJson({ head, length: body.length })), privateKeyPem).toString('base64') } };
137
+ }
138
+
139
+ export function verifySignedLedger(signed, publicKeyPem) {
140
+ if (!signed?.ledger || !signed.signature) return { ok: false, reason: 'not a signed ledger' };
141
+ const v = verifyLedger(signed.ledger);
142
+ if (!v.ok) return { ok: false, reason: 'the chain does not verify', errors: v.errors };
143
+ const entries = signed.ledger.entries;
144
+ const head = entries.length ? entries[entries.length - 1].entryHash : null;
145
+ if (head !== signed.head || entries.length !== signed.length) return { ok: false, reason: 'the ledger head or length differs from the signed one' };
146
+ let good = false;
147
+ try { good = crypto.verify(null, Buffer.from(canonicalJson({ head, length: entries.length })), publicKeyPem, Buffer.from(signed.signature.value, 'base64')); } catch { good = false; }
148
+ return good ? { ok: true, ledger: signed.ledger } : { ok: false, reason: 'the head signature does not verify under the custodian key' };
149
+ }
150
+
151
+ // ---------------------------------------------------------------- the release verdict
152
+
153
+ /**
154
+ * Evaluate the release gates for one sealed-split run and decide whether a new accuracy claim is allowed.
155
+ * `run` is required: the integrity gates (leaks, denominator) read it, and a verdict that cannot count leaks is not a release verdict.
156
+ */
157
+ export function evaluateReleaseGates({ protocol, score, run, defects = [], negatives = [], ledger = emptyLedger(), detectorDigest }) {
158
+ if (!run) return { ok: false, rejected: true, overall: 'rejected', errors: [{ code: 'RUN_REQUIRED', message: 'a release verdict needs the run record: leakage and the denominator are checked against it' }], gates: [] };
159
+ const lv = verifyLedger(ledger);
160
+ if (!lv.ok) return { ok: false, rejected: true, overall: 'rejected', errors: [{ code: 'LEDGER_TAMPERED', message: 'the sealed evaluation ledger does not verify, so no baseline can be trusted', detail: lv.errors }], gates: [] };
161
+ // The baseline is the last PASSING evaluation of any detector in the verified ledger; it cannot be supplied by the caller.
162
+ const lastPass = [...ledger.entries].reverse().find((e) => e.overall === 'pass' && e.baseline);
163
+ const g = evaluateGates({ protocol, score, defects, negatives, run, baseline: lastPass ? lastPass.baseline : null });
164
+ if (g.rejected) return g;
165
+ const sealedIds = protocol.splits.sealed;
166
+ const claim = assessReleaseClaim({ ledger, detectorDigest, sealedTargetIds: sealedIds, overall: g.overall });
167
+ // A first evaluation of this detector has no ledger entry yet; the entry the caller will append is what makes later claims checkable.
168
+ const firstEvaluation = !ledger.entries.some((e) => e.detectorDigest === detectorDigest);
169
+ const verdict = {
170
+ schema: GATE_REPORT_SCHEMA, ok: true, rejected: false,
171
+ protocolHash: protocol.protocolHash, synthetic: !!protocol.synthetic, detectorDigest,
172
+ overall: g.overall,
173
+ gates: g.gates.map((x) => ({ id: x.id, status: x.status, threshold: x.threshold ?? null, measured: x.measured ?? null, ...(x.reason ? { reason: x.reason } : {}) })),
174
+ population: { realSealedPositives: g.population.realSealedPositives, realSealedNegatives: g.population.realSealedNegatives, reason: g.population.reason },
175
+ claim: firstEvaluation && claim.reasons.every((r) => r === 'no-sealed-evaluation-of-this-detector')
176
+ ? { ...claim, reasons: claim.reasons.filter((r) => r !== 'no-sealed-evaluation-of-this-detector'), allowed: g.overall === 'pass', status: g.overall === 'pass' ? 'claim-allowed' : 'blocked' }
177
+ : claim,
178
+ aggregateOnly: 'no per-case sealed outcome appears in this report',
179
+ };
180
+ verdict.reportHash = digestOf({ ...verdict, reportHash: undefined });
181
+ return verdict;
182
+ }
183
+
184
+ // ---------------------------------------------------------------- signing
185
+
186
+ /** Custodian signature over the aggregate verdict. `privateKeyPem` is the custodian's Ed25519 key. */
187
+ export function signGateReport(report, privateKeyPem, { issuerId = 'evaluation-custodian' } = {}) {
188
+ if (!report || report.rejected) throw new Error('only an accepted gate report is signed');
189
+ const publicKeyPem = crypto.createPublicKey(privateKeyPem).export({ type: 'spki', format: 'pem' });
190
+ const fingerprint = crypto.createHash('sha256').update(publicKeyPem).digest('hex');
191
+ const issuance = { issuerId, keyFingerprint: fingerprint, trustBasis: TRUST_BASIS, independentlyCertified: false, statement: 'signed by a local custodian key; tamper evidence, not independent certification' };
192
+ const body = { report, issuance };
193
+ const signature = crypto.sign(null, Buffer.from(canonicalJson(body)), privateKeyPem).toString('base64');
194
+ return { ...body, signature: { algorithm: 'ed25519', value: signature } };
195
+ }
196
+
197
+ /** Verify with only the public key. A report that claims independent certification, or whose fingerprint is not this key, is rejected. */
198
+ export function verifyGateReport(signed, publicKeyPem) {
199
+ if (!signed || !signed.report || !signed.signature || !signed.issuance) return { ok: false, reason: 'not a signed gate report' };
200
+ if (signed.signature.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${signed.signature.algorithm}` };
201
+ if (signed.issuance.independentlyCertified !== false || signed.issuance.trustBasis !== TRUST_BASIS) return { ok: false, reason: 'the only verifiable trust basis is a self-issued local key' };
202
+ const fp = crypto.createHash('sha256').update(crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'pem' })).digest('hex');
203
+ if (signed.issuance.keyFingerprint !== fp) return { ok: false, reason: 'the issuer fingerprint is not the verifying key' };
204
+ let good = false;
205
+ try { good = crypto.verify(null, Buffer.from(canonicalJson({ report: signed.report, issuance: signed.issuance })), publicKeyPem, Buffer.from(signed.signature.value, 'base64')); } catch { good = false; }
206
+ if (!good) return { ok: false, reason: 'signature does not match the report' };
207
+ if (digestOf({ ...signed.report, reportHash: undefined }) !== signed.report.reportHash) return { ok: false, reason: 'report content does not match its recorded hash' };
208
+ return { ok: true, overall: signed.report.overall, claimAllowed: !!signed.report.claim?.allowed, trustBasis: TRUST_BASIS };
209
+ }