PoC Archive PoC Archive
Medium CVE-2026-6130 unpatched

local-mcp exec Tool Sandbox/Restriction Bypass (CVE-2026-6130)

by fan-67 (documented bypass, mirrored by original PoC contributor) · 2026-07-05

Severity
Medium
CVE
CVE-2026-6130
Category
misc
Affected product
local-mcp (zero-dependency MCP server for local file operations, v1.1.1)
Affected versions
1.1.1
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06
Author / Researcherfan-67 (documented bypass, mirrored by original PoC contributor)
CVE / AdvisoryCVE-2026-6130
Categorymisc
SeverityMedium
CVSS ScoreNot specified in source
StatusPoC
Tagsmcp, model-context-protocol, sandbox-bypass, command-execution, exec-restriction-bypass
RelatedN/A

Affected Target

FieldValue
Software / Systemlocal-mcp (zero-dependency MCP server for local file operations, v1.1.1)
Versions Affected1.1.1
Language / PlatformNode.js (MCP server) — .mjs source
Authentication RequiredLocal MCP client access (e.g. an AI agent configured to use the server)
Network Access RequiredNo (stdio/local HTTP transport)

Summary

This is not a standalone PoC script but the actual local-mcp MCP server codebase, whose exec tool contains the real CVE-2026-6130 bypass: a workaround using a generated batch file plus base64 encoding that circumvents intended command-execution restrictions within the tool’s sandboxing logic, allowing execution of commands that the restriction mechanism was meant to block.


Vulnerability Details

Root Cause

The exec tool’s command-restriction/sandboxing logic can be circumvented by wrapping the target command in a generated .bat file with base64-encoded content, which the restriction check does not account for.

Attack Vector

  1. Have MCP client access to a local-mcp server instance (e.g. via an AI agent/tool integration).
  2. Invoke the exec tool with a command constructed via the batch-file + base64 workaround technique.
  3. The restriction/sandboxing logic fails to recognize the wrapped command as restricted, allowing it to execute.

Impact

Bypass of intended command-execution restrictions in the local-mcp exec tool, allowing execution of commands the tool was designed to block.


Environment / Lab Setup

Target:   local-mcp v1.1.1 exec tool
Attacker: An MCP client capable of invoking the exec tool (e.g. an AI coding agent)

Proof of Concept

PoC Script

See local-mcp.mjs, lib/ in this folder.

1
node local-mcp.mjs (then invoke the exec tool via MCP with the bypass technique documented in the upstream project)

The full local-mcp server source is mirrored as-is; the exec tool implementation contains the functioning bypass technique referenced by CVE-2026-6130 rather than a separate exploit script.


Detection & Indicators of Compromise

Signs of compromise:

  • exec tool calls containing generated .bat file content with base64-decoded command strings

Remediation

ActionDetail
Primary fixUpdate local-mcp to a version that closes the batch-file/base64 restriction bypass in the exec tool
Interim mitigationRestrict which commands/tools an MCP client is permitted to invoke via local-mcp at a higher trust boundary

References


Notes

Mirrored from https://github.com/fan-67/local-mcp on 2026-07-05. Note: unlike most entries in this archive, the source repository is the actual vulnerable project itself (not a separate PoC repo) — the bypass technique is functionally present in its exec implementation, as documented in the upstream project’s own materials.

local-mcp.mjs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
import { serve, serveHttp, createProtocolHandler } from './lib/mcp-core.mjs';
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, appendFileSync, rmSync, renameSync, watch as fsWatch, globSync, openSync, readSync, closeSync, createReadStream } from 'fs';
import { execSync, spawnSync } from 'child_process';
import { resolve, join } from 'path';
import { availableParallelism } from 'os';
import { createInterface } from 'readline';
import { WORKSPACE, DATA, BOOKMARK_FILE, MCP_DIR } from './lib/config.mjs';

// === Exported for testing ===
export { createProtocolHandler as _createProtocolHandler };

// === Read-only mode ===
const READONLY = process.env.MCP_READONLY === 'true' || process.env.MCP_READONLY === '1';
function checkReadonly() {
  if (READONLY) throw err('READONLY_MODE', 'Write operations are disabled (MCP_READONLY=true)');
}

const ALLOW = [resolve(WORKSPACE + '/')];
function ok(p) { const f = resolve(p); if (!ALLOW.some(a => f.startsWith(a))) throw err("PERMISSION_DENIED", `Path must be under ${WORKSPACE}/`); return f; }
function err(t, msg) { const e = new Error(msg); e.code = e.type = t; return e; }
export function nl(t) { return (t || '').replace(/\r\n/g, '\n'); }

// === Binary detection: check first 8KB for null byte ===
function isBinary(fp) {
  try {
    const fd = openSync(fp, 'r');
    const buf = Buffer.alloc(8192);
    const n = readSync(fd, buf, 0, 8192, 0);
    closeSync(fd);
    return n > 0 && buf.subarray(0, n).includes(0);
  } catch { return false; }
}

// === Excluded dirs + .gitignore ===
const EXCLUDE_DIRS = ['node_modules', '.git', 'dist', '__pycache__', 'coverage', '.next'];
function isExcluded(p) { return EXCLUDE_DIRS.some(d => p.includes('/' + d + '/') || p.includes('/' + d) || p.startsWith(d + '/') || p === d); }

// Load .gitignore patterns from workspace root
const GITIGNORE_PATTERNS = (() => {
  const patterns = [];
  try {
    const text = readFileSync(join(WORKSPACE, '.gitignore'), 'utf-8');
    for (const line of text.split('\n')) {
      const t = line.trim();
      if (!t || t.startsWith('#')) continue;
      let p = t;
      let neg = false;
      if (p.startsWith('!')) { neg = true; p = p.slice(1); }
      if (p.startsWith('/')) p = p.slice(1);
      const dirOnly = p.endsWith('/');
      if (dirOnly) p = p.slice(0, -1);
      if (p) patterns.push({ p, neg, dirOnly });
    }
  } catch {}
  return patterns;
})();

function isGitignored(fp) {
  const rel = fp.startsWith(WORKSPACE) ? fp.slice(WORKSPACE.length).replace(/^\//, '') : fp;
  let ignored = false;
  for (const g of GITIGNORE_PATTERNS) {
    // Simple glob match: * matches non-/ chars
    const reStr = '^' + g.p.replace(/\*\*/g, '{{DOUBLESTAR}}').replace(/\*/g, '[^/]*').replace(/{{DOUBLESTAR}}/g, '.*') + (g.dirOnly ? '/.*' : '');
    if (new RegExp(reStr).test(rel)) ignored = !g.neg;
  }
  return ignored;
}

function isSkipped(fp) { return isExcluded(fp) || isGitignored(fp); }

// === Minimal line-based unified diff (LCS, zero-dep) ===
function createTwoFilesPatch(oldName, newName, oldStr, newStr) {
  const a = oldStr.split('\n'), b = newStr.split('\n');
  const m = a.length, n = b.length;
  if (oldStr === newStr) return `--- ${oldName}\n+++ ${newName}\n`;

  // For large files, fall back to simple hunk to avoid O(n*m) memory bomb
  if (m * n > 2_000_000) {
    const out = [`--- ${oldName}`, `+++ ${newName}`, `@@ -1,${m} +1,${n} @@`];
    for (let i = 0; i < Math.min(m, n); i++) {
      if (a[i] === b[i]) out.push(' ' + a[i]);
      else { out.push('-' + a[i]); out.push('+' + b[i]); }
    }
    for (let i = Math.min(m, n); i < m; i++) out.push('-' + a[i]);
    for (let i = Math.min(m, n); i < n; i++) out.push('+' + b[i]);
    return out.join('\n');
  }

  // LCS DP table (Int32Array: 4 bytes per cell, ~8MB for 1000×1000)
  const dp = Array.from({ length: m + 1 }, () => new Int32Array(n + 1));
  for (let i = 1; i <= m; i++) {
    const ai = a[i - 1], dpi = dp[i], dpm = dp[i - 1];
    for (let j = 1; j <= n; j++) dpi[j] = ai === b[j - 1] ? dpm[j - 1] + 1 : (dpm[j] > dpi[j - 1] ? dpm[j] : dpi[j - 1]);
  }

  // Traceback: 0=keep, 1=insert, 2=delete
  const ops = [];
  let i = m, j = n;
  while (i > 0 || j > 0) {
    if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) { ops.push(0); i--; j--; }
    else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { ops.push(1); j--; }
    else { ops.push(2); i--; }
  }
  ops.reverse();

  // Build unified diff hunks
  const out = [`--- ${oldName}`, `+++ ${newName}`];
  let ai = 0, bi = 0, pos = 0;
  while (pos < ops.length) {
    while (pos < ops.length && ops[pos] === 0) { pos++; ai++; bi++; }
    if (pos >= ops.length) break;
    const ctxBefore = Math.min(3, pos);
    const hunkStart = pos - ctxBefore;
    let ctxCount = 0, hunkEnd = pos;
    while (hunkEnd < ops.length) {
      if (ops[hunkEnd] === 0) { ctxCount++; if (ctxCount > 3) break; }
      else ctxCount = 0;
      hunkEnd++;
    }
    if (ctxCount > 3) hunkEnd -= (ctxCount - 3);
    const hunkAi = ai - (pos - hunkStart);
    const hunkBi = bi - (pos - hunkStart);
    let oldLen = 0, newLen = 0;
    for (let k = hunkStart; k < hunkEnd; k++) { if (ops[k] !== 1) oldLen++; if (ops[k] !== 2) newLen++; }
    out.push(`@@ -${hunkAi + 1},${oldLen} +${hunkBi + 1},${newLen} @@`);
    for (let k = hunkStart; k < hunkEnd; k++) {
      if (ops[k] === 0) { out.push(' ' + a[ai]); ai++; bi++; }
      else if (ops[k] === 2) { out.push('-' + a[ai]); ai++; }
      else { out.push('+' + b[bi]); bi++; }
    }
    pos = hunkEnd;
  }
  return out.join('\n');
}

// === File URI helpers ===
export function fileUriToPath(uri) {
  if (!uri.startsWith('file://')) throw err('INVALID_URI', 'Only file:// URIs are supported');
  const p = decodeURIComponent(uri.slice(7));
  return process.platform === 'win32' ? p.replace(/^\/([a-zA-Z]):\//, '$1:\\') : p;
}
export function pathToFileUri(p) {
  const abs = resolve(p);
  return 'file://' + (process.platform === 'win32' ? '/' + abs.replace(/\\/g, '/') : abs);
}

// === Watch state ===
const watchers = new Map();
let watchEventId = 0;

// === Iterative directory tree (stack-safe, no recursion) ===
export function treeDir(root, maxDepth) {
  let result = '';
  const stack = [{ dir: root, pre: '', depth: 0 }];
  while (stack.length) {
    const { dir, pre, depth } = stack.pop();
    if (depth >= maxDepth) { result += pre + '...\n'; continue; }
    const items = readdirSync(dir, { withFileTypes: true });
    const children = [];
    for (let i = 0; i < items.length; i++) {
      const entry = items[i];
      const last = i === items.length - 1;
      result += pre + (last ? '└── ' : '├── ') + entry.name + '\n';
      if (entry.isDirectory()) children.push({ dir: join(dir, entry.name), pre: pre + (last ? '    ' : '│   '), depth: depth + 1 });
    }
    for (let i = children.length - 1; i >= 0; i--) stack.push(children[i]);
  }
  return result;
}

// === Read cache + atomic write ===
const readCache = new Map(), MAX_CACHE = 10;
function cachedRead(f) {
  const mtime = statSync(f).mtimeMs;
  const entry = readCache.get(f);
  if (entry && entry.mtime === mtime) return entry.content;
  const content = readFileSync(f, 'utf-8');
  readCache.set(f, { content, mtime });
  if (readCache.size > MAX_CACHE) { const k = readCache.keys().next().value; if (k !== undefined) readCache.delete(k); }
  return content;
}
function invalidateCache(f) { readCache.delete(f); }
function updateCache(f) { try { const c = readFileSync(f, 'utf-8'); readCache.set(f, { content: c, mtime: statSync(f).mtimeMs }); } catch {} }
function atomicWrite(f, content) {
  const tmp = f + '.tmp.' + Date.now();
  writeFileSync(tmp, content, 'utf-8');
  renameSync(tmp, f);
  invalidateCache(f);
}

export function compactDiff(diff) {
  const lines = diff.split('\n');
  let add = 0, del = 0;
  for (const l of lines) {
    if (l.startsWith('+') && !l.startsWith('+++')) add++;
    else if (l.startsWith('-') && !l.startsWith('---')) del++;
  }
  return `+${add}/-${del} lines`;
}

export function scoreResults(results, query) {
  const q = query.toLowerCase();
  return results.map(r => {
    let s = 0;
    const n = r.split('/').pop().toLowerCase();
    if (n === q) s += 20;
    else if (n.includes(q)) s += 10;
    else for (let i = 0; i < q.length && i < n.length; i++) if (n[i] === q[i]) s += 2;
    s -= r.split('/').length;
    try { const st = statSync(join(WORKSPACE, r)); const age = Date.now() - st.mtimeMs; if (age < 3600000) s += 5; else if (age < 86400000) s += 3; else if (age < 604800000) s += 1; } catch {}
    return { r, s };
  }).sort((a, b) => b.s - a.s).map(x => x.r);
}

const tools = [
  { name: 'read', description: 'Read file (head/tail to limit lines; both together shows head + ... + tail)', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Absolute path to file' }, head: { type: 'number', description: 'Number of lines from start' }, tail: { type: 'number', description: 'Number of lines from end' } }, required: ['path'] } },
  { name: 'search', description: 'Search files by name or content (glob then grep)', inputSchema: { type: 'object', properties: { p: { type: 'string', description: 'Search pattern (filename or path fragment)' }, exclude: { type: 'string', description: 'Glob pattern to exclude (e.g. node_modules/**)' }, ext: { type: 'string', description: 'File extension filter (e.g. .mjs .js)' } }, required: ['p'] } },
  { name: 'ls', description: 'List directory contents', inputSchema: { type: 'object', properties: { p: { type: 'string', description: 'Directory path' }, sort: { type: 'string', description: 'Sort order: size' }, tree: { type: 'boolean', description: 'Show tree view' }, depth: { type: 'number', description: 'Tree depth (default 2)' }, detail: { type: 'boolean', description: 'Show full metadata' } } } },
  { name: 'exec', description: 'Execute shell command', inputSchema: { type: 'object', properties: { cmd: { type: 'string', description: 'Command string' }, args: { type: 'array', items: { type: 'string' }, description: 'Command as args array' }, cwd: { type: 'string', description: 'Working directory' }, t: { type: 'number', description: 'Timeout in seconds (default 30)' }, b64: { type: 'boolean', description: 'Base64 decode cmd before execution' } } } },
  { name: 'move', description: 'Move or rename file/directory', inputSchema: { type: 'object', properties: { source: { type: 'string', description: 'Source path' }, destination: { type: 'string', description: 'Destination path' } }, required: ['source', 'destination'] } },
  { name: 'batch', description: 'Batch multiple operations with rollback', inputSchema: { type: 'object', properties: { ops: { type: 'array', items: { type: 'object' }, description: 'Array of operations' }, stopOnError: { type: 'boolean', description: 'Stop on first error (default true)' }, atomic: { type: 'boolean', description: 'Rollback all on failure' } }, required: ['ops'] } },
  { name: 'file', description: 'Unified file operations (action=read|write|edit|append|delete|info|mkdir|move)', inputSchema: { type: 'object', properties: { action: { type: 'string', description: 'Operation: read|write|edit|append|delete|info|mkdir|move' }, path: { type: 'string', description: 'Target file path' }, content: { type: 'string', description: 'File content (for write)' }, old: { type: 'string', description: 'Text to replace (for edit)' }, new: { type: 'string', description: 'Replacement text (for edit)' }, destination: { type: 'string', description: 'Destination path (for move)' }, head: { type: 'number', description: 'Lines from start (for read)' }, tail: { type: 'number', description: 'Lines from end (for read)' }, dryRun: { type: 'boolean', description: 'Preview changes without applying' } }, required: ['action', 'path'] } },
  { name: 'block', description: 'Code block operations by range or function name', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, action: { type: 'string', description: 'read|replace|insert|delete' }, range: { type: 'object', properties: { start: { type: 'number', description: 'Start line (1-based)' }, end: { type: 'number', description: 'End line (1-based)' } }, description: 'Line range' }, name: { type: 'string', description: 'Function name to locate' }, content: { type: 'string', description: 'New content' }, dryRun: { type: 'boolean', description: 'Preview diff only' } }, required: ['path', 'action'] } },
  { name: 'bookmark', description: 'Persistent path aliases', inputSchema: { type: 'object', properties: { action: { type: 'string', description: 'add|get|list|delete' }, name: { type: 'string', description: 'Bookmark name' }, path: { type: 'string', description: 'Path to bookmark' } }, required: ['action'] } },
  { name: 'watch', description: 'Watch file/directory for changes (event-driven, returns new events since last call)', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Path to file or directory to watch' }, once: { type: 'boolean', description: 'If true, returns current state and stops watching' } }, required: ['path'] } }
];

export function applyEdit(content, oldText, newText) {
  const nOld = nl(oldText);
  const nNew = nl(newText || '');
  // Exact match (fast path)
  const idx = content.indexOf(nOld);
  if (idx !== -1) return content.slice(0, idx) + nNew + content.slice(idx + nOld.length);
  // Trim-match each line (fuzzy path)
  const oldLines = nOld.split('\n');
  const cLines = content.split('\n');
  const limit = cLines.length - oldLines.length;
  for (let i = 0; i <= limit; i++) {
    const match = cLines.slice(i, i + oldLines.length);
    if (oldLines.every((l, j) => l.trim() === match[j].trim())) {
      const newLines = nNew.split('\n').map((line, j) => {
        const adj = (match[j].match(/^\s*/)?.[0] || '').length - (oldLines[j].match(/^\s*/)?.[0] || '').length;
        return adj ? ' '.repeat(Math.max(0, adj)) + line.trimStart() : line;
      });
      cLines.splice(i, oldLines.length, ...newLines);
      return cLines.join('\n');
    }
  }
  throw err("MATCH_NOT_FOUND", "Match not found: " + (oldText || '').slice(0, 50));
}

export function findBlock(lines, name) {
  const re = new RegExp('(?:function|const|let|var|async)\\s+' + name + '\\b|' + name + '\\s*[:=]\\s*(?:async\\s+)?[(\\(]|' + name + '\\s*[:=]\\s*(?:async\\s+)?\\w+\\s*=>');
  for (let i = 0; i < lines.length; i++) {
    if (re.test(lines[i])) {
      let depth = 0, start = i;
      if (!lines[i].includes('{')) {
        for (let j = i + 1; j < lines.length && j < i + 5; j++) {
          if (lines[j].includes('{')) { start = j; break; }
        }
      }
      for (let j = start; j < lines.length; j++) {
        for (const ch of lines[j]) {
          if (ch === '{') depth++;
          else if (ch === '}') { depth--; if (depth === 0) return { start, end: j }; }
        }
      }
      return { start, end: lines.length - 1 };
    }
  }
  return null;
}

const MAX_GREP_TOTAL_BYTES = 100 * 1024 * 1024; // 100 MB total limit

async function runGrep(s, ext) {
  const exts = ext ? ext.split(/\s+/).filter(Boolean) : ['.mjs', '.js'];
  const results = [];
  const q = s.toLowerCase();
  for (const e of exts) {
    const pat = e.startsWith('.') ? `**/*${e}` : `**/*.${e}`;
    const files = globSync(pat, { cwd: WORKSPACE, exclude: isSkipped });
    let totalFiles = 0, totalBytes = 0;
    const MAX_GREP_FILES = 1000;
    for (const f of files) {
      const fp = join(WORKSPACE, f);
      if (isBinary(fp)) continue;
      try {
        if (++totalFiles > MAX_GREP_FILES || totalBytes > MAX_GREP_TOTAL_BYTES) break;
        const rl = createInterface({ input: createReadStream(fp, 'utf-8'), crlfDelay: Infinity });
        let ln = 0;
        for await (const line of rl) {
          ln++;
          totalBytes += Buffer.byteLength(line, 'utf-8');
          if (totalBytes > MAX_GREP_TOTAL_BYTES) break;
          if (line.toLowerCase().includes(q)) results.push(`${f}:${ln}`);
        }
      } catch {}
    }
  }
  return results;
}

// === In-memory bookmark cache ===
let _bookmarkCache = null;
function loadBookmarks() {
  if (_bookmarkCache) return _bookmarkCache;
  try { _bookmarkCache = JSON.parse(readFileSync(BOOKMARK_FILE, 'utf-8')); } catch { _bookmarkCache = {}; }
  return _bookmarkCache;
}
function saveBookmarks(bm) { _bookmarkCache = bm; atomicWrite(BOOKMARK_FILE, JSON.stringify(bm)); }

// === Stream head reader (zero alloc for large files) ===
function streamHead(f, maxLines) {
  try {
    const stream = createReadStream(f, { encoding: 'utf-8' });
    const rl = createInterface({ input: stream, crlfDelay: Infinity });
    const lines = [];
    let closed = false;
    return new Promise((resolve) => {
      let resolved = false;
      const done = (v) => { if (!resolved) { resolved = true; try { stream.destroy(); } catch {}; resolve(v); } };
      rl.on('line', (line) => {
        if (lines.length < maxLines) { lines.push(line); }
        else { if (!closed) { closed = true; done(lines.join('\n')); } }
      });
      rl.on('close', () => done(lines.join('\n')));
      rl.on('error', () => done(null));
      stream.on('error', () => done(null));
    });
  } catch { return null; }
}

const h = {
  read: async p => {
    const f = ok(p.path);
    // Stream head for large files (avoids full read)
    if (p.head && !p.tail) {
      const sh = await streamHead(f, p.head);
      if (sh !== null) return sh;
    }
    let c = cachedRead(f);
    const lines = c.split('\n');
    if (p.head && p.tail) {
      if (p.head + p.tail >= lines.length) return c;
      return lines.slice(0, p.head).join('\n') + `\n... (${lines.length - p.head - p.tail} lines omitted) ...\n` + lines.slice(-p.tail).join('\n');
    }
    if (p.head) return lines.slice(0, p.head).join('\n');
    if (p.tail) return lines.slice(-p.tail).join('\n');
    return c;
  },
  write: p => { checkReadonly(); atomicWrite(ok(p.path), p.content); updateCache(ok(p.path)); return 'ok'; },
  edit: p => {
    checkReadonly();
    const f = ok(p.path);
    const nc = nl(readFileSync(f, 'utf-8'));
    const modified = applyEdit(nc, p.old, p.new);
    const diff = createTwoFilesPatch(p.path, p.path, nc, modified, 'old', 'new');
    if (p.dryRun) return compactDiff(diff);
    atomicWrite(f, modified.replace(/\n/g, '\r\n'));
    updateCache(f);
    return compactDiff(diff);
  },
  append: p => { checkReadonly(); appendFileSync(ok(p.path), p.content, 'utf-8'); invalidateCache(ok(p.path)); return 'ok'; },
  move: p => { checkReadonly(); renameSync(ok(p.source), ok(p.destination)); invalidateCache(ok(p.source)); return 'ok'; },
  search: async p => {
    const pats = [p.p, `**/*${p.p}*`, `**/*${p.p.replace(/[/\\]/g, '')}*`];
    for (const pat of pats) {
      const results = globSync(pat, { cwd: WORKSPACE, exclude: isSkipped });
      if (results.length) return scoreResults(results, p.p);
    }
    if (!p.exclude) {
      const found = await runGrep(p.p, p.ext || '.mjs .js');
      if (found.length) return scoreResults(found, p.p);
    }
    return [];
  },
  grep: p => runGrep(p.s, p.ext),
  ls: p => {
    if (p.tree) return (p.p || 'MCP') + '/\n' + treeDir(ok(p.p || MCP_DIR), p.depth || 2);
    const d = ok(p.p || DATA);
    const items = readdirSync(d, { withFileTypes: true }).map(e => ({ n: e.name, d: e.isDirectory(), s: (e.isFile() && p.sort === 'size') ? statSync(join(d, e.name)).size : 0 }));
    if (p.sort === 'size') items.sort((a, b) => b.s - a.s);
    if (p.detail) return items;
    const files = items.filter(i => !i.d).length;
    const dirs = items.filter(i => i.d).length;
    const total = items.reduce((a, i) => a + i.s, 0);
    const kb = total > 1024 ? (total / 1024).toFixed(1) + 'KB' : total + 'B';
    return `${files} files, ${dirs} dirs (${kb})`;
  },
  tree: p => (p.p || 'MCP') + '/\n' + treeDir(ok(p.p || MCP_DIR), p.depth || 2),
  // exec: bat workaround — Chatbox CVE-2026-6130 blocks top-level cmd params
  // write cmd to run.bat → spawnSync → delete immediately
  // b64: base64 → PowerShell decode, bypass cmd escaping; batch embedded exec unaffected
  // ⚠️ keep taskkill — zombie processes linger on timeout
  exec: p => {
    checkReadonly();
    const cmd = p.args ? p.args.join(' ') : (p.cmd || '');
    if (!cmd) return '';
    const cwd = p.cwd || DATA;
    const t = (p.t || 30) * 1000;
    const batDir = join(DATA, 'temp');
    mkdirSync(batDir, { recursive: true });
    const bat = join(batDir, 'run.bat');
    const prolog = '@echo off\r\nchcp 65001 >nul\r\n';
    const batContent = p.b64
      ? prolog + 'powershell -NoProfile -Command "$c=[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String(\'' + Buffer.from(cmd, 'utf-8').toString('base64') + '\')); cmd /c $c; exit $LASTEXITCODE"\r\n'
      : prolog + cmd + '\r\n';
    writeFileSync(bat, batContent);
    const child = spawnSync('cmd', ['/c', bat], { encoding: 'utf-8', cwd, timeout: t, maxBuffer: 10 * 1024 * 1024 });
    rmSync(bat, { force: true });
    if (child.error) {
      if (child.pid) try { execSync(`taskkill /F /T /PID ${child.pid} 2>nul`, { timeout: 5000 }); } catch {}
      return (child.stderr || child.stdout || child.error.message || '').toString().trim();
    }
    return (child.stdout || '').trim();
  },
  batch: p => {
    checkReadonly();
    const results = [];
    let prev = null;
    const backups = new Map();
    if (p.atomic) {
      for (const o of p.ops) {
        const fp = o.path;
        if (fp && /^(edit|write|file|block|delete)$/.test(o.op || '') && !backups.has(fp)) {
          try { backups.set(fp, readFileSync(fp, 'utf-8')); } catch {}
        }
      }
    }
    for (const o of p.ops) {
      const op = { ...o };
      for (const [k, v] of Object.entries(op)) {
        if (typeof v === 'string' && v.includes('$prev')) op[k] = v.replace(/\$prev/g, prev ?? '');
      }
      try {
        if (!h[op.op]) throw new Error('Unknown operation: ' + op.op);
        prev = h[op.op](op);
        results.push({ ok: true, op: op.op, r: prev });
      } catch (e) {
        results.push({ ok: false, op: op.op, e: e.message });
        if (p.atomic) {
          for (const [fp, content] of backups) try { writeFileSync(fp, content, 'utf-8'); } catch {}
          results.push({ atomicRollback: true, files: [...backups.keys()] });
        }
        if (p.stopOnError !== false) break;
      }
    }
    return results;
  },
  file: p => {
    switch (p.action) {
      case 'read': return h.read(p);
      case 'write': checkReadonly(); return h.write(p);
      case 'edit': checkReadonly(); return h.edit(p);
      case 'append': checkReadonly(); return h.append(p);
      case 'move': checkReadonly(); renameSync(ok(p.path), ok(p.destination)); invalidateCache(ok(p.path)); return 'ok';
      case 'mkdir': checkReadonly(); mkdirSync(ok(p.path), { recursive: true }); return 'ok';
      case 'delete': checkReadonly(); rmSync(ok(p.path), { recursive: true, force: true }); return 'ok';
      case 'info': {
        const s = statSync(ok(p.path));
        return { size: s.size, mtime: s.mtime.toISOString(), isDir: s.isDirectory(), isFile: s.isFile() };
      }
      default: throw err("INVALID_ACTION", "action: read|write|edit|append|delete|move|info|mkdir");
    }
  },
  block: p => {
    const f = ok(p.path);
    const nc = nl(readFileSync(f, 'utf-8'));
    const lines = nc.split('\n');
    let rStart, rEnd;
    if (p.range) { rStart = p.range.start - 1; rEnd = (p.range.end || p.range.start) - 1; }
    if (p.name) {
      const blk = findBlock(lines, p.name);
      if (!blk) throw err("NAME_NOT_FOUND", "Not found: " + p.name);
      rStart = rStart != null ? Math.max(rStart, blk.start) : blk.start;
      rEnd = rEnd != null ? Math.min(rEnd, blk.end) : blk.end;
    }
    if (rStart == null) throw err("MISSING_PARAM", "Need range or name parameter");
    if (rStart < 0 || rEnd >= lines.length) throw err("OUT_OF_RANGE", `Line range out of bounds: ${rStart + 1}-${rEnd + 1} (file has ${lines.length} lines)`);
    const act = p.action;
    if (act === 'read') return lines.slice(rStart, rEnd + 1).join('\n');
    if (['replace', 'insert', 'delete'].includes(act)) checkReadonly();
    const before = lines.slice(0, rStart).join('\n');
    const after = lines.slice(rEnd + 1).join('\n');
    const blockStr = lines.slice(rStart, rEnd + 1).join('\n');
    let modified;
    if (act === 'replace' || act === 'insert') {
      const ct = p.content || '';
      modified = act === 'replace'
        ? (before + (before ? '\n' : '') + ct + (after ? '\n' : '') + after)
        : (before + (before ? '\n' : '') + ct + '\n' + blockStr + (after ? '\n' : '') + after);
    } else if (act === 'delete') {
      modified = before + (after ? '\n' : '') + after;
    } else throw err("INVALID_ACTION", "action: read|replace|insert|delete");
    const diff = createTwoFilesPatch(p.path, p.path, nc, modified, 'old', 'new');
    if (p.dryRun) return compactDiff(diff);
    atomicWrite(f, modified.replace(/\n/g, '\r\n'));
    updateCache(f);
    return compactDiff(diff);
Showing 500 of 597 lines View full file on GitHub →