ai-install-guard 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "name": "ai-install-guard",
12
12
  "source": "./",
13
- "version": "0.1.0",
13
+ "version": "0.3.0",
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.1.0",
3
+ "version": "0.3.0",
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",
@@ -19,5 +19,13 @@
19
19
  "skills",
20
20
  "mcp",
21
21
  "mods"
22
- ]
22
+ ],
23
+ "userConfig": {
24
+ "outsideLookups": {
25
+ "type": "boolean",
26
+ "title": "Outside lookups",
27
+ "description": "Check the packages a plugin uses against OSV, and its name against Anthropic's official plugins. Sends package and plugin names to those services. Off by default.",
28
+ "default": false
29
+ }
30
+ }
23
31
  }
package/CHANGELOG.md CHANGED
@@ -4,6 +4,56 @@ All notable changes to ai-install-guard. Ordered newest to oldest.
4
4
 
5
5
  ---
6
6
 
7
+ ## v0.3.0  ·  2026-10-23
8
+
9
+ **`/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
10
+
11
+ **`claude plugin`** - When Claude runs `claude plugin install`, `claude plugin update` or `claude plugin marketplace add` itself, including inside `sh -c`, `eval`, a here-document or a longer command line, the command is refused with a reason telling Claude the install waits for your decision, and the scan opens in the pane. Once you approve, the same command goes through. Approving it approves that whole command, so the install must stand on its own: one that also runs something else, or that is only known as it runs (a variable, a script piped into a shell, inline code for another language), is held as Could not verify; so is a shell or `eval` told to run a variable, whatever it holds. A marketplace Claude adds must be named by its full path or its https address, since the guard cannot see the shell's current folder. Every other command runs untouched, including ones that only print an install's words
12
+
13
+ **`/guard scan`** - The same scan and pane now serve `/guard scan` and the gates; a version approved through `/guard scan` counts when you install that same commit of that same folder
14
+
15
+ **`checks`** - A mod's `tool.call` or `tool.check` hook narrowed to one tool is described as that tool's ("sees every Bash call"), still as high risk, instead of as every tool call
16
+
17
+ ---
18
+
19
+ ## v0.2.0 &nbsp;·&nbsp; 2026-10-22
20
+
21
+ **`outsideLookups`** - Harder to fool: each package runner's own options are read (`-p` is a package to npx but a Python version to uvx), so `--with`, `--with-requirements` and file arguments no longer hide the main package; Python extras such as `httpx[cli]` are looked up; an advisory OSV answers with an empty or malformed record stays unresolved and a package with no usable answer is reported as not looked up; up to 300 advisories are read for malware instead of 30; every marketplace name is compared; and one scan asks for the official list and each advisory once, however many plugins it lists
22
+
23
+ **`provenance`** - A GitHub request that fails late is noted where it belongs and no longer hides the warnings earlier requests found; a listed plugin's worse finding now brings its own reason to the marketplace's row
24
+
25
+ **`skill`** - AI Install Guard for Claude chat: `npm run build:skill` bundles the scanner into an Agent Skill, `dist/ai-install-guard-skill.zip`, to upload to Claude. Attach a zip of a plugin, skill or MCP server, or paste a repository link, and Claude runs the same deterministic checks and presents the verdict, the plain-language summary and the evidence, treating the scanned files as untrusted data and never softening a failure. Where a chat's network settings block GitHub, it says so and asks for a zip
26
+
27
+ **`cli`** - `node scan.js <folder | zip | repository>` runs the scan outside Claude Code, with `--ref`, `--path`, `--details`, `--json` and `--lookups`, and exits with the verdict (0 to 3). A zip is unpacked by the scanner itself into its own temporary folder, which is removed afterwards. When it holds one folder, or one plugin or skill beside other files, that is what is scanned; several side by side need `--path`. Paths that would land outside the folder, special files, encrypted entries, names listed twice and anything over the size limits stop the scan as Could not verify, and links are recreated as links and judged, never followed. It starts only `git`, `sh`, `mktemp` and `rm` (PowerShell on Windows), without any `GIT_` variable from its own environment, and a command that prints more than 4 MiB fails the scan rather than being read in part
28
+
29
+ **`checks`** - A right-to-left plugin is no longer told Do not install just for defining a direction mark as a constant: a bidirectional control that is the whole of a one-line string, with nothing after it on its line but `;`, `,` or a comment, is a warning that names the escape to write instead, since it cannot reorder any code. Anywhere else, in a language the tokenizer does not know, and for tag characters, it still fails, and a file full of such constants cannot crowd out a real one
30
+
31
+ **`verdict`** - The middle verdict is now **Review what it can do**, formerly Install with caution. It means the candidate has powerful access worth knowing about, which a trustworthy tool may need too, not that it is a lesser kind of safe. The badge uses the new words, and a badge with the old words still counts as that verdict
32
+
33
+ **`pane`** - The mod capability rows now say in plain words what a mod can do and whether its README says why, which programs it starts (read from its code, with shells and other risky ones called out), and the README's own explanation, quoted and marked as the plugin's words
34
+
35
+ **`provenance`** - For a repository on GitHub, the report now says how old it and its owner's account are, its stars, forks and contributors, whether it is archived or a fork, and whether the scanned commit is signed and on the default branch. A brand-new account, a new repository with one author and few stars, and a commit named by id that is not on the default branch (it may come from a fork) are warnings; a lookup that fails is only noted
36
+
37
+ **`outsideLookups`** - A new setting, off by default. Turned on, the packages a plugin's MCP servers start or its manifests pin are checked against OSV, where malware (MAL ids, or advisories marked CWE-506) fails and a pinned version's known vulnerabilities warn, and its name is compared with Anthropic's official plugins, where a near miss warns
38
+
39
+ **`/guard badge`** - A README badge for plugin authors: after a scan that was Safe to install or Install with caution, `/guard badge` or Copy badge in the pane gives a shields.io badge naming the verdict and the exact commit or tree hash scanned, linked to how to check it. The guard never trusts a badge it meets: one that claims more than its own scan finds is a warning, and one that matches is only noted
40
+
41
+ **`README`** - The Reviewed by AI Install Guard mark heads the README
42
+
43
+ **`/guard scan <repository>`** - Scans one commit of a remote repository (`https://`, `github:owner/repo` or `owner/repo`, with `@ref`, `--ref` and `--path`). The ref is resolved with `git ls-remote`, only that commit is fetched into the guard's own temporary folder with no hooks, submodules, LFS downloads or filters, and the checkout must land on the pinned commit before anything is read; the folder is removed afterwards. Short commit ids, refs that name both a branch and a tag, and non-https sources are refused
44
+
45
+ **`provenance`** - New rows for a remote scan: the source and its pinned commit, branches or tags named like a commit id or `FETCH_HEAD` (the Plugin4Shell trick, a failure), and submodules whose code was not scanned. For a marketplace, each plugin it lists from elsewhere is checked for a full commit pin or an exact package version
46
+
47
+ **`mcp`** - MCP server checks: every server and what it runs or talks to, servers that download code or start unpinned packages or container images, inline shell scripts, remote and unencrypted servers, and API keys sent to a host that is not their own service
48
+
49
+ **`structure`** - Flags Git LFS pointers and git filters, whose real content is filled in at install and could not be scanned
50
+
51
+ **`pane`** - A row now says what it found in plain words before any code: which programs a candidate starts and what each is for, with shells, compilers and programs chosen at run time called out as worth a look, and which secrets or credential files its code reads. The code lines sit behind their own Show the code toggle, and the verdict's reasons use the same plain headline
52
+
53
+ **`checks`** - Fewer false alarms, found by scanning a real skill: a standalone skill is no longer failed for having no plugin manifest, a local preview address is not a remote instruction, a person called a reviewer is not a security scanner, a browser's program path is not its profile data, and one-word placeholder hosts are not listed
54
+
55
+ ---
56
+
7
57
  ## v0.1.0 &nbsp;·&nbsp; 2026-10-22
8
58
 
9
59
  **`pane`** - `/guard scan` opens a verdict pane: the recommendation and its reasons in a coloured banner, the counts, and every check grouped with failures first, each row opening to its evidence and advice. Approve and Reject record the decision against the exact tree hash; on Do not install or Could not verify the button reads Approve anyway and asks a second time. Details opens every row and Copy report copies the full text. Progress shows in the pane and the status line, and a toast says when the verdict is ready
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  <div align="center">
2
2
 
3
+ <img src="assets/reviewed-by-ai-install-guard-light@1x.png" alt="Reviewed by AI Install Guard" width="406" height="104">
4
+
3
5
  # AI Install Guard
4
6
 
5
7
  **A security gate for Claude Code. Nothing new gets installed until you have seen what it can do.**
@@ -16,7 +18,7 @@
16
18
 
17
19
  </div>
18
20
 
19
- > **Status: early development.** Today `/guard scan <path>` runs the deterministic checks on a local folder and shows the verdict in a pane, where you approve or reject that exact version. Remote fetching and the install gates below are being built in stages; see [Roadmap](#roadmap).
21
+ > **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).
20
22
 
21
23
  Everything you add to Claude Code runs with your permissions. A plugin can ship shell hooks that run every session, an MCP server can download and launch a package, a mod can read files or auto-approve tools, and a skill can carry hidden instructions the model will follow. The install prompt asks you to trust the author, and an update can change the files after you looked.
22
24
 
@@ -24,7 +26,7 @@ AI Install Guard is a Claude Code mod that stops every new plugin, mod, skill, M
24
26
 
25
27
  ## Who it is for
26
28
 
27
- 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. This release makes no network calls; later stages fetch the candidate itself, and any outside lookup stays optional and off by default. Today you run `/guard scan` on a folder yourself before installing it; the gates that hold an install automatically come in later stages (see [Roadmap](#roadmap)).
29
+ 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.
28
30
 
29
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.
30
32
 
@@ -32,31 +34,43 @@ Company security platforms such as AIR, Pluto and Neo watch a whole fleet of age
32
34
 
33
35
  1. **Fetch without running.** The candidate is fetched at an exact commit into a folder the guard owns. Nothing is installed, built or run.
34
36
  2. **Check every file.** Deterministic checks read the hooks, MCP servers, mod code, skills, scripts, images and manifest.
35
- 3. **Show a verdict.** A pane shows **Safe to install**, **Install with caution**, **Do not install** or **Could not verify**, a PASS / WARN / FAIL list with file and line evidence, and two buttons: **Install** and **Reject**.
37
+ 3. **Show a verdict.** A pane shows **Safe to install**, **Review what it can do**, **Do not install** or **Could not verify**, a PASS / WARN / FAIL list with file and line evidence, and two buttons: **Install** and **Reject**.
36
38
  4. **Install only what was scanned.** Install is pinned to the scanned commit and content hash. If the source changed since the scan, it is scanned again first.
37
39
 
38
40
  Approvals are remembered by commit and content hash, so the same version is never scanned twice, and an update is reviewed as a diff: anything new since the version you approved is listed and flagged. An optional model review can add warnings but can never clear a failure, so a README that says "this plugin is safe" changes nothing.
39
41
 
40
42
  ## Where it steps in
41
43
 
42
- | Layer | Catches | What happens |
43
- |-------|---------|--------------|
44
- | Slash command | `/plugin install`, `/plugin update`, `/plugin marketplace add` | Held until you press Install |
45
- | Claude's own tool calls | `claude plugin install`, `claude mcp add`, copies into skills folders, edits to `.mcp.json` or settings, `npx -y`, `curl \| sh` | Denied, with a note to the model that a scan is pending |
46
- | Mod loading | Any mod about to load, however it got there | Refused if its files do not match what you approved |
47
- | First use of a skill | Skills and commands, including ones copied in by hand | Blocked until scanned |
48
- | Session start | Anything installed while the guard was not running | Scanned, with Keep or Uninstall |
44
+ | Layer | Catches | What happens | Today |
45
+ |-------|---------|--------------|-------|
46
+ | Slash command | `/plugin install <name>@<marketplace>`, `/plugin marketplace add <source>` | Held until you press Install | Yes |
47
+ | 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
+ | 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
+ | 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 |
50
+ | First use of a skill | Skills and commands, including ones copied in by hand | Blocked until scanned | Later |
51
+ | Session start | Anything installed while the guard was not running | Scanned, with Keep or Uninstall | Later |
52
+
53
+ ### Installing with the guard on
54
+
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.
56
+
57
+ - **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
+ - **Reject** (`r`) records the rejection. The same version is refused from then on; `/guard forget notes@acme` judges it again.
59
+ - A version you already approved (the same commit of the same folder, here or with `/guard scan`) is scanned again and installs without asking, unless it now judges worse than when you approved it (an outside lookup turned on since may know it as malware). A version you rejected is refused at once. A new commit is always scanned.
60
+ - When the verdict is **Do not install**, the button reads **Install anyway** and asks a second time. **Could not verify** (the guard could not tell what would be installed: a package from npm, a plugin no marketplace lists, a name two marketplaces share) works the same way, and lets that one command through once.
61
+
62
+ When Claude runs `claude plugin install` itself, the guard refuses the command and tells Claude why: the install waits for your decision, and Claude should not retry it or find another way. Approve it in the pane, then tell Claude to run it again; the same command now goes through. Because approving it approves that whole command, the install must stand on its own (a `tail` after it is fine): a command that also runs something else, changes folder or writes a file, or that is only known as it runs (`$CMD plugin install …`, `bash -c "$CMD"`, a script piped into a shell, code for another language that mentions `claude`), is held as Could not verify. A marketplace Claude adds must be named by its full path or as `https://github.com/<owner>/<repo>`, since the guard cannot see the shell's current folder.
49
63
 
50
64
  ## What it checks
51
65
 
52
66
  | Threat | What gets flagged |
53
67
  |--------|-------------------|
54
- | Code execution | Shell hooks, MCP servers that launch binaries or unpinned `npx` / `uvx` packages, mods that run processes |
55
- | Data theft | Reads of `~/.ssh`, `~/.aws`, `.env`, keychains or secret env vars, combined with network calls |
68
+ | Code execution | Shell hooks, MCP servers that download code or launch unpinned `npx` / `uvx` packages or container images, mods that run processes |
69
+ | Data theft | Reads of `~/.ssh`, `~/.aws`, `.env`, keychains or secret env vars, combined with network calls; API keys an MCP server sends to a host that is not their own service |
56
70
  | Prompt injection | Hidden instructions in skills, commands, agents and tool descriptions, including invisible Unicode |
57
71
  | Permission escalation | Mods that auto-approve tools, writes to Claude settings or permissions, skills granting broad `Bash` |
58
72
  | Persistence | Writes to shell rc files, crontab, launchd, systemd, git hooks |
59
- | Supply chain | Marketplaces on a moving branch, brand-new repos, look-alike names, committed secrets, binaries, data hidden after the end of images |
73
+ | Supply chain | Brand-new repositories and accounts, commits that are not on the default branch (they may come from a fork), packages known to be malware or vulnerable (an optional lookup), names one letter away from an official plugin's (an optional lookup), marketplace plugins not pinned to a commit, branches named like a commit id or `FETCH_HEAD` (the Plugin4Shell trick), submodules and Git LFS files whose code is fetched at install, remote MCP servers, committed secrets, binaries, data hidden after the end of images |
60
74
  | Sneaky updates | An update that adds capabilities, hooks, domains or servers without saying so |
61
75
 
62
76
  ## Commands
@@ -64,12 +78,54 @@ Approvals are remembered by commit and content hash, so the same version is neve
64
78
  | Command | What it does |
65
79
  |---------|--------------|
66
80
  | `/guard scan <path>` | Scan a local folder without installing it and open the verdict pane |
67
- | `/guard scan --details <path>` | The same, and print the full report with every finding and its advice |
81
+ | `/guard scan <repository>` | Fetch one commit of a repository into the guard's own temporary folder, scan it and remove it. Takes `https://host/owner/repo`, a GitHub `…/tree/<ref>/<folder>` link, `github:owner/repo` or `owner/repo`, with an optional `@ref` |
82
+ | `/guard scan --ref <ref> --path <folder> <repository>` | Scan a branch, tag or full commit id (the default branch otherwise), and a folder inside the repository |
83
+ | `/guard scan --details …` | Any of the above, and print the full report with every finding and its advice |
68
84
  | `/guard report` | Reopen the last report |
85
+ | `/guard badge` | A README badge for the last scan, when it was Safe to install or Review what it can do (see [Review badge](#review-badge)) |
69
86
  | `/guard list` | Your approved and rejected versions, newest first |
70
87
  | `/guard forget <name>` | Forget decisions about a name, so it is judged again |
71
88
 
72
- In the pane, **Approve** (`i`) and **Reject** (`r`) record your decision for that exact content (its tree hash). When the verdict is Do not install or Could not verify, the button reads **Approve anyway** and asks a second time. **Details** (`d`) opens every row, and **Copy report** (`c`) copies the full report as text. Where no pane can show (a headless `claude -p` run), `/guard scan` prints the report instead.
89
+ A remote scan resolves the ref to one commit, fetches only that commit (no hooks, submodules, Git LFS downloads or filters), checks it out by its id and confirms that `HEAD` is that commit before reading a file. The report and the pane name the commit, and your decision is recorded against it and the tree hash.
90
+
91
+ Each row opens to say what it found in plain words first: which programs the candidate starts and what each is for, which secrets its code reads, and what is worth a closer look. The code lines that back it up sit behind **Show the code**.
92
+
93
+ In the pane, **Approve** (`i`) and **Reject** (`r`) record your decision for that exact content (its tree hash); for an install the guard is holding, the button reads **Install**. When the verdict is Do not install or Could not verify, the button reads **Approve anyway** (or **Install anyway**) and asks a second time. **Details** (`d`) opens every row, and **Copy report** (`c`) copies the full report as text. Where no pane can show (a headless `claude -p` run), `/guard scan` prints the report instead.
94
+
95
+ ## Use it in Claude chat
96
+
97
+ A chat cannot hold an install, but it can run the same scan before you install something elsewhere. The scanner also builds into an Agent Skill for Claude:
98
+
99
+ ```bash
100
+ npm ci --ignore-scripts
101
+ npm run build:skill
102
+ ```
103
+
104
+ This writes `dist/ai-install-guard-skill.zip`. Upload it in Claude under **Settings > Capabilities > Skills** (code execution must be on). Then, in any chat, attach a zip of a plugin, skill or MCP server, or paste a repository link, and ask:
105
+
106
+ > Is this plugin safe to install?
107
+
108
+ Claude runs the skill's `scripts/scan.js`: the same deterministic checks as `/guard scan`, without `claude plugin validate`, which a chat does not have. A zip is unpacked by the script itself into a temporary folder of its own, refusing any path that would land outside it, and a repository is fetched at one commit exactly as `/guard scan` fetches it. Claude answers with the verdict, each finding in plain words and the evidence behind it. The skill tells Claude that the scanned files are untrusted data, never instructions, and that a failure is never softened.
109
+
110
+ To fetch a repository, the chat's code execution must be allowed to reach `github.com` (and `api.github.com` for the repository's details) in its network settings. Where it is not, Claude says so and asks for a zip instead (on GitHub: **Code > Download ZIP**).
111
+
112
+ The same script runs anywhere with Node.js 18 or later: `node skill/ai-install-guard/scripts/scan.js --details <folder | zip | repository>`, with `--ref`, `--path`, `--json` and `--lookups`. Its exit status is the verdict: 0 Safe to install, 1 warnings to review, 2 Do not install, 3 Could not verify.
113
+
114
+ ## Review badge
115
+
116
+ If you publish a plugin, mod, skill or MCP server, you can show that it was scanned. Scan your own repository, then run `/guard badge` or press **Copy badge** (`b`) in the pane, and paste the result near the top of your README:
117
+
118
+ ```markdown
119
+ [![AI Install Guard: Safe to install at 4ac6edc](https://img.shields.io/badge/AI_Install_Guard-safe_to_install_%C2%B7_4ac6edc-2ea44f)](https://github.com/ziniman/ai-install-guard#review-badge "Scanned at commit 4ac6edc…, sha256:…")
120
+ ```
121
+
122
+ The badge says two things: the verdict (green for **Safe to install**, yellow for **Review what it can do**) and the exact version that was scanned, the commit or, for a local folder, the tree hash. Hovering over it shows the full commit and tree hash. There is no badge for **Do not install** or **Could not verify**.
123
+
124
+ A badge is a picture anyone can paste, so the guard never trusts one:
125
+
126
+ - **When you see one:** scan that version yourself with `/guard scan owner/repo --ref <commit>`, adding `--path <folder>` when the badge names a folder. Your own scan is what counts.
127
+ - **When the guard meets one:** a README badge that claims a better verdict than the guard's own scan is a warning, saying the badge is outdated or was never earned. A badge that matches is only noted. It never lifts a verdict.
128
+ - **When the code changes:** the badge still names the old commit. A new version needs a new scan and a new badge.
73
129
 
74
130
  ## What the guard itself can do
75
131
 
@@ -77,30 +133,53 @@ A security tool should be held to its own rules, so here is everything this mod
77
133
 
78
134
  | Capability | Why |
79
135
  |------------|-----|
80
- | `process.run` | Runs `claude plugin validate` on a candidate (it reads source and runs none of it), `readlink` to see where a symbolic link points without following it, and `printenv HOME` to expand `~`. Later stages add `git` to fetch candidates into the guard's own temporary folder. It never runs anything from a candidate. |
81
- | `fs.read`, `fs.list`, `fs.stat` | Reads the candidate's files to scan them. It never writes files. |
136
+ | `process.run` | Runs `git` to resolve a ref and fetch one commit into the guard's own temporary folder (made with `mktemp` and removed with `rm` afterwards), `claude plugin validate` on a candidate (it reads source and runs none of it), `claude plugin list --available --json` and `claude plugin marketplace list --json` to find where a plugin you install comes from, `readlink` to see where a symbolic link points without following it, and `printenv HOME` to expand `~`. It never runs anything from a candidate. |
137
+ | `fs.read`, `fs.list`, `fs.stat` | Reads the candidate's files to scan them, and a marketplace's catalog in Claude Code's own copy of it. It writes no files itself; only `git` writes, into the guard's temporary folder. |
138
+ | `command.run` (hook) | Intercepts `/plugin`, so `/plugin install` and `/plugin marketplace add` are scanned and held until you approve them. Every other `/plugin` command goes on untouched. |
139
+ | `command.run` (call) | When you press Install, runs your own `/plugin` command again, after checking the version is still the one you approved. |
140
+ | `tool.call` (hook) | Reads the text of each shell command Claude is about to run, to catch `claude plugin install` and its kin, and refuses those until you approve. Every other command and every other tool goes on untouched; it never rewrites or answers one. |
82
141
  | `command.register` | Adds the `/guard` command. |
83
- | `store`, `state` | Remembers your decisions and the last report, and holds what the pane shows. |
142
+ | `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. |
84
143
  | `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). |
145
+
146
+ A later stage adds, optionally, `model.complete` (a review that can only add warnings).
147
+
148
+ ### Outside lookups
149
+
150
+ Off by default, because they send names to other services. Turned on, the guard checks:
151
+
152
+ - **Packages:** every package an MCP server starts or the candidate's `package.json` or `requirements.txt` pins, against OSV. A package OSV lists as malware is a failure; a pinned version with known vulnerabilities is a warning.
153
+ - **Names:** its name against Anthropic's official plugins. A name one letter away from an official one is a warning; the same name is noted, so you can check it is that plugin.
154
+
155
+ Turn them on in `/config` (the AI Install Guard row), or with:
156
+
157
+ ```bash
158
+ claude plugin configure ai-install-guard
159
+ ```
85
160
 
86
- Later stages add `http.fetch` (public repository metadata) and, optionally, `model.complete` (a review that can only add warnings).
161
+ A lookup that fails or hits a rate limit is noted in the report and never fails the scan.
87
162
 
88
163
  ## What it is not
89
164
 
90
165
  - **Not a proof of safety.** Static analysis raises the bar and makes every capability visible; a determined attacker can still evade it.
91
166
  - **Install it first.** It judges only mods that load after it. For a company, the strongest setup is deploying it through managed settings so it loads first.
92
167
  - **No per-plugin block for classic shell hooks.** Claude Code runs them outside mods, so the guard scans before install and uninstalls on reject.
168
+ - **The `/plugin` menu is not gated yet.** A plugin picked from the menu installs without a scan; the guard says so when the menu opens. The mod loading gate in a later stage closes this. Until then, install with `/plugin install <name>@<marketplace>`.
169
+ - **Claude's commands are read, not run.** The guard finds an install in a shell command however it is quoted or wrapped (`env`, `sudo`, `sh -c`, `eval`, a here-document), and holds a command it cannot read (one built while it runs, a script piped into a shell, inline code for another language) as Could not verify. A script file that installs something is not read; the mod loading gate is the backstop for that.
170
+ - **Between Install and the install itself** Claude Code fetches the plugin again. The guard checks the commit just before, and a commit is fixed content, but a marketplace that names a branch rather than a commit can move in that moment. The scan warns about such marketplaces.
93
171
 
94
172
  ## Roadmap
95
173
 
96
174
  1. [x] Skeleton mod: `/guard scan <path>` lists and hashes a local folder
97
175
  2. [x] Checks for structure, mod capabilities, classic hooks, skills, code patterns and committed secrets
98
176
  3. [x] The verdict pane with Install and Reject
99
- 4. [ ] Remote fetch pinned to a commit, provenance checks (with an optional reputation lookup against outside catalogs and vulnerability databases), MCP server checks
100
- 5. [ ] Gates on `/plugin` and on Claude's own install commands, with approvals
101
- 6. [ ] Module load gate with hash match, skill gate, session-start reconciliation
102
- 7. [ ] Update diffs and the optional model review
103
- 8. [ ] Full documentation
177
+ 4. [x] Remote fetch pinned to a commit, repository provenance, MCP server checks, and optional lookups against OSV and the official plugin list
178
+ 5. [x] Gates on `/plugin install`, `/plugin marketplace add` and Claude's own `claude plugin` installs, with approvals pinned to a commit
179
+ 6. [ ] Gates on Claude adding MCP servers and writing into skill, plugin and settings folders
180
+ 7. [ ] Module load gate with hash match, skill gate, session-start reconciliation
181
+ 8. [ ] Update diffs and the optional model review
182
+ 9. [ ] Full documentation
104
183
 
105
184
  ## Contributing
106
185
 
@@ -0,0 +1,116 @@
1
+ // The install gate: an install asked for by `/plugin` or by a shell command
2
+ // Claude runs is let through only when the person already approved that exact
3
+ // version. Anything else is scanned and held in the pane, where Install
4
+ // carries it out.
5
+
6
+ import type { EngineInterface } from 'claude-code'
7
+
8
+ import type { GuardRequest } from '../../types'
9
+ import { pinnedDecision } from '../guard/decisions'
10
+ import { takePass } from '../guard/passes'
11
+ import { showScan, showUnverified, type Shown } from '../guard/run'
12
+ import { sourceRoot } from '../guard/view'
13
+ import { modHost } from '../mod-host'
14
+ import { resolvePin } from '../scan/remote'
15
+ import { safeLine } from '../scan/safe-text'
16
+ import type { InstallRequest, Reading } from './requests'
17
+ import { targetOf } from './resolve'
18
+
19
+ export type Judgement =
20
+ /** The install goes through; `note` says why, for a toast. */
21
+ | { kind: 'allow'; note?: string }
22
+ /** The install is held: `text` answers the command, or tells Claude why its command was refused. */
23
+ | { kind: 'hold'; text: string }
24
+
25
+ /** What the guard does with one install, asked for as `request` says. */
26
+ export async function judge($: EngineInterface, reading: Exclude<Reading, { kind: 'pass' }>, request: GuardRequest, lookups: boolean): Promise<Judgement> {
27
+ // Install anyway, confirmed, on something nothing could pin: this exact command, once.
28
+ if (await takePass($, { origin: request.origin, args: request.args })) {
29
+ return { kind: 'allow', note: `AI Install Guard: installing ${request.label}, as you allowed` }
30
+ }
31
+ const options = { action: 'install' as const, request, lookups }
32
+ if (reading.kind === 'unreadable') {
33
+ const shown = await showUnverified($, reading.label, reading.reason, options)
34
+ return shown.superseded ? superseded(request) : hold(request, 'Could not verify', '', shown.placed)
35
+ }
36
+ const name = nameOf(reading.request)
37
+ const host = modHost($)
38
+ const target = await targetOf(host, reading.request)
39
+ switch (target.kind) {
40
+ case 'browse':
41
+ return { kind: 'allow', note: BROWSE_NOTE }
42
+ case 'unverifiable': {
43
+ const shown = await showUnverified($, name, target.reason, options)
44
+ return shown.superseded ? superseded(request) : hold(request, 'Could not verify', '', shown.placed)
45
+ }
46
+ case 'remote': {
47
+ // The commit the install would take now. A rejection of it stands
48
+ // without a new scan; an approval is checked against a new one, which
49
+ // may judge worse (a lookup turned on since may know it as malware).
50
+ const pin = await resolvePin(host, target.spec).catch(() => undefined)
51
+ if (pin !== undefined) {
52
+ const decided = await pinnedDecision($, { root: sourceRoot(target.spec.url, pin.subdir), sha: pin.sha }).catch(() => undefined)
53
+ if (decided?.decision === 'rejected') return rejected(request, name, ` at commit ${pin.sha.slice(0, 12)}`, decided.decidedAt)
54
+ }
55
+ // The scan fetches exactly the commit just looked up, so the verdict is about what installs.
56
+ const spec = pin === undefined ? target.spec : { url: target.spec.url, ref: pin.sha, ...(pin.subdir === undefined ? {} : { subdir: pin.subdir }) }
57
+ return afterScan(request, name, await showScan($, name, { remote: spec }, options))
58
+ }
59
+ case 'local':
60
+ return afterScan(request, name, await showScan($, name, { folder: target.path }, options))
61
+ }
62
+ }
63
+
64
+ /** What the plugins menu cannot promise yet. */
65
+ export const BROWSE_NOTE = 'AI Install Guard: plugins picked from this menu are not scanned yet. To scan one first, run /plugin install <name>@<marketplace>'
66
+
67
+ /** The name a decision is kept under, so /guard forget finds it: the plugin as typed, or the marketplace's source. */
68
+ function nameOf(request: InstallRequest): string {
69
+ if (request.kind === 'marketplace') return safeLine(request.source)
70
+ return safeLine(request.marketplace === undefined ? request.plugin : `${request.plugin}@${request.marketplace}`)
71
+ }
72
+
73
+ 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)
75
+ const sha = shown.report.source?.sha
76
+ const where = sha === undefined ? '' : ` at commit ${sha.slice(0, 12)}`
77
+ // The same files approved before (through /guard scan, or an earlier install), and judged no
78
+ // worse now, need no second press.
79
+ 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
+ if (shown.view.decision === 'rejected') return rejected(request, name, where, shown.view.decidedAt)
81
+ if (shown.superseded) return superseded(request)
82
+ return hold(request, shown.report.verdict.recommendation, where, shown.placed, shown.text)
83
+ }
84
+
85
+ /** Another scan took the pane before this one finished: nothing to press, so nothing installs. */
86
+ function superseded(request: GuardRequest): Judgement {
87
+ const text = `AI Install Guard: another scan took the pane before the one for ${request.label} finished, so nothing was installed.`
88
+ return { kind: 'hold', text: request.origin === 'tool' ? `${text} Do not retry it now; tell the person, and run it again once they ask.` : `${text} Run it again to review it.` }
89
+ }
90
+
91
+ function rejected(request: GuardRequest, name: string, where: string, decidedAt: number | undefined): Judgement {
92
+ const when = decidedAt === undefined ? '' : ` on ${day(decidedAt)}`
93
+ if (request.origin === 'tool') {
94
+ return { kind: 'hold', text: `AI Install Guard: the person rejected this exact version of ${name}${where}${when}. Nothing was installed. Do not install it or try another way.` }
95
+ }
96
+ return { kind: 'hold', text: `AI Install Guard: you rejected ${name}${where}${when}. Nothing was installed. /guard forget ${name} judges it again.` }
97
+ }
98
+
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}`
102
+ if (request.origin === 'tool') {
103
+ const next = placed
104
+ ? '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
+ : '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}` }
107
+ }
108
+ 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.` }
110
+ }
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.` }
112
+ }
113
+
114
+ function day(time: number): string {
115
+ return new Date(time).toISOString().slice(0, 10)
116
+ }
@@ -0,0 +1,33 @@
1
+ // The last look before a held install runs: the version it would install now
2
+ // must still be the one the person approved. A plugin's own `$.command.run`
3
+ // does not pass through its own gate, so Install asks this itself.
4
+
5
+ import type { GuardView } from '../../types'
6
+ import { sourceRoot } from '../guard/view'
7
+ import type { Host } from '../scan/host'
8
+ import { takeInventory } from '../scan/inventory'
9
+ import { resolvePin } from '../scan/remote'
10
+ import { readPluginArgs } from './requests'
11
+ import { targetOf } from './resolve'
12
+
13
+ /**
14
+ * Undefined when the held install would still install what was scanned;
15
+ * otherwise why not. Something nothing could pin (Could not verify) has no
16
+ * version to compare, and the person allowed it as it is.
17
+ */
18
+ export async function changedSinceScan(host: Host, view: GuardView): Promise<string | undefined> {
19
+ if (view.treeHash === undefined || view.request === undefined) return undefined
20
+ const reading = readPluginArgs(view.request.args)
21
+ if (reading.kind !== 'install') return 'the install no longer reads as one the guard can check'
22
+ const target = await targetOf(host, reading.request)
23
+ if (target.kind === 'remote') {
24
+ const pin = await resolvePin(host, target.spec)
25
+ if (view.sha !== pin.sha || view.root !== sourceRoot(target.spec.url, pin.subdir)) return `it now points at commit ${pin.sha.slice(0, 12)}, not the one scanned`
26
+ return undefined
27
+ }
28
+ if (target.kind === 'local') {
29
+ const { inventory } = await takeInventory(host, target.path)
30
+ return inventory.treeHash === view.treeHash ? undefined : 'its files changed since the scan'
31
+ }
32
+ return target.kind === 'unverifiable' ? target.reason : 'it no longer names one plugin'
33
+ }