fix(hooks): support 'ask' decision for BeforeTool hooks (#21146)

This commit is contained in:
Christian Gunderman
2026-03-21 03:52:39 +00:00
committed by GitHub
parent d3766875f8
commit d1dc4902fd
32 changed files with 1016 additions and 117 deletions
+44 -17
View File
@@ -19,9 +19,15 @@ import {
type ToolConfirmationResponse,
type Question,
} from '../confirmation-bus/types.js';
import { type ApprovalMode } from '../policy/types.js';
import { ApprovalMode } from '../policy/types.js';
import type { SubagentProgress } from '../agents/types.js';
/**
/**
* Supported decisions for forcing tool execution behavior.
*/
export type ForcedToolDecision = 'allow' | 'deny' | 'ask_user';
/**
* Options bag for tool execution, replacing positional parameters that are
* only relevant to specific tool types.
@@ -65,6 +71,7 @@ export interface ToolInvocation<
*/
shouldConfirmExecute(
abortSignal: AbortSignal,
forcedDecision?: ForcedToolDecision,
): Promise<ToolCallConfirmationDetails | false>;
/**
@@ -148,6 +155,8 @@ export abstract class BaseToolInvocation<
readonly _toolDisplayName?: string,
readonly _serverName?: string,
readonly _toolAnnotations?: Record<string, unknown>,
readonly respectsAutoEdit: boolean = false,
readonly getApprovalMode: () => ApprovalMode = () => ApprovalMode.DEFAULT,
) {}
abstract getDescription(): string;
@@ -158,13 +167,23 @@ export abstract class BaseToolInvocation<
async shouldConfirmExecute(
abortSignal: AbortSignal,
forcedDecision?: ForcedToolDecision,
): Promise<ToolCallConfirmationDetails | false> {
const decision = await this.getMessageBusDecision(abortSignal);
if (decision === 'ALLOW') {
if (
this.respectsAutoEdit &&
this.getApprovalMode() === ApprovalMode.AUTO_EDIT &&
forcedDecision !== 'ask_user'
) {
return false;
}
if (decision === 'DENY') {
const decision =
forcedDecision ?? (await this.getMessageBusDecision(abortSignal));
if (decision === 'allow') {
return false;
}
if (decision === 'deny') {
throw new Error(
`Tool execution for "${
this._toolDisplayName || this._toolName
@@ -172,7 +191,7 @@ export abstract class BaseToolInvocation<
);
}
if (decision === 'ASK_USER') {
if (decision === 'ask_user') {
return this.getConfirmationDetails(abortSignal);
}
@@ -216,7 +235,7 @@ export abstract class BaseToolInvocation<
/**
* Subclasses should override this method to provide custom confirmation UI
* when the policy engine's decision is 'ASK_USER'.
* when the policy engine's decision is 'ask_user'.
* The base implementation provides a generic confirmation prompt.
*/
protected async getConfirmationDetails(
@@ -239,11 +258,12 @@ export abstract class BaseToolInvocation<
protected getMessageBusDecision(
abortSignal: AbortSignal,
): Promise<'ALLOW' | 'DENY' | 'ASK_USER'> {
forcedDecision?: ForcedToolDecision,
): Promise<ForcedToolDecision> {
if (!this.messageBus || !this._toolName) {
// If there's no message bus, we can't make a decision, so we allow.
// The legacy confirmation flow will still apply if the tool needs it.
return Promise.resolve('ALLOW');
return Promise.resolve('allow');
}
const correlationId = randomUUID();
@@ -257,11 +277,12 @@ export abstract class BaseToolInvocation<
},
serverName: this._serverName,
toolAnnotations: this._toolAnnotations,
forcedDecision,
};
return new Promise<'ALLOW' | 'DENY' | 'ASK_USER'>((resolve) => {
return new Promise<ForcedToolDecision>((resolve) => {
if (!this.messageBus) {
resolve('ALLOW');
resolve('allow');
return;
}
@@ -282,11 +303,11 @@ export abstract class BaseToolInvocation<
const abortHandler = () => {
cleanup();
resolve('DENY');
resolve('deny');
};
if (abortSignal.aborted) {
resolve('DENY');
resolve('deny');
return;
}
@@ -294,11 +315,11 @@ export abstract class BaseToolInvocation<
if (response.correlationId === correlationId) {
cleanup();
if (response.requiresUserConfirmation) {
resolve('ASK_USER');
resolve('ask_user');
} else if (response.confirmed) {
resolve('ALLOW');
resolve('allow');
} else {
resolve('DENY');
resolve('deny');
}
}
};
@@ -307,7 +328,7 @@ export abstract class BaseToolInvocation<
timeoutId = setTimeout(() => {
cleanup();
resolve('ASK_USER'); // Default to ASK_USER on timeout
resolve('ask_user'); // Default to ask_user on timeout
}, 30000);
this.messageBus.subscribe(
@@ -325,7 +346,7 @@ export abstract class BaseToolInvocation<
void this.messageBus.publish(request);
} catch (_error) {
cleanup();
resolve('ALLOW');
resolve('allow');
}
});
}
@@ -859,6 +880,7 @@ export interface DiffStat {
export interface ToolEditConfirmationDetails {
type: 'edit';
title: string;
systemMessage?: string;
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
@@ -897,6 +919,7 @@ export type ToolConfirmationPayload =
export interface ToolExecuteConfirmationDetails {
type: 'exec';
title: string;
systemMessage?: string;
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
command: string;
rootCommand: string;
@@ -907,6 +930,7 @@ export interface ToolExecuteConfirmationDetails {
export interface ToolMcpConfirmationDetails {
type: 'mcp';
title: string;
systemMessage?: string;
serverName: string;
toolName: string;
toolDisplayName: string;
@@ -919,6 +943,7 @@ export interface ToolMcpConfirmationDetails {
export interface ToolInfoConfirmationDetails {
type: 'info';
title: string;
systemMessage?: string;
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
prompt: string;
urls?: string[];
@@ -927,6 +952,7 @@ export interface ToolInfoConfirmationDetails {
export interface ToolAskUserConfirmationDetails {
type: 'ask_user';
title: string;
systemMessage?: string;
questions: Question[];
onConfirm: (
outcome: ToolConfirmationOutcome,
@@ -937,6 +963,7 @@ export interface ToolAskUserConfirmationDetails {
export interface ToolExitPlanModeConfirmationDetails {
type: 'exit_plan_mode';
title: string;
systemMessage?: string;
planPath: string;
onConfirm: (
outcome: ToolConfirmationOutcome,