mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 02:12:59 +00:00
fix(file-transfer): enforce node policy in gateway
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
166
extensions/file-transfer/src/shared/node-invoke-policy.test.ts
Normal file
166
extensions/file-transfer/src/shared/node-invoke-policy.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
347
extensions/file-transfer/src/shared/node-invoke-policy.ts
Normal file
347
extensions/file-transfer/src/shared/node-invoke-policy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>>)
|
||||
: [];
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
149
src/gateway/node-invoke-plugin-policy.ts
Normal file
149
src/gateway/node-invoke-plugin-policy.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,6 +23,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
|
||||
registerGatewayDiscoveryService() {},
|
||||
registerReload() {},
|
||||
registerNodeHostCommand() {},
|
||||
registerNodeInvokePolicy() {},
|
||||
registerSecurityAuditCollector() {},
|
||||
registerConfigMigration() {},
|
||||
registerMigrationProvider() {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,6 +31,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
||||
cliRegistrars: [],
|
||||
reloads: [],
|
||||
nodeHostCommands: [],
|
||||
nodeInvokePolicies: [],
|
||||
securityAuditCollectors: [],
|
||||
services: [],
|
||||
gatewayDiscoveryServices: [],
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user