Files
openclaw/src/plugins/hooks.security.test.ts
2026-03-28 03:30:25 +00:00

260 lines
8.8 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { createHookRunner } from "./hooks.js";
import { addStaticTestHooks } from "./hooks.test-helpers.js";
import { createEmptyPluginRegistry, type PluginRegistry } from "./registry.js";
import type { PluginHookBeforeToolCallResult, PluginHookMessageSendingResult } from "./types.js";
const toolEvent = { toolName: "bash", params: { command: "echo hello" } };
const toolCtx = { toolName: "bash" };
const messageEvent = { to: "user-1", content: "hello" };
const messageCtx = { channelId: "telegram" };
async function runBeforeToolCallWithHooks(
registry: PluginRegistry,
hooks: ReadonlyArray<{
pluginId: string;
result: PluginHookBeforeToolCallResult;
priority?: number;
handler?: () => PluginHookBeforeToolCallResult | Promise<PluginHookBeforeToolCallResult>;
}>,
catchErrors = true,
) {
addStaticTestHooks(registry, {
hookName: "before_tool_call",
hooks,
});
const runner = createHookRunner(registry, { catchErrors });
return await runner.runBeforeToolCall(toolEvent, toolCtx);
}
async function runMessageSendingWithHooks(
registry: PluginRegistry,
hooks: ReadonlyArray<{
pluginId: string;
result: PluginHookMessageSendingResult;
priority?: number;
handler?: () => PluginHookMessageSendingResult | Promise<PluginHookMessageSendingResult>;
}>,
catchErrors = true,
) {
addStaticTestHooks(registry, {
hookName: "message_sending",
hooks,
});
const runner = createHookRunner(registry, { catchErrors });
return await runner.runMessageSending(messageEvent, messageCtx);
}
function expectTerminalHookState<
TResult extends { block?: boolean; blockReason?: string; cancel?: boolean; content?: string },
>(result: TResult | undefined, expected: Partial<TResult>) {
if ("block" in expected) {
expect(result?.block).toBe(expected.block);
}
if ("blockReason" in expected) {
expect(result?.blockReason).toBe(expected.blockReason);
}
if ("cancel" in expected) {
expect(result?.cancel).toBe(expected.cancel);
}
if ("content" in expected) {
expect(result?.content).toBe(expected.content);
}
}
describe("before_tool_call terminal block semantics", () => {
let registry: PluginRegistry;
beforeEach(() => {
registry = createEmptyPluginRegistry();
});
it.each([
{
name: "keeps block=true when a lower-priority hook returns block=false",
hooks: [
{ pluginId: "high", result: { block: true, blockReason: "dangerous" }, priority: 100 },
{ pluginId: "low", result: { block: false }, priority: 10 },
],
expected: { block: true, blockReason: "dangerous" },
},
{
name: "treats explicit block=false as no-op when no prior hook blocked",
hooks: [{ pluginId: "single", result: { block: false }, priority: 10 }],
expected: { block: undefined },
},
{
name: "treats passive handler output as no-op for prior block",
hooks: [
{ pluginId: "high", result: { block: true, blockReason: "blocked" }, priority: 100 },
{ pluginId: "passive", result: {}, priority: 10 },
],
expected: { block: true, blockReason: "blocked" },
},
{
name: "respects block from a middle hook in a multi-handler chain",
hooks: [
{ pluginId: "high-passive", result: {}, priority: 100 },
{ pluginId: "middle-block", result: { block: true, blockReason: "mid" }, priority: 50 },
{ pluginId: "low-false", result: { block: false }, priority: 0 },
],
expected: { block: true, blockReason: "mid" },
},
] as const)("$name", async ({ hooks, expected }) => {
const result = await runBeforeToolCallWithHooks(registry, hooks);
expectTerminalHookState(result, expected);
});
it("short-circuits lower-priority hooks after block=true", async () => {
const high = vi.fn().mockReturnValue({ block: true, blockReason: "stop" });
const low = vi.fn().mockReturnValue({ params: { injected: true } });
const result = await runBeforeToolCallWithHooks(registry, [
{
pluginId: "high",
result: { block: true, blockReason: "stop" },
priority: 100,
handler: high,
},
{ pluginId: "low", result: { params: { injected: true } }, priority: 10, handler: low },
]);
expect(result?.block).toBe(true);
expect(high).toHaveBeenCalledTimes(1);
expect(low).not.toHaveBeenCalled();
});
it("preserves deterministic same-priority registration order when terminal hook runs first", async () => {
const first = vi.fn().mockReturnValue({ block: true, blockReason: "first" });
const second = vi.fn().mockReturnValue({ block: true, blockReason: "second" });
const result = await runBeforeToolCallWithHooks(registry, [
{
pluginId: "first",
result: { block: true, blockReason: "first" },
priority: 50,
handler: first,
},
{
pluginId: "second",
result: { block: true, blockReason: "second" },
priority: 50,
handler: second,
},
]);
expect(result?.block).toBe(true);
expect(result?.blockReason).toBe("first");
expect(first).toHaveBeenCalledTimes(1);
expect(second).not.toHaveBeenCalled();
});
it("stops before lower-priority throwing hooks when catchErrors is false", async () => {
const low = vi.fn().mockImplementation(() => {
throw new Error("should not run");
});
const result = await runBeforeToolCallWithHooks(
registry,
[
{ pluginId: "high", result: { block: true, blockReason: "guard" }, priority: 100 },
{ pluginId: "low", result: {}, priority: 10, handler: low },
],
false,
);
expect(result?.block).toBe(true);
expect(low).not.toHaveBeenCalled();
});
});
describe("message_sending terminal cancel semantics", () => {
let registry: PluginRegistry;
beforeEach(() => {
registry = createEmptyPluginRegistry();
});
it.each([
{
name: "keeps cancel=true when a lower-priority hook returns cancel=false",
hooks: [
{ pluginId: "high", result: { cancel: true, content: "guarded" }, priority: 100 },
{ pluginId: "low", result: { cancel: false, content: "override" }, priority: 10 },
],
expected: { cancel: true, content: "guarded" },
},
{
name: "treats explicit cancel=false as no-op when no prior hook canceled",
hooks: [{ pluginId: "single", result: { cancel: false }, priority: 10 }],
expected: { cancel: undefined },
},
{
name: "treats passive handler output as no-op for prior cancel",
hooks: [
{ pluginId: "high", result: { cancel: true }, priority: 100 },
{ pluginId: "passive", result: {}, priority: 10 },
],
expected: { cancel: true },
},
{
name: "allows lower-priority cancel when higher-priority hooks are non-terminal",
hooks: [
{ pluginId: "high-passive", result: { content: "rewritten" }, priority: 100 },
{ pluginId: "low-cancel", result: { cancel: true }, priority: 10 },
],
expected: { cancel: true },
},
] as const)("$name", async ({ hooks, expected }) => {
const result = await runMessageSendingWithHooks(registry, hooks);
expectTerminalHookState(result, expected);
});
it("short-circuits lower-priority hooks after cancel=true", async () => {
const high = vi.fn().mockReturnValue({ cancel: true, content: "guarded" });
const low = vi.fn().mockReturnValue({ cancel: false, content: "mutated" });
const result = await runMessageSendingWithHooks(registry, [
{
pluginId: "high",
result: { cancel: true, content: "guarded" },
priority: 100,
handler: high,
},
{
pluginId: "low",
result: { cancel: false, content: "mutated" },
priority: 10,
handler: low,
},
]);
expect(result?.cancel).toBe(true);
expect(result?.content).toBe("guarded");
expect(high).toHaveBeenCalledTimes(1);
expect(low).not.toHaveBeenCalled();
});
it("preserves deterministic same-priority registration order for non-terminal merges", async () => {
const result = await runMessageSendingWithHooks(registry, [
{ pluginId: "first", result: { content: "first" }, priority: 50 },
{ pluginId: "second", result: { content: "second" }, priority: 50 },
]);
expect(result?.content).toBe("second");
});
it("stops before lower-priority throwing hooks when catchErrors is false", async () => {
const low = vi.fn().mockImplementation(() => {
throw new Error("should not run");
});
const result = await runMessageSendingWithHooks(
registry,
[
{ pluginId: "high", result: { cancel: true }, priority: 100 },
{ pluginId: "low", result: {}, priority: 10, handler: low },
],
false,
);
expect(result?.cancel).toBe(true);
expect(low).not.toHaveBeenCalled();
});
});