feat(hooks): adds support for RuntimeHook functions. (#19598)

This commit is contained in:
Michael Bleigh
2026-02-24 13:03:36 -08:00
committed by GitHub
parent 14a5f2cc80
commit e653e2888f
17 changed files with 410 additions and 68 deletions
+6 -3
View File
@@ -8,7 +8,7 @@ import type { Config } from '../config/config.js';
import type { HookPlanner, HookEventContext } from './hookPlanner.js';
import type { HookRunner } from './hookRunner.js';
import type { HookAggregator, AggregatedHookResult } from './hookAggregator.js';
import { HookEventName } from './types.js';
import { HookEventName, HookType } from './types.js';
import type {
HookConfig,
HookInput,
@@ -500,7 +500,10 @@ export class HookEventHandler {
* Get hook name from config for display or telemetry
*/
private getHookName(config: HookConfig): string {
return config.name || config.command || 'unknown-command';
if (config.type === HookType.Command) {
return config.name || config.command || 'unknown-command';
}
return config.name || 'unknown-hook';
}
/**
@@ -513,7 +516,7 @@ export class HookEventHandler {
/**
* Get hook type from execution result for telemetry
*/
private getHookTypeFromResult(result: HookExecutionResult): 'command' {
private getHookTypeFromResult(result: HookExecutionResult): HookType {
return result.hookConfig.type;
}
}
+10 -3
View File
@@ -13,6 +13,7 @@ import {
HookEventName,
HookType,
HOOKS_CONFIG_FIELDS,
type CommandHookConfig,
} from './types.js';
import type { Config } from '../config/config.js';
import type { HookDefinition } from './types.js';
@@ -153,7 +154,9 @@ describe('HookRegistry', () => {
expect(hooks).toHaveLength(1);
expect(hooks[0].eventName).toBe(HookEventName.BeforeTool);
expect(hooks[0].config.type).toBe(HookType.Command);
expect(hooks[0].config.command).toBe('./hooks/check_style.sh');
expect((hooks[0].config as CommandHookConfig).command).toBe(
'./hooks/check_style.sh',
);
expect(hooks[0].matcher).toBe('EditTool');
expect(hooks[0].source).toBe(ConfigSource.Project);
});
@@ -186,7 +189,9 @@ describe('HookRegistry', () => {
expect(hooks).toHaveLength(1);
expect(hooks[0].eventName).toBe(HookEventName.AfterTool);
expect(hooks[0].config.type).toBe(HookType.Command);
expect(hooks[0].config.command).toBe('./hooks/after-tool.sh');
expect((hooks[0].config as CommandHookConfig).command).toBe(
'./hooks/after-tool.sh',
);
});
it('should handle invalid configuration gracefully', async () => {
@@ -632,7 +637,9 @@ describe('HookRegistry', () => {
// Should only load the valid hook
const hooks = hookRegistry.getAllHooks();
expect(hooks).toHaveLength(1);
expect(hooks[0].config.command).toBe('./valid-hook.sh');
expect((hooks[0].config as CommandHookConfig).command).toBe(
'./valid-hook.sh',
);
// Verify the warnings for invalid configurations
// 1st warning: non-object hookConfig ('invalid-string')
+47 -3
View File
@@ -34,11 +34,40 @@ export class HookRegistry {
this.config = config;
}
/**
* Register a new hook programmatically
*/
registerHook(
config: HookConfig,
eventName: HookEventName,
options?: { matcher?: string; sequential?: boolean; source?: ConfigSource },
): void {
const source = options?.source ?? ConfigSource.Runtime;
if (!this.validateHookConfig(config, eventName, source)) {
throw new Error(
`Invalid hook configuration for ${eventName} from ${source}`,
);
}
this.entries.push({
config,
source,
eventName,
matcher: options?.matcher,
sequential: options?.sequential,
enabled: true,
});
}
/**
* Initialize the registry by processing hooks from config
*/
async initialize(): Promise<void> {
this.entries = [];
const runtimeHooks = this.entries.filter(
(entry) => entry.source === ConfigSource.Runtime,
);
this.entries = [...runtimeHooks];
this.processHooksFromConfig();
debugLogger.debug(
@@ -93,7 +122,10 @@ export class HookRegistry {
private getHookName(
entry: HookRegistryEntry | { config: HookConfig },
): string {
return entry.config.name || entry.config.command || 'unknown-command';
if (entry.config.type === 'command') {
return entry.config.name || entry.config.command || 'unknown-command';
}
return entry.config.name || 'unknown-hook';
}
/**
@@ -261,7 +293,10 @@ please review the project settings (.gemini/settings.json) and remove them.`;
eventName: HookEventName,
source: ConfigSource,
): boolean {
if (!config.type || !['command', 'plugin'].includes(config.type)) {
if (
!config.type ||
!['command', 'plugin', 'runtime'].includes(config.type)
) {
debugLogger.warn(
`Invalid hook ${eventName} from ${source} type: ${config.type}`,
);
@@ -275,6 +310,13 @@ please review the project settings (.gemini/settings.json) and remove them.`;
return false;
}
if (config.type === 'runtime' && !config.name) {
debugLogger.warn(
`Runtime hook ${eventName} from ${source} missing name field`,
);
return false;
}
return true;
}
@@ -292,6 +334,8 @@ please review the project settings (.gemini/settings.json) and remove them.`;
*/
private getSourcePriority(source: ConfigSource): number {
switch (source) {
case ConfigSource.Runtime:
return 0; // Highest
case ConfigSource.Project:
return 1;
case ConfigSource.User:
+72 -3
View File
@@ -7,6 +7,8 @@
import { spawn } from 'node:child_process';
import type {
HookConfig,
CommandHookConfig,
RuntimeHookConfig,
HookInput,
HookOutput,
HookExecutionResult,
@@ -15,7 +17,7 @@ import type {
BeforeModelOutput,
BeforeToolInput,
} from './types.js';
import { HookEventName, ConfigSource } from './types.js';
import { HookEventName, ConfigSource, HookType } from './types.js';
import type { Config } from '../config/config.js';
import type { LLMRequest } from './hookTranslator.js';
import { debugLogger } from '../utils/debugLogger.js';
@@ -75,6 +77,15 @@ export class HookRunner {
}
try {
if (hookConfig.type === HookType.Runtime) {
return await this.executeRuntimeHook(
hookConfig,
eventName,
input,
startTime,
);
}
return await this.executeCommandHook(
hookConfig,
eventName,
@@ -83,7 +94,10 @@ export class HookRunner {
);
} catch (error) {
const duration = Date.now() - startTime;
const hookId = hookConfig.name || hookConfig.command || 'unknown';
const hookId =
hookConfig.name ||
(hookConfig.type === HookType.Command ? hookConfig.command : '') ||
'unknown';
const errorMessage = `Hook execution failed for event '${eventName}' (hook: ${hookId}): ${error}`;
debugLogger.warn(`Hook execution error (non-fatal): ${errorMessage}`);
@@ -230,11 +244,66 @@ export class HookRunner {
return modifiedInput;
}
/**
* Execute a runtime hook
*/
private async executeRuntimeHook(
hookConfig: RuntimeHookConfig,
eventName: HookEventName,
input: HookInput,
startTime: number,
): Promise<HookExecutionResult> {
const timeout = hookConfig.timeout ?? DEFAULT_HOOK_TIMEOUT;
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const controller = new AbortController();
try {
// Create a promise that rejects after timeout
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(
() => reject(new Error(`Hook timed out after ${timeout}ms`)),
timeout,
);
});
// Execute action with timeout race
const result = await Promise.race([
hookConfig.action(input, { signal: controller.signal }),
timeoutPromise,
]);
const output =
result === null || result === undefined ? undefined : result;
return {
hookConfig,
eventName,
success: true,
output,
duration: Date.now() - startTime,
};
} catch (error) {
// Abort the ongoing hook action if it timed out or errored
controller.abort();
return {
hookConfig,
eventName,
success: false,
error: error instanceof Error ? error : new Error(String(error)),
duration: Date.now() - startTime,
};
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
/**
* Execute a command hook
*/
private async executeCommandHook(
hookConfig: HookConfig,
hookConfig: CommandHookConfig,
eventName: HookEventName,
input: HookInput,
startTime: number,
+6 -5
View File
@@ -77,7 +77,7 @@ describe('HookSystem Integration', () => {
matcher: 'TestTool',
hooks: [
{
type: HookType.Command,
type: HookType.Command as const,
command: 'echo',
timeout: 5000,
},
@@ -164,7 +164,8 @@ describe('HookSystem Integration', () => {
{
type: 'invalid-type' as HookType, // Invalid hook type for testing
command: './test.sh',
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
],
@@ -279,12 +280,12 @@ describe('HookSystem Integration', () => {
matcher: 'TestTool',
hooks: [
{
type: HookType.Command,
type: HookType.Command as const,
command: 'echo "enabled-hook"',
timeout: 5000,
},
{
type: HookType.Command,
type: HookType.Command as const,
command: 'echo "disabled-hook"',
timeout: 5000,
},
@@ -350,7 +351,7 @@ describe('HookSystem Integration', () => {
matcher: 'TestTool',
hooks: [
{
type: HookType.Command,
type: HookType.Command as const,
command: 'echo "will-be-disabled"',
timeout: 5000,
},
+14
View File
@@ -21,6 +21,9 @@ import type {
AfterModelHookOutput,
BeforeToolSelectionHookOutput,
McpToolContext,
HookConfig,
HookEventName,
ConfigSource,
} from './types.js';
import { NotificationType } from './types.js';
import type { AggregatedHookResult } from './hookAggregator.js';
@@ -202,6 +205,17 @@ export class HookSystem {
return this.hookRegistry.getAllHooks();
}
/**
* Register a new hook programmatically
*/
registerHook(
config: HookConfig,
eventName: HookEventName,
options?: { matcher?: string; sequential?: boolean; source?: ConfigSource },
): void {
this.hookRegistry.registerHook(config, eventName, options);
}
/**
* Fire hook events directly
*/
@@ -0,0 +1,141 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HookSystem } from './hookSystem.js';
import { Config } from '../config/config.js';
import { HookType, HookEventName, ConfigSource } from './types.js';
import * as os from 'node:os';
import * as path from 'node:path';
import * as fs from 'node:fs';
// Mock console methods
vi.stubGlobal('console', {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
});
describe('Runtime Hooks', () => {
let hookSystem: HookSystem;
let config: Config;
beforeEach(() => {
vi.resetAllMocks();
const testDir = path.join(os.tmpdir(), 'test-runtime-hooks');
fs.mkdirSync(testDir, { recursive: true });
config = new Config({
model: 'gemini-3-flash-preview',
targetDir: testDir,
sessionId: 'test-session',
debugMode: false,
cwd: testDir,
});
// Stub getMessageBus
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(config as any).getMessageBus = () => undefined;
hookSystem = new HookSystem(config);
});
it('should register a runtime hook', async () => {
await hookSystem.initialize();
const action = vi.fn().mockResolvedValue(undefined);
hookSystem.registerHook(
{
type: HookType.Runtime,
name: 'test-hook',
action,
},
HookEventName.BeforeTool,
{ matcher: 'TestTool' },
);
const hooks = hookSystem.getAllHooks();
expect(hooks).toHaveLength(1);
expect(hooks[0].config.name).toBe('test-hook');
expect(hooks[0].source).toBe(ConfigSource.Runtime);
});
it('should execute a runtime hook', async () => {
await hookSystem.initialize();
const action = vi.fn().mockImplementation(async () => ({
decision: 'allow',
systemMessage: 'Hook ran',
}));
hookSystem.registerHook(
{
type: HookType.Runtime,
name: 'test-hook',
action,
},
HookEventName.BeforeTool,
{ matcher: 'TestTool' },
);
const result = await hookSystem
.getEventHandler()
.fireBeforeToolEvent('TestTool', { foo: 'bar' });
expect(action).toHaveBeenCalled();
expect(action.mock.calls[0][0]).toMatchObject({
tool_name: 'TestTool',
tool_input: { foo: 'bar' },
hook_event_name: 'BeforeTool',
});
expect(result.finalOutput?.systemMessage).toBe('Hook ran');
});
it('should handle runtime hook errors', async () => {
await hookSystem.initialize();
const action = vi.fn().mockRejectedValue(new Error('Hook failed'));
hookSystem.registerHook(
{
type: HookType.Runtime,
name: 'fail-hook',
action,
},
HookEventName.BeforeTool,
{ matcher: 'TestTool' },
);
// Should not throw, but handle error gracefully
await hookSystem.getEventHandler().fireBeforeToolEvent('TestTool', {});
expect(action).toHaveBeenCalled();
});
it('should preserve runtime hooks across re-initialization', async () => {
await hookSystem.initialize();
hookSystem.registerHook(
{
type: HookType.Runtime,
name: 'persist-hook',
action: async () => {},
},
HookEventName.BeforeTool,
{ matcher: 'TestTool' },
);
expect(hookSystem.getAllHooks()).toHaveLength(1);
// Re-initialize
await hookSystem.initialize();
expect(hookSystem.getAllHooks()).toHaveLength(1);
expect(hookSystem.getAllHooks()[0].config.name).toBe('persist-hook');
});
});
+32 -11
View File
@@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as fs from 'node:fs';
import { TrustedHooksManager } from './trustedHooks.js';
import { Storage } from '../config/storage.js';
import { HookEventName, HookType } from './types.js';
import { HookEventName, HookType, type HookDefinition } from './types.js';
vi.mock('node:fs');
vi.mock('../config/storage.js');
@@ -72,8 +72,16 @@ describe('TrustedHooksManager', () => {
[HookEventName.BeforeTool]: [
{
hooks: [
{ name: 'trusted-hook', type: HookType.Command, command: 'cmd1' },
{ name: 'new-hook', type: HookType.Command, command: 'cmd2' },
{
name: 'trusted-hook',
type: HookType.Command,
command: 'cmd1',
} as const,
{
name: 'new-hook',
type: HookType.Command,
command: 'cmd2',
} as const,
],
},
],
@@ -90,7 +98,11 @@ describe('TrustedHooksManager', () => {
[HookEventName.BeforeTool]: [
{
hooks: [
{ name: 'trusted-hook', type: HookType.Command, command: 'cmd1' },
{
name: 'trusted-hook',
type: HookType.Command,
command: 'cmd1',
} as const,
],
},
],
@@ -114,9 +126,12 @@ describe('TrustedHooksManager', () => {
],
};
expect(manager.getUntrustedHooks('/project', projectHooks)).toEqual([
'./script.sh',
]);
expect(
manager.getUntrustedHooks(
'/project',
projectHooks as Partial<Record<HookEventName, HookDefinition[]>>,
),
).toEqual(['./script.sh']);
});
it('should detect change in command as untrusted', () => {
@@ -142,11 +157,17 @@ describe('TrustedHooksManager', () => {
],
};
manager.trustHooks('/project', originalHook);
manager.trustHooks(
'/project',
originalHook as Partial<Record<HookEventName, HookDefinition[]>>,
);
expect(manager.getUntrustedHooks('/project', updatedHook)).toEqual([
'my-hook',
]);
expect(
manager.getUntrustedHooks(
'/project',
updatedHook as Partial<Record<HookEventName, HookDefinition[]>>,
),
).toEqual(['my-hook']);
});
});
+3
View File
@@ -9,6 +9,7 @@ import * as path from 'node:path';
import { Storage } from '../config/storage.js';
import {
getHookKey,
HookType,
type HookDefinition,
type HookEventName,
} from './types.js';
@@ -79,6 +80,7 @@ export class TrustedHooksManager {
for (const def of definitions) {
if (!def || !Array.isArray(def.hooks)) continue;
for (const hook of def.hooks) {
if (hook.type === HookType.Runtime) continue;
const key = getHookKey(hook);
if (!trustedKeys.has(key)) {
// Return friendly name or command
@@ -108,6 +110,7 @@ export class TrustedHooksManager {
for (const def of definitions) {
if (!def || !Array.isArray(def.hooks)) continue;
for (const hook of def.hooks) {
if (hook.type === HookType.Runtime) continue;
currentTrusted.add(getHookKey(hook));
}
}
+36 -10
View File
@@ -21,6 +21,7 @@ import { defaultHookTranslator } from './hookTranslator.js';
* Configuration source levels in precedence order (highest to lowest)
*/
export enum ConfigSource {
Runtime = 'runtime',
Project = 'project',
User = 'user',
System = 'system',
@@ -50,11 +51,43 @@ export enum HookEventName {
export const HOOKS_CONFIG_FIELDS = ['enabled', 'disabled', 'notifications'];
/**
* Hook configuration entry
* Hook implementation types
*/
export enum HookType {
Command = 'command',
Runtime = 'runtime',
}
/**
* Hook action function
*/
export type HookAction = (
input: HookInput,
options?: { signal: AbortSignal },
) => Promise<HookOutput | void | null>;
/**
* Runtime hook configuration
*/
export interface RuntimeHookConfig {
type: HookType.Runtime;
/** Unique name for the runtime hook */
name: string;
/** Function to execute when the hook is triggered */
action: HookAction;
command?: never;
source?: ConfigSource;
/** Maximum time allowed for hook execution in milliseconds */
timeout?: number;
}
/**
* Command hook configuration entry
*/
export interface CommandHookConfig {
type: HookType.Command;
command: string;
action?: never;
name?: string;
description?: string;
timeout?: number;
@@ -62,7 +95,7 @@ export interface CommandHookConfig {
env?: Record<string, string>;
}
export type HookConfig = CommandHookConfig;
export type HookConfig = CommandHookConfig | RuntimeHookConfig;
/**
* Hook definition with matcher
@@ -73,19 +106,12 @@ export interface HookDefinition {
hooks: HookConfig[];
}
/**
* Hook implementation types
*/
export enum HookType {
Command = 'command',
}
/**
* Generate a unique key for a hook configuration
*/
export function getHookKey(hook: HookConfig): string {
const name = hook.name || '';
const command = hook.command || '';
const command = hook.type === HookType.Command ? hook.command : '';
return `${name}:${command}`;
}