@remcp/runtime 0.2.107 → 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.
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { constants, createReadStream } from 'node:fs';
6
- import { access, chmod, chown, copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
6
+ import { access, chmod, chown, copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
7
7
  import { pipeline } from 'node:stream/promises';
8
8
  import { liveConfig, runtimeConfig } from '../config.mjs';
9
9
  import { documentKind, readDocxText, readPdfText } from '../documents.mjs';
@@ -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,24 +49,326 @@ 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 openRegularFile(absolute, flags = READ_NOFOLLOW) {
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 } = {}) {
204
+ const descriptorBound = process.platform === 'linux' && Boolean(constants.O_NOFOLLOW) && Boolean(constants.O_DIRECTORY);
205
+ if (!descriptorBound) {
206
+ if (create) await mkdir(absolute, { recursive:true, mode:0o700 });
207
+ const info = await lstat(absolute);
208
+ if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a safe directory');
209
+ const handle = {
210
+ stat: () => stat(absolute),
211
+ chmod: mode => chmod(absolute, mode),
212
+ close: async () => {},
213
+ };
214
+ return { handle, anchor:absolute, descriptorBound:false, close: handle.close };
215
+ }
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');
221
+ const root = path.parse(absolute).root;
222
+ const parts = absolute.slice(root.length).split(path.sep).filter(Boolean);
223
+ if (!parts.length) {
224
+ const handle = await open(root, DIRECTORY_READ_NOFOLLOW);
225
+ return { handle, anchor: `/proc/self/fd/${handle.fd}`, close: () => handle.close() };
226
+ }
227
+ const directories = [];
228
+ let current = root;
229
+ const closeDirectories = async () => {
230
+ for (const handle of directories.reverse()) await handle.close().catch(() => {});
231
+ };
232
+ try {
233
+ for (const part of parts) {
234
+ const candidate = path.join(current, part);
235
+ if (create) {
236
+ await mkdir(candidate, { mode:0o700 }).catch(error => {
237
+ if (error?.code !== 'EEXIST') throw error;
238
+ });
239
+ }
240
+ const handle = await open(candidate, DIRECTORY_READ_NOFOLLOW);
241
+ const info = await handle.stat();
242
+ if (!info.isDirectory()) {
243
+ await handle.close().catch(() => {});
244
+ fail('Filesystem path component is not a directory');
245
+ }
246
+ directories.push(handle);
247
+ current = `/proc/self/fd/${handle.fd}`;
248
+ }
249
+ const handle = directories.pop();
250
+ return {
251
+ handle,
252
+ anchor: current,
253
+ close: async () => {
254
+ try { await handle.close(); }
255
+ finally { await closeDirectories(); }
256
+ },
257
+ };
258
+ } catch (error) {
259
+ await closeDirectories();
260
+ throw error;
261
+ }
262
+ }
263
+
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
+ }
53
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);
54
288
  try {
55
- const info = assertRegularFile(await handle.stat(), absolute);
56
- return { handle, info };
289
+ const info = assertRegularFile(await opened.handle.stat(), label);
290
+ return { handle: opened.handle, info, close: opened.close };
57
291
  } catch (error) {
58
- await handle.close().catch(() => {});
292
+ await opened.close().catch(() => {});
59
293
  throw error;
60
294
  }
61
295
  }
62
296
 
297
+ export async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
298
+ return openRegularFileAtPath(absolute, flags, absolute);
299
+ }
300
+
301
+ async function openWritableParent(absolute, options = {}) {
302
+ if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
303
+ return openDirectoryPath(absolute, { create:true, ...options });
304
+ }
305
+ await mkdir(absolute, { recursive:true });
306
+ const info = await lstat(absolute);
307
+ if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a directory');
308
+ return { anchor:absolute, close:async() => {} };
309
+ }
310
+
311
+ async function writeResolvedFile(absolute, content, { append = false } = {}) {
312
+ const parent = await openWritableParent(path.dirname(absolute));
313
+ let handle;
314
+ try {
315
+ const flags = append
316
+ ? constants.O_WRONLY | constants.O_CREAT | (constants.O_APPEND || 0) | NOFOLLOW | NONBLOCK
317
+ : WRITE_TRUNCATE_NOFOLLOW;
318
+ handle = await open(path.join(parent.anchor, path.basename(absolute)), flags, 0o600);
319
+ await handle.writeFile(content);
320
+ } finally {
321
+ if (handle) await handle.close().catch(() => {});
322
+ await parent.close().catch(() => {});
323
+ }
324
+ }
325
+
326
+ async function withWritableParent(absolute, callback) {
327
+ const parent = await openWritableParent(path.dirname(absolute));
328
+ try {
329
+ return await callback(path.join(parent.anchor, path.basename(absolute)));
330
+ } finally {
331
+ await parent.close().catch(() => {});
332
+ }
333
+ }
334
+
335
+ async function openExistingParent(absolute) {
336
+ if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
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 });
356
+ await directory.close();
357
+ return;
358
+ }
359
+ await mkdir(absolute, { recursive:true });
360
+ const info = await lstat(absolute);
361
+ if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a directory');
362
+ }
363
+
63
364
  async function readRegularBuffer(absolute, maxBytes = Infinity) {
64
- const { handle, info } = await openRegularFile(absolute);
365
+ const opened = await openRegularFile(absolute);
366
+ const { handle, info } = opened;
65
367
  try {
66
368
  if (info.size > maxBytes) fail(`File is too large to read inline (${info.size} bytes)`);
67
369
  return { info, buffer: await handle.readFile() };
68
370
  } finally {
69
- await handle.close();
371
+ await opened.close();
70
372
  }
71
373
  }
72
374
 
@@ -90,29 +392,65 @@ async function collectTree(root, { maxFiles = 500, skip = [] } = {}) {
90
392
  const found = [];
91
393
  const denied = [];
92
394
  const skipName = name => skip.some(entry => (entry.endsWith('*') ? name.startsWith(entry.slice(0, -1)) : name === entry));
93
- async function visit(target) {
94
- if (found.length >= maxFiles) return;
95
- const info = await lstat(target).catch(() => null);
96
- // A symlink is skipped on purpose (it can point outside the allowed roots); that is not a denial
97
- // and must not be reported as one.
98
- if (!info || info.isSymbolicLink()) return;
99
- if (info.isFile()) { found.push(target); return; }
100
- if (!info.isDirectory()) return;
395
+ const visitDirectory = async (directory, logicalBase) => {
101
396
  let entries;
102
397
  try {
103
- entries = await readdir(target, { withFileTypes: true });
398
+ entries = await readdir(directory.anchor, { withFileTypes: true });
104
399
  } catch {
105
- // Reporting the skip matters: silently dropping a directory made a partial walk look complete.
106
- denied.push(target);
400
+ denied.push(logicalBase);
107
401
  return;
108
402
  }
109
403
  for (const entry of entries) {
110
404
  if (found.length >= maxFiles) return;
111
405
  if (entry.isSymbolicLink() || skipName(entry.name)) continue;
112
- 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;
113
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(() => {});
450
+ }
451
+ } finally {
452
+ if (directory) await directory.close().catch(() => {});
114
453
  }
115
- await visit(root);
116
454
  return { files: found, denied };
117
455
  }
118
456
 
@@ -229,37 +567,64 @@ export async function hashFileTool(args) {
229
567
  }
230
568
  const { handle, info } = opened;
231
569
  const algorithm = String(args.algorithm || 'sha256').toLowerCase();
232
- 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'); }
233
571
  const hash = createHash(algorithm);
234
572
  try {
235
573
  await pipeline(createReadStream(absolute, { fd: handle.fd, autoClose: false }), hash);
236
574
  return text(`${algorithm} ${hash.digest('hex')} ${displayPath(absolute)} (${info.size} bytes)`);
237
575
  } finally {
238
- await handle.close();
576
+ await opened.close();
239
577
  }
240
578
  }
241
579
 
242
- 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) {
243
591
  let entries;
244
592
  try {
245
- entries = await readdir(base, { withFileTypes: true });
593
+ entries = await readdir(directory.anchor, { withFileTypes: true });
246
594
  } catch (error) {
247
- // One unreadable subdirectory must not abort the whole listing.
248
- 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'})`];
249
596
  }
250
597
  entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
251
598
  const rows = [];
252
599
  for (const entry of entries) {
253
- const child = path.join(base, entry.name);
600
+ const childDisplay = path.join(displayBase, entry.name);
254
601
  if (entry.isDirectory()) {
255
602
  rows.push(`${prefix}[DIR] ${entry.name}`);
256
- 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
+ }
257
615
  } else if (entry.isSymbolicLink()) {
258
616
  rows.push(`${prefix}[LINK] ${entry.name}`);
259
617
  } else {
260
618
  if (pattern && !pattern.test(entry.name)) continue;
261
- const info = await stat(child).catch(() => null);
262
- 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
+ }
263
628
  }
264
629
  }
265
630
  return rows;
@@ -267,39 +632,85 @@ async function listEntry(base, depth, maxDepth, prefix, pattern) {
267
632
 
268
633
  export async function listDirectoryTool(args) {
269
634
  const absolute = await resolveSafePath(args.path);
270
- const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
271
- if (!info.isDirectory()) fail(`${displayPath(absolute)} is not a directory`);
272
635
  const depth = clampInteger(args.depth, 1, 1, 5);
273
636
  const pattern = typeof args.pattern === 'string' && args.pattern.trim() ? globToRegExp(args.pattern.trim()) : null;
274
- const rows = await listEntry(absolute, 1, depth, '', pattern);
275
- 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(() => {}); }
276
676
  }
277
677
 
278
678
  export async function getFileInfoTool(args) {
279
679
  const absolute = await resolveSafePath(args.path);
280
- const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
281
- const payload = {
282
- path: displayPath(absolute),
283
- type: info.isDirectory() ? 'directory' : info.isSymbolicLink() ? 'symlink' : 'file',
284
- size: info.size,
285
- createdAt: info.birthtime.toISOString(),
286
- modifiedAt: info.mtime.toISOString(),
287
- permissions: `0${(info.mode & 0o777).toString(8)}`,
288
- };
289
- if (info.isFile() && info.size <= MAX_INLINE_FILE_BYTES) {
290
- const opened = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES).catch(() => null);
291
- if (opened) {
292
- const decoded = decodeText(opened.buffer);
293
- if (decoded.encoding !== 'utf8') payload.encoding = decoded.encoding;
294
- if (decoded.encoding !== 'utf8' || !looksBinary(opened.buffer)) {
295
- const lines = splitLines(decoded.text);
296
- payload.lineCount = lines.length;
297
- payload.lastLine = Math.max(0, lines.length - 1);
298
- 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
+ }
299
708
  }
300
709
  }
710
+ return text(JSON.stringify(payload, null, 2));
711
+ } finally {
712
+ await opened.close().catch(() => {});
301
713
  }
302
- return text(JSON.stringify(payload, null, 2));
303
714
  }
304
715
 
305
716
  function assertWritableSize(content) {
@@ -331,8 +742,7 @@ export async function writeFileTool(args) {
331
742
  fail(`Appending ${bytes} bytes would grow ${displayPath(absolute)} to ${existingSize + bytes} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
332
743
  }
333
744
  }
334
- await mkdir(path.dirname(absolute), { recursive: true });
335
- await writeFile(absolute, content, mode === 'append' ? { encoding: 'utf8', flag: 'a' } : 'utf8');
745
+ await writeResolvedFile(absolute, content, { append: mode === 'append' });
336
746
  countEvent('bytesWritten', bytes);
337
747
  return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${bytes} bytes to ${displayPath(absolute)}.`);
338
748
  }
@@ -367,7 +777,7 @@ export async function readBinaryTool(args) {
367
777
  });
368
778
  return text(payload);
369
779
  } finally {
370
- await handle.close();
780
+ await opened.close();
371
781
  }
372
782
  }
373
783
 
@@ -387,8 +797,7 @@ export async function writeBinaryTool(args) {
387
797
  recordEvent('write_denied', { reason: 'size_limit' });
388
798
  fail(`Decoded content is ${buffer.length} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
389
799
  }
390
- await mkdir(path.dirname(absolute), { recursive: true });
391
- await writeFile(absolute, buffer, mode === 'append' ? { flag: 'a' } : undefined);
800
+ await writeResolvedFile(absolute, buffer, { append: mode === 'append' });
392
801
  countEvent('bytesWritten', buffer.length);
393
802
  return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${buffer.length} bytes to ${displayPath(absolute)}.`);
394
803
  }
@@ -435,7 +844,7 @@ export async function editBlockTool(args) {
435
844
  if (occurrences === expected) {
436
845
  const updated = content.split(oldString).join(newString);
437
846
  assertWritableSize(updated);
438
- if (!dryRun) await writeFile(absolute, updated, 'utf8');
847
+ if (!dryRun) await writeResolvedFile(absolute, updated);
439
848
  return text(present(updated, `Replaced ${occurrences} occurrence(s)`));
440
849
  }
441
850
  if (occurrences > 0) {
@@ -457,7 +866,7 @@ export async function editBlockTool(args) {
457
866
  // file to LF and turned a one-line change into a whole-file diff on Windows.
458
867
  const updated = `${lines.join(eol)}${endsWithNewline && lines.length ? eol : ''}`;
459
868
  assertWritableSize(updated);
460
- if (!dryRun) await writeFile(absolute, updated, 'utf8');
869
+ if (!dryRun) await writeResolvedFile(absolute, updated);
461
870
  return text(present(updated, `Replaced ${starts.length} occurrence(s) using whitespace-tolerant matching (line endings kept as ${eol === '\r\n' ? 'CRLF' : 'LF'})`));
462
871
  }
463
872
 
@@ -482,7 +891,7 @@ export async function replaceLinesTool(args) {
482
891
  if (dryRun) {
483
892
  return text(`${summary}\n(dry run: nothing was written)\n${unifiedDiff(original, updatedText, { oldLabel: displayPath(absolute), newLabel: 'after' })}`);
484
893
  }
485
- await writeFile(absolute, updatedText, 'utf8');
894
+ await writeResolvedFile(absolute, updatedText);
486
895
  return text(`${summary}.`);
487
896
  }
488
897
 
@@ -502,7 +911,7 @@ export async function replaceInFilesTool(args) {
502
911
  fail(`pattern is not a valid regular expression (${error instanceof Error ? error.message : String(error)})`);
503
912
  }
504
913
  }
505
- 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)}`));
506
915
  const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'] });
507
916
  const files = await confineAll(walk.files);
508
917
  const glob = filePattern ? globToRegExp(filePattern) : null;
@@ -532,7 +941,7 @@ export async function replaceInFilesTool(args) {
532
941
  const stats = diffStats(original, updated);
533
942
  changed.push({ file: displayPath(file), replacements: count, added: stats.added, removed: stats.removed });
534
943
  } finally {
535
- await handle.close();
944
+ await opened.close();
536
945
  }
537
946
  }
538
947
  if (!changed.length) return text(`No matches for ${JSON.stringify(pattern)} in ${displayPath(root)} (${scanned} text files scanned).`);
@@ -562,7 +971,6 @@ function trashDirectoryFor() {
562
971
 
563
972
  export async function moveToTrashTool(args) {
564
973
  const source = await resolveSafePath(args.source, 'source');
565
- const info = await stat(source).catch(() => fail(`Path not found: ${displayPath(source)}`));
566
974
  const trash = trashDirectoryFor();
567
975
  let destination = null;
568
976
  if (trash) {
@@ -577,7 +985,7 @@ export async function moveToTrashTool(args) {
577
985
  const existingDestination = await lstat(destination).catch(() => null);
578
986
  if (existingDestination?.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
579
987
  if (existingDestination && !existingDestination.isDirectory()) fail('Trash destination is not a directory');
580
- await mkdir(destination, { recursive: true, mode: 0o700 });
988
+ await ensureDirectoryPath(destination);
581
989
  destination = await resolveSafePath(destination, 'trash');
582
990
  const destinationInfo = await lstat(destination);
583
991
  if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
@@ -588,11 +996,17 @@ export async function moveToTrashTool(args) {
588
996
  target = path.join(destination, `${stamp}-${counter}-${path.basename(source)}`);
589
997
  counter += 1;
590
998
  }
591
- await rename(source, target).catch(async error => {
592
- if (error?.code !== 'EXDEV') throw error;
593
- if (info.isDirectory()) fail('Moving a directory to the trash across filesystems is not supported');
594
- await copyFile(source, target);
595
- 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
+ });
1009
+ });
596
1010
  });
597
1011
  return text(`Moved ${displayPath(source)} to ${displayPath(target)}. Restore it with move_file if this was a mistake.`);
598
1012
  }
@@ -606,7 +1020,7 @@ export async function readFilesTool(args) {
606
1020
  const maxLinesPerFile = clampInteger(args.max_lines_per_file, liveConfig('maxReadLines'), 1, 20000);
607
1021
  const includeIgnored = args.include_ignored === true;
608
1022
  const matcher = globToRegExp(pattern);
609
- const rootInfo = await stat(root).catch(() => null);
1023
+ const rootInfo = await getPathInfo(root).catch(() => null);
610
1024
  // A file path is matched directly; a directory is walked without following links.
611
1025
  const walk = rootInfo?.isFile()
612
1026
  ? { files: [root], denied: [] }
@@ -661,8 +1075,7 @@ export async function writeFilesTool(args) {
661
1075
  const bytes = assertWritableSize(content);
662
1076
  totalBytes += bytes;
663
1077
  if (totalBytes > runtimeConfig.maxWriteBytes * 4) fail(`This call would write ${totalBytes} bytes, above the ${runtimeConfig.maxWriteBytes * 4}-byte batch limit`);
664
- await mkdir(path.dirname(absolute), { recursive: true });
665
- await writeFile(absolute, content, entry.mode === 'append' ? { encoding: 'utf8', flag: 'a' } : 'utf8');
1078
+ await writeResolvedFile(absolute, content, { append: entry.mode === 'append' });
666
1079
  results.push(`${entry.mode === 'append' ? 'appended' : 'wrote'} ${displayPath(absolute)} (${bytes} bytes)`);
667
1080
  } catch (error) {
668
1081
  results.push(`failed ${target}: ${describeFilesystemFailure(error, { path: error?.path })}`);
@@ -675,15 +1088,19 @@ export async function writeFilesTool(args) {
675
1088
 
676
1089
  export async function deletePathTool(args) {
677
1090
  const absolute = await resolveSafePath(args.path);
678
- const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
679
1091
  if (path.dirname(absolute) === absolute) fail(`Refusing to delete the filesystem root ${displayPath(absolute)}`);
680
1092
  const recursive = args.recursive !== false;
681
- if (info.isDirectory() && !recursive) {
682
- const entries = await readdir(absolute).catch(() => []);
683
- if (entries.length) fail(`Directory is not empty: ${displayPath(absolute)}. Pass recursive: true to delete it with its contents.`);
684
- }
685
- const entries = info.isDirectory() ? await readdir(absolute).catch(() => []) : [];
686
- 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
+ });
687
1104
  return text(`Deleted ${info.isDirectory() ? 'directory' : 'file'} ${displayPath(absolute)}${info.isDirectory() ? ` and its ${entries.length} top-level entr${entries.length === 1 ? 'y' : 'ies'}` : ''}.`);
688
1105
  }
689
1106
 
@@ -698,13 +1115,15 @@ export async function deletePathsTool(args) {
698
1115
  try {
699
1116
  const absolute = await resolveSafePath(entry, 'paths[]');
700
1117
  if (path.dirname(absolute) === absolute) throw new Error('refusing to delete the filesystem root');
701
- const info = await stat(absolute).catch(() => null);
702
- if (!info) throw new Error('not found');
703
- if (info.isDirectory() && !recursive) {
704
- const children = await readdir(absolute).catch(() => []);
705
- if (children.length) throw new Error('directory is not empty (pass recursive: true)');
706
- }
707
- 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
+ });
708
1127
  deleted += 1;
709
1128
  results.push(`deleted ${displayPath(absolute)}`);
710
1129
  } catch (error) {
@@ -728,12 +1147,11 @@ export async function copyPathsTool(args) {
728
1147
  const source = await resolveSafePath(entry?.source, 'paths[].source');
729
1148
  const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
730
1149
  if (source === destination) throw new Error('source and destination are the same path');
731
- const info = await stat(source).catch(() => null);
732
- if (!info) throw new Error('source not found');
733
- const existing = await stat(destination).catch(() => null);
734
- if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
735
- await mkdir(path.dirname(destination), { recursive: true });
736
- await cp(source, destination, { 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
+ });
737
1155
  copied += 1;
738
1156
  results.push(`copied ${displayPath(source)} → ${displayPath(destination)}`);
739
1157
  } catch (error) {
@@ -755,18 +1173,22 @@ export async function movePathsTool(args) {
755
1173
  const source = await resolveSafePath(entry?.source, 'paths[].source');
756
1174
  const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
757
1175
  if (source === destination) throw new Error('source and destination are the same path');
758
- const info = await stat(source).catch(() => null);
759
- if (!info) throw new Error('source not found');
760
- const existing = await stat(destination).catch(() => null);
761
- if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
762
- await mkdir(path.dirname(destination), { recursive: true });
763
- try {
764
- await rename(source, destination);
765
- } catch (error) {
766
- if (error?.code !== 'EXDEV') throw error;
767
- await cp(source, destination, { recursive: true, force: overwrite, errorOnExist: !overwrite });
768
- await rm(source, { recursive: true, force: true });
769
- }
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
+ });
1191
+ });
770
1192
  moved += 1;
771
1193
  results.push(`moved ${displayPath(source)} → ${displayPath(destination)}`);
772
1194
  } catch (error) {
@@ -801,8 +1223,7 @@ export async function applyPatchTool(args) {
801
1223
  const stats = diffStats(original, updated);
802
1224
  if (!dryRun) {
803
1225
  assertWritableSize(updated);
804
- await mkdir(path.dirname(absolute), { recursive: true });
805
- await writeFile(absolute, updated, 'utf8');
1226
+ await writeResolvedFile(absolute, updated);
806
1227
  }
807
1228
  changed += 1;
808
1229
  const fuzzy = applied.filter(entry => entry.fuzz > 0).length;
@@ -816,41 +1237,81 @@ export async function applyPatchTool(args) {
816
1237
  return text([`${changed}/${files.length} file(s) ${dryRun ? 'would be patched' : 'patched'}`, ...results].join('\n'), failedCount > 0);
817
1238
  }
818
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
+
819
1279
  export async function setPermissionsTool(args) {
820
1280
  const absolute = await resolveSafePath(args.path);
821
- const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
822
1281
  const raw = typeof args.mode === 'string' ? args.mode.trim() : String(args.mode ?? '');
823
1282
  if (!/^[0-7]{3,4}$/.test(raw)) fail('mode must be an octal string such as "755" or "0644"');
824
1283
  const mode = Number.parseInt(raw, 8);
825
1284
  const recursive = args.recursive === true;
826
1285
  const uid = Number.isInteger(Number(args.uid)) ? Number(args.uid) : null;
827
1286
  const gid = Number.isInteger(Number(args.gid)) ? Number(args.gid) : null;
828
- const targets = [absolute];
829
- if (recursive) {
830
- // Directories are included, links are not: chmod follows a link and would change a target
831
- // outside the allowed roots.
832
- const walk = async target => {
833
- const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
834
- for (const entry of entries) {
835
- if (entry.isSymbolicLink()) continue;
836
- const child = path.join(target, entry.name);
837
- targets.push(child);
838
- if (entry.isDirectory()) await walk(child);
839
- }
840
- };
841
- await walk(absolute);
842
- }
843
- const confined = await confineAll(targets);
844
- let changed = 0;
845
1287
  const failures = [];
846
- for (const target of confined) {
847
- try {
848
- await chmod(target, mode);
849
- if (uid !== null || gid !== null) await chown(target, uid ?? -1, gid ?? -1);
850
- changed += 1;
851
- } catch (error) {
852
- // One protected file must not abort a recursive change; the caller gets the full picture.
853
- 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
+ }
854
1315
  }
855
1316
  }
856
1317
  const summary = `Set mode ${raw}${uid !== null || gid !== null ? ` (uid ${uid ?? '-'} gid ${gid ?? '-'})` : ''} on ${changed} path(s) starting at ${displayPath(absolute)}.`;
@@ -867,7 +1328,7 @@ export async function createDirectoryTool(args) {
867
1328
  for (const entry of list) {
868
1329
  try {
869
1330
  const absolute = await resolveSafePath(entry);
870
- await mkdir(absolute, { recursive: true });
1331
+ await ensureDirectoryPath(absolute);
871
1332
  created.push(displayPath(absolute));
872
1333
  } catch (error) {
873
1334
  failed.push(`${entry}: ${describeFilesystemFailure(error, { path: error?.path })}`);
@@ -885,24 +1346,28 @@ export async function moveFileTool(args) {
885
1346
  const source = await resolveSafePath(args.source, 'source');
886
1347
  const destination = await resolveSafePath(args.destination, 'destination');
887
1348
  if (source === destination) fail('source and destination are the same path');
888
- await stat(source).catch(() => fail(`Source not found: ${displayPath(source)}`));
889
1349
  const overwrite = args.overwrite !== false;
890
- const existing = await stat(destination).catch(() => null);
891
- if (existing && !overwrite) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
892
- if (existing?.isDirectory()) {
893
- const entries = await readdir(destination).catch(() => []);
894
- if (entries.length) fail(`Destination is a non-empty directory: ${displayPath(destination)}. Move it aside or pick another name.`);
895
- }
896
- await mkdir(path.dirname(destination), { recursive: true });
897
- try {
898
- await rename(source, destination);
899
- } catch (error) {
900
- if (error?.code !== 'EXDEV') throw error;
901
- const info = await stat(source);
902
- if (info.isDirectory()) fail('Moving a directory across filesystems is not supported; copy it manually or move within one volume');
903
- await copyFile(source, destination);
904
- await unlink(source);
905
- }
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
+ });
1370
+ });
906
1371
  return text(`Moved ${displayPath(source)} to ${displayPath(destination)}.`);
907
1372
  }
908
1373
 
@@ -910,31 +1375,24 @@ export async function copyFileTool(args) {
910
1375
  const source = await resolveSafePath(args.source, 'source');
911
1376
  const destination = await resolveSafePath(args.destination, 'destination');
912
1377
  if (source === destination) fail('source and destination are the same path');
913
- const info = await stat(source).catch(() => fail(`Source not found: ${displayPath(source)}`));
914
- if (info.isDirectory()) fail('copy_file copies single files only; create the directory and copy its files individually');
915
1378
  const overwrite = args.overwrite !== false;
916
- if (!overwrite && await pathExists(destination)) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
917
- await mkdir(path.dirname(destination), { recursive: true });
918
- await copyFile(source, destination, 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
+ });
919
1392
  return text(`Copied ${displayPath(source)} to ${displayPath(destination)} (${info.size} bytes).`);
920
1393
  }
921
1394
 
922
1395
  // --- archives -------------------------------------------------------------------------
923
- async function assertNoSymlinkTree(root, state = { entries: 0 }) {
924
- const info = await lstat(root);
925
- if (info.isSymbolicLink()) fail(`Archive sources cannot contain symbolic links: ${displayPath(root)}`);
926
- if (!info.isDirectory() && !info.isFile()) fail(`Archive sources cannot contain special files: ${displayPath(root)}`);
927
- state.entries += 1;
928
- if (state.entries > 100_000) fail('Archive source contains too many entries');
929
- if (state.maxBytes !== undefined && info.isFile()) {
930
- state.bytes = (state.bytes || 0) + info.size;
931
- if (state.bytes > state.maxBytes) fail('Archive source exceeds the safety limit');
932
- }
933
- if (!info.isDirectory()) return;
934
- const entries = await readdir(root, { withFileTypes: true });
935
- for (const entry of entries) await assertNoSymlinkTree(path.join(root, entry.name), state);
936
- }
937
-
938
1396
  const MAX_ARCHIVE_EXPANDED_BYTES = Math.min(
939
1397
  4 * 1024 * 1024 * 1024,
940
1398
  Math.max(64 * 1024 * 1024, Number(process.env.REMCP_MAX_ARCHIVE_EXPANDED_BYTES) || 512 * 1024 * 1024),
@@ -1007,43 +1465,7 @@ async function assertExtractedTree(root, state = { entries: 0, bytes: 0 }) {
1007
1465
  }
1008
1466
 
1009
1467
  async function openArchiveSource(absolute) {
1010
- if (process.platform !== 'linux') {
1011
- const opened = await openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
1012
- return { ...opened, close: () => opened.handle.close() };
1013
- }
1014
- const root = path.parse(absolute).root;
1015
- const parts = absolute.slice(root.length).split(path.sep).filter(Boolean);
1016
- if (!parts.length) return openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
1017
- const directories = [];
1018
- let current = root;
1019
- const closeDirectories = async () => {
1020
- for (const handle of directories.reverse()) await handle.close().catch(() => {});
1021
- };
1022
- try {
1023
- for (const part of parts.slice(0, -1)) {
1024
- const handle = await open(path.join(current, part), DIRECTORY_READ_NOFOLLOW);
1025
- try {
1026
- const info = await handle.stat();
1027
- if (!info.isDirectory()) fail('Archive source is not a safe regular file');
1028
- } catch (error) {
1029
- await handle.close().catch(() => {});
1030
- throw error;
1031
- }
1032
- directories.push(handle);
1033
- current = `/proc/self/fd/${handle.fd}`;
1034
- }
1035
- const opened = await openRegularFile(path.join(current, parts.at(-1)), READ_NOFOLLOW_NONBLOCK);
1036
- return {
1037
- ...opened,
1038
- close: async () => {
1039
- try { await opened.handle.close(); }
1040
- finally { await closeDirectories(); }
1041
- },
1042
- };
1043
- } catch (error) {
1044
- await closeDirectories();
1045
- throw error;
1046
- }
1468
+ return openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
1047
1469
  }
1048
1470
 
1049
1471
  async function snapshotRegularFile(source, destination, maxBytes) {
@@ -1127,47 +1549,48 @@ async function copyOpenRegularFile(sourceHandle, sourceInfo, destinationPath, st
1127
1549
 
1128
1550
  async function copyTreeContents(source, destination, state = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES }) {
1129
1551
  const initial = await lstat(source);
1130
- if (initial.isSymbolicLink() || (!initial.isDirectory() && !initial.isFile())) fail('Archive contains a link or special file; archive destination is unsafe');
1552
+ if (initial.isSymbolicLink() || (!initial.isDirectory() && !initial.isFile())) fail('Archive contains symbolic links or special files; archive destination is unsafe');
1131
1553
  state.entries += 1;
1132
1554
  if (state.entries > 100_000) fail('Archive contains too many entries');
1133
1555
  if (initial.isFile()) {
1134
- const opened = await openRegularFile(source, READ_NOFOLLOW_NONBLOCK);
1556
+ const opened = source.startsWith('/proc/self/fd/')
1557
+ ? await openRegularFile(source, READ_NOFOLLOW_NONBLOCK)
1558
+ : await openArchiveSource(source);
1135
1559
  try { await copyOpenRegularFile(opened.handle, opened.info, destination, state); }
1136
- finally { await opened.handle.close(); }
1560
+ finally { if (opened.close) await opened.close(); else await opened.handle.close(); }
1137
1561
  return;
1138
1562
  }
1139
1563
  let directoryHandle = null;
1140
1564
  let sourcePath = source;
1141
1565
  let info = initial;
1142
1566
  try {
1143
- if (process.platform === 'linux') {
1144
- try {
1145
- directoryHandle = await open(source, DIRECTORY_READ_NOFOLLOW);
1146
- info = await directoryHandle.stat();
1147
- if (!info.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
1148
- sourcePath = `/proc/self/fd/${directoryHandle.fd}`;
1149
- } catch (error) {
1150
- if (['ELOOP', 'ENOTDIR'].includes(error?.code)) fail('Archive contains a link or special file; archive destination is unsafe');
1151
- throw error;
1152
- }
1567
+ try {
1568
+ directoryHandle = await openDirectoryPath(source, { allowOutside:state.allowOutsideSource === true });
1569
+ info = await directoryHandle.handle.stat();
1570
+ if (!info.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
1571
+ sourcePath = directoryHandle.anchor;
1572
+ } catch (error) {
1573
+ if (['ELOOP', 'ENOTDIR'].includes(error?.code)) fail('Archive contains a link or special file; archive destination is unsafe');
1574
+ throw error;
1153
1575
  }
1154
1576
  const mode = info.mode & 0o777;
1155
1577
  const existing = await lstat(destination).catch(() => null);
1156
1578
  if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) fail('Archive contains a link or special file; archive destination is unsafe');
1157
- try { await mkdir(destination, { mode: mode | 0o700 }); }
1579
+ try { await ensureDirectoryPath(destination, { allowOutside:true }); }
1158
1580
  catch (error) { if (error?.code !== 'EEXIST') throw error; }
1159
1581
  const destinationInfo = await lstat(destination);
1160
1582
  if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Archive contains a link or special file; archive destination is unsafe');
1161
1583
  let destinationHandle;
1162
1584
  try {
1163
- destinationHandle = await open(destination, DIRECTORY_READ_NOFOLLOW);
1164
- const openedDestination = await destinationHandle.stat();
1585
+ destinationHandle = await openDirectoryPath(destination, { allowOutside:true });
1586
+ const openedDestination = await destinationHandle.handle.stat();
1165
1587
  if (!openedDestination.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
1166
- await destinationHandle.chmod(mode | 0o700);
1588
+ await destinationHandle.handle.chmod(mode | 0o700);
1589
+ const destinationAnchor = destinationHandle.anchor;
1167
1590
  for (const name of await readdir(sourcePath)) {
1168
- await copyTreeContents(path.join(sourcePath, name), path.join(destination, name), state);
1591
+ await copyTreeContents(path.join(sourcePath, name), path.join(destinationAnchor, name), state);
1169
1592
  }
1170
- await destinationHandle.chmod(mode);
1593
+ await destinationHandle.handle.chmod(mode);
1171
1594
  } finally {
1172
1595
  if (destinationHandle) await destinationHandle.close().catch(() => {});
1173
1596
  }
@@ -1184,32 +1607,39 @@ async function removeExtractionArtifact(target) {
1184
1607
  }
1185
1608
 
1186
1609
  async function recoverExtractionArtifacts(parent, destination, prefix) {
1187
- const entries = await readdir(parent, { withFileTypes: true });
1188
- const staleBefore = Date.now() - 60 * 60 * 1000;
1189
- for (const entry of entries) {
1190
- if (!entry.name.startsWith('.remcp-extract-txn-')) continue;
1191
- const candidate = path.join(parent, entry.name);
1192
- const info = await lstat(candidate).catch(() => null);
1193
- if (info && info.mtimeMs < staleBefore) await removeExtractionArtifact(candidate);
1194
- }
1195
- const backups = entries.filter(entry => entry.name.startsWith(prefix)).map(entry => path.join(parent, entry.name));
1196
- for (const backup of backups) {
1197
- const destinationInfo = await lstat(destination).catch(() => null);
1198
- if (destinationInfo) {
1199
- await removeExtractionArtifact(backup);
1200
- continue;
1201
- }
1202
- const backupInfo = await lstat(backup).catch(() => null);
1203
- if (!backupInfo) continue;
1204
- if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) {
1205
- await removeExtractionArtifact(backup);
1206
- continue;
1610
+ const parentDirectory = await openDirectoryPath(parent, { create:true });
1611
+ const anchor = parentDirectory.anchor;
1612
+ const destinationPath = path.join(anchor, path.basename(destination));
1613
+ try {
1614
+ const entries = await readdir(anchor, { withFileTypes: true });
1615
+ const staleBefore = Date.now() - 60 * 60 * 1000;
1616
+ for (const entry of entries) {
1617
+ if (!entry.name.startsWith('.remcp-extract-txn-')) continue;
1618
+ const candidate = path.join(anchor, entry.name);
1619
+ const info = await lstat(candidate).catch(() => null);
1620
+ if (info && info.mtimeMs < staleBefore) await removeExtractionArtifact(candidate);
1207
1621
  }
1208
- try {
1209
- await rename(backup, destination);
1210
- } catch {
1211
- continue;
1622
+ const backups = entries.filter(entry => entry.name.startsWith(prefix)).map(entry => path.join(anchor, entry.name));
1623
+ for (const backup of backups) {
1624
+ const destinationInfo = await lstat(destinationPath).catch(() => null);
1625
+ if (destinationInfo) {
1626
+ await removeExtractionArtifact(backup);
1627
+ continue;
1628
+ }
1629
+ const backupInfo = await lstat(backup).catch(() => null);
1630
+ if (!backupInfo) continue;
1631
+ if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) {
1632
+ await removeExtractionArtifact(backup);
1633
+ continue;
1634
+ }
1635
+ try {
1636
+ await rename(backup, destinationPath);
1637
+ } catch {
1638
+ continue;
1639
+ }
1212
1640
  }
1641
+ } finally {
1642
+ await parentDirectory.close().catch(() => {});
1213
1643
  }
1214
1644
  }
1215
1645
 
@@ -1232,20 +1662,23 @@ async function restoreExtractionBackup(backup, destination) {
1232
1662
  async function installExtractedTree(source, destination) {
1233
1663
  const parent = path.dirname(destination);
1234
1664
  if (destination === path.parse(destination).root) fail('Archive destination cannot be a filesystem root');
1235
- await mkdir(parent, { recursive: true, mode: 0o700 });
1236
1665
  const backupPrefix = `.remcp-extract-backup-${createHash('sha256').update(destination).digest('hex').slice(0, 16)}-`;
1237
1666
  await recoverExtractionArtifacts(parent, destination, backupPrefix);
1238
- const existing = await lstat(destination).catch(() => null);
1667
+ const parentDirectory = await openDirectoryPath(parent, { create:true });
1668
+ const parentAnchor = parentDirectory.anchor;
1669
+ const destinationPath = path.join(parentAnchor, path.basename(destination));
1670
+ const existing = await lstat(destinationPath).catch(() => null);
1239
1671
  const destinationMode = existing?.isDirectory() ? existing.mode & 0o777 : 0o700;
1240
- const transaction = await mkdtemp(path.join(parent, '.remcp-extract-txn-'));
1672
+ let transaction = '';
1241
1673
  let backup = '';
1242
1674
  let backupOwned = false;
1243
1675
  let preserveBackup = false;
1244
1676
  try {
1677
+ transaction = await mkdtemp(path.join(parentAnchor, '.remcp-extract-txn-'));
1245
1678
  if (existing) {
1246
- backup = path.join(parent, `${backupPrefix}${randomUUID()}`);
1679
+ backup = path.join(parentAnchor, `${backupPrefix}${randomUUID()}`);
1247
1680
  try {
1248
- await rename(destination, backup);
1681
+ await rename(destinationPath, backup);
1249
1682
  backupOwned = true;
1250
1683
  } catch (error) {
1251
1684
  if (error?.code !== 'ENOENT') throw error;
@@ -1255,11 +1688,11 @@ async function installExtractedTree(source, destination) {
1255
1688
  if (backupOwned) {
1256
1689
  const backupInfo = await lstat(backup);
1257
1690
  if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) fail('Archive destination is not a safe directory');
1258
- await copyTreeContents(backup, transaction);
1691
+ await copyTreeContents(backup, transaction, { entries:0, bytes:0, maxBytes:MAX_ARCHIVE_EXPANDED_BYTES, allowOutsideSource:true });
1259
1692
  }
1260
- await copyTreeContents(source, transaction);
1693
+ await copyTreeContents(source, transaction, { entries:0, bytes:0, maxBytes:MAX_ARCHIVE_EXPANDED_BYTES, allowOutsideSource:true });
1261
1694
  await chmod(transaction, destinationMode);
1262
- await rename(transaction, destination);
1695
+ await rename(transaction, destinationPath);
1263
1696
  if (backupOwned) {
1264
1697
  await removeExtractionArtifact(backup);
1265
1698
  backupOwned = false;
@@ -1267,7 +1700,7 @@ async function installExtractedTree(source, destination) {
1267
1700
  } catch (error) {
1268
1701
  if (backupOwned) {
1269
1702
  try {
1270
- await restoreExtractionBackup(backup, destination);
1703
+ await restoreExtractionBackup(backup, destinationPath);
1271
1704
  if (!await lstat(backup).catch(() => null)) backupOwned = false;
1272
1705
  else preserveBackup = true;
1273
1706
  } catch (restoreError) {
@@ -1277,24 +1710,32 @@ async function installExtractedTree(source, destination) {
1277
1710
  }
1278
1711
  throw error;
1279
1712
  } finally {
1280
- await rm(transaction, { recursive: true, force: true });
1713
+ if (transaction) await rm(transaction, { recursive: true, force: true });
1281
1714
  if (backupOwned && !preserveBackup) await removeExtractionArtifact(backup);
1715
+ await parentDirectory.close().catch(() => {});
1282
1716
  }
1283
1717
  }
1284
1718
 
1285
1719
  async function moveArchiveFile(source, destination) {
1720
+ const parent = path.dirname(destination);
1721
+ const parentDirectory = await openDirectoryPath(parent, { create:true });
1722
+ const destinationPath = path.join(parentDirectory.anchor, path.basename(destination));
1286
1723
  try {
1287
- await rename(source, destination);
1288
- } catch (error) {
1289
- if (!['EXDEV', 'EPERM'].includes(error?.code)) throw error;
1290
- const temporary = path.join(path.dirname(destination), `.${path.basename(destination)}.remcp-${randomUUID()}.tmp`);
1291
1724
  try {
1292
- await copyFile(source, temporary, constants.COPYFILE_EXCL);
1293
- await rename(temporary, destination);
1294
- } finally {
1295
- await unlink(temporary).catch(() => {});
1725
+ await rename(source, destinationPath);
1726
+ } catch (error) {
1727
+ if (!['EXDEV', 'EPERM'].includes(error?.code)) throw error;
1728
+ const temporary = path.join(parentDirectory.anchor, `.${path.basename(destination)}.remcp-${randomUUID()}.tmp`);
1729
+ try {
1730
+ await copyFile(source, temporary, constants.COPYFILE_EXCL);
1731
+ await rename(temporary, destinationPath);
1732
+ } finally {
1733
+ await unlink(temporary).catch(() => {});
1734
+ }
1735
+ await unlink(source);
1296
1736
  }
1297
- await unlink(source);
1737
+ } finally {
1738
+ await parentDirectory.close().catch(() => {});
1298
1739
  }
1299
1740
  }
1300
1741
 
@@ -1314,13 +1755,10 @@ export async function createArchiveTool(args) {
1314
1755
  if (!sources.length) fail('paths must list at least one file or directory');
1315
1756
  const resolved = [];
1316
1757
  for (const entry of sources) resolved.push(await resolveSafePath(entry, 'paths[]'));
1317
- const sourceState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES };
1318
- for (const entry of resolved) await assertNoSymlinkTree(entry, sourceState);
1319
1758
  const destination = await resolveSafePath(args.destination, 'destination');
1320
1759
  const existingDestination = await lstat(destination).catch(() => null);
1321
1760
  if (existingDestination?.isSymbolicLink()) fail('Archive destination cannot be a symbolic link');
1322
1761
  const format = String(args.format || (destination.endsWith('.zip') ? 'zip' : 'tar.gz')).toLowerCase();
1323
- await mkdir(path.dirname(destination), { recursive: true });
1324
1762
  const baseDir = path.dirname(resolved[0]);
1325
1763
  const names = resolved.map(entry => path.relative(baseDir, entry));
1326
1764
  if (names.some(name => !name || name.startsWith('..') || path.isAbsolute(name))) fail('Archive sources must share one parent directory');
@@ -1334,11 +1772,13 @@ export async function createArchiveTool(args) {
1334
1772
  const staging = path.join(stagingDirectory, `payload${suffix}`);
1335
1773
  const snapshotRoot = path.join(stagingDirectory, 'source');
1336
1774
  try {
1337
- await mkdir(snapshotRoot, { mode: 0o700 });
1775
+ await ensureDirectoryPath(snapshotRoot, { allowOutside:true });
1776
+
1338
1777
  const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES };
1339
1778
  for (let index = 0; index < resolved.length; index += 1) {
1340
1779
  const snapshotPath = path.join(snapshotRoot, names[index]);
1341
- await mkdir(path.dirname(snapshotPath), { recursive: true, mode: 0o700 });
1780
+ await ensureDirectoryPath(path.dirname(snapshotPath), { allowOutside:true });
1781
+
1342
1782
  await copyTreeContents(resolved[index], snapshotPath, snapshotState);
1343
1783
  }
1344
1784
  if (format === 'zip') {
@@ -1353,11 +1793,11 @@ export async function createArchiveTool(args) {
1353
1793
  } else {
1354
1794
  fail('format must be tar, tar.gz, or zip');
1355
1795
  }
1356
- const stagedInfo = await stat(staging);
1357
- assertArchiveExpandedSize(stagedInfo.size, 'archive output');
1358
- await mkdir(path.dirname(destination), { recursive: true });
1796
+ const stagedInfo = await stat(staging);
1797
+ assertArchiveExpandedSize(stagedInfo.size, 'archive output');
1798
+
1799
+ await moveArchiveFile(staging, destination);
1359
1800
 
1360
- await moveArchiveFile(staging, destination);
1361
1801
  const info = await stat(destination).catch(() => null);
1362
1802
  const note = ' (built outside the tree in temporary staging and committed atomically)';
1363
1803
 
@@ -1397,7 +1837,6 @@ export async function extractArchiveTool(args) {
1397
1837
  try {
1398
1838
  await mkdir(staging, { mode: 0o700 });
1399
1839
  await snapshotRegularFile(archive, archiveSnapshot, MAX_ARCHIVE_EXPANDED_BYTES);
1400
- await mkdir(parent, { recursive: true, mode: 0o700 });
1401
1840
  await recoverExtractionArtifacts(parent, destination, backupPrefix);
1402
1841
  const existingDestination = await lstat(destination).catch(() => null);
1403
1842
  if (existingDestination?.isSymbolicLink() || (existingDestination && !existingDestination.isDirectory())) fail('Archive destination is not a safe directory');
@@ -1493,8 +1932,17 @@ export async function takeScreenshotTool(args) {
1493
1932
  const requestedDirectory = args.directory ? await resolveSafePath(args.directory, 'directory') : '';
1494
1933
  const temporaryDirectory = requestedDirectory ? '' : await mkdtemp(path.join(os.tmpdir(), 'remcp-screenshot-'));
1495
1934
  const directory = requestedDirectory || temporaryDirectory;
1496
- if (requestedDirectory) await mkdir(directory, { recursive: true });
1497
- const file = path.join(directory, `capture-${randomUUID()}.png`);
1935
+ let outputDirectory = null;
1936
+ if (requestedDirectory) {
1937
+ await ensureDirectoryPath(directory);
1938
+ outputDirectory = await openWritableParent(directory);
1939
+ }
1940
+ const outputAnchor = outputDirectory
1941
+ ? outputDirectory.anchor.replace('/proc/self/fd/', `/proc/${process.pid}/fd/`)
1942
+ : directory;
1943
+ const fileName = `capture-${randomUUID()}.png`;
1944
+ const file = path.join(outputAnchor, fileName);
1945
+ const displayFile = path.join(directory, fileName);
1498
1946
  const attempts = [];
1499
1947
  let preserveCapture = false;
1500
1948
  try {
@@ -1524,26 +1972,28 @@ export async function takeScreenshotTool(args) {
1524
1972
  }
1525
1973
  if (!await pathExists(file)) fail(screenshotAdvice(attempts));
1526
1974
 
1527
- const { handle, info } = await openRegularFile(file);
1975
+ const opened = await openRegularFile(file);
1976
+ const { handle, info } = opened;
1528
1977
  try {
1529
1978
  if (info.size <= MAX_IMAGE_BYTES) {
1530
1979
  const buffer = await handle.readFile();
1531
1980
  preserveCapture = args.keep === true;
1532
1981
  return multi([
1533
- { type: 'text', text: `Screenshot of ${os.hostname()} (${info.size} bytes)${preserveCapture ? (' saved at ' + displayPath(file)) : ''}` },
1982
+ { type: 'text', text: `Screenshot of ${os.hostname()} (${info.size} bytes)${preserveCapture ? (' saved at ' + displayPath(displayFile)) : ''}` },
1534
1983
  image(buffer.toString('base64'), 'image/png'),
1535
1984
  ]);
1536
1985
  }
1537
1986
  preserveCapture = true;
1538
- return text(`Screenshot of ${os.hostname()} captured: ${info.size} bytes, above the ${MAX_IMAGE_BYTES}-byte inline limit, so it is saved at ${displayPath(file)} 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.`);
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.`);
1539
1988
  } finally {
1540
- await handle.close();
1989
+ await opened.close();
1541
1990
  }
1542
1991
  } finally {
1543
1992
  if (!preserveCapture) {
1544
1993
  if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => {});
1545
1994
  else await rm(file, { force: true }).catch(() => {});
1546
1995
  }
1996
+ if (outputDirectory) await outputDirectory.close().catch(() => {});
1547
1997
  }
1548
1998
  }
1549
1999
  export const fileToolHandlers = {