@tonyclaw/agent-inspector 3.0.26 → 3.0.27

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.
Files changed (72) hide show
  1. package/.output/cli.js +80 -11
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-SZRUDQc9.js → CompareDrawer-BVCAAbbu.js} +1 -1
  4. package/.output/public/assets/{ProxyViewerContainer-BJvwOrSi.js → ProxyViewerContainer-CYOXB81n.js} +200 -200
  5. package/.output/public/assets/{ReplayDialog-DgfmqiM3.js → ReplayDialog-DMX23O5G.js} +1 -1
  6. package/.output/public/assets/{RequestAnatomy-CS6c7Qfn.js → RequestAnatomy-S17pYDRc.js} +1 -1
  7. package/.output/public/assets/{ResponseView-C0Ri_5gm.js → ResponseView-Cf1XrawD.js} +2 -2
  8. package/.output/public/assets/{StreamingChunkSequence-7dYcHWyZ.js → StreamingChunkSequence-BdOrVlCi.js} +1 -1
  9. package/.output/public/assets/{_sessionId-D-V84cgw.js → _sessionId-BnKGmJrR.js} +1 -1
  10. package/.output/public/assets/{index-DgFWCxOs.js → index-9fMLCU4d.js} +1 -1
  11. package/.output/public/assets/{index-CSIL7-MK.js → index-B1atk19i.js} +2 -2
  12. package/.output/public/assets/{index-DrVJsks2.js → index-gY3I-7-D.js} +1 -1
  13. package/.output/public/assets/{json-viewer-C9Ji3gYN.js → json-viewer-CpyYpPjx.js} +1 -1
  14. package/.output/server/_libs/lucide-react.mjs +6 -6
  15. package/.output/server/{_sessionId-BQ6YQOO1.mjs → _sessionId-eA_h2_s6.mjs} +4 -2
  16. package/.output/server/_ssr/{CompareDrawer-CqNzIeMR.mjs → CompareDrawer-K2k9G7r1.mjs} +5 -3
  17. package/.output/server/_ssr/{ProxyViewerContainer-CMC7Vsbw.mjs → ProxyViewerContainer-CbyBpCAj.mjs} +27 -60
  18. package/.output/server/_ssr/{ReplayDialog-D9p_LaTr.mjs → ReplayDialog-DacI2Iv0.mjs} +5 -3
  19. package/.output/server/_ssr/{RequestAnatomy-B4ge3qNZ.mjs → RequestAnatomy-gh-pAdkO.mjs} +4 -2
  20. package/.output/server/_ssr/{ResponseView-BCqMKApI.mjs → ResponseView-BRJKN50-.mjs} +5 -3
  21. package/.output/server/_ssr/{StreamingChunkSequence-BA4zTLE4.mjs → StreamingChunkSequence-BQLVEuWY.mjs} +4 -2
  22. package/.output/server/_ssr/{index-CzxhDjlq.mjs → index-DuSWrjbH.mjs} +4 -2
  23. package/.output/server/_ssr/index.mjs +2 -2
  24. package/.output/server/_ssr/{json-viewer-B3qV6iEJ.mjs → json-viewer-DNs1ZjKI.mjs} +4 -2
  25. package/.output/server/_ssr/{router-CZe_R31M.mjs → router-Dgx6-ot3.mjs} +712 -217
  26. package/.output/server/{_tanstack-start-manifest_v-B50VTLrH.mjs → _tanstack-start-manifest_v-CoGjnkQD.mjs} +1 -1
  27. package/.output/server/index.mjs +64 -64
  28. package/README.md +8 -0
  29. package/package.json +4 -5
  30. package/src/cli.ts +43 -9
  31. package/src/components/providers/ProviderCard.tsx +3 -36
  32. package/src/components/providers/ProviderForm.tsx +13 -12
  33. package/src/components/providers/ProvidersPanel.tsx +2 -2
  34. package/src/components/providers/SettingsDialog.tsx +3 -3
  35. package/src/components/proxy-viewer/ProviderLogoStack.tsx +0 -3
  36. package/src/contracts/anthropic.ts +0 -5
  37. package/src/contracts/index.ts +0 -4
  38. package/src/contracts/log.ts +0 -1
  39. package/src/contracts/openai.ts +0 -3
  40. package/src/knowledge/openclawClient.ts +22 -9
  41. package/src/knowledge/openclawGatewayClient.ts +20 -13
  42. package/src/lib/alertContract.ts +0 -3
  43. package/src/lib/ecosystemContract.ts +0 -3
  44. package/src/lib/export-logs.ts +1 -4
  45. package/src/lib/safeFetch.ts +100 -0
  46. package/src/lib/ssrfGuard.ts +133 -0
  47. package/src/lib/stopReason.ts +1 -4
  48. package/src/lib/unknown.ts +27 -0
  49. package/src/lib/useProviders.ts +1 -1
  50. package/src/mcp/mode.ts +3 -9
  51. package/src/mcp/server.ts +5 -14
  52. package/src/mcp/toolHandlers.ts +45 -54
  53. package/src/proxy/constants.ts +17 -0
  54. package/src/proxy/formats/anthropic/handler.ts +5 -1
  55. package/src/proxy/formats/anthropic/schemas.ts +1 -7
  56. package/src/proxy/formats/index.ts +1 -1
  57. package/src/proxy/formats/openai/schemas.ts +0 -3
  58. package/src/proxy/formats/registry.ts +0 -3
  59. package/src/proxy/handler.ts +91 -12
  60. package/src/proxy/identityProxy.ts +74 -1
  61. package/src/proxy/logImporter.ts +82 -12
  62. package/src/proxy/logIndex.ts +1 -4
  63. package/src/proxy/providerSecretStore.ts +181 -0
  64. package/src/proxy/providers.ts +84 -11
  65. package/src/proxy/schemas.ts +1 -8
  66. package/src/proxy/sqliteLogIndex.ts +0 -16
  67. package/src/proxy/store.ts +1 -5
  68. package/src/proxy/toolSchemaWarnings.ts +1 -9
  69. package/src/routes/api/logs.$id.replay.ts +17 -5
  70. package/src/routes/api/providers.$providerId.model-metadata.ts +11 -1
  71. package/src/routes/api/providers.$providerId.ts +18 -5
  72. package/src/routes/api/providers.ts +18 -7
@@ -26,12 +26,29 @@ export const HEADER_HOST = "host";
26
26
  // Auth header types
27
27
  export const AUTH_HEADER_X_API_KEY = "x-api-key";
28
28
 
29
+ // Headers whose values contain secrets (API keys, bearer tokens).
30
+ // These are redacted before being written to logs when captureMode is "full".
31
+ export const SENSITIVE_HEADERS = new Set([
32
+ HEADER_AUTHORIZATION,
33
+ HEADER_X_API_KEY,
34
+ "cookie",
35
+ "set-cookie",
36
+ "proxy-authorization",
37
+ ]);
38
+
29
39
  // Content types
30
40
  export const CONTENT_TYPE_EVENT_STREAM = "text/event-stream";
31
41
 
32
42
  // Response status
33
43
  export const STATUS_FORBIDDEN = 403;
34
44
  export const STATUS_BAD_GATEWAY = 502;
45
+ export const STATUS_PAYLOAD_TOO_LARGE = 413;
46
+
47
+ // Maximum request body size accepted by the proxy (50 MB).
48
+ // Legitimate LLM API requests (system prompts, conversation history, tool
49
+ // definitions) are well under this limit; larger payloads are almost certainly
50
+ // malicious or malformed.
51
+ export const MAX_REQUEST_BODY_BYTES = 50 * 1024 * 1024;
35
52
 
36
53
  // Standard/safe headers to preserve when forwarding to upstream.
37
54
  // NOTE: Authorization is intentionally excluded to prevent client auth tokens from leaking.
@@ -13,7 +13,11 @@ export const AnthropicFormatHandler: FormatHandler = {
13
13
  if (result.success) {
14
14
  return {
15
15
  model: result.data.model,
16
- sessionId: result.data.metadata?.user_id ?? headers?.get("x-session-affinity") ?? null,
16
+ sessionId:
17
+ result.data.metadata?.user_id ??
18
+ headers?.get("x-agent-inspector-session-id") ??
19
+ headers?.get("x-session-affinity") ??
20
+ null,
17
21
  };
18
22
  }
19
23
  return null;
@@ -1,12 +1,6 @@
1
1
  export {
2
2
  AnthropicRequestSchema,
3
3
  AnthropicResponseSchema,
4
- InspectorRequestSchema,
5
- InspectorResponseSchema,
6
4
  SseEventSchema,
7
5
  } from "../../../contracts/anthropic";
8
- export type {
9
- AnthropicResponse,
10
- InspectorResponse,
11
- ResponseContentBlockType,
12
- } from "../../../contracts/anthropic";
6
+ export type { AnthropicResponse, ResponseContentBlockType } from "../../../contracts/anthropic";
@@ -9,5 +9,5 @@ import "./openai/zhipuProvider";
9
9
  import "./openai/alibabaProvider";
10
10
 
11
11
  export type { FormatHandler, ParsedRequest } from "./handler";
12
- export { apiFormatForPath, formatRegistry, formatForPath, requestFormatForPath } from "./registry";
12
+ export { apiFormatForPath, formatRegistry, formatForPath } from "./registry";
13
13
  export { registry } from "./providers";
@@ -20,9 +20,6 @@ export {
20
20
  } from "../../../contracts/openai";
21
21
  export type {
22
22
  OpenAIResponse,
23
- OpenAIResponsesRequest,
24
23
  OpenAIResponsesResponse,
25
- OpenAIResponsesSSEvent,
26
- OpenAIResponsesOutputItem,
27
24
  OpenAIToolCall,
28
25
  } from "../../../contracts/openai";
@@ -72,9 +72,6 @@ export function apiFormatForPath(path: string): ApiFormat {
72
72
  return formatRegistry.getApiFormatByPath(path) ?? "unknown";
73
73
  }
74
74
 
75
- /** Backward-compatible alias for older internal imports. */
76
- export const requestFormatForPath = apiFormatForPath;
77
-
78
75
  // Register known paths
79
76
  formatRegistry.registerPath(PATH_V1_MESSAGES, "anthropic");
80
77
  formatRegistry.registerPath(PATH_V1_CHAT_COMPLETIONS, "openai");
@@ -15,12 +15,19 @@ import {
15
15
  HEADER_CONTENT_ENCODING,
16
16
  HEADER_CONTENT_LENGTH,
17
17
  CONTENT_TYPE_EVENT_STREAM,
18
+ SENSITIVE_HEADERS,
18
19
  STATUS_FORBIDDEN,
19
20
  STATUS_BAD_GATEWAY,
21
+ STATUS_PAYLOAD_TOO_LARGE,
22
+ MAX_REQUEST_BODY_BYTES,
20
23
  } from "./constants";
21
24
  import { getConfig } from "./config";
25
+ import { safeFetch } from "../lib/safeFetch";
26
+
27
+ const ALLOW_LOOPBACK = process.env["AGENT_INSPECTOR_ALLOW_LOOPBACK"] === "1";
22
28
  import { stripClaudeCodeBillingHeader } from "./claudeCodeStrip";
23
29
  import { stripOpenAIOrphanToolMessages } from "./openaiOrphanToolStrip";
30
+ import { maskApiKey } from "../lib/mask";
24
31
  import { buildFileLogEntry, type FinalizeLogJob } from "./logFinalizer";
25
32
  import { enqueueFinalizeLogJob } from "./sessionRuntime";
26
33
  import { RawStreamCapture } from "./rawStreamCapture";
@@ -49,7 +56,8 @@ function buildProxyHeaders(
49
56
 
50
57
  if (rawHeaders !== undefined) {
51
58
  originalHeaders.forEach((value, key) => {
52
- rawHeaders[key.toLowerCase()] = value;
59
+ const lowerKey = key.toLowerCase();
60
+ rawHeaders[lowerKey] = SENSITIVE_HEADERS.has(lowerKey) ? maskApiKey(value) : value;
53
61
  });
54
62
  }
55
63
 
@@ -91,6 +99,50 @@ function errorMessage(err: unknown): string {
91
99
  return err instanceof Error ? err.message : String(err);
92
100
  }
93
101
 
102
+ type BodyReadResult = { ok: true; body: string | null } | { ok: false; reason: "too-large" };
103
+
104
+ /**
105
+ * Reads the request body as text, enforcing a maximum byte limit to prevent
106
+ * memory exhaustion from oversized payloads. Returns the body string, or null
107
+ * if the request has no body.
108
+ */
109
+ async function readBodyWithLimit(req: Request, limitBytes: number): Promise<BodyReadResult> {
110
+ if (req.body === null || req.method === "GET" || req.method === "HEAD") {
111
+ return { ok: true, body: null };
112
+ }
113
+
114
+ // Fast path: if Content-Length is present and exceeds the limit, reject
115
+ // without reading any bytes.
116
+ const contentLengthHeader = req.headers.get(HEADER_CONTENT_LENGTH);
117
+ if (contentLengthHeader !== null) {
118
+ const declared = Number.parseInt(contentLengthHeader, 10);
119
+ if (Number.isFinite(declared) && declared > limitBytes) {
120
+ return { ok: false, reason: "too-large" };
121
+ }
122
+ }
123
+
124
+ const reader = req.body.getReader();
125
+ const decoder = new TextDecoder();
126
+ const chunks: string[] = [];
127
+ let totalBytes = 0;
128
+
129
+ try {
130
+ for (;;) {
131
+ const { done, value } = await reader.read();
132
+ if (done) break;
133
+ totalBytes += value.byteLength;
134
+ if (totalBytes > limitBytes) {
135
+ return { ok: false, reason: "too-large" };
136
+ }
137
+ chunks.push(decoder.decode(value, { stream: true }));
138
+ }
139
+ chunks.push(decoder.decode());
140
+ return { ok: true, body: chunks.join("") };
141
+ } finally {
142
+ reader.releaseLock();
143
+ }
144
+ }
145
+
94
146
  function scheduleLogFinalization(job: FinalizeLogJob): void {
95
147
  void enqueueFinalizeLogJob(job).catch((err) => {
96
148
  logger.error(
@@ -230,11 +282,16 @@ export async function handleProxy(req: Request): Promise<Response> {
230
282
  const url = new URL(req.url);
231
283
  const parsed = parseRequestPath(req, url);
232
284
 
233
- // Read body only after cheap path checks
234
- let requestBody: string | null = null;
235
- if (req.body && req.method !== "GET" && req.method !== "HEAD") {
236
- requestBody = await req.text();
285
+ // Read body with a size limit to prevent memory exhaustion from oversized
286
+ // payloads. The limit is generous (50 MB) — legitimate LLM API requests are
287
+ // well under this, but a malicious client could send a multi-GB body to OOM
288
+ // the proxy.
289
+ const bodyRead = await readBodyWithLimit(req, MAX_REQUEST_BODY_BYTES);
290
+ if (!bodyRead.ok) {
291
+ logger.warn(`[handler] Rejected oversized request body: ${req.method} ${parsed.apiPath}`);
292
+ return new Response("Request body too large", { status: STATUS_PAYLOAD_TOO_LARGE });
237
293
  }
294
+ const requestBody = bodyRead.body;
238
295
 
239
296
  // Opt-in: strip Claude Code's synthetic billing-header text block from the
240
297
  // system prompt. Only runs for Anthropic-format requests (those whose path
@@ -284,6 +341,7 @@ export async function handleProxy(req: Request): Promise<Response> {
284
341
  const route = describeApiRoute(parsed.apiPath);
285
342
  const upstreamBase = selectUpstreamBase(route, matchedProviderConfig);
286
343
  const upstreamUrl = buildUpstreamUrl(upstreamBase, parsed.normalizedPath);
344
+
287
345
  const startTime = Date.now();
288
346
 
289
347
  const captureFullDetails = config.captureMode === "full";
@@ -324,7 +382,8 @@ export async function handleProxy(req: Request): Promise<Response> {
324
382
  : undefined;
325
383
  if (upstreamHeadersObj !== undefined) {
326
384
  upstreamHeaders.forEach((value, key) => {
327
- upstreamHeadersObj[key.toLowerCase()] = value;
385
+ const lowerKey = key.toLowerCase();
386
+ upstreamHeadersObj[lowerKey] = SENSITIVE_HEADERS.has(lowerKey) ? maskApiKey(value) : value;
328
387
  });
329
388
  }
330
389
  const log = await createLog(
@@ -343,12 +402,32 @@ export async function handleProxy(req: Request): Promise<Response> {
343
402
 
344
403
  let upstreamRes: Response;
345
404
  try {
346
- upstreamRes = await fetch(upstreamUrl, {
347
- method: req.method,
348
- headers: upstreamHeaders,
349
- body: bodyToForward,
350
- signal: req.signal,
351
- });
405
+ const fetchResult = await safeFetch(
406
+ upstreamUrl,
407
+ {
408
+ method: req.method,
409
+ headers: upstreamHeaders,
410
+ body: bodyToForward,
411
+ signal: req.signal,
412
+ },
413
+ { allowLoopback: ALLOW_LOOPBACK },
414
+ );
415
+ if (!fetchResult.ok) {
416
+ const status = fetchResult.kind === "network-policy" ? STATUS_FORBIDDEN : STATUS_BAD_GATEWAY;
417
+ logger.warn(`[handler] Safe fetch blocked ${upstreamUrl}: ${fetchResult.reason}`);
418
+ log.elapsedMs = Date.now() - startTime;
419
+ log.responseStatus = status;
420
+ log.responseText = fetchResult.reason;
421
+ appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: fetchResult.reason });
422
+ finalizeLogUpdate(log);
423
+ return new Response(
424
+ fetchResult.kind === "network-policy"
425
+ ? "Forbidden: upstream URL is not permitted"
426
+ : "Proxy error: upstream redirects are not permitted",
427
+ { status },
428
+ );
429
+ }
430
+ upstreamRes = fetchResult.response;
352
431
  } catch (err) {
353
432
  log.elapsedMs = Date.now() - startTime;
354
433
  // Check if it was a client abort (not a proxy error)
@@ -1,5 +1,6 @@
1
1
  import http from "node:http";
2
2
  import { Buffer } from "node:buffer";
3
+ import { timingSafeEqual } from "node:crypto";
3
4
  import { lookupClientByPort } from "./socketTracker";
4
5
 
5
6
  const IDENTITY_HEADERS = {
@@ -35,12 +36,63 @@ const RESPONSE_HOP_BY_HOP = new Set([
35
36
  export type IdentityProxyOptions = {
36
37
  /** Public port to listen on. Clients point at this. */
37
38
  listenPort: number;
39
+ /** Interface exposed to clients. Defaults are chosen by the CLI. */
40
+ listenHost: string;
38
41
  /** Host the upstream TanStack Start server listens on. */
39
42
  upstreamHost: string;
40
43
  /** Port the upstream TanStack Start server listens on. */
41
44
  upstreamPort: number;
45
+ /** Restrict non-proxy routes for clients outside the local machine. */
46
+ restrictRemoteControl?: boolean;
47
+ /** Explicitly expose UI, REST, and MCP to remote peers. */
48
+ allowRemoteControl?: boolean;
49
+ /** Optional Bearer token for remote control-plane API clients. */
50
+ controlToken?: string;
42
51
  };
43
52
 
53
+ function isLoopbackPeer(address: string | undefined): boolean {
54
+ if (address === undefined) return false;
55
+ const normalized = address.toLowerCase();
56
+ return (
57
+ normalized === "::1" || normalized.startsWith("127.") || normalized.startsWith("::ffff:127.")
58
+ );
59
+ }
60
+
61
+ function bearerToken(authorization: string | undefined): string | null {
62
+ if (authorization === undefined) return null;
63
+ const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
64
+ return match?.[1] ?? null;
65
+ }
66
+
67
+ function tokensMatch(received: string | null, expected: string | undefined): boolean {
68
+ if (received === null || expected === undefined || expected === "") return false;
69
+ const receivedBuffer = Buffer.from(received);
70
+ const expectedBuffer = Buffer.from(expected);
71
+ return (
72
+ receivedBuffer.byteLength === expectedBuffer.byteLength &&
73
+ timingSafeEqual(receivedBuffer, expectedBuffer)
74
+ );
75
+ }
76
+
77
+ export function isIdentityProxyRequestAllowed(input: {
78
+ path: string;
79
+ remoteAddress?: string;
80
+ authorization?: string;
81
+ options: Pick<
82
+ IdentityProxyOptions,
83
+ "restrictRemoteControl" | "allowRemoteControl" | "controlToken"
84
+ >;
85
+ }): boolean {
86
+ if (input.options.restrictRemoteControl !== true) return true;
87
+ if (input.options.allowRemoteControl === true) return true;
88
+ if (isLoopbackPeer(input.remoteAddress)) return true;
89
+
90
+ const pathname = new URL(input.path, "http://localhost").pathname;
91
+ if (pathname === "/proxy" || pathname.startsWith("/proxy/")) return true;
92
+ if (pathname === "/api/health") return true;
93
+ return tokensMatch(bearerToken(input.authorization), input.options.controlToken);
94
+ }
95
+
44
96
  /**
45
97
  * Start a tiny native Node HTTP server that:
46
98
  * 1. Reads the client connection's source port (`req.socket.remotePort`).
@@ -88,7 +140,7 @@ export function startIdentityProxy(options: IdentityProxyOptions): Promise<http.
88
140
  });
89
141
  });
90
142
  server.once("error", reject);
91
- server.listen(options.listenPort, () => {
143
+ server.listen(options.listenPort, options.listenHost, () => {
92
144
  server.removeListener("error", reject);
93
145
  resolve(server);
94
146
  });
@@ -100,6 +152,27 @@ async function handleRequest(
100
152
  res: http.ServerResponse,
101
153
  options: IdentityProxyOptions,
102
154
  ): Promise<void> {
155
+ if (
156
+ !isIdentityProxyRequestAllowed({
157
+ path: req.url ?? "/",
158
+ remoteAddress: req.socket.remoteAddress,
159
+ authorization: req.headers.authorization,
160
+ options,
161
+ })
162
+ ) {
163
+ res.writeHead(403, {
164
+ "content-type": "application/json; charset=utf-8",
165
+ "cache-control": "no-store",
166
+ });
167
+ res.end(
168
+ JSON.stringify({
169
+ error:
170
+ "Remote control-plane access is disabled. Use the proxy endpoint or connect locally.",
171
+ }),
172
+ );
173
+ return;
174
+ }
175
+
103
176
  // The connection-time lookup already populated the per-port cache; this
104
177
  // call now hits the cache and returns the result synchronously.
105
178
  const remotePort = req.socket.remotePort;
@@ -1,4 +1,5 @@
1
1
  import { basename } from "node:path";
2
+ import { Buffer } from "node:buffer";
2
3
  import JSZip from "jszip";
3
4
  import { z } from "zod";
4
5
  import {
@@ -14,6 +15,17 @@ import { analyzeToolSchemaWarnings } from "./toolSchemaWarnings";
14
15
 
15
16
  export type ImportedLogDraft = Omit<CapturedLog, "id">;
16
17
 
18
+ // Maximum compressed size for a ZIP import (200 MB). Legitimate exports are
19
+ // well under this; anything larger is almost certainly malicious.
20
+ const MAX_ZIP_COMPRESSED_BYTES = 200 * 1024 * 1024;
21
+
22
+ // Maximum total decompressed bytes within a single ZIP (500 MB). Prevents a
23
+ // small "ZIP bomb" archive (a few MB compressed → hundreds of GB decompressed)
24
+ // from exhausting memory. The ratio is checked against the compressed size of
25
+ // the *archive*, not per-entry, since the archive size is the only hard cap
26
+ // available before decompression begins.
27
+ const MAX_ZIP_DECOMPRESSED_BYTES = 500 * 1024 * 1024;
28
+
17
29
  export type ParsedLogImport =
18
30
  | {
19
31
  ok: true;
@@ -288,13 +300,31 @@ function legacyDraftFromParts(input: {
288
300
  });
289
301
  }
290
302
 
291
- async function readZipText(zip: JSZip, path: string): Promise<string | null> {
303
+ /**
304
+ * Reads a file from the ZIP as UTF-8 text. Decrements `remaining.bytes` by the
305
+ * decoded byte size; callers check `remaining.bytes < 0` after the call to
306
+ * detect that the cumulative decompressed size has blown the budget (a
307
+ * zip-bomb signal). We do NOT throw on budget exceeded because the project's
308
+ * lint rules forbid throwing inside non-CLI modules — using a mutable
309
+ * counter keeps the budget check declarative at every call site.
310
+ */
311
+ async function readZipText(
312
+ zip: JSZip,
313
+ path: string,
314
+ remaining: { bytes: number },
315
+ ): Promise<string | null> {
292
316
  const file = zip.file(path);
293
- return file === null ? null : await file.async("text");
317
+ if (file === null) return null;
318
+ const text = await file.async("text");
319
+ remaining.bytes -= Buffer.byteLength(text, "utf8");
320
+ return text;
294
321
  }
295
322
 
296
- async function readManifest(zip: JSZip): Promise<ParsedManifest | null> {
297
- const text = await readZipText(zip, "manifest.json");
323
+ async function readManifest(
324
+ zip: JSZip,
325
+ remaining: { bytes: number },
326
+ ): Promise<ParsedManifest | null> {
327
+ const text = await readZipText(zip, "manifest.json", remaining);
298
328
  const parsed = ManifestSchema.safeParse(parseJson(text));
299
329
  return parsed.success ? parsed.data : null;
300
330
  }
@@ -357,10 +387,24 @@ function validateManifestVersion(manifest: ParsedManifest | null):
357
387
  return { ok: true, warning: null };
358
388
  }
359
389
 
390
+ function bombError(warnings: string[] = []): ParsedLogImport {
391
+ return {
392
+ ok: false,
393
+ message: `Archive decompresses to more than ${MAX_ZIP_DECOMPRESSED_BYTES} bytes.`,
394
+ warnings,
395
+ };
396
+ }
397
+
360
398
  async function parseZipLogImport(data: Uint8Array): Promise<ParsedLogImport> {
361
399
  const warnings: string[] = [];
362
400
  const zip = await JSZip.loadAsync(data);
363
- const manifest = await readManifest(zip);
401
+
402
+ // Tracks remaining budget for decompressed bytes. Decremented on every read;
403
+ // any read that pushes it below zero marks the archive as a zip bomb.
404
+ const remaining = { bytes: MAX_ZIP_DECOMPRESSED_BYTES };
405
+
406
+ const manifest = await readManifest(zip, remaining);
407
+ if (remaining.bytes < 0) return bombError();
364
408
  const versionCheck = validateManifestVersion(manifest);
365
409
  if (!versionCheck.ok) {
366
410
  return {
@@ -378,16 +422,22 @@ async function parseZipLogImport(data: Uint8Array): Promise<ParsedLogImport> {
378
422
  warnings.push("Imported redacted export. Secrets and some replay details may be unavailable.");
379
423
  }
380
424
 
381
- const metadataText = await readZipText(zip, "logs.json");
425
+ const metadataText = await readZipText(zip, "logs.json", remaining);
426
+ if (remaining.bytes < 0) return bombError(warnings);
382
427
  const metadataLogs = metadataText === null ? null : parseJsonLogs(metadataText);
383
428
  const logs: ImportedLogDraft[] = [];
384
429
  let skipped = 0;
385
430
 
386
431
  if (metadataLogs !== null) {
387
432
  for (const log of metadataLogs) {
388
- const requestText = await readZipText(zip, `#${String(log.id)}.Request.json`);
389
- const responseText = await readZipText(zip, `#${String(log.id)}.Response.json`);
390
- const streamingText = await readZipText(zip, `#${String(log.id)}.SSE.Response.json`);
433
+ const requestText = await readZipText(zip, `#${String(log.id)}.Request.json`, remaining);
434
+ const responseText = await readZipText(zip, `#${String(log.id)}.Response.json`, remaining);
435
+ const streamingText = await readZipText(
436
+ zip,
437
+ `#${String(log.id)}.SSE.Response.json`,
438
+ remaining,
439
+ );
440
+ if (remaining.bytes < 0) return bombError(warnings);
391
441
  logs.push(
392
442
  draftFromCapturedLog(
393
443
  log,
@@ -401,9 +451,14 @@ async function parseZipLogImport(data: Uint8Array): Promise<ParsedLogImport> {
401
451
  }
402
452
 
403
453
  for (const originalId of manifest?.logIds ?? []) {
404
- const requestText = await readZipText(zip, `#${String(originalId)}.Request.json`);
405
- const responseText = await readZipText(zip, `#${String(originalId)}.Response.json`);
406
- const streamingText = await readZipText(zip, `#${String(originalId)}.SSE.Response.json`);
454
+ const requestText = await readZipText(zip, `#${String(originalId)}.Request.json`, remaining);
455
+ const responseText = await readZipText(zip, `#${String(originalId)}.Response.json`, remaining);
456
+ const streamingText = await readZipText(
457
+ zip,
458
+ `#${String(originalId)}.SSE.Response.json`,
459
+ remaining,
460
+ );
461
+ if (remaining.bytes < 0) return bombError(warnings);
407
462
  if (requestText === null && responseText === null && streamingText === null) {
408
463
  skipped += 1;
409
464
  continue;
@@ -469,9 +524,24 @@ export async function parseImportedLogFile(input: {
469
524
  }): Promise<ParsedLogImport> {
470
525
  try {
471
526
  if (looksLikeZip(input.data, input.fileName)) {
527
+ if (input.data.byteLength > MAX_ZIP_COMPRESSED_BYTES) {
528
+ return {
529
+ ok: false,
530
+ message: `Archive exceeds maximum size of ${MAX_ZIP_COMPRESSED_BYTES} bytes.`,
531
+ warnings: [],
532
+ };
533
+ }
472
534
  return await parseZipLogImport(input.data);
473
535
  }
474
536
 
537
+ if (input.data.byteLength > MAX_ZIP_COMPRESSED_BYTES) {
538
+ return {
539
+ ok: false,
540
+ message: `Import file exceeds maximum size of ${MAX_ZIP_COMPRESSED_BYTES} bytes.`,
541
+ warnings: [],
542
+ };
543
+ }
544
+
475
545
  return parseTextLogImport(new TextDecoder("utf-8").decode(input.data));
476
546
  } catch (err) {
477
547
  return {
@@ -5,6 +5,7 @@ import { createInterface } from "node:readline";
5
5
  import { Buffer } from "node:buffer";
6
6
  import { resolveLogDir, logger } from "./logger";
7
7
  import type { ApiFormat, CapturedLog } from "./schemas";
8
+ import { textByteLength } from "../lib/unknown";
8
9
  import {
9
10
  findSqliteLogIndexEntry,
10
11
  getSqliteLogIndexMaxId,
@@ -82,10 +83,6 @@ function getIndexPath(): string {
82
83
  let cachedIndex: LogIndex | null = null;
83
84
  let sqliteBackfillScheduledMaxId = 0;
84
85
 
85
- function textByteLength(value: string | null): number | null {
86
- return value === null ? null : Buffer.byteLength(value, "utf8");
87
- }
88
-
89
86
  export function createLogIndexEntryMetadata(log: CapturedLog): LogIndexEntryMetadata {
90
87
  return {
91
88
  sessionId: log.sessionId,