@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 +1 -1
- package/src/config.mjs +12 -2
- package/src/documents.mjs +7 -0
- package/src/extended/browser.mjs +134 -14
- package/src/extended/documents.mjs +107 -21
- package/src/policy.mjs +42 -9
- package/src/tools/files.mjs +505 -40
- package/src/tools/terminal.mjs +4 -2
- package/src/util.mjs +7 -1
package/package.json
CHANGED
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 {
|
|
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
|
-
|
|
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
|
}
|
package/src/extended/browser.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
)
|
|
160
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
342
|
-
const action = requireEnum(args.action || (
|
|
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
|
|
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
|
|
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 =
|
|
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 {
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
await
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
460
|
-
|
|
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 =>
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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(
|
|
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()];
|
package/src/tools/files.mjs
CHANGED
|
@@ -40,8 +40,14 @@ function assertRegularFile(info, absolute) {
|
|
|
40
40
|
return info;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
const
|
|
44
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
933
|
-
|
|
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
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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 ----------------------------------------------------------------------
|
package/src/tools/terminal.mjs
CHANGED
|
@@ -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
|
-
|
|
225
|
-
|
|
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.
|