clawarmor 3.0.1 → 3.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/cli.js +24 -4
- package/lib/audit.js +59 -17
- package/lib/harden.js +39 -1
- package/lib/profile-cmd.js +214 -0
- package/lib/profiles.js +159 -0
- package/lib/protect.js +93 -5
- package/lib/skill-report.js +124 -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
package/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { paint } from './lib/output/colors.js';
|
|
5
5
|
|
|
6
|
-
const VERSION = '3.
|
|
6
|
+
const VERSION = '3.1.0';
|
|
7
7
|
const GATEWAY_PORT_DEFAULT = 18789;
|
|
8
8
|
|
|
9
9
|
function isLocalhost(host) {
|
|
@@ -51,9 +51,11 @@ function usage() {
|
|
|
51
51
|
console.log(` ${paint.cyan('watch')} Monitor config and skill changes in real time`);
|
|
52
52
|
console.log(` ${paint.cyan('protect')} Install/uninstall/status the full guard system`);
|
|
53
53
|
console.log(` ${paint.cyan('prescan')} Pre-scan a skill before installing it`);
|
|
54
|
-
console.log(` ${paint.cyan('stack')}
|
|
55
|
-
console.log(` ${paint.cyan('
|
|
56
|
-
console.log(` ${paint.cyan('
|
|
54
|
+
console.log(` ${paint.cyan('stack')} Security orchestrator — deploy Invariant + IronCurtain from audit data`);
|
|
55
|
+
console.log(` ${paint.cyan('skill-report')} Show post-install audit impact of last skill install`);
|
|
56
|
+
console.log(` ${paint.cyan('profile')} Manage contextual hardening profiles`);
|
|
57
|
+
console.log(` ${paint.cyan('log')} View the audit event log`);
|
|
58
|
+
console.log(` ${paint.cyan('digest')} Show weekly security digest`);
|
|
57
59
|
console.log('');
|
|
58
60
|
console.log(` ${paint.dim('Flags:')}`);
|
|
59
61
|
console.log(` ${paint.dim('--url <host:port>')} Probe a specific host:port instead of 127.0.0.1`);
|
|
@@ -83,6 +85,9 @@ const parsedUrl = parseUrlFlag(urlArg);
|
|
|
83
85
|
const configIdx = args.indexOf('--config');
|
|
84
86
|
const configPathArg = configIdx !== -1 ? args[configIdx + 1] : null;
|
|
85
87
|
|
|
88
|
+
const profileIdx = args.indexOf('--profile');
|
|
89
|
+
const profileArg = profileIdx !== -1 ? args[profileIdx + 1] : null;
|
|
90
|
+
|
|
86
91
|
const flags = {
|
|
87
92
|
json: args.includes('--json'),
|
|
88
93
|
explainReads: args.includes('--explain-reads'),
|
|
@@ -90,6 +95,7 @@ const flags = {
|
|
|
90
95
|
targetPort: parsedUrl?.port || null,
|
|
91
96
|
configPath: configPathArg || null,
|
|
92
97
|
acceptChanges: args.includes('--accept-changes'),
|
|
98
|
+
profile: profileArg || null,
|
|
93
99
|
};
|
|
94
100
|
|
|
95
101
|
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { usage(); process.exit(0); }
|
|
@@ -201,6 +207,7 @@ if (cmd === 'log') {
|
|
|
201
207
|
}
|
|
202
208
|
|
|
203
209
|
if (cmd === 'harden') {
|
|
210
|
+
const hardenProfileIdx = args.indexOf('--profile');
|
|
204
211
|
const hardenFlags = {
|
|
205
212
|
dryRun: args.includes('--dry-run'),
|
|
206
213
|
auto: args.includes('--auto'),
|
|
@@ -208,6 +215,7 @@ if (cmd === 'harden') {
|
|
|
208
215
|
monitor: args.includes('--monitor'),
|
|
209
216
|
monitorReport: args.includes('--monitor-report'),
|
|
210
217
|
monitorOff: args.includes('--monitor-off'),
|
|
218
|
+
profile: hardenProfileIdx !== -1 ? args[hardenProfileIdx + 1] : null,
|
|
211
219
|
};
|
|
212
220
|
const { runHarden } = await import('./lib/harden.js');
|
|
213
221
|
process.exit(await runHarden(hardenFlags));
|
|
@@ -239,5 +247,17 @@ if (cmd === 'stack') {
|
|
|
239
247
|
process.exit(await runStack(stackArgs));
|
|
240
248
|
}
|
|
241
249
|
|
|
250
|
+
if (cmd === 'skill-report') {
|
|
251
|
+
const { runSkillReport } = await import('./lib/skill-report.js');
|
|
252
|
+
const srFlags = { apply: args.includes('--apply') };
|
|
253
|
+
process.exit(await runSkillReport(srFlags));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (cmd === 'profile') {
|
|
257
|
+
const { runProfileCmd } = await import('./lib/profile-cmd.js');
|
|
258
|
+
const profileArgs = args.slice(1);
|
|
259
|
+
process.exit(await runProfileCmd(profileArgs));
|
|
260
|
+
}
|
|
261
|
+
|
|
242
262
|
console.log(` ${paint.red('✗')} Unknown command: ${paint.bold(cmd)}`);
|
|
243
263
|
usage(); process.exit(1);
|
package/lib/audit.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { loadConfig } from './config.js';
|
|
2
2
|
import { paint, severityColor } from './output/colors.js';
|
|
3
|
+
import { getProfile, isExpectedFinding } from './profiles.js';
|
|
3
4
|
import { progressBar, scoreColor, gradeColor, scoreToGrade } from './output/progress.js';
|
|
4
5
|
import { probeGatewayLive } from './probes/gateway-probe.js';
|
|
5
6
|
import { discoverRunningInstance } from './discovery.js';
|
|
@@ -74,6 +75,19 @@ function appendHistory(entry) {
|
|
|
74
75
|
export async function runAudit(flags = {}) {
|
|
75
76
|
const GATEWAY_PORT_DEFAULT = 18789;
|
|
76
77
|
|
|
78
|
+
// Load active profile (from flag or saved file)
|
|
79
|
+
let profileName = flags.profile || null;
|
|
80
|
+
if (!profileName) {
|
|
81
|
+
try {
|
|
82
|
+
const { readFileSync: rfs, existsSync: efs } = await import('fs');
|
|
83
|
+
const { join: pjoin } = await import('path');
|
|
84
|
+
const { homedir: phome } = await import('os');
|
|
85
|
+
const pFile = pjoin(phome(), '.clawarmor', 'profile.json');
|
|
86
|
+
if (efs(pFile)) profileName = JSON.parse(rfs(pFile, 'utf8')).name || null;
|
|
87
|
+
} catch { /* non-fatal */ }
|
|
88
|
+
}
|
|
89
|
+
const activeProfile = profileName ? getProfile(profileName) : null;
|
|
90
|
+
|
|
77
91
|
// ── DISCOVERY: find what's actually running ──────────────────────────────
|
|
78
92
|
let discovery = null;
|
|
79
93
|
// Only auto-discover when no --url override was given
|
|
@@ -96,6 +110,11 @@ export async function runAudit(flags = {}) {
|
|
|
96
110
|
|
|
97
111
|
console.log(''); console.log(box('ClawArmor Audit v' + VERSION)); console.log('');
|
|
98
112
|
|
|
113
|
+
if (activeProfile) {
|
|
114
|
+
console.log(` ${paint.dim('Profile:')} ${paint.cyan(activeProfile.name)} ${paint.dim('—')} ${activeProfile.description}`);
|
|
115
|
+
console.log('');
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
if (error) {
|
|
100
119
|
console.log(` ${paint.red('✗')} ${error}`); console.log(''); process.exit(2);
|
|
101
120
|
}
|
|
@@ -180,11 +199,25 @@ export async function runAudit(flags = {}) {
|
|
|
180
199
|
const results = [...liveFindingResults, ...staticResults];
|
|
181
200
|
const failed = results.filter(r => !r.passed);
|
|
182
201
|
const passed = results.filter(r => r.passed);
|
|
183
|
-
|
|
202
|
+
|
|
203
|
+
// Annotate expected findings for active profile
|
|
204
|
+
const annotatedFailed = failed.map(f => {
|
|
205
|
+
if (activeProfile && isExpectedFinding(activeProfile.name, f.id)) {
|
|
206
|
+
return { ...f, _profileExpected: true };
|
|
207
|
+
}
|
|
208
|
+
return f;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Score: expected findings don't count against the score
|
|
212
|
+
const scoringFailed = activeProfile
|
|
213
|
+
? annotatedFailed.filter(f => !f._profileExpected)
|
|
214
|
+
: annotatedFailed;
|
|
215
|
+
|
|
216
|
+
const criticals = scoringFailed.filter(r => r.severity === 'CRITICAL').length;
|
|
184
217
|
|
|
185
218
|
// Score with floor rules
|
|
186
219
|
let score = 100;
|
|
187
|
-
for (const f of
|
|
220
|
+
for (const f of scoringFailed) score -= (W[f.severity] || 0);
|
|
188
221
|
score = Math.max(0, score);
|
|
189
222
|
if (criticals >= 2) score = Math.min(score, 25);
|
|
190
223
|
else if (criticals >= 1) score = Math.min(score, 50);
|
|
@@ -196,12 +229,12 @@ export async function runAudit(flags = {}) {
|
|
|
196
229
|
console.log(` ${paint.bold('Security Score:')} ${colorFn(score+'/100')} ${paint.dim('┃')} Grade: ${gradeColor(grade)}`);
|
|
197
230
|
console.log(` ${colorFn(progressBar(score,20))} ${paint.dim(score+'%')}`);
|
|
198
231
|
|
|
199
|
-
// Human verdict
|
|
232
|
+
// Human verdict (uses scoringFailed for accurate verdict)
|
|
200
233
|
{
|
|
201
|
-
const openCriticals =
|
|
202
|
-
const openHighs =
|
|
234
|
+
const openCriticals = scoringFailed.filter(f => f.severity === 'CRITICAL').length;
|
|
235
|
+
const openHighs = scoringFailed.filter(f => f.severity === 'HIGH').length;
|
|
203
236
|
let verdict;
|
|
204
|
-
if (!
|
|
237
|
+
if (!scoringFailed.length) {
|
|
205
238
|
verdict = paint.green('Your instance is secure. No issues found.');
|
|
206
239
|
} else if (openCriticals >= 1) {
|
|
207
240
|
verdict = paint.red('Your instance has CRITICAL exposure. Fix immediately before using.');
|
|
@@ -215,7 +248,7 @@ export async function runAudit(flags = {}) {
|
|
|
215
248
|
}
|
|
216
249
|
|
|
217
250
|
if (flags.json) {
|
|
218
|
-
console.log(JSON.stringify({score,grade,failed,passed},null,2));
|
|
251
|
+
console.log(JSON.stringify({score,grade,failed: annotatedFailed,passed},null,2));
|
|
219
252
|
const histJ = loadHistory();
|
|
220
253
|
const prevScoreJ = histJ.length ? histJ[histJ.length - 1].score : null;
|
|
221
254
|
const deltaJ = prevScoreJ != null ? score - prevScoreJ : null;
|
|
@@ -224,23 +257,30 @@ export async function runAudit(flags = {}) {
|
|
|
224
257
|
trigger: 'manual',
|
|
225
258
|
score,
|
|
226
259
|
delta: deltaJ,
|
|
227
|
-
findings:
|
|
260
|
+
findings: annotatedFailed.map(f => ({ id: f.id, severity: f.severity })),
|
|
228
261
|
blocked: null,
|
|
229
262
|
skill: null,
|
|
230
263
|
});
|
|
231
264
|
appendHistory({ timestamp: new Date().toISOString(), score, grade,
|
|
232
|
-
findings:
|
|
233
|
-
failedIds:
|
|
265
|
+
findings: annotatedFailed.length, criticals, version: VERSION,
|
|
266
|
+
failedIds: annotatedFailed.map(f => f.id) });
|
|
234
267
|
return 0;
|
|
235
268
|
}
|
|
236
269
|
|
|
237
270
|
for (const sev of ['CRITICAL','HIGH','MEDIUM','LOW']) {
|
|
238
|
-
const group =
|
|
271
|
+
const group = annotatedFailed.filter(f => f.severity === sev);
|
|
239
272
|
if (!group.length) continue;
|
|
240
273
|
console.log(''); console.log(SEP);
|
|
241
274
|
console.log(` ${severityColor[sev](sev)}${paint.dim(' ('+group.length+' finding'+(group.length>1?'s':'')+')')}`);
|
|
242
275
|
console.log(SEP);
|
|
243
|
-
for (const f of group)
|
|
276
|
+
for (const f of group) {
|
|
277
|
+
if (f._profileExpected) {
|
|
278
|
+
console.log('');
|
|
279
|
+
console.log(` ${paint.dim('○')} ${paint.dim('[profile: expected]')} ${paint.dim(f.title)}`);
|
|
280
|
+
} else {
|
|
281
|
+
printFinding(f);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
244
284
|
}
|
|
245
285
|
|
|
246
286
|
if (passed.length) {
|
|
@@ -254,10 +294,12 @@ export async function runAudit(flags = {}) {
|
|
|
254
294
|
}
|
|
255
295
|
|
|
256
296
|
console.log(''); console.log(SEP);
|
|
257
|
-
if (!
|
|
297
|
+
if (!scoringFailed.length) {
|
|
258
298
|
console.log(` ${paint.green('✓')} ${paint.bold('All checks passed.')}`);
|
|
259
299
|
} else {
|
|
260
|
-
|
|
300
|
+
const expectedCount = annotatedFailed.length - scoringFailed.length;
|
|
301
|
+
const suffix = expectedCount > 0 ? ` ${paint.dim(`(${expectedCount} expected for profile)`)}` : '';
|
|
302
|
+
console.log(` ${scoringFailed.length} issue${scoringFailed.length>1?'s':''} found. Fix above to improve score.${suffix}`);
|
|
261
303
|
}
|
|
262
304
|
console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor scan')} ${paint.dim('to check installed skills.')}`);
|
|
263
305
|
console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor trend')} ${paint.dim('to see score history.')}`);
|
|
@@ -291,7 +333,7 @@ export async function runAudit(flags = {}) {
|
|
|
291
333
|
trigger: 'manual',
|
|
292
334
|
score,
|
|
293
335
|
delta,
|
|
294
|
-
findings:
|
|
336
|
+
findings: annotatedFailed.map(f => ({ id: f.id, severity: f.severity })),
|
|
295
337
|
blocked: null,
|
|
296
338
|
skill: null,
|
|
297
339
|
});
|
|
@@ -301,10 +343,10 @@ export async function runAudit(flags = {}) {
|
|
|
301
343
|
timestamp: new Date().toISOString(),
|
|
302
344
|
score,
|
|
303
345
|
grade,
|
|
304
|
-
findings:
|
|
346
|
+
findings: annotatedFailed.length,
|
|
305
347
|
criticals,
|
|
306
348
|
version: VERSION,
|
|
307
|
-
failedIds:
|
|
349
|
+
failedIds: annotatedFailed.map(f => f.id),
|
|
308
350
|
});
|
|
309
351
|
|
|
310
352
|
return failed.length > 0 ? 1 : 0;
|
package/lib/harden.js
CHANGED
|
@@ -15,6 +15,7 @@ import { scoreToGrade, scoreColor, gradeColor } from './output/progress.js';
|
|
|
15
15
|
import { loadConfig, get } from './config.js';
|
|
16
16
|
import { saveSnapshot } from './snapshot.js';
|
|
17
17
|
import { enableMonitor, disableMonitor, getMonitorStatus, printMonitorReport } from './monitor.js';
|
|
18
|
+
import { getProfile, isExpectedFinding, getOverriddenSeverity } from './profiles.js';
|
|
18
19
|
|
|
19
20
|
const HOME = homedir();
|
|
20
21
|
const OC_DIR = join(HOME, '.openclaw');
|
|
@@ -255,10 +256,47 @@ export async function runHarden(flags = {}) {
|
|
|
255
256
|
return 0;
|
|
256
257
|
}
|
|
257
258
|
|
|
259
|
+
// Load active profile (from flag or saved file)
|
|
260
|
+
let profileName = flags.profile || null;
|
|
261
|
+
if (!profileName) {
|
|
262
|
+
try {
|
|
263
|
+
const { readFileSync: rfs, existsSync: efs } = await import('fs');
|
|
264
|
+
const { join: pjoin } = await import('path');
|
|
265
|
+
const { homedir: phome } = await import('os');
|
|
266
|
+
const pFile = pjoin(phome(), '.clawarmor', 'profile.json');
|
|
267
|
+
if (efs(pFile)) profileName = JSON.parse(rfs(pFile, 'utf8')).name || null;
|
|
268
|
+
} catch { /* non-fatal */ }
|
|
269
|
+
}
|
|
270
|
+
const profile = profileName ? getProfile(profileName) : null;
|
|
271
|
+
|
|
258
272
|
console.log(''); console.log(box('ClawArmor Harden v2.1')); console.log('');
|
|
259
273
|
|
|
274
|
+
if (profile) {
|
|
275
|
+
console.log(` ${paint.dim('Profile:')} ${paint.cyan(profile.name)} ${paint.dim('—')} ${profile.description}`);
|
|
276
|
+
console.log('');
|
|
277
|
+
}
|
|
278
|
+
|
|
260
279
|
const { config, configPath } = loadConfig();
|
|
261
|
-
const
|
|
280
|
+
const allFixes = buildFixes(config);
|
|
281
|
+
|
|
282
|
+
// When profile is set, skip or adjust fixes for expected capabilities
|
|
283
|
+
const fixes = allFixes.map(fix => {
|
|
284
|
+
if (!profile) return fix;
|
|
285
|
+
const overrideSev = getOverriddenSeverity(profile.name, fix.id);
|
|
286
|
+
const expected = isExpectedFinding(profile.name, fix.id);
|
|
287
|
+
if (expected) {
|
|
288
|
+
// Mark as expected — skip entirely from harden output
|
|
289
|
+
return { ...fix, _skipForProfile: true };
|
|
290
|
+
}
|
|
291
|
+
if (overrideSev) {
|
|
292
|
+
// Upgrade to higher severity if unexpected for this profile
|
|
293
|
+
const currentWeight = { SAFE: 1, CAUTION: 2, BREAKING: 3 };
|
|
294
|
+
const upgradeMap = { HIGH: IMPACT.BREAKING, MEDIUM: IMPACT.CAUTION, INFO: IMPACT.SAFE };
|
|
295
|
+
const newImpact = upgradeMap[overrideSev] || fix.impact;
|
|
296
|
+
return { ...fix, impact: newImpact, _profileOverride: overrideSev };
|
|
297
|
+
}
|
|
298
|
+
return fix;
|
|
299
|
+
}).filter(fix => !fix._skipForProfile);
|
|
262
300
|
|
|
263
301
|
// ── Monitor enable (advisory only, no apply) ───────────────────────────────
|
|
264
302
|
if (flags.monitor) {
|
|
@@ -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(', ');
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// lib/skill-report.js — Show post-install skill audit impact report
|
|
2
|
+
// Generated automatically after each skill install via the clawarmor-guard hook.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { homedir } from 'os';
|
|
7
|
+
import { spawnSync } from 'child_process';
|
|
8
|
+
import { paint } from './output/colors.js';
|
|
9
|
+
|
|
10
|
+
const HOME = homedir();
|
|
11
|
+
const CLAWARMOR_DIR = join(HOME, '.clawarmor');
|
|
12
|
+
const SKILL_REPORT_FILE = join(CLAWARMOR_DIR, 'skill-install-report.json');
|
|
13
|
+
const SEP = paint.dim('─'.repeat(52));
|
|
14
|
+
|
|
15
|
+
function box(title) {
|
|
16
|
+
const W = 52, pad = W - 2 - title.length, l = Math.floor(pad / 2), r = pad - l;
|
|
17
|
+
return [
|
|
18
|
+
paint.dim('╔' + '═'.repeat(W - 2) + '╗'),
|
|
19
|
+
paint.dim('║') + ' '.repeat(l) + paint.bold(title) + ' '.repeat(r) + paint.dim('║'),
|
|
20
|
+
paint.dim('╚' + '═'.repeat(W - 2) + '╝'),
|
|
21
|
+
].join('\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readReport() {
|
|
25
|
+
try {
|
|
26
|
+
if (!existsSync(SKILL_REPORT_FILE)) return null;
|
|
27
|
+
return JSON.parse(readFileSync(SKILL_REPORT_FILE, 'utf8'));
|
|
28
|
+
} catch { return null; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function applyFixes(fixes) {
|
|
32
|
+
let applied = 0, failed = 0;
|
|
33
|
+
for (const fix of fixes) {
|
|
34
|
+
try {
|
|
35
|
+
const result = spawnSync(fix, { shell: true, encoding: 'utf8', timeout: 30000, stdio: 'pipe' });
|
|
36
|
+
if (result.status === 0) {
|
|
37
|
+
console.log(` ${paint.green('✓')} ${fix}`);
|
|
38
|
+
applied++;
|
|
39
|
+
} else {
|
|
40
|
+
console.log(` ${paint.red('✗')} ${fix} — ${(result.stderr || '').split('\n')[0]}`);
|
|
41
|
+
failed++;
|
|
42
|
+
}
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.log(` ${paint.red('✗')} ${fix} — ${e.message?.split('\n')[0]}`);
|
|
45
|
+
failed++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { applied, failed };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function runSkillReport(flags = {}) {
|
|
52
|
+
console.log(''); console.log(box('ClawArmor Skill Report')); console.log('');
|
|
53
|
+
|
|
54
|
+
const report = readReport();
|
|
55
|
+
|
|
56
|
+
if (!report) {
|
|
57
|
+
console.log(` ${paint.dim('No skill install report found.')}`);
|
|
58
|
+
console.log(` ${paint.dim('Reports are generated automatically after each skill install.')}`);
|
|
59
|
+
console.log('');
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const installedAt = new Date(report.installedAt).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
|
|
64
|
+
const deltaStr = report.scoreDelta > 0
|
|
65
|
+
? paint.green(`+${report.scoreDelta}`)
|
|
66
|
+
: report.scoreDelta < 0
|
|
67
|
+
? paint.red(String(report.scoreDelta))
|
|
68
|
+
: paint.dim('±0');
|
|
69
|
+
|
|
70
|
+
console.log(` ${paint.bold('Skill:')} ${report.skill}`);
|
|
71
|
+
console.log(` ${paint.bold('Installed:')} ${installedAt}`);
|
|
72
|
+
console.log(` ${paint.bold('Score:')} ${report.scoreBefore}/100 → ${report.scoreAfter}/100 (${deltaStr})`);
|
|
73
|
+
console.log('');
|
|
74
|
+
|
|
75
|
+
if (report.newFindings && report.newFindings.length > 0) {
|
|
76
|
+
console.log(SEP);
|
|
77
|
+
console.log(` ${paint.bold('New findings after install:')}`);
|
|
78
|
+
console.log('');
|
|
79
|
+
for (const f of report.newFindings) {
|
|
80
|
+
const sev = f.severity || 'INFO';
|
|
81
|
+
const sevColors = { CRITICAL: paint.red, HIGH: paint.red, MEDIUM: paint.yellow, LOW: paint.dim, INFO: paint.dim };
|
|
82
|
+
const sevColor = sevColors[sev] || paint.dim;
|
|
83
|
+
console.log(` ${paint.red('✗')} ${paint.bold(f.title || f.id)} ${paint.dim('←')} ${sevColor(sev)}`);
|
|
84
|
+
if (f.description) {
|
|
85
|
+
for (const line of (f.description || '').split('\n').slice(0, 2)) {
|
|
86
|
+
console.log(` ${paint.dim(line)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
console.log('');
|
|
91
|
+
} else {
|
|
92
|
+
console.log(` ${paint.green('✓')} No new findings introduced by this install.`);
|
|
93
|
+
console.log('');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (report.proposedFixes && report.proposedFixes.length > 0) {
|
|
97
|
+
console.log(SEP);
|
|
98
|
+
console.log(` ${paint.bold('Proposed fixes:')}`);
|
|
99
|
+
console.log('');
|
|
100
|
+
for (const fix of report.proposedFixes) {
|
|
101
|
+
console.log(` ${paint.cyan('$')} ${fix}`);
|
|
102
|
+
}
|
|
103
|
+
console.log('');
|
|
104
|
+
|
|
105
|
+
if (flags.apply) {
|
|
106
|
+
console.log(SEP);
|
|
107
|
+
console.log(` ${paint.cyan('Applying proposed fixes...')}`);
|
|
108
|
+
console.log('');
|
|
109
|
+
const { applied, failed } = applyFixes(report.proposedFixes);
|
|
110
|
+
console.log('');
|
|
111
|
+
console.log(` Applied: ${paint.green(String(applied))} Failed: ${failed > 0 ? paint.red(String(failed)) : paint.dim('0')}`);
|
|
112
|
+
console.log('');
|
|
113
|
+
return failed > 0 ? 1 : 0;
|
|
114
|
+
} else {
|
|
115
|
+
console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor skill-report --apply')} ${paint.dim('to apply these fixes.')}`);
|
|
116
|
+
console.log('');
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
console.log(` ${paint.dim('No auto-fixable issues proposed.')}`);
|
|
120
|
+
console.log('');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
package/lib/stack/invariant.js
CHANGED
|
@@ -207,7 +207,7 @@ export function deploy(rulesContent) {
|
|
|
207
207
|
|
|
208
208
|
/**
|
|
209
209
|
* Get current status of Invariant integration.
|
|
210
|
-
* @returns {{ installed: boolean, rulesExist: boolean, rulesPath: string, ruleCount: number, lastDeployed: string|null }}
|
|
210
|
+
* @returns {{ installed: boolean, rulesExist: boolean, rulesPath: string, ruleCount: number, lastDeployed: string|null, enforcing: boolean }}
|
|
211
211
|
*/
|
|
212
212
|
export function getStatus() {
|
|
213
213
|
const installed = checkInstalled();
|
|
@@ -216,9 +216,12 @@ export function getStatus() {
|
|
|
216
216
|
if (rulesExist) {
|
|
217
217
|
try {
|
|
218
218
|
const content = readFileSync(RULES_PATH, 'utf8');
|
|
219
|
+
// Count non-comment rules (lines starting with 'raise')
|
|
219
220
|
ruleCount = (content.match(/^raise /gm) || []).length;
|
|
220
221
|
lastDeployed = statSync(RULES_PATH).mtime.toISOString();
|
|
221
222
|
} catch { /* non-fatal */ }
|
|
222
223
|
}
|
|
223
|
-
|
|
224
|
+
// enforcing = pip installed + rules file exists + at least 1 non-comment rule
|
|
225
|
+
const enforcing = installed && rulesExist && ruleCount > 0;
|
|
226
|
+
return { installed, rulesExist, rulesPath: RULES_PATH, ruleCount, lastDeployed, enforcing };
|
|
224
227
|
}
|
package/lib/stack/ironcurtain.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// IronCurtain: English constitution → LLM compiles to deterministic rules → runtime enforcement.
|
|
3
3
|
// We generate the Markdown constitution from audit findings. User runs compile-policy themselves.
|
|
4
4
|
|
|
5
|
-
import { existsSync, writeFileSync, mkdirSync, statSync } from 'fs';
|
|
5
|
+
import { existsSync, writeFileSync, mkdirSync, statSync, readdirSync } from 'fs';
|
|
6
6
|
import { join } from 'path';
|
|
7
7
|
import { homedir } from 'os';
|
|
8
8
|
import { spawnSync } from 'child_process';
|
|
@@ -180,16 +180,32 @@ export function writeConstitution(content) {
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Check if ~/.ironcurtain/generated/ exists and has compiled output.
|
|
185
|
+
* @returns {boolean}
|
|
186
|
+
*/
|
|
187
|
+
function checkCompiled() {
|
|
188
|
+
const generatedDir = join(IRONCURTAIN_DIR, 'generated');
|
|
189
|
+
if (!existsSync(generatedDir)) return false;
|
|
190
|
+
try {
|
|
191
|
+
const entries = readdirSync(generatedDir);
|
|
192
|
+
return entries.length > 0;
|
|
193
|
+
} catch { return false; }
|
|
194
|
+
}
|
|
195
|
+
|
|
183
196
|
/**
|
|
184
197
|
* Get current status of IronCurtain integration.
|
|
185
|
-
* @returns {{ installed: boolean, constitutionExists: boolean, constitutionPath: string, lastGenerated: string|null }}
|
|
198
|
+
* @returns {{ installed: boolean, constitutionExists: boolean, constitutionPath: string, lastGenerated: string|null, compiled: boolean, enforcing: boolean }}
|
|
186
199
|
*/
|
|
187
200
|
export function getStatus() {
|
|
188
|
-
const
|
|
201
|
+
const cliInstalled = checkInstalled();
|
|
189
202
|
const constitutionExists = existsSync(CONSTITUTION_PATH);
|
|
203
|
+
const compiled = checkCompiled();
|
|
190
204
|
let lastGenerated = null;
|
|
191
205
|
if (constitutionExists) {
|
|
192
206
|
try { lastGenerated = statSync(CONSTITUTION_PATH).mtime.toISOString(); } catch { /* non-fatal */ }
|
|
193
207
|
}
|
|
194
|
-
|
|
208
|
+
// enforcing = cli installed + compiled output exists
|
|
209
|
+
const enforcing = cliInstalled && compiled;
|
|
210
|
+
return { installed: cliInstalled, cliInstalled, constitutionExists, constitutionPath: CONSTITUTION_PATH, lastGenerated, compiled, enforcing };
|
|
195
211
|
}
|
package/lib/stack.js
CHANGED
|
@@ -8,7 +8,7 @@ import * as Invariant from './stack/invariant.js';
|
|
|
8
8
|
import * as IronCurtain from './stack/ironcurtain.js';
|
|
9
9
|
|
|
10
10
|
const SEP = paint.dim('─'.repeat(52));
|
|
11
|
-
const VERSION = '3.
|
|
11
|
+
const VERSION = '3.1.0';
|
|
12
12
|
|
|
13
13
|
function box(title) {
|
|
14
14
|
const W = 52, pad = W - 2 - title.length, l = Math.floor(pad / 2), r = pad - l;
|
|
@@ -39,41 +39,62 @@ async function stackStatus() {
|
|
|
39
39
|
|
|
40
40
|
// Invariant
|
|
41
41
|
const inv = Invariant.getStatus();
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
let invIcon, invStatus;
|
|
43
|
+
if (inv.enforcing) {
|
|
44
|
+
invIcon = paint.green('✓');
|
|
45
|
+
invStatus = `${paint.green('✓ actively enforcing')} ${paint.dim(`(${inv.ruleCount} rule${inv.ruleCount !== 1 ? 's' : ''})`)}`;
|
|
46
|
+
} else if (inv.rulesExist && inv.ruleCount > 0) {
|
|
47
|
+
invIcon = paint.yellow('○');
|
|
48
|
+
invStatus = `${paint.yellow('✓ rules generated')} ${paint.dim(`(${inv.ruleCount} rule${inv.ruleCount !== 1 ? 's' : ''}, not enforcing)`)}`;
|
|
49
|
+
} else {
|
|
50
|
+
invIcon = paint.yellow('○');
|
|
51
|
+
invStatus = paint.dim('not deployed');
|
|
52
|
+
}
|
|
46
53
|
const invPip = inv.installed ? paint.green('pip: installed') : paint.yellow('pip: not installed');
|
|
47
54
|
console.log(` ${invIcon} ${paint.bold('Invariant')} ${invStatus}`);
|
|
48
55
|
console.log(` ${paint.dim('Flow guardrails — detects multi-step attack chains')}`);
|
|
49
56
|
console.log(` ${paint.dim(invPip)}`);
|
|
50
57
|
if (!inv.rulesExist) {
|
|
51
58
|
console.log(` ${paint.dim('→ run: clawarmor stack deploy --invariant')}`);
|
|
59
|
+
} else if (!inv.enforcing) {
|
|
60
|
+
console.log(` ${paint.yellow('⚠')} Rules generated but not enforcing — install invariant-ai to activate: ${paint.cyan('pip3 install invariant-ai')}`);
|
|
52
61
|
}
|
|
53
62
|
console.log('');
|
|
54
63
|
|
|
55
64
|
// IronCurtain
|
|
56
65
|
const ic = IronCurtain.getStatus();
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
66
|
+
let icIcon, icStatus;
|
|
67
|
+
if (ic.enforcing) {
|
|
68
|
+
icIcon = paint.green('✓');
|
|
69
|
+
icStatus = paint.green('✓ compiled + running');
|
|
70
|
+
} else if (ic.constitutionExists) {
|
|
71
|
+
icIcon = paint.yellow('○');
|
|
72
|
+
icStatus = `${paint.yellow('✓ constitution written')} ${paint.dim('(not compiled)')}`;
|
|
73
|
+
} else {
|
|
74
|
+
icIcon = paint.yellow('○');
|
|
75
|
+
icStatus = paint.dim('not configured');
|
|
76
|
+
}
|
|
77
|
+
const icCli = ic.cliInstalled ? paint.green('cli: installed') : paint.yellow('cli: not installed');
|
|
62
78
|
console.log(` ${icIcon} ${paint.bold('IronCurtain')} ${icStatus}`);
|
|
63
79
|
console.log(` ${paint.dim('Runtime constitution — policy-enforced tool call interception')}`);
|
|
64
80
|
console.log(` ${paint.dim(icCli)}`);
|
|
65
81
|
if (!ic.constitutionExists) {
|
|
66
82
|
console.log(` ${paint.dim('→ run: clawarmor stack deploy --ironcurtain')}`);
|
|
67
|
-
} else {
|
|
68
|
-
console.log(` ${paint.
|
|
83
|
+
} else if (!ic.enforcing) {
|
|
84
|
+
console.log(` ${paint.yellow('⚠')} Constitution written but not compiled — run: ${paint.cyan('ironcurtain compile-policy ~/.ironcurtain/constitution-clawarmor.md')}`);
|
|
69
85
|
}
|
|
70
86
|
console.log('');
|
|
71
87
|
|
|
72
88
|
console.log(SEP);
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
89
|
+
// Count enforcing layers, not just deployed
|
|
90
|
+
const enforcingLayers = (inv.enforcing ? 1 : 0) + (ic.enforcing ? 1 : 0);
|
|
91
|
+
const layerColor = enforcingLayers >= 2 ? paint.green : enforcingLayers === 1 ? paint.yellow : paint.red;
|
|
92
|
+
console.log(` Stack coverage: ${layerColor(String(enforcingLayers))} / 2 layers enforcing`);
|
|
93
|
+
if (enforcingLayers < 2) {
|
|
94
|
+
const generatedLayers = (inv.rulesExist ? 1 : 0) + (ic.constitutionExists ? 1 : 0);
|
|
95
|
+
if (generatedLayers > enforcingLayers) {
|
|
96
|
+
console.log(` ${paint.dim(`(${generatedLayers} layer${generatedLayers !== 1 ? 's' : ''} generated but not yet enforcing)`)}`);
|
|
97
|
+
}
|
|
77
98
|
console.log(` ${paint.dim('→ run: clawarmor stack deploy --all')}`);
|
|
78
99
|
}
|
|
79
100
|
console.log('');
|
|
@@ -240,9 +261,9 @@ async function stackDeploy(flags) {
|
|
|
240
261
|
console.log(SEP);
|
|
241
262
|
const invS = Invariant.getStatus();
|
|
242
263
|
const icS = IronCurtain.getStatus();
|
|
243
|
-
const
|
|
244
|
-
const layerColor =
|
|
245
|
-
console.log(` Stack coverage: ${layerColor(String(
|
|
264
|
+
const deployedLayers = (invS.rulesExist ? 1 : 0) + (icS.constitutionExists ? 1 : 0);
|
|
265
|
+
const layerColor = deployedLayers >= 2 ? paint.green : deployedLayers === 1 ? paint.yellow : paint.red;
|
|
266
|
+
console.log(` Stack coverage: ${layerColor(String(deployedLayers))} / 2 layers generated`);
|
|
246
267
|
if (exitCode === 0) {
|
|
247
268
|
console.log(` ${paint.green('✓')} Done. Run ${paint.cyan('clawarmor stack status')} to verify.`);
|
|
248
269
|
} else {
|