@juspay/neurolink 9.88.2 → 9.88.4

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.
@@ -298,6 +298,16 @@ export type FileDetectorOptions = {
298
298
  maxSize?: number;
299
299
  timeout?: number;
300
300
  allowedTypes?: FileType[];
301
+ /**
302
+ * When set, local file paths must resolve inside this base directory;
303
+ * anything that escapes it (absolute path, `../` traversal, or a symlink
304
+ * pointing outside) is rejected. Containment is enforced on the real,
305
+ * symlink-resolved path of both the base dir and the target, so a symlink
306
+ * inside the base cannot be used to reach a file outside it. Servers that
307
+ * accept file paths from untrusted callers should set this to sandbox
308
+ * filesystem access; SDK callers loading their own files can omit it.
309
+ */
310
+ allowedBaseDir?: string;
301
311
  audioOptions?: AudioProcessorOptions;
302
312
  csvOptions?: CSVProcessorOptions;
303
313
  officeOptions?: OfficeProcessorOptions;
@@ -3,7 +3,8 @@
3
3
  * Centralized file detection for all multimodal file types
4
4
  * Uses multi-strategy approach for reliable type identification
5
5
  */
6
- import { readFile, stat } from "fs/promises";
6
+ import { open, readFile, realpath } from "fs/promises";
7
+ import { isAbsolute as isAbsolutePath, relative as relativePath, resolve as resolvePath, sep, } from "path";
7
8
  import { getGlobalDispatcher, interceptors, request } from "undici";
8
9
  // Lazy-loaded processor singletons — avoids loading heavy media deps
9
10
  // (mediabunny, fluent-ffmpeg, music-metadata, adm-zip) on every generate() call.
@@ -585,7 +586,9 @@ export class FileDetector {
585
586
  return result;
586
587
  }
587
588
  }
588
- logger.warn(`[FileDetector] Low confidence: ${best?.type ?? "unknown"} (${best?.metadata.confidence ?? 0}%)`);
589
+ // Below-threshold detection is the common case for any file under the
590
+ // ContentHeuristic ceiling — a debug detail, not a warning-worthy anomaly.
591
+ logger.debug(`[FileDetector] Best-effort type below threshold: ${best?.type ?? "unknown"} (${best?.metadata.confidence ?? 0}%, threshold ${confidenceThreshold}%)`);
589
592
  return best;
590
593
  }
591
594
  /**
@@ -1342,7 +1345,7 @@ export class FileDetector {
1342
1345
  bodyTimeout: timeout,
1343
1346
  });
1344
1347
  if (response.statusCode !== 200) {
1345
- throw new Error(`HTTP ${response.statusCode}`);
1348
+ throw new Error(`HTTP ${response.statusCode} fetching ${url}`);
1346
1349
  }
1347
1350
  const chunks = [];
1348
1351
  let totalSize = 0;
@@ -1359,16 +1362,49 @@ export class FileDetector {
1359
1362
  /**
1360
1363
  * Load file from filesystem path
1361
1364
  */
1362
- static async loadFromPath(path, options) {
1365
+ static async loadFromPath(filePath, options) {
1363
1366
  const maxSize = options?.maxSize || 200 * 1024 * 1024; // 200MB default (matches Curator memory-safety cap)
1364
- const statInfo = await stat(path);
1365
- if (!statInfo.isFile()) {
1366
- throw new Error("Not a file");
1367
+ // Reject NUL-byte injection outright (a classic path-truncation vector).
1368
+ if (filePath.includes("\0")) {
1369
+ throw new Error("Invalid file path: contains a null byte");
1370
+ }
1371
+ // Optional sandbox: when a base dir is configured (servers accepting paths
1372
+ // from untrusted callers), reject anything that resolves outside it. Real
1373
+ // paths are resolved (symlinks followed) on BOTH sides so a symlink inside
1374
+ // the base dir pointing outside cannot bypass containment. The
1375
+ // path.relative check (not a string prefix) correctly handles the root dir
1376
+ // ("/") and sibling-prefix ("/app" vs "/app-evil") edge cases.
1377
+ if (options?.allowedBaseDir) {
1378
+ let base;
1379
+ let real;
1380
+ try {
1381
+ base = await realpath(resolvePath(options.allowedBaseDir));
1382
+ real = await realpath(filePath);
1383
+ }
1384
+ catch {
1385
+ throw new Error(`Access denied: "${filePath}" could not be resolved within the allowed base directory`);
1386
+ }
1387
+ const rel = relativePath(base, real);
1388
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolutePath(rel)) {
1389
+ throw new Error(`Access denied: "${filePath}" resolves outside the allowed base directory`);
1390
+ }
1367
1391
  }
1368
- if (statInfo.size > maxSize) {
1369
- throw new Error(`File too large: ${formatFileSize(statInfo.size)} (max: ${formatFileSize(maxSize)})`);
1392
+ // Open a handle and stat/read through the SAME descriptor so a symlink
1393
+ // swap between the size check and the read cannot occur (TOCTOU).
1394
+ const handle = await open(filePath, "r");
1395
+ try {
1396
+ const statInfo = await handle.stat();
1397
+ if (!statInfo.isFile()) {
1398
+ throw new Error(`Not a file: ${filePath}`);
1399
+ }
1400
+ if (statInfo.size > maxSize) {
1401
+ throw new Error(`File too large: ${filePath} is ${formatFileSize(statInfo.size)} (max: ${formatFileSize(maxSize)})`);
1402
+ }
1403
+ return await handle.readFile();
1404
+ }
1405
+ finally {
1406
+ await handle.close();
1370
1407
  }
1371
- return await readFile(path);
1372
1408
  }
1373
1409
  /**
1374
1410
  * Load file from data URI
@@ -1376,7 +1412,7 @@ export class FileDetector {
1376
1412
  static loadFromDataURI(dataUri) {
1377
1413
  const match = dataUri.match(/^data:([^;]+);base64,(.+)$/);
1378
1414
  if (!match) {
1379
- throw new Error("Invalid data URI format");
1415
+ throw new Error(`Invalid data URI format (expected "data:<mime>;base64,<data>"): "${dataUri.slice(0, 32)}…"`);
1380
1416
  }
1381
1417
  return Buffer.from(match[2], "base64");
1382
1418
  }
@@ -1405,20 +1441,34 @@ class MagicBytesStrategy {
1405
1441
  if (this.isPDF(input)) {
1406
1442
  return this.result("pdf", "application/pdf", 95);
1407
1443
  }
1408
- // MP4/MOV: "ftyp" at offset 4
1444
+ // ISO-BMFF ("ftyp" at offset 4): MP4 video, QuickTime MOV, or M4A/M4B/M4P
1445
+ // audio all share this box — disambiguate by the major brand at offset 8-11,
1446
+ // otherwise an .m4a audio file is misrouted to the video pipeline.
1409
1447
  if (input.length >= 8 &&
1410
1448
  input[4] === 0x66 &&
1411
1449
  input[5] === 0x74 &&
1412
1450
  input[6] === 0x79 &&
1413
1451
  input[7] === 0x70) {
1452
+ const brand = input.length >= 12 ? input.toString("latin1", 8, 12) : "";
1453
+ if (/^(M4A|M4B|M4P|F4A|F4B)/.test(brand)) {
1454
+ return this.result("audio", "audio/mp4", 95);
1455
+ }
1456
+ if (brand.startsWith("qt")) {
1457
+ return this.result("video", "video/quicktime", 95);
1458
+ }
1414
1459
  return this.result("video", "video/mp4", 95);
1415
1460
  }
1416
- // MKV/WebM: EBML header
1461
+ // EBML container (MKV/WebM) both share the 0x1A45DFA3 header; the DocType
1462
+ // string in the header disambiguates WebM from generic Matroska.
1417
1463
  if (input.length >= 4 &&
1418
1464
  input[0] === 0x1a &&
1419
1465
  input[1] === 0x45 &&
1420
1466
  input[2] === 0xdf &&
1421
1467
  input[3] === 0xa3) {
1468
+ const head = input.toString("latin1", 0, Math.min(input.length, 64));
1469
+ if (head.includes("webm")) {
1470
+ return this.result("video", "video/webm", 92);
1471
+ }
1422
1472
  return this.result("video", "video/x-matroska", 90);
1423
1473
  }
1424
1474
  // AVI: "RIFF" + "AVI "
@@ -1452,7 +1502,13 @@ class MagicBytesStrategy {
1452
1502
  input[2] === 0x33) {
1453
1503
  return this.result("audio", "audio/mpeg", 95);
1454
1504
  }
1455
- // MP3: sync word
1505
+ // AAC (ADTS): 12-bit syncword 0xFFF with the 2 layer bits == 00. This must be
1506
+ // checked before the MP3 sync word below, because an ADTS header also satisfies
1507
+ // the looser 11-bit MPEG sync mask and would otherwise be mislabeled audio/mpeg.
1508
+ if (input.length >= 2 && input[0] === 0xff && (input[1] & 0xf6) === 0xf0) {
1509
+ return this.result("audio", "audio/aac", 85);
1510
+ }
1511
+ // MP3: sync word (MPEG audio — layer bits are non-zero, unlike ADTS AAC above)
1456
1512
  if (input.length >= 2 && input[0] === 0xff && (input[1] & 0xe0) === 0xe0) {
1457
1513
  return this.result("audio", "audio/mpeg", 80);
1458
1514
  }
@@ -298,6 +298,16 @@ export type FileDetectorOptions = {
298
298
  maxSize?: number;
299
299
  timeout?: number;
300
300
  allowedTypes?: FileType[];
301
+ /**
302
+ * When set, local file paths must resolve inside this base directory;
303
+ * anything that escapes it (absolute path, `../` traversal, or a symlink
304
+ * pointing outside) is rejected. Containment is enforced on the real,
305
+ * symlink-resolved path of both the base dir and the target, so a symlink
306
+ * inside the base cannot be used to reach a file outside it. Servers that
307
+ * accept file paths from untrusted callers should set this to sandbox
308
+ * filesystem access; SDK callers loading their own files can omit it.
309
+ */
310
+ allowedBaseDir?: string;
301
311
  audioOptions?: AudioProcessorOptions;
302
312
  csvOptions?: CSVProcessorOptions;
303
313
  officeOptions?: OfficeProcessorOptions;
@@ -3,7 +3,8 @@
3
3
  * Centralized file detection for all multimodal file types
4
4
  * Uses multi-strategy approach for reliable type identification
5
5
  */
6
- import { readFile, stat } from "fs/promises";
6
+ import { open, readFile, realpath } from "fs/promises";
7
+ import { isAbsolute as isAbsolutePath, relative as relativePath, resolve as resolvePath, sep, } from "path";
7
8
  import { getGlobalDispatcher, interceptors, request } from "undici";
8
9
  // Lazy-loaded processor singletons — avoids loading heavy media deps
9
10
  // (mediabunny, fluent-ffmpeg, music-metadata, adm-zip) on every generate() call.
@@ -585,7 +586,9 @@ export class FileDetector {
585
586
  return result;
586
587
  }
587
588
  }
588
- logger.warn(`[FileDetector] Low confidence: ${best?.type ?? "unknown"} (${best?.metadata.confidence ?? 0}%)`);
589
+ // Below-threshold detection is the common case for any file under the
590
+ // ContentHeuristic ceiling — a debug detail, not a warning-worthy anomaly.
591
+ logger.debug(`[FileDetector] Best-effort type below threshold: ${best?.type ?? "unknown"} (${best?.metadata.confidence ?? 0}%, threshold ${confidenceThreshold}%)`);
589
592
  return best;
590
593
  }
591
594
  /**
@@ -1342,7 +1345,7 @@ export class FileDetector {
1342
1345
  bodyTimeout: timeout,
1343
1346
  });
1344
1347
  if (response.statusCode !== 200) {
1345
- throw new Error(`HTTP ${response.statusCode}`);
1348
+ throw new Error(`HTTP ${response.statusCode} fetching ${url}`);
1346
1349
  }
1347
1350
  const chunks = [];
1348
1351
  let totalSize = 0;
@@ -1359,16 +1362,49 @@ export class FileDetector {
1359
1362
  /**
1360
1363
  * Load file from filesystem path
1361
1364
  */
1362
- static async loadFromPath(path, options) {
1365
+ static async loadFromPath(filePath, options) {
1363
1366
  const maxSize = options?.maxSize || 200 * 1024 * 1024; // 200MB default (matches Curator memory-safety cap)
1364
- const statInfo = await stat(path);
1365
- if (!statInfo.isFile()) {
1366
- throw new Error("Not a file");
1367
+ // Reject NUL-byte injection outright (a classic path-truncation vector).
1368
+ if (filePath.includes("\0")) {
1369
+ throw new Error("Invalid file path: contains a null byte");
1370
+ }
1371
+ // Optional sandbox: when a base dir is configured (servers accepting paths
1372
+ // from untrusted callers), reject anything that resolves outside it. Real
1373
+ // paths are resolved (symlinks followed) on BOTH sides so a symlink inside
1374
+ // the base dir pointing outside cannot bypass containment. The
1375
+ // path.relative check (not a string prefix) correctly handles the root dir
1376
+ // ("/") and sibling-prefix ("/app" vs "/app-evil") edge cases.
1377
+ if (options?.allowedBaseDir) {
1378
+ let base;
1379
+ let real;
1380
+ try {
1381
+ base = await realpath(resolvePath(options.allowedBaseDir));
1382
+ real = await realpath(filePath);
1383
+ }
1384
+ catch {
1385
+ throw new Error(`Access denied: "${filePath}" could not be resolved within the allowed base directory`);
1386
+ }
1387
+ const rel = relativePath(base, real);
1388
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolutePath(rel)) {
1389
+ throw new Error(`Access denied: "${filePath}" resolves outside the allowed base directory`);
1390
+ }
1367
1391
  }
1368
- if (statInfo.size > maxSize) {
1369
- throw new Error(`File too large: ${formatFileSize(statInfo.size)} (max: ${formatFileSize(maxSize)})`);
1392
+ // Open a handle and stat/read through the SAME descriptor so a symlink
1393
+ // swap between the size check and the read cannot occur (TOCTOU).
1394
+ const handle = await open(filePath, "r");
1395
+ try {
1396
+ const statInfo = await handle.stat();
1397
+ if (!statInfo.isFile()) {
1398
+ throw new Error(`Not a file: ${filePath}`);
1399
+ }
1400
+ if (statInfo.size > maxSize) {
1401
+ throw new Error(`File too large: ${filePath} is ${formatFileSize(statInfo.size)} (max: ${formatFileSize(maxSize)})`);
1402
+ }
1403
+ return await handle.readFile();
1404
+ }
1405
+ finally {
1406
+ await handle.close();
1370
1407
  }
1371
- return await readFile(path);
1372
1408
  }
1373
1409
  /**
1374
1410
  * Load file from data URI
@@ -1376,7 +1412,7 @@ export class FileDetector {
1376
1412
  static loadFromDataURI(dataUri) {
1377
1413
  const match = dataUri.match(/^data:([^;]+);base64,(.+)$/);
1378
1414
  if (!match) {
1379
- throw new Error("Invalid data URI format");
1415
+ throw new Error(`Invalid data URI format (expected "data:<mime>;base64,<data>"): "${dataUri.slice(0, 32)}…"`);
1380
1416
  }
1381
1417
  return Buffer.from(match[2], "base64");
1382
1418
  }
@@ -1405,20 +1441,34 @@ class MagicBytesStrategy {
1405
1441
  if (this.isPDF(input)) {
1406
1442
  return this.result("pdf", "application/pdf", 95);
1407
1443
  }
1408
- // MP4/MOV: "ftyp" at offset 4
1444
+ // ISO-BMFF ("ftyp" at offset 4): MP4 video, QuickTime MOV, or M4A/M4B/M4P
1445
+ // audio all share this box — disambiguate by the major brand at offset 8-11,
1446
+ // otherwise an .m4a audio file is misrouted to the video pipeline.
1409
1447
  if (input.length >= 8 &&
1410
1448
  input[4] === 0x66 &&
1411
1449
  input[5] === 0x74 &&
1412
1450
  input[6] === 0x79 &&
1413
1451
  input[7] === 0x70) {
1452
+ const brand = input.length >= 12 ? input.toString("latin1", 8, 12) : "";
1453
+ if (/^(M4A|M4B|M4P|F4A|F4B)/.test(brand)) {
1454
+ return this.result("audio", "audio/mp4", 95);
1455
+ }
1456
+ if (brand.startsWith("qt")) {
1457
+ return this.result("video", "video/quicktime", 95);
1458
+ }
1414
1459
  return this.result("video", "video/mp4", 95);
1415
1460
  }
1416
- // MKV/WebM: EBML header
1461
+ // EBML container (MKV/WebM) both share the 0x1A45DFA3 header; the DocType
1462
+ // string in the header disambiguates WebM from generic Matroska.
1417
1463
  if (input.length >= 4 &&
1418
1464
  input[0] === 0x1a &&
1419
1465
  input[1] === 0x45 &&
1420
1466
  input[2] === 0xdf &&
1421
1467
  input[3] === 0xa3) {
1468
+ const head = input.toString("latin1", 0, Math.min(input.length, 64));
1469
+ if (head.includes("webm")) {
1470
+ return this.result("video", "video/webm", 92);
1471
+ }
1422
1472
  return this.result("video", "video/x-matroska", 90);
1423
1473
  }
1424
1474
  // AVI: "RIFF" + "AVI "
@@ -1452,7 +1502,13 @@ class MagicBytesStrategy {
1452
1502
  input[2] === 0x33) {
1453
1503
  return this.result("audio", "audio/mpeg", 95);
1454
1504
  }
1455
- // MP3: sync word
1505
+ // AAC (ADTS): 12-bit syncword 0xFFF with the 2 layer bits == 00. This must be
1506
+ // checked before the MP3 sync word below, because an ADTS header also satisfies
1507
+ // the looser 11-bit MPEG sync mask and would otherwise be mislabeled audio/mpeg.
1508
+ if (input.length >= 2 && input[0] === 0xff && (input[1] & 0xf6) === 0xf0) {
1509
+ return this.result("audio", "audio/aac", 85);
1510
+ }
1511
+ // MP3: sync word (MPEG audio — layer bits are non-zero, unlike ADTS AAC above)
1456
1512
  if (input.length >= 2 && input[0] === 0xff && (input[1] & 0xe0) === 0xe0) {
1457
1513
  return this.result("audio", "audio/mpeg", 80);
1458
1514
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "9.88.2",
3
+ "version": "9.88.4",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (58+ servers), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {