feat(hooks): Hook Event Handling (#9097)

This commit is contained in:
Edilmo Palencia
2025-11-24 13:51:39 -08:00
committed by GitHub
parent d53a5c4fb5
commit 2034098780
8 changed files with 2035 additions and 4 deletions
@@ -4,10 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { EventEmitter } from 'node:events';
import type { PolicyEngine } from '../policy/policy-engine.js';
import { PolicyDecision } from '../policy/types.js';
import { MessageBusType, type Message } from './types.js';
import { PolicyDecision, getHookSource } from '../policy/types.js';
import {
MessageBusType,
type Message,
type HookExecutionRequest,
type HookPolicyDecision,
} from './types.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
export class MessageBus extends EventEmitter {
@@ -83,6 +89,39 @@ export class MessageBus extends EventEmitter {
default:
throw new Error(`Unknown policy decision: ${decision}`);
}
} else if (message.type === MessageBusType.HOOK_EXECUTION_REQUEST) {
// Handle hook execution requests through policy evaluation
const hookRequest = message as HookExecutionRequest;
const decision = await this.policyEngine.checkHook(hookRequest);
// Map decision to allow/deny for observability (ASK_USER treated as deny for hooks)
const effectiveDecision =
decision === PolicyDecision.ALLOW ? 'allow' : 'deny';
// Emit policy decision for observability
this.emitMessage({
type: MessageBusType.HOOK_POLICY_DECISION,
eventName: hookRequest.eventName,
hookSource: getHookSource(hookRequest.input),
decision: effectiveDecision,
reason:
decision !== PolicyDecision.ALLOW
? 'Hook execution denied by policy'
: undefined,
} as HookPolicyDecision);
// If allowed, emit the request for hook system to handle
if (decision === PolicyDecision.ALLOW) {
this.emitMessage(message);
} else {
// If denied or ASK_USER, emit error response (hooks don't support interactive confirmation)
this.emitMessage({
type: MessageBusType.HOOK_EXECUTION_RESPONSE,
correlationId: hookRequest.correlationId,
success: false,
error: new Error('Hook execution denied by policy'),
});
}
} else {
// For all other message types, just emit them
this.emitMessage(message);
@@ -105,4 +144,46 @@ export class MessageBus extends EventEmitter {
): void {
this.off(type, listener);
}
/**
* Request-response pattern: Publish a message and wait for a correlated response
* This enables synchronous-style communication over the async MessageBus
* The correlation ID is generated internally and added to the request
*/
async request<TRequest extends Message, TResponse extends Message>(
request: Omit<TRequest, 'correlationId'>,
responseType: TResponse['type'],
timeoutMs: number = 60000,
): Promise<TResponse> {
const correlationId = randomUUID();
return new Promise<TResponse>((resolve, reject) => {
const timeoutId = setTimeout(() => {
cleanup();
reject(new Error(`Request timed out waiting for ${responseType}`));
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeoutId);
this.unsubscribe(responseType, responseHandler);
};
const responseHandler = (response: TResponse) => {
// Check if this response matches our request
if (
'correlationId' in response &&
response.correlationId === correlationId
) {
cleanup();
resolve(response);
}
};
// Subscribe to responses
this.subscribe<TResponse>(responseType, responseHandler);
// Publish the request with correlation ID
this.publish({ ...request, correlationId } as TRequest);
});
}
}
+30 -1
View File
@@ -13,6 +13,9 @@ export enum MessageBusType {
TOOL_EXECUTION_SUCCESS = 'tool-execution-success',
TOOL_EXECUTION_FAILURE = 'tool-execution-failure',
UPDATE_POLICY = 'update-policy',
HOOK_EXECUTION_REQUEST = 'hook-execution-request',
HOOK_EXECUTION_RESPONSE = 'hook-execution-response',
HOOK_POLICY_DECISION = 'hook-policy-decision',
}
export interface ToolConfirmationRequest {
@@ -55,10 +58,36 @@ export interface ToolExecutionFailure<E = Error> {
error: E;
}
export interface HookExecutionRequest {
type: MessageBusType.HOOK_EXECUTION_REQUEST;
eventName: string;
input: Record<string, unknown>;
correlationId: string;
}
export interface HookExecutionResponse {
type: MessageBusType.HOOK_EXECUTION_RESPONSE;
correlationId: string;
success: boolean;
output?: Record<string, unknown>;
error?: Error;
}
export interface HookPolicyDecision {
type: MessageBusType.HOOK_POLICY_DECISION;
eventName: string;
hookSource: 'project' | 'user' | 'system' | 'extension';
decision: 'allow' | 'deny';
reason?: string;
}
export type Message =
| ToolConfirmationRequest
| ToolConfirmationResponse
| ToolPolicyRejection
| ToolExecutionSuccess
| ToolExecutionFailure
| UpdatePolicy;
| UpdatePolicy
| HookExecutionRequest
| HookExecutionResponse
| HookPolicyDecision;