diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 382c4046c93..cb6e242da04 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -81de442c9e0c902621f316297af3ad6c48a07c0c6bbfa5ca984419cfdb7f4292 plugin-sdk-api-baseline.json -136836d047bd0fb0723047945fdbd931a9128552ff49e8a27937d54dba0e9709 plugin-sdk-api-baseline.jsonl +06712d3ce26567af0adac8e48a9d43a1c9b43a4cff526e54d9c81a59ed274ef6 plugin-sdk-api-baseline.json +420538639d57e6fda499df8c32744f6870618983a094d6bcee48833bef31f52d plugin-sdk-api-baseline.jsonl diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 3dcd86429fc..e5ce896fbac 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -202,6 +202,12 @@ Dangerous or privacy-heavy commands such as `camera.snap`, `camera.clip`, and `gateway.nodes.allowCommands`. `gateway.nodes.denyCommands` always wins over defaults and extra allowlist entries. +Plugin-owned node commands can add a Gateway node-invoke policy. That policy +runs after the allowlist check and before forwarding to the node, so raw +`node.invoke`, CLI helpers, and dedicated agent tools share the same plugin +permission boundary. Dangerous plugin node commands still require explicit +`gateway.nodes.allowCommands` opt-in. + After a node changes its declared command list, reject the old device pairing and approve the new request so the gateway stores the updated command snapshot. diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 1742010ce18..5668bf72e29 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -178,7 +178,9 @@ Provider and channel execution paths must use the active runtime config snapshot }); ``` - Inside the Gateway this runtime is in-process. In plugin CLI commands it calls the configured Gateway over RPC, so commands such as `openclaw googlemeet recover-tab` can inspect paired nodes from the terminal. Node commands still go through normal Gateway node pairing, command allowlists, and node-local command handling. + Inside the Gateway this runtime is in-process. In plugin CLI commands it calls the configured Gateway over RPC, so commands such as `openclaw googlemeet recover-tab` can inspect paired nodes from the terminal. Node commands still go through normal Gateway node pairing, command allowlists, plugin node-invoke policies, and node-local command handling. + + Plugins that expose dangerous node-host commands should register a node-invoke policy with `api.registerNodeInvokePolicy(...)`. The policy runs in the Gateway after command allowlist checks and before the command is forwarded to the node, so direct `node.invoke` calls and higher-level plugin tools share the same enforcement path. diff --git a/extensions/file-transfer/index.ts b/extensions/file-transfer/index.ts index 4f2218efea5..8dc0da12faa 100644 --- a/extensions/file-transfer/index.ts +++ b/extensions/file-transfer/index.ts @@ -6,6 +6,7 @@ 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 { createFileTransferNodeInvokePolicy } from "./src/shared/node-invoke-policy.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"; @@ -15,6 +16,7 @@ const fileTransferNodeHostCommands: OpenClawPluginNodeHostCommand[] = [ { command: "file.fetch", cap: "file", + dangerous: true, handle: async (paramsJSON) => { const params = paramsJSON ? JSON.parse(paramsJSON) : {}; const result = await handleFileFetch(params); @@ -24,6 +26,7 @@ const fileTransferNodeHostCommands: OpenClawPluginNodeHostCommand[] = [ { command: "dir.list", cap: "file", + dangerous: true, handle: async (paramsJSON) => { const params = paramsJSON ? JSON.parse(paramsJSON) : {}; const result = await handleDirList(params); @@ -33,6 +36,7 @@ const fileTransferNodeHostCommands: OpenClawPluginNodeHostCommand[] = [ { command: "dir.fetch", cap: "file", + dangerous: true, handle: async (paramsJSON) => { const params = paramsJSON ? JSON.parse(paramsJSON) : {}; const result = await handleDirFetch(params); @@ -42,6 +46,7 @@ const fileTransferNodeHostCommands: OpenClawPluginNodeHostCommand[] = [ { command: "file.write", cap: "file", + dangerous: true, handle: async (paramsJSON) => { const params = paramsJSON ? JSON.parse(paramsJSON) : {}; const result = await handleFileWrite(params); @@ -56,6 +61,7 @@ export default definePluginEntry({ description: "Fetch, list, and write files on paired nodes via dedicated node commands.", nodeHostCommands: fileTransferNodeHostCommands, register(api) { + api.registerNodeInvokePolicy(createFileTransferNodeInvokePolicy()); api.registerTool(createFileFetchTool()); api.registerTool(createDirListTool()); api.registerTool(createDirFetchTool()); diff --git a/extensions/file-transfer/openclaw.plugin.json b/extensions/file-transfer/openclaw.plugin.json index 3d4cf93751e..a950dcc68a3 100644 --- a/extensions/file-transfer/openclaw.plugin.json +++ b/extensions/file-transfer/openclaw.plugin.json @@ -12,6 +12,39 @@ "configSchema": { "type": "object", "additionalProperties": false, - "properties": {} + "properties": { + "nodes": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "ask": { + "type": "string", + "enum": ["off", "on-miss", "always"] + }, + "allowReadPaths": { + "type": "array", + "items": { "type": "string" } + }, + "allowWritePaths": { + "type": "array", + "items": { "type": "string" } + }, + "denyPaths": { + "type": "array", + "items": { "type": "string" } + }, + "maxBytes": { + "type": "number" + }, + "followSymlinks": { + "type": "boolean", + "default": false + } + } + } + } + } } } diff --git a/extensions/file-transfer/src/node-host/dir-fetch.ts b/extensions/file-transfer/src/node-host/dir-fetch.ts index 22ae5f542e1..7aa7eff4aa5 100644 --- a/extensions/file-transfer/src/node-host/dir-fetch.ts +++ b/extensions/file-transfer/src/node-host/dir-fetch.ts @@ -169,7 +169,7 @@ export async function handleDirFetch(params: DirFetchParams): Promise.followSymlinks=true to allow, or update allowReadPaths to the canonical path)`, + message: `path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes..followSymlinks=true to allow, or update allowReadPaths to the canonical path)`, canonicalPath: canonical, }; } diff --git a/extensions/file-transfer/src/node-host/dir-list.ts b/extensions/file-transfer/src/node-host/dir-list.ts index 172cfcaa376..bc9523982a3 100644 --- a/extensions/file-transfer/src/node-host/dir-list.ts +++ b/extensions/file-transfer/src/node-host/dir-list.ts @@ -100,7 +100,7 @@ export async function handleDirList(params: DirListParams): Promise.followSymlinks=true to allow, or update allowReadPaths to the canonical path)`, + message: `path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes..followSymlinks=true to allow, or update allowReadPaths to the canonical path)`, canonicalPath: canonical, }; } diff --git a/extensions/file-transfer/src/node-host/file-fetch.ts b/extensions/file-transfer/src/node-host/file-fetch.ts index 4d459c1ee67..6e8f2150a08 100644 --- a/extensions/file-transfer/src/node-host/file-fetch.ts +++ b/extensions/file-transfer/src/node-host/file-fetch.ts @@ -118,7 +118,7 @@ export async function handleFileFetch(params: FileFetchParams): Promise.followSymlinks=true to allow, or update allowReadPaths to the canonical path)`, + message: `path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes..followSymlinks=true to allow, or update allowReadPaths to the canonical path)`, canonicalPath: canonical, }; } diff --git a/extensions/file-transfer/src/node-host/file-write.test.ts b/extensions/file-transfer/src/node-host/file-write.test.ts index 3742bd648bd..82a1fc52b82 100644 --- a/extensions/file-transfer/src/node-host/file-write.test.ts +++ b/extensions/file-transfer/src/node-host/file-write.test.ts @@ -37,20 +37,19 @@ describe("handleFileWrite — input validation", () => { const r = await handleFileWrite({ path: "/tmp/foo\0bar", contentBase64: b64("x") }); expect(r).toMatchObject({ ok: false, code: "INVALID_PATH" }); }); -}); -describe("handleFileWrite — zero-byte round-trip", () => { - it("writes an empty file when contentBase64 is empty string", async () => { + it("requires contentBase64 but allows an empty encoded payload", async () => { + const missing = await handleFileWrite({ path: path.join(tmpRoot, "missing.bin") }); + expect(missing).toMatchObject({ ok: false, code: "INVALID_BASE64" }); + const target = path.join(tmpRoot, "empty.bin"); - const r = await handleFileWrite({ path: target, contentBase64: "" }); - if (!r.ok) { - throw new Error(`expected ok, got ${r.code}: ${r.message}`); - } - expect(r.size).toBe(0); - // SHA-256 of empty input has a known fixed value. - expect(r.sha256).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); - const stat = await fs.stat(target); - expect(stat.size).toBe(0); + const empty = await handleFileWrite({ path: target, contentBase64: "" }); + expect(empty).toMatchObject({ + ok: true, + size: 0, + sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }); + expect(await fs.readFile(target)).toHaveLength(0); }); }); @@ -212,7 +211,7 @@ describe("handleFileWrite — symlink protection", () => { }); describe("handleFileWrite — integrity check", () => { - it("returns INTEGRITY_FAILURE without touching disk when expectedSha256 mismatches", async () => { + it("returns INTEGRITY_FAILURE before writing when expectedSha256 mismatches", async () => { const target = path.join(tmpRoot, "checked.txt"); const r = await handleFileWrite({ path: target, @@ -220,8 +219,7 @@ describe("handleFileWrite — integrity check", () => { expectedSha256: "0".repeat(64), }); expect(r).toMatchObject({ ok: false, code: "INTEGRITY_FAILURE" }); - // No file at the target — the hash check runs BEFORE the rename, so - // a bad caller hash never reaches disk. + // The file must never be created on a mismatch. await expect(fs.access(target)).rejects.toMatchObject({ code: "ENOENT" }); }); diff --git a/extensions/file-transfer/src/node-host/file-write.ts b/extensions/file-transfer/src/node-host/file-write.ts index 25b959e0ee4..2b1ae62156f 100644 --- a/extensions/file-transfer/src/node-host/file-write.ts +++ b/extensions/file-transfer/src/node-host/file-write.ts @@ -42,7 +42,8 @@ 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 hasContentBase64 = typeof params?.contentBase64 === "string"; + const contentBase64 = hasContentBase64 ? (params.contentBase64 as string) : ""; const overwrite = params?.overwrite === true; const createParents = params?.createParents === true; const expectedSha256 = @@ -59,6 +60,9 @@ export async function handleFileWrite( if (!path.isAbsolute(rawPath)) { return err("INVALID_PATH", "path must be absolute"); } + if (!hasContentBase64) { + return err("INVALID_BASE64", "contentBase64 is required"); + } // 2. Decode base64 → Buffer. // Buffer.from(s, "base64") in Node never throws — it silently drops @@ -130,7 +134,7 @@ export async function handleFileWrite( if (canonicalParent !== parentDir) { return err( "SYMLINK_REDIRECT", - `parent ${parentDir} resolves through a symlink to ${canonicalParent}; refusing because followSymlinks=false (set gateway.nodes.fileTransfer..followSymlinks=true to allow, or update allowWritePaths to the canonical path)`, + `parent ${parentDir} resolves through a symlink to ${canonicalParent}; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes..followSymlinks=true to allow, or update allowWritePaths to the canonical path)`, path.join(canonicalParent, path.basename(targetPath)), ); } diff --git a/extensions/file-transfer/src/shared/node-invoke-policy.test.ts b/extensions/file-transfer/src/shared/node-invoke-policy.test.ts new file mode 100644 index 00000000000..d0afa39753e --- /dev/null +++ b/extensions/file-transfer/src/shared/node-invoke-policy.test.ts @@ -0,0 +1,166 @@ +import type { OpenClawPluginNodeInvokePolicyContext } from "openclaw/plugin-sdk/plugin-entry"; +import { describe, expect, it, vi } from "vitest"; +import { createFileTransferNodeInvokePolicy } from "./node-invoke-policy.js"; + +vi.mock("./audit.js", () => ({ + appendFileTransferAudit: vi.fn(async () => undefined), +})); + +vi.mock("./policy.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + persistAllowAlways: vi.fn(async () => undefined), + }; +}); + +function createCtx(overrides: { + command?: string; + params?: Record; + pluginConfig?: Record; + approvals?: OpenClawPluginNodeInvokePolicyContext["approvals"]; +}) { + const invokeNode = vi.fn(async ({ params }: { params?: unknown } = {}) => ({ + ok: true as const, + payload: { + ok: true, + path: + typeof (params as { path?: unknown } | undefined)?.path === "string" + ? (params as { path: string }).path + : "/tmp/file.txt", + size: 1, + sha256: "a".repeat(64), + }, + })); + return { + ctx: { + nodeId: "node-1", + command: overrides.command ?? "file.fetch", + params: overrides.params ?? { path: "/tmp/file.txt", maxBytes: 1024 }, + config: {}, + pluginConfig: overrides.pluginConfig ?? { + nodes: { + "node-1": { + allowReadPaths: ["/tmp/**"], + allowWritePaths: ["/tmp/**"], + maxBytes: 512, + }, + }, + }, + node: { nodeId: "node-1", displayName: "Node One" }, + ...(overrides.approvals ? { approvals: overrides.approvals } : {}), + invokeNode, + }, + invokeNode, + }; +} + +describe("file-transfer node invoke policy", () => { + it("injects policy-owned limits before invoking the node", async () => { + const policy = createFileTransferNodeInvokePolicy(); + const { ctx, invokeNode } = createCtx({ + command: "file.fetch", + params: { path: "/tmp/file.txt", maxBytes: 4096, followSymlinks: true }, + }); + + const result = await policy.handle(ctx); + + expect(result.ok).toBe(true); + expect(invokeNode).toHaveBeenCalledWith({ + params: { + path: "/tmp/file.txt", + maxBytes: 512, + followSymlinks: false, + }, + }); + }); + + it("denies raw node.invoke before the node when plugin policy is missing", async () => { + const policy = createFileTransferNodeInvokePolicy(); + const { ctx, invokeNode } = createCtx({ pluginConfig: {} }); + + const result = await policy.handle(ctx); + + expect(result).toMatchObject({ ok: false, code: "NO_POLICY" }); + expect(invokeNode).not.toHaveBeenCalled(); + }); + + it("uses plugin approvals for ask-on-miss before invoking the node", async () => { + const policy = createFileTransferNodeInvokePolicy(); + const approvals = { + request: vi.fn(async () => ({ id: "approval-1", decision: "allow-once" as const })), + }; + const { ctx, invokeNode } = createCtx({ + params: { path: "/tmp/new.txt" }, + pluginConfig: { + nodes: { + "node-1": { + ask: "on-miss", + allowReadPaths: ["/allowed/**"], + }, + }, + }, + approvals, + }); + + const result = await policy.handle(ctx); + + expect(result.ok).toBe(true); + expect(approvals.request).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Read file: /tmp/new.txt", + severity: "info", + toolName: "file.fetch", + }), + ); + expect(invokeNode).toHaveBeenCalledWith({ + params: { + path: "/tmp/new.txt", + followSymlinks: false, + maxBytes: 8 * 1024 * 1024, + }, + }); + }); + + it("marks node transport failures as unavailable", async () => { + const policy = createFileTransferNodeInvokePolicy(); + const { ctx, invokeNode } = createCtx({ + params: { path: "/tmp/file.txt" }, + }); + invokeNode.mockResolvedValueOnce({ + ok: false, + code: "TIMEOUT", + message: "node timed out", + details: { nodeError: { code: "TIMEOUT" } }, + }); + + const result = await policy.handle(ctx); + + expect(result).toMatchObject({ + ok: false, + code: "TIMEOUT", + unavailable: true, + details: { nodeError: { code: "TIMEOUT" } }, + }); + }); + + it("rejects a postflight canonical path outside policy", async () => { + const policy = createFileTransferNodeInvokePolicy(); + const { ctx, invokeNode } = createCtx({ + params: { path: "/tmp/link.txt" }, + }); + invokeNode.mockResolvedValueOnce({ + ok: true, + payload: { + ok: true, + path: "/etc/passwd", + size: 1, + sha256: "a".repeat(64), + }, + }); + + const result = await policy.handle(ctx); + + expect(result).toMatchObject({ ok: false, code: "SYMLINK_TARGET_DENIED" }); + }); +}); diff --git a/extensions/file-transfer/src/shared/node-invoke-policy.ts b/extensions/file-transfer/src/shared/node-invoke-policy.ts new file mode 100644 index 00000000000..92e63a7e745 --- /dev/null +++ b/extensions/file-transfer/src/shared/node-invoke-policy.ts @@ -0,0 +1,347 @@ +import type { + OpenClawPluginNodeInvokePolicy, + OpenClawPluginNodeInvokePolicyContext, + OpenClawPluginNodeInvokePolicyResult, +} from "openclaw/plugin-sdk/plugin-entry"; +import { appendFileTransferAudit, type FileTransferAuditOp } from "./audit.js"; +import { evaluateFilePolicy, persistAllowAlways, type FilePolicyKind } from "./policy.js"; + +const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024; +const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024; +const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024; +const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024; + +type FileTransferCommand = "file.fetch" | "dir.list" | "dir.fetch" | "file.write"; + +const COMMANDS: FileTransferCommand[] = ["file.fetch", "dir.list", "dir.fetch", "file.write"]; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function readPath(params: Record): string { + return typeof params.path === "string" ? params.path.trim() : ""; +} + +function readMaxBytes(input: { + value: unknown; + defaultValue: number; + hardMax: number; + policyMax?: number; +}): number { + const requested = + typeof input.value === "number" && Number.isFinite(input.value) + ? Math.floor(input.value) + : input.defaultValue; + const clamped = Math.max(1, Math.min(requested, input.hardMax)); + return input.policyMax ? Math.min(clamped, input.policyMax) : clamped; +} + +function commandKind(command: FileTransferCommand): FilePolicyKind { + return command === "file.write" ? "write" : "read"; +} + +function promptVerb(command: FileTransferCommand): string { + switch (command) { + case "dir.fetch": + return "Fetch directory"; + case "dir.list": + return "List directory"; + case "file.write": + return "Write file"; + case "file.fetch": + return "Read file"; + } +} + +async function requestApproval(input: { + ctx: OpenClawPluginNodeInvokePolicyContext; + op: FileTransferAuditOp; + kind: FilePolicyKind; + path: string; + startedAt: number; +}): Promise< + | { ok: true; followSymlinks: boolean; maxBytes?: number } + | { ok: false; message: string; code: string } +> { + const nodeDisplayName = input.ctx.node?.displayName; + const decision = evaluateFilePolicy({ + nodeId: input.ctx.nodeId, + nodeDisplayName, + kind: input.kind, + path: input.path, + pluginConfig: input.ctx.pluginConfig, + }); + + if (decision.ok && decision.reason === "matched-allow") { + return { + ok: true, + followSymlinks: decision.followSymlinks, + maxBytes: decision.maxBytes, + }; + } + + const shouldAsk = + (decision.ok && decision.reason === "ask-always") || (!decision.ok && decision.askable); + if (!shouldAsk) { + await appendFileTransferAudit({ + op: input.op, + nodeId: input.ctx.nodeId, + nodeDisplayName, + requestedPath: input.path, + decision: + !decision.ok && decision.code === "NO_POLICY" ? "denied:no_policy" : "denied:policy", + errorCode: decision.ok ? undefined : decision.code, + reason: decision.ok ? decision.reason : decision.reason, + durationMs: Date.now() - input.startedAt, + }); + return { + ok: false, + code: decision.ok ? "POLICY_DENIED" : decision.code, + message: `${input.op} ${decision.ok ? "POLICY_DENIED" : decision.code}: ${decision.reason}`, + }; + } + + const approvals = input.ctx.approvals; + if (!approvals) { + await appendFileTransferAudit({ + op: input.op, + nodeId: input.ctx.nodeId, + nodeDisplayName, + requestedPath: input.path, + decision: "denied:approval", + reason: "plugin approvals unavailable", + durationMs: Date.now() - input.startedAt, + }); + return { + ok: false, + code: "APPROVAL_UNAVAILABLE", + message: `${input.op} APPROVAL_UNAVAILABLE: plugin approvals unavailable`, + }; + } + + const verb = promptVerb(input.op); + const subject = nodeDisplayName ?? input.ctx.nodeId; + const approval = await approvals.request({ + 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, + }); + + if (approval.decision === "deny" || approval.decision === null || !approval.decision) { + await appendFileTransferAudit({ + op: input.op, + nodeId: input.ctx.nodeId, + nodeDisplayName, + requestedPath: input.path, + decision: "denied:approval", + reason: approval.decision === "deny" ? "operator denied" : "no operator available", + durationMs: Date.now() - input.startedAt, + }); + return { + ok: false, + code: approval.decision === "deny" ? "APPROVAL_DENIED" : "APPROVAL_UNAVAILABLE", + message: + approval.decision === "deny" + ? `${input.op} APPROVAL_DENIED: operator denied the prompt` + : `${input.op} APPROVAL_UNAVAILABLE: no operator client connected to approve the request`, + }; + } + + if (approval.decision === "allow-always") { + try { + await persistAllowAlways({ + nodeId: input.ctx.nodeId, + nodeDisplayName, + kind: input.kind, + path: input.path, + }); + } catch (error) { + await appendFileTransferAudit({ + op: input.op, + nodeId: input.ctx.nodeId, + nodeDisplayName, + requestedPath: input.path, + decision: "allowed:always", + reason: `persist failed: ${String(error)}`, + durationMs: Date.now() - input.startedAt, + }); + return { + ok: true, + followSymlinks: decision.ok ? decision.followSymlinks : false, + maxBytes: decision.ok ? decision.maxBytes : undefined, + }; + } + } + + await appendFileTransferAudit({ + op: input.op, + nodeId: input.ctx.nodeId, + nodeDisplayName, + requestedPath: input.path, + decision: approval.decision === "allow-always" ? "allowed:always" : "allowed:once", + durationMs: Date.now() - input.startedAt, + }); + return { + ok: true, + followSymlinks: decision.ok ? decision.followSymlinks : false, + maxBytes: decision.ok ? decision.maxBytes : undefined, + }; +} + +function prepareParams(input: { + command: FileTransferCommand; + params: Record; + followSymlinks: boolean; + maxBytes?: number; +}): Record { + const next: Record = { + ...input.params, + followSymlinks: input.followSymlinks, + }; + if (input.command === "file.fetch") { + next.maxBytes = readMaxBytes({ + value: input.params.maxBytes, + defaultValue: FILE_FETCH_DEFAULT_MAX_BYTES, + hardMax: FILE_FETCH_HARD_MAX_BYTES, + policyMax: input.maxBytes, + }); + } else if (input.command === "dir.fetch") { + next.maxBytes = readMaxBytes({ + value: input.params.maxBytes, + defaultValue: DIR_FETCH_DEFAULT_MAX_BYTES, + hardMax: DIR_FETCH_HARD_MAX_BYTES, + policyMax: input.maxBytes, + }); + } + return next; +} + +async function handleFileTransferInvoke( + ctx: OpenClawPluginNodeInvokePolicyContext, +): Promise { + if (!COMMANDS.includes(ctx.command as FileTransferCommand)) { + return { ok: false, code: "UNSUPPORTED_COMMAND", message: "unsupported file-transfer command" }; + } + const command = ctx.command as FileTransferCommand; + const op: FileTransferAuditOp = command; + const params = asRecord(ctx.params); + const requestedPath = readPath(params); + const nodeDisplayName = ctx.node?.displayName; + const startedAt = Date.now(); + + if (!requestedPath) { + return { ok: false, code: "INVALID_PARAMS", message: `${op} path required` }; + } + + const gate = await requestApproval({ + ctx, + op, + kind: commandKind(command), + path: requestedPath, + startedAt, + }); + if (!gate.ok) { + return { ok: false, code: gate.code, message: gate.message }; + } + + const forwardedParams = prepareParams({ + command, + params, + followSymlinks: gate.followSymlinks, + maxBytes: gate.maxBytes, + }); + const result = await ctx.invokeNode({ params: forwardedParams }); + if (!result.ok) { + await appendFileTransferAudit({ + op, + nodeId: ctx.nodeId, + nodeDisplayName, + requestedPath, + decision: "error", + errorCode: result.code, + errorMessage: result.message, + durationMs: Date.now() - startedAt, + }); + return { + ok: false, + code: result.code, + message: `${op} failed: ${result.message}`, + details: result.details, + unavailable: true, + }; + } + + const payload = + result.payload && typeof result.payload === "object" && !Array.isArray(result.payload) + ? (result.payload as Record) + : null; + if (payload?.ok === false) { + await appendFileTransferAudit({ + op, + nodeId: ctx.nodeId, + nodeDisplayName, + requestedPath, + 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, + }); + return result; + } + + const canonicalPath = + payload && typeof payload.path === "string" && payload.path ? payload.path : requestedPath; + if (canonicalPath !== requestedPath) { + const postflight = evaluateFilePolicy({ + nodeId: ctx.nodeId, + nodeDisplayName, + kind: commandKind(command), + path: canonicalPath, + pluginConfig: ctx.pluginConfig, + }); + if (!postflight.ok) { + await appendFileTransferAudit({ + op, + nodeId: ctx.nodeId, + nodeDisplayName, + requestedPath, + canonicalPath, + decision: "denied:symlink_escape", + errorCode: postflight.code, + reason: postflight.reason, + durationMs: Date.now() - startedAt, + }); + return { + ok: false, + code: "SYMLINK_TARGET_DENIED", + message: `${op} SYMLINK_TARGET_DENIED: requested path resolved to ${canonicalPath} which is not allowed by policy`, + }; + } + } + + await appendFileTransferAudit({ + op, + nodeId: ctx.nodeId, + nodeDisplayName, + requestedPath, + canonicalPath, + decision: "allowed", + sizeBytes: typeof payload?.size === "number" ? payload.size : undefined, + sha256: typeof payload?.sha256 === "string" ? payload.sha256 : undefined, + durationMs: Date.now() - startedAt, + }); + + return result; +} + +export function createFileTransferNodeInvokePolicy(): OpenClawPluginNodeInvokePolicy { + return { + commands: COMMANDS, + handle: handleFileTransferInvoke, + }; +} diff --git a/extensions/file-transfer/src/shared/policy.test.ts b/extensions/file-transfer/src/shared/policy.test.ts index e4001d3b604..87fac69b949 100644 --- a/extensions/file-transfer/src/shared/policy.test.ts +++ b/extensions/file-transfer/src/shared/policy.test.ts @@ -2,7 +2,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -// Mock the plugin-sdk config-runtime surface so we can drive the policy +// Mock the plugin-sdk runtime-config surface so we can drive the policy // reader from the test without booting a gateway. mutateConfigFile is also // mocked so persistAllowAlways tests can assert what would have been written // without touching ~/.openclaw/openclaw.json. @@ -33,20 +33,26 @@ function withConfig(fileTransfer: Record | undefined) { getRuntimeConfigMock.mockReturnValue({}); } else { getRuntimeConfigMock.mockReturnValue({ - gateway: { nodes: { fileTransfer } }, + plugins: { + entries: { + "file-transfer": { + config: { nodes: fileTransfer }, + }, + }, + }, }); } } describe("evaluateFilePolicy — default deny", () => { - it("returns NO_POLICY when no gateway block is present", () => { + it("returns NO_POLICY when no plugin config block is present", () => { getRuntimeConfigMock.mockReturnValue({}); const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" }); expect(r).toMatchObject({ ok: false, code: "NO_POLICY", askable: false }); }); - it("returns NO_POLICY when fileTransfer block is missing", () => { - getRuntimeConfigMock.mockReturnValue({ gateway: { nodes: {} } }); + it("returns NO_POLICY when plugin policy block is missing", () => { + getRuntimeConfigMock.mockReturnValue({ plugins: { entries: { "file-transfer": {} } } }); const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" }); expect(r).toMatchObject({ ok: false, code: "NO_POLICY" }); }); @@ -56,6 +62,21 @@ describe("evaluateFilePolicy — default deny", () => { const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" }); expect(r).toMatchObject({ ok: false, code: "NO_POLICY" }); }); + + it("prefers the passed plugin config over the runtime config snapshot", () => { + getRuntimeConfigMock.mockReturnValue({}); + const r = evaluateFilePolicy({ + nodeId: "n1", + kind: "read", + path: "/tmp/x", + pluginConfig: { + nodes: { + n1: { allowReadPaths: ["/tmp/**"] }, + }, + }, + }); + expect(r).toMatchObject({ ok: true, reason: "matched-allow" }); + }); }); describe("evaluateFilePolicy — '..' traversal short-circuit", () => { @@ -275,7 +296,13 @@ describe("persistAllowAlways", () => { mutateConfigFileMock.mockImplementation( async ({ mutate }: { mutate: (draft: Record) => void }) => { const draft: Record = { - gateway: { nodes: { fileTransfer: { n1: { allowReadPaths: ["/tmp/**"] } } } }, + plugins: { + entries: { + "file-transfer": { + config: { nodes: { n1: { allowReadPaths: ["/tmp/**"] } } }, + }, + }, + }, }; mutate(draft); captured = draft; @@ -286,9 +313,17 @@ describe("persistAllowAlways", () => { expect(mutateConfigFileMock).toHaveBeenCalledOnce(); // Drill back into the captured draft to assert the added path. const root = captured as unknown as { - gateway: { nodes: { fileTransfer: Record } }; + plugins: { + entries: { + "file-transfer": { + config: { nodes: Record }; + }; + }; + }; }; - expect(root.gateway.nodes.fileTransfer.n1.allowReadPaths).toContain("/srv/added.png"); + expect(root.plugins.entries["file-transfer"].config.nodes.n1.allowReadPaths).toContain( + "/srv/added.png", + ); }); it("creates a new node entry keyed by displayName when no entry exists", async () => { @@ -309,9 +344,17 @@ describe("persistAllowAlways", () => { }); const root = captured as unknown as { - gateway: { nodes: { fileTransfer: Record } }; + plugins: { + entries: { + "file-transfer": { + config: { nodes: Record }; + }; + }; + }; }; - expect(root.gateway.nodes.fileTransfer["Lobster"].allowWritePaths).toContain("/srv/out.txt"); + expect(root.plugins.entries["file-transfer"].config.nodes["Lobster"].allowWritePaths).toContain( + "/srv/out.txt", + ); }); it("never persists under the '*' wildcard even when '*' is the matching key", async () => { @@ -319,7 +362,13 @@ describe("persistAllowAlways", () => { mutateConfigFileMock.mockImplementation( async ({ mutate }: { mutate: (draft: Record) => void }) => { const draft: Record = { - gateway: { nodes: { fileTransfer: { "*": { allowReadPaths: ["/var/log/**"] } } } }, + plugins: { + entries: { + "file-transfer": { + config: { nodes: { "*": { allowReadPaths: ["/var/log/**"] } } }, + }, + }, + }, }; mutate(draft); captured = draft; @@ -334,14 +383,22 @@ describe("persistAllowAlways", () => { }); const root = captured as unknown as { - gateway: { - nodes: { fileTransfer: Record }; + plugins: { + entries: { + "file-transfer": { + config: { nodes: Record }; + }; + }; }; }; // The "*" entry must not have been mutated. - expect(root.gateway.nodes.fileTransfer["*"].allowReadPaths).toEqual(["/var/log/**"]); + expect(root.plugins.entries["file-transfer"].config.nodes["*"].allowReadPaths).toEqual([ + "/var/log/**", + ]); // A new entry keyed by displayName (not "*") must hold the new path. - expect(root.gateway.nodes.fileTransfer["Lobster"].allowReadPaths).toEqual(["/srv/added.png"]); + expect(root.plugins.entries["file-transfer"].config.nodes["Lobster"].allowReadPaths).toEqual([ + "/srv/added.png", + ]); }); it("rejects unsafe keys (__proto__, prototype, constructor) that would mutate prototype chain", async () => { @@ -375,7 +432,13 @@ describe("persistAllowAlways", () => { mutateConfigFileMock.mockImplementation( async ({ mutate }: { mutate: (draft: Record) => void }) => { const draft: Record = { - gateway: { nodes: { fileTransfer: { n1: { allowReadPaths: ["/tmp/x"] } } } }, + plugins: { + entries: { + "file-transfer": { + config: { nodes: { n1: { allowReadPaths: ["/tmp/x"] } } }, + }, + }, + }, }; mutate(draft); captured = draft; @@ -384,9 +447,15 @@ describe("persistAllowAlways", () => { await persistAllowAlways({ nodeId: "n1", kind: "read", path: "/tmp/x" }); const root = captured as unknown as { - gateway: { nodes: { fileTransfer: Record } }; + plugins: { + entries: { + "file-transfer": { + config: { nodes: Record }; + }; + }; + }; }; - const list = root.gateway.nodes.fileTransfer.n1.allowReadPaths; + const list = root.plugins.entries["file-transfer"].config.nodes.n1.allowReadPaths; expect(list.filter((p) => p === "/tmp/x").length).toBe(1); }); }); diff --git a/extensions/file-transfer/src/shared/policy.ts b/extensions/file-transfer/src/shared/policy.ts index 4a4d2893bdd..f84a58daec2 100644 --- a/extensions/file-transfer/src/shared/policy.ts +++ b/extensions/file-transfer/src/shared/policy.ts @@ -1,24 +1,28 @@ -// Path policy for file-transfer tools. +// Path policy for file-transfer node.invoke calls. // // 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. +// `plugins.entries.file-transfer.config.nodes`. 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, -// "followSymlinks": false -// }, -// "*": { "ask": "on-miss" } +// "plugins": { +// "entries": { +// "file-transfer": { +// "config": { +// "nodes": { +// "": { +// "ask": "off" | "on-miss" | "always", +// "allowReadPaths": ["~/Screenshots/**", "/tmp/**"], +// "allowWritePaths": ["~/Downloads/**"], +// "denyPaths": ["**/.ssh/**", "**/.aws/**"], +// "maxBytes": 16777216, +// "followSymlinks": false +// }, +// "*": { "ask": "on-miss" } +// } +// } // } // } // } @@ -78,14 +82,46 @@ type NodeFilePolicyConfig = { type FilePolicyConfig = Record; -function readFilePolicyConfig(): FilePolicyConfig | null { - // gateway.nodes.fileTransfer is declared in src/config/types.gateway.ts - // so the cast through unknown the previous version needed is gone. - const fileTransfer = getRuntimeConfig().gateway?.nodes?.fileTransfer; - if (!fileTransfer) { +function asFilePolicyConfig(value: unknown): FilePolicyConfig | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } - return fileTransfer; + return value as FilePolicyConfig; +} + +function readFilePolicyConfigFromPluginConfig(pluginConfig: unknown): FilePolicyConfig | null { + if (!pluginConfig || typeof pluginConfig !== "object" || Array.isArray(pluginConfig)) { + return null; + } + const nodes = (pluginConfig as { nodes?: unknown }).nodes; + return asFilePolicyConfig(nodes); +} + +function readPluginConfigFromRuntimeConfig(): Record | null { + const cfg = getRuntimeConfig(); + const plugins = (cfg as { plugins?: unknown }).plugins; + if (!plugins || typeof plugins !== "object") { + return null; + } + const entries = (plugins as { entries?: unknown }).entries; + if (!entries || typeof entries !== "object") { + return null; + } + const entry = (entries as Record)["file-transfer"]; + if (!entry || typeof entry !== "object") { + return null; + } + const pluginConfig = (entry as { config?: unknown }).config; + return pluginConfig && typeof pluginConfig === "object" && !Array.isArray(pluginConfig) + ? (pluginConfig as Record) + : null; +} + +function readFilePolicyConfig(pluginConfig?: Record): FilePolicyConfig | null { + return ( + readFilePolicyConfigFromPluginConfig(pluginConfig) ?? + readFilePolicyConfigFromPluginConfig(readPluginConfigFromRuntimeConfig()) + ); } function expandTilde(p: string): string { @@ -143,7 +179,7 @@ function normalizeAskMode(value: unknown): FilePolicyAskMode { * Evaluate whether (nodeId, kind, path) is permitted. * * Resolution order: - * 1. No fileTransfer config or no entry for this node → NO_POLICY (deny, + * 1. No file-transfer 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). @@ -176,6 +212,7 @@ export function evaluateFilePolicy(input: { nodeDisplayName?: string; kind: FilePolicyKind; path: string; + pluginConfig?: Record; }): FilePolicyDecision { // Reject literal traversal sequences before consulting any allow/deny // glob list. minimatch on the raw string can wrongly accept @@ -188,13 +225,13 @@ export function evaluateFilePolicy(input: { askable: false, }; } - const config = readFilePolicyConfig(); + const config = readFilePolicyConfig(input.pluginConfig); if (!config) { return { ok: false, code: "NO_POLICY", reason: - "no gateway.nodes.fileTransfer config; file-transfer is deny-by-default until configured", + "no plugins.entries.file-transfer.config.nodes config; file-transfer is deny-by-default until configured", askable: false, }; } @@ -203,7 +240,7 @@ export function evaluateFilePolicy(input: { return { ok: false, code: "NO_POLICY", - reason: `no fileTransfer policy entry for "${input.nodeDisplayName ?? input.nodeId}"; configure gateway.nodes.fileTransfer or "*"`, + reason: `no file-transfer policy entry for "${input.nodeDisplayName ?? input.nodeId}"; configure plugins.entries.file-transfer.config.nodes or "*"`, askable: false, }; } @@ -280,7 +317,7 @@ export function evaluateFilePolicy(input: { * used as a property name (e.g. `__proto__` setter on a plain object). * The nodeDisplayName comes from paired-node metadata which we don't * fully control; refuse to persist policy under a key that could corrupt - * the fileTransfer container's prototype. + * the plugin policy container's prototype. */ function assertSafeConfigKey(key: string): string { if (key === "__proto__" || key === "prototype" || key === "constructor") { @@ -299,11 +336,14 @@ export async function persistAllowAlways(input: { await mutateConfigFile({ afterWrite: { mode: "none", reason: "file-transfer allow-always policy update" }, mutate: (draft) => { - // gateway.nodes.fileTransfer is declared in - // src/config/types.gateway.ts (GatewayNodeFileTransferEntry). - const gateway = (draft.gateway ??= {}); - const nodes = (gateway.nodes ??= {}); - const fileTransfer = (nodes.fileTransfer ??= {}); + // Plugin config is intentionally plugin-owned; the root OpenClawConfig + // type only guarantees `Record` here. + const root = draft as unknown as Record; + const plugins = (root.plugins ??= {}) as Record; + const entries = (plugins.entries ??= {}) as Record; + const pluginEntry = (entries["file-transfer"] ??= {}) as Record; + const pluginConfig = (pluginEntry.config ??= {}) as Record; + const fileTransfer = (pluginConfig.nodes ??= {}) as Record; // SECURITY: never persist allow-always under the "*" wildcard. An // operator approving a path on node A must not silently grant the diff --git a/extensions/file-transfer/src/tools/dir-fetch-tool.ts b/extensions/file-transfer/src/tools/dir-fetch-tool.ts index cfef88473de..169e110839b 100644 --- a/extensions/file-transfer/src/tools/dir-fetch-tool.ts +++ b/extensions/file-transfer/src/tools/dir-fetch-tool.ts @@ -13,7 +13,6 @@ 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, @@ -22,7 +21,6 @@ import { 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; @@ -387,7 +385,7 @@ export function createDirFetchTool(): AnyAgentTool { 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.", + "Retrieve a directory tree from a paired node as a gzipped tarball, unpack it on the gateway, and return a manifest of saved paths. Use to pull source trees, asset folders, or log directories in a single round-trip. The unpacked files live on the GATEWAY (not your local machine); pass localPath into other tools or use file_fetch on individual entries to ship them elsewhere. Rejects trees larger than 16 MB compressed. Requires operator opt-in: gateway.nodes.allowCommands must include 'dir.fetch' AND plugins.entries.file-transfer.config.nodes..allowReadPaths must match the directory path.", parameters: DirFetchToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; @@ -416,30 +414,13 @@ export function createDirFetchTool(): AnyAgentTool { 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, + maxBytes, includeDotfiles, - followSymlinks: gate.followSymlinks, }, idempotencyKey: crypto.randomUUID(), }); @@ -486,32 +467,6 @@ export function createDirFetchTool(): AnyAgentTool { 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( diff --git a/extensions/file-transfer/src/tools/dir-list-tool.ts b/extensions/file-transfer/src/tools/dir-list-tool.ts index 3e17a5b0e9e..aa9e1ad52d6 100644 --- a/extensions/file-transfer/src/tools/dir-list-tool.ts +++ b/extensions/file-transfer/src/tools/dir-list-tool.ts @@ -9,9 +9,7 @@ import { 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; @@ -44,7 +42,7 @@ export function createDirListTool(): AnyAgentTool { 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.", + "Retrieve a structured directory listing from a paired node. Returns file and subdirectory metadata (name, path, size, mimeType, isDir, mtime) without transferring file content. Use this to discover what files exist before fetching them with file_fetch. Pagination is offset-based; pass nextPageToken from the previous result. Requires operator opt-in: gateway.nodes.allowCommands must include 'dir.list' AND plugins.entries.file-transfer.config.nodes..allowReadPaths must match the directory path. Without policy configured, every call is denied.", parameters: DirListToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; @@ -77,21 +75,6 @@ export function createDirListTool(): AnyAgentTool { 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", @@ -99,7 +82,6 @@ export function createDirListTool(): AnyAgentTool { path: dirPath, pageToken, maxEntries, - followSymlinks: gate.followSymlinks, }, idempotencyKey: crypto.randomUUID(), }); @@ -138,32 +120,6 @@ export function createDirListTool(): AnyAgentTool { 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>) : []; diff --git a/extensions/file-transfer/src/tools/file-fetch-tool.ts b/extensions/file-transfer/src/tools/file-fetch-tool.ts index 82e8d7b4b23..404f4a4b2ec 100644 --- a/extensions/file-transfer/src/tools/file-fetch-tool.ts +++ b/extensions/file-transfer/src/tools/file-fetch-tool.ts @@ -10,14 +10,12 @@ 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; @@ -48,7 +46,7 @@ export function createFileFetchTool(): AnyAgentTool { 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.", + "Retrieve a file from a paired node by absolute path. Returns image content blocks for image MIME types, inlines small text files (≤8 KB) as text content, and saves everything else under the gateway media store with a path you can pass to file_write or other tools. Use this for screenshots, photos, receipts, logs, source files. Pair with file_write to copy a file from one node to another (no exec/cp shell-out needed). Requires operator opt-in: gateway.nodes.allowCommands must include 'file.fetch' AND plugins.entries.file-transfer.config.nodes..allowReadPaths must match the path. Without policy configured, every call is denied.", parameters: FileFetchToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; @@ -73,32 +71,12 @@ export function createFileFetchTool(): AnyAgentTool { 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, - followSymlinks: gate.followSymlinks, + maxBytes, }, idempotencyKey: crypto.randomUUID(), }); @@ -148,34 +126,6 @@ export function createFileFetchTool(): AnyAgentTool { 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( @@ -237,6 +187,7 @@ export function createFileFetchTool(): AnyAgentTool { mimeType, sha256, localPath, + mediaId: saved.id, 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 index 895bbb26b54..384d1be4baa 100644 --- a/extensions/file-transfer/src/tools/file-write-tool.ts +++ b/extensions/file-transfer/src/tools/file-write-tool.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import fs from "node:fs/promises"; import { callGatewayTool, listNodes, @@ -6,26 +7,35 @@ import { type AnyAgentTool, type NodeListNode, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { resolveMediaBufferPath } 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 { humanSize, readBoolean, readGatewayCallOptions, readTrimmedString, } from "../shared/params.js"; -import { evaluateFilePolicy } from "../shared/policy.js"; + +const FILE_WRITE_HARD_MAX_BYTES = 16 * 1024 * 1024; 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.", - }), + contentBase64: Type.Optional( + Type.String({ + description: "Base64-encoded bytes to write. Maximum 16 MB after decode.", + }), + ), + sourceMediaId: Type.Optional( + Type.String({ + description: + "Media id returned by file_fetch. Preferred for binary copies because bytes stay in the gateway media store.", + }), + ), mimeType: Type.Optional( Type.String({ description: "Content type hint. Not validated against the content.", @@ -45,6 +55,29 @@ const FILE_WRITE_SCHEMA = Type.Object({ ), }); +async function readSourceBytes(input: { + contentBase64?: string; + sourceMediaId?: string; +}): Promise<{ buffer: Buffer; contentBase64: string; source: "inline" | "media" }> { + const sourceMediaId = input.sourceMediaId?.trim(); + if (sourceMediaId) { + const mediaPath = await resolveMediaBufferPath(sourceMediaId, "file-transfer"); + const stat = await fs.stat(mediaPath); + if (stat.size > FILE_WRITE_HARD_MAX_BYTES) { + throw new Error( + `sourceMediaId too large: ${stat.size} bytes; maximum is ${FILE_WRITE_HARD_MAX_BYTES} bytes`, + ); + } + const buffer = await fs.readFile(mediaPath); + return { buffer, contentBase64: buffer.toString("base64"), source: "media" }; + } + if (input.contentBase64 === undefined) { + throw new Error("contentBase64 or sourceMediaId required"); + } + const buffer = Buffer.from(input.contentBase64, "base64"); + return { buffer, contentBase64: input.contentBase64, source: "inline" }; +} + type FileWriteSuccess = { ok: true; path: string; @@ -67,7 +100,7 @@ export function createFileWriteTool(): AnyAgentTool { 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.", + "Write file bytes to a paired node by absolute path. Atomic write (temp + rename). Refuses to overwrite by default — pass overwrite=true to replace. Refuses to write through symlink targets unless policy explicitly allows following symlinks. Pair with file_fetch by passing its mediaId as sourceMediaId for binary copy. Requires operator opt-in: gateway.nodes.allowCommands must include 'file.write' AND plugins.entries.file-transfer.config.nodes..allowWritePaths must match the destination path. Without policy configured, every call is denied.", parameters: FILE_WRITE_SCHEMA, async execute(_toolCallId, params) { const raw: Record = @@ -77,14 +110,10 @@ export function createFileWriteTool(): AnyAgentTool { const nodeQuery = readTrimmedString(raw, "node"); const filePath = readTrimmedString(raw, "path"); - // Type-check, NOT truthy-check: empty string is the valid base64 - // representation of a zero-byte file, and rejecting "" here would - // make zero-byte writes impossible round-trip from file_fetch. - const contentBase64Raw = raw.contentBase64; - if (typeof contentBase64Raw !== "string") { - throw new Error("contentBase64 required (string, may be empty for zero-byte files)"); - } - const contentBase64 = contentBase64Raw; + const contentBase64 = + typeof raw.contentBase64 === "string" ? (raw.contentBase64 as string) : undefined; + const sourceMediaId = + typeof raw.sourceMediaId === "string" ? (raw.sourceMediaId as string) : undefined; const overwrite = readBoolean(raw, "overwrite", false); const createParents = readBoolean(raw, "createParents", false); @@ -94,13 +123,13 @@ export function createFileWriteTool(): AnyAgentTool { if (!filePath) { throw new Error("path 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 sourceBytes = await readSourceBytes({ contentBase64, sourceMediaId }); + const buffer = sourceBytes.buffer; const expectedSha256 = crypto.createHash("sha256").update(buffer).digest("hex"); const gatewayOpts = readGatewayCallOptions(raw); @@ -110,31 +139,15 @@ export function createFileWriteTool(): AnyAgentTool { 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, + contentBase64: sourceBytes.contentBase64, overwrite, createParents, expectedSha256, - followSymlinks: gate.followSymlinks, }, idempotencyKey: crypto.randomUUID(), }); @@ -171,39 +184,6 @@ export function createFileWriteTool(): AnyAgentTool { 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, @@ -224,7 +204,7 @@ export function createFileWriteTool(): AnyAgentTool { text: `Wrote ${typed.path} (${humanSize(typed.size)}, sha256:${typed.sha256.slice(0, 12)})${overwriteNote}`, }, ], - details: typed, + details: { ...typed, source: sourceBytes.source }, }; }, }; diff --git a/src/agents/tools/nodes-tool-media.ts b/src/agents/tools/nodes-tool-media.ts index 8fcc9d2cc32..29abc2d9b22 100644 --- a/src/agents/tools/nodes-tool-media.ts +++ b/src/agents/tools/nodes-tool-media.ts @@ -27,23 +27,17 @@ export const MEDIA_INVOKE_ACTIONS = { "camera.clip": "camera_clip", "photos.latest": "photos_latest", "screen.record": "screen_record", - // file-transfer commands: redirect to dedicated tools so the path policy - // + operator approval flow always runs. Without this, an agent could - // call them via the generic nodes.action="invoke" surface and skip - // gatekeep() entirely. + // file-transfer commands: redirect to dedicated tools for better result + // formatting and media-store handling. The gateway still enforces the + // underlying node-invoke path policy for raw callers. "file.fetch": "file_fetch", "dir.list": "dir_list", "dir.fetch": "dir_fetch", "file.write": "file_write", } as const; -// Subset of MEDIA_INVOKE_ACTIONS where the dedicated tool enforces a -// security policy (path allowlist + operator approval), not just bloat -// avoidance. These commands MUST always redirect, even when the operator -// has set allowMediaInvokeCommands=true (which only suppresses the -// base64-bloat redirect, not policy enforcement). The generic -// nodes.invoke surface would otherwise bypass gatekeep() entirely and -// return raw payloads outside the file-transfer allowlist. +// Subset of MEDIA_INVOKE_ACTIONS where the dedicated tool is the preferred +// agent UX. Gateway node-invoke policy still protects raw node.invoke callers. export const POLICY_REDIRECT_INVOKE_COMMANDS: ReadonlySet = new Set([ "file.fetch", "dir.list", diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index cf585ff5a8c..ded31eaae73 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -1,13 +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"; +import { getActiveRuntimePluginRegistry } from "../plugins/active-runtime-registry.js"; import { normalizeDeviceMetadataForPolicy } from "./device-metadata-normalization.js"; import type { NodeSession } from "./node-registry.js"; @@ -52,17 +49,6 @@ 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]; @@ -87,7 +73,6 @@ export const DEFAULT_DANGEROUS_NODE_COMMANDS = [ ...CALENDAR_DANGEROUS_COMMANDS, ...REMINDERS_DANGEROUS_COMMANDS, ...SMS_DANGEROUS_COMMANDS, - ...FILE_DANGEROUS_COMMANDS, ]; const PLATFORM_DEFAULTS: Record = { @@ -198,6 +183,20 @@ function normalizePlatformId(platform?: string, deviceFamily?: string): Platform return byFamily ?? "unknown"; } +export function listDangerousPluginNodeCommands(): string[] { + const registry = getActiveRuntimePluginRegistry(); + if (!registry) { + return []; + } + const commands = [ + ...(registry.nodeHostCommands ?? []) + .filter((entry) => entry.command.dangerous === true) + .map((entry) => entry.command.command), + ...(registry.nodeInvokePolicies ?? []).flatMap((entry) => entry.policy.commands), + ]; + return [...new Set(commands.map((command) => command.trim()).filter(Boolean))]; +} + export function resolveNodeCommandAllowlist( cfg: OpenClawConfig, node?: Pick, @@ -206,7 +205,18 @@ export function resolveNodeCommandAllowlist( const base = PLATFORM_DEFAULTS[platformId] ?? PLATFORM_DEFAULTS.unknown; const extra = cfg.gateway?.nodes?.allowCommands ?? []; const deny = new Set(cfg.gateway?.nodes?.denyCommands ?? []); - const allow = new Set([...base, ...extra].map((cmd) => cmd.trim()).filter(Boolean)); + const dangerousPluginCommands = new Set(listDangerousPluginNodeCommands()); + const allow = new Set( + [...base, ...extra] + .map((cmd) => cmd.trim()) + .filter((cmd) => cmd && !dangerousPluginCommands.has(cmd)), + ); + for (const cmd of extra) { + const trimmed = cmd.trim(); + if (trimmed) { + allow.add(trimmed); + } + } for (const blocked of deny) { const trimmed = blocked.trim(); if (trimmed) { diff --git a/src/gateway/node-invoke-plugin-policy.ts b/src/gateway/node-invoke-plugin-policy.ts new file mode 100644 index 00000000000..064b06375f4 --- /dev/null +++ b/src/gateway/node-invoke-plugin-policy.ts @@ -0,0 +1,149 @@ +import { randomUUID } from "node:crypto"; +import type { PluginApprovalRequestPayload } from "../infra/plugin-approvals.js"; +import { DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS } from "../infra/plugin-approvals.js"; +import { getActiveRuntimePluginRegistry } from "../plugins/active-runtime-registry.js"; +import type { + OpenClawPluginNodeInvokePolicyContext, + OpenClawPluginNodeInvokePolicyResult, + OpenClawPluginNodeInvokeTransportResult, +} from "../plugins/types.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; +import type { NodeSession } from "./node-registry.js"; +import type { GatewayClient, GatewayRequestContext } from "./server-methods/types.js"; + +function parseScopes(client: GatewayClient | null): string[] { + return Array.isArray(client?.connect?.scopes) + ? client.connect.scopes.filter((scope): scope is string => typeof scope === "string") + : []; +} + +function parsePayload(payloadJSON: string | null | undefined, payload: unknown): unknown { + if (!payloadJSON) { + return payload; + } + try { + return JSON.parse(payloadJSON) as unknown; + } catch { + return payload; + } +} + +function createApprovalRuntime(params: { + context: GatewayRequestContext; + client: GatewayClient | null; + pluginId: string; +}): OpenClawPluginNodeInvokePolicyContext["approvals"] | undefined { + const manager = params.context.pluginApprovalManager; + if (!manager) { + return undefined; + } + return { + async request(input) { + const timeoutMs = + typeof input.timeoutMs === "number" && Number.isFinite(input.timeoutMs) + ? input.timeoutMs + : DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS; + const request: PluginApprovalRequestPayload = { + pluginId: params.pluginId, + title: input.title.slice(0, 80), + description: input.description.slice(0, 256), + severity: input.severity ?? "warning", + toolName: normalizeOptionalString(input.toolName) ?? null, + toolCallId: normalizeOptionalString(input.toolCallId) ?? null, + agentId: normalizeOptionalString(input.agentId) ?? null, + sessionKey: normalizeOptionalString(input.sessionKey) ?? null, + }; + const record = manager.create(request, timeoutMs, `plugin:${randomUUID()}`); + const decisionPromise = manager.register(record, timeoutMs); + const requestEvent = { + id: record.id, + request: record.request, + createdAtMs: record.createdAtMs, + expiresAtMs: record.expiresAtMs, + }; + params.context.broadcast("plugin.approval.requested", requestEvent, { + dropIfSlow: true, + }); + const hasApprovalClients = + params.context.hasExecApprovalClients?.(params.client?.connId) ?? false; + if (!hasApprovalClients) { + manager.expire(record.id, "no-approval-route"); + return { id: record.id, decision: null }; + } + const decision = await decisionPromise; + return { id: record.id, decision }; + }, + }; +} + +export async function applyPluginNodeInvokePolicy(params: { + context: GatewayRequestContext; + client: GatewayClient | null; + nodeSession: NodeSession; + command: string; + params: unknown; + timeoutMs?: number; + idempotencyKey?: string; +}): Promise { + const registry = getActiveRuntimePluginRegistry(); + const entry = registry?.nodeInvokePolicies?.find((candidate) => + candidate.policy.commands.includes(params.command), + ); + if (!entry) { + return null; + } + + const invokeNode: OpenClawPluginNodeInvokePolicyContext["invokeNode"] = async ( + override = {}, + ): Promise => { + const res = await params.context.nodeRegistry.invoke({ + nodeId: params.nodeSession.nodeId, + command: params.command, + params: override.params ?? params.params, + timeoutMs: override.timeoutMs ?? params.timeoutMs, + idempotencyKey: override.idempotencyKey ?? params.idempotencyKey, + }); + if (!res.ok) { + return { + ok: false, + code: res.error?.code, + message: res.error?.message ?? "node command failed", + details: { nodeError: res.error ?? null }, + }; + } + return { + ok: true, + payload: parsePayload(res.payloadJSON, res.payload), + payloadJSON: res.payloadJSON ?? null, + }; + }; + + return await entry.policy.handle({ + nodeId: params.nodeSession.nodeId, + command: params.command, + params: params.params, + timeoutMs: params.timeoutMs, + idempotencyKey: params.idempotencyKey, + config: params.context.getRuntimeConfig(), + pluginConfig: entry.pluginConfig, + node: { + nodeId: params.nodeSession.nodeId, + displayName: params.nodeSession.displayName, + platform: params.nodeSession.platform, + deviceFamily: params.nodeSession.deviceFamily, + commands: params.nodeSession.commands, + }, + client: params.client + ? { + connId: params.client.connId, + scopes: parseScopes(params.client), + } + : null, + approvals: createApprovalRuntime({ + context: params.context, + client: params.client, + pluginId: entry.pluginId, + }), + invokeNode, + }); +} diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 4d7ba9e6adc..765c8881509 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -36,6 +36,7 @@ import { } from "../file-transfer-dispatch.js"; import { createKnownNodeCatalog, getKnownNode, listKnownNodes } from "../node-catalog.js"; import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js"; +import { applyPluginNodeInvokePolicy } from "../node-invoke-plugin-policy.js"; import { sanitizeNodeInvokeParamsForForwarding } from "../node-invoke-sanitize.js"; import { type ConnectParams, @@ -1094,6 +1095,45 @@ export const nodeHandlers: GatewayRequestHandlers = { ); return; } + const policyResult = await applyPluginNodeInvokePolicy({ + context, + client, + nodeSession, + command, + params: forwardedParams.params, + timeoutMs: p.timeoutMs, + idempotencyKey: p.idempotencyKey, + }); + if (policyResult) { + if (!policyResult.ok) { + const errorCode = policyResult.unavailable + ? ErrorCodes.UNAVAILABLE + : ErrorCodes.INVALID_REQUEST; + respond( + false, + undefined, + errorShape(errorCode, policyResult.message, { + details: { + ...(policyResult.details ?? {}), + ...(policyResult.code ? { code: policyResult.code } : {}), + }, + }), + ); + return; + } + respond( + true, + { + ok: true, + nodeId, + command, + payload: policyResult.payload, + payloadJSON: policyResult.payloadJSON ?? null, + }, + undefined, + ); + return; + } const res = await context.nodeRegistry.invoke({ nodeId, command, diff --git a/src/infra/node-commands.ts b/src/infra/node-commands.ts index c4eda3bb584..3aa35051d2d 100644 --- a/src/infra/node-commands.ts +++ b/src/infra/node-commands.ts @@ -11,14 +11,3 @@ 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/media/store.ts b/src/media/store.ts index 4c6a66a4b4a..f6462bb5da0 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -455,10 +455,7 @@ export async function saveMediaBuffer( * @throws If the ID is unsafe, the file does not exist, or is not a * regular file. */ -export async function resolveMediaBufferPath( - id: string, - subdir: "inbound" = "inbound", -): Promise { +export async function resolveMediaBufferPath(id: string, subdir = "inbound"): Promise { // Guard against path traversal and null-byte injection. // // - Separator checks: reject any ID containing "/" or "\" (covers all diff --git a/src/plugin-sdk/media-store.ts b/src/plugin-sdk/media-store.ts index 4814d9ca5d8..b6fd6f58c5a 100644 --- a/src/plugin-sdk/media-store.ts +++ b/src/plugin-sdk/media-store.ts @@ -1,3 +1,3 @@ // Narrow media store helpers for channel runtimes that do not need the full media runtime. -export { saveMediaBuffer } from "../media/store.js"; +export { resolveMediaBufferPath, saveMediaBuffer } from "../media/store.js"; diff --git a/src/plugin-sdk/plugin-entry.ts b/src/plugin-sdk/plugin-entry.ts index 4a9ce81f519..eabfcdeddf9 100644 --- a/src/plugin-sdk/plugin-entry.ts +++ b/src/plugin-sdk/plugin-entry.ts @@ -18,6 +18,9 @@ import type { OpenClawPluginDefinition, OpenClawPluginHttpRouteHandler, OpenClawPluginNodeHostCommand, + OpenClawPluginNodeInvokePolicy, + OpenClawPluginNodeInvokePolicyContext, + OpenClawPluginNodeInvokePolicyResult, OpenClawPluginReloadRegistration, OpenClawPluginSecurityAuditCollector, OpenClawPluginSecurityAuditContext, @@ -116,6 +119,9 @@ export type { MigrationSummary, OpenClawPluginApi, OpenClawPluginNodeHostCommand, + OpenClawPluginNodeInvokePolicy, + OpenClawPluginNodeInvokePolicyContext, + OpenClawPluginNodeInvokePolicyResult, OpenClawPluginReloadRegistration, OpenClawPluginSecurityAuditCollector, OpenClawPluginSecurityAuditContext, diff --git a/src/plugin-sdk/plugin-test-api.ts b/src/plugin-sdk/plugin-test-api.ts index ad86ce47903..a72a9d0ca54 100644 --- a/src/plugin-sdk/plugin-test-api.ts +++ b/src/plugin-sdk/plugin-test-api.ts @@ -23,6 +23,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi registerGatewayDiscoveryService() {}, registerReload() {}, registerNodeHostCommand() {}, + registerNodeInvokePolicy() {}, registerSecurityAuditCollector() {}, registerConfigMigration() {}, registerMigrationProvider() {}, diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index b0eb4483e3d..4eef0935bd2 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -26,6 +26,7 @@ export type BuildPluginApiParams = { | "registerCli" | "registerReload" | "registerNodeHostCommand" + | "registerNodeInvokePolicy" | "registerSecurityAuditCollector" | "registerService" | "registerGatewayDiscoveryService" @@ -84,6 +85,7 @@ const noopRegisterGatewayMethod: OpenClawPluginApi["registerGatewayMethod"] = () const noopRegisterCli: OpenClawPluginApi["registerCli"] = () => {}; const noopRegisterReload: OpenClawPluginApi["registerReload"] = () => {}; const noopRegisterNodeHostCommand: OpenClawPluginApi["registerNodeHostCommand"] = () => {}; +const noopRegisterNodeInvokePolicy: OpenClawPluginApi["registerNodeInvokePolicy"] = () => {}; const noopRegisterSecurityAuditCollector: OpenClawPluginApi["registerSecurityAuditCollector"] = () => {}; const noopRegisterService: OpenClawPluginApi["registerService"] = () => {}; @@ -171,6 +173,7 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi registerCli: handlers.registerCli ?? noopRegisterCli, registerReload: handlers.registerReload ?? noopRegisterReload, registerNodeHostCommand: handlers.registerNodeHostCommand ?? noopRegisterNodeHostCommand, + registerNodeInvokePolicy: handlers.registerNodeInvokePolicy ?? noopRegisterNodeInvokePolicy, registerSecurityAuditCollector: handlers.registerSecurityAuditCollector ?? noopRegisterSecurityAuditCollector, registerService: handlers.registerService ?? noopRegisterService, diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index f2dae8150ee..348c8d5eec8 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -31,6 +31,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { cliRegistrars: [], reloads: [], nodeHostCommands: [], + nodeInvokePolicies: [], securityAuditCollectors: [], services: [], gatewayDiscoveryServices: [], diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 491b20bce71..16a63f5e264 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -230,6 +230,15 @@ export type PluginNodeHostCommandRegistration = { rootDir?: string; }; +export type PluginNodeInvokePolicyRegistration = { + pluginId: string; + pluginName?: string; + policy: import("./types.js").OpenClawPluginNodeInvokePolicy; + pluginConfig?: Record; + source: string; + rootDir?: string; +}; + export type PluginSecurityAuditCollectorRegistration = { pluginId: string; pluginName?: string; @@ -399,6 +408,7 @@ export type PluginRegistry = { cliRegistrars: PluginCliRegistration[]; reloads?: PluginReloadRegistration[]; nodeHostCommands?: PluginNodeHostCommandRegistration[]; + nodeInvokePolicies?: PluginNodeInvokePolicyRegistration[]; securityAuditCollectors?: PluginSecurityAuditCollectorRegistration[]; services: PluginServiceRegistration[]; gatewayDiscoveryServices: PluginGatewayDiscoveryServiceRegistration[]; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 5f5c24f5bf1..49eff042189 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -109,6 +109,7 @@ import type { PluginHttpRouteRegistration as RegistryTypesPluginHttpRouteRegistration, PluginAgentHarnessRegistration, PluginMemoryEmbeddingProviderRegistration, + PluginNodeInvokePolicyRegistration, PluginNodeHostCommandRegistration, PluginProviderRegistration, PluginRecord, @@ -147,6 +148,7 @@ import type { OpenClawPluginHttpRouteParams, OpenClawPluginHookOptions, OpenClawPluginNodeHostCommand, + OpenClawPluginNodeInvokePolicy, OpenClawPluginReloadRegistration, OpenClawPluginSecurityAuditCollector, MediaUnderstandingProviderPlugin, @@ -1240,6 +1242,57 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; + const registerNodeInvokePolicy = ( + record: PluginRecord, + policy: OpenClawPluginNodeInvokePolicy, + pluginConfig?: Record, + ) => { + const commands = Array.isArray(policy.commands) + ? policy.commands.map((command) => command.trim()).filter(Boolean) + : []; + if (commands.length === 0) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "node invoke policy registration missing commands", + }); + return; + } + if (typeof policy.handle !== "function") { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `node invoke policy registration missing handler: ${commands.join(", ")}`, + }); + return; + } + registry.nodeInvokePolicies ??= []; + for (const command of commands) { + const existing = registry.nodeInvokePolicies.find((entry) => + entry.policy.commands.includes(command), + ); + if (existing) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `node invoke policy already registered for ${command} (${existing.pluginId})`, + }); + return; + } + } + registry.nodeInvokePolicies.push({ + pluginId: record.id, + pluginName: record.name, + policy: { ...policy, commands }, + pluginConfig, + source: record.source, + rootDir: record.rootDir, + }); + }; + const registerSecurityAuditCollector = ( record: PluginRecord, collector: OpenClawPluginSecurityAuditCollector, @@ -2068,6 +2121,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerTextTransforms: (transforms) => registerTextTransforms(record, transforms), registerReload: (registration) => registerReload(record, registration), registerNodeHostCommand: (command) => registerNodeHostCommand(record, command), + registerNodeInvokePolicy: (policy) => + registerNodeInvokePolicy(record, policy, params.pluginConfig), registerSecurityAuditCollector: (collector) => registerSecurityAuditCollector(record, collector), registerInteractiveHandler: (registration) => { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 1a2c9a11ff7..fdf2c68f4d0 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -2045,9 +2045,89 @@ export type OpenClawPluginReloadRegistration = { export type OpenClawPluginNodeHostCommand = { command: string; cap?: string; + dangerous?: boolean; handle: (paramsJSON?: string | null) => Promise; }; +export type OpenClawPluginNodeInvokeTransportResult = + | { + ok: true; + payload?: unknown; + payloadJSON?: string | null; + } + | { + ok: false; + code?: string; + message: string; + details?: Record; + }; + +export type OpenClawPluginNodeInvokeApprovalDecision = "allow-once" | "allow-always" | "deny"; + +export type OpenClawPluginNodeInvokePolicyApprovalRuntime = { + request: (input: { + title: string; + description: string; + severity?: "info" | "warning" | "critical"; + toolName?: string; + toolCallId?: string; + agentId?: string; + sessionKey?: string; + timeoutMs?: number; + }) => Promise<{ + id?: string; + decision?: OpenClawPluginNodeInvokeApprovalDecision | null; + }>; +}; + +export type OpenClawPluginNodeInvokePolicyContext = { + nodeId: string; + command: string; + params: unknown; + timeoutMs?: number; + idempotencyKey?: string; + config: OpenClawConfig; + pluginConfig?: Record; + node?: { + nodeId: string; + displayName?: string; + platform?: string; + deviceFamily?: string; + commands?: string[]; + }; + client?: { + connId?: string; + scopes?: string[]; + } | null; + approvals?: OpenClawPluginNodeInvokePolicyApprovalRuntime; + invokeNode: (input?: { + params?: unknown; + timeoutMs?: number; + idempotencyKey?: string; + }) => Promise; +}; + +export type OpenClawPluginNodeInvokePolicyResult = + | { + ok: true; + payload?: unknown; + payloadJSON?: string | null; + } + | { + ok: false; + message: string; + code?: string; + details?: Record; + unavailable?: boolean; + }; + +export type OpenClawPluginNodeInvokePolicy = { + commands: string[]; + handle: ( + ctx: OpenClawPluginNodeInvokePolicyContext, + ) => Promise | OpenClawPluginNodeInvokePolicyResult; +}; + export type OpenClawPluginSecurityAuditContext = { config: OpenClawConfig; sourceConfig: OpenClawConfig; @@ -2318,6 +2398,7 @@ export type OpenClawPluginApi = { ) => void; registerReload: (registration: OpenClawPluginReloadRegistration) => void; registerNodeHostCommand: (command: OpenClawPluginNodeHostCommand) => void; + registerNodeInvokePolicy: (policy: OpenClawPluginNodeInvokePolicy) => void; registerSecurityAuditCollector: (collector: OpenClawPluginSecurityAuditCollector) => void; registerService: (service: OpenClawPluginService) => void; /** Register a local gateway discovery advertiser such as mDNS/Bonjour. */ diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index be400e8e0c7..d99e879f1e9 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -12,6 +12,7 @@ import { resolveGatewayAuth } from "../gateway/auth.js"; import { resolveAllowedAgentIds } from "../gateway/hooks-policy.js"; import { DEFAULT_DANGEROUS_NODE_COMMANDS, + listDangerousPluginNodeCommands, resolveNodeCommandAllowlist, } from "../gateway/node-command-policy.js"; import { @@ -868,9 +869,10 @@ export function collectNodeDangerousAllowCommandFindings( } const deny = new Set((cfg.gateway?.nodes?.denyCommands ?? []).map(normalizeNodeCommand)); - const dangerousAllowed = DEFAULT_DANGEROUS_NODE_COMMANDS.filter( - (cmd) => allow.has(cmd) && !deny.has(cmd), - ); + const dangerousAllowed = [ + ...DEFAULT_DANGEROUS_NODE_COMMANDS, + ...listDangerousPluginNodeCommands(), + ].filter((cmd) => allow.has(cmd) && !deny.has(cmd)); if (dangerousAllowed.length === 0) { return findings; }