ai-install-guard 0.0.0 → 0.1.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 +2 -1
- package/CHANGELOG.md +9 -1
- package/README.md +31 -5
- package/hooks/guard/decisions.ts +91 -0
- package/hooks/guard/pane.tsx +206 -0
- package/hooks/guard/view.ts +69 -0
- package/hooks/register.tsx +111 -20
- package/hooks/scan/checks/code.ts +536 -0
- package/hooks/scan/checks/context.ts +655 -0
- package/hooks/scan/checks/hooks.ts +156 -0
- package/hooks/scan/checks/magic.ts +40 -0
- package/hooks/scan/checks/mod.ts +290 -0
- package/hooks/scan/checks/patterns.ts +106 -0
- package/hooks/scan/checks/prompts.ts +316 -0
- package/hooks/scan/checks/quality.ts +56 -0
- package/hooks/scan/checks/secrets.ts +107 -0
- package/hooks/scan/checks/structure.ts +357 -0
- package/hooks/scan/checks/text.ts +362 -0
- package/hooks/scan/checks/types.ts +89 -0
- package/hooks/scan/checks/validator.ts +97 -0
- package/hooks/scan/report.ts +53 -31
- package/hooks/scan/scan.ts +274 -0
- package/hooks/scan/verdict.ts +58 -0
- package/package.json +1 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"name": "ai-install-guard",
|
|
12
12
|
"source": "./",
|
|
13
|
-
"version": "0.
|
|
13
|
+
"version": "0.1.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.1.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",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"homepage": "https://github.com/ziniman/ai-install-guard",
|
|
11
11
|
"repository": "https://github.com/ziniman/ai-install-guard",
|
|
12
12
|
"license": "Apache-2.0",
|
|
13
|
+
"types": "./types/index.d.ts",
|
|
13
14
|
"keywords": [
|
|
14
15
|
"security",
|
|
15
16
|
"supply-chain",
|
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,15 @@ All notable changes to ai-install-guard. Ordered newest to oldest.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## v0.1.0 · 2026-10-22
|
|
8
|
+
|
|
9
|
+
**`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
|
|
10
|
+
|
|
11
|
+
**`/guard report`, `/guard list`, `/guard forget`** - Reopen the last report, list your decisions, and forget the ones about a name
|
|
12
|
+
|
|
13
|
+
**`checks`** - Deterministic checks for structure and manifest (Claude Code's own validator, binaries and disguised files, symbolic links that leave the folder, data after an image's end, bundled permission settings), mod capabilities (every hooked event and `$` call in plain language, high-risk calls, capability mismatch), classic shell hooks and their scripts, skills, commands and agents (prompt injection, credential instructions, broad `allowed-tools`, remote instructions, look-alike names, text aimed at the scanner), code patterns (hidden Unicode with tag characters decoded, execution, obfuscation, credentials with network code, persistence, install scripts, registry redirects, network addresses) and committed secrets, each with a fixture and a test
|
|
14
|
+
|
|
15
|
+
**`/guard scan`** - Prints a verdict (Safe to install, Install with caution, Do not install, Could not verify), its top reasons and one line per check; `--details` adds every finding and its advice. Quoted text is escaped and marked as untrusted
|
|
8
16
|
|
|
9
17
|
**`/guard scan`** - Lists every file of a local folder, reads and hashes each one, records symbolic links with where they land without following them, and prints a SHA-256 tree hash that pins the exact content
|
|
10
18
|
|
package/README.md
CHANGED
|
@@ -16,12 +16,18 @@
|
|
|
16
16
|
|
|
17
17
|
</div>
|
|
18
18
|
|
|
19
|
-
> **Status: early development.** Today
|
|
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).
|
|
20
20
|
|
|
21
21
|
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
22
|
|
|
23
23
|
AI Install Guard is a Claude Code mod that stops every new plugin, mod, skill, MCP server or marketplace, and every update to one, scans it without running any of its code, and installs nothing until you approve it.
|
|
24
24
|
|
|
25
|
+
## Who it is for
|
|
26
|
+
|
|
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)).
|
|
28
|
+
|
|
29
|
+
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
|
+
|
|
25
31
|
## How it works
|
|
26
32
|
|
|
27
33
|
1. **Fetch without running.** The candidate is fetched at an exact commit into a folder the guard owns. Nothing is installed, built or run.
|
|
@@ -57,7 +63,27 @@ Approvals are remembered by commit and content hash, so the same version is neve
|
|
|
57
63
|
|
|
58
64
|
| Command | What it does |
|
|
59
65
|
|---------|--------------|
|
|
60
|
-
| `/guard scan <path>` | Scan a local folder without installing it |
|
|
66
|
+
| `/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 |
|
|
68
|
+
| `/guard report` | Reopen the last report |
|
|
69
|
+
| `/guard list` | Your approved and rejected versions, newest first |
|
|
70
|
+
| `/guard forget <name>` | Forget decisions about a name, so it is judged again |
|
|
71
|
+
|
|
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.
|
|
73
|
+
|
|
74
|
+
## What the guard itself can do
|
|
75
|
+
|
|
76
|
+
A security tool should be held to its own rules, so here is everything this mod uses, as `claude plugin validate .` lists it:
|
|
77
|
+
|
|
78
|
+
| Capability | Why |
|
|
79
|
+
|------------|-----|
|
|
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. |
|
|
82
|
+
| `command.register` | Adds the `/guard` command. |
|
|
83
|
+
| `store`, `state` | Remembers your decisions and the last report, and holds what the pane shows. |
|
|
84
|
+
| `ui` | The verdict pane, its status line and toasts, and copying the report. |
|
|
85
|
+
|
|
86
|
+
Later stages add `http.fetch` (public repository metadata) and, optionally, `model.complete` (a review that can only add warnings).
|
|
61
87
|
|
|
62
88
|
## What it is not
|
|
63
89
|
|
|
@@ -68,9 +94,9 @@ Approvals are remembered by commit and content hash, so the same version is neve
|
|
|
68
94
|
## Roadmap
|
|
69
95
|
|
|
70
96
|
1. [x] Skeleton mod: `/guard scan <path>` lists and hashes a local folder
|
|
71
|
-
2. [
|
|
72
|
-
3. [
|
|
73
|
-
4. [ ] Remote fetch pinned to a commit, provenance checks, MCP server checks
|
|
97
|
+
2. [x] Checks for structure, mod capabilities, classic hooks, skills, code patterns and committed secrets
|
|
98
|
+
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
|
|
74
100
|
5. [ ] Gates on `/plugin` and on Claude's own install commands, with approvals
|
|
75
101
|
6. [ ] Module load gate with hash match, skill gate, session-start reconciliation
|
|
76
102
|
7. [ ] Update diffs and the optional model review
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { EngineInterface } from 'claude-code'
|
|
2
|
+
|
|
3
|
+
import type { GuardVerdict, GuardView } from '../../types'
|
|
4
|
+
import { safeLine } from '../scan/safe-text'
|
|
5
|
+
|
|
6
|
+
/** One decision, remembered across sessions so the same tree is never judged twice. */
|
|
7
|
+
export type Decision = {
|
|
8
|
+
target: string
|
|
9
|
+
root?: string
|
|
10
|
+
treeHash: string
|
|
11
|
+
verdict: string
|
|
12
|
+
/** The verdict the decision was made on; an approval holds up to this one. */
|
|
13
|
+
verdictKind: GuardVerdict['kind']
|
|
14
|
+
decision: 'approved' | 'rejected'
|
|
15
|
+
/** Approved although the verdict was Do not install or Could not verify. */
|
|
16
|
+
overridden: boolean
|
|
17
|
+
decidedAt: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DECISIONS = 'decisions'
|
|
21
|
+
/** Room for the report text, well inside the store's 4 MiB in all. */
|
|
22
|
+
export const MAX_REPORT_CHARS = 1_000_000
|
|
23
|
+
const LAST_VIEW = 'last-view'
|
|
24
|
+
/** The store is capped at 4 MiB in all: keep the newest decisions only. */
|
|
25
|
+
const MAX_DECISIONS = 500
|
|
26
|
+
/** How serious each verdict is, to tell whether an approval still covers it. */
|
|
27
|
+
const SEVERITY: Record<GuardVerdict['kind'], number> = { safe: 0, caution: 1, unverified: 2, reject: 3 }
|
|
28
|
+
|
|
29
|
+
export async function loadDecisions($: EngineInterface): Promise<Decision[]> {
|
|
30
|
+
const stored = await $.store.get(DECISIONS)
|
|
31
|
+
return Array.isArray(stored) ? (stored as Decision[]) : []
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function recordDecision($: EngineInterface, decision: Decision): Promise<void> {
|
|
35
|
+
const others = (await loadDecisions($)).filter(item => item.treeHash !== decision.treeHash)
|
|
36
|
+
await $.store.set(DECISIONS, [decision, ...others].slice(0, MAX_DECISIONS))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Removes the decisions about `name`: its target as typed, its folder, or the
|
|
41
|
+
* last part of either. A name inside another (tool in my-tool-2) does not count.
|
|
42
|
+
*/
|
|
43
|
+
export async function forgetDecisions($: EngineInterface, name: string): Promise<Decision[]> {
|
|
44
|
+
const all = await loadDecisions($)
|
|
45
|
+
const last = (path: string): string => path.replace(/\/+$/, '').split('/').pop() ?? path
|
|
46
|
+
// Stored paths are display-safe; compare the name both as typed and made
|
|
47
|
+
// safe, without trailing slashes.
|
|
48
|
+
const wanted = new Set([name, safeLine(name)].map(value => value.replace(/\/+$/, '')))
|
|
49
|
+
const matches = (item: Decision): boolean =>
|
|
50
|
+
[item.target, item.root ?? '', last(item.target), last(item.root ?? '')].some(candidate => candidate !== '' && wanted.has(candidate.replace(/\/+$/, '')))
|
|
51
|
+
const kept = all.filter(item => !matches(item))
|
|
52
|
+
await $.store.set(DECISIONS, kept)
|
|
53
|
+
// The last report must not keep claiming a decision that is gone.
|
|
54
|
+
const forgotten = all.filter(matches)
|
|
55
|
+
const view = await loadLastView($)
|
|
56
|
+
if (view?.decision !== undefined && forgotten.some(item => item.treeHash === view.treeHash)) {
|
|
57
|
+
await saveLastView($, { ...view, decision: undefined, confirming: false })
|
|
58
|
+
}
|
|
59
|
+
return forgotten
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The decision that still holds for this tree at this verdict. A rejection
|
|
64
|
+
* always holds. An approval holds only while the verdict is no worse than the
|
|
65
|
+
* one it was given on: the same files can judge worse in another place (a
|
|
66
|
+
* copy whose absolute link now leaves the folder), and that needs a new look.
|
|
67
|
+
*/
|
|
68
|
+
export async function decisionFor($: EngineInterface, treeHash: string, kind: GuardVerdict['kind']): Promise<Decision | undefined> {
|
|
69
|
+
const found = (await loadDecisions($)).find(item => item.treeHash === treeHash)
|
|
70
|
+
if (found === undefined || found.decision === 'rejected') return found
|
|
71
|
+
const approvedUpTo = SEVERITY[found.verdictKind] as number | undefined
|
|
72
|
+
return approvedUpTo !== undefined && SEVERITY[kind] <= approvedUpTo ? found : undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Saves the view /guard report reopens. When it cannot be saved, the old one
|
|
77
|
+
* is removed, so a reopened report never shows an older verdict as current.
|
|
78
|
+
*/
|
|
79
|
+
export async function saveLastView($: EngineInterface, view: GuardView): Promise<void> {
|
|
80
|
+
try {
|
|
81
|
+
await $.store.set(LAST_VIEW, view)
|
|
82
|
+
} catch (error) {
|
|
83
|
+
await $.store.delete(LAST_VIEW).catch(() => undefined)
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function loadLastView($: EngineInterface): Promise<GuardView | undefined> {
|
|
89
|
+
const stored = await $.store.get(LAST_VIEW)
|
|
90
|
+
return stored !== null && typeof stored === 'object' ? (stored as GuardView) : undefined
|
|
91
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { atom, read, update } from 'claude-code'
|
|
2
|
+
import type { EngineInterface, RenderElement, RenderInput } from 'claude-code'
|
|
3
|
+
|
|
4
|
+
import type { GuardRow, GuardStatus, GuardView } from '../../types'
|
|
5
|
+
import { GROUPS } from '../scan/checks/types'
|
|
6
|
+
import { locate } from '../scan/verdict'
|
|
7
|
+
import { recordDecision, saveLastView } from './decisions'
|
|
8
|
+
import { needsConfirmation, PANE_ID, primaryLabel } from './view'
|
|
9
|
+
|
|
10
|
+
const viewAtom = atom({ plugin: 'ai-install-guard', key: 'view' } as const, null)
|
|
11
|
+
|
|
12
|
+
export const REPORT_TEXT = 'last-report-text'
|
|
13
|
+
|
|
14
|
+
const ICON: Record<GuardStatus, string> = { fail: '✖', warn: '⚠', pass: '✔', info: '·' }
|
|
15
|
+
const COLOR: Record<GuardStatus, string> = { fail: 'error', warn: 'warning', pass: 'success', info: 'subtle' }
|
|
16
|
+
const VERDICT_COLOR = { safe: 'success', caution: 'warning', reject: 'error', unverified: 'warning' } as const
|
|
17
|
+
|
|
18
|
+
type PaneInput = RenderInput<'Pane'>
|
|
19
|
+
|
|
20
|
+
/** The pane's tree for the scan in state, or a note when there is none. */
|
|
21
|
+
export async function drawPane($: EngineInterface, e: PaneInput): Promise<RenderElement> {
|
|
22
|
+
const { Box, Text, Button } = $.ui.resolve(e)
|
|
23
|
+
const view = await read($, viewAtom)
|
|
24
|
+
const width = Math.max(20, e.props.bodyColumns)
|
|
25
|
+
|
|
26
|
+
if (view === null) {
|
|
27
|
+
return (
|
|
28
|
+
<Box flexDirection="column" width={width}>
|
|
29
|
+
<Text dimColor>No scan yet. Run /guard scan <path>.</Text>
|
|
30
|
+
</Box>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const header = [view.kind === undefined ? undefined : `A ${view.kind}`, view.treeHash === undefined ? undefined : `tree ${view.treeHash.slice(7, 19)}`]
|
|
35
|
+
.filter(part => part !== undefined)
|
|
36
|
+
.join(' · ')
|
|
37
|
+
|
|
38
|
+
const rowsOf = (group: string): GuardRow[] => view.rows.filter(row => row.group === group)
|
|
39
|
+
const isOpen = (row: GuardRow): boolean => view.details || view.expanded.includes(row.id)
|
|
40
|
+
const toggle = (id: string) => () =>
|
|
41
|
+
update($, viewAtom, current =>
|
|
42
|
+
current === null ? current : { ...current, expanded: current.expanded.includes(id) ? current.expanded.filter(item => item !== id) : [...current.expanded, id] },
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const decide = (decision: 'approved' | 'rejected', overridden: boolean) => async () => {
|
|
46
|
+
const current = await read($, viewAtom)
|
|
47
|
+
if (current === null || current.verdict === undefined) return
|
|
48
|
+
// A scan that never read the files has no tree hash to pin a decision to:
|
|
49
|
+
// the pane closes, nothing is remembered, and nothing claims otherwise.
|
|
50
|
+
if (current.treeHash === undefined) {
|
|
51
|
+
$.ui.toast('AI Install Guard: closed; the scan read nothing, so there is no decision to remember')
|
|
52
|
+
await $.ui.close({ id: PANE_ID })
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
const decidedAt = Date.now()
|
|
56
|
+
try {
|
|
57
|
+
await recordDecision($, {
|
|
58
|
+
target: current.target,
|
|
59
|
+
root: current.root,
|
|
60
|
+
treeHash: current.treeHash,
|
|
61
|
+
verdict: current.verdict.recommendation,
|
|
62
|
+
verdictKind: current.verdict.kind,
|
|
63
|
+
decision,
|
|
64
|
+
overridden,
|
|
65
|
+
decidedAt,
|
|
66
|
+
})
|
|
67
|
+
} catch {
|
|
68
|
+
// Nothing was saved: say so, and leave the pane open to try again.
|
|
69
|
+
$.ui.toast('AI Install Guard: could not save the decision; nothing was recorded')
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
const decided = { ...current, decision, decidedAt, confirming: false }
|
|
73
|
+
await update($, viewAtom, () => decided)
|
|
74
|
+
// The reopened report shows the outcome, not the buttons again.
|
|
75
|
+
await saveLastView($, decided).catch(() => undefined)
|
|
76
|
+
$.ui.toast(`AI Install Guard: ${decision === 'approved' ? 'approved' : 'rejected'} ${current.target}`)
|
|
77
|
+
await $.ui.close({ id: PANE_ID })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const primary = async () => {
|
|
81
|
+
const current = await read($, viewAtom)
|
|
82
|
+
if (current === null) return
|
|
83
|
+
if (needsConfirmation(current)) {
|
|
84
|
+
await update($, viewAtom, latest => (latest === null ? latest : { ...latest, confirming: true }))
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
await decide('approved', false)()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const copy = async () => {
|
|
91
|
+
const text = await $.store.get(REPORT_TEXT)
|
|
92
|
+
const result = await $.ui.copy({ text: typeof text === 'string' ? text : 'No report yet.', surface: e.surface })
|
|
93
|
+
$.ui.toast(result.isCopied ? 'AI Install Guard: report copied' : 'AI Install Guard: could not copy the report here')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const verdict = view.verdict
|
|
97
|
+
return (
|
|
98
|
+
<Box flexDirection="column" width={width}>
|
|
99
|
+
<Text bold wrap="truncate-end">
|
|
100
|
+
AI Install Guard · {view.target}
|
|
101
|
+
</Text>
|
|
102
|
+
{header !== '' && (
|
|
103
|
+
<Text dimColor wrap="truncate-end">
|
|
104
|
+
{header}
|
|
105
|
+
</Text>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{verdict === undefined ? (
|
|
109
|
+
<Text color="suggestion" wrap="truncate-end">
|
|
110
|
+
Scanning: {view.stage}…
|
|
111
|
+
</Text>
|
|
112
|
+
) : (
|
|
113
|
+
<Box key="verdict" flexDirection="column" borderStyle="round" borderColor={VERDICT_COLOR[verdict.kind]} paddingX={1} marginTop={1}>
|
|
114
|
+
<Text bold color={VERDICT_COLOR[verdict.kind]}>
|
|
115
|
+
{verdict.recommendation}
|
|
116
|
+
</Text>
|
|
117
|
+
{verdict.reasons.map(reason => (
|
|
118
|
+
<Text wrap="truncate-end">
|
|
119
|
+
{reason}
|
|
120
|
+
</Text>
|
|
121
|
+
))}
|
|
122
|
+
</Box>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
{verdict !== undefined && (
|
|
126
|
+
<Text>
|
|
127
|
+
{verdict.counts.pass} passed · {verdict.counts.warn} warning{verdict.counts.warn === 1 ? '' : 's'} · {verdict.counts.fail} failed
|
|
128
|
+
</Text>
|
|
129
|
+
)}
|
|
130
|
+
{verdict !== undefined && view.rows.length > 0 && (
|
|
131
|
+
<Text dimColor wrap="truncate-end">
|
|
132
|
+
Quoted text comes from the scanned files and is untrusted.
|
|
133
|
+
</Text>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{GROUPS.filter(group => rowsOf(group.id).length > 0).map(group => (
|
|
137
|
+
<Box key={`group-${group.id}`} flexDirection="column" marginTop={1}>
|
|
138
|
+
<Text bold>{group.title}</Text>
|
|
139
|
+
{rowsOf(group.id).map(row => {
|
|
140
|
+
const first = row.evidence[0]
|
|
141
|
+
const summary = first === undefined ? '' : `: ${locate(first.file, first.line)}${first.excerpt}`
|
|
142
|
+
return (
|
|
143
|
+
<Box key={`row-${row.id}`} flexDirection="column">
|
|
144
|
+
<Button key={`toggle-${row.id}`} plain onPress={toggle(row.id)}>
|
|
145
|
+
<Text color={COLOR[row.status]}>{ICON[row.status]}</Text> {row.title}
|
|
146
|
+
<Text dimColor>{truncate(summary, width - row.title.length - 4)}</Text>
|
|
147
|
+
</Button>
|
|
148
|
+
{isOpen(row) && (
|
|
149
|
+
<Box key={`detail-${row.id}`} flexDirection="column" paddingLeft={2}>
|
|
150
|
+
{row.evidence.map(item => (
|
|
151
|
+
<Text wrap="wrap" color={item.status === undefined ? undefined : COLOR[item.status]}>
|
|
152
|
+
{locate(item.file, item.line)}
|
|
153
|
+
{item.excerpt}
|
|
154
|
+
</Text>
|
|
155
|
+
))}
|
|
156
|
+
{row.more > 0 && <Text dimColor>… and {row.more} more in the copied report</Text>}
|
|
157
|
+
{(row.status === 'fail' || row.status === 'warn') && (
|
|
158
|
+
<Text dimColor wrap="wrap">
|
|
159
|
+
{row.advice}
|
|
160
|
+
</Text>
|
|
161
|
+
)}
|
|
162
|
+
</Box>
|
|
163
|
+
)}
|
|
164
|
+
</Box>
|
|
165
|
+
)
|
|
166
|
+
})}
|
|
167
|
+
</Box>
|
|
168
|
+
))}
|
|
169
|
+
|
|
170
|
+
{verdict !== undefined && view.decision === undefined && !view.confirming && (
|
|
171
|
+
<Box key="actions" flexDirection="row" gap={1} marginTop={1}>
|
|
172
|
+
{view.treeHash !== undefined && (
|
|
173
|
+
<Button key="install" label={primaryLabel(view)} hotkey="i" variant={needsConfirmation(view) ? 'secondary' : 'primary'} onPress={primary} />
|
|
174
|
+
)}
|
|
175
|
+
<Button key="reject" label="Reject" hotkey="r" variant={needsConfirmation(view) ? 'primary' : 'secondary'} onPress={decide('rejected', false)} />
|
|
176
|
+
<Button key="details" label={view.details ? 'Fewer details' : 'Details'} hotkey="d" onPress={() => update($, viewAtom, current => (current === null ? current : { ...current, details: !current.details }))} />
|
|
177
|
+
<Button key="copy" label="Copy report" hotkey="c" onPress={copy} />
|
|
178
|
+
</Box>
|
|
179
|
+
)}
|
|
180
|
+
|
|
181
|
+
{verdict !== undefined && view.confirming && (
|
|
182
|
+
<Box key="confirm-row" flexDirection="column" marginTop={1}>
|
|
183
|
+
<Text color="warning" wrap="wrap">
|
|
184
|
+
The verdict is {verdict.recommendation}. {primaryLabel(view)}?
|
|
185
|
+
</Text>
|
|
186
|
+
<Box flexDirection="row" gap={1}>
|
|
187
|
+
<Button key="confirm" label={`Yes, ${primaryLabel(view).toLowerCase()}`} hotkey="y" onPress={decide('approved', true)} />
|
|
188
|
+
<Button key="cancel" label="Cancel" hotkey="n" variant="primary" onPress={() => update($, viewAtom, current => (current === null ? current : { ...current, confirming: false }))} />
|
|
189
|
+
</Box>
|
|
190
|
+
</Box>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
{view.decision !== undefined && (
|
|
194
|
+
<Text color={view.decision === 'approved' ? 'success' : 'error'}>
|
|
195
|
+
{view.decision === 'approved' ? 'Approved' : 'Rejected'}
|
|
196
|
+
{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
|
+
</Text>
|
|
198
|
+
)}
|
|
199
|
+
</Box>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function truncate(text: string, room: number): string {
|
|
204
|
+
if (room <= 1) return ''
|
|
205
|
+
return text.length <= room ? text : `${text.slice(0, Math.max(0, room - 1))}…`
|
|
206
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { GuardAction, GuardRow, GuardView } from '../../types'
|
|
2
|
+
import { redactSecrets } from '../scan/checks/patterns'
|
|
3
|
+
import type { CheckResult } from '../scan/checks/types'
|
|
4
|
+
import { safeLine } from '../scan/safe-text'
|
|
5
|
+
import type { ScanReport } from '../scan/scan'
|
|
6
|
+
|
|
7
|
+
/** Evidence the pane keeps per row; the full list stays in the stored report. */
|
|
8
|
+
const EVIDENCE_PER_ROW = 20
|
|
9
|
+
/** Characters the pane keeps per excerpt or reason: one huge hook command cannot fill the store. */
|
|
10
|
+
const MAX_TEXT = 500
|
|
11
|
+
|
|
12
|
+
function clip(text: string): string {
|
|
13
|
+
return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT - 1)}…` : text
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const PANE_ID = 'ai-install-guard'
|
|
17
|
+
|
|
18
|
+
export function scanningView(target: string, action: GuardAction): GuardView {
|
|
19
|
+
return { target: safeLine(target), action, stage: 'Starting', rows: [], expanded: [], details: false, confirming: false }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The pane's view of a finished scan: compact, every quoted text made safe. */
|
|
23
|
+
export function viewOf(report: ScanReport, action: GuardAction): GuardView {
|
|
24
|
+
return {
|
|
25
|
+
// The person's own words, but drawn beside an Approve button: made safe too.
|
|
26
|
+
target: safeLine(report.target),
|
|
27
|
+
action,
|
|
28
|
+
stage: '',
|
|
29
|
+
verdict: {
|
|
30
|
+
...report.verdict,
|
|
31
|
+
reasons: report.verdict.reasons.map(reason => clip(safeLine(redactSecrets(reason)))),
|
|
32
|
+
},
|
|
33
|
+
rows: report.results.map(rowOf),
|
|
34
|
+
kind: report.kind,
|
|
35
|
+
root: report.inventory === undefined ? undefined : safeLine(report.inventory.root),
|
|
36
|
+
treeHash: report.inventory?.treeHash,
|
|
37
|
+
expanded: [],
|
|
38
|
+
details: false,
|
|
39
|
+
confirming: false,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function rowOf(result: CheckResult): GuardRow {
|
|
44
|
+
return {
|
|
45
|
+
id: result.id,
|
|
46
|
+
group: result.group,
|
|
47
|
+
title: result.title,
|
|
48
|
+
status: result.status,
|
|
49
|
+
evidence: result.evidence.slice(0, EVIDENCE_PER_ROW).map(item => ({
|
|
50
|
+
file: clip(safeLine(redactSecrets(item.file))),
|
|
51
|
+
line: item.line,
|
|
52
|
+
excerpt: clip(safeLine(redactSecrets(item.excerpt))),
|
|
53
|
+
status: item.status,
|
|
54
|
+
})),
|
|
55
|
+
more: Math.max(0, result.evidence.length - EVIDENCE_PER_ROW),
|
|
56
|
+
advice: result.advice,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether the primary button needs a second, explicit confirmation: the verdict is Do not install or Could not verify. */
|
|
61
|
+
export function needsConfirmation(view: GuardView): boolean {
|
|
62
|
+
return view.verdict === undefined || view.verdict.kind === 'reject' || view.verdict.kind === 'unverified'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The primary button's label for this verdict. */
|
|
66
|
+
export function primaryLabel(view: GuardView): string {
|
|
67
|
+
const verb = view.action === 'install' ? 'Install' : 'Approve'
|
|
68
|
+
return needsConfirmation(view) ? `${verb} anyway` : verb
|
|
69
|
+
}
|
package/hooks/register.tsx
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
|
-
import type { Register } from 'claude-code'
|
|
1
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
2
|
+
import { atom, update } from 'claude-code'
|
|
2
3
|
|
|
4
|
+
import { decisionFor, forgetDecisions, loadDecisions, loadLastView, MAX_REPORT_CHARS, saveLastView } from './guard/decisions'
|
|
5
|
+
import { drawPane, REPORT_TEXT } from './guard/pane'
|
|
6
|
+
import { PANE_ID, scanningView, viewOf } from './guard/view'
|
|
3
7
|
import { modHost } from './mod-host'
|
|
4
8
|
import type { Host } from './scan/host'
|
|
5
|
-
import {
|
|
6
|
-
import { formatInventory } from './scan/report'
|
|
9
|
+
import { formatReport } from './scan/report'
|
|
7
10
|
import { safeLine } from './scan/safe-text'
|
|
11
|
+
import { scanFolder } from './scan/scan'
|
|
12
|
+
|
|
13
|
+
// Claude Code reads every state reference from the file that uses it, so each
|
|
14
|
+
// module declares its own atom for the pane's view.
|
|
15
|
+
const viewAtom = atom({ plugin: 'ai-install-guard', key: 'view' } as const, null)
|
|
8
16
|
|
|
9
17
|
const USAGE = [
|
|
10
18
|
'Usage:',
|
|
11
|
-
' /guard scan <path>
|
|
19
|
+
' /guard scan <path> Scan a local plugin, mod or skill folder without installing it',
|
|
20
|
+
' /guard scan --details <path> The same, with the full report as text',
|
|
21
|
+
' /guard report Reopen the last report',
|
|
22
|
+
' /guard list Your approved and rejected versions',
|
|
23
|
+
' /guard forget <name> Forget decisions about a name, so it is judged again',
|
|
12
24
|
].join('\n')
|
|
13
25
|
|
|
14
26
|
export const register: Register = on => {
|
|
@@ -16,34 +28,113 @@ export const register: Register = on => {
|
|
|
16
28
|
await $.command.register({
|
|
17
29
|
name: 'guard',
|
|
18
30
|
description: 'Scan a plugin, mod, skill or MCP server without installing it',
|
|
19
|
-
argumentHint: 'scan <path>',
|
|
31
|
+
argumentHint: 'scan <path> | report | list | forget <name>',
|
|
20
32
|
})
|
|
21
33
|
return next(e)
|
|
22
34
|
})
|
|
23
35
|
|
|
24
36
|
on('command.run', { command: 'guard' }, async ($, e) => {
|
|
25
|
-
const { sub, rest } = splitArgs(e.args)
|
|
26
|
-
if (sub
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return { text: formatInventory(rest, tree.inventory) }
|
|
33
|
-
} catch (error) {
|
|
34
|
-
return { text: `Could not verify ${safeLine(rest)}: ${safeLine(reasonOf(error))}` }
|
|
37
|
+
const { sub, rest, details } = splitArgs(e.args)
|
|
38
|
+
if (sub === 'scan' && rest !== '') return { text: await scan($, rest, details) }
|
|
39
|
+
if (sub === 'report') return { text: await reopen($) }
|
|
40
|
+
if (sub === 'list') return { text: await list($) }
|
|
41
|
+
if (sub === 'forget' && rest !== '') {
|
|
42
|
+
const count = (await forgetDecisions($, rest)).length
|
|
43
|
+
return { text: `AI Install Guard: forgot ${count} decision${count === 1 ? '' : 's'} about ${safeLine(rest)}.` }
|
|
35
44
|
}
|
|
45
|
+
return { text: USAGE }
|
|
36
46
|
}).catch(() => ({ text: 'AI Install Guard: the command failed before it could answer.' }))
|
|
47
|
+
|
|
48
|
+
on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e) => drawPane($, e))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Scans a folder with the pane showing progress and then the verdict. The
|
|
53
|
+
* command's own answer, which the model also reads, is one line: the quoted
|
|
54
|
+
* findings stay in the pane. Where no pane can show (a headless run), or with
|
|
55
|
+
* --details, the answer is the full text report.
|
|
56
|
+
*/
|
|
57
|
+
async function scan($: EngineInterface, target: string, details: boolean): Promise<string> {
|
|
58
|
+
const host = modHost($)
|
|
59
|
+
const opened = await $.ui.open({ id: PANE_ID, title: 'AI Install Guard', focus: true }).catch(() => ({ isPlaced: false }))
|
|
60
|
+
// Until this scan's report is stored, Copy report has nothing to copy, never
|
|
61
|
+
// the previous scan's report.
|
|
62
|
+
await $.store.delete(REPORT_TEXT).catch(() => undefined)
|
|
63
|
+
await update($, viewAtom, () => scanningView(target, 'approve'))
|
|
64
|
+
$.ui.status(`AI Install Guard: scanning ${safeLine(target)}`)
|
|
65
|
+
try {
|
|
66
|
+
const report = await scanFolder(host, target, await expandHome(host, target), {
|
|
67
|
+
// Stage names can carry a candidate's own words (a plugin's name).
|
|
68
|
+
onStage: stage => update($, viewAtom, current => (current === null ? current : { ...current, stage: safeLine(stage) })).then(() => undefined),
|
|
69
|
+
})
|
|
70
|
+
// A tree you already decided on shows that decision, not the buttons
|
|
71
|
+
// again, unless it now judges worse than when you approved it.
|
|
72
|
+
const earlier = report.inventory === undefined ? undefined : await decisionFor($, report.inventory.treeHash, report.verdict.kind).catch(() => undefined)
|
|
73
|
+
const view = earlier === undefined ? viewOf(report, 'approve') : { ...viewOf(report, 'approve'), decision: earlier.decision, decidedAt: earlier.decidedAt }
|
|
74
|
+
const text = formatReport(report, { details: true })
|
|
75
|
+
// The report text is stored before the pane shows the verdict, so Copy
|
|
76
|
+
// report always copies this scan's report. It is held well inside the
|
|
77
|
+
// store's limit; a failed write leaves Copy with nothing, not old text.
|
|
78
|
+
const stored = text.length > MAX_REPORT_CHARS ? `${text.slice(0, MAX_REPORT_CHARS)}\n… the report was cut here to fit the store` : text
|
|
79
|
+
await $.store.set(REPORT_TEXT, stored).catch(() => undefined)
|
|
80
|
+
await update($, viewAtom, () => view)
|
|
81
|
+
await saveLastView($, view).catch(() => undefined)
|
|
82
|
+
$.ui.toast(`AI Install Guard: ${report.verdict.recommendation} for ${safeLine(target)}`)
|
|
83
|
+
// Where no pane shows, the answer is the whole report: findings and advice.
|
|
84
|
+
if (!opened.isPlaced || details) return text
|
|
85
|
+
return `AI Install Guard: ${report.verdict.recommendation} for ${safeLine(target)}. The report is in the pane; /guard report reopens it.`
|
|
86
|
+
} catch (error) {
|
|
87
|
+
const reason = `Could not verify ${safeLine(target)}: ${safeLine(reasonOf(error))}`
|
|
88
|
+
await $.store.set(REPORT_TEXT, reason).catch(() => undefined)
|
|
89
|
+
// The pane, and the report /guard report reopens, show this failure: never
|
|
90
|
+
// a scanning pane, and never an older verdict in its place.
|
|
91
|
+
const failed = { ...scanningView(target, 'approve'), stage: '', verdict: { kind: 'unverified' as const, recommendation: 'Could not verify', reasons: [reason], counts: { pass: 0, warn: 0, fail: 0, info: 0 } } }
|
|
92
|
+
await update($, viewAtom, () => failed).catch(() => undefined)
|
|
93
|
+
await saveLastView($, failed).catch(() => undefined)
|
|
94
|
+
return reason
|
|
95
|
+
} finally {
|
|
96
|
+
$.ui.status(undefined)
|
|
97
|
+
}
|
|
37
98
|
}
|
|
38
99
|
|
|
39
|
-
|
|
40
|
-
|
|
100
|
+
async function reopen($: EngineInterface): Promise<string> {
|
|
101
|
+
const view = await loadLastView($)
|
|
102
|
+
if (view === undefined) return 'AI Install Guard: no report yet. Run /guard scan <path>.'
|
|
103
|
+
await update($, viewAtom, () => ({ ...view, confirming: false }))
|
|
104
|
+
const opened = await $.ui.open({ id: PANE_ID, title: 'AI Install Guard', focus: true }).catch(() => ({ isPlaced: false }))
|
|
105
|
+
if (opened.isPlaced) return `AI Install Guard: reopened the report for ${safeLine(view.target)}.`
|
|
106
|
+
const text = await $.store.get(REPORT_TEXT)
|
|
107
|
+
return typeof text === 'string' ? text : 'AI Install Guard: the last report is no longer stored.'
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function list($: EngineInterface): Promise<string> {
|
|
111
|
+
const decisions = await loadDecisions($)
|
|
112
|
+
if (decisions.length === 0) return 'AI Install Guard: no decisions yet.'
|
|
113
|
+
const lines = decisions.map(item => {
|
|
114
|
+
const when = new Date(item.decidedAt).toISOString().slice(0, 10)
|
|
115
|
+
const how = item.decision === 'approved' ? (item.overridden ? 'approved against the verdict' : 'approved') : 'rejected'
|
|
116
|
+
return ` ${when} ${how.padEnd(28)} ${safeLine(item.target)} (${item.verdict}, tree ${item.treeHash.slice(7, 19)})`
|
|
117
|
+
})
|
|
118
|
+
return ['AI Install Guard: your decisions, newest first', ...lines].join('\n')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `scan --details "my folder"` → `{ sub: 'scan', details: true, rest: 'my folder' }`.
|
|
123
|
+
* `--details` counts only as its own token right after the subcommand, so a
|
|
124
|
+
* quoted path that contains it is left whole.
|
|
125
|
+
*/
|
|
126
|
+
export function splitArgs(args: string): { sub: string; rest: string; details: boolean } {
|
|
41
127
|
const trimmed = args.trim()
|
|
42
128
|
const space = trimmed.search(/\s/)
|
|
43
129
|
const sub = space === -1 ? trimmed : trimmed.slice(0, space)
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
130
|
+
let rest = space === -1 ? '' : trimmed.slice(space).trim()
|
|
131
|
+
const details = /^--details(?:\s|$)/.test(rest)
|
|
132
|
+
if (details) rest = rest.slice('--details'.length).trim()
|
|
133
|
+
return { sub: sub.toLowerCase(), rest: unquote(rest), details }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function unquote(text: string): string {
|
|
137
|
+
return /^(["']).*\1$/.test(text) ? text.slice(1, -1) : text
|
|
47
138
|
}
|
|
48
139
|
|
|
49
140
|
async function expandHome(host: Host, path: string): Promise<string> {
|