clawarmor 1.2.0 → 2.0.0-alpha.3

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/lib/harden.js ADDED
@@ -0,0 +1,312 @@
1
+ // clawarmor harden — Interactive security hardening wizard.
2
+ // Modes:
3
+ // default: show each fix, prompt y/N before applying
4
+ // --dry-run: show what WOULD be fixed, no writes
5
+ // --auto: apply all safe fixes without confirmation (CI mode)
6
+
7
+ import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { homedir } from 'os';
10
+ import { execSync, spawnSync } from 'child_process';
11
+ import { createInterface } from 'readline';
12
+ import { paint } from './output/colors.js';
13
+ import { scoreToGrade, scoreColor, gradeColor } from './output/progress.js';
14
+ import { loadConfig, get } from './config.js';
15
+
16
+ const HOME = homedir();
17
+ const OC_DIR = join(HOME, '.openclaw');
18
+ const CLAWARMOR_DIR = join(HOME, '.clawarmor');
19
+ const HISTORY_FILE = join(CLAWARMOR_DIR, 'history.json');
20
+ const CLI_PATH = new URL('../cli.js', import.meta.url).pathname;
21
+ const SEP = paint.dim('─'.repeat(52));
22
+
23
+ function box(title) {
24
+ const W = 52, pad = W - 2 - title.length, l = Math.floor(pad / 2), r = pad - l;
25
+ return [
26
+ paint.dim('╔' + '═'.repeat(W - 2) + '╗'),
27
+ paint.dim('║') + ' '.repeat(l) + paint.bold(title) + ' '.repeat(r) + paint.dim('║'),
28
+ paint.dim('╚' + '═'.repeat(W - 2) + '╝'),
29
+ ].join('\n');
30
+ }
31
+
32
+ // ── Fix discovery ────────────────────────────────────────────────────────────
33
+
34
+ function findWorldReadableCredFiles() {
35
+ if (!existsSync(OC_DIR)) return [];
36
+ const bad = [];
37
+ try {
38
+ const entries = readdirSync(OC_DIR, { withFileTypes: true });
39
+ for (const entry of entries) {
40
+ if (!entry.isFile()) continue;
41
+ const filePath = join(OC_DIR, entry.name);
42
+ let s;
43
+ try { s = statSync(filePath); } catch { continue; }
44
+ const mode = s.mode & 0o777;
45
+ if (mode & 0o004 || mode & 0o040) {
46
+ bad.push({ path: filePath, name: entry.name, mode: mode.toString(8) });
47
+ }
48
+ }
49
+ } catch { /* non-fatal */ }
50
+ return bad;
51
+ }
52
+
53
+ function buildFixes(config) {
54
+ const fixes = [];
55
+
56
+ // Fix 1: world/group-readable credential files — chmod 600
57
+ const badFiles = findWorldReadableCredFiles();
58
+ for (const f of badFiles) {
59
+ fixes.push({
60
+ id: `cred.perms.${f.name}`,
61
+ problem: `${f.name} is readable by other users (permissions: ${f.mode})`,
62
+ action: `chmod 600 ${f.path}`,
63
+ description: `Set permissions to 600 (owner-only) on ${f.path}`,
64
+ type: 'shell',
65
+ manualNote: null,
66
+ });
67
+ }
68
+
69
+ // Fix 2: gateway.host = 0.0.0.0 → 127.0.0.1
70
+ const gwHost = get(config, 'gateway.host', null);
71
+ if (gwHost === '0.0.0.0') {
72
+ fixes.push({
73
+ id: 'gateway.host.open',
74
+ problem: 'gateway.host is set to 0.0.0.0 — listens on all network interfaces',
75
+ action: 'openclaw config set gateway.host 127.0.0.1',
76
+ description: 'Change gateway.host to 127.0.0.1 (loopback only)',
77
+ type: 'openclaw',
78
+ manualNote: 'Restart gateway after applying: openclaw gateway restart',
79
+ });
80
+ }
81
+
82
+ // Fix 3: exec.ask = off → always
83
+ const execAsk = get(config, 'exec.ask', null) ?? get(config, 'tools.exec.ask', null);
84
+ if (execAsk === 'off' || execAsk === false) {
85
+ fixes.push({
86
+ id: 'exec.ask.off',
87
+ problem: 'exec.ask is off — shell commands run without user confirmation',
88
+ action: 'openclaw config set exec.ask always',
89
+ description: 'Enable exec.ask so shell commands require confirmation',
90
+ type: 'openclaw',
91
+ manualNote: 'Restart gateway after applying: openclaw gateway restart',
92
+ });
93
+ }
94
+
95
+ return fixes;
96
+ }
97
+
98
+ // ── Apply a single fix ────────────────────────────────────────────────────────
99
+
100
+ function applyFix(fix) {
101
+ try {
102
+ execSync(fix.action, { stdio: 'ignore', shell: true });
103
+ return { ok: true };
104
+ } catch (e) {
105
+ return { ok: false, err: e.message.split('\n')[0] };
106
+ }
107
+ }
108
+
109
+ // ── Re-run audit (JSON) for before/after score ────────────────────────────────
110
+
111
+ function runAuditJson() {
112
+ try {
113
+ const result = spawnSync(process.execPath, [CLI_PATH, 'audit', '--json'], {
114
+ encoding: 'utf8',
115
+ timeout: 30000,
116
+ maxBuffer: 1024 * 1024,
117
+ });
118
+ if (result.stdout) {
119
+ const jsonStart = result.stdout.indexOf('{');
120
+ if (jsonStart !== -1) return JSON.parse(result.stdout.slice(jsonStart));
121
+ }
122
+ } catch { /* non-fatal */ }
123
+ return null;
124
+ }
125
+
126
+ // ── Interactive y/N prompt ────────────────────────────────────────────────────
127
+
128
+ async function askYN(question) {
129
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
130
+ return new Promise(resolve => {
131
+ rl.question(question, answer => {
132
+ rl.close();
133
+ const a = answer.trim().toLowerCase();
134
+ resolve(a === 'y' || a === 'yes');
135
+ });
136
+ });
137
+ }
138
+
139
+ // ── Load last history entry for before score ─────────────────────────────────
140
+
141
+ function loadLastHistoryEntry() {
142
+ try {
143
+ if (!existsSync(HISTORY_FILE)) return null;
144
+ const h = JSON.parse(readFileSync(HISTORY_FILE, 'utf8'));
145
+ if (!Array.isArray(h) || !h.length) return null;
146
+ return h[h.length - 1];
147
+ } catch { return null; }
148
+ }
149
+
150
+ // ── Manual follow-up items (things that require human action) ─────────────────
151
+
152
+ function getManualItems() {
153
+ return [
154
+ 'Rotate tokens older than 90 days (run: clawarmor log --tokens)',
155
+ 'Review and rotate any compromised or exposed credentials',
156
+ 'Enable agent sandbox isolation if Docker Desktop is available',
157
+ ];
158
+ }
159
+
160
+ // ── Main export ───────────────────────────────────────────────────────────────
161
+
162
+ export async function runHarden(flags = {}) {
163
+ console.log(''); console.log(box('ClawArmor Harden v2.0')); console.log('');
164
+
165
+ const { config } = loadConfig();
166
+ const fixes = buildFixes(config);
167
+
168
+ // Snapshot before score
169
+ const before = loadLastHistoryEntry();
170
+ const beforeScore = before?.score ?? null;
171
+ const beforeGrade = before?.grade ?? null;
172
+
173
+ // ── DRY RUN ────────────────────────────────────────────────────────────────
174
+ if (flags.dryRun) {
175
+ console.log(` ${paint.cyan('Dry run — showing what would be fixed (no changes applied):')}`);
176
+ console.log('');
177
+
178
+ if (!fixes.length) {
179
+ console.log(` ${paint.green('✓')} No auto-fixable issues found.`);
180
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor audit')} ${paint.dim('to see all findings.')}`);
181
+ console.log('');
182
+ return 0;
183
+ }
184
+
185
+ for (const fix of fixes) {
186
+ console.log(` ${paint.yellow('!')} ${paint.bold(fix.problem)}`);
187
+ console.log(` ${paint.dim('Fix:')} ${fix.description}`);
188
+ console.log(` ${paint.dim('Cmd:')} ${fix.action}`);
189
+ if (fix.manualNote) console.log(` ${paint.dim('Note:')} ${fix.manualNote}`);
190
+ console.log('');
191
+ }
192
+
193
+ console.log(SEP);
194
+ console.log(` ${fixes.length} fix${fixes.length !== 1 ? 'es' : ''} available.`);
195
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor harden')} ${paint.dim('to apply interactively.')}`);
196
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor harden --auto')} ${paint.dim('to apply all without prompts.')}`);
197
+ console.log('');
198
+
199
+ const manualItems = getManualItems();
200
+ console.log(SEP);
201
+ console.log(` ${paint.bold('Manual follow-up required:')}`);
202
+ for (const item of manualItems) {
203
+ console.log(` ${paint.dim('•')} ${item}`);
204
+ }
205
+ console.log('');
206
+ return 0;
207
+ }
208
+
209
+ // ── AUTO or INTERACTIVE ────────────────────────────────────────────────────
210
+
211
+ if (!fixes.length) {
212
+ console.log(` ${paint.green('✓')} No auto-fixable issues found. Your config looks good.`);
213
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor audit')} ${paint.dim('for the full picture.')}`);
214
+ console.log('');
215
+
216
+ const manualItems = getManualItems();
217
+ console.log(SEP);
218
+ console.log(` ${paint.bold('Manual follow-up required:')}`);
219
+ for (const item of manualItems) {
220
+ console.log(` ${paint.dim('•')} ${item}`);
221
+ }
222
+ console.log('');
223
+ return 0;
224
+ }
225
+
226
+ const modeLabel = flags.auto
227
+ ? paint.cyan('Auto mode — applying all safe fixes without confirmation')
228
+ : paint.cyan('Interactive mode — review and apply fixes one by one');
229
+ console.log(` ${modeLabel}`);
230
+ console.log('');
231
+
232
+ let applied = 0, skipped = 0, failed = 0;
233
+ const restartNotes = [];
234
+
235
+ for (const fix of fixes) {
236
+ console.log(SEP);
237
+ console.log(` ${paint.bold('Problem:')} ${fix.problem}`);
238
+ console.log(` ${paint.dim('Fix:')} ${fix.description}`);
239
+ console.log(` ${paint.dim('Command:')} ${paint.dim(fix.action)}`);
240
+ console.log('');
241
+
242
+ let doApply = flags.auto;
243
+
244
+ if (!flags.auto) {
245
+ doApply = await askYN(` Apply this fix? [y/N] `);
246
+ }
247
+
248
+ if (!doApply) {
249
+ console.log(` ${paint.dim('✗ Skipped')}`);
250
+ skipped++;
251
+ console.log('');
252
+ continue;
253
+ }
254
+
255
+ const result = applyFix(fix);
256
+ if (result.ok) {
257
+ console.log(` ${paint.green('✓ Fixed')}`);
258
+ applied++;
259
+ if (fix.manualNote) restartNotes.push(fix.manualNote);
260
+ } else {
261
+ console.log(` ${paint.red('✗ Failed:')} ${result.err}`);
262
+ failed++;
263
+ }
264
+ console.log('');
265
+ }
266
+
267
+ console.log(SEP);
268
+ console.log('');
269
+ console.log(` Applied: ${paint.green(String(applied))} Skipped: ${paint.dim(String(skipped))} Failed: ${failed > 0 ? paint.red(String(failed)) : paint.dim('0')}`);
270
+
271
+ // Restart notes
272
+ if (restartNotes.length) {
273
+ console.log('');
274
+ for (const note of [...new Set(restartNotes)]) {
275
+ console.log(` ${paint.yellow('!')} ${note}`);
276
+ }
277
+ }
278
+
279
+ // Manual follow-up
280
+ console.log('');
281
+ console.log(SEP);
282
+ console.log(` ${paint.bold('Manual follow-up required:')}`);
283
+ const manualItems = getManualItems();
284
+ for (const item of manualItems) {
285
+ console.log(` ${paint.dim('•')} ${item}`);
286
+ }
287
+
288
+ // Before/after score comparison (only if we applied something)
289
+ if (applied > 0) {
290
+ console.log('');
291
+ console.log(SEP);
292
+ console.log(` ${paint.dim('Re-running audit to measure impact...')}`);
293
+ const after = runAuditJson();
294
+ const afterScore = after?.score ?? null;
295
+ const afterGrade = after?.grade ?? null;
296
+
297
+ if (afterScore !== null) {
298
+ console.log('');
299
+ if (beforeScore !== null) {
300
+ const delta = afterScore - beforeScore;
301
+ const deltaStr = delta > 0 ? paint.green(`+${delta}`) : delta < 0 ? paint.red(String(delta)) : paint.dim('±0');
302
+ console.log(` Before: ${scoreColor(beforeScore)(beforeScore + '/100')} ${paint.dim('Grade:')} ${gradeColor(beforeGrade || scoreToGrade(beforeScore))}`);
303
+ console.log(` After: ${scoreColor(afterScore)(afterScore + '/100')} ${paint.dim('Grade:')} ${gradeColor(afterGrade || scoreToGrade(afterScore))} ${deltaStr}`);
304
+ } else {
305
+ console.log(` Score: ${scoreColor(afterScore)(afterScore + '/100')} ${paint.dim('Grade:')} ${gradeColor(afterGrade || scoreToGrade(afterScore))}`);
306
+ }
307
+ }
308
+ }
309
+
310
+ console.log('');
311
+ return failed > 0 ? 1 : 0;
312
+ }
@@ -0,0 +1,140 @@
1
+ // ClawArmor v2.0 — Audit Log Viewer
2
+ // Reads ~/.clawarmor/audit.log (JSONL) and displays events in human-readable form.
3
+ // Flags: --since <Nd|Nh> --json --tokens
4
+
5
+ import { existsSync, readFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { homedir } from 'os';
8
+ import { paint, severityColor } from './output/colors.js';
9
+
10
+ const LOG_FILE = join(homedir(), '.clawarmor', 'audit.log');
11
+ const SEP = paint.dim('─'.repeat(52));
12
+
13
+ function parseEntries() {
14
+ if (!existsSync(LOG_FILE)) return null;
15
+ const raw = readFileSync(LOG_FILE, 'utf8');
16
+ return raw
17
+ .split('\n')
18
+ .filter(Boolean)
19
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
20
+ .filter(Boolean);
21
+ }
22
+
23
+ function parseSince(sinceArg) {
24
+ if (!sinceArg) return null;
25
+ const m = sinceArg.match(/^(\d+)(d|h)$/);
26
+ if (!m) return null;
27
+ const n = parseInt(m[1], 10);
28
+ const ms = m[2] === 'd' ? n * 86_400_000 : n * 3_600_000;
29
+ return new Date(Date.now() - ms);
30
+ }
31
+
32
+ function cmdColor(cmd) {
33
+ switch (cmd) {
34
+ case 'audit': return paint.cyan(cmd.padEnd(7));
35
+ case 'scan': return paint.cyan(cmd.padEnd(7));
36
+ case 'prescan': return paint.cyan(cmd.padEnd(7));
37
+ case 'watch': return paint.dim(cmd.padEnd(7));
38
+ default: return paint.dim((cmd || '?').padEnd(7));
39
+ }
40
+ }
41
+
42
+ function formatScore(score, delta) {
43
+ if (score == null) return '';
44
+ const s = `${score}/100`;
45
+ if (delta == null) return paint.bold(s);
46
+ const dStr = delta >= 0 ? `+${delta}` : `${delta}`;
47
+ const dColor = delta >= 0 ? paint.green : paint.red;
48
+ return `${paint.bold(s)} ${dColor(dStr)}`;
49
+ }
50
+
51
+ function formatFindings(findings) {
52
+ if (!Array.isArray(findings) || !findings.length) return paint.green('clean');
53
+ const bySev = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 };
54
+ for (const f of findings) bySev[f.severity] = (bySev[f.severity] || 0) + 1;
55
+ const parts = [];
56
+ if (bySev.CRITICAL) parts.push(paint.red(`${bySev.CRITICAL}C`));
57
+ if (bySev.HIGH) parts.push(paint.yellow(`${bySev.HIGH}H`));
58
+ if (bySev.MEDIUM) parts.push(paint.cyan(`${bySev.MEDIUM}M`));
59
+ if (bySev.LOW || bySev.INFO)
60
+ parts.push(paint.dim(`${(bySev.LOW || 0) + (bySev.INFO || 0)}L`));
61
+ return parts.join(' ') || paint.green('clean');
62
+ }
63
+
64
+ function formatEntry(e) {
65
+ const ts = new Date(e.ts).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
66
+ const cmd = cmdColor(e.cmd);
67
+ const trigger = paint.dim(`[${e.trigger || 'manual'}]`);
68
+ const scoreStr = formatScore(e.score, e.delta);
69
+ const findingsStr = formatFindings(e.findings);
70
+ const blocked = e.blocked === true ? ` ${paint.red('BLOCKED')}` : '';
71
+ const skill = e.skill ? ` ${paint.cyan(e.skill)}` : '';
72
+
73
+ const parts = [paint.dim(ts), cmd, trigger];
74
+ if (scoreStr) parts.push(scoreStr);
75
+ parts.push(findingsStr);
76
+
77
+ return ` ${parts.join(' ')}${skill}${blocked}`;
78
+ }
79
+
80
+ export async function runLog(flags = {}) {
81
+ const entries = parseEntries();
82
+
83
+ if (!entries) {
84
+ console.log('');
85
+ console.log(` No audit log yet. Run ${paint.cyan('clawarmor audit')} to start.`);
86
+ console.log('');
87
+ return 0;
88
+ }
89
+
90
+ let filtered = entries;
91
+
92
+ // --since filter
93
+ const since = parseSince(flags.since);
94
+ if (since) {
95
+ filtered = filtered.filter(e => new Date(e.ts) >= since);
96
+ }
97
+
98
+ // --tokens filter
99
+ if (flags.tokens) {
100
+ filtered = filtered.filter(e =>
101
+ Array.isArray(e.findings) &&
102
+ e.findings.some(f => (f.id || '').includes('token') || (f.id || '').includes('access'))
103
+ );
104
+ }
105
+
106
+ // --json: raw JSONL output
107
+ if (flags.json) {
108
+ for (const e of filtered) console.log(JSON.stringify(e));
109
+ return 0;
110
+ }
111
+
112
+ // Default: last 10 events
113
+ const recent = filtered.slice(-10);
114
+
115
+ console.log('');
116
+ if (!recent.length) {
117
+ console.log(` No log entries match the given filters.`);
118
+ console.log(` ${paint.dim('Total entries in log:')} ${entries.length}`);
119
+ console.log('');
120
+ return 0;
121
+ }
122
+
123
+ console.log(SEP);
124
+ const label = flags.since ? ` — since ${flags.since}` : ` — last ${recent.length}`;
125
+ console.log(` ${paint.bold('ClawArmor Audit Log')}${paint.dim(label)}`);
126
+ console.log(SEP);
127
+ console.log('');
128
+
129
+ for (const e of recent) {
130
+ console.log(formatEntry(e));
131
+ }
132
+
133
+ if (filtered.length > 10) {
134
+ console.log('');
135
+ console.log(` ${paint.dim(`(showing 10 of ${filtered.length} entries — use --since to filter)`)}`);
136
+ }
137
+
138
+ console.log('');
139
+ return 0;
140
+ }
@@ -6,6 +6,7 @@ export function progressBar(score, width = 20) {
6
6
  }
7
7
 
8
8
  export function scoreToGrade(score) {
9
+ if (score === 100) return 'A+';
9
10
  if (score >= 90) return 'A';
10
11
  if (score >= 75) return 'B';
11
12
  if (score >= 60) return 'C';
@@ -22,6 +23,6 @@ export function scoreColor(score) {
22
23
  }
23
24
 
24
25
  export function gradeColor(grade) {
25
- const map = { A: paint.green, B: paint.pass, C: paint.yellow, D: paint.high, F: paint.critical };
26
+ const map = { 'A+': paint.green, A: paint.green, B: paint.pass, C: paint.yellow, D: paint.high, F: paint.critical };
26
27
  return (map[grade] || paint.dim)(grade);
27
28
  }
package/lib/prescan.js ADDED
@@ -0,0 +1,167 @@
1
+ // ClawArmor v2.0 — Pre-scan a skill before installing
2
+ // Downloads the npm package to a temp dir, scans it with the full
3
+ // ClawArmor scanner, and exits 1 (blocks install) only on CRITICAL findings.
4
+
5
+ import { mkdirSync, rmSync, readdirSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { tmpdir } from 'os';
8
+ import { execSync } from 'child_process';
9
+ import { scanFile } from './scanner/file-scanner.js';
10
+ import { scanSkillMdFiles } from './scanner/skill-md-scanner.js';
11
+ import { paint, severityColor } from './output/colors.js';
12
+ import { append } from './audit-log.js';
13
+
14
+ const SEP = paint.dim('─'.repeat(52));
15
+
16
+ function getAllFiles(dir, files = []) {
17
+ try {
18
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
19
+ if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === '__pycache__') continue;
20
+ const fp = join(dir, e.name);
21
+ if (e.isDirectory()) getAllFiles(fp, files);
22
+ else files.push(fp);
23
+ }
24
+ } catch { /* permission denied */ }
25
+ return files;
26
+ }
27
+
28
+ function cleanupTmp(dir) {
29
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* non-fatal */ }
30
+ }
31
+
32
+ export async function runPrescan(skillName) {
33
+ console.log('');
34
+ console.log(` ${paint.bold('ClawArmor Prescan')} — ${paint.cyan(skillName)}`);
35
+ console.log(` ${paint.dim('Fetching package from npm registry...')}`);
36
+ console.log('');
37
+
38
+ const tmpDir = join(tmpdir(), `clawarmor-prescan-${Date.now()}`);
39
+ mkdirSync(tmpDir, { recursive: true });
40
+
41
+ // ── Step 1: Download via npm pack ─────────────────────────────────────────
42
+ let tarball;
43
+ try {
44
+ execSync(`npm pack ${skillName}`, {
45
+ cwd: tmpDir,
46
+ timeout: 30000,
47
+ stdio: ['ignore', 'pipe', 'ignore'],
48
+ });
49
+ const tarballs = readdirSync(tmpDir).filter(f => f.endsWith('.tgz'));
50
+ if (!tarballs.length) throw new Error('npm pack produced no tarball');
51
+ tarball = join(tmpDir, tarballs[0]);
52
+ } catch {
53
+ cleanupTmp(tmpDir);
54
+ console.log(` ${paint.dim('ℹ')} Could not fetch skill for scanning`);
55
+ console.log(` ${paint.dim('(package not found or network error — install not blocked)')}`);
56
+ console.log('');
57
+ return 0;
58
+ }
59
+
60
+ // ── Step 2: Extract tarball ────────────────────────────────────────────────
61
+ const extractDir = join(tmpDir, 'extracted');
62
+ mkdirSync(extractDir, { recursive: true });
63
+ try {
64
+ execSync(`tar -xzf "${tarball}" -C "${extractDir}"`, {
65
+ timeout: 15000,
66
+ stdio: ['ignore', 'ignore', 'ignore'],
67
+ });
68
+ } catch {
69
+ cleanupTmp(tmpDir);
70
+ console.log(` ${paint.dim('ℹ')} Could not extract skill package — install not blocked`);
71
+ console.log('');
72
+ return 0;
73
+ }
74
+
75
+ // ── Step 3: Collect all files ──────────────────────────────────────────────
76
+ const allFiles = getAllFiles(extractDir);
77
+ console.log(` ${paint.dim('Scanning')} ${allFiles.length} file${allFiles.length !== 1 ? 's' : ''}...`);
78
+ console.log('');
79
+
80
+ // ── Step 4: Run ClawArmor scanners ────────────────────────────────────────
81
+ // Not a built-in — treat as third-party (isBuiltin = false)
82
+ const codeFindings = allFiles.flatMap(f => scanFile(f, false));
83
+ const mdResults = scanSkillMdFiles(allFiles, false);
84
+ const mdFindings = mdResults.flatMap(r => r.findings);
85
+ const allFindings = [...codeFindings, ...mdFindings];
86
+
87
+ const criticals = allFindings.filter(f => f.severity === 'CRITICAL');
88
+ const highs = allFindings.filter(f => f.severity === 'HIGH');
89
+ const mediums = allFindings.filter(f => f.severity === 'MEDIUM');
90
+ const lows = allFindings.filter(f => f.severity === 'LOW' || f.severity === 'INFO');
91
+
92
+ // ── Cleanup (always) ──────────────────────────────────────────────────────
93
+ cleanupTmp(tmpDir);
94
+
95
+ // ── Audit log ─────────────────────────────────────────────────────────────
96
+ append({
97
+ cmd: 'prescan',
98
+ trigger: 'prescan',
99
+ score: null,
100
+ delta: null,
101
+ findings: allFindings.map(f => ({ id: f.patternId || f.id || '?', severity: f.severity })),
102
+ blocked: criticals.length > 0,
103
+ skill: skillName,
104
+ });
105
+
106
+ // ── Output ────────────────────────────────────────────────────────────────
107
+ if (!allFindings.length) {
108
+ console.log(` ${paint.green('✓')} ClawArmor prescan: clean — 0 findings`);
109
+ console.log('');
110
+ return 0;
111
+ }
112
+
113
+ // CRITICAL → print details, block install (exit 1)
114
+ if (criticals.length) {
115
+ console.log(SEP);
116
+ console.log(` ${paint.red('✗')} ${paint.bold(`CRITICAL (${criticals.length}) — install blocked`)}`);
117
+ console.log(SEP);
118
+ for (const f of criticals) {
119
+ console.log('');
120
+ console.log(` ${paint.red('✗')} ${(severityColor['CRITICAL'] || paint.red)('[CRITICAL]')} ${paint.bold(f.title)}`);
121
+ console.log(` ${paint.dim(f.description || '')}`);
122
+ for (const m of (f.matches || []).slice(0, 2)) {
123
+ console.log(` ${paint.dim('→')} ${paint.cyan(':' + m.line)} ${paint.dim(m.snippet)}`);
124
+ }
125
+ }
126
+
127
+ if (highs.length || mediums.length) {
128
+ console.log('');
129
+ const extra = [];
130
+ if (highs.length) extra.push(`${highs.length} HIGH`);
131
+ if (mediums.length) extra.push(`${mediums.length} MEDIUM`);
132
+ console.log(` ${paint.yellow('!')} Additional: ${extra.join(', ')} (fix criticals first)`);
133
+ }
134
+
135
+ console.log('');
136
+ console.log(` ${paint.red('✗')} Skill blocked. Do NOT install ${paint.bold(skillName)}.`);
137
+ console.log('');
138
+ return 1;
139
+ }
140
+
141
+ // HIGH → warn, allow install
142
+ if (highs.length) {
143
+ console.log(SEP);
144
+ console.log(` ${paint.yellow('⚠')} ${paint.bold(`HIGH (${highs.length}) — review before using`)}`);
145
+ console.log(SEP);
146
+ for (const f of highs) {
147
+ console.log('');
148
+ console.log(` ${paint.yellow('!')} ${(severityColor['HIGH'] || paint.yellow)('[HIGH]')} ${paint.bold(f.title)}`);
149
+ console.log(` ${paint.dim(f.description || '')}`);
150
+ for (const m of (f.matches || []).slice(0, 2)) {
151
+ console.log(` ${paint.dim('→')} ${paint.cyan(':' + m.line)} ${paint.dim(m.snippet)}`);
152
+ }
153
+ }
154
+ console.log('');
155
+ }
156
+
157
+ // MEDIUM/LOW → summary line only
158
+ if (mediums.length || lows.length) {
159
+ const parts = [];
160
+ if (mediums.length) parts.push(`${mediums.length} medium`);
161
+ if (lows.length) parts.push(`${lows.length} low/info`);
162
+ console.log(` ${paint.dim('ℹ')} ${parts.join(', ')} additional finding${(mediums.length + lows.length) > 1 ? 's' : ''} (review manually)`);
163
+ console.log('');
164
+ }
165
+
166
+ return 0;
167
+ }