clawarmor 2.2.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +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/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
|
|