@dev-loops/core 1.0.0-rc.6 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/package.json +8 -1
  2. package/src/analysis/change-classifier.mjs +10 -0
  3. package/src/analysis/diff-analyzer.mjs +68 -1
  4. package/src/claude/hook-decisions.mjs +36 -4
  5. package/src/cli/primitives.mjs +30 -1
  6. package/src/config/config.mjs +254 -13
  7. package/src/config/extension-defaults.yaml +34 -1
  8. package/src/github/comment-id-guard.mjs +97 -9
  9. package/src/github/copilot-helpers.mjs +114 -5
  10. package/src/github/gh.mjs +94 -0
  11. package/src/github/issue-ops.mjs +7 -0
  12. package/src/loop/agent-stall.mjs +4 -2
  13. package/src/loop/commit-msg-guard.mjs +168 -0
  14. package/src/loop/copilot-loop-iterations.mjs +2 -1
  15. package/src/loop/default-branch-guard.mjs +34 -1
  16. package/src/loop/gate-carry-forward.mjs +19 -6
  17. package/src/loop/gate-fanin.mjs +190 -29
  18. package/src/loop/handoff-envelope.mjs +12 -19
  19. package/src/loop/issue-refinement-artifact.mjs +186 -42
  20. package/src/loop/lifecycle-state.mjs +21 -2
  21. package/src/loop/main-checkout-ff.mjs +34 -0
  22. package/src/loop/markdown-sections.mjs +40 -0
  23. package/src/loop/normalize.mjs +7 -0
  24. package/src/loop/plan-file-promote-contract.mjs +14 -1
  25. package/src/loop/plan-file-refine-contract.mjs +92 -8
  26. package/src/loop/policy-constants.mjs +9 -0
  27. package/src/loop/pr-gate-coordination.mjs +65 -12
  28. package/src/loop/public-dev-loop-routing.mjs +11 -15
  29. package/src/loop/queue-board-sync.mjs +1 -26
  30. package/src/loop/queue-driver.mjs +14 -1
  31. package/src/loop/refinement-grill-state.mjs +3 -5
  32. package/src/loop/retrospective-checkpoint.mjs +59 -1
  33. package/src/loop/review-dispatch-plan.mjs +448 -9
  34. package/src/loop/reviewer-loop-state.mjs +8 -13
  35. package/src/loop/run-post-merge-actions.mjs +148 -0
  36. package/src/loop/size-budget-merge-gate.mjs +121 -0
  37. package/src/loop/tracker-pr-state.mjs +5 -15
  38. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  39. package/src/loop/ui-review-drive.mjs +3 -1
  40. package/src/loop/ui-review-report.mjs +2 -5
  41. package/src/loop/ui-review-teardown.mjs +3 -1
  42. package/src/projects/list-queue-items.mjs +1 -27
  43. package/src/projects/move-queue-item.mjs +2 -28
  44. package/src/security/secret-scan.mjs +330 -0
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Fail-closed secret scan over the ADDED lines of a unified git diff.
3
+ *
4
+ * No external dependency (no gitleaks/trufflehog): this module is a small,
5
+ * self-contained detector set, deliberately kept auditable rather than
6
+ * outsourced. It is deterministic and side-effect free — it never reads or
7
+ * writes anything itself; callers (the CLI, the git hook) supply the diff
8
+ * text and act on the result.
9
+ *
10
+ * Three detector classes (see DETECTOR_CLASSES):
11
+ * - literal-credential: a known provider token PREFIX (ghp_, xoxb-, AKIA...,
12
+ * a PEM private-key header, ...), literal OR base64-encoded.
13
+ * - high-entropy: a long single-token run whose Shannon entropy is above a
14
+ * tuned threshold — catches a credential with no recognized prefix.
15
+ * - sink-pattern: a secret-NAMED variable (*TOKEN*, *SECRET*, ...) and an
16
+ * output sink (echo/printf/tee/base64/redirect/workflow `::` directive)
17
+ * on the SAME line — no literal secret value has to be present in the diff
18
+ * for this to fire; it is what catches a secret-named var piped into a
19
+ * logged/echoed stream.
20
+ *
21
+ * A finding NEVER carries the matched value — only file/line/detector-class
22
+ * and a canned, value-free reason string. A secret, once flagged, is treated
23
+ * as unrecoverable: there is no "show me the match" affordance anywhere in
24
+ * this module.
25
+ *
26
+ * Allowlisting: an inline `secret-scan:allow` marker plus a trailing reason
27
+ * (comment syntax is irrelevant — this is a plain substring test) on the SAME
28
+ * line exempts that one line from every detector. There is no global disable
29
+ * and no central baseline file — every exemption is visible in the diff it
30
+ * exempts.
31
+ */
32
+
33
+ export const DETECTOR_CLASSES = Object.freeze({
34
+ LITERAL_CREDENTIAL: "literal-credential",
35
+ HIGH_ENTROPY: "high-entropy",
36
+ SINK_PATTERN: "sink-pattern",
37
+ });
38
+
39
+ export const ALLOW_MARKER = "secret-scan:allow";
40
+ const ALLOW_RE = /secret-scan:allow\s+(\S.*?)\s*$/u;
41
+
42
+ /**
43
+ * Known provider token/credential PREFIX formats. Each pattern requires the
44
+ * full token shape (prefix + a plausible body length), not just the prefix
45
+ * alone — a bare mention of a prefix constant (as in this module's own
46
+ * source) is short of the required body length and never self-matches.
47
+ */
48
+ const LITERAL_PATTERNS = [
49
+ { name: "github-pat-classic", re: /\bghp_[A-Za-z0-9]{20,}\b/ },
50
+ { name: "github-pat-fine-grained", re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/ },
51
+ { name: "github-oauth-token", re: /\bgho_[A-Za-z0-9]{20,}\b/ },
52
+ { name: "github-user-to-server-token", re: /\bghu_[A-Za-z0-9]{20,}\b/ },
53
+ { name: "github-server-to-server-token", re: /\bghs_[A-Za-z0-9]{20,}\b/ },
54
+ { name: "github-refresh-token", re: /\bghr_[A-Za-z0-9]{20,}\b/ },
55
+ { name: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
56
+ { name: "aws-access-key-id", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
57
+ { name: "pem-private-key", re: /-----BEGIN [A-Z0-9]+(?: [A-Z0-9]+)* PRIVATE KEY-----/ },
58
+ ];
59
+
60
+ /** True when `text` matches any known literal credential prefix format. */
61
+ function matchLiteralPattern(text) {
62
+ for (const pattern of LITERAL_PATTERNS) {
63
+ if (pattern.re.test(text)) return pattern.name;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ // A base64-shaped run long enough to plausibly carry an encoded credential.
69
+ // `g` (global) so every candidate run on the line is checked, not just the
70
+ // first.
71
+ const BASE64_CANDIDATE_RE = /[A-Za-z0-9+/]{20,}={0,2}/gu;
72
+
73
+ /**
74
+ * Whether `text` contains a base64-shaped substring that DECODES to a known
75
+ * literal credential format — the "value stored encoded" case a plain
76
+ * literal-prefix scan over the RAW diff text would miss.
77
+ */
78
+ function matchBase64Credential(text) {
79
+ const candidates = text.match(BASE64_CANDIDATE_RE) ?? [];
80
+ for (const candidate of candidates) {
81
+ let decoded;
82
+ try {
83
+ decoded = Buffer.from(candidate, "base64").toString("utf8");
84
+ } catch {
85
+ continue;
86
+ }
87
+ const hit = matchLiteralPattern(decoded);
88
+ if (hit) return hit;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Shannon entropy in bits/character. Pure math, no I/O — exported so the
95
+ * threshold this module tunes is independently testable.
96
+ * @param {string} str
97
+ * @returns {number}
98
+ */
99
+ export function shannonEntropy(str) {
100
+ if (str.length === 0) return 0;
101
+ const counts = new Map();
102
+ for (const ch of str) counts.set(ch, (counts.get(ch) ?? 0) + 1);
103
+ let entropy = 0;
104
+ for (const count of counts.values()) {
105
+ const p = count / str.length;
106
+ entropy -= p * Math.log2(p);
107
+ }
108
+ return entropy;
109
+ }
110
+
111
+ // A candidate is a single unbroken run (no whitespace, no `=`) of the
112
+ // characters a token/secret literal is typically made of. `=` is
113
+ // deliberately EXCLUDED from the run (unlike the base64-candidate pattern
114
+ // above, which keeps it for trailing padding): an ordinary `KEY=value`
115
+ // shell/env assignment is otherwise read as one inflated, higher-entropy
116
+ // candidate spanning both sides of the `=` — splitting there scores the key
117
+ // and the value separately, each far more likely to fall under the length or
118
+ // digit/letter floor below on its own. Tuned length/threshold: 20 chars is
119
+ // short enough to catch a real secret, long enough that ordinary identifiers
120
+ // rarely reach it; 4.3 bits/char sits between plain lowercase English (~4.0
121
+ // max, usually much lower) and true base64/hex randomness (~4.5-6). Both
122
+ // digit AND letter are required so a long plain word (no digits) never
123
+ // qualifies — false positives here cost only an allowlist line; a missed
124
+ // real secret costs a leak, so the length/threshold pair leans toward
125
+ // catching more, not fewer.
126
+ const ENTROPY_CANDIDATE_RE = /[A-Za-z0-9+/_.-]{20,}/gu;
127
+ const ENTROPY_MIN_LENGTH = 20;
128
+ const ENTROPY_THRESHOLD = 4.3;
129
+
130
+ function hasHighEntropyToken(text) {
131
+ const candidates = text.match(ENTROPY_CANDIDATE_RE) ?? [];
132
+ for (const candidate of candidates) {
133
+ if (candidate.length < ENTROPY_MIN_LENGTH) continue;
134
+ if (!/[0-9]/.test(candidate) || !/[A-Za-z]/.test(candidate)) continue;
135
+ if (shannonEntropy(candidate) >= ENTROPY_THRESHOLD) return true;
136
+ }
137
+ return false;
138
+ }
139
+
140
+ // A variable/env-key NAME that reads as a secret. Substring match for
141
+ // TOKEN/SECRET/PASSWORD(/PASSWD) (`API_TOKEN`, `authToken`, `DB_PASSWORD`,
142
+ // ...), suffix match for `_PAT`/`_KEY` (deliberately a suffix, not a
143
+ // substring — "_KEY" alone would otherwise fire on ordinary words like
144
+ // "keyboard"), plus the literal incident-motivating name. Tested per
145
+ // IDENTIFIER, not per whole line — see hasSecretNamedIdentifier.
146
+ const SECRET_KEYWORD_RE = /(?:TOKEN|SECRET|PASSWORD|PASSWD)|_PAT$|_KEY$/iu;
147
+ const IDENTIFIER_RE = /[A-Za-z_][A-Za-z0-9_]*/gu;
148
+
149
+ /**
150
+ * True when `identifier` (an already-extracted `[A-Za-z_][A-Za-z0-9_]*` run)
151
+ * is SHAPED like a real variable/env-key name rather than a plain English
152
+ * word that merely contains a secret keyword as a substring: it has an
153
+ * underscore (snake_case / SCREAMING_SNAKE env-var shape, e.g.
154
+ * `GITHUB_TOKEN`, `foo_api_key`), mixes upper and lower case
155
+ * (camelCase/PascalCase, e.g. `authToken`), or is entirely uppercase letters
156
+ * (a bare ALL-CAPS identifier, e.g. `SECRET` used standalone). A plain
157
+ * single-case word with no underscore — "token", "secretary", "tokenize" —
158
+ * is not identifier-shaped, even though it may contain a secret keyword as a
159
+ * substring: "token-economical" and a bare "token" in prose don't qualify.
160
+ */
161
+ function isIdentifierShaped(identifier) {
162
+ if (identifier.includes("_")) return true;
163
+ const hasUpper = /[A-Z]/u.test(identifier);
164
+ const hasLower = /[a-z]/u.test(identifier);
165
+ if (hasUpper && hasLower) return true; // camelCase / PascalCase
166
+ return hasUpper && !hasLower; // bare ALL-CAPS identifier (e.g. `SECRET`)
167
+ }
168
+
169
+ function hasSecretNamedIdentifier(text) {
170
+ for (const identifier of text.match(IDENTIFIER_RE) ?? []) {
171
+ if (identifier.toUpperCase() === "BUNDLE_GITHUB__COM") return true;
172
+ if (SECRET_KEYWORD_RE.test(identifier) && isIdentifierShaped(identifier)) return true;
173
+ }
174
+ return false;
175
+ }
176
+
177
+ // An angle-bracket PLACEHOLDER or generic type — `<owner/name>`, `<x>`, a
178
+ // `Map` of two type parameters where the second is a credential-named type —
179
+ // whose trailing angle bracket is not a shell redirect. The content charset
180
+ // is deliberately narrow (identifier/path-ish chars only: letters, digits,
181
+ // `_ , . / : -` and interior spaces) and must both START and END on an
182
+ // identifier/path char (never on a space): this is what keeps a REAL
183
+ // redirect pair like `<input.txt >output.txt` (space right before the
184
+ // trailing bracket) from being misread as one placeholder spanning both —
185
+ // the bracket-strip below would otherwise swallow a genuine redirect target.
186
+ // Applied in a loop so nested placeholders (two levels of generic type
187
+ // parameters) are fully stripped, innermost first.
188
+ const ANGLE_PLACEHOLDER_RE = /<[A-Za-z0-9_](?:[A-Za-z0-9_ ,./:-]*[A-Za-z0-9_.])?>/gu;
189
+
190
+ function stripAnglePlaceholders(text) {
191
+ let stripped = text;
192
+ let previous;
193
+ do {
194
+ previous = stripped;
195
+ stripped = stripped.replace(ANGLE_PLACEHOLDER_RE, "");
196
+ } while (stripped !== previous);
197
+ return stripped;
198
+ }
199
+
200
+ // Output sinks named in the motivating incident: echo/printf/tee, base64
201
+ // (encode-then-emit), a `>`/`>>` redirect, and a GitHub Actions workflow
202
+ // `::directive::`. This is deliberately a same-line, no-literal-value-
203
+ // required class, so a variable name reaching a sink is enough on its own.
204
+ // A workflow mask directive next to the sink call does not change the
205
+ // verdict: masking only redacts what CI's log renderer shows LATER, so a
206
+ // value still reaches this stream unmasked, and the flow is still a hit.
207
+ // The redirect branch excludes `=>`/`->`/`>=` (lookbehind/lookahead around the
208
+ // bare `>`/`>>`) — those are an arrow function or a comparison in ordinary
209
+ // source, not a shell redirect, and would otherwise fire on nearly any JS/TS
210
+ // line that also happens to name a *TOKEN*/*SECRET*/... variable — and
211
+ // requires a plausible target to follow (`> file`, `>>out.log`,
212
+ // `>/dev/null`), never a bare `>` with nothing after it. Run against the
213
+ // angle-placeholder-stripped text (see stripAnglePlaceholders) so a `>` that
214
+ // merely closes a `<owner/name>` placeholder or a `Map<string, AuthToken>`
215
+ // generic is never read as a redirect.
216
+ const SINK_RE = /\b(?:echo|printf|tee|base64)\b|(?<![=-])>{1,2}(?!=)(?=\s*\S)|::[A-Za-z][\w-]*::/u;
217
+
218
+ function hasSecretNameToSinkFlow(text) {
219
+ return hasSecretNamedIdentifier(text) && SINK_RE.test(stripAnglePlaceholders(text));
220
+ }
221
+
222
+ /**
223
+ * The reason a matched line is allowlisted, or `null` when it is not. Any
224
+ * comment syntax works (`#`, `//`, `<!--`, ...): this is a plain substring
225
+ * test, not a language-aware parse — the `secret-scan:allow` marker plus a
226
+ * trailing reason exempts the ONE line it appears on, nothing else.
227
+ * @param {string} text
228
+ * @returns {string|null}
229
+ */
230
+ export function allowlistReason(text) {
231
+ const match = ALLOW_RE.exec(text);
232
+ return match ? match[1] : null;
233
+ }
234
+
235
+ /**
236
+ * Scan one added line's text against all three detector classes.
237
+ * @param {string} text
238
+ * @returns {{ detectorClass: string, reason: string }[]}
239
+ */
240
+ export function scanLineText(text) {
241
+ if (allowlistReason(text) !== null) return [];
242
+ const findings = [];
243
+ const literal = matchLiteralPattern(text) ?? matchBase64Credential(text);
244
+ if (literal) {
245
+ findings.push({
246
+ detectorClass: DETECTOR_CLASSES.LITERAL_CREDENTIAL,
247
+ reason: `matches known credential format (${literal})`,
248
+ });
249
+ }
250
+ if (hasHighEntropyToken(text)) {
251
+ findings.push({
252
+ detectorClass: DETECTOR_CLASSES.HIGH_ENTROPY,
253
+ reason: "high-entropy token-shaped literal",
254
+ });
255
+ }
256
+ if (hasSecretNameToSinkFlow(text)) {
257
+ findings.push({
258
+ detectorClass: DETECTOR_CLASSES.SINK_PATTERN,
259
+ reason: "secret-named variable flows into an output sink on this line",
260
+ });
261
+ }
262
+ return findings;
263
+ }
264
+
265
+ /**
266
+ * Parse a unified diff (`git diff --cached` output) into `{ file, line, text
267
+ * }` entries — one per ADDED line, `line` the new-file 1-based line number.
268
+ * Removed/context lines are skipped (but context lines still advance the
269
+ * new-file line counter). A binary-file diff (no `@@` hunk) yields no
270
+ * entries for that file.
271
+ * @param {string} diffText
272
+ * @returns {{ file: string, line: number, text: string }[]}
273
+ */
274
+ export function parseAddedLines(diffText) {
275
+ const entries = [];
276
+ let file = null;
277
+ let newLine = 0;
278
+ // `+++ `/`--- ` are STRUCTURAL file headers only before the first `@@`
279
+ // hunk of a file — inside a hunk, a line starting with `+` (even `++ ` or
280
+ // `--- `-shaped content) is added/removed CONTENT, never a header. Gate the
281
+ // header checks on hunk position (`inHunk`), not a naive prefix test alone,
282
+ // so a planted credential on a line whose content happens to start with
283
+ // `+ ` can never be misread as a header and skipped from every detector.
284
+ let inHunk = false;
285
+ for (const rawLine of diffText.split("\n")) {
286
+ if (rawLine.startsWith("diff --git ")) {
287
+ inHunk = false;
288
+ continue;
289
+ }
290
+ if (!inHunk && rawLine.startsWith("+++ ")) {
291
+ const target = rawLine.slice(4).replace(/\t.*$/u, "");
292
+ file = target === "/dev/null" ? null : target.replace(/^b\//u, "");
293
+ continue;
294
+ }
295
+ if (!inHunk && rawLine.startsWith("--- ")) continue;
296
+ if (rawLine.startsWith("@@")) {
297
+ const match = rawLine.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/u);
298
+ newLine = match ? Number.parseInt(match[1], 10) : 0;
299
+ inHunk = true;
300
+ continue;
301
+ }
302
+ if (rawLine.startsWith("+")) {
303
+ entries.push({ file, line: newLine, text: rawLine.slice(1) });
304
+ newLine += 1;
305
+ continue;
306
+ }
307
+ if (rawLine.startsWith("-")) continue; // removed line — never advances the new-file counter
308
+ if (rawLine.startsWith("\\")) continue; // ""
309
+ // A context line (leading space) or a blank line inside a hunk both
310
+ // still exist in the new file, so both advance the counter.
311
+ if (newLine > 0) newLine += 1;
312
+ }
313
+ return entries;
314
+ }
315
+
316
+ /**
317
+ * Scan a full unified diff. Findings never carry the matched substring —
318
+ * only file/line/detectorClass/reason.
319
+ * @param {string} diffText
320
+ * @returns {{ ok: boolean, findings: { file: string, line: number, detectorClass: string, reason: string }[] }}
321
+ */
322
+ export function scanDiffText(diffText) {
323
+ const findings = [];
324
+ for (const entry of parseAddedLines(diffText)) {
325
+ for (const hit of scanLineText(entry.text)) {
326
+ findings.push({ file: entry.file, line: entry.line, ...hit });
327
+ }
328
+ }
329
+ return { ok: findings.length === 0, findings };
330
+ }