@tonyclaw/agent-inspector 3.0.26 → 3.0.28

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 (86) 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-TghZr5t1.js} +1 -1
  4. package/.output/public/assets/{ProxyViewerContainer-BJvwOrSi.js → ProxyViewerContainer-fShBAZjj.js} +201 -201
  5. package/.output/public/assets/{ReplayDialog-DgfmqiM3.js → ReplayDialog-CZMApaF4.js} +1 -1
  6. package/.output/public/assets/{RequestAnatomy-CS6c7Qfn.js → RequestAnatomy-DNWccR7O.js} +1 -1
  7. package/.output/public/assets/{ResponseView-C0Ri_5gm.js → ResponseView-d25MdFhY.js} +2 -2
  8. package/.output/public/assets/{StreamingChunkSequence-7dYcHWyZ.js → StreamingChunkSequence-DIFZEnWe.js} +1 -1
  9. package/.output/public/assets/{_sessionId-D-V84cgw.js → _sessionId-DRfvuE2k.js} +1 -1
  10. package/.output/public/assets/{index-DgFWCxOs.js → index-B3aAuqWq.js} +1 -1
  11. package/.output/public/assets/index-BsxAGqMA.css +1 -0
  12. package/.output/public/assets/{index-DrVJsks2.js → index-D4HkS7Ct.js} +1 -1
  13. package/.output/public/assets/{index-CSIL7-MK.js → index-DzARL2Fi.js} +3 -3
  14. package/.output/public/assets/{json-viewer-C9Ji3gYN.js → json-viewer-wuJSKaed.js} +1 -1
  15. package/.output/server/_libs/lucide-react.mjs +39 -39
  16. package/.output/server/{_sessionId-BQ6YQOO1.mjs → _sessionId-BJTNiP_O.mjs} +4 -2
  17. package/.output/server/_ssr/{CompareDrawer-CqNzIeMR.mjs → CompareDrawer-BMRH3N97.mjs} +5 -3
  18. package/.output/server/_ssr/{ProxyViewerContainer-CMC7Vsbw.mjs → ProxyViewerContainer-AFwcHAIR.mjs} +330 -223
  19. package/.output/server/_ssr/{ReplayDialog-D9p_LaTr.mjs → ReplayDialog-CbWvRHiN.mjs} +6 -4
  20. package/.output/server/_ssr/{RequestAnatomy-B4ge3qNZ.mjs → RequestAnatomy-C4kcPyqN.mjs} +4 -2
  21. package/.output/server/_ssr/{ResponseView-BCqMKApI.mjs → ResponseView-D8VYt-gZ.mjs} +5 -3
  22. package/.output/server/_ssr/{StreamingChunkSequence-BA4zTLE4.mjs → StreamingChunkSequence-BUUo6vU0.mjs} +4 -2
  23. package/.output/server/_ssr/{index-CzxhDjlq.mjs → index-DTwMEEXO.mjs} +4 -2
  24. package/.output/server/_ssr/index.mjs +2 -2
  25. package/.output/server/_ssr/{json-viewer-B3qV6iEJ.mjs → json-viewer-BqF1pSQD.mjs} +5 -3
  26. package/.output/server/_ssr/{router-CZe_R31M.mjs → router-C8BOns7y.mjs} +839 -368
  27. package/.output/server/{_tanstack-start-manifest_v-B50VTLrH.mjs → _tanstack-start-manifest_v-BBf2nRhb.mjs} +1 -1
  28. package/.output/server/index.mjs +70 -70
  29. package/README.md +8 -0
  30. package/package.json +4 -5
  31. package/src/cli.ts +43 -9
  32. package/src/components/ProxyViewer.tsx +57 -1
  33. package/src/components/clients/ClientLogo.tsx +43 -4
  34. package/src/components/inspector-pet/InspectorPet.tsx +0 -7
  35. package/src/components/providers/ProviderCard.tsx +3 -36
  36. package/src/components/providers/ProviderForm.tsx +13 -12
  37. package/src/components/providers/ProvidersPanel.tsx +2 -2
  38. package/src/components/providers/SettingsDialog.tsx +3 -3
  39. package/src/components/proxy-viewer/ConversationGroup.tsx +1 -1
  40. package/src/components/proxy-viewer/ConversationGroupList.tsx +233 -152
  41. package/src/components/proxy-viewer/ConversationHeader.tsx +29 -13
  42. package/src/components/proxy-viewer/ProviderLogoStack.tsx +0 -3
  43. package/src/contracts/anthropic.ts +0 -5
  44. package/src/contracts/index.ts +0 -4
  45. package/src/contracts/log.ts +0 -1
  46. package/src/contracts/openai.ts +0 -3
  47. package/src/knowledge/openclawClient.ts +22 -9
  48. package/src/knowledge/openclawGatewayClient.ts +20 -13
  49. package/src/lib/alertContract.ts +0 -3
  50. package/src/lib/ecosystemContract.ts +0 -3
  51. package/src/lib/export-logs.ts +1 -4
  52. package/src/lib/jsonFile.ts +46 -0
  53. package/src/lib/safeFetch.ts +100 -0
  54. package/src/lib/ssrfGuard.ts +133 -0
  55. package/src/lib/stopReason.ts +1 -4
  56. package/src/lib/unknown.ts +27 -0
  57. package/src/lib/useProviders.ts +1 -1
  58. package/src/mcp/mode.ts +3 -9
  59. package/src/mcp/server.ts +5 -14
  60. package/src/mcp/toolHandlers.ts +45 -54
  61. package/src/proxy/constants.ts +17 -0
  62. package/src/proxy/evidenceAnalysis.ts +116 -117
  63. package/src/proxy/formats/anthropic/handler.ts +5 -1
  64. package/src/proxy/formats/anthropic/schemas.ts +1 -7
  65. package/src/proxy/formats/index.ts +1 -1
  66. package/src/proxy/formats/openai/schemas.ts +0 -3
  67. package/src/proxy/formats/registry.ts +0 -3
  68. package/src/proxy/groupEvidenceExporter.ts +4 -8
  69. package/src/proxy/groupStore.ts +3 -10
  70. package/src/proxy/handler.ts +91 -12
  71. package/src/proxy/identityProxy.ts +74 -1
  72. package/src/proxy/logImporter.ts +82 -12
  73. package/src/proxy/logIndex.ts +1 -4
  74. package/src/proxy/providerSecretStore.ts +177 -0
  75. package/src/proxy/providers.ts +84 -11
  76. package/src/proxy/runFailures.ts +4 -10
  77. package/src/proxy/runStore.ts +4 -11
  78. package/src/proxy/schemas.ts +1 -8
  79. package/src/proxy/sqliteLogIndex.ts +0 -16
  80. package/src/proxy/store.ts +1 -5
  81. package/src/proxy/toolSchemaWarnings.ts +1 -9
  82. package/src/routes/api/logs.$id.replay.ts +17 -5
  83. package/src/routes/api/providers.$providerId.model-metadata.ts +11 -1
  84. package/src/routes/api/providers.$providerId.ts +18 -5
  85. package/src/routes/api/providers.ts +18 -7
  86. package/.output/public/assets/index-CmeF5XXS.css +0 -1
@@ -24,6 +24,22 @@ import { resolveLogFormat } from "./log-formats";
24
24
  const HEADER_ACTION_BUTTON_CLASS =
25
25
  "bg-white/[0.045] hover:bg-white/[0.075] inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground";
26
26
 
27
+ export function isSyntheticSessionId(conversationId: string): boolean {
28
+ return (
29
+ conversationId.startsWith("PID:") ||
30
+ conversationId.startsWith("process:") ||
31
+ conversationId.startsWith("connection:") ||
32
+ conversationId.includes("|")
33
+ );
34
+ }
35
+
36
+ export function compactSessionLabel(conversationId: string): string {
37
+ if (isSyntheticSessionId(conversationId)) return "Sessionless";
38
+ return conversationId.length > 24
39
+ ? `${conversationId.slice(0, 12)}...${conversationId.slice(-12)}`
40
+ : conversationId;
41
+ }
42
+
27
43
  function HeaderMetric({
28
44
  icon,
29
45
  children,
@@ -52,7 +68,7 @@ function HeaderMetric({
52
68
  function PendingTurnBadge(): JSX.Element {
53
69
  return (
54
70
  <span
55
- className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-md bg-amber-300/[0.10] px-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-amber-100/90 ring-1 ring-amber-200/10"
71
+ className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-md bg-amber-300/[0.10] px-1.5 font-mono text-[10px] uppercase text-amber-100/90 ring-1 ring-amber-200/10"
56
72
  title="This session has a turn that has not finished yet"
57
73
  >
58
74
  <Loader2 className="size-3 animate-spin" />
@@ -145,11 +161,8 @@ export function ConversationHeader({
145
161
  const clientDisplayLabel = detectedClientLabel ?? userAgent ?? null;
146
162
  const clientTooltip =
147
163
  detectedClientLabel !== null && hasUserAgent ? `${detectedClientLabel} - ${userAgent}` : null;
148
- const isFallbackConversation = conversationId.startsWith("PID:") || conversationId.includes("|");
149
- const compactSessionLabel =
150
- conversationId.length > 24
151
- ? conversationId.slice(0, 12) + "..." + conversationId.slice(-12)
152
- : conversationId;
164
+ const isFallbackConversation = isSyntheticSessionId(conversationId);
165
+ const sessionLabel = compactSessionLabel(conversationId);
153
166
 
154
167
  const timeLabel = formatTimestampRange(startTime, endTime, timeDisplayFormat);
155
168
  const callsLabel = `${totalCalls} call${totalCalls !== 1 ? "s" : ""}`;
@@ -161,8 +174,8 @@ export function ConversationHeader({
161
174
  data-nav-id={`conv-${conversationId}`}
162
175
  data-nav-action={expanded ? "collapse" : "expand"}
163
176
  className={cn(
164
- "bg-card/80 shadow-sm relative flex cursor-pointer items-start gap-3 overflow-hidden rounded-[8px] px-3 py-2.5 transition-colors",
165
- "hover:bg-card",
177
+ "bg-black/[0.10] relative flex cursor-pointer items-start gap-2.5 overflow-hidden rounded-[8px] px-3 py-2.5 transition-colors",
178
+ "hover:bg-black/[0.16]",
166
179
  "select-none",
167
180
  "sticky top-0 z-10 mb-2",
168
181
  "focus-visible:ring-1 focus-visible:ring-cyan-300/35 focus-visible:outline-none",
@@ -185,17 +198,20 @@ export function ConversationHeader({
185
198
 
186
199
  <span className="min-w-0 flex-1">
187
200
  <span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
201
+ <span className="shrink-0 rounded bg-white/[0.04] px-1.5 py-0.5 font-mono text-[9px] uppercase text-muted-foreground/70">
202
+ Session
203
+ </span>
188
204
  {isFallbackConversation && showProcessMetadata ? (
189
205
  clientPid !== null && clientPid !== undefined ? (
190
206
  <span
191
- className="min-w-0 max-w-full truncate font-mono text-xs font-semibold text-violet-200"
207
+ className="min-w-0 max-w-full truncate font-mono text-xs font-semibold text-slate-100"
192
208
  title={`PID ${clientPid}`}
193
209
  >
194
210
  PID {clientPid}
195
211
  </span>
196
212
  ) : (
197
213
  <span
198
- className="min-w-0 max-w-full truncate font-mono text-xs font-semibold text-violet-200"
214
+ className="min-w-0 max-w-full truncate font-mono text-xs font-semibold text-slate-100"
199
215
  title={conversationId}
200
216
  >
201
217
  {conversationId}
@@ -203,10 +219,10 @@ export function ConversationHeader({
203
219
  )
204
220
  ) : (
205
221
  <span
206
- className="min-w-0 max-w-full truncate font-mono text-xs font-semibold text-violet-200"
222
+ className="min-w-0 max-w-full truncate font-mono text-xs font-semibold text-slate-100"
207
223
  title={conversationId}
208
224
  >
209
- {isFallbackConversation ? "sessionless" : compactSessionLabel}
225
+ {sessionLabel}
210
226
  </span>
211
227
  )}
212
228
 
@@ -240,7 +256,7 @@ export function ConversationHeader({
240
256
  className="bg-white/[0.045] inline-flex min-h-6 shrink-0 flex-wrap items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-muted-foreground"
241
257
  title={clientTooltip ?? clientDisplayLabel}
242
258
  >
243
- <ClientLogo client={clientApp} className="size-4 shrink-0" />
259
+ <ClientLogo client={clientApp} className="size-4 shrink-0" decorative={true} />
244
260
  <span className="font-mono leading-snug tabular-nums">{clientDisplayLabel}</span>
245
261
  </span>
246
262
  )}
@@ -5,9 +5,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/
5
5
 
6
6
  const MAX_VISIBLE_LOGOS = 3;
7
7
 
8
- export const ROW_CHEVRON_SLOT_CLASS =
9
- "flex h-8 w-5 shrink-0 items-center justify-center text-muted-foreground";
10
-
11
8
  export type ProviderLogoStackItem = {
12
9
  provider: Provider;
13
10
  tooltip: string;
@@ -228,8 +228,3 @@ export const SseEventSchema = z.discriminatedUnion("type", [
228
228
 
229
229
  export type AnthropicResponse = z.infer<typeof AnthropicResponseSchema>;
230
230
  export type ResponseContentBlockType = z.infer<typeof ResponseContentBlock>;
231
-
232
- // Backward-compatible aliases (originally named Inspector*)
233
- export const InspectorRequestSchema = AnthropicRequestSchema;
234
- export const InspectorResponseSchema = AnthropicResponseSchema;
235
- export type InspectorResponse = AnthropicResponse;
@@ -12,7 +12,6 @@ export type {
12
12
  CapturedLog,
13
13
  LogBodyChunk,
14
14
  LogBodyPart,
15
- RequestFormat,
16
15
  StreamingChunk,
17
16
  TokenUsage,
18
17
  } from "./log";
@@ -46,9 +45,6 @@ export {
46
45
  } from "./openai";
47
46
  export type {
48
47
  OpenAIResponse,
49
- OpenAIResponsesRequest,
50
48
  OpenAIResponsesResponse,
51
- OpenAIResponsesSSEvent,
52
- OpenAIResponsesOutputItem,
53
49
  OpenAIToolCall,
54
50
  } from "./openai";
@@ -2,7 +2,6 @@ import { z } from "zod";
2
2
  import { JsonValueSchema } from "./json";
3
3
 
4
4
  export type ApiFormat = "anthropic" | "openai" | "unknown";
5
- export type RequestFormat = ApiFormat;
6
5
 
7
6
  /**
8
7
  * A single SSE event from a streaming response.
@@ -242,10 +242,7 @@ export const OpenAIResponsesSSEventSchema = z
242
242
  .passthrough();
243
243
 
244
244
  export type OpenAIResponse = z.infer<typeof OpenAIResponseSchema>;
245
- export type OpenAIResponsesRequest = z.infer<typeof OpenAIResponsesRequestSchema>;
246
245
  export type OpenAIResponsesResponse = z.infer<typeof OpenAIResponsesResponseSchema>;
247
- export type OpenAIResponsesSSEvent = z.infer<typeof OpenAIResponsesSSEventSchema>;
248
- export type OpenAIResponsesOutputItem = z.infer<typeof OpenAIResponsesOutputItemSchema>;
249
246
 
250
247
  export function parseOpenAIResponse(rawBody: string): OpenAIResponse | null {
251
248
  try {
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { safeFetch } from "../lib/safeFetch";
2
3
  import type { KnowledgeCandidate, KnowledgeSearchResponse, OpenClawMemoryPayload } from "./types";
3
4
  import { KnowledgeSearchResponseSchema, KnowledgeSearchResultSchema } from "./types";
4
5
  import { isOpenClawGatewayConfigured, searchOpenClawGateway } from "./openclawGatewayClient";
@@ -81,11 +82,17 @@ export async function promoteToOpenClaw(candidate: KnowledgeCandidate): Promise<
81
82
  };
82
83
  }
83
84
 
84
- const response = await fetch(buildMemoryUrl(endpoint), {
85
- method: "POST",
86
- headers: authHeaders(),
87
- body: JSON.stringify(buildOpenClawPayload(candidate)),
88
- });
85
+ const fetchResult = await safeFetch(
86
+ buildMemoryUrl(endpoint),
87
+ {
88
+ method: "POST",
89
+ headers: authHeaders(),
90
+ body: JSON.stringify(buildOpenClawPayload(candidate)),
91
+ },
92
+ { bypass: true },
93
+ );
94
+ if (!fetchResult.ok) return { success: false, error: fetchResult.reason };
95
+ const response = fetchResult.response;
89
96
 
90
97
  if (!response.ok) {
91
98
  return { success: false, error: `OpenClaw memory write failed with ${response.status}` };
@@ -120,10 +127,16 @@ export async function searchOpenClaw(
120
127
  };
121
128
  }
122
129
 
123
- const response = await fetch(buildSearchUrl(endpoint, query, project), {
124
- method: "GET",
125
- headers: authHeaders(),
126
- });
130
+ const fetchResult = await safeFetch(
131
+ buildSearchUrl(endpoint, query, project),
132
+ {
133
+ method: "GET",
134
+ headers: authHeaders(),
135
+ },
136
+ { bypass: true },
137
+ );
138
+ if (!fetchResult.ok) return { results: [], warning: fetchResult.reason };
139
+ const response = fetchResult.response;
127
140
  if (!response.ok) {
128
141
  return { results: [], warning: `OpenClaw search failed with ${response.status}` };
129
142
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { safeFetch } from "../lib/safeFetch";
2
3
  import type { KnowledgeSearchResponse, KnowledgeSearchResult } from "./types";
3
4
 
4
5
  const OpenClawGatewayErrorSchema = z
@@ -192,19 +193,25 @@ export async function searchOpenClawGateway(
192
193
  };
193
194
  }
194
195
 
195
- const response = await fetch(buildToolInvokeUrl(gatewayUrl), {
196
- method: "POST",
197
- headers: gatewayHeaders(),
198
- body: JSON.stringify({
199
- tool: "memory_search",
200
- args: {
201
- query: buildSearchQuery(query, project),
202
- maxResults: 10,
203
- corpus: "all",
204
- },
205
- sessionKey: getOpenClawSessionKey(),
206
- }),
207
- });
196
+ const fetchResult = await safeFetch(
197
+ buildToolInvokeUrl(gatewayUrl),
198
+ {
199
+ method: "POST",
200
+ headers: gatewayHeaders(),
201
+ body: JSON.stringify({
202
+ tool: "memory_search",
203
+ args: {
204
+ query: buildSearchQuery(query, project),
205
+ maxResults: 10,
206
+ corpus: "all",
207
+ },
208
+ sessionKey: getOpenClawSessionKey(),
209
+ }),
210
+ },
211
+ { bypass: true },
212
+ );
213
+ if (!fetchResult.ok) return { results: [], warning: fetchResult.reason };
214
+ const response = fetchResult.response;
208
215
 
209
216
  const raw: unknown = await response.json().catch(() => null);
210
217
  if (!response.ok) {
@@ -68,12 +68,9 @@ export const AlertListResponseSchema = z.object({
68
68
  });
69
69
 
70
70
  export type AlertSeverity = z.infer<typeof AlertSeveritySchema>;
71
- export type AlertStatus = z.infer<typeof AlertStatusSchema>;
72
71
  export type AlertSource = z.infer<typeof AlertSourceSchema>;
73
72
  export type AlertCategory = z.infer<typeof AlertCategorySchema>;
74
- export type AlertEvidenceKind = z.infer<typeof AlertEvidenceKindSchema>;
75
73
  export type AlertEvidenceLink = z.infer<typeof AlertEvidenceLinkSchema>;
76
74
  export type Alert = z.infer<typeof AlertSchema>;
77
- export type AlertCategoryCount = z.infer<typeof AlertCategoryCountSchema>;
78
75
  export type AlertSummary = z.infer<typeof AlertSummarySchema>;
79
76
  export type AlertListResponse = z.infer<typeof AlertListResponseSchema>;
@@ -91,9 +91,6 @@ export type EcosystemPackageState = z.infer<typeof EcosystemPackageStateSchema>;
91
91
  export type EcosystemPackage = z.infer<typeof EcosystemPackageSchema>;
92
92
  export type EcosystemRunnerPreset = z.infer<typeof EcosystemRunnerPresetSchema>;
93
93
  export type EcosystemRecipe = z.infer<typeof EcosystemRecipeSchema>;
94
- export type EcosystemPackagesResponse = z.infer<typeof EcosystemPackagesResponseSchema>;
95
94
  export type EcosystemTaskAction = z.infer<typeof EcosystemTaskActionSchema>;
96
95
  export type EcosystemTaskStatus = z.infer<typeof EcosystemTaskStatusSchema>;
97
96
  export type EcosystemTask = z.infer<typeof EcosystemTaskSchema>;
98
- export type EcosystemTaskResponse = z.infer<typeof EcosystemTaskResponseSchema>;
99
- export type EcosystemTasksResponse = z.infer<typeof EcosystemTasksResponseSchema>;
@@ -2,6 +2,7 @@ import JSZip from "jszip";
2
2
  import type { CapturedLog } from "../contracts";
3
3
  import { fetchWithTimeout } from "./apiClient";
4
4
  import packageJson from "../../package.json";
5
+ import { isRecord } from "./unknown";
5
6
 
6
7
  export type ExportMode = "redacted" | "raw";
7
8
  export type ExportLogsResult =
@@ -97,10 +98,6 @@ async function fetchStreamingChunks(
97
98
  }
98
99
  }
99
100
 
100
- function isRecord(value: unknown): value is Record<string, unknown> {
101
- return typeof value === "object" && value !== null && !Array.isArray(value);
102
- }
103
-
104
101
  function redactValue(value: unknown): unknown {
105
102
  if (Array.isArray(value)) return value.map((item) => redactValue(item));
106
103
  if (!isRecord(value)) return value;
@@ -0,0 +1,46 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { z } from "zod";
3
+ import { readProperty } from "./unknown";
4
+
5
+ /**
6
+ * Reads a UTF-8 JSON file from disk and validates it against a Zod schema in
7
+ * one call. Used by config/secret/evidence files that store small records
8
+ * atomically rewritten on update — *not* for streaming log files (those go
9
+ * through line-by-line parsing in store.ts).
10
+ *
11
+ * On any failure returns `{ ok: false, reason }` rather than throwing. The
12
+ * caller decides between returning a fallback, logging, or surfacing an
13
+ * error response. To log on failure, wrap the call site with the project's
14
+ * `logger.warn` (the helper stays a pure parser to avoid a `lib → proxy`
15
+ * dependency direction).
16
+ */
17
+ export type ReadJsonFileFailureReason = "missing" | "io-error" | "parse-error" | "schema-mismatch";
18
+
19
+ export type ReadJsonFileResult<T> =
20
+ | { ok: true; data: T }
21
+ | { ok: false; reason: ReadJsonFileFailureReason };
22
+
23
+ export function readJsonFile<T>(path: string, schema: z.ZodType<T>): ReadJsonFileResult<T> {
24
+ let raw: string;
25
+ try {
26
+ raw = readFileSync(path, "utf8");
27
+ } catch (error) {
28
+ if (isEnoent(error)) return { ok: false, reason: "missing" };
29
+ return { ok: false, reason: "io-error" };
30
+ }
31
+
32
+ let parsed: unknown;
33
+ try {
34
+ parsed = JSON.parse(raw);
35
+ } catch {
36
+ return { ok: false, reason: "parse-error" };
37
+ }
38
+
39
+ const result = schema.safeParse(parsed);
40
+ if (!result.success) return { ok: false, reason: "schema-mismatch" };
41
+ return { ok: true, data: result.data };
42
+ }
43
+
44
+ function isEnoent(error: unknown): boolean {
45
+ return readProperty(error, "code") === "ENOENT";
46
+ }
@@ -0,0 +1,100 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { isIP } from "node:net";
3
+ import { evaluateUpstreamUrl, isInternalHost, type SsrfGuardOptions } from "./ssrfGuard";
4
+
5
+ export type ResolvedAddress = {
6
+ address: string;
7
+ family: number;
8
+ };
9
+
10
+ export type HostResolver = (hostname: string) => Promise<readonly ResolvedAddress[]>;
11
+
12
+ export type SafeFetchOptions = SsrfGuardOptions & {
13
+ resolver?: HostResolver;
14
+ };
15
+
16
+ export type SafeFetchResult =
17
+ | { ok: true; response: Response }
18
+ | { ok: false; kind: "network-policy" | "redirect"; reason: string };
19
+
20
+ async function defaultResolver(hostname: string): Promise<readonly ResolvedAddress[]> {
21
+ return lookup(hostname, { all: true, verbatim: true });
22
+ }
23
+
24
+ function isLoopbackAddress(hostname: string): boolean {
25
+ const lower = hostname.toLowerCase();
26
+ return (
27
+ lower === "::1" ||
28
+ lower.startsWith("::ffff:127.") ||
29
+ lower.startsWith("::ffff:7f00:") ||
30
+ /^127\./.test(lower)
31
+ );
32
+ }
33
+
34
+ export async function evaluateResolvedUpstreamUrl(
35
+ rawUrl: string,
36
+ options: SafeFetchOptions = {},
37
+ ): Promise<{ allow: true } | { allow: false; reason: string }> {
38
+ const initial = evaluateUpstreamUrl(rawUrl, options);
39
+ if (!initial.allow || options.bypass === true) return initial;
40
+
41
+ const parsed = new URL(rawUrl);
42
+ const hostname = parsed.hostname.replace(/^\[|]$/g, "");
43
+ if (isIP(hostname) !== 0 || hostname.toLowerCase() === "localhost") return initial;
44
+
45
+ let addresses: readonly ResolvedAddress[];
46
+ try {
47
+ addresses = await (options.resolver ?? defaultResolver)(hostname);
48
+ } catch (error) {
49
+ return {
50
+ allow: false,
51
+ reason: `Could not safely resolve upstream host "${hostname}": ${String(error)}`,
52
+ };
53
+ }
54
+
55
+ if (addresses.length === 0) {
56
+ return { allow: false, reason: `Upstream host "${hostname}" resolved to no addresses` };
57
+ }
58
+
59
+ for (const resolved of addresses) {
60
+ if (!isInternalHost(resolved.address)) continue;
61
+ if (options.allowLoopback === true && isLoopbackAddress(resolved.address)) continue;
62
+ return {
63
+ allow: false,
64
+ reason: `Refusing upstream host "${hostname}" because it resolves to internal address "${resolved.address}"`,
65
+ };
66
+ }
67
+
68
+ return { allow: true };
69
+ }
70
+
71
+ function isRedirectStatus(status: number): boolean {
72
+ return status >= 300 && status < 400;
73
+ }
74
+
75
+ export async function safeFetch(
76
+ rawUrl: string,
77
+ init: RequestInit = {},
78
+ options: SafeFetchOptions = {},
79
+ ): Promise<SafeFetchResult> {
80
+ const decision = await evaluateResolvedUpstreamUrl(rawUrl, options);
81
+ if (!decision.allow) {
82
+ return { ok: false, kind: "network-policy", reason: decision.reason };
83
+ }
84
+
85
+ const response = await fetch(rawUrl, { ...init, redirect: "manual" });
86
+ if (!isRedirectStatus(response.status)) return { ok: true, response };
87
+
88
+ if (response.body !== null) {
89
+ await response.body.cancel().catch(() => undefined);
90
+ }
91
+ const location = response.headers.get("location");
92
+ return {
93
+ ok: false,
94
+ kind: "redirect",
95
+ reason:
96
+ location === null
97
+ ? `Upstream returned blocked redirect HTTP ${String(response.status)}`
98
+ : `Upstream redirect to "${location}" is blocked; configure the final API URL directly`,
99
+ };
100
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * SSRF guard for upstream provider URLs.
3
+ *
4
+ * The proxy fetches arbitrary provider-configured base URLs (Anthropic, OpenAI,
5
+ * third-party compatible endpoints). Without protection, a malicious provider
6
+ * config could point the proxy at cloud metadata endpoints
7
+ * (169.254.169.254 / fd00:ec2::254), loopback services, or private RFC1918
8
+ * ranges — and the response body is captured to a log file the attacker can
9
+ * read back. We block those targets by default; an env var opt-in allows
10
+ * intentional localhost development (e.g. Ollama on 127.0.0.1).
11
+ */
12
+
13
+ export type SsrfDecision = { allow: true } | { allow: false; reason: string };
14
+
15
+ const LOOPBACK_HOSTNAMES = new Set([
16
+ "localhost",
17
+ "ip6-localhost",
18
+ "ip6-loopback",
19
+ "[::1]",
20
+ "[::ffff:127.0.0.1]",
21
+ ]);
22
+
23
+ /**
24
+ * Returns true when the host is a loopback address, link-local address, or
25
+ * private RFC1918 / RFC4193 / unique-local range. Both IPv4 dotted-quad and
26
+ * IPv6 forms are recognized; bracketed forms (`[::1]`) are normalized.
27
+ */
28
+ function isLoopbackHost(hostname: string): boolean {
29
+ const lower = hostname.toLowerCase().replace(/^\[|]$/g, "");
30
+ if (LOOPBACK_HOSTNAMES.has(lower)) return true;
31
+ if (lower === "::1") return true;
32
+ if (lower.startsWith("::ffff:127.")) return true;
33
+ if (lower.startsWith("::ffff:7f00:")) return true;
34
+ return /^127\./.test(lower);
35
+ }
36
+
37
+ function mappedIpv4Address(hostname: string): string | null {
38
+ const lower = hostname.toLowerCase();
39
+ if (!lower.startsWith("::ffff:")) return null;
40
+ const suffix = lower.slice("::ffff:".length);
41
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(suffix)) return suffix;
42
+ const match = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(suffix);
43
+ if (match === null) return null;
44
+ const highText = match[1];
45
+ const lowText = match[2];
46
+ if (highText === undefined || lowText === undefined) return null;
47
+ const high = Number.parseInt(highText, 16);
48
+ const low = Number.parseInt(lowText, 16);
49
+ return `${String(high >> 8)}.${String(high & 0xff)}.${String(low >> 8)}.${String(low & 0xff)}`;
50
+ }
51
+
52
+ export function isInternalHost(hostname: string): boolean {
53
+ if (LOOPBACK_HOSTNAMES.has(hostname.toLowerCase())) return true;
54
+
55
+ const mappedIpv4 = mappedIpv4Address(hostname);
56
+ if (mappedIpv4 !== null) return isInternalHost(mappedIpv4);
57
+
58
+ const ipv4Match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
59
+ if (ipv4Match !== null) {
60
+ const [, a, b] = ipv4Match;
61
+ if (a === undefined || b === undefined) return false;
62
+ const first = Number.parseInt(a, 10);
63
+ const second = Number.parseInt(b, 10);
64
+ if (first === 127) return true; // 127.0.0.0/8 loopback
65
+ if (first === 10) return true; // 10.0.0.0/8 private
66
+ if (first === 172 && second >= 16 && second <= 31) return true; // 172.16.0.0/12
67
+ if (first === 192 && second === 168) return true; // 192.168.0.0/16
68
+ if (first === 169 && second === 254) return true; // 169.254.0.0/16 link-local
69
+ if (first === 0) return true; // 0.0.0.0/8
70
+ if (first === 100 && second >= 64 && second <= 127) return true; // 100.64.0.0/10 CGNAT
71
+ if (first >= 224) return true; // multicast and reserved
72
+ if (first === 198 && (second === 18 || second === 19)) return true; // benchmark range
73
+ return false;
74
+ }
75
+
76
+ // IPv6 — bracketless form already stripped by URL parser; check for loopback
77
+ // and link-local prefixes.
78
+ const lower = hostname.toLowerCase();
79
+ if (lower === "::") return true;
80
+ if (lower === "::1") return true;
81
+ if (/^fe[89ab]/.test(lower)) return true; // fe80::/10 link-local
82
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // ULA (fd00::/8)
83
+ if (lower.startsWith("ff")) return true; // multicast
84
+
85
+ return false;
86
+ }
87
+
88
+ export type SsrfGuardOptions = {
89
+ /** Bypass the guard entirely (used by trusted test code). */
90
+ bypass?: boolean;
91
+ /** When true, allow loopback hosts (127.0.0.1, ::1, localhost). Default false. */
92
+ allowLoopback?: boolean;
93
+ };
94
+
95
+ /**
96
+ * Inspects a provider base URL and decides whether the proxy is allowed to
97
+ * forward requests to it. Returns `{ allow: true }` for public hosts and
98
+ * `{ allow: false, reason }` for internal/private addresses.
99
+ */
100
+ export function evaluateUpstreamUrl(rawUrl: string, options: SsrfGuardOptions = {}): SsrfDecision {
101
+ if (options.bypass === true) return { allow: true };
102
+
103
+ let parsed: URL;
104
+ try {
105
+ parsed = new URL(rawUrl);
106
+ } catch {
107
+ return { allow: false, reason: "Not a valid URL" };
108
+ }
109
+
110
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
111
+ return { allow: false, reason: `Unsupported scheme "${parsed.protocol}"` };
112
+ }
113
+
114
+ if (options.allowLoopback === true && parsed.hostname === "localhost") {
115
+ return { allow: true };
116
+ }
117
+
118
+ // Bun's URL parser keeps brackets on IPv6 hostnames (`[::1]`); strip them so
119
+ // prefix checks below work against the canonical form.
120
+ const hostname = parsed.hostname.replace(/^\[|]$/g, "");
121
+
122
+ if (isInternalHost(hostname)) {
123
+ if (options.allowLoopback === true) {
124
+ if (isLoopbackHost(hostname)) return { allow: true };
125
+ }
126
+ return {
127
+ allow: false,
128
+ reason: `Refusing to proxy to internal address "${hostname}"`,
129
+ };
130
+ }
131
+
132
+ return { allow: true };
133
+ }
@@ -1,11 +1,8 @@
1
1
  import type { CapturedLog } from "../contracts";
2
+ import { isRecord } from "./unknown";
2
3
 
3
4
  export type StopReason = "end_turn" | "tool_use" | "stop" | "length" | null;
4
5
 
5
- function isRecord(value: unknown): value is Record<string, unknown> {
6
- return typeof value === "object" && value !== null && !Array.isArray(value);
7
- }
8
-
9
6
  function normalizeAnthropicStopReason(value: string): StopReason {
10
7
  switch (value) {
11
8
  case "end_turn":
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared helpers for traversing values of type `unknown` without resorting to
3
+ * type assertions. The project's ESLint rules forbid `as` casts and non-null
4
+ * assertions, so every safe property read goes through these helpers.
5
+ */
6
+
7
+ /** True if `value` is a non-array, non-null object. */
8
+ export function isRecord(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+
12
+ /**
13
+ * Safely read an own property of `value`, returning `undefined` for any
14
+ * non-object input (including arrays, null, primitives). Uses
15
+ * `getOwnPropertyDescriptor` so that `__proto__` keys cannot be reached.
16
+ */
17
+ export function readProperty(value: unknown, name: string): unknown {
18
+ if (!isRecord(value)) return undefined;
19
+ const descriptor = Object.getOwnPropertyDescriptor(value, name);
20
+ return descriptor === undefined ? undefined : descriptor.value;
21
+ }
22
+
23
+ /** Byte length of a UTF-8 string, or `null` if the string is null. */
24
+ export function textByteLength(value: string | null): number | null {
25
+ if (value === null) return null;
26
+ return new TextEncoder().encode(value).byteLength;
27
+ }
@@ -13,7 +13,7 @@ async function fetcher(url: string): Promise<ProviderConfig[]> {
13
13
 
14
14
  export function useProviders() {
15
15
  const response: SWRResponse<ProviderConfig[], Error> = useSWR<ProviderConfig[], Error>(
16
- "/api/providers?includeSecrets=1",
16
+ "/api/providers",
17
17
  fetcher,
18
18
  {
19
19
  revalidateOnFocus: false,
package/src/mcp/mode.ts CHANGED
@@ -23,13 +23,7 @@ export function isMcpWriteEnabled(): boolean {
23
23
  if (readonlyFlag === true) return false;
24
24
 
25
25
  const writesFlag = parseFlag(process.env["AGENT_INSPECTOR_MCP_WRITES"]);
26
- if (writesFlag === false) return false;
27
-
28
- return true;
29
- }
30
-
31
- export function getMcpWriteMode(): McpWriteMode {
32
- return isMcpWriteEnabled() ? "enabled" : "readonly";
26
+ return writesFlag === true;
33
27
  }
34
28
 
35
29
  export function getMcpModeInfo(): {
@@ -50,7 +44,7 @@ export function getMcpModeInfo(): {
50
44
  AGENT_INSPECTOR_MCP_WRITES: process.env["AGENT_INSPECTOR_MCP_WRITES"] ?? null,
51
45
  },
52
46
  note: writesEnabled
53
- ? "MCP write/action tools are enabled. Set AGENT_INSPECTOR_MCP_READONLY=1 to expose the same catalog while blocking mutations."
54
- : "MCP is in readonly mode. Read tools/resources still work, while write/action tools return an isError result.",
47
+ ? "MCP write/action tools are enabled explicitly by AGENT_INSPECTOR_MCP_WRITES=1."
48
+ : "MCP is readonly by default. Read tools/resources still work; set AGENT_INSPECTOR_MCP_WRITES=1 to enable mutations.",
55
49
  };
56
50
  }