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.
@@ -1,15 +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
- import { recordDecision, saveLastView } from './decisions'
8
+ import { badgeFor } from './badge'
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'
8
13
  import { needsConfirmation, PANE_ID, primaryLabel } from './view'
9
14
 
10
15
  const viewAtom = atom({ plugin: 'ai-install-guard', key: 'view' } as const, null)
11
16
 
12
- export const REPORT_TEXT = 'last-report-text'
13
17
 
14
18
  const ICON: Record<GuardStatus, string> = { fail: '✖', warn: '⚠', pass: '✔', info: '·' }
15
19
  const COLOR: Record<GuardStatus, string> = { fail: 'error', warn: 'warning', pass: 'success', info: 'subtle' }
@@ -31,7 +35,11 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
31
35
  )
32
36
  }
33
37
 
34
- const header = [view.kind === undefined ? undefined : `A ${view.kind}`, view.treeHash === undefined ? undefined : `tree ${view.treeHash.slice(7, 19)}`]
38
+ const header = [
39
+ view.kind === undefined ? undefined : `A ${view.kind}`,
40
+ view.sha === undefined ? undefined : `commit ${view.sha.slice(0, 12)}`,
41
+ view.treeHash === undefined ? undefined : `tree ${view.treeHash.slice(7, 19)}`,
42
+ ]
35
43
  .filter(part => part !== undefined)
36
44
  .join(' · ')
37
45
 
@@ -41,15 +49,71 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
41
49
  update($, viewAtom, current =>
42
50
  current === null ? current : { ...current, expanded: current.expanded.includes(id) ? current.expanded.filter(item => item !== id) : [...current.expanded, id] },
43
51
  )
52
+ const codeOpen = (row: GuardRow): boolean => (view.codeOpen ?? []).includes(row.id)
53
+ const toggleCode = (id: string) => () =>
54
+ update($, viewAtom, current => {
55
+ if (current === null) return current
56
+ const open = current.codeOpen ?? []
57
+ return { ...current, codeOpen: open.includes(id) ? open.filter(item => item !== id) : [...open, id] }
58
+ })
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'
44
94
 
45
95
  const decide = (decision: 'approved' | 'rejected', overridden: boolean) => async () => {
46
- const current = await read($, viewAtom)
47
- 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
48
99
  // A scan that never read the files has no tree hash to pin a decision to:
49
- // 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.
50
102
  if (current.treeHash === undefined) {
51
- $.ui.toast('AI Install Guard: closed; the scan read nothing, so there is no decision to remember')
52
- 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)
53
117
  return
54
118
  }
55
119
  const decidedAt = Date.now()
@@ -57,6 +121,7 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
57
121
  await recordDecision($, {
58
122
  target: current.target,
59
123
  root: current.root,
124
+ sha: current.sha,
60
125
  treeHash: current.treeHash,
61
126
  verdict: current.verdict.recommendation,
62
127
  verdictKind: current.verdict.kind,
@@ -67,29 +132,53 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
67
132
  } catch {
68
133
  // Nothing was saved: say so, and leave the pane open to try again.
69
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' : ''}`)
70
143
  return
71
144
  }
72
- const decided = { ...current, decision, decidedAt, confirming: false }
73
- await update($, viewAtom, () => decided)
74
145
  // The reopened report shows the outcome, not the buttons again.
75
- await saveLastView($, decided).catch(() => undefined)
76
- $.ui.toast(`AI Install Guard: ${decision === 'approved' ? 'approved' : 'rejected'} ${current.target}`)
77
- 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)
78
155
  }
79
156
 
80
157
  const primary = async () => {
81
158
  const current = await read($, viewAtom)
82
- if (current === null) return
159
+ if (current === null || current.scanId !== view.scanId) return
83
160
  if (needsConfirmation(current)) {
84
- 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 }))
85
162
  return
86
163
  }
87
164
  await decide('approved', false)()
88
165
  }
89
166
 
167
+ const copyBadge = async () => {
168
+ const made = badgeFor(view)
169
+ if ('refusal' in made) {
170
+ $.ui.toast(`AI Install Guard: ${made.refusal}`)
171
+ return
172
+ }
173
+ const result = await $.ui.copy({ text: made.markdown, surface: e.surface })
174
+ $.ui.toast(result.isCopied ? 'AI Install Guard: badge copied; paste it near the top of your README' : 'AI Install Guard: could not copy the badge here')
175
+ }
176
+ const offersBadge = view.treeHash !== undefined && (view.verdict?.kind === 'safe' || view.verdict?.kind === 'caution')
177
+
90
178
  const copy = async () => {
91
- const text = await $.store.get(REPORT_TEXT)
92
- 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 })
93
182
  $.ui.toast(result.isCopied ? 'AI Install Guard: report copied' : 'AI Install Guard: could not copy the report here')
94
183
  }
95
184
 
@@ -105,6 +194,12 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
105
194
  </Text>
106
195
  )}
107
196
 
197
+ {view.request !== undefined && (
198
+ <Text dimColor wrap="truncate-end">
199
+ {requestLine(view.request)}
200
+ </Text>
201
+ )}
202
+
108
203
  {verdict === undefined ? (
109
204
  <Text color="suggestion" wrap="truncate-end">
110
205
  Scanning: {view.stage}…
@@ -138,7 +233,11 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
138
233
  <Text bold>{group.title}</Text>
139
234
  {rowsOf(group.id).map(row => {
140
235
  const first = row.evidence[0]
141
- const summary = first === undefined ? '' : `: ${locate(first.file, first.line)}${first.excerpt}`
236
+ const headline = row.summary?.[0]
237
+ const summary = headline !== undefined ? `: ${headline}` : first === undefined ? '' : `: ${locate(first.file, first.line)}${first.excerpt}`
238
+ const places = row.evidence.length + row.more
239
+ // With a summary, the code lines are one step further in.
240
+ const showCode = row.summary === undefined || codeOpen(row)
142
241
  return (
143
242
  <Box key={`row-${row.id}`} flexDirection="column">
144
243
  <Button key={`toggle-${row.id}`} plain onPress={toggle(row.id)}>
@@ -147,18 +246,31 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
147
246
  </Button>
148
247
  {isOpen(row) && (
149
248
  <Box key={`detail-${row.id}`} flexDirection="column" paddingLeft={2}>
150
- {row.evidence.map(item => (
151
- <Text wrap="wrap" color={item.status === undefined ? undefined : COLOR[item.status]}>
152
- {locate(item.file, item.line)}
153
- {item.excerpt}
249
+ {row.summary?.map((line, index) => (
250
+ <Text wrap="wrap" bold={index === 0}>
251
+ {line}
154
252
  </Text>
155
253
  ))}
156
- {row.more > 0 && <Text dimColor>… and {row.more} more in the copied report</Text>}
157
254
  {(row.status === 'fail' || row.status === 'warn') && (
158
255
  <Text dimColor wrap="wrap">
159
256
  {row.advice}
160
257
  </Text>
161
258
  )}
259
+ {row.summary !== undefined && places > 0 && (
260
+ <Button key={`code-${row.id}`} plain onPress={toggleCode(row.id)}>
261
+ <Text color="suggestion">
262
+ {codeOpen(row) ? 'Hide the code' : `Show the code (${places} place${places === 1 ? '' : 's'})`}
263
+ </Text>
264
+ </Button>
265
+ )}
266
+ {showCode &&
267
+ row.evidence.map(item => (
268
+ <Text wrap="wrap" color={item.status === undefined ? undefined : COLOR[item.status]}>
269
+ {locate(item.file, item.line)}
270
+ {item.excerpt}
271
+ </Text>
272
+ ))}
273
+ {showCode && row.more > 0 && <Text dimColor>… and {row.more} more in the copied report</Text>}
162
274
  </Box>
163
275
  )}
164
276
  </Box>
@@ -167,18 +279,19 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
167
279
  </Box>
168
280
  ))}
169
281
 
170
- {verdict !== undefined && view.decision === undefined && !view.confirming && (
282
+ {verdict !== undefined && view.decision === undefined && !view.confirming && view.deciding !== true && (
171
283
  <Box key="actions" flexDirection="row" gap={1} marginTop={1}>
172
- {view.treeHash !== undefined && (
284
+ {(view.treeHash !== undefined || (view.action === 'install' && view.request !== undefined)) && (
173
285
  <Button key="install" label={primaryLabel(view)} hotkey="i" variant={needsConfirmation(view) ? 'secondary' : 'primary'} onPress={primary} />
174
286
  )}
175
287
  <Button key="reject" label="Reject" hotkey="r" variant={needsConfirmation(view) ? 'primary' : 'secondary'} onPress={decide('rejected', false)} />
176
288
  <Button key="details" label={view.details ? 'Fewer details' : 'Details'} hotkey="d" onPress={() => update($, viewAtom, current => (current === null ? current : { ...current, details: !current.details }))} />
177
289
  <Button key="copy" label="Copy report" hotkey="c" onPress={copy} />
290
+ {offersBadge && <Button key="badge" label="Copy badge" hotkey="b" onPress={copyBadge} />}
178
291
  </Box>
179
292
  )}
180
293
 
181
- {verdict !== undefined && view.confirming && (
294
+ {verdict !== undefined && view.confirming && view.deciding !== true && (
182
295
  <Box key="confirm-row" flexDirection="column" marginTop={1}>
183
296
  <Text color="warning" wrap="wrap">
184
297
  The verdict is {verdict.recommendation}. {primaryLabel(view)}?
@@ -192,14 +305,72 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
192
305
 
193
306
  {view.decision !== undefined && (
194
307
  <Text color={view.decision === 'approved' ? 'success' : 'error'}>
195
- {view.decision === 'approved' ? 'Approved' : 'Rejected'}
196
- {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.'}
197
311
  </Text>
198
312
  )}
313
+ {view.decision !== undefined && offersBadge && (
314
+ <Box key="after-decision" flexDirection="row" gap={1} marginTop={1}>
315
+ <Button key="badge" label="Copy badge" hotkey="b" onPress={copyBadge} />
316
+ </Box>
317
+ )}
199
318
  </Box>
200
319
  )
201
320
  }
202
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
+
203
374
  function truncate(text: string, room: number): string {
204
375
  if (room <= 1) return ''
205
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
+ }
@@ -0,0 +1,158 @@
1
+ // A scan shown in the pane: progress while it runs, then the verdict, the
2
+ // stored report Copy report copies and the view /guard report reopens.
3
+
4
+ import { atom, read, update } from 'claude-code'
5
+ import type { EngineInterface } from 'claude-code'
6
+
7
+ import type { GuardAction, GuardRequest, GuardView } from '../../types'
8
+ import { modHost } from '../mod-host'
9
+ import type { RemoteSpec } from '../scan/remote'
10
+ import { formatReport } from '../scan/report'
11
+ import { safeLine } from '../scan/safe-text'
12
+ import { scanFolder, scanRemote, type ScanReport } from '../scan/scan'
13
+ import { decisionFor, saveLastView, saveReport } from './decisions'
14
+ import { PANE_ID, scanningView, viewOf } from './view'
15
+
16
+ const viewAtom = atom({ plugin: 'ai-install-guard', key: 'view' } as const, null)
17
+
18
+ export type ScanHow = { remote: RemoteSpec } | { folder: string }
19
+
20
+ export type ShowOptions = {
21
+ action: GuardAction
22
+ /** The install the pane's Install button carries out, for a held install. */
23
+ request?: GuardRequest
24
+ lookups: boolean
25
+ }
26
+
27
+ export type Shown =
28
+ | { kind: 'done'; report: ScanReport; view: GuardView; text: string; placed: boolean; superseded: boolean }
29
+ | { kind: 'failed'; reason: string; view: GuardView; placed: boolean; superseded: boolean }
30
+
31
+ let scans = 0
32
+
33
+ /**
34
+ * A name for one scan, unique in this session, that sorts after every scan
35
+ * started before it: the time, then a count for scans in the same moment.
36
+ */
37
+ function newScanId(): string {
38
+ scans += 1
39
+ return `${Date.now().toString(36).padStart(10, '0')}-${scans.toString(36).padStart(6, '0')}`
40
+ }
41
+
42
+ /** Opens the pane on a scan of `how`, and leaves the verdict in it. */
43
+ export async function showScan($: EngineInterface, target: string, how: ScanHow | (() => Promise<ScanHow>), options: ShowOptions): Promise<Shown> {
44
+ // Named before anything waits, so a scan asked for later is always the newer one.
45
+ const scanId = newScanId()
46
+ const host = modHost($)
47
+ const opened = await openPane($)
48
+ const scanning = { ...withRequest(scanningView(target, options.action), options.request), scanId }
49
+ if (!(await takePane($, scanning))) {
50
+ return { kind: 'failed', reason: `Could not verify ${safeLine(target)}: a newer scan has the pane`, view: scanning, placed: opened, superseded: true }
51
+ }
52
+ $.ui.status(`AI Install Guard: scanning ${safeLine(target)}`)
53
+ try {
54
+ const scanOptions = {
55
+ lookups: options.lookups,
56
+ // Stage names can carry a candidate's own words (a plugin's name).
57
+ onStage: (stage: string) => update($, viewAtom, current => (current === null || current.scanId !== scanId ? current : { ...current, stage: safeLine(stage) })).then(() => undefined),
58
+ }
59
+ const resolved = typeof how === 'function' ? await how() : how
60
+ const report = 'remote' in resolved ? await scanRemote(host, target, resolved.remote, scanOptions) : await scanFolder(host, target, resolved.folder, scanOptions)
61
+ // A tree you already decided on shows that decision, not the buttons
62
+ // again, unless it now judges worse than when you approved it.
63
+ const earlier = report.inventory === undefined ? undefined : await decisionFor($, { treeHash: report.inventory.treeHash, sha: report.source?.sha }, report.verdict.kind).catch(() => undefined)
64
+ const base = { ...withRequest(viewOf(report, options.action), options.request), scanId }
65
+ const view = earlier === undefined ? base : { ...base, decision: earlier.decision, decidedAt: earlier.decidedAt }
66
+ const text = formatReport(report, { details: true })
67
+ // A scan started since owns the pane now: this one leaves it, its report
68
+ // and the reopened report alone, so no press lands on the wrong scan.
69
+ if (!(await owns($, scanId))) return { kind: 'done', report, view, text, placed: opened, superseded: true }
70
+ // The report is stored under this scan's name before the pane shows the
71
+ // verdict, so Copy report copies it, and never another scan's.
72
+ await saveReport($, scanId, text)
73
+ if (!(await showOwn($, view))) return { kind: 'done', report, view, text, placed: opened, superseded: true }
74
+ $.ui.toast(`AI Install Guard: ${report.verdict.recommendation} for ${safeLine(target)}`)
75
+ return { kind: 'done', report, view, text, placed: opened, superseded: false }
76
+ } catch (error) {
77
+ const reason = `Could not verify ${safeLine(target)}: ${safeLine(error instanceof Error ? error.message : String(error))}`
78
+ const failed = await showFailure($, target, reason, options, scanId)
79
+ return { kind: 'failed', reason, view: failed.view, placed: opened, superseded: !failed.shown }
80
+ } finally {
81
+ $.ui.status(undefined)
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Opens the pane on an install the guard could not scan: Could not verify,
87
+ * with the reason, and Install anyway behind a second confirmation.
88
+ * `superseded` says a newer scan has the pane instead.
89
+ */
90
+ export async function showUnverified($: EngineInterface, target: string, reason: string, options: ShowOptions): Promise<{ view: GuardView; placed: boolean; superseded: boolean }> {
91
+ const scanId = newScanId()
92
+ const placed = await openPane($)
93
+ const scanning = { ...withRequest(scanningView(target, options.action), options.request), scanId }
94
+ if (!(await takePane($, scanning))) return { view: scanning, placed, superseded: true }
95
+ const failed = await showFailure($, target, `Could not verify ${safeLine(target)}: ${safeLine(reason)}`, options, scanId)
96
+ return { view: failed.view, placed, superseded: !failed.shown }
97
+ }
98
+
99
+ /**
100
+ * The pane, and the report /guard report reopens, show a failure: never a
101
+ * scanning pane, and never an older verdict in its place. Only while this
102
+ * scan still owns the pane; `shown` says whether it did.
103
+ */
104
+ async function showFailure($: EngineInterface, target: string, reason: string, options: ShowOptions, scanId: string): Promise<{ view: GuardView; shown: boolean }> {
105
+ const failed: GuardView = {
106
+ ...withRequest(scanningView(target, options.action), options.request),
107
+ scanId,
108
+ stage: '',
109
+ verdict: { kind: 'unverified', recommendation: 'Could not verify', reasons: [reason], counts: { pass: 0, warn: 0, fail: 0, info: 0 } },
110
+ }
111
+ if (!(await owns($, scanId).catch(() => false))) return { view: failed, shown: false }
112
+ await saveReport($, scanId, reason)
113
+ return { view: failed, shown: await showOwn($, failed).catch(() => false) }
114
+ }
115
+
116
+ /** Whether the pane still shows this scan. */
117
+ async function owns($: EngineInterface, scanId: string): Promise<boolean> {
118
+ return (await read($, viewAtom))?.scanId === scanId
119
+ }
120
+
121
+ /** Takes the pane for a scan just started, unless a newer scan already has it. */
122
+ async function takePane($: EngineInterface, view: GuardView): Promise<boolean> {
123
+ let taken = false
124
+ await update($, viewAtom, current => {
125
+ taken = current === null || current.scanId === undefined || view.scanId === undefined || current.scanId <= view.scanId
126
+ return taken ? view : current
127
+ })
128
+ return taken
129
+ }
130
+
131
+ /**
132
+ * Shows a scan's finished view while the pane still shows that scan, and
133
+ * saves it for /guard report. Should a newer scan take the pane while it is
134
+ * saved, the newer view is saved instead, so the reopened report is always
135
+ * the pane's.
136
+ */
137
+ async function showOwn($: EngineInterface, view: GuardView): Promise<boolean> {
138
+ let shown = false
139
+ await update($, viewAtom, current => {
140
+ shown = current?.scanId === view.scanId
141
+ return shown ? view : current
142
+ })
143
+ if (!shown) return false
144
+ await saveLastView($, view).catch(() => undefined)
145
+ const now = await read($, viewAtom).catch(() => null)
146
+ if (now !== null && now.scanId !== view.scanId) await saveLastView($, now).catch(() => undefined)
147
+ return true
148
+ }
149
+
150
+ /** Opens the pane; false where no surface can show it (a headless run). */
151
+ async function openPane($: EngineInterface): Promise<boolean> {
152
+ const opened = await $.ui.open({ id: PANE_ID, title: 'AI Install Guard', focus: true }).catch(() => ({ isPlaced: false }))
153
+ return opened.isPlaced
154
+ }
155
+
156
+ function withRequest(view: GuardView, request: GuardRequest | undefined): GuardView {
157
+ return request === undefined ? view : { ...view, request }
158
+ }
@@ -32,7 +32,15 @@ export function viewOf(report: ScanReport, action: GuardAction): GuardView {
32
32
  },
33
33
  rows: report.results.map(rowOf),
34
34
  kind: report.kind,
35
- root: report.inventory === undefined ? undefined : safeLine(report.inventory.root),
35
+ // A remote scan's folder is a temporary one: its source names it instead.
36
+ root:
37
+ report.source !== undefined
38
+ ? sourceRoot(report.source.url, report.source.subdir)
39
+ : report.inventory === undefined
40
+ ? undefined
41
+ : safeLine(report.inventory.root),
42
+ sha: report.source?.sha,
43
+ ...(report.source?.subdir === undefined ? {} : { subdir: encodeFolder(report.source.subdir) }),
36
44
  treeHash: report.inventory?.treeHash,
37
45
  expanded: [],
38
46
  details: false,
@@ -40,6 +48,11 @@ export function viewOf(report: ScanReport, action: GuardAction): GuardView {
40
48
  }
41
49
  }
42
50
 
51
+ /** How a decision names a remote source: its address, and the folder in it after `#`. */
52
+ export function sourceRoot(url: string, subdir: string | undefined): string {
53
+ return safeLine(`${url}${subdir === undefined ? '' : `#${subdir}`}`)
54
+ }
55
+
43
56
  function rowOf(result: CheckResult): GuardRow {
44
57
  return {
45
58
  id: result.id,
@@ -54,6 +67,7 @@ function rowOf(result: CheckResult): GuardRow {
54
67
  })),
55
68
  more: Math.max(0, result.evidence.length - EVIDENCE_PER_ROW),
56
69
  advice: result.advice,
70
+ ...(result.summary === undefined || result.summary.length === 0 ? {} : { summary: result.summary.slice(0, 8).map(line => clip(safeLine(redactSecrets(line)))) }),
57
71
  }
58
72
  }
59
73
 
@@ -67,3 +81,12 @@ export function primaryLabel(view: GuardView): string {
67
81
  const verb = view.action === 'install' ? 'Install' : 'Approve'
68
82
  return needsConfirmation(view) ? `${verb} anyway` : verb
69
83
  }
84
+
85
+ /**
86
+ * A folder name as the pane and a badge show it: whole characters other than
87
+ * letters, digits and `_ . / -` percent-encoded, so the exact folder stays
88
+ * named and nothing in it can be read as markup or hide itself.
89
+ */
90
+ export function encodeFolder(folder: string): string {
91
+ return folder.replace(/[^\w./-]/gu, char => [...new TextEncoder().encode(char)].map(byte => `%${byte.toString(16).toUpperCase().padStart(2, '0')}`).join(''))
92
+ }
package/hooks/mod-host.ts CHANGED
@@ -27,5 +27,6 @@ export function modHost($: EngineInterface): Host {
27
27
  throw new Error(`could not read the link ${path}`)
28
28
  },
29
29
  run: (argv, options) => $.process.run(argv, options),
30
+ fetch: (url, init) => $.http.fetch(url, init),
30
31
  }
31
32
  }