@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,660 @@
|
|
|
1
|
+
// File Transfer plugin module implements dir fetch tool behavior.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import type { AnyAgentTool } from "actagent/plugin-sdk/agent-harness-runtime";
|
|
7
|
+
import { saveMediaBuffer } from "actagent/plugin-sdk/media-store";
|
|
8
|
+
import { appendFileTransferAudit } from "../shared/audit.js";
|
|
9
|
+
import { IMAGE_MIME_INLINE_SET, mimeFromExtension } from "../shared/mime.js";
|
|
10
|
+
import { humanSize, readBoolean, readClampedInt } from "../shared/params.js";
|
|
11
|
+
import {
|
|
12
|
+
DIR_FETCH_DEFAULT_MAX_BYTES,
|
|
13
|
+
DIR_FETCH_HARD_MAX_BYTES,
|
|
14
|
+
DIR_FETCH_TOOL_DESCRIPTOR,
|
|
15
|
+
FILE_TRANSFER_SUBDIR,
|
|
16
|
+
} from "./descriptors.js";
|
|
17
|
+
import { invokeNodeToolPayload, readRequiredNodePath } from "./node-tool-invoke.js";
|
|
18
|
+
|
|
19
|
+
// Cap how many local file paths we surface in details.media.mediaUrls.
|
|
20
|
+
// Larger trees still land on disk but we don't spam the channel adapter
|
|
21
|
+
// with hundreds of attachments.
|
|
22
|
+
const MEDIA_URL_CAP = 25;
|
|
23
|
+
|
|
24
|
+
// Hard timeout for gateway-side tar processes.
|
|
25
|
+
const TAR_UNPACK_TIMEOUT_MS = 60_000;
|
|
26
|
+
|
|
27
|
+
// Cap on number of entries pre-validated. The compressed tar is already
|
|
28
|
+
// capped at DIR_FETCH_HARD_MAX_BYTES upstream, and we walk the unpacked
|
|
29
|
+
// tree to compute hashes — TAR_UNPACK_MAX_ENTRIES bounds how much work
|
|
30
|
+
// that walk can do.
|
|
31
|
+
const TAR_UNPACK_MAX_ENTRIES = 5000;
|
|
32
|
+
const TAR_LIST_OUTPUT_MAX_CHARS = 32 * 1024 * 1024;
|
|
33
|
+
const TAR_STDERR_TAIL_CHARS = 4096;
|
|
34
|
+
|
|
35
|
+
// Hard caps on uncompressed extraction. Defends against decompression-bomb
|
|
36
|
+
// archives that compress to <16MB but expand to gigabytes. Both caps are
|
|
37
|
+
// enforced during the post-extract walk: total bytes summed across entries
|
|
38
|
+
// and per-file size to bound any single fs.stat / hash operation.
|
|
39
|
+
const DIR_FETCH_MAX_UNCOMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
40
|
+
const DIR_FETCH_MAX_SINGLE_FILE_BYTES = 16 * 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
function appendBoundedTextTail(current: string, chunk: Buffer, maxChars: number): string {
|
|
43
|
+
const next = current + chunk.toString();
|
|
44
|
+
return next.length > maxChars ? next.slice(-maxChars) : next;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function listTarOutputLines<T>(input: {
|
|
48
|
+
args: string[];
|
|
49
|
+
label: string;
|
|
50
|
+
tarBuffer: Buffer;
|
|
51
|
+
mapLine: (line: string) => T;
|
|
52
|
+
maxValues: number;
|
|
53
|
+
}): Promise<{ ok: true; values: T[] } | { ok: false; reason: string }> {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
|
|
56
|
+
const child = spawn(tarBin, input.args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
57
|
+
const values: T[] = [];
|
|
58
|
+
let pending = "";
|
|
59
|
+
let outputChars = 0;
|
|
60
|
+
let stderr = "";
|
|
61
|
+
let settled = false;
|
|
62
|
+
|
|
63
|
+
const finish = (result: { ok: true; values: T[] } | { ok: false; reason: string }): void => {
|
|
64
|
+
if (settled) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
settled = true;
|
|
68
|
+
clearTimeout(watchdog);
|
|
69
|
+
resolve(result);
|
|
70
|
+
};
|
|
71
|
+
const stopChild = (): void => {
|
|
72
|
+
try {
|
|
73
|
+
child.kill("SIGKILL");
|
|
74
|
+
} catch {
|
|
75
|
+
/* gone */
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const appendLine = (line: string): boolean => {
|
|
79
|
+
if (settled) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (!line) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
values.push(input.mapLine(line));
|
|
86
|
+
if (values.length >= input.maxValues) {
|
|
87
|
+
stopChild();
|
|
88
|
+
finish({ ok: true, values });
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
};
|
|
93
|
+
const consumeChunk = (chunk: Buffer): void => {
|
|
94
|
+
if (settled) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const text = chunk.toString();
|
|
98
|
+
outputChars += text.length;
|
|
99
|
+
if (outputChars > TAR_LIST_OUTPUT_MAX_CHARS) {
|
|
100
|
+
stopChild();
|
|
101
|
+
finish({ ok: false, reason: `${input.label} output too large` });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const lines = `${pending}${text}`.split("\n");
|
|
105
|
+
pending = lines.pop() ?? "";
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
if (!appendLine(line)) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const watchdog: ReturnType<typeof setTimeout> = setTimeout(() => {
|
|
114
|
+
stopChild();
|
|
115
|
+
finish({ ok: false, reason: `${input.label} timed out` });
|
|
116
|
+
}, 30_000);
|
|
117
|
+
child.stdout.on("data", consumeChunk);
|
|
118
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
119
|
+
stderr = appendBoundedTextTail(stderr, chunk, TAR_STDERR_TAIL_CHARS);
|
|
120
|
+
});
|
|
121
|
+
child.on("close", (code) => {
|
|
122
|
+
if (settled) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (code !== 0) {
|
|
126
|
+
finish({ ok: false, reason: `${input.label} exited ${code}: ${stderr.slice(-200)}` });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (pending) {
|
|
130
|
+
appendLine(pending);
|
|
131
|
+
}
|
|
132
|
+
finish({ ok: true, values });
|
|
133
|
+
});
|
|
134
|
+
child.on("error", (e) => {
|
|
135
|
+
finish({ ok: false, reason: `${input.label} error: ${String(e)}` });
|
|
136
|
+
});
|
|
137
|
+
child.stdin.on("error", (e: NodeJS.ErrnoException) => {
|
|
138
|
+
if (settled && e.code === "EPIPE") {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
finish({ ok: false, reason: `${input.label} input error: ${String(e)}` });
|
|
142
|
+
});
|
|
143
|
+
child.stdin.end(input.tarBuffer);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function computeFileSha256(filePath: string): Promise<string> {
|
|
148
|
+
// Stream the hash so we never pull a whole large file into memory.
|
|
149
|
+
// file_fetch caps single files at 16MB, but unpacked dir_fetch entries
|
|
150
|
+
// share the 64MB uncompressed budget — better to stream regardless.
|
|
151
|
+
const hash = crypto.createHash("sha256");
|
|
152
|
+
const handle = await fs.open(filePath, "r");
|
|
153
|
+
try {
|
|
154
|
+
const chunkSize = 64 * 1024;
|
|
155
|
+
const buf = Buffer.allocUnsafe(chunkSize);
|
|
156
|
+
while (true) {
|
|
157
|
+
const { bytesRead } = await handle.read(buf, 0, chunkSize, null);
|
|
158
|
+
if (bytesRead === 0) {
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
hash.update(buf.subarray(0, bytesRead));
|
|
162
|
+
}
|
|
163
|
+
} finally {
|
|
164
|
+
await handle.close();
|
|
165
|
+
}
|
|
166
|
+
return hash.digest("hex");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Run two passes against the buffer to enumerate entries BEFORE we extract:
|
|
171
|
+
*
|
|
172
|
+
* 1. `tar -tf -` produces names ONLY, one per line. This is whitespace-safe
|
|
173
|
+
* because each line is exactly one path; no parsing of fixed columns.
|
|
174
|
+
* Used to validate paths (reject absolute, '..' traversal).
|
|
175
|
+
* 2. `tar -tvf -` adds type info via the `ls -l`-style perm prefix.
|
|
176
|
+
* Used ONLY to detect symlinks / hardlinks / non-regular entries via
|
|
177
|
+
* the FIRST CHARACTER of each line, never the path column.
|
|
178
|
+
*
|
|
179
|
+
* Size limits are enforced at the *extraction* step instead — the tar
|
|
180
|
+
* unpack process is bounded by the maxBytes we already pass through, and
|
|
181
|
+
* the post-extract walkDir is hard-capped by TAR_UNPACK_MAX_ENTRIES.
|
|
182
|
+
* Trying to parse uncompressed sizes from `tar -tvf` output is fragile
|
|
183
|
+
* (filenames with whitespace shift the columns) and Aisle flagged that
|
|
184
|
+
* shape as a bypass primitive — drop it.
|
|
185
|
+
*/
|
|
186
|
+
async function listTarPaths(
|
|
187
|
+
tarBuffer: Buffer,
|
|
188
|
+
): Promise<{ ok: true; paths: string[] } | { ok: false; reason: string }> {
|
|
189
|
+
const result = await listTarOutputLines({
|
|
190
|
+
args: ["-tzf", "-"],
|
|
191
|
+
label: "tar -tzf",
|
|
192
|
+
tarBuffer,
|
|
193
|
+
mapLine: (line) => line,
|
|
194
|
+
maxValues: TAR_UNPACK_MAX_ENTRIES + 1,
|
|
195
|
+
});
|
|
196
|
+
return result.ok ? { ok: true, paths: result.values } : result;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function listTarTypeChars(
|
|
200
|
+
tarBuffer: Buffer,
|
|
201
|
+
): Promise<{ ok: true; typeChars: string[] } | { ok: false; reason: string }> {
|
|
202
|
+
const result = await listTarOutputLines({
|
|
203
|
+
args: ["-tzvf", "-"],
|
|
204
|
+
label: "tar -tzvf",
|
|
205
|
+
tarBuffer,
|
|
206
|
+
mapLine: (line) => line.charAt(0),
|
|
207
|
+
maxValues: TAR_UNPACK_MAX_ENTRIES + 1,
|
|
208
|
+
});
|
|
209
|
+
return result.ok ? { ok: true, typeChars: result.values } : result;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function preValidateTarball(
|
|
213
|
+
tarBuffer: Buffer,
|
|
214
|
+
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
215
|
+
const namesResult = await listTarPaths(tarBuffer);
|
|
216
|
+
if (!namesResult.ok) {
|
|
217
|
+
return namesResult;
|
|
218
|
+
}
|
|
219
|
+
const paths = namesResult.paths;
|
|
220
|
+
if (paths.length > TAR_UNPACK_MAX_ENTRIES) {
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
reason: `archive contains ${paths.length} entries; limit ${TAR_UNPACK_MAX_ENTRIES}`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const typesResult = await listTarTypeChars(tarBuffer);
|
|
228
|
+
if (!typesResult.ok) {
|
|
229
|
+
return typesResult;
|
|
230
|
+
}
|
|
231
|
+
const typeChars = typesResult.typeChars;
|
|
232
|
+
// The two passes should report the same number of entries; if they
|
|
233
|
+
// don't, something exotic is going on (filenames with newlines, etc.)
|
|
234
|
+
// and we refuse defensively.
|
|
235
|
+
if (typeChars.length !== paths.length) {
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
reason: `tar -tzf and tar -tzvf disagree on entry count (${paths.length} vs ${typeChars.length}); refusing`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (let i = 0; i < paths.length; i++) {
|
|
243
|
+
const entryPath = paths[i];
|
|
244
|
+
const t = typeChars[i];
|
|
245
|
+
if (t === "l" || t === "h") {
|
|
246
|
+
return { ok: false, reason: `archive contains link entry: ${entryPath}` };
|
|
247
|
+
}
|
|
248
|
+
if (t !== "-" && t !== "d") {
|
|
249
|
+
return { ok: false, reason: `archive contains non-regular entry type '${t}': ${entryPath}` };
|
|
250
|
+
}
|
|
251
|
+
if (path.isAbsolute(entryPath)) {
|
|
252
|
+
return { ok: false, reason: `archive contains absolute path: ${entryPath}` };
|
|
253
|
+
}
|
|
254
|
+
const norm = path.posix.normalize(entryPath);
|
|
255
|
+
if (norm === ".." || norm.startsWith("../") || norm.includes("/../")) {
|
|
256
|
+
return { ok: false, reason: `archive contains '..' traversal: ${entryPath}` };
|
|
257
|
+
}
|
|
258
|
+
// Reject backslash-containing names too — refuses Windows-style
|
|
259
|
+
// traversal in archives produced by an attacker on a Windows node.
|
|
260
|
+
if (entryPath.includes("\\")) {
|
|
261
|
+
return { ok: false, reason: `archive contains backslash in path: ${entryPath}` };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { ok: true };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function validateTarUncompressedBudget(
|
|
268
|
+
tarBuffer: Buffer,
|
|
269
|
+
maxBytes = DIR_FETCH_MAX_UNCOMPRESSED_BYTES,
|
|
270
|
+
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
271
|
+
return new Promise((resolve) => {
|
|
272
|
+
const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
|
|
273
|
+
const child = spawn(tarBin, ["-xOzf", "-"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
274
|
+
let totalBytes = 0;
|
|
275
|
+
let stderr = "";
|
|
276
|
+
let settled = false;
|
|
277
|
+
const finish = (result: { ok: true } | { ok: false; reason: string }): void => {
|
|
278
|
+
if (settled) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
settled = true;
|
|
282
|
+
clearTimeout(watchdog);
|
|
283
|
+
resolve(result);
|
|
284
|
+
};
|
|
285
|
+
const watchdog: ReturnType<typeof setTimeout> = setTimeout(() => {
|
|
286
|
+
try {
|
|
287
|
+
child.kill("SIGKILL");
|
|
288
|
+
} catch {
|
|
289
|
+
/* gone */
|
|
290
|
+
}
|
|
291
|
+
finish({ ok: false, reason: "tar uncompressed budget validation timed out" });
|
|
292
|
+
}, TAR_UNPACK_TIMEOUT_MS);
|
|
293
|
+
|
|
294
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
295
|
+
totalBytes += chunk.byteLength;
|
|
296
|
+
if (totalBytes > maxBytes) {
|
|
297
|
+
try {
|
|
298
|
+
child.kill("SIGKILL");
|
|
299
|
+
} catch {
|
|
300
|
+
/* gone */
|
|
301
|
+
}
|
|
302
|
+
finish({
|
|
303
|
+
ok: false,
|
|
304
|
+
reason: `archive expands past uncompressed budget ${maxBytes} bytes`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
309
|
+
stderr += chunk.toString();
|
|
310
|
+
if (stderr.length > 4096) {
|
|
311
|
+
stderr = stderr.slice(-4096);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
child.on("close", (code) => {
|
|
315
|
+
if (settled) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (code !== 0) {
|
|
319
|
+
finish({
|
|
320
|
+
ok: false,
|
|
321
|
+
reason: `tar uncompressed budget validation exited ${code}: ${stderr.slice(-200)}`,
|
|
322
|
+
});
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
finish({ ok: true });
|
|
326
|
+
});
|
|
327
|
+
child.on("error", (error) => {
|
|
328
|
+
finish({
|
|
329
|
+
ok: false,
|
|
330
|
+
reason: `tar uncompressed budget validation error: ${String(error)}`,
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
child.stdin.on("error", (error: NodeJS.ErrnoException) => {
|
|
334
|
+
if (settled && error.code === "EPIPE") {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
finish({
|
|
338
|
+
ok: false,
|
|
339
|
+
reason: `tar uncompressed budget validation input error: ${String(error)}`,
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
child.stdin.end(tarBuffer);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
type UnpackedFileEntry = {
|
|
347
|
+
relPath: string;
|
|
348
|
+
size: number;
|
|
349
|
+
mimeType: string;
|
|
350
|
+
sha256: string;
|
|
351
|
+
localPath: string;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Unpack a gzipped tarball into a target directory via `tar -xzf -`.
|
|
356
|
+
* Caller MUST have run `preValidateTarball` first — this function trusts
|
|
357
|
+
* that the archive contains only regular files / dirs with relative,
|
|
358
|
+
* non-traversing paths. Without that pre-validation, raw `tar -xzf` is
|
|
359
|
+
* unsafe (tarbomb, symlink-then-write tricks, decompression bomb).
|
|
360
|
+
*
|
|
361
|
+
* The `-P` flag is intentionally omitted so absolute paths in the
|
|
362
|
+
* archive are stripped to relative ones (defense-in-depth on top of the
|
|
363
|
+
* pre-validation rejection). A hard wall-clock timeout caps the unpack
|
|
364
|
+
* at TAR_UNPACK_TIMEOUT_MS to avoid hangs.
|
|
365
|
+
*
|
|
366
|
+
* BSD tar (macOS) and GNU tar disagree on flags: `--no-overwrite-dir` is
|
|
367
|
+
* GNU-only and BSD tar rejects it. We use only flags both implementations
|
|
368
|
+
* accept. Defense-in-depth comes from the pre-validation step instead.
|
|
369
|
+
*
|
|
370
|
+
* `--no-same-owner` and `--no-same-permissions` are accepted by both BSD
|
|
371
|
+
* and GNU tar. They prevent the archive from setting file ownership
|
|
372
|
+
* (uid/gid) and dangerous mode bits (setuid/setgid/world-writable) on
|
|
373
|
+
* the gateway filesystem. If the gateway is ever run as root or with
|
|
374
|
+
* elevated privileges, a malicious node could otherwise plant
|
|
375
|
+
* privileged executables here.
|
|
376
|
+
*/
|
|
377
|
+
async function unpackTar(tarBuffer: Buffer, destDir: string): Promise<void> {
|
|
378
|
+
await fs.mkdir(destDir, { recursive: true, mode: 0o700 });
|
|
379
|
+
return new Promise((resolve, reject) => {
|
|
380
|
+
const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
|
|
381
|
+
const child = spawn(
|
|
382
|
+
tarBin,
|
|
383
|
+
["-xzf", "-", "-C", destDir, "--no-same-owner", "--no-same-permissions"],
|
|
384
|
+
{
|
|
385
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
let stderrOut = "";
|
|
389
|
+
let settled = false;
|
|
390
|
+
const fail = (error: Error): void => {
|
|
391
|
+
if (settled) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
settled = true;
|
|
395
|
+
clearTimeout(watchdog);
|
|
396
|
+
reject(error);
|
|
397
|
+
};
|
|
398
|
+
const succeed = (): void => {
|
|
399
|
+
if (settled) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
settled = true;
|
|
403
|
+
clearTimeout(watchdog);
|
|
404
|
+
resolve();
|
|
405
|
+
};
|
|
406
|
+
const watchdog: ReturnType<typeof setTimeout> = setTimeout(() => {
|
|
407
|
+
try {
|
|
408
|
+
child.kill("SIGKILL");
|
|
409
|
+
} catch {
|
|
410
|
+
/* already gone */
|
|
411
|
+
}
|
|
412
|
+
fail(new Error(`tar unpack timed out after ${TAR_UNPACK_TIMEOUT_MS}ms`));
|
|
413
|
+
}, TAR_UNPACK_TIMEOUT_MS);
|
|
414
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
415
|
+
stderrOut = appendBoundedTextTail(stderrOut, chunk, TAR_STDERR_TAIL_CHARS);
|
|
416
|
+
});
|
|
417
|
+
child.on("close", (code) => {
|
|
418
|
+
if (code !== 0) {
|
|
419
|
+
fail(new Error(`tar unpack exited ${code}: ${stderrOut.slice(-300)}`));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
succeed();
|
|
423
|
+
});
|
|
424
|
+
child.on("error", (e) => {
|
|
425
|
+
fail(e);
|
|
426
|
+
});
|
|
427
|
+
child.stdin.on("error", (e: NodeJS.ErrnoException) => {
|
|
428
|
+
if (settled && e.code === "EPIPE") {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
fail(e);
|
|
432
|
+
});
|
|
433
|
+
child.stdin.end(tarBuffer);
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Walk a directory recursively, collecting file entries (skips directories).
|
|
439
|
+
* Skips symlinks — we don't want to follow links the archive might have
|
|
440
|
+
* carried in. Files only.
|
|
441
|
+
*/
|
|
442
|
+
async function walkDir(
|
|
443
|
+
dir: string,
|
|
444
|
+
rootDir: string,
|
|
445
|
+
): Promise<{ relPath: string; absPath: string }[]> {
|
|
446
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
447
|
+
const results: { relPath: string; absPath: string }[] = [];
|
|
448
|
+
for (const entry of entries) {
|
|
449
|
+
const absPath = path.join(dir, entry.name);
|
|
450
|
+
if (entry.isDirectory()) {
|
|
451
|
+
const nested = await walkDir(absPath, rootDir);
|
|
452
|
+
results.push(...nested);
|
|
453
|
+
} else if (entry.isFile()) {
|
|
454
|
+
const relPath = path.relative(rootDir, absPath);
|
|
455
|
+
results.push({ relPath, absPath });
|
|
456
|
+
}
|
|
457
|
+
// Symlinks are intentionally ignored: don't follow them out of destDir.
|
|
458
|
+
}
|
|
459
|
+
return results;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function createDirFetchTool(): AnyAgentTool {
|
|
463
|
+
return {
|
|
464
|
+
...DIR_FETCH_TOOL_DESCRIPTOR,
|
|
465
|
+
execute: async (_toolCallId, args) => {
|
|
466
|
+
const params = args as Record<string, unknown>;
|
|
467
|
+
const { node, requestedPath: dirPath } = readRequiredNodePath(params);
|
|
468
|
+
|
|
469
|
+
const maxBytes = readClampedInt({
|
|
470
|
+
input: params,
|
|
471
|
+
key: "maxBytes",
|
|
472
|
+
defaultValue: DIR_FETCH_DEFAULT_MAX_BYTES,
|
|
473
|
+
hardMin: 1,
|
|
474
|
+
hardMax: DIR_FETCH_HARD_MAX_BYTES,
|
|
475
|
+
});
|
|
476
|
+
const includeDotfiles = readBoolean(params, "includeDotfiles", false);
|
|
477
|
+
|
|
478
|
+
const { nodeId, nodeDisplayName, payload, startedAt } = await invokeNodeToolPayload({
|
|
479
|
+
node,
|
|
480
|
+
params,
|
|
481
|
+
command: "dir.fetch",
|
|
482
|
+
commandParams: {
|
|
483
|
+
path: dirPath,
|
|
484
|
+
maxBytes,
|
|
485
|
+
includeDotfiles,
|
|
486
|
+
},
|
|
487
|
+
requestedPath: dirPath,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const canonicalPath = typeof payload.path === "string" ? payload.path : "";
|
|
491
|
+
const tarBase64 = typeof payload.tarBase64 === "string" ? payload.tarBase64 : "";
|
|
492
|
+
const tarBytes = typeof payload.tarBytes === "number" ? payload.tarBytes : -1;
|
|
493
|
+
const sha256 = typeof payload.sha256 === "string" ? payload.sha256 : "";
|
|
494
|
+
const fileCount = typeof payload.fileCount === "number" ? payload.fileCount : 0;
|
|
495
|
+
|
|
496
|
+
if (!canonicalPath || !tarBase64 || tarBytes < 0 || !sha256) {
|
|
497
|
+
throw new Error("invalid dir.fetch payload (missing fields)");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const tarBuffer = Buffer.from(tarBase64, "base64");
|
|
501
|
+
if (tarBuffer.byteLength !== tarBytes) {
|
|
502
|
+
throw new Error(
|
|
503
|
+
`dir.fetch size mismatch: payload says ${tarBytes} bytes, decoded ${tarBuffer.byteLength}`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
const localSha256 = crypto.createHash("sha256").update(tarBuffer).digest("hex");
|
|
507
|
+
if (localSha256 !== sha256) {
|
|
508
|
+
throw new Error("dir.fetch sha256 mismatch (integrity failure)");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Pre-validate before extraction. The node is in the trust boundary
|
|
512
|
+
// for v1, but a malicious or compromised node should not be able to
|
|
513
|
+
// pivot into arbitrary file write on the gateway via tar tricks.
|
|
514
|
+
// Rejects: symlinks, hardlinks, absolute paths, ".." traversal,
|
|
515
|
+
// entry counts and uncompressed sizes above the caps.
|
|
516
|
+
const validation = await preValidateTarball(tarBuffer);
|
|
517
|
+
if (!validation.ok) {
|
|
518
|
+
await appendFileTransferAudit({
|
|
519
|
+
op: "dir.fetch",
|
|
520
|
+
nodeId,
|
|
521
|
+
nodeDisplayName,
|
|
522
|
+
requestedPath: dirPath,
|
|
523
|
+
canonicalPath,
|
|
524
|
+
decision: "error",
|
|
525
|
+
errorCode: "UNSAFE_ARCHIVE",
|
|
526
|
+
errorMessage: validation.reason,
|
|
527
|
+
sizeBytes: tarBytes,
|
|
528
|
+
sha256,
|
|
529
|
+
durationMs: Date.now() - startedAt,
|
|
530
|
+
});
|
|
531
|
+
throw new Error(`dir.fetch UNSAFE_ARCHIVE: ${validation.reason}`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const budget = await validateTarUncompressedBudget(tarBuffer);
|
|
535
|
+
if (!budget.ok) {
|
|
536
|
+
await appendFileTransferAudit({
|
|
537
|
+
op: "dir.fetch",
|
|
538
|
+
nodeId,
|
|
539
|
+
nodeDisplayName,
|
|
540
|
+
requestedPath: dirPath,
|
|
541
|
+
canonicalPath,
|
|
542
|
+
decision: "error",
|
|
543
|
+
errorCode: "TREE_TOO_LARGE",
|
|
544
|
+
errorMessage: budget.reason,
|
|
545
|
+
sizeBytes: tarBytes,
|
|
546
|
+
sha256,
|
|
547
|
+
durationMs: Date.now() - startedAt,
|
|
548
|
+
});
|
|
549
|
+
throw new Error(`dir.fetch UNCOMPRESSED_TOO_LARGE: ${budget.reason}`);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Save tarball under the file-transfer subdir (no 2-min TTL).
|
|
553
|
+
const savedTar = await saveMediaBuffer(
|
|
554
|
+
tarBuffer,
|
|
555
|
+
"application/gzip",
|
|
556
|
+
FILE_TRANSFER_SUBDIR,
|
|
557
|
+
DIR_FETCH_HARD_MAX_BYTES,
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
const tarDir = path.dirname(savedTar.path);
|
|
561
|
+
const tarBaseName = path.basename(savedTar.path, path.extname(savedTar.path));
|
|
562
|
+
const unpackId = `dir-fetch-${tarBaseName}`;
|
|
563
|
+
const rootDir = path.join(tarDir, unpackId);
|
|
564
|
+
|
|
565
|
+
await unpackTar(tarBuffer, rootDir);
|
|
566
|
+
|
|
567
|
+
const walked = await walkDir(rootDir, rootDir);
|
|
568
|
+
const files: UnpackedFileEntry[] = [];
|
|
569
|
+
// Defense-in-depth budget on the *uncompressed* extraction. Compressed
|
|
570
|
+
// tar is bounded upstream; an attacker can still send a highly
|
|
571
|
+
// compressible bomb (gigabytes of zeros) that fits under that cap.
|
|
572
|
+
// Stop walking + clean up if the unpacked tree busts the budget.
|
|
573
|
+
let totalUncompressed = 0;
|
|
574
|
+
const abortAndCleanup = async (reason: string): Promise<never> => {
|
|
575
|
+
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => {});
|
|
576
|
+
await appendFileTransferAudit({
|
|
577
|
+
op: "dir.fetch",
|
|
578
|
+
nodeId,
|
|
579
|
+
nodeDisplayName,
|
|
580
|
+
requestedPath: dirPath,
|
|
581
|
+
canonicalPath,
|
|
582
|
+
decision: "error",
|
|
583
|
+
errorCode: "TREE_TOO_LARGE",
|
|
584
|
+
errorMessage: reason,
|
|
585
|
+
sizeBytes: tarBytes,
|
|
586
|
+
sha256,
|
|
587
|
+
durationMs: Date.now() - startedAt,
|
|
588
|
+
});
|
|
589
|
+
throw new Error(`dir.fetch UNCOMPRESSED_TOO_LARGE: ${reason}`);
|
|
590
|
+
};
|
|
591
|
+
for (const { relPath, absPath } of walked) {
|
|
592
|
+
let size;
|
|
593
|
+
try {
|
|
594
|
+
const st = await fs.stat(absPath);
|
|
595
|
+
size = st.size;
|
|
596
|
+
} catch {
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (size > DIR_FETCH_MAX_SINGLE_FILE_BYTES) {
|
|
600
|
+
await abortAndCleanup(
|
|
601
|
+
`extracted file ${relPath} is ${size} bytes (limit ${DIR_FETCH_MAX_SINGLE_FILE_BYTES})`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
totalUncompressed += size;
|
|
605
|
+
if (totalUncompressed > DIR_FETCH_MAX_UNCOMPRESSED_BYTES) {
|
|
606
|
+
await abortAndCleanup(
|
|
607
|
+
`extracted tree exceeds uncompressed budget ${DIR_FETCH_MAX_UNCOMPRESSED_BYTES} bytes (decompression bomb?)`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
const mimeType = mimeFromExtension(relPath);
|
|
611
|
+
const fileSha256 = await computeFileSha256(absPath);
|
|
612
|
+
files.push({ relPath, size, mimeType, sha256: fileSha256, localPath: absPath });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const imageFiles = files.filter((f) => IMAGE_MIME_INLINE_SET.has(f.mimeType));
|
|
616
|
+
const nonImageFiles = files.filter((f) => !IMAGE_MIME_INLINE_SET.has(f.mimeType));
|
|
617
|
+
const allOrdered = [...imageFiles, ...nonImageFiles];
|
|
618
|
+
const droppedFromMedia = Math.max(0, allOrdered.length - MEDIA_URL_CAP);
|
|
619
|
+
const mediaUrls = allOrdered.slice(0, MEDIA_URL_CAP).map((f) => f.localPath);
|
|
620
|
+
|
|
621
|
+
const shortHash = sha256.slice(0, 12);
|
|
622
|
+
const mediaNote = droppedFromMedia
|
|
623
|
+
? ` (channel attaches first ${MEDIA_URL_CAP}; ${droppedFromMedia} more in details.files)`
|
|
624
|
+
: "";
|
|
625
|
+
const summaryText = `Fetched ${fileCount} files from ${canonicalPath} (${humanSize(tarBytes)} compressed, sha256:${shortHash}) — saved on the gateway under ${rootDir}/${mediaNote}`;
|
|
626
|
+
|
|
627
|
+
await appendFileTransferAudit({
|
|
628
|
+
op: "dir.fetch",
|
|
629
|
+
nodeId,
|
|
630
|
+
nodeDisplayName,
|
|
631
|
+
requestedPath: dirPath,
|
|
632
|
+
canonicalPath,
|
|
633
|
+
decision: "allowed",
|
|
634
|
+
sizeBytes: tarBytes,
|
|
635
|
+
sha256,
|
|
636
|
+
durationMs: Date.now() - startedAt,
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
return {
|
|
640
|
+
content: [{ type: "text" as const, text: summaryText }],
|
|
641
|
+
details: {
|
|
642
|
+
path: canonicalPath,
|
|
643
|
+
rootDir,
|
|
644
|
+
fileCount,
|
|
645
|
+
tarBytes,
|
|
646
|
+
sha256,
|
|
647
|
+
files,
|
|
648
|
+
media: {
|
|
649
|
+
mediaUrls,
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export const testing = {
|
|
658
|
+
preValidateTarball,
|
|
659
|
+
validateTarUncompressedBudget,
|
|
660
|
+
};
|