@remcp/runtime 0.2.108 → 0.2.110

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.
@@ -12,7 +12,7 @@ import { describeFilesystemFailure } from '../permissions.mjs';
12
12
  import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
13
13
  import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
14
14
  import { countEvent, recordEvent } from '../telemetry.mjs';
15
- import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, looksBinary, multi, pageLines, resolveSafePath, splitLines, text } from '../util.mjs';
15
+ import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, isInsideRoot, looksBinary, multi, pageLines, resolveSafePath, splitLines, text, throwIfCancelled } from '../util.mjs';
16
16
 
17
17
  const MAX_INLINE_FILE_BYTES = 20 * 1024 * 1024;
18
18
  // An image travels base64-encoded, which costs a third more bytes. The agent's stdio transport holds
@@ -49,7 +49,158 @@ const WRITE_CREATE_NOFOLLOW = constants.O_WRONLY | constants.O_CREAT | constants
49
49
  const WRITE_TRUNCATE_NOFOLLOW = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | NOFOLLOW | NONBLOCK;
50
50
  const DIRECTORY_READ_NOFOLLOW = constants.O_RDONLY | (constants.O_DIRECTORY || 0) | NOFOLLOW | NONBLOCK;
51
51
 
52
- async function openDirectoryPath(absolute, { create = false } = {}) {
52
+ async function openDirectoryFromFilesystemRoot(absolute) {
53
+ const root = path.parse(absolute).root;
54
+ const parts = absolute.slice(root.length).split(path.sep).filter(Boolean);
55
+ const directories = [];
56
+ let current = root;
57
+ const closeDirectories = async () => {
58
+ for (const handle of directories.reverse()) await handle.close().catch(() => {});
59
+ };
60
+ try {
61
+ const rootHandle = await open(root, DIRECTORY_READ_NOFOLLOW);
62
+ directories.push(rootHandle);
63
+ current = `/proc/self/fd/${rootHandle.fd}`;
64
+ for (const part of parts) {
65
+ const candidate = path.join(current, part);
66
+ const next = await open(candidate, DIRECTORY_READ_NOFOLLOW);
67
+ try {
68
+ const info = await next.stat();
69
+ if (!info.isDirectory()) fail('Filesystem path component is not a directory');
70
+ directories.push(next);
71
+ current = `/proc/self/fd/${next.fd}`;
72
+ } catch (error) {
73
+ await next.close().catch(() => {});
74
+ throw error;
75
+ }
76
+ }
77
+ const handle = directories.pop();
78
+ return {
79
+ handle,
80
+ anchor: current,
81
+ close: async () => {
82
+ try { await handle.close(); }
83
+ finally { await closeDirectories(); }
84
+ },
85
+ };
86
+ } catch (error) {
87
+ await closeDirectories();
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ const secureRootPromises = new Map();
93
+
94
+ async function secureAllowedRoot(root) {
95
+ if (!secureRootPromises.has(root)) {
96
+ secureRootPromises.set(root, openDirectoryFromFilesystemRoot(root));
97
+ }
98
+ try {
99
+ return await secureRootPromises.get(root);
100
+ } catch (error) {
101
+ secureRootPromises.delete(root);
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ async function cloneDirectory(directory) {
107
+ const handle = await open(`${directory.anchor}/.`, DIRECTORY_READ_NOFOLLOW);
108
+ return {
109
+ handle,
110
+ anchor: `/proc/self/fd/${handle.fd}`,
111
+ close: () => handle.close(),
112
+ };
113
+ }
114
+
115
+ async function openConfinedDirectoryPath(absolute, create) {
116
+ if (!runtimeConfig.allowedRoots.length || absolute.startsWith('/proc/')) return null;
117
+ const root = runtimeConfig.allowedRoots.find(candidate => isInsideRoot(absolute, candidate));
118
+ if (!root) return null;
119
+ const relative = path.relative(root, absolute);
120
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
121
+ const rootDirectory = await secureAllowedRoot(root);
122
+ const directories = [];
123
+ let current = rootDirectory.anchor;
124
+ const closeDirectories = async () => {
125
+ for (const handle of directories.reverse()) await handle.close().catch(() => {});
126
+ };
127
+ try {
128
+ for (const part of relative.split(path.sep).filter(Boolean)) {
129
+ const candidate = path.join(current, part);
130
+ if (create) {
131
+ await mkdir(candidate, { mode:0o700 }).catch(error => {
132
+ if (error?.code !== 'EEXIST') throw error;
133
+ });
134
+ }
135
+ const handle = await open(candidate, DIRECTORY_READ_NOFOLLOW);
136
+ const info = await handle.stat();
137
+ if (!info.isDirectory()) {
138
+ await handle.close().catch(() => {});
139
+ fail('Filesystem path component is not a directory');
140
+ }
141
+ directories.push(handle);
142
+ current = `/proc/self/fd/${handle.fd}`;
143
+ }
144
+ if (!directories.length) return cloneDirectory(rootDirectory);
145
+ const handle = directories.pop();
146
+ return {
147
+ handle,
148
+ anchor: current,
149
+ close: async () => {
150
+ try { await handle.close(); }
151
+ finally { await closeDirectories(); }
152
+ },
153
+ };
154
+ } catch (error) {
155
+ await closeDirectories();
156
+ throw error;
157
+ }
158
+ }
159
+
160
+ async function openDescriptorDirectoryPath(absolute, create) {
161
+ const match = absolute.match(/^(\/proc\/(?:self|\d+)\/fd\/\d+)(?:\/(.*))?$/);
162
+ if (!match) return null;
163
+ // The proc-fd entry itself is a symlink. Make it an intermediate component so
164
+ // O_NOFOLLOW protects the referenced directory entry rather than rejecting procfs.
165
+ const base = await open(`${match[1]}/.`, DIRECTORY_READ_NOFOLLOW);
166
+ const directories = [base];
167
+ let current = `/proc/self/fd/${base.fd}`;
168
+ const closeDirectories = async () => {
169
+ for (const handle of directories.reverse()) await handle.close().catch(() => {});
170
+ };
171
+ try {
172
+ for (const part of (match[2] || '').split(path.sep).filter(Boolean)) {
173
+ const candidate = path.join(current, part);
174
+ if (create) {
175
+ await mkdir(candidate, { mode:0o700 }).catch(error => {
176
+ if (error?.code !== 'EEXIST') throw error;
177
+ });
178
+ }
179
+ const handle = await open(candidate, DIRECTORY_READ_NOFOLLOW);
180
+ const info = await handle.stat();
181
+ if (!info.isDirectory()) {
182
+ await handle.close().catch(() => {});
183
+ fail('Filesystem path component is not a directory');
184
+ }
185
+ directories.push(handle);
186
+ current = `/proc/self/fd/${handle.fd}`;
187
+ }
188
+ const handle = directories.pop();
189
+ return {
190
+ handle,
191
+ anchor: current,
192
+ close: async () => {
193
+ try { await handle.close(); }
194
+ finally { await closeDirectories(); }
195
+ },
196
+ };
197
+ } catch (error) {
198
+ await closeDirectories();
199
+ throw error;
200
+ }
201
+ }
202
+
203
+ export async function openDirectoryPath(absolute, { create = false, allowOutside = false } = {}) {
53
204
  const descriptorBound = process.platform === 'linux' && Boolean(constants.O_NOFOLLOW) && Boolean(constants.O_DIRECTORY);
54
205
  if (!descriptorBound) {
55
206
  if (create) await mkdir(absolute, { recursive:true, mode:0o700 });
@@ -62,10 +213,11 @@ async function openDirectoryPath(absolute, { create = false } = {}) {
62
213
  };
63
214
  return { handle, anchor:absolute, descriptorBound:false, close: handle.close };
64
215
  }
65
- if (absolute.startsWith('/proc/self/fd/')) {
66
- const handle = await open(absolute, DIRECTORY_READ_NOFOLLOW);
67
- return { handle, anchor: `/proc/self/fd/${handle.fd}`, close: () => handle.close() };
68
- }
216
+ const confinedPath = await openConfinedDirectoryPath(absolute, create);
217
+ if (confinedPath) return confinedPath;
218
+ const descriptorPath = await openDescriptorDirectoryPath(absolute, create);
219
+ if (descriptorPath) return descriptorPath;
220
+ if (runtimeConfig.allowedRoots.length && !allowOutside) fail('Path is outside the directories this device allows');
69
221
  const root = path.parse(absolute).root;
70
222
  const parts = absolute.slice(root.length).split(path.sep).filter(Boolean);
71
223
  if (!parts.length) {
@@ -109,20 +261,62 @@ async function openDirectoryPath(absolute, { create = false } = {}) {
109
261
  }
110
262
  }
111
263
 
112
- async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
264
+ async function openEntryAtPath(absolute, flags) {
265
+ const descriptorFile = /^\/proc\/(?:self|\d+)\/fd\/\d+$/.test(absolute);
266
+ if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY && !descriptorFile) {
267
+ const parent = await openDirectoryPath(path.dirname(absolute));
268
+ try {
269
+ const handle = await open(path.join(parent.anchor, path.basename(absolute)), flags);
270
+ return {
271
+ handle,
272
+ close: async () => {
273
+ try { await handle.close(); }
274
+ finally { await parent.close().catch(() => {}); }
275
+ },
276
+ };
277
+ } catch (error) {
278
+ await parent.close().catch(() => {});
279
+ throw error;
280
+ }
281
+ }
113
282
  const handle = await open(absolute, flags);
283
+ return { handle, close: () => handle.close() };
284
+ }
285
+
286
+ // A hard link inside an allowed root is the one escape path-based confinement cannot see: the very
287
+ // same inode is reachable from outside the root, so a read or write here is visible there (round 2
288
+ // audit R2-14). The allowlist is a guardrail, not an inode boundary, so this warns instead of
289
+ // refusing — refusing would break legitimate layouts (backups, dotfile managers, deduplicated
290
+ // stores). One warning per path keeps a hot loop from flooding the log.
291
+ const hardlinkWarned = new Set();
292
+
293
+ function warnOnHardlink(label, info) {
294
+ if (!Number.isFinite(info?.nlink) || info.nlink <= 1 || hardlinkWarned.has(label)) return;
295
+ if (hardlinkWarned.size > 1000) hardlinkWarned.clear();
296
+ hardlinkWarned.add(label);
297
+ countEvent('hardlinkWarnings');
298
+ console.warn(`ReMCP runtime: ${label} has ${info.nlink} hard links, so the same file is reachable from outside the allowed roots`);
299
+ }
300
+
301
+ async function openRegularFileAtPath(absolute, flags, label = absolute) {
302
+ const opened = await openEntryAtPath(absolute, flags);
114
303
  try {
115
- const info = assertRegularFile(await handle.stat(), absolute);
116
- return { handle, info };
304
+ const info = assertRegularFile(await opened.handle.stat(), label);
305
+ warnOnHardlink(label, info);
306
+ return { handle: opened.handle, info, close: opened.close };
117
307
  } catch (error) {
118
- await handle.close().catch(() => {});
308
+ await opened.close().catch(() => {});
119
309
  throw error;
120
310
  }
121
311
  }
122
312
 
123
- async function openWritableParent(absolute) {
313
+ export async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
314
+ return openRegularFileAtPath(absolute, flags, absolute);
315
+ }
316
+
317
+ async function openWritableParent(absolute, options = {}) {
124
318
  if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
125
- return openDirectoryPath(absolute, { create:true });
319
+ return openDirectoryPath(absolute, { create:true, ...options });
126
320
  }
127
321
  await mkdir(absolute, { recursive:true });
128
322
  const info = await lstat(absolute);
@@ -154,9 +348,27 @@ async function withWritableParent(absolute, callback) {
154
348
  }
155
349
  }
156
350
 
157
- async function ensureDirectoryPath(absolute) {
351
+ async function openExistingParent(absolute) {
158
352
  if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
159
- const directory = await openDirectoryPath(absolute, { create:true });
353
+ return openDirectoryPath(absolute);
354
+ }
355
+ const info = await lstat(absolute);
356
+ if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a directory');
357
+ return { anchor:absolute, close:async() => {} };
358
+ }
359
+
360
+ async function withExistingParent(absolute, callback) {
361
+ const parent = await openExistingParent(path.dirname(absolute));
362
+ try {
363
+ return await callback(path.join(parent.anchor, path.basename(absolute)));
364
+ } finally {
365
+ await parent.close().catch(() => {});
366
+ }
367
+ }
368
+
369
+ async function ensureDirectoryPath(absolute, options = {}) {
370
+ if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
371
+ const directory = await openDirectoryPath(absolute, { create:true, ...options });
160
372
  await directory.close();
161
373
  return;
162
374
  }
@@ -166,12 +378,13 @@ async function ensureDirectoryPath(absolute) {
166
378
  }
167
379
 
168
380
  async function readRegularBuffer(absolute, maxBytes = Infinity) {
169
- const { handle, info } = await openRegularFile(absolute);
381
+ const opened = await openRegularFile(absolute);
382
+ const { handle, info } = opened;
170
383
  try {
171
384
  if (info.size > maxBytes) fail(`File is too large to read inline (${info.size} bytes)`);
172
385
  return { info, buffer: await handle.readFile() };
173
386
  } finally {
174
- await handle.close();
387
+ await opened.close();
175
388
  }
176
389
  }
177
390
 
@@ -191,33 +404,72 @@ async function overwriteOpenFile(handle, content) {
191
404
  // anywhere, so links are never followed and each collected path is resolved through
192
405
  // resolveSafePath again before a tool reads or writes it. `stat` follows links, which is exactly
193
406
  // how a symlinked directory inside a root used to expose files outside it.
194
- async function collectTree(root, { maxFiles = 500, skip = [] } = {}) {
407
+ async function collectTree(root, { maxFiles = 500, skip = [], signal = null } = {}) {
195
408
  const found = [];
196
409
  const denied = [];
197
410
  const skipName = name => skip.some(entry => (entry.endsWith('*') ? name.startsWith(entry.slice(0, -1)) : name === entry));
198
- async function visit(target) {
199
- if (found.length >= maxFiles) return;
200
- const info = await lstat(target).catch(() => null);
201
- // A symlink is skipped on purpose (it can point outside the allowed roots); that is not a denial
202
- // and must not be reported as one.
203
- if (!info || info.isSymbolicLink()) return;
204
- if (info.isFile()) { found.push(target); return; }
205
- if (!info.isDirectory()) return;
411
+ const visitDirectory = async (directory, logicalBase) => {
206
412
  let entries;
207
413
  try {
208
- entries = await readdir(target, { withFileTypes: true });
414
+ entries = await readdir(directory.anchor, { withFileTypes: true });
209
415
  } catch {
210
- // Reporting the skip matters: silently dropping a directory made a partial walk look complete.
211
- denied.push(target);
416
+ denied.push(logicalBase);
212
417
  return;
213
418
  }
214
419
  for (const entry of entries) {
420
+ throwIfCancelled(signal);
215
421
  if (found.length >= maxFiles) return;
216
422
  if (entry.isSymbolicLink() || skipName(entry.name)) continue;
217
- await visit(path.join(target, entry.name));
423
+ const childLogical = path.join(logicalBase, entry.name);
424
+ const childAnchor = path.join(directory.anchor, entry.name);
425
+ if (entry.isDirectory()) {
426
+ let child;
427
+ try {
428
+ child = await openDirectoryPath(childAnchor);
429
+ const info = await child.handle.stat();
430
+ if (!info.isDirectory()) fail('Filesystem path component is not a directory');
431
+ await visitDirectory(child, childLogical);
432
+ } catch {
433
+ denied.push(childLogical);
434
+ } finally {
435
+ if (child) await child.close().catch(() => {});
436
+ }
437
+ } else if (entry.isFile()) {
438
+ let opened;
439
+ try {
440
+ opened = await openEntryAtPath(childAnchor, READ_NOFOLLOW_NONBLOCK);
441
+ const info = await opened.handle.stat();
442
+ if (info.isFile()) found.push(childLogical);
443
+ } catch {
444
+ denied.push(childLogical);
445
+ } finally {
446
+ if (opened) await opened.close().catch(() => {});
447
+ }
448
+ }
449
+ if (found.length >= maxFiles) return;
450
+ }
451
+ };
452
+ let directory;
453
+ try {
454
+ directory = await openDirectoryPath(root);
455
+ await visitDirectory(directory, root);
456
+ } catch {
457
+ // A cancelled walk must not degrade into the "root is a single file" fallback below.
458
+ throwIfCancelled(signal);
459
+ let opened;
460
+ try {
461
+ opened = await openEntryAtPath(root, READ_NOFOLLOW_NONBLOCK);
462
+ const info = await opened.handle.stat();
463
+ if (info.isFile()) found.push(root);
464
+ else denied.push(root);
465
+ } catch {
466
+ denied.push(root);
467
+ } finally {
468
+ if (opened) await opened.close().catch(() => {});
218
469
  }
470
+ } finally {
471
+ if (directory) await directory.close().catch(() => {});
219
472
  }
220
- await visit(root);
221
473
  return { files: found, denied };
222
474
  }
223
475
 
@@ -276,11 +528,12 @@ ${page.slice.join('\n')}`);
276
528
  return text(`${header}\n${slice.join('\n')}`);
277
529
  }
278
530
 
279
- export async function readMultipleFilesTool(args) {
531
+ export async function readMultipleFilesTool(args, extra = {}) {
280
532
  if (!Array.isArray(args.paths) || !args.paths.length) fail('paths must be a non-empty array');
281
533
  if (args.paths.length > 50) fail('paths accepts at most 50 entries per call');
282
534
  const sections = [];
283
535
  for (const entry of args.paths) {
536
+ throwIfCancelled(extra.signal);
284
537
  let absolute;
285
538
  try {
286
539
  absolute = await resolveSafePath(entry, 'paths[]');
@@ -334,37 +587,64 @@ export async function hashFileTool(args) {
334
587
  }
335
588
  const { handle, info } = opened;
336
589
  const algorithm = String(args.algorithm || 'sha256').toLowerCase();
337
- if (!['sha256', 'sha1', 'md5'].includes(algorithm)) { await handle.close(); fail('algorithm must be sha256, sha1, or md5'); }
590
+ if (!['sha256', 'sha1', 'md5'].includes(algorithm)) { await opened.close(); fail('algorithm must be sha256, sha1, or md5'); }
338
591
  const hash = createHash(algorithm);
339
592
  try {
340
593
  await pipeline(createReadStream(absolute, { fd: handle.fd, autoClose: false }), hash);
341
594
  return text(`${algorithm} ${hash.digest('hex')} ${displayPath(absolute)} (${info.size} bytes)`);
342
595
  } finally {
343
- await handle.close();
596
+ await opened.close();
344
597
  }
345
598
  }
346
599
 
347
- async function listEntry(base, depth, maxDepth, prefix, pattern) {
600
+ async function openListEntry(base, name) {
601
+ const opened = await openEntryAtPath(path.join(base, name), READ_NOFOLLOW_NONBLOCK);
602
+ try {
603
+ return { handle: opened.handle, info: await opened.handle.stat(), close: opened.close };
604
+ } catch (error) {
605
+ await opened.close().catch(() => {});
606
+ throw error;
607
+ }
608
+ }
609
+
610
+ async function listEntry(directory, depth, maxDepth, prefix, pattern, displayBase) {
348
611
  let entries;
349
612
  try {
350
- entries = await readdir(base, { withFileTypes: true });
613
+ entries = await readdir(directory.anchor, { withFileTypes: true });
351
614
  } catch (error) {
352
- // One unreadable subdirectory must not abort the whole listing.
353
- return [`${prefix}[DENIED] ${path.basename(base)} (${error instanceof Error ? error.code || error.message : 'unreadable'})`];
615
+ return [`${prefix}[DENIED] ${path.basename(displayBase)} (${error instanceof Error ? error.code || error.message : 'unreadable'})`];
354
616
  }
355
617
  entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
356
618
  const rows = [];
357
619
  for (const entry of entries) {
358
- const child = path.join(base, entry.name);
620
+ const childDisplay = path.join(displayBase, entry.name);
359
621
  if (entry.isDirectory()) {
360
622
  rows.push(`${prefix}[DIR] ${entry.name}`);
361
- if (depth < maxDepth) rows.push(...await listEntry(child, depth + 1, maxDepth, `${prefix} `.replace(/ {2}$/, '') + ' ', pattern));
623
+ if (depth >= maxDepth) continue;
624
+ let child;
625
+ try {
626
+ child = await openDirectoryPath(path.join(directory.anchor, entry.name));
627
+ const childInfo = await child.handle.stat();
628
+ if (!childInfo.isDirectory()) fail('Filesystem path component is not a directory');
629
+ rows.push(...await listEntry(child, depth + 1, maxDepth, `${prefix} `.replace(/ {2}$/, '') + ' ', pattern, childDisplay));
630
+ } catch (error) {
631
+ rows.push(`${prefix} [DENIED] ${entry.name} (${error instanceof Error ? error.code || error.message : 'unreadable'})`);
632
+ } finally {
633
+ if (child) await child.close().catch(() => {});
634
+ }
362
635
  } else if (entry.isSymbolicLink()) {
363
636
  rows.push(`${prefix}[LINK] ${entry.name}`);
364
637
  } else {
365
638
  if (pattern && !pattern.test(entry.name)) continue;
366
- const info = await stat(child).catch(() => null);
367
- rows.push(`${prefix}[FILE] ${entry.name}${info ? ` (${info.size} bytes)` : ''}`);
639
+ let opened;
640
+ try {
641
+ opened = await openListEntry(directory.anchor, entry.name);
642
+ rows.push(`${prefix}[${opened.info.isFile() ? 'FILE' : 'SPECIAL'}] ${entry.name}${opened.info.isFile() ? ` (${opened.info.size} bytes)` : ''}`);
643
+ } catch (error) {
644
+ rows.push(`${prefix}[DENIED] ${entry.name} (${error instanceof Error ? error.code || error.message : 'unreadable'})`);
645
+ } finally {
646
+ if (opened) await opened.close().catch(() => {});
647
+ }
368
648
  }
369
649
  }
370
650
  return rows;
@@ -372,39 +652,85 @@ async function listEntry(base, depth, maxDepth, prefix, pattern) {
372
652
 
373
653
  export async function listDirectoryTool(args) {
374
654
  const absolute = await resolveSafePath(args.path);
375
- const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
376
- if (!info.isDirectory()) fail(`${displayPath(absolute)} is not a directory`);
377
655
  const depth = clampInteger(args.depth, 1, 1, 5);
378
656
  const pattern = typeof args.pattern === 'string' && args.pattern.trim() ? globToRegExp(args.pattern.trim()) : null;
379
- const rows = await listEntry(absolute, 1, depth, '', pattern);
380
- return text(`${displayPath(absolute)}${pattern ? ` · matching ${args.pattern}` : ''}\n${rows.join('\n') || '(empty)'}`);
657
+ let directory;
658
+ try {
659
+ directory = await openDirectoryPath(absolute);
660
+ const info = await directory.handle.stat();
661
+ if (!info.isDirectory()) fail(`${displayPath(absolute)} is not a directory`);
662
+ const rows = await listEntry(directory, 1, depth, '', pattern, absolute);
663
+ return text(`${displayPath(absolute)}${pattern ? ` · matching ${args.pattern}` : ''}\n${rows.join('\n') || '(empty)'}`);
664
+ } catch (error) {
665
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') fail(`Path not found: ${displayPath(absolute)}`);
666
+ throw error;
667
+ } finally {
668
+ if (directory) await directory.close().catch(() => {});
669
+ }
670
+ }
671
+
672
+ async function openPathForInfo(absolute) {
673
+ let directory;
674
+ try {
675
+ directory = await openDirectoryPath(absolute);
676
+ return { info: await directory.handle.stat(), close: directory.close };
677
+ } catch (directoryError) {
678
+ const canTryFile = directoryError?.code === 'ENOTDIR'
679
+ || directoryError?.code === 'EISDIR'
680
+ || (!directoryError?.code && /not a directory/i.test(directoryError?.message || ''));
681
+ if (!canTryFile) throw directoryError;
682
+ }
683
+ const opened = await openEntryAtPath(absolute, READ_NOFOLLOW_NONBLOCK);
684
+ try {
685
+ return { info: await opened.handle.stat(), close: opened.close };
686
+ } catch (error) {
687
+ await opened.close().catch(() => {});
688
+ throw error;
689
+ }
690
+ }
691
+
692
+ async function getPathInfo(absolute) {
693
+ const opened = await openPathForInfo(absolute);
694
+ try { return opened.info; }
695
+ finally { await opened.close().catch(() => {}); }
381
696
  }
382
697
 
383
698
  export async function getFileInfoTool(args) {
384
699
  const absolute = await resolveSafePath(args.path);
385
- const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
386
- const payload = {
387
- path: displayPath(absolute),
388
- type: info.isDirectory() ? 'directory' : info.isSymbolicLink() ? 'symlink' : 'file',
389
- size: info.size,
390
- createdAt: info.birthtime.toISOString(),
391
- modifiedAt: info.mtime.toISOString(),
392
- permissions: `0${(info.mode & 0o777).toString(8)}`,
393
- };
394
- if (info.isFile() && info.size <= MAX_INLINE_FILE_BYTES) {
395
- const opened = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES).catch(() => null);
396
- if (opened) {
397
- const decoded = decodeText(opened.buffer);
398
- if (decoded.encoding !== 'utf8') payload.encoding = decoded.encoding;
399
- if (decoded.encoding !== 'utf8' || !looksBinary(opened.buffer)) {
400
- const lines = splitLines(decoded.text);
401
- payload.lineCount = lines.length;
402
- payload.lastLine = Math.max(0, lines.length - 1);
403
- payload.eol = detectEol(decoded.text) === '\r\n' ? 'CRLF' : 'LF';
700
+ let opened;
701
+ try {
702
+ opened = await openPathForInfo(absolute);
703
+ } catch (error) {
704
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') fail(`Path not found: ${displayPath(absolute)}`);
705
+ throw error;
706
+ }
707
+ const { info } = opened;
708
+ try {
709
+ const payload = {
710
+ path: displayPath(absolute),
711
+ type: info.isDirectory() ? 'directory' : info.isSymbolicLink() ? 'symlink' : info.isFile() ? 'file' : 'special',
712
+ size: info.size,
713
+ createdAt: info.birthtime.toISOString(),
714
+ modifiedAt: info.mtime.toISOString(),
715
+ permissions: `0${(info.mode & 0o777).toString(8)}`,
716
+ };
717
+ if (info.isFile() && info.size <= MAX_INLINE_FILE_BYTES) {
718
+ const regular = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES).catch(() => null);
719
+ if (regular) {
720
+ const decoded = decodeText(regular.buffer);
721
+ if (decoded.encoding !== 'utf8') payload.encoding = decoded.encoding;
722
+ if (decoded.encoding !== 'utf8' || !looksBinary(regular.buffer)) {
723
+ const lines = splitLines(decoded.text);
724
+ payload.lineCount = lines.length;
725
+ payload.lastLine = Math.max(0, lines.length - 1);
726
+ payload.eol = detectEol(decoded.text) === '\r\n' ? 'CRLF' : 'LF';
727
+ }
404
728
  }
405
729
  }
730
+ return text(JSON.stringify(payload, null, 2));
731
+ } finally {
732
+ await opened.close().catch(() => {});
406
733
  }
407
- return text(JSON.stringify(payload, null, 2));
408
734
  }
409
735
 
410
736
  function assertWritableSize(content) {
@@ -471,7 +797,7 @@ export async function readBinaryTool(args) {
471
797
  });
472
798
  return text(payload);
473
799
  } finally {
474
- await handle.close();
800
+ await opened.close();
475
801
  }
476
802
  }
477
803
 
@@ -589,7 +915,7 @@ export async function replaceLinesTool(args) {
589
915
  return text(`${summary}.`);
590
916
  }
591
917
 
592
- export async function replaceInFilesTool(args) {
918
+ export async function replaceInFilesTool(args, extra = {}) {
593
919
  const root = await resolveSafePath(args.path);
594
920
  const pattern = typeof args.pattern === 'string' && args.pattern ? args.pattern : fail('pattern is required');
595
921
  const replacement = typeof args.replacement === 'string' ? args.replacement : fail('replacement must be a string');
@@ -605,13 +931,14 @@ export async function replaceInFilesTool(args) {
605
931
  fail(`pattern is not a valid regular expression (${error instanceof Error ? error.message : String(error)})`);
606
932
  }
607
933
  }
608
- const info = await stat(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
609
- const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'] });
934
+ const info = await getPathInfo(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
935
+ const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'], signal: extra.signal });
610
936
  const files = await confineAll(walk.files);
611
937
  const glob = filePattern ? globToRegExp(filePattern) : null;
612
938
  const changed = [];
613
939
  let scanned = 0;
614
940
  for (const file of files) {
941
+ throwIfCancelled(extra.signal);
615
942
  if (changed.length >= maxFiles) break;
616
943
  if (glob && !glob.test(path.basename(file))) continue;
617
944
  let opened;
@@ -635,7 +962,7 @@ export async function replaceInFilesTool(args) {
635
962
  const stats = diffStats(original, updated);
636
963
  changed.push({ file: displayPath(file), replacements: count, added: stats.added, removed: stats.removed });
637
964
  } finally {
638
- await handle.close();
965
+ await opened.close();
639
966
  }
640
967
  }
641
968
  if (!changed.length) return text(`No matches for ${JSON.stringify(pattern)} in ${displayPath(root)} (${scanned} text files scanned).`);
@@ -665,7 +992,6 @@ function trashDirectoryFor() {
665
992
 
666
993
  export async function moveToTrashTool(args) {
667
994
  const source = await resolveSafePath(args.source, 'source');
668
- const info = await stat(source).catch(() => fail(`Path not found: ${displayPath(source)}`));
669
995
  const trash = trashDirectoryFor();
670
996
  let destination = null;
671
997
  if (trash) {
@@ -691,18 +1017,22 @@ export async function moveToTrashTool(args) {
691
1017
  target = path.join(destination, `${stamp}-${counter}-${path.basename(source)}`);
692
1018
  counter += 1;
693
1019
  }
694
- await withWritableParent(target, async anchoredTarget => {
695
- await rename(source, anchoredTarget).catch(async error => {
696
- if (error?.code !== 'EXDEV') throw error;
697
- if (info.isDirectory()) fail('Moving a directory to the trash across filesystems is not supported');
698
- await copyFile(source, anchoredTarget);
699
- await unlink(source);
1020
+ await withExistingParent(source, async anchoredSource => {
1021
+ const sourceInfo = await lstat(anchoredSource).catch(() => fail(`Path not found: ${displayPath(source)}`));
1022
+ if (sourceInfo.isSymbolicLink()) fail('source cannot be a symbolic link');
1023
+ await withWritableParent(target, async anchoredTarget => {
1024
+ await rename(anchoredSource, anchoredTarget).catch(async error => {
1025
+ if (error?.code !== 'EXDEV') throw error;
1026
+ if (sourceInfo.isDirectory()) fail('Moving a directory to the trash across filesystems is not supported');
1027
+ await copyTreeContents(anchoredSource, anchoredTarget, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
1028
+ await rm(anchoredSource, { force:true });
1029
+ });
700
1030
  });
701
1031
  });
702
1032
  return text(`Moved ${displayPath(source)} to ${displayPath(target)}. Restore it with move_file if this was a mistake.`);
703
1033
  }
704
1034
 
705
- export async function readFilesTool(args) {
1035
+ export async function readFilesTool(args, extra = {}) {
706
1036
  // Glob-first bulk read: one call fills the model's context with every file that matters
707
1037
  // instead of one round trip per path.
708
1038
  const root = await resolveSafePath(args.path || '.');
@@ -711,13 +1041,14 @@ export async function readFilesTool(args) {
711
1041
  const maxLinesPerFile = clampInteger(args.max_lines_per_file, liveConfig('maxReadLines'), 1, 20000);
712
1042
  const includeIgnored = args.include_ignored === true;
713
1043
  const matcher = globToRegExp(pattern);
714
- const rootInfo = await stat(root).catch(() => null);
1044
+ const rootInfo = await getPathInfo(root).catch(() => null);
715
1045
  // A file path is matched directly; a directory is walked without following links.
716
1046
  const walk = rootInfo?.isFile()
717
1047
  ? { files: [root], denied: [] }
718
1048
  : await collectTree(root, {
719
1049
  maxFiles: maxFiles + 1,
720
1050
  skip: includeIgnored ? ['.remcp-trash*'] : ['node_modules', '.git', '.remcp-trash*'],
1051
+ signal: extra.signal,
721
1052
  });
722
1053
  const candidates = await confineAll(walk.files);
723
1054
  const matched = candidates.filter(target => {
@@ -729,6 +1060,7 @@ export async function readFilesTool(args) {
729
1060
  const sections = [];
730
1061
  let skipped = 0;
731
1062
  for (const file of files.slice(0, maxFiles)) {
1063
+ throwIfCancelled(extra.signal);
732
1064
  try {
733
1065
  const { content, encoding } = await readTextFile(file);
734
1066
  const lines = splitLines(content);
@@ -748,7 +1080,7 @@ export async function readFilesTool(args) {
748
1080
  return text(`${header}\n\n${sections.join('\n\n')}`);
749
1081
  }
750
1082
 
751
- export async function writeFilesTool(args) {
1083
+ export async function writeFilesTool(args, extra = {}) {
752
1084
  // Bulk write for scaffolding: one call creates or replaces many files.
753
1085
  const files = Array.isArray(args.files) ? args.files : fail('files must be an array of { path, content } objects');
754
1086
  if (!files.length) fail('files must not be empty');
@@ -756,6 +1088,7 @@ export async function writeFilesTool(args) {
756
1088
  const results = [];
757
1089
  let totalBytes = 0;
758
1090
  for (const entry of files) {
1091
+ throwIfCancelled(extra.signal);
759
1092
  const target = typeof entry?.path === 'string' ? entry.path : null;
760
1093
  if (!target) { results.push('skipped: entry without a path'); continue; }
761
1094
  if (typeof entry.content !== 'string') { results.push(`skipped ${target}: content must be a string`); continue; }
@@ -779,19 +1112,23 @@ export async function writeFilesTool(args) {
779
1112
 
780
1113
  export async function deletePathTool(args) {
781
1114
  const absolute = await resolveSafePath(args.path);
782
- const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
783
1115
  if (path.dirname(absolute) === absolute) fail(`Refusing to delete the filesystem root ${displayPath(absolute)}`);
784
1116
  const recursive = args.recursive !== false;
785
- if (info.isDirectory() && !recursive) {
786
- const entries = await readdir(absolute).catch(() => []);
787
- if (entries.length) fail(`Directory is not empty: ${displayPath(absolute)}. Pass recursive: true to delete it with its contents.`);
788
- }
789
- const entries = info.isDirectory() ? await readdir(absolute).catch(() => []) : [];
790
- await rm(absolute, { recursive: true, force: false });
1117
+ let info;
1118
+ let entries = [];
1119
+ await withExistingParent(absolute, async target => {
1120
+ info = await lstat(target).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
1121
+ if (info.isDirectory() && !recursive) {
1122
+ entries = await readdir(target).catch(() => []);
1123
+ if (entries.length) fail(`Directory is not empty: ${displayPath(absolute)}. Pass recursive: true to delete it with its contents.`);
1124
+ }
1125
+ if (info.isDirectory()) entries = await readdir(target).catch(() => []);
1126
+ await rm(target, { recursive:true, force:false });
1127
+ });
791
1128
  return text(`Deleted ${info.isDirectory() ? 'directory' : 'file'} ${displayPath(absolute)}${info.isDirectory() ? ` and its ${entries.length} top-level entr${entries.length === 1 ? 'y' : 'ies'}` : ''}.`);
792
1129
  }
793
1130
 
794
- export async function deletePathsTool(args) {
1131
+ export async function deletePathsTool(args, extra = {}) {
795
1132
  const paths = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of absolute paths');
796
1133
  if (!paths.length) fail('paths must not be empty');
797
1134
  if (paths.length > 500) fail('paths accepts at most 500 entries per call');
@@ -799,16 +1136,19 @@ export async function deletePathsTool(args) {
799
1136
  const results = [];
800
1137
  let deleted = 0;
801
1138
  for (const entry of paths) {
1139
+ throwIfCancelled(extra.signal);
802
1140
  try {
803
1141
  const absolute = await resolveSafePath(entry, 'paths[]');
804
1142
  if (path.dirname(absolute) === absolute) throw new Error('refusing to delete the filesystem root');
805
- const info = await stat(absolute).catch(() => null);
806
- if (!info) throw new Error('not found');
807
- if (info.isDirectory() && !recursive) {
808
- const children = await readdir(absolute).catch(() => []);
809
- if (children.length) throw new Error('directory is not empty (pass recursive: true)');
810
- }
811
- await rm(absolute, { recursive: true, force: false });
1143
+ await withExistingParent(absolute, async target => {
1144
+ const info = await lstat(target).catch(() => null);
1145
+ if (!info) throw new Error('not found');
1146
+ if (info.isDirectory() && !recursive) {
1147
+ const children = await readdir(target).catch(() => []);
1148
+ if (children.length) throw new Error('directory is not empty (pass recursive: true)');
1149
+ }
1150
+ await rm(target, { recursive:true, force:false });
1151
+ });
812
1152
  deleted += 1;
813
1153
  results.push(`deleted ${displayPath(absolute)}`);
814
1154
  } catch (error) {
@@ -820,7 +1160,7 @@ export async function deletePathsTool(args) {
820
1160
 
821
1161
  // Recursive copy for files and whole directories, so a project or a backup can be
822
1162
  // duplicated in one call.
823
- export async function copyPathsTool(args) {
1163
+ export async function copyPathsTool(args, extra = {}) {
824
1164
  const pairs = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of { source, destination } objects');
825
1165
  if (!pairs.length) fail('paths must not be empty');
826
1166
  if (pairs.length > 200) fail('paths accepts at most 200 entries per call');
@@ -828,15 +1168,16 @@ export async function copyPathsTool(args) {
828
1168
  const results = [];
829
1169
  let copied = 0;
830
1170
  for (const entry of pairs) {
1171
+ throwIfCancelled(extra.signal);
831
1172
  try {
832
1173
  const source = await resolveSafePath(entry?.source, 'paths[].source');
833
1174
  const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
834
1175
  if (source === destination) throw new Error('source and destination are the same path');
835
- const info = await stat(source).catch(() => null);
836
- if (!info) throw new Error('source not found');
837
- const existing = await stat(destination).catch(() => null);
838
- if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
839
- await withWritableParent(destination, anchoredDestination => cp(source, anchoredDestination, { recursive: true, force: overwrite, errorOnExist: !overwrite }));
1176
+ await withWritableParent(destination, async anchoredDestination => {
1177
+ const existing = await lstat(anchoredDestination).catch(() => null);
1178
+ if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
1179
+ await copyTreeContents(source, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER, signal: extra.signal });
1180
+ });
840
1181
  copied += 1;
841
1182
  results.push(`copied ${displayPath(source)} → ${displayPath(destination)}`);
842
1183
  } catch (error) {
@@ -846,7 +1187,7 @@ export async function copyPathsTool(args) {
846
1187
  return text(`${copied}/${pairs.length} path(s) copied\n${results.join('\n')}`, copied !== pairs.length);
847
1188
  }
848
1189
 
849
- export async function movePathsTool(args) {
1190
+ export async function movePathsTool(args, extra = {}) {
850
1191
  const pairs = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of { source, destination } objects');
851
1192
  if (!pairs.length) fail('paths must not be empty');
852
1193
  if (pairs.length > 200) fail('paths accepts at most 200 entries per call');
@@ -854,22 +1195,26 @@ export async function movePathsTool(args) {
854
1195
  const results = [];
855
1196
  let moved = 0;
856
1197
  for (const entry of pairs) {
1198
+ throwIfCancelled(extra.signal);
857
1199
  try {
858
1200
  const source = await resolveSafePath(entry?.source, 'paths[].source');
859
1201
  const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
860
1202
  if (source === destination) throw new Error('source and destination are the same path');
861
- const info = await stat(source).catch(() => null);
862
- if (!info) throw new Error('source not found');
863
- const existing = await stat(destination).catch(() => null);
864
- if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
865
- await withWritableParent(destination, async anchoredDestination => {
866
- try {
867
- await rename(source, anchoredDestination);
868
- } catch (error) {
869
- if (error?.code !== 'EXDEV') throw error;
870
- await cp(source, anchoredDestination, { recursive: true, force: overwrite, errorOnExist: !overwrite });
871
- await rm(source, { recursive: true, force: true });
872
- }
1203
+ await withExistingParent(source, async anchoredSource => {
1204
+ const sourceInfo = await lstat(anchoredSource).catch(() => null);
1205
+ if (!sourceInfo) throw new Error('source not found');
1206
+ if (sourceInfo.isSymbolicLink()) throw new Error('source cannot be a symbolic link');
1207
+ await withWritableParent(destination, async anchoredDestination => {
1208
+ const existing = await lstat(anchoredDestination).catch(() => null);
1209
+ if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
1210
+ try {
1211
+ await rename(anchoredSource, anchoredDestination);
1212
+ } catch (error) {
1213
+ if (error?.code !== 'EXDEV') throw error;
1214
+ await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER, signal: extra.signal });
1215
+ await rm(anchoredSource, { recursive:true, force:true });
1216
+ }
1217
+ });
873
1218
  });
874
1219
  moved += 1;
875
1220
  results.push(`moved ${displayPath(source)} → ${displayPath(destination)}`);
@@ -919,41 +1264,86 @@ export async function applyPatchTool(args) {
919
1264
  return text([`${changed}/${files.length} file(s) ${dryRun ? 'would be patched' : 'patched'}`, ...results].join('\n'), failedCount > 0);
920
1265
  }
921
1266
 
1267
+ async function setLinuxPermissions(target, mode, uid, gid, recursive, displayTarget, failures) {
1268
+ let directory = null;
1269
+ let file = null;
1270
+ try {
1271
+ const info = await lstat(target);
1272
+ if (info.isSymbolicLink()) {
1273
+ failures.push(`${displayTarget}: symbolic links are not changed`);
1274
+ return 0;
1275
+ }
1276
+ if (info.isDirectory()) {
1277
+ directory = await openDirectoryPath(target);
1278
+ let changed = 0;
1279
+ if (recursive) {
1280
+ for (const entry of await readdir(directory.anchor, { withFileTypes:true })) {
1281
+ if (entry.isSymbolicLink()) continue;
1282
+ changed += await setLinuxPermissions(path.join(directory.anchor, entry.name), mode, uid, gid, true, path.join(displayTarget, entry.name), failures);
1283
+ }
1284
+ }
1285
+ await directory.handle.chmod(mode);
1286
+ if (uid !== null || gid !== null) await directory.handle.chown(uid ?? -1, gid ?? -1);
1287
+ return changed + 1;
1288
+ }
1289
+ if (!info.isFile()) {
1290
+ failures.push(`${displayTarget}: special files are not changed`);
1291
+ return 0;
1292
+ }
1293
+ file = await openRegularFile(target, READ_NOFOLLOW);
1294
+ await file.handle.chmod(mode);
1295
+ if (uid !== null || gid !== null) await file.handle.chown(uid ?? -1, gid ?? -1);
1296
+ return 1;
1297
+ } catch (error) {
1298
+ failures.push(`${displayTarget}: ${describeFilesystemFailure(error, { path:error?.path })}`);
1299
+ return 0;
1300
+ } finally {
1301
+ if (file) await file.close().catch(() => {});
1302
+ if (directory) await directory.close().catch(() => {});
1303
+ }
1304
+ }
1305
+
922
1306
  export async function setPermissionsTool(args) {
923
1307
  const absolute = await resolveSafePath(args.path);
924
- const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
925
1308
  const raw = typeof args.mode === 'string' ? args.mode.trim() : String(args.mode ?? '');
926
1309
  if (!/^[0-7]{3,4}$/.test(raw)) fail('mode must be an octal string such as "755" or "0644"');
927
1310
  const mode = Number.parseInt(raw, 8);
928
1311
  const recursive = args.recursive === true;
929
1312
  const uid = Number.isInteger(Number(args.uid)) ? Number(args.uid) : null;
930
1313
  const gid = Number.isInteger(Number(args.gid)) ? Number(args.gid) : null;
931
- const targets = [absolute];
932
- if (recursive) {
933
- // Directories are included, links are not: chmod follows a link and would change a target
934
- // outside the allowed roots.
935
- const walk = async target => {
936
- const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
937
- for (const entry of entries) {
938
- if (entry.isSymbolicLink()) continue;
939
- const child = path.join(target, entry.name);
940
- targets.push(child);
941
- if (entry.isDirectory()) await walk(child);
942
- }
943
- };
944
- await walk(absolute);
945
- }
946
- const confined = await confineAll(targets);
947
- let changed = 0;
948
1314
  const failures = [];
949
- for (const target of confined) {
950
- try {
951
- await chmod(target, mode);
952
- if (uid !== null || gid !== null) await chown(target, uid ?? -1, gid ?? -1);
953
- changed += 1;
954
- } catch (error) {
955
- // One protected file must not abort a recursive change; the caller gets the full picture.
956
- failures.push(`${displayPath(target)}: ${describeFilesystemFailure(error, { path: error?.path })}`);
1315
+ let changed = 0;
1316
+ if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
1317
+ changed = await setLinuxPermissions(absolute, mode, uid, gid, recursive, displayPath(absolute), failures);
1318
+ } else {
1319
+ // macOS/Windows fallback: Node exposes no directory-fd-relative chmod/chown there, so this
1320
+ // branch re-resolves each path lexically (confineAll) and then acts on the path. Between those
1321
+ // two steps another process can swap a component, so confinement is a guardrail on this
1322
+ // platform rather than a descriptor-bound guarantee — the same residual the archive/document
1323
+ // staging path documents. Linux uses the descriptor-bound path above.
1324
+ const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
1325
+ const targets = [absolute];
1326
+ if (recursive && info.isDirectory()) {
1327
+ const walk = async target => {
1328
+ const entries = await readdir(target, { withFileTypes:true }).catch(() => []);
1329
+ for (const entry of entries) {
1330
+ if (entry.isSymbolicLink()) continue;
1331
+ const child = path.join(target, entry.name);
1332
+ targets.push(child);
1333
+ if (entry.isDirectory()) await walk(child);
1334
+ }
1335
+ };
1336
+ await walk(absolute);
1337
+ }
1338
+ const confined = await confineAll(targets);
1339
+ for (const target of confined) {
1340
+ try {
1341
+ await chmod(target, mode);
1342
+ if (uid !== null || gid !== null) await chown(target, uid ?? -1, gid ?? -1);
1343
+ changed += 1;
1344
+ } catch (error) {
1345
+ failures.push(`${displayPath(target)}: ${describeFilesystemFailure(error, { path:error?.path })}`);
1346
+ }
957
1347
  }
958
1348
  }
959
1349
  const summary = `Set mode ${raw}${uid !== null || gid !== null ? ` (uid ${uid ?? '-'} gid ${gid ?? '-'})` : ''} on ${changed} path(s) starting at ${displayPath(absolute)}.`;
@@ -988,24 +1378,27 @@ export async function moveFileTool(args) {
988
1378
  const source = await resolveSafePath(args.source, 'source');
989
1379
  const destination = await resolveSafePath(args.destination, 'destination');
990
1380
  if (source === destination) fail('source and destination are the same path');
991
- await stat(source).catch(() => fail(`Source not found: ${displayPath(source)}`));
992
1381
  const overwrite = args.overwrite !== false;
993
- const existing = await stat(destination).catch(() => null);
994
- if (existing && !overwrite) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
995
- if (existing?.isDirectory()) {
996
- const entries = await readdir(destination).catch(() => []);
997
- if (entries.length) fail(`Destination is a non-empty directory: ${displayPath(destination)}. Move it aside or pick another name.`);
998
- }
999
- await withWritableParent(destination, async anchoredDestination => {
1000
- try {
1001
- await rename(source, anchoredDestination);
1002
- } catch (error) {
1003
- if (error?.code !== 'EXDEV') throw error;
1004
- const info = await stat(source);
1005
- if (info.isDirectory()) fail('Moving a directory across filesystems is not supported; copy it manually or move within one volume');
1006
- await copyFile(source, anchoredDestination);
1007
- await unlink(source);
1008
- }
1382
+ await withExistingParent(source, async anchoredSource => {
1383
+ const sourceInfo = await lstat(anchoredSource).catch(() => fail(`Source not found: ${displayPath(source)}`));
1384
+ if (sourceInfo.isSymbolicLink()) fail('source cannot be a symbolic link');
1385
+ await withWritableParent(destination, async anchoredDestination => {
1386
+ const existing = await lstat(anchoredDestination).catch(() => null);
1387
+ if (existing?.isSymbolicLink()) fail('destination cannot be a symbolic link');
1388
+ if (existing && !overwrite) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
1389
+ if (existing?.isDirectory()) {
1390
+ const entries = await readdir(anchoredDestination).catch(() => []);
1391
+ if (entries.length) fail(`Destination is a non-empty directory: ${displayPath(destination)}. Move it aside or pick another name.`);
1392
+ }
1393
+ try {
1394
+ await rename(anchoredSource, anchoredDestination);
1395
+ } catch (error) {
1396
+ if (error?.code !== 'EXDEV') throw error;
1397
+ if (sourceInfo.isDirectory()) fail('Moving a directory across filesystems is not supported; copy it manually or move within one volume');
1398
+ await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
1399
+ await rm(anchoredSource, { force:true });
1400
+ }
1401
+ });
1009
1402
  });
1010
1403
  return text(`Moved ${displayPath(source)} to ${displayPath(destination)}.`);
1011
1404
  }
@@ -1014,11 +1407,20 @@ export async function copyFileTool(args) {
1014
1407
  const source = await resolveSafePath(args.source, 'source');
1015
1408
  const destination = await resolveSafePath(args.destination, 'destination');
1016
1409
  if (source === destination) fail('source and destination are the same path');
1017
- const info = await stat(source).catch(() => fail(`Source not found: ${displayPath(source)}`));
1018
- if (info.isDirectory()) fail('copy_file copies single files only; create the directory and copy its files individually');
1019
1410
  const overwrite = args.overwrite !== false;
1020
- if (!overwrite && await pathExists(destination)) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
1021
- await withWritableParent(destination, anchoredDestination => copyFile(source, anchoredDestination, overwrite ? 0 : constants.COPYFILE_EXCL));
1411
+ let info;
1412
+ await withExistingParent(source, async anchoredSource => {
1413
+ const sourceInfo = await lstat(anchoredSource).catch(() => fail(`Source not found: ${displayPath(source)}`));
1414
+ if (sourceInfo.isSymbolicLink()) fail('source cannot be a symbolic link');
1415
+ if (sourceInfo.isDirectory()) fail('copy_file copies single files only; create the directory and copy its files individually');
1416
+ info = sourceInfo;
1417
+ await withWritableParent(destination, async anchoredDestination => {
1418
+ const existing = await lstat(anchoredDestination).catch(() => null);
1419
+ if (existing?.isSymbolicLink()) fail('destination cannot be a symbolic link');
1420
+ if (existing && !overwrite) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
1421
+ await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
1422
+ });
1423
+ });
1022
1424
  return text(`Copied ${displayPath(source)} to ${displayPath(destination)} (${info.size} bytes).`);
1023
1425
  }
1024
1426
 
@@ -1095,43 +1497,7 @@ async function assertExtractedTree(root, state = { entries: 0, bytes: 0 }) {
1095
1497
  }
1096
1498
 
1097
1499
  async function openArchiveSource(absolute) {
1098
- if (process.platform !== 'linux' || !constants.O_NOFOLLOW || !constants.O_DIRECTORY) {
1099
- const opened = await openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
1100
- return { ...opened, close: () => opened.handle.close() };
1101
- }
1102
- const root = path.parse(absolute).root;
1103
- const parts = absolute.slice(root.length).split(path.sep).filter(Boolean);
1104
- if (!parts.length) return openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
1105
- const directories = [];
1106
- let current = root;
1107
- const closeDirectories = async () => {
1108
- for (const handle of directories.reverse()) await handle.close().catch(() => {});
1109
- };
1110
- try {
1111
- for (const part of parts.slice(0, -1)) {
1112
- const handle = await open(path.join(current, part), DIRECTORY_READ_NOFOLLOW);
1113
- try {
1114
- const info = await handle.stat();
1115
- if (!info.isDirectory()) fail('Archive source is not a safe regular file');
1116
- } catch (error) {
1117
- await handle.close().catch(() => {});
1118
- throw error;
1119
- }
1120
- directories.push(handle);
1121
- current = `/proc/self/fd/${handle.fd}`;
1122
- }
1123
- const opened = await openRegularFile(path.join(current, parts.at(-1)), READ_NOFOLLOW_NONBLOCK);
1124
- return {
1125
- ...opened,
1126
- close: async () => {
1127
- try { await opened.handle.close(); }
1128
- finally { await closeDirectories(); }
1129
- },
1130
- };
1131
- } catch (error) {
1132
- await closeDirectories();
1133
- throw error;
1134
- }
1500
+ return openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
1135
1501
  }
1136
1502
 
1137
1503
  async function snapshotRegularFile(source, destination, maxBytes) {
@@ -1214,6 +1580,7 @@ async function copyOpenRegularFile(sourceHandle, sourceInfo, destinationPath, st
1214
1580
  }
1215
1581
 
1216
1582
  async function copyTreeContents(source, destination, state = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES }) {
1583
+ throwIfCancelled(state.signal);
1217
1584
  const initial = await lstat(source);
1218
1585
  if (initial.isSymbolicLink() || (!initial.isDirectory() && !initial.isFile())) fail('Archive contains symbolic links or special files; archive destination is unsafe');
1219
1586
  state.entries += 1;
@@ -1231,7 +1598,7 @@ async function copyTreeContents(source, destination, state = { entries: 0, bytes
1231
1598
  let info = initial;
1232
1599
  try {
1233
1600
  try {
1234
- directoryHandle = await openDirectoryPath(source);
1601
+ directoryHandle = await openDirectoryPath(source, { allowOutside:state.allowOutsideSource === true });
1235
1602
  info = await directoryHandle.handle.stat();
1236
1603
  if (!info.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
1237
1604
  sourcePath = directoryHandle.anchor;
@@ -1242,13 +1609,13 @@ async function copyTreeContents(source, destination, state = { entries: 0, bytes
1242
1609
  const mode = info.mode & 0o777;
1243
1610
  const existing = await lstat(destination).catch(() => null);
1244
1611
  if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) fail('Archive contains a link or special file; archive destination is unsafe');
1245
- try { await mkdir(destination, { mode: mode | 0o700 }); }
1612
+ try { await ensureDirectoryPath(destination, { allowOutside:true }); }
1246
1613
  catch (error) { if (error?.code !== 'EEXIST') throw error; }
1247
1614
  const destinationInfo = await lstat(destination);
1248
1615
  if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Archive contains a link or special file; archive destination is unsafe');
1249
1616
  let destinationHandle;
1250
1617
  try {
1251
- destinationHandle = await openDirectoryPath(destination);
1618
+ destinationHandle = await openDirectoryPath(destination, { allowOutside:true });
1252
1619
  const openedDestination = await destinationHandle.handle.stat();
1253
1620
  if (!openedDestination.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
1254
1621
  await destinationHandle.handle.chmod(mode | 0o700);
@@ -1354,9 +1721,9 @@ async function installExtractedTree(source, destination) {
1354
1721
  if (backupOwned) {
1355
1722
  const backupInfo = await lstat(backup);
1356
1723
  if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) fail('Archive destination is not a safe directory');
1357
- await copyTreeContents(backup, transaction);
1724
+ await copyTreeContents(backup, transaction, { entries:0, bytes:0, maxBytes:MAX_ARCHIVE_EXPANDED_BYTES, allowOutsideSource:true });
1358
1725
  }
1359
- await copyTreeContents(source, transaction);
1726
+ await copyTreeContents(source, transaction, { entries:0, bytes:0, maxBytes:MAX_ARCHIVE_EXPANDED_BYTES, allowOutsideSource:true });
1360
1727
  await chmod(transaction, destinationMode);
1361
1728
  await rename(transaction, destinationPath);
1362
1729
  if (backupOwned) {
@@ -1415,12 +1782,15 @@ function archiveTool() {
1415
1782
  return { tar: probe('tar'), zip: probe('zip'), unzip: probe('unzip', ['-v']) };
1416
1783
  }
1417
1784
 
1418
- export async function createArchiveTool(args) {
1785
+ export async function createArchiveTool(args, extra = {}) {
1419
1786
  const tools = archiveTool();
1420
1787
  const sources = Array.isArray(args.paths) ? args.paths : [args.paths].filter(Boolean);
1421
1788
  if (!sources.length) fail('paths must list at least one file or directory');
1422
1789
  const resolved = [];
1423
- for (const entry of sources) resolved.push(await resolveSafePath(entry, 'paths[]'));
1790
+ for (const entry of sources) {
1791
+ throwIfCancelled(extra.signal);
1792
+ resolved.push(await resolveSafePath(entry, 'paths[]'));
1793
+ }
1424
1794
  const destination = await resolveSafePath(args.destination, 'destination');
1425
1795
  const existingDestination = await lstat(destination).catch(() => null);
1426
1796
  if (existingDestination?.isSymbolicLink()) fail('Archive destination cannot be a symbolic link');
@@ -1438,12 +1808,13 @@ export async function createArchiveTool(args) {
1438
1808
  const staging = path.join(stagingDirectory, `payload${suffix}`);
1439
1809
  const snapshotRoot = path.join(stagingDirectory, 'source');
1440
1810
  try {
1441
- await ensureDirectoryPath(snapshotRoot);
1811
+ await ensureDirectoryPath(snapshotRoot, { allowOutside:true });
1442
1812
 
1443
- const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES };
1813
+ const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES, signal: extra.signal };
1444
1814
  for (let index = 0; index < resolved.length; index += 1) {
1815
+ throwIfCancelled(extra.signal);
1445
1816
  const snapshotPath = path.join(snapshotRoot, names[index]);
1446
- await ensureDirectoryPath(path.dirname(snapshotPath));
1817
+ await ensureDirectoryPath(path.dirname(snapshotPath), { allowOutside:true });
1447
1818
 
1448
1819
  await copyTreeContents(resolved[index], snapshotPath, snapshotState);
1449
1820
  }
@@ -1638,7 +2009,8 @@ export async function takeScreenshotTool(args) {
1638
2009
  }
1639
2010
  if (!await pathExists(file)) fail(screenshotAdvice(attempts));
1640
2011
 
1641
- const { handle, info } = await openRegularFile(file);
2012
+ const opened = await openRegularFile(file);
2013
+ const { handle, info } = opened;
1642
2014
  try {
1643
2015
  if (info.size <= MAX_IMAGE_BYTES) {
1644
2016
  const buffer = await handle.readFile();
@@ -1651,7 +2023,7 @@ export async function takeScreenshotTool(args) {
1651
2023
  preserveCapture = true;
1652
2024
  return text(`Screenshot of ${os.hostname()} captured: ${info.size} bytes, above the ${MAX_IMAGE_BYTES}-byte inline limit, so it is saved at ${displayPath(displayFile)} instead of being returned as an image. Fetch it with read_binary using chunks of up to ${MAX_BINARY_CHUNK_BYTES} bytes (offset_bytes and length_bytes), or ask for a smaller region.`);
1653
2025
  } finally {
1654
- await handle.close();
2026
+ await opened.close();
1655
2027
  }
1656
2028
  } finally {
1657
2029
  if (!preserveCapture) {