ai-install-guard 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +11 -2
- package/CHANGELOG.md +47 -1
- package/README.md +96 -9
- package/assets/reviewed-by-ai-install-guard-light@1x.png +0 -0
- package/hooks/guard/badge.ts +35 -0
- package/hooks/guard/decisions.ts +100 -0
- package/hooks/guard/pane.tsx +253 -0
- package/hooks/guard/view.ts +87 -0
- package/hooks/mod-host.ts +1 -0
- package/hooks/register.tsx +171 -21
- package/hooks/scan/checks/code.ts +638 -0
- package/hooks/scan/checks/context.ts +655 -0
- package/hooks/scan/checks/hooks.ts +156 -0
- package/hooks/scan/checks/magic.ts +40 -0
- package/hooks/scan/checks/mcp.ts +271 -0
- package/hooks/scan/checks/mod.ts +374 -0
- package/hooks/scan/checks/patterns.ts +107 -0
- package/hooks/scan/checks/programs.ts +150 -0
- package/hooks/scan/checks/prompts.ts +319 -0
- package/hooks/scan/checks/provenance.ts +190 -0
- package/hooks/scan/checks/quality.ts +56 -0
- package/hooks/scan/checks/secrets.ts +107 -0
- package/hooks/scan/checks/structure.ts +449 -0
- package/hooks/scan/checks/text.ts +362 -0
- package/hooks/scan/checks/types.ts +105 -0
- package/hooks/scan/checks/validator.ts +111 -0
- 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 +406 -0
- package/hooks/scan/report.ts +62 -31
- package/hooks/scan/scan.ts +360 -0
- package/hooks/scan/verdict.ts +64 -0
- package/package.json +8 -3
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { atom, read, update } from 'claude-code'
|
|
2
|
+
import type { EngineInterface, RenderElement, RenderInput } from 'claude-code'
|
|
3
|
+
|
|
4
|
+
import type { GuardRow, GuardStatus, GuardView } from '../../types'
|
|
5
|
+
import { GROUPS } from '../scan/checks/types'
|
|
6
|
+
import { locate } from '../scan/verdict'
|
|
7
|
+
import { badgeFor } from './badge'
|
|
8
|
+
import { recordDecision, saveLastView } from './decisions'
|
|
9
|
+
import { needsConfirmation, PANE_ID, primaryLabel } from './view'
|
|
10
|
+
|
|
11
|
+
const viewAtom = atom({ plugin: 'ai-install-guard', key: 'view' } as const, null)
|
|
12
|
+
|
|
13
|
+
export const REPORT_TEXT = 'last-report-text'
|
|
14
|
+
|
|
15
|
+
const ICON: Record<GuardStatus, string> = { fail: '✖', warn: '⚠', pass: '✔', info: '·' }
|
|
16
|
+
const COLOR: Record<GuardStatus, string> = { fail: 'error', warn: 'warning', pass: 'success', info: 'subtle' }
|
|
17
|
+
const VERDICT_COLOR = { safe: 'success', caution: 'warning', reject: 'error', unverified: 'warning' } as const
|
|
18
|
+
|
|
19
|
+
type PaneInput = RenderInput<'Pane'>
|
|
20
|
+
|
|
21
|
+
/** The pane's tree for the scan in state, or a note when there is none. */
|
|
22
|
+
export async function drawPane($: EngineInterface, e: PaneInput): Promise<RenderElement> {
|
|
23
|
+
const { Box, Text, Button } = $.ui.resolve(e)
|
|
24
|
+
const view = await read($, viewAtom)
|
|
25
|
+
const width = Math.max(20, e.props.bodyColumns)
|
|
26
|
+
|
|
27
|
+
if (view === null) {
|
|
28
|
+
return (
|
|
29
|
+
<Box flexDirection="column" width={width}>
|
|
30
|
+
<Text dimColor>No scan yet. Run /guard scan <path>.</Text>
|
|
31
|
+
</Box>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const header = [
|
|
36
|
+
view.kind === undefined ? undefined : `A ${view.kind}`,
|
|
37
|
+
view.sha === undefined ? undefined : `commit ${view.sha.slice(0, 12)}`,
|
|
38
|
+
view.treeHash === undefined ? undefined : `tree ${view.treeHash.slice(7, 19)}`,
|
|
39
|
+
]
|
|
40
|
+
.filter(part => part !== undefined)
|
|
41
|
+
.join(' · ')
|
|
42
|
+
|
|
43
|
+
const rowsOf = (group: string): GuardRow[] => view.rows.filter(row => row.group === group)
|
|
44
|
+
const isOpen = (row: GuardRow): boolean => view.details || view.expanded.includes(row.id)
|
|
45
|
+
const toggle = (id: string) => () =>
|
|
46
|
+
update($, viewAtom, current =>
|
|
47
|
+
current === null ? current : { ...current, expanded: current.expanded.includes(id) ? current.expanded.filter(item => item !== id) : [...current.expanded, id] },
|
|
48
|
+
)
|
|
49
|
+
const codeOpen = (row: GuardRow): boolean => (view.codeOpen ?? []).includes(row.id)
|
|
50
|
+
const toggleCode = (id: string) => () =>
|
|
51
|
+
update($, viewAtom, current => {
|
|
52
|
+
if (current === null) return current
|
|
53
|
+
const open = current.codeOpen ?? []
|
|
54
|
+
return { ...current, codeOpen: open.includes(id) ? open.filter(item => item !== id) : [...open, id] }
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const decide = (decision: 'approved' | 'rejected', overridden: boolean) => async () => {
|
|
58
|
+
const current = await read($, viewAtom)
|
|
59
|
+
if (current === null || current.verdict === undefined) return
|
|
60
|
+
// 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.
|
|
62
|
+
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 })
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
const decidedAt = Date.now()
|
|
68
|
+
try {
|
|
69
|
+
await recordDecision($, {
|
|
70
|
+
target: current.target,
|
|
71
|
+
root: current.root,
|
|
72
|
+
sha: current.sha,
|
|
73
|
+
treeHash: current.treeHash,
|
|
74
|
+
verdict: current.verdict.recommendation,
|
|
75
|
+
verdictKind: current.verdict.kind,
|
|
76
|
+
decision,
|
|
77
|
+
overridden,
|
|
78
|
+
decidedAt,
|
|
79
|
+
})
|
|
80
|
+
} catch {
|
|
81
|
+
// Nothing was saved: say so, and leave the pane open to try again.
|
|
82
|
+
$.ui.toast('AI Install Guard: could not save the decision; nothing was recorded')
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
const decided = { ...current, decision, decidedAt, confirming: false }
|
|
86
|
+
await update($, viewAtom, () => decided)
|
|
87
|
+
// 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 })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const primary = async () => {
|
|
94
|
+
const current = await read($, viewAtom)
|
|
95
|
+
if (current === null) return
|
|
96
|
+
if (needsConfirmation(current)) {
|
|
97
|
+
await update($, viewAtom, latest => (latest === null ? latest : { ...latest, confirming: true }))
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
await decide('approved', false)()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const copyBadge = async () => {
|
|
104
|
+
const made = badgeFor(view)
|
|
105
|
+
if ('refusal' in made) {
|
|
106
|
+
$.ui.toast(`AI Install Guard: ${made.refusal}`)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
const result = await $.ui.copy({ text: made.markdown, surface: e.surface })
|
|
110
|
+
$.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')
|
|
111
|
+
}
|
|
112
|
+
const offersBadge = view.treeHash !== undefined && (view.verdict?.kind === 'safe' || view.verdict?.kind === 'caution')
|
|
113
|
+
|
|
114
|
+
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 })
|
|
117
|
+
$.ui.toast(result.isCopied ? 'AI Install Guard: report copied' : 'AI Install Guard: could not copy the report here')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const verdict = view.verdict
|
|
121
|
+
return (
|
|
122
|
+
<Box flexDirection="column" width={width}>
|
|
123
|
+
<Text bold wrap="truncate-end">
|
|
124
|
+
AI Install Guard · {view.target}
|
|
125
|
+
</Text>
|
|
126
|
+
{header !== '' && (
|
|
127
|
+
<Text dimColor wrap="truncate-end">
|
|
128
|
+
{header}
|
|
129
|
+
</Text>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{verdict === undefined ? (
|
|
133
|
+
<Text color="suggestion" wrap="truncate-end">
|
|
134
|
+
Scanning: {view.stage}…
|
|
135
|
+
</Text>
|
|
136
|
+
) : (
|
|
137
|
+
<Box key="verdict" flexDirection="column" borderStyle="round" borderColor={VERDICT_COLOR[verdict.kind]} paddingX={1} marginTop={1}>
|
|
138
|
+
<Text bold color={VERDICT_COLOR[verdict.kind]}>
|
|
139
|
+
{verdict.recommendation}
|
|
140
|
+
</Text>
|
|
141
|
+
{verdict.reasons.map(reason => (
|
|
142
|
+
<Text wrap="truncate-end">
|
|
143
|
+
{reason}
|
|
144
|
+
</Text>
|
|
145
|
+
))}
|
|
146
|
+
</Box>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{verdict !== undefined && (
|
|
150
|
+
<Text>
|
|
151
|
+
{verdict.counts.pass} passed · {verdict.counts.warn} warning{verdict.counts.warn === 1 ? '' : 's'} · {verdict.counts.fail} failed
|
|
152
|
+
</Text>
|
|
153
|
+
)}
|
|
154
|
+
{verdict !== undefined && view.rows.length > 0 && (
|
|
155
|
+
<Text dimColor wrap="truncate-end">
|
|
156
|
+
Quoted text comes from the scanned files and is untrusted.
|
|
157
|
+
</Text>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
{GROUPS.filter(group => rowsOf(group.id).length > 0).map(group => (
|
|
161
|
+
<Box key={`group-${group.id}`} flexDirection="column" marginTop={1}>
|
|
162
|
+
<Text bold>{group.title}</Text>
|
|
163
|
+
{rowsOf(group.id).map(row => {
|
|
164
|
+
const first = row.evidence[0]
|
|
165
|
+
const headline = row.summary?.[0]
|
|
166
|
+
const summary = headline !== undefined ? `: ${headline}` : first === undefined ? '' : `: ${locate(first.file, first.line)}${first.excerpt}`
|
|
167
|
+
const places = row.evidence.length + row.more
|
|
168
|
+
// With a summary, the code lines are one step further in.
|
|
169
|
+
const showCode = row.summary === undefined || codeOpen(row)
|
|
170
|
+
return (
|
|
171
|
+
<Box key={`row-${row.id}`} flexDirection="column">
|
|
172
|
+
<Button key={`toggle-${row.id}`} plain onPress={toggle(row.id)}>
|
|
173
|
+
<Text color={COLOR[row.status]}>{ICON[row.status]}</Text> {row.title}
|
|
174
|
+
<Text dimColor>{truncate(summary, width - row.title.length - 4)}</Text>
|
|
175
|
+
</Button>
|
|
176
|
+
{isOpen(row) && (
|
|
177
|
+
<Box key={`detail-${row.id}`} flexDirection="column" paddingLeft={2}>
|
|
178
|
+
{row.summary?.map((line, index) => (
|
|
179
|
+
<Text wrap="wrap" bold={index === 0}>
|
|
180
|
+
{line}
|
|
181
|
+
</Text>
|
|
182
|
+
))}
|
|
183
|
+
{(row.status === 'fail' || row.status === 'warn') && (
|
|
184
|
+
<Text dimColor wrap="wrap">
|
|
185
|
+
{row.advice}
|
|
186
|
+
</Text>
|
|
187
|
+
)}
|
|
188
|
+
{row.summary !== undefined && places > 0 && (
|
|
189
|
+
<Button key={`code-${row.id}`} plain onPress={toggleCode(row.id)}>
|
|
190
|
+
<Text color="suggestion">
|
|
191
|
+
{codeOpen(row) ? 'Hide the code' : `Show the code (${places} place${places === 1 ? '' : 's'})`}
|
|
192
|
+
</Text>
|
|
193
|
+
</Button>
|
|
194
|
+
)}
|
|
195
|
+
{showCode &&
|
|
196
|
+
row.evidence.map(item => (
|
|
197
|
+
<Text wrap="wrap" color={item.status === undefined ? undefined : COLOR[item.status]}>
|
|
198
|
+
{locate(item.file, item.line)}
|
|
199
|
+
{item.excerpt}
|
|
200
|
+
</Text>
|
|
201
|
+
))}
|
|
202
|
+
{showCode && row.more > 0 && <Text dimColor>… and {row.more} more in the copied report</Text>}
|
|
203
|
+
</Box>
|
|
204
|
+
)}
|
|
205
|
+
</Box>
|
|
206
|
+
)
|
|
207
|
+
})}
|
|
208
|
+
</Box>
|
|
209
|
+
))}
|
|
210
|
+
|
|
211
|
+
{verdict !== undefined && view.decision === undefined && !view.confirming && (
|
|
212
|
+
<Box key="actions" flexDirection="row" gap={1} marginTop={1}>
|
|
213
|
+
{view.treeHash !== undefined && (
|
|
214
|
+
<Button key="install" label={primaryLabel(view)} hotkey="i" variant={needsConfirmation(view) ? 'secondary' : 'primary'} onPress={primary} />
|
|
215
|
+
)}
|
|
216
|
+
<Button key="reject" label="Reject" hotkey="r" variant={needsConfirmation(view) ? 'primary' : 'secondary'} onPress={decide('rejected', false)} />
|
|
217
|
+
<Button key="details" label={view.details ? 'Fewer details' : 'Details'} hotkey="d" onPress={() => update($, viewAtom, current => (current === null ? current : { ...current, details: !current.details }))} />
|
|
218
|
+
<Button key="copy" label="Copy report" hotkey="c" onPress={copy} />
|
|
219
|
+
{offersBadge && <Button key="badge" label="Copy badge" hotkey="b" onPress={copyBadge} />}
|
|
220
|
+
</Box>
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
{verdict !== undefined && view.confirming && (
|
|
224
|
+
<Box key="confirm-row" flexDirection="column" marginTop={1}>
|
|
225
|
+
<Text color="warning" wrap="wrap">
|
|
226
|
+
The verdict is {verdict.recommendation}. {primaryLabel(view)}?
|
|
227
|
+
</Text>
|
|
228
|
+
<Box flexDirection="row" gap={1}>
|
|
229
|
+
<Button key="confirm" label={`Yes, ${primaryLabel(view).toLowerCase()}`} hotkey="y" onPress={decide('approved', true)} />
|
|
230
|
+
<Button key="cancel" label="Cancel" hotkey="n" variant="primary" onPress={() => update($, viewAtom, current => (current === null ? current : { ...current, confirming: false }))} />
|
|
231
|
+
</Box>
|
|
232
|
+
</Box>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{view.decision !== undefined && (
|
|
236
|
+
<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.
|
|
239
|
+
</Text>
|
|
240
|
+
)}
|
|
241
|
+
{view.decision !== undefined && offersBadge && (
|
|
242
|
+
<Box key="after-decision" flexDirection="row" gap={1} marginTop={1}>
|
|
243
|
+
<Button key="badge" label="Copy badge" hotkey="b" onPress={copyBadge} />
|
|
244
|
+
</Box>
|
|
245
|
+
)}
|
|
246
|
+
</Box>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function truncate(text: string, room: number): string {
|
|
251
|
+
if (room <= 1) return ''
|
|
252
|
+
return text.length <= room ? text : `${text.slice(0, Math.max(0, room - 1))}…`
|
|
253
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { GuardAction, GuardRow, GuardView } from '../../types'
|
|
2
|
+
import { redactSecrets } from '../scan/checks/patterns'
|
|
3
|
+
import type { CheckResult } from '../scan/checks/types'
|
|
4
|
+
import { safeLine } from '../scan/safe-text'
|
|
5
|
+
import type { ScanReport } from '../scan/scan'
|
|
6
|
+
|
|
7
|
+
/** Evidence the pane keeps per row; the full list stays in the stored report. */
|
|
8
|
+
const EVIDENCE_PER_ROW = 20
|
|
9
|
+
/** Characters the pane keeps per excerpt or reason: one huge hook command cannot fill the store. */
|
|
10
|
+
const MAX_TEXT = 500
|
|
11
|
+
|
|
12
|
+
function clip(text: string): string {
|
|
13
|
+
return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT - 1)}…` : text
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const PANE_ID = 'ai-install-guard'
|
|
17
|
+
|
|
18
|
+
export function scanningView(target: string, action: GuardAction): GuardView {
|
|
19
|
+
return { target: safeLine(target), action, stage: 'Starting', rows: [], expanded: [], details: false, confirming: false }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The pane's view of a finished scan: compact, every quoted text made safe. */
|
|
23
|
+
export function viewOf(report: ScanReport, action: GuardAction): GuardView {
|
|
24
|
+
return {
|
|
25
|
+
// The person's own words, but drawn beside an Approve button: made safe too.
|
|
26
|
+
target: safeLine(report.target),
|
|
27
|
+
action,
|
|
28
|
+
stage: '',
|
|
29
|
+
verdict: {
|
|
30
|
+
...report.verdict,
|
|
31
|
+
reasons: report.verdict.reasons.map(reason => clip(safeLine(redactSecrets(reason)))),
|
|
32
|
+
},
|
|
33
|
+
rows: report.results.map(rowOf),
|
|
34
|
+
kind: report.kind,
|
|
35
|
+
// A remote scan's folder is a temporary one: its source names it instead.
|
|
36
|
+
root:
|
|
37
|
+
report.source !== undefined
|
|
38
|
+
? safeLine(`${report.source.url}${report.source.subdir === undefined ? '' : `#${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) }),
|
|
44
|
+
treeHash: report.inventory?.treeHash,
|
|
45
|
+
expanded: [],
|
|
46
|
+
details: false,
|
|
47
|
+
confirming: false,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function rowOf(result: CheckResult): GuardRow {
|
|
52
|
+
return {
|
|
53
|
+
id: result.id,
|
|
54
|
+
group: result.group,
|
|
55
|
+
title: result.title,
|
|
56
|
+
status: result.status,
|
|
57
|
+
evidence: result.evidence.slice(0, EVIDENCE_PER_ROW).map(item => ({
|
|
58
|
+
file: clip(safeLine(redactSecrets(item.file))),
|
|
59
|
+
line: item.line,
|
|
60
|
+
excerpt: clip(safeLine(redactSecrets(item.excerpt))),
|
|
61
|
+
status: item.status,
|
|
62
|
+
})),
|
|
63
|
+
more: Math.max(0, result.evidence.length - EVIDENCE_PER_ROW),
|
|
64
|
+
advice: result.advice,
|
|
65
|
+
...(result.summary === undefined || result.summary.length === 0 ? {} : { summary: result.summary.slice(0, 8).map(line => clip(safeLine(redactSecrets(line)))) }),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether the primary button needs a second, explicit confirmation: the verdict is Do not install or Could not verify. */
|
|
70
|
+
export function needsConfirmation(view: GuardView): boolean {
|
|
71
|
+
return view.verdict === undefined || view.verdict.kind === 'reject' || view.verdict.kind === 'unverified'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The primary button's label for this verdict. */
|
|
75
|
+
export function primaryLabel(view: GuardView): string {
|
|
76
|
+
const verb = view.action === 'install' ? 'Install' : 'Approve'
|
|
77
|
+
return needsConfirmation(view) ? `${verb} anyway` : verb
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A folder name as the pane and a badge show it: whole characters other than
|
|
82
|
+
* letters, digits and `_ . / -` percent-encoded, so the exact folder stays
|
|
83
|
+
* named and nothing in it can be read as markup or hide itself.
|
|
84
|
+
*/
|
|
85
|
+
export function encodeFolder(folder: string): string {
|
|
86
|
+
return folder.replace(/[^\w./-]/gu, char => [...new TextEncoder().encode(char)].map(byte => `%${byte.toString(16).toUpperCase().padStart(2, '0')}`).join(''))
|
|
87
|
+
}
|
package/hooks/mod-host.ts
CHANGED
package/hooks/register.tsx
CHANGED
|
@@ -1,49 +1,199 @@
|
|
|
1
|
-
import type { Register } from 'claude-code'
|
|
1
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
2
|
+
import { atom, update } from 'claude-code'
|
|
2
3
|
|
|
4
|
+
import { badgeFor } from './guard/badge'
|
|
5
|
+
import { decisionFor, forgetDecisions, loadDecisions, loadLastView, MAX_REPORT_CHARS, saveLastView } from './guard/decisions'
|
|
6
|
+
import { drawPane, REPORT_TEXT } from './guard/pane'
|
|
7
|
+
import { PANE_ID, scanningView, viewOf } from './guard/view'
|
|
3
8
|
import { modHost } from './mod-host'
|
|
4
9
|
import type { Host } from './scan/host'
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
10
|
+
import { looksRemote, parseRemote, type RemoteSpec } from './scan/remote'
|
|
11
|
+
import { formatReport } from './scan/report'
|
|
7
12
|
import { safeLine } from './scan/safe-text'
|
|
13
|
+
import { scanFolder, scanRemote } from './scan/scan'
|
|
14
|
+
|
|
15
|
+
// Claude Code reads every state reference from the file that uses it, so each
|
|
16
|
+
// module declares its own atom for the pane's view.
|
|
17
|
+
const viewAtom = atom({ plugin: 'ai-install-guard', key: 'view' } as const, null)
|
|
8
18
|
|
|
9
19
|
const USAGE = [
|
|
10
20
|
'Usage:',
|
|
11
|
-
' /guard scan <path>
|
|
21
|
+
' /guard scan <path> Scan a local plugin, mod or skill folder without installing it',
|
|
22
|
+
' /guard scan <repository> Fetch one commit of a repository and scan it: https://host/owner/repo,',
|
|
23
|
+
' github:owner/repo or owner/repo, with an optional @ref',
|
|
24
|
+
' --ref <branch|tag|commit> The commit to scan (the default branch otherwise)',
|
|
25
|
+
' --path <folder> A folder inside the repository',
|
|
26
|
+
' --details The full report as text',
|
|
27
|
+
' /guard report Reopen the last report',
|
|
28
|
+
' /guard badge A README badge for the last scan (Safe to install or Review what it can do)',
|
|
29
|
+
' /guard list Your approved and rejected versions',
|
|
30
|
+
' /guard forget <name> Forget decisions about a name, so it is judged again',
|
|
12
31
|
].join('\n')
|
|
13
32
|
|
|
14
|
-
export const register: Register = on => {
|
|
33
|
+
export const register: Register = (on, options) => {
|
|
34
|
+
// Off unless you turn it on: the lookups send package and plugin names out.
|
|
35
|
+
const lookups = options.outsideLookups === true
|
|
36
|
+
|
|
15
37
|
on('session.start', async ($, e, next) => {
|
|
16
38
|
await $.command.register({
|
|
17
39
|
name: 'guard',
|
|
18
40
|
description: 'Scan a plugin, mod, skill or MCP server without installing it',
|
|
19
|
-
argumentHint: 'scan <path>',
|
|
41
|
+
argumentHint: 'scan <path | repository> | report | badge | list | forget <name>',
|
|
20
42
|
})
|
|
21
43
|
return next(e)
|
|
22
44
|
})
|
|
23
45
|
|
|
24
46
|
on('command.run', { command: 'guard' }, async ($, e) => {
|
|
25
|
-
const { sub, rest } = splitArgs(e.args)
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return { text: `Could not verify ${safeLine(rest)}: ${safeLine(reasonOf(error))}` }
|
|
47
|
+
const { sub, rest, details, ref, path, error } = splitArgs(e.args)
|
|
48
|
+
if (error !== undefined) return { text: `AI Install Guard: ${error}\n\n${USAGE}` }
|
|
49
|
+
if (sub === 'scan' && rest !== '') return { text: await scan($, rest, details, { ref, path }, lookups) }
|
|
50
|
+
if (sub === 'report') return { text: await reopen($) }
|
|
51
|
+
if (sub === 'badge') return { text: await badge($) }
|
|
52
|
+
if (sub === 'list') return { text: await list($) }
|
|
53
|
+
if (sub === 'forget' && rest !== '') {
|
|
54
|
+
const count = (await forgetDecisions($, rest)).length
|
|
55
|
+
return { text: `AI Install Guard: forgot ${count} decision${count === 1 ? '' : 's'} about ${safeLine(rest)}.` }
|
|
35
56
|
}
|
|
57
|
+
return { text: USAGE }
|
|
36
58
|
}).catch(() => ({ text: 'AI Install Guard: the command failed before it could answer.' }))
|
|
59
|
+
|
|
60
|
+
on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e) => drawPane($, e))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Scans a folder with the pane showing progress and then the verdict. The
|
|
65
|
+
* command's own answer, which the model also reads, is one line: the quoted
|
|
66
|
+
* findings stay in the pane. Where no pane can show (a headless run), or with
|
|
67
|
+
* --details, the answer is the full text report.
|
|
68
|
+
*/
|
|
69
|
+
async function scan($: EngineInterface, target: string, details: boolean, flags: { ref?: string; path?: string }, lookups: boolean): Promise<string> {
|
|
70
|
+
const host = modHost($)
|
|
71
|
+
const opened = await $.ui.open({ id: PANE_ID, title: 'AI Install Guard', focus: true }).catch(() => ({ isPlaced: false }))
|
|
72
|
+
// Until this scan's report is stored, Copy report has nothing to copy, never
|
|
73
|
+
// the previous scan's report.
|
|
74
|
+
await $.store.delete(REPORT_TEXT).catch(() => undefined)
|
|
75
|
+
await update($, viewAtom, () => scanningView(target, 'approve'))
|
|
76
|
+
$.ui.status(`AI Install Guard: scanning ${safeLine(target)}`)
|
|
77
|
+
try {
|
|
78
|
+
const options = {
|
|
79
|
+
lookups,
|
|
80
|
+
// Stage names can carry a candidate's own words (a plugin's name).
|
|
81
|
+
onStage: (stage: string) => update($, viewAtom, current => (current === null ? current : { ...current, stage: safeLine(stage) })).then(() => undefined),
|
|
82
|
+
}
|
|
83
|
+
const spec = await remoteSpecFor(host, target, flags)
|
|
84
|
+
const report = spec === undefined ? await scanFolder(host, target, await expandHome(host, target), options) : await scanRemote(host, target, spec, options)
|
|
85
|
+
// A tree you already decided on shows that decision, not the buttons
|
|
86
|
+
// again, unless it now judges worse than when you approved it.
|
|
87
|
+
const earlier = report.inventory === undefined ? undefined : await decisionFor($, { treeHash: report.inventory.treeHash, sha: report.source?.sha }, report.verdict.kind).catch(() => undefined)
|
|
88
|
+
const view = earlier === undefined ? viewOf(report, 'approve') : { ...viewOf(report, 'approve'), decision: earlier.decision, decidedAt: earlier.decidedAt }
|
|
89
|
+
const text = formatReport(report, { details: true })
|
|
90
|
+
// The report text is stored before the pane shows the verdict, so Copy
|
|
91
|
+
// report always copies this scan's report. It is held well inside the
|
|
92
|
+
// store's limit; a failed write leaves Copy with nothing, not old text.
|
|
93
|
+
const stored = text.length > MAX_REPORT_CHARS ? `${text.slice(0, MAX_REPORT_CHARS)}\n… the report was cut here to fit the store` : text
|
|
94
|
+
await $.store.set(REPORT_TEXT, stored).catch(() => undefined)
|
|
95
|
+
await update($, viewAtom, () => view)
|
|
96
|
+
await saveLastView($, view).catch(() => undefined)
|
|
97
|
+
$.ui.toast(`AI Install Guard: ${report.verdict.recommendation} for ${safeLine(target)}`)
|
|
98
|
+
// Where no pane shows, the answer is the whole report: findings and advice.
|
|
99
|
+
if (!opened.isPlaced || details) return text
|
|
100
|
+
return `AI Install Guard: ${report.verdict.recommendation} for ${safeLine(target)}. The report is in the pane; /guard report reopens it.`
|
|
101
|
+
} catch (error) {
|
|
102
|
+
const reason = `Could not verify ${safeLine(target)}: ${safeLine(reasonOf(error))}`
|
|
103
|
+
await $.store.set(REPORT_TEXT, reason).catch(() => undefined)
|
|
104
|
+
// The pane, and the report /guard report reopens, show this failure: never
|
|
105
|
+
// a scanning pane, and never an older verdict in its place.
|
|
106
|
+
const failed = { ...scanningView(target, 'approve'), stage: '', verdict: { kind: 'unverified' as const, recommendation: 'Could not verify', reasons: [reason], counts: { pass: 0, warn: 0, fail: 0, info: 0 } } }
|
|
107
|
+
await update($, viewAtom, () => failed).catch(() => undefined)
|
|
108
|
+
await saveLastView($, failed).catch(() => undefined)
|
|
109
|
+
return reason
|
|
110
|
+
} finally {
|
|
111
|
+
$.ui.status(undefined)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function reopen($: EngineInterface): Promise<string> {
|
|
116
|
+
const view = await loadLastView($)
|
|
117
|
+
if (view === undefined) return 'AI Install Guard: no report yet. Run /guard scan <path>.'
|
|
118
|
+
await update($, viewAtom, () => ({ ...view, confirming: false }))
|
|
119
|
+
const opened = await $.ui.open({ id: PANE_ID, title: 'AI Install Guard', focus: true }).catch(() => ({ isPlaced: false }))
|
|
120
|
+
if (opened.isPlaced) return `AI Install Guard: reopened the report for ${safeLine(view.target)}.`
|
|
121
|
+
const text = await $.store.get(REPORT_TEXT)
|
|
122
|
+
return typeof text === 'string' ? text : 'AI Install Guard: the last report is no longer stored.'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function badge($: EngineInterface): Promise<string> {
|
|
126
|
+
const view = await loadLastView($)
|
|
127
|
+
const made = badgeFor(view)
|
|
128
|
+
if ('refusal' in made) return `AI Install Guard: ${made.refusal}`
|
|
129
|
+
return [
|
|
130
|
+
`AI Install Guard: a README badge for ${view?.target ?? 'the last scan'} at ${made.pin}. Paste it near the top of your README:`,
|
|
131
|
+
'',
|
|
132
|
+
made.markdown,
|
|
133
|
+
'',
|
|
134
|
+
'It names the exact version that was scanned. A change to your code needs a new scan and a new badge; the guard checks a badge against its own scan and never trusts it.',
|
|
135
|
+
].join('\n')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function list($: EngineInterface): Promise<string> {
|
|
139
|
+
const decisions = await loadDecisions($)
|
|
140
|
+
if (decisions.length === 0) return 'AI Install Guard: no decisions yet.'
|
|
141
|
+
const lines = decisions.map(item => {
|
|
142
|
+
const when = new Date(item.decidedAt).toISOString().slice(0, 10)
|
|
143
|
+
const how = item.decision === 'approved' ? (item.overridden ? 'approved against the verdict' : 'approved') : 'rejected'
|
|
144
|
+
return ` ${when} ${how.padEnd(28)} ${safeLine(item.target)} (${item.verdict}, tree ${item.treeHash.slice(7, 19)})`
|
|
145
|
+
})
|
|
146
|
+
return ['AI Install Guard: your decisions, newest first', ...lines].join('\n')
|
|
37
147
|
}
|
|
38
148
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
149
|
+
/**
|
|
150
|
+
* `scan --details --ref v1 "my folder"` → `{ sub: 'scan', details: true, ref: 'v1', rest: 'my folder' }`.
|
|
151
|
+
* Options count only as their own tokens right after the subcommand, so a
|
|
152
|
+
* quoted path that contains one is left whole.
|
|
153
|
+
*/
|
|
154
|
+
export function splitArgs(args: string): { sub: string; rest: string; details: boolean; ref?: string; path?: string; error?: string } {
|
|
41
155
|
const trimmed = args.trim()
|
|
42
156
|
const space = trimmed.search(/\s/)
|
|
43
157
|
const sub = space === -1 ? trimmed : trimmed.slice(0, space)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
158
|
+
let rest = space === -1 ? '' : trimmed.slice(space).trim()
|
|
159
|
+
let details = false
|
|
160
|
+
let ref: string | undefined
|
|
161
|
+
let path: string | undefined
|
|
162
|
+
for (;;) {
|
|
163
|
+
const option = /^(--details|--ref|--path)(?:\s+|$)/.exec(rest)
|
|
164
|
+
if (option === null) break
|
|
165
|
+
rest = rest.slice(option[0].length)
|
|
166
|
+
if (option[1] === '--details') {
|
|
167
|
+
details = true
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
const value = /^("[^"]*"|'[^']*'|\S+)\s*/.exec(rest)
|
|
171
|
+
if (value === null || (value[1] as string).startsWith('--')) return { sub: sub.toLowerCase(), rest: '', details, error: `${option[1]} needs a value` }
|
|
172
|
+
rest = rest.slice(value[0].length)
|
|
173
|
+
if (option[1] === '--ref') ref = unquote(value[1] as string)
|
|
174
|
+
else path = unquote(value[1] as string)
|
|
175
|
+
}
|
|
176
|
+
return { sub: sub.toLowerCase(), rest: unquote(rest), details, ref, path }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The remote source `target` names, or undefined for a local folder. A bare
|
|
181
|
+
* owner/repo is a folder when one exists by that name and no --ref or --path
|
|
182
|
+
* says otherwise.
|
|
183
|
+
*/
|
|
184
|
+
async function remoteSpecFor(host: Host, target: string, flags: { ref?: string; path?: string }): Promise<RemoteSpec | undefined> {
|
|
185
|
+
const shape = looksRemote(target)
|
|
186
|
+
const asked = flags.ref !== undefined || flags.path !== undefined
|
|
187
|
+
if (shape === 'local') {
|
|
188
|
+
if (asked) throw new Error('--ref and --path apply to a repository, not to a folder')
|
|
189
|
+
return undefined
|
|
190
|
+
}
|
|
191
|
+
if (shape === 'maybe' && !asked && (await host.stat(target).catch(() => undefined)) !== undefined) return undefined
|
|
192
|
+
return parseRemote(target, { ref: flags.ref, subdir: flags.path })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function unquote(text: string): string {
|
|
196
|
+
return /^(["']).*\1$/.test(text) ? text.slice(1, -1) : text
|
|
47
197
|
}
|
|
48
198
|
|
|
49
199
|
async function expandHome(host: Host, path: string): Promise<string> {
|