@stigmer/runner 3.4.1 → 3.5.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/dist/.build-fingerprint +1 -1
- package/dist/activities/attach-session.d.ts +34 -0
- package/dist/activities/attach-session.js +95 -0
- package/dist/activities/attach-session.js.map +1 -0
- package/dist/activities/discover-mcp-server.d.ts +9 -0
- package/dist/activities/discover-mcp-server.js +6 -1
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/connect-backfill.d.ts +2 -1
- package/dist/activities/execute-cursor/connect-backfill.js +2 -2
- package/dist/activities/execute-cursor/connect-backfill.js.map +1 -1
- package/dist/activities/execute-cursor/fetch-interceptor.js +31 -0
- package/dist/activities/execute-cursor/fetch-interceptor.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +70 -3
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/mcp-resolver.d.ts +7 -1
- package/dist/activities/execute-cursor/mcp-resolver.js +15 -2
- package/dist/activities/execute-cursor/mcp-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/sdk-warmup.d.ts +44 -0
- package/dist/activities/execute-cursor/sdk-warmup.js +61 -0
- package/dist/activities/execute-cursor/sdk-warmup.js.map +1 -0
- package/dist/activities/execute-deep-agent/setup.js +48 -5
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +9 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js +19 -5
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +13 -1
- package/dist/client/stigmer-client.js +40 -5
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/client/token-claims.d.ts +9 -6
- package/dist/client/token-claims.js +22 -6
- package/dist/client/token-claims.js.map +1 -1
- package/dist/main.d.ts +8 -1
- package/dist/main.js +122 -2
- package/dist/main.js.map +1 -1
- package/dist/middleware/index.d.ts +0 -1
- package/dist/middleware/index.js +0 -1
- package/dist/middleware/index.js.map +1 -1
- package/dist/otel-metrics.d.ts +10 -1
- package/dist/otel-metrics.js +22 -1
- package/dist/otel-metrics.js.map +1 -1
- package/dist/pool-member.d.ts +61 -0
- package/dist/pool-member.js +51 -0
- package/dist/pool-member.js.map +1 -0
- package/dist/runner-manager.d.ts +7 -0
- package/dist/runner-manager.js +39 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +15 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/cold-start-timing.d.ts +89 -0
- package/dist/shared/cold-start-timing.js +166 -0
- package/dist/shared/cold-start-timing.js.map +1 -0
- package/dist/shared/connect-backfill.d.ts +2 -1
- package/dist/shared/connect-backfill.js +4 -2
- package/dist/shared/connect-backfill.js.map +1 -1
- package/dist/shared/mcp-manager.d.ts +5 -15
- package/dist/shared/mcp-manager.js +5 -32
- package/dist/shared/mcp-manager.js.map +1 -1
- package/dist/shared/mcp-resolver.d.ts +15 -5
- package/dist/shared/mcp-resolver.js +23 -6
- package/dist/shared/mcp-resolver.js.map +1 -1
- package/dist/shared/mcp-transport-guard.d.ts +53 -0
- package/dist/shared/mcp-transport-guard.js +70 -0
- package/dist/shared/mcp-transport-guard.js.map +1 -0
- package/dist/shared/tool-kind.js +6 -0
- package/dist/shared/tool-kind.js.map +1 -1
- package/dist/tools/index.d.ts +27 -0
- package/dist/tools/index.js +27 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/think-tool.js.map +1 -0
- package/dist/tools/url-guard.d.ts +64 -0
- package/dist/tools/url-guard.js +211 -0
- package/dist/tools/url-guard.js.map +1 -0
- package/dist/tools/web-fetch-tool.d.ts +53 -0
- package/dist/tools/web-fetch-tool.js +216 -0
- package/dist/tools/web-fetch-tool.js.map +1 -0
- package/package.json +4 -2
- package/src/__tests__/otel-metrics.test.ts +6 -0
- package/src/__tests__/pool-member.test.ts +76 -0
- package/src/activities/__tests__/attach-session.test.ts +133 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +34 -11
- package/src/activities/attach-session.ts +112 -0
- package/src/activities/discover-mcp-server.ts +19 -1
- package/src/activities/execute-cursor/__tests__/fetch-interceptor.test.ts +77 -0
- package/src/activities/execute-cursor/__tests__/sdk-warmup.test.ts +45 -0
- package/src/activities/execute-cursor/connect-backfill.ts +3 -0
- package/src/activities/execute-cursor/fetch-interceptor.ts +39 -0
- package/src/activities/execute-cursor/index.ts +72 -3
- package/src/activities/execute-cursor/mcp-resolver.ts +21 -2
- package/src/activities/execute-cursor/sdk-warmup.ts +68 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +24 -0
- package/src/activities/execute-deep-agent/setup.ts +48 -7
- package/src/activities/execute-deep-agent/subagent-transformer.ts +27 -2
- package/src/client/__tests__/stigmer-client.test.ts +77 -2
- package/src/client/__tests__/token-claims.test.ts +15 -1
- package/src/client/stigmer-client.ts +51 -6
- package/src/client/token-claims.ts +29 -6
- package/src/main.ts +139 -2
- package/src/middleware/index.ts +0 -1
- package/src/otel-metrics.ts +25 -1
- package/src/pool-member.ts +99 -0
- package/src/runner-manager.ts +52 -0
- package/src/runner.ts +15 -0
- package/src/shared/__tests__/cold-start-timing.test.ts +271 -0
- package/src/shared/__tests__/connect-backfill.test.ts +11 -11
- package/src/shared/__tests__/mcp-manager.test.ts +2 -73
- package/src/shared/__tests__/mcp-resolver.test.ts +108 -0
- package/src/shared/__tests__/mcp-transport-guard.test.ts +71 -0
- package/src/shared/cold-start-timing.ts +197 -0
- package/src/shared/connect-backfill.ts +5 -1
- package/src/shared/mcp-manager.ts +4 -41
- package/src/shared/mcp-resolver.ts +29 -6
- package/src/shared/mcp-transport-guard.ts +81 -0
- package/src/shared/tool-kind.ts +6 -0
- package/src/tools/__tests__/url-guard.test.ts +137 -0
- package/src/tools/__tests__/web-fetch-tool.test.ts +193 -0
- package/src/tools/index.ts +28 -0
- package/src/tools/url-guard.ts +232 -0
- package/src/tools/web-fetch-tool.ts +275 -0
- package/dist/middleware/think-tool.js.map +0 -1
- /package/dist/{middleware → tools}/think-tool.d.ts +0 -0
- /package/dist/{middleware → tools}/think-tool.js +0 -0
- /package/src/{middleware → tools}/__tests__/think-tool.test.ts +0 -0
- /package/src/{middleware → tools}/think-tool.ts +0 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
blockedReason,
|
|
4
|
+
resolveGuardPosture,
|
|
5
|
+
validateFetchUrl,
|
|
6
|
+
UrlGuardError,
|
|
7
|
+
} from "../url-guard.js";
|
|
8
|
+
|
|
9
|
+
describe("resolveGuardPosture", () => {
|
|
10
|
+
it("defaults to strict on cloud runners", () => {
|
|
11
|
+
expect(resolveGuardPosture("cloud", {})).toBe("strict");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("defaults to relaxed on local runners", () => {
|
|
15
|
+
expect(resolveGuardPosture("local", {})).toBe("relaxed");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("override=true relaxes a cloud runner", () => {
|
|
19
|
+
expect(resolveGuardPosture("cloud", { STIGMER_WEB_FETCH_ALLOW_PRIVATE: "true" })).toBe("relaxed");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("override=false hardens a local runner", () => {
|
|
23
|
+
expect(resolveGuardPosture("local", { STIGMER_WEB_FETCH_ALLOW_PRIVATE: "false" })).toBe("strict");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("ignores malformed override values", () => {
|
|
27
|
+
expect(resolveGuardPosture("cloud", { STIGMER_WEB_FETCH_ALLOW_PRIVATE: "yes" })).toBe("strict");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("blockedReason — strict posture", () => {
|
|
32
|
+
it.each([
|
|
33
|
+
["127.0.0.1", "loopback"],
|
|
34
|
+
["127.255.255.255", "loopback"],
|
|
35
|
+
["0.0.0.0", "unspecified"],
|
|
36
|
+
["10.0.0.1", "private (RFC 1918)"],
|
|
37
|
+
["172.16.0.1", "private (RFC 1918)"],
|
|
38
|
+
["172.31.255.255", "private (RFC 1918)"],
|
|
39
|
+
["192.168.1.1", "private (RFC 1918)"],
|
|
40
|
+
["169.254.169.254", "link-local (cloud metadata)"],
|
|
41
|
+
["169.254.0.1", "link-local (cloud metadata)"],
|
|
42
|
+
])("blocks %s as %s", (address, reason) => {
|
|
43
|
+
expect(blockedReason(address, "strict")).toBe(reason);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it.each(["8.8.8.8", "104.16.0.1", "172.15.0.1", "172.32.0.1", "192.169.0.1"])(
|
|
47
|
+
"allows public IPv4 %s",
|
|
48
|
+
(address) => {
|
|
49
|
+
expect(blockedReason(address, "strict")).toBeNull();
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
it.each([
|
|
54
|
+
["::1", "loopback"],
|
|
55
|
+
["::", "unspecified"],
|
|
56
|
+
["fc00::1", "private (unique local)"],
|
|
57
|
+
["fd12:3456::1", "private (unique local)"],
|
|
58
|
+
["fe80::1", "link-local (cloud metadata)"],
|
|
59
|
+
["febf::1", "link-local (cloud metadata)"],
|
|
60
|
+
])("blocks IPv6 %s as %s", (address, reason) => {
|
|
61
|
+
expect(blockedReason(address, "strict")).toBe(reason);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("allows public IPv6", () => {
|
|
65
|
+
expect(blockedReason("2606:4700::6810:1", "strict")).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("blocks IPv4-mapped IPv6 carrying a blocked v4 (dotted form)", () => {
|
|
69
|
+
expect(blockedReason("::ffff:127.0.0.1", "strict")).toBe("loopback");
|
|
70
|
+
expect(blockedReason("::ffff:10.0.0.1", "strict")).toBe("private (RFC 1918)");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("blocks IPv4-mapped IPv6 carrying a blocked v4 (hex form)", () => {
|
|
74
|
+
// ::ffff:7f00:1 is 127.0.0.1 — the hex spelling must not bypass the guard.
|
|
75
|
+
expect(blockedReason("::ffff:7f00:1", "strict")).toBe("loopback");
|
|
76
|
+
expect(blockedReason("::ffff:a9fe:a9fe", "strict")).toBe("link-local (cloud metadata)");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("allows IPv4-mapped IPv6 carrying a public v4", () => {
|
|
80
|
+
expect(blockedReason("::ffff:8.8.8.8", "strict")).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("fails closed on garbage", () => {
|
|
84
|
+
expect(blockedReason("not-an-ip", "strict")).toBe("unrecognized");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("blockedReason — relaxed posture", () => {
|
|
89
|
+
it.each(["127.0.0.1", "10.0.0.1", "192.168.1.1", "::1", "fd00::1"])(
|
|
90
|
+
"allows private/loopback %s (the machine belongs to the user)",
|
|
91
|
+
(address) => {
|
|
92
|
+
expect(blockedReason(address, "relaxed")).toBeNull();
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
it("still blocks the cloud metadata range", () => {
|
|
97
|
+
expect(blockedReason("169.254.169.254", "relaxed")).toBe("link-local (cloud metadata)");
|
|
98
|
+
expect(blockedReason("fe80::1", "relaxed")).toBe("link-local (cloud metadata)");
|
|
99
|
+
expect(blockedReason("::ffff:169.254.169.254", "relaxed")).toBe("link-local (cloud metadata)");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("validateFetchUrl", () => {
|
|
104
|
+
it("rejects malformed URLs", async () => {
|
|
105
|
+
await expect(validateFetchUrl("not a url", "strict")).rejects.toThrow(UrlGuardError);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it.each(["file:///etc/passwd", "ftp://example.com/x", "gopher://example.com"])(
|
|
109
|
+
"rejects non-http(s) scheme %s",
|
|
110
|
+
async (url) => {
|
|
111
|
+
await expect(validateFetchUrl(url, "strict")).rejects.toThrow(/only http and https/);
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
it("rejects literal blocked IPs without a DNS lookup", async () => {
|
|
116
|
+
await expect(validateFetchUrl("http://127.0.0.1:8080/x", "strict")).rejects.toThrow(/loopback/);
|
|
117
|
+
await expect(validateFetchUrl("http://169.254.169.254/latest/meta-data", "strict"))
|
|
118
|
+
.rejects.toThrow(/cloud metadata/);
|
|
119
|
+
await expect(validateFetchUrl("http://[::1]/", "strict")).rejects.toThrow(/loopback/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("allows literal loopback under the relaxed posture", async () => {
|
|
123
|
+
const url = await validateFetchUrl("http://127.0.0.1:3000/api", "relaxed");
|
|
124
|
+
expect(url.hostname).toBe("127.0.0.1");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("rejects hostnames that resolve to blocked addresses", async () => {
|
|
128
|
+
// localhost resolves to 127.0.0.1 / ::1 everywhere.
|
|
129
|
+
await expect(validateFetchUrl("http://localhost:9999/", "strict")).rejects.toThrow(UrlGuardError);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("rejects unresolvable hostnames", async () => {
|
|
133
|
+
await expect(
|
|
134
|
+
validateFetchUrl("http://definitely-not-a-real-host.stigmer.invalid/", "strict"),
|
|
135
|
+
).rejects.toThrow(/Could not resolve/);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { createWebFetchTool } from "../web-fetch-tool.js";
|
|
3
|
+
|
|
4
|
+
// Hostnames are literal public IPs throughout so the URL guard classifies
|
|
5
|
+
// them without a DNS lookup — tests stay hermetic even though fetch itself
|
|
6
|
+
// is mocked.
|
|
7
|
+
const PUBLIC_URL = "http://8.8.8.8/page";
|
|
8
|
+
|
|
9
|
+
const fetchMock = vi.fn();
|
|
10
|
+
|
|
11
|
+
function textResponse(
|
|
12
|
+
body: string,
|
|
13
|
+
init: { status?: number; contentType?: string; headers?: Record<string, string> } = {},
|
|
14
|
+
): Response {
|
|
15
|
+
return new Response(body, {
|
|
16
|
+
status: init.status ?? 200,
|
|
17
|
+
headers: { "content-type": init.contentType ?? "text/plain; charset=utf-8", ...init.headers },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function redirectResponse(location: string, status = 302): Response {
|
|
22
|
+
return new Response(null, { status, headers: { location } });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
27
|
+
fetchMock.mockReset();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.unstubAllGlobals();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const invoke = (
|
|
35
|
+
input: { url: string; max_length?: number; start_index?: number },
|
|
36
|
+
posture: "strict" | "relaxed" = "strict",
|
|
37
|
+
) => createWebFetchTool({ posture }).invoke(input) as Promise<string>;
|
|
38
|
+
|
|
39
|
+
describe("web_fetch — identity", () => {
|
|
40
|
+
it("is named web_fetch with a url-first schema", () => {
|
|
41
|
+
const t = createWebFetchTool({ posture: "strict" });
|
|
42
|
+
expect(t.name).toBe("web_fetch");
|
|
43
|
+
expect(t.description).toContain("Fetch the contents of a URL");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("web_fetch — content handling", () => {
|
|
48
|
+
it("converts HTML to Markdown and strips chrome", async () => {
|
|
49
|
+
fetchMock.mockResolvedValueOnce(textResponse(
|
|
50
|
+
`<html><head><title>T</title><script>evil()</script><style>.x{}</style></head>
|
|
51
|
+
<body><nav><a href="/home">Home</a></nav>
|
|
52
|
+
<h1>Docs</h1><p>Hello <a href="https://stigmer.ai">Stigmer</a>.</p>
|
|
53
|
+
<footer>© 2026</footer></body></html>`,
|
|
54
|
+
{ contentType: "text/html" },
|
|
55
|
+
));
|
|
56
|
+
|
|
57
|
+
const result = await invoke({ url: PUBLIC_URL });
|
|
58
|
+
expect(result).toContain("# Docs");
|
|
59
|
+
expect(result).toContain("[Stigmer](https://stigmer.ai)");
|
|
60
|
+
expect(result).not.toContain("evil()");
|
|
61
|
+
expect(result).not.toContain(".x{}");
|
|
62
|
+
expect(result).not.toContain("Home");
|
|
63
|
+
expect(result).not.toContain("© 2026");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns non-HTML text verbatim", async () => {
|
|
67
|
+
const markdown = "# Already markdown\n\nplain content";
|
|
68
|
+
fetchMock.mockResolvedValueOnce(textResponse(markdown, { contentType: "text/markdown" }));
|
|
69
|
+
await expect(invoke({ url: PUBLIC_URL })).resolves.toBe(markdown);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("treats a missing content-type as text", async () => {
|
|
73
|
+
const response = new Response("raw", { status: 200 });
|
|
74
|
+
response.headers.delete("content-type");
|
|
75
|
+
fetchMock.mockResolvedValueOnce(response);
|
|
76
|
+
await expect(invoke({ url: PUBLIC_URL })).resolves.toBe("raw");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("refuses binary content", async () => {
|
|
80
|
+
fetchMock.mockResolvedValueOnce(textResponse("\x89PNG", { contentType: "image/png" }));
|
|
81
|
+
const result = await invoke({ url: PUBLIC_URL });
|
|
82
|
+
expect(result).toMatch(/^Error: .*non-text content \(image\/png\)/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("reports HTTP error statuses", async () => {
|
|
86
|
+
fetchMock.mockResolvedValueOnce(textResponse("gone", { status: 404 }));
|
|
87
|
+
const result = await invoke({ url: PUBLIC_URL });
|
|
88
|
+
expect(result).toContain("Error:");
|
|
89
|
+
expect(result).toContain("404");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("describes an empty body instead of returning nothing", async () => {
|
|
93
|
+
fetchMock.mockResolvedValueOnce(textResponse(""));
|
|
94
|
+
const result = await invoke({ url: PUBLIC_URL });
|
|
95
|
+
expect(result).toContain("empty body");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("web_fetch — pagination", () => {
|
|
100
|
+
const body = "abcdefghij".repeat(10); // 100 chars
|
|
101
|
+
|
|
102
|
+
it("windows content by max_length with a continuation notice", async () => {
|
|
103
|
+
fetchMock.mockResolvedValueOnce(textResponse(body));
|
|
104
|
+
const result = await invoke({ url: PUBLIC_URL, max_length: 40 });
|
|
105
|
+
expect(result.startsWith(body.slice(0, 40))).toBe(true);
|
|
106
|
+
expect(result).toContain("truncated at 40 of 100");
|
|
107
|
+
expect(result).toContain("start_index=40");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("continues from start_index without a notice at the end", async () => {
|
|
111
|
+
fetchMock.mockResolvedValueOnce(textResponse(body));
|
|
112
|
+
const result = await invoke({ url: PUBLIC_URL, max_length: 60, start_index: 40 });
|
|
113
|
+
expect(result).toBe(body.slice(40));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("rejects a start_index past the end", async () => {
|
|
117
|
+
fetchMock.mockResolvedValueOnce(textResponse(body));
|
|
118
|
+
const result = await invoke({ url: PUBLIC_URL, start_index: 500 });
|
|
119
|
+
expect(result).toContain("Error: start_index 500 is beyond the end");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("caps unbounded responses at the byte limit and says so", async () => {
|
|
123
|
+
fetchMock.mockResolvedValueOnce(textResponse("a".repeat(2 * 1024 * 1024 + 100)));
|
|
124
|
+
const result = await invoke({ url: PUBLIC_URL, max_length: 50 });
|
|
125
|
+
expect(result).toContain("exceeded the 2 MB fetch limit");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("web_fetch — redirects", () => {
|
|
130
|
+
it("follows redirects and resolves relative Location headers", async () => {
|
|
131
|
+
fetchMock
|
|
132
|
+
.mockResolvedValueOnce(redirectResponse("/moved"))
|
|
133
|
+
.mockResolvedValueOnce(textResponse("landed"));
|
|
134
|
+
|
|
135
|
+
const result = await invoke({ url: PUBLIC_URL });
|
|
136
|
+
expect(result).toBe("landed");
|
|
137
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
138
|
+
expect(String(fetchMock.mock.calls[1][0])).toBe("http://8.8.8.8/moved");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("re-guards every hop — a redirect into a private address is refused", async () => {
|
|
142
|
+
fetchMock.mockResolvedValueOnce(redirectResponse("http://10.0.0.1/internal"));
|
|
143
|
+
const result = await invoke({ url: PUBLIC_URL });
|
|
144
|
+
expect(result).toMatch(/^Error: .*private/);
|
|
145
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("gives up after too many redirects", async () => {
|
|
149
|
+
fetchMock.mockResolvedValue(redirectResponse("/again"));
|
|
150
|
+
const result = await invoke({ url: PUBLIC_URL });
|
|
151
|
+
expect(result).toContain("too many redirects");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("fails on a redirect without a Location header", async () => {
|
|
155
|
+
fetchMock.mockResolvedValueOnce(new Response(null, { status: 302 }));
|
|
156
|
+
const result = await invoke({ url: PUBLIC_URL });
|
|
157
|
+
expect(result).toContain("without a Location header");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("web_fetch — guard and failure surface", () => {
|
|
162
|
+
it("refuses blocked addresses under the strict posture", async () => {
|
|
163
|
+
const result = await invoke({ url: "http://127.0.0.1:8080/x" });
|
|
164
|
+
expect(result).toMatch(/^Error: .*loopback/);
|
|
165
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("allows loopback under the relaxed posture", async () => {
|
|
169
|
+
fetchMock.mockResolvedValueOnce(textResponse("local dev server"));
|
|
170
|
+
const result = await invoke({ url: "http://127.0.0.1:3000/" }, "relaxed");
|
|
171
|
+
expect(result).toBe("local dev server");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("refuses non-http schemes", async () => {
|
|
175
|
+
const result = await invoke({ url: "file:///etc/passwd" });
|
|
176
|
+
expect(result).toMatch(/^Error: .*only http and https/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("reports timeouts as a readable error", async () => {
|
|
180
|
+
fetchMock.mockRejectedValueOnce(
|
|
181
|
+
Object.assign(new Error("operation timed out"), { name: "TimeoutError" }),
|
|
182
|
+
);
|
|
183
|
+
const result = await invoke({ url: PUBLIC_URL });
|
|
184
|
+
expect(result).toContain("timed out after 15s");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("reports network failures without throwing", async () => {
|
|
188
|
+
fetchMock.mockRejectedValueOnce(new Error("socket hang up"));
|
|
189
|
+
const result = await invoke({ url: PUBLIC_URL });
|
|
190
|
+
expect(result).toContain("Error: Failed to fetch");
|
|
191
|
+
expect(result).toContain("socket hang up");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native built-in tools for the deep-agent harness.
|
|
3
|
+
*
|
|
4
|
+
* These are Stigmer-authored capabilities appended to the agent's tool list
|
|
5
|
+
* in execute-deep-agent/setup.ts (and mirrored for sub-agents in
|
|
6
|
+
* subagent-transformer.ts) — distinct from deepagents' backend tools
|
|
7
|
+
* (ls/read_file/execute/…) and from MCP tools, which arrive via the
|
|
8
|
+
* connection layer.
|
|
9
|
+
*
|
|
10
|
+
* Contract for adding a native tool (what keeps the two harnesses at parity):
|
|
11
|
+
*
|
|
12
|
+
* 1. Name it in snake_case (`web_fetch`, not `WebFetch` — PascalCase names
|
|
13
|
+
* belong to the Cursor harness).
|
|
14
|
+
* 2. Register the name in shared/tool-kind.ts `TOOL_NAME_TO_KIND` and its
|
|
15
|
+
* lockstep mirrors (test/fixtures/tool-view/classification.json, the SDK
|
|
16
|
+
* fallback resolver), and extend the ToolKind enum comment in
|
|
17
|
+
* agentexecution/v1/enum.proto with a `Native:` clause.
|
|
18
|
+
* 3. Name the primary argument to match the SDK's presentation `primaryField`
|
|
19
|
+
* for the tool's kind (sdk/react tool-categories.ts) so both harnesses
|
|
20
|
+
* render identically.
|
|
21
|
+
* 4. Decide the approval posture explicitly: unless the tool's kind maps to
|
|
22
|
+
* an approval category in `toolApprovalCategory`, it is auto-approved.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export { createThinkTool } from "./think-tool.js";
|
|
26
|
+
export { createWebFetchTool } from "./web-fetch-tool.js";
|
|
27
|
+
export { validateFetchUrl, resolveGuardPosture, UrlGuardError } from "./url-guard.js";
|
|
28
|
+
export type { GuardPosture } from "./url-guard.js";
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL guard for the native `web_fetch` tool — the SSRF boundary.
|
|
3
|
+
*
|
|
4
|
+
* Why this is load-bearing: `web_fetch` is deliberately auto-approved (see
|
|
5
|
+
* shared/tool-kind.ts `toolApprovalCategory`), so in unattended runs it is
|
|
6
|
+
* the only ungated network path an agent has. On managed cloud runners the
|
|
7
|
+
* fetch executes inside the runner process, which sits on infrastructure
|
|
8
|
+
* with network reach to internal services and the cloud metadata endpoint —
|
|
9
|
+
* without this guard, "fetch a URL" becomes "read my pod's credentials".
|
|
10
|
+
*
|
|
11
|
+
* Posture is mode-aware because locality differs, not trust:
|
|
12
|
+
*
|
|
13
|
+
* - "strict" (managed cloud runners): loopback, RFC 1918 private,
|
|
14
|
+
* link-local, and their IPv6 equivalents are all rejected.
|
|
15
|
+
* - "relaxed" (self-hosted / local runners): the machine belongs to the
|
|
16
|
+
* user, and fetching their own dev server (http://localhost:3000) is a
|
|
17
|
+
* legitimate ask — only the link-local range (which contains the cloud
|
|
18
|
+
* metadata endpoint 169.254.169.254) stays blocked, as costless
|
|
19
|
+
* defense-in-depth.
|
|
20
|
+
*
|
|
21
|
+
* The default posture derives from Config.mode ("cloud" → strict), NOT from
|
|
22
|
+
* cloudModeEnabled — that is the Cursor cloud-agent feature flag and says
|
|
23
|
+
* nothing about where this process runs. Config.mode tracks credential
|
|
24
|
+
* transport rather than physical locality (a desktop runner proxies traffic
|
|
25
|
+
* while executing on the user's machine), so STIGMER_WEB_FETCH_ALLOW_PRIVATE
|
|
26
|
+
* exists as an explicit override for embedders that know better. Managed
|
|
27
|
+
* cloud deployments never set it.
|
|
28
|
+
*
|
|
29
|
+
* Validation runs on the addresses DNS resolves to immediately before the
|
|
30
|
+
* request is dispatched, and the caller re-validates every redirect hop.
|
|
31
|
+
* Accepted limitation: a DNS-rebinding window remains between our lookup
|
|
32
|
+
* and the socket connect — Node's fetch (undici) offers no lookup pinning
|
|
33
|
+
* without replacing the dispatcher, and the strict posture's range blocks
|
|
34
|
+
* make the rebinding payoff (an internal address) unreachable anyway.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { lookup } from "node:dns/promises";
|
|
38
|
+
import { isIP } from "node:net";
|
|
39
|
+
|
|
40
|
+
export type GuardPosture = "strict" | "relaxed";
|
|
41
|
+
|
|
42
|
+
/** Thrown for every guard rejection; message is safe to surface to the model. */
|
|
43
|
+
export class UrlGuardError extends Error {
|
|
44
|
+
constructor(message: string) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "UrlGuardError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Derive the guard posture from runner mode plus the explicit override.
|
|
52
|
+
*
|
|
53
|
+
* @param mode Config.mode — "cloud" means a managed runner on shared infrastructure.
|
|
54
|
+
* @param env Environment to read STIGMER_WEB_FETCH_ALLOW_PRIVATE from.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveGuardPosture(
|
|
57
|
+
mode: "local" | "cloud",
|
|
58
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
59
|
+
): GuardPosture {
|
|
60
|
+
const override = env.STIGMER_WEB_FETCH_ALLOW_PRIVATE;
|
|
61
|
+
if (override === "true") return "relaxed";
|
|
62
|
+
if (override === "false") return "strict";
|
|
63
|
+
return mode === "cloud" ? "strict" : "relaxed";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validate a URL for fetching under the given posture.
|
|
68
|
+
*
|
|
69
|
+
* Checks the scheme, then resolves the hostname and checks EVERY address it
|
|
70
|
+
* resolves to (a hostname with one public and one private A record must be
|
|
71
|
+
* rejected, or the private record becomes the bypass).
|
|
72
|
+
*
|
|
73
|
+
* @returns the parsed URL on success.
|
|
74
|
+
* @throws UrlGuardError with a model-readable reason on rejection.
|
|
75
|
+
*/
|
|
76
|
+
export async function validateFetchUrl(
|
|
77
|
+
rawUrl: string,
|
|
78
|
+
posture: GuardPosture,
|
|
79
|
+
): Promise<URL> {
|
|
80
|
+
let url: URL;
|
|
81
|
+
try {
|
|
82
|
+
url = new URL(rawUrl);
|
|
83
|
+
} catch {
|
|
84
|
+
throw new UrlGuardError(`Invalid URL: ${rawUrl}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
88
|
+
throw new UrlGuardError(
|
|
89
|
+
`Unsupported URL scheme "${url.protocol.replace(/:$/, "")}" — only http and https are allowed.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const addresses = await resolveAddresses(url.hostname);
|
|
94
|
+
for (const address of addresses) {
|
|
95
|
+
const reason = blockedReason(address, posture);
|
|
96
|
+
if (reason) {
|
|
97
|
+
throw new UrlGuardError(
|
|
98
|
+
`Refusing to fetch ${url.hostname}: it resolves to ${address}, a ${reason} address that this runner does not fetch from.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return url;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve a hostname to all of its addresses. Literal IPs pass through
|
|
108
|
+
* (bracketed IPv6 hosts arrive from URL.hostname still bracketed).
|
|
109
|
+
*/
|
|
110
|
+
async function resolveAddresses(hostname: string): Promise<string[]> {
|
|
111
|
+
const literal = hostname.replace(/^\[|\]$/g, "");
|
|
112
|
+
if (isIP(literal) !== 0) {
|
|
113
|
+
return [literal];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const records = await lookup(hostname, { all: true, verbatim: true });
|
|
118
|
+
return records.map((r) => r.address);
|
|
119
|
+
} catch {
|
|
120
|
+
throw new UrlGuardError(`Could not resolve hostname: ${hostname}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Classify an IP address against the posture's blocked ranges.
|
|
126
|
+
*
|
|
127
|
+
* @returns a human-readable range name when blocked, or null when allowed.
|
|
128
|
+
*/
|
|
129
|
+
export function blockedReason(address: string, posture: GuardPosture): string | null {
|
|
130
|
+
const family = isIP(address);
|
|
131
|
+
|
|
132
|
+
if (family === 4) {
|
|
133
|
+
return blockedReasonV4(address, posture);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (family === 6) {
|
|
137
|
+
const groups = expandV6(address);
|
|
138
|
+
if (!groups) return "unrecognized";
|
|
139
|
+
|
|
140
|
+
// IPv4-mapped IPv6 (::ffff:a.b.c.d, in dotted OR hex form) — classify
|
|
141
|
+
// the embedded IPv4 so the mapping cannot smuggle a blocked v4 address
|
|
142
|
+
// past the guard.
|
|
143
|
+
const embedded = extractMappedV4(groups);
|
|
144
|
+
if (embedded) {
|
|
145
|
+
return blockedReasonV4(embedded, posture);
|
|
146
|
+
}
|
|
147
|
+
return blockedReasonV6(groups, posture);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Unparseable — fail closed; only real addresses get sockets.
|
|
151
|
+
return "unrecognized";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function blockedReasonV4(address: string, posture: GuardPosture): string | null {
|
|
155
|
+
const octets = address.split(".").map(Number);
|
|
156
|
+
if (octets.length !== 4 || octets.some((o) => Number.isNaN(o) || o < 0 || o > 255)) {
|
|
157
|
+
return "unrecognized";
|
|
158
|
+
}
|
|
159
|
+
const [a, b] = octets;
|
|
160
|
+
|
|
161
|
+
// Link-local (169.254.0.0/16) hosts the cloud metadata service at
|
|
162
|
+
// 169.254.169.254 — blocked under BOTH postures.
|
|
163
|
+
if (a === 169 && b === 254) return "link-local (cloud metadata)";
|
|
164
|
+
|
|
165
|
+
if (posture === "relaxed") return null;
|
|
166
|
+
|
|
167
|
+
if (a === 127) return "loopback";
|
|
168
|
+
if (a === 0) return "unspecified";
|
|
169
|
+
if (a === 10) return "private (RFC 1918)";
|
|
170
|
+
if (a === 172 && b >= 16 && b <= 31) return "private (RFC 1918)";
|
|
171
|
+
if (a === 192 && b === 168) return "private (RFC 1918)";
|
|
172
|
+
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function blockedReasonV6(groups: readonly number[], posture: GuardPosture): string | null {
|
|
177
|
+
// fe80::/10 — IPv6 link-local, the v6 sibling of the metadata range;
|
|
178
|
+
// blocked under BOTH postures for symmetry with v4.
|
|
179
|
+
if ((groups[0] & 0xffc0) === 0xfe80) return "link-local (cloud metadata)";
|
|
180
|
+
|
|
181
|
+
if (posture === "relaxed") return null;
|
|
182
|
+
|
|
183
|
+
const allZero = groups.every((g) => g === 0);
|
|
184
|
+
if (allZero) return "unspecified";
|
|
185
|
+
if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return "loopback";
|
|
186
|
+
// fc00::/7 — unique local (private) addresses.
|
|
187
|
+
if ((groups[0] & 0xfe00) === 0xfc00) return "private (unique local)";
|
|
188
|
+
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Expand an IPv6 address (already validated by isIP) into its 8 groups.
|
|
194
|
+
* Handles `::` compression, a trailing dotted-IPv4 tail, and zone suffixes.
|
|
195
|
+
*/
|
|
196
|
+
function expandV6(address: string): number[] | null {
|
|
197
|
+
let text = address.toLowerCase().split("%")[0];
|
|
198
|
+
|
|
199
|
+
// Convert a dotted-IPv4 tail (e.g. ::ffff:127.0.0.1) into two hex groups.
|
|
200
|
+
const v4Tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
|
|
201
|
+
if (v4Tail) {
|
|
202
|
+
const octets = v4Tail[1].split(".").map(Number);
|
|
203
|
+
if (octets.length !== 4 || octets.some((o) => o > 255)) return null;
|
|
204
|
+
const hex = `${((octets[0] << 8) | octets[1]).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`;
|
|
205
|
+
text = text.slice(0, -v4Tail[1].length) + hex;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const halves = text.split("::");
|
|
209
|
+
if (halves.length > 2) return null;
|
|
210
|
+
|
|
211
|
+
const parseHalf = (half: string): number[] =>
|
|
212
|
+
half === "" ? [] : half.split(":").map((g) => parseInt(g, 16));
|
|
213
|
+
|
|
214
|
+
const head = parseHalf(halves[0]);
|
|
215
|
+
const tail = halves.length === 2 ? parseHalf(halves[1]) : [];
|
|
216
|
+
const fill = 8 - head.length - tail.length;
|
|
217
|
+
if (halves.length === 2 ? fill < 0 : head.length !== 8) return null;
|
|
218
|
+
|
|
219
|
+
const groups = [...head, ...(halves.length === 2 ? Array(fill).fill(0) : []), ...tail];
|
|
220
|
+
if (groups.length !== 8 || groups.some((g) => Number.isNaN(g) || g < 0 || g > 0xffff)) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
return groups;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Extract the IPv4 payload from IPv4-mapped groups (::ffff:0:0/96), if any. */
|
|
227
|
+
function extractMappedV4(groups: readonly number[]): string | null {
|
|
228
|
+
const isMapped = groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff;
|
|
229
|
+
if (!isMapped) return null;
|
|
230
|
+
const [hi, lo] = [groups[6], groups[7]];
|
|
231
|
+
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
|
232
|
+
}
|