From 844f3d62e9cd4217fe75704bd6598dcf2444aeb6 Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Wed, 29 Apr 2026 06:13:00 +0000 Subject: [PATCH] feat(file-transfer): add bundled plugin for binary file ops on nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New extensions/file-transfer/ plugin exposing four agent tools (file_fetch, dir_list, dir_fetch, file_write) and four matching node-host commands (file.fetch, dir.list, dir.fetch, file.write). Lets agents read and write files on paired nodes by absolute path, bypassing the bash output cap (200KB) and the live tool-result text cap that would otherwise truncate base64 payloads. Public surface -------------- - file_fetch({ node, path, maxBytes? }) Image MIMEs return image content blocks; small text (<=8 KB) inlines as text content; everything else returns a saved-media-path text block. sha256-verified end-to-end. - dir_list({ node, path, pageToken?, maxEntries? }) Structured directory listing — name, path, size, mimeType, isDir, mtime. Paginated. No content transfer. - dir_fetch({ node, path, maxBytes?, includeDotfiles? }) Server-side tar -czf streamed back, unpacked into the gateway media store, returns a manifest of saved paths. Single round-trip. 60s wall-clock timeouts on tar create/unpack. tar -xzf without -P rejects absolute paths in archive entries. - file_write({ node, path, contentBase64, mimeType?, overwrite?, createParents? }) Atomic write (temp + rename). Refuses to overwrite by default. Refuses to write through symlinks (lstat check). Buffer-side sha256 (no read-back race). Pair with file_fetch to round-trip files between nodes — DO NOT use exec/cp for file copies. All four commands gated by: - dangerous-by-default node command policy (gateway.nodes.allowCommands opt-in) - per-node path policy (gateway.nodes.fileTransfer) - optional operator approval prompt (ask: off | on-miss | always) 16 MB raw byte ceiling per single-frame round-trip (25 MB WS frame with ~33% base64 overhead and JSON envelope). 8 MB defaults. Path policy and approvals ------------------------- Default behavior is DENY. The operator must explicitly opt in: { "gateway": { "nodes": { "fileTransfer": { "": { "ask": "off" | "on-miss" | "always", "allowReadPaths": ["~/Screenshots/**", "/tmp/**"], "allowWritePaths": ["~/Downloads/**"], "denyPaths": ["**/.ssh/**", "**/.aws/**"], "maxBytes": 16777216 }, "*": { "ask": "on-miss" } } } } } ask modes: off — silent: allow if matched, deny if not (default) on-miss — silent allow if matched; prompt on miss always — prompt every call (denyPaths still hard-deny) denyPaths always wins. allow-always from the prompt persists the exact path back into allowReadPaths/allowWritePaths via mutateConfigFile so subsequent matching calls go silent. Reuses existing primitives — no new gateway methods: plugin.approval.request / plugin.approval.waitDecision decision: allow-once | allow-always | deny Pre-flight against requested path AND post-flight against the canonicalPath returned by the node — closes symlink-escape attacks where the requested path matched policy but realpath resolves somewhere else. Audit log --------- JSONL at ~/.openclaw/audit/file-transfer.jsonl. Records every decision (allow/allowed-once/allowed-always/denied/error) with timestamp, op, nodeId, displayName, requestedPath, canonicalPath, decision, error code, sizeBytes, sha256, durationMs. Best-effort writes; never propagates failure. Plugin layout ------------- extensions/file-transfer/ index.ts definePluginEntry, nodeHostCommands openclaw.plugin.json contracts.tools registration package.json src/node-host/{file-fetch,dir-list,dir-fetch,file-write}.ts src/tools/{file-fetch,dir-list,dir-fetch,file-write}-tool.ts src/shared/ mime.ts single-source extension->MIME map + image/text sets errors.ts shared error code enum and helpers params.ts shared param-validation helpers + GatewayCallOptions policy.ts evaluateFilePolicy, persistAllowAlways approval.ts plugin.approval.request wrapper gatekeep.ts one-stop policy + approval + audit orchestrator audit.ts JSONL audit sink Core touch points ----------------- - src/infra/node-commands.ts: NODE_FILE_FETCH_COMMAND, NODE_DIR_LIST_COMMAND, NODE_DIR_FETCH_COMMAND, NODE_FILE_WRITE_COMMAND, NODE_FILE_COMMANDS array - src/gateway/node-command-policy.ts: all four added to DEFAULT_DANGEROUS_NODE_COMMANDS - src/security/audit-extra.sync.ts: audit detail mentions file ops - src/agents/tools/nodes-tool-media.ts: MEDIA_INVOKE_ACTIONS entry for file.fetch redirects raw nodes(action=invoke) callers to the dedicated file_fetch tool to prevent base64 context bloat - src/agents/tools/nodes-tool.ts: nodes tool description points to the dedicated file_fetch tool Known limitations / follow-ups ------------------------------ - No tests in this PR. For a security-sensitive surface this is a gap; will follow up with a test pass. - Direct CLI invocation (openclaw nodes invoke --command file.fetch) bypasses the plugin policy entirely. Plugin-side gating is the realistic threat model (agent on iMessage requesting paths it shouldn't), but for true defense-in-depth, policy belongs in the gateway-side node.invoke dispatch. Move-policy-to-core is a separate PR. - file_watch (long-lived filesystem event subscription) is not included; it needs a new node-protocol primitive for streaming event channels and was descoped from this PR. - dir_fetch includeDotfiles: true is the only supported mode; BSD tar exclude patterns reliably collapse dotfile filtering to an empty archive. Reliable filtering needs a `find ! -name ".*" | tar -T -` pipeline; deferred. - dir_fetch du -sk preflight is a heuristic (du * 4 vs maxBytes); the mid-stream byte cap is the actual safety net. --- extensions/file-transfer/index.ts | 65 ++++ extensions/file-transfer/openclaw.plugin.json | 17 + extensions/file-transfer/package.json | 17 + .../file-transfer/src/node-host/dir-fetch.ts | 263 +++++++++++++ .../file-transfer/src/node-host/dir-list.ts | 166 ++++++++ .../file-transfer/src/node-host/file-fetch.ts | 170 +++++++++ .../file-transfer/src/node-host/file-write.ts | 208 ++++++++++ .../file-transfer/src/shared/approval.ts | 89 +++++ extensions/file-transfer/src/shared/audit.ts | 84 ++++ extensions/file-transfer/src/shared/errors.ts | 68 ++++ .../file-transfer/src/shared/gatekeep.ts | 174 +++++++++ extensions/file-transfer/src/shared/mime.ts | 53 +++ extensions/file-transfer/src/shared/params.ts | 62 +++ extensions/file-transfer/src/shared/policy.ts | 269 +++++++++++++ .../file-transfer/src/tools/dir-fetch-tool.ts | 361 ++++++++++++++++++ .../file-transfer/src/tools/dir-list-tool.ts | 199 ++++++++++ .../src/tools/file-fetch-tool.ts | 242 ++++++++++++ .../src/tools/file-write-tool.ts | 227 +++++++++++ src/agents/tools/nodes-tool-media.ts | 1 + src/agents/tools/nodes-tool.ts | 2 +- src/gateway/node-command-policy.ts | 16 + src/infra/node-commands.ts | 11 + src/security/audit-extra.sync.ts | 2 +- 23 files changed, 2764 insertions(+), 2 deletions(-) create mode 100644 extensions/file-transfer/index.ts create mode 100644 extensions/file-transfer/openclaw.plugin.json create mode 100644 extensions/file-transfer/package.json create mode 100644 extensions/file-transfer/src/node-host/dir-fetch.ts create mode 100644 extensions/file-transfer/src/node-host/dir-list.ts create mode 100644 extensions/file-transfer/src/node-host/file-fetch.ts create mode 100644 extensions/file-transfer/src/node-host/file-write.ts create mode 100644 extensions/file-transfer/src/shared/approval.ts create mode 100644 extensions/file-transfer/src/shared/audit.ts create mode 100644 extensions/file-transfer/src/shared/errors.ts create mode 100644 extensions/file-transfer/src/shared/gatekeep.ts create mode 100644 extensions/file-transfer/src/shared/mime.ts create mode 100644 extensions/file-transfer/src/shared/params.ts create mode 100644 extensions/file-transfer/src/shared/policy.ts create mode 100644 extensions/file-transfer/src/tools/dir-fetch-tool.ts create mode 100644 extensions/file-transfer/src/tools/dir-list-tool.ts create mode 100644 extensions/file-transfer/src/tools/file-fetch-tool.ts create mode 100644 extensions/file-transfer/src/tools/file-write-tool.ts diff --git a/extensions/file-transfer/index.ts b/extensions/file-transfer/index.ts new file mode 100644 index 00000000000..22eb8d7bf4f --- /dev/null +++ b/extensions/file-transfer/index.ts @@ -0,0 +1,65 @@ +import { + definePluginEntry, + type AnyAgentTool, + type OpenClawPluginNodeHostCommand, +} from "openclaw/plugin-sdk/plugin-entry"; +import { handleDirFetch } from "./src/node-host/dir-fetch.js"; +import { handleDirList } from "./src/node-host/dir-list.js"; +import { handleFileFetch } from "./src/node-host/file-fetch.js"; +import { handleFileWrite } from "./src/node-host/file-write.js"; +import { createDirFetchTool } from "./src/tools/dir-fetch-tool.js"; +import { createDirListTool } from "./src/tools/dir-list-tool.js"; +import { createFileFetchTool } from "./src/tools/file-fetch-tool.js"; +import { createFileWriteTool } from "./src/tools/file-write-tool.js"; + +const fileTransferNodeHostCommands: OpenClawPluginNodeHostCommand[] = [ + { + command: "file.fetch", + cap: "file", + handle: async (paramsJSON) => { + const params = paramsJSON ? JSON.parse(paramsJSON) : {}; + const result = await handleFileFetch(params); + return JSON.stringify(result); + }, + }, + { + command: "dir.list", + cap: "file", + handle: async (paramsJSON) => { + const params = paramsJSON ? JSON.parse(paramsJSON) : {}; + const result = await handleDirList(params); + return JSON.stringify(result); + }, + }, + { + command: "dir.fetch", + cap: "file", + handle: async (paramsJSON) => { + const params = paramsJSON ? JSON.parse(paramsJSON) : {}; + const result = await handleDirFetch(params); + return JSON.stringify(result); + }, + }, + { + command: "file.write", + cap: "file", + handle: async (paramsJSON) => { + const params = paramsJSON ? JSON.parse(paramsJSON) : {}; + const result = await handleFileWrite(params); + return JSON.stringify(result); + }, + }, +]; + +export default definePluginEntry({ + id: "file-transfer", + name: "File Transfer", + description: "Fetch, list, write, and watch files on paired nodes via dedicated node commands.", + nodeHostCommands: fileTransferNodeHostCommands, + register(api) { + api.registerTool(createFileFetchTool() as AnyAgentTool); + api.registerTool(createDirListTool() as AnyAgentTool); + api.registerTool(createDirFetchTool() as AnyAgentTool); + api.registerTool(createFileWriteTool() as AnyAgentTool); + }, +}); diff --git a/extensions/file-transfer/openclaw.plugin.json b/extensions/file-transfer/openclaw.plugin.json new file mode 100644 index 00000000000..8bcfe328d63 --- /dev/null +++ b/extensions/file-transfer/openclaw.plugin.json @@ -0,0 +1,17 @@ +{ + "id": "file-transfer", + "activation": { + "onStartup": true + }, + "enabledByDefault": true, + "name": "File Transfer", + "description": "Fetch, list, write, and watch files on paired nodes via dedicated node commands. Bypasses bash stdout truncation by using base64 over node.invoke for binaries up to 16 MB.", + "contracts": { + "tools": ["file_fetch", "dir_list", "dir_fetch", "file_write"] + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/file-transfer/package.json b/extensions/file-transfer/package.json new file mode 100644 index 00000000000..cf315923cbb --- /dev/null +++ b/extensions/file-transfer/package.json @@ -0,0 +1,17 @@ +{ + "name": "@openclaw/file-transfer", + "version": "2026.4.27", + "description": "OpenClaw file transfer plugin (file_fetch, dir_list, dir_fetch, file_write, file_watch)", + "type": "module", + "devDependencies": { + "@openclaw/plugin-sdk": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "bundle": { + "stageRuntimeDependencies": false + } + } +} diff --git a/extensions/file-transfer/src/node-host/dir-fetch.ts b/extensions/file-transfer/src/node-host/dir-fetch.ts new file mode 100644 index 00000000000..0fffc7a2e2b --- /dev/null +++ b/extensions/file-transfer/src/node-host/dir-fetch.ts @@ -0,0 +1,263 @@ +import { spawn, spawnSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024; +export const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024; + +export type DirFetchParams = { + path?: unknown; + maxBytes?: unknown; + includeDotfiles?: unknown; +}; + +export type DirFetchOk = { + ok: true; + path: string; + tarBase64: string; + tarBytes: number; + sha256: string; + fileCount: number; +}; + +export type DirFetchErrCode = + | "INVALID_PATH" + | "NOT_FOUND" + | "IS_FILE" + | "TREE_TOO_LARGE" + | "READ_ERROR"; + +export type DirFetchErr = { + ok: false; + code: DirFetchErrCode; + message: string; + canonicalPath?: string; +}; + +export type DirFetchResult = DirFetchOk | DirFetchErr; + +function clampMaxBytes(input: unknown): number { + if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) { + return DIR_FETCH_DEFAULT_MAX_BYTES; + } + return Math.min(Math.floor(input), DIR_FETCH_HARD_MAX_BYTES); +} + +function classifyFsError(err: unknown): DirFetchErrCode { + const code = (err as { code?: string } | null)?.code; + if (code === "ENOENT") { + return "NOT_FOUND"; + } + return "READ_ERROR"; +} + +async function preflightDu(dirPath: string, maxBytes: number): Promise { + // du -sk gives size in 1KB blocks (512-byte blocks on macOS with -k) + // We use maxBytes * 4 as the rough heuristic ceiling (generous, gzip compresses) + const heuristicKb = Math.ceil((maxBytes * 4) / 1024); + return new Promise((resolve) => { + const du = spawn("du", ["-sk", dirPath], { stdio: ["ignore", "pipe", "ignore"] }); + let output = ""; + du.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + du.on("close", (code) => { + if (code !== 0) { + // du failed; be permissive and let tar catch the overflow + resolve(true); + return; + } + const match = /^(\d+)/.exec(output.trim()); + if (!match) { + resolve(true); + return; + } + const sizeKb = parseInt(match[1], 10); + resolve(sizeKb <= heuristicKb); + }); + du.on("error", () => { + // du not available; skip preflight + resolve(true); + }); + }); +} + +function countTarEntries(tarBuffer: Buffer): number { + const result = spawnSync("tar", ["-tzf", "-"], { + input: tarBuffer, + maxBuffer: 32 * 1024 * 1024, + timeout: 10000, + }); + if (result.status !== 0 || !result.stdout) { + return 0; + } + const lines = (result.stdout as Buffer) + .toString("utf-8") + .split("\n") + .filter((l) => l.trim().length > 0 && l !== "./"); + return lines.length; +} + +export async function handleDirFetch(params: DirFetchParams): Promise { + const requestedPath = params.path; + if (typeof requestedPath !== "string" || requestedPath.length === 0) { + return { ok: false, code: "INVALID_PATH", message: "path required" }; + } + if (requestedPath.includes("\0")) { + return { ok: false, code: "INVALID_PATH", message: "path contains NUL byte" }; + } + if (!path.isAbsolute(requestedPath)) { + return { ok: false, code: "INVALID_PATH", message: "path must be absolute" }; + } + + const maxBytes = clampMaxBytes(params.maxBytes); + const includeDotfiles = params.includeDotfiles === true; + + let canonical: string; + try { + canonical = await fs.realpath(requestedPath); + } catch (err) { + const code = classifyFsError(err); + return { + ok: false, + code, + message: code === "NOT_FOUND" ? "directory not found" : `realpath failed: ${String(err)}`, + }; + } + + let stats: Awaited>; + try { + stats = await fs.stat(canonical); + } catch (err) { + const code = classifyFsError(err); + return { ok: false, code, message: `stat failed: ${String(err)}`, canonicalPath: canonical }; + } + + if (!stats.isDirectory()) { + return { + ok: false, + code: "IS_FILE", + message: "path is not a directory", + canonicalPath: canonical, + }; + } + + // Preflight size check using du + const withinBudget = await preflightDu(canonical, maxBytes); + if (!withinBudget) { + return { + ok: false, + code: "TREE_TOO_LARGE", + message: `directory tree exceeds estimated size limit (${maxBytes} bytes raw)`, + canonicalPath: canonical, + }; + } + + // Build tar args. Shell out to /usr/bin/tar for portability. + // -cz: create + gzip + // -C : change to directory so paths in archive are relative + // .: include everything from that directory + // v1: includeDotfiles is accepted in the API but not enforced. BSD tar's + // --exclude pattern matching is unreliable for dotfiles (every plausible + // pattern except "*/.*" collapses the archive on macOS). Reliable filtering + // requires a `find ! -name '.*' | tar -T -` pipeline; deferred to v2. + // For now we always archive everything in the directory. + void includeDotfiles; + const tarArgs: string[] = ["-czf", "-", "-C", canonical, "."]; + + // Capture tar output with a hard byte cap and a wall-clock timeout. + // SIGTERM if the byte cap is exceeded; SIGKILL if the timeout fires + // (covers tar hanging on a slow filesystem or symlink loop). + const TAR_HARD_TIMEOUT_MS = 60_000; + const tarBuffer = await new Promise((resolve) => { + const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar"; + const child = spawn(tarBin, tarArgs, { + stdio: ["ignore", "pipe", "pipe"], + }); + + const chunks: Buffer[] = []; + let totalBytes = 0; + let aborted = false; + + const watchdog = setTimeout(() => { + if (aborted) return; + aborted = true; + try { + child.kill("SIGKILL"); + } catch { + /* already gone */ + } + resolve("TIMEOUT"); + }, TAR_HARD_TIMEOUT_MS); + + child.stdout.on("data", (chunk: Buffer) => { + if (aborted) return; + totalBytes += chunk.byteLength; + if (totalBytes > maxBytes) { + aborted = true; + clearTimeout(watchdog); + child.kill("SIGTERM"); + resolve("TOO_LARGE"); + return; + } + chunks.push(chunk); + }); + + child.on("close", (code) => { + clearTimeout(watchdog); + if (aborted) return; + if (code !== 0) { + resolve("ERROR"); + return; + } + resolve(Buffer.concat(chunks)); + }); + + child.on("error", () => { + clearTimeout(watchdog); + if (!aborted) { + resolve("ERROR"); + } + }); + }); + + if (tarBuffer === "TOO_LARGE") { + return { + ok: false, + code: "TREE_TOO_LARGE", + message: `tarball exceeded ${maxBytes} byte limit mid-stream`, + canonicalPath: canonical, + }; + } + if (tarBuffer === "TIMEOUT") { + return { + ok: false, + code: "READ_ERROR", + message: "tar command exceeded 60s wall-clock timeout (slow filesystem or symlink loop?)", + canonicalPath: canonical, + }; + } + if (tarBuffer === "ERROR") { + return { + ok: false, + code: "READ_ERROR", + message: "tar command failed", + canonicalPath: canonical, + }; + } + + const sha256 = crypto.createHash("sha256").update(tarBuffer).digest("hex"); + const tarBase64 = tarBuffer.toString("base64"); + const tarBytes = tarBuffer.byteLength; + const fileCount = countTarEntries(tarBuffer); + + return { + ok: true, + path: canonical, + tarBase64, + tarBytes, + sha256, + fileCount, + }; +} diff --git a/extensions/file-transfer/src/node-host/dir-list.ts b/extensions/file-transfer/src/node-host/dir-list.ts new file mode 100644 index 00000000000..218492ab7c6 --- /dev/null +++ b/extensions/file-transfer/src/node-host/dir-list.ts @@ -0,0 +1,166 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { mimeFromExtension } from "../shared/mime.js"; + +export const DIR_LIST_DEFAULT_MAX_ENTRIES = 200; +export const DIR_LIST_HARD_MAX_ENTRIES = 5000; + +export type DirListParams = { + path?: unknown; + pageToken?: unknown; + maxEntries?: unknown; +}; + +export type DirListEntry = { + name: string; + path: string; + size: number; + mimeType: string; + isDir: boolean; + mtime: number; +}; + +export type DirListOk = { + ok: true; + path: string; + entries: DirListEntry[]; + nextPageToken?: string; + truncated: boolean; +}; + +export type DirListErrCode = + | "INVALID_PATH" + | "NOT_FOUND" + | "PERMISSION_DENIED" + | "IS_FILE" + | "READ_ERROR"; + +export type DirListErr = { + ok: false; + code: DirListErrCode; + message: string; + canonicalPath?: string; +}; + +export type DirListResult = DirListOk | DirListErr; + +function clampMaxEntries(input: unknown): number { + if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) { + return DIR_LIST_DEFAULT_MAX_ENTRIES; + } + return Math.min(Math.floor(input), DIR_LIST_HARD_MAX_ENTRIES); +} + +function classifyFsError(err: unknown): DirListErrCode { + const code = (err as { code?: string } | null)?.code; + if (code === "ENOENT") { + return "NOT_FOUND"; + } + if (code === "EACCES" || code === "EPERM") { + return "PERMISSION_DENIED"; + } + return "READ_ERROR"; +} + +export async function handleDirList(params: DirListParams): Promise { + const requestedPath = params.path; + if (typeof requestedPath !== "string" || requestedPath.length === 0) { + return { ok: false, code: "INVALID_PATH", message: "path required" }; + } + if (requestedPath.includes("\0")) { + return { ok: false, code: "INVALID_PATH", message: "path contains NUL byte" }; + } + if (!path.isAbsolute(requestedPath)) { + return { ok: false, code: "INVALID_PATH", message: "path must be absolute" }; + } + + const maxEntries = clampMaxEntries(params.maxEntries); + const offset = + typeof params.pageToken === "string" && params.pageToken.length > 0 + ? Math.max(0, parseInt(params.pageToken, 10) || 0) + : 0; + + let canonical: string; + try { + canonical = await fs.realpath(requestedPath); + } catch (err) { + const code = classifyFsError(err); + return { + ok: false, + code, + message: code === "NOT_FOUND" ? "path not found" : `realpath failed: ${String(err)}`, + }; + } + + let stats: Awaited>; + try { + stats = await fs.stat(canonical); + } catch (err) { + const code = classifyFsError(err); + return { ok: false, code, message: `stat failed: ${String(err)}`, canonicalPath: canonical }; + } + + if (!stats.isDirectory()) { + return { + ok: false, + code: "IS_FILE", + message: "path is not a directory", + canonicalPath: canonical, + }; + } + + let names: string[]; + try { + names = await fs.readdir(canonical, { encoding: "utf8" }); + } catch (err) { + const code = classifyFsError(err); + return { + ok: false, + code, + message: `readdir failed: ${String(err)}`, + canonicalPath: canonical, + }; + } + + // Sort by name for stable pagination + names.sort((a, b) => a.localeCompare(b)); + + const total = names.length; + const page = names.slice(offset, offset + maxEntries); + const truncated = offset + maxEntries < total; + const nextPageToken = truncated ? String(offset + maxEntries) : undefined; + + const entries: DirListEntry[] = []; + for (const name of page) { + const entryPath = path.join(canonical, name); + + let isDir = false; + let size = 0; + let mtime = 0; + try { + const s = await fs.stat(entryPath); + isDir = s.isDirectory(); + size = isDir ? 0 : s.size; + mtime = s.mtimeMs; + } catch { + // stat may fail for broken symlinks; keep zeros and treat as file + } + + entries.push({ + name, + path: entryPath, + size, + mimeType: isDir ? "inode/directory" : mimeFromExtension(name), + isDir, + mtime, + }); + } + + return { + ok: true, + path: canonical, + entries, + nextPageToken, + truncated, + }; +} diff --git a/extensions/file-transfer/src/node-host/file-fetch.ts b/extensions/file-transfer/src/node-host/file-fetch.ts new file mode 100644 index 00000000000..f876f962f1b --- /dev/null +++ b/extensions/file-transfer/src/node-host/file-fetch.ts @@ -0,0 +1,170 @@ +import { spawnSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { EXTENSION_MIME } from "../shared/mime.js"; + +export const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024; +export const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024; + +export type FileFetchParams = { + path?: unknown; + maxBytes?: unknown; +}; + +export type FileFetchOk = { + ok: true; + path: string; + size: number; + mimeType: string; + base64: string; + sha256: string; +}; + +export type FileFetchErrCode = + | "INVALID_PATH" + | "NOT_FOUND" + | "PERMISSION_DENIED" + | "IS_DIRECTORY" + | "FILE_TOO_LARGE" + | "PATH_TRAVERSAL" + | "READ_ERROR"; + +export type FileFetchErr = { + ok: false; + code: FileFetchErrCode; + message: string; + canonicalPath?: string; +}; + +export type FileFetchResult = FileFetchOk | FileFetchErr; + +function detectMimeType(filePath: string): string { + if (process.platform !== "win32") { + try { + const result = spawnSync("file", ["-b", "--mime-type", filePath], { + encoding: "utf-8", + timeout: 2000, + }); + const stdout = result.stdout?.trim(); + if (result.status === 0 && stdout) { + return stdout; + } + } catch { + // fall through to extension fallback + } + } + const ext = path.extname(filePath).toLowerCase(); + return EXTENSION_MIME[ext] ?? "application/octet-stream"; +} + +function clampMaxBytes(input: unknown): number { + if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) { + return FILE_FETCH_DEFAULT_MAX_BYTES; + } + return Math.min(Math.floor(input), FILE_FETCH_HARD_MAX_BYTES); +} + +function classifyFsError(err: unknown): FileFetchErrCode { + const code = (err as { code?: string } | null)?.code; + if (code === "ENOENT") { + return "NOT_FOUND"; + } + if (code === "EACCES" || code === "EPERM") { + return "PERMISSION_DENIED"; + } + if (code === "EISDIR") { + return "IS_DIRECTORY"; + } + return "READ_ERROR"; +} + +export async function handleFileFetch(params: FileFetchParams): Promise { + const requestedPath = params.path; + if (typeof requestedPath !== "string" || requestedPath.length === 0) { + return { ok: false, code: "INVALID_PATH", message: "path required" }; + } + if (requestedPath.includes("\0")) { + return { ok: false, code: "INVALID_PATH", message: "path contains NUL byte" }; + } + if (!path.isAbsolute(requestedPath)) { + return { ok: false, code: "INVALID_PATH", message: "path must be absolute" }; + } + + const maxBytes = clampMaxBytes(params.maxBytes); + + let canonical: string; + try { + canonical = await fs.realpath(requestedPath); + } catch (err) { + const code = classifyFsError(err); + return { + ok: false, + code, + message: code === "NOT_FOUND" ? "file not found" : `realpath failed: ${String(err)}`, + }; + } + + let stats: Awaited>; + try { + stats = await fs.stat(canonical); + } catch (err) { + const code = classifyFsError(err); + return { ok: false, code, message: `stat failed: ${String(err)}`, canonicalPath: canonical }; + } + + if (stats.isDirectory()) { + return { + ok: false, + code: "IS_DIRECTORY", + message: "path is a directory", + canonicalPath: canonical, + }; + } + if (!stats.isFile()) { + return { + ok: false, + code: "READ_ERROR", + message: "path is not a regular file", + canonicalPath: canonical, + }; + } + if (stats.size > maxBytes) { + return { + ok: false, + code: "FILE_TOO_LARGE", + message: `file size ${stats.size} exceeds limit ${maxBytes}`, + canonicalPath: canonical, + }; + } + + let buffer: Buffer; + try { + buffer = await fs.readFile(canonical); + } catch (err) { + const code = classifyFsError(err); + return { ok: false, code, message: `read failed: ${String(err)}`, canonicalPath: canonical }; + } + + if (buffer.byteLength > maxBytes) { + return { + ok: false, + code: "FILE_TOO_LARGE", + message: `read ${buffer.byteLength} bytes exceeds limit ${maxBytes}`, + canonicalPath: canonical, + }; + } + + const sha256 = crypto.createHash("sha256").update(buffer).digest("hex"); + const base64 = buffer.toString("base64"); + const mimeType = detectMimeType(canonical); + + return { + ok: true, + path: canonical, + size: buffer.byteLength, + mimeType, + base64, + sha256, + }; +} diff --git a/extensions/file-transfer/src/node-host/file-write.ts b/extensions/file-transfer/src/node-host/file-write.ts new file mode 100644 index 00000000000..6a3654a2426 --- /dev/null +++ b/extensions/file-transfer/src/node-host/file-write.ts @@ -0,0 +1,208 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const MAX_CONTENT_BYTES = 16 * 1024 * 1024; // 16 MB + +type FileWriteParams = { + path: string; + contentBase64: string; + overwrite: boolean; + createParents: boolean; + expectedSha256?: string; +}; + +type FileWriteSuccess = { + ok: true; + path: string; + size: number; + sha256: string; + overwritten: boolean; +}; + +type FileWriteError = { + ok: false; + code: string; + message: string; + canonicalPath?: string; +}; + +type FileWriteResult = FileWriteSuccess | FileWriteError; + +function sha256Hex(buf: Buffer): string { + return crypto.createHash("sha256").update(buf).digest("hex"); +} + +function err(code: string, message: string, canonicalPath?: string): FileWriteError { + return { ok: false, code, message, ...(canonicalPath ? { canonicalPath } : {}) }; +} + +export async function handleFileWrite( + params: Partial & Record, +): Promise { + const rawPath = typeof params?.path === "string" ? params.path : ""; + const contentBase64 = typeof params?.contentBase64 === "string" ? params.contentBase64 : ""; + const overwrite = params?.overwrite === true; + const createParents = params?.createParents === true; + const expectedSha256 = + typeof params?.expectedSha256 === "string" ? params.expectedSha256 : undefined; + + // 1. Validate path: must be absolute, non-empty, no NUL byte + if (!rawPath) { + return err("INVALID_PATH", "path is required"); + } + if (rawPath.includes("\0")) { + return err("INVALID_PATH", "path must not contain NUL bytes"); + } + if (!path.isAbsolute(rawPath)) { + return err("INVALID_PATH", "path must be absolute"); + } + + // 2. Decode base64 → Buffer + let buf: Buffer; + try { + buf = Buffer.from(contentBase64, "base64"); + // Verify round-trip to catch invalid base64 + if ( + buf.toString("base64") !== contentBase64 && + Buffer.from(contentBase64, "base64url").toString("base64url") !== contentBase64 + ) { + // Tolerate standard base64 with or without padding; just use what we decoded. + } + } catch { + return err("INVALID_BASE64", "contentBase64 is not valid base64"); + } + + if (buf.length > MAX_CONTENT_BYTES) { + return err( + "FILE_TOO_LARGE", + `decoded content is ${buf.length} bytes; maximum is ${MAX_CONTENT_BYTES} bytes (16 MB)`, + ); + } + + // 3. Resolve parent dir + const targetPath = path.normalize(rawPath); + const parentDir = path.dirname(targetPath); + + let parentExists = false; + try { + await fs.access(parentDir); + parentExists = true; + } catch { + parentExists = false; + } + + if (!parentExists) { + if (!createParents) { + return err("PARENT_NOT_FOUND", `parent directory does not exist: ${parentDir}`); + } + try { + await fs.mkdir(parentDir, { recursive: true }); + } catch (mkdirErr) { + const message = mkdirErr instanceof Error ? mkdirErr.message : String(mkdirErr); + return err("WRITE_ERROR", `failed to create parent directories: ${message}`); + } + } + + // 4. Refuse to write through symlinks (lstat sees the link itself, not + // its target). A path that's a symlink could escape the operator's + // intended path policy — e.g., an allowed dir could contain a + // symlink pointing at /etc/hosts. + // Otherwise determine overwritten status and reject directories. + let overwritten = false; + try { + const existingLStat = await fs.lstat(targetPath); + if (existingLStat.isSymbolicLink()) { + return err( + "SYMLINK_TARGET_DENIED", + `path is a symlink; refusing to write through it: ${targetPath}`, + ); + } + if (existingLStat.isDirectory()) { + return err("IS_DIRECTORY", `path resolves to a directory: ${targetPath}`); + } + if (!overwrite) { + return err( + "EXISTS_NO_OVERWRITE", + `file already exists and overwrite is false: ${targetPath}`, + ); + } + overwritten = true; + } catch (statErr: unknown) { + // ENOENT is fine — file does not exist yet + if ((statErr as NodeJS.ErrnoException).code !== "ENOENT") { + const message = statErr instanceof Error ? statErr.message : String(statErr); + if (message.toLowerCase().includes("permission")) { + return err("PERMISSION_DENIED", `permission denied: ${targetPath}`); + } + return err("WRITE_ERROR", `unexpected stat error: ${message}`); + } + } + + // 5. Atomic write: write to tmp, then rename + const tmpSuffix = crypto.randomBytes(8).toString("hex"); + const tmpPath = `${targetPath}.${tmpSuffix}.tmp`; + + try { + await fs.writeFile(tmpPath, buf); + } catch (writeErr) { + const message = writeErr instanceof Error ? writeErr.message : String(writeErr); + // Clean up tmp if possible + await fs.unlink(tmpPath).catch(() => {}); + if (message.toLowerCase().includes("permission") || message.toLowerCase().includes("access")) { + return err("PERMISSION_DENIED", `permission denied writing to: ${parentDir}`); + } + return err("WRITE_ERROR", `failed to write file: ${message}`); + } + + try { + await fs.rename(tmpPath, targetPath); + } catch (renameErr) { + const message = renameErr instanceof Error ? renameErr.message : String(renameErr); + await fs.unlink(tmpPath).catch(() => {}); + if (message.toLowerCase().includes("permission") || message.toLowerCase().includes("access")) { + return err("PERMISSION_DENIED", `permission denied renaming to: ${targetPath}`); + } + return err("WRITE_ERROR", `failed to rename tmp to target: ${message}`); + } + + // 6. Compute sha256 from the buffer we just wrote, NOT from a re-read. + // A re-read would race against any concurrent process that overwrote + // the file between rename and read — we'd compute the wrong hash and + // either approve a corrupted file or unlink someone else's data on + // a false mismatch. Buffer-side sha256 is what the caller actually + // asked us to write. + const computedSha256 = sha256Hex(buf); + + // 7. Integrity check against the optional expectedSha256 — this is now + // a redundancy check (if expectedSha256 differs from what we hashed + // of the input buffer, the caller mis-encoded contentBase64). The + // file already exists at this point; on mismatch we unlink to avoid + // leaving a file the caller didn't intend. + if (expectedSha256 && expectedSha256.toLowerCase() !== computedSha256) { + await fs.unlink(targetPath).catch(() => {}); + return err( + "INTEGRITY_FAILURE", + `sha256 mismatch: expected ${expectedSha256.toLowerCase()}, got ${computedSha256}`, + targetPath, + ); + } + const writtenBuf = buf; + + // 8. Re-realpath to resolve any symlinks in the final path + let canonicalPath = targetPath; + try { + canonicalPath = await fs.realpath(targetPath); + } catch { + // Best effort; use normalized path as fallback + canonicalPath = targetPath; + } + + return { + ok: true, + path: canonicalPath, + size: writtenBuf.length, + sha256: computedSha256, + overwritten, + }; +} diff --git a/extensions/file-transfer/src/shared/approval.ts b/extensions/file-transfer/src/shared/approval.ts new file mode 100644 index 00000000000..9a60667842e --- /dev/null +++ b/extensions/file-transfer/src/shared/approval.ts @@ -0,0 +1,89 @@ +// Approval-flow wrappers around the generic plugin.approval.request / +// plugin.approval.waitDecision gateway methods. +// +// Used by the file-transfer policy gate when ask=on-miss/always: the +// operator sees a modal in their macOS/iOS app with allow-once / +// allow-always / deny. + +import { callGatewayTool } from "openclaw/plugin-sdk/agent-harness-runtime"; +import type { GatewayCallOptions } from "./params.js"; + +export type ApprovalDecision = "allow-once" | "allow-always" | "deny"; + +export type ApprovalOutcome = { + decision: ApprovalDecision | null; + approvalId?: string; +}; + +const DEFAULT_APPROVAL_TIMEOUT_MS = 120_000; +const PLUGIN_ID = "file-transfer"; + +/** + * Issue a two-phase plugin.approval.request and wait for the operator's + * decision. Returns null decision if the request was unavailable (e.g., + * no operator client connected) — caller should then fall back to deny. + */ +export async function requestFileTransferApproval(input: { + gatewayOpts: GatewayCallOptions; + title: string; + description: string; + severity?: "info" | "warning" | "critical"; + toolName: string; + toolCallId?: string; + agentId?: string; + sessionKey?: string; + timeoutMs?: number; +}): Promise { + const timeoutMs = input.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS; + + type RequestResult = { id?: string; decision?: ApprovalDecision | null }; + + const requestResult = (await callGatewayTool( + "plugin.approval.request", + { + ...input.gatewayOpts, + timeoutMs: timeoutMs + 10_000, + }, + { + pluginId: PLUGIN_ID, + title: input.title.slice(0, 80), + description: input.description.slice(0, 256), + severity: input.severity ?? "warning", + toolName: input.toolName, + toolCallId: input.toolCallId, + agentId: input.agentId, + sessionKey: input.sessionKey, + timeoutMs, + twoPhase: true, + }, + )) as RequestResult | undefined; + + if (!requestResult || requestResult.decision === null) { + // Approval system explicitly declined or no operator available. + return { decision: null }; + } + + // Two-phase: if the request returned a synchronous decision, use it; + // otherwise wait on the approval id. + if (requestResult.decision) { + return { decision: requestResult.decision, approvalId: requestResult.id }; + } + if (!requestResult.id) { + return { decision: null }; + } + + type WaitResult = { id?: string; decision?: ApprovalDecision | null }; + const waitResult = (await callGatewayTool( + "plugin.approval.waitDecision", + { + ...input.gatewayOpts, + timeoutMs: timeoutMs + 10_000, + }, + { id: requestResult.id }, + )) as WaitResult | undefined; + + return { + decision: waitResult?.decision ?? null, + approvalId: requestResult.id, + }; +} diff --git a/extensions/file-transfer/src/shared/audit.ts b/extensions/file-transfer/src/shared/audit.ts new file mode 100644 index 00000000000..932ffdc89b1 --- /dev/null +++ b/extensions/file-transfer/src/shared/audit.ts @@ -0,0 +1,84 @@ +// Append-only audit log for file-transfer operations. +// +// Records every decision (allow/deny/error) at the gateway-side tool +// layer. Lands at ~/.openclaw/audit/file-transfer.jsonl. Rotation is +// caller's responsibility — the file grows unbounded. +// +// Log records do NOT include file contents or hashes of secrets. They do +// include canonical paths and sha256 of the payload, so treat the audit +// file as sensitive. + +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export type FileTransferAuditOp = "file.fetch" | "dir.list" | "dir.fetch" | "file.write"; + +export type FileTransferAuditDecision = + | "allowed" + | "allowed:once" + | "allowed:always" + | "denied:no_policy" + | "denied:policy" + | "denied:approval" + | "denied:command_not_allowed" + | "denied:symlink_escape" + | "error"; + +export type FileTransferAuditRecord = { + timestamp: string; + op: FileTransferAuditOp; + nodeId: string; + nodeDisplayName?: string; + requestedPath: string; + canonicalPath?: string; + decision: FileTransferAuditDecision; + errorCode?: string; + errorMessage?: string; + sizeBytes?: number; + sha256?: string; + durationMs?: number; + // Tying back to the agent that initiated the op + requesterAgentId?: string; + sessionKey?: string; + // Reason text for denials + reason?: string; +}; + +let auditDirPromise: Promise | null = null; + +async function ensureAuditDir(): Promise { + if (auditDirPromise) { + return auditDirPromise; + } + auditDirPromise = (async () => { + const dir = path.join(os.homedir(), ".openclaw", "audit"); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + return dir; + })(); + return auditDirPromise; +} + +function auditFilePath(dir: string): string { + return path.join(dir, "file-transfer.jsonl"); +} + +/** + * Append an audit record. Best-effort — failures are logged to stderr and + * never propagated to the caller (the caller's operation is the source of + * truth, not the audit write). + */ +export async function appendFileTransferAudit( + record: Omit, +): Promise { + try { + const dir = await ensureAuditDir(); + const line = `${JSON.stringify({ + timestamp: new Date().toISOString(), + ...record, + })}\n`; + await fs.appendFile(auditFilePath(dir), line, { mode: 0o600 }); + } catch (e) { + process.stderr.write(`[file-transfer:audit] append failed: ${String(e)}\n`); + } +} diff --git a/extensions/file-transfer/src/shared/errors.ts b/extensions/file-transfer/src/shared/errors.ts new file mode 100644 index 00000000000..9c1ed19d8f0 --- /dev/null +++ b/extensions/file-transfer/src/shared/errors.ts @@ -0,0 +1,68 @@ +// Shared error code surface across the four file-transfer tools/handlers. +// Every tool returns the same { ok: false, code, message, canonicalPath? } +// shape so the model can reason about errors uniformly. + +export type FileTransferErrCode = + // Path-shape errors (caller's fault) + | "INVALID_PATH" + | "INVALID_BASE64" + | "INVALID_PARAMS" + // Filesystem errors (file/dir layer) + | "NOT_FOUND" + | "PERMISSION_DENIED" + | "IS_DIRECTORY" + | "IS_FILE" + | "PARENT_NOT_FOUND" + | "EXISTS_NO_OVERWRITE" + | "READ_ERROR" + | "WRITE_ERROR" + // Size/limit errors + | "FILE_TOO_LARGE" + | "TREE_TOO_LARGE" + // Safety errors + | "PATH_TRAVERSAL" + | "SYMLINK_TARGET_DENIED" + | "INTEGRITY_FAILURE" + // Policy errors (gateway-side) + | "POLICY_DENIED" + | "NO_POLICY"; + +export type FileTransferErr = { + ok: false; + code: FileTransferErrCode; + message: string; + canonicalPath?: string; +}; + +export function err( + code: FileTransferErrCode, + message: string, + canonicalPath?: string, +): FileTransferErr { + return { ok: false, code, message, ...(canonicalPath ? { canonicalPath } : {}) }; +} + +// Translate a node-side fs error to a public error code. +export function classifyFsError(e: unknown): FileTransferErrCode { + const code = (e as { code?: string } | null)?.code; + if (code === "ENOENT") { + return "NOT_FOUND"; + } + if (code === "EACCES" || code === "EPERM") { + return "PERMISSION_DENIED"; + } + if (code === "EISDIR") { + return "IS_DIRECTORY"; + } + return "READ_ERROR"; +} + +// Convert a node-host error payload to a thrown Error for agent-tool consumption. +// The agent-tool surfaces these as failed tool results uniformly. +export function throwFromNodePayload(operation: string, payload: Record): never { + const code = typeof payload.code === "string" ? payload.code : "ERROR"; + const message = typeof payload.message === "string" ? payload.message : `${operation} failed`; + const canonical = + typeof payload.canonicalPath === "string" ? ` (canonical=${payload.canonicalPath})` : ""; + throw new Error(`${operation} ${code}: ${message}${canonical}`); +} diff --git a/extensions/file-transfer/src/shared/gatekeep.ts b/extensions/file-transfer/src/shared/gatekeep.ts new file mode 100644 index 00000000000..69a1256bb44 --- /dev/null +++ b/extensions/file-transfer/src/shared/gatekeep.ts @@ -0,0 +1,174 @@ +// One-stop gatekeep: evaluate policy, prompt operator if needed, persist +// allow-always to config, audit the outcome. Returns a uniform decision +// the tool can act on. + +import { requestFileTransferApproval } from "./approval.js"; +import { appendFileTransferAudit, type FileTransferAuditOp } from "./audit.js"; +import type { GatewayCallOptions } from "./params.js"; +import { evaluateFilePolicy, persistAllowAlways, type FilePolicyKind } from "./policy.js"; + +export type GatekeepOutcome = { ok: true; maxBytes?: number } | { ok: false; throwMessage: string }; + +/** + * Single-call entry point used by every tool's execute() before it + * forwards to the node. Handles policy evaluation, optional + * plugin-approval prompt, persistence on allow-always, and the + * pre-flight audit log entry. + * + * Caller is responsible for the post-flight check (re-evaluate against + * canonicalPath returned by the node) — we don't have the canonical + * path here yet. + */ +export async function gatekeep(input: { + op: FileTransferAuditOp; + nodeId: string; + nodeDisplayName?: string; + kind: FilePolicyKind; + path: string; + toolCallId?: string; + agentId?: string; + sessionKey?: string; + gatewayOpts: GatewayCallOptions; + startedAt: number; + /** Operation-friendly label for the approval prompt, e.g. "Read file". */ + promptVerb: string; +}): Promise { + const decision = evaluateFilePolicy({ + nodeId: input.nodeId, + nodeDisplayName: input.nodeDisplayName, + kind: input.kind, + path: input.path, + }); + + // Silent allow path. + if (decision.ok && decision.reason === "matched-allow") { + return { ok: true, maxBytes: decision.maxBytes }; + } + + // ask=always: prompt even on a match. + // Or: ask=on-miss + no allow match: prompt. + const shouldAsk = + (decision.ok && decision.reason === "ask-always") || + (!decision.ok && decision.askable === true); + + if (shouldAsk) { + const verb = input.promptVerb; + const subject = input.nodeDisplayName ?? input.nodeId; + const approval = await requestFileTransferApproval({ + gatewayOpts: input.gatewayOpts, + title: `${verb}: ${input.path}`, + 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.`, + severity: input.kind === "write" ? "warning" : "info", + toolName: input.op, + toolCallId: input.toolCallId, + agentId: input.agentId, + sessionKey: input.sessionKey, + }); + + if (approval.decision === "deny") { + await appendFileTransferAudit({ + op: input.op, + nodeId: input.nodeId, + nodeDisplayName: input.nodeDisplayName, + requestedPath: input.path, + decision: "denied:approval", + reason: "operator denied", + durationMs: Date.now() - input.startedAt, + }); + return { + ok: false, + throwMessage: `${input.op} APPROVAL_DENIED: operator denied the prompt`, + }; + } + + if (approval.decision === "allow-once") { + await appendFileTransferAudit({ + op: input.op, + nodeId: input.nodeId, + nodeDisplayName: input.nodeDisplayName, + requestedPath: input.path, + decision: "allowed:once", + durationMs: Date.now() - input.startedAt, + }); + return { + ok: true, + maxBytes: decision.ok ? decision.maxBytes : undefined, + }; + } + + if (approval.decision === "allow-always") { + try { + await persistAllowAlways({ + nodeId: input.nodeId, + nodeDisplayName: input.nodeDisplayName, + kind: input.kind, + path: input.path, + }); + } catch (e) { + // The approval is still valid for this call — failure to persist + // shouldn't block the operation. Just note it in the audit. + await appendFileTransferAudit({ + op: input.op, + nodeId: input.nodeId, + nodeDisplayName: input.nodeDisplayName, + requestedPath: input.path, + decision: "allowed:always", + reason: `persist failed: ${String(e)}`, + durationMs: Date.now() - input.startedAt, + }); + return { + ok: true, + maxBytes: decision.ok ? decision.maxBytes : undefined, + }; + } + await appendFileTransferAudit({ + op: input.op, + nodeId: input.nodeId, + nodeDisplayName: input.nodeDisplayName, + requestedPath: input.path, + decision: "allowed:always", + durationMs: Date.now() - input.startedAt, + }); + return { + ok: true, + maxBytes: decision.ok ? decision.maxBytes : undefined, + }; + } + + // null decision: no operator available, treat as deny. + await appendFileTransferAudit({ + op: input.op, + nodeId: input.nodeId, + nodeDisplayName: input.nodeDisplayName, + requestedPath: input.path, + decision: "denied:approval", + reason: "no operator available to approve", + durationMs: Date.now() - input.startedAt, + }); + return { + ok: false, + throwMessage: `${input.op} APPROVAL_UNAVAILABLE: no operator client connected to approve the request`, + }; + } + + // Plain deny path. + if (!decision.ok) { + await appendFileTransferAudit({ + op: input.op, + nodeId: input.nodeId, + nodeDisplayName: input.nodeDisplayName, + requestedPath: input.path, + decision: decision.code === "NO_POLICY" ? "denied:no_policy" : "denied:policy", + errorCode: decision.code, + reason: decision.reason, + durationMs: Date.now() - input.startedAt, + }); + return { + ok: false, + throwMessage: `${input.op} ${decision.code}: ${decision.reason}`, + }; + } + + // Shouldn't reach here. + return { ok: true, maxBytes: undefined }; +} diff --git a/extensions/file-transfer/src/shared/mime.ts b/extensions/file-transfer/src/shared/mime.ts new file mode 100644 index 00000000000..c0949438614 --- /dev/null +++ b/extensions/file-transfer/src/shared/mime.ts @@ -0,0 +1,53 @@ +import path from "node:path"; + +// Single source of truth for extension→MIME mapping. Used by all four +// handlers/tools so adding a new extension lands everywhere at once. +export const EXTENSION_MIME: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".bmp": "image/bmp", + ".heic": "image/heic", + ".heif": "image/heif", + ".pdf": "application/pdf", + ".txt": "text/plain", + ".log": "text/plain", + ".md": "text/markdown", + ".json": "application/json", + ".csv": "text/csv", + ".html": "text/html", + ".xml": "application/xml", + ".zip": "application/zip", + ".tar": "application/x-tar", + ".gz": "application/gzip", +}; + +// MIME types we treat as inline-displayable images for vision-capable models. +// Note: heic/heif are detectable but not all providers can render them, so we +// leave them out of the inline-image set and let them flow as text+saved-path. +export const IMAGE_MIME_INLINE_SET = new Set([ + "image/png", + "image/jpeg", + "image/webp", + "image/gif", +]); + +// Plain-text MIME types where inlining the content into a text block is more +// useful than a "saved at " stub for small files (under TEXT_INLINE_MAX). +export const TEXT_INLINE_MIME_SET = new Set([ + "text/plain", + "text/markdown", + "text/csv", + "text/html", + "application/json", + "application/xml", +]); + +export const TEXT_INLINE_MAX_BYTES = 8 * 1024; + +export function mimeFromExtension(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + return EXTENSION_MIME[ext] ?? "application/octet-stream"; +} diff --git a/extensions/file-transfer/src/shared/params.ts b/extensions/file-transfer/src/shared/params.ts new file mode 100644 index 00000000000..f5b5c3f022e --- /dev/null +++ b/extensions/file-transfer/src/shared/params.ts @@ -0,0 +1,62 @@ +// Shared param-validation helpers used by all four agent tools. +// Goal: identical validation behavior + identical error shapes everywhere. + +export type GatewayCallOptions = { + gatewayUrl?: string; + gatewayToken?: string; + timeoutMs?: number; +}; + +export function readGatewayCallOptions(params: Record): GatewayCallOptions { + const opts: GatewayCallOptions = {}; + if (typeof params.gatewayUrl === "string" && params.gatewayUrl.trim()) { + opts.gatewayUrl = params.gatewayUrl.trim(); + } + if (typeof params.gatewayToken === "string" && params.gatewayToken.trim()) { + opts.gatewayToken = params.gatewayToken.trim(); + } + if (typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)) { + opts.timeoutMs = params.timeoutMs; + } + return opts; +} + +export function readTrimmedString(params: Record, key: string): string { + const value = params[key]; + return typeof value === "string" ? value.trim() : ""; +} + +export function readBoolean( + params: Record, + key: string, + defaultValue = false, +): boolean { + const value = params[key]; + if (typeof value === "boolean") { + return value; + } + return defaultValue; +} + +export function readClampedInt(params: { + input: Record; + key: string; + defaultValue: number; + hardMin: number; + hardMax: number; +}): number { + const value = params.input[params.key]; + const requested = + typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : params.defaultValue; + return Math.max(params.hardMin, Math.min(requested, params.hardMax)); +} + +export function humanSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} diff --git a/extensions/file-transfer/src/shared/policy.ts b/extensions/file-transfer/src/shared/policy.ts new file mode 100644 index 00000000000..0ce3e64e0a6 --- /dev/null +++ b/extensions/file-transfer/src/shared/policy.ts @@ -0,0 +1,269 @@ +// Path policy for file-transfer tools. +// +// Default behavior is DENY. The operator must explicitly opt in by adding +// a config block to ~/.openclaw/openclaw.json under +// `gateway.nodes.fileTransfer`. Without a matching block, every file +// operation is rejected before reaching the node. +// +// Schema (informal): +// +// "gateway": { +// "nodes": { +// "fileTransfer": { +// "": { +// "ask": "off" | "on-miss" | "always", +// "allowReadPaths": ["~/Screenshots/**", "/tmp/**"], +// "allowWritePaths": ["~/Downloads/**"], +// "denyPaths": ["**/.ssh/**", "**/.aws/**"], +// "maxBytes": 16777216 +// }, +// "*": { "ask": "on-miss" } +// } +// } +// } +// +// `ask` modes: +// off — silent: allow if matched, deny if not (today's default) +// on-miss — silent allow if matched; prompt operator if not matched +// always — prompt operator on every call (denyPaths still hard-deny) +// +// `denyPaths` always wins, even in `ask: always`. +// `allow-always` from the prompt appends the path back into allowReadPaths / +// allowWritePaths via mutateConfigFile. + +import os from "node:os"; +import path from "node:path"; +import { minimatch } from "minimatch"; +import { mutateConfigFile } from "openclaw/plugin-sdk/config-runtime"; +import { getRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; + +export type FilePolicyKind = "read" | "write"; +export type FilePolicyAskMode = "off" | "on-miss" | "always"; + +export type FilePolicyDecision = + | { ok: true; reason: "matched-allow"; maxBytes?: number } + | { ok: true; reason: "ask-always"; askMode: FilePolicyAskMode; maxBytes?: number } + | { + ok: false; + code: "NO_POLICY" | "POLICY_DENIED"; + reason: string; + askable: boolean; + askMode?: FilePolicyAskMode; + }; + +type NodeFilePolicyConfig = { + ask?: FilePolicyAskMode; + allowReadPaths?: string[]; + allowWritePaths?: string[]; + denyPaths?: string[]; + maxBytes?: number; +}; + +type FilePolicyConfig = Record; + +function readFilePolicyConfig(): FilePolicyConfig | null { + const cfg = getRuntimeConfig(); + const gateway = (cfg as { gateway?: unknown }).gateway; + if (!gateway || typeof gateway !== "object") { + return null; + } + const nodes = (gateway as { nodes?: unknown }).nodes; + if (!nodes || typeof nodes !== "object") { + return null; + } + const fileTransfer = (nodes as { fileTransfer?: unknown }).fileTransfer; + if (!fileTransfer || typeof fileTransfer !== "object" || Array.isArray(fileTransfer)) { + return null; + } + return fileTransfer as FilePolicyConfig; +} + +function expandTilde(p: string): string { + if (p.startsWith("~/") || p === "~") { + return path.join(os.homedir(), p.slice(p === "~" ? 1 : 2)); + } + return p; +} + +function normalizeGlobs(patterns: string[] | undefined): string[] { + if (!Array.isArray(patterns)) { + return []; + } + return patterns + .filter((p): p is string => typeof p === "string" && p.trim().length > 0) + .map((p) => expandTilde(p.trim())); +} + +function matchesAny(target: string, patterns: string[]): boolean { + for (const pattern of patterns) { + if (minimatch(target, pattern, { dot: true })) { + return true; + } + } + return false; +} + +function resolveNodePolicy( + config: FilePolicyConfig, + nodeId: string, + nodeDisplayName?: string, +): { key: string; entry: NodeFilePolicyConfig } | null { + const candidates = [nodeId, nodeDisplayName].filter( + (k): k is string => typeof k === "string" && k.length > 0, + ); + for (const key of candidates) { + if (config[key]) { + return { key, entry: config[key] }; + } + } + if (config["*"]) { + return { key: "*", entry: config["*"] }; + } + return null; +} + +function normalizeAskMode(value: unknown): FilePolicyAskMode { + if (value === "on-miss" || value === "always" || value === "off") { + return value; + } + return "off"; +} + +/** + * Evaluate whether (nodeId, kind, path) is permitted. + * + * Resolution order: + * 1. No fileTransfer config or no entry for this node → NO_POLICY (deny, + * not askable — operator hasn't opted in at all). + * 2. denyPaths matches → POLICY_DENIED, not askable (hard deny). + * 3. ask=always → ask-always (prompt every time). + * 4. allowPaths matches → matched-allow (silent allow). + * 5. ask=on-miss → POLICY_DENIED with askable=true. + * 6. ask=off (or unset) → POLICY_DENIED, not askable. + */ +export function evaluateFilePolicy(input: { + nodeId: string; + nodeDisplayName?: string; + kind: FilePolicyKind; + path: string; +}): FilePolicyDecision { + const config = readFilePolicyConfig(); + if (!config) { + return { + ok: false, + code: "NO_POLICY", + reason: + "no gateway.nodes.fileTransfer config; file-transfer is deny-by-default until configured", + askable: false, + }; + } + const resolved = resolveNodePolicy(config, input.nodeId, input.nodeDisplayName); + if (!resolved) { + return { + ok: false, + code: "NO_POLICY", + reason: `no fileTransfer policy entry for "${input.nodeDisplayName ?? input.nodeId}"; configure gateway.nodes.fileTransfer or "*"`, + askable: false, + }; + } + const nodeConfig = resolved.entry; + const askMode = normalizeAskMode(nodeConfig.ask); + + // 1. Deny patterns always win. + const denyPatterns = normalizeGlobs(nodeConfig.denyPaths); + if (matchesAny(input.path, denyPatterns)) { + return { + ok: false, + code: "POLICY_DENIED", + reason: "path matches a denyPaths pattern", + askable: false, + askMode, + }; + } + + const maxBytes = + typeof nodeConfig.maxBytes === "number" && Number.isFinite(nodeConfig.maxBytes) + ? Math.max(1, Math.floor(nodeConfig.maxBytes)) + : undefined; + + // 2. ask=always: prompt every time even if matched. + if (askMode === "always") { + return { ok: true, reason: "ask-always", askMode, maxBytes }; + } + + // 3. Match against allow list for this kind. + const allowPatterns = + input.kind === "read" + ? normalizeGlobs(nodeConfig.allowReadPaths) + : normalizeGlobs(nodeConfig.allowWritePaths); + + if (allowPatterns.length > 0 && matchesAny(input.path, allowPatterns)) { + return { ok: true, reason: "matched-allow", maxBytes }; + } + + // 4. No allow match. Either askable on miss or hard-deny. + if (askMode === "on-miss") { + return { + ok: false, + code: "POLICY_DENIED", + reason: `path does not match any allow${input.kind === "read" ? "Read" : "Write"}Paths pattern`, + askable: true, + askMode, + }; + } + + return { + ok: false, + code: "POLICY_DENIED", + reason: + allowPatterns.length === 0 + ? `no allow${input.kind === "read" ? "Read" : "Write"}Paths configured` + : `path does not match any allow${input.kind === "read" ? "Read" : "Write"}Paths pattern`, + askable: false, + askMode, + }; +} + +/** + * Persist an "allow-always" approval by appending the path to the + * relevant allowReadPaths / allowWritePaths list for the node. Uses + * mutateConfigFile so the change survives gateway restarts. + * + * Inserts under whichever key matched the policy (per-node entry, or + * the "*" wildcard if that's what was hit). If no entry exists yet, + * creates one keyed by nodeDisplayName ?? nodeId. + */ +export async function persistAllowAlways(input: { + nodeId: string; + nodeDisplayName?: string; + kind: FilePolicyKind; + path: string; +}): Promise { + const field = input.kind === "read" ? "allowReadPaths" : "allowWritePaths"; + await mutateConfigFile({ + afterWrite: { mode: "none", reason: "file-transfer allow-always policy update" }, + mutate: (draft) => { + // Cast through unknown — OpenClawConfig type doesn't yet declare + // gateway.nodes.fileTransfer. + const root = draft as unknown as Record; + const gateway = (root.gateway ??= {}) as Record; + const nodes = (gateway.nodes ??= {}) as Record; + const fileTransfer = (nodes.fileTransfer ??= {}) as Record; + + const candidates = [input.nodeId, input.nodeDisplayName, "*"].filter( + (k): k is string => typeof k === "string" && k.length > 0, + ); + let key = candidates.find((c) => fileTransfer[c]); + if (!key) { + key = input.nodeDisplayName ?? input.nodeId; + fileTransfer[key] = {}; + } + const entry = fileTransfer[key]; + const list = Array.isArray(entry[field]) ? (entry[field] as string[]) : []; + if (!list.includes(input.path)) { + list.push(input.path); + } + entry[field] = list; + }, + }); +} diff --git a/extensions/file-transfer/src/tools/dir-fetch-tool.ts b/extensions/file-transfer/src/tools/dir-fetch-tool.ts new file mode 100644 index 00000000000..450b22df41a --- /dev/null +++ b/extensions/file-transfer/src/tools/dir-fetch-tool.ts @@ -0,0 +1,361 @@ +import { spawn } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { + callGatewayTool, + listNodes, + resolveNodeIdFromList, + type AnyAgentTool, + type NodeListNode, +} from "openclaw/plugin-sdk/agent-harness-runtime"; +import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store"; +import { Type } from "typebox"; +import { appendFileTransferAudit } from "../shared/audit.js"; +import { throwFromNodePayload } from "../shared/errors.js"; +import { gatekeep } from "../shared/gatekeep.js"; +import { IMAGE_MIME_INLINE_SET, mimeFromExtension } from "../shared/mime.js"; +import { + humanSize, + readBoolean, + readClampedInt, + readGatewayCallOptions, + readTrimmedString, +} from "../shared/params.js"; +import { evaluateFilePolicy } from "../shared/policy.js"; + +const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024; +const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024; +const FILE_TRANSFER_SUBDIR = "file-transfer"; + +// Cap how many local file paths we surface in details.media.mediaUrls. +// Larger trees still land on disk but we don't spam the channel adapter +// with hundreds of attachments. +const MEDIA_URL_CAP = 25; + +// Hard timeout for the gateway-side `tar -xzf` unpack process. +const TAR_UNPACK_TIMEOUT_MS = 60_000; + +const DirFetchToolSchema = Type.Object({ + node: Type.String({ + description: "Node id, name, or IP. Resolves the same way as the nodes tool.", + }), + path: Type.String({ + description: "Absolute path to the directory on the node to fetch. Canonicalized server-side.", + }), + maxBytes: Type.Optional( + Type.Number({ + description: + "Max gzipped tarball bytes to fetch. Default 8 MB, hard ceiling 16 MB (single round-trip).", + }), + ), + includeDotfiles: Type.Optional( + Type.Boolean({ + description: "Reserved for v2; currently always includes dotfiles (v1 quirk in BSD tar).", + }), + ), + gatewayUrl: Type.Optional(Type.String()), + gatewayToken: Type.Optional(Type.String()), + timeoutMs: Type.Optional(Type.Number()), +}); + +async function computeFileSha256(filePath: string): Promise { + const buf = await fs.readFile(filePath); + return crypto.createHash("sha256").update(buf).digest("hex"); +} + +type UnpackedFileEntry = { + relPath: string; + size: number; + mimeType: string; + sha256: string; + localPath: string; +}; + +/** + * Unpack a gzipped tarball into a target directory via `tar -xzf -`. The + * `-P` flag is intentionally omitted so absolute paths in the archive are + * stripped to relative ones and `..` traversal is rejected by tar itself. + * A hard wall-clock timeout caps the unpack at TAR_UNPACK_TIMEOUT_MS to + * avoid hangs on hostile/large archives. + */ +async function unpackTar(tarBuffer: Buffer, destDir: string): Promise { + await fs.mkdir(destDir, { recursive: true, mode: 0o700 }); + return new Promise((resolve, reject) => { + const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar"; + const child = spawn( + tarBin, + [ + "-xzf", + "-", + "-C", + destDir, + // Refuse archives whose paths escape destDir. + "--no-overwrite-dir", + ], + { stdio: ["pipe", "ignore", "pipe"] }, + ); + let stderrOut = ""; + const watchdog = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + /* already gone */ + } + reject(new Error(`tar unpack timed out after ${TAR_UNPACK_TIMEOUT_MS}ms`)); + }, TAR_UNPACK_TIMEOUT_MS); + child.stderr.on("data", (chunk: Buffer) => { + stderrOut += chunk.toString(); + }); + child.on("close", (code) => { + clearTimeout(watchdog); + if (code !== 0) { + reject(new Error(`tar unpack exited ${code}: ${stderrOut.slice(0, 300)}`)); + return; + } + resolve(); + }); + child.on("error", (e) => { + clearTimeout(watchdog); + reject(e); + }); + child.stdin.end(tarBuffer); + }); +} + +/** + * Walk a directory recursively, collecting file entries (skips directories). + * Skips symlinks — we don't want to follow links the archive might have + * carried in. Files only. + */ +async function walkDir( + dir: string, + rootDir: string, +): Promise<{ relPath: string; absPath: string }[]> { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const results: { relPath: string; absPath: string }[] = []; + for (const entry of entries) { + const absPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const nested = await walkDir(absPath, rootDir); + results.push(...nested); + } else if (entry.isFile()) { + const relPath = path.relative(rootDir, absPath); + results.push({ relPath, absPath }); + } + // Symlinks are intentionally ignored: don't follow them out of destDir. + } + return results; +} + +export function createDirFetchTool(): AnyAgentTool { + return { + label: "Directory Fetch", + name: "dir_fetch", + description: + "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 gateway.nodes.fileTransfer..allowReadPaths must match the directory path.", + parameters: DirFetchToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const node = readTrimmedString(params, "node"); + const dirPath = readTrimmedString(params, "path"); + if (!node) { + throw new Error("node required"); + } + if (!dirPath) { + throw new Error("path required"); + } + + const maxBytes = readClampedInt({ + input: params, + key: "maxBytes", + defaultValue: DIR_FETCH_DEFAULT_MAX_BYTES, + hardMin: 1, + hardMax: DIR_FETCH_HARD_MAX_BYTES, + }); + const includeDotfiles = readBoolean(params, "includeDotfiles", false); + + const gatewayOpts = readGatewayCallOptions(params); + const nodes: NodeListNode[] = await listNodes(gatewayOpts); + const nodeId = resolveNodeIdFromList(nodes, node, false); + const nodeMeta = nodes.find((n) => n.nodeId === nodeId); + const nodeDisplayName = nodeMeta?.displayName ?? node; + const startedAt = Date.now(); + + const gate = await gatekeep({ + op: "dir.fetch", + nodeId, + nodeDisplayName, + kind: "read", + path: dirPath, + toolCallId: _toolCallId, + gatewayOpts, + startedAt, + promptVerb: "Fetch directory tree", + }); + if (!gate.ok) { + throw new Error(gate.throwMessage); + } + const effectiveMaxBytes = gate.maxBytes ? Math.min(maxBytes, gate.maxBytes) : maxBytes; + + const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, { + nodeId, + command: "dir.fetch", + params: { + path: dirPath, + maxBytes: effectiveMaxBytes, + includeDotfiles, + }, + idempotencyKey: crypto.randomUUID(), + }); + + const payload = + raw?.payload && typeof raw.payload === "object" && !Array.isArray(raw.payload) + ? (raw.payload as Record) + : null; + if (!payload) { + await appendFileTransferAudit({ + op: "dir.fetch", + nodeId, + nodeDisplayName, + requestedPath: dirPath, + decision: "error", + errorMessage: "invalid payload", + durationMs: Date.now() - startedAt, + }); + throw new Error("invalid dir.fetch payload"); + } + if (payload.ok === false) { + await appendFileTransferAudit({ + op: "dir.fetch", + nodeId, + nodeDisplayName, + requestedPath: dirPath, + canonicalPath: + typeof payload.canonicalPath === "string" ? payload.canonicalPath : undefined, + decision: "error", + errorCode: typeof payload.code === "string" ? payload.code : undefined, + errorMessage: typeof payload.message === "string" ? payload.message : undefined, + durationMs: Date.now() - startedAt, + }); + throwFromNodePayload("dir.fetch", payload); + } + + const canonicalPath = typeof payload.path === "string" ? payload.path : ""; + const tarBase64 = typeof payload.tarBase64 === "string" ? payload.tarBase64 : ""; + const tarBytes = typeof payload.tarBytes === "number" ? payload.tarBytes : -1; + const sha256 = typeof payload.sha256 === "string" ? payload.sha256 : ""; + const fileCount = typeof payload.fileCount === "number" ? payload.fileCount : 0; + + if (!canonicalPath || !tarBase64 || tarBytes < 0 || !sha256) { + throw new Error("invalid dir.fetch payload (missing fields)"); + } + + // Post-flight policy on canonicalized path. + if (canonicalPath !== dirPath) { + const postflight = evaluateFilePolicy({ + nodeId, + nodeDisplayName, + kind: "read", + path: canonicalPath, + }); + if (!postflight.ok) { + await appendFileTransferAudit({ + op: "dir.fetch", + nodeId, + nodeDisplayName, + requestedPath: dirPath, + canonicalPath, + decision: "denied:symlink_escape", + errorCode: postflight.code, + reason: postflight.reason, + durationMs: Date.now() - startedAt, + }); + throw new Error( + `dir.fetch SYMLINK_TARGET_DENIED: requested path resolved to ${canonicalPath} which is not allowed by policy`, + ); + } + } + + const tarBuffer = Buffer.from(tarBase64, "base64"); + if (tarBuffer.byteLength !== tarBytes) { + throw new Error( + `dir.fetch size mismatch: payload says ${tarBytes} bytes, decoded ${tarBuffer.byteLength}`, + ); + } + const localSha256 = crypto.createHash("sha256").update(tarBuffer).digest("hex"); + if (localSha256 !== sha256) { + throw new Error("dir.fetch sha256 mismatch (integrity failure)"); + } + + // Save tarball under the file-transfer subdir (no 2-min TTL). + const savedTar = await saveMediaBuffer( + tarBuffer, + "application/gzip", + FILE_TRANSFER_SUBDIR, + DIR_FETCH_HARD_MAX_BYTES, + ); + + const tarDir = path.dirname(savedTar.path); + const tarBaseName = path.basename(savedTar.path, path.extname(savedTar.path)); + const unpackId = `dir-fetch-${tarBaseName}`; + const rootDir = path.join(tarDir, unpackId); + + await unpackTar(tarBuffer, rootDir); + + const walked = await walkDir(rootDir, rootDir); + const files: UnpackedFileEntry[] = []; + for (const { relPath, absPath } of walked) { + let size = 0; + try { + const st = await fs.stat(absPath); + size = st.size; + } catch { + continue; + } + const mimeType = mimeFromExtension(relPath); + const fileSha256 = await computeFileSha256(absPath); + files.push({ relPath, size, mimeType, sha256: fileSha256, localPath: absPath }); + } + + const imageFiles = files.filter((f) => IMAGE_MIME_INLINE_SET.has(f.mimeType)); + const nonImageFiles = files.filter((f) => !IMAGE_MIME_INLINE_SET.has(f.mimeType)); + const allOrdered = [...imageFiles, ...nonImageFiles]; + const droppedFromMedia = Math.max(0, allOrdered.length - MEDIA_URL_CAP); + const mediaUrls = allOrdered.slice(0, MEDIA_URL_CAP).map((f) => f.localPath); + + const shortHash = sha256.slice(0, 12); + const mediaNote = droppedFromMedia + ? ` (channel attaches first ${MEDIA_URL_CAP}; ${droppedFromMedia} more in details.files)` + : ""; + const summaryText = `Fetched ${fileCount} files from ${canonicalPath} (${humanSize(tarBytes)} compressed, sha256:${shortHash}) — saved on the gateway under ${rootDir}/${mediaNote}`; + + await appendFileTransferAudit({ + op: "dir.fetch", + nodeId, + nodeDisplayName, + requestedPath: dirPath, + canonicalPath, + decision: "allowed", + sizeBytes: tarBytes, + sha256, + durationMs: Date.now() - startedAt, + }); + + return { + content: [{ type: "text" as const, text: summaryText }], + details: { + path: canonicalPath, + rootDir, + fileCount, + tarBytes, + sha256, + files, + media: { + mediaUrls, + }, + }, + }; + }, + }; +} diff --git a/extensions/file-transfer/src/tools/dir-list-tool.ts b/extensions/file-transfer/src/tools/dir-list-tool.ts new file mode 100644 index 00000000000..e6036d5a6ff --- /dev/null +++ b/extensions/file-transfer/src/tools/dir-list-tool.ts @@ -0,0 +1,199 @@ +import crypto from "node:crypto"; +import { + callGatewayTool, + listNodes, + resolveNodeIdFromList, + type AnyAgentTool, + type NodeListNode, +} from "openclaw/plugin-sdk/agent-harness-runtime"; +import { Type } from "typebox"; +import { appendFileTransferAudit } from "../shared/audit.js"; +import { throwFromNodePayload } from "../shared/errors.js"; +import { gatekeep } from "../shared/gatekeep.js"; +import { readClampedInt, readGatewayCallOptions, readTrimmedString } from "../shared/params.js"; +import { evaluateFilePolicy } from "../shared/policy.js"; + +const DIR_LIST_DEFAULT_MAX_ENTRIES = 200; +const DIR_LIST_HARD_MAX_ENTRIES = 5000; + +const DirListToolSchema = Type.Object({ + node: Type.String({ + description: "Node id, name, or IP. Resolves the same way as the nodes tool.", + }), + path: Type.String({ + description: "Absolute path to the directory on the node. Canonicalized server-side.", + }), + pageToken: Type.Optional( + Type.String({ + description: + "Pagination token from a previous dir_list call. Omit to start from the beginning.", + }), + ), + maxEntries: Type.Optional( + Type.Number({ + description: `Max entries per page. Default ${DIR_LIST_DEFAULT_MAX_ENTRIES}, hard ceiling ${DIR_LIST_HARD_MAX_ENTRIES}.`, + }), + ), + gatewayUrl: Type.Optional(Type.String()), + gatewayToken: Type.Optional(Type.String()), + timeoutMs: Type.Optional(Type.Number()), +}); + +export function createDirListTool(): AnyAgentTool { + return { + label: "Directory List", + name: "dir_list", + description: + "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 gateway.nodes.fileTransfer..allowReadPaths must match the directory path. Without policy configured, every call is denied.", + parameters: DirListToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const node = readTrimmedString(params, "node"); + const dirPath = readTrimmedString(params, "path"); + if (!node) { + throw new Error("node required"); + } + if (!dirPath) { + throw new Error("path required"); + } + + const maxEntries = readClampedInt({ + input: params, + key: "maxEntries", + defaultValue: DIR_LIST_DEFAULT_MAX_ENTRIES, + hardMin: 1, + hardMax: DIR_LIST_HARD_MAX_ENTRIES, + }); + + const pageToken = + typeof params.pageToken === "string" && params.pageToken.trim() + ? params.pageToken.trim() + : undefined; + + const gatewayOpts = readGatewayCallOptions(params); + const nodes: NodeListNode[] = await listNodes(gatewayOpts); + const nodeId = resolveNodeIdFromList(nodes, node, false); + const nodeMeta = nodes.find((n) => n.nodeId === nodeId); + const nodeDisplayName = nodeMeta?.displayName ?? node; + const startedAt = Date.now(); + + const gate = await gatekeep({ + op: "dir.list", + nodeId, + nodeDisplayName, + kind: "read", + path: dirPath, + toolCallId: _toolCallId, + gatewayOpts, + startedAt, + promptVerb: "List directory", + }); + if (!gate.ok) { + throw new Error(gate.throwMessage); + } + + const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, { + nodeId, + command: "dir.list", + params: { + path: dirPath, + pageToken, + maxEntries, + }, + idempotencyKey: crypto.randomUUID(), + }); + + const payload = + raw?.payload && typeof raw.payload === "object" && !Array.isArray(raw.payload) + ? (raw.payload as Record) + : null; + if (!payload) { + await appendFileTransferAudit({ + op: "dir.list", + nodeId, + nodeDisplayName, + requestedPath: dirPath, + decision: "error", + errorMessage: "invalid payload", + durationMs: Date.now() - startedAt, + }); + throw new Error("invalid dir.list payload"); + } + if (payload.ok === false) { + await appendFileTransferAudit({ + op: "dir.list", + nodeId, + nodeDisplayName, + requestedPath: dirPath, + canonicalPath: + typeof payload.canonicalPath === "string" ? payload.canonicalPath : undefined, + decision: "error", + errorCode: typeof payload.code === "string" ? payload.code : undefined, + errorMessage: typeof payload.message === "string" ? payload.message : undefined, + durationMs: Date.now() - startedAt, + }); + throwFromNodePayload("dir.list", payload); + } + + const canonicalPath = typeof payload.path === "string" ? payload.path : dirPath; + + // Post-flight policy on canonicalized dir. + if (canonicalPath !== dirPath) { + const postflight = evaluateFilePolicy({ + nodeId, + nodeDisplayName, + kind: "read", + path: canonicalPath, + }); + if (!postflight.ok) { + await appendFileTransferAudit({ + op: "dir.list", + nodeId, + nodeDisplayName, + requestedPath: dirPath, + canonicalPath, + decision: "denied:symlink_escape", + errorCode: postflight.code, + reason: postflight.reason, + durationMs: Date.now() - startedAt, + }); + throw new Error( + `dir.list SYMLINK_TARGET_DENIED: requested path resolved to ${canonicalPath} which is not allowed by policy`, + ); + } + } + + const entries = Array.isArray(payload.entries) + ? (payload.entries as Array>) + : []; + const truncated = payload.truncated === true; + const nextPageToken = + typeof payload.nextPageToken === "string" ? payload.nextPageToken : undefined; + + const fileCount = entries.filter((e) => !e.isDir).length; + const dirCount = entries.filter((e) => e.isDir).length; + const truncatedNote = truncated ? " (more entries available — pass nextPageToken)" : ""; + const summary = `Listed ${canonicalPath}: ${fileCount} file${fileCount !== 1 ? "s" : ""}, ${dirCount} subdir${dirCount !== 1 ? "s" : ""}${truncatedNote}`; + + await appendFileTransferAudit({ + op: "dir.list", + nodeId, + nodeDisplayName, + requestedPath: dirPath, + canonicalPath, + decision: "allowed", + durationMs: Date.now() - startedAt, + }); + + return { + content: [{ type: "text" as const, text: summary }], + details: { + path: canonicalPath, + entries, + nextPageToken, + truncated, + }, + }; + }, + }; +} diff --git a/extensions/file-transfer/src/tools/file-fetch-tool.ts b/extensions/file-transfer/src/tools/file-fetch-tool.ts new file mode 100644 index 00000000000..df915cd6524 --- /dev/null +++ b/extensions/file-transfer/src/tools/file-fetch-tool.ts @@ -0,0 +1,242 @@ +import crypto from "node:crypto"; +import { + callGatewayTool, + listNodes, + resolveNodeIdFromList, + type AnyAgentTool, + type NodeListNode, +} from "openclaw/plugin-sdk/agent-harness-runtime"; +import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store"; +import { Type } from "typebox"; +import { appendFileTransferAudit } from "../shared/audit.js"; +import { throwFromNodePayload } from "../shared/errors.js"; +import { gatekeep } from "../shared/gatekeep.js"; +import { + IMAGE_MIME_INLINE_SET, + TEXT_INLINE_MAX_BYTES, + TEXT_INLINE_MIME_SET, +} from "../shared/mime.js"; +import { humanSize, readGatewayCallOptions, readTrimmedString } from "../shared/params.js"; +import { evaluateFilePolicy } from "../shared/policy.js"; + +const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024; +const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024; +// Stash fetched files in a non-TTL subdir so a follow-up tool call within +// the same agent turn can still reference them. The default "inbound" +// subdir gets cleaned every 2 minutes which has bitten us in iMessage flows. +const FILE_TRANSFER_SUBDIR = "file-transfer"; + +const FileFetchToolSchema = Type.Object({ + node: Type.String({ + description: "Node id, name, or IP. Resolves the same way as the nodes tool.", + }), + path: Type.String({ + description: "Absolute path to the file on the node. Canonicalized server-side.", + }), + maxBytes: Type.Optional( + Type.Number({ + description: "Max bytes to fetch. Default 8 MB, hard ceiling 16 MB (single round-trip).", + }), + ), + gatewayUrl: Type.Optional(Type.String()), + gatewayToken: Type.Optional(Type.String()), + timeoutMs: Type.Optional(Type.Number()), +}); + +export function createFileFetchTool(): AnyAgentTool { + return { + label: "File Fetch", + name: "file_fetch", + description: + "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 gateway.nodes.fileTransfer..allowReadPaths must match the path. Without policy configured, every call is denied.", + parameters: FileFetchToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const node = readTrimmedString(params, "node"); + const filePath = readTrimmedString(params, "path"); + if (!node) { + throw new Error("node required"); + } + if (!filePath) { + throw new Error("path required"); + } + const requestedMax = + typeof params.maxBytes === "number" && Number.isFinite(params.maxBytes) + ? Math.floor(params.maxBytes) + : FILE_FETCH_DEFAULT_MAX_BYTES; + const maxBytes = Math.max(1, Math.min(requestedMax, FILE_FETCH_HARD_MAX_BYTES)); + + const gatewayOpts = readGatewayCallOptions(params); + const nodes: NodeListNode[] = await listNodes(gatewayOpts); + const nodeId = resolveNodeIdFromList(nodes, node, false); + const nodeMeta = nodes.find((n) => n.nodeId === nodeId); + const nodeDisplayName = nodeMeta?.displayName ?? node; + const startedAt = Date.now(); + + // Gatekeep: evaluate policy + prompt operator if ask=on-miss/always. + // Post-flight policy check below (after node returns canonicalPath) + // catches symlink escapes. + const gate = await gatekeep({ + op: "file.fetch", + nodeId, + nodeDisplayName, + kind: "read", + path: filePath, + toolCallId: _toolCallId, + gatewayOpts, + startedAt, + promptVerb: "Read file", + }); + if (!gate.ok) { + throw new Error(gate.throwMessage); + } + const effectiveMaxBytes = gate.maxBytes ? Math.min(maxBytes, gate.maxBytes) : maxBytes; + + const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, { + nodeId, + command: "file.fetch", + params: { + path: filePath, + maxBytes: effectiveMaxBytes, + }, + idempotencyKey: crypto.randomUUID(), + }); + + const payload = + raw?.payload && typeof raw.payload === "object" && !Array.isArray(raw.payload) + ? (raw.payload as Record) + : null; + if (!payload) { + await appendFileTransferAudit({ + op: "file.fetch", + nodeId, + nodeDisplayName, + requestedPath: filePath, + decision: "error", + errorMessage: "invalid payload", + durationMs: Date.now() - startedAt, + }); + throw new Error("invalid file.fetch payload"); + } + if (payload.ok === false) { + await appendFileTransferAudit({ + op: "file.fetch", + nodeId, + nodeDisplayName, + requestedPath: filePath, + canonicalPath: + typeof payload.canonicalPath === "string" ? payload.canonicalPath : undefined, + decision: "error", + errorCode: typeof payload.code === "string" ? payload.code : undefined, + errorMessage: typeof payload.message === "string" ? payload.message : undefined, + durationMs: Date.now() - startedAt, + }); + throwFromNodePayload("file.fetch", payload); + } + + const canonicalPath = typeof payload.path === "string" ? payload.path : ""; + const size = typeof payload.size === "number" ? payload.size : -1; + const mimeType = typeof payload.mimeType === "string" ? payload.mimeType : ""; + const base64 = typeof payload.base64 === "string" ? payload.base64 : ""; + const sha256 = typeof payload.sha256 === "string" ? payload.sha256 : ""; + if (!canonicalPath || size < 0 || !mimeType || !base64 || !sha256) { + throw new Error("invalid file.fetch payload (missing fields)"); + } + + // Post-flight policy check on the canonicalized path. Catches the + // symlink-escape case where the requested path matched policy but + // resolves to something that doesn't. + if (canonicalPath !== filePath) { + const postflight = evaluateFilePolicy({ + nodeId, + nodeDisplayName, + kind: "read", + path: canonicalPath, + }); + if (!postflight.ok) { + await appendFileTransferAudit({ + op: "file.fetch", + nodeId, + nodeDisplayName, + requestedPath: filePath, + canonicalPath, + decision: "denied:symlink_escape", + errorCode: postflight.code, + reason: postflight.reason, + durationMs: Date.now() - startedAt, + }); + throw new Error( + `file.fetch SYMLINK_TARGET_DENIED: requested path resolved to ${canonicalPath} which is not allowed by policy`, + ); + } + } + + const buffer = Buffer.from(base64, "base64"); + if (buffer.byteLength !== size) { + throw new Error( + `file.fetch size mismatch: payload says ${size} bytes, decoded ${buffer.byteLength}`, + ); + } + const localSha256 = crypto.createHash("sha256").update(buffer).digest("hex"); + if (localSha256 !== sha256) { + throw new Error("file.fetch sha256 mismatch (integrity failure)"); + } + + const saved = await saveMediaBuffer( + buffer, + mimeType, + FILE_TRANSFER_SUBDIR, + FILE_FETCH_HARD_MAX_BYTES, + ); + const localPath = saved.path; + + const isInlineImage = IMAGE_MIME_INLINE_SET.has(mimeType); + const isInlineText = TEXT_INLINE_MIME_SET.has(mimeType) && size <= TEXT_INLINE_MAX_BYTES; + + const content: Array< + { type: "text"; text: string } | { type: "image"; data: string; mimeType: string } + > = []; + if (isInlineImage) { + content.push({ type: "image", data: base64, mimeType }); + } else if (isInlineText) { + const text = buffer.toString("utf-8"); + content.push({ + type: "text", + text: `Fetched ${canonicalPath} (${humanSize(size)}, ${mimeType}, sha256:${sha256.slice(0, 12)}) saved at ${localPath}\n\n--- contents ---\n${text}`, + }); + } else { + const shortHash = sha256.slice(0, 12); + content.push({ + type: "text", + text: `Fetched ${canonicalPath} (${humanSize(size)}, ${mimeType}, sha256:${shortHash}) saved at ${localPath}`, + }); + } + + await appendFileTransferAudit({ + op: "file.fetch", + nodeId, + nodeDisplayName, + requestedPath: filePath, + canonicalPath, + decision: "allowed", + sizeBytes: size, + sha256, + durationMs: Date.now() - startedAt, + }); + + return { + content, + details: { + path: canonicalPath, + size, + mimeType, + sha256, + localPath, + media: { + mediaUrls: [localPath], + }, + }, + }; + }, + }; +} diff --git a/extensions/file-transfer/src/tools/file-write-tool.ts b/extensions/file-transfer/src/tools/file-write-tool.ts new file mode 100644 index 00000000000..409908c748f --- /dev/null +++ b/extensions/file-transfer/src/tools/file-write-tool.ts @@ -0,0 +1,227 @@ +import crypto from "node:crypto"; +import { + callGatewayTool, + listNodes, + resolveNodeIdFromList, + type AnyAgentTool, + type NodeListNode, +} from "openclaw/plugin-sdk/agent-harness-runtime"; +import { Type } from "typebox"; +import { appendFileTransferAudit } from "../shared/audit.js"; +import { throwFromNodePayload } from "../shared/errors.js"; +import { gatekeep } from "../shared/gatekeep.js"; +import { + humanSize, + readBoolean, + readGatewayCallOptions, + readTrimmedString, +} from "../shared/params.js"; +import { evaluateFilePolicy } from "../shared/policy.js"; + +const FILE_WRITE_SCHEMA = Type.Object({ + node: Type.String({ description: "Node id or display name to write the file on." }), + path: Type.String({ + description: "Absolute path on the node to write. Canonicalized server-side.", + }), + contentBase64: Type.String({ + description: "Base64-encoded bytes to write. Maximum 16 MB after decode.", + }), + mimeType: Type.Optional( + Type.String({ + description: "Content type hint. Not validated against the content.", + }), + ), + overwrite: Type.Optional( + Type.Boolean({ + description: "Allow overwriting an existing file. Default false.", + default: false, + }), + ), + createParents: Type.Optional( + Type.Boolean({ + description: "Create missing parent directories (mkdir -p). Default false.", + default: false, + }), + ), +}); + +type FileWriteSuccess = { + ok: true; + path: string; + size: number; + sha256: string; + overwritten: boolean; +}; + +type FileWriteError = { + ok: false; + code: string; + message: string; + canonicalPath?: string; +}; + +type FileWritePayload = FileWriteSuccess | FileWriteError; + +export function createFileWriteTool(): AnyAgentTool { + return { + label: "File Write", + name: "file_write", + description: + "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 (the node will reject if the path resolves to a symlink). Pair with file_fetch to round-trip a file from one node to another: file_fetch returns base64 in the image content block (.data) and as inline content for small text — pass that base64 directly as contentBase64 here. DO NOT use exec/cp/system.run for file copies; this tool IS the same-machine copy. Requires operator opt-in: gateway.nodes.allowCommands must include 'file.write' AND gateway.nodes.fileTransfer..allowWritePaths must match the destination path. Without policy configured, every call is denied.", + parameters: FILE_WRITE_SCHEMA, + async execute(_toolCallId, params) { + const raw = ( + params && typeof params === "object" && !Array.isArray(params) + ? (params as Record) + : {} + ) as Record; + + const nodeQuery = readTrimmedString(raw, "node"); + const filePath = readTrimmedString(raw, "path"); + const contentBase64 = typeof raw.contentBase64 === "string" ? raw.contentBase64 : ""; + const overwrite = readBoolean(raw, "overwrite", false); + const createParents = readBoolean(raw, "createParents", false); + + if (!nodeQuery) { + throw new Error("node required"); + } + if (!filePath) { + throw new Error("path required"); + } + if (!contentBase64) { + throw new Error("contentBase64 required"); + } + + // Compute the sha256 of the bytes we're sending so the node can do + // an end-to-end integrity check after writing. This is always + // sender-side computed; ignore any caller-supplied expectedSha256 + // to avoid the model passing a wrong hash and triggering an + // unintended unlink. + const buffer = Buffer.from(contentBase64, "base64"); + const expectedSha256 = crypto.createHash("sha256").update(buffer).digest("hex"); + + const gatewayOpts = readGatewayCallOptions(raw); + const nodes: NodeListNode[] = await listNodes(gatewayOpts); + const nodeId = resolveNodeIdFromList(nodes, nodeQuery, false); + const nodeMeta = nodes.find((n) => n.nodeId === nodeId); + const nodeDisplayName = nodeMeta?.displayName ?? nodeQuery; + const startedAt = Date.now(); + + const gate = await gatekeep({ + op: "file.write", + nodeId, + nodeDisplayName, + kind: "write", + path: filePath, + toolCallId: _toolCallId, + gatewayOpts, + startedAt, + promptVerb: "Write file", + }); + if (!gate.ok) { + throw new Error(gate.throwMessage); + } + + const result = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, { + nodeId, + command: "file.write", + params: { + path: filePath, + contentBase64, + overwrite, + createParents, + expectedSha256, + }, + idempotencyKey: crypto.randomUUID(), + }); + + const payload = (result as { payload?: unknown })?.payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + await appendFileTransferAudit({ + op: "file.write", + nodeId, + nodeDisplayName, + requestedPath: filePath, + decision: "error", + errorMessage: "unexpected response from node", + sizeBytes: buffer.byteLength, + durationMs: Date.now() - startedAt, + }); + throw new Error("unexpected file.write response from node"); + } + + const typed = payload as FileWritePayload; + if (!typed.ok) { + await appendFileTransferAudit({ + op: "file.write", + nodeId, + nodeDisplayName, + requestedPath: filePath, + canonicalPath: typed.canonicalPath, + decision: "error", + errorCode: typed.code, + errorMessage: typed.message, + sizeBytes: buffer.byteLength, + durationMs: Date.now() - startedAt, + }); + throwFromNodePayload("file.write", typed as unknown as Record); + } + + // Post-flight policy on canonicalized path. + if (typed.path !== filePath) { + const postflight = evaluateFilePolicy({ + nodeId, + nodeDisplayName, + kind: "write", + path: typed.path, + }); + if (!postflight.ok) { + await appendFileTransferAudit({ + op: "file.write", + nodeId, + nodeDisplayName, + requestedPath: filePath, + canonicalPath: typed.path, + decision: "denied:symlink_escape", + errorCode: postflight.code, + reason: postflight.reason, + sizeBytes: typed.size, + sha256: typed.sha256, + durationMs: Date.now() - startedAt, + }); + // The file is already written. The most we can do here is + // surface the issue loudly. We don't try to unlink because + // (a) the file may legitimately exist there and we just + // didn't have policy for it, and (b) unlinking on policy + // failure adds destructive ambiguity. + throw new Error( + `file.write SYMLINK_TARGET_WARNING: file written but canonical path ${typed.path} is not in this node's allowWritePaths`, + ); + } + } + + await appendFileTransferAudit({ + op: "file.write", + nodeId, + nodeDisplayName, + requestedPath: filePath, + canonicalPath: typed.path, + decision: "allowed", + sizeBytes: typed.size, + sha256: typed.sha256, + durationMs: Date.now() - startedAt, + }); + + const overwriteNote = typed.overwritten ? " (overwrote existing file)" : ""; + return { + content: [ + { + type: "text" as const, + text: `Wrote ${typed.path} (${humanSize(typed.size)}, sha256:${typed.sha256.slice(0, 12)})${overwriteNote}`, + }, + ], + details: typed, + }; + }, + }; +} diff --git a/src/agents/tools/nodes-tool-media.ts b/src/agents/tools/nodes-tool-media.ts index 0e091082ef2..c21e6325cef 100644 --- a/src/agents/tools/nodes-tool-media.ts +++ b/src/agents/tools/nodes-tool-media.ts @@ -27,6 +27,7 @@ export const MEDIA_INVOKE_ACTIONS = { "camera.clip": "camera_clip", "photos.latest": "photos_latest", "screen.record": "screen_record", + "file.fetch": "file_fetch", } as const; export type NodeMediaAction = "camera_snap" | "photos_latest" | "camera_clip" | "screen_record"; diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 9035b82b3a6..2a572022756 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -138,7 +138,7 @@ export function createNodesTool(options?: { name: "nodes", ownerOnly: isOpenClawOwnerOnlyCoreToolName("nodes"), description: - "Discover and control paired nodes (status/describe/pairing/notify/camera/photos/screen/location/notifications/invoke).", + "Discover and control paired nodes (status/describe/pairing/notify/camera/photos/screen/location/notifications/invoke). For file retrieval, use the dedicated file_fetch tool.", parameters: NodesToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index d6a12915335..cf585ff5a8c 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -1,6 +1,10 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { NODE_BROWSER_PROXY_COMMAND, + NODE_DIR_FETCH_COMMAND, + NODE_DIR_LIST_COMMAND, + NODE_FILE_FETCH_COMMAND, + NODE_FILE_WRITE_COMMAND, NODE_SYSTEM_NOTIFY_COMMAND, NODE_SYSTEM_RUN_COMMANDS, } from "../infra/node-commands.js"; @@ -48,6 +52,17 @@ const MOTION_COMMANDS = ["motion.activity", "motion.pedometer"]; const SMS_DANGEROUS_COMMANDS = ["sms.send", "sms.search"]; +// File operations on arbitrary node paths are sensitive — operator must opt +// in via `gateway.nodes.allowCommands`. Writes are more dangerous than reads; +// dir.list leaks information through enumeration; dir.fetch transfers tree +// content. All four are dangerous-by-default. +const FILE_DANGEROUS_COMMANDS = [ + NODE_FILE_FETCH_COMMAND, + NODE_DIR_LIST_COMMAND, + NODE_DIR_FETCH_COMMAND, + NODE_FILE_WRITE_COMMAND, +]; + // iOS nodes don't implement system.run/which, but they do support notifications. const IOS_SYSTEM_COMMANDS = [NODE_SYSTEM_NOTIFY_COMMAND]; @@ -72,6 +87,7 @@ export const DEFAULT_DANGEROUS_NODE_COMMANDS = [ ...CALENDAR_DANGEROUS_COMMANDS, ...REMINDERS_DANGEROUS_COMMANDS, ...SMS_DANGEROUS_COMMANDS, + ...FILE_DANGEROUS_COMMANDS, ]; const PLATFORM_DEFAULTS: Record = { diff --git a/src/infra/node-commands.ts b/src/infra/node-commands.ts index 3aa35051d2d..c4eda3bb584 100644 --- a/src/infra/node-commands.ts +++ b/src/infra/node-commands.ts @@ -11,3 +11,14 @@ export const NODE_EXEC_APPROVALS_COMMANDS = [ "system.execApprovals.get", "system.execApprovals.set", ] as const; + +export const NODE_FILE_FETCH_COMMAND = "file.fetch"; +export const NODE_DIR_LIST_COMMAND = "dir.list"; +export const NODE_DIR_FETCH_COMMAND = "dir.fetch"; +export const NODE_FILE_WRITE_COMMAND = "file.write"; +export const NODE_FILE_COMMANDS = [ + NODE_FILE_FETCH_COMMAND, + NODE_DIR_LIST_COMMAND, + NODE_DIR_FETCH_COMMAND, + NODE_FILE_WRITE_COMMAND, +] as const; diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index b094e16c665..91b0b9f1a43 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -939,7 +939,7 @@ export function collectNodeDangerousAllowCommandFindings( title: "Dangerous node commands explicitly enabled", detail: `gateway.nodes.allowCommands includes: ${dangerousAllowed.join(", ")}. ` + - "These commands can trigger high-impact device actions (camera/screen/contacts/calendar/reminders/SMS).", + "These commands can trigger high-impact device actions or read node files (camera/screen/contacts/calendar/reminders/SMS/file).", remediation: "Remove these entries from gateway.nodes.allowCommands (recommended). " + "If you keep them, treat gateway auth as full operator access and keep gateway exposure local/tailnet-only.",