clawhub-guard 1.0.0 → 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,10 +1,10 @@
1
1
  {
2
2
  "name": "clawhub-guard",
3
- "version": "1.0.0",
4
- "description": "Pre-install security scanner for ClawHub skills — scan before you install, never trust blindly.",
3
+ "version": "1.1.0",
4
+ "description": "Pre-install security scanner for ClawHub skills — scan, audit, watch, and block risky installs.",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
7
- "clawhub-guard": "./bin/clawhub-guard.js"
7
+ "clawhub-guard": "./src/cli.js"
8
8
  },
9
9
  "scripts": {
10
10
  "test": "node src/cli.js scan --help"
package/src/cli.js CHANGED
@@ -1,32 +1,46 @@
1
1
  #!/usr/bin/env node
2
- // src/cli.js — Main CLI for clawhub-guard
2
+ // src/cli.js — Main CLI for clawhub-guard v1.1.0
3
3
 
4
- const { scanLocal, getSkillInstallPath, isSkillInstalled } = require('./scan.js');
4
+ const {
5
+ scanLocal, scanUrl, auditAll, printAuditReport, saveReport,
6
+ getSkillInstallPath, isSkillInstalled
7
+ } = require('./scan.js');
5
8
  const { installSkill } = require('./install.js');
9
+ const { printScanReport } = require('./report.js');
10
+ const path = require('node:path');
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
6
13
 
7
14
  const args = process.argv.slice(2);
8
15
  const command = args[0];
9
16
 
10
17
  function usage() {
11
18
  console.log(`
12
- 🛡️ clawhub-guard — Pre-install security scanner for ClawHub skills
19
+ 🛡️ clawhub-guard v1.1.0 — Pre-install security scanner for ClawHub skills
13
20
 
14
- Usage:
15
- clawhub-guard install <skill-name> Scan + install a ClawHub skill
16
- clawhub-guard scan <skill-name> Scan an installed ClawHub skill
17
- clawhub-guard scan --local <path> Scan a local skill directory
18
- clawhub-guard --help Show this help
21
+ Commands:
22
+ install <name> Scan + install a ClawHub skill
23
+ scan <name> Scan an installed skill (fuzzy name match)
24
+ scan --local <path> Scan a local directory
25
+ scan --url <github> Scan a GitHub repo (clones to temp, scans, cleans)
26
+ audit Audit ALL installed skills with summary table
27
+ watch Watch skills dir and auto-scan new installs
28
+ history Show scan report history
19
29
 
20
30
  Options:
21
31
  --threshold <0-100> Minimum score to pass (default: 70)
32
+ --fail-under <score> CI mode: exit 1 if score below this
22
33
  --force Skip security scan and install anyway
23
34
  --json Output scan result as JSON
35
+ --no-log Skip saving to scan history
24
36
 
25
37
  Examples:
26
38
  clawhub-guard install summarize
27
- clawhub-guard scan --local ./my-skill/
28
- clawhub-guard install database-query --threshold 80
29
- clawhub-guard scan skill-vetter --json
39
+ clawhub-guard scan --url https://github.com/user/skill-repo
40
+ clawhub-guard audit
41
+ clawhub-guard audit --fail-under 80
42
+ clawhub-guard watch
43
+ clawhub-guard history
30
44
  `);
31
45
  }
32
46
 
@@ -40,18 +54,31 @@ const options = {
40
54
  threshold: 70,
41
55
  force: false,
42
56
  json: false,
57
+ local: false,
58
+ url: false,
59
+ log: true,
60
+ failUnder: null,
43
61
  };
44
62
  for (let i = 0; i < args.length; i++) {
45
63
  if (args[i] === '--threshold' && args[i + 1]) {
46
64
  options.threshold = parseInt(args[i + 1], 10);
47
65
  i++;
48
66
  }
67
+ if (args[i] === '--fail-under' && args[i + 1]) {
68
+ options.failUnder = parseInt(args[i + 1], 10);
69
+ options.threshold = options.failUnder;
70
+ i++;
71
+ }
49
72
  if (args[i] === '--force') options.force = true;
50
73
  if (args[i] === '--json') options.json = true;
51
74
  if (args[i] === '--local') options.local = true;
75
+ if (args[i] === '--url') options.url = true;
76
+ if (args[i] === '--no-log') options.log = false;
52
77
  }
53
78
 
54
79
  switch (command) {
80
+
81
+ // ─── INSTALL ─────────────────────────────────────
55
82
  case 'install': {
56
83
  const skillName = args[1];
57
84
  if (!skillName) {
@@ -59,72 +86,209 @@ switch (command) {
59
86
  process.exit(1);
60
87
  }
61
88
  const result = installSkill(skillName, options);
89
+ if (options.log && result.scanResult) {
90
+ saveReport(result.scanResult, `install ${skillName}`);
91
+ }
62
92
  process.exit(result.success ? 0 : 1);
63
93
  }
64
94
 
95
+ // ─── SCAN ────────────────────────────────────────
65
96
  case 'scan': {
66
97
  let target = args[1];
67
- if (!target && !options.local) {
68
- console.error('❌ Error: skill name or --local <path> required.');
69
- process.exit(1);
98
+
99
+ // --url mode
100
+ if (options.url) {
101
+ target = args.find((a, i) => args[i - 1] === '--url') || args[2] || target;
102
+ if (!target) {
103
+ console.error('❌ Error: URL required with --url flag.');
104
+ process.exit(1);
105
+ }
106
+ const result = scanUrl(target, options);
107
+ if (options.log) saveReport(result, `scan --url ${target}`);
108
+ if (options.json) {
109
+ console.log(JSON.stringify(result, null, 2));
110
+ } else {
111
+ printScanReport(result);
112
+ }
113
+ process.exit(result.passed ? 0 : 1);
70
114
  }
71
115
 
72
- // --local mode: scan a directory
116
+ // --local mode
73
117
  if (options.local) {
74
118
  target = args.find((a, i) => args[i - 1] === '--local') || args[2];
75
119
  if (!target) {
76
- // If --local was passed as the target itself (e.g., scan --local ./path)
77
- target = args[1];
120
+ console.error('❌ Error: path required with --local flag.');
121
+ process.exit(1);
78
122
  }
123
+ const result = scanLocal(target, options);
124
+ if (options.log) saveReport(result, `scan --local ${target}`);
125
+ if (options.json) {
126
+ console.log(JSON.stringify(result, null, 2));
127
+ } else {
128
+ printScanReport(result);
129
+ }
130
+ process.exit(result.passed ? 0 : 1);
79
131
  }
80
132
 
81
- const fs = require('node:fs');
82
- const path = require('node:path');
83
- const os = require('node:os');
133
+ // Skill name mode (default)
134
+ if (!target) {
135
+ console.error('❌ Error: skill name, --local <path>, or --url <url> required.');
136
+ process.exit(1);
137
+ }
84
138
 
85
139
  let scanTarget;
86
- if (options.local || target.includes('/') || target.includes('\\')) {
140
+ if (target.includes('http://') || target.includes('https://')) {
141
+ // Auto-detect URL
142
+ scanTarget = target;
143
+ const result = scanUrl(scanTarget, options);
144
+ if (options.log) saveReport(result, `scan --url ${target}`);
145
+ if (options.json) console.log(JSON.stringify(result, null, 2));
146
+ else printScanReport(result);
147
+ process.exit(result.passed ? 0 : 1);
148
+ }
149
+
150
+ if (target.includes('/') || target.includes('\\')) {
87
151
  scanTarget = target;
88
152
  } else {
89
153
  scanTarget = getSkillInstallPath(target);
90
154
  if (!isSkillInstalled(target)) {
91
- // Try fuzzy match
155
+ // Fuzzy match
92
156
  const workspace = process.env.OPENCLAW_WORKSPACE_DIR ||
93
157
  path.join(os.homedir(), '.openclaw', 'workspace');
94
158
  const skillsDir = path.join(workspace, 'skills');
95
159
  let installedList = [];
96
160
  if (fs.existsSync(skillsDir)) {
97
161
  installedList = fs.readdirSync(skillsDir, { withFileTypes: true })
98
- .filter(d => d.isDirectory())
99
- .map(d => d.name);
100
- // Normalize names (strip hyphens, underscores) for fuzzy matching
162
+ .filter(d => d.isDirectory()).map(d => d.name);
101
163
  const normalize = s => s.toLowerCase().replace(/[-_]/g, '');
102
164
  const normTarget = normalize(target);
103
165
  const match = installedList.find(e =>
104
166
  normalize(e).includes(normTarget) || normTarget.includes(normalize(e))
105
167
  );
106
- if (match) {
107
- scanTarget = getSkillInstallPath(match);
108
- }
168
+ if (match) scanTarget = getSkillInstallPath(match);
109
169
  }
110
170
  if (!scanTarget) {
111
171
  console.error(`❌ Skill '${target}' not installed.`);
112
- console.error(` Installed skills: ${installedList.join(', ') || 'none'}`);
172
+ console.error(` Installed: ${installedList.join(', ') || 'none'}`);
113
173
  process.exit(1);
114
174
  }
115
175
  }
116
176
  }
117
177
 
118
178
  const result = scanLocal(scanTarget, options);
179
+ if (options.log) saveReport(result, `scan ${target}`);
119
180
  if (options.json) {
120
181
  console.log(JSON.stringify(result, null, 2));
121
182
  } else {
122
- const { printScanReport } = require('./install.js');
123
183
  printScanReport(result);
124
184
  }
125
185
  process.exit(result.passed ? 0 : 1);
126
186
  }
127
187
 
188
+ // ─── AUDIT ───────────────────────────────────────
189
+ case 'audit': {
190
+ const result = auditAll(options);
191
+ if (options.json) {
192
+ console.log(JSON.stringify(result, null, 2));
193
+ } else {
194
+ printAuditReport(result, options);
195
+ }
196
+ if (options.log && result.summary) {
197
+ saveReport({ score: result.summary.avgScore, verdict: 'AUDIT', target: 'all',
198
+ findings: result.skills.flatMap(s => s.findings), summary: result.summary,
199
+ passed: result.summary.blocked === 0 }, 'audit');
200
+ }
201
+ const exitCode = (options.failUnder && result.summary.avgScore < options.failUnder) ? 1 : 0;
202
+ process.exit(exitCode);
203
+ }
204
+
205
+ // ─── WATCH ───────────────────────────────────────
206
+ case 'watch': {
207
+ console.log('👀 Watching skills directory for changes...\n');
208
+ console.log(' New skill installs will be auto-scanned.\n');
209
+ console.log(' Press Ctrl+C to stop.\n');
210
+
211
+ const workspace = process.env.OPENCLAW_WORKSPACE_DIR ||
212
+ path.join(os.homedir(), '.openclaw', 'workspace');
213
+ const skillsDir = path.join(workspace, 'skills');
214
+
215
+ let knownSkills = new Set();
216
+ if (fs.existsSync(skillsDir)) {
217
+ fs.readdirSync(skillsDir, { withFileTypes: true })
218
+ .filter(d => d.isDirectory())
219
+ .forEach(d => knownSkills.add(d.name));
220
+ }
221
+
222
+ const interval = setInterval(() => {
223
+ if (!fs.existsSync(skillsDir)) return;
224
+ const current = fs.readdirSync(skillsDir, { withFileTypes: true })
225
+ .filter(d => d.isDirectory())
226
+ .map(d => d.name);
227
+
228
+ for (const skill of current) {
229
+ if (!knownSkills.has(skill)) {
230
+ knownSkills.add(skill);
231
+ const skillPath = path.join(skillsDir, skill);
232
+ console.log(`\n🔔 New skill detected: ${skill}`);
233
+ console.log(`🔍 Auto-scanning...\n`);
234
+
235
+ const result = scanLocal(skillPath, options);
236
+ printScanReport(result);
237
+
238
+ if (options.log) saveReport(result, `watch:${skill}`);
239
+
240
+ if (!result.passed && result.verdict === 'BLOCK') {
241
+ console.log(`❌ AUTO-BLOCKED: ${skill} (${result.score}/100)`);
242
+ console.log(` Run: openclaw skills uninstall ${skill}\n`);
243
+ }
244
+ }
245
+ }
246
+ }, 3000); // Poll every 3 seconds
247
+
248
+ process.on('SIGINT', () => { clearInterval(interval); process.exit(0); });
249
+ process.on('SIGTERM', () => { clearInterval(interval); process.exit(0); });
250
+
251
+ // Keep alive
252
+ setInterval(() => {}, 60000);
253
+ break;
254
+ }
255
+
256
+ // ─── HISTORY ─────────────────────────────────────
257
+ case 'history': {
258
+ const logFile = path.join(os.homedir(), '.clawhub-guard', 'scan-history.jsonl');
259
+ if (!fs.existsSync(logFile)) {
260
+ console.log('📭 No scan history yet. Run some scans first!');
261
+ process.exit(0);
262
+ }
263
+
264
+ const lines = fs.readFileSync(logFile, 'utf-8').trim().split('\n').filter(Boolean);
265
+ const entries = lines.map(l => JSON.parse(l));
266
+
267
+ if (options.json) {
268
+ console.log(JSON.stringify(entries, null, 2));
269
+ process.exit(0);
270
+ }
271
+
272
+ console.log(`\n${'═'.repeat(70)}`);
273
+ console.log(` SCAN HISTORY — clawhub-guard (${entries.length} entries)`);
274
+ console.log(`${'═'.repeat(70)}`);
275
+ console.log(` ${'DATE'.padEnd(22)} ${'COMMAND'.padEnd(24)} ${'SCORE'.padEnd(8)} ${'VERDICT'.padEnd(8)} TARGET`);
276
+ console.log(` ${'─'.repeat(68)}`);
277
+
278
+ for (const e of entries.slice(-20)) {
279
+ const date = new Date(e.timestamp).toLocaleString('zh-TW', {
280
+ month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
281
+ });
282
+ const icon = e.verdict === 'PASS' ? '✅' : e.verdict === 'WARN' ? '⚠️' :
283
+ e.verdict === 'BLOCK' ? '❌' : '📊';
284
+ const target = (e.target || '').split('/').slice(-1)[0] || e.target || '';
285
+ console.log(` ${date.padEnd(22)} ${e.command.padEnd(24)} ${String(e.score).padEnd(8)} ${icon} ${String(e.verdict).padEnd(6)} ${target}`);
286
+ }
287
+ console.log(`${'═'.repeat(70)}\n`);
288
+ process.exit(0);
289
+ }
290
+
291
+ // ─── DEFAULT ─────────────────────────────────────
128
292
  default:
129
293
  console.error(`❌ Unknown command: ${command}`);
130
294
  usage();
package/src/install.js CHANGED
@@ -1,26 +1,22 @@
1
1
  // src/install.js — Install logic with pre-scan guard
2
2
 
3
3
  const { execSync } = require('node:child_process');
4
- const { scanLocal, isSkillInstalled } = require('./scan.js');
4
+ const path = require('node:path');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const { scanLocal } = require('./scan.js');
8
+ const { printScanReport } = require('./report.js');
5
9
 
6
10
  /**
7
11
  * Install a ClawHub skill with pre-scan security check
8
- * @param {string} skillName - Name of the ClawHub skill
9
- * @param {object} options
10
- * @param {number} options.threshold - Minimum security score (default 70)
11
- * @param {boolean} options.force - Skip scan and install anyway
12
- * @returns {object} Install result
13
12
  */
14
13
  function installSkill(skillName, options = {}) {
15
14
  const threshold = options.threshold ?? 70;
16
15
 
17
- // Step 0: Force mode — skip scan
18
16
  if (options.force) {
19
17
  return doInstall(skillName);
20
18
  }
21
19
 
22
- // Step 1: Can't pre-scan before download, so install first →
23
- // scan the installed copy, then warn
24
20
  console.log(`\n📦 Installing ${skillName} for pre-scan...\n`);
25
21
 
26
22
  let installResult = doInstall(skillName);
@@ -28,17 +24,13 @@ function installSkill(skillName, options = {}) {
28
24
  return installResult;
29
25
  }
30
26
 
31
- // Step 2: Scan the just-installed skill
32
27
  console.log(`\n🔍 Running security scan on ${skillName}...\n`);
33
28
 
34
29
  const scanResult = scanLocal(installResult.installPath, { threshold });
35
-
36
- // Step 3: Print report
37
30
  printScanReport(scanResult);
38
31
 
39
- // Step 4: If unsafe, offer to uninstall
40
32
  if (!scanResult.passed) {
41
- console.log(`\n⚠️ RISK THRESHOLD NOT MET (${scanResult.score}/100, need ${threshold})`);
33
+ console.log(`⚠️ RISK THRESHOLD NOT MET (${scanResult.score}/100, need ${threshold})`);
42
34
  console.log(` Verdict: ${scanResult.verdict}`);
43
35
 
44
36
  if (scanResult.verdict === 'BLOCK') {
@@ -49,31 +41,21 @@ function installSkill(skillName, options = {}) {
49
41
  stdio: 'pipe'
50
42
  });
51
43
  } catch {
52
- // Manual cleanup
53
- const fs = require('node:fs');
54
- const path = require('node:path');
55
- const skillPath = installResult.installPath;
56
- if (fs.existsSync(skillPath)) {
57
- fs.rmSync(skillPath, { recursive: true, force: true });
44
+ if (fs.existsSync(installResult.installPath)) {
45
+ fs.rmSync(installResult.installPath, { recursive: true, force: true });
58
46
  }
59
47
  }
60
- console.log(`💡 Tip: Review the findings above. Use --force to install anyway.\n`);
48
+ console.log(`💡 Tip: Review the findings. Use --force to install anyway.\n`);
61
49
  return { success: false, blocked: true, scanResult, installResult };
62
50
  }
63
51
 
64
- if (scanResult.verdict === 'WARN') {
65
- console.log(`⚠️ Installed with warnings — review the findings above.\n`);
66
- }
52
+ console.log(`⚠️ Installed with warnings — review the findings above.\n`);
67
53
  }
68
54
 
69
55
  return { success: true, blocked: false, scanResult, installResult };
70
56
  }
71
57
 
72
58
  function doInstall(skillName) {
73
- const path = require('node:path');
74
- const fs = require('node:fs');
75
- const os = require('node:os');
76
-
77
59
  try {
78
60
  const output = execSync(`openclaw.cmd skills install ${skillName}`, {
79
61
  encoding: 'utf-8',
@@ -82,8 +64,7 @@ function doInstall(skillName) {
82
64
  });
83
65
  console.log(output);
84
66
 
85
- // Determine install path
86
- const workspace = process.env.OPENCLAW_WORKSPACE_DIR ||
67
+ const workspace = process.env.OPENCLAW_WORKSPACE_DIR ||
87
68
  path.join(os.homedir(), '.openclaw', 'workspace');
88
69
  const installPath = path.join(workspace, 'skills', skillName);
89
70
 
@@ -95,39 +76,4 @@ function doInstall(skillName) {
95
76
  }
96
77
  }
97
78
 
98
- function printScanReport(result) {
99
- console.log(`\n${'═'.repeat(55)}`);
100
- console.log(` SECURITY SCAN REPORT — clawhub-guard`);
101
- console.log(`${'═'.repeat(55)}`);
102
- console.log(` Target: ${result.target || 'N/A'}`);
103
- console.log(` Score: ${result.score}/100`);
104
- console.log(` Threshold: ${result.threshold}/100`);
105
- console.log(` Verdict: ${result.passed ? '✅ PASS' : result.verdict === 'BLOCK' ? '❌ BLOCK' : '⚠️ WARN'}`);
106
- console.log(` Engines: ${result.availableEngines}/${result.totalEngines} available`);
107
- console.log(` Findings: ${(result.findings || []).length}`);
108
- console.log(`${'─'.repeat(55)}`);
109
-
110
- if (result.findings && result.findings.length > 0) {
111
- const bySeverity = {};
112
- for (const f of result.findings) {
113
- bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
114
- }
115
- for (const [sev, count] of Object.entries(bySeverity)) {
116
- const icon = sev === 'critical' ? '🔴' : sev === 'high' ? '🟠' : sev === 'medium' ? '🟡' : '⚪';
117
- console.log(` ${icon} ${sev}: ${count}`);
118
- }
119
- console.log(`${'─'.repeat(55)}`);
120
- for (const f of (result.findings || []).slice(0, 5)) {
121
- console.log(` • [${f.severity}] ${f.rule}: ${f.message}`);
122
- }
123
- if (result.findings.length > 5) {
124
- console.log(` • ... and ${result.findings.length - 5} more`);
125
- }
126
- } else {
127
- console.log(` ✅ No findings — clean!`);
128
- }
129
-
130
- console.log(`${'═'.repeat(55)}\n`);
131
- }
132
-
133
- module.exports = { installSkill, printScanReport };
79
+ module.exports = { installSkill, doInstall, printScanReport };
package/src/report.js ADDED
@@ -0,0 +1,54 @@
1
+ // src/report.js — Report formatting for clawhub-guard
2
+
3
+ /**
4
+ * Print formatted scan report for a single skill
5
+ */
6
+ function printScanReport(result) {
7
+ console.log(`\n${'═'.repeat(55)}`);
8
+ console.log(` SECURITY SCAN REPORT — clawhub-guard`);
9
+ console.log(`${'═'.repeat(55)}`);
10
+ console.log(` Target: ${result.target || result.sourceUrl || 'N/A'}`);
11
+ console.log(` Score: ${result.score}/100`);
12
+ console.log(` Threshold: ${result.threshold}/100`);
13
+
14
+ const verdictIcon = result.passed ? '✅ PASS' :
15
+ result.verdict === 'BLOCK' ? '❌ BLOCK' : '⚠️ WARN';
16
+ console.log(` Verdict: ${verdictIcon}`);
17
+ console.log(` Engines: ${result.availableEngines}/${result.totalEngines} available`);
18
+ console.log(` Findings: ${(result.findings || []).length}`);
19
+ console.log(`${'─'.repeat(55)}`);
20
+
21
+ if (result.findings && result.findings.length > 0) {
22
+ const bySeverity = {};
23
+ for (const f of result.findings) {
24
+ bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
25
+ }
26
+ for (const [sev, count] of Object.entries(bySeverity)) {
27
+ const icon = sev === 'critical' ? '🔴' : sev === 'high' ? '🟠' :
28
+ sev === 'medium' ? '🟡' : '⚪';
29
+ console.log(` ${icon} ${sev}: ${count}`);
30
+ }
31
+ console.log(`${'─'.repeat(55)}`);
32
+ for (const f of (result.findings || []).slice(0, 8)) {
33
+ console.log(` • [${f.severity}] ${f.rule}: ${f.message}`);
34
+ }
35
+ if (result.findings.length > 8) {
36
+ console.log(` • ... and ${result.findings.length - 8} more`);
37
+ }
38
+ } else {
39
+ console.log(` ✅ No findings — clean!`);
40
+ }
41
+
42
+ console.log(`${'═'.repeat(55)}\n`);
43
+ }
44
+
45
+ /**
46
+ * Print a section header
47
+ */
48
+ function header(text) {
49
+ console.log(`\n${'─'.repeat(50)}`);
50
+ console.log(` ${text}`);
51
+ console.log(`${'─'.repeat(50)}\n`);
52
+ }
53
+
54
+ module.exports = { printScanReport, header };
package/src/scan.js CHANGED
@@ -7,14 +7,10 @@ const os = require('node:os');
7
7
 
8
8
  /**
9
9
  * Scan a local directory for security issues using agent-shield
10
- * @param {string} targetPath - Path to the skill directory
11
- * @param {object} options
12
- * @param {number} options.threshold - Minimum score to pass (0-100)
13
- * @returns {object} Scan result with score, findings, and verdict
14
10
  */
15
11
  function scanLocal(targetPath, options = {}) {
16
12
  const threshold = options.threshold ?? 70;
17
-
13
+
18
14
  if (!fs.existsSync(targetPath)) {
19
15
  return {
20
16
  success: false,
@@ -36,20 +32,16 @@ function scanLocal(targetPath, options = {}) {
36
32
  }
37
33
  );
38
34
  } catch (err) {
39
- // agent-shield exits non-zero if findings exist — still parse output
40
35
  stdout = err.stdout || err.stderr || '';
41
36
  }
42
37
 
43
- // Try to extract JSON from agent-shield output (it may have noise)
44
38
  let result;
45
39
  try {
46
40
  const jsonStart = stdout.indexOf('{');
47
41
  if (jsonStart >= 0) {
48
42
  result = JSON.parse(stdout.slice(jsonStart));
49
43
  }
50
- } catch {
51
- // Fallback: parse manually
52
- }
44
+ } catch { /* fall through */ }
53
45
 
54
46
  if (!result) {
55
47
  return {
@@ -61,7 +53,6 @@ function scanLocal(targetPath, options = {}) {
61
53
  };
62
54
  }
63
55
 
64
- // Calculate score based on findings
65
56
  const allFindings = result.allFindings || [];
66
57
  const severityScores = { critical: 40, high: 25, medium: 10, low: 3 };
67
58
  let penalty = 0;
@@ -85,20 +76,172 @@ function scanLocal(targetPath, options = {}) {
85
76
  }
86
77
 
87
78
  /**
88
- * Determine ClawHub skill install directory
79
+ * Scan a GitHub URL by cloning to temp and scanning
89
80
  */
90
- function getSkillInstallPath(skillName) {
91
- const workspace = process.env.OPENCLAW_WORKSPACE_DIR ||
81
+ function scanUrl(url, options = {}) {
82
+ const tempDir = path.join(os.tmpdir(), `clawhub-guard-${Date.now()}`);
83
+
84
+ try {
85
+ fs.mkdirSync(tempDir, { recursive: true });
86
+
87
+ console.log(`\n📥 Cloning ${url}...\n`);
88
+ execSync(`git clone --depth 1 "${url}" "${tempDir}"`, {
89
+ encoding: 'utf-8',
90
+ timeout: 30000,
91
+ stdio: 'pipe',
92
+ });
93
+
94
+ console.log(`🔍 Scanning...\n`);
95
+ const result = scanLocal(tempDir, options);
96
+ result.sourceUrl = url;
97
+ return result;
98
+ } catch (err) {
99
+ return {
100
+ success: false,
101
+ error: `Failed to clone/scan URL: ${err.stderr || err.message}`,
102
+ score: 0,
103
+ verdict: 'ERROR',
104
+ sourceUrl: url,
105
+ };
106
+ } finally {
107
+ // Cleanup temp dir
108
+ try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Audit all installed skills - scan everything and produce summary
114
+ */
115
+ function auditAll(options = {}) {
116
+ const workspace = process.env.OPENCLAW_WORKSPACE_DIR ||
92
117
  path.join(os.homedir(), '.openclaw', 'workspace');
93
- return path.join(workspace, 'skills', skillName);
118
+ const skillsDir = path.join(workspace, 'skills');
119
+
120
+ if (!fs.existsSync(skillsDir)) {
121
+ console.log('📦 No skills installed yet.');
122
+ return { skills: [], summary: { total: 0, passed: 0, warned: 0, blocked: 0 } };
123
+ }
124
+
125
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true })
126
+ .filter(d => d.isDirectory())
127
+ .map(d => d.name);
128
+
129
+ console.log(`\n🔍 Auditing ${entries.length} installed skill(s)...\n`);
130
+
131
+ const results = [];
132
+ for (const skill of entries) {
133
+ const skillPath = path.join(skillsDir, skill);
134
+ process.stdout.write(` Scanning ${skill}... `);
135
+ const result = scanLocal(skillPath, options);
136
+
137
+ const icon = result.verdict === 'PASS' ? '✅' :
138
+ result.verdict === 'WARN' ? '⚠️' : '❌';
139
+ console.log(`${icon} ${result.score}/100`);
140
+
141
+ results.push({
142
+ name: skill,
143
+ path: skillPath,
144
+ score: result.score,
145
+ verdict: result.verdict,
146
+ findingsCount: (result.findings || []).length,
147
+ findings: result.findings || [],
148
+ });
149
+ }
150
+
151
+ const summary = {
152
+ total: results.length,
153
+ passed: results.filter(r => r.verdict === 'PASS').length,
154
+ warned: results.filter(r => r.verdict === 'WARN').length,
155
+ blocked: results.filter(r => r.verdict === 'BLOCK').length,
156
+ avgScore: results.length > 0
157
+ ? Math.round(results.reduce((s, r) => s + r.score, 0) / results.length)
158
+ : 0,
159
+ };
160
+
161
+ return { skills: results, summary, timestamp: new Date().toISOString() };
162
+ }
163
+
164
+ /**
165
+ * Print audit summary as a formatted table
166
+ */
167
+ function printAuditReport(auditResult, options = {}) {
168
+ const { skills, summary } = auditResult;
169
+
170
+ console.log(`\n${'═'.repeat(65)}`);
171
+ console.log(` SKILL AUDIT SUMMARY — clawhub-guard`);
172
+ console.log(`${'═'.repeat(65)}`);
173
+
174
+ if (skills.length === 0) {
175
+ console.log(` No skills installed.\n`);
176
+ return;
177
+ }
178
+
179
+ // Table header
180
+ console.log(` ${'SKILL'.padEnd(28)} ${'SCORE'.padEnd(8)} ${'VERDICT'.padEnd(8)} FINDINGS`);
181
+ console.log(` ${'─'.repeat(60)}`);
182
+
183
+ for (const r of skills) {
184
+ const icon = r.verdict === 'PASS' ? '✅' : r.verdict === 'WARN' ? '⚠️' : '❌';
185
+ console.log(` ${r.name.padEnd(28)} ${String(r.score).padEnd(8)} ${icon} ${String(r.verdict).padEnd(6)} ${r.findingsCount}`);
186
+ }
187
+
188
+ console.log(` ${'─'.repeat(60)}`);
189
+ console.log(` TOTAL: ${summary.total} | ✅ ${summary.passed} passed | ⚠️ ${summary.warned} warned | ❌ ${summary.blocked} blocked`);
190
+ console.log(` Average score: ${summary.avgScore}/100`);
191
+ console.log(`${'═'.repeat(65)}\n`);
192
+
193
+ // Highlight risky skills
194
+ const risky = skills.filter(r => r.verdict !== 'PASS');
195
+ if (risky.length > 0) {
196
+ console.log(`⚠️ Skills needing attention:\n`);
197
+ for (const r of risky) {
198
+ console.log(` ${r.name} (${r.score}/100):`);
199
+ for (const f of r.findings.slice(0, 3)) {
200
+ console.log(` - [${f.severity}] ${f.rule}: ${f.message}`);
201
+ }
202
+ }
203
+ console.log('');
204
+ }
94
205
  }
95
206
 
96
207
  /**
97
- * Check if a skill is already installed locally
208
+ * Save scan report to log file
98
209
  */
210
+ function saveReport(result, command) {
211
+ const stateDir = path.join(os.homedir(), '.clawhub-guard');
212
+ fs.mkdirSync(stateDir, { recursive: true });
213
+
214
+ const logFile = path.join(stateDir, 'scan-history.jsonl');
215
+ const entry = {
216
+ timestamp: new Date().toISOString(),
217
+ command,
218
+ score: result.score,
219
+ verdict: result.verdict,
220
+ target: result.target || result.sourceUrl || 'unknown',
221
+ findingsCount: (result.findings || []).length,
222
+ summary: result.summary || null,
223
+ };
224
+
225
+ fs.appendFileSync(logFile, JSON.stringify(entry) + '\n', 'utf-8');
226
+ return logFile;
227
+ }
228
+
229
+ function getSkillInstallPath(skillName) {
230
+ const workspace = process.env.OPENCLAW_WORKSPACE_DIR ||
231
+ path.join(os.homedir(), '.openclaw', 'workspace');
232
+ return path.join(workspace, 'skills', skillName);
233
+ }
234
+
99
235
  function isSkillInstalled(skillName) {
100
- const installPath = getSkillInstallPath(skillName);
101
- return fs.existsSync(installPath);
236
+ return fs.existsSync(getSkillInstallPath(skillName));
102
237
  }
103
238
 
104
- module.exports = { scanLocal, getSkillInstallPath, isSkillInstalled };
239
+ module.exports = {
240
+ scanLocal,
241
+ scanUrl,
242
+ auditAll,
243
+ printAuditReport,
244
+ saveReport,
245
+ getSkillInstallPath,
246
+ isSkillInstalled
247
+ };