forgedock 1.7.1 → 1.8.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.
- package/bin/engine/admission.mjs +242 -7
- package/bin/engine/admission.test.mjs +183 -2
- package/bin/engine/phases.mjs +31 -22
- package/bin/engine/resolve.mjs +8 -1
- package/bin/engine/resolve.test.mjs +5 -0
- package/bin/engine.mjs +26 -17
- package/bin/forgedock.mjs +15 -2
- package/bin/hooks/pre-tool-use.mjs +573 -2
- package/bin/journey.mjs +11 -7
- package/bin/labels.json +5 -0
- package/bin/opencode-adapter.mjs +47 -13
- package/bin/orchestrate-preflight.mjs +434 -0
- package/bin/runner.mjs +30 -4
- package/commands/audit-agents.md +75 -26
- package/commands/autopilot.md +4 -4
- package/commands/cleanup.md +10 -5
- package/commands/issue.md +95 -35
- package/commands/orchestrate/phase-1-resolve.md +70 -32
- package/commands/orchestrate/phase-3-dependency.md +141 -13
- package/commands/orchestrate/phase-4-execution.md +660 -70
- package/commands/orchestrate/phase-5-cleanup.md +5 -11
- package/commands/orchestrate/phase-6-report.md +27 -1
- package/commands/orchestrate/safety.md +1 -1
- package/commands/orchestrate.md +39 -1
- package/commands/quality-gate.md +30 -10
- package/commands/review-pr-agents/protocols.md +13 -0
- package/commands/review-pr-agents.md +1 -1
- package/commands/review-pr-staging.md +100 -18
- package/commands/review-pr.md +78 -24
- package/commands/upgrade-deps.md +1 -1
- package/commands/work-on/build/validate.md +25 -4
- package/commands/work-on/close.md +40 -10
- package/commands/work-on/decompose.md +33 -1
- package/commands/work-on/investigate.md +33 -0
- package/commands/work-on/remediate.md +40 -5
- package/commands/work-on/review.md +27 -0
- package/commands/work-on.md +48 -5
- package/docs/CONFIG.md +45 -10
- package/docs/OPENCODE.md +21 -6
- package/docs/spec/review-protocol.md +7 -0
- package/package.json +1 -1
- package/scripts/check-admission-parity.mjs +1 -0
- package/scripts/check-command-side-effects.sh +14 -13
- package/scripts/check-shell-portability.sh +36 -0
- package/scripts/check-spec-markers.sh +21 -11
- package/scripts/checkbox-sections.test.sh +67 -0
- package/scripts/extract-affected-files.sh +85 -5
- package/scripts/extract-affected-files.test.sh +154 -2
package/bin/engine/admission.mjs
CHANGED
|
@@ -32,24 +32,82 @@
|
|
|
32
32
|
* cascade until N tokens spent" — `max_generation` and `token_budget` below
|
|
33
33
|
* are independent levers precisely so that shape of policy is expressible.
|
|
34
34
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* including `all`. That exclusion lives upstream of this module (the P3
|
|
39
|
-
* batching eligibility check in `phase-1-resolve.md` / the surface-area
|
|
40
|
-
* batching check in `phase-4-execution.md`) and is intentionally absent
|
|
41
|
-
* from the levers this module resolves.
|
|
35
|
+
* Billing findings are never batched. Security-relevant P3 findings may only
|
|
36
|
+
* batch with findings in the same coarse class, with a maximum of three
|
|
37
|
+
* members. The prose mirrors use `classifyBatchSafety` as their reference.
|
|
42
38
|
*/
|
|
43
39
|
|
|
44
40
|
/** Sentinel string accepted anywhere an "int | unlimited" lever is read. */
|
|
45
41
|
export const UNLIMITED = "unlimited";
|
|
46
42
|
|
|
43
|
+
const BATCH_SAFETY_CLASSES = [
|
|
44
|
+
["billing", /\bbilling\b/i],
|
|
45
|
+
["auth", /\b(?:[A-Za-z0-9]*[A-Z][A-Za-z0-9]*[Aa][Uu][Tt][Hh][A-Za-z0-9]*|[Aa][Uu][Tt][Hh](?:[NnZz]?_|[A-Z]))\b|\b(?:authentication|authorization|authn|authz)\b/i],
|
|
46
|
+
["injection", /\b(?:inject|injection|xss|csrf|ssrf|deserializ|rce)\w*/i],
|
|
47
|
+
["access-control", /\b(?:bypass|escalat|privilege)\w*/i],
|
|
48
|
+
["credential", /\b(?:credential|secret|token|password|pgpassword|htpasswd)\w*/i],
|
|
49
|
+
["redaction", /\b(?:redact|sanitiz)\w*/i],
|
|
50
|
+
["scheme", /\bscheme\b/i],
|
|
51
|
+
["traversal", /\btraversal\b/i],
|
|
52
|
+
["security", /\b(?:security|anti-bot)\b/i],
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Return the batching safety class for title/Problem text. `null` is routine
|
|
57
|
+
* work; `billing` is an absolute exclusion; every other value is eligible only
|
|
58
|
+
* for a same-class, maximum-three-member security batch.
|
|
59
|
+
*
|
|
60
|
+
* `auth` deliberately matches camelCase/PascalCase and underscore identifiers
|
|
61
|
+
* (MaintenanceAuth, AdminAuth, authz_check) without matching authority_source.
|
|
62
|
+
* @param {string} text
|
|
63
|
+
* @returns {string|null}
|
|
64
|
+
*/
|
|
65
|
+
export function classifyBatchSafety(text) {
|
|
66
|
+
const findingText = text.replace(/^\*\*Agent\*\*:.*$/gim, "");
|
|
67
|
+
const explicitClass = findingText.match(/<!--\s*FORGE:CLASS:\s*([a-z0-9-]+)\s*-->/i)?.[1];
|
|
68
|
+
if (explicitClass) return explicitClass.toLowerCase();
|
|
69
|
+
return BATCH_SAFETY_CLASSES.find(([, pattern]) => pattern.test(findingText))?.[0] ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* True only for the narrow automated-alert duplicate case. Human-authored
|
|
74
|
+
* reports and alerts without a stable generator/trigger identity always stay
|
|
75
|
+
* for investigation.
|
|
76
|
+
*
|
|
77
|
+
* @param {Object} canonical
|
|
78
|
+
* @param {Object} candidate
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
export function canDeduplicateAutomatedAlert(canonical, candidate) {
|
|
82
|
+
const isAutomated = (issue) =>
|
|
83
|
+
issue?.authorType === "Bot" || /\[bot\]$/i.test(issue?.authorLogin || "");
|
|
84
|
+
const normalizeTitle = (title) => String(title || "").trim().replace(/\s+/g, " ").toLowerCase();
|
|
85
|
+
const hasIdentity = (value) => typeof value === "string" && value.trim() !== "";
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
isAutomated(canonical) &&
|
|
89
|
+
isAutomated(candidate) &&
|
|
90
|
+
normalizeTitle(canonical.title) !== "" &&
|
|
91
|
+
normalizeTitle(canonical.title) === normalizeTitle(candidate.title) &&
|
|
92
|
+
hasIdentity(canonical.generator) &&
|
|
93
|
+
hasIdentity(candidate.generator) &&
|
|
94
|
+
canonical.generator === candidate.generator &&
|
|
95
|
+
hasIdentity(canonical.trigger) &&
|
|
96
|
+
hasIdentity(candidate.trigger) &&
|
|
97
|
+
canonical.trigger === candidate.trigger
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
47
101
|
/**
|
|
48
102
|
* @typedef {Object} CascadePolicy
|
|
49
103
|
* @property {number|typeof UNLIMITED} maxGeneration - Max cascade generation depth
|
|
50
104
|
* admitted. 1 = only original (non-review-finding-spawned) issues; a
|
|
51
105
|
* review-finding whose source is itself a review-finding is generation 2,
|
|
52
106
|
* and so on up the chain. `unlimited` removes the cap entirely.
|
|
107
|
+
* @property {number} batchMaxGeneration - Deepest review-finding generation that
|
|
108
|
+
* may be absorbed into a P3 batch. This is deliberately always finite: batching
|
|
109
|
+
* is a bounded aggregation exception to autonomous cascade admission, not an
|
|
110
|
+
* alternate path to unlimited recursion.
|
|
53
111
|
* @property {number|typeof UNLIMITED} tokenBudget - Per-batch token ceiling for
|
|
54
112
|
* Step 4C's review-finding cascade dispatch (mirrors, and by default reads
|
|
55
113
|
* through to, `pipeline.token_budget_per_batch`). `unlimited` removes the cap.
|
|
@@ -59,6 +117,10 @@ export const UNLIMITED = "unlimited";
|
|
|
59
117
|
* heuristic defers P3-and-below findings.
|
|
60
118
|
* @property {boolean} p3SameFileDefer - Whether a P3 finding sharing a file with
|
|
61
119
|
* the active batch is deferred.
|
|
120
|
+
* @property {number|null} maxAmplification - Maximum findings spawned per merged
|
|
121
|
+
* unit before same-lineage refinements are deferred. `null` disables the bound.
|
|
122
|
+
* @property {number} convergenceWindow - Merged-unit window used to warn when
|
|
123
|
+
* amplification remains at or above 1.0.
|
|
62
124
|
*/
|
|
63
125
|
|
|
64
126
|
/**
|
|
@@ -78,6 +140,7 @@ export const UNLIMITED = "unlimited";
|
|
|
78
140
|
export const CASCADE_PRESETS = Object.freeze({
|
|
79
141
|
all: Object.freeze({
|
|
80
142
|
maxGeneration: UNLIMITED,
|
|
143
|
+
batchMaxGeneration: 2,
|
|
81
144
|
tokenBudget: UNLIMITED,
|
|
82
145
|
deferOnBatchGated: false,
|
|
83
146
|
keywordHeuristic: false,
|
|
@@ -85,6 +148,7 @@ export const CASCADE_PRESETS = Object.freeze({
|
|
|
85
148
|
}),
|
|
86
149
|
balanced: Object.freeze({
|
|
87
150
|
maxGeneration: 1,
|
|
151
|
+
batchMaxGeneration: 2,
|
|
88
152
|
tokenBudget: 900000,
|
|
89
153
|
deferOnBatchGated: true,
|
|
90
154
|
keywordHeuristic: true,
|
|
@@ -92,6 +156,7 @@ export const CASCADE_PRESETS = Object.freeze({
|
|
|
92
156
|
}),
|
|
93
157
|
conservative: Object.freeze({
|
|
94
158
|
maxGeneration: 1,
|
|
159
|
+
batchMaxGeneration: 2,
|
|
95
160
|
tokenBudget: 450000,
|
|
96
161
|
deferOnBatchGated: true,
|
|
97
162
|
keywordHeuristic: true,
|
|
@@ -101,6 +166,19 @@ export const CASCADE_PRESETS = Object.freeze({
|
|
|
101
166
|
|
|
102
167
|
export const DEFAULT_CASCADE_POLICY_NAME = "balanced";
|
|
103
168
|
|
|
169
|
+
/** Parse an optional positive decimal used for an opt-in ratio ceiling. */
|
|
170
|
+
export function parseOptionalPositiveNumber(raw) {
|
|
171
|
+
if (raw === undefined || raw === null || raw === "null" || raw === "" || raw === "off") {
|
|
172
|
+
return { value: null, warning: null };
|
|
173
|
+
}
|
|
174
|
+
const n = typeof raw === "number" ? raw : Number(String(raw).trim());
|
|
175
|
+
if (Number.isFinite(n) && n > 0) return { value: n, warning: null };
|
|
176
|
+
return {
|
|
177
|
+
value: null,
|
|
178
|
+
warning: `not a positive number or "off" ("${raw}") — disabling the bound`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
104
182
|
/**
|
|
105
183
|
* Parse a raw config value that may be a positive integer, the literal
|
|
106
184
|
* string "unlimited" (case-insensitive), or absent/invalid. Mirrors the
|
|
@@ -144,10 +222,13 @@ export function parseIntOrUnlimited(raw, fallback) {
|
|
|
144
222
|
* that resolves to `balanced` exactly like today's hardcoded behavior).
|
|
145
223
|
* @param {string} [config.policy]
|
|
146
224
|
* @param {number|string} [config.max_generation]
|
|
225
|
+
* @param {number|string} [config.batch_max_generation]
|
|
147
226
|
* @param {number|string} [config.token_budget]
|
|
148
227
|
* @param {boolean} [config.defer_on_batch_gated]
|
|
149
228
|
* @param {boolean} [config.keyword_heuristic]
|
|
150
229
|
* @param {boolean} [config.p3_same_file_defer]
|
|
230
|
+
* @param {number|string} [config.max_amplification]
|
|
231
|
+
* @param {number|string} [config.convergence_window]
|
|
151
232
|
* @param {number|string} [legacyTokenBudgetPerBatch] - Deprecated-alias fallback:
|
|
152
233
|
* `pipeline.token_budget_per_batch`, read when `config.token_budget` is absent
|
|
153
234
|
* so existing configs keep working unchanged (see forge#1858).
|
|
@@ -173,6 +254,27 @@ export function resolveCascadePolicy(config = {}, legacyTokenBudgetPerBatch) {
|
|
|
173
254
|
const maxGen = parseIntOrUnlimited(config.max_generation, preset.maxGeneration);
|
|
174
255
|
if (maxGen.warning) warnings.push(`orchestration.cascade.max_generation ${maxGen.warning}`);
|
|
175
256
|
|
|
257
|
+
// Unlike explicit Phase 1 admission, automated batching must retain a finite
|
|
258
|
+
// recursion bound even under policy: all. Do not accept the unlimited sentinel.
|
|
259
|
+
const rawBatchMaxGeneration = config.batch_max_generation;
|
|
260
|
+
const parsedBatchMaxGeneration = Number(rawBatchMaxGeneration);
|
|
261
|
+
const batchMaxGeneration =
|
|
262
|
+
rawBatchMaxGeneration === undefined || rawBatchMaxGeneration === null || rawBatchMaxGeneration === ""
|
|
263
|
+
? preset.batchMaxGeneration
|
|
264
|
+
: Number.isInteger(parsedBatchMaxGeneration) && parsedBatchMaxGeneration > 0
|
|
265
|
+
? parsedBatchMaxGeneration
|
|
266
|
+
: preset.batchMaxGeneration;
|
|
267
|
+
if (
|
|
268
|
+
rawBatchMaxGeneration !== undefined &&
|
|
269
|
+
rawBatchMaxGeneration !== null &&
|
|
270
|
+
rawBatchMaxGeneration !== "" &&
|
|
271
|
+
!(Number.isInteger(parsedBatchMaxGeneration) && parsedBatchMaxGeneration > 0)
|
|
272
|
+
) {
|
|
273
|
+
warnings.push(
|
|
274
|
+
`orchestration.cascade.batch_max_generation is not a positive integer ("${rawBatchMaxGeneration}") — falling back to default ${preset.batchMaxGeneration}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
176
278
|
// token_budget precedence: orchestration.cascade.token_budget (new home) >
|
|
177
279
|
// pipeline.token_budget_per_batch (deprecated alias, forge#1858) > preset default.
|
|
178
280
|
// The legacy fallback is validated through parseIntOrUnlimited itself before use —
|
|
@@ -195,6 +297,16 @@ export function resolveCascadePolicy(config = {}, legacyTokenBudgetPerBatch) {
|
|
|
195
297
|
typeof config.keyword_heuristic === "boolean" ? config.keyword_heuristic : preset.keywordHeuristic;
|
|
196
298
|
const p3SameFileDefer =
|
|
197
299
|
typeof config.p3_same_file_defer === "boolean" ? config.p3_same_file_defer : preset.p3SameFileDefer;
|
|
300
|
+
const maxAmplification = parseOptionalPositiveNumber(config.max_amplification);
|
|
301
|
+
if (maxAmplification.warning) warnings.push(`orchestration.cascade.max_amplification ${maxAmplification.warning}`);
|
|
302
|
+
const convergenceWindow = parseIntOrUnlimited(config.convergence_window, 3);
|
|
303
|
+
if (convergenceWindow.warning || convergenceWindow.value === UNLIMITED) {
|
|
304
|
+
warnings.push(
|
|
305
|
+
convergenceWindow.warning
|
|
306
|
+
? `orchestration.cascade.convergence_window ${convergenceWindow.warning}`
|
|
307
|
+
: 'orchestration.cascade.convergence_window cannot be "unlimited" — falling back to default 3',
|
|
308
|
+
);
|
|
309
|
+
}
|
|
198
310
|
|
|
199
311
|
// Both-uncapped notice: neither generation depth nor token spend is bounded this
|
|
200
312
|
// run. This is never a preset default (no preset in CASCADE_PRESETS sets both to
|
|
@@ -213,10 +325,13 @@ export function resolveCascadePolicy(config = {}, legacyTokenBudgetPerBatch) {
|
|
|
213
325
|
return {
|
|
214
326
|
policy: {
|
|
215
327
|
maxGeneration: maxGen.value,
|
|
328
|
+
batchMaxGeneration,
|
|
216
329
|
tokenBudget: tokenBudget.value,
|
|
217
330
|
deferOnBatchGated,
|
|
218
331
|
keywordHeuristic,
|
|
219
332
|
p3SameFileDefer,
|
|
333
|
+
maxAmplification: maxAmplification.value,
|
|
334
|
+
convergenceWindow: convergenceWindow.value === UNLIMITED ? 3 : convergenceWindow.value,
|
|
220
335
|
},
|
|
221
336
|
policyName,
|
|
222
337
|
bothUncapped,
|
|
@@ -237,6 +352,18 @@ export function admitsGeneration(generation, policy) {
|
|
|
237
352
|
return generation <= policy.maxGeneration;
|
|
238
353
|
}
|
|
239
354
|
|
|
355
|
+
/**
|
|
356
|
+
* P3 batches may aggregate deferred findings only through this finite ceiling.
|
|
357
|
+
* The resulting batch must record its maximum member generation for auditability.
|
|
358
|
+
*
|
|
359
|
+
* @param {number} generation
|
|
360
|
+
* @param {CascadePolicy} policy
|
|
361
|
+
* @returns {boolean}
|
|
362
|
+
*/
|
|
363
|
+
export function admitsBatchGeneration(generation, policy) {
|
|
364
|
+
return generation <= policy.batchMaxGeneration;
|
|
365
|
+
}
|
|
366
|
+
|
|
240
367
|
/**
|
|
241
368
|
* @param {number} projectedSpend - BATCH_TOKEN_SPEND if this unit were admitted.
|
|
242
369
|
* @param {CascadePolicy} policy
|
|
@@ -247,6 +374,19 @@ export function admitsTokenSpend(projectedSpend, policy) {
|
|
|
247
374
|
return projectedSpend <= policy.tokenBudget;
|
|
248
375
|
}
|
|
249
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Compute the observable cascade amplification signal and opt-in bound state.
|
|
379
|
+
* The bound is deliberately not an admission decision: callers only apply it
|
|
380
|
+
* to same-lineage refinements, never to unrelated or high-value findings.
|
|
381
|
+
*/
|
|
382
|
+
export function evaluateAmplification(mergedUnits, findingsSpawned, policy) {
|
|
383
|
+
const ratio = mergedUnits > 0 ? findingsSpawned / mergedUnits : 0;
|
|
384
|
+
return {
|
|
385
|
+
ratio,
|
|
386
|
+
exceedsBound: policy.maxAmplification !== null && ratio > policy.maxAmplification,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
250
390
|
/**
|
|
251
391
|
* Evaluate the Step 4C rule chain for a single cascade-spawned finding.
|
|
252
392
|
* Mirrors `commands/orchestrate/phase-4-execution.md` Step 4C's "Evaluation
|
|
@@ -292,3 +432,98 @@ export function evaluateCascadeFinding(finding, policy) {
|
|
|
292
432
|
}
|
|
293
433
|
return { admit: true, reason: null };
|
|
294
434
|
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Deterministic P3 batching rules mirrored by the orchestration specs.
|
|
438
|
+
* Grouping order is deliberately strongest-context first: a finding can only
|
|
439
|
+
* belong to one group, so earlier rules claim it before broader rules run.
|
|
440
|
+
*/
|
|
441
|
+
export const P3_BATCHING_RULES = Object.freeze({
|
|
442
|
+
maxMembers: 8,
|
|
443
|
+
sameFileMinimum: 2,
|
|
444
|
+
sourcePrMinimum: 2,
|
|
445
|
+
defectClassMinimum: 2,
|
|
446
|
+
leafDirectoryMinimum: 3,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const HIGH_BLAST_RADIUS = /(?:^|\/)(?:\.env\.example|docker-compose[^/]*|compose[^/]*|index\.[^/]+|main\.[^/]+)$/i;
|
|
450
|
+
|
|
451
|
+
function priorityOf(finding) {
|
|
452
|
+
const labels = (finding.labels || []).map((label) => typeof label === "string" ? label : label?.name);
|
|
453
|
+
return labels.find((label) => /^priority:P[0-3]$/.test(label))?.slice(-2) || labels.find((label) => /^P[0-3]$/.test(label)) || "P3";
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Priority is urgency, not risk: only P0/P1 stay individual for latency. */
|
|
457
|
+
export function batchExclusionReason(finding, dangerZones = []) {
|
|
458
|
+
if (["P0", "P1"].includes(priorityOf(finding))) return "urgency";
|
|
459
|
+
const file = finding.affectedFile || "";
|
|
460
|
+
if (/^infra\/migrations\/.*credit_balance/i.test(file) || /^services\/api\/app\/billing\//i.test(file)) return "domain";
|
|
461
|
+
if (dangerZones.some((zone) => file === zone || file.startsWith(`${zone}/`))) return "domain";
|
|
462
|
+
if (/\/migrations?\//i.test(file) || HIGH_BLAST_RADIUS.test(file)) return "high-blast-radius";
|
|
463
|
+
return classifyBatchSafety(`${finding.title || ""}\n${finding.body || ""}`) === "billing" ? "domain" : null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function sourcePr(body = "") {
|
|
467
|
+
return body.match(/^\*\*Source\*\*: PR #(\d+)\b/m)?.[1] || null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function defectClass(body = "") {
|
|
471
|
+
return body.match(/<!-- FORGE:CLASS: ([a-z0-9]+(?:-[a-z0-9]+)*) -->/)?.[1] || null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function leafDirectory(file = "") {
|
|
475
|
+
const slash = file.lastIndexOf("/");
|
|
476
|
+
return slash > 0 ? file.slice(0, slash) : null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Plan P3 batches from already safety-filtered, open, unbatched, undispatched
|
|
481
|
+
* findings. The caller executes these groups and preserves unclaimed findings
|
|
482
|
+
* for a later full-queue sweep.
|
|
483
|
+
*/
|
|
484
|
+
export function planP3BatchGroups(findings, rules = P3_BATCHING_RULES, { openBatches = [], dangerZones = [] } = {}) {
|
|
485
|
+
const remaining = new Map(
|
|
486
|
+
findings
|
|
487
|
+
.filter((finding) => finding?.number && finding.affectedFile && !batchExclusionReason(finding, dangerZones))
|
|
488
|
+
.map((finding) => [finding.number, finding]),
|
|
489
|
+
);
|
|
490
|
+
const groups = [];
|
|
491
|
+
const extensions = [];
|
|
492
|
+
|
|
493
|
+
for (const batch of openBatches) {
|
|
494
|
+
const key = batch.affectedFile || batch.key;
|
|
495
|
+
const headroom = rules.maxMembers - (batch.memberCount ?? batch.members?.length ?? 0);
|
|
496
|
+
if (!key || headroom <= 0) continue;
|
|
497
|
+
for (const member of batch.members || []) remaining.delete(typeof member === "object" ? member.number : member);
|
|
498
|
+
const members = [...remaining.values()].filter((finding) => finding.affectedFile === key).slice(0, headroom);
|
|
499
|
+
if (members.length) {
|
|
500
|
+
extensions.push({ batch: batch.number, key, members: members.map((finding) => finding.number) });
|
|
501
|
+
members.forEach((finding) => remaining.delete(finding.number));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const claim = (kind, key, minimum) => {
|
|
506
|
+
const byKey = new Map();
|
|
507
|
+
for (const finding of remaining.values()) {
|
|
508
|
+
const groupKey = key(finding);
|
|
509
|
+
if (!groupKey) continue;
|
|
510
|
+
const members = byKey.get(groupKey) || [];
|
|
511
|
+
members.push(finding);
|
|
512
|
+
byKey.set(groupKey, members);
|
|
513
|
+
}
|
|
514
|
+
for (const [groupKey, members] of [...byKey].sort(([a], [b]) => a.localeCompare(b))) {
|
|
515
|
+
while (members.length >= minimum) {
|
|
516
|
+
const chunk = members.splice(0, rules.maxMembers);
|
|
517
|
+
groups.push({ kind, key: groupKey, members: chunk.map((finding) => finding.number) });
|
|
518
|
+
chunk.forEach((finding) => remaining.delete(finding.number));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
claim("same-file", (finding) => finding.affectedFile, rules.sameFileMinimum);
|
|
524
|
+
claim("source-pr", (finding) => sourcePr(finding.body), rules.sourcePrMinimum);
|
|
525
|
+
claim("defect-class", (finding) => defectClass(finding.body), rules.defectClassMinimum);
|
|
526
|
+
claim("leaf-directory", (finding) => leafDirectory(finding.affectedFile), rules.leafDirectoryMinimum);
|
|
527
|
+
|
|
528
|
+
return { groups, extensions, ungrouped: [...remaining.keys()] };
|
|
529
|
+
}
|
|
@@ -5,12 +5,73 @@ import {
|
|
|
5
5
|
CASCADE_PRESETS,
|
|
6
6
|
DEFAULT_CASCADE_POLICY_NAME,
|
|
7
7
|
parseIntOrUnlimited,
|
|
8
|
+
parseOptionalPositiveNumber,
|
|
8
9
|
resolveCascadePolicy,
|
|
9
10
|
admitsGeneration,
|
|
11
|
+
admitsBatchGeneration,
|
|
10
12
|
admitsTokenSpend,
|
|
11
13
|
evaluateCascadeFinding,
|
|
14
|
+
classifyBatchSafety,
|
|
15
|
+
evaluateAmplification,
|
|
16
|
+
batchExclusionReason,
|
|
17
|
+
planP3BatchGroups,
|
|
18
|
+
canDeduplicateAutomatedAlert,
|
|
12
19
|
} from "./admission.mjs";
|
|
13
20
|
|
|
21
|
+
describe("classifyBatchSafety", () => {
|
|
22
|
+
it("classifies the six security findings that previously evaded batching exclusion", () => {
|
|
23
|
+
const fixtures = [
|
|
24
|
+
["MaintenanceAuth alias bypasses write rate limit", "auth"],
|
|
25
|
+
["nested bash -c double substitution permits command injection", "injection"],
|
|
26
|
+
["CTA href needs scheme validation to reject javascript URIs", "scheme"],
|
|
27
|
+
["Discord markdown code-fence injection from log samples", "injection"],
|
|
28
|
+
["redact raw psql DETAIL and CONTEXT on migration failure", "redaction"],
|
|
29
|
+
["PGPASSWORD interpolated into a SQL literal", "credential"],
|
|
30
|
+
];
|
|
31
|
+
for (const [text, expected] of fixtures) {
|
|
32
|
+
assert.equal(classifyBatchSafety(text), expected, text);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("keeps the documented false positives batchable while matching identifier auth", () => {
|
|
37
|
+
assert.equal(classifyBatchSafety("authority_source docstring fix"), null);
|
|
38
|
+
assert.equal(classifyBatchSafety("**Agent**: Security\n## Problem\nstale docstring count"), null);
|
|
39
|
+
assert.equal(classifyBatchSafety("AdminAuth and authz_check must reject bypasses"), "auth");
|
|
40
|
+
assert.equal(classifyBatchSafety("injection\n<!-- FORGE:CLASS: shell-hardening -->"), "shell-hardening");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("canDeduplicateAutomatedAlert", () => {
|
|
45
|
+
const canonical = {
|
|
46
|
+
authorType: "Bot",
|
|
47
|
+
authorLogin: "github-actions[bot]",
|
|
48
|
+
title: "Backup restore drill failed",
|
|
49
|
+
generator: "backup-restore-drill.yml",
|
|
50
|
+
trigger: "corrupt-backup-fixture-v1",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
it("permits only byte-for-byte equivalent machine alerts after title normalization", () => {
|
|
54
|
+
assert.equal(
|
|
55
|
+
canDeduplicateAutomatedAlert(canonical, {
|
|
56
|
+
...canonical,
|
|
57
|
+
authorLogin: "app[bot]",
|
|
58
|
+
title: " backup restore drill FAILED ",
|
|
59
|
+
}),
|
|
60
|
+
true,
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("rejects human reports and differing generators or triggers", () => {
|
|
65
|
+
assert.equal(
|
|
66
|
+
canDeduplicateAutomatedAlert(canonical, { ...canonical, authorType: "User", authorLogin: "person" }),
|
|
67
|
+
false,
|
|
68
|
+
);
|
|
69
|
+
assert.equal(canDeduplicateAutomatedAlert(canonical, { ...canonical, generator: "other.yml" }), false);
|
|
70
|
+
assert.equal(canDeduplicateAutomatedAlert(canonical, { ...canonical, trigger: "other-fixture" }), false);
|
|
71
|
+
assert.equal(canDeduplicateAutomatedAlert(canonical, { ...canonical, generator: undefined }), false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
14
75
|
describe("parseIntOrUnlimited", () => {
|
|
15
76
|
it("parses a positive integer", () => {
|
|
16
77
|
assert.deepEqual(parseIntOrUnlimited("3", 1), { value: 3, warning: null });
|
|
@@ -39,17 +100,36 @@ describe("parseIntOrUnlimited", () => {
|
|
|
39
100
|
});
|
|
40
101
|
});
|
|
41
102
|
|
|
103
|
+
describe("parseOptionalPositiveNumber", () => {
|
|
104
|
+
it("defaults to disabled and accepts a positive decimal", () => {
|
|
105
|
+
assert.deepEqual(parseOptionalPositiveNumber(undefined), { value: null, warning: null });
|
|
106
|
+
assert.deepEqual(parseOptionalPositiveNumber("off"), { value: null, warning: null });
|
|
107
|
+
assert.deepEqual(parseOptionalPositiveNumber("1.5"), { value: 1.5, warning: null });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("disables invalid values with a warning", () => {
|
|
111
|
+
const result = parseOptionalPositiveNumber("0");
|
|
112
|
+
assert.equal(result.value, null);
|
|
113
|
+
assert.match(result.warning, /disabling the bound/);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
42
117
|
describe("resolveCascadePolicy — presets", () => {
|
|
43
118
|
it("defaults to balanced when no config is given (no-op, matches pre-#2234 hardcoded behavior)", () => {
|
|
44
119
|
const { policy, policyName, warnings } = resolveCascadePolicy();
|
|
45
120
|
assert.equal(policyName, DEFAULT_CASCADE_POLICY_NAME);
|
|
46
|
-
assert.deepEqual(policy,
|
|
121
|
+
assert.deepEqual(policy, {
|
|
122
|
+
...CASCADE_PRESETS.balanced,
|
|
123
|
+
maxAmplification: null,
|
|
124
|
+
convergenceWindow: 3,
|
|
125
|
+
});
|
|
47
126
|
assert.deepEqual(warnings, []);
|
|
48
127
|
});
|
|
49
128
|
|
|
50
129
|
it("policy: all removes both caps and disables every heuristic", () => {
|
|
51
130
|
const { policy, warnings } = resolveCascadePolicy({ policy: "all" });
|
|
52
131
|
assert.equal(policy.maxGeneration, UNLIMITED);
|
|
132
|
+
assert.equal(policy.batchMaxGeneration, 2);
|
|
53
133
|
assert.equal(policy.tokenBudget, UNLIMITED);
|
|
54
134
|
assert.equal(policy.deferOnBatchGated, false);
|
|
55
135
|
assert.equal(policy.keywordHeuristic, false);
|
|
@@ -65,6 +145,7 @@ describe("resolveCascadePolicy — presets", () => {
|
|
|
65
145
|
it("policy: conservative keeps balanced's shape but lowers the token budget", () => {
|
|
66
146
|
const { policy } = resolveCascadePolicy({ policy: "conservative" });
|
|
67
147
|
assert.equal(policy.maxGeneration, 1);
|
|
148
|
+
assert.equal(policy.batchMaxGeneration, 2);
|
|
68
149
|
assert.equal(policy.tokenBudget, 450000);
|
|
69
150
|
assert.equal(policy.deferOnBatchGated, true);
|
|
70
151
|
});
|
|
@@ -72,7 +153,11 @@ describe("resolveCascadePolicy — presets", () => {
|
|
|
72
153
|
it("unrecognized policy name falls back to balanced with a warning", () => {
|
|
73
154
|
const { policy, policyName, warnings } = resolveCascadePolicy({ policy: "yolo" });
|
|
74
155
|
assert.equal(policyName, "balanced");
|
|
75
|
-
assert.deepEqual(policy,
|
|
156
|
+
assert.deepEqual(policy, {
|
|
157
|
+
...CASCADE_PRESETS.balanced,
|
|
158
|
+
maxAmplification: null,
|
|
159
|
+
convergenceWindow: 3,
|
|
160
|
+
});
|
|
76
161
|
assert.equal(warnings.length, 1);
|
|
77
162
|
assert.match(warnings[0], /not one of/);
|
|
78
163
|
});
|
|
@@ -87,6 +172,8 @@ describe("resolveCascadePolicy — granular overrides compose with a preset", ()
|
|
|
87
172
|
assert.equal(policy.deferOnBatchGated, true);
|
|
88
173
|
assert.equal(policy.keywordHeuristic, true);
|
|
89
174
|
assert.equal(policy.p3SameFileDefer, true);
|
|
175
|
+
assert.equal(policy.maxAmplification, null);
|
|
176
|
+
assert.equal(policy.convergenceWindow, 3);
|
|
90
177
|
});
|
|
91
178
|
|
|
92
179
|
it("granular boolean overrides on top of the all preset", () => {
|
|
@@ -104,6 +191,32 @@ describe("resolveCascadePolicy — granular overrides compose with a preset", ()
|
|
|
104
191
|
assert.equal(admitsGeneration(3, policy), true);
|
|
105
192
|
assert.equal(admitsGeneration(4, policy), false);
|
|
106
193
|
});
|
|
194
|
+
|
|
195
|
+
it("keeps batching finite even when explicit cascade admission is unlimited", () => {
|
|
196
|
+
const { policy } = resolveCascadePolicy({ policy: "all" });
|
|
197
|
+
assert.equal(admitsBatchGeneration(2, policy), true);
|
|
198
|
+
assert.equal(admitsBatchGeneration(3, policy), false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("allows a repository to set a different finite batching ceiling", () => {
|
|
202
|
+
const { policy } = resolveCascadePolicy({ batch_max_generation: 3 });
|
|
203
|
+
assert.equal(policy.batchMaxGeneration, 3);
|
|
204
|
+
assert.equal(admitsBatchGeneration(3, policy), true);
|
|
205
|
+
assert.equal(admitsBatchGeneration(4, policy), false);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("evaluateAmplification", () => {
|
|
210
|
+
it("reports the running ratio without bounding the default policy", () => {
|
|
211
|
+
const { policy } = resolveCascadePolicy();
|
|
212
|
+
assert.deepEqual(evaluateAmplification(2, 3, policy), { ratio: 1.5, exceedsBound: false });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("flags an opt-in bound only after the ratio exceeds it", () => {
|
|
216
|
+
const { policy } = resolveCascadePolicy({ max_amplification: 1 });
|
|
217
|
+
assert.equal(evaluateAmplification(2, 2, policy).exceedsBound, false);
|
|
218
|
+
assert.equal(evaluateAmplification(2, 3, policy).exceedsBound, true);
|
|
219
|
+
});
|
|
107
220
|
});
|
|
108
221
|
|
|
109
222
|
describe("resolveCascadePolicy — token_budget deprecated-alias fallback", () => {
|
|
@@ -263,3 +376,71 @@ describe("evaluateCascadeFinding — Step 4C rule-chain parity", () => {
|
|
|
263
376
|
assert.match(result.reason, /token budget exhausted/);
|
|
264
377
|
});
|
|
265
378
|
});
|
|
379
|
+
|
|
380
|
+
describe("planP3BatchGroups — concern-level P3 batching", () => {
|
|
381
|
+
const finding = (number, affectedFile, body = "") => ({ number, affectedFile, body });
|
|
382
|
+
|
|
383
|
+
it("keeps same-file grouping ahead of every broader key", () => {
|
|
384
|
+
const plan = planP3BatchGroups([
|
|
385
|
+
finding(1, "infra/monitoring/a.yml", "**Source**: PR #42"),
|
|
386
|
+
finding(2, "infra/monitoring/a.yml", "**Source**: PR #42"),
|
|
387
|
+
finding(3, "infra/monitoring/b.yml", "**Source**: PR #42"),
|
|
388
|
+
]);
|
|
389
|
+
assert.deepEqual(plan.groups, [
|
|
390
|
+
{ kind: "same-file", key: "infra/monitoring/a.yml", members: [1, 2] },
|
|
391
|
+
]);
|
|
392
|
+
assert.deepEqual(plan.ungrouped, [3]);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("groups all remaining findings from one source PR cohort", () => {
|
|
396
|
+
const plan = planP3BatchGroups([
|
|
397
|
+
finding(1, "infra/monitoring/a.yml", "**Source**: PR #42"),
|
|
398
|
+
finding(2, "infra/monitoring/b.yml", "**Source**: PR #42"),
|
|
399
|
+
finding(3, "scripts/a.sh", "**Source**: PR #42"),
|
|
400
|
+
finding(4, "README.md", "**Source**: PR #42"),
|
|
401
|
+
]);
|
|
402
|
+
assert.deepEqual(plan.groups, [
|
|
403
|
+
{ kind: "source-pr", key: "42", members: [1, 2, 3, 4] },
|
|
404
|
+
]);
|
|
405
|
+
assert.deepEqual(plan.ungrouped, []);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("groups explicit defect classes across files", () => {
|
|
409
|
+
const plan = planP3BatchGroups([
|
|
410
|
+
finding(1, "infra/monitoring/a.yml", "<!-- FORGE:CLASS: fail-loud-check -->"),
|
|
411
|
+
finding(2, "scripts/check.sh", "<!-- FORGE:CLASS: fail-loud-check -->"),
|
|
412
|
+
]);
|
|
413
|
+
assert.deepEqual(plan.groups, [
|
|
414
|
+
{ kind: "defect-class", key: "fail-loud-check", members: [1, 2] },
|
|
415
|
+
]);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("lowers leaf-directory grouping to three and caps batches at eight", () => {
|
|
419
|
+
const three = planP3BatchGroups([
|
|
420
|
+
finding(1, "scripts/a.sh"), finding(2, "scripts/b.sh"), finding(3, "scripts/c.sh"),
|
|
421
|
+
]);
|
|
422
|
+
assert.deepEqual(three.groups, [{ kind: "leaf-directory", key: "scripts", members: [1, 2, 3] }]);
|
|
423
|
+
|
|
424
|
+
const nine = planP3BatchGroups(Array.from({ length: 9 }, (_, index) => finding(index + 1, `scripts/${index}.sh`)));
|
|
425
|
+
assert.deepEqual(nine.groups, [{ kind: "leaf-directory", key: "scripts", members: [1, 2, 3, 4, 5, 6, 7, 8] }]);
|
|
426
|
+
assert.deepEqual(nine.ungrouped, [9]);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("extends compatible open batches before creating another one", () => {
|
|
430
|
+
const plan = planP3BatchGroups(
|
|
431
|
+
[finding(1, "scripts/a.sh"), finding(2, "scripts/a.sh")],
|
|
432
|
+
undefined,
|
|
433
|
+
{ openBatches: [{ number: 99, affectedFile: "scripts/a.sh", members: [1], memberCount: 7 }] },
|
|
434
|
+
);
|
|
435
|
+
assert.deepEqual(plan.extensions, [{ batch: 99, key: "scripts/a.sh", members: [2] }]);
|
|
436
|
+
assert.deepEqual(plan.groups, []);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("uses urgency and path risk instead of excluding every P2 finding", () => {
|
|
440
|
+
assert.equal(batchExclusionReason({ labels: ["priority:P2"], affectedFile: "scripts/a.sh" }), null);
|
|
441
|
+
assert.equal(batchExclusionReason({ labels: ["priority:P1"], affectedFile: "scripts/a.sh" }), "urgency");
|
|
442
|
+
assert.equal(batchExclusionReason({ labels: ["priority:P2"], affectedFile: "infra/migrations/0333_credit_balance.sql" }), "domain");
|
|
443
|
+
assert.equal(batchExclusionReason({ labels: ["priority:P2"], affectedFile: "services/api/app/billing/charge.py" }), "domain");
|
|
444
|
+
assert.equal(batchExclusionReason({ labels: ["priority:P2"], affectedFile: ".env.example" }), "high-blast-radius");
|
|
445
|
+
});
|
|
446
|
+
});
|