openclaw-node-harness 2.1.0 → 2.2.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.
Files changed (52) hide show
  1. package/bin/lane-watchdog.js +54 -23
  2. package/bin/mesh-agent.js +49 -18
  3. package/bin/mesh-bridge.js +3 -2
  4. package/bin/mesh-deploy.js +4 -0
  5. package/bin/mesh-health-publisher.js +41 -1
  6. package/bin/mesh-task-daemon.js +14 -4
  7. package/bin/mesh.js +17 -43
  8. package/install.sh +3 -2
  9. package/lib/agent-activity.js +2 -2
  10. package/lib/exec-safety.js +163 -0
  11. package/lib/kanban-io.js +20 -33
  12. package/lib/llm-providers.js +27 -0
  13. package/lib/mcp-knowledge/core.mjs +7 -5
  14. package/lib/mcp-knowledge/server.mjs +8 -1
  15. package/lib/mesh-collab.js +274 -250
  16. package/lib/mesh-harness.js +6 -0
  17. package/lib/mesh-plans.js +84 -45
  18. package/lib/mesh-tasks.js +113 -81
  19. package/lib/nats-resolve.js +4 -4
  20. package/lib/pre-compression-flush.mjs +2 -0
  21. package/lib/session-store.mjs +6 -3
  22. package/mission-control/package-lock.json +4188 -3698
  23. package/mission-control/package.json +2 -2
  24. package/mission-control/src/app/api/diagnostics/route.ts +8 -0
  25. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +8 -0
  26. package/mission-control/src/app/api/memory/graph/route.ts +34 -18
  27. package/mission-control/src/app/api/memory/search/route.ts +9 -5
  28. package/mission-control/src/app/api/mesh/identity/route.ts +13 -5
  29. package/mission-control/src/app/api/mesh/nodes/route.ts +8 -0
  30. package/mission-control/src/app/api/settings/gateway/route.ts +62 -0
  31. package/mission-control/src/app/api/souls/[id]/evolution/route.ts +49 -12
  32. package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
  33. package/mission-control/src/app/api/souls/[id]/propagate/route.ts +24 -5
  34. package/mission-control/src/app/api/souls/route.ts +6 -4
  35. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +7 -1
  36. package/mission-control/src/app/api/tasks/[id]/route.ts +20 -4
  37. package/mission-control/src/app/api/tasks/route.ts +68 -9
  38. package/mission-control/src/app/api/workspace/read/route.ts +11 -0
  39. package/mission-control/src/lib/config.ts +11 -2
  40. package/mission-control/src/lib/db/index.ts +16 -1
  41. package/mission-control/src/lib/memory/extract.ts +2 -1
  42. package/mission-control/src/lib/memory/retrieval.ts +3 -2
  43. package/mission-control/src/lib/sync/tasks.ts +4 -1
  44. package/mission-control/src/middleware.ts +82 -0
  45. package/package.json +1 -1
  46. package/services/launchd/ai.openclaw.lane-watchdog.plist +1 -1
  47. package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
  48. package/services/launchd/ai.openclaw.mesh-agent.plist +4 -0
  49. package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
  50. package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
  51. package/services/launchd/ai.openclaw.mission-control.plist +5 -4
  52. package/uninstall.sh +37 -9
@@ -0,0 +1,163 @@
1
+ /**
2
+ * exec-safety.js — Shared command safety filtering for mesh exec.
3
+ *
4
+ * Used by both CLI-side (mesh.js) and server-side (NATS exec handler)
5
+ * to block destructive or unauthorized commands before execution.
6
+ *
7
+ * Two layers:
8
+ * 1. DESTRUCTIVE_PATTERNS — blocklist of known-dangerous patterns
9
+ * 2. ALLOWED_PREFIXES — allowlist for server-side execution (opt-in)
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ // Shell metacharacter detection — blocks command chaining/injection.
15
+ // Safe pipes to common read-only utilities are allowed.
16
+ const SHELL_CHAIN_PATTERNS = /[\n\r\0;`]|\$\(|\|\||&&|<\(|>\(|<<|>>|>\s|\|(?!\s*grep\b|\s*head\b|\s*tail\b|\s*wc\b|\s*sort\b)/;
17
+
18
+ function containsShellChaining(cmd) {
19
+ // Allow safe pipes to common read-only utilities
20
+ return SHELL_CHAIN_PATTERNS.test(cmd);
21
+ }
22
+
23
+ // Dangerous flags that allow arbitrary code execution via node
24
+ const DANGEROUS_NODE_FLAGS = /\bnode\s+(-e\b|--eval\b|-p\b|--print\b|-r\b|--require\b|--import\b|--loader\b|--experimental-loader\b)/;
25
+
26
+ // Dangerous git flags that allow arbitrary config / code execution
27
+ const DANGEROUS_GIT_FLAGS = /\bgit\s+(-c\s|--config\s)/;
28
+
29
+ // Dangerous find flags that allow arbitrary command execution
30
+ const DANGEROUS_FIND_FLAGS = /\bfind\b.*\s(-exec\b|-execdir\b|-delete\b|-ok\b|-okdir\b)/;
31
+
32
+ // Dangerous make variable overrides (SHELL=, CC=, etc.)
33
+ const DANGEROUS_MAKE_FLAGS = /\bmake\b.*\b(SHELL|CC|CXX|LD|AR)=/;
34
+
35
+ // Dangerous python flags that allow arbitrary code execution
36
+ const DANGEROUS_PYTHON_FLAGS = /\bpython3?\s+(-c\b|-m\s+http)/;
37
+
38
+ const DESTRUCTIVE_PATTERNS = [
39
+ /\brm\s+(-[a-zA-Z]*)?r[a-zA-Z]*f/, // rm -rf, rm -fr, rm --recursive --force
40
+ /\brm\s+(-[a-zA-Z]*)?f[a-zA-Z]*r/, // rm -fr variants
41
+ /\bmkfs\b/, // format filesystem
42
+ /\bdd\s+.*of=/, // raw disk write
43
+ /\b>\s*\/dev\/[sh]d/, // write to raw device
44
+ /\bcurl\b.*\|\s*(ba)?sh/, // curl pipe to shell
45
+ /\bwget\b.*\|\s*(ba)?sh/, // wget pipe to shell
46
+ /\bchmod\s+(-[a-zA-Z]*\s+)?777\s+\//, // chmod 777 on root paths
47
+ /\b:(){ :\|:& };:/, // fork bomb
48
+ /\bsudo\b/, // sudo escalation
49
+ /\bsu\s+-?\s/, // su user switch
50
+ /\bpasswd\b/, // password change
51
+ /\buseradd\b|\buserdel\b/, // user management
52
+ /\biptables\b|\bnft\b/, // firewall modification
53
+ /\bsystemctl\s+(stop|disable|mask)/, // service disruption
54
+ /\blaunchctl\s+(unload|remove)/, // macOS service disruption
55
+ /\bkill\s+-9\s+1\b/, // kill init/launchd
56
+ />\s*\/etc\//, // overwrite system config
57
+ /\beval\b.*\$\(/, // eval with command substitution
58
+ ];
59
+
60
+ /**
61
+ * Allowed command prefixes for server-side NATS exec.
62
+ * Only commands starting with one of these are permitted.
63
+ * CLI-side uses blocklist only; server-side uses both blocklist + allowlist.
64
+ */
65
+ const ALLOWED_EXEC_PREFIXES = [
66
+ 'git ', 'node ', 'python ', 'python3 ',
67
+ 'npm test', 'npm run test', 'npm run lint', 'npm run build', 'npm run dev',
68
+ 'npm run start', 'npm install', 'npm ci', 'npm ls', 'npm outdated',
69
+ 'npm audit', 'npm version', 'npm pack', 'npm run check',
70
+ 'npx vitest', 'npx jest', 'npx eslint', 'npx prettier', 'npx tsc',
71
+ 'cat ', 'ls ', 'head ', 'tail ', 'grep ', 'find ', 'wc ',
72
+ 'echo ', 'date ', 'uptime ', 'df ', 'free ', 'ps ',
73
+ 'bash openclaw/', 'bash ~/openclaw/', 'bash ./bin/',
74
+ 'pwd', 'which ',
75
+ 'cargo ', 'go ', 'make ', 'pytest ', 'jest ', 'vitest ',
76
+ ];
77
+
78
+ /**
79
+ * Check if a command matches any destructive pattern.
80
+ * @param {string} command
81
+ * @returns {{ blocked: boolean, pattern?: RegExp }}
82
+ */
83
+ function checkDestructivePatterns(command) {
84
+ for (const pattern of DESTRUCTIVE_PATTERNS) {
85
+ if (pattern.test(command)) {
86
+ return { blocked: true, pattern };
87
+ }
88
+ }
89
+ return { blocked: false };
90
+ }
91
+
92
+ /**
93
+ * Check if a command is allowed by the server-side allowlist.
94
+ * @param {string} command
95
+ * @returns {boolean}
96
+ */
97
+ function isAllowedExecCommand(command) {
98
+ const trimmed = (command || '').trim();
99
+ if (!trimmed) return false;
100
+ return ALLOWED_EXEC_PREFIXES.some(p => trimmed.startsWith(p));
101
+ }
102
+
103
+ /**
104
+ * Full server-side validation: blocklist + allowlist.
105
+ * Returns { allowed: true } or { allowed: false, reason: string }.
106
+ * @param {string} command
107
+ * @returns {{ allowed: boolean, reason?: string }}
108
+ */
109
+ function validateExecCommand(command) {
110
+ const trimmed = (command || '').trim();
111
+ if (!trimmed) {
112
+ return { allowed: false, reason: 'Empty command' };
113
+ }
114
+
115
+ if (containsShellChaining(trimmed)) {
116
+ return { allowed: false, reason: `Command contains shell chaining operators: ${trimmed.slice(0, 80)}` };
117
+ }
118
+
119
+ if (DANGEROUS_NODE_FLAGS.test(trimmed)) {
120
+ return { allowed: false, reason: `Dangerous node flag detected: ${trimmed.slice(0, 80)}` };
121
+ }
122
+
123
+ if (DANGEROUS_GIT_FLAGS.test(trimmed)) {
124
+ return { allowed: false, reason: `Dangerous git flag detected: ${trimmed.slice(0, 80)}` };
125
+ }
126
+
127
+ if (DANGEROUS_FIND_FLAGS.test(trimmed)) {
128
+ return { allowed: false, reason: `Dangerous find flag detected: ${trimmed.slice(0, 80)}` };
129
+ }
130
+
131
+ if (DANGEROUS_MAKE_FLAGS.test(trimmed)) {
132
+ return { allowed: false, reason: `Dangerous make variable override detected: ${trimmed.slice(0, 80)}` };
133
+ }
134
+
135
+ if (DANGEROUS_PYTHON_FLAGS.test(trimmed)) {
136
+ return { allowed: false, reason: `Dangerous python flag detected: ${trimmed.slice(0, 80)}` };
137
+ }
138
+
139
+ const destructive = checkDestructivePatterns(trimmed);
140
+ if (destructive.blocked) {
141
+ return { allowed: false, reason: `Blocked by destructive pattern: ${destructive.pattern}` };
142
+ }
143
+
144
+ if (!isAllowedExecCommand(trimmed)) {
145
+ return { allowed: false, reason: `Command not in server-side allowlist: ${trimmed.slice(0, 80)}` };
146
+ }
147
+
148
+ return { allowed: true };
149
+ }
150
+
151
+ module.exports = {
152
+ DESTRUCTIVE_PATTERNS,
153
+ ALLOWED_EXEC_PREFIXES,
154
+ DANGEROUS_NODE_FLAGS,
155
+ DANGEROUS_GIT_FLAGS,
156
+ DANGEROUS_FIND_FLAGS,
157
+ DANGEROUS_MAKE_FLAGS,
158
+ DANGEROUS_PYTHON_FLAGS,
159
+ checkDestructivePatterns,
160
+ isAllowedExecCommand,
161
+ validateExecCommand,
162
+ containsShellChaining,
163
+ };
package/lib/kanban-io.js CHANGED
@@ -28,47 +28,30 @@ const ACTIVE_TASKS_PATH = path.join(
28
28
  // Prevents lost updates when mesh-bridge and memory-daemon write concurrently.
29
29
  // See architecture note above for why this is local-only.
30
30
 
31
- function withMkdirLock(filePath, fn) {
31
+ async function withMkdirLock(filePath, fn) {
32
32
  const lockDir = filePath + '.lk';
33
- const maxWait = 5000; // 5s max wait
33
+ const maxWait = 5000;
34
+ const pollInterval = 50;
34
35
  const start = Date.now();
35
36
 
36
- // Acquire: mkdir is atomic on POSIX
37
- while (true) {
37
+ while (Date.now() - start < maxWait) {
38
38
  try {
39
39
  fs.mkdirSync(lockDir);
40
- break; // got the lock
41
- } catch (err) {
42
- if (err.code !== 'EEXIST') throw err;
43
- // Lock held by another process — check for stale lock (>30s)
44
40
  try {
45
- const lockAge = Date.now() - fs.statSync(lockDir).mtimeMs;
46
- if (lockAge > 30000) {
47
- // Stale lock previous holder crashed
48
- fs.rmdirSync(lockDir);
49
- continue;
50
- }
51
- } catch { /* stat failed, lock was just released */ continue; }
52
-
53
- if (Date.now() - start > maxWait) {
54
- throw new Error(`kanban-io: lock timeout after ${maxWait}ms on ${filePath}`);
41
+ return await fn();
42
+ } finally {
43
+ try { fs.rmdirSync(lockDir); } catch {}
55
44
  }
56
- // Sleep ~10ms — Atomics.wait is precise but throws on main thread
57
- // in some Node.js builds; fall back to busy-spin (rare contention path)
58
- try {
59
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
60
- } catch {
61
- const end = Date.now() + 10;
62
- while (Date.now() < end) { /* busy-wait fallback */ }
45
+ } catch (err) {
46
+ if (err.code === 'EEXIST') {
47
+ await new Promise(r => setTimeout(r, pollInterval));
48
+ continue;
63
49
  }
50
+ throw err;
64
51
  }
65
52
  }
66
-
67
- try {
68
- return fn();
69
- } finally {
70
- try { fs.rmdirSync(lockDir); } catch { /* already released */ }
71
- }
53
+ // Timeout — refuse to proceed without lock to prevent data corruption
54
+ throw new Error(`kanban-io: lock acquisition timeout after ${maxWait}ms — file may be corrupted`);
72
55
  }
73
56
 
74
57
  // ── Parser ──────────────────────────────────────────
@@ -367,9 +350,13 @@ function _updateTaskInPlaceUnsafe(filePath, taskId, fieldUpdates = {}, arrayAppe
367
350
  ...lines.slice(blockEnd),
368
351
  ];
369
352
 
370
- // Atomic write
353
+ // Atomic write — fsync before rename to ensure data hits disk
371
354
  const tmpPath = filePath + '.tmp.' + process.pid;
372
- fs.writeFileSync(tmpPath, newLines.join('\n'));
355
+ const output = newLines.join('\n');
356
+ const fd = fs.openSync(tmpPath, 'w');
357
+ fs.writeSync(fd, output);
358
+ fs.fsyncSync(fd);
359
+ fs.closeSync(fd);
373
360
  fs.renameSync(tmpPath, filePath);
374
361
  }
375
362
 
@@ -21,6 +21,30 @@ const path = require('path');
21
21
  const fs = require('fs');
22
22
  const os = require('os');
23
23
 
24
+ // ── Shell Command Security ─────────────────────────
25
+ const SHELL_CHAIN_PATTERNS = /[\n\r\0;`]|\$\(|\|\||&&|<\(|>\(|<<|>>|>\s|\|(?!\s*grep\b|\s*head\b|\s*tail\b|\s*wc\b|\s*sort\b)/;
26
+
27
+ const DANGEROUS_FIND_FLAGS = /\bfind\b.*\s(-exec\b|-execdir\b|-delete\b|-ok\b|-okdir\b)/;
28
+
29
+ const DANGEROUS_NODE_FLAGS = /\bnode\s+(-e\b|--eval\b|-p\b|--print\b|-r\b|--require\b|--import\b|--loader\b|--experimental-loader\b)/;
30
+
31
+ const SHELL_PROVIDER_ALLOWED_PREFIXES = [
32
+ 'npm test', 'npm run', 'node ', 'python ', 'pytest', 'cargo test',
33
+ 'go test', 'make', 'jest', 'vitest', 'mocha',
34
+ 'bash openclaw/', 'bash ~/openclaw/', 'bash ./bin/',
35
+ 'sh openclaw/', 'sh ~/openclaw/', 'sh ./bin/',
36
+ 'cat ', 'echo ', 'ls ', 'grep ', 'find ', 'git '
37
+ ];
38
+
39
+ function validateShellCommand(cmd) {
40
+ const trimmed = (cmd || '').trim();
41
+ if (!trimmed) return false;
42
+ if (SHELL_CHAIN_PATTERNS.test(trimmed)) return false;
43
+ if (DANGEROUS_NODE_FLAGS.test(trimmed)) return false;
44
+ if (DANGEROUS_FIND_FLAGS.test(trimmed)) return false;
45
+ return SHELL_PROVIDER_ALLOWED_PREFIXES.some(p => trimmed.startsWith(p));
46
+ }
47
+
24
48
  // ── Generic Provider Factory ────────────────────────
25
49
  // Most agentic coding CLIs follow a similar pattern:
26
50
  // binary [prompt-flag] "prompt" [model-flag] model [cwd-flag] dir
@@ -167,6 +191,9 @@ const PROVIDERS = {
167
191
  buildArgs(prompt, model, task) {
168
192
  // Use task.description (the raw command) if available, fall back to prompt
169
193
  const cmd = (task && task.description) ? task.description : prompt;
194
+ if (!validateShellCommand(cmd)) {
195
+ throw new Error(`Shell provider: command blocked by security filter: ${cmd.slice(0, 80)}`);
196
+ }
170
197
  return ['-c', cmd];
171
198
  },
172
199
  cleanEnv(env) {
@@ -349,14 +349,14 @@ export async function indexWorkspace(db, root, opts = {}) {
349
349
  const texts = chunks.map(c => c.text);
350
350
  const vectors = await embedBatch(texts);
351
351
 
352
- // sqlite-vec quirk: rowid must be literal, not bound param with better-sqlite3.
352
+ // Insert chunks and their vector embeddings
353
353
  const doInsert = db.transaction(() => {
354
354
  insertDoc.run(file.rel, hash, Date.now(), chunks.length);
355
355
  for (let i = 0; i < chunks.length; i++) {
356
356
  const snippet = chunks[i].text.slice(0, SNIPPET_LENGTH).replace(/\n/g, ' ');
357
357
  const info = insertChunk.run(file.rel, chunks[i].section, chunks[i].text, snippet);
358
358
  const vecBuf = Buffer.from(vectors[i].buffer);
359
- db.prepare(`INSERT INTO chunk_vectors VALUES (${info.lastInsertRowid}, ?)`).run(vecBuf);
359
+ db.prepare(`INSERT INTO chunk_vectors VALUES (?, ?)`).run(info.lastInsertRowid, vecBuf);
360
360
  }
361
361
  });
362
362
  doInsert();
@@ -373,6 +373,7 @@ export async function indexWorkspace(db, root, opts = {}) {
373
373
  // ─── Search Functions ────────────────────────────────────────────────────────
374
374
 
375
375
  export async function semanticSearch(db, query, limit = 10) {
376
+ const safeLimit = Math.max(1, Math.min(100, Math.floor(Number(limit) || 10)));
376
377
  const count = db.prepare('SELECT COUNT(*) as c FROM chunk_vectors').get().c;
377
378
  if (count === 0) return [];
378
379
 
@@ -388,7 +389,7 @@ export async function semanticSearch(db, query, limit = 10) {
388
389
  c.snippet
389
390
  FROM chunk_vectors cv
390
391
  JOIN chunks c ON c.id = cv.rowid
391
- WHERE embedding MATCH ? AND k = ${limit}
392
+ WHERE embedding MATCH ? AND k = ${safeLimit}
392
393
  ORDER BY distance
393
394
  `).all(vecBuf);
394
395
 
@@ -401,6 +402,7 @@ export async function semanticSearch(db, query, limit = 10) {
401
402
  }
402
403
 
403
404
  export async function findRelated(db, docPath, limit = 10) {
405
+ const safeLimit = Math.max(1, Math.min(100, Math.floor(Number(limit) || 10)));
404
406
  const chunkIds = db.prepare('SELECT id FROM chunks WHERE doc_path = ?').all(docPath);
405
407
 
406
408
  if (chunkIds.length === 0) {
@@ -445,7 +447,7 @@ export async function findRelated(db, docPath, limit = 10) {
445
447
  c.snippet
446
448
  FROM chunk_vectors cv
447
449
  JOIN chunks c ON c.id = cv.rowid
448
- WHERE embedding MATCH ? AND k = ${limit * 3}
450
+ WHERE embedding MATCH ? AND k = ${safeLimit * 3}
449
451
  ORDER BY distance
450
452
  `).all(vecBuf);
451
453
 
@@ -459,7 +461,7 @@ export async function findRelated(db, docPath, limit = 10) {
459
461
  }
460
462
  }
461
463
 
462
- return [...seen.values()].slice(0, limit).map(r => ({
464
+ return [...seen.values()].slice(0, safeLimit).map(r => ({
463
465
  path: r.doc_path,
464
466
  section: r.section,
465
467
  score: parseFloat((1 - r.distance * r.distance / 2).toFixed(4)),
@@ -163,7 +163,14 @@ async function startHttp(engine, port, host) {
163
163
  // Parse body
164
164
  const chunks = [];
165
165
  for await (const chunk of req) chunks.push(chunk);
166
- const body = JSON.parse(Buffer.concat(chunks).toString());
166
+ let body;
167
+ try {
168
+ body = JSON.parse(Buffer.concat(chunks).toString());
169
+ } catch (e) {
170
+ res.writeHead(400, { 'Content-Type': 'application/json' });
171
+ res.end(JSON.stringify({ error: 'Invalid JSON in request body' }));
172
+ return;
173
+ }
167
174
 
168
175
  await transport.handleRequest(req, res, body);
169
176
  return;