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 CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { paint } from './lib/output/colors.js';
5
5
 
6
- const VERSION = '3.0.0';
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')} Security orchestrator — deploy Invariant + IronCurtain from audit data`);
55
- console.log(` ${paint.cyan('log')} View the audit event log`);
56
- console.log(` ${paint.cyan('digest')} Show weekly security digest`);
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
- const criticals = failed.filter(r => r.severity === 'CRITICAL').length;
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 failed) score -= (W[f.severity] || 0);
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 = failed.filter(f => f.severity === 'CRITICAL').length;
202
- const openHighs = failed.filter(f => f.severity === 'HIGH').length;
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 (!failed.length) {
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: failed.map(f => ({ id: f.id, severity: f.severity })),
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: failed.length, criticals, version: VERSION,
233
- failedIds: failed.map(f => f.id) });
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 = failed.filter(f => f.severity === sev);
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) printFinding(f);
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 (!failed.length) {
297
+ if (!scoringFailed.length) {
258
298
  console.log(` ${paint.green('✓')} ${paint.bold('All checks passed.')}`);
259
299
  } else {
260
- console.log(` ${failed.length} issue${failed.length>1?'s':''} found. Fix above to improve score.`);
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: failed.map(f => ({ id: f.id, severity: f.severity })),
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: failed.length,
346
+ findings: annotatedFailed.length,
305
347
  criticals,
306
348
  version: VERSION,
307
- failedIds: failed.map(f => f.id),
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 fixes = buildFixes(config);
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
+ }
@@ -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(', ');
@@ -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
+ }
@@ -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
- return { installed, rulesExist, rulesPath: RULES_PATH, ruleCount, lastDeployed };
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
  }
@@ -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 installed = checkInstalled();
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
- return { installed, constitutionExists, constitutionPath: CONSTITUTION_PATH, lastGenerated };
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.0.0';
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
- const invIcon = inv.rulesExist ? paint.green('✓') : paint.yellow('○');
43
- const invStatus = inv.rulesExist
44
- ? `${paint.green('deployed')} ${paint.dim(`(${inv.ruleCount} rule${inv.ruleCount !== 1 ? 's' : ''}, ~/.clawarmor/invariant-rules.inv)`)}`
45
- : paint.dim('not deployed');
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
- const icIcon = ic.constitutionExists ? paint.green('✓') : paint.yellow('○');
58
- const icStatus = ic.constitutionExists
59
- ? `${paint.green('constitution exists')} ${paint.dim('(~/.ironcurtain/constitution-clawarmor.md)')}`
60
- : paint.dim('not configured');
61
- const icCli = ic.installed ? paint.green('cli: installed') : paint.yellow('cli: not installed');
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.dim(' compile: ironcurtain compile-policy ~/.ironcurtain/constitution-clawarmor.md')}`);
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
- const layers = (inv.rulesExist ? 1 : 0) + (ic.constitutionExists ? 1 : 0);
74
- const layerColor = layers >= 2 ? paint.green : layers === 1 ? paint.yellow : paint.red;
75
- console.log(` Stack coverage: ${layerColor(String(layers))} / 2 layers active`);
76
- if (layers < 2) {
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 layers = (invS.rulesExist ? 1 : 0) + (icS.constitutionExists ? 1 : 0);
244
- const layerColor = layers >= 2 ? paint.green : layers === 1 ? paint.yellow : paint.red;
245
- console.log(` Stack coverage: ${layerColor(String(layers))} / 2 layers active`);
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawarmor",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "Security armor for OpenClaw agents — audit, scan, monitor",
5
5
  "bin": {
6
6
  "clawarmor": "cli.js"