@tonyclaw/agent-inspector 3.0.25 → 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 (81) hide show
  1. package/.output/cli.js +80 -11
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-BnvOPwpe.js → CompareDrawer-BVCAAbbu.js} +1 -1
  4. package/.output/public/assets/ProxyViewerContainer-CYOXB81n.js +4234 -0
  5. package/.output/public/assets/{ReplayDialog-BJgoQWdW.js → ReplayDialog-DMX23O5G.js} +1 -1
  6. package/.output/public/assets/{RequestAnatomy-CpBZEHnM.js → RequestAnatomy-S17pYDRc.js} +1 -1
  7. package/.output/public/assets/{ResponseView-CWmyRb2M.js → ResponseView-Cf1XrawD.js} +2 -2
  8. package/.output/public/assets/{StreamingChunkSequence-BWhC8bwx.js → StreamingChunkSequence-BdOrVlCi.js} +1 -1
  9. package/.output/public/assets/{_sessionId-Cq-2OqUU.js → _sessionId-BnKGmJrR.js} +1 -1
  10. package/.output/public/assets/{index-Cf8O0Oos.js → index-9fMLCU4d.js} +1 -1
  11. package/.output/public/assets/{index-BKi9IRtV.js → index-B1atk19i.js} +3 -3
  12. package/.output/public/assets/index-CmeF5XXS.css +1 -0
  13. package/.output/public/assets/{index-DdgTJ4QJ.js → index-gY3I-7-D.js} +1 -1
  14. package/.output/public/assets/{json-viewer-Bqu_4hmm.js → json-viewer-CpyYpPjx.js} +1 -1
  15. package/.output/server/_libs/lucide-react.mjs +6 -6
  16. package/.output/server/_libs/three.mjs +26016 -0
  17. package/.output/server/{_sessionId-Ba7H3Gjw.mjs → _sessionId-eA_h2_s6.mjs} +9 -6
  18. package/.output/server/_ssr/{CompareDrawer-aH59Ab6x.mjs → CompareDrawer-K2k9G7r1.mjs} +10 -7
  19. package/.output/server/_ssr/{ProxyViewerContainer-By-I0tJv.mjs → ProxyViewerContainer-CbyBpCAj.mjs} +804 -363
  20. package/.output/server/_ssr/{ReplayDialog-BO4Vy0et.mjs → ReplayDialog-DacI2Iv0.mjs} +10 -7
  21. package/.output/server/_ssr/{RequestAnatomy-FtOr7vSS.mjs → RequestAnatomy-gh-pAdkO.mjs} +9 -6
  22. package/.output/server/_ssr/{ResponseView-cvw6ajue.mjs → ResponseView-BRJKN50-.mjs} +10 -7
  23. package/.output/server/_ssr/{StreamingChunkSequence-Oc8S2kD6.mjs → StreamingChunkSequence-BQLVEuWY.mjs} +9 -6
  24. package/.output/server/_ssr/{index-BDRlQheR.mjs → index-DuSWrjbH.mjs} +9 -6
  25. package/.output/server/_ssr/index.mjs +2 -2
  26. package/.output/server/_ssr/{json-viewer-Cd6trhQr.mjs → json-viewer-DNs1ZjKI.mjs} +9 -6
  27. package/.output/server/_ssr/{router-bofP-Ekl.mjs → router-Dgx6-ot3.mjs} +713 -218
  28. package/.output/server/{_tanstack-start-manifest_v-BPuf86qB.mjs → _tanstack-start-manifest_v-CoGjnkQD.mjs} +1 -1
  29. package/.output/server/index.mjs +65 -65
  30. package/README.md +8 -0
  31. package/package.json +6 -5
  32. package/src/cli.ts +43 -9
  33. package/src/components/inspector-pet/InspectorPet.tsx +66 -51
  34. package/src/components/inspector-pet/InspectorPetCrab3D.tsx +455 -0
  35. package/src/components/inspector-pet/inspectorPetLogic.ts +28 -43
  36. package/src/components/providers/ProviderCard.tsx +3 -36
  37. package/src/components/providers/ProviderForm.tsx +251 -175
  38. package/src/components/providers/ProvidersPanel.tsx +18 -8
  39. package/src/components/providers/SettingsDialog.tsx +3 -3
  40. package/src/components/providers/providerFormCapabilities.ts +36 -0
  41. package/src/components/proxy-viewer/ProviderLogoStack.tsx +0 -3
  42. package/src/contracts/anthropic.ts +0 -5
  43. package/src/contracts/index.ts +0 -4
  44. package/src/contracts/log.ts +0 -1
  45. package/src/contracts/openai.ts +0 -3
  46. package/src/knowledge/openclawClient.ts +22 -9
  47. package/src/knowledge/openclawGatewayClient.ts +20 -13
  48. package/src/lib/alertContract.ts +0 -3
  49. package/src/lib/ecosystemContract.ts +0 -3
  50. package/src/lib/export-logs.ts +1 -4
  51. package/src/lib/safeFetch.ts +100 -0
  52. package/src/lib/ssrfGuard.ts +133 -0
  53. package/src/lib/stopReason.ts +1 -4
  54. package/src/lib/unknown.ts +27 -0
  55. package/src/lib/useProviders.ts +1 -1
  56. package/src/mcp/mode.ts +3 -9
  57. package/src/mcp/server.ts +5 -14
  58. package/src/mcp/toolHandlers.ts +45 -54
  59. package/src/proxy/constants.ts +17 -0
  60. package/src/proxy/formats/anthropic/handler.ts +5 -1
  61. package/src/proxy/formats/anthropic/schemas.ts +1 -7
  62. package/src/proxy/formats/index.ts +1 -1
  63. package/src/proxy/formats/openai/schemas.ts +0 -3
  64. package/src/proxy/formats/registry.ts +0 -3
  65. package/src/proxy/handler.ts +91 -12
  66. package/src/proxy/identityProxy.ts +74 -1
  67. package/src/proxy/logImporter.ts +82 -12
  68. package/src/proxy/logIndex.ts +1 -4
  69. package/src/proxy/providerSecretStore.ts +181 -0
  70. package/src/proxy/providers.ts +84 -11
  71. package/src/proxy/schemas.ts +1 -8
  72. package/src/proxy/sqliteLogIndex.ts +0 -16
  73. package/src/proxy/store.ts +1 -5
  74. package/src/proxy/toolSchemaWarnings.ts +1 -9
  75. package/src/routes/api/logs.$id.replay.ts +17 -5
  76. package/src/routes/api/providers.$providerId.model-metadata.ts +11 -1
  77. package/src/routes/api/providers.$providerId.ts +18 -5
  78. package/src/routes/api/providers.ts +18 -7
  79. package/styles/globals.css +0 -61
  80. package/.output/public/assets/ProxyViewerContainer-DcqFqo1m.js +0 -127
  81. package/.output/public/assets/index-BMYuDL5X.css +0 -1
@@ -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,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
  }
package/src/mcp/server.ts CHANGED
@@ -1262,15 +1262,10 @@ function registerTools(server: McpServer): void {
1262
1262
  {
1263
1263
  title: "List configured LLM providers",
1264
1264
  description:
1265
- "Returns the configured ProviderConfig array with apiKey redacted by default. Set includeSecrets=true only when the user explicitly needs plaintext local provider keys; this exposes secrets from <dataDir>/providers.json.",
1266
- inputSchema: z.object({
1267
- includeSecrets: z
1268
- .boolean()
1269
- .optional()
1270
- .describe("Return plaintext provider API keys. Exposes local secrets when true."),
1271
- }),
1265
+ "Returns the configured ProviderConfig array with write-only API keys represented as configured.",
1266
+ inputSchema: z.object({}),
1272
1267
  },
1273
- (args) => safeCall(() => listProvidersImpl(callApi, args)),
1268
+ () => safeCall(() => listProvidersImpl(callApi)),
1274
1269
  );
1275
1270
 
1276
1271
  // ----- Action tools -----
@@ -1280,16 +1275,12 @@ function registerTools(server: McpServer): void {
1280
1275
  {
1281
1276
  title: "Get a single provider by id",
1282
1277
  description:
1283
- "Returns one ProviderConfig with apiKey redacted by default. Set includeSecrets=true only when the user explicitly needs the plaintext local provider key.",
1278
+ "Returns one ProviderConfig. API keys are write-only and represented as configured.",
1284
1279
  inputSchema: z.object({
1285
1280
  id: z.string().describe("The provider id."),
1286
- includeSecrets: z
1287
- .boolean()
1288
- .optional()
1289
- .describe("Return the plaintext provider API key. Exposes a local secret when true."),
1290
1281
  }),
1291
1282
  },
1292
- ({ id, includeSecrets }) => safeCall(() => getProviderImpl(callApi, id, { includeSecrets })),
1283
+ ({ id }) => safeCall(() => getProviderImpl(callApi, id)),
1293
1284
  );
1294
1285
 
1295
1286
  server.registerTool(
@@ -36,6 +36,7 @@ import {
36
36
  } from "../lib/groupContract";
37
37
  import { type CreateInspectorRunInput, type UpdateInspectorRunInput } from "../lib/runContract";
38
38
  import { SessionInfoSchema } from "../lib/sessionInfoContract";
39
+ import { isRecord, textByteLength } from "../lib/unknown";
39
40
  import { CapturedLogSchema, type CapturedLog } from "../contracts";
40
41
  import {
41
42
  addInspectorGroupSession,
@@ -112,11 +113,6 @@ function inspectorBaseUrl(): string {
112
113
  return `http://127.0.0.1:${String(getCurrentPort())}`;
113
114
  }
114
115
 
115
- function textByteLength(value: string | null): number | null {
116
- if (value === null) return null;
117
- return Buffer.byteLength(value, "utf8");
118
- }
119
-
120
116
  function hasBodyText(value: string | null, byteLength: number | null | undefined): boolean {
121
117
  if (value !== null && value.length > 0) return true;
122
118
  return byteLength !== undefined && byteLength !== null && byteLength > 0;
@@ -272,15 +268,44 @@ async function readCompactLogsPage(
272
268
  params: URLSearchParams,
273
269
  ): Promise<{ logs: CapturedLog[]; total: number } | ToolResult> {
274
270
  const path = `/api/logs?${params.toString()}`;
275
- const res = await callApi(path);
276
- if (!res.ok) return toolError(`GET ${path} returned ${res.status}`);
277
- const rawBody: unknown = await res.json();
278
- const parsed = LogsListResponseSchema.safeParse(rawBody);
279
- if (!parsed.success) return toolError("GET /api/logs returned an unparseable logs shape");
271
+ const parsed = await fetchApiJson(callApi, path, LogsListResponseSchema);
272
+ if (!parsed.ok) return parsed.error;
280
273
  const logs = parsed.data.logs ?? [];
281
274
  return { logs, total: parsed.data.total ?? logs.length };
282
275
  }
283
276
 
277
+ /**
278
+ * Fetches a JSON endpoint and validates it against a Zod schema in one call.
279
+ * On HTTP error or schema mismatch returns a `ToolResult` error so the caller
280
+ * can propagate it directly. On success returns the typed parsed value.
281
+ */
282
+ type FetchApiResult<T> = { ok: true; data: T } | { ok: false; error: ToolResult };
283
+
284
+ async function fetchApiJson<T>(
285
+ callApi: CallApiFn,
286
+ path: string,
287
+ schema: z.ZodType<T>,
288
+ init?: RequestInit,
289
+ ): Promise<FetchApiResult<T>> {
290
+ const res = await callApi(path, init);
291
+ if (!res.ok) {
292
+ return { ok: false, error: toolError(`${methodFor(init)} ${path} returned ${res.status}`) };
293
+ }
294
+ const rawBody: unknown = await res.json();
295
+ const parsed = schema.safeParse(rawBody);
296
+ if (!parsed.success) {
297
+ return {
298
+ ok: false,
299
+ error: toolError(`${methodFor(init)} ${path} returned an unparseable shape`),
300
+ };
301
+ }
302
+ return { ok: true, data: parsed.data };
303
+ }
304
+
305
+ function methodFor(init: RequestInit | undefined): string {
306
+ return (init?.method ?? "GET").toUpperCase();
307
+ }
308
+
284
309
  function isToolResult(value: unknown): value is ToolResult {
285
310
  return (
286
311
  typeof value === "object" &&
@@ -373,13 +398,8 @@ export async function searchLogsImpl(
373
398
  }
374
399
 
375
400
  export async function getLogImpl(callApi: CallApiFn, id: number): Promise<ToolResult> {
376
- const res = await callApi(`/api/logs/${id}`);
377
- if (!res.ok) return toolError(`GET /api/logs/${id} returned ${res.status}`);
378
- const rawBody: unknown = await res.json();
379
- const parsed = CapturedLogSchema.safeParse(rawBody);
380
- if (!parsed.success) {
381
- return toolError(`GET /api/logs/${id} returned an unparseable CapturedLog`);
382
- }
401
+ const parsed = await fetchApiJson(callApi, `/api/logs/${id}`, CapturedLogSchema);
402
+ if (!parsed.ok) return parsed.error;
383
403
  return textJson(buildBoundedLogDetail(parsed.data));
384
404
  }
385
405
 
@@ -413,11 +433,8 @@ export async function getSessionImpl(
413
433
  params.set("includeHistory", "1");
414
434
  }
415
435
  const path = `/api/sessions?${params.toString()}`;
416
- const res = await callApi(path);
417
- if (!res.ok) return toolError(`GET ${path} returned ${res.status}`);
418
- const rawBody: unknown = await res.json();
419
- const parsed = SessionInfoSchema.safeParse(rawBody);
420
- if (!parsed.success) return toolError("GET /api/sessions returned an unparseable session info");
436
+ const parsed = await fetchApiJson(callApi, path, SessionInfoSchema);
437
+ if (!parsed.ok) return parsed.error;
421
438
  return textJson(parsed.data);
422
439
  }
423
440
 
@@ -724,13 +741,8 @@ export async function getProcessTimelineImpl(
724
741
  }
725
742
 
726
743
  export async function getLogHeadersImpl(callApi: CallApiFn, id: number): Promise<ToolResult> {
727
- const res = await callApi(`/api/logs/${id}`);
728
- if (!res.ok) return toolError(`GET /api/logs/${id} returned ${res.status}`);
729
- const rawBody: unknown = await res.json();
730
- const parsed = CapturedLogSchema.safeParse(rawBody);
731
- if (!parsed.success) {
732
- return toolError(`GET /api/logs/${id} returned an unparseable CapturedLog`);
733
- }
744
+ const parsed = await fetchApiJson(callApi, `/api/logs/${id}`, CapturedLogSchema);
745
+ if (!parsed.ok) return parsed.error;
734
746
  const log = parsed.data;
735
747
  return textJson({
736
748
  id: log.id,
@@ -1045,31 +1057,14 @@ export async function listModelsImpl(callApi: CallApiFn): Promise<ToolResult> {
1045
1057
  return textJson(await res.json());
1046
1058
  }
1047
1059
 
1048
- export type ProviderReadArgs = {
1049
- includeSecrets?: boolean;
1050
- };
1051
-
1052
- function includeSecretsQuery(includeSecrets: boolean | undefined): string {
1053
- return includeSecrets === true ? "?includeSecrets=1" : "";
1054
- }
1055
-
1056
- export async function listProvidersImpl(
1057
- callApi: CallApiFn,
1058
- args: ProviderReadArgs = {},
1059
- ): Promise<ToolResult> {
1060
- const res = await callApi(`/api/providers${includeSecretsQuery(args.includeSecrets)}`);
1060
+ export async function listProvidersImpl(callApi: CallApiFn): Promise<ToolResult> {
1061
+ const res = await callApi("/api/providers");
1061
1062
  if (!res.ok) return toolError(`GET /api/providers returned ${res.status}`);
1062
1063
  return textJson(await res.json());
1063
1064
  }
1064
1065
 
1065
- export async function getProviderImpl(
1066
- callApi: CallApiFn,
1067
- id: string,
1068
- args: ProviderReadArgs = {},
1069
- ): Promise<ToolResult> {
1070
- const res = await callApi(
1071
- `/api/providers/${encodeURIComponent(id)}${includeSecretsQuery(args.includeSecrets)}`,
1072
- );
1066
+ export async function getProviderImpl(callApi: CallApiFn, id: string): Promise<ToolResult> {
1067
+ const res = await callApi(`/api/providers/${encodeURIComponent(id)}`);
1073
1068
  if (!res.ok) return toolError(`GET /api/providers/${id} returned ${res.status}`);
1074
1069
  return textJson(await res.json());
1075
1070
  }
@@ -1182,10 +1177,6 @@ type ProviderDiagnosticIssue = {
1182
1177
  endpoint: string | null;
1183
1178
  };
1184
1179
 
1185
- function isRecord(value: unknown): value is Record<string, unknown> {
1186
- return typeof value === "object" && value !== null && !Array.isArray(value);
1187
- }
1188
-
1189
1180
  function stringField(record: Record<string, unknown>, key: string): string | null {
1190
1181
  const value = record[key];
1191
1182
  return typeof value === "string" && value.length > 0 ? value : null;