Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
888 changes: 888 additions & 0 deletions integration-tests/hooks-system.test.ts

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions integration-tests/test-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,16 @@ export class TestRig {
return logs;
}

readAllApiRequest(): ParsedLog[] {
const logs = this._readAndParseTelemetryLog();
const apiRequests = logs.filter(
(logData) =>
logData.attributes &&
logData.attributes['event.name'] === 'gemini_cli.api_request',
);
return apiRequests;
}

readLastApiRequest(): ParsedLog | null {
const logs = this._readAndParseTelemetryLog();
const apiRequests = logs.filter(
Expand Down Expand Up @@ -1019,4 +1029,47 @@ export class TestRig {
await run.expectText(' Type your message or @path/to/file', 30000);
return run;
}

readHookLogs() {
const parsedLogs = this._readAndParseTelemetryLog();
const logs: {
hookCall: {
hook_event_name: string;
hook_name: string;
hook_input: Record<string, unknown>;
hook_output: Record<string, unknown>;
exit_code: number;
stdout: string;
stderr: string;
duration_ms: number;
success: boolean;
error: string;
};
}[] = [];

for (const logData of parsedLogs) {
// Look for tool call logs
if (
logData.attributes &&
logData.attributes['event.name'] === 'gemini_cli.hook_call'
) {
logs.push({
hookCall: {
hook_event_name: logData.attributes.hook_event_name ?? '',
hook_name: logData.attributes.hook_name ?? '',
hook_input: logData.attributes.hook_input ?? {},
hook_output: logData.attributes.hook_output ?? {},
exit_code: logData.attributes.exit_code ?? 0,
stdout: logData.attributes.stdout ?? '',
stderr: logData.attributes.stderr ?? '',
duration_ms: logData.attributes.duration_ms ?? 0,
success: logData.attributes.success ?? false,
error: logData.attributes.error ?? '',
},
});
}
}

return logs;
}
}
2 changes: 1 addition & 1 deletion integration-tests/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default defineConfig({
globalSetup: './globalSetup.ts',
reporters: ['default'],
include: ['**/*.test.ts'],
retry: 2,
retry: 0,
fileParallelism: true,
poolOptions: {
threads: {
Expand Down
6 changes: 6 additions & 0 deletions packages/a2a-server/src/utils/testing_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
GeminiClient,
HookSystem,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
import type { Config, Storage } from '@google/gemini-cli-core';
import { expect, vi } from 'vitest';

Expand Down Expand Up @@ -56,6 +58,10 @@ export function createMockConfig(
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
...overrides,
} as unknown as Config;
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
mockConfig.getHookSystem = vi
.fn()
.mockReturnValue(new HookSystem(mockConfig));

mockConfig.getGeminiClient = vi
.fn()
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/ui/hooks/useToolScheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ import {
ToolConfirmationOutcome,
ApprovalMode,
MockTool,
HookSystem,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
import { ToolCallStatus } from '../types.js';

// Mocks
Expand Down Expand Up @@ -81,6 +83,8 @@ const mockConfig = {
getMessageBus: () => null,
getPolicyEngine: () => null,
} as unknown as Config;
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
mockConfig.getHookSystem = vi.fn().mockReturnValue(new HookSystem(mockConfig));

const mockTool = new MockTool({
name: 'mockTool',
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ import type { Mock } from 'vitest';
import type { ConfigParameters, SandboxConfig } from './config.js';
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from './config.js';
import { ApprovalMode } from '../policy/types.js';
import type { HookDefinition } from '../hooks/types.js';
import { HookType, HookEventName } from '../hooks/types.js';
import {
type HookDefinition,
HookEventName,
HookType,
} from '../hooks/types.js';
import * as path from 'node:path';
import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../tools/memoryTool.js';
import {
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import type { EventEmitter } from 'node:events';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import { PolicyEngine } from '../policy/policy-engine.js';
import type { PolicyEngineConfig } from '../policy/types.js';
import { HookSystem } from '../hooks/index.js';
import type { UserTierId } from '../code_assist/types.js';
import { getCodeAssistServer } from '../code_assist/codeAssist.js';
import type { Experiments } from '../code_assist/experiments/experiments.js';
Expand Down Expand Up @@ -403,6 +404,7 @@ export class Config {
| undefined;
private experiments: Experiments | undefined;
private experimentsPromise: Promise<void> | undefined;
private hookSystem?: HookSystem;

constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
Expand Down Expand Up @@ -592,6 +594,12 @@ export class Config {
await this.getExtensionLoader().start(this),
]);

// Initialize hook system if enabled
if (this.enableHooks) {
this.hookSystem = new HookSystem(this);
await this.hookSystem.initialize();
}

await this.geminiClient.initialize();
}

Expand Down Expand Up @@ -1352,6 +1360,13 @@ export class Config {
return registry;
}

/**
* Get the hook system instance
*/
getHookSystem(): HookSystem | undefined {
return this.hookSystem;
}

/**
* Get hooks configuration
*/
Expand Down
84 changes: 83 additions & 1 deletion packages/core/src/confirmation-bus/message-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
MessageBusType,
type Message,
type HookExecutionRequest,
type HookPolicyDecision,
} from './types.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';

export class MessageBus extends EventEmitter {
Expand Down Expand Up @@ -80,6 +86,40 @@ 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 = this.policyEngine.checkHook(hookRequest);

// Emit policy decision for observability
this.emitMessage({
type: MessageBusType.HOOK_POLICY_DECISION,
eventName: hookRequest.eventName,
hookSource:
(hookRequest.input['hook_source'] as
| 'project'
| 'user'
| 'system'
| 'extension') || 'project',
decision: decision === PolicyDecision.ALLOW ? 'allow' : 'deny',
reason:
decision === PolicyDecision.DENY
? '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, emit error response
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);
Expand All @@ -102,4 +142,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);
});
}
}
31 changes: 30 additions & 1 deletion packages/core/src/confirmation-bus/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -54,10 +57,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;
7 changes: 7 additions & 0 deletions packages/core/src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { ideContextStore } from '../ide/ideContext.js';
import type { ModelRouterService } from '../routing/modelRouterService.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
import { ChatCompressionService } from '../services/chatCompressionService.js';
import { HookSystem } from '../hooks/hookSystem.js';

vi.mock('../services/chatCompressionService.js');

Expand Down Expand Up @@ -119,6 +120,7 @@ vi.mock('../telemetry/uiTelemetry.js', () => ({
getLastPromptTokenCount: vi.fn(),
},
}));
vi.mock('../hooks/hookSystem.js');

/**
* Array.fromAsync ponyfill, which will be available in es 2024.
Expand Down Expand Up @@ -242,6 +244,8 @@ describe('Gemini Client (client.ts)', () => {
getModelRouterService: vi.fn().mockReturnValue({
route: vi.fn().mockResolvedValue({ model: 'default-routed-model' }),
}),
getMessageBus: vi.fn().mockReturnValue(undefined),
getEnableHooks: vi.fn().mockReturnValue(false),
isInFallbackMode: vi.fn().mockReturnValue(false),
setFallbackMode: vi.fn(),
getChatCompression: vi.fn().mockReturnValue(undefined),
Expand All @@ -261,6 +265,9 @@ describe('Gemini Client (client.ts)', () => {
}),
}),
} as unknown as Config;
mockConfig.getHookSystem = vi
.fn()
.mockReturnValue(new HookSystem(mockConfig));

client = new GeminiClient(mockConfig);
await client.initialize();
Expand Down
Loading
Loading