mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(hooks): adds support for RuntimeHook functions. (#19598)
This commit is contained in:
@@ -9,7 +9,11 @@ import * as fs from 'node:fs';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import { ExtensionManager } from './extension-manager.js';
|
import { ExtensionManager } from './extension-manager.js';
|
||||||
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
|
import {
|
||||||
|
debugLogger,
|
||||||
|
coreEvents,
|
||||||
|
type CommandHookConfig,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
import { createTestMergedSettings } from './settings.js';
|
import { createTestMergedSettings } from './settings.js';
|
||||||
import { createExtension } from '../test-utils/createExtension.js';
|
import { createExtension } from '../test-utils/createExtension.js';
|
||||||
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
|
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
|
||||||
@@ -248,9 +252,11 @@ System using model: \${MODEL_NAME}
|
|||||||
|
|
||||||
expect(extension.hooks).toBeDefined();
|
expect(extension.hooks).toBeDefined();
|
||||||
expect(extension.hooks?.BeforeTool).toHaveLength(1);
|
expect(extension.hooks?.BeforeTool).toHaveLength(1);
|
||||||
expect(extension.hooks?.BeforeTool![0].hooks[0].env?.['HOOK_CMD']).toBe(
|
expect(
|
||||||
'hello-world',
|
(extension.hooks?.BeforeTool![0].hooks[0] as CommandHookConfig).env?.[
|
||||||
);
|
'HOOK_CMD'
|
||||||
|
],
|
||||||
|
).toBe('hello-world');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pick up new settings after restartExtension', async () => {
|
it('should pick up new settings after restartExtension', async () => {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
applyAdminAllowlist,
|
applyAdminAllowlist,
|
||||||
getAdminBlockedMcpServersMessage,
|
getAdminBlockedMcpServersMessage,
|
||||||
CoreToolCallStatus,
|
CoreToolCallStatus,
|
||||||
|
HookType,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { maybeRequestConsentOrFail } from './extensions/consent.js';
|
import { maybeRequestConsentOrFail } from './extensions/consent.js';
|
||||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||||
@@ -735,6 +736,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
if (eventHooks) {
|
if (eventHooks) {
|
||||||
for (const definition of eventHooks) {
|
for (const definition of eventHooks) {
|
||||||
for (const hook of definition.hooks) {
|
for (const hook of definition.hooks) {
|
||||||
|
if (hook.type === HookType.Command) {
|
||||||
// Merge existing env with new env vars, giving extension settings precedence.
|
// Merge existing env with new env vars, giving extension settings precedence.
|
||||||
hook.env = { ...hook.env, ...hookEnv };
|
hook.env = { ...hook.env, ...hookEnv };
|
||||||
}
|
}
|
||||||
@@ -743,6 +745,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let skills = await loadSkillsFromDir(
|
let skills = await loadSkillsFromDir(
|
||||||
path.join(effectiveExtensionPath, 'skills'),
|
path.join(effectiveExtensionPath, 'skills'),
|
||||||
|
|||||||
@@ -1928,7 +1928,7 @@ describe('Config getHooks', () => {
|
|||||||
const mockHooks = {
|
const mockHooks = {
|
||||||
BeforeTool: [
|
BeforeTool: [
|
||||||
{
|
{
|
||||||
hooks: [{ type: HookType.Command, command: 'echo 1' }],
|
hooks: [{ type: HookType.Command, command: 'echo 1' } as const],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -2235,7 +2235,7 @@ describe('Hooks configuration', () => {
|
|||||||
const initialHooks = {
|
const initialHooks = {
|
||||||
BeforeAgent: [
|
BeforeAgent: [
|
||||||
{
|
{
|
||||||
hooks: [{ type: HookType.Command, command: 'initial' }],
|
hooks: [{ type: HookType.Command as const, command: 'initial' }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { Config } from '../config/config.js';
|
|||||||
import type { HookPlanner, HookEventContext } from './hookPlanner.js';
|
import type { HookPlanner, HookEventContext } from './hookPlanner.js';
|
||||||
import type { HookRunner } from './hookRunner.js';
|
import type { HookRunner } from './hookRunner.js';
|
||||||
import type { HookAggregator, AggregatedHookResult } from './hookAggregator.js';
|
import type { HookAggregator, AggregatedHookResult } from './hookAggregator.js';
|
||||||
import { HookEventName } from './types.js';
|
import { HookEventName, HookType } from './types.js';
|
||||||
import type {
|
import type {
|
||||||
HookConfig,
|
HookConfig,
|
||||||
HookInput,
|
HookInput,
|
||||||
@@ -500,8 +500,11 @@ export class HookEventHandler {
|
|||||||
* Get hook name from config for display or telemetry
|
* Get hook name from config for display or telemetry
|
||||||
*/
|
*/
|
||||||
private getHookName(config: HookConfig): string {
|
private getHookName(config: HookConfig): string {
|
||||||
|
if (config.type === HookType.Command) {
|
||||||
return config.name || config.command || 'unknown-command';
|
return config.name || config.command || 'unknown-command';
|
||||||
}
|
}
|
||||||
|
return config.name || 'unknown-hook';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get hook name from execution result for telemetry
|
* Get hook name from execution result for telemetry
|
||||||
@@ -513,7 +516,7 @@ export class HookEventHandler {
|
|||||||
/**
|
/**
|
||||||
* Get hook type from execution result for telemetry
|
* Get hook type from execution result for telemetry
|
||||||
*/
|
*/
|
||||||
private getHookTypeFromResult(result: HookExecutionResult): 'command' {
|
private getHookTypeFromResult(result: HookExecutionResult): HookType {
|
||||||
return result.hookConfig.type;
|
return result.hookConfig.type;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
HookEventName,
|
HookEventName,
|
||||||
HookType,
|
HookType,
|
||||||
HOOKS_CONFIG_FIELDS,
|
HOOKS_CONFIG_FIELDS,
|
||||||
|
type CommandHookConfig,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import type { Config } from '../config/config.js';
|
import type { Config } from '../config/config.js';
|
||||||
import type { HookDefinition } from './types.js';
|
import type { HookDefinition } from './types.js';
|
||||||
@@ -153,7 +154,9 @@ describe('HookRegistry', () => {
|
|||||||
expect(hooks).toHaveLength(1);
|
expect(hooks).toHaveLength(1);
|
||||||
expect(hooks[0].eventName).toBe(HookEventName.BeforeTool);
|
expect(hooks[0].eventName).toBe(HookEventName.BeforeTool);
|
||||||
expect(hooks[0].config.type).toBe(HookType.Command);
|
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].matcher).toBe('EditTool');
|
||||||
expect(hooks[0].source).toBe(ConfigSource.Project);
|
expect(hooks[0].source).toBe(ConfigSource.Project);
|
||||||
});
|
});
|
||||||
@@ -186,7 +189,9 @@ describe('HookRegistry', () => {
|
|||||||
expect(hooks).toHaveLength(1);
|
expect(hooks).toHaveLength(1);
|
||||||
expect(hooks[0].eventName).toBe(HookEventName.AfterTool);
|
expect(hooks[0].eventName).toBe(HookEventName.AfterTool);
|
||||||
expect(hooks[0].config.type).toBe(HookType.Command);
|
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 () => {
|
it('should handle invalid configuration gracefully', async () => {
|
||||||
@@ -632,7 +637,9 @@ describe('HookRegistry', () => {
|
|||||||
// Should only load the valid hook
|
// Should only load the valid hook
|
||||||
const hooks = hookRegistry.getAllHooks();
|
const hooks = hookRegistry.getAllHooks();
|
||||||
expect(hooks).toHaveLength(1);
|
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
|
// Verify the warnings for invalid configurations
|
||||||
// 1st warning: non-object hookConfig ('invalid-string')
|
// 1st warning: non-object hookConfig ('invalid-string')
|
||||||
|
|||||||
@@ -34,11 +34,40 @@ export class HookRegistry {
|
|||||||
this.config = config;
|
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
|
* Initialize the registry by processing hooks from config
|
||||||
*/
|
*/
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
this.entries = [];
|
const runtimeHooks = this.entries.filter(
|
||||||
|
(entry) => entry.source === ConfigSource.Runtime,
|
||||||
|
);
|
||||||
|
this.entries = [...runtimeHooks];
|
||||||
this.processHooksFromConfig();
|
this.processHooksFromConfig();
|
||||||
|
|
||||||
debugLogger.debug(
|
debugLogger.debug(
|
||||||
@@ -93,8 +122,11 @@ export class HookRegistry {
|
|||||||
private getHookName(
|
private getHookName(
|
||||||
entry: HookRegistryEntry | { config: HookConfig },
|
entry: HookRegistryEntry | { config: HookConfig },
|
||||||
): string {
|
): string {
|
||||||
|
if (entry.config.type === 'command') {
|
||||||
return entry.config.name || entry.config.command || 'unknown-command';
|
return entry.config.name || entry.config.command || 'unknown-command';
|
||||||
}
|
}
|
||||||
|
return entry.config.name || 'unknown-hook';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check for untrusted project hooks and warn the user
|
* Check for untrusted project hooks and warn the user
|
||||||
@@ -261,7 +293,10 @@ please review the project settings (.gemini/settings.json) and remove them.`;
|
|||||||
eventName: HookEventName,
|
eventName: HookEventName,
|
||||||
source: ConfigSource,
|
source: ConfigSource,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!config.type || !['command', 'plugin'].includes(config.type)) {
|
if (
|
||||||
|
!config.type ||
|
||||||
|
!['command', 'plugin', 'runtime'].includes(config.type)
|
||||||
|
) {
|
||||||
debugLogger.warn(
|
debugLogger.warn(
|
||||||
`Invalid hook ${eventName} from ${source} type: ${config.type}`,
|
`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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.type === 'runtime' && !config.name) {
|
||||||
|
debugLogger.warn(
|
||||||
|
`Runtime hook ${eventName} from ${source} missing name field`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +334,8 @@ please review the project settings (.gemini/settings.json) and remove them.`;
|
|||||||
*/
|
*/
|
||||||
private getSourcePriority(source: ConfigSource): number {
|
private getSourcePriority(source: ConfigSource): number {
|
||||||
switch (source) {
|
switch (source) {
|
||||||
|
case ConfigSource.Runtime:
|
||||||
|
return 0; // Highest
|
||||||
case ConfigSource.Project:
|
case ConfigSource.Project:
|
||||||
return 1;
|
return 1;
|
||||||
case ConfigSource.User:
|
case ConfigSource.User:
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type {
|
import type {
|
||||||
HookConfig,
|
HookConfig,
|
||||||
|
CommandHookConfig,
|
||||||
|
RuntimeHookConfig,
|
||||||
HookInput,
|
HookInput,
|
||||||
HookOutput,
|
HookOutput,
|
||||||
HookExecutionResult,
|
HookExecutionResult,
|
||||||
@@ -15,7 +17,7 @@ import type {
|
|||||||
BeforeModelOutput,
|
BeforeModelOutput,
|
||||||
BeforeToolInput,
|
BeforeToolInput,
|
||||||
} from './types.js';
|
} 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 { Config } from '../config/config.js';
|
||||||
import type { LLMRequest } from './hookTranslator.js';
|
import type { LLMRequest } from './hookTranslator.js';
|
||||||
import { debugLogger } from '../utils/debugLogger.js';
|
import { debugLogger } from '../utils/debugLogger.js';
|
||||||
@@ -75,6 +77,15 @@ export class HookRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (hookConfig.type === HookType.Runtime) {
|
||||||
|
return await this.executeRuntimeHook(
|
||||||
|
hookConfig,
|
||||||
|
eventName,
|
||||||
|
input,
|
||||||
|
startTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return await this.executeCommandHook(
|
return await this.executeCommandHook(
|
||||||
hookConfig,
|
hookConfig,
|
||||||
eventName,
|
eventName,
|
||||||
@@ -83,7 +94,10 @@ export class HookRunner {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const duration = Date.now() - startTime;
|
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}`;
|
const errorMessage = `Hook execution failed for event '${eventName}' (hook: ${hookId}): ${error}`;
|
||||||
debugLogger.warn(`Hook execution error (non-fatal): ${errorMessage}`);
|
debugLogger.warn(`Hook execution error (non-fatal): ${errorMessage}`);
|
||||||
|
|
||||||
@@ -230,11 +244,66 @@ export class HookRunner {
|
|||||||
return modifiedInput;
|
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
|
* Execute a command hook
|
||||||
*/
|
*/
|
||||||
private async executeCommandHook(
|
private async executeCommandHook(
|
||||||
hookConfig: HookConfig,
|
hookConfig: CommandHookConfig,
|
||||||
eventName: HookEventName,
|
eventName: HookEventName,
|
||||||
input: HookInput,
|
input: HookInput,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ describe('HookSystem Integration', () => {
|
|||||||
matcher: 'TestTool',
|
matcher: 'TestTool',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
type: HookType.Command,
|
type: HookType.Command as const,
|
||||||
command: 'echo',
|
command: 'echo',
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
},
|
},
|
||||||
@@ -164,7 +164,8 @@ describe('HookSystem Integration', () => {
|
|||||||
{
|
{
|
||||||
type: 'invalid-type' as HookType, // Invalid hook type for testing
|
type: 'invalid-type' as HookType, // Invalid hook type for testing
|
||||||
command: './test.sh',
|
command: './test.sh',
|
||||||
},
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -279,12 +280,12 @@ describe('HookSystem Integration', () => {
|
|||||||
matcher: 'TestTool',
|
matcher: 'TestTool',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
type: HookType.Command,
|
type: HookType.Command as const,
|
||||||
command: 'echo "enabled-hook"',
|
command: 'echo "enabled-hook"',
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: HookType.Command,
|
type: HookType.Command as const,
|
||||||
command: 'echo "disabled-hook"',
|
command: 'echo "disabled-hook"',
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
},
|
},
|
||||||
@@ -350,7 +351,7 @@ describe('HookSystem Integration', () => {
|
|||||||
matcher: 'TestTool',
|
matcher: 'TestTool',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
type: HookType.Command,
|
type: HookType.Command as const,
|
||||||
command: 'echo "will-be-disabled"',
|
command: 'echo "will-be-disabled"',
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import type {
|
|||||||
AfterModelHookOutput,
|
AfterModelHookOutput,
|
||||||
BeforeToolSelectionHookOutput,
|
BeforeToolSelectionHookOutput,
|
||||||
McpToolContext,
|
McpToolContext,
|
||||||
|
HookConfig,
|
||||||
|
HookEventName,
|
||||||
|
ConfigSource,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { NotificationType } from './types.js';
|
import { NotificationType } from './types.js';
|
||||||
import type { AggregatedHookResult } from './hookAggregator.js';
|
import type { AggregatedHookResult } from './hookAggregator.js';
|
||||||
@@ -202,6 +205,17 @@ export class HookSystem {
|
|||||||
return this.hookRegistry.getAllHooks();
|
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
|
* 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import { TrustedHooksManager } from './trustedHooks.js';
|
import { TrustedHooksManager } from './trustedHooks.js';
|
||||||
import { Storage } from '../config/storage.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('node:fs');
|
||||||
vi.mock('../config/storage.js');
|
vi.mock('../config/storage.js');
|
||||||
@@ -72,8 +72,16 @@ describe('TrustedHooksManager', () => {
|
|||||||
[HookEventName.BeforeTool]: [
|
[HookEventName.BeforeTool]: [
|
||||||
{
|
{
|
||||||
hooks: [
|
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]: [
|
[HookEventName.BeforeTool]: [
|
||||||
{
|
{
|
||||||
hooks: [
|
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([
|
expect(
|
||||||
'./script.sh',
|
manager.getUntrustedHooks(
|
||||||
]);
|
'/project',
|
||||||
|
projectHooks as Partial<Record<HookEventName, HookDefinition[]>>,
|
||||||
|
),
|
||||||
|
).toEqual(['./script.sh']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should detect change in command as untrusted', () => {
|
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([
|
expect(
|
||||||
'my-hook',
|
manager.getUntrustedHooks(
|
||||||
]);
|
'/project',
|
||||||
|
updatedHook as Partial<Record<HookEventName, HookDefinition[]>>,
|
||||||
|
),
|
||||||
|
).toEqual(['my-hook']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import * as path from 'node:path';
|
|||||||
import { Storage } from '../config/storage.js';
|
import { Storage } from '../config/storage.js';
|
||||||
import {
|
import {
|
||||||
getHookKey,
|
getHookKey,
|
||||||
|
HookType,
|
||||||
type HookDefinition,
|
type HookDefinition,
|
||||||
type HookEventName,
|
type HookEventName,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
@@ -79,6 +80,7 @@ export class TrustedHooksManager {
|
|||||||
for (const def of definitions) {
|
for (const def of definitions) {
|
||||||
if (!def || !Array.isArray(def.hooks)) continue;
|
if (!def || !Array.isArray(def.hooks)) continue;
|
||||||
for (const hook of def.hooks) {
|
for (const hook of def.hooks) {
|
||||||
|
if (hook.type === HookType.Runtime) continue;
|
||||||
const key = getHookKey(hook);
|
const key = getHookKey(hook);
|
||||||
if (!trustedKeys.has(key)) {
|
if (!trustedKeys.has(key)) {
|
||||||
// Return friendly name or command
|
// Return friendly name or command
|
||||||
@@ -108,6 +110,7 @@ export class TrustedHooksManager {
|
|||||||
for (const def of definitions) {
|
for (const def of definitions) {
|
||||||
if (!def || !Array.isArray(def.hooks)) continue;
|
if (!def || !Array.isArray(def.hooks)) continue;
|
||||||
for (const hook of def.hooks) {
|
for (const hook of def.hooks) {
|
||||||
|
if (hook.type === HookType.Runtime) continue;
|
||||||
currentTrusted.add(getHookKey(hook));
|
currentTrusted.add(getHookKey(hook));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { defaultHookTranslator } from './hookTranslator.js';
|
|||||||
* Configuration source levels in precedence order (highest to lowest)
|
* Configuration source levels in precedence order (highest to lowest)
|
||||||
*/
|
*/
|
||||||
export enum ConfigSource {
|
export enum ConfigSource {
|
||||||
|
Runtime = 'runtime',
|
||||||
Project = 'project',
|
Project = 'project',
|
||||||
User = 'user',
|
User = 'user',
|
||||||
System = 'system',
|
System = 'system',
|
||||||
@@ -50,11 +51,43 @@ export enum HookEventName {
|
|||||||
export const HOOKS_CONFIG_FIELDS = ['enabled', 'disabled', 'notifications'];
|
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 {
|
export interface CommandHookConfig {
|
||||||
type: HookType.Command;
|
type: HookType.Command;
|
||||||
command: string;
|
command: string;
|
||||||
|
action?: never;
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
@@ -62,7 +95,7 @@ export interface CommandHookConfig {
|
|||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HookConfig = CommandHookConfig;
|
export type HookConfig = CommandHookConfig | RuntimeHookConfig;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook definition with matcher
|
* Hook definition with matcher
|
||||||
@@ -73,19 +106,12 @@ export interface HookDefinition {
|
|||||||
hooks: HookConfig[];
|
hooks: HookConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook implementation types
|
|
||||||
*/
|
|
||||||
export enum HookType {
|
|
||||||
Command = 'command',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique key for a hook configuration
|
* Generate a unique key for a hook configuration
|
||||||
*/
|
*/
|
||||||
export function getHookKey(hook: HookConfig): string {
|
export function getHookKey(hook: HookConfig): string {
|
||||||
const name = hook.name || '';
|
const name = hook.name || '';
|
||||||
const command = hook.command || '';
|
const command = hook.type === HookType.Command ? hook.command : '';
|
||||||
return `${name}:${command}`;
|
return `${name}:${command}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
WebFetchFallbackAttemptEvent,
|
WebFetchFallbackAttemptEvent,
|
||||||
HookCallEvent,
|
HookCallEvent,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
|
import { HookType } from '../../hooks/types.js';
|
||||||
import { AgentTerminateMode } from '../../agents/types.js';
|
import { AgentTerminateMode } from '../../agents/types.js';
|
||||||
import { GIT_COMMIT_INFO, CLI_VERSION } from '../../generated/git-commit.js';
|
import { GIT_COMMIT_INFO, CLI_VERSION } from '../../generated/git-commit.js';
|
||||||
import { UserAccountManager } from '../../utils/userAccountManager.js';
|
import { UserAccountManager } from '../../utils/userAccountManager.js';
|
||||||
@@ -1401,7 +1402,7 @@ describe('ClearcutLogger', () => {
|
|||||||
|
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'before-tool',
|
'before-tool',
|
||||||
'command',
|
HookType.Command,
|
||||||
hookName,
|
hookName,
|
||||||
{}, // input
|
{}, // input
|
||||||
150, // duration
|
150, // duration
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ import {
|
|||||||
EVENT_HOOK_CALL,
|
EVENT_HOOK_CALL,
|
||||||
LlmRole,
|
LlmRole,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { HookType } from '../hooks/types.js';
|
||||||
import * as metrics from './metrics.js';
|
import * as metrics from './metrics.js';
|
||||||
import { FileOperation } from './metrics.js';
|
import { FileOperation } from './metrics.js';
|
||||||
import * as sdk from './sdk.js';
|
import * as sdk from './sdk.js';
|
||||||
@@ -2327,7 +2328,7 @@ describe('loggers', () => {
|
|||||||
it('should log hook call event to Clearcut and OTEL', () => {
|
it('should log hook call event to Clearcut and OTEL', () => {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'before-tool',
|
'before-tool',
|
||||||
'command',
|
HookType.Command,
|
||||||
'/path/to/script.sh',
|
'/path/to/script.sh',
|
||||||
{ arg: 'val' },
|
{ arg: 'val' },
|
||||||
150,
|
150,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { HookCallEvent, EVENT_HOOK_CALL } from './types.js';
|
import { HookCallEvent, EVENT_HOOK_CALL } from './types.js';
|
||||||
|
import { HookType } from '../hooks/types.js';
|
||||||
import type { Config } from '../config/config.js';
|
import type { Config } from '../config/config.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +41,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
it('should create an event with all fields', () => {
|
it('should create an event with all fields', () => {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeTool',
|
'BeforeTool',
|
||||||
'command',
|
HookType.Command,
|
||||||
'test-hook',
|
'test-hook',
|
||||||
{ tool_name: 'ReadFile' },
|
{ tool_name: 'ReadFile' },
|
||||||
100,
|
100,
|
||||||
@@ -69,7 +70,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
it('should create an event with minimal fields', () => {
|
it('should create an event with minimal fields', () => {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeTool',
|
'BeforeTool',
|
||||||
'command',
|
HookType.Command,
|
||||||
'test-hook',
|
'test-hook',
|
||||||
{ tool_name: 'ReadFile' },
|
{ tool_name: 'ReadFile' },
|
||||||
100,
|
100,
|
||||||
@@ -90,7 +91,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
it('should include all fields when logPrompts is enabled', () => {
|
it('should include all fields when logPrompts is enabled', () => {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeTool',
|
'BeforeTool',
|
||||||
'command',
|
HookType.Command,
|
||||||
'/path/to/.gemini/hooks/check-secrets.sh --api-key=abc123',
|
'/path/to/.gemini/hooks/check-secrets.sh --api-key=abc123',
|
||||||
{ tool_name: 'ReadFile', args: { file: 'secret.txt' } },
|
{ tool_name: 'ReadFile', args: { file: 'secret.txt' } },
|
||||||
100,
|
100,
|
||||||
@@ -123,7 +124,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
it('should include hook_input and hook_output as JSON strings', () => {
|
it('should include hook_input and hook_output as JSON strings', () => {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeTool',
|
'BeforeTool',
|
||||||
'command',
|
HookType.Command,
|
||||||
'test-hook',
|
'test-hook',
|
||||||
{ tool_name: 'ReadFile', args: { file: 'test.txt' } },
|
{ tool_name: 'ReadFile', args: { file: 'test.txt' } },
|
||||||
100,
|
100,
|
||||||
@@ -154,7 +155,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
it('should exclude PII-sensitive fields when logPrompts is disabled', () => {
|
it('should exclude PII-sensitive fields when logPrompts is disabled', () => {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeTool',
|
'BeforeTool',
|
||||||
'command',
|
HookType.Command,
|
||||||
'/path/to/.gemini/hooks/check-secrets.sh --api-key=abc123',
|
'/path/to/.gemini/hooks/check-secrets.sh --api-key=abc123',
|
||||||
{ tool_name: 'ReadFile', args: { file: 'secret.txt' } },
|
{ tool_name: 'ReadFile', args: { file: 'secret.txt' } },
|
||||||
100,
|
100,
|
||||||
@@ -232,7 +233,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
for (const testCase of testCases) {
|
for (const testCase of testCases) {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeTool',
|
'BeforeTool',
|
||||||
'command',
|
HookType.Command,
|
||||||
testCase.input,
|
testCase.input,
|
||||||
{ tool_name: 'ReadFile' },
|
{ tool_name: 'ReadFile' },
|
||||||
100,
|
100,
|
||||||
@@ -248,7 +249,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
it('should still include error field even when logPrompts is disabled', () => {
|
it('should still include error field even when logPrompts is disabled', () => {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeTool',
|
'BeforeTool',
|
||||||
'command',
|
HookType.Command,
|
||||||
'test-hook',
|
'test-hook',
|
||||||
{ tool_name: 'ReadFile' },
|
{ tool_name: 'ReadFile' },
|
||||||
100,
|
100,
|
||||||
@@ -276,7 +277,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
it('should handle commands with multiple spaces', () => {
|
it('should handle commands with multiple spaces', () => {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeTool',
|
'BeforeTool',
|
||||||
'command',
|
HookType.Command,
|
||||||
'python script.py --arg1 --arg2',
|
'python script.py --arg1 --arg2',
|
||||||
{},
|
{},
|
||||||
100,
|
100,
|
||||||
@@ -290,7 +291,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
it('should handle mixed path separators', () => {
|
it('should handle mixed path separators', () => {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeTool',
|
'BeforeTool',
|
||||||
'command',
|
HookType.Command,
|
||||||
'/path/to\\mixed\\separators.sh',
|
'/path/to\\mixed\\separators.sh',
|
||||||
{},
|
{},
|
||||||
100,
|
100,
|
||||||
@@ -304,7 +305,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
it('should handle trailing slashes', () => {
|
it('should handle trailing slashes', () => {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeTool',
|
'BeforeTool',
|
||||||
'command',
|
HookType.Command,
|
||||||
'/path/to/directory/',
|
'/path/to/directory/',
|
||||||
{},
|
{},
|
||||||
100,
|
100,
|
||||||
@@ -320,7 +321,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
it('should format success message correctly', () => {
|
it('should format success message correctly', () => {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeTool',
|
'BeforeTool',
|
||||||
'command',
|
HookType.Command,
|
||||||
'test-hook',
|
'test-hook',
|
||||||
{},
|
{},
|
||||||
150,
|
150,
|
||||||
@@ -335,7 +336,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
it('should format failure message correctly', () => {
|
it('should format failure message correctly', () => {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'AfterTool',
|
'AfterTool',
|
||||||
'command',
|
HookType.Command,
|
||||||
'validation-hook',
|
'validation-hook',
|
||||||
{},
|
{},
|
||||||
75,
|
75,
|
||||||
@@ -354,7 +355,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
|
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeModel',
|
'BeforeModel',
|
||||||
'command',
|
HookType.Command,
|
||||||
'$GEMINI_PROJECT_DIR/.gemini/hooks/add-context.sh',
|
'$GEMINI_PROJECT_DIR/.gemini/hooks/add-context.sh',
|
||||||
{
|
{
|
||||||
llm_request: {
|
llm_request: {
|
||||||
@@ -394,7 +395,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
|
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeModel',
|
'BeforeModel',
|
||||||
'command',
|
HookType.Command,
|
||||||
'$GEMINI_PROJECT_DIR/.gemini/hooks/add-context.sh',
|
'$GEMINI_PROJECT_DIR/.gemini/hooks/add-context.sh',
|
||||||
{
|
{
|
||||||
llm_request: {
|
llm_request: {
|
||||||
@@ -438,7 +439,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
it('should sanitize commands with API keys', () => {
|
it('should sanitize commands with API keys', () => {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeTool',
|
'BeforeTool',
|
||||||
'command',
|
HookType.Command,
|
||||||
'curl https://api.example.com -H "Authorization: Bearer sk-abc123xyz"',
|
'curl https://api.example.com -H "Authorization: Bearer sk-abc123xyz"',
|
||||||
{},
|
{},
|
||||||
100,
|
100,
|
||||||
@@ -452,7 +453,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
it('should sanitize commands with database credentials', () => {
|
it('should sanitize commands with database credentials', () => {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeTool',
|
'BeforeTool',
|
||||||
'command',
|
HookType.Command,
|
||||||
'psql postgresql://user:password@localhost/db',
|
'psql postgresql://user:password@localhost/db',
|
||||||
{},
|
{},
|
||||||
100,
|
100,
|
||||||
@@ -466,7 +467,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
it('should sanitize commands with environment variables containing secrets', () => {
|
it('should sanitize commands with environment variables containing secrets', () => {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeTool',
|
'BeforeTool',
|
||||||
'command',
|
HookType.Command,
|
||||||
'AWS_SECRET_KEY=abc123 aws s3 ls',
|
'AWS_SECRET_KEY=abc123 aws s3 ls',
|
||||||
{},
|
{},
|
||||||
100,
|
100,
|
||||||
@@ -480,7 +481,7 @@ describe('Telemetry Sanitization', () => {
|
|||||||
it('should sanitize Python scripts with file paths', () => {
|
it('should sanitize Python scripts with file paths', () => {
|
||||||
const event = new HookCallEvent(
|
const event = new HookCallEvent(
|
||||||
'BeforeTool',
|
'BeforeTool',
|
||||||
'command',
|
HookType.Command,
|
||||||
'python /home/john.doe/projects/secret-scanner/scan.py --config=/etc/secrets.yml',
|
'python /home/john.doe/projects/secret-scanner/scan.py --config=/etc/secrets.yml',
|
||||||
{},
|
{},
|
||||||
100,
|
100,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { sanitizeHookName } from './sanitize.js';
|
|||||||
import { getFileDiffFromResultDisplay } from '../utils/fileDiffUtils.js';
|
import { getFileDiffFromResultDisplay } from '../utils/fileDiffUtils.js';
|
||||||
import { LlmRole } from './llmRole.js';
|
import { LlmRole } from './llmRole.js';
|
||||||
export { LlmRole };
|
export { LlmRole };
|
||||||
|
import type { HookType } from '../hooks/types.js';
|
||||||
|
|
||||||
export interface BaseTelemetryEvent {
|
export interface BaseTelemetryEvent {
|
||||||
'event.name': string;
|
'event.name': string;
|
||||||
@@ -2166,7 +2167,7 @@ export class HookCallEvent implements BaseTelemetryEvent {
|
|||||||
'event.name': string;
|
'event.name': string;
|
||||||
'event.timestamp': string;
|
'event.timestamp': string;
|
||||||
hook_event_name: string;
|
hook_event_name: string;
|
||||||
hook_type: 'command';
|
hook_type: HookType;
|
||||||
hook_name: string;
|
hook_name: string;
|
||||||
hook_input: Record<string, unknown>;
|
hook_input: Record<string, unknown>;
|
||||||
hook_output?: Record<string, unknown>;
|
hook_output?: Record<string, unknown>;
|
||||||
@@ -2179,7 +2180,7 @@ export class HookCallEvent implements BaseTelemetryEvent {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
hookEventName: string,
|
hookEventName: string,
|
||||||
hookType: 'command',
|
hookType: HookType,
|
||||||
hookName: string,
|
hookName: string,
|
||||||
hookInput: Record<string, unknown>,
|
hookInput: Record<string, unknown>,
|
||||||
durationMs: number,
|
durationMs: number,
|
||||||
|
|||||||
Reference in New Issue
Block a user