@alexkroman1/aai 1.1.0 → 1.2.1

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,5 @@
1
1
 
2
- > @alexkroman1/aai@1.1.0 build /home/runner/work/agent/agent/packages/aai
2
+ > @alexkroman1/aai@1.2.1 build /home/runner/work/agent/agent/packages/aai
3
3
  > tsdown && tsc -p tsconfig.build.json
4
4
 
5
5
  ℹ tsdown v0.21.7 powered by rolldown v1.0.0-rc.12
@@ -9,12 +9,12 @@
9
9
  ℹ tsconfig: tsconfig.json
10
10
  ℹ Build start
11
11
  ℹ dist/host/runtime-barrel.js 50.02 kB │ gzip: 15.70 kB
12
+ ℹ dist/index.js  6.38 kB │ gzip: 2.49 kB
12
13
  ℹ dist/sdk/protocol.js  4.75 kB │ gzip: 1.76 kB
13
- ℹ dist/index.js  2.49 kB │ gzip: 1.04 kB
14
14
  ℹ dist/sdk/manifest-barrel.js  0.26 kB │ gzip: 0.17 kB
15
15
  ℹ dist/constants-VTFoymJ-.js  2.75 kB │ gzip: 1.23 kB
16
16
  ℹ dist/_internal-types-CoDTiBd1.js  2.33 kB │ gzip: 0.99 kB
17
17
  ℹ dist/types-Cfx_4QDK.js  1.74 kB │ gzip: 0.93 kB
18
18
  ℹ dist/ws-upgrade-BeOQ7fXL.js  1.14 kB │ gzip: 0.54 kB
19
- ℹ 8 files, total: 65.47 kB
20
- ✔ Build complete in 51ms
19
+ ℹ 8 files, total: 69.36 kB
20
+ ✔ Build complete in 39ms
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @alexkroman1/aai
2
2
 
3
+ ## 1.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 7af69b8: Fix gVisor/Deno binary discovery in distroless Docker images
8
+
9
+ ## 1.2.0
10
+
11
+ ### Minor Changes
12
+
13
+ - ed0dfbb: Add allowedHosts manifest field and host-proxied fetch for sandbox agents
14
+
15
+ ### Patch Changes
16
+
17
+ - 231ebc1: Fix Docker build (missing unzip, CI=true for pnpm) and add test:adversarial command with CI integration
18
+
3
19
  ## 1.1.0
4
20
 
5
21
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * Types, KV interface, utils, and constants used across
5
5
  * aai-cli, aai-server, and aai-ui.
6
6
  */
7
+ export * from "./sdk/allowed-hosts.ts";
7
8
  export * from "./sdk/constants.ts";
8
9
  export * from "./sdk/define.ts";
9
10
  export * from "./sdk/kv.ts";
package/dist/index.js CHANGED
@@ -1,6 +1,95 @@
1
1
  import { _ as WS_OPEN, a as DEFAULT_SHUTDOWN_TIMEOUT_MS, c as FETCH_TIMEOUT_MS, d as MAX_PAGE_CHARS, f as MAX_TOOL_RESULT_CHARS, g as TOOL_EXECUTION_TIMEOUT_MS, h as RUN_CODE_TIMEOUT_MS, i as DEFAULT_SESSION_START_TIMEOUT_MS, l as MAX_HTML_BYTES, m as MAX_WS_PAYLOAD_BYTES, n as DEFAULT_IDLE_TIMEOUT_MS, o as DEFAULT_STT_SAMPLE_RATE, p as MAX_VALUE_SIZE, r as DEFAULT_MAX_HISTORY, s as DEFAULT_TTS_SAMPLE_RATE, t as AGENT_CSP, u as MAX_MESSAGE_BUFFER_SIZE } from "./constants-VTFoymJ-.js";
2
2
  import { i as ToolChoiceSchema, n as DEFAULT_GREETING, r as DEFAULT_SYSTEM_PROMPT, t as BuiltinToolSchema } from "./types-Cfx_4QDK.js";
3
3
  import { i as toolError, n as errorDetail, r as errorMessage, t as parseWsUpgradeParams } from "./ws-upgrade-BeOQ7fXL.js";
4
+ //#region sdk/allowed-hosts.ts
5
+ /**
6
+ * Allowlist matching for outbound host validation.
7
+ *
8
+ * Used at deploy time (manifest validation) and at runtime (SSRF enforcement)
9
+ * to restrict which external hosts an agent is permitted to contact.
10
+ *
11
+ * Lives in sdk/ because it has zero Node.js dependencies and can run in any
12
+ * environment (browser, Deno, Node.js sandboxes).
13
+ */
14
+ /** Private/special-use TLDs that must never appear in allowedHosts patterns. */
15
+ const BLOCKED_TLDS = [
16
+ "local",
17
+ "internal",
18
+ "localhost"
19
+ ];
20
+ /**
21
+ * Regex that matches an IPv4 address (four decimal octets separated by dots).
22
+ * Anchored so partial matches like "192.168.1.1.example.com" don't trigger it.
23
+ */
24
+ const IPV4_RE = /^(\d{1,3}\.){3}\d{1,3}$/;
25
+ function fail(reason) {
26
+ return {
27
+ valid: false,
28
+ reason
29
+ };
30
+ }
31
+ function checkStructural(pattern) {
32
+ if (pattern === "") return fail("Pattern must not be empty.");
33
+ if (pattern.includes("://")) return fail("Pattern must not include a protocol (e.g. remove 'https://').");
34
+ if (pattern.includes("/")) return fail("Pattern must not include a path component (remove '/').");
35
+ if (pattern.includes("?")) return fail("Pattern must not include a query string (remove '?').");
36
+ if (pattern.startsWith("[") || pattern.includes("::")) return fail("IP address literals are not allowed in allowedHosts patterns.");
37
+ if (pattern.includes(":")) return fail("Pattern must not include a port number (e.g. remove ':8080').");
38
+ return null;
39
+ }
40
+ function checkWildcard(pattern) {
41
+ if (!pattern.includes("*")) return null;
42
+ if (pattern === "*" || pattern === "**") return fail("Bare wildcard '*' is not allowed. Use '*.example.com' to allow all subdomains.");
43
+ if (pattern.indexOf("*") !== 0 || pattern[1] !== ".") return fail("Wildcard '*' may only appear as the leading segment (e.g. '*.example.com').");
44
+ if (pattern.lastIndexOf("*") !== 0) return fail("Only a single leading wildcard segment is supported.");
45
+ return null;
46
+ }
47
+ function checkHostPart(hostPart) {
48
+ if (IPV4_RE.test(hostPart)) return fail("IP address literals are not allowed in allowedHosts patterns.");
49
+ const tld = hostPart.split(".").at(-1)?.toLowerCase() ?? "";
50
+ if (BLOCKED_TLDS.includes(tld)) return fail(`Patterns ending in '.${tld}' are not allowed (private/special-use TLD).`);
51
+ return null;
52
+ }
53
+ /**
54
+ * Validate a single `allowedHosts` pattern at deploy time.
55
+ *
56
+ * Returns `{ valid: true }` for acceptable patterns or
57
+ * `{ valid: false; reason: string }` with a human-readable rejection reason.
58
+ */
59
+ function validateAllowedHostPattern(pattern) {
60
+ const structural = checkStructural(pattern);
61
+ if (structural !== null) return structural;
62
+ const wildcard = checkWildcard(pattern);
63
+ if (wildcard !== null) return wildcard;
64
+ const hostCheck = checkHostPart(pattern.startsWith("*.") ? pattern.slice(2) : pattern);
65
+ if (hostCheck !== null) return hostCheck;
66
+ return { valid: true };
67
+ }
68
+ /**
69
+ * Test whether `hostname` matches any pattern in `patterns`.
70
+ *
71
+ * - Exact match is case-insensitive; trailing dots on the hostname are stripped.
72
+ * - Wildcard pattern `*.example.com` matches any hostname ending with
73
+ * `.example.com` (one or more labels), but does NOT match `example.com` itself.
74
+ * - A port suffix on `hostname` (e.g. `api.example.com:8080`) is stripped before
75
+ * matching.
76
+ * - Returns `false` when `patterns` is empty.
77
+ */
78
+ function matchesAllowedHost(hostname, patterns) {
79
+ if (patterns.length === 0) return false;
80
+ const portIndex = hostname.lastIndexOf(":");
81
+ let host = portIndex !== -1 && !hostname.includes("[") ? hostname.slice(0, portIndex) : hostname;
82
+ host = host.toLowerCase().replace(/\.$/, "");
83
+ for (const pattern of patterns) {
84
+ const p = pattern.toLowerCase();
85
+ if (p.startsWith("*.")) {
86
+ const suffix = p.slice(1);
87
+ if (host.endsWith(suffix) && host.length > suffix.length) return true;
88
+ } else if (host === p) return true;
89
+ }
90
+ return false;
91
+ }
92
+ //#endregion
4
93
  //#region sdk/define.ts
5
94
  /**
6
95
  * Define a tool with typed parameters and execute function.
@@ -60,4 +149,4 @@ function agent(def) {
60
149
  };
61
150
  }
62
151
  //#endregion
63
- export { AGENT_CSP, BuiltinToolSchema, DEFAULT_GREETING, DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_MAX_HISTORY, DEFAULT_SESSION_START_TIMEOUT_MS, DEFAULT_SHUTDOWN_TIMEOUT_MS, DEFAULT_STT_SAMPLE_RATE, DEFAULT_SYSTEM_PROMPT, DEFAULT_TTS_SAMPLE_RATE, FETCH_TIMEOUT_MS, MAX_HTML_BYTES, MAX_MESSAGE_BUFFER_SIZE, MAX_PAGE_CHARS, MAX_TOOL_RESULT_CHARS, MAX_VALUE_SIZE, MAX_WS_PAYLOAD_BYTES, RUN_CODE_TIMEOUT_MS, TOOL_EXECUTION_TIMEOUT_MS, ToolChoiceSchema, WS_OPEN, agent, errorDetail, errorMessage, parseWsUpgradeParams, tool, toolError };
152
+ export { AGENT_CSP, BuiltinToolSchema, DEFAULT_GREETING, DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_MAX_HISTORY, DEFAULT_SESSION_START_TIMEOUT_MS, DEFAULT_SHUTDOWN_TIMEOUT_MS, DEFAULT_STT_SAMPLE_RATE, DEFAULT_SYSTEM_PROMPT, DEFAULT_TTS_SAMPLE_RATE, FETCH_TIMEOUT_MS, MAX_HTML_BYTES, MAX_MESSAGE_BUFFER_SIZE, MAX_PAGE_CHARS, MAX_TOOL_RESULT_CHARS, MAX_VALUE_SIZE, MAX_WS_PAYLOAD_BYTES, RUN_CODE_TIMEOUT_MS, TOOL_EXECUTION_TIMEOUT_MS, ToolChoiceSchema, WS_OPEN, agent, errorDetail, errorMessage, matchesAllowedHost, parseWsUpgradeParams, tool, toolError, validateAllowedHostPattern };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Allowlist matching for outbound host validation.
3
+ *
4
+ * Used at deploy time (manifest validation) and at runtime (SSRF enforcement)
5
+ * to restrict which external hosts an agent is permitted to contact.
6
+ *
7
+ * Lives in sdk/ because it has zero Node.js dependencies and can run in any
8
+ * environment (browser, Deno, Node.js sandboxes).
9
+ */
10
+ type ValidationResult = {
11
+ valid: true;
12
+ } | {
13
+ valid: false;
14
+ reason: string;
15
+ };
16
+ /**
17
+ * Validate a single `allowedHosts` pattern at deploy time.
18
+ *
19
+ * Returns `{ valid: true }` for acceptable patterns or
20
+ * `{ valid: false; reason: string }` with a human-readable rejection reason.
21
+ */
22
+ export declare function validateAllowedHostPattern(pattern: string): ValidationResult;
23
+ /**
24
+ * Test whether `hostname` matches any pattern in `patterns`.
25
+ *
26
+ * - Exact match is case-insensitive; trailing dots on the hostname are stripped.
27
+ * - Wildcard pattern `*.example.com` matches any hostname ending with
28
+ * `.example.com` (one or more labels), but does NOT match `example.com` itself.
29
+ * - A port suffix on `hostname` (e.g. `api.example.com:8080`) is stripped before
30
+ * matching.
31
+ * - Returns `false` when `patterns` is empty.
32
+ */
33
+ export declare function matchesAllowedHost(hostname: string, patterns: string[]): boolean;
34
+ export {};
@@ -37,6 +37,8 @@ export type Manifest = {
37
37
  theme?: Record<string, string> | undefined;
38
38
  /** Custom tool definitions keyed by tool name. */
39
39
  tools: Record<string, ToolManifest>;
40
+ /** Hostnames the agent is allowed to fetch. Empty = no fetch access. */
41
+ allowedHosts: string[];
40
42
  };
41
43
  /**
42
44
  * Parse and normalize a raw agent manifest, applying defaults for all
@@ -60,6 +60,7 @@ export type KvRequest = z.infer<typeof KvRequestSchema>;
60
60
  * @public
61
61
  */
62
62
  export declare const SessionErrorCodeSchema: z.ZodEnum<{
63
+ internal: "internal";
63
64
  tool: "tool";
64
65
  connection: "connection";
65
66
  stt: "stt";
@@ -67,7 +68,6 @@ export declare const SessionErrorCodeSchema: z.ZodEnum<{
67
68
  tts: "tts";
68
69
  protocol: "protocol";
69
70
  audio: "audio";
70
- internal: "internal";
71
71
  }>;
72
72
  /**
73
73
  * Error codes for categorizing session errors on the wire.
@@ -107,6 +107,7 @@ export declare const ClientEventSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
107
107
  }, z.core.$strip>, z.ZodObject<{
108
108
  type: z.ZodLiteral<"error">;
109
109
  code: z.ZodEnum<{
110
+ internal: "internal";
110
111
  tool: "tool";
111
112
  connection: "connection";
112
113
  stt: "stt";
@@ -114,7 +115,6 @@ export declare const ClientEventSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
114
115
  tts: "tts";
115
116
  protocol: "protocol";
116
117
  audio: "audio";
117
- internal: "internal";
118
118
  }>;
119
119
  message: z.ZodString;
120
120
  }, z.core.$strip>, z.ZodObject<{
@@ -189,6 +189,7 @@ export declare const ServerMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
189
189
  }, z.core.$strip>, z.ZodObject<{
190
190
  type: z.ZodLiteral<"error">;
191
191
  code: z.ZodEnum<{
192
+ internal: "internal";
192
193
  tool: "tool";
193
194
  connection: "connection";
194
195
  stt: "stt";
@@ -196,7 +197,6 @@ export declare const ServerMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
196
197
  tts: "tts";
197
198
  protocol: "protocol";
198
199
  audio: "audio";
199
- internal: "internal";
200
200
  }>;
201
201
  message: z.ZodString;
202
202
  }, z.core.$strip>, z.ZodObject<{
package/index.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  // biome-ignore-all lint/performance/noReExportAll: barrel file by design
10
10
 
11
+ export * from "./sdk/allowed-hosts.ts";
11
12
  export * from "./sdk/constants.ts";
12
13
  export * from "./sdk/define.ts";
13
14
  export * from "./sdk/kv.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -26,9 +26,11 @@ exports[`export surface stability > @alexkroman1/aai main export 1`] = `
26
26
  "agent",
27
27
  "errorDetail",
28
28
  "errorMessage",
29
+ "matchesAllowedHost",
29
30
  "parseWsUpgradeParams",
30
31
  "tool",
31
32
  "toolError",
33
+ "validateAllowedHostPattern",
32
34
  ]
33
35
  `;
34
36
 
@@ -0,0 +1,236 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
2
+ import { describe, expect, test } from "vitest";
3
+ import { matchesAllowedHost, validateAllowedHostPattern } from "./allowed-hosts.ts";
4
+
5
+ describe("validateAllowedHostPattern", () => {
6
+ describe("valid patterns", () => {
7
+ test("accepts exact hostname", () => {
8
+ expect(validateAllowedHostPattern("api.weather.com")).toEqual({
9
+ valid: true,
10
+ });
11
+ });
12
+
13
+ test("accepts exact hostname without subdomain", () => {
14
+ expect(validateAllowedHostPattern("example.com")).toEqual({ valid: true });
15
+ });
16
+
17
+ test("accepts wildcard subdomain", () => {
18
+ expect(validateAllowedHostPattern("*.mycompany.com")).toEqual({
19
+ valid: true,
20
+ });
21
+ });
22
+
23
+ test("accepts wildcard with multiple domain levels", () => {
24
+ expect(validateAllowedHostPattern("*.api.mycompany.com")).toEqual({
25
+ valid: true,
26
+ });
27
+ });
28
+ });
29
+
30
+ describe("rejects bare wildcards", () => {
31
+ test("rejects bare *", () => {
32
+ const result = validateAllowedHostPattern("*");
33
+ expect(result.valid).toBe(false);
34
+ expect((result as { valid: false; reason: string }).reason).toMatch(/bare/i);
35
+ });
36
+
37
+ test("rejects bare **", () => {
38
+ const result = validateAllowedHostPattern("**");
39
+ expect(result.valid).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe("rejects wildcard in non-leading position", () => {
44
+ test("rejects wildcard in middle position", () => {
45
+ const result = validateAllowedHostPattern("api.*.com");
46
+ expect(result.valid).toBe(false);
47
+ });
48
+
49
+ test("rejects wildcard at end", () => {
50
+ const result = validateAllowedHostPattern("api.com.*");
51
+ expect(result.valid).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe("rejects IP addresses", () => {
56
+ test("rejects IPv4 address", () => {
57
+ const result = validateAllowedHostPattern("192.168.1.1");
58
+ expect(result.valid).toBe(false);
59
+ expect((result as { valid: false; reason: string }).reason).toMatch(/ip/i);
60
+ });
61
+
62
+ test("rejects IPv4 loopback", () => {
63
+ const result = validateAllowedHostPattern("127.0.0.1");
64
+ expect(result.valid).toBe(false);
65
+ });
66
+
67
+ test("rejects IPv6 address", () => {
68
+ const result = validateAllowedHostPattern("::1");
69
+ expect(result.valid).toBe(false);
70
+ expect((result as { valid: false; reason: string }).reason).toMatch(/ip/i);
71
+ });
72
+
73
+ test("rejects full IPv6 address", () => {
74
+ const result = validateAllowedHostPattern("2001:db8::1");
75
+ expect(result.valid).toBe(false);
76
+ expect((result as { valid: false; reason: string }).reason).toMatch(/ip/i);
77
+ });
78
+ });
79
+
80
+ describe("rejects private TLDs", () => {
81
+ test("rejects *.local", () => {
82
+ const result = validateAllowedHostPattern("*.local");
83
+ expect(result.valid).toBe(false);
84
+ });
85
+
86
+ test("rejects *.internal", () => {
87
+ const result = validateAllowedHostPattern("*.internal");
88
+ expect(result.valid).toBe(false);
89
+ });
90
+
91
+ test("rejects *.localhost", () => {
92
+ const result = validateAllowedHostPattern("*.localhost");
93
+ expect(result.valid).toBe(false);
94
+ });
95
+
96
+ test("rejects exact match foo.local", () => {
97
+ const result = validateAllowedHostPattern("foo.local");
98
+ expect(result.valid).toBe(false);
99
+ });
100
+
101
+ test("rejects exact match foo.internal", () => {
102
+ const result = validateAllowedHostPattern("foo.internal");
103
+ expect(result.valid).toBe(false);
104
+ });
105
+
106
+ test("rejects exact match foo.localhost", () => {
107
+ const result = validateAllowedHostPattern("foo.localhost");
108
+ expect(result.valid).toBe(false);
109
+ });
110
+ });
111
+
112
+ describe("rejects cloud metadata hostnames", () => {
113
+ test("rejects metadata.google.internal", () => {
114
+ const result = validateAllowedHostPattern("metadata.google.internal");
115
+ expect(result.valid).toBe(false);
116
+ });
117
+
118
+ test("rejects instance-data.ec2.internal", () => {
119
+ const result = validateAllowedHostPattern("instance-data.ec2.internal");
120
+ expect(result.valid).toBe(false);
121
+ });
122
+ });
123
+
124
+ describe("rejects empty and malformed patterns", () => {
125
+ test("rejects empty string", () => {
126
+ const result = validateAllowedHostPattern("");
127
+ expect(result.valid).toBe(false);
128
+ });
129
+
130
+ test("rejects pattern with protocol", () => {
131
+ const result = validateAllowedHostPattern("https://api.example.com");
132
+ expect(result.valid).toBe(false);
133
+ expect((result as { valid: false; reason: string }).reason).toMatch(/protocol/i);
134
+ });
135
+
136
+ test("rejects pattern with path", () => {
137
+ const result = validateAllowedHostPattern("api.example.com/path");
138
+ expect(result.valid).toBe(false);
139
+ expect((result as { valid: false; reason: string }).reason).toMatch(/path/i);
140
+ });
141
+
142
+ test("rejects pattern with query", () => {
143
+ const result = validateAllowedHostPattern("api.example.com?query=1");
144
+ expect(result.valid).toBe(false);
145
+ expect((result as { valid: false; reason: string }).reason).toMatch(/query/i);
146
+ });
147
+
148
+ test("rejects pattern with port", () => {
149
+ const result = validateAllowedHostPattern("api.example.com:8080");
150
+ expect(result.valid).toBe(false);
151
+ expect((result as { valid: false; reason: string }).reason).toMatch(/port/i);
152
+ });
153
+ });
154
+ });
155
+
156
+ describe("matchesAllowedHost", () => {
157
+ describe("exact matching", () => {
158
+ test("exact match", () => {
159
+ expect(matchesAllowedHost("api.example.com", ["api.example.com"])).toBe(true);
160
+ });
161
+
162
+ test("case-insensitive exact match", () => {
163
+ expect(matchesAllowedHost("API.Example.COM", ["api.example.com"])).toBe(true);
164
+ });
165
+
166
+ test("strips trailing dot from hostname", () => {
167
+ expect(matchesAllowedHost("api.example.com.", ["api.example.com"])).toBe(true);
168
+ });
169
+
170
+ test("exact match doesn't match subdomain", () => {
171
+ expect(matchesAllowedHost("sub.api.example.com", ["api.example.com"])).toBe(false);
172
+ });
173
+
174
+ test("exact match doesn't match parent domain", () => {
175
+ expect(matchesAllowedHost("example.com", ["api.example.com"])).toBe(false);
176
+ });
177
+ });
178
+
179
+ describe("wildcard matching", () => {
180
+ test("wildcard matches one subdomain level", () => {
181
+ expect(matchesAllowedHost("foo.example.com", ["*.example.com"])).toBe(true);
182
+ });
183
+
184
+ test("wildcard matches multiple subdomain levels", () => {
185
+ expect(matchesAllowedHost("a.b.example.com", ["*.example.com"])).toBe(true);
186
+ });
187
+
188
+ test("wildcard does not match bare domain", () => {
189
+ expect(matchesAllowedHost("example.com", ["*.example.com"])).toBe(false);
190
+ });
191
+
192
+ test("wildcard is case-insensitive", () => {
193
+ expect(matchesAllowedHost("FOO.Example.COM", ["*.example.com"])).toBe(true);
194
+ });
195
+ });
196
+
197
+ describe("no match", () => {
198
+ test("no match returns false", () => {
199
+ expect(matchesAllowedHost("other.com", ["api.example.com"])).toBe(false);
200
+ });
201
+
202
+ test("empty patterns returns false", () => {
203
+ expect(matchesAllowedHost("api.example.com", [])).toBe(false);
204
+ });
205
+ });
206
+
207
+ describe("multiple patterns", () => {
208
+ test("matches against multiple patterns — first matches", () => {
209
+ expect(matchesAllowedHost("api.example.com", ["api.example.com", "other.com"])).toBe(true);
210
+ });
211
+
212
+ test("matches against multiple patterns — second matches", () => {
213
+ expect(matchesAllowedHost("other.com", ["api.example.com", "other.com"])).toBe(true);
214
+ });
215
+
216
+ test("matches against multiple patterns — none match", () => {
217
+ expect(matchesAllowedHost("nope.com", ["api.example.com", "other.com"])).toBe(false);
218
+ });
219
+ });
220
+
221
+ describe("port handling", () => {
222
+ test("port in hostname is stripped before matching exact", () => {
223
+ expect(matchesAllowedHost("api.weather.com:8080", ["api.weather.com"])).toBe(true);
224
+ });
225
+
226
+ test("port with wildcard pattern", () => {
227
+ expect(matchesAllowedHost("sub.example.com:443", ["*.example.com"])).toBe(true);
228
+ });
229
+ });
230
+
231
+ describe("IDN/punycode", () => {
232
+ test("ASCII hostnames compared normally", () => {
233
+ expect(matchesAllowedHost("xn--nxasmq6b.com", ["xn--nxasmq6b.com"])).toBe(true);
234
+ });
235
+ });
236
+ });
@@ -0,0 +1,113 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
2
+ /**
3
+ * Allowlist matching for outbound host validation.
4
+ *
5
+ * Used at deploy time (manifest validation) and at runtime (SSRF enforcement)
6
+ * to restrict which external hosts an agent is permitted to contact.
7
+ *
8
+ * Lives in sdk/ because it has zero Node.js dependencies and can run in any
9
+ * environment (browser, Deno, Node.js sandboxes).
10
+ */
11
+
12
+ /** Private/special-use TLDs that must never appear in allowedHosts patterns. */
13
+ const BLOCKED_TLDS = ["local", "internal", "localhost"];
14
+
15
+ /**
16
+ * Regex that matches an IPv4 address (four decimal octets separated by dots).
17
+ * Anchored so partial matches like "192.168.1.1.example.com" don't trigger it.
18
+ */
19
+ const IPV4_RE = /^(\d{1,3}\.){3}\d{1,3}$/;
20
+
21
+ type ValidationResult = { valid: true } | { valid: false; reason: string };
22
+
23
+ function fail(reason: string): { valid: false; reason: string } {
24
+ return { valid: false, reason };
25
+ }
26
+
27
+ function checkStructural(pattern: string): ValidationResult | null {
28
+ if (pattern === "") return fail("Pattern must not be empty.");
29
+ if (pattern.includes("://"))
30
+ return fail("Pattern must not include a protocol (e.g. remove 'https://').");
31
+ if (pattern.includes("/")) return fail("Pattern must not include a path component (remove '/').");
32
+ if (pattern.includes("?")) return fail("Pattern must not include a query string (remove '?').");
33
+ if (pattern.startsWith("[") || pattern.includes("::"))
34
+ return fail("IP address literals are not allowed in allowedHosts patterns.");
35
+ if (pattern.includes(":"))
36
+ return fail("Pattern must not include a port number (e.g. remove ':8080').");
37
+ return null;
38
+ }
39
+
40
+ function checkWildcard(pattern: string): ValidationResult | null {
41
+ if (!pattern.includes("*")) return null;
42
+ if (pattern === "*" || pattern === "**")
43
+ return fail("Bare wildcard '*' is not allowed. Use '*.example.com' to allow all subdomains.");
44
+ const wildcardIndex = pattern.indexOf("*");
45
+ if (wildcardIndex !== 0 || pattern[1] !== ".")
46
+ return fail("Wildcard '*' may only appear as the leading segment (e.g. '*.example.com').");
47
+ if (pattern.lastIndexOf("*") !== 0)
48
+ return fail("Only a single leading wildcard segment is supported.");
49
+ return null;
50
+ }
51
+
52
+ function checkHostPart(hostPart: string): ValidationResult | null {
53
+ if (IPV4_RE.test(hostPart))
54
+ return fail("IP address literals are not allowed in allowedHosts patterns.");
55
+ const tld = hostPart.split(".").at(-1)?.toLowerCase() ?? "";
56
+ if (BLOCKED_TLDS.includes(tld))
57
+ return fail(`Patterns ending in '.${tld}' are not allowed (private/special-use TLD).`);
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * Validate a single `allowedHosts` pattern at deploy time.
63
+ *
64
+ * Returns `{ valid: true }` for acceptable patterns or
65
+ * `{ valid: false; reason: string }` with a human-readable rejection reason.
66
+ */
67
+ export function validateAllowedHostPattern(pattern: string): ValidationResult {
68
+ const structural = checkStructural(pattern);
69
+ if (structural !== null) return structural;
70
+
71
+ const wildcard = checkWildcard(pattern);
72
+ if (wildcard !== null) return wildcard;
73
+
74
+ const hostPart = pattern.startsWith("*.") ? pattern.slice(2) : pattern;
75
+ const hostCheck = checkHostPart(hostPart);
76
+ if (hostCheck !== null) return hostCheck;
77
+
78
+ return { valid: true };
79
+ }
80
+
81
+ /**
82
+ * Test whether `hostname` matches any pattern in `patterns`.
83
+ *
84
+ * - Exact match is case-insensitive; trailing dots on the hostname are stripped.
85
+ * - Wildcard pattern `*.example.com` matches any hostname ending with
86
+ * `.example.com` (one or more labels), but does NOT match `example.com` itself.
87
+ * - A port suffix on `hostname` (e.g. `api.example.com:8080`) is stripped before
88
+ * matching.
89
+ * - Returns `false` when `patterns` is empty.
90
+ */
91
+ export function matchesAllowedHost(hostname: string, patterns: string[]): boolean {
92
+ if (patterns.length === 0) return false;
93
+
94
+ // Strip port — only when there are no brackets (IPv6 bracket notation).
95
+ const portIndex = hostname.lastIndexOf(":");
96
+ let host = portIndex !== -1 && !hostname.includes("[") ? hostname.slice(0, portIndex) : hostname;
97
+
98
+ // Normalise: lowercase + strip trailing dot
99
+ host = host.toLowerCase().replace(/\.$/, "");
100
+
101
+ for (const pattern of patterns) {
102
+ const p = pattern.toLowerCase();
103
+ if (p.startsWith("*.")) {
104
+ // Wildcard: hostname must end with the suffix (e.g. ".example.com")
105
+ const suffix = p.slice(1); // ".example.com"
106
+ if (host.endsWith(suffix) && host.length > suffix.length) return true;
107
+ } else if (host === p) {
108
+ return true;
109
+ }
110
+ }
111
+
112
+ return false;
113
+ }
@@ -15,6 +15,7 @@ describe("parseManifest", () => {
15
15
  maxSteps: 5,
16
16
  toolChoice: "auto",
17
17
  builtinTools: [],
18
+ allowedHosts: [],
18
19
  tools: {},
19
20
  });
20
21
  });
@@ -56,6 +57,37 @@ describe("parseManifest", () => {
56
57
  test("rejects unknown builtinTools", () => {
57
58
  expect(() => parseManifest({ name: "X", builtinTools: ["not_a_tool"] })).toThrow();
58
59
  });
60
+
61
+ test("allowedHosts defaults to empty array when omitted", () => {
62
+ const result = parseManifest({ name: "test" });
63
+ expect(result.allowedHosts).toEqual([]);
64
+ });
65
+
66
+ test("allowedHosts passes through valid patterns", () => {
67
+ const result = parseManifest({
68
+ name: "test",
69
+ allowedHosts: ["api.weather.com", "*.mycompany.com"],
70
+ });
71
+ expect(result.allowedHosts).toEqual(["api.weather.com", "*.mycompany.com"]);
72
+ });
73
+
74
+ test("rejects invalid allowedHosts pattern", () => {
75
+ expect(() => parseManifest({ name: "test", allowedHosts: ["*"] })).toThrow();
76
+ });
77
+
78
+ test("rejects allowedHosts with IP address", () => {
79
+ expect(() => parseManifest({ name: "test", allowedHosts: ["192.168.1.1"] })).toThrow();
80
+ });
81
+
82
+ test("rejects allowedHosts with private TLD", () => {
83
+ expect(() => parseManifest({ name: "test", allowedHosts: ["*.internal"] })).toThrow();
84
+ });
85
+
86
+ test("rejects allowedHosts with protocol", () => {
87
+ expect(() =>
88
+ parseManifest({ name: "test", allowedHosts: ["https://api.example.com"] }),
89
+ ).toThrow();
90
+ });
59
91
  });
60
92
 
61
93
  // ── Property-based tests ─────────────────────────────────────────────────
@@ -117,4 +149,9 @@ describe("manifest type contracts", () => {
117
149
  const schemas = agentToolsToSchemas({});
118
150
  expectTypeOf(schemas).toEqualTypeOf<ToolSchema[]>();
119
151
  });
152
+
153
+ test("Manifest has allowedHosts as string[]", () => {
154
+ const result = parseManifest({ name: "test" });
155
+ expectTypeOf(result.allowedHosts).toEqualTypeOf<string[]>();
156
+ });
120
157
  });
package/sdk/manifest.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { z } from "zod";
10
+ import { validateAllowedHostPattern } from "./allowed-hosts.ts";
10
11
  import { BuiltinToolSchema, DEFAULT_GREETING, DEFAULT_SYSTEM_PROMPT } from "./types.ts";
11
12
 
12
13
  /**
@@ -43,6 +44,8 @@ export type Manifest = {
43
44
  theme?: Record<string, string> | undefined;
44
45
  /** Custom tool definitions keyed by tool name. */
45
46
  tools: Record<string, ToolManifest>;
47
+ /** Hostnames the agent is allowed to fetch. Empty = no fetch access. */
48
+ allowedHosts: string[];
46
49
  };
47
50
 
48
51
  const ToolManifestSchema = z.object({
@@ -61,6 +64,21 @@ const ManifestSchema = z.object({
61
64
  idleTimeoutMs: z.number().int().positive().optional(),
62
65
  theme: z.record(z.string(), z.string()).optional(),
63
66
  tools: z.record(z.string(), ToolManifestSchema).optional(),
67
+ allowedHosts: z
68
+ .array(z.string())
69
+ .optional()
70
+ .superRefine((hosts, ctx) => {
71
+ if (!hosts) return;
72
+ for (const h of hosts) {
73
+ const result = validateAllowedHostPattern(h);
74
+ if (!result.valid) {
75
+ ctx.addIssue({
76
+ code: z.ZodIssueCode.custom,
77
+ message: `Invalid allowedHosts pattern "${h}": ${result.reason}`,
78
+ });
79
+ }
80
+ }
81
+ }),
64
82
  });
65
83
 
66
84
  /**
@@ -85,5 +103,6 @@ export function parseManifest(input: unknown): Manifest {
85
103
  idleTimeoutMs: parsed.idleTimeoutMs,
86
104
  theme: parsed.theme,
87
105
  tools: parsed.tools ?? {},
106
+ allowedHosts: parsed.allowedHosts ?? [],
88
107
  };
89
108
  }