ai-install-guard 0.1.0 → 0.3.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,210 @@
1
+ // Where an install would come from: the repository, folder and commit Claude
2
+ // Code would install, read from its own plugin listing and marketplace
3
+ // catalogs. Only the `claude` command and file reads are used; nothing from
4
+ // the candidate runs.
5
+
6
+ import type { Host } from '../scan/host'
7
+ import { looksRemote, parseRemote, type RemoteSpec } from '../scan/remote'
8
+ import { safeLine } from '../scan/safe-text'
9
+ import type { InstallRequest } from './requests'
10
+
11
+ export type Target =
12
+ /** One commit of a repository, and the folder in it. */
13
+ | { kind: 'remote'; spec: RemoteSpec }
14
+ /** A folder on this machine: a marketplace's own copy of a plugin, or a marketplace added from a folder. */
15
+ | { kind: 'local'; path: string }
16
+ /** A marketplace name: Claude Code shows its menu to pick a plugin from. */
17
+ | { kind: 'browse' }
18
+ | { kind: 'unverifiable'; reason: string }
19
+
20
+ /** The most a marketplace catalog may weigh before the guard stops reading it. */
21
+ const MAX_CATALOG_BYTES = 4 * 1024 * 1024
22
+
23
+ type Listed = { pluginId?: unknown; name?: unknown; marketplaceName?: unknown; source?: unknown }
24
+ type Marketplace = { name: string; installLocation?: string }
25
+
26
+ /** The target an install request names. A lookup that fails says why, as an unverifiable target. */
27
+ export async function targetOf(host: Host, request: InstallRequest): Promise<Target> {
28
+ try {
29
+ return request.kind === 'marketplace' ? await marketplaceTarget(host, request.source) : await pluginTarget(host, request)
30
+ } catch (error) {
31
+ return { kind: 'unverifiable', reason: error instanceof Error ? error.message : String(error) }
32
+ }
33
+ }
34
+
35
+ async function pluginTarget(host: Host, request: Extract<InstallRequest, { kind: 'plugin' }>): Promise<Target> {
36
+ const listing = await claudeJson(host, ['plugin', 'list', '--available', '--json'], 'the plugin list')
37
+ const available: Listed[] = isRecord(listing) && Array.isArray(listing.available) ? (listing.available as Listed[]) : []
38
+ const installed = isRecord(listing) && Array.isArray(listing.installed) ? (listing.installed as { id?: unknown }[]) : []
39
+ const id = request.marketplace === undefined ? request.plugin : `${request.plugin}@${request.marketplace}`
40
+ const matches = available.filter(item => item.name === request.plugin && (request.marketplace === undefined || item.marketplaceName === request.marketplace))
41
+ // Where marketplaces live is read only when it is needed: for a relative source or a catalog file.
42
+ let listed: Promise<Marketplace[]> | undefined
43
+ const marketplaces = (): Promise<Marketplace[]> => (listed ??= marketplaceList(host))
44
+
45
+ if (matches.length === 0) {
46
+ // An installed plugin is not in the available list: its catalog says where an update comes from.
47
+ // A marketplace named is that marketplace's plugin and no other's.
48
+ const mine = installed.map(item => (typeof item.id === 'string' ? item.id : '')).filter(item => (request.marketplace === undefined ? item.startsWith(`${request.plugin}@`) : item === id))
49
+ if (mine.length > 1 && request.marketplace === undefined) return ambiguous(request.plugin, mine.map(item => item.split('@')[1] ?? ''))
50
+ const owner = mine[0]?.split('@')[1] ?? request.marketplace
51
+ const market = (await marketplaces()).find(item => item.name === owner)
52
+ if (market !== undefined) {
53
+ const entry = await catalogEntry(host, market, request.plugin)
54
+ if (entry !== undefined) return sourceTarget(host, entry, async () => market)
55
+ }
56
+ // `/plugin install <marketplace>` opens that marketplace's menu.
57
+ if (request.verb === 'install' && request.marketplace === undefined && (await marketplaces()).some(item => item.name === request.plugin)) return { kind: 'browse' }
58
+ return { kind: 'unverifiable', reason: `${safeLine(id)} is not in the marketplaces you added, so there was nothing to scan` }
59
+ }
60
+
61
+ // The same plugin can be listed by two marketplaces. One source between them is one target.
62
+ const distinct = new Map(matches.map(item => [sourceKey(item), item]))
63
+ if (distinct.size > 1) return ambiguous(request.plugin, matches.map(item => String(item.marketplaceName)))
64
+ const entry = [...distinct.values()][0] as Listed
65
+ return sourceTarget(host, entry, () => marketplaces().then(list => list.find(item => item.name === entry.marketplaceName)))
66
+ }
67
+
68
+ function ambiguous(plugin: string, marketplaces: readonly string[]): Target {
69
+ const names = [...new Set(marketplaces)].map(safeLine)
70
+ return {
71
+ kind: 'unverifiable',
72
+ reason: `${safeLine(plugin)} is in more than one marketplace (${names.join(', ')}); name one, as in ${safeLine(plugin)}@${names[0] ?? '<marketplace>'}`,
73
+ }
74
+ }
75
+
76
+ /**
77
+ * A source's identity: a relative source belongs to its marketplace, so two
78
+ * marketplaces' `./x` are never the same; an object source is its fields in
79
+ * name order, however a listing orders them.
80
+ */
81
+ function sourceKey(item: Listed): string {
82
+ if (typeof item.source === 'string') return `${String(item.marketplaceName)}:${item.source}`
83
+ return JSON.stringify(canonical(item.source ?? null))
84
+ }
85
+
86
+ function canonical(value: unknown): unknown {
87
+ if (Array.isArray(value)) return value.map(canonical)
88
+ if (!isRecord(value)) return value
89
+ return Object.keys(value)
90
+ .sort()
91
+ .map(key => [key, canonical(value[key])])
92
+ }
93
+
94
+ /** Where a catalog entry's `source` says the plugin comes from; `marketOf` finds its marketplace when a relative source needs it. */
95
+ async function sourceTarget(host: Host, entry: Listed, marketOf: () => Promise<Marketplace | undefined>): Promise<Target> {
96
+ const source = entry.source
97
+ if (typeof source === 'string') {
98
+ if (!source.startsWith('./')) return { kind: 'unverifiable', reason: `its source "${safeLine(source)}" is not a folder inside its marketplace` }
99
+ const market = await marketOf()
100
+ if (market?.installLocation === undefined) return { kind: 'unverifiable', reason: 'its marketplace has no copy on this machine to read it from' }
101
+ return { kind: 'local', path: await insideFolder(host, market.installLocation, source) }
102
+ }
103
+ if (!isRecord(source) || typeof source.source !== 'string') return { kind: 'unverifiable', reason: 'its catalog entry names no source' }
104
+ const ref = text(source.sha) ?? text(source.ref)
105
+ switch (source.source) {
106
+ case 'url':
107
+ return remote(text(source.url), ref)
108
+ case 'git-subdir':
109
+ // Without its folder the entry would name the whole repository, which is not what installs.
110
+ if (text(source.path) === undefined) return { kind: 'unverifiable', reason: 'its catalog entry names no folder inside its repository' }
111
+ return remote(text(source.url), ref, text(source.path))
112
+ case 'github':
113
+ return remote(text(source.repo) === undefined ? undefined : `https://github.com/${text(source.repo)}.git`, ref)
114
+ default:
115
+ return { kind: 'unverifiable', reason: `it installs from a ${safeLine(source.source)} source, which the guard cannot read yet` }
116
+ }
117
+ }
118
+
119
+ function remote(url: string | undefined, ref: string | undefined, subdir?: string): Target {
120
+ if (url === undefined) return { kind: 'unverifiable', reason: 'its catalog entry names no repository' }
121
+ return { kind: 'remote', spec: parseRemote(url, { ref, subdir }) }
122
+ }
123
+
124
+ /** What `marketplace add <source>` would add: a repository, or a folder. */
125
+ async function marketplaceTarget(host: Host, source: string): Promise<Target> {
126
+ const shape = looksRemote(source)
127
+ if (shape === 'local' || (shape === 'maybe' && (await host.stat(source).catch(() => undefined)) !== undefined)) {
128
+ const path = await expandHome(host, source)
129
+ const stat = await host.stat(path).catch(() => undefined)
130
+ if (stat === undefined) return { kind: 'unverifiable', reason: `there is no folder at ${safeLine(source)}` }
131
+ if (stat.kind !== 'dir') return { kind: 'unverifiable', reason: `${safeLine(source)} is a file; the guard scans the folder or repository a marketplace lives in` }
132
+ return { kind: 'local', path }
133
+ }
134
+ const spec = parseRemote(source)
135
+ if (/\.json$/i.test(spec.url)) return { kind: 'unverifiable', reason: 'it adds a catalog file from a web address; the guard scans the repository a marketplace lives in' }
136
+ return { kind: 'remote', spec }
137
+ }
138
+
139
+ async function marketplaceList(host: Host): Promise<Marketplace[]> {
140
+ const listed = await claudeJson(host, ['plugin', 'marketplace', 'list', '--json'], 'the marketplace list')
141
+ if (!Array.isArray(listed)) return []
142
+ return listed.flatMap(item => (isRecord(item) && typeof item.name === 'string' ? [{ name: item.name, installLocation: text(item.installLocation) }] : []))
143
+ }
144
+
145
+ /** A plugin's entry in a marketplace's own catalog file. */
146
+ async function catalogEntry(host: Host, market: Marketplace, plugin: string): Promise<Listed | undefined> {
147
+ if (market.installLocation === undefined) return undefined
148
+ const path = await insideFolder(host, market.installLocation, './.claude-plugin/marketplace.json')
149
+ const stat = await host.stat(path)
150
+ if (stat.kind !== 'file' || stat.size > MAX_CATALOG_BYTES) throw new Error(`the catalog of ${safeLine(market.name)} is not a file the guard can read`)
151
+ let parsed: unknown
152
+ try {
153
+ parsed = JSON.parse(new TextDecoder().decode(await host.readBytes(path)))
154
+ } catch {
155
+ throw new Error(`the catalog of ${safeLine(market.name)} is not valid JSON`)
156
+ }
157
+ const plugins = isRecord(parsed) && Array.isArray(parsed.plugins) ? parsed.plugins : []
158
+ const found = plugins.find(item => isRecord(item) && item.name === plugin)
159
+ return isRecord(found) ? { name: plugin, marketplaceName: market.name, source: found.source } : undefined
160
+ }
161
+
162
+ /**
163
+ * `relative` inside `folder`: no `..` above it, and no symbolic link on the
164
+ * way, which could lead the scan (and the install) out to other files.
165
+ */
166
+ async function insideFolder(host: Host, folder: string, relative: string): Promise<string> {
167
+ const parts = relative.split(/[\\/]+/).filter(part => part !== '' && part !== '.')
168
+ if (parts.includes('..')) throw new Error(`"${safeLine(relative)}" leads outside its marketplace`)
169
+ let path = folder.replace(/[\\/]+$/, '')
170
+ for (const part of parts) {
171
+ path = `${path}/${part}`
172
+ const stat = await host.stat(path, { resolve: false }).catch(() => undefined)
173
+ if (stat === undefined) throw new Error(`its marketplace has no ${safeLine(relative)}`)
174
+ if (stat.isLink) throw new Error(`"${safeLine(relative)}" passes through a symbolic link, which could lead outside its marketplace`)
175
+ }
176
+ return path
177
+ }
178
+
179
+ /** Runs `claude <args>` and reads its JSON answer. */
180
+ async function claudeJson(host: Host, args: readonly string[], what: string): Promise<unknown> {
181
+ const result = await host.run(['claude', ...args], { timeoutMs: 60_000 }).catch((error: unknown) => {
182
+ throw new Error(`could not read ${what}: ${error instanceof Error ? safeLine(error.message) : 'the claude command did not run'}`)
183
+ })
184
+ if (result.exitCode !== 0) {
185
+ const line = result.stderr.split('\n').find(item => item.trim() !== '') ?? `claude exited with ${result.exitCode}`
186
+ throw new Error(`could not read ${what}: ${safeLine(line.trim().slice(0, 300))}`)
187
+ }
188
+ try {
189
+ return JSON.parse(result.stdout)
190
+ } catch {
191
+ throw new Error(`could not read ${what}: its answer was not valid JSON`)
192
+ }
193
+ }
194
+
195
+ /** `~` and `~/…` as the full path of your home folder. */
196
+ export async function expandHome(host: Host, path: string): Promise<string> {
197
+ if (path !== '~' && !path.startsWith('~/')) return path
198
+ const { exitCode, stdout } = await host.run(['printenv', 'HOME'])
199
+ const home = stdout.trim()
200
+ if (exitCode !== 0 || home === '') throw new Error('could not find the home folder')
201
+ return home + path.slice(1)
202
+ }
203
+
204
+ function isRecord(value: unknown): value is Record<string, unknown> {
205
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
206
+ }
207
+
208
+ function text(value: unknown): string | undefined {
209
+ return typeof value === 'string' && value !== '' ? value : undefined
210
+ }
@@ -0,0 +1,35 @@
1
+ import type { GuardView } from '../../types'
2
+
3
+ /** Where the badge links: what it means and how to check it. */
4
+ export const BADGE_PAGE = 'https://github.com/ziniman/ai-install-guard#review-badge'
5
+
6
+ const COLOR = { safe: '2ea44f', caution: 'dbab09' } as const
7
+ const MESSAGE = { safe: 'safe to install', caution: 'review what it can do' } as const
8
+
9
+ /** A shields.io static badge part: dashes and underscores doubled, spaces as underscores. */
10
+ function shieldPart(text: string): string {
11
+ return encodeURIComponent(text.replace(/-/g, '--').replace(/_/g, '__').replace(/ /g, '_'))
12
+ }
13
+
14
+ /**
15
+ * The README badge for the last scan: its verdict and the exact version it
16
+ * covers. Offered for Safe to install and Review what it can do only, and
17
+ * only for a scan that read the files, so it always names what was scanned.
18
+ */
19
+ export function badgeFor(view: GuardView | undefined): { markdown: string; pin: string } | { refusal: string } {
20
+ if (view?.verdict === undefined || view.treeHash === undefined) return { refusal: 'Scan something first: /guard scan <path | repository>.' }
21
+ const kind = view.verdict.kind
22
+ if (kind !== 'safe' && kind !== 'caution') return { refusal: `There is no badge for ${view.verdict.recommendation}.` }
23
+ // Only hex from the scan goes into the badge, never text from the candidate.
24
+ const sha = view.sha !== undefined && /^[0-9a-f]{40,64}$/.test(view.sha) ? view.sha : undefined
25
+ const tree = /^sha256:[0-9a-f]{64}$/.test(view.treeHash) ? view.treeHash : undefined
26
+ if (tree === undefined) return { refusal: 'The last scan has no tree hash to pin a badge to.' }
27
+ const pin = sha !== undefined ? sha.slice(0, 7) : `tree ${tree.slice(7, 15)}`
28
+ // A scan of one folder always says so: the rest of the commit was not
29
+ // judged. The view holds the folder percent-encoded, whole, never cut.
30
+ const folder = sha !== undefined ? view.subdir : undefined
31
+ if (folder !== undefined && !/^[\w./%-]{1,400}$/.test(folder)) return { refusal: 'The folder name is too long to show in a badge.' }
32
+ const image = `https://img.shields.io/badge/${shieldPart('AI Install Guard')}-${shieldPart(`${MESSAGE[kind]} · ${pin}`)}-${COLOR[kind]}`
33
+ const scanned = sha !== undefined ? `commit ${sha}${folder === undefined ? '' : `, folder ${folder}`}, ${tree}` : tree
34
+ return { markdown: `[![AI Install Guard: ${view.verdict.recommendation} at ${pin}${folder === undefined ? '' : ` in ${folder}`}](${image})](${BADGE_PAGE} "Scanned at ${scanned}")`, pin }
35
+ }
@@ -7,6 +7,8 @@ import { safeLine } from '../scan/safe-text'
7
7
  export type Decision = {
8
8
  target: string
9
9
  root?: string
10
+ /** The commit, for a remote source. */
11
+ sha?: string
10
12
  treeHash: string
11
13
  verdict: string
12
14
  /** The verdict the decision was made on; an approval holds up to this one. */
@@ -20,6 +22,8 @@ export type Decision = {
20
22
  const DECISIONS = 'decisions'
21
23
  /** Room for the report text, well inside the store's 4 MiB in all. */
22
24
  export const MAX_REPORT_CHARS = 1_000_000
25
+ /** The last report as text, under the name of the scan that wrote it. */
26
+ const REPORT_TEXT = 'last-report-text'
23
27
  const LAST_VIEW = 'last-view'
24
28
  /** The store is capped at 4 MiB in all: keep the newest decisions only. */
25
29
  const MAX_DECISIONS = 500
@@ -31,8 +35,13 @@ export async function loadDecisions($: EngineInterface): Promise<Decision[]> {
31
35
  return Array.isArray(stored) ? (stored as Decision[]) : []
32
36
  }
33
37
 
38
+ /** One decision per version: the same files at the same commit (or as a folder). */
39
+ function sameVersion(a: { treeHash: string; sha?: string }, b: { treeHash: string; sha?: string }): boolean {
40
+ return a.treeHash === b.treeHash && (a.sha ?? '') === (b.sha ?? '')
41
+ }
42
+
34
43
  export async function recordDecision($: EngineInterface, decision: Decision): Promise<void> {
35
- const others = (await loadDecisions($)).filter(item => item.treeHash !== decision.treeHash)
44
+ const others = (await loadDecisions($)).filter(item => !sameVersion(item, decision))
36
45
  await $.store.set(DECISIONS, [decision, ...others].slice(0, MAX_DECISIONS))
37
46
  }
38
47
 
@@ -65,13 +74,45 @@ export async function forgetDecisions($: EngineInterface, name: string): Promise
65
74
  * one it was given on: the same files can judge worse in another place (a
66
75
  * copy whose absolute link now leaves the folder), and that needs a new look.
67
76
  */
68
- export async function decisionFor($: EngineInterface, treeHash: string, kind: GuardVerdict['kind']): Promise<Decision | undefined> {
69
- const found = (await loadDecisions($)).find(item => item.treeHash === treeHash)
77
+ export async function decisionFor($: EngineInterface, version: { treeHash: string; sha?: string }, kind: GuardVerdict['kind']): Promise<Decision | undefined> {
78
+ // A decision is about one version: another commit with the same files, or
79
+ // the same files as a local folder, is judged on its own.
80
+ const found = (await loadDecisions($)).find(item => sameVersion(item, version))
70
81
  if (found === undefined || found.decision === 'rejected') return found
71
82
  const approvedUpTo = SEVERITY[found.verdictKind] as number | undefined
72
83
  return approvedUpTo !== undefined && SEVERITY[kind] <= approvedUpTo ? found : undefined
73
84
  }
74
85
 
86
+ /**
87
+ * The decision on one commit of one source, found before any scan: a commit
88
+ * names its files exactly, so the same commit of the same folder is the same
89
+ * version however it is asked for.
90
+ */
91
+ export async function pinnedDecision($: EngineInterface, pin: { root: string; sha: string }): Promise<Decision | undefined> {
92
+ return (await loadDecisions($)).find(item => item.sha === pin.sha && item.root === pin.root)
93
+ }
94
+
95
+ /**
96
+ * Stores the text report of one scan, held well inside the store's limit. A
97
+ * failed write leaves no report, never another scan's.
98
+ */
99
+ export async function saveReport($: EngineInterface, scanId: string, text: string): Promise<void> {
100
+ // A newer scan's report is never replaced by an older one's.
101
+ const stored = await $.store.get(REPORT_TEXT).catch(() => undefined)
102
+ const storedId = stored !== null && typeof stored === 'object' ? (stored as { scanId?: unknown }).scanId : undefined
103
+ if (typeof storedId === 'string' && storedId > scanId) return
104
+ const kept = text.length > MAX_REPORT_CHARS ? `${text.slice(0, MAX_REPORT_CHARS)}\n… the report was cut here to fit the store` : text
105
+ await $.store.set(REPORT_TEXT, { scanId, text: kept }).catch(() => $.store.delete(REPORT_TEXT).catch(() => undefined))
106
+ }
107
+
108
+ /** The text report of the scan `scanId` names, if it is still the one stored. */
109
+ export async function loadReport($: EngineInterface, scanId: string | undefined): Promise<string | undefined> {
110
+ const stored = await $.store.get(REPORT_TEXT)
111
+ if (stored === null || typeof stored !== 'object') return undefined
112
+ const { scanId: storedId, text } = stored as { scanId?: unknown; text?: unknown }
113
+ return storedId === scanId && typeof text === 'string' ? text : undefined
114
+ }
115
+
75
116
  /**
76
117
  * Saves the view /guard report reopens. When it cannot be saved, the old one
77
118
  * is removed, so a reopened report never shows an older verdict as current.