ai-install-guard 0.0.0 → 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.
@@ -0,0 +1,374 @@
1
+ import { isLive, type ModuleInfo, type ModuleUse, type ScanContext } from './context'
2
+ import { describePrograms, type ProgramUse } from './programs'
3
+ import { spanKinds } from './text'
4
+ import { row, type CheckResult, type Evidence, type Status } from './types'
5
+ import type { ValidatorReport } from './validator'
6
+
7
+ const GROUP = 'mod' as const
8
+
9
+ type Risk = 'high' | 'medium' | 'low'
10
+
11
+ type Capability = { risk: Risk; plain: string }
12
+
13
+ /** What each `$` call lets a mod do, by `noun.method`, then by `noun.*`. */
14
+ const CALLS: Record<string, Capability> = {
15
+ 'process.run': { risk: 'high', plain: 'runs programs on your machine' },
16
+ 'process.spawn': { risk: 'high', plain: 'starts programs on your machine and streams their output' },
17
+ 'fs.write': { risk: 'high', plain: 'writes files anywhere it can reach' },
18
+ 'fs.read': { risk: 'low', plain: 'reads files' },
19
+ 'fs.list': { risk: 'low', plain: 'lists folders' },
20
+ 'fs.stat': { risk: 'low', plain: 'looks up files' },
21
+ 'fs.exists': { risk: 'low', plain: 'checks whether files exist' },
22
+ 'fs.ancestors': { risk: 'low', plain: 'looks for files in parent folders' },
23
+ 'http.fetch': { risk: 'medium', plain: 'sends requests over the network' },
24
+ 'env.get': { risk: 'medium', plain: 'reads environment variables' },
25
+ 'env.set': { risk: 'medium', plain: 'sets environment variables for the session' },
26
+ 'model.complete': { risk: 'medium', plain: 'calls the model on your account' },
27
+ 'model.fork': { risk: 'medium', plain: 'asks the model about the whole conversation' },
28
+ 'agent.spawn': { risk: 'medium', plain: 'starts subagents' },
29
+ 'agent.register': { risk: 'low', plain: 'adds a subagent type' },
30
+ 'tool.call': { risk: 'medium', plain: 'calls tools itself (Bash, Write, ...)' },
31
+ 'tool.register': { risk: 'low', plain: 'adds a tool the model can call' },
32
+ 'prompt.submit': { risk: 'medium', plain: 'sends prompts as if you typed them' },
33
+ 'prompt.fill': { risk: 'low', plain: 'types into your prompt box' },
34
+ 'command.run': { risk: 'medium', plain: 'runs slash commands, which can include /plugin' },
35
+ 'command.register': { risk: 'low', plain: 'adds a slash command' },
36
+ 'config.set': { risk: 'high', plain: 'changes your settings' },
37
+ 'settings.write': { risk: 'high', plain: 'changes your settings' },
38
+ 'settings.read': { risk: 'medium', plain: 'reads your settings' },
39
+ 'session.append': { risk: 'medium', plain: 'adds messages the model reads' },
40
+ 'session.messages': { risk: 'medium', plain: 'reads the conversation' },
41
+ 'store.get': { risk: 'low', plain: 'reads its own saved data' },
42
+ 'store.set': { risk: 'low', plain: 'saves its own data' },
43
+ 'store.delete': { risk: 'low', plain: 'deletes its own saved data' },
44
+ 'store.keys': { risk: 'low', plain: 'lists its own saved data' },
45
+ 'state.get': { risk: 'low', plain: 'reads session values' },
46
+ 'state.set': { risk: 'low', plain: 'sets its own session values' },
47
+ }
48
+
49
+ const NOUNS: Record<string, Capability> = {
50
+ process: { risk: 'high', plain: 'runs programs' },
51
+ fs: { risk: 'low', plain: 'uses the file system' },
52
+ http: { risk: 'medium', plain: 'uses the network' },
53
+ env: { risk: 'medium', plain: 'uses environment variables' },
54
+ model: { risk: 'medium', plain: 'calls the model' },
55
+ settings: { risk: 'medium', plain: 'uses your settings' },
56
+ config: { risk: 'medium', plain: 'uses your settings' },
57
+ ui: { risk: 'low', plain: 'draws in the interface' },
58
+ clock: { risk: 'low', plain: 'uses timers' },
59
+ audio: { risk: 'low', plain: 'plays sounds' },
60
+ store: { risk: 'low', plain: 'keeps its own data' },
61
+ state: { risk: 'low', plain: 'keeps session values' },
62
+ session: { risk: 'low', plain: 'reads session details' },
63
+ plugin: { risk: 'low', plain: 'reads its own plugin details' },
64
+ }
65
+
66
+ /** What hooking each event lets a mod do. */
67
+ const EVENTS: Record<string, Capability> = {
68
+ 'tool.call': { risk: 'high', plain: 'sees every tool call and can deny, rewrite or answer it' },
69
+ 'tool.check': { risk: 'high', plain: 'decides whether tool calls are allowed, so it can auto-approve them' },
70
+ 'plugin.register': { risk: 'high', plain: 'judges other plugins as they load and can refuse them' },
71
+ 'prompt.compose': { risk: 'high', plain: 'changes the system prompt' },
72
+ '*': { risk: 'high', plain: 'hooks every event' },
73
+ 'prompt.submit': { risk: 'medium', plain: 'sees and can rewrite every prompt you send' },
74
+ 'prompt.mention': { risk: 'medium', plain: 'sees and can rewrite @-mentions' },
75
+ 'tool.describe': { risk: 'medium', plain: 'rewrites the tool descriptions the model reads' },
76
+ 'session.append': { risk: 'medium', plain: 'sees and can rewrite every message the conversation keeps' },
77
+ 'session.receive': { risk: 'medium', plain: 'sees and can drop messages delivered to the session' },
78
+ 'skill.prompt': { risk: 'medium', plain: 'rewrites skill prompts' },
79
+ 'config.set': { risk: 'medium', plain: 'can block or change settings changes' },
80
+ 'agent.offer': { risk: 'low', plain: 'decides which subagent types the model sees' },
81
+ 'session.start': { risk: 'low', plain: 'runs when a session starts' },
82
+ 'session.end': { risk: 'low', plain: 'runs when a session ends' },
83
+ 'ui.render': { risk: 'low', plain: 'draws in the interface' },
84
+ 'ui.press': { risk: 'low', plain: 'reacts to its own buttons' },
85
+ }
86
+
87
+ const NAMESPACES: Record<string, Capability> = {
88
+ classic: { risk: 'medium', plain: 'sees classic hook events' },
89
+ telemetry: { risk: 'medium', plain: 'sees telemetry' },
90
+ turn: { risk: 'low', plain: 'follows each turn' },
91
+ ui: { risk: 'low', plain: 'draws in the interface' },
92
+ fs: { risk: 'medium', plain: "sees and can change other plugins' file access" },
93
+ process: { risk: 'medium', plain: "sees and can change other plugins' processes" },
94
+ http: { risk: 'medium', plain: "sees and can change other plugins' network calls" },
95
+ model: { risk: 'medium', plain: "sees and can change other plugins' model calls" },
96
+ }
97
+
98
+ /** Built-in commands whose interception matters: installs, settings, permissions. */
99
+ const SENSITIVE_COMMANDS = new Set(['plugin', 'plugins', 'config', 'permissions', 'mcp', 'hooks', 'login', 'logout', 'model', 'agents', 'memory', 'install', 'sandbox'])
100
+
101
+ const COSMETIC = /\b(?:themes?|colou?r(?:s|ful|scheme)?|palette|fonts?|status ?line|statusline|spinners?|banners?|emoji|appearance|cosmetic|look and feel|sounds?|animations?|ascii art|greetings?|quotes? of the day|fortune|wallpaper|decorat\w*)\b/i
102
+
103
+ export function modChecks(ctx: ScanContext, validator: ValidatorReport | undefined): CheckResult[] {
104
+ if (ctx.modules.length === 0) return []
105
+ const modules = ctx.modules.map(info => withValidator(info, validator))
106
+ const uses = modules.flatMap(info => [
107
+ ...dedupe(info.events).map(use => ({ use, kind: 'event' as const, capability: eventCapability(use) })),
108
+ ...dedupe(info.calls).map(use => ({ use, kind: 'call' as const, capability: callCapability(use.name) })),
109
+ ])
110
+ // A call is explained when the README (or the manifest's description)
111
+ // names it as a name of its own, not after a negation ("never calls
112
+ // process.run"), on a line or table row that says something about it: at
113
+ // least three words besides the name. A wildcard is never explained. The
114
+ // words are kept to quote beside the call, marked as the candidate's.
115
+ const sources: { where: Explanation['where']; text: string }[] = [
116
+ { where: 'README', text: (ctx.readme?.text ?? '').replace(/<!--[\s\S]{0,20000}?-->/g, ' ') },
117
+ { where: 'description', text: typeof ctx.manifest?.description === 'string' ? ctx.manifest.description : '' },
118
+ ]
119
+ const explanationCache = new Map<string, Explanation | undefined>()
120
+ const explanationFor = (name: string): Explanation | undefined => {
121
+ if (explanationCache.has(name)) return explanationCache.get(name)
122
+ const answer = findExplanation(name, sources)
123
+ explanationCache.set(name, answer)
124
+ return answer
125
+ }
126
+ const isExplained = (name: string): boolean => explanationFor(name) !== undefined
127
+
128
+ const inventory: Evidence[] = uses.map(({ use, kind, capability }) => ({
129
+ file: use.file,
130
+ line: use.line > 0 ? use.line : undefined,
131
+ excerpt: `${kind === 'event' ? 'hooks' : 'calls'} ${use.name}${use.detail === undefined ? '' : ` ${use.detail}`}: ${capability.plain}`,
132
+ status: 'info',
133
+ }))
134
+
135
+ const high: Evidence[] = []
136
+ const medium: Evidence[] = []
137
+ for (const { use, kind, capability } of uses) {
138
+ const excerpt = `${kind === 'event' ? 'hooks' : 'calls'} ${use.name}: ${capability.plain}`
139
+ if (capability.risk === 'high') {
140
+ const explainedHere = isExplained(use.name)
141
+ high.push({
142
+ file: use.file,
143
+ line: use.line > 0 ? use.line : undefined,
144
+ excerpt: explainedHere ? `${excerpt} (the README names it)` : excerpt,
145
+ status: explainedHere ? 'warn' : 'fail',
146
+ })
147
+ } else if (capability.risk === 'medium') {
148
+ // Still a warning when explained: network access always deserves a look.
149
+ medium.push({ file: use.file, line: use.line > 0 ? use.line : undefined, excerpt: isExplained(use.name) ? `${excerpt} (the README names it)` : excerpt, status: 'warn' })
150
+ }
151
+ }
152
+ const allCalls = new Set(modules.flatMap(info => info.calls.map(call => call.name)))
153
+ if (allCalls.has('fs.read') && allCalls.has('http.fetch')) {
154
+ const explainedHere = isExplained('http.fetch') && isExplained('fs.read')
155
+ high.push({
156
+ file: modules[0]?.entry ?? '-',
157
+ excerpt: `reads files and sends requests: it can send your files out${explainedHere ? ' (the README names both)' : ''}`,
158
+ status: explainedHere ? 'warn' : 'fail',
159
+ })
160
+ }
161
+ for (const info of modules) {
162
+ for (const url of dedupe(info.urls)) medium.push({ file: url.file, line: url.line, excerpt: `network address in the module: ${url.name}`, status: 'info' })
163
+ for (const name of dedupe(info.envNames)) medium.push({ file: name.file, line: name.line, excerpt: `reads the environment variable ${name.name}`, status: 'warn' })
164
+ }
165
+
166
+ const description = [ctx.manifest?.description, ctx.manifest?.name, proseOf(ctx.readme?.text ?? '').slice(0, 800)].filter(item => typeof item === 'string').join('\n')
167
+ const cosmetic = COSMETIC.exec(description)
168
+ const strongest = uses.find(item => item.capability.risk === 'high')
169
+ const mismatch: Evidence[] =
170
+ cosmetic !== null && strongest !== undefined
171
+ ? [{
172
+ file: strongest.use.file,
173
+ line: strongest.use.line > 0 ? strongest.use.line : undefined,
174
+ excerpt: `describes itself as "${cosmetic[0]}" but ${strongest.kind === 'event' ? 'hooks' : 'calls'} ${strongest.use.name}`,
175
+ status: 'fail',
176
+ }]
177
+ : []
178
+
179
+ return [
180
+ {
181
+ id: 'mod.uses',
182
+ group: GROUP,
183
+ title: 'Events it hooks and calls it makes',
184
+ status: 'info',
185
+ severity: 'none',
186
+ evidence: inventory,
187
+ advice: 'This is everything the mod can do through Claude Code. Compare it with what it says it does.',
188
+ },
189
+ row(
190
+ {
191
+ id: 'mod.high-risk',
192
+ group: GROUP,
193
+ title: 'High-risk capabilities',
194
+ advice: 'Running programs, writing files, deciding tool permissions, judging other plugins or changing the system prompt is what a mod needs to take over a session. Install only if its purpose needs it and its README says why.',
195
+ summary: capabilitySummary(uses.filter(item => item.capability.risk === 'high'), explanationFor, ctx, modules, allCalls.has('fs.read') && allCalls.has('http.fetch')),
196
+ },
197
+ high,
198
+ 'fail',
199
+ ),
200
+ row(
201
+ {
202
+ id: 'mod.medium-risk',
203
+ group: GROUP,
204
+ title: 'Network, environment and prompt access',
205
+ advice: 'Check every address it talks to and every variable it reads against what it says it does.',
206
+ summary: capabilitySummary(uses.filter(item => item.capability.risk === 'medium'), explanationFor, ctx, modules, false),
207
+ },
208
+ medium,
209
+ ),
210
+ row(
211
+ {
212
+ id: 'mod.mismatch',
213
+ group: GROUP,
214
+ title: 'Capabilities match its description',
215
+ advice: 'A cosmetic mod has no reason to run programs or decide permissions. Treat the mismatch as hostile.',
216
+ },
217
+ mismatch,
218
+ 'fail',
219
+ ),
220
+ ]
221
+ }
222
+
223
+ /** A README's prose: no code, links, images, badges, HTML or URLs. */
224
+ function proseOf(markdown: string): string {
225
+ return markdown
226
+ .replace(/```[\s\S]*?```/g, ' ')
227
+ .replace(/`[^`\n]*`/g, ' ')
228
+ .replace(/<[^>\n]+>/g, ' ')
229
+ .replace(/!?\[([^\]\n]*)\]\([^)\n]*\)/g, (_, label: string) => (label.includes('[') ? ' ' : label))
230
+ .replace(/\bhttps?:\/\/\S+/g, ' ')
231
+ }
232
+
233
+ function callCapability(name: string): Capability {
234
+ if (name.endsWith('.*')) {
235
+ // The noun kept whole, its methods called later: judged by its riskiest one.
236
+ const noun = name.slice(0, -2)
237
+ const methods = Object.entries(CALLS).filter(([key]) => key.startsWith(`${noun}.`)).map(([, value]) => value)
238
+ const worst = methods.find(item => item.risk === 'high') ?? methods.find(item => item.risk === 'medium') ?? methods[0]
239
+ if (worst !== undefined) return { risk: worst.risk, plain: `keeps $.${noun} to call later: ${worst.plain}` }
240
+ }
241
+ if (Object.hasOwn(CALLS, name)) return CALLS[name] as Capability
242
+ const noun = name.split('.')[0] ?? ''
243
+ return Object.hasOwn(NOUNS, noun) ? (NOUNS[noun] as Capability) : { risk: 'low', plain: 'a call this scanner does not know' }
244
+ }
245
+
246
+ function eventCapability(use: ModuleUse): Capability {
247
+ if (use.name === 'command.run') {
248
+ const command = /command\s*:\s*['"`]([^'"`]+)['"`]/.exec(use.detail ?? '')?.[1]
249
+ if (command === undefined) return { risk: 'high', plain: 'sees and can answer every slash command, /plugin included' }
250
+ if (SENSITIVE_COMMANDS.has(command)) return { risk: 'high', plain: `intercepts /${command}` }
251
+ return { risk: 'low', plain: `answers /${command}` }
252
+ }
253
+ if (Object.hasOwn(EVENTS, use.name)) return EVENTS[use.name] as Capability
254
+ const namespace = use.name.split('.')[0] ?? ''
255
+ return Object.hasOwn(NAMESPACES, namespace) ? (NAMESPACES[namespace] as Capability) : { risk: 'low', plain: 'an event this scanner does not know' }
256
+ }
257
+
258
+ /** Lowercased names the README or description spells out, such as `process.run`. */
259
+
260
+ /** Adds what Claude Code's own validator saw and the source scan missed. */
261
+ function withValidator(info: ModuleInfo, validator: ValidatorReport | undefined): ModuleInfo {
262
+ if (validator?.ran !== true) return info
263
+ const normalize = (path: string): string => path.replace(/^\.\//, '')
264
+ const report = validator.modules.find(item => normalize(item.module) === normalize(info.declaredAs))
265
+ if (report === undefined) return info
266
+ const known = new Set([...info.events.map(use => use.name), ...info.calls.map(use => use.name)])
267
+ const extra = (names: string[]): ModuleUse[] =>
268
+ names
269
+ .map(name => name.replace(/\{.*\}$/, ''))
270
+ .filter(name => !known.has(name))
271
+ .map(name => ({ name, file: info.entry, line: 0 }))
272
+ return { ...info, events: [...info.events, ...extra(report.hooks)], calls: [...info.calls, ...extra(report.calls)] }
273
+ }
274
+
275
+ function dedupe(uses: readonly ModuleUse[]): ModuleUse[] {
276
+ const seen = new Set<string>()
277
+ return uses.filter(use => {
278
+ const key = `${use.name}|${use.detail ?? ''}`
279
+ if (seen.has(key)) return false
280
+ seen.add(key)
281
+ return true
282
+ })
283
+ }
284
+
285
+ export type { Status }
286
+
287
+ /** Where a call is explained, and the words that explain it. */
288
+ type Explanation = { where: 'README' | 'description'; quote: string }
289
+
290
+ const NEGATION = /\b(?:never|not|no|without|don['’]t|doesn['’]t|won['’]t|cannot|can['’]t)\b(?:\W+\w+){0,3}\W*$/i
291
+
292
+ function findExplanation(name: string, sources: readonly { where: Explanation['where']; text: string }[]): Explanation | undefined {
293
+ if (name.length < 5 || !/^[a-z]+\.[a-zA-Z.]+$/.test(name)) return undefined
294
+ const escaped = name.replace(/\./g, '\\.')
295
+ const mentions = new RegExp(`(?<![\\w.$])${escaped}(?![\\w])`, 'gi')
296
+ const mention = new RegExp(`(?<![\\w.$])${escaped}(?![\\w])`, 'i')
297
+ for (const { where, text } of sources) {
298
+ for (const line of text.split('\n')) {
299
+ for (const found of line.matchAll(mentions)) {
300
+ const index = found.index ?? 0
301
+ const before = line.slice(Math.max(0, index - 40), index).split(/[.!?|]/).pop() ?? ''
302
+ if (NEGATION.test(before)) continue
303
+ // A table row: every cell but the one that is only the call's name;
304
+ // a reason cell that repeats the name is still the reason.
305
+ const cells = line.trim().startsWith('|') ? line.split('|').map(cell => cell.trim()).filter(cell => cell !== '' && !/^:?-+:?$/.test(cell)) : [line]
306
+ const plain = (text: string) => text.replace(/[`*_]/g, '').replace(/\s+/g, ' ').trim()
307
+ const words = cells.filter(cell => plain(cell).toLowerCase() !== name.toLowerCase()).map(plain).join(' ')
308
+ const said = plain((cells.length > 1 ? words : line).replace(mentions, ''))
309
+ if ((said.match(/[A-Za-z]{3,}/g) ?? []).length < 3) continue
310
+ const quote = cells.length > 1 ? words : line.replace(/[`*_]/g, '').replace(/\s+/g, ' ').trim()
311
+ return { where, quote: quote.length > 220 ? `${quote.slice(0, 219)}…` : quote }
312
+ }
313
+ }
314
+ }
315
+ return undefined
316
+ }
317
+
318
+ /**
319
+ * A capability row in plain words: what the mod can do and whether its
320
+ * README says why, the README's own words for it (marked as the
321
+ * candidate's), then the programs it starts when it runs any.
322
+ */
323
+ function capabilitySummary(
324
+ uses: readonly { use: ModuleUse; kind: 'event' | 'call'; capability: Capability }[],
325
+ explanationFor: (name: string) => Explanation | undefined,
326
+ ctx: ScanContext,
327
+ modules: readonly ModuleInfo[],
328
+ readsAndSends: boolean,
329
+ ): string[] | undefined {
330
+ const names = [...new Set(uses.map(item => item.use.name))]
331
+ if (names.length === 0) return undefined
332
+ const plain = [...new Set(uses.map(item => item.capability.plain))]
333
+ const told = names.every(name => explanationFor(name) !== undefined)
334
+ const lines = [`${plain.join('; ').replace(/^./, char => char.toUpperCase())}${told ? '; its README says why' : '; its README does not say why'}`]
335
+ if (readsAndSends) lines.push('Reads files and sends requests, so it could send your files out')
336
+ // The reasons come before the program list, so a long list cannot push them out of view.
337
+ for (const name of names.slice(0, 2)) {
338
+ const explanation = explanationFor(name)
339
+ if (explanation !== undefined) lines.push(`${explanation.where === 'README' ? 'The README says' : 'Its description says'} about ${name} (its words, not checked): "${explanation.quote}"`)
340
+ }
341
+ // Programs only for a mod that calls processes itself; hooking another's
342
+ // process.run starts nothing.
343
+ if (uses.some(item => item.kind === 'call' && /^process(?:\.|$)/.test(item.use.name))) {
344
+ // Only the modules that reach for processes, and the files they import.
345
+ const files = new Set(modules.filter(info => info.calls.some(call => /^process(?:\.|$)/.test(call.name))).flatMap(info => [info.entry, ...info.files]))
346
+ const described = describePrograms(programsOfModule(ctx, files), { buildsCode: 0, downloads: 0 }).filter(line => !line.startsWith('In '))
347
+ lines.push(...(described.length > 0 ? described.slice(0, 4) : ['Which programs it starts could not be read from its code']))
348
+ }
349
+ return lines
350
+ }
351
+
352
+ /**
353
+ * The programs a mod's own files start with a literal name, as in
354
+ * `.run(['git', ...])` or `['run'](['git'])`, outside comments, patterns and
355
+ * strings. A name built while it runs (`${tool}`) is a program chosen then.
356
+ */
357
+ function programsOfModule(ctx: ScanContext, files: ReadonlySet<string>): ProgramUse[] {
358
+ const uses: ProgramUse[] = []
359
+ for (const path of files) {
360
+ if (!/\.(?:[cm]?[jt]sx?)$/.test(path)) continue
361
+ const text = ctx.text(path)
362
+ if (text === undefined) continue
363
+ const kindAt = spanKinds(text, 'js')
364
+ for (const match of text.matchAll(/(?:\.(?:run|spawn)|\[\s*(["'])(?:run|spawn)\1\s*\])\s*\(\s*\[\s*(["'`])([^"'`\s]{1,100})\2/g)) {
365
+ const kind = kindAt(match.index ?? 0)
366
+ if (kind !== 'code') continue
367
+ const raw = match[3] as string
368
+ const dynamic = match[2] === '`' && raw.includes('${')
369
+ uses.push({ name: dynamic ? raw.slice(0, 60) : (raw.split(/[\\/]/).pop() ?? '').replace(/\.exe$/i, '').toLowerCase(), file: path, dynamic })
370
+ if (uses.length >= 500) return uses
371
+ }
372
+ }
373
+ return uses
374
+ }
@@ -0,0 +1,107 @@
1
+ // Detection patterns shared by the checks. They are written as escaped
2
+ // regular expressions so the guard's own source does not match its rules.
3
+
4
+ /** A network tool at the start of a shell command or after a separator. */
5
+ export const NETWORK_COMMAND = /(?:^|[\s;&|(`'"])(?:curl|wget|nc|ncat|netcat|socat|telnet|ssh|scp|sftp|rsync|ftp|tftp|nslookup)(?=\s|$)/
6
+
7
+ /** Network access in source code. */
8
+ export const NETWORK_CODE =
9
+ /\bfetch\s*\(|\bXMLHttpRequest\b|\bhttps?\.(?:request|get)\s*\(|\brequire\s*\(\s*['"](?:node:)?(?:http|https|net|dgram|tls|dns)['"]\s*\)|\bfrom\s+['"](?:node:)?(?:http|https|net|dgram|tls|dns)['"]|\baxios\b|\bundici\b|\brequests\.(?:get|post|put|patch|request|Session)\b|\burllib\b|\bhttpx\b|\bsocket\.(?:socket|create_connection)\b|\bnew\s+WebSocket\b|\$\s*\.\s*http\s*\.\s*fetch\b|\bdns\.(?:lookup|resolve\w*)\s*\(/
10
+
11
+ /** Downloading code and running it, or decoding text and running it. */
12
+ export const DOWNLOAD_AND_RUN =
13
+ /\b(?:curl|wget)\b[^\n|]{0,500}\|\s*(?:sudo\s+)?(?:\/[\w.\/-]*\/)?(?:env\s+)?(?:ba|z|da|k)?sh\b|\b(?:curl|wget)\b[^\n|]{0,500}\|\s*(?:sudo\s+)?(?:\/[\w.\/-]*\/)?(?:env\s+)?(?:python[23]?|node|perl|ruby|php)\b|\b(?:ba|z)?sh\s+-c\s+["']?\$\(\s*(?:curl|wget)|(?:\b(?:source|(?:ba|z|da|k)?sh)|(?:^|(?<=[\s;&|(]))\.)\s+<\(\s*(?:curl|wget)|\bbase64\s+(?:-d|--decode|-D)\b[^\n|]{0,500}\|\s*(?:sudo\s+)?(?:\/[\w.\/-]*\/)?(?:env\s+)?(?:ba|z|da)?sh\b|\bpython[23]?\s+-c\s+["'][^"'\n]{0,500}urllib|\beval\s+["']?\$\(\s*(?:curl|wget|echo|base64)/
14
+
15
+ /**
16
+ * Fetching and running a package that is not pinned to an exact version
17
+ * (npx, npm exec, pnpm dlx, yarn dlx, bunx, uvx, pipx run):
18
+ * no version, a tag (@latest, @next) or a range (@^1.2) all count. An exact
19
+ * x.y.z, with any prerelease (-rc-1) or build (+5) part, is a pin.
20
+ */
21
+ export const UNPINNED_RUNNER =
22
+ /\b(?:npx|bunx|pnpx|npm\s+exec|pnpm\s+dlx|yarn\s+dlx|bun\s+x)\s+(?:-{1,2}[\w-]+(?:=\S+)?\s+){0,5}(?!-)(?:@[\w.-]+\/)?[\w.-]+(?:@(?!\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?(?![\w.+-]))[^\s`'"();&|]*)?(?![@\w./-])|\b(?:uvx|pipx\s+run)\s+(?!\S+(?:==|@)\d)/
23
+
24
+ /** Shell `eval` and piping into a shell. */
25
+ export const SHELL_EVAL = /(?:^|[\s;&|(])eval\s|\|\s*(?:sudo\s+)?(?:\/[\w.\/-]*\/)?(?:env\s+)?(?:ba|z|da|k)?sh\s*(?:$|[;&|)])/m
26
+
27
+ /** Places that hold credentials. */
28
+ export const CREDENTIAL_PATH =
29
+ /\.ssh(?:\/|\b)|\bid_(?:rsa|dsa|ecdsa|ed25519)\b|\.aws\/(?:credentials|config)\b|\.(?:netrc|git-credentials|pypirc)\b|\.npmrc\b|\.docker\/config\.json|\.kube\/config\b|\.config\/gh\/hosts|\.gnupg\b|\.claude\/\.credentials|Library\/Keychains|\bsecurity\s+(?:find|dump)-(?:generic|internet)-password|\bdump-keychain\b|\bLogin Data\b|Cookies\.binarycookies|(?:Application Support|\.config)\/(?:Google\/Chrome|google-chrome|chromium|BraveSoftware|Microsoft Edge)\b|\/(?:Google\/Chrome|Chromium|BraveSoftware\/Brave-Browser|Microsoft\/Edge)\/User Data\b|\.mozilla\/firefox/
30
+
31
+ /** Environment variables that hold secrets, and dumps of the whole environment. */
32
+ export const SECRET_ENV =
33
+ /\b(?:ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CLAUDE_CODE_OAUTH_TOKEN|OPENAI_API_KEY|GITHUB_TOKEN|GH_TOKEN|GITLAB_TOKEN|NPM_TOKEN|NODE_AUTH_TOKEN|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|AZURE_CLIENT_SECRET|GOOGLE_APPLICATION_CREDENTIALS|SLACK_BOT_TOKEN|SLACK_TOKEN|STRIPE_SECRET_KEY|HF_TOKEN|HUGGING_FACE_HUB_TOKEN)\b|\bJSON\.stringify\s*\(\s*process\.env\s*\)|\bObject\.(?:entries|keys|values)\s*\(\s*process\.env\s*\)|\bjson\.dumps\s*\(\s*(?:dict\s*\(\s*)?os\.environ|(?:^|[\s;&|])printenv[ \t]*(?:$|[|>;&])|(?:^|[\s;&|])env[ \t]*\|/m
34
+
35
+ /** Places whose change persists past the session or grants itself more. */
36
+ export const PERSISTENCE_TARGET =
37
+ /(?<![\w$)\]])\.(?:bashrc|bash_profile|bash_login|profile|zshrc|zprofile|zshenv|zlogin)\b|\bconfig\.fish\b|\bcrontab\b|\/etc\/cron|\bLaunchAgents\b|\bLaunchDaemons\b|\blaunchctl\s+(?:load|bootstrap|submit)\b|\bsystemctl\s+(?:--user\s+)?enable\b|\/etc\/systemd\/|\.config\/systemd\/|\.git\/hooks\b|\bcore\.hooksPath\b|\/etc\/profile\b|\.config\/autostart\b|CurrentVersion\\+Run\b/
38
+
39
+ /** Claude Code settings and permission switches. */
40
+ export const CLAUDE_SETTINGS =
41
+ /\.claude\/settings(?:\.local)?\.json|\bbypassPermissions\b|--dangerously-skip-permissions|\bdangerouslySkipPermissions\b|\benableAllProjectMcpServers\b|\benabledMcpjsonServers\b|"defaultMode"\s*:\s*"(?:bypassPermissions|acceptEdits)"|\bskipDangerousModePermissionPrompt\b|\bpermissions\s*(?:\.|\[\s*['"])\s*allow\b/
42
+
43
+ /** Claude Code folders that load what is written into them. */
44
+ export const CLAUDE_LOAD_DIR = /\.claude\/(?:skills|plugins|agents|commands|hooks)\b|\bCLAUDE\.md\b/
45
+
46
+ /** Writing a file from a shell command: a redirect, tee, cp, mv and the like. */
47
+ export const WRITE_SHELL =
48
+ /(?<![<>=!-])(?:\d?>>?|&>>?)(?!&)\s*["']?(?!\/dev\/null)[~$\/.\w]|\btee\s+(?:-a\s+)?["']?[~$\/.\w]|(?:^|[\s;&|])(?:cp|mv|install|ln|rsync)\s+\S|\bsed\s+-i\b/
49
+
50
+ /** Writing a file from JavaScript or Python. */
51
+ export const WRITE_CODE =
52
+ /\b(?:writeFile|appendFile|createWriteStream|writeFileSync|appendFileSync|copyFile|copyFileSync|renameSync|symlinkSync|cpSync)\s*\(|\bBun\.write\s*\(|\bDeno\.writeTextFile\s*\(|\$\s*\.\s*fs\s*\.\s*write\b|\bopen\s*\([^\n]{0,300}?,\s*(?:mode\s*=\s*)?['"](?:w|a|x|r\+|w\+|a\+)b?['"]|\.open\s*\(\s*(?:mode\s*=\s*)?['"](?:w|a|x|r\+|w\+|a\+)b?['"]|\.write_(?:text|bytes)\s*\(|\bshutil\.(?:copy\w*|move)\s*\(|\bos\.(?:rename|replace|symlink)\s*\(/
53
+
54
+ /** Running programs from code. */
55
+ export const PROCESS_CODE =
56
+ /\brequire\s*\(\s*['"](?:node:)?child_process['"]\s*\)|\bfrom\s+['"](?:node:)?child_process['"]|\b(?:execSync|execFileSync|spawnSync|execFile)\s*\(|\bchild_process\b|\bBun\.spawn(?:Sync)?\s*\(|\bDeno\.(?:run|Command)\b/
57
+
58
+ export const PYTHON_PROCESS = /\bsubprocess\.(?:run|call|Popen|check_output|check_call|getoutput|getstatusoutput)\b|\bos\.(?:system|popen|spawn\w*|exec[lv]p?e?)\s*\(|\bpty\.spawn\s*\(/
59
+
60
+ /** Building code from text at run time. */
61
+ export const CODE_FROM_TEXT = /\beval\s*\(|\bnew\s+Function\s*\(|\bvm\.(?:runIn\w*Context|Script|compileFunction)\b|\bimport\s*\(\s*['"`]https?:/
62
+
63
+ export const PYTHON_CODE_FROM_TEXT = /(?:^|[^.\w])(?:exec|eval|compile)\s*\(|\b__import__\s*\(|\b(?:pickle|marshal|dill|cloudpickle)\.loads?\s*\(/
64
+
65
+ /** Decoding text into bytes or code. */
66
+ export const DECODE = /\batob\s*\(|\bBuffer\.from\s*\([^)\n]{0,300}['"](?:base64|hex)['"]|\bbase64\.(?:b64decode|b32decode|a85decode|decodebytes)\b|\bbase64\s+(?:-d|--decode|-D)\b|\bcodecs\.decode\s*\(|\bbytes\.fromhex\s*\(/
67
+
68
+ /** Hosts often used to collect stolen data. */
69
+ export const COLLECTION_HOST =
70
+ /\b(?:webhook\.site|requestbin\.(?:com|net)|pipedream\.net|[\w-]{1,63}\.ngrok(?:-free)?\.(?:io|app|dev)|transfer\.sh|pastebin\.com|paste\.ee|hastebin\.com|discord(?:app)?\.com\/api\/webhooks|api\.telegram\.org|burpcollaborator\.net|oast\.(?:fun|me|pro|live|site|online)|interact\.sh|[\w-]{1,63}\.trycloudflare\.com|hooks\.slack\.com)\b/i
71
+
72
+ /** Known key formats. Evidence names the kind and place, never the value. */
73
+ export const SECRET_KEYS: readonly { kind: string; pattern: RegExp }[] = [
74
+ { kind: 'AWS access key ID', pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/ },
75
+ { kind: 'AWS secret access key', pattern: /\baws_?secret_?access_?key\b['"]?\s*[:=]\s*['"]?[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/i },
76
+ { kind: 'GitHub token', pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}\b|\bgithub_pat_[A-Za-z0-9_]{22,255}\b/ },
77
+ { kind: 'Slack token', pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,255}\b/ },
78
+ { kind: 'Slack webhook', pattern: /https:\/\/hooks\.slack\.com\/services\/T[A-Za-z0-9]{1,40}\/B[A-Za-z0-9]{1,40}\/[A-Za-z0-9]{1,64}/ },
79
+ { kind: 'private key', pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/ },
80
+ { kind: 'Anthropic API key', pattern: /\bsk-ant-(?:api|admin|oat)\d{2}-[A-Za-z0-9_-]{20,255}/ },
81
+ { kind: 'OpenAI API key', pattern: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{16,200}T3BlbkFJ[A-Za-z0-9_-]{16,200}/ },
82
+ { kind: 'Google API key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/ },
83
+ { kind: 'Stripe live key', pattern: /\b(?:sk|rk)_live_[0-9a-zA-Z]{24,255}\b/ },
84
+ { kind: 'npm token', pattern: /\bnpm_[A-Za-z0-9]{36}\b/ },
85
+ { kind: 'SendGrid key', pattern: /\bSG\.[\w-]{22}\.[\w-]{43}\b/ },
86
+ { kind: 'Hugging Face token', pattern: /\bhf_[A-Za-z0-9]{34,255}\b/ },
87
+ ]
88
+
89
+ /** A secret variable given a value in text: `ANTHROPIC_API_KEY=...`, `"GITHUB_TOKEN": "..."`. */
90
+ const SECRET_ASSIGNMENT =
91
+ /\b((?:[A-Z][A-Z0-9]*_)*(?:API_KEY|AUTH_TOKEN|OAUTH_TOKEN|ACCESS_KEY|SECRET_ACCESS_KEY|SECRET_KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY))(['"]?\s*[:=]\s*['"]?)([^\s'"`,;]{6,})/g
92
+
93
+ /**
94
+ * Replaces anything that looks like a key or a secret's value with
95
+ * `[redacted <kind>]`. Every excerpt a report shows passes through it, so a
96
+ * line quoted for another reason cannot print a key.
97
+ */
98
+ export function redactSecrets(text: string): string {
99
+ let out = text
100
+ for (const { kind, pattern } of SECRET_KEYS) {
101
+ out = out.replace(new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`), `[redacted ${kind}]`)
102
+ }
103
+ return out
104
+ .replace(SECRET_ASSIGNMENT, (_, name: string, separator: string) => `${name}${separator}[redacted]`)
105
+ // A password in a URL: https://user:secret@host keeps the user, hides the secret.
106
+ .replace(/(\b[a-z][a-z0-9+.-]{1,20}:\/\/[^\s:/@]{1,100}:)[^\s@/]{1,200}@/gi, '$1[redacted]@')
107
+ }
@@ -0,0 +1,150 @@
1
+ // Turns "this code runs programs" into what a person can judge: which
2
+ // programs, what each one is for, and which deserve a closer look.
3
+
4
+ /** One place code starts a program. `dynamic` when the program is a variable. */
5
+ export type ProgramUse = { name: string; file: string; dynamic: boolean }
6
+
7
+ type Kind = { label: string; risky: boolean }
8
+
9
+ // Written as patterns, which the guard's own scan reads as inert, not as
10
+ // names of tools it runs.
11
+ // Written as patterns with an escape, which the guard's own scan reads as
12
+ // inert rather than as names of tools it runs.
13
+ const KINDS: readonly { match: RegExp; kind: Kind }[] = [
14
+ { match: /^(?:sh|bash|zsh|dash|ksh|fish|csh|tcsh|cmd|powershell|pwsh)(?:\.exe)?$/, kind: { label: 'a shell, which can run any command', risky: true } },
15
+ { match: /^(?:swiftc|swift|gcc|g\+\+|clang|clang\+\+|cc|c\+\+|rustc|cargo|go|javac|make|cmake|tsc|esbuild)(?:\.exe)?$/, kind: { label: 'builds code, which can then run', risky: true } },
16
+ { match: /^(?:node|python|python3|deno|bun|ruby|perl|php|java)(?:\.exe)?$/, kind: { label: 'runs other scripts', risky: false } },
17
+ { match: /^(?:curl|wget|ssh|scp|sftp|rsync|nc|ncat|netcat|socat|ftp|telnet)(?:\.exe)?$/, kind: { label: 'talks to the network', risky: true } },
18
+ { match: /^(?:npm|npx|pnpm|pnpx|yarn|bunx|pip|pip3|pipx|uv|uvx|brew|apt|apt-get|gem)(?:\.exe)?$/, kind: { label: 'installs or fetches packages', risky: true } },
19
+ { match: /^(?:sudo|doas|su)(?:\.exe)?$/, kind: { label: 'runs as administrator', risky: true } },
20
+ { match: /^(?:security)(?:\.exe)?$/, kind: { label: 'reads the macOS keychain', risky: true } },
21
+ { match: /^(?:osascript)(?:\.exe)?$/, kind: { label: 'controls other apps', risky: true } },
22
+ { match: /^(?:launchctl|systemctl|crontab|schtasks)(?:\.exe)?$/, kind: { label: 'sets up background jobs', risky: true } },
23
+ { match: /^(?:ffmpeg|ffprobe|ffplay|sox|magick|convert|identify|exiftool|gm|vips)(?:\.exe)?$/, kind: { label: 'video, audio and image tools', risky: false } },
24
+ { match: /^(?:whisper|whisper-cli|whisper-cpp)(?:\.exe)?$/, kind: { label: 'speech to text', risky: false } },
25
+ { match: /^(?:git|gh)(?:\.exe)?$/, kind: { label: 'git', risky: false } },
26
+ { match: /^(?:rm|rmdir|del|shred)(?:\.exe)?$/, kind: { label: 'deletes files', risky: false } },
27
+ {
28
+ match: /^(?:cp|mv|mkdir|chmod|chown|ln|tar|unzip|zip|gzip|gunzip|touch|cat|ls|find|xargs|sed|awk|grep|head|tail|sort|which|du|df)$/,
29
+ kind: { label: 'file housekeeping', risky: false },
30
+ },
31
+ { match: /^(?:open|xdg-open|start|explorer)(?:\.exe)?$/, kind: { label: 'opens files or links', risky: false } },
32
+ { match: /^(?:chrome|google-chrome|chromium|chromium-browser|firefox|playwright)(?:\.exe)?$/, kind: { label: 'a web browser', risky: false } },
33
+ ]
34
+
35
+ const KIND_OF = { get: (name: string): Kind | undefined => KINDS.find(entry => entry.match.test(name))?.kind }
36
+
37
+ const JS_CALLS =
38
+ /(?<![\w$.])(?:execFileSync|execFile|spawnSync|spawn|execSync|exec|fork)\s*\(\s*(?:(["'`])([^"'`\n]{1,200})|([A-Za-z_$][\w$.]{0,80}))|\b(?:child_process|childProcess|cp)\.(execFileSync|execFile|spawnSync|spawn|execSync|exec|fork)\s*\(\s*(?:(["'`])([^"'`\n]{1,200})|([A-Za-z_$][\w$.]{0,80}))|\bBun\.spawn(?:Sync)?\s*\(\s*\[\s*(["'`])([^"'`\n]{1,200})|\bnew\s+Deno\.Command\s*\(\s*(["'`])([^"'`\n]{1,200})|\bDeno\.run\s*\(\s*\{\s*cmd\s*:\s*\[\s*(["'`])([^"'`\n]{1,200})/g
39
+
40
+ const PY_CALLS =
41
+ /\bsubprocess\.(?:run|call|Popen|check_output|check_call|getoutput|getstatusoutput)\s*\(\s*(?:\[\s*[rbfu]?(["'])([^"'\n]{1,200})|\[\s*([A-Za-z_][\w.]{0,80})|[rbfu]?(["'])([^"'\n]{1,200})|([A-Za-z_][\w.]{0,80}))|\bos\.(?:system|popen)\s*\(\s*[rbfu]?(["'])([^"'\n]{1,200})|\bpty\.spawn\s*\(\s*(?:\[\s*)?[rbfu]?(["'])([^"'\n]{1,200})|\bos\.(?:spawn|exec)[a-z]{0,4}\s*\(\s*(?:os\.P_\w+\s*,\s*)?(?:[rbfu]?(["'])([^"'\n]{1,200})|([A-Za-z_][\w.]{0,80}))/g
42
+
43
+ /** The programs a file starts, from its calls; `keep` drops matches in comments. */
44
+ export function programsIn(path: string, text: string, language: 'js' | 'python' | 'shell' | 'other', keep: (index: number) => boolean): ProgramUse[] {
45
+ const uses: ProgramUse[] = []
46
+ const patterns = language === 'js' ? [JS_CALLS] : language === 'python' ? [PY_CALLS] : language === 'shell' ? [] : [JS_CALLS, PY_CALLS]
47
+ for (const pattern of patterns) {
48
+ for (const match of text.matchAll(new RegExp(pattern.source, pattern.flags))) {
49
+ if (uses.length >= 500) return uses
50
+ const index = match.index ?? 0
51
+ if (!keep(index)) continue
52
+ const groups = match.slice(1)
53
+ // The literal is the group after a quote; a variable has no quote before it.
54
+ let literal: string | undefined
55
+ let variable: string | undefined
56
+ for (let i = 0; i < groups.length; i += 1) {
57
+ const value = groups[i]
58
+ if (value === undefined) continue
59
+ if (/^["'`]$/.test(value)) {
60
+ literal = groups[i + 1]
61
+ break
62
+ }
63
+ if (/^(?:execFileSync|execFile|spawnSync|spawn|execSync|exec|fork)$/.test(value)) continue
64
+ variable = value
65
+ break
66
+ }
67
+ if (literal !== undefined) {
68
+ // A shell command line (execSync, os.system, a string to subprocess)
69
+ // starts with its program; a list or execFile names the program alone.
70
+ const call = /(\w+)\s*\(\s*\[?\s*[rbfu]?["'`]?$/.exec(match[0].slice(0, match[0].length - literal.length))?.[1] ?? ''
71
+ const listForm = /\(\s*\[/.test(match[0])
72
+ const isLine = !listForm && (/^(?:execSync|exec|system|popen|getoutput|getstatusoutput)$/.test(call) || match[0].startsWith('subprocess.'))
73
+ const raw = (isLine ? literal.trim().split(/\s+/)[0] : literal) ?? ''
74
+ // A name built while it runs (`${tool}`, f"{tool}") is a program chosen at run time.
75
+ const fString = /[^\w]f["']$|^f["']/.test(match[0].slice(0, match[0].length - literal.length).slice(-2))
76
+ if (raw.includes('${') || (fString && raw.includes('{'))) {
77
+ uses.push({ name: raw.slice(0, 60), file: path, dynamic: true })
78
+ continue
79
+ }
80
+ const name = normalise(raw)
81
+ if (name !== '') uses.push({ name, file: path, dynamic: false })
82
+ } else if (variable !== undefined) {
83
+ // The running interpreter itself is node or python, not an unknown program.
84
+ const itself = variable === 'process.execPath' ? 'node' : variable === 'sys.executable' ? 'python' : undefined
85
+ uses.push({ name: itself ?? variable, file: path, dynamic: itself === undefined })
86
+ }
87
+ }
88
+ }
89
+ return uses
90
+ }
91
+
92
+ function normalise(program: string): string {
93
+ const base = program.replace(/^["']|["']$/g, '').split(/[\\/]/).pop() ?? ''
94
+ return base.replace(/\.exe$/i, '').toLowerCase().slice(0, 60)
95
+ }
96
+
97
+ function plural(count: number, word: string): string {
98
+ return `${count} ${word}${count === 1 ? '' : 's'}`
99
+ }
100
+
101
+ /**
102
+ * The plain-language lines for a row about running programs: a headline, then
103
+ * the programs worth a look, then the rest by what they are for.
104
+ */
105
+ export function describePrograms(uses: readonly ProgramUse[], extra: { buildsCode: number; downloads: number }): string[] {
106
+ const counts = new Map<string, number>()
107
+ const files = new Set<string>()
108
+ const dynamic = new Map<string, number>()
109
+ for (const use of uses) {
110
+ files.add(use.file)
111
+ if (use.dynamic) dynamic.set(use.name, (dynamic.get(use.name) ?? 0) + 1)
112
+ else counts.set(use.name, (counts.get(use.name) ?? 0) + 1)
113
+ }
114
+ const byCount = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
115
+ const risky = byCount.filter(([name]) => KIND_OF.get(name)?.risky === true)
116
+ const ordered = [...risky, ...byCount.filter(([name]) => KIND_OF.get(name)?.risky !== true)]
117
+
118
+ const parts: string[] = []
119
+ if (extra.downloads > 0) parts.push('downloads code and runs it')
120
+ if (extra.buildsCode > 0) parts.push('builds code from text and runs it')
121
+ if (ordered.length > 0) {
122
+ const names = ordered.slice(0, 6).map(([name]) => name)
123
+ parts.push(`starts ${names.join(', ')}${ordered.length > 6 ? ` and ${ordered.length - 6} more` : ''}`)
124
+ }
125
+ if (dynamic.size > 0) parts.push('starts programs chosen while it runs')
126
+ if (parts.length === 0) return []
127
+ const headline = parts.join('; ')
128
+ const lines = [headline.charAt(0).toUpperCase() + headline.slice(1)]
129
+
130
+ const lookAt: string[] = []
131
+ if (extra.downloads > 0) lookAt.push(`downloads code and runs it (${plural(extra.downloads, 'place')})`)
132
+ if (extra.buildsCode > 0) lookAt.push(`builds code from text and runs it, as eval does (${plural(extra.buildsCode, 'place')})`)
133
+ for (const [name, count] of risky) lookAt.push(`${name}: ${KIND_OF.get(name)?.label} (${plural(count, 'place')})`)
134
+ if (dynamic.size > 0) lookAt.push(`a program named by ${[...dynamic.keys()].slice(0, 4).join(', ')}, chosen while it runs (${plural([...dynamic.values()].reduce((a, b) => a + b, 0), 'place')})`)
135
+ if (lookAt.length > 0) lines.push(`Worth a look: ${lookAt.join('; ')}`)
136
+
137
+ const groups = new Map<string, string[]>()
138
+ for (const [name, count] of byCount) {
139
+ const kind = KIND_OF.get(name)
140
+ if (kind?.risky === true) continue
141
+ const label = kind?.label ?? 'other programs'
142
+ groups.set(label, [...(groups.get(label) ?? []), `${name} (${count})`])
143
+ }
144
+ for (const [label, names] of groups) lines.push(`${label.charAt(0).toUpperCase()}${label.slice(1)}: ${names.join(', ')}`)
145
+ if (files.size > 0) {
146
+ const list = [...files].sort()
147
+ lines.push(`In ${plural(list.length, 'file')}: ${list.slice(0, 4).join(', ')}${list.length > 4 ? ` and ${list.length - 4} more` : ''}`)
148
+ }
149
+ return lines
150
+ }