clawarmor 2.0.0-alpha.3 → 2.1.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/prescan.js CHANGED
@@ -1,16 +1,17 @@
1
1
  // ClawArmor v2.0 — Pre-scan a skill before installing
2
- // Downloads the npm package to a temp dir, scans it with the full
3
- // ClawArmor scanner, and exits 1 (blocks install) only on CRITICAL findings.
2
+ // Supports both npm packages and ClawHub skills.
3
+ // ClawHub skills are checked locally first; npm is used as fallback.
4
4
 
5
- import { mkdirSync, rmSync, readdirSync } from 'fs';
5
+ import { mkdirSync, rmSync, readdirSync, existsSync } from 'fs';
6
6
  import { join } from 'path';
7
- import { tmpdir } from 'os';
7
+ import { tmpdir, homedir } from 'os';
8
8
  import { execSync } from 'child_process';
9
9
  import { scanFile } from './scanner/file-scanner.js';
10
10
  import { scanSkillMdFiles } from './scanner/skill-md-scanner.js';
11
11
  import { paint, severityColor } from './output/colors.js';
12
12
  import { append } from './audit-log.js';
13
13
 
14
+ const HOME = homedir();
14
15
  const SEP = paint.dim('─'.repeat(52));
15
16
 
16
17
  function getAllFiles(dir, files = []) {
@@ -29,91 +30,83 @@ function cleanupTmp(dir) {
29
30
  try { rmSync(dir, { recursive: true, force: true }); } catch { /* non-fatal */ }
30
31
  }
31
32
 
32
- export async function runPrescan(skillName) {
33
- console.log('');
34
- console.log(` ${paint.bold('ClawArmor Prescan')} — ${paint.cyan(skillName)}`);
35
- console.log(` ${paint.dim('Fetching package from npm registry...')}`);
36
- console.log('');
33
+ // ── ClawHub skill detection ──────────────────────────────────────────────────
37
34
 
38
- const tmpDir = join(tmpdir(), `clawarmor-prescan-${Date.now()}`);
39
- mkdirSync(tmpDir, { recursive: true });
35
+ function isNpmScoped(name) {
36
+ // Scoped npm package: @org/pkg
37
+ return name.startsWith('@') && name.includes('/');
38
+ }
40
39
 
41
- // ── Step 1: Download via npm pack ─────────────────────────────────────────
42
- let tarball;
40
+ function looksLikeClawHubSkill(name) {
41
+ // Plain name, no scope, no slash — could be a ClawHub skill; try local first
42
+ return !name.startsWith('@') && !name.includes('/');
43
+ }
44
+
45
+ // Returns the local install path for a ClawHub skill, or null if not found.
46
+ function findLocalClawHubSkill(name) {
47
+ // Path 1: ~/.openclaw/skills/<name>/
48
+ const userSkillsPath = join(HOME, '.openclaw', 'skills', name);
49
+ if (existsSync(userSkillsPath)) return userSkillsPath;
50
+
51
+ // Path 2: openclaw npm module's skills directory
52
+ // Try common global npm locations
53
+ const candidates = [
54
+ join(HOME, '.npm-global', 'lib', 'node_modules', 'openclaw', 'skills', name),
55
+ '/usr/local/lib/node_modules/openclaw/skills/' + name,
56
+ '/usr/lib/node_modules/openclaw/skills/' + name,
57
+ join(HOME, '.nvm', 'versions', 'node'), // nvm — we check dirs below
58
+ ];
59
+
60
+ // Try to resolve openclaw via node resolution from this file's location
43
61
  try {
44
- execSync(`npm pack ${skillName}`, {
45
- cwd: tmpDir,
46
- timeout: 30000,
62
+ const result = execSync('node -e "console.log(require.resolve(\'openclaw/package.json\'))"', {
63
+ encoding: 'utf8',
64
+ timeout: 5000,
47
65
  stdio: ['ignore', 'pipe', 'ignore'],
48
- });
49
- const tarballs = readdirSync(tmpDir).filter(f => f.endsWith('.tgz'));
50
- if (!tarballs.length) throw new Error('npm pack produced no tarball');
51
- tarball = join(tmpDir, tarballs[0]);
52
- } catch {
53
- cleanupTmp(tmpDir);
54
- console.log(` ${paint.dim('ℹ')} Could not fetch skill for scanning`);
55
- console.log(` ${paint.dim('(package not found or network error install not blocked)')}`);
56
- console.log('');
57
- return 0;
58
- }
66
+ }).trim();
67
+ if (result) {
68
+ // result is like /path/to/node_modules/openclaw/package.json
69
+ const ocDir = result.replace(/[\\/]package\.json$/, '');
70
+ const skillPath = join(ocDir, 'skills', name);
71
+ if (existsSync(skillPath)) return skillPath;
72
+ }
73
+ } catch { /* openclaw may not be installed */ }
59
74
 
60
- // ── Step 2: Extract tarball ────────────────────────────────────────────────
61
- const extractDir = join(tmpDir, 'extracted');
62
- mkdirSync(extractDir, { recursive: true });
63
- try {
64
- execSync(`tar -xzf "${tarball}" -C "${extractDir}"`, {
65
- timeout: 15000,
66
- stdio: ['ignore', 'ignore', 'ignore'],
67
- });
68
- } catch {
69
- cleanupTmp(tmpDir);
70
- console.log(` ${paint.dim('ℹ')} Could not extract skill package — install not blocked`);
71
- console.log('');
72
- return 0;
75
+ for (const candidate of candidates) {
76
+ if (existsSync(candidate)) return candidate;
73
77
  }
74
78
 
75
- // ── Step 3: Collect all files ──────────────────────────────────────────────
76
- const allFiles = getAllFiles(extractDir);
77
- console.log(` ${paint.dim('Scanning')} ${allFiles.length} file${allFiles.length !== 1 ? 's' : ''}...`);
78
- console.log('');
79
+ return null;
80
+ }
81
+
82
+ // ── Scan a directory of files ────────────────────────────────────────────────
79
83
 
80
- // ── Step 4: Run ClawArmor scanners ────────────────────────────────────────
81
- // Not a built-in — treat as third-party (isBuiltin = false)
84
+ function scanDirectory(dir) {
85
+ const allFiles = getAllFiles(dir);
82
86
  const codeFindings = allFiles.flatMap(f => scanFile(f, false));
83
87
  const mdResults = scanSkillMdFiles(allFiles, false);
84
88
  const mdFindings = mdResults.flatMap(r => r.findings);
85
- const allFindings = [...codeFindings, ...mdFindings];
89
+ return { allFiles, allFindings: [...codeFindings, ...mdFindings] };
90
+ }
91
+
92
+ // ── Result printer ───────────────────────────────────────────────────────────
86
93
 
94
+ function printResult(skillName, allFiles, allFindings) {
87
95
  const criticals = allFindings.filter(f => f.severity === 'CRITICAL');
88
96
  const highs = allFindings.filter(f => f.severity === 'HIGH');
89
97
  const mediums = allFindings.filter(f => f.severity === 'MEDIUM');
90
98
  const lows = allFindings.filter(f => f.severity === 'LOW' || f.severity === 'INFO');
99
+ const fileCount = allFiles.length;
91
100
 
92
- // ── Cleanup (always) ──────────────────────────────────────────────────────
93
- cleanupTmp(tmpDir);
94
-
95
- // ── Audit log ─────────────────────────────────────────────────────────────
96
- append({
97
- cmd: 'prescan',
98
- trigger: 'prescan',
99
- score: null,
100
- delta: null,
101
- findings: allFindings.map(f => ({ id: f.patternId || f.id || '?', severity: f.severity })),
102
- blocked: criticals.length > 0,
103
- skill: skillName,
104
- });
105
-
106
- // ── Output ────────────────────────────────────────────────────────────────
107
101
  if (!allFindings.length) {
108
- console.log(` ${paint.green('✓')} ClawArmor prescan: clean 0 findings`);
102
+ console.log(` ${paint.green('✓')} ${paint.bold(skillName)} ${paint.dim('—')} clean ${paint.dim('(' + fileCount + ' file' + (fileCount !== 1 ? 's' : '') + ' scanned, 0 findings)')}`);
109
103
  console.log('');
110
104
  return 0;
111
105
  }
112
106
 
113
- // CRITICAL → print details, block install (exit 1)
114
107
  if (criticals.length) {
115
- console.log(SEP);
116
- console.log(` ${paint.red('')} ${paint.bold(`CRITICAL (${criticals.length}) — install blocked`)}`);
108
+ console.log(` ${paint.red('✗')} ${paint.bold(skillName)} ${paint.dim('—')} ${paint.red('BLOCKED')} ${paint.dim('(' + criticals.length + ' critical finding' + (criticals.length !== 1 ? 's' : '') + ')')}`);
109
+ console.log('');
117
110
  console.log(SEP);
118
111
  for (const f of criticals) {
119
112
  console.log('');
@@ -138,10 +131,10 @@ export async function runPrescan(skillName) {
138
131
  return 1;
139
132
  }
140
133
 
141
- // HIGH → warn, allow install
134
+ // HIGH → warn, allow
142
135
  if (highs.length) {
143
- console.log(SEP);
144
- console.log(` ${paint.yellow('')} ${paint.bold(`HIGH (${highs.length}) — review before using`)}`);
136
+ console.log(` ${paint.yellow('⚠')} ${paint.bold(skillName)} ${paint.dim('—')} ${paint.yellow('review recommended')} ${paint.dim('(' + highs.length + ' high finding' + (highs.length !== 1 ? 's' : '') + ', ' + fileCount + ' files)')}`);
137
+ console.log('');
145
138
  console.log(SEP);
146
139
  for (const f of highs) {
147
140
  console.log('');
@@ -152,9 +145,10 @@ export async function runPrescan(skillName) {
152
145
  }
153
146
  }
154
147
  console.log('');
148
+ } else {
149
+ console.log(` ${paint.yellow('!')} ${paint.bold(skillName)} ${paint.dim('—')} ${paint.dim(fileCount + ' files scanned, ' + (mediums.length + lows.length) + ' low/medium findings')}`);
155
150
  }
156
151
 
157
- // MEDIUM/LOW → summary line only
158
152
  if (mediums.length || lows.length) {
159
153
  const parts = [];
160
154
  if (mediums.length) parts.push(`${mediums.length} medium`);
@@ -165,3 +159,105 @@ export async function runPrescan(skillName) {
165
159
 
166
160
  return 0;
167
161
  }
162
+
163
+ // ── Download via npm pack ────────────────────────────────────────────────────
164
+
165
+ async function scanViaNpm(skillName, tmpDir) {
166
+ let tarball;
167
+ try {
168
+ execSync(`npm pack ${skillName}`, {
169
+ cwd: tmpDir,
170
+ timeout: 30000,
171
+ stdio: ['ignore', 'pipe', 'ignore'],
172
+ });
173
+ const tarballs = readdirSync(tmpDir).filter(f => f.endsWith('.tgz'));
174
+ if (!tarballs.length) throw new Error('npm pack produced no tarball');
175
+ tarball = join(tmpDir, tarballs[0]);
176
+ } catch {
177
+ return null; // not found or network error
178
+ }
179
+
180
+ const extractDir = join(tmpDir, 'extracted');
181
+ mkdirSync(extractDir, { recursive: true });
182
+ try {
183
+ execSync(`tar -xzf "${tarball}" -C "${extractDir}"`, {
184
+ timeout: 15000,
185
+ stdio: ['ignore', 'ignore', 'ignore'],
186
+ });
187
+ } catch {
188
+ return null;
189
+ }
190
+
191
+ return extractDir;
192
+ }
193
+
194
+ // ── Main export ───────────────────────────────────────────────────────────────
195
+
196
+ export async function runPrescan(skillName) {
197
+ console.log('');
198
+ console.log(` ${paint.bold('ClawArmor Prescan')} — ${paint.cyan(skillName)}`);
199
+ console.log('');
200
+
201
+ let scanDir = null;
202
+ let usedTmp = null;
203
+ let source = 'npm';
204
+
205
+ // ── Step 1: Check local ClawHub install (for plain skill names) ───────────
206
+ if (looksLikeClawHubSkill(skillName)) {
207
+ const localPath = findLocalClawHubSkill(skillName);
208
+ if (localPath) {
209
+ console.log(` ${paint.dim('Found locally:')} ${paint.dim(localPath)}`);
210
+ console.log(` ${paint.dim('Scanning local files...')}`);
211
+ console.log('');
212
+ scanDir = localPath;
213
+ source = 'local';
214
+ } else {
215
+ console.log(` ${paint.dim('Not found locally — fetching from npm registry...')}`);
216
+ console.log('');
217
+ }
218
+ } else {
219
+ console.log(` ${paint.dim('Fetching package from npm registry...')}`);
220
+ console.log('');
221
+ }
222
+
223
+ // ── Step 2: Fallback to npm pack if not local ─────────────────────────────
224
+ if (!scanDir) {
225
+ const tmpDir = join(tmpdir(), `clawarmor-prescan-${Date.now()}`);
226
+ mkdirSync(tmpDir, { recursive: true });
227
+ usedTmp = tmpDir;
228
+
229
+ const extractDir = await scanViaNpm(skillName, tmpDir);
230
+ if (!extractDir) {
231
+ cleanupTmp(tmpDir);
232
+ console.log(` ${paint.dim('ℹ')} Could not fetch skill for scanning`);
233
+ console.log(` ${paint.dim('(package not found or network error — install not blocked)')}`);
234
+ console.log('');
235
+ return 0;
236
+ }
237
+ scanDir = extractDir;
238
+ }
239
+
240
+ // ── Step 3: Scan ──────────────────────────────────────────────────────────
241
+ const { allFiles, allFindings } = scanDirectory(scanDir);
242
+ console.log(` ${paint.dim('Scanning')} ${allFiles.length} file${allFiles.length !== 1 ? 's' : ''}...`);
243
+ console.log('');
244
+
245
+ // ── Cleanup tmp (always, only if we created one) ──────────────────────────
246
+ if (usedTmp) cleanupTmp(usedTmp);
247
+
248
+ // ── Audit log ─────────────────────────────────────────────────────────────
249
+ const criticals = allFindings.filter(f => f.severity === 'CRITICAL');
250
+ append({
251
+ cmd: 'prescan',
252
+ trigger: 'prescan',
253
+ score: null,
254
+ delta: null,
255
+ findings: allFindings.map(f => ({ id: f.patternId || f.id || '?', severity: f.severity })),
256
+ blocked: criticals.length > 0,
257
+ skill: skillName,
258
+ source,
259
+ });
260
+
261
+ // ── Output ────────────────────────────────────────────────────────────────
262
+ return printResult(skillName, allFiles, allFindings);
263
+ }
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() {
@@ -251,10 +298,14 @@ export async function runProtect(flags = {}) {
251
298
  shellPath = shellPath ? shellPath + ', ~/.bashrc' : '~/.bashrc';
252
299
  shellInstalled = true;
253
300
  }
301
+ if (injectFishFunction()) {
302
+ shellPath = shellPath ? shellPath + ', ~/.config/fish' : '~/.config/fish';
303
+ shellInstalled = true;
304
+ }
254
305
  if (shellInstalled) {
255
306
  console.log(` ✓ Shell intercept added (${shellPath})`);
256
307
  } else {
257
- console.log(` ! No ~/.zshrc or ~/.bashrc found — shell intercept skipped`);
308
+ console.log(` ! No ~/.zshrc, ~/.bashrc, or fish config found — shell intercept skipped`);
258
309
  }
259
310
 
260
311
  // 4. Weekly digest cron
@@ -296,6 +347,10 @@ export async function runProtect(flags = {}) {
296
347
  console.log(` ✓ Shell intercept removed from ~/.bashrc`);
297
348
  shellRemoved = true;
298
349
  }
350
+ if (removeFishFunction()) {
351
+ console.log(` ✓ Shell intercept removed from ~/.config/fish/functions/openclaw.fish`);
352
+ shellRemoved = true;
353
+ }
299
354
  if (!shellRemoved) {
300
355
  console.log(` - No shell intercept found to remove`);
301
356
  }
@@ -322,14 +377,15 @@ export async function runProtect(flags = {}) {
322
377
  // Shell function
323
378
  const inZsh = shellFunctionPresent(zshrc);
324
379
  const inBash = shellFunctionPresent(bashrc);
325
- if (inZsh || inBash) {
326
- 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(', ');
327
383
  console.log(` Shell intercept ✓ active (${where})`);
328
384
  } else {
329
385
  console.log(` Shell intercept ✗ not installed`);
330
386
  }
331
387
 
332
- const allActive = hookOk && daemon.running && (inZsh || inBash);
388
+ const allActive = hookOk && daemon.running && (inZsh || inBash || inFish);
333
389
  console.log('');
334
390
  if (allActive) {
335
391
  console.log(` Full protection active.`);
package/lib/status.js CHANGED
@@ -16,9 +16,11 @@ const HISTORY_FILE = join(CLAWARMOR_DIR, 'history.json');
16
16
  const AUDIT_LOG = join(CLAWARMOR_DIR, 'audit.log');
17
17
  const ZSHRC = join(HOME, '.zshrc');
18
18
  const BASHRC = join(HOME, '.bashrc');
19
+ const FISH_FUNCTION_FILE = join(HOME, '.config', 'fish', 'functions', 'openclaw.fish');
19
20
  const SHELL_MARKER = '# ClawArmor intercept — added by: clawarmor protect --install';
20
21
  const CRON_JOBS_FILE = join(OC_DIR, 'cron', 'jobs.json');
21
- const VERSION = '2.0.0-alpha.3';
22
+ const HOOKS_DIR = join(OC_DIR, 'hooks', 'clawarmor-guard');
23
+ const VERSION = '2.0.0';
22
24
 
23
25
  const SEP = paint.dim('─'.repeat(52));
24
26
 
@@ -54,13 +56,19 @@ function trendArrow(delta) {
54
56
  function intercept() {
55
57
  const inZsh = existsSync(ZSHRC) && readFileSync(ZSHRC, 'utf8').includes(SHELL_MARKER);
56
58
  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
+ 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(', ');
59
62
  return { active: true, where };
60
63
  }
61
64
  return { active: false, where: null };
62
65
  }
63
66
 
67
+ function hookFilesExist() {
68
+ return existsSync(join(HOOKS_DIR, 'HOOK.md')) &&
69
+ existsSync(join(HOOKS_DIR, 'handler.js'));
70
+ }
71
+
64
72
  function parseAuditLog() {
65
73
  if (!existsSync(AUDIT_LOG)) return { count: 0, lastEntry: null };
66
74
  try {
@@ -91,7 +99,6 @@ function credentialSummary() {
91
99
  if (!existsSync(credFile)) return { count: 0, oldestDays: null };
92
100
  try {
93
101
  const data = JSON.parse(readFileSync(credFile, 'utf8'));
94
- // Count token entries — format varies; try common patterns
95
102
  let tokens = [];
96
103
  if (Array.isArray(data)) tokens = data;
97
104
  else if (data.accounts) tokens = Object.values(data.accounts);
@@ -99,7 +106,6 @@ function credentialSummary() {
99
106
 
100
107
  const count = tokens.length;
101
108
 
102
- // Try to find oldest by looking for date fields
103
109
  let oldest = null;
104
110
  for (const tok of tokens) {
105
111
  if (tok && typeof tok === 'object') {
@@ -120,15 +126,13 @@ function configBaselineStatus() {
120
126
  if (!existsSync(baselineFile)) return { status: 'unknown' };
121
127
  try {
122
128
  const baseline = JSON.parse(readFileSync(baselineFile, 'utf8'));
123
- // Simple presence check — full integrity is handled by lib/integrity.js
124
129
  return { status: 'baseline', at: baseline.at || null };
125
130
  } catch { return { status: 'unknown' }; }
126
131
  }
127
132
 
128
133
  function nextDigestDate() {
129
- // Next Sunday at 9am
130
134
  const now = new Date();
131
- const dayOfWeek = now.getDay(); // 0=Sun
135
+ const dayOfWeek = now.getDay();
132
136
  const daysUntilSunday = dayOfWeek === 0 ? 7 : 7 - dayOfWeek;
133
137
  const next = new Date(now);
134
138
  next.setDate(now.getDate() + daysUntilSunday);
@@ -146,6 +150,15 @@ function digestInstalled() {
146
150
  return jobs.some(j => j.id === 'clawarmor-weekly-digest');
147
151
  }
148
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
+
149
162
  // ── Main export ───────────────────────────────────────────────────────────────
150
163
 
151
164
  export async function runStatus() {
@@ -158,12 +171,10 @@ export async function runStatus() {
158
171
  const history = readJson(HISTORY_FILE) || [];
159
172
  const latestHistoryForPosture = history.length ? history[history.length - 1] : null;
160
173
 
161
- // Prefer last-score.json (written by watch/guard hook), fall back to history.json
162
174
  let score = lastScore?.score ?? latestHistoryForPosture?.score ?? null;
163
175
  let grade = lastScore?.grade ?? latestHistoryForPosture?.grade ?? null;
164
176
  let scoreTs = lastScore?.timestamp ?? latestHistoryForPosture?.timestamp ?? null;
165
177
 
166
- // Trend: compare to ~7 days ago from history
167
178
  let weekDelta = null;
168
179
  if (history.length >= 2) {
169
180
  const weekAgo = Date.now() - 7 * 86_400_000;
@@ -173,13 +184,13 @@ export async function runStatus() {
173
184
  }
174
185
  }
175
186
 
176
- // Score line
177
187
  if (score != null) {
178
188
  const grade2 = grade || scoreToGrade(score);
179
189
  const colorFn = scoreColor(score);
190
+ const gradeFn = gradeStatusColor(grade2);
180
191
  const arrow = trendArrow(weekDelta);
181
192
  const weekNote = weekDelta != null ? paint.dim(' vs last week') : '';
182
- console.log(` ${paint.dim('Posture')} ${gradeColor(grade2)} ${colorFn(score + '/100')} ${arrow}${weekNote}`);
193
+ console.log(` ${paint.dim('Posture')} ${gradeFn(grade2)} ${colorFn(score + '/100')} ${arrow}${weekNote}`);
183
194
  } else {
184
195
  console.log(` ${paint.dim('Posture')} ${paint.dim('No audit data — run: clawarmor audit')}`);
185
196
  }
@@ -193,9 +204,12 @@ export async function runStatus() {
193
204
 
194
205
  // ── Watcher ───────────────────────────────────────────────────────────────
195
206
  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')}`;
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
+ }
199
213
  console.log(` ${paint.dim('Watcher')} ${watchStr}`);
200
214
 
201
215
  // ── Shell intercept ───────────────────────────────────────────────────────
@@ -245,6 +259,15 @@ export async function runStatus() {
245
259
  console.log(` ${paint.dim('Next digest')} ${paint.dim('not scheduled')} ${paint.dim('(run: clawarmor protect --install)')}`);
246
260
  }
247
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
+ }
248
271
  console.log('');
249
272
  return 0;
250
273
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clawarmor",
3
- "version": "2.0.0-alpha.3",
4
- "description": "Security armor for OpenClaw agents audit, scan, monitor",
3
+ "version": "2.1.0",
4
+ "description": "Security armor for OpenClaw agents \u2014 audit, scan, monitor",
5
5
  "bin": {
6
6
  "clawarmor": "cli.js"
7
7
  },