@crewhaus/tool-navigate 0.1.4 → 0.1.6
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/dist/index.d.ts +30 -0
- package/dist/index.js +237 -0
- package/package.json +12 -9
- package/src/index.test.ts +0 -156
- package/src/index.ts +0 -248
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Driver } from "@crewhaus/computer-use-driver";
|
|
2
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
3
|
+
import type { RegisteredTool } from "@crewhaus/tool-catalog";
|
|
4
|
+
export declare class NavigateError extends CrewhausError {
|
|
5
|
+
readonly name = "NavigateError";
|
|
6
|
+
constructor(message: string, cause?: unknown);
|
|
7
|
+
}
|
|
8
|
+
export type DnsLookupFn = (hostname: string) => Promise<{
|
|
9
|
+
readonly address: string;
|
|
10
|
+
readonly family: number;
|
|
11
|
+
}>;
|
|
12
|
+
/** Test hook: override DNS resolution. Pass `undefined` to restore the default. */
|
|
13
|
+
export declare function _setDnsLookup(fn: DnsLookupFn | undefined): void;
|
|
14
|
+
/**
|
|
15
|
+
* Reject anything that isn't a public http/https target before the browser
|
|
16
|
+
* sees it. This guard blocks the direct vectors — non-http(s) schemes, IP
|
|
17
|
+
* literals, loopback/mDNS names, and names that resolve to a private range
|
|
18
|
+
* at check time. The rebinding TOCTOU this check alone cannot close (the
|
|
19
|
+
* browser re-resolves DNS at connect time, and sub-resource fetches never
|
|
20
|
+
* pass through here) is closed at the connection layer by the chromium
|
|
21
|
+
* backend's DNS-pinning proxy — see `ssrf-proxy.ts` in computer-use-driver,
|
|
22
|
+
* on by default. The remote (CDP/Browserless) backend launches its browser
|
|
23
|
+
* elsewhere, so for that backend this pre-goto guard is still the only
|
|
24
|
+
* navigate-side control; pin egress at the remote browser's host.
|
|
25
|
+
*/
|
|
26
|
+
export declare function assertSafeNavigationTarget(rawUrl: string): Promise<void>;
|
|
27
|
+
export type CreateNavigateToolOptions = {
|
|
28
|
+
readonly driver: Driver;
|
|
29
|
+
};
|
|
30
|
+
export declare function createNavigateTool(opts: CreateNavigateToolOptions): RegisteredTool;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog R4 `tool-navigate` — Section 25 BROW.
|
|
3
|
+
*
|
|
4
|
+
* `Navigate(url)` tool. Drives `driver.goto(url)` so the browser agent
|
|
5
|
+
* can bootstrap to a starting page. Returns a short text confirmation
|
|
6
|
+
* the model can read back; surfaces playwright errors verbatim (e.g.
|
|
7
|
+
* unreachable host, navigation timeout) so the model can recover or
|
|
8
|
+
* report the failure.
|
|
9
|
+
*
|
|
10
|
+
* Flag profile: `destructive: false` (default-allowed). Navigation is
|
|
11
|
+
* the agent's only bootstrap path — gating it behind an `alwaysAllow`
|
|
12
|
+
* rule would silently break every spec that doesn't define one. The
|
|
13
|
+
* §3 permission-engine still applies the user's explicit `alwaysDeny`
|
|
14
|
+
* rules; Click/Type/Key/Scroll stay destructive-by-default in
|
|
15
|
+
* `tool-mouse-keyboard`.
|
|
16
|
+
*
|
|
17
|
+
* Scope is `"external"` because the URL request crosses a network
|
|
18
|
+
* boundary. Before handing the URL to the browser we run an SSRF guard
|
|
19
|
+
* (`assertSafeNavigationTarget`): the model picks this URL and the model
|
|
20
|
+
* is prompt-injectable, so an attacker who controls any untrusted input
|
|
21
|
+
* (a channel message, fetched page, MCP result) could otherwise steer it
|
|
22
|
+
* to `file:///etc/passwd`, `http://169.254.169.254/…` (cloud metadata) or
|
|
23
|
+
* a loopback admin port and read the result back via `Screenshot`. Output
|
|
24
|
+
* classification is off — the response is a deterministic short string,
|
|
25
|
+
* not model-generated content.
|
|
26
|
+
*/
|
|
27
|
+
import { lookup as nodeDnsLookup } from "node:dns/promises";
|
|
28
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
29
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
export class NavigateError extends CrewhausError {
|
|
32
|
+
name = "NavigateError";
|
|
33
|
+
constructor(message, cause) {
|
|
34
|
+
super("tool", message, cause);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const navigateSchema = z
|
|
38
|
+
.object({
|
|
39
|
+
/** Absolute URL to navigate to (http/https). */
|
|
40
|
+
url: z.string().url(),
|
|
41
|
+
})
|
|
42
|
+
.strict();
|
|
43
|
+
/** Only real web schemes — blocks file:, gopher:, chrome:, data:, about:, etc. */
|
|
44
|
+
const ALLOWED_SCHEMES = new Set(["http:", "https:"]);
|
|
45
|
+
let dnsLookupFn = nodeDnsLookup;
|
|
46
|
+
/** Test hook: override DNS resolution. Pass `undefined` to restore the default. */
|
|
47
|
+
export function _setDnsLookup(fn) {
|
|
48
|
+
dnsLookupFn = fn ?? nodeDnsLookup;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* SSRF defenses below mirror `@crewhaus/tool-fetch` (`isPrivateIp` /
|
|
52
|
+
* `normalizeIpv4`). They are duplicated rather than imported to avoid
|
|
53
|
+
* coupling the browser bundle to tool-fetch's module-level config state —
|
|
54
|
+
* keep the copies in sync. The IPv4-mapped IPv6 handling (`::ffff:…`, both
|
|
55
|
+
* dotted and hex-compressed) is load-bearing: `new URL` normalizes
|
|
56
|
+
* `http://[::ffff:127.0.0.1]/` to `[::ffff:7f00:1]`, which would otherwise
|
|
57
|
+
* slip past a naive loopback check.
|
|
58
|
+
*/
|
|
59
|
+
function normalizeIpv4(raw) {
|
|
60
|
+
const trimmed = raw.trim();
|
|
61
|
+
if (trimmed === "")
|
|
62
|
+
return null;
|
|
63
|
+
const parseComponent = (s) => {
|
|
64
|
+
if (s === "")
|
|
65
|
+
return null;
|
|
66
|
+
let value;
|
|
67
|
+
if (/^0[xX][0-9a-fA-F]+$/.test(s)) {
|
|
68
|
+
value = Number.parseInt(s.slice(2), 16);
|
|
69
|
+
}
|
|
70
|
+
else if (/^0[0-7]+$/.test(s)) {
|
|
71
|
+
value = Number.parseInt(s, 8);
|
|
72
|
+
}
|
|
73
|
+
else if (/^[0-9]+$/.test(s)) {
|
|
74
|
+
value = Number.parseInt(s, 10);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return Number.isNaN(value) ? null : value;
|
|
80
|
+
};
|
|
81
|
+
const segments = trimmed.split(".");
|
|
82
|
+
if (segments.length > 4)
|
|
83
|
+
return null;
|
|
84
|
+
const components = [];
|
|
85
|
+
for (const seg of segments) {
|
|
86
|
+
const value = parseComponent(seg);
|
|
87
|
+
if (value === null || value < 0)
|
|
88
|
+
return null;
|
|
89
|
+
components.push(value);
|
|
90
|
+
}
|
|
91
|
+
const n = components.length;
|
|
92
|
+
const octets = [0, 0, 0, 0];
|
|
93
|
+
for (let i = 0; i < n - 1; i++) {
|
|
94
|
+
const c = components[i];
|
|
95
|
+
if (c > 255)
|
|
96
|
+
return null;
|
|
97
|
+
octets[i] = c;
|
|
98
|
+
}
|
|
99
|
+
const last = components[n - 1];
|
|
100
|
+
const maxLast = 2 ** (8 * (4 - (n - 1)));
|
|
101
|
+
if (last >= maxLast)
|
|
102
|
+
return null;
|
|
103
|
+
let rest = last;
|
|
104
|
+
for (let i = 3; i >= n - 1; i--) {
|
|
105
|
+
octets[i] = rest & 0xff;
|
|
106
|
+
rest = Math.floor(rest / 256);
|
|
107
|
+
}
|
|
108
|
+
return octets.join(".");
|
|
109
|
+
}
|
|
110
|
+
function isPrivateIp(addr) {
|
|
111
|
+
const ip = addr.replace(/^\[/, "").replace(/\]$/, "");
|
|
112
|
+
const lowerIp = ip.toLowerCase();
|
|
113
|
+
// IPv6 unspecified / loopback / link-local / unique-local
|
|
114
|
+
if (lowerIp === "::" || lowerIp === "::1")
|
|
115
|
+
return true;
|
|
116
|
+
if (lowerIp.startsWith("fe80:"))
|
|
117
|
+
return true;
|
|
118
|
+
if (lowerIp.startsWith("fc") || lowerIp.startsWith("fd"))
|
|
119
|
+
return true;
|
|
120
|
+
// IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1 / ::ffff:7f00:1) — classify by
|
|
121
|
+
// the embedded IPv4 address so a mapped literal can't smuggle past.
|
|
122
|
+
const mapped = lowerIp.match(/^::ffff:(.+)$/);
|
|
123
|
+
if (mapped) {
|
|
124
|
+
const tail = mapped[1];
|
|
125
|
+
if (tail.includes(".")) {
|
|
126
|
+
return isPrivateIp(tail);
|
|
127
|
+
}
|
|
128
|
+
const hexGroups = tail.split(":");
|
|
129
|
+
if (hexGroups.length === 2 && hexGroups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) {
|
|
130
|
+
const hi = Number.parseInt(hexGroups[0], 16);
|
|
131
|
+
const lo = Number.parseInt(hexGroups[1], 16);
|
|
132
|
+
return isPrivateIp([(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff].join("."));
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
// IPv4 — normalise octal/hex/integer literals to dotted-decimal first.
|
|
137
|
+
const normalized = normalizeIpv4(ip);
|
|
138
|
+
if (normalized === null)
|
|
139
|
+
return false;
|
|
140
|
+
const parts = normalized.split(".").map((p) => Number.parseInt(p, 10));
|
|
141
|
+
if (parts.length !== 4 || parts.some((nn) => Number.isNaN(nn) || nn < 0 || nn > 255)) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const [a, b] = parts;
|
|
145
|
+
if (a === 127)
|
|
146
|
+
return true; // 127.0.0.0/8 loopback
|
|
147
|
+
if (a === 10)
|
|
148
|
+
return true; // 10.0.0.0/8 RFC1918
|
|
149
|
+
if (a === 169 && b === 254)
|
|
150
|
+
return true; // 169.254.0.0/16 link-local + AWS metadata
|
|
151
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
152
|
+
return true; // 172.16.0.0/12 RFC1918
|
|
153
|
+
if (a === 192 && b === 168)
|
|
154
|
+
return true; // 192.168.0.0/16 RFC1918
|
|
155
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
156
|
+
return true; // 100.64.0.0/10 CGNAT (RFC6598)
|
|
157
|
+
if (a === 192 && b === 0 && parts[2] === 0)
|
|
158
|
+
return true; // 192.0.0.0/24 (RFC6890)
|
|
159
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
160
|
+
return true; // 198.18.0.0/15 (RFC2544)
|
|
161
|
+
if (a === 0)
|
|
162
|
+
return true; // 0.0.0.0/8
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Reject anything that isn't a public http/https target before the browser
|
|
167
|
+
* sees it. This guard blocks the direct vectors — non-http(s) schemes, IP
|
|
168
|
+
* literals, loopback/mDNS names, and names that resolve to a private range
|
|
169
|
+
* at check time. The rebinding TOCTOU this check alone cannot close (the
|
|
170
|
+
* browser re-resolves DNS at connect time, and sub-resource fetches never
|
|
171
|
+
* pass through here) is closed at the connection layer by the chromium
|
|
172
|
+
* backend's DNS-pinning proxy — see `ssrf-proxy.ts` in computer-use-driver,
|
|
173
|
+
* on by default. The remote (CDP/Browserless) backend launches its browser
|
|
174
|
+
* elsewhere, so for that backend this pre-goto guard is still the only
|
|
175
|
+
* navigate-side control; pin egress at the remote browser's host.
|
|
176
|
+
*/
|
|
177
|
+
export async function assertSafeNavigationTarget(rawUrl) {
|
|
178
|
+
let url;
|
|
179
|
+
try {
|
|
180
|
+
url = new URL(rawUrl);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
throw new NavigateError(`navigation to ${rawUrl} blocked: not a valid absolute URL`);
|
|
184
|
+
}
|
|
185
|
+
if (!ALLOWED_SCHEMES.has(url.protocol)) {
|
|
186
|
+
throw new NavigateError(`navigation to ${rawUrl} blocked: only http/https is allowed (got "${url.protocol}")`);
|
|
187
|
+
}
|
|
188
|
+
const hostname = url.hostname.toLowerCase();
|
|
189
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
190
|
+
throw new NavigateError(`navigation to ${rawUrl} blocked: loopback host`);
|
|
191
|
+
}
|
|
192
|
+
if (hostname.endsWith(".local")) {
|
|
193
|
+
throw new NavigateError(`navigation to ${rawUrl} blocked: mDNS host "${hostname}"`);
|
|
194
|
+
}
|
|
195
|
+
const unbracketed = hostname.replace(/^\[/, "").replace(/\]$/, "");
|
|
196
|
+
if (isPrivateIp(unbracketed)) {
|
|
197
|
+
throw new NavigateError(`navigation to ${rawUrl} blocked: private/loopback IP`);
|
|
198
|
+
}
|
|
199
|
+
// IP literals are their own target — nothing to resolve/rebind.
|
|
200
|
+
const isIpLiteral = normalizeIpv4(unbracketed) !== null || unbracketed.includes(":");
|
|
201
|
+
if (isIpLiteral)
|
|
202
|
+
return;
|
|
203
|
+
let resolved;
|
|
204
|
+
try {
|
|
205
|
+
resolved = await dnsLookupFn(unbracketed);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
throw new NavigateError(`navigation to ${rawUrl} blocked: cannot resolve host: ${err.message}`);
|
|
209
|
+
}
|
|
210
|
+
if (isPrivateIp(resolved.address)) {
|
|
211
|
+
throw new NavigateError(`navigation to ${rawUrl} blocked: host resolves to private IP ${resolved.address}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
export function createNavigateTool(opts) {
|
|
215
|
+
return buildTool({
|
|
216
|
+
name: "Navigate",
|
|
217
|
+
description: "Navigate the browser to a URL. Call this first to load a starting page (e.g. a search engine, documentation site, or known landing page) before using Screenshot/FindElement/Click/Type. Returns a short confirmation; pair with Screenshot to see what loaded.",
|
|
218
|
+
inputSchema: navigateSchema,
|
|
219
|
+
readOnly: false,
|
|
220
|
+
destructive: false,
|
|
221
|
+
concurrencySafe: false,
|
|
222
|
+
classifyOutput: false,
|
|
223
|
+
scope: "external",
|
|
224
|
+
execute: async (input) => {
|
|
225
|
+
// SSRF guard runs BEFORE the browser touches the URL. Throws
|
|
226
|
+
// NavigateError directly so the block reason surfaces unwrapped.
|
|
227
|
+
await assertSafeNavigationTarget(input.url);
|
|
228
|
+
try {
|
|
229
|
+
await opts.driver.goto(input.url);
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
throw new NavigateError(`navigation to ${input.url} failed: ${err.message}`, err);
|
|
233
|
+
}
|
|
234
|
+
return [{ type: "text", text: `navigated to ${input.url}` }];
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
}
|
package/package.json
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/tool-navigate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Navigate tool — drives driver.goto(url) for the BROW agent (Section 25 BROW)",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/computer-use-driver": "0.1.
|
|
16
|
-
"@crewhaus/errors": "0.1.
|
|
17
|
-
"@crewhaus/tool-builder": "0.1.
|
|
18
|
-
"@crewhaus/tool-catalog": "0.1.
|
|
18
|
+
"@crewhaus/computer-use-driver": "0.1.6",
|
|
19
|
+
"@crewhaus/errors": "0.1.6",
|
|
20
|
+
"@crewhaus/tool-builder": "0.1.6",
|
|
21
|
+
"@crewhaus/tool-catalog": "0.1.6",
|
|
19
22
|
"zod": "^3.23.8"
|
|
20
23
|
},
|
|
21
24
|
"license": "Apache-2.0",
|
|
@@ -36,5 +39,5 @@
|
|
|
36
39
|
"publishConfig": {
|
|
37
40
|
"access": "public"
|
|
38
41
|
},
|
|
39
|
-
"files": ["
|
|
42
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
40
43
|
}
|
package/src/index.test.ts
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import type { Driver } from "@crewhaus/computer-use-driver";
|
|
3
|
-
import { NavigateError, _setDnsLookup, createNavigateTool } from "./index.js";
|
|
4
|
-
|
|
5
|
-
type GotoRecord = { calls: string[] };
|
|
6
|
-
|
|
7
|
-
function stubDriver(record: GotoRecord, opts: { gotoErr?: Error } = {}): Driver {
|
|
8
|
-
return {
|
|
9
|
-
backend: "chromium",
|
|
10
|
-
async connect() {},
|
|
11
|
-
async goto(url: string) {
|
|
12
|
-
record.calls.push(url);
|
|
13
|
-
if (opts.gotoErr) throw opts.gotoErr;
|
|
14
|
-
},
|
|
15
|
-
async screenshot() {
|
|
16
|
-
return new Uint8Array(0);
|
|
17
|
-
},
|
|
18
|
-
async click() {},
|
|
19
|
-
async type() {},
|
|
20
|
-
async key() {},
|
|
21
|
-
async scroll() {},
|
|
22
|
-
async getViewport() {
|
|
23
|
-
return { width: 800, height: 600, devicePixelRatio: 1 };
|
|
24
|
-
},
|
|
25
|
-
async disconnect() {},
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
describe("createNavigateTool", () => {
|
|
30
|
-
// A public IP literal skips DNS resolution, keeping these tests hermetic
|
|
31
|
-
// while still passing the SSRF guard (it's a public, non-loopback address).
|
|
32
|
-
const PUBLIC_URL = "https://93.184.216.34/";
|
|
33
|
-
|
|
34
|
-
test("happy path: forwards url to driver.goto and returns a text confirmation", async () => {
|
|
35
|
-
const record: GotoRecord = { calls: [] };
|
|
36
|
-
const tool = createNavigateTool({ driver: stubDriver(record) });
|
|
37
|
-
|
|
38
|
-
const result = await tool.execute({ url: PUBLIC_URL }, {});
|
|
39
|
-
expect(record.calls).toEqual([PUBLIC_URL]);
|
|
40
|
-
expect(Array.isArray(result)).toBe(true);
|
|
41
|
-
if (!Array.isArray(result)) throw new Error("expected ToolResultContent array");
|
|
42
|
-
expect(result).toHaveLength(1);
|
|
43
|
-
const block = result[0];
|
|
44
|
-
if (block?.type !== "text") throw new Error("expected text block");
|
|
45
|
-
expect(block.text).toBe(`navigated to ${PUBLIC_URL}`);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("schema rejects non-URL strings (and missing url)", () => {
|
|
49
|
-
const record: GotoRecord = { calls: [] };
|
|
50
|
-
const tool = createNavigateTool({ driver: stubDriver(record) });
|
|
51
|
-
expect(() => tool.inputSchema.parse({ url: "not-a-url" })).toThrow();
|
|
52
|
-
expect(() => tool.inputSchema.parse({})).toThrow();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("driver.goto failure surfaces as NavigateError with the URL in the message", async () => {
|
|
56
|
-
const record: GotoRecord = { calls: [] };
|
|
57
|
-
const tool = createNavigateTool({
|
|
58
|
-
driver: stubDriver(record, { gotoErr: new Error("net::ERR_CONNECTION_REFUSED") }),
|
|
59
|
-
});
|
|
60
|
-
try {
|
|
61
|
-
// Public IP literal passes the SSRF guard and reaches driver.goto.
|
|
62
|
-
await tool.execute({ url: PUBLIC_URL }, {});
|
|
63
|
-
throw new Error("expected NavigateError to be thrown");
|
|
64
|
-
} catch (err) {
|
|
65
|
-
expect(err).toBeInstanceOf(NavigateError);
|
|
66
|
-
expect((err as Error).message).toContain(PUBLIC_URL);
|
|
67
|
-
expect((err as Error).message).toContain("net::ERR_CONNECTION_REFUSED");
|
|
68
|
-
}
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("flag profile: default-allowed, external scope, classifier off", () => {
|
|
72
|
-
const record: GotoRecord = { calls: [] };
|
|
73
|
-
const tool = createNavigateTool({ driver: stubDriver(record) });
|
|
74
|
-
// destructive: false → permission-engine allows in default mode.
|
|
75
|
-
// Navigation is the agent's bootstrap; gating it behind alwaysAllow
|
|
76
|
-
// would silently break every browser spec without explicit rules.
|
|
77
|
-
expect(tool.destructive).toBe(false);
|
|
78
|
-
expect(tool.readOnly).toBe(false);
|
|
79
|
-
expect(tool.classifyOutput).toBe(false);
|
|
80
|
-
expect(tool.scope).toBe("external");
|
|
81
|
-
expect(tool.name).toBe("Navigate");
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
// SECURITY: the model picks the navigation URL and is prompt-injectable, so
|
|
86
|
-
// an attacker who controls any untrusted input could steer it to file://,
|
|
87
|
-
// cloud metadata, or a loopback/private host and read the result back via
|
|
88
|
-
// Screenshot. The guard must run before driver.goto.
|
|
89
|
-
describe("Navigate SSRF guard", () => {
|
|
90
|
-
afterEach(() => _setDnsLookup(undefined));
|
|
91
|
-
|
|
92
|
-
const expectBlocked = async (url: string) => {
|
|
93
|
-
const record: GotoRecord = { calls: [] };
|
|
94
|
-
const tool = createNavigateTool({ driver: stubDriver(record) });
|
|
95
|
-
let threw: unknown;
|
|
96
|
-
try {
|
|
97
|
-
await tool.execute({ url }, {});
|
|
98
|
-
} catch (err) {
|
|
99
|
-
threw = err;
|
|
100
|
-
}
|
|
101
|
-
expect(threw).toBeInstanceOf(NavigateError);
|
|
102
|
-
// The browser must never have been touched.
|
|
103
|
-
expect(record.calls).toEqual([]);
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
test.each([
|
|
107
|
-
["file scheme", "file:///etc/passwd"],
|
|
108
|
-
["gopher scheme", "gopher://127.0.0.1:6379/"],
|
|
109
|
-
["data scheme", "data:text/html,<h1>x</h1>"],
|
|
110
|
-
["loopback IPv4", "http://127.0.0.1/"],
|
|
111
|
-
["loopback name", "http://localhost:8080/admin"],
|
|
112
|
-
["AWS/GCP metadata", "http://169.254.169.254/latest/meta-data/iam/security-credentials/"],
|
|
113
|
-
["RFC1918 10.x", "http://10.0.0.5/"],
|
|
114
|
-
["RFC1918 192.168", "http://192.168.1.1/"],
|
|
115
|
-
["RFC1918 172.16", "http://172.16.0.1/"],
|
|
116
|
-
["CGNAT 100.64", "http://100.64.0.1/"],
|
|
117
|
-
["0.0.0.0", "http://0.0.0.0/"],
|
|
118
|
-
["IPv6 loopback", "http://[::1]/"],
|
|
119
|
-
["IPv4-mapped IPv6 loopback", "http://[::ffff:127.0.0.1]/"],
|
|
120
|
-
["IPv4-mapped IPv6 metadata", "http://[::ffff:169.254.169.254]/"],
|
|
121
|
-
["octal-encoded loopback", "http://0177.0.0.1/"],
|
|
122
|
-
["integer-encoded loopback", "http://2130706433/"],
|
|
123
|
-
["mDNS .local", "http://printer.local/"],
|
|
124
|
-
])("blocks %s", async (_label, url) => {
|
|
125
|
-
await expectBlocked(url);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("blocks a public hostname that resolves to a private IP (DNS rebinding at check time)", async () => {
|
|
129
|
-
_setDnsLookup(async () => ({ address: "127.0.0.1", family: 4 }));
|
|
130
|
-
const record: GotoRecord = { calls: [] };
|
|
131
|
-
const tool = createNavigateTool({ driver: stubDriver(record) });
|
|
132
|
-
await expect(
|
|
133
|
-
tool.execute({ url: "http://totally-innocent.example/" }, {}),
|
|
134
|
-
).rejects.toBeInstanceOf(NavigateError);
|
|
135
|
-
expect(record.calls).toEqual([]);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test("allows a public hostname that resolves to a public IP", async () => {
|
|
139
|
-
_setDnsLookup(async () => ({ address: "93.184.216.34", family: 4 }));
|
|
140
|
-
const record: GotoRecord = { calls: [] };
|
|
141
|
-
const tool = createNavigateTool({ driver: stubDriver(record) });
|
|
142
|
-
await tool.execute({ url: "http://example.com/" }, {});
|
|
143
|
-
expect(record.calls).toEqual(["http://example.com/"]);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("allows a public IP literal without any DNS lookup", async () => {
|
|
147
|
-
// If DNS were consulted for an IP literal this would throw.
|
|
148
|
-
_setDnsLookup(async () => {
|
|
149
|
-
throw new Error("DNS must not be called for an IP literal");
|
|
150
|
-
});
|
|
151
|
-
const record: GotoRecord = { calls: [] };
|
|
152
|
-
const tool = createNavigateTool({ driver: stubDriver(record) });
|
|
153
|
-
await tool.execute({ url: "https://93.184.216.34/" }, {});
|
|
154
|
-
expect(record.calls).toEqual(["https://93.184.216.34/"]);
|
|
155
|
-
});
|
|
156
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Catalog R4 `tool-navigate` — Section 25 BROW.
|
|
3
|
-
*
|
|
4
|
-
* `Navigate(url)` tool. Drives `driver.goto(url)` so the browser agent
|
|
5
|
-
* can bootstrap to a starting page. Returns a short text confirmation
|
|
6
|
-
* the model can read back; surfaces playwright errors verbatim (e.g.
|
|
7
|
-
* unreachable host, navigation timeout) so the model can recover or
|
|
8
|
-
* report the failure.
|
|
9
|
-
*
|
|
10
|
-
* Flag profile: `destructive: false` (default-allowed). Navigation is
|
|
11
|
-
* the agent's only bootstrap path — gating it behind an `alwaysAllow`
|
|
12
|
-
* rule would silently break every spec that doesn't define one. The
|
|
13
|
-
* §3 permission-engine still applies the user's explicit `alwaysDeny`
|
|
14
|
-
* rules; Click/Type/Key/Scroll stay destructive-by-default in
|
|
15
|
-
* `tool-mouse-keyboard`.
|
|
16
|
-
*
|
|
17
|
-
* Scope is `"external"` because the URL request crosses a network
|
|
18
|
-
* boundary. Before handing the URL to the browser we run an SSRF guard
|
|
19
|
-
* (`assertSafeNavigationTarget`): the model picks this URL and the model
|
|
20
|
-
* is prompt-injectable, so an attacker who controls any untrusted input
|
|
21
|
-
* (a channel message, fetched page, MCP result) could otherwise steer it
|
|
22
|
-
* to `file:///etc/passwd`, `http://169.254.169.254/…` (cloud metadata) or
|
|
23
|
-
* a loopback admin port and read the result back via `Screenshot`. Output
|
|
24
|
-
* classification is off — the response is a deterministic short string,
|
|
25
|
-
* not model-generated content.
|
|
26
|
-
*/
|
|
27
|
-
import { lookup as nodeDnsLookup } from "node:dns/promises";
|
|
28
|
-
import type { Driver } from "@crewhaus/computer-use-driver";
|
|
29
|
-
import { CrewhausError } from "@crewhaus/errors";
|
|
30
|
-
import { buildTool } from "@crewhaus/tool-builder";
|
|
31
|
-
import type { RegisteredTool, ToolResultContent } from "@crewhaus/tool-catalog";
|
|
32
|
-
import { z } from "zod";
|
|
33
|
-
|
|
34
|
-
export class NavigateError extends CrewhausError {
|
|
35
|
-
override readonly name = "NavigateError";
|
|
36
|
-
constructor(message: string, cause?: unknown) {
|
|
37
|
-
super("tool", message, cause);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const navigateSchema = z
|
|
42
|
-
.object({
|
|
43
|
-
/** Absolute URL to navigate to (http/https). */
|
|
44
|
-
url: z.string().url(),
|
|
45
|
-
})
|
|
46
|
-
.strict();
|
|
47
|
-
|
|
48
|
-
/** Only real web schemes — blocks file:, gopher:, chrome:, data:, about:, etc. */
|
|
49
|
-
const ALLOWED_SCHEMES = new Set(["http:", "https:"]);
|
|
50
|
-
|
|
51
|
-
export type DnsLookupFn = (
|
|
52
|
-
hostname: string,
|
|
53
|
-
) => Promise<{ readonly address: string; readonly family: number }>;
|
|
54
|
-
|
|
55
|
-
let dnsLookupFn: DnsLookupFn = nodeDnsLookup;
|
|
56
|
-
|
|
57
|
-
/** Test hook: override DNS resolution. Pass `undefined` to restore the default. */
|
|
58
|
-
export function _setDnsLookup(fn: DnsLookupFn | undefined): void {
|
|
59
|
-
dnsLookupFn = fn ?? nodeDnsLookup;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* SSRF defenses below mirror `@crewhaus/tool-fetch` (`isPrivateIp` /
|
|
64
|
-
* `normalizeIpv4`). They are duplicated rather than imported to avoid
|
|
65
|
-
* coupling the browser bundle to tool-fetch's module-level config state —
|
|
66
|
-
* keep the copies in sync. The IPv4-mapped IPv6 handling (`::ffff:…`, both
|
|
67
|
-
* dotted and hex-compressed) is load-bearing: `new URL` normalizes
|
|
68
|
-
* `http://[::ffff:127.0.0.1]/` to `[::ffff:7f00:1]`, which would otherwise
|
|
69
|
-
* slip past a naive loopback check.
|
|
70
|
-
*/
|
|
71
|
-
function normalizeIpv4(raw: string): string | null {
|
|
72
|
-
const trimmed = raw.trim();
|
|
73
|
-
if (trimmed === "") return null;
|
|
74
|
-
|
|
75
|
-
const parseComponent = (s: string): number | null => {
|
|
76
|
-
if (s === "") return null;
|
|
77
|
-
let value: number;
|
|
78
|
-
if (/^0[xX][0-9a-fA-F]+$/.test(s)) {
|
|
79
|
-
value = Number.parseInt(s.slice(2), 16);
|
|
80
|
-
} else if (/^0[0-7]+$/.test(s)) {
|
|
81
|
-
value = Number.parseInt(s, 8);
|
|
82
|
-
} else if (/^[0-9]+$/.test(s)) {
|
|
83
|
-
value = Number.parseInt(s, 10);
|
|
84
|
-
} else {
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
return Number.isNaN(value) ? null : value;
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
const segments = trimmed.split(".");
|
|
91
|
-
if (segments.length > 4) return null;
|
|
92
|
-
|
|
93
|
-
const components: number[] = [];
|
|
94
|
-
for (const seg of segments) {
|
|
95
|
-
const value = parseComponent(seg);
|
|
96
|
-
if (value === null || value < 0) return null;
|
|
97
|
-
components.push(value);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const n = components.length;
|
|
101
|
-
const octets = [0, 0, 0, 0];
|
|
102
|
-
for (let i = 0; i < n - 1; i++) {
|
|
103
|
-
const c = components[i] as number;
|
|
104
|
-
if (c > 255) return null;
|
|
105
|
-
octets[i] = c;
|
|
106
|
-
}
|
|
107
|
-
const last = components[n - 1] as number;
|
|
108
|
-
const maxLast = 2 ** (8 * (4 - (n - 1)));
|
|
109
|
-
if (last >= maxLast) return null;
|
|
110
|
-
let rest = last;
|
|
111
|
-
for (let i = 3; i >= n - 1; i--) {
|
|
112
|
-
octets[i] = rest & 0xff;
|
|
113
|
-
rest = Math.floor(rest / 256);
|
|
114
|
-
}
|
|
115
|
-
return octets.join(".");
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function isPrivateIp(addr: string): boolean {
|
|
119
|
-
const ip = addr.replace(/^\[/, "").replace(/\]$/, "");
|
|
120
|
-
const lowerIp = ip.toLowerCase();
|
|
121
|
-
|
|
122
|
-
// IPv6 unspecified / loopback / link-local / unique-local
|
|
123
|
-
if (lowerIp === "::" || lowerIp === "::1") return true;
|
|
124
|
-
if (lowerIp.startsWith("fe80:")) return true;
|
|
125
|
-
if (lowerIp.startsWith("fc") || lowerIp.startsWith("fd")) return true;
|
|
126
|
-
|
|
127
|
-
// IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1 / ::ffff:7f00:1) — classify by
|
|
128
|
-
// the embedded IPv4 address so a mapped literal can't smuggle past.
|
|
129
|
-
const mapped = lowerIp.match(/^::ffff:(.+)$/);
|
|
130
|
-
if (mapped) {
|
|
131
|
-
const tail = mapped[1] as string;
|
|
132
|
-
if (tail.includes(".")) {
|
|
133
|
-
return isPrivateIp(tail);
|
|
134
|
-
}
|
|
135
|
-
const hexGroups = tail.split(":");
|
|
136
|
-
if (hexGroups.length === 2 && hexGroups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) {
|
|
137
|
-
const hi = Number.parseInt(hexGroups[0] as string, 16);
|
|
138
|
-
const lo = Number.parseInt(hexGroups[1] as string, 16);
|
|
139
|
-
return isPrivateIp([(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff].join("."));
|
|
140
|
-
}
|
|
141
|
-
return false;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// IPv4 — normalise octal/hex/integer literals to dotted-decimal first.
|
|
145
|
-
const normalized = normalizeIpv4(ip);
|
|
146
|
-
if (normalized === null) return false;
|
|
147
|
-
const parts = normalized.split(".").map((p) => Number.parseInt(p, 10));
|
|
148
|
-
if (parts.length !== 4 || parts.some((nn) => Number.isNaN(nn) || nn < 0 || nn > 255)) {
|
|
149
|
-
return false;
|
|
150
|
-
}
|
|
151
|
-
const [a, b] = parts as [number, number, number, number];
|
|
152
|
-
|
|
153
|
-
if (a === 127) return true; // 127.0.0.0/8 loopback
|
|
154
|
-
if (a === 10) return true; // 10.0.0.0/8 RFC1918
|
|
155
|
-
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + AWS metadata
|
|
156
|
-
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 RFC1918
|
|
157
|
-
if (a === 192 && b === 168) return true; // 192.168.0.0/16 RFC1918
|
|
158
|
-
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT (RFC6598)
|
|
159
|
-
if (a === 192 && b === 0 && parts[2] === 0) return true; // 192.0.0.0/24 (RFC6890)
|
|
160
|
-
if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 (RFC2544)
|
|
161
|
-
if (a === 0) return true; // 0.0.0.0/8
|
|
162
|
-
return false;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Reject anything that isn't a public http/https target before the browser
|
|
167
|
-
* sees it. This guard blocks the direct vectors — non-http(s) schemes, IP
|
|
168
|
-
* literals, loopback/mDNS names, and names that resolve to a private range
|
|
169
|
-
* at check time. The rebinding TOCTOU this check alone cannot close (the
|
|
170
|
-
* browser re-resolves DNS at connect time, and sub-resource fetches never
|
|
171
|
-
* pass through here) is closed at the connection layer by the chromium
|
|
172
|
-
* backend's DNS-pinning proxy — see `ssrf-proxy.ts` in computer-use-driver,
|
|
173
|
-
* on by default. The remote (CDP/Browserless) backend launches its browser
|
|
174
|
-
* elsewhere, so for that backend this pre-goto guard is still the only
|
|
175
|
-
* navigate-side control; pin egress at the remote browser's host.
|
|
176
|
-
*/
|
|
177
|
-
export async function assertSafeNavigationTarget(rawUrl: string): Promise<void> {
|
|
178
|
-
let url: URL;
|
|
179
|
-
try {
|
|
180
|
-
url = new URL(rawUrl);
|
|
181
|
-
} catch {
|
|
182
|
-
throw new NavigateError(`navigation to ${rawUrl} blocked: not a valid absolute URL`);
|
|
183
|
-
}
|
|
184
|
-
if (!ALLOWED_SCHEMES.has(url.protocol)) {
|
|
185
|
-
throw new NavigateError(
|
|
186
|
-
`navigation to ${rawUrl} blocked: only http/https is allowed (got "${url.protocol}")`,
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
const hostname = url.hostname.toLowerCase();
|
|
190
|
-
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
191
|
-
throw new NavigateError(`navigation to ${rawUrl} blocked: loopback host`);
|
|
192
|
-
}
|
|
193
|
-
if (hostname.endsWith(".local")) {
|
|
194
|
-
throw new NavigateError(`navigation to ${rawUrl} blocked: mDNS host "${hostname}"`);
|
|
195
|
-
}
|
|
196
|
-
const unbracketed = hostname.replace(/^\[/, "").replace(/\]$/, "");
|
|
197
|
-
if (isPrivateIp(unbracketed)) {
|
|
198
|
-
throw new NavigateError(`navigation to ${rawUrl} blocked: private/loopback IP`);
|
|
199
|
-
}
|
|
200
|
-
// IP literals are their own target — nothing to resolve/rebind.
|
|
201
|
-
const isIpLiteral = normalizeIpv4(unbracketed) !== null || unbracketed.includes(":");
|
|
202
|
-
if (isIpLiteral) return;
|
|
203
|
-
let resolved: { readonly address: string };
|
|
204
|
-
try {
|
|
205
|
-
resolved = await dnsLookupFn(unbracketed);
|
|
206
|
-
} catch (err) {
|
|
207
|
-
throw new NavigateError(
|
|
208
|
-
`navigation to ${rawUrl} blocked: cannot resolve host: ${(err as Error).message}`,
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
if (isPrivateIp(resolved.address)) {
|
|
212
|
-
throw new NavigateError(
|
|
213
|
-
`navigation to ${rawUrl} blocked: host resolves to private IP ${resolved.address}`,
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export type CreateNavigateToolOptions = {
|
|
219
|
-
readonly driver: Driver;
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
export function createNavigateTool(opts: CreateNavigateToolOptions): RegisteredTool {
|
|
223
|
-
return buildTool({
|
|
224
|
-
name: "Navigate",
|
|
225
|
-
description:
|
|
226
|
-
"Navigate the browser to a URL. Call this first to load a starting page (e.g. a search engine, documentation site, or known landing page) before using Screenshot/FindElement/Click/Type. Returns a short confirmation; pair with Screenshot to see what loaded.",
|
|
227
|
-
inputSchema: navigateSchema,
|
|
228
|
-
readOnly: false,
|
|
229
|
-
destructive: false,
|
|
230
|
-
concurrencySafe: false,
|
|
231
|
-
classifyOutput: false,
|
|
232
|
-
scope: "external",
|
|
233
|
-
execute: async (input): Promise<ToolResultContent> => {
|
|
234
|
-
// SSRF guard runs BEFORE the browser touches the URL. Throws
|
|
235
|
-
// NavigateError directly so the block reason surfaces unwrapped.
|
|
236
|
-
await assertSafeNavigationTarget(input.url);
|
|
237
|
-
try {
|
|
238
|
-
await opts.driver.goto(input.url);
|
|
239
|
-
} catch (err) {
|
|
240
|
-
throw new NavigateError(
|
|
241
|
-
`navigation to ${input.url} failed: ${(err as Error).message}`,
|
|
242
|
-
err,
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
return [{ type: "text", text: `navigated to ${input.url}` }];
|
|
246
|
-
},
|
|
247
|
-
});
|
|
248
|
-
}
|