test: narrow nodes workspace guard imports

This commit is contained in:
Peter Steinberger
2026-04-10 15:37:18 +01:00
parent 1714e7bbe5
commit a48eb84181
3 changed files with 79 additions and 66 deletions

View File

@@ -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<typeof createNodesToolHarness> & { 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);
});
});

View File

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

View File

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