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 +59 -22
- package/cli.js +8 -1
- package/lib/stack/index.js +91 -0
- package/lib/stack/invariant.js +224 -0
- package/lib/stack/ironcurtain.js +195 -0
- package/lib/stack.js +410 -0
- package/lib/status.js +23 -1
- 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 = '
|
|
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 = '
|
|
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
|
|