@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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +90 -1
- package/dist/sdk/allowed-hosts.d.ts +34 -0
- package/dist/sdk/manifest.d.ts +2 -0
- package/dist/sdk/protocol.d.ts +3 -3
- package/index.ts +1 -0
- package/package.json +1 -1
- package/sdk/__snapshots__/exports.test.ts.snap +2 -0
- package/sdk/allowed-hosts.test.ts +236 -0
- package/sdk/allowed-hosts.ts +113 -0
- package/sdk/manifest.test.ts +37 -0
- package/sdk/manifest.ts +19 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @alexkroman1/aai@1.1
|
|
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
|
[34mℹ[39m [34mtsdown v0.21.7[39m powered by [38;2;255;126;23mrolldown v1.0.0-rc.12[39m
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
[34mℹ[39m tsconfig: [34mtsconfig.json[39m
|
|
10
10
|
[34mℹ[39m Build start
|
|
11
11
|
[34mℹ[39m [2mdist/[22m[1mhost/runtime-barrel.js[22m [2m50.02 kB[22m [2m│ gzip: 15.70 kB[22m
|
|
12
|
+
[34mℹ[39m [2mdist/[22m[1mindex.js[22m [2m 6.38 kB[22m [2m│ gzip: 2.49 kB[22m
|
|
12
13
|
[34mℹ[39m [2mdist/[22m[1msdk/protocol.js[22m [2m 4.75 kB[22m [2m│ gzip: 1.76 kB[22m
|
|
13
|
-
[34mℹ[39m [2mdist/[22m[1mindex.js[22m [2m 2.49 kB[22m [2m│ gzip: 1.04 kB[22m
|
|
14
14
|
[34mℹ[39m [2mdist/[22m[1msdk/manifest-barrel.js[22m [2m 0.26 kB[22m [2m│ gzip: 0.17 kB[22m
|
|
15
15
|
[34mℹ[39m [2mdist/[22mconstants-VTFoymJ-.js [2m 2.75 kB[22m [2m│ gzip: 1.23 kB[22m
|
|
16
16
|
[34mℹ[39m [2mdist/[22m_internal-types-CoDTiBd1.js [2m 2.33 kB[22m [2m│ gzip: 0.99 kB[22m
|
|
17
17
|
[34mℹ[39m [2mdist/[22mtypes-Cfx_4QDK.js [2m 1.74 kB[22m [2m│ gzip: 0.93 kB[22m
|
|
18
18
|
[34mℹ[39m [2mdist/[22mws-upgrade-BeOQ7fXL.js [2m 1.14 kB[22m [2m│ gzip: 0.54 kB[22m
|
|
19
|
-
[34mℹ[39m 8 files, total:
|
|
20
|
-
[32m✔[39m Build complete in [
|
|
19
|
+
[34mℹ[39m 8 files, total: 69.36 kB
|
|
20
|
+
[32m✔[39m Build complete in [32m39ms[39m
|
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
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 {};
|
package/dist/sdk/manifest.d.ts
CHANGED
|
@@ -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
|
package/dist/sdk/protocol.d.ts
CHANGED
|
@@ -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
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/sdk/manifest.test.ts
CHANGED
|
@@ -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
|
}
|