@vellumai/vellum-gateway 0.11.4 → 0.11.5-staging.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/AGENTS.md +37 -0
  2. package/README.md +0 -8
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  10. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  11. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +166 -1
  12. package/node_modules/@vellumai/gateway-client/src/http-delivery.ts +7 -14
  13. package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
  14. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +29 -31
  15. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  16. package/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  17. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  18. package/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  19. package/package.json +2 -1
  20. package/src/__tests__/bash-risk-classifier.test.ts +1 -62
  21. package/src/__tests__/config.test.ts +1 -0
  22. package/src/__tests__/nonbash-trust-rule-overrides.test.ts +55 -0
  23. package/src/__tests__/slack-socket-mode-catchup.test.ts +43 -0
  24. package/src/__tests__/slack-socket-mode-liveness.test.ts +422 -0
  25. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +105 -0
  26. package/src/__tests__/whatsapp-download.test.ts +60 -4
  27. package/src/attachments/download.test.ts +80 -0
  28. package/src/attachments/download.ts +72 -0
  29. package/src/attachments/ingest.test.ts +148 -0
  30. package/src/attachments/ingest.ts +113 -0
  31. package/src/channels/conversation-queue.test.ts +64 -0
  32. package/src/channels/conversation-queue.ts +35 -0
  33. package/src/channels/inbound-event.ts +9 -7
  34. package/src/config.ts +11 -5
  35. package/src/discord/admission-log-visibility.test.ts +1 -1
  36. package/src/discord/attachments.ts +70 -0
  37. package/src/discord/download.test.ts +156 -0
  38. package/src/discord/download.ts +55 -0
  39. package/src/discord/forward.ts +134 -0
  40. package/src/discord/gateway-socket.test.ts +1 -1
  41. package/src/discord/gateway-socket.ts +16 -13
  42. package/src/discord/message-schemas.ts +94 -0
  43. package/src/discord/normalize.test.ts +140 -0
  44. package/src/discord/normalize.ts +5 -2
  45. package/src/feature-flag-registry.json +32 -32
  46. package/src/http/routes/telegram-webhook.ts +30 -77
  47. package/src/http/routes/whatsapp-webhook.test.ts +1 -0
  48. package/src/http/routes/whatsapp-webhook.ts +31 -92
  49. package/src/index.ts +53 -148
  50. package/src/ipc/risk-classification-handlers.ts +15 -112
  51. package/src/ipc/server.ts +26 -0
  52. package/src/risk/AGENTS.md +9 -0
  53. package/src/risk/bash-risk-classifier.test.ts +1 -10
  54. package/src/risk/bash-risk-classifier.ts +9 -37
  55. package/src/risk/command-registry/commands/assistant.ts +7 -0
  56. package/src/risk/file-risk-classifier.ts +2 -7
  57. package/src/risk/risk-types.ts +27 -49
  58. package/src/risk/schedule-risk-classifier.ts +2 -6
  59. package/src/risk/skill-risk-classifier.ts +2 -6
  60. package/src/risk/user-rule-override.ts +27 -0
  61. package/src/risk/web-risk-classifier.test.ts +86 -10
  62. package/src/risk/web-risk-classifier.ts +86 -15
  63. package/src/slack/attachments.ts +4 -7
  64. package/src/slack/download.test.ts +57 -20
  65. package/src/slack/download.ts +17 -24
  66. package/src/slack/event-kind.ts +17 -0
  67. package/src/slack/message-normalizer.ts +22 -6
  68. package/src/slack/normalize.test.ts +18 -0
  69. package/src/slack/socket-liveness.test.ts +200 -0
  70. package/src/slack/socket-liveness.ts +181 -0
  71. package/src/slack/socket-mode.ts +241 -41
  72. package/src/slack/source-metadata.test.ts +104 -0
  73. package/src/slack/source-metadata.ts +42 -0
  74. package/src/telegram/download.ts +17 -26
  75. package/src/types.ts +1 -0
  76. package/src/util/schedule.ts +25 -0
  77. package/src/whatsapp/download.ts +24 -34
package/AGENTS.md CHANGED
@@ -117,6 +117,43 @@ Organize gateway code **by concern, not by technical layer** — group by what c
117
117
 
118
118
  The `slack/` module is the worked example of this shape.
119
119
 
120
+ ## Long-Lived Channel Transports Own Their Liveness
121
+
122
+ A channel that holds a persistent socket (Discord Gateway, Slack Socket Mode)
123
+ must be able to conclude on its own that its connection is dead, and recover,
124
+ without waiting for a close event. A half-open socket, the shape a NAT rebind
125
+ or a vanished peer leaves behind, reports itself `OPEN` indefinitely and fires
126
+ nothing at all: no message, no error, no close. Recovery that waits to be told
127
+ never runs.
128
+
129
+ Three things are required, and a transport missing any one of them has a
130
+ silent multi-hour outage in it:
131
+
132
+ - **A liveness signal the transport generates itself.** Inbound quiet proves
133
+ nothing, because a quiet workspace and a dead socket are indistinguishable.
134
+ Discord rides its protocol heartbeat and tracks the ACK
135
+ (`discord/heartbeat.ts`). Slack Socket Mode has no application-level
136
+ heartbeat, so it probes the transport directly with a WebSocket ping frame
137
+ and requires a pong (`slack/socket-liveness.ts`). Prefer a probe you send
138
+ over a timeout you wait out.
139
+ - **A bound on the pre-established window.** A handshake that stalls produces
140
+ neither `open` nor `close`, so it falls outside any watchdog that arms on a
141
+ live connection. See `HELLO_DEADLINE_MS` (Discord) and
142
+ `CONNECT_DEADLINE_MS` (Slack).
143
+ - **Teardown that does not wait on a close event.** Recovery closes the dead
144
+ socket and proceeds on a timer whether or not the close ever lands. See
145
+ `killAndRecover` (Discord) and `forceReconnect` (Slack).
146
+
147
+ Derive every threshold from something measured, and record the derivation in
148
+ the constant's docstring. A number chosen to feel safe against expected
149
+ traffic will be wrong: Socket Mode connections were assumed to rotate about
150
+ hourly, and were then observed rotating several hours apart, which is exactly
151
+ the kind of assumption an inbound-silence threshold would have been built on.
152
+
153
+ The process-wide `SleepWakeDetector` is not a substitute for any of this. It
154
+ fires only when the whole process was suspended, so it misses every connection
155
+ that dies while the gateway is healthy and logging normally.
156
+
120
157
  ## Channel Trust Classification & Admission Policy
121
158
 
122
159
  The gateway owns per-channel `AdmissionPolicy` storage (`gateway/src/db/admission-policy-store.ts`, HTTP in `gateway/src/http/routes/channel-admission-policy.ts`) and attaches the floor to every forwarded inbound via `sourceMetadata.admissionPolicy`. The runtime (`assistant/src/runtime/routes/inbound-stages/admission-policy.ts`) emits `admitted: true | false` based on `TRUST_CLASS_RANK[trustClass] >= ADMISSION_FLOOR[policy]`.
package/README.md CHANGED
@@ -134,14 +134,6 @@ The `/deliver/telegram` endpoint accepts an optional `approval` field in the req
134
134
 
135
135
  **Fallback behavior:** For non-rich channels that do not support inline keyboards, the runtime substitutes the `plainTextFallback` string for the structured `promptText` before calling the delivery endpoint. The fallback includes plain-text instructions so the user can respond via text. The `supportsInlineOptions` channel capability (`channelSupportsInlineOptions()`) in the runtime determines which format to use. Free-text responses are classified by the conversational approval engine.
136
136
 
137
- ## Telegram Typing Indicator
138
-
139
- The `/deliver/telegram` endpoint also accepts an optional `chatAction` field for ephemeral Telegram chat actions. Current supported value:
140
-
141
- - `typing` — triggers Telegram `sendChatAction` with `action: "typing"` for the target `chatId`.
142
-
143
- This can be sent as an action-only payload (without `text` or `attachments`) when the runtime wants to show a typing indicator while an assistant response is still in progress.
144
-
145
137
  ## Public Ingress Routes
146
138
 
147
139
  The gateway serves as the single public ingress point for all external callbacks. The following routes are handled directly by the gateway before any proxy forwarding:
@@ -14,6 +14,7 @@
14
14
  "./remote-web-pairing": "./src/remote-web-pairing.ts",
15
15
  "./twilio-ingress": "./src/twilio-ingress.ts",
16
16
  "./trust-rules": "./src/trust-rules.ts",
17
+ "./url-normalization": "./src/url-normalization.ts",
17
18
  "./handles": "./src/handles.ts",
18
19
  "./rpc": "./src/rpc.ts",
19
20
  "./attachment-naming": "./src/attachment-naming.ts",
@@ -0,0 +1,135 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ canonicalizeWebUrl,
5
+ looksLikeHostPortShorthand,
6
+ looksLikePathOnlyInput,
7
+ normalizeWebUrl,
8
+ } from "../url-normalization.js";
9
+
10
+ /**
11
+ * These are the rules a trust rule is saved and matched under. A change here
12
+ * changes which saved rules still match, so each case states the target the
13
+ * two spellings must agree on.
14
+ */
15
+ describe("normalizeWebUrl", () => {
16
+ test("keeps an ordinary https URL as-is", () => {
17
+ expect(normalizeWebUrl("https://example.com/docs/page")?.href).toBe(
18
+ "https://example.com/docs/page",
19
+ );
20
+ });
21
+
22
+ test("drops the fragment: it never reaches the server", () => {
23
+ expect(normalizeWebUrl("https://example.com/docs#section")?.href).toBe(
24
+ "https://example.com/docs",
25
+ );
26
+ });
27
+
28
+ test("drops userinfo so credentials cannot land in a saved rule", () => {
29
+ const credentialed = new URL("https://example.com/docs/page");
30
+ credentialed.username = "demo";
31
+ credentialed.password = ["c", "r", "e", "d", "1", "2", "3"].join("");
32
+
33
+ const normalized = normalizeWebUrl(credentialed.href);
34
+ expect(normalized?.href).toBe("https://example.com/docs/page");
35
+ expect(normalized?.username).toBe("");
36
+ expect(normalized?.password).toBe("");
37
+ });
38
+
39
+ test("strips a trailing root dot from the hostname", () => {
40
+ expect(normalizeWebUrl("https://example.com./docs/page")?.href).toBe(
41
+ "https://example.com/docs/page",
42
+ );
43
+ });
44
+
45
+ test("decodes escaped path segments so one path has one spelling", () => {
46
+ // Without this, a rule scoped to /private is bypassed by /%70rivate.
47
+ expect(normalizeWebUrl("https://example.com/%70rivate")?.href).toBe(
48
+ "https://example.com/private",
49
+ );
50
+ });
51
+
52
+ test("reads scheme-less input as https", () => {
53
+ expect(normalizeWebUrl("example.com/docs")?.href).toBe(
54
+ "https://example.com/docs",
55
+ );
56
+ });
57
+
58
+ test("reads host:port shorthand as an https origin, not a scheme", () => {
59
+ expect(normalizeWebUrl("example.com:8443/status")?.origin).toBe(
60
+ "https://example.com:8443",
61
+ );
62
+ expect(normalizeWebUrl("[2001:db8::1]:8443/status")?.origin).toBe(
63
+ "https://[2001:db8::1]:8443",
64
+ );
65
+ });
66
+
67
+ test("rejects path-only input rather than coercing it to a host", () => {
68
+ for (const input of ["/etc/passwd", "./rel", "../up", "?q=1", "#frag"]) {
69
+ expect(normalizeWebUrl(input)).toBeNull();
70
+ }
71
+ });
72
+
73
+ test("rejects every non-http scheme", () => {
74
+ for (const input of [
75
+ "file:///etc/passwd",
76
+ "data:text/html,<script>",
77
+ "javascript:alert(1)",
78
+ "ftp://example.com/f",
79
+ ]) {
80
+ expect(normalizeWebUrl(input)).toBeNull();
81
+ }
82
+ });
83
+
84
+ test("rejects empty and whitespace-only input", () => {
85
+ expect(normalizeWebUrl("")).toBeNull();
86
+ expect(normalizeWebUrl(" ")).toBeNull();
87
+ });
88
+
89
+ test("trims surrounding whitespace", () => {
90
+ expect(normalizeWebUrl(" https://example.com/a ")?.href).toBe(
91
+ "https://example.com/a",
92
+ );
93
+ });
94
+
95
+ test("returns null rather than throwing on an unparseable authority", () => {
96
+ expect(normalizeWebUrl("https://")).toBeNull();
97
+ });
98
+ });
99
+
100
+ describe("canonicalizeWebUrl", () => {
101
+ test("keeps the parser's form when the path is not decodable, without throwing", () => {
102
+ // `%zz` and a truncated UTF-8 sequence are not valid escapes; the path
103
+ // must survive unchanged rather than the call throwing.
104
+ expect(canonicalizeWebUrl(new URL("https://example.com/100%zz")).href).toBe(
105
+ "https://example.com/100%zz",
106
+ );
107
+ expect(
108
+ canonicalizeWebUrl(new URL("https://example.com/%E0%A4%A")).href,
109
+ ).toBe("https://example.com/%E0%A4%A");
110
+ });
111
+
112
+ test("decodes an escaped percent, so `%25` and `%` are one path", () => {
113
+ expect(canonicalizeWebUrl(new URL("https://example.com/100%25")).href).toBe(
114
+ "https://example.com/100%",
115
+ );
116
+ });
117
+ });
118
+
119
+ describe("input shape predicates", () => {
120
+ test("host:port shorthand is recognized, scheme-prefixed input is not", () => {
121
+ expect(looksLikeHostPortShorthand("example.com:8443/x")).toBe(true);
122
+ expect(looksLikeHostPortShorthand("[2001:db8::1]:443")).toBe(true);
123
+ expect(looksLikeHostPortShorthand("https://example.com/x")).toBe(false);
124
+ expect(looksLikeHostPortShorthand("example.com/x")).toBe(false);
125
+ });
126
+
127
+ test("path-only input is recognized", () => {
128
+ expect(looksLikePathOnlyInput("/abs")).toBe(true);
129
+ expect(looksLikePathOnlyInput("./rel")).toBe(true);
130
+ expect(looksLikePathOnlyInput("../up")).toBe(true);
131
+ expect(looksLikePathOnlyInput("?q=1")).toBe(true);
132
+ expect(looksLikePathOnlyInput("#frag")).toBe(true);
133
+ expect(looksLikePathOnlyInput("example.com/x")).toBe(false);
134
+ });
135
+ });
@@ -29,3 +29,4 @@ export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
30
  export * from "./remote-web-pairing.js";
31
31
  export * from "./twilio-ingress.js";
32
+ export * from "./url-normalization.js";
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Canonical URL normalization for web tool inputs, shared by the gateway's
3
+ * risk classifiers and the daemon's URL safety checks.
4
+ *
5
+ * A URL a model passes to `web_fetch` / `network_request` reaches more than
6
+ * one consumer, and they must agree on what counts as the same target: the
7
+ * gateway builds the trust-rule ladder from it, and anything that later
8
+ * compares a URL to a saved rule has to fold the same spellings together. Two
9
+ * normalizations would mean two answers, so it lives here rather than in
10
+ * either process.
11
+ *
12
+ * Data-only in spirit: no imports, no config, no I/O.
13
+ */
14
+
15
+ /** Whether a bare `host:port` (or `[v6]:port`) shorthand was written. */
16
+ export function looksLikeHostPortShorthand(value: string): boolean {
17
+ if (/^\[[0-9a-fA-F:.%]+\]:\d+(?:[/?#]|$)/.test(value)) {
18
+ return true;
19
+ }
20
+ return /^[^/?#@\s:]+:\d+(?:[/?#]|$)/.test(value);
21
+ }
22
+
23
+ /** Whether the input is a path, query, or fragment rather than a URL. */
24
+ export function looksLikePathOnlyInput(value: string): boolean {
25
+ return (
26
+ value.startsWith("/") ||
27
+ value.startsWith("./") ||
28
+ value.startsWith("../") ||
29
+ value.startsWith("?") ||
30
+ value.startsWith("#")
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Strip the parts of a URL that must not affect a trust decision, and fold
36
+ * the encodings that would otherwise let one URL wear two spellings.
37
+ *
38
+ * Percent-escaped path segments are decoded (`/%70rivate` and `/private` are
39
+ * one path, so a path-scoped rule cannot be bypassed by escaping), a trailing
40
+ * root dot is dropped from the hostname, and fragment and userinfo are
41
+ * removed: the fragment never reaches the server, and credentials in the
42
+ * authority must never end up in a saved rule.
43
+ */
44
+ export function canonicalizeWebUrl(parsed: URL): URL {
45
+ parsed.hash = "";
46
+ parsed.username = "";
47
+ parsed.password = "";
48
+
49
+ try {
50
+ parsed.pathname = decodeURI(parsed.pathname);
51
+ } catch {
52
+ // Keep the URL parser's canonical form when decoding fails.
53
+ }
54
+
55
+ if (parsed.hostname.endsWith(".")) {
56
+ parsed.hostname = parsed.hostname.replace(/\.+$/, "");
57
+ }
58
+
59
+ return parsed;
60
+ }
61
+
62
+ /**
63
+ * Parse and canonicalize a web tool's `url` input, or `null` when it is not a
64
+ * URL this system will fetch.
65
+ *
66
+ * Accepts `https://host/path`, `http://…`, bare `host/path`, and `host:port`
67
+ * shorthand. Rejects path-only input and any other scheme (`file:`, `data:`,
68
+ * `javascript:`), so a non-http target can never acquire a web trust rule.
69
+ */
70
+ export function normalizeWebUrl(rawUrl: string): URL | null {
71
+ const trimmed = rawUrl.trim();
72
+ if (!trimmed) {
73
+ return null;
74
+ }
75
+
76
+ if (looksLikeHostPortShorthand(trimmed)) {
77
+ try {
78
+ return canonicalizeWebUrl(new URL(`https://${trimmed}`));
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ try {
85
+ const parsed = new URL(trimmed);
86
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
87
+ return canonicalizeWebUrl(parsed);
88
+ }
89
+ return null;
90
+ } catch {
91
+ // Not an absolute URL; fall through to the shorthand forms.
92
+ }
93
+
94
+ if (looksLikePathOnlyInput(trimmed)) {
95
+ return null;
96
+ }
97
+
98
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) {
99
+ return null;
100
+ }
101
+
102
+ try {
103
+ return canonicalizeWebUrl(new URL(`https://${trimmed}`));
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
@@ -14,6 +14,7 @@
14
14
  "./remote-web-pairing": "./src/remote-web-pairing.ts",
15
15
  "./twilio-ingress": "./src/twilio-ingress.ts",
16
16
  "./trust-rules": "./src/trust-rules.ts",
17
+ "./url-normalization": "./src/url-normalization.ts",
17
18
  "./handles": "./src/handles.ts",
18
19
  "./rpc": "./src/rpc.ts",
19
20
  "./attachment-naming": "./src/attachment-naming.ts",
@@ -0,0 +1,135 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ canonicalizeWebUrl,
5
+ looksLikeHostPortShorthand,
6
+ looksLikePathOnlyInput,
7
+ normalizeWebUrl,
8
+ } from "../url-normalization.js";
9
+
10
+ /**
11
+ * These are the rules a trust rule is saved and matched under. A change here
12
+ * changes which saved rules still match, so each case states the target the
13
+ * two spellings must agree on.
14
+ */
15
+ describe("normalizeWebUrl", () => {
16
+ test("keeps an ordinary https URL as-is", () => {
17
+ expect(normalizeWebUrl("https://example.com/docs/page")?.href).toBe(
18
+ "https://example.com/docs/page",
19
+ );
20
+ });
21
+
22
+ test("drops the fragment: it never reaches the server", () => {
23
+ expect(normalizeWebUrl("https://example.com/docs#section")?.href).toBe(
24
+ "https://example.com/docs",
25
+ );
26
+ });
27
+
28
+ test("drops userinfo so credentials cannot land in a saved rule", () => {
29
+ const credentialed = new URL("https://example.com/docs/page");
30
+ credentialed.username = "demo";
31
+ credentialed.password = ["c", "r", "e", "d", "1", "2", "3"].join("");
32
+
33
+ const normalized = normalizeWebUrl(credentialed.href);
34
+ expect(normalized?.href).toBe("https://example.com/docs/page");
35
+ expect(normalized?.username).toBe("");
36
+ expect(normalized?.password).toBe("");
37
+ });
38
+
39
+ test("strips a trailing root dot from the hostname", () => {
40
+ expect(normalizeWebUrl("https://example.com./docs/page")?.href).toBe(
41
+ "https://example.com/docs/page",
42
+ );
43
+ });
44
+
45
+ test("decodes escaped path segments so one path has one spelling", () => {
46
+ // Without this, a rule scoped to /private is bypassed by /%70rivate.
47
+ expect(normalizeWebUrl("https://example.com/%70rivate")?.href).toBe(
48
+ "https://example.com/private",
49
+ );
50
+ });
51
+
52
+ test("reads scheme-less input as https", () => {
53
+ expect(normalizeWebUrl("example.com/docs")?.href).toBe(
54
+ "https://example.com/docs",
55
+ );
56
+ });
57
+
58
+ test("reads host:port shorthand as an https origin, not a scheme", () => {
59
+ expect(normalizeWebUrl("example.com:8443/status")?.origin).toBe(
60
+ "https://example.com:8443",
61
+ );
62
+ expect(normalizeWebUrl("[2001:db8::1]:8443/status")?.origin).toBe(
63
+ "https://[2001:db8::1]:8443",
64
+ );
65
+ });
66
+
67
+ test("rejects path-only input rather than coercing it to a host", () => {
68
+ for (const input of ["/etc/passwd", "./rel", "../up", "?q=1", "#frag"]) {
69
+ expect(normalizeWebUrl(input)).toBeNull();
70
+ }
71
+ });
72
+
73
+ test("rejects every non-http scheme", () => {
74
+ for (const input of [
75
+ "file:///etc/passwd",
76
+ "data:text/html,<script>",
77
+ "javascript:alert(1)",
78
+ "ftp://example.com/f",
79
+ ]) {
80
+ expect(normalizeWebUrl(input)).toBeNull();
81
+ }
82
+ });
83
+
84
+ test("rejects empty and whitespace-only input", () => {
85
+ expect(normalizeWebUrl("")).toBeNull();
86
+ expect(normalizeWebUrl(" ")).toBeNull();
87
+ });
88
+
89
+ test("trims surrounding whitespace", () => {
90
+ expect(normalizeWebUrl(" https://example.com/a ")?.href).toBe(
91
+ "https://example.com/a",
92
+ );
93
+ });
94
+
95
+ test("returns null rather than throwing on an unparseable authority", () => {
96
+ expect(normalizeWebUrl("https://")).toBeNull();
97
+ });
98
+ });
99
+
100
+ describe("canonicalizeWebUrl", () => {
101
+ test("keeps the parser's form when the path is not decodable, without throwing", () => {
102
+ // `%zz` and a truncated UTF-8 sequence are not valid escapes; the path
103
+ // must survive unchanged rather than the call throwing.
104
+ expect(canonicalizeWebUrl(new URL("https://example.com/100%zz")).href).toBe(
105
+ "https://example.com/100%zz",
106
+ );
107
+ expect(
108
+ canonicalizeWebUrl(new URL("https://example.com/%E0%A4%A")).href,
109
+ ).toBe("https://example.com/%E0%A4%A");
110
+ });
111
+
112
+ test("decodes an escaped percent, so `%25` and `%` are one path", () => {
113
+ expect(canonicalizeWebUrl(new URL("https://example.com/100%25")).href).toBe(
114
+ "https://example.com/100%",
115
+ );
116
+ });
117
+ });
118
+
119
+ describe("input shape predicates", () => {
120
+ test("host:port shorthand is recognized, scheme-prefixed input is not", () => {
121
+ expect(looksLikeHostPortShorthand("example.com:8443/x")).toBe(true);
122
+ expect(looksLikeHostPortShorthand("[2001:db8::1]:443")).toBe(true);
123
+ expect(looksLikeHostPortShorthand("https://example.com/x")).toBe(false);
124
+ expect(looksLikeHostPortShorthand("example.com/x")).toBe(false);
125
+ });
126
+
127
+ test("path-only input is recognized", () => {
128
+ expect(looksLikePathOnlyInput("/abs")).toBe(true);
129
+ expect(looksLikePathOnlyInput("./rel")).toBe(true);
130
+ expect(looksLikePathOnlyInput("../up")).toBe(true);
131
+ expect(looksLikePathOnlyInput("?q=1")).toBe(true);
132
+ expect(looksLikePathOnlyInput("#frag")).toBe(true);
133
+ expect(looksLikePathOnlyInput("example.com/x")).toBe(false);
134
+ });
135
+ });
@@ -29,3 +29,4 @@ export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
30
  export * from "./remote-web-pairing.js";
31
31
  export * from "./twilio-ingress.js";
32
+ export * from "./url-normalization.js";
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Canonical URL normalization for web tool inputs, shared by the gateway's
3
+ * risk classifiers and the daemon's URL safety checks.
4
+ *
5
+ * A URL a model passes to `web_fetch` / `network_request` reaches more than
6
+ * one consumer, and they must agree on what counts as the same target: the
7
+ * gateway builds the trust-rule ladder from it, and anything that later
8
+ * compares a URL to a saved rule has to fold the same spellings together. Two
9
+ * normalizations would mean two answers, so it lives here rather than in
10
+ * either process.
11
+ *
12
+ * Data-only in spirit: no imports, no config, no I/O.
13
+ */
14
+
15
+ /** Whether a bare `host:port` (or `[v6]:port`) shorthand was written. */
16
+ export function looksLikeHostPortShorthand(value: string): boolean {
17
+ if (/^\[[0-9a-fA-F:.%]+\]:\d+(?:[/?#]|$)/.test(value)) {
18
+ return true;
19
+ }
20
+ return /^[^/?#@\s:]+:\d+(?:[/?#]|$)/.test(value);
21
+ }
22
+
23
+ /** Whether the input is a path, query, or fragment rather than a URL. */
24
+ export function looksLikePathOnlyInput(value: string): boolean {
25
+ return (
26
+ value.startsWith("/") ||
27
+ value.startsWith("./") ||
28
+ value.startsWith("../") ||
29
+ value.startsWith("?") ||
30
+ value.startsWith("#")
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Strip the parts of a URL that must not affect a trust decision, and fold
36
+ * the encodings that would otherwise let one URL wear two spellings.
37
+ *
38
+ * Percent-escaped path segments are decoded (`/%70rivate` and `/private` are
39
+ * one path, so a path-scoped rule cannot be bypassed by escaping), a trailing
40
+ * root dot is dropped from the hostname, and fragment and userinfo are
41
+ * removed: the fragment never reaches the server, and credentials in the
42
+ * authority must never end up in a saved rule.
43
+ */
44
+ export function canonicalizeWebUrl(parsed: URL): URL {
45
+ parsed.hash = "";
46
+ parsed.username = "";
47
+ parsed.password = "";
48
+
49
+ try {
50
+ parsed.pathname = decodeURI(parsed.pathname);
51
+ } catch {
52
+ // Keep the URL parser's canonical form when decoding fails.
53
+ }
54
+
55
+ if (parsed.hostname.endsWith(".")) {
56
+ parsed.hostname = parsed.hostname.replace(/\.+$/, "");
57
+ }
58
+
59
+ return parsed;
60
+ }
61
+
62
+ /**
63
+ * Parse and canonicalize a web tool's `url` input, or `null` when it is not a
64
+ * URL this system will fetch.
65
+ *
66
+ * Accepts `https://host/path`, `http://…`, bare `host/path`, and `host:port`
67
+ * shorthand. Rejects path-only input and any other scheme (`file:`, `data:`,
68
+ * `javascript:`), so a non-http target can never acquire a web trust rule.
69
+ */
70
+ export function normalizeWebUrl(rawUrl: string): URL | null {
71
+ const trimmed = rawUrl.trim();
72
+ if (!trimmed) {
73
+ return null;
74
+ }
75
+
76
+ if (looksLikeHostPortShorthand(trimmed)) {
77
+ try {
78
+ return canonicalizeWebUrl(new URL(`https://${trimmed}`));
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ try {
85
+ const parsed = new URL(trimmed);
86
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
87
+ return canonicalizeWebUrl(parsed);
88
+ }
89
+ return null;
90
+ } catch {
91
+ // Not an absolute URL; fall through to the shorthand forms.
92
+ }
93
+
94
+ if (looksLikePathOnlyInput(trimmed)) {
95
+ return null;
96
+ }
97
+
98
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) {
99
+ return null;
100
+ }
101
+
102
+ try {
103
+ return canonicalizeWebUrl(new URL(`https://${trimmed}`));
104
+ } catch {
105
+ return null;
106
+ }
107
+ }