@jami-studio/core 0.92.25 → 0.92.26

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.
@@ -1,5 +1,11 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.92.26
4
+
5
+ ### Patch Changes
6
+
7
+ - 64f78fe: ssrfSafeFetch trusts the deployment's OWN configured origins (APP_URL, BETTER_AUTH_URL, WEBHOOK_BASE_URL, WORKSPACE_GATEWAY_URL, workspace app manifest URLs). These are operator configuration, not user input, so a fetch to them is a self-call — the private-address guard was blocking every workspace-internal A2A call (call-agent to a sibling app) on local dev and self-hosted private networks. Only exact origin matches are trusted; redirect hops are still re-validated, so a trusted origin cannot 30x into the private network, and the default posture with no configured origins is unchanged.
8
+
3
9
  ## 0.92.25
4
10
 
5
11
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.92.25",
3
+ "version": "0.92.26",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/studio-jami/jami-studio#readme",
6
6
  "bugs": {
@@ -1,306 +1,363 @@
1
- const METADATA_HOSTS = [
2
- "metadata.google.internal",
3
- "metadata.google.internal.",
4
- ];
5
-
6
- const DNS_REBIND_SUFFIXES = [
7
- ".nip.io",
8
- ".sslip.io",
9
- ".xip.io",
10
- ".localtest.me",
11
- ".lvh.me",
12
- ];
13
-
14
- function isPrivateIpv4(a: number, b: number, c = 0, d = 0): boolean {
15
- if (![a, b, c, d].every((part) => part >= 0 && part <= 255)) return true;
16
- if (a === 127) return true;
17
- if (a === 10) return true;
18
- if (a === 172 && b >= 16 && b <= 31) return true;
19
- if (a === 192 && b === 168) return true;
20
- if (a === 169 && b === 254) return true;
21
- if (a === 0) return true;
22
- if (a === 100 && b >= 64 && b <= 127) return true;
23
- if (a === 192 && b === 0) return true;
24
- if (a === 198 && (b === 18 || b === 19)) return true;
25
- if (a === 192 && b === 0 && c === 2) return true;
26
- if (a === 198 && b === 51 && c === 100) return true;
27
- if (a === 203 && b === 0 && c === 113) return true;
28
- if (a >= 224) return true;
29
- return false;
30
- }
31
-
32
- function isPrivateIpv4MappedHex(host: string): boolean {
33
- const mapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
34
- if (!mapped) return false;
35
- const high = Number.parseInt(mapped[1], 16);
36
- const low = Number.parseInt(mapped[2], 16);
37
- if (high < 0 || high > 0xffff || low < 0 || low > 0xffff) return false;
38
- const a = (high >> 8) & 0xff;
39
- const b = high & 0xff;
40
- const c = (low >> 8) & 0xff;
41
- const d = low & 0xff;
42
- return isPrivateIpv4(a, b, c, d);
43
- }
44
-
45
- function isPrivateHost(hostname: string): boolean {
46
- const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
47
- if (
48
- host === "localhost" ||
49
- host === "::1" ||
50
- host === "::0" ||
51
- host === "::"
52
- ) {
53
- return true;
54
- }
55
- if (METADATA_HOSTS.includes(host)) return true;
56
-
57
- // IPv6 ULA/link-local/multicast.
58
- if (/^f[cd]/.test(host) || /^fe[89ab]/.test(host)) return true;
59
- if (/^ff/i.test(host)) return true;
60
-
61
- // IPv4-mapped IPv6. URL parsing may preserve dotted form in some runtimes
62
- // or normalize it to hex, e.g. [::ffff:127.0.0.1] -> ::ffff:7f00:1.
63
- const v4mappedDotted = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
64
- if (v4mappedDotted) {
65
- const [a, b, c, d] = v4mappedDotted[1].split(".").map(Number);
66
- if (isPrivateIpv4(a, b, c, d)) return true;
67
- }
68
- if (isPrivateIpv4MappedHex(host)) return true;
69
-
70
- // Dotted IPv4. URL parsing normalizes shorthand/octal/hex IPv4 forms to
71
- // dotted decimal before we reach this point.
72
- const parts = host.split(".");
73
- if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p))) {
74
- const [a, b, c, d] = parts.map(Number);
75
- if (isPrivateIpv4(a, b, c, d)) return true;
76
- }
77
-
78
- // Decimal integer IPv4.
79
- if (/^\d+$/.test(host)) {
80
- const num = Number(host);
81
- if (num >= 0 && num <= 0xffffffff) {
82
- const a = (num >>> 24) & 0xff;
83
- const b = (num >>> 16) & 0xff;
84
- const c = (num >>> 8) & 0xff;
85
- const d = num & 0xff;
86
- if (isPrivateIpv4(a, b, c, d)) return true;
87
- }
88
- }
89
-
90
- return false;
91
- }
92
-
93
- export function isBlockedExtensionUrl(url: string): boolean {
94
- try {
95
- const parsed = new URL(url);
96
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
97
- return true;
98
- }
99
- const host = parsed.hostname.toLowerCase();
100
- if (isPrivateHost(host)) return true;
101
- if (
102
- DNS_REBIND_SUFFIXES.some((suffix) => {
103
- const bare = suffix.slice(1);
104
- return host === bare || host.endsWith(suffix);
105
- })
106
- ) {
107
- return true;
108
- }
109
- } catch {
110
- return true;
111
- }
112
- return false;
113
- }
114
-
115
- function isIpLiteralHost(hostname: string): boolean {
116
- const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
117
- if (host.includes(":")) return true;
118
- const parts = host.split(".");
119
- return parts.length === 4 && parts.every((p) => /^\d+$/.test(p));
120
- }
121
-
122
- /**
123
- * Async SSRF guard for environments that can resolve DNS. The synchronous
124
- * guard catches literals and known rebinding domains; this closes the common
125
- * "public hostname resolves to a private address" gap before dispatch.
126
- */
127
- export async function isBlockedExtensionUrlWithDns(
128
- url: string,
129
- ): Promise<boolean> {
130
- if (isBlockedExtensionUrl(url)) return true;
131
-
132
- let hostname: string;
133
- try {
134
- hostname = new URL(url).hostname.toLowerCase();
135
- } catch {
136
- return true;
137
- }
138
- if (!hostname || isIpLiteralHost(hostname)) return false;
139
-
140
- try {
141
- const { lookup } = await import("node:dns/promises");
142
- const records = await lookup(hostname, { all: true, verbatim: true });
143
- return records.some((record) => isPrivateHost(record.address));
144
- } catch {
145
- // Some edge runtimes do not expose DNS lookup. Keep the deterministic
146
- // parser-based protections instead of failing every outbound request.
147
- return false;
148
- }
149
- }
150
-
151
- /**
152
- * Build an undici Dispatcher whose connect-time DNS lookup runs through a
153
- * private-IP guard. This closes the TOCTOU gap where:
154
- * 1. We resolve hostname public IP and pass.
155
- * 2. Between that lookup and the actual connect, DNS rebinding flips the
156
- * record to a private IP.
157
- * 3. fetch() resolves again and connects to the private IP.
158
- *
159
- * With a custom dispatcher, the same lookup that produces the IP also gates
160
- * the connect: if the IP is in the private set, the connect throws.
161
- *
162
- * Returns `null` if undici / node:dns are not available (e.g. some edge
163
- * runtimes); the caller should fall back to the regular `fetch` path —
164
- * `isBlockedExtensionUrlWithDns` will still have caught most rebinding cases.
165
- */
166
- export async function createSsrfSafeDispatcher(): Promise<unknown | null> {
167
- // Keep the optional undici import opaque to Vite/Rolldown. A static
168
- // `import("undici")` makes browser builds try to resolve and bundle undici
169
- // even though this dispatcher is only useful in Node server runtimes.
170
- let undici: any;
171
- let dnsModule: any;
172
- try {
173
- const runtimeImport = new Function(
174
- "specifier",
175
- "return import(specifier)",
176
- ) as (specifier: string) => Promise<any>;
177
- undici = await runtimeImport("undici");
178
- dnsModule = await import("node:dns");
179
- } catch {
180
- return null;
181
- }
182
-
183
- const { Agent } = undici;
184
- const { lookup } = dnsModule;
185
- if (!Agent || !lookup) return null;
186
-
187
- return new Agent({
188
- connect: {
189
- // Override DNS lookup at connect time so the IP we hand to undici's
190
- // socket is the one we authorized. Reject any record in the private
191
- // set BEFORE the TCP handshake.
192
- lookup: (
193
- hostname: string,
194
- options: any,
195
- callback: (
196
- err: NodeJS.ErrnoException | null,
197
- address?: string | { address: string; family: number }[],
198
- family?: number,
199
- ) => void,
200
- ) => {
201
- lookup(
202
- hostname,
203
- { all: true, verbatim: true },
204
- (err: NodeJS.ErrnoException | null, addresses: any) => {
205
- if (err) return callback(err);
206
- const list: { address: string; family: number }[] = Array.isArray(
207
- addresses,
208
- )
209
- ? addresses
210
- : [{ address: addresses, family: 4 }];
211
- for (const record of list) {
212
- if (isPrivateHost(record.address)) {
213
- const e = new Error(
214
- `Connect blocked: ${hostname} resolved to private address ${record.address}`,
215
- ) as NodeJS.ErrnoException;
216
- e.code = "EAI_BLOCKED";
217
- return callback(e);
218
- }
219
- }
220
- // Mirror Node's lookup behavior: when `all` is true, return the
221
- // array; otherwise the first entry. undici's connect honors
222
- // `options.all`.
223
- if (options && options.all) {
224
- return callback(null, list as any);
225
- }
226
- const first = list[0];
227
- return callback(null, first.address, first.family);
228
- },
229
- );
230
- },
231
- },
232
- });
233
- }
234
-
235
- /**
236
- * SSRF-safe `fetch` for any server-side request to a user/agent-supplied URL.
237
- *
238
- * Applies the same protections the extension proxy uses, so every call site
239
- * that fetches an untrusted URL gets them without re-implementing the loop:
240
- * 1. Pre-flight DNS-aware private-address check (isBlockedExtensionUrlWithDns)
241
- * on the initial URL and on every redirect hop.
242
- * 2. A connect-time dispatcher that re-checks the resolved IP at TCP-connect
243
- * time (closes the DNS-rebinding TOCTOU) when undici is available.
244
- * 3. Manual redirect handling — a public URL cannot 30x-redirect into the
245
- * private network because each hop is re-validated before it is followed.
246
- *
247
- * Throws an Error whose message starts with "SSRF blocked:" when a target
248
- * (initial or via redirect) resolves to a private/internal address, or when the
249
- * redirect limit is exceeded. Otherwise returns the final Response.
250
- *
251
- * `httpsOnly` extends the per-hop validation to the URL scheme: redirects are
252
- * followed only to `https:` targets, so an HTTPS-only caller cannot be
253
- * downgraded to plain HTTP by a 30x from the (untrusted) origin.
254
- */
255
- export async function ssrfSafeFetch(
256
- url: string,
257
- init: RequestInit = {},
258
- options: { maxRedirects?: number; httpsOnly?: boolean } = {},
259
- ): Promise<Response> {
260
- const maxRedirects = options.maxRedirects ?? 3;
261
- const dispatcher = (await createSsrfSafeDispatcher()) ?? undefined;
262
-
263
- let currentUrl = url;
264
- for (let hop = 0; hop <= maxRedirects; hop++) {
265
- if (options.httpsOnly && new URL(currentUrl).protocol !== "https:") {
266
- throw new Error(
267
- `SSRF blocked: refusing to fetch non-HTTPS address (${currentUrl})`,
268
- );
269
- }
270
- if (await isBlockedExtensionUrlWithDns(currentUrl)) {
271
- throw new Error(
272
- `SSRF blocked: refusing to fetch private/internal address (${currentUrl})`,
273
- );
274
- }
275
- const fetchOpts: RequestInit & { dispatcher?: unknown } = {
276
- ...init,
277
- redirect: "manual",
278
- };
279
- if (dispatcher) fetchOpts.dispatcher = dispatcher;
280
-
281
- const response = await fetch(currentUrl, fetchOpts);
282
- if (response.status >= 300 && response.status < 400) {
283
- const location = response.headers.get("location");
284
- if (!location) return response;
285
- // Drain the redirect body so the hop's connection is released instead
286
- // of being held until GC.
287
- await response.body?.cancel().catch(() => {});
288
- currentUrl = new URL(location, currentUrl).href;
289
- continue;
290
- }
291
- return response;
292
- }
293
- throw new Error(
294
- `SSRF blocked: too many redirects (>${maxRedirects}) while fetching ${url}`,
295
- );
296
- }
297
-
298
- // ─────────────────────────────────────────────────────────────────────────────
299
- // Legacy aliases — predate the Tools → Extensions rename. Templates import
300
- // these via the legacy `@agent-native/core/tools/url-safety` subpath; keep
301
- // the names exported so they keep resolving until every consumer updates.
302
- // ─────────────────────────────────────────────────────────────────────────────
303
-
304
- export { isBlockedExtensionUrl as isBlockedToolUrl };
305
- export { isBlockedExtensionUrlWithDns as isBlockedToolUrlWithDns };
306
- export { ssrfSafeFetch as ssrfSafeToolFetch };
1
+ const METADATA_HOSTS = [
2
+ "metadata.google.internal",
3
+ "metadata.google.internal.",
4
+ ];
5
+
6
+ const DNS_REBIND_SUFFIXES = [
7
+ ".nip.io",
8
+ ".sslip.io",
9
+ ".xip.io",
10
+ ".localtest.me",
11
+ ".lvh.me",
12
+ ];
13
+
14
+ function isPrivateIpv4(a: number, b: number, c = 0, d = 0): boolean {
15
+ if (![a, b, c, d].every((part) => part >= 0 && part <= 255)) return true;
16
+ if (a === 127) return true;
17
+ if (a === 10) return true;
18
+ if (a === 172 && b >= 16 && b <= 31) return true;
19
+ if (a === 192 && b === 168) return true;
20
+ if (a === 169 && b === 254) return true;
21
+ if (a === 0) return true;
22
+ if (a === 100 && b >= 64 && b <= 127) return true;
23
+ if (a === 192 && b === 0) return true;
24
+ if (a === 198 && (b === 18 || b === 19)) return true;
25
+ if (a === 192 && b === 0 && c === 2) return true;
26
+ if (a === 198 && b === 51 && c === 100) return true;
27
+ if (a === 203 && b === 0 && c === 113) return true;
28
+ if (a >= 224) return true;
29
+ return false;
30
+ }
31
+
32
+ function isPrivateIpv4MappedHex(host: string): boolean {
33
+ const mapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
34
+ if (!mapped) return false;
35
+ const high = Number.parseInt(mapped[1], 16);
36
+ const low = Number.parseInt(mapped[2], 16);
37
+ if (high < 0 || high > 0xffff || low < 0 || low > 0xffff) return false;
38
+ const a = (high >> 8) & 0xff;
39
+ const b = high & 0xff;
40
+ const c = (low >> 8) & 0xff;
41
+ const d = low & 0xff;
42
+ return isPrivateIpv4(a, b, c, d);
43
+ }
44
+
45
+ function isPrivateHost(hostname: string): boolean {
46
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
47
+ if (
48
+ host === "localhost" ||
49
+ host === "::1" ||
50
+ host === "::0" ||
51
+ host === "::"
52
+ ) {
53
+ return true;
54
+ }
55
+ if (METADATA_HOSTS.includes(host)) return true;
56
+
57
+ // IPv6 ULA/link-local/multicast.
58
+ if (/^f[cd]/.test(host) || /^fe[89ab]/.test(host)) return true;
59
+ if (/^ff/i.test(host)) return true;
60
+
61
+ // IPv4-mapped IPv6. URL parsing may preserve dotted form in some runtimes
62
+ // or normalize it to hex, e.g. [::ffff:127.0.0.1] -> ::ffff:7f00:1.
63
+ const v4mappedDotted = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
64
+ if (v4mappedDotted) {
65
+ const [a, b, c, d] = v4mappedDotted[1].split(".").map(Number);
66
+ if (isPrivateIpv4(a, b, c, d)) return true;
67
+ }
68
+ if (isPrivateIpv4MappedHex(host)) return true;
69
+
70
+ // Dotted IPv4. URL parsing normalizes shorthand/octal/hex IPv4 forms to
71
+ // dotted decimal before we reach this point.
72
+ const parts = host.split(".");
73
+ if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p))) {
74
+ const [a, b, c, d] = parts.map(Number);
75
+ if (isPrivateIpv4(a, b, c, d)) return true;
76
+ }
77
+
78
+ // Decimal integer IPv4.
79
+ if (/^\d+$/.test(host)) {
80
+ const num = Number(host);
81
+ if (num >= 0 && num <= 0xffffffff) {
82
+ const a = (num >>> 24) & 0xff;
83
+ const b = (num >>> 16) & 0xff;
84
+ const c = (num >>> 8) & 0xff;
85
+ const d = num & 0xff;
86
+ if (isPrivateIpv4(a, b, c, d)) return true;
87
+ }
88
+ }
89
+
90
+ return false;
91
+ }
92
+
93
+ export function isBlockedExtensionUrl(url: string): boolean {
94
+ try {
95
+ const parsed = new URL(url);
96
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
97
+ return true;
98
+ }
99
+ const host = parsed.hostname.toLowerCase();
100
+ if (isPrivateHost(host)) return true;
101
+ if (
102
+ DNS_REBIND_SUFFIXES.some((suffix) => {
103
+ const bare = suffix.slice(1);
104
+ return host === bare || host.endsWith(suffix);
105
+ })
106
+ ) {
107
+ return true;
108
+ }
109
+ } catch {
110
+ return true;
111
+ }
112
+ return false;
113
+ }
114
+
115
+ function isIpLiteralHost(hostname: string): boolean {
116
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
117
+ if (host.includes(":")) return true;
118
+ const parts = host.split(".");
119
+ return parts.length === 4 && parts.every((p) => /^\d+$/.test(p));
120
+ }
121
+
122
+ /**
123
+ * Async SSRF guard for environments that can resolve DNS. The synchronous
124
+ * guard catches literals and known rebinding domains; this closes the common
125
+ * "public hostname resolves to a private address" gap before dispatch.
126
+ */
127
+ export async function isBlockedExtensionUrlWithDns(
128
+ url: string,
129
+ ): Promise<boolean> {
130
+ if (isBlockedExtensionUrl(url)) return true;
131
+
132
+ let hostname: string;
133
+ try {
134
+ hostname = new URL(url).hostname.toLowerCase();
135
+ } catch {
136
+ return true;
137
+ }
138
+ if (!hostname || isIpLiteralHost(hostname)) return false;
139
+
140
+ try {
141
+ const { lookup } = await import("node:dns/promises");
142
+ const records = await lookup(hostname, { all: true, verbatim: true });
143
+ return records.some((record) => isPrivateHost(record.address));
144
+ } catch {
145
+ // Some edge runtimes do not expose DNS lookup. Keep the deterministic
146
+ // parser-based protections instead of failing every outbound request.
147
+ return false;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Origins the deployment itself declares as its own surfaces. These come from
153
+ * operator-controlled configuration (env), NOT from user/agent input, so a
154
+ * fetch to them is a self-call, not an SSRF: cross-app A2A on a workspace
155
+ * (call-agent) targets sibling apps at the deployment's own base URL, and in
156
+ * local dev / self-hosted private networks that origin is a loopback or
157
+ * RFC-1918 address the private-address guard would otherwise block — which
158
+ * broke every workspace-internal agent call on `wrangler pages dev` and the
159
+ * dev gateway. Only EXACT origin matches (scheme + host + port) are trusted;
160
+ * everything else keeps the full SSRF policy (including redirect hops).
161
+ */
162
+ export function trustedInternalOrigins(): Set<string> {
163
+ const origins = new Set<string>();
164
+ const addUrl = (value: unknown) => {
165
+ if (typeof value !== "string" || !value.trim()) return;
166
+ try {
167
+ origins.add(new URL(value).origin);
168
+ } catch {
169
+ // Ignore malformed configured URLs they never matched anything.
170
+ }
171
+ };
172
+ const env = (globalThis as any)?.process?.env ?? {};
173
+ addUrl(env.APP_URL);
174
+ addUrl(env.BETTER_AUTH_URL);
175
+ addUrl(env.WEBHOOK_BASE_URL);
176
+ addUrl(env.WORKSPACE_GATEWAY_URL);
177
+ const manifestJson = env.AGENT_NATIVE_WORKSPACE_APPS_JSON;
178
+ if (typeof manifestJson === "string" && manifestJson.trim()) {
179
+ try {
180
+ const apps = JSON.parse(manifestJson);
181
+ if (Array.isArray(apps)) {
182
+ for (const app of apps) addUrl(app?.url);
183
+ }
184
+ } catch {
185
+ // Malformed manifest no trusted origins from it.
186
+ }
187
+ }
188
+ return origins;
189
+ }
190
+
191
+ /** True when `url`'s origin exactly matches a configured internal origin. */
192
+ export function isTrustedInternalUrl(url: string): boolean {
193
+ try {
194
+ return trustedInternalOrigins().has(new URL(url).origin);
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Build an undici Dispatcher whose connect-time DNS lookup runs through a
202
+ * private-IP guard. This closes the TOCTOU gap where:
203
+ * 1. We resolve hostname → public IP and pass.
204
+ * 2. Between that lookup and the actual connect, DNS rebinding flips the
205
+ * record to a private IP.
206
+ * 3. fetch() resolves again and connects to the private IP.
207
+ *
208
+ * With a custom dispatcher, the same lookup that produces the IP also gates
209
+ * the connect: if the IP is in the private set, the connect throws.
210
+ *
211
+ * Returns `null` if undici / node:dns are not available (e.g. some edge
212
+ * runtimes); the caller should fall back to the regular `fetch` path —
213
+ * `isBlockedExtensionUrlWithDns` will still have caught most rebinding cases.
214
+ */
215
+ export async function createSsrfSafeDispatcher(): Promise<unknown | null> {
216
+ // Keep the optional undici import opaque to Vite/Rolldown. A static
217
+ // `import("undici")` makes browser builds try to resolve and bundle undici
218
+ // even though this dispatcher is only useful in Node server runtimes.
219
+ let undici: any;
220
+ let dnsModule: any;
221
+ try {
222
+ const runtimeImport = new Function(
223
+ "specifier",
224
+ "return import(specifier)",
225
+ ) as (specifier: string) => Promise<any>;
226
+ undici = await runtimeImport("undici");
227
+ dnsModule = await import("node:dns");
228
+ } catch {
229
+ return null;
230
+ }
231
+
232
+ const { Agent } = undici;
233
+ const { lookup } = dnsModule;
234
+ if (!Agent || !lookup) return null;
235
+
236
+ return new Agent({
237
+ connect: {
238
+ // Override DNS lookup at connect time so the IP we hand to undici's
239
+ // socket is the one we authorized. Reject any record in the private
240
+ // set BEFORE the TCP handshake.
241
+ lookup: (
242
+ hostname: string,
243
+ options: any,
244
+ callback: (
245
+ err: NodeJS.ErrnoException | null,
246
+ address?: string | { address: string; family: number }[],
247
+ family?: number,
248
+ ) => void,
249
+ ) => {
250
+ lookup(
251
+ hostname,
252
+ { all: true, verbatim: true },
253
+ (err: NodeJS.ErrnoException | null, addresses: any) => {
254
+ if (err) return callback(err);
255
+ const list: { address: string; family: number }[] = Array.isArray(
256
+ addresses,
257
+ )
258
+ ? addresses
259
+ : [{ address: addresses, family: 4 }];
260
+ for (const record of list) {
261
+ if (isPrivateHost(record.address)) {
262
+ const e = new Error(
263
+ `Connect blocked: ${hostname} resolved to private address ${record.address}`,
264
+ ) as NodeJS.ErrnoException;
265
+ e.code = "EAI_BLOCKED";
266
+ return callback(e);
267
+ }
268
+ }
269
+ // Mirror Node's lookup behavior: when `all` is true, return the
270
+ // array; otherwise the first entry. undici's connect honors
271
+ // `options.all`.
272
+ if (options && options.all) {
273
+ return callback(null, list as any);
274
+ }
275
+ const first = list[0];
276
+ return callback(null, first.address, first.family);
277
+ },
278
+ );
279
+ },
280
+ },
281
+ });
282
+ }
283
+
284
+ /**
285
+ * SSRF-safe `fetch` for any server-side request to a user/agent-supplied URL.
286
+ *
287
+ * Applies the same protections the extension proxy uses, so every call site
288
+ * that fetches an untrusted URL gets them without re-implementing the loop:
289
+ * 1. Pre-flight DNS-aware private-address check (isBlockedExtensionUrlWithDns)
290
+ * on the initial URL and on every redirect hop.
291
+ * 2. A connect-time dispatcher that re-checks the resolved IP at TCP-connect
292
+ * time (closes the DNS-rebinding TOCTOU) when undici is available.
293
+ * 3. Manual redirect handling — a public URL cannot 30x-redirect into the
294
+ * private network because each hop is re-validated before it is followed.
295
+ *
296
+ * Throws an Error whose message starts with "SSRF blocked:" when a target
297
+ * (initial or via redirect) resolves to a private/internal address, or when the
298
+ * redirect limit is exceeded. Otherwise returns the final Response.
299
+ *
300
+ * `httpsOnly` extends the per-hop validation to the URL scheme: redirects are
301
+ * followed only to `https:` targets, so an HTTPS-only caller cannot be
302
+ * downgraded to plain HTTP by a 30x from the (untrusted) origin.
303
+ */
304
+ export async function ssrfSafeFetch(
305
+ url: string,
306
+ init: RequestInit = {},
307
+ options: { maxRedirects?: number; httpsOnly?: boolean } = {},
308
+ ): Promise<Response> {
309
+ const maxRedirects = options.maxRedirects ?? 3;
310
+ const dispatcher = (await createSsrfSafeDispatcher()) ?? undefined;
311
+
312
+ let currentUrl = url;
313
+ for (let hop = 0; hop <= maxRedirects; hop++) {
314
+ if (options.httpsOnly && new URL(currentUrl).protocol !== "https:") {
315
+ throw new Error(
316
+ `SSRF blocked: refusing to fetch non-HTTPS address (${currentUrl})`,
317
+ );
318
+ }
319
+ // The deployment's own configured origins are self-calls (workspace A2A,
320
+ // self-dispatch), not attacker-controlled targets — skip only the
321
+ // private-address block for exact origin matches. Each redirect hop is
322
+ // still re-validated, so a trusted origin cannot 30x into the private
323
+ // network.
324
+ const trustedHop = isTrustedInternalUrl(currentUrl);
325
+ if (!trustedHop && (await isBlockedExtensionUrlWithDns(currentUrl))) {
326
+ throw new Error(
327
+ `SSRF blocked: refusing to fetch private/internal address (${currentUrl})`,
328
+ );
329
+ }
330
+ const fetchOpts: RequestInit & { dispatcher?: unknown } = {
331
+ ...init,
332
+ redirect: "manual",
333
+ };
334
+ // The connect-time guard would also reject a trusted internal origin's
335
+ // loopback/private IP at the TCP layer — attach it only to untrusted hops.
336
+ if (dispatcher && !trustedHop) fetchOpts.dispatcher = dispatcher;
337
+
338
+ const response = await fetch(currentUrl, fetchOpts);
339
+ if (response.status >= 300 && response.status < 400) {
340
+ const location = response.headers.get("location");
341
+ if (!location) return response;
342
+ // Drain the redirect body so the hop's connection is released instead
343
+ // of being held until GC.
344
+ await response.body?.cancel().catch(() => {});
345
+ currentUrl = new URL(location, currentUrl).href;
346
+ continue;
347
+ }
348
+ return response;
349
+ }
350
+ throw new Error(
351
+ `SSRF blocked: too many redirects (>${maxRedirects}) while fetching ${url}`,
352
+ );
353
+ }
354
+
355
+ // ─────────────────────────────────────────────────────────────────────────────
356
+ // Legacy aliases — predate the Tools → Extensions rename. Templates import
357
+ // these via the legacy `@agent-native/core/tools/url-safety` subpath; keep
358
+ // the names exported so they keep resolving until every consumer updates.
359
+ // ─────────────────────────────────────────────────────────────────────────────
360
+
361
+ export { isBlockedExtensionUrl as isBlockedToolUrl };
362
+ export { isBlockedExtensionUrlWithDns as isBlockedToolUrlWithDns };
363
+ export { ssrfSafeFetch as ssrfSafeToolFetch };
@@ -32,12 +32,11 @@ function parseMentions(value: string | null): Mention[] {
32
32
  export default defineAction({
33
33
  description: "List all comments on a document, grouped by thread.",
34
34
  schema: z.object({
35
- documentId: z.string().optional().describe("Document ID (required)"),
35
+ documentId: z.string().min(1).describe("Document ID (required)"),
36
36
  }),
37
37
  http: { method: "GET" },
38
38
  run: async (args) => {
39
39
  const documentId = args.documentId;
40
- if (!documentId) throw new Error("--documentId is required");
41
40
 
42
41
  const access = await assertAccess("document", documentId, "viewer");
43
42
  const ownerEmail = access.resource.ownerEmail as string;
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
62
62
  error: string;
63
63
  states?: undefined;
64
64
  } | {
65
- error?: undefined;
66
65
  states: {
67
66
  clientId: number;
68
67
  state: string;
69
68
  }[];
69
+ error?: undefined;
70
70
  }>>;
71
71
  /**
72
72
  * GET /_agent-native/collab/:docId/users
@@ -77,10 +77,10 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
77
77
  error: string;
78
78
  users?: undefined;
79
79
  } | {
80
- error?: undefined;
81
80
  users: {
82
81
  clientId: number;
83
82
  lastSeen: number;
84
83
  }[];
84
+ error?: undefined;
85
85
  }>>;
86
86
  //# sourceMappingURL=awareness.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"awareness.d.ts","sourceRoot":"","sources":["../../src/collab/awareness.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAgB3C,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB;AAOD,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAElE,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,WAAW,CAAC;IACpB,IAAI,EAAE,kBAAkB,CAAC;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,MAAM,EAAE,KAAK,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACnD,gFAAgF;IAChF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAOD,wBAAgB,mBAAmB,IAAI,YAAY,CAElD;AAED,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,cAAc,GAAG,SAAS,GAChC,IAAI,CAON;AAED,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,KAAK,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,EAClD,KAAK,CAAC,EAAE,cAAc,GACrB,IAAI,CAgBN;AAoBD,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,SAAS,GAAE,MAAmB,GAC7B,IAAI,CAON;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAE1E;AAiBD,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAO1E;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,GAAG,IAAI,CAOnE;AAkCD;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa;;;;;;kBAwDa,MAAM;eAAS,MAAM;;GAoB1D,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,cAAc;;;;;;kBAYM,MAAM;kBAAY,MAAM;;GAMvD,CAAC"}
1
+ {"version":3,"file":"awareness.d.ts","sourceRoot":"","sources":["../../src/collab/awareness.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAgB3C,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB;AAOD,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAElE,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,WAAW,CAAC;IACpB,IAAI,EAAE,kBAAkB,CAAC;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,MAAM,EAAE,KAAK,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACnD,gFAAgF;IAChF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAOD,wBAAgB,mBAAmB,IAAI,YAAY,CAElD;AAED,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,cAAc,GAAG,SAAS,GAChC,IAAI,CAON;AAED,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,KAAK,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,EAClD,KAAK,CAAC,EAAE,cAAc,GACrB,IAAI,CAgBN;AAoBD,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,SAAS,GAAE,MAAmB,GAC7B,IAAI,CAON;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAE1E;AAiBD,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAO1E;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,GAAG,IAAI,CAOnE;AAkCD;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa;;;;;kBAwDa,MAAM;eAAS,MAAM;;;GAoB1D,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,cAAc;;;;;kBAYM,MAAM;kBAAY,MAAM;;;GAMvD,CAAC"}
@@ -5,6 +5,20 @@ export declare function isBlockedExtensionUrl(url: string): boolean;
5
5
  * "public hostname resolves to a private address" gap before dispatch.
6
6
  */
7
7
  export declare function isBlockedExtensionUrlWithDns(url: string): Promise<boolean>;
8
+ /**
9
+ * Origins the deployment itself declares as its own surfaces. These come from
10
+ * operator-controlled configuration (env), NOT from user/agent input, so a
11
+ * fetch to them is a self-call, not an SSRF: cross-app A2A on a workspace
12
+ * (call-agent) targets sibling apps at the deployment's own base URL, and in
13
+ * local dev / self-hosted private networks that origin is a loopback or
14
+ * RFC-1918 address the private-address guard would otherwise block — which
15
+ * broke every workspace-internal agent call on `wrangler pages dev` and the
16
+ * dev gateway. Only EXACT origin matches (scheme + host + port) are trusted;
17
+ * everything else keeps the full SSRF policy (including redirect hops).
18
+ */
19
+ export declare function trustedInternalOrigins(): Set<string>;
20
+ /** True when `url`'s origin exactly matches a configured internal origin. */
21
+ export declare function isTrustedInternalUrl(url: string): boolean;
8
22
  /**
9
23
  * Build an undici Dispatcher whose connect-time DNS lookup runs through a
10
24
  * private-IP guard. This closes the TOCTOU gap where:
@@ -1 +1 @@
1
- {"version":3,"file":"url-safety.d.ts","sourceRoot":"","sources":["../../src/extensions/url-safety.ts"],"names":[],"mappings":"AA4FA,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAoB1D;AASD;;;;GAIG;AACH,wBAAsB,4BAA4B,CAChD,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,OAAO,CAAC,CAoBlB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,wBAAwB,IAAI,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAmExE;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,MAAM,EACX,IAAI,GAAE,WAAgB,EACtB,OAAO,GAAE;IAAE,YAAY,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAO,GAC3D,OAAO,CAAC,QAAQ,CAAC,CAqCnB;AAQD,OAAO,EAAE,qBAAqB,IAAI,gBAAgB,EAAE,CAAC;AACrD,OAAO,EAAE,4BAA4B,IAAI,uBAAuB,EAAE,CAAC;AACnE,OAAO,EAAE,aAAa,IAAI,iBAAiB,EAAE,CAAC"}
1
+ {"version":3,"file":"url-safety.d.ts","sourceRoot":"","sources":["../../src/extensions/url-safety.ts"],"names":[],"mappings":"AA4FA,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAoB1D;AASD;;;;GAIG;AACH,wBAAsB,4BAA4B,CAChD,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,OAAO,CAAC,CAoBlB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,IAAI,GAAG,CAAC,MAAM,CAAC,CA2BpD;AAED,6EAA6E;AAC7E,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAMzD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,wBAAwB,IAAI,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAmExE;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,MAAM,EACX,IAAI,GAAE,WAAgB,EACtB,OAAO,GAAE;IAAE,YAAY,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAO,GAC3D,OAAO,CAAC,QAAQ,CAAC,CA6CnB;AAQD,OAAO,EAAE,qBAAqB,IAAI,gBAAgB,EAAE,CAAC;AACrD,OAAO,EAAE,4BAA4B,IAAI,uBAAuB,EAAE,CAAC;AACnE,OAAO,EAAE,aAAa,IAAI,iBAAiB,EAAE,CAAC"}
@@ -157,6 +157,58 @@ export async function isBlockedExtensionUrlWithDns(url) {
157
157
  return false;
158
158
  }
159
159
  }
160
+ /**
161
+ * Origins the deployment itself declares as its own surfaces. These come from
162
+ * operator-controlled configuration (env), NOT from user/agent input, so a
163
+ * fetch to them is a self-call, not an SSRF: cross-app A2A on a workspace
164
+ * (call-agent) targets sibling apps at the deployment's own base URL, and in
165
+ * local dev / self-hosted private networks that origin is a loopback or
166
+ * RFC-1918 address the private-address guard would otherwise block — which
167
+ * broke every workspace-internal agent call on `wrangler pages dev` and the
168
+ * dev gateway. Only EXACT origin matches (scheme + host + port) are trusted;
169
+ * everything else keeps the full SSRF policy (including redirect hops).
170
+ */
171
+ export function trustedInternalOrigins() {
172
+ const origins = new Set();
173
+ const addUrl = (value) => {
174
+ if (typeof value !== "string" || !value.trim())
175
+ return;
176
+ try {
177
+ origins.add(new URL(value).origin);
178
+ }
179
+ catch {
180
+ // Ignore malformed configured URLs — they never matched anything.
181
+ }
182
+ };
183
+ const env = globalThis?.process?.env ?? {};
184
+ addUrl(env.APP_URL);
185
+ addUrl(env.BETTER_AUTH_URL);
186
+ addUrl(env.WEBHOOK_BASE_URL);
187
+ addUrl(env.WORKSPACE_GATEWAY_URL);
188
+ const manifestJson = env.AGENT_NATIVE_WORKSPACE_APPS_JSON;
189
+ if (typeof manifestJson === "string" && manifestJson.trim()) {
190
+ try {
191
+ const apps = JSON.parse(manifestJson);
192
+ if (Array.isArray(apps)) {
193
+ for (const app of apps)
194
+ addUrl(app?.url);
195
+ }
196
+ }
197
+ catch {
198
+ // Malformed manifest — no trusted origins from it.
199
+ }
200
+ }
201
+ return origins;
202
+ }
203
+ /** True when `url`'s origin exactly matches a configured internal origin. */
204
+ export function isTrustedInternalUrl(url) {
205
+ try {
206
+ return trustedInternalOrigins().has(new URL(url).origin);
207
+ }
208
+ catch {
209
+ return false;
210
+ }
211
+ }
160
212
  /**
161
213
  * Build an undici Dispatcher whose connect-time DNS lookup runs through a
162
214
  * private-IP guard. This closes the TOCTOU gap where:
@@ -250,14 +302,22 @@ export async function ssrfSafeFetch(url, init = {}, options = {}) {
250
302
  if (options.httpsOnly && new URL(currentUrl).protocol !== "https:") {
251
303
  throw new Error(`SSRF blocked: refusing to fetch non-HTTPS address (${currentUrl})`);
252
304
  }
253
- if (await isBlockedExtensionUrlWithDns(currentUrl)) {
305
+ // The deployment's own configured origins are self-calls (workspace A2A,
306
+ // self-dispatch), not attacker-controlled targets — skip only the
307
+ // private-address block for exact origin matches. Each redirect hop is
308
+ // still re-validated, so a trusted origin cannot 30x into the private
309
+ // network.
310
+ const trustedHop = isTrustedInternalUrl(currentUrl);
311
+ if (!trustedHop && (await isBlockedExtensionUrlWithDns(currentUrl))) {
254
312
  throw new Error(`SSRF blocked: refusing to fetch private/internal address (${currentUrl})`);
255
313
  }
256
314
  const fetchOpts = {
257
315
  ...init,
258
316
  redirect: "manual",
259
317
  };
260
- if (dispatcher)
318
+ // The connect-time guard would also reject a trusted internal origin's
319
+ // loopback/private IP at the TCP layer — attach it only to untrusted hops.
320
+ if (dispatcher && !trustedHop)
261
321
  fetchOpts.dispatcher = dispatcher;
262
322
  const response = await fetch(currentUrl, fetchOpts);
263
323
  if (response.status >= 300 && response.status < 400) {
@@ -1 +1 @@
1
- {"version":3,"file":"url-safety.js","sourceRoot":"","sources":["../../src/extensions/url-safety.ts"],"names":[],"mappings":"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","sourcesContent":["const METADATA_HOSTS = [\r\n \"metadata.google.internal\",\r\n \"metadata.google.internal.\",\r\n];\r\n\r\nconst DNS_REBIND_SUFFIXES = [\r\n \".nip.io\",\r\n \".sslip.io\",\r\n \".xip.io\",\r\n \".localtest.me\",\r\n \".lvh.me\",\r\n];\r\n\r\nfunction isPrivateIpv4(a: number, b: number, c = 0, d = 0): boolean {\r\n if (![a, b, c, d].every((part) => part >= 0 && part <= 255)) return true;\r\n if (a === 127) return true;\r\n if (a === 10) return true;\r\n if (a === 172 && b >= 16 && b <= 31) return true;\r\n if (a === 192 && b === 168) return true;\r\n if (a === 169 && b === 254) return true;\r\n if (a === 0) return true;\r\n if (a === 100 && b >= 64 && b <= 127) return true;\r\n if (a === 192 && b === 0) return true;\r\n if (a === 198 && (b === 18 || b === 19)) return true;\r\n if (a === 192 && b === 0 && c === 2) return true;\r\n if (a === 198 && b === 51 && c === 100) return true;\r\n if (a === 203 && b === 0 && c === 113) return true;\r\n if (a >= 224) return true;\r\n return false;\r\n}\r\n\r\nfunction isPrivateIpv4MappedHex(host: string): boolean {\r\n const mapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);\r\n if (!mapped) return false;\r\n const high = Number.parseInt(mapped[1], 16);\r\n const low = Number.parseInt(mapped[2], 16);\r\n if (high < 0 || high > 0xffff || low < 0 || low > 0xffff) return false;\r\n const a = (high >> 8) & 0xff;\r\n const b = high & 0xff;\r\n const c = (low >> 8) & 0xff;\r\n const d = low & 0xff;\r\n return isPrivateIpv4(a, b, c, d);\r\n}\r\n\r\nfunction isPrivateHost(hostname: string): boolean {\r\n const host = hostname.toLowerCase().replace(/^\\[|\\]$/g, \"\");\r\n if (\r\n host === \"localhost\" ||\r\n host === \"::1\" ||\r\n host === \"::0\" ||\r\n host === \"::\"\r\n ) {\r\n return true;\r\n }\r\n if (METADATA_HOSTS.includes(host)) return true;\r\n\r\n // IPv6 ULA/link-local/multicast.\r\n if (/^f[cd]/.test(host) || /^fe[89ab]/.test(host)) return true;\r\n if (/^ff/i.test(host)) return true;\r\n\r\n // IPv4-mapped IPv6. URL parsing may preserve dotted form in some runtimes\r\n // or normalize it to hex, e.g. [::ffff:127.0.0.1] -> ::ffff:7f00:1.\r\n const v4mappedDotted = host.match(/^::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/);\r\n if (v4mappedDotted) {\r\n const [a, b, c, d] = v4mappedDotted[1].split(\".\").map(Number);\r\n if (isPrivateIpv4(a, b, c, d)) return true;\r\n }\r\n if (isPrivateIpv4MappedHex(host)) return true;\r\n\r\n // Dotted IPv4. URL parsing normalizes shorthand/octal/hex IPv4 forms to\r\n // dotted decimal before we reach this point.\r\n const parts = host.split(\".\");\r\n if (parts.length === 4 && parts.every((p) => /^\\d+$/.test(p))) {\r\n const [a, b, c, d] = parts.map(Number);\r\n if (isPrivateIpv4(a, b, c, d)) return true;\r\n }\r\n\r\n // Decimal integer IPv4.\r\n if (/^\\d+$/.test(host)) {\r\n const num = Number(host);\r\n if (num >= 0 && num <= 0xffffffff) {\r\n const a = (num >>> 24) & 0xff;\r\n const b = (num >>> 16) & 0xff;\r\n const c = (num >>> 8) & 0xff;\r\n const d = num & 0xff;\r\n if (isPrivateIpv4(a, b, c, d)) return true;\r\n }\r\n }\r\n\r\n return false;\r\n}\r\n\r\nexport function isBlockedExtensionUrl(url: string): boolean {\r\n try {\r\n const parsed = new URL(url);\r\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") {\r\n return true;\r\n }\r\n const host = parsed.hostname.toLowerCase();\r\n if (isPrivateHost(host)) return true;\r\n if (\r\n DNS_REBIND_SUFFIXES.some((suffix) => {\r\n const bare = suffix.slice(1);\r\n return host === bare || host.endsWith(suffix);\r\n })\r\n ) {\r\n return true;\r\n }\r\n } catch {\r\n return true;\r\n }\r\n return false;\r\n}\r\n\r\nfunction isIpLiteralHost(hostname: string): boolean {\r\n const host = hostname.toLowerCase().replace(/^\\[|\\]$/g, \"\");\r\n if (host.includes(\":\")) return true;\r\n const parts = host.split(\".\");\r\n return parts.length === 4 && parts.every((p) => /^\\d+$/.test(p));\r\n}\r\n\r\n/**\r\n * Async SSRF guard for environments that can resolve DNS. The synchronous\r\n * guard catches literals and known rebinding domains; this closes the common\r\n * \"public hostname resolves to a private address\" gap before dispatch.\r\n */\r\nexport async function isBlockedExtensionUrlWithDns(\r\n url: string,\r\n): Promise<boolean> {\r\n if (isBlockedExtensionUrl(url)) return true;\r\n\r\n let hostname: string;\r\n try {\r\n hostname = new URL(url).hostname.toLowerCase();\r\n } catch {\r\n return true;\r\n }\r\n if (!hostname || isIpLiteralHost(hostname)) return false;\r\n\r\n try {\r\n const { lookup } = await import(\"node:dns/promises\");\r\n const records = await lookup(hostname, { all: true, verbatim: true });\r\n return records.some((record) => isPrivateHost(record.address));\r\n } catch {\r\n // Some edge runtimes do not expose DNS lookup. Keep the deterministic\r\n // parser-based protections instead of failing every outbound request.\r\n return false;\r\n }\r\n}\r\n\r\n/**\r\n * Build an undici Dispatcher whose connect-time DNS lookup runs through a\r\n * private-IP guard. This closes the TOCTOU gap where:\r\n * 1. We resolve hostname → public IP and pass.\r\n * 2. Between that lookup and the actual connect, DNS rebinding flips the\r\n * record to a private IP.\r\n * 3. fetch() resolves again and connects to the private IP.\r\n *\r\n * With a custom dispatcher, the same lookup that produces the IP also gates\r\n * the connect: if the IP is in the private set, the connect throws.\r\n *\r\n * Returns `null` if undici / node:dns are not available (e.g. some edge\r\n * runtimes); the caller should fall back to the regular `fetch` path —\r\n * `isBlockedExtensionUrlWithDns` will still have caught most rebinding cases.\r\n */\r\nexport async function createSsrfSafeDispatcher(): Promise<unknown | null> {\r\n // Keep the optional undici import opaque to Vite/Rolldown. A static\r\n // `import(\"undici\")` makes browser builds try to resolve and bundle undici\r\n // even though this dispatcher is only useful in Node server runtimes.\r\n let undici: any;\r\n let dnsModule: any;\r\n try {\r\n const runtimeImport = new Function(\r\n \"specifier\",\r\n \"return import(specifier)\",\r\n ) as (specifier: string) => Promise<any>;\r\n undici = await runtimeImport(\"undici\");\r\n dnsModule = await import(\"node:dns\");\r\n } catch {\r\n return null;\r\n }\r\n\r\n const { Agent } = undici;\r\n const { lookup } = dnsModule;\r\n if (!Agent || !lookup) return null;\r\n\r\n return new Agent({\r\n connect: {\r\n // Override DNS lookup at connect time so the IP we hand to undici's\r\n // socket is the one we authorized. Reject any record in the private\r\n // set BEFORE the TCP handshake.\r\n lookup: (\r\n hostname: string,\r\n options: any,\r\n callback: (\r\n err: NodeJS.ErrnoException | null,\r\n address?: string | { address: string; family: number }[],\r\n family?: number,\r\n ) => void,\r\n ) => {\r\n lookup(\r\n hostname,\r\n { all: true, verbatim: true },\r\n (err: NodeJS.ErrnoException | null, addresses: any) => {\r\n if (err) return callback(err);\r\n const list: { address: string; family: number }[] = Array.isArray(\r\n addresses,\r\n )\r\n ? addresses\r\n : [{ address: addresses, family: 4 }];\r\n for (const record of list) {\r\n if (isPrivateHost(record.address)) {\r\n const e = new Error(\r\n `Connect blocked: ${hostname} resolved to private address ${record.address}`,\r\n ) as NodeJS.ErrnoException;\r\n e.code = \"EAI_BLOCKED\";\r\n return callback(e);\r\n }\r\n }\r\n // Mirror Node's lookup behavior: when `all` is true, return the\r\n // array; otherwise the first entry. undici's connect honors\r\n // `options.all`.\r\n if (options && options.all) {\r\n return callback(null, list as any);\r\n }\r\n const first = list[0];\r\n return callback(null, first.address, first.family);\r\n },\r\n );\r\n },\r\n },\r\n });\r\n}\r\n\r\n/**\r\n * SSRF-safe `fetch` for any server-side request to a user/agent-supplied URL.\r\n *\r\n * Applies the same protections the extension proxy uses, so every call site\r\n * that fetches an untrusted URL gets them without re-implementing the loop:\r\n * 1. Pre-flight DNS-aware private-address check (isBlockedExtensionUrlWithDns)\r\n * on the initial URL and on every redirect hop.\r\n * 2. A connect-time dispatcher that re-checks the resolved IP at TCP-connect\r\n * time (closes the DNS-rebinding TOCTOU) when undici is available.\r\n * 3. Manual redirect handling — a public URL cannot 30x-redirect into the\r\n * private network because each hop is re-validated before it is followed.\r\n *\r\n * Throws an Error whose message starts with \"SSRF blocked:\" when a target\r\n * (initial or via redirect) resolves to a private/internal address, or when the\r\n * redirect limit is exceeded. Otherwise returns the final Response.\r\n *\r\n * `httpsOnly` extends the per-hop validation to the URL scheme: redirects are\r\n * followed only to `https:` targets, so an HTTPS-only caller cannot be\r\n * downgraded to plain HTTP by a 30x from the (untrusted) origin.\r\n */\r\nexport async function ssrfSafeFetch(\r\n url: string,\r\n init: RequestInit = {},\r\n options: { maxRedirects?: number; httpsOnly?: boolean } = {},\r\n): Promise<Response> {\r\n const maxRedirects = options.maxRedirects ?? 3;\r\n const dispatcher = (await createSsrfSafeDispatcher()) ?? undefined;\r\n\r\n let currentUrl = url;\r\n for (let hop = 0; hop <= maxRedirects; hop++) {\r\n if (options.httpsOnly && new URL(currentUrl).protocol !== \"https:\") {\r\n throw new Error(\r\n `SSRF blocked: refusing to fetch non-HTTPS address (${currentUrl})`,\r\n );\r\n }\r\n if (await isBlockedExtensionUrlWithDns(currentUrl)) {\r\n throw new Error(\r\n `SSRF blocked: refusing to fetch private/internal address (${currentUrl})`,\r\n );\r\n }\r\n const fetchOpts: RequestInit & { dispatcher?: unknown } = {\r\n ...init,\r\n redirect: \"manual\",\r\n };\r\n if (dispatcher) fetchOpts.dispatcher = dispatcher;\r\n\r\n const response = await fetch(currentUrl, fetchOpts);\r\n if (response.status >= 300 && response.status < 400) {\r\n const location = response.headers.get(\"location\");\r\n if (!location) return response;\r\n // Drain the redirect body so the hop's connection is released instead\r\n // of being held until GC.\r\n await response.body?.cancel().catch(() => {});\r\n currentUrl = new URL(location, currentUrl).href;\r\n continue;\r\n }\r\n return response;\r\n }\r\n throw new Error(\r\n `SSRF blocked: too many redirects (>${maxRedirects}) while fetching ${url}`,\r\n );\r\n}\r\n\r\n// ─────────────────────────────────────────────────────────────────────────────\r\n// Legacy aliases — predate the Tools → Extensions rename. Templates import\r\n// these via the legacy `@agent-native/core/tools/url-safety` subpath; keep\r\n// the names exported so they keep resolving until every consumer updates.\r\n// ─────────────────────────────────────────────────────────────────────────────\r\n\r\nexport { isBlockedExtensionUrl as isBlockedToolUrl };\r\nexport { isBlockedExtensionUrlWithDns as isBlockedToolUrlWithDns };\r\nexport { ssrfSafeFetch as ssrfSafeToolFetch };\r\n"]}
1
+ {"version":3,"file":"url-safety.js","sourceRoot":"","sources":["../../src/extensions/url-safety.ts"],"names":[],"mappings":"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","sourcesContent":["const METADATA_HOSTS = [\n \"metadata.google.internal\",\n \"metadata.google.internal.\",\n];\n\nconst DNS_REBIND_SUFFIXES = [\n \".nip.io\",\n \".sslip.io\",\n \".xip.io\",\n \".localtest.me\",\n \".lvh.me\",\n];\n\nfunction isPrivateIpv4(a: number, b: number, c = 0, d = 0): boolean {\n if (![a, b, c, d].every((part) => part >= 0 && part <= 255)) return true;\n if (a === 127) return true;\n if (a === 10) return true;\n if (a === 172 && b >= 16 && b <= 31) return true;\n if (a === 192 && b === 168) return true;\n if (a === 169 && b === 254) return true;\n if (a === 0) return true;\n if (a === 100 && b >= 64 && b <= 127) return true;\n if (a === 192 && b === 0) return true;\n if (a === 198 && (b === 18 || b === 19)) return true;\n if (a === 192 && b === 0 && c === 2) return true;\n if (a === 198 && b === 51 && c === 100) return true;\n if (a === 203 && b === 0 && c === 113) return true;\n if (a >= 224) return true;\n return false;\n}\n\nfunction isPrivateIpv4MappedHex(host: string): boolean {\n const mapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);\n if (!mapped) return false;\n const high = Number.parseInt(mapped[1], 16);\n const low = Number.parseInt(mapped[2], 16);\n if (high < 0 || high > 0xffff || low < 0 || low > 0xffff) return false;\n const a = (high >> 8) & 0xff;\n const b = high & 0xff;\n const c = (low >> 8) & 0xff;\n const d = low & 0xff;\n return isPrivateIpv4(a, b, c, d);\n}\n\nfunction isPrivateHost(hostname: string): boolean {\n const host = hostname.toLowerCase().replace(/^\\[|\\]$/g, \"\");\n if (\n host === \"localhost\" ||\n host === \"::1\" ||\n host === \"::0\" ||\n host === \"::\"\n ) {\n return true;\n }\n if (METADATA_HOSTS.includes(host)) return true;\n\n // IPv6 ULA/link-local/multicast.\n if (/^f[cd]/.test(host) || /^fe[89ab]/.test(host)) return true;\n if (/^ff/i.test(host)) return true;\n\n // IPv4-mapped IPv6. URL parsing may preserve dotted form in some runtimes\n // or normalize it to hex, e.g. [::ffff:127.0.0.1] -> ::ffff:7f00:1.\n const v4mappedDotted = host.match(/^::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/);\n if (v4mappedDotted) {\n const [a, b, c, d] = v4mappedDotted[1].split(\".\").map(Number);\n if (isPrivateIpv4(a, b, c, d)) return true;\n }\n if (isPrivateIpv4MappedHex(host)) return true;\n\n // Dotted IPv4. URL parsing normalizes shorthand/octal/hex IPv4 forms to\n // dotted decimal before we reach this point.\n const parts = host.split(\".\");\n if (parts.length === 4 && parts.every((p) => /^\\d+$/.test(p))) {\n const [a, b, c, d] = parts.map(Number);\n if (isPrivateIpv4(a, b, c, d)) return true;\n }\n\n // Decimal integer IPv4.\n if (/^\\d+$/.test(host)) {\n const num = Number(host);\n if (num >= 0 && num <= 0xffffffff) {\n const a = (num >>> 24) & 0xff;\n const b = (num >>> 16) & 0xff;\n const c = (num >>> 8) & 0xff;\n const d = num & 0xff;\n if (isPrivateIpv4(a, b, c, d)) return true;\n }\n }\n\n return false;\n}\n\nexport function isBlockedExtensionUrl(url: string): boolean {\n try {\n const parsed = new URL(url);\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") {\n return true;\n }\n const host = parsed.hostname.toLowerCase();\n if (isPrivateHost(host)) return true;\n if (\n DNS_REBIND_SUFFIXES.some((suffix) => {\n const bare = suffix.slice(1);\n return host === bare || host.endsWith(suffix);\n })\n ) {\n return true;\n }\n } catch {\n return true;\n }\n return false;\n}\n\nfunction isIpLiteralHost(hostname: string): boolean {\n const host = hostname.toLowerCase().replace(/^\\[|\\]$/g, \"\");\n if (host.includes(\":\")) return true;\n const parts = host.split(\".\");\n return parts.length === 4 && parts.every((p) => /^\\d+$/.test(p));\n}\n\n/**\n * Async SSRF guard for environments that can resolve DNS. The synchronous\n * guard catches literals and known rebinding domains; this closes the common\n * \"public hostname resolves to a private address\" gap before dispatch.\n */\nexport async function isBlockedExtensionUrlWithDns(\n url: string,\n): Promise<boolean> {\n if (isBlockedExtensionUrl(url)) return true;\n\n let hostname: string;\n try {\n hostname = new URL(url).hostname.toLowerCase();\n } catch {\n return true;\n }\n if (!hostname || isIpLiteralHost(hostname)) return false;\n\n try {\n const { lookup } = await import(\"node:dns/promises\");\n const records = await lookup(hostname, { all: true, verbatim: true });\n return records.some((record) => isPrivateHost(record.address));\n } catch {\n // Some edge runtimes do not expose DNS lookup. Keep the deterministic\n // parser-based protections instead of failing every outbound request.\n return false;\n }\n}\n\n/**\n * Origins the deployment itself declares as its own surfaces. These come from\n * operator-controlled configuration (env), NOT from user/agent input, so a\n * fetch to them is a self-call, not an SSRF: cross-app A2A on a workspace\n * (call-agent) targets sibling apps at the deployment's own base URL, and in\n * local dev / self-hosted private networks that origin is a loopback or\n * RFC-1918 address the private-address guard would otherwise block — which\n * broke every workspace-internal agent call on `wrangler pages dev` and the\n * dev gateway. Only EXACT origin matches (scheme + host + port) are trusted;\n * everything else keeps the full SSRF policy (including redirect hops).\n */\nexport function trustedInternalOrigins(): Set<string> {\n const origins = new Set<string>();\n const addUrl = (value: unknown) => {\n if (typeof value !== \"string\" || !value.trim()) return;\n try {\n origins.add(new URL(value).origin);\n } catch {\n // Ignore malformed configured URLs — they never matched anything.\n }\n };\n const env = (globalThis as any)?.process?.env ?? {};\n addUrl(env.APP_URL);\n addUrl(env.BETTER_AUTH_URL);\n addUrl(env.WEBHOOK_BASE_URL);\n addUrl(env.WORKSPACE_GATEWAY_URL);\n const manifestJson = env.AGENT_NATIVE_WORKSPACE_APPS_JSON;\n if (typeof manifestJson === \"string\" && manifestJson.trim()) {\n try {\n const apps = JSON.parse(manifestJson);\n if (Array.isArray(apps)) {\n for (const app of apps) addUrl(app?.url);\n }\n } catch {\n // Malformed manifest — no trusted origins from it.\n }\n }\n return origins;\n}\n\n/** True when `url`'s origin exactly matches a configured internal origin. */\nexport function isTrustedInternalUrl(url: string): boolean {\n try {\n return trustedInternalOrigins().has(new URL(url).origin);\n } catch {\n return false;\n }\n}\n\n/**\n * Build an undici Dispatcher whose connect-time DNS lookup runs through a\n * private-IP guard. This closes the TOCTOU gap where:\n * 1. We resolve hostname → public IP and pass.\n * 2. Between that lookup and the actual connect, DNS rebinding flips the\n * record to a private IP.\n * 3. fetch() resolves again and connects to the private IP.\n *\n * With a custom dispatcher, the same lookup that produces the IP also gates\n * the connect: if the IP is in the private set, the connect throws.\n *\n * Returns `null` if undici / node:dns are not available (e.g. some edge\n * runtimes); the caller should fall back to the regular `fetch` path —\n * `isBlockedExtensionUrlWithDns` will still have caught most rebinding cases.\n */\nexport async function createSsrfSafeDispatcher(): Promise<unknown | null> {\n // Keep the optional undici import opaque to Vite/Rolldown. A static\n // `import(\"undici\")` makes browser builds try to resolve and bundle undici\n // even though this dispatcher is only useful in Node server runtimes.\n let undici: any;\n let dnsModule: any;\n try {\n const runtimeImport = new Function(\n \"specifier\",\n \"return import(specifier)\",\n ) as (specifier: string) => Promise<any>;\n undici = await runtimeImport(\"undici\");\n dnsModule = await import(\"node:dns\");\n } catch {\n return null;\n }\n\n const { Agent } = undici;\n const { lookup } = dnsModule;\n if (!Agent || !lookup) return null;\n\n return new Agent({\n connect: {\n // Override DNS lookup at connect time so the IP we hand to undici's\n // socket is the one we authorized. Reject any record in the private\n // set BEFORE the TCP handshake.\n lookup: (\n hostname: string,\n options: any,\n callback: (\n err: NodeJS.ErrnoException | null,\n address?: string | { address: string; family: number }[],\n family?: number,\n ) => void,\n ) => {\n lookup(\n hostname,\n { all: true, verbatim: true },\n (err: NodeJS.ErrnoException | null, addresses: any) => {\n if (err) return callback(err);\n const list: { address: string; family: number }[] = Array.isArray(\n addresses,\n )\n ? addresses\n : [{ address: addresses, family: 4 }];\n for (const record of list) {\n if (isPrivateHost(record.address)) {\n const e = new Error(\n `Connect blocked: ${hostname} resolved to private address ${record.address}`,\n ) as NodeJS.ErrnoException;\n e.code = \"EAI_BLOCKED\";\n return callback(e);\n }\n }\n // Mirror Node's lookup behavior: when `all` is true, return the\n // array; otherwise the first entry. undici's connect honors\n // `options.all`.\n if (options && options.all) {\n return callback(null, list as any);\n }\n const first = list[0];\n return callback(null, first.address, first.family);\n },\n );\n },\n },\n });\n}\n\n/**\n * SSRF-safe `fetch` for any server-side request to a user/agent-supplied URL.\n *\n * Applies the same protections the extension proxy uses, so every call site\n * that fetches an untrusted URL gets them without re-implementing the loop:\n * 1. Pre-flight DNS-aware private-address check (isBlockedExtensionUrlWithDns)\n * on the initial URL and on every redirect hop.\n * 2. A connect-time dispatcher that re-checks the resolved IP at TCP-connect\n * time (closes the DNS-rebinding TOCTOU) when undici is available.\n * 3. Manual redirect handling — a public URL cannot 30x-redirect into the\n * private network because each hop is re-validated before it is followed.\n *\n * Throws an Error whose message starts with \"SSRF blocked:\" when a target\n * (initial or via redirect) resolves to a private/internal address, or when the\n * redirect limit is exceeded. Otherwise returns the final Response.\n *\n * `httpsOnly` extends the per-hop validation to the URL scheme: redirects are\n * followed only to `https:` targets, so an HTTPS-only caller cannot be\n * downgraded to plain HTTP by a 30x from the (untrusted) origin.\n */\nexport async function ssrfSafeFetch(\n url: string,\n init: RequestInit = {},\n options: { maxRedirects?: number; httpsOnly?: boolean } = {},\n): Promise<Response> {\n const maxRedirects = options.maxRedirects ?? 3;\n const dispatcher = (await createSsrfSafeDispatcher()) ?? undefined;\n\n let currentUrl = url;\n for (let hop = 0; hop <= maxRedirects; hop++) {\n if (options.httpsOnly && new URL(currentUrl).protocol !== \"https:\") {\n throw new Error(\n `SSRF blocked: refusing to fetch non-HTTPS address (${currentUrl})`,\n );\n }\n // The deployment's own configured origins are self-calls (workspace A2A,\n // self-dispatch), not attacker-controlled targets — skip only the\n // private-address block for exact origin matches. Each redirect hop is\n // still re-validated, so a trusted origin cannot 30x into the private\n // network.\n const trustedHop = isTrustedInternalUrl(currentUrl);\n if (!trustedHop && (await isBlockedExtensionUrlWithDns(currentUrl))) {\n throw new Error(\n `SSRF blocked: refusing to fetch private/internal address (${currentUrl})`,\n );\n }\n const fetchOpts: RequestInit & { dispatcher?: unknown } = {\n ...init,\n redirect: \"manual\",\n };\n // The connect-time guard would also reject a trusted internal origin's\n // loopback/private IP at the TCP layer — attach it only to untrusted hops.\n if (dispatcher && !trustedHop) fetchOpts.dispatcher = dispatcher;\n\n const response = await fetch(currentUrl, fetchOpts);\n if (response.status >= 300 && response.status < 400) {\n const location = response.headers.get(\"location\");\n if (!location) return response;\n // Drain the redirect body so the hop's connection is released instead\n // of being held until GC.\n await response.body?.cancel().catch(() => {});\n currentUrl = new URL(location, currentUrl).href;\n continue;\n }\n return response;\n }\n throw new Error(\n `SSRF blocked: too many redirects (>${maxRedirects}) while fetching ${url}`,\n );\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Legacy aliases — predate the Tools → Extensions rename. Templates import\n// these via the legacy `@agent-native/core/tools/url-safety` subpath; keep\n// the names exported so they keep resolving until every consumer updates.\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport { isBlockedExtensionUrl as isBlockedToolUrl };\nexport { isBlockedExtensionUrlWithDns as isBlockedToolUrlWithDns };\nexport { ssrfSafeFetch as ssrfSafeToolFetch };\n"]}
@@ -17,12 +17,12 @@ declare const _default: import("../../action.js").ActionDefinition<{
17
17
  id?: undefined;
18
18
  provider?: undefined;
19
19
  } | {
20
- error?: undefined;
21
20
  configured?: undefined;
22
21
  connectPath?: undefined;
23
22
  url: string;
24
23
  id: string;
25
24
  provider: string;
25
+ error?: undefined;
26
26
  }>;
27
27
  export default _default;
28
28
  //# sourceMappingURL=upload-image.d.ts.map
@@ -41,16 +41,16 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
41
41
  thumbsUpRate: number;
42
42
  avgEvalScore: number;
43
43
  } | {
44
- error?: undefined;
45
44
  summary: import("./types.js").TraceSummary;
46
45
  spans: import("./types.js").TraceSpan[];
47
46
  id?: undefined;
47
+ error?: undefined;
48
48
  ok?: undefined;
49
49
  } | {
50
- error?: undefined;
51
50
  summary?: undefined;
52
51
  spans?: undefined;
53
52
  id: string;
53
+ error?: undefined;
54
54
  ok?: undefined;
55
55
  } | {
56
56
  summary?: undefined;
@@ -59,10 +59,10 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
59
59
  error: any;
60
60
  ok?: undefined;
61
61
  } | {
62
- error?: undefined;
63
62
  summary?: undefined;
64
63
  spans?: undefined;
65
64
  id?: undefined;
65
+ error?: undefined;
66
66
  ok: boolean;
67
67
  }>>;
68
68
  //# sourceMappingURL=routes.d.ts.map
@@ -15,7 +15,7 @@ export declare function createProgressHandler(): import("h3").EventHandlerWithFe
15
15
  error: string;
16
16
  ok?: undefined;
17
17
  } | {
18
- error?: undefined;
19
18
  ok: boolean;
19
+ error?: undefined;
20
20
  }>>;
21
21
  //# sourceMappingURL=routes.d.ts.map
@@ -52,8 +52,8 @@ export declare function handleDeleteResource(event: any): Promise<{
52
52
  error: string;
53
53
  ok?: undefined;
54
54
  } | {
55
- error?: undefined;
56
55
  ok: boolean;
56
+ error?: undefined;
57
57
  }>;
58
58
  /** POST /_agent-native/resources/upload — upload a file as a resource */
59
59
  export declare function handleUploadResource(event: any): Promise<import("./store.js").Resource | {
@@ -74,10 +74,10 @@ export declare function handleUploadResource(event: any): Promise<import("./stor
74
74
  runId: string | null;
75
75
  expiresAt: number | null;
76
76
  metadata: string | null;
77
- error?: undefined;
78
77
  url: string;
79
78
  provider: string;
80
79
  storageSetupRequired?: undefined;
80
+ error?: undefined;
81
81
  } | {
82
82
  error: string;
83
83
  storageSetupRequired: boolean;
@@ -27,11 +27,11 @@ export declare function resolveAgentEngineApiKeyWriteTarget(event: H3Event, scop
27
27
  export declare function createAgentEngineApiKeyHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
28
28
  error: any;
29
29
  } | {
30
- error?: undefined;
31
30
  ok: boolean;
32
31
  key: string;
33
32
  baseUrlKey?: string;
34
33
  scope: AgentEngineApiKeyScope;
34
+ error?: undefined;
35
35
  }>>;
36
36
  export {};
37
37
  //# sourceMappingURL=agent-engine-api-key-route.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jami-studio/core",
3
- "version": "0.92.25",
3
+ "version": "0.92.26",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/studio-jami/jami-studio#readme",
6
6
  "bugs": {