Compare commits

...

3 Commits

Author SHA1 Message Date
Vincent Koc
b71d5609d9 Skills: expand direct slash dispatch regression coverage 2026-03-14 23:19:20 -07:00
Vincent Koc
6b47a95c6f Changelog: note direct skill tool policy fix 2026-03-14 20:17:39 -07:00
Vincent Koc
190a6d3426 Skills: apply tool policy to direct slash dispatch 2026-03-14 20:12:26 -07:00
3 changed files with 295 additions and 15 deletions

View File

@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
- Skills/tool policy: apply the full tool-policy pipeline to direct `/skill` dispatch so denied tools stay unavailable in inline actions too. Thanks @vincentkoc.
### Fixes

View File

@ -0,0 +1,167 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SkillCommandSpec } from "../../agents/skills.js";
import type { TemplateContext } from "../templating.js";
import { clearInlineDirectives } from "./get-reply-directives-utils.js";
import { buildTestCtx } from "./test-ctx.js";
import type { TypingController } from "./typing.js";
const handleCommandsMock = vi.fn();
const gatewayExecuteMock = vi.fn();
const readExecuteMock = vi.fn();
vi.mock("./commands.js", () => ({
handleCommands: (...args: unknown[]) => handleCommandsMock(...args),
buildStatusReply: vi.fn(),
buildCommandContext: vi.fn(),
}));
vi.mock("../../agents/openclaw-tools.js", () => ({
createOpenClawTools: () => [
{
name: "gateway",
ownerOnly: true,
execute: gatewayExecuteMock,
},
{
name: "read",
execute: readExecuteMock,
},
],
}));
const { handleInlineActions } = await import("./get-reply-inline-actions.js");
type HandleInlineActionsInput = Parameters<typeof handleInlineActions>[0];
const createTypingController = (): TypingController => ({
onReplyStart: async () => {},
startTypingLoop: async () => {},
startTypingOnText: async () => {},
refreshTypingTtl: () => {},
isActive: () => false,
markRunComplete: () => {},
markDispatchIdle: () => {},
cleanup: vi.fn(),
});
const defaultSkillCommands: SkillCommandSpec[] = [
{
name: "danger-skill",
skillName: "danger-skill",
description: "Direct tool dispatch",
dispatch: {
kind: "tool",
toolName: "gateway",
},
},
{
name: "read-skill",
skillName: "read-skill",
description: "Allowed direct tool dispatch",
dispatch: {
kind: "tool",
toolName: "read",
},
},
];
function createInput(overrides?: {
body?: string;
senderIsOwner?: boolean;
skillCommands?: SkillCommandSpec[];
}): HandleInlineActionsInput {
const body = overrides?.body ?? "/danger-skill test";
const ctx = buildTestCtx({
Body: body,
CommandBody: body,
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+123",
To: "whatsapp:+123",
});
return {
ctx,
sessionCtx: ctx as unknown as TemplateContext,
cfg: {
commands: { text: true },
tools: {
deny: ["gateway"],
},
},
agentId: "main",
sessionKey: "agent:main:main",
workspaceDir: "/tmp",
isGroup: false,
typing: createTypingController(),
allowTextCommands: true,
inlineStatusRequested: false,
command: {
surface: "whatsapp",
channel: "whatsapp",
channelId: "whatsapp",
ownerList: [],
senderIsOwner: overrides?.senderIsOwner ?? true,
isAuthorizedSender: true,
senderId: "owner-1",
abortKey: "whatsapp:+123",
rawBodyNormalized: body,
commandBodyNormalized: body,
from: "whatsapp:+123",
to: "whatsapp:+123",
},
directives: clearInlineDirectives(body),
cleanedBody: body,
elevatedEnabled: false,
elevatedAllowed: false,
elevatedFailures: [],
defaultActivation: () => "always",
resolvedThinkLevel: undefined,
resolvedVerboseLevel: undefined,
resolvedReasoningLevel: "off",
resolvedElevatedLevel: "off",
resolveDefaultThinkingLevel: async () => "off",
provider: "openai",
model: "gpt-4o-mini",
contextTokens: 0,
abortedLastRun: false,
sessionScope: "per-sender",
skillCommands: overrides?.skillCommands ?? defaultSkillCommands,
};
}
describe("handleInlineActions skill tool dispatch", () => {
beforeEach(() => {
handleCommandsMock.mockReset();
gatewayExecuteMock.mockReset().mockResolvedValue({ content: "EXECUTED" });
readExecuteMock.mockReset().mockResolvedValue({ content: "READ" });
});
it("applies the tool policy pipeline before direct /skill tool execution", async () => {
const result = await handleInlineActions(createInput());
expect(result).toEqual({
kind: "reply",
reply: { text: "❌ Tool not available: gateway" },
});
expect(gatewayExecuteMock).not.toHaveBeenCalled();
});
it("executes an allowed tool through direct /skill dispatch", async () => {
const result = await handleInlineActions(createInput({ body: "/read-skill test" }));
expect(result).toEqual({
kind: "reply",
reply: { text: "READ" },
});
expect(readExecuteMock).toHaveBeenCalled();
});
it("keeps owner-only tools blocked for non-owners before policy resolution", async () => {
const result = await handleInlineActions(createInput({ senderIsOwner: false }));
expect(result).toEqual({
kind: "reply",
reply: { text: "❌ Tool not available: gateway" },
});
expect(gatewayExecuteMock).not.toHaveBeenCalled();
});
});

View File

@ -1,13 +1,26 @@
import { collectTextContentBlocks } from "../../agents/content-blocks.js";
import { createOpenClawTools } from "../../agents/openclaw-tools.js";
import type { BlockReplyChunking } from "../../agents/pi-embedded-block-chunker.js";
import {
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
resolveSubagentToolPolicyForSession,
} from "../../agents/pi-tools.policy.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js";
import type { SkillCommandSpec } from "../../agents/skills.js";
import { applyOwnerOnlyToolPolicy } from "../../agents/tool-policy.js";
import {
applyToolPolicyPipeline,
buildDefaultToolPolicyPipelineSteps,
} from "../../agents/tool-policy-pipeline.js";
import { resolveToolProfilePolicy } from "../../agents/tool-policy-shared.js";
import { applyOwnerOnlyToolPolicy, mergeAlsoAllowPolicy } from "../../agents/tool-policy.js";
import { getChannelDock } from "../../channels/dock.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { generateSecureToken } from "../../infra/secure-random.js";
import { getPluginToolMeta } from "../../plugins/tools.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
import { resolveGatewayMessageChannel } from "../../utils/message-channel.js";
import {
listReservedChatSlashCommandNames,
@ -85,6 +98,110 @@ function extractTextFromToolResult(result: any): string | null {
return trimmed ? trimmed : null;
}
function resolveSkillDispatchTools(params: {
ctx: MsgContext;
cfg: OpenClawConfig;
agentId: string;
sessionKey: string;
workspaceDir: string;
agentDir?: string;
provider: string;
senderIsOwner: boolean;
senderId?: string;
}) {
const channel =
resolveGatewayMessageChannel(params.ctx.Surface) ??
resolveGatewayMessageChannel(params.ctx.Provider) ??
undefined;
const tools = createOpenClawTools({
agentSessionKey: params.sessionKey,
agentChannel: channel,
agentAccountId: (params.ctx as { AccountId?: string }).AccountId,
agentTo: params.ctx.OriginatingTo ?? params.ctx.To,
agentThreadId: params.ctx.MessageThreadId ?? undefined,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
config: params.cfg,
requesterSenderId: params.senderId ?? undefined,
senderIsOwner: params.senderIsOwner,
});
const toolsByAuthorization = applyOwnerOnlyToolPolicy(tools, params.senderIsOwner);
const {
agentId: resolvedAgentId,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
profile,
providerProfile,
profileAlsoAllow,
providerProfileAlsoAllow,
} = resolveEffectiveToolPolicy({
config: params.cfg,
sessionKey: params.sessionKey,
agentId: params.agentId,
modelProvider: params.provider,
});
const groupCtx = params.ctx as {
AccountId?: string;
GroupID?: string;
GroupChannel?: string;
GroupSpace?: string;
SenderId?: string;
SenderName?: string;
SenderUsername?: string;
SenderE164?: string;
};
const groupPolicy = resolveGroupToolPolicy({
config: params.cfg,
sessionKey: params.sessionKey,
messageProvider: channel,
groupId: groupCtx.GroupID,
groupChannel: groupCtx.GroupChannel,
groupSpace: groupCtx.GroupSpace,
accountId: groupCtx.AccountId,
senderId: groupCtx.SenderId,
senderName: groupCtx.SenderName,
senderUsername: groupCtx.SenderUsername,
senderE164: groupCtx.SenderE164,
});
const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), profileAlsoAllow);
const providerProfilePolicy = mergeAlsoAllowPolicy(
resolveToolProfilePolicy(providerProfile),
providerProfileAlsoAllow,
);
const sandboxRuntime = resolveSandboxRuntimeStatus({
cfg: params.cfg,
sessionKey: params.sessionKey,
});
const sandboxPolicy = sandboxRuntime.sandboxed ? sandboxRuntime.toolPolicy : undefined;
const subagentPolicy = isSubagentSessionKey(params.sessionKey)
? resolveSubagentToolPolicyForSession(params.cfg, params.sessionKey)
: undefined;
return applyToolPolicyPipeline({
tools: toolsByAuthorization,
toolMeta: (tool) => getPluginToolMeta(tool),
warn: logVerbose,
steps: [
...buildDefaultToolPolicyPipelineSteps({
profilePolicy,
profile,
providerProfilePolicy,
providerProfile,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
agentId: resolvedAgentId,
}),
{ policy: sandboxPolicy, label: "sandbox tools.allow" },
{ policy: subagentPolicy, label: "subagent tools.allow" },
],
});
}
export async function handleInlineActions(params: {
ctx: MsgContext;
sessionCtx: TemplateContext;
@ -206,22 +323,17 @@ export async function handleInlineActions(params: {
const dispatch = skillInvocation.command.dispatch;
if (dispatch?.kind === "tool") {
const rawArgs = (skillInvocation.args ?? "").trim();
const channel =
resolveGatewayMessageChannel(ctx.Surface) ??
resolveGatewayMessageChannel(ctx.Provider) ??
undefined;
const tools = createOpenClawTools({
agentSessionKey: sessionKey,
agentChannel: channel,
agentAccountId: (ctx as { AccountId?: string }).AccountId,
agentTo: ctx.OriginatingTo ?? ctx.To,
agentThreadId: ctx.MessageThreadId ?? undefined,
agentDir,
const authorizedTools = resolveSkillDispatchTools({
ctx,
cfg,
agentId,
sessionKey,
workspaceDir,
config: cfg,
agentDir,
provider,
senderIsOwner: command.senderIsOwner,
senderId: command.senderId,
});
const authorizedTools = applyOwnerOnlyToolPolicy(tools, command.senderIsOwner);
const tool = authorizedTools.find((candidate) => candidate.name === dispatch.toolName);
if (!tool) {