ai-install-guard 0.3.1 → 0.3.2

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