Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as l10n from '@vscode/l10n';
7
import type * as vscode from 'vscode';
8
import { IChatHookService, IPostToolUseHookResult, IPreToolUseHookResult } from '../../../platform/chat/common/chatHookService';
9
import { IPostToolUseHookCommandInput, IPostToolUseHookSpecificCommandOutput, IPreToolUseHookCommandInput, IPreToolUseHookSpecificCommandOutput } from '../../../platform/chat/common/hookCommandTypes';
10
import { HookCommandResultKind, IHookCommandResult, IHookExecutor } from '../../../platform/chat/common/hookExecutor';
11
import { IHooksOutputChannel } from '../../../platform/chat/common/hooksOutputChannel';
12
import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService';
13
import { ILogService } from '../../../platform/log/common/logService';
14
import { CopilotChatAttr, GenAiAttr, GenAiOperationName, IOTelService, SpanKind, SpanStatusCode, truncateForOTel } from '../../../platform/otel/common/index';
15
import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';
16
import { raceTimeout } from '../../../util/vs/base/common/async';
17
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
18
import { StopWatch } from '../../../util/vs/base/common/stopwatch';
19
import { formatHookErrorMessage, processHookResults } from '../../intents/node/hookResultProcessor';
20
import { IToolsService, isToolValidationError } from '../../tools/common/toolsService';
21
import { ChatHookTelemetry } from './chatHookTelemetry';
22
23
const permissionPriority: Record<string, number> = { 'deny': 2, 'ask': 1, 'allow': 0 };
24
25
/**
26
* One-way compatible hook event name mappings. When a hook written for one event
27
* type is reused under a different type (e.g. a Stop hook scoped to a custom
28
* agent runs as SubagentStop), the hookEventName in the output won't match.
29
*
30
* The map key is the hookEventName from the output; the value is the hook type
31
* it should also be accepted for. The mapping is intentionally one-way:
32
* a Stop hook is accepted when running as SubagentStop, but a SubagentStop
33
* hook's output is NOT accepted when running as a top-level Stop.
34
*/
35
const compatibleHookEventNames: ReadonlyMap<vscode.ChatHookType, vscode.ChatHookType> = new Map([
36
['Stop', 'SubagentStop'],
37
['SessionStart', 'SubagentStart'],
38
]);
39
40
export function isCompatibleHookEventName(hookEventName: string, hookType: string): boolean {
41
return hookEventName === hookType || compatibleHookEventNames.get(hookEventName as vscode.ChatHookType) === hookType;
42
}
43
44
/**
45
* Keys that should be redacted when logging hook input.
46
*/
47
const redactedInputKeys = ['toolArgs', 'tool_input'];
48
49
export class ChatHookService implements IChatHookService {
50
declare readonly _serviceBrand: undefined;
51
52
private _requestCounter = 0;
53
private readonly _telemetry: ChatHookTelemetry;
54
55
constructor(
56
@ISessionTranscriptService private readonly _sessionTranscriptService: ISessionTranscriptService,
57
@ILogService private readonly _logService: ILogService,
58
@IHookExecutor private readonly _hookExecutor: IHookExecutor,
59
@IHooksOutputChannel private readonly _outputChannel: IHooksOutputChannel,
60
@ITelemetryService telemetryService: ITelemetryService,
61
@IToolsService private readonly _toolsService: IToolsService,
62
@IOTelService private readonly _otelService: IOTelService,
63
) {
64
this._telemetry = new ChatHookTelemetry(telemetryService);
65
}
66
67
private _log(requestId: number, hookType: string, message: string): void {
68
this._outputChannel.appendLine(`[#${requestId}] [${hookType}] ${message}`);
69
}
70
71
private _redactForLogging(input: Record<string, unknown>): Record<string, unknown> {
72
const result = { ...input };
73
for (const key of redactedInputKeys) {
74
if (Object.hasOwn(result, key)) {
75
result[key] = '...';
76
}
77
}
78
return result;
79
}
80
81
private _logCommandResult(requestId: number, hookType: string, commandResult: IHookCommandResult, elapsed: number): void {
82
const elapsedRounded = Math.round(elapsed);
83
const resultKindStr = commandResult.kind === HookCommandResultKind.Success ? 'Success'
84
: commandResult.kind === HookCommandResultKind.NonBlockingError ? 'NonBlockingError'
85
: 'Error';
86
const resultStr = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);
87
const hasOutput = resultStr.length > 0 && resultStr !== '{}' && resultStr !== '[]';
88
if (hasOutput) {
89
this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsedRounded}ms`);
90
this._log(requestId, hookType, `Output: ${resultStr}`);
91
} else {
92
this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsedRounded}ms, no output`);
93
}
94
}
95
96
logConfiguredHooks(hooks: vscode.ChatRequestHooks | undefined): void {
97
if (hooks) {
98
this._telemetry.logConfiguredHooks(hooks);
99
}
100
}
101
102
async executeHook(hookType: vscode.ChatHookType, hooks: vscode.ChatRequestHooks | undefined, input: unknown, sessionId?: string, token?: vscode.CancellationToken): Promise<vscode.ChatHookResult[]> {
103
if (!hooks) {
104
return [];
105
}
106
107
const hookCommands = hooks[hookType];
108
if (!hookCommands || hookCommands.length === 0) {
109
return [];
110
}
111
112
const hookCount = hookCommands.length;
113
const overallStopWatch = StopWatch.create();
114
let hasError = false;
115
let hasCaughtException = false;
116
117
try {
118
// Flush transcript before running hooks so scripts see up-to-date content
119
let transcriptPath: vscode.Uri | undefined;
120
if (sessionId) {
121
await raceTimeout(this._sessionTranscriptService.flush(sessionId), 500);
122
transcriptPath = this._sessionTranscriptService.getTranscriptPath(sessionId);
123
}
124
125
// Build common input properties merged with caller-specific input
126
const commonInput = {
127
timestamp: new Date().toISOString(),
128
hook_event_name: hookType,
129
...(sessionId ? { session_id: sessionId } : undefined),
130
...(transcriptPath ? { transcript_path: transcriptPath.fsPath } : undefined),
131
};
132
const fullInput = (typeof input === 'object' && input !== null)
133
? { ...commonInput, ...input }
134
: commonInput;
135
136
const results: vscode.ChatHookResult[] = [];
137
const effectiveToken = token ?? CancellationToken.None;
138
const requestId = this._requestCounter++;
139
140
this._logService.debug(`[ChatHookService] Executing ${hookCommands.length} hook(s) for type '${hookType}'`);
141
this._log(requestId, hookType, `Executing ${hookCommands.length} hook(s)`);
142
143
const chatSessionId = sessionId;
144
145
for (const hookCommand of hookCommands) {
146
try {
147
// Include per-command cwd in the input
148
const commandInput = hookCommand.cwd
149
? { ...fullInput, cwd: hookCommand.cwd.fsPath }
150
: fullInput;
151
152
this._log(requestId, hookType, `Running: ${JSON.stringify(hookCommand)}`);
153
const inputForLog = this._redactForLogging(commandInput as Record<string, unknown>);
154
this._log(requestId, hookType, `Input: ${JSON.stringify(inputForLog)}`);
155
156
const span = this._otelService.startSpan(`execute_hook ${hookType}`, {
157
kind: SpanKind.INTERNAL,
158
attributes: {
159
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_HOOK,
160
[CopilotChatAttr.HOOK_TYPE]: hookType,
161
'copilot_chat.hook_command': hookCommand.command,
162
...(chatSessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: chatSessionId } : {}),
163
},
164
});
165
166
try {
167
// Capture hook input for debug panel resolve
168
try {
169
span.setAttribute(CopilotChatAttr.HOOK_INPUT, truncateForOTel(JSON.stringify(commandInput)));
170
} catch { /* swallow serialization errors */ }
171
172
const sw = StopWatch.create();
173
const commandResult = await this._hookExecutor.executeCommand(hookCommand, commandInput, effectiveToken);
174
const elapsed = sw.elapsed();
175
176
this._logCommandResult(requestId, hookType, commandResult, elapsed);
177
178
// Record result on OTel span
179
const resultKind = commandResult.kind === HookCommandResultKind.Success ? 'success'
180
: commandResult.kind === HookCommandResultKind.NonBlockingError ? 'non_blocking_error'
181
: 'error';
182
span.setAttribute(CopilotChatAttr.HOOK_RESULT_KIND, resultKind);
183
184
if (commandResult.kind === HookCommandResultKind.Error || commandResult.kind === HookCommandResultKind.NonBlockingError) {
185
hasError = true;
186
// Record exit code on error
187
if (commandResult.exitCode !== undefined) {
188
span.setAttribute('copilot_chat.hook_exit_code', commandResult.exitCode);
189
}
190
// Error output goes to span status message (displayed as errorMessage in resolve)
191
span.setStatus(SpanStatusCode.ERROR, typeof commandResult.result === 'string' ? commandResult.result : undefined);
192
} else {
193
span.setStatus(SpanStatusCode.OK);
194
// Capture hook output for debug panel resolve (success only — errors go to errorMessage)
195
try {
196
const output = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);
197
if (output) {
198
span.setAttribute(CopilotChatAttr.HOOK_OUTPUT, truncateForOTel(output));
199
}
200
} catch { /* swallow serialization errors */ }
201
}
202
203
const result = this._toHookResult(hookType, commandResult);
204
results.push(result);
205
206
// If stopReason is set (including empty string for "stop without message"), stop processing remaining hooks
207
if (result.stopReason !== undefined) {
208
this._log(requestId, hookType, `Stopping: ${result.stopReason}`);
209
this._logService.debug(`[ChatHookService] Stopping after hook: ${result.stopReason}`);
210
break;
211
}
212
} catch (spanErr) {
213
const error = spanErr instanceof Error ? spanErr : new Error(String(spanErr));
214
span.recordException(error);
215
span.setStatus(SpanStatusCode.ERROR, error.message);
216
throw spanErr;
217
} finally {
218
span.end();
219
}
220
} catch (err) {
221
hasCaughtException = true;
222
const errMessage = err instanceof Error ? err.message : String(err);
223
this._log(requestId, hookType, `Error: ${errMessage}`);
224
this._logService.error(err instanceof Error ? err : new Error(errMessage), '[ChatHookService] Error running hook command');
225
results.push({
226
resultKind: 'warning',
227
output: undefined,
228
warningMessage: errMessage,
229
});
230
}
231
}
232
233
return results;
234
} catch (e) {
235
hasCaughtException = true;
236
this._logService.error(`[ChatHookService] Error executing ${hookType} hook`, e);
237
return [];
238
} finally {
239
this._telemetry.logHookExecuted(hookType, hookCount, overallStopWatch.elapsed(), hasError, hasCaughtException);
240
}
241
}
242
243
private _toHookResult(hookType: string, commandResult: IHookCommandResult): vscode.ChatHookResult {
244
switch (commandResult.kind) {
245
case HookCommandResultKind.Error: {
246
// Exit code 2 - blocking error
247
// Callers handle this based on hook type (e.g., deny for PreToolUse, blocking reason for Stop)
248
const message = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);
249
return {
250
resultKind: 'error',
251
output: message,
252
};
253
}
254
case HookCommandResultKind.NonBlockingError: {
255
// Non-blocking error - shown to user only as warning
256
const errorMessage = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);
257
return {
258
resultKind: 'warning',
259
output: undefined,
260
warningMessage: errorMessage,
261
};
262
}
263
case HookCommandResultKind.Success: {
264
if (typeof commandResult.result !== 'object') {
265
return {
266
resultKind: 'success',
267
output: commandResult.result,
268
};
269
}
270
271
// Extract common fields (continue, stopReason, systemMessage)
272
const resultObj = commandResult.result as Record<string, unknown>;
273
const stopReason = typeof resultObj['stopReason'] === 'string' ? resultObj['stopReason'] : undefined;
274
const continueFlag = resultObj['continue'];
275
const systemMessage = typeof resultObj['systemMessage'] === 'string' ? resultObj['systemMessage'] : undefined;
276
277
// Handle continue field: when false, stopReason is effective
278
let effectiveStopReason = stopReason;
279
if (continueFlag === false && !effectiveStopReason) {
280
effectiveStopReason = '';
281
}
282
283
// Check hookEventName at top level — if present and mismatched, skip this result
284
const topLevelHookEventName = resultObj['hookEventName'];
285
if (typeof topLevelHookEventName === 'string' && !isCompatibleHookEventName(topLevelHookEventName, hookType)) {
286
this._logService.trace(`[ChatHookService] Ignoring result with mismatched hookEventName '${topLevelHookEventName}' (expected '${hookType}')`);
287
return {
288
resultKind: 'success',
289
output: undefined,
290
};
291
}
292
293
// Check hookEventName inside hookSpecificOutput — if mismatched, strip hookSpecificOutput but keep the rest
294
let stripHookSpecificOutput = false;
295
const hookSpecificOutput = resultObj['hookSpecificOutput'];
296
if (typeof hookSpecificOutput === 'object' && hookSpecificOutput !== null) {
297
const nestedHookEventName = (hookSpecificOutput as Record<string, unknown>)['hookEventName'];
298
if (typeof nestedHookEventName === 'string' && !isCompatibleHookEventName(nestedHookEventName, hookType)) {
299
this._logService.trace(`[ChatHookService] Stripping hookSpecificOutput with mismatched hookEventName '${nestedHookEventName}' (expected '${hookType}')`);
300
stripHookSpecificOutput = true;
301
}
302
}
303
304
// Extract hook-specific output (everything except common fields)
305
const commonFields = new Set(['continue', 'stopReason', 'systemMessage']);
306
if (stripHookSpecificOutput) {
307
commonFields.add('hookSpecificOutput');
308
}
309
const hookOutput: Record<string, unknown> = {};
310
for (const [key, value] of Object.entries(resultObj)) {
311
if (value !== undefined && !commonFields.has(key)) {
312
hookOutput[key] = value;
313
}
314
}
315
316
return {
317
resultKind: 'success',
318
stopReason: effectiveStopReason,
319
warningMessage: systemMessage,
320
output: Object.keys(hookOutput).length > 0 ? hookOutput : undefined,
321
};
322
}
323
default:
324
return {
325
resultKind: 'warning',
326
warningMessage: `Unexpected hook command result kind: ${(commandResult as IHookCommandResult).kind}`,
327
output: undefined,
328
};
329
}
330
}
331
332
async executePreToolUseHook(toolName: string, toolInput: unknown, toolCallId: string, hooks: vscode.ChatRequestHooks | undefined, sessionId?: string, token?: vscode.CancellationToken, outputStream?: vscode.ChatResponseStream): Promise<IPreToolUseHookResult | undefined> {
333
const hookInput: IPreToolUseHookCommandInput = {
334
tool_name: toolName,
335
tool_input: toolInput,
336
tool_use_id: toolCallId,
337
};
338
const results = await this.executeHook(
339
'PreToolUse',
340
hooks,
341
hookInput,
342
sessionId,
343
token
344
);
345
346
if (results.length === 0) {
347
return undefined;
348
}
349
350
// Collapse results: deny > ask > allow (most restrictive wins),
351
// collect all additionalContext, last updatedInput wins
352
let mostRestrictiveDecision: 'allow' | 'deny' | 'ask' | undefined;
353
let winningReason: string | undefined;
354
let lastUpdatedInput: object | undefined;
355
const allAdditionalContext: string[] = [];
356
357
processHookResults({
358
hookType: 'PreToolUse',
359
results,
360
outputStream,
361
logService: this._logService,
362
onSuccess: (output) => {
363
if (typeof output !== 'object' || output === null) {
364
return;
365
}
366
367
const hookOutput = output as { hookSpecificOutput?: IPreToolUseHookSpecificCommandOutput };
368
const hookSpecificOutput = hookOutput.hookSpecificOutput;
369
if (!hookSpecificOutput) {
370
return;
371
}
372
373
if (hookSpecificOutput.additionalContext) {
374
allAdditionalContext.push(hookSpecificOutput.additionalContext);
375
}
376
377
if (hookSpecificOutput.updatedInput) {
378
lastUpdatedInput = hookSpecificOutput.updatedInput;
379
}
380
381
const decision = hookSpecificOutput.permissionDecision;
382
if (decision && !(decision in permissionPriority)) {
383
const message = `Invalid permissionDecision value '${String(decision)}'. Expected 'allow', 'deny', or 'ask'. Field was ignored.`;
384
this._logService.warn(`[ChatHookService] ${message}`);
385
this._outputChannel.appendLine(`[PreToolUse] ${message}`);
386
} else if (decision && (mostRestrictiveDecision === undefined || (permissionPriority[decision] ?? 0) > (permissionPriority[mostRestrictiveDecision] ?? 0))) {
387
mostRestrictiveDecision = decision;
388
winningReason = hookSpecificOutput.permissionDecisionReason;
389
}
390
},
391
// Exit code 2 (error) means deny the tool
392
onError: (errorMessage) => {
393
const messageWithTool = errorMessage
394
? l10n.t('Tried to use {0} - {1}', toolName, errorMessage)
395
: l10n.t('Tried to use {0} - an unexpected error occurred', toolName);
396
outputStream?.hookProgress('PreToolUse', formatHookErrorMessage(messageWithTool));
397
mostRestrictiveDecision = 'deny';
398
winningReason = messageWithTool || winningReason;
399
},
400
});
401
402
// Validate updatedInput against the tool's input schema before returning it
403
if (lastUpdatedInput) {
404
const validationResult = this._toolsService.validateToolInput(toolName, JSON.stringify(lastUpdatedInput));
405
if (isToolValidationError(validationResult)) {
406
const message = `Discarding updatedInput for tool '${toolName}': schema validation failed: ${validationResult.error}`;
407
this._logService.warn(`[ChatHookService] ${message}`);
408
this._outputChannel.appendLine(`[PreToolUse] ${message}`);
409
lastUpdatedInput = undefined;
410
}
411
}
412
413
if (!mostRestrictiveDecision && !lastUpdatedInput && allAdditionalContext.length === 0) {
414
return undefined;
415
}
416
417
const hookResult: IPreToolUseHookResult = {
418
permissionDecision: mostRestrictiveDecision,
419
permissionDecisionReason: winningReason,
420
updatedInput: lastUpdatedInput,
421
additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined,
422
};
423
424
this._telemetry.logPreToolUseResult(hookResult);
425
426
return hookResult;
427
}
428
429
async executePostToolUseHook(toolName: string, toolInput: unknown, toolResponseText: string, toolCallId: string, hooks: vscode.ChatRequestHooks | undefined, sessionId?: string, token?: vscode.CancellationToken, outputStream?: vscode.ChatResponseStream): Promise<IPostToolUseHookResult | undefined> {
430
const hookInput: IPostToolUseHookCommandInput = {
431
tool_name: toolName,
432
tool_input: toolInput,
433
tool_response: toolResponseText,
434
tool_use_id: toolCallId,
435
};
436
const results = await this.executeHook(
437
'PostToolUse',
438
hooks,
439
hookInput,
440
sessionId,
441
token
442
);
443
444
if (results.length === 0) {
445
return undefined;
446
}
447
448
// Collapse results: first block wins, collect all additionalContext
449
let hasBlock = false;
450
let blockReason: string | undefined;
451
const allAdditionalContext: string[] = [];
452
453
processHookResults({
454
hookType: 'PostToolUse',
455
results,
456
outputStream,
457
logService: this._logService,
458
onSuccess: (output) => {
459
if (typeof output !== 'object' || output === null) {
460
return;
461
}
462
463
const hookOutput = output as {
464
decision?: string;
465
reason?: string;
466
hookSpecificOutput?: IPostToolUseHookSpecificCommandOutput;
467
};
468
469
// Collect additionalContext from hookSpecificOutput
470
if (hookOutput.hookSpecificOutput?.additionalContext) {
471
allAdditionalContext.push(hookOutput.hookSpecificOutput.additionalContext);
472
}
473
474
// Track the first block decision
475
if (hookOutput.decision === 'block' && !hasBlock) {
476
hasBlock = true;
477
blockReason = hookOutput.reason;
478
} else if (hookOutput.decision !== undefined && hookOutput.decision !== 'block') {
479
const message = `Invalid PostToolUse decision value '${String(hookOutput.decision)}'. Expected 'block'. Field was ignored.`;
480
this._logService.warn(`[ChatHookService] ${message}`);
481
this._outputChannel.appendLine(`[PostToolUse] ${message}`);
482
}
483
},
484
// Exit code 2 (error) means block the tool result
485
onError: (errorMessage) => {
486
if (!hasBlock) {
487
hasBlock = true;
488
const messageWithTool = errorMessage
489
? l10n.t('Tried to use {0} - {1}', toolName, errorMessage)
490
: l10n.t('Tried to use {0} - an unexpected error occurred', toolName);
491
blockReason = messageWithTool || undefined;
492
outputStream?.hookProgress('PostToolUse', formatHookErrorMessage(messageWithTool));
493
} else {
494
const messageWithTool = errorMessage
495
? l10n.t('Tried to use {0} - {1}', toolName, errorMessage)
496
: l10n.t('Tried to use {0} - an unexpected error occurred', toolName);
497
outputStream?.hookProgress('PostToolUse', undefined, formatHookErrorMessage(messageWithTool));
498
}
499
},
500
});
501
502
if (!hasBlock && allAdditionalContext.length === 0) {
503
return undefined;
504
}
505
506
const hookResult: IPostToolUseHookResult = {
507
decision: hasBlock ? 'block' : undefined,
508
reason: blockReason,
509
additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined,
510
};
511
512
this._telemetry.logPostToolUseResult(hookResult);
513
514
return hookResult;
515
}
516
}
517
518