@remcp/runtime 0.2.8 → 0.2.10

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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.10
4
+
5
+ Tool-call reliability fixes found by the second audit round.
6
+
7
+ - `apply_patch` accepts `diff -u` headers that carry a tab and timestamp (the timestamp is not part
8
+ of the file path), treats a blank line inside a hunk as an empty context line instead of dropping
9
+ it, and drops trailing empty context lines the diff's own line counts say are not part of the hunk.
10
+ - The output budget now bounds the serialised frame, not the raw bytes: a control character becomes
11
+ six bytes once JSON-escaped, so an ANSI-heavy result used to pass the check and still exceed the
12
+ transport limit, closing the connection mid-call.
13
+ - `read_file`, `read_binary`, `read_image` and `hash_file` refuse anything that is not a regular
14
+ file, so a FIFO cannot hang a call and a device node cannot flood it.
15
+ - Tree walks report the directories they could not read instead of silently returning a partial
16
+ result, `read_files` reports the real number of matches, `set_permissions` reports per-path
17
+ failures instead of stopping at the first one, and a glob character class with an invalid range
18
+ falls back to a literal match instead of throwing out of the tool.
19
+
3
20
  ## 0.2.7
4
21
 
5
22
  Security and correctness fixes for the device runtime.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
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
@@ -6,6 +6,16 @@ import { splitLines } from './util.mjs';
6
6
 
7
7
  const HUNK_HEADER = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/;
8
8
 
9
+ // `diff -u` writes `--- a/file<TAB>2026-09-17 12:00:00` and a model may paste that verbatim. The
10
+ // tab-separated timestamp is not part of the path, and keeping it made apply_patch create a file
11
+ // whose name contained the date while the real target was never touched.
12
+ function patchPath(value) {
13
+ const raw = String(value || '').trim();
14
+ if (!raw) return '';
15
+ const [path] = raw.split('\t');
16
+ return path.trim();
17
+ }
18
+
9
19
  export function parseUnifiedDiff(patch) {
10
20
  const lines = String(patch).replace(/\r\n/g, '\n').split('\n');
11
21
  const files = [];
@@ -13,13 +23,13 @@ export function parseUnifiedDiff(patch) {
13
23
  let hunk = null;
14
24
  for (const line of lines) {
15
25
  if (line.startsWith('--- ')) {
16
- current = { oldPath: line.slice(4).trim(), newPath: null, hunks: [] };
26
+ current = { oldPath: patchPath(line.slice(4)), newPath: null, hunks: [] };
17
27
  files.push(current);
18
28
  hunk = null;
19
29
  continue;
20
30
  }
21
31
  if (line.startsWith('+++ ')) {
22
- if (current) current.newPath = line.slice(4).trim();
32
+ if (current) current.newPath = patchPath(line.slice(4));
23
33
  continue;
24
34
  }
25
35
  const header = line.match(HUNK_HEADER);
@@ -37,10 +47,32 @@ export function parseUnifiedDiff(patch) {
37
47
  }
38
48
  if (!hunk) continue;
39
49
  if (line.startsWith('\\')) continue; // ""
40
- if (line === '' && hunk.lines.length === 0) continue;
50
+ if (line === '') {
51
+ // A blank line inside a hunk is an empty context line (" " with its trailing space stripped by
52
+ // an editor or a chat client), not a separator. Dropping it shifted every following line and
53
+ // applied the hunk in the wrong place while still reporting success.
54
+ if (hunk.lines.length) hunk.lines.push({ type: ' ', text: '' });
55
+ continue;
56
+ }
41
57
  const marker = line[0];
42
58
  if (marker === ' ' || marker === '+' || marker === '-') hunk.lines.push({ type: marker, text: line.slice(1) });
43
59
  }
60
+ // A diff ends with a newline, and a chat client may strip the trailing space of the last context
61
+ // line, leaving an empty string that is not part of any hunk. The declared line counts say how
62
+ // many lines belong to a hunk, so trailing empty context lines beyond them are dropped.
63
+ for (const file of files) {
64
+ for (const entry of file.hunks) {
65
+ const countOld = lines => lines.filter(line => line.type !== '+').length;
66
+ const countNew = lines => lines.filter(line => line.type !== '-').length;
67
+ while (entry.lines.length > 1) {
68
+ const last = entry.lines[entry.lines.length - 1];
69
+ const overOld = countOld(entry.lines) > entry.oldCount;
70
+ const overNew = countNew(entry.lines) > entry.newCount;
71
+ if (last.type !== ' ' || last.text !== '' || (!overOld && !overNew)) break;
72
+ entry.lines.pop();
73
+ }
74
+ }
75
+ }
44
76
  return files.filter(file => file.hunks.length);
45
77
  }
46
78
 
@@ -28,20 +28,38 @@ function detectEol(content) {
28
28
  return crlf > lf ? '\r\n' : '\n';
29
29
  }
30
30
 
31
+ // Only regular files can be read: a FIFO blocks until a writer appears, a device node can be
32
+ // endless, and both would hang or flood a tool call instead of returning an answer.
33
+ function assertRegularFile(info, absolute) {
34
+ if (info.isDirectory()) fail(`${displayPath(absolute)} is a directory, not a file`);
35
+ if (!info.isFile()) fail(`${displayPath(absolute)} is not a regular file`);
36
+ return info;
37
+ }
38
+
31
39
  // Traversal helper for every multi-file tool. A symbolic link inside an allowed root can point
32
40
  // anywhere, so links are never followed and each collected path is resolved through
33
41
  // resolveSafePath again before a tool reads or writes it. `stat` follows links, which is exactly
34
42
  // how a symlinked directory inside a root used to expose files outside it.
35
43
  async function collectTree(root, { maxFiles = 500, skip = [] } = {}) {
36
44
  const found = [];
45
+ const denied = [];
37
46
  const skipName = name => skip.some(entry => (entry.endsWith('*') ? name.startsWith(entry.slice(0, -1)) : name === entry));
38
47
  async function visit(target) {
39
48
  if (found.length >= maxFiles) return;
40
49
  const info = await lstat(target).catch(() => null);
50
+ // A symlink is skipped on purpose (it can point outside the allowed roots); that is not a denial
51
+ // and must not be reported as one.
41
52
  if (!info || info.isSymbolicLink()) return;
42
53
  if (info.isFile()) { found.push(target); return; }
43
54
  if (!info.isDirectory()) return;
44
- const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
55
+ let entries;
56
+ try {
57
+ entries = await readdir(target, { withFileTypes: true });
58
+ } catch {
59
+ // Reporting the skip matters: silently dropping a directory made a partial walk look complete.
60
+ denied.push(target);
61
+ return;
62
+ }
45
63
  for (const entry of entries) {
46
64
  if (found.length >= maxFiles) return;
47
65
  if (entry.isSymbolicLink() || skipName(entry.name)) continue;
@@ -49,7 +67,7 @@ async function collectTree(root, { maxFiles = 500, skip = [] } = {}) {
49
67
  }
50
68
  }
51
69
  await visit(root);
52
- return found;
70
+ return { files: found, denied };
53
71
  }
54
72
 
55
73
  // Every path a multi-file tool is about to touch passes through the same confinement check as a
@@ -64,7 +82,7 @@ async function confineAll(paths) {
64
82
 
65
83
  async function readTextFile(absolute) {
66
84
  const info = await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`));
67
- if (info.isDirectory()) fail(`${displayPath(absolute)} is a directory, not a file`);
85
+ assertRegularFile(info, absolute);
68
86
  if (info.size > MAX_INLINE_FILE_BYTES) fail(`File is too large to read inline (${info.size} bytes)`);
69
87
  const buffer = await readFile(absolute);
70
88
  const decoded = decodeText(buffer);
@@ -116,8 +134,7 @@ export async function readMultipleFilesTool(args) {
116
134
 
117
135
  export async function readImageTool(args) {
118
136
  const absolute = await resolveSafePath(args.path);
119
- const info = await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`));
120
- if (info.isDirectory()) fail(`${displayPath(absolute)} is a directory, not an image`);
137
+ const info = assertRegularFile(await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`)), absolute);
121
138
  if (info.size > MAX_IMAGE_BYTES) fail(`Image is ${info.size} bytes, above the ${MAX_IMAGE_BYTES}-byte inline limit`);
122
139
  const mimeType = IMAGE_TYPES.get(path.extname(absolute).toLowerCase());
123
140
  if (!mimeType) fail(`${displayPath(absolute)} is not a supported image type (${[...IMAGE_TYPES.keys()].join(', ')})`);
@@ -134,8 +151,7 @@ export async function readImageTool(args) {
134
151
 
135
152
  export async function hashFileTool(args) {
136
153
  const absolute = await resolveSafePath(args.path);
137
- const info = await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`));
138
- if (info.isDirectory()) fail(`${displayPath(absolute)} is a directory, not a file`);
154
+ const info = assertRegularFile(await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`)), absolute);
139
155
  const algorithm = String(args.algorithm || 'sha256').toLowerCase();
140
156
  if (!['sha256', 'sha1', 'md5'].includes(algorithm)) fail('algorithm must be sha256, sha1, or md5');
141
157
  const hash = createHash(algorithm);
@@ -245,8 +261,7 @@ export async function writeFileTool(args) {
245
261
  // large file is read as a sequence of base64 slices and written back the same way.
246
262
  export async function readBinaryTool(args) {
247
263
  const absolute = await resolveSafePath(args.path);
248
- const info = await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`));
249
- if (info.isDirectory()) fail(`${displayPath(absolute)} is a directory, not a file`);
264
+ const info = assertRegularFile(await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`)), absolute);
250
265
  const offset = Math.max(0, Number.isFinite(Number(args.offset_bytes)) ? Math.trunc(Number(args.offset_bytes)) : 0);
251
266
  const length = clampInteger(args.length_bytes, MAX_BINARY_CHUNK_BYTES, 1, MAX_BINARY_CHUNK_BYTES);
252
267
  const start = Math.min(offset, info.size);
@@ -403,7 +418,8 @@ export async function replaceInFilesTool(args) {
403
418
  }
404
419
  }
405
420
  const info = await stat(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
406
- const files = await confineAll(info.isFile() ? [root] : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'] }));
421
+ const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'] });
422
+ const files = await confineAll(walk.files);
407
423
  const glob = filePattern ? globToRegExp(filePattern) : null;
408
424
  const changed = [];
409
425
  let scanned = 0;
@@ -495,16 +511,18 @@ export async function readFilesTool(args) {
495
511
  const matcher = globToRegExp(pattern);
496
512
  const rootInfo = await stat(root).catch(() => null);
497
513
  // 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, {
514
+ const walk = rootInfo?.isFile()
515
+ ? { files: [root], denied: [] }
516
+ : await collectTree(root, {
501
517
  maxFiles: maxFiles + 1,
502
518
  skip: includeIgnored ? ['.remcp-trash*'] : ['node_modules', '.git', '.remcp-trash*'],
503
- }));
504
- const files = candidates.filter(target => {
519
+ });
520
+ const candidates = await confineAll(walk.files);
521
+ const matched = candidates.filter(target => {
505
522
  const relative = path.relative(root, target) || path.basename(target);
506
523
  return matcher.test(relative.split(path.sep).join('/')) || matcher.test(path.basename(target));
507
- }).slice(0, maxFiles);
524
+ });
525
+ const files = matched.slice(0, maxFiles);
508
526
  if (!files.length) return text(`No files matched ${pattern} under ${displayPath(root)}.`);
509
527
  const sections = [];
510
528
  let skipped = 0;
@@ -520,7 +538,11 @@ export async function readFilesTool(args) {
520
538
  sections.push(`===== ${displayPath(file)} =====\n(skipped: ${error instanceof Error ? error.message : String(error)})`);
521
539
  }
522
540
  }
523
- const header = `${files.length} file(s) matched ${pattern} under ${displayPath(root)}${files.length > maxFiles ? ` (showing the first ${maxFiles})` : ''}${skipped ? `, ${skipped} skipped` : ''}`;
541
+ const notes = [];
542
+ if (matched.length > files.length) notes.push(`showing the first ${files.length}`);
543
+ if (skipped) notes.push(`${skipped} unreadable`);
544
+ if (walk.denied.length) notes.push(`${walk.denied.length} unreadable director${walk.denied.length === 1 ? 'y' : 'ies'} skipped`);
545
+ const header = `${matched.length} file(s) matched ${pattern} under ${displayPath(root)}${notes.length ? ` (${notes.join(', ')})` : ''}`;
524
546
  return text(`${header}\n\n${sections.join('\n\n')}`);
525
547
  }
526
548
 
@@ -723,16 +745,20 @@ export async function setPermissionsTool(args) {
723
745
  }
724
746
  const confined = await confineAll(targets);
725
747
  let changed = 0;
748
+ const failures = [];
726
749
  for (const target of confined) {
727
750
  try {
728
751
  await chmod(target, mode);
729
752
  if (uid !== null || gid !== null) await chown(target, uid ?? -1, gid ?? -1);
730
753
  changed += 1;
731
754
  } catch (error) {
732
- fail(`Could not change permissions on ${displayPath(target)}: ${error instanceof Error ? error.message : String(error)}`);
755
+ // One protected file must not abort a recursive change; the caller gets the full picture.
756
+ failures.push(`${displayPath(target)}: ${error instanceof Error ? error.message : String(error)}`);
733
757
  }
734
758
  }
735
- return text(`Set mode ${raw}${uid !== null || gid !== null ? ` (uid ${uid ?? '-'} gid ${gid ?? '-'})` : ''} on ${changed} path(s) starting at ${displayPath(absolute)}.`);
759
+ const summary = `Set mode ${raw}${uid !== null || gid !== null ? ` (uid ${uid ?? '-'} gid ${gid ?? '-'})` : ''} on ${changed} path(s) starting at ${displayPath(absolute)}.`;
760
+ if (!failures.length) return text(summary);
761
+ return text(`${summary}\n${failures.length} path(s) could not be changed:\n${failures.slice(0, 20).join('\n')}`, true);
736
762
  }
737
763
 
738
764
  export async function createDirectoryTool(args) {
package/src/util.mjs CHANGED
@@ -103,13 +103,31 @@ export function displayPath(absolute) {
103
103
  return absolute.startsWith(home + path.sep) ? `~/${absolute.slice(home.length + 1)}` : absolute;
104
104
  }
105
105
 
106
+ // The transport measures the serialised frame, not the raw string: a control character becomes six
107
+ // bytes once JSON-escaped, so an ANSI-heavy command output could pass this check and still exceed
108
+ // the stdio/relay frame limit, which closes the connection and restarts the runtime.
109
+ function frameBytes(text) {
110
+ return Buffer.byteLength(JSON.stringify(String(text)), 'utf8');
111
+ }
112
+
106
113
  export function truncate(text, maxBytes) {
107
114
  const limit = maxBytes || runtimeConfig.maxOutputBytes;
108
- const buffer = Buffer.from(text, 'utf8');
109
- if (buffer.length <= limit) return text;
110
- const head = buffer.subarray(0, Math.floor(limit * 0.7)).toString('utf8');
111
- const tail = buffer.subarray(buffer.length - Math.floor(limit * 0.2)).toString('utf8');
112
- return `${head}\n… output truncated (${buffer.length} bytes, limit ${limit}) …\n${tail}`;
115
+ const value = String(text);
116
+ if (Buffer.byteLength(value, 'utf8') <= limit && frameBytes(value) <= limit) return value;
117
+ // Shrink until the escaped frame fits, so the escaped size is what the caller gets is bounded.
118
+ let size = Math.min(Buffer.byteLength(value, 'utf8'), limit);
119
+ let rendered = '';
120
+ for (let attempt = 0; attempt < 8; attempt += 1) {
121
+ const buffer = Buffer.from(value, 'utf8');
122
+ const head = buffer.subarray(0, Math.max(0, Math.floor(size * 0.7))).toString('utf8');
123
+ const tail = buffer.subarray(Math.max(0, buffer.length - Math.floor(size * 0.2))).toString('utf8');
124
+ rendered = `${head}\n… output truncated (${buffer.length} bytes, limit ${limit}) …\n${tail}`;
125
+ if (frameBytes(rendered) <= limit) return rendered;
126
+ size = Math.floor(size * 0.6);
127
+ if (size < 512) break;
128
+ }
129
+ const buffer = Buffer.from(value, 'utf8');
130
+ return `${buffer.subarray(0, 256).toString('utf8')}\n… output truncated (${buffer.length} bytes, limit ${limit}) …`;
113
131
  }
114
132
 
115
133
  // structuredContent mirrors the text so a client can rely on the declared outputSchema, but the
@@ -196,12 +214,20 @@ export function globToRegExp(pattern) {
196
214
  if (char === '?') { out += '[^/]'; continue; }
197
215
  if (char === '[') {
198
216
  const close = source.indexOf(']', index + 1);
199
- if (close > index + 1) {
217
+ if (close > index + 1 && close - index <= 64) {
200
218
  let body = source.slice(index + 1, close);
201
219
  const negated = body.startsWith('!') || body.startsWith('^');
202
220
  if (negated) body = body.slice(1);
203
221
  body = body.replace(/\\/g, '\\\\').replace(/\]/g, '\\]').replace(/\^/g, '\\^');
204
- out += `[${negated ? '^/' : ''}${body}]`;
222
+ const candidate = `[${negated ? '^/' : ''}${body}]`;
223
+ // A class like [z-a] is not a valid range: the pattern falls back to a literal match instead
224
+ // of throwing out of the tool, because a user pattern must never break a call.
225
+ try {
226
+ new RegExp(candidate);
227
+ out += candidate;
228
+ } catch {
229
+ out += `\\${char}`;
230
+ }
205
231
  index = close;
206
232
  continue;
207
233
  }