ai-warden 1.0.4 → 1.1.1

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
@@ -253,34 +253,115 @@ console.log(detector.removePII(text)); // Quick remove
253
253
 
254
254
  ## 🎮 CLI Usage
255
255
 
256
- AI-Warden includes a command-line tool for file and directory scanning.
256
+ AI-Warden includes a command-line tool for file, directory, and skill repo scanning.
257
257
 
258
258
  ```bash
259
259
  # Install globally
260
260
  npm install -g ai-warden
261
+ ```
261
262
 
262
- # Scan a file
263
- aiwarden scan file.txt
263
+ ### 🆓 Offline Commands (Free — no API key needed)
264
264
 
265
- # Scan a directory
266
- aiwarden scan ./src
265
+ These run entirely on your machine. No data leaves your system.
267
266
 
268
- # Scan with options
267
+ ```bash
268
+ # Scan local files and directories
269
+ aiwarden scan file.txt
270
+ aiwarden scan ./src
269
271
  aiwarden scan ./src --mode strict --verbose
270
-
271
- # Interactive whitelist mode
272
272
  aiwarden scan ./src --interactive
273
273
 
274
- # Use custom ignore file
275
- aiwarden scan ./src --ignore-file .aiwardenignore.ci
274
+ # Scan remote skill repos (local pattern matching)
275
+ aiwarden scan-skill https://github.com/user/skill --offline
276
+ aiwarden scan-skill https://github.com/user/skill --offline --json
277
+ aiwarden scan-skill https://github.com/user/skill --offline --strict
278
+ ```
279
+
280
+ ### 🔑 API Key Setup (required for API-powered features)
281
+
282
+ ```bash
283
+ # Login (opens browser, saves key locally)
284
+ aiwarden login
285
+
286
+ # Check your current key and tier
287
+ aiwarden whoami
288
+
289
+ # Or set key manually
290
+ export AI_WARDEN_API_KEY=sk_live_xxx
291
+ ```
292
+
293
+ Get your API key: [ai-warden.io/signup](https://ai-warden.io/signup) (free tier: 5,000 validations/month)
294
+
295
+ ### 🚀 API-Powered Commands (requires API key)
296
+
297
+ Full Judge Mars ML analysis — near-zero false positives, deeper detection.
298
+
299
+ ```bash
300
+ # Validate text input
301
+ aiwarden validate "User input text"
302
+ aiwarden validate "Text to check" --json
303
+
304
+ # Scan skill repos with ML engine
305
+ aiwarden scan-skill https://github.com/user/skill
306
+ aiwarden scan-skill https://github.com/user/skill --json --strict
276
307
  ```
277
308
 
278
- **CLI Options:**
309
+ ### CLI Options
310
+
311
+ **Scan options:**
279
312
  - `--mode <strict|balanced|permissive>` - Detection sensitivity
280
313
  - `--verbose` - Detailed output
281
314
  - `--interactive` - Interactive whitelist mode
282
315
  - `--ignore-file <path>` - Custom .aiwardenignore file
283
316
 
317
+ **Scan-skill options:**
318
+ - `--offline` - Use local scanner only (free, no API key)
319
+ - `--json` - Machine-readable JSON output
320
+ - `--strict` - Exit code 1 unless verdict is SAFE
321
+
322
+ ---
323
+
324
+ ## 🔍 Skill Scanner — Example Output
325
+
326
+ ```
327
+ 🔍 AI-Warden Skill Scan
328
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
329
+ Skill: smart-web-search
330
+ Source: github.com/davidme6/smart-web-search
331
+ Files: 4 scanned
332
+ Mode: offline
333
+
334
+ LICENSE ✅ Safe (0.00)
335
+ README.md ❌ CRITICAL (1.00)
336
+ ├─ P102: Data Forwarding Instructions [CRITICAL] — "Email**: smart-web-search@feedback.com"
337
+ └─ H003: Excessive External URLs [LOW] — "Found 11 external URLs"
338
+ SKILL.md ✅ Safe (0.19)
339
+ └─ H003: Excessive External URLs [LOW] — "Found 20 external URLs"
340
+ _meta.json ✅ Safe (0.00)
341
+
342
+ Verdict: ❌ DANGEROUS
343
+ Trust Score: 0/100
344
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
345
+ ```
346
+
347
+ ### Verdicts
348
+
349
+ | Verdict | Trust Score | Meaning |
350
+ |---------|-------------|---------|
351
+ | ✅ SAFE | 70-100 | No threats detected |
352
+ | ⚠️ WARNING | 25-69 | Suspicious patterns found, review recommended |
353
+ | ❌ DANGEROUS | 0-24 | Active threats detected, do not install |
354
+
355
+ ### Offline vs API Mode
356
+
357
+ | | Offline (free) | API (metered) |
358
+ |---|---|---|
359
+ | Detection | Regex patterns | Judge Mars ML + patterns |
360
+ | Speed | Instant | ~150ms/file |
361
+ | False positives | Higher | Lower |
362
+ | Zero-day threats | ❌ | ✅ |
363
+ | Requires API key | No | Yes |
364
+
284
365
  ---
285
366
 
286
367
  ## 🔧 Configuration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-warden",
3
- "version": "1.0.4",
3
+ "version": "1.1.1",
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
  };
@@ -181,11 +180,33 @@ class AIWardenClient {
181
180
  if (res.statusCode === 200) {
182
181
  try {
183
182
  const parsed = JSON.parse(data);
184
- // Extract cleanText if present
183
+ // Normalize API response to match scan() format
184
+ const passed = parsed.safe === true || parsed.decision === 'ALLOW';
185
+ const confidence = parsed.confidence || 0;
186
+ const riskScore = passed ? 0 : Math.round(confidence * 1000);
187
+
188
+ let riskLevel = 'NONE';
189
+ if (!passed) {
190
+ if (confidence >= 0.9) riskLevel = 'CRITICAL';
191
+ else if (confidence >= 0.7) riskLevel = 'HIGH';
192
+ else if (confidence >= 0.4) riskLevel = 'MEDIUM';
193
+ else riskLevel = 'LOW';
194
+ }
195
+
185
196
  const result = {
186
- ...parsed,
197
+ passed,
198
+ riskScore,
199
+ riskLevel,
200
+ findings: passed ? [] : [{ name: `${parsed.layer || 'cascade'}`, severity: riskLevel }],
187
201
  cleanText: parsed.cleanText || content,
188
- appliedSettings: parsed.appliedSettings || this.settings
202
+ appliedSettings: parsed.appliedSettings || this.settings,
203
+ stats: {
204
+ scanTimeMs: parsed.latency_ms || 0,
205
+ layer: parsed.layer,
206
+ confidence
207
+ },
208
+ // Preserve raw API fields for advanced use
209
+ raw: { safe: parsed.safe, decision: parsed.decision, confidence: parsed.confidence, layer: parsed.layer }
189
210
  };
190
211
  resolve(result);
191
212
  } catch (error) {
@@ -0,0 +1,483 @@
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
+ // Sandwich scan thresholds (match index.js _sandwichScan)
24
+ const HEAD_WORDS = 2000;
25
+ const TAIL_WORDS = 2000;
26
+ const SANDWICH_THRESHOLD = HEAD_WORDS + TAIL_WORDS; // 4000 words
27
+
28
+ // Binary file extensions to skip
29
+ const BINARY_EXTENSIONS = new Set([
30
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.svg',
31
+ '.woff', '.woff2', '.ttf', '.eot', '.otf',
32
+ '.zip', '.tar', '.gz', '.bz2', '.7z', '.rar',
33
+ '.pdf', '.doc', '.docx', '.xls', '.xlsx',
34
+ '.exe', '.dll', '.so', '.dylib', '.o',
35
+ '.mp3', '.mp4', '.avi', '.mov', '.wav',
36
+ '.bin', '.dat', '.db', '.sqlite',
37
+ '.pyc', '.class', '.wasm',
38
+ ]);
39
+
40
+ /**
41
+ * Apply sandwich truncation for large file content.
42
+ * Keeps HEAD_WORDS from the start and TAIL_WORDS from the end.
43
+ * Returns { content, sandwich } where sandwich is metadata or null.
44
+ */
45
+ function applySandwich(content) {
46
+ const words = content.split(/\s+/);
47
+ if (words.length <= SANDWICH_THRESHOLD) {
48
+ return { content, sandwich: null };
49
+ }
50
+
51
+ const head = words.slice(0, HEAD_WORDS).join(' ');
52
+ const tail = words.slice(-TAIL_WORDS).join(' ');
53
+ const truncated = head + '\n\n[...middle content omitted for performance...]\n\n' + tail;
54
+
55
+ return {
56
+ content: truncated,
57
+ sandwich: {
58
+ enabled: true,
59
+ originalWords: words.length,
60
+ scannedWords: HEAD_WORDS + TAIL_WORDS,
61
+ },
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Parse a GitHub URL into { source, owner, repo, name, displayUrl }
67
+ */
68
+ function parseRepoUrl(url) {
69
+ // Remove trailing slash and .git
70
+ url = url.replace(/\/+$/, '').replace(/\.git$/, '');
71
+
72
+ const match = url.match(/(?:https?:\/\/)?(?:www\.)?(github\.com|clawhub\.com)\/([^/]+)\/([^/]+)/);
73
+ if (!match) {
74
+ throw new Error(`Invalid repository URL: ${url}\nExpected: https://github.com/<owner>/<repo>`);
75
+ }
76
+
77
+ return {
78
+ source: match[1].replace('.com', ''),
79
+ owner: match[2],
80
+ repo: match[3],
81
+ name: match[3],
82
+ displayUrl: `${match[1]}/${match[2]}/${match[3]}`,
83
+ cloneUrl: `https://${match[1]}/${match[2]}/${match[3]}.git`,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Clone repo to temp directory
89
+ */
90
+ function cloneRepo(cloneUrl) {
91
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-warden-scan-'));
92
+
93
+ try {
94
+ execSync(`git clone --depth 1 ${cloneUrl} ${tmpDir}`, {
95
+ stdio: 'pipe',
96
+ timeout: 60000, // 60s timeout
97
+ });
98
+ } catch (err) {
99
+ // Clean up on failure
100
+ cleanupDir(tmpDir);
101
+ const msg = err.stderr ? err.stderr.toString().trim() : err.message;
102
+ throw new Error(`Failed to clone repository: ${msg}`);
103
+ }
104
+
105
+ return tmpDir;
106
+ }
107
+
108
+ /**
109
+ * Get git SHA of cloned repo
110
+ */
111
+ function getGitSha(repoDir) {
112
+ try {
113
+ return execSync('git rev-parse HEAD', { cwd: repoDir, stdio: 'pipe' })
114
+ .toString()
115
+ .trim();
116
+ } catch {
117
+ return 'unknown';
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Classify a file by its path/name into a role
123
+ */
124
+ function classifyFileRole(relativePath) {
125
+ const basename = path.basename(relativePath);
126
+ const dir = path.dirname(relativePath);
127
+ const ext = path.extname(relativePath).toLowerCase();
128
+
129
+ if (basename === 'SKILL.md') return 'skill-definition';
130
+ if (dir.startsWith('scripts') && (ext === '.sh' || ext === '.py')) return 'install-script';
131
+ if (dir.startsWith('references')) return 'reference-doc';
132
+ if (['.yaml', '.yml', '.json', '.toml'].includes(ext)) return 'config';
133
+ if (ext === '.md') return 'documentation';
134
+ return 'unknown';
135
+ }
136
+
137
+ /**
138
+ * Enumerate all text files in a directory (skip binaries, .git, large files)
139
+ */
140
+ function enumerateFiles(dir, baseDir = dir) {
141
+ const results = [];
142
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
143
+
144
+ for (const entry of entries) {
145
+ const fullPath = path.join(dir, entry.name);
146
+ const relativePath = path.relative(baseDir, fullPath);
147
+
148
+ // Skip hidden dirs and node_modules
149
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
150
+
151
+ if (entry.isDirectory()) {
152
+ results.push(...enumerateFiles(fullPath, baseDir));
153
+ } else if (entry.isFile()) {
154
+ const ext = path.extname(entry.name).toLowerCase();
155
+ if (BINARY_EXTENSIONS.has(ext)) continue;
156
+
157
+ const stat = fs.statSync(fullPath);
158
+ if (stat.size > MAX_FILE_SIZE) continue;
159
+ if (stat.size === 0) continue;
160
+
161
+ // Try reading as text — skip if binary
162
+ try {
163
+ const content = fs.readFileSync(fullPath, 'utf-8');
164
+ // Quick binary check: if it has null bytes, skip
165
+ if (content.includes('\0')) continue;
166
+
167
+ results.push({
168
+ path: relativePath,
169
+ role: classifyFileRole(relativePath),
170
+ content,
171
+ });
172
+ } catch {
173
+ // Can't read as text, skip
174
+ }
175
+ }
176
+ }
177
+
178
+ return results;
179
+ }
180
+
181
+ /**
182
+ * Run offline scan on all files using local scanner
183
+ */
184
+ function scanOffline(files, options = {}) {
185
+ const startTime = Date.now();
186
+ const results = [];
187
+ let worstScore = 0;
188
+ let anyThreats = false;
189
+
190
+ for (const file of files) {
191
+ const scanOpts = {
192
+ mode: options.mode || 'balanced',
193
+ context: file.role === 'skill-definition' ? 'skill' : 'user',
194
+ };
195
+
196
+ const result = scanner.scan(file.content, scanOpts);
197
+ const normalizedScore = Math.min(result.riskScore / (scanOpts.mode === 'strict' ? 75 : 150), 1.0);
198
+
199
+ results.push({
200
+ path: file.path,
201
+ role: file.role,
202
+ safe: result.passed,
203
+ score: parseFloat(normalizedScore.toFixed(2)),
204
+ riskLevel: result.riskLevel,
205
+ findings: result.findings,
206
+ findingsCount: result.findings.length,
207
+ });
208
+
209
+ if (normalizedScore > worstScore) worstScore = normalizedScore;
210
+ if (!result.passed) anyThreats = true;
211
+ }
212
+
213
+ const scanTime = ((Date.now() - startTime) / 1000).toFixed(1);
214
+ const trustScore = Math.max(0, Math.round((1 - worstScore) * 100));
215
+
216
+ let verdict;
217
+ if (!anyThreats && worstScore < 0.3) verdict = 'SAFE';
218
+ else if (anyThreats) verdict = 'DANGEROUS';
219
+ else verdict = 'WARNING';
220
+
221
+ return {
222
+ files: results,
223
+ verdict,
224
+ trustScore,
225
+ scanTime,
226
+ mode: 'offline',
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Send files to API for scanning (batched)
232
+ */
233
+ async function scanApi(files, repoInfo, options = {}) {
234
+ const apiKey = options.apiKey || process.env.AI_WARDEN_API_KEY || config.getApiKey();
235
+ if (!apiKey) {
236
+ throw new Error('API key required for online scan. Use --offline for free local scanning, or run: aiwarden login');
237
+ }
238
+
239
+ const apiUrl = options.apiUrl || 'https://api.ai-warden.io';
240
+ const startTime = Date.now();
241
+
242
+ // Chunk files into batches
243
+ const batches = [];
244
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
245
+ batches.push(files.slice(i, i + BATCH_SIZE));
246
+ }
247
+
248
+ const allResults = [];
249
+
250
+ for (const batch of batches) {
251
+ const payload = {
252
+ skill: {
253
+ source: repoInfo.source,
254
+ url: repoInfo.displayUrl,
255
+ ref: repoInfo.sha,
256
+ name: repoInfo.name,
257
+ },
258
+ files: batch.map(f => {
259
+ const { content, sandwich } = applySandwich(f.content);
260
+ const entry = {
261
+ path: f.path,
262
+ role: f.role,
263
+ content,
264
+ };
265
+ if (sandwich) entry.sandwich = sandwich;
266
+ return entry;
267
+ }),
268
+ };
269
+
270
+ const result = await postJson(`${apiUrl}/v1/scan-skill`, payload, apiKey);
271
+ if (result.files) {
272
+ allResults.push(...result.files);
273
+ }
274
+ }
275
+
276
+ const scanTime = ((Date.now() - startTime) / 1000).toFixed(1);
277
+
278
+ // Aggregate verdict from all batches
279
+ let worstScore = 0;
280
+ let anyDangerous = false;
281
+ let anyWarning = false;
282
+
283
+ for (const r of allResults) {
284
+ if (r.score > worstScore) worstScore = r.score;
285
+ if (r.riskLevel === 'CRITICAL' || r.riskLevel === 'HIGH') anyDangerous = true;
286
+ if (r.riskLevel === 'MEDIUM') anyWarning = true;
287
+ }
288
+
289
+ const trustScore = Math.max(0, Math.round((1 - worstScore) * 100));
290
+ let verdict;
291
+ if (anyDangerous) verdict = 'DANGEROUS';
292
+ else if (anyWarning) verdict = 'WARNING';
293
+ else verdict = 'SAFE';
294
+
295
+ return {
296
+ files: allResults,
297
+ verdict,
298
+ trustScore,
299
+ scanTime,
300
+ mode: 'api',
301
+ };
302
+ }
303
+
304
+ /**
305
+ * POST JSON to URL (using native http/https)
306
+ */
307
+ function postJson(url, payload, apiKey) {
308
+ return new Promise((resolve, reject) => {
309
+ const urlObj = new URL(url);
310
+ const protocol = urlObj.protocol === 'https:' ? https : http;
311
+ const body = JSON.stringify(payload);
312
+
313
+ const opts = {
314
+ hostname: urlObj.hostname,
315
+ path: urlObj.pathname,
316
+ method: 'POST',
317
+ headers: {
318
+ 'X-API-Key': apiKey,
319
+ 'Content-Type': 'application/json',
320
+ 'Content-Length': Buffer.byteLength(body),
321
+ 'User-Agent': 'ai-warden-cli/1.0.4',
322
+ },
323
+ timeout: 30000,
324
+ };
325
+ if (urlObj.port) opts.port = urlObj.port;
326
+
327
+ const req = protocol.request(opts, (res) => {
328
+ let data = '';
329
+ res.on('data', chunk => data += chunk);
330
+ res.on('end', () => {
331
+ if (res.statusCode === 200) {
332
+ try { resolve(JSON.parse(data)); }
333
+ catch (e) { reject(new Error(`Invalid API response: ${e.message}`)); }
334
+ } else if (res.statusCode === 401) {
335
+ reject(new Error('Invalid API key'));
336
+ } else {
337
+ reject(new Error(`API error ${res.statusCode}: ${data}`));
338
+ }
339
+ });
340
+ });
341
+
342
+ req.on('error', e => reject(new Error(`Network error: ${e.message}`)));
343
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
344
+ req.write(body);
345
+ req.end();
346
+ });
347
+ }
348
+
349
+ /**
350
+ * Format human-readable report
351
+ */
352
+ function formatReport(scanResult, repoInfo) {
353
+ const lines = [];
354
+ const bar = '━'.repeat(45);
355
+
356
+ lines.push('');
357
+ lines.push('🔍 AI-Warden Skill Scan');
358
+ lines.push(bar);
359
+
360
+ lines.push(` Skill: ${repoInfo.name}`);
361
+ lines.push(` Source: ${repoInfo.displayUrl}`);
362
+ lines.push(` Files: ${scanResult.files.length} scanned`);
363
+ if (scanResult.mode === 'offline') lines.push(` Mode: offline`);
364
+ lines.push('');
365
+
366
+ for (const file of scanResult.files) {
367
+ const icon = file.safe !== false ? '✅' : '❌';
368
+ const label = file.safe !== false ? 'Safe' : file.riskLevel || 'Threat';
369
+ const score = `(${file.score.toFixed(2)})`;
370
+ const name = file.path.padEnd(25);
371
+ lines.push(` ${name}${icon} ${label.padEnd(10)} ${score}`);
372
+
373
+ // Show findings detail for flagged files
374
+ if (file.findings && file.findings.length > 0) {
375
+ for (let i = 0; i < file.findings.length; i++) {
376
+ const f = file.findings[i];
377
+ const isLast = i === file.findings.length - 1;
378
+ const branch = isLast ? '└─' : '├─';
379
+ const severity = f.severity || 'UNKNOWN';
380
+ const detail = f.match || f.detail || '';
381
+ const truncated = detail.length > 60 ? detail.substring(0, 57) + '...' : detail;
382
+ lines.push(` ${branch} ${f.id}: ${f.name} [${severity}]${truncated ? ' — "' + truncated + '"' : ''}`);
383
+ }
384
+ }
385
+ }
386
+
387
+ lines.push('');
388
+
389
+ const verdictIcon = scanResult.verdict === 'SAFE' ? '✅' : scanResult.verdict === 'WARNING' ? '⚠️' : '❌';
390
+ lines.push(` Verdict: ${verdictIcon} ${scanResult.verdict}`);
391
+ lines.push(` Trust Score: ${scanResult.trustScore}/100`);
392
+ lines.push(` Scan Time: ${scanResult.scanTime}s`);
393
+ lines.push(bar);
394
+ lines.push('');
395
+
396
+ return lines.join('\n');
397
+ }
398
+
399
+ /**
400
+ * Clean up temp directory
401
+ */
402
+ function cleanupDir(dir) {
403
+ try {
404
+ fs.rmSync(dir, { recursive: true, force: true });
405
+ } catch {
406
+ // Best effort cleanup
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Main entry point for scan-skill command
412
+ */
413
+ async function scanSkill(url, options = {}) {
414
+ // 1. Parse URL
415
+ const repoInfo = parseRepoUrl(url);
416
+
417
+ // 2. Clone
418
+ if (!options.json) {
419
+ process.stderr.write(`⏳ Cloning ${repoInfo.displayUrl}...\n`);
420
+ }
421
+
422
+ const tmpDir = cloneRepo(repoInfo.cloneUrl);
423
+
424
+ try {
425
+ // Get git SHA
426
+ repoInfo.sha = getGitSha(tmpDir);
427
+
428
+ // 3. Enumerate files
429
+ const files = enumerateFiles(tmpDir);
430
+
431
+ if (files.length === 0) {
432
+ if (!options.json) {
433
+ console.log('⚠️ No scannable files found in repository');
434
+ }
435
+ process.exit(0);
436
+ }
437
+
438
+ // 4. Scan
439
+ let result;
440
+ if (options.offline) {
441
+ result = scanOffline(files, options);
442
+ } else {
443
+ try {
444
+ result = await scanApi(files, repoInfo, options);
445
+ } catch (err) {
446
+ if (!options.json) {
447
+ console.error(`⚠️ API unavailable (${err.message}), falling back to offline scan`);
448
+ }
449
+ result = scanOffline(files, options);
450
+ }
451
+ }
452
+
453
+ // 5. Output
454
+ if (options.json) {
455
+ console.log(JSON.stringify({
456
+ skill: { name: repoInfo.name, source: repoInfo.source, url: repoInfo.displayUrl, ref: repoInfo.sha },
457
+ ...result,
458
+ }, null, 2));
459
+ } else {
460
+ console.log(formatReport(result, repoInfo));
461
+ }
462
+
463
+ // 6. Exit code
464
+ if (options.strict && result.verdict !== 'SAFE') {
465
+ process.exit(1);
466
+ }
467
+
468
+ process.exit(result.verdict === 'DANGEROUS' ? 1 : 0);
469
+
470
+ } finally {
471
+ // 7. Cleanup
472
+ cleanupDir(tmpDir);
473
+ }
474
+ }
475
+
476
+ module.exports = {
477
+ scanSkill,
478
+ parseRepoUrl,
479
+ classifyFileRole,
480
+ enumerateFiles,
481
+ scanOffline,
482
+ formatReport,
483
+ };