ai-install-guard 0.2.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 +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +36 -18
- 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/decisions.ts +32 -0
- package/hooks/guard/pane.tsx +146 -22
- package/hooks/guard/passes.ts +34 -0
- package/hooks/guard/run.ts +158 -0
- package/hooks/guard/view.ts +6 -1
- package/hooks/register.tsx +49 -66
- package/hooks/scan/checks/mod.ts +9 -0
- package/hooks/scan/remote.ts +24 -3
- package/package.json +1 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Where an install would come from: the repository, folder and commit Claude
|
|
2
|
+
// Code would install, read from its own plugin listing and marketplace
|
|
3
|
+
// catalogs. Only the `claude` command and file reads are used; nothing from
|
|
4
|
+
// the candidate runs.
|
|
5
|
+
|
|
6
|
+
import type { Host } from '../scan/host'
|
|
7
|
+
import { looksRemote, parseRemote, type RemoteSpec } from '../scan/remote'
|
|
8
|
+
import { safeLine } from '../scan/safe-text'
|
|
9
|
+
import type { InstallRequest } from './requests'
|
|
10
|
+
|
|
11
|
+
export type Target =
|
|
12
|
+
/** One commit of a repository, and the folder in it. */
|
|
13
|
+
| { kind: 'remote'; spec: RemoteSpec }
|
|
14
|
+
/** A folder on this machine: a marketplace's own copy of a plugin, or a marketplace added from a folder. */
|
|
15
|
+
| { kind: 'local'; path: string }
|
|
16
|
+
/** A marketplace name: Claude Code shows its menu to pick a plugin from. */
|
|
17
|
+
| { kind: 'browse' }
|
|
18
|
+
| { kind: 'unverifiable'; reason: string }
|
|
19
|
+
|
|
20
|
+
/** The most a marketplace catalog may weigh before the guard stops reading it. */
|
|
21
|
+
const MAX_CATALOG_BYTES = 4 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
type Listed = { pluginId?: unknown; name?: unknown; marketplaceName?: unknown; source?: unknown }
|
|
24
|
+
type Marketplace = { name: string; installLocation?: string }
|
|
25
|
+
|
|
26
|
+
/** The target an install request names. A lookup that fails says why, as an unverifiable target. */
|
|
27
|
+
export async function targetOf(host: Host, request: InstallRequest): Promise<Target> {
|
|
28
|
+
try {
|
|
29
|
+
return request.kind === 'marketplace' ? await marketplaceTarget(host, request.source) : await pluginTarget(host, request)
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return { kind: 'unverifiable', reason: error instanceof Error ? error.message : String(error) }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function pluginTarget(host: Host, request: Extract<InstallRequest, { kind: 'plugin' }>): Promise<Target> {
|
|
36
|
+
const listing = await claudeJson(host, ['plugin', 'list', '--available', '--json'], 'the plugin list')
|
|
37
|
+
const available: Listed[] = isRecord(listing) && Array.isArray(listing.available) ? (listing.available as Listed[]) : []
|
|
38
|
+
const installed = isRecord(listing) && Array.isArray(listing.installed) ? (listing.installed as { id?: unknown }[]) : []
|
|
39
|
+
const id = request.marketplace === undefined ? request.plugin : `${request.plugin}@${request.marketplace}`
|
|
40
|
+
const matches = available.filter(item => item.name === request.plugin && (request.marketplace === undefined || item.marketplaceName === request.marketplace))
|
|
41
|
+
// Where marketplaces live is read only when it is needed: for a relative source or a catalog file.
|
|
42
|
+
let listed: Promise<Marketplace[]> | undefined
|
|
43
|
+
const marketplaces = (): Promise<Marketplace[]> => (listed ??= marketplaceList(host))
|
|
44
|
+
|
|
45
|
+
if (matches.length === 0) {
|
|
46
|
+
// An installed plugin is not in the available list: its catalog says where an update comes from.
|
|
47
|
+
// A marketplace named is that marketplace's plugin and no other's.
|
|
48
|
+
const mine = installed.map(item => (typeof item.id === 'string' ? item.id : '')).filter(item => (request.marketplace === undefined ? item.startsWith(`${request.plugin}@`) : item === id))
|
|
49
|
+
if (mine.length > 1 && request.marketplace === undefined) return ambiguous(request.plugin, mine.map(item => item.split('@')[1] ?? ''))
|
|
50
|
+
const owner = mine[0]?.split('@')[1] ?? request.marketplace
|
|
51
|
+
const market = (await marketplaces()).find(item => item.name === owner)
|
|
52
|
+
if (market !== undefined) {
|
|
53
|
+
const entry = await catalogEntry(host, market, request.plugin)
|
|
54
|
+
if (entry !== undefined) return sourceTarget(host, entry, async () => market)
|
|
55
|
+
}
|
|
56
|
+
// `/plugin install <marketplace>` opens that marketplace's menu.
|
|
57
|
+
if (request.verb === 'install' && request.marketplace === undefined && (await marketplaces()).some(item => item.name === request.plugin)) return { kind: 'browse' }
|
|
58
|
+
return { kind: 'unverifiable', reason: `${safeLine(id)} is not in the marketplaces you added, so there was nothing to scan` }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The same plugin can be listed by two marketplaces. One source between them is one target.
|
|
62
|
+
const distinct = new Map(matches.map(item => [sourceKey(item), item]))
|
|
63
|
+
if (distinct.size > 1) return ambiguous(request.plugin, matches.map(item => String(item.marketplaceName)))
|
|
64
|
+
const entry = [...distinct.values()][0] as Listed
|
|
65
|
+
return sourceTarget(host, entry, () => marketplaces().then(list => list.find(item => item.name === entry.marketplaceName)))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ambiguous(plugin: string, marketplaces: readonly string[]): Target {
|
|
69
|
+
const names = [...new Set(marketplaces)].map(safeLine)
|
|
70
|
+
return {
|
|
71
|
+
kind: 'unverifiable',
|
|
72
|
+
reason: `${safeLine(plugin)} is in more than one marketplace (${names.join(', ')}); name one, as in ${safeLine(plugin)}@${names[0] ?? '<marketplace>'}`,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A source's identity: a relative source belongs to its marketplace, so two
|
|
78
|
+
* marketplaces' `./x` are never the same; an object source is its fields in
|
|
79
|
+
* name order, however a listing orders them.
|
|
80
|
+
*/
|
|
81
|
+
function sourceKey(item: Listed): string {
|
|
82
|
+
if (typeof item.source === 'string') return `${String(item.marketplaceName)}:${item.source}`
|
|
83
|
+
return JSON.stringify(canonical(item.source ?? null))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function canonical(value: unknown): unknown {
|
|
87
|
+
if (Array.isArray(value)) return value.map(canonical)
|
|
88
|
+
if (!isRecord(value)) return value
|
|
89
|
+
return Object.keys(value)
|
|
90
|
+
.sort()
|
|
91
|
+
.map(key => [key, canonical(value[key])])
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Where a catalog entry's `source` says the plugin comes from; `marketOf` finds its marketplace when a relative source needs it. */
|
|
95
|
+
async function sourceTarget(host: Host, entry: Listed, marketOf: () => Promise<Marketplace | undefined>): Promise<Target> {
|
|
96
|
+
const source = entry.source
|
|
97
|
+
if (typeof source === 'string') {
|
|
98
|
+
if (!source.startsWith('./')) return { kind: 'unverifiable', reason: `its source "${safeLine(source)}" is not a folder inside its marketplace` }
|
|
99
|
+
const market = await marketOf()
|
|
100
|
+
if (market?.installLocation === undefined) return { kind: 'unverifiable', reason: 'its marketplace has no copy on this machine to read it from' }
|
|
101
|
+
return { kind: 'local', path: await insideFolder(host, market.installLocation, source) }
|
|
102
|
+
}
|
|
103
|
+
if (!isRecord(source) || typeof source.source !== 'string') return { kind: 'unverifiable', reason: 'its catalog entry names no source' }
|
|
104
|
+
const ref = text(source.sha) ?? text(source.ref)
|
|
105
|
+
switch (source.source) {
|
|
106
|
+
case 'url':
|
|
107
|
+
return remote(text(source.url), ref)
|
|
108
|
+
case 'git-subdir':
|
|
109
|
+
// Without its folder the entry would name the whole repository, which is not what installs.
|
|
110
|
+
if (text(source.path) === undefined) return { kind: 'unverifiable', reason: 'its catalog entry names no folder inside its repository' }
|
|
111
|
+
return remote(text(source.url), ref, text(source.path))
|
|
112
|
+
case 'github':
|
|
113
|
+
return remote(text(source.repo) === undefined ? undefined : `https://github.com/${text(source.repo)}.git`, ref)
|
|
114
|
+
default:
|
|
115
|
+
return { kind: 'unverifiable', reason: `it installs from a ${safeLine(source.source)} source, which the guard cannot read yet` }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function remote(url: string | undefined, ref: string | undefined, subdir?: string): Target {
|
|
120
|
+
if (url === undefined) return { kind: 'unverifiable', reason: 'its catalog entry names no repository' }
|
|
121
|
+
return { kind: 'remote', spec: parseRemote(url, { ref, subdir }) }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** What `marketplace add <source>` would add: a repository, or a folder. */
|
|
125
|
+
async function marketplaceTarget(host: Host, source: string): Promise<Target> {
|
|
126
|
+
const shape = looksRemote(source)
|
|
127
|
+
if (shape === 'local' || (shape === 'maybe' && (await host.stat(source).catch(() => undefined)) !== undefined)) {
|
|
128
|
+
const path = await expandHome(host, source)
|
|
129
|
+
const stat = await host.stat(path).catch(() => undefined)
|
|
130
|
+
if (stat === undefined) return { kind: 'unverifiable', reason: `there is no folder at ${safeLine(source)}` }
|
|
131
|
+
if (stat.kind !== 'dir') return { kind: 'unverifiable', reason: `${safeLine(source)} is a file; the guard scans the folder or repository a marketplace lives in` }
|
|
132
|
+
return { kind: 'local', path }
|
|
133
|
+
}
|
|
134
|
+
const spec = parseRemote(source)
|
|
135
|
+
if (/\.json$/i.test(spec.url)) return { kind: 'unverifiable', reason: 'it adds a catalog file from a web address; the guard scans the repository a marketplace lives in' }
|
|
136
|
+
return { kind: 'remote', spec }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function marketplaceList(host: Host): Promise<Marketplace[]> {
|
|
140
|
+
const listed = await claudeJson(host, ['plugin', 'marketplace', 'list', '--json'], 'the marketplace list')
|
|
141
|
+
if (!Array.isArray(listed)) return []
|
|
142
|
+
return listed.flatMap(item => (isRecord(item) && typeof item.name === 'string' ? [{ name: item.name, installLocation: text(item.installLocation) }] : []))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A plugin's entry in a marketplace's own catalog file. */
|
|
146
|
+
async function catalogEntry(host: Host, market: Marketplace, plugin: string): Promise<Listed | undefined> {
|
|
147
|
+
if (market.installLocation === undefined) return undefined
|
|
148
|
+
const path = await insideFolder(host, market.installLocation, './.claude-plugin/marketplace.json')
|
|
149
|
+
const stat = await host.stat(path)
|
|
150
|
+
if (stat.kind !== 'file' || stat.size > MAX_CATALOG_BYTES) throw new Error(`the catalog of ${safeLine(market.name)} is not a file the guard can read`)
|
|
151
|
+
let parsed: unknown
|
|
152
|
+
try {
|
|
153
|
+
parsed = JSON.parse(new TextDecoder().decode(await host.readBytes(path)))
|
|
154
|
+
} catch {
|
|
155
|
+
throw new Error(`the catalog of ${safeLine(market.name)} is not valid JSON`)
|
|
156
|
+
}
|
|
157
|
+
const plugins = isRecord(parsed) && Array.isArray(parsed.plugins) ? parsed.plugins : []
|
|
158
|
+
const found = plugins.find(item => isRecord(item) && item.name === plugin)
|
|
159
|
+
return isRecord(found) ? { name: plugin, marketplaceName: market.name, source: found.source } : undefined
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `relative` inside `folder`: no `..` above it, and no symbolic link on the
|
|
164
|
+
* way, which could lead the scan (and the install) out to other files.
|
|
165
|
+
*/
|
|
166
|
+
async function insideFolder(host: Host, folder: string, relative: string): Promise<string> {
|
|
167
|
+
const parts = relative.split(/[\\/]+/).filter(part => part !== '' && part !== '.')
|
|
168
|
+
if (parts.includes('..')) throw new Error(`"${safeLine(relative)}" leads outside its marketplace`)
|
|
169
|
+
let path = folder.replace(/[\\/]+$/, '')
|
|
170
|
+
for (const part of parts) {
|
|
171
|
+
path = `${path}/${part}`
|
|
172
|
+
const stat = await host.stat(path, { resolve: false }).catch(() => undefined)
|
|
173
|
+
if (stat === undefined) throw new Error(`its marketplace has no ${safeLine(relative)}`)
|
|
174
|
+
if (stat.isLink) throw new Error(`"${safeLine(relative)}" passes through a symbolic link, which could lead outside its marketplace`)
|
|
175
|
+
}
|
|
176
|
+
return path
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Runs `claude <args>` and reads its JSON answer. */
|
|
180
|
+
async function claudeJson(host: Host, args: readonly string[], what: string): Promise<unknown> {
|
|
181
|
+
const result = await host.run(['claude', ...args], { timeoutMs: 60_000 }).catch((error: unknown) => {
|
|
182
|
+
throw new Error(`could not read ${what}: ${error instanceof Error ? safeLine(error.message) : 'the claude command did not run'}`)
|
|
183
|
+
})
|
|
184
|
+
if (result.exitCode !== 0) {
|
|
185
|
+
const line = result.stderr.split('\n').find(item => item.trim() !== '') ?? `claude exited with ${result.exitCode}`
|
|
186
|
+
throw new Error(`could not read ${what}: ${safeLine(line.trim().slice(0, 300))}`)
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
return JSON.parse(result.stdout)
|
|
190
|
+
} catch {
|
|
191
|
+
throw new Error(`could not read ${what}: its answer was not valid JSON`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** `~` and `~/…` as the full path of your home folder. */
|
|
196
|
+
export async function expandHome(host: Host, path: string): Promise<string> {
|
|
197
|
+
if (path !== '~' && !path.startsWith('~/')) return path
|
|
198
|
+
const { exitCode, stdout } = await host.run(['printenv', 'HOME'])
|
|
199
|
+
const home = stdout.trim()
|
|
200
|
+
if (exitCode !== 0 || home === '') throw new Error('could not find the home folder')
|
|
201
|
+
return home + path.slice(1)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
205
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function text(value: unknown): string | undefined {
|
|
209
|
+
return typeof value === 'string' && value !== '' ? value : undefined
|
|
210
|
+
}
|
package/hooks/guard/decisions.ts
CHANGED
|
@@ -22,6 +22,8 @@ export type Decision = {
|
|
|
22
22
|
const DECISIONS = 'decisions'
|
|
23
23
|
/** Room for the report text, well inside the store's 4 MiB in all. */
|
|
24
24
|
export const MAX_REPORT_CHARS = 1_000_000
|
|
25
|
+
/** The last report as text, under the name of the scan that wrote it. */
|
|
26
|
+
const REPORT_TEXT = 'last-report-text'
|
|
25
27
|
const LAST_VIEW = 'last-view'
|
|
26
28
|
/** The store is capped at 4 MiB in all: keep the newest decisions only. */
|
|
27
29
|
const MAX_DECISIONS = 500
|
|
@@ -81,6 +83,36 @@ export async function decisionFor($: EngineInterface, version: { treeHash: strin
|
|
|
81
83
|
return approvedUpTo !== undefined && SEVERITY[kind] <= approvedUpTo ? found : undefined
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The decision on one commit of one source, found before any scan: a commit
|
|
88
|
+
* names its files exactly, so the same commit of the same folder is the same
|
|
89
|
+
* version however it is asked for.
|
|
90
|
+
*/
|
|
91
|
+
export async function pinnedDecision($: EngineInterface, pin: { root: string; sha: string }): Promise<Decision | undefined> {
|
|
92
|
+
return (await loadDecisions($)).find(item => item.sha === pin.sha && item.root === pin.root)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Stores the text report of one scan, held well inside the store's limit. A
|
|
97
|
+
* failed write leaves no report, never another scan's.
|
|
98
|
+
*/
|
|
99
|
+
export async function saveReport($: EngineInterface, scanId: string, text: string): Promise<void> {
|
|
100
|
+
// A newer scan's report is never replaced by an older one's.
|
|
101
|
+
const stored = await $.store.get(REPORT_TEXT).catch(() => undefined)
|
|
102
|
+
const storedId = stored !== null && typeof stored === 'object' ? (stored as { scanId?: unknown }).scanId : undefined
|
|
103
|
+
if (typeof storedId === 'string' && storedId > scanId) return
|
|
104
|
+
const kept = text.length > MAX_REPORT_CHARS ? `${text.slice(0, MAX_REPORT_CHARS)}\n… the report was cut here to fit the store` : text
|
|
105
|
+
await $.store.set(REPORT_TEXT, { scanId, text: kept }).catch(() => $.store.delete(REPORT_TEXT).catch(() => undefined))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The text report of the scan `scanId` names, if it is still the one stored. */
|
|
109
|
+
export async function loadReport($: EngineInterface, scanId: string | undefined): Promise<string | undefined> {
|
|
110
|
+
const stored = await $.store.get(REPORT_TEXT)
|
|
111
|
+
if (stored === null || typeof stored !== 'object') return undefined
|
|
112
|
+
const { scanId: storedId, text } = stored as { scanId?: unknown; text?: unknown }
|
|
113
|
+
return storedId === scanId && typeof text === 'string' ? text : undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
84
116
|
/**
|
|
85
117
|
* Saves the view /guard report reopens. When it cannot be saved, the old one
|
|
86
118
|
* is removed, so a reopened report never shows an older verdict as current.
|
package/hooks/guard/pane.tsx
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { atom, read, update } from 'claude-code'
|
|
2
2
|
import type { EngineInterface, RenderElement, RenderInput } from 'claude-code'
|
|
3
3
|
|
|
4
|
-
import type { GuardRow, GuardStatus, GuardView } from '../../types'
|
|
4
|
+
import type { GuardRequest, GuardRow, GuardStatus, GuardView } from '../../types'
|
|
5
5
|
import { GROUPS } from '../scan/checks/types'
|
|
6
|
+
import { safeLine } from '../scan/safe-text'
|
|
6
7
|
import { locate } from '../scan/verdict'
|
|
7
8
|
import { badgeFor } from './badge'
|
|
8
|
-
import {
|
|
9
|
+
import { changedSinceScan } from '../gate/recheck'
|
|
10
|
+
import { modHost } from '../mod-host'
|
|
11
|
+
import { loadReport, recordDecision, saveLastView } from './decisions'
|
|
12
|
+
import { grantPass, takePass } from './passes'
|
|
9
13
|
import { needsConfirmation, PANE_ID, primaryLabel } from './view'
|
|
10
14
|
|
|
11
15
|
const viewAtom = atom({ plugin: 'ai-install-guard', key: 'view' } as const, null)
|
|
12
16
|
|
|
13
|
-
export const REPORT_TEXT = 'last-report-text'
|
|
14
17
|
|
|
15
18
|
const ICON: Record<GuardStatus, string> = { fail: '✖', warn: '⚠', pass: '✔', info: '·' }
|
|
16
19
|
const COLOR: Record<GuardStatus, string> = { fail: 'error', warn: 'warning', pass: 'success', info: 'subtle' }
|
|
@@ -54,14 +57,63 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
|
|
|
54
57
|
return { ...current, codeOpen: open.includes(id) ? open.filter(item => item !== id) : [...open, id] }
|
|
55
58
|
})
|
|
56
59
|
|
|
60
|
+
// A press counts only on the scan it was drawn from, and only once: the
|
|
61
|
+
// first press claims the decision, and a scan that took the pane since, or
|
|
62
|
+
// a second press, changes nothing.
|
|
63
|
+
const claim = async (): Promise<GuardView | undefined> => {
|
|
64
|
+
let claimed: GuardView | undefined
|
|
65
|
+
await update($, viewAtom, latest => {
|
|
66
|
+
claimed = undefined
|
|
67
|
+
if (latest === null || latest.scanId !== view.scanId || latest.verdict === undefined || latest.decision !== undefined || latest.deciding === true) return latest
|
|
68
|
+
claimed = { ...latest, deciding: true }
|
|
69
|
+
return claimed
|
|
70
|
+
})
|
|
71
|
+
return claimed
|
|
72
|
+
}
|
|
73
|
+
/** Puts `next` in the pane while it still shows this scan; false once another scan has taken it. */
|
|
74
|
+
const replaceOwn = async (next: GuardView): Promise<boolean> => {
|
|
75
|
+
let ours = false
|
|
76
|
+
await update($, viewAtom, latest => {
|
|
77
|
+
ours = latest !== null && latest.scanId === view.scanId
|
|
78
|
+
return ours ? next : latest
|
|
79
|
+
})
|
|
80
|
+
return ours
|
|
81
|
+
}
|
|
82
|
+
const release = (current: GuardView) => replaceOwn({ ...current, deciding: false })
|
|
83
|
+
/**
|
|
84
|
+
* Saves this scan's outcome for /guard report; should a newer scan take
|
|
85
|
+
* the pane meanwhile, its view is saved instead, so the reopened report is
|
|
86
|
+
* always the pane's.
|
|
87
|
+
*/
|
|
88
|
+
const saveOwn = async (outcome: GuardView): Promise<void> => {
|
|
89
|
+
await saveLastView($, outcome).catch(() => undefined)
|
|
90
|
+
const now = await read($, viewAtom).catch(() => null)
|
|
91
|
+
if (now !== null && now.scanId !== outcome.scanId) await saveLastView($, now).catch(() => undefined)
|
|
92
|
+
}
|
|
93
|
+
const movedOn = 'the pane has moved on to another scan'
|
|
94
|
+
|
|
57
95
|
const decide = (decision: 'approved' | 'rejected', overridden: boolean) => async () => {
|
|
58
|
-
const current = await
|
|
59
|
-
if (current ===
|
|
96
|
+
const current = await claim()
|
|
97
|
+
if (current === undefined || current.verdict === undefined) return
|
|
98
|
+
const held = current.action === 'install' ? current.request : undefined
|
|
60
99
|
// A scan that never read the files has no tree hash to pin a decision to:
|
|
61
|
-
//
|
|
100
|
+
// nothing is remembered, and nothing claims otherwise. A held install the
|
|
101
|
+
// person allows anyway goes through once, as that exact command.
|
|
62
102
|
if (current.treeHash === undefined) {
|
|
63
|
-
|
|
64
|
-
|
|
103
|
+
if (held !== undefined && decision === 'approved') {
|
|
104
|
+
// The pass is spent on this request: the reopened report shows it allowed, not the buttons again.
|
|
105
|
+
const allowed = { ...current, decision, decidedAt: Date.now(), confirming: false, deciding: false }
|
|
106
|
+
if (!(await replaceOwn(allowed))) {
|
|
107
|
+
$.ui.toast(`AI Install Guard: nothing was allowed: ${movedOn}`)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
await grantPass($, { origin: held.origin, args: held.args })
|
|
111
|
+
await saveOwn(allowed)
|
|
112
|
+
await carryOut($, held, allowed)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
$.ui.toast(held !== undefined ? 'AI Install Guard: rejected; nothing was installed' : 'AI Install Guard: closed; the scan read nothing, so there is no decision to remember')
|
|
116
|
+
if (await release(current)) await closeOwn($, current)
|
|
65
117
|
return
|
|
66
118
|
}
|
|
67
119
|
const decidedAt = Date.now()
|
|
@@ -80,21 +132,33 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
|
|
|
80
132
|
} catch {
|
|
81
133
|
// Nothing was saved: say so, and leave the pane open to try again.
|
|
82
134
|
$.ui.toast('AI Install Guard: could not save the decision; nothing was recorded')
|
|
135
|
+
await release(current)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
const decided = { ...current, decision, decidedAt, confirming: false, deciding: false }
|
|
139
|
+
// The decision is saved for its version either way; the pane, the
|
|
140
|
+
// reopened report and the install are this scan's only while it has the pane.
|
|
141
|
+
if (!(await replaceOwn(decided))) {
|
|
142
|
+
$.ui.toast(`AI Install Guard: ${decision} ${current.target}, but ${movedOn}${held !== undefined && decision === 'approved' ? '; run the install again and it goes through' : ''}`)
|
|
83
143
|
return
|
|
84
144
|
}
|
|
85
|
-
const decided = { ...current, decision, decidedAt, confirming: false }
|
|
86
|
-
await update($, viewAtom, () => decided)
|
|
87
145
|
// The reopened report shows the outcome, not the buttons again.
|
|
88
|
-
await
|
|
89
|
-
|
|
90
|
-
|
|
146
|
+
await saveOwn(decided)
|
|
147
|
+
// The approval is saved for this exact version, so the install, asked for
|
|
148
|
+
// again, now goes through: the gate looks the version up again first.
|
|
149
|
+
if (held !== undefined && decision === 'approved') {
|
|
150
|
+
await carryOut($, held, decided)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
$.ui.toast(`AI Install Guard: ${decision === 'approved' ? 'approved' : 'rejected'} ${current.target}${held === undefined ? '' : '; nothing was installed'}`)
|
|
154
|
+
await closeOwn($, decided)
|
|
91
155
|
}
|
|
92
156
|
|
|
93
157
|
const primary = async () => {
|
|
94
158
|
const current = await read($, viewAtom)
|
|
95
|
-
if (current === null) return
|
|
159
|
+
if (current === null || current.scanId !== view.scanId) return
|
|
96
160
|
if (needsConfirmation(current)) {
|
|
97
|
-
await update($, viewAtom, latest => (latest === null ? latest : { ...latest, confirming: true }))
|
|
161
|
+
await update($, viewAtom, latest => (latest === null || latest.scanId !== view.scanId ? latest : { ...latest, confirming: true }))
|
|
98
162
|
return
|
|
99
163
|
}
|
|
100
164
|
await decide('approved', false)()
|
|
@@ -112,8 +176,9 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
|
|
|
112
176
|
const offersBadge = view.treeHash !== undefined && (view.verdict?.kind === 'safe' || view.verdict?.kind === 'caution')
|
|
113
177
|
|
|
114
178
|
const copy = async () => {
|
|
115
|
-
|
|
116
|
-
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 })
|
|
117
182
|
$.ui.toast(result.isCopied ? 'AI Install Guard: report copied' : 'AI Install Guard: could not copy the report here')
|
|
118
183
|
}
|
|
119
184
|
|
|
@@ -129,6 +194,12 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
|
|
|
129
194
|
</Text>
|
|
130
195
|
)}
|
|
131
196
|
|
|
197
|
+
{view.request !== undefined && (
|
|
198
|
+
<Text dimColor wrap="truncate-end">
|
|
199
|
+
{requestLine(view.request)}
|
|
200
|
+
</Text>
|
|
201
|
+
)}
|
|
202
|
+
|
|
132
203
|
{verdict === undefined ? (
|
|
133
204
|
<Text color="suggestion" wrap="truncate-end">
|
|
134
205
|
Scanning: {view.stage}…
|
|
@@ -208,9 +279,9 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
|
|
|
208
279
|
</Box>
|
|
209
280
|
))}
|
|
210
281
|
|
|
211
|
-
{verdict !== undefined && view.decision === undefined && !view.confirming && (
|
|
282
|
+
{verdict !== undefined && view.decision === undefined && !view.confirming && view.deciding !== true && (
|
|
212
283
|
<Box key="actions" flexDirection="row" gap={1} marginTop={1}>
|
|
213
|
-
{view.treeHash !== undefined && (
|
|
284
|
+
{(view.treeHash !== undefined || (view.action === 'install' && view.request !== undefined)) && (
|
|
214
285
|
<Button key="install" label={primaryLabel(view)} hotkey="i" variant={needsConfirmation(view) ? 'secondary' : 'primary'} onPress={primary} />
|
|
215
286
|
)}
|
|
216
287
|
<Button key="reject" label="Reject" hotkey="r" variant={needsConfirmation(view) ? 'primary' : 'secondary'} onPress={decide('rejected', false)} />
|
|
@@ -220,7 +291,7 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
|
|
|
220
291
|
</Box>
|
|
221
292
|
)}
|
|
222
293
|
|
|
223
|
-
{verdict !== undefined && view.confirming && (
|
|
294
|
+
{verdict !== undefined && view.confirming && view.deciding !== true && (
|
|
224
295
|
<Box key="confirm-row" flexDirection="column" marginTop={1}>
|
|
225
296
|
<Text color="warning" wrap="wrap">
|
|
226
297
|
The verdict is {verdict.recommendation}. {primaryLabel(view)}?
|
|
@@ -234,8 +305,9 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
|
|
|
234
305
|
|
|
235
306
|
{view.decision !== undefined && (
|
|
236
307
|
<Text color={view.decision === 'approved' ? 'success' : 'error'}>
|
|
237
|
-
{view.decision === 'approved' ? 'Approved' : 'Rejected'}
|
|
238
|
-
{view.decidedAt === undefined ? '' : ` on ${new Date(view.decidedAt).toISOString().slice(0, 10)}`}
|
|
308
|
+
{view.treeHash === undefined ? 'Allowed once' : view.decision === 'approved' ? 'Approved' : 'Rejected'}
|
|
309
|
+
{view.decidedAt === undefined ? '' : ` on ${new Date(view.decidedAt).toISOString().slice(0, 10)}`}
|
|
310
|
+
{view.treeHash === undefined ? '. Nothing could be pinned, so nothing is remembered; the same install is held again next time.' : '. The decision is saved for this exact version; /guard forget clears it.'}
|
|
239
311
|
</Text>
|
|
240
312
|
)}
|
|
241
313
|
{view.decision !== undefined && offersBadge && (
|
|
@@ -247,6 +319,58 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
|
|
|
247
319
|
)
|
|
248
320
|
}
|
|
249
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Closes the pane while it shows this scan. Closing is a step of its own, so
|
|
324
|
+
* a newer scan may take the pane in that moment: while that scan still runs
|
|
325
|
+
* or waits for a decision it gets the pane back at once, so its verdict is
|
|
326
|
+
* never left hidden; one already decided closed the pane itself.
|
|
327
|
+
*/
|
|
328
|
+
async function closeOwn($: EngineInterface, view: GuardView): Promise<void> {
|
|
329
|
+
const before = await read($, viewAtom).catch(() => null)
|
|
330
|
+
if (before !== null && before.scanId !== view.scanId) return
|
|
331
|
+
await $.ui.close({ id: PANE_ID })
|
|
332
|
+
const after = await read($, viewAtom).catch(() => null)
|
|
333
|
+
const waiting = after !== null && after.scanId !== view.scanId && after.decision === undefined && after.deciding !== true
|
|
334
|
+
if (waiting) await $.ui.open({ id: PANE_ID, title: 'AI Install Guard', focus: true }).catch(() => undefined)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** What the held install is, in the person's or Claude's own words. */
|
|
338
|
+
function requestLine(request: GuardRequest): string {
|
|
339
|
+
return request.origin === 'tool' ? `Claude asked to run: ${safeLine(request.args)}` : `Held until you decide: /plugin ${safeLine(request.args)}`
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Carries out a held install the person approved. Their own /plugin command
|
|
344
|
+
* runs again once the version it would install is checked to be the one
|
|
345
|
+
* scanned; a command Claude ran is Claude's to run again, once the person
|
|
346
|
+
* tells it to, and its gate checks the version then.
|
|
347
|
+
*/
|
|
348
|
+
async function carryOut($: EngineInterface, held: GuardRequest, view: GuardView): Promise<void> {
|
|
349
|
+
await closeOwn($, view)
|
|
350
|
+
if (held.origin === 'tool') {
|
|
351
|
+
$.ui.toast(`AI Install Guard: approved ${held.label}. Tell Claude to run the install again; it goes through now`)
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
// The plugin's own command does not pass through its own gate, so the
|
|
355
|
+
// version is looked up again here: a commit that moved since the scan is
|
|
356
|
+
// not installed on the strength of the old one's verdict.
|
|
357
|
+
const changed = await changedSinceScan(modHost($), view).catch((error: unknown) => `the guard could not look it up again (${error instanceof Error ? error.message : String(error)})`)
|
|
358
|
+
if (changed !== undefined) {
|
|
359
|
+
await takePass($, { origin: 'command', args: held.args }).catch(() => undefined)
|
|
360
|
+
$.ui.toast(`AI Install Guard: nothing was installed: ${safeLine(changed)}. Run /plugin ${safeLine(held.args)} again to scan it`)
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
$.ui.toast(`AI Install Guard: approved; installing ${held.label}`)
|
|
364
|
+
try {
|
|
365
|
+
await $.command.run({ command: 'plugin', args: held.args })
|
|
366
|
+
} catch {
|
|
367
|
+
$.ui.toast(`AI Install Guard: approved, but the install did not start. Run /plugin ${safeLine(held.args)} again; it goes through now`)
|
|
368
|
+
} finally {
|
|
369
|
+
// A pass the gate did not use up is not left for a later command.
|
|
370
|
+
await takePass($, { origin: 'command', args: held.args }).catch(() => undefined)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
250
374
|
function truncate(text: string, room: number): string {
|
|
251
375
|
if (room <= 1) return ''
|
|
252
376
|
return text.length <= room ? text : `${text.slice(0, Math.max(0, room - 1))}…`
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Installs the person allowed once although nothing could be pinned: Could
|
|
2
|
+
// not verify, then Install anyway, confirmed. Each pass lets that exact
|
|
3
|
+
// command through one time in this session and is then used up.
|
|
4
|
+
|
|
5
|
+
import { atom, update } from 'claude-code'
|
|
6
|
+
import type { EngineInterface } from 'claude-code'
|
|
7
|
+
|
|
8
|
+
import type { GuardPass } from '../../types'
|
|
9
|
+
|
|
10
|
+
const passesAtom = atom({ plugin: 'ai-install-guard', key: 'passes' } as const, [] as GuardPass[])
|
|
11
|
+
|
|
12
|
+
/** Passes kept at once; an old one unused falls off. */
|
|
13
|
+
const MAX_PASSES = 20
|
|
14
|
+
|
|
15
|
+
function samePass(a: GuardPass, b: GuardPass): boolean {
|
|
16
|
+
return a.origin === b.origin && a.args === b.args
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function grantPass($: EngineInterface, pass: GuardPass): Promise<void> {
|
|
20
|
+
await update($, passesAtom, current => [...current.filter(item => !samePass(item, pass)), pass].slice(-MAX_PASSES))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Uses up the pass for this command, if there is one. Finding and removing it
|
|
25
|
+
* is one write, so two commands at once cannot both use the same pass.
|
|
26
|
+
*/
|
|
27
|
+
export async function takePass($: EngineInterface, pass: GuardPass): Promise<boolean> {
|
|
28
|
+
let taken = false
|
|
29
|
+
await update($, passesAtom, latest => {
|
|
30
|
+
taken = latest.some(item => samePass(item, pass))
|
|
31
|
+
return taken ? latest.filter(item => !samePass(item, pass)) : latest
|
|
32
|
+
})
|
|
33
|
+
return taken
|
|
34
|
+
}
|