Path: blob/main/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts
5241 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CancellationToken } from '../../../../../base/common/cancellation.js';6import { Emitter, Event } from '../../../../../base/common/event.js';7import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';8import { StopWatch } from '../../../../../base/common/stopwatch.js';9import { URI, isUriComponents } from '../../../../../base/common/uri.js';10import { localize } from '../../../../../nls.js';11import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';12import { ILogService } from '../../../../../platform/log/common/log.js';13import { Registry } from '../../../../../platform/registry/common/platform.js';14import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../../services/output/common/output.js';15import { HookType, HookTypeValue, IChatRequestHooks, IHookCommand } from '../promptSyntax/hookSchema.js';16import {17HookCommandResultKind,18IHookCommandInput,19IHookCommandResult,20IPostToolUseCommandInput,21IPreToolUseCommandInput22} from './hooksCommandTypes.js';23import {24commonHookOutputValidator,25IHookResult,26IPostToolUseCallerInput,27IPostToolUseHookResult,28IPreToolUseCallerInput,29IPreToolUseHookResult,30postToolUseOutputValidator,31PreToolUsePermissionDecision,32preToolUseOutputValidator33} from './hooksTypes.js';3435export const hooksOutputChannelId = 'hooksExecution';36const hooksOutputChannelLabel = localize('hooksExecutionChannel', "Hooks");3738export interface IHooksExecutionOptions {39readonly input?: unknown;40readonly token?: CancellationToken;41}4243export interface IHookExecutedEvent {44readonly hookType: HookTypeValue;45readonly sessionResource: URI;46readonly input: unknown;47readonly results: readonly IHookResult[];48readonly durationMs: number;49}5051/**52* Callback interface for hook execution proxies.53* MainThreadHooks implements this to forward calls to the extension host.54*/55export interface IHooksExecutionProxy {56runHookCommand(hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise<IHookCommandResult>;57}5859export const IHooksExecutionService = createDecorator<IHooksExecutionService>('hooksExecutionService');6061export interface IHooksExecutionService {62_serviceBrand: undefined;6364/**65* Fires when a hook has finished executing.66*/67readonly onDidExecuteHook: Event<IHookExecutedEvent>;6869/**70* Called by mainThreadHooks when extension host is ready71*/72setProxy(proxy: IHooksExecutionProxy): void;7374/**75* Register hooks for a session. Returns a disposable that unregisters them.76*/77registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable;7879/**80* Get hooks registered for a session.81*/82getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined;8384/**85* Execute hooks of the given type for the given session86*/87executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise<IHookResult[]>;8889/**90* Execute preToolUse hooks with typed input and validated output.91* The execution service builds the full hook input from the caller input plus session context.92* Returns a combined result with common fields and permission decision.93*/94executePreToolUseHook(sessionResource: URI, input: IPreToolUseCallerInput, token?: CancellationToken): Promise<IPreToolUseHookResult | undefined>;9596/**97* Execute postToolUse hooks with typed input and validated output.98* Called after a tool completes successfully. The execution service builds the full hook input99* from the caller input plus session context.100* Returns a combined result with decision and additional context.101*/102executePostToolUseHook(sessionResource: URI, input: IPostToolUseCallerInput, token?: CancellationToken): Promise<IPostToolUseHookResult | undefined>;103}104105/**106* Keys that should be redacted when logging hook input.107*/108const redactedInputKeys = ['toolArgs'];109110export class HooksExecutionService extends Disposable implements IHooksExecutionService {111declare readonly _serviceBrand: undefined;112113private readonly _onDidExecuteHook = this._register(new Emitter<IHookExecutedEvent>());114readonly onDidExecuteHook: Event<IHookExecutedEvent> = this._onDidExecuteHook.event;115116private _proxy: IHooksExecutionProxy | undefined;117private readonly _sessionHooks = new Map<string, IChatRequestHooks>();118/** Stored transcript path per session (keyed by session URI string). */119private readonly _sessionTranscriptPaths = new Map<string, URI>();120private _channelRegistered = false;121private _requestCounter = 0;122123constructor(124@ILogService private readonly _logService: ILogService,125@IOutputService private readonly _outputService: IOutputService,126) {127super();128}129130setProxy(proxy: IHooksExecutionProxy): void {131this._proxy = proxy;132}133134private _ensureOutputChannel(): void {135if (this._channelRegistered) {136return;137}138Registry.as<IOutputChannelRegistry>(Extensions.OutputChannels).registerChannel({139id: hooksOutputChannelId,140label: hooksOutputChannelLabel,141log: false142});143this._channelRegistered = true;144}145146private _log(requestId: number, hookType: HookTypeValue, message: string): void {147this._ensureOutputChannel();148const channel = this._outputService.getChannel(hooksOutputChannelId);149if (channel) {150channel.append(`${new Date().toISOString()} [#${requestId}] [${hookType}] ${message}\n`);151}152}153154private _redactForLogging(input: object): object {155const result: Record<string, unknown> = { ...input };156for (const key of redactedInputKeys) {157if (Object.hasOwn(result, key)) {158result[key] = '...';159}160}161return result;162}163164/**165* JSON.stringify replacer that converts URI / UriComponents values to their string form.166*/167private readonly _uriReplacer = (_key: string, value: unknown): unknown => {168if (URI.isUri(value)) {169return value.fsPath;170}171if (isUriComponents(value)) {172return URI.revive(value).fsPath;173}174return value;175};176177private async _runSingleHook(178requestId: number,179hookType: HookTypeValue,180hookCommand: IHookCommand,181sessionResource: URI,182callerInput: unknown,183transcriptPath: URI | undefined,184token: CancellationToken185): Promise<IHookResult> {186// Build the common hook input properties.187// URI values are kept as URI objects through the RPC boundary, and converted188// to filesystem paths on the extension host side during JSON serialization.189const commonInput: IHookCommandInput = {190timestamp: new Date().toISOString(),191cwd: hookCommand.cwd ?? URI.file(''),192sessionId: sessionResource.toString(),193hookEventName: hookType,194...(transcriptPath ? { transcript_path: transcriptPath } : undefined),195};196197// Merge common properties with caller-specific input198const fullInput = !!callerInput && typeof callerInput === 'object'199? { ...commonInput, ...callerInput }200: commonInput;201202const hookCommandJson = JSON.stringify(hookCommand, this._uriReplacer);203this._log(requestId, hookType, `Running: ${hookCommandJson}`);204const inputForLog = this._redactForLogging(fullInput);205this._log(requestId, hookType, `Input: ${JSON.stringify(inputForLog, this._uriReplacer)}`);206207const sw = StopWatch.create();208try {209const commandResult = await this._proxy!.runHookCommand(hookCommand, fullInput, token);210const result = this._toInternalResult(commandResult);211this._logCommandResult(requestId, hookType, commandResult, Math.round(sw.elapsed()));212return result;213} catch (err) {214const errMessage = err instanceof Error ? err.message : String(err);215this._log(requestId, hookType, `Error in ${Math.round(sw.elapsed())}ms: ${errMessage}`);216return this._createErrorResult(errMessage);217}218}219220private _createErrorResult(errorMessage: string): IHookResult {221return {222resultKind: 'error',223output: errorMessage,224};225}226227private _toInternalResult(commandResult: IHookCommandResult): IHookResult {228switch (commandResult.kind) {229case HookCommandResultKind.Error: {230// Blocking error - shown to model231return this._createErrorResult(232typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result)233);234}235case HookCommandResultKind.NonBlockingError: {236// Non-blocking error - shown to user only as warning237const errorMessage = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);238return {239resultKind: 'warning',240output: undefined,241warningMessage: errorMessage,242};243}244case HookCommandResultKind.Success: {245// For string results, no common fields to extract246if (typeof commandResult.result !== 'object') {247return {248resultKind: 'success',249output: commandResult.result,250};251}252253// Extract and validate common fields254const validationResult = commonHookOutputValidator.validate(commandResult.result);255const commonFields = validationResult.error ? {} : validationResult.content;256257// Extract only known hook-specific fields for output258const resultObj = commandResult.result as Record<string, unknown>;259const hookOutput = this._extractHookSpecificOutput(resultObj);260261return {262resultKind: 'success',263stopReason: commonFields.stopReason,264warningMessage: commonFields.systemMessage,265output: Object.keys(hookOutput).length > 0 ? hookOutput : undefined,266};267}268default: {269// Unexpected result kind - treat as blocking error270return this._createErrorResult(`Unexpected hook command result kind: ${commandResult.kind}`);271}272}273}274275/**276* Extract hook-specific output fields, excluding common fields.277*/278private _extractHookSpecificOutput(result: Record<string, unknown>): Record<string, unknown> {279const commonFields = new Set(['stopReason', 'systemMessage']);280const output: Record<string, unknown> = {};281for (const [key, value] of Object.entries(result)) {282if (value !== undefined && !commonFields.has(key)) {283output[key] = value;284}285}286287return output;288}289290private _logCommandResult(requestId: number, hookType: HookTypeValue, result: IHookCommandResult, elapsed: number): void {291const resultKindStr = result.kind === HookCommandResultKind.Success ? 'Success'292: result.kind === HookCommandResultKind.NonBlockingError ? 'NonBlockingError'293: 'Error';294const resultStr = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);295const hasOutput = resultStr.length > 0 && resultStr !== '{}' && resultStr !== '[]';296if (hasOutput) {297this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms`);298this._log(requestId, hookType, `Output: ${resultStr}`);299} else {300this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms, no output`);301}302}303304/**305* Extract `transcript_path` from hook input if present.306* The caller (e.g. SessionStart) may include it as a URI in the input object.307*/308private _extractTranscriptPath(input: unknown): URI | undefined {309if (typeof input !== 'object' || input === null) {310return undefined;311}312const transcriptPath = (input as Record<string, unknown>)['transcriptPath'];313if (URI.isUri(transcriptPath)) {314return transcriptPath;315}316if (isUriComponents(transcriptPath)) {317return URI.revive(transcriptPath);318}319return undefined;320}321322registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable {323const key = sessionResource.toString();324this._sessionHooks.set(key, hooks);325return toDisposable(() => {326this._sessionHooks.delete(key);327this._sessionTranscriptPaths.delete(key);328});329}330331getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined {332return this._sessionHooks.get(sessionResource.toString());333}334335async executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise<IHookResult[]> {336const sw = StopWatch.create();337const results: IHookResult[] = [];338339try {340if (!this._proxy) {341return results;342}343344const sessionKey = sessionResource.toString();345346// Extract and store transcript_path from input when present (e.g. SessionStart)347const inputTranscriptPath = this._extractTranscriptPath(options?.input);348if (inputTranscriptPath) {349this._sessionTranscriptPaths.set(sessionKey, inputTranscriptPath);350}351352const hooks = this.getHooksForSession(sessionResource);353if (!hooks) {354return results;355}356357const hookCommands = hooks[hookType];358if (!hookCommands || hookCommands.length === 0) {359return results;360}361362const transcriptPath = this._sessionTranscriptPaths.get(sessionKey);363364const requestId = this._requestCounter++;365const token = options?.token ?? CancellationToken.None;366367this._logService.debug(`[HooksExecutionService] Executing ${hookCommands.length} hook(s) for type '${hookType}'`);368this._log(requestId, hookType, `Executing ${hookCommands.length} hook(s)`);369370for (const hookCommand of hookCommands) {371const result = await this._runSingleHook(requestId, hookType, hookCommand, sessionResource, options?.input, transcriptPath, token);372results.push(result);373374// If stopReason is set, stop processing remaining hooks375if (result.stopReason) {376this._log(requestId, hookType, `Stopping: ${result.stopReason}`);377break;378}379}380381return results;382} finally {383this._onDidExecuteHook.fire({384hookType,385sessionResource,386input: options?.input,387results,388durationMs: Math.round(sw.elapsed()),389});390}391}392393async executePreToolUseHook(sessionResource: URI, input: IPreToolUseCallerInput, token?: CancellationToken): Promise<IPreToolUseHookResult | undefined> {394const toolSpecificInput: IPreToolUseCommandInput = {395tool_name: input.toolName,396tool_input: input.toolInput,397tool_use_id: input.toolCallId,398};399400const results = await this.executeHook(HookType.PreToolUse, sessionResource, {401input: toolSpecificInput,402token: token ?? CancellationToken.None,403});404405// Run all hooks and collapse results. Most restrictive decision wins: deny > ask > allow.406// Collect all additionalContext strings from every hook.407const allAdditionalContext: string[] = [];408let mostRestrictiveDecision: PreToolUsePermissionDecision | undefined;409let winningResult: IHookResult | undefined;410let winningReason: string | undefined;411let lastUpdatedInput: object | undefined;412413for (const result of results) {414if (result.resultKind === 'success' && typeof result.output === 'object' && result.output !== null) {415const validationResult = preToolUseOutputValidator.validate(result.output);416if (!validationResult.error) {417const hookSpecificOutput = validationResult.content.hookSpecificOutput;418if (hookSpecificOutput) {419// Validate hookEventName if present - must match the hook type420if (hookSpecificOutput.hookEventName !== undefined && hookSpecificOutput.hookEventName !== HookType.PreToolUse) {421this._logService.warn(`[HooksExecutionService] preToolUse hook returned invalid hookEventName '${hookSpecificOutput.hookEventName}', expected '${HookType.PreToolUse}'`);422continue;423}424425// Collect additionalContext from every hook426if (hookSpecificOutput.additionalContext) {427allAdditionalContext.push(hookSpecificOutput.additionalContext);428}429430// Track the last updatedInput (later hooks override earlier ones)431if (hookSpecificOutput.updatedInput) {432lastUpdatedInput = hookSpecificOutput.updatedInput;433}434435// Track the most restrictive decision: deny > ask > allow436const decision = hookSpecificOutput.permissionDecision;437if (decision && this._isMoreRestrictive(decision, mostRestrictiveDecision)) {438mostRestrictiveDecision = decision;439winningResult = result;440winningReason = hookSpecificOutput.permissionDecisionReason;441}442}443} else {444this._logService.warn(`[HooksExecutionService] preToolUse hook output validation failed: ${validationResult.error.message}`);445}446}447}448449if (!mostRestrictiveDecision && !lastUpdatedInput && allAdditionalContext.length === 0) {450return undefined;451}452453const baseResult = winningResult ?? results[0];454return {455...baseResult,456permissionDecision: mostRestrictiveDecision,457permissionDecisionReason: winningReason,458updatedInput: lastUpdatedInput,459additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined,460};461}462463/**464* Returns true if `candidate` is more restrictive than `current`.465* Restriction order: deny > ask > allow.466*/467private _isMoreRestrictive(candidate: PreToolUsePermissionDecision, current: PreToolUsePermissionDecision | undefined): boolean {468const order: Record<PreToolUsePermissionDecision, number> = { 'deny': 2, 'ask': 1, 'allow': 0 };469return current === undefined || order[candidate] > order[current];470}471472async executePostToolUseHook(sessionResource: URI, input: IPostToolUseCallerInput, token?: CancellationToken): Promise<IPostToolUseHookResult | undefined> {473// Check if there are PostToolUse hooks registered before doing any work stringifying tool results474const hooks = this.getHooksForSession(sessionResource);475const hookCommands = hooks?.[HookType.PostToolUse];476if (!hookCommands || hookCommands.length === 0) {477return undefined;478}479480// Lazily render tool response text only when hooks are registered481const toolResponseText = input.getToolResponseText();482483const toolSpecificInput: IPostToolUseCommandInput = {484tool_name: input.toolName,485tool_input: input.toolInput,486tool_response: toolResponseText,487tool_use_id: input.toolCallId,488};489490const results = await this.executeHook(HookType.PostToolUse, sessionResource, {491input: toolSpecificInput,492token: token ?? CancellationToken.None,493});494495// Run all hooks and collapse results. Block is the most restrictive decision.496// Collect all additionalContext strings from every hook.497const allAdditionalContext: string[] = [];498let hasBlock = false;499let blockReason: string | undefined;500let blockResult: IHookResult | undefined;501502for (const result of results) {503if (result.resultKind === 'success' && typeof result.output === 'object' && result.output !== null) {504const validationResult = postToolUseOutputValidator.validate(result.output);505if (!validationResult.error) {506const validated = validationResult.content;507508// Validate hookEventName if present509if (validated.hookSpecificOutput?.hookEventName !== undefined && validated.hookSpecificOutput.hookEventName !== HookType.PostToolUse) {510this._logService.warn(`[HooksExecutionService] postToolUse hook returned invalid hookEventName '${validated.hookSpecificOutput.hookEventName}', expected '${HookType.PostToolUse}'`);511continue;512}513514// Collect additionalContext from every hook515if (validated.hookSpecificOutput?.additionalContext) {516allAdditionalContext.push(validated.hookSpecificOutput.additionalContext);517}518519// Track the first block decision (most restrictive)520if (validated.decision === 'block' && !hasBlock) {521hasBlock = true;522blockReason = validated.reason;523blockResult = result;524}525} else {526this._logService.warn(`[HooksExecutionService] postToolUse hook output validation failed: ${validationResult.error.message}`);527}528}529}530531// Return combined result if there's a block decision or any additional context532if (!hasBlock && allAdditionalContext.length === 0) {533return undefined;534}535536const baseResult = blockResult ?? results[0];537return {538...baseResult,539decision: hasBlock ? 'block' : undefined,540reason: blockReason,541additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined,542};543}544}545546547