import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { gzipSync } from "node:zlib"; import type { OpenClawPluginNodeInvokePolicyContext } from "openclaw/plugin-sdk/plugin-entry"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { createFileTransferNodeInvokePolicy } from "./node-invoke-policy.js"; vi.mock("./audit.js", () => ({ appendFileTransferAudit: vi.fn(async () => undefined), })); vi.mock("./policy.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, persistAllowAlways: vi.fn(async () => undefined), }; }); const tmpRoots: string[] = []; const testUnlessWindows = process.platform === "win32" ? it.skip : it; afterEach(async () => { await Promise.all(tmpRoots.map((tmpRoot) => fs.rm(tmpRoot, { recursive: true, force: true }))); tmpRoots.length = 0; }); afterAll(() => { vi.doUnmock("./audit.js"); vi.doUnmock("./policy.js"); vi.resetModules(); }); function tarEntries(entries: Record): string { const blocks: Buffer[] = []; for (const [relPath, contents] of Object.entries(entries)) { const payload = Buffer.from(contents); blocks.push(createTarFileHeader(relPath, payload.byteLength), payload); const padding = (512 - (payload.byteLength % 512)) % 512; if (padding > 0) { blocks.push(Buffer.alloc(padding)); } } blocks.push(Buffer.alloc(1024)); return gzipSync(Buffer.concat(blocks)).toString("base64"); } function writeTarString(header: Buffer, offset: number, length: number, value: string): void { header.write(value.slice(0, length), offset, length, "utf8"); } function writeTarOctal(header: Buffer, offset: number, length: number, value: number): void { const text = value.toString(8).padStart(length - 1, "0"); header.write(`${text}\0`.slice(-length), offset, length, "ascii"); } function createTarFileHeader(name: string, size: number): Buffer { const header = Buffer.alloc(512); writeTarString(header, 0, 100, name); writeTarOctal(header, 100, 8, 0o644); writeTarOctal(header, 108, 8, 0); writeTarOctal(header, 116, 8, 0); writeTarOctal(header, 124, 12, size); writeTarOctal(header, 136, 12, 0); header.fill(" ", 148, 156); header.write("0", 156, 1, "ascii"); header.write("ustar\0", 257, 6, "ascii"); header.write("00", 263, 2, "ascii"); const checksum = header.reduce((sum, byte) => sum + byte, 0); header.write(checksum.toString(8).padStart(6, "0"), 148, 6, "ascii"); header[154] = 0; header[155] = 0x20; return header; } function createCtx(overrides: { command?: string; params?: Record; pluginConfig?: Record; approvals?: OpenClawPluginNodeInvokePolicyContext["approvals"]; }) { const invokeNode = vi.fn( async ({ params, }: Parameters[0] = {}) => ({ ok: true, 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, }; } function requireRecord(value: unknown, label: string): Record { if (typeof value !== "object" || value === null) { throw new Error(`${label} was not an object`); } return value as Record; } function expectRecordFields(record: Record, fields: Record) { for (const [key, value] of Object.entries(fields)) { expect(record[key]).toEqual(value); } } function expectResultFields(result: unknown, fields: Record) { expectRecordFields(requireRecord(result, "policy result"), fields); } function requireInvokeParams( invokeNode: ReturnType>, callIndex: number, ) { const call = (invokeNode.mock.calls as unknown[][])[callIndex]?.[0]; const request = requireRecord(call, `invoke call ${callIndex + 1}`); return requireRecord(request.params, `invoke call ${callIndex + 1} params`); } 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).toHaveBeenNthCalledWith(1, { params: { path: "/tmp/file.txt", maxBytes: 512, followSymlinks: false, preflightOnly: true, }, }); expect(invokeNode).toHaveBeenNthCalledWith(2, { params: { path: "/tmp/file.txt", maxBytes: 512, followSymlinks: false, }, }); }); it("normalizes string maxBytes before invoking the node", async () => { const policy = createFileTransferNodeInvokePolicy(); const { ctx, invokeNode } = createCtx({ params: { path: "/tmp/file.txt", maxBytes: "1024" }, pluginConfig: { nodes: { "node-1": { allowReadPaths: ["/tmp/**"], }, }, }, }); const result = await policy.handle(ctx); expect(result.ok).toBe(true); expect(invokeNode).toHaveBeenNthCalledWith(1, { params: { path: "/tmp/file.txt", maxBytes: 1024, followSymlinks: false, preflightOnly: true, }, }); }); it("rejects malformed maxBytes before invoking the node", async () => { const policy = createFileTransferNodeInvokePolicy(); const { ctx, invokeNode } = createCtx({ params: { path: "/tmp/file.txt", maxBytes: "1024.5" }, }); const result = await policy.handle(ctx); expectResultFields(result, { ok: false, code: "INVALID_PARAMS", message: "maxBytes must be a positive integer", }); expect(invokeNode).not.toHaveBeenCalled(); }); it("rejects malformed maxBytes before requesting approval", async () => { const policy = createFileTransferNodeInvokePolicy(); const approvals = { request: vi.fn(async () => ({ id: "approval-1", decision: "allow-always" as const })), }; const { ctx, invokeNode } = createCtx({ params: { path: "/tmp/new.txt", maxBytes: "1024.5" }, pluginConfig: { nodes: { "node-1": { ask: "on-miss", allowReadPaths: ["/allowed/**"], }, }, }, approvals, }); const result = await policy.handle(ctx); expectResultFields(result, { ok: false, code: "INVALID_PARAMS", message: "maxBytes must be a positive integer", }); expect(approvals.request).not.toHaveBeenCalled(); expect(invokeNode).not.toHaveBeenCalled(); }); 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); expectResultFields(result, { 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/**"], maxBytes: 256, }, }, }, approvals, }); const result = await policy.handle(ctx); expect(result.ok).toBe(true); const approvalCalls = approvals.request.mock.calls as unknown[][]; const approvalRequest = requireRecord(approvalCalls[0]?.[0], "approval request"); expectRecordFields(approvalRequest, { title: "Read file: /tmp/new.txt", severity: "info", toolName: "file.fetch", }); expect(invokeNode).toHaveBeenNthCalledWith(1, { params: { path: "/tmp/new.txt", followSymlinks: false, maxBytes: 256, preflightOnly: true, }, }); expect(invokeNode).toHaveBeenNthCalledWith(2, { params: { path: "/tmp/new.txt", followSymlinks: false, maxBytes: 256, }, }); }); 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); expectResultFields(result, { ok: false, code: "TIMEOUT", unavailable: true, details: { nodeError: { code: "TIMEOUT" } }, }); }); it("checks file.fetch canonical policy before requesting bytes", 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); expectResultFields(result, { ok: false, code: "SYMLINK_TARGET_DENIED" }); expect(invokeNode).toHaveBeenCalledTimes(1); expectRecordFields(requireInvokeParams(invokeNode, 0), { path: "/tmp/link.txt", followSymlinks: false, preflightOnly: true, }); }); it("continues file.fetch after preflight without forwarding caller preflightOnly", async () => { const policy = createFileTransferNodeInvokePolicy(); const { ctx, invokeNode } = createCtx({ params: { path: "/tmp/file.txt", preflightOnly: true }, }); const result = await policy.handle(ctx); expectResultFields(result, { ok: true }); expect(invokeNode).toHaveBeenCalledTimes(2); expectRecordFields(requireInvokeParams(invokeNode, 0), { path: "/tmp/file.txt", preflightOnly: true, }); expect(requireInvokeParams(invokeNode, 1).preflightOnly).toBeUndefined(); }); it("checks file.write canonical policy before the mutating node call", async () => { const policy = createFileTransferNodeInvokePolicy(); const { ctx, invokeNode } = createCtx({ command: "file.write", params: { path: "/tmp/link/out.txt", contentBase64: Buffer.from("payload").toString("base64"), createParents: true, }, pluginConfig: { nodes: { "node-1": { allowWritePaths: ["/tmp/**"], followSymlinks: true, }, }, }, }); invokeNode.mockResolvedValueOnce({ ok: true, payload: { ok: true, path: "/etc/out.txt", size: 7, sha256: "b".repeat(64), overwritten: false, }, }); const result = await policy.handle(ctx); expectResultFields(result, { ok: false, code: "SYMLINK_TARGET_DENIED" }); expect(invokeNode).toHaveBeenCalledTimes(1); expectRecordFields(requireInvokeParams(invokeNode, 0), { path: "/tmp/link/out.txt", followSymlinks: true, preflightOnly: true, }); }); it("continues file.write after preflight without forwarding caller preflightOnly", async () => { const policy = createFileTransferNodeInvokePolicy(); const { ctx, invokeNode } = createCtx({ command: "file.write", params: { path: "/tmp/link/out.txt", contentBase64: Buffer.from("payload").toString("base64"), createParents: true, preflightOnly: true, }, pluginConfig: { nodes: { "node-1": { allowWritePaths: ["/tmp/**", "/private/tmp/**"], followSymlinks: true, }, }, }, }); invokeNode .mockResolvedValueOnce({ ok: true, payload: { ok: true, path: "/private/tmp/out.txt", size: 7, sha256: "b".repeat(64), overwritten: false, }, }) .mockResolvedValueOnce({ ok: true, payload: { ok: true, path: "/private/tmp/out.txt", size: 7, sha256: "b".repeat(64), overwritten: false, }, }); const result = await policy.handle(ctx); expectResultFields(result, { ok: true }); expect(invokeNode).toHaveBeenCalledTimes(2); expect(requireInvokeParams(invokeNode, 0).preflightOnly).toBe(true); expect(requireInvokeParams(invokeNode, 1).preflightOnly).toBeUndefined(); }); it("checks every dir.fetch preflight entry before requesting the archive", async () => { const policy = createFileTransferNodeInvokePolicy(); const { ctx, invokeNode } = createCtx({ command: "dir.fetch", params: { path: "/home/me" }, pluginConfig: { nodes: { "node-1": { allowReadPaths: ["/home/me", "/home/me/**"], denyPaths: ["**/.ssh/**"], }, }, }, }); invokeNode.mockResolvedValueOnce({ ok: true, payload: { ok: true, path: "/home/me", entries: ["ok.txt", ".ssh/id_rsa"], fileCount: 2, preflightOnly: true, }, }); const result = await policy.handle(ctx); expectResultFields(result, { ok: false, code: "PATH_POLICY_DENIED" }); expect( requireRecord(requireRecord(result, "policy result").details, "result details").path, ).toBe("/home/me/.ssh/id_rsa"); expect(invokeNode).toHaveBeenCalledTimes(1); expectRecordFields(requireInvokeParams(invokeNode, 0), { path: "/home/me", preflightOnly: true, }); }); it("rejects dir.fetch preflight responses without an entry list", async () => { const policy = createFileTransferNodeInvokePolicy(); const { ctx, invokeNode } = createCtx({ command: "dir.fetch", params: { path: "/home/me" }, pluginConfig: { nodes: { "node-1": { allowReadPaths: ["/home/me", "/home/me/**"], }, }, }, }); invokeNode.mockResolvedValueOnce({ ok: true, payload: { ok: true, path: "/home/me", fileCount: 2, preflightOnly: true, }, }); const result = await policy.handle(ctx); expectResultFields(result, { ok: false, code: "PREFLIGHT_ENTRIES_MISSING" }); expect(invokeNode).toHaveBeenCalledTimes(1); }); it("rejects invalid dir.fetch preflight entries before requesting the archive", async () => { const policy = createFileTransferNodeInvokePolicy(); const { ctx, invokeNode } = createCtx({ command: "dir.fetch", params: { path: "/home/me" }, pluginConfig: { nodes: { "node-1": { allowReadPaths: ["/home/me", "/home/me/**"], }, }, }, }); invokeNode.mockResolvedValueOnce({ ok: true, payload: { ok: true, path: "/home/me", entries: ["ok.txt", "/etc/passwd"], fileCount: 2, preflightOnly: true, }, }); const result = await policy.handle(ctx); expectResultFields(result, { ok: false, code: "PREFLIGHT_ENTRY_INVALID" }); expect(invokeNode).toHaveBeenCalledTimes(1); }); it("rejects oversized dir.fetch preflight entry lists before requesting the archive", async () => { const policy = createFileTransferNodeInvokePolicy(); const entries = Array.from({ length: 5001 }, (_, index) => `file-${index}.txt`); const { ctx, invokeNode } = createCtx({ command: "dir.fetch", params: { path: "/home/me" }, pluginConfig: { nodes: { "node-1": { allowReadPaths: ["/home/me", "/home/me/**"], }, }, }, }); invokeNode.mockResolvedValueOnce({ ok: true, payload: { ok: true, path: "/home/me", entries, fileCount: entries.length, preflightOnly: true, }, }); const result = await policy.handle(ctx); expectResultFields(result, { ok: false, code: "PREFLIGHT_ENTRIES_TOO_MANY" }); expect(invokeNode).toHaveBeenCalledTimes(1); }); testUnlessWindows( "continues dir.fetch after preflight without forwarding caller preflightOnly", async () => { const policy = createFileTransferNodeInvokePolicy(); const tarBase64 = tarEntries({ "a.txt": "a", "sub/b.txt": "b", }); const { ctx, invokeNode } = createCtx({ command: "dir.fetch", params: { path: "/tmp/project", preflightOnly: true }, }); invokeNode .mockResolvedValueOnce({ ok: true, payload: { ok: true, path: "/tmp/project", entries: ["a.txt", "sub/b.txt"], fileCount: 2, preflightOnly: true, }, }) .mockResolvedValueOnce({ ok: true, payload: { ok: true, path: "/tmp/project", tarBase64, tarBytes: 7, sha256: "c".repeat(64), fileCount: 2, entries: ["a.txt", "sub/b.txt"], }, }); const result = await policy.handle(ctx); expectResultFields(result, { ok: true }); expect(invokeNode).toHaveBeenCalledTimes(2); expectRecordFields(requireInvokeParams(invokeNode, 0), { path: "/tmp/project", preflightOnly: true, }); expect(requireInvokeParams(invokeNode, 1).preflightOnly).toBeUndefined(); }, ); testUnlessWindows( "checks final dir.fetch archive entries before returning the archive", async () => { const policy = createFileTransferNodeInvokePolicy(); const tarBase64 = tarEntries({ "ok.txt": "ok", ".ssh/id_rsa": "secret", }); const { ctx, invokeNode } = createCtx({ command: "dir.fetch", params: { path: "/home/me" }, pluginConfig: { nodes: { "node-1": { allowReadPaths: ["/home/me", "/home/me/**"], denyPaths: ["**/.ssh/**"], }, }, }, }); invokeNode .mockResolvedValueOnce({ ok: true, payload: { ok: true, path: "/home/me", entries: ["ok.txt"], fileCount: 1, preflightOnly: true, }, }) .mockResolvedValueOnce({ ok: true, payload: { ok: true, path: "/home/me", tarBase64, tarBytes: 7, sha256: "c".repeat(64), fileCount: 2, }, }); const result = await policy.handle(ctx); expectResultFields(result, { ok: false, code: "PATH_POLICY_DENIED" }); expect( requireRecord(requireRecord(result, "policy result").details, "result details").path, ).toBe("/home/me/.ssh/id_rsa"); expect(invokeNode).toHaveBeenCalledTimes(2); }, ); testUnlessWindows("rejects oversized final dir.fetch archive entry lists", async () => { const policy = createFileTransferNodeInvokePolicy(); const tarBase64 = tarEntries( Object.fromEntries(Array.from({ length: 5001 }, (_, index) => [`file-${index}.txt`, "x"])), ); const { ctx, invokeNode } = createCtx({ command: "dir.fetch", params: { path: "/tmp/project" }, pluginConfig: { nodes: { "node-1": { allowReadPaths: ["/tmp/project", "/tmp/project/**"], }, }, }, }); invokeNode .mockResolvedValueOnce({ ok: true, payload: { ok: true, path: "/tmp/project", entries: ["file-0.txt"], fileCount: 1, preflightOnly: true, }, }) .mockResolvedValueOnce({ ok: true, payload: { ok: true, path: "/tmp/project", tarBase64, tarBytes: 7, sha256: "c".repeat(64), fileCount: 5001, }, }); const result = await policy.handle(ctx); expectResultFields(result, { ok: false, code: "ARCHIVE_ENTRIES_TOO_MANY" }); expect(invokeNode).toHaveBeenCalledTimes(2); }); it("rejects final dir.fetch archive responses without readable archive entries", async () => { const policy = createFileTransferNodeInvokePolicy(); const { ctx, invokeNode } = createCtx({ command: "dir.fetch", params: { path: "/tmp/project" }, }); invokeNode .mockResolvedValueOnce({ ok: true, payload: { ok: true, path: "/tmp/project", entries: ["a.txt"], fileCount: 1, preflightOnly: true, }, }) .mockResolvedValueOnce({ ok: true, payload: { ok: true, path: "/tmp/project", tarBytes: 7, sha256: "c".repeat(64), fileCount: 1, }, }); const result = await policy.handle(ctx); expectResultFields(result, { ok: false, code: "ARCHIVE_ENTRIES_MISSING" }); expect(invokeNode).toHaveBeenCalledTimes(2); }); });