clawarmor 2.1.0 → 2.2.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 CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { paint } from './lib/output/colors.js';
5
5
 
6
- const VERSION = '2.1.0';
6
+ const VERSION = '2.2.0';
7
7
  const GATEWAY_PORT_DEFAULT = 18789;
8
8
 
9
9
  function isLocalhost(host) {
@@ -45,7 +45,8 @@ function usage() {
45
45
  console.log(` ${paint.cyan('trend')} Show score over last N audits (ASCII chart)`);
46
46
  console.log(` ${paint.cyan('compare')} Compare coverage vs openclaw security audit`);
47
47
  console.log(` ${paint.cyan('fix')} Auto-apply safe fixes (--dry-run to preview, --apply to run)`);
48
- console.log(` ${paint.cyan('harden')} Interactive hardening wizard (--dry-run, --auto)`);
48
+ console.log(` ${paint.cyan('harden')} Interactive hardening wizard (--dry-run, --auto, --monitor)`);
49
+ console.log(` ${paint.cyan('rollback')} Restore config from a snapshot (--list, --id <id>)`);
49
50
  console.log(` ${paint.cyan('status')} One-screen security posture dashboard`);
50
51
  console.log(` ${paint.cyan('watch')} Monitor config and skill changes in real time`);
51
52
  console.log(` ${paint.cyan('protect')} Install/uninstall/status the full guard system`);
@@ -203,11 +204,24 @@ if (cmd === 'harden') {
203
204
  dryRun: args.includes('--dry-run'),
204
205
  auto: args.includes('--auto'),
205
206
  force: args.includes('--force'),
207
+ monitor: args.includes('--monitor'),
208
+ monitorReport: args.includes('--monitor-report'),
209
+ monitorOff: args.includes('--monitor-off'),
206
210
  };
207
211
  const { runHarden } = await import('./lib/harden.js');
208
212
  process.exit(await runHarden(hardenFlags));
209
213
  }
210
214
 
215
+ if (cmd === 'rollback') {
216
+ const idIdx = args.indexOf('--id');
217
+ const rollbackFlags = {
218
+ list: args.includes('--list'),
219
+ id: idIdx !== -1 ? args[idIdx + 1] : null,
220
+ };
221
+ const { runRollback } = await import('./lib/rollback.js');
222
+ process.exit(await runRollback(rollbackFlags));
223
+ }
224
+
211
225
  if (cmd === 'status') {
212
226
  const { runStatus } = await import('./lib/status.js');
213
227
  process.exit(await runStatus());
package/lib/fix.js CHANGED
@@ -1,13 +1,15 @@
1
1
  // clawarmor fix — auto-apply safe one-liner fixes
2
2
  // Now with impact classification: safe / caution / breaking
3
- import { readFileSync } from 'fs';
3
+ import { readFileSync, existsSync, statSync } from 'fs';
4
4
  import { homedir } from 'os';
5
5
  import { join } from 'path';
6
6
  import { execSync, spawnSync } from 'child_process';
7
7
  import { paint } from './output/colors.js';
8
8
  import { loadConfig, get } from './config.js';
9
+ import { saveSnapshot } from './snapshot.js';
9
10
 
10
- const HISTORY_PATH = join(homedir(), '.clawarmor', 'history.json');
11
+ const HOME = homedir();
12
+ const HISTORY_PATH = join(HOME, '.clawarmor', 'history.json');
11
13
  const SEP = paint.dim('─'.repeat(52));
12
14
 
13
15
  // Impact levels
@@ -97,6 +99,26 @@ const AUTO_FIXABLE = {
97
99
  },
98
100
  };
99
101
 
102
+ function expandHome(p) {
103
+ return p.startsWith('~') ? HOME + p.slice(1) : p;
104
+ }
105
+
106
+ function collectFilePermissions(ids) {
107
+ const perms = {};
108
+ for (const id of ids) {
109
+ const f = AUTO_FIXABLE[id];
110
+ if (!f || !f.shell) continue;
111
+ const m = f.cmd.match(/^chmod\s+\d+\s+(.+)$/);
112
+ if (!m) continue;
113
+ const p = expandHome(m[1].trim());
114
+ try {
115
+ const mode = statSync(p).mode & 0o777;
116
+ perms[p] = mode.toString(8).padStart(3, '0');
117
+ } catch { /* file may not exist */ }
118
+ }
119
+ return perms;
120
+ }
121
+
100
122
  function box(title) {
101
123
  const W=52, pad=W-2-title.length, l=Math.floor(pad/2), r=pad-l;
102
124
  return [paint.dim('╔'+'═'.repeat(W-2)+'╗'),
@@ -139,7 +161,7 @@ export async function runFix(flags = {}) {
139
161
  }
140
162
 
141
163
  // Load config for mainKey check
142
- const { config: cfg } = loadConfig();
164
+ const { config: cfg, configPath } = loadConfig();
143
165
  const mainKey = get(cfg, 'agents.mainKey', 'main');
144
166
 
145
167
  console.log('');
@@ -176,6 +198,12 @@ export async function runFix(flags = {}) {
176
198
  console.log(''); return 0;
177
199
  }
178
200
 
201
+ // ── Snapshot before applying any fix ──────────────────────────────────────
202
+ const applyTrigger = flags.force ? 'fix --apply --force' : 'fix --apply';
203
+ const toApply = fixable.filter(id => flags.force || AUTO_FIXABLE[id].impact !== IMPACT.BREAKING);
204
+ const configContent = (() => { try { return readFileSync(configPath, 'utf8'); } catch { return null; } })();
205
+ saveSnapshot({ trigger: applyTrigger, configPath, configContent, filePermissions: collectFilePermissions(toApply), appliedFixes: toApply });
206
+
179
207
  // Apply fixes
180
208
  let applied = 0, failed = 0, skippedBreaking = 0, needsRestart = false;
181
209
  console.log(SEP);
package/lib/harden.js CHANGED
@@ -13,6 +13,8 @@ import { createInterface } from 'readline';
13
13
  import { paint } from './output/colors.js';
14
14
  import { scoreToGrade, scoreColor, gradeColor } from './output/progress.js';
15
15
  import { loadConfig, get } from './config.js';
16
+ import { saveSnapshot } from './snapshot.js';
17
+ import { enableMonitor, disableMonitor, getMonitorStatus, printMonitorReport } from './monitor.js';
16
18
 
17
19
  const HOME = homedir();
18
20
  const OC_DIR = join(HOME, '.openclaw');
@@ -202,6 +204,23 @@ function getManualItems() {
202
204
  ];
203
205
  }
204
206
 
207
+ // ── Collect current file permissions for shell chmod fixes ────────────────────
208
+
209
+ function collectFilePermissions(fixes) {
210
+ const perms = {};
211
+ for (const fix of fixes) {
212
+ if (fix.type !== 'shell') continue;
213
+ const m = fix.action.match(/^chmod\s+\d+\s+(.+)$/);
214
+ if (!m) continue;
215
+ const p = m[1].trim();
216
+ try {
217
+ const mode = statSync(p).mode & 0o777;
218
+ perms[p] = mode.toString(8).padStart(3, '0');
219
+ } catch { /* file may not exist */ }
220
+ }
221
+ return perms;
222
+ }
223
+
205
224
  // ── Print impact summary ──────────────────────────────────────────────────────
206
225
 
207
226
  function printImpactSummary(fixes) {
@@ -219,11 +238,44 @@ function printImpactSummary(fixes) {
219
238
  // ── Main export ───────────────────────────────────────────────────────────────
220
239
 
221
240
  export async function runHarden(flags = {}) {
241
+ // ── Monitor advisory flags (early return, no box) ──────────────────────────
242
+ if (flags.monitorOff) {
243
+ const ok = disableMonitor();
244
+ console.log('');
245
+ console.log(ok
246
+ ? ` ${paint.green('✓')} Monitor mode disabled.`
247
+ : ` ${paint.red('✗')} Failed to disable monitor mode.`);
248
+ console.log('');
249
+ return ok ? 0 : 1;
250
+ }
251
+
252
+ if (flags.monitorReport) {
253
+ const status = getMonitorStatus();
254
+ printMonitorReport(status);
255
+ return 0;
256
+ }
257
+
222
258
  console.log(''); console.log(box('ClawArmor Harden v2.1')); console.log('');
223
259
 
224
- const { config } = loadConfig();
260
+ const { config, configPath } = loadConfig();
225
261
  const fixes = buildFixes(config);
226
262
 
263
+ // ── Monitor enable (advisory only, no apply) ───────────────────────────────
264
+ if (flags.monitor) {
265
+ const fixIds = fixes.map(f => f.id);
266
+ const ok = enableMonitor(fixIds);
267
+ if (ok) {
268
+ console.log(` ${paint.green('✓')} Monitor mode enabled.`);
269
+ console.log(` ${paint.dim('Observing:')} ${fixIds.length ? fixIds.join(', ') : 'no current fixes found'}`);
270
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor harden --monitor-report')} ${paint.dim('to see what would have changed.')}`);
271
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor harden --monitor-off')} ${paint.dim('to disable.')}`);
272
+ } else {
273
+ console.log(` ${paint.red('✗')} Failed to enable monitor mode.`);
274
+ }
275
+ console.log('');
276
+ return ok ? 0 : 1;
277
+ }
278
+
227
279
  // Snapshot before score
228
280
  const before = loadLastHistoryEntry();
229
281
  const beforeScore = before?.score ?? null;
@@ -306,6 +358,13 @@ export async function runHarden(flags = {}) {
306
358
  }
307
359
  console.log('');
308
360
 
361
+ // ── Snapshot before applying any fix ──────────────────────────────────────
362
+ const trigger = flags.auto
363
+ ? (flags.force ? 'harden --auto --force' : 'harden --auto')
364
+ : 'harden --interactive';
365
+ const configContent = (() => { try { return readFileSync(configPath, 'utf8'); } catch { return null; } })();
366
+ saveSnapshot({ trigger, configPath, configContent, filePermissions: collectFilePermissions(fixes), appliedFixes: fixes.map(f => f.id) });
367
+
309
368
  let applied = 0, skipped = 0, failed = 0, skippedBreaking = 0;
310
369
  const restartNotes = [];
311
370
 
package/lib/monitor.js ADDED
@@ -0,0 +1,131 @@
1
+ // clawarmor monitor — Monitor mode state management and reporting.
2
+ // Monitor state stored in ~/.clawarmor/monitor.json:
3
+ // { enabled: true, startedAt: ISO, fixes: [fix ids being monitored] }
4
+ // Advisory only — no config changes are applied.
5
+
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { homedir } from 'os';
9
+ import { paint } from './output/colors.js';
10
+
11
+ const HOME = homedir();
12
+ const CLAWARMOR_DIR = join(HOME, '.clawarmor');
13
+ const MONITOR_FILE = join(CLAWARMOR_DIR, 'monitor.json');
14
+ const AUDIT_LOG = join(CLAWARMOR_DIR, 'audit.log');
15
+
16
+ const SEP = paint.dim('─'.repeat(52));
17
+
18
+ /**
19
+ * Get current monitor mode status.
20
+ * @returns {{ enabled: boolean, startedAt?: string, fixes?: string[] }}
21
+ */
22
+ export function getMonitorStatus() {
23
+ if (!existsSync(MONITOR_FILE)) return { enabled: false };
24
+ try { return JSON.parse(readFileSync(MONITOR_FILE, 'utf8')); } catch { return { enabled: false }; }
25
+ }
26
+
27
+ /**
28
+ * Enable monitor mode, recording the fix ids to observe.
29
+ * @param {string[]} fixIds
30
+ * @returns {boolean} success
31
+ */
32
+ export function enableMonitor(fixIds = []) {
33
+ try {
34
+ if (!existsSync(CLAWARMOR_DIR)) mkdirSync(CLAWARMOR_DIR, { recursive: true });
35
+ const data = { enabled: true, startedAt: new Date().toISOString(), fixes: fixIds };
36
+ writeFileSync(MONITOR_FILE, JSON.stringify(data, null, 2), 'utf8');
37
+ return true;
38
+ } catch { return false; }
39
+ }
40
+
41
+ /**
42
+ * Disable monitor mode by removing the state file.
43
+ * @returns {boolean} success
44
+ */
45
+ export function disableMonitor() {
46
+ try {
47
+ if (existsSync(MONITOR_FILE)) unlinkSync(MONITOR_FILE);
48
+ return true;
49
+ } catch { return false; }
50
+ }
51
+
52
+ /** @returns {Array<Object>} parsed audit log entries since given ISO timestamp */
53
+ function readAuditsSince(sinceIso) {
54
+ if (!existsSync(AUDIT_LOG)) return [];
55
+ try {
56
+ const since = new Date(sinceIso).getTime();
57
+ return readFileSync(AUDIT_LOG, 'utf8')
58
+ .split('\n').filter(Boolean)
59
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
60
+ .filter(l => l && new Date(l.ts).getTime() >= since);
61
+ } catch { return []; }
62
+ }
63
+
64
+ /**
65
+ * Print a monitor mode report showing audit activity since monitoring started.
66
+ * @param {{ enabled: boolean, startedAt?: string, fixes?: string[] }} status
67
+ */
68
+ export function printMonitorReport(status) {
69
+ console.log('');
70
+ console.log(` ${paint.bold('Monitor Mode Report')}`);
71
+ console.log('');
72
+
73
+ if (!status.enabled || !status.startedAt) {
74
+ console.log(` ${paint.dim('Monitor mode is not active.')}`);
75
+ console.log(` ${paint.dim('Enable with:')} ${paint.cyan('clawarmor harden --monitor')}`);
76
+ console.log('');
77
+ return;
78
+ }
79
+
80
+ const monitoredFixes = status.fixes || [];
81
+ const audits = readAuditsSince(status.startedAt);
82
+
83
+ console.log(` ${paint.dim('Started:')} ${new Date(status.startedAt).toLocaleString()}`);
84
+ console.log(` ${paint.dim('Monitoring:')} ${monitoredFixes.length ? monitoredFixes.join(', ') : 'all available fixes'}`);
85
+ console.log(` ${paint.dim('Audits run:')} ${audits.length}`);
86
+ console.log('');
87
+
88
+ if (!audits.length) {
89
+ console.log(` ${paint.dim('No audits found since monitoring started.')}`);
90
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor audit')} ${paint.dim('to generate data.')}`);
91
+ console.log('');
92
+ return;
93
+ }
94
+
95
+ console.log(SEP);
96
+ console.log('');
97
+
98
+ const scores = audits.map(a => a.score).filter(s => typeof s === 'number');
99
+ if (scores.length >= 2) {
100
+ const delta = scores[scores.length - 1] - scores[0];
101
+ const deltaStr = delta > 0 ? paint.green(`+${delta}`) : delta < 0 ? paint.red(String(delta)) : paint.dim('±0');
102
+ console.log(` ${paint.dim('Score change:')} ${scores[0]} → ${scores[scores.length - 1]} (${deltaStr})`);
103
+ } else if (scores.length === 1) {
104
+ console.log(` ${paint.dim('Score observed:')} ${scores[0]}`);
105
+ }
106
+
107
+ let criticalCount = 0, highCount = 0;
108
+ for (const a of audits) {
109
+ if (Array.isArray(a.findings)) {
110
+ for (const f of a.findings) {
111
+ if (f.severity === 'CRITICAL') criticalCount++;
112
+ else if (f.severity === 'HIGH') highCount++;
113
+ }
114
+ }
115
+ }
116
+ if (criticalCount > 0 || highCount > 0) {
117
+ console.log(` ${paint.dim('Findings:')} ${paint.red(String(criticalCount) + ' critical')} ${paint.dim('·')} ${paint.yellow(String(highCount) + ' high')}`);
118
+ }
119
+
120
+ console.log('');
121
+
122
+ const lastScore = scores.length ? scores[scores.length - 1] : null;
123
+ if (lastScore !== null && lastScore < 75) {
124
+ console.log(` ${paint.yellow('!')} ${paint.bold('Recommendation:')} Score is below 75. Consider applying fixes:`);
125
+ console.log(` ${paint.dim('Run:')} ${paint.cyan('clawarmor harden')}`);
126
+ } else {
127
+ console.log(` ${paint.green('✓')} Monitoring period looks stable.`);
128
+ console.log(` ${paint.dim('When ready, apply fixes with:')} ${paint.cyan('clawarmor harden')}`);
129
+ }
130
+ console.log('');
131
+ }
@@ -0,0 +1,98 @@
1
+ // clawarmor rollback — Restore config from a saved snapshot.
2
+ // clawarmor rollback Restore most recent snapshot
3
+ // clawarmor rollback --list List all available snapshots with details
4
+ // clawarmor rollback --id <id> Restore a specific snapshot by id
5
+
6
+ import { paint } from './output/colors.js';
7
+ import { listSnapshots, loadSnapshot, loadLatestSnapshot, restoreSnapshot } from './snapshot.js';
8
+
9
+ const SEP = paint.dim('─'.repeat(52));
10
+
11
+ function box(title) {
12
+ const W = 52, pad = W - 2 - title.length, l = Math.floor(pad / 2), r = pad - l;
13
+ return [
14
+ paint.dim('╔' + '═'.repeat(W - 2) + '╗'),
15
+ paint.dim('║') + ' '.repeat(l) + paint.bold(title) + ' '.repeat(r) + paint.dim('║'),
16
+ paint.dim('╚' + '═'.repeat(W - 2) + '╝'),
17
+ ].join('\n');
18
+ }
19
+
20
+ function fmtDate(isoString) {
21
+ if (!isoString) return 'unknown';
22
+ try { return new Date(isoString).toLocaleString(); } catch { return isoString; }
23
+ }
24
+
25
+ /**
26
+ * Main rollback command.
27
+ * @param {{ list?: boolean, id?: string }} flags
28
+ */
29
+ export async function runRollback(flags = {}) {
30
+ console.log(''); console.log(box('ClawArmor Rollback')); console.log('');
31
+
32
+ // ── LIST mode ─────────────────────────────────────────────────────────────
33
+ if (flags.list) {
34
+ const snapshots = listSnapshots();
35
+ if (!snapshots.length) {
36
+ console.log(` ${paint.dim('No snapshots found.')}`);
37
+ console.log(` ${paint.dim('Snapshots are created automatically before every harden or fix run.')}`);
38
+ console.log('');
39
+ return 0;
40
+ }
41
+
42
+ console.log(` ${paint.bold(String(snapshots.length))} snapshot${snapshots.length !== 1 ? 's' : ''} available:`);
43
+ console.log('');
44
+ for (const s of snapshots) {
45
+ const fixList = s.appliedFixes.length ? s.appliedFixes.join(', ') : 'no fixes recorded';
46
+ console.log(` ${paint.cyan(s.id)}`);
47
+ console.log(` ${paint.dim('Date:')} ${fmtDate(s.timestamp)}`);
48
+ console.log(` ${paint.dim('Trigger:')} ${s.trigger}`);
49
+ console.log(` ${paint.dim('Fixes:')} ${fixList}`);
50
+ console.log('');
51
+ }
52
+ console.log(` ${paint.dim('To restore a specific snapshot:')} clawarmor rollback --id <id>`);
53
+ console.log('');
54
+ return 0;
55
+ }
56
+
57
+ // ── RESTORE specific snapshot or most recent ───────────────────────────────
58
+ let snapshot;
59
+ if (flags.id) {
60
+ snapshot = loadSnapshot(flags.id);
61
+ if (!snapshot) {
62
+ console.log(` ${paint.red('✗')} Snapshot not found: ${paint.bold(flags.id)}`);
63
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor rollback --list')} ${paint.dim('to see available snapshots.')}`);
64
+ console.log('');
65
+ return 1;
66
+ }
67
+ } else {
68
+ snapshot = loadLatestSnapshot();
69
+ if (!snapshot) {
70
+ console.log(` ${paint.dim('No snapshots found.')}`);
71
+ console.log(` ${paint.dim('Snapshots are created automatically before every harden or fix run.')}`);
72
+ console.log('');
73
+ return 0;
74
+ }
75
+ }
76
+
77
+ console.log(` ${paint.dim('Snapshot:')} ${fmtDate(snapshot.timestamp)}`);
78
+ console.log(` ${paint.dim('Trigger:')} ${snapshot.trigger}`);
79
+ if (snapshot.appliedFixes?.length) {
80
+ console.log(` ${paint.dim('Fixes:')} ${snapshot.appliedFixes.join(', ')}`);
81
+ }
82
+ console.log('');
83
+ console.log(SEP);
84
+ console.log('');
85
+
86
+ const result = restoreSnapshot(snapshot);
87
+ if (!result.ok) {
88
+ console.log(` ${paint.red('✗')} Restore failed: ${result.err}`);
89
+ console.log('');
90
+ return 1;
91
+ }
92
+
93
+ const dateStr = fmtDate(snapshot.timestamp);
94
+ console.log(` ${paint.green('✓')} Restored to snapshot from ${paint.bold(dateStr)}.`);
95
+ console.log(` ${paint.dim('Run')} ${paint.cyan('openclaw gateway restart')} ${paint.dim('to apply.')}`);
96
+ console.log('');
97
+ return 0;
98
+ }
@@ -0,0 +1,104 @@
1
+ // clawarmor snapshot — Config snapshot save/load/list/restore.
2
+ // Snapshots are saved to ~/.clawarmor/snapshots/<timestamp>.json.
3
+ // Max 20 snapshots are kept; oldest are pruned on new save.
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, chmodSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { homedir } from 'os';
8
+
9
+ const HOME = homedir();
10
+ const SNAPSHOTS_DIR = join(HOME, '.clawarmor', 'snapshots');
11
+ const MAX_SNAPSHOTS = 20;
12
+
13
+ /** @returns {string[]} snapshot filenames sorted oldest-first */
14
+ function listSnapshotFiles() {
15
+ if (!existsSync(SNAPSHOTS_DIR)) return [];
16
+ try {
17
+ return readdirSync(SNAPSHOTS_DIR).filter(f => f.endsWith('.json')).sort();
18
+ } catch { return []; }
19
+ }
20
+
21
+ /** Prune to MAX_SNAPSHOTS, removing oldest entries. */
22
+ function pruneSnapshots() {
23
+ const files = listSnapshotFiles();
24
+ if (files.length <= MAX_SNAPSHOTS) return;
25
+ for (const f of files.slice(0, files.length - MAX_SNAPSHOTS)) {
26
+ try { unlinkSync(join(SNAPSHOTS_DIR, f)); } catch { /* non-fatal */ }
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Save a snapshot before applying fixes.
32
+ * @param {{ trigger: string, configPath: string, configContent: string|null, filePermissions: Object, appliedFixes: string[] }} opts
33
+ * @returns {string|null} snapshot id or null on failure
34
+ */
35
+ export function saveSnapshot({ trigger, configPath, configContent, filePermissions = {}, appliedFixes = [] }) {
36
+ try {
37
+ if (!existsSync(SNAPSHOTS_DIR)) mkdirSync(SNAPSHOTS_DIR, { recursive: true });
38
+ const timestamp = new Date().toISOString();
39
+ const id = timestamp.replace(/[:.]/g, '-');
40
+ const snapshot = { timestamp, trigger, configPath, configContent, filePermissions, appliedFixes };
41
+ writeFileSync(join(SNAPSHOTS_DIR, `${id}.json`), JSON.stringify(snapshot, null, 2), 'utf8');
42
+ pruneSnapshots();
43
+ return id;
44
+ } catch { return null; }
45
+ }
46
+
47
+ /**
48
+ * List all snapshots, newest first.
49
+ * @returns {Array<{ id: string, timestamp: string, trigger: string, appliedFixes: string[], configPath: string }>}
50
+ */
51
+ export function listSnapshots() {
52
+ return listSnapshotFiles().reverse().map(f => {
53
+ try {
54
+ const data = JSON.parse(readFileSync(join(SNAPSHOTS_DIR, f), 'utf8'));
55
+ return {
56
+ id: f.replace('.json', ''),
57
+ timestamp: data.timestamp,
58
+ trigger: data.trigger || 'unknown',
59
+ appliedFixes: data.appliedFixes || [],
60
+ configPath: data.configPath || null,
61
+ };
62
+ } catch { return null; }
63
+ }).filter(Boolean);
64
+ }
65
+
66
+ /**
67
+ * Load a specific snapshot by id.
68
+ * @param {string} id
69
+ * @returns {Object|null}
70
+ */
71
+ export function loadSnapshot(id) {
72
+ const filePath = join(SNAPSHOTS_DIR, `${id}.json`);
73
+ if (!existsSync(filePath)) return null;
74
+ try { return JSON.parse(readFileSync(filePath, 'utf8')); } catch { return null; }
75
+ }
76
+
77
+ /**
78
+ * Load the most recent snapshot.
79
+ * @returns {Object|null}
80
+ */
81
+ export function loadLatestSnapshot() {
82
+ const files = listSnapshotFiles();
83
+ if (!files.length) return null;
84
+ try { return JSON.parse(readFileSync(join(SNAPSHOTS_DIR, files[files.length - 1]), 'utf8')); } catch { return null; }
85
+ }
86
+
87
+ /**
88
+ * Restore a snapshot: writes config content and restores file permissions.
89
+ * @param {Object} snapshot
90
+ * @returns {{ ok: boolean, err?: string }}
91
+ */
92
+ export function restoreSnapshot(snapshot) {
93
+ try {
94
+ if (snapshot.configPath && snapshot.configContent != null) {
95
+ writeFileSync(snapshot.configPath, snapshot.configContent, 'utf8');
96
+ }
97
+ for (const [filePath, octalStr] of Object.entries(snapshot.filePermissions || {})) {
98
+ try {
99
+ if (existsSync(filePath)) chmodSync(filePath, parseInt(octalStr, 8));
100
+ } catch { /* non-fatal per-file */ }
101
+ }
102
+ return { ok: true };
103
+ } catch (e) { return { ok: false, err: e.message }; }
104
+ }
package/lib/status.js CHANGED
@@ -7,6 +7,7 @@ import { homedir } from 'os';
7
7
  import { paint } from './output/colors.js';
8
8
  import { scoreToGrade, scoreColor, gradeColor } from './output/progress.js';
9
9
  import { watchDaemonStatus } from './watch.js';
10
+ import { getMonitorStatus } from './monitor.js';
10
11
 
11
12
  const HOME = homedir();
12
13
  const OC_DIR = join(HOME, '.openclaw');
@@ -20,7 +21,7 @@ const FISH_FUNCTION_FILE = join(HOME, '.config', 'fish', 'functions', 'openclaw.
20
21
  const SHELL_MARKER = '# ClawArmor intercept — added by: clawarmor protect --install';
21
22
  const CRON_JOBS_FILE = join(OC_DIR, 'cron', 'jobs.json');
22
23
  const HOOKS_DIR = join(OC_DIR, 'hooks', 'clawarmor-guard');
23
- const VERSION = '2.0.0';
24
+ const VERSION = '2.2.0';
24
25
 
25
26
  const SEP = paint.dim('─'.repeat(52));
26
27
 
@@ -212,6 +213,14 @@ export async function runStatus() {
212
213
  }
213
214
  console.log(` ${paint.dim('Watcher')} ${watchStr}`);
214
215
 
216
+ // ── Monitor mode ──────────────────────────────────────────────────────────
217
+ const monitorStatus = getMonitorStatus();
218
+ if (monitorStatus.enabled) {
219
+ const startedAgo = timeAgo(monitorStatus.startedAt);
220
+ const fixCount = monitorStatus.fixes?.length || 0;
221
+ console.log(` ${paint.dim('Monitor')} ${paint.yellow('●')} ${paint.bold('active')} ${paint.dim('(started ' + startedAgo + ', ' + fixCount + ' fix' + (fixCount !== 1 ? 'es' : '') + ')')} ${paint.dim('→ clawarmor harden --monitor-report')}`);
222
+ }
223
+
215
224
  // ── Shell intercept ───────────────────────────────────────────────────────
216
225
  const icp = intercept();
217
226
  const icpStr = icp.active
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawarmor",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Security armor for OpenClaw agents \u2014 audit, scan, monitor",
5
5
  "bin": {
6
6
  "clawarmor": "cli.js"