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 +16 -2
- package/lib/fix.js +31 -3
- package/lib/harden.js +60 -1
- package/lib/monitor.js +131 -0
- package/lib/rollback.js +98 -0
- package/lib/snapshot.js +104 -0
- package/lib/status.js +10 -1
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { paint } from './lib/output/colors.js';
|
|
5
5
|
|
|
6
|
-
const VERSION = '2.
|
|
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
|
|
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
|
+
}
|
package/lib/rollback.js
ADDED
|
@@ -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
|
+
}
|
package/lib/snapshot.js
ADDED
|
@@ -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.
|
|
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
|