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.
- package/CHANGELOG.md +58 -0
- package/clawgear-skills/clawarmor-live-monitor/SKILL.md +120 -0
- package/clawgear-skills/hardened-operator-baseline/SKILL.md +172 -0
- package/clawgear-skills/incident-response-playbook/SKILL.md +189 -0
- package/clawgear-skills/skill-security-scanner/SKILL.md +170 -0
- package/cli.js +52 -6
- package/lib/audit-quiet.js +89 -0
- package/lib/audit.js +59 -17
- package/lib/baseline-cmd.js +189 -0
- package/lib/baseline.js +106 -0
- package/lib/harden.js +39 -1
- package/lib/incident-cmd.js +201 -0
- package/lib/profile-cmd.js +214 -0
- package/lib/profiles.js +159 -0
- package/lib/protect.js +93 -5
- package/lib/scan.js +88 -17
- package/lib/skill-report.js +124 -0
- package/lib/skill-verify.js +259 -0
- package/lib/stack/invariant.js +5 -2
- package/lib/stack/ironcurtain.js +20 -4
- package/lib/stack.js +40 -19
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/lib/profiles.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
//
|
|
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))}`);
|