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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +11 -2
- package/CHANGELOG.md +47 -1
- package/README.md +96 -9
- package/assets/reviewed-by-ai-install-guard-light@1x.png +0 -0
- package/hooks/guard/badge.ts +35 -0
- package/hooks/guard/decisions.ts +100 -0
- package/hooks/guard/pane.tsx +253 -0
- package/hooks/guard/view.ts +87 -0
- package/hooks/mod-host.ts +1 -0
- package/hooks/register.tsx +171 -21
- package/hooks/scan/checks/code.ts +638 -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/mcp.ts +271 -0
- package/hooks/scan/checks/mod.ts +374 -0
- package/hooks/scan/checks/patterns.ts +107 -0
- package/hooks/scan/checks/programs.ts +150 -0
- package/hooks/scan/checks/prompts.ts +319 -0
- package/hooks/scan/checks/provenance.ts +190 -0
- package/hooks/scan/checks/quality.ts +56 -0
- package/hooks/scan/checks/secrets.ts +107 -0
- package/hooks/scan/checks/structure.ts +449 -0
- package/hooks/scan/checks/text.ts +362 -0
- package/hooks/scan/checks/types.ts +105 -0
- package/hooks/scan/checks/validator.ts +111 -0
- package/hooks/scan/github.ts +219 -0
- package/hooks/scan/host.ts +13 -0
- package/hooks/scan/lookups.ts +370 -0
- package/hooks/scan/remote.ts +406 -0
- package/hooks/scan/report.ts +62 -31
- package/hooks/scan/scan.ts +360 -0
- package/hooks/scan/verdict.ts +64 -0
- package/package.json +8 -3
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
const EXCERPT_WIDTH = 100
|
|
2
|
+
|
|
3
|
+
export type Match = {
|
|
4
|
+
/** Where the match starts in the text. */
|
|
5
|
+
index: number
|
|
6
|
+
line: number
|
|
7
|
+
/** The matched text. */
|
|
8
|
+
text: string
|
|
9
|
+
/** A short window of the line around the match, for evidence. */
|
|
10
|
+
excerpt: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every match of `pattern` in `text`, with 1-based line numbers, up to
|
|
15
|
+
* `limit` of those `keep` accepts. Rejected matches are skipped before they
|
|
16
|
+
* count, so no number of decoys can use up the limit.
|
|
17
|
+
*/
|
|
18
|
+
export function findAll(text: string, pattern: RegExp, limit = 20, keep?: (index: number) => boolean): Match[] {
|
|
19
|
+
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`
|
|
20
|
+
const global = new RegExp(pattern.source, flags)
|
|
21
|
+
const starts = lineStarts(text)
|
|
22
|
+
const matches: Match[] = []
|
|
23
|
+
for (const found of text.matchAll(global)) {
|
|
24
|
+
const index = found.index ?? 0
|
|
25
|
+
if (keep !== undefined && !keep(index)) {
|
|
26
|
+
if (found[0].length === 0) global.lastIndex += 1
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
const line = lineAt(starts, index)
|
|
30
|
+
const lineStart = starts[line - 1] as number
|
|
31
|
+
const lineEnd = line < starts.length ? (starts[line] as number) - 1 : text.length
|
|
32
|
+
// Only a window around the match is copied, so a match on a huge line stays cheap.
|
|
33
|
+
const from = Math.max(lineStart, index - EXCERPT_WIDTH)
|
|
34
|
+
const to = Math.min(lineEnd, index + found[0].length + EXCERPT_WIDTH)
|
|
35
|
+
const window = text.slice(from, to)
|
|
36
|
+
const cut = excerpt(window, index - from, found[0].length)
|
|
37
|
+
matches.push({ index, line, text: found[0], excerpt: `${from > lineStart && !cut.startsWith('…') ? '…' : ''}${cut}${to < lineEnd && !cut.endsWith('…') ? '…' : ''}` })
|
|
38
|
+
if (matches.length >= limit) break
|
|
39
|
+
if (found[0].length === 0) global.lastIndex += 1
|
|
40
|
+
}
|
|
41
|
+
return matches
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The first match, or undefined. */
|
|
45
|
+
export function findFirst(text: string, pattern: RegExp): Match | undefined {
|
|
46
|
+
return findAll(text, pattern, 1)[0]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The 1-based line holding `needle`, or undefined. */
|
|
50
|
+
export function lineOf(text: string, needle: string): number | undefined {
|
|
51
|
+
const index = text.indexOf(needle)
|
|
52
|
+
return index === -1 ? undefined : lineAt(lineStarts(text), index)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A window of `line` around [start, start + length), trimmed and marked with … where cut. */
|
|
56
|
+
export function excerpt(line: string, start = 0, length = 0): string {
|
|
57
|
+
const trimmed = line.replace(/\r$/, '')
|
|
58
|
+
if (trimmed.length <= EXCERPT_WIDTH) {
|
|
59
|
+
return trimmed.trim()
|
|
60
|
+
}
|
|
61
|
+
const center = start + Math.floor(length / 2)
|
|
62
|
+
const from = Math.max(0, Math.min(center - EXCERPT_WIDTH / 2, trimmed.length - EXCERPT_WIDTH))
|
|
63
|
+
const to = from + EXCERPT_WIDTH
|
|
64
|
+
return `${from > 0 ? '…' : ''}${trimmed.slice(from, to).trim()}${to < trimmed.length ? '…' : ''}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function lineStarts(text: string): number[] {
|
|
68
|
+
const starts = [0]
|
|
69
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
70
|
+
if (text.charCodeAt(i) === 10) starts.push(i + 1)
|
|
71
|
+
}
|
|
72
|
+
return starts
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function lineAt(starts: readonly number[], index: number): number {
|
|
76
|
+
let low = 0
|
|
77
|
+
let high = starts.length - 1
|
|
78
|
+
while (low < high) {
|
|
79
|
+
const mid = Math.ceil((low + high) / 2)
|
|
80
|
+
if ((starts[mid] as number) <= index) low = mid
|
|
81
|
+
else high = mid - 1
|
|
82
|
+
}
|
|
83
|
+
return low + 1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `a/b` + `../c` → `c`; undefined when the path climbs out of the root. */
|
|
87
|
+
export function joinPath(dir: string, rel: string): string | undefined {
|
|
88
|
+
const parts = rel.startsWith('/') ? [] : dir.split('/').filter(part => part !== '')
|
|
89
|
+
for (const part of rel.split('/')) {
|
|
90
|
+
if (part === '..') {
|
|
91
|
+
if (parts.length === 0) return undefined
|
|
92
|
+
parts.pop()
|
|
93
|
+
} else if (part !== '.' && part !== '') {
|
|
94
|
+
parts.push(part)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return parts.join('/')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function dirname(path: string): string {
|
|
101
|
+
const slash = path.lastIndexOf('/')
|
|
102
|
+
return slash === -1 ? '' : path.slice(0, slash)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function basename(path: string): string {
|
|
106
|
+
return path.slice(path.lastIndexOf('/') + 1)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function extension(path: string): string {
|
|
110
|
+
const name = basename(path)
|
|
111
|
+
const dot = name.lastIndexOf('.')
|
|
112
|
+
return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `comment` is a comment that fills its line from the start; `trailing-comment`
|
|
117
|
+
* starts after code on its line. The tokenizer can be fooled, and a comment
|
|
118
|
+
* hiding a command is a classic trick, so only a whole-line comment is fully
|
|
119
|
+
* trusted to be inert.
|
|
120
|
+
*/
|
|
121
|
+
export type SpanKind = 'code' | 'string' | 'comment' | 'trailing-comment' | 'regex'
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* What each character of a source file is: code, inside a string, a
|
|
125
|
+
* comment or (in JavaScript) a regular expression literal. A lightweight
|
|
126
|
+
* tokenizer, good enough to tell a pattern or a remark about `.ssh` from code
|
|
127
|
+
* that opens it.
|
|
128
|
+
*/
|
|
129
|
+
export function spanKinds(text: string, language: 'js' | 'python' | 'shell' | 'other'): (index: number) => SpanKind {
|
|
130
|
+
const kinds = new Uint8Array(text.length)
|
|
131
|
+
const CODE = 0
|
|
132
|
+
const STRING = 1
|
|
133
|
+
const COMMENT = 2
|
|
134
|
+
const REGEX = 3
|
|
135
|
+
const TRAILING = 4
|
|
136
|
+
const mark = (from: number, to: number, kind: number): void => {
|
|
137
|
+
kinds.fill(kind, from, Math.min(to, text.length))
|
|
138
|
+
}
|
|
139
|
+
// What ends a line comment: JavaScript also ends one at a lone CR, U+2028
|
|
140
|
+
// and U+2029, Python at a lone CR. Missing one would read a whole
|
|
141
|
+
// CR-only file as one comment.
|
|
142
|
+
const isLineEnd = (char: string | undefined): boolean =>
|
|
143
|
+
char === '\n' || (language !== 'shell' && char === '\r') || (language === 'js' && (char === '\u2028' || char === '\u2029'))
|
|
144
|
+
const lineEndFrom = (from: number): number => {
|
|
145
|
+
let j = from
|
|
146
|
+
while (j < text.length && !isLineEnd(text[j])) j += 1
|
|
147
|
+
return j
|
|
148
|
+
}
|
|
149
|
+
// Whether anything but blanks came earlier on the current line, kept as
|
|
150
|
+
// the scan goes, so telling a whole-line comment from a trailing one is
|
|
151
|
+
// constant time however many comments share a line.
|
|
152
|
+
let lineHasCode = false
|
|
153
|
+
const commentKind = (): number => (lineHasCode ? TRAILING : COMMENT)
|
|
154
|
+
|
|
155
|
+
let i = 0
|
|
156
|
+
let lastSignificant = ''
|
|
157
|
+
let lastWord = ''
|
|
158
|
+
// After a `/` that opened no literal on its line, the rest of that line is
|
|
159
|
+
// not tried again: one pass per line, so `[/[/[/...` cannot stall the scan.
|
|
160
|
+
let noRegexBefore = 0
|
|
161
|
+
// Which `(` were opened by if, while, for or with: after their `)` a
|
|
162
|
+
// statement starts, so a slash there opens a pattern, not a division.
|
|
163
|
+
const parens: boolean[] = []
|
|
164
|
+
|
|
165
|
+
while (i < text.length) {
|
|
166
|
+
const char = text[i] as string
|
|
167
|
+
const next = text[i + 1]
|
|
168
|
+
if (isLineEnd(char)) {
|
|
169
|
+
lineHasCode = false
|
|
170
|
+
i += 1
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
if (language === 'js' && char === '/' && next === '/') {
|
|
174
|
+
const stop = lineEndFrom(i)
|
|
175
|
+
mark(i, stop, commentKind())
|
|
176
|
+
i = stop
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
if (language === 'js' && char === '/' && next === '*') {
|
|
180
|
+
const close = text.indexOf('*/', i + 2)
|
|
181
|
+
const stop = close === -1 ? text.length : close + 2
|
|
182
|
+
mark(i, stop, commentKind())
|
|
183
|
+
// A block comment may end mid-line: what follows on that line is code.
|
|
184
|
+
if (text.slice(i, stop).includes('\n')) lineHasCode = false
|
|
185
|
+
i = stop
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
// In shell, `#` starts a comment only at the start of a word: after an
|
|
189
|
+
// unescaped blank (`echo \ #` keeps the `#` in the word) or a line start.
|
|
190
|
+
const isShellComment = language === 'shell' && (i === 0 || (/\s/.test(text[i - 1] as string) && text[i - 2] !== '\\'))
|
|
191
|
+
if (char === '#' && (language === 'python' || isShellComment)) {
|
|
192
|
+
const stop = lineEndFrom(i)
|
|
193
|
+
mark(i, stop, commentKind())
|
|
194
|
+
i = stop
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
if (/[ \t\f\v]/.test(char)) {
|
|
198
|
+
i += 1
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
lineHasCode = true
|
|
202
|
+
// In shell a backslash escapes the next character outside strings too:
|
|
203
|
+
// `\'` is a quote character, not the start of a string.
|
|
204
|
+
if (language === 'shell' && char === '\\') {
|
|
205
|
+
i += 2
|
|
206
|
+
lastSignificant = 'a'
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
if (char === '`' && language === 'js') {
|
|
210
|
+
// A template literal: its text is a string, each `${...}` inside it is
|
|
211
|
+
// code (and may hold strings and templates of its own).
|
|
212
|
+
i = templateEnd(text, i, mark, STRING, CODE)
|
|
213
|
+
lastSignificant = '`'
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
if (char === '"' || char === "'") {
|
|
217
|
+
const triple = language === 'python' && text.startsWith(char.repeat(3), i)
|
|
218
|
+
const quote = triple ? char.repeat(3) : char
|
|
219
|
+
let j = i + quote.length
|
|
220
|
+
while (j < text.length) {
|
|
221
|
+
// A backslash escapes in every string but a shell's plain single-quoted
|
|
222
|
+
// one; in a shell `$'...'` string it escapes again.
|
|
223
|
+
if (text[j] === '\\' && !(language === 'shell' && char === "'" && text[i - 1] !== '$')) {
|
|
224
|
+
j += 2
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
if (text.startsWith(quote, j)) {
|
|
228
|
+
j += quote.length
|
|
229
|
+
break
|
|
230
|
+
}
|
|
231
|
+
if (!triple && text[j] === '\n' && language !== 'shell') break
|
|
232
|
+
j += 1
|
|
233
|
+
}
|
|
234
|
+
mark(i, j, STRING)
|
|
235
|
+
i = j
|
|
236
|
+
lastSignificant = char
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
if (language === 'js' && char === '/' && i >= noRegexBefore && regexCanStart(lastSignificant, lastWord)) {
|
|
240
|
+
let j = i + 1
|
|
241
|
+
let inClass = false
|
|
242
|
+
while (j < text.length && !isLineEnd(text[j])) {
|
|
243
|
+
if (text[j] === '\\') {
|
|
244
|
+
j += 2
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
if (text[j] === '[') inClass = true
|
|
248
|
+
else if (text[j] === ']') inClass = false
|
|
249
|
+
else if (text[j] === '/' && !inClass) break
|
|
250
|
+
j += 1
|
|
251
|
+
}
|
|
252
|
+
if (text[j] === '/') {
|
|
253
|
+
// The literal is skipped as one unit either way, so a `/*` inside it
|
|
254
|
+
// never opens a comment. Only one with an escape counts as a pattern;
|
|
255
|
+
// without one, what it holds is judged as code.
|
|
256
|
+
const isPattern = text.slice(i + 1, j).includes('\\')
|
|
257
|
+
j += 1
|
|
258
|
+
while (j < text.length && /[a-z]/i.test(text[j] as string)) j += 1
|
|
259
|
+
mark(i, j, isPattern ? REGEX : CODE)
|
|
260
|
+
i = j
|
|
261
|
+
lastSignificant = '/'
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
noRegexBefore = j
|
|
265
|
+
}
|
|
266
|
+
const before = lastSignificant
|
|
267
|
+
// `x++ / y` and `x-- / y` divide: a postfix operator ends an operand.
|
|
268
|
+
lastSignificant = (char === '+' || char === '-') && text[i - 1] === char ? 'a' : char
|
|
269
|
+
if (char === '(') parens.push(/^(?:if|while|for|with)$/.test(lastWord))
|
|
270
|
+
if (char === ')' && parens.pop() === true) lastSignificant = ';'
|
|
271
|
+
if (/[A-Za-z_$]/.test(char)) {
|
|
272
|
+
let j = i
|
|
273
|
+
while (j < text.length && /[\w$]/.test(text[j] as string)) j += 1
|
|
274
|
+
// `obj.in` or `x.return` is a property, an operand, never a keyword.
|
|
275
|
+
lastWord = before === '.' ? '' : text.slice(i, j)
|
|
276
|
+
lastSignificant = 'a'
|
|
277
|
+
i = j
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
lastWord = ''
|
|
281
|
+
i += 1
|
|
282
|
+
}
|
|
283
|
+
return index => (['code', 'string', 'comment', 'regex', 'trailing-comment'] as const)[kinds[index] ?? CODE] as SpanKind
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Marks a template literal starting at `start` (its backtick) and returns the
|
|
288
|
+
* index after its closing backtick: text as `string`, each `${...}` as `code`,
|
|
289
|
+
* with nested quotes and templates inside an interpolation skipped whole.
|
|
290
|
+
*/
|
|
291
|
+
const MAX_TEMPLATE_DEPTH = 32
|
|
292
|
+
|
|
293
|
+
function templateEnd(text: string, start: number, mark: (from: number, to: number, kind: number) => void, STRING: number, CODE: number, depth = 0): number {
|
|
294
|
+
// Nested deeper than any real code: the rest is judged as code, never hidden.
|
|
295
|
+
if (depth > MAX_TEMPLATE_DEPTH) {
|
|
296
|
+
mark(start, text.length, CODE)
|
|
297
|
+
return text.length
|
|
298
|
+
}
|
|
299
|
+
let j = start + 1
|
|
300
|
+
let segment = start
|
|
301
|
+
while (j < text.length) {
|
|
302
|
+
if (text[j] === '\\') {
|
|
303
|
+
j += 2
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
if (text[j] === '`') {
|
|
307
|
+
mark(segment, j + 1, STRING)
|
|
308
|
+
return j + 1
|
|
309
|
+
}
|
|
310
|
+
if (text[j] === '$' && text[j + 1] === '{') {
|
|
311
|
+
mark(segment, j + 2, STRING)
|
|
312
|
+
const close = interpolationEnd(text, j + 2, mark, STRING, CODE, depth + 1)
|
|
313
|
+
j = close + 1
|
|
314
|
+
segment = j
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
j += 1
|
|
318
|
+
}
|
|
319
|
+
mark(segment, text.length, STRING)
|
|
320
|
+
return text.length
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The index of the `}` closing an interpolation opened just before `start`. */
|
|
324
|
+
function interpolationEnd(text: string, start: number, mark: (from: number, to: number, kind: number) => void, STRING: number, CODE: number, nesting: number): number {
|
|
325
|
+
let depth = 1
|
|
326
|
+
let j = start
|
|
327
|
+
let codeFrom = start
|
|
328
|
+
while (j < text.length) {
|
|
329
|
+
const char = text[j]
|
|
330
|
+
if (char === '`') {
|
|
331
|
+
mark(codeFrom, j, CODE)
|
|
332
|
+
j = templateEnd(text, j, mark, STRING, CODE, nesting + 1)
|
|
333
|
+
codeFrom = j
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
if (char === '"' || char === "'") {
|
|
337
|
+
let k = j + 1
|
|
338
|
+
while (k < text.length && text[k] !== char && text[k] !== '\n') k += text[k] === '\\' ? 2 : 1
|
|
339
|
+
j = k + 1
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
if (char === '{') depth += 1
|
|
343
|
+
if (char === '}') {
|
|
344
|
+
depth -= 1
|
|
345
|
+
if (depth === 0) {
|
|
346
|
+
mark(codeFrom, j, CODE)
|
|
347
|
+
return j
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
j += 1
|
|
351
|
+
}
|
|
352
|
+
mark(codeFrom, text.length, CODE)
|
|
353
|
+
return text.length
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function regexCanStart(lastSignificant: string, lastWord: string): boolean {
|
|
357
|
+
if (lastSignificant === '') return true
|
|
358
|
+
if (lastSignificant === 'a') return /^(?:return|typeof|case|do|else|in|of|new|delete|void|throw|yield|await|instanceof)$/.test(lastWord)
|
|
359
|
+
// Not after `)`, `]` or `}`: there a slash divides, and reading code as a
|
|
360
|
+
// pattern would hide it.
|
|
361
|
+
return /[(,=:[!&|?{;+\-*%<>~^]/.test(lastSignificant)
|
|
362
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { redactSecrets } from './patterns'
|
|
2
|
+
|
|
3
|
+
export type Status = 'pass' | 'warn' | 'fail' | 'info'
|
|
4
|
+
|
|
5
|
+
export type Severity = 'none' | 'low' | 'medium' | 'high' | 'critical'
|
|
6
|
+
|
|
7
|
+
export type GroupId =
|
|
8
|
+
| 'provenance'
|
|
9
|
+
| 'structure'
|
|
10
|
+
| 'mod'
|
|
11
|
+
| 'hooks'
|
|
12
|
+
| 'mcp'
|
|
13
|
+
| 'prompts'
|
|
14
|
+
| 'code'
|
|
15
|
+
| 'secrets'
|
|
16
|
+
| 'quality'
|
|
17
|
+
| 'update'
|
|
18
|
+
|
|
19
|
+
/** The report's groups, in the order it shows them. */
|
|
20
|
+
export const GROUPS: readonly { id: GroupId; title: string }[] = [
|
|
21
|
+
{ id: 'provenance', title: 'Provenance and trust' },
|
|
22
|
+
{ id: 'structure', title: 'Structure and manifest' },
|
|
23
|
+
{ id: 'mod', title: 'Mod capabilities' },
|
|
24
|
+
{ id: 'hooks', title: 'Classic hooks' },
|
|
25
|
+
{ id: 'mcp', title: 'MCP servers' },
|
|
26
|
+
{ id: 'prompts', title: 'Skills, commands and agents' },
|
|
27
|
+
{ id: 'code', title: 'Code patterns' },
|
|
28
|
+
{ id: 'secrets', title: 'Committed secrets' },
|
|
29
|
+
{ id: 'quality', title: 'Quality signals' },
|
|
30
|
+
{ id: 'update', title: 'New since the version you approved' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One place a check points at. `excerpt` is quoted from the candidate and is
|
|
35
|
+
* untrusted: reports pass it through `safeText` and never render it as
|
|
36
|
+
* markup. `status` says how bad this one place is, when a row mixes them.
|
|
37
|
+
*/
|
|
38
|
+
export type Evidence = {
|
|
39
|
+
file: string
|
|
40
|
+
line?: number
|
|
41
|
+
excerpt: string
|
|
42
|
+
status?: Status
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type CheckResult = {
|
|
46
|
+
id: string
|
|
47
|
+
group: GroupId
|
|
48
|
+
title: string
|
|
49
|
+
status: Status
|
|
50
|
+
severity: Severity
|
|
51
|
+
evidence: Evidence[]
|
|
52
|
+
advice: string
|
|
53
|
+
/**
|
|
54
|
+
* What the evidence means, in plain words for a person: a headline, then a
|
|
55
|
+
* few lines. Shown before the evidence, which stays one step further in.
|
|
56
|
+
*/
|
|
57
|
+
summary?: string[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const RANK: Record<Status, number> = { info: 0, pass: 1, warn: 2, fail: 3 }
|
|
61
|
+
|
|
62
|
+
/** The worse of two statuses: fail > warn > pass > info. */
|
|
63
|
+
export function worse(a: Status, b: Status): Status {
|
|
64
|
+
return RANK[a] >= RANK[b] ? a : b
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function rankOf(status: Status): number {
|
|
68
|
+
return RANK[status]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function severityFor(status: Status): Severity {
|
|
72
|
+
return status === 'fail' ? 'high' : status === 'warn' ? 'medium' : 'none'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* At most `max` pieces of evidence, the worst first, and a line counting the
|
|
77
|
+
* rest. Every piece is judged before capping, so a late failure still counts.
|
|
78
|
+
*/
|
|
79
|
+
export function capEvidence(evidence: Evidence[], max: number, fallback: Status = 'warn'): Evidence[] {
|
|
80
|
+
if (evidence.length <= max) return evidence
|
|
81
|
+
const worstFirst = [...evidence].sort((a, b) => RANK[b.status ?? fallback] - RANK[a.status ?? fallback])
|
|
82
|
+
return [...worstFirst.slice(0, max), { file: '-', excerpt: `and ${evidence.length - max} more`, status: 'info' }]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Builds a check's row from its evidence: the row takes the worst status any
|
|
87
|
+
* piece of evidence carries (`fallback` when none does), and `pass` when
|
|
88
|
+
* there is no evidence at all.
|
|
89
|
+
*/
|
|
90
|
+
export function row(
|
|
91
|
+
base: { id: string; group: GroupId; title: string; advice: string; summary?: string[] },
|
|
92
|
+
evidence: Evidence[],
|
|
93
|
+
fallback: Status = 'warn',
|
|
94
|
+
severity?: Severity,
|
|
95
|
+
): CheckResult {
|
|
96
|
+
if (evidence.length === 0) {
|
|
97
|
+
const { summary: _unused, ...rest } = base
|
|
98
|
+
return { ...rest, status: 'pass', severity: 'none', evidence: [] }
|
|
99
|
+
}
|
|
100
|
+
const status = evidence.reduce<Status>((acc, item) => worse(acc, item.status ?? fallback), 'info')
|
|
101
|
+
const worstFirst = [...evidence]
|
|
102
|
+
.sort((a, b) => RANK[b.status ?? fallback] - RANK[a.status ?? fallback])
|
|
103
|
+
.map(item => ({ ...item, excerpt: redactSecrets(item.excerpt) }))
|
|
104
|
+
return { ...base, status, severity: severity ?? severityFor(status), evidence: worstFirst }
|
|
105
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Host } from '../host'
|
|
2
|
+
import { isRecord, parseJson } from './context'
|
|
3
|
+
|
|
4
|
+
export type ValidatorProblem = { file: string; message: string }
|
|
5
|
+
|
|
6
|
+
export type ValidatorReport =
|
|
7
|
+
| {
|
|
8
|
+
ran: true
|
|
9
|
+
errors: ValidatorProblem[]
|
|
10
|
+
warnings: ValidatorProblem[]
|
|
11
|
+
/** Per module: the events it hooks and the `$` calls it makes, as Claude Code reads them. */
|
|
12
|
+
modules: { module: string; hooks: string[]; calls: string[] }[]
|
|
13
|
+
}
|
|
14
|
+
| { ran: false; reason: string }
|
|
15
|
+
|
|
16
|
+
const TIMEOUT_MS = 60_000
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runs `claude plugin validate --json` on the candidate. It reads the
|
|
20
|
+
* manifest and the hooks module's source the way Claude Code will and runs
|
|
21
|
+
* none of it, so it is the one external tool the scan uses on a candidate.
|
|
22
|
+
*/
|
|
23
|
+
export async function runValidator(host: Host, root: string): Promise<ValidatorReport> {
|
|
24
|
+
const result = await host
|
|
25
|
+
.run(['claude', 'plugin', 'validate', '--json', root], { timeoutMs: TIMEOUT_MS })
|
|
26
|
+
.catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: error instanceof Error ? error.message : String(error) }))
|
|
27
|
+
const parsed = parseJson(result.stdout)
|
|
28
|
+
// Only a report shaped like one counts: an error object is not a pass.
|
|
29
|
+
const isReport = parsed.ok && isRecord(parsed.value) && typeof parsed.value.success === 'boolean' && ('manifest' in parsed.value || 'contents' in parsed.value)
|
|
30
|
+
if (!isReport || !parsed.ok || !isRecord(parsed.value)) {
|
|
31
|
+
const reason = result.stderr.trim().split('\n')[0] ?? ''
|
|
32
|
+
return { ran: false, reason: reason === '' ? `exit code ${result.exitCode}` : reason.slice(0, 200) }
|
|
33
|
+
}
|
|
34
|
+
const report = relativeTo(root, parseValidatorReport(parsed.value))
|
|
35
|
+
if (report.ran && parsed.value.success === false && report.errors.length === 0) {
|
|
36
|
+
report.errors.push({ file: '-', message: `claude plugin validate failed (exit code ${result.exitCode})` })
|
|
37
|
+
}
|
|
38
|
+
return report
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parseValidatorReport(report: Record<string, unknown>): ValidatorReport {
|
|
42
|
+
const errors: ValidatorProblem[] = []
|
|
43
|
+
const warnings: ValidatorProblem[] = []
|
|
44
|
+
const modules: { module: string; hooks: string[]; calls: string[] }[] = []
|
|
45
|
+
const sections = [report.manifest, ...(Array.isArray(report.contents) ? report.contents : [])]
|
|
46
|
+
for (const section of sections) {
|
|
47
|
+
if (!isRecord(section)) continue
|
|
48
|
+
const file = typeof section.file === 'string' ? section.file : '-'
|
|
49
|
+
for (const item of Array.isArray(section.errors) ? section.errors : []) errors.push(problem(file, item))
|
|
50
|
+
for (const item of Array.isArray(section.warnings) ? section.warnings : []) warnings.push(problem(file, item))
|
|
51
|
+
for (const note of Array.isArray(section.notes) ? section.notes : []) {
|
|
52
|
+
if (typeof note !== 'string') continue
|
|
53
|
+
const hooks = /^(\S+) hooks: (.*)$/.exec(note)
|
|
54
|
+
const calls = /^(\S+) calls: (.*)$/.exec(note)
|
|
55
|
+
const match = hooks ?? calls
|
|
56
|
+
if (match === null) continue
|
|
57
|
+
const name = match[1] as string
|
|
58
|
+
let entry = modules.find(item => item.module === name)
|
|
59
|
+
if (entry === undefined) {
|
|
60
|
+
entry = { module: name, hooks: [], calls: [] }
|
|
61
|
+
modules.push(entry)
|
|
62
|
+
}
|
|
63
|
+
const list = splitList(match[2] as string)
|
|
64
|
+
if (hooks !== null) entry.hooks.push(...list)
|
|
65
|
+
else entry.calls.push(...list.map(call => call.replace(/^\$\./, '').replace(/\s*\(via [^)]*\)$/, '')))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { ran: true, errors, warnings, modules }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function problem(file: string, item: unknown): ValidatorProblem {
|
|
72
|
+
if (typeof item === 'string') return { file, message: item }
|
|
73
|
+
if (isRecord(item)) {
|
|
74
|
+
const path = typeof item.path === 'string' && item.path !== '' ? `${item.path}: ` : ''
|
|
75
|
+
return { file, message: `${path}${typeof item.message === 'string' ? item.message : JSON.stringify(item)}` }
|
|
76
|
+
}
|
|
77
|
+
return { file, message: String(item) }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Splits `a, b{x=1, y=2}, c` on top-level commas. */
|
|
81
|
+
function splitList(text: string): string[] {
|
|
82
|
+
const out: string[] = []
|
|
83
|
+
let depth = 0
|
|
84
|
+
let current = ''
|
|
85
|
+
for (const char of text) {
|
|
86
|
+
if (char === '{' || char === '(') depth += 1
|
|
87
|
+
if (char === '}' || char === ')') depth -= 1
|
|
88
|
+
if (char === ',' && depth === 0) {
|
|
89
|
+
if (current.trim() !== '') out.push(current.trim())
|
|
90
|
+
current = ''
|
|
91
|
+
} else {
|
|
92
|
+
current += char
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (current.trim() !== '') out.push(current.trim())
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The report with the scanned folder's own path taken out of file names and
|
|
101
|
+
* messages: a remote scan's folder is a temporary one, and every other place
|
|
102
|
+
* in the report is relative to the candidate.
|
|
103
|
+
*/
|
|
104
|
+
function relativeTo(root: string, report: ValidatorReport): ValidatorReport {
|
|
105
|
+
if (!report.ran) return report
|
|
106
|
+
const roots = [root, `/private${root}`].map(path => path.replace(/\/+$/, ''))
|
|
107
|
+
const strip = (text: string): string =>
|
|
108
|
+
roots.reduce((out, path) => out.split(`${path}/`).join('').split(path).join('.'), text)
|
|
109
|
+
const fix = (item: ValidatorProblem): ValidatorProblem => ({ file: strip(item.file), message: strip(item.message) })
|
|
110
|
+
return { ...report, errors: report.errors.map(fix), warnings: report.warnings.map(fix) }
|
|
111
|
+
}
|