ai-install-guard 0.2.0 → 0.3.1

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
+ }
@@ -22,6 +22,8 @@ export type Decision = {
22
22
  const DECISIONS = 'decisions'
23
23
  /** Room for the report text, well inside the store's 4 MiB in all. */
24
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'
25
27
  const LAST_VIEW = 'last-view'
26
28
  /** The store is capped at 4 MiB in all: keep the newest decisions only. */
27
29
  const MAX_DECISIONS = 500
@@ -81,6 +83,36 @@ export async function decisionFor($: EngineInterface, version: { treeHash: strin
81
83
  return approvedUpTo !== undefined && SEVERITY[kind] <= approvedUpTo ? found : undefined
82
84
  }
83
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
+
84
116
  /**
85
117
  * Saves the view /guard report reopens. When it cannot be saved, the old one
86
118
  * is removed, so a reopened report never shows an older verdict as current.
@@ -1,16 +1,19 @@
1
1
  import { atom, read, update } from 'claude-code'
2
2
  import type { EngineInterface, RenderElement, RenderInput } from 'claude-code'
3
3
 
4
- import type { GuardRow, GuardStatus, GuardView } from '../../types'
4
+ import type { GuardRequest, GuardRow, GuardStatus, GuardView } from '../../types'
5
5
  import { GROUPS } from '../scan/checks/types'
6
+ import { safeLine } from '../scan/safe-text'
6
7
  import { locate } from '../scan/verdict'
7
8
  import { badgeFor } from './badge'
8
- import { recordDecision, saveLastView } from './decisions'
9
+ import { changedSinceScan } from '../gate/recheck'
10
+ import { modHost } from '../mod-host'
11
+ import { loadReport, recordDecision, saveLastView } from './decisions'
12
+ import { grantPass, takePass } from './passes'
9
13
  import { needsConfirmation, PANE_ID, primaryLabel } from './view'
10
14
 
11
15
  const viewAtom = atom({ plugin: 'ai-install-guard', key: 'view' } as const, null)
12
16
 
13
- export const REPORT_TEXT = 'last-report-text'
14
17
 
15
18
  const ICON: Record<GuardStatus, string> = { fail: '✖', warn: '⚠', pass: '✔', info: '·' }
16
19
  const COLOR: Record<GuardStatus, string> = { fail: 'error', warn: 'warning', pass: 'success', info: 'subtle' }
@@ -54,14 +57,63 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
54
57
  return { ...current, codeOpen: open.includes(id) ? open.filter(item => item !== id) : [...open, id] }
55
58
  })
56
59
 
60
+ // A press counts only on the scan it was drawn from, and only once: the
61
+ // first press claims the decision, and a scan that took the pane since, or
62
+ // a second press, changes nothing.
63
+ const claim = async (): Promise<GuardView | undefined> => {
64
+ let claimed: GuardView | undefined
65
+ await update($, viewAtom, latest => {
66
+ claimed = undefined
67
+ if (latest === null || latest.scanId !== view.scanId || latest.verdict === undefined || latest.decision !== undefined || latest.deciding === true) return latest
68
+ claimed = { ...latest, deciding: true }
69
+ return claimed
70
+ })
71
+ return claimed
72
+ }
73
+ /** Puts `next` in the pane while it still shows this scan; false once another scan has taken it. */
74
+ const replaceOwn = async (next: GuardView): Promise<boolean> => {
75
+ let ours = false
76
+ await update($, viewAtom, latest => {
77
+ ours = latest !== null && latest.scanId === view.scanId
78
+ return ours ? next : latest
79
+ })
80
+ return ours
81
+ }
82
+ const release = (current: GuardView) => replaceOwn({ ...current, deciding: false })
83
+ /**
84
+ * Saves this scan's outcome for /guard report; should a newer scan take
85
+ * the pane meanwhile, its view is saved instead, so the reopened report is
86
+ * always the pane's.
87
+ */
88
+ const saveOwn = async (outcome: GuardView): Promise<void> => {
89
+ await saveLastView($, outcome).catch(() => undefined)
90
+ const now = await read($, viewAtom).catch(() => null)
91
+ if (now !== null && now.scanId !== outcome.scanId) await saveLastView($, now).catch(() => undefined)
92
+ }
93
+ const movedOn = 'the pane has moved on to another scan'
94
+
57
95
  const decide = (decision: 'approved' | 'rejected', overridden: boolean) => async () => {
58
- const current = await read($, viewAtom)
59
- if (current === null || current.verdict === undefined) return
96
+ const current = await claim()
97
+ if (current === undefined || current.verdict === undefined) return
98
+ const held = current.action === 'install' ? current.request : undefined
60
99
  // A scan that never read the files has no tree hash to pin a decision to:
61
- // the pane closes, nothing is remembered, and nothing claims otherwise.
100
+ // nothing is remembered, and nothing claims otherwise. A held install the
101
+ // person allows anyway goes through once, as that exact command.
62
102
  if (current.treeHash === undefined) {
63
- $.ui.toast('AI Install Guard: closed; the scan read nothing, so there is no decision to remember')
64
- await $.ui.close({ id: PANE_ID })
103
+ if (held !== undefined && decision === 'approved') {
104
+ // The pass is spent on this request: the reopened report shows it allowed, not the buttons again.
105
+ const allowed = { ...current, decision, decidedAt: Date.now(), confirming: false, deciding: false }
106
+ if (!(await replaceOwn(allowed))) {
107
+ $.ui.toast(`AI Install Guard: nothing was allowed: ${movedOn}`)
108
+ return
109
+ }
110
+ await grantPass($, { origin: held.origin, args: held.args })
111
+ await saveOwn(allowed)
112
+ await carryOut($, held, allowed)
113
+ return
114
+ }
115
+ $.ui.toast(held !== undefined ? 'AI Install Guard: rejected; nothing was installed' : 'AI Install Guard: closed; the scan read nothing, so there is no decision to remember')
116
+ if (await release(current)) await closeOwn($, current)
65
117
  return
66
118
  }
67
119
  const decidedAt = Date.now()
@@ -80,21 +132,33 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
80
132
  } catch {
81
133
  // Nothing was saved: say so, and leave the pane open to try again.
82
134
  $.ui.toast('AI Install Guard: could not save the decision; nothing was recorded')
135
+ await release(current)
136
+ return
137
+ }
138
+ const decided = { ...current, decision, decidedAt, confirming: false, deciding: false }
139
+ // The decision is saved for its version either way; the pane, the
140
+ // reopened report and the install are this scan's only while it has the pane.
141
+ if (!(await replaceOwn(decided))) {
142
+ $.ui.toast(`AI Install Guard: ${decision} ${current.target}, but ${movedOn}${held !== undefined && decision === 'approved' ? '; run the install again and it goes through' : ''}`)
83
143
  return
84
144
  }
85
- const decided = { ...current, decision, decidedAt, confirming: false }
86
- await update($, viewAtom, () => decided)
87
145
  // The reopened report shows the outcome, not the buttons again.
88
- await saveLastView($, decided).catch(() => undefined)
89
- $.ui.toast(`AI Install Guard: ${decision === 'approved' ? 'approved' : 'rejected'} ${current.target}`)
90
- await $.ui.close({ id: PANE_ID })
146
+ await saveOwn(decided)
147
+ // The approval is saved for this exact version, so the install, asked for
148
+ // again, now goes through: the gate looks the version up again first.
149
+ if (held !== undefined && decision === 'approved') {
150
+ await carryOut($, held, decided)
151
+ return
152
+ }
153
+ $.ui.toast(`AI Install Guard: ${decision === 'approved' ? 'approved' : 'rejected'} ${current.target}${held === undefined ? '' : '; nothing was installed'}`)
154
+ await closeOwn($, decided)
91
155
  }
92
156
 
93
157
  const primary = async () => {
94
158
  const current = await read($, viewAtom)
95
- if (current === null) return
159
+ if (current === null || current.scanId !== view.scanId) return
96
160
  if (needsConfirmation(current)) {
97
- await update($, viewAtom, latest => (latest === null ? latest : { ...latest, confirming: true }))
161
+ await update($, viewAtom, latest => (latest === null || latest.scanId !== view.scanId ? latest : { ...latest, confirming: true }))
98
162
  return
99
163
  }
100
164
  await decide('approved', false)()
@@ -112,8 +176,9 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
112
176
  const offersBadge = view.treeHash !== undefined && (view.verdict?.kind === 'safe' || view.verdict?.kind === 'caution')
113
177
 
114
178
  const copy = async () => {
115
- const text = await $.store.get(REPORT_TEXT)
116
- const result = await $.ui.copy({ text: typeof text === 'string' ? text : 'No report yet.', surface: e.surface })
179
+ // Only this scan's own report: never another scan's in its place.
180
+ const text = await loadReport($, view.scanId)
181
+ const result = await $.ui.copy({ text: text ?? 'No report yet.', surface: e.surface })
117
182
  $.ui.toast(result.isCopied ? 'AI Install Guard: report copied' : 'AI Install Guard: could not copy the report here')
118
183
  }
119
184
 
@@ -129,6 +194,12 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
129
194
  </Text>
130
195
  )}
131
196
 
197
+ {view.request !== undefined && (
198
+ <Text dimColor wrap="truncate-end">
199
+ {requestLine(view.request)}
200
+ </Text>
201
+ )}
202
+
132
203
  {verdict === undefined ? (
133
204
  <Text color="suggestion" wrap="truncate-end">
134
205
  Scanning: {view.stage}…
@@ -208,9 +279,9 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
208
279
  </Box>
209
280
  ))}
210
281
 
211
- {verdict !== undefined && view.decision === undefined && !view.confirming && (
282
+ {verdict !== undefined && view.decision === undefined && !view.confirming && view.deciding !== true && (
212
283
  <Box key="actions" flexDirection="row" gap={1} marginTop={1}>
213
- {view.treeHash !== undefined && (
284
+ {(view.treeHash !== undefined || (view.action === 'install' && view.request !== undefined)) && (
214
285
  <Button key="install" label={primaryLabel(view)} hotkey="i" variant={needsConfirmation(view) ? 'secondary' : 'primary'} onPress={primary} />
215
286
  )}
216
287
  <Button key="reject" label="Reject" hotkey="r" variant={needsConfirmation(view) ? 'primary' : 'secondary'} onPress={decide('rejected', false)} />
@@ -220,7 +291,7 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
220
291
  </Box>
221
292
  )}
222
293
 
223
- {verdict !== undefined && view.confirming && (
294
+ {verdict !== undefined && view.confirming && view.deciding !== true && (
224
295
  <Box key="confirm-row" flexDirection="column" marginTop={1}>
225
296
  <Text color="warning" wrap="wrap">
226
297
  The verdict is {verdict.recommendation}. {primaryLabel(view)}?
@@ -234,8 +305,9 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
234
305
 
235
306
  {view.decision !== undefined && (
236
307
  <Text color={view.decision === 'approved' ? 'success' : 'error'}>
237
- {view.decision === 'approved' ? 'Approved' : 'Rejected'}
238
- {view.decidedAt === undefined ? '' : ` on ${new Date(view.decidedAt).toISOString().slice(0, 10)}`}. The decision is saved for this exact version; /guard forget clears it.
308
+ {view.treeHash === undefined ? 'Allowed once' : view.decision === 'approved' ? 'Approved' : 'Rejected'}
309
+ {view.decidedAt === undefined ? '' : ` on ${new Date(view.decidedAt).toISOString().slice(0, 10)}`}
310
+ {view.treeHash === undefined ? '. Nothing could be pinned, so nothing is remembered; the same install is held again next time.' : '. The decision is saved for this exact version; /guard forget clears it.'}
239
311
  </Text>
240
312
  )}
241
313
  {view.decision !== undefined && offersBadge && (
@@ -247,6 +319,58 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
247
319
  )
248
320
  }
249
321
 
322
+ /**
323
+ * Closes the pane while it shows this scan. Closing is a step of its own, so
324
+ * a newer scan may take the pane in that moment: while that scan still runs
325
+ * or waits for a decision it gets the pane back at once, so its verdict is
326
+ * never left hidden; one already decided closed the pane itself.
327
+ */
328
+ async function closeOwn($: EngineInterface, view: GuardView): Promise<void> {
329
+ const before = await read($, viewAtom).catch(() => null)
330
+ if (before !== null && before.scanId !== view.scanId) return
331
+ await $.ui.close({ id: PANE_ID })
332
+ const after = await read($, viewAtom).catch(() => null)
333
+ const waiting = after !== null && after.scanId !== view.scanId && after.decision === undefined && after.deciding !== true
334
+ if (waiting) await $.ui.open({ id: PANE_ID, title: 'AI Install Guard', focus: true }).catch(() => undefined)
335
+ }
336
+
337
+ /** What the held install is, in the person's or Claude's own words. */
338
+ function requestLine(request: GuardRequest): string {
339
+ return request.origin === 'tool' ? `Claude asked to run: ${safeLine(request.args)}` : `Held until you decide: /plugin ${safeLine(request.args)}`
340
+ }
341
+
342
+ /**
343
+ * Carries out a held install the person approved. Their own /plugin command
344
+ * runs again once the version it would install is checked to be the one
345
+ * scanned; a command Claude ran is Claude's to run again, once the person
346
+ * tells it to, and its gate checks the version then.
347
+ */
348
+ async function carryOut($: EngineInterface, held: GuardRequest, view: GuardView): Promise<void> {
349
+ await closeOwn($, view)
350
+ if (held.origin === 'tool') {
351
+ $.ui.toast(`AI Install Guard: approved ${held.label}. Tell Claude to run the install again; it goes through now`)
352
+ return
353
+ }
354
+ // The plugin's own command does not pass through its own gate, so the
355
+ // version is looked up again here: a commit that moved since the scan is
356
+ // not installed on the strength of the old one's verdict.
357
+ const changed = await changedSinceScan(modHost($), view).catch((error: unknown) => `the guard could not look it up again (${error instanceof Error ? error.message : String(error)})`)
358
+ if (changed !== undefined) {
359
+ await takePass($, { origin: 'command', args: held.args }).catch(() => undefined)
360
+ $.ui.toast(`AI Install Guard: nothing was installed: ${safeLine(changed)}. Run /plugin ${safeLine(held.args)} again to scan it`)
361
+ return
362
+ }
363
+ $.ui.toast(`AI Install Guard: approved; installing ${held.label}`)
364
+ try {
365
+ await $.command.run({ command: 'plugin', args: held.args })
366
+ } catch {
367
+ $.ui.toast(`AI Install Guard: approved, but the install did not start. Run /plugin ${safeLine(held.args)} again; it goes through now`)
368
+ } finally {
369
+ // A pass the gate did not use up is not left for a later command.
370
+ await takePass($, { origin: 'command', args: held.args }).catch(() => undefined)
371
+ }
372
+ }
373
+
250
374
  function truncate(text: string, room: number): string {
251
375
  if (room <= 1) return ''
252
376
  return text.length <= room ? text : `${text.slice(0, Math.max(0, room - 1))}…`
@@ -0,0 +1,34 @@
1
+ // Installs the person allowed once although nothing could be pinned: Could
2
+ // not verify, then Install anyway, confirmed. Each pass lets that exact
3
+ // command through one time in this session and is then used up.
4
+
5
+ import { atom, update } from 'claude-code'
6
+ import type { EngineInterface } from 'claude-code'
7
+
8
+ import type { GuardPass } from '../../types'
9
+
10
+ const passesAtom = atom({ plugin: 'ai-install-guard', key: 'passes' } as const, [] as GuardPass[])
11
+
12
+ /** Passes kept at once; an old one unused falls off. */
13
+ const MAX_PASSES = 20
14
+
15
+ function samePass(a: GuardPass, b: GuardPass): boolean {
16
+ return a.origin === b.origin && a.args === b.args
17
+ }
18
+
19
+ export async function grantPass($: EngineInterface, pass: GuardPass): Promise<void> {
20
+ await update($, passesAtom, current => [...current.filter(item => !samePass(item, pass)), pass].slice(-MAX_PASSES))
21
+ }
22
+
23
+ /**
24
+ * Uses up the pass for this command, if there is one. Finding and removing it
25
+ * is one write, so two commands at once cannot both use the same pass.
26
+ */
27
+ export async function takePass($: EngineInterface, pass: GuardPass): Promise<boolean> {
28
+ let taken = false
29
+ await update($, passesAtom, latest => {
30
+ taken = latest.some(item => samePass(item, pass))
31
+ return taken ? latest.filter(item => !samePass(item, pass)) : latest
32
+ })
33
+ return taken
34
+ }