clawarmor 3.2.0 → 3.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,94 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.4.0] — 2026-03-08
4
+
5
+ ### New Features
6
+
7
+ #### `clawarmor harden --report` — Structured Hardening Reports
8
+ Export a portable, structured summary of every hardening run — what was hardened, what was
9
+ skipped, why, and what was already good. The #1 feature gap for enterprise adoption.
10
+
11
+ **Flags:**
12
+ - `--report [path]` — Write JSON report (default: `~/.openclaw/clawarmor-harden-report-YYYY-MM-DD.json`)
13
+ - `--report-format text` — Write Markdown report instead of JSON
14
+
15
+ **JSON report structure:**
16
+ ```json
17
+ {
18
+ "version": "3.4.0",
19
+ "timestamp": "...",
20
+ "system": { "os": "...", "openclaw_version": "..." },
21
+ "summary": { "total_checks": N, "hardened": N, "already_good": N, "skipped": N },
22
+ "items": [
23
+ { "check": "exec.ask.off", "status": "hardened", "before": "off", "after": "on-miss", "action": "..." },
24
+ { "check": "gateway.host.open", "status": "skipped", "skipped_reason": "Breaking fix..." }
25
+ ]
26
+ }
27
+ ```
28
+
29
+ **Examples:**
30
+ ```bash
31
+ clawarmor harden --report
32
+ clawarmor harden --report /tmp/my-report.json
33
+ clawarmor harden --report /tmp/report.md --report-format text
34
+ clawarmor harden --auto --report
35
+ ```
36
+
37
+ Existing `clawarmor harden` behavior unchanged when `--report` is not passed.
38
+
39
+ ---
40
+
41
+ ## [3.3.0] — 2026-03-07
42
+
43
+ ### New Features
44
+
45
+ #### `clawarmor invariant sync` — Invariant Deep Integration
46
+ The Invariant integration in v3.0 detected presence of `invariant-ai`. v3.3.0 does the real work:
47
+ it reads your latest audit findings and generates severity-tiered Invariant DSL policies that
48
+ actually enforce behavioral guardrails at runtime.
49
+
50
+ **Severity tiers:**
51
+ - `CRITICAL`/`HIGH` findings → `raise "..."` hard enforcement rules (blocks the trace)
52
+ - `MEDIUM` findings → `warn "..."` monitoring/alerting rules (logs but allows)
53
+ - `LOW`/`INFO` findings → `# informational` comments (guidance only)
54
+
55
+ **Policy mappings (finding → Invariant rule):**
56
+ | Finding type | Generated policy |
57
+ |---|---|
58
+ | `exec.ask=off` / unrestricted exec | `raise` on any `exec` tool call |
59
+ | Credential files world-readable | `raise` on `read_file` to sensitive paths (`.ssh`, `.aws`, `agent-accounts`, `.openclaw`) |
60
+ | Open channel policy (no `allowFrom`) | `raise`/`warn` on `read_file → send_message` without channel restriction |
61
+ | Elevated tool calls unrestricted | `raise`/`warn` on elevated calls with no `allowFrom_restricted` metadata |
62
+ | Skill supply chain / unpinned | `raise`/`warn` on tool calls lacking `skill_verified` or `skill_pinned` metadata |
63
+ | API key/secret in config files | `raise`/`warn` on `read_file` output containing secret patterns → `send_message` |
64
+ | Baseline: prompt injection | `raise` on web content → outbound message (always included) |
65
+
66
+ **New commands:**
67
+ ```
68
+ clawarmor invariant sync # generate tiered policies from latest audit
69
+ clawarmor invariant sync --dry-run # preview without writing
70
+ clawarmor invariant sync --push # generate + validate + push to Invariant instance
71
+ clawarmor invariant sync --push --host <host> --port <port>
72
+ clawarmor invariant sync --json # machine-readable output
73
+ clawarmor invariant status # show current policy file + last sync report
74
+ ```
75
+
76
+ **Policy output:**
77
+ - Policy file: `~/.clawarmor/invariant-policies/clawarmor.inv`
78
+ - Sync report: `~/.clawarmor/invariant-policies/sync-report.json`
79
+
80
+ **`--push` behavior:**
81
+ 1. Validates policy syntax via `LocalPolicy.from_file()` (requires `pip3 install invariant-ai`)
82
+ 2. If Invariant instance running on `localhost:8000` → live-reloads policy immediately
83
+ 3. If not running → policy written to disk, enforces on next Invariant start
84
+
85
+ **Relationship to `clawarmor stack`:**
86
+ - `stack deploy/sync` generates basic `.inv` rules in `~/.clawarmor/invariant-rules.inv`
87
+ - `invariant sync` generates richer severity-tiered policies in `~/.clawarmor/invariant-policies/clawarmor.inv`
88
+ - They are complementary; `invariant sync` is the recommended path for serious deployments
89
+
90
+ ---
91
+
3
92
  ## [3.2.0] — 2026-03-03
4
93
 
5
94
  ### New Features
package/README.md CHANGED
@@ -51,9 +51,11 @@ ClawArmor sits at the foundation and orchestrates the layers above it:
51
51
  |---|---|
52
52
  | `audit` | Score your OpenClaw config (0–100), live gateway probes, plain-English verdict |
53
53
  | `scan` | Scan all installed skill files for malicious code and SKILL.md instructions |
54
+ | `scan --json` | Machine-readable scan output — pipe to CI, scripts, or dashboards |
54
55
  | `prescan <skill>` | Pre-scan a skill before installing — blocks on CRITICAL findings |
56
+ | `skill verify <name>` | Deep-verify a specific installed skill — checks SKILL.md + all referenced scripts |
55
57
  | `fix` | Auto-apply safe fixes (--dry-run to preview, --apply to run) |
56
- | `harden` | Interactive hardening wizard (--dry-run, --auto, --monitor) |
58
+ | `harden` | Interactive hardening wizard (--dry-run, --auto, --monitor, --report) |
57
59
  | `status` | One-screen security posture dashboard |
58
60
  | `verify` | Re-run only previously-failed checks (CI-friendly, exit 0 = all fixed) |
59
61
 
@@ -67,6 +69,31 @@ ClawArmor sits at the foundation and orchestrates the layers above it:
67
69
  | `stack sync` | Regenerate stack configs from latest audit — run after harden/fix |
68
70
  | `stack teardown` | Remove deployed stack components |
69
71
 
72
+ ### Invariant Deep Integration (v3.3.0)
73
+
74
+ | Command | Description |
75
+ |---|---|
76
+ | `invariant sync` | Generate severity-tiered Invariant policies from latest audit findings |
77
+ | `invariant sync --dry-run` | Preview policies without writing |
78
+ | `invariant sync --push` | Generate + validate + push to running Invariant instance |
79
+ | `invariant sync --json` | Machine-readable output for scripting |
80
+ | `invariant status` | Show current policy file and last sync report |
81
+
82
+ **Severity tiers:**
83
+ - `CRITICAL`/`HIGH` findings → `raise "..."` (hard enforcement — blocks trace)
84
+ - `MEDIUM` findings → `warn "..."` (monitoring/alerting — logged)
85
+ - `LOW`/`INFO` findings → `# comment` (informational only)
86
+
87
+ Policies are written to `~/.clawarmor/invariant-policies/clawarmor.inv`. With `--push`, ClawArmor validates the policy syntax via `invariant-ai` and live-reloads a running Invariant instance. If no instance is running, the policy is written to disk and enforces on next start.
88
+
89
+ ```bash
90
+ pip3 install invariant-ai # required for --push validation
91
+ clawarmor audit # run audit to capture findings
92
+ clawarmor invariant sync # generate tiered policies
93
+ clawarmor invariant sync --push # push to running Invariant instance
94
+ clawarmor invariant status # check what's deployed
95
+ ```
96
+
70
97
  ### History & Monitoring
71
98
 
72
99
  | Command | Description |
@@ -76,6 +103,9 @@ ClawArmor sits at the foundation and orchestrates the layers above it:
76
103
  | `log` | View the audit event log |
77
104
  | `digest` | Show weekly security digest |
78
105
  | `watch` | Monitor config and skill changes in real time |
106
+ | `baseline save` | Save current scan results as baseline |
107
+ | `baseline diff` | Compare current scan against saved baseline — see what changed |
108
+ | `incident create` | Log a security incident with timestamp, findings, and remediation notes |
79
109
  | `protect --install` | Install guard hook, shell intercept (zsh/bash/fish), and watch daemon |
80
110
  | `snapshot` | Save a config snapshot manually (auto-saved before every harden/fix) |
81
111
  | `rollback` | Restore config from auto-snapshot (--list, --id <id>) |
@@ -116,6 +146,24 @@ clawarmor harden --monitor-report # see what it observed
116
146
  clawarmor harden --monitor-off # stop monitoring
117
147
  ```
118
148
 
149
+ **Hardening reports** (v3.4.0) — Export a structured report after hardening:
150
+
151
+ ```bash
152
+ # Write JSON report to default location (~/.openclaw/clawarmor-harden-report-YYYY-MM-DD.json)
153
+ clawarmor harden --report
154
+
155
+ # Write JSON report to a custom path
156
+ clawarmor harden --report /path/to/report.json
157
+
158
+ # Write Markdown report (human-readable, shareable)
159
+ clawarmor harden --report /path/to/report.md --report-format text
160
+
161
+ # Combine with auto mode
162
+ clawarmor harden --auto --report
163
+ ```
164
+
165
+ Report structure includes: version, timestamp, OS/OpenClaw info, summary counts (hardened/skipped/already-good), and per-check action details with before/after values.
166
+
119
167
  ## Philosophy
120
168
 
121
169
  ClawArmor runs entirely on your machine — no telemetry, no cloud, no accounts.
@@ -12,7 +12,7 @@ A security gate that sits between you and any skill you're about to install. Bef
12
12
 
13
13
  ## The problem it solves
14
14
 
15
- In early 2026, a malicious skill called `openclaw-web-search` was distributed on a third-party registry. It appeared functional but contained an obfuscated payload that exfiltrated session tokens via a DNS covert channel. It was installed by dozens of operators without inspection.
15
+ In early 2026, a malicious skill called `openclaw-web-search` was distributed on a third-party registry. It appeared functional but contained an obfuscated payload that sent session tokens to an attacker-controlled DNS server. It was installed by dozens of operators without inspection.
16
16
 
17
17
  This scanner would have caught it. The obfuscation patterns and DNS module import are both CRITICAL-severity matches in ClawArmor's pattern library.
18
18
 
package/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { paint } from './lib/output/colors.js';
5
5
 
6
- const VERSION = '3.2.0';
6
+ const VERSION = '3.4.0';
7
7
  const GATEWAY_PORT_DEFAULT = 18789;
8
8
 
9
9
  function isLocalhost(host) {
@@ -54,6 +54,7 @@ function usage() {
54
54
  console.log(` ${paint.cyan('baseline')} Save, list, and diff security baselines (save|list|diff)`);
55
55
  console.log(` ${paint.cyan('incident')} Log and manage security incidents (create|list)`);
56
56
  console.log(` ${paint.cyan('stack')} Security orchestrator — deploy Invariant + IronCurtain from audit data`);
57
+ console.log(` ${paint.cyan('invariant')} Invariant deep integration — sync findings → runtime policies (v3.3.0)`);
57
58
  console.log(` ${paint.cyan('skill')} Skill utilities — verify a skill directory (skill verify <dir>)`);
58
59
  console.log(` ${paint.cyan('skill-report')} Show post-install audit impact of last skill install`);
59
60
  console.log(` ${paint.cyan('profile')} Manage contextual hardening profiles`);
@@ -211,6 +212,12 @@ if (cmd === 'log') {
211
212
 
212
213
  if (cmd === 'harden') {
213
214
  const hardenProfileIdx = args.indexOf('--profile');
215
+ const reportIdx = args.indexOf('--report');
216
+ const reportFormatIdx = args.indexOf('--report-format');
217
+ // --report can be a flag alone or --report <path>
218
+ const reportNext = reportIdx !== -1 ? args[reportIdx + 1] : null;
219
+ const reportPath = (reportNext && !reportNext.startsWith('--')) ? reportNext : null;
220
+ const reportFormat = reportFormatIdx !== -1 ? (args[reportFormatIdx + 1] || 'json') : 'json';
214
221
  const hardenFlags = {
215
222
  dryRun: args.includes('--dry-run'),
216
223
  auto: args.includes('--auto'),
@@ -219,6 +226,9 @@ if (cmd === 'harden') {
219
226
  monitorReport: args.includes('--monitor-report'),
220
227
  monitorOff: args.includes('--monitor-off'),
221
228
  profile: hardenProfileIdx !== -1 ? args[hardenProfileIdx + 1] : null,
229
+ report: reportIdx !== -1,
230
+ reportPath: reportPath,
231
+ reportFormat: reportFormat,
222
232
  };
223
233
  const { runHarden } = await import('./lib/harden.js');
224
234
  process.exit(await runHarden(hardenFlags));
@@ -285,5 +295,31 @@ if (cmd === 'skill') {
285
295
  process.exit(1);
286
296
  }
287
297
 
298
+
299
+ if (cmd === 'invariant') {
300
+ const sub = args[1];
301
+ const invArgs = args.slice(2);
302
+
303
+ if (!sub || sub === 'status') {
304
+ const { runInvariantStatus } = await import('./lib/invariant-sync.js');
305
+ process.exit(await runInvariantStatus());
306
+ }
307
+
308
+ if (sub === 'sync') {
309
+ const { runInvariantSync } = await import('./lib/invariant-sync.js');
310
+ process.exit(await runInvariantSync(invArgs));
311
+ }
312
+
313
+ console.log('');
314
+ console.log(` Invariant subcommands:`);
315
+ console.log(` clawarmor invariant status # show policy + last sync`);
316
+ console.log(` clawarmor invariant sync # generate policies from latest audit`);
317
+ console.log(` clawarmor invariant sync --dry-run # preview without writing`);
318
+ console.log(` clawarmor invariant sync --push # generate + push to Invariant instance`);
319
+ console.log(` clawarmor invariant sync --push --host <host> --port <port>`);
320
+ console.log('');
321
+ process.exit(1);
322
+ }
323
+
288
324
  console.log(` ${paint.red('✗')} Unknown command: ${paint.bold(cmd)}`);
289
325
  usage(); process.exit(1);
package/lib/harden.js CHANGED
@@ -4,10 +4,11 @@
4
4
  // --dry-run: show what WOULD be fixed, no writes
5
5
  // --auto: apply all safe + caution fixes without confirmation (skips breaking)
6
6
  // --auto --force: apply ALL fixes including breaking ones
7
+ // --report [path]: write a structured report (JSON or Markdown) after hardening
7
8
 
8
- import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
9
- import { join } from 'path';
10
- import { homedir } from 'os';
9
+ import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
10
+ import { join, dirname } from 'path';
11
+ import { homedir, platform, release } from 'os';
11
12
  import { execSync, spawnSync } from 'child_process';
12
13
  import { createInterface } from 'readline';
13
14
  import { paint } from './output/colors.js';
@@ -24,6 +25,8 @@ const HISTORY_FILE = join(CLAWARMOR_DIR, 'history.json');
24
25
  const CLI_PATH = new URL('../cli.js', import.meta.url).pathname;
25
26
  const SEP = paint.dim('─'.repeat(52));
26
27
 
28
+ const VERSION = '3.4.0';
29
+
27
30
  // ── Impact levels ─────────────────────────────────────────────────────────────
28
31
  // SAFE: No functionality impact. Pure security improvement.
29
32
  // CAUTION: May change agent behavior. User should be aware.
@@ -83,8 +86,6 @@ function buildFixes(config) {
83
86
  // Fix 1: world/group-readable credential files — chmod 600
84
87
  const badFiles = findWorldReadableCredFiles();
85
88
  for (const f of badFiles) {
86
- // Classify: credential files (tokens, keys) are safe to lock down.
87
- // Config files that other tools might read need caution.
88
89
  const isSensitive = /\.(env|json|key|pem|token|secret)$/i.test(f.name) ||
89
90
  f.name === 'agent-accounts.json' ||
90
91
  f.name === 'openclaw.json';
@@ -101,6 +102,9 @@ function buildFixes(config) {
101
102
  ? 'Only restricts other system users. Scripts will still run as you.'
102
103
  : 'Only restricts other system users from reading this file. Your agent is unaffected.',
103
104
  manualNote: null,
105
+ // for report: capture before state
106
+ _reportBefore: f.mode,
107
+ _reportAfter: '600',
104
108
  });
105
109
  }
106
110
 
@@ -119,6 +123,8 @@ function buildFixes(config) {
119
123
  ' tablet, or another computer), those connections will be blocked.\n' +
120
124
  ' Only localhost access will work after this change.',
121
125
  manualNote: 'Restart gateway after applying: openclaw gateway restart',
126
+ _reportBefore: '0.0.0.0',
127
+ _reportAfter: '127.0.0.1',
122
128
  });
123
129
  }
124
130
 
@@ -137,6 +143,8 @@ function buildFixes(config) {
137
143
  ' shell commands may pause waiting for approval.\n' +
138
144
  ' You\'ll need to approve commands via the web UI or CLI.',
139
145
  manualNote: 'Restart gateway after applying: openclaw gateway restart',
146
+ _reportBefore: String(execAsk),
147
+ _reportAfter: 'on-miss',
140
148
  });
141
149
  }
142
150
 
@@ -236,6 +244,171 @@ function printImpactSummary(fixes) {
236
244
  return parts.join(paint.dim(' · '));
237
245
  }
238
246
 
247
+ // ── Report support ────────────────────────────────────────────────────────────
248
+
249
+ function getSystemInfo() {
250
+ let osInfo = `${platform()} ${release()}`;
251
+ let ocVersion = 'unknown';
252
+ try {
253
+ const r = spawnSync('openclaw', ['--version'], { encoding: 'utf8', timeout: 5000 });
254
+ if (r.stdout) ocVersion = r.stdout.trim().split('\n')[0] || 'unknown';
255
+ } catch { /* non-fatal */ }
256
+ return { os: osInfo, openclaw_version: ocVersion };
257
+ }
258
+
259
+ function defaultReportPath(format) {
260
+ const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
261
+ const ext = format === 'text' ? 'md' : 'json';
262
+ return join(HOME, '.openclaw', `clawarmor-harden-report-${date}.${ext}`);
263
+ }
264
+
265
+ function buildReportItems({ fixes, applied, skipped, failed, skippedBreaking, applyResults }) {
266
+ const items = [];
267
+ const appliedSet = new Set(applied);
268
+ const skippedSet = new Set(skipped);
269
+ const failedSet = new Set(failed);
270
+
271
+ for (const fix of fixes) {
272
+ if (failedSet.has(fix.id)) {
273
+ const res = applyResults[fix.id];
274
+ items.push({
275
+ check: fix.id,
276
+ status: 'failed',
277
+ action: fix.description,
278
+ error: res?.err || 'unknown error',
279
+ });
280
+ } else if (skippedSet.has(fix.id)) {
281
+ const isBreaking = fix.impact === IMPACT.BREAKING;
282
+ items.push({
283
+ check: fix.id,
284
+ status: 'skipped',
285
+ skipped_reason: isBreaking
286
+ ? 'Breaking fix — skipped in auto mode (use --auto --force to include)'
287
+ : 'User declined',
288
+ });
289
+ } else if (appliedSet.has(fix.id)) {
290
+ items.push({
291
+ check: fix.id,
292
+ status: 'hardened',
293
+ before: fix._reportBefore ?? null,
294
+ after: fix._reportAfter ?? null,
295
+ action: fix.description,
296
+ });
297
+ }
298
+ }
299
+ return items;
300
+ }
301
+
302
+ function writeJsonReport(reportPath, items) {
303
+ const sysInfo = getSystemInfo();
304
+ const hardened = items.filter(i => i.status === 'hardened').length;
305
+ const already_good = items.filter(i => i.status === 'already_good').length;
306
+ const skipped = items.filter(i => i.status === 'skipped').length;
307
+ const failed = items.filter(i => i.status === 'failed').length;
308
+
309
+ const report = {
310
+ version: VERSION,
311
+ timestamp: new Date().toISOString(),
312
+ system: sysInfo,
313
+ summary: {
314
+ total_checks: items.length,
315
+ hardened,
316
+ already_good,
317
+ skipped,
318
+ failed,
319
+ },
320
+ items,
321
+ };
322
+
323
+ try { mkdirSync(dirname(reportPath), { recursive: true }); } catch {}
324
+ writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf8');
325
+ return report;
326
+ }
327
+
328
+ function writeMarkdownReport(reportPath, items) {
329
+ const sysInfo = getSystemInfo();
330
+ const now = new Date();
331
+ const dateStr = now.toLocaleString('en-US', {
332
+ year: 'numeric', month: '2-digit', day: '2-digit',
333
+ hour: '2-digit', minute: '2-digit', hour12: false
334
+ });
335
+
336
+ const hardened = items.filter(i => i.status === 'hardened');
337
+ const alreadyGood = items.filter(i => i.status === 'already_good');
338
+ const skippedItems = items.filter(i => i.status === 'skipped');
339
+ const failedItems = items.filter(i => i.status === 'failed');
340
+
341
+ let md = `# ClawArmor Hardening Report
342
+ Generated: ${dateStr}
343
+ ClawArmor: v${VERSION} | OS: ${sysInfo.os} | OpenClaw: ${sysInfo.openclaw_version}
344
+
345
+ ## Summary
346
+ - ✅ ${alreadyGood.length} check${alreadyGood.length !== 1 ? 's' : ''} already good
347
+ - 🔧 ${hardened.length} hardened
348
+ - ⚠️ ${skippedItems.length} skipped
349
+ ${failedItems.length ? `- ❌ ${failedItems.length} failed\n` : ''}`;
350
+
351
+ if (hardened.length) {
352
+ md += `
353
+ ## Actions Taken
354
+
355
+ | Check | Before | After | Action |
356
+ |-------|--------|-------|--------|
357
+ `;
358
+ for (const item of hardened) {
359
+ md += `| ${item.check} | ${item.before ?? '—'} | ${item.after ?? '—'} | ${item.action ?? '—'} |\n`;
360
+ }
361
+ }
362
+
363
+ if (alreadyGood.length) {
364
+ md += `
365
+ ## Already Good
366
+
367
+ `;
368
+ for (const item of alreadyGood) {
369
+ md += `- ${item.check}\n`;
370
+ }
371
+ }
372
+
373
+ if (skippedItems.length) {
374
+ md += `
375
+ ## Skipped
376
+
377
+ `;
378
+ for (const item of skippedItems) {
379
+ md += `- **${item.check}**: ${item.skipped_reason || 'no reason given'}\n`;
380
+ }
381
+ }
382
+
383
+ if (failedItems.length) {
384
+ md += `
385
+ ## Failed
386
+
387
+ `;
388
+ for (const item of failedItems) {
389
+ md += `- **${item.check}**: ${item.error || 'unknown error'}\n`;
390
+ }
391
+ }
392
+
393
+ try { mkdirSync(dirname(reportPath), { recursive: true }); } catch {}
394
+ writeFileSync(reportPath, md, 'utf8');
395
+ }
396
+
397
+ function printReportSummary(items, reportPath, format) {
398
+ const hardened = items.filter(i => i.status === 'hardened').length;
399
+ const alreadyGood = items.filter(i => i.status === 'already_good').length;
400
+ const skipped = items.filter(i => i.status === 'skipped').length;
401
+ const failed = items.filter(i => i.status === 'failed').length;
402
+
403
+ console.log('');
404
+ console.log(SEP);
405
+ console.log(` ${paint.bold('Hardening Report')}`);
406
+ console.log(` ${paint.green('✅')} ${alreadyGood} already good ${paint.cyan('🔧')} ${hardened} hardened ${paint.yellow('⚠️')} ${skipped} skipped${failed ? ` ${paint.red('❌')} ${failed} failed` : ''}`);
407
+ console.log(` ${paint.dim('Report written:')} ${reportPath}`);
408
+ console.log(` ${paint.dim('Format:')} ${format === 'text' ? 'Markdown (.md)' : 'JSON'}`);
409
+ console.log('');
410
+ }
411
+
239
412
  // ── Main export ───────────────────────────────────────────────────────────────
240
413
 
241
414
  export async function runHarden(flags = {}) {
@@ -285,12 +458,9 @@ export async function runHarden(flags = {}) {
285
458
  const overrideSev = getOverriddenSeverity(profile.name, fix.id);
286
459
  const expected = isExpectedFinding(profile.name, fix.id);
287
460
  if (expected) {
288
- // Mark as expected — skip entirely from harden output
289
461
  return { ...fix, _skipForProfile: true };
290
462
  }
291
463
  if (overrideSev) {
292
- // Upgrade to higher severity if unexpected for this profile
293
- const currentWeight = { SAFE: 1, CAUTION: 2, BREAKING: 3 };
294
464
  const upgradeMap = { HIGH: IMPACT.BREAKING, MEDIUM: IMPACT.CAUTION, INFO: IMPACT.SAFE };
295
465
  const newImpact = upgradeMap[overrideSev] || fix.impact;
296
466
  return { ...fix, impact: newImpact, _profileOverride: overrideSev };
@@ -380,11 +550,24 @@ export async function runHarden(flags = {}) {
380
550
  console.log(` ${paint.dim('•')} ${item}`);
381
551
  }
382
552
  console.log('');
553
+
554
+ // If report requested but nothing to harden, still write an empty/all-good report
555
+ if (flags.report) {
556
+ const format = flags.reportFormat || 'json';
557
+ const reportPath = flags.reportPath || defaultReportPath(format);
558
+ const items = []; // no fixes, no items (could add "already_good" items if we tracked checks)
559
+ if (format === 'text') {
560
+ writeMarkdownReport(reportPath, items);
561
+ } else {
562
+ writeJsonReport(reportPath, items);
563
+ }
564
+ printReportSummary(items, reportPath, format);
565
+ }
566
+
383
567
  return 0;
384
568
  }
385
569
 
386
570
  if (flags.auto) {
387
- // --auto mode: apply safe + caution, SKIP breaking unless --force
388
571
  const autoLabel = flags.force
389
572
  ? paint.cyan('Auto mode (--force) — applying ALL fixes including breaking')
390
573
  : paint.cyan('Auto mode — applying safe + caution fixes (skipping breaking)');
@@ -406,6 +589,12 @@ export async function runHarden(flags = {}) {
406
589
  let applied = 0, skipped = 0, failed = 0, skippedBreaking = 0;
407
590
  const restartNotes = [];
408
591
 
592
+ // Report tracking
593
+ const appliedIds = [];
594
+ const skippedIds = [];
595
+ const failedIds = [];
596
+ const applyResults = {};
597
+
409
598
  for (const fix of fixes) {
410
599
  const badge = IMPACT_BADGE[fix.impact]();
411
600
 
@@ -420,17 +609,16 @@ export async function runHarden(flags = {}) {
420
609
  let doApply;
421
610
 
422
611
  if (flags.auto) {
423
- // In auto mode: apply safe + caution, skip breaking unless --force
424
612
  if (fix.impact === IMPACT.BREAKING && !flags.force) {
425
613
  console.log(` ${paint.red('⊘ Skipped')} ${paint.dim('(breaking — use --auto --force to include)')}`);
426
614
  skippedBreaking++;
427
615
  skipped++;
616
+ skippedIds.push(fix.id);
428
617
  console.log('');
429
618
  continue;
430
619
  }
431
620
  doApply = true;
432
621
  } else {
433
- // Interactive mode: always ask, but warn about breaking
434
622
  if (fix.impact === IMPACT.BREAKING) {
435
623
  console.log(` ${paint.red('⚠ This fix will change how your agent works.')}`);
436
624
  console.log(` ${paint.red(' Read the impact above carefully before applying.')}`);
@@ -442,18 +630,22 @@ export async function runHarden(flags = {}) {
442
630
  if (!doApply) {
443
631
  console.log(` ${paint.dim('✗ Skipped')}`);
444
632
  skipped++;
633
+ skippedIds.push(fix.id);
445
634
  console.log('');
446
635
  continue;
447
636
  }
448
637
 
449
638
  const result = applyFix(fix);
639
+ applyResults[fix.id] = result;
450
640
  if (result.ok) {
451
641
  console.log(` ${paint.green('✓ Fixed')}`);
452
642
  applied++;
643
+ appliedIds.push(fix.id);
453
644
  if (fix.manualNote) restartNotes.push(fix.manualNote);
454
645
  } else {
455
646
  console.log(` ${paint.red('✗ Failed:')} ${result.err}`);
456
647
  failed++;
648
+ failedIds.push(fix.id);
457
649
  }
458
650
  console.log('');
459
651
  }
@@ -505,6 +697,28 @@ export async function runHarden(flags = {}) {
505
697
  }
506
698
  }
507
699
 
700
+ // ── Write report if requested ──────────────────────────────────────────────
701
+ if (flags.report) {
702
+ const format = flags.reportFormat || 'json';
703
+ const reportPath = flags.reportPath || defaultReportPath(format);
704
+
705
+ const reportItems = buildReportItems({
706
+ fixes,
707
+ applied: appliedIds,
708
+ skipped: skippedIds,
709
+ failed: failedIds,
710
+ applyResults,
711
+ });
712
+
713
+ if (format === 'text') {
714
+ writeMarkdownReport(reportPath, reportItems);
715
+ } else {
716
+ writeJsonReport(reportPath, reportItems);
717
+ }
718
+
719
+ printReportSummary(reportItems, reportPath, format);
720
+ }
721
+
508
722
  console.log('');
509
723
  return failed > 0 ? 1 : 0;
510
724
  }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawarmor",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "Security armor for OpenClaw agents — audit, scan, monitor",
5
5
  "bin": {
6
6
  "clawarmor": "cli.js"