ai-install-guard 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,18 @@
1
1
  import type { EngineInterface, Register } from 'claude-code'
2
2
  import { atom, update } from 'claude-code'
3
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'
4
+ import { BROWSE_NOTE, judge } from './gate/gate'
5
+ import { readPluginArgs, readShellCommand } from './gate/requests'
6
+ import { expandHome } from './gate/resolve'
7
+ import { badgeFor } from './guard/badge'
8
+ import { forgetDecisions, loadDecisions, loadLastView, loadReport } from './guard/decisions'
9
+ import { drawPane } from './guard/pane'
10
+ import { showScan } from './guard/run'
11
+ import { PANE_ID } from './guard/view'
7
12
  import { modHost } from './mod-host'
8
13
  import type { Host } from './scan/host'
9
- import { formatReport } from './scan/report'
14
+ import { looksRemote, parseRemote, type RemoteSpec } from './scan/remote'
10
15
  import { safeLine } from './scan/safe-text'
11
- import { scanFolder } from './scan/scan'
12
16
 
13
17
  // Claude Code reads every state reference from the file that uses it, so each
14
18
  // module declares its own atom for the pane's view.
@@ -17,26 +21,36 @@ const viewAtom = atom({ plugin: 'ai-install-guard', key: 'view' } as const, null
17
21
  const USAGE = [
18
22
  'Usage:',
19
23
  ' /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',
24
+ ' /guard scan <repository> Fetch one commit of a repository and scan it: https://host/owner/repo,',
25
+ ' github:owner/repo or owner/repo, with an optional @ref',
26
+ ' --ref <branch|tag|commit> The commit to scan (the default branch otherwise)',
27
+ ' --path <folder> A folder inside the repository',
28
+ ' --details The full report as text',
21
29
  ' /guard report Reopen the last report',
30
+ ' /guard badge A README badge for the last scan (Safe to install or Review what it can do)',
22
31
  ' /guard list Your approved and rejected versions',
23
32
  ' /guard forget <name> Forget decisions about a name, so it is judged again',
24
33
  ].join('\n')
25
34
 
26
- export const register: Register = on => {
35
+ export const register: Register = (on, options) => {
36
+ // Off unless you turn it on: the lookups send package and plugin names out.
37
+ const lookups = options.outsideLookups === true
38
+
27
39
  on('session.start', async ($, e, next) => {
28
40
  await $.command.register({
29
41
  name: 'guard',
30
42
  description: 'Scan a plugin, mod, skill or MCP server without installing it',
31
- argumentHint: 'scan <path> | report | list | forget <name>',
43
+ argumentHint: 'scan <path | repository> | report | badge | list | forget <name>',
32
44
  })
33
45
  return next(e)
34
46
  })
35
47
 
36
48
  on('command.run', { command: 'guard' }, async ($, e) => {
37
- const { sub, rest, details } = splitArgs(e.args)
38
- if (sub === 'scan' && rest !== '') return { text: await scan($, rest, details) }
49
+ const { sub, rest, details, ref, path, error } = splitArgs(e.args)
50
+ if (error !== undefined) return { text: `AI Install Guard: ${error}\n\n${USAGE}` }
51
+ if (sub === 'scan' && rest !== '') return { text: await scan($, rest, details, { ref, path }, lookups) }
39
52
  if (sub === 'report') return { text: await reopen($) }
53
+ if (sub === 'badge') return { text: await badge($) }
40
54
  if (sub === 'list') return { text: await list($) }
41
55
  if (sub === 'forget' && rest !== '') {
42
56
  const count = (await forgetDecisions($, rest)).length
@@ -45,6 +59,33 @@ export const register: Register = on => {
45
59
  return { text: USAGE }
46
60
  }).catch(() => ({ text: 'AI Install Guard: the command failed before it could answer.' }))
47
61
 
62
+ // Gate A: the person's own /plugin install and /plugin marketplace add. An
63
+ // install is answered in the command's place, so nothing installs, until
64
+ // the person approves that exact version.
65
+ on('command.run', { command: 'plugin' }, async ($, e, next) => {
66
+ const reading = readPluginArgs(e.args)
67
+ if (reading.kind === 'pass') {
68
+ if (reading.browse) $.ui.toast(BROWSE_NOTE)
69
+ return next(e)
70
+ }
71
+ const judged = await judge($, reading, { origin: 'command', args: e.args, label: reading.label }, lookups)
72
+ if (judged.kind === 'hold') return { text: judged.text }
73
+ if (judged.note !== undefined) $.ui.toast(judged.note)
74
+ return next(e)
75
+ }).catch(($, e, next) => (next.called ? next(e) : { text: 'AI Install Guard stopped this install: it failed before it could check it, so nothing was installed.' }))
76
+
77
+ // Gate B: Claude installing a plugin or adding a marketplace with the claude
78
+ // command. The command is refused, with a reason Claude reads, until the
79
+ // person approves that exact version in the pane.
80
+ on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
81
+ const reading = readShellCommand(e.command)
82
+ if (reading.kind === 'pass') return next(e)
83
+ const judged = await judge($, reading, { origin: 'tool', args: e.command, label: reading.label }, lookups)
84
+ if (judged.kind === 'hold') return { deny: judged.text }
85
+ if (judged.note !== undefined) $.ui.toast(judged.note)
86
+ return next(e)
87
+ }).catch(($, e, next) => (next.called ? next(e) : { deny: 'AI Install Guard stopped this command: it may install a plugin or add a marketplace, and the guard failed before it could check it. Nothing was installed.' }))
88
+
48
89
  on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e) => drawPane($, e))
49
90
  }
50
91
 
@@ -54,57 +95,43 @@ export const register: Register = on => {
54
95
  * findings stay in the pane. Where no pane can show (a headless run), or with
55
96
  * --details, the answer is the full text report.
56
97
  */
57
- async function scan($: EngineInterface, target: string, details: boolean): Promise<string> {
98
+ async function scan($: EngineInterface, target: string, details: boolean, flags: { ref?: string; path?: string }, lookups: boolean): Promise<string> {
58
99
  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
- }
100
+ const shown = await showScan(
101
+ $,
102
+ target,
103
+ async () => {
104
+ const spec = await remoteSpecFor(host, target, flags)
105
+ return spec === undefined ? { folder: await expandHome(host, target) } : { remote: spec }
106
+ },
107
+ { action: 'approve', lookups },
108
+ )
109
+ if (shown.kind === 'failed') return shown.reason
110
+ // Where no pane shows, the answer is the whole report: findings and advice.
111
+ if (!shown.placed || details) return shown.text
112
+ return `AI Install Guard: ${shown.report.verdict.recommendation} for ${safeLine(target)}. The report is in the pane; /guard report reopens it.`
98
113
  }
99
114
 
100
115
  async function reopen($: EngineInterface): Promise<string> {
101
116
  const view = await loadLastView($)
102
117
  if (view === undefined) return 'AI Install Guard: no report yet. Run /guard scan <path>.'
103
- await update($, viewAtom, () => ({ ...view, confirming: false }))
118
+ await update($, viewAtom, () => ({ ...view, confirming: false, deciding: false }))
104
119
  const opened = await $.ui.open({ id: PANE_ID, title: 'AI Install Guard', focus: true }).catch(() => ({ isPlaced: false }))
105
120
  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.'
121
+ return (await loadReport($, view.scanId)) ?? 'AI Install Guard: the last report is no longer stored.'
122
+ }
123
+
124
+ async function badge($: EngineInterface): Promise<string> {
125
+ const view = await loadLastView($)
126
+ const made = badgeFor(view)
127
+ if ('refusal' in made) return `AI Install Guard: ${made.refusal}`
128
+ return [
129
+ `AI Install Guard: a README badge for ${view?.target ?? 'the last scan'} at ${made.pin}. Paste it near the top of your README:`,
130
+ '',
131
+ made.markdown,
132
+ '',
133
+ 'It names the exact version that was scanned. A change to your code needs a new scan and a new badge; the guard checks a badge against its own scan and never trusts it.',
134
+ ].join('\n')
108
135
  }
109
136
 
110
137
  async function list($: EngineInterface): Promise<string> {
@@ -119,36 +146,51 @@ async function list($: EngineInterface): Promise<string> {
119
146
  }
120
147
 
121
148
  /**
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.
149
+ * `scan --details --ref v1 "my folder"` → `{ sub: 'scan', details: true, ref: 'v1', rest: 'my folder' }`.
150
+ * Options count only as their own tokens right after the subcommand, so a
151
+ * quoted path that contains one is left whole.
125
152
  */
126
- export function splitArgs(args: string): { sub: string; rest: string; details: boolean } {
153
+ export function splitArgs(args: string): { sub: string; rest: string; details: boolean; ref?: string; path?: string; error?: string } {
127
154
  const trimmed = args.trim()
128
155
  const space = trimmed.search(/\s/)
129
156
  const sub = space === -1 ? trimmed : trimmed.slice(0, space)
130
157
  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
158
+ let details = false
159
+ let ref: string | undefined
160
+ let path: string | undefined
161
+ for (;;) {
162
+ const option = /^(--details|--ref|--path)(?:\s+|$)/.exec(rest)
163
+ if (option === null) break
164
+ rest = rest.slice(option[0].length)
165
+ if (option[1] === '--details') {
166
+ details = true
167
+ continue
168
+ }
169
+ const value = /^("[^"]*"|'[^']*'|\S+)\s*/.exec(rest)
170
+ if (value === null || (value[1] as string).startsWith('--')) return { sub: sub.toLowerCase(), rest: '', details, error: `${option[1]} needs a value` }
171
+ rest = rest.slice(value[0].length)
172
+ if (option[1] === '--ref') ref = unquote(value[1] as string)
173
+ else path = unquote(value[1] as string)
174
+ }
175
+ return { sub: sub.toLowerCase(), rest: unquote(rest), details, ref, path }
138
176
  }
139
177
 
140
- async function expandHome(host: Host, path: string): Promise<string> {
141
- if (path !== '~' && !path.startsWith('~/')) {
142
- return path
143
- }
144
- const { exitCode, stdout } = await host.run(['printenv', 'HOME'])
145
- const home = stdout.trim()
146
- if (exitCode !== 0 || home === '') {
147
- throw new Error('could not find the home folder')
178
+ /**
179
+ * The remote source `target` names, or undefined for a local folder. A bare
180
+ * owner/repo is a folder when one exists by that name and no --ref or --path
181
+ * says otherwise.
182
+ */
183
+ async function remoteSpecFor(host: Host, target: string, flags: { ref?: string; path?: string }): Promise<RemoteSpec | undefined> {
184
+ const shape = looksRemote(target)
185
+ const asked = flags.ref !== undefined || flags.path !== undefined
186
+ if (shape === 'local') {
187
+ if (asked) throw new Error('--ref and --path apply to a repository, not to a folder')
188
+ return undefined
148
189
  }
149
- return home + path.slice(1)
190
+ if (shape === 'maybe' && !asked && (await host.stat(target).catch(() => undefined)) !== undefined) return undefined
191
+ return parseRemote(target, { ref: flags.ref, subdir: flags.path })
150
192
  }
151
193
 
152
- function reasonOf(error: unknown): string {
153
- return error instanceof Error ? error.message : String(error)
194
+ function unquote(text: string): string {
195
+ return /^(["']).*\1$/.test(text) ? text.slice(1, -1) : text
154
196
  }
@@ -21,6 +21,7 @@ import {
21
21
  WRITE_SHELL,
22
22
  } from './patterns'
23
23
  import { basename, extension, findAll, findFirst, spanKinds, type Match, type SpanKind } from './text'
24
+ import { describePrograms, programsIn, type ProgramUse } from './programs'
24
25
  import { row, worse, type CheckResult, type Evidence, type Status } from './types'
25
26
 
26
27
  const GROUP = 'code' as const
@@ -159,6 +160,7 @@ const ANSI = /\u001b\[[0-9;?]{0,40}[A-Za-z]|\u001b\][^\u0007\u001b]{0,1000}\u000
159
160
 
160
161
  function hiddenCheck(ctx: ScanContext): CheckResult {
161
162
  const evidence: Evidence[] = []
163
+ let foundLone = false
162
164
  for (const file of ctx.files) {
163
165
  const text = file.isText ? ctx.text(file.path) : undefined
164
166
  if (text === undefined) continue
@@ -172,10 +174,24 @@ function hiddenCheck(ctx: ScanContext): CheckResult {
172
174
  for (const match of findAll(text, VARIATION_SUPPLEMENT, 3)) {
173
175
  evidence.push({ file: file.path, line: match.line, excerpt: `${[...match.text].length} invisible variation selectors in a row (a way to smuggle data)`, status: cap('fail') })
174
176
  }
175
- const bidi = findAll(text, BIDI, 5)
176
- for (const match of bidi) {
177
+ // In code a control that is all of a string, with nothing after it, is
178
+ // only a warning, and only in a language the tokenizer knows. Failures
179
+ // are found first, so no number of such strings can crowd out a real one.
180
+ const language = isCode ? (ctx.interpreters.get(file.path) ?? languageOf(file.path, text)) : 'other'
181
+ let kinds: ((index: number) => SpanKind) | undefined
182
+ const kindAt = (index: number): SpanKind => (kinds ??= spanKinds(text, language))(index)
183
+ const lone = (index: number): boolean => language !== 'other' && isLoneInString(text, index, kindAt)
184
+ for (const match of findAll(text, BIDI, 5, index => !lone(index))) {
177
185
  evidence.push({ file: file.path, line: match.line, excerpt: `bidirectional control ${codePoints(match.text)}: text may read differently than it runs`, status: cap(isCode ? 'fail' : 'warn') })
178
186
  }
187
+ if (language !== 'other') {
188
+ for (const match of findAll(text, BIDI, 3, lone)) {
189
+ const status = cap('warn')
190
+ if (status === 'warn') foundLone = true
191
+ const excerpt = `bidirectional control ${codePoints(match.text)} is all of a string: it cannot reorder code there, but the string looks empty; write it as ${escapeFor(match, file.path, text, language)}`
192
+ evidence.push({ file: file.path, line: match.line, excerpt, status })
193
+ }
194
+ }
179
195
  const zero = findAll(text, ZERO_WIDTH, 50)
180
196
  if (zero.length > 0 && (isCode || isMarkdown(file.path))) {
181
197
  const first = zero[0] as (typeof zero)[number]
@@ -191,12 +207,52 @@ function hiddenCheck(ctx: ScanContext): CheckResult {
191
207
  id: 'code.hidden-characters',
192
208
  group: GROUP,
193
209
  title: 'Hidden characters',
194
- advice: 'Invisible tag characters carry text the model reads and you do not see. Bidirectional controls make code read differently than it runs. Neither belongs in a plugin.',
210
+ advice: `Invisible tag characters carry text the model reads and you do not see. Bidirectional controls make code read differently than it runs. Neither belongs in a plugin.${
211
+ foundLone ? " A plugin that needs a direction mark, as one for right-to-left text does, should write it as an escape such as '\\u200f', never the raw character, which makes its string look empty." : ''
212
+ }`,
195
213
  },
196
214
  evidence,
197
215
  )
198
216
  }
199
217
 
218
+ /**
219
+ * Whether the bidirectional control at `index` is all of a one-line string,
220
+ * as in `const RLM = '\u200f'`, with nothing after it on its line but blanks,
221
+ * `;`, `,` or a comment. What it can reorder runs to the end of its line, and
222
+ * there that is no code; what comes before it does not move.
223
+ */
224
+ function isLoneInString(text: string, index: number, kindAt: (index: number) => SpanKind): boolean {
225
+ const quote = text[index - 1]
226
+ if ((quote !== "'" && quote !== '"') || text[index + 1] !== quote) return false
227
+ if (kindAt(index - 1) !== 'string' || kindAt(index) !== 'string' || kindAt(index + 1) !== 'string') return false
228
+ // The quote must open the string: what comes before it is no string, and no
229
+ // `}`, which may close a template's `${...}` and leave the quote as its text.
230
+ if (index >= 2 && (kindAt(index - 2) === 'string' || text[index - 2] === '}')) return false
231
+ // Only LF ends the line here. U+2028 and U+2029 end a JavaScript line, but
232
+ // editors often show them inline and U+2028 ends no bidi paragraph, so code
233
+ // after one can still be reordered: that fails.
234
+ for (let i = index + 2; i < text.length && text[i] !== '\n'; i += 1) {
235
+ const char = text[i]
236
+ if (char === ' ' || char === '\t' || char === ';' || char === ',') continue
237
+ // Only a CR that ends the line: a lone one ends a line too, and code may follow it.
238
+ if (char === '\r' && (i + 1 === text.length || text[i + 1] === '\n')) continue
239
+ const kind = kindAt(i)
240
+ if (kind !== 'comment' && kind !== 'trailing-comment') return false
241
+ }
242
+ return true
243
+ }
244
+
245
+ /** `'\u200f'` for a lone control, as its language writes the escape. */
246
+ function escapeFor(match: Match, path: string, text: string, language: Language): string {
247
+ const hex = (match.text.codePointAt(0) as number).toString(16).padStart(4, '0')
248
+ if (language !== 'shell') return `${text[match.index - 1]}\\u${hex}${text[match.index - 1]}`
249
+ // fish reads the escape bare. Other shells differ (sh has no such escape,
250
+ // and bash before 4.2 not in $'...'), but every printf takes octal bytes.
251
+ if ((interpreterOf(text) || extension(path)) === 'fish') return `\\u${hex}`
252
+ const octal = [...new TextEncoder().encode(match.text)].map(byte => `\\${byte.toString(8)}`).join('')
253
+ return `"$(printf '${octal}')"`
254
+ }
255
+
200
256
  /** Tag characters U+E0020..U+E007E mirror printable ASCII. */
201
257
  export function decodeTags(text: string): string {
202
258
  let out = ''
@@ -213,6 +269,9 @@ function codePoints(text: string): string {
213
269
 
214
270
  function execCheck(ctx: ScanContext): CheckResult {
215
271
  const evidence: Evidence[] = []
272
+ const uses: ProgramUse[] = []
273
+ let buildsCode = 0
274
+ let downloads = 0
216
275
  for (const file of codeFiles(ctx)) {
217
276
  // Code files, and anything live code runs or loads whatever its name.
218
277
  if (!isCodeFile(file.path, file.text) && !LIVE_ROLES.has(file.role)) continue
@@ -227,18 +286,35 @@ function execCheck(ctx: ScanContext): CheckResult {
227
286
  evidence.push(...evidenceFor(file, pattern, inModule ? 'fail' : 'warn', match => `${what}: ${match.excerpt}`, 3))
228
287
  }
229
288
  evidence.push(...evidenceFor(file, DOWNLOAD_AND_RUN, 'fail', match => `downloads or decodes code and runs it: ${match.excerpt}`, 3))
289
+ // The summary counts live code only: tests and docs are only reported.
290
+ if (isLive(file.role)) {
291
+ const keep = (index: number) => !['comment', 'regex', 'trailing-comment'].includes(file.kindAt(index))
292
+ uses.push(...programsIn(file.path, file.text, file.language, keep))
293
+ // The same language rules as the evidence, each place counted once.
294
+ const fromText = file.language === 'js' ? [CODE_FROM_TEXT] : file.language === 'python' ? [PYTHON_CODE_FROM_TEXT] : file.language === 'shell' ? [SHELL_EVAL] : [CODE_FROM_TEXT, PYTHON_CODE_FROM_TEXT, SHELL_EVAL]
295
+ buildsCode += new Set(fromText.flatMap(pattern => findAll(file.text, pattern, 50, keep).map(match => match.line))).size
296
+ downloads += findAll(file.text, DOWNLOAD_AND_RUN, 50, keep).length
297
+ }
230
298
  }
231
299
  return row(
232
300
  {
233
301
  id: 'code.execution',
234
302
  group: GROUP,
235
303
  title: 'Running programs and building code at run time',
236
- advice: 'Inside a mod this is a red flag: mods reach the machine only through $. In a script, check that each command it runs is one its purpose needs.',
304
+ advice:
305
+ 'Check that each program fits what it says it does. Shells, compilers and programs chosen while it runs can do anything, so look at those first. Inside a mod any of this is a red flag: a mod reaches your machine only through Claude Code.',
306
+ summary: summaryOrFallback(describePrograms(uses, { buildsCode, downloads }), evidence),
237
307
  },
238
308
  evidence,
239
309
  )
240
310
  }
241
311
 
312
+ /** A row that found something but could not name it still says so in words. */
313
+ function summaryOrFallback(summary: string[], evidence: readonly Evidence[]): string[] {
314
+ if (summary.length > 0 || !evidence.some(item => item.status === 'warn' || item.status === 'fail')) return summary
315
+ return ['Starts other programs; which ones could not be read from the code']
316
+ }
317
+
242
318
  function obfuscationCheck(ctx: ScanContext): CheckResult {
243
319
  const evidence: Evidence[] = []
244
320
  for (const file of codeFiles(ctx)) {
@@ -402,12 +478,36 @@ function denseLine(text: string): Match | undefined {
402
478
 
403
479
  function credentialsCheck(ctx: ScanContext): CheckResult {
404
480
  const evidence: Evidence[] = []
481
+ const secrets = new Set<string>()
482
+ const places = new Set<string>()
483
+ const files = new Set<string>()
484
+ const sending = new Set<string>()
405
485
  for (const file of codeFiles(ctx)) {
406
- if (file.role === 'hook-script') continue
486
+ // Hook scripts and MCP configuration are judged where their target is
487
+ // known: the hooks check, and mcp.credentials with the server's host.
488
+ if (file.role === 'hook-script' || file.role === 'mcp-config') continue
407
489
  const sends = [NETWORK_CODE, NETWORK_COMMAND, COLLECTION_HOST].some(pattern => active(file, pattern, 1).length > 0)
408
490
  const lead = sends ? 'with network code in the same file: ' : ''
409
- evidence.push(...evidenceFor(file, CREDENTIAL_PATH, sends ? 'fail' : 'warn', match => `${lead}${match.excerpt}`, 3))
410
- evidence.push(...evidenceFor(file, SECRET_ENV, sends ? 'fail' : 'warn', match => `${lead}reads ${secretName(match)}`, 3))
491
+ const paths = evidenceFor(file, CREDENTIAL_PATH, sends ? 'fail' : 'warn', match => `${lead}${match.excerpt}`, 3)
492
+ const names = evidenceFor(file, SECRET_ENV, sends ? 'fail' : 'warn', match => `${lead}reads ${secretName(match)}`, 3)
493
+ evidence.push(...paths, ...names)
494
+ if (!isLive(file.role)) continue
495
+ for (const match of active(file, CREDENTIAL_PATH, 20)) places.add(match.text.trim().replace(/\/$/, ''))
496
+ // A dump of the whole environment (JSON.stringify(process.env), printenv) is every secret at once.
497
+ for (const match of active(file, SECRET_ENV, 20)) secrets.add(/process\.env|os\.environ|printenv|\benv\b/.test(match.text) ? 'all your environment variables' : secretName(match))
498
+ if (paths.length + names.length > 0) {
499
+ files.add(file.path)
500
+ if (sends) sending.add(file.path)
501
+ }
502
+ }
503
+ const summary: string[] = []
504
+ const things = [...secrets, ...places]
505
+ if (things.length > 0) {
506
+ summary.push(`Reads ${things.slice(0, 5).join(', ')}${things.length > 5 ? ` and ${things.length - 5} more` : ''}${sending.size > 0 ? ', in code that can also reach the network' : ''}`)
507
+ if (secrets.size > 0) summary.push(`Secrets from your environment: ${[...secrets].join(', ')}`)
508
+ if (places.size > 0) summary.push(`Credential files and stores: ${[...places].join(', ')}`)
509
+ if (sending.size > 0) summary.push(`Worth a look: ${[...sending].slice(0, 4).join(', ')} can also send data over the network`)
510
+ summary.push(`In ${files.size} file${files.size === 1 ? '' : 's'}: ${[...files].slice(0, 4).join(', ')}${files.size > 4 ? ` and ${files.size - 4} more` : ''}`)
411
511
  }
412
512
  return row(
413
513
  {
@@ -415,6 +515,7 @@ function credentialsCheck(ctx: ScanContext): CheckResult {
415
515
  group: GROUP,
416
516
  title: 'Credentials and secrets read by code',
417
517
  advice: 'Code that reads your keys, tokens or browser data and can reach the network can steal them. Do not install it unless that is plainly its job.',
518
+ summary,
418
519
  },
419
520
  evidence,
420
521
  )
@@ -506,7 +607,8 @@ function domainsCheck(ctx: ScanContext): CheckResult {
506
607
  const authority = /\/\/([^/?#]{0,300})/.exec(match.text)?.[1] ?? ''
507
608
  const hasUserInfo = authority.includes('@')
508
609
  const host = (/(\[[0-9a-fA-F:]+\]|[A-Za-z0-9.-]+)/.exec(authority.slice(authority.lastIndexOf('@') + 1))?.[1] ?? '').toLowerCase()
509
- if (host === '') continue
610
+ // A one-word host (https://x) is a placeholder, not a place on the internet.
611
+ if (host === '' || (!host.includes('.') && !host.startsWith('['))) continue
510
612
  if (hasUserInfo) evidence.push(judged(file, match, 'warn', `an address whose real host hides after "@": ${host}`))
511
613
  if (/^(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0|example\.(?:com|org|net))$/.test(host)) continue
512
614
  if (!hosts.has(host)) hosts.set(host, { file: file.path, line: match.line })