mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
2761 lines
92 KiB
TypeScript
2761 lines
92 KiB
TypeScript
import fs from "node:fs/promises";
|
|
/**
|
|
* Integration-style tests for before_tool_call behavior.
|
|
* Covers loop detection, diagnostics, plugin approval, and skill telemetry
|
|
* around wrapped tool execution.
|
|
*/
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { GatewayClientRequestError } from "../gateway/client.js";
|
|
import {
|
|
onInternalDiagnosticEvent,
|
|
onDiagnosticEvent,
|
|
onTrustedInternalDiagnosticEvent,
|
|
resetDiagnosticEventsForTest,
|
|
type DiagnosticEventPayload,
|
|
type DiagnosticEventPrivateData,
|
|
type DiagnosticToolLoopEvent,
|
|
} from "../infra/diagnostic-events.js";
|
|
import { MAX_PLUGIN_APPROVAL_TIMEOUT_MS } from "../infra/plugin-approvals.js";
|
|
import { resetDiagnosticSessionStateForTest } from "../logging/diagnostic-session-state.js";
|
|
import {
|
|
PluginApprovalResolutions,
|
|
type PluginApprovalResolution,
|
|
} from "../plugins/hook-before-tool-call-result.js";
|
|
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
|
import { createHookRunner, type HookRunner } from "../plugins/hooks.js";
|
|
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
|
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
|
import { setPluginToolMeta } from "../plugins/tools.js";
|
|
import { createCanonicalFixtureSkill } from "../skills/test-support/test-helpers.js";
|
|
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
|
|
import {
|
|
getBeforeToolCallFailureDisposition,
|
|
getBeforeToolCallPolicyDiagnosticState,
|
|
runBeforeToolCallHook,
|
|
wrapToolWithBeforeToolCallHook,
|
|
} from "./agent-tools.before-tool-call.js";
|
|
import { createOpenClawCodingTools } from "./agent-tools.js";
|
|
import { CRITICAL_THRESHOLD } from "./tool-loop-detection.js";
|
|
import type { AnyAgentTool } from "./tools/common.js";
|
|
import { callGatewayTool } from "./tools/gateway.js";
|
|
|
|
vi.mock("../plugins/hook-runner-global.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../plugins/hook-runner-global.js")>(
|
|
"../plugins/hook-runner-global.js",
|
|
);
|
|
return {
|
|
...actual,
|
|
getGlobalHookRunner: vi.fn(actual.getGlobalHookRunner),
|
|
};
|
|
});
|
|
vi.mock("./tools/gateway.js", () => ({
|
|
callGatewayTool: vi.fn(),
|
|
}));
|
|
|
|
const mockGetGlobalHookRunner = vi.mocked(getGlobalHookRunner);
|
|
const hookRunnerGlobalStateKey = Symbol.for("openclaw.plugins.hook-runner-global-state");
|
|
|
|
function setGlobalHookRunnerForTest(hookRunner: HookRunner | null): void {
|
|
const hookRunnerGlobalState = globalThis as Record<
|
|
symbol,
|
|
{ hookRunner: HookRunner | null; registry?: unknown } | undefined
|
|
>;
|
|
if (!hookRunnerGlobalState[hookRunnerGlobalStateKey]) {
|
|
hookRunnerGlobalState[hookRunnerGlobalStateKey] = {
|
|
hookRunner: null,
|
|
registry: null,
|
|
};
|
|
}
|
|
hookRunnerGlobalState[hookRunnerGlobalStateKey].hookRunner = hookRunner;
|
|
}
|
|
|
|
function getGlobalHookRunnerForTest(): HookRunner | null {
|
|
const hookRunnerGlobalState = globalThis as Record<
|
|
symbol,
|
|
{ hookRunner: HookRunner | null; registry?: unknown } | undefined
|
|
>;
|
|
return hookRunnerGlobalState[hookRunnerGlobalStateKey]?.hookRunner ?? null;
|
|
}
|
|
|
|
type TestHookRunner = HookRunner & {
|
|
hasHooks: ReturnType<typeof vi.fn<HookRunner["hasHooks"]>>;
|
|
runBeforeToolCall: ReturnType<typeof vi.fn<HookRunner["runBeforeToolCall"]>>;
|
|
};
|
|
|
|
function createTestHookRunner(): TestHookRunner {
|
|
return {
|
|
...createHookRunner(createEmptyPluginRegistry()),
|
|
hasHooks: vi.fn<HookRunner["hasHooks"]>(),
|
|
runBeforeToolCall: vi.fn<HookRunner["runBeforeToolCall"]>(),
|
|
};
|
|
}
|
|
|
|
function asAgentTool(tool: { name: string; execute: ReturnType<typeof vi.fn> }): AnyAgentTool {
|
|
return tool as unknown as AnyAgentTool;
|
|
}
|
|
|
|
afterEach(() => {
|
|
setGlobalHookRunnerForTest(null);
|
|
mockGetGlobalHookRunner.mockReset();
|
|
mockGetGlobalHookRunner.mockImplementation(() => getGlobalHookRunnerForTest());
|
|
});
|
|
|
|
describe("before_tool_call loop detection behavior", () => {
|
|
let hookRunner: TestHookRunner;
|
|
const enabledLoopDetectionContext = {
|
|
agentId: "main",
|
|
sessionKey: "main",
|
|
loopDetection: { enabled: true },
|
|
};
|
|
|
|
const disabledLoopDetectionContext = {
|
|
agentId: "main",
|
|
sessionKey: "main",
|
|
loopDetection: { enabled: false },
|
|
};
|
|
|
|
beforeEach(() => {
|
|
resetDiagnosticSessionStateForTest();
|
|
resetDiagnosticEventsForTest();
|
|
hookRunner = createTestHookRunner();
|
|
mockGetGlobalHookRunner.mockReturnValue(hookRunner);
|
|
hookRunner.hasHooks.mockReturnValue(false);
|
|
});
|
|
|
|
function createWrappedTool(
|
|
name: string,
|
|
execute: ReturnType<typeof vi.fn>,
|
|
loopDetectionContext = enabledLoopDetectionContext,
|
|
) {
|
|
return wrapToolWithBeforeToolCallHook(
|
|
{ name, execute } as unknown as AnyAgentTool,
|
|
loopDetectionContext,
|
|
);
|
|
}
|
|
|
|
async function withToolLoopEvents(
|
|
run: (emitted: DiagnosticToolLoopEvent[]) => Promise<void>,
|
|
filter: (evt: DiagnosticToolLoopEvent) => boolean = () => true,
|
|
) {
|
|
const emitted: DiagnosticToolLoopEvent[] = [];
|
|
const stop = onDiagnosticEvent((evt) => {
|
|
if (evt.type === "tool.loop" && filter(evt)) {
|
|
emitted.push(evt);
|
|
}
|
|
});
|
|
try {
|
|
await run(emitted);
|
|
} finally {
|
|
stop();
|
|
}
|
|
}
|
|
|
|
async function withToolExecutionEvents(
|
|
run: (emitted: DiagnosticEventPayload[], flush: () => Promise<void>) => Promise<void>,
|
|
) {
|
|
const emitted: DiagnosticEventPayload[] = [];
|
|
const stop = onInternalDiagnosticEvent((evt) => {
|
|
if (evt.type.startsWith("tool.execution.")) {
|
|
emitted.push(evt);
|
|
}
|
|
});
|
|
const flush = () =>
|
|
new Promise<void>((resolve) => {
|
|
setImmediate(resolve);
|
|
});
|
|
try {
|
|
await run(emitted, flush);
|
|
} finally {
|
|
stop();
|
|
}
|
|
}
|
|
|
|
async function withDiagnosticEvents(
|
|
run: (emitted: DiagnosticEventPayload[], flush: () => Promise<void>) => Promise<void>,
|
|
) {
|
|
const emitted: DiagnosticEventPayload[] = [];
|
|
const stop = onInternalDiagnosticEvent((evt) => {
|
|
emitted.push(evt);
|
|
});
|
|
const flush = () =>
|
|
new Promise<void>((resolve) => {
|
|
setImmediate(resolve);
|
|
});
|
|
try {
|
|
await run(emitted, flush);
|
|
} finally {
|
|
stop();
|
|
}
|
|
}
|
|
|
|
async function withSkillUsageDiagnosticEvents(
|
|
run: (
|
|
emitted: DiagnosticEventPayload[],
|
|
privateData: DiagnosticEventPrivateData[],
|
|
flush: () => Promise<void>,
|
|
) => Promise<void>,
|
|
) {
|
|
const emitted: DiagnosticEventPayload[] = [];
|
|
const skillUsagePrivateData: DiagnosticEventPrivateData[] = [];
|
|
const stopShared = onInternalDiagnosticEvent((event) => emitted.push(event));
|
|
const stopTrusted = onTrustedInternalDiagnosticEvent((event, _metadata, privateData) => {
|
|
if (event.type === "skill.used") {
|
|
skillUsagePrivateData.push(privateData);
|
|
}
|
|
});
|
|
const flush = () =>
|
|
new Promise<void>((resolve) => {
|
|
setImmediate(resolve);
|
|
});
|
|
try {
|
|
await run(emitted, skillUsagePrivateData, flush);
|
|
} finally {
|
|
stopTrusted();
|
|
stopShared();
|
|
}
|
|
}
|
|
|
|
function createPingPongTools(options?: { withProgress?: boolean }) {
|
|
const readExecute = options?.withProgress
|
|
? vi.fn().mockImplementation(async (toolCallId: string) => ({
|
|
content: [{ type: "text", text: `read ${toolCallId}` }],
|
|
details: { ok: true },
|
|
}))
|
|
: vi.fn().mockResolvedValue({
|
|
content: [{ type: "text", text: "read ok" }],
|
|
details: { ok: true },
|
|
});
|
|
const listExecute = options?.withProgress
|
|
? vi.fn().mockImplementation(async (toolCallId: string) => ({
|
|
content: [{ type: "text", text: `list ${toolCallId}` }],
|
|
details: { ok: true },
|
|
}))
|
|
: vi.fn().mockResolvedValue({
|
|
content: [{ type: "text", text: "list ok" }],
|
|
details: { ok: true },
|
|
});
|
|
return {
|
|
readTool: createWrappedTool("read", readExecute),
|
|
listTool: createWrappedTool("list", listExecute),
|
|
};
|
|
}
|
|
|
|
async function runPingPongSequence(
|
|
readTool: ReturnType<typeof createWrappedTool>,
|
|
listTool: ReturnType<typeof createWrappedTool>,
|
|
count: number,
|
|
) {
|
|
for (let i = 0; i < count; i += 1) {
|
|
if (i % 2 === 0) {
|
|
await readTool.execute(`read-${i}`, { path: "/a.txt" }, undefined, undefined);
|
|
} else {
|
|
await listTool.execute(`list-${i}`, { dir: "/workspace" }, undefined, undefined);
|
|
}
|
|
}
|
|
}
|
|
|
|
function createGenericReadRepeatFixture() {
|
|
const execute = vi.fn().mockResolvedValue({
|
|
content: [{ type: "text", text: "same output" }],
|
|
details: { ok: true },
|
|
});
|
|
return {
|
|
tool: createWrappedTool("read", execute),
|
|
params: { path: "/tmp/file" },
|
|
};
|
|
}
|
|
|
|
function createNoProgressProcessFixture(sessionId: string) {
|
|
const execute = vi.fn().mockResolvedValue({
|
|
content: [{ type: "text", text: "(no new output)\n\nProcess still running." }],
|
|
details: { status: "running", aggregated: "steady" },
|
|
});
|
|
return {
|
|
tool: createWrappedTool("process", execute),
|
|
params: { action: "poll", sessionId },
|
|
};
|
|
}
|
|
|
|
function expectCriticalLoopEvent(
|
|
loopEvent: DiagnosticToolLoopEvent | undefined,
|
|
params: {
|
|
detector: "ping_pong" | "known_poll_no_progress";
|
|
toolName: string;
|
|
count?: number;
|
|
},
|
|
) {
|
|
expect(loopEvent?.type).toBe("tool.loop");
|
|
expect(loopEvent?.level).toBe("critical");
|
|
expect(loopEvent?.action).toBe("block");
|
|
expect(loopEvent?.detector).toBe(params.detector);
|
|
expect(loopEvent?.count).toBe(params.count ?? CRITICAL_THRESHOLD);
|
|
expect(loopEvent?.toolName).toBe(params.toolName);
|
|
}
|
|
|
|
function expectToolLoopBlockedResult(result: unknown, expectedReason: string) {
|
|
const record = requireRecord(result, "tool result");
|
|
const content = requireArray(record.content, "tool result content");
|
|
const textContent = requireRecord(content[0], "tool result content item");
|
|
expect(textContent.type).toBe("text");
|
|
expect(String(textContent.text)).toContain(expectedReason);
|
|
const details = requireRecord(record.details, "tool result details");
|
|
expect(details.status).toBe("blocked");
|
|
expect(details.deniedReason).toBe("tool-loop");
|
|
expect(String(details.reason)).toContain(expectedReason);
|
|
}
|
|
|
|
async function expectUnblockedToolExecution(
|
|
tool: ReturnType<typeof createWrappedTool>,
|
|
toolCallId: string,
|
|
params: unknown,
|
|
) {
|
|
const result = await tool.execute(toolCallId, params, undefined, undefined);
|
|
const record = requireRecord(result, "tool result");
|
|
requireArray(record.content, "tool result content");
|
|
requireRecord(record.details, "tool result details");
|
|
return result;
|
|
}
|
|
|
|
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null) {
|
|
throw new Error(`${label} was not an object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function requireArray(value: unknown, label: string): unknown[] {
|
|
expect(Array.isArray(value)).toBe(true);
|
|
if (!Array.isArray(value)) {
|
|
throw new Error(`${label} was not an array`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function expectEventFields(
|
|
event: DiagnosticEventPayload | DiagnosticToolLoopEvent | undefined,
|
|
fields: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const record = requireRecord(event, "diagnostic event");
|
|
for (const [key, value] of Object.entries(fields)) {
|
|
expect(record[key]).toEqual(value);
|
|
}
|
|
return record;
|
|
}
|
|
|
|
it("blocks known poll loops when no progress repeats", async () => {
|
|
const { tool, params } = createNoProgressProcessFixture("sess-1");
|
|
|
|
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
|
|
await expectUnblockedToolExecution(tool, `poll-${i}`, params);
|
|
}
|
|
|
|
await withDiagnosticEvents(async (emitted, flush) => {
|
|
const result = await tool.execute(`poll-${CRITICAL_THRESHOLD}`, params, undefined, undefined);
|
|
await flush();
|
|
expectToolLoopBlockedResult(result, "CRITICAL");
|
|
const securityEvent = emitted.find(
|
|
(event): event is Extract<DiagnosticEventPayload, { type: "security.event" }> =>
|
|
event.type === "security.event",
|
|
);
|
|
expect(securityEvent).toMatchObject({
|
|
type: "security.event",
|
|
category: "tool",
|
|
action: "tool.execution.blocked",
|
|
outcome: "denied",
|
|
reason: "tool-loop",
|
|
policy: {
|
|
id: "tool-loop-detection",
|
|
decision: "deny",
|
|
reason: "tool-loop",
|
|
},
|
|
control: {
|
|
id: "tool-loop-detection",
|
|
family: "authorization",
|
|
},
|
|
attributes: {
|
|
params_kind: "object",
|
|
tool_source: "core",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
it("does nothing when loopDetection.enabled is false", async () => {
|
|
const execute = vi.fn().mockResolvedValue({
|
|
content: [{ type: "text", text: "(no new output)\n\nProcess still running." }],
|
|
details: { status: "running", aggregated: "steady" },
|
|
});
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "process", execute }), {
|
|
...disabledLoopDetectionContext,
|
|
});
|
|
const params = { action: "poll", sessionId: "sess-off" };
|
|
|
|
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
|
|
await expectUnblockedToolExecution(tool, `poll-${i}`, params);
|
|
}
|
|
});
|
|
|
|
it("does not block known poll loops when output progresses", async () => {
|
|
const execute = vi.fn().mockImplementation(async (toolCallId: string) => {
|
|
return {
|
|
content: [{ type: "text", text: `output ${toolCallId}` }],
|
|
details: { status: "running", aggregated: `output ${toolCallId}` },
|
|
};
|
|
});
|
|
const tool = createWrappedTool("process", execute);
|
|
const params = { action: "poll", sessionId: "sess-2" };
|
|
|
|
for (let i = 0; i < CRITICAL_THRESHOLD + 5; i += 1) {
|
|
await expectUnblockedToolExecution(tool, `poll-progress-${i}`, params);
|
|
}
|
|
});
|
|
|
|
it("keeps generic repeated calls unblocked below critical threshold", async () => {
|
|
const { tool, params } = createGenericReadRepeatFixture();
|
|
|
|
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
|
|
await expectUnblockedToolExecution(tool, `read-${i}`, params);
|
|
}
|
|
});
|
|
|
|
it("blocks generic repeated no-progress calls at critical threshold", async () => {
|
|
const { tool, params } = createGenericReadRepeatFixture();
|
|
|
|
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
|
|
await expectUnblockedToolExecution(tool, `read-${i}`, params);
|
|
}
|
|
|
|
const result = await tool.execute(`read-${CRITICAL_THRESHOLD}`, params, undefined, undefined);
|
|
expectToolLoopBlockedResult(result, "identical outcomes");
|
|
});
|
|
|
|
it("does not carry loop history across run ids", async () => {
|
|
const execute = vi.fn().mockResolvedValue({
|
|
content: [{ type: "text", text: "same output" }],
|
|
details: { ok: true },
|
|
});
|
|
const params = { path: "/tmp/file" };
|
|
const firstRunTool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
...enabledLoopDetectionContext,
|
|
runId: "heartbeat-1",
|
|
});
|
|
const secondRunTool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
...enabledLoopDetectionContext,
|
|
runId: "heartbeat-2",
|
|
});
|
|
|
|
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
|
|
await expectUnblockedToolExecution(firstRunTool, `old-run-${i}`, params);
|
|
}
|
|
|
|
await expectUnblockedToolExecution(secondRunTool, "new-run-0", params);
|
|
});
|
|
|
|
it("escalates generic repeat diagnostics from warning to critical", async () => {
|
|
await withToolLoopEvents(async (emitted) => {
|
|
const { tool, params } = createGenericReadRepeatFixture();
|
|
|
|
for (let i = 0; i < 21; i += 1) {
|
|
await tool.execute(`read-bucket-${i}`, params, undefined, undefined);
|
|
}
|
|
|
|
const genericEvents = emitted.filter((evt) => evt.detector === "generic_repeat");
|
|
expect(genericEvents.map((evt) => [evt.level, evt.count])).toEqual([
|
|
["warning", 10],
|
|
["critical", 20],
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("emits structured warning diagnostic events for ping-pong loops", async () => {
|
|
await withToolLoopEvents(async (emitted) => {
|
|
const { readTool, listTool } = createPingPongTools();
|
|
await runPingPongSequence(readTool, listTool, 9);
|
|
|
|
await listTool.execute("list-9", { dir: "/workspace" }, undefined, undefined);
|
|
await readTool.execute("read-10", { path: "/a.txt" }, undefined, undefined);
|
|
await listTool.execute("list-11", { dir: "/workspace" }, undefined, undefined);
|
|
|
|
const pingPongWarns = emitted.filter(
|
|
(evt) => evt.level === "warning" && evt.detector === "ping_pong",
|
|
);
|
|
expect(pingPongWarns).toHaveLength(1);
|
|
const loopEvent = pingPongWarns[0];
|
|
expect(loopEvent?.type).toBe("tool.loop");
|
|
expect(loopEvent?.level).toBe("warning");
|
|
expect(loopEvent?.action).toBe("warn");
|
|
expect(loopEvent?.detector).toBe("ping_pong");
|
|
expect(loopEvent?.count).toBe(10);
|
|
expect(loopEvent?.toolName).toBe("list");
|
|
});
|
|
});
|
|
|
|
it("blocks ping-pong loops at critical threshold and emits critical diagnostic events", async () => {
|
|
await withToolLoopEvents(async (emitted) => {
|
|
const { readTool, listTool } = createPingPongTools();
|
|
await runPingPongSequence(readTool, listTool, CRITICAL_THRESHOLD - 1);
|
|
|
|
const result = await listTool.execute(
|
|
`list-${CRITICAL_THRESHOLD - 1}`,
|
|
{ dir: "/workspace" },
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expectToolLoopBlockedResult(result, "CRITICAL");
|
|
|
|
const loopEvent = emitted.at(-1);
|
|
expectCriticalLoopEvent(loopEvent, {
|
|
detector: "ping_pong",
|
|
toolName: "list",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("does not block ping-pong at critical threshold when outcomes are progressing", async () => {
|
|
await withToolLoopEvents(async (emitted) => {
|
|
const { readTool, listTool } = createPingPongTools({ withProgress: true });
|
|
await runPingPongSequence(readTool, listTool, CRITICAL_THRESHOLD - 1);
|
|
|
|
await expectUnblockedToolExecution(listTool, `list-${CRITICAL_THRESHOLD - 1}`, {
|
|
dir: "/workspace",
|
|
});
|
|
|
|
const criticalPingPong = emitted.find(
|
|
(evt) => evt.level === "critical" && evt.detector === "ping_pong",
|
|
);
|
|
expect(criticalPingPong).toBeUndefined();
|
|
const warningPingPong = emitted.find(
|
|
(evt) => evt.level === "warning" && evt.detector === "ping_pong",
|
|
);
|
|
expectEventFields(warningPingPong, {
|
|
type: "tool.loop",
|
|
level: "warning",
|
|
action: "warn",
|
|
detector: "ping_pong",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("emits structured critical diagnostic events when blocking loops", async () => {
|
|
await withToolLoopEvents(async (emitted) => {
|
|
const { tool, params } = createNoProgressProcessFixture("sess-crit");
|
|
|
|
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
|
|
await tool.execute(`poll-${i}`, params, undefined, undefined);
|
|
}
|
|
|
|
const result = await tool.execute(`poll-${CRITICAL_THRESHOLD}`, params, undefined, undefined);
|
|
expectToolLoopBlockedResult(result, "CRITICAL");
|
|
|
|
const loopEvent = emitted.at(-1);
|
|
expectCriticalLoopEvent(loopEvent, {
|
|
detector: "known_poll_no_progress",
|
|
toolName: "process",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("emits diagnostic tool execution events without parameter values", async () => {
|
|
const trace = {
|
|
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
|
|
spanId: "00f067aa0ba902b7",
|
|
traceFlags: "01",
|
|
};
|
|
const execute = vi.fn().mockResolvedValue({
|
|
content: [{ type: "text", text: "ok" }],
|
|
});
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "bash", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
sessionId: "session-id",
|
|
runId: "run-1",
|
|
trace,
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withToolExecutionEvents(async (emitted, flush) => {
|
|
await tool.execute(
|
|
"tool-call-1",
|
|
{ command: "pwd", token: "sk-1234567890abcdef1234567890abcdef" },
|
|
undefined,
|
|
undefined,
|
|
);
|
|
await flush();
|
|
|
|
expect(emitted.map((evt) => evt.type)).toEqual([
|
|
"tool.execution.started",
|
|
"tool.execution.completed",
|
|
]);
|
|
const started = expectEventFields(emitted[0], {
|
|
type: "tool.execution.started",
|
|
runId: "run-1",
|
|
sessionKey: "session-key",
|
|
sessionId: "session-id",
|
|
toolName: "exec",
|
|
toolCallId: "tool-call-1",
|
|
paramsSummary: {
|
|
kind: "object",
|
|
},
|
|
});
|
|
const startedTrace = requireRecord(started.trace, "started trace");
|
|
expect(startedTrace.traceId).toBe(trace.traceId);
|
|
expect(startedTrace.parentSpanId).toBe(trace.spanId);
|
|
expect(typeof startedTrace.spanId).toBe("string");
|
|
expect(startedTrace.traceFlags).toBe(trace.traceFlags);
|
|
expect(emitted[0]?.trace).not.toBe(trace);
|
|
expect(Object.isFrozen(emitted[0]?.trace)).toBe(true);
|
|
const completed = expectEventFields(emitted[1], {
|
|
type: "tool.execution.completed",
|
|
});
|
|
expect(typeof completed.durationMs).toBe("number");
|
|
expect(JSON.stringify(emitted)).not.toContain("sk-1234567890abcdef1234567890abcdef");
|
|
expect(JSON.stringify(emitted)).not.toContain("pwd");
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ label: "fails", error: new Error("hook crashed"), terminalReason: "failed" },
|
|
{
|
|
label: "times out",
|
|
error: Object.assign(new Error("timed out after 5ms"), { name: "TimeoutError" }),
|
|
terminalReason: "timed_out",
|
|
},
|
|
] as const)(
|
|
"emits a terminal diagnostic when a before_tool_call hook $label",
|
|
async (testCase) => {
|
|
hookRunner.hasHooks.mockImplementation((hookName: string) => hookName === "before_tool_call");
|
|
hookRunner.runBeforeToolCall.mockRejectedValueOnce(testCase.error);
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] });
|
|
const tool = wrapToolWithBeforeToolCallHook(
|
|
{ name: "exec", execute } as unknown as AnyAgentTool,
|
|
{
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
sessionId: "session-id",
|
|
runId: "run-1",
|
|
loopDetection: { enabled: false },
|
|
},
|
|
);
|
|
|
|
await withToolExecutionEvents(async (emitted, flush) => {
|
|
await expect(
|
|
tool.execute("tool-call-hook-failure", { command: "private" }, undefined, undefined),
|
|
).rejects.toThrow("Tool call blocked because before_tool_call hook failed");
|
|
await flush();
|
|
|
|
expect(execute).not.toHaveBeenCalled();
|
|
expect(emitted.map((event) => event.type)).toEqual(["tool.execution.error"]);
|
|
const terminal = expectEventFields(emitted[0], {
|
|
type: "tool.execution.error",
|
|
runId: "run-1",
|
|
sessionKey: "session-key",
|
|
sessionId: "session-id",
|
|
agentId: "main",
|
|
toolName: "exec",
|
|
toolCallId: "tool-call-hook-failure",
|
|
paramsSummary: { kind: "object" },
|
|
errorCategory: "before_tool_call",
|
|
terminalReason: testCase.terminalReason,
|
|
});
|
|
expect(typeof terminal.durationMs).toBe("number");
|
|
expect(JSON.stringify(emitted)).not.toContain("private");
|
|
});
|
|
},
|
|
);
|
|
|
|
it("emits a terminal diagnostic when hook preflight rejects", async () => {
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] });
|
|
const params = Object.defineProperty({}, "private", {
|
|
enumerable: true,
|
|
get() {
|
|
throw new Error("private hook preflight failure");
|
|
},
|
|
});
|
|
const tool = wrapToolWithBeforeToolCallHook(
|
|
{ name: "read", execute } as unknown as AnyAgentTool,
|
|
{
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
runId: "run-1",
|
|
},
|
|
);
|
|
|
|
await withToolExecutionEvents(async (emitted, flush) => {
|
|
await expect(
|
|
tool.execute("tool-call-preflight", params, undefined, undefined),
|
|
).rejects.toThrow("Tool call blocked because before_tool_call hook failed");
|
|
await flush();
|
|
|
|
expect(execute).not.toHaveBeenCalled();
|
|
expect(emitted.map((event) => event.type)).toEqual(["tool.execution.error"]);
|
|
expectEventFields(emitted[0], {
|
|
type: "tool.execution.error",
|
|
runId: "run-1",
|
|
sessionKey: "session-key",
|
|
agentId: "main",
|
|
toolName: "read",
|
|
toolCallId: "tool-call-preflight",
|
|
paramsSummary: { kind: "object" },
|
|
errorCategory: "before_tool_call",
|
|
terminalReason: "failed",
|
|
});
|
|
expect(JSON.stringify(emitted)).not.toContain("private hook preflight failure");
|
|
});
|
|
});
|
|
|
|
it("preserves preparation timeout disposition when wrapper diagnostics are delegated", async () => {
|
|
const timeout = Object.assign(new Error("private preparation timeout"), {
|
|
name: "TimeoutError",
|
|
});
|
|
const tool = wrapToolWithBeforeToolCallHook(
|
|
{
|
|
name: "exec",
|
|
execute: vi.fn(),
|
|
prepareBeforeToolCallParams: vi.fn().mockRejectedValue(timeout),
|
|
} as unknown as AnyAgentTool,
|
|
{ runId: "run-1" },
|
|
{ emitDiagnostics: false },
|
|
);
|
|
|
|
const error = await tool
|
|
.execute("tool-call-preparation-timeout", { command: "private" }, undefined, undefined)
|
|
.catch((cause: unknown) => cause);
|
|
|
|
expect(getBeforeToolCallFailureDisposition(error)).toBe("timed_out");
|
|
expect(error).toHaveProperty("cause", timeout);
|
|
});
|
|
|
|
it("emits a blocked terminal diagnostic when tool approval is denied", async () => {
|
|
hookRunner.hasHooks.mockImplementation((hookName: string) => hookName === "before_tool_call");
|
|
hookRunner.runBeforeToolCall.mockResolvedValueOnce({
|
|
requireApproval: { title: "Approve", description: "Approve tool" },
|
|
});
|
|
const mockCallGateway = vi.mocked(callGatewayTool);
|
|
mockCallGateway.mockResolvedValueOnce({ id: "approval-1", decision: "deny" });
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] });
|
|
const tool = wrapToolWithBeforeToolCallHook(
|
|
{ name: "exec", execute } as unknown as AnyAgentTool,
|
|
{ agentId: "main", sessionKey: "session-key", runId: "run-1" },
|
|
);
|
|
|
|
await withToolExecutionEvents(async (emitted, flush) => {
|
|
await expect(
|
|
tool.execute("tool-call-denied", { command: "private" }, undefined, undefined),
|
|
).rejects.toThrow("Denied by user");
|
|
await flush();
|
|
|
|
expect(execute).not.toHaveBeenCalled();
|
|
expect(emitted.map((event) => event.type)).toEqual(["tool.execution.blocked"]);
|
|
expectEventFields(emitted[0], {
|
|
type: "tool.execution.blocked",
|
|
runId: "run-1",
|
|
sessionKey: "session-key",
|
|
toolName: "exec",
|
|
toolCallId: "tool-call-denied",
|
|
deniedReason: "plugin-approval",
|
|
reason: "plugin-approval",
|
|
});
|
|
expect(JSON.stringify(emitted)).not.toContain("private");
|
|
});
|
|
mockCallGateway.mockReset();
|
|
});
|
|
|
|
it("emits a blocked terminal diagnostic when approval is report-only", async () => {
|
|
hookRunner.hasHooks.mockImplementation((hookName: string) => hookName === "before_tool_call");
|
|
hookRunner.runBeforeToolCall.mockResolvedValueOnce({
|
|
requireApproval: { title: "Approve", description: "Review before running" },
|
|
});
|
|
const mockCallGateway = vi.mocked(callGatewayTool);
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] });
|
|
const tool = wrapToolWithBeforeToolCallHook(
|
|
{ name: "exec", execute } as unknown as AnyAgentTool,
|
|
{ agentId: "main", sessionKey: "session-key", runId: "run-1" },
|
|
{ approvalMode: "report" },
|
|
);
|
|
|
|
await withToolExecutionEvents(async (emitted, flush) => {
|
|
await expect(
|
|
tool.execute("tool-call-report", { command: "private" }, undefined, undefined),
|
|
).rejects.toThrow("Review before running");
|
|
await flush();
|
|
|
|
expect(execute).not.toHaveBeenCalled();
|
|
expect(mockCallGateway).not.toHaveBeenCalled();
|
|
expect(emitted.map((event) => event.type)).toEqual(["tool.execution.blocked"]);
|
|
expectEventFields(emitted[0], {
|
|
type: "tool.execution.blocked",
|
|
runId: "run-1",
|
|
sessionKey: "session-key",
|
|
toolName: "exec",
|
|
toolCallId: "tool-call-report",
|
|
deniedReason: "plugin-approval",
|
|
reason: "plugin-approval",
|
|
});
|
|
expect(JSON.stringify(emitted)).not.toContain("private");
|
|
expect(JSON.stringify(emitted)).not.toContain("Review before running");
|
|
});
|
|
mockCallGateway.mockReset();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: "failure",
|
|
details: { status: "failed", exitCode: 1 },
|
|
terminal: {
|
|
type: "tool.execution.error",
|
|
errorCategory: "tool_result_error",
|
|
terminalReason: "failed",
|
|
},
|
|
},
|
|
{
|
|
label: "timeout",
|
|
details: { status: "timeout", timedOut: true },
|
|
terminal: {
|
|
type: "tool.execution.error",
|
|
errorCategory: "tool_result_error",
|
|
terminalReason: "timed_out",
|
|
},
|
|
},
|
|
{
|
|
label: "cancellation",
|
|
details: { status: "cancelled" },
|
|
terminal: {
|
|
type: "tool.execution.error",
|
|
errorCategory: "tool_result_error",
|
|
terminalReason: "cancelled",
|
|
},
|
|
},
|
|
{
|
|
label: "blocked action",
|
|
details: { status: "blocked" },
|
|
terminal: {
|
|
type: "tool.execution.blocked",
|
|
deniedReason: "tool_result_blocked",
|
|
reason: "tool_result_blocked",
|
|
},
|
|
},
|
|
])("classifies a resolved $label result as terminal failure", async ({ details, terminal }) => {
|
|
const execute = vi.fn().mockResolvedValue({
|
|
content: [{ type: "text", text: "tool failed" }],
|
|
details,
|
|
});
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "exec", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
runId: "run-1",
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withToolExecutionEvents(async (emitted, flush) => {
|
|
await tool.execute("tool-call-1", { command: "false" }, undefined, undefined);
|
|
await flush();
|
|
|
|
expect(emitted.map((event) => event.type)).toEqual(["tool.execution.started", terminal.type]);
|
|
expectEventFields(emitted[1], terminal);
|
|
});
|
|
});
|
|
|
|
it("classifies plugin and MCP tool execution diagnostics with bounded owner labels", async () => {
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] });
|
|
const rawTool = { name: "mcp_search", execute } as unknown as AnyAgentTool;
|
|
setPluginToolMeta(rawTool, { pluginId: "bundle-mcp", optional: false });
|
|
const tool = wrapToolWithBeforeToolCallHook(rawTool, {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withToolExecutionEvents(async (emitted, flush) => {
|
|
await tool.execute("tool-call-mcp", { query: "status" }, undefined, undefined);
|
|
await flush();
|
|
|
|
expectEventFields(emitted[0], {
|
|
type: "tool.execution.started",
|
|
toolName: "mcp_search",
|
|
toolSource: "mcp",
|
|
toolOwner: "bundle-mcp",
|
|
});
|
|
expectEventFields(emitted[1], {
|
|
type: "tool.execution.completed",
|
|
toolSource: "mcp",
|
|
toolOwner: "bundle-mcp",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("emits skill usage diagnostics when a run reads a known skill instruction file", async () => {
|
|
const workspaceDir = path.join("/tmp", "openclaw-skill-usage");
|
|
const skillBaseDir = path.join(workspaceDir, ".agents", "skills", "demo-skill");
|
|
const skillFilePath = path.join(skillBaseDir, "SKILL.md");
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "skill" }] });
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
sessionId: "session-id",
|
|
runId: "run-1",
|
|
workspaceDir,
|
|
skillsSnapshot: {
|
|
prompt: "",
|
|
skills: [{ name: "demo-skill" }],
|
|
resolvedSkills: [
|
|
createCanonicalFixtureSkill({
|
|
name: "demo-skill",
|
|
description: "Demo",
|
|
filePath: skillFilePath,
|
|
baseDir: skillBaseDir,
|
|
source: "workspace",
|
|
}),
|
|
],
|
|
},
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withSkillUsageDiagnosticEvents(async (emitted, privateData, flush) => {
|
|
await tool.execute(
|
|
"tool-call-skill-read",
|
|
{ path: `${path.join(".agents", "skills", "demo-skill", "SKILL.md")}</arg_value>>` },
|
|
undefined,
|
|
undefined,
|
|
);
|
|
await flush();
|
|
|
|
expect(emitted.map((evt) => evt.type)).toEqual([
|
|
"tool.execution.started",
|
|
"skill.used",
|
|
"tool.execution.completed",
|
|
]);
|
|
expectEventFields(emitted[1], {
|
|
type: "skill.used",
|
|
agentId: "main",
|
|
runId: "run-1",
|
|
sessionKey: "session-key",
|
|
sessionId: "session-id",
|
|
skillName: "demo-skill",
|
|
skillSource: "workspace",
|
|
activation: "read",
|
|
toolName: "read",
|
|
toolCallId: "tool-call-skill-read",
|
|
});
|
|
expect(JSON.stringify(emitted[1])).not.toContain(skillFilePath);
|
|
expect(JSON.stringify(emitted)).not.toContain("SKILL.md");
|
|
expect(JSON.stringify(emitted)).not.toContain(skillBaseDir);
|
|
expect(privateData[0]?.skillUsage?.skillFile).toBe(skillFilePath);
|
|
});
|
|
});
|
|
|
|
it("matches home-compacted skill instruction paths from prompts", async () => {
|
|
const skillBaseDir = path.join(os.homedir(), ".openclaw", "skills", "home-skill");
|
|
const skillFilePath = path.join(skillBaseDir, "SKILL.md");
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "skill" }] });
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
workspaceDir: "/tmp/openclaw-workspace",
|
|
skillsSnapshot: {
|
|
prompt: "",
|
|
skills: [{ name: "home-skill" }],
|
|
resolvedSkills: [
|
|
createCanonicalFixtureSkill({
|
|
name: "home-skill",
|
|
description: "Home skill",
|
|
filePath: skillFilePath,
|
|
baseDir: skillBaseDir,
|
|
source: "openclaw-managed",
|
|
}),
|
|
],
|
|
},
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withSkillUsageDiagnosticEvents(async (emitted, privateData, flush) => {
|
|
await tool.execute(
|
|
"tool-call-home-skill",
|
|
{ path: "~/.openclaw/skills/home-skill/SKILL.md" },
|
|
undefined,
|
|
undefined,
|
|
);
|
|
await flush();
|
|
|
|
expectEventFields(emitted[1], {
|
|
type: "skill.used",
|
|
skillName: "home-skill",
|
|
skillSource: "workspace",
|
|
activation: "read",
|
|
toolName: "read",
|
|
});
|
|
expect(JSON.stringify(emitted[1])).not.toContain(skillFilePath);
|
|
expect(JSON.stringify(emitted)).not.toContain(os.homedir());
|
|
expect(privateData[0]?.skillUsage?.skillFile).toBe(skillFilePath);
|
|
});
|
|
});
|
|
|
|
it("emits skill usage diagnostics for node skill locators", async () => {
|
|
const locator = "node://node-1/skills/remote-skill/SKILL.md";
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "skill" }] });
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
skillsSnapshot: {
|
|
prompt: "",
|
|
skills: [{ name: "remote-skill" }],
|
|
resolvedSkills: [
|
|
createCanonicalFixtureSkill({
|
|
name: "remote-skill",
|
|
description: "Remote skill",
|
|
filePath: locator,
|
|
baseDir: "node://node-1/skills/remote-skill",
|
|
source: "openclaw-node",
|
|
}),
|
|
],
|
|
},
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withSkillUsageDiagnosticEvents(async (emitted, _privateData, flush) => {
|
|
await tool.execute("tool-call-node-skill", { path: locator }, undefined, undefined);
|
|
await flush();
|
|
|
|
expectEventFields(emitted[1], {
|
|
type: "skill.used",
|
|
skillName: "remote-skill",
|
|
activation: "read",
|
|
toolName: "read",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("accounts sandbox skill reads against the original canonical file", async () => {
|
|
const workspaceDir = "/workspace";
|
|
const readPath = "/workspace/.openclaw/sandbox-skills/skills/demo/SKILL.md";
|
|
const skillFile = "/agent-workspace/skills/demo/SKILL.md";
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "skill" }] });
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
workspaceDir,
|
|
skillUsagePaths: [
|
|
{
|
|
readPath,
|
|
skillFile,
|
|
skillName: "demo",
|
|
skillSource: "workspace",
|
|
},
|
|
],
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withSkillUsageDiagnosticEvents(async (emitted, privateData, flush) => {
|
|
await tool.execute(
|
|
"tool-call-sandbox-skill",
|
|
{ path: ".openclaw/sandbox-skills/skills/demo/SKILL.md" },
|
|
undefined,
|
|
undefined,
|
|
);
|
|
await flush();
|
|
|
|
expectEventFields(emitted[1], {
|
|
type: "skill.used",
|
|
skillName: "demo",
|
|
skillSource: "workspace",
|
|
activation: "read",
|
|
toolName: "read",
|
|
});
|
|
expect(JSON.stringify(emitted[1])).not.toContain(skillFile);
|
|
expect(privateData[0]?.skillUsage?.skillFile).toBe(skillFile);
|
|
});
|
|
});
|
|
|
|
it("does not count unused read params as skill usage", async () => {
|
|
const workspaceDir = path.join("/tmp", "openclaw-skill-unused-param");
|
|
const skillBaseDir = path.join(workspaceDir, ".agents", "skills", "demo-skill");
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "readme" }] });
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
workspaceDir,
|
|
skillsSnapshot: {
|
|
prompt: "",
|
|
skills: [{ name: "demo-skill" }],
|
|
resolvedSkills: [
|
|
createCanonicalFixtureSkill({
|
|
name: "demo-skill",
|
|
description: "Demo",
|
|
filePath: path.join(skillBaseDir, "SKILL.md"),
|
|
baseDir: skillBaseDir,
|
|
source: "workspace",
|
|
}),
|
|
],
|
|
},
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withDiagnosticEvents(async (emitted, flush) => {
|
|
await tool.execute(
|
|
"tool-call-unused-skill-param",
|
|
{
|
|
path: "README.md",
|
|
file: path.join(".agents", "skills", "demo-skill", "SKILL.md"),
|
|
},
|
|
undefined,
|
|
undefined,
|
|
);
|
|
await flush();
|
|
|
|
expect(emitted.map((evt) => evt.type)).toEqual([
|
|
"tool.execution.started",
|
|
"tool.execution.completed",
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("emits skill usage diagnostics for command-dispatched skill tools", async () => {
|
|
const skillBaseDir = path.join("/tmp", "openclaw-skill-command", "skills", "matrix-profile");
|
|
const skillFilePath = path.join(skillBaseDir, "SKILL.md");
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "sent" }] });
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "message", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
sessionId: "session-id",
|
|
skillCommand: {
|
|
commandName: "set_profile",
|
|
skillFile: skillFilePath,
|
|
skillName: "matrix-profile",
|
|
skillSource: "workspace",
|
|
toolName: "message",
|
|
},
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withSkillUsageDiagnosticEvents(async (emitted, privateData, flush) => {
|
|
await tool.execute(
|
|
"tool-call-skill-command",
|
|
{ command: "display name", commandName: "set_profile", skillName: "matrix-profile" },
|
|
undefined,
|
|
undefined,
|
|
);
|
|
await flush();
|
|
|
|
expect(emitted.map((evt) => evt.type)).toEqual([
|
|
"tool.execution.started",
|
|
"skill.used",
|
|
"tool.execution.completed",
|
|
]);
|
|
expectEventFields(emitted[1], {
|
|
type: "skill.used",
|
|
skillName: "matrix-profile",
|
|
skillSource: "workspace",
|
|
activation: "command",
|
|
toolName: "message",
|
|
toolCallId: "tool-call-skill-command",
|
|
});
|
|
expect(JSON.stringify(emitted[1])).not.toContain(skillFilePath);
|
|
expect(privateData[0]?.skillUsage?.skillFile).toBe(skillFilePath);
|
|
expect(JSON.stringify(emitted)).not.toContain("display name");
|
|
});
|
|
});
|
|
|
|
it("emits diagnostic tool execution error events with redacted errors", async () => {
|
|
const execute = vi
|
|
.fn()
|
|
.mockRejectedValue(new Error("failed with key sk-1234567890abcdef1234567890abcdef"));
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withToolExecutionEvents(async (emitted, flush) => {
|
|
await expect(
|
|
tool.execute("tool-call-error", { path: "/tmp/file" }, undefined, undefined),
|
|
).rejects.toThrow("failed with key");
|
|
await flush();
|
|
|
|
expect(emitted.map((evt) => evt.type)).toEqual([
|
|
"tool.execution.started",
|
|
"tool.execution.error",
|
|
]);
|
|
const errorEvent = expectEventFields(emitted[1], {
|
|
type: "tool.execution.error",
|
|
toolName: "read",
|
|
toolCallId: "tool-call-error",
|
|
errorCategory: "Error",
|
|
});
|
|
expect(typeof errorEvent.durationMs).toBe("number");
|
|
expect(JSON.stringify(emitted[1])).not.toContain("sk-1234567890abcdef1234567890abcdef");
|
|
});
|
|
});
|
|
|
|
it("classifies a tool error as cancelled only when the run signal is aborted", async () => {
|
|
const abortController = new AbortController();
|
|
const execute = vi.fn().mockImplementation(() => {
|
|
abortController.abort();
|
|
throw new Error("tool stopped with run");
|
|
});
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withToolExecutionEvents(async (emitted, flush) => {
|
|
await expect(
|
|
tool.execute(
|
|
"tool-call-cancelled",
|
|
{ path: "/tmp/file" },
|
|
abortController.signal,
|
|
undefined,
|
|
),
|
|
).rejects.toThrow("tool stopped with run");
|
|
await flush();
|
|
|
|
expectEventFields(emitted[1], {
|
|
type: "tool.execution.error",
|
|
toolCallId: "tool-call-cancelled",
|
|
errorCategory: "aborted",
|
|
terminalReason: "cancelled",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("classifies a tool error as timed out when the run timeout signal is aborted", async () => {
|
|
const abortController = new AbortController();
|
|
const execute = vi.fn().mockImplementation(() => {
|
|
abortController.abort(Object.assign(new Error("timed out"), { name: "TimeoutError" }));
|
|
throw new Error("tool stopped with timeout");
|
|
});
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withToolExecutionEvents(async (emitted, flush) => {
|
|
await expect(
|
|
tool.execute("tool-call-timeout", { path: "/tmp/file" }, abortController.signal, undefined),
|
|
).rejects.toThrow("tool stopped with timeout");
|
|
await flush();
|
|
|
|
expectEventFields(emitted[1], {
|
|
type: "tool.execution.error",
|
|
toolCallId: "tool-call-timeout",
|
|
terminalReason: "timed_out",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("classifies a tool-local timeout without an aborted run signal", async () => {
|
|
const execute = vi
|
|
.fn()
|
|
.mockRejectedValue(
|
|
Object.assign(new Error("tool deadline elapsed"), { name: "TimeoutError" }),
|
|
);
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
loopDetection: { enabled: false },
|
|
});
|
|
const runSignal = new AbortController().signal;
|
|
|
|
await withToolExecutionEvents(async (emitted, flush) => {
|
|
await expect(
|
|
tool.execute("tool-call-local-timeout", { path: "/tmp/file" }, runSignal, undefined),
|
|
).rejects.toThrow("tool deadline elapsed");
|
|
await flush();
|
|
|
|
expectEventFields(emitted[1], {
|
|
type: "tool.execution.error",
|
|
toolCallId: "tool-call-local-timeout",
|
|
terminalReason: "timed_out",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("emits blocked diagnostics without error severity for intentional hook vetoes", async () => {
|
|
hookRunner.hasHooks.mockImplementation((hookName: string) => hookName === "before_tool_call");
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
block: true,
|
|
blockReason: "blocked by policy",
|
|
});
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "nope" }] });
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withToolExecutionEvents(async (emitted, flush) => {
|
|
const result = await tool.execute("tool-call-blocked", { path: "/tmp/file" });
|
|
await flush();
|
|
|
|
expect(result).toEqual({
|
|
content: [{ type: "text", text: "blocked by policy" }],
|
|
details: {
|
|
status: "blocked",
|
|
deniedReason: "plugin-before-tool-call",
|
|
reason: "blocked by policy",
|
|
},
|
|
});
|
|
expect(execute).not.toHaveBeenCalled();
|
|
expect(emitted.map((evt) => evt.type)).toEqual(["tool.execution.blocked"]);
|
|
expectEventFields(emitted[0], {
|
|
type: "tool.execution.blocked",
|
|
toolName: "read",
|
|
toolCallId: "tool-call-blocked",
|
|
deniedReason: "plugin-before-tool-call",
|
|
reason: "blocked by policy",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("emits a security event for intentional hook vetoes", async () => {
|
|
hookRunner.hasHooks.mockImplementation((hookName: string) => hookName === "before_tool_call");
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
block: true,
|
|
blockReason: "blocked by policy",
|
|
});
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "nope" }] });
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withDiagnosticEvents(async (emitted, flush) => {
|
|
await tool.execute("tool-call-blocked", { path: "/tmp/file" });
|
|
await flush();
|
|
|
|
const securityEvent = emitted.find(
|
|
(event): event is Extract<DiagnosticEventPayload, { type: "security.event" }> =>
|
|
event.type === "security.event",
|
|
);
|
|
expect(securityEvent).toMatchObject({
|
|
type: "security.event",
|
|
category: "tool",
|
|
action: "tool.execution.blocked",
|
|
outcome: "denied",
|
|
severity: "medium",
|
|
reason: "plugin-before-tool-call",
|
|
actor: { kind: "agent" },
|
|
target: {
|
|
kind: "tool",
|
|
name: "read",
|
|
},
|
|
policy: {
|
|
id: "plugin-before-tool-call",
|
|
decision: "deny",
|
|
reason: "plugin-before-tool-call",
|
|
},
|
|
control: {
|
|
id: "before-tool-call",
|
|
family: "approval",
|
|
},
|
|
attributes: {
|
|
params_kind: "object",
|
|
tool_source: "core",
|
|
},
|
|
});
|
|
expect(securityEvent?.eventId).toBeTypeOf("string");
|
|
expect(JSON.stringify(securityEvent)).not.toContain("/tmp/file");
|
|
expect(emitted.some((event) => event.type === "tool.execution.blocked")).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("does not let hostile thrown values break diagnostic error emission", async () => {
|
|
const hostileError = new Proxy(
|
|
{},
|
|
{
|
|
get() {
|
|
throw new Error("diagnostic getter should not run");
|
|
},
|
|
getOwnPropertyDescriptor() {
|
|
throw new Error("diagnostic descriptor failed");
|
|
},
|
|
},
|
|
);
|
|
const execute = vi.fn().mockRejectedValue(hostileError);
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withToolExecutionEvents(async (emitted, flush) => {
|
|
await expect(
|
|
tool.execute("tool-call-hostile-error", { path: "/tmp/file" }, undefined, undefined),
|
|
).rejects.toBe(hostileError);
|
|
await flush();
|
|
|
|
expect(emitted.map((evt) => evt.type)).toEqual([
|
|
"tool.execution.started",
|
|
"tool.execution.error",
|
|
]);
|
|
expectEventFields(emitted[1], {
|
|
type: "tool.execution.error",
|
|
toolName: "read",
|
|
toolCallId: "tool-call-hostile-error",
|
|
errorCategory: "object",
|
|
});
|
|
expect(emitted[1]).not.toHaveProperty("errorCode");
|
|
});
|
|
});
|
|
|
|
it("emits only numeric HTTP status codes as diagnostic tool error codes", async () => {
|
|
const error = Object.assign(new Error("rate limited"), {
|
|
code: "SECRET_TOKEN",
|
|
status: 429,
|
|
});
|
|
const execute = vi.fn().mockRejectedValue(error);
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withToolExecutionEvents(async (emitted, flush) => {
|
|
await expect(
|
|
tool.execute("tool-call-status-code", { path: "/tmp/file" }, undefined, undefined),
|
|
).rejects.toThrow("rate limited");
|
|
await flush();
|
|
|
|
expectEventFields(emitted[1], {
|
|
type: "tool.execution.error",
|
|
errorCode: "429",
|
|
});
|
|
expect(JSON.stringify(emitted[1])).not.toContain("SECRET_TOKEN");
|
|
});
|
|
});
|
|
|
|
it("summarizes hostile object params without enumerating keys", async () => {
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] });
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "bash", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
loopDetection: { enabled: false },
|
|
});
|
|
const params = new Proxy(
|
|
{},
|
|
{
|
|
ownKeys() {
|
|
throw new Error("should not enumerate params");
|
|
},
|
|
},
|
|
);
|
|
|
|
await withToolExecutionEvents(async (emitted, flush) => {
|
|
await tool.execute("tool-call-proxy", params, undefined, undefined);
|
|
await flush();
|
|
|
|
const started = expectEventFields(emitted[0], {
|
|
type: "tool.execution.started",
|
|
});
|
|
expect(started.paramsSummary).toEqual({ kind: "object" });
|
|
expect(execute).toHaveBeenCalledTimes(1);
|
|
expect(execute.mock.calls[0]?.[1]).toBe(params);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("before_tool_call requireApproval handling", () => {
|
|
let hookRunner: TestHookRunner;
|
|
const mockCallGateway = vi.mocked(callGatewayTool);
|
|
|
|
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null) {
|
|
throw new Error(`${label} was not an object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function requireHookCall(
|
|
index: number,
|
|
): [event: Record<string, unknown>, context: Record<string, unknown>] {
|
|
const call = hookRunner.runBeforeToolCall.mock.calls[index] as unknown[] | undefined;
|
|
if (!call) {
|
|
throw new Error(`missing before_tool_call hook call ${index + 1}`);
|
|
}
|
|
return [
|
|
requireRecord(call[0], "before_tool_call event"),
|
|
requireRecord(call[1], "before_tool_call context"),
|
|
];
|
|
}
|
|
|
|
function requireGatewayCall(index: number): unknown[] {
|
|
const call = mockCallGateway.mock.calls[index] as unknown[] | undefined;
|
|
if (!call) {
|
|
throw new Error(`missing gateway call ${index + 1}`);
|
|
}
|
|
return call;
|
|
}
|
|
|
|
function expectRecordFields(record: Record<string, unknown>, fields: Record<string, unknown>) {
|
|
for (const [key, value] of Object.entries(fields)) {
|
|
expect(record[key]).toEqual(value);
|
|
}
|
|
}
|
|
|
|
function registerTelegramPluginApprovalSetup(): void {
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{
|
|
pluginId: "telegram",
|
|
source: "test",
|
|
plugin: {
|
|
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
|
|
approvalCapability: {
|
|
native: {},
|
|
getActionAvailabilityState: () => ({ kind: "enabled" as const }),
|
|
getExecInitiatingSurfaceState: () => ({ kind: "disabled" as const }),
|
|
describePluginApprovalSetup: () => "Configure Telegram native approval setup.",
|
|
},
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetDiagnosticSessionStateForTest();
|
|
resetDiagnosticEventsForTest();
|
|
hookRunner = createTestHookRunner();
|
|
hookRunner.hasHooks.mockImplementation((hookName) => hookName === "before_tool_call");
|
|
mockGetGlobalHookRunner.mockReturnValue(hookRunner);
|
|
// Keep the global singleton aligned as a fallback in case another setup path
|
|
// preloads hook-runner-global before this test's module reset/mocks take effect.
|
|
setGlobalHookRunnerForTest(hookRunner);
|
|
mockCallGateway.mockReset();
|
|
setActivePluginRegistry(createEmptyPluginRegistry());
|
|
});
|
|
|
|
async function runAbortDuringApprovalWait(options?: {
|
|
abortReason?: unknown;
|
|
onResolution?: (decision: PluginApprovalResolution) => void | Promise<void>;
|
|
}) {
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "Abortable",
|
|
description: "Will be aborted",
|
|
onResolution: options?.onResolution,
|
|
},
|
|
});
|
|
|
|
const controller = new AbortController();
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-abort", status: "accepted" });
|
|
mockCallGateway.mockImplementationOnce(() => new Promise(() => {}));
|
|
setTimeout(() => controller.abort(options?.abortReason ?? new Error("run cancelled")), 10);
|
|
|
|
return await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
signal: controller.signal,
|
|
});
|
|
}
|
|
|
|
it("blocks without triggering approval when both block and requireApproval are set", async () => {
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
block: true,
|
|
blockReason: "Blocked by security plugin",
|
|
requireApproval: {
|
|
title: "Should not reach gateway",
|
|
description: "This approval should be skipped",
|
|
pluginId: "lower-priority-plugin",
|
|
},
|
|
});
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: { command: "rm -rf" },
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(result.blocked).toBe(true);
|
|
expect(result).toHaveProperty("reason", "Blocked by security plugin");
|
|
expect(mockCallGateway).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks when before_tool_call hook execution throws", async () => {
|
|
hookRunner.runBeforeToolCall.mockRejectedValueOnce(new Error("hook crashed"));
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: { command: "ls" },
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(result.blocked).toBe(true);
|
|
expect(result).toHaveProperty("disposition", "failed");
|
|
expect(result).toHaveProperty(
|
|
"reason",
|
|
"Tool call blocked because before_tool_call hook failed",
|
|
);
|
|
});
|
|
|
|
it("classifies a loop preflight exception as a before-tool failure", async () => {
|
|
const ctx = {
|
|
sessionKey: "main",
|
|
get loopDetection(): never {
|
|
throw new Error("loop state unavailable");
|
|
},
|
|
};
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: { command: "ls" },
|
|
ctx,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
blocked: true,
|
|
kind: "failure",
|
|
disposition: "failed",
|
|
reason: "Tool call blocked because before_tool_call hook failed",
|
|
});
|
|
});
|
|
|
|
it("passes diagnostic trace context to before_tool_call hooks", async () => {
|
|
const trace = {
|
|
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
|
|
spanId: "00f067aa0ba902b7",
|
|
traceFlags: "01",
|
|
};
|
|
hookRunner.runBeforeToolCall.mockResolvedValue(undefined);
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: { command: "pwd" },
|
|
toolCallId: "tool-1",
|
|
ctx: { agentId: "main", sessionKey: "main", runId: "run-1", trace },
|
|
});
|
|
|
|
expect(result.blocked).toBe(false);
|
|
const [event, toolContext] = requireHookCall(0);
|
|
expectRecordFields(event, {
|
|
toolName: "exec",
|
|
runId: "run-1",
|
|
toolCallId: "tool-1",
|
|
});
|
|
expectRecordFields(toolContext, {
|
|
toolName: "exec",
|
|
runId: "run-1",
|
|
toolCallId: "tool-1",
|
|
});
|
|
expect(toolContext.trace).toEqual(trace);
|
|
expect(toolContext.trace).not.toBe(trace);
|
|
expect(Object.isFrozen(toolContext.trace)).toBe(true);
|
|
});
|
|
|
|
it("passes host-derived apply_patch paths to before_tool_call hooks", async () => {
|
|
const cwd = path.join("/tmp", "openclaw-hooks");
|
|
const patch = [
|
|
"*** Begin Patch",
|
|
"*** Add File: src/new.ts",
|
|
"+x",
|
|
"*** Update File: src/old.ts",
|
|
"*** Move to: src/renamed.ts",
|
|
"@@",
|
|
"+y",
|
|
"*** Delete File: src/dead.ts",
|
|
"*** End Patch",
|
|
].join("\n");
|
|
hookRunner.runBeforeToolCall.mockResolvedValue(undefined);
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "apply_patch",
|
|
params: { input: patch },
|
|
toolCallId: "patch-1",
|
|
ctx: { agentId: "main", cwd, sessionKey: "main", runId: "run-patch" },
|
|
});
|
|
|
|
expect(result.blocked).toBe(false);
|
|
const [event, context] = requireHookCall(0);
|
|
expectRecordFields(event, {
|
|
toolName: "apply_patch",
|
|
runId: "run-patch",
|
|
toolCallId: "patch-1",
|
|
derivedPaths: [
|
|
path.join(cwd, "src/new.ts"),
|
|
path.join(cwd, "src/old.ts"),
|
|
path.join(cwd, "src/renamed.ts"),
|
|
path.join(cwd, "src/dead.ts"),
|
|
],
|
|
});
|
|
expectRecordFields(context, {
|
|
toolName: "apply_patch",
|
|
runId: "run-patch",
|
|
toolCallId: "patch-1",
|
|
});
|
|
});
|
|
|
|
it("derives sandboxed apply_patch paths through the sandbox bridge", async () => {
|
|
const patch = [
|
|
"*** Begin Patch",
|
|
"*** Add File: /workspace/src/new.ts",
|
|
"+x",
|
|
"*** End Patch",
|
|
].join("\n");
|
|
hookRunner.runBeforeToolCall.mockResolvedValue(undefined);
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "apply_patch",
|
|
params: { input: patch },
|
|
toolCallId: "patch-sandbox",
|
|
ctx: {
|
|
agentId: "main",
|
|
cwd: "/workspace",
|
|
sandbox: {
|
|
root: "/workspace",
|
|
bridge: {
|
|
resolvePath: ({ filePath }: { filePath: string }) => ({
|
|
containerPath: filePath,
|
|
hostPath: "/host/sandbox/src/new.ts",
|
|
relativePath: "src/new.ts",
|
|
}),
|
|
} as never,
|
|
},
|
|
sessionKey: "main",
|
|
runId: "run-patch",
|
|
},
|
|
});
|
|
|
|
expect(result.blocked).toBe(false);
|
|
const [event] = requireHookCall(0);
|
|
expectRecordFields(event, {
|
|
toolName: "apply_patch",
|
|
derivedPaths: ["/host/sandbox/src/new.ts"],
|
|
});
|
|
});
|
|
|
|
it("does not fail hooks when sandbox path derivation rejects a target", async () => {
|
|
const patch = ["*** Begin Patch", "*** Add File: /outside.ts", "+x", "*** End Patch"].join(
|
|
"\n",
|
|
);
|
|
hookRunner.runBeforeToolCall.mockResolvedValue(undefined);
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "apply_patch",
|
|
params: { input: patch },
|
|
toolCallId: "patch-sandbox-rejected",
|
|
ctx: {
|
|
agentId: "main",
|
|
cwd: "/workspace",
|
|
sandbox: {
|
|
root: "/workspace",
|
|
bridge: {
|
|
resolvePath: () => {
|
|
throw new Error("Path escapes sandbox root");
|
|
},
|
|
} as never,
|
|
},
|
|
sessionKey: "main",
|
|
runId: "run-patch",
|
|
},
|
|
});
|
|
|
|
expect(result.blocked).toBe(false);
|
|
const [event, context] = requireHookCall(0);
|
|
expect(event).not.toHaveProperty("derivedPaths");
|
|
expectRecordFields(context, {
|
|
toolName: "apply_patch",
|
|
runId: "run-patch",
|
|
toolCallId: "patch-sandbox-rejected",
|
|
});
|
|
});
|
|
|
|
it("skips derived path extraction when no policies or hooks can consume it", async () => {
|
|
hookRunner.hasHooks.mockReturnValue(false);
|
|
const params = {};
|
|
Object.defineProperty(params, "input", {
|
|
enumerable: true,
|
|
get() {
|
|
throw new Error("should not derive paths");
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
runBeforeToolCallHook({
|
|
toolName: "apply_patch",
|
|
params,
|
|
toolCallId: "patch-no-hooks",
|
|
}),
|
|
).resolves.toEqual({ blocked: false, params });
|
|
expect(hookRunner.runBeforeToolCall).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports trusted policy diagnostics through guarded readers", () => {
|
|
hookRunner.hasHooks.mockReturnValue(false);
|
|
const registry = createEmptyPluginRegistry();
|
|
const unreadableIdPolicy: Record<string, unknown> = {
|
|
description: "synthetic trusted policy",
|
|
evaluate: () => undefined,
|
|
};
|
|
Object.defineProperty(unreadableIdPolicy, "id", {
|
|
enumerable: true,
|
|
get() {
|
|
throw new Error("fuzzplugin trusted policy id is unreadable");
|
|
},
|
|
});
|
|
registry.trustedToolPolicies = [
|
|
{
|
|
pluginId: "fuzzplugin",
|
|
pluginName: "Fuzz Plugin",
|
|
source: "test",
|
|
policy: unreadableIdPolicy as never,
|
|
},
|
|
{
|
|
pluginId: "mockplugin",
|
|
pluginName: "Mock Plugin",
|
|
source: "test",
|
|
policy: {
|
|
id: "mockpolicy",
|
|
description: "mock policy",
|
|
evaluate: () => undefined,
|
|
},
|
|
},
|
|
];
|
|
setActivePluginRegistry(registry);
|
|
|
|
let state: ReturnType<typeof getBeforeToolCallPolicyDiagnosticState> | undefined;
|
|
try {
|
|
state = getBeforeToolCallPolicyDiagnosticState();
|
|
} finally {
|
|
setActivePluginRegistry(createEmptyPluginRegistry());
|
|
}
|
|
|
|
expect(state).toEqual({
|
|
hasBeforeToolCallHook: false,
|
|
trustedToolPolicies: [
|
|
{
|
|
id: "fuzzplugin",
|
|
pluginId: "fuzzplugin",
|
|
pluginName: "Fuzz Plugin",
|
|
},
|
|
{
|
|
id: "mockpolicy",
|
|
pluginId: "mockplugin",
|
|
pluginName: "Mock Plugin",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("recomputes host-derived paths after trusted policy param rewrites", async () => {
|
|
const cwd = path.join("/tmp", "openclaw-hooks");
|
|
const originalPatch = [
|
|
"*** Begin Patch",
|
|
"*** Add File: src/old.ts",
|
|
"+x",
|
|
"*** End Patch",
|
|
].join("\n");
|
|
const rewrittenPatch = [
|
|
"*** Begin Patch",
|
|
"*** Add File: src/new.ts",
|
|
"+x",
|
|
"*** End Patch",
|
|
].join("\n");
|
|
const seenByLaterPolicy: unknown[] = [];
|
|
const registry = createEmptyPluginRegistry();
|
|
registry.trustedToolPolicies = [
|
|
{
|
|
pluginId: "trusted-rewriter",
|
|
pluginName: "Trusted Rewriter",
|
|
source: "test",
|
|
policy: {
|
|
id: "rewrite",
|
|
description: "rewrite",
|
|
evaluate: () => ({ params: { input: rewrittenPatch } }),
|
|
},
|
|
},
|
|
{
|
|
pluginId: "trusted-inspector",
|
|
pluginName: "Trusted Inspector",
|
|
source: "test",
|
|
policy: {
|
|
id: "inspect",
|
|
description: "inspect",
|
|
evaluate: (event) => {
|
|
seenByLaterPolicy.push(event.derivedPaths);
|
|
return undefined;
|
|
},
|
|
},
|
|
},
|
|
];
|
|
setActivePluginRegistry(registry);
|
|
hookRunner.runBeforeToolCall.mockResolvedValue(undefined);
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "apply_patch",
|
|
params: { input: originalPatch },
|
|
toolCallId: "patch-rewrite",
|
|
ctx: { agentId: "main", cwd, sessionKey: "main", runId: "run-patch" },
|
|
});
|
|
|
|
expect(result).toEqual({ blocked: false, params: { input: rewrittenPatch } });
|
|
expect(seenByLaterPolicy).toEqual([[path.join(cwd, "src/new.ts")]]);
|
|
const [event] = requireHookCall(0);
|
|
expectRecordFields(event, {
|
|
params: { input: rewrittenPatch },
|
|
derivedPaths: [path.join(cwd, "src/new.ts")],
|
|
});
|
|
});
|
|
|
|
it("calls gateway RPC and unblocks on allow-once", async () => {
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "Sensitive",
|
|
description: "Sensitive op",
|
|
pluginId: "sage",
|
|
},
|
|
});
|
|
|
|
// First call: plugin.approval.request → returns server-generated id
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-1", status: "accepted" });
|
|
// Second call: plugin.approval.waitDecision → returns allow-once
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-1", decision: "allow-once" });
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: { command: "rm -rf" },
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(result.blocked).toBe(false);
|
|
expect(mockCallGateway).toHaveBeenCalledTimes(2);
|
|
const requestCall = requireGatewayCall(0);
|
|
expect(requestCall[0]).toBe("plugin.approval.request");
|
|
requireRecord(requestCall[1], "approval request gateway client");
|
|
expect(requireRecord(requestCall[2], "approval request params").twoPhase).toBe(true);
|
|
expect(requestCall[3]).toEqual({ expectFinal: false });
|
|
const waitCall = requireGatewayCall(1);
|
|
expect(waitCall[0]).toBe("plugin.approval.waitDecision");
|
|
requireRecord(waitCall[1], "approval wait gateway client");
|
|
expect(waitCall[2]).toEqual({ id: "server-id-1" });
|
|
});
|
|
|
|
it("caps oversized plugin approval timeouts before calling gateway", async () => {
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "Oversized timeout",
|
|
description: "Still valid gateway payload",
|
|
pluginId: "sage",
|
|
timeoutMs: Number.MAX_SAFE_INTEGER,
|
|
},
|
|
});
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-oversized", status: "accepted" });
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-oversized", decision: "allow-once" });
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: { command: "rm -rf" },
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(result.blocked).toBe(false);
|
|
const requestCall = requireGatewayCall(0);
|
|
expect(requireRecord(requestCall[1], "approval request gateway client").timeoutMs).toBe(
|
|
MAX_PLUGIN_APPROVAL_TIMEOUT_MS + 10_000,
|
|
);
|
|
expect(requireRecord(requestCall[2], "approval request params").timeoutMs).toBe(
|
|
MAX_PLUGIN_APPROVAL_TIMEOUT_MS,
|
|
);
|
|
const waitCall = requireGatewayCall(1);
|
|
expect(requireRecord(waitCall[1], "approval wait gateway client").timeoutMs).toBe(
|
|
MAX_PLUGIN_APPROVAL_TIMEOUT_MS + 10_000,
|
|
);
|
|
});
|
|
|
|
it("blocks on deny decision", async () => {
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "Dangerous",
|
|
description: "Dangerous op",
|
|
},
|
|
});
|
|
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-2", status: "accepted" });
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-2", decision: "deny" });
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(result.blocked).toBe(true);
|
|
expect(result).toHaveProperty("disposition", "blocked");
|
|
expect(result).toHaveProperty("reason", "Denied by user");
|
|
});
|
|
|
|
it("keeps the generic plugin approval timeout reason unchanged", async () => {
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "Timeout test",
|
|
description: "Will time out",
|
|
},
|
|
});
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-timeout", status: "accepted" });
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-timeout", decision: null });
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
blocked: true,
|
|
kind: "failure",
|
|
reason: "Approval timed out",
|
|
});
|
|
});
|
|
|
|
it("blocks turn-source plugin approval timeouts with setup guidance", async () => {
|
|
registerTelegramPluginApprovalSetup();
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "Timeout test",
|
|
description: "Will time out",
|
|
},
|
|
});
|
|
|
|
mockCallGateway.mockResolvedValueOnce({
|
|
id: "server-id-3",
|
|
status: "accepted",
|
|
deliveryRoute: "turn-source",
|
|
});
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-3", decision: null });
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: {
|
|
agentId: "main",
|
|
sessionKey: "main",
|
|
turnSourceChannel: "telegram",
|
|
turnSourceTo: "-100123456789",
|
|
turnSourceAccountId: "default",
|
|
},
|
|
});
|
|
|
|
expect(result.blocked).toBe(true);
|
|
expect(result).toHaveProperty("disposition", "timed_out");
|
|
expect(result).toHaveProperty(
|
|
"reason",
|
|
"Approval timed out\n\nConfigure Telegram native approval setup.",
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
["a timeout", null],
|
|
["an explicit timeout decision", PluginApprovalResolutions.TIMEOUT],
|
|
["an unknown decision", "approved"],
|
|
["a malformed truthy decision", true as unknown as string],
|
|
])("blocks on %s even when deprecated timeoutBehavior is allow", async (_label, decision) => {
|
|
const onResolution = vi.fn();
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
params: { command: "safe-command" },
|
|
requireApproval: {
|
|
title: "Lenient timeout",
|
|
description: "Must fail closed",
|
|
timeoutBehavior: "allow",
|
|
onResolution,
|
|
},
|
|
});
|
|
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-4", status: "accepted" });
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-4", decision });
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: { command: "rm -rf /" },
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
blocked: true,
|
|
kind: "failure",
|
|
disposition: "timed_out",
|
|
deniedReason: "plugin-approval",
|
|
reason: "Approval timed out",
|
|
params: { command: "rm -rf /" },
|
|
});
|
|
expect(onResolution).toHaveBeenCalledWith(PluginApprovalResolutions.TIMEOUT);
|
|
});
|
|
|
|
it("blocks exact allow decisions excluded by the request", async () => {
|
|
const onResolution = vi.fn();
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
params: { command: "safe-command" },
|
|
requireApproval: {
|
|
title: "Restricted approval",
|
|
description: "Allow once only",
|
|
allowedDecisions: ["allow-once", "deny"],
|
|
onResolution,
|
|
},
|
|
});
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-restricted", status: "accepted" });
|
|
mockCallGateway.mockResolvedValueOnce({
|
|
id: "server-id-restricted",
|
|
decision: "allow-always",
|
|
});
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: { command: "unsafe-command" },
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
blocked: true,
|
|
disposition: "timed_out",
|
|
reason: "Approval timed out",
|
|
params: { command: "unsafe-command" },
|
|
});
|
|
expect(onResolution).toHaveBeenCalledWith(PluginApprovalResolutions.TIMEOUT);
|
|
});
|
|
|
|
it("blocks a wait decision bound to another approval id", async () => {
|
|
const onResolution = vi.fn();
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
params: { command: "safe-command" },
|
|
requireApproval: {
|
|
title: "Bound approval",
|
|
description: "Must match the request id",
|
|
onResolution,
|
|
},
|
|
});
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-bound", status: "accepted" });
|
|
mockCallGateway.mockResolvedValueOnce({
|
|
id: "server-id-other",
|
|
decision: "allow-once",
|
|
});
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: { command: "unsafe-command" },
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
blocked: true,
|
|
disposition: "timed_out",
|
|
reason: "Approval timed out",
|
|
params: { command: "unsafe-command" },
|
|
});
|
|
expect(onResolution).toHaveBeenCalledWith(PluginApprovalResolutions.TIMEOUT);
|
|
});
|
|
|
|
it("falls back to block on gateway error", async () => {
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "Gateway down",
|
|
description: "Gateway is unavailable",
|
|
},
|
|
});
|
|
|
|
mockCallGateway.mockRejectedValueOnce(new Error("unknown method plugin.approval.request"));
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(result.blocked).toBe(true);
|
|
expect(result).toHaveProperty("reason", "Plugin approval required (gateway unavailable)");
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"surfaces validation rejections",
|
|
new GatewayClientRequestError({
|
|
code: "INVALID_REQUEST",
|
|
message:
|
|
"invalid plugin.approval.request params: at /title: must not have more than 80 characters",
|
|
}),
|
|
"Plugin approval request rejected: invalid plugin.approval.request params: at /title: must not have more than 80 characters",
|
|
],
|
|
[
|
|
"keeps structured service failures on the unavailable fallback",
|
|
new GatewayClientRequestError({
|
|
code: "UNAVAILABLE",
|
|
message: "approval service unavailable",
|
|
}),
|
|
"Plugin approval required (gateway unavailable)",
|
|
],
|
|
])("%s", async (_label, error, expectedReason) => {
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "x".repeat(81),
|
|
description: "Gateway classification test",
|
|
},
|
|
});
|
|
mockCallGateway.mockRejectedValueOnce(error);
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(result.blocked).toBe(true);
|
|
expect(result).toHaveProperty("reason", expectedReason);
|
|
});
|
|
|
|
it("reports an expired accepted approval without calling it a request rejection", async () => {
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: { title: "Approval", description: "Wait phase classification" },
|
|
});
|
|
mockCallGateway
|
|
.mockResolvedValueOnce({ id: "plugin:accepted", status: "accepted" })
|
|
.mockRejectedValueOnce(
|
|
new GatewayClientRequestError({
|
|
code: "INVALID_REQUEST",
|
|
message: "approval expired or not found",
|
|
}),
|
|
);
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(result).toHaveProperty(
|
|
"reason",
|
|
"Plugin approval no longer available: approval expired or not found",
|
|
);
|
|
});
|
|
|
|
it("blocks when gateway returns no id", async () => {
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "No ID",
|
|
description: "Registration returns no id",
|
|
},
|
|
});
|
|
|
|
mockCallGateway.mockResolvedValueOnce({ status: "error" });
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(result.blocked).toBe(true);
|
|
expect(result).toHaveProperty("reason", "Registration returns no id");
|
|
});
|
|
|
|
it("blocks on immediate null decision without calling waitDecision even when timeoutBehavior is allow", async () => {
|
|
const onResolution = vi.fn();
|
|
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "No route",
|
|
description: "No approval route available",
|
|
timeoutBehavior: "allow",
|
|
onResolution,
|
|
},
|
|
});
|
|
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-immediate", decision: null });
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(result.blocked).toBe(true);
|
|
expect(result).toHaveProperty("reason", "Plugin approval unavailable (no approval route)");
|
|
expect(onResolution).toHaveBeenCalledWith("cancelled");
|
|
expect(mockCallGateway.mock.calls.map(([method]) => method)).toEqual([
|
|
"plugin.approval.request",
|
|
]);
|
|
});
|
|
|
|
it("unblocks immediately when abort signal fires during waitDecision", async () => {
|
|
const result = await runAbortDuringApprovalWait();
|
|
|
|
expect(result.blocked).toBe(true);
|
|
expect(result).toHaveProperty("reason", "Approval cancelled (run aborted)");
|
|
expect(mockCallGateway).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("classifies non-Error abort reasons as run abort cancellation", async () => {
|
|
const result = await runAbortDuringApprovalWait({ abortReason: "sessions_yield" });
|
|
|
|
expect(result.blocked).toBe(true);
|
|
expect(result).toHaveProperty("reason", "Approval cancelled (run aborted)");
|
|
});
|
|
|
|
it("removes abort listener after waitDecision resolves", async () => {
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "Cleanup listener",
|
|
description: "Wait resolves quickly",
|
|
},
|
|
});
|
|
|
|
const controller = new AbortController();
|
|
const removeListenerSpy = vi.spyOn(controller.signal, "removeEventListener");
|
|
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-cleanup", status: "accepted" });
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-cleanup", decision: "allow-once" });
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
signal: controller.signal,
|
|
});
|
|
|
|
expect(result.blocked).toBe(false);
|
|
expect(removeListenerSpy.mock.calls.map(([type]) => type)).toContain("abort");
|
|
});
|
|
|
|
it("calls onResolution with allow-once on approval", async () => {
|
|
const onResolution = vi.fn();
|
|
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "Needs approval",
|
|
description: "Check this",
|
|
onResolution,
|
|
},
|
|
});
|
|
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-r1", status: "accepted" });
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-r1", decision: "allow-once" });
|
|
|
|
await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(onResolution).toHaveBeenCalledWith("allow-once");
|
|
});
|
|
|
|
it("allows allow-always decisions for tool approvals", async () => {
|
|
const onResolution = vi.fn();
|
|
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "Needs durable approval",
|
|
description: "Check this durable approval",
|
|
onResolution,
|
|
},
|
|
});
|
|
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-allow-always", status: "accepted" });
|
|
mockCallGateway.mockResolvedValueOnce({
|
|
id: "server-id-allow-always",
|
|
decision: "allow-always",
|
|
});
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: { command: "echo ok" },
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
blocked: false,
|
|
params: { command: "echo ok" },
|
|
approvalResolution: "allow-always",
|
|
});
|
|
expect(onResolution).toHaveBeenCalledWith("allow-always");
|
|
});
|
|
|
|
it("does not await onResolution before returning approval outcome", async () => {
|
|
const onResolution = vi.fn(() => new Promise<void>(() => {}));
|
|
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "Non-blocking callback",
|
|
description: "Should not block tool execution",
|
|
onResolution,
|
|
},
|
|
});
|
|
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-r1-nonblocking", status: "accepted" });
|
|
mockCallGateway.mockResolvedValueOnce({
|
|
id: "server-id-r1-nonblocking",
|
|
decision: "allow-once",
|
|
});
|
|
|
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
try {
|
|
const result = await Promise.race([
|
|
runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
}),
|
|
new Promise<never>((_, reject) => {
|
|
timeoutId = setTimeout(
|
|
() => reject(new Error("runBeforeToolCallHook waited for onResolution")),
|
|
250,
|
|
);
|
|
}),
|
|
]);
|
|
|
|
expect(result).toEqual({
|
|
blocked: false,
|
|
params: {},
|
|
approvalResolution: "allow-once",
|
|
});
|
|
expect(onResolution).toHaveBeenCalledWith("allow-once");
|
|
} finally {
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("calls onResolution with deny on denial", async () => {
|
|
const onResolution = vi.fn();
|
|
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "Needs approval",
|
|
description: "Check this",
|
|
onResolution,
|
|
},
|
|
});
|
|
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-r2", status: "accepted" });
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-r2", decision: "deny" });
|
|
|
|
await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(onResolution).toHaveBeenCalledWith("deny");
|
|
});
|
|
|
|
it("calls onResolution with timeout when decision is null", async () => {
|
|
const onResolution = vi.fn();
|
|
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "Timeout resolution",
|
|
description: "Will time out",
|
|
onResolution,
|
|
},
|
|
});
|
|
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-r3", status: "accepted" });
|
|
mockCallGateway.mockResolvedValueOnce({ id: "server-id-r3", decision: null });
|
|
|
|
await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(onResolution).toHaveBeenCalledWith("timeout");
|
|
});
|
|
|
|
it("calls onResolution with cancelled on gateway error", async () => {
|
|
const onResolution = vi.fn();
|
|
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "Gateway error",
|
|
description: "Gateway will fail",
|
|
onResolution,
|
|
},
|
|
});
|
|
|
|
mockCallGateway.mockRejectedValueOnce(new Error("gateway down"));
|
|
|
|
const result = await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(result.blocked).toBe(true);
|
|
expect(result).toHaveProperty("reason", "Plugin approval required (gateway unavailable)");
|
|
expect(onResolution).toHaveBeenCalledWith("cancelled");
|
|
});
|
|
|
|
it("calls onResolution with cancelled when abort signal fires", async () => {
|
|
const onResolution = vi.fn();
|
|
const result = await runAbortDuringApprovalWait({ onResolution });
|
|
|
|
expect(result.blocked).toBe(true);
|
|
expect(result).toHaveProperty("reason", "Approval cancelled (run aborted)");
|
|
expect(onResolution).toHaveBeenCalledWith("cancelled");
|
|
});
|
|
|
|
it("calls onResolution with cancelled when gateway returns no id", async () => {
|
|
const onResolution = vi.fn();
|
|
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "No ID",
|
|
description: "Registration returns no id",
|
|
onResolution,
|
|
},
|
|
});
|
|
|
|
mockCallGateway.mockResolvedValueOnce({ status: "error" });
|
|
|
|
await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
expect(onResolution).toHaveBeenCalledWith("cancelled");
|
|
});
|
|
|
|
it("forwards turn source routing fields from ctx to plugin.approval.request", async () => {
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "Channel-routed approval",
|
|
description: "Must route to telegram",
|
|
pluginId: "my-plugin",
|
|
},
|
|
});
|
|
|
|
mockCallGateway.mockResolvedValueOnce({ id: "route-id-1", status: "accepted" });
|
|
mockCallGateway.mockResolvedValueOnce({ id: "route-id-1", decision: "allow-once" });
|
|
|
|
await runBeforeToolCallHook({
|
|
toolName: "fetch",
|
|
params: { url: "https://example.com" },
|
|
ctx: {
|
|
agentId: "main",
|
|
sessionKey: "main",
|
|
turnSourceChannel: "telegram",
|
|
turnSourceTo: "-100123456789",
|
|
turnSourceAccountId: "acct-42",
|
|
turnSourceThreadId: 9001,
|
|
},
|
|
});
|
|
|
|
const requestCall = requireGatewayCall(0);
|
|
expect(requestCall[0]).toBe("plugin.approval.request");
|
|
const requestParams = requireRecord(requestCall[2], "approval request params");
|
|
expect(requestParams.turnSourceChannel).toBe("telegram");
|
|
expect(requestParams.turnSourceTo).toBe("-100123456789");
|
|
expect(requestParams.turnSourceAccountId).toBe("acct-42");
|
|
expect(requestParams.turnSourceThreadId).toBe(9001);
|
|
});
|
|
|
|
it("uses the transport channel when tool policy provider differs", async () => {
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "Transport routed approval",
|
|
description: "Must use the transport channel",
|
|
pluginId: "my-plugin",
|
|
},
|
|
});
|
|
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hook-route-"));
|
|
await fs.writeFile(path.join(tempDir, "note.txt"), "hello");
|
|
mockCallGateway.mockResolvedValueOnce({ id: "transport-route-id", status: "accepted" });
|
|
mockCallGateway.mockResolvedValueOnce({
|
|
id: "transport-route-id",
|
|
decision: "allow-once",
|
|
});
|
|
|
|
const tools = createOpenClawCodingTools({
|
|
workspaceDir: tempDir,
|
|
messageProvider: "discord-voice",
|
|
messageChannel: "discord",
|
|
currentChannelId: "native-channel-1",
|
|
currentMessagingTarget: "channel:deliverable-1",
|
|
agentAccountId: "acct-1",
|
|
currentThreadTs: "thread-1",
|
|
approvalReviewerDeviceId: "device-tui-reviewer",
|
|
});
|
|
const readTool = tools.find((tool) => tool.name === "read");
|
|
if (!readTool) {
|
|
throw new Error("missing read tool");
|
|
}
|
|
await readTool.execute("tool-hook-route", { path: "note.txt" }, undefined, undefined);
|
|
|
|
const requestCall = requireGatewayCall(0);
|
|
expect(requestCall[0]).toBe("plugin.approval.request");
|
|
const requestParams = requireRecord(requestCall[2], "approval request params");
|
|
expect(requestParams.turnSourceChannel).toBe("discord");
|
|
expect(requestParams.turnSourceTo).toBe("channel:deliverable-1");
|
|
expect(requestParams.turnSourceAccountId).toBe("acct-1");
|
|
expect(requestParams.turnSourceThreadId).toBe("thread-1");
|
|
expect(requestParams.approvalReviewerDeviceIds).toEqual(["device-tui-reviewer"]);
|
|
});
|
|
|
|
it("omits turn source routing fields when ctx does not carry them", async () => {
|
|
hookRunner.runBeforeToolCall.mockResolvedValue({
|
|
requireApproval: {
|
|
title: "No route ctx",
|
|
description: "Local-only approval",
|
|
},
|
|
});
|
|
|
|
mockCallGateway.mockResolvedValueOnce({ id: "no-route-id", status: "accepted" });
|
|
mockCallGateway.mockResolvedValueOnce({ id: "no-route-id", decision: "allow-once" });
|
|
|
|
await runBeforeToolCallHook({
|
|
toolName: "bash",
|
|
params: {},
|
|
ctx: { agentId: "main", sessionKey: "main" },
|
|
});
|
|
|
|
const requestCall = requireGatewayCall(0);
|
|
const requestParams = requireRecord(requestCall[2], "approval request params");
|
|
expect(requestParams.turnSourceChannel).toBeUndefined();
|
|
expect(requestParams.turnSourceTo).toBeUndefined();
|
|
expect(requestParams.turnSourceAccountId).toBeUndefined();
|
|
expect(requestParams.turnSourceThreadId).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("before_tool_call tool content private-data capture", () => {
|
|
type TrustedToolEvent = {
|
|
event: DiagnosticEventPayload;
|
|
privateData: DiagnosticEventPrivateData;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
resetDiagnosticSessionStateForTest();
|
|
resetDiagnosticEventsForTest();
|
|
});
|
|
|
|
async function withTrustedToolEvents(
|
|
run: (emitted: TrustedToolEvent[], flush: () => Promise<void>) => Promise<void>,
|
|
) {
|
|
const emitted: TrustedToolEvent[] = [];
|
|
const stop = onTrustedInternalDiagnosticEvent((event, _metadata, privateData) => {
|
|
if (event.type.startsWith("tool.execution.")) {
|
|
emitted.push({ event, privateData });
|
|
}
|
|
});
|
|
const flush = () =>
|
|
new Promise<void>((resolve) => {
|
|
setImmediate(resolve);
|
|
});
|
|
try {
|
|
await run(emitted, flush);
|
|
} finally {
|
|
stop();
|
|
}
|
|
}
|
|
|
|
function configWithToolContent(
|
|
fields: { toolInputs?: boolean; toolOutputs?: boolean } = {
|
|
toolInputs: true,
|
|
toolOutputs: true,
|
|
},
|
|
) {
|
|
return {
|
|
diagnostics: {
|
|
enabled: true,
|
|
otel: {
|
|
enabled: true,
|
|
traces: true,
|
|
captureContent: { enabled: true, ...fields },
|
|
},
|
|
},
|
|
} as unknown as import("../config/types.openclaw.js").OpenClawConfig;
|
|
}
|
|
|
|
it("attaches tool input/output to private data when opted in", async () => {
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "file body" }] });
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
runId: "run-1",
|
|
loopDetection: { enabled: false },
|
|
config: configWithToolContent(),
|
|
});
|
|
|
|
await withTrustedToolEvents(async (emitted, flush) => {
|
|
await tool.execute("call-1", { path: "/etc/secret" }, undefined, undefined);
|
|
await flush();
|
|
|
|
const completed = emitted.find((e) => e.event.type === "tool.execution.completed");
|
|
expect(completed?.privateData.toolContent?.toolInput).toEqual({ path: "/etc/secret" });
|
|
expect(completed?.privateData.toolContent?.toolOutput).toEqual({
|
|
content: [{ type: "text", text: "file body" }],
|
|
});
|
|
// Public event payload must never carry raw params/results.
|
|
expect(JSON.stringify(completed?.event)).not.toContain("/etc/secret");
|
|
expect(JSON.stringify(completed?.event)).not.toContain("file body");
|
|
});
|
|
});
|
|
|
|
it("omits tool content from private data when capture is not configured", async () => {
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] });
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
runId: "run-1",
|
|
loopDetection: { enabled: false },
|
|
});
|
|
|
|
await withTrustedToolEvents(async (emitted, flush) => {
|
|
await tool.execute("call-1", { path: "/etc/secret" }, undefined, undefined);
|
|
await flush();
|
|
|
|
const completed = emitted.find((e) => e.event.type === "tool.execution.completed");
|
|
expect(completed).toBeDefined();
|
|
expect(completed?.privateData.toolContent).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("captures only opted-in fields and clones away from live params", async () => {
|
|
const liveParams = { path: "/etc/secret" };
|
|
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "out" }] });
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
runId: "run-1",
|
|
loopDetection: { enabled: false },
|
|
config: configWithToolContent({ toolInputs: true, toolOutputs: false }),
|
|
});
|
|
|
|
await withTrustedToolEvents(async (emitted, flush) => {
|
|
await tool.execute("call-1", liveParams, undefined, undefined);
|
|
await flush();
|
|
|
|
const completed = emitted.find((e) => e.event.type === "tool.execution.completed");
|
|
expect(completed?.privateData.toolContent?.toolInput).toEqual({ path: "/etc/secret" });
|
|
expect(completed?.privateData.toolContent?.toolOutput).toBeUndefined();
|
|
// Captured snapshot is a clone, not the live params object.
|
|
expect(completed?.privateData.toolContent?.toolInput).not.toBe(liveParams);
|
|
});
|
|
});
|
|
|
|
it("attaches tool input but not output on execution errors", async () => {
|
|
const execute = vi.fn().mockRejectedValue(new Error("boom"));
|
|
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
|
|
agentId: "main",
|
|
sessionKey: "session-key",
|
|
runId: "run-1",
|
|
loopDetection: { enabled: false },
|
|
config: configWithToolContent(),
|
|
});
|
|
|
|
await withTrustedToolEvents(async (emitted, flush) => {
|
|
await expect(
|
|
tool.execute("call-1", { path: "/etc/secret" }, undefined, undefined),
|
|
).rejects.toThrow("boom");
|
|
await flush();
|
|
|
|
const errored = emitted.find((e) => e.event.type === "tool.execution.error");
|
|
expect(errored?.privateData.toolContent?.toolInput).toEqual({ path: "/etc/secret" });
|
|
expect(errored?.privateData.toolContent?.toolOutput).toBeUndefined();
|
|
});
|
|
});
|
|
});
|