clawarmor 1.1.0 → 2.0.0-alpha.2

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/watch.js ADDED
@@ -0,0 +1,235 @@
1
+ // clawarmor watch — Real-time file watcher for OpenClaw config and skills.
2
+ // Uses Node.js built-in fs.watch only. Zero new dependencies.
3
+
4
+ import { watch, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { homedir } from 'os';
7
+ import { spawnSync, fork } from 'child_process';
8
+
9
+ const HOME = homedir();
10
+ const OC_DIR = join(HOME, '.openclaw');
11
+ const CLAWARMOR_DIR = join(HOME, '.clawarmor');
12
+ const PID_FILE = join(CLAWARMOR_DIR, 'watch.pid');
13
+ const LAST_SCORE_FILE = join(CLAWARMOR_DIR, 'last-score.json');
14
+ const CONFIG_FILE = join(OC_DIR, 'openclaw.json');
15
+ const SKILLS_DIR = join(OC_DIR, 'skills');
16
+ const CLI_PATH = new URL('../cli.js', import.meta.url).pathname;
17
+
18
+ // Dynamically detect npm global skills dir
19
+ function detectNpmSkillsDir() {
20
+ try {
21
+ const result = spawnSync('npm', ['root', '-g'], { encoding: 'utf8', timeout: 5000 });
22
+ if (result.status === 0 && result.stdout) {
23
+ const npmRoot = result.stdout.trim();
24
+ const candidate = join(npmRoot, 'openclaw', 'skills');
25
+ if (existsSync(candidate)) return candidate;
26
+ }
27
+ } catch { /* skip */ }
28
+ return null;
29
+ }
30
+
31
+ function readLastScore() {
32
+ try {
33
+ if (existsSync(LAST_SCORE_FILE)) {
34
+ return JSON.parse(readFileSync(LAST_SCORE_FILE, 'utf8'));
35
+ }
36
+ } catch { /* ignore */ }
37
+ return null;
38
+ }
39
+
40
+ function writeLastScore(data) {
41
+ try {
42
+ mkdirSync(CLAWARMOR_DIR, { recursive: true });
43
+ writeFileSync(LAST_SCORE_FILE, JSON.stringify(data, null, 2), 'utf8');
44
+ } catch { /* non-fatal */ }
45
+ }
46
+
47
+ function runAuditJson() {
48
+ try {
49
+ const result = spawnSync(process.execPath, [CLI_PATH, 'audit', '--json'], {
50
+ encoding: 'utf8',
51
+ timeout: 30000,
52
+ maxBuffer: 1024 * 1024,
53
+ });
54
+ if (result.stdout) {
55
+ // Find the JSON blob in output (audit --json may mix some text before JSON)
56
+ const jsonStart = result.stdout.indexOf('{');
57
+ if (jsonStart !== -1) {
58
+ return JSON.parse(result.stdout.slice(jsonStart));
59
+ }
60
+ }
61
+ } catch { /* non-fatal */ }
62
+ return null;
63
+ }
64
+
65
+ function timestamp() {
66
+ return new Date().toLocaleTimeString('en-US', { hour12: false });
67
+ }
68
+
69
+ function onConfigChange() {
70
+ console.log(`[${timestamp()}] Config changed — re-running audit...`);
71
+
72
+ const auditResult = runAuditJson();
73
+ if (!auditResult) {
74
+ console.log(`[${timestamp()}] Could not parse audit output.`);
75
+ return;
76
+ }
77
+
78
+ const newScore = auditResult.score ?? null;
79
+ const lastState = readLastScore();
80
+
81
+ if (newScore !== null) {
82
+ const lastScore = lastState?.score ?? null;
83
+ if (lastScore !== null && newScore < lastScore) {
84
+ const drop = lastScore - newScore;
85
+ console.log(`[${timestamp()}] ALERT: Security score dropped ${drop} points (${lastScore} → ${newScore})`);
86
+ const newCriticals = (auditResult.failed || []).filter(f => f.severity === 'CRITICAL');
87
+ if (newCriticals.length) {
88
+ console.log(`[${timestamp()}] CRITICAL findings: ${newCriticals.map(f => f.id || f.title).join(', ')}`);
89
+ }
90
+ } else if (lastScore !== null && newScore > lastScore) {
91
+ console.log(`[${timestamp()}] Score improved: ${lastScore} → ${newScore}`);
92
+ } else {
93
+ console.log(`[${timestamp()}] Score unchanged: ${newScore}/100`);
94
+ }
95
+ writeLastScore({ score: newScore, grade: auditResult.grade, timestamp: new Date().toISOString() });
96
+ }
97
+ }
98
+
99
+ function onNewSkill(skillName) {
100
+ console.log(`[${timestamp()}] New skill detected: ${skillName}`);
101
+ console.log(`[${timestamp()}] Run: clawarmor scan to check for malicious patterns`);
102
+ }
103
+
104
+ export async function runWatch(flags = {}) {
105
+ if (flags.daemon) {
106
+ return startDaemon();
107
+ }
108
+
109
+ // Ensure ~/.clawarmor/ exists
110
+ mkdirSync(CLAWARMOR_DIR, { recursive: true });
111
+
112
+ const watchTargets = [];
113
+
114
+ if (existsSync(CONFIG_FILE)) {
115
+ watchTargets.push({ path: CONFIG_FILE, label: 'openclaw.json', type: 'config' });
116
+ }
117
+
118
+ if (!existsSync(OC_DIR)) {
119
+ console.log(` [watch] ~/.openclaw/ not found — waiting for it to appear is not supported.`);
120
+ console.log(` [watch] Run: openclaw doctor to set up OpenClaw first.`);
121
+ return 1;
122
+ }
123
+
124
+ // Watch skills dir (may not exist yet)
125
+ if (existsSync(SKILLS_DIR)) {
126
+ watchTargets.push({ path: SKILLS_DIR, label: 'skills/', type: 'skills' });
127
+ }
128
+
129
+ const npmSkillsDir = detectNpmSkillsDir();
130
+ if (npmSkillsDir) {
131
+ watchTargets.push({ path: npmSkillsDir, label: 'npm skills/', type: 'skills' });
132
+ }
133
+
134
+ if (!watchTargets.length) {
135
+ console.log(` [watch] Nothing to watch — no config or skills directories found.`);
136
+ return 1;
137
+ }
138
+
139
+ console.log(` ClawArmor Watch — monitoring ${watchTargets.length} path(s)`);
140
+ for (const t of watchTargets) console.log(` ${t.label} (${t.path})`);
141
+ console.log(` Press Ctrl+C to stop.\n`);
142
+
143
+ const debounceMap = new Map();
144
+ const DEBOUNCE_MS = 500;
145
+
146
+ // Track skill dirs seen
147
+ const seenSkills = new Set();
148
+ try {
149
+ if (existsSync(SKILLS_DIR)) {
150
+ readdirSync(SKILLS_DIR).forEach(d => seenSkills.add(d));
151
+ }
152
+ } catch { /* ignore */ }
153
+
154
+ for (const target of watchTargets) {
155
+ try {
156
+ watch(target.path, { recursive: target.type === 'skills' }, (eventType, filename) => {
157
+ const key = `${target.path}::${filename}`;
158
+ if (debounceMap.has(key)) clearTimeout(debounceMap.get(key));
159
+
160
+ debounceMap.set(key, setTimeout(() => {
161
+ debounceMap.delete(key);
162
+
163
+ if (target.type === 'config') {
164
+ onConfigChange();
165
+ } else if (target.type === 'skills' && filename) {
166
+ // Check if this is a new top-level skill directory
167
+ const topDir = filename.split('/')[0];
168
+ if (topDir && !seenSkills.has(topDir)) {
169
+ seenSkills.add(topDir);
170
+ onNewSkill(topDir);
171
+ }
172
+ }
173
+ }, DEBOUNCE_MS));
174
+ });
175
+ } catch (e) {
176
+ console.log(` [watch] Could not watch ${target.label}: ${e.message}`);
177
+ }
178
+ }
179
+
180
+ // Keep alive
181
+ return new Promise(() => {});
182
+ }
183
+
184
+ function startDaemon() {
185
+ mkdirSync(CLAWARMOR_DIR, { recursive: true });
186
+
187
+ const child = fork(CLI_PATH, ['watch'], {
188
+ detached: true,
189
+ stdio: 'ignore',
190
+ env: { ...process.env, CLAWARMOR_DAEMON: '1' },
191
+ });
192
+
193
+ child.unref();
194
+
195
+ try {
196
+ writeFileSync(PID_FILE, String(child.pid), 'utf8');
197
+ } catch { /* non-fatal */ }
198
+
199
+ console.log(` ClawArmor Watch daemon started (PID ${child.pid})`);
200
+ console.log(` PID written to: ${PID_FILE}`);
201
+ console.log(` Stop with: kill $(cat ${PID_FILE})`);
202
+ return 0;
203
+ }
204
+
205
+ export function stopDaemon() {
206
+ if (!existsSync(PID_FILE)) {
207
+ console.log(' No watch daemon PID file found.');
208
+ return false;
209
+ }
210
+ try {
211
+ const pid = parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10);
212
+ if (isNaN(pid)) { console.log(' Invalid PID in watch.pid'); return false; }
213
+ process.kill(pid, 'SIGTERM');
214
+ // Remove PID file
215
+ try { unlinkSync(PID_FILE); } catch { /* ignore */ }
216
+ console.log(` Watch daemon (PID ${pid}) stopped.`);
217
+ return true;
218
+ } catch (e) {
219
+ console.log(` Could not stop watch daemon: ${e.message}`);
220
+ return false;
221
+ }
222
+ }
223
+
224
+ export function watchDaemonStatus() {
225
+ if (!existsSync(PID_FILE)) return { running: false, pid: null };
226
+ try {
227
+ const pid = parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10);
228
+ if (isNaN(pid)) return { running: false, pid: null };
229
+ // Check if the process is alive
230
+ process.kill(pid, 0); // throws if not running
231
+ return { running: true, pid };
232
+ } catch {
233
+ return { running: false, pid: null };
234
+ }
235
+ }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "clawarmor",
3
- "version": "1.1.0",
3
+ "version": "2.0.0-alpha.2",
4
4
  "description": "Security armor for OpenClaw agents — audit, scan, monitor",
5
5
  "bin": {
6
- "clawarmor": "./cli.js"
6
+ "clawarmor": "cli.js"
7
7
  },
8
8
  "type": "module",
9
9
  "engines": {
@@ -23,7 +23,7 @@
23
23
  "license": "MIT",
24
24
  "repository": {
25
25
  "type": "git",
26
- "url": "https://github.com/pinzasai/clawarmor"
26
+ "url": "git+https://github.com/pinzasai/clawarmor.git"
27
27
  },
28
28
  "homepage": "https://clawarmor.dev"
29
29
  }