@remcp/runtime 0.2.6 → 0.2.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.7
4
+
5
+ Security and correctness fixes for the device runtime.
6
+
7
+ - **Symlink confinement fixed.** `read_files`, `replace_in_files` and `set_permissions` walked a
8
+ tree with `stat`, which follows symbolic links, and never re-checked the children they collected.
9
+ A directory symlink inside an allowed root could therefore be read *and rewritten* outside it.
10
+ Traversal now uses `lstat`, never follows a link, and re-resolves every collected path through
11
+ the same confinement check a single-file call uses.
12
+ - **Large results no longer kill the runtime.** The inline image limit is 4 MiB instead of 8 MiB:
13
+ base64 costs a third more bytes and the MCP stdio client drops the connection above 10 MB, which
14
+ used to restart the runtime in the middle of a call. The text budget is shared between `content`
15
+ and `structuredContent`, which carry the same string.
16
+ - **`apply_patch` inserts zero-context hunks at the right line**: `@@ -N,0 +M,K @@` inserts *after*
17
+ line N, and the previous calculation applied every such hunk one line early while reporting
18
+ success.
19
+ - **Glob patterns honour `[abc]`, `{a,b}` and `?`**: character classes and brace alternatives were
20
+ escaped into literal text and matched nothing, and `?` could match a directory separator.
21
+
3
22
  ## 0.2.3
4
23
 
5
24
  Full surface and review-aligned annotations. (0.2.0 was published from an earlier snapshot that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "First-party ReMCP local device runtime: file, search, terminal and process tools over MCP for computers paired with ReMCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/patch.mjs CHANGED
@@ -52,7 +52,10 @@ function normalize(line) {
52
52
  // differences, the same way patch(1) does with fuzz.
53
53
  function locate(lines, hunk) {
54
54
  const expected = hunk.lines.filter(entry => entry.type !== '+').map(entry => entry.text);
55
- if (!expected.length) return { index: hunk.oldStart - 1, fuzz: 0 };
55
+ // A hunk with no context and no removals is a pure insertion: `@@ -N,0 +M,K @@` inserts after
56
+ // line N, so the zero-based insertion point is N (and the end of file when N is the last line).
57
+ // Using N-1 here inserted every such hunk one line too early while still reporting success.
58
+ if (!expected.length) return { index: Math.min(Math.max(hunk.oldStart, 0), lines.length), fuzz: 0 };
56
59
  const candidates = [];
57
60
  const anchor = Math.max(0, hunk.oldStart - 1);
58
61
  for (let offset = 0; offset <= 200; offset += 1) {
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { constants, createReadStream } from 'node:fs';
6
- import { access, chmod, chown, copyFile, cp, mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
6
+ import { access, chmod, chown, copyFile, cp, lstat, mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
7
7
  import { pipeline } from 'node:stream/promises';
8
8
  import { runtimeConfig } from '../config.mjs';
9
9
  import { diffStats, unifiedDiff } from '../diff.mjs';
@@ -12,7 +12,10 @@ import { countEvent, recordEvent } from '../telemetry.mjs';
12
12
  import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, looksBinary, multi, pageLines, resolveSafePath, splitLines, text } from '../util.mjs';
13
13
 
14
14
  const MAX_INLINE_FILE_BYTES = 20 * 1024 * 1024;
15
- const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
15
+ // An image travels base64-encoded, which costs a third more bytes, and the MCP stdio client drops
16
+ // the connection above 10 MB. 4 MiB of image is ~5.4 MiB on the wire, which leaves room for the
17
+ // summary text and the frame overhead; anything larger goes through read_binary in chunks instead.
18
+ const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
16
19
  const MAX_BINARY_CHUNK_BYTES = 1024 * 1024;
17
20
  const IMAGE_TYPES = new Map([
18
21
  ['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'],
@@ -25,6 +28,40 @@ function detectEol(content) {
25
28
  return crlf > lf ? '\r\n' : '\n';
26
29
  }
27
30
 
31
+ // Traversal helper for every multi-file tool. A symbolic link inside an allowed root can point
32
+ // anywhere, so links are never followed and each collected path is resolved through
33
+ // resolveSafePath again before a tool reads or writes it. `stat` follows links, which is exactly
34
+ // how a symlinked directory inside a root used to expose files outside it.
35
+ async function collectTree(root, { maxFiles = 500, skip = [] } = {}) {
36
+ const found = [];
37
+ const skipName = name => skip.some(entry => (entry.endsWith('*') ? name.startsWith(entry.slice(0, -1)) : name === entry));
38
+ async function visit(target) {
39
+ if (found.length >= maxFiles) return;
40
+ const info = await lstat(target).catch(() => null);
41
+ if (!info || info.isSymbolicLink()) return;
42
+ if (info.isFile()) { found.push(target); return; }
43
+ if (!info.isDirectory()) return;
44
+ const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
45
+ for (const entry of entries) {
46
+ if (found.length >= maxFiles) return;
47
+ if (entry.isSymbolicLink() || skipName(entry.name)) continue;
48
+ await visit(path.join(target, entry.name));
49
+ }
50
+ }
51
+ await visit(root);
52
+ return found;
53
+ }
54
+
55
+ // Every path a multi-file tool is about to touch passes through the same confinement check as a
56
+ // single-file call, so dropping the traversal shortcut cannot widen what is reachable.
57
+ async function confineAll(paths) {
58
+ const safe = [];
59
+ for (const target of paths) {
60
+ try { safe.push(await resolveSafePath(target)); } catch { /* outside the allowed roots: skip it */ }
61
+ }
62
+ return safe;
63
+ }
64
+
28
65
  async function readTextFile(absolute) {
29
66
  const info = await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`));
30
67
  if (info.isDirectory()) fail(`${displayPath(absolute)} is a directory, not a file`);
@@ -366,22 +403,7 @@ export async function replaceInFilesTool(args) {
366
403
  }
367
404
  }
368
405
  const info = await stat(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
369
- const files = [];
370
- async function collect(target) {
371
- if (files.length > maxFiles) return;
372
- const entryInfo = await stat(target).catch(() => null);
373
- if (!entryInfo) return;
374
- if (entryInfo.isFile()) { files.push(target); return; }
375
- if (!entryInfo.isDirectory()) return;
376
- const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
377
- for (const entry of entries) {
378
- if (files.length > maxFiles) return;
379
- if (entry.name === '.git' || entry.name === 'node_modules' || entry.name.startsWith('.remcp-trash')) continue;
380
- await collect(path.join(target, entry.name));
381
- }
382
- }
383
- if (info.isFile()) files.push(root);
384
- else await collect(root);
406
+ const files = await confineAll(info.isFile() ? [root] : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'] }));
385
407
  const glob = filePattern ? globToRegExp(filePattern) : null;
386
408
  const changed = [];
387
409
  let scanned = 0;
@@ -471,30 +493,18 @@ export async function readFilesTool(args) {
471
493
  const maxLinesPerFile = clampInteger(args.max_lines_per_file, runtimeConfig.maxReadLines, 1, 20000);
472
494
  const includeIgnored = args.include_ignored === true;
473
495
  const matcher = globToRegExp(pattern);
474
- const files = [];
475
- async function collect(target) {
476
- if (files.length > maxFiles) return;
477
- const info = await stat(target).catch(() => null);
478
- if (!info) return;
479
- if (info.isFile()) {
480
- const relative = path.relative(root, target) || path.basename(target);
481
- if (matcher.test(relative.split(path.sep).join('/')) || matcher.test(path.basename(target))) files.push(target);
482
- return;
483
- }
484
- if (!info.isDirectory()) return;
485
- const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
486
- for (const entry of entries) {
487
- if (files.length > maxFiles) return;
488
- if (entry.name.startsWith('.remcp-trash')) continue;
489
- if (!includeIgnored && (entry.name === 'node_modules' || entry.name === '.git')) continue;
490
- await collect(path.join(target, entry.name));
491
- }
492
- }
493
- if ((await stat(root).catch(() => null))?.isFile()) {
494
- files.push(root);
495
- } else {
496
- await collect(root);
497
- }
496
+ const rootInfo = await stat(root).catch(() => null);
497
+ // A file path is matched directly; a directory is walked without following links.
498
+ const candidates = rootInfo?.isFile()
499
+ ? [root]
500
+ : await confineAll(await collectTree(root, {
501
+ maxFiles: maxFiles + 1,
502
+ skip: includeIgnored ? ['.remcp-trash*'] : ['node_modules', '.git', '.remcp-trash*'],
503
+ }));
504
+ const files = candidates.filter(target => {
505
+ const relative = path.relative(root, target) || path.basename(target);
506
+ return matcher.test(relative.split(path.sep).join('/')) || matcher.test(path.basename(target));
507
+ }).slice(0, maxFiles);
498
508
  if (!files.length) return text(`No files matched ${pattern} under ${displayPath(root)}.`);
499
509
  const sections = [];
500
510
  let skipped = 0;
@@ -696,16 +706,24 @@ export async function setPermissionsTool(args) {
696
706
  const recursive = args.recursive === true;
697
707
  const uid = Number.isInteger(Number(args.uid)) ? Number(args.uid) : null;
698
708
  const gid = Number.isInteger(Number(args.gid)) ? Number(args.gid) : null;
699
- const targets = [];
700
- async function collect(target) {
701
- targets.push(target);
702
- const entry = await stat(target).catch(() => null);
703
- if (!entry?.isDirectory() || !recursive) return;
704
- for (const child of await readdir(target).catch(() => [])) await collect(path.join(target, child));
705
- }
706
- await collect(absolute);
709
+ const targets = [absolute];
710
+ if (recursive) {
711
+ // Directories are included, links are not: chmod follows a link and would change a target
712
+ // outside the allowed roots.
713
+ const walk = async target => {
714
+ const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
715
+ for (const entry of entries) {
716
+ if (entry.isSymbolicLink()) continue;
717
+ const child = path.join(target, entry.name);
718
+ targets.push(child);
719
+ if (entry.isDirectory()) await walk(child);
720
+ }
721
+ };
722
+ await walk(absolute);
723
+ }
724
+ const confined = await confineAll(targets);
707
725
  let changed = 0;
708
- for (const target of targets) {
726
+ for (const target of confined) {
709
727
  try {
710
728
  await chmod(target, mode);
711
729
  if (uid !== null || gid !== null) await chown(target, uid ?? -1, gid ?? -1);
package/src/util.mjs CHANGED
@@ -112,11 +112,19 @@ export function truncate(text, maxBytes) {
112
112
  return `${head}\n… output truncated (${buffer.length} bytes, limit ${limit}) …\n${tail}`;
113
113
  }
114
114
 
115
+ // structuredContent mirrors the text so a client can rely on the declared outputSchema, but the
116
+ // same string twice doubles the message: above this size the mirror becomes a summary and the full
117
+ // result stays in content, which is the primary channel every client reads.
118
+ const STRUCTURED_MIRROR_LIMIT_BYTES = 256 * 1024;
119
+
115
120
  export function text(value, isError = false) {
116
121
  const body = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
117
122
  const rendered = truncate(body);
118
- // structuredContent mirrors the text so a client can rely on the declared outputSchema.
119
- return { content: [{ type: 'text', text: rendered }], structuredContent: { text: rendered }, ...(isError ? { isError: true } : {}) };
123
+ const size = Buffer.byteLength(rendered, 'utf8');
124
+ const mirror = size <= STRUCTURED_MIRROR_LIMIT_BYTES
125
+ ? rendered
126
+ : `${rendered.slice(0, 512)}… (${size} bytes total; the full result is in the text content)`;
127
+ return { content: [{ type: 'text', text: rendered }], structuredContent: { text: mirror }, ...(isError ? { isError: true } : {}) };
120
128
  }
121
129
 
122
130
  export function image(data, mimeType) {
@@ -169,14 +177,49 @@ export function pageLines(lines, offset, length) {
169
177
  return { start, end, slice: lines.slice(start, end) };
170
178
  }
171
179
 
180
+ // Glob translation for the file tools. `?` never crosses a directory separator, `[abc]` and
181
+ // `{a,b}` are honoured, and `**/` may match no directory at all so `**/*` also matches a file in
182
+ // the root. The previous version escaped class and brace syntax, so those patterns silently
183
+ // matched nothing.
172
184
  export function globToRegExp(pattern) {
173
- // `**/` may match no directory at all, so `**/*` also matches a file in the root.
174
- const escaped = String(pattern).replace(/[.+^${}()|[\]\\]/g, '\\$&')
175
- .replace(/\*\*\//g, '\u0001')
176
- .replace(/\*\*/g, '\u0000')
177
- .replace(/\*/g, '[^/]*')
178
- .replace(/\?/g, '.')
179
- .replace(/\u0000/g, '.*')
180
- .replace(/\u0001/g, '(?:.*/)?');
181
- return new RegExp(`^${escaped}$`);
185
+ const source = String(pattern);
186
+ let out = '';
187
+ for (let index = 0; index < source.length; index += 1) {
188
+ const char = source[index];
189
+ if (char === '*') {
190
+ if (source[index + 1] === '*') {
191
+ if (source[index + 2] === '/') { out += '(?:.*/)?'; index += 2; }
192
+ else { out += '.*'; index += 1; }
193
+ } else out += '[^/]*';
194
+ continue;
195
+ }
196
+ if (char === '?') { out += '[^/]'; continue; }
197
+ if (char === '[') {
198
+ const close = source.indexOf(']', index + 1);
199
+ if (close > index + 1) {
200
+ let body = source.slice(index + 1, close);
201
+ const negated = body.startsWith('!') || body.startsWith('^');
202
+ if (negated) body = body.slice(1);
203
+ body = body.replace(/\\/g, '\\\\').replace(/\]/g, '\\]').replace(/\^/g, '\\^');
204
+ out += `[${negated ? '^/' : ''}${body}]`;
205
+ index = close;
206
+ continue;
207
+ }
208
+ out += '\\[';
209
+ continue;
210
+ }
211
+ if (char === '{') {
212
+ const close = source.indexOf('}', index + 1);
213
+ if (close > index + 1) {
214
+ const alternatives = source.slice(index + 1, close).split(',').map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
215
+ out += `(?:${alternatives.join('|')})`;
216
+ index = close;
217
+ continue;
218
+ }
219
+ out += '\\{';
220
+ continue;
221
+ }
222
+ out += /[.+^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
223
+ }
224
+ return new RegExp(`^${out}$`);
182
225
  }