ai-warden 1.0.4 → 1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-warden",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "description": "AI security scanner - Detect prompt injection attacks and PII with user settings",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -16,6 +16,7 @@ const IgnoreParser = require('./ignoreParser');
16
16
  const InteractivePrompt = require('./interactivePrompt');
17
17
  const AIWarden = require('./index');
18
18
  const config = require('./config');
19
+ const { scanSkill } = require('./skillScanner');
19
20
 
20
21
  const args = process.argv.slice(2);
21
22
  const command = args[0];
@@ -33,6 +34,7 @@ Usage: aiwarden <command> [options]
33
34
 
34
35
  Commands:
35
36
  scan <path> Scan file or directory (offline mode)
37
+ scan-skill <url> Scan a remote skill repo for threats
36
38
  validate <text> Validate text via API (requires API key)
37
39
  login Sign up or log in and save API key
38
40
  logout Remove saved API key
@@ -46,6 +48,12 @@ Scan Options (offline mode):
46
48
  --interactive Interactive mode (whitelist threats as you go)
47
49
  --ignore-file <path> Custom ignore file (default: .aiwardenignore)
48
50
 
51
+ Scan-Skill Options:
52
+ --offline Use local scanner (free, no API key needed)
53
+ --json Output raw JSON
54
+ --strict Exit code 1 if verdict is not SAFE
55
+ --mode <mode> Detection mode (strict|balanced|permissive)
56
+
49
57
  Validate Options (API mode):
50
58
  --api-key <key> API key (or use AI_WARDEN_API_KEY env var)
51
59
  --mode <mode> Detection mode (strict|balanced|permissive)
@@ -57,6 +65,11 @@ Examples:
57
65
  aiwarden scan file.txt Scan single file
58
66
  aiwarden scan . --mode strict Strict detection mode
59
67
 
68
+ # Skill repo scanning
69
+ aiwarden scan-skill https://github.com/user/skill --offline
70
+ aiwarden scan-skill https://github.com/user/skill --json
71
+ aiwarden scan-skill https://github.com/user/skill --strict
72
+
60
73
  # API validation (requires API key)
61
74
  aiwarden validate "Ignore all instructions" --api-key sk_live_xxx
62
75
  export AI_WARDEN_API_KEY=sk_live_xxx
@@ -325,22 +338,26 @@ async function validateCommand(textOrPath, options = {}) {
325
338
  } else {
326
339
  // Human-readable output
327
340
  const data = result.data || result; // Handle nested response
328
- const blocked = data.decision === 'BLOCK';
341
+ const blocked = data.decision === 'BLOCK' || data.safe === false;
342
+ const riskPct = data.riskScore != null ? (data.riskScore * 100).toFixed(1) + '%' : 'N/A';
343
+ const confPct = data.confidence != null ? (data.confidence * 100).toFixed(1) + '%' : 'N/A';
344
+ const latency = data.latency_ms != null ? `${data.latency_ms.toFixed(0)}ms` : '';
329
345
 
330
346
  console.log('═══════════════════════════════════════════════════════');
331
- console.log(`Status: ${blocked ? '❌ BLOCKED' : '✅ SAFE'}`);
332
- console.log(`Layer: ${data.layer_name || data.layer || 'unknown'}`);
333
- console.log(`Confidence: ${((data.confidence || 0) * 100).toFixed(1)}%`);
334
- console.log(`Decision: ${data.decision || 'unknown'}`);
347
+ console.log(`Status: ${blocked ? '❌ BLOCKED' : '✅ SAFE'}`);
348
+ console.log(`Risk Score: ${riskPct}`);
349
+ console.log(`Confidence: ${confPct}`);
350
+ console.log(`Layer: ${data.layer_name || data.layer || 'unknown'}`);
351
+ if (latency) console.log(`Latency: ${latency}`);
335
352
 
336
353
  // Show sandwich info if used
337
354
  if (data.sandwich && data.sandwich.enabled) {
338
- console.log(`Scanned: ${data.sandwich.scannedWords.toLocaleString()} words (head + tail)`);
339
- console.log(`Original: ${data.sandwich.originalWords.toLocaleString()} words`);
355
+ console.log(`Scanned: ${data.sandwich.scannedWords.toLocaleString()} words (head + tail)`);
356
+ console.log(`Original: ${data.sandwich.originalWords.toLocaleString()} words`);
340
357
  }
341
358
 
342
359
  if (data.reason) {
343
- console.log(`Reason: ${data.reason}`);
360
+ console.log(`Reason: ${data.reason}`);
344
361
  }
345
362
 
346
363
  if (data.cleanText && data.cleanText !== text) {
@@ -351,7 +368,7 @@ async function validateCommand(textOrPath, options = {}) {
351
368
  console.log('═══════════════════════════════════════════════════════');
352
369
  }
353
370
 
354
- process.exit((result.data || result).decision === 'BLOCK' ? 1 : 0);
371
+ process.exit((result.data || result).decision === 'BLOCK' || (result.data || result).safe === false ? 1 : 0);
355
372
 
356
373
  } catch (error) {
357
374
  console.error('❌ Validation failed:', error.message);
@@ -390,6 +407,10 @@ function parseArgs() {
390
407
  } else if (args[i] === '--ignore-file' && args[i + 1]) {
391
408
  options.ignoreFile = args[i + 1];
392
409
  i++;
410
+ } else if (args[i] === '--offline') {
411
+ options.offline = true;
412
+ } else if (args[i] === '--strict') {
413
+ options.strict = true;
393
414
  }
394
415
  }
395
416
 
@@ -617,6 +638,15 @@ function whoamiCommand() {
617
638
  const targetPath = args[1];
618
639
  const options = parseArgs();
619
640
  scanCommand(targetPath, options);
641
+ } else if (command === 'scan-skill') {
642
+ const url = args[1];
643
+ if (!url) {
644
+ console.error('❌ Error: No URL specified');
645
+ console.log('Usage: aiwarden scan-skill <url> [--offline] [--json] [--strict]');
646
+ process.exit(1);
647
+ }
648
+ const options = parseArgs();
649
+ await scanSkill(url, options);
620
650
  } else if (command === 'validate') {
621
651
  const text = args[1];
622
652
  const options = parseArgs();
package/src/client.js CHANGED
@@ -79,10 +79,10 @@ class AIWardenClient {
79
79
  path: urlObj.pathname + urlObj.search,
80
80
  method: 'GET',
81
81
  headers: {
82
- 'Authorization': `Bearer ${this.apiKey}`,
82
+ 'X-API-Key': this.apiKey,
83
83
  'Content-Type': 'application/json',
84
- 'Cache-Control': 'no-cache', // Prevent Cloudflare from caching and stripping auth header
85
- 'User-Agent': 'ai-warden-sdk/0.7.0'
84
+ 'Cache-Control': 'no-cache',
85
+ 'User-Agent': 'ai-warden-sdk/1.1.0'
86
86
  },
87
87
  timeout: this.timeout
88
88
  };
@@ -140,10 +140,9 @@ class AIWardenClient {
140
140
  await this.init();
141
141
  }
142
142
 
143
- const url = `${this.apiUrl}/api/validate`;
143
+ const url = `${this.apiUrl}/v1/validate`;
144
144
  const payload = {
145
145
  text: content, // API expects 'text', not 'content'
146
- // Don't send settings - backend ignores them anyway and Cloudflare WAF blocks large payloads
147
146
  ...options
148
147
  };
149
148
 
@@ -157,10 +156,10 @@ class AIWardenClient {
157
156
  path: urlObj.pathname + urlObj.search,
158
157
  method: 'POST',
159
158
  headers: {
160
- 'Authorization': `Bearer ${this.apiKey}`,
159
+ 'X-API-Key': this.apiKey,
161
160
  'Content-Type': 'application/json',
162
161
  'Content-Length': Buffer.byteLength(body),
163
- 'User-Agent': 'ai-warden-sdk/0.7.0'
162
+ 'User-Agent': 'ai-warden-sdk/1.1.0'
164
163
  },
165
164
  timeout: this.timeout
166
165
  };
@@ -0,0 +1,448 @@
1
+ /**
2
+ * Skill Scanner - Scan remote skill repos for prompt injection threats
3
+ *
4
+ * Supports:
5
+ * - GitHub/ClawHub repo URLs
6
+ * - Offline mode (local scanner) and API mode
7
+ * - Batched file scanning for large repos
8
+ * - Human-readable and JSON output
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+ const { execSync } = require('child_process');
15
+ const https = require('https');
16
+ const http = require('http');
17
+ const scanner = require('./scanner');
18
+ const config = require('./config');
19
+
20
+ const MAX_FILE_SIZE = 100 * 1024; // 100KB
21
+ const BATCH_SIZE = 50;
22
+
23
+ // Binary file extensions to skip
24
+ const BINARY_EXTENSIONS = new Set([
25
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.svg',
26
+ '.woff', '.woff2', '.ttf', '.eot', '.otf',
27
+ '.zip', '.tar', '.gz', '.bz2', '.7z', '.rar',
28
+ '.pdf', '.doc', '.docx', '.xls', '.xlsx',
29
+ '.exe', '.dll', '.so', '.dylib', '.o',
30
+ '.mp3', '.mp4', '.avi', '.mov', '.wav',
31
+ '.bin', '.dat', '.db', '.sqlite',
32
+ '.pyc', '.class', '.wasm',
33
+ ]);
34
+
35
+ /**
36
+ * Parse a GitHub URL into { source, owner, repo, name, displayUrl }
37
+ */
38
+ function parseRepoUrl(url) {
39
+ // Remove trailing slash and .git
40
+ url = url.replace(/\/+$/, '').replace(/\.git$/, '');
41
+
42
+ const match = url.match(/(?:https?:\/\/)?(?:www\.)?(github\.com|clawhub\.com)\/([^/]+)\/([^/]+)/);
43
+ if (!match) {
44
+ throw new Error(`Invalid repository URL: ${url}\nExpected: https://github.com/<owner>/<repo>`);
45
+ }
46
+
47
+ return {
48
+ source: match[1].replace('.com', ''),
49
+ owner: match[2],
50
+ repo: match[3],
51
+ name: match[3],
52
+ displayUrl: `${match[1]}/${match[2]}/${match[3]}`,
53
+ cloneUrl: `https://${match[1]}/${match[2]}/${match[3]}.git`,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Clone repo to temp directory
59
+ */
60
+ function cloneRepo(cloneUrl) {
61
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-warden-scan-'));
62
+
63
+ try {
64
+ execSync(`git clone --depth 1 ${cloneUrl} ${tmpDir}`, {
65
+ stdio: 'pipe',
66
+ timeout: 60000, // 60s timeout
67
+ });
68
+ } catch (err) {
69
+ // Clean up on failure
70
+ cleanupDir(tmpDir);
71
+ const msg = err.stderr ? err.stderr.toString().trim() : err.message;
72
+ throw new Error(`Failed to clone repository: ${msg}`);
73
+ }
74
+
75
+ return tmpDir;
76
+ }
77
+
78
+ /**
79
+ * Get git SHA of cloned repo
80
+ */
81
+ function getGitSha(repoDir) {
82
+ try {
83
+ return execSync('git rev-parse HEAD', { cwd: repoDir, stdio: 'pipe' })
84
+ .toString()
85
+ .trim();
86
+ } catch {
87
+ return 'unknown';
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Classify a file by its path/name into a role
93
+ */
94
+ function classifyFileRole(relativePath) {
95
+ const basename = path.basename(relativePath);
96
+ const dir = path.dirname(relativePath);
97
+ const ext = path.extname(relativePath).toLowerCase();
98
+
99
+ if (basename === 'SKILL.md') return 'skill-definition';
100
+ if (dir.startsWith('scripts') && (ext === '.sh' || ext === '.py')) return 'install-script';
101
+ if (dir.startsWith('references')) return 'reference-doc';
102
+ if (['.yaml', '.yml', '.json', '.toml'].includes(ext)) return 'config';
103
+ if (ext === '.md') return 'documentation';
104
+ return 'unknown';
105
+ }
106
+
107
+ /**
108
+ * Enumerate all text files in a directory (skip binaries, .git, large files)
109
+ */
110
+ function enumerateFiles(dir, baseDir = dir) {
111
+ const results = [];
112
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
113
+
114
+ for (const entry of entries) {
115
+ const fullPath = path.join(dir, entry.name);
116
+ const relativePath = path.relative(baseDir, fullPath);
117
+
118
+ // Skip hidden dirs and node_modules
119
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
120
+
121
+ if (entry.isDirectory()) {
122
+ results.push(...enumerateFiles(fullPath, baseDir));
123
+ } else if (entry.isFile()) {
124
+ const ext = path.extname(entry.name).toLowerCase();
125
+ if (BINARY_EXTENSIONS.has(ext)) continue;
126
+
127
+ const stat = fs.statSync(fullPath);
128
+ if (stat.size > MAX_FILE_SIZE) continue;
129
+ if (stat.size === 0) continue;
130
+
131
+ // Try reading as text — skip if binary
132
+ try {
133
+ const content = fs.readFileSync(fullPath, 'utf-8');
134
+ // Quick binary check: if it has null bytes, skip
135
+ if (content.includes('\0')) continue;
136
+
137
+ results.push({
138
+ path: relativePath,
139
+ role: classifyFileRole(relativePath),
140
+ content,
141
+ });
142
+ } catch {
143
+ // Can't read as text, skip
144
+ }
145
+ }
146
+ }
147
+
148
+ return results;
149
+ }
150
+
151
+ /**
152
+ * Run offline scan on all files using local scanner
153
+ */
154
+ function scanOffline(files, options = {}) {
155
+ const startTime = Date.now();
156
+ const results = [];
157
+ let worstScore = 0;
158
+ let anyThreats = false;
159
+
160
+ for (const file of files) {
161
+ const scanOpts = {
162
+ mode: options.mode || 'balanced',
163
+ context: file.role === 'skill-definition' ? 'skill' : 'user',
164
+ };
165
+
166
+ const result = scanner.scan(file.content, scanOpts);
167
+ const normalizedScore = Math.min(result.riskScore / (scanOpts.mode === 'strict' ? 75 : 150), 1.0);
168
+
169
+ results.push({
170
+ path: file.path,
171
+ role: file.role,
172
+ safe: result.passed,
173
+ score: parseFloat(normalizedScore.toFixed(2)),
174
+ riskLevel: result.riskLevel,
175
+ findings: result.findings,
176
+ findingsCount: result.findings.length,
177
+ });
178
+
179
+ if (normalizedScore > worstScore) worstScore = normalizedScore;
180
+ if (!result.passed) anyThreats = true;
181
+ }
182
+
183
+ const scanTime = ((Date.now() - startTime) / 1000).toFixed(1);
184
+ const trustScore = Math.max(0, Math.round((1 - worstScore) * 100));
185
+
186
+ let verdict;
187
+ if (!anyThreats && worstScore < 0.3) verdict = 'SAFE';
188
+ else if (anyThreats) verdict = 'DANGEROUS';
189
+ else verdict = 'WARNING';
190
+
191
+ return {
192
+ files: results,
193
+ verdict,
194
+ trustScore,
195
+ scanTime,
196
+ mode: 'offline',
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Send files to API for scanning (batched)
202
+ */
203
+ async function scanApi(files, repoInfo, options = {}) {
204
+ const apiKey = options.apiKey || process.env.AI_WARDEN_API_KEY || config.getApiKey();
205
+ if (!apiKey) {
206
+ throw new Error('API key required for online scan. Use --offline for free local scanning, or run: aiwarden login');
207
+ }
208
+
209
+ const apiUrl = options.apiUrl || 'https://api.ai-warden.io';
210
+ const startTime = Date.now();
211
+
212
+ // Chunk files into batches
213
+ const batches = [];
214
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
215
+ batches.push(files.slice(i, i + BATCH_SIZE));
216
+ }
217
+
218
+ const allResults = [];
219
+
220
+ for (const batch of batches) {
221
+ const payload = {
222
+ skill: {
223
+ source: repoInfo.source,
224
+ url: repoInfo.displayUrl,
225
+ ref: repoInfo.sha,
226
+ name: repoInfo.name,
227
+ },
228
+ files: batch.map(f => ({
229
+ path: f.path,
230
+ role: f.role,
231
+ content: f.content,
232
+ })),
233
+ };
234
+
235
+ const result = await postJson(`${apiUrl}/v1/scan-skill`, payload, apiKey);
236
+ if (result.files) {
237
+ allResults.push(...result.files);
238
+ }
239
+ }
240
+
241
+ const scanTime = ((Date.now() - startTime) / 1000).toFixed(1);
242
+
243
+ // Aggregate verdict from all batches
244
+ let worstScore = 0;
245
+ let anyDangerous = false;
246
+ let anyWarning = false;
247
+
248
+ for (const r of allResults) {
249
+ if (r.score > worstScore) worstScore = r.score;
250
+ if (r.riskLevel === 'CRITICAL' || r.riskLevel === 'HIGH') anyDangerous = true;
251
+ if (r.riskLevel === 'MEDIUM') anyWarning = true;
252
+ }
253
+
254
+ const trustScore = Math.max(0, Math.round((1 - worstScore) * 100));
255
+ let verdict;
256
+ if (anyDangerous) verdict = 'DANGEROUS';
257
+ else if (anyWarning) verdict = 'WARNING';
258
+ else verdict = 'SAFE';
259
+
260
+ return {
261
+ files: allResults,
262
+ verdict,
263
+ trustScore,
264
+ scanTime,
265
+ mode: 'api',
266
+ };
267
+ }
268
+
269
+ /**
270
+ * POST JSON to URL (using native http/https)
271
+ */
272
+ function postJson(url, payload, apiKey) {
273
+ return new Promise((resolve, reject) => {
274
+ const urlObj = new URL(url);
275
+ const protocol = urlObj.protocol === 'https:' ? https : http;
276
+ const body = JSON.stringify(payload);
277
+
278
+ const opts = {
279
+ hostname: urlObj.hostname,
280
+ path: urlObj.pathname,
281
+ method: 'POST',
282
+ headers: {
283
+ 'X-API-Key': apiKey,
284
+ 'Content-Type': 'application/json',
285
+ 'Content-Length': Buffer.byteLength(body),
286
+ 'User-Agent': 'ai-warden-cli/1.0.4',
287
+ },
288
+ timeout: 30000,
289
+ };
290
+ if (urlObj.port) opts.port = urlObj.port;
291
+
292
+ const req = protocol.request(opts, (res) => {
293
+ let data = '';
294
+ res.on('data', chunk => data += chunk);
295
+ res.on('end', () => {
296
+ if (res.statusCode === 200) {
297
+ try { resolve(JSON.parse(data)); }
298
+ catch (e) { reject(new Error(`Invalid API response: ${e.message}`)); }
299
+ } else if (res.statusCode === 401) {
300
+ reject(new Error('Invalid API key'));
301
+ } else {
302
+ reject(new Error(`API error ${res.statusCode}: ${data}`));
303
+ }
304
+ });
305
+ });
306
+
307
+ req.on('error', e => reject(new Error(`Network error: ${e.message}`)));
308
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
309
+ req.write(body);
310
+ req.end();
311
+ });
312
+ }
313
+
314
+ /**
315
+ * Format human-readable report
316
+ */
317
+ function formatReport(scanResult, repoInfo) {
318
+ const lines = [];
319
+ const bar = '━'.repeat(45);
320
+
321
+ lines.push('');
322
+ lines.push('🔍 AI-Warden Skill Scan');
323
+ lines.push(bar);
324
+
325
+ lines.push(` Skill: ${repoInfo.name}`);
326
+ lines.push(` Source: ${repoInfo.displayUrl}`);
327
+ lines.push(` Files: ${scanResult.files.length} scanned`);
328
+ if (scanResult.mode === 'offline') lines.push(` Mode: offline`);
329
+ lines.push('');
330
+
331
+ for (const file of scanResult.files) {
332
+ const icon = file.safe !== false ? '✅' : '❌';
333
+ const label = file.safe !== false ? 'Safe' : file.riskLevel || 'Threat';
334
+ const score = `(${file.score.toFixed(2)})`;
335
+ const name = file.path.padEnd(25);
336
+ lines.push(` ${name}${icon} ${label.padEnd(10)} ${score}`);
337
+
338
+ // Show findings detail for flagged files
339
+ if (file.findings && file.findings.length > 0) {
340
+ for (let i = 0; i < file.findings.length; i++) {
341
+ const f = file.findings[i];
342
+ const isLast = i === file.findings.length - 1;
343
+ const branch = isLast ? '└─' : '├─';
344
+ const severity = f.severity || 'UNKNOWN';
345
+ const detail = f.match || f.detail || '';
346
+ const truncated = detail.length > 60 ? detail.substring(0, 57) + '...' : detail;
347
+ lines.push(` ${branch} ${f.id}: ${f.name} [${severity}]${truncated ? ' — "' + truncated + '"' : ''}`);
348
+ }
349
+ }
350
+ }
351
+
352
+ lines.push('');
353
+
354
+ const verdictIcon = scanResult.verdict === 'SAFE' ? '✅' : scanResult.verdict === 'WARNING' ? '⚠️' : '❌';
355
+ lines.push(` Verdict: ${verdictIcon} ${scanResult.verdict}`);
356
+ lines.push(` Trust Score: ${scanResult.trustScore}/100`);
357
+ lines.push(` Scan Time: ${scanResult.scanTime}s`);
358
+ lines.push(bar);
359
+ lines.push('');
360
+
361
+ return lines.join('\n');
362
+ }
363
+
364
+ /**
365
+ * Clean up temp directory
366
+ */
367
+ function cleanupDir(dir) {
368
+ try {
369
+ fs.rmSync(dir, { recursive: true, force: true });
370
+ } catch {
371
+ // Best effort cleanup
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Main entry point for scan-skill command
377
+ */
378
+ async function scanSkill(url, options = {}) {
379
+ // 1. Parse URL
380
+ const repoInfo = parseRepoUrl(url);
381
+
382
+ // 2. Clone
383
+ if (!options.json) {
384
+ process.stderr.write(`⏳ Cloning ${repoInfo.displayUrl}...\n`);
385
+ }
386
+
387
+ const tmpDir = cloneRepo(repoInfo.cloneUrl);
388
+
389
+ try {
390
+ // Get git SHA
391
+ repoInfo.sha = getGitSha(tmpDir);
392
+
393
+ // 3. Enumerate files
394
+ const files = enumerateFiles(tmpDir);
395
+
396
+ if (files.length === 0) {
397
+ if (!options.json) {
398
+ console.log('⚠️ No scannable files found in repository');
399
+ }
400
+ process.exit(0);
401
+ }
402
+
403
+ // 4. Scan
404
+ let result;
405
+ if (options.offline) {
406
+ result = scanOffline(files, options);
407
+ } else {
408
+ try {
409
+ result = await scanApi(files, repoInfo, options);
410
+ } catch (err) {
411
+ if (!options.json) {
412
+ console.error(`⚠️ API unavailable (${err.message}), falling back to offline scan`);
413
+ }
414
+ result = scanOffline(files, options);
415
+ }
416
+ }
417
+
418
+ // 5. Output
419
+ if (options.json) {
420
+ console.log(JSON.stringify({
421
+ skill: { name: repoInfo.name, source: repoInfo.source, url: repoInfo.displayUrl, ref: repoInfo.sha },
422
+ ...result,
423
+ }, null, 2));
424
+ } else {
425
+ console.log(formatReport(result, repoInfo));
426
+ }
427
+
428
+ // 6. Exit code
429
+ if (options.strict && result.verdict !== 'SAFE') {
430
+ process.exit(1);
431
+ }
432
+
433
+ process.exit(result.verdict === 'DANGEROUS' ? 1 : 0);
434
+
435
+ } finally {
436
+ // 7. Cleanup
437
+ cleanupDir(tmpDir);
438
+ }
439
+ }
440
+
441
+ module.exports = {
442
+ scanSkill,
443
+ parseRepoUrl,
444
+ classifyFileRole,
445
+ enumerateFiles,
446
+ scanOffline,
447
+ formatReport,
448
+ };