ai-install-guard 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ import { isLive, type ScanContext } from './context'
2
+ import { SECRET_KEYS } from './patterns'
3
+ import { basename, findAll } from './text'
4
+ import { row, type CheckResult, type Evidence } from './types'
5
+
6
+ const GROUP = 'secrets' as const
7
+
8
+ const PLACEHOLDER = /^(?:|<.*>|\$\{.*\}|your[-_ ].*|xx+|\*+|changeme|change-me|todo|tbd|none|null|example|placeholder|dummy|test|secret|password|\.\.\.)$/i
9
+
10
+ /**
11
+ * A binary file's text for the key patterns: UTF-16 decoded as such (a BOM,
12
+ * or NUL in every other byte), anything else with printable ASCII and line
13
+ * breaks kept and every other byte a space.
14
+ */
15
+ function asciiOf(bytes: Uint8Array | undefined): string | undefined {
16
+ if (bytes === undefined) return undefined
17
+ // Decoded by hand: a mod's TextDecoder need not know UTF-16.
18
+ const utf16 = utf16Of(bytes)
19
+ if (utf16 !== undefined) {
20
+ let text = ''
21
+ const little = utf16 === 'utf-16le'
22
+ for (let i = 0; i + 1 < bytes.length; i += 2) {
23
+ const code = little ? (bytes[i] as number) | ((bytes[i + 1] as number) << 8) : ((bytes[i] as number) << 8) | (bytes[i + 1] as number)
24
+ text += String.fromCharCode(code)
25
+ }
26
+ return text
27
+ }
28
+ let out = ''
29
+ for (const byte of bytes) out += byte === 10 || (byte >= 32 && byte < 127) ? String.fromCharCode(byte) : ' '
30
+ return out
31
+ }
32
+
33
+ function utf16Of(bytes: Uint8Array): 'utf-16le' | 'utf-16be' | undefined {
34
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) return 'utf-16le'
35
+ if (bytes[0] === 0xfe && bytes[1] === 0xff) return 'utf-16be'
36
+ const sample = Math.min(bytes.length, 400) & ~1
37
+ if (sample < 8) return undefined
38
+ let evenNul = 0
39
+ let oddNul = 0
40
+ for (let i = 0; i < sample; i += 2) {
41
+ if (bytes[i] === 0) evenNul += 1
42
+ if (bytes[i + 1] === 0) oddNul += 1
43
+ }
44
+ const half = sample / 2
45
+ if (oddNul > half * 0.8 && evenNul < half * 0.2) return 'utf-16le'
46
+ if (evenNul > half * 0.8 && oddNul < half * 0.2) return 'utf-16be'
47
+ return undefined
48
+ }
49
+
50
+ export function secretChecks(ctx: ScanContext): CheckResult[] {
51
+ const keys: Evidence[] = []
52
+ for (const file of ctx.files) {
53
+ // Binary files are read too, as ASCII: a NUL byte must not hide a key.
54
+ const text = file.isText ? ctx.text(file.path) : asciiOf(ctx.tree.contents.get(file.path))
55
+ if (text === undefined) continue
56
+ const live = isLive(ctx.roleOf(file.path))
57
+ for (const { kind, pattern } of SECRET_KEYS) {
58
+ for (const match of findAll(text, pattern, 3)) {
59
+ keys.push({ file: file.path, line: match.line, excerpt: kind, status: live ? 'fail' : 'info' })
60
+ }
61
+ }
62
+ }
63
+
64
+ const envFiles: Evidence[] = []
65
+ for (const file of ctx.files) {
66
+ const name = basename(file.path)
67
+ if (!/^\.env(?:\..+)?$/.test(name) || /\.(?:example|sample|template|dist|defaults?|schema)$/i.test(name)) continue
68
+ const text = ctx.text(file.path) ?? asciiOf(ctx.tree.contents.get(file.path)) ?? ''
69
+ const lines = text.split(/\r?\n/)
70
+ const filled = lines
71
+ .map((line, index) => ({ index, match: /^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]{0,200})[ \t]*=(.*)$/.exec(line) }))
72
+ .filter(item => item.match !== null && !PLACEHOLDER.test((item.match[2] as string).trim().replace(/^['"]|['"]$/g, '')))
73
+ if (filled.length > 0) {
74
+ const first = filled[0] as { index: number; match: RegExpExecArray }
75
+ envFiles.push({
76
+ file: file.path,
77
+ line: first.index + 1,
78
+ excerpt: `${filled.length} variable${filled.length === 1 ? '' : 's'} with values, first ${first.match[1]}`,
79
+ status: isLive(ctx.roleOf(file.path)) ? 'fail' : 'info',
80
+ })
81
+ }
82
+ }
83
+
84
+ return [
85
+ row(
86
+ {
87
+ id: 'secrets.keys',
88
+ group: GROUP,
89
+ title: 'Committed keys and tokens',
90
+ advice: 'A real key in a public plugin is already leaked, and one that looks real may be bait. The value is never shown here.',
91
+ },
92
+ keys,
93
+ 'fail',
94
+ 'critical',
95
+ ),
96
+ row(
97
+ {
98
+ id: 'secrets.env-files',
99
+ group: GROUP,
100
+ title: '.env files with values',
101
+ advice: 'A .env file with values ships someone\'s configuration, often their secrets.',
102
+ },
103
+ envFiles,
104
+ 'fail',
105
+ ),
106
+ ]
107
+ }
@@ -0,0 +1,449 @@
1
+ import type { InventoryFile, InventoryLink } from '../inventory'
2
+ import { isLive, isRecord, isTestPath, parseJson, type ScanContext } from './context'
3
+ import { CLAUDE_SETTINGS } from './patterns'
4
+ import { ARCHIVE, ARCHIVE_EXTENSIONS, EXECUTABLE, IMAGE_EXTENSIONS, magicOf, NATIVE_EXTENSIONS, TEXT_EXTENSIONS } from './magic'
5
+ import { basename, dirname, extension, findFirst, joinPath } from './text'
6
+ import { row, type CheckResult, type Evidence, type Status } from './types'
7
+
8
+ export { magicOf }
9
+ import type { ValidatorReport } from './validator'
10
+
11
+ const GROUP = 'structure' as const
12
+ const LARGE_FILE = 1024 * 1024
13
+ const MINIFIED_LINE = 1000
14
+
15
+ export function structureChecks(ctx: ScanContext, validator: ValidatorReport | undefined): CheckResult[] {
16
+ return [
17
+ manifestCheck(ctx, validator),
18
+ componentsCheck(ctx),
19
+ filesCheck(ctx),
20
+ linksCheck(ctx),
21
+ imagesCheck(ctx),
22
+ settingsCheck(ctx),
23
+ filledInCheck(ctx),
24
+ ]
25
+ }
26
+
27
+ function manifestCheck(ctx: ScanContext, validator: ValidatorReport | undefined): CheckResult {
28
+ const base = {
29
+ id: 'structure.manifest',
30
+ group: GROUP,
31
+ title: 'Manifest and Claude Code validation',
32
+ advice: 'Claude Code refuses or misreads what fails validation; a candidate that does not validate cannot be judged as written.',
33
+ }
34
+ const evidence: Evidence[] = []
35
+ if (ctx.manifestError !== undefined) {
36
+ evidence.push({ file: '.claude-plugin/plugin.json', excerpt: `does not parse: ${ctx.manifestError}`, status: 'fail' })
37
+ }
38
+ for (const problem of ctx.hookConfigErrors) {
39
+ evidence.push({ file: problem.file, excerpt: `does not parse: ${problem.error}`, status: 'fail' })
40
+ }
41
+ if (validator?.ran === true) {
42
+ for (const error of validator.errors) evidence.push({ file: error.file, excerpt: error.message, status: 'fail' })
43
+ }
44
+ if (evidence.length > 0) {
45
+ return row(base, evidence, 'fail')
46
+ }
47
+ if (validator?.ran === false) {
48
+ return { ...base, status: 'info', severity: 'none', evidence: [{ file: '-', excerpt: `claude plugin validate did not run: ${validator.reason}` }] }
49
+ }
50
+ const warnings = validator?.ran === true ? validator.warnings.length : 0
51
+ return {
52
+ ...base,
53
+ status: 'pass',
54
+ severity: 'none',
55
+ evidence: validator?.ran === true && warnings > 0
56
+ ? [{ file: '-', excerpt: `claude plugin validate: ${warnings} warning${warnings === 1 ? '' : 's'}, no errors`, status: 'pass' }]
57
+ : [],
58
+ }
59
+ }
60
+
61
+ function componentsCheck(ctx: ScanContext): CheckResult {
62
+ const count = (prefix: string, test: (path: string) => boolean = () => true): number =>
63
+ ctx.files.filter(file => file.path.startsWith(prefix) && test(file.path)).length
64
+ const parts: string[] = [`a ${ctx.kind}`]
65
+ const skills = new Set(ctx.prompts.filter(path => /(^|\/)SKILL\.md$/.test(path)).map(path => dirname(path)))
66
+ if (skills.size > 0) parts.push(`${skills.size} skill${skills.size === 1 ? '' : 's'}`)
67
+ const commands = ctx.prompts.filter(path => path.startsWith('commands/')).length
68
+ if (commands > 0) parts.push(`${commands} command${commands === 1 ? '' : 's'}`)
69
+ const agents = ctx.prompts.filter(path => path.startsWith('agents/')).length
70
+ if (agents > 0) parts.push(`${agents} agent${agents === 1 ? '' : 's'}`)
71
+ if (ctx.modules.length > 0) parts.push(`${ctx.modules.length} hooks module${ctx.modules.length === 1 ? '' : 's'} (a mod)`)
72
+ if (ctx.hookCommands.length > 0) parts.push(`${ctx.hookCommands.length} classic hook${ctx.hookCommands.length === 1 ? '' : 's'}`)
73
+ if (ctx.mcpServers.length > 0) parts.push(`${ctx.mcpServers.length} MCP server${ctx.mcpServers.length === 1 ? '' : 's'}`)
74
+ if (ctx.files.some(file => file.path === '.lsp.json')) parts.push('LSP servers')
75
+ const bins = count('bin/')
76
+ if (bins > 0) parts.push(`${bins} file${bins === 1 ? '' : 's'} in bin/ (added to your PATH)`)
77
+ if (ctx.files.some(file => file.path === 'settings.json')) parts.push('default settings')
78
+ return {
79
+ id: 'structure.components',
80
+ group: GROUP,
81
+ title: 'What it ships',
82
+ status: 'info',
83
+ severity: 'none',
84
+ evidence: [{ file: '-', excerpt: parts.join(', ') }],
85
+ advice: 'Each kind of component reaches your machine differently; the groups below check each one.',
86
+ }
87
+ }
88
+
89
+ function filesCheck(ctx: ScanContext): CheckResult {
90
+ const evidence: Evidence[] = []
91
+ const strict = ctx.kind === 'skill' || ctx.modules.length > 0
92
+ for (const file of ctx.files) {
93
+ const bytes = ctx.tree.contents.get(file.path)
94
+ if (bytes === undefined) continue
95
+ // A versioned native library (libfoo.so.1, libbar.1.dylib) is a .so or .dylib.
96
+ const versioned = /\.(so|dylib)(?:\.\d+)+$|\.\d+\.(dylib)$/.exec(basename(file.path))
97
+ const ext = versioned !== null ? ((versioned[1] ?? versioned[2]) as string) : extension(file.path)
98
+ const magic = magicOf(bytes)
99
+ const live = isLive(ctx.roleOf(file.path))
100
+ const cap = (status: Status): Status => (live ? status : 'info')
101
+ const inSkillOrMod = strict || file.path.startsWith('skills/') || ctx.roleOf(file.path) === 'module'
102
+
103
+ if ((magic !== undefined && EXECUTABLE.has(magic)) || NATIVE_EXTENSIONS.has(ext) || basename(file.path).endsWith('.pyc')) {
104
+ const disguised = magic !== undefined && EXECUTABLE.has(magic) && !NATIVE_EXTENSIONS.has(ext) && ext !== '' && ext !== 'bin'
105
+ const what = magic === undefined ? `compiled file (.${ext})` : `${magic} binary`
106
+ evidence.push({
107
+ file: file.path,
108
+ excerpt: disguised ? `${what} named like a .${ext} file` : `${what}: cannot be reviewed as source`,
109
+ status: cap(disguised || inSkillOrMod ? 'fail' : 'warn'),
110
+ })
111
+ continue
112
+ }
113
+ if ((magic !== undefined && ARCHIVE.has(magic)) || ARCHIVE_EXTENSIONS.has(ext)) {
114
+ const disguised = magic !== undefined && ARCHIVE.has(magic) && !ARCHIVE_EXTENSIONS.has(ext)
115
+ evidence.push({
116
+ file: file.path,
117
+ excerpt: disguised ? `${magic} archive named like a .${ext} file` : `${magic ?? ext} archive: its contents are not scanned`,
118
+ status: cap(disguised ? 'fail' : 'warn'),
119
+ })
120
+ continue
121
+ }
122
+ const expectedImage = Object.hasOwn(IMAGE_EXTENSIONS, ext) ? IMAGE_EXTENSIONS[ext] : undefined
123
+ if (expectedImage !== undefined && magic !== expectedImage) {
124
+ evidence.push({ file: file.path, excerpt: `named .${ext} but is ${magic === undefined ? (file.isText ? 'text' : 'not an image') : magic}`, status: cap('fail') })
125
+ continue
126
+ }
127
+ if (!file.isText && TEXT_EXTENSIONS.has(ext)) {
128
+ evidence.push({ file: file.path, excerpt: `named .${ext} but holds NUL bytes, which hide its text from text tools`, status: cap('warn') })
129
+ }
130
+ if (file.size > LARGE_FILE) {
131
+ evidence.push({ file: file.path, excerpt: `${(file.size / LARGE_FILE).toFixed(1)} MB: large for a plugin file`, status: cap('warn') })
132
+ }
133
+ if (/^(js|mjs|cjs)$/.test(ext) && (file.path.endsWith('.min.js') || longestLine(ctx.text(file.path) ?? '') > MINIFIED_LINE)) {
134
+ evidence.push({ file: file.path, excerpt: 'minified or bundled JavaScript: hard to review', status: cap('warn') })
135
+ }
136
+ }
137
+ for (const entry of ctx.tree.inventory.entries) {
138
+ if (entry.kind === 'other') evidence.push({ file: entry.path, excerpt: 'not a regular file (a device, pipe or socket)', status: 'warn' })
139
+ }
140
+ return row(
141
+ {
142
+ id: 'structure.files',
143
+ group: GROUP,
144
+ title: 'Unexpected files',
145
+ advice: 'Binaries, archives and minified code cannot be reviewed here. Prefer plugins that ship readable source, and treat a file whose content does not match its name as hostile.',
146
+ },
147
+ evidence,
148
+ )
149
+ }
150
+
151
+ function longestLine(text: string): number {
152
+ let longest = 0
153
+ let start = 0
154
+ for (let i = 0; i <= text.length; i += 1) {
155
+ if (i === text.length || text.charCodeAt(i) === 10) {
156
+ longest = Math.max(longest, i - start)
157
+ start = i + 1
158
+ }
159
+ }
160
+ return longest
161
+ }
162
+
163
+ function linksCheck(ctx: ScanContext): CheckResult {
164
+ const evidence: Evidence[] = []
165
+ for (const entry of ctx.tree.inventory.entries) {
166
+ if (entry.kind !== 'link') continue
167
+ evidence.push(describeLink(entry, ctx))
168
+ }
169
+ return row(
170
+ {
171
+ id: 'structure.links',
172
+ group: GROUP,
173
+ title: 'Symbolic links',
174
+ advice: 'A link that leaves the folder makes Claude Code read or run files outside the plugin, such as your keys. Do not install a plugin that ships one.',
175
+ },
176
+ evidence,
177
+ )
178
+ }
179
+
180
+ function describeLink(link: InventoryLink, ctx: ScanContext): Evidence {
181
+ // A link in a test folder that nothing names is only reported.
182
+ const isAbsolute = link.target.startsWith('/') || /^[A-Za-z]:[\\/]/.test(link.target)
183
+ const climbsOut = !isAbsolute && joinPath(dirname(link.path), link.target) === undefined
184
+ const role = ctx.roleOf(link.path)
185
+ const live = role !== 'other' || !isTestPath(link.path)
186
+ if (isAbsolute && link.isInside) {
187
+ // It lands inside here, but an absolute path lands somewhere else on every other machine.
188
+ return { file: link.path, excerpt: `absolute link to ${link.target}: where it leads depends on the machine`, status: live ? 'warn' : 'info' }
189
+ }
190
+ if (isAbsolute || climbsOut || (link.landsAt !== undefined && !link.isInside)) {
191
+ return { file: link.path, excerpt: `links outside the folder to ${link.target}`, status: live ? 'fail' : 'info' }
192
+ }
193
+ if (link.landsAt === undefined) {
194
+ return { file: link.path, excerpt: `links to ${link.target}, which does not exist`, status: live ? 'warn' : 'info' }
195
+ }
196
+ return { file: link.path, excerpt: `links inside the folder to ${link.landsAt}`, status: 'info' }
197
+ }
198
+
199
+ function imagesCheck(ctx: ScanContext): CheckResult {
200
+ const evidence: Evidence[] = []
201
+ for (const file of ctx.files) {
202
+ const bytes = ctx.tree.contents.get(file.path)
203
+ if (bytes === undefined) continue
204
+ const trailing = trailingBytes(bytes)
205
+ if (trailing !== undefined && trailing.extra > 0) {
206
+ evidence.push({
207
+ file: file.path,
208
+ excerpt: `${trailing.extra} byte${trailing.extra === 1 ? '' : 's'} after the end of the ${trailing.format} image`,
209
+ status: isLive(ctx.roleOf(file.path)) ? 'fail' : 'info',
210
+ })
211
+ } else if (trailing?.malformed === true) {
212
+ evidence.push({ file: file.path, excerpt: `malformed ${trailing.format}: its end could not be found`, status: 'warn' })
213
+ }
214
+ if (extension(file.path) === 'svg') {
215
+ const found = findFirst(ctx.text(file.path) ?? '', /<script\b|\son[a-z]+\s*=|<foreignObject\b|javascript:/i)
216
+ if (found !== undefined) evidence.push({ file: file.path, line: found.line, excerpt: `SVG with active content: ${found.excerpt}`, status: 'warn' })
217
+ }
218
+ }
219
+ return row(
220
+ {
221
+ id: 'structure.images',
222
+ group: GROUP,
223
+ title: 'Data hidden in images',
224
+ advice: 'Bytes after an image ends are invisible when the image is shown and are a common way to smuggle a payload. Do not install a plugin whose images carry them.',
225
+ },
226
+ evidence,
227
+ )
228
+ }
229
+
230
+ /** For a PNG, JPEG or WebP: how many bytes follow the image's own end. */
231
+ export function trailingBytes(bytes: Uint8Array): { format: string; extra: number; malformed?: true } | undefined {
232
+ const magic = magicOf(bytes)
233
+ if (magic === 'png') {
234
+ let offset = 8
235
+ while (offset + 12 <= bytes.length) {
236
+ const length = (((bytes[offset] as number) << 24) | ((bytes[offset + 1] as number) << 16) | ((bytes[offset + 2] as number) << 8) | (bytes[offset + 3] as number)) >>> 0
237
+ const type = String.fromCharCode(...bytes.slice(offset + 4, offset + 8))
238
+ offset += 12 + length
239
+ if (type === 'IEND') return { format: 'PNG', extra: Math.max(0, bytes.length - offset) }
240
+ }
241
+ return { format: 'PNG', extra: 0, malformed: true }
242
+ }
243
+ if (magic === 'jpeg') {
244
+ const end = jpegEnd(bytes)
245
+ if (end === undefined) return { format: 'JPEG', extra: 0, malformed: true }
246
+ const rest = bytes.slice(end)
247
+ return { format: 'JPEG', extra: rest.every(byte => byte === 0) ? 0 : rest.length }
248
+ }
249
+ if (magic === 'webp') {
250
+ const size = ((bytes[4] as number) | ((bytes[5] as number) << 8) | ((bytes[6] as number) << 16) | ((bytes[7] as number) << 24)) >>> 0
251
+ const end = size + 8 + (size % 2)
252
+ // A size that claims more than the file holds is forged or broken.
253
+ if (end > bytes.length + (size % 2)) return { format: 'WebP', extra: 0, malformed: true }
254
+ return { format: 'WebP', extra: Math.max(0, bytes.length - end) }
255
+ }
256
+ if (magic === 'gif') {
257
+ const end = gifEnd(bytes)
258
+ if (end === undefined) return { format: 'GIF', extra: 0, malformed: true }
259
+ return { format: 'GIF', extra: bytes.length - end }
260
+ }
261
+ return undefined
262
+ }
263
+
264
+ /** Where a GIF's trailer (0x3B) ends, walking its blocks; undefined when malformed. */
265
+ function gifEnd(bytes: Uint8Array): number | undefined {
266
+ if (bytes.length < 13) return undefined
267
+ let offset = 13
268
+ const flags = bytes[10] as number
269
+ if ((flags & 0x80) !== 0) offset += 3 * (1 << ((flags & 0x07) + 1))
270
+ const skipSubBlocks = (): boolean => {
271
+ while (offset < bytes.length) {
272
+ const size = bytes[offset] as number
273
+ offset += 1
274
+ if (size === 0) return true
275
+ offset += size
276
+ }
277
+ return false
278
+ }
279
+ while (offset < bytes.length) {
280
+ const block = bytes[offset] as number
281
+ if (block === 0x3b) return offset + 1
282
+ if (block === 0x21) {
283
+ offset += 2
284
+ if (!skipSubBlocks()) return undefined
285
+ } else if (block === 0x2c) {
286
+ if (offset + 10 > bytes.length) return undefined
287
+ const local = bytes[offset + 9] as number
288
+ offset += 10
289
+ if ((local & 0x80) !== 0) offset += 3 * (1 << ((local & 0x07) + 1))
290
+ offset += 1
291
+ if (!skipSubBlocks()) return undefined
292
+ } else {
293
+ return undefined
294
+ }
295
+ }
296
+ return undefined
297
+ }
298
+
299
+ function jpegEnd(bytes: Uint8Array): number | undefined {
300
+ let offset = 2
301
+ while (offset + 1 < bytes.length) {
302
+ if (bytes[offset] !== 0xff) return undefined
303
+ let marker = bytes[offset + 1] as number
304
+ while (marker === 0xff && offset + 2 < bytes.length) {
305
+ offset += 1
306
+ marker = bytes[offset + 1] as number
307
+ }
308
+ if (marker === 0xd9) return offset + 2
309
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
310
+ offset += 2
311
+ continue
312
+ }
313
+ const length = (((bytes[offset + 2] as number) << 8) | (bytes[offset + 3] as number)) >>> 0
314
+ offset += 2 + length
315
+ if (marker === 0xda) {
316
+ // Entropy-coded data runs to the next marker that is not a stuffed 0xFF00 or a restart.
317
+ while (offset + 1 < bytes.length) {
318
+ if (bytes[offset] === 0xff) {
319
+ const next = bytes[offset + 1] as number
320
+ if (next !== 0x00 && !(next >= 0xd0 && next <= 0xd7)) break
321
+ }
322
+ offset += 1
323
+ }
324
+ }
325
+ }
326
+ return undefined
327
+ }
328
+
329
+ function settingsCheck(ctx: ScanContext): CheckResult {
330
+ const evidence: Evidence[] = []
331
+ for (const file of ctx.files) {
332
+ if (!/^settings(\.local)?\.json$/.test(basename(file.path)) || !isLive(ctx.roleOf(file.path))) continue
333
+ const raw = ctx.text(file.path) ?? ''
334
+ const parsed = parseJson(raw)
335
+ if (!parsed.ok || !isRecord(parsed.value)) continue
336
+ const settings = parsed.value
337
+ const granted = isRecord(settings.permissions) ? settings.permissions : undefined
338
+ if (granted !== undefined && (Array.isArray(granted.allow) || typeof granted.defaultMode === 'string')) {
339
+ evidence.push({ file: file.path, excerpt: 'grants itself permissions (an allow list or a default mode)', status: 'fail' })
340
+ }
341
+ const hit = findFirst(raw, CLAUDE_SETTINGS)
342
+ if (hit !== undefined) evidence.push({ file: file.path, line: hit.line, excerpt: hit.excerpt, status: 'fail' })
343
+ for (const key of ['hooks', 'apiKeyHelper', 'statusLine', 'awsAuthRefresh', 'otelHeadersHelper']) {
344
+ if (key in settings) evidence.push({ file: file.path, excerpt: `sets ${key}, which runs commands`, status: 'warn' })
345
+ }
346
+ }
347
+ return row(
348
+ {
349
+ id: 'structure.settings',
350
+ group: GROUP,
351
+ title: 'Bundled Claude Code settings',
352
+ advice: 'A plugin should never change your permissions. Settings that run commands deserve the same scrutiny as hooks.',
353
+ },
354
+ evidence,
355
+ )
356
+ }
357
+
358
+ export type { InventoryFile }
359
+
360
+ /**
361
+ * Files whose real content is not in the folder: Git LFS pointers, whose file
362
+ * is downloaded at install, and paths a git filter rewrites on checkout. The
363
+ * scan reads what is stored, so the code that runs could be anything.
364
+ */
365
+ function filledInCheck(ctx: ScanContext): CheckResult {
366
+ const evidence: Evidence[] = []
367
+ for (const file of ctx.files) {
368
+ const name = basename(file.path)
369
+ if (name === '.gitattributes') {
370
+ // A line covers the files its pattern names under its own folder:
371
+ // rewriting only tests or docs is noted, live code is a warning.
372
+ const folder = dirname(file.path)
373
+ const lines = (ctx.text(file.path) ?? '').split('\n')
374
+ for (let index = 0; index < lines.length && index < 1000; index += 1) {
375
+ const line = (lines[index] as string).trim()
376
+ if (line.startsWith('#') || line.startsWith('[attr]')) continue
377
+ const driver = /(?:^|\s)filter=([^\s]{1,100})/.exec(line)?.[1]
378
+ if (driver === undefined) continue
379
+ const pattern = line.split(/\s/)[0] as string
380
+ const coversLive = ctx.files.some(other => {
381
+ const rel = folder === '' ? other.path : other.path.startsWith(`${folder}/`) ? other.path.slice(folder.length + 1) : undefined
382
+ return rel !== undefined && attributeMatches(pattern, rel) && isLive(ctx.roleOf(other.path))
383
+ })
384
+ evidence.push({ file: file.path, line: index + 1, excerpt: `git rewrites these files with the "${driver}" filter on checkout: ${line.slice(0, 200)}`, status: coversLive ? 'warn' : 'info' })
385
+ }
386
+ continue
387
+ }
388
+ if (file.size > 1024) continue
389
+ const text = ctx.text(file.path)
390
+ if (text === undefined || !text.startsWith('version https://git-lfs.github.com/spec/')) continue
391
+ evidence.push({
392
+ file: file.path,
393
+ excerpt: 'a Git LFS pointer: the real file is downloaded when it is installed, and this scan could not read it',
394
+ // Images and docs often live in LFS; code that runs must not.
395
+ status: isLive(ctx.roleOf(file.path)) ? 'fail' : 'info',
396
+ })
397
+ if (evidence.length >= 20) break
398
+ }
399
+ return row(
400
+ {
401
+ id: 'structure.fetched',
402
+ group: GROUP,
403
+ title: 'Files filled in at install',
404
+ advice: 'The scan reads what the repository stores. A file that Git LFS downloads or a filter rewrites at install can hold different code, which was not checked.',
405
+ },
406
+ evidence,
407
+ )
408
+ }
409
+
410
+ /**
411
+ * Whether a .gitattributes pattern names `path` (relative to the attributes
412
+ * file's folder): no slash matches the name at any depth, a slash anchors it,
413
+ * `*` and `?` stay within a folder and `**` crosses them. A pattern too
414
+ * elaborate to match cheaply counts as matching, which can only warn more.
415
+ */
416
+ export function attributeMatches(pattern: string, path: string): boolean {
417
+ if (pattern.endsWith('/')) return false
418
+ if (pattern.length > 200 || path.length > 1000 || (pattern.match(/[*?[]/g) ?? []).length > 8) return true
419
+ const anchored = pattern.includes('/')
420
+ const body = pattern.startsWith('/') ? pattern.slice(1) : pattern
421
+ let source = ''
422
+ for (let index = 0; index < body.length; index += 1) {
423
+ const char = body[index] as string
424
+ if (char === '*' && body[index + 1] === '*') {
425
+ if (body[index + 2] === '/') {
426
+ source += '(?:.*/)?'
427
+ index += 2
428
+ } else {
429
+ source += '.*'
430
+ index += 1
431
+ }
432
+ } else if (char === '*') {
433
+ source += '[^/]*'
434
+ } else if (char === '?') {
435
+ source += '[^/]'
436
+ } else if (char === '[' && body.indexOf(']', index + 2) !== -1) {
437
+ const end = body.indexOf(']', index + 2)
438
+ source += `[${body.slice(index + 1, end).replace(/^!/, '^').replace(/[\\\]]/g, '\\$&')}]`
439
+ index = end
440
+ } else {
441
+ source += char.replace(/[.+^${}()|\\[\]]/g, '\\$&')
442
+ }
443
+ }
444
+ try {
445
+ return new RegExp(`^${source}$`).test(anchored ? path : basename(path))
446
+ } catch {
447
+ return true
448
+ }
449
+ }