ai-install-guard 0.1.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.
@@ -1,4 +1,6 @@
1
- import type { ModuleInfo, ModuleUse, ScanContext } from './context'
1
+ import { isLive, type ModuleInfo, type ModuleUse, type ScanContext } from './context'
2
+ import { describePrograms, type ProgramUse } from './programs'
3
+ import { spanKinds } from './text'
2
4
  import { row, type CheckResult, type Evidence, type Status } from './types'
3
5
  import type { ValidatorReport } from './validator'
4
6
 
@@ -105,28 +107,23 @@ export function modChecks(ctx: ScanContext, validator: ValidatorReport | undefin
105
107
  ...dedupe(info.events).map(use => ({ use, kind: 'event' as const, capability: eventCapability(use) })),
106
108
  ...dedupe(info.calls).map(use => ({ use, kind: 'call' as const, capability: callCapability(use.name) })),
107
109
  ])
108
- const explained = explanationsOf(ctx)
109
- // The README must spell the call or event out as a name of its own; a wildcard is never explained.
110
- const explainedCache = new Map<string, boolean>()
111
- const isExplained = (name: string): boolean => {
112
- const cached = explainedCache.get(name)
113
- if (cached !== undefined) return cached
114
- const answer = explains(name)
115
- explainedCache.set(name, answer)
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)
116
124
  return answer
117
125
  }
118
- const explains = (name: string): boolean => {
119
- if (name.length < 5 || !/^[a-z]+\.[a-zA-Z.]+$/.test(name)) return false
120
- const escaped = name.replace(/\./g, '\\.')
121
- const mention = new RegExp(`(?<![\\w.$])${escaped}(?![\\w])`, 'gi')
122
- // A mention right after a negation ("we never call process.run",
123
- // "no process.run") says it does not use the call, so it explains nothing.
124
- for (const found of explained.matchAll(mention)) {
125
- const before = explained.slice(Math.max(0, (found.index ?? 0) - 40), found.index).split(/[.!?|\n]/).pop() ?? ''
126
- if (!/\b(?:never|not|no|without|don['’]t|doesn['’]t|won['’]t|cannot|can['’]t)\b(?:\W+\w+){0,3}\W*$/i.test(before)) return true
127
- }
128
- return false
129
- }
126
+ const isExplained = (name: string): boolean => explanationFor(name) !== undefined
130
127
 
131
128
  const inventory: Evidence[] = uses.map(({ use, kind, capability }) => ({
132
129
  file: use.file,
@@ -148,7 +145,8 @@ export function modChecks(ctx: ScanContext, validator: ValidatorReport | undefin
148
145
  status: explainedHere ? 'warn' : 'fail',
149
146
  })
150
147
  } else if (capability.risk === 'medium') {
151
- medium.push({ file: use.file, line: use.line > 0 ? use.line : undefined, excerpt, status: 'warn' })
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' })
152
150
  }
153
151
  }
154
152
  const allCalls = new Set(modules.flatMap(info => info.calls.map(call => call.name)))
@@ -194,6 +192,7 @@ export function modChecks(ctx: ScanContext, validator: ValidatorReport | undefin
194
192
  group: GROUP,
195
193
  title: 'High-risk capabilities',
196
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')),
197
196
  },
198
197
  high,
199
198
  'fail',
@@ -204,6 +203,7 @@ export function modChecks(ctx: ScanContext, validator: ValidatorReport | undefin
204
203
  group: GROUP,
205
204
  title: 'Network, environment and prompt access',
206
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
207
  },
208
208
  medium,
209
209
  ),
@@ -256,11 +256,6 @@ function eventCapability(use: ModuleUse): Capability {
256
256
  }
257
257
 
258
258
  /** Lowercased names the README or description spells out, such as `process.run`. */
259
- function explanationsOf(ctx: ScanContext): string {
260
- // Only what a reader sees: HTML comments are hidden when the README renders.
261
- const visible = (ctx.readme?.text ?? '').replace(/<!--[\s\S]{0,20000}?-->/g, ' ')
262
- return [visible, typeof ctx.manifest?.description === 'string' ? ctx.manifest.description : ''].join('\n\n').toLowerCase()
263
- }
264
259
 
265
260
  /** Adds what Claude Code's own validator saw and the source scan missed. */
266
261
  function withValidator(info: ModuleInfo, validator: ValidatorReport | undefined): ModuleInfo {
@@ -288,3 +283,92 @@ function dedupe(uses: readonly ModuleUse[]): ModuleUse[] {
288
283
  }
289
284
 
290
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
+ }
@@ -13,19 +13,20 @@ export const DOWNLOAD_AND_RUN =
13
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
14
 
15
15
  /**
16
- * Fetching and running a package that is not pinned to an exact version:
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):
17
18
  * no version, a tag (@latest, @next) or a range (@^1.2) all count. An exact
18
19
  * x.y.z, with any prerelease (-rc-1) or build (+5) part, is a pin.
19
20
  */
20
21
  export const UNPINNED_RUNNER =
21
- /\b(?:npx|bunx|pnpx)\s+(?:-{1,2}[\w-]+\s+){0,5}(?!-)(?:@[\w.-]+\/)?[\w.-]+(?:@(?!\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?(?![\w.+-]))[^\s`'"();&|]*)?(?![@\w./-])|\b(?:uvx|pipx\s+run)\s+(?!\S+(?:==|@)\d)/
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)/
22
23
 
23
24
  /** Shell `eval` and piping into a shell. */
24
25
  export const SHELL_EVAL = /(?:^|[\s;&|(])eval\s|\|\s*(?:sudo\s+)?(?:\/[\w.\/-]*\/)?(?:env\s+)?(?:ba|z|da|k)?sh\s*(?:$|[;&|)])/m
25
26
 
26
27
  /** Places that hold credentials. */
27
28
  export const CREDENTIAL_PATH =
28
- /\.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|\/Google\/Chrome\/|\.mozilla\/firefox|BraveSoftware|Microsoft\/Edge\//
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/
29
30
 
30
31
  /** Environment variables that hold secrets, and dumps of the whole environment. */
31
32
  export const SECRET_ENV =
@@ -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
+ }
@@ -25,7 +25,8 @@ const INJECTION: readonly RegExp[] = [
25
25
  /** Text that talks to a security scanner instead of to a person. */
26
26
  const SCANNER_ADDRESS: readonly RegExp[] = [
27
27
  /\bnote\s+(?:to|for)\s+(?:the\s+|any\s+)?(?:security\s+|ai\s+|automated\s+)?(?:scanners?|reviewers?|auditors?|analy[sz]ers?)\b/i,
28
- /\b(?:security\s+)?(?:scanners?|reviewers?|auditors?|analy[sz]ers?|llms?|language\s+models?|ai\s+(?:assistants?|models?|reviewers?))\b[^\n]{0,80}?\b(?:report|mark|classify|treat|consider|rate|flag|score)\b[^\n]{0,40}?\b(?:safe|pass(?:ed|ing)?|clean|benign|trusted|harmless|low[- ]risk|no\s+(?:issues|findings))\b/i,
28
+ // "Reviewer" or "auditor" alone is usually a person; it counts only as a tool.
29
+ /\b(?:(?:security|ai|automated|static|code)\s+(?:scanners?|reviewers?|auditors?|analy[sz]ers?)|scanners?|analy[sz]ers?|llms?|language\s+models?|ai\s+(?:assistants?|models?))\b[^\n]{0,80}?\b(?:report|mark|classify|treat|consider|rate|flag|score)\b[^\n]{0,40}?\b(?:safe|pass(?:ed|ing)?|clean|benign|trusted|harmless|low[- ]risk|no\s+(?:issues|findings))\b/i,
29
30
  /\b(?:this|the)\s+(?:plugin|skill|mod|code|package|repo(?:sitory)?|extension)\s+(?:is|has\s+been)\s+(?:completely\s+|totally\s+|fully\s+|100%\s+)?(?:safe|benign|harmless|verified|audited|approved)\b[^\n]{0,60}?\b(?:report|mark|pass|ignore|skip|approve)\b/i,
30
31
  ]
31
32
 
@@ -101,7 +102,9 @@ export function promptChecks(ctx: ScanContext): CheckResult[] {
101
102
  credentials.push({ file: path, line: match.line, excerpt: `names ${secretName(match)}`, status: cap(/\b(?:echo|print|printenv|cat|send|include|output)\b/i.test(lineText) ? 'fail' : 'warn') })
102
103
  }
103
104
 
104
- for (const match of findAll(text, REMOTE_INSTRUCTIONS, 3)) {
105
+ // A local address (a preview server the skill starts) is not remote.
106
+ const remoteOnly = (index: number) => !/^[^\n]{0,120}?https?:\/\/(?:localhost|127(?:\.\d{1,3}){3}|\[::1\]|0\.0\.0\.0)(?![\w.-])/i.test(text.slice(index, index + 200))
107
+ for (const match of findAll(text, REMOTE_INSTRUCTIONS, 3, remoteOnly)) {
105
108
  remote.push({ file: path, line: match.line, excerpt: match.excerpt, status: cap('warn') })
106
109
  }
107
110
  // Telling the model to download and run code fails; a sentence warning
@@ -0,0 +1,190 @@
1
+ import { isRecord, parseJson, type ScanContext } from './context'
2
+ import { joinPath } from './text'
3
+ import { capEvidence, row, type CheckResult, type Evidence } from './types'
4
+
5
+ const GROUP = 'provenance' as const
6
+ const MARKETPLACE = '.claude-plugin/marketplace.json'
7
+ const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i
8
+ const SPECIAL_REFS = /^(?:HEAD|FETCH_HEAD|ORIG_HEAD|MERGE_HEAD|CHERRY_PICK_HEAD|REVERT_HEAD|REBASE_HEAD|BISECT_HEAD|AUTO_MERGE)$/
9
+ const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/
10
+
11
+ /**
12
+ * Group 1, for a marketplace: whether each plugin it lists from elsewhere is
13
+ * pinned to one commit (or one package version). A plugin that follows a
14
+ * branch installs whatever the branch holds that day, not what was reviewed.
15
+ */
16
+ export function marketplacePinsCheck(ctx: ScanContext): CheckResult[] {
17
+ const parsed = parseJson(ctx.text(MARKETPLACE))
18
+ if (!parsed.ok || !isRecord(parsed.value) || !Array.isArray(parsed.value.plugins)) return []
19
+ const evidence: Evidence[] = []
20
+ for (const plugin of parsed.value.plugins) {
21
+ if (!isRecord(plugin)) continue
22
+ const name = typeof plugin.name === 'string' ? plugin.name : '(unnamed)'
23
+ const judged = judgeSource(plugin.source)
24
+ if (judged !== undefined) evidence.push({ file: MARKETPLACE, excerpt: `${name}: ${judged.what}`, status: judged.status })
25
+ }
26
+ if (evidence.length === 0) return []
27
+ return [
28
+ row(
29
+ {
30
+ id: 'provenance.pins',
31
+ group: GROUP,
32
+ title: 'Marketplace pins',
33
+ advice: 'Pin each plugin to a full commit id (sha) or an exact package version. A branch, a tag or a commit given as ref can change what installs after it was reviewed.',
34
+ },
35
+ // Every entry is judged; only the worst are listed.
36
+ capEvidence(evidence, 30),
37
+ ),
38
+ ]
39
+ }
40
+
41
+ function judgeSource(source: unknown): { what: string; status: 'pass' | 'warn' | 'fail' } | undefined {
42
+ if (typeof source === 'string') {
43
+ // A folder in the marketplace is pinned with the marketplace itself; one
44
+ // that climbs out of it is not part of what was pinned.
45
+ if (source.startsWith('./') || source.startsWith('../')) {
46
+ return joinPath('', source) === undefined ? { what: `comes from ${source}, outside the marketplace, which is not pinned with it`, status: 'warn' } : undefined
47
+ }
48
+ return { what: `comes from ${source} and follows its default branch`, status: 'warn' }
49
+ }
50
+ if (!isRecord(source)) return undefined
51
+ const kind = typeof source.source === 'string' ? source.source : 'unknown'
52
+ if (kind === 'npm' || kind === 'pip') {
53
+ const version = typeof source.version === 'string' ? source.version : undefined
54
+ const pkg = typeof source.package === 'string' ? source.package : '(package)'
55
+ return version !== undefined && EXACT_VERSION.test(version)
56
+ ? { what: `${kind} package ${pkg} at exactly ${version}`, status: 'pass' }
57
+ : { what: `${kind} package ${pkg} ${version === undefined ? 'at whatever version is newest' : `at ${version}, not one exact version`}`, status: 'warn' }
58
+ }
59
+ const where = typeof source.repo === 'string' ? source.repo : typeof source.url === 'string' ? source.url : kind
60
+ const sha = typeof source.sha === 'string' ? source.sha : undefined
61
+ const ref = typeof source.ref === 'string' ? source.ref : undefined
62
+ if (ref !== undefined && SPECIAL_REFS.test(ref)) return { what: `${where} names git's own ${ref} as its ref`, status: 'fail' }
63
+ if (sha !== undefined) {
64
+ return FULL_SHA.test(sha)
65
+ ? { what: `${where} pinned to commit ${sha.slice(0, 12)}`, status: 'pass' }
66
+ : { what: `${where} has sha "${sha.slice(0, 64)}", which is not a full commit id`, status: 'warn' }
67
+ }
68
+ if (ref !== undefined && FULL_SHA.test(ref)) {
69
+ return { what: `${where} gives a commit id as its ref; a branch with that name would be checked out instead, so use sha`, status: 'warn' }
70
+ }
71
+ if (ref !== undefined) return { what: `${where} follows ${ref}, which its owner can move`, status: 'warn' }
72
+ return { what: `${where} follows its default branch`, status: 'warn' }
73
+ }
74
+
75
+ /** What a README's AI Install Guard badge claims, read from its shields.io image. */
76
+ export type BadgeClaim = {
77
+ file: string
78
+ line: number
79
+ claimed: 'safe' | 'caution' | 'reject' | 'unverified'
80
+ /** The version the badge names: a short commit or `tree <hash>`, when it names one. */
81
+ pin?: string
82
+ text: string
83
+ }
84
+
85
+ const BADGE_IMAGE = /img\.shields\.io\/badge\/AI(?:_|%20| )Install(?:_|%20| )Guard-([^)\s"'?]{1,300})/gi
86
+ const HTML_BLOCK_START = /^ {0,3}<\/?[A-Za-z][\w-]*(?:\s|\/?>|$)/
87
+
88
+ /**
89
+ * The strongest claim among the AI Install Guard badges a README shows, read
90
+ * the way a Markdown renderer shows it: fenced and indented code blocks and
91
+ * HTML comments are left out (there a badge is an example or hidden), while
92
+ * HTML blocks and list items, where badges usually sit indented, count. A
93
+ * badge whose words name no verdict is read as claiming the best one, since
94
+ * that is what it would make a reader think.
95
+ */
96
+ export function badgeClaim(readme: { path: string; text: string } | undefined): BadgeClaim | undefined {
97
+ if (readme === undefined) return undefined
98
+ const lines = readme.text.split('\n')
99
+ let fence: { char: string; length: number } | undefined
100
+ let comment = false
101
+ let htmlBlock = false
102
+ let indentedCode = false
103
+ let listContext = false
104
+ let previousBlank = true
105
+ let best: BadgeClaim | undefined
106
+ for (let index = 0; index < lines.length; index += 1) {
107
+ let line = lines[index] as string
108
+ const blank = line.trim() === ''
109
+ // Inside a list item a fence may sit as deep as the item's own content.
110
+ const fenceIndent = listContext ? '[ \\t]{0,16}' : ' {0,3}'
111
+ if (fence !== undefined) {
112
+ // A fence closes only with its own character, at least as long, and nothing after.
113
+ const close = new RegExp(`^${fenceIndent}(\`{3,}|~{3,})[ \\t]*$`).exec(line)
114
+ if (close !== null && (close[1] as string)[0] === fence.char && (close[1] as string).length >= fence.length) fence = undefined
115
+ continue
116
+ }
117
+ if (comment) {
118
+ const end = line.indexOf('-->')
119
+ if (end === -1) continue
120
+ comment = false
121
+ line = line.slice(end + 3)
122
+ }
123
+ const open = htmlBlock ? null : new RegExp(`^${fenceIndent}(\`{3,}|~{3,})(.*)$`).exec(line)
124
+ // A backtick fence's info string cannot hold a backtick: ```x``` is inline code.
125
+ if (open !== null && !((open[1] as string)[0] === '`' && (open[2] as string).includes('`'))) {
126
+ fence = { char: (open[1] as string)[0] as string, length: (open[1] as string).length }
127
+ continue
128
+ }
129
+ // An HTML comment hides what it holds only when it opens a line; inline,
130
+ // only a closed one is a comment.
131
+ if (/^ {0,3}<!--/.test(line) && !line.includes('-->', line.indexOf('<!--') + 4)) {
132
+ comment = true
133
+ continue
134
+ }
135
+ const indented = /^(?: {4}|\t)/.test(line)
136
+ if (indentedCode && !blank && !indented) indentedCode = false
137
+ if (!indentedCode && indented && previousBlank && !listContext && !htmlBlock) indentedCode = true
138
+ if (htmlBlock && blank) htmlBlock = false
139
+ else if (!htmlBlock && !indentedCode && HTML_BLOCK_START.test(line)) htmlBlock = true
140
+ if (!blank && !indented) listContext = /^ {0,3}(?:[-*+]|\d{1,9}[.)])\s/.test(line) || (listContext && !previousBlank)
141
+ else if (/^ {0,3}(?:[-*+]|\d{1,9}[.)])\s/.test(line)) listContext = true
142
+ previousBlank = blank
143
+ if (indentedCode) continue
144
+ const shown = line.replace(/<!--[\s\S]*?-->/g, '').replace(/`[^`]*`/g, '')
145
+ for (const found of shown.matchAll(BADGE_IMAGE)) {
146
+ let message = found[1] as string
147
+ try {
148
+ message = decodeURIComponent(message)
149
+ } catch {
150
+ // Left as written.
151
+ }
152
+ const words = message.replace(/--/g, '\u0000').replace(/-[0-9a-f]{3,8}$|-[a-z]+$/i, '').replace(/_/g, ' ').replace(/\u0000/g, '-').toLowerCase()
153
+ const claimed = /do not install/.test(words) ? 'reject' : /could not verify/.test(words) ? 'unverified' : /caution|review what it can do/.test(words) ? 'caution' : 'safe'
154
+ const pin = /·\s*((?:tree\s+)?[0-9a-f]{7,64})\b/.exec(words)?.[1]
155
+ if (best === undefined || VERDICT_RANK[claimed] < VERDICT_RANK[best.claimed]) best = { file: readme.path, line: index + 1, claimed, pin, text: words.slice(0, 120) }
156
+ }
157
+ }
158
+ return best
159
+ }
160
+
161
+ const VERDICT_RANK = { safe: 0, caution: 1, unverified: 2, reject: 3 } as const
162
+ const VERDICT_WORDS = { safe: 'Safe to install', caution: 'Review what it can do', unverified: 'Could not verify', reject: 'Do not install' } as const
163
+
164
+ /**
165
+ * The row for a README's badge, judged against this scan's own verdict. A
166
+ * badge never improves a verdict: one that claims better than the scan finds
167
+ * is a warning, one that matches is only noted.
168
+ */
169
+ export function badgeRow(claim: BadgeClaim, actual: keyof typeof VERDICT_RANK, version: { sha?: string; treeHash?: string } = {}): CheckResult {
170
+ const overclaims = VERDICT_RANK[claim.claimed] < VERDICT_RANK[actual]
171
+ // Which version the badge names, against the one scanned now.
172
+ const scanned = version.sha !== undefined ? version.sha.slice(0, 7) : version.treeHash !== undefined ? `tree ${version.treeHash.slice(7, 15)}` : undefined
173
+ const sameVersion = claim.pin !== undefined && scanned !== undefined && (claim.pin.startsWith('tree ') ? version.treeHash?.slice(7).startsWith(claim.pin.slice(5)) === true : version.sha?.startsWith(claim.pin) === true)
174
+ const named = claim.pin === undefined ? 'names no version' : sameVersion ? `is for this version (${claim.pin})` : `is for another version (${claim.pin})`
175
+ const summary = overclaims
176
+ ? [`The README's badge says ${VERDICT_WORDS[claim.claimed]}, but this version scans as ${VERDICT_WORDS[actual]}`, `The badge ${named}: it is outdated or was never earned`]
177
+ : sameVersion && claim.claimed === actual
178
+ ? [`The README shows an AI Install Guard badge for this version (${VERDICT_WORDS[claim.claimed]}); this scan agrees`]
179
+ : [`The README's badge says ${VERDICT_WORDS[claim.claimed]} and ${named}; this version scans as ${VERDICT_WORDS[actual]}`]
180
+ return row(
181
+ {
182
+ id: 'provenance.badge',
183
+ group: GROUP,
184
+ title: 'Review badge in the README',
185
+ advice: 'A badge is a picture anyone can paste. This scan is what counts: a badge that claims more than it finds is outdated or was never earned.',
186
+ summary,
187
+ },
188
+ [{ file: claim.file, line: claim.line, excerpt: `badge: ${claim.text}`, status: overclaims ? 'warn' : 'info' }],
189
+ )
190
+ }