ai-install-guard 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -35,7 +35,7 @@ export function viewOf(report: ScanReport, action: GuardAction): GuardView {
35
35
  // A remote scan's folder is a temporary one: its source names it instead.
36
36
  root:
37
37
  report.source !== undefined
38
- ? safeLine(`${report.source.url}${report.source.subdir === undefined ? '' : `#${report.source.subdir}`}`)
38
+ ? sourceRoot(report.source.url, report.source.subdir)
39
39
  : report.inventory === undefined
40
40
  ? undefined
41
41
  : safeLine(report.inventory.root),
@@ -48,6 +48,11 @@ export function viewOf(report: ScanReport, action: GuardAction): GuardView {
48
48
  }
49
49
  }
50
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
+
51
56
  function rowOf(result: CheckResult): GuardRow {
52
57
  return {
53
58
  id: result.id,
@@ -1,16 +1,18 @@
1
1
  import type { EngineInterface, Register } from 'claude-code'
2
2
  import { atom, update } from 'claude-code'
3
3
 
4
+ import { BROWSE_NOTE, judge } from './gate/gate'
5
+ import { readPluginArgs, readShellCommand } from './gate/requests'
6
+ import { expandHome } from './gate/resolve'
4
7
  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'
8
+ import { forgetDecisions, loadDecisions, loadLastView, loadReport } from './guard/decisions'
9
+ import { drawPane } from './guard/pane'
10
+ import { showScan } from './guard/run'
11
+ import { PANE_ID } from './guard/view'
8
12
  import { modHost } from './mod-host'
9
13
  import type { Host } from './scan/host'
10
14
  import { looksRemote, parseRemote, type RemoteSpec } from './scan/remote'
11
- import { formatReport } from './scan/report'
12
15
  import { safeLine } from './scan/safe-text'
13
- import { scanFolder, scanRemote } from './scan/scan'
14
16
 
15
17
  // Claude Code reads every state reference from the file that uses it, so each
16
18
  // module declares its own atom for the pane's view.
@@ -57,6 +59,33 @@ export const register: Register = (on, options) => {
57
59
  return { text: USAGE }
58
60
  }).catch(() => ({ text: 'AI Install Guard: the command failed before it could answer.' }))
59
61
 
62
+ // Gate A: the person's own /plugin install and /plugin marketplace add. An
63
+ // install is answered in the command's place, so nothing installs, until
64
+ // the person approves that exact version.
65
+ on('command.run', { command: 'plugin' }, async ($, e, next) => {
66
+ const reading = readPluginArgs(e.args)
67
+ if (reading.kind === 'pass') {
68
+ if (reading.browse) $.ui.toast(BROWSE_NOTE)
69
+ return next(e)
70
+ }
71
+ const judged = await judge($, reading, { origin: 'command', args: e.args, label: reading.label }, lookups)
72
+ if (judged.kind === 'hold') return { text: judged.text }
73
+ if (judged.note !== undefined) $.ui.toast(judged.note)
74
+ return next(e)
75
+ }).catch(($, e, next) => (next.called ? next(e) : { text: 'AI Install Guard stopped this install: it failed before it could check it, so nothing was installed.' }))
76
+
77
+ // Gate B: Claude installing a plugin or adding a marketplace with the claude
78
+ // command. The command is refused, with a reason Claude reads, until the
79
+ // person approves that exact version in the pane.
80
+ on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
81
+ const reading = readShellCommand(e.command)
82
+ if (reading.kind === 'pass') return next(e)
83
+ const judged = await judge($, reading, { origin: 'tool', args: e.command, label: reading.label }, lookups)
84
+ if (judged.kind === 'hold') return { deny: judged.text }
85
+ if (judged.note !== undefined) $.ui.toast(judged.note)
86
+ return next(e)
87
+ }).catch(($, e, next) => (next.called ? next(e) : { deny: 'AI Install Guard stopped this command: it may install a plugin or add a marketplace, and the guard failed before it could check it. Nothing was installed.' }))
88
+
60
89
  on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e) => drawPane($, e))
61
90
  }
62
91
 
@@ -68,58 +97,28 @@ export const register: Register = (on, options) => {
68
97
  */
69
98
  async function scan($: EngineInterface, target: string, details: boolean, flags: { ref?: string; path?: string }, lookups: boolean): Promise<string> {
70
99
  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
- }
100
+ const shown = await showScan(
101
+ $,
102
+ target,
103
+ async () => {
104
+ const spec = await remoteSpecFor(host, target, flags)
105
+ return spec === undefined ? { folder: await expandHome(host, target) } : { remote: spec }
106
+ },
107
+ { action: 'approve', lookups },
108
+ )
109
+ if (shown.kind === 'failed') return shown.reason
110
+ // Where no pane shows, the answer is the whole report: findings and advice.
111
+ if (!shown.placed || details) return shown.text
112
+ return `AI Install Guard: ${shown.report.verdict.recommendation} for ${safeLine(target)}. The report is in the pane; /guard report reopens it.`
113
113
  }
114
114
 
115
115
  async function reopen($: EngineInterface): Promise<string> {
116
116
  const view = await loadLastView($)
117
117
  if (view === undefined) return 'AI Install Guard: no report yet. Run /guard scan <path>.'
118
- await update($, viewAtom, () => ({ ...view, confirming: false }))
118
+ await update($, viewAtom, () => ({ ...view, confirming: false, deciding: false }))
119
119
  const opened = await $.ui.open({ id: PANE_ID, title: 'AI Install Guard', focus: true }).catch(() => ({ isPlaced: false }))
120
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.'
121
+ return (await loadReport($, view.scanId)) ?? 'AI Install Guard: the last report is no longer stored.'
123
122
  }
124
123
 
125
124
  async function badge($: EngineInterface): Promise<string> {
@@ -195,19 +194,3 @@ async function remoteSpecFor(host: Host, target: string, flags: { ref?: string;
195
194
  function unquote(text: string): string {
196
195
  return /^(["']).*\1$/.test(text) ? text.slice(1, -1) : text
197
196
  }
198
-
199
- async function expandHome(host: Host, path: string): Promise<string> {
200
- if (path !== '~' && !path.startsWith('~/')) {
201
- return path
202
- }
203
- const { exitCode, stdout } = await host.run(['printenv', 'HOME'])
204
- const home = stdout.trim()
205
- if (exitCode !== 0 || home === '') {
206
- throw new Error('could not find the home folder')
207
- }
208
- return home + path.slice(1)
209
- }
210
-
211
- function reasonOf(error: unknown): string {
212
- return error instanceof Error ? error.message : String(error)
213
- }
@@ -250,6 +250,15 @@ function eventCapability(use: ModuleUse): Capability {
250
250
  if (SENSITIVE_COMMANDS.has(command)) return { risk: 'high', plain: `intercepts /${command}` }
251
251
  return { risk: 'low', plain: `answers /${command}` }
252
252
  }
253
+ // A tool hook narrowed to one tool is described as that tool's, still high risk.
254
+ if (use.name === 'tool.call' || use.name === 'tool.check') {
255
+ const tool = /\btool\s*:\s*['"`]([\w.-]{1,64})['"`]/.exec(use.detail ?? '')?.[1]
256
+ if (tool !== undefined) {
257
+ return use.name === 'tool.call'
258
+ ? { risk: 'high', plain: `sees every ${tool} call and can deny, rewrite or answer it` }
259
+ : { risk: 'high', plain: `decides whether ${tool} calls are allowed, so it can auto-approve them` }
260
+ }
261
+ }
253
262
  if (Object.hasOwn(EVENTS, use.name)) return EVENTS[use.name] as Capability
254
263
  const namespace = use.name.split('.')[0] ?? ''
255
264
  return Object.hasOwn(NAMESPACES, namespace) ? (NAMESPACES[namespace] as Capability) : { risk: 'low', plain: 'an event this scanner does not know' }
@@ -216,9 +216,7 @@ export function resolveRef(listed: RemoteRefs, ref: string | undefined): Pick<Pi
216
216
  */
217
217
  export async function fetchPinned(host: Host, spec: RemoteSpec, onStage?: (stage: string) => Promise<void> | void): Promise<FetchedSource> {
218
218
  await onStage?.(`resolving ${safeLine(spec.url)}`)
219
- const listing = await git(host, ['ls-remote', '--symref', spec.url, 'HEAD', 'refs/heads/*', 'refs/tags/*'], 60_000)
220
- if (listing.exitCode !== 0) throw failure(listing, `could not reach ${spec.url}`)
221
- const listed = parseLsRemote(listing.stdout)
219
+ const listed = await listRefs(host, spec.url)
222
220
  const fromLink = spec.treePath !== undefined && spec.ref === undefined ? splitTreePath(listed, spec.treePath) : undefined
223
221
  const subdir = spec.subdir ?? fromLink?.subdir
224
222
  const pinned = resolveRef(listed, spec.ref ?? fromLink?.ref)
@@ -280,6 +278,29 @@ export async function fetchPinned(host: Host, spec: RemoteSpec, onStage?: (stage
280
278
  }
281
279
  }
282
280
 
281
+ /**
282
+ * The commit `spec` names now, and the folder in it, without fetching
283
+ * anything: what a later fetch of the same spec would pin, so a decision made
284
+ * on that commit can be found before a scan.
285
+ */
286
+ export async function resolvePin(host: Host, spec: RemoteSpec): Promise<{ sha: string; subdir?: string }> {
287
+ // A full commit id needs no listing: it already is the pin.
288
+ if (spec.ref !== undefined && spec.treePath === undefined && FULL_SHA.test(spec.ref.toLowerCase())) {
289
+ return { sha: spec.ref.toLowerCase(), ...(spec.subdir === undefined ? {} : { subdir: spec.subdir }) }
290
+ }
291
+ const listed = await listRefs(host, spec.url)
292
+ const fromLink = spec.treePath !== undefined && spec.ref === undefined ? splitTreePath(listed, spec.treePath) : undefined
293
+ const subdir = spec.subdir ?? fromLink?.subdir
294
+ return { sha: resolveRef(listed, spec.ref ?? fromLink?.ref).sha, ...(subdir === undefined ? {} : { subdir }) }
295
+ }
296
+
297
+ /** The repository's branches and tags, as `git ls-remote` lists them. */
298
+ async function listRefs(host: Host, url: string): Promise<RemoteRefs> {
299
+ const listing = await git(host, ['ls-remote', '--symref', url, 'HEAD', 'refs/heads/*', 'refs/tags/*'], 60_000)
300
+ if (listing.exitCode !== 0) throw failure(listing, `could not reach ${url}`)
301
+ return parseLsRemote(listing.stdout)
302
+ }
303
+
283
304
  function git(host: Host, args: readonly string[], timeoutMs = 60_000, env: Record<string, string> = GIT_ENV): Promise<RunResult> {
284
305
  return host.run(['git', ...args], { timeoutMs, env })
285
306
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-install-guard",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "A pre-install security gate for Claude Code: scans plugins, mods, skills and MCP servers without running them, and installs nothing until you approve.",
5
5
  "scripts": {
6
6
  "build:fixtures": "node scripts/build-fixtures.js",