fix(file-transfer): enforce node policy in gateway

This commit is contained in:
Peter Steinberger
2026-04-29 23:43:45 +01:00
parent 1dd632e9fa
commit 4fa1f5d218
33 changed files with 1182 additions and 331 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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.
</Accordion>
<Accordion title="api.runtime.tasks.managedFlows">

View File

@@ -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());

View File

@@ -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
}
}
}
}
}
}
}

View File

@@ -169,7 +169,7 @@ export async function handleDirFetch(params: DirFetchParams): Promise<DirFetchRe
return {
ok: false,
code: "SYMLINK_REDIRECT",
message: `path traverses a symlink; refusing because followSymlinks=false (set gateway.nodes.fileTransfer.<node>.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.<node>.followSymlinks=true to allow, or update allowReadPaths to the canonical path)`,
canonicalPath: canonical,
};
}

View File

@@ -100,7 +100,7 @@ export async function handleDirList(params: DirListParams): Promise<DirListResul
return {
ok: false,
code: "SYMLINK_REDIRECT",
message: `path traverses a symlink; refusing because followSymlinks=false (set gateway.nodes.fileTransfer.<node>.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.<node>.followSymlinks=true to allow, or update allowReadPaths to the canonical path)`,
canonicalPath: canonical,
};
}

View File

@@ -118,7 +118,7 @@ export async function handleFileFetch(params: FileFetchParams): Promise<FileFetc
return {
ok: false,
code: "SYMLINK_REDIRECT",
message: `path traverses a symlink; refusing because followSymlinks=false (set gateway.nodes.fileTransfer.<node>.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.<node>.followSymlinks=true to allow, or update allowReadPaths to the canonical path)`,
canonicalPath: canonical,
};
}

View File

@@ -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" });
});

View File

@@ -42,7 +42,8 @@ export async function handleFileWrite(
params: Partial<FileWriteParams> & Record<string, unknown>,
): Promise<FileWriteResult> {
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.<node>.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.<node>.followSymlinks=true to allow, or update allowWritePaths to the canonical path)`,
path.join(canonicalParent, path.basename(targetPath)),
);
}

View File

@@ -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<typeof import("./policy.js")>();
return {
...actual,
persistAllowAlways: vi.fn(async () => undefined),
};
});
function createCtx(overrides: {
command?: string;
params?: Record<string, unknown>;
pluginConfig?: Record<string, unknown>;
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" });
});
});

View File

@@ -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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readPath(params: Record<string, unknown>): 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<string, unknown>;
followSymlinks: boolean;
maxBytes?: number;
}): Record<string, unknown> {
const next: Record<string, unknown> = {
...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<OpenClawPluginNodeInvokePolicyResult> {
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<string, unknown>)
: 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,
};
}

View File

@@ -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<string, unknown> | 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<string, unknown>) => void }) => {
const draft: Record<string, unknown> = {
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<string, { allowReadPaths: string[] }> } };
plugins: {
entries: {
"file-transfer": {
config: { nodes: Record<string, { allowReadPaths: string[] }> };
};
};
};
};
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<string, { allowWritePaths: string[] }> } };
plugins: {
entries: {
"file-transfer": {
config: { nodes: Record<string, { allowWritePaths: string[] }> };
};
};
};
};
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<string, unknown>) => void }) => {
const draft: Record<string, unknown> = {
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<string, { allowReadPaths?: string[] }> };
plugins: {
entries: {
"file-transfer": {
config: { nodes: Record<string, { allowReadPaths?: string[] }> };
};
};
};
};
// 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<string, unknown>) => void }) => {
const draft: Record<string, unknown> = {
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<string, { allowReadPaths: string[] }> } };
plugins: {
entries: {
"file-transfer": {
config: { nodes: Record<string, { allowReadPaths: string[] }> };
};
};
};
};
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);
});
});

View File

@@ -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": {
// "<nodeId-or-displayName>": {
// "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": {
// "<nodeId-or-displayName>": {
// "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<string, NodeFilePolicyConfig>;
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<string, unknown> | 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<string, unknown>)["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<string, unknown>)
: null;
}
function readFilePolicyConfig(pluginConfig?: Record<string, unknown>): 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<string, unknown>;
}): 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<string, unknown>` here.
const root = draft as unknown as Record<string, unknown>;
const plugins = (root.plugins ??= {}) as Record<string, unknown>;
const entries = (plugins.entries ??= {}) as Record<string, unknown>;
const pluginEntry = (entries["file-transfer"] ??= {}) as Record<string, unknown>;
const pluginConfig = (pluginEntry.config ??= {}) as Record<string, unknown>;
const fileTransfer = (pluginConfig.nodes ??= {}) as Record<string, NodeFilePolicyConfig>;
// SECURITY: never persist allow-always under the "*" wildcard. An
// operator approving a path on node A must not silently grant the

View File

@@ -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.<node>.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.<node>.allowReadPaths must match the directory path.",
parameters: DirFetchToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
@@ -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(

View File

@@ -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.<node>.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.<node>.allowReadPaths must match the directory path. Without policy configured, every call is denied.",
parameters: DirListToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
@@ -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<Record<string, unknown>>)
: [];

View File

@@ -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.<node>.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.<node>.allowReadPaths must match the path. Without policy configured, every call is denied.",
parameters: FileFetchToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
@@ -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],
},

View File

@@ -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.<node>.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.<node>.allowWritePaths must match the destination path. Without policy configured, every call is denied.",
parameters: FILE_WRITE_SCHEMA,
async execute(_toolCallId, params) {
const raw: Record<string, unknown> =
@@ -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<string, unknown>);
}
// 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 },
};
},
};

View File

@@ -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<string> = new Set([
"file.fetch",
"dir.list",

View File

@@ -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<string, string[]> = {
@@ -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<NodeSession, "platform" | "deviceFamily">,
@@ -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) {

View File

@@ -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<OpenClawPluginNodeInvokePolicyResult | null> {
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<OpenClawPluginNodeInvokeTransportResult> => {
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,
});
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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<string> {
export async function resolveMediaBufferPath(id: string, subdir = "inbound"): Promise<string> {
// Guard against path traversal and null-byte injection.
//
// - Separator checks: reject any ID containing "/" or "\" (covers all

View File

@@ -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";

View File

@@ -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,

View File

@@ -23,6 +23,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
registerGatewayDiscoveryService() {},
registerReload() {},
registerNodeHostCommand() {},
registerNodeInvokePolicy() {},
registerSecurityAuditCollector() {},
registerConfigMigration() {},
registerMigrationProvider() {},

View File

@@ -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,

View File

@@ -31,6 +31,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
cliRegistrars: [],
reloads: [],
nodeHostCommands: [],
nodeInvokePolicies: [],
securityAuditCollectors: [],
services: [],
gatewayDiscoveryServices: [],

View File

@@ -230,6 +230,15 @@ export type PluginNodeHostCommandRegistration = {
rootDir?: string;
};
export type PluginNodeInvokePolicyRegistration = {
pluginId: string;
pluginName?: string;
policy: import("./types.js").OpenClawPluginNodeInvokePolicy;
pluginConfig?: Record<string, unknown>;
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[];

View File

@@ -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<string, unknown>,
) => {
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) => {

View File

@@ -2045,9 +2045,89 @@ export type OpenClawPluginReloadRegistration = {
export type OpenClawPluginNodeHostCommand = {
command: string;
cap?: string;
dangerous?: boolean;
handle: (paramsJSON?: string | null) => Promise<string>;
};
export type OpenClawPluginNodeInvokeTransportResult =
| {
ok: true;
payload?: unknown;
payloadJSON?: string | null;
}
| {
ok: false;
code?: string;
message: string;
details?: Record<string, unknown>;
};
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<string, unknown>;
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<OpenClawPluginNodeInvokeTransportResult>;
};
export type OpenClawPluginNodeInvokePolicyResult =
| {
ok: true;
payload?: unknown;
payloadJSON?: string | null;
}
| {
ok: false;
message: string;
code?: string;
details?: Record<string, unknown>;
unavailable?: boolean;
};
export type OpenClawPluginNodeInvokePolicy = {
commands: string[];
handle: (
ctx: OpenClawPluginNodeInvokePolicyContext,
) => Promise<OpenClawPluginNodeInvokePolicyResult> | 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. */

View File

@@ -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;
}