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/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
+ }