@juspay/neurolink 9.94.3 → 9.94.5

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.
@@ -15,6 +15,7 @@
15
15
  * jina_ (Jina), fish- (Fish Audio)
16
16
  * - Generic key=value pairs: api_key=…, access_token: …, secret_key=…
17
17
  */
18
+ import { basename, resolve as resolvePath } from "path";
18
19
  const TOKEN_PREFIXES = [
19
20
  "sk",
20
21
  "pk",
@@ -156,12 +157,161 @@ export function safeDebugSerialize(value, maxLen = 10_000) {
156
157
  * for diagnostics. Use this for any `baseURL`/endpoint a caller may have
157
158
  * supplied with inline credentials. The match is global, so every `//…@`
158
159
  * authority in the string is redacted (e.g. a proxy chain or a URL embedded
159
- * in a query parameter), not just the first.
160
+ * in a query parameter), not just the first. IPv6 literal hosts (`//u:p@[::1]/x`)
161
+ * and percent-encoded userinfo (`//u:p%40x@host/x`) are handled naturally —
162
+ * neither `[`/`]` nor `%` stop either pass below, so the bracketed host or
163
+ * encoded byte just rides along as part of the (non-redacted) remainder.
164
+ *
165
+ * This is best-effort defense-in-depth for free-form logging, deliberately
166
+ * regex-based so it can scrub *multiple* embedded authorities out of
167
+ * arbitrary text (a real URL parser only ever parses one URL at a time).
168
+ * It is NOT the primary protection for user-facing errors — that's
169
+ * {@link redactUrlForError}, which parses the single input via `new URL()`
170
+ * and reconstructs from protocol+host+pathname, never touching userinfo at
171
+ * all. Prefer that function whenever the input is known to be exactly one
172
+ * URL.
173
+ *
174
+ * Two passes, deliberately:
175
+ * 1. Well-formed authorities — credentials with no raw `/` — bounded at the
176
+ * authority's first `/` (or string end). Greedy backtracking naturally
177
+ * absorbs multiple `@` segments within one authority (e.g. `//a@b@host`
178
+ * → `//***@host`) while leaving a second, separate `//...@` authority
179
+ * elsewhere in the string (e.g. one embedded in a query parameter)
180
+ * untouched, because pass 1's own exclusion of `/` stops it at the first
181
+ * `/`, before it can ever reach a nested authority.
182
+ * 2. Malformed authorities whose credentials contain an unescaped `/`
183
+ * (e.g. `//user:sec/ret@host/path`) — pass 1 can't find an `@` before
184
+ * hitting that `/`, so this pass allows `/` and redacts up to the last
185
+ * remaining `@`. It stops before any *nested* `//` (via a negative
186
+ * lookahead) rather than excluding a specific character like `*` —
187
+ * excluding a literal character is what let a password containing both
188
+ * `/` and `*` (e.g. `//user:pa*ss/word@host/path`, legal under RFC 3986)
189
+ * slip through both passes entirely in an earlier version of this
190
+ * function.
160
191
  *
161
192
  * @param url - The URL (or URL-shaped string) to redact.
162
193
  */
163
194
  export function redactUrlCredentials(url) {
164
- return url.replace(/\/\/[^/@]+@/g, "//***@");
195
+ const wellFormed = url.replace(/\/\/[^/\s"'<>]*@/g, "//***@");
196
+ return wellFormed.replace(/\/\/(?:(?!\/\/)[^\s"'<>])*@/g, "//***@");
197
+ }
198
+ /**
199
+ * Reduce a URL to its origin + pathname for safe inclusion in error messages
200
+ * and logs, dropping the query string and fragment — where presigned-URL
201
+ * signatures, SAS tokens, and other secrets commonly live — while keeping
202
+ * enough of the URL (scheme, host, path) for the error to stay diagnostic.
203
+ *
204
+ * Falls back to a best-effort redacted string (query/fragment and
205
+ * `user:pass@` stripped) when the input isn't a parseable absolute URL.
206
+ * Either way the result is capped at 200 chars so a very long host/path
207
+ * can't blow up the error message.
208
+ *
209
+ * @param url - The URL (or URL-shaped string) to redact.
210
+ */
211
+ export function redactUrlForError(url) {
212
+ const truncate = (s) => s.length > 200 ? `${s.slice(0, 200)}…` : s;
213
+ try {
214
+ const parsed = new URL(url);
215
+ // Reconstruct explicitly from protocol + host + pathname — never
216
+ // parsed.username/parsed.password, and never parsed.href (which
217
+ // re-serializes any embedded userinfo) — so a `user:pass@host` in the
218
+ // input can't leak into a diagnostic error message.
219
+ return truncate(`${parsed.protocol}//${parsed.host}${parsed.pathname}`);
220
+ }
221
+ catch {
222
+ // Unparseable input: `new URL()` gave us nothing to work with, so
223
+ // best-effort drop the query/fragment (where secrets like tokens live)
224
+ // and strip any `//user:pass@` segment before falling back to the raw
225
+ // string — never echo the input completely unredacted.
226
+ const withoutQueryOrFragment = url.split(/[?#]/)[0];
227
+ return truncate(redactUrlCredentials(withoutQueryOrFragment));
228
+ }
229
+ }
230
+ /**
231
+ * Scrub any embedded absolute URLs out of free-form text — typically the
232
+ * `.message` of an underlying network/DNS/TLS error thrown by `fetch`,
233
+ * undici's `request()`, or Node's resolver, all of which frequently embed
234
+ * the full request URL (query string, credentials, and all) verbatim.
235
+ * Interpolating that message raw into a thrown or logged error would leak a
236
+ * presigned URL's token even after the explicit URL argument was already
237
+ * redacted via {@link redactUrlForError}. Finds each `scheme://…` run in the
238
+ * text and replaces it with its redacted form.
239
+ *
240
+ * @param text - Free-form text (typically `Error.message`) to scrub.
241
+ */
242
+ export function redactUrlsInText(text) {
243
+ return text.replace(/[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"'<>)]+/g, (match) => redactUrlForError(match));
244
+ }
245
+ /**
246
+ * Redact every occurrence of `filePath` from an error message, checking both
247
+ * the path exactly as given and its `path.resolve()`'d form.
248
+ *
249
+ * Node's own fs errors (ENOENT/EACCES) embed the exact string passed to the
250
+ * fs call, so a plain substring match usually suffices — but wrapped or
251
+ * lower-level errors sometimes normalize the path first (relative→absolute,
252
+ * trailing-slash variations), which an exact-match-only redaction would miss.
253
+ * This is defense-in-depth, not a guarantee: a `realpath`-resolved (symlink-
254
+ * following) variant can still differ from both forms and would slip through.
255
+ *
256
+ * @param message - The error message to scrub.
257
+ * @param filePath - The path to redact (both as given and resolved).
258
+ */
259
+ export function redactPathFromMessage(message, filePath) {
260
+ const safeName = basename(filePath);
261
+ // Longest-first: the resolved (absolute) form always contains the relative
262
+ // form as a substring, so redacting the relative form first would only
263
+ // strip the trailing segment of an absolute path and leave its parent
264
+ // directory behind (e.g. "/Users/me/project/foo/bar.png" redacting
265
+ // "foo/bar.png" first leaves "/Users/me/project/bar.png"). Redacting the
266
+ // longer resolved variant first removes the whole absolute path in one
267
+ // pass, so the shorter relative pass below has nothing left to match.
268
+ return message
269
+ .split(resolvePath(filePath))
270
+ .join(safeName)
271
+ .split(filePath)
272
+ .join(safeName);
273
+ }
274
+ /**
275
+ * Build a sanitized copy of a network/URL/file error, safe to attach as
276
+ * `Error.cause` on a rethrow.
277
+ *
278
+ * Redacting only the OUTER thrown error's message isn't enough: if the RAW
279
+ * underlying error is attached as `cause`, anything that walks the cause
280
+ * chain (cause-aware logging, telemetry spans) can still recover an
281
+ * unredacted URL/path/secret via `error.cause.message`. This returns a NEW
282
+ * `Error` whose message has been through {@link redactUrlsInText} — and,
283
+ * when `options.filePath` is supplied, {@link redactPathFromMessage} too, so
284
+ * a filesystem error's ENOENT/EACCES text doesn't leak the host's directory
285
+ * layout via the cause chain the way the outer message already avoids.
286
+ * `.name` and `.code` (`NodeJS.ErrnoException`) are copied over so retry
287
+ * classification (e.g. `isRetryableNetworkError`) still works when it
288
+ * inspects the cause. The raw original is never referenced by the result.
289
+ *
290
+ * Handles non-`Error` thrown values too (a raw string/object can just as
291
+ * easily carry an unredacted URL or path) by running their `String()` form
292
+ * through the same redaction before wrapping.
293
+ *
294
+ * @param error - The raw underlying error (or thrown value) to sanitize.
295
+ * @param options - `filePath`: a known filesystem path to redact to its
296
+ * basename, in addition to URL redaction.
297
+ */
298
+ export function sanitizeErrorCause(error, options) {
299
+ const redact = (text) => {
300
+ const urlSafe = redactUrlsInText(text);
301
+ return options?.filePath
302
+ ? redactPathFromMessage(urlSafe, options.filePath)
303
+ : urlSafe;
304
+ };
305
+ if (!(error instanceof Error)) {
306
+ return new Error(redact(String(error)));
307
+ }
308
+ const sanitized = new Error(redact(error.message));
309
+ sanitized.name = error.name;
310
+ const code = error.code;
311
+ if (code !== undefined) {
312
+ sanitized.code = code;
313
+ }
314
+ return sanitized;
165
315
  }
166
316
  /**
167
317
  * Recursively sanitize a record/array, returning a structurally identical
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync, statSync } from "fs";
2
+ import { readFile as readFileAsync, stat as statAsync } from "fs/promises";
2
3
  import { getGlobalDispatcher, interceptors, request } from "undici";
3
4
  import { MultimodalLogger, ProviderImageAdapter, } from "../adapters/providerImageAdapter.js";
4
5
  import { CONVERSATION_INSTRUCTIONS, STRUCTURED_OUTPUT_INSTRUCTIONS, } from "../config/conversationMemory.js";
@@ -6,8 +7,10 @@ import { getAvailableInputTokens } from "../constants/contextWindows.js";
6
7
  import { enforceAggregateFileBudget, FILE_READ_BUDGET_PERCENT, } from "../context/fileTokenBudget.js";
7
8
  import { SIZE_TIER_THRESHOLDS } from "../types/index.js";
8
9
  import { tracers, ATTR, withSpan } from "../telemetry/index.js";
10
+ import { NeuroLinkError } from "./errorHandling.js";
9
11
  import { FileDetector } from "./fileDetector.js";
10
12
  import { getImageCache } from "./imageCache.js";
13
+ import { ImageProcessor } from "./imageProcessor.js";
11
14
  import { logger } from "./logger.js";
12
15
  import { PDFImageConverter, PDFProcessor } from "./pdfProcessor.js";
13
16
  import { urlDownloadRateLimiter } from "./rateLimiter.js";
@@ -1396,13 +1399,38 @@ function detectMimeTypeFromBuffer(buffer) {
1396
1399
  /**
1397
1400
  * Convert file path to raw base64 string.
1398
1401
  * Returns raw base64 (not a data: URI) to avoid SSRF validation in AI SDK v6.
1402
+ * Uses async fs so a large/slow-filesystem image never blocks the event loop
1403
+ * while the size guard (below) or the read itself is in flight.
1399
1404
  */
1400
- function convertFilePathToBase64(filePath) {
1401
- if (!existsSync(filePath)) {
1402
- throw new Error(`Image file not found: ${filePath}`);
1405
+ async function convertFilePathToBase64(filePath) {
1406
+ let stats;
1407
+ try {
1408
+ stats = await statAsync(filePath);
1403
1409
  }
1404
- const buffer = readFileSync(filePath);
1405
- return buffer.toString("base64");
1410
+ catch (error) {
1411
+ // Only ENOENT means "not found" — EACCES/ELOOP/etc. are real access or
1412
+ // filesystem problems that must not be misreported as a missing file.
1413
+ const code = error instanceof Error
1414
+ ? error.code
1415
+ : undefined;
1416
+ if (code === "ENOENT") {
1417
+ throw new Error(`Image file not found: ${filePath}`, { cause: error });
1418
+ }
1419
+ throw new Error(`Cannot access image file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1420
+ }
1421
+ if (!stats.isFile()) {
1422
+ throw new Error(`Image path is not a file: ${filePath}`);
1423
+ }
1424
+ // #257: check the file size via stat BEFORE reading it into memory, so a
1425
+ // huge image path never triggers an unbounded read + base64 allocation.
1426
+ // Delegates to ImageProcessor's shared guard (no local reimplementation).
1427
+ const context = `image file "${filePath}"`;
1428
+ ImageProcessor.validateSize(stats.size, context);
1429
+ const buffer = await readFileAsync(filePath);
1430
+ // TOCTOU: the file can grow, or a symlink can be swapped, between the stat
1431
+ // above and this read completing. Re-validate the bytes actually read
1432
+ // (not just the pre-read stat) before the base64 allocation.
1433
+ return ImageProcessor.safeBase64Convert(buffer, context);
1406
1434
  }
1407
1435
  /**
1408
1436
  * Process a single image input and convert to raw base64 format.
@@ -1413,7 +1441,7 @@ function convertFilePathToBase64(filePath) {
1413
1441
  * Passing raw base64 avoids this because `new URL(base64string)` throws and
1414
1442
  * the SDK treats the string as inline base64 data instead.
1415
1443
  */
1416
- function processImageToBase64(image, index) {
1444
+ async function processImageToBase64(image, index) {
1417
1445
  let imageData;
1418
1446
  let mimeType = "image/jpeg"; // Default mime type
1419
1447
  if (typeof image === "string") {
@@ -1444,7 +1472,7 @@ function processImageToBase64(image, index) {
1444
1472
  else {
1445
1473
  // File path string - convert to raw base64
1446
1474
  try {
1447
- imageData = convertFilePathToBase64(image);
1475
+ imageData = await convertFilePathToBase64(image);
1448
1476
  mimeType = getMimeTypeFromExtension(image);
1449
1477
  }
1450
1478
  catch (error) {
@@ -1452,6 +1480,11 @@ function processImageToBase64(image, index) {
1452
1480
  index,
1453
1481
  filePath: image,
1454
1482
  });
1483
+ // Preserve typed errors (e.g. IMAGE_TOO_LARGE) as-is — don't flatten
1484
+ // them into a generic Error and lose the error `code` for callers.
1485
+ if (error instanceof NeuroLinkError) {
1486
+ throw error;
1487
+ }
1455
1488
  throw new Error(`Failed to convert file path to base64: ${image}. ${error}`, { cause: error });
1456
1489
  }
1457
1490
  }
@@ -1462,6 +1495,9 @@ function processImageToBase64(image, index) {
1462
1495
  if (detectedMimeType) {
1463
1496
  mimeType = detectedMimeType;
1464
1497
  }
1498
+ // #257: guard the buffer size before the unbounded base64 conversion.
1499
+ // Delegates to ImageProcessor's shared guard (no local reimplementation).
1500
+ ImageProcessor.validateBufferSize(image, `image input at index ${index}`);
1465
1501
  imageData = image.toString("base64");
1466
1502
  }
1467
1503
  return { imageData, mimeType };
@@ -1524,11 +1560,13 @@ async function convertSimpleImagesToProviderFormat(text, images, provider, _mode
1524
1560
  const content = [
1525
1561
  { type: "text", text: enhancedText },
1526
1562
  ];
1527
- // Process all images (including downloaded URLs) for Vercel AI SDK
1528
- actualImages.forEach(({ data: image }, index) => {
1563
+ // Process all images (including downloaded URLs) for Vercel AI SDK.
1564
+ // Sequential for...of (not Promise.all) to preserve image ordering and
1565
+ // keep the original forEach's one-at-a-time error semantics.
1566
+ for (const [index, { data: image }] of actualImages.entries()) {
1529
1567
  try {
1530
1568
  // Use helper function to process image and reduce nesting depth
1531
- const { imageData, mimeType } = processImageToBase64(image, index);
1569
+ const { imageData, mimeType } = await processImageToBase64(image, index);
1532
1570
  content.push({
1533
1571
  type: "image",
1534
1572
  image: imageData,
@@ -1542,7 +1580,7 @@ async function convertSimpleImagesToProviderFormat(text, images, provider, _mode
1542
1580
  });
1543
1581
  throw error;
1544
1582
  }
1545
- });
1583
+ }
1546
1584
  return content;
1547
1585
  }
1548
1586
  /**
@@ -34,6 +34,7 @@ export declare const ERROR_CODES: {
34
34
  readonly IMAGE_TOO_LARGE: "IMAGE_TOO_LARGE";
35
35
  readonly IMAGE_TOO_SMALL: "IMAGE_TOO_SMALL";
36
36
  readonly INVALID_IMAGE_FORMAT: "INVALID_IMAGE_FORMAT";
37
+ readonly INVALID_IMAGE_SIZE: "INVALID_IMAGE_SIZE";
37
38
  readonly PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED";
38
39
  readonly RATE_LIMITER_QUEUE_FULL: "RATE_LIMITER_QUEUE_FULL";
39
40
  readonly RATE_LIMITER_QUEUE_TIMEOUT: "RATE_LIMITER_QUEUE_TIMEOUT";
@@ -199,6 +200,14 @@ export declare class ErrorFactory {
199
200
  * Create an image too large error
200
201
  */
201
202
  static imageTooLarge(sizeMB: string, maxMB: string): NeuroLinkError;
203
+ /**
204
+ * Create an invalid image size error (NaN/Infinity/negative byte length).
205
+ * Distinct from `imageTooLarge` — this rejects a malformed size value
206
+ * before it reaches the max-size comparison, since `NaN > maxSize` and
207
+ * `-1 > maxSize` both evaluate to `false` and would otherwise let a
208
+ * corrupted stat/header value silently skip the guard.
209
+ */
210
+ static invalidImageSize(size: number): NeuroLinkError;
202
211
  /**
203
212
  * Create an image too small error
204
213
  */
@@ -45,6 +45,7 @@ export const ERROR_CODES = {
45
45
  IMAGE_TOO_LARGE: "IMAGE_TOO_LARGE",
46
46
  IMAGE_TOO_SMALL: "IMAGE_TOO_SMALL",
47
47
  INVALID_IMAGE_FORMAT: "INVALID_IMAGE_FORMAT",
48
+ INVALID_IMAGE_SIZE: "INVALID_IMAGE_SIZE",
48
49
  // PDF validation errors
49
50
  PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED",
50
51
  // Rate limiter errors
@@ -570,6 +571,26 @@ export class ErrorFactory {
570
571
  },
571
572
  });
572
573
  }
574
+ /**
575
+ * Create an invalid image size error (NaN/Infinity/negative byte length).
576
+ * Distinct from `imageTooLarge` — this rejects a malformed size value
577
+ * before it reaches the max-size comparison, since `NaN > maxSize` and
578
+ * `-1 > maxSize` both evaluate to `false` and would otherwise let a
579
+ * corrupted stat/header value silently skip the guard.
580
+ */
581
+ static invalidImageSize(size) {
582
+ return new NeuroLinkError({
583
+ code: ERROR_CODES.INVALID_IMAGE_SIZE,
584
+ message: `Invalid image size: ${size} (must be a finite, non-negative number)`,
585
+ category: ErrorCategory.VALIDATION,
586
+ severity: ErrorSeverity.MEDIUM,
587
+ retriable: false,
588
+ context: {
589
+ field: "input.images",
590
+ size,
591
+ },
592
+ });
593
+ }
573
594
  /**
574
595
  * Create an image too small error
575
596
  */
@@ -4,7 +4,7 @@
4
4
  * Uses multi-strategy approach for reliable type identification
5
5
  */
6
6
  import { open, readFile, realpath } from "fs/promises";
7
- import { isAbsolute as isAbsolutePath, relative as relativePath, resolve as resolvePath, sep, } from "path";
7
+ import { basename, isAbsolute as isAbsolutePath, relative as relativePath, resolve as resolvePath, sep, } from "path";
8
8
  import { getGlobalDispatcher, interceptors, request } from "undici";
9
9
  // Lazy-loaded processor singletons — avoids loading heavy media deps
10
10
  // (mediabunny, fluent-ffmpeg, music-metadata, adm-zip) on every generate() call.
@@ -24,6 +24,7 @@ import { tracers, ATTR, withSpan } from "../telemetry/index.js";
24
24
  import { CSVProcessor } from "./csvProcessor.js";
25
25
  import { ImageProcessor } from "./imageProcessor.js";
26
26
  import { logger } from "./logger.js";
27
+ import { redactUrlForError, sanitizeErrorCause } from "./logSanitize.js";
27
28
  import { mimeHintToExtension, mimeHintToFileType, normalizeMimeHint, } from "./mimeTypeHints.js";
28
29
  import { PDFProcessor } from "./pdfProcessor.js";
29
30
  /**
@@ -1338,25 +1339,55 @@ export class FileDetector {
1338
1339
  const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
1339
1340
  const retryDelay = options?.retryDelay ?? DEFAULT_RETRY_DELAY;
1340
1341
  return withRetry(async () => {
1341
- const response = await request(url, {
1342
- dispatcher: getGlobalDispatcher().compose(interceptors.redirect({ maxRedirections: 5 })),
1343
- method: "GET",
1344
- headersTimeout: timeout,
1345
- bodyTimeout: timeout,
1346
- });
1347
- if (response.statusCode !== 200) {
1348
- throw new Error(`HTTP ${response.statusCode} fetching ${url}`);
1349
- }
1350
- const chunks = [];
1351
- let totalSize = 0;
1352
- for await (const chunk of response.body) {
1353
- totalSize += chunk.length;
1354
- if (totalSize > maxSize) {
1355
- throw new Error(`File too large: ${formatFileSize(totalSize)} (max: ${formatFileSize(maxSize)})`);
1342
+ try {
1343
+ const response = await request(url, {
1344
+ dispatcher: getGlobalDispatcher().compose(interceptors.redirect({ maxRedirections: 5 })),
1345
+ method: "GET",
1346
+ headersTimeout: timeout,
1347
+ bodyTimeout: timeout,
1348
+ });
1349
+ if (response.statusCode !== 200) {
1350
+ // Query string / fragment stripped — a presigned URL's token must
1351
+ // not be echoed into a thrown error.
1352
+ throw new Error(`HTTP ${response.statusCode} fetching ${redactUrlForError(url)}`);
1356
1353
  }
1357
- chunks.push(chunk);
1354
+ const chunks = [];
1355
+ let totalSize = 0;
1356
+ for await (const chunk of response.body) {
1357
+ totalSize += chunk.length;
1358
+ if (totalSize > maxSize) {
1359
+ throw new Error(`File too large: ${formatFileSize(totalSize)} (max: ${formatFileSize(maxSize)})`);
1360
+ }
1361
+ chunks.push(chunk);
1362
+ }
1363
+ return Buffer.concat(chunks);
1364
+ }
1365
+ catch (error) {
1366
+ // Node/undici DNS, TLS, and connect-timeout errors embed the full
1367
+ // request URL (including a presigned query token) in
1368
+ // `error.message`. Redact into a NEW error instead of mutating the
1369
+ // original in place, so anything that still holds a reference to
1370
+ // the original — debug logs, telemetry spans, upstream callers —
1371
+ // keeps seeing the real message. `.code` is copied onto the new
1372
+ // error so `isRetryableNetworkError`'s retry check in the outer
1373
+ // `withRetry` catch still classifies it correctly. The raw
1374
+ // original error is NEVER attached as `cause` — that would leave
1375
+ // the unredacted URL reachable via `error.cause.message` for
1376
+ // anything that walks the cause chain (cause-aware logging,
1377
+ // telemetry). `cause` instead gets its own sanitized copy.
1378
+ // `sanitizeErrorCause` handles non-`Error` thrown values too (a raw
1379
+ // string/object can just as easily carry the full URL), so there is
1380
+ // no unconditional `throw error` fallback that would bypass
1381
+ // redaction for that case.
1382
+ const cause = sanitizeErrorCause(error);
1383
+ const redacted = new Error(cause.message, { cause });
1384
+ redacted.name = cause.name;
1385
+ const code = cause.code;
1386
+ if (code !== undefined) {
1387
+ redacted.code = code;
1388
+ }
1389
+ throw redacted;
1358
1390
  }
1359
- return Buffer.concat(chunks);
1360
1391
  }, { maxRetries, retryDelay });
1361
1392
  }
1362
1393
  /**
@@ -1374,6 +1405,13 @@ export class FileDetector {
1374
1405
  // the base dir pointing outside cannot bypass containment. The
1375
1406
  // path.relative check (not a string prefix) correctly handles the root dir
1376
1407
  // ("/") and sibling-prefix ("/app" vs "/app-evil") edge cases.
1408
+ //
1409
+ // The actual open() below MUST target this validated `real` path, not the
1410
+ // original `filePath` — otherwise a symlink swapped between the realpath()
1411
+ // check and the open() call routes the read outside the sandbox even
1412
+ // though validation passed (TOCTOU). With no sandbox configured there's no
1413
+ // boundary to defend, so the original path is used as given.
1414
+ let pathToOpen = filePath;
1377
1415
  if (options?.allowedBaseDir) {
1378
1416
  let base;
1379
1417
  let real;
@@ -1381,24 +1419,66 @@ export class FileDetector {
1381
1419
  base = await realpath(resolvePath(options.allowedBaseDir));
1382
1420
  real = await realpath(filePath);
1383
1421
  }
1384
- catch {
1385
- throw new Error(`Access denied: "${filePath}" could not be resolved within the allowed base directory`);
1422
+ catch (error) {
1423
+ // Full path stays in the debug log; the thrown (potentially
1424
+ // client-facing) error only gets the basename to avoid leaking the
1425
+ // host's directory layout to an untrusted caller. The cause is
1426
+ // sanitized too — Node's realpath ENOENT/EACCES messages embed the
1427
+ // full path verbatim, which would otherwise survive on the cause
1428
+ // chain (cause-aware logging, telemetry) even though the outer
1429
+ // message is already redacted.
1430
+ logger.debug("loadFromPath: realpath resolution failed", {
1431
+ filePath,
1432
+ error,
1433
+ });
1434
+ // Assigned to a variable before the throw (rather than an inline
1435
+ // `{ cause: sanitizeErrorCause(...) }`) so the sanitized, path-redacted
1436
+ // copy is unambiguously the attached cause — the raw `error`, whose
1437
+ // message still embeds the full path, is never reachable from the
1438
+ // thrown result.
1439
+ const cause = sanitizeErrorCause(error, { filePath });
1440
+ const denied = new Error(`Access denied: "${basename(filePath)}" could not be resolved within the allowed base directory`, { cause });
1441
+ throw denied;
1386
1442
  }
1387
1443
  const rel = relativePath(base, real);
1388
1444
  if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolutePath(rel)) {
1389
- throw new Error(`Access denied: "${filePath}" resolves outside the allowed base directory`);
1445
+ logger.debug("loadFromPath: path resolves outside allowed base dir", {
1446
+ filePath,
1447
+ real,
1448
+ });
1449
+ throw new Error(`Access denied: "${basename(filePath)}" resolves outside the allowed base directory`);
1390
1450
  }
1451
+ pathToOpen = real;
1391
1452
  }
1392
1453
  // Open a handle and stat/read through the SAME descriptor so a symlink
1393
1454
  // swap between the size check and the read cannot occur (TOCTOU).
1394
- const handle = await open(filePath, "r");
1455
+ let handle;
1456
+ try {
1457
+ handle = await open(pathToOpen, "r");
1458
+ }
1459
+ catch (error) {
1460
+ // A failed open (ENOENT/EACCES/…) embeds the opened path verbatim in
1461
+ // its message. When a sandbox is configured `pathToOpen` is the
1462
+ // realpath-resolved target (`real`), which differs from both `filePath`
1463
+ // and its resolved form — so redact `pathToOpen` specifically, or the
1464
+ // full host path would survive on both the thrown message and the cause
1465
+ // chain despite this PR's path-redaction hardening.
1466
+ const cause = sanitizeErrorCause(error, { filePath: pathToOpen });
1467
+ const failed = new Error(cause.message, { cause });
1468
+ failed.name = cause.name;
1469
+ const code = cause.code;
1470
+ if (code !== undefined) {
1471
+ failed.code = code;
1472
+ }
1473
+ throw failed;
1474
+ }
1395
1475
  try {
1396
1476
  const statInfo = await handle.stat();
1397
1477
  if (!statInfo.isFile()) {
1398
- throw new Error(`Not a file: ${filePath}`);
1478
+ throw new Error(`Not a file: ${basename(filePath)}`);
1399
1479
  }
1400
1480
  if (statInfo.size > maxSize) {
1401
- throw new Error(`File too large: ${filePath} is ${formatFileSize(statInfo.size)} (max: ${formatFileSize(maxSize)})`);
1481
+ throw new Error(`File too large: ${basename(filePath)} is ${formatFileSize(statInfo.size)} (max: ${formatFileSize(maxSize)})`);
1402
1482
  }
1403
1483
  return await handle.readFile();
1404
1484
  }
@@ -1450,6 +1530,15 @@ class MagicBytesStrategy {
1450
1530
  input[6] === 0x79 &&
1451
1531
  input[7] === 0x70) {
1452
1532
  const brand = input.length >= 12 ? input.toString("latin1", 8, 12) : "";
1533
+ // AVIF images share the ISO-BMFF ftyp box with MP4/MOV; the major brand
1534
+ // ('avif' still, 'avis' sequence, 'avio' intra-only AV1 image/sequence
1535
+ // — spec-listed under compatible_brands but also emitted as
1536
+ // major_brand by real encoders) distinguishes them. Detect before the
1537
+ // audio/video branches so an AVIF buffer isn't misrouted to the video
1538
+ // pipeline (#286).
1539
+ if (/^(avif|avis|avio)/.test(brand)) {
1540
+ return this.result("image", "image/avif", 95);
1541
+ }
1453
1542
  if (/^(M4A|M4B|M4P|F4A|F4B)/.test(brand)) {
1454
1543
  return this.result("audio", "audio/mp4", 95);
1455
1544
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { createHash } from "crypto";
16
16
  import { logger } from "./logger.js";
17
+ import { redactUrlForError } from "./logSanitize.js";
17
18
  /**
18
19
  * LRU Cache for downloaded images
19
20
  *
@@ -142,7 +143,9 @@ export class ImageCache {
142
143
  const entry = this.cache.get(normalizedUrl);
143
144
  if (!entry) {
144
145
  this.stats.misses++;
145
- logger.debug("Image cache miss", { url: normalizedUrl.substring(0, 50) });
146
+ logger.debug("Image cache miss", {
147
+ url: redactUrlForError(normalizedUrl),
148
+ });
146
149
  return null;
147
150
  }
148
151
  // Check TTL expiration
@@ -150,7 +153,7 @@ export class ImageCache {
150
153
  this.stats.expirations++;
151
154
  this.delete(normalizedUrl);
152
155
  logger.debug("Image cache entry expired", {
153
- url: normalizedUrl.substring(0, 50),
156
+ url: redactUrlForError(normalizedUrl),
154
157
  });
155
158
  return null;
156
159
  }
@@ -162,7 +165,7 @@ export class ImageCache {
162
165
  this.cache.set(normalizedUrl, entry);
163
166
  this.stats.hits++;
164
167
  logger.debug("Image cache hit", {
165
- url: normalizedUrl.substring(0, 50),
168
+ url: redactUrlForError(normalizedUrl),
166
169
  accessCount: entry.accessCount,
167
170
  });
168
171
  return entry;
@@ -192,7 +195,7 @@ export class ImageCache {
192
195
  // Skip caching if image exceeds max size
193
196
  if (size > this.maxImageSize) {
194
197
  logger.debug("Image too large to cache", {
195
- url: normalizedUrl.substring(0, 50),
198
+ url: redactUrlForError(normalizedUrl),
196
199
  size,
197
200
  maxSize: this.maxImageSize,
198
201
  });
@@ -211,8 +214,8 @@ export class ImageCache {
211
214
  // Update content hash index to point to the new URL as well
212
215
  this.contentHashIndex.set(contentHash, normalizedUrl);
213
216
  logger.debug("Image cache dedup hit", {
214
- newUrl: normalizedUrl.substring(0, 50),
215
- existingUrl: existingUrl.substring(0, 50),
217
+ newUrl: redactUrlForError(normalizedUrl),
218
+ existingUrl: redactUrlForError(existingUrl),
216
219
  });
217
220
  return;
218
221
  }
@@ -234,7 +237,7 @@ export class ImageCache {
234
237
  this.cache.set(normalizedUrl, entry);
235
238
  this.contentHashIndex.set(contentHash, normalizedUrl);
236
239
  logger.debug("Image cached", {
237
- url: normalizedUrl.substring(0, 50),
240
+ url: redactUrlForError(normalizedUrl),
238
241
  size,
239
242
  contentHash: contentHash.substring(0, 8),
240
243
  cacheSize: this.cache.size,
@@ -272,7 +275,7 @@ export class ImageCache {
272
275
  this.cache.delete(oldestKey);
273
276
  this.stats.evictions++;
274
277
  logger.debug("Image cache eviction", {
275
- url: String(oldestKey).substring(0, 50),
278
+ url: redactUrlForError(String(oldestKey)),
276
279
  });
277
280
  }
278
281
  }