@remcp/runtime 0.2.106 → 0.2.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.106",
3
+ "version": "0.2.107",
4
4
  "description": "First-party ReMCP local device runtime: filesystem, terminal, computer-use, browser, diagnostics and document tools over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
4
4
  import { expandHome } from './util.mjs';
5
5
 
6
6
  const configDir = process.env.REMCP_RUNTIME_CONFIG_DIR || path.join(os.homedir(), '.config', 'remcp');
@@ -161,7 +161,17 @@ export function persistConfigValue(key, value) {
161
161
  const file = readConfigFile();
162
162
  const next = { ...file, [key]: value };
163
163
  mkdirSync(runtimeConfigDir, { recursive: true, mode: 0o700 });
164
- writeFileSync(runtimeConfigPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
164
+ const existing = lstatSync(runtimeConfigPath, { throwIfNoEntry: false });
165
+ if (existing && (existing.isSymbolicLink() || !existing.isFile())) throw new Error('runtime configuration path must be a regular file');
166
+ const temporary = `${runtimeConfigPath}.${process.pid}.${Date.now()}.tmp`;
167
+ try {
168
+ writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
169
+ renameSync(temporary, runtimeConfigPath);
170
+ chmodSync(runtimeConfigPath, 0o600);
171
+ } catch (error) {
172
+ try { unlinkSync(temporary); } catch {}
173
+ throw error;
174
+ }
165
175
  return runtimeConfigPath;
166
176
  }
167
177
 
package/src/documents.mjs CHANGED
@@ -14,6 +14,8 @@ import { inflateRawSync, inflateSync } from 'node:zlib';
14
14
  // Nothing here writes, and nothing leaves the computer.
15
15
 
16
16
  const MAX_INFLATE_BYTES = 32 * 1024 * 1024;
17
+ const MAX_PDF_DECODED_BYTES = 128 * 1024 * 1024;
18
+ const MAX_PDF_STREAMS = 10_000;
17
19
 
18
20
  function inflate(buffer, raw = false) {
19
21
  try {
@@ -260,6 +262,8 @@ function pdfTextPieces(content) {
260
262
  export function readPdfText(buffer) {
261
263
  const raw = buffer.toString('latin1');
262
264
  const chunks = [];
265
+ let decodedBytes = 0;
266
+ let streamCount = 0;
263
267
  let index = 0;
264
268
  while (index < raw.length) {
265
269
  const streamStart = raw.indexOf('stream', index);
@@ -271,6 +275,9 @@ export function readPdfText(buffer) {
271
275
  if (end < 0) break;
272
276
  const body = Buffer.from(raw.slice(start, end), 'latin1');
273
277
  const decoded = body.subarray(0, 5).toString('latin1') === '<?xml' ? body : (inflate(body) ?? inflate(body, true) ?? body);
278
+ decodedBytes += decoded.length;
279
+ streamCount += 1;
280
+ if (decodedBytes > MAX_PDF_DECODED_BYTES || streamCount > MAX_PDF_STREAMS) throw new Error('PDF decoded content exceeds the safety limit');
274
281
  chunks.push(decoded.toString('latin1'));
275
282
  index = end + 'endstream'.length;
276
283
  }
@@ -1,12 +1,17 @@
1
1
  import process from 'node:process';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { isIP } from 'node:net';
4
5
  import { existsSync, mkdirSync } from 'node:fs';
5
6
  import { resolveSafePath } from '../util.mjs';
6
7
  import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
7
8
 
8
9
  const DEFAULT_ENDPOINT = 'http://127.0.0.1:9222';
9
10
  const MAX_DISCOVERY_BYTES = 8 * 1024 * 1024;
11
+ const MAX_CDP_MESSAGE_BYTES = 16 * 1024 * 1024;
12
+ const MAX_EVALUATION_BYTES = 256 * 1024;
13
+ const BROWSER_PROTOCOLS = new Set(['http:', 'https:']);
14
+ const BLOCKED_BROWSER_SCHEME = /(?:^|[\s"'`(=])(?:file|chrome|devtools|view-source|filesystem|blob):/i;
10
15
 
11
16
  let browserLaunchPromise = null;
12
17
 
@@ -86,11 +91,67 @@ function boundedText(value, max = 1000) {
86
91
  return text.length <= max ? text : `${text.slice(0, max)}…`;
87
92
  }
88
93
 
94
+ function normalizedHost(hostname) {
95
+ return String(hostname || '').replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
96
+ }
97
+
89
98
  function isLoopbackHost(hostname) {
90
- const host = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase();
99
+ const host = normalizedHost(hostname);
91
100
  return ['localhost', '127.0.0.1', '::1'].includes(host);
92
101
  }
93
102
 
103
+ function localBrowserNavigationAllowed() {
104
+ return ['REMCP_BROWSER_ALLOW_LOCAL_NAVIGATION', 'REMCP_BROWSER_ALLOW_LOCAL', 'REMCP_ALLOW_LOCAL_BROWSER_NAVIGATION']
105
+ .some(name => ['1', 'true', 'yes', 'on'].includes(String(process.env[name] || '').trim().toLowerCase()));
106
+ }
107
+
108
+ function isPrivateIpv4(host) {
109
+ if (isIP(host) !== 4) return false;
110
+ const parts = host.split('.').map(Number);
111
+ const [a, b, c] = parts;
112
+ return a === 0
113
+ || a === 10
114
+ || a === 127
115
+ || (a === 100 && b >= 64 && b <= 127)
116
+ || (a === 169 && b === 254)
117
+ || (a === 172 && b >= 16 && b <= 31)
118
+ || (a === 192 && b === 0 && c === 0)
119
+ || (a === 192 && b === 0 && c === 2)
120
+ || (a === 192 && b === 168)
121
+ || (a === 198 && (b === 18 || b === 19))
122
+ || (a === 198 && b === 51 && c === 100)
123
+ || (a === 203 && b === 0 && c === 113)
124
+ || a >= 224;
125
+ }
126
+
127
+ function isPrivateIpv6(host) {
128
+ if (isIP(host) !== 6) return false;
129
+ if (host === '::' || host === '::1') return true;
130
+ if (/^f[cd]/.test(host) || /^fe[89ab]/.test(host) || host.startsWith('ff')) return true;
131
+ const mapped = host.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
132
+ if (mapped) {
133
+ const high = Number.parseInt(mapped[1], 16);
134
+ const low = Number.parseInt(mapped[2], 16);
135
+ return isPrivateIpv4([high >> 8, high & 255, low >> 8, low & 255].join('.'));
136
+ }
137
+ return false;
138
+ }
139
+
140
+ function isPrivateBrowserHost(hostname) {
141
+ const host = normalizedHost(hostname);
142
+ if (!host) return true;
143
+ if (isPrivateIpv4(host) || isPrivateIpv6(host)) return true;
144
+ return host === 'localhost'
145
+ || host.endsWith('.localhost')
146
+ || host.endsWith('.local')
147
+ || host.endsWith('.internal')
148
+ || host.endsWith('.home.arpa')
149
+ || host.endsWith('.lan')
150
+ || host === 'localhost.localdomain'
151
+ || host === 'ip6-localhost'
152
+ || host === 'ip6-loopback';
153
+ }
154
+
94
155
  function endpointUrl(value) {
95
156
  let url;
96
157
  try { url = new URL(optionalString(value) || process.env.REMCP_CDP_URL || DEFAULT_ENDPOINT); }
@@ -100,13 +161,51 @@ function endpointUrl(value) {
100
161
  return url;
101
162
  }
102
163
 
164
+ function safeBrowserUrl(value, field = 'url') {
165
+ let url;
166
+ try { url = new URL(String(value || '')); }
167
+ catch { throw new Error(`${field} must be a valid absolute URL`); }
168
+ if (!BROWSER_PROTOCOLS.has(url.protocol)) throw new Error(`${field} must use http or https`);
169
+ if (url.username || url.password) throw new Error(`${field} must not embed credentials`);
170
+ if (!localBrowserNavigationAllowed() && isPrivateBrowserHost(url.hostname)) {
171
+ throw new Error(`${field} must not target loopback, private, or link-local addresses`);
172
+ }
173
+ return url.href;
174
+ }
175
+
176
+ function safePageUrl(value, field = 'browser page URL') {
177
+ if (String(value || '') === 'about:blank') return value;
178
+ return safeBrowserUrl(value, field);
179
+ }
180
+
181
+ function expressionHasPrivateDestination(expression) {
182
+ const urls = expression.match(/https?:\/\/[^\s"'`<>]+/gi) || [];
183
+ return urls.some(value => {
184
+ try { return isPrivateBrowserHost(new URL(value).hostname); }
185
+ catch { return false; }
186
+ });
187
+ }
188
+
189
+ function safeEvaluationExpression(value) {
190
+ const expression = String(value || '');
191
+ if (!expression.trim()) throw new Error('expression is required');
192
+ if (expression.includes('\0') || Buffer.byteLength(expression, 'utf8') > MAX_EVALUATION_BYTES) {
193
+ throw new Error(`expression must be at most ${MAX_EVALUATION_BYTES} bytes`);
194
+ }
195
+ if (BLOCKED_BROWSER_SCHEME.test(expression)) throw new Error('expression contains a blocked local browser scheme');
196
+ if (!localBrowserNavigationAllowed() && expressionHasPrivateDestination(expression)) {
197
+ throw new Error('expression contains a blocked private browser destination');
198
+ }
199
+ return expression;
200
+ }
201
+
103
202
  async function requestJson(pathname, endpoint, options = {}) {
104
203
  const base = endpointUrl(endpoint);
105
204
  const url = new URL(pathname, base);
106
205
  const controller = new AbortController();
107
206
  const timer = setTimeout(() => controller.abort(), clamp(options.timeout_ms, 5000, 100, 30_000));
108
207
  try {
109
- const response = await fetch(url, { method: options.method || 'GET', signal: controller.signal });
208
+ const response = await fetch(url, { method: options.method || 'GET', signal: controller.signal, redirect: 'error' });
110
209
  if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
111
210
  const declared = Number(response.headers.get('content-length') || 0);
112
211
  if (declared > MAX_DISCOVERY_BYTES) throw new Error(`CDP discovery response is too large (${declared} bytes)`);
@@ -144,7 +243,11 @@ export async function browserCapabilityAvailable(endpoint, timeoutMs = 500) {
144
243
  export async function listBrowserTargets(endpoint) {
145
244
  await ensureBrowserEndpoint(endpoint);
146
245
  const list = await requestJson('/json/list', endpoint);
147
- return Array.isArray(list) ? list : [];
246
+ if (!Array.isArray(list)) return [];
247
+ return list.filter(item => {
248
+ if (!item || item.type !== 'page' || !item.webSocketDebuggerUrl) return false;
249
+ try { safePageUrl(item.url); return true; } catch { return false; }
250
+ });
148
251
  }
149
252
 
150
253
  async function chooseTarget(args = {}) {
@@ -152,12 +255,17 @@ async function chooseTarget(args = {}) {
152
255
  const id = optionalString(args.target_id || args.targetId);
153
256
  const title = optionalString(args.title);
154
257
  const urlContains = optionalString(args.url_contains || args.urlContains);
155
- const target = targets.find(item =>
258
+ const matched = targets.find(item =>
156
259
  (!id || item.id === id) &&
157
260
  (!title || String(item.title || '').toLowerCase().includes(title.toLowerCase())) &&
158
261
  (!urlContains || String(item.url || '').toLowerCase().includes(urlContains.toLowerCase()))
159
- ) || targets[0];
160
- if (!target) unavailable('Browser control', 'no debuggable page target is available');
262
+ );
263
+ const hasSelector = Boolean(id || title || urlContains);
264
+ const target = matched || (hasSelector ? null : targets[0]);
265
+ if (!target) {
266
+ if (hasSelector) throw new Error('No browser page target matched the requested selector');
267
+ unavailable('Browser control', 'no debuggable page target is available');
268
+ }
161
269
  let socketUrl;
162
270
  try { socketUrl = new URL(String(target.webSocketDebuggerUrl)); }
163
271
  catch { throw new Error('Browser returned an invalid CDP WebSocket URL'); }
@@ -192,8 +300,13 @@ class CdpSession {
192
300
  }
193
301
 
194
302
  onMessage(event) {
303
+ const raw = String(event.data);
304
+ if (Buffer.byteLength(raw, 'utf8') > MAX_CDP_MESSAGE_BYTES) {
305
+ this.close();
306
+ return;
307
+ }
195
308
  let message;
196
- try { message = JSON.parse(String(event.data)); } catch { return; }
309
+ try { message = JSON.parse(raw); } catch { return; }
197
310
  if (message.id && this.pending.has(message.id)) {
198
311
  const pending = this.pending.get(message.id);
199
312
  this.pending.delete(message.id);
@@ -277,8 +390,13 @@ class CdpSession {
277
390
 
278
391
  async function withTarget(args, callback) {
279
392
  const target = await chooseTarget(args);
393
+ safePageUrl(target.url);
280
394
  const session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
281
- try { return await callback(session, target); } finally { session.close(); }
395
+ try {
396
+ const currentUrl = await evaluate(session, 'location.href').catch(() => target.url);
397
+ safePageUrl(currentUrl);
398
+ return await callback(session, target);
399
+ } finally { session.close(); }
282
400
  }
283
401
 
284
402
  async function evaluate(session, expression, awaitPromise = true) {
@@ -338,8 +456,9 @@ async function waitForHistoryReady(session, expectedUrl, timeoutMs) {
338
456
  }
339
457
 
340
458
  export async function browserNavigate(args = {}) {
341
- const url = optionalString(args.url);
342
- const action = requireEnum(args.action || (url ? 'url' : 'reload'), 'action', ['url','new_tab','back','forward','reload']);
459
+ const requestedUrl = optionalString(args.url);
460
+ const action = requireEnum(args.action || (requestedUrl ? 'url' : 'reload'), 'action', ['url','new_tab','back','forward','reload']);
461
+ const url = ['url','new_tab'].includes(action) ? safeBrowserUrl(requestedUrl) : null;
343
462
  if (['url','new_tab'].includes(action) && !url) throw new Error('url is required when action=url or new_tab');
344
463
  if (action === 'new_tab') {
345
464
  await ensureBrowserEndpoint(args.endpoint);
@@ -347,7 +466,7 @@ export async function browserNavigate(args = {}) {
347
466
  return jsonResult({
348
467
  target_id:created?.id || null,
349
468
  action,
350
- url:created?.url || url,
469
+ url:created?.url ? safePageUrl(created.url) : url,
351
470
  title:created?.title || '',
352
471
  });
353
472
  }
@@ -372,7 +491,7 @@ export async function browserNavigate(args = {}) {
372
491
  const nextIndex = action === 'back' ? current - 1 : current + 1;
373
492
  const entry = Array.isArray(history.entries) ? history.entries[nextIndex] : null;
374
493
  if (!entry) throw new Error(`Cannot navigate ${action}: no history entry is available`);
375
- historyUrl = entry.url || null;
494
+ historyUrl = entry.url ? safePageUrl(entry.url, 'history URL') : null;
376
495
  await session.send('Page.navigateToHistoryEntry', { entryId: entry.id });
377
496
  }
378
497
  if (loaded) await loaded;
@@ -380,6 +499,7 @@ export async function browserNavigate(args = {}) {
380
499
  await waitForHistoryReady(session, historyUrl, timeoutMs);
381
500
  }
382
501
  const currentUrl = await evaluate(session, 'location.href').catch(() => url || target.url || null);
502
+ if (currentUrl) safePageUrl(currentUrl);
383
503
  return jsonResult({ target_id: target.id, action, url: currentUrl, frame_id: frameId, error_text: errorText });
384
504
  });
385
505
  }
@@ -701,6 +821,7 @@ export async function browserWait(args = {}) {
701
821
  const idleMs = clamp(args.idle_ms, 500, 100, 10_000);
702
822
  const wanted = optionalString(args.selector || args.text || args.value || args.expression);
703
823
  if (['selector','text','url_contains','expression'].includes(condition) && !wanted) throw new Error('selector/text/value/expression is required for this condition');
824
+ if (condition === 'expression') safeEvaluationExpression(wanted);
704
825
  return withTarget(args, async (session, target) => {
705
826
  const started = Date.now();
706
827
  const initialUrl = await evaluate(session, 'location.href').catch(() => target.url || '');
@@ -770,8 +891,7 @@ export async function browserWait(args = {}) {
770
891
  }
771
892
 
772
893
  export async function browserEvaluate(args) {
773
- const expression = optionalString(args.expression);
774
- if (!expression) throw new Error('expression is required');
894
+ const expression = safeEvaluationExpression(args.expression);
775
895
  return withTarget(args, async (session, target) => jsonResult({ target_id: target.id, value: await evaluate(session, expression, args.await_promise !== false) }));
776
896
  }
777
897
 
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import process from 'node:process';
3
- import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { constants } from 'node:fs';
5
+ import { copyFile, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
4
6
  import { readDocxText, readPdfText } from '../documents.mjs';
5
7
  import { resolveSafePath, text } from '../util.mjs';
6
8
  import {
@@ -26,6 +28,51 @@ function xmlUnescape(value) {
26
28
 
27
29
  const MAX_OOXML_ENTRIES = 10_000;
28
30
  const MAX_OOXML_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
31
+ const MAX_DOCUMENT_BYTES = 128 * 1024 * 1024;
32
+
33
+ async function readDocumentFile(filePath) {
34
+ const info = await lstat(filePath);
35
+ if (!info.isFile()) throw new Error('Document path must be a regular file');
36
+ if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
37
+ return readFile(filePath);
38
+ }
39
+
40
+ async function copyRegularNoFollow(source, destination) {
41
+ const sourceHandle = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
42
+ try {
43
+ const info = await sourceHandle.stat();
44
+ if (!info.isFile()) throw new Error('Document path must be a regular file');
45
+ if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
46
+ const destinationHandle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
47
+ try {
48
+ await destinationHandle.writeFile(await sourceHandle.readFile());
49
+ } finally {
50
+ await destinationHandle.close();
51
+ }
52
+ } finally {
53
+ await sourceHandle.close();
54
+ }
55
+ }
56
+
57
+ async function assertSafeExtractedTree(root, state = { entries: 0 }) {
58
+ const info = await lstat(root);
59
+ if (info.isSymbolicLink()) throw new Error('OOXML archive contains a symbolic link');
60
+ if (!info.isDirectory() && !info.isFile()) throw new Error('OOXML archive contains a special file');
61
+ state.entries += 1;
62
+ if (state.entries > MAX_OOXML_ENTRIES) throw new Error('OOXML archive has too many extracted entries');
63
+ if (!info.isDirectory()) return;
64
+ for (const entry of await readdir(root, { withFileTypes: true })) {
65
+ await assertSafeExtractedTree(path.join(root, entry.name), state);
66
+ }
67
+ }
68
+
69
+ function safePdfPattern(value, fallback) {
70
+ const pattern = String(value || fallback);
71
+ if (!pattern || pattern.length > 240 || pattern.includes('\0') || path.isAbsolute(pattern) || /[\\/]/.test(pattern) || pattern.split(/[\\/]/).includes('..')) {
72
+ throw new Error('PDF split pattern must be a filename without path separators');
73
+ }
74
+ return pattern;
75
+ }
29
76
 
30
77
  function safeArchiveEntry(name) {
31
78
  const normalized = String(name || '').replace(/\\/g, '/');
@@ -51,14 +98,22 @@ async function validateZipArchive(source) {
51
98
  }
52
99
 
53
100
  async function extractZip(source, destination) {
54
- await validateZipArchive(source);
55
- await mkdir(destination, { recursive:true });
56
- if (process.platform === 'win32') {
57
- await runPowerShell(`Add-Type -AssemblyName System.IO.Compression.FileSystem;[IO.Compression.ZipFile]::ExtractToDirectory('${escapePowerShellSingle(source)}','${escapePowerShellSingle(destination)}')`, { label:'extract OOXML archive', timeout:30_000 });
58
- return;
101
+ const sourceDirectory = await tempDir('remcp-ooxml-source-');
102
+ const sourceCopy = path.join(sourceDirectory, 'source.zip');
103
+ try {
104
+ await copyRegularNoFollow(source, sourceCopy);
105
+ await validateZipArchive(sourceCopy);
106
+ await mkdir(destination, { recursive:true });
107
+ if (process.platform === 'win32') {
108
+ await runPowerShell(`Add-Type -AssemblyName System.IO.Compression.FileSystem;[IO.Compression.ZipFile]::ExtractToDirectory('${escapePowerShellSingle(sourceCopy)}','${escapePowerShellSingle(destination)}')`, { label:'extract OOXML archive', timeout:30_000 });
109
+ } else {
110
+ if (!commandExists('unzip')) unavailable('OOXML document editing', 'unzip is required');
111
+ await runFile('unzip', ['-qq',sourceCopy,'-d',destination], { label:'extract OOXML archive', timeout:30_000 });
112
+ }
113
+ await assertSafeExtractedTree(destination);
114
+ } finally {
115
+ await removeTemp(sourceDirectory);
59
116
  }
60
- if (!commandExists('unzip')) unavailable('OOXML document editing', 'unzip is required');
61
- await runFile('unzip', ['-qq',source,'-d',destination], { label:'extract OOXML archive', timeout:30_000 });
62
117
  }
63
118
 
64
119
  async function createZip(sourceDir, destination) {
@@ -71,6 +126,26 @@ async function createZip(sourceDir, destination) {
71
126
  await runFile('zip', ['-qr',destination,'.'], { cwd:sourceDir, label:'create OOXML archive', timeout:30_000 });
72
127
  }
73
128
 
129
+ async function commitDocumentOutput(staged, destination) {
130
+ try {
131
+ await rename(staged, destination);
132
+ return;
133
+ } catch (error) {
134
+ if (!['EEXIST', 'EPERM', 'ENOTEMPTY'].includes(error?.code)) throw error;
135
+ }
136
+ const backup = `${destination}.remcp-old-${randomUUID()}`;
137
+ let moved = false;
138
+ try {
139
+ await rename(destination, backup);
140
+ moved = true;
141
+ await rename(staged, destination);
142
+ await rm(backup, { force: true }).catch(() => {});
143
+ } catch (error) {
144
+ if (moved && !await lstat(destination).catch(() => null)) await rename(backup, destination).catch(() => {});
145
+ throw error;
146
+ }
147
+ }
148
+
74
149
  async function ensureCreateTargetIsNew(filePath) {
75
150
  try {
76
151
  await stat(filePath);
@@ -188,7 +263,7 @@ async function readPdfDocument(filePath, data) {
188
263
  export async function readDocument(args) {
189
264
  const filePath = await resolveSafePath(args.path,'path');
190
265
  const lower = filePath.toLowerCase();
191
- const data = await readFile(filePath);
266
+ const data = await readDocumentFile(filePath);
192
267
  if (lower.endsWith('.docx')) return text(readDocxText(data));
193
268
  if (lower.endsWith('.pdf')) return text(await readPdfDocument(filePath,data));
194
269
  if (lower.endsWith('.xlsx')) return jsonResult(await readXlsx(filePath,args));
@@ -275,8 +350,12 @@ export async function editSpreadsheet(args) {
275
350
  const edits = expandSpreadsheetEdits(requestedEdits);
276
351
  const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
277
352
  const dir = await tempDir(create ? 'remcp-xlsx-create-' : 'remcp-xlsx-edit-');
278
- const tempOut = path.join(path.dirname(output), `.remcp-${Date.now()}-${path.basename(output)}`);
353
+ let outputStage = '';
354
+ let tempOut = '';
279
355
  try {
356
+ await mkdir(path.dirname(output), { recursive:true });
357
+ outputStage = await mkdtemp(path.join(path.dirname(output), '.remcp-xlsx-output-'));
358
+ tempOut = path.join(outputStage, path.basename(output));
280
359
  if (create) {
281
360
  await ensureCreateTargetIsNew(filePath);
282
361
  await createBlankXlsxTree(dir, optionalString(args.sheet));
@@ -290,12 +369,12 @@ export async function editSpreadsheet(args) {
290
369
  let xml = await readFile(worksheetPath,'utf8');
291
370
  for (const edit of edits) xml = setWorksheetCell(xml,String(edit.cell || '').toUpperCase(),edit.value,edit.formula);
292
371
  await writeFile(worksheetPath,xml,'utf8');
293
- await mkdir(path.dirname(output), { recursive:true });
294
372
  await createZip(dir,tempOut);
295
- await copyFile(tempOut,output);
373
+ await commitDocumentOutput(tempOut,output);
296
374
  return jsonResult({ path:output, sheet:sheet.name, edited_cells:edits.length, bytes:(await stat(output)).size, created:create });
297
375
  } finally {
298
- await rm(tempOut,{force:true}).catch(() => {});
376
+ if (tempOut) await rm(tempOut,{force:true}).catch(() => {});
377
+ if (outputStage) await rm(outputStage,{recursive:true, force:true}).catch(() => {});
299
378
  await removeTemp(dir);
300
379
  }
301
380
  }
@@ -356,9 +435,13 @@ export async function editDocument(args) {
356
435
  if (!operations.length || operations.length > 100) throw new Error('operations must contain 1..100 edits');
357
436
  const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
358
437
  const dir = await tempDir(create ? 'remcp-docx-create-' : 'remcp-docx-edit-');
359
- const tempOut = path.join(path.dirname(output), `.remcp-${Date.now()}-${path.basename(output)}`);
438
+ let outputStage = '';
439
+ let tempOut = '';
360
440
  let changes = 0;
361
441
  try {
442
+ await mkdir(path.dirname(output), { recursive:true });
443
+ outputStage = await mkdtemp(path.join(path.dirname(output), '.remcp-docx-output-'));
444
+ tempOut = path.join(outputStage, path.basename(output));
362
445
  if (create) {
363
446
  await ensureCreateTargetIsNew(filePath);
364
447
  await createBlankDocxTree(dir);
@@ -383,12 +466,12 @@ export async function editDocument(args) {
383
466
  }
384
467
  }
385
468
  await writeFile(documentPath,xml,'utf8');
386
- await mkdir(path.dirname(output), { recursive:true });
387
469
  await createZip(dir,tempOut);
388
- await copyFile(tempOut,output);
470
+ await commitDocumentOutput(tempOut,output);
389
471
  return jsonResult({ path:output, operations:operations.length, changes, bytes:(await stat(output)).size, created:create });
390
472
  } finally {
391
- await rm(tempOut,{force:true}).catch(() => {});
473
+ if (tempOut) await rm(tempOut,{force:true}).catch(() => {});
474
+ if (outputStage) await rm(outputStage,{recursive:true, force:true}).catch(() => {});
392
475
  await removeTemp(dir);
393
476
  }
394
477
  }
@@ -437,12 +520,12 @@ export async function pdfAction(args) {
437
520
  const action = requireEnum(args.action,'action',['merge','split','extract_pages','annotations','info']);
438
521
  if (action === 'annotations') {
439
522
  const source = await resolveSafePath(args.path,'path');
440
- return jsonResult({ path:source, annotations:pdfAnnotations(await readFile(source)) });
523
+ return jsonResult({ path:source, annotations:pdfAnnotations(await readDocumentFile(source)) });
441
524
  }
442
525
  if (action === 'info') {
443
526
  const source = await resolveSafePath(args.path,'path');
444
527
  if (commandExists('pdfinfo')) return text((await runFile('pdfinfo',[source],{label:'pdfinfo'})).stdout);
445
- const data = await readFile(source); return jsonResult({ path:source, bytes:data.length, annotation_count:pdfAnnotations(data).length });
528
+ const data = await readDocumentFile(source); return jsonResult({ path:source, bytes:data.length, annotation_count:pdfAnnotations(data).length });
446
529
  }
447
530
  if (action === 'merge') {
448
531
  const requested = Array.isArray(args.paths) ? args.paths : [];
@@ -456,8 +539,11 @@ export async function pdfAction(args) {
456
539
  }
457
540
  const source=await resolveSafePath(args.path,'path');
458
541
  if(action==='split'){
459
- const outputDir=await resolveSafePath(args.output_dir || path.dirname(source),'output_dir'); await mkdir(outputDir,{recursive:true});
460
- const pattern=path.join(outputDir,optionalString(args.pattern)||`${path.basename(source,path.extname(source))}-%d.pdf`);
542
+ const requestedOutputDir=await resolveSafePath(args.output_dir || path.dirname(source),'output_dir');
543
+ await mkdir(requestedOutputDir,{recursive:true});
544
+ const outputDir=await resolveSafePath(requestedOutputDir,'output_dir');
545
+ const patternName=safePdfPattern(optionalString(args.pattern),`${path.basename(source,path.extname(source))}-%d.pdf`);
546
+ const pattern=path.join(outputDir,patternName);
461
547
  if(commandExists('pdfseparate')) await runFile('pdfseparate',[source,pattern],{label:'PDF split',timeout:60_000});
462
548
  else unavailable('PDF split','install poppler-utils (pdfseparate)');
463
549
  return jsonResult({action,output_dir:outputDir,files:(await readdir(outputDir)).filter(name=>name.toLowerCase().endsWith('.pdf')).sort()});
package/src/policy.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import path from 'node:path';
1
2
  import { runtimeConfig } from './config.mjs';
2
3
  import { recordEvent } from './telemetry.mjs';
3
4
  import { fail } from './util.mjs';
@@ -10,7 +11,10 @@ import { fail } from './util.mjs';
10
11
  // Rules are matched against the *command word* of each shell segment, so a read-only
11
12
  // command that merely mentions a dangerous word (`grep -n format README.md`,
12
13
  // `cat notes/shutdown.md`) is not refused.
13
- const WRAPPERS = new Set(['sudo', 'doas', 'env', 'nohup', 'command', 'builtin', 'time', 'nice', 'ionice', 'stdbuf', 'timeout', 'setsid', 'exec']);
14
+ const WRAPPERS = new Set(['sudo', 'doas', 'env', 'nohup', 'command', 'builtin', 'time', 'nice', 'ionice', 'stdbuf', 'timeout', 'setsid', 'exec', 'busybox']);
15
+ const SHELL_WRAPPERS = /^(?:sh|bash|zsh|dash|ksh|fish|cmd(?:\.exe)?|powershell(?:\.exe)?|pwsh(?:\.exe)?)$/i;
16
+ const INTERPRETER_EXECUTION = /\b(?:os\.system|subprocess\.(?:run|call|check_call|check_output|Popen)|child_process|\bexec\s*\(|\beval\s*\(|\bspawn\s*\()/i;
17
+ const CATASTROPHIC_TEXT = /(?:mkfs(?:\.[a-z0-9]+)?\b|\b(?:fdisk|sfdisk|cfdisk|parted|diskpart|gdisk)\b|\bdd\b[^\n]*(?:\bof=\s*\/dev\/|\bof=\s*\\\\\.\\)|\b(?:shutdown|reboot|halt|poweroff|restart-computer|stop-computer)\b|\brm\b[^\n]*(?:--recursive|--force|-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)[^\n]*(?:\s|\s*\/\s*|\s*~\s*|\s*\$HOME\b|\s*\$\{HOME\})|\bpython[0-9.]*\b[^\n]*(?:os\.system|subprocess|rm\s+-[^\n]*r[^\n]*f))/i;
14
18
 
15
19
  const DANGEROUS_RULES = [
16
20
  { id: 'filesystem-format', description: 'formats a filesystem', commands: /^mkfs(\.[a-z0-9]+)?$/i, args: () => true },
@@ -18,15 +22,19 @@ const DANGEROUS_RULES = [
18
22
  { id: 'disk-partition', description: 'repartitions a disk', commands: /^(fdisk|sfdisk|cfdisk|parted|diskpart|gdisk)$/i, args: () => true },
19
23
  { id: 'redirect-to-device', description: 'redirects output into a block device', commands: null, anySegment: />>?\s*\/dev\/(sd|hd|vd|nvme|mmcblk|disk)/i },
20
24
  { id: 'host-power', description: 'powers off or reboots the machine', commands: /^(shutdown|reboot|halt|poweroff|restart-computer|stop-computer)$/i, args: () => true },
25
+ { id: 'service-power', description: 'changes system power through a service manager', commands: /^systemctl$/i, args: segment => /\b(?:poweroff|reboot|halt|shutdown)\b/i.test(segment) },
21
26
  { id: 'init-runlevel', description: 'changes the init runlevel', commands: /^init$/i, args: segment => /\binit\s+[06]\b/i.test(segment) },
22
27
  { id: 'fork-bomb', description: 'starts a fork bomb', commands: null, anySegment: /:\s*\(\s*\)\s*\{[^}]*\}\s*;\s*:/ },
23
28
  {
24
29
  id: 'recursive-root-delete',
25
30
  description: 'recursively deletes a root or home path',
26
31
  commands: /^rm$/i,
27
- args: segment => /(^|\s)-[a-z]*r[a-z]*(\s|$)/i.test(segment)
28
- && /(^|\s)-[a-z]*f[a-z]*(\s|$)/i.test(segment)
29
- && /(\s)(\/|\/\*|~|~\/\*|\$HOME|\$\{HOME\})(\s|$)/i.test(segment),
32
+ args: segment => {
33
+ const recursive = /(^|\s)(?:--recursive|-[a-z]*r[a-z]*)(?=\s|$)/i.test(segment);
34
+ const force = /(^|\s)(?:--force|-[a-z]*f[a-z]*)(?=\s|$)/i.test(segment);
35
+ const rootTarget = /(\s)(\/|\/\*|~|~\/\*|\$HOME|\$\{HOME\})(\s|$)/i.test(segment);
36
+ return recursive && force && rootTarget;
37
+ },
30
38
  },
31
39
  { id: 'chmod-root', description: 'recursively rewrites permissions on a root path', commands: /^chmod$/i, args: segment => /(\s)(\/|\/\*)(\s|$)/.test(segment) },
32
40
  { id: 'chown-root', description: 'recursively rewrites ownership on a root path', commands: /^chown$/i, args: segment => /(\s)(\/|\/\*)(\s|$)/.test(segment) },
@@ -39,6 +47,10 @@ function normalize(command) {
39
47
  return String(command).replace(/\s+/g, ' ').trim();
40
48
  }
41
49
 
50
+ function matchingText(value) {
51
+ return String(value).replace(/(["'])(.*?)\1/g, '$2').replace(/\\(["'\\$`])/g, '$1');
52
+ }
53
+
42
54
  // Split on shell control operators so each simple command is judged on its own command
43
55
  // word instead of on every word in the line.
44
56
  function segments(command) {
@@ -49,14 +61,15 @@ function segments(command) {
49
61
  }
50
62
 
51
63
  function commandWord(segment) {
52
- const tokens = segment.split(' ').filter(Boolean);
64
+ const tokens = matchingText(segment).split(' ').filter(Boolean);
53
65
  while (tokens.length) {
54
66
  const token = tokens[0];
55
67
  if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { tokens.shift(); continue; }
56
- if (WRAPPERS.has(token.toLowerCase())) {
68
+ const tokenName = path.basename(token).replace(/\.(exe|cmd|bat)$/i, '').toLowerCase();
69
+ if (WRAPPERS.has(tokenName)) {
57
70
  tokens.shift();
58
71
  while (tokens.length && tokens[0].startsWith('-')) tokens.shift();
59
- if (token.toLowerCase() === 'timeout' && tokens.length && /^\d+[smhd]?$/.test(tokens[0])) tokens.shift();
72
+ if (tokenName === 'timeout' && tokens.length && /^\d+[smhd]?$/.test(tokens[0])) tokens.shift();
60
73
  continue;
61
74
  }
62
75
  break;
@@ -65,22 +78,42 @@ function commandWord(segment) {
65
78
  return word.replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat)$/i, '');
66
79
  }
67
80
 
81
+ function nestedCommandValues(command) {
82
+ const values = [];
83
+ const normalized = String(command || '');
84
+ const substitution = /\$\(([\s\S]*?)\)|`([^`]*)`/g;
85
+ for (const match of normalized.matchAll(substitution)) values.push(match[1] || match[2] || '');
86
+ const shellCommand = /(^|\s)(?:-c|--command|-e|--eval)\s+(?:"([\s\S]*?)"|'([\s\S]*?)'|([^\s;]+))/g;
87
+ for (const match of normalized.matchAll(shellCommand)) values.push(match[2] || match[3] || match[4] || '');
88
+ return values;
89
+ }
90
+
91
+ function nestedExecutionIsDangerous(command) {
92
+ const normalized = String(command || '');
93
+ const shell = normalized.split(/\s+/).some(token => SHELL_WRAPPERS.test(path.basename(token).replace(/\.(exe|cmd|bat)$/i, '')));
94
+ return (shell || INTERPRETER_EXECUTION.test(normalized)) && CATASTROPHIC_TEXT.test(normalized);
95
+ }
96
+
68
97
  export function checkCommand(command) {
69
98
  if (runtimeConfig.unrestricted) return { warned: false, mode: 'allow', unrestricted: true, findings: [] };
70
99
  const normalized = normalize(command);
71
- const parts = segments(normalized);
100
+ const nested = nestedCommandValues(normalized);
101
+ const commands = [normalized, ...nested];
102
+ const parts = commands.flatMap(segments);
72
103
  const findings = [];
73
104
  for (const blocked of runtimeConfig.blockedCommands) {
74
105
  if (normalized.toLowerCase().includes(blocked.toLowerCase())) findings.push({ id: 'policy', description: blocked, source: 'device-policy' });
75
106
  }
76
107
  if (runtimeConfig.dangerousCommands !== 'allow') {
77
108
  for (const part of parts) {
109
+ const matchText = matchingText(part);
78
110
  const word = commandWord(part);
79
111
  for (const rule of DANGEROUS_RULES) {
80
- const matches = rule.anySegment ? rule.anySegment.test(part) : Boolean(word && rule.commands?.test(word) && rule.args(part));
112
+ const matches = rule.anySegment ? rule.anySegment.test(matchText) : Boolean(word && rule.commands?.test(word) && rule.args(matchText));
81
113
  if (matches) findings.push({ id: rule.id, description: rule.description, source: 'builtin' });
82
114
  }
83
115
  }
116
+ if (nestedExecutionIsDangerous(matchingText(normalized))) findings.push({ id: 'nested-catastrophic-command', description: 'executes a catastrophic command through a shell or interpreter', source: 'builtin' });
84
117
  }
85
118
  if (!findings.length) return { warned: false, mode: runtimeConfig.dangerousCommands };
86
119
  const unique = [...new Map(findings.map(item => [item.id + item.description, item])).values()];
@@ -40,8 +40,14 @@ function assertRegularFile(info, absolute) {
40
40
  return info;
41
41
  }
42
42
 
43
- const READ_NOFOLLOW = constants.O_RDONLY | (constants.O_NOFOLLOW || 0);
44
- const READWRITE_NOFOLLOW = constants.O_RDWR | (constants.O_NOFOLLOW || 0);
43
+ const NOFOLLOW = constants.O_NOFOLLOW || 0;
44
+ const NONBLOCK = constants.O_NONBLOCK || 0;
45
+ const READ_NOFOLLOW = constants.O_RDONLY | NOFOLLOW;
46
+ const READ_NOFOLLOW_NONBLOCK = READ_NOFOLLOW | NONBLOCK;
47
+ const READWRITE_NOFOLLOW = constants.O_RDWR | NOFOLLOW;
48
+ const WRITE_CREATE_NOFOLLOW = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW | NONBLOCK;
49
+ const WRITE_TRUNCATE_NOFOLLOW = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | NOFOLLOW | NONBLOCK;
50
+ const DIRECTORY_READ_NOFOLLOW = constants.O_RDONLY | (constants.O_DIRECTORY || 0) | NOFOLLOW | NONBLOCK;
45
51
 
46
52
  async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
47
53
  const handle = await open(absolute, flags);
@@ -568,11 +574,17 @@ export async function moveToTrashTool(args) {
568
574
  } catch { destination = null; }
569
575
  }
570
576
  if (!destination) destination = path.join(path.dirname(source), '.remcp-trash');
571
- await mkdir(destination, { recursive: true });
577
+ const existingDestination = await lstat(destination).catch(() => null);
578
+ if (existingDestination?.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
579
+ if (existingDestination && !existingDestination.isDirectory()) fail('Trash destination is not a directory');
580
+ await mkdir(destination, { recursive: true, mode: 0o700 });
581
+ destination = await resolveSafePath(destination, 'trash');
582
+ const destinationInfo = await lstat(destination);
583
+ if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
572
584
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
573
585
  let target = path.join(destination, `${stamp}-${path.basename(source)}`);
574
586
  let counter = 1;
575
- while (await access(target, constants.F_OK).then(() => true, () => false)) {
587
+ while (await lstat(target).then(value => Boolean(value), () => false)) {
576
588
  target = path.join(destination, `${stamp}-${counter}-${path.basename(source)}`);
577
589
  counter += 1;
578
590
  }
@@ -908,6 +920,384 @@ export async function copyFileTool(args) {
908
920
  }
909
921
 
910
922
  // --- 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
+ const MAX_ARCHIVE_EXPANDED_BYTES = Math.min(
939
+ 4 * 1024 * 1024 * 1024,
940
+ Math.max(64 * 1024 * 1024, Number(process.env.REMCP_MAX_ARCHIVE_EXPANDED_BYTES) || 512 * 1024 * 1024),
941
+ );
942
+
943
+ function safeArchiveEntry(name) {
944
+ const normalized = String(name || '').replace(/\\/g, '/');
945
+ return Boolean(normalized) && !normalized.startsWith('/') && !/^[A-Za-z]:\//.test(normalized) && !normalized.split('/').includes('..');
946
+ }
947
+
948
+ function archiveExpandedSize(lines, pattern, label, expectedEntries = null) {
949
+ let total = 0;
950
+ let matched = 0;
951
+ for (const line of lines) {
952
+ const match = line.match(pattern);
953
+ if (!match) continue;
954
+ total += Number(match[1]);
955
+ matched += 1;
956
+ if (!Number.isSafeInteger(total) || total > MAX_ARCHIVE_EXPANDED_BYTES) fail(`${label} exceeds the ${MAX_ARCHIVE_EXPANDED_BYTES}-byte extraction limit`);
957
+ }
958
+ if ((!matched && expectedEntries !== 0) || (expectedEntries !== null && matched < expectedEntries)) fail(`Could not determine ${label} size safely`);
959
+ return total;
960
+ }
961
+
962
+ function tarEntrySize(line) {
963
+ const tokens = String(line || '').trim().split(/\s+/);
964
+ for (const index of [2, 3, 4]) {
965
+ const size = Number(tokens[index]);
966
+ if (!Number.isSafeInteger(size) || size < 0) continue;
967
+ const tail = tokens.slice(index + 1);
968
+ const gnuDate = /^\d{4}-\d{2}-\d{2}$/.test(tail[0] || '');
969
+ const bsdDate = /^[A-Z][a-z]{2}$/.test(tail[0] || '')
970
+ && /^\d{1,2}$/.test(tail[1] || '')
971
+ && ((/^\d{2}:\d{2}(?::\d{2})?$/.test(tail[2] || '') && /^\d{4}$/.test(tail[3] || ''))
972
+ || /^\d{4}$/.test(tail[2] || ''));
973
+ if (gnuDate || bsdDate) return size;
974
+ }
975
+ return null;
976
+ }
977
+
978
+ function archiveTarExpandedSize(lines, label, expectedEntries) {
979
+ let total = 0;
980
+ let matched = 0;
981
+ for (const line of lines) {
982
+ const size = tarEntrySize(line);
983
+ if (size === null) continue;
984
+ total += size;
985
+ matched += 1;
986
+ if (!Number.isSafeInteger(total) || total > MAX_ARCHIVE_EXPANDED_BYTES) fail(`${label} exceeds the ${MAX_ARCHIVE_EXPANDED_BYTES}-byte extraction limit`);
987
+ }
988
+ if (matched < expectedEntries) fail(`Could not determine ${label} size safely`);
989
+ return total;
990
+ }
991
+
992
+ function assertArchiveExpandedSize(bytes, label) {
993
+ if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > MAX_ARCHIVE_EXPANDED_BYTES) {
994
+ fail(`${label} exceeds the ${MAX_ARCHIVE_EXPANDED_BYTES}-byte extraction limit`);
995
+ }
996
+ }
997
+
998
+ async function assertExtractedTree(root, state = { entries: 0, bytes: 0 }) {
999
+ const info = await lstat(root);
1000
+ if (info.isSymbolicLink()) fail('Archive extraction produced a symbolic link');
1001
+ if (!info.isDirectory() && !info.isFile()) fail('Archive extraction produced a special file');
1002
+ state.entries += 1;
1003
+ state.bytes += info.isFile() ? info.size : 0;
1004
+ if (state.entries > 100_000 || state.bytes > MAX_ARCHIVE_EXPANDED_BYTES) fail('Archive extraction exceeded the safety limit');
1005
+ if (!info.isDirectory()) return;
1006
+ for (const entry of await readdir(root, { withFileTypes: true })) await assertExtractedTree(path.join(root, entry.name), state);
1007
+ }
1008
+
1009
+ 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
+ }
1047
+ }
1048
+
1049
+ async function snapshotRegularFile(source, destination, maxBytes) {
1050
+ const sourceInfo = await lstat(source);
1051
+ if (sourceInfo.isSymbolicLink()) fail(`Archive source is a symbolic link: ${displayPath(source)}`);
1052
+ if (!sourceInfo.isFile()) fail(`Archive source is not a regular file: ${displayPath(source)}`);
1053
+ const opened = await openArchiveSource(source);
1054
+ const { handle, info } = opened;
1055
+ let output;
1056
+ try {
1057
+ if (info.size > maxBytes) fail(`Archive is ${info.size} bytes, above the ${maxBytes}-byte snapshot limit`);
1058
+ output = await open(destination, WRITE_CREATE_NOFOLLOW, 0o400);
1059
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
1060
+ let offset = 0;
1061
+ for (;;) {
1062
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
1063
+ if (!bytesRead) break;
1064
+ if (!Number.isSafeInteger(offset + bytesRead) || offset + bytesRead > maxBytes) {
1065
+ fail(`Archive grew beyond the ${maxBytes}-byte snapshot limit`);
1066
+ }
1067
+ let written = 0;
1068
+ while (written < bytesRead) {
1069
+ const result = await output.write(buffer, written, bytesRead - written, offset + written);
1070
+ if (!result.bytesWritten) throw new Error('Could not finish writing the archive snapshot');
1071
+ written += result.bytesWritten;
1072
+ }
1073
+ offset += bytesRead;
1074
+ }
1075
+ await output.truncate(offset);
1076
+ await output.sync();
1077
+ await output.chmod(0o400);
1078
+ return offset;
1079
+ } finally {
1080
+ if (output) await output.close().catch(() => {});
1081
+ await opened.close().catch(() => {});
1082
+ }
1083
+ }
1084
+
1085
+ async function copyOpenRegularFile(sourceHandle, sourceInfo, destinationPath, state) {
1086
+ const mode = sourceInfo.mode & 0o777;
1087
+ const currentBytes = state.bytes || 0;
1088
+ if (state.maxBytes !== undefined && sourceInfo.size > state.maxBytes - currentBytes) {
1089
+ fail(`Archive contents exceed the ${state.maxBytes}-byte extraction limit`);
1090
+ }
1091
+ const existing = await lstat(destinationPath).catch(() => null);
1092
+ if (existing && (!existing.isFile() || existing.isSymbolicLink())) fail('Archive contains a link or special file; archive destination is unsafe');
1093
+ let output;
1094
+ try {
1095
+ try {
1096
+ output = await open(destinationPath, WRITE_TRUNCATE_NOFOLLOW, mode);
1097
+ } catch (error) {
1098
+ if (['ELOOP', 'ENOTDIR', 'EISDIR'].includes(error?.code)) fail('Archive contains a link or special file; archive destination is unsafe');
1099
+ throw error;
1100
+ }
1101
+ const outputInfo = await output.stat();
1102
+ if (!outputInfo.isFile()) fail('Archive contains a link or special file; archive destination is unsafe');
1103
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
1104
+ let offset = 0;
1105
+ for (;;) {
1106
+ const { bytesRead } = await sourceHandle.read(buffer, 0, buffer.length, offset);
1107
+ if (!bytesRead) break;
1108
+ if (!Number.isSafeInteger(offset + bytesRead) || (state.maxBytes !== undefined && currentBytes + offset + bytesRead > state.maxBytes)) {
1109
+ fail(`Archive contents exceed the ${state.maxBytes}-byte extraction limit`);
1110
+ }
1111
+ let written = 0;
1112
+ while (written < bytesRead) {
1113
+ const result = await output.write(buffer, written, bytesRead - written, offset + written);
1114
+ if (!result.bytesWritten) throw new Error('Could not finish copying archive contents');
1115
+ written += result.bytesWritten;
1116
+ }
1117
+ offset += bytesRead;
1118
+ }
1119
+ await output.truncate(offset);
1120
+ await output.sync();
1121
+ await output.chmod(mode);
1122
+ state.bytes = currentBytes + offset;
1123
+ } finally {
1124
+ if (output) await output.close().catch(() => {});
1125
+ }
1126
+ }
1127
+
1128
+ async function copyTreeContents(source, destination, state = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES }) {
1129
+ 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');
1131
+ state.entries += 1;
1132
+ if (state.entries > 100_000) fail('Archive contains too many entries');
1133
+ if (initial.isFile()) {
1134
+ const opened = await openRegularFile(source, READ_NOFOLLOW_NONBLOCK);
1135
+ try { await copyOpenRegularFile(opened.handle, opened.info, destination, state); }
1136
+ finally { await opened.handle.close(); }
1137
+ return;
1138
+ }
1139
+ let directoryHandle = null;
1140
+ let sourcePath = source;
1141
+ let info = initial;
1142
+ 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
+ }
1153
+ }
1154
+ const mode = info.mode & 0o777;
1155
+ const existing = await lstat(destination).catch(() => null);
1156
+ 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 }); }
1158
+ catch (error) { if (error?.code !== 'EEXIST') throw error; }
1159
+ const destinationInfo = await lstat(destination);
1160
+ if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Archive contains a link or special file; archive destination is unsafe');
1161
+ let destinationHandle;
1162
+ try {
1163
+ destinationHandle = await open(destination, DIRECTORY_READ_NOFOLLOW);
1164
+ const openedDestination = await destinationHandle.stat();
1165
+ if (!openedDestination.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
1166
+ await destinationHandle.chmod(mode | 0o700);
1167
+ for (const name of await readdir(sourcePath)) {
1168
+ await copyTreeContents(path.join(sourcePath, name), path.join(destination, name), state);
1169
+ }
1170
+ await destinationHandle.chmod(mode);
1171
+ } finally {
1172
+ if (destinationHandle) await destinationHandle.close().catch(() => {});
1173
+ }
1174
+ } finally {
1175
+ if (directoryHandle) await directoryHandle.close().catch(() => {});
1176
+ }
1177
+ }
1178
+
1179
+ async function removeExtractionArtifact(target) {
1180
+ const info = await lstat(target).catch(() => null);
1181
+ if (!info) return;
1182
+ if (info.isDirectory() && !info.isSymbolicLink()) await rm(target, { recursive: true, force: true });
1183
+ else await unlink(target).catch(error => { if (error?.code !== 'ENOENT') throw error; });
1184
+ }
1185
+
1186
+ 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;
1207
+ }
1208
+ try {
1209
+ await rename(backup, destination);
1210
+ } catch {
1211
+ continue;
1212
+ }
1213
+ }
1214
+ }
1215
+
1216
+ async function restoreExtractionBackup(backup, destination) {
1217
+ if (await lstat(destination).catch(() => null)) return false;
1218
+ const info = await lstat(backup).catch(() => null);
1219
+ if (!info) return false;
1220
+ if (info.isSymbolicLink() || !info.isDirectory()) {
1221
+ await removeExtractionArtifact(backup);
1222
+ return false;
1223
+ }
1224
+ try {
1225
+ await rename(backup, destination);
1226
+ return true;
1227
+ } catch {
1228
+ return false;
1229
+ }
1230
+ }
1231
+
1232
+ async function installExtractedTree(source, destination) {
1233
+ const parent = path.dirname(destination);
1234
+ if (destination === path.parse(destination).root) fail('Archive destination cannot be a filesystem root');
1235
+ await mkdir(parent, { recursive: true, mode: 0o700 });
1236
+ const backupPrefix = `.remcp-extract-backup-${createHash('sha256').update(destination).digest('hex').slice(0, 16)}-`;
1237
+ await recoverExtractionArtifacts(parent, destination, backupPrefix);
1238
+ const existing = await lstat(destination).catch(() => null);
1239
+ const destinationMode = existing?.isDirectory() ? existing.mode & 0o777 : 0o700;
1240
+ const transaction = await mkdtemp(path.join(parent, '.remcp-extract-txn-'));
1241
+ let backup = '';
1242
+ let backupOwned = false;
1243
+ let preserveBackup = false;
1244
+ try {
1245
+ if (existing) {
1246
+ backup = path.join(parent, `${backupPrefix}${randomUUID()}`);
1247
+ try {
1248
+ await rename(destination, backup);
1249
+ backupOwned = true;
1250
+ } catch (error) {
1251
+ if (error?.code !== 'ENOENT') throw error;
1252
+ backup = '';
1253
+ }
1254
+ }
1255
+ if (backupOwned) {
1256
+ const backupInfo = await lstat(backup);
1257
+ if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) fail('Archive destination is not a safe directory');
1258
+ await copyTreeContents(backup, transaction);
1259
+ }
1260
+ await copyTreeContents(source, transaction);
1261
+ await chmod(transaction, destinationMode);
1262
+ await rename(transaction, destination);
1263
+ if (backupOwned) {
1264
+ await removeExtractionArtifact(backup);
1265
+ backupOwned = false;
1266
+ }
1267
+ } catch (error) {
1268
+ if (backupOwned) {
1269
+ try {
1270
+ await restoreExtractionBackup(backup, destination);
1271
+ if (!await lstat(backup).catch(() => null)) backupOwned = false;
1272
+ else preserveBackup = true;
1273
+ } catch (restoreError) {
1274
+ preserveBackup = true;
1275
+ throw new AggregateError([error, restoreError], 'Could not restore archive destination after extraction failure');
1276
+ }
1277
+ }
1278
+ throw error;
1279
+ } finally {
1280
+ await rm(transaction, { recursive: true, force: true });
1281
+ if (backupOwned && !preserveBackup) await removeExtractionArtifact(backup);
1282
+ }
1283
+ }
1284
+
1285
+ async function moveArchiveFile(source, destination) {
1286
+ 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
+ try {
1292
+ await copyFile(source, temporary, constants.COPYFILE_EXCL);
1293
+ await rename(temporary, destination);
1294
+ } finally {
1295
+ await unlink(temporary).catch(() => {});
1296
+ }
1297
+ await unlink(source);
1298
+ }
1299
+ }
1300
+
911
1301
  function archiveTool() {
912
1302
  const probe = (name, versionArgs = ['--version']) => {
913
1303
  const result = spawnSync(name, versionArgs, { encoding: 'utf8' });
@@ -924,57 +1314,132 @@ export async function createArchiveTool(args) {
924
1314
  if (!sources.length) fail('paths must list at least one file or directory');
925
1315
  const resolved = [];
926
1316
  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);
927
1319
  const destination = await resolveSafePath(args.destination, 'destination');
1320
+ const existingDestination = await lstat(destination).catch(() => null);
1321
+ if (existingDestination?.isSymbolicLink()) fail('Archive destination cannot be a symbolic link');
928
1322
  const format = String(args.format || (destination.endsWith('.zip') ? 'zip' : 'tar.gz')).toLowerCase();
929
1323
  await mkdir(path.dirname(destination), { recursive: true });
930
1324
  const baseDir = path.dirname(resolved[0]);
931
1325
  const names = resolved.map(entry => path.relative(baseDir, entry));
932
- // An archive written inside the tree it packs makes tar abort with "file changed as we
933
- // read it" (the directory mtime moves while it is being read), so build it outside the
934
- // tree first and move it into place afterwards.
935
- const destinationRelative = path.relative(baseDir, destination);
936
- const selfInside = !destinationRelative.startsWith('..') && !path.isAbsolute(destinationRelative);
1326
+ if (names.some(name => !name || name.startsWith('..') || path.isAbsolute(name))) fail('Archive sources must share one parent directory');
1327
+ const archiveNames = names.map(name => name.startsWith('-') ? `./${name}` : name);
937
1328
  const suffix = format === 'zip' ? '.zip' : format === 'tar' ? '.tar' : '.tar.gz';
938
- const staging = selfInside ? path.join(os.tmpdir(), `remcp-archive-${Date.now()}-${process.pid}${suffix}`) : destination;
939
- const output = staging;
940
- if (format === 'zip') {
941
- if (!tools.zip) fail('zip is not installed on this device; use format "tar.gz"');
942
- const result = spawnSync(tools.zip, ['-r', '-q', output, ...names], { cwd: baseDir, encoding: 'utf8' });
943
- if (result.status !== 0) fail(`zip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
944
- } else if (format === 'tar' || format === 'tar.gz' || format === 'tgz') {
945
- if (!tools.tar) fail('tar is not installed on this device');
946
- const flags = format === 'tar' ? '-cf' : '-czf';
947
- const result = spawnSync(tools.tar, [flags, output, ...names], { cwd: baseDir, encoding: 'utf8' });
948
- if (result.status !== 0) fail(`tar failed: ${(result.stderr || '').trim() || `exit ${result.status}`}`);
949
- } else {
950
- fail('format must be tar, tar.gz, or zip');
1329
+ const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-archive-'));
1330
+ if (resolved.some(entry => !path.relative(entry, stagingDirectory).startsWith('..') && !path.isAbsolute(path.relative(entry, stagingDirectory)))) {
1331
+ await rm(stagingDirectory, { recursive: true, force: true });
1332
+ fail('Archive source must not contain the system temporary directory');
951
1333
  }
952
- if (selfInside) {
953
- await mkdir(path.dirname(destination), { recursive: true });
954
- await rename(staging, destination);
1334
+ const staging = path.join(stagingDirectory, `payload${suffix}`);
1335
+ const snapshotRoot = path.join(stagingDirectory, 'source');
1336
+ try {
1337
+ await mkdir(snapshotRoot, { mode: 0o700 });
1338
+ const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES };
1339
+ for (let index = 0; index < resolved.length; index += 1) {
1340
+ const snapshotPath = path.join(snapshotRoot, names[index]);
1341
+ await mkdir(path.dirname(snapshotPath), { recursive: true, mode: 0o700 });
1342
+ await copyTreeContents(resolved[index], snapshotPath, snapshotState);
1343
+ }
1344
+ if (format === 'zip') {
1345
+ if (!tools.zip) fail('zip is not installed on this device; use format "tar.gz"');
1346
+ const result = spawnSync(tools.zip, ['-r', '-q', '-y', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1347
+ if (result.status !== 0) fail(`zip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1348
+ } else if (format === 'tar' || format === 'tar.gz' || format === 'tgz') {
1349
+ if (!tools.tar) fail('tar is not installed on this device');
1350
+ const flags = format === 'tar' ? '-cf' : '-czf';
1351
+ const result = spawnSync(tools.tar, [flags, staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1352
+ if (result.status !== 0) fail(`tar failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1353
+ } else {
1354
+ fail('format must be tar, tar.gz, or zip');
1355
+ }
1356
+ const stagedInfo = await stat(staging);
1357
+ assertArchiveExpandedSize(stagedInfo.size, 'archive output');
1358
+ await mkdir(path.dirname(destination), { recursive: true });
1359
+
1360
+ await moveArchiveFile(staging, destination);
1361
+ const info = await stat(destination).catch(() => null);
1362
+ const note = ' (built outside the tree in temporary staging and committed atomically)';
1363
+
1364
+ return text(`Created ${displayPath(destination)} (${format}, ${info?.size ?? 0} bytes) from ${resolved.length} path(s)${note}.`);
1365
+ } finally {
1366
+ if (stagingDirectory) await rm(stagingDirectory, { recursive: true, force: true });
955
1367
  }
956
- const info = await stat(destination).catch(() => null);
957
- const note = selfInside ? ' (built outside the tree so it does not include itself)' : '';
958
- return text(`Created ${displayPath(destination)} (${format}, ${info?.size ?? 0} bytes) from ${resolved.length} path(s)${note}.`);
1368
+ }
1369
+
1370
+ function archiveFormatForPath(value) {
1371
+ if (/\.zip$/i.test(value)) return 'zip';
1372
+ if (/\.(tar\.gz|tgz)$/i.test(value)) return 'tar.gz';
1373
+ if (/\.(tar\.bz2|tbz2?)$/i.test(value)) return 'tar.bz2';
1374
+ if (/\.tar\.xz$/i.test(value)) return 'tar.xz';
1375
+ return 'tar';
1376
+ }
1377
+
1378
+ function archiveSnapshotSuffix(format) {
1379
+ if (format === 'zip') return '.zip';
1380
+ if (format === 'tar.bz2') return '.tar.bz2';
1381
+ if (format === 'tar.xz') return '.tar.xz';
1382
+ if (format === 'tar.gz') return '.tar.gz';
1383
+ return '.tar';
959
1384
  }
960
1385
 
961
1386
  export async function extractArchiveTool(args) {
962
1387
  const tools = archiveTool();
963
1388
  const archive = await resolveSafePath(args.archive, 'archive');
964
1389
  const destination = await resolveSafePath(args.destination || path.dirname(archive), 'destination');
965
- await mkdir(destination, { recursive: true });
966
- if (/\.zip$/i.test(archive)) {
967
- if (!tools.unzip) fail('unzip is not installed on this device');
968
- const result = spawnSync(tools.unzip, ['-o', '-q', archive, '-d', destination], { encoding: 'utf8' });
969
- if (result.status !== 0) fail(`unzip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
970
- } else {
971
- if (!tools.tar) fail('tar is not installed on this device');
972
- const flags = /\.(tar\.gz|tgz)$/i.test(archive) ? '-xzf' : /\.(tar\.bz2|tbz2?)$/i.test(archive) ? '-xjf' : /\.tar\.xz$/i.test(archive) ? '-xJf' : '-xf';
973
- const result = spawnSync(tools.tar, [flags, archive, '-C', destination], { encoding: 'utf8' });
974
- if (result.status !== 0) fail(`tar failed: ${(result.stderr || '').trim() || `exit ${result.status}`}`);
1390
+ if (destination === path.parse(destination).root) fail('Archive destination cannot be a filesystem root');
1391
+ const parent = path.dirname(destination);
1392
+ const backupPrefix = `.remcp-extract-backup-${createHash('sha256').update(destination).digest('hex').slice(0, 16)}-`;
1393
+ const format = archiveFormatForPath(archive);
1394
+ const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-extract-'));
1395
+ const archiveSnapshot = path.join(stagingDirectory, `source${archiveSnapshotSuffix(format)}`);
1396
+ const staging = path.join(stagingDirectory, 'tree');
1397
+ try {
1398
+ await mkdir(staging, { mode: 0o700 });
1399
+ await snapshotRegularFile(archive, archiveSnapshot, MAX_ARCHIVE_EXPANDED_BYTES);
1400
+ await mkdir(parent, { recursive: true, mode: 0o700 });
1401
+ await recoverExtractionArtifacts(parent, destination, backupPrefix);
1402
+ const existingDestination = await lstat(destination).catch(() => null);
1403
+ if (existingDestination?.isSymbolicLink() || (existingDestination && !existingDestination.isDirectory())) fail('Archive destination is not a safe directory');
1404
+ if (format === 'zip') {
1405
+ if (!tools.unzip) fail('unzip is not installed on this device');
1406
+ const listing = spawnSync(tools.unzip, ['-Z', '-1', archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
1407
+ if (listing.error || listing.status !== 0) fail(`Could not inspect ZIP archive: ${(listing.stderr || listing.error?.message || `exit ${listing.status}`).trim()}`);
1408
+ const names = String(listing.stdout || '').split(/\r?\n/).filter(Boolean);
1409
+ if (names.length > 100_000 || names.some(name => !safeArchiveEntry(name))) fail('ZIP archive contains an unsafe path or too many entries');
1410
+ const details = spawnSync(tools.unzip, ['-Z', '-v', archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
1411
+ if (details.error || details.status !== 0) fail(`Could not inspect ZIP sizes: ${(details.stderr || details.error?.message || `exit ${details.status}`).trim()}`);
1412
+ const detailText = String(details.stdout || '');
1413
+ if (/symbolic link|Unix file attributes \([^)]*\b12\d{4}/i.test(detailText)) fail('ZIP archive contains a symbolic link');
1414
+ const expandedBytes = archiveExpandedSize(detailText.split(/\r?\n/), /uncompressed size:\s*(\d+)\s*bytes/i, 'ZIP archive', names.length);
1415
+ assertArchiveExpandedSize(expandedBytes, 'ZIP archive');
1416
+ const result = spawnSync(tools.unzip, ['-o', '-q', archiveSnapshot, '-d', staging], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1417
+ if (result.status !== 0) fail(`unzip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1418
+ } else {
1419
+ if (!tools.tar) fail('tar is not installed on this device');
1420
+ const listFlags = format === 'tar.gz' ? '-tzf' : format === 'tar.bz2' ? '-tjf' : format === 'tar.xz' ? '-tJf' : '-tf';
1421
+ const listing = spawnSync(tools.tar, [listFlags, archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
1422
+ if (listing.error || listing.status !== 0) fail(`Could not inspect archive: ${(listing.stderr || listing.error?.message || `exit ${listing.status}`).trim()}`);
1423
+ const names = String(listing.stdout || '').split(/\r?\n/).filter(Boolean);
1424
+ if (names.length > 100_000 || names.some(name => !safeArchiveEntry(name))) fail('Archive contains an unsafe path or too many entries');
1425
+ const verboseFlags = format === 'tar.gz' ? '-tvzf' : format === 'tar.bz2' ? '-tvjf' : format === 'tar.xz' ? '-tvJf' : '-tvf';
1426
+ const details = spawnSync(tools.tar, [verboseFlags, archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
1427
+ if (details.error || details.status !== 0) fail(`Could not inspect archive sizes: ${(details.stderr || details.error?.message || `exit ${details.status}`).trim()}`);
1428
+ const detailLines = String(details.stdout || '').split(/\r?\n/);
1429
+ if (detailLines.some(line => /^[bclph]/.test(line))) fail('Archive contains a link or special file; archive destination is unsafe');
1430
+ const expandedBytes = archiveTarExpandedSize(detailLines, 'archive', names.length);
1431
+ assertArchiveExpandedSize(expandedBytes, 'archive');
1432
+ const flags = format === 'tar.gz' ? '-xzf' : format === 'tar.bz2' ? '-xjf' : format === 'tar.xz' ? '-xJf' : '-xf';
1433
+ const result = spawnSync(tools.tar, [flags, archiveSnapshot, '-C', staging], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1434
+ if (result.status !== 0) fail(`tar failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1435
+ }
1436
+ await assertExtractedTree(staging);
1437
+ await installExtractedTree(staging, destination);
1438
+ const entries = await readdir(destination).catch(() => []);
1439
+ return text(`Extracted ${displayPath(archive)} into ${displayPath(destination)} (${entries.length} top-level entries).`);
1440
+ } finally {
1441
+ await rm(stagingDirectory, { recursive: true, force: true });
975
1442
  }
976
- const entries = await readdir(destination).catch(() => []);
977
- return text(`Extracted ${displayPath(archive)} into ${displayPath(destination)} (${entries.length} top-level entries).`);
978
1443
  }
979
1444
 
980
1445
  // --- screenshots ----------------------------------------------------------------------
@@ -221,8 +221,10 @@ export async function interactWithProcessTool(args, extra = {}) {
221
221
  const session = getProcessSession(pid);
222
222
  if (!session) fail(`No ReMCP session with pid ${pid}`);
223
223
  if (session.exited) fail(`Process ${pid} already exited`);
224
- const input = typeof args.input === 'string' ? args.input : fail('input must be a string');
225
- const timeoutMs = clampInteger(args.timeout_ms, 1000, 0, 120000);
224
+ const input = typeof args.input === 'string' ? args.input : fail('input must be a string');
225
+ assertAllowedCommand(input);
226
+ const timeoutMs = clampInteger(args.timeout_ms, 1000, 0, 120000);
227
+
226
228
  session.cursor = session.droppedLines + session.lines.length;
227
229
  session.waitCursor = session.cursor;
228
230
  session.lastPartialRead = null;
package/src/util.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
- import { realpath } from 'node:fs/promises';
3
+ import { lstat, realpath } from 'node:fs/promises';
4
4
  import { liveConfig, runtimeConfig } from './config.mjs';
5
5
 
6
6
  export class ToolError extends Error {}
@@ -93,6 +93,12 @@ export async function resolveSafePath(value, field = 'path') {
93
93
  const absolute = path.resolve(raw);
94
94
  if (!runtimeConfig.allowedRoots.length) return absolute;
95
95
 
96
+ const leaf = await lstat(absolute).catch(error => {
97
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null;
98
+ throw error;
99
+ });
100
+ if (leaf?.isSymbolicLink()) fail(`Path is a symbolic link: ${absolute}`);
101
+
96
102
  // Canonicalize both sides before enforcing confinement. This matters on macOS where
97
103
  // /var is a symlink to /private/var: a safe path returned by an earlier canonicalization
98
104
  // must not be rejected merely because its lexical prefix differs from the configured root.