@frockbot/plugin-web 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ // The provider-neutral web-search contract.
2
+ //
3
+ // The register (`docs/research/grokbot-computer.md:564`, row 47) names web
4
+ // search as a first-class tool but records no schema, no bound, and no error
5
+ // shape for it: none was ever measured. Everything here is FrockBot's own
6
+ // contract, defined from first principles, and it is deliberately narrower
7
+ // than any one provider's API so a second provider can satisfy it unchanged —
8
+ // the two-provider check the constitution applies to the model interface.
9
+ //
10
+ // This module holds no transport. `plugin-provider-ollama-cloud` implements
11
+ // {@link WebSearchV1} over `POST {apiBaseUrl}/api/web_search`; this Package
12
+ // never imports it.
13
+ import type { ToolDefinition, ToolSchema } from "@frockbot/kernel-contracts";
14
+
15
+ export const WEB_SEARCH_TOOL_NAME_V1 = "web_search";
16
+
17
+ /** Input bounds. A query longer than this is a paste, not a search. */
18
+ export const WEB_SEARCH_MAX_QUERY_LENGTH_V1 = 400;
19
+ export const WEB_SEARCH_MIN_RESULTS_V1 = 1;
20
+ export const WEB_SEARCH_MAX_RESULTS_V1 = 10;
21
+ export const WEB_SEARCH_DEFAULT_RESULTS_V1 = 5;
22
+
23
+ /**
24
+ * Output bounds. A snippet is an orientation aid, not the page: a Bot that
25
+ * wants the page calls `web_fetch`. Truncating here keeps one search from
26
+ * spending a Turn's context, and keeps the durable `tool/result` small.
27
+ */
28
+ export const WEB_SEARCH_MAX_SNIPPET_LENGTH_V1 = 1000;
29
+
30
+ export interface WebSearchResultV1 {
31
+ title: string;
32
+ url: string;
33
+ snippet: string;
34
+ }
35
+
36
+ export interface WebSearchResponseV1 {
37
+ query: string;
38
+ results: WebSearchResultV1[];
39
+ }
40
+
41
+ export interface WebSearchRequestV1 {
42
+ query: string;
43
+ maxResults: number;
44
+ }
45
+
46
+ /**
47
+ * What one search runs under: the durable effect identity of the tool call and
48
+ * its cancellation signal. Both are kernel vocabulary, not provider
49
+ * vocabulary — a provider that needs a per-call credential lease keys it on
50
+ * `effectId`, and one that needs neither ignores both.
51
+ */
52
+ export interface WebSearchExecutionV1 {
53
+ effectId: string;
54
+ signal: AbortSignal;
55
+ }
56
+
57
+ /** The narrow interface a search provider Package implements. */
58
+ export interface WebSearchV1 {
59
+ search(
60
+ request: WebSearchRequestV1,
61
+ execution: WebSearchExecutionV1,
62
+ ): Promise<WebSearchResponseV1>;
63
+ }
64
+
65
+ export const WEB_SEARCH_INPUT_SCHEMA_V1: ToolSchema["inputSchema"] = {
66
+ type: "object",
67
+ properties: {
68
+ query: {
69
+ type: "string",
70
+ description: "What to search the public web for.",
71
+ minLength: 1,
72
+ maxLength: WEB_SEARCH_MAX_QUERY_LENGTH_V1,
73
+ },
74
+ max_results: {
75
+ type: "integer",
76
+ description: `How many results to return, ${WEB_SEARCH_MIN_RESULTS_V1}–${WEB_SEARCH_MAX_RESULTS_V1}. Defaults to ${WEB_SEARCH_DEFAULT_RESULTS_V1}.`,
77
+ minimum: WEB_SEARCH_MIN_RESULTS_V1,
78
+ maximum: WEB_SEARCH_MAX_RESULTS_V1,
79
+ },
80
+ },
81
+ required: ["query"],
82
+ additionalProperties: false,
83
+ };
84
+
85
+ function isRecord(value: unknown): value is Record<string, unknown> {
86
+ return typeof value === "object" && value !== null && !Array.isArray(value);
87
+ }
88
+
89
+ /** Decode the model's arguments at the tool seam. Throws with a plain reason. */
90
+ export function decodeWebSearchInputV1(input: unknown): WebSearchRequestV1 {
91
+ if (!isRecord(input)) throw new Error("web_search input must be an object");
92
+ const query = typeof input.query === "string" ? input.query.trim() : "";
93
+ if (query.length === 0 || query.length > WEB_SEARCH_MAX_QUERY_LENGTH_V1) {
94
+ throw new Error(
95
+ `web_search query must be 1–${WEB_SEARCH_MAX_QUERY_LENGTH_V1} characters`,
96
+ );
97
+ }
98
+ const requested = input.max_results ?? WEB_SEARCH_DEFAULT_RESULTS_V1;
99
+ if (
100
+ typeof requested !== "number" ||
101
+ !Number.isSafeInteger(requested) ||
102
+ requested < WEB_SEARCH_MIN_RESULTS_V1 ||
103
+ requested > WEB_SEARCH_MAX_RESULTS_V1
104
+ ) {
105
+ throw new Error(
106
+ `web_search max_results must be an integer ${WEB_SEARCH_MIN_RESULTS_V1}–${WEB_SEARCH_MAX_RESULTS_V1}`,
107
+ );
108
+ }
109
+ return { query, maxResults: requested };
110
+ }
111
+
112
+ function boundedSnippet(value: unknown): string {
113
+ const text =
114
+ typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
115
+ return text.slice(0, WEB_SEARCH_MAX_SNIPPET_LENGTH_V1);
116
+ }
117
+
118
+ /**
119
+ * Decode a provider's answer at the seam it crosses. Anything the provider
120
+ * adds is dropped, a result with no usable `url` is dropped, and the list is
121
+ * trimmed to what was asked for — the model never sees a provider's shape.
122
+ */
123
+ export function decodeWebSearchResponseV1(
124
+ value: unknown,
125
+ request: WebSearchRequestV1,
126
+ ): WebSearchResponseV1 {
127
+ if (!isRecord(value)) throw new Error("web search response is invalid");
128
+ const rows = value.results;
129
+ if (!Array.isArray(rows)) throw new Error("web search response is invalid");
130
+ const results: WebSearchResultV1[] = [];
131
+ for (const row of rows) {
132
+ if (results.length >= request.maxResults) break;
133
+ if (!isRecord(row)) continue;
134
+ const url = typeof row.url === "string" ? row.url.trim() : "";
135
+ if (url.length === 0 || url.length > 2048) continue;
136
+ const title = typeof row.title === "string" ? row.title.trim() : "";
137
+ results.push({
138
+ title: title.slice(0, 300),
139
+ url,
140
+ // Ollama names the field `content`; the contract names it `snippet`.
141
+ snippet: boundedSnippet(row.snippet ?? row.content ?? row.description),
142
+ });
143
+ }
144
+ return { query: request.query, results };
145
+ }
146
+
147
+ /**
148
+ * The durable `tool/result` body. The kernel event carries `content: string`
149
+ * (`kernel-contracts/src/types.ts`), so every tool emits stable JSON rather
150
+ * than prose: a later reader parses it instead of re-reading a sentence.
151
+ */
152
+ export function encodeWebSearchResultV1(response: WebSearchResponseV1): string {
153
+ return JSON.stringify(response);
154
+ }
155
+
156
+ /**
157
+ * Build the `web_search` tool over any {@link WebSearchV1}. The definition,
158
+ * its bounds, and its durable result shape live here so a second provider
159
+ * Package contributes the same tool by supplying transport alone.
160
+ *
161
+ * `idempotent: true`: a search is read-only, so recovery after eviction
162
+ * re-runs it rather than reconciling a recorded effect.
163
+ */
164
+ export function createWebSearchToolDefinitionV1(
165
+ provider: WebSearchV1,
166
+ ): ToolDefinition {
167
+ return {
168
+ name: WEB_SEARCH_TOOL_NAME_V1,
169
+ // A general work tool: the reach an `executor` subagent has, and not the
170
+ // narrow reach of `browserUse`, `computerUse`, or the two video roles.
171
+ admission: { subagentRoles: ["executor"] },
172
+ description:
173
+ "Search the public web and return titles, URLs and short snippets. Use web_fetch to read a result in full.",
174
+ inputSchema: WEB_SEARCH_INPUT_SCHEMA_V1,
175
+ idempotent: true,
176
+ validate: (input: unknown) => {
177
+ try {
178
+ decodeWebSearchInputV1(input);
179
+ return true;
180
+ } catch {
181
+ return false;
182
+ }
183
+ },
184
+ execute: async (input, context) => {
185
+ let request: WebSearchRequestV1;
186
+ try {
187
+ request = decodeWebSearchInputV1(input);
188
+ } catch (error) {
189
+ return {
190
+ content: JSON.stringify({
191
+ error: "web-search-invalid-input",
192
+ message: error instanceof Error ? error.message : "invalid input",
193
+ }),
194
+ isError: true,
195
+ };
196
+ }
197
+ try {
198
+ const response = await provider.search(request, {
199
+ effectId: context.effectId,
200
+ signal: context.signal,
201
+ });
202
+ return { content: encodeWebSearchResultV1(response), isError: false };
203
+ } catch (error) {
204
+ return {
205
+ content: JSON.stringify({
206
+ error: "web-search-failed",
207
+ query: request.query,
208
+ message: (error instanceof Error
209
+ ? error.message
210
+ : "web search failed"
211
+ ).slice(0, 500),
212
+ }),
213
+ isError: true,
214
+ };
215
+ }
216
+ },
217
+ };
218
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./contract.js";
2
+ export * from "./ssrf.js";
3
+ export * from "./agent.js";
4
+ export { default as manifest } from "./manifest.js";
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;
@@ -0,0 +1,174 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ classifyOutboundUrlV1,
4
+ classifyWebFetchUrlV1,
5
+ parseIpv4LiteralV1,
6
+ parseIpv6LiteralV1,
7
+ type SsrfRefusalReasonV1,
8
+ } from "./ssrf.ts";
9
+
10
+ /**
11
+ * The classifier's whole contract, as a table. Each row is a URL a model could
12
+ * plausibly hand `web_fetch` and the verdict the Bot's outbound boundary must
13
+ * reach. `undefined` means the URL is allowed.
14
+ */
15
+ const TABLE: ReadonlyArray<[string, SsrfRefusalReasonV1 | undefined]> = [
16
+ // Allowed: ordinary public sites, in the shapes they actually arrive in.
17
+ ["https://example.com/", undefined],
18
+ ["https://example.com:443/a/b?c=d#e", undefined],
19
+ ["https://sub.example.co.uk/page", undefined],
20
+ ["https://example.test/index.html", undefined],
21
+ ["https://1.1.1.1/", undefined],
22
+ ["https://8.8.8.8/resolve", undefined],
23
+ ["https://[2606:4700:4700::1111]/", undefined],
24
+ ["https://xn--bcher-kva.example/", undefined],
25
+
26
+ // Rule 1: scheme.
27
+ ["http://example.com/", "ssrf-blocked-scheme"],
28
+ ["http://169.254.169.254/latest/meta-data", "ssrf-blocked-scheme"],
29
+ ["ftp://example.com/", "ssrf-blocked-scheme"],
30
+ ["file:///etc/passwd", "ssrf-blocked-scheme"],
31
+ ["data:text/html,<b>hi</b>", "ssrf-blocked-scheme"],
32
+ ["blob:https://example.com/abc", "ssrf-blocked-scheme"],
33
+
34
+ // Rule 2: port.
35
+ ["https://example.com:8080/", "ssrf-blocked-port"],
36
+ ["https://example.com:22/", "ssrf-blocked-port"],
37
+
38
+ // Rule 5: credentials in the URL.
39
+ ["https://user:pass@example.com/", "ssrf-blocked-credentials"],
40
+ ["https://user@example.com/", "ssrf-blocked-credentials"],
41
+
42
+ // Rule 3: names that are not the public internet.
43
+ ["https://localhost/", "ssrf-blocked-host"],
44
+ ["https://server.local/", "ssrf-blocked-host"],
45
+ ["https://printer.home.arpa/", "ssrf-blocked-host"],
46
+ ["https://api.localhost/", "ssrf-blocked-host"],
47
+ ["https://metadata.google.internal/computeMetadata/v1/", "ssrf-blocked-host"],
48
+ ["https://foo.internal/", "ssrf-blocked-host"],
49
+ ["https://redis/", "ssrf-blocked-host"],
50
+ ["https://metadata/", "ssrf-blocked-host"],
51
+
52
+ // Rule 4: IPv4, in every encoding the host parser accepts.
53
+ ["https://169.254.169.254/latest/meta-data", "ssrf-blocked-private-address"],
54
+ ["https://127.0.0.1/", "ssrf-blocked-private-address"],
55
+ ["https://0177.0.0.1/", "ssrf-blocked-private-address"],
56
+ ["https://2130706433/", "ssrf-blocked-private-address"],
57
+ ["https://0x7f000001/", "ssrf-blocked-private-address"],
58
+ ["https://0x7f.1/", "ssrf-blocked-private-address"],
59
+ ["https://127.1/", "ssrf-blocked-private-address"],
60
+ ["https://10.0.0.5/", "ssrf-blocked-private-address"],
61
+ ["https://172.16.0.1/", "ssrf-blocked-private-address"],
62
+ ["https://192.168.1.1/", "ssrf-blocked-private-address"],
63
+ ["https://100.64.0.1/", "ssrf-blocked-private-address"],
64
+ ["https://198.18.0.1/", "ssrf-blocked-private-address"],
65
+ ["https://192.0.0.1/", "ssrf-blocked-private-address"],
66
+ ["https://0.0.0.0/", "ssrf-blocked-private-address"],
67
+ ["https://255.255.255.255/", "ssrf-blocked-private-address"],
68
+ ["https://239.0.0.1/", "ssrf-blocked-private-address"],
69
+
70
+ // Rule 4: IPv6, including the mapped and compatible forms.
71
+ ["https://[::1]/", "ssrf-blocked-private-address"],
72
+ ["https://[::]/", "ssrf-blocked-private-address"],
73
+ ["https://[::ffff:127.0.0.1]/", "ssrf-blocked-private-address"],
74
+ ["https://[::ffff:8.8.8.8]/", "ssrf-blocked-private-address"],
75
+ ["https://[fd00::1]/", "ssrf-blocked-private-address"],
76
+ ["https://[fe80::1]/", "ssrf-blocked-private-address"],
77
+ ["https://[ff02::1]/", "ssrf-blocked-private-address"],
78
+
79
+ // Not URLs at all.
80
+ ["", "ssrf-invalid-url"],
81
+ ["not a url", "ssrf-invalid-url"],
82
+ ["/relative/path", "ssrf-invalid-url"],
83
+ ];
84
+
85
+ describe("the web_fetch outbound classifier", () => {
86
+ for (const [candidate, expected] of TABLE) {
87
+ test(`${expected ?? "allows"} ${candidate || "(empty)"}`, () => {
88
+ const verdict = classifyWebFetchUrlV1(candidate);
89
+ if (expected === undefined) {
90
+ expect({ candidate, allowed: verdict.allowed }).toEqual({
91
+ candidate,
92
+ allowed: true,
93
+ });
94
+ return;
95
+ }
96
+ expect({
97
+ candidate,
98
+ reason: verdict.allowed ? undefined : verdict.reason,
99
+ }).toEqual({ candidate, reason: expected });
100
+ });
101
+ }
102
+
103
+ test("refuses a redirect that leaves the public internet", () => {
104
+ // The first hop is a legitimate public URL; the `Location` is not. Only
105
+ // re-running the classifier on the resolved target catches this, which is
106
+ // exactly what `web_fetch` does on every hop.
107
+ expect(classifyWebFetchUrlV1("https://redirector.example/go").allowed).toBe(
108
+ true,
109
+ );
110
+ const followed = classifyWebFetchUrlV1(
111
+ new URL(
112
+ "//169.254.169.254/latest/meta-data",
113
+ "https://redirector.example/go",
114
+ ).toString(),
115
+ );
116
+ expect(followed.allowed ? "allowed" : followed.reason).toBe(
117
+ "ssrf-blocked-private-address",
118
+ );
119
+ });
120
+
121
+ test("lets a caller that legitimately needs a port opt into one", () => {
122
+ // `web_fetch` refuses a non-default port; a User-named MCP endpoint on its
123
+ // own port is an ordinary deployment, so that caller opts in explicitly.
124
+ expect(
125
+ classifyWebFetchUrlV1("https://mcp.example.test:8443/mcp").allowed,
126
+ ).toBe(false);
127
+ expect(
128
+ classifyOutboundUrlV1("https://mcp.example.test:8443/mcp", {
129
+ allowNonDefaultPort: true,
130
+ }).allowed,
131
+ ).toBe(true);
132
+ // Opting into a port opts into nothing else.
133
+ const stillPrivate = classifyOutboundUrlV1("https://127.0.0.1:8443/", {
134
+ allowNonDefaultPort: true,
135
+ });
136
+ expect(stillPrivate.allowed ? "allowed" : stillPrivate.reason).toBe(
137
+ "ssrf-blocked-private-address",
138
+ );
139
+ });
140
+
141
+ test("never names a resolved address in a refusal", () => {
142
+ const verdict = classifyWebFetchUrlV1("https://169.254.169.254/");
143
+ expect(verdict.allowed).toBe(false);
144
+ if (verdict.allowed) return;
145
+ expect(verdict.message).not.toContain("169.254");
146
+ });
147
+
148
+ test("normalizes every IPv4 encoding to the same address", () => {
149
+ for (const candidate of [
150
+ "127.0.0.1",
151
+ "0177.0.0.1",
152
+ "2130706433",
153
+ "0x7f000001",
154
+ "127.1",
155
+ ]) {
156
+ expect({ candidate, octets: parseIpv4LiteralV1(candidate) }).toEqual({
157
+ candidate,
158
+ octets: [127, 0, 0, 1],
159
+ });
160
+ }
161
+ expect(parseIpv4LiteralV1("example.com")).toBeUndefined();
162
+ expect(parseIpv4LiteralV1("256.0.0.1")).toBeUndefined();
163
+ });
164
+
165
+ test("expands compressed and v4-embedded IPv6 literals", () => {
166
+ expect(parseIpv6LiteralV1("[::1]")).toEqual([
167
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
168
+ ]);
169
+ expect(parseIpv6LiteralV1("::ffff:127.0.0.1")).toEqual([
170
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 127, 0, 0, 1,
171
+ ]);
172
+ expect(parseIpv6LiteralV1("example.com")).toBeUndefined();
173
+ });
174
+ });