ai-install-guard 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,406 @@
1
+ // Fetching a remote candidate without running any of it: the ref is resolved
2
+ // to one commit, only that commit is fetched into a folder the guard owns,
3
+ // and the checkout is confirmed to have landed on it. The checks then read
4
+ // that folder like any local one, and the folder is removed afterwards.
5
+
6
+ import { capEvidence, row, type CheckResult, type Evidence } from './checks/types'
7
+ import type { Host, RunResult } from './host'
8
+ import { LIMITS } from './inventory'
9
+ import { safeLine } from './safe-text'
10
+
11
+ /** A remote candidate as asked for: an https git URL, a ref and a folder in it. */
12
+ export type RemoteSpec = {
13
+ url: string
14
+ ref?: string
15
+ subdir?: string
16
+ /**
17
+ * The part of a GitHub link after /tree/: a ref, which may itself hold
18
+ * slashes, then a folder. Split once the repository's refs are known.
19
+ */
20
+ treePath?: string
21
+ }
22
+
23
+ /** What was actually scanned: one commit, named however it was asked for. */
24
+ export type PinnedSource = {
25
+ url: string
26
+ /** The ref as asked for; absent for the default branch. */
27
+ ref?: string
28
+ refKind: 'commit' | 'tag' | 'branch' | 'default'
29
+ sha: string
30
+ /** The branch the repository's HEAD names, when it says. */
31
+ defaultBranch?: string
32
+ subdir?: string
33
+ }
34
+
35
+ export type FetchedSource = {
36
+ root: string
37
+ source: PinnedSource
38
+ /** Rows about the source itself: what was pinned and how its refs look. */
39
+ results: CheckResult[]
40
+ cleanup: () => Promise<void>
41
+ }
42
+
43
+ /** The most a fetched commit may weigh before the scan gives up. */
44
+ export const MAX_FETCH_BYTES = 50 * 1024 * 1024
45
+
46
+ /** How long a fetch may take. */
47
+ const FETCH_TIMEOUT_MS = 120_000
48
+
49
+ /** The same limit in the 512-byte blocks a shell's `ulimit -f` counts. */
50
+ const FETCH_LIMIT_BLOCKS = Math.ceil(MAX_FETCH_BYTES / 512)
51
+
52
+ /**
53
+ * Runs the fetch under a file-size limit, so a commit far over the limit is
54
+ * stopped as it arrives rather than measured after it has filled the disk.
55
+ * fetch.unpackLimit=1 keeps everything in the one pack the limit applies to.
56
+ * Where there is no `sh` (Windows), the fetch is bounded by its time alone.
57
+ */
58
+ async function boundedFetch(host: Host, args: readonly string[]): Promise<RunResult> {
59
+ // Whether there is a shell to set the limit is asked first, so a capped
60
+ // fetch that fails or times out is never run again without its cap.
61
+ const shell = await host.run(['sh', '-c', 'exit 0'], { timeoutMs: 10_000 }).catch(() => undefined)
62
+ if (shell === undefined || shell.exitCode !== 0) return git(host, args, FETCH_TIMEOUT_MS)
63
+ return host.run(['sh', '-c', `ulimit -f ${FETCH_LIMIT_BLOCKS} && exec git "$@"`, 'sh', ...args], { timeoutMs: FETCH_TIMEOUT_MS, env: GIT_ENV })
64
+ }
65
+
66
+ /** No prompts for credentials, and no Git LFS downloads during checkout. */
67
+ const GIT_ENV: Record<string, string> = { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_LFS_SKIP_SMUDGE: '1' }
68
+
69
+ /**
70
+ * For the steps that touch only the local copy: none of your own git
71
+ * configuration or attributes, so a `.gitattributes` in the candidate cannot
72
+ * select a filter program you have configured. The network steps keep your
73
+ * configuration for proxies and certificates.
74
+ */
75
+ const ISOLATED_ENV: Record<string, string> = { ...GIT_ENV, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_ATTR_NOSYSTEM: '1' }
76
+
77
+ /** Names git gives its own refs; a branch or tag with one is a trap. */
78
+ const SPECIAL_REFS = new Set(['HEAD', 'FETCH_HEAD', 'ORIG_HEAD', 'MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'REBASE_HEAD', 'BISECT_HEAD', 'AUTO_MERGE'])
79
+
80
+ const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
81
+ const SHORT_SHA = /^[0-9a-f]{7,39}$/
82
+ const SHORTHAND = /^(?:github:)?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?(?:@([^\s@]{1,250}))?$/
83
+
84
+ /** Whether `target` is written as a remote source rather than a folder. */
85
+ export function looksRemote(target: string): 'remote' | 'maybe' | 'local' {
86
+ if (/^(?:[a-z][a-z0-9+.-]*:\/\/|git@|github:)/i.test(target)) return 'remote'
87
+ // owner/repo could also be a folder: the caller checks the disk first.
88
+ return SHORTHAND.test(target) ? 'maybe' : 'local'
89
+ }
90
+
91
+ /**
92
+ * Reads a remote source: `https://host/path`, a GitHub tree URL
93
+ * (`https://github.com/o/r/tree/<ref>/<folder>`), `github:o/r` or `o/r`, each
94
+ * with an optional `@ref`. Only https is fetched, and never with a password
95
+ * in the URL. `ref` and `subdir` from options win over the text's own.
96
+ */
97
+ export function parseRemote(target: string, options: { ref?: string; subdir?: string } = {}): RemoteSpec {
98
+ const text = target.trim()
99
+ let spec: RemoteSpec
100
+ const shorthand = SHORTHAND.exec(text)
101
+ if (shorthand !== null && !text.includes('://')) {
102
+ spec = { url: `https://github.com/${shorthand[1]}/${shorthand[2]}.git`, ref: shorthand[3] }
103
+ } else {
104
+ let url: URL
105
+ try {
106
+ url = new URL(text)
107
+ } catch {
108
+ throw new Error(`"${safeLine(text)}" is not a folder or a repository address`)
109
+ }
110
+ if (url.protocol !== 'https:') throw new Error('only https:// sources are fetched')
111
+ if (url.username !== '' || url.password !== '') throw new Error('a source address must not carry a user name or password')
112
+ const parts = url.pathname.split('/').filter(part => part !== '')
113
+ if (url.hostname.toLowerCase() === 'github.com' && parts.length >= 2) {
114
+ const repo = (parts[1] as string).replace(/\.git$/, '')
115
+ spec = { url: `https://github.com/${parts[0]}/${repo}.git` }
116
+ if (parts[2] === 'tree' && parts[3] !== undefined) spec.treePath = parts.slice(3).map(decodeURIComponent).join('/')
117
+ } else {
118
+ spec = { url: `${url.origin}${url.pathname}` }
119
+ }
120
+ }
121
+ // A ref named outright replaces the link's own ref and folder.
122
+ if (options.ref !== undefined) {
123
+ spec.ref = options.ref
124
+ delete spec.treePath
125
+ }
126
+ if (options.subdir !== undefined) spec.subdir = options.subdir
127
+ if (spec.subdir !== undefined) spec.subdir = safeSubdir(spec.subdir)
128
+ return spec
129
+ }
130
+
131
+ /** A folder inside the repository: relative, with no `..`, normalised. */
132
+ function safeSubdir(subdir: string): string | undefined {
133
+ const parts = subdir.split(/[\\/]+/).filter(part => part !== '' && part !== '.')
134
+ if (/^[\\/]|^[A-Za-z]:/.test(subdir) || parts.includes('..')) throw new Error(`the folder "${safeLine(subdir)}" is not inside the repository`)
135
+ return parts.length === 0 ? undefined : parts.join('/')
136
+ }
137
+
138
+ /**
139
+ * Splits a GitHub link's `<ref>/<folder>` at the longest leading part that
140
+ * names a branch or tag, so a branch called feature/foo is not read as
141
+ * branch feature, folder foo.
142
+ */
143
+ export function splitTreePath(listed: RemoteRefs, treePath: string): { ref: string; subdir?: string } {
144
+ const parts = treePath.split('/').filter(part => part !== '')
145
+ for (let count = parts.length; count >= 1; count -= 1) {
146
+ const ref = parts.slice(0, count).join('/')
147
+ const known = listed.refs.has(`refs/heads/${ref}`) || listed.refs.has(`refs/tags/${ref}`) || (count === 1 && FULL_SHA.test(ref.toLowerCase()))
148
+ if (known) return { ref, subdir: count < parts.length ? safeSubdir(parts.slice(count).join('/')) : undefined }
149
+ }
150
+ throw new Error(`no branch or tag of the repository matches the link's "${safeLine(treePath)}"`)
151
+ }
152
+
153
+ /** Why a ref name cannot be fetched safely, or undefined when it can. */
154
+ function unsafeRef(ref: string): string | undefined {
155
+ if (ref.startsWith('-')) return 'starts with a dash'
156
+ if (SPECIAL_REFS.has(ref)) return `is git's own ${ref}`
157
+ if (/[\x00-\x20\x7f~^:?*[\\]|\.\.|@\{|\/\/|^\/|\/$|\.lock$|^\.|\/\./.test(ref)) return 'is not a valid ref name'
158
+ return undefined
159
+ }
160
+
161
+ export type RemoteRefs = {
162
+ head?: string
163
+ defaultBranch?: string
164
+ /** Full ref name to commit: a tag's own commit when the tag is annotated. */
165
+ refs: Map<string, string>
166
+ }
167
+
168
+ export function parseLsRemote(stdout: string): RemoteRefs {
169
+ const refs = new Map<string, string>()
170
+ const peeled = new Map<string, string>()
171
+ let head: string | undefined
172
+ let defaultBranch: string | undefined
173
+ for (const line of stdout.split('\n')) {
174
+ const symref = /^ref: (\S+)\tHEAD$/.exec(line)
175
+ if (symref !== null) {
176
+ defaultBranch = symref[1]
177
+ continue
178
+ }
179
+ const entry = /^([0-9a-f]{40}|[0-9a-f]{64})\t(\S+)$/.exec(line)
180
+ if (entry === null) continue
181
+ const [, sha, name] = entry as unknown as [string, string, string]
182
+ if (name === 'HEAD') head = sha
183
+ else if (name.endsWith('^{}')) peeled.set(name.slice(0, -3), sha)
184
+ else refs.set(name, sha)
185
+ }
186
+ for (const [name, sha] of peeled) refs.set(name, sha)
187
+ return { head, defaultBranch, refs }
188
+ }
189
+
190
+ /** The commit a ref names, refusing anything a name could make ambiguous. */
191
+ export function resolveRef(listed: RemoteRefs, ref: string | undefined): Pick<PinnedSource, 'sha' | 'refKind' | 'ref'> {
192
+ if (ref === undefined) {
193
+ if (listed.head === undefined) throw new Error('the repository has no default branch to scan')
194
+ return { sha: listed.head, refKind: 'default' }
195
+ }
196
+ const lower = ref.toLowerCase()
197
+ if (FULL_SHA.test(lower)) return { sha: lower, refKind: 'commit', ref }
198
+ const bad = unsafeRef(ref)
199
+ if (bad !== undefined) throw new Error(`the ref "${safeLine(ref)}" ${bad}`)
200
+ const names = ref.startsWith('refs/') ? [ref] : [`refs/tags/${ref}`, `refs/heads/${ref}`]
201
+ const found = names.filter(name => listed.refs.has(name))
202
+ if (found.length > 1) throw new Error(`both a branch and a tag are named "${safeLine(ref)}"; name the commit instead`)
203
+ const name = found[0]
204
+ if (name === undefined) {
205
+ if (SHORT_SHA.test(lower)) throw new Error(`"${safeLine(ref)}" looks like a short commit id; use the full 40-character id`)
206
+ throw new Error(`the repository has no branch or tag named "${safeLine(ref)}"`)
207
+ }
208
+ return { sha: listed.refs.get(name) as string, refKind: name.startsWith('refs/tags/') ? 'tag' : 'branch', ref }
209
+ }
210
+
211
+ /**
212
+ * Fetches exactly one commit into a new folder of the guard's own and checks
213
+ * it out, with no hooks, no submodules, no LFS downloads and no filters. The
214
+ * checkout is asserted to sit on the pinned commit: a branch named like the
215
+ * commit, or like FETCH_HEAD, cannot swap other code in.
216
+ */
217
+ export async function fetchPinned(host: Host, spec: RemoteSpec, onStage?: (stage: string) => Promise<void> | void): Promise<FetchedSource> {
218
+ await onStage?.(`resolving ${safeLine(spec.url)}`)
219
+ const listing = await git(host, ['ls-remote', '--symref', spec.url, 'HEAD', 'refs/heads/*', 'refs/tags/*'], 60_000)
220
+ if (listing.exitCode !== 0) throw failure(listing, `could not reach ${spec.url}`)
221
+ const listed = parseLsRemote(listing.stdout)
222
+ const fromLink = spec.treePath !== undefined && spec.ref === undefined ? splitTreePath(listed, spec.treePath) : undefined
223
+ const subdir = spec.subdir ?? fromLink?.subdir
224
+ const pinned = resolveRef(listed, spec.ref ?? fromLink?.ref)
225
+ const dir = await makeTempDir(host)
226
+ const cleanup = () => removeTempDir(host, dir)
227
+ try {
228
+ const work = `${dir}/repo`
229
+ await must(git(host, ['init', '-q', '--template=', work], 60_000, ISOLATED_ENV), 'could not prepare a folder for the fetch')
230
+ await onStage?.(`fetching ${pinned.sha.slice(0, 12)}`)
231
+ const fetched = await boundedFetch(host, [
232
+ '-C', work, '-c', 'protocol.allow=never', '-c', 'protocol.https.allow=always', '-c', 'fetch.unpackLimit=1',
233
+ 'fetch', '--depth', '1', '--no-tags', '--no-recurse-submodules', '-q', spec.url, pinned.sha,
234
+ ])
235
+ if (fetched.exitCode !== 0) {
236
+ // The size limit stops git's index-pack as it writes the pack; git then
237
+ // says so and gives up. A fetch that stops without a word is likely it too.
238
+ if (/index-pack failed|unpack-objects failed|file size limit exceeded/i.test(fetched.stderr)) {
239
+ throw new Error(`the commit weighs more than ${MAX_FETCH_BYTES / 1024 / 1024} MB, too much to scan`)
240
+ }
241
+ if (fetched.stderr.trim() === '') throw new Error(`the fetch stopped without a message; the commit may weigh more than ${MAX_FETCH_BYTES / 1024 / 1024} MB`)
242
+ throw failure(fetched, `could not fetch ${pinned.sha}`)
243
+ }
244
+ const counted = await must(git(host, ['-C', work, 'count-objects', '-v'], 60_000, ISOLATED_ENV), 'could not measure the fetch')
245
+ const kib = ['size', 'size-pack'].reduce((sum, key) => sum + Number(new RegExp(`^${key}:\\s*(\\d+)`, 'm').exec(counted.stdout)?.[1] ?? 0), 0)
246
+ if (kib * 1024 > MAX_FETCH_BYTES) throw new Error(`the commit weighs more than ${MAX_FETCH_BYTES / 1024 / 1024} MB, too much to scan`)
247
+ // What the checkout will write, measured before it is written: a small
248
+ // pack can hold files that compress to almost nothing.
249
+ const tree = await must(git(host, ['-C', work, 'ls-tree', '-r', '-z', '-l', '--full-tree', pinned.sha], 60_000, ISOLATED_ENV), 'could not list the commit')
250
+ const entries = parseLsTree(tree.stdout)
251
+ const bytes = entries.reduce((sum, entry) => sum + (entry.size ?? 0), 0)
252
+ if (entries.length > LIMITS.maxEntries) throw new Error(`the commit holds more than ${LIMITS.maxEntries} files`)
253
+ if (bytes > LIMITS.maxTotalBytes) throw new Error(`the commit's files add up to more than ${LIMITS.maxTotalBytes / 1024 / 1024} MB, too much to scan`)
254
+ await must(
255
+ git(
256
+ host,
257
+ ['-C', work, '-c', 'core.autocrlf=false', '-c', 'core.symlinks=true', '-c', 'filter.lfs.smudge=', '-c', 'filter.lfs.process=', '-c', 'filter.lfs.required=false', 'checkout', '-q', '--detach', pinned.sha],
258
+ 120_000,
259
+ ISOLATED_ENV,
260
+ ),
261
+ `could not check out ${pinned.sha}`,
262
+ )
263
+ // The assertion that closes Plugin4Shell: what HEAD is, not what was asked.
264
+ const head = (await must(git(host, ['-C', work, 'rev-parse', '--verify', 'HEAD'], 60_000, ISOLATED_ENV), 'could not read the checkout')).stdout.trim()
265
+ if (head !== pinned.sha) throw new Error(`the checkout landed on ${safeLine(head)}, not on the pinned commit ${pinned.sha}`)
266
+ const source: PinnedSource = { url: spec.url, ...pinned, defaultBranch: listed.defaultBranch?.replace(/^refs\/heads\//, ''), subdir: subdir }
267
+ let root = work
268
+ // Every step of the folder must be a plain folder: a link anywhere on the
269
+ // way could lead out of the checkout to your own files.
270
+ for (const part of subdir?.split('/') ?? []) {
271
+ root = `${root}/${part}`
272
+ const stat = await host.stat(root, { resolve: false }).catch(() => undefined)
273
+ if (stat?.isLink === true) throw new Error(`"${safeLine(subdir ?? '')}" passes through a symbolic link, which could lead outside the repository`)
274
+ if (stat === undefined || stat.kind !== 'dir') throw new Error(`the commit has no folder "${safeLine(subdir ?? '')}"`)
275
+ }
276
+ return { root, source, results: [sourceRow(source), refsRow(listed), checkoutRow(entries, subdir)], cleanup }
277
+ } catch (error) {
278
+ await cleanup().catch(() => undefined)
279
+ throw error
280
+ }
281
+ }
282
+
283
+ function git(host: Host, args: readonly string[], timeoutMs = 60_000, env: Record<string, string> = GIT_ENV): Promise<RunResult> {
284
+ return host.run(['git', ...args], { timeoutMs, env })
285
+ }
286
+
287
+ type TreeEntry = { mode: string; type: string; oid: string; size?: number; path: string }
288
+
289
+ /** `git ls-tree -r -z -l` output: one entry per NUL, with each blob's size. */
290
+ function parseLsTree(stdout: string): TreeEntry[] {
291
+ const out: TreeEntry[] = []
292
+ for (const entry of stdout.split(String.fromCharCode(0))) {
293
+ const match = /^(\d{6}) (\w+) ([0-9a-f]{40,64}) +(\d+|-)\t([\s\S]+)$/.exec(entry)
294
+ if (match === null) continue
295
+ const [, mode, type, oid, size, path] = match as unknown as [string, string, string, string, string, string]
296
+ out.push({ mode, type, oid, size: size === '-' ? undefined : Number(size), path })
297
+ }
298
+ return out
299
+ }
300
+
301
+ async function must(result: Promise<RunResult>, what: string): Promise<RunResult> {
302
+ const done = await result
303
+ if (done.exitCode !== 0) throw failure(done, what)
304
+ return done
305
+ }
306
+
307
+ function failure(result: RunResult, what: string): Error {
308
+ const line = result.stderr.split('\n').find(text => text.trim() !== '')
309
+ return new Error(`${what}: ${safeLine(line?.trim().slice(0, 300) ?? `git exited with ${result.exitCode}`)}`)
310
+ }
311
+
312
+ const TEMP_NAME = /[\\/]ai-install-guard\.[A-Za-z0-9._-]{6,}$/
313
+
314
+ /** A new, empty folder under the system's temp folder, owned by the guard. */
315
+ async function makeTempDir(host: Host): Promise<string> {
316
+ const posix = await host.run(['mktemp', '-d', '-t', 'ai-install-guard.XXXXXXXX']).catch(() => undefined)
317
+ const made = posix !== undefined && posix.exitCode === 0 ? posix.stdout.trim() : await windowsTempDir(host)
318
+ if (!TEMP_NAME.test(made)) throw new Error('could not make a temporary folder for the fetch')
319
+ return made.replace(/\\/g, '/')
320
+ }
321
+
322
+ const WINDOWS_MKTEMP =
323
+ "$p = Join-Path ([IO.Path]::GetTempPath()) ('ai-install-guard.' + [guid]::NewGuid().ToString('N').Substring(0, 12)); New-Item -ItemType Directory -Path $p | Out-Null; [Console]::Out.Write($p)"
324
+
325
+ async function windowsTempDir(host: Host): Promise<string> {
326
+ const result = await host.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_MKTEMP]).catch(() => undefined)
327
+ return result !== undefined && result.exitCode === 0 ? result.stdout.trim() : ''
328
+ }
329
+
330
+ /** Removes a folder this module made, and nothing else. */
331
+ async function removeTempDir(host: Host, dir: string): Promise<void> {
332
+ if (!TEMP_NAME.test(dir)) return
333
+ const posix = await host.run(['rm', '-rf', '--', dir]).catch(() => undefined)
334
+ if (posix !== undefined && posix.exitCode === 0) return
335
+ await host.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', 'Remove-Item -LiteralPath $env:GUARD_DIR -Recurse -Force'], { env: { GUARD_DIR: dir } }).catch(() => undefined)
336
+ }
337
+
338
+ function sourceRow(source: PinnedSource): CheckResult {
339
+ const what =
340
+ source.refKind === 'commit'
341
+ ? 'pinned to the commit you named'
342
+ : source.refKind === 'tag'
343
+ ? `tag ${safeLine(source.ref ?? '')} (a tag can be moved later; this scan holds the commit)`
344
+ : source.refKind === 'branch'
345
+ ? `branch ${safeLine(source.ref ?? '')} (a branch moves; this scan holds the commit)`
346
+ : `the default branch${source.defaultBranch === undefined ? '' : ` ${safeLine(source.defaultBranch)}`} (it moves; this scan holds the commit)`
347
+ return row(
348
+ {
349
+ id: 'provenance.source',
350
+ group: 'provenance',
351
+ title: 'Source and pinned commit',
352
+ advice: 'Everything below describes this one commit. Installing anything else needs a new scan.',
353
+ },
354
+ [{ file: '-', excerpt: `${safeLine(source.url)}${source.subdir === undefined ? '' : ` folder ${safeLine(source.subdir)}`} at ${source.sha}: ${what}`, status: 'info' }],
355
+ 'info',
356
+ )
357
+ }
358
+
359
+ /**
360
+ * Branches and tags named like a commit id, or like git's own FETCH_HEAD, are
361
+ * how a pinned install is made to check out other code (Plugin4Shell). There
362
+ * is no honest reason for one; a short hex name is only odd.
363
+ */
364
+ function refsRow(listed: RemoteRefs): CheckResult {
365
+ const evidence: Evidence[] = []
366
+ for (const name of listed.refs.keys()) {
367
+ const match = /^refs\/(heads|tags)\/(.+)$/.exec(name)
368
+ if (match === null) continue
369
+ const kind = match[1] === 'heads' ? 'branch' : 'tag'
370
+ const short = match[2] as string
371
+ const isDefault = name === listed.defaultBranch
372
+ const label = `the ${isDefault ? 'default ' : ''}${kind} "${safeLine(short)}"`
373
+ if (FULL_SHA.test(short.toLowerCase())) evidence.push({ file: '-', excerpt: `${label} is named like a commit id, which can make a pinned install check out other code`, status: 'fail' })
374
+ else if (SPECIAL_REFS.has(short)) evidence.push({ file: '-', excerpt: `${label} is named like git's own ${short}, which can make a pinned install check out other code`, status: 'fail' })
375
+ else if (SHORT_SHA.test(short) && /\d/.test(short) && /[a-f]/.test(short)) evidence.push({ file: '-', excerpt: `${label} is named like a short commit id`, status: 'warn' })
376
+ }
377
+ return row(
378
+ {
379
+ id: 'provenance.refs',
380
+ group: 'provenance',
381
+ title: 'Branch and tag names',
382
+ advice: 'A branch named like a commit id or like FETCH_HEAD lets the repository swap in other code when a client checks out a pinned commit by name. Treat the repository as hostile.',
383
+ },
384
+ capEvidence(evidence, 20),
385
+ )
386
+ }
387
+
388
+ /** What a plain checkout leaves out: submodules, whose code lives elsewhere. */
389
+ function checkoutRow(entries: readonly TreeEntry[], subdir: string | undefined): CheckResult {
390
+ const evidence: Evidence[] = []
391
+ for (const { mode, oid, path } of entries) {
392
+ if (mode !== '160000') continue
393
+ if (subdir !== undefined && path !== subdir && !path.startsWith(`${subdir}/`)) continue
394
+ const shown = subdir === undefined ? path : path.slice(subdir.length + 1) || '.'
395
+ evidence.push({ file: shown, excerpt: `a submodule at commit ${oid.slice(0, 12)}: its code is fetched separately and is not part of this scan`, status: 'warn' })
396
+ }
397
+ return row(
398
+ {
399
+ id: 'provenance.checkout',
400
+ group: 'provenance',
401
+ title: 'Code outside the commit',
402
+ advice: 'A submodule pulls code from another repository at install time. That code was not scanned.',
403
+ },
404
+ capEvidence(evidence, 20),
405
+ )
406
+ }
@@ -25,8 +25,12 @@ export function formatReport(report: ScanReport, options: { details?: boolean }
25
25
  if (inventory !== undefined) {
26
26
  const files = inventory.entries.filter(entry => entry.kind === 'file').length
27
27
  lines.push(`${report.kind === undefined ? '' : `A ${report.kind} · `}${files} files (${formatBytes(inventory.totalBytes)}) · tree ${inventory.treeHash}`)
28
- lines.push(`Folder: ${safeLine(inventory.root)}`)
29
- if (inventory.skipped.length > 0) lines.push(`Not read: ${inventory.skipped.map(safeLine).join(', ')}`)
28
+ const { source } = report
29
+ if (source === undefined) lines.push(`Folder: ${safeLine(inventory.root)}`)
30
+ else lines.push(`Source: ${safeLine(source.url)}${source.subdir === undefined ? '' : ` folder ${safeLine(source.subdir)}`} at commit ${source.sha}`)
31
+ // A fetched commit's .git folder is the guard's own, not the candidate's.
32
+ const skipped = source === undefined ? inventory.skipped : inventory.skipped.filter(path => path !== '.git')
33
+ if (skipped.length > 0) lines.push(`Not read: ${skipped.map(safeLine).join(', ')}`)
30
34
  }
31
35
  if (report.results.length === 0) return lines.join('\n')
32
36
 
@@ -47,13 +51,18 @@ export function countsLine(report: ScanReport): string {
47
51
  function formatRow(result: CheckResult, details: boolean): string[] {
48
52
  const shown = details ? result.evidence : result.evidence.slice(0, 1)
49
53
  const head = ` ${LABEL[result.status]} ${result.title}`
54
+ const summary = (result.summary ?? []).map(line => safeLine(redactSecrets(line)))
50
55
  if (!details) {
56
+ if (summary[0] !== undefined) return [`${head}: ${summary[0]}`]
51
57
  const first = shown[0]
52
58
  const more = result.evidence.length > 1 ? ` (+${result.evidence.length - 1} more)` : ''
53
59
  return [first === undefined ? head : `${head}: ${evidenceLine(first)}${more}`]
54
60
  }
55
61
  const out = [head]
56
- for (const item of shown) out.push(` ${item.status === undefined || item.status === result.status ? '' : `${LABEL[item.status]} `}${evidenceLine(item)}`)
62
+ // What it means first, then where, then what to do.
63
+ for (const line of summary) out.push(` ${line}`)
64
+ if (summary.length > 0 && shown.length > 0) out.push(' Where:')
65
+ for (const item of shown) out.push(` ${summary.length > 0 ? ' ' : ''}${item.status === undefined || item.status === result.status ? '' : `${LABEL[item.status]} `}${evidenceLine(item)}`)
57
66
  if (result.status === 'fail' || result.status === 'warn') out.push(` Advice: ${result.advice}`)
58
67
  return out
59
68
  }
@@ -1,17 +1,22 @@
1
1
  import { codeChecks } from './checks/code'
2
2
  import { buildContext, isRecord, parseJson, type CandidateKind, type ScanContext } from './checks/context'
3
3
  import { hookChecks } from './checks/hooks'
4
+ import { mcpChecks } from './checks/mcp'
4
5
  import { modChecks } from './checks/mod'
5
6
  import { promptChecks } from './checks/prompts'
7
+ import { badgeClaim, badgeRow, marketplacePinsCheck, type BadgeClaim } from './checks/provenance'
6
8
  import { licenseCheck, qualityChecks } from './checks/quality'
7
9
  import { secretChecks } from './checks/secrets'
8
10
  import { structureChecks } from './checks/structure'
9
11
  import { joinPath } from './checks/text'
10
- import { GROUPS, rankOf, type CheckResult, type Evidence } from './checks/types'
12
+ import { GROUPS, rankOf, row, type CheckResult, type Evidence } from './checks/types'
11
13
  import { runValidator } from './checks/validator'
12
14
  import type { Host } from './host'
13
15
  import { treeHash } from './hash'
14
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'
15
20
  import { decide, type Verdict } from './verdict'
16
21
 
17
22
  export type ScanReport = {
@@ -21,11 +26,19 @@ export type ScanReport = {
21
26
  inventory?: Inventory
22
27
  results: CheckResult[]
23
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
24
33
  }
25
34
 
26
35
  export type ScanOptions = {
27
36
  /** Run `claude plugin validate` on the candidate (on by default). */
28
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
29
42
  /** Told each stage as it starts, for progress in the pane. */
30
43
  onStage?: (stage: string) => Promise<void> | void
31
44
  /**
@@ -41,6 +54,7 @@ export async function scanFolder(host: Host, target: string, path: string, rawOp
41
54
  // Progress is a courtesy: a failure to show it never fails the scan.
42
55
  const options: ScanOptions = {
43
56
  ...rawOptions,
57
+ ...(rawOptions.lookups === true && rawOptions.lookupCache === undefined ? { lookupCache: newLookupCache() } : {}),
44
58
  onStage: async stage => {
45
59
  try {
46
60
  await rawOptions.onStage?.(stage)
@@ -78,7 +92,10 @@ export async function scanFolder(host: Host, target: string, path: string, rawOp
78
92
  results = mergeResults(results, [missingSourceRow(source.name, source.path)])
79
93
  continue
80
94
  }
81
- results = mergeResults(results, prefixed(await checksFor(host, buildContext(nested), options), source.path))
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))
82
99
  }
83
100
  } catch (error) {
84
101
  return { target, kind: ctx.kind, inventory: tree.inventory, results: [], verdict: decide([], `a check failed: ${reasonOf(error)}`) }
@@ -99,17 +116,41 @@ export async function scanFolder(host: Host, target: string, path: string, rawOp
99
116
  ])
100
117
  results = [...results, ...extra]
101
118
  }
102
- results = sortResults(results)
103
- return { target, kind: ctx.kind, inventory: tree.inventory, results, verdict: decide(results) }
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)]))
104
139
  }
105
140
 
106
141
  async function checksFor(host: Host, ctx: ScanContext, options: ScanOptions): Promise<CheckResult[]> {
107
- const validator = options.validate === false || ctx.kind === 'folder' ? undefined : await runValidator(host, ctx.tree.inventory.root)
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)
108
146
  return [
109
147
  licenseCheck(ctx),
148
+ ...marketplacePinsCheck(ctx),
110
149
  ...structureChecks(ctx, validator),
111
150
  ...modChecks(ctx, validator),
112
151
  ...hookChecks(ctx),
152
+ ...mcpChecks(ctx),
153
+ ...(options.lookups === true ? await outsideLookups(host, ctx, options.lookupCache) : []),
113
154
  ...promptChecks(ctx),
114
155
  ...codeChecks(ctx),
115
156
  ...secretChecks(ctx),
@@ -125,6 +166,44 @@ type MarketplaceSource =
125
166
  | { kind: 'overflow'; count: number }
126
167
 
127
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
+
128
207
  function marketplaceSources(ctx: ScanContext): MarketplaceSource[] {
129
208
  const raw = ctx.text('.claude-plugin/marketplace.json')
130
209
  const parsed = parseJson(raw)
@@ -177,10 +256,11 @@ async function subTree(tree: ScannedTree, prefix: string): Promise<ScannedTree |
177
256
  }
178
257
  }
179
258
 
180
- /** A nested plugin's rows, with every place named from the marketplace root. */
259
+ /** A nested plugin's rows, with every place and its summary named from the marketplace root. */
181
260
  function prefixed(results: readonly CheckResult[], prefix: string): CheckResult[] {
182
261
  return results.map(result => ({
183
262
  ...result,
263
+ ...(result.summary === undefined ? {} : { summary: result.summary.map((line, index) => (index === 0 ? `${prefix}: ${line}` : line)) }),
184
264
  evidence: result.evidence.map(item =>
185
265
  item.file === '-' || item.file === '' ? { ...item, excerpt: `${prefix}: ${item.excerpt}` } : { ...item, file: `${prefix}/${item.file}` },
186
266
  ),
@@ -210,8 +290,14 @@ export function mergeResults(base: readonly CheckResult[], extra: readonly Check
210
290
  const kept = existing.evidence[at] as Evidence
211
291
  if (rankOf(item.status ?? result.status) > rankOf(kept.status ?? existing.status)) existing.evidence[at] = item
212
292
  }
213
- // A pass on one side and a note on the other stay a pass; anything worse wins.
214
- if (rankOf(result.status) > rankOf(existing.status)) existing.status = result.status
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
+ }
215
301
  if (order[result.severity] > order[existing.severity]) existing.severity = result.severity
216
302
  existing.evidence.sort((a, b) => rankOf(b.status ?? existing.status) - rankOf(a.status ?? existing.status))
217
303
  }
@@ -4,7 +4,8 @@ export type VerdictKind = 'safe' | 'caution' | 'reject' | 'unverified'
4
4
 
5
5
  export const RECOMMENDATION: Record<VerdictKind, string> = {
6
6
  safe: 'Safe to install',
7
- caution: 'Install with caution',
7
+ // Not a lesser "safe": powerful access worth knowing about before you install.
8
+ caution: 'Review what it can do',
8
9
  reject: 'Do not install',
9
10
  unverified: 'Could not verify',
10
11
  }
@@ -20,7 +21,7 @@ export type Verdict = {
20
21
  }
21
22
 
22
23
  /**
23
- * Any FAIL: do not install. Else any WARN: install with caution. Else safe.
24
+ * Any FAIL: do not install. Else any WARN: review what it can do. Else safe.
24
25
  * A scan that could not finish is "could not verify" whatever it found.
25
26
  * Rows only ever add to this: nothing a row says can lower another row.
26
27
  */
@@ -45,8 +46,13 @@ function severityRank(result: CheckResult): number {
45
46
  return order[result.severity] * 10 + rankOf(result.status)
46
47
  }
47
48
 
48
- /** `Title: file:line what` from the row's worst piece of evidence. */
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
+ */
49
53
  export function reasonOf(result: CheckResult): string {
54
+ const headline = result.summary?.[0]
55
+ if (headline !== undefined) return `${result.title}: ${headline}`
50
56
  const worst = [...result.evidence].sort((a, b) => rankOf(b.status ?? result.status) - rankOf(a.status ?? result.status))[0]
51
57
  if (worst === undefined) return result.title
52
58
  return `${result.title}: ${locate(worst.file, worst.line)}${worst.excerpt}`