@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,42 @@
1
+ // File Transfer tests cover params plugin behavior.
2
+ import { describe, expect, it } from "vitest";
3
+ import { readClampedInt, readGatewayCallOptions } from "./params.js";
4
+
5
+ describe("file-transfer shared params", () => {
6
+ it("normalizes string timeoutMs values for gateway calls", () => {
7
+ expect(readGatewayCallOptions({ timeoutMs: "5000" }).timeoutMs).toBe(5000);
8
+ });
9
+
10
+ it("rejects malformed timeoutMs values before gateway calls", () => {
11
+ expect(() => readGatewayCallOptions({ timeoutMs: "5000.5" })).toThrow(
12
+ "timeoutMs must be a positive integer",
13
+ );
14
+ expect(() => readGatewayCallOptions({ timeoutMs: 0 })).toThrow(
15
+ "timeoutMs must be a positive integer",
16
+ );
17
+ });
18
+
19
+ it("normalizes and clamps string integer limits", () => {
20
+ expect(
21
+ readClampedInt({
22
+ input: { maxBytes: "1024" },
23
+ key: "maxBytes",
24
+ defaultValue: 256,
25
+ hardMin: 1,
26
+ hardMax: 512,
27
+ }),
28
+ ).toBe(512);
29
+ });
30
+
31
+ it("rejects malformed integer limits instead of silently using defaults", () => {
32
+ expect(() =>
33
+ readClampedInt({
34
+ input: { maxEntries: "2.5" },
35
+ key: "maxEntries",
36
+ defaultValue: 200,
37
+ hardMin: 1,
38
+ hardMax: 5000,
39
+ }),
40
+ ).toThrow("maxEntries must be a positive integer");
41
+ });
42
+ });
@@ -0,0 +1,60 @@
1
+ // Shared param-validation helpers used by all four agent tools.
2
+ // Goal: identical validation behavior + identical error shapes everywhere.
3
+
4
+ import { readPositiveIntegerParam } from "actagent/plugin-sdk/param-readers";
5
+
6
+ type GatewayCallOptions = {
7
+ gatewayUrl?: string;
8
+ gatewayToken?: string;
9
+ timeoutMs?: number;
10
+ };
11
+
12
+ export function readGatewayCallOptions(params: Record<string, unknown>): GatewayCallOptions {
13
+ const opts: GatewayCallOptions = {};
14
+ if (typeof params.gatewayUrl === "string" && params.gatewayUrl.trim()) {
15
+ opts.gatewayUrl = params.gatewayUrl.trim();
16
+ }
17
+ if (typeof params.gatewayToken === "string" && params.gatewayToken.trim()) {
18
+ opts.gatewayToken = params.gatewayToken.trim();
19
+ }
20
+ opts.timeoutMs = readPositiveIntegerParam(params, "timeoutMs");
21
+ return opts;
22
+ }
23
+
24
+ export function readTrimmedString(params: Record<string, unknown>, key: string): string {
25
+ const value = params[key];
26
+ return typeof value === "string" ? value.trim() : "";
27
+ }
28
+
29
+ export function readBoolean(
30
+ params: Record<string, unknown>,
31
+ key: string,
32
+ defaultValue = false,
33
+ ): boolean {
34
+ const value = params[key];
35
+ if (typeof value === "boolean") {
36
+ return value;
37
+ }
38
+ return defaultValue;
39
+ }
40
+
41
+ export function readClampedInt(params: {
42
+ input: Record<string, unknown>;
43
+ key: string;
44
+ defaultValue: number;
45
+ hardMin: number;
46
+ hardMax: number;
47
+ }): number {
48
+ const requested = readPositiveIntegerParam(params.input, params.key) ?? params.defaultValue;
49
+ return Math.max(params.hardMin, Math.min(requested, params.hardMax));
50
+ }
51
+
52
+ export function humanSize(bytes: number): string {
53
+ if (bytes < 1024) {
54
+ return `${bytes} B`;
55
+ }
56
+ if (bytes < 1024 * 1024) {
57
+ return `${(bytes / 1024).toFixed(1)} KB`;
58
+ }
59
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
60
+ }
@@ -0,0 +1,568 @@
1
+ // File Transfer tests cover policy plugin behavior.
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ // Mock the plugin-sdk runtime-config surface so we can drive the policy
7
+ // reader from the test without booting a gateway. mutateConfigFile is also
8
+ // mocked so persistAllowAlways tests can assert what would have been written
9
+ // without touching ~/.actagent/actagent.json.
10
+ const getRuntimeConfigMock = vi.fn();
11
+ const mutateConfigFileMock = vi.fn();
12
+
13
+ vi.mock("actagent/plugin-sdk/runtime-config-snapshot", () => ({
14
+ getRuntimeConfig: () => getRuntimeConfigMock(),
15
+ }));
16
+ vi.mock("actagent/plugin-sdk/config-mutation", () => ({
17
+ mutateConfigFile: (input: unknown) => mutateConfigFileMock(input),
18
+ }));
19
+
20
+ // Imported AFTER vi.mock so the mocked module is what policy.ts binds to.
21
+ const { evaluateFilePolicy, persistAllowAlways } = await import("./policy.js");
22
+
23
+ beforeEach(() => {
24
+ getRuntimeConfigMock.mockReset();
25
+ mutateConfigFileMock.mockReset();
26
+ });
27
+
28
+ afterEach(() => {
29
+ vi.restoreAllMocks();
30
+ });
31
+
32
+ afterAll(() => {
33
+ vi.doUnmock("actagent/plugin-sdk/runtime-config-snapshot");
34
+ vi.doUnmock("actagent/plugin-sdk/config-mutation");
35
+ vi.resetModules();
36
+ });
37
+
38
+ function withConfig(fileTransfer: Record<string, unknown> | undefined) {
39
+ if (fileTransfer === undefined) {
40
+ getRuntimeConfigMock.mockReturnValue({});
41
+ } else {
42
+ getRuntimeConfigMock.mockReturnValue({
43
+ plugins: {
44
+ entries: {
45
+ "file-transfer": {
46
+ config: { nodes: fileTransfer },
47
+ },
48
+ },
49
+ },
50
+ });
51
+ }
52
+ }
53
+
54
+ function expectResultFields(result: unknown, fields: Record<string, unknown>) {
55
+ if (typeof result !== "object" || result === null) {
56
+ throw new Error("policy result was not an object");
57
+ }
58
+ const record = result as Record<string, unknown>;
59
+ for (const [key, value] of Object.entries(fields)) {
60
+ expect(record[key]).toEqual(value);
61
+ }
62
+ }
63
+
64
+ describe("evaluateFilePolicy — default deny", () => {
65
+ it("returns NO_POLICY when no plugin config block is present", () => {
66
+ getRuntimeConfigMock.mockReturnValue({});
67
+ const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
68
+ expectResultFields(r, { ok: false, code: "NO_POLICY", askable: false });
69
+ });
70
+
71
+ it("returns NO_POLICY when plugin policy block is missing", () => {
72
+ getRuntimeConfigMock.mockReturnValue({ plugins: { entries: { "file-transfer": {} } } });
73
+ const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
74
+ expectResultFields(r, { ok: false, code: "NO_POLICY" });
75
+ });
76
+
77
+ it("returns NO_POLICY when no entry exists for the node and no '*' fallback", () => {
78
+ withConfig({ "other-node": { allowReadPaths: ["/tmp/**"] } });
79
+ const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
80
+ expectResultFields(r, { ok: false, code: "NO_POLICY" });
81
+ });
82
+
83
+ it("prefers the current runtime config over a stale passed plugin config", () => {
84
+ getRuntimeConfigMock.mockReturnValue({
85
+ plugins: {
86
+ entries: {
87
+ "file-transfer": {
88
+ config: {
89
+ nodes: {
90
+ n1: { allowReadPaths: ["/tmp/**"] },
91
+ },
92
+ },
93
+ },
94
+ },
95
+ },
96
+ });
97
+ const r = evaluateFilePolicy({
98
+ nodeId: "n1",
99
+ kind: "read",
100
+ path: "/tmp/x",
101
+ pluginConfig: {
102
+ nodes: {
103
+ n1: { allowReadPaths: ["/stale/**"] },
104
+ },
105
+ },
106
+ });
107
+ expectResultFields(r, { ok: true, reason: "matched-allow" });
108
+ });
109
+ });
110
+
111
+ describe("evaluateFilePolicy — '..' traversal short-circuit", () => {
112
+ it("rejects /allowed/../etc/passwd even when /allowed/** is allowed", () => {
113
+ withConfig({
114
+ n1: { allowReadPaths: ["/allowed/**"] },
115
+ });
116
+ const r = evaluateFilePolicy({
117
+ nodeId: "n1",
118
+ kind: "read",
119
+ path: "/allowed/../etc/passwd",
120
+ });
121
+ expectResultFields(r, { ok: false, code: "POLICY_DENIED", askable: false });
122
+ expect(r.ok ? "" : r.reason).toMatch(/\.\./);
123
+ });
124
+
125
+ it("rejects a path that ENDS in /..", () => {
126
+ withConfig({
127
+ n1: { allowReadPaths: ["/tmp/**"] },
128
+ });
129
+ const r = evaluateFilePolicy({
130
+ nodeId: "n1",
131
+ kind: "read",
132
+ path: "/tmp/foo/..",
133
+ });
134
+ expectResultFields(r, { ok: false, code: "POLICY_DENIED" });
135
+ });
136
+
137
+ it("rejects bare '..'", () => {
138
+ withConfig({
139
+ n1: { allowReadPaths: ["/**"] },
140
+ });
141
+ const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: ".." });
142
+ expectResultFields(r, { ok: false, code: "POLICY_DENIED" });
143
+ });
144
+ });
145
+
146
+ describe("evaluateFilePolicy — denyPaths always wins", () => {
147
+ it("denies even when allowReadPaths matches", () => {
148
+ withConfig({
149
+ n1: {
150
+ allowReadPaths: ["/tmp/**"],
151
+ denyPaths: ["**/.ssh/**"],
152
+ },
153
+ });
154
+ const r = evaluateFilePolicy({
155
+ nodeId: "n1",
156
+ kind: "read",
157
+ path: "/tmp/.ssh/id_rsa",
158
+ });
159
+ expectResultFields(r, { ok: false, code: "POLICY_DENIED", askable: false });
160
+ expect(r.ok ? "" : r.reason).toMatch(/deny/);
161
+ });
162
+
163
+ it("treats globstar slash as zero or more directories in denyPaths", () => {
164
+ withConfig({
165
+ n1: {
166
+ allowReadPaths: ["~/Downloads/**"],
167
+ denyPaths: ["~/Downloads/**/*.pem"],
168
+ },
169
+ });
170
+ const r = evaluateFilePolicy({
171
+ nodeId: "n1",
172
+ kind: "read",
173
+ path: path.join(os.homedir(), "Downloads", "key.pem"),
174
+ });
175
+ expectResultFields(r, { ok: false, code: "POLICY_DENIED", askable: false });
176
+ });
177
+
178
+ it("preserves minimatch brace semantics in denyPaths", () => {
179
+ withConfig({
180
+ n1: {
181
+ allowReadPaths: ["~/Downloads/**"],
182
+ denyPaths: ["~/Downloads/**/*.{pem,key}", "**/.{ssh,aws}/**"],
183
+ },
184
+ });
185
+ expectResultFields(
186
+ evaluateFilePolicy({
187
+ nodeId: "n1",
188
+ kind: "read",
189
+ path: path.join(os.homedir(), "Downloads", "api.key"),
190
+ }),
191
+ { ok: false, code: "POLICY_DENIED", askable: false },
192
+ );
193
+ expectResultFields(
194
+ evaluateFilePolicy({
195
+ nodeId: "n1",
196
+ kind: "read",
197
+ path: path.join(os.homedir(), "Downloads", ".aws", "credentials"),
198
+ }),
199
+ { ok: false, code: "POLICY_DENIED", askable: false },
200
+ );
201
+ });
202
+
203
+ it("denies even with ask=always (denyPaths is hard)", () => {
204
+ withConfig({
205
+ n1: {
206
+ ask: "always",
207
+ denyPaths: ["**/secrets/**"],
208
+ },
209
+ });
210
+ const r = evaluateFilePolicy({
211
+ nodeId: "n1",
212
+ kind: "read",
213
+ path: "/var/secrets/api.key",
214
+ });
215
+ expectResultFields(r, { ok: false, code: "POLICY_DENIED", askable: false });
216
+ });
217
+ });
218
+
219
+ describe("evaluateFilePolicy — allow matching", () => {
220
+ it("allows on matched-allow with ask=off (default)", () => {
221
+ withConfig({
222
+ n1: { allowReadPaths: ["/tmp/**"] },
223
+ });
224
+ expect(evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/foo/bar.png" })).toEqual({
225
+ ok: true,
226
+ reason: "matched-allow",
227
+ maxBytes: undefined,
228
+ followSymlinks: false,
229
+ });
230
+ });
231
+
232
+ it("propagates per-node maxBytes on matched-allow", () => {
233
+ withConfig({
234
+ n1: { allowReadPaths: ["/tmp/**"], maxBytes: 1024 },
235
+ });
236
+ const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
237
+ expectResultFields(r, { ok: true, maxBytes: 1024 });
238
+ });
239
+
240
+ it("uses kind=write to consult allowWritePaths, not allowReadPaths", () => {
241
+ withConfig({
242
+ n1: { allowReadPaths: ["/tmp/**"], allowWritePaths: ["/srv/**"] },
243
+ });
244
+ expectResultFields(evaluateFilePolicy({ nodeId: "n1", kind: "write", path: "/srv/out.txt" }), {
245
+ ok: true,
246
+ });
247
+ expectResultFields(evaluateFilePolicy({ nodeId: "n1", kind: "write", path: "/tmp/out.txt" }), {
248
+ ok: false,
249
+ code: "POLICY_DENIED",
250
+ });
251
+ });
252
+
253
+ it("propagates followSymlinks=false by default and =true when configured", () => {
254
+ withConfig({
255
+ n1: { allowReadPaths: ["/tmp/**"] },
256
+ });
257
+ expectResultFields(evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" }), {
258
+ ok: true,
259
+ followSymlinks: false,
260
+ });
261
+
262
+ withConfig({
263
+ n2: { allowReadPaths: ["/tmp/**"], followSymlinks: true },
264
+ });
265
+ expectResultFields(evaluateFilePolicy({ nodeId: "n2", kind: "read", path: "/tmp/x" }), {
266
+ ok: true,
267
+ followSymlinks: true,
268
+ });
269
+ });
270
+
271
+ it("expands tilde in patterns relative to homedir", () => {
272
+ const home = os.homedir();
273
+ withConfig({
274
+ n1: { allowReadPaths: ["~/Screenshots/**"] },
275
+ });
276
+ expectResultFields(
277
+ evaluateFilePolicy({
278
+ nodeId: "n1",
279
+ kind: "read",
280
+ path: path.join(home, "Screenshots", "shot.png"),
281
+ }),
282
+ { ok: true },
283
+ );
284
+ });
285
+
286
+ it("matches Windows node paths without gateway-local path semantics", () => {
287
+ withConfig({
288
+ n1: { allowReadPaths: ["C:/Users/me/**"] },
289
+ });
290
+ expectResultFields(
291
+ evaluateFilePolicy({
292
+ nodeId: "n1",
293
+ kind: "read",
294
+ path: "C:\\Users\\me\\file.txt",
295
+ }),
296
+ { ok: true },
297
+ );
298
+ });
299
+ });
300
+
301
+ describe("evaluateFilePolicy — ask modes", () => {
302
+ it("ask=on-miss returns askable POLICY_DENIED on miss", () => {
303
+ withConfig({
304
+ n1: { ask: "on-miss", allowReadPaths: ["/var/log/**"] },
305
+ });
306
+ const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
307
+ expectResultFields(r, {
308
+ ok: false,
309
+ code: "POLICY_DENIED",
310
+ askable: true,
311
+ askMode: "on-miss",
312
+ });
313
+ });
314
+
315
+ it("ask=on-miss miss preserves transfer caps for one-time approvals", () => {
316
+ withConfig({
317
+ n1: {
318
+ ask: "on-miss",
319
+ allowReadPaths: ["/var/log/**"],
320
+ maxBytes: 4096,
321
+ followSymlinks: true,
322
+ },
323
+ });
324
+ const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
325
+ expectResultFields(r, {
326
+ ok: false,
327
+ code: "POLICY_DENIED",
328
+ askable: true,
329
+ askMode: "on-miss",
330
+ maxBytes: 4096,
331
+ followSymlinks: true,
332
+ });
333
+ });
334
+
335
+ it("ask=on-miss still silent-allows on a match", () => {
336
+ withConfig({
337
+ n1: { ask: "on-miss", allowReadPaths: ["/tmp/**"] },
338
+ });
339
+ const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
340
+ expectResultFields(r, { ok: true, reason: "matched-allow" });
341
+ });
342
+
343
+ it("ask=always always returns ask-always (prompt on every call)", () => {
344
+ withConfig({
345
+ n1: { ask: "always", allowReadPaths: ["/tmp/**"] },
346
+ });
347
+ const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
348
+ expectResultFields(r, { ok: true, reason: "ask-always", askMode: "always" });
349
+ });
350
+
351
+ it("ask=off returns non-askable POLICY_DENIED on miss", () => {
352
+ withConfig({
353
+ n1: { ask: "off", allowReadPaths: ["/var/log/**"] },
354
+ });
355
+ const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
356
+ expectResultFields(r, { ok: false, code: "POLICY_DENIED", askable: false });
357
+ });
358
+
359
+ it("invalid ask values normalize to off", () => {
360
+ withConfig({
361
+ n1: { ask: "sometimes", allowReadPaths: ["/var/log/**"] },
362
+ });
363
+ const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
364
+ expectResultFields(r, { ok: false, askable: false });
365
+ });
366
+ });
367
+
368
+ describe("evaluateFilePolicy — node-id resolution", () => {
369
+ it("resolves by displayName when nodeId has no entry", () => {
370
+ withConfig({
371
+ "Lobster MacBook": { allowReadPaths: ["/tmp/**"] },
372
+ });
373
+ expectResultFields(
374
+ evaluateFilePolicy({
375
+ nodeId: "node-abc-123",
376
+ nodeDisplayName: "Lobster MacBook",
377
+ kind: "read",
378
+ path: "/tmp/x",
379
+ }),
380
+ { ok: true },
381
+ );
382
+ });
383
+
384
+ it("falls back to '*' wildcard when neither id nor displayName matches", () => {
385
+ withConfig({
386
+ "*": { allowReadPaths: ["/tmp/**"] },
387
+ });
388
+ expectResultFields(
389
+ evaluateFilePolicy({
390
+ nodeId: "n1",
391
+ nodeDisplayName: "anything",
392
+ kind: "read",
393
+ path: "/tmp/x",
394
+ }),
395
+ { ok: true },
396
+ );
397
+ });
398
+ });
399
+
400
+ describe("persistAllowAlways", () => {
401
+ it("appends path to allowReadPaths under the existing matching key", async () => {
402
+ let captured: Record<string, unknown> | null = null;
403
+ mutateConfigFileMock.mockImplementation(
404
+ async ({ mutate }: { mutate: (draft: Record<string, unknown>) => void }) => {
405
+ const draft: Record<string, unknown> = {
406
+ plugins: {
407
+ entries: {
408
+ "file-transfer": {
409
+ config: { nodes: { n1: { allowReadPaths: ["/tmp/**"] } } },
410
+ },
411
+ },
412
+ },
413
+ };
414
+ mutate(draft);
415
+ captured = draft;
416
+ },
417
+ );
418
+ await persistAllowAlways({ nodeId: "n1", kind: "read", path: "/srv/added.png" });
419
+
420
+ expect(mutateConfigFileMock).toHaveBeenCalledOnce();
421
+ // Drill back into the captured draft to assert the added path.
422
+ const root = captured as unknown as {
423
+ plugins: {
424
+ entries: {
425
+ "file-transfer": {
426
+ config: { nodes: Record<string, { allowReadPaths: string[] }> };
427
+ };
428
+ };
429
+ };
430
+ };
431
+ expect(root.plugins.entries["file-transfer"].config.nodes.n1.allowReadPaths).toContain(
432
+ "/srv/added.png",
433
+ );
434
+ });
435
+
436
+ it("creates a new node entry keyed by displayName when no entry exists", async () => {
437
+ let captured: Record<string, unknown> | null = null;
438
+ mutateConfigFileMock.mockImplementation(
439
+ async ({ mutate }: { mutate: (draft: Record<string, unknown>) => void }) => {
440
+ const draft: Record<string, unknown> = {};
441
+ mutate(draft);
442
+ captured = draft;
443
+ },
444
+ );
445
+
446
+ await persistAllowAlways({
447
+ nodeId: "n1",
448
+ nodeDisplayName: "Lobster",
449
+ kind: "write",
450
+ path: "/srv/out.txt",
451
+ });
452
+
453
+ const root = captured as unknown as {
454
+ plugins: {
455
+ entries: {
456
+ "file-transfer": {
457
+ config: { nodes: Record<string, { allowWritePaths: string[] }> };
458
+ };
459
+ };
460
+ };
461
+ };
462
+ expect(root.plugins.entries["file-transfer"].config.nodes["Lobster"].allowWritePaths).toContain(
463
+ "/srv/out.txt",
464
+ );
465
+ });
466
+
467
+ it("never persists under the '*' wildcard even when '*' is the matching key", async () => {
468
+ let captured: Record<string, unknown> | null = null;
469
+ mutateConfigFileMock.mockImplementation(
470
+ async ({ mutate }: { mutate: (draft: Record<string, unknown>) => void }) => {
471
+ const draft: Record<string, unknown> = {
472
+ plugins: {
473
+ entries: {
474
+ "file-transfer": {
475
+ config: { nodes: { "*": { allowReadPaths: ["/var/log/**"] } } },
476
+ },
477
+ },
478
+ },
479
+ };
480
+ mutate(draft);
481
+ captured = draft;
482
+ },
483
+ );
484
+
485
+ await persistAllowAlways({
486
+ nodeId: "n1",
487
+ nodeDisplayName: "Lobster",
488
+ kind: "read",
489
+ path: "/srv/added.png",
490
+ });
491
+
492
+ const root = captured as unknown as {
493
+ plugins: {
494
+ entries: {
495
+ "file-transfer": {
496
+ config: { nodes: Record<string, { allowReadPaths?: string[] }> };
497
+ };
498
+ };
499
+ };
500
+ };
501
+ // The "*" entry must not have been mutated.
502
+ expect(root.plugins.entries["file-transfer"].config.nodes["*"].allowReadPaths).toEqual([
503
+ "/var/log/**",
504
+ ]);
505
+ // A new entry keyed by displayName (not "*") must hold the new path.
506
+ expect(root.plugins.entries["file-transfer"].config.nodes["Lobster"].allowReadPaths).toEqual([
507
+ "/srv/added.png",
508
+ ]);
509
+ });
510
+
511
+ it("rejects unsafe keys (__proto__, prototype, constructor) that would mutate prototype chain", async () => {
512
+ mutateConfigFileMock.mockImplementation(
513
+ async ({ mutate }: { mutate: (draft: Record<string, unknown>) => void }) => {
514
+ const draft: Record<string, unknown> = {};
515
+ mutate(draft);
516
+ },
517
+ );
518
+
519
+ await expect(
520
+ persistAllowAlways({
521
+ nodeId: "n1",
522
+ nodeDisplayName: "__proto__",
523
+ kind: "read",
524
+ path: "/etc/passwd",
525
+ }),
526
+ ).rejects.toThrow(/unsafe key.*__proto__/);
527
+
528
+ await expect(
529
+ persistAllowAlways({
530
+ nodeId: "constructor",
531
+ kind: "read",
532
+ path: "/etc/passwd",
533
+ }),
534
+ ).rejects.toThrow(/unsafe key.*constructor/);
535
+ });
536
+
537
+ it("dedupes when path already present", async () => {
538
+ let captured: Record<string, unknown> | null = null;
539
+ mutateConfigFileMock.mockImplementation(
540
+ async ({ mutate }: { mutate: (draft: Record<string, unknown>) => void }) => {
541
+ const draft: Record<string, unknown> = {
542
+ plugins: {
543
+ entries: {
544
+ "file-transfer": {
545
+ config: { nodes: { n1: { allowReadPaths: ["/tmp/x"] } } },
546
+ },
547
+ },
548
+ },
549
+ };
550
+ mutate(draft);
551
+ captured = draft;
552
+ },
553
+ );
554
+ await persistAllowAlways({ nodeId: "n1", kind: "read", path: "/tmp/x" });
555
+
556
+ const root = captured as unknown as {
557
+ plugins: {
558
+ entries: {
559
+ "file-transfer": {
560
+ config: { nodes: Record<string, { allowReadPaths: string[] }> };
561
+ };
562
+ };
563
+ };
564
+ };
565
+ const list = root.plugins.entries["file-transfer"].config.nodes.n1.allowReadPaths;
566
+ expect(list.reduce((count, p) => count + (p === "/tmp/x" ? 1 : 0), 0)).toBe(1);
567
+ });
568
+ });