clawarmor 2.2.1 → 3.0.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # ClawArmor
2
2
 
3
- Security armor for OpenClaw agents — audit, scan, monitor.
3
+ The security control plane for OpenClaw agents — audit, harden, and orchestrate your full protection stack.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/clawarmor?color=3fb950&label=npm&style=flat-square)](https://www.npmjs.com/package/clawarmor)
6
6
  [![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE)
@@ -8,43 +8,77 @@ Security armor for OpenClaw agents — audit, scan, monitor.
8
8
 
9
9
  ## What it does
10
10
 
11
- - Audits your OpenClaw config and live gateway with 30+ checks scored 0–100
12
- - Scans every installed skill file for malicious code and prompt injection patterns
13
- - Guards every install: intercepts `openclaw clawhub install`, pre-scans before activation
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
- | `protect --install` | Install guard hook, shell intercept (zsh/bash/fish), and watch daemon |
31
- | `protect --uninstall` | Remove all ClawArmor protection components |
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
- | `fix` | Auto-apply safe fixes (--dry-run to preview, --apply to run) |
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
- | Runtime policy enforcement | Requires a runtime layer (SupraWall) | None |
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` mode skips breaking changes unless you pass `--force`.
101
+ **Impact classification** — Every fix is tagged 🟢 Safe, 🟡 Caution, or 🔴 Breaking. `--auto` skips breaking changes unless you pass `--force`.
67
102
 
68
- **Config snapshots** — ClawArmor auto-saves your config before every `harden` or `fix` run. If something breaks, roll back instantly:
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 do before enforcing:
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 = '2.2.0';
6
+ const VERSION = '3.0.0';
7
7
  const GATEWAY_PORT_DEFAULT = 18789;
8
8
 
9
9
  function isLocalhost(host) {
@@ -51,6 +51,7 @@ 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`);
54
55
  console.log(` ${paint.cyan('log')} View the audit event log`);
55
56
  console.log(` ${paint.cyan('digest')} Show weekly security digest`);
56
57
  console.log('');
@@ -232,5 +233,11 @@ if (cmd === 'digest') {
232
233
  process.exit(await runDigest());
233
234
  }
234
235
 
236
+ if (cmd === 'stack') {
237
+ const { runStack } = await import('./lib/stack.js');
238
+ const stackArgs = args.slice(1);
239
+ process.exit(await runStack(stackArgs));
240
+ }
241
+
235
242
  console.log(` ${paint.red('✗')} Unknown command: ${paint.bold(cmd)}`);
236
243
  usage(); process.exit(1);
@@ -0,0 +1,91 @@
1
+ // lib/stack/index.js — Stack orchestrator
2
+ // Reads latest audit result from ~/.clawarmor/audit.log (JSONL),
3
+ // maps score + findings to a risk profile, and determines deployment plan.
4
+
5
+ import { existsSync, readFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { homedir } from 'os';
8
+
9
+ const HOME = homedir();
10
+ const AUDIT_LOG = join(HOME, '.clawarmor', 'audit.log');
11
+
12
+ /**
13
+ * Read the latest scored audit entry from ~/.clawarmor/audit.log (JSONL).
14
+ * Prefers entries where score !== null (cmd==='audit'), skips scan entries.
15
+ * Falls back to last-score.json combined with history.json if no scored entry found.
16
+ */
17
+ function readLatestAudit() {
18
+ if (existsSync(AUDIT_LOG)) {
19
+ try {
20
+ const lines = readFileSync(AUDIT_LOG, 'utf8').split('\n').filter(Boolean);
21
+ for (let i = lines.length - 1; i >= 0; i--) {
22
+ try {
23
+ const entry = JSON.parse(lines[i]);
24
+ if (entry && entry.score != null) return entry;
25
+ } catch { /* skip bad lines */ }
26
+ }
27
+ } catch { /* non-fatal */ }
28
+ }
29
+ // Fallback: last-score.json (minimal — no findings detail)
30
+ const lastScoreFile = join(HOME, '.clawarmor', 'last-score.json');
31
+ if (existsSync(lastScoreFile)) {
32
+ try {
33
+ const s = JSON.parse(readFileSync(lastScoreFile, 'utf8'));
34
+ if (s && s.score != null) return { score: s.score, grade: s.grade, findings: [] };
35
+ } catch { /* non-fatal */ }
36
+ }
37
+ return null;
38
+ }
39
+
40
+ /**
41
+ * Map audit score + findings to a risk profile.
42
+ * @param {Object|null} audit
43
+ * @returns {{ level: string, label: string, score: number|null, findings: Array }}
44
+ */
45
+ export function getRiskProfile(audit) {
46
+ if (!audit) return { level: 'unknown', label: 'No audit data', score: null, findings: [] };
47
+ const score = audit.score ?? null;
48
+ const findings = audit.findings ?? [];
49
+ let level, label;
50
+ if (score == null) { level = 'unknown'; label = 'Unknown risk'; }
51
+ else if (score < 50) { level = 'critical'; label = 'Critical / High risk'; }
52
+ else if (score < 75) { level = 'medium'; label = 'Medium risk'; }
53
+ else { level = 'low'; label = 'Low risk'; }
54
+ return { level, label, score, findings };
55
+ }
56
+
57
+ /**
58
+ * Read latest audit + derive risk profile.
59
+ * @returns {Promise<{ audit: Object|null, profile: Object }>}
60
+ */
61
+ export async function getStackStatus() {
62
+ const audit = readLatestAudit();
63
+ const profile = getRiskProfile(audit);
64
+ return { audit, profile };
65
+ }
66
+
67
+ /**
68
+ * Get recommended deployment plan based on risk profile.
69
+ * @param {Object} profile - from getRiskProfile()
70
+ * @returns {{ invariant: boolean, ironcurtain: boolean, reason: string }}
71
+ */
72
+ export function getPlan(profile) {
73
+ const { level } = profile;
74
+ if (level === 'critical') return {
75
+ invariant: true, ironcurtain: true,
76
+ reason: 'Critical risk: deploy Invariant flow guardrails + generate IronCurtain constitution',
77
+ };
78
+ if (level === 'medium') return {
79
+ invariant: true, ironcurtain: true,
80
+ reason: 'Medium risk: deploy Invariant flow guardrails + generate IronCurtain constitution',
81
+ };
82
+ if (level === 'low') return {
83
+ invariant: false, ironcurtain: true,
84
+ reason: 'Low risk: generate IronCurtain constitution as reference hardening',
85
+ };
86
+ // unknown — no audit yet
87
+ return {
88
+ invariant: true, ironcurtain: true,
89
+ reason: 'No audit data: run clawarmor audit first for precise recommendations',
90
+ };
91
+ }
@@ -0,0 +1,224 @@
1
+ // lib/stack/invariant.js — Invariant Guardrails integration
2
+ // Invariant is a Python guardrailing library (invariantlabs-ai/invariant).
3
+ // Rules are plain Invariant DSL strings generated from audit findings.
4
+
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { homedir } from 'os';
8
+ import { execSync, spawnSync } from 'child_process';
9
+
10
+ const HOME = homedir();
11
+ const CLAWARMOR_DIR = join(HOME, '.clawarmor');
12
+ const RULES_PATH = join(CLAWARMOR_DIR, 'invariant-rules.inv');
13
+
14
+ /** Check if invariant-ai Python package is installed. */
15
+ export function checkInstalled() {
16
+ try {
17
+ const r = spawnSync('pip3', ['show', 'invariant-ai'], { encoding: 'utf8', timeout: 10000 });
18
+ return r.status === 0 && !!(r.stdout && r.stdout.includes('Name:'));
19
+ } catch { return false; }
20
+ }
21
+
22
+ /**
23
+ * Install invariant-ai via pip3.
24
+ * @returns {{ ok: boolean, err?: string }}
25
+ */
26
+ export function install() {
27
+ try {
28
+ execSync('pip3 install invariant-ai', { stdio: 'pipe', timeout: 120000 });
29
+ return { ok: true };
30
+ } catch (e) {
31
+ return { ok: false, err: e.message?.split('\n')[0] || 'pip3 install failed' };
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Generate Invariant DSL rules from audit findings.
37
+ *
38
+ * Key mappings (from research):
39
+ * exec.security=full + exec.ask=off → flag unrestricted exec tool calls
40
+ * channels with no allowFrom → flag ungated message sends
41
+ * credential files world-readable → block reads on sensitive paths
42
+ * skill supply chain findings → block tool calls from unverified skill sources
43
+ * elevated.allowFrom empty → flag escalated calls from non-allowlisted sources
44
+ *
45
+ * @param {Array} findings - array of { id, severity, title, detail }
46
+ * @returns {string} Invariant policy (.inv) text
47
+ */
48
+ export function generateRules(findings) {
49
+ const now = new Date().toISOString().slice(0, 10);
50
+ const lines = [
51
+ `# ClawArmor-Generated Invariant Policy`,
52
+ `# Generated: ${now} by clawarmor stack`,
53
+ `# Source: clawarmor stack deploy --invariant`,
54
+ '',
55
+ ];
56
+
57
+ if (!findings || !findings.length) {
58
+ lines.push('# No findings from latest audit — no findings-specific rules generated.');
59
+ lines.push('# Run: clawarmor audit, then: clawarmor stack sync');
60
+ lines.push('');
61
+ lines.push('# Generic fallback: prompt injection via web tool → send_message');
62
+ lines.push('raise "Possible prompt injection via tool output" if:');
63
+ lines.push(' (output: ToolOutput) -> (call2: ToolCall)');
64
+ lines.push(' output is tool:get_website');
65
+ lines.push(' prompt_injection(output.content, threshold=0.7)');
66
+ lines.push(' call2 is tool:send_message');
67
+ lines.push('');
68
+ return lines.join('\n');
69
+ }
70
+
71
+ const generated = new Set();
72
+
73
+ for (const finding of findings) {
74
+ const id = (finding.id || '').toLowerCase();
75
+ const detail = (finding.detail || '').toLowerCase();
76
+ const title = (finding.title || '').toLowerCase();
77
+ // label shown in rule comment — id is always present; title/severity optional
78
+ const label = finding.title || finding.severity || finding.id;
79
+
80
+ // exec.ask=off / exec unrestricted → flag bare exec tool calls
81
+ // Matches: exec.approval, exec.ask, tools.exec.*
82
+ if (
83
+ !generated.has('exec.unrestricted') &&
84
+ (id.includes('exec') || title.includes('exec')) &&
85
+ (id.includes('ask') || id.includes('approval') || id.includes('exec') ||
86
+ detail.includes('ask') || title.includes('approval') || title.includes('unrestricted') ||
87
+ detail.includes('unrestricted'))
88
+ ) {
89
+ generated.add('exec.unrestricted');
90
+ lines.push(`# Finding: ${finding.id} — ${label}`);
91
+ lines.push(`raise "Unrestricted exec tool call detected (no approval gate)" if:`);
92
+ lines.push(` (call: ToolCall)`);
93
+ lines.push(` call is tool:exec`);
94
+ lines.push('');
95
+ }
96
+
97
+ // channels with no allowFrom / open group policy → ungated message sends
98
+ // Matches: channel.groupPolicy, channel.allowFrom, channels.*
99
+ if (
100
+ !generated.has('channel.ungated') &&
101
+ (id.includes('channel') || title.includes('channel')) &&
102
+ (id.includes('allow') || id.includes('group') || id.includes('policy') ||
103
+ detail.includes('allowfrom') || title.includes('restriction') ||
104
+ title.includes('gate') || title.includes('group') || detail.includes('open'))
105
+ ) {
106
+ generated.add('channel.ungated');
107
+ lines.push(`# Finding: ${finding.id} — ${label}`);
108
+ lines.push(`raise "Message sent via ungated channel (no allowFrom restriction)" if:`);
109
+ lines.push(` (call: ToolCall) -> (call2: ToolCall)`);
110
+ lines.push(` call is tool:read_file`);
111
+ lines.push(` call2 is tool:send_message({channel: ".*"})`);
112
+ lines.push('');
113
+ }
114
+
115
+ // credential files / secret exposure → block reads on sensitive paths
116
+ // Matches: cred.*, credential-file, cred.json_secrets, filesystem.perms
117
+ if (
118
+ !generated.has('cred.worldread') &&
119
+ (id.includes('cred') || id.includes('credential') || id.includes('filesystem') ||
120
+ id.includes('secret')) &&
121
+ (id.includes('perm') || id.includes('secret') || id.includes('file') ||
122
+ detail.includes('world') || detail.includes('readable') || detail.includes('permission') ||
123
+ title.includes('world') || title.includes('permission') || title.includes('secret'))
124
+ ) {
125
+ generated.add('cred.worldread');
126
+ lines.push(`# Finding: ${finding.id} — ${label}`);
127
+ lines.push(`raise "File read on sensitive credential path" if:`);
128
+ lines.push(` (call: ToolCall)`);
129
+ lines.push(` call is tool:read_file`);
130
+ lines.push(` any(s in str(call.args.get("path", "")) for s in [".ssh", ".aws", "agent-accounts", ".openclaw"])`);
131
+ lines.push('');
132
+ }
133
+
134
+ // skill supply chain — block tool calls from unverified sources
135
+ // Matches: skill.pinning, skill.supplychain, skills.*
136
+ if (
137
+ !generated.has('skill.supplychain') &&
138
+ id.includes('skill') &&
139
+ (id.includes('pin') || id.includes('supply') || id.includes('md') ||
140
+ detail.includes('supply') || detail.includes('unverified') ||
141
+ title.includes('pin') || title.includes('supply'))
142
+ ) {
143
+ generated.add('skill.supplychain');
144
+ lines.push(`# Finding: ${finding.id} — ${label}`);
145
+ lines.push(`raise "Tool call from unverified skill source (no version pin)" if:`);
146
+ lines.push(` (call: ToolCall)`);
147
+ lines.push(` not call.metadata.get("skill_verified", False)`);
148
+ lines.push('');
149
+ }
150
+
151
+ // elevated permissions with no allowFrom restriction
152
+ // Matches: elevated.allowFrom, tools.elevated.*, allowFrom.*
153
+ if (
154
+ !generated.has('elevated.ungated') &&
155
+ (id.includes('elevated') || id.includes('allowfrom') || title.includes('elevated'))
156
+ ) {
157
+ generated.add('elevated.ungated');
158
+ lines.push(`# Finding: ${finding.id} — ${label}`);
159
+ lines.push(`raise "Elevated tool call from ungated source" if:`);
160
+ lines.push(` (call: ToolCall)`);
161
+ lines.push(` call.metadata.get("elevated", False)`);
162
+ lines.push(` not call.metadata.get("allowFrom_restricted", False)`);
163
+ lines.push('');
164
+ }
165
+ }
166
+
167
+ if (!generated.size) {
168
+ lines.push('# No specific rule mappings matched. Generic fallback:');
169
+ lines.push('');
170
+ lines.push('raise "Possible prompt injection via tool output" if:');
171
+ lines.push(' (output: ToolOutput) -> (call2: ToolCall)');
172
+ lines.push(' output is tool:get_website');
173
+ lines.push(' prompt_injection(output.content, threshold=0.7)');
174
+ lines.push(' call2 is tool:send_message');
175
+ lines.push('');
176
+ }
177
+
178
+ return lines.join('\n');
179
+ }
180
+
181
+ /**
182
+ * Write rules to ~/.clawarmor/invariant-rules.inv and validate syntax if invariant-ai is installed.
183
+ * @param {string} rulesContent
184
+ * @returns {{ ok: boolean, err?: string, rulesPath: string }}
185
+ */
186
+ export function deploy(rulesContent) {
187
+ try {
188
+ if (!existsSync(CLAWARMOR_DIR)) mkdirSync(CLAWARMOR_DIR, { recursive: true });
189
+ writeFileSync(RULES_PATH, rulesContent, 'utf8');
190
+
191
+ // Validate syntax if invariant-ai is available (non-fatal if not)
192
+ if (checkInstalled()) {
193
+ const v = spawnSync('python3', [
194
+ '-c',
195
+ `from invariant.analyzer import LocalPolicy; LocalPolicy.from_file('${RULES_PATH}'); print('ok')`,
196
+ ], { encoding: 'utf8', timeout: 30000 });
197
+ if (v.status !== 0) {
198
+ const msg = (v.stderr || '').split('\n')[0];
199
+ return { ok: false, err: `Syntax validation failed: ${msg}`, rulesPath: RULES_PATH };
200
+ }
201
+ }
202
+ return { ok: true, rulesPath: RULES_PATH };
203
+ } catch (e) {
204
+ return { ok: false, err: e.message?.split('\n')[0] || 'deploy failed', rulesPath: RULES_PATH };
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Get current status of Invariant integration.
210
+ * @returns {{ installed: boolean, rulesExist: boolean, rulesPath: string, ruleCount: number, lastDeployed: string|null }}
211
+ */
212
+ export function getStatus() {
213
+ const installed = checkInstalled();
214
+ const rulesExist = existsSync(RULES_PATH);
215
+ let ruleCount = 0, lastDeployed = null;
216
+ if (rulesExist) {
217
+ try {
218
+ const content = readFileSync(RULES_PATH, 'utf8');
219
+ ruleCount = (content.match(/^raise /gm) || []).length;
220
+ lastDeployed = statSync(RULES_PATH).mtime.toISOString();
221
+ } catch { /* non-fatal */ }
222
+ }
223
+ return { installed, rulesExist, rulesPath: RULES_PATH, ruleCount, lastDeployed };
224
+ }
@@ -0,0 +1,195 @@
1
+ // lib/stack/ironcurtain.js — IronCurtain integration
2
+ // IronCurtain: English constitution → LLM compiles to deterministic rules → runtime enforcement.
3
+ // We generate the Markdown constitution from audit findings. User runs compile-policy themselves.
4
+
5
+ import { existsSync, writeFileSync, mkdirSync, statSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { homedir } from 'os';
8
+ import { spawnSync } from 'child_process';
9
+
10
+ const HOME = homedir();
11
+ const IRONCURTAIN_DIR = join(HOME, '.ironcurtain');
12
+ const CONSTITUTION_PATH = join(IRONCURTAIN_DIR, 'constitution-clawarmor.md');
13
+
14
+ /** Check if ironcurtain CLI is available (npm -g). */
15
+ export function checkInstalled() {
16
+ try {
17
+ const r = spawnSync('ironcurtain', ['--version'], { encoding: 'utf8', timeout: 5000 });
18
+ return r.status === 0;
19
+ } catch { return false; }
20
+ }
21
+
22
+ /**
23
+ * Generate an IronCurtain constitution Markdown from audit findings.
24
+ *
25
+ * Key mappings (from research):
26
+ * exec.ask=off → Escalate: All exec tool calls require human approval
27
+ * elevated.allowFrom empty → Forbidden: Elevated operations without allowFrom restrictions
28
+ * channels.groupPolicy=open → Escalate: Messages to external groups require approval
29
+ * credential world-readable → Forbidden: Reading lax-permission credential files
30
+ * skill supply chain → Forbidden: Loading unverified/unpinned skills
31
+ *
32
+ * @param {Array} findings - array of { id, severity, title, detail }
33
+ * @returns {string} Markdown constitution text
34
+ */
35
+ export function generateConstitution(findings) {
36
+ const now = new Date().toISOString().slice(0, 10);
37
+ const lines = [
38
+ `# ClawArmor-Generated Constitution`,
39
+ `_Generated: ${now} by clawarmor stack deploy --ironcurtain_`,
40
+ '',
41
+ ];
42
+
43
+ const allowed = [
44
+ 'Read and write files only within the project workspace directory',
45
+ 'Search the web for public information',
46
+ 'View git status and git log',
47
+ ];
48
+ const escalate = [];
49
+ const forbidden = [
50
+ 'Accessing ~/.ssh, ~/.aws, ~/.openclaw, or other credential files outside the workspace',
51
+ 'Deleting files outside the project workspace directory',
52
+ ];
53
+
54
+ if (!findings || !findings.length) {
55
+ // Conservative defaults when no audit data
56
+ escalate.push('All exec tool calls (no audit data — applying conservative defaults)');
57
+ escalate.push('Any network request to external URLs');
58
+ } else {
59
+ const added = new Set();
60
+ for (const finding of findings) {
61
+ const id = (finding.id || '').toLowerCase();
62
+ const detail = (finding.detail || '').toLowerCase();
63
+ const title = (finding.title || '').toLowerCase();
64
+ const ref = `(audit: ${finding.id})`; // eslint-disable-line no-unused-vars
65
+
66
+ // exec.ask=off → escalate exec
67
+ if (
68
+ !added.has('exec') &&
69
+ (id.includes('exec') || title.includes('exec')) &&
70
+ (id.includes('ask') || id.includes('approval') || detail.includes('ask') ||
71
+ title.includes('approval') || title.includes('unrestricted') || detail.includes('unrestricted'))
72
+ ) {
73
+ added.add('exec');
74
+ escalate.push(`All exec tool calls require human approval ${ref}`);
75
+ }
76
+
77
+ // elevated.allowFrom empty → forbidden elevated ops
78
+ // Matches: elevated.*, tools.elevated.*, allowfrom.*
79
+ if (
80
+ !added.has('elevated') &&
81
+ (id.includes('elevated') || id.includes('allowfrom') || title.includes('elevated'))
82
+ ) {
83
+ added.add('elevated');
84
+ forbidden.push(`Elevated operations without explicit allowFrom restrictions ${ref}`);
85
+ }
86
+
87
+ // channels with no allowFrom / group policy open → escalate external messages
88
+ // Matches: channel.groupPolicy, channel.allowFrom, channels.*
89
+ if (
90
+ !added.has('channel') &&
91
+ (id.includes('channel') || title.includes('channel')) &&
92
+ (id.includes('allow') || id.includes('group') || id.includes('policy') ||
93
+ detail.includes('allowfrom') || detail.includes('open') ||
94
+ title.includes('restriction') || title.includes('gate') || title.includes('group'))
95
+ ) {
96
+ added.add('channel');
97
+ escalate.push(`Messages sent to external groups or channels require approval ${ref}`);
98
+ }
99
+
100
+ // gateway.host=0.0.0.0 → forbidden external gateway exposure
101
+ if (
102
+ !added.has('gateway') &&
103
+ id.includes('gateway') && (id.includes('host') || detail.includes('0.0.0.0'))
104
+ ) {
105
+ added.add('gateway');
106
+ forbidden.push(`Exposing the gateway to all network interfaces ${ref}`);
107
+ }
108
+
109
+ // credential files world-readable / secret exposure
110
+ // Matches: cred.*, credential-file, cred.json_secrets, filesystem.perms
111
+ if (
112
+ !added.has('cred.perm') &&
113
+ (id.includes('cred') || id.includes('credential') || id.includes('filesystem') ||
114
+ id.includes('secret')) &&
115
+ (id.includes('perm') || id.includes('secret') || id.includes('file') ||
116
+ detail.includes('world') || detail.includes('readable') || detail.includes('permission') ||
117
+ title.includes('permission') || title.includes('world') || title.includes('secret'))
118
+ ) {
119
+ added.add('cred.perm');
120
+ forbidden.push(`Reading credential files with overly permissive file modes ${ref}`);
121
+ }
122
+
123
+ // skill supply chain
124
+ if (
125
+ !added.has('skill') &&
126
+ id.includes('skill') &&
127
+ (id.includes('pin') || id.includes('supply') || detail.includes('supply') ||
128
+ detail.includes('unverified') || title.includes('pin') || title.includes('supply'))
129
+ ) {
130
+ added.add('skill');
131
+ forbidden.push(`Loading unverified skills or skills without pinned versions ${ref}`);
132
+ }
133
+ }
134
+
135
+ if (!escalate.length) {
136
+ escalate.push('Any shell command execution');
137
+ escalate.push('Any network request to external URLs not required by the task');
138
+ }
139
+ }
140
+
141
+ lines.push('## Allowed');
142
+ for (const item of allowed) lines.push(`- ${item}`);
143
+ lines.push('');
144
+
145
+ if (escalate.length) {
146
+ lines.push('## Escalate (require human approval)');
147
+ for (const item of escalate) lines.push(`- ${item}`);
148
+ lines.push('');
149
+ }
150
+
151
+ lines.push('## Forbidden');
152
+ for (const item of forbidden) lines.push(`- ${item}`);
153
+ lines.push('');
154
+
155
+ lines.push('---');
156
+ lines.push('_To compile into deterministic rules:_');
157
+ lines.push('```');
158
+ lines.push('ironcurtain compile-policy ~/.ironcurtain/constitution-clawarmor.md');
159
+ lines.push('```');
160
+ lines.push('_To update after a new audit:_');
161
+ lines.push('```');
162
+ lines.push('clawarmor stack sync');
163
+ lines.push('```');
164
+
165
+ return lines.join('\n');
166
+ }
167
+
168
+ /**
169
+ * Write constitution to ~/.ironcurtain/constitution-clawarmor.md.
170
+ * @param {string} content
171
+ * @returns {{ ok: boolean, err?: string, path: string }}
172
+ */
173
+ export function writeConstitution(content) {
174
+ try {
175
+ if (!existsSync(IRONCURTAIN_DIR)) mkdirSync(IRONCURTAIN_DIR, { recursive: true });
176
+ writeFileSync(CONSTITUTION_PATH, content, 'utf8');
177
+ return { ok: true, path: CONSTITUTION_PATH };
178
+ } catch (e) {
179
+ return { ok: false, err: e.message?.split('\n')[0] || 'write failed', path: CONSTITUTION_PATH };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Get current status of IronCurtain integration.
185
+ * @returns {{ installed: boolean, constitutionExists: boolean, constitutionPath: string, lastGenerated: string|null }}
186
+ */
187
+ export function getStatus() {
188
+ const installed = checkInstalled();
189
+ const constitutionExists = existsSync(CONSTITUTION_PATH);
190
+ let lastGenerated = null;
191
+ if (constitutionExists) {
192
+ try { lastGenerated = statSync(CONSTITUTION_PATH).mtime.toISOString(); } catch { /* non-fatal */ }
193
+ }
194
+ return { installed, constitutionExists, constitutionPath: CONSTITUTION_PATH, lastGenerated };
195
+ }
package/lib/stack.js ADDED
@@ -0,0 +1,410 @@
1
+ // lib/stack.js — ClawArmor stack command handler
2
+ // Subcommands: status, plan, deploy [--invariant|--ironcurtain|--all], sync, teardown
3
+
4
+ import { unlinkSync } from 'fs';
5
+ import { paint } from './output/colors.js';
6
+ import { getStackStatus, getPlan } from './stack/index.js';
7
+ import * as Invariant from './stack/invariant.js';
8
+ import * as IronCurtain from './stack/ironcurtain.js';
9
+
10
+ const SEP = paint.dim('─'.repeat(52));
11
+ const VERSION = '3.0.0';
12
+
13
+ function box(title) {
14
+ const W = 52, pad = W - 2 - title.length, l = Math.floor(pad / 2), r = pad - l;
15
+ return [
16
+ paint.dim('╔' + '═'.repeat(W - 2) + '╗'),
17
+ paint.dim('║') + ' '.repeat(l) + paint.bold(title) + ' '.repeat(r) + paint.dim('║'),
18
+ paint.dim('╚' + '═'.repeat(W - 2) + '╝'),
19
+ ].join('\n');
20
+ }
21
+
22
+ function riskBadge(level) {
23
+ if (level === 'critical') return paint.critical('⬤ Critical');
24
+ if (level === 'medium') return paint.yellow('⬤ Medium');
25
+ if (level === 'low') return paint.green('⬤ Low');
26
+ return paint.dim('⬤ Unknown');
27
+ }
28
+
29
+ // ── status ────────────────────────────────────────────────────────────────────
30
+
31
+ async function stackStatus() {
32
+ console.log(''); console.log(box(`ClawArmor Stack v${VERSION}`)); console.log('');
33
+
34
+ const { audit, profile } = await getStackStatus();
35
+ const scoreNote = profile.score != null ? `(audit score: ${profile.score}/100)` : '(no audit data)';
36
+ console.log(` ${paint.dim('Risk profile')} ${riskBadge(profile.level)} ${paint.dim(scoreNote)}`);
37
+ console.log('');
38
+ console.log(SEP);
39
+
40
+ // Invariant
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');
46
+ const invPip = inv.installed ? paint.green('pip: installed') : paint.yellow('pip: not installed');
47
+ console.log(` ${invIcon} ${paint.bold('Invariant')} ${invStatus}`);
48
+ console.log(` ${paint.dim('Flow guardrails — detects multi-step attack chains')}`);
49
+ console.log(` ${paint.dim(invPip)}`);
50
+ if (!inv.rulesExist) {
51
+ console.log(` ${paint.dim('→ run: clawarmor stack deploy --invariant')}`);
52
+ }
53
+ console.log('');
54
+
55
+ // IronCurtain
56
+ 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');
62
+ console.log(` ${icIcon} ${paint.bold('IronCurtain')} ${icStatus}`);
63
+ console.log(` ${paint.dim('Runtime constitution — policy-enforced tool call interception')}`);
64
+ console.log(` ${paint.dim(icCli)}`);
65
+ if (!ic.constitutionExists) {
66
+ console.log(` ${paint.dim('→ run: clawarmor stack deploy --ironcurtain')}`);
67
+ } else {
68
+ console.log(` ${paint.dim('→ compile: ironcurtain compile-policy ~/.ironcurtain/constitution-clawarmor.md')}`);
69
+ }
70
+ console.log('');
71
+
72
+ 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) {
77
+ console.log(` ${paint.dim('→ run: clawarmor stack deploy --all')}`);
78
+ }
79
+ console.log('');
80
+ return 0;
81
+ }
82
+
83
+ // ── plan ──────────────────────────────────────────────────────────────────────
84
+
85
+ async function stackPlan() {
86
+ console.log(''); console.log(box(`ClawArmor Stack v${VERSION}`)); console.log('');
87
+ console.log(` ${paint.cyan('Plan — what would be deployed (no changes made):')}`);
88
+ console.log('');
89
+
90
+ const { audit, profile } = await getStackStatus();
91
+ const plan = getPlan(profile);
92
+
93
+ console.log(` ${paint.dim('Risk profile')} ${riskBadge(profile.level)}`);
94
+ if (profile.score != null) console.log(` ${paint.dim('Audit score')} ${profile.score}/100`);
95
+ if (audit?.findings?.length) console.log(` ${paint.dim('Findings')} ${audit.findings.length} total`);
96
+ console.log('');
97
+ console.log(` ${paint.dim('Deployment rationale:')}`);
98
+ console.log(` ${plan.reason}`);
99
+ console.log('');
100
+ console.log(SEP);
101
+ console.log(` ${paint.bold('Components:')}`);
102
+ console.log('');
103
+
104
+ const inv = Invariant.getStatus();
105
+ const invMark = plan.invariant
106
+ ? (inv.rulesExist ? paint.dim('○ already deployed') : paint.green('→ would deploy'))
107
+ : paint.dim('– not recommended for this risk level');
108
+ console.log(` Invariant ${invMark}`);
109
+ if (plan.invariant && !inv.rulesExist) {
110
+ console.log(` ${paint.dim('Would: pip3 install invariant-ai')}`);
111
+ console.log(` ${paint.dim('Would: generate rules from ' + (audit?.findings?.length || 0) + ' audit findings')}`);
112
+ console.log(` ${paint.dim('Would: write ~/.clawarmor/invariant-rules.inv')}`);
113
+ }
114
+ console.log('');
115
+
116
+ const ic = IronCurtain.getStatus();
117
+ const icMark = plan.ironcurtain
118
+ ? (ic.constitutionExists ? paint.dim('○ already deployed') : paint.green('→ would deploy'))
119
+ : paint.dim('– not recommended for this risk level');
120
+ console.log(` IronCurtain ${icMark}`);
121
+ if (plan.ironcurtain && !ic.constitutionExists) {
122
+ console.log(` ${paint.dim('Would: generate constitution from audit findings')}`);
123
+ console.log(` ${paint.dim('Would: write ~/.ironcurtain/constitution-clawarmor.md')}`);
124
+ console.log(` ${paint.dim('Then: ironcurtain compile-policy (manual step)')}`);
125
+ }
126
+ console.log('');
127
+
128
+ console.log(SEP);
129
+ console.log(` ${paint.dim('To apply: clawarmor stack deploy --all')}`);
130
+ console.log('');
131
+ return 0;
132
+ }
133
+
134
+ // ── deploy ────────────────────────────────────────────────────────────────────
135
+
136
+ async function stackDeploy(flags) {
137
+ const doInvariant = flags.invariant || flags.all;
138
+ const doIronCurtain = flags.ironcurtain || flags.all;
139
+
140
+ console.log(''); console.log(box(`ClawArmor Stack v${VERSION}`)); console.log('');
141
+
142
+ if (!doInvariant && !doIronCurtain) {
143
+ console.log(` ${paint.yellow('!')} Specify a component to deploy:`);
144
+ console.log(` ${paint.cyan('clawarmor stack deploy --invariant')}`);
145
+ console.log(` ${paint.cyan('clawarmor stack deploy --ironcurtain')}`);
146
+ console.log(` ${paint.cyan('clawarmor stack deploy --all')}`);
147
+ console.log('');
148
+ return 1;
149
+ }
150
+
151
+ const { audit, profile } = await getStackStatus();
152
+ const findings = audit?.findings ?? [];
153
+
154
+ console.log(` ${paint.dim('Risk profile')} ${riskBadge(profile.level)}`);
155
+ if (findings.length) {
156
+ console.log(` ${paint.dim('Findings')} ${findings.length} — generating configs from audit data`);
157
+ } else {
158
+ console.log(` ${paint.dim('Findings')} ${paint.dim('none — run clawarmor audit first for best results')}`);
159
+ }
160
+ console.log('');
161
+
162
+ let exitCode = 0;
163
+
164
+ // ── Invariant ──────────────────────────────────────────────────────────────
165
+ if (doInvariant) {
166
+ console.log(SEP);
167
+ console.log(` ${paint.bold('Invariant')} — flow guardrails`);
168
+ console.log('');
169
+
170
+ const alreadyInstalled = Invariant.checkInstalled();
171
+ if (!alreadyInstalled) {
172
+ process.stdout.write(` ${paint.dim('Installing invariant-ai via pip3...')} `);
173
+ const result = Invariant.install();
174
+ if (result.ok) {
175
+ process.stdout.write(paint.green('✓\n'));
176
+ } else {
177
+ process.stdout.write(paint.red('✗\n'));
178
+ console.log(` ${paint.red('Error:')} ${result.err}`);
179
+ console.log(` ${paint.dim('Try manually: pip3 install invariant-ai')}`);
180
+ exitCode = 1;
181
+ }
182
+ } else {
183
+ console.log(` ${paint.green('✓')} invariant-ai already installed`);
184
+ }
185
+
186
+ process.stdout.write(` ${paint.dim('Generating rules from audit findings...')} `);
187
+ const rules = Invariant.generateRules(findings);
188
+ process.stdout.write(paint.green('✓\n'));
189
+
190
+ process.stdout.write(` ${paint.dim('Writing ~/.clawarmor/invariant-rules.inv...')} `);
191
+ const deployResult = Invariant.deploy(rules);
192
+ if (deployResult.ok) {
193
+ process.stdout.write(paint.green('✓\n'));
194
+ const s = Invariant.getStatus();
195
+ console.log(` ${paint.green('✓')} Deployed: ${s.ruleCount} rule${s.ruleCount !== 1 ? 's' : ''}`);
196
+ console.log(` ${paint.dim('Path:')} ${deployResult.rulesPath}`);
197
+ } else {
198
+ process.stdout.write(paint.red('✗\n'));
199
+ console.log(` ${paint.red('Error:')} ${deployResult.err}`);
200
+ exitCode = 1;
201
+ }
202
+ console.log('');
203
+ }
204
+
205
+ // ── IronCurtain ────────────────────────────────────────────────────────────
206
+ if (doIronCurtain) {
207
+ console.log(SEP);
208
+ console.log(` ${paint.bold('IronCurtain')} — runtime constitution`);
209
+ console.log('');
210
+
211
+ const icInstalled = IronCurtain.checkInstalled();
212
+ if (icInstalled) {
213
+ console.log(` ${paint.green('✓')} ironcurtain CLI installed`);
214
+ } else {
215
+ console.log(` ${paint.yellow('○')} ironcurtain CLI not installed ${paint.dim('(constitution generated regardless)')}`);
216
+ console.log(` ${paint.dim('Install: npm install -g ironcurtain')}`);
217
+ }
218
+
219
+ process.stdout.write(` ${paint.dim('Generating constitution from audit findings...')} `);
220
+ const constitution = IronCurtain.generateConstitution(findings);
221
+ process.stdout.write(paint.green('✓\n'));
222
+
223
+ process.stdout.write(` ${paint.dim('Writing ~/.ironcurtain/constitution-clawarmor.md...')} `);
224
+ const writeResult = IronCurtain.writeConstitution(constitution);
225
+ if (writeResult.ok) {
226
+ process.stdout.write(paint.green('✓\n'));
227
+ console.log(` ${paint.green('✓')} Constitution written: ${writeResult.path}`);
228
+ } else {
229
+ process.stdout.write(paint.red('✗\n'));
230
+ console.log(` ${paint.red('Error:')} ${writeResult.err}`);
231
+ exitCode = 1;
232
+ }
233
+
234
+ console.log('');
235
+ console.log(` ${paint.yellow('!')} Next step — compile the constitution into deterministic rules:`);
236
+ console.log(` ${paint.dim('ironcurtain compile-policy ~/.ironcurtain/constitution-clawarmor.md')}`);
237
+ console.log('');
238
+ }
239
+
240
+ console.log(SEP);
241
+ const invS = Invariant.getStatus();
242
+ 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`);
246
+ if (exitCode === 0) {
247
+ console.log(` ${paint.green('✓')} Done. Run ${paint.cyan('clawarmor stack status')} to verify.`);
248
+ } else {
249
+ console.log(` ${paint.yellow('!')} Completed with errors. Check output above.`);
250
+ }
251
+ console.log('');
252
+ return exitCode;
253
+ }
254
+
255
+ // ── sync ──────────────────────────────────────────────────────────────────────
256
+
257
+ async function stackSync() {
258
+ console.log(''); console.log(box(`ClawArmor Stack v${VERSION}`)); console.log('');
259
+ console.log(` ${paint.cyan('Sync — regenerating stack configs from latest audit...')}`);
260
+ console.log('');
261
+
262
+ const { audit, profile } = await getStackStatus();
263
+ if (!audit) {
264
+ console.log(` ${paint.yellow('!')} No audit data found.`);
265
+ console.log(` ${paint.dim('Run clawarmor audit first, then clawarmor stack sync.')}`);
266
+ console.log('');
267
+ return 1;
268
+ }
269
+
270
+ const findings = audit.findings ?? [];
271
+ console.log(` ${paint.dim('Audit score')} ${profile.score ?? 'n/a'}/100 ${paint.dim('(' + findings.length + ' findings)')}`);
272
+ console.log('');
273
+
274
+ const invStatus = Invariant.getStatus();
275
+ const icStatus = IronCurtain.getStatus();
276
+ let updated = 0;
277
+
278
+ if (invStatus.rulesExist) {
279
+ process.stdout.write(` ${paint.dim('Invariant: regenerating rules...')} `);
280
+ const rules = Invariant.generateRules(findings);
281
+ const result = Invariant.deploy(rules);
282
+ if (result.ok) { process.stdout.write(paint.green('✓\n')); updated++; }
283
+ else { process.stdout.write(paint.red('✗\n')); console.log(` ${paint.dim(result.err)}`); }
284
+ } else {
285
+ console.log(` ${paint.dim('Invariant: not deployed — skipping')}`);
286
+ }
287
+
288
+ if (icStatus.constitutionExists) {
289
+ process.stdout.write(` ${paint.dim('IronCurtain: regenerating constitution...')} `);
290
+ const constitution = IronCurtain.generateConstitution(findings);
291
+ const result = IronCurtain.writeConstitution(constitution);
292
+ if (result.ok) { process.stdout.write(paint.green('✓\n')); updated++; }
293
+ else { process.stdout.write(paint.red('✗\n')); console.log(` ${paint.dim(result.err)}`); }
294
+ } else {
295
+ console.log(` ${paint.dim('IronCurtain: not deployed — skipping')}`);
296
+ }
297
+
298
+ console.log('');
299
+ if (updated > 0) {
300
+ console.log(` ${paint.green('✓')} Synced ${updated} component${updated !== 1 ? 's' : ''} from latest audit.`);
301
+ if (icStatus.constitutionExists && IronCurtain.checkInstalled()) {
302
+ console.log(` ${paint.yellow('!')} Re-compile IronCurtain constitution to take effect:`);
303
+ console.log(` ${paint.dim('ironcurtain compile-policy ~/.ironcurtain/constitution-clawarmor.md')}`);
304
+ }
305
+ } else {
306
+ console.log(` ${paint.dim('Nothing synced. Run clawarmor stack deploy --all to set up the stack.')}`);
307
+ }
308
+ console.log('');
309
+ return 0;
310
+ }
311
+
312
+ // ── teardown ──────────────────────────────────────────────────────────────────
313
+
314
+ async function stackTeardown(flags) {
315
+ const doInvariant = flags.invariant || flags.all;
316
+ const doIronCurtain = flags.ironcurtain || flags.all;
317
+
318
+ console.log(''); console.log(box(`ClawArmor Stack v${VERSION}`)); console.log('');
319
+
320
+ if (!doInvariant && !doIronCurtain) {
321
+ console.log(` ${paint.yellow('!')} Specify a component to teardown:`);
322
+ console.log(` ${paint.cyan('clawarmor stack teardown --invariant')}`);
323
+ console.log(` ${paint.cyan('clawarmor stack teardown --ironcurtain')}`);
324
+ console.log(` ${paint.cyan('clawarmor stack teardown --all')}`);
325
+ console.log('');
326
+ return 1;
327
+ }
328
+
329
+ console.log(` ${paint.cyan('Teardown — removing deployed stack components...')}`);
330
+ console.log('');
331
+
332
+ const invStatus = Invariant.getStatus();
333
+ const icStatus = IronCurtain.getStatus();
334
+ let removed = 0;
335
+
336
+ if (doInvariant) {
337
+ if (invStatus.rulesExist) {
338
+ try {
339
+ unlinkSync(invStatus.rulesPath);
340
+ console.log(` ${paint.green('✓')} Invariant rules removed: ${invStatus.rulesPath}`);
341
+ removed++;
342
+ } catch (e) {
343
+ console.log(` ${paint.red('✗')} Failed to remove Invariant rules: ${e.message?.split('\n')[0]}`);
344
+ }
345
+ } else {
346
+ console.log(` ${paint.dim('○')} Invariant rules not found — nothing to remove`);
347
+ }
348
+ }
349
+
350
+ if (doIronCurtain) {
351
+ if (icStatus.constitutionExists) {
352
+ try {
353
+ unlinkSync(icStatus.constitutionPath);
354
+ console.log(` ${paint.green('✓')} IronCurtain constitution removed: ${icStatus.constitutionPath}`);
355
+ removed++;
356
+ } catch (e) {
357
+ console.log(` ${paint.red('✗')} Failed to remove IronCurtain constitution: ${e.message?.split('\n')[0]}`);
358
+ }
359
+ } else {
360
+ console.log(` ${paint.dim('○')} IronCurtain constitution not found — nothing to remove`);
361
+ }
362
+ }
363
+
364
+ console.log('');
365
+ if (removed > 0) {
366
+ console.log(` ${paint.green('✓')} Teardown complete. Removed ${removed} component${removed !== 1 ? 's' : ''}.`);
367
+ } else {
368
+ console.log(` ${paint.dim('Nothing removed.')}`);
369
+ }
370
+ console.log('');
371
+ return 0;
372
+ }
373
+
374
+ // ── Main export ───────────────────────────────────────────────────────────────
375
+
376
+ export async function runStack(args = []) {
377
+ const sub = args[0];
378
+
379
+ if (!sub || sub === 'status') return stackStatus();
380
+ if (sub === 'plan') return stackPlan();
381
+ if (sub === 'sync') return stackSync();
382
+
383
+ if (sub === 'deploy') {
384
+ return stackDeploy({
385
+ invariant: args.includes('--invariant'),
386
+ ironcurtain: args.includes('--ironcurtain'),
387
+ all: args.includes('--all'),
388
+ });
389
+ }
390
+
391
+ if (sub === 'teardown') {
392
+ return stackTeardown({
393
+ invariant: args.includes('--invariant'),
394
+ ironcurtain: args.includes('--ironcurtain'),
395
+ all: args.includes('--all'),
396
+ });
397
+ }
398
+
399
+ console.log('');
400
+ console.log(` ${paint.red('✗')} Unknown stack subcommand: ${paint.bold(sub)}`);
401
+ console.log('');
402
+ console.log(` ${paint.bold('Stack subcommands:')}`);
403
+ console.log(` ${paint.cyan('clawarmor stack status')}`);
404
+ console.log(` ${paint.cyan('clawarmor stack plan')}`);
405
+ console.log(` ${paint.cyan('clawarmor stack deploy --invariant | --ironcurtain | --all')}`);
406
+ console.log(` ${paint.cyan('clawarmor stack sync')}`);
407
+ console.log(` ${paint.cyan('clawarmor stack teardown --invariant | --ironcurtain | --all')}`);
408
+ console.log('');
409
+ return 1;
410
+ }
package/lib/status.js CHANGED
@@ -8,6 +8,8 @@ import { paint } from './output/colors.js';
8
8
  import { scoreToGrade, scoreColor, gradeColor } from './output/progress.js';
9
9
  import { watchDaemonStatus } from './watch.js';
10
10
  import { getMonitorStatus } from './monitor.js';
11
+ import { getStatus as getInvariantStatus } from './stack/invariant.js';
12
+ import { getStatus as getIronCurtainStatus } from './stack/ironcurtain.js';
11
13
 
12
14
  const HOME = homedir();
13
15
  const OC_DIR = join(HOME, '.openclaw');
@@ -21,7 +23,7 @@ const FISH_FUNCTION_FILE = join(HOME, '.config', 'fish', 'functions', 'openclaw.
21
23
  const SHELL_MARKER = '# ClawArmor intercept — added by: clawarmor protect --install';
22
24
  const CRON_JOBS_FILE = join(OC_DIR, 'cron', 'jobs.json');
23
25
  const HOOKS_DIR = join(OC_DIR, 'hooks', 'clawarmor-guard');
24
- const VERSION = '2.2.0';
26
+ const VERSION = '3.0.0';
25
27
 
26
28
  const SEP = paint.dim('─'.repeat(52));
27
29
 
@@ -256,6 +258,26 @@ export async function runStatus() {
256
258
  }
257
259
  console.log(` ${paint.dim('Credentials')} ${credStr}`);
258
260
 
261
+ // ── Stack protection ──────────────────────────────────────────────────────
262
+ {
263
+ const inv = getInvariantStatus();
264
+ const ic = getIronCurtainStatus();
265
+ const layers = (inv.rulesExist ? 1 : 0) + (ic.constitutionExists ? 1 : 0);
266
+
267
+ const invStr = inv.rulesExist
268
+ ? `${paint.green('✓')} ${paint.dim(`Invariant (${inv.ruleCount} rule${inv.ruleCount !== 1 ? 's' : ''})`)}`
269
+ : `${paint.yellow('○')} ${paint.dim('Invariant: not deployed')}`;
270
+ const icStr = ic.constitutionExists
271
+ ? `${paint.green('✓')} ${paint.dim('IronCurtain (constitution exists)')}`
272
+ : `${paint.yellow('○')} ${paint.dim('IronCurtain: not configured')}`;
273
+
274
+ const layerColor = layers >= 2 ? paint.green : layers === 1 ? paint.yellow : paint.red;
275
+ console.log(` ${paint.dim('Stack')} ${layerColor(String(layers) + '/2')} ${paint.dim('layers')} ${invStr} ${icStr}`);
276
+ if (layers < 2) {
277
+ console.log(` ${paint.dim('→ run: clawarmor stack deploy --all')}`);
278
+ }
279
+ }
280
+
259
281
  console.log('');
260
282
  console.log(SEP);
261
283
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawarmor",
3
- "version": "2.2.1",
3
+ "version": "3.0.1",
4
4
  "description": "Security armor for OpenClaw agents — audit, scan, monitor",
5
5
  "bin": {
6
6
  "clawarmor": "cli.js"