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
7 changed files with 297 additions and 128 deletions

View File

@@ -22,7 +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`.
- Device pairing/setup codes: bind setup-code pairing to the intended node role and scope set so approval keeps the expected device profile. Thanks @vincentkoc.
- 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

@@ -407,12 +407,7 @@ export default function register(api: OpenClawPluginApi) {
const payload: SetupPayload = {
url: urlResult.url,
bootstrapToken: (
await issueDeviceBootstrapToken({
role: "node",
scopes: [],
})
).token,
bootstrapToken: (await issueDeviceBootstrapToken()).token,
};
if (action === "qr") {

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) {

View File

@@ -43,22 +43,6 @@ describe("device bootstrap tokens", () => {
});
});
it("persists an intended role and scopes when requested", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({
baseDir,
role: "node",
scopes: [],
});
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
const parsed = JSON.parse(raw) as Record<string, { roles?: string[]; scopes?: string[] }>;
expect(parsed[issued.token]).toMatchObject({
roles: ["node"],
scopes: [],
});
});
it("verifies valid bootstrap tokens once and deletes them after success", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
@@ -217,64 +201,4 @@ describe("device bootstrap tokens", () => {
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
});
it("rejects a role that does not match the issued pairing profile", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({
baseDir,
role: "node",
scopes: [],
});
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator",
scopes: [],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
});
it("accepts constrained tokens when the requested role and scopes match", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({
baseDir,
role: "node",
scopes: [],
});
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "node",
scopes: [],
baseDir,
}),
).resolves.toEqual({ ok: true });
});
it("rejects scopes that do not match the issued pairing profile", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({
baseDir,
role: "node",
scopes: [],
});
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "node",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
});
});

View File

@@ -1,5 +1,4 @@
import path from "node:path";
import { normalizeDeviceAuthRole, normalizeDeviceAuthScopes } from "../shared/device-auth.js";
import { resolvePairingPaths } from "./pairing-files.js";
import {
createAsyncLock,
@@ -64,24 +63,16 @@ async function persistState(state: DeviceBootstrapStateFile, baseDir?: string):
export async function issueDeviceBootstrapToken(
params: {
baseDir?: string;
role?: string;
scopes?: readonly string[];
} = {},
): Promise<{ token: string; expiresAtMs: number }> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const token = generatePairingToken();
const issuedAtMs = Date.now();
const role = params.role?.trim();
const scopes = normalizeDeviceAuthScopes(
Array.isArray(params.scopes) ? [...params.scopes] : undefined,
);
state[token] = {
token,
ts: issuedAtMs,
issuedAtMs,
...(role ? { roles: [normalizeDeviceAuthRole(role)] } : {}),
...(scopes.length > 0 || Array.isArray(params.scopes) ? { scopes } : {}),
};
await persistState(state, params.baseDir);
return { token, expiresAtMs: issuedAtMs + DEVICE_BOOTSTRAP_TOKEN_TTL_MS };
@@ -111,28 +102,10 @@ export async function verifyDeviceBootstrapToken(params: {
const deviceId = params.deviceId.trim();
const publicKey = params.publicKey.trim();
const role = normalizeDeviceAuthRole(params.role);
const requestedScopes = normalizeDeviceAuthScopes([...params.scopes]);
const role = params.role.trim();
if (!deviceId || !publicKey || !role) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
const allowedRoles = Array.isArray(entry.roles)
? entry.roles.map((value) => normalizeDeviceAuthRole(String(value))).filter(Boolean)
: [];
if (allowedRoles.length > 0 && !allowedRoles.includes(role)) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
if (Array.isArray(entry.scopes)) {
const allowedScopes = normalizeDeviceAuthScopes(entry.scopes);
// Both arrays are normalized through normalizeDeviceAuthScopes, which
// sorts and deduplicates them before comparison.
if (
allowedScopes.length !== requestedScopes.length ||
allowedScopes.some((value, index) => value !== requestedScopes[index])
) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
}
// Bootstrap setup codes are single-use. Consume the record before returning
// success so the same token cannot be replayed to mutate a pending request.

View File

@@ -400,8 +400,6 @@ export async function resolvePairingSetupFromConfig(
bootstrapToken: (
await issueDeviceBootstrapToken({
baseDir: options.pairingBaseDir,
role: "node",
scopes: [],
})
).token,
},