clawarmor 2.0.0-alpha.2 → 2.0.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/lib/protect.js CHANGED
@@ -18,6 +18,9 @@ const HOOKS_DIR = join(OC_DIR, 'hooks');
18
18
  const GUARD_HOOK_DIR = join(HOOKS_DIR, 'clawarmor-guard');
19
19
  const CLI_PATH = new URL('../cli.js', import.meta.url).pathname;
20
20
 
21
+ const FISH_FUNCTIONS_DIR = join(HOME, '.config', 'fish', 'functions');
22
+ const FISH_FUNCTION_FILE = join(FISH_FUNCTIONS_DIR, 'openclaw.fish');
23
+
21
24
  const SHELL_FUNCTION = `
22
25
  # ClawArmor intercept — added by: clawarmor protect --install
23
26
  # Wraps 'openclaw clawhub install' to scan skills before activation.
@@ -34,6 +37,17 @@ openclaw() {
34
37
  const SHELL_MARKER_START = '# ClawArmor intercept — added by: clawarmor protect --install';
35
38
  const SHELL_MARKER_END = '# End ClawArmor intercept';
36
39
 
40
+ const FISH_FUNCTION = `# ClawArmor intercept — added by: clawarmor protect --install
41
+ function openclaw
42
+ if test (count $argv) -ge 3 -a "$argv[1]" = clawhub -a "$argv[2]" = install
43
+ echo "🛡 ClawArmor: scanning $argv[3] before install..."
44
+ clawarmor prescan $argv[3]; or begin; echo "❌ Blocked."; return 1; end
45
+ end
46
+ command openclaw $argv
47
+ end
48
+ `;
49
+ const FISH_MARKER = '# ClawArmor intercept — added by: clawarmor protect --install';
50
+
37
51
  const HOOK_MD = `---
38
52
  name: clawarmor-guard
39
53
  description: Runs a silent security audit on gateway startup and alerts on regressions
@@ -148,6 +162,39 @@ export default async function handler(event) {
148
162
  }
149
163
  `;
150
164
 
165
+ // ── Fish shell ────────────────────────────────────────────────────────────────
166
+
167
+ function fishConfigExists() {
168
+ return existsSync(join(HOME, '.config', 'fish', 'config.fish'));
169
+ }
170
+
171
+ function fishFunctionPresent() {
172
+ if (!existsSync(FISH_FUNCTION_FILE)) return false;
173
+ try {
174
+ return readFileSync(FISH_FUNCTION_FILE, 'utf8').includes(FISH_MARKER);
175
+ } catch { return false; }
176
+ }
177
+
178
+ function injectFishFunction() {
179
+ if (!fishConfigExists()) return false;
180
+ if (fishFunctionPresent()) return true;
181
+ try {
182
+ mkdirSync(FISH_FUNCTIONS_DIR, { recursive: true });
183
+ writeFileSync(FISH_FUNCTION_FILE, FISH_FUNCTION, 'utf8');
184
+ return true;
185
+ } catch { return false; }
186
+ }
187
+
188
+ function removeFishFunction() {
189
+ if (!existsSync(FISH_FUNCTION_FILE)) return false;
190
+ try {
191
+ const content = readFileSync(FISH_FUNCTION_FILE, 'utf8');
192
+ if (!content.includes(FISH_MARKER)) return false;
193
+ rmSync(FISH_FUNCTION_FILE, { force: true });
194
+ return true;
195
+ } catch { return false; }
196
+ }
197
+
151
198
  // ── Install ──────────────────────────────────────────────────────────────────
152
199
 
153
200
  function writeHookFiles() {
@@ -222,29 +269,52 @@ export async function runProtect(flags = {}) {
222
269
 
223
270
  // 1. Hook files
224
271
  writeHookFiles();
225
- console.log(` ✓ Hook files written to: ~/.openclaw/hooks/clawarmor-guard/`);
272
+ console.log(` ✓ Gateway hook installed (clawarmor-guard)`);
226
273
 
227
- // 2. Shell intercept
274
+ // 2. Start watch daemon
275
+ const daemonStarted = startWatchDaemon();
276
+ let daemonPid = null;
277
+ if (daemonStarted) {
278
+ // Read the PID that was written by the daemon
279
+ try {
280
+ const pidFile = join(CLAWARMOR_DIR, 'watch.pid');
281
+ if (existsSync(pidFile)) daemonPid = readFileSync(pidFile, 'utf8').trim();
282
+ } catch { /* non-fatal */ }
283
+ const pidStr = daemonPid ? ` (PID ${daemonPid})` : '';
284
+ console.log(` ✓ Watch daemon started${pidStr}`);
285
+ } else {
286
+ console.log(` ! Watch daemon could not be started automatically`);
287
+ console.log(` Run manually: clawarmor watch --daemon`);
288
+ }
289
+
290
+ // 3. Shell intercept
228
291
  let shellInstalled = false;
292
+ let shellPath = null;
229
293
  if (injectShellFunction(zshrc)) {
230
- console.log(` ✓ Shell intercept added to ~/.zshrc`);
294
+ shellPath = '~/.zshrc';
231
295
  shellInstalled = true;
232
296
  }
233
297
  if (injectShellFunction(bashrc)) {
234
- console.log(` ✓ Shell intercept added to ~/.bashrc`);
298
+ shellPath = shellPath ? shellPath + ', ~/.bashrc' : '~/.bashrc';
299
+ shellInstalled = true;
300
+ }
301
+ if (injectFishFunction()) {
302
+ shellPath = shellPath ? shellPath + ', ~/.config/fish' : '~/.config/fish';
235
303
  shellInstalled = true;
236
304
  }
237
- if (!shellInstalled) {
238
- console.log(` ! No ~/.zshrc or ~/.bashrc found — shell intercept skipped`);
305
+ if (shellInstalled) {
306
+ console.log(` Shell intercept added (${shellPath})`);
307
+ } else {
308
+ console.log(` ! No ~/.zshrc, ~/.bashrc, or fish config found — shell intercept skipped`);
239
309
  }
240
310
 
241
- // 3. Start watch daemon
242
- const daemonStarted = startWatchDaemon();
243
- if (daemonStarted) {
244
- console.log(` ✓ Watch daemon started`);
311
+ // 4. Weekly digest cron
312
+ const { installDigestCron } = await import('./digest.js');
313
+ const cronOk = installDigestCron();
314
+ if (cronOk) {
315
+ console.log(` ✓ Weekly digest scheduled (Sundays 9am)`);
245
316
  } else {
246
- console.log(` ! Watch daemon could not be started automatically`);
247
- console.log(` Run manually: clawarmor watch --daemon`);
317
+ console.log(` ! Could not write digest cron job`);
248
318
  }
249
319
 
250
320
  console.log('\n ClawArmor Protect is now active.\n');
@@ -277,6 +347,10 @@ export async function runProtect(flags = {}) {
277
347
  console.log(` ✓ Shell intercept removed from ~/.bashrc`);
278
348
  shellRemoved = true;
279
349
  }
350
+ if (removeFishFunction()) {
351
+ console.log(` ✓ Shell intercept removed from ~/.config/fish/functions/openclaw.fish`);
352
+ shellRemoved = true;
353
+ }
280
354
  if (!shellRemoved) {
281
355
  console.log(` - No shell intercept found to remove`);
282
356
  }
@@ -303,14 +377,15 @@ export async function runProtect(flags = {}) {
303
377
  // Shell function
304
378
  const inZsh = shellFunctionPresent(zshrc);
305
379
  const inBash = shellFunctionPresent(bashrc);
306
- if (inZsh || inBash) {
307
- const where = [inZsh && '~/.zshrc', inBash && '~/.bashrc'].filter(Boolean).join(', ');
380
+ const inFish = fishFunctionPresent();
381
+ if (inZsh || inBash || inFish) {
382
+ const where = [inZsh && '~/.zshrc', inBash && '~/.bashrc', inFish && '~/.config/fish'].filter(Boolean).join(', ');
308
383
  console.log(` Shell intercept ✓ active (${where})`);
309
384
  } else {
310
385
  console.log(` Shell intercept ✗ not installed`);
311
386
  }
312
387
 
313
- const allActive = hookOk && daemon.running && (inZsh || inBash);
388
+ const allActive = hookOk && daemon.running && (inZsh || inBash || inFish);
314
389
  console.log('');
315
390
  if (allActive) {
316
391
  console.log(` Full protection active.`);
package/lib/status.js ADDED
@@ -0,0 +1,273 @@
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 FISH_FUNCTION_FILE = join(HOME, '.config', 'fish', 'functions', 'openclaw.fish');
20
+ const SHELL_MARKER = '# ClawArmor intercept — added by: clawarmor protect --install';
21
+ const CRON_JOBS_FILE = join(OC_DIR, 'cron', 'jobs.json');
22
+ const HOOKS_DIR = join(OC_DIR, 'hooks', 'clawarmor-guard');
23
+ const VERSION = '2.0.0';
24
+
25
+ const SEP = paint.dim('─'.repeat(52));
26
+
27
+ // ── Helpers ─────────────────────────────────────────────────────────────────
28
+
29
+ function readJson(file) {
30
+ try {
31
+ if (!existsSync(file)) return null;
32
+ return JSON.parse(readFileSync(file, 'utf8'));
33
+ } catch { return null; }
34
+ }
35
+
36
+ function timeAgo(isoString) {
37
+ if (!isoString) return 'never';
38
+ const ms = Date.now() - new Date(isoString).getTime();
39
+ const secs = Math.floor(ms / 1000);
40
+ if (secs < 60) return `${secs}s ago`;
41
+ const mins = Math.floor(secs / 60);
42
+ if (mins < 60) return `${mins}m ago`;
43
+ const hours = Math.floor(mins / 60);
44
+ if (hours < 24) return `${hours}h ago`;
45
+ const days = Math.floor(hours / 24);
46
+ return `${days}d ago`;
47
+ }
48
+
49
+ function trendArrow(delta) {
50
+ if (delta == null) return paint.dim('—');
51
+ if (delta > 0) return paint.green(`↑+${delta}`);
52
+ if (delta < 0) return paint.red(`↓${delta}`);
53
+ return paint.dim('→±0');
54
+ }
55
+
56
+ function intercept() {
57
+ const inZsh = existsSync(ZSHRC) && readFileSync(ZSHRC, 'utf8').includes(SHELL_MARKER);
58
+ const inBash = existsSync(BASHRC) && readFileSync(BASHRC, 'utf8').includes(SHELL_MARKER);
59
+ const inFish = existsSync(FISH_FUNCTION_FILE) && readFileSync(FISH_FUNCTION_FILE, 'utf8').includes(SHELL_MARKER);
60
+ if (inZsh || inBash || inFish) {
61
+ const where = [inZsh && '~/.zshrc', inBash && '~/.bashrc', inFish && '~/.config/fish'].filter(Boolean).join(', ');
62
+ return { active: true, where };
63
+ }
64
+ return { active: false, where: null };
65
+ }
66
+
67
+ function hookFilesExist() {
68
+ return existsSync(join(HOOKS_DIR, 'HOOK.md')) &&
69
+ existsSync(join(HOOKS_DIR, 'handler.js'));
70
+ }
71
+
72
+ function parseAuditLog() {
73
+ if (!existsSync(AUDIT_LOG)) return { count: 0, lastEntry: null };
74
+ try {
75
+ const lines = readFileSync(AUDIT_LOG, 'utf8')
76
+ .split('\n')
77
+ .filter(Boolean)
78
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
79
+ .filter(Boolean);
80
+ return { count: lines.length, lastEntry: lines[lines.length - 1] || null };
81
+ } catch { return { count: 0, lastEntry: null }; }
82
+ }
83
+
84
+ function countInstalledSkills() {
85
+ const dirs = [];
86
+ const userSkillsDir = join(OC_DIR, 'skills');
87
+ if (existsSync(userSkillsDir)) {
88
+ try {
89
+ dirs.push(...readdirSync(userSkillsDir, { withFileTypes: true })
90
+ .filter(e => e.isDirectory())
91
+ .map(e => join(userSkillsDir, e.name)));
92
+ } catch { /* skip */ }
93
+ }
94
+ return dirs.length;
95
+ }
96
+
97
+ function credentialSummary() {
98
+ const credFile = join(OC_DIR, 'agent-accounts.json');
99
+ if (!existsSync(credFile)) return { count: 0, oldestDays: null };
100
+ try {
101
+ const data = JSON.parse(readFileSync(credFile, 'utf8'));
102
+ let tokens = [];
103
+ if (Array.isArray(data)) tokens = data;
104
+ else if (data.accounts) tokens = Object.values(data.accounts);
105
+ else if (typeof data === 'object') tokens = Object.values(data);
106
+
107
+ const count = tokens.length;
108
+
109
+ let oldest = null;
110
+ for (const tok of tokens) {
111
+ if (tok && typeof tok === 'object') {
112
+ const dateStr = tok.createdAt || tok.created_at || tok.timestamp || null;
113
+ if (dateStr) {
114
+ const d = new Date(dateStr);
115
+ if (!isNaN(d) && (!oldest || d < oldest)) oldest = d;
116
+ }
117
+ }
118
+ }
119
+ const oldestDays = oldest ? Math.floor((Date.now() - oldest.getTime()) / 86_400_000) : null;
120
+ return { count, oldestDays };
121
+ } catch { return { count: 0, oldestDays: null }; }
122
+ }
123
+
124
+ function configBaselineStatus() {
125
+ const baselineFile = join(CLAWARMOR_DIR, 'config-baseline.json');
126
+ if (!existsSync(baselineFile)) return { status: 'unknown' };
127
+ try {
128
+ const baseline = JSON.parse(readFileSync(baselineFile, 'utf8'));
129
+ return { status: 'baseline', at: baseline.at || null };
130
+ } catch { return { status: 'unknown' }; }
131
+ }
132
+
133
+ function nextDigestDate() {
134
+ const now = new Date();
135
+ const dayOfWeek = now.getDay();
136
+ const daysUntilSunday = dayOfWeek === 0 ? 7 : 7 - dayOfWeek;
137
+ const next = new Date(now);
138
+ next.setDate(now.getDate() + daysUntilSunday);
139
+ next.setHours(9, 0, 0, 0);
140
+ const daysUntil = Math.ceil((next - now) / 86_400_000);
141
+ return {
142
+ label: next.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }),
143
+ daysUntil,
144
+ };
145
+ }
146
+
147
+ function digestInstalled() {
148
+ const jobs = readJson(CRON_JOBS_FILE);
149
+ if (!Array.isArray(jobs)) return false;
150
+ return jobs.some(j => j.id === 'clawarmor-weekly-digest');
151
+ }
152
+
153
+ // ── Grade color (A+/A=green, B=yellow, C=orange/yellow, D/F=red) ─────────────
154
+
155
+ function gradeStatusColor(grade) {
156
+ if (grade === 'A+' || grade === 'A') return paint.green;
157
+ if (grade === 'B') return paint.yellow;
158
+ if (grade === 'C') return paint.yellow; // no orange in ANSI; yellow is closest
159
+ return paint.red; // D, F
160
+ }
161
+
162
+ // ── Main export ───────────────────────────────────────────────────────────────
163
+
164
+ export async function runStatus() {
165
+ console.log('');
166
+ console.log(` ${paint.bold('ClawArmor')} ${paint.dim('v' + VERSION)} ${paint.dim('—')} ${paint.bold('Security Status')}`);
167
+ console.log('');
168
+
169
+ // ── Posture ──────────────────────────────────────────────────────────────
170
+ const lastScore = readJson(LAST_SCORE_FILE);
171
+ const history = readJson(HISTORY_FILE) || [];
172
+ const latestHistoryForPosture = history.length ? history[history.length - 1] : null;
173
+
174
+ let score = lastScore?.score ?? latestHistoryForPosture?.score ?? null;
175
+ let grade = lastScore?.grade ?? latestHistoryForPosture?.grade ?? null;
176
+ let scoreTs = lastScore?.timestamp ?? latestHistoryForPosture?.timestamp ?? null;
177
+
178
+ let weekDelta = null;
179
+ if (history.length >= 2) {
180
+ const weekAgo = Date.now() - 7 * 86_400_000;
181
+ const weekEntry = [...history].reverse().find(h => new Date(h.timestamp).getTime() < weekAgo);
182
+ if (weekEntry && score != null) {
183
+ weekDelta = score - weekEntry.score;
184
+ }
185
+ }
186
+
187
+ if (score != null) {
188
+ const grade2 = grade || scoreToGrade(score);
189
+ const colorFn = scoreColor(score);
190
+ const gradeFn = gradeStatusColor(grade2);
191
+ const arrow = trendArrow(weekDelta);
192
+ const weekNote = weekDelta != null ? paint.dim(' vs last week') : '';
193
+ console.log(` ${paint.dim('Posture')} ${gradeFn(grade2)} ${colorFn(score + '/100')} ${arrow}${weekNote}`);
194
+ } else {
195
+ console.log(` ${paint.dim('Posture')} ${paint.dim('No audit data — run: clawarmor audit')}`);
196
+ }
197
+
198
+ // ── Last audit ────────────────────────────────────────────────────────────
199
+ const { count: logCount, lastEntry } = parseAuditLog();
200
+ const lastAuditTs = lastEntry?.ts ?? scoreTs ?? null;
201
+ const trigger = lastEntry?.trigger ?? 'manual';
202
+ const auditAgo = lastAuditTs ? timeAgo(lastAuditTs) : 'never';
203
+ console.log(` ${paint.dim('Last audit')} ${paint.bold(auditAgo)} ${paint.dim('(' + trigger + ')')}`);
204
+
205
+ // ── Watcher ───────────────────────────────────────────────────────────────
206
+ const daemon = watchDaemonStatus();
207
+ let watchStr;
208
+ if (daemon.running) {
209
+ watchStr = `${paint.green('●')} ${paint.bold('running')} ${paint.dim('(PID ' + daemon.pid + ')')}`;
210
+ } else {
211
+ watchStr = `${paint.red('○')} ${paint.red('stopped')} ${paint.dim('→ run: clawarmor watch --daemon')}`;
212
+ }
213
+ console.log(` ${paint.dim('Watcher')} ${watchStr}`);
214
+
215
+ // ── Shell intercept ───────────────────────────────────────────────────────
216
+ const icp = intercept();
217
+ const icpStr = icp.active
218
+ ? `${paint.green('✓')} ${paint.bold('active')} ${paint.dim('(' + icp.where + ')')}`
219
+ : `${paint.red('✗')} ${paint.dim('not installed')}`;
220
+ console.log(` ${paint.dim('Intercept')} ${icpStr}`);
221
+
222
+ // ── Audit log ─────────────────────────────────────────────────────────────
223
+ console.log(` ${paint.dim('Audit log')} ${paint.bold(String(logCount))} ${paint.dim('events')} ${paint.dim('(clawarmor log to view)')}`);
224
+
225
+ console.log('');
226
+ console.log(SEP);
227
+
228
+ // ── Skills ────────────────────────────────────────────────────────────────
229
+ const skillCount = countInstalledSkills();
230
+ console.log(` ${paint.dim('Skills')} ${paint.bold(String(skillCount))} ${paint.dim('installed')} ${paint.dim('(clawarmor scan to check)')}`);
231
+
232
+ // ── Config baseline ───────────────────────────────────────────────────────
233
+ const baseline = configBaselineStatus();
234
+ const configStr = baseline.status === 'baseline'
235
+ ? `${paint.green('Baseline match')} ${paint.green('✓')}`
236
+ : paint.dim('No baseline yet — run: clawarmor audit');
237
+ console.log(` ${paint.dim('Config')} ${configStr}`);
238
+
239
+ // ── Credentials ───────────────────────────────────────────────────────────
240
+ const creds = credentialSummary();
241
+ let credStr = creds.count > 0
242
+ ? `${paint.bold(String(creds.count))} ${paint.dim('tokens')}`
243
+ : paint.dim('none found');
244
+ if (creds.oldestDays != null) {
245
+ const ageMark = creds.oldestDays > 90 ? paint.yellow('!') : paint.green('✓');
246
+ credStr += `${paint.dim(', oldest:')} ${creds.oldestDays}d ${ageMark}`;
247
+ }
248
+ console.log(` ${paint.dim('Credentials')} ${credStr}`);
249
+
250
+ console.log('');
251
+ console.log(SEP);
252
+
253
+ // ── Next digest ───────────────────────────────────────────────────────────
254
+ const digestOk = digestInstalled();
255
+ const nextDigest = nextDigestDate();
256
+ if (digestOk) {
257
+ console.log(` ${paint.dim('Next digest')} ${paint.bold(nextDigest.label)} ${paint.dim('(' + nextDigest.daysUntil + ' days)')}`);
258
+ } else {
259
+ console.log(` ${paint.dim('Next digest')} ${paint.dim('not scheduled')} ${paint.dim('(run: clawarmor protect --install)')}`);
260
+ }
261
+
262
+ // ── Full protection footer ────────────────────────────────────────────────
263
+ const hookOk = hookFilesExist();
264
+ const fullProtection = hookOk && daemon.running && icp.active;
265
+ console.log('');
266
+ if (fullProtection) {
267
+ console.log(` Full protection: ${paint.green('[✓ YES]')}`);
268
+ } else {
269
+ console.log(` Full protection: ${paint.red('[✗ NO')} ${paint.dim('— run clawarmor protect --install]')}`);
270
+ }
271
+ console.log('');
272
+ return 0;
273
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawarmor",
3
- "version": "2.0.0-alpha.2",
3
+ "version": "2.0.0",
4
4
  "description": "Security armor for OpenClaw agents — audit, scan, monitor",
5
5
  "bin": {
6
6
  "clawarmor": "cli.js"