clawarmor 3.0.1 → 3.2.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.
@@ -0,0 +1,214 @@
1
+ // lib/profile-cmd.js — clawarmor profile command
2
+ // Subcommands: list, detect, set <name>, show
3
+
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { homedir } from 'os';
7
+ import { paint } from './output/colors.js';
8
+ import { listProfiles, getProfile, detectProfile } from './profiles.js';
9
+ import { loadConfig } from './config.js';
10
+
11
+ const HOME = homedir();
12
+ const CLAWARMOR_DIR = join(HOME, '.clawarmor');
13
+ const PROFILE_FILE = join(CLAWARMOR_DIR, 'profile.json');
14
+ const SEP = paint.dim('─'.repeat(52));
15
+
16
+ function box(title) {
17
+ const W = 52, pad = W - 2 - title.length, l = Math.floor(pad / 2), r = pad - l;
18
+ return [
19
+ paint.dim('╔' + '═'.repeat(W - 2) + '╗'),
20
+ paint.dim('║') + ' '.repeat(l) + paint.bold(title) + ' '.repeat(r) + paint.dim('║'),
21
+ paint.dim('╚' + '═'.repeat(W - 2) + '╝'),
22
+ ].join('\n');
23
+ }
24
+
25
+ function readCurrentProfile() {
26
+ try {
27
+ if (!existsSync(PROFILE_FILE)) return null;
28
+ return JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
29
+ } catch { return null; }
30
+ }
31
+
32
+ function writeProfile(name) {
33
+ try {
34
+ mkdirSync(CLAWARMOR_DIR, { recursive: true });
35
+ writeFileSync(PROFILE_FILE, JSON.stringify({ name, setAt: new Date().toISOString() }, null, 2), 'utf8');
36
+ return true;
37
+ } catch { return false; }
38
+ }
39
+
40
+ function profileBadge(name) {
41
+ const badges = {
42
+ coding: paint.cyan('coding'),
43
+ browsing: paint.green('browsing'),
44
+ messaging: paint.yellow('messaging'),
45
+ general: paint.dim('general'),
46
+ };
47
+ return badges[name] || paint.dim(name);
48
+ }
49
+
50
+ async function listCmd() {
51
+ console.log(''); console.log(box('ClawArmor Profiles')); console.log('');
52
+ console.log(` ${paint.bold('Available profiles:')}`);
53
+ console.log('');
54
+
55
+ const current = readCurrentProfile();
56
+ const profiles = listProfiles();
57
+
58
+ for (const p of profiles) {
59
+ const isCurrent = current?.name === p.name;
60
+ const marker = isCurrent ? paint.green('→') : paint.dim('·');
61
+ const badge = profileBadge(p.name);
62
+ console.log(` ${marker} ${badge.padEnd(12)} ${p.description}`);
63
+ if (p.allowedCapabilities.length > 0) {
64
+ console.log(` ${paint.dim('allows:')} ${paint.dim(p.allowedCapabilities.join(', '))}`);
65
+ }
66
+ if (p.restrictedCapabilities.length > 0) {
67
+ console.log(` ${paint.dim('restricts:')} ${paint.dim(p.restrictedCapabilities.join(', '))}`);
68
+ }
69
+ console.log('');
70
+ }
71
+
72
+ if (current) {
73
+ console.log(` ${paint.dim('Current profile:')} ${profileBadge(current.name)}`);
74
+ } else {
75
+ console.log(` ${paint.dim('No profile set. Defaulting to')} ${profileBadge('general')}`);
76
+ console.log(` ${paint.dim('Set with:')} ${paint.cyan('clawarmor profile set <name>')}`);
77
+ }
78
+ console.log('');
79
+ return 0;
80
+ }
81
+
82
+ async function detectCmd() {
83
+ console.log(''); console.log(box('ClawArmor Profile Detect')); console.log('');
84
+
85
+ const { config } = loadConfig();
86
+ const { profile: detected, reasons } = detectProfile(config);
87
+
88
+ console.log(` ${paint.bold('Auto-detected profile:')} ${profileBadge(detected)}`);
89
+ console.log('');
90
+ console.log(` ${paint.dim('Reasoning:')}`);
91
+ for (const reason of reasons) {
92
+ console.log(` ${paint.dim('·')} ${reason}`);
93
+ }
94
+ console.log('');
95
+
96
+ const profileDef = getProfile(detected);
97
+ if (profileDef) {
98
+ console.log(` ${paint.dim('Profile description:')} ${profileDef.description}`);
99
+ }
100
+
101
+ const current = readCurrentProfile();
102
+ if (current && current.name !== detected) {
103
+ console.log('');
104
+ console.log(` ${paint.yellow('!')} Current profile is ${profileBadge(current.name)}, detected ${profileBadge(detected)}`);
105
+ console.log(` ${paint.dim('Run')} ${paint.cyan(`clawarmor profile set ${detected}`)} ${paint.dim('to switch.')}`);
106
+ } else if (!current) {
107
+ console.log('');
108
+ console.log(` ${paint.dim('Run')} ${paint.cyan(`clawarmor profile set ${detected}`)} ${paint.dim('to activate this profile.')}`);
109
+ }
110
+
111
+ console.log('');
112
+ return 0;
113
+ }
114
+
115
+ async function setCmd(name) {
116
+ if (!name) {
117
+ console.log('');
118
+ console.log(` ${paint.red('✗')} Profile name required.`);
119
+ console.log(` Usage: ${paint.cyan('clawarmor profile set <name>')}`);
120
+ console.log(` Available: ${listProfiles().map(p => p.name).join(', ')}`);
121
+ console.log('');
122
+ return 1;
123
+ }
124
+
125
+ const profile = getProfile(name);
126
+ if (!profile) {
127
+ console.log('');
128
+ console.log(` ${paint.red('✗')} Unknown profile: ${paint.bold(name)}`);
129
+ console.log(` Available: ${listProfiles().map(p => p.name).join(', ')}`);
130
+ console.log('');
131
+ return 1;
132
+ }
133
+
134
+ const ok = writeProfile(name);
135
+ console.log('');
136
+ if (ok) {
137
+ console.log(` ${paint.green('✓')} Profile set to ${profileBadge(name)}`);
138
+ console.log(` ${paint.dim(profile.description)}`);
139
+ console.log('');
140
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor harden --profile ' + name)} ${paint.dim('for profile-aware recommendations.')}`);
141
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor audit --profile ' + name)} ${paint.dim('for profile-adjusted scoring.')}`);
142
+ } else {
143
+ console.log(` ${paint.red('✗')} Failed to write profile to ${PROFILE_FILE}`);
144
+ }
145
+ console.log('');
146
+ return ok ? 0 : 1;
147
+ }
148
+
149
+ async function showCmd() {
150
+ console.log(''); console.log(box('ClawArmor Current Profile')); console.log('');
151
+
152
+ const current = readCurrentProfile();
153
+
154
+ if (!current) {
155
+ console.log(` ${paint.dim('No profile set.')}`);
156
+ console.log(` ${paint.dim('Defaulting to general — no relaxations or restrictions applied.')}`);
157
+ console.log('');
158
+ console.log(` ${paint.dim('Set a profile:')} ${paint.cyan('clawarmor profile set <name>')}`);
159
+ console.log(` ${paint.dim('Auto-detect:')} ${paint.cyan('clawarmor profile detect')}`);
160
+ console.log('');
161
+ return 0;
162
+ }
163
+
164
+ const profileDef = getProfile(current.name);
165
+ const setAt = current.setAt ? new Date(current.setAt).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }) : 'unknown';
166
+
167
+ console.log(` ${paint.bold('Profile:')} ${profileBadge(current.name)}`);
168
+ console.log(` ${paint.bold('Set at:')} ${setAt}`);
169
+ console.log('');
170
+
171
+ if (profileDef) {
172
+ console.log(` ${paint.dim(profileDef.description)}`);
173
+ console.log('');
174
+ if (profileDef.allowedCapabilities.length > 0) {
175
+ console.log(` ${paint.green('Allowed:')} ${profileDef.allowedCapabilities.join(', ')}`);
176
+ }
177
+ if (profileDef.restrictedCapabilities.length > 0) {
178
+ console.log(` ${paint.yellow('Restricted:')} ${profileDef.restrictedCapabilities.join(', ')}`);
179
+ }
180
+ if (Object.keys(profileDef.checkWeightOverrides).length > 0) {
181
+ console.log('');
182
+ console.log(` ${paint.dim('Check overrides:')}`);
183
+ for (const [check, severity] of Object.entries(profileDef.checkWeightOverrides)) {
184
+ console.log(` ${paint.dim(check)} → ${severity}`);
185
+ }
186
+ }
187
+ }
188
+
189
+ console.log('');
190
+ console.log(` ${paint.dim('Change profile:')} ${paint.cyan('clawarmor profile set <name>')}`);
191
+ console.log(` ${paint.dim('List profiles:')} ${paint.cyan('clawarmor profile list')}`);
192
+ console.log('');
193
+ return 0;
194
+ }
195
+
196
+ export async function runProfileCmd(args = []) {
197
+ const sub = args[0];
198
+
199
+ if (!sub || sub === 'list') return listCmd();
200
+ if (sub === 'detect') return detectCmd();
201
+ if (sub === 'set') return setCmd(args[1]);
202
+ if (sub === 'show') return showCmd();
203
+
204
+ console.log('');
205
+ console.log(` ${paint.red('✗')} Unknown profile subcommand: ${paint.bold(sub)}`);
206
+ console.log('');
207
+ console.log(` ${paint.bold('Profile subcommands:')}`);
208
+ console.log(` ${paint.cyan('clawarmor profile list')}`);
209
+ console.log(` ${paint.cyan('clawarmor profile detect')}`);
210
+ console.log(` ${paint.cyan('clawarmor profile set <name>')}`);
211
+ console.log(` ${paint.cyan('clawarmor profile show')}`);
212
+ console.log('');
213
+ return 1;
214
+ }
@@ -0,0 +1,159 @@
1
+ // lib/profiles.js — Contextual hardening profiles
2
+ // Profiles adjust harden/audit recommendations based on what the agent actually does.
3
+
4
+ const PROFILES = {
5
+ coding: {
6
+ name: 'coding',
7
+ description: 'Code-focused agent — exec, file write, git are expected. External sends are restricted.',
8
+ allowedCapabilities: ['exec', 'file.write', 'git', 'file.read'],
9
+ restrictedCapabilities: ['external.send', 'external.network', 'channel.external'],
10
+ checkWeightOverrides: {
11
+ // exec being enabled is EXPECTED for a coding agent — downgrade severity
12
+ 'exec.ask.off': 'INFO',
13
+ 'exec.approval': 'INFO',
14
+ // external sends from a coding agent are UNEXPECTED — upgrade severity
15
+ 'channel.groupPolicy': 'HIGH',
16
+ 'channel.allowFrom': 'HIGH',
17
+ },
18
+ },
19
+ browsing: {
20
+ name: 'browsing',
21
+ description: 'Web browsing agent — fetch and read are expected. File writes and exec are restricted.',
22
+ allowedCapabilities: ['fetch', 'file.read', 'web'],
23
+ restrictedCapabilities: ['exec', 'file.write', 'channel.external'],
24
+ checkWeightOverrides: {
25
+ // file writes from a browsing agent are UNEXPECTED
26
+ 'filesystem.perms': 'HIGH',
27
+ // exec from a browsing agent is UNEXPECTED
28
+ 'exec.ask.off': 'HIGH',
29
+ 'exec.approval': 'HIGH',
30
+ },
31
+ },
32
+ messaging: {
33
+ name: 'messaging',
34
+ description: 'Messaging agent — channel access and send are expected. Exec and file access are restricted.',
35
+ allowedCapabilities: ['channel.send', 'channel.read', 'message'],
36
+ restrictedCapabilities: ['exec', 'file.write', 'file.read'],
37
+ checkWeightOverrides: {
38
+ // channel sends are EXPECTED for a messaging agent — downgrade severity
39
+ 'channel.groupPolicy': 'INFO',
40
+ 'channel.allowFrom': 'INFO',
41
+ // exec from a messaging agent is UNEXPECTED
42
+ 'exec.ask.off': 'HIGH',
43
+ 'exec.approval': 'HIGH',
44
+ },
45
+ },
46
+ general: {
47
+ name: 'general',
48
+ description: 'General-purpose agent — balanced defaults. No relaxations or extra restrictions.',
49
+ allowedCapabilities: [],
50
+ restrictedCapabilities: [],
51
+ checkWeightOverrides: {},
52
+ },
53
+ };
54
+
55
+ /**
56
+ * Get a profile by name.
57
+ * @param {string} name
58
+ * @returns {object|null}
59
+ */
60
+ export function getProfile(name) {
61
+ return PROFILES[name] || null;
62
+ }
63
+
64
+ /**
65
+ * List all available profiles.
66
+ * @returns {object[]}
67
+ */
68
+ export function listProfiles() {
69
+ return Object.values(PROFILES);
70
+ }
71
+
72
+ /**
73
+ * Auto-detect profile from openclaw config.
74
+ * @param {object} config - parsed openclaw.json
75
+ * @returns {{ profile: string, reasons: string[] }}
76
+ */
77
+ export function detectProfile(config) {
78
+ if (!config) return { profile: 'general', reasons: ['No config found — using general profile'] };
79
+
80
+ const reasons = [];
81
+
82
+ // Check for exec tools
83
+ const execEnabled = config?.tools?.exec?.enabled !== false && config?.exec?.enabled !== false;
84
+ const execAsk = config?.tools?.exec?.ask ?? config?.exec?.ask;
85
+ const hasExec = execEnabled && execAsk !== 'always';
86
+
87
+ // Check for web/fetch tools
88
+ const hasWeb = !!(
89
+ config?.tools?.fetch || config?.tools?.web ||
90
+ (config?.skills && JSON.stringify(config.skills).toLowerCase().includes('browser'))
91
+ );
92
+
93
+ // Check for channel/messaging tools
94
+ const hasChannels = !!(
95
+ config?.channels || config?.messaging ||
96
+ (config?.tools && JSON.stringify(config.tools).toLowerCase().includes('channel'))
97
+ );
98
+
99
+ // Check for git
100
+ const hasGit = !!(
101
+ config?.tools?.git ||
102
+ (config?.skills && JSON.stringify(config.skills).toLowerCase().includes('git'))
103
+ );
104
+
105
+ // Decision logic
106
+ if (hasExec && hasGit && !hasChannels) {
107
+ reasons.push('exec tools present → coding agent');
108
+ if (hasGit) reasons.push('git tools detected → coding profile');
109
+ return { profile: 'coding', reasons };
110
+ }
111
+
112
+ if (hasChannels && !hasExec) {
113
+ reasons.push('channel/messaging tools present → messaging agent');
114
+ return { profile: 'messaging', reasons };
115
+ }
116
+
117
+ if (hasWeb && !hasExec && !hasChannels) {
118
+ reasons.push('web/fetch tools present → browsing agent');
119
+ return { profile: 'browsing', reasons };
120
+ }
121
+
122
+ reasons.push('No strong signal detected → using general profile');
123
+ return { profile: 'general', reasons };
124
+ }
125
+
126
+ /**
127
+ * Check if a finding is expected for a given profile.
128
+ * @param {string} profileName
129
+ * @param {string} checkId - the check/finding id
130
+ * @returns {boolean}
131
+ */
132
+ export function isExpectedFinding(profileName, checkId) {
133
+ const profile = getProfile(profileName);
134
+ if (!profile) return false;
135
+ const id = (checkId || '').toLowerCase();
136
+ // Check if this finding's id matches any allowed capability patterns
137
+ // For exec findings in a coding profile, they're expected
138
+ if (profileName === 'coding' && (id.includes('exec') && (id.includes('ask') || id.includes('approval')))) return true;
139
+ if (profileName === 'messaging' && (id.includes('channel') && (id.includes('group') || id.includes('allow')))) return true;
140
+ return false;
141
+ }
142
+
143
+ /**
144
+ * Get overridden severity for a check in a given profile.
145
+ * @param {string} profileName
146
+ * @param {string} checkId
147
+ * @param {string} defaultSeverity
148
+ * @returns {string} overridden or original severity
149
+ */
150
+ export function getOverriddenSeverity(profileName, checkId) {
151
+ const profile = getProfile(profileName);
152
+ if (!profile) return null;
153
+ const id = (checkId || '').toLowerCase();
154
+ // Check overrides by matching check id prefixes
155
+ for (const [pattern, overrideSev] of Object.entries(profile.checkWeightOverrides)) {
156
+ if (id.includes(pattern.toLowerCase())) return overrideSev;
157
+ }
158
+ return null;
159
+ }
package/lib/protect.js CHANGED
@@ -68,7 +68,8 @@ Install with: \`clawarmor protect --install\`
68
68
  `;
69
69
 
70
70
  const HANDLER_JS = `// clawarmor-guard hook handler
71
- // Fires on gateway:startup. Silent unless score drops or CRITICAL finding appears.
71
+ // Fires on gateway:startup and after clawhub skill installs.
72
+ // Silent unless score drops or CRITICAL finding appears.
72
73
  // No external dependencies.
73
74
 
74
75
  import { spawnSync } from 'child_process';
@@ -79,6 +80,7 @@ import { homedir } from 'os';
79
80
  const HOME = homedir();
80
81
  const CLAWARMOR_DIR = join(HOME, '.clawarmor');
81
82
  const LAST_SCORE_FILE = join(CLAWARMOR_DIR, 'last-score.json');
83
+ const SKILL_REPORT_FILE = join(CLAWARMOR_DIR, 'skill-install-report.json');
82
84
 
83
85
  function readLastScore() {
84
86
  try {
@@ -109,8 +111,81 @@ function runAuditJson() {
109
111
  return null;
110
112
  }
111
113
 
112
- // Main hook entry point — called by openclaw on gateway:startup
114
+ function runStackSync() {
115
+ try {
116
+ spawnSync('clawarmor', ['stack', 'sync'], {
117
+ encoding: 'utf8',
118
+ timeout: 60000,
119
+ stdio: 'ignore',
120
+ });
121
+ } catch {}
122
+ }
123
+
124
+ function buildProposedFixes(newFindings) {
125
+ const fixes = [];
126
+ for (const f of newFindings) {
127
+ const id = (f.id || '').toLowerCase();
128
+ if (id.includes('exec') && id.includes('ask')) {
129
+ fixes.push('openclaw config set tools.exec.ask on-miss');
130
+ } else if (id.includes('gateway') && id.includes('host')) {
131
+ fixes.push('openclaw config set gateway.host 127.0.0.1');
132
+ } else if (id.includes('cred') || id.includes('filesystem')) {
133
+ fixes.push('clawarmor harden --auto');
134
+ } else if (id.includes('skill')) {
135
+ fixes.push('clawarmor scan');
136
+ }
137
+ }
138
+ return [...new Set(fixes)];
139
+ }
140
+
141
+ function handleSkillInstall(skillName, scoreBefore, auditResult) {
142
+ const scoreAfter = auditResult?.score ?? null;
143
+ if (scoreAfter === null) return;
144
+
145
+ // Regenerate stack rules for new tool surface
146
+ runStackSync();
147
+
148
+ const scoreDelta = scoreAfter - scoreBefore;
149
+
150
+ if (scoreDelta < 0) {
151
+ const prevFailed = [];
152
+ const newFailed = auditResult.failed || [];
153
+ // newFindings = findings in new audit not previously known
154
+ const lastState = readLastScore();
155
+ const prevIds = new Set((lastState?.failedIds || []));
156
+ const newFindings = newFailed.filter(f => !prevIds.has(f.id));
157
+ const proposedFixes = buildProposedFixes(newFindings);
158
+
159
+ const report = {
160
+ skill: skillName,
161
+ installedAt: new Date().toISOString(),
162
+ scoreBefore,
163
+ scoreAfter,
164
+ scoreDelta,
165
+ newFindings,
166
+ proposedFixes,
167
+ };
168
+
169
+ try {
170
+ mkdirSync(CLAWARMOR_DIR, { recursive: true });
171
+ writeFileSync(SKILL_REPORT_FILE, JSON.stringify(report, null, 2), 'utf8');
172
+ } catch {}
173
+
174
+ console.error(\`⚠ ClawArmor: skill install dropped score \${scoreBefore}→\${scoreAfter} (\${scoreDelta}). Run: clawarmor stack sync && clawarmor fix --dry-run\`);
175
+ } else {
176
+ console.error(\`✓ ClawArmor: score unchanged after install (\${scoreAfter}/100)\`);
177
+ }
178
+ }
179
+
180
+ // Main hook entry point — called by openclaw on gateway:startup and clawhub install
113
181
  export default async function handler(event) {
182
+ const isSkillInstall = event?.type === 'clawhub:install' || event?.skill;
183
+ const skillName = event?.skill || event?.args?.[0] || null;
184
+
185
+ // Capture score before install (for skill diff)
186
+ const lastState = readLastScore();
187
+ const lastScore = lastState?.score ?? null;
188
+
114
189
  let auditResult;
115
190
  try {
116
191
  auditResult = runAuditJson();
@@ -122,14 +197,20 @@ export default async function handler(event) {
122
197
  if (!auditResult) return;
123
198
 
124
199
  const newScore = auditResult.score ?? null;
125
- const lastState = readLastScore();
126
- const lastScore = lastState?.score ?? null;
127
200
  const isFirstRun = lastScore === null;
128
201
 
129
202
  if (newScore !== null) {
130
203
  if (isFirstRun) {
131
- writeLastScore({ score: newScore, grade: auditResult.grade, timestamp: new Date().toISOString() });
204
+ writeLastScore({
205
+ score: newScore,
206
+ grade: auditResult.grade,
207
+ failedIds: (auditResult.failed || []).map(f => f.id),
208
+ timestamp: new Date().toISOString(),
209
+ });
132
210
  // First run — establish baseline silently
211
+ if (isSkillInstall && skillName) {
212
+ runStackSync();
213
+ }
133
214
  return;
134
215
  }
135
216
 
@@ -142,9 +223,16 @@ export default async function handler(event) {
142
223
  score: newScore,
143
224
  grade: auditResult.grade,
144
225
  criticals: newCriticalCount,
226
+ failedIds: (auditResult.failed || []).map(f => f.id),
145
227
  timestamp: new Date().toISOString(),
146
228
  });
147
229
 
230
+ // Skill install post-audit diff
231
+ if (isSkillInstall && skillName) {
232
+ handleSkillInstall(skillName, lastScore, auditResult);
233
+ return;
234
+ }
235
+
148
236
  if (newCriticalCount > hadCriticals) {
149
237
  // New CRITICAL finding — alert immediately
150
238
  const names = newCriticals.map(f => f.id || f.title).join(', ');
package/lib/scan.js CHANGED
@@ -16,28 +16,40 @@ function box(title) {
16
16
  paint.dim('╚'+'═'.repeat(W-2)+'╝')].join('\n');
17
17
  }
18
18
 
19
- export async function runScan() {
20
- console.log(''); console.log(box('ClawArmor Skill Scan v0.6')); console.log('');
21
- console.log(` ${paint.dim('Scanning:')} Installed OpenClaw skills (code + SKILL.md)`);
22
- console.log(` ${paint.dim('Started:')} ${new Date().toLocaleString('en-US',{dateStyle:'medium',timeStyle:'short'})}`);
23
- console.log('');
19
+ export async function runScan(flags = {}) {
20
+ const jsonMode = flags.json || false;
21
+
22
+ if (!jsonMode) {
23
+ console.log(''); console.log(box('ClawArmor Skill Scan v0.6')); console.log('');
24
+ console.log(` ${paint.dim('Scanning:')} Installed OpenClaw skills (code + SKILL.md)`);
25
+ console.log(` ${paint.dim('Started:')} ${new Date().toLocaleString('en-US',{dateStyle:'medium',timeStyle:'short'})}`);
26
+ console.log('');
27
+ }
24
28
 
25
29
  const skills = findInstalledSkills();
26
30
  if (!skills.length) {
27
- console.log(` ${paint.dim('No installed skills found.')}`); console.log(''); return 0;
31
+ if (jsonMode) {
32
+ process.stdout.write(JSON.stringify({ verdict: 'PASS', score: 100, totalSkills: 0, flaggedSkills: 0, findings: [], scannedAt: new Date().toISOString() }, null, 2) + '\n');
33
+ } else {
34
+ console.log(` ${paint.dim('No installed skills found.')}`); console.log('');
35
+ }
36
+ return 0;
28
37
  }
29
38
 
30
39
  const userSkills = skills.filter(s => !s.isBuiltin);
31
40
  const builtinSkills = skills.filter(s => s.isBuiltin);
32
- console.log(` ${paint.dim('Found')} ${paint.bold(String(skills.length))} ${paint.dim('skills')} ${paint.dim(`(${userSkills.length} user-installed, ${builtinSkills.length} built-in)`)}`);
33
- console.log('');
41
+ if (!jsonMode) {
42
+ console.log(` ${paint.dim('Found')} ${paint.bold(String(skills.length))} ${paint.dim('skills')} ${paint.dim(`(${userSkills.length} user-installed, ${builtinSkills.length} built-in)`)}`);
43
+ console.log('');
44
+ }
34
45
 
35
46
  let totalCritical = 0, totalHigh = 0;
36
47
  const flagged = [];
37
48
  const auditFindings = []; // accumulated for audit log
49
+ const jsonFindings = []; // for --json output
38
50
 
39
51
  for (const skill of skills) {
40
- process.stdout.write(` ${skill.isBuiltin ? paint.dim('⊙') : paint.cyan('▶')} ${paint.bold(skill.name)}${paint.dim(skill.isBuiltin?' [built-in]':' [user]')}...`);
52
+ if (!jsonMode) process.stdout.write(` ${skill.isBuiltin ? paint.dim('⊙') : paint.cyan('▶')} ${paint.bold(skill.name)}${paint.dim(skill.isBuiltin?' [built-in]':' [user]')}...`);
41
53
 
42
54
  // Code findings (JS, py, sh, etc.)
43
55
  const codeFindings = skill.files.flatMap(f => scanFile(f, skill.isBuiltin));
@@ -56,21 +68,80 @@ export async function runScan() {
56
68
 
57
69
  totalCritical += critical.length; totalHigh += high.length;
58
70
 
59
- if (!allFindings.length) { process.stdout.write(` ${paint.green('✓ clean')}\n`); continue; }
71
+ // Collect findings for JSON output
72
+ for (const f of allFindings) {
73
+ if (['CRITICAL','HIGH','MEDIUM','LOW'].includes(f.severity)) {
74
+ for (const m of (f.matches || [])) {
75
+ jsonFindings.push({
76
+ skill: skill.name,
77
+ severity: f.severity,
78
+ patternId: f.patternId || f.id || 'unknown',
79
+ message: f.title || f.description || '',
80
+ file: (f.file || '').replace(HOME, '~'),
81
+ line: m.line,
82
+ });
83
+ }
84
+ if (!(f.matches && f.matches.length)) {
85
+ jsonFindings.push({
86
+ skill: skill.name,
87
+ severity: f.severity,
88
+ patternId: f.patternId || f.id || 'unknown',
89
+ message: f.title || f.description || '',
90
+ file: (f.file || '').replace(HOME, '~'),
91
+ line: null,
92
+ });
93
+ }
94
+ }
95
+ }
96
+
97
+ if (!jsonMode) {
98
+ if (!allFindings.length) { process.stdout.write(` ${paint.green('✓ clean')}\n`); continue; }
60
99
 
61
- const parts = [];
62
- if (critical.length) parts.push(paint.red(`${critical.length} critical`));
63
- if (high.length) parts.push(paint.yellow(`${high.length} high`));
64
- if (medium.length) parts.push(paint.cyan(`${medium.length} medium`));
65
- if (info.length) parts.push(paint.dim(`${info.length} info`));
66
- process.stdout.write(` ${parts.join(', ')}\n`);
100
+ const parts = [];
101
+ if (critical.length) parts.push(paint.red(`${critical.length} critical`));
102
+ if (high.length) parts.push(paint.yellow(`${high.length} high`));
103
+ if (medium.length) parts.push(paint.cyan(`${medium.length} medium`));
104
+ if (info.length) parts.push(paint.dim(`${info.length} info`));
105
+ process.stdout.write(` ${parts.join(', ')}\n`);
106
+ }
67
107
 
68
108
  if (critical.length || high.length || medium.length) {
69
109
  flagged.push({ skill, codeFindings, mdResults });
70
110
  }
71
111
  }
72
112
 
73
- // Detailed report for flagged skills
113
+ // JSON output mode
114
+ if (jsonMode) {
115
+ // Compute scan score: start 100, -25 per CRITICAL, -10 per HIGH, -3 per MEDIUM
116
+ let scanScore = 100;
117
+ for (const f of jsonFindings) {
118
+ if (f.severity === 'CRITICAL') scanScore -= 25;
119
+ else if (f.severity === 'HIGH') scanScore -= 10;
120
+ else if (f.severity === 'MEDIUM') scanScore -= 3;
121
+ }
122
+ scanScore = Math.max(0, scanScore);
123
+
124
+ let verdict = 'PASS';
125
+ if (totalCritical > 0) verdict = 'BLOCK';
126
+ else if (totalHigh > 0) verdict = 'WARN';
127
+
128
+ const output = {
129
+ verdict,
130
+ score: scanScore,
131
+ totalSkills: skills.length,
132
+ flaggedSkills: flagged.length,
133
+ findings: jsonFindings,
134
+ scannedAt: new Date().toISOString(),
135
+ };
136
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
137
+
138
+ auditLogAppend({ cmd: 'scan', trigger: 'manual', score: null, delta: null,
139
+ findings: auditFindings, blocked: null, skill: null });
140
+
141
+ return totalCritical > 0 ? 1 : 0;
142
+ }
143
+
144
+ // Detailed human report for flagged skills
74
145
  for (const {skill, codeFindings, mdResults} of flagged) {
75
146
  console.log(''); console.log(SEP);
76
147
  console.log(` ${paint.bold(skill.name)} ${paint.dim(short(skill.path))}`);