openclaw-node-harness 2.1.0 → 2.1.1

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 (36) hide show
  1. package/bin/lane-watchdog.js +23 -2
  2. package/bin/mesh-agent.js +38 -16
  3. package/bin/mesh-bridge.js +3 -2
  4. package/bin/mesh-health-publisher.js +41 -1
  5. package/bin/mesh-task-daemon.js +5 -0
  6. package/bin/mesh.js +8 -19
  7. package/install.sh +3 -2
  8. package/lib/agent-activity.js +2 -2
  9. package/lib/exec-safety.js +105 -0
  10. package/lib/kanban-io.js +15 -31
  11. package/lib/llm-providers.js +16 -0
  12. package/lib/mcp-knowledge/core.mjs +7 -5
  13. package/lib/mcp-knowledge/server.mjs +8 -1
  14. package/lib/mesh-collab.js +268 -250
  15. package/lib/mesh-plans.js +66 -45
  16. package/lib/mesh-tasks.js +89 -73
  17. package/lib/nats-resolve.js +4 -4
  18. package/lib/pre-compression-flush.mjs +2 -0
  19. package/lib/session-store.mjs +6 -3
  20. package/mission-control/src/app/api/memory/search/route.ts +6 -3
  21. package/mission-control/src/app/api/souls/[id]/evolution/route.ts +21 -5
  22. package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
  23. package/mission-control/src/app/api/souls/[id]/propagate/route.ts +14 -2
  24. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +7 -1
  25. package/mission-control/src/app/api/workspace/read/route.ts +11 -0
  26. package/mission-control/src/lib/config.ts +9 -0
  27. package/mission-control/src/lib/db/index.ts +16 -1
  28. package/mission-control/src/lib/memory/extract.ts +2 -1
  29. package/mission-control/src/lib/memory/retrieval.ts +3 -2
  30. package/mission-control/src/middleware.ts +82 -0
  31. package/package.json +1 -1
  32. package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
  33. package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
  34. package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
  35. package/services/launchd/ai.openclaw.mission-control.plist +1 -1
  36. package/uninstall.sh +37 -9
@@ -37,6 +37,11 @@ let lastInterventionAt = 0;
37
37
  let logWatcher = null;
38
38
  let errWatcher = null;
39
39
 
40
+ // Incident log dedup: suppress identical messages within 60s
41
+ let lastIncidentMsg = '';
42
+ let lastIncidentAt = 0;
43
+ let suppressedCount = 0;
44
+
40
45
  // Track detected events
41
46
  const events = {
42
47
  agentTimeout: null, // timestamp of last "embedded run timeout"
@@ -45,6 +50,22 @@ const events = {
45
50
 
46
51
  // --- Helpers ---
47
52
  function log(msg) {
53
+ const now = Date.now();
54
+ // Dedup: suppress identical messages within 60s
55
+ if (msg === lastIncidentMsg && (now - lastIncidentAt) < 60_000) {
56
+ suppressedCount++;
57
+ return;
58
+ }
59
+ // If we suppressed duplicates, emit a summary before the new message
60
+ if (suppressedCount > 0) {
61
+ const summaryLine = `${new Date().toISOString()} [lane-watchdog] (suppressed ${suppressedCount} duplicate message(s))`;
62
+ console.log(summaryLine);
63
+ try { fs.appendFileSync(INCIDENT_LOG, summaryLine + '\n'); } catch { /* best effort */ }
64
+ }
65
+ lastIncidentMsg = msg;
66
+ lastIncidentAt = now;
67
+ suppressedCount = 0;
68
+
48
69
  const ts = new Date().toISOString();
49
70
  const line = `${ts} [lane-watchdog] ${msg}`;
50
71
  console.log(line);
@@ -220,8 +241,8 @@ function main() {
220
241
  for (const sig of ['SIGTERM', 'SIGINT']) {
221
242
  process.on(sig, () => {
222
243
  log(`Received ${sig}, shutting down`);
223
- if (logWatcher) fs.unwatchFile(GATEWAY_LOG);
224
- if (errWatcher) fs.unwatchFile(GATEWAY_ERR_LOG);
244
+ if (logWatcher) logWatcher.close();
245
+ if (errWatcher) errWatcher.close();
225
246
  process.exit(0);
226
247
  });
227
248
  }
package/bin/mesh-agent.js CHANGED
@@ -36,7 +36,7 @@
36
36
  */
37
37
 
38
38
  const { connect, StringCodec } = require('nats');
39
- const { spawn, execSync } = require('child_process');
39
+ const { spawn, execSync, execFileSync } = require('child_process');
40
40
  const os = require('os');
41
41
  const path = require('path');
42
42
  const fs = require('fs');
@@ -46,7 +46,7 @@ const { loadHarnessRules, runMeshHarness, runPostCommitValidation, formatHarness
46
46
  const { findRole, formatRoleForPrompt } = require('../lib/role-loader');
47
47
 
48
48
  const sc = StringCodec();
49
- const { NATS_URL } = require('../lib/nats-resolve');
49
+ const { NATS_URL, natsConnectOpts } = require('../lib/nats-resolve');
50
50
  const { resolveProvider, resolveModel } = require('../lib/llm-providers');
51
51
  const NODE_ID = process.env.MESH_NODE_ID || os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
52
52
  const POLL_INTERVAL = parseInt(process.env.MESH_POLL_INTERVAL || '15000'); // 15s between polls
@@ -198,8 +198,9 @@ function buildInitialPrompt(task) {
198
198
  }
199
199
 
200
200
  if (task.metric) {
201
+ const safeMetric = isAllowedMetric(task.metric) ? task.metric : '[metric command filtered for security]';
201
202
  parts.push(`## Verification`);
202
- parts.push(`Run this command to check your work: \`${task.metric}\``);
203
+ parts.push(`Run this command to check your work: \`${safeMetric}\``);
203
204
  parts.push(`Your changes are only accepted if this command exits with code 0.`);
204
205
  parts.push('');
205
206
  }
@@ -224,7 +225,8 @@ function buildInitialPrompt(task) {
224
225
  parts.push('- Make minimal, focused changes. Do not add scope beyond what is asked.');
225
226
  parts.push('- If you hit a blocker you cannot resolve, explain what is blocking you clearly.');
226
227
  if (task.metric) {
227
- parts.push(`- After making changes, run \`${task.metric}\` to verify.`);
228
+ const safeMetric = isAllowedMetric(task.metric) ? task.metric : '[metric command filtered for security]';
229
+ parts.push(`- After making changes, run \`${safeMetric}\` to verify.`);
228
230
  parts.push('- If verification fails, analyze the failure and iterate on your approach.');
229
231
  }
230
232
 
@@ -264,8 +266,9 @@ function buildRetryPrompt(task, previousAttempts, attemptNumber) {
264
266
  }
265
267
 
266
268
  if (task.metric) {
269
+ const safeMetric = isAllowedMetric(task.metric) ? task.metric : '[metric command filtered for security]';
267
270
  parts.push(`## Verification`);
268
- parts.push(`Run: \`${task.metric}\``);
271
+ parts.push(`Run: \`${safeMetric}\``);
269
272
  parts.push(`Must exit code 0.`);
270
273
  parts.push('');
271
274
  }
@@ -289,7 +292,8 @@ function buildRetryPrompt(task, previousAttempts, attemptNumber) {
289
292
  parts.push('- Read the relevant files before making changes.');
290
293
  parts.push('- Make minimal, focused changes.');
291
294
  if (task.metric) {
292
- parts.push(`- Run \`${task.metric}\` to verify before finishing.`);
295
+ const safeMetric = isAllowedMetric(task.metric) ? task.metric : '[metric command filtered for security]';
296
+ parts.push(`- Run \`${safeMetric}\` to verify before finishing.`);
293
297
  }
294
298
 
295
299
  return parts.join('\n');
@@ -305,6 +309,9 @@ const WORKTREE_BASE = process.env.MESH_WORKTREE_BASE || path.join(process.env.HO
305
309
  * On failure, returns null (falls back to shared workspace).
306
310
  */
307
311
  function createWorktree(taskId) {
312
+ if (!/^[\w][\w.-]{0,127}$/.test(taskId)) {
313
+ throw new Error(`Invalid taskId: contains unsafe characters`);
314
+ }
308
315
  const worktreePath = path.join(WORKTREE_BASE, taskId);
309
316
  const branch = `mesh/${taskId}`;
310
317
 
@@ -315,19 +322,19 @@ function createWorktree(taskId) {
315
322
  if (fs.existsSync(worktreePath)) {
316
323
  log(`Cleaning stale worktree: ${worktreePath}`);
317
324
  try {
318
- execSync(`git worktree remove --force "${worktreePath}"`, { cwd: WORKSPACE, timeout: 10000 });
325
+ execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: WORKSPACE, timeout: 10000 });
319
326
  } catch {
320
327
  // If git worktree remove fails, manually clean up
321
328
  fs.rmSync(worktreePath, { recursive: true, force: true });
322
329
  }
323
330
  // Also clean up the branch if it exists
324
331
  try {
325
- execSync(`git branch -D "${branch}"`, { cwd: WORKSPACE, timeout: 5000, stdio: 'ignore' });
332
+ execFileSync('git', ['branch', '-D', branch], { cwd: WORKSPACE, timeout: 5000, stdio: 'ignore' });
326
333
  } catch { /* branch may not exist */ }
327
334
  }
328
335
 
329
336
  // Create new worktree branched off HEAD
330
- execSync(`git worktree add -b "${branch}" "${worktreePath}" HEAD`, {
337
+ execFileSync('git', ['worktree', 'add', '-b', branch, worktreePath, 'HEAD'], {
331
338
  cwd: WORKSPACE,
332
339
  timeout: 30000,
333
340
  stdio: 'pipe',
@@ -375,7 +382,7 @@ function commitAndMergeWorktree(worktreePath, taskId, summary) {
375
382
  log(`WARNING: commit message doesn't follow conventional format: "${commitMsg}"`);
376
383
  }
377
384
 
378
- execSync(`git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, {
385
+ execFileSync('git', ['commit', '-m', commitMsg], {
379
386
  cwd: worktreePath, timeout: 10000, stdio: 'pipe',
380
387
  });
381
388
 
@@ -391,7 +398,7 @@ function commitAndMergeWorktree(worktreePath, taskId, summary) {
391
398
  const mergeMsg = `Merge ${branch}: ${taskId}`;
392
399
  for (let attempt = 0; attempt < 2; attempt++) {
393
400
  try {
394
- execSync(`git merge --no-ff "${branch}" -m "${mergeMsg.replace(/"/g, '\\"')}"`, {
401
+ execFileSync('git', ['merge', '--no-ff', branch, '-m', mergeMsg], {
395
402
  cwd: WORKSPACE, timeout: 30000, stdio: 'pipe',
396
403
  });
397
404
  log(`Merged ${branch} into main${attempt > 0 ? ' (retry succeeded)' : ''}`);
@@ -429,13 +436,13 @@ function cleanupWorktree(worktreePath, keep = false) {
429
436
  const branch = `mesh/${taskId}`;
430
437
 
431
438
  try {
432
- execSync(`git worktree remove --force "${worktreePath}"`, {
439
+ execFileSync('git', ['worktree', 'remove', '--force', worktreePath], {
433
440
  cwd: WORKSPACE,
434
441
  timeout: 10000,
435
442
  stdio: 'pipe',
436
443
  });
437
444
  if (!keep) {
438
- execSync(`git branch -D "${branch}"`, {
445
+ execFileSync('git', ['branch', '-D', branch], {
439
446
  cwd: WORKSPACE,
440
447
  timeout: 5000,
441
448
  stdio: 'ignore',
@@ -507,9 +514,10 @@ function runLLM(prompt, task, worktreePath) {
507
514
 
508
515
  let stdout = '';
509
516
  let stderr = '';
517
+ const MAX_OUTPUT = 1024 * 1024; // 1MB cap
510
518
 
511
- child.stdout.on('data', (d) => { stdout += d.toString(); });
512
- child.stderr.on('data', (d) => { stderr += d.toString(); });
519
+ child.stdout.on('data', (d) => { if (stdout.length < MAX_OUTPUT) stdout += d.toString().slice(0, MAX_OUTPUT - stdout.length); });
520
+ child.stderr.on('data', (d) => { if (stderr.length < MAX_OUTPUT) stderr += d.toString().slice(0, MAX_OUTPUT - stderr.length); });
513
521
 
514
522
  child.on('close', (code) => {
515
523
  clearInterval(heartbeatTimer);
@@ -525,10 +533,23 @@ function runLLM(prompt, task, worktreePath) {
525
533
 
526
534
  // ── Metric Evaluation ─────────────────────────────────
527
535
 
536
+ const ALLOWED_METRIC_PREFIXES = [
537
+ 'npm test', 'npm run', 'node ', 'pytest', 'cargo test',
538
+ 'go test', 'make test', 'jest', 'vitest', 'mocha',
539
+ ];
540
+
541
+ function isAllowedMetric(cmd) {
542
+ return ALLOWED_METRIC_PREFIXES.some(prefix => cmd.startsWith(prefix));
543
+ }
544
+
528
545
  /**
529
546
  * Run the task's metric command. Returns { passed, output }.
530
547
  */
531
548
  function evaluateMetric(metric, cwd) {
549
+ if (!isAllowedMetric(metric)) {
550
+ log(`WARNING: Metric command blocked by security filter: ${metric}`);
551
+ return Promise.resolve({ passed: false, output: 'Metric command blocked by security filter' });
552
+ }
532
553
  return new Promise((resolve) => {
533
554
  const child = spawn('bash', ['-c', metric], {
534
555
  cwd: cwd || WORKSPACE,
@@ -1394,8 +1415,9 @@ async function main() {
1394
1415
  log(` Poll interval: ${POLL_INTERVAL / 1000}s`);
1395
1416
  log(` Mode: ${ONCE ? 'single task' : 'continuous'} ${DRY_RUN ? '(dry run)' : ''}`);
1396
1417
 
1418
+ const natsOpts = natsConnectOpts();
1397
1419
  nc = await connect({
1398
- servers: NATS_URL,
1420
+ ...natsOpts,
1399
1421
  timeout: 5000,
1400
1422
  reconnect: true,
1401
1423
  maxReconnectAttempts: 10,
@@ -21,7 +21,7 @@ const path = require('path');
21
21
  const { readTasks, updateTaskInPlace, isoTimestamp, ACTIVE_TASKS_PATH } = require('../lib/kanban-io');
22
22
 
23
23
  const sc = StringCodec();
24
- const { NATS_URL } = require('../lib/nats-resolve');
24
+ const { NATS_URL, natsConnectOpts } = require('../lib/nats-resolve');
25
25
  const DISPATCH_INTERVAL = parseInt(process.env.BRIDGE_DISPATCH_INTERVAL || '10000'); // 10s
26
26
  const LOG_DIR = path.join(process.env.HOME, '.openclaw', 'workspace', 'memory', 'mesh-logs');
27
27
  const WORKSPACE = path.join(process.env.HOME, '.openclaw', 'workspace');
@@ -726,8 +726,9 @@ async function main() {
726
726
  log(` Dispatch interval: ${DISPATCH_INTERVAL / 1000}s`);
727
727
  log(` Mode: ${DRY_RUN ? 'dry run' : 'live'}`);
728
728
 
729
+ const natsOpts = natsConnectOpts();
729
730
  nc = await connect({
730
- servers: NATS_URL,
731
+ ...natsOpts,
731
732
  timeout: 5000,
732
733
  reconnect: true,
733
734
  maxReconnectAttempts: 10,
@@ -36,6 +36,12 @@ const IS_MAC = os.platform() === "darwin";
36
36
 
37
37
  const { ROLE_COMPONENTS } = require('../lib/mesh-roles');
38
38
 
39
+ // ── Circuit Breaker State ───────────────────────────────────────────────
40
+ let consecutiveFailures = 0;
41
+ let skipTicksRemaining = 0;
42
+ let lastErrorMsg = '';
43
+ let lastErrorRepeatCount = 0;
44
+
39
45
  // ── Health Gathering ─────────────────────────────────────────────────────
40
46
  // All the expensive execSync calls happen here, on our own schedule.
41
47
  // No request timeout to race against.
@@ -226,11 +232,45 @@ async function main() {
226
232
 
227
233
  // Publish immediately, then every interval
228
234
  async function publish() {
235
+ // Circuit breaker: skip ticks during backoff
236
+ if (skipTicksRemaining > 0) {
237
+ skipTicksRemaining--;
238
+ return;
239
+ }
240
+
229
241
  try {
230
242
  const health = gatherHealth();
231
243
  await kv.put(NODE_ID, sc.encode(JSON.stringify(health)));
244
+ // Reset on success
245
+ if (consecutiveFailures > 0) {
246
+ console.log(`[health-publisher] recovered after ${consecutiveFailures} consecutive failures`);
247
+ }
248
+ consecutiveFailures = 0;
249
+ lastErrorMsg = '';
250
+ lastErrorRepeatCount = 0;
232
251
  } catch (err) {
233
- console.error("[health-publisher] publish failed:", err.message);
252
+ consecutiveFailures++;
253
+ const msg = err.message;
254
+
255
+ // Log dedup: after 3 identical consecutive errors, log every 10th
256
+ if (msg === lastErrorMsg) {
257
+ lastErrorRepeatCount++;
258
+ if (lastErrorRepeatCount === 3) {
259
+ console.error(`[health-publisher] suppressing repeated errors (${lastErrorRepeatCount} occurrences): ${msg}`);
260
+ } else if (lastErrorRepeatCount > 3 && lastErrorRepeatCount % 10 === 0) {
261
+ console.error(`[health-publisher] suppressing repeated errors (${lastErrorRepeatCount} occurrences): ${msg}`);
262
+ }
263
+ // Silently skip logs between dedup thresholds
264
+ } else {
265
+ lastErrorMsg = msg;
266
+ lastErrorRepeatCount = 1;
267
+ console.error("[health-publisher] publish failed:", msg);
268
+ }
269
+
270
+ // Exponential backoff: skip 2^min(N,6) ticks (max ~64 ticks / ~16 min at 15s)
271
+ const backoffTicks = Math.pow(2, Math.min(consecutiveFailures, 6));
272
+ skipTicksRemaining = backoffTicks;
273
+ console.error(`[health-publisher] backoff: skipping next ${backoffTicks} ticks (failures=${consecutiveFailures})`);
234
274
  }
235
275
  }
236
276
 
@@ -2106,6 +2106,11 @@ async function main() {
2106
2106
  clearInterval(budgetTimer);
2107
2107
  clearInterval(stallTimer);
2108
2108
  clearInterval(recruitTimer);
2109
+ if (circlingStepSweepTimer) clearInterval(circlingStepSweepTimer);
2110
+ if (circlingStepTimers) {
2111
+ for (const timer of circlingStepTimers.values()) clearTimeout(timer);
2112
+ circlingStepTimers.clear();
2113
+ }
2109
2114
  for (const sub of subs) sub.unsubscribe();
2110
2115
  await nc.drain();
2111
2116
  process.exit(0);
package/bin/mesh.js CHANGED
@@ -88,27 +88,16 @@ function remoteNode() {
88
88
 
89
89
  // ─── Exec safety ─────────────────────────────────────
90
90
 
91
- const DESTRUCTIVE_PATTERNS = [
92
- /\brm\s+(-[a-zA-Z]*)?r[a-zA-Z]*f/, // rm -rf, rm -fr, rm --recursive --force
93
- /\brm\s+(-[a-zA-Z]*)?f[a-zA-Z]*r/, // rm -fr variants
94
- /\bmkfs\b/, // format filesystem
95
- /\bdd\s+.*of=/, // raw disk write
96
- /\b>\s*\/dev\/[sh]d/, // write to raw device
97
- /\bcurl\b.*\|\s*(ba)?sh/, // curl pipe to shell
98
- /\bwget\b.*\|\s*(ba)?sh/, // wget pipe to shell
99
- /\bchmod\s+(-[a-zA-Z]*\s+)?777\s+\//, // chmod 777 on root paths
100
- /\b:(){ :\|:& };:/, // fork bomb
101
- ];
91
+ const { checkDestructivePatterns } = require('../lib/exec-safety');
102
92
 
103
93
  function checkExecSafety(command) {
104
- for (const pattern of DESTRUCTIVE_PATTERNS) {
105
- if (pattern.test(command)) {
106
- console.error(`BLOCKED: Command matches destructive pattern.`);
107
- console.error(` Command: ${command}`);
108
- console.error(` Pattern: ${pattern}`);
109
- console.error(`\nIf this is intentional, SSH into the node and run it directly.`);
110
- process.exit(1);
111
- }
94
+ const result = checkDestructivePatterns(command);
95
+ if (result.blocked) {
96
+ console.error(`BLOCKED: Command matches destructive pattern.`);
97
+ console.error(` Command: ${command}`);
98
+ console.error(` Pattern: ${result.pattern}`);
99
+ console.error(`\nIf this is intentional, SSH into the node and run it directly.`);
100
+ process.exit(1);
112
101
  }
113
102
  }
114
103
 
package/install.sh CHANGED
@@ -770,8 +770,9 @@ else
770
770
  fi
771
771
 
772
772
  if [ "$OS" = "macos" ]; then
773
- TEMPLATE="$LAUNCHD_TEMPLATES/ai.openclaw.${SVC_NAME}.plist"
774
- DEST="$LAUNCHD_DEST/ai.openclaw.${SVC_NAME}.plist"
773
+ LAUNCHD_SVC_NAME="${SVC_NAME#openclaw-}"
774
+ TEMPLATE="$LAUNCHD_TEMPLATES/ai.openclaw.${LAUNCHD_SVC_NAME}.plist"
775
+ DEST="$LAUNCHD_DEST/ai.openclaw.${LAUNCHD_SVC_NAME}.plist"
775
776
 
776
777
  if [ ! -f "$TEMPLATE" ]; then
777
778
  warn " Template not found: $TEMPLATE"
@@ -147,7 +147,7 @@ async function readLastEntry(filePath) {
147
147
  } else {
148
148
  const fh = await open(filePath, 'r');
149
149
  try {
150
- const buffer = Buffer.allocUnsafe(chunkSize);
150
+ const buffer = Buffer.alloc(chunkSize);
151
151
  await fh.read(buffer, 0, chunkSize, offset);
152
152
  content = buffer.toString('utf-8');
153
153
  } finally {
@@ -195,7 +195,7 @@ async function parseJsonlTail(filePath, maxBytes = 131072) {
195
195
  const fh = await open(filePath, 'r');
196
196
  try {
197
197
  const length = size - offset;
198
- const buffer = Buffer.allocUnsafe(length);
198
+ const buffer = Buffer.alloc(length);
199
199
  await fh.read(buffer, 0, length, offset);
200
200
  content = buffer.toString('utf-8');
201
201
  } finally {
@@ -0,0 +1,105 @@
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
+ const DESTRUCTIVE_PATTERNS = [
15
+ /\brm\s+(-[a-zA-Z]*)?r[a-zA-Z]*f/, // rm -rf, rm -fr, rm --recursive --force
16
+ /\brm\s+(-[a-zA-Z]*)?f[a-zA-Z]*r/, // rm -fr variants
17
+ /\bmkfs\b/, // format filesystem
18
+ /\bdd\s+.*of=/, // raw disk write
19
+ /\b>\s*\/dev\/[sh]d/, // write to raw device
20
+ /\bcurl\b.*\|\s*(ba)?sh/, // curl pipe to shell
21
+ /\bwget\b.*\|\s*(ba)?sh/, // wget pipe to shell
22
+ /\bchmod\s+(-[a-zA-Z]*\s+)?777\s+\//, // chmod 777 on root paths
23
+ /\b:(){ :\|:& };:/, // fork bomb
24
+ /\bsudo\b/, // sudo escalation
25
+ /\bsu\s+-?\s/, // su user switch
26
+ /\bpasswd\b/, // password change
27
+ /\buseradd\b|\buserdel\b/, // user management
28
+ /\biptables\b|\bnft\b/, // firewall modification
29
+ /\bsystemctl\s+(stop|disable|mask)/, // service disruption
30
+ /\blaunchctl\s+(unload|remove)/, // macOS service disruption
31
+ /\bkill\s+-9\s+1\b/, // kill init/launchd
32
+ />\s*\/etc\//, // overwrite system config
33
+ /\beval\b.*\$\(/, // eval with command substitution
34
+ ];
35
+
36
+ /**
37
+ * Allowed command prefixes for server-side NATS exec.
38
+ * Only commands starting with one of these are permitted.
39
+ * CLI-side uses blocklist only; server-side uses both blocklist + allowlist.
40
+ */
41
+ const ALLOWED_EXEC_PREFIXES = [
42
+ 'git ', 'npm ', 'node ', 'npx ', 'python ', 'python3 ',
43
+ 'cat ', 'ls ', 'head ', 'tail ', 'grep ', 'find ', 'wc ',
44
+ 'echo ', 'date ', 'uptime ', 'df ', 'free ', 'ps ',
45
+ 'bash openclaw/', 'bash ~/openclaw/', 'bash ./bin/',
46
+ 'cd ', 'pwd', 'which ', 'env ', 'printenv ',
47
+ 'cargo ', 'go ', 'make ', 'pytest ', 'jest ', 'vitest ',
48
+ ];
49
+
50
+ /**
51
+ * Check if a command matches any destructive pattern.
52
+ * @param {string} command
53
+ * @returns {{ blocked: boolean, pattern?: RegExp }}
54
+ */
55
+ function checkDestructivePatterns(command) {
56
+ for (const pattern of DESTRUCTIVE_PATTERNS) {
57
+ if (pattern.test(command)) {
58
+ return { blocked: true, pattern };
59
+ }
60
+ }
61
+ return { blocked: false };
62
+ }
63
+
64
+ /**
65
+ * Check if a command is allowed by the server-side allowlist.
66
+ * @param {string} command
67
+ * @returns {boolean}
68
+ */
69
+ function isAllowedExecCommand(command) {
70
+ const trimmed = (command || '').trim();
71
+ if (!trimmed) return false;
72
+ return ALLOWED_EXEC_PREFIXES.some(p => trimmed.startsWith(p));
73
+ }
74
+
75
+ /**
76
+ * Full server-side validation: blocklist + allowlist.
77
+ * Returns { allowed: true } or { allowed: false, reason: string }.
78
+ * @param {string} command
79
+ * @returns {{ allowed: boolean, reason?: string }}
80
+ */
81
+ function validateExecCommand(command) {
82
+ const trimmed = (command || '').trim();
83
+ if (!trimmed) {
84
+ return { allowed: false, reason: 'Empty command' };
85
+ }
86
+
87
+ const destructive = checkDestructivePatterns(trimmed);
88
+ if (destructive.blocked) {
89
+ return { allowed: false, reason: `Blocked by destructive pattern: ${destructive.pattern}` };
90
+ }
91
+
92
+ if (!isAllowedExecCommand(trimmed)) {
93
+ return { allowed: false, reason: `Command not in server-side allowlist: ${trimmed.slice(0, 80)}` };
94
+ }
95
+
96
+ return { allowed: true };
97
+ }
98
+
99
+ module.exports = {
100
+ DESTRUCTIVE_PATTERNS,
101
+ ALLOWED_EXEC_PREFIXES,
102
+ checkDestructivePatterns,
103
+ isAllowedExecCommand,
104
+ validateExecCommand,
105
+ };
package/lib/kanban-io.js CHANGED
@@ -28,47 +28,31 @@ 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 — force acquire (stale lock)
54
+ try { fs.rmdirSync(lockDir); } catch {}
55
+ return fn();
72
56
  }
73
57
 
74
58
  // ── Parser ──────────────────────────────────────────
@@ -21,6 +21,19 @@ 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_PROVIDER_ALLOWED_PREFIXES = [
26
+ 'npm test', 'npm run', 'node ', 'python ', 'pytest', 'cargo test',
27
+ 'go test', 'make', 'jest', 'vitest', 'mocha', 'bash ', 'sh ',
28
+ 'cat ', 'echo ', 'ls ', 'grep ', 'find ', 'git '
29
+ ];
30
+
31
+ function validateShellCommand(cmd) {
32
+ const trimmed = (cmd || '').trim();
33
+ if (!trimmed) return false;
34
+ return SHELL_PROVIDER_ALLOWED_PREFIXES.some(p => trimmed.startsWith(p));
35
+ }
36
+
24
37
  // ── Generic Provider Factory ────────────────────────
25
38
  // Most agentic coding CLIs follow a similar pattern:
26
39
  // binary [prompt-flag] "prompt" [model-flag] model [cwd-flag] dir
@@ -167,6 +180,9 @@ const PROVIDERS = {
167
180
  buildArgs(prompt, model, task) {
168
181
  // Use task.description (the raw command) if available, fall back to prompt
169
182
  const cmd = (task && task.description) ? task.description : prompt;
183
+ if (!validateShellCommand(cmd)) {
184
+ throw new Error(`Shell provider: command blocked by security filter: ${cmd.slice(0, 80)}`);
185
+ }
170
186
  return ['-c', cmd];
171
187
  },
172
188
  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;