Skip to content

Commit c7a9f45

Browse files
authored
Enhance preToolUse hook and clean up code (microsoft#293265)
* Flesh out preToolUse hook * Cleanup * cleanups * Cleanup
1 parent ccb9e57 commit c7a9f45

File tree

9 files changed

+406
-33
lines changed

9 files changed

+406
-33
lines changed

src/vs/workbench/api/common/extHostLanguageModelTools.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,8 +290,12 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape
290290
chatRequestId: context.chatRequestId,
291291
chatSessionId: context.chatSessionId,
292292
chatSessionResource: context.chatSessionResource,
293-
chatInteractionId: context.chatInteractionId
293+
chatInteractionId: context.chatInteractionId,
294+
forceConfirmationReason: context.forceConfirmationReason
294295
};
296+
if (context.forceConfirmationReason) {
297+
checkProposedApiEnabled(item.extension, 'chatParticipantPrivate');
298+
}
295299
if (item.tool.prepareInvocation) {
296300
const result = await item.tool.prepareInvocation(options, token);
297301
if (!result) {
@@ -309,7 +313,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape
309313
} : undefined,
310314
invocationMessage: typeConvert.MarkdownString.fromStrict(result.invocationMessage),
311315
pastTenseMessage: typeConvert.MarkdownString.fromStrict(result.pastTenseMessage),
312-
presentation: result.presentation as ToolInvocationPresentation | undefined
316+
presentation: result.presentation as ToolInvocationPresentation | undefined,
313317
};
314318
}
315319

src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js';
3434
import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';
3535
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
3636
import { IExtensionService } from '../../../../services/extensions/common/extensions.js';
37-
import { IPreToolUseCallerInput } from '../../common/hooks/hooksTypes.js';
37+
import { IPreToolUseCallerInput, IPreToolUseHookResult } from '../../common/hooks/hooksTypes.js';
3838
import { IHooksExecutionService } from '../../common/hooks/hooksExecutionService.js';
3939
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
4040
import { ChatRequestToolReferenceEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js';
@@ -363,19 +363,21 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo
363363

364364
/**
365365
* Execute the preToolUse hook and handle denial.
366-
* Returns a tool result if the hook denied execution, or undefined to continue.
366+
* Returns an object containing:
367+
* - denialResult: A tool result if the hook denied execution (caller should return early)
368+
* - hookResult: The full hook result for use in auto-approval logic (allow/ask decisions)
367369
* @param pendingInvocation If there's an existing streaming invocation from beginToolCall, pass it here to cancel it instead of creating a new one.
368370
*/
369-
private async _executePreToolUseHookAndHandleDenial(
371+
private async _executePreToolUseHook(
370372
dto: IToolInvocation,
371373
toolData: IToolData | undefined,
372374
request: IChatRequestModel | undefined,
373375
pendingInvocation: ChatToolInvocation | undefined,
374376
token: CancellationToken
375-
): Promise<IToolResult | undefined> {
377+
): Promise<{ denialResult?: IToolResult; hookResult?: IPreToolUseHookResult }> {
376378
// Skip hook if no session context or tool doesn't exist
377379
if (!dto.context?.sessionResource || !toolData) {
378-
return undefined;
380+
return {};
379381
}
380382

381383
const hookInput: IPreToolUseCallerInput = {
@@ -409,12 +411,15 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo
409411

410412
const denialMessage = localize('toolExecutionDenied', "Tool execution denied: {0}", hookReason);
411413
return {
412-
content: [{ kind: 'text', value: denialMessage }],
413-
toolResultError: hookReason,
414+
denialResult: {
415+
content: [{ kind: 'text', value: denialMessage }],
416+
toolResultError: hookReason,
417+
},
418+
hookResult,
414419
};
415420
}
416421

417-
return undefined;
422+
return { hookResult };
418423
}
419424

420425
async invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise<IToolResult> {
@@ -465,7 +470,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo
465470
}
466471

467472
// Execute preToolUse hook - returns early if hook denies execution
468-
const hookDenialResult = await this._executePreToolUseHookAndHandleDenial(dto, toolData, request, toolInvocation, token);
473+
const { denialResult: hookDenialResult, hookResult: preToolUseHookResult } = await this._executePreToolUseHook(dto, toolData, request, toolInvocation, token);
469474
if (hookDenialResult) {
470475
// Clean up pending tool call if it exists
471476
if (pendingToolCallKey) {
@@ -522,10 +527,11 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo
522527
dto.userSelectedTools = request.userSelectedTools && { ...request.userSelectedTools };
523528

524529
prepareTimeWatch = StopWatch.create(true);
525-
preparedInvocation = await this.prepareToolInvocation(tool, dto, token);
530+
preparedInvocation = await this.prepareToolInvocationWithHookResult(tool, dto, preToolUseHookResult, token);
526531
prepareTimeWatch.stop();
527532

528-
const autoConfirmed = await this.shouldAutoConfirm(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, dto.context?.sessionResource);
533+
const { autoConfirmed, preparedInvocation: updatedPreparedInvocation } = await this.resolveAutoConfirmFromHook(preToolUseHookResult, tool, dto, preparedInvocation, dto.context?.sessionResource);
534+
preparedInvocation = updatedPreparedInvocation;
529535

530536

531537
// Important: a tool invocation that will be autoconfirmed should never
@@ -570,9 +576,12 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo
570576
}
571577
} else {
572578
prepareTimeWatch = StopWatch.create(true);
573-
preparedInvocation = await this.prepareToolInvocation(tool, dto, token);
579+
preparedInvocation = await this.prepareToolInvocationWithHookResult(tool, dto, preToolUseHookResult, token);
574580
prepareTimeWatch.stop();
575-
if (preparedInvocation?.confirmationMessages?.title && !(await this.shouldAutoConfirm(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, undefined))) {
581+
582+
const { autoConfirmed: fallbackAutoConfirmed, preparedInvocation: updatedPreparedInvocation } = await this.resolveAutoConfirmFromHook(preToolUseHookResult, tool, dto, preparedInvocation, undefined);
583+
preparedInvocation = updatedPreparedInvocation;
584+
if (preparedInvocation?.confirmationMessages?.title && !fallbackAutoConfirmed) {
576585
const result = await this._dialogService.confirm({ message: renderAsPlaintext(preparedInvocation.confirmationMessages.title), detail: renderAsPlaintext(preparedInvocation.confirmationMessages.message!) });
577586
if (!result.confirmed) {
578587
throw new CancellationError();
@@ -656,15 +665,77 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo
656665
}
657666
}
658667

659-
private async prepareToolInvocation(tool: IToolEntry, dto: IToolInvocation, token: CancellationToken): Promise<IPreparedToolInvocation | undefined> {
668+
private async prepareToolInvocationWithHookResult(tool: IToolEntry, dto: IToolInvocation, hookResult: IPreToolUseHookResult | undefined, token: CancellationToken): Promise<IPreparedToolInvocation | undefined> {
669+
let forceConfirmationReason: string | undefined;
670+
if (hookResult?.permissionDecision === 'ask') {
671+
const hookMessage = localize('preToolUseHookRequiredConfirmation', "{0} required confirmation", HookType.PreToolUse);
672+
forceConfirmationReason = hookResult.permissionDecisionReason
673+
? `${hookMessage}: ${hookResult.permissionDecisionReason}`
674+
: hookMessage;
675+
}
676+
return this.prepareToolInvocation(tool, dto, forceConfirmationReason, token);
677+
}
678+
679+
/**
680+
* Determines the auto-confirm decision based on a preToolUse hook result.
681+
* If the hook returned 'allow', auto-approves. If 'ask', forces confirmation
682+
* and ensures confirmation messages exist on `preparedInvocation`. Otherwise
683+
* falls back to normal auto-confirm logic.
684+
*
685+
* Returns the possibly-updated preparedInvocation along with the auto-confirm decision,
686+
* since when the hook returns 'ask' and preparedInvocation was undefined, we create one.
687+
*/
688+
private async resolveAutoConfirmFromHook(
689+
hookResult: IPreToolUseHookResult | undefined,
690+
tool: IToolEntry,
691+
dto: IToolInvocation,
692+
preparedInvocation: IPreparedToolInvocation | undefined,
693+
sessionResource: URI | undefined,
694+
): Promise<{ autoConfirmed: ConfirmedReason | undefined; preparedInvocation: IPreparedToolInvocation | undefined }> {
695+
if (hookResult?.permissionDecision === 'allow') {
696+
this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} auto-approved by preToolUse hook`);
697+
return { autoConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded, reason: localize('hookAllowed', "Allowed by hook") }, preparedInvocation };
698+
}
699+
700+
if (hookResult?.permissionDecision === 'ask') {
701+
this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} requires confirmation (preToolUse hook returned 'ask')`);
702+
// Ensure confirmation messages exist when hook requires confirmation
703+
if (!preparedInvocation?.confirmationMessages?.title) {
704+
if (!preparedInvocation) {
705+
preparedInvocation = {};
706+
}
707+
const fullReferenceName = getToolFullReferenceName(tool.data);
708+
const hookReason = hookResult.permissionDecisionReason;
709+
const baseMessage = localize('hookRequiresConfirmation.message', "{0} hook confirmation required", HookType.PreToolUse);
710+
preparedInvocation.confirmationMessages = {
711+
...preparedInvocation.confirmationMessages,
712+
title: localize('hookRequiresConfirmation.title', "Use the '{0}' tool?", fullReferenceName),
713+
message: new MarkdownString(hookReason ? `${baseMessage}\n\n${hookReason}` : baseMessage),
714+
allowAutoConfirm: false,
715+
};
716+
preparedInvocation.toolSpecificData = {
717+
kind: 'input',
718+
rawInput: dto.parameters,
719+
};
720+
}
721+
return { autoConfirmed: undefined, preparedInvocation };
722+
}
723+
724+
// No hook decision - use normal auto-confirm logic
725+
const autoConfirmed = await this.shouldAutoConfirm(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, sessionResource);
726+
return { autoConfirmed, preparedInvocation };
727+
}
728+
729+
private async prepareToolInvocation(tool: IToolEntry, dto: IToolInvocation, forceConfirmationReason: string | undefined, token: CancellationToken): Promise<IPreparedToolInvocation | undefined> {
660730
let prepared: IPreparedToolInvocation | undefined;
661731
if (tool.impl!.prepareToolInvocation) {
662732
const preparePromise = tool.impl!.prepareToolInvocation({
663733
parameters: dto.parameters,
664734
chatRequestId: dto.chatRequestId,
665735
chatSessionId: dto.context?.sessionId,
666736
chatSessionResource: dto.context?.sessionResource,
667-
chatInteractionId: dto.chatInteractionId
737+
chatInteractionId: dto.chatInteractionId,
738+
forceConfirmationReason: forceConfirmationReason
668739
}, token);
669740

670741
const raceResult = await Promise.race([

src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,28 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';
7-
import { URI } from '../../../../../base/common/uri.js';
8-
import { HookType, HookTypeValue, IChatRequestHooks, IHookCommand } from '../promptSyntax/hookSchema.js';
9-
import { IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
10-
import { ILogService } from '../../../../../platform/log/common/log.js';
116
import { CancellationToken } from '../../../../../base/common/cancellation.js';
7+
import { IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
128
import { StopWatch } from '../../../../../base/common/stopwatch.js';
13-
import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../../services/output/common/output.js';
14-
import { Registry } from '../../../../../platform/registry/common/platform.js';
9+
import { URI } from '../../../../../base/common/uri.js';
1510
import { localize } from '../../../../../nls.js';
11+
import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';
12+
import { ILogService } from '../../../../../platform/log/common/log.js';
13+
import { Registry } from '../../../../../platform/registry/common/platform.js';
14+
import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../../services/output/common/output.js';
15+
import { HookType, HookTypeValue, IChatRequestHooks, IHookCommand } from '../promptSyntax/hookSchema.js';
1616
import {
1717
HookCommandResultKind,
1818
IHookCommandInput,
1919
IHookCommandResult,
20-
IPreToolUseCommandInput,
20+
IPreToolUseCommandInput
2121
} from './hooksCommandTypes.js';
2222
import {
2323
commonHookOutputValidator,
2424
IHookResult,
2525
IPreToolUseCallerInput,
2626
IPreToolUseHookResult,
27-
preToolUseOutputValidator,
27+
preToolUseOutputValidator
2828
} from './hooksTypes.js';
2929

3030
export const hooksOutputChannelId = 'hooksExecution';
@@ -296,7 +296,8 @@ export class HooksExecutionService implements IHooksExecutionService {
296296
token: token ?? CancellationToken.None,
297297
});
298298

299-
// Collect all valid outputs - "any deny wins" for security
299+
// Collect all valid outputs - priority order: deny > ask > allow
300+
let lastAskResult: IPreToolUseHookResult | undefined;
300301
let lastAllowResult: IPreToolUseHookResult | undefined;
301302
for (const result of results) {
302303
if (result.success && typeof result.output === 'object' && result.output !== null) {
@@ -305,6 +306,12 @@ export class HooksExecutionService implements IHooksExecutionService {
305306
// Extract from hookSpecificOutput wrapper
306307
const hookSpecificOutput = validationResult.content.hookSpecificOutput;
307308
if (hookSpecificOutput) {
309+
// Validate hookEventName if present - must match the hook type
310+
if (hookSpecificOutput.hookEventName !== undefined && hookSpecificOutput.hookEventName !== HookType.PreToolUse) {
311+
this._logService.warn(`[HooksExecutionService] preToolUse hook returned invalid hookEventName '${hookSpecificOutput.hookEventName}', expected '${HookType.PreToolUse}'`);
312+
continue;
313+
}
314+
308315
const preToolUseResult: IPreToolUseHookResult = {
309316
...result,
310317
permissionDecision: hookSpecificOutput.permissionDecision,
@@ -316,6 +323,10 @@ export class HooksExecutionService implements IHooksExecutionService {
316323
if (hookSpecificOutput.permissionDecision === 'deny') {
317324
return preToolUseResult;
318325
}
326+
// Track 'ask' results (ask takes priority over allow)
327+
if (hookSpecificOutput.permissionDecision === 'ask') {
328+
lastAskResult = preToolUseResult;
329+
}
319330
// Track the last allow in case we need to return it
320331
if (hookSpecificOutput.permissionDecision === 'allow') {
321332
lastAllowResult = preToolUseResult;
@@ -328,7 +339,7 @@ export class HooksExecutionService implements IHooksExecutionService {
328339
}
329340
}
330341

331-
// Return the last allow result, or undefined if no valid outputs
332-
return lastAllowResult;
342+
// Return with priority: ask > allow > undefined
343+
return lastAskResult ?? lastAllowResult;
333344
}
334345
}

src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,19 @@ export interface IPreToolUseCallerInput {
6767
export const preToolUseOutputValidator = vObj({
6868
hookSpecificOutput: vOptionalProp(vObj({
6969
hookEventName: vOptionalProp(vString()),
70-
permissionDecision: vEnum('allow', 'deny'),
70+
permissionDecision: vEnum('allow', 'deny', 'ask'),
7171
permissionDecisionReason: vOptionalProp(vString()),
7272
additionalContext: vOptionalProp(vString()),
7373
})),
7474
});
7575

7676
/**
7777
* Valid permission decisions for preToolUse hooks.
78+
* - 'allow': Auto-approve the tool execution (skip user confirmation)
79+
* - 'deny': Deny the tool execution
80+
* - 'ask': Always require user confirmation (never auto-approve)
7881
*/
79-
export type PreToolUsePermissionDecision = 'allow' | 'deny';
82+
export type PreToolUsePermissionDecision = 'allow' | 'deny' | 'ask';
8083

8184
/**
8285
* Result from preToolUse hooks with permission decision fields.

src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ export interface IToolInvocationPreparationContext {
203203
chatSessionId?: string;
204204
chatSessionResource: URI | undefined;
205205
chatInteractionId?: string;
206+
/** If set, tells the tool that it should include confirmmation messages. */
207+
forceConfirmationReason?: string;
206208
}
207209

208210
export type ToolInputOutputBase = {

0 commit comments

Comments
 (0)