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,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.
|
|
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
|
-
"
|
|
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
|
}
|