ai-install-guard 0.3.0 → 0.3.2

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.
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "name": "ai-install-guard",
12
12
  "source": "./",
13
- "version": "0.3.0",
13
+ "version": "0.3.2",
14
14
  "description": "Scans plugins, mods, skills and MCP servers without running them, shows a PASS / WARN / FAIL verdict, and installs nothing until you approve.",
15
15
  "category": "security",
16
16
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-install-guard",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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
  "author": {
6
6
  "name": "Boaz Ziniman",
package/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to ai-install-guard. Ordered newest to oldest.
4
4
 
5
5
  ---
6
6
 
7
+ ## v0.3.2  ·  2026-10-23
8
+
9
+ **`pane`** - The held command, the verdict's reasons and the title wrap instead of being cut off at the pane's edge, so a long reason can be read in a narrow pane. A Could not verify reason shows as a sentence of its own under the banner
10
+
11
+ **`/plugin install`** - When the guard could not verify an install, the command's answer says why, and names the button that installs it anyway; a scan's own findings stay in the pane. `/plugin install <name> --marketplace <source>` is now scanned like any install: for a marketplace on GitHub, its catalog is read at the commit Claude Code would add, and the folder or repository it names for the plugin is scanned. Claude's own `claude plugin install … --marketplace` must name the marketplace by its https address or full path
12
+
13
+ ---
14
+
15
+ ## v0.3.1 &nbsp;·&nbsp; 2026-10-23
16
+
17
+ **`README`** - An Uninstall section, linked from the top: how to remove or pause the guard in Claude Code or from a terminal, how to turn it off from outside Claude Code if it ever holds something it should not, how to clear your decisions before you go, and that the guard never holds an uninstall or a disable
18
+
19
+ **`ci`** - The workflows use the current Node 24 releases of checkout, setup-node and upload-artifact, pinned to their commits, instead of Node 20 ones the runners now force onto Node 24, and npm no longer warns about the `always-auth` setting the old setup-node wrote
20
+
21
+ ---
22
+
7
23
  ## v0.3.0 &nbsp;·&nbsp; 2026-10-23
8
24
 
9
25
  **`/plugin install`** - The guard now holds installs. `/plugin install <name>@<marketplace>` and `/plugin marketplace add <source>` are looked up (the repository, folder and commit Claude Code would install from), scanned at that commit and answered in the command's place, so nothing installs until you press Install in the pane. Install records your approval for that commit and tree hash, looks the plugin up once more, and runs your command again; a commit that moved since the scan is not installed. A version you already approved installs without asking again unless a new scan judges it worse, a rejected one stays refused, and what the guard cannot pin (an npm package, a plugin no marketplace lists, a name two marketplaces share) is Could not verify, with Install anyway behind a second confirmation that lets that one command through once. The `/plugin` menu itself is not gated yet, and says so when it opens
package/README.md CHANGED
@@ -16,6 +16,8 @@
16
16
  /plugin install ai-install-guard --marketplace ziniman/ai-install-guard
17
17
  ```
18
18
 
19
+ To turn it off or remove it, see [Uninstall](#uninstall).
20
+
19
21
  </div>
20
22
 
21
23
  > **Status: early development.** `/plugin install` and `/plugin marketplace add`, and the same installs when Claude runs `claude plugin` itself, are held until you approve the exact version the guard scanned. `/guard scan` checks a folder or a repository before you install it anywhere. The other gates are being built in stages; see [Where it steps in](#where-it-steps-in) and [Roadmap](#roadmap).
@@ -28,7 +30,7 @@ AI Install Guard is a Claude Code mod that stops every new plugin, mod, skill, M
28
30
 
29
31
  You, before you add something to Claude Code. It runs inside your own session, needs no account, server or agent, and is open source, so you can read every check it makes. It reaches the network only to fetch the candidate from where it lives and to read that repository's public details; any outside lookup stays optional and off by default.
30
32
 
31
- Company security platforms such as AIR, Pluto and Neo watch a whole fleet of agents and enforce policy across it. The guard does not replace them: it is an open, local check for one developer's installs, and it works the same whether or not your company runs one.
33
+ Company-wide security platforms watch a whole fleet of agents and enforce policy across it. The guard does not replace them: it is an open, local check for one developer's installs, and it works the same whether or not your company runs one.
32
34
 
33
35
  ## How it works
34
36
 
@@ -43,7 +45,7 @@ Approvals are remembered by commit and content hash, so the same version is neve
43
45
 
44
46
  | Layer | Catches | What happens | Today |
45
47
  |-------|---------|--------------|-------|
46
- | Slash command | `/plugin install <name>@<marketplace>`, `/plugin marketplace add <source>` | Held until you press Install | Yes |
48
+ | Slash command | `/plugin install <name>@<marketplace>`, `/plugin install <name> --marketplace <source>`, `/plugin marketplace add <source>` | Held until you press Install | Yes |
47
49
  | Claude's own commands | `claude plugin install`, `claude plugin update`, `claude plugin marketplace add`, also inside `sh -c`, `eval` or a here-document | Refused, with a note telling Claude the install waits for your decision | Yes |
48
50
  | Claude's own commands | `claude mcp add`, copies into skills folders, edits to `.mcp.json` or settings, `npx -y`, `curl \| sh` | Refused until scanned | Next stage |
49
51
  | Mod loading | Any mod about to load, however it got there, including plugins picked from the `/plugin` menu | Refused if its files do not match what you approved | Later |
@@ -52,7 +54,9 @@ Approvals are remembered by commit and content hash, so the same version is neve
52
54
 
53
55
  ### Installing with the guard on
54
56
 
55
- Run `/plugin install notes@acme` as usual. The guard looks up where Claude Code would install it from (the repository, folder and commit its marketplace names), scans that commit, and answers in the command's place, so nothing is installed yet. The pane shows the verdict and the command it is holding.
57
+ Run `/plugin install notes@acme` as usual. The guard looks up where Claude Code would install it from (the repository, folder and commit its marketplace names), scans that commit, and answers in the command's place, so nothing is installed yet. The pane shows the verdict and the command it is holding. When it could not verify the install, the command's answer says why too.
58
+
59
+ To install from a marketplace you have not added yet, `/plugin install rtl-toggle --marketplace owner/repo` works the same way: for a marketplace on GitHub, the guard reads its catalog at the commit Claude Code would add and scans the folder or repository that catalog names for the plugin. A marketplace hosted elsewhere is Could not verify until you add it with `/plugin marketplace add`, which the guard scans.
56
60
 
57
61
  - **Install** (`i`) records your approval for that exact commit and tree hash, looks the plugin up once more, and runs your `/plugin` command again. If the commit moved since the scan, nothing is installed and the next `/plugin install` scans the new one.
58
62
  - **Reject** (`r`) records the rejection. The same version is refused from then on; `/guard forget notes@acme` judges it again.
@@ -141,7 +145,7 @@ A security tool should be held to its own rules, so here is everything this mod
141
145
  | `command.register` | Adds the `/guard` command. |
142
146
  | `store`, `state` | Remembers your decisions and the last report, holds what the pane shows, and keeps a one-time pass for an install you allowed anyway. |
143
147
  | `ui` | The verdict pane, its status line and toasts, and copying the report. |
144
- | `http.fetch` | For a repository on GitHub, reads its public metadata from GitHub's API: age, owner, stars, contributors, and whether the scanned commit is signed and on the default branch. GitHub is where the code came from, so this tells it nothing new. With **outside lookups** on, also sends package names to [OSV](https://osv.dev) and reads Anthropic's [official plugin list](https://github.com/anthropics/claude-plugins-official). |
148
+ | `http.fetch` | For a repository on GitHub, reads its public metadata from GitHub's API: age, owner, stars, contributors, and whether the scanned commit is signed and on the default branch. For `/plugin install <name> --marketplace <owner>/<repo>`, reads that marketplace's catalog from GitHub at the commit that would be added, to find where the plugin is. GitHub is where the code came from, so this tells it nothing new. With **outside lookups** on, also sends package names to [OSV](https://osv.dev) and reads Anthropic's [official plugin list](https://github.com/anthropics/claude-plugins-official). |
145
149
 
146
150
  A later stage adds, optionally, `model.complete` (a review that can only add warnings).
147
151
 
@@ -160,6 +164,40 @@ claude plugin configure ai-install-guard
160
164
 
161
165
  A lookup that fails or hits a rate limit is noted in the report and never fails the scan.
162
166
 
167
+ ## Uninstall
168
+
169
+ The guard holds installs, never removals: uninstalling or disabling it, or any other plugin, goes straight through when the command says so plainly. One whose action is only known as it runs, such as `claude plugin "$ACTION" …`, is held, since it could as well be an install.
170
+
171
+ **Remove it** in Claude Code:
172
+
173
+ ```
174
+ /plugin uninstall ai-install-guard@ai-install-guard
175
+ ```
176
+
177
+ or from a terminal:
178
+
179
+ ```bash
180
+ claude plugin uninstall ai-install-guard@ai-install-guard
181
+ ```
182
+
183
+ If you installed it for one project only (`--scope project` or `--scope local`), add the same `--scope`. To remove its marketplace as well, run `/plugin marketplace remove ai-install-guard`. A session that is already open may keep the guard loaded until you restart it.
184
+
185
+ **Turn it off for a while** with `/plugin disable ai-install-guard@ai-install-guard`, and back on with `/plugin enable ai-install-guard@ai-install-guard`.
186
+
187
+ **If it ever gets in your way.** The guard fails closed: when it cannot check something, it stops it. So a bug in the guard could hold an install, or a shell command it mistakes for one, that should have gone through. You can always turn it off from a terminal outside Claude Code, where the guard sees nothing:
188
+
189
+ ```bash
190
+ claude plugin disable ai-install-guard@ai-install-guard
191
+ ```
192
+
193
+ If even that fails, set `"ai-install-guard@ai-install-guard": false` under `enabledPlugins` in `~/.claude/settings.json` (or in the project's `.claude/settings.json` if you installed it there). Then [open an issue](https://github.com/ziniman/ai-install-guard/issues) with the command it stopped, so it can be fixed.
194
+
195
+ **Your decisions.** Approvals and rejections are kept in the store Claude Code gives the plugin, which can outlast an uninstall, so a later reinstall may find them again. To start clean, run `/guard list` before you uninstall and `/guard forget <name>` for each one. The guard writes no files of its own: its only folder is the temporary one it fetches into, removed after each scan.
196
+
197
+ **If your organization installed it** through managed settings, it is theirs to remove; ask whoever manages your Claude Code settings.
198
+
199
+ **In Claude chat,** remove the skill under **Settings > Capabilities > Skills**.
200
+
163
201
  ## What it is not
164
202
 
165
203
  - **Not a proof of safety.** Static analysis raises the bar and makes every capability visible; a determined attacker can still evade it.
@@ -13,6 +13,7 @@ import { sourceRoot } from '../guard/view'
13
13
  import { modHost } from '../mod-host'
14
14
  import { resolvePin } from '../scan/remote'
15
15
  import { safeLine } from '../scan/safe-text'
16
+ import type { VerdictKind } from '../scan/verdict'
16
17
  import type { InstallRequest, Reading } from './requests'
17
18
  import { targetOf } from './resolve'
18
19
 
@@ -31,7 +32,8 @@ export async function judge($: EngineInterface, reading: Exclude<Reading, { kind
31
32
  const options = { action: 'install' as const, request, lookups }
32
33
  if (reading.kind === 'unreadable') {
33
34
  const shown = await showUnverified($, reading.label, reading.reason, options)
34
- return shown.superseded ? superseded(request) : hold(request, 'Could not verify', '', shown.placed)
35
+ // The reason is the guard's own reading of the command, so Claude may read it too.
36
+ return shown.superseded ? superseded(request) : hold(request, UNVERIFIED, '', shown.placed, { why: reading.reason, toClaude: true })
35
37
  }
36
38
  const name = nameOf(reading.request)
37
39
  const host = modHost($)
@@ -41,7 +43,8 @@ export async function judge($: EngineInterface, reading: Exclude<Reading, { kind
41
43
  return { kind: 'allow', note: BROWSE_NOTE }
42
44
  case 'unverifiable': {
43
45
  const shown = await showUnverified($, name, target.reason, options)
44
- return shown.superseded ? superseded(request) : hold(request, 'Could not verify', '', shown.placed)
46
+ // This reason can quote a marketplace's own catalog: the person reads it, Claude does not.
47
+ return shown.superseded ? superseded(request) : hold(request, UNVERIFIED, '', shown.placed, { why: target.reason, toClaude: false })
45
48
  }
46
49
  case 'remote': {
47
50
  // The commit the install would take now. A rejection of it stands
@@ -70,8 +73,11 @@ function nameOf(request: InstallRequest): string {
70
73
  return safeLine(request.marketplace === undefined ? request.plugin : `${request.plugin}@${request.marketplace}`)
71
74
  }
72
75
 
76
+ /** How a held install that could not be checked is described. */
77
+ const UNVERIFIED = { kind: 'unverified', recommendation: 'Could not verify' } as const
78
+
73
79
  function afterScan(request: GuardRequest, name: string, shown: Shown): Judgement {
74
- if (shown.kind === 'failed') return shown.superseded ? superseded(request) : hold(request, 'Could not verify', '', shown.placed)
80
+ if (shown.kind === 'failed') return shown.superseded ? superseded(request) : hold(request, UNVERIFIED, '', shown.placed)
75
81
  const sha = shown.report.source?.sha
76
82
  const where = sha === undefined ? '' : ` at commit ${sha.slice(0, 12)}`
77
83
  // The same files approved before (through /guard scan, or an earlier install), and judged no
@@ -79,7 +85,7 @@ function afterScan(request: GuardRequest, name: string, shown: Shown): Judgement
79
85
  if (shown.view.decision === 'approved') return { kind: 'allow', note: `AI Install Guard: you approved ${name}${where} on ${day(shown.view.decidedAt ?? Date.now())}; installing it` }
80
86
  if (shown.view.decision === 'rejected') return rejected(request, name, where, shown.view.decidedAt)
81
87
  if (shown.superseded) return superseded(request)
82
- return hold(request, shown.report.verdict.recommendation, where, shown.placed, shown.text)
88
+ return hold(request, shown.report.verdict, where, shown.placed, { report: shown.text })
83
89
  }
84
90
 
85
91
  /** Another scan took the pane before this one finished: nothing to press, so nothing installs. */
@@ -96,19 +102,34 @@ function rejected(request: GuardRequest, name: string, where: string, decidedAt:
96
102
  return { kind: 'hold', text: `AI Install Guard: you rejected ${name}${where}${when}. Nothing was installed. /guard forget ${name} judges it again.` }
97
103
  }
98
104
 
99
- /** The answer while an install waits for the person's decision in the pane. */
100
- function hold(request: GuardRequest, recommendation: string, where: string, placed: boolean, report?: string): Judgement {
101
- const what = `${recommendation} for ${request.label}${where}`
105
+ /**
106
+ * The answer while an install waits for the person's decision in the pane.
107
+ * `why` says why it could not be checked; Claude reads it only when it is the
108
+ * guard's own reading of Claude's command, never words from a catalog.
109
+ */
110
+ function hold(
111
+ request: GuardRequest,
112
+ verdict: { kind: VerdictKind; recommendation: string },
113
+ where: string,
114
+ placed: boolean,
115
+ more: { report?: string; why?: string; toClaude?: boolean } = {},
116
+ ): Judgement {
117
+ const unverified = verdict.kind === 'unverified'
118
+ const why = more.why === undefined ? '' : `: ${safeLine(more.why)}`
119
+ const what = unverified ? `could not verify ${request.label}${where}` : `${verdict.recommendation} for ${request.label}${where}`
120
+ // Do not install and Could not verify install only through Install anyway, which asks twice.
121
+ const button = unverified || verdict.kind === 'reject' ? 'Install anyway (it asks you to confirm)' : 'Install'
102
122
  if (request.origin === 'tool') {
103
123
  const next = placed
104
124
  ? 'The verdict and the Install and Reject buttons are in the guard\'s pane. Tell the person the install waits for their decision there, and run the same command again only after they say they approved it.'
105
125
  : 'This session cannot show the guard\'s pane, so the person cannot approve it here.'
106
- return { kind: 'hold', text: `AI Install Guard held this install for the person to review: ${what}. Nothing was installed. Do not retry it or install it another way. ${next}` }
126
+ return { kind: 'hold', text: `AI Install Guard held this install for the person to review: ${unverified ? `it ${what}${more.toClaude === true ? why : ''}` : what}. Nothing was installed. Do not retry it or install it another way. ${next}` }
107
127
  }
128
+ const said = unverified ? `AI Install Guard ${what}${why}.` : `AI Install Guard: ${what}.`
108
129
  if (!placed) {
109
- return { kind: 'hold', text: `${report === undefined ? '' : `${report}\n\n`}AI Install Guard: ${what}. Nothing was installed: approving an install needs the guard's pane, which this session cannot show.` }
130
+ return { kind: 'hold', text: `${more.report === undefined ? '' : `${more.report}\n\n`}${said} Nothing was installed: approving an install needs the guard's pane, which this session cannot show.` }
110
131
  }
111
- return { kind: 'hold', text: `AI Install Guard: ${what}. Nothing is installed yet: press Install in the guard's pane to install it, or Reject.` }
132
+ return { kind: 'hold', text: `${said} Nothing is installed yet: press ${button} in the guard's pane to install it, or Reject.` }
112
133
  }
113
134
 
114
135
  function day(time: number): string {
@@ -6,7 +6,8 @@ import { safeLine } from '../scan/safe-text'
6
6
 
7
7
  /** One thing to add to Claude Code: a plugin from a marketplace, or a marketplace. */
8
8
  export type InstallRequest =
9
- | { kind: 'plugin'; verb: 'install' | 'update'; plugin: string; marketplace?: string }
9
+ /** `marketplaceSource` is the marketplace `--marketplace` adds first: a repository or a folder. */
10
+ | { kind: 'plugin'; verb: 'install' | 'update'; plugin: string; marketplace?: string; marketplaceSource?: string }
10
11
  | { kind: 'marketplace'; source: string }
11
12
 
12
13
  export type Reading =
@@ -70,11 +71,12 @@ function readShell(command: string): Reading {
70
71
  if (others > 0) return { kind: 'unreadable', label: only.label, reason: `the command runs more than the install; run ${only.label} as a command of its own, so approving it approves only the install` }
71
72
  // The guard cannot see the shell's current folder, so a source named
72
73
  // relative to it could be another folder than the one the guard scans.
73
- if (only.request.kind === 'marketplace' && !/^(?:https:\/\/|\/|~\/)/i.test(only.request.source)) {
74
+ const source = only.request.kind === 'marketplace' ? only.request.source : only.request.marketplaceSource
75
+ if (source !== undefined && !/^(?:https:\/\/|\/|~\/)/i.test(source)) {
74
76
  return {
75
77
  kind: 'unreadable',
76
78
  label: only.label,
77
- reason: `"${safeLine(only.request.source)}" could be a folder in the shell's current folder or a repository; name it as a full path or as https://github.com/<owner>/<repo>, so the guard scans what is added`,
79
+ reason: `"${safeLine(source)}" could be a folder in the shell's current folder or a repository; name it as a full path or as https://github.com/<owner>/<repo>, so the guard scans what is added`,
78
80
  }
79
81
  }
80
82
  return only
@@ -386,23 +388,22 @@ function programName(word: string | undefined): string {
386
388
  function readInstall(words: readonly string[], verb: 'install' | 'update', command: string): Reading {
387
389
  const options = readOptions(words, INSTALL_VALUES, INSTALL_FLAGS)
388
390
  const named = options.positionals[0]
389
- const label = `${command}${named === undefined ? '' : ` ${safeLine(named)}`}`
391
+ const marketplaceSource = options.values.get('--marketplace')
392
+ const label = `${command}${named === undefined ? '' : ` ${safeLine(named)}`}${marketplaceSource === undefined ? '' : ` --marketplace ${safeLine(marketplaceSource)}`}`
390
393
  if (options.unknown !== undefined) return { kind: 'unreadable', label, reason: `the guard does not know the option ${safeLine(options.unknown)}` }
391
394
  // With no plugin named, Claude Code shows its menu to pick one from.
392
395
  if (named === undefined) return verb === 'install' ? BROWSE : PASS
393
396
  if (options.positionals.length > 1) return { kind: 'unreadable', label, reason: 'it names more than one plugin; install one at a time' }
394
- if (options.values.has('--marketplace')) {
395
- return {
396
- kind: 'unreadable',
397
- label,
398
- reason: `it installs from a marketplace that is not added yet. Add it first with /plugin marketplace add ${safeLine(options.values.get('--marketplace') ?? '<source>')}, which the guard scans, then install ${safeLine(named)}@<marketplace>`,
399
- }
400
- }
401
397
  const id = PLUGIN_ID.exec(named)
402
398
  if (id === null) return { kind: 'unreadable', label, reason: `"${safeLine(named)}" is not a plugin name the guard can look up` }
403
399
  const plugin = id[1] as string
404
400
  const marketplace = id[2]
405
401
  if (options.values.has('--registry') || marketplace?.toLowerCase() === 'npm') return { kind: 'unreadable', label, reason: 'it installs a package from npm, which the guard cannot read yet' }
402
+ if (marketplaceSource !== undefined) {
403
+ // The plugin is looked up in the marketplace --marketplace adds, by its name alone.
404
+ if (marketplace !== undefined) return { kind: 'unreadable', label, reason: `with --marketplace, name the plugin alone (${safeLine(plugin)}), not as ${safeLine(named)}` }
405
+ return { kind: 'install', request: { kind: 'plugin', verb, plugin, marketplaceSource }, label }
406
+ }
406
407
  return { kind: 'install', request: { kind: 'plugin', verb, plugin, ...(marketplace === undefined ? {} : { marketplace }) }, label }
407
408
  }
408
409
 
@@ -4,7 +4,7 @@
4
4
  // the candidate runs.
5
5
 
6
6
  import type { Host } from '../scan/host'
7
- import { looksRemote, parseRemote, type RemoteSpec } from '../scan/remote'
7
+ import { looksRemote, parseRemote, resolvePin, type RemoteSpec } from '../scan/remote'
8
8
  import { safeLine } from '../scan/safe-text'
9
9
  import type { InstallRequest } from './requests'
10
10
 
@@ -33,6 +33,7 @@ export async function targetOf(host: Host, request: InstallRequest): Promise<Tar
33
33
  }
34
34
 
35
35
  async function pluginTarget(host: Host, request: Extract<InstallRequest, { kind: 'plugin' }>): Promise<Target> {
36
+ if (request.marketplaceSource !== undefined) return fromNewMarketplace(host, request.plugin, request.marketplaceSource)
36
37
  const listing = await claudeJson(host, ['plugin', 'list', '--available', '--json'], 'the plugin list')
37
38
  const available: Listed[] = isRecord(listing) && Array.isArray(listing.available) ? (listing.available as Listed[]) : []
38
39
  const installed = isRecord(listing) && Array.isArray(listing.installed) ? (listing.installed as { id?: unknown }[]) : []
@@ -112,7 +113,7 @@ async function sourceTarget(host: Host, entry: Listed, marketOf: () => Promise<M
112
113
  case 'github':
113
114
  return remote(text(source.repo) === undefined ? undefined : `https://github.com/${text(source.repo)}.git`, ref)
114
115
  default:
115
- return { kind: 'unverifiable', reason: `it installs from a ${safeLine(source.source)} source, which the guard cannot read yet` }
116
+ return { kind: 'unverifiable', reason: `it installs from ${/^(?:[aeiou]|npm)/i.test(source.source) ? 'an' : 'a'} ${safeLine(source.source)} source, which the guard cannot read yet` }
116
117
  }
117
118
  }
118
119
 
@@ -136,6 +137,66 @@ async function marketplaceTarget(host: Host, source: string): Promise<Target> {
136
137
  return { kind: 'remote', spec }
137
138
  }
138
139
 
140
+ /**
141
+ * A plugin from a marketplace `--marketplace` adds first: the marketplace's
142
+ * catalog is read at the commit that would be added (from GitHub, for a
143
+ * repository there) or from its folder, and the plugin's own entry names
144
+ * the folder or repository to scan.
145
+ */
146
+ async function fromNewMarketplace(host: Host, plugin: string, source: string): Promise<Target> {
147
+ const market = await marketplaceTarget(host, source)
148
+ if (market.kind !== 'remote' && market.kind !== 'local') return market.kind === 'browse' ? { kind: 'unverifiable', reason: 'it names no marketplace' } : market
149
+ if (market.kind === 'local') {
150
+ const entry = await catalogEntry(host, { name: safeLine(source), installLocation: market.path }, plugin)
151
+ if (entry === undefined) return { kind: 'unverifiable', reason: `${safeLine(plugin)} is not in the marketplace at ${safeLine(source)}` }
152
+ return sourceTarget(host, entry, async () => ({ name: safeLine(source), installLocation: market.path }))
153
+ }
154
+ // Only a GitHub marketplace's catalog can be read before it is added: anything else needs no lookup to say so.
155
+ if (githubRepo(market.spec.url) === undefined) return { kind: 'unverifiable', reason: NOT_ON_GITHUB }
156
+ const pin = await resolvePin(host, market.spec)
157
+ const catalog = await remoteCatalog(host, market.spec.url, pin.sha, pin.subdir)
158
+ if (typeof catalog === 'string') return { kind: 'unverifiable', reason: catalog }
159
+ const found = catalog.find(item => isRecord(item) && item.name === plugin)
160
+ if (!isRecord(found)) return { kind: 'unverifiable', reason: `${safeLine(plugin)} is not in the marketplace at ${safeLine(source)}` }
161
+ if (typeof found.source === 'string') {
162
+ // A folder of the marketplace's own repository, at the same commit.
163
+ if (!found.source.startsWith('./')) return { kind: 'unverifiable', reason: 'its source is not a folder inside its marketplace' }
164
+ const parts = [pin.subdir ?? '', found.source].join('/').split('/').filter(part => part !== '' && part !== '.')
165
+ if (parts.includes('..')) return { kind: 'unverifiable', reason: 'its source leads outside its marketplace' }
166
+ return { kind: 'remote', spec: { url: market.spec.url, ref: pin.sha, ...(parts.length === 0 ? {} : { subdir: parts.join('/') }) } }
167
+ }
168
+ return sourceTarget(host, { name: plugin, source: found.source }, async () => undefined)
169
+ }
170
+
171
+ /**
172
+ * A marketplace's catalog at one commit of its repository, read from GitHub;
173
+ * a string says why it could not be read. Other hosts are not read before
174
+ * the marketplace is added.
175
+ */
176
+ async function remoteCatalog(host: Host, url: string, sha: string, subdir: string | undefined): Promise<unknown[] | string> {
177
+ const repo = githubRepo(url)
178
+ if (repo === undefined) return NOT_ON_GITHUB
179
+ if (host.fetch === undefined) return 'the catalog of its marketplace cannot be read here'
180
+ const path = `${subdir === undefined ? '' : `${subdir}/`}.claude-plugin/marketplace.json`
181
+ const answer = await host.fetch(`https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${sha}/${path.split('/').map(encodeURIComponent).join('/')}`).catch(() => undefined)
182
+ if (answer === undefined || !answer.ok) return 'the catalog of its marketplace could not be read from GitHub'
183
+ if (answer.text.length > MAX_CATALOG_BYTES) return 'the catalog of its marketplace is too large to read'
184
+ try {
185
+ const parsed: unknown = JSON.parse(answer.text)
186
+ return isRecord(parsed) && Array.isArray(parsed.plugins) ? parsed.plugins : 'the catalog of its marketplace lists no plugins'
187
+ } catch {
188
+ return 'the catalog of its marketplace is not valid JSON'
189
+ }
190
+ }
191
+
192
+ const NOT_ON_GITHUB = 'its marketplace is not on GitHub, so its catalog cannot be read before it is added; add it first with /plugin marketplace add'
193
+
194
+ /** The owner and name of a GitHub repository's https address, as `parseRemote` writes it. */
195
+ function githubRepo(url: string): { owner: string; name: string } | undefined {
196
+ const match = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)\.git$/.exec(url)
197
+ return match === null ? undefined : { owner: match[1] as string, name: match[2] as string }
198
+ }
199
+
139
200
  async function marketplaceList(host: Host): Promise<Marketplace[]> {
140
201
  const listed = await claudeJson(host, ['plugin', 'marketplace', 'list', '--json'], 'the marketplace list')
141
202
  if (!Array.isArray(listed)) return []
@@ -185,17 +185,17 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
185
185
  const verdict = view.verdict
186
186
  return (
187
187
  <Box flexDirection="column" width={width}>
188
- <Text bold wrap="truncate-end">
188
+ <Text bold wrap="wrap">
189
189
  AI Install Guard · {view.target}
190
190
  </Text>
191
191
  {header !== '' && (
192
- <Text dimColor wrap="truncate-end">
192
+ <Text dimColor wrap="wrap">
193
193
  {header}
194
194
  </Text>
195
195
  )}
196
196
 
197
197
  {view.request !== undefined && (
198
- <Text dimColor wrap="truncate-end">
198
+ <Text dimColor wrap="wrap">
199
199
  {requestLine(view.request)}
200
200
  </Text>
201
201
  )}
@@ -210,7 +210,7 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
210
210
  {verdict.recommendation}
211
211
  </Text>
212
212
  {verdict.reasons.map(reason => (
213
- <Text wrap="truncate-end">
213
+ <Text wrap="wrap">
214
214
  {reason}
215
215
  </Text>
216
216
  ))}
@@ -223,7 +223,7 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
223
223
  </Text>
224
224
  )}
225
225
  {verdict !== undefined && view.rows.length > 0 && (
226
- <Text dimColor wrap="truncate-end">
226
+ <Text dimColor wrap="wrap">
227
227
  Quoted text comes from the scanned files and is untrusted.
228
228
  </Text>
229
229
  )}
@@ -92,21 +92,28 @@ export async function showUnverified($: EngineInterface, target: string, reason:
92
92
  const placed = await openPane($)
93
93
  const scanning = { ...withRequest(scanningView(target, options.action), options.request), scanId }
94
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)
95
+ // The banner already says Could not verify and the title names the target: the pane shows the reason itself.
96
+ const failed = await showFailure($, target, `Could not verify ${safeLine(target)}: ${safeLine(reason)}`, options, scanId, sentence(reason))
96
97
  return { view: failed.view, placed, superseded: !failed.shown }
97
98
  }
98
99
 
100
+ /** A reason as a sentence of its own: made safe, its first letter a capital. */
101
+ function sentence(reason: string): string {
102
+ const safe = safeLine(reason)
103
+ return safe.charAt(0).toUpperCase() + safe.slice(1)
104
+ }
105
+
99
106
  /**
100
107
  * The pane, and the report /guard report reopens, show a failure: never a
101
108
  * scanning pane, and never an older verdict in its place. Only while this
102
109
  * scan still owns the pane; `shown` says whether it did.
103
110
  */
104
- async function showFailure($: EngineInterface, target: string, reason: string, options: ShowOptions, scanId: string): Promise<{ view: GuardView; shown: boolean }> {
111
+ async function showFailure($: EngineInterface, target: string, reason: string, options: ShowOptions, scanId: string, shownReason = reason): Promise<{ view: GuardView; shown: boolean }> {
105
112
  const failed: GuardView = {
106
113
  ...withRequest(scanningView(target, options.action), options.request),
107
114
  scanId,
108
115
  stage: '',
109
- verdict: { kind: 'unverified', recommendation: 'Could not verify', reasons: [reason], counts: { pass: 0, warn: 0, fail: 0, info: 0 } },
116
+ verdict: { kind: 'unverified', recommendation: 'Could not verify', reasons: [shownReason], counts: { pass: 0, warn: 0, fail: 0, info: 0 } },
110
117
  }
111
118
  if (!(await owns($, scanId).catch(() => false))) return { view: failed, shown: false }
112
119
  await saveReport($, scanId, reason)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-install-guard",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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",