@remcp/runtime 0.2.108 → 0.2.109

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 } 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,46 @@ 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
+ async function openRegularFileAtPath(absolute, flags, label = absolute) {
287
+ const opened = await openEntryAtPath(absolute, flags);
114
288
  try {
115
- const info = assertRegularFile(await handle.stat(), absolute);
116
- return { handle, info };
289
+ const info = assertRegularFile(await opened.handle.stat(), label);
290
+ return { handle: opened.handle, info, close: opened.close };
117
291
  } catch (error) {
118
- await handle.close().catch(() => {});
292
+ await opened.close().catch(() => {});
119
293
  throw error;
120
294
  }
121
295
  }
122
296
 
123
- async function openWritableParent(absolute) {
297
+ export async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
298
+ return openRegularFileAtPath(absolute, flags, absolute);
299
+ }
300
+
301
+ async function openWritableParent(absolute, options = {}) {
124
302
  if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
125
- return openDirectoryPath(absolute, { create:true });
303
+ return openDirectoryPath(absolute, { create:true, ...options });
126
304
  }
127
305
  await mkdir(absolute, { recursive:true });
128
306
  const info = await lstat(absolute);
@@ -154,9 +332,27 @@ async function withWritableParent(absolute, callback) {
154
332
  }
155
333
  }
156
334
 
157
- async function ensureDirectoryPath(absolute) {
335
+ async function openExistingParent(absolute) {
158
336
  if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
159
- const directory = await openDirectoryPath(absolute, { create:true });
337
+ return openDirectoryPath(absolute);
338
+ }
339
+ const info = await lstat(absolute);
340
+ if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a directory');
341
+ return { anchor:absolute, close:async() => {} };
342
+ }
343
+
344
+ async function withExistingParent(absolute, callback) {
345
+ const parent = await openExistingParent(path.dirname(absolute));
346
+ try {
347
+ return await callback(path.join(parent.anchor, path.basename(absolute)));
348
+ } finally {
349
+ await parent.close().catch(() => {});
350
+ }
351
+ }
352
+
353
+ async function ensureDirectoryPath(absolute, options = {}) {
354
+ if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
355
+ const directory = await openDirectoryPath(absolute, { create:true, ...options });
160
356
  await directory.close();
161
357
  return;
162
358
  }
@@ -166,12 +362,13 @@ async function ensureDirectoryPath(absolute) {
166
362
  }
167
363
 
168
364
  async function readRegularBuffer(absolute, maxBytes = Infinity) {
169
- const { handle, info } = await openRegularFile(absolute);
365
+ const opened = await openRegularFile(absolute);
366
+ const { handle, info } = opened;
170
367
  try {
171
368
  if (info.size > maxBytes) fail(`File is too large to read inline (${info.size} bytes)`);
172
369
  return { info, buffer: await handle.readFile() };
173
370
  } finally {
174
- await handle.close();
371
+ await opened.close();
175
372
  }
176
373
  }
177
374
 
@@ -195,29 +392,65 @@ async function collectTree(root, { maxFiles = 500, skip = [] } = {}) {
195
392
  const found = [];
196
393
  const denied = [];
197
394
  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;
395
+ const visitDirectory = async (directory, logicalBase) => {
206
396
  let entries;
207
397
  try {
208
- entries = await readdir(target, { withFileTypes: true });
398
+ entries = await readdir(directory.anchor, { withFileTypes: true });
209
399
  } catch {
210
- // Reporting the skip matters: silently dropping a directory made a partial walk look complete.
211
- denied.push(target);
400
+ denied.push(logicalBase);
212
401
  return;
213
402
  }
214
403
  for (const entry of entries) {
215
404
  if (found.length >= maxFiles) return;
216
405
  if (entry.isSymbolicLink() || skipName(entry.name)) continue;
217
- await visit(path.join(target, entry.name));
406
+ const childLogical = path.join(logicalBase, entry.name);
407
+ const childAnchor = path.join(directory.anchor, entry.name);
408
+ if (entry.isDirectory()) {
409
+ let child;
410
+ try {
411
+ child = await openDirectoryPath(childAnchor);
412
+ const info = await child.handle.stat();
413
+ if (!info.isDirectory()) fail('Filesystem path component is not a directory');
414
+ await visitDirectory(child, childLogical);
415
+ } catch {
416
+ denied.push(childLogical);
417
+ } finally {
418
+ if (child) await child.close().catch(() => {});
419
+ }
420
+ } else if (entry.isFile()) {
421
+ let opened;
422
+ try {
423
+ opened = await openEntryAtPath(childAnchor, READ_NOFOLLOW_NONBLOCK);
424
+ const info = await opened.handle.stat();
425
+ if (info.isFile()) found.push(childLogical);
426
+ } catch {
427
+ denied.push(childLogical);
428
+ } finally {
429
+ if (opened) await opened.close().catch(() => {});
430
+ }
431
+ }
432
+ if (found.length >= maxFiles) return;
433
+ }
434
+ };
435
+ let directory;
436
+ try {
437
+ directory = await openDirectoryPath(root);
438
+ await visitDirectory(directory, root);
439
+ } catch {
440
+ let opened;
441
+ try {
442
+ opened = await openEntryAtPath(root, READ_NOFOLLOW_NONBLOCK);
443
+ const info = await opened.handle.stat();
444
+ if (info.isFile()) found.push(root);
445
+ else denied.push(root);
446
+ } catch {
447
+ denied.push(root);
448
+ } finally {
449
+ if (opened) await opened.close().catch(() => {});
218
450
  }
451
+ } finally {
452
+ if (directory) await directory.close().catch(() => {});
219
453
  }
220
- await visit(root);
221
454
  return { files: found, denied };
222
455
  }
223
456
 
@@ -334,37 +567,64 @@ export async function hashFileTool(args) {
334
567
  }
335
568
  const { handle, info } = opened;
336
569
  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'); }
570
+ if (!['sha256', 'sha1', 'md5'].includes(algorithm)) { await opened.close(); fail('algorithm must be sha256, sha1, or md5'); }
338
571
  const hash = createHash(algorithm);
339
572
  try {
340
573
  await pipeline(createReadStream(absolute, { fd: handle.fd, autoClose: false }), hash);
341
574
  return text(`${algorithm} ${hash.digest('hex')} ${displayPath(absolute)} (${info.size} bytes)`);
342
575
  } finally {
343
- await handle.close();
576
+ await opened.close();
344
577
  }
345
578
  }
346
579
 
347
- async function listEntry(base, depth, maxDepth, prefix, pattern) {
580
+ async function openListEntry(base, name) {
581
+ const opened = await openEntryAtPath(path.join(base, name), READ_NOFOLLOW_NONBLOCK);
582
+ try {
583
+ return { handle: opened.handle, info: await opened.handle.stat(), close: opened.close };
584
+ } catch (error) {
585
+ await opened.close().catch(() => {});
586
+ throw error;
587
+ }
588
+ }
589
+
590
+ async function listEntry(directory, depth, maxDepth, prefix, pattern, displayBase) {
348
591
  let entries;
349
592
  try {
350
- entries = await readdir(base, { withFileTypes: true });
593
+ entries = await readdir(directory.anchor, { withFileTypes: true });
351
594
  } 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'})`];
595
+ return [`${prefix}[DENIED] ${path.basename(displayBase)} (${error instanceof Error ? error.code || error.message : 'unreadable'})`];
354
596
  }
355
597
  entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
356
598
  const rows = [];
357
599
  for (const entry of entries) {
358
- const child = path.join(base, entry.name);
600
+ const childDisplay = path.join(displayBase, entry.name);
359
601
  if (entry.isDirectory()) {
360
602
  rows.push(`${prefix}[DIR] ${entry.name}`);
361
- if (depth < maxDepth) rows.push(...await listEntry(child, depth + 1, maxDepth, `${prefix} `.replace(/ {2}$/, '') + ' ', pattern));
603
+ if (depth >= maxDepth) continue;
604
+ let child;
605
+ try {
606
+ child = await openDirectoryPath(path.join(directory.anchor, entry.name));
607
+ const childInfo = await child.handle.stat();
608
+ if (!childInfo.isDirectory()) fail('Filesystem path component is not a directory');
609
+ rows.push(...await listEntry(child, depth + 1, maxDepth, `${prefix} `.replace(/ {2}$/, '') + ' ', pattern, childDisplay));
610
+ } catch (error) {
611
+ rows.push(`${prefix} [DENIED] ${entry.name} (${error instanceof Error ? error.code || error.message : 'unreadable'})`);
612
+ } finally {
613
+ if (child) await child.close().catch(() => {});
614
+ }
362
615
  } else if (entry.isSymbolicLink()) {
363
616
  rows.push(`${prefix}[LINK] ${entry.name}`);
364
617
  } else {
365
618
  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)` : ''}`);
619
+ let opened;
620
+ try {
621
+ opened = await openListEntry(directory.anchor, entry.name);
622
+ rows.push(`${prefix}[${opened.info.isFile() ? 'FILE' : 'SPECIAL'}] ${entry.name}${opened.info.isFile() ? ` (${opened.info.size} bytes)` : ''}`);
623
+ } catch (error) {
624
+ rows.push(`${prefix}[DENIED] ${entry.name} (${error instanceof Error ? error.code || error.message : 'unreadable'})`);
625
+ } finally {
626
+ if (opened) await opened.close().catch(() => {});
627
+ }
368
628
  }
369
629
  }
370
630
  return rows;
@@ -372,39 +632,85 @@ async function listEntry(base, depth, maxDepth, prefix, pattern) {
372
632
 
373
633
  export async function listDirectoryTool(args) {
374
634
  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
635
  const depth = clampInteger(args.depth, 1, 1, 5);
378
636
  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)'}`);
637
+ let directory;
638
+ try {
639
+ directory = await openDirectoryPath(absolute);
640
+ const info = await directory.handle.stat();
641
+ if (!info.isDirectory()) fail(`${displayPath(absolute)} is not a directory`);
642
+ const rows = await listEntry(directory, 1, depth, '', pattern, absolute);
643
+ return text(`${displayPath(absolute)}${pattern ? ` · matching ${args.pattern}` : ''}\n${rows.join('\n') || '(empty)'}`);
644
+ } catch (error) {
645
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') fail(`Path not found: ${displayPath(absolute)}`);
646
+ throw error;
647
+ } finally {
648
+ if (directory) await directory.close().catch(() => {});
649
+ }
650
+ }
651
+
652
+ async function openPathForInfo(absolute) {
653
+ let directory;
654
+ try {
655
+ directory = await openDirectoryPath(absolute);
656
+ return { info: await directory.handle.stat(), close: directory.close };
657
+ } catch (directoryError) {
658
+ const canTryFile = directoryError?.code === 'ENOTDIR'
659
+ || directoryError?.code === 'EISDIR'
660
+ || (!directoryError?.code && /not a directory/i.test(directoryError?.message || ''));
661
+ if (!canTryFile) throw directoryError;
662
+ }
663
+ const opened = await openEntryAtPath(absolute, READ_NOFOLLOW_NONBLOCK);
664
+ try {
665
+ return { info: await opened.handle.stat(), close: opened.close };
666
+ } catch (error) {
667
+ await opened.close().catch(() => {});
668
+ throw error;
669
+ }
670
+ }
671
+
672
+ async function getPathInfo(absolute) {
673
+ const opened = await openPathForInfo(absolute);
674
+ try { return opened.info; }
675
+ finally { await opened.close().catch(() => {}); }
381
676
  }
382
677
 
383
678
  export async function getFileInfoTool(args) {
384
679
  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';
680
+ let opened;
681
+ try {
682
+ opened = await openPathForInfo(absolute);
683
+ } catch (error) {
684
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') fail(`Path not found: ${displayPath(absolute)}`);
685
+ throw error;
686
+ }
687
+ const { info } = opened;
688
+ try {
689
+ const payload = {
690
+ path: displayPath(absolute),
691
+ type: info.isDirectory() ? 'directory' : info.isSymbolicLink() ? 'symlink' : info.isFile() ? 'file' : 'special',
692
+ size: info.size,
693
+ createdAt: info.birthtime.toISOString(),
694
+ modifiedAt: info.mtime.toISOString(),
695
+ permissions: `0${(info.mode & 0o777).toString(8)}`,
696
+ };
697
+ if (info.isFile() && info.size <= MAX_INLINE_FILE_BYTES) {
698
+ const regular = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES).catch(() => null);
699
+ if (regular) {
700
+ const decoded = decodeText(regular.buffer);
701
+ if (decoded.encoding !== 'utf8') payload.encoding = decoded.encoding;
702
+ if (decoded.encoding !== 'utf8' || !looksBinary(regular.buffer)) {
703
+ const lines = splitLines(decoded.text);
704
+ payload.lineCount = lines.length;
705
+ payload.lastLine = Math.max(0, lines.length - 1);
706
+ payload.eol = detectEol(decoded.text) === '\r\n' ? 'CRLF' : 'LF';
707
+ }
404
708
  }
405
709
  }
710
+ return text(JSON.stringify(payload, null, 2));
711
+ } finally {
712
+ await opened.close().catch(() => {});
406
713
  }
407
- return text(JSON.stringify(payload, null, 2));
408
714
  }
409
715
 
410
716
  function assertWritableSize(content) {
@@ -471,7 +777,7 @@ export async function readBinaryTool(args) {
471
777
  });
472
778
  return text(payload);
473
779
  } finally {
474
- await handle.close();
780
+ await opened.close();
475
781
  }
476
782
  }
477
783
 
@@ -605,7 +911,7 @@ export async function replaceInFilesTool(args) {
605
911
  fail(`pattern is not a valid regular expression (${error instanceof Error ? error.message : String(error)})`);
606
912
  }
607
913
  }
608
- const info = await stat(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
914
+ const info = await getPathInfo(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
609
915
  const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'] });
610
916
  const files = await confineAll(walk.files);
611
917
  const glob = filePattern ? globToRegExp(filePattern) : null;
@@ -635,7 +941,7 @@ export async function replaceInFilesTool(args) {
635
941
  const stats = diffStats(original, updated);
636
942
  changed.push({ file: displayPath(file), replacements: count, added: stats.added, removed: stats.removed });
637
943
  } finally {
638
- await handle.close();
944
+ await opened.close();
639
945
  }
640
946
  }
641
947
  if (!changed.length) return text(`No matches for ${JSON.stringify(pattern)} in ${displayPath(root)} (${scanned} text files scanned).`);
@@ -665,7 +971,6 @@ function trashDirectoryFor() {
665
971
 
666
972
  export async function moveToTrashTool(args) {
667
973
  const source = await resolveSafePath(args.source, 'source');
668
- const info = await stat(source).catch(() => fail(`Path not found: ${displayPath(source)}`));
669
974
  const trash = trashDirectoryFor();
670
975
  let destination = null;
671
976
  if (trash) {
@@ -691,12 +996,16 @@ export async function moveToTrashTool(args) {
691
996
  target = path.join(destination, `${stamp}-${counter}-${path.basename(source)}`);
692
997
  counter += 1;
693
998
  }
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);
999
+ await withExistingParent(source, async anchoredSource => {
1000
+ const sourceInfo = await lstat(anchoredSource).catch(() => fail(`Path not found: ${displayPath(source)}`));
1001
+ if (sourceInfo.isSymbolicLink()) fail('source cannot be a symbolic link');
1002
+ await withWritableParent(target, async anchoredTarget => {
1003
+ await rename(anchoredSource, anchoredTarget).catch(async error => {
1004
+ if (error?.code !== 'EXDEV') throw error;
1005
+ if (sourceInfo.isDirectory()) fail('Moving a directory to the trash across filesystems is not supported');
1006
+ await copyTreeContents(anchoredSource, anchoredTarget, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
1007
+ await rm(anchoredSource, { force:true });
1008
+ });
700
1009
  });
701
1010
  });
702
1011
  return text(`Moved ${displayPath(source)} to ${displayPath(target)}. Restore it with move_file if this was a mistake.`);
@@ -711,7 +1020,7 @@ export async function readFilesTool(args) {
711
1020
  const maxLinesPerFile = clampInteger(args.max_lines_per_file, liveConfig('maxReadLines'), 1, 20000);
712
1021
  const includeIgnored = args.include_ignored === true;
713
1022
  const matcher = globToRegExp(pattern);
714
- const rootInfo = await stat(root).catch(() => null);
1023
+ const rootInfo = await getPathInfo(root).catch(() => null);
715
1024
  // A file path is matched directly; a directory is walked without following links.
716
1025
  const walk = rootInfo?.isFile()
717
1026
  ? { files: [root], denied: [] }
@@ -779,15 +1088,19 @@ export async function writeFilesTool(args) {
779
1088
 
780
1089
  export async function deletePathTool(args) {
781
1090
  const absolute = await resolveSafePath(args.path);
782
- const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
783
1091
  if (path.dirname(absolute) === absolute) fail(`Refusing to delete the filesystem root ${displayPath(absolute)}`);
784
1092
  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 });
1093
+ let info;
1094
+ let entries = [];
1095
+ await withExistingParent(absolute, async target => {
1096
+ info = await lstat(target).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
1097
+ if (info.isDirectory() && !recursive) {
1098
+ entries = await readdir(target).catch(() => []);
1099
+ if (entries.length) fail(`Directory is not empty: ${displayPath(absolute)}. Pass recursive: true to delete it with its contents.`);
1100
+ }
1101
+ if (info.isDirectory()) entries = await readdir(target).catch(() => []);
1102
+ await rm(target, { recursive:true, force:false });
1103
+ });
791
1104
  return text(`Deleted ${info.isDirectory() ? 'directory' : 'file'} ${displayPath(absolute)}${info.isDirectory() ? ` and its ${entries.length} top-level entr${entries.length === 1 ? 'y' : 'ies'}` : ''}.`);
792
1105
  }
793
1106
 
@@ -802,13 +1115,15 @@ export async function deletePathsTool(args) {
802
1115
  try {
803
1116
  const absolute = await resolveSafePath(entry, 'paths[]');
804
1117
  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 });
1118
+ await withExistingParent(absolute, async target => {
1119
+ const info = await lstat(target).catch(() => null);
1120
+ if (!info) throw new Error('not found');
1121
+ if (info.isDirectory() && !recursive) {
1122
+ const children = await readdir(target).catch(() => []);
1123
+ if (children.length) throw new Error('directory is not empty (pass recursive: true)');
1124
+ }
1125
+ await rm(target, { recursive:true, force:false });
1126
+ });
812
1127
  deleted += 1;
813
1128
  results.push(`deleted ${displayPath(absolute)}`);
814
1129
  } catch (error) {
@@ -832,11 +1147,11 @@ export async function copyPathsTool(args) {
832
1147
  const source = await resolveSafePath(entry?.source, 'paths[].source');
833
1148
  const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
834
1149
  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 }));
1150
+ await withWritableParent(destination, async anchoredDestination => {
1151
+ const existing = await lstat(anchoredDestination).catch(() => null);
1152
+ if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
1153
+ await copyTreeContents(source, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
1154
+ });
840
1155
  copied += 1;
841
1156
  results.push(`copied ${displayPath(source)} → ${displayPath(destination)}`);
842
1157
  } catch (error) {
@@ -858,18 +1173,21 @@ export async function movePathsTool(args) {
858
1173
  const source = await resolveSafePath(entry?.source, 'paths[].source');
859
1174
  const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
860
1175
  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
- }
1176
+ await withExistingParent(source, async anchoredSource => {
1177
+ const sourceInfo = await lstat(anchoredSource).catch(() => null);
1178
+ if (!sourceInfo) throw new Error('source not found');
1179
+ if (sourceInfo.isSymbolicLink()) throw new Error('source cannot be a symbolic link');
1180
+ await withWritableParent(destination, async anchoredDestination => {
1181
+ const existing = await lstat(anchoredDestination).catch(() => null);
1182
+ if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
1183
+ try {
1184
+ await rename(anchoredSource, anchoredDestination);
1185
+ } catch (error) {
1186
+ if (error?.code !== 'EXDEV') throw error;
1187
+ await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
1188
+ await rm(anchoredSource, { recursive:true, force:true });
1189
+ }
1190
+ });
873
1191
  });
874
1192
  moved += 1;
875
1193
  results.push(`moved ${displayPath(source)} → ${displayPath(destination)}`);
@@ -919,41 +1237,81 @@ export async function applyPatchTool(args) {
919
1237
  return text([`${changed}/${files.length} file(s) ${dryRun ? 'would be patched' : 'patched'}`, ...results].join('\n'), failedCount > 0);
920
1238
  }
921
1239
 
1240
+ async function setLinuxPermissions(target, mode, uid, gid, recursive, displayTarget, failures) {
1241
+ let directory = null;
1242
+ let file = null;
1243
+ try {
1244
+ const info = await lstat(target);
1245
+ if (info.isSymbolicLink()) {
1246
+ failures.push(`${displayTarget}: symbolic links are not changed`);
1247
+ return 0;
1248
+ }
1249
+ if (info.isDirectory()) {
1250
+ directory = await openDirectoryPath(target);
1251
+ let changed = 0;
1252
+ if (recursive) {
1253
+ for (const entry of await readdir(directory.anchor, { withFileTypes:true })) {
1254
+ if (entry.isSymbolicLink()) continue;
1255
+ changed += await setLinuxPermissions(path.join(directory.anchor, entry.name), mode, uid, gid, true, path.join(displayTarget, entry.name), failures);
1256
+ }
1257
+ }
1258
+ await directory.handle.chmod(mode);
1259
+ if (uid !== null || gid !== null) await directory.handle.chown(uid ?? -1, gid ?? -1);
1260
+ return changed + 1;
1261
+ }
1262
+ if (!info.isFile()) {
1263
+ failures.push(`${displayTarget}: special files are not changed`);
1264
+ return 0;
1265
+ }
1266
+ file = await openRegularFile(target, READ_NOFOLLOW);
1267
+ await file.handle.chmod(mode);
1268
+ if (uid !== null || gid !== null) await file.handle.chown(uid ?? -1, gid ?? -1);
1269
+ return 1;
1270
+ } catch (error) {
1271
+ failures.push(`${displayTarget}: ${describeFilesystemFailure(error, { path:error?.path })}`);
1272
+ return 0;
1273
+ } finally {
1274
+ if (file) await file.close().catch(() => {});
1275
+ if (directory) await directory.close().catch(() => {});
1276
+ }
1277
+ }
1278
+
922
1279
  export async function setPermissionsTool(args) {
923
1280
  const absolute = await resolveSafePath(args.path);
924
- const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
925
1281
  const raw = typeof args.mode === 'string' ? args.mode.trim() : String(args.mode ?? '');
926
1282
  if (!/^[0-7]{3,4}$/.test(raw)) fail('mode must be an octal string such as "755" or "0644"');
927
1283
  const mode = Number.parseInt(raw, 8);
928
1284
  const recursive = args.recursive === true;
929
1285
  const uid = Number.isInteger(Number(args.uid)) ? Number(args.uid) : null;
930
1286
  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
1287
  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 })}`);
1288
+ let changed = 0;
1289
+ if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
1290
+ changed = await setLinuxPermissions(absolute, mode, uid, gid, recursive, displayPath(absolute), failures);
1291
+ } else {
1292
+ const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
1293
+ const targets = [absolute];
1294
+ if (recursive && info.isDirectory()) {
1295
+ const walk = async target => {
1296
+ const entries = await readdir(target, { withFileTypes:true }).catch(() => []);
1297
+ for (const entry of entries) {
1298
+ if (entry.isSymbolicLink()) continue;
1299
+ const child = path.join(target, entry.name);
1300
+ targets.push(child);
1301
+ if (entry.isDirectory()) await walk(child);
1302
+ }
1303
+ };
1304
+ await walk(absolute);
1305
+ }
1306
+ const confined = await confineAll(targets);
1307
+ for (const target of confined) {
1308
+ try {
1309
+ await chmod(target, mode);
1310
+ if (uid !== null || gid !== null) await chown(target, uid ?? -1, gid ?? -1);
1311
+ changed += 1;
1312
+ } catch (error) {
1313
+ failures.push(`${displayPath(target)}: ${describeFilesystemFailure(error, { path:error?.path })}`);
1314
+ }
957
1315
  }
958
1316
  }
959
1317
  const summary = `Set mode ${raw}${uid !== null || gid !== null ? ` (uid ${uid ?? '-'} gid ${gid ?? '-'})` : ''} on ${changed} path(s) starting at ${displayPath(absolute)}.`;
@@ -988,24 +1346,27 @@ export async function moveFileTool(args) {
988
1346
  const source = await resolveSafePath(args.source, 'source');
989
1347
  const destination = await resolveSafePath(args.destination, 'destination');
990
1348
  if (source === destination) fail('source and destination are the same path');
991
- await stat(source).catch(() => fail(`Source not found: ${displayPath(source)}`));
992
1349
  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
- }
1350
+ await withExistingParent(source, async anchoredSource => {
1351
+ const sourceInfo = await lstat(anchoredSource).catch(() => fail(`Source not found: ${displayPath(source)}`));
1352
+ if (sourceInfo.isSymbolicLink()) fail('source cannot be a symbolic link');
1353
+ await withWritableParent(destination, async anchoredDestination => {
1354
+ const existing = await lstat(anchoredDestination).catch(() => null);
1355
+ if (existing?.isSymbolicLink()) fail('destination cannot be a symbolic link');
1356
+ if (existing && !overwrite) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
1357
+ if (existing?.isDirectory()) {
1358
+ const entries = await readdir(anchoredDestination).catch(() => []);
1359
+ if (entries.length) fail(`Destination is a non-empty directory: ${displayPath(destination)}. Move it aside or pick another name.`);
1360
+ }
1361
+ try {
1362
+ await rename(anchoredSource, anchoredDestination);
1363
+ } catch (error) {
1364
+ if (error?.code !== 'EXDEV') throw error;
1365
+ if (sourceInfo.isDirectory()) fail('Moving a directory across filesystems is not supported; copy it manually or move within one volume');
1366
+ await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
1367
+ await rm(anchoredSource, { force:true });
1368
+ }
1369
+ });
1009
1370
  });
1010
1371
  return text(`Moved ${displayPath(source)} to ${displayPath(destination)}.`);
1011
1372
  }
@@ -1014,11 +1375,20 @@ export async function copyFileTool(args) {
1014
1375
  const source = await resolveSafePath(args.source, 'source');
1015
1376
  const destination = await resolveSafePath(args.destination, 'destination');
1016
1377
  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
1378
  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));
1379
+ let info;
1380
+ await withExistingParent(source, async anchoredSource => {
1381
+ const sourceInfo = await lstat(anchoredSource).catch(() => fail(`Source not found: ${displayPath(source)}`));
1382
+ if (sourceInfo.isSymbolicLink()) fail('source cannot be a symbolic link');
1383
+ if (sourceInfo.isDirectory()) fail('copy_file copies single files only; create the directory and copy its files individually');
1384
+ info = sourceInfo;
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
+ await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
1390
+ });
1391
+ });
1022
1392
  return text(`Copied ${displayPath(source)} to ${displayPath(destination)} (${info.size} bytes).`);
1023
1393
  }
1024
1394
 
@@ -1095,43 +1465,7 @@ async function assertExtractedTree(root, state = { entries: 0, bytes: 0 }) {
1095
1465
  }
1096
1466
 
1097
1467
  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
- }
1468
+ return openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
1135
1469
  }
1136
1470
 
1137
1471
  async function snapshotRegularFile(source, destination, maxBytes) {
@@ -1231,7 +1565,7 @@ async function copyTreeContents(source, destination, state = { entries: 0, bytes
1231
1565
  let info = initial;
1232
1566
  try {
1233
1567
  try {
1234
- directoryHandle = await openDirectoryPath(source);
1568
+ directoryHandle = await openDirectoryPath(source, { allowOutside:state.allowOutsideSource === true });
1235
1569
  info = await directoryHandle.handle.stat();
1236
1570
  if (!info.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
1237
1571
  sourcePath = directoryHandle.anchor;
@@ -1242,13 +1576,13 @@ async function copyTreeContents(source, destination, state = { entries: 0, bytes
1242
1576
  const mode = info.mode & 0o777;
1243
1577
  const existing = await lstat(destination).catch(() => null);
1244
1578
  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 }); }
1579
+ try { await ensureDirectoryPath(destination, { allowOutside:true }); }
1246
1580
  catch (error) { if (error?.code !== 'EEXIST') throw error; }
1247
1581
  const destinationInfo = await lstat(destination);
1248
1582
  if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Archive contains a link or special file; archive destination is unsafe');
1249
1583
  let destinationHandle;
1250
1584
  try {
1251
- destinationHandle = await openDirectoryPath(destination);
1585
+ destinationHandle = await openDirectoryPath(destination, { allowOutside:true });
1252
1586
  const openedDestination = await destinationHandle.handle.stat();
1253
1587
  if (!openedDestination.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
1254
1588
  await destinationHandle.handle.chmod(mode | 0o700);
@@ -1354,9 +1688,9 @@ async function installExtractedTree(source, destination) {
1354
1688
  if (backupOwned) {
1355
1689
  const backupInfo = await lstat(backup);
1356
1690
  if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) fail('Archive destination is not a safe directory');
1357
- await copyTreeContents(backup, transaction);
1691
+ await copyTreeContents(backup, transaction, { entries:0, bytes:0, maxBytes:MAX_ARCHIVE_EXPANDED_BYTES, allowOutsideSource:true });
1358
1692
  }
1359
- await copyTreeContents(source, transaction);
1693
+ await copyTreeContents(source, transaction, { entries:0, bytes:0, maxBytes:MAX_ARCHIVE_EXPANDED_BYTES, allowOutsideSource:true });
1360
1694
  await chmod(transaction, destinationMode);
1361
1695
  await rename(transaction, destinationPath);
1362
1696
  if (backupOwned) {
@@ -1438,12 +1772,12 @@ export async function createArchiveTool(args) {
1438
1772
  const staging = path.join(stagingDirectory, `payload${suffix}`);
1439
1773
  const snapshotRoot = path.join(stagingDirectory, 'source');
1440
1774
  try {
1441
- await ensureDirectoryPath(snapshotRoot);
1775
+ await ensureDirectoryPath(snapshotRoot, { allowOutside:true });
1442
1776
 
1443
1777
  const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES };
1444
1778
  for (let index = 0; index < resolved.length; index += 1) {
1445
1779
  const snapshotPath = path.join(snapshotRoot, names[index]);
1446
- await ensureDirectoryPath(path.dirname(snapshotPath));
1780
+ await ensureDirectoryPath(path.dirname(snapshotPath), { allowOutside:true });
1447
1781
 
1448
1782
  await copyTreeContents(resolved[index], snapshotPath, snapshotState);
1449
1783
  }
@@ -1638,7 +1972,8 @@ export async function takeScreenshotTool(args) {
1638
1972
  }
1639
1973
  if (!await pathExists(file)) fail(screenshotAdvice(attempts));
1640
1974
 
1641
- const { handle, info } = await openRegularFile(file);
1975
+ const opened = await openRegularFile(file);
1976
+ const { handle, info } = opened;
1642
1977
  try {
1643
1978
  if (info.size <= MAX_IMAGE_BYTES) {
1644
1979
  const buffer = await handle.readFile();
@@ -1651,7 +1986,7 @@ export async function takeScreenshotTool(args) {
1651
1986
  preserveCapture = true;
1652
1987
  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
1988
  } finally {
1654
- await handle.close();
1989
+ await opened.close();
1655
1990
  }
1656
1991
  } finally {
1657
1992
  if (!preserveCapture) {