clawarmor 1.2.0 → 2.0.0-alpha.3
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 +66 -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/digest.js +157 -0
- package/lib/harden.js +312 -0
- package/lib/log-viewer.js +140 -0
- package/lib/output/progress.js +2 -1
- package/lib/prescan.js +167 -0
- package/lib/protect.js +352 -0
- package/lib/scan.js +14 -0
- package/lib/status.js +250 -0
- package/lib/watch.js +235 -0
- package/package.json +1 -1
package/lib/protect.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
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(` ✓ Gateway hook installed (clawarmor-guard)`);
|
|
226
|
+
|
|
227
|
+
// 2. Start watch daemon
|
|
228
|
+
const daemonStarted = startWatchDaemon();
|
|
229
|
+
let daemonPid = null;
|
|
230
|
+
if (daemonStarted) {
|
|
231
|
+
// Read the PID that was written by the daemon
|
|
232
|
+
try {
|
|
233
|
+
const pidFile = join(CLAWARMOR_DIR, 'watch.pid');
|
|
234
|
+
if (existsSync(pidFile)) daemonPid = readFileSync(pidFile, 'utf8').trim();
|
|
235
|
+
} catch { /* non-fatal */ }
|
|
236
|
+
const pidStr = daemonPid ? ` (PID ${daemonPid})` : '';
|
|
237
|
+
console.log(` ✓ Watch daemon started${pidStr}`);
|
|
238
|
+
} else {
|
|
239
|
+
console.log(` ! Watch daemon could not be started automatically`);
|
|
240
|
+
console.log(` Run manually: clawarmor watch --daemon`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 3. Shell intercept
|
|
244
|
+
let shellInstalled = false;
|
|
245
|
+
let shellPath = null;
|
|
246
|
+
if (injectShellFunction(zshrc)) {
|
|
247
|
+
shellPath = '~/.zshrc';
|
|
248
|
+
shellInstalled = true;
|
|
249
|
+
}
|
|
250
|
+
if (injectShellFunction(bashrc)) {
|
|
251
|
+
shellPath = shellPath ? shellPath + ', ~/.bashrc' : '~/.bashrc';
|
|
252
|
+
shellInstalled = true;
|
|
253
|
+
}
|
|
254
|
+
if (shellInstalled) {
|
|
255
|
+
console.log(` ✓ Shell intercept added (${shellPath})`);
|
|
256
|
+
} else {
|
|
257
|
+
console.log(` ! No ~/.zshrc or ~/.bashrc found — shell intercept skipped`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 4. Weekly digest cron
|
|
261
|
+
const { installDigestCron } = await import('./digest.js');
|
|
262
|
+
const cronOk = installDigestCron();
|
|
263
|
+
if (cronOk) {
|
|
264
|
+
console.log(` ✓ Weekly digest scheduled (Sundays 9am)`);
|
|
265
|
+
} else {
|
|
266
|
+
console.log(` ! Could not write digest cron job`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
console.log('\n ClawArmor Protect is now active.\n');
|
|
270
|
+
console.log(` The guard hook fires on every gateway startup.`);
|
|
271
|
+
console.log(` The watcher monitors config and skill changes in real time.`);
|
|
272
|
+
if (shellInstalled) {
|
|
273
|
+
console.log(` Restart your shell (or: source ~/.zshrc) for the intercept to take effect.`);
|
|
274
|
+
}
|
|
275
|
+
console.log('');
|
|
276
|
+
return 0;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (flags.uninstall) {
|
|
280
|
+
console.log('\n ClawArmor Protect — uninstalling...\n');
|
|
281
|
+
|
|
282
|
+
// 1. Hook files
|
|
283
|
+
if (removeHookFiles()) {
|
|
284
|
+
console.log(` ✓ Hook files removed`);
|
|
285
|
+
} else {
|
|
286
|
+
console.log(` - Hook files were not present`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 2. Shell function
|
|
290
|
+
let shellRemoved = false;
|
|
291
|
+
if (removeShellFunction(zshrc)) {
|
|
292
|
+
console.log(` ✓ Shell intercept removed from ~/.zshrc`);
|
|
293
|
+
shellRemoved = true;
|
|
294
|
+
}
|
|
295
|
+
if (removeShellFunction(bashrc)) {
|
|
296
|
+
console.log(` ✓ Shell intercept removed from ~/.bashrc`);
|
|
297
|
+
shellRemoved = true;
|
|
298
|
+
}
|
|
299
|
+
if (!shellRemoved) {
|
|
300
|
+
console.log(` - No shell intercept found to remove`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 3. Stop watch daemon
|
|
304
|
+
const { stopDaemon } = await import('./watch.js');
|
|
305
|
+
stopDaemon();
|
|
306
|
+
|
|
307
|
+
console.log('\n ClawArmor Protect has been uninstalled.\n');
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (flags.status) {
|
|
312
|
+
console.log('\n ClawArmor Protect — Status\n');
|
|
313
|
+
|
|
314
|
+
// Hook files
|
|
315
|
+
const hookOk = hookFilesExist();
|
|
316
|
+
console.log(` Hook (gateway:startup) ${hookOk ? '✓ installed' : '✗ not installed'}`);
|
|
317
|
+
|
|
318
|
+
// Watch daemon
|
|
319
|
+
const daemon = watchDaemonStatus();
|
|
320
|
+
console.log(` Watch daemon ${daemon.running ? `● running (PID ${daemon.pid})` : '○ not running'}`);
|
|
321
|
+
|
|
322
|
+
// Shell function
|
|
323
|
+
const inZsh = shellFunctionPresent(zshrc);
|
|
324
|
+
const inBash = shellFunctionPresent(bashrc);
|
|
325
|
+
if (inZsh || inBash) {
|
|
326
|
+
const where = [inZsh && '~/.zshrc', inBash && '~/.bashrc'].filter(Boolean).join(', ');
|
|
327
|
+
console.log(` Shell intercept ✓ active (${where})`);
|
|
328
|
+
} else {
|
|
329
|
+
console.log(` Shell intercept ✗ not installed`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const allActive = hookOk && daemon.running && (inZsh || inBash);
|
|
333
|
+
console.log('');
|
|
334
|
+
if (allActive) {
|
|
335
|
+
console.log(` Full protection active.`);
|
|
336
|
+
} else {
|
|
337
|
+
console.log(` Protection incomplete. Run: clawarmor protect --install`);
|
|
338
|
+
}
|
|
339
|
+
console.log('');
|
|
340
|
+
return 0;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// No flag — show usage
|
|
344
|
+
console.log('');
|
|
345
|
+
console.log(` Usage: clawarmor protect [--install | --uninstall | --status]`);
|
|
346
|
+
console.log('');
|
|
347
|
+
console.log(` --install Install the guard hook, shell intercept, and watch daemon`);
|
|
348
|
+
console.log(` --uninstall Remove all ClawArmor protect components`);
|
|
349
|
+
console.log(` --status Show current protection state`);
|
|
350
|
+
console.log('');
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
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/status.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// clawarmor status — One-screen security posture dashboard.
|
|
2
|
+
// Shows: score+grade+trend, last audit, watcher, intercept, log, skills, config, credentials, next digest.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { homedir } from 'os';
|
|
7
|
+
import { paint } from './output/colors.js';
|
|
8
|
+
import { scoreToGrade, scoreColor, gradeColor } from './output/progress.js';
|
|
9
|
+
import { watchDaemonStatus } from './watch.js';
|
|
10
|
+
|
|
11
|
+
const HOME = homedir();
|
|
12
|
+
const OC_DIR = join(HOME, '.openclaw');
|
|
13
|
+
const CLAWARMOR_DIR = join(HOME, '.clawarmor');
|
|
14
|
+
const LAST_SCORE_FILE = join(CLAWARMOR_DIR, 'last-score.json');
|
|
15
|
+
const HISTORY_FILE = join(CLAWARMOR_DIR, 'history.json');
|
|
16
|
+
const AUDIT_LOG = join(CLAWARMOR_DIR, 'audit.log');
|
|
17
|
+
const ZSHRC = join(HOME, '.zshrc');
|
|
18
|
+
const BASHRC = join(HOME, '.bashrc');
|
|
19
|
+
const SHELL_MARKER = '# ClawArmor intercept — added by: clawarmor protect --install';
|
|
20
|
+
const CRON_JOBS_FILE = join(OC_DIR, 'cron', 'jobs.json');
|
|
21
|
+
const VERSION = '2.0.0-alpha.3';
|
|
22
|
+
|
|
23
|
+
const SEP = paint.dim('─'.repeat(52));
|
|
24
|
+
|
|
25
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function readJson(file) {
|
|
28
|
+
try {
|
|
29
|
+
if (!existsSync(file)) return null;
|
|
30
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
31
|
+
} catch { return null; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function timeAgo(isoString) {
|
|
35
|
+
if (!isoString) return 'never';
|
|
36
|
+
const ms = Date.now() - new Date(isoString).getTime();
|
|
37
|
+
const secs = Math.floor(ms / 1000);
|
|
38
|
+
if (secs < 60) return `${secs}s ago`;
|
|
39
|
+
const mins = Math.floor(secs / 60);
|
|
40
|
+
if (mins < 60) return `${mins}m ago`;
|
|
41
|
+
const hours = Math.floor(mins / 60);
|
|
42
|
+
if (hours < 24) return `${hours}h ago`;
|
|
43
|
+
const days = Math.floor(hours / 24);
|
|
44
|
+
return `${days}d ago`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function trendArrow(delta) {
|
|
48
|
+
if (delta == null) return paint.dim('—');
|
|
49
|
+
if (delta > 0) return paint.green(`↑+${delta}`);
|
|
50
|
+
if (delta < 0) return paint.red(`↓${delta}`);
|
|
51
|
+
return paint.dim('→±0');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function intercept() {
|
|
55
|
+
const inZsh = existsSync(ZSHRC) && readFileSync(ZSHRC, 'utf8').includes(SHELL_MARKER);
|
|
56
|
+
const inBash = existsSync(BASHRC) && readFileSync(BASHRC, 'utf8').includes(SHELL_MARKER);
|
|
57
|
+
if (inZsh || inBash) {
|
|
58
|
+
const where = [inZsh && '~/.zshrc', inBash && '~/.bashrc'].filter(Boolean).join(', ');
|
|
59
|
+
return { active: true, where };
|
|
60
|
+
}
|
|
61
|
+
return { active: false, where: null };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseAuditLog() {
|
|
65
|
+
if (!existsSync(AUDIT_LOG)) return { count: 0, lastEntry: null };
|
|
66
|
+
try {
|
|
67
|
+
const lines = readFileSync(AUDIT_LOG, 'utf8')
|
|
68
|
+
.split('\n')
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
return { count: lines.length, lastEntry: lines[lines.length - 1] || null };
|
|
73
|
+
} catch { return { count: 0, lastEntry: null }; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function countInstalledSkills() {
|
|
77
|
+
const dirs = [];
|
|
78
|
+
const userSkillsDir = join(OC_DIR, 'skills');
|
|
79
|
+
if (existsSync(userSkillsDir)) {
|
|
80
|
+
try {
|
|
81
|
+
dirs.push(...readdirSync(userSkillsDir, { withFileTypes: true })
|
|
82
|
+
.filter(e => e.isDirectory())
|
|
83
|
+
.map(e => join(userSkillsDir, e.name)));
|
|
84
|
+
} catch { /* skip */ }
|
|
85
|
+
}
|
|
86
|
+
return dirs.length;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function credentialSummary() {
|
|
90
|
+
const credFile = join(OC_DIR, 'agent-accounts.json');
|
|
91
|
+
if (!existsSync(credFile)) return { count: 0, oldestDays: null };
|
|
92
|
+
try {
|
|
93
|
+
const data = JSON.parse(readFileSync(credFile, 'utf8'));
|
|
94
|
+
// Count token entries — format varies; try common patterns
|
|
95
|
+
let tokens = [];
|
|
96
|
+
if (Array.isArray(data)) tokens = data;
|
|
97
|
+
else if (data.accounts) tokens = Object.values(data.accounts);
|
|
98
|
+
else if (typeof data === 'object') tokens = Object.values(data);
|
|
99
|
+
|
|
100
|
+
const count = tokens.length;
|
|
101
|
+
|
|
102
|
+
// Try to find oldest by looking for date fields
|
|
103
|
+
let oldest = null;
|
|
104
|
+
for (const tok of tokens) {
|
|
105
|
+
if (tok && typeof tok === 'object') {
|
|
106
|
+
const dateStr = tok.createdAt || tok.created_at || tok.timestamp || null;
|
|
107
|
+
if (dateStr) {
|
|
108
|
+
const d = new Date(dateStr);
|
|
109
|
+
if (!isNaN(d) && (!oldest || d < oldest)) oldest = d;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const oldestDays = oldest ? Math.floor((Date.now() - oldest.getTime()) / 86_400_000) : null;
|
|
114
|
+
return { count, oldestDays };
|
|
115
|
+
} catch { return { count: 0, oldestDays: null }; }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function configBaselineStatus() {
|
|
119
|
+
const baselineFile = join(CLAWARMOR_DIR, 'config-baseline.json');
|
|
120
|
+
if (!existsSync(baselineFile)) return { status: 'unknown' };
|
|
121
|
+
try {
|
|
122
|
+
const baseline = JSON.parse(readFileSync(baselineFile, 'utf8'));
|
|
123
|
+
// Simple presence check — full integrity is handled by lib/integrity.js
|
|
124
|
+
return { status: 'baseline', at: baseline.at || null };
|
|
125
|
+
} catch { return { status: 'unknown' }; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function nextDigestDate() {
|
|
129
|
+
// Next Sunday at 9am
|
|
130
|
+
const now = new Date();
|
|
131
|
+
const dayOfWeek = now.getDay(); // 0=Sun
|
|
132
|
+
const daysUntilSunday = dayOfWeek === 0 ? 7 : 7 - dayOfWeek;
|
|
133
|
+
const next = new Date(now);
|
|
134
|
+
next.setDate(now.getDate() + daysUntilSunday);
|
|
135
|
+
next.setHours(9, 0, 0, 0);
|
|
136
|
+
const daysUntil = Math.ceil((next - now) / 86_400_000);
|
|
137
|
+
return {
|
|
138
|
+
label: next.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }),
|
|
139
|
+
daysUntil,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function digestInstalled() {
|
|
144
|
+
const jobs = readJson(CRON_JOBS_FILE);
|
|
145
|
+
if (!Array.isArray(jobs)) return false;
|
|
146
|
+
return jobs.some(j => j.id === 'clawarmor-weekly-digest');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Main export ───────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export async function runStatus() {
|
|
152
|
+
console.log('');
|
|
153
|
+
console.log(` ${paint.bold('ClawArmor')} ${paint.dim('v' + VERSION)} ${paint.dim('—')} ${paint.bold('Security Status')}`);
|
|
154
|
+
console.log('');
|
|
155
|
+
|
|
156
|
+
// ── Posture ──────────────────────────────────────────────────────────────
|
|
157
|
+
const lastScore = readJson(LAST_SCORE_FILE);
|
|
158
|
+
const history = readJson(HISTORY_FILE) || [];
|
|
159
|
+
const latestHistoryForPosture = history.length ? history[history.length - 1] : null;
|
|
160
|
+
|
|
161
|
+
// Prefer last-score.json (written by watch/guard hook), fall back to history.json
|
|
162
|
+
let score = lastScore?.score ?? latestHistoryForPosture?.score ?? null;
|
|
163
|
+
let grade = lastScore?.grade ?? latestHistoryForPosture?.grade ?? null;
|
|
164
|
+
let scoreTs = lastScore?.timestamp ?? latestHistoryForPosture?.timestamp ?? null;
|
|
165
|
+
|
|
166
|
+
// Trend: compare to ~7 days ago from history
|
|
167
|
+
let weekDelta = null;
|
|
168
|
+
if (history.length >= 2) {
|
|
169
|
+
const weekAgo = Date.now() - 7 * 86_400_000;
|
|
170
|
+
const weekEntry = [...history].reverse().find(h => new Date(h.timestamp).getTime() < weekAgo);
|
|
171
|
+
if (weekEntry && score != null) {
|
|
172
|
+
weekDelta = score - weekEntry.score;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Score line
|
|
177
|
+
if (score != null) {
|
|
178
|
+
const grade2 = grade || scoreToGrade(score);
|
|
179
|
+
const colorFn = scoreColor(score);
|
|
180
|
+
const arrow = trendArrow(weekDelta);
|
|
181
|
+
const weekNote = weekDelta != null ? paint.dim(' vs last week') : '';
|
|
182
|
+
console.log(` ${paint.dim('Posture')} ${gradeColor(grade2)} ${colorFn(score + '/100')} ${arrow}${weekNote}`);
|
|
183
|
+
} else {
|
|
184
|
+
console.log(` ${paint.dim('Posture')} ${paint.dim('No audit data — run: clawarmor audit')}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Last audit ────────────────────────────────────────────────────────────
|
|
188
|
+
const { count: logCount, lastEntry } = parseAuditLog();
|
|
189
|
+
const lastAuditTs = lastEntry?.ts ?? scoreTs ?? null;
|
|
190
|
+
const trigger = lastEntry?.trigger ?? 'manual';
|
|
191
|
+
const auditAgo = lastAuditTs ? timeAgo(lastAuditTs) : 'never';
|
|
192
|
+
console.log(` ${paint.dim('Last audit')} ${paint.bold(auditAgo)} ${paint.dim('(' + trigger + ')')}`);
|
|
193
|
+
|
|
194
|
+
// ── Watcher ───────────────────────────────────────────────────────────────
|
|
195
|
+
const daemon = watchDaemonStatus();
|
|
196
|
+
const watchStr = daemon.running
|
|
197
|
+
? `${paint.green('●')} ${paint.bold('running')} ${paint.dim('(PID ' + daemon.pid + ')')}`
|
|
198
|
+
: `${paint.dim('○')} ${paint.dim('stopped')}`;
|
|
199
|
+
console.log(` ${paint.dim('Watcher')} ${watchStr}`);
|
|
200
|
+
|
|
201
|
+
// ── Shell intercept ───────────────────────────────────────────────────────
|
|
202
|
+
const icp = intercept();
|
|
203
|
+
const icpStr = icp.active
|
|
204
|
+
? `${paint.green('✓')} ${paint.bold('active')} ${paint.dim('(' + icp.where + ')')}`
|
|
205
|
+
: `${paint.red('✗')} ${paint.dim('not installed')}`;
|
|
206
|
+
console.log(` ${paint.dim('Intercept')} ${icpStr}`);
|
|
207
|
+
|
|
208
|
+
// ── Audit log ─────────────────────────────────────────────────────────────
|
|
209
|
+
console.log(` ${paint.dim('Audit log')} ${paint.bold(String(logCount))} ${paint.dim('events')} ${paint.dim('(clawarmor log to view)')}`);
|
|
210
|
+
|
|
211
|
+
console.log('');
|
|
212
|
+
console.log(SEP);
|
|
213
|
+
|
|
214
|
+
// ── Skills ────────────────────────────────────────────────────────────────
|
|
215
|
+
const skillCount = countInstalledSkills();
|
|
216
|
+
console.log(` ${paint.dim('Skills')} ${paint.bold(String(skillCount))} ${paint.dim('installed')} ${paint.dim('(clawarmor scan to check)')}`);
|
|
217
|
+
|
|
218
|
+
// ── Config baseline ───────────────────────────────────────────────────────
|
|
219
|
+
const baseline = configBaselineStatus();
|
|
220
|
+
const configStr = baseline.status === 'baseline'
|
|
221
|
+
? `${paint.green('Baseline match')} ${paint.green('✓')}`
|
|
222
|
+
: paint.dim('No baseline yet — run: clawarmor audit');
|
|
223
|
+
console.log(` ${paint.dim('Config')} ${configStr}`);
|
|
224
|
+
|
|
225
|
+
// ── Credentials ───────────────────────────────────────────────────────────
|
|
226
|
+
const creds = credentialSummary();
|
|
227
|
+
let credStr = creds.count > 0
|
|
228
|
+
? `${paint.bold(String(creds.count))} ${paint.dim('tokens')}`
|
|
229
|
+
: paint.dim('none found');
|
|
230
|
+
if (creds.oldestDays != null) {
|
|
231
|
+
const ageMark = creds.oldestDays > 90 ? paint.yellow('!') : paint.green('✓');
|
|
232
|
+
credStr += `${paint.dim(', oldest:')} ${creds.oldestDays}d ${ageMark}`;
|
|
233
|
+
}
|
|
234
|
+
console.log(` ${paint.dim('Credentials')} ${credStr}`);
|
|
235
|
+
|
|
236
|
+
console.log('');
|
|
237
|
+
console.log(SEP);
|
|
238
|
+
|
|
239
|
+
// ── Next digest ───────────────────────────────────────────────────────────
|
|
240
|
+
const digestOk = digestInstalled();
|
|
241
|
+
const nextDigest = nextDigestDate();
|
|
242
|
+
if (digestOk) {
|
|
243
|
+
console.log(` ${paint.dim('Next digest')} ${paint.bold(nextDigest.label)} ${paint.dim('(' + nextDigest.daysUntil + ' days)')}`);
|
|
244
|
+
} else {
|
|
245
|
+
console.log(` ${paint.dim('Next digest')} ${paint.dim('not scheduled')} ${paint.dim('(run: clawarmor protect --install)')}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
console.log('');
|
|
249
|
+
return 0;
|
|
250
|
+
}
|