@stratusagent/tool-web 0.6.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stratus Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # @stratusagent/tool-web
2
+
3
+ `web.fetch`: retrieve a URL and get back the text a reader would keep. No
4
+ browser, no JavaScript, no Chromium — this is what an agent reaches for
5
+ twenty times for every once it needs `browser.*`.
6
+
7
+ ## Install and enable
8
+
9
+ ```bash
10
+ npm install @stratusagent/tool-web
11
+ ```
12
+
13
+ ```jsonc
14
+ // ~/.stratus/config.json — a trusted config only
15
+ {
16
+ "plugins": {
17
+ "@stratusagent/tool-web": { "enabled": true }
18
+ }
19
+ }
20
+ ```
21
+
22
+ Then the agent's soul decides, per identity:
23
+
24
+ ```markdown
25
+ ---
26
+ id: ava
27
+ tools: [web.fetch]
28
+ ---
29
+ ```
30
+
31
+ ## Risk model
32
+
33
+ | Tool | Risk | What approval mode does |
34
+ | --- | --- | --- |
35
+ | `web.fetch` | `gated` | `interactive` asks at the terminal, `remote` asks in Slack, `headless` refuses. It reaches a service outside Stratus at an address the agent chose, which is the line 03 draws. |
36
+
37
+ Approval decides *whether*; the address policy decides *where*, and it is
38
+ not the same question. An approver looking at `https://example.com/report`
39
+ has approved that URL — not the redirect it answers with.
40
+
41
+ ## Settings
42
+
43
+ | Key | Default | What |
44
+ | --- | --- | --- |
45
+ | `allowedHosts` | none | Hosts exempt from the address check, by name or literal address. The narrow override: one internal service an agent is meant to reach. |
46
+ | `allowPrivateAddresses` | `false` | Reach any non-global address. The trusted-workstation posture — it turns the SSRF protection off rather than adjusting it. |
47
+ | `maxBytes` | `400000` | Stop reading here; the result says `truncated`. |
48
+ | `timeoutMs` | `20000` | Give up on the whole exchange. |
49
+ | `maxRedirects` | `5` | Hops to follow before refusing. |
50
+ | `userAgent` | `StratusAgent/0.5 …` | What to send. |
51
+
52
+ All of them can be set per agent under `agents`, and `allowedHosts` in
53
+ particular should be: an exemption written once at the top level is an
54
+ exemption every agent gets.
55
+
56
+ ## What it refuses, and where
57
+
58
+ The address policy is `@stratusagent/egress`, **the same module
59
+ `@stratusagent/tool-browser` uses** — not a copy of it. A second
60
+ implementation would not drift into a style difference; the stale one would
61
+ be an SSRF hole. Both packs are tested against the same table of hostile
62
+ URLs, which is what fails if the module is ever forked.
63
+
64
+ - **Schemes**: `http:` and `https:`. `file:`, `data:`, and `javascript:` are
65
+ refused before anything is fetched — this process can read the files a
66
+ `file:` URL names.
67
+ - **Addresses**: every non-global address in both families — loopback,
68
+ RFC 1918, carrier-NAT, link-local (where cloud instance metadata lives),
69
+ IPv6 unique-local and link-local, multicast, and the IPv4-mapped, NAT64,
70
+ and 6to4 forms that write the same addresses a different way.
71
+ - **Every hop**: redirects are followed one at a time and each one faces the
72
+ policy from scratch, so a public URL that answers `302 Location:
73
+ http://169.254.169.254/` is a refusal rather than a fetch.
74
+ - **The connection itself**: the socket's own DNS resolution is the one that
75
+ gets checked. Resolving a name to validate it and letting the client
76
+ resolve it again to connect is DNS rebinding with extra steps.
@@ -0,0 +1,51 @@
1
+ import type { JsonObject, Plugin } from '@stratusagent/core';
2
+ import { type EgressPolicy } from '@stratusagent/egress';
3
+ export { decodeEntities, extractTitle, htmlToText } from './readability.ts';
4
+ export interface WebPluginConfig extends JsonObject {
5
+ /** Reach addresses that are not globally routable. See the README. */
6
+ allowPrivateAddresses?: boolean;
7
+ /** Hosts exempt from the address check, by name or literal address. */
8
+ allowedHosts?: string[];
9
+ maxBytes?: number;
10
+ timeoutMs?: number;
11
+ maxRedirects?: number;
12
+ userAgent?: string;
13
+ }
14
+ /**
15
+ * Fetch a URL, following redirects **one validated hop at a time**.
16
+ *
17
+ * The loop is here rather than in the HTTP client for one reason: every hop
18
+ * is a new request to a new host, and a client that followed redirects for
19
+ * us would resolve and connect to hosts nothing checked. An approved public
20
+ * URL that answers `302 Location: http://169.254.169.254/` is exactly the
21
+ * attack, and it is defeated by the second hop facing the policy the same
22
+ * way the first one did.
23
+ */
24
+ export declare const fetchThroughPolicy: (rawUrl: string, options?: {
25
+ policy?: EgressPolicy;
26
+ maxBytes?: number;
27
+ timeoutMs?: number;
28
+ maxRedirects?: number;
29
+ userAgent?: string;
30
+ signal?: AbortSignal;
31
+ }) => Promise<{
32
+ url: string;
33
+ status: number;
34
+ contentType: string;
35
+ body: string;
36
+ truncated: boolean;
37
+ hops: string[];
38
+ }>;
39
+ /**
40
+ * The `web` toolset: one tool, deliberately.
41
+ *
42
+ * This is the capability an agent reaches for twenty times for every once
43
+ * it needs a browser, and paying Chromium's startup and memory for it is
44
+ * the wrong trade. Search is not here and is not coming: every backend
45
+ * needs a vendor key and a commercial relationship, so `web.search` belongs
46
+ * to the ecosystem.
47
+ */
48
+ export declare const createWebPlugin: (config?: JsonObject) => Plugin;
49
+ /** The loader's ABI. See `docs/architecture/plugins.md`. */
50
+ export declare const createPlugin: (config?: JsonObject) => Plugin;
51
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,EAAiB,MAAM,oBAAoB,CAAC;AAC5E,OAAO,EAGL,KAAK,YAAY,EAClB,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAO5E,MAAM,WAAW,eAAgB,SAAQ,UAAU;IACjD,sEAAsE;IACtE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,uEAAuE;IACvE,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA8BD;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,GAC7B,QAAQ,MAAM,EACd,UAAS;IACP,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACjB,KACL,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAmChH,CAAC;AAkDF;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,GAAI,SAAQ,UAAe,KAAG,MAKxD,CAAC;AAEH,4DAA4D;AAC5D,eAAO,MAAM,YAAY,YARe,UAAU,KAAQ,MAQf,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,135 @@
1
+ import { assertRequestAllowed, requestThroughPolicy, } from '@stratusagent/egress';
2
+ import { resolvePluginAgentConfig } from '@stratusagent/plugins';
3
+ import { extractTitle, htmlToText } from "./readability.js";
4
+ export { decodeEntities, extractTitle, htmlToText } from "./readability.js";
5
+ const DEFAULT_MAX_BYTES = 400_000;
6
+ const DEFAULT_TIMEOUT_MS = 20_000;
7
+ const DEFAULT_MAX_REDIRECTS = 5;
8
+ const DEFAULT_USER_AGENT = 'StratusAgent/0.5 (+https://github.com/stratuslabs/agent)';
9
+ const asNumber = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
10
+ const settingsFor = (config, session) => {
11
+ // Per call, from the session: an agent allowed to reach an internal host
12
+ // is a decision about that agent, and closing over it at setup would
13
+ // give the exemption to everyone.
14
+ const resolved = resolvePluginAgentConfig(config, session.agent.id);
15
+ const policy = {
16
+ ...(resolved.allowPrivateAddresses === true ? { allowPrivateAddresses: true } : {}),
17
+ ...(Array.isArray(resolved.allowedHosts)
18
+ ? { allowedHosts: resolved.allowedHosts.filter((entry) => typeof entry === 'string') }
19
+ : {}),
20
+ };
21
+ return {
22
+ policy,
23
+ maxBytes: asNumber(resolved.maxBytes, DEFAULT_MAX_BYTES),
24
+ timeoutMs: asNumber(resolved.timeoutMs, DEFAULT_TIMEOUT_MS),
25
+ maxRedirects: asNumber(resolved.maxRedirects, DEFAULT_MAX_REDIRECTS),
26
+ userAgent: typeof resolved.userAgent === 'string' ? resolved.userAgent : DEFAULT_USER_AGENT,
27
+ };
28
+ };
29
+ const isRedirect = (status) => status >= 300 && status < 400;
30
+ const contentTypeOf = (headers) => String(headers['content-type'] ?? '').toLowerCase();
31
+ /**
32
+ * Fetch a URL, following redirects **one validated hop at a time**.
33
+ *
34
+ * The loop is here rather than in the HTTP client for one reason: every hop
35
+ * is a new request to a new host, and a client that followed redirects for
36
+ * us would resolve and connect to hosts nothing checked. An approved public
37
+ * URL that answers `302 Location: http://169.254.169.254/` is exactly the
38
+ * attack, and it is defeated by the second hop facing the policy the same
39
+ * way the first one did.
40
+ */
41
+ export const fetchThroughPolicy = async (rawUrl, options = {}) => {
42
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
43
+ const hops = [];
44
+ let current = assertRequestAllowed(rawUrl, options.policy ?? {}).href;
45
+ for (let hop = 0; hop <= maxRedirects; hop += 1) {
46
+ hops.push(current);
47
+ const response = await requestThroughPolicy(current, {
48
+ ...(options.policy ? { policy: options.policy } : {}),
49
+ headers: { 'user-agent': options.userAgent ?? DEFAULT_USER_AGENT, accept: 'text/html,text/plain;q=0.9,*/*;q=0.5' },
50
+ ...(options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes }),
51
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
52
+ ...(options.signal ? { signal: options.signal } : {}),
53
+ });
54
+ const location = response.headers.location;
55
+ if (isRedirect(response.status) && typeof location === 'string' && location.length > 0) {
56
+ // Resolved against the current URL, then checked from scratch — a
57
+ // relative redirect is still a new destination.
58
+ const next = new URL(location, current).href;
59
+ current = assertRequestAllowed(next, options.policy ?? {}).href;
60
+ continue;
61
+ }
62
+ return {
63
+ url: current,
64
+ status: response.status,
65
+ contentType: contentTypeOf(response.headers),
66
+ body: response.body,
67
+ truncated: response.truncated,
68
+ hops,
69
+ };
70
+ }
71
+ throw new Error(`Too many redirects (${maxRedirects}) starting at ${rawUrl}`);
72
+ };
73
+ const createFetchTool = (config) => ({
74
+ name: 'web.fetch',
75
+ description: 'Retrieve a URL and return its readable text. No browser, no JavaScript.',
76
+ // Gated: it reaches a service outside Stratus, on an address an agent
77
+ // chose. The address policy decides *where*; approval decides *whether*.
78
+ risk: 'gated',
79
+ parameters: {
80
+ type: 'object',
81
+ properties: {
82
+ url: { type: 'string' },
83
+ maxBytes: { type: 'number' },
84
+ raw: { type: 'boolean', description: 'Return the body as received, without text extraction.' },
85
+ },
86
+ required: ['url'],
87
+ },
88
+ async execute(input, session, context) {
89
+ const url = typeof input.url === 'string' ? input.url : '';
90
+ if (!url) {
91
+ throw new Error('url is required.');
92
+ }
93
+ const settings = settingsFor(config, session);
94
+ const response = await fetchThroughPolicy(url, {
95
+ policy: settings.policy,
96
+ maxBytes: asNumber(input.maxBytes, settings.maxBytes),
97
+ timeoutMs: settings.timeoutMs,
98
+ maxRedirects: settings.maxRedirects,
99
+ userAgent: settings.userAgent,
100
+ ...(context?.signal ? { signal: context.signal } : {}),
101
+ });
102
+ const html = response.contentType.includes('html');
103
+ const text = input.raw === true || !html ? response.body : htmlToText(response.body);
104
+ const title = html ? extractTitle(response.body) : undefined;
105
+ return {
106
+ url: response.url,
107
+ status: response.status,
108
+ contentType: response.contentType,
109
+ ...(title ? { title } : {}),
110
+ // Named `redirects` rather than left implicit: an agent that followed
111
+ // a link somewhere else should be able to say where it ended up.
112
+ ...(response.hops.length > 1 ? { redirects: response.hops } : {}),
113
+ text,
114
+ truncated: response.truncated,
115
+ };
116
+ },
117
+ });
118
+ /**
119
+ * The `web` toolset: one tool, deliberately.
120
+ *
121
+ * This is the capability an agent reaches for twenty times for every once
122
+ * it needs a browser, and paying Chromium's startup and memory for it is
123
+ * the wrong trade. Search is not here and is not coming: every backend
124
+ * needs a vendor key and a commercial relationship, so `web.search` belongs
125
+ * to the ecosystem.
126
+ */
127
+ export const createWebPlugin = (config = {}) => ({
128
+ name: '@stratusagent/tool-web',
129
+ setup(context) {
130
+ context.tools.register(createFetchTool(config));
131
+ },
132
+ });
133
+ /** The loader's ABI. See `docs/architecture/plugins.md`. */
134
+ export const createPlugin = createWebPlugin;
135
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * HTML to the text a reader would keep.
3
+ *
4
+ * Not a parser and not trying to be one: an agent asked for a page's
5
+ * content, and what it must not receive is fifty kilobytes of navigation,
6
+ * script, and styling with three sentences somewhere inside. So the
7
+ * elements that never carry the article are dropped whole, block
8
+ * boundaries become newlines, and everything else collapses to text.
9
+ *
10
+ * A real DOM would do better on a hostile page. It would also be a
11
+ * dependency, a parse of untrusted markup, and a second answer to what a
12
+ * page "is" — for a fetcher whose output is fed to a model as prose, this
13
+ * trade is the right one, and the failure mode is a worse extraction
14
+ * rather than an incorrect one.
15
+ */
16
+ export declare const decodeEntities: (value: string) => string;
17
+ export declare const extractTitle: (html: string) => string | undefined;
18
+ export declare const htmlToText: (html: string) => string;
19
+ //# sourceMappingURL=readability.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readability.d.ts","sourceRoot":"","sources":["../src/readability.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAuCH,eAAO,MAAM,cAAc,GAAI,OAAO,MAAM,KAAG,MAW3C,CAAC;AAEL,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,KAAG,MAAM,GAAG,SAGpD,CAAC;AAEF,eAAO,MAAM,UAAU,GAAI,MAAM,MAAM,KAAG,MAwBzC,CAAC"}
@@ -0,0 +1,87 @@
1
+ /**
2
+ * HTML to the text a reader would keep.
3
+ *
4
+ * Not a parser and not trying to be one: an agent asked for a page's
5
+ * content, and what it must not receive is fifty kilobytes of navigation,
6
+ * script, and styling with three sentences somewhere inside. So the
7
+ * elements that never carry the article are dropped whole, block
8
+ * boundaries become newlines, and everything else collapses to text.
9
+ *
10
+ * A real DOM would do better on a hostile page. It would also be a
11
+ * dependency, a parse of untrusted markup, and a second answer to what a
12
+ * page "is" — for a fetcher whose output is fed to a model as prose, this
13
+ * trade is the right one, and the failure mode is a worse extraction
14
+ * rather than an incorrect one.
15
+ */
16
+ const DROPPED_ELEMENTS = [
17
+ 'script',
18
+ 'style',
19
+ 'noscript',
20
+ 'template',
21
+ 'svg',
22
+ 'canvas',
23
+ 'iframe',
24
+ 'nav',
25
+ 'header',
26
+ 'footer',
27
+ 'aside',
28
+ 'form',
29
+ ];
30
+ const BLOCK_ELEMENTS = [
31
+ 'p', 'div', 'section', 'article', 'main', 'br', 'hr',
32
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
33
+ 'ul', 'ol', 'table', 'tr', 'blockquote', 'pre',
34
+ ];
35
+ const NAMED_ENTITIES = {
36
+ amp: '&',
37
+ lt: '<',
38
+ gt: '>',
39
+ quot: '"',
40
+ apos: "'",
41
+ nbsp: ' ',
42
+ mdash: '—',
43
+ ndash: '–',
44
+ hellip: '…',
45
+ rsquo: '’',
46
+ lsquo: '‘',
47
+ ldquo: '“',
48
+ rdquo: '”',
49
+ };
50
+ export const decodeEntities = (value) => value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity) => {
51
+ if (entity.startsWith('#x') || entity.startsWith('#X')) {
52
+ const code = Number.parseInt(entity.slice(2), 16);
53
+ return Number.isFinite(code) ? String.fromCodePoint(code) : match;
54
+ }
55
+ if (entity.startsWith('#')) {
56
+ const code = Number.parseInt(entity.slice(1), 10);
57
+ return Number.isFinite(code) ? String.fromCodePoint(code) : match;
58
+ }
59
+ return NAMED_ENTITIES[entity.toLowerCase()] ?? match;
60
+ });
61
+ export const extractTitle = (html) => {
62
+ const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
63
+ return match?.[1] ? decodeEntities(match[1]).trim() : undefined;
64
+ };
65
+ export const htmlToText = (html) => {
66
+ let text = html.replace(/<!--[\s\S]*?-->/g, '');
67
+ for (const element of DROPPED_ELEMENTS) {
68
+ text = text.replace(new RegExp(`<${element}\\b[^>]*>[\\s\\S]*?</${element}>`, 'gi'), ' ');
69
+ // Unclosed or self-closing forms of the same elements.
70
+ text = text.replace(new RegExp(`<${element}\\b[^>]*/?>`, 'gi'), ' ');
71
+ }
72
+ text = text.replace(/<head\b[^>]*>[\s\S]*?<\/head>/gi, ' ');
73
+ text = text.replace(/<li\b[^>]*>/gi, '\n- ');
74
+ for (const element of BLOCK_ELEMENTS) {
75
+ text = text.replace(new RegExp(`</?${element}\\b[^>]*>`, 'gi'), '\n');
76
+ }
77
+ text = text.replace(/<[^>]+>/g, ' ');
78
+ text = decodeEntities(text);
79
+ return text
80
+ .split('\n')
81
+ .map((line) => line.replace(/[ \t ]+/g, ' ').trim())
82
+ .filter((line, index, lines) => line.length > 0 || (lines[index - 1] ?? '').length > 0)
83
+ .join('\n')
84
+ .replace(/\n{3,}/g, '\n\n')
85
+ .trim();
86
+ };
87
+ //# sourceMappingURL=readability.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readability.js","sourceRoot":"","sources":["../src/readability.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "@stratusagent/tool-web",
3
+ "version": "0.6.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ },
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "engines": {
18
+ "node": ">=22.13 <23 || >=23.4"
19
+ },
20
+ "dependencies": {
21
+ "@stratusagent/core": "0.6.0",
22
+ "@stratusagent/egress": "0.6.0",
23
+ "@stratusagent/plugins": "0.6.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^24.6.0",
27
+ "typescript": "^5.9.3"
28
+ },
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/stratuslabs/agent.git",
33
+ "directory": "packages/tool-web"
34
+ },
35
+ "description": "Web fetching for Stratus agents: retrieve a URL as readable text, with no browser and a shared address policy",
36
+ "keywords": [
37
+ "stratus",
38
+ "agent",
39
+ "ai",
40
+ "llm",
41
+ "stratus-plugin",
42
+ "tools",
43
+ "web"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "stratus": {
49
+ "pluginVersion": 1,
50
+ "contributes": {
51
+ "tools": [
52
+ {
53
+ "name": "web.fetch",
54
+ "risk": "gated"
55
+ }
56
+ ]
57
+ },
58
+ "config": {
59
+ "type": "object",
60
+ "properties": {
61
+ "allowPrivateAddresses": {
62
+ "type": "boolean"
63
+ },
64
+ "allowedHosts": {
65
+ "type": "array",
66
+ "items": {
67
+ "type": "string"
68
+ }
69
+ },
70
+ "maxBytes": {
71
+ "type": "integer"
72
+ },
73
+ "timeoutMs": {
74
+ "type": "integer"
75
+ },
76
+ "maxRedirects": {
77
+ "type": "integer"
78
+ },
79
+ "userAgent": {
80
+ "type": "string"
81
+ }
82
+ },
83
+ "additionalProperties": false
84
+ }
85
+ },
86
+ "scripts": {
87
+ "build": "tsc -b",
88
+ "typecheck": "tsc --noEmit --project tsconfig.test.json",
89
+ "test": "node --test --experimental-strip-types"
90
+ }
91
+ }