@remcp/runtime 0.2.105 → 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.
@@ -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.