Path: blob/main/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts
13399 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 * as l10n from '@vscode/l10n';6import type * as vscode from 'vscode';7import { IChatHookService, IPostToolUseHookResult, IPreToolUseHookResult } from '../../../platform/chat/common/chatHookService';8import { IPostToolUseHookCommandInput, IPostToolUseHookSpecificCommandOutput, IPreToolUseHookCommandInput, IPreToolUseHookSpecificCommandOutput } from '../../../platform/chat/common/hookCommandTypes';9import { HookCommandResultKind, IHookCommandResult, IHookExecutor } from '../../../platform/chat/common/hookExecutor';10import { IHooksOutputChannel } from '../../../platform/chat/common/hooksOutputChannel';11import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService';12import { ILogService } from '../../../platform/log/common/logService';13import { CopilotChatAttr, GenAiAttr, GenAiOperationName, IOTelService, SpanKind, SpanStatusCode, truncateForOTel } from '../../../platform/otel/common/index';14import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';15import { raceTimeout } from '../../../util/vs/base/common/async';16import { CancellationToken } from '../../../util/vs/base/common/cancellation';17import { StopWatch } from '../../../util/vs/base/common/stopwatch';18import { formatHookErrorMessage, processHookResults } from '../../intents/node/hookResultProcessor';19import { IToolsService, isToolValidationError } from '../../tools/common/toolsService';20import { ChatHookTelemetry } from './chatHookTelemetry';2122const permissionPriority: Record<string, number> = { 'deny': 2, 'ask': 1, 'allow': 0 };2324/**25* One-way compatible hook event name mappings. When a hook written for one event26* type is reused under a different type (e.g. a Stop hook scoped to a custom27* agent runs as SubagentStop), the hookEventName in the output won't match.28*29* The map key is the hookEventName from the output; the value is the hook type30* it should also be accepted for. The mapping is intentionally one-way:31* a Stop hook is accepted when running as SubagentStop, but a SubagentStop32* hook's output is NOT accepted when running as a top-level Stop.33*/34const compatibleHookEventNames: ReadonlyMap<vscode.ChatHookType, vscode.ChatHookType> = new Map([35['Stop', 'SubagentStop'],36['SessionStart', 'SubagentStart'],37]);3839export function isCompatibleHookEventName(hookEventName: string, hookType: string): boolean {40return hookEventName === hookType || compatibleHookEventNames.get(hookEventName as vscode.ChatHookType) === hookType;41}4243/**44* Keys that should be redacted when logging hook input.45*/46const redactedInputKeys = ['toolArgs', 'tool_input'];4748export class ChatHookService implements IChatHookService {49declare readonly _serviceBrand: undefined;5051private _requestCounter = 0;52private readonly _telemetry: ChatHookTelemetry;5354constructor(55@ISessionTranscriptService private readonly _sessionTranscriptService: ISessionTranscriptService,56@ILogService private readonly _logService: ILogService,57@IHookExecutor private readonly _hookExecutor: IHookExecutor,58@IHooksOutputChannel private readonly _outputChannel: IHooksOutputChannel,59@ITelemetryService telemetryService: ITelemetryService,60@IToolsService private readonly _toolsService: IToolsService,61@IOTelService private readonly _otelService: IOTelService,62) {63this._telemetry = new ChatHookTelemetry(telemetryService);64}6566private _log(requestId: number, hookType: string, message: string): void {67this._outputChannel.appendLine(`[#${requestId}] [${hookType}] ${message}`);68}6970private _redactForLogging(input: Record<string, unknown>): Record<string, unknown> {71const result = { ...input };72for (const key of redactedInputKeys) {73if (Object.hasOwn(result, key)) {74result[key] = '...';75}76}77return result;78}7980private _logCommandResult(requestId: number, hookType: string, commandResult: IHookCommandResult, elapsed: number): void {81const elapsedRounded = Math.round(elapsed);82const resultKindStr = commandResult.kind === HookCommandResultKind.Success ? 'Success'83: commandResult.kind === HookCommandResultKind.NonBlockingError ? 'NonBlockingError'84: 'Error';85const resultStr = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);86const hasOutput = resultStr.length > 0 && resultStr !== '{}' && resultStr !== '[]';87if (hasOutput) {88this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsedRounded}ms`);89this._log(requestId, hookType, `Output: ${resultStr}`);90} else {91this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsedRounded}ms, no output`);92}93}9495logConfiguredHooks(hooks: vscode.ChatRequestHooks | undefined): void {96if (hooks) {97this._telemetry.logConfiguredHooks(hooks);98}99}100101async executeHook(hookType: vscode.ChatHookType, hooks: vscode.ChatRequestHooks | undefined, input: unknown, sessionId?: string, token?: vscode.CancellationToken): Promise<vscode.ChatHookResult[]> {102if (!hooks) {103return [];104}105106const hookCommands = hooks[hookType];107if (!hookCommands || hookCommands.length === 0) {108return [];109}110111const hookCount = hookCommands.length;112const overallStopWatch = StopWatch.create();113let hasError = false;114let hasCaughtException = false;115116try {117// Flush transcript before running hooks so scripts see up-to-date content118let transcriptPath: vscode.Uri | undefined;119if (sessionId) {120await raceTimeout(this._sessionTranscriptService.flush(sessionId), 500);121transcriptPath = this._sessionTranscriptService.getTranscriptPath(sessionId);122}123124// Build common input properties merged with caller-specific input125const commonInput = {126timestamp: new Date().toISOString(),127hook_event_name: hookType,128...(sessionId ? { session_id: sessionId } : undefined),129...(transcriptPath ? { transcript_path: transcriptPath.fsPath } : undefined),130};131const fullInput = (typeof input === 'object' && input !== null)132? { ...commonInput, ...input }133: commonInput;134135const results: vscode.ChatHookResult[] = [];136const effectiveToken = token ?? CancellationToken.None;137const requestId = this._requestCounter++;138139this._logService.debug(`[ChatHookService] Executing ${hookCommands.length} hook(s) for type '${hookType}'`);140this._log(requestId, hookType, `Executing ${hookCommands.length} hook(s)`);141142const chatSessionId = sessionId;143144for (const hookCommand of hookCommands) {145try {146// Include per-command cwd in the input147const commandInput = hookCommand.cwd148? { ...fullInput, cwd: hookCommand.cwd.fsPath }149: fullInput;150151this._log(requestId, hookType, `Running: ${JSON.stringify(hookCommand)}`);152const inputForLog = this._redactForLogging(commandInput as Record<string, unknown>);153this._log(requestId, hookType, `Input: ${JSON.stringify(inputForLog)}`);154155const span = this._otelService.startSpan(`execute_hook ${hookType}`, {156kind: SpanKind.INTERNAL,157attributes: {158[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_HOOK,159[CopilotChatAttr.HOOK_TYPE]: hookType,160'copilot_chat.hook_command': hookCommand.command,161...(chatSessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: chatSessionId } : {}),162},163});164165try {166// Capture hook input for debug panel resolve167try {168span.setAttribute(CopilotChatAttr.HOOK_INPUT, truncateForOTel(JSON.stringify(commandInput)));169} catch { /* swallow serialization errors */ }170171const sw = StopWatch.create();172const commandResult = await this._hookExecutor.executeCommand(hookCommand, commandInput, effectiveToken);173const elapsed = sw.elapsed();174175this._logCommandResult(requestId, hookType, commandResult, elapsed);176177// Record result on OTel span178const resultKind = commandResult.kind === HookCommandResultKind.Success ? 'success'179: commandResult.kind === HookCommandResultKind.NonBlockingError ? 'non_blocking_error'180: 'error';181span.setAttribute(CopilotChatAttr.HOOK_RESULT_KIND, resultKind);182183if (commandResult.kind === HookCommandResultKind.Error || commandResult.kind === HookCommandResultKind.NonBlockingError) {184hasError = true;185// Record exit code on error186if (commandResult.exitCode !== undefined) {187span.setAttribute('copilot_chat.hook_exit_code', commandResult.exitCode);188}189// Error output goes to span status message (displayed as errorMessage in resolve)190span.setStatus(SpanStatusCode.ERROR, typeof commandResult.result === 'string' ? commandResult.result : undefined);191} else {192span.setStatus(SpanStatusCode.OK);193// Capture hook output for debug panel resolve (success only — errors go to errorMessage)194try {195const output = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);196if (output) {197span.setAttribute(CopilotChatAttr.HOOK_OUTPUT, truncateForOTel(output));198}199} catch { /* swallow serialization errors */ }200}201202const result = this._toHookResult(hookType, commandResult);203results.push(result);204205// If stopReason is set (including empty string for "stop without message"), stop processing remaining hooks206if (result.stopReason !== undefined) {207this._log(requestId, hookType, `Stopping: ${result.stopReason}`);208this._logService.debug(`[ChatHookService] Stopping after hook: ${result.stopReason}`);209break;210}211} catch (spanErr) {212const error = spanErr instanceof Error ? spanErr : new Error(String(spanErr));213span.recordException(error);214span.setStatus(SpanStatusCode.ERROR, error.message);215throw spanErr;216} finally {217span.end();218}219} catch (err) {220hasCaughtException = true;221const errMessage = err instanceof Error ? err.message : String(err);222this._log(requestId, hookType, `Error: ${errMessage}`);223this._logService.error(err instanceof Error ? err : new Error(errMessage), '[ChatHookService] Error running hook command');224results.push({225resultKind: 'warning',226output: undefined,227warningMessage: errMessage,228});229}230}231232return results;233} catch (e) {234hasCaughtException = true;235this._logService.error(`[ChatHookService] Error executing ${hookType} hook`, e);236return [];237} finally {238this._telemetry.logHookExecuted(hookType, hookCount, overallStopWatch.elapsed(), hasError, hasCaughtException);239}240}241242private _toHookResult(hookType: string, commandResult: IHookCommandResult): vscode.ChatHookResult {243switch (commandResult.kind) {244case HookCommandResultKind.Error: {245// Exit code 2 - blocking error246// Callers handle this based on hook type (e.g., deny for PreToolUse, blocking reason for Stop)247const message = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);248return {249resultKind: 'error',250output: message,251};252}253case HookCommandResultKind.NonBlockingError: {254// Non-blocking error - shown to user only as warning255const errorMessage = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);256return {257resultKind: 'warning',258output: undefined,259warningMessage: errorMessage,260};261}262case HookCommandResultKind.Success: {263if (typeof commandResult.result !== 'object') {264return {265resultKind: 'success',266output: commandResult.result,267};268}269270// Extract common fields (continue, stopReason, systemMessage)271const resultObj = commandResult.result as Record<string, unknown>;272const stopReason = typeof resultObj['stopReason'] === 'string' ? resultObj['stopReason'] : undefined;273const continueFlag = resultObj['continue'];274const systemMessage = typeof resultObj['systemMessage'] === 'string' ? resultObj['systemMessage'] : undefined;275276// Handle continue field: when false, stopReason is effective277let effectiveStopReason = stopReason;278if (continueFlag === false && !effectiveStopReason) {279effectiveStopReason = '';280}281282// Check hookEventName at top level — if present and mismatched, skip this result283const topLevelHookEventName = resultObj['hookEventName'];284if (typeof topLevelHookEventName === 'string' && !isCompatibleHookEventName(topLevelHookEventName, hookType)) {285this._logService.trace(`[ChatHookService] Ignoring result with mismatched hookEventName '${topLevelHookEventName}' (expected '${hookType}')`);286return {287resultKind: 'success',288output: undefined,289};290}291292// Check hookEventName inside hookSpecificOutput — if mismatched, strip hookSpecificOutput but keep the rest293let stripHookSpecificOutput = false;294const hookSpecificOutput = resultObj['hookSpecificOutput'];295if (typeof hookSpecificOutput === 'object' && hookSpecificOutput !== null) {296const nestedHookEventName = (hookSpecificOutput as Record<string, unknown>)['hookEventName'];297if (typeof nestedHookEventName === 'string' && !isCompatibleHookEventName(nestedHookEventName, hookType)) {298this._logService.trace(`[ChatHookService] Stripping hookSpecificOutput with mismatched hookEventName '${nestedHookEventName}' (expected '${hookType}')`);299stripHookSpecificOutput = true;300}301}302303// Extract hook-specific output (everything except common fields)304const commonFields = new Set(['continue', 'stopReason', 'systemMessage']);305if (stripHookSpecificOutput) {306commonFields.add('hookSpecificOutput');307}308const hookOutput: Record<string, unknown> = {};309for (const [key, value] of Object.entries(resultObj)) {310if (value !== undefined && !commonFields.has(key)) {311hookOutput[key] = value;312}313}314315return {316resultKind: 'success',317stopReason: effectiveStopReason,318warningMessage: systemMessage,319output: Object.keys(hookOutput).length > 0 ? hookOutput : undefined,320};321}322default:323return {324resultKind: 'warning',325warningMessage: `Unexpected hook command result kind: ${(commandResult as IHookCommandResult).kind}`,326output: undefined,327};328}329}330331async executePreToolUseHook(toolName: string, toolInput: unknown, toolCallId: string, hooks: vscode.ChatRequestHooks | undefined, sessionId?: string, token?: vscode.CancellationToken, outputStream?: vscode.ChatResponseStream): Promise<IPreToolUseHookResult | undefined> {332const hookInput: IPreToolUseHookCommandInput = {333tool_name: toolName,334tool_input: toolInput,335tool_use_id: toolCallId,336};337const results = await this.executeHook(338'PreToolUse',339hooks,340hookInput,341sessionId,342token343);344345if (results.length === 0) {346return undefined;347}348349// Collapse results: deny > ask > allow (most restrictive wins),350// collect all additionalContext, last updatedInput wins351let mostRestrictiveDecision: 'allow' | 'deny' | 'ask' | undefined;352let winningReason: string | undefined;353let lastUpdatedInput: object | undefined;354const allAdditionalContext: string[] = [];355356processHookResults({357hookType: 'PreToolUse',358results,359outputStream,360logService: this._logService,361onSuccess: (output) => {362if (typeof output !== 'object' || output === null) {363return;364}365366const hookOutput = output as { hookSpecificOutput?: IPreToolUseHookSpecificCommandOutput };367const hookSpecificOutput = hookOutput.hookSpecificOutput;368if (!hookSpecificOutput) {369return;370}371372if (hookSpecificOutput.additionalContext) {373allAdditionalContext.push(hookSpecificOutput.additionalContext);374}375376if (hookSpecificOutput.updatedInput) {377lastUpdatedInput = hookSpecificOutput.updatedInput;378}379380const decision = hookSpecificOutput.permissionDecision;381if (decision && !(decision in permissionPriority)) {382const message = `Invalid permissionDecision value '${String(decision)}'. Expected 'allow', 'deny', or 'ask'. Field was ignored.`;383this._logService.warn(`[ChatHookService] ${message}`);384this._outputChannel.appendLine(`[PreToolUse] ${message}`);385} else if (decision && (mostRestrictiveDecision === undefined || (permissionPriority[decision] ?? 0) > (permissionPriority[mostRestrictiveDecision] ?? 0))) {386mostRestrictiveDecision = decision;387winningReason = hookSpecificOutput.permissionDecisionReason;388}389},390// Exit code 2 (error) means deny the tool391onError: (errorMessage) => {392const messageWithTool = errorMessage393? l10n.t('Tried to use {0} - {1}', toolName, errorMessage)394: l10n.t('Tried to use {0} - an unexpected error occurred', toolName);395outputStream?.hookProgress('PreToolUse', formatHookErrorMessage(messageWithTool));396mostRestrictiveDecision = 'deny';397winningReason = messageWithTool || winningReason;398},399});400401// Validate updatedInput against the tool's input schema before returning it402if (lastUpdatedInput) {403const validationResult = this._toolsService.validateToolInput(toolName, JSON.stringify(lastUpdatedInput));404if (isToolValidationError(validationResult)) {405const message = `Discarding updatedInput for tool '${toolName}': schema validation failed: ${validationResult.error}`;406this._logService.warn(`[ChatHookService] ${message}`);407this._outputChannel.appendLine(`[PreToolUse] ${message}`);408lastUpdatedInput = undefined;409}410}411412if (!mostRestrictiveDecision && !lastUpdatedInput && allAdditionalContext.length === 0) {413return undefined;414}415416const hookResult: IPreToolUseHookResult = {417permissionDecision: mostRestrictiveDecision,418permissionDecisionReason: winningReason,419updatedInput: lastUpdatedInput,420additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined,421};422423this._telemetry.logPreToolUseResult(hookResult);424425return hookResult;426}427428async executePostToolUseHook(toolName: string, toolInput: unknown, toolResponseText: string, toolCallId: string, hooks: vscode.ChatRequestHooks | undefined, sessionId?: string, token?: vscode.CancellationToken, outputStream?: vscode.ChatResponseStream): Promise<IPostToolUseHookResult | undefined> {429const hookInput: IPostToolUseHookCommandInput = {430tool_name: toolName,431tool_input: toolInput,432tool_response: toolResponseText,433tool_use_id: toolCallId,434};435const results = await this.executeHook(436'PostToolUse',437hooks,438hookInput,439sessionId,440token441);442443if (results.length === 0) {444return undefined;445}446447// Collapse results: first block wins, collect all additionalContext448let hasBlock = false;449let blockReason: string | undefined;450const allAdditionalContext: string[] = [];451452processHookResults({453hookType: 'PostToolUse',454results,455outputStream,456logService: this._logService,457onSuccess: (output) => {458if (typeof output !== 'object' || output === null) {459return;460}461462const hookOutput = output as {463decision?: string;464reason?: string;465hookSpecificOutput?: IPostToolUseHookSpecificCommandOutput;466};467468// Collect additionalContext from hookSpecificOutput469if (hookOutput.hookSpecificOutput?.additionalContext) {470allAdditionalContext.push(hookOutput.hookSpecificOutput.additionalContext);471}472473// Track the first block decision474if (hookOutput.decision === 'block' && !hasBlock) {475hasBlock = true;476blockReason = hookOutput.reason;477} else if (hookOutput.decision !== undefined && hookOutput.decision !== 'block') {478const message = `Invalid PostToolUse decision value '${String(hookOutput.decision)}'. Expected 'block'. Field was ignored.`;479this._logService.warn(`[ChatHookService] ${message}`);480this._outputChannel.appendLine(`[PostToolUse] ${message}`);481}482},483// Exit code 2 (error) means block the tool result484onError: (errorMessage) => {485if (!hasBlock) {486hasBlock = true;487const messageWithTool = errorMessage488? l10n.t('Tried to use {0} - {1}', toolName, errorMessage)489: l10n.t('Tried to use {0} - an unexpected error occurred', toolName);490blockReason = messageWithTool || undefined;491outputStream?.hookProgress('PostToolUse', formatHookErrorMessage(messageWithTool));492} else {493const messageWithTool = errorMessage494? l10n.t('Tried to use {0} - {1}', toolName, errorMessage)495: l10n.t('Tried to use {0} - an unexpected error occurred', toolName);496outputStream?.hookProgress('PostToolUse', undefined, formatHookErrorMessage(messageWithTool));497}498},499});500501if (!hasBlock && allAdditionalContext.length === 0) {502return undefined;503}504505const hookResult: IPostToolUseHookResult = {506decision: hasBlock ? 'block' : undefined,507reason: blockReason,508additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined,509};510511this._telemetry.logPostToolUseResult(hookResult);512513return hookResult;514}515}516517518