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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +11 -2
- package/CHANGELOG.md +47 -1
- package/README.md +96 -9
- package/assets/reviewed-by-ai-install-guard-light@1x.png +0 -0
- package/hooks/guard/badge.ts +35 -0
- package/hooks/guard/decisions.ts +100 -0
- package/hooks/guard/pane.tsx +253 -0
- package/hooks/guard/view.ts +87 -0
- package/hooks/mod-host.ts +1 -0
- package/hooks/register.tsx +171 -21
- package/hooks/scan/checks/code.ts +638 -0
- package/hooks/scan/checks/context.ts +655 -0
- package/hooks/scan/checks/hooks.ts +156 -0
- package/hooks/scan/checks/magic.ts +40 -0
- package/hooks/scan/checks/mcp.ts +271 -0
- package/hooks/scan/checks/mod.ts +374 -0
- package/hooks/scan/checks/patterns.ts +107 -0
- package/hooks/scan/checks/programs.ts +150 -0
- package/hooks/scan/checks/prompts.ts +319 -0
- package/hooks/scan/checks/provenance.ts +190 -0
- package/hooks/scan/checks/quality.ts +56 -0
- package/hooks/scan/checks/secrets.ts +107 -0
- package/hooks/scan/checks/structure.ts +449 -0
- package/hooks/scan/checks/text.ts +362 -0
- package/hooks/scan/checks/types.ts +105 -0
- package/hooks/scan/checks/validator.ts +111 -0
- package/hooks/scan/github.ts +219 -0
- package/hooks/scan/host.ts +13 -0
- package/hooks/scan/lookups.ts +370 -0
- package/hooks/scan/remote.ts +406 -0
- package/hooks/scan/report.ts +62 -31
- package/hooks/scan/scan.ts +360 -0
- package/hooks/scan/verdict.ts +64 -0
- package/package.json +8 -3
|
@@ -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
|
+
}
|