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,360 @@
1
+ import { codeChecks } from './checks/code'
2
+ import { buildContext, isRecord, parseJson, type CandidateKind, type ScanContext } from './checks/context'
3
+ import { hookChecks } from './checks/hooks'
4
+ import { mcpChecks } from './checks/mcp'
5
+ import { modChecks } from './checks/mod'
6
+ import { promptChecks } from './checks/prompts'
7
+ import { badgeClaim, badgeRow, marketplacePinsCheck, type BadgeClaim } from './checks/provenance'
8
+ import { licenseCheck, qualityChecks } from './checks/quality'
9
+ import { secretChecks } from './checks/secrets'
10
+ import { structureChecks } from './checks/structure'
11
+ import { joinPath } from './checks/text'
12
+ import { GROUPS, rankOf, row, type CheckResult, type Evidence } from './checks/types'
13
+ import { runValidator } from './checks/validator'
14
+ import type { Host } from './host'
15
+ import { treeHash } from './hash'
16
+ import { ScanError, takeInventory, type Inventory, type InventoryEntry, type ScannedTree } from './inventory'
17
+ import { githubProvenance } from './github'
18
+ import { newLookupCache, outsideLookups, type LookupCache } from './lookups'
19
+ import { fetchPinned, type PinnedSource, type RemoteSpec } from './remote'
20
+ import { decide, type Verdict } from './verdict'
21
+
22
+ export type ScanReport = {
23
+ /** What was asked for, as the person wrote it. */
24
+ target: string
25
+ kind?: CandidateKind
26
+ inventory?: Inventory
27
+ results: CheckResult[]
28
+ verdict: Verdict
29
+ /** For a remote scan: the one commit that was fetched and checked. */
30
+ source?: PinnedSource
31
+ /** What an AI Install Guard badge in the README claims, judged in `results`. */
32
+ badgeClaim?: BadgeClaim
33
+ }
34
+
35
+ export type ScanOptions = {
36
+ /** Run `claude plugin validate` on the candidate (on by default). */
37
+ validate?: boolean
38
+ /** The outside lookups (OSV, the official plugin list): off unless turned on. */
39
+ lookups?: boolean
40
+ /** Shared by one scan's lookups, so a marketplace and its plugins ask once. */
41
+ lookupCache?: LookupCache
42
+ /** Told each stage as it starts, for progress in the pane. */
43
+ onStage?: (stage: string) => Promise<void> | void
44
+ /**
45
+ * Rows from analyzers outside the deterministic checks (the model review, an
46
+ * add-on). They are added to the report; they can raise the verdict and
47
+ * never lower it, because a row can only add a WARN or FAIL of its own.
48
+ */
49
+ extraResults?: (report: Omit<ScanReport, 'verdict'>) => Promise<CheckResult[]>
50
+ }
51
+
52
+ /** Scans a local folder: inventory, every deterministic check, the verdict. */
53
+ export async function scanFolder(host: Host, target: string, path: string, rawOptions: ScanOptions = {}): Promise<ScanReport> {
54
+ // Progress is a courtesy: a failure to show it never fails the scan.
55
+ const options: ScanOptions = {
56
+ ...rawOptions,
57
+ ...(rawOptions.lookups === true && rawOptions.lookupCache === undefined ? { lookupCache: newLookupCache() } : {}),
58
+ onStage: async stage => {
59
+ try {
60
+ await rawOptions.onStage?.(stage)
61
+ } catch {
62
+ // ignored on purpose
63
+ }
64
+ },
65
+ }
66
+ let tree: ScannedTree
67
+ try {
68
+ await options.onStage?.('reading files')
69
+ tree = await takeInventory(host, path)
70
+ } catch (error) {
71
+ return { target, results: [], verdict: decide([], reasonOf(error)) }
72
+ }
73
+ const ctx = buildContext(tree)
74
+ let results: CheckResult[]
75
+ try {
76
+ await options.onStage?.('running the checks')
77
+ results = await checksFor(host, ctx, options)
78
+ // A marketplace's local plugins are plugins in their own right: each is
79
+ // checked from its own folder, as Claude Code would read it.
80
+ for (const source of marketplaceSources(ctx)) {
81
+ if (source.kind === 'overflow') {
82
+ results = mergeResults(results, [overflowRow(source.count)])
83
+ continue
84
+ }
85
+ if (source.kind === 'remote') {
86
+ results = mergeResults(results, [remoteSourceNote(source.name, source.description)])
87
+ continue
88
+ }
89
+ await options.onStage?.(`checking ${source.name}`)
90
+ const nested = await subTree(tree, source.path)
91
+ if (nested === undefined) {
92
+ results = mergeResults(results, [missingSourceRow(source.name, source.path)])
93
+ continue
94
+ }
95
+ // A listed plugin's own README badge is judged against its own rows.
96
+ const nestedCtx = buildContext(nested)
97
+ const nestedResults = withBadgeClaim(await checksFor(host, nestedCtx, options), badgeClaim(nestedCtx.readme), { treeHash: nested.inventory.treeHash })
98
+ results = mergeResults(results, prefixed(nestedResults, source.path))
99
+ }
100
+ } catch (error) {
101
+ return { target, kind: ctx.kind, inventory: tree.inventory, results: [], verdict: decide([], `a check failed: ${reasonOf(error)}`) }
102
+ }
103
+ if (options.extraResults !== undefined) {
104
+ const extra = await options
105
+ .extraResults({ target, kind: ctx.kind, inventory: tree.inventory, results })
106
+ .catch((error: unknown): CheckResult[] => [
107
+ {
108
+ id: 'analysis.unfinished',
109
+ group: 'quality',
110
+ title: 'An additional review did not finish',
111
+ status: 'warn',
112
+ severity: 'medium',
113
+ evidence: [{ file: '-', excerpt: reasonOf(error), status: 'warn' }],
114
+ advice: 'Part of the review you asked for is missing, so the verdict rests on the deterministic checks alone.',
115
+ },
116
+ ])
117
+ results = [...results, ...extra]
118
+ }
119
+ const claim = badgeClaim(ctx.readme)
120
+ results = withBadgeClaim(sortResults(results), claim, { treeHash: tree.inventory.treeHash })
121
+ return { target, kind: ctx.kind, inventory: tree.inventory, results, verdict: decide(results), ...(claim === undefined ? {} : { badgeClaim: claim }) }
122
+ }
123
+
124
+ /**
125
+ * The results with a README badge judged against everything else: the badge
126
+ * row is computed last, from the verdict the other rows give, so a badge can
127
+ * add a warning and never lift a verdict.
128
+ */
129
+ export function withBadgeClaim(results: readonly CheckResult[], claim: BadgeClaim | undefined, version: { sha?: string; treeHash?: string }): CheckResult[] {
130
+ if (claim === undefined) return [...results]
131
+ // Evidence about nested plugins' badges, merged in already, stays; this
132
+ // README's own is judged again.
133
+ const rest = results.flatMap(result => {
134
+ if (result.id !== 'provenance.badge') return [result]
135
+ const kept = result.evidence.filter(item => item.file !== claim.file)
136
+ return kept.length === 0 ? [] : [row({ id: result.id, group: result.group, title: result.title, advice: result.advice, summary: result.summary }, kept, 'info')]
137
+ })
138
+ return sortResults(mergeResults(rest, [badgeRow(claim, decide(rest).kind, version)]))
139
+ }
140
+
141
+ async function checksFor(host: Host, ctx: ScanContext, options: ScanOptions): Promise<CheckResult[]> {
142
+ // Claude Code validates plugins and marketplaces; a standalone skill or a
143
+ // plain folder has no manifest for it to read.
144
+ const hasManifest = ctx.files.some(file => file.path === '.claude-plugin/plugin.json' || file.path === '.claude-plugin/marketplace.json')
145
+ const validator = options.validate === false || ctx.kind === 'folder' || (ctx.kind === 'skill' && !hasManifest) ? undefined : await runValidator(host, ctx.tree.inventory.root)
146
+ return [
147
+ licenseCheck(ctx),
148
+ ...marketplacePinsCheck(ctx),
149
+ ...structureChecks(ctx, validator),
150
+ ...modChecks(ctx, validator),
151
+ ...hookChecks(ctx),
152
+ ...mcpChecks(ctx),
153
+ ...(options.lookups === true ? await outsideLookups(host, ctx, options.lookupCache) : []),
154
+ ...promptChecks(ctx),
155
+ ...codeChecks(ctx),
156
+ ...secretChecks(ctx),
157
+ ...qualityChecks(ctx),
158
+ ]
159
+ }
160
+
161
+ const MAX_NESTED_PLUGINS = 50
162
+
163
+ type MarketplaceSource =
164
+ | { kind: 'local'; name: string; path: string }
165
+ | { kind: 'remote'; name: string; description: string }
166
+ | { kind: 'overflow'; count: number }
167
+
168
+ /** The plugins a `.claude-plugin/marketplace.json` lists, other than the root itself. */
169
+ /**
170
+ * Scans a remote source: fetches the one commit its ref names into the
171
+ * guard's own temporary folder, scans that folder, and removes it. A failure
172
+ * to fetch or verify is "Could not verify", never a pass.
173
+ */
174
+ export async function scanRemote(host: Host, target: string, spec: RemoteSpec, options: ScanOptions = {}): Promise<ScanReport> {
175
+ let fetched
176
+ try {
177
+ fetched = await fetchPinned(host, spec, async stage => {
178
+ try {
179
+ await options.onStage?.(stage)
180
+ } catch {
181
+ // Progress is a courtesy, as in scanFolder.
182
+ }
183
+ })
184
+ } catch (error) {
185
+ return { target, results: [], verdict: decide([], reasonOf(error)) }
186
+ }
187
+ try {
188
+ const report = await scanFolder(host, target, fetched.root, options)
189
+ if (report.verdict.kind === 'unverified') return { ...report, source: fetched.source }
190
+ // What GitHub says about where it came from, then the badge, judged again
191
+ // with the source's own rows counted.
192
+ // Progress is a courtesy here too: a failure to show it, thrown or
193
+ // rejected, never fails the scan.
194
+ try {
195
+ await options.onStage?.('reading the repository history')
196
+ } catch {
197
+ // ignored on purpose
198
+ }
199
+ const history = await githubProvenance(host, fetched.source)
200
+ const results = withBadgeClaim(sortResults([...fetched.results, ...history, ...report.results]), report.badgeClaim, { sha: fetched.source.sha, treeHash: report.inventory?.treeHash })
201
+ return { ...report, source: fetched.source, results, verdict: decide(results) }
202
+ } finally {
203
+ await fetched.cleanup().catch(() => undefined)
204
+ }
205
+ }
206
+
207
+ function marketplaceSources(ctx: ScanContext): MarketplaceSource[] {
208
+ const raw = ctx.text('.claude-plugin/marketplace.json')
209
+ const parsed = parseJson(raw)
210
+ if (!parsed.ok || !isRecord(parsed.value) || !Array.isArray(parsed.value.plugins)) return []
211
+ const out: MarketplaceSource[] = []
212
+ const skipped = parsed.value.plugins.length - MAX_NESTED_PLUGINS
213
+ if (skipped > 0) out.push({ kind: 'overflow', count: skipped })
214
+ for (const plugin of parsed.value.plugins.slice(0, MAX_NESTED_PLUGINS)) {
215
+ if (!isRecord(plugin)) continue
216
+ const name = typeof plugin.name === 'string' ? plugin.name : '(unnamed)'
217
+ const source = plugin.source
218
+ if (typeof source === 'string' && (source.startsWith('./') || source.startsWith('../'))) {
219
+ const path = joinPath('', source)
220
+ if (path === undefined) out.push({ kind: 'remote', name, description: `${source}, outside the marketplace folder` })
221
+ else if (path !== '') out.push({ kind: 'local', name, path })
222
+ } else {
223
+ out.push({ kind: 'remote', name, description: typeof source === 'string' ? source : JSON.stringify(source) })
224
+ }
225
+ }
226
+ return out
227
+ }
228
+
229
+ /** The part of a scanned tree under `prefix`, re-rooted there. */
230
+ async function subTree(tree: ScannedTree, prefix: string): Promise<ScannedTree | undefined> {
231
+ const root = `${tree.inventory.root}/${prefix}`
232
+ const entries = tree.inventory.entries
233
+ .filter(entry => entry.path.startsWith(`${prefix}/`))
234
+ .map((entry): InventoryEntry => {
235
+ const path = entry.path.slice(prefix.length + 1)
236
+ if (entry.kind !== 'link' || !entry.isInside || entry.landsAt === undefined) return { ...entry, path }
237
+ // A link's landing, seen from the nested plugin: inside it, or outside
238
+ // it (once installed on its own, the plugin no longer holds that file).
239
+ if (entry.landsAt === prefix) return { ...entry, path, landsAt: '.' }
240
+ return entry.landsAt.startsWith(`${prefix}/`)
241
+ ? { ...entry, path, landsAt: entry.landsAt.slice(prefix.length + 1) }
242
+ : { ...entry, path, landsAt: `${tree.inventory.root}/${entry.landsAt}`, isInside: false }
243
+ })
244
+ if (entries.length === 0) return undefined
245
+ const contents = new Map<string, Uint8Array>()
246
+ for (const [path, bytes] of tree.contents) if (path.startsWith(`${prefix}/`)) contents.set(path.slice(prefix.length + 1), bytes)
247
+ return {
248
+ inventory: {
249
+ root,
250
+ entries,
251
+ totalBytes: entries.reduce((sum, entry) => sum + (entry.kind === 'file' ? entry.size : 0), 0),
252
+ treeHash: await treeHash(entries),
253
+ skipped: [],
254
+ },
255
+ contents,
256
+ }
257
+ }
258
+
259
+ /** A nested plugin's rows, with every place and its summary named from the marketplace root. */
260
+ function prefixed(results: readonly CheckResult[], prefix: string): CheckResult[] {
261
+ return results.map(result => ({
262
+ ...result,
263
+ ...(result.summary === undefined ? {} : { summary: result.summary.map((line, index) => (index === 0 ? `${prefix}: ${line}` : line)) }),
264
+ evidence: result.evidence.map(item =>
265
+ item.file === '-' || item.file === '' ? { ...item, excerpt: `${prefix}: ${item.excerpt}` } : { ...item, file: `${prefix}/${item.file}` },
266
+ ),
267
+ }))
268
+ }
269
+
270
+ /** One row per check: evidence from both, without repeats, at the worse status. */
271
+ export function mergeResults(base: readonly CheckResult[], extra: readonly CheckResult[]): CheckResult[] {
272
+ const out = base.map(result => ({ ...result, evidence: [...result.evidence] }))
273
+ const order = { none: 0, low: 1, medium: 2, high: 3, critical: 4 }
274
+ for (const result of extra) {
275
+ const existing = out.find(item => item.id === result.id)
276
+ if (existing === undefined) {
277
+ out.push({ ...result, evidence: [...result.evidence] })
278
+ continue
279
+ }
280
+ const seen = new Map(existing.evidence.map((item, index) => [`${item.file}:${item.line ?? ''}:${item.excerpt}`, index]))
281
+ for (const item of result.evidence) {
282
+ const key = `${item.file}:${item.line ?? ''}:${item.excerpt}`
283
+ const at = seen.get(key)
284
+ if (at === undefined) {
285
+ existing.evidence.push(item)
286
+ continue
287
+ }
288
+ // The same place seen twice keeps the worse judgement, so a failure is
289
+ // never shown with only a note as its evidence.
290
+ const kept = existing.evidence[at] as Evidence
291
+ if (rankOf(item.status ?? result.status) > rankOf(kept.status ?? existing.status)) existing.evidence[at] = item
292
+ }
293
+ // A pass on one side and a note on the other stay a pass; anything worse
294
+ // wins, and brings its own summary, so the reason shown is the worst one.
295
+ if (rankOf(result.status) > rankOf(existing.status)) {
296
+ existing.status = result.status
297
+ if (result.summary !== undefined) existing.summary = result.summary
298
+ } else if (existing.summary === undefined && result.summary !== undefined) {
299
+ existing.summary = result.summary
300
+ }
301
+ if (order[result.severity] > order[existing.severity]) existing.severity = result.severity
302
+ existing.evidence.sort((a, b) => rankOf(b.status ?? existing.status) - rankOf(a.status ?? existing.status))
303
+ }
304
+ return out
305
+ }
306
+
307
+ function remoteSourceNote(name: string, description: string): CheckResult {
308
+ return {
309
+ id: 'structure.marketplace',
310
+ group: 'structure',
311
+ title: 'Plugins this marketplace lists',
312
+ status: 'info',
313
+ severity: 'none',
314
+ evidence: [{ file: '.claude-plugin/marketplace.json', excerpt: `${name} comes from ${description}: scanned when you install it`, status: 'info' }],
315
+ advice: 'A marketplace only lists plugins. Each one is scanned on its own when it is installed.',
316
+ }
317
+ }
318
+
319
+ function overflowRow(count: number): CheckResult {
320
+ return {
321
+ id: 'structure.marketplace',
322
+ group: 'structure',
323
+ title: 'Plugins this marketplace lists',
324
+ status: 'warn',
325
+ severity: 'medium',
326
+ evidence: [{ file: '.claude-plugin/marketplace.json', excerpt: `${count} more plugins than the ${MAX_NESTED_PLUGINS} checked here were not scanned`, status: 'warn' }],
327
+ advice: 'Unscanned plugins are unknown. Each one is still scanned on its own when it is installed.',
328
+ }
329
+ }
330
+
331
+ function missingSourceRow(name: string, path: string): CheckResult {
332
+ return {
333
+ id: 'structure.marketplace',
334
+ group: 'structure',
335
+ title: 'Plugins this marketplace lists',
336
+ status: 'warn',
337
+ severity: 'medium',
338
+ evidence: [{ file: '.claude-plugin/marketplace.json', excerpt: `${name} points at ${path}, which is not in this folder`, status: 'warn' }],
339
+ advice: 'A marketplace entry that points nowhere cannot be checked.',
340
+ }
341
+ }
342
+
343
+ /** In group order; inside a group, failures first, then warnings. */
344
+ export function sortResults(results: readonly CheckResult[]): CheckResult[] {
345
+ const groupIndex = new Map(GROUPS.map((group, index) => [group.id, index]))
346
+ const statusIndex = { fail: 0, warn: 1, pass: 2, info: 3 }
347
+ return results
348
+ .map((result, index) => ({ result, index }))
349
+ .sort((a, b) =>
350
+ (groupIndex.get(a.result.group) ?? 99) - (groupIndex.get(b.result.group) ?? 99) ||
351
+ statusIndex[a.result.status] - statusIndex[b.result.status] ||
352
+ a.index - b.index,
353
+ )
354
+ .map(item => item.result)
355
+ }
356
+
357
+ function reasonOf(error: unknown): string {
358
+ if (error instanceof ScanError) return error.message
359
+ return error instanceof Error ? error.message : String(error)
360
+ }
@@ -0,0 +1,64 @@
1
+ import { rankOf, type CheckResult } from './checks/types'
2
+
3
+ export type VerdictKind = 'safe' | 'caution' | 'reject' | 'unverified'
4
+
5
+ export const RECOMMENDATION: Record<VerdictKind, string> = {
6
+ safe: 'Safe to install',
7
+ // Not a lesser "safe": powerful access worth knowing about before you install.
8
+ caution: 'Review what it can do',
9
+ reject: 'Do not install',
10
+ unverified: 'Could not verify',
11
+ }
12
+
13
+ export type Counts = { pass: number; warn: number; fail: number; info: number }
14
+
15
+ export type Verdict = {
16
+ kind: VerdictKind
17
+ recommendation: string
18
+ /** Up to three: why not to install, what to watch, or why it could not be judged. */
19
+ reasons: string[]
20
+ counts: Counts
21
+ }
22
+
23
+ /**
24
+ * Any FAIL: do not install. Else any WARN: review what it can do. Else safe.
25
+ * A scan that could not finish is "could not verify" whatever it found.
26
+ * Rows only ever add to this: nothing a row says can lower another row.
27
+ */
28
+ export function decide(results: readonly CheckResult[], error?: string): Verdict {
29
+ const counts: Counts = { pass: 0, warn: 0, fail: 0, info: 0 }
30
+ for (const result of results) counts[result.status] += 1
31
+ if (error !== undefined) {
32
+ return { kind: 'unverified', recommendation: RECOMMENDATION.unverified, reasons: [error], counts }
33
+ }
34
+ const kind: VerdictKind = counts.fail > 0 ? 'reject' : counts.warn > 0 ? 'caution' : 'safe'
35
+ const focus = kind === 'reject' ? 'fail' : 'warn'
36
+ const reasons = [...results]
37
+ .filter(result => result.status === focus)
38
+ .sort((a, b) => severityRank(b) - severityRank(a))
39
+ .slice(0, 3)
40
+ .map(reasonOf)
41
+ return { kind, recommendation: RECOMMENDATION[kind], reasons, counts }
42
+ }
43
+
44
+ function severityRank(result: CheckResult): number {
45
+ const order = { none: 0, low: 1, medium: 2, high: 3, critical: 4 }
46
+ return order[result.severity] * 10 + rankOf(result.status)
47
+ }
48
+
49
+ /**
50
+ * `Title: what it means` from the row's plain summary, or `Title: file:line
51
+ * what` from its worst piece of evidence when it has none.
52
+ */
53
+ export function reasonOf(result: CheckResult): string {
54
+ const headline = result.summary?.[0]
55
+ if (headline !== undefined) return `${result.title}: ${headline}`
56
+ const worst = [...result.evidence].sort((a, b) => rankOf(b.status ?? result.status) - rankOf(a.status ?? result.status))[0]
57
+ if (worst === undefined) return result.title
58
+ return `${result.title}: ${locate(worst.file, worst.line)}${worst.excerpt}`
59
+ }
60
+
61
+ export function locate(file: string, line?: number): string {
62
+ if (file === '-' || file === '') return ''
63
+ return `${file}${line === undefined ? '' : `:${line}`} `
64
+ }
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "ai-install-guard",
3
- "version": "0.0.0",
3
+ "version": "0.2.0",
4
4
  "description": "A pre-install security gate for Claude Code: scans plugins, mods, skills and MCP servers without running them, and installs nothing until you approve.",
5
5
  "scripts": {
6
6
  "build:fixtures": "node scripts/build-fixtures.js",
7
+ "build:skill": "node scripts/build-skill.js",
7
8
  "sync:versions": "node scripts/sync-versions.js",
8
9
  "validate": "claude plugin validate --strict .",
9
- "typecheck": "tsc -p .",
10
+ "typecheck": "tsc -p . && tsc -p cli",
10
11
  "test": "claude plugin test .",
11
- "check": "npm run validate && npm run typecheck && npm test",
12
+ "test:skill": "node scripts/build-skill.js && node --test cli/scan.spec.mjs",
13
+ "check": "npm run validate && npm run typecheck && npm test && npm run test:skill",
12
14
  "version": "node scripts/sync-versions.js && git add .claude-plugin/"
13
15
  },
14
16
  "keywords": [
@@ -28,6 +30,7 @@
28
30
  "!hooks/**/*.test.ts",
29
31
  "!hooks/test-support/",
30
32
  "README.md",
33
+ "assets/",
31
34
  "CHANGELOG.md",
32
35
  "LICENSE"
33
36
  ],
@@ -43,6 +46,8 @@
43
46
  "node": ">=18"
44
47
  },
45
48
  "devDependencies": {
49
+ "@types/node": "^20.19.43",
50
+ "esbuild": "^0.28.2",
46
51
  "typescript": "^5.9.3"
47
52
  }
48
53
  }