| import { afterEach, describe, expect, it, vi } from "vitest"; |
| import type { CliBackendToolPermissionResult } from "../../plugins/cli-backend.types.js"; |
| import { |
| initializeGlobalHookRunner, |
| resetGlobalHookRunner, |
| } from "../../plugins/hook-runner-global.js"; |
| import type { PluginHookHandlerMap } from "../../plugins/hook-types.js"; |
| import { createMockPluginRegistry } from "../../plugins/hooks.test-fixtures.js"; |
| import * as beforeToolCall from "../agent-tools.before-tool-call.js"; |
| import { callGatewayTool } from "../tools/gateway.js"; |
| import { |
| closePluginTestAdmissions, |
| createExecution, |
| requestNativeTool, |
| runPlugin, |
| SUCCESS_RESULT, |
| } from "./execute-plugin.test-support.js"; |
|
|
| vi.mock("../tools/gateway.js", () => ({ |
| callGatewayTool: vi.fn(), |
| })); |
|
|
| const mockCallGatewayTool = vi.mocked(callGatewayTool); |
|
|
| function installBeforeToolCallHook( |
| handler: PluginHookHandlerMap["before_tool_call"], |
| matcher?: [string, ...string[]], |
| ) { |
| initializeGlobalHookRunner( |
| createMockPluginRegistry([ |
| { |
| hookName: "before_tool_call", |
| handler: (...args) => Reflect.apply(handler, undefined, args), |
| ...(matcher ? { matcher } : {}), |
| }, |
| ]), |
| ); |
| } |
|
|
| afterEach(() => { |
| resetGlobalHookRunner(); |
| closePluginTestAdmissions(); |
| mockCallGatewayTool.mockReset(); |
| vi.restoreAllMocks(); |
| }); |
|
|
| describe("plugin-owned CLI native tool policy", () => { |
| it("denies native tools when caller authority expires during policy or before a retained call", async () => { |
| const { context } = await createExecution({ nativeTools: ["WebFetch"] }); |
| let callerCurrent = true; |
| context.params.assertCurrent = () => { |
| if (!callerCurrent) { |
| throw new Error("caller revoked"); |
| } |
| }; |
| const hook = vi.fn(async () => { |
| callerCurrent = false; |
| }); |
| installBeforeToolCallHook(hook); |
| await runPlugin(context, async function* (execution) { |
| await expect( |
| requestNativeTool(execution, "WebFetch", { url: "https://example.com" }), |
| ).resolves.toMatchObject({ behavior: "deny" }); |
| await expect( |
| requestNativeTool(execution, "WebFetch", { url: "https://example.com/retained" }), |
| ).resolves.toMatchObject({ behavior: "deny" }); |
| expect(execution.abortSignal?.aborted).toBe(false); |
| yield SUCCESS_RESULT; |
| }); |
| expect(hook).toHaveBeenCalledOnce(); |
| expect(mockCallGatewayTool).not.toHaveBeenCalled(); |
| }); |
|
|
| it("runs canonical policy before native approval and carries rewritten params plus run context", async () => { |
| const policy = vi.spyOn(beforeToolCall, "runBeforeToolCallHook"); |
| const hook = vi.fn(async (_event: unknown, _context: unknown) => ({ |
| params: { url: "https://example.com/rewritten" }, |
| })); |
| installBeforeToolCallHook(hook); |
| const { context } = await createExecution({ nativeTools: ["WebFetch"] }); |
| Object.assign(context.params, { |
| messageChannel: "telegram", |
| messageProvider: "telegram", |
| currentChannelId: "chat-1", |
| chatId: "chat-1", |
| agentAccountId: "bot-1", |
| senderId: "user-1", |
| senderIsOwner: true, |
| currentThreadTs: "thread-1", |
| }); |
| let decision: CliBackendToolPermissionResult | undefined; |
|
|
| await runPlugin(context, async function* (execution) { |
| decision = await requestNativeTool(execution, "WebFetch", { |
| url: "https://example.com/original", |
| }); |
| yield SUCCESS_RESULT; |
| }); |
|
|
| expect(decision).toEqual({ |
| behavior: "allow", |
| updatedInput: { url: "https://example.com/rewritten" }, |
| }); |
| expect(hook).toHaveBeenCalledOnce(); |
| expect(hook.mock.calls[0]?.[0]).toMatchObject({ |
| toolName: "web_fetch", |
| params: { url: "https://example.com/original" }, |
| toolCallId: "native-WebFetch", |
| runId: context.params.runId, |
| }); |
| expect(hook.mock.calls[0]?.[1]).toMatchObject({ |
| agentId: "main", |
| sessionKey: "agent:main:main", |
| sessionId: "sdk-session", |
| runId: context.params.runId, |
| channelId: "chat-1", |
| requester: { |
| channel: "telegram", |
| accountId: "bot-1", |
| senderId: "user-1", |
| senderIsOwner: true, |
| }, |
| }); |
| expect(policy.mock.calls[0]?.[0]).toMatchObject({ |
| ctx: { |
| config: context.params.config, |
| cwd: "/tmp", |
| workspaceDir: "/tmp", |
| turnSourceChannel: "telegram", |
| turnSourceTo: "chat-1", |
| turnSourceAccountId: "bot-1", |
| turnSourceThreadId: "thread-1", |
| loopDetection: undefined, |
| }, |
| }); |
| expect(mockCallGatewayTool).not.toHaveBeenCalled(); |
| }); |
|
|
| it.each([ |
| { native: "Bash", canonical: "exec", input: { command: "echo blocked" } }, |
| { |
| native: "WebFetch", |
| canonical: "web_fetch", |
| input: { url: "https://example.com", prompt: "summarize" }, |
| }, |
| { native: "WebSearch", canonical: "web_search", input: { query: "blocked" } }, |
| ])( |
| "applies matched $canonical policy to native $native", |
| async ({ native, canonical, input }) => { |
| const hook = vi.fn(async () => ({ block: true, blockReason: `${canonical} blocked` })); |
| installBeforeToolCallHook(hook, [canonical]); |
| const { context } = await createExecution({ nativeTools: [native] }); |
| let decision: CliBackendToolPermissionResult | undefined; |
|
|
| await runPlugin(context, async function* (execution) { |
| decision = await requestNativeTool(execution, native, input); |
| yield SUCCESS_RESULT; |
| }); |
|
|
| expect(decision).toEqual({ behavior: "deny", message: `${canonical} blocked` }); |
| expect(hook).toHaveBeenCalledWith( |
| expect.objectContaining({ toolName: canonical, params: input }), |
| expect.objectContaining({ toolName: canonical }), |
| ); |
| expect(mockCallGatewayTool).not.toHaveBeenCalled(); |
| }, |
| ); |
|
|
| it.each([ |
| { native: "Read", canonical: "read", input: { file_path: "/tmp/private.txt" } }, |
| { |
| native: "Write", |
| canonical: "write", |
| input: { file_path: "/tmp/private.txt", content: "private" }, |
| }, |
| { |
| native: "Edit", |
| canonical: "edit", |
| input: { file_path: "/tmp/private.txt", old_string: "old", new_string: "new" }, |
| }, |
| ])( |
| "applies path-based $canonical policy to native $native", |
| async ({ native, canonical, input }) => { |
| const hook = vi.fn(async (event: { params: Record<string, unknown> }) => |
| event.params.path === "/tmp/private.txt" |
| ? { block: true, blockReason: "private path blocked" } |
| : undefined, |
| ); |
| installBeforeToolCallHook(hook, [canonical]); |
| const { context } = await createExecution({ nativeTools: [native] }); |
| let decision: CliBackendToolPermissionResult | undefined; |
|
|
| await runPlugin(context, async function* (execution) { |
| decision = await requestNativeTool(execution, native, input); |
| yield SUCCESS_RESULT; |
| }); |
|
|
| expect(decision).toEqual({ behavior: "deny", message: "private path blocked" }); |
| expect(hook).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| toolName: canonical, |
| params: expect.objectContaining({ path: "/tmp/private.txt" }), |
| }), |
| expect.objectContaining({ toolName: canonical }), |
| ); |
| }, |
| ); |
|
|
| it("projects rewritten canonical file arguments back into the native Edit schema", async () => { |
| const hook = vi.fn(async () => ({ |
| params: { |
| path: "/tmp/approved.txt", |
| edits: [{ oldText: "safe-before", newText: "safe-after" }], |
| }, |
| })); |
| installBeforeToolCallHook(hook, ["edit"]); |
| const { context } = await createExecution({ nativeTools: ["Edit"] }); |
| let decision: CliBackendToolPermissionResult | undefined; |
|
|
| await runPlugin(context, async function* (execution) { |
| decision = await requestNativeTool(execution, "Edit", { |
| file_path: "/tmp/original.txt", |
| old_string: "before", |
| new_string: "after", |
| replace_all: false, |
| }); |
| yield SUCCESS_RESULT; |
| }); |
|
|
| expect(hook).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| toolName: "edit", |
| params: expect.objectContaining({ |
| path: "/tmp/original.txt", |
| edits: [{ oldText: "before", newText: "after" }], |
| }), |
| }), |
| expect.anything(), |
| ); |
| expect(decision).toEqual({ |
| behavior: "allow", |
| updatedInput: { |
| file_path: "/tmp/approved.txt", |
| old_string: "safe-before", |
| new_string: "safe-after", |
| replace_all: false, |
| }, |
| }); |
| }); |
|
|
| it("rejects conflicting native and canonical paths before invoking policy", async () => { |
| const hook = vi.fn(async () => undefined); |
| installBeforeToolCallHook(hook, ["read"]); |
| const { context } = await createExecution({ nativeTools: ["Read"] }); |
| let decision: CliBackendToolPermissionResult | undefined; |
|
|
| await runPlugin(context, async function* (execution) { |
| decision = await requestNativeTool(execution, "Read", { |
| file_path: "/tmp/private.txt", |
| path: "/tmp/allowed.txt", |
| }); |
| yield SUCCESS_RESULT; |
| }); |
|
|
| expect(decision).toEqual( |
| expect.objectContaining({ |
| behavior: "deny", |
| message: expect.stringContaining("conflicting"), |
| }), |
| ); |
| expect(hook).not.toHaveBeenCalled(); |
| }); |
|
|
| it("rejects canonical edit rewrites that the native tool cannot represent", async () => { |
| const hook = vi.fn(async () => ({ |
| params: { |
| edits: [ |
| { oldText: "first", newText: "one" }, |
| { oldText: "second", newText: "two" }, |
| ], |
| }, |
| })); |
| installBeforeToolCallHook(hook, ["edit"]); |
| const { context } = await createExecution({ nativeTools: ["Edit"] }); |
| let decision: CliBackendToolPermissionResult | undefined; |
|
|
| await runPlugin(context, async function* (execution) { |
| decision = await requestNativeTool(execution, "Edit", { |
| file_path: "/tmp/file.txt", |
| old_string: "before", |
| new_string: "after", |
| }); |
| yield SUCCESS_RESULT; |
| }); |
|
|
| expect(decision).toEqual( |
| expect.objectContaining({ |
| behavior: "deny", |
| message: expect.stringContaining("native edit"), |
| }), |
| ); |
| }); |
|
|
| it.each([ |
| { |
| name: "blocks", |
| handler: vi.fn(async () => ({ block: true, blockReason: "blocked by plugin policy" })), |
| message: "blocked by plugin policy", |
| }, |
| { |
| name: "fails", |
| handler: vi.fn(async () => { |
| throw new Error("policy crashed"); |
| }), |
| message: "before_tool_call hook failed", |
| }, |
| ])("fails closed when before_tool_call $name", async ({ handler, message }) => { |
| installBeforeToolCallHook(handler); |
| const { context } = await createExecution({ nativeTools: ["Bash"] }); |
| let decision: CliBackendToolPermissionResult | undefined; |
|
|
| await runPlugin(context, async function* (execution) { |
| decision = await requestNativeTool(execution); |
| yield SUCCESS_RESULT; |
| }); |
|
|
| expect(decision).toEqual( |
| expect.objectContaining({ behavior: "deny", message: expect.stringContaining(message) }), |
| ); |
| expect(mockCallGatewayTool).not.toHaveBeenCalled(); |
| }); |
|
|
| it("aborts before_tool_call without reaching native approval", async () => { |
| const controller = new AbortController(); |
| const hook = vi.fn( |
| async (_event: unknown, hookContext: { abortSignal?: AbortSignal }): Promise<undefined> => |
| await new Promise((_, reject) => { |
| hookContext.abortSignal?.addEventListener( |
| "abort", |
| () => reject(new Error("policy aborted")), |
| { once: true }, |
| ); |
| }), |
| ); |
| installBeforeToolCallHook(hook); |
| const { context } = await createExecution({ |
| abortSignal: controller.signal, |
| nativeTools: ["Bash"], |
| }); |
| const run = runPlugin(context, async function* (execution) { |
| await requestNativeTool(execution); |
| yield SUCCESS_RESULT; |
| }); |
| await vi.waitFor(() => expect(hook).toHaveBeenCalledOnce()); |
|
|
| controller.abort(new Error("cancel policy")); |
|
|
| await expect(run).rejects.toMatchObject({ name: "AbortError" }); |
| expect(mockCallGatewayTool).not.toHaveBeenCalled(); |
| }); |
|
|
| it("fails closed when canonical policy returns a non-record rewrite", async () => { |
| vi.spyOn(beforeToolCall, "runBeforeToolCallHook").mockResolvedValueOnce({ |
| blocked: false, |
| params: "invalid", |
| }); |
| const { context } = await createExecution({ nativeTools: ["Bash"] }); |
| let decision: CliBackendToolPermissionResult | undefined; |
|
|
| await runPlugin(context, async function* (execution) { |
| decision = await requestNativeTool(execution); |
| yield SUCCESS_RESULT; |
| }); |
|
|
| expect(decision).toEqual( |
| expect.objectContaining({ |
| behavior: "deny", |
| message: expect.stringContaining("invalid input"), |
| }), |
| ); |
| expect(mockCallGatewayTool).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|