clawarmor 1.2.0 → 2.0.0-alpha.2
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 +44 -2
- package/lib/audit-log.js +38 -0
- package/lib/audit.js +37 -2
- package/lib/checks/credential-files.js +156 -0
- package/lib/checks/exec-approval.js +64 -0
- package/lib/checks/git-credential-leak.js +135 -0
- package/lib/checks/skill-pinning.js +159 -0
- package/lib/checks/token-age.js +180 -0
- package/lib/log-viewer.js +140 -0
- package/lib/prescan.js +167 -0
- package/lib/protect.js +333 -0
- package/lib/scan.js +14 -0
- package/lib/watch.js +235 -0
- package/package.json +1 -1
package/lib/protect.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// clawarmor protect — Install/uninstall/status the full ClawArmor guard system.
|
|
2
|
+
// --install: writes hook files, adds shell intercept, starts watch daemon
|
|
3
|
+
// --uninstall: reverses all of the above cleanly
|
|
4
|
+
// --status: shows current state without modifying anything
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync, rmSync,
|
|
8
|
+
} from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
import { spawnSync } from 'child_process';
|
|
12
|
+
import { watchDaemonStatus } from './watch.js';
|
|
13
|
+
|
|
14
|
+
const HOME = homedir();
|
|
15
|
+
const OC_DIR = join(HOME, '.openclaw');
|
|
16
|
+
const CLAWARMOR_DIR = join(HOME, '.clawarmor');
|
|
17
|
+
const HOOKS_DIR = join(OC_DIR, 'hooks');
|
|
18
|
+
const GUARD_HOOK_DIR = join(HOOKS_DIR, 'clawarmor-guard');
|
|
19
|
+
const CLI_PATH = new URL('../cli.js', import.meta.url).pathname;
|
|
20
|
+
|
|
21
|
+
const SHELL_FUNCTION = `
|
|
22
|
+
# ClawArmor intercept — added by: clawarmor protect --install
|
|
23
|
+
# Wraps 'openclaw clawhub install' to scan skills before activation.
|
|
24
|
+
openclaw() {
|
|
25
|
+
if [ "$1" = "clawhub" ] && [ "$2" = "install" ] && [ -n "$3" ]; then
|
|
26
|
+
echo "ClawArmor: scanning $3 before install..."
|
|
27
|
+
clawarmor prescan "$3" || { echo "Blocked by ClawArmor. Use --force to override."; return 1; }
|
|
28
|
+
fi
|
|
29
|
+
command openclaw "$@"
|
|
30
|
+
}
|
|
31
|
+
# End ClawArmor intercept
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const SHELL_MARKER_START = '# ClawArmor intercept — added by: clawarmor protect --install';
|
|
35
|
+
const SHELL_MARKER_END = '# End ClawArmor intercept';
|
|
36
|
+
|
|
37
|
+
const HOOK_MD = `---
|
|
38
|
+
name: clawarmor-guard
|
|
39
|
+
description: Runs a silent security audit on gateway startup and alerts on regressions
|
|
40
|
+
events:
|
|
41
|
+
- gateway:startup
|
|
42
|
+
requires:
|
|
43
|
+
bins:
|
|
44
|
+
- clawarmor
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
# clawarmor-guard
|
|
48
|
+
|
|
49
|
+
Fires on every gateway startup. Runs \`clawarmor audit --json\` in the background,
|
|
50
|
+
compares the score to the last known baseline, and alerts the agent if the score
|
|
51
|
+
drops by 5 or more points, or if a new CRITICAL finding appears.
|
|
52
|
+
|
|
53
|
+
Install with: \`clawarmor protect --install\`
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const HANDLER_JS = `// clawarmor-guard hook handler
|
|
57
|
+
// Fires on gateway:startup. Silent unless score drops or CRITICAL finding appears.
|
|
58
|
+
// No external dependencies.
|
|
59
|
+
|
|
60
|
+
import { spawnSync } from 'child_process';
|
|
61
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
62
|
+
import { join } from 'path';
|
|
63
|
+
import { homedir } from 'os';
|
|
64
|
+
|
|
65
|
+
const HOME = homedir();
|
|
66
|
+
const CLAWARMOR_DIR = join(HOME, '.clawarmor');
|
|
67
|
+
const LAST_SCORE_FILE = join(CLAWARMOR_DIR, 'last-score.json');
|
|
68
|
+
|
|
69
|
+
function readLastScore() {
|
|
70
|
+
try {
|
|
71
|
+
if (existsSync(LAST_SCORE_FILE)) return JSON.parse(readFileSync(LAST_SCORE_FILE, 'utf8'));
|
|
72
|
+
} catch {}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function writeLastScore(data) {
|
|
77
|
+
try {
|
|
78
|
+
mkdirSync(CLAWARMOR_DIR, { recursive: true });
|
|
79
|
+
writeFileSync(LAST_SCORE_FILE, JSON.stringify(data, null, 2), 'utf8');
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function runAuditJson() {
|
|
84
|
+
try {
|
|
85
|
+
const result = spawnSync('clawarmor', ['audit', '--json'], {
|
|
86
|
+
encoding: 'utf8',
|
|
87
|
+
timeout: 30000,
|
|
88
|
+
maxBuffer: 1024 * 1024,
|
|
89
|
+
});
|
|
90
|
+
if (result.stdout) {
|
|
91
|
+
const jsonStart = result.stdout.indexOf('{');
|
|
92
|
+
if (jsonStart !== -1) return JSON.parse(result.stdout.slice(jsonStart));
|
|
93
|
+
}
|
|
94
|
+
} catch {}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Main hook entry point — called by openclaw on gateway:startup
|
|
99
|
+
export default async function handler(event) {
|
|
100
|
+
let auditResult;
|
|
101
|
+
try {
|
|
102
|
+
auditResult = runAuditJson();
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// If clawarmor itself fails, don't block startup
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!auditResult) return;
|
|
109
|
+
|
|
110
|
+
const newScore = auditResult.score ?? null;
|
|
111
|
+
const lastState = readLastScore();
|
|
112
|
+
const lastScore = lastState?.score ?? null;
|
|
113
|
+
const isFirstRun = lastScore === null;
|
|
114
|
+
|
|
115
|
+
if (newScore !== null) {
|
|
116
|
+
if (isFirstRun) {
|
|
117
|
+
writeLastScore({ score: newScore, grade: auditResult.grade, timestamp: new Date().toISOString() });
|
|
118
|
+
// First run — establish baseline silently
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const drop = lastScore - newScore;
|
|
123
|
+
const newCriticals = (auditResult.failed || []).filter(f => f.severity === 'CRITICAL');
|
|
124
|
+
const hadCriticals = (lastState?.criticals || 0);
|
|
125
|
+
const newCriticalCount = newCriticals.length;
|
|
126
|
+
|
|
127
|
+
writeLastScore({
|
|
128
|
+
score: newScore,
|
|
129
|
+
grade: auditResult.grade,
|
|
130
|
+
criticals: newCriticalCount,
|
|
131
|
+
timestamp: new Date().toISOString(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (newCriticalCount > hadCriticals) {
|
|
135
|
+
// New CRITICAL finding — alert immediately
|
|
136
|
+
const names = newCriticals.map(f => f.id || f.title).join(', ');
|
|
137
|
+
console.error(\`[ClawArmor] CRITICAL security finding: \${names}\`);
|
|
138
|
+
console.error(\`[ClawArmor] Run: clawarmor audit for details and fix commands.\`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (drop >= 5) {
|
|
143
|
+
console.error(\`[ClawArmor] Security score dropped \${drop} points (\${lastScore} → \${newScore})\`);
|
|
144
|
+
console.error(\`[ClawArmor] Run: clawarmor audit to see what changed.\`);
|
|
145
|
+
}
|
|
146
|
+
// Score improved or unchanged — no output (don't interrupt users with good news)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
`;
|
|
150
|
+
|
|
151
|
+
// ── Install ──────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
function writeHookFiles() {
|
|
154
|
+
mkdirSync(GUARD_HOOK_DIR, { recursive: true });
|
|
155
|
+
writeFileSync(join(GUARD_HOOK_DIR, 'HOOK.md'), HOOK_MD, 'utf8');
|
|
156
|
+
writeFileSync(join(GUARD_HOOK_DIR, 'handler.js'), HANDLER_JS, 'utf8');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function removeHookFiles() {
|
|
160
|
+
if (!existsSync(GUARD_HOOK_DIR)) return false;
|
|
161
|
+
try {
|
|
162
|
+
rmSync(GUARD_HOOK_DIR, { recursive: true, force: true });
|
|
163
|
+
return true;
|
|
164
|
+
} catch { return false; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function hookFilesExist() {
|
|
168
|
+
return existsSync(join(GUARD_HOOK_DIR, 'HOOK.md')) &&
|
|
169
|
+
existsSync(join(GUARD_HOOK_DIR, 'handler.js'));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Shell function ────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
function shellFunctionPresent(rcPath) {
|
|
175
|
+
if (!existsSync(rcPath)) return false;
|
|
176
|
+
try {
|
|
177
|
+
return readFileSync(rcPath, 'utf8').includes(SHELL_MARKER_START);
|
|
178
|
+
} catch { return false; }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function injectShellFunction(rcPath) {
|
|
182
|
+
if (!existsSync(rcPath)) return false;
|
|
183
|
+
if (shellFunctionPresent(rcPath)) return true; // already there
|
|
184
|
+
try {
|
|
185
|
+
const existing = readFileSync(rcPath, 'utf8');
|
|
186
|
+
writeFileSync(rcPath, existing + '\n' + SHELL_FUNCTION, 'utf8');
|
|
187
|
+
return true;
|
|
188
|
+
} catch { return false; }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function removeShellFunction(rcPath) {
|
|
192
|
+
if (!existsSync(rcPath)) return false;
|
|
193
|
+
try {
|
|
194
|
+
const content = readFileSync(rcPath, 'utf8');
|
|
195
|
+
const start = content.indexOf(SHELL_MARKER_START);
|
|
196
|
+
const end = content.indexOf(SHELL_MARKER_END);
|
|
197
|
+
if (start === -1) return false;
|
|
198
|
+
const after = end !== -1 ? end + SHELL_MARKER_END.length : start;
|
|
199
|
+
const newContent = content.slice(0, start).trimEnd() + '\n' + content.slice(after + 1);
|
|
200
|
+
writeFileSync(rcPath, newContent, 'utf8');
|
|
201
|
+
return true;
|
|
202
|
+
} catch { return false; }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function startWatchDaemon() {
|
|
206
|
+
const result = spawnSync(process.execPath, [CLI_PATH, 'watch', '--daemon'], {
|
|
207
|
+
encoding: 'utf8',
|
|
208
|
+
timeout: 10000,
|
|
209
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
210
|
+
});
|
|
211
|
+
return result.status === 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
export async function runProtect(flags = {}) {
|
|
217
|
+
const zshrc = join(HOME, '.zshrc');
|
|
218
|
+
const bashrc = join(HOME, '.bashrc');
|
|
219
|
+
|
|
220
|
+
if (flags.install) {
|
|
221
|
+
console.log('\n ClawArmor Protect — installing...\n');
|
|
222
|
+
|
|
223
|
+
// 1. Hook files
|
|
224
|
+
writeHookFiles();
|
|
225
|
+
console.log(` ✓ Hook files written to: ~/.openclaw/hooks/clawarmor-guard/`);
|
|
226
|
+
|
|
227
|
+
// 2. Shell intercept
|
|
228
|
+
let shellInstalled = false;
|
|
229
|
+
if (injectShellFunction(zshrc)) {
|
|
230
|
+
console.log(` ✓ Shell intercept added to ~/.zshrc`);
|
|
231
|
+
shellInstalled = true;
|
|
232
|
+
}
|
|
233
|
+
if (injectShellFunction(bashrc)) {
|
|
234
|
+
console.log(` ✓ Shell intercept added to ~/.bashrc`);
|
|
235
|
+
shellInstalled = true;
|
|
236
|
+
}
|
|
237
|
+
if (!shellInstalled) {
|
|
238
|
+
console.log(` ! No ~/.zshrc or ~/.bashrc found — shell intercept skipped`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 3. Start watch daemon
|
|
242
|
+
const daemonStarted = startWatchDaemon();
|
|
243
|
+
if (daemonStarted) {
|
|
244
|
+
console.log(` ✓ Watch daemon started`);
|
|
245
|
+
} else {
|
|
246
|
+
console.log(` ! Watch daemon could not be started automatically`);
|
|
247
|
+
console.log(` Run manually: clawarmor watch --daemon`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
console.log('\n ClawArmor Protect is now active.\n');
|
|
251
|
+
console.log(` The guard hook fires on every gateway startup.`);
|
|
252
|
+
console.log(` The watcher monitors config and skill changes in real time.`);
|
|
253
|
+
if (shellInstalled) {
|
|
254
|
+
console.log(` Restart your shell (or: source ~/.zshrc) for the intercept to take effect.`);
|
|
255
|
+
}
|
|
256
|
+
console.log('');
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (flags.uninstall) {
|
|
261
|
+
console.log('\n ClawArmor Protect — uninstalling...\n');
|
|
262
|
+
|
|
263
|
+
// 1. Hook files
|
|
264
|
+
if (removeHookFiles()) {
|
|
265
|
+
console.log(` ✓ Hook files removed`);
|
|
266
|
+
} else {
|
|
267
|
+
console.log(` - Hook files were not present`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 2. Shell function
|
|
271
|
+
let shellRemoved = false;
|
|
272
|
+
if (removeShellFunction(zshrc)) {
|
|
273
|
+
console.log(` ✓ Shell intercept removed from ~/.zshrc`);
|
|
274
|
+
shellRemoved = true;
|
|
275
|
+
}
|
|
276
|
+
if (removeShellFunction(bashrc)) {
|
|
277
|
+
console.log(` ✓ Shell intercept removed from ~/.bashrc`);
|
|
278
|
+
shellRemoved = true;
|
|
279
|
+
}
|
|
280
|
+
if (!shellRemoved) {
|
|
281
|
+
console.log(` - No shell intercept found to remove`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 3. Stop watch daemon
|
|
285
|
+
const { stopDaemon } = await import('./watch.js');
|
|
286
|
+
stopDaemon();
|
|
287
|
+
|
|
288
|
+
console.log('\n ClawArmor Protect has been uninstalled.\n');
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (flags.status) {
|
|
293
|
+
console.log('\n ClawArmor Protect — Status\n');
|
|
294
|
+
|
|
295
|
+
// Hook files
|
|
296
|
+
const hookOk = hookFilesExist();
|
|
297
|
+
console.log(` Hook (gateway:startup) ${hookOk ? '✓ installed' : '✗ not installed'}`);
|
|
298
|
+
|
|
299
|
+
// Watch daemon
|
|
300
|
+
const daemon = watchDaemonStatus();
|
|
301
|
+
console.log(` Watch daemon ${daemon.running ? `● running (PID ${daemon.pid})` : '○ not running'}`);
|
|
302
|
+
|
|
303
|
+
// Shell function
|
|
304
|
+
const inZsh = shellFunctionPresent(zshrc);
|
|
305
|
+
const inBash = shellFunctionPresent(bashrc);
|
|
306
|
+
if (inZsh || inBash) {
|
|
307
|
+
const where = [inZsh && '~/.zshrc', inBash && '~/.bashrc'].filter(Boolean).join(', ');
|
|
308
|
+
console.log(` Shell intercept ✓ active (${where})`);
|
|
309
|
+
} else {
|
|
310
|
+
console.log(` Shell intercept ✗ not installed`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const allActive = hookOk && daemon.running && (inZsh || inBash);
|
|
314
|
+
console.log('');
|
|
315
|
+
if (allActive) {
|
|
316
|
+
console.log(` Full protection active.`);
|
|
317
|
+
} else {
|
|
318
|
+
console.log(` Protection incomplete. Run: clawarmor protect --install`);
|
|
319
|
+
}
|
|
320
|
+
console.log('');
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// No flag — show usage
|
|
325
|
+
console.log('');
|
|
326
|
+
console.log(` Usage: clawarmor protect [--install | --uninstall | --status]`);
|
|
327
|
+
console.log('');
|
|
328
|
+
console.log(` --install Install the guard hook, shell intercept, and watch daemon`);
|
|
329
|
+
console.log(` --uninstall Remove all ClawArmor protect components`);
|
|
330
|
+
console.log(` --status Show current protection state`);
|
|
331
|
+
console.log('');
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
package/lib/scan.js
CHANGED
|
@@ -2,6 +2,7 @@ import { paint, severityColor } from './output/colors.js';
|
|
|
2
2
|
import { scanFile } from './scanner/file-scanner.js';
|
|
3
3
|
import { findInstalledSkills } from './scanner/skill-finder.js';
|
|
4
4
|
import { scanSkillMdFiles } from './scanner/skill-md-scanner.js';
|
|
5
|
+
import { append as auditLogAppend } from './audit-log.js';
|
|
5
6
|
|
|
6
7
|
const SEP = paint.dim('─'.repeat(52));
|
|
7
8
|
const HOME = process.env.HOME || '';
|
|
@@ -33,6 +34,7 @@ export async function runScan() {
|
|
|
33
34
|
|
|
34
35
|
let totalCritical = 0, totalHigh = 0;
|
|
35
36
|
const flagged = [];
|
|
37
|
+
const auditFindings = []; // accumulated for audit log
|
|
36
38
|
|
|
37
39
|
for (const skill of skills) {
|
|
38
40
|
process.stdout.write(` ${skill.isBuiltin ? paint.dim('⊙') : paint.cyan('▶')} ${paint.bold(skill.name)}${paint.dim(skill.isBuiltin?' [built-in]':' [user]')}...`);
|
|
@@ -45,6 +47,7 @@ export async function runScan() {
|
|
|
45
47
|
const mdFindings = mdResults.flatMap(r => r.findings);
|
|
46
48
|
|
|
47
49
|
const allFindings = [...codeFindings, ...mdFindings];
|
|
50
|
+
for (const f of allFindings) auditFindings.push({ id: f.patternId || f.id || '?', severity: f.severity });
|
|
48
51
|
|
|
49
52
|
const critical = allFindings.filter(f => f.severity==='CRITICAL');
|
|
50
53
|
const high = allFindings.filter(f => f.severity==='HIGH');
|
|
@@ -125,5 +128,16 @@ export async function runScan() {
|
|
|
125
128
|
}
|
|
126
129
|
console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor audit')} ${paint.dim('to check your config.')}`);
|
|
127
130
|
console.log('');
|
|
131
|
+
|
|
132
|
+
auditLogAppend({
|
|
133
|
+
cmd: 'scan',
|
|
134
|
+
trigger: 'manual',
|
|
135
|
+
score: null,
|
|
136
|
+
delta: null,
|
|
137
|
+
findings: auditFindings,
|
|
138
|
+
blocked: null,
|
|
139
|
+
skill: null,
|
|
140
|
+
});
|
|
141
|
+
|
|
128
142
|
return totalCritical > 0 ? 1 : 0;
|
|
129
143
|
}
|
package/lib/watch.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// clawarmor watch — Real-time file watcher for OpenClaw config and skills.
|
|
2
|
+
// Uses Node.js built-in fs.watch only. Zero new dependencies.
|
|
3
|
+
|
|
4
|
+
import { watch, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { homedir } from 'os';
|
|
7
|
+
import { spawnSync, fork } from 'child_process';
|
|
8
|
+
|
|
9
|
+
const HOME = homedir();
|
|
10
|
+
const OC_DIR = join(HOME, '.openclaw');
|
|
11
|
+
const CLAWARMOR_DIR = join(HOME, '.clawarmor');
|
|
12
|
+
const PID_FILE = join(CLAWARMOR_DIR, 'watch.pid');
|
|
13
|
+
const LAST_SCORE_FILE = join(CLAWARMOR_DIR, 'last-score.json');
|
|
14
|
+
const CONFIG_FILE = join(OC_DIR, 'openclaw.json');
|
|
15
|
+
const SKILLS_DIR = join(OC_DIR, 'skills');
|
|
16
|
+
const CLI_PATH = new URL('../cli.js', import.meta.url).pathname;
|
|
17
|
+
|
|
18
|
+
// Dynamically detect npm global skills dir
|
|
19
|
+
function detectNpmSkillsDir() {
|
|
20
|
+
try {
|
|
21
|
+
const result = spawnSync('npm', ['root', '-g'], { encoding: 'utf8', timeout: 5000 });
|
|
22
|
+
if (result.status === 0 && result.stdout) {
|
|
23
|
+
const npmRoot = result.stdout.trim();
|
|
24
|
+
const candidate = join(npmRoot, 'openclaw', 'skills');
|
|
25
|
+
if (existsSync(candidate)) return candidate;
|
|
26
|
+
}
|
|
27
|
+
} catch { /* skip */ }
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readLastScore() {
|
|
32
|
+
try {
|
|
33
|
+
if (existsSync(LAST_SCORE_FILE)) {
|
|
34
|
+
return JSON.parse(readFileSync(LAST_SCORE_FILE, 'utf8'));
|
|
35
|
+
}
|
|
36
|
+
} catch { /* ignore */ }
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeLastScore(data) {
|
|
41
|
+
try {
|
|
42
|
+
mkdirSync(CLAWARMOR_DIR, { recursive: true });
|
|
43
|
+
writeFileSync(LAST_SCORE_FILE, JSON.stringify(data, null, 2), 'utf8');
|
|
44
|
+
} catch { /* non-fatal */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function runAuditJson() {
|
|
48
|
+
try {
|
|
49
|
+
const result = spawnSync(process.execPath, [CLI_PATH, 'audit', '--json'], {
|
|
50
|
+
encoding: 'utf8',
|
|
51
|
+
timeout: 30000,
|
|
52
|
+
maxBuffer: 1024 * 1024,
|
|
53
|
+
});
|
|
54
|
+
if (result.stdout) {
|
|
55
|
+
// Find the JSON blob in output (audit --json may mix some text before JSON)
|
|
56
|
+
const jsonStart = result.stdout.indexOf('{');
|
|
57
|
+
if (jsonStart !== -1) {
|
|
58
|
+
return JSON.parse(result.stdout.slice(jsonStart));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch { /* non-fatal */ }
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function timestamp() {
|
|
66
|
+
return new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function onConfigChange() {
|
|
70
|
+
console.log(`[${timestamp()}] Config changed — re-running audit...`);
|
|
71
|
+
|
|
72
|
+
const auditResult = runAuditJson();
|
|
73
|
+
if (!auditResult) {
|
|
74
|
+
console.log(`[${timestamp()}] Could not parse audit output.`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const newScore = auditResult.score ?? null;
|
|
79
|
+
const lastState = readLastScore();
|
|
80
|
+
|
|
81
|
+
if (newScore !== null) {
|
|
82
|
+
const lastScore = lastState?.score ?? null;
|
|
83
|
+
if (lastScore !== null && newScore < lastScore) {
|
|
84
|
+
const drop = lastScore - newScore;
|
|
85
|
+
console.log(`[${timestamp()}] ALERT: Security score dropped ${drop} points (${lastScore} → ${newScore})`);
|
|
86
|
+
const newCriticals = (auditResult.failed || []).filter(f => f.severity === 'CRITICAL');
|
|
87
|
+
if (newCriticals.length) {
|
|
88
|
+
console.log(`[${timestamp()}] CRITICAL findings: ${newCriticals.map(f => f.id || f.title).join(', ')}`);
|
|
89
|
+
}
|
|
90
|
+
} else if (lastScore !== null && newScore > lastScore) {
|
|
91
|
+
console.log(`[${timestamp()}] Score improved: ${lastScore} → ${newScore}`);
|
|
92
|
+
} else {
|
|
93
|
+
console.log(`[${timestamp()}] Score unchanged: ${newScore}/100`);
|
|
94
|
+
}
|
|
95
|
+
writeLastScore({ score: newScore, grade: auditResult.grade, timestamp: new Date().toISOString() });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function onNewSkill(skillName) {
|
|
100
|
+
console.log(`[${timestamp()}] New skill detected: ${skillName}`);
|
|
101
|
+
console.log(`[${timestamp()}] Run: clawarmor scan to check for malicious patterns`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function runWatch(flags = {}) {
|
|
105
|
+
if (flags.daemon) {
|
|
106
|
+
return startDaemon();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Ensure ~/.clawarmor/ exists
|
|
110
|
+
mkdirSync(CLAWARMOR_DIR, { recursive: true });
|
|
111
|
+
|
|
112
|
+
const watchTargets = [];
|
|
113
|
+
|
|
114
|
+
if (existsSync(CONFIG_FILE)) {
|
|
115
|
+
watchTargets.push({ path: CONFIG_FILE, label: 'openclaw.json', type: 'config' });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!existsSync(OC_DIR)) {
|
|
119
|
+
console.log(` [watch] ~/.openclaw/ not found — waiting for it to appear is not supported.`);
|
|
120
|
+
console.log(` [watch] Run: openclaw doctor to set up OpenClaw first.`);
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Watch skills dir (may not exist yet)
|
|
125
|
+
if (existsSync(SKILLS_DIR)) {
|
|
126
|
+
watchTargets.push({ path: SKILLS_DIR, label: 'skills/', type: 'skills' });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const npmSkillsDir = detectNpmSkillsDir();
|
|
130
|
+
if (npmSkillsDir) {
|
|
131
|
+
watchTargets.push({ path: npmSkillsDir, label: 'npm skills/', type: 'skills' });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!watchTargets.length) {
|
|
135
|
+
console.log(` [watch] Nothing to watch — no config or skills directories found.`);
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log(` ClawArmor Watch — monitoring ${watchTargets.length} path(s)`);
|
|
140
|
+
for (const t of watchTargets) console.log(` ${t.label} (${t.path})`);
|
|
141
|
+
console.log(` Press Ctrl+C to stop.\n`);
|
|
142
|
+
|
|
143
|
+
const debounceMap = new Map();
|
|
144
|
+
const DEBOUNCE_MS = 500;
|
|
145
|
+
|
|
146
|
+
// Track skill dirs seen
|
|
147
|
+
const seenSkills = new Set();
|
|
148
|
+
try {
|
|
149
|
+
if (existsSync(SKILLS_DIR)) {
|
|
150
|
+
readdirSync(SKILLS_DIR).forEach(d => seenSkills.add(d));
|
|
151
|
+
}
|
|
152
|
+
} catch { /* ignore */ }
|
|
153
|
+
|
|
154
|
+
for (const target of watchTargets) {
|
|
155
|
+
try {
|
|
156
|
+
watch(target.path, { recursive: target.type === 'skills' }, (eventType, filename) => {
|
|
157
|
+
const key = `${target.path}::${filename}`;
|
|
158
|
+
if (debounceMap.has(key)) clearTimeout(debounceMap.get(key));
|
|
159
|
+
|
|
160
|
+
debounceMap.set(key, setTimeout(() => {
|
|
161
|
+
debounceMap.delete(key);
|
|
162
|
+
|
|
163
|
+
if (target.type === 'config') {
|
|
164
|
+
onConfigChange();
|
|
165
|
+
} else if (target.type === 'skills' && filename) {
|
|
166
|
+
// Check if this is a new top-level skill directory
|
|
167
|
+
const topDir = filename.split('/')[0];
|
|
168
|
+
if (topDir && !seenSkills.has(topDir)) {
|
|
169
|
+
seenSkills.add(topDir);
|
|
170
|
+
onNewSkill(topDir);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}, DEBOUNCE_MS));
|
|
174
|
+
});
|
|
175
|
+
} catch (e) {
|
|
176
|
+
console.log(` [watch] Could not watch ${target.label}: ${e.message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Keep alive
|
|
181
|
+
return new Promise(() => {});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function startDaemon() {
|
|
185
|
+
mkdirSync(CLAWARMOR_DIR, { recursive: true });
|
|
186
|
+
|
|
187
|
+
const child = fork(CLI_PATH, ['watch'], {
|
|
188
|
+
detached: true,
|
|
189
|
+
stdio: 'ignore',
|
|
190
|
+
env: { ...process.env, CLAWARMOR_DAEMON: '1' },
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
child.unref();
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
writeFileSync(PID_FILE, String(child.pid), 'utf8');
|
|
197
|
+
} catch { /* non-fatal */ }
|
|
198
|
+
|
|
199
|
+
console.log(` ClawArmor Watch daemon started (PID ${child.pid})`);
|
|
200
|
+
console.log(` PID written to: ${PID_FILE}`);
|
|
201
|
+
console.log(` Stop with: kill $(cat ${PID_FILE})`);
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function stopDaemon() {
|
|
206
|
+
if (!existsSync(PID_FILE)) {
|
|
207
|
+
console.log(' No watch daemon PID file found.');
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const pid = parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10);
|
|
212
|
+
if (isNaN(pid)) { console.log(' Invalid PID in watch.pid'); return false; }
|
|
213
|
+
process.kill(pid, 'SIGTERM');
|
|
214
|
+
// Remove PID file
|
|
215
|
+
try { unlinkSync(PID_FILE); } catch { /* ignore */ }
|
|
216
|
+
console.log(` Watch daemon (PID ${pid}) stopped.`);
|
|
217
|
+
return true;
|
|
218
|
+
} catch (e) {
|
|
219
|
+
console.log(` Could not stop watch daemon: ${e.message}`);
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function watchDaemonStatus() {
|
|
225
|
+
if (!existsSync(PID_FILE)) return { running: false, pid: null };
|
|
226
|
+
try {
|
|
227
|
+
const pid = parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10);
|
|
228
|
+
if (isNaN(pid)) return { running: false, pid: null };
|
|
229
|
+
// Check if the process is alive
|
|
230
|
+
process.kill(pid, 0); // throws if not running
|
|
231
|
+
return { running: true, pid };
|
|
232
|
+
} catch {
|
|
233
|
+
return { running: false, pid: null };
|
|
234
|
+
}
|
|
235
|
+
}
|