@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.
- package/AGENTS.md +37 -0
- package/README.md +0 -8
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +166 -1
- package/node_modules/@vellumai/gateway-client/src/http-delivery.ts +7 -14
- package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +29 -31
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
- package/package.json +2 -1
- package/src/__tests__/bash-risk-classifier.test.ts +1 -62
- package/src/__tests__/config.test.ts +1 -0
- package/src/__tests__/nonbash-trust-rule-overrides.test.ts +55 -0
- package/src/__tests__/slack-socket-mode-catchup.test.ts +43 -0
- package/src/__tests__/slack-socket-mode-liveness.test.ts +422 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +105 -0
- package/src/__tests__/whatsapp-download.test.ts +60 -4
- package/src/attachments/download.test.ts +80 -0
- package/src/attachments/download.ts +72 -0
- package/src/attachments/ingest.test.ts +148 -0
- package/src/attachments/ingest.ts +113 -0
- package/src/channels/conversation-queue.test.ts +64 -0
- package/src/channels/conversation-queue.ts +35 -0
- package/src/channels/inbound-event.ts +9 -7
- package/src/config.ts +11 -5
- package/src/discord/admission-log-visibility.test.ts +1 -1
- package/src/discord/attachments.ts +70 -0
- package/src/discord/download.test.ts +156 -0
- package/src/discord/download.ts +55 -0
- package/src/discord/forward.ts +134 -0
- package/src/discord/gateway-socket.test.ts +1 -1
- package/src/discord/gateway-socket.ts +16 -13
- package/src/discord/message-schemas.ts +94 -0
- package/src/discord/normalize.test.ts +140 -0
- package/src/discord/normalize.ts +5 -2
- package/src/feature-flag-registry.json +32 -32
- package/src/http/routes/telegram-webhook.ts +30 -77
- package/src/http/routes/whatsapp-webhook.test.ts +1 -0
- package/src/http/routes/whatsapp-webhook.ts +31 -92
- package/src/index.ts +53 -148
- package/src/ipc/risk-classification-handlers.ts +15 -112
- package/src/ipc/server.ts +26 -0
- package/src/risk/AGENTS.md +9 -0
- package/src/risk/bash-risk-classifier.test.ts +1 -10
- package/src/risk/bash-risk-classifier.ts +9 -37
- package/src/risk/command-registry/commands/assistant.ts +7 -0
- package/src/risk/file-risk-classifier.ts +2 -7
- package/src/risk/risk-types.ts +27 -49
- package/src/risk/schedule-risk-classifier.ts +2 -6
- package/src/risk/skill-risk-classifier.ts +2 -6
- package/src/risk/user-rule-override.ts +27 -0
- package/src/risk/web-risk-classifier.test.ts +86 -10
- package/src/risk/web-risk-classifier.ts +86 -15
- package/src/slack/attachments.ts +4 -7
- package/src/slack/download.test.ts +57 -20
- package/src/slack/download.ts +17 -24
- package/src/slack/event-kind.ts +17 -0
- package/src/slack/message-normalizer.ts +22 -6
- package/src/slack/normalize.test.ts +18 -0
- package/src/slack/socket-liveness.test.ts +200 -0
- package/src/slack/socket-liveness.ts +181 -0
- package/src/slack/socket-mode.ts +241 -41
- package/src/slack/source-metadata.test.ts +104 -0
- package/src/slack/source-metadata.ts +42 -0
- package/src/telegram/download.ts +17 -26
- package/src/types.ts +1 -0
- package/src/util/schedule.ts +25 -0
- 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:
|
package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json
CHANGED
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|
package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json
CHANGED
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|