@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,383 @@
|
|
|
1
|
+
// Path policy for file-transfer node.invoke calls.
|
|
2
|
+
//
|
|
3
|
+
// Default behavior is DENY. The operator must explicitly opt in by adding
|
|
4
|
+
// a config block to ~/.actagent/actagent.json under
|
|
5
|
+
// `plugins.entries.file-transfer.config.nodes`. Without a matching block,
|
|
6
|
+
// every file operation is rejected before reaching the node.
|
|
7
|
+
//
|
|
8
|
+
// Schema (informal):
|
|
9
|
+
//
|
|
10
|
+
// "plugins": {
|
|
11
|
+
// "entries": {
|
|
12
|
+
// "file-transfer": {
|
|
13
|
+
// "config": {
|
|
14
|
+
// "nodes": {
|
|
15
|
+
// "<nodeId-or-displayName>": {
|
|
16
|
+
// "ask": "off" | "on-miss" | "always",
|
|
17
|
+
// "allowReadPaths": ["~/Screenshots/**", "/tmp/**"],
|
|
18
|
+
// "allowWritePaths": ["~/Downloads/**"],
|
|
19
|
+
// "denyPaths": ["**/.ssh/**", "**/.aws/**"],
|
|
20
|
+
// "maxBytes": 16777216,
|
|
21
|
+
// "followSymlinks": false
|
|
22
|
+
// },
|
|
23
|
+
// "*": { "ask": "on-miss" }
|
|
24
|
+
// }
|
|
25
|
+
// }
|
|
26
|
+
// }
|
|
27
|
+
// }
|
|
28
|
+
// }
|
|
29
|
+
//
|
|
30
|
+
// `ask` modes:
|
|
31
|
+
// off — silent: allow if matched, deny if not (today's default)
|
|
32
|
+
// on-miss — silent allow if matched; prompt operator if not matched
|
|
33
|
+
// always — prompt operator on every call (denyPaths still hard-deny)
|
|
34
|
+
//
|
|
35
|
+
// `denyPaths` always wins, even in `ask: always`.
|
|
36
|
+
// `allow-always` from the prompt appends the path back into allowReadPaths /
|
|
37
|
+
// allowWritePaths via mutateConfigFile.
|
|
38
|
+
//
|
|
39
|
+
// `followSymlinks` (default false): if false, the node-side handler
|
|
40
|
+
// realpaths the requested path (or its parent for new-file writes) BEFORE
|
|
41
|
+
// any I/O, and refuses with SYMLINK_REDIRECT if it differs from the
|
|
42
|
+
// requested path. This stops a symlink in user-controlled territory
|
|
43
|
+
// (e.g. ~/Downloads/evil → /etc) from redirecting an allowed-looking path
|
|
44
|
+
// to a disallowed canonical location. Set to true to opt back into the
|
|
45
|
+
// looser "follow + post-flight check" behavior, e.g. on macOS where
|
|
46
|
+
// /var → /private/var trips the check for /var/folders paths.
|
|
47
|
+
|
|
48
|
+
import os from "node:os";
|
|
49
|
+
import path from "node:path";
|
|
50
|
+
import { minimatch } from "minimatch";
|
|
51
|
+
import { mutateConfigFile } from "actagent/plugin-sdk/config-mutation";
|
|
52
|
+
import { getRuntimeConfig } from "actagent/plugin-sdk/runtime-config-snapshot";
|
|
53
|
+
|
|
54
|
+
export type FilePolicyKind = "read" | "write";
|
|
55
|
+
export type FilePolicyAskMode = "off" | "on-miss" | "always";
|
|
56
|
+
|
|
57
|
+
export type FilePolicyDecision =
|
|
58
|
+
| { ok: true; reason: "matched-allow"; maxBytes?: number; followSymlinks: boolean }
|
|
59
|
+
| {
|
|
60
|
+
ok: true;
|
|
61
|
+
reason: "ask-always";
|
|
62
|
+
askMode: FilePolicyAskMode;
|
|
63
|
+
maxBytes?: number;
|
|
64
|
+
followSymlinks: boolean;
|
|
65
|
+
}
|
|
66
|
+
| {
|
|
67
|
+
ok: false;
|
|
68
|
+
code: "NO_POLICY" | "POLICY_DENIED";
|
|
69
|
+
reason: string;
|
|
70
|
+
askable: boolean;
|
|
71
|
+
askMode?: FilePolicyAskMode;
|
|
72
|
+
maxBytes?: number;
|
|
73
|
+
followSymlinks?: boolean;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type NodeFilePolicyConfig = {
|
|
77
|
+
ask?: FilePolicyAskMode;
|
|
78
|
+
allowReadPaths?: string[];
|
|
79
|
+
allowWritePaths?: string[];
|
|
80
|
+
denyPaths?: string[];
|
|
81
|
+
maxBytes?: number;
|
|
82
|
+
followSymlinks?: boolean;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
type FilePolicyConfig = Record<string, NodeFilePolicyConfig>;
|
|
86
|
+
|
|
87
|
+
function asFilePolicyConfig(value: unknown): FilePolicyConfig | null {
|
|
88
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return value as FilePolicyConfig;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readFilePolicyConfigFromPluginConfig(pluginConfig: unknown): FilePolicyConfig | null {
|
|
95
|
+
if (!pluginConfig || typeof pluginConfig !== "object" || Array.isArray(pluginConfig)) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const nodes = (pluginConfig as { nodes?: unknown }).nodes;
|
|
99
|
+
return asFilePolicyConfig(nodes);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readPluginConfigFromRuntimeConfig(): Record<string, unknown> | null {
|
|
103
|
+
const cfg = getRuntimeConfig();
|
|
104
|
+
const plugins = (cfg as { plugins?: unknown }).plugins;
|
|
105
|
+
if (!plugins || typeof plugins !== "object") {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const entries = (plugins as { entries?: unknown }).entries;
|
|
109
|
+
if (!entries || typeof entries !== "object") {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const entry = (entries as Record<string, unknown>)["file-transfer"];
|
|
113
|
+
if (!entry || typeof entry !== "object") {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const pluginConfig = (entry as { config?: unknown }).config;
|
|
117
|
+
return pluginConfig && typeof pluginConfig === "object" && !Array.isArray(pluginConfig)
|
|
118
|
+
? (pluginConfig as Record<string, unknown>)
|
|
119
|
+
: null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readFilePolicyConfig(pluginConfig?: Record<string, unknown>): FilePolicyConfig | null {
|
|
123
|
+
return (
|
|
124
|
+
readFilePolicyConfigFromPluginConfig(readPluginConfigFromRuntimeConfig()) ??
|
|
125
|
+
readFilePolicyConfigFromPluginConfig(pluginConfig)
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function expandTilde(p: string): string {
|
|
130
|
+
if (p.startsWith("~/") || p === "~") {
|
|
131
|
+
return path.join(os.homedir(), p.slice(p === "~" ? 1 : 2));
|
|
132
|
+
}
|
|
133
|
+
return p;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeGlobs(patterns: string[] | undefined): string[] {
|
|
137
|
+
if (!Array.isArray(patterns)) {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
return patterns
|
|
141
|
+
.filter((p): p is string => typeof p === "string" && p.trim().length > 0)
|
|
142
|
+
.map((p) => expandTilde(p.trim()));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function matchesAny(target: string, patterns: string[]): boolean {
|
|
146
|
+
const normalizedTarget = target.replace(/\\/gu, "/");
|
|
147
|
+
for (const pattern of patterns) {
|
|
148
|
+
const normalizedPattern = pattern.replace(/\\/gu, "/");
|
|
149
|
+
if (
|
|
150
|
+
minimatch(target, pattern, { dot: true }) ||
|
|
151
|
+
minimatch(normalizedTarget, normalizedPattern, { dot: true })
|
|
152
|
+
) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resolveNodePolicy(
|
|
160
|
+
config: FilePolicyConfig,
|
|
161
|
+
nodeId: string,
|
|
162
|
+
nodeDisplayName?: string,
|
|
163
|
+
): { key: string; entry: NodeFilePolicyConfig } | null {
|
|
164
|
+
const candidates = [nodeId, nodeDisplayName].filter(
|
|
165
|
+
(k): k is string => typeof k === "string" && k.length > 0,
|
|
166
|
+
);
|
|
167
|
+
for (const key of candidates) {
|
|
168
|
+
if (config[key]) {
|
|
169
|
+
return { key, entry: config[key] };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (config["*"]) {
|
|
173
|
+
return { key: "*", entry: config["*"] };
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function normalizeAskMode(value: unknown): FilePolicyAskMode {
|
|
179
|
+
if (value === "on-miss" || value === "always" || value === "off") {
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
return "off";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Evaluate whether (nodeId, kind, path) is permitted.
|
|
187
|
+
*
|
|
188
|
+
* Resolution order:
|
|
189
|
+
* 1. No file-transfer config or no entry for this node → NO_POLICY (deny,
|
|
190
|
+
* not askable — operator hasn't opted in at all).
|
|
191
|
+
* 2. denyPaths matches → POLICY_DENIED, not askable (hard deny).
|
|
192
|
+
* 3. ask=always → ask-always (prompt every time).
|
|
193
|
+
* 4. allowPaths matches → matched-allow (silent allow).
|
|
194
|
+
* 5. ask=on-miss → POLICY_DENIED with askable=true.
|
|
195
|
+
* 6. ask=off (or unset) → POLICY_DENIED, not askable.
|
|
196
|
+
*/
|
|
197
|
+
/**
|
|
198
|
+
* Reject any path whose RAW string contains a ".." segment. Checking the
|
|
199
|
+
* raw string (not the normalized form) is the point — `posix.normalize`
|
|
200
|
+
* collapses "/allowed/../etc/passwd" to "/etc/passwd", which would defeat
|
|
201
|
+
* the check. We want to flag the literal traversal sequence the agent
|
|
202
|
+
* passed in, before any glob match runs.
|
|
203
|
+
*
|
|
204
|
+
* Without this, "/allowed/../etc/passwd" matches the glob "/allowed/**"
|
|
205
|
+
* pre-realpath, so the node fetches the bytes before the post-flight
|
|
206
|
+
* canonical-path check denies — too late, the bytes already crossed the
|
|
207
|
+
* node→gateway boundary.
|
|
208
|
+
*
|
|
209
|
+
* Treats backslash and forward slash as equivalent separators so a Windows
|
|
210
|
+
* node can't be hit with "C:\\allowed\\..\\Windows\\system.ini".
|
|
211
|
+
*/
|
|
212
|
+
function containsParentRefSegment(p: string): boolean {
|
|
213
|
+
const unified = p.replace(/\\/gu, "/");
|
|
214
|
+
return unified.split("/").includes("..");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function evaluateFilePolicy(input: {
|
|
218
|
+
nodeId: string;
|
|
219
|
+
nodeDisplayName?: string;
|
|
220
|
+
kind: FilePolicyKind;
|
|
221
|
+
path: string;
|
|
222
|
+
pluginConfig?: Record<string, unknown>;
|
|
223
|
+
}): FilePolicyDecision {
|
|
224
|
+
// Reject literal traversal sequences before consulting any allow/deny
|
|
225
|
+
// glob list. minimatch on the raw string can wrongly accept
|
|
226
|
+
// "/allowed/../etc/passwd" against "/allowed/**".
|
|
227
|
+
if (containsParentRefSegment(input.path)) {
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
code: "POLICY_DENIED",
|
|
231
|
+
reason: "path contains '..' segments; reject before glob match",
|
|
232
|
+
askable: false,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const config = readFilePolicyConfig(input.pluginConfig);
|
|
236
|
+
if (!config) {
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
code: "NO_POLICY",
|
|
240
|
+
reason:
|
|
241
|
+
"no plugins.entries.file-transfer.config.nodes config; file-transfer is deny-by-default until configured",
|
|
242
|
+
askable: false,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const resolved = resolveNodePolicy(config, input.nodeId, input.nodeDisplayName);
|
|
246
|
+
if (!resolved) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
code: "NO_POLICY",
|
|
250
|
+
reason: `no file-transfer policy entry for "${input.nodeDisplayName ?? input.nodeId}"; configure plugins.entries.file-transfer.config.nodes or "*"`,
|
|
251
|
+
askable: false,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const nodeConfig = resolved.entry;
|
|
255
|
+
const askMode = normalizeAskMode(nodeConfig.ask);
|
|
256
|
+
|
|
257
|
+
const maxBytes =
|
|
258
|
+
typeof nodeConfig.maxBytes === "number" && Number.isFinite(nodeConfig.maxBytes)
|
|
259
|
+
? Math.max(1, Math.floor(nodeConfig.maxBytes))
|
|
260
|
+
: undefined;
|
|
261
|
+
const followSymlinks = nodeConfig.followSymlinks === true;
|
|
262
|
+
|
|
263
|
+
// 1. Deny patterns always win.
|
|
264
|
+
const denyPatterns = normalizeGlobs(nodeConfig.denyPaths);
|
|
265
|
+
if (matchesAny(input.path, denyPatterns)) {
|
|
266
|
+
return {
|
|
267
|
+
ok: false,
|
|
268
|
+
code: "POLICY_DENIED",
|
|
269
|
+
reason: "path matches a denyPaths pattern",
|
|
270
|
+
askable: false,
|
|
271
|
+
askMode,
|
|
272
|
+
maxBytes,
|
|
273
|
+
followSymlinks,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 2. ask=always: prompt every time even if matched.
|
|
278
|
+
if (askMode === "always") {
|
|
279
|
+
return { ok: true, reason: "ask-always", askMode, maxBytes, followSymlinks };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 3. Match against allow list for this kind.
|
|
283
|
+
const allowPatterns =
|
|
284
|
+
input.kind === "read"
|
|
285
|
+
? normalizeGlobs(nodeConfig.allowReadPaths)
|
|
286
|
+
: normalizeGlobs(nodeConfig.allowWritePaths);
|
|
287
|
+
|
|
288
|
+
if (allowPatterns.length > 0 && matchesAny(input.path, allowPatterns)) {
|
|
289
|
+
return { ok: true, reason: "matched-allow", maxBytes, followSymlinks };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 4. No allow match. Either askable on miss or hard-deny.
|
|
293
|
+
if (askMode === "on-miss") {
|
|
294
|
+
return {
|
|
295
|
+
ok: false,
|
|
296
|
+
code: "POLICY_DENIED",
|
|
297
|
+
reason: `path does not match any allow${input.kind === "read" ? "Read" : "Write"}Paths pattern`,
|
|
298
|
+
askable: true,
|
|
299
|
+
askMode,
|
|
300
|
+
maxBytes,
|
|
301
|
+
followSymlinks,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
ok: false,
|
|
307
|
+
code: "POLICY_DENIED",
|
|
308
|
+
reason:
|
|
309
|
+
allowPatterns.length === 0
|
|
310
|
+
? `no allow${input.kind === "read" ? "Read" : "Write"}Paths configured`
|
|
311
|
+
: `path does not match any allow${input.kind === "read" ? "Read" : "Write"}Paths pattern`,
|
|
312
|
+
askable: false,
|
|
313
|
+
askMode,
|
|
314
|
+
maxBytes,
|
|
315
|
+
followSymlinks,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Persist an "allow-always" approval by appending the path to the
|
|
321
|
+
* relevant allowReadPaths / allowWritePaths list for the node. Uses
|
|
322
|
+
* mutateConfigFile so the change survives gateway restarts.
|
|
323
|
+
*
|
|
324
|
+
* Inserts under whichever key matched the policy (per-node entry, or
|
|
325
|
+
* the "*" wildcard if that's what was hit). If no entry exists yet,
|
|
326
|
+
* creates one keyed by nodeDisplayName ?? nodeId.
|
|
327
|
+
*/
|
|
328
|
+
/**
|
|
329
|
+
* Reject special object keys that would mutate the prototype chain when
|
|
330
|
+
* used as a property name (e.g. `__proto__` setter on a plain object).
|
|
331
|
+
* The nodeDisplayName comes from paired-node metadata which we don't
|
|
332
|
+
* fully control; refuse to persist policy under a key that could corrupt
|
|
333
|
+
* the plugin policy container's prototype.
|
|
334
|
+
*/
|
|
335
|
+
function assertSafeConfigKey(key: string): string {
|
|
336
|
+
if (key === "__proto__" || key === "prototype" || key === "constructor") {
|
|
337
|
+
throw new Error(`refusing to persist file-transfer policy under unsafe key: ${key}`);
|
|
338
|
+
}
|
|
339
|
+
return key;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function persistAllowAlways(input: {
|
|
343
|
+
nodeId: string;
|
|
344
|
+
nodeDisplayName?: string;
|
|
345
|
+
kind: FilePolicyKind;
|
|
346
|
+
path: string;
|
|
347
|
+
}): Promise<void> {
|
|
348
|
+
const field = input.kind === "read" ? "allowReadPaths" : "allowWritePaths";
|
|
349
|
+
await mutateConfigFile({
|
|
350
|
+
afterWrite: { mode: "none", reason: "file-transfer allow-always policy update" },
|
|
351
|
+
mutate: (draft) => {
|
|
352
|
+
// Plugin config is intentionally plugin-owned; the root ACTAgentConfig
|
|
353
|
+
// type only guarantees `Record<string, unknown>` here.
|
|
354
|
+
const root = draft as unknown as Record<string, unknown>;
|
|
355
|
+
const plugins = (root.plugins ??= {}) as Record<string, unknown>;
|
|
356
|
+
const entries = (plugins.entries ??= {}) as Record<string, unknown>;
|
|
357
|
+
const pluginEntry = (entries["file-transfer"] ??= {}) as Record<string, unknown>;
|
|
358
|
+
const pluginConfig = (pluginEntry.config ??= {}) as Record<string, unknown>;
|
|
359
|
+
const fileTransfer = (pluginConfig.nodes ??= {}) as Record<string, NodeFilePolicyConfig>;
|
|
360
|
+
|
|
361
|
+
// SECURITY: never persist allow-always under the "*" wildcard. An
|
|
362
|
+
// operator approving a path on node A must not silently grant the
|
|
363
|
+
// same path on every other node sharing the wildcard entry. Always
|
|
364
|
+
// write under the specific node's own entry, creating it if needed.
|
|
365
|
+
const candidates = [input.nodeId, input.nodeDisplayName].filter(
|
|
366
|
+
(k): k is string => typeof k === "string" && k.length > 0,
|
|
367
|
+
);
|
|
368
|
+
// Use hasOwnProperty so a node with displayName "constructor" doesn't
|
|
369
|
+
// accidentally hit Object.prototype.constructor and pretend to match.
|
|
370
|
+
let key = candidates.find((c) => Object.hasOwn(fileTransfer, c));
|
|
371
|
+
if (!key) {
|
|
372
|
+
key = assertSafeConfigKey(input.nodeDisplayName ?? input.nodeId);
|
|
373
|
+
fileTransfer[key] = {};
|
|
374
|
+
}
|
|
375
|
+
const entry = fileTransfer[key];
|
|
376
|
+
const list = Array.isArray(entry[field]) ? entry[field] : [];
|
|
377
|
+
if (!list.includes(input.path)) {
|
|
378
|
+
list.push(input.path);
|
|
379
|
+
}
|
|
380
|
+
entry[field] = list;
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// File Transfer plugin module implements descriptors behavior.
|
|
2
|
+
import { optionalPositiveIntegerSchema } from "actagent/plugin-sdk/channel-actions";
|
|
3
|
+
import type { AnyAgentTool } from "actagent/plugin-sdk/plugin-entry";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
|
|
6
|
+
type FileTransferToolDescriptor = Pick<
|
|
7
|
+
AnyAgentTool,
|
|
8
|
+
"label" | "name" | "description" | "parameters"
|
|
9
|
+
>;
|
|
10
|
+
|
|
11
|
+
// Stash fetched files in a non-TTL subdir so follow-up tool calls within
|
|
12
|
+
// the same turn can still reference them.
|
|
13
|
+
export const FILE_TRANSFER_SUBDIR = "file-transfer";
|
|
14
|
+
|
|
15
|
+
export const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
16
|
+
export const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
|
|
17
|
+
export const DIR_LIST_DEFAULT_MAX_ENTRIES = 200;
|
|
18
|
+
export const DIR_LIST_HARD_MAX_ENTRIES = 5000;
|
|
19
|
+
export const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
20
|
+
export const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
|
|
21
|
+
export const FILE_WRITE_HARD_MAX_BYTES = 16 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
export const FileFetchToolSchema = Type.Object({
|
|
24
|
+
node: Type.String({
|
|
25
|
+
description: "Node id, name, or IP. Resolves the same way as the nodes tool.",
|
|
26
|
+
}),
|
|
27
|
+
path: Type.String({
|
|
28
|
+
description: "Absolute path to the file on the node. Canonicalized server-side.",
|
|
29
|
+
}),
|
|
30
|
+
maxBytes: optionalPositiveIntegerSchema({
|
|
31
|
+
description: "Max bytes to fetch. Default 8 MB, hard ceiling 16 MB (single round-trip).",
|
|
32
|
+
}),
|
|
33
|
+
gatewayUrl: Type.Optional(Type.String()),
|
|
34
|
+
gatewayToken: Type.Optional(Type.String()),
|
|
35
|
+
timeoutMs: optionalPositiveIntegerSchema(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const FILE_FETCH_TOOL_DESCRIPTOR: FileTransferToolDescriptor = {
|
|
39
|
+
label: "File Fetch",
|
|
40
|
+
name: "file_fetch",
|
|
41
|
+
description:
|
|
42
|
+
"Retrieve a file from a paired node by absolute path. Returns image content blocks for image MIME types, inlines small text files (≤8 KB) as text content, and saves everything else under the gateway media store with a path you can pass to file_write or other tools. Use this for screenshots, photos, receipts, logs, source files. Pair with file_write to copy a file from one node to another (no exec/cp shell-out needed). Requires operator opt-in: gateway.nodes.allowCommands must include 'file.fetch' AND plugins.entries.file-transfer.config.nodes.<node>.allowReadPaths must match the path. Without policy configured, every call is denied.",
|
|
43
|
+
parameters: FileFetchToolSchema,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const DirListToolSchema = Type.Object({
|
|
47
|
+
node: Type.String({
|
|
48
|
+
description: "Node id, name, or IP. Resolves the same way as the nodes tool.",
|
|
49
|
+
}),
|
|
50
|
+
path: Type.String({
|
|
51
|
+
description: "Absolute path to the directory on the node. Canonicalized server-side.",
|
|
52
|
+
}),
|
|
53
|
+
pageToken: Type.Optional(
|
|
54
|
+
Type.String({
|
|
55
|
+
description:
|
|
56
|
+
"Pagination token from a previous dir_list call. Omit to start from the beginning.",
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
maxEntries: optionalPositiveIntegerSchema({
|
|
60
|
+
description: `Max entries per page. Default ${DIR_LIST_DEFAULT_MAX_ENTRIES}, hard ceiling ${DIR_LIST_HARD_MAX_ENTRIES}.`,
|
|
61
|
+
}),
|
|
62
|
+
gatewayUrl: Type.Optional(Type.String()),
|
|
63
|
+
gatewayToken: Type.Optional(Type.String()),
|
|
64
|
+
timeoutMs: optionalPositiveIntegerSchema(),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const DIR_LIST_TOOL_DESCRIPTOR: FileTransferToolDescriptor = {
|
|
68
|
+
label: "Directory List",
|
|
69
|
+
name: "dir_list",
|
|
70
|
+
description:
|
|
71
|
+
"Retrieve a structured directory listing from a paired node. Returns file and subdirectory metadata (name, path, size, mimeType, isDir, mtime) without transferring file content. Use this to discover what files exist before fetching them with file_fetch. Pagination is offset-based; pass nextPageToken from the previous result. Requires operator opt-in: gateway.nodes.allowCommands must include 'dir.list' AND plugins.entries.file-transfer.config.nodes.<node>.allowReadPaths must match the directory path. Without policy configured, every call is denied.",
|
|
72
|
+
parameters: DirListToolSchema,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const DirFetchToolSchema = Type.Object({
|
|
76
|
+
node: Type.String({
|
|
77
|
+
description: "Node id, name, or IP. Resolves the same way as the nodes tool.",
|
|
78
|
+
}),
|
|
79
|
+
path: Type.String({
|
|
80
|
+
description: "Absolute path to the directory on the node to fetch. Canonicalized server-side.",
|
|
81
|
+
}),
|
|
82
|
+
maxBytes: optionalPositiveIntegerSchema({
|
|
83
|
+
description:
|
|
84
|
+
"Max gzipped tarball bytes to fetch. Default 8 MB, hard ceiling 16 MB (single round-trip).",
|
|
85
|
+
}),
|
|
86
|
+
includeDotfiles: Type.Optional(
|
|
87
|
+
Type.Boolean({
|
|
88
|
+
description: "Reserved for v2; currently always includes dotfiles (v1 quirk in BSD tar).",
|
|
89
|
+
}),
|
|
90
|
+
),
|
|
91
|
+
gatewayUrl: Type.Optional(Type.String()),
|
|
92
|
+
gatewayToken: Type.Optional(Type.String()),
|
|
93
|
+
timeoutMs: optionalPositiveIntegerSchema(),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const DIR_FETCH_TOOL_DESCRIPTOR: FileTransferToolDescriptor = {
|
|
97
|
+
label: "Directory Fetch",
|
|
98
|
+
name: "dir_fetch",
|
|
99
|
+
description:
|
|
100
|
+
"Retrieve a directory tree from a paired node as a gzipped tarball, unpack it on the gateway, and return a manifest of saved paths. Use to pull source trees, asset folders, or log directories in a single round-trip. The unpacked files live on the GATEWAY (not your local machine); pass localPath into other tools or use file_fetch on individual entries to ship them elsewhere. Rejects trees larger than 16 MB compressed. Requires operator opt-in: gateway.nodes.allowCommands must include 'dir.fetch' AND plugins.entries.file-transfer.config.nodes.<node>.allowReadPaths must match the directory path.",
|
|
101
|
+
parameters: DirFetchToolSchema,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const FileWriteToolSchema = Type.Object({
|
|
105
|
+
node: Type.String({ description: "Node id or display name to write the file on." }),
|
|
106
|
+
path: Type.String({
|
|
107
|
+
description: "Absolute path on the node to write. Canonicalized server-side.",
|
|
108
|
+
}),
|
|
109
|
+
contentBase64: Type.Optional(
|
|
110
|
+
Type.String({
|
|
111
|
+
description: "Base64-encoded bytes to write. Maximum 16 MB after decode.",
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
sourceMediaId: Type.Optional(
|
|
115
|
+
Type.String({
|
|
116
|
+
description:
|
|
117
|
+
"Media id returned by file_fetch. Preferred for binary copies because bytes stay in the gateway media store.",
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
120
|
+
mimeType: Type.Optional(
|
|
121
|
+
Type.String({
|
|
122
|
+
description: "Content type hint. Not validated against the content.",
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
125
|
+
overwrite: Type.Optional(
|
|
126
|
+
Type.Boolean({
|
|
127
|
+
description: "Allow overwriting an existing file. Default false.",
|
|
128
|
+
default: false,
|
|
129
|
+
}),
|
|
130
|
+
),
|
|
131
|
+
createParents: Type.Optional(
|
|
132
|
+
Type.Boolean({
|
|
133
|
+
description: "Create missing parent directories (mkdir -p). Default false.",
|
|
134
|
+
default: false,
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
export const FILE_WRITE_TOOL_DESCRIPTOR: FileTransferToolDescriptor = {
|
|
140
|
+
label: "File Write",
|
|
141
|
+
name: "file_write",
|
|
142
|
+
description:
|
|
143
|
+
"Write file bytes to a paired node by absolute path. Atomic write (temp + rename). Refuses to overwrite by default — pass overwrite=true to replace. Refuses to write through symlink targets unless policy explicitly allows following symlinks. Pair with file_fetch by passing its mediaId as sourceMediaId for binary copy. Requires operator opt-in: gateway.nodes.allowCommands must include 'file.write' AND plugins.entries.file-transfer.config.nodes.<node>.allowWritePaths must match the destination path. Without policy configured, every call is denied.",
|
|
144
|
+
parameters: FileWriteToolSchema,
|
|
145
|
+
};
|