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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +10 -2
- package/CHANGELOG.md +50 -0
- package/README.md +103 -24
- package/assets/reviewed-by-ai-install-guard-light@1x.png +0 -0
- package/hooks/gate/gate.ts +116 -0
- package/hooks/gate/recheck.ts +33 -0
- package/hooks/gate/requests.ts +728 -0
- package/hooks/gate/resolve.ts +210 -0
- package/hooks/guard/badge.ts +35 -0
- package/hooks/guard/decisions.ts +44 -3
- package/hooks/guard/pane.tsx +200 -29
- package/hooks/guard/passes.ts +34 -0
- package/hooks/guard/run.ts +158 -0
- package/hooks/guard/view.ts +24 -1
- package/hooks/mod-host.ts +1 -0
- package/hooks/register.tsx +117 -75
- package/hooks/scan/checks/code.ts +110 -8
- package/hooks/scan/checks/mcp.ts +271 -0
- package/hooks/scan/checks/mod.ts +120 -27
- package/hooks/scan/checks/patterns.ts +4 -3
- package/hooks/scan/checks/programs.ts +150 -0
- package/hooks/scan/checks/prompts.ts +5 -2
- package/hooks/scan/checks/provenance.ts +190 -0
- package/hooks/scan/checks/structure.ts +92 -0
- package/hooks/scan/checks/types.ts +18 -2
- package/hooks/scan/checks/validator.ts +15 -1
- package/hooks/scan/github.ts +219 -0
- package/hooks/scan/host.ts +13 -0
- package/hooks/scan/lookups.ts +370 -0
- package/hooks/scan/remote.ts +427 -0
- package/hooks/scan/report.ts +12 -3
- package/hooks/scan/scan.ts +94 -8
- package/hooks/scan/verdict.ts +9 -3
- package/package.json +8 -3
package/hooks/guard/pane.tsx
CHANGED
|
@@ -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 {
|
|
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 = [
|
|
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
|
|
47
|
-
if (current ===
|
|
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
|
-
//
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
92
|
-
const
|
|
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
|
|
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.
|
|
151
|
-
<Text wrap="wrap"
|
|
152
|
-
{
|
|
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)}`}
|
|
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
|
+
}
|
package/hooks/guard/view.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|