Hooks on Gemini CLI: Best practices
This guide covers security considerations, performance optimization, debugging techniques, and privacy considerations for developing and deploying hooks in Gemini CLI.
Security considerations
Section titled “Security considerations”Validate all inputs
Section titled “Validate all inputs”Never trust data from hooks without validation. Hook inputs may contain user-provided data that could be malicious:
#!/usr/bin/env bashinput=$(cat)
# Validate JSON structureif ! echo "$input" | jq empty 2>/dev/null; then echo "Invalid JSON input" >&2 exit 1fi
# Validate required fieldstool_name=$(echo "$input" | jq -r '.tool_name // empty')if [ -z "$tool_name" ]; then echo "Missing tool_name field" >&2 exit 1fiUse timeouts
Section titled “Use timeouts”Set reasonable timeouts to prevent hooks from hanging indefinitely:
{ "hooks": { "BeforeTool": [ { "matcher": "*", "hooks": [ { "name": "slow-validator", "type": "command", "command": "./hooks/validate.sh", "timeout": 5000 } ] } ] }}Recommended timeouts:
- Fast validation: 1000-5000ms
- Network requests: 10000-30000ms
- Heavy computation: 30000-60000ms
Limit permissions
Section titled “Limit permissions”Run hooks with minimal required permissions:
#!/usr/bin/env bash# Don't run as rootif [ "$EUID" -eq 0 ]; then echo "Hook should not run as root" >&2 exit 1fi
# Check file permissions before writingif [ -w "$file_path" ]; then # Safe to writeelse echo "Insufficient permissions" >&2 exit 1fiScan for secrets
Section titled “Scan for secrets”Use BeforeTool hooks to prevent committing sensitive data:
const SECRET_PATTERNS = [ /api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i, /password\s*[:=]\s*['"]?[^\s'"]{8,}['"]?/i, /secret\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i, /AKIA[0-9A-Z]{16}/, // AWS access key /ghp_[a-zA-Z0-9]{36}/, // GitHub personal access token /sk-[a-zA-Z0-9]{48}/, // OpenAI API key];
function containsSecret(content) { return SECRET_PATTERNS.some((pattern) => pattern.test(content));}Review external scripts
Section titled “Review external scripts”Always review hook scripts from untrusted sources before enabling them:
# Review before installingcat third-party-hook.sh | less
# Check for suspicious patternsgrep -E 'curl|wget|ssh|eval' third-party-hook.sh
# Verify hook sourcels -la third-party-hook.shSandbox untrusted hooks
Section titled “Sandbox untrusted hooks”For maximum security, consider running untrusted hooks in isolated environments:
# Run hook in Docker containerdocker run --rm \ -v "$GEMINI_PROJECT_DIR:/workspace:ro" \ -i untrusted-hook-image \ /hook-script.sh < input.jsonPerformance
Section titled “Performance”Keep hooks fast
Section titled “Keep hooks fast”Hooks run synchronously—slow hooks delay the agent loop. Optimize for speed by using parallel operations:
// Sequential operations are slowerconst data1 = await fetch(url1).then((r) => r.json());const data2 = await fetch(url2).then((r) => r.json());const data3 = await fetch(url3).then((r) => r.json());
// Prefer parallel operations for better performanceconst [data1, data2, data3] = await Promise.all([ fetch(url1).then((r) => r.json()), fetch(url2).then((r) => r.json()), fetch(url3).then((r) => r.json()),]);Cache expensive operations
Section titled “Cache expensive operations”Store results between invocations to avoid repeated computation:
const fs = require('fs');const path = require('path');
const CACHE_FILE = '.gemini/hook-cache.json';
function readCache() { try { return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch { return {}; }}
function writeCache(data) { fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));}
async function main() { const cache = readCache(); const cacheKey = `tool-list-${(Date.now() / 3600000) | 0}`; // Hourly cache
if (cache[cacheKey]) { console.log(JSON.stringify(cache[cacheKey])); return; }
// Expensive operation const result = await computeExpensiveResult(); cache[cacheKey] = result; writeCache(cache);
console.log(JSON.stringify(result));}Use appropriate events
Section titled “Use appropriate events”Choose hook events that match your use case to avoid unnecessary execution.
AfterAgent fires once per agent loop completion, while AfterModel fires
after every LLM call (potentially multiple times per loop):
// If checking final completion, use AfterAgent instead of AfterModel{ "hooks": { "AfterAgent": [ { "matcher": "*", "hooks": [ { "name": "final-checker", "command": "./check-completion.sh" } ] } ] }}Filter with matchers
Section titled “Filter with matchers”Use specific matchers to avoid unnecessary hook execution. Instead of matching
all tools with *, specify only the tools you need:
{ "matcher": "write_file|replace", "hooks": [ { "name": "validate-writes", "command": "./validate.sh" } ]}Optimize JSON parsing
Section titled “Optimize JSON parsing”For large inputs, use streaming JSON parsers to avoid loading everything into memory:
// Standard approach: parse entire inputconst input = JSON.parse(await readStdin());const content = input.tool_input.content;
// For very large inputs: stream and extract only needed fieldsconst { createReadStream } = require('fs');const JSONStream = require('JSONStream');
const stream = createReadStream(0).pipe(JSONStream.parse('tool_input.content'));let content = '';stream.on('data', (chunk) => { content += chunk;});Debugging
Section titled “Debugging”Log to files
Section titled “Log to files”Write debug information to dedicated log files:
#!/usr/bin/env bashLOG_FILE=".gemini/hooks/debug.log"
# Log with timestamplog() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"}
input=$(cat)log "Received input: ${input:0:100}..."
# Hook logic here
log "Hook completed successfully"Use stderr for errors
Section titled “Use stderr for errors”Error messages on stderr are surfaced appropriately based on exit codes:
try { const result = dangerousOperation(); console.log(JSON.stringify({ result }));} catch (error) { console.error(`Hook error: ${error.message}`); process.exit(2); // Blocking error}Test hooks independently
Section titled “Test hooks independently”Run hook scripts manually with sample JSON input:
# Create test inputcat > test-input.json << 'EOF'{ "session_id": "test-123", "cwd": "/tmp/test", "hook_event_name": "BeforeTool", "tool_name": "write_file", "tool_input": { "file_path": "test.txt", "content": "Test content" }}EOF
# Test the hookcat test-input.json | .gemini/hooks/my-hook.sh
# Check exit codeecho "Exit code: $?"Check exit codes
Section titled “Check exit codes”Ensure your script returns the correct exit code:
#!/usr/bin/env bashset -e # Exit on error
# Hook logicprocess_input() { # ...}
if process_input; then echo "Success message" exit 0else echo "Error message" >&2 exit 2fiEnable telemetry
Section titled “Enable telemetry”Hook execution is logged when telemetry.logPrompts is enabled:
{ "telemetry": { "logPrompts": true }}View hook telemetry in logs to debug execution issues.
Use hook panel
Section titled “Use hook panel”The /hooks panel command shows execution status and recent output:
/hooks panelCheck for:
- Hook execution counts
- Recent successes/failures
- Error messages
- Execution timing
Development
Section titled “Development”Start simple
Section titled “Start simple”Begin with basic logging hooks before implementing complex logic:
#!/usr/bin/env bash# Simple logging hook to understand input structureinput=$(cat)echo "$input" >> .gemini/hook-inputs.logecho "Logged input"Use JSON libraries
Section titled “Use JSON libraries”Parse JSON with proper libraries instead of text processing:
Bad:
# Fragile text parsingtool_name=$(echo "$input" | grep -oP '"tool_name":\s*"\K[^"]+')Good:
# Robust JSON parsingtool_name=$(echo "$input" | jq -r '.tool_name')Make scripts executable
Section titled “Make scripts executable”Always make hook scripts executable:
chmod +x .gemini/hooks/*.shchmod +x .gemini/hooks/*.jsVersion control
Section titled “Version control”Commit hooks to share with your team:
git add .gemini/hooks/git add .gemini/settings.jsongit commit -m "Add project hooks for security and testing".gitignore considerations:
# Ignore hook cache and logs.gemini/hook-cache.json.gemini/hook-debug.log.gemini/memory/session-*.jsonl
# Keep hook scripts!.gemini/hooks/*.sh!.gemini/hooks/*.jsDocument behavior
Section titled “Document behavior”Add descriptions to help others understand your hooks:
{ "hooks": { "BeforeTool": [ { "matcher": "write_file|replace", "hooks": [ { "name": "secret-scanner", "type": "command", "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh", "description": "Scans code changes for API keys, passwords, and other secrets before writing" } ] } ] }}Add comments in hook scripts:
#!/usr/bin/env node/** * RAG Tool Filter Hook * * This hook reduces the tool space from 100+ tools to ~15 relevant ones * by extracting keywords from the user's request and filtering tools * based on semantic similarity. * * Performance: ~500ms average, cached tool embeddings * Dependencies: @google/generative-ai */Troubleshooting
Section titled “Troubleshooting”Hook not executing
Section titled “Hook not executing”Check hook name in /hooks panel:
/hooks panelVerify the hook appears in the list and is enabled.
Verify matcher pattern:
# Test regex patternecho "write_file|replace" | grep -E "write_.*|replace"Check disabled list:
{ "hooks": { "disabled": ["my-hook-name"] }}Ensure script is executable:
ls -la .gemini/hooks/my-hook.shchmod +x .gemini/hooks/my-hook.shVerify script path:
# Check path expansionecho "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh"
# Verify file existstest -f "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" && echo "File exists"Hook timing out
Section titled “Hook timing out”Check configured timeout:
{ "name": "slow-hook", "timeout": 60000}Optimize slow operations:
// Before: Sequential operations (slow)for (const item of items) { await processItem(item);}
// After: Parallel operations (fast)await Promise.all(items.map((item) => processItem(item)));Use caching:
const cache = new Map();
async function getCachedData(key) { if (cache.has(key)) { return cache.get(key); } const data = await fetchData(key); cache.set(key, data); return data;}Consider splitting into multiple faster hooks:
{ "hooks": { "BeforeTool": [ { "matcher": "write_file", "hooks": [ { "name": "quick-check", "command": "./quick-validation.sh", "timeout": 1000 } ] }, { "matcher": "write_file", "hooks": [ { "name": "deep-check", "command": "./deep-analysis.sh", "timeout": 30000 } ] } ] }}Invalid JSON output
Section titled “Invalid JSON output”Validate JSON before outputting:
#!/usr/bin/env bashoutput='{"decision": "allow"}'
# Validate JSONif echo "$output" | jq empty 2>/dev/null; then echo "$output"else echo "Invalid JSON generated" >&2 exit 1fiEnsure proper quoting and escaping:
// Bad: Unescaped string interpolationconst message = `User said: ${userInput}`;console.log(JSON.stringify({ message }));
// Good: Automatic escapingconsole.log(JSON.stringify({ message: `User said: ${userInput}` }));Check for binary data or control characters:
function sanitizeForJSON(str) { return str.replace(/[\x00-\x1F\x7F-\x9F]/g, ''); // Remove control chars}
const cleanContent = sanitizeForJSON(content);console.log(JSON.stringify({ content: cleanContent }));Exit code issues
Section titled “Exit code issues”Verify script returns correct codes:
#!/usr/bin/env bashset -e # Exit on error
# Processing logicif validate_input; then echo "Success" exit 0else echo "Validation failed" >&2 exit 2fiCheck for unintended errors:
#!/usr/bin/env bash# Don't use 'set -e' if you want to handle errors explicitly# set -e
if ! command_that_might_fail; then # Handle error echo "Command failed but continuing" >&2fi
# Always exit explicitlyexit 0Use trap for cleanup:
#!/usr/bin/env bash
cleanup() { # Cleanup logic rm -f /tmp/hook-temp-*}
trap cleanup EXIT
# Hook logic hereEnvironment variables not available
Section titled “Environment variables not available”Check if variable is set:
#!/usr/bin/env bash
if [ -z "$GEMINI_PROJECT_DIR" ]; then echo "GEMINI_PROJECT_DIR not set" >&2 exit 1fi
if [ -z "$CUSTOM_VAR" ]; then echo "Warning: CUSTOM_VAR not set, using default" >&2 CUSTOM_VAR="default-value"fiDebug available variables:
#!/usr/bin/env bash
# List all environment variablesenv > .gemini/hook-env.log
# Check specific variablesecho "GEMINI_PROJECT_DIR: $GEMINI_PROJECT_DIR" >> .gemini/hook-env.logecho "GEMINI_SESSION_ID: $GEMINI_SESSION_ID" >> .gemini/hook-env.logecho "GEMINI_API_KEY: ${GEMINI_API_KEY:+<set>}" >> .gemini/hook-env.logUse .env files:
#!/usr/bin/env bash
# Load .env file if it existsif [ -f "$GEMINI_PROJECT_DIR/.env" ]; then source "$GEMINI_PROJECT_DIR/.env"fiPrivacy considerations
Section titled “Privacy considerations”Hook inputs and outputs may contain sensitive information. Gemini CLI respects
the telemetry.logPrompts setting for hook data logging.
What data is collected
Section titled “What data is collected”Hook telemetry may include:
- Hook inputs: User prompts, tool arguments, file contents
- Hook outputs: Hook responses, decision reasons, added context
- Standard streams: stdout and stderr from hook processes
- Execution metadata: Hook name, event type, duration, success/failure
Privacy settings
Section titled “Privacy settings”Enabled (default):
Full hook I/O is logged to telemetry. Use this when:
- Developing and debugging hooks
- Telemetry is redirected to a trusted enterprise system
- You understand and accept the privacy implications
Disabled:
Only metadata is logged (event name, duration, success/failure). Hook inputs and outputs are excluded. Use this when:
- Sending telemetry to third-party systems
- Working with sensitive data
- Privacy regulations require minimizing data collection
Configuration
Section titled “Configuration”Disable PII logging in settings:
{ "telemetry": { "logPrompts": false }}Disable via environment variable:
export GEMINI_TELEMETRY_LOG_PROMPTS=falseSensitive data in hooks
Section titled “Sensitive data in hooks”If your hooks process sensitive data:
- Minimize logging: Don’t write sensitive data to log files
- Sanitize outputs: Remove sensitive data before outputting
- Use secure storage: Encrypt sensitive data at rest
- Limit access: Restrict hook script permissions
Example sanitization:
function sanitizeOutput(data) { const sanitized = { ...data };
// Remove sensitive fields delete sanitized.apiKey; delete sanitized.password;
// Redact sensitive strings if (sanitized.content) { sanitized.content = sanitized.content.replace( /api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/gi, '[REDACTED]', ); }
return sanitized;}
console.log(JSON.stringify(sanitizeOutput(hookOutput)));Learn more
Section titled “Learn more”- Hooks Reference - Complete API reference
- Writing Hooks - Tutorial and examples
- Configuration - Gemini CLI settings