1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
|
import { access, mkdtemp, readdir, readFile, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createWriteFileTool } from "../../src/tools/write-file.js";
describe("write_file tool", () => {
let workDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
});
it("writes a new file", async () => {
const tool = createWriteFileTool(workDir);
const result = await tool.execute({
path: "output.txt",
content: "test content",
});
expect(result).toMatch(/successfully wrote/i);
const written = await readFile(join(workDir, "output.txt"), "utf8");
expect(written).toBe("test content");
});
it("creates parent directories", async () => {
const tool = createWriteFileTool(workDir);
const result = await tool.execute({
path: "nested/dir/file.txt",
content: "nested",
});
expect(result).toMatch(/successfully wrote/i);
const written = await readFile(join(workDir, "nested/dir/file.txt"), "utf8");
expect(written).toBe("nested");
});
it("blocks path traversal", async () => {
const tool = createWriteFileTool(workDir);
const result = await tool.execute({ path: "../evil.txt", content: "bad" });
expect(result).toMatch(/outside the working directory/i);
});
// Regression for `resolve(join(workingDirectory, filePath))` — when filePath
// is absolute, `join` does NOT short-circuit, it concatenates. The old code
// silently rewrote `/etc/foo` to `<workdir>/etc/foo` and "succeeded" by
// writing to the wrong location. After the fix, absolute paths resolve
// to themselves and the workdir gate behaves correctly.
describe("absolute path handling", () => {
it("writes an absolute path that lives under the workdir to the expected location", async () => {
const tool = createWriteFileTool(workDir);
const absoluteTarget = join(workDir, "abs.txt");
const result = await tool.execute({ path: absoluteTarget, content: "abs content" });
expect(result).toMatch(/successfully wrote/i);
// File must exist at exactly `absoluteTarget`, NOT at
// `<workdir>/<workdir>/abs.txt` (the old mangled location).
const written = await readFile(absoluteTarget, "utf8");
expect(written).toBe("abs content");
});
it("rejects absolute paths outside the workdir instead of silently mangling them", async () => {
const tool = createWriteFileTool(workDir);
// Pick a path under tmpdir that's definitely not under workDir.
// Under the bug, this got rewritten to `<workdir>/tmp/...` and the
// write "succeeded" at the wrong location.
const evilPath = join(tmpdir(), `dispatch-evil-${Date.now()}.txt`);
const result = await tool.execute({ path: evilPath, content: "should not land" });
expect(result).toMatch(/outside the working directory/i);
});
});
// Symlink containment: even when the *leaf* doesn't exist yet (the
// common case for write_file creating a new file), `canonicalize`
// must walk up to the nearest existing ancestor and resolve symlinks
// there. Otherwise, a directory symlink inside workdir pointing
// outside lets a write escape the workspace.
describe("symlink handling", () => {
let externalDir: string;
beforeEach(async () => {
externalDir = await mkdtemp(join(tmpdir(), "dispatch-external-"));
});
afterEach(async () => {
await rm(externalDir, { recursive: true, force: true });
});
it("blocks writes that escape through a parent symlink (leaf does not exist yet)", async () => {
const tool = createWriteFileTool(workDir);
// `escape` is a symlink *inside* workdir to a directory *outside*.
await symlink(externalDir, join(workDir, "escape"));
const result = await tool.execute({
path: "escape/payload.txt",
content: "malicious payload",
});
expect(result).toMatch(/outside the working directory/i);
// And the file must NOT exist in externalDir.
await expect(access(join(externalDir, "payload.txt"))).rejects.toThrow();
// And externalDir should be empty (nothing leaked through).
const entries = await readdir(externalDir);
expect(entries).toEqual([]);
});
});
describe("onAfterWrite hook", () => {
it("appends the hook's returned string to a successful write", async () => {
const tool = createWriteFileTool(workDir, async (abs) => `DIAGNOSTICS for ${abs}`);
const result = await tool.execute({ path: "a.luau", content: "local x = 1" });
expect(result).toMatch(/successfully wrote/i);
expect(result).toContain("DIAGNOSTICS for");
expect(result).toContain(join(workDir, "a.luau"));
});
it("does not append when the hook returns empty string", async () => {
const tool = createWriteFileTool(workDir, async () => "");
const result = await tool.execute({ path: "a.luau", content: "local x = 1" });
expect(result.trim()).toMatch(/^Successfully wrote to "a\.luau"\.$/);
});
it("does not run the hook when the write is blocked (traversal)", async () => {
let called = false;
const tool = createWriteFileTool(workDir, async () => {
called = true;
return "should not appear";
});
const result = await tool.execute({ path: "../evil.txt", content: "bad" });
expect(result).toMatch(/outside the working directory/i);
expect(called).toBe(false);
});
it("swallows hook errors so a throwing hook never fails the write", async () => {
const tool = createWriteFileTool(workDir, async () => {
throw new Error("lsp blew up");
});
const result = await tool.execute({ path: "a.luau", content: "local x = 1" });
expect(result).toMatch(/successfully wrote/i);
expect(result).not.toContain("lsp blew up");
});
it("passes the canonical absolute path to the hook", async () => {
let seen = "";
const tool = createWriteFileTool(workDir, async (abs) => {
seen = abs;
return "";
});
await tool.execute({ path: "nested/b.luau", content: "x" });
expect(seen).toBe(join(workDir, "nested/b.luau"));
});
});
});
|