@actagent/file-transfer 2026.6.2
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/actagent.plugin.json +50 -0
- package/index.test.ts +93 -0
- package/index.ts +121 -0
- package/package.json +18 -0
- package/src/node-host/dir-fetch.test.ts +131 -0
- package/src/node-host/dir-fetch.ts +363 -0
- package/src/node-host/dir-list.test.ts +169 -0
- package/src/node-host/dir-list.ts +155 -0
- package/src/node-host/file-fetch.test.ts +254 -0
- package/src/node-host/file-fetch.ts +203 -0
- package/src/node-host/file-write.test.ts +378 -0
- package/src/node-host/file-write.ts +280 -0
- package/src/node-host/path-errors.ts +112 -0
- package/src/shared/audit.ts +98 -0
- package/src/shared/errors.test.ts +63 -0
- package/src/shared/errors.ts +68 -0
- package/src/shared/lazy-node-invoke-policy.test.ts +102 -0
- package/src/shared/lazy-node-invoke-policy.ts +36 -0
- package/src/shared/mime.test.ts +61 -0
- package/src/shared/mime.ts +30 -0
- package/src/shared/node-invoke-policy-commands.ts +9 -0
- package/src/shared/node-invoke-policy.test.ts +763 -0
- package/src/shared/node-invoke-policy.ts +947 -0
- package/src/shared/params.test.ts +42 -0
- package/src/shared/params.ts +60 -0
- package/src/shared/policy.test.ts +568 -0
- package/src/shared/policy.ts +383 -0
- package/src/tools/descriptors.ts +145 -0
- package/src/tools/dir-fetch-tool.test.ts +194 -0
- package/src/tools/dir-fetch-tool.ts +660 -0
- package/src/tools/dir-list-tool.ts +79 -0
- package/src/tools/file-fetch-tool.test.ts +82 -0
- package/src/tools/file-fetch-tool.ts +133 -0
- package/src/tools/file-write-tool.test.ts +30 -0
- package/src/tools/file-write-tool.ts +122 -0
- package/src/tools/node-tool-invoke.ts +97 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// File Transfer tests cover file write plugin behavior.
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
7
|
+
import { handleFileWrite } from "./file-write.js";
|
|
8
|
+
|
|
9
|
+
let tmpRoot: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
// realpath: see file-fetch.test.ts for the macOS symlinked-tmpdir reason.
|
|
13
|
+
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "file-write-test-")));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await fs.rm(tmpRoot, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function b64(s: string): string {
|
|
21
|
+
return Buffer.from(s, "utf-8").toString("base64");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function expectFailure(result: Awaited<ReturnType<typeof handleFileWrite>>, code: string) {
|
|
25
|
+
expect(result.ok).toBe(false);
|
|
26
|
+
if (result.ok) {
|
|
27
|
+
throw new Error("expected file write failure");
|
|
28
|
+
}
|
|
29
|
+
expect(result.code).toBe(code);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function expectSuccessFields(
|
|
33
|
+
result: Awaited<ReturnType<typeof handleFileWrite>>,
|
|
34
|
+
fields: Record<string, unknown>,
|
|
35
|
+
) {
|
|
36
|
+
expect(result.ok).toBe(true);
|
|
37
|
+
if (!result.ok) {
|
|
38
|
+
throw new Error(`expected ok, got ${result.code}: ${result.message}`);
|
|
39
|
+
}
|
|
40
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
41
|
+
expect(result[key as keyof typeof result]).toEqual(value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function expectAccessMissing(target: string) {
|
|
46
|
+
try {
|
|
47
|
+
await fs.access(target);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`expected ${target} to be missing`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("handleFileWrite — input validation", () => {
|
|
56
|
+
it("rejects empty / non-string path", async () => {
|
|
57
|
+
expectFailure(await handleFileWrite({ path: "", contentBase64: b64("x") }), "INVALID_PATH");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("rejects relative paths", async () => {
|
|
61
|
+
const r = await handleFileWrite({ path: "relative.txt", contentBase64: b64("x") });
|
|
62
|
+
expectFailure(r, "INVALID_PATH");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("rejects paths with NUL bytes", async () => {
|
|
66
|
+
const r = await handleFileWrite({ path: "/tmp/foo\0bar", contentBase64: b64("x") });
|
|
67
|
+
expectFailure(r, "INVALID_PATH");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("requires contentBase64 but allows an empty encoded payload", async () => {
|
|
71
|
+
const missing = await handleFileWrite({ path: path.join(tmpRoot, "missing.bin") });
|
|
72
|
+
expectFailure(missing, "INVALID_BASE64");
|
|
73
|
+
|
|
74
|
+
const target = path.join(tmpRoot, "empty.bin");
|
|
75
|
+
const empty = await handleFileWrite({ path: target, contentBase64: "" });
|
|
76
|
+
expectSuccessFields(empty, {
|
|
77
|
+
size: 0,
|
|
78
|
+
sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
79
|
+
});
|
|
80
|
+
expect(await fs.readFile(target)).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("handleFileWrite — happy path", () => {
|
|
85
|
+
it("writes a new file and returns size + sha256 + overwritten=false", async () => {
|
|
86
|
+
const target = path.join(tmpRoot, "out.txt");
|
|
87
|
+
const contents = "hello write\n";
|
|
88
|
+
const r = await handleFileWrite({ path: target, contentBase64: b64(contents) });
|
|
89
|
+
if (!r.ok) {
|
|
90
|
+
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
|
|
91
|
+
}
|
|
92
|
+
expect(r.size).toBe(contents.length);
|
|
93
|
+
expect(r.overwritten).toBe(false);
|
|
94
|
+
const expectedSha = crypto.createHash("sha256").update(contents).digest("hex");
|
|
95
|
+
expect(r.sha256).toBe(expectedSha);
|
|
96
|
+
|
|
97
|
+
const onDisk = await fs.readFile(target, "utf-8");
|
|
98
|
+
expect(onDisk).toBe(contents);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("does not leave .tmp files behind on success", async () => {
|
|
102
|
+
const target = path.join(tmpRoot, "atomic.txt");
|
|
103
|
+
const r = await handleFileWrite({ path: target, contentBase64: b64("body") });
|
|
104
|
+
expect(r.ok).toBe(true);
|
|
105
|
+
|
|
106
|
+
const entries = await fs.readdir(tmpRoot);
|
|
107
|
+
const tmpFiles = entries.filter((n) => n.includes(".tmp"));
|
|
108
|
+
expect(tmpFiles).toStrictEqual([]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("handleFileWrite — overwrite policy", () => {
|
|
113
|
+
it("refuses to overwrite an existing file when overwrite=false", async () => {
|
|
114
|
+
const target = path.join(tmpRoot, "exists.txt");
|
|
115
|
+
await fs.writeFile(target, "before");
|
|
116
|
+
|
|
117
|
+
const r = await handleFileWrite({
|
|
118
|
+
path: target,
|
|
119
|
+
contentBase64: b64("after"),
|
|
120
|
+
overwrite: false,
|
|
121
|
+
});
|
|
122
|
+
expectFailure(r, "EXISTS_NO_OVERWRITE");
|
|
123
|
+
expect(await fs.readFile(target, "utf-8")).toBe("before");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("overwrites and reports overwritten=true when overwrite=true", async () => {
|
|
127
|
+
const target = path.join(tmpRoot, "exists.txt");
|
|
128
|
+
await fs.writeFile(target, "before");
|
|
129
|
+
|
|
130
|
+
const r = await handleFileWrite({
|
|
131
|
+
path: target,
|
|
132
|
+
contentBase64: b64("after"),
|
|
133
|
+
overwrite: true,
|
|
134
|
+
});
|
|
135
|
+
if (!r.ok) {
|
|
136
|
+
throw new Error("expected ok");
|
|
137
|
+
}
|
|
138
|
+
expect(r.overwritten).toBe(true);
|
|
139
|
+
expect(await fs.readFile(target, "utf-8")).toBe("after");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("handleFileWrite — parent directory handling", () => {
|
|
144
|
+
it("returns PARENT_NOT_FOUND when parent is missing and createParents=false", async () => {
|
|
145
|
+
const target = path.join(tmpRoot, "nested", "child.txt");
|
|
146
|
+
const r = await handleFileWrite({
|
|
147
|
+
path: target,
|
|
148
|
+
contentBase64: b64("x"),
|
|
149
|
+
createParents: false,
|
|
150
|
+
});
|
|
151
|
+
expectFailure(r, "PARENT_NOT_FOUND");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("creates missing parents when createParents=true", async () => {
|
|
155
|
+
const target = path.join(tmpRoot, "deep", "nested", "child.txt");
|
|
156
|
+
const r = await handleFileWrite({
|
|
157
|
+
path: target,
|
|
158
|
+
contentBase64: b64("x"),
|
|
159
|
+
createParents: true,
|
|
160
|
+
});
|
|
161
|
+
expect(r.ok).toBe(true);
|
|
162
|
+
expect(await fs.readFile(target, "utf-8")).toBe("x");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("handleFileWrite — symlink protection", () => {
|
|
167
|
+
it("refuses to write through an existing symlink (lstat)", async () => {
|
|
168
|
+
const real = path.join(tmpRoot, "real.txt");
|
|
169
|
+
const link = path.join(tmpRoot, "link.txt");
|
|
170
|
+
await fs.writeFile(real, "untouched");
|
|
171
|
+
await fs.symlink(real, link);
|
|
172
|
+
|
|
173
|
+
const r = await handleFileWrite({
|
|
174
|
+
path: link,
|
|
175
|
+
contentBase64: b64("evil"),
|
|
176
|
+
overwrite: true,
|
|
177
|
+
});
|
|
178
|
+
expectFailure(r, "SYMLINK_TARGET_DENIED");
|
|
179
|
+
// The original file must be unchanged.
|
|
180
|
+
expect(await fs.readFile(real, "utf-8")).toBe("untouched");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("refuses to write through a symlink in a parent directory by default", async () => {
|
|
184
|
+
// realDir is the actual victim; sentinel is a pre-existing file in it.
|
|
185
|
+
const realDir = path.join(tmpRoot, "real-dir");
|
|
186
|
+
await fs.mkdir(realDir);
|
|
187
|
+
const sentinel = path.join(realDir, "sentinel.txt");
|
|
188
|
+
await fs.writeFile(sentinel, "DO_NOT_TOUCH");
|
|
189
|
+
|
|
190
|
+
// /tmpRoot/allowed -> /tmpRoot/real-dir (symlink in a parent segment).
|
|
191
|
+
const allowed = path.join(tmpRoot, "allowed");
|
|
192
|
+
await fs.symlink(realDir, allowed);
|
|
193
|
+
|
|
194
|
+
// Asking to write to .../allowed/new-file.txt — the lexical parent
|
|
195
|
+
// (.../allowed) resolves through a symlink to .../real-dir. Refuse.
|
|
196
|
+
const r = await handleFileWrite({
|
|
197
|
+
path: path.join(allowed, "new-file.txt"),
|
|
198
|
+
contentBase64: b64("payload"),
|
|
199
|
+
});
|
|
200
|
+
expectFailure(r, "SYMLINK_REDIRECT");
|
|
201
|
+
// The error includes the canonical target so the operator can
|
|
202
|
+
// either update allowWritePaths or set followSymlinks=true.
|
|
203
|
+
expect(r.ok ? null : r.canonicalPath).toBe(path.join(realDir, "new-file.txt"));
|
|
204
|
+
// No file was created at the canonical target.
|
|
205
|
+
await expectAccessMissing(path.join(realDir, "new-file.txt"));
|
|
206
|
+
// Sentinel must be untouched.
|
|
207
|
+
expect(await fs.readFile(sentinel, "utf-8")).toBe("DO_NOT_TOUCH");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("checks symlinked parents before recursive mkdir", async () => {
|
|
211
|
+
const realDir = path.join(tmpRoot, "real-dir");
|
|
212
|
+
await fs.mkdir(realDir);
|
|
213
|
+
const allowed = path.join(tmpRoot, "allowed");
|
|
214
|
+
await fs.symlink(realDir, allowed);
|
|
215
|
+
|
|
216
|
+
const r = await handleFileWrite({
|
|
217
|
+
path: path.join(allowed, "new", "child.txt"),
|
|
218
|
+
contentBase64: b64("payload"),
|
|
219
|
+
createParents: true,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expectFailure(r, "SYMLINK_REDIRECT");
|
|
223
|
+
expect(r.ok ? null : r.canonicalPath).toBe(path.join(realDir, "new", "child.txt"));
|
|
224
|
+
await expectAccessMissing(path.join(realDir, "new"));
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("follows the parent symlink when followSymlinks=true", async () => {
|
|
228
|
+
const realDir = path.join(tmpRoot, "real-dir");
|
|
229
|
+
await fs.mkdir(realDir);
|
|
230
|
+
const allowed = path.join(tmpRoot, "allowed");
|
|
231
|
+
await fs.symlink(realDir, allowed);
|
|
232
|
+
|
|
233
|
+
const r = await handleFileWrite({
|
|
234
|
+
path: path.join(allowed, "new-file.txt"),
|
|
235
|
+
contentBase64: b64("payload"),
|
|
236
|
+
followSymlinks: true,
|
|
237
|
+
});
|
|
238
|
+
expect(r.ok).toBe(true);
|
|
239
|
+
// The file landed in the canonical (real) directory.
|
|
240
|
+
expect(await fs.readFile(path.join(realDir, "new-file.txt"), "utf-8")).toBe("payload");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("preflights canonical write targets without creating files or parents", async () => {
|
|
244
|
+
const realDir = path.join(tmpRoot, "real-dir");
|
|
245
|
+
await fs.mkdir(realDir);
|
|
246
|
+
const allowed = path.join(tmpRoot, "allowed");
|
|
247
|
+
await fs.symlink(realDir, allowed);
|
|
248
|
+
|
|
249
|
+
const r = await handleFileWrite({
|
|
250
|
+
path: path.join(allowed, "new", "child.txt"),
|
|
251
|
+
contentBase64: b64("payload"),
|
|
252
|
+
createParents: true,
|
|
253
|
+
followSymlinks: true,
|
|
254
|
+
preflightOnly: true,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expectSuccessFields(r, {
|
|
258
|
+
path: path.join(realDir, "new", "child.txt"),
|
|
259
|
+
size: "payload".length,
|
|
260
|
+
});
|
|
261
|
+
await expectAccessMissing(path.join(realDir, "new"));
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("refuses to overwrite a directory", async () => {
|
|
265
|
+
const target = path.join(tmpRoot, "is-a-dir");
|
|
266
|
+
await fs.mkdir(target);
|
|
267
|
+
|
|
268
|
+
const r = await handleFileWrite({
|
|
269
|
+
path: target,
|
|
270
|
+
contentBase64: b64("x"),
|
|
271
|
+
overwrite: true,
|
|
272
|
+
});
|
|
273
|
+
expectFailure(r, "IS_DIRECTORY");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("handleFileWrite — integrity check", () => {
|
|
278
|
+
it("returns INTEGRITY_FAILURE before writing when expectedSha256 mismatches", async () => {
|
|
279
|
+
const target = path.join(tmpRoot, "checked.txt");
|
|
280
|
+
const r = await handleFileWrite({
|
|
281
|
+
path: target,
|
|
282
|
+
contentBase64: b64("real-content"),
|
|
283
|
+
expectedSha256: "0".repeat(64),
|
|
284
|
+
});
|
|
285
|
+
expectFailure(r, "INTEGRITY_FAILURE");
|
|
286
|
+
// The file must never be created on a mismatch.
|
|
287
|
+
await expectAccessMissing(target);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("does NOT replace or delete an existing file when overwrite=true and expectedSha256 mismatches", async () => {
|
|
291
|
+
const target = path.join(tmpRoot, "victim.txt");
|
|
292
|
+
await fs.writeFile(target, "ORIGINAL_CONTENT_DO_NOT_TOUCH");
|
|
293
|
+
|
|
294
|
+
const r = await handleFileWrite({
|
|
295
|
+
path: target,
|
|
296
|
+
contentBase64: b64("attacker-content"),
|
|
297
|
+
overwrite: true,
|
|
298
|
+
expectedSha256: "0".repeat(64),
|
|
299
|
+
});
|
|
300
|
+
expectFailure(r, "INTEGRITY_FAILURE");
|
|
301
|
+
// Critical: the original must survive. A bad caller hash must not
|
|
302
|
+
// be a primitive for replacing-then-deleting an existing file.
|
|
303
|
+
expect(await fs.readFile(target, "utf-8")).toBe("ORIGINAL_CONTENT_DO_NOT_TOUCH");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("accepts a matching expectedSha256 and keeps the file", async () => {
|
|
307
|
+
const target = path.join(tmpRoot, "checked.txt");
|
|
308
|
+
const contents = "real-content";
|
|
309
|
+
const sha = crypto.createHash("sha256").update(contents).digest("hex");
|
|
310
|
+
|
|
311
|
+
const r = await handleFileWrite({
|
|
312
|
+
path: target,
|
|
313
|
+
contentBase64: b64(contents),
|
|
314
|
+
expectedSha256: sha,
|
|
315
|
+
});
|
|
316
|
+
expect(r.ok).toBe(true);
|
|
317
|
+
expect(await fs.readFile(target, "utf-8")).toBe(contents);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("treats expectedSha256 as case-insensitive", async () => {
|
|
321
|
+
const target = path.join(tmpRoot, "checked.txt");
|
|
322
|
+
const contents = "abc";
|
|
323
|
+
const sha = crypto.createHash("sha256").update(contents).digest("hex").toUpperCase();
|
|
324
|
+
|
|
325
|
+
const r = await handleFileWrite({
|
|
326
|
+
path: target,
|
|
327
|
+
contentBase64: b64(contents),
|
|
328
|
+
expectedSha256: sha,
|
|
329
|
+
});
|
|
330
|
+
expect(r.ok).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("handleFileWrite — base64 round-trip validation", () => {
|
|
335
|
+
it("rejects malformed base64 that silently drops characters", async () => {
|
|
336
|
+
const target = path.join(tmpRoot, "bad.bin");
|
|
337
|
+
// "@" is not in the base64 alphabet — Buffer.from would silently drop
|
|
338
|
+
// it and decode "AAA" instead of failing.
|
|
339
|
+
const r = await handleFileWrite({
|
|
340
|
+
path: target,
|
|
341
|
+
contentBase64: "AAA@@@",
|
|
342
|
+
});
|
|
343
|
+
expectFailure(r, "INVALID_BASE64");
|
|
344
|
+
await expectAccessMissing(target);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("accepts standard base64 with and without padding", async () => {
|
|
348
|
+
const target = path.join(tmpRoot, "padded.bin");
|
|
349
|
+
// Buffer.from("hi") -> "aGk=" with padding, "aGk" without.
|
|
350
|
+
const r1 = await handleFileWrite({ path: target, contentBase64: "aGk=" });
|
|
351
|
+
expect(r1.ok).toBe(true);
|
|
352
|
+
|
|
353
|
+
const target2 = path.join(tmpRoot, "unpadded.bin");
|
|
354
|
+
const r2 = await handleFileWrite({ path: target2, contentBase64: "aGk" });
|
|
355
|
+
expect(r2.ok).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("accepts base64url variant (-_ instead of +/)", async () => {
|
|
359
|
+
const target = path.join(tmpRoot, "url.bin");
|
|
360
|
+
// Buffer.from([0xfb, 0xff]) -> "+/8=" standard, "-_8=" url
|
|
361
|
+
const r = await handleFileWrite({ path: target, contentBase64: "-_8=" });
|
|
362
|
+
expect(r.ok).toBe(true);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe("handleFileWrite — size cap", () => {
|
|
367
|
+
it("rejects content larger than the 16MB cap", async () => {
|
|
368
|
+
const target = path.join(tmpRoot, "big.bin");
|
|
369
|
+
// 17MB of zero-bytes — base64 inflates by ~4/3 but we're checking the
|
|
370
|
+
// decoded buffer length so this is fine.
|
|
371
|
+
const big = Buffer.alloc(17 * 1024 * 1024, 0);
|
|
372
|
+
const r = await handleFileWrite({
|
|
373
|
+
path: target,
|
|
374
|
+
contentBase64: big.toString("base64"),
|
|
375
|
+
});
|
|
376
|
+
expectFailure(r, "FILE_TOO_LARGE");
|
|
377
|
+
});
|
|
378
|
+
});
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// File Transfer plugin module implements file write behavior.
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
canonicalPathFromExistingAncestor,
|
|
7
|
+
FsSafeError,
|
|
8
|
+
resolveAbsolutePathForWrite,
|
|
9
|
+
root,
|
|
10
|
+
} from "actagent/plugin-sdk/security-runtime";
|
|
11
|
+
|
|
12
|
+
const MAX_CONTENT_BYTES = 16 * 1024 * 1024; // 16 MB
|
|
13
|
+
|
|
14
|
+
type FileWriteParams = {
|
|
15
|
+
path: string;
|
|
16
|
+
contentBase64: string;
|
|
17
|
+
overwrite: boolean;
|
|
18
|
+
createParents: boolean;
|
|
19
|
+
expectedSha256?: string;
|
|
20
|
+
followSymlinks?: boolean;
|
|
21
|
+
preflightOnly?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type FileWriteSuccess = {
|
|
25
|
+
ok: true;
|
|
26
|
+
path: string;
|
|
27
|
+
size: number;
|
|
28
|
+
sha256: string;
|
|
29
|
+
overwritten: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type FileWriteError = {
|
|
33
|
+
ok: false;
|
|
34
|
+
code: string;
|
|
35
|
+
message: string;
|
|
36
|
+
canonicalPath?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type FileWriteResult = FileWriteSuccess | FileWriteError;
|
|
40
|
+
|
|
41
|
+
function sha256Hex(buf: Buffer): string {
|
|
42
|
+
return crypto.createHash("sha256").update(buf).digest("hex");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function err(code: string, message: string, canonicalPath?: string): FileWriteError {
|
|
46
|
+
return { ok: false, code, message, ...(canonicalPath ? { canonicalPath } : {}) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function symlinkRedirectError(error: FsSafeError): FileWriteError {
|
|
50
|
+
const canonicalTarget =
|
|
51
|
+
error.cause &&
|
|
52
|
+
typeof error.cause === "object" &&
|
|
53
|
+
"canonicalPath" in error.cause &&
|
|
54
|
+
typeof error.cause.canonicalPath === "string"
|
|
55
|
+
? error.cause.canonicalPath
|
|
56
|
+
: undefined;
|
|
57
|
+
return err(
|
|
58
|
+
"SYMLINK_REDIRECT",
|
|
59
|
+
"path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowWritePaths to the canonical path)",
|
|
60
|
+
canonicalTarget,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeFsSafeError(error: FsSafeError, targetPath: string): FileWriteError {
|
|
65
|
+
if (error.code === "symlink") {
|
|
66
|
+
return err(
|
|
67
|
+
"SYMLINK_TARGET_DENIED",
|
|
68
|
+
`path is a symlink; refusing to write through it: ${targetPath}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (error.code === "not-file") {
|
|
72
|
+
return err("IS_DIRECTORY", `path resolves to a directory: ${targetPath}`);
|
|
73
|
+
}
|
|
74
|
+
if (error.code === "already-exists") {
|
|
75
|
+
return err("EXISTS_NO_OVERWRITE", `file already exists and overwrite is false: ${targetPath}`);
|
|
76
|
+
}
|
|
77
|
+
return err("WRITE_ERROR", error.message, targetPath);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function handleFileWrite(
|
|
81
|
+
params: Partial<FileWriteParams> & Record<string, unknown>,
|
|
82
|
+
): Promise<FileWriteResult> {
|
|
83
|
+
const rawPath = typeof params?.path === "string" ? params.path : "";
|
|
84
|
+
const hasContentBase64 = typeof params?.contentBase64 === "string";
|
|
85
|
+
const contentBase64 = hasContentBase64 ? (params.contentBase64 as string) : "";
|
|
86
|
+
const overwrite = params?.overwrite === true;
|
|
87
|
+
const createParents = params?.createParents === true;
|
|
88
|
+
const expectedSha256 =
|
|
89
|
+
typeof params?.expectedSha256 === "string" ? params.expectedSha256 : undefined;
|
|
90
|
+
const followSymlinks = params?.followSymlinks === true;
|
|
91
|
+
const preflightOnly = params?.preflightOnly === true;
|
|
92
|
+
|
|
93
|
+
// 1. Validate path: must be absolute, non-empty, no NUL byte
|
|
94
|
+
if (!rawPath) {
|
|
95
|
+
return err("INVALID_PATH", "path is required");
|
|
96
|
+
}
|
|
97
|
+
if (rawPath.includes("\0")) {
|
|
98
|
+
return err("INVALID_PATH", "path must not contain NUL bytes");
|
|
99
|
+
}
|
|
100
|
+
if (!path.isAbsolute(rawPath)) {
|
|
101
|
+
return err("INVALID_PATH", "path must be absolute");
|
|
102
|
+
}
|
|
103
|
+
if (!hasContentBase64) {
|
|
104
|
+
return err("INVALID_BASE64", "contentBase64 is required");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 2. Decode base64 → Buffer.
|
|
108
|
+
// Buffer.from(s, "base64") in Node never throws — it silently drops
|
|
109
|
+
// non-base64 characters and returns whatever it could decode. That
|
|
110
|
+
// means a typo or truncated input would land garbage on disk if we
|
|
111
|
+
// accepted whatever decoded. Defense: round-trip the decoded buffer
|
|
112
|
+
// back to base64 and compare against the input modulo padding/url
|
|
113
|
+
// variants. A mismatch means characters were silently dropped.
|
|
114
|
+
const buf = Buffer.from(contentBase64, "base64");
|
|
115
|
+
const reEncoded = buf.toString("base64");
|
|
116
|
+
// Normalize: drop padding and convert base64url chars to standard so the
|
|
117
|
+
// comparison tolerates both "=" / no-"=" inputs and "-_" base64url.
|
|
118
|
+
const normalize = (s: string): string =>
|
|
119
|
+
s.replace(/=+$/u, "").replace(/-/gu, "+").replace(/_/gu, "/");
|
|
120
|
+
if (normalize(reEncoded) !== normalize(contentBase64)) {
|
|
121
|
+
return err("INVALID_BASE64", "contentBase64 is not valid base64");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (buf.length > MAX_CONTENT_BYTES) {
|
|
125
|
+
return err(
|
|
126
|
+
"FILE_TOO_LARGE",
|
|
127
|
+
`decoded content is ${buf.length} bytes; maximum is ${MAX_CONTENT_BYTES} bytes (16 MB)`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let targetPath: string;
|
|
132
|
+
let parentDir: string;
|
|
133
|
+
let parentExists: boolean;
|
|
134
|
+
try {
|
|
135
|
+
const resolved = await resolveAbsolutePathForWrite(rawPath, {
|
|
136
|
+
symlinks: followSymlinks ? "follow" : "reject",
|
|
137
|
+
});
|
|
138
|
+
targetPath = resolved.path;
|
|
139
|
+
parentDir = resolved.parentDir;
|
|
140
|
+
parentExists = resolved.parentExists;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error instanceof FsSafeError && error.code === "symlink") {
|
|
143
|
+
return symlinkRedirectError(error);
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!parentExists) {
|
|
149
|
+
if (!createParents) {
|
|
150
|
+
return err("PARENT_NOT_FOUND", `parent directory does not exist: ${parentDir}`);
|
|
151
|
+
}
|
|
152
|
+
if (preflightOnly) {
|
|
153
|
+
const computedSha256 = sha256Hex(buf);
|
|
154
|
+
if (expectedSha256 && expectedSha256.toLowerCase() !== computedSha256) {
|
|
155
|
+
return err(
|
|
156
|
+
"INTEGRITY_FAILURE",
|
|
157
|
+
`sha256 mismatch: expected ${expectedSha256.toLowerCase()}, got ${computedSha256}`,
|
|
158
|
+
targetPath,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
path: await canonicalPathFromExistingAncestor(targetPath),
|
|
164
|
+
size: buf.length,
|
|
165
|
+
sha256: computedSha256,
|
|
166
|
+
overwritten: false,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
await fs.mkdir(parentDir, { recursive: true });
|
|
171
|
+
} catch (mkdirErr) {
|
|
172
|
+
const message = mkdirErr instanceof Error ? mkdirErr.message : String(mkdirErr);
|
|
173
|
+
return err("WRITE_ERROR", `failed to create parent directories: ${message}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await resolveAbsolutePathForWrite(targetPath, {
|
|
179
|
+
symlinks: followSymlinks ? "follow" : "reject",
|
|
180
|
+
});
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error instanceof FsSafeError && error.code === "symlink") {
|
|
183
|
+
return symlinkRedirectError(error);
|
|
184
|
+
}
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const targetFileName = path.basename(targetPath);
|
|
189
|
+
const parentRoot = await root(parentDir);
|
|
190
|
+
let overwritten = false;
|
|
191
|
+
try {
|
|
192
|
+
const existingLStat = await fs.lstat(targetPath);
|
|
193
|
+
if (existingLStat.isSymbolicLink()) {
|
|
194
|
+
return err(
|
|
195
|
+
"SYMLINK_TARGET_DENIED",
|
|
196
|
+
`path is a symlink; refusing to write through it: ${targetPath}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if (existingLStat.isDirectory()) {
|
|
200
|
+
return err("IS_DIRECTORY", `path resolves to a directory: ${targetPath}`);
|
|
201
|
+
}
|
|
202
|
+
if (!overwrite) {
|
|
203
|
+
return err(
|
|
204
|
+
"EXISTS_NO_OVERWRITE",
|
|
205
|
+
`file already exists and overwrite is false: ${targetPath}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
overwritten = true;
|
|
209
|
+
} catch (statErr: unknown) {
|
|
210
|
+
const statErrorCode =
|
|
211
|
+
statErr instanceof FsSafeError ? statErr.code : (statErr as NodeJS.ErrnoException).code;
|
|
212
|
+
if (statErrorCode !== "not-found" && statErrorCode !== "ENOENT") {
|
|
213
|
+
const message = statErr instanceof Error ? statErr.message : String(statErr);
|
|
214
|
+
if (message.toLowerCase().includes("permission")) {
|
|
215
|
+
return err("PERMISSION_DENIED", `permission denied: ${targetPath}`);
|
|
216
|
+
}
|
|
217
|
+
return err("WRITE_ERROR", `unexpected stat error: ${message}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 5. Hash the decoded buffer BEFORE touching disk. If the caller
|
|
222
|
+
// supplied expectedSha256 and it doesn't match, refuse outright so
|
|
223
|
+
// a bad caller hash with overwrite=true can't replace + delete the
|
|
224
|
+
// original. Computing from the buffer (not a re-read) is the right
|
|
225
|
+
// source of truth — the caller asked us to write THESE bytes.
|
|
226
|
+
const computedSha256 = sha256Hex(buf);
|
|
227
|
+
if (expectedSha256 && expectedSha256.toLowerCase() !== computedSha256) {
|
|
228
|
+
return err(
|
|
229
|
+
"INTEGRITY_FAILURE",
|
|
230
|
+
`sha256 mismatch: expected ${expectedSha256.toLowerCase()}, got ${computedSha256}`,
|
|
231
|
+
targetPath,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (preflightOnly) {
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
path: await canonicalPathFromExistingAncestor(targetPath),
|
|
239
|
+
size: buf.length,
|
|
240
|
+
sha256: computedSha256,
|
|
241
|
+
overwritten,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
if (overwrite) {
|
|
247
|
+
await parentRoot.write(targetFileName, buf);
|
|
248
|
+
} else {
|
|
249
|
+
await parentRoot.create(targetFileName, buf);
|
|
250
|
+
}
|
|
251
|
+
} catch (writeErr) {
|
|
252
|
+
if (writeErr instanceof FsSafeError) {
|
|
253
|
+
return writeFsSafeError(writeErr, targetPath);
|
|
254
|
+
}
|
|
255
|
+
const message = writeErr instanceof Error ? writeErr.message : String(writeErr);
|
|
256
|
+
if (message.toLowerCase().includes("permission") || message.toLowerCase().includes("access")) {
|
|
257
|
+
return err("PERMISSION_DENIED", `permission denied writing to: ${parentDir}`);
|
|
258
|
+
}
|
|
259
|
+
return err("WRITE_ERROR", `failed to write file: ${message}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let canonicalPath = targetPath;
|
|
263
|
+
try {
|
|
264
|
+
const opened = await parentRoot.open(targetFileName);
|
|
265
|
+
canonicalPath = opened.realPath;
|
|
266
|
+
await opened.handle.close().catch(() => undefined);
|
|
267
|
+
} catch (openErr) {
|
|
268
|
+
if (openErr instanceof FsSafeError) {
|
|
269
|
+
return writeFsSafeError(openErr, targetPath);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
ok: true,
|
|
275
|
+
path: canonicalPath,
|
|
276
|
+
size: buf.length,
|
|
277
|
+
sha256: computedSha256,
|
|
278
|
+
overwritten,
|
|
279
|
+
};
|
|
280
|
+
}
|