skill-checker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,2166 @@
1
+ // src/cli.ts
2
+ import { Command } from "commander";
3
+
4
+ // src/parser.ts
5
+ import { readFileSync, readdirSync, statSync, existsSync } from "fs";
6
+ import { join, extname, basename, resolve } from "path";
7
+ import { parse as parseYaml } from "yaml";
8
+ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
9
+ ".png",
10
+ ".jpg",
11
+ ".jpeg",
12
+ ".gif",
13
+ ".bmp",
14
+ ".ico",
15
+ ".webp",
16
+ ".svg",
17
+ ".woff",
18
+ ".woff2",
19
+ ".ttf",
20
+ ".eot",
21
+ ".otf",
22
+ ".zip",
23
+ ".gz",
24
+ ".tar",
25
+ ".bz2",
26
+ ".7z",
27
+ ".rar",
28
+ ".exe",
29
+ ".dll",
30
+ ".so",
31
+ ".dylib",
32
+ ".bin",
33
+ ".pdf",
34
+ ".doc",
35
+ ".docx",
36
+ ".xls",
37
+ ".xlsx",
38
+ ".mp3",
39
+ ".mp4",
40
+ ".wav",
41
+ ".avi",
42
+ ".mov",
43
+ ".wasm",
44
+ ".pyc",
45
+ ".class"
46
+ ]);
47
+ function parseSkill(dirPath) {
48
+ const absDir = resolve(dirPath);
49
+ const skillMdPath = join(absDir, "SKILL.md");
50
+ const hasSkillMd = existsSync(skillMdPath);
51
+ const raw = hasSkillMd ? readFileSync(skillMdPath, "utf-8") : "";
52
+ const { frontmatter, frontmatterValid, body, bodyStartLine } = parseFrontmatter(raw);
53
+ const files = enumerateFiles(absDir);
54
+ return {
55
+ dirPath: absDir,
56
+ raw,
57
+ frontmatter,
58
+ frontmatterValid,
59
+ body,
60
+ bodyLines: body.split("\n"),
61
+ bodyStartLine,
62
+ files
63
+ };
64
+ }
65
+ function parseFrontmatter(raw) {
66
+ const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
67
+ const match = raw.match(fmRegex);
68
+ if (!match) {
69
+ return {
70
+ frontmatter: {},
71
+ frontmatterValid: false,
72
+ body: raw,
73
+ bodyStartLine: 1
74
+ };
75
+ }
76
+ const yamlStr = match[1];
77
+ const fmLineCount = match[0].split("\n").length;
78
+ try {
79
+ const parsed = parseYaml(yamlStr);
80
+ return {
81
+ frontmatter: typeof parsed === "object" && parsed !== null ? parsed : {},
82
+ frontmatterValid: true,
83
+ body: raw.slice(match[0].length),
84
+ bodyStartLine: fmLineCount
85
+ };
86
+ } catch {
87
+ return {
88
+ frontmatter: {},
89
+ frontmatterValid: false,
90
+ body: raw.slice(match[0].length),
91
+ bodyStartLine: fmLineCount
92
+ };
93
+ }
94
+ }
95
+ function enumerateFiles(dirPath, maxDepth = 5) {
96
+ const files = [];
97
+ if (!existsSync(dirPath)) return files;
98
+ function walk(currentDir, depth) {
99
+ if (depth > maxDepth) return;
100
+ let entries;
101
+ try {
102
+ entries = readdirSync(currentDir, { withFileTypes: true });
103
+ } catch {
104
+ return;
105
+ }
106
+ for (const entry of entries) {
107
+ const fullPath = join(currentDir, entry.name);
108
+ if (entry.isDirectory()) {
109
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
110
+ walk(fullPath, depth + 1);
111
+ continue;
112
+ }
113
+ const ext = extname(entry.name).toLowerCase();
114
+ let stats;
115
+ try {
116
+ stats = statSync(fullPath);
117
+ } catch {
118
+ continue;
119
+ }
120
+ const isBinary = BINARY_EXTENSIONS.has(ext);
121
+ const relativePath = fullPath.slice(dirPath.length + 1);
122
+ let content;
123
+ if (!isBinary && stats.size < 1e6) {
124
+ try {
125
+ content = readFileSync(fullPath, "utf-8");
126
+ } catch {
127
+ }
128
+ }
129
+ files.push({
130
+ path: relativePath,
131
+ name: basename(entry.name, ext),
132
+ extension: ext,
133
+ sizeBytes: stats.size,
134
+ isBinary,
135
+ content
136
+ });
137
+ }
138
+ }
139
+ walk(dirPath, 0);
140
+ return files;
141
+ }
142
+
143
+ // src/checks/structural.ts
144
+ var HYPHEN_CASE_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
145
+ var MAX_NAME_LENGTH = 64;
146
+ var EXECUTABLE_EXTENSIONS = /* @__PURE__ */ new Set([
147
+ ".exe",
148
+ ".bat",
149
+ ".cmd",
150
+ ".sh",
151
+ ".bash",
152
+ ".ps1",
153
+ ".com",
154
+ ".msi"
155
+ ]);
156
+ var BINARY_EXTENSIONS2 = /* @__PURE__ */ new Set([
157
+ ".exe",
158
+ ".dll",
159
+ ".so",
160
+ ".dylib",
161
+ ".bin",
162
+ ".wasm",
163
+ ".class",
164
+ ".pyc"
165
+ ]);
166
+ var structuralChecks = {
167
+ name: "Structural Validity",
168
+ category: "STRUCT",
169
+ run(skill) {
170
+ const results = [];
171
+ if (!skill.raw) {
172
+ results.push({
173
+ id: "STRUCT-001",
174
+ category: "STRUCT",
175
+ severity: "CRITICAL",
176
+ title: "Missing SKILL.md",
177
+ message: "No SKILL.md file found in the skill directory."
178
+ });
179
+ return results;
180
+ }
181
+ if (!skill.frontmatterValid) {
182
+ results.push({
183
+ id: "STRUCT-002",
184
+ category: "STRUCT",
185
+ severity: "HIGH",
186
+ title: "Invalid YAML frontmatter",
187
+ message: "SKILL.md is missing valid YAML frontmatter (---...--- block)."
188
+ });
189
+ }
190
+ if (!skill.frontmatter.name) {
191
+ results.push({
192
+ id: "STRUCT-003",
193
+ category: "STRUCT",
194
+ severity: "HIGH",
195
+ title: "Missing name field",
196
+ message: 'Frontmatter is missing the required "name" field.'
197
+ });
198
+ }
199
+ if (!skill.frontmatter.description) {
200
+ results.push({
201
+ id: "STRUCT-004",
202
+ category: "STRUCT",
203
+ severity: "MEDIUM",
204
+ title: "Missing description field",
205
+ message: 'Frontmatter is missing the "description" field.'
206
+ });
207
+ }
208
+ if (skill.body.trim().length < 50) {
209
+ results.push({
210
+ id: "STRUCT-005",
211
+ category: "STRUCT",
212
+ severity: "CRITICAL",
213
+ title: "SKILL.md body is too short",
214
+ message: `Body is only ${skill.body.trim().length} characters. A valid skill should have meaningful instructions (>=50 chars).`
215
+ });
216
+ }
217
+ for (const file of skill.files) {
218
+ const ext = file.extension.toLowerCase();
219
+ if (BINARY_EXTENSIONS2.has(ext) || EXECUTABLE_EXTENSIONS.has(ext)) {
220
+ results.push({
221
+ id: "STRUCT-006",
222
+ category: "STRUCT",
223
+ severity: "HIGH",
224
+ title: "Unexpected binary/executable file",
225
+ message: `Found unexpected file: ${file.path} (${ext})`
226
+ });
227
+ }
228
+ }
229
+ const name = skill.frontmatter.name;
230
+ if (name) {
231
+ if (!HYPHEN_CASE_RE.test(name)) {
232
+ results.push({
233
+ id: "STRUCT-007",
234
+ category: "STRUCT",
235
+ severity: "MEDIUM",
236
+ title: "Name not in hyphen-case format",
237
+ message: `Skill name "${name}" should be in hyphen-case (e.g. "my-skill").`
238
+ });
239
+ }
240
+ if (name.length > MAX_NAME_LENGTH) {
241
+ results.push({
242
+ id: "STRUCT-007",
243
+ category: "STRUCT",
244
+ severity: "MEDIUM",
245
+ title: "Name too long",
246
+ message: `Skill name is ${name.length} chars, max ${MAX_NAME_LENGTH}.`
247
+ });
248
+ }
249
+ }
250
+ return results;
251
+ }
252
+ };
253
+
254
+ // src/utils/context.ts
255
+ function isInCodeBlock(lines, lineIndex) {
256
+ let inBlock = false;
257
+ for (let i = 0; i < lineIndex && i < lines.length; i++) {
258
+ if (lines[i].trim().startsWith("```")) {
259
+ inBlock = !inBlock;
260
+ }
261
+ }
262
+ return inBlock;
263
+ }
264
+ function isNamespaceOrSchemaURI(url, line) {
265
+ if (/\bxmlns\b/i.test(line)) return true;
266
+ if (/\bnamespace\b/i.test(line)) return true;
267
+ if (/\bschema[s]?\b/i.test(line) && !/(schema\.org)/i.test(url)) return true;
268
+ const parsed = parseURLPath(url);
269
+ if (!parsed) return false;
270
+ if (/\/\d{4}\//.test(parsed.path)) {
271
+ if (!parsed.hasQuery && !parsed.hasFileExtension) return true;
272
+ }
273
+ return false;
274
+ }
275
+ function isInNetworkRequestContext(line) {
276
+ const networkPatterns = [
277
+ /\bfetch\s*\(/i,
278
+ /\bcurl\s+/i,
279
+ /\bwget\s+/i,
280
+ /\baxios\b/i,
281
+ /\brequests?\.(get|post|put|delete|head)\s*\(/i,
282
+ /\bhttp\.(get|request)\s*\(/i,
283
+ /\bopen\s*\(\s*["'](?:GET|POST|PUT|DELETE)/i,
284
+ /\bURLSession\b/,
285
+ /\bInvoke-WebRequest\b/i
286
+ ];
287
+ return networkPatterns.some((p) => p.test(line));
288
+ }
289
+ function isInDocumentationContext(lines, lineIndex) {
290
+ const line = lines[lineIndex];
291
+ if (/^\s*[-*]\s+\*\*\w+\*\*\s*[::]/.test(line)) return true;
292
+ for (let i = lineIndex; i >= Math.max(0, lineIndex - 15); i--) {
293
+ const l = lines[i];
294
+ if (/^#{1,4}\s+.*(install|setup|prerequisite|requirement|depend|getting\s+started)/i.test(l)) {
295
+ return true;
296
+ }
297
+ }
298
+ return false;
299
+ }
300
+ function parseURLPath(url) {
301
+ try {
302
+ const u = new URL(url);
303
+ const hasQuery = u.search.length > 0;
304
+ const lastSegment = u.pathname.split("/").pop() ?? "";
305
+ const hasFileExtension = /\.\w{1,5}$/.test(lastSegment);
306
+ return { path: u.pathname, hasQuery, hasFileExtension };
307
+ } catch {
308
+ return null;
309
+ }
310
+ }
311
+
312
+ // src/checks/content.ts
313
+ var STRICT_PLACEHOLDER_PATTERNS = [
314
+ /\bTODO\b/,
315
+ /\bFIXME\b/,
316
+ /\bHACK\b/,
317
+ /\bXXX\b/,
318
+ /\binsert\s+here\b/i,
319
+ /\bfill\s+in\b/i,
320
+ /\bTBD\b/,
321
+ /\bcoming\s+soon\b/i
322
+ ];
323
+ var CONTEXT_SENSITIVE_PLACEHOLDER_PATTERNS = [
324
+ /\bplaceholder\b/i
325
+ ];
326
+ var LOREM_PATTERNS = [
327
+ /lorem\s+ipsum/i,
328
+ /dolor\s+sit\s+amet/i,
329
+ /consectetur\s+adipiscing/i
330
+ ];
331
+ var AD_PATTERNS = [
332
+ /\bbuy\s+now\b/i,
333
+ /\bfree\s+trial\b/i,
334
+ /\bdiscount\b/i,
335
+ /\bpromo\s*code\b/i,
336
+ /\bsubscribe\s+(to|now)\b/i,
337
+ /\bsponsored\s+by\b/i,
338
+ /\baffiliate\s+link\b/i,
339
+ /\bclick\s+here\s+to\s+(buy|subscribe|download)/i,
340
+ /\buse\s+code\b.*\b\d+%?\s*off\b/i,
341
+ /\bcheck\s+out\s+my\b/i
342
+ ];
343
+ var contentChecks = {
344
+ name: "Content Quality",
345
+ category: "CONT",
346
+ run(skill) {
347
+ const results = [];
348
+ if (!skill.body || skill.body.trim().length === 0) return results;
349
+ for (let i = 0; i < skill.bodyLines.length; i++) {
350
+ const line = skill.bodyLines[i];
351
+ let matched = false;
352
+ for (const pattern of STRICT_PLACEHOLDER_PATTERNS) {
353
+ if (pattern.test(line)) {
354
+ matched = true;
355
+ break;
356
+ }
357
+ }
358
+ if (!matched) {
359
+ const inCodeBlk = isInCodeBlock(skill.bodyLines, i);
360
+ const hasInlineCode = /`[^`]*placeholder[^`]*`/i.test(line);
361
+ const isTechnicalRef = (
362
+ // CSS/HTML context
363
+ /class\s*=\s*["'].*placeholder/i.test(line) || // Compound technical terms
364
+ /placeholder[_-]?(type|text|image|content|area|location|id|index|name|shape)/i.test(line) || // PPT/slide layout context: placeholder alongside slide/layout terms
365
+ /\bplaceholder\b.*\b(TITLE|SUBTITLE|BODY|OBJECT|SLIDE|layout|slide|shape|pptx|presentation)/i.test(line) || /\b(TITLE|SUBTITLE|BODY|OBJECT|SLIDE|layout|slide|shape|pptx|presentation)\b.*\bplaceholder\b/i.test(line) || // API/code context: placeholder as a noun in technical documentation
366
+ /\bplaceholder\s+(areas?|locations?|counts?|slots?|elements?|fields?)\b/i.test(line) || /\b(replace|replacing|replacement)\b.*\bplaceholder\b/i.test(line)
367
+ );
368
+ if (!inCodeBlk && !hasInlineCode && !isTechnicalRef) {
369
+ for (const pattern of CONTEXT_SENSITIVE_PLACEHOLDER_PATTERNS) {
370
+ if (pattern.test(line)) {
371
+ matched = true;
372
+ break;
373
+ }
374
+ }
375
+ }
376
+ }
377
+ if (matched) {
378
+ results.push({
379
+ id: "CONT-001",
380
+ category: "CONT",
381
+ severity: "HIGH",
382
+ title: "Placeholder content detected",
383
+ message: `Line ${skill.bodyStartLine + i}: Contains placeholder text.`,
384
+ line: skill.bodyStartLine + i,
385
+ snippet: line.trim().slice(0, 120)
386
+ });
387
+ }
388
+ }
389
+ for (const pattern of LOREM_PATTERNS) {
390
+ if (pattern.test(skill.body)) {
391
+ results.push({
392
+ id: "CONT-002",
393
+ category: "CONT",
394
+ severity: "CRITICAL",
395
+ title: "Lorem ipsum filler text",
396
+ message: "Body contains lorem ipsum placeholder text."
397
+ });
398
+ break;
399
+ }
400
+ }
401
+ checkRepetition(results, skill);
402
+ checkDescriptionMismatch(results, skill);
403
+ for (let i = 0; i < skill.bodyLines.length; i++) {
404
+ const line = skill.bodyLines[i];
405
+ for (const pattern of AD_PATTERNS) {
406
+ if (pattern.test(line)) {
407
+ results.push({
408
+ id: "CONT-005",
409
+ category: "CONT",
410
+ severity: "HIGH",
411
+ title: "Promotional/advertising content",
412
+ message: `Line ${skill.bodyStartLine + i}: Contains ad-like content.`,
413
+ line: skill.bodyStartLine + i,
414
+ snippet: line.trim().slice(0, 120)
415
+ });
416
+ break;
417
+ }
418
+ }
419
+ }
420
+ checkCodeHeavy(results, skill);
421
+ checkNameMismatch(results, skill);
422
+ return results;
423
+ }
424
+ };
425
+ function checkRepetition(results, skill) {
426
+ const lines = skill.bodyLines.filter((l) => l.trim().length > 0);
427
+ if (lines.length < 5) return;
428
+ const lineCounts = /* @__PURE__ */ new Map();
429
+ for (const line of lines) {
430
+ const normalized = line.trim().toLowerCase();
431
+ lineCounts.set(normalized, (lineCounts.get(normalized) ?? 0) + 1);
432
+ }
433
+ let duplicated = 0;
434
+ for (const count of lineCounts.values()) {
435
+ if (count > 1) duplicated += count - 1;
436
+ }
437
+ const ratio = duplicated / lines.length;
438
+ if (ratio > 0.5) {
439
+ results.push({
440
+ id: "CONT-003",
441
+ category: "CONT",
442
+ severity: "MEDIUM",
443
+ title: "Low information density",
444
+ message: `${Math.round(ratio * 100)}% of lines are duplicates. Possible filler content.`
445
+ });
446
+ }
447
+ }
448
+ function checkDescriptionMismatch(results, skill) {
449
+ const desc = skill.frontmatter.description;
450
+ if (!desc || desc.length < 10) return;
451
+ const descWords = desc.toLowerCase().split(/\W+/).filter((w) => w.length > 4);
452
+ if (descWords.length === 0) return;
453
+ const bodyLower = skill.body.toLowerCase();
454
+ const matched = descWords.filter((w) => bodyLower.includes(w));
455
+ if (matched.length / descWords.length < 0.2) {
456
+ results.push({
457
+ id: "CONT-004",
458
+ category: "CONT",
459
+ severity: "MEDIUM",
460
+ title: "Description/body mismatch",
461
+ message: "The frontmatter description appears unrelated to the body content."
462
+ });
463
+ }
464
+ }
465
+ function checkCodeHeavy(results, skill) {
466
+ const lines = skill.bodyLines;
467
+ if (lines.length < 10) return;
468
+ let inCodeBlock = false;
469
+ let codeLines = 0;
470
+ for (const line of lines) {
471
+ if (line.trim().startsWith("```")) {
472
+ inCodeBlock = !inCodeBlock;
473
+ continue;
474
+ }
475
+ if (inCodeBlock) codeLines++;
476
+ }
477
+ const nonEmptyLines = lines.filter((l) => l.trim().length > 0).length;
478
+ if (nonEmptyLines > 0 && codeLines / nonEmptyLines > 0.8) {
479
+ results.push({
480
+ id: "CONT-006",
481
+ category: "CONT",
482
+ severity: "MEDIUM",
483
+ title: "Body is mostly code examples",
484
+ message: "Over 80% of body content is in code blocks with minimal instructions."
485
+ });
486
+ }
487
+ }
488
+ function checkNameMismatch(results, skill) {
489
+ const name = skill.frontmatter.name;
490
+ if (!name) return;
491
+ const nameWords = name.split(/[-_]/).filter((w) => w.length > 2).map((w) => w.toLowerCase());
492
+ const bodyLower = skill.body.toLowerCase();
493
+ const capabilityHints = nameWords.filter(
494
+ (w) => !["the", "and", "for", "skill", "tool", "helper", "util"].includes(w)
495
+ );
496
+ if (capabilityHints.length === 0) return;
497
+ const matched = capabilityHints.filter((w) => bodyLower.includes(w));
498
+ if (matched.length === 0 && capabilityHints.length >= 2) {
499
+ results.push({
500
+ id: "CONT-007",
501
+ category: "CONT",
502
+ severity: "HIGH",
503
+ title: "Name/body capability mismatch",
504
+ message: `Skill name "${name}" implies capabilities not found in body content.`
505
+ });
506
+ }
507
+ }
508
+
509
+ // src/utils/unicode.ts
510
+ var ZERO_WIDTH_CHARS = [
511
+ "\u200B",
512
+ // ZERO WIDTH SPACE
513
+ "\u200C",
514
+ // ZERO WIDTH NON-JOINER
515
+ "\u200D",
516
+ // ZERO WIDTH JOINER
517
+ "\u200E",
518
+ // LEFT-TO-RIGHT MARK
519
+ "\u200F",
520
+ // RIGHT-TO-LEFT MARK
521
+ "\uFEFF",
522
+ // ZERO WIDTH NO-BREAK SPACE (BOM)
523
+ "\u2060",
524
+ // WORD JOINER
525
+ "\u2061",
526
+ // FUNCTION APPLICATION
527
+ "\u2062",
528
+ // INVISIBLE TIMES
529
+ "\u2063",
530
+ // INVISIBLE SEPARATOR
531
+ "\u2064"
532
+ // INVISIBLE PLUS
533
+ ];
534
+ var RTL_OVERRIDE_CHARS = [
535
+ "\u202A",
536
+ // LEFT-TO-RIGHT EMBEDDING
537
+ "\u202B",
538
+ // RIGHT-TO-LEFT EMBEDDING
539
+ "\u202C",
540
+ // POP DIRECTIONAL FORMATTING
541
+ "\u202D",
542
+ // LEFT-TO-RIGHT OVERRIDE
543
+ "\u202E",
544
+ // RIGHT-TO-LEFT OVERRIDE
545
+ "\u2066",
546
+ // LEFT-TO-RIGHT ISOLATE
547
+ "\u2067",
548
+ // RIGHT-TO-LEFT ISOLATE
549
+ "\u2068",
550
+ // FIRST STRONG ISOLATE
551
+ "\u2069"
552
+ // POP DIRECTIONAL ISOLATE
553
+ ];
554
+ var HOMOGLYPHS = {
555
+ "\u0410": "A",
556
+ // Cyrillic А
557
+ "\u0412": "B",
558
+ // Cyrillic В
559
+ "\u0421": "C",
560
+ // Cyrillic С
561
+ "\u0415": "E",
562
+ // Cyrillic Е
563
+ "\u041D": "H",
564
+ // Cyrillic Н
565
+ "\u041A": "K",
566
+ // Cyrillic К
567
+ "\u041C": "M",
568
+ // Cyrillic М
569
+ "\u041E": "O",
570
+ // Cyrillic О
571
+ "\u0420": "P",
572
+ // Cyrillic Р
573
+ "\u0422": "T",
574
+ // Cyrillic Т
575
+ "\u0425": "X",
576
+ // Cyrillic Х
577
+ "\u0430": "a",
578
+ // Cyrillic а
579
+ "\u0435": "e",
580
+ // Cyrillic е
581
+ "\u043E": "o",
582
+ // Cyrillic о
583
+ "\u0440": "p",
584
+ // Cyrillic р
585
+ "\u0441": "c",
586
+ // Cyrillic с
587
+ "\u0443": "y",
588
+ // Cyrillic у
589
+ "\u0445": "x",
590
+ // Cyrillic х
591
+ "\u0391": "A",
592
+ // Greek Α
593
+ "\u0392": "B",
594
+ // Greek Β
595
+ "\u0395": "E",
596
+ // Greek Ε
597
+ "\u0397": "H",
598
+ // Greek Η
599
+ "\u0399": "I",
600
+ // Greek Ι
601
+ "\u039A": "K",
602
+ // Greek Κ
603
+ "\u039C": "M",
604
+ // Greek Μ
605
+ "\u039D": "N",
606
+ // Greek Ν
607
+ "\u039F": "O",
608
+ // Greek Ο
609
+ "\u03A1": "P",
610
+ // Greek Ρ
611
+ "\u03A4": "T",
612
+ // Greek Τ
613
+ "\u03A7": "X",
614
+ // Greek Χ
615
+ "\u03BF": "o"
616
+ // Greek ο
617
+ };
618
+ function findZeroWidthChars(text) {
619
+ const found = [];
620
+ for (let i = 0; i < text.length; i++) {
621
+ if (ZERO_WIDTH_CHARS.includes(text[i])) {
622
+ found.push({
623
+ char: text[i],
624
+ codePoint: "U+" + text[i].charCodeAt(0).toString(16).toUpperCase().padStart(4, "0"),
625
+ position: i
626
+ });
627
+ }
628
+ }
629
+ return found;
630
+ }
631
+ function findRTLOverrides(text) {
632
+ const found = [];
633
+ for (let i = 0; i < text.length; i++) {
634
+ if (RTL_OVERRIDE_CHARS.includes(text[i])) {
635
+ found.push({
636
+ char: text[i],
637
+ codePoint: "U+" + text[i].charCodeAt(0).toString(16).toUpperCase().padStart(4, "0"),
638
+ position: i
639
+ });
640
+ }
641
+ }
642
+ return found;
643
+ }
644
+ function findHomoglyphs(text) {
645
+ const found = [];
646
+ for (let i = 0; i < text.length; i++) {
647
+ const latin = HOMOGLYPHS[text[i]];
648
+ if (latin) {
649
+ found.push({ char: text[i], looksLike: latin, position: i });
650
+ }
651
+ }
652
+ return found;
653
+ }
654
+
655
+ // src/utils/entropy.ts
656
+ function shannonEntropy(str) {
657
+ if (str.length === 0) return 0;
658
+ const freq = /* @__PURE__ */ new Map();
659
+ for (const ch of str) {
660
+ freq.set(ch, (freq.get(ch) ?? 0) + 1);
661
+ }
662
+ let entropy = 0;
663
+ const len = str.length;
664
+ for (const count of freq.values()) {
665
+ const p = count / len;
666
+ if (p > 0) {
667
+ entropy -= p * Math.log2(p);
668
+ }
669
+ }
670
+ return entropy;
671
+ }
672
+ function isBase64Like(str) {
673
+ if (str.length < 50) return false;
674
+ return /^[A-Za-z0-9+/=]{50,}$/.test(str.trim());
675
+ }
676
+ function isHexEncoded(str) {
677
+ if (str.length < 50) return false;
678
+ return /^(0x)?[0-9a-fA-F]{50,}$/.test(str.trim());
679
+ }
680
+ function tryDecodeBase64(str) {
681
+ try {
682
+ const decoded = Buffer.from(str.trim(), "base64").toString("utf-8");
683
+ const printable = decoded.replace(/[^\x20-\x7E\n\r\t]/g, "");
684
+ if (printable.length / decoded.length > 0.8) {
685
+ return decoded;
686
+ }
687
+ return null;
688
+ } catch {
689
+ return null;
690
+ }
691
+ }
692
+
693
+ // src/checks/injection.ts
694
+ var SYSTEM_OVERRIDE_PATTERNS = [
695
+ /ignore\s+(all\s+)?previous\s+instructions/i,
696
+ /ignore\s+(all\s+)?prior\s+instructions/i,
697
+ /disregard\s+(all\s+)?previous/i,
698
+ /forget\s+(all\s+)?previous/i,
699
+ /you\s+are\s+now\s+a\s+different/i,
700
+ /new\s+system\s+prompt/i,
701
+ /override\s+system\s+prompt/i,
702
+ /your\s+new\s+instructions?\s+(are|is)/i,
703
+ /from\s+now\s+on,?\s+you\s+(will|must|should)/i,
704
+ /act\s+as\s+(if|though)\s+your\s+instructions/i
705
+ ];
706
+ var TOOL_MANIPULATION_PATTERNS = [
707
+ /\bresult\s*[:=]\s*["']?success/i,
708
+ /tool_result/i,
709
+ /<tool_result>/i,
710
+ /\breturn\s+["']?(true|success|approved)/i,
711
+ /permissionDecision\s*[:=]/i
712
+ ];
713
+ var TAG_INJECTION_PATTERNS = [
714
+ /<system>/i,
715
+ /<\/system>/i,
716
+ /<\|im_start\|>/i,
717
+ /<\|im_end\|>/i,
718
+ /<\|endoftext\|>/i,
719
+ /<human>/i,
720
+ /<assistant>/i,
721
+ /<\|system\|>/i,
722
+ /<\|user\|>/i,
723
+ /<\|assistant\|>/i
724
+ ];
725
+ var DELIMITER_PATTERNS = [
726
+ /={5,}/,
727
+ /-{5,}\s*(system|instruction|prompt)/i,
728
+ /#{3,}\s*(system|instruction|prompt)/i,
729
+ /\[SYSTEM\]/i,
730
+ /\[INST\]/i,
731
+ /\[\/INST\]/i
732
+ ];
733
+ var injectionChecks = {
734
+ name: "Injection Detection",
735
+ category: "INJ",
736
+ run(skill) {
737
+ const results = [];
738
+ const fullText = skill.raw;
739
+ const zeroWidth = findZeroWidthChars(fullText);
740
+ if (zeroWidth.length > 0) {
741
+ results.push({
742
+ id: "INJ-001",
743
+ category: "INJ",
744
+ severity: "CRITICAL",
745
+ title: "Zero-width Unicode characters detected",
746
+ message: `Found ${zeroWidth.length} zero-width character(s): ${zeroWidth.slice(0, 5).map((z) => z.codePoint).join(", ")}. These can hide malicious content.`
747
+ });
748
+ }
749
+ const homoglyphs = findHomoglyphs(fullText);
750
+ if (homoglyphs.length > 0) {
751
+ results.push({
752
+ id: "INJ-002",
753
+ category: "INJ",
754
+ severity: "HIGH",
755
+ title: "Homoglyph characters detected",
756
+ message: `Found ${homoglyphs.length} character(s) that mimic Latin letters (e.g. Cyrillic/Greek). Could be used for spoofing.`,
757
+ snippet: homoglyphs.slice(0, 5).map((h) => `"${h.char}" looks like "${h.looksLike}"`).join(", ")
758
+ });
759
+ }
760
+ const rtl = findRTLOverrides(fullText);
761
+ if (rtl.length > 0) {
762
+ results.push({
763
+ id: "INJ-003",
764
+ category: "INJ",
765
+ severity: "CRITICAL",
766
+ title: "RTL override characters detected",
767
+ message: `Found ${rtl.length} RTL/bidirectional override character(s): ${rtl.slice(0, 5).map((r) => r.codePoint).join(", ")}. These can manipulate text display direction.`
768
+ });
769
+ }
770
+ for (let i = 0; i < skill.bodyLines.length; i++) {
771
+ const line = skill.bodyLines[i];
772
+ const lineNum = skill.bodyStartLine + i;
773
+ for (const pattern of SYSTEM_OVERRIDE_PATTERNS) {
774
+ if (pattern.test(line)) {
775
+ results.push({
776
+ id: "INJ-004",
777
+ category: "INJ",
778
+ severity: "CRITICAL",
779
+ title: "System prompt override attempt",
780
+ message: `Line ${lineNum}: Attempts to override system instructions.`,
781
+ line: lineNum,
782
+ snippet: line.trim().slice(0, 120)
783
+ });
784
+ break;
785
+ }
786
+ }
787
+ for (const pattern of TOOL_MANIPULATION_PATTERNS) {
788
+ if (pattern.test(line)) {
789
+ results.push({
790
+ id: "INJ-005",
791
+ category: "INJ",
792
+ severity: "HIGH",
793
+ title: "Tool output manipulation",
794
+ message: `Line ${lineNum}: Attempts to manipulate tool results.`,
795
+ line: lineNum,
796
+ snippet: line.trim().slice(0, 120)
797
+ });
798
+ break;
799
+ }
800
+ }
801
+ for (const pattern of TAG_INJECTION_PATTERNS) {
802
+ if (pattern.test(line)) {
803
+ results.push({
804
+ id: "INJ-007",
805
+ category: "INJ",
806
+ severity: "CRITICAL",
807
+ title: "Tag injection detected",
808
+ message: `Line ${lineNum}: Contains special model/system tags.`,
809
+ line: lineNum,
810
+ snippet: line.trim().slice(0, 120)
811
+ });
812
+ break;
813
+ }
814
+ }
815
+ for (const pattern of DELIMITER_PATTERNS) {
816
+ if (pattern.test(line)) {
817
+ results.push({
818
+ id: "INJ-009",
819
+ category: "INJ",
820
+ severity: "MEDIUM",
821
+ title: "Delimiter confusion pattern",
822
+ message: `Line ${lineNum}: Uses patterns that could confuse model context boundaries.`,
823
+ line: lineNum,
824
+ snippet: line.trim().slice(0, 120)
825
+ });
826
+ break;
827
+ }
828
+ }
829
+ }
830
+ const commentRegex = /<!--([\s\S]*?)-->/g;
831
+ let commentMatch;
832
+ while ((commentMatch = commentRegex.exec(fullText)) !== null) {
833
+ const commentBody = commentMatch[1];
834
+ if (hasInstructionLikeContent(commentBody)) {
835
+ const lineNum = fullText.slice(0, commentMatch.index).split("\n").length;
836
+ results.push({
837
+ id: "INJ-006",
838
+ category: "INJ",
839
+ severity: "HIGH",
840
+ title: "Hidden instructions in HTML comment",
841
+ message: `Line ${lineNum}: HTML comment contains instruction-like content.`,
842
+ line: lineNum,
843
+ snippet: commentBody.trim().slice(0, 120)
844
+ });
845
+ }
846
+ }
847
+ const base64Regex = /[A-Za-z0-9+/=]{60,}/g;
848
+ let b64Match;
849
+ while ((b64Match = base64Regex.exec(skill.body)) !== null) {
850
+ const candidate = b64Match[0];
851
+ if (isBase64Like(candidate)) {
852
+ const decoded = tryDecodeBase64(candidate);
853
+ if (decoded && hasInstructionLikeContent(decoded)) {
854
+ const lineNum = skill.bodyStartLine + skill.body.slice(0, b64Match.index).split("\n").length - 1;
855
+ results.push({
856
+ id: "INJ-008",
857
+ category: "INJ",
858
+ severity: "CRITICAL",
859
+ title: "Encoded instructions detected",
860
+ message: `Line ${lineNum}: Base64 string decodes to instruction-like content.`,
861
+ line: lineNum,
862
+ snippet: decoded.slice(0, 120)
863
+ });
864
+ }
865
+ }
866
+ }
867
+ return dedup(results);
868
+ }
869
+ };
870
+ function hasInstructionLikeContent(text) {
871
+ const instructionPatterns = [
872
+ /you\s+(must|should|will|are)/i,
873
+ /ignore\s+previous/i,
874
+ /execute\s+the\s+following/i,
875
+ /run\s+this\s+command/i,
876
+ /\bsudo\b/i,
877
+ /\brm\s+-rf\b/i,
878
+ /\bcurl\b.*\bsh\b/i,
879
+ /\beval\b/i,
880
+ /\bexec\b/i
881
+ ];
882
+ return instructionPatterns.some((p) => p.test(text));
883
+ }
884
+ function dedup(results) {
885
+ const seen = /* @__PURE__ */ new Set();
886
+ return results.filter((r) => {
887
+ const key = `${r.id}:${r.line ?? ""}`;
888
+ if (seen.has(key)) return false;
889
+ seen.add(key);
890
+ return true;
891
+ });
892
+ }
893
+
894
+ // src/checks/code-safety.ts
895
+ var EVAL_PATTERNS = [
896
+ /\beval\s*\(/,
897
+ /\bexec\s*\(/,
898
+ /\bnew\s+Function\s*\(/,
899
+ /\bsetTimeout\s*\(\s*["'`]/,
900
+ /\bsetInterval\s*\(\s*["'`]/
901
+ ];
902
+ var SHELL_EXEC_PATTERNS = [
903
+ /\bchild_process\b/,
904
+ /\bexecSync\b/,
905
+ /\bspawnSync\b/,
906
+ /\bos\.system\s*\(/,
907
+ /\bsubprocess\.(run|call|Popen)\s*\(/,
908
+ /(?<!\bplatform\.)\bsystem\s*\(/,
909
+ // exclude platform.system()
910
+ /`[^`]*\$\([^)]+\)[^`]*`/
911
+ // backtick with command substitution
912
+ ];
913
+ var SHELL_EXEC_FALSE_POSITIVES = [
914
+ /\bplatform\.system\s*\(\s*\)/
915
+ // Python: just reads OS name
916
+ ];
917
+ var DESTRUCTIVE_PATTERNS = [
918
+ /\brm\s+-rf\b/,
919
+ /\brm\s+-r\b/,
920
+ /\brmdir\b/,
921
+ /\bunlink\s*\(/,
922
+ /\bfs\.rm(Sync)?\s*\(/,
923
+ /\bshutil\.rmtree\s*\(/,
924
+ /\bdel\s+\/[sf]/i,
925
+ /\bformat\s+[a-z]:/i
926
+ ];
927
+ var NETWORK_PATTERNS = [
928
+ /\bfetch\s*\(\s*["'`]https?:\/\//,
929
+ /\baxios\.(get|post|put|delete)\s*\(\s*["'`]https?:\/\//,
930
+ /\bcurl\s+/,
931
+ /\bwget\s+/,
932
+ /\brequests?\.(get|post)\s*\(/,
933
+ /\bhttp\.get\s*\(/,
934
+ /\bURLSession\b/
935
+ ];
936
+ var FILE_WRITE_PATTERNS = [
937
+ /\bfs\.writeFile(Sync)?\s*\(\s*["'`]\//,
938
+ /\bopen\s*\(\s*["'`]\/[^"'`]+["'`]\s*,\s*["'`]w/,
939
+ />\s*\/etc\//,
940
+ />\s*\/usr\//,
941
+ />\s*~\//,
942
+ />\s*\$HOME\//
943
+ ];
944
+ var ENV_ACCESS_PATTERNS = [
945
+ /process\.env\b/,
946
+ /\bos\.environ\b/,
947
+ /\bgetenv\s*\(/,
948
+ /\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API_KEY)\w*\}?/i
949
+ ];
950
+ var DYNAMIC_CODE_PATTERNS = [
951
+ /\bcompile\s*\(/,
952
+ /\bcodegen\b/i,
953
+ /\bimport\s*\(\s*[^"'`\s]/,
954
+ /\brequire\s*\(\s*[^"'`\s]/,
955
+ /\b__import__\s*\(/
956
+ ];
957
+ var PERMISSION_PATTERNS = [
958
+ /\bchmod\s+[+0-9]/,
959
+ /\bchown\b/,
960
+ /\bsudo\b/,
961
+ /\bdoas\b/,
962
+ /\bsetuid\b/,
963
+ /\bsetgid\b/
964
+ ];
965
+ var codeSafetyChecks = {
966
+ name: "Code Safety",
967
+ category: "CODE",
968
+ run(skill) {
969
+ const results = [];
970
+ const textSources = getTextSources(skill);
971
+ for (const { text, source } of textSources) {
972
+ const lines = text.split("\n");
973
+ for (let i = 0; i < lines.length; i++) {
974
+ const line = lines[i];
975
+ const lineNum = i + 1;
976
+ const loc = `${source}:${lineNum}`;
977
+ checkPatterns(results, line, EVAL_PATTERNS, {
978
+ id: "CODE-001",
979
+ severity: "CRITICAL",
980
+ title: "eval/exec/Function constructor",
981
+ loc,
982
+ lineNum
983
+ });
984
+ if (!SHELL_EXEC_FALSE_POSITIVES.some((p) => p.test(line))) {
985
+ checkPatterns(results, line, SHELL_EXEC_PATTERNS, {
986
+ id: "CODE-002",
987
+ severity: "CRITICAL",
988
+ title: "Shell/subprocess execution",
989
+ loc,
990
+ lineNum
991
+ });
992
+ }
993
+ checkPatterns(results, line, DESTRUCTIVE_PATTERNS, {
994
+ id: "CODE-003",
995
+ severity: "CRITICAL",
996
+ title: "Destructive file operation",
997
+ loc,
998
+ lineNum
999
+ });
1000
+ checkPatterns(results, line, NETWORK_PATTERNS, {
1001
+ id: "CODE-004",
1002
+ severity: "HIGH",
1003
+ title: "Hardcoded external URL/network request",
1004
+ loc,
1005
+ lineNum
1006
+ });
1007
+ checkPatterns(results, line, FILE_WRITE_PATTERNS, {
1008
+ id: "CODE-005",
1009
+ severity: "HIGH",
1010
+ title: "File write outside expected directory",
1011
+ loc,
1012
+ lineNum
1013
+ });
1014
+ checkPatterns(results, line, ENV_ACCESS_PATTERNS, {
1015
+ id: "CODE-006",
1016
+ severity: "MEDIUM",
1017
+ title: "Environment variable access",
1018
+ loc,
1019
+ lineNum
1020
+ });
1021
+ checkPatterns(results, line, DYNAMIC_CODE_PATTERNS, {
1022
+ id: "CODE-010",
1023
+ severity: "HIGH",
1024
+ title: "Dynamic code generation pattern",
1025
+ loc,
1026
+ lineNum
1027
+ });
1028
+ {
1029
+ const srcLines = text.split("\n");
1030
+ const isDoc = isInDocumentationContext(srcLines, i);
1031
+ if (!isDoc) {
1032
+ checkPatterns(results, line, PERMISSION_PATTERNS, {
1033
+ id: "CODE-012",
1034
+ severity: "HIGH",
1035
+ title: "Permission escalation",
1036
+ loc,
1037
+ lineNum
1038
+ });
1039
+ }
1040
+ }
1041
+ }
1042
+ scanEncodedStrings(results, text, source);
1043
+ scanObfuscation(results, text, source);
1044
+ }
1045
+ return results;
1046
+ }
1047
+ };
1048
+ function checkPatterns(results, line, patterns, opts) {
1049
+ for (const pattern of patterns) {
1050
+ if (pattern.test(line)) {
1051
+ results.push({
1052
+ id: opts.id,
1053
+ category: "CODE",
1054
+ severity: opts.severity,
1055
+ title: opts.title,
1056
+ message: `At ${opts.loc}: ${line.trim().slice(0, 120)}`,
1057
+ line: opts.lineNum,
1058
+ snippet: line.trim().slice(0, 120)
1059
+ });
1060
+ return;
1061
+ }
1062
+ }
1063
+ }
1064
+ function getTextSources(skill) {
1065
+ const sources = [
1066
+ { text: skill.body, source: "SKILL.md" }
1067
+ ];
1068
+ for (const file of skill.files) {
1069
+ if (file.content && file.path !== "SKILL.md") {
1070
+ sources.push({ text: file.content, source: file.path });
1071
+ }
1072
+ }
1073
+ return sources;
1074
+ }
1075
+ function scanEncodedStrings(results, text, source) {
1076
+ const longStringRegex = /[A-Za-z0-9+/=]{50,}|(?:0x)?[0-9a-fA-F]{50,}/g;
1077
+ let match;
1078
+ while ((match = longStringRegex.exec(text)) !== null) {
1079
+ const str = match[0];
1080
+ if (isBase64Like(str) || isHexEncoded(str)) {
1081
+ const lineNum = text.slice(0, match.index).split("\n").length;
1082
+ results.push({
1083
+ id: "CODE-007",
1084
+ category: "CODE",
1085
+ severity: "HIGH",
1086
+ title: "Long encoded string",
1087
+ message: `${source}:${lineNum}: Found ${str.length}-char encoded string.`,
1088
+ line: lineNum,
1089
+ snippet: str.slice(0, 80) + "..."
1090
+ });
1091
+ }
1092
+ }
1093
+ const wordRegex = /\b[A-Za-z0-9_]{20,}\b/g;
1094
+ while ((match = wordRegex.exec(text)) !== null) {
1095
+ const entropy = shannonEntropy(match[0]);
1096
+ if (entropy > 4.5) {
1097
+ const lineNum = text.slice(0, match.index).split("\n").length;
1098
+ results.push({
1099
+ id: "CODE-008",
1100
+ category: "CODE",
1101
+ severity: "MEDIUM",
1102
+ title: "High entropy string",
1103
+ message: `${source}:${lineNum}: String "${match[0].slice(0, 30)}..." has entropy ${entropy.toFixed(2)} bits/char.`,
1104
+ line: lineNum
1105
+ });
1106
+ }
1107
+ }
1108
+ const multiEncodingPatterns = [
1109
+ /atob\s*\(\s*atob/i,
1110
+ /base64.*decode.*base64.*decode/i,
1111
+ /Buffer\.from\(.*Buffer\.from/,
1112
+ /decode.*decode.*decode/i
1113
+ ];
1114
+ for (const pattern of multiEncodingPatterns) {
1115
+ if (pattern.test(text)) {
1116
+ results.push({
1117
+ id: "CODE-009",
1118
+ category: "CODE",
1119
+ severity: "CRITICAL",
1120
+ title: "Multi-layer encoding detected",
1121
+ message: `${source}: Contains nested encoding/decoding operations.`
1122
+ });
1123
+ break;
1124
+ }
1125
+ }
1126
+ }
1127
+ function scanObfuscation(results, text, source) {
1128
+ const obfuscatedVarRegex = /\b_0x[0-9a-f]{2,}\b/g;
1129
+ const obfMatches = text.match(obfuscatedVarRegex);
1130
+ if (obfMatches && obfMatches.length >= 3) {
1131
+ results.push({
1132
+ id: "CODE-011",
1133
+ category: "CODE",
1134
+ severity: "MEDIUM",
1135
+ title: "Obfuscated variable names",
1136
+ message: `${source}: Found ${obfMatches.length} hex-style variable names (e.g. ${obfMatches[0]}). May indicate obfuscated code.`
1137
+ });
1138
+ }
1139
+ }
1140
+
1141
+ // src/checks/supply-chain.ts
1142
+ var SUSPICIOUS_DOMAINS = [
1143
+ "evil.com",
1144
+ "malware.com",
1145
+ "exploit.in",
1146
+ "darkweb.onion",
1147
+ "pastebin.com",
1148
+ // often used for payload hosting
1149
+ "ngrok.io",
1150
+ // tunneling service
1151
+ "requestbin.com",
1152
+ "webhook.site",
1153
+ "pipedream.net",
1154
+ "burpcollaborator.net",
1155
+ "interact.sh",
1156
+ "oastify.com"
1157
+ ];
1158
+ var MCP_SERVER_PATTERN = /\bmcp[-_]?server\b/i;
1159
+ var NPX_Y_PATTERN = /\bnpx\s+-y\s+/;
1160
+ var NPM_INSTALL_PATTERN = /\bnpm\s+install\b/;
1161
+ var PIP_INSTALL_PATTERN = /\bpip3?\s+install\b/;
1162
+ var GIT_CLONE_PATTERN = /\bgit\s+clone\b/;
1163
+ var URL_PATTERN = /https?:\/\/[^\s"'`<>)\]]+/g;
1164
+ var IP_URL_PATTERN = /https?:\/\/(?:\d{1,3}\.){3}\d{1,3}/;
1165
+ var supplyChainChecks = {
1166
+ name: "Supply Chain",
1167
+ category: "SUPPLY",
1168
+ run(skill) {
1169
+ const results = [];
1170
+ const allText = getAllText(skill);
1171
+ for (let i = 0; i < allText.length; i++) {
1172
+ const { line, lineNum, source } = allText[i];
1173
+ if (MCP_SERVER_PATTERN.test(line)) {
1174
+ results.push({
1175
+ id: "SUPPLY-001",
1176
+ category: "SUPPLY",
1177
+ severity: "HIGH",
1178
+ title: "MCP server reference",
1179
+ message: `${source}:${lineNum}: References an MCP server. Verify it is from a trusted source.`,
1180
+ line: lineNum,
1181
+ snippet: line.trim().slice(0, 120)
1182
+ });
1183
+ }
1184
+ if (NPX_Y_PATTERN.test(line)) {
1185
+ results.push({
1186
+ id: "SUPPLY-002",
1187
+ category: "SUPPLY",
1188
+ severity: "MEDIUM",
1189
+ title: "npx -y auto-install",
1190
+ message: `${source}:${lineNum}: Uses npx -y which auto-installs packages without confirmation.`,
1191
+ line: lineNum,
1192
+ snippet: line.trim().slice(0, 120)
1193
+ });
1194
+ }
1195
+ if (NPM_INSTALL_PATTERN.test(line) || PIP_INSTALL_PATTERN.test(line)) {
1196
+ const allLines = getAllLines(skill);
1197
+ const globalIdx = findGlobalLineIndex(allLines, source, lineNum);
1198
+ const isDoc = globalIdx >= 0 && isInDocumentationContext(
1199
+ allLines.map((l) => l.line),
1200
+ globalIdx
1201
+ );
1202
+ if (!isDoc) {
1203
+ results.push({
1204
+ id: "SUPPLY-003",
1205
+ category: "SUPPLY",
1206
+ severity: "HIGH",
1207
+ title: "Package installation command",
1208
+ message: `${source}:${lineNum}: Installs packages. Verify package names are legitimate.`,
1209
+ line: lineNum,
1210
+ snippet: line.trim().slice(0, 120)
1211
+ });
1212
+ }
1213
+ }
1214
+ if (GIT_CLONE_PATTERN.test(line)) {
1215
+ results.push({
1216
+ id: "SUPPLY-006",
1217
+ category: "SUPPLY",
1218
+ severity: "MEDIUM",
1219
+ title: "git clone command",
1220
+ message: `${source}:${lineNum}: Clones a git repository. Verify the source.`,
1221
+ line: lineNum,
1222
+ snippet: line.trim().slice(0, 120)
1223
+ });
1224
+ }
1225
+ const urls = line.match(URL_PATTERN) || [];
1226
+ for (const url of urls) {
1227
+ if (url.startsWith("http://")) {
1228
+ if (!isNamespaceOrSchemaURI(url, line)) {
1229
+ const isNetworkCtx = isInNetworkRequestContext(line);
1230
+ results.push({
1231
+ id: "SUPPLY-004",
1232
+ category: "SUPPLY",
1233
+ severity: isNetworkCtx ? "HIGH" : "MEDIUM",
1234
+ title: "Non-HTTPS URL",
1235
+ message: `${source}:${lineNum}: Uses insecure HTTP: ${url}`,
1236
+ line: lineNum,
1237
+ snippet: url
1238
+ });
1239
+ }
1240
+ }
1241
+ if (IP_URL_PATTERN.test(url)) {
1242
+ if (!/https?:\/\/127\.0\.0\.1/.test(url) && !/https?:\/\/0\.0\.0\.0/.test(url)) {
1243
+ results.push({
1244
+ id: "SUPPLY-005",
1245
+ category: "SUPPLY",
1246
+ severity: "CRITICAL",
1247
+ title: "IP address used instead of domain",
1248
+ message: `${source}:${lineNum}: Uses raw IP address: ${url}. This may bypass DNS-based security.`,
1249
+ line: lineNum,
1250
+ snippet: url
1251
+ });
1252
+ }
1253
+ }
1254
+ for (const domain of SUSPICIOUS_DOMAINS) {
1255
+ if (url.includes(domain)) {
1256
+ results.push({
1257
+ id: "SUPPLY-007",
1258
+ category: "SUPPLY",
1259
+ severity: "CRITICAL",
1260
+ title: "Suspicious domain detected",
1261
+ message: `${source}:${lineNum}: References suspicious domain "${domain}".`,
1262
+ line: lineNum,
1263
+ snippet: url
1264
+ });
1265
+ break;
1266
+ }
1267
+ }
1268
+ }
1269
+ }
1270
+ return results;
1271
+ }
1272
+ };
1273
+ function getAllText(skill) {
1274
+ const result = [];
1275
+ for (let i = 0; i < skill.bodyLines.length; i++) {
1276
+ result.push({
1277
+ line: skill.bodyLines[i],
1278
+ lineNum: skill.bodyStartLine + i,
1279
+ source: "SKILL.md"
1280
+ });
1281
+ }
1282
+ for (const file of skill.files) {
1283
+ if (file.content && file.path !== "SKILL.md") {
1284
+ const lines = file.content.split("\n");
1285
+ for (let i = 0; i < lines.length; i++) {
1286
+ result.push({ line: lines[i], lineNum: i + 1, source: file.path });
1287
+ }
1288
+ }
1289
+ }
1290
+ return result;
1291
+ }
1292
+ function getAllLines(skill) {
1293
+ return getAllText(skill);
1294
+ }
1295
+ function findGlobalLineIndex(allLines, source, lineNum) {
1296
+ return allLines.findIndex(
1297
+ (l) => l.source === source && l.lineNum === lineNum
1298
+ );
1299
+ }
1300
+
1301
+ // src/checks/resource.ts
1302
+ var AMPLIFICATION_PATTERNS = [
1303
+ /\brepeat\s+(this|the\s+above)\s+\d+\s+times\b/i,
1304
+ /\bdo\s+this\s+forever\b/i,
1305
+ /\binfinite\s+loop\b/i,
1306
+ /\bwhile\s*\(\s*true\s*\)/,
1307
+ /\bfor\s*\(\s*;;\s*\)/,
1308
+ /\brecursively\s+(apply|run|execute|call)/i,
1309
+ /\bkeep\s+(running|doing|executing)\s+until/i
1310
+ ];
1311
+ var UNRESTRICTED_TOOL_PATTERNS = [
1312
+ /\bBash\s*\(\s*\*\s*\)/,
1313
+ /allowed[_-]?tools\s*:\s*\[?\s*["']?\*["']?\s*\]?/i,
1314
+ /\ball\s+tools\b/i,
1315
+ /\bunrestricted\s+access\b/i,
1316
+ /\bfull\s+access\b/i
1317
+ ];
1318
+ var DISABLE_SAFETY_PATTERNS = [
1319
+ /\bdisable\s+(safety|security|checks?|hooks?|guard)/i,
1320
+ /\bbypass\s+(safety|security|checks?|hooks?|guard)/i,
1321
+ /\bskip\s+(safety|security|checks?|hooks?|guard|verification)/i,
1322
+ /\bturn\s+off\s+(safety|security|checks?|hooks?)/i,
1323
+ /--no-verify\b/,
1324
+ /--force\b/,
1325
+ /--skip-hooks?\b/
1326
+ ];
1327
+ var IGNORE_RULES_PATTERNS = [
1328
+ /\bignore\s+(the\s+)?CLAUDE\.md\b/i,
1329
+ /\bignore\s+(the\s+)?project\s+rules?\b/i,
1330
+ /\bignore\s+(the\s+)?\.claude\b/i,
1331
+ /\boverride\s+(the\s+)?project\s+(settings?|config|rules?)\b/i,
1332
+ /\bdo\s+not\s+(follow|obey|respect)\s+(the\s+)?(project|CLAUDE)/i,
1333
+ /\bdisregard\s+(the\s+)?(project|CLAUDE)\s+(rules?|config|settings?)/i
1334
+ ];
1335
+ var TOKEN_WASTE_PATTERNS = [
1336
+ /\brepeat\s+(every|each)\s+(response|answer|reply)/i,
1337
+ /\balways\s+(start|begin|end)\s+(every|each)\s+(response|answer|reply)\s+with/i,
1338
+ /\binclude\s+this\s+(text|message|string)\s+in\s+(every|each|all)/i,
1339
+ /\bprint\s+(the\s+)?full\s+(source|code|file)\s+(every|each)\s+time/i
1340
+ ];
1341
+ var resourceChecks = {
1342
+ name: "Resource Abuse",
1343
+ category: "RES",
1344
+ run(skill) {
1345
+ const results = [];
1346
+ for (let i = 0; i < skill.bodyLines.length; i++) {
1347
+ const line = skill.bodyLines[i];
1348
+ const lineNum = skill.bodyStartLine + i;
1349
+ for (const pattern of AMPLIFICATION_PATTERNS) {
1350
+ if (pattern.test(line)) {
1351
+ results.push({
1352
+ id: "RES-001",
1353
+ category: "RES",
1354
+ severity: "HIGH",
1355
+ title: "Instruction amplification",
1356
+ message: `Line ${lineNum}: Contains recursive/repetitive task pattern.`,
1357
+ line: lineNum,
1358
+ snippet: line.trim().slice(0, 120)
1359
+ });
1360
+ break;
1361
+ }
1362
+ }
1363
+ for (const pattern of UNRESTRICTED_TOOL_PATTERNS) {
1364
+ if (pattern.test(line)) {
1365
+ results.push({
1366
+ id: "RES-002",
1367
+ category: "RES",
1368
+ severity: "CRITICAL",
1369
+ title: "Unrestricted tool access requested",
1370
+ message: `Line ${lineNum}: Requests broad/unrestricted tool access.`,
1371
+ line: lineNum,
1372
+ snippet: line.trim().slice(0, 120)
1373
+ });
1374
+ break;
1375
+ }
1376
+ }
1377
+ for (const pattern of DISABLE_SAFETY_PATTERNS) {
1378
+ if (pattern.test(line)) {
1379
+ results.push({
1380
+ id: "RES-004",
1381
+ category: "RES",
1382
+ severity: "CRITICAL",
1383
+ title: "Attempts to disable safety checks",
1384
+ message: `Line ${lineNum}: Instructs disabling of safety mechanisms.`,
1385
+ line: lineNum,
1386
+ snippet: line.trim().slice(0, 120)
1387
+ });
1388
+ break;
1389
+ }
1390
+ }
1391
+ for (const pattern of TOKEN_WASTE_PATTERNS) {
1392
+ if (pattern.test(line)) {
1393
+ results.push({
1394
+ id: "RES-005",
1395
+ category: "RES",
1396
+ severity: "MEDIUM",
1397
+ title: "Token waste pattern",
1398
+ message: `Line ${lineNum}: Contains instructions that waste tokens.`,
1399
+ line: lineNum,
1400
+ snippet: line.trim().slice(0, 120)
1401
+ });
1402
+ break;
1403
+ }
1404
+ }
1405
+ for (const pattern of IGNORE_RULES_PATTERNS) {
1406
+ if (pattern.test(line)) {
1407
+ results.push({
1408
+ id: "RES-006",
1409
+ category: "RES",
1410
+ severity: "CRITICAL",
1411
+ title: "Attempts to ignore project rules",
1412
+ message: `Line ${lineNum}: Instructs ignoring CLAUDE.md or project configuration.`,
1413
+ line: lineNum,
1414
+ snippet: line.trim().slice(0, 120)
1415
+ });
1416
+ break;
1417
+ }
1418
+ }
1419
+ }
1420
+ const allowedTools = skill.frontmatter["allowed-tools"];
1421
+ if (Array.isArray(allowedTools) && allowedTools.length > 15) {
1422
+ results.push({
1423
+ id: "RES-003",
1424
+ category: "RES",
1425
+ severity: "MEDIUM",
1426
+ title: "Excessive allowed-tools list",
1427
+ message: `Frontmatter declares ${allowedTools.length} allowed tools. This is unusually broad.`
1428
+ });
1429
+ }
1430
+ if (Array.isArray(allowedTools)) {
1431
+ for (const tool of allowedTools) {
1432
+ if (typeof tool !== "string") continue;
1433
+ for (const pattern of UNRESTRICTED_TOOL_PATTERNS) {
1434
+ if (pattern.test(tool)) {
1435
+ results.push({
1436
+ id: "RES-002",
1437
+ category: "RES",
1438
+ severity: "CRITICAL",
1439
+ title: "Unrestricted tool access requested",
1440
+ message: `Frontmatter allowed-tools contains dangerous pattern: "${tool}"`,
1441
+ snippet: tool.slice(0, 120)
1442
+ });
1443
+ break;
1444
+ }
1445
+ }
1446
+ }
1447
+ }
1448
+ return results;
1449
+ }
1450
+ };
1451
+
1452
+ // src/ioc/index.ts
1453
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
1454
+ import { join as join2 } from "path";
1455
+ import { homedir } from "os";
1456
+
1457
+ // src/ioc/indicators.ts
1458
+ var DEFAULT_IOC = {
1459
+ version: "2026.03.06",
1460
+ updated: "2026-03-06",
1461
+ c2_ips: [
1462
+ "91.92.242.30",
1463
+ "91.92.242.39",
1464
+ "185.220.101.1",
1465
+ "185.220.101.2",
1466
+ "45.155.205.233"
1467
+ ],
1468
+ malicious_hashes: {
1469
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855": "clawhavoc-empty-payload",
1470
+ "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2": "clawhavoc-exfiltrator"
1471
+ },
1472
+ malicious_domains: [
1473
+ "webhook.site",
1474
+ "requestbin.com",
1475
+ "pipedream.com",
1476
+ "pipedream.net",
1477
+ "hookbin.com",
1478
+ "beeceptor.com",
1479
+ "ngrok.io",
1480
+ "ngrok-free.app",
1481
+ "serveo.net",
1482
+ "localtunnel.me",
1483
+ "bore.pub",
1484
+ "interact.sh",
1485
+ "oast.fun",
1486
+ "oastify.com",
1487
+ "dnslog.cn",
1488
+ "ceye.io",
1489
+ "burpcollaborator.net",
1490
+ "pastebin.com",
1491
+ "paste.ee",
1492
+ "hastebin.com",
1493
+ "ghostbin.com",
1494
+ "evil.com",
1495
+ "malware.com",
1496
+ "exploit.in"
1497
+ ],
1498
+ typosquat: {
1499
+ known_patterns: [
1500
+ "clawhub1",
1501
+ "cllawhub",
1502
+ "clawhab",
1503
+ "moltbot",
1504
+ "claw-hub",
1505
+ "clawhub-pro"
1506
+ ],
1507
+ protected_names: [
1508
+ "clawhub",
1509
+ "secureclaw",
1510
+ "openclaw",
1511
+ "clawbot",
1512
+ "claude",
1513
+ "anthropic",
1514
+ "skill-checker"
1515
+ ]
1516
+ },
1517
+ malicious_publishers: [
1518
+ "clawhavoc",
1519
+ "phantom-tracker",
1520
+ "solana-wallet-drainer"
1521
+ ]
1522
+ };
1523
+
1524
+ // src/ioc/index.ts
1525
+ var cachedIOC = null;
1526
+ function loadIOC() {
1527
+ if (cachedIOC) return cachedIOC;
1528
+ const ioc = structuredClone(DEFAULT_IOC);
1529
+ const overridePath = join2(
1530
+ homedir(),
1531
+ ".config",
1532
+ "skill-checker",
1533
+ "ioc-override.json"
1534
+ );
1535
+ if (existsSync2(overridePath)) {
1536
+ try {
1537
+ const raw = readFileSync2(overridePath, "utf-8");
1538
+ const ext = JSON.parse(raw);
1539
+ mergeIOC(ioc, ext);
1540
+ } catch {
1541
+ }
1542
+ }
1543
+ cachedIOC = ioc;
1544
+ return ioc;
1545
+ }
1546
+ function mergeIOC(base, ext) {
1547
+ if (ext.c2_ips) {
1548
+ base.c2_ips = dedupe([...base.c2_ips, ...ext.c2_ips]);
1549
+ }
1550
+ if (ext.malicious_hashes) {
1551
+ Object.assign(base.malicious_hashes, ext.malicious_hashes);
1552
+ }
1553
+ if (ext.malicious_domains) {
1554
+ base.malicious_domains = dedupe([
1555
+ ...base.malicious_domains,
1556
+ ...ext.malicious_domains
1557
+ ]);
1558
+ }
1559
+ if (ext.typosquat) {
1560
+ if (ext.typosquat.known_patterns) {
1561
+ base.typosquat.known_patterns = dedupe([
1562
+ ...base.typosquat.known_patterns,
1563
+ ...ext.typosquat.known_patterns
1564
+ ]);
1565
+ }
1566
+ if (ext.typosquat.protected_names) {
1567
+ base.typosquat.protected_names = dedupe([
1568
+ ...base.typosquat.protected_names,
1569
+ ...ext.typosquat.protected_names
1570
+ ]);
1571
+ }
1572
+ }
1573
+ if (ext.malicious_publishers) {
1574
+ base.malicious_publishers = dedupe([
1575
+ ...base.malicious_publishers,
1576
+ ...ext.malicious_publishers
1577
+ ]);
1578
+ }
1579
+ if (ext.version) base.version = ext.version;
1580
+ if (ext.updated) base.updated = ext.updated;
1581
+ }
1582
+ function dedupe(arr) {
1583
+ return [...new Set(arr)];
1584
+ }
1585
+
1586
+ // src/ioc/matcher.ts
1587
+ import { createHash } from "crypto";
1588
+ import { readFileSync as readFileSync3 } from "fs";
1589
+ import { join as join3 } from "path";
1590
+
1591
+ // src/utils/levenshtein.ts
1592
+ function levenshtein(a, b) {
1593
+ if (a === b) return 0;
1594
+ if (a.length === 0) return b.length;
1595
+ if (b.length === 0) return a.length;
1596
+ if (a.length > b.length) [a, b] = [b, a];
1597
+ const aLen = a.length;
1598
+ const bLen = b.length;
1599
+ let prev = new Array(aLen + 1);
1600
+ let curr = new Array(aLen + 1);
1601
+ for (let i = 0; i <= aLen; i++) prev[i] = i;
1602
+ for (let j = 1; j <= bLen; j++) {
1603
+ curr[0] = j;
1604
+ for (let i = 1; i <= aLen; i++) {
1605
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
1606
+ curr[i] = Math.min(
1607
+ prev[i] + 1,
1608
+ // deletion
1609
+ curr[i - 1] + 1,
1610
+ // insertion
1611
+ prev[i - 1] + cost
1612
+ // substitution
1613
+ );
1614
+ }
1615
+ [prev, curr] = [curr, prev];
1616
+ }
1617
+ return prev[aLen];
1618
+ }
1619
+
1620
+ // src/ioc/matcher.ts
1621
+ var IPV4_PATTERN = /\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g;
1622
+ function isPrivateIP(ip) {
1623
+ const parts = ip.split(".").map(Number);
1624
+ if (parts.length !== 4 || parts.some((p) => p < 0 || p > 255)) return true;
1625
+ if (parts[0] === 127) return true;
1626
+ if (parts[0] === 10) return true;
1627
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
1628
+ if (parts[0] === 192 && parts[1] === 168) return true;
1629
+ if (parts.every((p) => p === 0)) return true;
1630
+ if (parts[0] === 169 && parts[1] === 254) return true;
1631
+ return false;
1632
+ }
1633
+ function matchMaliciousHashes(skill, ioc) {
1634
+ const matches = [];
1635
+ const hashKeys = Object.keys(ioc.malicious_hashes);
1636
+ if (hashKeys.length === 0) return matches;
1637
+ for (const file of skill.files) {
1638
+ const filePath = join3(skill.dirPath, file.path);
1639
+ try {
1640
+ const content = readFileSync3(filePath);
1641
+ const hash = createHash("sha256").update(content).digest("hex");
1642
+ if (ioc.malicious_hashes[hash]) {
1643
+ matches.push({
1644
+ file: file.path,
1645
+ hash,
1646
+ description: ioc.malicious_hashes[hash]
1647
+ });
1648
+ }
1649
+ } catch {
1650
+ }
1651
+ }
1652
+ return matches;
1653
+ }
1654
+ function matchC2IPs(skill, ioc) {
1655
+ const matches = [];
1656
+ if (ioc.c2_ips.length === 0) return matches;
1657
+ const c2Set = new Set(ioc.c2_ips);
1658
+ const allText = getAllText2(skill);
1659
+ for (const { line, lineNum, source } of allText) {
1660
+ let m;
1661
+ const re = new RegExp(IPV4_PATTERN.source, "g");
1662
+ while ((m = re.exec(line)) !== null) {
1663
+ const ip = m[1];
1664
+ if (!isPrivateIP(ip) && c2Set.has(ip)) {
1665
+ matches.push({
1666
+ ip,
1667
+ line: lineNum,
1668
+ source,
1669
+ snippet: line.trim().slice(0, 120)
1670
+ });
1671
+ }
1672
+ }
1673
+ }
1674
+ return matches;
1675
+ }
1676
+ function matchTyposquat(skillName, ioc) {
1677
+ if (!skillName) return null;
1678
+ const name = skillName.toLowerCase().trim();
1679
+ for (const pattern of ioc.typosquat.known_patterns) {
1680
+ if (name === pattern.toLowerCase()) {
1681
+ return { type: "known", target: pattern };
1682
+ }
1683
+ }
1684
+ for (const protected_name of ioc.typosquat.protected_names) {
1685
+ const pn = protected_name.toLowerCase();
1686
+ if (name === pn) continue;
1687
+ const dist = levenshtein(name, pn);
1688
+ if (dist > 0 && dist <= 2) {
1689
+ return { type: "similar", target: protected_name, distance: dist };
1690
+ }
1691
+ }
1692
+ return null;
1693
+ }
1694
+ function getAllText2(skill) {
1695
+ const result = [];
1696
+ for (let i = 0; i < skill.bodyLines.length; i++) {
1697
+ result.push({
1698
+ line: skill.bodyLines[i],
1699
+ lineNum: skill.bodyStartLine + i,
1700
+ source: "SKILL.md"
1701
+ });
1702
+ }
1703
+ for (const file of skill.files) {
1704
+ if (file.content && file.path !== "SKILL.md") {
1705
+ const lines = file.content.split("\n");
1706
+ for (let i = 0; i < lines.length; i++) {
1707
+ result.push({ line: lines[i], lineNum: i + 1, source: file.path });
1708
+ }
1709
+ }
1710
+ }
1711
+ return result;
1712
+ }
1713
+
1714
+ // src/checks/ioc.ts
1715
+ var iocChecks = {
1716
+ name: "IOC Threat Intelligence",
1717
+ category: "SUPPLY",
1718
+ run(skill) {
1719
+ const results = [];
1720
+ const ioc = loadIOC();
1721
+ const hashMatches = matchMaliciousHashes(skill, ioc);
1722
+ for (const match of hashMatches) {
1723
+ results.push({
1724
+ id: "SUPPLY-008",
1725
+ category: "SUPPLY",
1726
+ severity: "CRITICAL",
1727
+ title: "Known malicious file hash",
1728
+ message: `File "${match.file}" matches known malicious hash: ${match.description}`,
1729
+ snippet: match.hash
1730
+ });
1731
+ }
1732
+ const ipMatches = matchC2IPs(skill, ioc);
1733
+ for (const match of ipMatches) {
1734
+ results.push({
1735
+ id: "SUPPLY-009",
1736
+ category: "SUPPLY",
1737
+ severity: "CRITICAL",
1738
+ title: "Known C2 IP address",
1739
+ message: `${match.source}:${match.line}: Contains known C2 server IP: ${match.ip}`,
1740
+ line: match.line,
1741
+ snippet: match.snippet
1742
+ });
1743
+ }
1744
+ const skillName = skill.frontmatter.name;
1745
+ if (skillName) {
1746
+ const typoMatch = matchTyposquat(skillName, ioc);
1747
+ if (typoMatch) {
1748
+ if (typoMatch.type === "known") {
1749
+ results.push({
1750
+ id: "SUPPLY-010",
1751
+ category: "SUPPLY",
1752
+ severity: "CRITICAL",
1753
+ title: "Known typosquat name",
1754
+ message: `Skill name "${skillName}" matches known typosquat pattern "${typoMatch.target}".`,
1755
+ snippet: skillName
1756
+ });
1757
+ } else {
1758
+ results.push({
1759
+ id: "SUPPLY-010",
1760
+ category: "SUPPLY",
1761
+ severity: "HIGH",
1762
+ title: "Possible typosquat name",
1763
+ message: `Skill name "${skillName}" is similar to protected name "${typoMatch.target}" (edit distance: ${typoMatch.distance}).`,
1764
+ snippet: skillName
1765
+ });
1766
+ }
1767
+ }
1768
+ }
1769
+ return results;
1770
+ }
1771
+ };
1772
+
1773
+ // src/checks/index.ts
1774
+ var ALL_MODULES = [
1775
+ structuralChecks,
1776
+ contentChecks,
1777
+ injectionChecks,
1778
+ codeSafetyChecks,
1779
+ supplyChainChecks,
1780
+ resourceChecks,
1781
+ iocChecks
1782
+ ];
1783
+ function runAllChecks(skill) {
1784
+ const results = [];
1785
+ for (const mod of ALL_MODULES) {
1786
+ results.push(...mod.run(skill));
1787
+ }
1788
+ return results;
1789
+ }
1790
+
1791
+ // src/types.ts
1792
+ var SEVERITY_SCORES = {
1793
+ CRITICAL: 25,
1794
+ HIGH: 10,
1795
+ MEDIUM: 3,
1796
+ LOW: 1
1797
+ };
1798
+ function computeGrade(score) {
1799
+ if (score >= 90) return "A";
1800
+ if (score >= 75) return "B";
1801
+ if (score >= 60) return "C";
1802
+ if (score >= 40) return "D";
1803
+ return "F";
1804
+ }
1805
+ var DEFAULT_CONFIG = {
1806
+ policy: "balanced",
1807
+ overrides: {},
1808
+ ignore: []
1809
+ };
1810
+ function getHookAction(policy, severity) {
1811
+ const matrix = {
1812
+ strict: {
1813
+ CRITICAL: "deny",
1814
+ HIGH: "deny",
1815
+ MEDIUM: "ask",
1816
+ LOW: "report"
1817
+ },
1818
+ balanced: {
1819
+ CRITICAL: "deny",
1820
+ HIGH: "ask",
1821
+ MEDIUM: "report",
1822
+ LOW: "report"
1823
+ },
1824
+ permissive: {
1825
+ CRITICAL: "ask",
1826
+ HIGH: "report",
1827
+ MEDIUM: "report",
1828
+ LOW: "report"
1829
+ }
1830
+ };
1831
+ return matrix[policy][severity];
1832
+ }
1833
+
1834
+ // src/scanner.ts
1835
+ function scanSkillDirectory(dirPath, config = DEFAULT_CONFIG) {
1836
+ const skill = parseSkill(dirPath);
1837
+ return buildReport(skill, config);
1838
+ }
1839
+ function buildReport(skill, config) {
1840
+ let results = runAllChecks(skill);
1841
+ results = results.map((r) => {
1842
+ if (config.overrides[r.id]) {
1843
+ return { ...r, severity: config.overrides[r.id] };
1844
+ }
1845
+ return r;
1846
+ });
1847
+ results = results.filter((r) => !config.ignore.includes(r.id));
1848
+ const score = calculateScore(results);
1849
+ const grade = computeGrade(score);
1850
+ const summary = {
1851
+ total: results.length,
1852
+ critical: results.filter((r) => r.severity === "CRITICAL").length,
1853
+ high: results.filter((r) => r.severity === "HIGH").length,
1854
+ medium: results.filter((r) => r.severity === "MEDIUM").length,
1855
+ low: results.filter((r) => r.severity === "LOW").length
1856
+ };
1857
+ return {
1858
+ skillPath: skill.dirPath,
1859
+ skillName: skill.frontmatter.name ?? "unknown",
1860
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1861
+ results,
1862
+ score,
1863
+ grade,
1864
+ summary
1865
+ };
1866
+ }
1867
+ function calculateScore(results) {
1868
+ let score = 100;
1869
+ for (const r of results) {
1870
+ score -= SEVERITY_SCORES[r.severity];
1871
+ }
1872
+ return Math.max(0, score);
1873
+ }
1874
+ function worstSeverity(results) {
1875
+ const order = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
1876
+ for (const sev of order) {
1877
+ if (results.some((r) => r.severity === sev)) return sev;
1878
+ }
1879
+ return null;
1880
+ }
1881
+
1882
+ // src/reporter/terminal.ts
1883
+ import chalk from "chalk";
1884
+ var SEVERITY_COLORS = {
1885
+ CRITICAL: chalk.bgRed.white.bold,
1886
+ HIGH: chalk.red.bold,
1887
+ MEDIUM: chalk.yellow,
1888
+ LOW: chalk.gray
1889
+ };
1890
+ var GRADE_COLORS = {
1891
+ A: chalk.green.bold,
1892
+ B: chalk.cyan.bold,
1893
+ C: chalk.yellow.bold,
1894
+ D: chalk.red.bold,
1895
+ F: chalk.bgRed.white.bold
1896
+ };
1897
+ var SEVERITY_ICONS = {
1898
+ CRITICAL: "X",
1899
+ HIGH: "!",
1900
+ MEDIUM: "~",
1901
+ LOW: "-"
1902
+ };
1903
+ function formatTerminalReport(report) {
1904
+ const lines = [];
1905
+ lines.push("");
1906
+ lines.push(
1907
+ chalk.bold("Skill Security Report") + chalk.gray(` - ${report.skillName}`)
1908
+ );
1909
+ lines.push(chalk.gray(`Path: ${report.skillPath}`));
1910
+ lines.push(chalk.gray(`Time: ${report.timestamp}`));
1911
+ lines.push("");
1912
+ const gradeStr = GRADE_COLORS[report.grade](` ${report.grade} `);
1913
+ const scoreStr = report.score >= 75 ? chalk.green(`${report.score}/100`) : report.score >= 40 ? chalk.yellow(`${report.score}/100`) : chalk.red(`${report.score}/100`);
1914
+ lines.push(`Grade: ${gradeStr} Score: ${scoreStr}`);
1915
+ lines.push("");
1916
+ const parts = [];
1917
+ if (report.summary.critical > 0)
1918
+ parts.push(chalk.bgRed.white(` ${report.summary.critical} CRITICAL `));
1919
+ if (report.summary.high > 0)
1920
+ parts.push(chalk.red(` ${report.summary.high} HIGH `));
1921
+ if (report.summary.medium > 0)
1922
+ parts.push(chalk.yellow(` ${report.summary.medium} MEDIUM `));
1923
+ if (report.summary.low > 0)
1924
+ parts.push(chalk.gray(` ${report.summary.low} LOW `));
1925
+ if (parts.length > 0) {
1926
+ lines.push(`Findings: ${parts.join(" ")}`);
1927
+ } else {
1928
+ lines.push(chalk.green("No issues found."));
1929
+ }
1930
+ lines.push("");
1931
+ if (report.results.length > 0) {
1932
+ lines.push(chalk.bold.underline("Findings:"));
1933
+ lines.push("");
1934
+ const grouped = /* @__PURE__ */ new Map();
1935
+ for (const r of report.results) {
1936
+ const group = grouped.get(r.category) ?? [];
1937
+ group.push(r);
1938
+ grouped.set(r.category, group);
1939
+ }
1940
+ for (const [category, findings] of grouped) {
1941
+ lines.push(chalk.bold(`[${category}]`));
1942
+ const order = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
1943
+ findings.sort(
1944
+ (a, b) => order.indexOf(a.severity) - order.indexOf(b.severity)
1945
+ );
1946
+ for (const f of findings) {
1947
+ const icon = SEVERITY_ICONS[f.severity];
1948
+ const sevLabel = SEVERITY_COLORS[f.severity](
1949
+ ` ${f.severity} `
1950
+ );
1951
+ const idStr = chalk.gray(f.id);
1952
+ lines.push(` [${icon}] ${sevLabel} ${idStr} ${f.title}`);
1953
+ lines.push(` ${chalk.gray(f.message)}`);
1954
+ if (f.snippet) {
1955
+ lines.push(` ${chalk.dim(f.snippet)}`);
1956
+ }
1957
+ }
1958
+ lines.push("");
1959
+ }
1960
+ }
1961
+ lines.push(chalk.bold("Recommendation:"));
1962
+ switch (report.grade) {
1963
+ case "A":
1964
+ lines.push(chalk.green(" Safe to install."));
1965
+ break;
1966
+ case "B":
1967
+ lines.push(chalk.cyan(" Minor issues found. Generally safe."));
1968
+ break;
1969
+ case "C":
1970
+ lines.push(
1971
+ chalk.yellow(" Review recommended before installation.")
1972
+ );
1973
+ break;
1974
+ case "D":
1975
+ lines.push(
1976
+ chalk.red(" Significant risks detected. Install with caution.")
1977
+ );
1978
+ break;
1979
+ case "F":
1980
+ lines.push(
1981
+ chalk.bgRed.white(" DO NOT INSTALL. Critical security issues found.")
1982
+ );
1983
+ break;
1984
+ }
1985
+ lines.push("");
1986
+ return lines.join("\n");
1987
+ }
1988
+
1989
+ // src/reporter/json.ts
1990
+ function formatJsonReport(report) {
1991
+ return JSON.stringify(report, null, 2);
1992
+ }
1993
+ function generateHookResponse(report, config = DEFAULT_CONFIG) {
1994
+ const worst = worstSeverity(report.results);
1995
+ if (!worst) {
1996
+ return { permissionDecision: "allow" };
1997
+ }
1998
+ const action = getHookAction(config.policy, worst);
1999
+ switch (action) {
2000
+ case "deny":
2001
+ return {
2002
+ permissionDecision: "deny",
2003
+ reason: buildDenySummary(report)
2004
+ };
2005
+ case "ask":
2006
+ return {
2007
+ permissionDecision: "ask",
2008
+ reason: buildAskSummary(report)
2009
+ };
2010
+ case "report":
2011
+ return {
2012
+ permissionDecision: "allow",
2013
+ additionalContext: buildReportSummary(report)
2014
+ };
2015
+ }
2016
+ }
2017
+ function buildDenySummary(report) {
2018
+ const lines = [
2019
+ `Skill Security Check FAILED (Grade: ${report.grade}, Score: ${report.score}/100)`
2020
+ ];
2021
+ const criticals = report.results.filter((r) => r.severity === "CRITICAL");
2022
+ if (criticals.length > 0) {
2023
+ lines.push(`Critical issues (${criticals.length}):`);
2024
+ for (const c of criticals.slice(0, 5)) {
2025
+ lines.push(` - [${c.id}] ${c.title}: ${c.message}`);
2026
+ }
2027
+ }
2028
+ return lines.join("\n");
2029
+ }
2030
+ function buildAskSummary(report) {
2031
+ const lines = [
2032
+ `Skill Security Check: Grade ${report.grade} (${report.score}/100)`,
2033
+ `Found: ${report.summary.critical} critical, ${report.summary.high} high, ${report.summary.medium} medium issues.`,
2034
+ "Review the findings before allowing installation."
2035
+ ];
2036
+ return lines.join("\n");
2037
+ }
2038
+ function buildReportSummary(report) {
2039
+ const lines = [
2040
+ `[Skill Checker] Grade: ${report.grade} (${report.score}/100)`,
2041
+ `Issues: ${report.summary.total} (${report.summary.critical}C/${report.summary.high}H/${report.summary.medium}M/${report.summary.low}L)`
2042
+ ];
2043
+ if (report.results.length > 0) {
2044
+ lines.push("Top findings:");
2045
+ for (const r of report.results.slice(0, 3)) {
2046
+ lines.push(` [${r.id}] ${r.severity}: ${r.title}`);
2047
+ }
2048
+ }
2049
+ return lines.join("\n");
2050
+ }
2051
+
2052
+ // src/config.ts
2053
+ import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
2054
+ import { join as join4, resolve as resolve2 } from "path";
2055
+ import { parse as parseYaml2 } from "yaml";
2056
+ var CONFIG_FILENAMES = [
2057
+ ".skillcheckerrc.yaml",
2058
+ ".skillcheckerrc.yml",
2059
+ ".skillcheckerrc"
2060
+ ];
2061
+ function loadConfig(startDir, configPath) {
2062
+ if (configPath) {
2063
+ const absPath = resolve2(configPath);
2064
+ if (existsSync3(absPath)) {
2065
+ return parseConfigFile(absPath);
2066
+ }
2067
+ return { ...DEFAULT_CONFIG };
2068
+ }
2069
+ const dir = startDir ? resolve2(startDir) : process.cwd();
2070
+ let current = dir;
2071
+ while (true) {
2072
+ for (const filename of CONFIG_FILENAMES) {
2073
+ const configPath2 = join4(current, filename);
2074
+ if (existsSync3(configPath2)) {
2075
+ return parseConfigFile(configPath2);
2076
+ }
2077
+ }
2078
+ const parent = join4(current, "..");
2079
+ if (parent === current) break;
2080
+ current = parent;
2081
+ }
2082
+ const home = process.env.HOME ?? process.env.USERPROFILE;
2083
+ if (home) {
2084
+ for (const filename of CONFIG_FILENAMES) {
2085
+ const configPath2 = join4(home, filename);
2086
+ if (existsSync3(configPath2)) {
2087
+ return parseConfigFile(configPath2);
2088
+ }
2089
+ }
2090
+ }
2091
+ return { ...DEFAULT_CONFIG };
2092
+ }
2093
+ function parseConfigFile(path) {
2094
+ try {
2095
+ const raw = readFileSync4(path, "utf-8");
2096
+ const parsed = parseYaml2(raw);
2097
+ if (!parsed || typeof parsed !== "object") {
2098
+ return { ...DEFAULT_CONFIG };
2099
+ }
2100
+ const config = {
2101
+ policy: isValidPolicy(parsed.policy) ? parsed.policy : "balanced",
2102
+ overrides: {},
2103
+ ignore: []
2104
+ };
2105
+ if (parsed.overrides && typeof parsed.overrides === "object") {
2106
+ for (const [key, value] of Object.entries(parsed.overrides)) {
2107
+ const sev = normalizeSeverity(value);
2108
+ if (sev) {
2109
+ config.overrides[key] = sev;
2110
+ }
2111
+ }
2112
+ }
2113
+ if (Array.isArray(parsed.ignore)) {
2114
+ config.ignore = parsed.ignore.filter(
2115
+ (item) => typeof item === "string"
2116
+ );
2117
+ }
2118
+ return config;
2119
+ } catch {
2120
+ return { ...DEFAULT_CONFIG };
2121
+ }
2122
+ }
2123
+ function isValidPolicy(value) {
2124
+ return typeof value === "string" && ["strict", "balanced", "permissive"].includes(value);
2125
+ }
2126
+ function normalizeSeverity(value) {
2127
+ const upper = value?.toUpperCase();
2128
+ if (["CRITICAL", "HIGH", "MEDIUM", "LOW"].includes(upper)) {
2129
+ return upper;
2130
+ }
2131
+ return null;
2132
+ }
2133
+
2134
+ // src/cli.ts
2135
+ var program = new Command();
2136
+ program.name("skill-checker").description(
2137
+ "Security checker for Claude Code skills - detect injection, malicious code, and supply chain risks"
2138
+ ).version("0.1.0");
2139
+ program.command("scan").description("Scan a skill directory for security issues").argument("<path>", "Path to the skill directory").option("-f, --format <format>", "Output format: terminal, json, hook", "terminal").option("-p, --policy <policy>", "Policy: strict, balanced, permissive").option("-c, --config <path>", "Path to config file").action(
2140
+ (path, opts) => {
2141
+ const config = loadConfig(path, opts.config);
2142
+ if (opts.policy) {
2143
+ config.policy = opts.policy;
2144
+ }
2145
+ const report = scanSkillDirectory(path, config);
2146
+ switch (opts.format) {
2147
+ case "json":
2148
+ console.log(formatJsonReport(report));
2149
+ break;
2150
+ case "hook": {
2151
+ const hookResp = generateHookResponse(report, config);
2152
+ console.log(JSON.stringify(hookResp));
2153
+ break;
2154
+ }
2155
+ case "terminal":
2156
+ default:
2157
+ console.log(formatTerminalReport(report));
2158
+ break;
2159
+ }
2160
+ if (report.summary.critical > 0) {
2161
+ process.exit(1);
2162
+ }
2163
+ }
2164
+ );
2165
+ program.parse();
2166
+ //# sourceMappingURL=cli.js.map