clawarmor 3.2.0 → 3.5.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.
@@ -0,0 +1,668 @@
1
+ // lib/invariant-sync.js — clawarmor invariant sync command
2
+ // v3.3.0: Deep Invariant integration
3
+ //
4
+ // Severity tiers:
5
+ // CRITICAL/HIGH → raise "..." if: ... (hard enforcement — blocks the trace)
6
+ // MEDIUM → warn "..." if: ... (monitoring/alerting — logs but allows)
7
+ // LOW/INFO → # informational comment only
8
+ //
9
+ // Optional push to running Invariant instance via Python bridge.
10
+
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs';
12
+ import { join } from 'path';
13
+ import { homedir } from 'os';
14
+ import { execSync, spawnSync } from 'child_process';
15
+ import { paint } from './output/colors.js';
16
+ import { getStackStatus } from './stack/index.js';
17
+ import { checkInstalled as invariantInstalled, install as installInvariant } from './stack/invariant.js';
18
+
19
+ const HOME = homedir();
20
+ const CLAWARMOR_DIR = join(HOME, '.clawarmor');
21
+ const AUDIT_LOG = join(CLAWARMOR_DIR, 'audit.log');
22
+
23
+ // Output paths
24
+ const POLICY_DIR = join(CLAWARMOR_DIR, 'invariant-policies');
25
+ const POLICY_PATH = join(POLICY_DIR, 'clawarmor.inv');
26
+ const REPORT_PATH = join(POLICY_DIR, 'sync-report.json');
27
+
28
+ const SEP = paint.dim('─'.repeat(52));
29
+
30
+ function box(title) {
31
+ const W = 52, pad = W - 2 - title.length, l = Math.floor(pad / 2), r = pad - l;
32
+ return [
33
+ paint.dim('╔' + '═'.repeat(W - 2) + '╗'),
34
+ paint.dim('║') + ' '.repeat(l) + paint.bold(title) + ' '.repeat(r) + paint.dim('║'),
35
+ paint.dim('╚' + '═'.repeat(W - 2) + '╝'),
36
+ ].join('\n');
37
+ }
38
+
39
+ // ── Policy generation ─────────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Map a single finding to one or more Invariant policy clauses.
43
+ * Returns { clauses: string[], tier: 'enforce'|'monitor'|'info', mapped: boolean }
44
+ */
45
+ function findingToPolicy(finding) {
46
+ const id = (finding.id || '').toLowerCase();
47
+ const severity = (finding.severity || '').toUpperCase();
48
+ const title = (finding.title || '').toLowerCase();
49
+ const detail = (finding.detail || '').toLowerCase();
50
+
51
+ // Determine enforcement tier from severity
52
+ const tier =
53
+ severity === 'CRITICAL' || severity === 'HIGH' ? 'enforce' :
54
+ severity === 'MEDIUM' ? 'monitor' : 'info';
55
+
56
+ const directive = tier === 'enforce' ? 'raise' : tier === 'monitor' ? 'warn' : null;
57
+ const clauses = [];
58
+
59
+ // ── exec.ask=off / unrestricted exec ──────────────────────────────────────
60
+ if (
61
+ (id.includes('exec') || title.includes('exec')) &&
62
+ (id.includes('ask') || id.includes('approval') || title.includes('approval') ||
63
+ title.includes('unrestricted') || detail.includes('unrestricted') || detail.includes('ask'))
64
+ ) {
65
+ if (directive) {
66
+ clauses.push(
67
+ `# Finding ${finding.id} [${severity}]: ${finding.title || 'Unrestricted exec'}`,
68
+ `${directive} "[ClawArmor] Unrestricted exec tool call — no approval gate (finding: ${finding.id})" if:`,
69
+ ` (call: ToolCall)`,
70
+ ` call is tool:exec`,
71
+ ``
72
+ );
73
+ } else {
74
+ clauses.push(
75
+ `# [INFO] Finding ${finding.id}: ${finding.title || 'Exec approval'} — consider enabling exec.ask`,
76
+ ``
77
+ );
78
+ }
79
+ return { clauses, tier, mapped: true };
80
+ }
81
+
82
+ // ── Credential files world-readable / permission issues ───────────────────
83
+ if (
84
+ (id.includes('cred') || id.includes('credential') || id.includes('filesystem') ||
85
+ id.includes('secret') || id.includes('permission') || id.includes('perm')) &&
86
+ (id.includes('perm') || id.includes('secret') || id.includes('file') ||
87
+ detail.includes('world') || detail.includes('readable') || detail.includes('permission') ||
88
+ title.includes('world') || title.includes('permission') || title.includes('credential'))
89
+ ) {
90
+ const sensitivePatterns = ['.ssh', '.aws', 'agent-accounts', '.openclaw', 'secrets'];
91
+ if (directive) {
92
+ clauses.push(
93
+ `# Finding ${finding.id} [${severity}]: ${finding.title || 'Credential file exposure'}`,
94
+ `${directive} "[ClawArmor] Read on sensitive credential path (finding: ${finding.id})" if:`,
95
+ ` (call: ToolCall)`,
96
+ ` call is tool:read_file`,
97
+ ` any(s in str(call.args.get("path", "")) for s in ${JSON.stringify(sensitivePatterns)})`,
98
+ ``
99
+ );
100
+ } else {
101
+ clauses.push(
102
+ `# [INFO] Finding ${finding.id}: ${finding.title || 'Credential file'} — review file permissions`,
103
+ ``
104
+ );
105
+ }
106
+ return { clauses, tier, mapped: true };
107
+ }
108
+
109
+ // ── Open channel policy / ungated sends ───────────────────────────────────
110
+ if (
111
+ (id.includes('channel') || title.includes('channel') || id.includes('group') ||
112
+ title.includes('group') || id.includes('policy')) &&
113
+ (id.includes('allow') || id.includes('group') || id.includes('policy') ||
114
+ detail.includes('allowfrom') || detail.includes('open') || title.includes('open') ||
115
+ title.includes('restriction') || title.includes('ungated'))
116
+ ) {
117
+ if (directive) {
118
+ clauses.push(
119
+ `# Finding ${finding.id} [${severity}]: ${finding.title || 'Open channel policy'}`,
120
+ `${directive} "[ClawArmor] Message sent via ungated channel — no allowFrom restriction (finding: ${finding.id})" if:`,
121
+ ` (call: ToolCall) -> (call2: ToolCall)`,
122
+ ` call is tool:read_file`,
123
+ ` call2 is tool:send_message`,
124
+ ` not call2.args.get("channel_restricted", False)`,
125
+ ``
126
+ );
127
+ } else {
128
+ clauses.push(
129
+ `# [INFO] Finding ${finding.id}: ${finding.title || 'Channel policy'} — consider restricting with allowFrom`,
130
+ ``
131
+ );
132
+ }
133
+ return { clauses, tier, mapped: true };
134
+ }
135
+
136
+ // ── Elevated tool calls with no restriction ───────────────────────────────
137
+ if (
138
+ id.includes('elevated') || title.includes('elevated') ||
139
+ (id.includes('allowfrom') && (id.includes('elevated') || title.includes('elevated')))
140
+ ) {
141
+ if (directive) {
142
+ clauses.push(
143
+ `# Finding ${finding.id} [${severity}]: ${finding.title || 'Elevated tool access'}`,
144
+ `${directive} "[ClawArmor] Elevated tool call from unrestricted source (finding: ${finding.id})" if:`,
145
+ ` (call: ToolCall)`,
146
+ ` call.metadata.get("elevated", False)`,
147
+ ` not call.metadata.get("allowFrom_restricted", False)`,
148
+ ``
149
+ );
150
+ } else {
151
+ clauses.push(
152
+ `# [INFO] Finding ${finding.id}: ${finding.title || 'Elevated access'} — restrict with allowFrom`,
153
+ ``
154
+ );
155
+ }
156
+ return { clauses, tier, mapped: true };
157
+ }
158
+
159
+ // ── Skill supply chain / unpinned skills ──────────────────────────────────
160
+ if (
161
+ id.includes('skill') &&
162
+ (id.includes('pin') || id.includes('supply') || id.includes('chain') ||
163
+ title.includes('supply') || title.includes('unverified') || title.includes('pin'))
164
+ ) {
165
+ if (directive) {
166
+ clauses.push(
167
+ `# Finding ${finding.id} [${severity}]: ${finding.title || 'Skill supply chain'}`,
168
+ `${directive} "[ClawArmor] Tool call from unverified/unpinned skill (finding: ${finding.id})" if:`,
169
+ ` (call: ToolCall)`,
170
+ ` not call.metadata.get("skill_verified", False)`,
171
+ ` not call.metadata.get("skill_pinned", False)`,
172
+ ``
173
+ );
174
+ } else {
175
+ clauses.push(
176
+ `# [INFO] Finding ${finding.id}: ${finding.title || 'Skill pinning'} — pin skill versions`,
177
+ ``
178
+ );
179
+ }
180
+ return { clauses, tier, mapped: true };
181
+ }
182
+
183
+ // ── API keys / secrets in config files ────────────────────────────────────
184
+ if (
185
+ (id.includes('api') || id.includes('token') || id.includes('key') || id.includes('secret')) &&
186
+ (id.includes('config') || id.includes('json') || id.includes('leak') || id.includes('exposure') ||
187
+ title.includes('api key') || title.includes('token') || detail.includes('api key'))
188
+ ) {
189
+ if (directive) {
190
+ clauses.push(
191
+ `# Finding ${finding.id} [${severity}]: ${finding.title || 'API key/secret exposure'}`,
192
+ `${directive} "[ClawArmor] Possible exfil of secrets — read sensitive config then send_message (finding: ${finding.id})" if:`,
193
+ ` (output: ToolOutput) -> (call2: ToolCall)`,
194
+ ` output is tool:read_file`,
195
+ ` any(k in str(output.content) for k in ["apiKey", "api_key", "token", "secret", "password"])`,
196
+ ` call2 is tool:send_message`,
197
+ ``
198
+ );
199
+ } else {
200
+ clauses.push(
201
+ `# [INFO] Finding ${finding.id}: ${finding.title || 'Secrets in config'} — move secrets to env vars`,
202
+ ``
203
+ );
204
+ }
205
+ return { clauses, tier, mapped: true };
206
+ }
207
+
208
+ // ── Gateway / auth issues ──────────────────────────────────────────────────
209
+ if (
210
+ id.includes('gateway') || id.includes('auth') ||
211
+ title.includes('gateway') || title.includes('auth') || title.includes('unauthenticated')
212
+ ) {
213
+ if (directive) {
214
+ clauses.push(
215
+ `# Finding ${finding.id} [${severity}]: ${finding.title || 'Gateway auth'}`,
216
+ `${directive} "[ClawArmor] Unauthenticated gateway connection attempt (finding: ${finding.id})" if:`,
217
+ ` (call: ToolCall)`,
218
+ ` call is tool:gateway_connect`,
219
+ ` not call.args.get("authenticated", False)`,
220
+ ``
221
+ );
222
+ } else {
223
+ clauses.push(
224
+ `# [INFO] Finding ${finding.id}: ${finding.title || 'Auth issue'} — review gateway authentication`,
225
+ ``
226
+ );
227
+ }
228
+ return { clauses, tier, mapped: true };
229
+ }
230
+
231
+ // ── Unmapped finding ───────────────────────────────────────────────────────
232
+ return {
233
+ clauses: [`# [UNMAPPED] Finding ${finding.id} [${severity}]: ${finding.title || id} — no specific Invariant rule\n`],
234
+ tier,
235
+ mapped: false,
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Generate a full Invariant policy file from all findings.
241
+ * Returns { policy: string, stats: { enforce, monitor, info, unmapped, total } }
242
+ */
243
+ export function generateEnhancedPolicy(findings) {
244
+ const now = new Date().toISOString().slice(0, 10);
245
+ const ts = new Date().toISOString();
246
+
247
+ const header = [
248
+ `# ClawArmor v3.3.0 — Invariant Runtime Policy`,
249
+ `# Generated: ${ts}`,
250
+ `# Source: clawarmor invariant sync`,
251
+ `# Format: Invariant DSL (.inv) — https://github.com/invariantlabs-ai/invariant`,
252
+ `#`,
253
+ `# Tier mapping:`,
254
+ `# CRITICAL/HIGH findings → raise "..." (hard enforcement, blocks trace)`,
255
+ `# MEDIUM findings → warn "..." (monitoring/alerting, logged)`,
256
+ `# LOW/INFO findings → # comment (informational, no enforcement)`,
257
+ ``,
258
+ ];
259
+
260
+ const stats = { enforce: 0, monitor: 0, info: 0, unmapped: 0, total: 0 };
261
+
262
+ if (!findings || !findings.length) {
263
+ return {
264
+ policy: [
265
+ ...header,
266
+ `# No findings from latest audit.`,
267
+ `# Run: clawarmor audit then clawarmor invariant sync`,
268
+ ``,
269
+ `# Generic baseline: prompt injection via web tool → send_message`,
270
+ `raise "[ClawArmor] Possible prompt injection: web content → outbound message" if:`,
271
+ ` (output: ToolOutput) -> (call: ToolCall)`,
272
+ ` output is tool:get_website`,
273
+ ` prompt_injection(output.content, threshold=0.7)`,
274
+ ` call is tool:send_message`,
275
+ ``,
276
+ ].join('\n'),
277
+ stats,
278
+ };
279
+ }
280
+
281
+ // Sort findings: CRITICAL first, then HIGH, MEDIUM, LOW, INFO
282
+ const ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3, INFO: 4 };
283
+ const sorted = [...findings].sort((a, b) => {
284
+ const sa = ORDER[(a.severity || '').toUpperCase()] ?? 5;
285
+ const sb = ORDER[(b.severity || '').toUpperCase()] ?? 5;
286
+ return sa - sb;
287
+ });
288
+
289
+ const enforceSections = [`# ═══ ENFORCEMENT POLICIES (CRITICAL/HIGH) ═══════════════════════════════\n`];
290
+ const monitorSections = [`# ═══ MONITORING POLICIES (MEDIUM) ══════════════════════════════════════\n`];
291
+ const infoSections = [`# ═══ INFORMATIONAL (LOW/INFO) ════════════════════════════════════════\n`];
292
+
293
+ // Deduplicate by policy key to avoid duplicate rules
294
+ const seenKeys = new Set();
295
+
296
+ for (const finding of sorted) {
297
+ const { clauses, tier, mapped } = findingToPolicy(finding);
298
+ stats.total++;
299
+ if (!mapped) stats.unmapped++;
300
+
301
+ // Build a dedup key from the first raise/warn line
302
+ const raiseLine = clauses.find(l => l.startsWith('raise') || l.startsWith('warn') || l.startsWith('#'));
303
+ const key = raiseLine?.slice(0, 80) || finding.id;
304
+ if (seenKeys.has(key)) continue;
305
+ seenKeys.add(key);
306
+
307
+ if (tier === 'enforce') {
308
+ stats.enforce++;
309
+ enforceSections.push(...clauses);
310
+ } else if (tier === 'monitor') {
311
+ stats.monitor++;
312
+ monitorSections.push(...clauses);
313
+ } else {
314
+ stats.info++;
315
+ infoSections.push(...clauses);
316
+ }
317
+ }
318
+
319
+ // Always include prompt injection baseline
320
+ enforceSections.push(
321
+ `# Baseline: prompt injection via web content → outbound message`,
322
+ `raise "[ClawArmor] Prompt injection risk: web content flowing to outbound call" if:`,
323
+ ` (output: ToolOutput) -> (call: ToolCall)`,
324
+ ` output is tool:get_website`,
325
+ ` prompt_injection(output.content, threshold=0.7)`,
326
+ ` call is tool:send_message`,
327
+ ``,
328
+ );
329
+
330
+ const allSections = [
331
+ ...header,
332
+ ...enforceSections,
333
+ ``,
334
+ ...monitorSections,
335
+ ``,
336
+ ...infoSections,
337
+ ];
338
+
339
+ return { policy: allSections.join('\n'), stats };
340
+ }
341
+
342
+ // ── Invariant push (optional) ─────────────────────────────────────────────────
343
+
344
+ /**
345
+ * Attempt to push the policy to a running Invariant instance via Python bridge.
346
+ * Invariant exposes LocalPolicy.from_string() — we validate + optionally hot-reload.
347
+ * @param {string} policyContent
348
+ * @param {{ host?: string, port?: number }} opts
349
+ * @returns {{ ok: boolean, method: string, err?: string }}
350
+ */
351
+ function pushToInvariant(policyContent, opts = {}) {
352
+ if (!invariantInstalled()) {
353
+ return { ok: false, method: 'pip', err: 'invariant-ai not installed — run: pip3 install invariant-ai' };
354
+ }
355
+
356
+ // Write policy to temp file for Python to load
357
+ const tmpPath = join(CLAWARMOR_DIR, '.inv-push-tmp.inv');
358
+ try {
359
+ writeFileSync(tmpPath, policyContent, 'utf8');
360
+ } catch (e) {
361
+ return { ok: false, method: 'push', err: `Could not write temp file: ${e.message}` };
362
+ }
363
+
364
+ // Validate syntax first
365
+ const validateScript = `
366
+ from invariant.analyzer import LocalPolicy
367
+ try:
368
+ p = LocalPolicy.from_file('${tmpPath}')
369
+ print('VALID:' + str(len(p.rules)) + ' rules')
370
+ except Exception as e:
371
+ print('ERROR:' + str(e))
372
+ `.trim();
373
+
374
+ const validateResult = spawnSync('python3', ['-c', validateScript], {
375
+ encoding: 'utf8',
376
+ timeout: 30000,
377
+ });
378
+
379
+ if (validateResult.status !== 0 || (validateResult.stdout || '').startsWith('ERROR:')) {
380
+ const msg = (validateResult.stdout || validateResult.stderr || '').split('\n')[0].replace('ERROR:', '');
381
+ return { ok: false, method: 'validate', err: `Policy syntax error: ${msg.trim()}` };
382
+ }
383
+
384
+ const validatedInfo = (validateResult.stdout || '').trim().replace('VALID:', '');
385
+
386
+ // Try to push to a running Invariant gateway instance (if available)
387
+ const host = opts.host || '127.0.0.1';
388
+ const port = opts.port || 8000;
389
+
390
+ const pushScript = `
391
+ import urllib.request, json, sys
392
+
393
+ policy_path = '${tmpPath}'
394
+ host = '${host}'
395
+ port = ${port}
396
+ url = f'http://{host}:{port}/api/policy/reload'
397
+
398
+ try:
399
+ with open(policy_path) as f:
400
+ policy_content = f.read()
401
+
402
+ payload = json.dumps({'policy': policy_content, 'source': 'clawarmor-v3.3.0'}).encode()
403
+ req = urllib.request.Request(url, data=payload, headers={'Content-Type': 'application/json'})
404
+ resp = urllib.request.urlopen(req, timeout=5)
405
+ print('PUSHED:' + resp.read().decode()[:200])
406
+ except urllib.error.URLError as e:
407
+ # Instance not running — not an error, just not enforcing live
408
+ print('OFFLINE:' + str(e.reason))
409
+ except Exception as e:
410
+ print('OFFLINE:' + str(e))
411
+ `.trim();
412
+
413
+ const pushResult = spawnSync('python3', ['-c', pushScript], {
414
+ encoding: 'utf8',
415
+ timeout: 10000,
416
+ });
417
+
418
+ const pushOut = (pushResult.stdout || '').trim();
419
+ if (pushOut.startsWith('PUSHED:')) {
420
+ return { ok: true, method: 'live-reload', validatedInfo, pushOut: pushOut.replace('PUSHED:', '') };
421
+ } else {
422
+ // Not running live — still OK (rules file written, will be picked up on next start)
423
+ return { ok: true, method: 'file-only', validatedInfo, note: 'Invariant not running — policy written to disk, enforces on next start' };
424
+ }
425
+ }
426
+
427
+ // ── Main command ──────────────────────────────────────────────────────────────
428
+
429
+ /**
430
+ * Run `clawarmor invariant sync`.
431
+ * @param {string[]} args
432
+ * @returns {Promise<number>} exit code
433
+ */
434
+ export async function runInvariantSync(args = []) {
435
+ const push = args.includes('--push');
436
+ const dryRun = args.includes('--dry-run');
437
+ const json = args.includes('--json');
438
+
439
+ const hostIdx = args.indexOf('--host');
440
+ const host = hostIdx !== -1 ? args[hostIdx + 1] : null;
441
+ const portIdx = args.indexOf('--port');
442
+ const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) || 8000 : 8000;
443
+
444
+ if (!json) {
445
+ console.log('');
446
+ console.log(box('ClawArmor Invariant Sync v3.3.0'));
447
+ console.log('');
448
+ }
449
+
450
+ // Load audit data
451
+ const { audit, profile } = await getStackStatus();
452
+ if (!audit) {
453
+ if (json) {
454
+ console.log(JSON.stringify({ ok: false, error: 'No audit data — run clawarmor audit first' }));
455
+ } else {
456
+ console.log(` ${paint.yellow('!')} No audit data found.`);
457
+ console.log(` ${paint.dim('Run clawarmor audit first, then clawarmor invariant sync.')}`);
458
+ console.log('');
459
+ }
460
+ return 1;
461
+ }
462
+
463
+ const findings = audit.findings ?? [];
464
+
465
+ if (!json) {
466
+ console.log(` ${paint.dim('Audit score')} ${profile.score ?? 'n/a'}/100 ${paint.dim('(' + findings.length + ' findings)')}`);
467
+ console.log(` ${paint.dim('Risk profile')} ${profile.label}`);
468
+ console.log('');
469
+ console.log(SEP);
470
+ console.log('');
471
+ console.log(` ${paint.cyan('Generating severity-tiered Invariant policies...')}`);
472
+ console.log('');
473
+ }
474
+
475
+ const { policy, stats } = generateEnhancedPolicy(findings);
476
+
477
+ if (!json) {
478
+ console.log(` ${paint.bold('Policy summary:')}`);
479
+ console.log(` ${paint.red('✗ Enforce')} ${stats.enforce} ${paint.dim('rules (CRITICAL/HIGH → hard block)')}`);
480
+ console.log(` ${paint.yellow('! Monitor')} ${stats.monitor} ${paint.dim('rules (MEDIUM → alert/log)')}`);
481
+ console.log(` ${paint.dim(' Info')} ${stats.info} ${paint.dim('comments (LOW/INFO → guidance only)')}`);
482
+ if (stats.unmapped > 0) {
483
+ console.log(` ${paint.dim(' Unmapped')} ${stats.unmapped} ${paint.dim('findings (no specific Invariant mapping)')}`);
484
+ }
485
+ console.log('');
486
+ }
487
+
488
+ if (dryRun) {
489
+ if (!json) {
490
+ console.log(SEP);
491
+ console.log(` ${paint.dim('--dry-run: policy preview (not written):')}`);
492
+ console.log('');
493
+ const lines = policy.split('\n');
494
+ for (const line of lines.slice(0, 60)) {
495
+ console.log(` ${paint.dim(line)}`);
496
+ }
497
+ if (lines.length > 60) {
498
+ console.log(` ${paint.dim(` ... (${lines.length - 60} more lines)`)}`);
499
+ }
500
+ console.log('');
501
+ console.log(` ${paint.dim('Run without --dry-run to write and activate.')}`);
502
+ console.log('');
503
+ } else {
504
+ console.log(JSON.stringify({ ok: true, dryRun: true, stats, policy }, null, 2));
505
+ }
506
+ return 0;
507
+ }
508
+
509
+ // Write policy file
510
+ try {
511
+ if (!existsSync(POLICY_DIR)) mkdirSync(POLICY_DIR, { recursive: true });
512
+ writeFileSync(POLICY_PATH, policy, 'utf8');
513
+ } catch (e) {
514
+ if (json) {
515
+ console.log(JSON.stringify({ ok: false, error: e.message }));
516
+ } else {
517
+ console.log(` ${paint.red('✗')} Failed to write policy: ${e.message}`);
518
+ }
519
+ return 1;
520
+ }
521
+
522
+ // Write JSON sync report
523
+ const report = {
524
+ syncedAt: new Date().toISOString(),
525
+ auditScore: profile.score,
526
+ findingsCount: findings.length,
527
+ stats,
528
+ policyPath: POLICY_PATH,
529
+ pushed: false,
530
+ pushMethod: null,
531
+ pushError: null,
532
+ };
533
+
534
+ if (!json) {
535
+ console.log(` ${paint.green('✓')} Policy written: ${POLICY_PATH}`);
536
+ console.log('');
537
+ console.log(SEP);
538
+ }
539
+
540
+ // Optional push
541
+ if (push) {
542
+ if (!json) {
543
+ process.stdout.write(` ${paint.dim('Pushing to Invariant instance...')} `);
544
+ }
545
+ const pushResult = pushToInvariant(policy, { host, port });
546
+ report.pushed = pushResult.ok;
547
+ report.pushMethod = pushResult.method;
548
+ if (!pushResult.ok) {
549
+ report.pushError = pushResult.err;
550
+ }
551
+
552
+ if (!json) {
553
+ if (pushResult.method === 'live-reload') {
554
+ process.stdout.write(paint.green('✓\n'));
555
+ console.log(` ${paint.green('✓')} Live-reloaded: Invariant instance updated immediately`);
556
+ if (pushResult.validatedInfo) {
557
+ console.log(` ${paint.dim('Validated: ' + pushResult.validatedInfo + ' rules')}`);
558
+ }
559
+ } else if (pushResult.method === 'file-only') {
560
+ process.stdout.write(paint.yellow('○\n'));
561
+ console.log(` ${paint.yellow('○')} Invariant instance not running — policy on disk, enforces on next start`);
562
+ if (pushResult.validatedInfo) {
563
+ console.log(` ${paint.dim('Validated: ' + pushResult.validatedInfo + ' rules')}`);
564
+ }
565
+ if (pushResult.note) {
566
+ console.log(` ${paint.dim(pushResult.note)}`);
567
+ }
568
+ } else {
569
+ process.stdout.write(paint.red('✗\n'));
570
+ console.log(` ${paint.red('Error:')} ${pushResult.err}`);
571
+ }
572
+ console.log('');
573
+ }
574
+ } else {
575
+ if (!json) {
576
+ console.log('');
577
+ console.log(` ${paint.dim('Tip: use --push to validate syntax + push to running Invariant instance')}`);
578
+ console.log(` ${paint.dim(' pip3 install invariant-ai (required for --push)')}`);
579
+ console.log('');
580
+ }
581
+ }
582
+
583
+ // Write report
584
+ try {
585
+ writeFileSync(REPORT_PATH, JSON.stringify(report, null, 2), 'utf8');
586
+ } catch { /* non-fatal */ }
587
+
588
+ if (!json) {
589
+ console.log(SEP);
590
+ console.log('');
591
+ console.log(` ${paint.green('✓')} Invariant sync complete.`);
592
+ console.log('');
593
+ console.log(` ${paint.dim('Policy file:')} ${POLICY_PATH}`);
594
+ console.log(` ${paint.dim('Sync report:')} ${REPORT_PATH}`);
595
+ console.log('');
596
+
597
+ const invInstalled = invariantInstalled();
598
+ if (!invInstalled) {
599
+ console.log(` ${paint.yellow('!')} invariant-ai not installed.`);
600
+ console.log(` ${paint.dim('Install to validate + push policies: pip3 install invariant-ai')}`);
601
+ console.log(` ${paint.dim('Then re-run: clawarmor invariant sync --push')}`);
602
+ console.log('');
603
+ } else {
604
+ console.log(` ${paint.dim('To activate enforcement:')}`);
605
+ console.log(` ${paint.cyan('clawarmor invariant sync --push')} ${paint.dim('# push to running Invariant instance')}`);
606
+ console.log('');
607
+ }
608
+ } else {
609
+ console.log(JSON.stringify({ ok: true, ...report, policy }, null, 2));
610
+ }
611
+
612
+ return 0;
613
+ }
614
+
615
+ // ── Status subcommand ─────────────────────────────────────────────────────────
616
+
617
+ export async function runInvariantStatus() {
618
+ console.log('');
619
+ console.log(box('ClawArmor Invariant Sync v3.3.0'));
620
+ console.log('');
621
+
622
+ const installed = invariantInstalled();
623
+ const policyExists = existsSync(POLICY_PATH);
624
+ const reportExists = existsSync(REPORT_PATH);
625
+
626
+ console.log(` ${paint.bold('invariant-ai')} ${installed ? paint.green('✓ installed') : paint.yellow('○ not installed')}`);
627
+ if (!installed) {
628
+ console.log(` ${paint.dim('Install: pip3 install invariant-ai')}`);
629
+ }
630
+ console.log('');
631
+
632
+ if (policyExists) {
633
+ try {
634
+ const content = readFileSync(POLICY_PATH, 'utf8');
635
+ const raiseCount = (content.match(/^raise /gm) || []).length;
636
+ const warnCount = (content.match(/^warn /gm) || []).length;
637
+ const mtime = statSync(POLICY_PATH).mtime.toISOString().slice(0, 19).replace('T', ' ');
638
+ console.log(` ${paint.green('✓')} ${paint.bold('Policy file')} ${POLICY_PATH}`);
639
+ console.log(` ${paint.dim('Rules:')} ${raiseCount} enforce + ${warnCount} monitor`);
640
+ console.log(` ${paint.dim('Updated:')} ${mtime}`);
641
+ } catch { /* non-fatal */ }
642
+ } else {
643
+ console.log(` ${paint.yellow('○')} ${paint.bold('Policy file')} not synced`);
644
+ console.log(` ${paint.dim('Run: clawarmor invariant sync')}`);
645
+ }
646
+ console.log('');
647
+
648
+ if (reportExists) {
649
+ try {
650
+ const report = JSON.parse(readFileSync(REPORT_PATH, 'utf8'));
651
+ console.log(SEP);
652
+ console.log(` ${paint.bold('Last sync')}`);
653
+ console.log(` ${paint.dim('Date:')} ${report.syncedAt?.slice(0, 19).replace('T', ' ') ?? 'unknown'}`);
654
+ console.log(` ${paint.dim('Audit score:')} ${report.auditScore ?? 'n/a'}/100`);
655
+ console.log(` ${paint.dim('Findings:')} ${report.findingsCount ?? 0}`);
656
+ if (report.stats) {
657
+ console.log(` ${paint.dim('Policies:')} ${report.stats.enforce ?? 0} enforce, ${report.stats.monitor ?? 0} monitor, ${report.stats.info ?? 0} info`);
658
+ }
659
+ if (report.pushed) {
660
+ console.log(` ${paint.dim('Pushed:')} ${paint.green('yes')} (${report.pushMethod})`);
661
+ } else if (report.pushError) {
662
+ console.log(` ${paint.dim('Pushed:')} ${paint.yellow('no')} — ${report.pushError}`);
663
+ }
664
+ } catch { /* non-fatal */ }
665
+ }
666
+ console.log('');
667
+ return 0;
668
+ }