@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.
- package/.output/cli.js +80 -11
- package/.output/nitro.json +1 -1
- package/.output/public/assets/{CompareDrawer-SZRUDQc9.js → CompareDrawer-BVCAAbbu.js} +1 -1
- package/.output/public/assets/{ProxyViewerContainer-BJvwOrSi.js → ProxyViewerContainer-CYOXB81n.js} +200 -200
- package/.output/public/assets/{ReplayDialog-DgfmqiM3.js → ReplayDialog-DMX23O5G.js} +1 -1
- package/.output/public/assets/{RequestAnatomy-CS6c7Qfn.js → RequestAnatomy-S17pYDRc.js} +1 -1
- package/.output/public/assets/{ResponseView-C0Ri_5gm.js → ResponseView-Cf1XrawD.js} +2 -2
- package/.output/public/assets/{StreamingChunkSequence-7dYcHWyZ.js → StreamingChunkSequence-BdOrVlCi.js} +1 -1
- package/.output/public/assets/{_sessionId-D-V84cgw.js → _sessionId-BnKGmJrR.js} +1 -1
- package/.output/public/assets/{index-DgFWCxOs.js → index-9fMLCU4d.js} +1 -1
- package/.output/public/assets/{index-CSIL7-MK.js → index-B1atk19i.js} +2 -2
- package/.output/public/assets/{index-DrVJsks2.js → index-gY3I-7-D.js} +1 -1
- package/.output/public/assets/{json-viewer-C9Ji3gYN.js → json-viewer-CpyYpPjx.js} +1 -1
- package/.output/server/_libs/lucide-react.mjs +6 -6
- package/.output/server/{_sessionId-BQ6YQOO1.mjs → _sessionId-eA_h2_s6.mjs} +4 -2
- package/.output/server/_ssr/{CompareDrawer-CqNzIeMR.mjs → CompareDrawer-K2k9G7r1.mjs} +5 -3
- package/.output/server/_ssr/{ProxyViewerContainer-CMC7Vsbw.mjs → ProxyViewerContainer-CbyBpCAj.mjs} +27 -60
- package/.output/server/_ssr/{ReplayDialog-D9p_LaTr.mjs → ReplayDialog-DacI2Iv0.mjs} +5 -3
- package/.output/server/_ssr/{RequestAnatomy-B4ge3qNZ.mjs → RequestAnatomy-gh-pAdkO.mjs} +4 -2
- package/.output/server/_ssr/{ResponseView-BCqMKApI.mjs → ResponseView-BRJKN50-.mjs} +5 -3
- package/.output/server/_ssr/{StreamingChunkSequence-BA4zTLE4.mjs → StreamingChunkSequence-BQLVEuWY.mjs} +4 -2
- package/.output/server/_ssr/{index-CzxhDjlq.mjs → index-DuSWrjbH.mjs} +4 -2
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{json-viewer-B3qV6iEJ.mjs → json-viewer-DNs1ZjKI.mjs} +4 -2
- package/.output/server/_ssr/{router-CZe_R31M.mjs → router-Dgx6-ot3.mjs} +712 -217
- package/.output/server/{_tanstack-start-manifest_v-B50VTLrH.mjs → _tanstack-start-manifest_v-CoGjnkQD.mjs} +1 -1
- package/.output/server/index.mjs +64 -64
- package/README.md +8 -0
- package/package.json +4 -5
- package/src/cli.ts +43 -9
- package/src/components/providers/ProviderCard.tsx +3 -36
- package/src/components/providers/ProviderForm.tsx +13 -12
- package/src/components/providers/ProvidersPanel.tsx +2 -2
- package/src/components/providers/SettingsDialog.tsx +3 -3
- package/src/components/proxy-viewer/ProviderLogoStack.tsx +0 -3
- package/src/contracts/anthropic.ts +0 -5
- package/src/contracts/index.ts +0 -4
- package/src/contracts/log.ts +0 -1
- package/src/contracts/openai.ts +0 -3
- package/src/knowledge/openclawClient.ts +22 -9
- package/src/knowledge/openclawGatewayClient.ts +20 -13
- package/src/lib/alertContract.ts +0 -3
- package/src/lib/ecosystemContract.ts +0 -3
- package/src/lib/export-logs.ts +1 -4
- package/src/lib/safeFetch.ts +100 -0
- package/src/lib/ssrfGuard.ts +133 -0
- package/src/lib/stopReason.ts +1 -4
- package/src/lib/unknown.ts +27 -0
- package/src/lib/useProviders.ts +1 -1
- package/src/mcp/mode.ts +3 -9
- package/src/mcp/server.ts +5 -14
- package/src/mcp/toolHandlers.ts +45 -54
- package/src/proxy/constants.ts +17 -0
- package/src/proxy/formats/anthropic/handler.ts +5 -1
- package/src/proxy/formats/anthropic/schemas.ts +1 -7
- package/src/proxy/formats/index.ts +1 -1
- package/src/proxy/formats/openai/schemas.ts +0 -3
- package/src/proxy/formats/registry.ts +0 -3
- package/src/proxy/handler.ts +91 -12
- package/src/proxy/identityProxy.ts +74 -1
- package/src/proxy/logImporter.ts +82 -12
- package/src/proxy/logIndex.ts +1 -4
- package/src/proxy/providerSecretStore.ts +181 -0
- package/src/proxy/providers.ts +84 -11
- package/src/proxy/schemas.ts +1 -8
- package/src/proxy/sqliteLogIndex.ts +0 -16
- package/src/proxy/store.ts +1 -5
- package/src/proxy/toolSchemaWarnings.ts +1 -9
- package/src/routes/api/logs.$id.replay.ts +17 -5
- package/src/routes/api/providers.$providerId.model-metadata.ts +11 -1
- package/src/routes/api/providers.$providerId.ts +18 -5
- package/src/routes/api/providers.ts +18 -7
|
@@ -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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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) {
|
package/src/lib/alertContract.ts
CHANGED
|
@@ -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>;
|
package/src/lib/export-logs.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/stopReason.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/useProviders.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
54
|
-
: "MCP is
|
|
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
|
|
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
|
-
(
|
|
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
|
|
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
|
|
1283
|
+
({ id }) => safeCall(() => getProviderImpl(callApi, id)),
|
|
1293
1284
|
);
|
|
1294
1285
|
|
|
1295
1286
|
server.registerTool(
|
package/src/mcp/toolHandlers.ts
CHANGED
|
@@ -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
|
|
276
|
-
if (!
|
|
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
|
|
377
|
-
if (!
|
|
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
|
|
417
|
-
if (!
|
|
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
|
|
728
|
-
if (!
|
|
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
|
|
1049
|
-
|
|
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
|
|
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;
|