@dbx-tools/appkit-web-search 0.3.21
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/README.md +234 -0
- package/index.ts +21 -0
- package/package.json +54 -0
- package/src/allowlist.ts +143 -0
- package/src/config.ts +282 -0
- package/src/fetch.ts +118 -0
- package/src/plugin.ts +87 -0
- package/src/provider.ts +90 -0
- package/src/runtime.ts +40 -0
- package/src/schema.ts +116 -0
- package/src/scrape.ts +116 -0
- package/src/search.ts +257 -0
- package/src/tool.ts +144 -0
- package/tsconfig.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# @dbx-tools/appkit-web-search
|
|
2
|
+
|
|
3
|
+
Server-side web-search runtime, Mastra tools, and AppKit plugin.
|
|
4
|
+
|
|
5
|
+
Import this package when an AppKit or Mastra backend needs to search the web and
|
|
6
|
+
read pages. `web_search` runs on the
|
|
7
|
+
[Databricks Model Serving native web-search tool](https://docs.databricks.com/aws/en/machine-learning/model-serving/web-search):
|
|
8
|
+
the model searches the web server-side and returns a synthesized answer plus the
|
|
9
|
+
sources it used - no third-party search API key. `web_fetch` reads a single page
|
|
10
|
+
through [`got-scraping`](https://www.npmjs.com/package/got-scraping) (browser-like
|
|
11
|
+
TLS + header fingerprints so a fetch survives common bot walls), which Databricks
|
|
12
|
+
has no equivalent for.
|
|
13
|
+
|
|
14
|
+
Key features:
|
|
15
|
+
|
|
16
|
+
- AppKit plugin registration that resolves and logs the effective policy at boot.
|
|
17
|
+
- Two Mastra tools: `web_search` (answer + citations) and `web_fetch` (page
|
|
18
|
+
contents as readable text or raw HTML).
|
|
19
|
+
- `web_search` resolves its OWN web-search-capable model - defaulting to Gemini,
|
|
20
|
+
then GPT - independently of the calling agent's chat model (which may not
|
|
21
|
+
support web search). Loose names (`"gemini"`, `"gpt"`) fuzzy-match the live
|
|
22
|
+
catalogue via [`@dbx-tools/model`](../model).
|
|
23
|
+
- A built-in provider -> tool-spec map (OpenAI Responses API
|
|
24
|
+
`{"type":"web_search"}`, Gemini Chat Completions `{"google_search":{}}`),
|
|
25
|
+
overridable per provider via the `WEB_SEARCH_TOOLS` setting.
|
|
26
|
+
- An optional URL allow-list (globs or bare hosts) built on
|
|
27
|
+
[`@dbx-tools/path`](../path)'s `match` matcher: `web_search` silently filters
|
|
28
|
+
disallowed citations, `web_fetch` refuses a disallowed URL.
|
|
29
|
+
- Per-tool approval gating: gate every call, or (for `web_fetch`) only calls
|
|
30
|
+
whose URL matches a pattern, mapped onto Mastra's `requireApproval`.
|
|
31
|
+
|
|
32
|
+
## Why Use This Over Native AppKit
|
|
33
|
+
|
|
34
|
+
AppKit has no first-party web-search or page-fetch surface. Use this package when
|
|
35
|
+
an agent needs to look things up on the open web or read a URL the user pasted,
|
|
36
|
+
with a policy layer (allow-list + optional approval) around it so a deployment
|
|
37
|
+
controls which sites are reachable and which calls pause for a human. Crucially,
|
|
38
|
+
the web-search tool resolves its own web-search-capable model, so an agent
|
|
39
|
+
running on any chat model (including one without web search) can still search. It
|
|
40
|
+
is a thin add-on in the same shape as [`@dbx-tools/email`](../email): a Mastra
|
|
41
|
+
tool pair plus an AppKit plugin that primes their shared runtime.
|
|
42
|
+
|
|
43
|
+
## Register The AppKit Plugin
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { createApp, lakebase, server } from "@databricks/appkit";
|
|
47
|
+
import { plugin as webSearchPlugin, tool as webTool } from "@dbx-tools/appkit-web-search";
|
|
48
|
+
import { agents, plugin as mastraPlugin } from "@dbx-tools/appkit-mastra";
|
|
49
|
+
|
|
50
|
+
const researcher = agents.createAgent({
|
|
51
|
+
instructions: "Research questions using web_search, then read sources with web_fetch.",
|
|
52
|
+
tools: () => ({
|
|
53
|
+
web_search: webTool.webSearchTool(),
|
|
54
|
+
web_fetch: webTool.webFetchTool(),
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await createApp({
|
|
59
|
+
plugins: [
|
|
60
|
+
server(),
|
|
61
|
+
lakebase(),
|
|
62
|
+
webSearchPlugin.webSearch({
|
|
63
|
+
model: "gemini", // defaults to Gemini, then GPT, when omitted
|
|
64
|
+
allowedUrls: ["*.databricks.com", "docs.example.com"],
|
|
65
|
+
}),
|
|
66
|
+
mastraPlugin.mastra({ agents: researcher, storage: true }),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`plugin.webSearch()` resolves config (over env), compiles the allow-list, and
|
|
72
|
+
primes the shared runtime the tools reuse. `tool.webSearchTool()` /
|
|
73
|
+
`tool.webFetchTool()` build the two Mastra tools. Approval, when enabled,
|
|
74
|
+
requires Mastra storage, so register `lakebase()` or configure storage in the
|
|
75
|
+
Mastra plugin.
|
|
76
|
+
|
|
77
|
+
## Choose The Web-Search Model
|
|
78
|
+
|
|
79
|
+
The native web-search tool only runs on certain models, and it is provider-
|
|
80
|
+
specific. This package resolves a web-search-capable model INDEPENDENTLY of the
|
|
81
|
+
agent's chat model, so an agent on any model can still search:
|
|
82
|
+
|
|
83
|
+
- Default preference: an appropriate **Gemini**, then **GPT** (`modelFallbacks`).
|
|
84
|
+
- Pin one via `model` (or `WEB_SEARCH_MODEL`): an endpoint name
|
|
85
|
+
(`"databricks-gemini-3-pro"`), a loose name (`"gemini"`, `"gpt"`), or a
|
|
86
|
+
capability class - all fuzzy-matched against the live catalogue.
|
|
87
|
+
- Per call, the model can pass a `model` argument to override.
|
|
88
|
+
- If an explicitly requested model doesn't support web search (e.g. a Claude or
|
|
89
|
+
Llama endpoint), the tool errors rather than silently searching with the wrong
|
|
90
|
+
thing. When nothing is pinned, unsupported fallbacks are skipped.
|
|
91
|
+
|
|
92
|
+
Model resolution runs against the LIVE workspace catalogue (via
|
|
93
|
+
[`@dbx-tools/model`](../model)), so it only ever picks an endpoint that is
|
|
94
|
+
actually deployed - a configured id that doesn't exist is never called.
|
|
95
|
+
|
|
96
|
+
### Scrape Fallback
|
|
97
|
+
|
|
98
|
+
The native tool is always preferred. But when the workspace has NO deployed
|
|
99
|
+
GPT/Gemini endpoint (so native web search can't run at all), the tool falls
|
|
100
|
+
back to a DuckDuckGo scrape (via `got-scraping`) so it still returns results
|
|
101
|
+
instead of erroring. Fallback results set `model` to `"scrape:duckduckgo"` and
|
|
102
|
+
carry the substance in `citations` (there is no model synthesizing an answer,
|
|
103
|
+
so `answer` is a lead-in over the top results). It is enabled by default; set
|
|
104
|
+
`scrapeFallback: false` (or `WEB_SEARCH_SCRAPE_FALLBACK=0`) to require a native
|
|
105
|
+
model and error when none is deployed.
|
|
106
|
+
|
|
107
|
+
The right provider tool-spec is selected automatically from the resolved model:
|
|
108
|
+
OpenAI GPT uses the Responses API `{"type":"web_search"}`; Gemini uses Chat
|
|
109
|
+
Completions `{"google_search":{}}`. Override or extend that map per provider with
|
|
110
|
+
the `webSearchTools` setting (env `WEB_SEARCH_TOOLS`, JSON) as the platform
|
|
111
|
+
evolves:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
plugin.webSearch({
|
|
115
|
+
model: "databricks-gemini-3-pro",
|
|
116
|
+
webSearchTools: { gemini: { tool: { google_search: {} } } },
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Search And Fetch Without An Agent
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { search, fetch, runtime } from "@dbx-tools/appkit-web-search";
|
|
124
|
+
import { getExecutionContext } from "@databricks/appkit";
|
|
125
|
+
|
|
126
|
+
// Prime the shared runtime once (or let the plugin do it at setup):
|
|
127
|
+
runtime.getWebSearchRuntime({ model: "gemini", allowedUrls: ["*.databricks.com"] });
|
|
128
|
+
|
|
129
|
+
// web_search needs the OBO client + host from the active execution context:
|
|
130
|
+
const ctx = getExecutionContext();
|
|
131
|
+
const host = (await ctx.client.config.getHost()).toString();
|
|
132
|
+
|
|
133
|
+
const result = await search.runWebSearch(
|
|
134
|
+
{ query: "unity catalog lineage best practices" },
|
|
135
|
+
runtime.getWebSearchRuntime().config,
|
|
136
|
+
{ client: ctx.client, host },
|
|
137
|
+
);
|
|
138
|
+
console.log(result.answer, result.citations, result.model);
|
|
139
|
+
|
|
140
|
+
const page = await fetch.runWebFetch(
|
|
141
|
+
{ url: result.citations[0]!.url, format: "text" },
|
|
142
|
+
runtime.getWebSearchRuntime().config,
|
|
143
|
+
);
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Use direct calls for operational lookups, tests, or admin flows where a model is
|
|
147
|
+
not involved. The same resolved runtime is used by the AppKit plugin and tools.
|
|
148
|
+
|
|
149
|
+
## Restrict Which URLs Are Reachable
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { allowlist } from "@dbx-tools/appkit-web-search";
|
|
153
|
+
|
|
154
|
+
const list = allowlist.toUrlAllowList(
|
|
155
|
+
allowlist.parseAllowedUrls(["*.databricks.com", "docs.example.com/api/**"]),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
list.allows("https://docs.databricks.com/aws/en/index.html"); // true
|
|
159
|
+
list.allows("https://evil.example.com/"); // false
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Each entry is a glob compiled by [`@dbx-tools/path`](../path)'s `match` matcher.
|
|
163
|
+
A host entry (no path, e.g. `databricks.com` or `*.databricks.com`) matches the
|
|
164
|
+
URL's hostname, and a bare host also matches its subdomains; a path entry
|
|
165
|
+
(`docs.example.com/api/**`) matches host + pathname. Enforcement is asymmetric by
|
|
166
|
+
design: `web_search` citations are silently filtered to the permitted set (the
|
|
167
|
+
model never surfaces a source it then can't fetch), while an explicit `web_fetch`
|
|
168
|
+
of a disallowed URL is refused with an error (a visible, correctable mistake). An
|
|
169
|
+
empty / absent allow-list permits everything.
|
|
170
|
+
|
|
171
|
+
## Gate Calls For Approval
|
|
172
|
+
|
|
173
|
+
Both tools run without approval by default. Pass `approval` to a tool (or set it
|
|
174
|
+
plugin-wide) to require a human click. `true` gates every call; a URL pattern (or
|
|
175
|
+
list) gates only calls whose URL matches - a `web_fetch` is evaluated precisely
|
|
176
|
+
against its target URL.
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
import { tool } from "@dbx-tools/appkit-web-search";
|
|
180
|
+
|
|
181
|
+
// Every fetch requires approval:
|
|
182
|
+
tool.webFetchTool({ approval: true });
|
|
183
|
+
|
|
184
|
+
// Only fetches of an internal domain require approval:
|
|
185
|
+
tool.webFetchTool({ approval: "*.internal.example.com" });
|
|
186
|
+
|
|
187
|
+
// Plugin-wide default (tools inherit unless they set their own):
|
|
188
|
+
plugin.webSearch({ approval: ["*.internal.example.com", "*.corp.example.com"] });
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Approval uses the same glob syntax as the allow-list, so the two policies read
|
|
192
|
+
identically.
|
|
193
|
+
|
|
194
|
+
## Configuration
|
|
195
|
+
|
|
196
|
+
Resolution order is explicit config first, then env vars, then a built-in
|
|
197
|
+
default:
|
|
198
|
+
|
|
199
|
+
- `WEB_SEARCH_MODEL` (default web-search model; else the Gemini->GPT fallbacks);
|
|
200
|
+
- `WEB_SEARCH_MODEL_FALLBACKS` (comma/space-separated candidate ids);
|
|
201
|
+
- `WEB_SEARCH_TOOLS` (JSON provider -> tool-spec override map);
|
|
202
|
+
- `WEB_SEARCH_FUZZY` / `WEB_SEARCH_FUZZY_THRESHOLD` (loose-name matching; default on / 0.4);
|
|
203
|
+
- `WEB_SEARCH_MAX_CITATIONS` (default 10);
|
|
204
|
+
- `WEB_SEARCH_FETCH_MAX_LENGTH` (default 50000 characters);
|
|
205
|
+
- `WEB_SEARCH_TIMEOUT_MS` (default 30000);
|
|
206
|
+
- `WEB_SEARCH_SCRAPE_FALLBACK` (fall back to a DuckDuckGo scrape when no native
|
|
207
|
+
web-search model is deployed; default on);
|
|
208
|
+
- `WEB_SEARCH_ALLOWED_URLS` (comma/space-separated globs; empty = unrestricted).
|
|
209
|
+
|
|
210
|
+
The plugin's `maxCitations` / `fetchMaxLength` are hard caps: a per-call request
|
|
211
|
+
may narrow them but never exceed them.
|
|
212
|
+
|
|
213
|
+
Requirements: the native web-search tool runs on pay-per-token GPT / Gemini
|
|
214
|
+
serving endpoints with cross-region processing enabled; it is unavailable on
|
|
215
|
+
provisioned throughput, for external models, or under HIPAA/BAA compliance. See
|
|
216
|
+
the [Databricks docs](https://docs.databricks.com/aws/en/machine-learning/model-serving/web-search).
|
|
217
|
+
|
|
218
|
+
## Modules
|
|
219
|
+
|
|
220
|
+
- `plugin` - `WebSearchPlugin`, `webSearch()` AppKit plugin factory.
|
|
221
|
+
- `tool` - `webSearchTool()` / `webFetchTool()` Mastra tools.
|
|
222
|
+
- `search` - `runWebSearch()` over the Databricks native web-search tool.
|
|
223
|
+
- `provider` - provider detection + the provider -> tool-spec map.
|
|
224
|
+
- `scrape` - `runScrapeSearch()` DuckDuckGo fallback for workspaces with no
|
|
225
|
+
native web-search model.
|
|
226
|
+
- `fetch` - `runWebFetch()` over got-scraping, plus `htmlToText()`.
|
|
227
|
+
- `runtime` - shared runtime, `getWebSearchRuntime()`, `resetWebSearchRuntime()`.
|
|
228
|
+
- `config` - config types, JSON schema, `resolveWebSearchConfig()`, approval helpers.
|
|
229
|
+
- `allowlist` - URL allow-list parsing/compiling on top of `@dbx-tools/path`.
|
|
230
|
+
- `schema` - zod tool contracts and inferred types.
|
|
231
|
+
|
|
232
|
+
Pair this package with [`@dbx-tools/appkit-mastra`](../appkit-mastra) to spread
|
|
233
|
+
the tools into an agent (and resolve its own model via [`@dbx-tools/model`](../model)),
|
|
234
|
+
and [`@dbx-tools/email`](../email) for the sibling approval-gated tool pattern.
|
package/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// GENERATED by projen watch - DO NOT EDIT.
|
|
2
|
+
// Regenerated from the exporting modules in ./src.
|
|
3
|
+
// Hand edits are overwritten on the next watch; this file is read-only.
|
|
4
|
+
|
|
5
|
+
export * as allowlist from "./src/allowlist";
|
|
6
|
+
export * as config from "./src/config";
|
|
7
|
+
export * as fetch from "./src/fetch";
|
|
8
|
+
export * as plugin from "./src/plugin";
|
|
9
|
+
export * as provider from "./src/provider";
|
|
10
|
+
export * as runtime from "./src/runtime";
|
|
11
|
+
export * as schema from "./src/schema";
|
|
12
|
+
export * as scrape from "./src/scrape";
|
|
13
|
+
export * as search from "./src/search";
|
|
14
|
+
export * as tool from "./src/tool";
|
|
15
|
+
export type { UrlAllowList } from "./src/allowlist";
|
|
16
|
+
export type { ApprovalGate, WebSearchPluginConfig, ResolvedWebSearchConfig } from "./src/config";
|
|
17
|
+
export type { WebSearchProvider, WebSearchProviderSpec } from "./src/provider";
|
|
18
|
+
export type { WebSearchRuntime } from "./src/runtime";
|
|
19
|
+
export type { WebSearchRequest, WebSearchCitation, WebSearchResult, WebFetchRequest, WebFetchResult } from "./src/schema";
|
|
20
|
+
export type { WebSearchContext } from "./src/search";
|
|
21
|
+
export type { WebSearchToolOptions } from "./src/tool";
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dbx-tools/appkit-web-search",
|
|
3
|
+
"repository": {
|
|
4
|
+
"type": "git",
|
|
5
|
+
"url": "git+https://github.com/reggie-db/dbx-tools.git",
|
|
6
|
+
"directory": "workspaces/node/appkit-web-search"
|
|
7
|
+
},
|
|
8
|
+
"devDependencies": {
|
|
9
|
+
"@types/express": "^5.0.5",
|
|
10
|
+
"@types/json-schema": "^7",
|
|
11
|
+
"@types/node": "^24.6.0",
|
|
12
|
+
"tsx": "^4.23.0",
|
|
13
|
+
"typescript": "^5.9.3"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@databricks/appkit": "^0.43.0",
|
|
17
|
+
"@mastra/core": "^1.47.0",
|
|
18
|
+
"got-scraping": "^4.2.1",
|
|
19
|
+
"zod": "^4.3.6",
|
|
20
|
+
"@dbx-tools/shared-core": "0.3.21",
|
|
21
|
+
"@dbx-tools/model": "0.3.21",
|
|
22
|
+
"@dbx-tools/path": "0.3.21",
|
|
23
|
+
"@dbx-tools/shared-model": "0.3.21"
|
|
24
|
+
},
|
|
25
|
+
"main": "index.ts",
|
|
26
|
+
"license": "UNLICENSED",
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"version": "0.3.21",
|
|
31
|
+
"types": "index.ts",
|
|
32
|
+
"type": "module",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": "./index.ts",
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"dbxToolsConfig": {
|
|
38
|
+
"tags": [
|
|
39
|
+
"node"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"pnpm exec projen\".",
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "projen build",
|
|
45
|
+
"compile": "projen compile",
|
|
46
|
+
"default": "projen default",
|
|
47
|
+
"package": "projen package",
|
|
48
|
+
"post-compile": "projen post-compile",
|
|
49
|
+
"pre-compile": "projen pre-compile",
|
|
50
|
+
"test": "projen test",
|
|
51
|
+
"watch": "projen watch",
|
|
52
|
+
"projen": "projen"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/allowlist.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL allow-list policy for the web-search add-on.
|
|
3
|
+
*
|
|
4
|
+
* A configured allow-list restricts which URLs the tools will surface or
|
|
5
|
+
* fetch. Each entry is a glob compiled by `@dbx-tools/path`'s
|
|
6
|
+
* {@link match.toPathMatcher} (the same `Minimatch`-backed matcher the
|
|
7
|
+
* package's file scanning uses). Because that matcher treats `/` as a
|
|
8
|
+
* path-segment boundary, entries are matched against the right slice of the
|
|
9
|
+
* URL rather than the raw `href`:
|
|
10
|
+
*
|
|
11
|
+
* - A **host** entry (no `/` after the optional scheme, e.g. `databricks.com`
|
|
12
|
+
* or `*.databricks.com`) is tested against the URL's `hostname`. A bare
|
|
13
|
+
* host with no wildcard also matches its subdomains, so `databricks.com`
|
|
14
|
+
* permits `docs.databricks.com` - the intuitive reading of a domain
|
|
15
|
+
* allow-list.
|
|
16
|
+
* - A **path** entry (contains a `/`, e.g. `docs.example.com/api/**`) is
|
|
17
|
+
* tested against `host + pathname` (scheme and query stripped), so path
|
|
18
|
+
* globs work without fighting the `https://` prefix.
|
|
19
|
+
*
|
|
20
|
+
* Enforcement is asymmetric by design (see the module's two consumers):
|
|
21
|
+
* `web_search` results are SILENTLY filtered to the permitted set, while an
|
|
22
|
+
* explicit `web_fetch` of a disallowed URL is refused with an error - the
|
|
23
|
+
* search never leaks a URL the caller then can't fetch, but a direct fetch
|
|
24
|
+
* of a blocked URL is a visible, correctable mistake rather than a silent
|
|
25
|
+
* empty.
|
|
26
|
+
*
|
|
27
|
+
* An empty / absent allow-list permits everything.
|
|
28
|
+
*
|
|
29
|
+
* @module
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { object } from "@dbx-tools/shared-core";
|
|
33
|
+
import { match, type PathMatcher } from "@dbx-tools/path";
|
|
34
|
+
|
|
35
|
+
/** A compiled URL allow-list. Build one with {@link toUrlAllowList}. */
|
|
36
|
+
export interface UrlAllowList {
|
|
37
|
+
/** The normalized entries backing this list (for diagnostics). */
|
|
38
|
+
readonly patterns: readonly string[];
|
|
39
|
+
/** Whether the list actually restricts anything (`false` == permit all). */
|
|
40
|
+
readonly restricted: boolean;
|
|
41
|
+
/** Whether `url` is permitted. An unrestricted list permits everything. */
|
|
42
|
+
allows(url: string): boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Strip a leading `scheme://` (or bare `scheme:`) from a pattern. */
|
|
46
|
+
function stripScheme(pattern: string): string {
|
|
47
|
+
return pattern.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/^[a-z][a-z0-9+.-]*:/i, "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Normalize one raw allow-list entry: trim it and drop any scheme. The
|
|
52
|
+
* scheme is irrelevant to matching (we compare against hostname / host+path),
|
|
53
|
+
* so `https://docs.example.com/x` and `docs.example.com/x` are equivalent.
|
|
54
|
+
*/
|
|
55
|
+
export function normalizeUrlPattern(pattern: string): string {
|
|
56
|
+
return stripScheme(pattern.trim());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse a raw allow-list from config (`string[]`) or an env var (a CSV /
|
|
61
|
+
* whitespace-separated string) into a normalized, de-duplicated entry list.
|
|
62
|
+
* Entries are trimmed and scheme-stripped; empties are dropped. Mirrors the
|
|
63
|
+
* email add-on's `parseAllowedSenders` so the two policies read their config
|
|
64
|
+
* identically.
|
|
65
|
+
*/
|
|
66
|
+
export function parseAllowedUrls(raw: string | string[] | undefined): string[] {
|
|
67
|
+
const entries = typeof raw === "string" ? raw.split(/[\s,]+/) : Array.isArray(raw) ? raw : [];
|
|
68
|
+
return [
|
|
69
|
+
...object
|
|
70
|
+
.sequence(entries)
|
|
71
|
+
.map((entry) => normalizeUrlPattern(entry))
|
|
72
|
+
.filter((entry) => entry.length > 0)
|
|
73
|
+
.distinct()
|
|
74
|
+
.toArray(),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One compiled entry: which URL slice it tests, and the matcher for it. */
|
|
79
|
+
interface CompiledPattern {
|
|
80
|
+
target: "hostname" | "hostPath";
|
|
81
|
+
matcher: PathMatcher;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Compile a single normalized entry into a {@link CompiledPattern}. */
|
|
85
|
+
function compilePattern(pattern: string): CompiledPattern {
|
|
86
|
+
if (pattern.includes("/")) {
|
|
87
|
+
// A path entry matches against `host + pathname` (no scheme, no query).
|
|
88
|
+
return { target: "hostPath", matcher: match.toPathMatcher(pattern) };
|
|
89
|
+
}
|
|
90
|
+
// A host entry matches the hostname. A bare host (no wildcard) also
|
|
91
|
+
// permits its subdomains via an added `*.<host>` alternative.
|
|
92
|
+
const globs = pattern.includes("*") ? [pattern] : [pattern, `*.${pattern}`];
|
|
93
|
+
return { target: "hostname", matcher: match.toPathMatcher(...globs) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The URL slices a compiled pattern is tested against. */
|
|
97
|
+
function urlTargets(url: string): { hostname: string; hostPath: string } | undefined {
|
|
98
|
+
try {
|
|
99
|
+
const u = new URL(url);
|
|
100
|
+
return {
|
|
101
|
+
hostname: u.hostname,
|
|
102
|
+
// Drop a trailing slash so `example.com/api` matches `example.com/api/`.
|
|
103
|
+
hostPath: `${u.hostname}${u.pathname}`.replace(/\/$/, ""),
|
|
104
|
+
};
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Compile a normalized entry list into a {@link UrlAllowList}. An empty list
|
|
112
|
+
* yields a permit-all allow-list whose {@link UrlAllowList.allows} always
|
|
113
|
+
* returns `true`. A URL that can't be parsed is never permitted by a
|
|
114
|
+
* restricted list.
|
|
115
|
+
*/
|
|
116
|
+
export function toUrlAllowList(patterns: readonly string[]): UrlAllowList {
|
|
117
|
+
if (patterns.length === 0) {
|
|
118
|
+
return { patterns: [], restricted: false, allows: () => true };
|
|
119
|
+
}
|
|
120
|
+
const compiled = patterns.map(compilePattern);
|
|
121
|
+
return {
|
|
122
|
+
patterns,
|
|
123
|
+
restricted: true,
|
|
124
|
+
allows: (url: string) => {
|
|
125
|
+
const targets = urlTargets(url);
|
|
126
|
+
if (!targets) return false;
|
|
127
|
+
return compiled.some((c) => c.matcher(targets[c.target]));
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Throw when `url` is not permitted by the allow-list. No-op when the
|
|
134
|
+
* allow-list is unrestricted. The single enforcement point for the
|
|
135
|
+
* `web_fetch` path.
|
|
136
|
+
*/
|
|
137
|
+
export function assertUrlAllowed(url: string, allow: UrlAllowList): void {
|
|
138
|
+
if (!allow.allows(url)) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`web-search: URL "${url}" is not permitted by the configured allow-list (${allow.patterns.join(", ")})`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|