@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.
Files changed (36) hide show
  1. package/actagent.plugin.json +50 -0
  2. package/index.test.ts +93 -0
  3. package/index.ts +121 -0
  4. package/package.json +18 -0
  5. package/src/node-host/dir-fetch.test.ts +131 -0
  6. package/src/node-host/dir-fetch.ts +363 -0
  7. package/src/node-host/dir-list.test.ts +169 -0
  8. package/src/node-host/dir-list.ts +155 -0
  9. package/src/node-host/file-fetch.test.ts +254 -0
  10. package/src/node-host/file-fetch.ts +203 -0
  11. package/src/node-host/file-write.test.ts +378 -0
  12. package/src/node-host/file-write.ts +280 -0
  13. package/src/node-host/path-errors.ts +112 -0
  14. package/src/shared/audit.ts +98 -0
  15. package/src/shared/errors.test.ts +63 -0
  16. package/src/shared/errors.ts +68 -0
  17. package/src/shared/lazy-node-invoke-policy.test.ts +102 -0
  18. package/src/shared/lazy-node-invoke-policy.ts +36 -0
  19. package/src/shared/mime.test.ts +61 -0
  20. package/src/shared/mime.ts +30 -0
  21. package/src/shared/node-invoke-policy-commands.ts +9 -0
  22. package/src/shared/node-invoke-policy.test.ts +763 -0
  23. package/src/shared/node-invoke-policy.ts +947 -0
  24. package/src/shared/params.test.ts +42 -0
  25. package/src/shared/params.ts +60 -0
  26. package/src/shared/policy.test.ts +568 -0
  27. package/src/shared/policy.ts +383 -0
  28. package/src/tools/descriptors.ts +145 -0
  29. package/src/tools/dir-fetch-tool.test.ts +194 -0
  30. package/src/tools/dir-fetch-tool.ts +660 -0
  31. package/src/tools/dir-list-tool.ts +79 -0
  32. package/src/tools/file-fetch-tool.test.ts +82 -0
  33. package/src/tools/file-fetch-tool.ts +133 -0
  34. package/src/tools/file-write-tool.test.ts +30 -0
  35. package/src/tools/file-write-tool.ts +122 -0
  36. package/src/tools/node-tool-invoke.ts +97 -0
@@ -0,0 +1,947 @@
1
+ // File Transfer plugin module implements node invoke policy behavior.
2
+ import { spawn } from "node:child_process";
3
+ import { readPositiveIntegerParam } from "actagent/plugin-sdk/param-readers";
4
+ import type {
5
+ ACTAgentPluginNodeInvokePolicy,
6
+ ACTAgentPluginNodeInvokePolicyContext,
7
+ ACTAgentPluginNodeInvokePolicyResult,
8
+ } from "actagent/plugin-sdk/plugin-entry";
9
+ import { appendFileTransferAudit, type FileTransferAuditOp } from "./audit.js";
10
+ import {
11
+ FILE_TRANSFER_NODE_INVOKE_COMMANDS,
12
+ type FileTransferNodeInvokeCommand,
13
+ } from "./node-invoke-policy-commands.js";
14
+ import { evaluateFilePolicy, persistAllowAlways, type FilePolicyKind } from "./policy.js";
15
+
16
+ const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
17
+ const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
18
+ const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
19
+ const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
20
+ const DIR_FETCH_MAX_ENTRIES = 5000;
21
+ const DIR_FETCH_ARCHIVE_LIST_TIMEOUT_MS = 30_000;
22
+ const DIR_FETCH_ARCHIVE_LIST_MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
23
+ const DIR_FETCH_ARCHIVE_LIST_STDERR_TAIL_CHARS = 4096;
24
+
25
+ type FileTransferCommand = FileTransferNodeInvokeCommand;
26
+
27
+ function asRecord(value: unknown): Record<string, unknown> {
28
+ return value && typeof value === "object" && !Array.isArray(value)
29
+ ? (value as Record<string, unknown>)
30
+ : {};
31
+ }
32
+
33
+ function appendBoundedTextTail(current: string, chunk: Buffer, maxChars: number): string {
34
+ const next = current + chunk.toString();
35
+ return next.length > maxChars ? next.slice(-maxChars) : next;
36
+ }
37
+
38
+ function readPath(params: Record<string, unknown>): string {
39
+ return typeof params.path === "string" ? params.path.trim() : "";
40
+ }
41
+
42
+ function readMaxBytes(input: {
43
+ value: unknown;
44
+ defaultValue: number;
45
+ hardMax: number;
46
+ policyMax?: number;
47
+ }): number {
48
+ const parsed =
49
+ input.value === undefined
50
+ ? input.defaultValue
51
+ : readPositiveIntegerParam({ maxBytes: input.value }, "maxBytes");
52
+ const requested = parsed ?? input.defaultValue;
53
+ const clamped = Math.max(1, Math.min(requested, input.hardMax));
54
+ return input.policyMax ? Math.min(clamped, input.policyMax) : clamped;
55
+ }
56
+
57
+ function commandKind(command: FileTransferCommand): FilePolicyKind {
58
+ return command === "file.write" ? "write" : "read";
59
+ }
60
+
61
+ function validateFetchMaxBytesParam(command: FileTransferCommand, params: Record<string, unknown>) {
62
+ if (command !== "file.fetch" && command !== "dir.fetch") {
63
+ return;
64
+ }
65
+ if (params.maxBytes !== undefined) {
66
+ readPositiveIntegerParam(params, "maxBytes");
67
+ }
68
+ }
69
+
70
+ function promptVerb(command: FileTransferCommand): string {
71
+ switch (command) {
72
+ case "dir.fetch":
73
+ return "Fetch directory";
74
+ case "dir.list":
75
+ return "List directory";
76
+ case "file.write":
77
+ return "Write file";
78
+ case "file.fetch":
79
+ return "Read file";
80
+ }
81
+ return command;
82
+ }
83
+
84
+ async function requestApproval(input: {
85
+ ctx: ACTAgentPluginNodeInvokePolicyContext;
86
+ op: FileTransferAuditOp;
87
+ kind: FilePolicyKind;
88
+ path: string;
89
+ startedAt: number;
90
+ }): Promise<
91
+ | { ok: true; followSymlinks: boolean; maxBytes?: number }
92
+ | { ok: false; message: string; code: string }
93
+ > {
94
+ const nodeDisplayName = input.ctx.node?.displayName;
95
+ const decision = evaluateFilePolicy({
96
+ nodeId: input.ctx.nodeId,
97
+ nodeDisplayName,
98
+ kind: input.kind,
99
+ path: input.path,
100
+ pluginConfig: input.ctx.pluginConfig,
101
+ });
102
+
103
+ if (decision.ok && decision.reason === "matched-allow") {
104
+ return {
105
+ ok: true,
106
+ followSymlinks: decision.followSymlinks,
107
+ maxBytes: decision.maxBytes,
108
+ };
109
+ }
110
+
111
+ const shouldAsk =
112
+ (decision.ok && decision.reason === "ask-always") || (!decision.ok && decision.askable);
113
+ if (!shouldAsk) {
114
+ await appendFileTransferAudit({
115
+ op: input.op,
116
+ nodeId: input.ctx.nodeId,
117
+ nodeDisplayName,
118
+ requestedPath: input.path,
119
+ decision:
120
+ !decision.ok && decision.code === "NO_POLICY" ? "denied:no_policy" : "denied:policy",
121
+ errorCode: decision.ok ? undefined : decision.code,
122
+ reason: decision.ok ? decision.reason : decision.reason,
123
+ durationMs: Date.now() - input.startedAt,
124
+ });
125
+ return {
126
+ ok: false,
127
+ code: decision.ok ? "POLICY_DENIED" : decision.code,
128
+ message: `${input.op} ${decision.ok ? "POLICY_DENIED" : decision.code}: ${decision.reason}`,
129
+ };
130
+ }
131
+
132
+ const approvals = input.ctx.approvals;
133
+ if (!approvals) {
134
+ await appendFileTransferAudit({
135
+ op: input.op,
136
+ nodeId: input.ctx.nodeId,
137
+ nodeDisplayName,
138
+ requestedPath: input.path,
139
+ decision: "denied:approval",
140
+ reason: "plugin approvals unavailable",
141
+ durationMs: Date.now() - input.startedAt,
142
+ });
143
+ return {
144
+ ok: false,
145
+ code: "APPROVAL_UNAVAILABLE",
146
+ message: `${input.op} APPROVAL_UNAVAILABLE: plugin approvals unavailable`,
147
+ };
148
+ }
149
+
150
+ const verb = promptVerb(input.op);
151
+ const subject = nodeDisplayName ?? input.ctx.nodeId;
152
+ const approval = await approvals.request({
153
+ title: `${verb}: ${input.path}`,
154
+ description: `Allow ${verb.toLowerCase()} on ${subject}\nPath: ${input.path}\nKind: ${input.kind}\n\n"allow-always" appends this exact path to allow${input.kind === "read" ? "Read" : "Write"}Paths.`,
155
+ severity: input.kind === "write" ? "warning" : "info",
156
+ toolName: input.op,
157
+ });
158
+
159
+ if (approval.decision === "deny" || approval.decision === null || !approval.decision) {
160
+ await appendFileTransferAudit({
161
+ op: input.op,
162
+ nodeId: input.ctx.nodeId,
163
+ nodeDisplayName,
164
+ requestedPath: input.path,
165
+ decision: "denied:approval",
166
+ reason: approval.decision === "deny" ? "operator denied" : "no operator available",
167
+ durationMs: Date.now() - input.startedAt,
168
+ });
169
+ return {
170
+ ok: false,
171
+ code: approval.decision === "deny" ? "APPROVAL_DENIED" : "APPROVAL_UNAVAILABLE",
172
+ message:
173
+ approval.decision === "deny"
174
+ ? `${input.op} APPROVAL_DENIED: operator denied the prompt`
175
+ : `${input.op} APPROVAL_UNAVAILABLE: no operator client connected to approve the request`,
176
+ };
177
+ }
178
+
179
+ if (approval.decision === "allow-always") {
180
+ try {
181
+ await persistAllowAlways({
182
+ nodeId: input.ctx.nodeId,
183
+ nodeDisplayName,
184
+ kind: input.kind,
185
+ path: input.path,
186
+ });
187
+ const refreshed = evaluateFilePolicy({
188
+ nodeId: input.ctx.nodeId,
189
+ nodeDisplayName,
190
+ kind: input.kind,
191
+ path: input.path,
192
+ pluginConfig: input.ctx.pluginConfig,
193
+ });
194
+ if (refreshed.ok) {
195
+ await appendFileTransferAudit({
196
+ op: input.op,
197
+ nodeId: input.ctx.nodeId,
198
+ nodeDisplayName,
199
+ requestedPath: input.path,
200
+ decision: "allowed:always",
201
+ durationMs: Date.now() - input.startedAt,
202
+ });
203
+ return {
204
+ ok: true,
205
+ followSymlinks: refreshed.followSymlinks,
206
+ maxBytes: refreshed.maxBytes,
207
+ };
208
+ }
209
+ } catch (error) {
210
+ await appendFileTransferAudit({
211
+ op: input.op,
212
+ nodeId: input.ctx.nodeId,
213
+ nodeDisplayName,
214
+ requestedPath: input.path,
215
+ decision: "allowed:always",
216
+ reason: `persist failed: ${String(error)}`,
217
+ durationMs: Date.now() - input.startedAt,
218
+ });
219
+ return {
220
+ ok: true,
221
+ followSymlinks: decision.ok ? decision.followSymlinks : false,
222
+ maxBytes: decision.maxBytes,
223
+ };
224
+ }
225
+ }
226
+
227
+ await appendFileTransferAudit({
228
+ op: input.op,
229
+ nodeId: input.ctx.nodeId,
230
+ nodeDisplayName,
231
+ requestedPath: input.path,
232
+ decision: approval.decision === "allow-always" ? "allowed:always" : "allowed:once",
233
+ durationMs: Date.now() - input.startedAt,
234
+ });
235
+ return {
236
+ ok: true,
237
+ followSymlinks: decision.ok ? decision.followSymlinks : false,
238
+ maxBytes: decision.maxBytes,
239
+ };
240
+ }
241
+
242
+ function prepareParams(input: {
243
+ command: FileTransferCommand;
244
+ params: Record<string, unknown>;
245
+ followSymlinks: boolean;
246
+ maxBytes?: number;
247
+ }): Record<string, unknown> {
248
+ const next: Record<string, unknown> = {
249
+ ...input.params,
250
+ followSymlinks: input.followSymlinks,
251
+ };
252
+ delete next.preflightOnly;
253
+ if (input.command === "file.fetch") {
254
+ next.maxBytes = readMaxBytes({
255
+ value: input.params.maxBytes,
256
+ defaultValue: FILE_FETCH_DEFAULT_MAX_BYTES,
257
+ hardMax: FILE_FETCH_HARD_MAX_BYTES,
258
+ policyMax: input.maxBytes,
259
+ });
260
+ } else if (input.command === "dir.fetch") {
261
+ next.maxBytes = readMaxBytes({
262
+ value: input.params.maxBytes,
263
+ defaultValue: DIR_FETCH_DEFAULT_MAX_BYTES,
264
+ hardMax: DIR_FETCH_HARD_MAX_BYTES,
265
+ policyMax: input.maxBytes,
266
+ });
267
+ }
268
+ return next;
269
+ }
270
+
271
+ function readResultPayload(result: { payload?: unknown }): Record<string, unknown> | null {
272
+ return result.payload && typeof result.payload === "object" && !Array.isArray(result.payload)
273
+ ? (result.payload as Record<string, unknown>)
274
+ : null;
275
+ }
276
+
277
+ function joinRemotePolicyPath(root: string, relPath: string): string {
278
+ const rel = relPath.replace(/\\/gu, "/").replace(/^\.\//u, "");
279
+ if (!rel || rel === ".") {
280
+ return root;
281
+ }
282
+ const sep = root.includes("\\") && !root.includes("/") ? "\\" : "/";
283
+ const cleanRoot = root.replace(/[\\/]$/u, "");
284
+ const prefix = cleanRoot || sep;
285
+ return `${prefix}${prefix.endsWith(sep) ? "" : sep}${rel.split("/").join(sep)}`;
286
+ }
287
+
288
+ function validateDirFetchPreflightEntry(
289
+ entry: string,
290
+ ): { ok: true } | { ok: false; reason: string } {
291
+ if (entry.includes("\0")) {
292
+ return { ok: false, reason: "entry contains NUL byte" };
293
+ }
294
+ const normalized = entry.replace(/\\/gu, "/").replace(/^\.\//u, "");
295
+ if (!normalized || normalized === ".") {
296
+ return { ok: false, reason: "entry is empty" };
297
+ }
298
+ if (normalized.startsWith("/") || /^[A-Za-z]:\//u.test(normalized)) {
299
+ return { ok: false, reason: "entry is absolute" };
300
+ }
301
+ if (normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
302
+ return { ok: false, reason: "entry contains '..' traversal" };
303
+ }
304
+ return { ok: true };
305
+ }
306
+
307
+ function normalizeTarEntryPath(entry: string): string | null {
308
+ const normalized = entry.replace(/\\/gu, "/").replace(/^\.\//u, "").replace(/\/$/u, "");
309
+ return normalized.length > 0 ? normalized : null;
310
+ }
311
+
312
+ async function listDirFetchArchiveEntries(
313
+ payload: Record<string, unknown> | null,
314
+ ): Promise<{ ok: true; entries: string[] } | { ok: false; code: string; reason: string }> {
315
+ const tarBase64 = typeof payload?.tarBase64 === "string" ? payload.tarBase64 : "";
316
+ if (!tarBase64) {
317
+ return {
318
+ ok: false,
319
+ code: "ARCHIVE_ENTRIES_MISSING",
320
+ reason: "dir.fetch archive did not return tarBase64",
321
+ };
322
+ }
323
+ const tarBuffer = Buffer.from(tarBase64, "base64");
324
+ return await new Promise<
325
+ { ok: true; entries: string[] } | { ok: false; code: string; reason: string }
326
+ >((resolve) => {
327
+ const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
328
+ const child = spawn(tarBin, ["-tzf", "-"], { stdio: ["pipe", "pipe", "pipe"] });
329
+ const entries: string[] = [];
330
+ let pending = "";
331
+ let outputBytes = 0;
332
+ let stderr = "";
333
+ let settled = false;
334
+ const finish = (
335
+ result: { ok: true; entries: string[] } | { ok: false; code: string; reason: string },
336
+ ): void => {
337
+ if (settled) {
338
+ return;
339
+ }
340
+ settled = true;
341
+ clearTimeout(watchdog);
342
+ resolve(result);
343
+ };
344
+ const stopChild = (): void => {
345
+ try {
346
+ child.kill("SIGKILL");
347
+ } catch {
348
+ /* gone */
349
+ }
350
+ };
351
+ const appendLine = (line: string): boolean => {
352
+ if (settled) {
353
+ return false;
354
+ }
355
+ const entry = normalizeTarEntryPath(line);
356
+ if (entry !== null) {
357
+ entries.push(entry);
358
+ if (entries.length > DIR_FETCH_MAX_ENTRIES) {
359
+ stopChild();
360
+ finish({
361
+ ok: false,
362
+ code: "ARCHIVE_ENTRIES_TOO_MANY",
363
+ reason: `dir.fetch archive contains more than ${DIR_FETCH_MAX_ENTRIES} entries`,
364
+ });
365
+ return false;
366
+ }
367
+ }
368
+ return true;
369
+ };
370
+ const watchdog = setTimeout(() => {
371
+ stopChild();
372
+ finish({
373
+ ok: false,
374
+ code: "ARCHIVE_ENTRIES_UNREADABLE",
375
+ reason: "tar -tzf timed out",
376
+ });
377
+ }, DIR_FETCH_ARCHIVE_LIST_TIMEOUT_MS);
378
+ child.stdout.on("data", (chunk: Buffer) => {
379
+ if (settled) {
380
+ return;
381
+ }
382
+ outputBytes += chunk.byteLength;
383
+ if (outputBytes > DIR_FETCH_ARCHIVE_LIST_MAX_OUTPUT_BYTES) {
384
+ stopChild();
385
+ finish({
386
+ ok: false,
387
+ code: "ARCHIVE_ENTRIES_UNREADABLE",
388
+ reason: "tar -tzf output too large",
389
+ });
390
+ return;
391
+ }
392
+ const lines = `${pending}${chunk.toString()}`.split("\n");
393
+ pending = lines.pop() ?? "";
394
+ for (const line of lines) {
395
+ if (!appendLine(line)) {
396
+ return;
397
+ }
398
+ }
399
+ });
400
+ child.stderr.on("data", (chunk: Buffer) => {
401
+ stderr = appendBoundedTextTail(stderr, chunk, DIR_FETCH_ARCHIVE_LIST_STDERR_TAIL_CHARS);
402
+ });
403
+ child.on("close", (code) => {
404
+ if (settled) {
405
+ return;
406
+ }
407
+ if (code !== 0) {
408
+ finish({
409
+ ok: false,
410
+ code: "ARCHIVE_ENTRIES_UNREADABLE",
411
+ reason: `tar -tzf exited ${code}: ${stderr.slice(-200)}`,
412
+ });
413
+ return;
414
+ }
415
+ if (pending) {
416
+ if (!appendLine(pending)) {
417
+ return;
418
+ }
419
+ }
420
+ finish({ ok: true, entries });
421
+ });
422
+ child.on("error", (error) => {
423
+ finish({
424
+ ok: false,
425
+ code: "ARCHIVE_ENTRIES_UNREADABLE",
426
+ reason: `tar -tzf error: ${String(error)}`,
427
+ });
428
+ });
429
+ child.stdin.on("error", (error: NodeJS.ErrnoException) => {
430
+ if (settled && error.code === "EPIPE") {
431
+ return;
432
+ }
433
+ finish({
434
+ ok: false,
435
+ code: "ARCHIVE_ENTRIES_UNREADABLE",
436
+ reason: `tar -tzf input error: ${String(error)}`,
437
+ });
438
+ });
439
+ child.stdin.end(tarBuffer);
440
+ });
441
+ }
442
+
443
+ async function validateDirFetchEntries(input: {
444
+ ctx: ACTAgentPluginNodeInvokePolicyContext;
445
+ op: FileTransferAuditOp;
446
+ requestedPath: string;
447
+ canonicalPath: string;
448
+ entries: unknown;
449
+ startedAt: number;
450
+ phase: "preflight" | "archive";
451
+ }): Promise<ACTAgentPluginNodeInvokePolicyResult | null> {
452
+ const nodeDisplayName = input.ctx.node?.displayName;
453
+ const missingCode =
454
+ input.phase === "preflight" ? "PREFLIGHT_ENTRIES_MISSING" : "ARCHIVE_ENTRIES_MISSING";
455
+ const invalidCode =
456
+ input.phase === "preflight" ? "PREFLIGHT_ENTRY_INVALID" : "ARCHIVE_ENTRY_INVALID";
457
+ const tooManyCode =
458
+ input.phase === "preflight" ? "PREFLIGHT_ENTRIES_TOO_MANY" : "ARCHIVE_ENTRIES_TOO_MANY";
459
+ if (!Array.isArray(input.entries)) {
460
+ await appendFileTransferAudit({
461
+ op: input.op,
462
+ nodeId: input.ctx.nodeId,
463
+ nodeDisplayName,
464
+ requestedPath: input.requestedPath,
465
+ canonicalPath: input.canonicalPath,
466
+ decision: "error",
467
+ errorCode: missingCode,
468
+ reason: `dir.fetch ${input.phase} did not return entries`,
469
+ durationMs: Date.now() - input.startedAt,
470
+ });
471
+ return policyDeniedResult({
472
+ op: input.op,
473
+ code: missingCode,
474
+ message: `dir.fetch ${input.phase} did not return entries; refusing archive transfer`,
475
+ details: { path: input.canonicalPath },
476
+ });
477
+ }
478
+ if (input.entries.length > DIR_FETCH_MAX_ENTRIES) {
479
+ const reason = `dir.fetch ${input.phase} contains ${input.entries.length} entries; limit ${DIR_FETCH_MAX_ENTRIES}`;
480
+ await appendFileTransferAudit({
481
+ op: input.op,
482
+ nodeId: input.ctx.nodeId,
483
+ nodeDisplayName,
484
+ requestedPath: input.requestedPath,
485
+ canonicalPath: input.canonicalPath,
486
+ decision: "denied:policy",
487
+ errorCode: tooManyCode,
488
+ reason,
489
+ durationMs: Date.now() - input.startedAt,
490
+ });
491
+ return policyDeniedResult({
492
+ op: input.op,
493
+ code: tooManyCode,
494
+ message: `${reason}; refusing archive transfer`,
495
+ details: { path: input.canonicalPath, reason },
496
+ });
497
+ }
498
+
499
+ const entries: string[] = [];
500
+ for (const entry of input.entries) {
501
+ if (typeof entry !== "string" || entry.length === 0) {
502
+ await appendFileTransferAudit({
503
+ op: input.op,
504
+ nodeId: input.ctx.nodeId,
505
+ nodeDisplayName,
506
+ requestedPath: input.requestedPath,
507
+ canonicalPath: input.canonicalPath,
508
+ decision: "denied:policy",
509
+ errorCode: invalidCode,
510
+ reason: "entry is not a non-empty string",
511
+ durationMs: Date.now() - input.startedAt,
512
+ });
513
+ return policyDeniedResult({
514
+ op: input.op,
515
+ code: invalidCode,
516
+ message: `directory ${input.phase} entry is invalid: entry is not a non-empty string`,
517
+ details: { path: input.canonicalPath, reason: "entry is not a non-empty string" },
518
+ });
519
+ }
520
+ const entryValidation = validateDirFetchPreflightEntry(entry);
521
+ if (!entryValidation.ok) {
522
+ const candidate = joinRemotePolicyPath(input.canonicalPath, entry);
523
+ await appendFileTransferAudit({
524
+ op: input.op,
525
+ nodeId: input.ctx.nodeId,
526
+ nodeDisplayName,
527
+ requestedPath: input.requestedPath,
528
+ canonicalPath: candidate,
529
+ decision: "denied:policy",
530
+ errorCode: invalidCode,
531
+ reason: entryValidation.reason,
532
+ durationMs: Date.now() - input.startedAt,
533
+ });
534
+ return policyDeniedResult({
535
+ op: input.op,
536
+ code: invalidCode,
537
+ message: `directory ${input.phase} entry ${entry} is invalid: ${entryValidation.reason}`,
538
+ details: { path: candidate, reason: entryValidation.reason },
539
+ });
540
+ }
541
+ entries.push(entry);
542
+ }
543
+
544
+ const candidates = [
545
+ input.canonicalPath,
546
+ ...entries.map((entry) => joinRemotePolicyPath(input.canonicalPath, entry)),
547
+ ];
548
+ for (const candidate of candidates) {
549
+ const policy = evaluateFilePolicy({
550
+ nodeId: input.ctx.nodeId,
551
+ nodeDisplayName,
552
+ kind: "read",
553
+ path: candidate,
554
+ pluginConfig: input.ctx.pluginConfig,
555
+ });
556
+ if (policy.ok) {
557
+ continue;
558
+ }
559
+ await appendFileTransferAudit({
560
+ op: input.op,
561
+ nodeId: input.ctx.nodeId,
562
+ nodeDisplayName,
563
+ requestedPath: input.requestedPath,
564
+ canonicalPath: candidate,
565
+ decision: "denied:policy",
566
+ errorCode: policy.code,
567
+ reason: policy.reason,
568
+ durationMs: Date.now() - input.startedAt,
569
+ });
570
+ return policyDeniedResult({
571
+ op: input.op,
572
+ code: "PATH_POLICY_DENIED",
573
+ message: `directory ${input.phase} entry ${candidate} is not allowed by policy: ${policy.reason}`,
574
+ details: { path: candidate, reason: policy.reason },
575
+ });
576
+ }
577
+
578
+ return null;
579
+ }
580
+
581
+ function policyDeniedResult(input: {
582
+ op: FileTransferAuditOp;
583
+ code: string;
584
+ message: string;
585
+ details?: Record<string, unknown>;
586
+ }): ACTAgentPluginNodeInvokePolicyResult {
587
+ return {
588
+ ok: false,
589
+ code: input.code,
590
+ message: `${input.op} ${input.code}: ${input.message}`,
591
+ ...(input.details ? { details: input.details } : {}),
592
+ };
593
+ }
594
+
595
+ type PreflightResult =
596
+ | {
597
+ ok: true;
598
+ payload: Record<string, unknown> | null;
599
+ canonicalPath: string;
600
+ }
601
+ | {
602
+ ok: false;
603
+ result: ACTAgentPluginNodeInvokePolicyResult;
604
+ };
605
+
606
+ async function invokePreflight(input: {
607
+ ctx: ACTAgentPluginNodeInvokePolicyContext;
608
+ op: FileTransferAuditOp;
609
+ params: Record<string, unknown>;
610
+ requestedPath: string;
611
+ startedAt: number;
612
+ }): Promise<PreflightResult> {
613
+ const nodeDisplayName = input.ctx.node?.displayName;
614
+ const preflight = await input.ctx.invokeNode({
615
+ params: {
616
+ ...input.params,
617
+ preflightOnly: true,
618
+ },
619
+ });
620
+ if (!preflight.ok) {
621
+ await appendFileTransferAudit({
622
+ op: input.op,
623
+ nodeId: input.ctx.nodeId,
624
+ nodeDisplayName,
625
+ requestedPath: input.requestedPath,
626
+ decision: "error",
627
+ errorCode: preflight.code,
628
+ errorMessage: preflight.message,
629
+ durationMs: Date.now() - input.startedAt,
630
+ });
631
+ return {
632
+ ok: false,
633
+ result: {
634
+ ok: false,
635
+ code: preflight.code,
636
+ message: `${input.op} failed: ${preflight.message}`,
637
+ details: preflight.details,
638
+ unavailable: true,
639
+ },
640
+ };
641
+ }
642
+
643
+ const payload = readResultPayload(preflight);
644
+ if (payload?.ok === false) {
645
+ await appendFileTransferAudit({
646
+ op: input.op,
647
+ nodeId: input.ctx.nodeId,
648
+ nodeDisplayName,
649
+ requestedPath: input.requestedPath,
650
+ canonicalPath: typeof payload.canonicalPath === "string" ? payload.canonicalPath : undefined,
651
+ decision: "error",
652
+ errorCode: typeof payload.code === "string" ? payload.code : undefined,
653
+ errorMessage: typeof payload.message === "string" ? payload.message : undefined,
654
+ durationMs: Date.now() - input.startedAt,
655
+ });
656
+ return { ok: false, result: preflight };
657
+ }
658
+
659
+ const canonicalPath =
660
+ payload && typeof payload.path === "string" && payload.path
661
+ ? payload.path
662
+ : input.requestedPath;
663
+ return { ok: true, payload, canonicalPath };
664
+ }
665
+
666
+ async function runPathPreflight(input: {
667
+ ctx: ACTAgentPluginNodeInvokePolicyContext;
668
+ op: FileTransferAuditOp;
669
+ kind: FilePolicyKind;
670
+ params: Record<string, unknown>;
671
+ requestedPath: string;
672
+ startedAt: number;
673
+ }): Promise<ACTAgentPluginNodeInvokePolicyResult | null> {
674
+ const preflight = await invokePreflight(input);
675
+ if (!preflight.ok) {
676
+ return preflight.result;
677
+ }
678
+
679
+ const nodeDisplayName = input.ctx.node?.displayName;
680
+ const { canonicalPath } = preflight;
681
+ if (canonicalPath === input.requestedPath) {
682
+ return null;
683
+ }
684
+
685
+ const policy = evaluateFilePolicy({
686
+ nodeId: input.ctx.nodeId,
687
+ nodeDisplayName,
688
+ kind: input.kind,
689
+ path: canonicalPath,
690
+ pluginConfig: input.ctx.pluginConfig,
691
+ });
692
+ if (policy.ok) {
693
+ return null;
694
+ }
695
+
696
+ await appendFileTransferAudit({
697
+ op: input.op,
698
+ nodeId: input.ctx.nodeId,
699
+ nodeDisplayName,
700
+ requestedPath: input.requestedPath,
701
+ canonicalPath,
702
+ decision: "denied:symlink_escape",
703
+ errorCode: policy.code,
704
+ reason: policy.reason,
705
+ durationMs: Date.now() - input.startedAt,
706
+ });
707
+ return {
708
+ ok: false,
709
+ code: "SYMLINK_TARGET_DENIED",
710
+ message: `${input.op} SYMLINK_TARGET_DENIED: requested path resolved to ${canonicalPath} which is not allowed by policy`,
711
+ };
712
+ }
713
+
714
+ async function runDirFetchPreflight(input: {
715
+ ctx: ACTAgentPluginNodeInvokePolicyContext;
716
+ op: FileTransferAuditOp;
717
+ params: Record<string, unknown>;
718
+ requestedPath: string;
719
+ startedAt: number;
720
+ }): Promise<ACTAgentPluginNodeInvokePolicyResult | null> {
721
+ const preflight = await invokePreflight(input);
722
+ if (!preflight.ok) {
723
+ return preflight.result;
724
+ }
725
+
726
+ return await validateDirFetchEntries({
727
+ ctx: input.ctx,
728
+ op: input.op,
729
+ requestedPath: input.requestedPath,
730
+ canonicalPath: preflight.canonicalPath,
731
+ entries: preflight.payload?.entries,
732
+ startedAt: input.startedAt,
733
+ phase: "preflight",
734
+ });
735
+ }
736
+
737
+ async function handleFileTransferInvoke(
738
+ ctx: ACTAgentPluginNodeInvokePolicyContext,
739
+ ): Promise<ACTAgentPluginNodeInvokePolicyResult> {
740
+ if (!FILE_TRANSFER_NODE_INVOKE_COMMANDS.includes(ctx.command as FileTransferCommand)) {
741
+ return { ok: false, code: "UNSUPPORTED_COMMAND", message: "unsupported file-transfer command" };
742
+ }
743
+ const command = ctx.command as FileTransferCommand;
744
+ const op: FileTransferAuditOp = command;
745
+ const params = asRecord(ctx.params);
746
+ const requestedPath = readPath(params);
747
+ const nodeDisplayName = ctx.node?.displayName;
748
+ const startedAt = Date.now();
749
+
750
+ if (!requestedPath) {
751
+ return { ok: false, code: "INVALID_PARAMS", message: `${op} path required` };
752
+ }
753
+ try {
754
+ validateFetchMaxBytesParam(command, params);
755
+ } catch (error) {
756
+ return {
757
+ ok: false,
758
+ code: "INVALID_PARAMS",
759
+ message: error instanceof Error ? error.message : String(error),
760
+ };
761
+ }
762
+
763
+ const gate = await requestApproval({
764
+ ctx,
765
+ op,
766
+ kind: commandKind(command),
767
+ path: requestedPath,
768
+ startedAt,
769
+ });
770
+ if (!gate.ok) {
771
+ return { ok: false, code: gate.code, message: gate.message };
772
+ }
773
+
774
+ let forwardedParams: Record<string, unknown>;
775
+ try {
776
+ forwardedParams = prepareParams({
777
+ command,
778
+ params,
779
+ followSymlinks: gate.followSymlinks,
780
+ maxBytes: gate.maxBytes,
781
+ });
782
+ } catch (error) {
783
+ return {
784
+ ok: false,
785
+ code: "INVALID_PARAMS",
786
+ message: error instanceof Error ? error.message : String(error),
787
+ };
788
+ }
789
+ if (command === "file.fetch") {
790
+ const preflightDeny = await runPathPreflight({
791
+ ctx,
792
+ op,
793
+ kind: "read",
794
+ params: forwardedParams,
795
+ requestedPath,
796
+ startedAt,
797
+ });
798
+ if (preflightDeny) {
799
+ return preflightDeny;
800
+ }
801
+ } else if (command === "file.write") {
802
+ const preflightDeny = await runPathPreflight({
803
+ ctx,
804
+ op,
805
+ kind: "write",
806
+ params: forwardedParams,
807
+ requestedPath,
808
+ startedAt,
809
+ });
810
+ if (preflightDeny) {
811
+ return preflightDeny;
812
+ }
813
+ } else if (command === "dir.fetch") {
814
+ const preflightDeny = await runDirFetchPreflight({
815
+ ctx,
816
+ op,
817
+ params: forwardedParams,
818
+ requestedPath,
819
+ startedAt,
820
+ });
821
+ if (preflightDeny) {
822
+ return preflightDeny;
823
+ }
824
+ }
825
+
826
+ const result = await ctx.invokeNode({ params: forwardedParams });
827
+ if (!result.ok) {
828
+ await appendFileTransferAudit({
829
+ op,
830
+ nodeId: ctx.nodeId,
831
+ nodeDisplayName,
832
+ requestedPath,
833
+ decision: "error",
834
+ errorCode: result.code,
835
+ errorMessage: result.message,
836
+ durationMs: Date.now() - startedAt,
837
+ });
838
+ return {
839
+ ok: false,
840
+ code: result.code,
841
+ message: `${op} failed: ${result.message}`,
842
+ details: result.details,
843
+ unavailable: true,
844
+ };
845
+ }
846
+
847
+ const payload = readResultPayload(result);
848
+ if (payload?.ok === false) {
849
+ await appendFileTransferAudit({
850
+ op,
851
+ nodeId: ctx.nodeId,
852
+ nodeDisplayName,
853
+ requestedPath,
854
+ canonicalPath: typeof payload.canonicalPath === "string" ? payload.canonicalPath : undefined,
855
+ decision: "error",
856
+ errorCode: typeof payload.code === "string" ? payload.code : undefined,
857
+ errorMessage: typeof payload.message === "string" ? payload.message : undefined,
858
+ durationMs: Date.now() - startedAt,
859
+ });
860
+ return result;
861
+ }
862
+
863
+ const canonicalPath =
864
+ payload && typeof payload.path === "string" && payload.path ? payload.path : requestedPath;
865
+ if (canonicalPath !== requestedPath) {
866
+ const postflight = evaluateFilePolicy({
867
+ nodeId: ctx.nodeId,
868
+ nodeDisplayName,
869
+ kind: commandKind(command),
870
+ path: canonicalPath,
871
+ pluginConfig: ctx.pluginConfig,
872
+ });
873
+ if (!postflight.ok) {
874
+ await appendFileTransferAudit({
875
+ op,
876
+ nodeId: ctx.nodeId,
877
+ nodeDisplayName,
878
+ requestedPath,
879
+ canonicalPath,
880
+ decision: "denied:symlink_escape",
881
+ errorCode: postflight.code,
882
+ reason: postflight.reason,
883
+ durationMs: Date.now() - startedAt,
884
+ });
885
+ return {
886
+ ok: false,
887
+ code: "SYMLINK_TARGET_DENIED",
888
+ message: `${op} SYMLINK_TARGET_DENIED: requested path resolved to ${canonicalPath} which is not allowed by policy`,
889
+ };
890
+ }
891
+ }
892
+ if (command === "dir.fetch") {
893
+ const archiveEntries = await listDirFetchArchiveEntries(payload);
894
+ if (!archiveEntries.ok) {
895
+ await appendFileTransferAudit({
896
+ op,
897
+ nodeId: ctx.nodeId,
898
+ nodeDisplayName,
899
+ requestedPath,
900
+ canonicalPath,
901
+ decision: "error",
902
+ errorCode: archiveEntries.code,
903
+ reason: archiveEntries.reason,
904
+ durationMs: Date.now() - startedAt,
905
+ });
906
+ return policyDeniedResult({
907
+ op,
908
+ code: archiveEntries.code,
909
+ message: `${archiveEntries.reason}; refusing archive transfer`,
910
+ details: { path: canonicalPath, reason: archiveEntries.reason },
911
+ });
912
+ }
913
+ const archiveDeny = await validateDirFetchEntries({
914
+ ctx,
915
+ op,
916
+ requestedPath,
917
+ canonicalPath,
918
+ entries: archiveEntries.entries,
919
+ startedAt,
920
+ phase: "archive",
921
+ });
922
+ if (archiveDeny) {
923
+ return archiveDeny;
924
+ }
925
+ }
926
+
927
+ await appendFileTransferAudit({
928
+ op,
929
+ nodeId: ctx.nodeId,
930
+ nodeDisplayName,
931
+ requestedPath,
932
+ canonicalPath,
933
+ decision: "allowed",
934
+ sizeBytes: typeof payload?.size === "number" ? payload.size : undefined,
935
+ sha256: typeof payload?.sha256 === "string" ? payload.sha256 : undefined,
936
+ durationMs: Date.now() - startedAt,
937
+ });
938
+
939
+ return result;
940
+ }
941
+
942
+ export function createFileTransferNodeInvokePolicy(): ACTAgentPluginNodeInvokePolicy {
943
+ return {
944
+ commands: [...FILE_TRANSFER_NODE_INVOKE_COMMANDS],
945
+ handle: handleFileTransferInvoke,
946
+ };
947
+ }