Add Folder Trust Support To Hooks (#15325)

This commit is contained in:
Sehoon Shon
2025-12-22 11:46:38 -05:00
committed by GitHub
parent d6a2f1d670
commit dced409ac4
10 changed files with 188 additions and 23 deletions
@@ -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)) {
+1 -2
View File
@@ -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(() => ({
+32 -2
View File
@@ -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: [
+11 -12
View File
@@ -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,
+91 -1
View File
@@ -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,
+24 -2
View File
@@ -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,
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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';
+11
View File
@@ -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;