mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-26 04:54:25 -07:00
Add Folder Trust Support To Hooks (#15325)
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
PolicyDecision,
|
PolicyDecision,
|
||||||
@@ -13,6 +13,21 @@ import {
|
|||||||
import { createPolicyEngineConfig } from './policy.js';
|
import { createPolicyEngineConfig } from './policy.js';
|
||||||
import type { Settings } from './settings.js';
|
import type { Settings } from './settings.js';
|
||||||
|
|
||||||
|
// Mock Storage to ensure tests are hermetic and don't read from user's home directory
|
||||||
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
|
const Storage = actual.Storage;
|
||||||
|
// Monkey-patch static methods
|
||||||
|
Storage.getUserPoliciesDir = () => '/non-existent/user/policies';
|
||||||
|
Storage.getSystemPoliciesDir = () => '/non-existent/system/policies';
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
Storage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('Policy Engine Integration Tests', () => {
|
describe('Policy Engine Integration Tests', () => {
|
||||||
describe('Policy configuration produces valid PolicyEngine config', () => {
|
describe('Policy configuration produces valid PolicyEngine config', () => {
|
||||||
it('should create a working PolicyEngine from basic settings', async () => {
|
it('should create a working PolicyEngine from basic settings', async () => {
|
||||||
|
|||||||
@@ -161,7 +161,6 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
|
|||||||
const userConfig: Record<string, TrustLevel> = {};
|
const userConfig: Record<string, TrustLevel> = {};
|
||||||
|
|
||||||
const userPath = getTrustedFoldersPath();
|
const userPath = getTrustedFoldersPath();
|
||||||
|
|
||||||
// Load user trusted folders
|
// Load user trusted folders
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(userPath)) {
|
if (fs.existsSync(userPath)) {
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { HookPlanner } from './hookPlanner.js';
|
import { HookPlanner } from './hookPlanner.js';
|
||||||
import type { HookRegistry, HookRegistryEntry } from './hookRegistry.js';
|
import type { HookRegistry, HookRegistryEntry } from './hookRegistry.js';
|
||||||
import { HookEventName, HookType } from './types.js';
|
import { ConfigSource, HookEventName, HookType } from './types.js';
|
||||||
import { ConfigSource } from './hookRegistry.js';
|
|
||||||
|
|
||||||
// Mock debugLogger using vi.hoisted
|
// Mock debugLogger using vi.hoisted
|
||||||
const mockDebugLogger = vi.hoisted(() => ({
|
const mockDebugLogger = vi.hoisted(() => ({
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import { HookRegistry, ConfigSource } from './hookRegistry.js';
|
import { HookRegistry } from './hookRegistry.js';
|
||||||
import type { Storage } from '../config/storage.js';
|
import type { Storage } from '../config/storage.js';
|
||||||
import { HookEventName, HookType } from './types.js';
|
import { ConfigSource, HookEventName, HookType } 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';
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ describe('HookRegistry', () => {
|
|||||||
getExtensions: vi.fn().mockReturnValue([]),
|
getExtensions: vi.fn().mockReturnValue([]),
|
||||||
getHooks: vi.fn().mockReturnValue({}),
|
getHooks: vi.fn().mockReturnValue({}),
|
||||||
getDisabledHooks: vi.fn().mockReturnValue([]),
|
getDisabledHooks: vi.fn().mockReturnValue([]),
|
||||||
|
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
hookRegistry = new HookRegistry(mockConfig);
|
hookRegistry = new HookRegistry(mockConfig);
|
||||||
@@ -68,6 +69,35 @@ describe('HookRegistry', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not load hooks if folder is not trusted', async () => {
|
||||||
|
vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false);
|
||||||
|
const mockHooksConfig = {
|
||||||
|
BeforeTool: [
|
||||||
|
{
|
||||||
|
hooks: [
|
||||||
|
{
|
||||||
|
type: 'command',
|
||||||
|
command: './hooks/test.sh',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockConfig.getHooks).mockReturnValue(
|
||||||
|
mockHooksConfig as unknown as {
|
||||||
|
[K in HookEventName]?: HookDefinition[];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await hookRegistry.initialize();
|
||||||
|
|
||||||
|
expect(hookRegistry.getAllHooks()).toHaveLength(0);
|
||||||
|
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
||||||
|
'Project hooks disabled because the folder is not trusted.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should load hooks from project configuration', async () => {
|
it('should load hooks from project configuration', async () => {
|
||||||
const mockHooksConfig = {
|
const mockHooksConfig = {
|
||||||
BeforeTool: [
|
BeforeTool: [
|
||||||
|
|||||||
@@ -6,19 +6,9 @@
|
|||||||
|
|
||||||
import type { Config } from '../config/config.js';
|
import type { Config } from '../config/config.js';
|
||||||
import type { HookDefinition, HookConfig } from './types.js';
|
import type { HookDefinition, HookConfig } from './types.js';
|
||||||
import { HookEventName } from './types.js';
|
import { HookEventName, ConfigSource } from './types.js';
|
||||||
import { debugLogger } from '../utils/debugLogger.js';
|
import { debugLogger } from '../utils/debugLogger.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration source levels in precedence order (highest to lowest)
|
|
||||||
*/
|
|
||||||
export enum ConfigSource {
|
|
||||||
Project = 'project',
|
|
||||||
User = 'user',
|
|
||||||
System = 'system',
|
|
||||||
Extensions = 'extensions',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook registry entry with source information
|
* Hook registry entry with source information
|
||||||
*/
|
*/
|
||||||
@@ -111,7 +101,13 @@ export class HookRegistry {
|
|||||||
// Get hooks from the main config (this comes from the merged settings)
|
// Get hooks from the main config (this comes from the merged settings)
|
||||||
const configHooks = this.config.getHooks();
|
const configHooks = this.config.getHooks();
|
||||||
if (configHooks) {
|
if (configHooks) {
|
||||||
this.processHooksConfiguration(configHooks, ConfigSource.Project);
|
if (this.config.isTrustedFolder()) {
|
||||||
|
this.processHooksConfiguration(configHooks, ConfigSource.Project);
|
||||||
|
} else {
|
||||||
|
debugLogger.warn(
|
||||||
|
'Project hooks disabled because the folder is not trusted.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get hooks from extensions
|
// Get hooks from extensions
|
||||||
@@ -189,6 +185,9 @@ export class HookRegistry {
|
|||||||
} as HookRegistryEntry);
|
} as HookRegistryEntry);
|
||||||
const isDisabled = disabledHooks.includes(hookName);
|
const isDisabled = disabledHooks.includes(hookName);
|
||||||
|
|
||||||
|
// Add source to hook config
|
||||||
|
hookConfig.source = source;
|
||||||
|
|
||||||
this.entries.push({
|
this.entries.push({
|
||||||
config: hookConfig,
|
config: hookConfig,
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { HookEventName, HookType } from './types.js';
|
|||||||
import type { HookConfig } from './types.js';
|
import type { HookConfig } from './types.js';
|
||||||
import type { HookInput } from './types.js';
|
import type { HookInput } from './types.js';
|
||||||
import type { Readable, Writable } from 'node:stream';
|
import type { Readable, Writable } from 'node:stream';
|
||||||
|
import type { Config } from '../config/config.js';
|
||||||
|
import { ConfigSource } from './types.js';
|
||||||
|
|
||||||
// Mock type for the child_process spawn
|
// Mock type for the child_process spawn
|
||||||
type MockChildProcessWithoutNullStreams = ChildProcessWithoutNullStreams & {
|
type MockChildProcessWithoutNullStreams = ChildProcessWithoutNullStreams & {
|
||||||
@@ -53,6 +55,7 @@ vi.stubGlobal('console', mockConsole);
|
|||||||
describe('HookRunner', () => {
|
describe('HookRunner', () => {
|
||||||
let hookRunner: HookRunner;
|
let hookRunner: HookRunner;
|
||||||
let mockSpawn: MockChildProcessWithoutNullStreams;
|
let mockSpawn: MockChildProcessWithoutNullStreams;
|
||||||
|
let mockConfig: Config;
|
||||||
|
|
||||||
const mockInput: HookInput = {
|
const mockInput: HookInput = {
|
||||||
session_id: 'test-session',
|
session_id: 'test-session',
|
||||||
@@ -65,7 +68,11 @@ describe('HookRunner', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
|
||||||
hookRunner = new HookRunner();
|
mockConfig = {
|
||||||
|
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
hookRunner = new HookRunner(mockConfig);
|
||||||
|
|
||||||
// Mock spawn with accessible mock functions
|
// Mock spawn with accessible mock functions
|
||||||
const mockStdoutOn = vi.fn();
|
const mockStdoutOn = vi.fn();
|
||||||
@@ -100,6 +107,89 @@ describe('HookRunner', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('executeHook', () => {
|
describe('executeHook', () => {
|
||||||
|
describe('security checks', () => {
|
||||||
|
it('should block project hooks in untrusted folders', async () => {
|
||||||
|
vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false);
|
||||||
|
|
||||||
|
const projectHookConfig: HookConfig = {
|
||||||
|
type: HookType.Command,
|
||||||
|
command: './hooks/test.sh',
|
||||||
|
source: ConfigSource.Project,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await hookRunner.executeHook(
|
||||||
|
projectHookConfig,
|
||||||
|
HookEventName.BeforeTool,
|
||||||
|
mockInput,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error?.message).toContain(
|
||||||
|
'Security: Blocked execution of project hook in untrusted folder',
|
||||||
|
);
|
||||||
|
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Security: Blocked execution'),
|
||||||
|
);
|
||||||
|
expect(spawn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow project hooks in trusted folders', async () => {
|
||||||
|
vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(true);
|
||||||
|
|
||||||
|
const projectHookConfig: HookConfig = {
|
||||||
|
type: HookType.Command,
|
||||||
|
command: './hooks/test.sh',
|
||||||
|
source: ConfigSource.Project,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock successful execution
|
||||||
|
mockSpawn.mockProcessOn.mockImplementation(
|
||||||
|
(event: string, callback: (code: number) => void) => {
|
||||||
|
if (event === 'close') {
|
||||||
|
setTimeout(() => callback(0), 10);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await hookRunner.executeHook(
|
||||||
|
projectHookConfig,
|
||||||
|
HookEventName.BeforeTool,
|
||||||
|
mockInput,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(spawn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow non-project hooks even in untrusted folders', async () => {
|
||||||
|
vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false);
|
||||||
|
|
||||||
|
const systemHookConfig: HookConfig = {
|
||||||
|
type: HookType.Command,
|
||||||
|
command: './hooks/test.sh',
|
||||||
|
source: ConfigSource.System,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock successful execution
|
||||||
|
mockSpawn.mockProcessOn.mockImplementation(
|
||||||
|
(event: string, callback: (code: number) => void) => {
|
||||||
|
if (event === 'close') {
|
||||||
|
setTimeout(() => callback(0), 10);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await hookRunner.executeHook(
|
||||||
|
systemHookConfig,
|
||||||
|
HookEventName.BeforeTool,
|
||||||
|
mockInput,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(spawn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('command hooks', () => {
|
describe('command hooks', () => {
|
||||||
const commandConfig: HookConfig = {
|
const commandConfig: HookConfig = {
|
||||||
type: HookType.Command,
|
type: HookType.Command,
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { HookConfig } from './types.js';
|
import type { HookConfig } from './types.js';
|
||||||
import { HookEventName } from './types.js';
|
import { HookEventName, ConfigSource } from './types.js';
|
||||||
|
import type { Config } from '../config/config.js';
|
||||||
import type {
|
import type {
|
||||||
HookInput,
|
HookInput,
|
||||||
HookOutput,
|
HookOutput,
|
||||||
@@ -39,7 +40,11 @@ const EXIT_CODE_NON_BLOCKING_ERROR = 1;
|
|||||||
* Hook runner that executes command hooks
|
* Hook runner that executes command hooks
|
||||||
*/
|
*/
|
||||||
export class HookRunner {
|
export class HookRunner {
|
||||||
constructor() {}
|
private readonly config: Config;
|
||||||
|
|
||||||
|
constructor(config: Config) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a single hook
|
* Execute a single hook
|
||||||
@@ -51,6 +56,23 @@ export class HookRunner {
|
|||||||
): Promise<HookExecutionResult> {
|
): Promise<HookExecutionResult> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Secondary security check: Ensure project hooks are not executed in untrusted folders
|
||||||
|
if (
|
||||||
|
hookConfig.source === ConfigSource.Project &&
|
||||||
|
!this.config.isTrustedFolder()
|
||||||
|
) {
|
||||||
|
const errorMessage =
|
||||||
|
'Security: Blocked execution of project hook in untrusted folder';
|
||||||
|
debugLogger.warn(errorMessage);
|
||||||
|
return {
|
||||||
|
hookConfig,
|
||||||
|
eventName,
|
||||||
|
success: false,
|
||||||
|
error: new Error(errorMessage),
|
||||||
|
duration: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.executeCommandHook(
|
return await this.executeCommandHook(
|
||||||
hookConfig,
|
hookConfig,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export class HookSystem {
|
|||||||
|
|
||||||
// Initialize components
|
// Initialize components
|
||||||
this.hookRegistry = new HookRegistry(config);
|
this.hookRegistry = new HookRegistry(config);
|
||||||
this.hookRunner = new HookRunner();
|
this.hookRunner = new HookRunner(config);
|
||||||
this.hookAggregator = new HookAggregator();
|
this.hookAggregator = new HookAggregator();
|
||||||
this.hookPlanner = new HookPlanner(this.hookRegistry);
|
this.hookPlanner = new HookPlanner(this.hookRegistry);
|
||||||
this.hookEventHandler = new HookEventHandler(
|
this.hookEventHandler = new HookEventHandler(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export { HookEventHandler } from './hookEventHandler.js';
|
|||||||
|
|
||||||
// Export interfaces and enums
|
// Export interfaces and enums
|
||||||
export type { HookRegistryEntry } from './hookRegistry.js';
|
export type { HookRegistryEntry } from './hookRegistry.js';
|
||||||
export { ConfigSource } from './hookRegistry.js';
|
export { ConfigSource } from './types.js';
|
||||||
export type { AggregatedHookResult } from './hookAggregator.js';
|
export type { AggregatedHookResult } from './hookAggregator.js';
|
||||||
export type { HookEventContext } from './hookPlanner.js';
|
export type { HookEventContext } from './hookPlanner.js';
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ import type {
|
|||||||
} from './hookTranslator.js';
|
} from './hookTranslator.js';
|
||||||
import { defaultHookTranslator } from './hookTranslator.js';
|
import { defaultHookTranslator } from './hookTranslator.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration source levels in precedence order (highest to lowest)
|
||||||
|
*/
|
||||||
|
export enum ConfigSource {
|
||||||
|
Project = 'project',
|
||||||
|
User = 'user',
|
||||||
|
System = 'system',
|
||||||
|
Extensions = 'extensions',
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event names for the hook system
|
* Event names for the hook system
|
||||||
*/
|
*/
|
||||||
@@ -43,6 +53,7 @@ export interface CommandHookConfig {
|
|||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
source?: ConfigSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HookConfig = CommandHookConfig;
|
export type HookConfig = CommandHookConfig;
|
||||||
|
|||||||
Reference in New Issue
Block a user