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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +16 -0
- package/README.md +42 -4
- package/hooks/gate/gate.ts +31 -10
- package/hooks/gate/requests.ts +12 -11
- package/hooks/gate/resolve.ts +63 -2
- package/hooks/guard/pane.tsx +5 -5
- package/hooks/guard/run.ts +10 -3
- package/package.json +1 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"name": "ai-install-guard",
|
|
12
12
|
"source": "./",
|
|
13
|
-
"version": "0.3.
|
|
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.
|
|
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 · 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 · 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
|
|
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.
|
package/hooks/gate/gate.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
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`}
|
|
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:
|
|
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 {
|
package/hooks/gate/requests.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
package/hooks/gate/resolve.ts
CHANGED
|
@@ -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 []
|
package/hooks/guard/pane.tsx
CHANGED
|
@@ -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="
|
|
188
|
+
<Text bold wrap="wrap">
|
|
189
189
|
AI Install Guard · {view.target}
|
|
190
190
|
</Text>
|
|
191
191
|
{header !== '' && (
|
|
192
|
-
<Text dimColor wrap="
|
|
192
|
+
<Text dimColor wrap="wrap">
|
|
193
193
|
{header}
|
|
194
194
|
</Text>
|
|
195
195
|
)}
|
|
196
196
|
|
|
197
197
|
{view.request !== undefined && (
|
|
198
|
-
<Text dimColor wrap="
|
|
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="
|
|
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="
|
|
226
|
+
<Text dimColor wrap="wrap">
|
|
227
227
|
Quoted text comes from the scanned files and is untrusted.
|
|
228
228
|
</Text>
|
|
229
229
|
)}
|
package/hooks/guard/run.ts
CHANGED
|
@@ -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
|
-
|
|
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: [
|
|
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.
|
|
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",
|