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/README.md +50 -146
- package/cli.js +3 -2
- package/demo-preview.gif +0 -0
- package/demo.cast +680 -0
- package/demo.gif +0 -0
- package/lib/fix.js +76 -14
- package/lib/harden.js +114 -13
- package/lib/prescan.js +166 -70
- package/lib/protect.js +60 -4
- package/lib/status.js +38 -15
- package/package.json +2 -2
- package/scripts/record-demo.py +125 -0
package/lib/prescan.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
// ClawArmor v2.0 — Pre-scan a skill before installing
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
35
|
+
function isNpmScoped(name) {
|
|
36
|
+
// Scoped npm package: @org/pkg
|
|
37
|
+
return name.startsWith('@') && name.includes('/');
|
|
38
|
+
}
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
|
|
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(
|
|
45
|
-
|
|
46
|
-
timeout:
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Scan a directory of files ────────────────────────────────────────────────
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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('✓')}
|
|
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(
|
|
116
|
-
console.log(
|
|
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
|
|
134
|
+
// HIGH → warn, allow
|
|
142
135
|
if (highs.length) {
|
|
143
|
-
console.log(
|
|
144
|
-
console.log(
|
|
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
|
|
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
|
-
|
|
326
|
-
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
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();
|
|
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')} ${
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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.
|
|
4
|
-
"description": "Security armor for OpenClaw agents
|
|
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
|
},
|