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,319 @@
1
+ import { languageOf, secretName } from './code'
2
+ import { isCodeFile, isLive, type ScanContext } from './context'
3
+ import { CREDENTIAL_PATH, DOWNLOAD_AND_RUN, NETWORK_COMMAND, NETWORK_CODE, SECRET_ENV, UNPINNED_RUNNER } from './patterns'
4
+ import { basename, dirname, findAll, findFirst, spanKinds } from './text'
5
+ import { row, type CheckResult, type Evidence, type Status } from './types'
6
+
7
+ const GROUP = 'prompts' as const
8
+
9
+ /** Phrases that try to take over the model or hide from the person. */
10
+ const INJECTION: readonly RegExp[] = [
11
+ /\bignore\s+(?:all\s+|any\s+)?(?:of\s+)?(?:the\s+|your\s+)?(?:previous|prior|above|earlier|preceding|system|original)\s+(?:instructions?|prompts?|messages?|rules|guidelines|directions)/i,
12
+ /\bdisregard\s+(?:(?:all|any)\s+)?(?:(?:the|your)\s+)?(?:(?:previous|prior|above|earlier|system|original)\s+)?(?:instructions?|prompts?|rules|guidelines)\b/i,
13
+ /\bforget\s+(?:all\s+|everything\s+)?(?:about\s+)?(?:your|the|previous|prior)\s+(?:instructions?|rules|guidelines)/i,
14
+ /\b(?:do\s+not|don['’]t|never)\s+(?:tell|inform|show|mention|reveal|disclose|alert|notify|warn)\s+(?:this\s+to\s+|it\s+to\s+|anything\s+to\s+)?(?:the\s+)?user\b/i,
15
+ /\bwithout\s+(?:telling|asking|informing|notifying|alerting|showing|warning)\s+(?:the\s+)?user\b/i,
16
+ /\b(?:silently|secretly|quietly|covertly|discreetly|invisibly)\s+(?:run|execute|send|upload|post|install|delete|remove|read|copy|modify|fetch|download|exfiltrate|transmit|forward|add|write|change|append|collect)\b/i,
17
+ /\b(?:override|bypass|disable|circumvent|skip)\s+(?:the\s+|all\s+|any\s+|your\s+)?(?:permission|permissions|safety|security|sandbox|approval|approvals|guardrails?|confirmation)\b/i,
18
+ /\bexfiltrat\w*/i,
19
+ /\b(?:send|post|upload|forward|transmit)\s+(?:it|them|this|that|everything|all|the\s+\w+(?:\s+\w+)?)\s+to\s+(?:https?:\/\/|the\s+(?:url|endpoint|server|webhook)|this\s+(?:url|endpoint|server|webhook))/i,
20
+ /\byou\s+are\s+now\s+(?:in\s+)?(?:developer|god|jailbreak|unrestricted|dan)\b/i,
21
+ /\b(?:new|updated|real|actual)\s+system\s+(?:prompt|instructions?)\s*:/i,
22
+ /<\s*\/?\s*(?:system|system-reminder|system_prompt|assistant)\s*>/i,
23
+ ]
24
+
25
+ /** Text that talks to a security scanner instead of to a person. */
26
+ const SCANNER_ADDRESS: readonly RegExp[] = [
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
+ // "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,
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,
31
+ ]
32
+
33
+ const READ_VERB = /\b(?:cat|read|open|print|show|copy|upload|send|include|paste|output|display|load|attach|collect)\b/i
34
+
35
+ const REMOTE_INSTRUCTIONS =
36
+ /\b(?:fetch|download|read|load|get|curl|retrieve|visit|open)\b[^\n]{0,80}?https?:\/\/\S{1,500}[^\n]{0,80}?\b(?:follow|execute|run|apply|obey|use|do)\b[^\n]{0,30}?\b(?:instructions?|steps|commands?|directions|it|them)\b|\bfollow\s+(?:the\s+)?(?:instructions?|steps|directions)\s+(?:at|from|in|on)\s+https?:\/\//i
37
+
38
+ /** Claude Code's own commands, which a skill or command should not shadow. */
39
+ const BUILT_IN_COMMANDS = new Set([
40
+ 'add-dir', 'agents', 'bug', 'clear', 'compact', 'config', 'context', 'cost', 'doctor', 'exit', 'export', 'feedback', 'help',
41
+ 'hooks', 'ide', 'init', 'install-github-app', 'login', 'logout', 'mcp', 'memory', 'model', 'output-style', 'permissions',
42
+ 'plugin', 'plugins', 'pr-comments', 'privacy-settings', 'release-notes', 'resume', 'review', 'rewind', 'sandbox',
43
+ 'security-review', 'skills', 'status', 'statusline', 'tasks', 'terminal-setup', 'theme', 'todos', 'upgrade', 'usage', 'vim',
44
+ ])
45
+
46
+ const CLAIMS_ANTHROPIC = /\b(?:by|from|made\s+by|official(?:ly)?|verified\s+by|endorsed\s+by|approved\s+by)\s+anthropic\b|\banthropic[-\s]?(?:official|verified|certified)\b|\bofficial\s+claude\b/i
47
+
48
+ /**
49
+ * What is wrong with a piece of model-facing text on its own (a prompt or
50
+ * agent hook): injection fails when it reaches out, reading credentials
51
+ * fails, anything else is a warning, as any model hook is.
52
+ */
53
+ export function promptConcerns(text: string): { status: Status; reasons: string[] } {
54
+ const reachesOut = NETWORK_COMMAND.test(text) || NETWORK_CODE.test(text) || /https?:\/\//.test(text) || CREDENTIAL_PATH.test(text)
55
+ const reasons: string[] = []
56
+ let status: Status = 'warn'
57
+ if (INJECTION.some(pattern => pattern.test(text))) {
58
+ reasons.push('prompt injection')
59
+ if (reachesOut) status = 'fail'
60
+ }
61
+ if (CREDENTIAL_PATH.test(text) && READ_VERB.test(text)) {
62
+ reasons.push('reads credentials')
63
+ status = 'fail'
64
+ }
65
+ if (SCANNER_ADDRESS.some(pattern => pattern.test(text))) reasons.push('talks to the scanner')
66
+ return { status, reasons }
67
+ }
68
+
69
+ export function promptChecks(ctx: ScanContext): CheckResult[] {
70
+ const injection: Evidence[] = []
71
+ const credentials: Evidence[] = []
72
+ const tools: Evidence[] = []
73
+ const remote: Evidence[] = []
74
+ const names: Evidence[] = []
75
+
76
+ for (const path of ctx.prompts) {
77
+ const text = ctx.text(path)
78
+ if (text === undefined) continue
79
+ const live = isLive(ctx.roleOf(path))
80
+ const cap = (status: Status): Status => (live ? status : 'info')
81
+ const reachesOut = NETWORK_COMMAND.test(text) || NETWORK_CODE.test(text) || /https?:\/\//.test(text) || CREDENTIAL_PATH.test(text)
82
+
83
+ for (const pattern of INJECTION) {
84
+ for (const match of findAll(text, pattern, 3)) {
85
+ const hidden = isInsideComment(text, match.line) ? ' (inside an HTML comment, hidden when rendered)' : ''
86
+ injection.push({ file: path, line: match.line, excerpt: `${match.excerpt}${hidden}`, status: cap(reachesOut ? 'fail' : 'warn') })
87
+ }
88
+ }
89
+ const hiddenInstructions = findAll(text, /<!--[\s\S]{0,5000}?-->/g, 10_000)
90
+ .filter(comment => comment.text.length > 40 && /\b(?:run|execute|send|call|use|read|write|ignore|always|never|must)\b/i.test(comment.text))
91
+ .slice(0, 5)
92
+ for (const comment of hiddenInstructions) {
93
+ injection.push({ file: path, line: comment.line, excerpt: `instructions inside an HTML comment, hidden when rendered: ${comment.excerpt}`, status: cap('warn') })
94
+ }
95
+
96
+ for (const match of findAll(text, CREDENTIAL_PATH, 5)) {
97
+ const lineText = text.split('\n')[match.line - 1] ?? ''
98
+ credentials.push({ file: path, line: match.line, excerpt: match.excerpt, status: cap(READ_VERB.test(lineText) ? 'fail' : 'warn') })
99
+ }
100
+ for (const match of findAll(text, SECRET_ENV, 5)) {
101
+ const lineText = text.split('\n')[match.line - 1] ?? ''
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') })
103
+ }
104
+
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)) {
108
+ remote.push({ file: path, line: match.line, excerpt: match.excerpt, status: cap('warn') })
109
+ }
110
+ // Telling the model to download and run code fails; a sentence warning
111
+ // against it ("never run curl | sh") is still a warning, since negation
112
+ // is easy to fake ("don't hesitate to run ...").
113
+ for (const match of findAll(text, DOWNLOAD_AND_RUN, 3)) {
114
+ const warnsAgainst = isNegated(text, match.index)
115
+ remote.push({
116
+ file: path,
117
+ line: match.line,
118
+ excerpt: `${warnsAgainst ? 'mentions' : 'tells the model to'} download and run code: ${match.excerpt}`,
119
+ status: cap(warnsAgainst ? 'warn' : 'fail'),
120
+ })
121
+ }
122
+ // An unpinned package runner counts only as a command (in a code span or
123
+ // block), not prose about one ("npx is a package runner").
124
+ const inCode = codeSpans(text)
125
+ for (const match of findAll(text, UNPINNED_RUNNER, 3, inCode)) {
126
+ remote.push({ file: path, line: match.line, excerpt: `tells the model to run an unpinned package: ${match.excerpt}`, status: cap('warn') })
127
+ }
128
+
129
+ const front = frontmatter(text)
130
+ for (const key of ['allowed-tools', 'tools']) {
131
+ const value = front[key]
132
+ if (value === undefined) continue
133
+ for (const tool of toolList(value)) {
134
+ const verdict = judgeTool(tool)
135
+ if (verdict !== undefined) tools.push({ file: path, line: lineOfKey(text, key), excerpt: `${key}: ${tool} (${verdict.why})`, status: cap(verdict.status) })
136
+ }
137
+ }
138
+ const mode = front.permissionMode ?? front['permission-mode']
139
+ if (mode !== undefined && /\b(?:bypassPermissions|acceptEdits)\b/i.test(mode)) {
140
+ tools.push({ file: path, line: lineOfKey(text, 'permissionMode'), excerpt: `permissionMode: ${mode}`, status: cap('fail') })
141
+ }
142
+
143
+ const name = (front.name ?? basename(path).replace(/\.(md|mdx|markdown)$/i, '')).trim().toLowerCase()
144
+ const commandName = /(^|\/)SKILL\.md$/i.test(path) ? (front.name ?? basename(dirname(path))).toLowerCase() : name
145
+ if (BUILT_IN_COMMANDS.has(commandName)) {
146
+ names.push({ file: path, excerpt: `named /${commandName}, the same as a Claude Code command`, status: cap('warn') })
147
+ }
148
+ const claim = findFirst(`${front.name ?? ''}\n${front.description ?? ''}`, CLAIMS_ANTHROPIC)
149
+ if (claim !== undefined) names.push({ file: path, excerpt: `claims to come from Anthropic: ${claim.excerpt}`, status: cap('warn') })
150
+ if (/(^|\/)SKILL\.md$/i.test(path) && front.name !== undefined && front.name.trim() !== basename(dirname(path)) && dirname(path) !== '') {
151
+ names.push({ file: path, excerpt: `skill name "${front.name.trim()}" differs from its folder "${basename(dirname(path))}"`, status: 'info' })
152
+ }
153
+ }
154
+
155
+ const manifestName = typeof ctx.manifest?.name === 'string' ? ctx.manifest.name : ''
156
+ const manifestText = `${manifestName}\n${typeof ctx.manifest?.description === 'string' ? ctx.manifest.description : ''}`
157
+ const claim = findFirst(manifestText, CLAIMS_ANTHROPIC)
158
+ if (claim !== undefined || /\banthropic\b/i.test(manifestName)) {
159
+ names.push({ file: '.claude-plugin/plugin.json', excerpt: `presents itself as Anthropic's: ${claim?.excerpt ?? manifestName}`, status: 'warn' })
160
+ }
161
+
162
+ // Text aimed at the scanner, anywhere in the candidate but its own patterns and comments.
163
+ const addressed: Evidence[] = []
164
+ for (const file of ctx.files) {
165
+ const text = ctx.text(file.path)
166
+ if (text === undefined) continue
167
+ const kindAt = isCodeFile(file.path, text) ? spanKinds(text, languageOf(file.path, text)) : undefined
168
+ const status: Status = ctx.roleOf(file.path) === 'test' ? 'info' : 'warn'
169
+ const seen = new Set<number>()
170
+ for (const pattern of SCANNER_ADDRESS) {
171
+ for (const match of findAll(text, pattern, 2)) {
172
+ const kind = kindAt?.(match.index)
173
+ if (kind === 'regex' || kind === 'comment' || seen.has(match.line)) continue
174
+ seen.add(match.line)
175
+ addressed.push({ file: file.path, line: match.line, excerpt: match.excerpt, status })
176
+ }
177
+ }
178
+ }
179
+
180
+ const results = [
181
+ row({ id: 'prompts.injection', group: GROUP, title: 'Prompt injection', advice: 'Skills, commands and agents are instructions the model follows. Text that tells it to ignore you, hide what it does or send data out is an attack, more so when it names a URL or your keys.' }, injection),
182
+ row({ id: 'prompts.credentials', group: GROUP, title: 'Instructions that touch credentials', advice: 'A skill has no reason to read your keys, tokens or browser data. Do not install one that asks the model to.' }, credentials),
183
+ row({ id: 'prompts.tools', group: GROUP, title: 'Tool permissions it grants itself', advice: 'allowed-tools runs those tools without asking you. Bash with no pattern, a wildcard, an interpreter or a package runner is the same as giving it your shell.' }, tools),
184
+ row({ id: 'prompts.remote', group: GROUP, title: 'Remote instructions and code', advice: 'Instructions or code loaded from a URL at run time can change after this scan, so they were never checked. Prefer skills that ship everything they run.' }, remote),
185
+ row({ id: 'prompts.names', group: GROUP, title: 'Names and claims', advice: 'A name that shadows a built-in command or claims to be from Anthropic is a common way to be trusted by mistake.' }, names),
186
+ row({ id: 'prompts.scanner', group: GROUP, title: 'Text addressed to a security scanner', advice: 'Legitimate plugins do not talk to scanners. This text cannot change the verdict here; treat it as a sign of intent.' }, addressed),
187
+ ]
188
+ return ctx.prompts.length === 0 ? results.filter(result => result.id === 'prompts.names' || result.id === 'prompts.scanner') : results
189
+ }
190
+
191
+ /** Programs that run anything they are given, fetch and run packages, or reach the network. */
192
+ const SHELL_WIDE = new Set([
193
+ 'sh', 'bash', 'zsh', 'dash', 'fish', 'ksh', 'csh', 'tcsh', 'pwsh', 'powershell', 'cmd', 'osascript',
194
+ 'python', 'node', 'deno', 'bun', 'perl', 'ruby', 'php', 'lua', 'awk', 'gawk',
195
+ 'eval', 'exec', 'env', 'xargs', 'sudo', 'su', 'doas', 'nohup', 'timeout', 'watch',
196
+ 'npx', 'bunx', 'pnpx', 'uvx', 'pipx', 'npm', 'pnpm', 'yarn', 'uv',
197
+ 'curl', 'wget', 'nc', 'ncat', 'netcat', 'socat', 'ssh', 'scp', 'sftp', 'telnet',
198
+ ])
199
+
200
+ /**
201
+ * Whether the words right before `index` tell the reader not to run what
202
+ * follows: the nearest negation ("never run this:", "avoid", "do not use")
203
+ * within three words. "Don't hesitate to run" or "never forget to run" urge
204
+ * the reader on, so they are not a negation.
205
+ */
206
+ function isNegated(text: string, index: number): boolean {
207
+ const before = text.slice(Math.max(0, index - 40), index).split(/[.!?\n]/).pop() ?? ''
208
+ const last = Array.from(before.matchAll(/\b(?:never|not|don['’]t|do not|avoid|instead of)\b/gi)).at(-1)
209
+ if (last === undefined) return false
210
+ const rest = before.slice(last.index ?? 0)
211
+ if (/^(?:never|not|don['’]t|do not)\s+(?:ever\s+)?(?:hesitate|forget|fail|neglect|wait)\b/i.test(rest)) return false
212
+ return /^(?:never|not|don['’]t|do not|avoid|instead of)(?:\W+\w+){0,3}\W*$/i.test(rest)
213
+ }
214
+
215
+ /**
216
+ * A lookup for whether an index falls inside a Markdown code span or fenced
217
+ * block. Fences, line breaks and backticks are found once per text, so
218
+ * asking about many matches stays linear.
219
+ */
220
+ function codeSpans(text: string): (index: number) => boolean {
221
+ const positions = (pattern: RegExp) => Array.from(text.matchAll(pattern), match => match.index ?? 0)
222
+ const fences = positions(/^[ \t]*(?:```|~~~)/gm)
223
+ const breaks = positions(/\n/g)
224
+ const ticks = positions(/`/g)
225
+ const prompts = new Map<number, boolean>()
226
+ return index => {
227
+ if (countBefore(fences, index) % 2 === 1) return true
228
+ const line = countBefore(breaks, index)
229
+ const lineStart = line === 0 ? 0 : (breaks[line - 1] as number) + 1
230
+ if ((countBefore(ticks, index) - countBefore(ticks, lineStart)) % 2 === 1) return true
231
+ let prompt = prompts.get(lineStart)
232
+ if (prompt === undefined) {
233
+ const shell = /[ \t]*\$\s/y
234
+ shell.lastIndex = lineStart
235
+ prompt = shell.test(text)
236
+ prompts.set(lineStart, prompt)
237
+ }
238
+ return prompt
239
+ }
240
+ }
241
+
242
+ /** How many of the sorted `positions` come before `index`. */
243
+ function countBefore(positions: number[], index: number): number {
244
+ let low = 0
245
+ let high = positions.length
246
+ while (low < high) {
247
+ const mid = (low + high) >> 1
248
+ if ((positions[mid] as number) < index) low = mid + 1
249
+ else high = mid
250
+ }
251
+ return low
252
+ }
253
+
254
+ function judgeTool(tool: string): { status: Status; why: string } | undefined {
255
+ const bash = /^Bash\s*(?:\(([\s\S]*)\))?$/i.exec(tool.trim())
256
+ if (bash === null) return undefined
257
+ const pattern = bash[1]?.trim()
258
+ // Bash with no pattern runs any command unasked, exactly as Bash(*) does.
259
+ if (pattern === undefined) return { status: 'fail', why: 'any shell command: no pattern narrows it' }
260
+ if (/^\*+\s*(?::\s*\*+)?$/.test(pattern) || pattern === '') return { status: 'fail', why: 'a wildcard: any shell command' }
261
+ // The program the pattern allows, without its path: `/bin/sh:*` is `sh`.
262
+ const program = basename(/^[^\s:]+/.exec(pattern)?.[0] ?? '').toLowerCase()
263
+ if (SHELL_WIDE.has(program) || /^python\d*(?:\.\d+)?$/.test(program)) {
264
+ return { status: 'fail', why: 'an interpreter, package runner or network tool: any command through it' }
265
+ }
266
+ return undefined
267
+ }
268
+
269
+ function frontmatter(text: string): Record<string, string> {
270
+ const match = /^\ufeff?---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(text)
271
+ if (match === null) return {}
272
+ const out: Record<string, string> = {}
273
+ let lastKey: string | undefined
274
+ for (const line of (match[1] as string).split(/\r?\n/)) {
275
+ const pair = /^([A-Za-z][\w-]*)\s*:\s*(.*)$/.exec(line)
276
+ if (pair !== null) {
277
+ lastKey = pair[1] as string
278
+ out[lastKey] = unquote((pair[2] as string).trim())
279
+ } else if (lastKey !== undefined && /^[ \t]*-[ \t]+/.test(line)) {
280
+ // A YAML list item, indented or at column 0 under its key.
281
+ out[lastKey] = `${out[lastKey] ?? ''}${out[lastKey] ? ', ' : ''}${unquote(line.replace(/^[ \t]*-[ \t]+/, '').trim())}`
282
+ }
283
+ }
284
+ return out
285
+ }
286
+
287
+ /** YAML's surrounding quotes, removed: `"help"` → `help`. */
288
+ function unquote(value: string): string {
289
+ return /^(["']).*\1$/.test(value) ? value.slice(1, -1) : value
290
+ }
291
+
292
+ /** `Bash(git:*), Read` or `[Bash, Read]` → the tool names, keeping each pattern whole. */
293
+ function toolList(value: string): string[] {
294
+ const out: string[] = []
295
+ let depth = 0
296
+ let current = ''
297
+ for (const char of value.replace(/^\[|\]$/g, '')) {
298
+ if (char === '(') depth += 1
299
+ if (char === ')') depth = Math.max(0, depth - 1)
300
+ if ((char === ',' || (char === ' ' && depth === 0 && current.trim() !== '' && !current.endsWith('('))) && depth === 0) {
301
+ if (current.trim() !== '') out.push(current.trim().replace(/^["']|["']$/g, ''))
302
+ current = ''
303
+ } else {
304
+ current += char
305
+ }
306
+ }
307
+ if (current.trim() !== '') out.push(current.trim().replace(/^["']|["']$/g, ''))
308
+ return out
309
+ }
310
+
311
+ function lineOfKey(text: string, key: string): number | undefined {
312
+ return findFirst(text, new RegExp(`^${key}\\s*:`, 'm'))?.line
313
+ }
314
+
315
+ function isInsideComment(text: string, line: number): boolean {
316
+ const lines = text.split('\n')
317
+ const before = lines.slice(0, line).join('\n')
318
+ return before.lastIndexOf('<!--') > before.lastIndexOf('-->')
319
+ }
@@ -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
+ }
@@ -0,0 +1,56 @@
1
+ import { isTestPath, type ScanContext } from './context'
2
+ import type { CheckResult } from './types'
3
+
4
+ /** Group 1's local part: a license. The rest of provenance needs the repo. */
5
+ export function licenseCheck(ctx: ScanContext): CheckResult {
6
+ const file = ctx.files.find(entry => /^(?:license|licence|copying)(?:\.[a-z]+)?$/i.test(entry.path))
7
+ const declared = typeof ctx.manifest?.license === 'string' && ctx.manifest.license.trim() !== '' ? ctx.manifest.license.trim() : undefined
8
+ const found = file?.path ?? (declared === undefined ? undefined : `license "${declared}" in plugin.json`)
9
+ return {
10
+ id: 'provenance.license',
11
+ group: 'provenance',
12
+ title: 'License',
13
+ status: found === undefined ? 'warn' : 'pass',
14
+ severity: found === undefined ? 'low' : 'none',
15
+ evidence: found === undefined ? [{ file: '-', excerpt: 'no LICENSE file and no license in plugin.json', status: 'warn' }] : [{ file: found, excerpt: 'license present', status: 'pass' }],
16
+ advice: 'Without a license you have no clear right to use or change the code, and it is a sign of a hurried or throwaway project.',
17
+ }
18
+ }
19
+
20
+ /** Group 9: reported, never a reason to fail. */
21
+ export function qualityChecks(ctx: ScanContext): CheckResult[] {
22
+ const tests = ctx.files.filter(file => isTestPath(file.path)).length
23
+ const readme = ctx.readme?.text ?? ''
24
+ const explainsUninstall = /\b(?:uninstall|remove|disable)\b/i.test(readme)
25
+ return [
26
+ {
27
+ id: 'quality.tests',
28
+ group: 'quality',
29
+ title: 'Tests',
30
+ status: 'info',
31
+ severity: 'none',
32
+ evidence: [{ file: '-', excerpt: tests > 0 ? `${tests} test file${tests === 1 ? '' : 's'} (not run)` : 'no tests found' }],
33
+ advice: 'Tests are a sign of care. They are never run here.',
34
+ },
35
+ {
36
+ id: 'quality.readme',
37
+ group: 'quality',
38
+ title: 'README',
39
+ status: readme.trim().length < 80 ? 'warn' : 'info',
40
+ severity: readme.trim().length < 80 ? 'low' : 'none',
41
+ evidence: [
42
+ {
43
+ file: ctx.readme?.path ?? '-',
44
+ excerpt:
45
+ readme.trim().length < 80
46
+ ? 'no README that says what it does'
47
+ : explainsUninstall
48
+ ? 'says what it does and how to remove it'
49
+ : 'does not say how to uninstall',
50
+ status: readme.trim().length < 80 ? 'warn' : 'info',
51
+ },
52
+ ],
53
+ advice: 'A README should say what the plugin does, what it needs and how to remove it.',
54
+ },
55
+ ]
56
+ }