import fs from "node:fs/promises" ;
import os from "node:os" ;
import path from "node:path" ;
import { beforeEach, describe, expect, it } from "vitest" ;
import "./test-helpers/fast-bash-tools.js" ;
import "./test-helpers/fast-coding-tools.js" ;
import "./test-helpers/fast-openclaw-tools.js" ;
import type { OpenClawConfig } from "../config/config.js" ;
import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js" ;
import { setActivePluginRegistry } from "../plugins/runtime.js" ;
import { createSessionConversationTestRegistry } from "../test-utils/session-conversation-registry.js" ;
import { createOpenClawCodingTools } from "./pi-tools.js" ;
import { resolveEffectiveToolPolicy } from "./pi-tools.policy.js" ;
import type { SandboxDockerConfig } from "./sandbox.js" ;
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js" ;
import { createRestrictedAgentSandboxConfig } from "./test-helpers/sandbox-agent-config-fixtures.js" ;
type ToolWithExecute = {
execute: (toolCallId: string, args: unknown, signal?: AbortSignal) => Promise<unknown>;
};
describe("Agent-specific tool filtering" , () => {
beforeEach(() => {
setActivePluginRegistry(createSessionConversationTestRegistry());
});
const sandboxFsBridgeStub: SandboxFsBridge = {
resolvePath: () => ({
hostPath: "/tmp/sandbox" ,
relativePath: "" ,
containerPath: "/workspace" ,
}),
readFile: async () => Buffer.from("" ),
writeFile: async () => {},
mkdirp: async () => {},
remove: async () => {},
rename: async () => {},
stat: async () => null ,
};
function expectReadOnlyToolSet(toolNames: string[], extraDenied: string[] = []) {
expect(toolNames).toContain("read" );
expect(toolNames).not.toContain("exec" );
expect(toolNames).not.toContain("write" );
expect(toolNames).not.toContain("apply_patch" );
for (const toolName of extraDenied) {
expect(toolNames).not.toContain(toolName);
}
}
async function withApplyPatchEscapeCase(
opts: { workspaceOnly?: boolean },
run: (params: {
applyPatchTool: ToolWithExecute;
escapedPath: string;
patch: string;
}) => Promise<void >,
) {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-tools-" ));
const escapedPath = path.join(
path.dirname(workspaceDir),
`escaped-${process.pid}-${Date.now()}-${Math.random().toString(16 ).slice(2 )}.txt`,
);
const relativeEscape = path.relative(workspaceDir, escapedPath);
try {
const cfg: OpenClawConfig = {
tools: {
allow: ["read" , "write" , "exec" ],
exec: {
applyPatch: opts.workspaceOnly === false ? { workspaceOnly: false } : {},
},
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:main" ,
workspaceDir,
agentDir: "/tmp/agent" ,
modelProvider: "openai" ,
modelId: "gpt-5.4" ,
});
const applyPatchTool = tools.find((t) => t.name === "apply_patch" );
if (!applyPatchTool) {
throw new Error("apply_patch tool missing" );
}
const patch = `*** Begin Patch
*** Add File: ${relativeEscape}
+escaped
*** End Patch`;
await run({
applyPatchTool: applyPatchTool as unknown as ToolWithExecute,
escapedPath,
patch,
});
} finally {
await fs.rm(escapedPath, { force: true });
await fs.rm(workspaceDir, { recursive: true , force: true });
}
}
function createMainSessionTools(cfg: OpenClawConfig) {
return createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:main" ,
workspaceDir: "/tmp/test" ,
agentDir: "/tmp/agent" ,
});
}
function createMainAgentConfig(params: {
tools: NonNullable<OpenClawConfig["tools" ]>;
agentTools?: NonNullable<NonNullable<OpenClawConfig["agents" ]>["list" ]>[number]["tools" ];
}): OpenClawConfig {
return {
tools: params.tools,
agents: {
list: [
{
id: "main" ,
workspace: "~/openclaw" ,
...(params.agentTools ? { tools: params.agentTools } : {}),
},
],
},
};
}
it("should apply global tool policy when no agent-specific policy exists" , () => {
const cfg = createMainAgentConfig({
tools: {
allow: ["read" , "write" ],
deny: ["bash" ],
},
});
const tools = createMainSessionTools(cfg);
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("read" );
expect(toolNames).toContain("write" );
expect(toolNames).not.toContain("exec" );
expect(toolNames).not.toContain("apply_patch" );
});
it("should keep global tool policy when agent only sets tools.elevated" , () => {
const cfg = createMainAgentConfig({
tools: {
deny: ["write" ],
},
agentTools: {
elevated: {
enabled: true ,
allowFrom: { whatsapp: ["+15555550123" ] },
},
},
});
const tools = createMainSessionTools(cfg);
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("exec" );
expect(toolNames).toContain("read" );
expect(toolNames).not.toContain("write" );
expect(toolNames).not.toContain("apply_patch" );
});
it("should allow apply_patch for OpenAI models when write is allow-listed" , () => {
const cfg: OpenClawConfig = {
tools: {
allow: ["read" , "write" , "exec" ],
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:main" ,
workspaceDir: "/tmp/test" ,
agentDir: "/tmp/agent" ,
modelProvider: "openai" ,
modelId: "gpt-5.4" ,
});
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("read" );
expect(toolNames).toContain("exec" );
expect(toolNames).toContain("apply_patch" );
});
it("should allow disabling apply_patch explicitly" , () => {
const cfg: OpenClawConfig = {
tools: {
allow: ["read" , "write" , "exec" ],
exec: {
applyPatch: { enabled: false },
},
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:main" ,
workspaceDir: "/tmp/test" ,
agentDir: "/tmp/agent" ,
modelProvider: "openai" ,
modelId: "gpt-5.4" ,
});
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("exec" );
expect(toolNames).not.toContain("apply_patch" );
});
it("defaults apply_patch to workspace-only (blocks traversal)" , async () => {
await withApplyPatchEscapeCase({}, async ({ applyPatchTool, escapedPath, patch }) => {
await expect(applyPatchTool.execute("tc1" , { input: patch })).rejects.toThrow(
/Path escapes sandbox root/,
);
await expect(fs.readFile(escapedPath, "utf8" )).rejects.toBeDefined();
});
});
it("allows disabling apply_patch workspace-only via config (dangerous)" , async () => {
await withApplyPatchEscapeCase(
{ workspaceOnly: false },
async ({ applyPatchTool, escapedPath, patch }) => {
await applyPatchTool.execute("tc2" , { input: patch });
const contents = await fs.readFile(escapedPath, "utf8" );
expect(contents).toBe("escaped\n" );
},
);
});
it("should apply agent-specific tool policy" , () => {
const cfg: OpenClawConfig = {
tools: {
allow: ["read" , "write" , "exec" ],
deny: [],
},
agents: {
list: [
{
id: "restricted" ,
workspace: "~/openclaw-restricted" ,
tools: {
allow: ["read" ], // Agent override: only read
deny: ["exec" , "write" , "edit" ],
},
},
],
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:restricted:main" ,
workspaceDir: "/tmp/test-restricted" ,
agentDir: "/tmp/agent-restricted" ,
});
expectReadOnlyToolSet(
tools.map((t) => t.name),
["edit" ],
);
});
it("should apply provider-specific tool policy" , () => {
const cfg: OpenClawConfig = {
tools: {
allow: ["read" , "write" , "exec" ],
byProvider: {
"google-antigravity" : {
allow: ["read" ],
},
},
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:main" ,
workspaceDir: "/tmp/test-provider" ,
agentDir: "/tmp/agent-provider" ,
modelProvider: "google-antigravity" ,
modelId: "claude-opus-4-6-thinking" ,
});
expectReadOnlyToolSet(tools.map((t) => t.name));
});
it("should apply provider-specific tool profile overrides" , () => {
const cfg: OpenClawConfig = {
tools: {
profile: "coding" ,
byProvider: {
"google-antigravity" : {
profile: "minimal" ,
},
},
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:main" ,
workspaceDir: "/tmp/test-provider-profile" ,
agentDir: "/tmp/agent-provider-profile" ,
modelProvider: "google-antigravity" ,
modelId: "claude-opus-4-6-thinking" ,
});
const toolNames = tools.map((t) => t.name);
expect(toolNames).toEqual(["session_status" ]);
});
it("should resolve different tool policies for different agents" , () => {
const cfg: OpenClawConfig = {
agents: {
list: [
{
id: "main" ,
workspace: "~/openclaw" ,
// No tools restriction - all tools available
},
{
id: "family" ,
workspace: "~/openclaw-family" ,
tools: {
allow: ["read" ],
deny: ["exec" , "write" , "edit" , "process" ],
},
},
],
},
};
// main agent: no override
const mainPolicy = resolveEffectiveToolPolicy({
config: cfg,
sessionKey: "agent:main:main" ,
});
expect(mainPolicy.agentId).toBe("main" );
expect(mainPolicy.agentPolicy).toBeUndefined();
// family agent: restricted
const familyPolicy = resolveEffectiveToolPolicy({
config: cfg,
sessionKey: "agent:family:whatsapp:group:123" ,
});
expect(familyPolicy.agentId).toBe("family" );
expect(familyPolicy.agentPolicy).toEqual({
allow: ["read" ],
deny: ["exec" , "write" , "edit" , "process" ],
});
});
it("should resolve group tool policy overrides (group-specific beats wildcard)" , () => {
const cfg: OpenClawConfig = {
channels: {
whatsapp: {
groups: {
"*" : {
tools: { allow: ["read" ] },
},
trusted: {
tools: { allow: ["read" , "exec" ] },
},
},
},
},
};
expect(
resolveChannelGroupToolsPolicy({ cfg, channel: "whatsapp" , groupId: "trusted" }),
).toEqual({ allow: ["read" , "exec" ] });
expect(
resolveChannelGroupToolsPolicy({ cfg, channel: "whatsapp" , groupId: "unknown" }),
).toEqual({ allow: ["read" ] });
});
it("should apply per-sender tool policies for group tools" , () => {
const cfg: OpenClawConfig = {
channels: {
whatsapp: {
groups: {
"*" : {
tools: { allow: ["read" ] },
toolsBySender: {
"id:alice" : { allow: ["read" , "exec" ] },
},
},
},
},
},
};
expect(
resolveChannelGroupToolsPolicy({
cfg,
channel: "whatsapp" ,
groupId: "family" ,
senderId: "alice" ,
}),
).toEqual({ allow: ["read" , "exec" ] });
expect(
resolveChannelGroupToolsPolicy({
cfg,
channel: "whatsapp" ,
groupId: "family" ,
senderId: "bob" ,
}),
).toEqual({ allow: ["read" ] });
});
it("should not let default sender policy override group tools" , () => {
const cfg: OpenClawConfig = {
channels: {
whatsapp: {
groups: {
"*" : {
toolsBySender: {
"id:admin" : { allow: ["read" , "exec" ] },
},
},
locked: {
tools: { allow: ["read" ] },
},
},
},
},
};
expect(
resolveChannelGroupToolsPolicy({
cfg,
channel: "whatsapp" ,
groupId: "locked" ,
senderId: "admin" ,
}),
).toEqual({ allow: ["read" ] });
});
it("should resolve telegram group tool policy for topic session keys" , () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
groups: {
"123" : {
tools: { allow: ["read" ] },
},
},
},
},
};
expect(resolveChannelGroupToolsPolicy({ cfg, channel: "telegram" , groupId: "123" })).toEqual({
allow: ["read" ],
});
});
it("should resolve feishu group tool policy for sender-scoped session keys" , () => {
const cfg: OpenClawConfig = {
channels: {
feishu: {
groups: {
oc_group_chat: {
tools: { allow: ["read" ] },
},
},
},
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user" ,
messageProvider: "feishu" ,
workspaceDir: "/tmp/test-feishu-scoped-group" ,
agentDir: "/tmp/agent-feishu" ,
});
const names = tools.map((t) => t.name);
expect(names).toContain("read" );
expect(names).not.toContain("exec" );
});
it("should prefer scoped group candidates before wildcard tool policy" , () => {
const cfg: OpenClawConfig = {
channels: {
feishu: {
groups: {
"*" : {
tools: { allow: ["read" , "exec" ] },
},
oc_group_chat: {
tools: { allow: ["read" ] },
},
},
},
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user" ,
messageProvider: "feishu" ,
workspaceDir: "/tmp/test-feishu-wildcard-group" ,
agentDir: "/tmp/agent-feishu-wildcard" ,
});
const names = tools.map((t) => t.name);
expect(names).toContain("read" );
expect(names).not.toContain("exec" );
});
it("should resolve inherited group tool policy for subagent parent groups" , () => {
const cfg: OpenClawConfig = {
channels: {
whatsapp: {
groups: {
trusted: {
tools: { allow: ["read" ] },
},
},
},
},
};
expect(
resolveChannelGroupToolsPolicy({ cfg, channel: "whatsapp" , groupId: "trusted" }),
).toEqual({ allow: ["read" ] });
});
it("should apply global tool policy before agent-specific policy" , () => {
const cfg: OpenClawConfig = {
tools: {
deny: ["browser" ], // Global deny
},
agents: {
list: [
{
id: "work" ,
workspace: "~/openclaw-work" ,
tools: {
deny: ["exec" , "process" ], // Agent deny (override)
},
},
],
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:work:slack:dm:user123" ,
workspaceDir: "/tmp/test-work" ,
agentDir: "/tmp/agent-work" ,
});
const toolNames = tools.map((t) => t.name);
// Global policy still applies; agent policy further restricts
expect(toolNames).not.toContain("browser" );
expect(toolNames).not.toContain("exec" );
expect(toolNames).not.toContain("process" );
expect(toolNames).not.toContain("apply_patch" );
});
it("should work with sandbox tools filtering" , () => {
const cfg = createRestrictedAgentSandboxConfig({
agentTools: {
allow: ["read" ], // Agent further restricts to only read
deny: ["exec" , "write" ],
},
globalSandboxTools: {
allow: ["read" , "write" , "exec" ], // Sandbox allows these
deny: [],
},
});
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:restricted:main" ,
workspaceDir: "/tmp/test-restricted" ,
agentDir: "/tmp/agent-restricted" ,
sandbox: {
enabled: true ,
backendId: "docker" ,
sessionKey: "agent:restricted:main" ,
workspaceDir: "/tmp/sandbox" ,
agentWorkspaceDir: "/tmp/test-restricted" ,
workspaceAccess: "none" ,
runtimeId: "test-container" ,
runtimeLabel: "test-container" ,
containerName: "test-container" ,
containerWorkdir: "/workspace" ,
docker: {
image: "test-image" ,
containerPrefix: "test-" ,
workdir: "/workspace" ,
readOnlyRoot: true ,
tmpfs: [],
network: "none" ,
capDrop: [],
} satisfies SandboxDockerConfig,
tools: {
allow: ["read" , "write" , "exec" ],
deny: [],
},
fsBridge: sandboxFsBridgeStub,
browserAllowHostControl: false ,
},
});
const toolNames = tools.map((t) => t.name);
// Agent policy should be applied first, then sandbox
// Agent allows only "read", sandbox allows ["read", "write", "exec"]
// Result: only "read" (most restrictive wins)
expect(toolNames).toContain("read" );
expect(toolNames).not.toContain("exec" );
expect(toolNames).not.toContain("write" );
});
});
Messung V0.5 in Prozent C=99 H=98 G=98
¤ Dauer der Verarbeitung: 0.15 Sekunden
(vorverarbeitet am 2026-05-26)
¤
*© Formatika GbR, Deutschland