clawarmor 3.0.0 → 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/README.md +59 -22
- 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/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ClawArmor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The security control plane for OpenClaw agents — audit, harden, and orchestrate your full protection stack.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/clawarmor)
|
|
6
6
|
[](LICENSE)
|
|
@@ -8,43 +8,77 @@ Security armor for OpenClaw agents — audit, scan, monitor.
|
|
|
8
8
|
|
|
9
9
|
## What it does
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
AI agent security isn't one tool — it's a stack. ClawArmor is the foundation and control plane:
|
|
12
|
+
|
|
13
|
+
1. **Audits** your OpenClaw config and live gateway — 30+ checks, scored 0–100
|
|
14
|
+
2. **Hardens** your setup — auto-applies safe fixes, snapshots before every change
|
|
15
|
+
3. **Orchestrates** the full security stack — deploys and configures [Invariant Guardrails](https://github.com/invariantlabs-ai/invariant) and [IronCurtain](https://github.com/provos/ironcurtain) based on your audit results
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
clawarmor audit → understand your risk (0–100 score)
|
|
19
|
+
clawarmor stack plan → see what protection stack your risk profile needs
|
|
20
|
+
clawarmor stack deploy → deploy it in one command
|
|
21
|
+
clawarmor stack sync → keep everything aligned after changes
|
|
22
|
+
```
|
|
14
23
|
|
|
15
24
|
## Quick start
|
|
16
25
|
|
|
17
26
|
```bash
|
|
18
27
|
npm install -g clawarmor
|
|
19
|
-
clawarmor protect --install
|
|
20
|
-
clawarmor audit
|
|
28
|
+
clawarmor protect --install # install guard hooks
|
|
29
|
+
clawarmor audit # score your setup
|
|
30
|
+
clawarmor stack deploy --all # deploy full protection stack
|
|
21
31
|
```
|
|
22
32
|
|
|
33
|
+
## The Stack
|
|
34
|
+
|
|
35
|
+
ClawArmor sits at the foundation and orchestrates the layers above it:
|
|
36
|
+
|
|
37
|
+
| Layer | Tool | What it does | ClawArmor role |
|
|
38
|
+
|---|---|---|---|
|
|
39
|
+
| **Foundation** | ClawArmor | Config hygiene, credential checks, skill supply chain | Audits + hardens |
|
|
40
|
+
| **Flow guardrails** | [Invariant](https://github.com/invariantlabs-ai/invariant) | Detects multi-step attack chains at runtime | Generates rules from audit findings |
|
|
41
|
+
| **Runtime sandbox** | [IronCurtain](https://github.com/provos/ironcurtain) | Policy-enforced tool call interception, V8 isolate | Generates constitution from audit findings |
|
|
42
|
+
| **Action gating** | [Latch](https://github.com/latchagent/latch) | Human approval for risky actions via Telegram | Coming in v3.2 |
|
|
43
|
+
|
|
44
|
+
`clawarmor stack deploy` reads your audit score, generates the right config for each tool, and deploys them. `clawarmor stack sync` keeps everything updated as your setup changes.
|
|
45
|
+
|
|
23
46
|
## Commands
|
|
24
47
|
|
|
48
|
+
### Core
|
|
49
|
+
|
|
25
50
|
| Command | Description |
|
|
26
51
|
|---|---|
|
|
27
52
|
| `audit` | Score your OpenClaw config (0–100), live gateway probes, plain-English verdict |
|
|
28
53
|
| `scan` | Scan all installed skill files for malicious code and SKILL.md instructions |
|
|
29
54
|
| `prescan <skill>` | Pre-scan a skill before installing — blocks on CRITICAL findings |
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `protect --status` | Show current protection state |
|
|
33
|
-
| `watch` | Monitor config and skill changes in real time |
|
|
34
|
-
| `watch --daemon` | Start the watcher as a background daemon |
|
|
35
|
-
| `harden` | Interactive hardening wizard (--dry-run, --auto) |
|
|
55
|
+
| `fix` | Auto-apply safe fixes (--dry-run to preview, --apply to run) |
|
|
56
|
+
| `harden` | Interactive hardening wizard (--dry-run, --auto, --monitor) |
|
|
36
57
|
| `status` | One-screen security posture dashboard |
|
|
37
|
-
| `log` | View the audit event log |
|
|
38
|
-
| `digest` | Show weekly security digest |
|
|
39
58
|
| `verify` | Re-run only previously-failed checks (CI-friendly, exit 0 = all fixed) |
|
|
59
|
+
|
|
60
|
+
### Stack Orchestration
|
|
61
|
+
|
|
62
|
+
| Command | Description |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `stack status` | Show all stack components, install state, config state |
|
|
65
|
+
| `stack plan` | Preview what would be deployed based on current audit (no changes) |
|
|
66
|
+
| `stack deploy` | Deploy stack components (--invariant, --ironcurtain, --all) |
|
|
67
|
+
| `stack sync` | Regenerate stack configs from latest audit — run after harden/fix |
|
|
68
|
+
| `stack teardown` | Remove deployed stack components |
|
|
69
|
+
|
|
70
|
+
### History & Monitoring
|
|
71
|
+
|
|
72
|
+
| Command | Description |
|
|
73
|
+
|---|---|
|
|
40
74
|
| `trend` | ASCII chart of your security score over time |
|
|
41
75
|
| `compare` | Compare coverage vs openclaw security audit |
|
|
42
|
-
| `
|
|
76
|
+
| `log` | View the audit event log |
|
|
77
|
+
| `digest` | Show weekly security digest |
|
|
78
|
+
| `watch` | Monitor config and skill changes in real time |
|
|
79
|
+
| `protect --install` | Install guard hook, shell intercept (zsh/bash/fish), and watch daemon |
|
|
43
80
|
| `snapshot` | Save a config snapshot manually (auto-saved before every harden/fix) |
|
|
44
81
|
| `rollback` | Restore config from auto-snapshot (--list, --id <id>) |
|
|
45
|
-
| `harden --monitor` | Enable monitor mode — observe before enforcing |
|
|
46
|
-
| `harden --monitor-report` | Show what monitor mode has observed |
|
|
47
|
-
| `harden --monitor-off` | Disable monitor mode |
|
|
48
82
|
|
|
49
83
|
## What it catches
|
|
50
84
|
|
|
@@ -59,13 +93,14 @@ clawarmor audit
|
|
|
59
93
|
| Live gateway auth | WebSocket probe — does server actually reject unauthenticated connections? | Full |
|
|
60
94
|
| CORS misconfiguration | OPTIONS probe with arbitrary origin | Full |
|
|
61
95
|
| Gateway exposure | TCP-connects to every non-loopback interface | Full |
|
|
62
|
-
|
|
|
96
|
+
| Multi-step attack chains | read→exfil, inject→execute flows (via Invariant) | Full (with stack) |
|
|
97
|
+
| Runtime tool call interception | Policy-enforced sandboxing (via IronCurtain) | Full (with stack) |
|
|
63
98
|
|
|
64
99
|
## Safety features
|
|
65
100
|
|
|
66
|
-
**Impact classification** — Every fix is tagged 🟢 Safe, 🟡 Caution, or 🔴 Breaking. `--auto`
|
|
101
|
+
**Impact classification** — Every fix is tagged 🟢 Safe, 🟡 Caution, or 🔴 Breaking. `--auto` skips breaking changes unless you pass `--force`.
|
|
67
102
|
|
|
68
|
-
**Config snapshots** —
|
|
103
|
+
**Config snapshots** — Auto-saves before every `harden` or `fix` run:
|
|
69
104
|
|
|
70
105
|
```bash
|
|
71
106
|
clawarmor rollback --list # see all snapshots
|
|
@@ -73,7 +108,7 @@ clawarmor rollback # restore the latest
|
|
|
73
108
|
clawarmor rollback --id <n> # restore a specific one
|
|
74
109
|
```
|
|
75
110
|
|
|
76
|
-
**Monitor mode** — Observe what `harden` would
|
|
111
|
+
**Monitor mode** — Observe what `harden` would change before enforcing:
|
|
77
112
|
|
|
78
113
|
```bash
|
|
79
114
|
clawarmor harden --monitor # start monitoring
|
|
@@ -87,6 +122,8 @@ ClawArmor runs entirely on your machine — no telemetry, no cloud, no accounts.
|
|
|
87
122
|
It has zero npm runtime dependencies, using only Node.js built-ins.
|
|
88
123
|
Every run prints exactly what files it reads and what network calls it makes before executing anything.
|
|
89
124
|
|
|
125
|
+
The full security stack for AI agents doesn't exist as one product. ClawArmor is the foundation that ties it together.
|
|
126
|
+
|
|
90
127
|
## License
|
|
91
128
|
|
|
92
129
|
MIT
|
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 {
|