@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 +17 -0
- package/package.json +1 -1
- package/src/patch.mjs +35 -3
- package/src/tools/files.mjs +45 -19
- package/src/util.mjs +33 -7
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
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)
|
|
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)
|
|
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 === ''
|
|
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
|
|
package/src/tools/files.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
499
|
-
? [root]
|
|
500
|
-
: await
|
|
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
|
|
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
|
-
})
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
}
|