@polpo-ai/tools 0.18.18 → 0.18.20
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/__tests__/http-hardening.test.d.ts +2 -0
- package/dist/__tests__/http-hardening.test.d.ts.map +1 -0
- package/dist/__tests__/http-hardening.test.js +188 -0
- package/dist/__tests__/http-hardening.test.js.map +1 -0
- package/dist/__tests__/image-tools.test.d.ts +2 -0
- package/dist/__tests__/image-tools.test.d.ts.map +1 -0
- package/dist/__tests__/image-tools.test.js +72 -0
- package/dist/__tests__/image-tools.test.js.map +1 -0
- package/dist/__tests__/path-sandbox.test.d.ts +2 -0
- package/dist/__tests__/path-sandbox.test.d.ts.map +1 -0
- package/dist/__tests__/path-sandbox.test.js +58 -0
- package/dist/__tests__/path-sandbox.test.js.map +1 -0
- package/dist/__tests__/system-tools.test.js +145 -1
- package/dist/__tests__/system-tools.test.js.map +1 -1
- package/dist/adapters/node-shell.d.ts +8 -2
- package/dist/adapters/node-shell.d.ts.map +1 -1
- package/dist/adapters/node-shell.js +46 -21
- package/dist/adapters/node-shell.js.map +1 -1
- package/dist/http-tools.d.ts.map +1 -1
- package/dist/http-tools.js +82 -29
- package/dist/http-tools.js.map +1 -1
- package/dist/image-tools.d.ts.map +1 -1
- package/dist/image-tools.js +12 -5
- package/dist/image-tools.js.map +1 -1
- package/dist/lib/provider-resolver.d.ts +3 -0
- package/dist/lib/provider-resolver.d.ts.map +1 -1
- package/dist/lib/provider-resolver.js +8 -0
- package/dist/lib/provider-resolver.js.map +1 -1
- package/dist/path-sandbox.d.ts.map +1 -1
- package/dist/path-sandbox.js +57 -9
- package/dist/path-sandbox.js.map +1 -1
- package/dist/safe-fetch.d.ts +52 -0
- package/dist/safe-fetch.d.ts.map +1 -0
- package/dist/safe-fetch.js +202 -0
- package/dist/safe-fetch.js.map +1 -0
- package/dist/shell-quote.d.ts +10 -0
- package/dist/shell-quote.d.ts.map +1 -0
- package/dist/shell-quote.js +12 -0
- package/dist/shell-quote.js.map +1 -0
- package/dist/ssrf-guard.d.ts +14 -0
- package/dist/ssrf-guard.d.ts.map +1 -1
- package/dist/ssrf-guard.js +80 -3
- package/dist/ssrf-guard.js.map +1 -1
- package/dist/system-tools.d.ts.map +1 -1
- package/dist/system-tools.js +166 -28
- package/dist/system-tools.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http-hardening.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/http-hardening.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* http_fetch / http_download hardening: SSRF on redirects, DNS-pinned
|
|
3
|
+
* connects, bounded bodies and an overall deadline.
|
|
4
|
+
*/
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { createHttpTools } from "../http-tools.js";
|
|
11
|
+
import { safeFetch, readBodyCapped } from "../safe-fetch.js";
|
|
12
|
+
import { assertUrlAllowed, blockedAddressReason } from "../ssrf-guard.js";
|
|
13
|
+
let cwd;
|
|
14
|
+
const originalFetch = globalThis.fetch;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
cwd = mkdtempSync(join(tmpdir(), "polpo-http-"));
|
|
17
|
+
});
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
globalThis.fetch = originalFetch;
|
|
20
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
function tool(name) {
|
|
23
|
+
return createHttpTools(cwd, [cwd], [name])[0];
|
|
24
|
+
}
|
|
25
|
+
function text(result) {
|
|
26
|
+
return result.content[0].text;
|
|
27
|
+
}
|
|
28
|
+
function mockFetch(impl) {
|
|
29
|
+
const fn = vi.fn(impl);
|
|
30
|
+
globalThis.fetch = fn;
|
|
31
|
+
return fn;
|
|
32
|
+
}
|
|
33
|
+
function redirect(status, location) {
|
|
34
|
+
return new Response(null, { status, headers: { location } });
|
|
35
|
+
}
|
|
36
|
+
/** A body that never ends. */
|
|
37
|
+
function endlessBody(onCancel) {
|
|
38
|
+
const chunk = new Uint8Array(64 * 1024).fill(0x61);
|
|
39
|
+
return new ReadableStream({
|
|
40
|
+
pull(controller) { controller.enqueue(chunk); },
|
|
41
|
+
cancel() { onCancel?.(); },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
describe("http_fetch — redirects re-apply the SSRF guard", () => {
|
|
45
|
+
it("refuses a redirect to the cloud metadata address and never requests it", async () => {
|
|
46
|
+
const fetchMock = mockFetch(async (url) => {
|
|
47
|
+
if (url.startsWith("https://public.example/"))
|
|
48
|
+
return redirect(302, "http://169.254.169.254/latest/meta-data/");
|
|
49
|
+
return new Response("SECRET-CREDENTIALS", { status: 200, headers: { "content-type": "text/plain" } });
|
|
50
|
+
});
|
|
51
|
+
const result = await tool("http_fetch").execute("c1", { url: "https://public.example/start" });
|
|
52
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
53
|
+
expect(fetchMock.mock.calls[0][1]).toMatchObject({ redirect: "manual" });
|
|
54
|
+
expect(text(result)).not.toContain("SECRET-CREDENTIALS");
|
|
55
|
+
expect(result.details).toMatchObject({ error: "ssrf_blocked" });
|
|
56
|
+
expect(text(result)).toMatch(/169\.254\.169\.254/);
|
|
57
|
+
});
|
|
58
|
+
it("refuses redirects to private hosts, IPv4-mapped IPv6 and non-http schemes", async () => {
|
|
59
|
+
for (const location of ["http://10.0.0.5/", "http://[::ffff:127.0.0.1]:8080/", "http://localhost./", "file:///etc/passwd"]) {
|
|
60
|
+
const fetchMock = mockFetch(async () => redirect(307, location));
|
|
61
|
+
const result = await tool("http_fetch").execute("c1", { url: "https://public.example/" });
|
|
62
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
63
|
+
expect(result.details).toMatchObject({ error: "ssrf_blocked" });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
it("follows public redirects, rewrites 303 to GET and drops credentials across origins", async () => {
|
|
67
|
+
const fetchMock = mockFetch(async (url) => {
|
|
68
|
+
if (url === "https://a.example/submit")
|
|
69
|
+
return redirect(303, "https://b.example/result");
|
|
70
|
+
return new Response("done", { status: 200, headers: { "content-type": "text/plain" } });
|
|
71
|
+
});
|
|
72
|
+
const result = await tool("http_fetch").execute("c1", {
|
|
73
|
+
url: "https://a.example/submit",
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { Authorization: "Bearer secret", "Content-Type": "application/json", "X-Trace": "1" },
|
|
76
|
+
body: "{}",
|
|
77
|
+
});
|
|
78
|
+
expect(text(result)).toContain("done");
|
|
79
|
+
expect(result.details).toMatchObject({ status: 200, redirects: 1, finalUrl: "https://b.example/result" });
|
|
80
|
+
const second = fetchMock.mock.calls[1];
|
|
81
|
+
expect(second[0]).toBe("https://b.example/result");
|
|
82
|
+
expect(second[1]).toMatchObject({ method: "GET", body: undefined, redirect: "manual" });
|
|
83
|
+
expect(second[1].headers).toEqual({ "X-Trace": "1" });
|
|
84
|
+
});
|
|
85
|
+
it("stops after the redirect limit", async () => {
|
|
86
|
+
let n = 0;
|
|
87
|
+
const fetchMock = mockFetch(async () => redirect(302, `https://loop.example/${++n}`));
|
|
88
|
+
const result = await tool("http_fetch").execute("c1", { url: "https://loop.example/0" });
|
|
89
|
+
expect(text(result)).toMatch(/too many redirects/i);
|
|
90
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe("http_fetch — bounded body and overall deadline", () => {
|
|
94
|
+
it("stops reading an endless body at the cap and marks it truncated", { timeout: 10_000 }, async () => {
|
|
95
|
+
let cancelled = false;
|
|
96
|
+
mockFetch(async () => new Response(endlessBody(() => { cancelled = true; }), {
|
|
97
|
+
status: 200,
|
|
98
|
+
headers: { "content-type": "text/plain" },
|
|
99
|
+
}));
|
|
100
|
+
const result = await tool("http_fetch").execute("c1", { url: "https://big.example/" });
|
|
101
|
+
expect(text(result)).toMatch(/\[truncated — response body exceeded 100000 bytes\]/);
|
|
102
|
+
expect(text(result).length).toBeLessThan(110_000);
|
|
103
|
+
expect(result.details).toMatchObject({ truncated: true });
|
|
104
|
+
expect(cancelled).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
it("applies the timeout to the body read, not just to the headers", { timeout: 10_000 }, async () => {
|
|
107
|
+
mockFetch(async (_url, init) => {
|
|
108
|
+
// Headers arrive immediately, the body never does. Mirror real fetch:
|
|
109
|
+
// aborting the request errors the body stream.
|
|
110
|
+
const body = new ReadableStream({
|
|
111
|
+
start(controller) {
|
|
112
|
+
init?.signal?.addEventListener("abort", () => controller.error(new DOMException("aborted", "AbortError")));
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
return new Response(body, { status: 200, headers: { "content-type": "text/plain" } });
|
|
116
|
+
});
|
|
117
|
+
const t0 = Date.now();
|
|
118
|
+
const result = await tool("http_fetch").execute("c1", { url: "https://slow.example/", timeout: 200 });
|
|
119
|
+
expect(Date.now() - t0).toBeLessThan(5_000);
|
|
120
|
+
expect(text(result)).toMatch(/timed out after 200ms/);
|
|
121
|
+
});
|
|
122
|
+
it("treats timeout 0 / negative as the default instead of an instant abort", async () => {
|
|
123
|
+
mockFetch(async () => new Response("ok", { status: 200, headers: { "content-type": "text/plain" } }));
|
|
124
|
+
for (const timeout of [0, -1]) {
|
|
125
|
+
const result = await tool("http_fetch").execute("c1", { url: "https://ok.example/", timeout });
|
|
126
|
+
expect(text(result)).toContain("ok");
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
it("http_download reapplies the SSRF guard on redirects", async () => {
|
|
130
|
+
mockFetch(async () => redirect(301, "http://192.168.1.1/router.cfg"));
|
|
131
|
+
const result = await tool("http_download").execute("c1", { url: "https://files.example/x", path: "x.bin" });
|
|
132
|
+
expect(result.details).toMatchObject({ error: "ssrf_blocked" });
|
|
133
|
+
expect(existsSync(join(cwd, "x.bin"))).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
describe("safeFetch — DNS answers are validated at connect time", () => {
|
|
137
|
+
let server;
|
|
138
|
+
let port;
|
|
139
|
+
let hits;
|
|
140
|
+
beforeEach(async () => {
|
|
141
|
+
hits = 0;
|
|
142
|
+
server = createServer((_req, res) => { hits++; res.end("internal-only"); });
|
|
143
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
144
|
+
port = server.address().port;
|
|
145
|
+
});
|
|
146
|
+
afterEach(async () => {
|
|
147
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
148
|
+
});
|
|
149
|
+
it("refuses a public-looking hostname that resolves to loopback (DNS rebinding)", async () => {
|
|
150
|
+
const controller = new AbortController();
|
|
151
|
+
const resolveHostname = async () => ["127.0.0.1"];
|
|
152
|
+
// The URL itself passes the static guard…
|
|
153
|
+
expect(() => assertUrlAllowed(`http://rebind.example:${port}/`)).not.toThrow();
|
|
154
|
+
// …but the connection is refused once DNS answers are inspected.
|
|
155
|
+
await expect(safeFetch(`http://rebind.example:${port}/`, { signal: controller.signal, resolveHostname })).rejects.toSatisfy((err) => /SSRF blocked/.test(`${err?.message} ${err?.cause?.message}`));
|
|
156
|
+
expect(hits).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
it("refuses when any of the answers is private", async () => {
|
|
159
|
+
const controller = new AbortController();
|
|
160
|
+
const resolveHostname = async () => ["93.184.216.34", "10.1.2.3"];
|
|
161
|
+
await expect(safeFetch(`http://mixed.example:${port}/`, { signal: controller.signal, resolveHostname })).rejects.toSatisfy((err) => /10\.1\.2\.3/.test(`${err?.message} ${err?.cause?.message}`));
|
|
162
|
+
expect(hits).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
describe("ssrf guard — address classification", () => {
|
|
166
|
+
it.each([
|
|
167
|
+
"127.0.0.1", "10.0.0.1", "100.64.0.1", "169.254.169.254", "0.1.2.3", "224.0.0.1",
|
|
168
|
+
"::1", "::", "fd00::1", "fe80::1", "::ffff:127.0.0.1", "::ffff:a9fe:a9fe", "64:ff9b::a9fe:a9fe",
|
|
169
|
+
])("blocks %s", (address) => {
|
|
170
|
+
expect(blockedAddressReason(address)).toBeTruthy();
|
|
171
|
+
});
|
|
172
|
+
it.each(["8.8.8.8", "93.184.216.34", "2606:4700::1111", "172.32.0.1"])("allows %s", (address) => {
|
|
173
|
+
expect(blockedAddressReason(address)).toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
it("blocks URL literals that the old guard let through", () => {
|
|
176
|
+
for (const url of ["http://[::ffff:127.0.0.1]/", "http://[fd12::1]/", "http://100.64.1.1/", "http://localhost./", "http://app.localhost/"]) {
|
|
177
|
+
expect(() => assertUrlAllowed(url)).toThrow(/SSRF blocked/);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
describe("readBodyCapped", () => {
|
|
182
|
+
it("returns the full body under the cap", async () => {
|
|
183
|
+
const { bytes, truncated } = await readBodyCapped(new Response("hello"), 10);
|
|
184
|
+
expect(new TextDecoder().decode(bytes)).toBe("hello");
|
|
185
|
+
expect(truncated).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
//# sourceMappingURL=http-hardening.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http-hardening.test.js","sourceRoot":"","sources":["../../src/__tests__/http-hardening.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"image-tools.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/image-tools.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { renderToolResult } from "@polpo-ai/core";
|
|
6
|
+
const generateText = vi.fn();
|
|
7
|
+
const gatewayLanguageModel = vi.fn((id) => ({ via: "gateway", id }));
|
|
8
|
+
const anthropicModel = vi.fn((id) => ({ via: "anthropic", id }));
|
|
9
|
+
const createAnthropic = vi.fn(() => anthropicModel);
|
|
10
|
+
vi.mock("ai", () => ({ generateText }));
|
|
11
|
+
vi.mock("@ai-sdk/gateway", () => ({
|
|
12
|
+
createGatewayProvider: () => ({ languageModel: gatewayLanguageModel }),
|
|
13
|
+
}));
|
|
14
|
+
vi.mock("@ai-sdk/anthropic", () => ({ createAnthropic }));
|
|
15
|
+
const { createImageTools } = await import("../image-tools.js");
|
|
16
|
+
let cwd;
|
|
17
|
+
const savedEnv = { ...process.env };
|
|
18
|
+
function analyzeTool(vault) {
|
|
19
|
+
const tool = createImageTools({
|
|
20
|
+
cwd,
|
|
21
|
+
allowedTools: ["image_analyze"],
|
|
22
|
+
visionModel: "anthropic/claude-haiku-5.5",
|
|
23
|
+
...(vault ? { vault: vault } : {}),
|
|
24
|
+
})[0];
|
|
25
|
+
return (params) => tool.execute("call-1", params);
|
|
26
|
+
}
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
cwd = mkdtempSync(join(tmpdir(), "polpo-image-tools-"));
|
|
29
|
+
writeFileSync(join(cwd, "shot.png"), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13]));
|
|
30
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
31
|
+
delete process.env.AI_GATEWAY_API_KEY;
|
|
32
|
+
generateText.mockReset();
|
|
33
|
+
generateText.mockResolvedValue({
|
|
34
|
+
text: "A seaside apartment",
|
|
35
|
+
usage: { totalTokens: 12, inputTokens: 10, outputTokens: 2 },
|
|
36
|
+
providerMetadata: { gateway: { generationId: "gen_1", marketCost: "0.0004", cost: "0.0004" } },
|
|
37
|
+
});
|
|
38
|
+
gatewayLanguageModel.mockClear();
|
|
39
|
+
createAnthropic.mockClear();
|
|
40
|
+
});
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
43
|
+
process.env = { ...savedEnv };
|
|
44
|
+
});
|
|
45
|
+
describe("image_analyze credential routing — production regression 2026-10-23", () => {
|
|
46
|
+
it("uses the managed gateway when the agent has no provider key", async () => {
|
|
47
|
+
process.env.AI_GATEWAY_API_KEY = "gw-test";
|
|
48
|
+
const result = await analyzeTool()({ path: "shot.png" });
|
|
49
|
+
expect(gatewayLanguageModel).toHaveBeenCalledWith("anthropic/claude-haiku-5.5");
|
|
50
|
+
expect(createAnthropic).not.toHaveBeenCalled();
|
|
51
|
+
expect(generateText.mock.calls[0][0].model).toEqual({ via: "gateway", id: "anthropic/claude-haiku-5.5" });
|
|
52
|
+
expect(result.isError).toBeUndefined();
|
|
53
|
+
expect(result.details).toMatchObject({ provider: "gateway", model: "claude-haiku-5.5" });
|
|
54
|
+
expect(result.details.usage).toMatchObject({ generationId: "gen_1" });
|
|
55
|
+
});
|
|
56
|
+
it("keeps an agent's own provider key on the direct BYOK path", async () => {
|
|
57
|
+
process.env.AI_GATEWAY_API_KEY = "gw-test";
|
|
58
|
+
const vault = { getKey: (service) => service === "anthropic" ? "sk-ant-agent" : undefined };
|
|
59
|
+
const result = await analyzeTool(vault)({ path: "shot.png" });
|
|
60
|
+
expect(createAnthropic).toHaveBeenCalledWith({ apiKey: "sk-ant-agent" });
|
|
61
|
+
expect(anthropicModel).toHaveBeenCalledWith("claude-haiku-5.5");
|
|
62
|
+
expect(gatewayLanguageModel).not.toHaveBeenCalled();
|
|
63
|
+
expect(result.details).toMatchObject({ provider: "anthropic" });
|
|
64
|
+
expect(result.details.usage).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
it("reports a missing credential as a tool error, never as a successful result", async () => {
|
|
67
|
+
const result = await analyzeTool()({ path: "shot.png" });
|
|
68
|
+
expect(generateText).not.toHaveBeenCalled();
|
|
69
|
+
expect(renderToolResult(result)).toMatch(/^Error: Image analysis error: Missing environment variable: ANTHROPIC_API_KEY/);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
//# sourceMappingURL=image-tools.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"image-tools.test.js","sourceRoot":"","sources":["../../src/__tests__/image-tools.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"path-sandbox.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/path-sandbox.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { isPathAllowed } from "../path-sandbox.js";
|
|
6
|
+
import { createSystemTools } from "../system-tools.js";
|
|
7
|
+
import { NodeFileSystem } from "../adapters/node-filesystem.js";
|
|
8
|
+
import { NodeShell } from "../adapters/node-shell.js";
|
|
9
|
+
let root;
|
|
10
|
+
let sandbox;
|
|
11
|
+
let outside;
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
root = mkdtempSync(join(tmpdir(), "polpo-sandbox-"));
|
|
14
|
+
sandbox = join(root, "sandbox");
|
|
15
|
+
outside = join(root, "outside");
|
|
16
|
+
mkdirSync(sandbox);
|
|
17
|
+
mkdirSync(outside);
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
rmSync(root, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
describe("isPathAllowed — symlinked ancestors of new paths", () => {
|
|
23
|
+
it("rejects a not-yet-existing file under a symlinked directory that points outside", () => {
|
|
24
|
+
symlinkSync(outside, join(sandbox, "escape"));
|
|
25
|
+
expect(isPathAllowed(join(sandbox, "escape", "new.txt"), [sandbox])).toBe(false);
|
|
26
|
+
expect(isPathAllowed(join(sandbox, "escape", "deep", "nested", "new.txt"), [sandbox])).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
it("rejects a dangling symlink whose target is outside the sandbox", () => {
|
|
29
|
+
symlinkSync(join(outside, "not-yet.txt"), join(sandbox, "dangling"));
|
|
30
|
+
expect(isPathAllowed(join(sandbox, "dangling"), [sandbox])).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
it("rejects symlink loops instead of falling back to the logical path", () => {
|
|
33
|
+
symlinkSync(join(sandbox, "b"), join(sandbox, "a"));
|
|
34
|
+
symlinkSync(join(sandbox, "a"), join(sandbox, "b"));
|
|
35
|
+
expect(isPathAllowed(join(sandbox, "a", "file.txt"), [sandbox])).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
it("still allows new files under real and in-sandbox symlinked directories", () => {
|
|
38
|
+
mkdirSync(join(sandbox, "real"));
|
|
39
|
+
symlinkSync(join(sandbox, "real"), join(sandbox, "alias"));
|
|
40
|
+
expect(isPathAllowed(join(sandbox, "real", "new.txt"), [sandbox])).toBe(true);
|
|
41
|
+
expect(isPathAllowed(join(sandbox, "alias", "new.txt"), [sandbox])).toBe(true);
|
|
42
|
+
expect(isPathAllowed(join(sandbox, "missing", "dir", "new.txt"), [sandbox])).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
it("accepts an allowed root that is itself reached through a symlink", () => {
|
|
45
|
+
const linkedRoot = join(root, "linked-root");
|
|
46
|
+
symlinkSync(sandbox, linkedRoot);
|
|
47
|
+
expect(isPathAllowed(join(linkedRoot, "new.txt"), [linkedRoot])).toBe(true);
|
|
48
|
+
expect(isPathAllowed(join(sandbox, "new.txt"), [linkedRoot])).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
it("blocks the write tool from creating a file through a symlinked directory", async () => {
|
|
51
|
+
symlinkSync(outside, join(sandbox, "escape"));
|
|
52
|
+
const tools = createSystemTools(sandbox, undefined, [sandbox], undefined, undefined, new NodeFileSystem(), new NodeShell());
|
|
53
|
+
const write = tools.find((tool) => tool.name === "write");
|
|
54
|
+
await expect(write.execute("c1", { path: "escape/pwned.txt", content: "x" })).rejects.toThrow(/sandbox/);
|
|
55
|
+
expect(existsSync(join(outside, "pwned.txt"))).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
//# sourceMappingURL=path-sandbox.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"path-sandbox.test.js","sourceRoot":"","sources":["../../src/__tests__/path-sandbox.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* Docker rig).
|
|
23
23
|
*/
|
|
24
24
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
25
|
-
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync, statSync, symlinkSync, } from "node:fs";
|
|
25
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync, statSync, symlinkSync, existsSync, } from "node:fs";
|
|
26
26
|
import { tmpdir } from "node:os";
|
|
27
27
|
import { join } from "node:path";
|
|
28
28
|
import { createSystemTools } from "../system-tools.js";
|
|
@@ -53,6 +53,31 @@ afterEach(() => {
|
|
|
53
53
|
rmSync(cwd, { recursive: true, force: true });
|
|
54
54
|
});
|
|
55
55
|
describe("runtime requirements", () => {
|
|
56
|
+
it("rejects binary PNG content before returning it as line-numbered model text", async () => {
|
|
57
|
+
writeFileSync(join(cwd, "mockup.png"), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13]));
|
|
58
|
+
const result = await tool("read").execute("png-production-regression", { path: "mockup.png" });
|
|
59
|
+
expect(result).toMatchObject({ isError: true, details: { error: "binary_file" } });
|
|
60
|
+
expect(text(result)).toContain("image_analyze");
|
|
61
|
+
expect(text(result)).not.toContain("\u0000");
|
|
62
|
+
});
|
|
63
|
+
it("rejects any binary file by magic number or content, not only images", async () => {
|
|
64
|
+
writeFileSync(join(cwd, "archive.zip"), Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00, 0x08, 0x00]));
|
|
65
|
+
const result = await tool("read").execute("zip-binary", { path: "archive.zip" });
|
|
66
|
+
expect(result).toMatchObject({ isError: true, details: { error: "binary_file" } });
|
|
67
|
+
expect(text(result)).toMatch(/^Error: This is a binary file/);
|
|
68
|
+
const noExtension = Buffer.alloc(256);
|
|
69
|
+
for (let i = 0; i < noExtension.length; i++)
|
|
70
|
+
noExtension[i] = (i * 37) % 256;
|
|
71
|
+
writeFileSync(join(cwd, "blob"), noExtension);
|
|
72
|
+
expect(await tool("read").execute("blob", { path: "blob" })).toMatchObject({ isError: true, details: { error: "binary_file" } });
|
|
73
|
+
});
|
|
74
|
+
it("still reads a text page with a stray NUL far into it, normalized for persistence", async () => {
|
|
75
|
+
// Production case: an HTML page with one U+0000 at offset 34236.
|
|
76
|
+
writeFileSync(join(cwd, "page.html"), `${"<p>pasta</p>\n".repeat(2_500)}x\u0000y\n`);
|
|
77
|
+
const result = await tool("read").execute("late-nul", { path: "page.html", offset: 2_501, limit: 1 });
|
|
78
|
+
expect(result.isError).toBeUndefined();
|
|
79
|
+
expect(text(result)).toBe("2501\tx\\u0000y\n... (1 more lines)");
|
|
80
|
+
});
|
|
56
81
|
it("annotates the tools returned to the runtime", () => {
|
|
57
82
|
expect(tool("read").requiresSandbox).toBe(true);
|
|
58
83
|
expect(tool("bash").requiresSandbox).toBe(true);
|
|
@@ -314,6 +339,68 @@ describe("bash — happy path", () => {
|
|
|
314
339
|
expect(text(result)).toMatch(/3/);
|
|
315
340
|
});
|
|
316
341
|
});
|
|
342
|
+
describe("bash — cancellation and timeout bounds", () => {
|
|
343
|
+
function isAlive(pid) {
|
|
344
|
+
try {
|
|
345
|
+
process.kill(pid, 0);
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
it("kills the whole process tree when the abort signal fires", { timeout: 20_000 }, async () => {
|
|
353
|
+
const pidFile = join(cwd, "child.pid");
|
|
354
|
+
const controller = new AbortController();
|
|
355
|
+
const t0 = Date.now();
|
|
356
|
+
const pending = tool("bash").execute("c1",
|
|
357
|
+
// The background child survives a kill of the top-level shell alone.
|
|
358
|
+
{ command: `sleep 60 & echo $! > ${pidFile}; wait` }, controller.signal);
|
|
359
|
+
for (let i = 0; i < 200 && !(existsSync(pidFile) && readFileSync(pidFile, "utf8").trim()); i++)
|
|
360
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
361
|
+
const childPid = Number(readFileSync(pidFile, "utf8").trim());
|
|
362
|
+
expect(isAlive(childPid)).toBe(true);
|
|
363
|
+
controller.abort();
|
|
364
|
+
const result = await pending;
|
|
365
|
+
expect(Date.now() - t0).toBeLessThan(10_000);
|
|
366
|
+
expect(text(result)).toMatch(/abort/i);
|
|
367
|
+
for (let i = 0; i < 50 && isAlive(childPid); i++)
|
|
368
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
369
|
+
expect(isAlive(childPid)).toBe(false);
|
|
370
|
+
});
|
|
371
|
+
it("does not start the command when the signal is already aborted", async () => {
|
|
372
|
+
const marker = join(cwd, "should-not-exist");
|
|
373
|
+
const controller = new AbortController();
|
|
374
|
+
controller.abort();
|
|
375
|
+
const result = await tool("bash").execute("c1", { command: `touch ${marker}` }, controller.signal);
|
|
376
|
+
expect(text(result)).toMatch(/abort/i);
|
|
377
|
+
expect(existsSync(marker)).toBe(false);
|
|
378
|
+
});
|
|
379
|
+
it("returns on abort even when the Shell implementation ignores the signal", async () => {
|
|
380
|
+
const shell = { execute: vi.fn(() => new Promise(() => undefined)) };
|
|
381
|
+
const bash = createSystemTools(cwd, ["bash"], [cwd], undefined, undefined, new NodeFileSystem(), shell)
|
|
382
|
+
.find((candidate) => candidate.name === "bash");
|
|
383
|
+
const controller = new AbortController();
|
|
384
|
+
const pending = bash.execute("c1", { command: "sleep 60" }, controller.signal);
|
|
385
|
+
controller.abort();
|
|
386
|
+
expect(text(await pending)).toMatch(/abort/i);
|
|
387
|
+
expect(shell.execute.mock.calls[0][1]).toMatchObject({ signal: controller.signal });
|
|
388
|
+
});
|
|
389
|
+
it.each([
|
|
390
|
+
[undefined, 120_000],
|
|
391
|
+
[0, 120_000],
|
|
392
|
+
[-5, 120_000],
|
|
393
|
+
[Number.NaN, 120_000],
|
|
394
|
+
[5_000, 5_000],
|
|
395
|
+
[86_400_000, 600_000],
|
|
396
|
+
])("normalizes timeout %s to %s ms", async (timeout, expected) => {
|
|
397
|
+
const shell = { execute: vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })) };
|
|
398
|
+
const bash = createSystemTools(cwd, ["bash"], [cwd], undefined, undefined, new NodeFileSystem(), shell)
|
|
399
|
+
.find((candidate) => candidate.name === "bash");
|
|
400
|
+
await bash.execute("c1", { command: "true", ...(timeout === undefined ? {} : { timeout }) });
|
|
401
|
+
expect(shell.execute.mock.calls[0][1]).toMatchObject({ timeout: expected });
|
|
402
|
+
});
|
|
403
|
+
});
|
|
317
404
|
describe("bash — adversarial", () => {
|
|
318
405
|
it("surfaces a non-zero exit clearly", async () => {
|
|
319
406
|
const result = await tool("bash").execute("c1", { command: "false" });
|
|
@@ -458,6 +545,63 @@ describe("grep — adversarial", () => {
|
|
|
458
545
|
expect(text(result)).toContain("real.txt");
|
|
459
546
|
});
|
|
460
547
|
});
|
|
548
|
+
describe("grep — shell injection and error reporting", () => {
|
|
549
|
+
it("does not execute command substitution in the pattern", async () => {
|
|
550
|
+
writeFileSync(join(cwd, "f.txt"), "needle\n");
|
|
551
|
+
const marker = join(cwd, "pwned-pattern");
|
|
552
|
+
const result = await tool("grep").execute("c1", { pattern: `$(touch ${marker})needle` });
|
|
553
|
+
expect(existsSync(marker)).toBe(false);
|
|
554
|
+
expect(text(result)).not.toContain("f.txt");
|
|
555
|
+
});
|
|
556
|
+
it("does not execute command substitution or backticks in include and path", async () => {
|
|
557
|
+
writeFileSync(join(cwd, "f.txt"), "needle\n");
|
|
558
|
+
const includeMarker = join(cwd, "pwned-include");
|
|
559
|
+
const pathMarker = join(cwd, "pwned-path");
|
|
560
|
+
await tool("grep").execute("c1", { pattern: "needle", include: `*.txt$(touch ${includeMarker})` });
|
|
561
|
+
await tool("grep").execute("c2", { pattern: "needle", path: `\`touch ${pathMarker}\`` }).catch(() => undefined);
|
|
562
|
+
expect(existsSync(includeMarker)).toBe(false);
|
|
563
|
+
expect(existsSync(pathMarker)).toBe(false);
|
|
564
|
+
});
|
|
565
|
+
it("matches patterns containing single quotes and shell metacharacters literally", async () => {
|
|
566
|
+
writeFileSync(join(cwd, "q.txt"), "it's $HOME; `x` | y\n");
|
|
567
|
+
expect(text(await tool("grep").execute("c1", { pattern: "it's" }))).toContain("q.txt");
|
|
568
|
+
expect(text(await tool("grep").execute("c2", { pattern: "\\$HOME; `x` \\| y" }))).toContain("q.txt");
|
|
569
|
+
});
|
|
570
|
+
it.skipIf(process.getuid?.() === 0)("keeps matches when some files are unreadable (grep exit 2)", async () => {
|
|
571
|
+
writeFileSync(join(cwd, "ok.txt"), "needle\n");
|
|
572
|
+
writeFileSync(join(cwd, "locked.txt"), "needle\n", { mode: 0o000 });
|
|
573
|
+
const result = await tool("grep").execute("c1", { pattern: "needle" });
|
|
574
|
+
expect(text(result)).toContain("ok.txt");
|
|
575
|
+
expect(text(result)).not.toMatch(/^Error:/);
|
|
576
|
+
expect(text(result)).toMatch(/grep reported errors/);
|
|
577
|
+
});
|
|
578
|
+
it("treats a pattern that starts with a dash as a pattern, not an option", async () => {
|
|
579
|
+
writeFileSync(join(cwd, "f.txt"), "a --version b\n");
|
|
580
|
+
const result = await tool("grep").execute("c1", { pattern: "--version" });
|
|
581
|
+
expect(text(result)).toContain("f.txt");
|
|
582
|
+
});
|
|
583
|
+
it("reports an invalid regex as an error instead of 'No matches found'", async () => {
|
|
584
|
+
writeFileSync(join(cwd, "f.txt"), "needle\n");
|
|
585
|
+
const result = await tool("grep").execute("c1", { pattern: "(unclosed" });
|
|
586
|
+
expect(text(result)).toMatch(/^Error:/);
|
|
587
|
+
expect(text(result)).not.toMatch(/No matches found/);
|
|
588
|
+
});
|
|
589
|
+
it("reports a shell failure (e.g. timeout) as an error instead of 'No matches found'", async () => {
|
|
590
|
+
const shell = { execute: vi.fn(async () => ({ stdout: "", stderr: "Command timed out after 15000ms (SIGKILL).", exitCode: 124 })) };
|
|
591
|
+
const grep = createSystemTools(cwd, ["grep"], [cwd], undefined, undefined, new NodeFileSystem(), shell)
|
|
592
|
+
.find((candidate) => candidate.name === "grep");
|
|
593
|
+
const result = await grep.execute("c1", { pattern: "needle" });
|
|
594
|
+
expect(text(result)).toMatch(/^Error:.*timed out/i);
|
|
595
|
+
});
|
|
596
|
+
it("flags truncated output when more than 100 lines match", async () => {
|
|
597
|
+
writeFileSync(join(cwd, "many.txt"), Array.from({ length: 250 }, (_, i) => `needle ${i}`).join("\n"));
|
|
598
|
+
const result = await tool("grep").execute("c1", { pattern: "needle" });
|
|
599
|
+
const lines = text(result).split("\n");
|
|
600
|
+
expect(lines.filter((line) => line.includes("many.txt"))).toHaveLength(100);
|
|
601
|
+
expect(text(result)).toMatch(/truncated/i);
|
|
602
|
+
expect(result.details).toMatchObject({ truncated: true });
|
|
603
|
+
});
|
|
604
|
+
});
|
|
461
605
|
// ────────────────────────────────────────────────────────────
|
|
462
606
|
describe("ls — happy path", () => {
|
|
463
607
|
it("lists files and directories", async () => {
|