diff --git a/src/agents/openclaw-tools.nodes-workspace-guard.test.ts b/src/agents/openclaw-tools.nodes-workspace-guard.test.ts index d22645ad5b9..eff57b745ba 100644 --- a/src/agents/openclaw-tools.nodes-workspace-guard.test.ts +++ b/src/agents/openclaw-tools.nodes-workspace-guard.test.ts @@ -1,4 +1,5 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { applyNodesToolWorkspaceGuard } from "./openclaw-tools.nodes-workspace-guard.js"; import type { AnyAgentTool } from "./tools/common.js"; const mocks = vi.hoisted(() => ({ @@ -26,68 +27,56 @@ const mocks = vi.hoisted(() => ({ const relative = resolved === root ? "" : resolved.slice(root.length + 1); return { resolved, relative }; }), - nodesExecute: vi.fn(async () => ({ - content: [{ type: "text", text: "ok" }], - details: {}, - })), })); vi.mock("./sandbox-paths.js", () => ({ assertSandboxPath: mocks.assertSandboxPath, })); -vi.mock("./tools/nodes-tool.js", () => ({ - createNodesTool: () => - ({ - name: "nodes", - label: "Nodes", - description: "nodes test tool", - parameters: { - type: "object", - properties: {}, - }, - execute: mocks.nodesExecute, - }) as unknown as AnyAgentTool, -})); - -let createOpenClawTools: typeof import("./openclaw-tools.js").createOpenClawTools; - const WORKSPACE_ROOT = "/tmp/openclaw-workspace-nodes-guard"; -describe("createOpenClawTools nodes workspace guard", () => { - beforeAll(async () => { - vi.resetModules(); - ({ createOpenClawTools } = await import("./openclaw-tools.js")); - }); +function createNodesToolHarness() { + const nodesExecute = vi.fn(async () => ({ + content: [{ type: "text", text: "ok" }], + details: {}, + })); + const tool = { + description: "nodes test tool", + execute: nodesExecute, + label: "Nodes", + name: "nodes", + parameters: { + properties: {}, + type: "object", + }, + } as unknown as AnyAgentTool; + return { nodesExecute, tool }; +} +describe("applyNodesToolWorkspaceGuard", () => { beforeEach(() => { mocks.assertSandboxPath.mockClear(); - mocks.nodesExecute.mockClear(); }); function getNodesTool( workspaceOnly: boolean, options?: { sandboxRoot?: string; sandboxContainerWorkdir?: string }, - ): AnyAgentTool { - const tools = createOpenClawTools({ - workspaceDir: WORKSPACE_ROOT, - fsPolicy: { workspaceOnly }, - sandboxRoot: options?.sandboxRoot, - sandboxContainerWorkdir: options?.sandboxContainerWorkdir, - disablePluginTools: true, - disableMessageTool: true, - }); - const nodesTool = tools.find((tool) => tool.name === "nodes"); - expect(nodesTool).toBeDefined(); - if (!nodesTool) { - throw new Error("missing nodes tool"); - } - return nodesTool; + ): ReturnType & { guardedTool: AnyAgentTool } { + const harness = createNodesToolHarness(); + return { + ...harness, + guardedTool: applyNodesToolWorkspaceGuard(harness.tool, { + workspaceDir: WORKSPACE_ROOT, + fsPolicy: { workspaceOnly }, + sandboxRoot: options?.sandboxRoot, + sandboxContainerWorkdir: options?.sandboxContainerWorkdir, + }), + }; } it("guards outPath when workspaceOnly is enabled", async () => { - const nodesTool = getNodesTool(true); - await nodesTool.execute("call-1", { + const { guardedTool, nodesExecute } = getNodesTool(true); + await guardedTool.execute("call-1", { action: "screen_record", outPath: `${WORKSPACE_ROOT}/videos/capture.mp4`, }); @@ -97,12 +86,12 @@ describe("createOpenClawTools nodes workspace guard", () => { cwd: WORKSPACE_ROOT, root: WORKSPACE_ROOT, }); - expect(mocks.nodesExecute).toHaveBeenCalledTimes(1); + expect(nodesExecute).toHaveBeenCalledTimes(1); }); it("normalizes relative outPath to an absolute workspace path before execute", async () => { - const nodesTool = getNodesTool(true); - await nodesTool.execute("call-rel", { + const { guardedTool, nodesExecute } = getNodesTool(true); + await guardedTool.execute("call-rel", { action: "screen_record", outPath: "videos/capture.mp4", }); @@ -112,7 +101,7 @@ describe("createOpenClawTools nodes workspace guard", () => { cwd: WORKSPACE_ROOT, root: WORKSPACE_ROOT, }); - expect(mocks.nodesExecute).toHaveBeenCalledWith( + expect(nodesExecute).toHaveBeenCalledWith( "call-rel", { action: "screen_record", @@ -124,11 +113,11 @@ describe("createOpenClawTools nodes workspace guard", () => { }); it("maps sandbox container outPath to host root when containerWorkdir is provided", async () => { - const nodesTool = getNodesTool(true, { + const { guardedTool, nodesExecute } = getNodesTool(true, { sandboxRoot: WORKSPACE_ROOT, sandboxContainerWorkdir: "/workspace", }); - await nodesTool.execute("call-sandbox", { + await guardedTool.execute("call-sandbox", { action: "screen_record", outPath: "/workspace/videos/capture.mp4", }); @@ -138,7 +127,7 @@ describe("createOpenClawTools nodes workspace guard", () => { cwd: WORKSPACE_ROOT, root: WORKSPACE_ROOT, }); - expect(mocks.nodesExecute).toHaveBeenCalledWith( + expect(nodesExecute).toHaveBeenCalledWith( "call-sandbox", { action: "screen_record", @@ -150,26 +139,26 @@ describe("createOpenClawTools nodes workspace guard", () => { }); it("rejects outPath outside workspace when workspaceOnly is enabled", async () => { - const nodesTool = getNodesTool(true); + const { guardedTool, nodesExecute } = getNodesTool(true); await expect( - nodesTool.execute("call-2", { + guardedTool.execute("call-2", { action: "screen_record", outPath: "/etc/passwd", }), ).rejects.toThrow(/Path escapes sandbox root/); expect(mocks.assertSandboxPath).toHaveBeenCalledTimes(1); - expect(mocks.nodesExecute).not.toHaveBeenCalled(); + expect(nodesExecute).not.toHaveBeenCalled(); }); it("does not guard outPath when workspaceOnly is disabled", async () => { - const nodesTool = getNodesTool(false); - await nodesTool.execute("call-3", { + const { guardedTool, nodesExecute } = getNodesTool(false); + await guardedTool.execute("call-3", { action: "screen_record", outPath: "/etc/passwd", }); expect(mocks.assertSandboxPath).not.toHaveBeenCalled(); - expect(mocks.nodesExecute).toHaveBeenCalledTimes(1); + expect(nodesExecute).toHaveBeenCalledTimes(1); }); }); diff --git a/src/agents/openclaw-tools.nodes-workspace-guard.ts b/src/agents/openclaw-tools.nodes-workspace-guard.ts new file mode 100644 index 00000000000..eee4a665772 --- /dev/null +++ b/src/agents/openclaw-tools.nodes-workspace-guard.ts @@ -0,0 +1,26 @@ +import { wrapToolWorkspaceRootGuardWithOptions } from "./pi-tools.read.js"; +import type { ToolFsPolicy } from "./tool-fs-policy.js"; +import type { AnyAgentTool } from "./tools/common.js"; + +export function applyNodesToolWorkspaceGuard( + nodesToolBase: AnyAgentTool, + options: { + fsPolicy?: ToolFsPolicy; + sandboxContainerWorkdir?: string; + sandboxRoot?: string; + workspaceDir: string; + }, +): AnyAgentTool { + if (options.fsPolicy?.workspaceOnly !== true) { + return nodesToolBase; + } + return wrapToolWorkspaceRootGuardWithOptions( + nodesToolBase, + options.sandboxRoot ?? options.workspaceDir, + { + containerWorkdir: options.sandboxContainerWorkdir, + normalizeGuardedPathParams: true, + pathParamKeys: ["outPath"], + }, + ); +} diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 7955f766d20..d058de6a946 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -5,11 +5,11 @@ import { normalizeDeliveryContext } from "../utils/delivery-context.js"; import type { GatewayMessageChannel } from "../utils/message-channel.js"; import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js"; import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js"; +import { applyNodesToolWorkspaceGuard } from "./openclaw-tools.nodes-workspace-guard.js"; import { collectPresentOpenClawTools, isUpdatePlanToolEnabledForOpenClawTools, } from "./openclaw-tools.registration.js"; -import { wrapToolWorkspaceRootGuardWithOptions } from "./pi-tools.read.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; import type { SpawnedToolContext } from "./spawned-context.js"; import type { ToolFsPolicy } from "./tool-fs-policy.js"; @@ -217,14 +217,12 @@ export function createOpenClawTools( modelHasVision: options?.modelHasVision, allowMediaInvokeCommands: options?.allowMediaInvokeCommands, }); - const nodesTool = - options?.fsPolicy?.workspaceOnly === true - ? wrapToolWorkspaceRootGuardWithOptions(nodesToolBase, options?.sandboxRoot ?? workspaceDir, { - containerWorkdir: options?.sandboxContainerWorkdir, - pathParamKeys: ["outPath"], - normalizeGuardedPathParams: true, - }) - : nodesToolBase; + const nodesTool = applyNodesToolWorkspaceGuard(nodesToolBase, { + fsPolicy: options?.fsPolicy, + sandboxContainerWorkdir: options?.sandboxContainerWorkdir, + sandboxRoot: options?.sandboxRoot, + workspaceDir, + }); const tools: AnyAgentTool[] = [ createCanvasTool({ config: options?.config }), nodesTool,