ai-install-guard 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +10 -2
- package/CHANGELOG.md +38 -0
- package/README.md +71 -10
- package/assets/reviewed-by-ai-install-guard-light@1x.png +0 -0
- package/hooks/guard/badge.ts +35 -0
- package/hooks/guard/decisions.ts +12 -3
- package/hooks/guard/pane.tsx +54 -7
- package/hooks/guard/view.ts +19 -1
- package/hooks/mod-host.ts +1 -0
- package/hooks/register.tsx +77 -18
- package/hooks/scan/checks/code.ts +110 -8
- package/hooks/scan/checks/mcp.ts +271 -0
- package/hooks/scan/checks/mod.ts +111 -27
- package/hooks/scan/checks/patterns.ts +4 -3
- package/hooks/scan/checks/programs.ts +150 -0
- package/hooks/scan/checks/prompts.ts +5 -2
- package/hooks/scan/checks/provenance.ts +190 -0
- package/hooks/scan/checks/structure.ts +92 -0
- package/hooks/scan/checks/types.ts +18 -2
- package/hooks/scan/checks/validator.ts +15 -1
- package/hooks/scan/github.ts +219 -0
- package/hooks/scan/host.ts +13 -0
- package/hooks/scan/lookups.ts +370 -0
- package/hooks/scan/remote.ts +406 -0
- package/hooks/scan/report.ts +12 -3
- package/hooks/scan/scan.ts +94 -8
- package/hooks/scan/verdict.ts +9 -3
- package/package.json +8 -3
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"name": "ai-install-guard",
|
|
12
12
|
"source": "./",
|
|
13
|
-
"version": "0.
|
|
13
|
+
"version": "0.2.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.
|
|
3
|
+
"version": "0.2.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,44 @@ All notable changes to ai-install-guard. Ordered newest to oldest.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## v0.2.0 · 2026-10-22
|
|
8
|
+
|
|
9
|
+
**`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
|
|
10
|
+
|
|
11
|
+
**`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
|
|
12
|
+
|
|
13
|
+
**`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
|
|
14
|
+
|
|
15
|
+
**`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
|
|
16
|
+
|
|
17
|
+
**`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
|
|
18
|
+
|
|
19
|
+
**`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
|
|
20
|
+
|
|
21
|
+
**`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
|
|
22
|
+
|
|
23
|
+
**`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
|
|
24
|
+
|
|
25
|
+
**`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
|
|
26
|
+
|
|
27
|
+
**`/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
|
|
28
|
+
|
|
29
|
+
**`README`** - The Reviewed by AI Install Guard mark heads the README
|
|
30
|
+
|
|
31
|
+
**`/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
|
|
32
|
+
|
|
33
|
+
**`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
|
|
34
|
+
|
|
35
|
+
**`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
|
|
36
|
+
|
|
37
|
+
**`structure`** - Flags Git LFS pointers and git filters, whose real content is filled in at install and could not be scanned
|
|
38
|
+
|
|
39
|
+
**`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
|
|
40
|
+
|
|
41
|
+
**`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
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
7
45
|
## v0.1.0 · 2026-10-22
|
|
8
46
|
|
|
9
47
|
**`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
|
|
21
|
+
> **Status: early development.** Today `/guard scan` runs the deterministic checks on a local folder, or on one commit of a repository it fetches without running anything, and shows the verdict in a pane, where you approve or reject that exact version. The install gates below are being built in stages; see [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
|
|
|
@@ -32,7 +34,7 @@ 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**, **
|
|
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.
|
|
@@ -51,12 +53,12 @@ Approvals are remembered by commit and content hash, so the same version is neve
|
|
|
51
53
|
|
|
52
54
|
| Threat | What gets flagged |
|
|
53
55
|
|--------|-------------------|
|
|
54
|
-
| Code execution | Shell hooks, MCP servers that
|
|
55
|
-
| Data theft | Reads of `~/.ssh`, `~/.aws`, `.env`, keychains or secret env vars, combined with network calls |
|
|
56
|
+
| Code execution | Shell hooks, MCP servers that download code or launch unpinned `npx` / `uvx` packages or container images, mods that run processes |
|
|
57
|
+
| 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
58
|
| Prompt injection | Hidden instructions in skills, commands, agents and tool descriptions, including invisible Unicode |
|
|
57
59
|
| Permission escalation | Mods that auto-approve tools, writes to Claude settings or permissions, skills granting broad `Bash` |
|
|
58
60
|
| Persistence | Writes to shell rc files, crontab, launchd, systemd, git hooks |
|
|
59
|
-
| Supply chain |
|
|
61
|
+
| 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
62
|
| Sneaky updates | An update that adds capabilities, hooks, domains or servers without saying so |
|
|
61
63
|
|
|
62
64
|
## Commands
|
|
@@ -64,26 +66,85 @@ Approvals are remembered by commit and content hash, so the same version is neve
|
|
|
64
66
|
| Command | What it does |
|
|
65
67
|
|---------|--------------|
|
|
66
68
|
| `/guard scan <path>` | Scan a local folder without installing it and open the verdict pane |
|
|
67
|
-
| `/guard scan
|
|
69
|
+
| `/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` |
|
|
70
|
+
| `/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 |
|
|
71
|
+
| `/guard scan --details …` | Any of the above, and print the full report with every finding and its advice |
|
|
68
72
|
| `/guard report` | Reopen the last report |
|
|
73
|
+
| `/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
74
|
| `/guard list` | Your approved and rejected versions, newest first |
|
|
70
75
|
| `/guard forget <name>` | Forget decisions about a name, so it is judged again |
|
|
71
76
|
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
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**.
|
|
80
|
+
|
|
72
81
|
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.
|
|
73
82
|
|
|
83
|
+
## Use it in Claude chat
|
|
84
|
+
|
|
85
|
+
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:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npm ci --ignore-scripts
|
|
89
|
+
npm run build:skill
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
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:
|
|
93
|
+
|
|
94
|
+
> Is this plugin safe to install?
|
|
95
|
+
|
|
96
|
+
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.
|
|
97
|
+
|
|
98
|
+
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**).
|
|
99
|
+
|
|
100
|
+
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.
|
|
101
|
+
|
|
102
|
+
## Review badge
|
|
103
|
+
|
|
104
|
+
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:
|
|
105
|
+
|
|
106
|
+
```markdown
|
|
107
|
+
[](https://github.com/ziniman/ai-install-guard#review-badge "Scanned at commit 4ac6edc…, sha256:…")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
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**.
|
|
111
|
+
|
|
112
|
+
A badge is a picture anyone can paste, so the guard never trusts one:
|
|
113
|
+
|
|
114
|
+
- **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.
|
|
115
|
+
- **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.
|
|
116
|
+
- **When the code changes:** the badge still names the old commit. A new version needs a new scan and a new badge.
|
|
117
|
+
|
|
74
118
|
## What the guard itself can do
|
|
75
119
|
|
|
76
120
|
A security tool should be held to its own rules, so here is everything this mod uses, as `claude plugin validate .` lists it:
|
|
77
121
|
|
|
78
122
|
| Capability | Why |
|
|
79
123
|
|------------|-----|
|
|
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 `~`.
|
|
81
|
-
| `fs.read`, `fs.list`, `fs.stat` | Reads the candidate's files to scan them. It
|
|
124
|
+
| `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), `readlink` to see where a symbolic link points without following it, and `printenv HOME` to expand `~`. It never runs anything from a candidate. |
|
|
125
|
+
| `fs.read`, `fs.list`, `fs.stat` | Reads the candidate's files to scan them. It writes no files itself; only `git` writes, into the guard's temporary folder. |
|
|
82
126
|
| `command.register` | Adds the `/guard` command. |
|
|
83
127
|
| `store`, `state` | Remembers your decisions and the last report, and holds what the pane shows. |
|
|
84
128
|
| `ui` | The verdict pane, its status line and toasts, and copying the report. |
|
|
85
129
|
|
|
86
|
-
|
|
130
|
+
| `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). |
|
|
131
|
+
|
|
132
|
+
A later stage adds, optionally, `model.complete` (a review that can only add warnings).
|
|
133
|
+
|
|
134
|
+
### Outside lookups
|
|
135
|
+
|
|
136
|
+
Off by default, because they send names to other services. Turned on, the guard checks:
|
|
137
|
+
|
|
138
|
+
- **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.
|
|
139
|
+
- **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.
|
|
140
|
+
|
|
141
|
+
Turn them on in `/config` (the AI Install Guard row), or with:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
claude plugin configure ai-install-guard
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
A lookup that fails or hits a rate limit is noted in the report and never fails the scan.
|
|
87
148
|
|
|
88
149
|
## What it is not
|
|
89
150
|
|
|
@@ -96,7 +157,7 @@ Later stages add `http.fetch` (public repository metadata) and, optionally, `mod
|
|
|
96
157
|
1. [x] Skeleton mod: `/guard scan <path>` lists and hashes a local folder
|
|
97
158
|
2. [x] Checks for structure, mod capabilities, classic hooks, skills, code patterns and committed secrets
|
|
98
159
|
3. [x] The verdict pane with Install and Reject
|
|
99
|
-
4. [
|
|
160
|
+
4. [x] Remote fetch pinned to a commit, repository provenance, MCP server checks, and optional lookups against OSV and the official plugin list
|
|
100
161
|
5. [ ] Gates on `/plugin` and on Claude's own install commands, with approvals
|
|
101
162
|
6. [ ] Module load gate with hash match, skill gate, session-start reconciliation
|
|
102
163
|
7. [ ] Update diffs and the optional model review
|
|
Binary file
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { GuardView } from '../../types'
|
|
2
|
+
|
|
3
|
+
/** Where the badge links: what it means and how to check it. */
|
|
4
|
+
export const BADGE_PAGE = 'https://github.com/ziniman/ai-install-guard#review-badge'
|
|
5
|
+
|
|
6
|
+
const COLOR = { safe: '2ea44f', caution: 'dbab09' } as const
|
|
7
|
+
const MESSAGE = { safe: 'safe to install', caution: 'review what it can do' } as const
|
|
8
|
+
|
|
9
|
+
/** A shields.io static badge part: dashes and underscores doubled, spaces as underscores. */
|
|
10
|
+
function shieldPart(text: string): string {
|
|
11
|
+
return encodeURIComponent(text.replace(/-/g, '--').replace(/_/g, '__').replace(/ /g, '_'))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The README badge for the last scan: its verdict and the exact version it
|
|
16
|
+
* covers. Offered for Safe to install and Review what it can do only, and
|
|
17
|
+
* only for a scan that read the files, so it always names what was scanned.
|
|
18
|
+
*/
|
|
19
|
+
export function badgeFor(view: GuardView | undefined): { markdown: string; pin: string } | { refusal: string } {
|
|
20
|
+
if (view?.verdict === undefined || view.treeHash === undefined) return { refusal: 'Scan something first: /guard scan <path | repository>.' }
|
|
21
|
+
const kind = view.verdict.kind
|
|
22
|
+
if (kind !== 'safe' && kind !== 'caution') return { refusal: `There is no badge for ${view.verdict.recommendation}.` }
|
|
23
|
+
// Only hex from the scan goes into the badge, never text from the candidate.
|
|
24
|
+
const sha = view.sha !== undefined && /^[0-9a-f]{40,64}$/.test(view.sha) ? view.sha : undefined
|
|
25
|
+
const tree = /^sha256:[0-9a-f]{64}$/.test(view.treeHash) ? view.treeHash : undefined
|
|
26
|
+
if (tree === undefined) return { refusal: 'The last scan has no tree hash to pin a badge to.' }
|
|
27
|
+
const pin = sha !== undefined ? sha.slice(0, 7) : `tree ${tree.slice(7, 15)}`
|
|
28
|
+
// A scan of one folder always says so: the rest of the commit was not
|
|
29
|
+
// judged. The view holds the folder percent-encoded, whole, never cut.
|
|
30
|
+
const folder = sha !== undefined ? view.subdir : undefined
|
|
31
|
+
if (folder !== undefined && !/^[\w./%-]{1,400}$/.test(folder)) return { refusal: 'The folder name is too long to show in a badge.' }
|
|
32
|
+
const image = `https://img.shields.io/badge/${shieldPart('AI Install Guard')}-${shieldPart(`${MESSAGE[kind]} · ${pin}`)}-${COLOR[kind]}`
|
|
33
|
+
const scanned = sha !== undefined ? `commit ${sha}${folder === undefined ? '' : `, folder ${folder}`}, ${tree}` : tree
|
|
34
|
+
return { markdown: `[](${BADGE_PAGE} "Scanned at ${scanned}")`, pin }
|
|
35
|
+
}
|
package/hooks/guard/decisions.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { safeLine } from '../scan/safe-text'
|
|
|
7
7
|
export type Decision = {
|
|
8
8
|
target: string
|
|
9
9
|
root?: string
|
|
10
|
+
/** The commit, for a remote source. */
|
|
11
|
+
sha?: string
|
|
10
12
|
treeHash: string
|
|
11
13
|
verdict: string
|
|
12
14
|
/** The verdict the decision was made on; an approval holds up to this one. */
|
|
@@ -31,8 +33,13 @@ export async function loadDecisions($: EngineInterface): Promise<Decision[]> {
|
|
|
31
33
|
return Array.isArray(stored) ? (stored as Decision[]) : []
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
/** One decision per version: the same files at the same commit (or as a folder). */
|
|
37
|
+
function sameVersion(a: { treeHash: string; sha?: string }, b: { treeHash: string; sha?: string }): boolean {
|
|
38
|
+
return a.treeHash === b.treeHash && (a.sha ?? '') === (b.sha ?? '')
|
|
39
|
+
}
|
|
40
|
+
|
|
34
41
|
export async function recordDecision($: EngineInterface, decision: Decision): Promise<void> {
|
|
35
|
-
const others = (await loadDecisions($)).filter(item => item
|
|
42
|
+
const others = (await loadDecisions($)).filter(item => !sameVersion(item, decision))
|
|
36
43
|
await $.store.set(DECISIONS, [decision, ...others].slice(0, MAX_DECISIONS))
|
|
37
44
|
}
|
|
38
45
|
|
|
@@ -65,8 +72,10 @@ export async function forgetDecisions($: EngineInterface, name: string): Promise
|
|
|
65
72
|
* one it was given on: the same files can judge worse in another place (a
|
|
66
73
|
* copy whose absolute link now leaves the folder), and that needs a new look.
|
|
67
74
|
*/
|
|
68
|
-
export async function decisionFor($: EngineInterface, treeHash: string, kind: GuardVerdict['kind']): Promise<Decision | undefined> {
|
|
69
|
-
|
|
75
|
+
export async function decisionFor($: EngineInterface, version: { treeHash: string; sha?: string }, kind: GuardVerdict['kind']): Promise<Decision | undefined> {
|
|
76
|
+
// A decision is about one version: another commit with the same files, or
|
|
77
|
+
// the same files as a local folder, is judged on its own.
|
|
78
|
+
const found = (await loadDecisions($)).find(item => sameVersion(item, version))
|
|
70
79
|
if (found === undefined || found.decision === 'rejected') return found
|
|
71
80
|
const approvedUpTo = SEVERITY[found.verdictKind] as number | undefined
|
|
72
81
|
return approvedUpTo !== undefined && SEVERITY[kind] <= approvedUpTo ? found : undefined
|
package/hooks/guard/pane.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import type { EngineInterface, RenderElement, RenderInput } from 'claude-code'
|
|
|
4
4
|
import type { GuardRow, GuardStatus, GuardView } from '../../types'
|
|
5
5
|
import { GROUPS } from '../scan/checks/types'
|
|
6
6
|
import { locate } from '../scan/verdict'
|
|
7
|
+
import { badgeFor } from './badge'
|
|
7
8
|
import { recordDecision, saveLastView } from './decisions'
|
|
8
9
|
import { needsConfirmation, PANE_ID, primaryLabel } from './view'
|
|
9
10
|
|
|
@@ -31,7 +32,11 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
|
|
|
31
32
|
)
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
const header = [
|
|
35
|
+
const header = [
|
|
36
|
+
view.kind === undefined ? undefined : `A ${view.kind}`,
|
|
37
|
+
view.sha === undefined ? undefined : `commit ${view.sha.slice(0, 12)}`,
|
|
38
|
+
view.treeHash === undefined ? undefined : `tree ${view.treeHash.slice(7, 19)}`,
|
|
39
|
+
]
|
|
35
40
|
.filter(part => part !== undefined)
|
|
36
41
|
.join(' · ')
|
|
37
42
|
|
|
@@ -41,6 +46,13 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
|
|
|
41
46
|
update($, viewAtom, current =>
|
|
42
47
|
current === null ? current : { ...current, expanded: current.expanded.includes(id) ? current.expanded.filter(item => item !== id) : [...current.expanded, id] },
|
|
43
48
|
)
|
|
49
|
+
const codeOpen = (row: GuardRow): boolean => (view.codeOpen ?? []).includes(row.id)
|
|
50
|
+
const toggleCode = (id: string) => () =>
|
|
51
|
+
update($, viewAtom, current => {
|
|
52
|
+
if (current === null) return current
|
|
53
|
+
const open = current.codeOpen ?? []
|
|
54
|
+
return { ...current, codeOpen: open.includes(id) ? open.filter(item => item !== id) : [...open, id] }
|
|
55
|
+
})
|
|
44
56
|
|
|
45
57
|
const decide = (decision: 'approved' | 'rejected', overridden: boolean) => async () => {
|
|
46
58
|
const current = await read($, viewAtom)
|
|
@@ -57,6 +69,7 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
|
|
|
57
69
|
await recordDecision($, {
|
|
58
70
|
target: current.target,
|
|
59
71
|
root: current.root,
|
|
72
|
+
sha: current.sha,
|
|
60
73
|
treeHash: current.treeHash,
|
|
61
74
|
verdict: current.verdict.recommendation,
|
|
62
75
|
verdictKind: current.verdict.kind,
|
|
@@ -87,6 +100,17 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
|
|
|
87
100
|
await decide('approved', false)()
|
|
88
101
|
}
|
|
89
102
|
|
|
103
|
+
const copyBadge = async () => {
|
|
104
|
+
const made = badgeFor(view)
|
|
105
|
+
if ('refusal' in made) {
|
|
106
|
+
$.ui.toast(`AI Install Guard: ${made.refusal}`)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
const result = await $.ui.copy({ text: made.markdown, surface: e.surface })
|
|
110
|
+
$.ui.toast(result.isCopied ? 'AI Install Guard: badge copied; paste it near the top of your README' : 'AI Install Guard: could not copy the badge here')
|
|
111
|
+
}
|
|
112
|
+
const offersBadge = view.treeHash !== undefined && (view.verdict?.kind === 'safe' || view.verdict?.kind === 'caution')
|
|
113
|
+
|
|
90
114
|
const copy = async () => {
|
|
91
115
|
const text = await $.store.get(REPORT_TEXT)
|
|
92
116
|
const result = await $.ui.copy({ text: typeof text === 'string' ? text : 'No report yet.', surface: e.surface })
|
|
@@ -138,7 +162,11 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
|
|
|
138
162
|
<Text bold>{group.title}</Text>
|
|
139
163
|
{rowsOf(group.id).map(row => {
|
|
140
164
|
const first = row.evidence[0]
|
|
141
|
-
const
|
|
165
|
+
const headline = row.summary?.[0]
|
|
166
|
+
const summary = headline !== undefined ? `: ${headline}` : first === undefined ? '' : `: ${locate(first.file, first.line)}${first.excerpt}`
|
|
167
|
+
const places = row.evidence.length + row.more
|
|
168
|
+
// With a summary, the code lines are one step further in.
|
|
169
|
+
const showCode = row.summary === undefined || codeOpen(row)
|
|
142
170
|
return (
|
|
143
171
|
<Box key={`row-${row.id}`} flexDirection="column">
|
|
144
172
|
<Button key={`toggle-${row.id}`} plain onPress={toggle(row.id)}>
|
|
@@ -147,18 +175,31 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
|
|
|
147
175
|
</Button>
|
|
148
176
|
{isOpen(row) && (
|
|
149
177
|
<Box key={`detail-${row.id}`} flexDirection="column" paddingLeft={2}>
|
|
150
|
-
{row.
|
|
151
|
-
<Text wrap="wrap"
|
|
152
|
-
{
|
|
153
|
-
{item.excerpt}
|
|
178
|
+
{row.summary?.map((line, index) => (
|
|
179
|
+
<Text wrap="wrap" bold={index === 0}>
|
|
180
|
+
{line}
|
|
154
181
|
</Text>
|
|
155
182
|
))}
|
|
156
|
-
{row.more > 0 && <Text dimColor>… and {row.more} more in the copied report</Text>}
|
|
157
183
|
{(row.status === 'fail' || row.status === 'warn') && (
|
|
158
184
|
<Text dimColor wrap="wrap">
|
|
159
185
|
{row.advice}
|
|
160
186
|
</Text>
|
|
161
187
|
)}
|
|
188
|
+
{row.summary !== undefined && places > 0 && (
|
|
189
|
+
<Button key={`code-${row.id}`} plain onPress={toggleCode(row.id)}>
|
|
190
|
+
<Text color="suggestion">
|
|
191
|
+
{codeOpen(row) ? 'Hide the code' : `Show the code (${places} place${places === 1 ? '' : 's'})`}
|
|
192
|
+
</Text>
|
|
193
|
+
</Button>
|
|
194
|
+
)}
|
|
195
|
+
{showCode &&
|
|
196
|
+
row.evidence.map(item => (
|
|
197
|
+
<Text wrap="wrap" color={item.status === undefined ? undefined : COLOR[item.status]}>
|
|
198
|
+
{locate(item.file, item.line)}
|
|
199
|
+
{item.excerpt}
|
|
200
|
+
</Text>
|
|
201
|
+
))}
|
|
202
|
+
{showCode && row.more > 0 && <Text dimColor>… and {row.more} more in the copied report</Text>}
|
|
162
203
|
</Box>
|
|
163
204
|
)}
|
|
164
205
|
</Box>
|
|
@@ -175,6 +216,7 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
|
|
|
175
216
|
<Button key="reject" label="Reject" hotkey="r" variant={needsConfirmation(view) ? 'primary' : 'secondary'} onPress={decide('rejected', false)} />
|
|
176
217
|
<Button key="details" label={view.details ? 'Fewer details' : 'Details'} hotkey="d" onPress={() => update($, viewAtom, current => (current === null ? current : { ...current, details: !current.details }))} />
|
|
177
218
|
<Button key="copy" label="Copy report" hotkey="c" onPress={copy} />
|
|
219
|
+
{offersBadge && <Button key="badge" label="Copy badge" hotkey="b" onPress={copyBadge} />}
|
|
178
220
|
</Box>
|
|
179
221
|
)}
|
|
180
222
|
|
|
@@ -196,6 +238,11 @@ export async function drawPane($: EngineInterface, e: PaneInput): Promise<Render
|
|
|
196
238
|
{view.decidedAt === undefined ? '' : ` on ${new Date(view.decidedAt).toISOString().slice(0, 10)}`}. The decision is saved for this exact version; /guard forget clears it.
|
|
197
239
|
</Text>
|
|
198
240
|
)}
|
|
241
|
+
{view.decision !== undefined && offersBadge && (
|
|
242
|
+
<Box key="after-decision" flexDirection="row" gap={1} marginTop={1}>
|
|
243
|
+
<Button key="badge" label="Copy badge" hotkey="b" onPress={copyBadge} />
|
|
244
|
+
</Box>
|
|
245
|
+
)}
|
|
199
246
|
</Box>
|
|
200
247
|
)
|
|
201
248
|
}
|
package/hooks/guard/view.ts
CHANGED
|
@@ -32,7 +32,15 @@ export function viewOf(report: ScanReport, action: GuardAction): GuardView {
|
|
|
32
32
|
},
|
|
33
33
|
rows: report.results.map(rowOf),
|
|
34
34
|
kind: report.kind,
|
|
35
|
-
|
|
35
|
+
// A remote scan's folder is a temporary one: its source names it instead.
|
|
36
|
+
root:
|
|
37
|
+
report.source !== undefined
|
|
38
|
+
? safeLine(`${report.source.url}${report.source.subdir === undefined ? '' : `#${report.source.subdir}`}`)
|
|
39
|
+
: report.inventory === undefined
|
|
40
|
+
? undefined
|
|
41
|
+
: safeLine(report.inventory.root),
|
|
42
|
+
sha: report.source?.sha,
|
|
43
|
+
...(report.source?.subdir === undefined ? {} : { subdir: encodeFolder(report.source.subdir) }),
|
|
36
44
|
treeHash: report.inventory?.treeHash,
|
|
37
45
|
expanded: [],
|
|
38
46
|
details: false,
|
|
@@ -54,6 +62,7 @@ function rowOf(result: CheckResult): GuardRow {
|
|
|
54
62
|
})),
|
|
55
63
|
more: Math.max(0, result.evidence.length - EVIDENCE_PER_ROW),
|
|
56
64
|
advice: result.advice,
|
|
65
|
+
...(result.summary === undefined || result.summary.length === 0 ? {} : { summary: result.summary.slice(0, 8).map(line => clip(safeLine(redactSecrets(line)))) }),
|
|
57
66
|
}
|
|
58
67
|
}
|
|
59
68
|
|
|
@@ -67,3 +76,12 @@ export function primaryLabel(view: GuardView): string {
|
|
|
67
76
|
const verb = view.action === 'install' ? 'Install' : 'Approve'
|
|
68
77
|
return needsConfirmation(view) ? `${verb} anyway` : verb
|
|
69
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A folder name as the pane and a badge show it: whole characters other than
|
|
82
|
+
* letters, digits and `_ . / -` percent-encoded, so the exact folder stays
|
|
83
|
+
* named and nothing in it can be read as markup or hide itself.
|
|
84
|
+
*/
|
|
85
|
+
export function encodeFolder(folder: string): string {
|
|
86
|
+
return folder.replace(/[^\w./-]/gu, char => [...new TextEncoder().encode(char)].map(byte => `%${byte.toString(16).toUpperCase().padStart(2, '0')}`).join(''))
|
|
87
|
+
}
|