diff --git a/extensions/cli/src/stream/streamChatResponse.helpers.ts b/extensions/cli/src/stream/streamChatResponse.helpers.ts index 776af67f554..355bb8d2b2e 100644 --- a/extensions/cli/src/stream/streamChatResponse.helpers.ts +++ b/extensions/cli/src/stream/streamChatResponse.helpers.ts @@ -28,6 +28,12 @@ import { PreprocessedToolCall, ToolCall } from "../tools/types.js"; import { logger } from "../util/logger.js"; import { StreamCallbacks } from "./streamChatResponse.types.js"; +import { + getPermissionDeniedMessage, + resolveUserPermissionResult, + type ToolPermissionApprovalResult, + type ToolPermissionDenialReason, +} from "./toolPermissionResult.js"; export interface ToolResultWithStatus extends ChatCompletionToolMessageParam { status: ToolStatus; @@ -38,12 +44,9 @@ export function handlePermissionDenied( toolCall: PreprocessedToolCall, chatHistoryEntries: ChatCompletionToolMessageParam[], callbacks?: StreamCallbacks, - reason: "user" | "policy" = "user", + reason: ToolPermissionDenialReason = "user", ): void { - const deniedMessage = - reason === "policy" - ? `Command blocked by security policy` - : `Permission denied by user`; + const deniedMessage = getPermissionDeniedMessage(reason); logger.info("Tool call denied", { name: toolCall.name, @@ -65,9 +68,13 @@ export function handlePermissionDenied( export async function requestUserPermission( toolCall: PreprocessedToolCall, callbacks?: StreamCallbacks, -): Promise { +): Promise { if (!callbacks?.onToolPermissionRequest) { - return false; + logger.error("Cannot request interactive tool permission", { + name: toolCall.name, + reason: "onToolPermissionRequest callback is missing", + }); + return undefined; } const toolCallRequest: ToolCallRequest = { @@ -115,7 +122,7 @@ export async function checkToolPermissionApproval( toolCall: PreprocessedToolCall, callbacks?: StreamCallbacks, isHeadless?: boolean, -): Promise<{ approved: boolean; denialReason?: "user" | "policy" }> { +): Promise { const permissionCheck = checkToolPermission(toolCall, permissions); if (permissionCheck.permission === "allow") { @@ -126,9 +133,7 @@ export async function checkToolPermissionApproval( return { approved: false, denialReason: "policy" }; } const userApproved = await requestUserPermission(toolCall, callbacks); - return userApproved - ? { approved: true } - : { approved: false, denialReason: "user" }; + return resolveUserPermissionResult(userApproved); } else if (permissionCheck.permission === "exclude") { // Tool blocked by security policy return { approved: false, denialReason: "policy" }; @@ -520,10 +525,7 @@ export async function executeStreamedToolCalls( if (!permissionResult.approved) { // Permission denied: create entry with canceled status const denialReason = permissionResult.denialReason || "user"; - const deniedMessage = - denialReason === "policy" - ? `Command blocked by security policy` - : `Permission denied by user`; + const deniedMessage = getPermissionDeniedMessage(denialReason); const deniedEntry: ToolResultWithStatus = { role: "tool", diff --git a/extensions/cli/src/stream/streamChatResponse.permissions.test.ts b/extensions/cli/src/stream/streamChatResponse.permissions.test.ts new file mode 100644 index 00000000000..d642321da18 --- /dev/null +++ b/extensions/cli/src/stream/streamChatResponse.permissions.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { + getPermissionDeniedMessage, + resolveUserPermissionResult, +} from "./toolPermissionResult.js"; + +describe("tool permission result", () => { + it("reports a missing interactive callback instead of a user denial", () => { + const result = resolveUserPermissionResult(undefined); + + expect(result).toEqual({ + approved: false, + denialReason: "callback_missing", + }); + expect(getPermissionDeniedMessage(result.denialReason!)).toBe( + "Interactive permission prompt unavailable", + ); + }); +}); diff --git a/extensions/cli/src/stream/toolPermissionResult.ts b/extensions/cli/src/stream/toolPermissionResult.ts new file mode 100644 index 00000000000..befd898bf6d --- /dev/null +++ b/extensions/cli/src/stream/toolPermissionResult.ts @@ -0,0 +1,29 @@ +export type ToolPermissionDenialReason = "user" | "policy" | "callback_missing"; + +export interface ToolPermissionApprovalResult { + approved: boolean; + denialReason?: ToolPermissionDenialReason; +} + +export function resolveUserPermissionResult( + userApproved: boolean | undefined, +): ToolPermissionApprovalResult { + if (userApproved === undefined) { + return { approved: false, denialReason: "callback_missing" }; + } + return userApproved + ? { approved: true } + : { approved: false, denialReason: "user" }; +} + +export function getPermissionDeniedMessage( + reason: ToolPermissionDenialReason, +): string { + if (reason === "policy") { + return "Command blocked by security policy"; + } + if (reason === "callback_missing") { + return "Interactive permission prompt unavailable"; + } + return "Permission denied by user"; +}