wolverine-ai 4.0.5 → 4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wolverine-ai",
3
- "version": "4.0.5",
3
+ "version": "4.2.0",
4
4
  "description": "Self-healing Node.js server framework powered by AI. Catches crashes, diagnoses errors, generates fixes, verifies, and restarts — automatically.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -34,7 +34,15 @@ Module._load = function (request, parent, isMain) {
34
34
  _hookFastify(instance);
35
35
  return instance;
36
36
  };
37
- Object.keys(originalFastify).forEach((key) => { wrapped[key] = originalFastify[key]; });
37
+ // #23: Copy all own properties (including non-enumerable and symbols) to preserve prototype chain
38
+ for (const key of Object.getOwnPropertyNames(originalFastify)) {
39
+ if (key !== "length" && key !== "name" && key !== "prototype") {
40
+ try { wrapped[key] = originalFastify[key]; } catch {}
41
+ }
42
+ }
43
+ for (const sym of Object.getOwnPropertySymbols(originalFastify)) {
44
+ try { wrapped[sym] = originalFastify[sym]; } catch {}
45
+ }
38
46
  wrapped.default = wrapped; // ESM compat
39
47
  return wrapped;
40
48
  }
@@ -48,7 +56,15 @@ Module._load = function (request, parent, isMain) {
48
56
  _hookExpress(app);
49
57
  return app;
50
58
  };
51
- Object.keys(originalExpress).forEach((key) => { wrapped[key] = originalExpress[key]; });
59
+ // #23: Copy all own properties (including non-enumerable and symbols)
60
+ for (const key of Object.getOwnPropertyNames(originalExpress)) {
61
+ if (key !== "length" && key !== "name" && key !== "prototype") {
62
+ try { wrapped[key] = originalExpress[key]; } catch {}
63
+ }
64
+ }
65
+ for (const sym of Object.getOwnPropertySymbols(originalExpress)) {
66
+ try { wrapped[sym] = originalExpress[sym]; } catch {}
67
+ }
52
68
  return wrapped;
53
69
  }
54
70
 
@@ -94,9 +110,13 @@ function _hookExpress(app) {
94
110
  // Wrap app.listen to inject error middleware AFTER all user middleware
95
111
  const originalListen = app.listen;
96
112
  app.listen = function (...args) {
97
- app.use(function _wolverineErrorHook(err, req, res, next) {
98
- _reportError(req.originalUrl || req.url, req.method, err);
99
- next(err);
113
+ // #24: Use process.nextTick to ensure our error middleware is added AFTER
114
+ // any middleware registered synchronously after listen() is called
115
+ process.nextTick(() => {
116
+ app.use(function _wolverineErrorHook(err, req, res, next) {
117
+ _reportError(req.originalUrl || req.url, req.method, err);
118
+ next(err);
119
+ });
100
120
  });
101
121
  return originalListen.apply(this, args);
102
122
  };
@@ -114,7 +134,8 @@ function _reportError(url, method, error) {
114
134
  let file = null, line = null;
115
135
  if (error.stack) {
116
136
  for (const frame of error.stack.split("\n")) {
117
- const m = frame.match(/\(([^)]+):(\d+):(\d+)\)/) || frame.match(/at\s+([^\s(]+):(\d+):(\d+)/);
137
+ // #25: Second regex uses (.+) instead of ([^\s(]+) to handle Windows paths with spaces
138
+ const m = frame.match(/\(([^)]+):(\d+):(\d+)\)/) || frame.match(/at\s+(.+):(\d+):(\d+)/);
118
139
  if (m && !m[1].includes("node_modules") && !m[1].includes("node:")) {
119
140
  file = m[1]; line = parseInt(m[2], 10); break;
120
141
  }
@@ -284,7 +284,9 @@ class WolverineRunner {
284
284
  this._clearStabilityTimer();
285
285
  // Clear any pending heals — restart is a clean slate
286
286
  this._pendingErrorHeal = null;
287
- this._healInProgress = false;
287
+ // #1: Don't clear _healInProgress here — only the heal function itself should clear it
288
+ // #6: Clear stale heal status so dashboard doesn't show phantom heals
289
+ this._healStatus = null;
288
290
 
289
291
  if (this.child) {
290
292
  const oldChild = this.child;
@@ -295,11 +297,9 @@ class WolverineRunner {
295
297
  const onExit = () => {
296
298
  if (spawned) return; // Prevent double-spawn from exit + force-kill timeout
297
299
  spawned = true;
300
+ // #7: Don't call _ensurePortFree() here — _spawn() already calls it
298
301
  // Give port time to fully release (TIME_WAIT)
299
- setTimeout(() => {
300
- this._ensurePortFree();
301
- setTimeout(() => this._spawn(), 500);
302
- }, 500);
302
+ setTimeout(() => this._spawn(), 500);
303
303
  };
304
304
 
305
305
  oldChild.removeAllListeners("exit");
@@ -314,7 +314,7 @@ class WolverineRunner {
314
314
  }
315
315
  }, 3000);
316
316
  } else {
317
- this._ensurePortFree();
317
+ // #7: Don't call _ensurePortFree() here — _spawn() already calls it
318
318
  setTimeout(() => this._spawn(), 500);
319
319
  }
320
320
  }
@@ -394,7 +394,12 @@ class WolverineRunner {
394
394
  process.stderr.write(text);
395
395
  });
396
396
 
397
- this._startStabilityTimer();
397
+ // #27: Only start stability timer if there's a backup to promote — don't clear
398
+ // an existing timer on every spawn (e.g., auto-update restart shouldn't reset
399
+ // the stability countdown for a previously healed backup)
400
+ if (this._lastBackupId) {
401
+ this._startStabilityTimer();
402
+ }
398
403
 
399
404
  // Start process monitor (memory, CPU, heartbeat)
400
405
  if (this.child && this.child.pid) {
@@ -418,32 +423,46 @@ class WolverineRunner {
418
423
  this.healthMonitor.stop();
419
424
  this.healthMonitor.reset();
420
425
  this.healthMonitor.start(async (reason) => {
421
- if (this._healInProgress || !this.running) return;
422
- console.log(chalk.red(`\n🚨 Health check triggered heal (reason: ${reason})`));
423
- this.logger.error(EVENT_TYPES.HEALTH_UNRESPONSIVE, `Server unresponsive: ${reason}`, { reason });
424
- this.healthMonitor.stop();
425
-
426
- // Kill the hung process remove exit listener to prevent double-heal
427
- if (this.child) {
428
- const pid = this.child.pid;
429
- this.child.removeAllListeners("exit");
430
- this._killProcessTree(pid, "SIGKILL");
431
- this.child = null;
432
- }
426
+ try {
427
+ if (this._healInProgress || !this.running) return;
428
+ // #26: Claim the heal lock immediately — prevents exit event from starting
429
+ // a concurrent heal between our check and the child kill below
430
+ this._healInProgress = true;
431
+ console.log(chalk.red(`\n🚨 Health check triggered heal (reason: ${reason})`));
432
+ this.logger.error(EVENT_TYPES.HEALTH_UNRESPONSIVE, `Server unresponsive: ${reason}`, { reason });
433
+ this.healthMonitor.stop();
434
+
435
+ // Kill the hung process — remove exit listener to prevent double-heal
436
+ if (this.child) {
437
+ const pid = this.child.pid;
438
+ this.child.removeAllListeners("exit");
439
+ this._killProcessTree(pid, "SIGKILL");
440
+ this.child = null;
441
+ }
433
442
 
434
- // Synthesize error context for the heal pipeline
435
- this._stderrBuffer = `Server became unresponsive. Health check failed: ${reason}\n` +
436
- `The server was running but stopped responding to HTTP requests.\n` +
437
- `Possible causes: infinite loop, deadlock, memory exhaustion, blocked event loop.`;
443
+ // Synthesize error context for the heal pipeline
444
+ this._stderrBuffer = `Server became unresponsive. Health check failed: ${reason}\n` +
445
+ `The server was running but stopped responding to HTTP requests.\n` +
446
+ `Possible causes: infinite loop, deadlock, memory exhaustion, blocked event loop.`;
438
447
 
439
- this.retryCount++;
440
- if (this.retryCount > this.maxRetries) {
441
- console.log(chalk.red(`\n🛑 Max retries reached.`));
442
- this._logRollbackHint();
443
- this.running = false;
444
- return;
448
+ this.retryCount++;
449
+ if (this.retryCount > this.maxRetries) {
450
+ console.log(chalk.red(`\n🛑 Max retries reached.`));
451
+ this._logRollbackHint();
452
+ this.running = false;
453
+ this._healInProgress = false;
454
+ return;
455
+ }
456
+ // Release lock so _healAndRestart can acquire it
457
+ this._healInProgress = false;
458
+ await this._healAndRestart();
459
+ } catch (err) {
460
+ // #5: Prevent unhandled errors in health callback from crashing the parent
461
+ console.log(chalk.red(` ⚠️ Health callback error: ${err.message}`));
462
+ this._healInProgress = false;
463
+ this._healStatus = null;
464
+ if (this.running) this._spawn();
445
465
  }
446
- await this._healAndRestart();
447
466
  });
448
467
 
449
468
  this.child.on("exit", async (code, signal) => {
@@ -459,6 +478,13 @@ class WolverineRunner {
459
478
  return;
460
479
  }
461
480
 
481
+ // #28: SIGKILL = likely OOM — synthesize useful stderr for the heal pipeline
482
+ if (signal === "SIGKILL" && (!this._stderrBuffer.trim() || this._stderrBuffer.trim().length < 10)) {
483
+ this._stderrBuffer = `Process killed by SIGKILL (possible OOM). Memory limit may have been exceeded. Check memory usage patterns and reduce memory consumption.\nExit code: ${code}, Signal: ${signal}`;
484
+ console.log(chalk.red(`\n💀 Process killed by SIGKILL (possible OOM)`));
485
+ this.logger.error(EVENT_TYPES.PROCESS_CRASH, "SIGKILL — possible OOM", { exitCode: code, signal });
486
+ }
487
+
462
488
  // Killed by signal with no stderr — just restart, don't waste tokens healing
463
489
  if (!this._stderrBuffer.trim() || this._stderrBuffer.trim().length < 10) {
464
490
  console.log(chalk.yellow(`\n⚠️ Process killed (code: ${code}, signal: ${signal}) — no error to heal, restarting`));
@@ -483,13 +509,28 @@ class WolverineRunner {
483
509
  }
484
510
 
485
511
  this.retryCount++;
486
- await this._healAndRestart();
512
+ // #3: Guard against unhandled rejections — don't let heal errors crash the parent
513
+ try {
514
+ await this._healAndRestart();
515
+ } catch (healErr) {
516
+ console.log(chalk.red(` ⚠️ Heal error (recovering): ${healErr.message}`));
517
+ this._healInProgress = false;
518
+ this._healStatus = null;
519
+ if (this.running) this._spawn(); // restart without healing
520
+ }
487
521
  });
488
522
 
489
523
  this.child.on("error", (err) => {
490
524
  console.log(chalk.red(`Failed to start process: ${err.message}`));
491
525
  this.logger.error(EVENT_TYPES.PROCESS_CRASH, `Failed to start: ${err.message}`);
492
- this.running = false;
526
+ // #10: Retry spawn after delay instead of permanently dying
527
+ if (this.running && this.retryCount < this.maxRetries) {
528
+ this.retryCount++;
529
+ console.log(chalk.yellow(` Retrying spawn in 5s (attempt ${this.retryCount}/${this.maxRetries})...`));
530
+ setTimeout(() => { if (this.running) this._spawn(); }, 5000);
531
+ } else {
532
+ this.running = false;
533
+ }
493
534
  });
494
535
 
495
536
  // IPC channel: child reports caught 500 errors (Fastify/Express)
@@ -519,6 +560,8 @@ class WolverineRunner {
519
560
 
520
561
  async _healAndRestart() {
521
562
  if (this._healInProgress) return;
563
+ // #9: Bail if stop() was called during the window between crash and heal
564
+ if (this._shuttingDown) return;
522
565
  this._healInProgress = true;
523
566
  this._healStatus = { active: true, error: this._stderrBuffer.slice(0, 200), phase: "diagnosing", startedAt: Date.now() };
524
567
 
@@ -545,6 +588,8 @@ class WolverineRunner {
545
588
  }
546
589
 
547
590
  try {
591
+ // #9: Check again before expensive heal — stop() may have been called during loop guard
592
+ if (this._shuttingDown) { this._healInProgress = false; return; }
548
593
  const result = await heal({
549
594
  stderr: this._stderrBuffer,
550
595
  cwd: this.cwd,
@@ -584,6 +629,8 @@ class WolverineRunner {
584
629
  this._healStatus = null;
585
630
  // Clear pending errors — the heal fixed the root cause, stale errors are irrelevant
586
631
  this._pendingErrorHeal = null;
632
+ // #9: Don't restart if stop() was called while heal was running
633
+ if (this._shuttingDown) return;
587
634
  // Use restart() to properly kill old child before spawning — prevents EADDRINUSE
588
635
  this.restart();
589
636
  } else {
@@ -626,9 +673,14 @@ class WolverineRunner {
626
673
  }
627
674
  }
628
675
  } catch (err) {
629
- console.log(chalk.red(`\n🐺 Wolverine encountered an error: ${err.message}`));
676
+ // #4: Don't permanently die on transient errors — restart without healing
677
+ console.log(chalk.red(`\n🐺 Wolverine heal error (recovering): ${err.message}`));
630
678
  this._healInProgress = false;
631
- this.running = false;
679
+ this._healStatus = null;
680
+ if (this.running) {
681
+ console.log(chalk.yellow(" Restarting without healing..."));
682
+ this._spawn();
683
+ }
632
684
  }
633
685
  }
634
686
 
@@ -646,6 +698,15 @@ class WolverineRunner {
646
698
  }
647
699
  this._healInProgress = true;
648
700
 
701
+ // #8: Safety timeout — if heal hangs, force-release the lock after 6 minutes
702
+ const healTimeout = setTimeout(() => {
703
+ if (this._healInProgress) {
704
+ console.log(chalk.red(` ⚠️ _healFromError safety timeout (6min) — releasing heal lock`));
705
+ this._healInProgress = false;
706
+ this._healStatus = null;
707
+ }
708
+ }, 360000);
709
+
649
710
  console.log(chalk.yellow(`\n🐺 Wolverine healing caught error on ${routePath}...`));
650
711
  this._healStatus = { active: true, route: routePath, error: errorDetails?.message?.slice(0, 200), phase: "diagnosing", startedAt: Date.now() };
651
712
  this.logger.info("heal.error_monitor", `Healing caught 500 on ${routePath}`, { route: routePath });
@@ -700,6 +761,7 @@ class WolverineRunner {
700
761
  routeContext: { path: routePath, method: errorDetails?.method },
701
762
  });
702
763
 
764
+ clearTimeout(healTimeout);
703
765
  if (result.healed) {
704
766
  console.log(chalk.green(`\n🐺 Wolverine healed ${routePath} via ${result.mode}! Restarting...\n`));
705
767
  this.retryCount = 0; // Fresh start after successful heal
@@ -721,6 +783,7 @@ class WolverineRunner {
721
783
  this._healStatus = null;
722
784
  }
723
785
  } catch (err) {
786
+ clearTimeout(healTimeout);
724
787
  console.log(chalk.red(`\n🐺 Error during heal: ${err.message}`));
725
788
  this._healInProgress = false;
726
789
  this._healStatus = null;
@@ -23,6 +23,8 @@ const BOOT_PROBE_TIMEOUT_MS = 10000; // 10 seconds
23
23
  */
24
24
  function syntaxCheck(scriptPath) {
25
25
  return new Promise((resolve) => {
26
+ // #21: Guard against double resolve from exit + error firing in sequence
27
+ let settled = false;
26
28
  const child = spawn("node", ["--check", scriptPath], {
27
29
  stdio: ["ignore", "ignore", "pipe"],
28
30
  timeout: 5000,
@@ -32,6 +34,8 @@ function syntaxCheck(scriptPath) {
32
34
  child.stderr.on("data", (data) => { stderr += data.toString(); });
33
35
 
34
36
  child.on("exit", (code) => {
37
+ if (settled) return;
38
+ settled = true;
35
39
  resolve({
36
40
  valid: code === 0,
37
41
  error: code !== 0 ? stderr.trim() : undefined,
@@ -39,6 +43,8 @@ function syntaxCheck(scriptPath) {
39
43
  });
40
44
 
41
45
  child.on("error", (err) => {
46
+ if (settled) return;
47
+ settled = true;
42
48
  resolve({ valid: false, error: err.message });
43
49
  });
44
50
  });
@@ -71,6 +77,8 @@ function bootProbe(scriptPath, cwd, originalErrorSignature) {
71
77
  child.on("exit", (code) => {
72
78
  if (settled) return;
73
79
  settled = true;
80
+ // #19: Always ensure the child is killed after settling (handles orphan sub-processes)
81
+ try { child.kill("SIGTERM"); } catch {}
74
82
 
75
83
  if (code === 0) {
76
84
  resolve({ status: "alive" });
@@ -101,6 +109,8 @@ function bootProbe(scriptPath, cwd, originalErrorSignature) {
101
109
  child.on("error", (err) => {
102
110
  if (settled) return;
103
111
  settled = true;
112
+ // #19: Always ensure the child is killed after settling
113
+ try { child.kill("SIGTERM"); } catch {}
104
114
  resolve({ status: "crashed", stderr: err.message, sameError: false, exitCode: null });
105
115
  });
106
116
 
@@ -192,9 +202,10 @@ async function verifyFix(scriptPath, cwd, originalErrorSignature, routeContext)
192
202
  const relPath = path.relative(cwd, changedFile).replace(/\\/g, "/");
193
203
  if (relPath.startsWith("server/") && relPath.endsWith(".js")) {
194
204
  try {
195
- const { execSync } = require("child_process");
205
+ const { execFileSync } = require("child_process");
206
+ // #22: Use execFileSync with args array to prevent path injection via relPath
196
207
  const testCode = `try{require('./${relPath}');console.log('MODULE_OK')}catch(e){console.error(e.message);process.exit(1)}`;
197
- const out = execSync(`node -e "${testCode}"`, {
208
+ const out = execFileSync("node", ["-e", testCode], {
198
209
  cwd, timeout: 5000, encoding: "utf-8",
199
210
  env: { ...process.env, NODE_PATH: path.join(cwd, "node_modules") },
200
211
  });
@@ -237,6 +248,14 @@ function routeProbe(scriptPath, cwd, routeContext) {
237
248
  child.stdout.on("data", (d) => { stdout += d.toString(); });
238
249
  child.stderr.on("data", (d) => { stderr += d.toString(); });
239
250
 
251
+ // #20: Handle spawn errors (e.g., node binary not found)
252
+ child.on("error", (err) => {
253
+ clearInterval(checkPort);
254
+ if (settled) return;
255
+ settled = true;
256
+ resolve({ status: "failed", statusCode: 0, body: err.message });
257
+ });
258
+
240
259
  child.on("exit", () => {
241
260
  if (settled) return;
242
261
  settled = true;
@@ -35,6 +35,17 @@ async function heal(opts) {
35
35
  if (err.message === "timeout") {
36
36
  console.log(chalk.red(`\n🐺 Heal timed out after ${HEAL_TIMEOUT_MS / 1000}s`));
37
37
  if (opts.logger) opts.logger.error(EVENT_TYPES.HEAL_FAILED, `Heal timed out after ${HEAL_TIMEOUT_MS / 1000}s`);
38
+ // #11: Rollback on timeout — the background _healImpl may have partially applied patches
39
+ if (opts.backupManager) {
40
+ try {
41
+ const all = opts.backupManager.getAll();
42
+ const latest = all.find(b => b.status === "unstable");
43
+ if (latest) {
44
+ opts.backupManager.rollbackTo(latest.id);
45
+ console.log(chalk.yellow(` ↩️ Rolled back to ${latest.id} (timeout cleanup)`));
46
+ }
47
+ } catch {}
48
+ }
38
49
  return { healed: false, explanation: `Heal timed out after ${HEAL_TIMEOUT_MS / 1000}s` };
39
50
  }
40
51
  throw err;
@@ -154,7 +165,7 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
154
165
  // 4c. Pre-heal operational fix — detect common non-code errors
155
166
  // Some crashes aren't code bugs (missing npm packages, missing config files).
156
167
  // Fix these directly without wasting AI tokens.
157
- const opsFix = await tryOperationalFix(parsed, cwd, logger);
168
+ const opsFix = await tryOperationalFix(parsed, cwd, logger, sandbox);
158
169
  if (opsFix.fixed) {
159
170
  console.log(chalk.green(` ⚡ Operational fix applied: ${opsFix.action}`));
160
171
  if (logger) logger.info(EVENT_TYPES.HEAL_SUCCESS, `Operational fix: ${opsFix.action}`, { action: opsFix.action });
@@ -286,8 +297,15 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
286
297
  goal: `Fix: ${parsed.errorMessage.slice(0, 80)}`,
287
298
 
288
299
  onAttempt: async (iteration, researchCtx, priorAttempts) => {
289
- // Create backup for this attempt
290
- const bid = backupManager.createBackup(`heal attempt ${iteration}: ${parsed.errorMessage.slice(0, 60)}`);
300
+ // #12: Create backup for this attempt — if backup fails, skip the attempt entirely
301
+ let bid;
302
+ try {
303
+ bid = backupManager.createBackup(`heal attempt ${iteration}: ${parsed.errorMessage.slice(0, 60)}`);
304
+ } catch (backupErr) {
305
+ console.log(chalk.red(` ⚠️ Backup creation failed: ${backupErr.message} — skipping attempt`));
306
+ if (logger) logger.error(EVENT_TYPES.BACKUP_CREATED, `Backup failed: ${backupErr.message}`);
307
+ return { healed: false, explanation: `Backup creation failed: ${backupErr.message}` };
308
+ }
291
309
  backupManager.setErrorSignature(bid, errorSignature);
292
310
  if (logger) logger.info(EVENT_TYPES.BACKUP_CREATED, `Backup ${bid} (iteration ${iteration})`, { backupId: bid });
293
311
 
@@ -312,7 +330,7 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
312
330
  errorMessage: parsed.errorMessage, stackTrace: parsed.stackTrace,
313
331
  extraContext: envContext,
314
332
  });
315
- rateLimiter.record(errorSignature);
333
+ // #15: Don't record rate limit until AFTER verification — failed attempts shouldn't exhaust the limit
316
334
 
317
335
  // Execute shell commands first (npm install, mkdir, etc.)
318
336
  if (repair.commands && Array.isArray(repair.commands)) {
@@ -351,6 +369,7 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
351
369
  const verification = await verifyFix(parsed.filePath, cwd, errorSignature, routeContext);
352
370
  if (verification.verified) {
353
371
  backupManager.markVerified(bid);
372
+ rateLimiter.record(errorSignature);
354
373
  rateLimiter.clearSignature(errorSignature);
355
374
  // Track tool operations: file read + patch + verify + any commands
356
375
  // These are the same operations an agent would do with read_file/write_file/bash_exec
@@ -360,10 +379,11 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
360
379
  return { healed: true, explanation: repair.explanation, backupId: bid, mode: "fast" };
361
380
  }
362
381
 
363
- backupManager.rollbackTo(bid);
382
+ // #13: Safe rollback — wrap in try/catch to prevent rollback-of-rollback loop
383
+ try { backupManager.rollbackTo(bid); } catch (rbErr) { console.log(chalk.red(` ⚠️ Rollback failed: ${rbErr.message}`)); }
364
384
  return { healed: false, explanation: `Fast path: ${verification.status}` };
365
385
  } catch (err) {
366
- backupManager.rollbackTo(bid);
386
+ try { backupManager.rollbackTo(bid); } catch (rbErr) { console.log(chalk.red(` ⚠️ Rollback failed: ${rbErr.message}`)); }
367
387
  return { healed: false, explanation: `Fast path error: ${err.message}` };
368
388
  }
369
389
  } else if (iteration <= 2) {
@@ -519,7 +539,7 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
519
539
  * Try to fix common operational errors without AI.
520
540
  * Returns { fixed: boolean, action: string }
521
541
  */
522
- async function tryOperationalFix(parsed, cwd, logger) {
542
+ async function tryOperationalFix(parsed, cwd, logger, sandbox) {
523
543
  const { execSync } = require("child_process");
524
544
  const msg = parsed.errorMessage || "";
525
545
 
@@ -552,9 +572,14 @@ async function tryOperationalFix(parsed, cwd, logger) {
552
572
 
553
573
  // Only auto-create if it's inside the project and looks like a config/data file
554
574
  const rel = path.relative(cwd, missingFile).replace(/\\/g, "/");
555
- if (!rel.startsWith("..") && /\.(json|yaml|yml|toml|ini|conf|cfg|env|log|txt|csv|db|sqlite)$/i.test(missingFile)) {
575
+ // #18: Validate through sandbox to prevent creating files outside allowed paths
576
+ let safePath = missingFile;
577
+ if (sandbox) {
578
+ try { safePath = sandbox.resolve(missingFile); } catch { safePath = null; }
579
+ }
580
+ if (safePath && !rel.startsWith("..") && /\.(json|yaml|yml|toml|ini|conf|cfg|env|log|txt|csv|db|sqlite)$/i.test(missingFile)) {
556
581
  try {
557
- fs.mkdirSync(path.dirname(missingFile), { recursive: true });
582
+ fs.mkdirSync(path.dirname(safePath), { recursive: true });
558
583
  const ext = path.extname(missingFile).toLowerCase();
559
584
 
560
585
  // For JSON config files, try to infer expected structure from the code or error message
@@ -587,7 +612,7 @@ async function tryOperationalFix(parsed, cwd, logger) {
587
612
  content = defaults[ext] || "";
588
613
  }
589
614
 
590
- fs.writeFileSync(missingFile, content, "utf-8");
615
+ fs.writeFileSync(safePath, content, "utf-8");
591
616
  console.log(chalk.blue(` 📄 Created missing file: ${rel}`));
592
617
  return { fixed: true, action: `Created missing file: ${rel} with ${content === "{}" ? "empty" : "inferred"} config` };
593
618
  } catch {}
@@ -646,10 +671,13 @@ function _inferJsonConfig(missingFile, cwd, parsed) {
646
671
  const sourceFile = parsed.filePath;
647
672
  if (!sourceFile) return null;
648
673
 
674
+ // #17: Escape all regex special characters in basename to prevent regex injection
675
+ const escapedBasename = basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
676
+
649
677
  try {
650
678
  const source = fs.readFileSync(sourceFile, "utf-8");
651
679
  // Look for property accesses on the loaded config: config.apiUrl, config.timeout, etc.
652
- const configVarMatch = source.match(new RegExp(`(?:const|let|var)\\s+(\\w+)\\s*=\\s*(?:require|JSON\\.parse).*${basename.replace(".", "\\.")}`));
680
+ const configVarMatch = source.match(new RegExp(`(?:const|let|var)\\s+(\\w+)\\s*=\\s*(?:require|JSON\\.parse).*${escapedBasename}`));
653
681
  if (!configVarMatch) return null;
654
682
 
655
683
  const varName = configVarMatch[1];
@@ -6,14 +6,14 @@
6
6
  },
7
7
 
8
8
  "models": {
9
- "reasoning": "gpt-4o",
10
- "coding": "gpt-4o",
11
- "chat": "gpt-4o-mini",
9
+ "reasoning": "claude-sonnet-4-6",
10
+ "coding": "claude-sonnet-4-6",
11
+ "chat": "gpt-5.4-mini",
12
12
  "tool": "gpt-4o-mini",
13
13
  "classifier": "gpt-4o-mini",
14
- "audit": "gpt-4o-mini",
15
- "compacting": "gpt-4o-mini",
16
- "research": "gpt-4o"
14
+ "audit": "wolverine-test-1",
15
+ "compacting": "wolverine-test-1",
16
+ "research": "claude-sonnet-4-6"
17
17
  },
18
18
 
19
19
  "embedding": "wolverine-embedding-1",