acuvo-code 0.2.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 (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
@@ -0,0 +1,1382 @@
1
+ /**
2
+ * ── ⭐⭐ WE REVIEW OUR OWN ACTIONS OBSESSIVELY AND THE USER'S CODE NOT AT ALL ─
3
+ *
4
+ * This package spends most of its guard budget on what the AGENT is allowed to
5
+ * do: `command.mjs` vets every argv, `fetch-text.mjs` refuses a private address,
6
+ * `mcp.mjs` closed an RCE through env values, `secret-paths.mjs` refuses to
7
+ * commit a credential file. All of that protects the MACHINE the agent runs on.
8
+ *
9
+ * None of it looks at the thing we actually hand the user: the CODE.
10
+ *
11
+ * ⚠️ An agent that writes `db.query('… WHERE id = ' + req.query.id)`, commits a
12
+ * live API key, and then prints a green ✔ because `npm test` exited 0 is worse
13
+ * than no agent — the tests pass, the reviewer is absent, and the user has been
14
+ * given confidence instead of code. This module is the missing half.
15
+ *
16
+ * ── ⚠️⚠️ WHAT THIS IS NOT — SAY IT BEFORE ANYTHING ELSE ─────────────────────
17
+ * THIS IS A LAYER, NOT A BOUNDARY. It is a pattern matcher over ONE file's text.
18
+ * It has:
19
+ * · no types, so it cannot know `id` is a number
20
+ * · no cross-file view, so a sanitizer that lives in `lib/clean.js` is
21
+ * invisible and a tainted value arriving through an import is invisible too
22
+ * · no execution, so it cannot know which branch runs
23
+ *
24
+ * Therefore it MISSES things, and a clean result is not a safety claim. That is
25
+ * why `formatReviewSummary` prints `REVIEW_CAVEAT` alongside findings and why it
26
+ * prints NOTHING AT ALL when there are none — an "✔ no vulnerabilities found"
27
+ * line is the same false all-clear that `see_page` once emitted for pages it had
28
+ * never seen, and it would be believed.
29
+ *
30
+ * ── ⚠️⚠️ THE HARD PART IS NOT FINDING THINGS. IT IS NOT CRYING WOLF ─────────
31
+ * A reviewer that flags every `innerHTML` is noise, and noise gets switched off
32
+ * — at which point it protects nothing at all. A guard that fails correct work
33
+ * is worse than no guard (this repo has learned that four times in one day). So
34
+ * every rule here obeys three house rules:
35
+ *
36
+ * 1. NO EVIDENCE, NO FINDING. A literal string is not user input.
37
+ * `eval('2+2')` is not flagged. `exec('ls -la')` is not flagged.
38
+ * `el.innerHTML = '<b>hi</b>'` is not flagged.
39
+ * 2. EVERY FINDING CARRIES A CONFIDENCE AND THE REASON FOR IT
40
+ * (`confidenceWhy`), so a reader can dismiss one in a second. Default
41
+ * reporting threshold is `medium`; `low` findings exist, are honest about
42
+ * being guesses, and stay out of the report unless asked for.
43
+ * 3. THE SUSPECT GETS TO SPEAK. A `catch {}` whose comment says "ignore" is
44
+ * not flagged; a `DOMPurify.sanitize(...)` is not flagged; an interpolated
45
+ * value that is a file-local `const` literal drops to `low`.
46
+ *
47
+ * ── ⭐ WHY THIS RULE LIST AND NOT THE OWASP TOP TEN ─────────────────────────
48
+ * The list is chosen from what an LLM actually emits in generated app code,
49
+ * because a rule that never fires costs the same attention as one that does:
50
+ *
51
+ * · INJECTION — models write string-built SQL constantly; it is the single
52
+ * most common serious defect in generated CRUD code.
53
+ * · SECRETS — a model that has seen a key in context will helpfully paste it
54
+ * back. Git keeps it after you delete it, so this one is unrecoverable.
55
+ * · WEB (XSS / CORS / cookies) — `dangerouslySetInnerHTML` is the fastest way
56
+ * a generated React page becomes exploitable, and `origin:'*'` +
57
+ * `credentials:true` is a config a model copies from a tutorial.
58
+ * · AUTH / CRYPTO — `Math.random()` for a reset token and `md5(password)` are
59
+ * both idioms that look plausible and are catastrophic.
60
+ * · FOOTGUNS — a swallowed error is how a generated app "works" while doing
61
+ * nothing; path traversal and open redirect are one line each.
62
+ *
63
+ * Deliberately NOT covered: race conditions, business-logic authorisation,
64
+ * dependency CVEs, anything needing a call graph. Naming what we do not cover
65
+ * is part of the guarantee.
66
+ *
67
+ * ── ⭐ ON `secret-paths.mjs`: IMPORTED, NOT RE-TYPED ────────────────────────
68
+ * `refusedCommitPath` is imported rather than reproduced. That module's own
69
+ * header records what happened when this concept existed twice: `read-window`
70
+ * kept a second credential list and the two DISAGREED, so which of a user's
71
+ * secrets were protected depended on which verb the model happened to pick.
72
+ * Writing "my" list here would be that bug for the third time. The import is
73
+ * also cycle-free by construction — `secret-paths.mjs` is a documented LEAF
74
+ * (zero imports), which is exactly the property that makes it importable from
75
+ * anywhere, including here.
76
+ *
77
+ * ⭐ AND IT CHANGES THE VERDICT, NOT JUST THE WORDING: for a credential path we
78
+ * emit ONE finding ("never commit this") and DO NOT run the secret-literal
79
+ * rules. A `.env` file full of keys is a `.env` file doing its job; forty
80
+ * "hardcoded secret" findings on it would be the noise problem in its purest
81
+ * form.
82
+ *
83
+ * ── ⭐ PURITY / INJECTION SEAMS ─────────────────────────────────────────────
84
+ * `reviewCode(path, content)` touches nothing outside its arguments — no fs, no
85
+ * network, no clock, no env. There is nothing to inject because there is nothing
86
+ * outside. The one function that needs the outside, `reviewWrittenFiles`, takes
87
+ * `read` as a parameter and defaults to a reader that refuses, so every branch
88
+ * of this file tests with no network, no database and no API key.
89
+ */
90
+
91
+ import { refusedCommitPath } from './secret-paths.mjs';
92
+
93
+ /** Categories a finding can belong to. Exported so the lead can filter. */
94
+ export const CATEGORIES = ['injection', 'secret', 'web', 'auth', 'footgun'];
95
+
96
+ const SEVERITY_RANK = { critical: 3, high: 2, medium: 1, low: 0 };
97
+ const CONFIDENCE_RANK = { high: 2, medium: 1, low: 0 };
98
+
99
+ /** One threshold, shared by every entry point. See the note in `reviewCode`. */
100
+ export const DEFAULT_MIN_CONFIDENCE = 'medium';
101
+
102
+ /**
103
+ * ⚠️ A FILE WE CANNOT LEX IS A FILE WE DO NOT REVIEW. Guessing at a language
104
+ * whose comment and string syntax we do not know produces findings inside
105
+ * comments, which is the fastest way to become noise.
106
+ */
107
+ const LANGS = {
108
+ js: { id: 'js', line: '//', block: ['/*', '*/'], quotes: `'"\``, full: true },
109
+ c: { id: 'c', line: '//', block: ['/*', '*/'], quotes: `'"`, full: true },
110
+ py: { id: 'py', line: '#', block: null, quotes: `'"`, full: true },
111
+ rb: { id: 'rb', line: '#', block: null, quotes: `'"`, full: true },
112
+ php: { id: 'php', line: '//', block: ['/*', '*/'], quotes: `'"`, full: true },
113
+ sh: { id: 'sh', line: '#', block: null, quotes: `'"`, full: true },
114
+ html: { id: 'html', line: null, block: ['<!--', '-->'], quotes: `'"`, full: true },
115
+ // ⭐ DATA FILES GET THE SECRET RULES ONLY. A `config.json` with a live key in
116
+ // it is a real finding; "SQL injection" in JSON is not a thing that exists.
117
+ data: { id: 'data', line: '#', block: null, quotes: `'"`, full: false },
118
+ };
119
+
120
+ const EXT_LANG = {
121
+ js: 'js', mjs: 'js', cjs: 'js', jsx: 'js', ts: 'js', tsx: 'js', mts: 'js', cts: 'js',
122
+ vue: 'js', svelte: 'js', astro: 'js',
123
+ py: 'py', rb: 'rb', php: 'php', sh: 'sh', bash: 'sh', zsh: 'sh',
124
+ go: 'c', java: 'c', cs: 'c', kt: 'c', swift: 'c', c: 'c', h: 'c', cc: 'c', cpp: 'c', rs: 'c', scala: 'c',
125
+ html: 'html', htm: 'html',
126
+ json: 'data', yaml: 'data', yml: 'data', toml: 'data', ini: 'data', cfg: 'data', conf: 'data', properties: 'data',
127
+ };
128
+
129
+ /** Files that are generated, vendored or minified — reviewing them is all noise. */
130
+ const SKIP_PATH = [
131
+ /(^|\/)node_modules\//i,
132
+ /(^|\/)(dist|build|out|coverage|vendor|third_party)\//i,
133
+ /\.min\.(js|css)$/i,
134
+ /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$/i,
135
+ /\.(map|lock)$/i,
136
+ ];
137
+
138
+ /** A regex line-scanner on a 4MB bundle is a hang, not a review. */
139
+ export const MAX_REVIEW_BYTES = 512 * 1024;
140
+ /** One line this long means minified or generated: columns are meaningless. */
141
+ export const MAX_LINE_CHARS = 2000;
142
+ /** Past this many findings in one file the answer is "rewrite it", not a list. */
143
+ export const MAX_FINDINGS_PER_FILE = 40;
144
+
145
+ // ── text handling ───────────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Blank out comments, preserving every byte offset (comment chars become
149
+ * spaces, newlines stay newlines). Offsets must survive because a finding
150
+ * reports a COLUMN, and a column that does not point at the code it names is
151
+ * worse than no column.
152
+ *
153
+ * ⚠️ STRINGS ARE NOT BLANKED — deliberately. Half the evidence lives inside
154
+ * string literals (the SQL text, the connection string, the `-----BEGIN` block).
155
+ * Only comments go.
156
+ *
157
+ * ⚠️ KNOWN LIMIT, WRITTEN DOWN RATHER THAN HIDDEN: this does not track JS
158
+ * regex literals, so a regex whose body contains an unescaped `//` would be
159
+ * read as a comment. In practice `//` inside a regex is written `\/\/`, which
160
+ * has no adjacent pair. It is a lexer, not a parser, and this is the price.
161
+ */
162
+ export function blankComments(content, lang) {
163
+ const src = String(content);
164
+ const out = new Array(src.length);
165
+ let inBlock = false;
166
+ let quote = null;
167
+ // Inside a backtick template, `${ … }` is CODE again — a quote character in
168
+ // there must not be read as closing the template.
169
+ let tplDepth = 0;
170
+ const bs = lang.block ? lang.block[0] : null;
171
+ const be = lang.block ? lang.block[1] : null;
172
+
173
+ for (let i = 0; i < src.length; i++) {
174
+ const ch = src[i];
175
+ const keepNewline = ch === '\n' || ch === '\r';
176
+
177
+ if (inBlock) {
178
+ out[i] = keepNewline ? ch : ' ';
179
+ if (be && src.startsWith(be, i)) {
180
+ for (let k = 0; k < be.length; k++) out[i + k] = src[i + k] === '\n' ? '\n' : ' ';
181
+ i += be.length - 1;
182
+ inBlock = false;
183
+ }
184
+ continue;
185
+ }
186
+
187
+ if (quote) {
188
+ out[i] = ch;
189
+ if (ch === '\\') { if (i + 1 < src.length) out[i + 1] = src[i + 1]; i++; continue; }
190
+ if (quote === '`' && ch === '$' && src[i + 1] === '{') { tplDepth++; out[i + 1] = '{'; i++; continue; }
191
+ if (quote === '`' && tplDepth > 0 && ch === '}') { tplDepth--; continue; }
192
+ if (tplDepth === 0 && ch === quote) quote = null;
193
+ // A single-quoted string in most of these languages cannot span lines;
194
+ // if it does, we are mis-lexing, so bail out at the newline rather than
195
+ // swallowing the rest of the file.
196
+ if (keepNewline && quote !== '`') quote = null;
197
+ continue;
198
+ }
199
+
200
+ if (bs && src.startsWith(bs, i)) { inBlock = true; out[i] = ' '; continue; }
201
+ if (lang.line && src.startsWith(lang.line, i)) {
202
+ while (i < src.length && src[i] !== '\n') { out[i] = ' '; i++; }
203
+ out[i] = '\n';
204
+ continue;
205
+ }
206
+ if (lang.quotes.includes(ch)) { quote = ch; tplDepth = 0; }
207
+ out[i] = ch;
208
+ }
209
+ return out.join('');
210
+ }
211
+
212
+ function excerpt(line) {
213
+ const t = String(line ?? '').trim();
214
+ return t.length > 200 ? `${t.slice(0, 197)}…` : t;
215
+ }
216
+
217
+ function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
218
+
219
+ /**
220
+ * ⚠️ `\b(token)\b` DOES NOT MATCH `resetToken`, AND THAT SILENTLY DISABLED THE
221
+ * BEST RULE IN THIS FILE. Every noun list below is matched against this
222
+ * normalised form instead: camel boundaries become underscores, then lowercase.
223
+ * `resetToken` → `reset_token` → `\btoken\b` matches. `sessionId` →
224
+ * `session_id`. Without this, insecure-randomness fired on `const token =`
225
+ * (snake by luck) and not on `const resetToken =`, which is the spelling a
226
+ * model actually emits.
227
+ */
228
+ function camelWords(text) {
229
+ return String(text ?? '')
230
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
231
+ // ⚠️ `_` AND `-` TOO. `res.cookie('session_id', t)` went unflagged because
232
+ // `\bsession\b` does not match `session_id` — the underscore is a word
233
+ // character, so there is no boundary after `session`. Snake_case is the
234
+ // other half of how these names are spelled and both must normalise.
235
+ .replace(/[_-]+/g, ' ')
236
+ .toLowerCase();
237
+ }
238
+
239
+ /**
240
+ * Is this expression made only of literals? If so it is not attacker-controlled
241
+ * and nothing here may flag it. This is rule 1 in code.
242
+ */
243
+ export function isLiteralOnly(expr) {
244
+ const t = String(expr ?? '').trim().replace(/[;,)\]}]+$/, '').trim();
245
+ if (!t) return false;
246
+ if (/^`[^`$]*`$/.test(t)) return true; // template with no ${…}
247
+ if (/^'(?:\\.|[^'\\])*'$/.test(t)) return true;
248
+ if (/^"(?:\\.|[^"\\])*"$/.test(t)) return true;
249
+ // A concatenation of literals only: 'a' + "b"
250
+ if (/^(['"][^'"]*['"]\s*\+\s*)+['"][^'"]*['"]$/.test(t)) return true;
251
+ return false;
252
+ }
253
+
254
+ // ── taint: the only thing standing between "a finding" and "noise" ──────────
255
+
256
+ const TAINT_SOURCES = [
257
+ /\breq(uest)?\s*\.\s*(query|body|params|headers|cookies|url|originalUrl|files|path)\b/,
258
+ /\bctx\s*\.\s*(query|params|request|req)\b/,
259
+ /\bevent\s*\.\s*(body|queryStringParameters|pathParameters|headers)\b/,
260
+ /\bsearchParams\s*\.\s*get\s*\(/,
261
+ /\buseSearchParams\s*\(/,
262
+ /\bformData\s*\.\s*get\s*\(/,
263
+ /\bprocess\s*\.\s*argv\b/,
264
+ /\bsys\s*\.\s*argv\b/,
265
+ /\blocation\s*\.\s*(search|hash|href|pathname)\b/,
266
+ /\bdocument\s*\.\s*(URL|referrer|cookie)\b/,
267
+ /\bwindow\s*\.\s*name\b/,
268
+ /\brequest\s*\.\s*(args|form|json|values|files|GET|POST)\b/,
269
+ /\bparams\s*\[/,
270
+ /\$_(GET|POST|REQUEST|COOKIE|FILES)\b/,
271
+ ];
272
+
273
+ /**
274
+ * ⚠️ ONE FORWARD PASS, AND IT IS DELIBERATELY AN OVER-APPROXIMATION.
275
+ *
276
+ * The tainted set is built over the whole file before any rule runs, so a
277
+ * variable declared at line 90 counts as tainted at line 10 too. That is
278
+ * technically wrong and it is the right trade: handlers are routinely defined
279
+ * below the routes that reference them, and the alternative — losing taint
280
+ * because of source order — turns HIGH-confidence findings into MEDIUM ones,
281
+ * i.e. it degrades the thing that makes this reviewer usable.
282
+ *
283
+ * It never CREATES a finding on its own: every rule that consults taint also
284
+ * requires its own dangerous-sink evidence on the line.
285
+ */
286
+ export function taintedNames(codeLines) {
287
+ const tainted = new Set();
288
+ const safeConsts = new Set();
289
+ const seen = (expr) => TAINT_SOURCES.some((rx) => rx.test(expr))
290
+ || [...tainted].some((n) => new RegExp(`\\b${escapeRx(n)}\\b`).test(expr));
291
+
292
+ // Two passes: a value can be laundered through an intermediate declared
293
+ // above OR below its use, and one pass would only catch the first order.
294
+ for (let pass = 0; pass < 2; pass++) {
295
+ for (const code of codeLines) {
296
+ /**
297
+ * ⚠️⚠️ `^` WITHOUT `\s*` MADE THIS RULE A NO-OP AND EVERY TEST STILL
298
+ * PASSED. The first version anchored with `(?:^|[;{}]\s*)`, so it saw
299
+ * `const x = req.query.id` at column 0 and never saw the indented one
300
+ * inside a route handler — which is where every declaration in real code
301
+ * lives. Taint was therefore empty for realistic files, every
302
+ * taint-gated rule silently dropped to its lower confidence, and nothing
303
+ * went red. Caught only by running the reviewer over a whole clean file
304
+ * and noticing a finding that should have fired did not.
305
+ */
306
+ const m = /(?:^\s*|[;{}]\s*)(?:const|let|var)\s+(\{[^}]*\}|\[[^\]]*\]|[A-Za-z_$][\w$]*)\s*=\s*(.+)$/.exec(code)
307
+ || /^\s*([A-Za-z_][\w]*)\s*=\s*(.+)$/.exec(code);
308
+ if (!m) continue;
309
+ const target = m[1].trim();
310
+ const rhs = m[2].trim();
311
+
312
+ // The bare-assignment arm can capture a keyword (`return x = 1`); a
313
+ // language keyword is never a variable we want to taint.
314
+ if (/^(const|let|var|return|await|if|while|for|else)$/.test(target)) continue;
315
+
316
+ if (seen(rhs)) {
317
+ for (const name of bindingNames(target)) tainted.add(name);
318
+ } else if (pass === 0 && isLiteralOnly(rhs) && /^[A-Za-z_$][\w$]*$/.test(target)) {
319
+ safeConsts.add(target);
320
+ }
321
+ }
322
+ }
323
+ return { tainted, safeConsts };
324
+ }
325
+
326
+ /** `{ a, b: c }` → ['a','c'] · `[x, y]` → ['x','y'] · `name` → ['name'] */
327
+ function bindingNames(target) {
328
+ const inner = /^[{[]/.test(target) ? target.slice(1, -1) : target;
329
+ return inner
330
+ .split(',')
331
+ .map((p) => p.includes(':') ? p.split(':').pop() : p)
332
+ .map((p) => p.replace(/=.*$/, '').replace(/\.\.\./, '').trim())
333
+ /**
334
+ * ⚠️ A DENYLIST, NOT A LENGTH RULE. The first version required 2+
335
+ * characters to stop `i` and `e` tainting a whole file — and that dropped
336
+ * `const q = req.query.q`, which is exactly how a model names a search
337
+ * parameter. Measured: the XSS in `res.send(\`…${q}…\`)` went unreported.
338
+ * So single letters are allowed except the handful that are conventionally
339
+ * a counter or a caught error and are never a request value. `n` is NOT on
340
+ * the list: `const n = req.query.n` is how a model names a page size, and
341
+ * excluding it lost the unbounded-loop finding that depends on it.
342
+ */
343
+ .filter((p) => /^[A-Za-z_$][\w$]*$/.test(p) && !/^(i|j|k|e|_|\$)$/.test(p));
344
+ }
345
+
346
+ // ── the rules ───────────────────────────────────────────────────────────────
347
+
348
+ const SQL_STMT = /\b(select|insert\s+into|update|delete\s+from|drop\s+table|alter\s+table)\b[\s\S]{0,240}?\b(from|into|set|where|values|table)\b/i;
349
+
350
+ /**
351
+ * ── ⚠️⚠️ THE FALSE POSITIVE THAT NEARLY SHIPPED ────────────────────────────
352
+ * `SQL_STMT` alone matches this, which is an ordinary log line:
353
+ *
354
+ * console.log('Select the file from the list: ' + name)
355
+ *
356
+ * "Select" … "from" is the same shape as `SELECT … FROM`. A reviewer that
357
+ * flags that once is a reviewer nobody runs twice. So a SQL finding needs
358
+ * evidence beyond the two keywords, and there are two independent kinds:
359
+ *
360
+ * · SHAPE — what follows FROM/INTO/UPDATE parses as a table reference. In
361
+ * prose, `from the list` is followed by another bare word; in SQL, the
362
+ * table name is followed by WHERE/SET/VALUES/JOIN/a quote/end of string.
363
+ * · CONTEXT — something on the line executes or names a query (`db.query`,
364
+ * `cursor.execute`, `knex.raw`, a variable called `sql`).
365
+ *
366
+ * With neither, the finding still exists but drops to `low` and says why —
367
+ * recall is kept for `minConfidence: 'low'`, and the default report stays
368
+ * quiet. That is the trade this whole module is built around.
369
+ */
370
+ const SQL_SHAPE = /\b(from|into|join|update)\s+(?:\$\{[^}]*\}|["'`[]?[A-Za-z_][\w.$]*["'`\]]?)\s*(?:\bwhere\b|\bset\b|\bvalues\b|\bjoin\b|\border\b|\bgroup\b|\blimit\b|\bon\b|\bas\b|[;,()]|["'`]|\+|\$\{|$)/i;
371
+ const SQL_CONTEXT = /\b(query|execute|exec|raw|prepare|db|cursor|conn|connection|knex|sequelize|pool|client|sql|stmt|statement)\b/i;
372
+ const SANITISERS = /\b(DOMPurify\s*\.\s*sanitize|sanitize[Hh]tml|sanitizeHTML|xss\s*\(|escapeHtml|createDOMPurify)\b/;
373
+ const AUTH_COOKIE = /\b(sess|session|sid|token|jwt|auth|login|remember|csrf)\b/i;
374
+ const CREDENTIAL_NOUN = /\b(password|passwd|pwd|credential|passphrase)\b/i;
375
+ // ⚠️ Matched against `camelWords(…)`, so every separator has already become a
376
+ // space — hence `api ?key`, not `api[_-]?key`. Deliberately WITHOUT a bare
377
+ // `key`: `keyframe`, `Object.keys` and `keyboard` are everywhere, and
378
+ // `Math.random()` beside one of those is not a security defect.
379
+ const TOKEN_NOUN = /\b(token|secret|password|passwd|otp|nonce|salt|session|csrf|api ?key|private ?key|reset ?code|verification ?code|auth ?code|uuid|guid)\b/i;
380
+
381
+ const SECRET_NAME = /\b(api[_-]?key|apikey|secret[_-]?key|client[_-]?secret|app[_-]?secret|secret|access[_-]?token|auth[_-]?token|refresh[_-]?token|bearer[_-]?token|password|passwd|db[_-]?pass|private[_-]?key)\b/i;
382
+
383
+ /**
384
+ * Prefixes that are a live credential by construction — no entropy heuristic
385
+ * needed, and no placeholder ever looks like this.
386
+ */
387
+ const KNOWN_KEY = [
388
+ [/\bsk-[A-Za-z0-9_-]{16,}/, 'an OpenAI-style secret key'],
389
+ [/\bsk_live_[A-Za-z0-9]{16,}/, 'a live Stripe secret key'],
390
+ [/\brk_live_[A-Za-z0-9]{16,}/, 'a live Stripe restricted key'],
391
+ [/\b(ghp|gho|ghu|ghs)_[A-Za-z0-9]{20,}/, 'a GitHub token'],
392
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}/, 'a GitHub fine-grained token'],
393
+ [/\bAKIA[0-9A-Z]{16}\b/, 'an AWS access key id'],
394
+ [/\bAIza[0-9A-Za-z_-]{30,}/, 'a Google API key'],
395
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}/, 'a Slack token'],
396
+ [/\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/, 'a SendGrid key'],
397
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, 'a private key block'],
398
+ ];
399
+
400
+ const PLACEHOLDER = /(your[_-]?|my[_-]?|example|sample|placeholder|changeme|change[_-]?me|dummy|fake|todo|xxxx|<[^>]+>|\$\{|%s|\.\.\.|insert[_-]?here|redacted|replace[_-]?me|test[_-]?key|foo|bar|abc123)/i;
401
+
402
+ /**
403
+ * Does this literal look like a real credential rather than a stand-in?
404
+ * ⚠️ Deliberately strict. A false "you committed a key" costs the user a
405
+ * rotation they did not need and teaches them to ignore the reviewer.
406
+ */
407
+ export function looksLikeRealSecret(value) {
408
+ const v = String(value ?? '');
409
+ if (v.length < 12) return false;
410
+ if (PLACEHOLDER.test(v)) return false;
411
+ if (/^[A-Z_]+$/.test(v)) return false; // an env var NAME, not a value
412
+ if (new Set(v).size < 8) return false; // 'xxxxxxxxxxxxxx'
413
+ if (/^(https?|file):\/\//i.test(v)) return false; // a URL is not a key
414
+ if (/\s/.test(v)) return false; // a sentence, not a credential
415
+ /**
416
+ * ⚠️ MEASURED FALSE POSITIVE: `const SECRET_HEADER = 'x-app-secret'` was
417
+ * flagged as a committed credential. All-lowercase kebab or snake words are
418
+ * header names, config keys and env var names — never a generated key, which
419
+ * always mixes case or digits.
420
+ */
421
+ if (/^[a-z][a-z0-9]*([-_][a-z0-9]+)*$/.test(v)) return false;
422
+ const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((rx) => rx.test(v)).length;
423
+ return classes >= 2;
424
+ }
425
+
426
+ /** Read the text inside a call's parentheses, spanning up to `maxLines`. */
427
+ function readCall(codeLines, li, openIdx, maxLines = 8) {
428
+ let depth = 0;
429
+ let out = '';
430
+ for (let i = li; i < codeLines.length && i - li < maxLines; i++) {
431
+ const s = i === li ? codeLines[i].slice(openIdx) : codeLines[i];
432
+ for (const ch of s) {
433
+ if (ch === '(') { depth++; if (depth === 1) continue; }
434
+ else if (ch === ')') { depth--; if (depth === 0) return out; }
435
+ if (depth >= 1) out += ch;
436
+ }
437
+ out += ' ';
438
+ }
439
+ return out;
440
+ }
441
+
442
+ /** Read a `{ … }` block starting at `openIdx`, spanning up to `maxLines`. */
443
+ function readBlock(codeLines, li, openIdx, maxLines = 8) {
444
+ let depth = 0;
445
+ let out = '';
446
+ let endLine = li;
447
+ for (let i = li; i < codeLines.length && i - li < maxLines; i++) {
448
+ const s = i === li ? codeLines[i].slice(openIdx) : codeLines[i];
449
+ for (const ch of s) {
450
+ if (ch === '{') { depth++; if (depth === 1) continue; }
451
+ else if (ch === '}') { depth--; if (depth === 0) return { body: out, endLine: i }; }
452
+ if (depth >= 1) out += ch;
453
+ }
454
+ out += '\n';
455
+ endLine = i;
456
+ }
457
+ return { body: out, endLine, unterminated: true };
458
+ }
459
+
460
+ // ── review ──────────────────────────────────────────────────────────────────
461
+
462
+ /**
463
+ * Review one file's source text.
464
+ *
465
+ * @param {string} path workspace-relative path (decides the language, and
466
+ * whether the file is a credential file at all)
467
+ * @param {string} content the source text
468
+ * @param {{minConfidence?: 'low'|'medium'|'high'}} [opts]
469
+ * @returns {any[]} findings, most serious first. NEVER throws.
470
+ */
471
+ export function reviewCode(path, content, opts = {}) {
472
+ /**
473
+ * ⚠️ THE DEFAULT IS `medium`, AND IT MUST BE THE SAME NUMBER IN ALL THREE
474
+ * ENTRY POINTS. The first version defaulted `reviewCode` to `low` while
475
+ * `executeReviewCode` and `reviewWrittenFiles` defaulted to `medium`, so the
476
+ * function the tests called was strictly noisier than the one the agent
477
+ * calls — the low-confidence guesses this module deliberately hides were
478
+ * visible only to the test suite. Two thresholds means the thing under test
479
+ * is not the thing that ships.
480
+ */
481
+ const min = CONFIDENCE_RANK[opts.minConfidence] ?? CONFIDENCE_RANK[DEFAULT_MIN_CONFIDENCE];
482
+ let findings;
483
+ try {
484
+ findings = collect(path, content);
485
+ } catch (err) {
486
+ /**
487
+ * ⚠️ A REVIEWER THAT THROWS DESTROYS THE REPORT IT WAS ADDED TO. It runs at
488
+ * the end of a run, after the work is done; an exception here would turn a
489
+ * successful build into a crash. It reports its own failure as a finding
490
+ * instead, so the failure is visible rather than silent.
491
+ */
492
+ return [{
493
+ path, line: 1, column: 1, rule: 'reviewer-failed', category: 'footgun',
494
+ severity: 'low', confidence: 'high',
495
+ confidenceWhy: 'the reviewer itself threw — this is about the reviewer, not your code',
496
+ why: `the code reviewer could not parse this file: ${err && err.message}`,
497
+ fix: 'nothing to do in your code — this file was simply not reviewed, so do not read a clean result for it as a pass',
498
+ evidence: '',
499
+ }];
500
+ }
501
+ return findings
502
+ .filter((f) => CONFIDENCE_RANK[f.confidence] >= min)
503
+ .sort((a, b) =>
504
+ (SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity])
505
+ || (CONFIDENCE_RANK[b.confidence] - CONFIDENCE_RANK[a.confidence])
506
+ || (a.line - b.line)
507
+ || a.rule.localeCompare(b.rule))
508
+ .slice(0, MAX_FINDINGS_PER_FILE);
509
+ }
510
+
511
+ function collect(path, content) {
512
+ const p = String(path ?? '');
513
+ const src = String(content ?? '');
514
+
515
+ /**
516
+ * ⭐ THE CREDENTIAL-PATH CHECK RUNS FIRST AND ALONE, and it runs BEFORE the
517
+ * extension gate because `.env`, `.pem` and `id_rsa` are not languages we
518
+ * lex. One finding, and the secret-literal rules are skipped: an env file
519
+ * full of keys is an env file working correctly.
520
+ */
521
+ const refusal = refusedCommitPath(p);
522
+ if (refusal) {
523
+ return [{
524
+ path: p, line: 1, column: 1, rule: 'credential-file', category: 'secret',
525
+ severity: 'high', confidence: 'high',
526
+ confidenceWhy: 'decided by the path alone, using the same list this agent uses to refuse commits',
527
+ why: `${refusal}. Its CONTENTS are fine — the danger is the file reaching version control, because git keeps it after you delete it.`,
528
+ fix: `add \`${p}\` to .gitignore before the next commit, ship a \`${p}.example\` with the keys blanked, and if it was ever committed rotate every value in it — deleting the file does not remove it from history`,
529
+ evidence: '',
530
+ }];
531
+ }
532
+
533
+ if (SKIP_PATH.some((rx) => rx.test(p))) return [];
534
+ if (src.length > MAX_REVIEW_BYTES) return [];
535
+
536
+ const ext = (p.split('.').pop() || '').toLowerCase();
537
+ const lang = LANGS[EXT_LANG[ext]];
538
+ if (!lang) return [];
539
+
540
+ const raw = src.split(/\r?\n/);
541
+ // ⚠️ Minified/generated: one enormous line. Column numbers would be useless
542
+ // and every rule would fire at once. Reviewing it is worse than skipping it.
543
+ if (raw.some((l) => l.length > MAX_LINE_CHARS)) return [];
544
+
545
+ const code = blankComments(src, lang).split(/\r?\n/);
546
+ const { tainted, safeConsts } = lang.full ? taintedNames(code) : { tainted: new Set(), safeConsts: new Set() };
547
+
548
+ const isTest = /(^|\/)(tests?|__tests__|spec|specs|fixtures?|mocks?|examples?)\//i.test(p)
549
+ || /\.(test|spec)\.[a-z]+$/i.test(p);
550
+
551
+ const ctx = { path: p, lang, raw, code, tainted, safeConsts, isTest, codeText: code.join('\n') };
552
+ ctx.isTainted = (expr) => {
553
+ const e = String(expr ?? '');
554
+ if (!e.trim()) return false;
555
+ if (TAINT_SOURCES.some((rx) => rx.test(e))) return true;
556
+ return [...tainted].some((n) => new RegExp(`\\b${escapeRx(n)}\\b`).test(e));
557
+ };
558
+
559
+ const out = [];
560
+ ruleSecrets(ctx, out);
561
+ if (lang.full) {
562
+ ruleInjection(ctx, out);
563
+ ruleWeb(ctx, out);
564
+ ruleAuthCrypto(ctx, out);
565
+ ruleFootguns(ctx, out);
566
+ }
567
+
568
+ // Dedupe: one finding per rule per line, whichever fired first.
569
+ const seen = new Set();
570
+ return out.filter((f) => {
571
+ const k = `${f.rule}:${f.line}`;
572
+ if (seen.has(k)) return false;
573
+ seen.add(k);
574
+ return true;
575
+ });
576
+ }
577
+
578
+ function add(ctx, out, f) {
579
+ out.push({
580
+ path: ctx.path,
581
+ line: f.line,
582
+ column: f.column ?? 1,
583
+ rule: f.rule,
584
+ category: f.category,
585
+ severity: f.severity,
586
+ confidence: f.confidence,
587
+ confidenceWhy: f.confidenceWhy,
588
+ why: f.why,
589
+ fix: f.fix,
590
+ evidence: excerpt(ctx.raw[f.line - 1]),
591
+ });
592
+ }
593
+
594
+ // ── injection ───────────────────────────────────────────────────────────────
595
+
596
+ /**
597
+ * Every value spliced into this text, as source expressions.
598
+ *
599
+ * ⚠️ THE LANGUAGE GATE IS NOT TIDINESS, IT IS A BUG FIX. The python `%`
600
+ * extractor, run on JavaScript, reads `WHERE name LIKE '%foo%'` as an
601
+ * interpolation of `foo` — a SQL wildcard turned into a fake injection
602
+ * finding. `%` and `.format` only mean interpolation in python; `#{}` only in
603
+ * ruby. Applying every language's rules to every language is how a reviewer
604
+ * invents defects.
605
+ */
606
+ function interpolatedExprs(text, langId = 'js') {
607
+ const out = [];
608
+ for (const m of text.matchAll(/\$\{([^}]*)\}/g)) out.push(m[1].trim()); // JS template
609
+ for (const m of text.matchAll(/['"]\s*\+\s*([A-Za-z_$][\w$.[\]()]*)/g)) out.push(m[1].trim()); // concat
610
+ for (const m of text.matchAll(/([A-Za-z_$][\w$.[\]()]*)\s*\+\s*['"]/g)) out.push(m[1].trim());
611
+ if (langId === 'py') {
612
+ /**
613
+ * ⚠️ THE `%` MUST FOLLOW A CLOSING QUOTE. `"… WHERE id = %s"` is the
614
+ * CORRECT parameterised form and its `%s` is a placeholder, not an
615
+ * interpolation — reading it as one turned a safe `cursor.execute(sql,
616
+ * (uid,))` into a critical injection finding. Real python interpolation is
617
+ * `"…" % (uid,)`, where the operator sits outside the string.
618
+ */
619
+ for (const m of text.matchAll(/['"]\s*%\s*\(?\s*([A-Za-z_][\w.[\]]*)/g)) out.push(m[1].trim());
620
+ for (const m of text.matchAll(/\.format\s*\(\s*([^)]*)\)/g)) out.push(m[1].trim());
621
+ // f-strings interpolate with neither `+` nor `${`.
622
+ if (/\bf['"]/.test(text)) for (const m of text.matchAll(/\{([A-Za-z_][\w.[\]]*)\}/g)) out.push(m[1].trim());
623
+ }
624
+ if (langId === 'rb') for (const m of text.matchAll(/#\{([^}]*)\}/g)) out.push(m[1].trim());
625
+ return out.filter(Boolean);
626
+ }
627
+
628
+ function ruleInjection(ctx, out) {
629
+ for (let i = 0; i < ctx.code.length; i++) {
630
+ const line = ctx.code[i];
631
+ const n = i + 1;
632
+ if (!line.trim()) continue;
633
+
634
+ // ── SQL built by string concatenation ──────────────────────────────────
635
+ if (SQL_STMT.test(line)) {
636
+ const exprs = interpolatedExprs(line, ctx.lang.id);
637
+ const real = exprs.filter((e) => !/^['"`]/.test(e));
638
+ if (real.length > 0) {
639
+ const anyTainted = real.some((e) => ctx.isTainted(e));
640
+ const allConst = real.every((e) => ctx.safeConsts.has(e.split(/[.[(]/)[0]));
641
+ // See the SQL_SHAPE comment: without shape or context evidence this is
642
+ // as likely to be an English sentence as a query.
643
+ const strong = SQL_SHAPE.test(line) || SQL_CONTEXT.test(camelWords(line));
644
+ const confidence = !strong ? 'low' : (anyTainted ? 'high' : (allConst ? 'low' : 'medium'));
645
+ add(ctx, out, {
646
+ line: n, column: line.search(SQL_STMT) + 1,
647
+ rule: 'sql-string-concat', category: 'injection',
648
+ severity: 'critical', confidence,
649
+ confidenceWhy: !strong
650
+ ? 'these words have the shape of SQL, but nothing on this line executes or names a query and what follows FROM/INTO does not parse as a table — this may simply be an English sentence'
651
+ : (anyTainted
652
+ ? `\`${real.find((e) => ctx.isTainted(e))}\` traces back to request input in this file`
653
+ : (allConst
654
+ ? `every interpolated value (${real.join(', ')}) is a literal \`const\` declared in this file, so it is probably a table name — check it is not reassigned`
655
+ : `\`${real[0]}\` is a variable and this file does not show where it came from`)),
656
+ why: 'the query text is assembled by string interpolation, so whatever this value contains becomes SQL. A value of `1 OR 1=1--` reads the whole table; `1; DROP TABLE users--` does worse.',
657
+ fix: 'use a placeholder and pass the value separately — `db.query("SELECT * FROM users WHERE id = $1", [id])` (pg) or `?` (mysql/sqlite). If the interpolated part is an identifier (a table or column name) placeholders cannot help: check it against a hardcoded allow-list instead.',
658
+ });
659
+ }
660
+ }
661
+
662
+ // ── a shell command built from a variable ──────────────────────────────
663
+ const shell = /\b(exec|execSync|os\.system|popen|shell_exec|system)\s*\(/.exec(line);
664
+ if (shell) {
665
+ const args = readCall(ctx.code, i, line.indexOf('(', shell.index));
666
+ const exprs = interpolatedExprs(args, ctx.lang.id);
667
+ if (exprs.length > 0 && !isLiteralOnly(args)) {
668
+ const anyTainted = exprs.some((e) => ctx.isTainted(e));
669
+ add(ctx, out, {
670
+ line: n, column: shell.index + 1,
671
+ rule: 'shell-string-interpolation', category: 'injection',
672
+ severity: 'critical', confidence: anyTainted ? 'high' : 'medium',
673
+ confidenceWhy: anyTainted
674
+ ? `\`${exprs.find((e) => ctx.isTainted(e))}\` traces back to request input in this file`
675
+ : 'the command string is built from a variable whose origin is not visible here',
676
+ why: `\`${shell[1]}\` hands the whole string to a shell, so \`;\`, \`|\`, \`$(…)\` and backticks in that value are commands. A filename of \`a; rm -rf ~\` is a working exploit.`,
677
+ fix: 'use the argv form, which never involves a shell: `execFile("git", ["checkout", branch])` in Node, `subprocess.run(["git","checkout",branch])` in Python. If you truly need a shell, validate the value against an allow-list first — quoting by hand is not reliable.',
678
+ });
679
+ }
680
+ }
681
+ const pyShell = /subprocess\.(run|call|check_output|check_call|Popen)\s*\(/.exec(line);
682
+ if (pyShell) {
683
+ const args = readCall(ctx.code, i, line.indexOf('(', pyShell.index));
684
+ if (/shell\s*=\s*True/.test(args) && interpolatedExprs(args, ctx.lang.id).length > 0) {
685
+ add(ctx, out, {
686
+ line: n, column: pyShell.index + 1,
687
+ rule: 'shell-string-interpolation', category: 'injection',
688
+ severity: 'critical', confidence: ctx.isTainted(args) ? 'high' : 'medium',
689
+ confidenceWhy: ctx.isTainted(args)
690
+ ? 'an interpolated value traces back to request input in this file'
691
+ : '`shell=True` with an interpolated command string; the value\'s origin is not visible here',
692
+ why: '`shell=True` runs the string through /bin/sh, so shell metacharacters in the interpolated value are commands.',
693
+ fix: 'drop `shell=True` and pass a list: `subprocess.run(["git", "checkout", branch])`.',
694
+ });
695
+ }
696
+ }
697
+
698
+ // ── eval / new Function on something that is not a literal ─────────────
699
+ const ev = /\b(eval|new\s+Function)\s*\(/.exec(line);
700
+ if (ev) {
701
+ const args = readCall(ctx.code, i, line.indexOf('(', ev.index));
702
+ // ⭐ RULE 1 IN ACTION: `eval('2+2')` is ugly, not a vulnerability. No
703
+ // evidence of an outside value means no finding.
704
+ if (args.trim() && !isLiteralOnly(args)) {
705
+ const anyTainted = ctx.isTainted(args);
706
+ add(ctx, out, {
707
+ line: n, column: ev.index + 1,
708
+ rule: 'eval-non-literal', category: 'injection',
709
+ severity: anyTainted ? 'critical' : 'high',
710
+ confidence: anyTainted ? 'high' : 'medium',
711
+ confidenceWhy: anyTainted
712
+ ? 'the evaluated expression traces back to request input in this file'
713
+ : 'the argument is not a literal, so this executes text computed at runtime — where that text comes from is not visible here',
714
+ why: `\`${ev[1].replace(/\s+/g, ' ')}\` compiles and runs its argument as code with this program's full privileges. If any part of that string can be influenced from outside, it is remote code execution.`,
715
+ fix: 'for data use `JSON.parse`; for a dispatch table use an object of allowed functions keyed by name; for arithmetic use a small expression parser. If it is a fixed snippet, inline it — there is no case where `eval` of a computed string is the right answer.',
716
+ });
717
+ }
718
+ }
719
+ }
720
+ }
721
+
722
+ // ── secrets ─────────────────────────────────────────────────────────────────
723
+
724
+ function ruleSecrets(ctx, out) {
725
+ for (let i = 0; i < ctx.code.length; i++) {
726
+ const line = ctx.code[i];
727
+ const n = i + 1;
728
+ if (!line.trim()) continue;
729
+
730
+ // A key whose shape identifies the issuer. No heuristic needed.
731
+ for (const [rx, what] of KNOWN_KEY) {
732
+ const m = rx.exec(line);
733
+ if (!m) continue;
734
+ add(ctx, out, {
735
+ line: n, column: m.index + 1,
736
+ rule: 'committed-secret', category: 'secret',
737
+ severity: 'critical', confidence: ctx.isTest ? 'medium' : 'high',
738
+ confidenceWhy: ctx.isTest
739
+ ? `the value has the exact shape of ${what}, but this path looks like a test fixture, where a fake key is normal — confirm it is fake`
740
+ : `the value has the exact shape of ${what}; no placeholder looks like this`,
741
+ why: 'a live credential in source is compromised the moment the file is shared, pushed, or included in a build. Git keeps it after you delete the line, and public-repo scanners find these within minutes.',
742
+ fix: 'move it to an environment variable (`process.env.API_KEY`), add the env file to .gitignore, and ROTATE the key — assume it is already burned. Removing the line is not enough; the value stays in history.',
743
+ });
744
+ }
745
+
746
+ // A connection string carrying a password.
747
+ const conn = /\b[a-z][a-z0-9+.-]*:\/\/([A-Za-z0-9._%-]+):([^@\s'"/`]{3,})@/i.exec(line);
748
+ if (conn && !PLACEHOLDER.test(conn[2]) && !/^(pass(word)?|secret|user|admin|root|test)$/i.test(conn[2])) {
749
+ add(ctx, out, {
750
+ line: n, column: conn.index + 1,
751
+ rule: 'connection-string-password', category: 'secret',
752
+ severity: 'critical', confidence: ctx.isTest ? 'low' : 'high',
753
+ confidenceWhy: ctx.isTest
754
+ ? 'a URL with embedded credentials, but this path looks like a test fixture'
755
+ : 'a URL with a non-placeholder password embedded in it',
756
+ why: 'the database password is in source. It also leaks further than you expect: connection URLs end up in logs, error messages and crash reports.',
757
+ fix: 'build the URL from env vars at runtime — `postgres://${process.env.DB_USER}:${process.env.DB_PASS}@host/db` — and rotate this password, because it is already in git history.',
758
+ });
759
+ }
760
+
761
+ /**
762
+ * name = "value" where the name says credential and the value looks real.
763
+ *
764
+ * ⚠️ `matchAll`, NOT `exec`. With `exec` this read only the FIRST
765
+ * assignment on the line, so `{ url: "x", apiKey: "…" }` — one object
766
+ * literal, which is how config is actually written — was judged on `url`
767
+ * and the key beside it was never looked at.
768
+ */
769
+ // ⚠️ `['"]?` BEFORE THE COLON. In JSON — the single most likely place for a
770
+ // committed key after a .env — the NAME is quoted too (`"apiKey": "…"`),
771
+ // and without this the rule matched nothing in any .json file at all.
772
+ for (const assign of line.matchAll(/([A-Za-z_$][\w$.-]*)['"]?\s*[:=]\s*(['"`])((?:\\.|(?!\2).)*)\2/g)) {
773
+ if (!SECRET_NAME.test(assign[1]) || !looksLikeRealSecret(assign[3])) continue;
774
+ // Already reported, more precisely, by the known-prefix rule above.
775
+ if (KNOWN_KEY.some(([rx]) => rx.test(assign[3]))) continue;
776
+ add(ctx, out, {
777
+ line: n, column: assign.index + 1,
778
+ rule: 'hardcoded-secret', category: 'secret',
779
+ severity: 'high', confidence: ctx.isTest ? 'low' : 'medium',
780
+ confidenceWhy: ctx.isTest
781
+ ? `\`${assign[1]}\` is assigned a literal, but this path looks like a test fixture where a dummy value is expected`
782
+ : `\`${assign[1]}\` is assigned a string literal that does not look like a placeholder — it may still be a sample value, so check before rotating`,
783
+ why: 'a credential written into source is shared with everyone who can read the repo, and it survives deletion because git keeps history.',
784
+ fix: 'read it from the environment instead (`process.env.…` / `os.environ[…]`), keep the real value in a gitignored .env, and commit a `.env.example` with the key names only.',
785
+ });
786
+ }
787
+
788
+ // process.env.X || 'literal' — the fallback IS the committed secret.
789
+ const fallback = /process\.env\.([A-Z_][A-Z0-9_]*)\s*(\|\||\?\?)\s*(['"`])((?:\\.|(?!\3).)*)\3/.exec(line);
790
+ if (fallback && SECRET_NAME.test(fallback[1]) && looksLikeRealSecret(fallback[4])) {
791
+ add(ctx, out, {
792
+ line: n, column: fallback.index + 1,
793
+ rule: 'hardcoded-secret-fallback', category: 'secret',
794
+ severity: 'high', confidence: 'medium',
795
+ confidenceWhy: 'the env var is read correctly; the literal after `||` is the part that ships',
796
+ why: 'the fallback defeats the environment variable: if the variable is ever unset — a new machine, a missed CI secret — the app silently runs on this hardcoded value instead of failing.',
797
+ fix: 'fail loudly instead: `const key = process.env.API_KEY; if (!key) throw new Error("API_KEY is not set");`. A missing secret should stop the process, not be quietly substituted.',
798
+ });
799
+ }
800
+ }
801
+ }
802
+
803
+ // ── web ─────────────────────────────────────────────────────────────────────
804
+
805
+ function ruleWeb(ctx, out) {
806
+ for (let i = 0; i < ctx.code.length; i++) {
807
+ const line = ctx.code[i];
808
+ const n = i + 1;
809
+ if (!line.trim()) continue;
810
+
811
+ // dangerouslySetInnerHTML={{ __html: value }}
812
+ const dsi = /dangerouslySetInnerHTML\s*=\s*\{\{\s*__html\s*:\s*([^}]*)\}/.exec(line);
813
+ if (dsi) {
814
+ const val = dsi[1].trim();
815
+ // ⭐ THE SUSPECT GETS TO SPEAK: a named sanitizer is accepted at face
816
+ // value. We cannot verify it (no cross-file view) and flagging it anyway
817
+ // would punish the correct fix, which is how a reviewer gets muted.
818
+ if (!isLiteralOnly(val) && !SANITISERS.test(val)) {
819
+ const t = ctx.isTainted(val);
820
+ add(ctx, out, {
821
+ line: n, column: dsi.index + 1,
822
+ rule: 'dangerously-set-inner-html', category: 'web',
823
+ severity: 'high', confidence: t ? 'high' : 'medium',
824
+ confidenceWhy: t
825
+ ? `\`${val}\` traces back to request input in this file, and no sanitizer call appears on this line`
826
+ : 'the value is not a literal and no sanitizer call appears on this line; where it comes from is not visible here',
827
+ why: 'React escapes everything except this. Whatever HTML is in that value is parsed and run, so a stored `<img src=x onerror=fetch("/api/keys").then(...)>` executes in every viewer\'s session.',
828
+ fix: 'render it as text (`{value}`) if it is text. If it really must be HTML, sanitize on the way in AND out with DOMPurify: `dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(value) }}`.',
829
+ });
830
+ }
831
+ }
832
+
833
+ // el.innerHTML = value · insertAdjacentHTML · document.write
834
+ const ih = /\.\s*(innerHTML|outerHTML)\s*=\s*([^;]+)/.exec(line);
835
+ if (ih) {
836
+ const val = ih[2].trim();
837
+ if (!isLiteralOnly(val) && !SANITISERS.test(val)) {
838
+ const t = ctx.isTainted(val);
839
+ add(ctx, out, {
840
+ line: n, column: ih.index + 1,
841
+ rule: 'inner-html-assignment', category: 'web',
842
+ severity: t ? 'high' : 'medium', confidence: t ? 'high' : 'low',
843
+ confidenceWhy: t
844
+ ? `\`${val}\` traces back to input read from the URL or a request in this file`
845
+ : 'assigning a non-literal to innerHTML is only a bug if the value can carry markup from elsewhere — this file does not show where it comes from, so this is a prompt to check, not a defect',
846
+ why: 'assigning to innerHTML parses the string as HTML. Any markup in the value runs, including `<img onerror>` — which is XSS in the user\'s own session.',
847
+ fix: 'use `textContent` when you want text (it is also faster). When you need structure, build it with `createElement`/`append`, or sanitize with DOMPurify first.',
848
+ });
849
+ }
850
+ }
851
+
852
+ const iah = /\.\s*insertAdjacentHTML\s*\(/.exec(line);
853
+ if (iah) {
854
+ const args = readCall(ctx.code, i, line.indexOf('(', iah.index));
855
+ const val = args.split(',').slice(1).join(',').trim();
856
+ if (val && !isLiteralOnly(val) && !SANITISERS.test(val) && ctx.isTainted(val)) {
857
+ add(ctx, out, {
858
+ line: n, column: iah.index + 1,
859
+ rule: 'inner-html-assignment', category: 'web',
860
+ severity: 'high', confidence: 'high',
861
+ confidenceWhy: 'the inserted markup traces back to input read from the URL or a request in this file',
862
+ why: 'insertAdjacentHTML parses its argument as HTML, so markup in that value runs in the page.',
863
+ fix: 'insert text with `insertAdjacentText`, or sanitize with DOMPurify before inserting.',
864
+ });
865
+ }
866
+ }
867
+
868
+ // An HTML response assembled by interpolation, with no escaping in sight.
869
+ const htmlOut = /\b(res|response)\s*\.\s*(send|write|end)\s*\(\s*`/.exec(line);
870
+ if (htmlOut) {
871
+ const args = readCall(ctx.code, i, line.indexOf('(', htmlOut.index));
872
+ const exprs = interpolatedExprs(args, ctx.lang.id);
873
+ const tainted = exprs.filter((e) => ctx.isTainted(e) && !/escape|sanit|encodeURI/i.test(e));
874
+ if (/<[a-z!/]/i.test(args) && tainted.length > 0) {
875
+ add(ctx, out, {
876
+ line: n, column: htmlOut.index + 1,
877
+ rule: 'unescaped-html-output', category: 'web',
878
+ severity: 'high', confidence: 'high',
879
+ confidenceWhy: `\`${tainted[0]}\` traces back to request input and is interpolated straight into an HTML response with no escaping call`,
880
+ why: 'the value is written into the page as markup. A visitor who supplies `<script>` gets it executed in every viewer\'s browser — reflected XSS, and stored XSS if the value came from the database.',
881
+ fix: 'escape on output: replace `& < > " \'` with entities, use a template engine that escapes by default (EJS `<%= %>`, Handlebars `{{ }}`), or return JSON and render on the client.',
882
+ });
883
+ }
884
+ }
885
+
886
+ // Handlebars/Mustache triple-stache disables escaping.
887
+ const triple = /\{\{\{\s*[\w.]+\s*\}\}\}/.exec(line);
888
+ if (triple && (ctx.lang.id === 'html' || /\.hbs$|\.handlebars$|\.mustache$/i.test(ctx.path))) {
889
+ add(ctx, out, {
890
+ line: n, column: triple.index + 1,
891
+ rule: 'unescaped-html-output', category: 'web',
892
+ severity: 'medium', confidence: 'medium',
893
+ confidenceWhy: 'triple braces explicitly turn OFF the escaping the double-brace form gives you; whether the value is attacker-controlled is not visible here',
894
+ why: '`{{{ x }}}` inserts raw HTML. Double braces escape; triple braces are the opt-out, and are only safe for markup you generated yourself.',
895
+ fix: 'use `{{ x }}` unless the value is HTML you built and sanitized. If it must be raw, sanitize it before it reaches the template.',
896
+ });
897
+ }
898
+
899
+ // CORS wildcard together with credentials — the combination is the bug.
900
+ const wildcard = /(Access-Control-Allow-Origin['"\s:,]+\*)|(origin\s*:\s*['"]\*['"])/.exec(line);
901
+ if (wildcard && /(Access-Control-Allow-Credentials['"\s:,]+true)|(credentials\s*:\s*true)/i.test(ctx.codeText)) {
902
+ add(ctx, out, {
903
+ line: n, column: wildcard.index + 1,
904
+ rule: 'cors-wildcard-with-credentials', category: 'web',
905
+ severity: 'high', confidence: 'high',
906
+ confidenceWhy: 'both halves are present in this file: a `*` origin here and a credentials-true setting elsewhere in the same file',
907
+ why: 'either alone is defensible; together they mean any website can make authenticated requests to this API using the visitor\'s cookies and read the responses. Browsers reject the literal combination, so code like this usually goes on to reflect the request Origin instead, which is the same hole with extra steps.',
908
+ fix: 'list the origins you actually serve: `cors({ origin: ["https://app.example.com"], credentials: true })`. If you genuinely need any origin, you cannot also have credentials — use a bearer token instead of cookies.',
909
+ });
910
+ }
911
+
912
+ // A cookie set without the flags that make it a session cookie.
913
+ const ck = /\b(res|reply|ctx)\s*\.\s*(cookie|setCookie)\s*\(|\bcookies\s*\.\s*set\s*\(/.exec(line);
914
+ if (ck) {
915
+ const args = readCall(ctx.code, i, line.indexOf('(', ck.index));
916
+ const nameMatch = /['"`]([^'"`]+)['"`]/.exec(args);
917
+ const cookieName = nameMatch ? nameMatch[1] : '';
918
+ const missing = [];
919
+ if (!/httpOnly\s*:\s*true/i.test(args)) missing.push('httpOnly');
920
+ if (!/secure\s*:\s*true/i.test(args)) missing.push('secure');
921
+ if (!/sameSite\s*:/i.test(args)) missing.push('sameSite');
922
+ if (missing.length > 0) {
923
+ const authish = AUTH_COOKIE.test(camelWords(cookieName));
924
+ add(ctx, out, {
925
+ line: n, column: ck.index + 1,
926
+ rule: 'cookie-missing-flags', category: 'web',
927
+ severity: authish ? 'high' : 'low',
928
+ // ⚠️ A theme-preference cookie without httpOnly is CORRECT — the
929
+ // client needs to read it. Only a cookie whose NAME says session or
930
+ // token earns a report-level finding.
931
+ confidence: authish ? 'high' : 'low',
932
+ confidenceWhy: authish
933
+ ? `the cookie name \`${cookieName}\` says this carries a session or token, so the missing flags matter`
934
+ : `\`${cookieName || 'this cookie'}\` does not look like a session cookie — a preference cookie is often meant to be readable by JavaScript, so this may be entirely correct`,
935
+ why: `missing ${missing.join(', ')}. Without httpOnly any XSS on the page can read the cookie; without secure it is sent over plain HTTP; without sameSite another site can make the browser send it (CSRF).`,
936
+ fix: `pass the flags: \`{ httpOnly: true, secure: true, sameSite: "lax", path: "/" }\`. Use \`secure: process.env.NODE_ENV === "production"\` if you develop over http://localhost.`,
937
+ });
938
+ }
939
+ }
940
+ }
941
+ }
942
+
943
+ // ── auth / crypto ───────────────────────────────────────────────────────────
944
+
945
+ function ruleAuthCrypto(ctx, out) {
946
+ for (let i = 0; i < ctx.code.length; i++) {
947
+ const line = ctx.code[i];
948
+ const n = i + 1;
949
+ if (!line.trim()) continue;
950
+
951
+ // Math.random() — ONLY where the surrounding names say "this is a secret".
952
+ // ⚠️ Math.random() for a jitter, an animation or a demo dataset is fine and
953
+ // is by far the common case; flagging it unconditionally is exactly the
954
+ // noise that gets a reviewer disabled.
955
+ const mr = /\b(Math\.random\s*\(|random\.(random|randint|choice|randrange)\s*\()/.exec(line);
956
+ if (mr && TOKEN_NOUN.test(camelWords(line))) {
957
+ add(ctx, out, {
958
+ line: n, column: mr.index + 1,
959
+ rule: 'insecure-randomness', category: 'auth',
960
+ severity: 'critical', confidence: 'high',
961
+ confidenceWhy: `this line both calls a non-cryptographic RNG and names a secret (${(TOKEN_NOUN.exec(camelWords(line)) || [])[0]})`,
962
+ why: 'Math.random is a fast, seeded, predictable generator — it was never meant to be unguessable. Given a couple of outputs an attacker can recover its state and predict every later value, so password-reset tokens and session ids become forgeable.',
963
+ fix: 'use the CSPRNG: `crypto.randomUUID()` or `crypto.randomBytes(32).toString("hex")` in Node, `crypto.getRandomValues()` in the browser, `secrets.token_urlsafe(32)` in Python.',
964
+ });
965
+ }
966
+
967
+ // md5 / sha1 for a password.
968
+ const wh = /\b(createHash\s*\(\s*['"](md5|sha1)['"]|hashlib\.(md5|sha1)\s*\(|\bmd5\s*\()/i.exec(line);
969
+ if (wh) {
970
+ const near = [ctx.code[i - 1] ?? '', line, ctx.code[i + 1] ?? ''].join(' ');
971
+ const cred = CREDENTIAL_NOUN.test(camelWords(near));
972
+ add(ctx, out, {
973
+ line: n, column: wh.index + 1,
974
+ rule: 'weak-password-hash', category: 'auth',
975
+ severity: cred ? 'critical' : 'low',
976
+ // ⭐ md5 for an ETag, a cache key or a content fingerprint is FINE.
977
+ // Without a credential word nearby this stays `low` and never reaches
978
+ // the default report.
979
+ confidence: cred ? 'high' : 'low',
980
+ confidenceWhy: cred
981
+ ? 'a password/credential word appears within one line of this hash call'
982
+ : 'md5/sha1 is perfectly fine for a cache key, an ETag or a content fingerprint — reported only in case this one is hashing a credential',
983
+ why: 'md5 and sha1 are built to be fast, which is the opposite of what a password hash needs. Commodity hardware tries billions of candidates a second, so a stolen table of md5 password hashes is a table of passwords.',
984
+ fix: 'use a slow, salted password hash: bcrypt (`bcrypt.hash(pw, 12)`), scrypt (`crypto.scrypt`) or argon2. For non-password hashing, md5 is fine and this finding does not apply.',
985
+ });
986
+ }
987
+
988
+ // A JWT signed or verified with a literal secret.
989
+ const jwt = /\bjwt\s*\.\s*(sign|verify)\s*\(/.exec(line);
990
+ if (jwt) {
991
+ const args = readCall(ctx.code, i, line.indexOf('(', jwt.index));
992
+ const parts = splitTopLevel(args);
993
+ const secretArg = (parts[1] ?? '').trim();
994
+ if (secretArg && /^['"`]/.test(secretArg) && isLiteralOnly(secretArg)) {
995
+ add(ctx, out, {
996
+ line: n, column: jwt.index + 1,
997
+ rule: 'hardcoded-jwt-secret', category: 'auth',
998
+ severity: 'critical', confidence: 'high',
999
+ confidenceWhy: 'the signing key argument is a string literal in the source',
1000
+ why: 'the signing key is the only thing that makes a token unforgeable. Anyone who reads this file — or the published bundle, or the git history — can mint a token for any user, including an admin.',
1001
+ fix: 'read it from the environment (`process.env.JWT_SECRET`), refuse to start if it is missing, and use at least 32 random bytes. Rotating it logs everyone out, which is the correct price for a leaked key.',
1002
+ });
1003
+ }
1004
+ }
1005
+
1006
+ // jwt.decode does not verify. It is legitimate on an already-verified
1007
+ // token, so this is a MEDIUM prompt, not an accusation.
1008
+ const dec = /\bjwt\s*\.\s*decode\s*\(/.exec(line);
1009
+ if (dec) {
1010
+ add(ctx, out, {
1011
+ line: n, column: dec.index + 1,
1012
+ rule: 'jwt-decode-without-verify', category: 'auth',
1013
+ severity: 'high', confidence: 'medium',
1014
+ confidenceWhy: 'decoding is correct if this token was already verified elsewhere — this file does not show that, so it is worth one look',
1015
+ why: '`jwt.decode` only base64-decodes the token; it checks NO signature. If an authorisation decision is made from its output, anyone can hand-craft a token claiming `role: "admin"`.',
1016
+ fix: 'use `jwt.verify(token, secret, { algorithms: ["HS256"] })` anywhere the claims are trusted. Keep `decode` only for inspecting a token you have already verified.',
1017
+ });
1018
+ }
1019
+
1020
+ // Signature verification explicitly turned off.
1021
+ /**
1022
+ * ⚠️ `verify = False` IS PYTHON-CASED ON PURPOSE. An earlier version also
1023
+ * matched `verify: false` / `verify = false`, which is an ordinary
1024
+ * JavaScript feature flag (`const verify = false`) and has nothing to do
1025
+ * with TLS. Capital-F `False` only exists in python, where `verify=False`
1026
+ * on a `requests` call means exactly one thing.
1027
+ */
1028
+ const off = /(algorithms\s*:\s*\[\s*['"]none['"])|(\bverify\s*=\s*False\b)|(rejectUnauthorized\s*:\s*false)|(NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0)|(InsecureSkipVerify\s*:\s*true)/.exec(line);
1029
+ if (off) {
1030
+ add(ctx, out, {
1031
+ line: n, column: off.index + 1,
1032
+ rule: 'verification-disabled', category: 'auth',
1033
+ severity: 'critical', confidence: 'high',
1034
+ confidenceWhy: 'the switch that performs the check is explicitly set to off on this line',
1035
+ why: 'this turns off the check that makes the channel or the token trustworthy. With TLS verification off, anyone on the network path can present their own certificate and read and rewrite the traffic; with `alg: none` accepted, any token validates.',
1036
+ fix: 'remove the flag. If a self-signed certificate is the reason, add that certificate to the trust store (`NODE_EXTRA_CA_CERTS`) instead of disabling verification for every connection the process makes.',
1037
+ });
1038
+ }
1039
+ }
1040
+ }
1041
+
1042
+ /** Split a call's argument text on top-level commas only. */
1043
+ function splitTopLevel(text) {
1044
+ const parts = [];
1045
+ let depth = 0;
1046
+ let cur = '';
1047
+ let quote = null;
1048
+ for (const ch of text) {
1049
+ if (quote) { cur += ch; if (ch === quote) quote = null; continue; }
1050
+ if (`'"\``.includes(ch)) { quote = ch; cur += ch; continue; }
1051
+ if ('([{'.includes(ch)) depth++;
1052
+ if (')]}'.includes(ch)) depth--;
1053
+ if (ch === ',' && depth === 0) { parts.push(cur); cur = ''; continue; }
1054
+ cur += ch;
1055
+ }
1056
+ parts.push(cur);
1057
+ return parts;
1058
+ }
1059
+
1060
+ // ── footguns ────────────────────────────────────────────────────────────────
1061
+
1062
+ /** A comment that says the silence is on purpose. The author gets the benefit. */
1063
+ const DELIBERATE = /\b(ignore|ignored|intentional|deliberate|on purpose|best[- ]effort|no[- ]?op|noop|not important|don't care|dont care|fine|expected)\b/i;
1064
+
1065
+ function ruleFootguns(ctx, out) {
1066
+ for (let i = 0; i < ctx.code.length; i++) {
1067
+ const line = ctx.code[i];
1068
+ const n = i + 1;
1069
+ if (!line.trim()) continue;
1070
+
1071
+ // ── an empty catch ─────────────────────────────────────────────────────
1072
+ const cat = /\bcatch\s*(\([^)]*\))?\s*\{/.exec(line);
1073
+ if (cat) {
1074
+ const open = line.indexOf('{', cat.index);
1075
+ const { body, endLine } = readBlock(ctx.code, i, open, 12);
1076
+ if (!body.trim()) {
1077
+ // Comments were blanked out of `code`, so an "empty" body may still
1078
+ // carry an explanation in `raw`. Read it before accusing anyone.
1079
+ const rawBody = ctx.raw.slice(i, endLine + 1).join(' ');
1080
+ if (!DELIBERATE.test(rawBody)) {
1081
+ add(ctx, out, {
1082
+ line: n, column: cat.index + 1,
1083
+ rule: 'swallowed-error', category: 'footgun',
1084
+ severity: 'medium', confidence: 'high',
1085
+ confidenceWhy: 'the catch block contains no statements at all, and no comment says the silence is deliberate',
1086
+ why: 'the failure is discarded. The function returns as if it worked, the caller carries on with missing data, and the first symptom is wrong output somewhere else with no trace of the real cause. This is the single most common reason a generated app "runs" while doing nothing.',
1087
+ fix: 'at minimum log it with context (`console.error("saving profile failed", err)`); better, rethrow or return a failure the caller must handle. If ignoring it really is correct, say so in a comment — this reviewer reads it and stays quiet.',
1088
+ });
1089
+ }
1090
+ }
1091
+ }
1092
+
1093
+ // .catch(() => {})
1094
+ const pcat = /\.catch\s*\(\s*(?:\([^)]*\)|[\w$]+)?\s*=>\s*\{\s*\}\s*\)/.exec(line);
1095
+ if (pcat && !DELIBERATE.test(ctx.raw[i] ?? '')) {
1096
+ add(ctx, out, {
1097
+ line: n, column: pcat.index + 1,
1098
+ rule: 'swallowed-error', category: 'footgun',
1099
+ severity: 'medium', confidence: 'high',
1100
+ confidenceWhy: 'the rejection handler has an empty body and no comment explains it',
1101
+ why: 'the promise rejection is discarded, so a failed request or write looks exactly like a successful one.',
1102
+ fix: 'log or handle it: `.catch((err) => console.error("upload failed", err))`, or let it reject and handle it where the outcome matters.',
1103
+ });
1104
+ }
1105
+
1106
+ // except …: pass
1107
+ if (ctx.lang.id === 'py' && /^\s*except\b.*:\s*$/.test(line)) {
1108
+ let j = i + 1;
1109
+ while (j < ctx.code.length && !ctx.code[j].trim()) j++;
1110
+ if (j < ctx.code.length && /^\s*pass\s*$/.test(ctx.code[j])) {
1111
+ const rawBody = ctx.raw.slice(i, j + 1).join(' ');
1112
+ if (!DELIBERATE.test(rawBody)) {
1113
+ add(ctx, out, {
1114
+ line: n, column: (line.length - line.trimStart().length) + 1,
1115
+ rule: 'swallowed-error', category: 'footgun',
1116
+ severity: 'medium', confidence: 'high',
1117
+ confidenceWhy: 'the except body is exactly `pass` and no comment explains it',
1118
+ why: 'the exception is discarded, so the caller cannot tell a failure from a success.',
1119
+ fix: 'log it (`logging.exception("…")`), narrow the except to the one error you expect, or re-raise. If it is deliberate, say so in a comment.',
1120
+ });
1121
+ }
1122
+ }
1123
+ }
1124
+
1125
+ // ── an unbounded loop whose size comes from the request ────────────────
1126
+ const forBound = /for\s*\([^;]*;\s*[\w$]+\s*<\s*([^;)]+)[;)]/.exec(line);
1127
+ if (forBound && ctx.isTainted(forBound[1])) {
1128
+ add(ctx, out, {
1129
+ line: n, column: forBound.index + 1,
1130
+ rule: 'unbounded-input-loop', category: 'footgun',
1131
+ severity: 'high', confidence: 'high',
1132
+ confidenceWhy: `the loop bound \`${forBound[1].trim()}\` traces back to request input in this file`,
1133
+ why: 'the caller chooses how many iterations this process performs. One request with a large number pins a CPU and stops the server answering anyone — a denial of service that needs no tooling to exploit.',
1134
+ fix: 'clamp it before looping: `const n = Math.min(Number(req.query.n) || 0, 100);`, and reject anything larger with a 400 rather than silently truncating.',
1135
+ });
1136
+ }
1137
+ const sizer = /\b(new\s+Array|Array|Buffer\.alloc|\.repeat)\s*\(\s*([^),]+)/.exec(line);
1138
+ if (sizer && ctx.isTainted(sizer[2])) {
1139
+ add(ctx, out, {
1140
+ line: n, column: sizer.index + 1,
1141
+ rule: 'unbounded-input-loop', category: 'footgun',
1142
+ severity: 'high', confidence: 'high',
1143
+ confidenceWhy: `the allocation size \`${sizer[2].trim()}\` traces back to request input in this file`,
1144
+ why: 'the caller chooses how much memory this allocates. A single request asking for a huge size exhausts the heap and takes the process down.',
1145
+ fix: 'clamp the value against a maximum before allocating, and reject anything above it with a 400.',
1146
+ });
1147
+ }
1148
+
1149
+ // ── an open redirect ───────────────────────────────────────────────────
1150
+ const red = /\b(res|response)\s*\.\s*redirect\s*\(|\b(window\.)?location\s*\.\s*(href|assign|replace)\s*[=(]|\bwindow\.location\s*=/.exec(line);
1151
+ if (red) {
1152
+ // `res.redirect(302, url)` — drop a leading status code so the target is
1153
+ // what gets taint-checked, not the number in front of it.
1154
+ const target = line.slice(red.index + red[0].length).replace(/^\s*\d+\s*,\s*/, '');
1155
+ if (ctx.isTainted(target) && !/startsWith\s*\(|allow|whitelist|allowlist|new URL\s*\(/i.test(line)) {
1156
+ add(ctx, out, {
1157
+ line: n, column: red.index + 1,
1158
+ rule: 'unvalidated-redirect', category: 'footgun',
1159
+ severity: 'medium', confidence: 'high',
1160
+ confidenceWhy: 'the redirect target traces back to request input in this file and no allow-list check appears on this line',
1161
+ why: 'the caller picks where your site sends the visitor. `?next=https://evil.example/login` produces a phishing page the victim reached by clicking YOUR domain, which is exactly what makes it work.',
1162
+ fix: 'only accept paths on your own site — reject anything not starting with a single `/` (`if (!/^\\/[^/]/.test(next)) next = "/";`) — or map an opaque key to a fixed table of destinations.',
1163
+ });
1164
+ }
1165
+ }
1166
+
1167
+ // ── path traversal ─────────────────────────────────────────────────────
1168
+ const fsCall = /\b(readFile|readFileSync|writeFile|writeFileSync|createReadStream|createWriteStream|sendFile|unlink|unlinkSync|readdir|readdirSync|open)\s*\(|\bpath\s*\.\s*(join|resolve)\s*\(/.exec(line);
1169
+ if (fsCall) {
1170
+ const args = readCall(ctx.code, i, line.indexOf('(', fsCall.index));
1171
+ const guarded = /basename\s*\(|startsWith\s*\(|allow|whitelist|allowlist|normalize\s*\(/i.test(
1172
+ [ctx.code[i - 2] ?? '', ctx.code[i - 1] ?? '', line, ctx.code[i + 1] ?? ''].join(' '),
1173
+ );
1174
+ if (ctx.isTainted(args)) {
1175
+ add(ctx, out, {
1176
+ line: n, column: fsCall.index + 1,
1177
+ rule: 'path-traversal', category: 'footgun',
1178
+ severity: 'high',
1179
+ confidence: guarded ? 'low' : 'high',
1180
+ confidenceWhy: guarded
1181
+ ? 'a path is built from request input, but a basename/normalize/allow-list check appears nearby — check that it actually covers this call'
1182
+ : 'the path is built from request input and no basename, normalize-and-compare, or allow-list check appears within two lines',
1183
+ why: 'the caller chooses which file is opened. `../../../../etc/passwd` — or `..%2f..%2f` after URL-decoding — walks out of the directory you meant, and on a server that serves user files it reads your .env.',
1184
+ fix: 'strip the directory part (`path.basename(name)`) when only a filename is meant. When subdirectories are legitimate, resolve then verify containment: `const full = path.resolve(ROOT, rel); if (!full.startsWith(ROOT + path.sep)) throw new Error("outside root");`.',
1185
+ });
1186
+ }
1187
+ }
1188
+ }
1189
+ }
1190
+
1191
+ // ── the report side ─────────────────────────────────────────────────────────
1192
+
1193
+ /**
1194
+ * ⚠️⚠️ SAY WHAT WE DID NOT DO. Printed with every non-empty summary so nobody
1195
+ * reads "3 findings" as "and there are exactly 3 problems".
1196
+ */
1197
+ export const REVIEW_CAVEAT = 'Pattern review only — one file at a time, no types, no cross-file view. It cannot see a sanitizer or a taint that lives in another module, so a short list is not a clean bill of health.';
1198
+
1199
+ /**
1200
+ * Review everything a run wrote. The lead calls this after the work, with a
1201
+ * reader closed over the workspace.
1202
+ *
1203
+ * ⚠️ IT NEVER THROWS AND IT NEVER READS ANYTHING BY ITSELF. `read` is a
1204
+ * parameter with a refusing default, which is what lets every branch below be
1205
+ * tested with no filesystem at all.
1206
+ *
1207
+ * @param {string[]} paths workspace-relative paths that were written
1208
+ * @param {{read?: (p:string)=>string, minConfidence?: string, maxFiles?: number}} [opts]
1209
+ */
1210
+ export function reviewWrittenFiles(paths, opts = {}) {
1211
+ const read = opts.read ?? (() => { throw new Error('no reader was supplied to reviewWrittenFiles — pass { read } so it can load the files it is asked to review'); });
1212
+ const minConfidence = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
1213
+ const maxFiles = opts.maxFiles ?? 60;
1214
+
1215
+ const findings = [];
1216
+ const reviewed = [];
1217
+ const skipped = [];
1218
+
1219
+ for (const p of (paths ?? []).slice(0, maxFiles)) {
1220
+ if (typeof p !== 'string' || !p.trim()) continue;
1221
+ let text;
1222
+ try {
1223
+ text = read(p);
1224
+ } catch (err) {
1225
+ // A file deleted by a later step, or a binary, is not a review failure.
1226
+ skipped.push({ path: p, reason: `could not read it: ${err && err.message}` });
1227
+ continue;
1228
+ }
1229
+ if (typeof text !== 'string') { skipped.push({ path: p, reason: 'not text' }); continue; }
1230
+ reviewed.push(p);
1231
+ for (const f of reviewCode(p, text, { minConfidence })) findings.push(f);
1232
+ }
1233
+
1234
+ findings.sort((a, b) =>
1235
+ (SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity])
1236
+ || (CONFIDENCE_RANK[b.confidence] - CONFIDENCE_RANK[a.confidence])
1237
+ || a.path.localeCompare(b.path)
1238
+ || (a.line - b.line));
1239
+
1240
+ return { findings, reviewed, skipped };
1241
+ }
1242
+
1243
+ /**
1244
+ * Render the findings for the end-of-run report.
1245
+ *
1246
+ * ⚠️⚠️ RETURNS `[]` WHEN THERE IS NOTHING TO SAY. It must never print an
1247
+ * all-clear. A regex layer that says "no vulnerabilities found" is making a
1248
+ * claim it cannot support, and a user who reads it once will trust it forever.
1249
+ * Silence is the honest output of a reviewer that found nothing.
1250
+ *
1251
+ * @param {{findings:any[]}|any[]} result output of `reviewWrittenFiles`, or a bare findings array
1252
+ * @param {{paint?: any, max?: number}} [opts]
1253
+ * @returns {string[]} lines
1254
+ */
1255
+ export function formatReviewSummary(result, opts = {}) {
1256
+ const findings = Array.isArray(result) ? result : (result?.findings ?? []);
1257
+ if (findings.length === 0) return [];
1258
+ const p = opts.paint ?? { red: (t) => t, gold: (t) => t, dim: (t) => t };
1259
+ const max = opts.max ?? 8;
1260
+
1261
+ const worst = findings.filter((f) => f.severity === 'critical').length;
1262
+ const head = worst > 0
1263
+ ? p.red(`⚠ code review — ${findings.length} finding${findings.length === 1 ? '' : 's'} in the code just written, ${worst} critical`)
1264
+ : p.gold(`⚠ code review — ${findings.length} finding${findings.length === 1 ? '' : 's'} in the code just written`);
1265
+
1266
+ const lines = [head];
1267
+ for (const f of findings.slice(0, max)) {
1268
+ lines.push(` ${f.path}:${f.line} ${f.rule} (${f.severity}, ${f.confidence} confidence)`);
1269
+ lines.push(p.dim(` why: ${f.why}`));
1270
+ lines.push(p.dim(` fix: ${f.fix}`));
1271
+ lines.push(p.dim(` confidence: ${f.confidenceWhy}`));
1272
+ }
1273
+ if (findings.length > max) lines.push(p.dim(` … and ${findings.length - max} more — run \`review_code\` on the file for the full list`));
1274
+ lines.push(p.dim(` ${REVIEW_CAVEAT}`));
1275
+ return lines;
1276
+ }
1277
+
1278
+ /** The same summary as a machine-readable object, for `--json`. */
1279
+ export function reviewToJson(result) {
1280
+ const findings = Array.isArray(result) ? result : (result?.findings ?? []);
1281
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
1282
+ for (const f of findings) counts[f.severity] = (counts[f.severity] ?? 0) + 1;
1283
+ return {
1284
+ findings,
1285
+ counts,
1286
+ reviewed: Array.isArray(result) ? undefined : result?.reviewed,
1287
+ caveat: REVIEW_CAVEAT,
1288
+ };
1289
+ }
1290
+
1291
+ // ── the tool ────────────────────────────────────────────────────────────────
1292
+
1293
+ export function codeReviewToolSchemas() {
1294
+ return [
1295
+ {
1296
+ type: 'function',
1297
+ function: {
1298
+ name: 'review_code',
1299
+ description: [
1300
+ 'Review a source file you wrote for security defects before telling the user it is done.',
1301
+ 'Covers injection (SQL built by concatenation, shell strings, eval of a computed value), secrets committed in source,',
1302
+ 'web issues (dangerouslySetInnerHTML, innerHTML, unescaped output, CORS wildcard with credentials, cookies missing httpOnly/secure/sameSite),',
1303
+ 'auth and crypto (Math.random for tokens, md5/sha1 for passwords, hardcoded JWT secrets, verification switched off),',
1304
+ 'and footguns (swallowed errors, loops sized by request input, unvalidated redirects, path traversal).',
1305
+ 'Every finding names the line, why it is dangerous, the concrete fix, and a confidence with the reason for it —',
1306
+ 'a `low` confidence finding is a prompt to look, not a defect.',
1307
+ 'It is a pattern layer over ONE file: it has no types and no cross-file view, so a clean result is NOT a guarantee of safety.',
1308
+ 'Fix anything critical or high before reporting the work as finished.',
1309
+ ].join(' '),
1310
+ parameters: {
1311
+ type: 'object',
1312
+ properties: {
1313
+ path: {
1314
+ type: 'string',
1315
+ description: 'Workspace-relative path of the file to review. The extension selects the language; the path alone also decides whether this is a credential file that must never be committed.',
1316
+ },
1317
+ content: {
1318
+ type: 'string',
1319
+ description: 'The source text. Optional — omit it to review the file as it is on disk. Pass it to review code you have not written yet.',
1320
+ },
1321
+ minConfidence: {
1322
+ type: 'string',
1323
+ enum: ['low', 'medium', 'high'],
1324
+ description: 'Lowest confidence to report. Default "medium". Use "low" for a pre-release sweep — it includes guesses that are often correct behaviour.',
1325
+ },
1326
+ },
1327
+ required: ['path'],
1328
+ },
1329
+ },
1330
+ },
1331
+ ];
1332
+ }
1333
+
1334
+ /**
1335
+ * Execute `review_code`.
1336
+ *
1337
+ * @param {{path:string, content?:string, minConfidence?:string}} args
1338
+ * @param {{read?: (p:string)=>string}} [deps] `read` is injected so this
1339
+ * function is testable with no filesystem; the lead closes it over
1340
+ * `resolveInWorkspace`.
1341
+ */
1342
+ export function executeReviewCode(args = {}, deps = {}) {
1343
+ const path = typeof args.path === 'string' ? args.path.trim() : '';
1344
+ if (!path) {
1345
+ return { error: 'review_code needs a `path` — pass the workspace-relative path of the file to review.' };
1346
+ }
1347
+
1348
+ let content = typeof args.content === 'string' ? args.content : null;
1349
+ if (content === null) {
1350
+ if (typeof deps.read !== 'function') {
1351
+ // ⭐ EVERY REFUSAL NAMES THE WAY OUT.
1352
+ return { error: `review_code could not read ${path} and no content was supplied — pass \`content\` with the source text, or write the file first so it can be read from the workspace.` };
1353
+ }
1354
+ try {
1355
+ content = deps.read(path);
1356
+ } catch (err) {
1357
+ return { error: `review_code could not read ${path}: ${err && err.message}. Pass \`content\` with the source text instead, or check the path is relative to the workspace root.` };
1358
+ }
1359
+ }
1360
+ if (typeof content !== 'string') {
1361
+ return { error: `review_code got no text for ${path} — pass \`content\` with the source, or point at a text file.` };
1362
+ }
1363
+
1364
+ const findings = reviewCode(path, content, { minConfidence: args.minConfidence ?? DEFAULT_MIN_CONFIDENCE });
1365
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
1366
+ for (const f of findings) counts[f.severity] = (counts[f.severity] ?? 0) + 1;
1367
+
1368
+ return {
1369
+ path,
1370
+ findings,
1371
+ counts,
1372
+ /**
1373
+ * ⚠️ THE ZERO CASE IS WORDED CAREFULLY. "No findings" is a statement about
1374
+ * this reviewer, not about the file. It must not read as an all-clear,
1375
+ * because the model will quote it to the user as one.
1376
+ */
1377
+ summary: findings.length === 0
1378
+ ? `No findings at ${args.minConfidence ?? DEFAULT_MIN_CONFIDENCE} confidence or above in ${path}. That means these patterns did not match — it is not a guarantee the file is safe.`
1379
+ : `${findings.length} finding${findings.length === 1 ? '' : 's'} in ${path}: ${counts.critical} critical, ${counts.high} high, ${counts.medium} medium, ${counts.low} low. Fix critical and high before calling this done.`,
1380
+ caveat: REVIEW_CAVEAT,
1381
+ };
1382
+ }