skill-checker 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -4,62 +4,179 @@ Security checker for Claude Code skills — detect injection, malicious code, an
4
4
 
5
5
  ## Features
6
6
 
7
- - **51 security rules** across 6 categories: structural validity, content quality, injection detection, code safety, supply chain, and resource abuse
7
+ - **52 security rules** across 6 categories: structural validity, content quality, injection detection, code safety, supply chain, and resource abuse
8
8
  - **Scoring system**: Grade A–F with 0–100 score
9
9
  - **Dual entry**: CLI tool + PreToolUse hook for automatic interception
10
10
  - **Configurable policies**: strict / balanced / permissive approval strategies
11
+ - **Context-aware detection**: severity reduction in code blocks and documentation sections, with zero reduction for injection rules
12
+ - **IOC threat intelligence**: built-in seed data for known malicious hashes, C2 IPs, and typosquat names
11
13
  - **Multiple output formats**: terminal (color), JSON, hook response
12
14
 
13
15
  ## Security Standard & Benchmark
14
16
 
15
- Skill Checker's 51 rules are aligned with established security frameworks
16
- including OWASP Top 10 for LLM Applications (2025), MITRE CWE, and MITRE
17
- ATT&CK. The tool ships with a reproducible benchmark dataset of six fixture
18
- skills covering all rule categories. This alignment is an internal mapping
19
- exercise — Skill Checker does not claim third-party certification or
20
- external audit status.
17
+ Skill Checker's 52 rules are aligned with established security frameworks including OWASP Top 10 for LLM Applications (2025), MITRE CWE, and MITRE ATT&CK. The tool ships with a reproducible benchmark dataset of six fixture skills covering all rule categories. This alignment is an internal mapping exercise — Skill Checker does not claim third-party certification or external audit status.
21
18
 
22
- See [docs/SECURITY_BENCHMARK.md](docs/SECURITY_BENCHMARK.md) for the full
23
- rule mapping matrix, benchmark methodology, scoring model, and known
24
- limitations.
19
+ See [docs/SECURITY_BENCHMARK.md](docs/SECURITY_BENCHMARK.md) for the full rule mapping matrix, benchmark methodology, scoring model, and known limitations.
25
20
 
26
21
  ## Quick Start
27
22
 
28
23
  ```bash
24
+ # Install globally
25
+ npm install -g skill-checker
26
+
29
27
  # Scan a skill directory
28
+ skill-checker scan ./path/to/skill/
29
+
30
+ # Or run without installing
30
31
  npx skill-checker scan ./path/to/skill/
32
+ ```
31
33
 
32
- # Scan with JSON output
33
- npx skill-checker scan ./path/to/skill/ --format json
34
+ ## Usage
34
35
 
35
- # Scan with strict policy
36
- npx skill-checker scan ./path/to/skill/ --policy strict
36
+ ```bash
37
+ skill-checker scan <path> [options]
37
38
  ```
38
39
 
39
- ## Installation
40
+ | Option | Description |
41
+ |--------|-------------|
42
+ | `-f, --format <format>` | Output format: `terminal` (default), `json`, `hook` |
43
+ | `-p, --policy <policy>` | Approval policy: `strict`, `balanced` (default), `permissive` |
44
+ | `-c, --config <path>` | Path to config file |
40
45
 
41
46
  ```bash
42
- npm install -g skill-checker
47
+ # Colored terminal report
48
+ skill-checker scan ./my-skill
49
+
50
+ # JSON output for CI/programmatic use
51
+ skill-checker scan ./my-skill --format json
52
+
53
+ # Hook response format (for PreToolUse integration)
54
+ skill-checker scan ./my-skill --format hook
55
+
56
+ # Strict policy — deny on HIGH and above
57
+ skill-checker scan ./my-skill --policy strict
43
58
  ```
44
59
 
60
+ Exit code `0` = no critical issues, `1` = critical issues detected.
61
+
62
+ ## Hook Integration
63
+
64
+ Skill Checker can run automatically as a Claude Code [PreToolUse hook](https://docs.anthropic.com/en/docs/claude-code/hooks), intercepting skill file writes before they happen.
65
+
66
+ ### Setup
67
+
68
+ ```bash
69
+ npx tsx hook/install.ts
70
+ ```
71
+
72
+ This adds a hook entry to `~/.claude/settings.json`:
73
+
74
+ ```json
75
+ {
76
+ "hooks": {
77
+ "PreToolUse": [
78
+ {
79
+ "matcher": "Write|Edit",
80
+ "hook": "/path/to/skill-gate.sh"
81
+ }
82
+ ]
83
+ }
84
+ }
85
+ ```
86
+
87
+ ### How It Works
88
+
89
+ 1. Claude Code intercepts Write/Edit operations targeting SKILL.md files
90
+ 2. `skill-gate.sh` receives the file content via stdin (JSON)
91
+ 3. Runs `skill-checker scan --format hook` on the content
92
+ 4. Returns a permission decision: `allow`, `ask`, or `deny`
93
+
94
+ The hook is fail-closed — if the scanner is unavailable, JSON parsing fails, or any unexpected error occurs, it returns `ask` (never silently allows).
95
+
96
+ ### Requirements
97
+
98
+ - `jq` must be installed for JSON parsing
99
+ - `skill-checker` must be globally installed or available via `npx`
100
+
101
+ ## Scoring
102
+
103
+ Base score starts at **100**. Each finding deducts points by severity:
104
+
105
+ | Severity | Deduction |
106
+ |----------|-----------|
107
+ | CRITICAL | -25 |
108
+ | HIGH | -10 |
109
+ | MEDIUM | -3 |
110
+ | LOW | -1 |
111
+
112
+ | Grade | Score | Meaning |
113
+ |-------|-------|---------|
114
+ | A | 90–100 | Safe to install |
115
+ | B | 75–89 | Minor issues |
116
+ | C | 60–74 | Review advised |
117
+ | D | 40–59 | Significant risk |
118
+ | F | 0–39 | Not recommended |
119
+
45
120
  ## Configuration
46
121
 
47
- Create a `.skillcheckerrc.yaml` in your project root or home directory:
122
+ Create `.skillcheckerrc.yaml` in your project root or home directory:
48
123
 
49
124
  ```yaml
50
- policy: balanced
125
+ # Approval policy
126
+ policy: balanced # strict / balanced / permissive
51
127
 
128
+ # Override severity for specific rules
52
129
  overrides:
53
- CODE-006: LOW
130
+ CODE-006: LOW # env var access is expected in my skills
131
+ SUPPLY-002: LOW # I trust npx -y in my workflow
54
132
 
133
+ # Ignore rules entirely
55
134
  ignore:
56
- - CONT-006
135
+ - CONT-006 # reference-heavy skills are fine
136
+ ```
137
+
138
+ Config is resolved in order: CLI `--config` flag → project directory (walks up) → home directory → defaults.
139
+
140
+ ### Policy Matrix
141
+
142
+ | Severity | strict | balanced | permissive |
143
+ |----------|--------|----------|------------|
144
+ | CRITICAL | deny | deny | ask |
145
+ | HIGH | deny | ask | report |
146
+ | MEDIUM | ask | report | report |
147
+ | LOW | report | report | report |
148
+
149
+ ## Rule Categories
150
+
151
+ | Category | Rules | Examples |
152
+ |----------|-------|---------|
153
+ | Structural (STRUCT) | 8 | Missing SKILL.md, invalid frontmatter, binary files |
154
+ | Content (CONT) | 7 | Placeholder text, lorem ipsum, promotional content |
155
+ | Injection (INJ) | 9 | Zero-width chars, prompt override, tag injection, encoded payloads |
156
+ | Code Safety (CODE) | 12 | eval/exec, shell execution, rm -rf, obfuscation |
157
+ | Supply Chain (SUPPLY) | 10 | Unknown MCP servers, suspicious domains, malicious hashes, typosquat |
158
+ | Resource Abuse (RES) | 6 | Unrestricted tool access, disable safety checks, ignore project rules |
159
+
160
+ See [docs/SECURITY_BENCHMARK.md](docs/SECURITY_BENCHMARK.md) for the complete rule mapping with OWASP/CWE/ATT&CK references.
161
+
162
+ ## Programmatic API
163
+
164
+ ```typescript
165
+ import { scanSkillDirectory } from 'skill-checker';
166
+
167
+ const report = scanSkillDirectory('./my-skill', {
168
+ policy: 'strict',
169
+ overrides: { 'CODE-006': 'LOW' },
170
+ ignore: ['CONT-001'],
171
+ });
172
+
173
+ console.log(report.grade, report.score, report.results.length);
57
174
  ```
58
175
 
59
176
  ## License
60
177
 
61
- This project is licensed under the **GNU Affero General Public License v3.0 (AGPLv3)** - see the [LICENSE](LICENSE) file for details.
178
+ This project is licensed under the **GNU Affero General Public License v3.0 (AGPLv3)** see the [LICENSE](LICENSE) file for details.
62
179
 
63
- **商业授权 (Commercial License)**
180
+ **Commercial License (商业授权)**
64
181
 
65
- 如果您希望将本工具集成到闭源的商业产品、SaaS 服务中,或者由于公司合规原因无法遵守 AGPLv3 协议,请通过 Alexander.kinging@gmail.com 联系作者购买商业授权。
182
+ If you want to integrate this tool into a closed-source commercial product or SaaS, or cannot comply with AGPLv3 due to company policy, contact Alexander.kinging@gmail.com for a commercial license.
package/dist/cli.js CHANGED
@@ -1,16 +1,9 @@
1
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
- }) : x)(function(x) {
4
- if (typeof require !== "undefined") return require.apply(this, arguments);
5
- throw Error('Dynamic require of "' + x + '" is not supported');
6
- });
7
-
8
1
  // src/cli.ts
9
2
  import { createRequire } from "module";
10
3
  import { Command } from "commander";
11
4
 
12
5
  // src/parser.ts
13
- import { readFileSync, readdirSync, statSync, existsSync } from "fs";
6
+ import { readFileSync, readdirSync, lstatSync, existsSync, openSync, readSync, closeSync } from "fs";
14
7
  import { join, extname, basename, resolve } from "path";
15
8
  import { parse as parseYaml } from "yaml";
16
9
  var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
@@ -124,52 +117,66 @@ function enumerateFiles(dirPath, warnings) {
124
117
  }
125
118
  for (const entry of entries) {
126
119
  const fullPath = join(currentDir, entry.name);
127
- if (entry.isDirectory()) {
120
+ const relativePath = fullPath.slice(dirPath.length + 1);
121
+ let lstats;
122
+ try {
123
+ lstats = lstatSync(fullPath);
124
+ } catch {
125
+ continue;
126
+ }
127
+ if (lstats.isSymbolicLink()) {
128
+ warnings.push(`Skipped symlink: ${relativePath}`);
129
+ continue;
130
+ }
131
+ if (lstats.isDirectory()) {
128
132
  if (SKIP_DIRS.has(entry.name)) continue;
129
133
  if (WARN_SKIP_DIRS.has(entry.name)) {
130
- const rel = fullPath.slice(dirPath.length + 1);
131
- warnings.push(`Skipped directory: ${rel}. May contain unscanned files.`);
134
+ warnings.push(`Skipped directory: ${relativePath}. May contain unscanned files.`);
132
135
  continue;
133
136
  }
134
137
  walk(fullPath, depth + 1);
135
138
  continue;
136
139
  }
137
- const ext = extname(entry.name).toLowerCase();
138
- let stats;
139
- try {
140
- stats = statSync(fullPath);
141
- } catch {
140
+ if (!lstats.isFile()) {
141
+ warnings.push(`Skipped special file: ${relativePath}`);
142
142
  continue;
143
143
  }
144
+ const ext = extname(entry.name).toLowerCase();
144
145
  const isBinary = BINARY_EXTENSIONS.has(ext);
145
- const relativePath = fullPath.slice(dirPath.length + 1);
146
146
  let content;
147
147
  if (!isBinary) {
148
- if (stats.size <= FULL_READ_LIMIT) {
148
+ if (lstats.size <= FULL_READ_LIMIT) {
149
149
  try {
150
150
  content = readFileSync(fullPath, "utf-8");
151
151
  } catch {
152
152
  }
153
- } else if (stats.size <= 5e7) {
153
+ } else if (lstats.size <= 5e7) {
154
+ let fd;
154
155
  try {
155
- const fd = __require("fs").openSync(fullPath, "r");
156
+ fd = openSync(fullPath, "r");
156
157
  const buf = Buffer.alloc(PARTIAL_READ_LIMIT);
157
- const bytesRead = __require("fs").readSync(fd, buf, 0, PARTIAL_READ_LIMIT, 0);
158
- __require("fs").closeSync(fd);
158
+ const bytesRead = readSync(fd, buf, 0, PARTIAL_READ_LIMIT, 0);
159
159
  content = buf.slice(0, bytesRead).toString("utf-8");
160
- warnings.push(`Large file partially scanned (first ${PARTIAL_READ_LIMIT} bytes): ${relativePath} (${stats.size} bytes total)`);
160
+ warnings.push(`Large file partially scanned (first ${PARTIAL_READ_LIMIT} bytes): ${relativePath} (${lstats.size} bytes total)`);
161
161
  } catch {
162
- warnings.push(`Large file could not be read: ${relativePath} (${stats.size} bytes)`);
162
+ warnings.push(`Large file could not be read: ${relativePath} (${lstats.size} bytes)`);
163
+ } finally {
164
+ if (fd !== void 0) {
165
+ try {
166
+ closeSync(fd);
167
+ } catch {
168
+ }
169
+ }
163
170
  }
164
171
  } else {
165
- warnings.push(`File too large to scan: ${relativePath} (${stats.size} bytes). Content not checked.`);
172
+ warnings.push(`File too large to scan: ${relativePath} (${lstats.size} bytes). Content not checked.`);
166
173
  }
167
174
  }
168
175
  files.push({
169
176
  path: relativePath,
170
177
  name: basename(entry.name, ext),
171
178
  extension: ext,
172
- sizeBytes: stats.size,
179
+ sizeBytes: lstats.size,
173
180
  isBinary,
174
181
  content
175
182
  });
@@ -261,7 +268,8 @@ var structuralChecks = {
261
268
  category: "STRUCT",
262
269
  severity: "HIGH",
263
270
  title: "Unexpected binary/executable file",
264
- message: `Found unexpected file: ${file.path} (${ext})`
271
+ message: `Found unexpected file: ${file.path} (${ext})`,
272
+ source: file.path
265
273
  });
266
274
  }
267
275
  }
@@ -287,12 +295,14 @@ var structuralChecks = {
287
295
  }
288
296
  }
289
297
  for (const warning of skill.warnings) {
298
+ const pathMatch = warning.match(/:\s*(.+?)(?:\s*\(|$)/);
290
299
  results.push({
291
300
  id: "STRUCT-008",
292
301
  category: "STRUCT",
293
302
  severity: "MEDIUM",
294
303
  title: "Scan coverage warning",
295
- message: warning
304
+ message: warning,
305
+ source: pathMatch?.[1]?.trim()
296
306
  });
297
307
  }
298
308
  return results;
@@ -1004,7 +1014,11 @@ function getHookAction(policy, severity) {
1004
1014
  LOW: "report"
1005
1015
  }
1006
1016
  };
1007
- return matrix[policy][severity];
1017
+ const row = matrix[policy];
1018
+ if (!row) {
1019
+ return matrix.balanced[severity];
1020
+ }
1021
+ return row[severity];
1008
1022
  }
1009
1023
 
1010
1024
  // src/checks/code-safety.ts
@@ -1096,7 +1110,8 @@ var codeSafetyChecks = {
1096
1110
  severity: "CRITICAL",
1097
1111
  title: "eval/exec/Function constructor",
1098
1112
  loc,
1099
- lineNum
1113
+ lineNum,
1114
+ source
1100
1115
  });
1101
1116
  if (!SHELL_EXEC_FALSE_POSITIVES.some((p) => p.test(line))) {
1102
1117
  checkPatterns(results, line, SHELL_EXEC_PATTERNS, {
@@ -1104,7 +1119,8 @@ var codeSafetyChecks = {
1104
1119
  severity: "CRITICAL",
1105
1120
  title: "Shell/subprocess execution",
1106
1121
  loc,
1107
- lineNum
1122
+ lineNum,
1123
+ source
1108
1124
  });
1109
1125
  }
1110
1126
  checkPatterns(results, line, DESTRUCTIVE_PATTERNS, {
@@ -1113,6 +1129,7 @@ var codeSafetyChecks = {
1113
1129
  title: "Destructive file operation",
1114
1130
  loc,
1115
1131
  lineNum,
1132
+ source,
1116
1133
  codeBlockCtx: cbCtx
1117
1134
  });
1118
1135
  checkPatterns(results, line, NETWORK_PATTERNS, {
@@ -1121,6 +1138,7 @@ var codeSafetyChecks = {
1121
1138
  title: "Hardcoded external URL/network request",
1122
1139
  loc,
1123
1140
  lineNum,
1141
+ source,
1124
1142
  codeBlockCtx: cbCtx
1125
1143
  });
1126
1144
  checkPatterns(results, line, FILE_WRITE_PATTERNS, {
@@ -1128,7 +1146,8 @@ var codeSafetyChecks = {
1128
1146
  severity: "HIGH",
1129
1147
  title: "File write outside expected directory",
1130
1148
  loc,
1131
- lineNum
1149
+ lineNum,
1150
+ source
1132
1151
  });
1133
1152
  checkPatterns(results, line, ENV_ACCESS_PATTERNS, {
1134
1153
  id: "CODE-006",
@@ -1136,6 +1155,7 @@ var codeSafetyChecks = {
1136
1155
  title: "Environment variable access",
1137
1156
  loc,
1138
1157
  lineNum,
1158
+ source,
1139
1159
  codeBlockCtx: cbCtx
1140
1160
  });
1141
1161
  checkPatterns(results, line, DYNAMIC_CODE_PATTERNS, {
@@ -1143,7 +1163,8 @@ var codeSafetyChecks = {
1143
1163
  severity: "HIGH",
1144
1164
  title: "Dynamic code generation pattern",
1145
1165
  loc,
1146
- lineNum
1166
+ lineNum,
1167
+ source
1147
1168
  });
1148
1169
  {
1149
1170
  const isDoc = isInDocumentationContext(lines, i);
@@ -1153,7 +1174,8 @@ var codeSafetyChecks = {
1153
1174
  severity: "HIGH",
1154
1175
  title: "Permission escalation",
1155
1176
  loc,
1156
- lineNum
1177
+ lineNum,
1178
+ source
1157
1179
  });
1158
1180
  }
1159
1181
  }
@@ -1184,6 +1206,7 @@ function checkPatterns(results, line, patterns, opts) {
1184
1206
  message: `At ${opts.loc}: ${line.trim().slice(0, 120)}${msgSuffix}`,
1185
1207
  line: opts.lineNum,
1186
1208
  snippet: line.trim().slice(0, 120),
1209
+ source: opts.source,
1187
1210
  reducedFrom
1188
1211
  });
1189
1212
  return;
@@ -1215,7 +1238,8 @@ function scanEncodedStrings(results, text, source) {
1215
1238
  title: "Long encoded string",
1216
1239
  message: `${source}:${lineNum}: Found ${str.length}-char encoded string.`,
1217
1240
  line: lineNum,
1218
- snippet: str.slice(0, 80) + "..."
1241
+ snippet: str.slice(0, 80) + "...",
1242
+ source
1219
1243
  });
1220
1244
  }
1221
1245
  }
@@ -1230,7 +1254,8 @@ function scanEncodedStrings(results, text, source) {
1230
1254
  severity: "MEDIUM",
1231
1255
  title: "High entropy string",
1232
1256
  message: `${source}:${lineNum}: String "${match[0].slice(0, 30)}..." has entropy ${entropy.toFixed(2)} bits/char.`,
1233
- line: lineNum
1257
+ line: lineNum,
1258
+ source
1234
1259
  });
1235
1260
  }
1236
1261
  }
@@ -1247,7 +1272,8 @@ function scanEncodedStrings(results, text, source) {
1247
1272
  category: "CODE",
1248
1273
  severity: "CRITICAL",
1249
1274
  title: "Multi-layer encoding detected",
1250
- message: `${source}: Contains nested encoding/decoding operations.`
1275
+ message: `${source}: Contains nested encoding/decoding operations.`,
1276
+ source
1251
1277
  });
1252
1278
  break;
1253
1279
  }
@@ -1262,7 +1288,8 @@ function scanObfuscation(results, text, source) {
1262
1288
  category: "CODE",
1263
1289
  severity: "MEDIUM",
1264
1290
  title: "Obfuscated variable names",
1265
- message: `${source}: Found ${obfMatches.length} hex-style variable names (e.g. ${obfMatches[0]}). May indicate obfuscated code.`
1291
+ message: `${source}: Found ${obfMatches.length} hex-style variable names (e.g. ${obfMatches[0]}). May indicate obfuscated code.`,
1292
+ source
1266
1293
  });
1267
1294
  }
1268
1295
  }
@@ -1453,6 +1480,7 @@ var supplyChainChecks = {
1453
1480
  message: `${source}:${lineNum}: References an MCP server. Verify it is from a trusted source.${msgSuffix}`,
1454
1481
  line: lineNum,
1455
1482
  snippet: line.trim().slice(0, 120),
1483
+ source,
1456
1484
  reducedFrom
1457
1485
  });
1458
1486
  }
@@ -1464,7 +1492,8 @@ var supplyChainChecks = {
1464
1492
  title: "npx -y auto-install",
1465
1493
  message: `${source}:${lineNum}: Uses npx -y which auto-installs packages without confirmation.`,
1466
1494
  line: lineNum,
1467
- snippet: line.trim().slice(0, 120)
1495
+ snippet: line.trim().slice(0, 120),
1496
+ source
1468
1497
  });
1469
1498
  }
1470
1499
  if (NPM_INSTALL_PATTERN.test(line) || PIP_INSTALL_PATTERN.test(line)) {
@@ -1494,6 +1523,7 @@ var supplyChainChecks = {
1494
1523
  message: `${source}:${lineNum}: Installs packages. Verify package names are legitimate.${msgSuffix}`,
1495
1524
  line: lineNum,
1496
1525
  snippet: line.trim().slice(0, 120),
1526
+ source,
1497
1527
  reducedFrom
1498
1528
  });
1499
1529
  }
@@ -1506,7 +1536,8 @@ var supplyChainChecks = {
1506
1536
  title: "git clone command",
1507
1537
  message: `${source}:${lineNum}: Clones a git repository. Verify the source.`,
1508
1538
  line: lineNum,
1509
- snippet: line.trim().slice(0, 120)
1539
+ snippet: line.trim().slice(0, 120),
1540
+ source
1510
1541
  });
1511
1542
  }
1512
1543
  const urls = line.match(URL_PATTERN) || [];
@@ -1535,6 +1566,7 @@ var supplyChainChecks = {
1535
1566
  message: `${source}:${lineNum}: Uses insecure HTTP: ${url}${msgSuffix}`,
1536
1567
  line: lineNum,
1537
1568
  snippet: url,
1569
+ source,
1538
1570
  reducedFrom
1539
1571
  });
1540
1572
  }
@@ -1548,7 +1580,8 @@ var supplyChainChecks = {
1548
1580
  title: "IP address used instead of domain",
1549
1581
  message: `${source}:${lineNum}: Uses raw IP address: ${url}. This may bypass DNS-based security.`,
1550
1582
  line: lineNum,
1551
- snippet: url
1583
+ snippet: url,
1584
+ source
1552
1585
  });
1553
1586
  }
1554
1587
  }
@@ -1562,7 +1595,8 @@ var supplyChainChecks = {
1562
1595
  title: "Suspicious domain detected",
1563
1596
  message: `${source}:${lineNum}: References suspicious domain "${domain}".`,
1564
1597
  line: lineNum,
1565
- snippet: url
1598
+ snippet: url,
1599
+ source
1566
1600
  });
1567
1601
  break;
1568
1602
  }
@@ -1762,7 +1796,7 @@ var resourceChecks = {
1762
1796
 
1763
1797
  // src/ioc/matcher.ts
1764
1798
  import { createHash } from "crypto";
1765
- import { openSync, readSync, closeSync, statSync as statSync2 } from "fs";
1799
+ import { openSync as openSync2, readSync as readSync2, closeSync as closeSync2, statSync } from "fs";
1766
1800
  import { join as join3 } from "path";
1767
1801
 
1768
1802
  // src/utils/levenshtein.ts
@@ -1811,18 +1845,18 @@ function isPrivateIP(ip) {
1811
1845
  var HASH_CHUNK_SIZE = 64 * 1024;
1812
1846
  function computeFileHash(filePath) {
1813
1847
  const hash = createHash("sha256");
1814
- const fd = openSync(filePath, "r");
1848
+ const fd = openSync2(filePath, "r");
1815
1849
  try {
1816
1850
  const buf = Buffer.alloc(HASH_CHUNK_SIZE);
1817
1851
  let bytesRead;
1818
1852
  do {
1819
- bytesRead = readSync(fd, buf, 0, HASH_CHUNK_SIZE, null);
1853
+ bytesRead = readSync2(fd, buf, 0, HASH_CHUNK_SIZE, null);
1820
1854
  if (bytesRead > 0) {
1821
1855
  hash.update(buf.subarray(0, bytesRead));
1822
1856
  }
1823
1857
  } while (bytesRead > 0);
1824
1858
  } finally {
1825
- closeSync(fd);
1859
+ closeSync2(fd);
1826
1860
  }
1827
1861
  return hash.digest("hex");
1828
1862
  }
@@ -1833,7 +1867,7 @@ function matchMaliciousHashes(skill, ioc) {
1833
1867
  for (const file of skill.files) {
1834
1868
  const filePath = join3(skill.dirPath, file.path);
1835
1869
  try {
1836
- const stat = statSync2(filePath);
1870
+ const stat = statSync(filePath);
1837
1871
  if (stat.size === 0) continue;
1838
1872
  const hash = computeFileHash(filePath);
1839
1873
  if (hash === EMPTY_FILE_HASH) continue;
@@ -1924,7 +1958,8 @@ var iocChecks = {
1924
1958
  severity: "CRITICAL",
1925
1959
  title: "Known malicious file hash",
1926
1960
  message: `File "${match.file}" matches known malicious hash: ${match.description}`,
1927
- snippet: match.hash
1961
+ snippet: match.hash,
1962
+ source: match.file
1928
1963
  });
1929
1964
  }
1930
1965
  const ipMatches = matchC2IPs(skill, ioc);
@@ -1936,7 +1971,8 @@ var iocChecks = {
1936
1971
  title: "Known C2 IP address",
1937
1972
  message: `${match.source}:${match.line}: Contains known C2 server IP: ${match.ip}`,
1938
1973
  line: match.line,
1939
- snippet: match.snippet
1974
+ snippet: match.snippet,
1975
+ source: match.source
1940
1976
  });
1941
1977
  }
1942
1978
  const skillName = skill.frontmatter.name;
@@ -2029,8 +2065,8 @@ function deduplicateResults(results) {
2029
2065
  LOW: 1
2030
2066
  };
2031
2067
  for (const r of results) {
2032
- const sourceFile = extractSourceFile(r.message);
2033
- const key = `${r.id}::${sourceFile}`;
2068
+ const sourceKey = r.source ?? `_no_source_:${r.category}:${r.line ?? ""}`;
2069
+ const key = `${r.id}::${sourceKey}`;
2034
2070
  const group = groups.get(key);
2035
2071
  if (group) {
2036
2072
  group.push(r);
@@ -2044,16 +2080,13 @@ function deduplicateResults(results) {
2044
2080
  const best = { ...group[0] };
2045
2081
  if (group.length > 1) {
2046
2082
  best.occurrences = group.length;
2047
- best.message += ` (${group.length} occurrences in this file)`;
2083
+ const suffix = best.source ? ` (${group.length} occurrences in this file)` : ` (${group.length} occurrences)`;
2084
+ best.message += suffix;
2048
2085
  }
2049
2086
  deduped.push(best);
2050
2087
  }
2051
2088
  return deduped;
2052
2089
  }
2053
- function extractSourceFile(message) {
2054
- const m = message.match(/^(?:At\s+)?([^:]+):\d+:/);
2055
- return m?.[1] ?? "unknown";
2056
- }
2057
2090
  function calculateScore(results) {
2058
2091
  let score = 100;
2059
2092
  for (const r of results) {
@@ -2328,8 +2361,13 @@ var program = new Command();
2328
2361
  program.name("skill-checker").description(
2329
2362
  "Security checker for Claude Code skills - detect injection, malicious code, and supply chain risks"
2330
2363
  ).version(pkg.version);
2364
+ var VALID_POLICIES = ["strict", "balanced", "permissive"];
2331
2365
  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(
2332
2366
  (path, opts) => {
2367
+ if (opts.policy && !VALID_POLICIES.includes(opts.policy)) {
2368
+ console.error(`Error: invalid policy "${opts.policy}". Valid values: ${VALID_POLICIES.join(", ")}`);
2369
+ process.exit(1);
2370
+ }
2333
2371
  const config = loadConfig(path, opts.config);
2334
2372
  if (opts.policy) {
2335
2373
  config.policy = opts.policy;