From 0a173cac46c1bdc230faf57c9ce1b6bb91c4f45f Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:13:15 -0500 Subject: [PATCH] docs(hooks): comprehensive update of hook documentation and specs (#16816) --- docs/hooks/best-practices.md | 681 ++++++++-------------- docs/hooks/index.md | 785 ++++--------------------- docs/hooks/reference.md | 393 ++++++++----- docs/hooks/writing-hooks.md | 1052 +++++++--------------------------- 4 files changed, 835 insertions(+), 2076 deletions(-) diff --git a/docs/hooks/best-practices.md b/docs/hooks/best-practices.md index 663066c0c8..559f3f18bb 100644 --- a/docs/hooks/best-practices.md +++ b/docs/hooks/best-practices.md @@ -1,4 +1,4 @@ -# Hooks on Gemini CLI: Best practices +# Hooks Best Practices This guide covers security considerations, performance optimization, debugging techniques, and privacy considerations for developing and deploying hooks in @@ -15,21 +15,20 @@ using parallel operations: // Sequential operations are slower const 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 performance // Start requests concurrently const p1 = fetch(url1).then((r) => r.json()); const p2 = fetch(url2).then((r) => r.json()); -const p3 = fetch(url3).then((r) => r.json()); // Wait for all results -const [data1, data2, data3] = await Promise.all([p1, p2, p3]); +const [data1, data2] = await Promise.all([p1, p2]); ``` ### Cache expensive operations -Store results between invocations to avoid repeated computation: +Store results between invocations to avoid repeated computation, especially for +hooks that run frequently (like `BeforeTool` or `AfterModel`). ```javascript const fs = require('fs'); @@ -54,6 +53,7 @@ async function main() { const cacheKey = `tool-list-${(Date.now() / 3600000) | 0}`; // Hourly cache if (cache[cacheKey]) { + // Write JSON to stdout console.log(JSON.stringify(cache[cacheKey])); return; } @@ -70,32 +70,20 @@ async function main() { ### 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): -```json -// If checking final completion, use AfterAgent instead of AfterModel -{ - "hooks": { - "AfterAgent": [ - { - "matcher": "*", - "hooks": [ - { - "name": "final-checker", - "command": "./check-completion.sh" - } - ] - } - ] - } -} -``` +- **`AfterAgent`**: Fires **once** per turn after the model finishes its final + response. Use this for quality validation (Retries) or final logging. +- **`AfterModel`**: Fires after **every chunk** of LLM output. Use this for + real-time redaction, PII filtering, or monitoring output as it streams. + +If you only need to check the final completion, use `AfterAgent` to save +performance. ### Filter with matchers Use specific matchers to avoid unnecessary hook execution. Instead of matching -all tools with `*`, specify only the tools you need: +all tools with `*`, specify only the tools you need. This saves the overhead of +spawning a process for irrelevant events. ```json { @@ -111,30 +99,32 @@ all tools with `*`, specify only the tools you need: ### Optimize JSON parsing -For large inputs, use streaming JSON parsers to avoid loading everything into -memory: - -```javascript -// Standard approach: parse entire input -const input = JSON.parse(await readStdin()); -const content = input.tool_input.content; - -// For very large inputs: stream and extract only needed fields -const { 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; -}); -``` +For large inputs (like `AfterModel` receiving a large context), standard JSON +parsing can be slow. If you only need one field, consider streaming parsers or +lightweight extraction logic, though for most shell scripts `jq` is sufficient. ## Debugging +### The "Strict JSON" rule + +The most common cause of hook failure is "polluting" the standard output. + +- **stdout** is for **JSON only**. +- **stderr** is for **logs and text**. + +**Good:** + +```bash +#!/bin/bash +echo "Starting check..." >&2 # <--- Redirect to stderr +echo '{"decision": "allow"}' + +``` + ### Log to files -Write debug information to dedicated log files: +Since hooks run in the background, writing to a dedicated log file is often the +easiest way to debug complex logic. ```bash #!/usr/bin/env bash @@ -151,6 +141,9 @@ log "Received input: ${input:0:100}..." # Hook logic here log "Hook completed successfully" +# Always output valid JSON to stdout at the end, even if just empty +echo "{}" + ``` ### Use stderr for errors @@ -162,6 +155,7 @@ try { const result = dangerousOperation(); console.log(JSON.stringify({ result })); } catch (error) { + // Write the error description to stderr so the user/agent sees it console.error(`Hook error: ${error.message}`); process.exit(2); // Blocking error } @@ -169,7 +163,8 @@ try { ### Test hooks independently -Run hook scripts manually with sample JSON input: +Run hook scripts manually with sample JSON input to verify they behave as +expected before hooking them up to the CLI. ```bash # Create test input @@ -191,33 +186,46 @@ cat test-input.json | .gemini/hooks/my-hook.sh # Check exit code echo "Exit code: $?" + ``` ### Check exit codes -Ensure your script returns the correct exit code: +Gemini CLI uses exit codes for high-level flow control: + +- **Exit 0 (Success)**: The hook ran successfully. The CLI parses `stdout` for + JSON decisions. +- **Exit 2 (System Block)**: A critical block occurred. `stderr` is used as the + reason. + - For **Agent/Model** events, this aborts the turn. + - For **Tool** events, this blocks the tool but allows the agent to continue. + - For **AfterAgent**, this triggers an automatic retry turn. + +> **TIP** +> +> **Blocking vs. Stopping**: Use `decision: "deny"` (or Exit Code 2) to block a +> **specific action**. Use `{"continue": false}` in your JSON output to **kill +> the entire agent loop** immediately. ```bash #!/usr/bin/env bash -set -e # Exit on error +set -e # Hook logic -process_input() { - # ... -} - if process_input; then - echo "Success message" + echo '{"decision": "allow"}' exit 0 else - echo "Error message" >&2 + echo "Critical validation failure" >&2 exit 2 fi + ``` ### Enable telemetry -Hook execution is logged when `telemetry.logPrompts` is enabled: +Hook execution is logged when `telemetry.logPrompts` is enabled. You can view +these logs to debug execution flow. ```json { @@ -227,11 +235,10 @@ Hook execution is logged when `telemetry.logPrompts` is enabled: } ``` -View hook telemetry in logs to debug execution issues. - ### Use hook panel -The `/hooks panel` command shows execution status and recent output: +The `/hooks panel` command inside the CLI shows execution status and recent +output: ```bash /hooks panel @@ -255,18 +262,64 @@ Begin with basic logging hooks before implementing complex logic: # Simple logging hook to understand input structure input=$(cat) echo "$input" >> .gemini/hook-inputs.log -echo "Logged input" +# Always return valid JSON +echo "{}" + +``` + +### Documenting your hooks + +Maintainability is critical for complex hook systems. Use descriptions and +comments to help yourself and others understand why a hook exists. + +**Use the `description` field**: This text is displayed in the `/hooks panel` UI +and helps diagnose issues. + +```json +{ + "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 and secrets before writing" + } + ] + } + ] + } +} +``` + +**Add comments in hook scripts**: Explain performance expectations and +dependencies. + +```javascript +#!/usr/bin/env node +/** + * RAG Tool Filter Hook + * + * Reduces the tool space by extracting keywords from the user's request. + * + * Performance: ~500ms average + * Dependencies: @google/generative-ai + */ ``` ### Use JSON libraries -Parse JSON with proper libraries instead of text processing: +Parse JSON with proper libraries instead of text processing. **Bad:** ```bash # Fragile text parsing tool_name=$(echo "$input" | grep -oP '"tool_name":\s*"\K[^"]+') + ``` **Good:** @@ -274,6 +327,7 @@ tool_name=$(echo "$input" | grep -oP '"tool_name":\s*"\K[^"]+') ```bash # Robust JSON parsing tool_name=$(echo "$input" | jq -r '.tool_name') + ``` ### Make scripts executable @@ -283,6 +337,7 @@ Always make hook scripts executable: ```bash chmod +x .gemini/hooks/*.sh chmod +x .gemini/hooks/*.js + ``` ### Version control @@ -292,7 +347,7 @@ Commit hooks to share with your team: ```bash git add .gemini/hooks/ git add .gemini/settings.json -git commit -m "Add project hooks for security and testing" + ``` **`.gitignore` considerations:** @@ -306,295 +361,10 @@ git commit -m "Add project hooks for security and testing" # Keep hook scripts !.gemini/hooks/*.sh !.gemini/hooks/*.js + ``` -### Document behavior - -Add descriptions to help others understand your hooks: - -```json -{ - "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: - -```javascript -#!/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 - -### Hook not executing - -**Check hook name in `/hooks panel`:** - -```bash -/hooks panel -``` - -Verify the hook appears in the list and is enabled. - -**Verify matcher pattern:** - -```bash -# Test regex pattern -echo "write_file|replace" | grep -E "write_.*|replace" -``` - -**Check disabled list:** - -```json -{ - "hooks": { - "disabled": ["my-hook-name"] - } -} -``` - -**Ensure script is executable:** - -```bash -ls -la .gemini/hooks/my-hook.sh -chmod +x .gemini/hooks/my-hook.sh -``` - -**Verify script path:** - -```bash -# Check path expansion -echo "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" - -# Verify file exists -test -f "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" && echo "File exists" -``` - -### Hook timing out - -**Check configured timeout:** - -```json -{ - "name": "slow-hook", - "timeout": 60000 -} -``` - -**Optimize slow operations:** - -```javascript -// 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:** - -```javascript -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:** - -```json -{ - "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 - -**Validate JSON before outputting:** - -```bash -#!/usr/bin/env bash -output='{"decision": "allow"}' - -# Validate JSON -if echo "$output" | jq empty 2>/dev/null; then - echo "$output" -else - echo "Invalid JSON generated" >&2 - exit 1 -fi -``` - -**Ensure proper quoting and escaping:** - -```javascript -// Bad: Unescaped string interpolation -const message = `User said: ${userInput}`; -console.log(JSON.stringify({ message })); - -// Good: Automatic escaping -console.log(JSON.stringify({ message: `User said: ${userInput}` })); -``` - -**Check for binary data or control characters:** - -```javascript -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 - -**Verify script returns correct codes:** - -```bash -#!/usr/bin/env bash -set -e # Exit on error - -# Processing logic -if validate_input; then - echo "Success" - exit 0 -else - echo "Validation failed" >&2 - exit 2 -fi -``` - -**Check for unintended errors:** - -```bash -#!/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" >&2 -fi - -# Always exit explicitly -exit 0 -``` - -**Use trap for cleanup:** - -```bash -#!/usr/bin/env bash - -cleanup() { - # Cleanup logic - rm -f /tmp/hook-temp-* -} - -trap cleanup EXIT - -# Hook logic here -``` - -### Environment variables not available - -**Check if variable is set:** - -```bash -#!/usr/bin/env bash - -if [ -z "$GEMINI_PROJECT_DIR" ]; then - echo "GEMINI_PROJECT_DIR not set" >&2 - exit 1 -fi - -if [ -z "$CUSTOM_VAR" ]; then - echo "Warning: CUSTOM_VAR not set, using default" >&2 - CUSTOM_VAR="default-value" -fi -``` - -**Debug available variables:** - -```bash -#!/usr/bin/env bash - -# List all environment variables -env > .gemini/hook-env.log - -# Check specific variables -echo "GEMINI_PROJECT_DIR: $GEMINI_PROJECT_DIR" >> .gemini/hook-env.log -echo "GEMINI_SESSION_ID: $GEMINI_SESSION_ID" >> .gemini/hook-env.log -echo "GEMINI_API_KEY: ${GEMINI_API_KEY:+}" >> .gemini/hook-env.log -``` - -**Use .env files:** - -```bash -#!/usr/bin/env bash - -# Load .env file if it exists -if [ -f "$GEMINI_PROJECT_DIR/.env" ]; then - source "$GEMINI_PROJECT_DIR/.env" -fi -``` - -## Using Hooks Securely +## Hook security ### Threat Model @@ -621,11 +391,10 @@ When you open a project with hooks defined in `.gemini/settings.json`: it). 5. **Trust**: The hook is marked as "trusted" for this project. -> [!IMPORTANT] **Modification Detection**: If the `command` string of a project -> hook is changed (e.g., by a `git pull`), its identity changes. Gemini CLI will -> treat it as a **new, untrusted hook** and warn you again. This prevents -> malicious actors from silently swapping a verified command for a malicious -> one. +> **Modification detection**: If the `command` string of a project hook is +> changed (e.g., by a `git pull`), its identity changes. Gemini CLI will treat +> it as a **new, untrusted hook** and warn you again. This prevents malicious +> actors from silently swapping a verified command for a malicious one. ### Risks @@ -646,32 +415,134 @@ When you open a project with hooks defined in `.gemini/settings.json`: publishers, well-known community members). - Be cautious with obfuscated scripts or compiled binaries from unknown sources. -#### Sanitize Environment +#### Sanitize environment Hooks inherit the environment of the Gemini CLI process, which may include -sensitive API keys. Gemini CLI attempts to sanitize sensitive variables, but you -should be cautious. +sensitive API keys. Gemini CLI provides a +[redaction system](/docs/get-started/configuration#environment-variable-redaction) +that automatically filters variables matching sensitive patterns (e.g., `KEY`, +`TOKEN`). -- **Avoid printing environment variables** to stdout/stderr unless necessary. -- **Use `.env` files** to securely manage sensitive variables, ensuring they are - excluded from version control. +> **Disabled by Default**: Environment redaction is currently **OFF by +> default**. We strongly recommend enabling it if you are running third-party +> hooks or working in sensitive environments. -**System Administrators:** You can enforce environment variable redaction by -default in the system configuration (e.g., `/etc/gemini-cli/settings.json`): +**Impact on hooks:** + +- **Security**: Prevents your hook scripts from accidentally leaking secrets. +- **Troubleshooting**: If your hook depends on a specific environment variable + that is being blocked, you must explicitly allow it in `settings.json`. ```json { "security": { "environmentVariableRedaction": { "enabled": true, - "blocked": ["MY_SECRET_KEY"], - "allowed": ["SAFE_VAR"] + "allowed": ["MY_REQUIRED_TOOL_KEY"] } } } ``` -## Authoring Secure Hooks +**System administrators:** You can enforce redaction for all users in the system +configuration. + +## Troubleshooting + +### Hook not executing + +**Check hook name in `/hooks panel`:** Verify the hook appears in the list and +is enabled. + +**Verify matcher pattern:** + +```bash +# Test regex pattern +echo "write_file|replace" | grep -E "write_.*|replace" + +``` + +**Check disabled list:** Verify the hook is not listed in your `settings.json`: + +```json +{ + "hooks": { + "disabled": ["my-hook-name"] + } +} +``` + +**Ensure script is executable**: For macOS and Linux users, verify the script +has execution permissions: + +```bash +ls -la .gemini/hooks/my-hook.sh +chmod +x .gemini/hooks/my-hook.sh +``` + +**Verify script path:** Ensure the path in `settings.json` resolves correctly. + +```bash +# Check path expansion +echo "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" + +# Verify file exists +test -f "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" && echo "File exists" +``` + +### Hook timing out + +**Check configured timeout:** The default is 60000ms (1 minute). You can +increase this in `settings.json`: + +```json +{ + "name": "slow-hook", + "timeout": 120000 +} +``` + +**Optimize slow operations:** Move heavy processing to background tasks or use +caching. + +### Invalid JSON output + +**Validate JSON before outputting:** + +```bash +#!/usr/bin/env bash +output='{"decision": "allow"}' + +# Validate JSON +if echo "$output" | jq empty 2>/dev/null; then + echo "$output" +else + echo "Invalid JSON generated" >&2 + exit 1 +fi + +``` + +### Environment variables not available + +**Check if variable is set:** + +```bash +#!/usr/bin/env bash +if [ -z "$GEMINI_PROJECT_DIR" ]; then + echo "GEMINI_PROJECT_DIR not set" >&2 + exit 1 +fi + +``` + +**Debug available variables:** + +```bash +env > .gemini/hook-env.log +``` + +## Authoring secure hooks When writing your own hooks, follow these practices to ensure they are robust and secure. @@ -766,40 +637,17 @@ function containsSecret(content) { ## Privacy considerations -Hook inputs and outputs may contain sensitive information. Gemini CLI respects -the `telemetry.logPrompts` setting for hook data logging. +Hook inputs and outputs may contain sensitive information. ### 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 +Hook telemetry may include inputs (prompts, code) and outputs (decisions, +reasons) unless disabled. ### 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 - -**Disable PII logging in settings:** +**Disable PII logging:** If you are working with sensitive data, disable prompt +logging in your settings: ```json { @@ -809,48 +657,19 @@ outputs are excluded. Use this when: } ``` -**Disable via environment variable:** +**Suppress Output:** Individual hooks can request their metadata be hidden from +logs and telemetry by returning `"suppressOutput": true` in their JSON response. -```bash -export GEMINI_TELEMETRY_LOG_PROMPTS=false -``` +> **Note** + +> `suppressOutput` only affects background logging. Any `systemMessage` or +> `reason` included in the JSON will still be displayed to the user in the +> terminal. ### Sensitive data in hooks If your hooks process sensitive data: -1. **Minimize logging:** Don't write sensitive data to log files -2. **Sanitize outputs:** Remove sensitive data before outputting -3. **Use secure storage:** Encrypt sensitive data at rest -4. **Limit access:** Restrict hook script permissions - -**Example sanitization:** - -```javascript -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 - -- [Hooks Reference](index.md) - Complete API reference -- [Writing Hooks](writing-hooks.md) - Tutorial and examples -- [Configuration](../get-started/configuration.md) - Gemini CLI settings -- [Hooks Design Document](../hooks-design.md) - Technical architecture +1. **Minimize logging:** Don't write sensitive data to log files. +2. **Sanitize outputs:** Remove sensitive data before outputting JSON or writing + to stderr. diff --git a/docs/hooks/index.md b/docs/hooks/index.md index 8fe66d6fce..24c843128a 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -4,109 +4,116 @@ Hooks are scripts or programs that Gemini CLI executes at specific points in the agentic loop, allowing you to intercept and customize behavior without modifying the CLI's source code. -> [!NOTE] **Hooks are currently an experimental feature.** -> -> To use hooks, you must explicitly enable them in your `settings.json`: -> -> ```json -> { -> "tools": { "enableHooks": true }, -> "hooks": { "enabled": true } -> } -> ``` -> -> Both of these are needed in this experimental phase. +## Availability -See [writing hooks guide](writing-hooks.md) for a tutorial on creating your -first hook and a comprehensive example. +> **Experimental Feature**: Hooks are currently enabled by default only in the +> **Preview** and **Nightly** release channels. -See [hooks reference](reference.md) for the technical specification of the I/O -schemas. +If you are on the Stable channel, you must explicitly enable the hooks system in +your `settings.json`: -See [best practices](best-practices.md) for guidelines on security, performance, -and debugging. +```json +{ + "hooksConfig": { + "enabled": true + } +} +``` + +- **[Writing hooks guide](/docs/hooks/writing-hooks)**: A tutorial on creating + your first hook with comprehensive examples. +- **[Hooks reference](/docs/hooks/reference)**: The definitive technical + specification of I/O schemas and exit codes. +- **[Best practices](/docs/hooks/best-practices)**: Guidelines on security, + performance, and debugging. ## What are hooks? -With hooks, you can: - -- **Add context:** Inject relevant information before the model processes a - request -- **Validate actions:** Review and block potentially dangerous operations -- **Enforce policies:** Implement security and compliance requirements -- **Log interactions:** Track tool usage and model responses -- **Optimize behavior:** Dynamically adjust tool selection or model parameters - Hooks run synchronously as part of the agent loopβ€”when a hook event fires, Gemini CLI waits for all matching hooks to complete before continuing. -## Security and Risks +With hooks, you can: -> **Warning: Hooks execute arbitrary code with your user privileges.** -> -> By configuring hooks, you are explicitly allowing Gemini CLI to run shell -> commands on your machine. Malicious or poorly configured hooks can: - -- **Exfiltrate data**: Read sensitive files (`.env`, ssh keys) and send them to - remote servers. -- **Modify system**: Delete files, install malware, or change system settings. -- **Consume resources**: Run infinite loops or crash your system. - -**Project-level hooks** (in `.gemini/settings.json`) and **Extension hooks** are -particularly risky when opening third-party projects or extensions from -untrusted authors. Gemini CLI will **warn you** the first time it detects a new -project hook (identified by its name and command), but it is **your -responsibility** to review these hooks (and any installed extensions) before -trusting them. - -> **Note:** Extension hooks are subject to a mandatory security warning and -> consent flow during extension installation or update if hooks are detected. -> You must explicitly approve the installation or update of any extension that -> contains hooks. - -See [Security Considerations](best-practices.md#using-hooks-securely) for a -detailed threat model and mitigation strategies. +- **Add context:** Inject relevant information (like git history) before the + model processes a request. +- **Validate actions:** Review tool arguments and block potentially dangerous + operations. +- **Enforce policies:** Implement security scanners and compliance checks. +- **Log interactions:** Track tool usage and model responses for auditing. +- **Optimize behavior:** Dynamically filter available tools or adjust model + parameters. ## Core concepts ### Hook events -Hooks are triggered by specific events in Gemini CLI's lifecycle. The following -table lists all available hook events: +Hooks are triggered by specific events in Gemini CLI's lifecycle. -| Event | When It Fires | Common Use Cases | -| --------------------- | --------------------------------------------- | ------------------------------------------ | -| `SessionStart` | When a session begins | Initialize resources, load context | -| `SessionEnd` | When a session ends | Clean up, save state | -| `BeforeAgent` | After user submits prompt, before planning | Add context, validate prompts | -| `AfterAgent` | When agent loop ends | Review output, force continuation | -| `BeforeModel` | Before sending request to LLM | Modify prompts, add instructions | -| `AfterModel` | After receiving LLM response | Filter responses, log interactions | -| `BeforeToolSelection` | Before LLM selects tools (after BeforeModel) | Filter available tools, optimize selection | -| `BeforeTool` | Before a tool executes | Validate arguments, block dangerous ops | -| `AfterTool` | After a tool executes | Process results, run tests | -| `PreCompress` | Before context compression | Save state, notify user | -| `Notification` | When a notification occurs (e.g., permission) | Auto-approve, log decisions | +| Event | When It Fires | Impact | Common Use Cases | +| --------------------- | ---------------------------------------------- | ---------------------- | -------------------------------------------- | +| `SessionStart` | When a session begins (startup, resume, clear) | Inject Context | Initialize resources, load context | +| `SessionEnd` | When a session ends (exit, clear) | Advisory | Clean up, save state | +| `BeforeAgent` | After user submits prompt, before planning | Block Turn / Context | Add context, validate prompts, block turns | +| `AfterAgent` | When agent loop ends | Retry / Halt | Review output, force retry or halt execution | +| `BeforeModel` | Before sending request to LLM | Block Turn / Mock | Modify prompts, swap models, mock responses | +| `AfterModel` | After receiving LLM response | Block Turn / Redact | Filter/redact responses, log interactions | +| `BeforeToolSelection` | Before LLM selects tools | Filter Tools | Filter available tools, optimize selection | +| `BeforeTool` | Before a tool executes | Block Tool / Rewrite | Validate arguments, block dangerous ops | +| `AfterTool` | After a tool executes | Block Result / Context | Process results, run tests, hide results | +| `PreCompress` | Before context compression | Advisory | Save state, notify user | +| `Notification` | When a system notification occurs | Advisory | Forward to desktop alerts, logging | -### Hook types +### Global mechanics -Gemini CLI currently supports **command hooks** that run shell commands or -scripts: +Understanding these core principles is essential for building robust hooks. -```json -{ - "type": "command", - "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh", - "timeout": 30000 -} -``` +#### Strict JSON requirements (The "Golden Rule") -**Note:** Plugin hooks (npm packages) are planned for a future release. +Hooks communicate via `stdin` (Input) and `stdout` (Output). -### Matchers +1. **Silence is Mandatory**: Your script **must not** print any plain text to + `stdout` other than the final JSON object. **Even a single `echo` or `print` + call before the JSON will break parsing.** +2. **Pollution = Failure**: If `stdout` contains non-JSON text, parsing will + fail. The CLI will default to "Allow" and treat the entire output as a + `systemMessage`. +3. **Debug via Stderr**: Use `stderr` for **all** logging and debugging (e.g., + `echo "debug" >&2`). Gemini CLI captures `stderr` but never attempts to parse + it as JSON. -For tool-related events (`BeforeTool`, `AfterTool`), you can filter which tools -trigger the hook: +#### Exit codes + +Gemini CLI uses exit codes to determine the high-level outcome of a hook +execution: + +| Exit Code | Label | Behavioral Impact | +| --------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **0** | **Success** | The `stdout` is parsed as JSON. **Preferred code** for all logic, including intentional blocks (e.g., `{"decision": "deny"}`). | +| **2** | **System Block** | **Critical Block**. The target action (tool, turn, or stop) is aborted. `stderr` is used as the rejection reason. High severity; used for security stops or script failures. | +| **Other** | **Warning** | Non-fatal failure. A warning is shown, but the interaction proceeds using original parameters. | + +#### Matchers + +You can filter which specific tools or triggers fire your hook using the +`matcher` field. + +- **Tool events** (`BeforeTool`, `AfterTool`): Matchers are **Regular + Expressions**. (e.g., `"write_.*"`). +- **Lifecycle events**: Matchers are **Exact Strings**. (e.g., `"startup"`). +- **Wildcards**: `"*"` or `""` (empty string) matches all occurrences. + +## Configuration + +Hook definitions are configured in `settings.json`. Gemini CLI merges +configurations from multiple layers in the following order of precedence +(highest to lowest): + +1. **Project settings**: `.gemini/settings.json` in the current directory. +2. **User settings**: `~/.gemini/settings.json`. +3. **System settings**: `/etc/gemini-cli/settings.json`. +4. **Extensions**: Hooks defined by installed extensions. + +### Configuration schema ```json { @@ -114,382 +121,13 @@ trigger the hook: "BeforeTool": [ { "matcher": "write_file|replace", - "hooks": [ - /* hooks for write operations */ - ] - } - ] - } -} -``` - -**Matcher patterns:** - -- **Exact match:** `"read_file"` matches only `read_file` -- **Regex:** `"write_.*|replace"` matches `write_file`, `replace` -- **Wildcard:** `"*"` or `""` matches all tools - -**Session event matchers:** - -- **SessionStart:** `startup`, `resume`, `clear` -- **SessionEnd:** `exit`, `clear`, `logout`, `prompt_input_exit` -- **PreCompress:** `manual`, `auto` -- **Notification:** `ToolPermission` - -## Hook input/output contract - -### Command hook communication - -Hooks communicate via: - -- **Input:** JSON on stdin -- **Output:** Exit code + stdout/stderr - -### Exit codes - -- **0:** Success - stdout shown to user (or injected as context for some events) -- **2:** Blocking error - stderr shown to agent/user, operation may be blocked -- **Other:** Non-blocking warning - logged but execution continues - -### Common input fields - -Every hook receives these base fields: - -```json -{ - "session_id": "abc123", - "transcript_path": "/path/to/transcript.jsonl", - "cwd": "/path/to/project", - "hook_event_name": "BeforeTool", - "timestamp": "2025-12-01T10:30:00Z" - // ... event-specific fields -} -``` - -### Event-specific fields - -#### BeforeTool - -**Input:** - -```json -{ - "tool_name": "write_file", - "tool_input": { - "file_path": "/path/to/file.ts", - "content": "..." - } -} -``` - -**Output (JSON on stdout):** - -```json -{ - "decision": "allow|deny|ask|block", - "reason": "Explanation shown to agent", - "systemMessage": "Message shown to user" -} -``` - -Or simple exit codes: - -- Exit 0 = allow (stdout shown to user) -- Exit 2 = deny (stderr shown to agent) - -#### AfterTool - -**Input:** - -```json -{ - "tool_name": "read_file", - "tool_input": { "file_path": "..." }, - "tool_response": "file contents..." -} -``` - -**Output:** - -```json -{ - "decision": "allow|deny", - "hookSpecificOutput": { - "hookEventName": "AfterTool", - "additionalContext": "Extra context for agent" - } -} -``` - -#### BeforeAgent - -**Input:** - -```json -{ - "prompt": "Fix the authentication bug" -} -``` - -**Output:** - -```json -{ - "decision": "allow|deny", - "hookSpecificOutput": { - "hookEventName": "BeforeAgent", - "additionalContext": "Recent project decisions: ..." - } -} -``` - -#### BeforeModel - -**Input:** - -```json -{ - "llm_request": { - "model": "gemini-2.0-flash-exp", - "messages": [{ "role": "user", "content": "Hello" }], - "config": { "temperature": 0.7 }, - "toolConfig": { - "functionCallingConfig": { - "mode": "AUTO", - "allowedFunctionNames": ["read_file", "write_file"] - } - } - } -} -``` - -**Output:** - -```json -{ - "decision": "allow", - "hookSpecificOutput": { - "hookEventName": "BeforeModel", - "llm_request": { - "messages": [ - { "role": "system", "content": "Additional instructions..." }, - { "role": "user", "content": "Hello" } - ] - } - } -} -``` - -#### AfterModel - -**Input:** - -```json -{ - "llm_request": { - "model": "gemini-2.0-flash-exp", - "messages": [ - /* ... */ - ], - "config": { - /* ... */ - }, - "toolConfig": { - /* ... */ - } - }, - "llm_response": { - "text": "string", - "candidates": [ - { - "content": { - "role": "model", - "parts": ["array of content parts"] - }, - "finishReason": "STOP" - } - ] - } -} -``` - -**Output:** - -```json -{ - "hookSpecificOutput": { - "hookEventName": "AfterModel", - "llm_response": { - "candidate": { - /* modified response */ - } - } - } -} -``` - -#### BeforeToolSelection - -**Input:** - -```json -{ - "llm_request": { - "model": "gemini-2.0-flash-exp", - "messages": [ - /* ... */ - ], - "toolConfig": { - "functionCallingConfig": { - "mode": "AUTO", - "allowedFunctionNames": [ - /* 100+ tools */ - ] - } - } - } -} -``` - -**Output:** - -```json -{ - "hookSpecificOutput": { - "hookEventName": "BeforeToolSelection", - "toolConfig": { - "functionCallingConfig": { - "mode": "ANY", - "allowedFunctionNames": ["read_file", "write_file", "replace"] - } - } - } -} -``` - -Or simple output (comma-separated tool names sets mode to ANY): - -```bash -echo "read_file,write_file,replace" -``` - -#### SessionStart - -**Input:** - -```json -{ - "source": "startup|resume|clear" -} -``` - -**Output:** - -```json -{ - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": "Loaded 5 project memories" - } -} -``` - -#### SessionEnd - -**Input:** - -```json -{ - "reason": "exit|clear|logout|prompt_input_exit|other" -} -``` - -No structured output expected (but stdout/stderr logged). - -#### PreCompress - -**Input:** - -```json -{ - "trigger": "manual|auto" -} -``` - -**Output:** - -```json -{ - "systemMessage": "Compression starting..." -} -``` - -#### Notification - -**Input:** - -```json -{ - "notification_type": "ToolPermission", - "message": "string", - "details": { - /* notification details */ - } -} -``` - -**Output:** - -```json -{ - "systemMessage": "Notification logged" -} -``` - -## Configuration - -Hook definitions are configured in `settings.json` files using the `hooks` -object. Configuration can be specified at multiple levels with defined -precedence rules. - -### Configuration layers - -Hook configurations are applied in the following order of execution (lower -numbers run first): - -1. **Project settings:** `.gemini/settings.json` in your project directory - (highest priority) -2. **User settings:** `~/.gemini/settings.json` -3. **System settings:** `/etc/gemini-cli/settings.json` -4. **Extensions:** Internal hooks defined by installed extensions (lowest - priority). See [Extensions documentation](../extensions/index.md#hooks) for - details on how extensions define and configure hooks. - -#### Deduplication and shadowing - -If multiple hooks with the identical **name** and **command** are discovered -across different configuration layers, Gemini CLI deduplicates them. The hook -from the higher-priority layer (e.g., Project) will be kept, and others will be -ignored. - -Within each level, hooks run in the order they are declared in the -configuration. - -### Configuration schema - -```json -{ - "hooks": { - "EventName": [ - { - "matcher": "pattern", "hooks": [ { - "name": "hook-identifier", + "name": "security-check", "type": "command", - "command": "./path/to/script.sh", - "description": "What this hook does", - "timeout": 30000 + "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/security.sh", + "timeout": 5000, + "sequential": false } ] } @@ -498,226 +136,33 @@ configuration. } ``` -**Configuration properties:** - -- **`name`** (string, recommended): Unique identifier for the hook used in - `/hooks enable/disable` commands. If omitted, the `command` path is used as - the identifier. -- **`type`** (string, required): Hook type - currently only `"command"` is - supported -- **`command`** (string, required): Path to the script or command to execute -- **`description`** (string, optional): Human-readable description shown in - `/hooks panel` -- **`timeout`** (number, optional): Timeout in milliseconds (default: 60000) -- **`matcher`** (string, optional): Pattern to filter when hook runs (event - matchers only) - ### Environment variables -Hooks have access to: +Hooks are executed with a sanitized environment. -- `GEMINI_PROJECT_DIR`: Project root directory -- `GEMINI_SESSION_ID`: Current session ID -- `GEMINI_API_KEY`: Gemini API key (if configured) -- All other environment variables from the parent process +- `GEMINI_PROJECT_DIR`: The absolute path to the project root. +- `GEMINI_SESSION_ID`: The unique ID for the current session. +- `GEMINI_CWD`: The current working directory. +- `CLAUDE_PROJECT_DIR`: (Alias) Provided for compatibility. + +## Security and risks + +> **Warning: Hooks execute arbitrary code with your user privileges.** By +> configuring hooks, you are allowing scripts to run shell commands on your +> machine. + +**Project-level hooks** are particularly risky when opening untrusted projects. +Gemini CLI **fingerprints** project hooks. If a hook's name or command changes +(e.g., via `git pull`), it is treated as a **new, untrusted hook** and you will +be warned before it executes. + +See [Security Considerations](/docs/hooks/best-practices#using-hooks-securely) +for a detailed threat model. ## Managing hooks -### View registered hooks +Use the CLI commands to manage hooks without editing JSON manually: -Use the `/hooks panel` command to view all registered hooks: - -```bash -/hooks panel -``` - -This command displays: - -- All configured hooks organized by event -- Hook source (user, project, system) -- Hook type (command or plugin) -- Individual hook status (enabled/disabled) - -### Enable and disable all hooks at once - -You can enable or disable all hooks at once using commands: - -```bash -/hooks enable-all -/hooks disable-all -``` - -These commands provide a shortcut to enable or disable all configured hooks -without managing them individually. The `enable-all` command removes all hooks -from the `hooks.disabled` array, while `disable-all` adds all configured hooks -to the disabled list. Changes take effect immediately without requiring a -restart. - -### Enable and disable individual hooks - -You can enable or disable individual hooks using commands: - -```bash -/hooks enable hook-name -/hooks disable hook-name -``` - -These commands allow you to control hook execution without editing configuration -files. The hook name should match the `name` field in your hook configuration. -Changes made via these commands are persisted to your settings. The settings are -saved to workspace scope if available, otherwise to your global user settings -(`~/.gemini/settings.json`). - -### Disabled hooks configuration - -To permanently disable hooks, add them to the `hooks.disabled` array in your -`settings.json`: - -```json -{ - "hooks": { - "disabled": ["secret-scanner", "auto-test"] - } -} -``` - -**Note:** The `hooks.disabled` array uses a UNION merge strategy. Disabled hooks -from all configuration levels (user, project, system) are combined and -deduplicated, meaning a hook disabled at any level remains disabled. - -## Migration from Claude Code - -If you have hooks configured for Claude Code, you can migrate them: - -```bash -gemini hooks migrate --from-claude -``` - -This command: - -- Reads `.claude/settings.json` -- Converts event names (`PreToolUse` β†’ `BeforeTool`, etc.) -- Translates tool names (`Bash` β†’ `run_shell_command`, `replace` β†’ `replace`) -- Updates matcher patterns -- Writes to `.gemini/settings.json` - -### Event name mapping - -| Claude Code | Gemini CLI | -| ------------------ | -------------- | -| `PreToolUse` | `BeforeTool` | -| `PostToolUse` | `AfterTool` | -| `UserPromptSubmit` | `BeforeAgent` | -| `Stop` | `AfterAgent` | -| `Notification` | `Notification` | -| `SessionStart` | `SessionStart` | -| `SessionEnd` | `SessionEnd` | -| `PreCompact` | `PreCompress` | - -### Tool name mapping - -| Claude Code | Gemini CLI | -| ----------- | --------------------- | -| `Bash` | `run_shell_command` | -| `Edit` | `replace` | -| `Read` | `read_file` | -| `Write` | `write_file` | -| `Glob` | `glob` | -| `Grep` | `search_file_content` | -| `LS` | `list_directory` | - -## Tool and Event Matchers Reference - -### Available tool names for matchers - -The following built-in tools can be used in `BeforeTool` and `AfterTool` hook -matchers: - -#### File operations - -- `read_file` - Read a single file -- `read_many_files` - Read multiple files at once -- `write_file` - Create or overwrite a file -- `replace` - Edit file content with find/replace - -#### File system - -- `list_directory` - List directory contents -- `glob` - Find files matching a pattern -- `search_file_content` - Search within file contents - -#### Execution - -- `run_shell_command` - Execute shell commands - -#### Web and external - -- `google_web_search` - Google Search with grounding -- `web_fetch` - Fetch web page content - -#### Agent features - -- `write_todos` - Manage TODO items -- `save_memory` - Save information to memory -- `delegate_to_agent` - Delegate tasks to sub-agents - -#### Example matchers - -```json -{ - "matcher": "write_file|replace" // File editing tools -} -``` - -```json -{ - "matcher": "read_.*" // All read operations -} -``` - -```json -{ - "matcher": "run_shell_command" // Only shell commands -} -``` - -```json -{ - "matcher": "*" // All tools -} -``` - -### Event-specific matchers - -#### SessionStart event matchers - -- `startup` - Fresh session start -- `resume` - Resuming a previous session -- `clear` - Session cleared - -#### SessionEnd event matchers - -- `exit` - Normal exit -- `clear` - Session cleared -- `logout` - User logged out -- `prompt_input_exit` - Exit from prompt input -- `other` - Other reasons - -#### PreCompress event matchers - -- `manual` - Manually triggered compression -- `auto` - Automatically triggered compression - -#### Notification event matchers - -- `ToolPermission` - Tool permission notifications - -## Learn more - -- [Writing Hooks](writing-hooks.md) - Tutorial and comprehensive example -- [Best Practices](best-practices.md) - Security, performance, and debugging -- [Custom Commands](../cli/custom-commands.md) - Create reusable prompt - shortcuts -- [Configuration](../get-started/configuration.md) - Gemini CLI configuration - options -- [Hooks Design Document](../hooks-design.md) - Technical architecture details +- **View hooks:** `/hooks panel` +- **Enable/Disable all:** `/hooks enable-all` or `/hooks disable-all` +- **Toggle individual:** `/hooks enable ` or `/hooks disable ` diff --git a/docs/hooks/reference.md b/docs/hooks/reference.md index f65abeaf84..6f7a82ad09 100644 --- a/docs/hooks/reference.md +++ b/docs/hooks/reference.md @@ -1,178 +1,295 @@ -# Hooks Reference +# Hooks reference This document provides the technical specification for Gemini CLI hooks, -including the JSON schemas for input and output, exit code behaviors, and the -stable model API. +including JSON schemas and API details. -## Communication Protocol +## Global hook mechanics -Hooks communicate with Gemini CLI via standard streams and exit codes: - -- **Input**: Gemini CLI sends a JSON object to the hook's `stdin`. -- **Output**: The hook sends a JSON object (or plain text) to `stdout`. -- **Exit Codes**: Used to signal success or blocking errors. - -### Exit Code Behavior - -| Exit Code | Meaning | Behavior | -| :-------- | :----------------- | :---------------------------------------------------------------------------------------------- | -| `0` | **Success** | `stdout` is parsed as JSON. If parsing fails, it's treated as a `systemMessage`. | -| `2` | **Blocking Error** | Interrupts the current operation. `stderr` is shown to the agent (for tool events) or the user. | -| Other | **Warning** | Execution continues. `stderr` is logged as a non-blocking warning. | +- **Communication**: `stdin` for Input (JSON), `stdout` for Output (JSON), and + `stderr` for logs and feedback. +- **Exit codes**: + - `0`: Success. `stdout` is parsed as JSON. **Preferred for all logic.** + - `2`: System Block. The action is blocked; `stderr` is used as the rejection + reason. + - `Other`: Warning. A non-fatal failure occurred; the CLI continues with a + warning. +- **Silence is Mandatory**: Your script **must not** print any plain text to + `stdout` other than the final JSON. --- -## Input Schema (`stdin`) +## Base input schema -Every hook receives a base JSON object. Extra fields are added depending on the -specific event. +All hooks receive these common fields via `stdin`: -### Base Fields (All Events) - -| Field | Type | Description | -| :---------------- | :------- | :---------------------------------------------------- | -| `session_id` | `string` | Unique identifier for the current CLI session. | -| `transcript_path` | `string` | Path to the session's JSON transcript (if available). | -| `cwd` | `string` | The current working directory. | -| `hook_event_name` | `string` | The name of the firing event (e.g., `BeforeTool`). | -| `timestamp` | `string` | ISO 8601 timestamp of the event. | - -### Event-Specific Fields - -#### Tool Events (`BeforeTool`, `AfterTool`) - -- `tool_name`: (`string`) The internal name of the tool (e.g., `write_file`, - `run_shell_command`). -- `tool_input`: (`object`) The arguments passed to the tool. -- `tool_response`: (`object`, **AfterTool only**) The raw output from the tool - execution. -- `mcp_context`: (`object`, **optional**) Present only for MCP tool invocations. - Contains server identity information: - - `server_name`: (`string`) The configured name of the MCP server. - - `tool_name`: (`string`) The original tool name from the MCP server. - - `command`: (`string`, optional) For stdio transport, the command used to - start the server. - - `args`: (`string[]`, optional) For stdio transport, the command arguments. - - `cwd`: (`string`, optional) For stdio transport, the working directory. - - `url`: (`string`, optional) For SSE/HTTP transport, the server URL. - - `tcp`: (`string`, optional) For WebSocket transport, the TCP address. - -#### Agent Events (`BeforeAgent`, `AfterAgent`) - -- `prompt`: (`string`) The user's submitted prompt. -- `prompt_response`: (`string`, **AfterAgent only**) The final response text - from the model. -- `stop_hook_active`: (`boolean`, **AfterAgent only**) Indicates if a stop hook - is already handling a continuation. - -#### Model Events (`BeforeModel`, `AfterModel`, `BeforeToolSelection`) - -- `llm_request`: (`LLMRequest`) A stable representation of the outgoing request. - See [Stable Model API](#stable-model-api). -- `llm_response`: (`LLMResponse`, **AfterModel only**) A stable representation - of the incoming response. - -#### Session & Notification Events - -- `source`: (`startup` | `resume` | `clear`, **SessionStart only**) The trigger - source. -- `reason`: (`exit` | `clear` | `logout` | `prompt_input_exit` | `other`, - **SessionEnd only**) The reason for session end. -- `trigger`: (`manual` | `auto`, **PreCompress only**) What triggered the - compression event. -- `notification_type`: (`ToolPermission`, **Notification only**) The type of - notification being fired. -- `message`: (`string`, **Notification only**) The notification message. -- `details`: (`object`, **Notification only**) Payload-specific details for the - notification. +```typescript +{ + "session_id": string, // Unique ID for the current session + "transcript_path": string, // Absolute path to session transcript JSON + "cwd": string, // Current working directory + "hook_event_name": string, // The firing event (e.g. "BeforeTool") + "timestamp": string // ISO 8601 execution time +} +``` --- -## Output Schema (`stdout`) +## Common output fields -If the hook exits with `0`, the CLI attempts to parse `stdout` as JSON. +Most hooks support these fields in their `stdout` JSON: -### Common Output Fields +| Field | Type | Description | +| :--------------- | :-------- | :----------------------------------------------------------------------------- | +| `systemMessage` | `string` | Displayed immediately to the user in the terminal. | +| `suppressOutput` | `boolean` | If `true`, hides internal hook metadata from logs/telemetry. | +| `continue` | `boolean` | If `false`, stops the entire agent loop immediately. | +| `stopReason` | `string` | Displayed to the user when `continue` is `false`. | +| `decision` | `string` | `"allow"` or `"deny"` (alias `"block"`). Specific impact depends on the event. | +| `reason` | `string` | The feedback/error message provided when a `decision` is `"deny"`. | -| Field | Type | Description | -| :------------------- | :-------- | :------------------------------------------------------------------------------------- | -| `decision` | `string` | One of: `allow`, `deny`, `block`, `ask`, `approve`. | -| `reason` | `string` | Explanation shown to the **agent** when a decision is `deny` or `block`. | -| `systemMessage` | `string` | Message displayed in Gemini CLI terminal to provide warning or context to the **user** | -| `continue` | `boolean` | If `false`, immediately terminates the agent loop for this turn. | -| `stopReason` | `string` | Message shown to the user when `continue` is `false`. | -| `suppressOutput` | `boolean` | If `true`, the hook execution is hidden from the CLI transcript. | -| `hookSpecificOutput` | `object` | Container for event-specific data (see below). | +--- -### `hookSpecificOutput` Reference +## Tool hooks -| Field | Supported Events | Description | -| :------------------ | :----------------------------------------- | :-------------------------------------------------------------------------------- | -| `additionalContext` | `SessionStart`, `BeforeAgent`, `AfterTool` | Appends text directly to the agent's context. | -| `llm_request` | `BeforeModel` | A `Partial` to override parameters of the outgoing call. | -| `llm_response` | `BeforeModel` | A **full** `LLMResponse` to bypass the model and provide a synthetic result. | -| `llm_response` | `AfterModel` | A `Partial` to modify the model's response before the agent sees it. | -| `toolConfig` | `BeforeToolSelection` | Object containing `mode` (`AUTO`/`ANY`/`NONE`) and `allowedFunctionNames`. | +### Matchers and tool names + +For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is +compared against the name of the tool being executed. + +- **Built-in Tools**: You can match any built-in tool (e.g., `read_file`, + `run_shell_command`). See the [Tools Reference](/docs/tools) for a full list + of available tool names. +- **MCP Tools**: Tools from MCP servers follow the naming pattern + `mcp____`. +- **Regex Support**: Matchers support regular expressions (e.g., + `matcher: "read_.*"` matches all file reading tools). + +### `BeforeTool` + +Fires before a tool is invoked. Used for argument validation, security checks, +and parameter rewriting. + +- **Input Fields**: + - `tool_name`: (`string`) The name of the tool being called. + - `tool_input`: (`object`) The raw arguments generated by the model. + - `mcp_context`: (`object`) Optional metadata for MCP-based tools. +- **Relevant Output Fields**: + - `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from + executing. + - `reason`: Required if denied. This text is sent **to the agent** as a tool + error, allowing it to respond or retry. + - `hookSpecificOutput.tool_input`: An object that **merges with and + overrides** the model's arguments before execution. + - `continue`: Set to `false` to **kill the entire agent loop** immediately. +- **Exit Code 2 (Block Tool)**: Prevents execution. Uses `stderr` as the + `reason` sent to the agent. **The turn continues.** + +### `AfterTool` + +Fires after a tool executes. Used for result auditing, context injection, or +hiding sensitive output from the agent. + +- **Input Fields**: + - `tool_name`: (`string`) + - `tool_input`: (`object`) The original arguments. + - `tool_response`: (`object`) The result containing `llmContent`, + `returnDisplay`, and optional `error`. + - `mcp_context`: (`object`) +- **Relevant Output Fields**: + - `decision`: Set to `"deny"` to hide the real tool output from the agent. + - `reason`: Required if denied. This text **replaces** the tool result sent + back to the model. + - `hookSpecificOutput.additionalContext`: Text that is **appended** to the + tool result for the agent. + - `continue`: Set to `false` to **kill the entire agent loop** immediately. +- **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the + replacement content sent to the agent. **The turn continues.** + +--- + +## Agent hooks + +### `BeforeAgent` + +Fires after a user submits a prompt, but before the agent begins planning. Used +for prompt validation or injecting dynamic context. + +- **Input Fields**: + - `prompt`: (`string`) The original text submitted by the user. +- **Relevant Output Fields**: + - `hookSpecificOutput.additionalContext`: Text that is **appended** to the + prompt for this turn only. + - `decision`: Set to `"deny"` to block the turn and **discard the user's + message** (it will not appear in history). + - `continue`: Set to `false` to block the turn but **save the message to + history**. + - `reason`: Required if denied or stopped. +- **Exit Code 2 (Block Turn)**: Aborts the turn and erases the prompt from + context. Same as `decision: "deny"`. + +### `AfterAgent` + +Fires once per turn after the model generates its final response. Primary use +case is response validation and automatic retries. + +- **Input Fields**: + - `prompt`: (`string`) The user's original request. + - `prompt_response`: (`string`) The final text generated by the agent. + - `stop_hook_active`: (`boolean`) Indicates if this hook is already running as + part of a retry sequence. +- **Relevant Output Fields**: + - `decision`: Set to `"deny"` to **reject the response** and force a retry. + - `reason`: Required if denied. This text is sent **to the agent as a new + prompt** to request a correction. + - `continue`: Set to `false` to **stop the session** without retrying. +- **Exit Code 2 (Retry)**: Rejects the response and triggers an automatic retry + turn using `stderr` as the feedback prompt. + +--- + +## Model hooks + +### `BeforeModel` + +Fires before sending a request to the LLM. Operates on a stable, SDK-agnostic +request format. + +- **Input Fields**: + - `llm_request`: (`object`) Contains `model`, `messages`, and `config` + (generation params). +- **Relevant Output Fields**: + - `hookSpecificOutput.llm_request`: An object that **overrides** parts of the + outgoing request (e.g., changing models or temperature). + - `hookSpecificOutput.llm_response`: A **Synthetic Response** object. If + provided, the CLI skips the LLM call entirely and uses this as the response. + - `decision`: Set to `"deny"` to block the request and abort the turn. +- **Exit Code 2 (Block Turn)**: Aborts the turn and skips the LLM call. Uses + `stderr` as the error message. + +### `BeforeToolSelection` + +Fires before the LLM decides which tools to call. Used to filter the available +toolset or force specific tool modes. + +- **Input Fields**: + - `llm_request`: (`object`) Same format as `BeforeModel`. +- **Relevant Output Fields**: + - `hookSpecificOutput.toolConfig.mode`: (`"AUTO" | "ANY" | "NONE"`) + - `"NONE"`: Disables all tools (Wins over other hooks). + - `"ANY"`: Forces at least one tool call. + - `hookSpecificOutput.toolConfig.allowedFunctionNames`: (`string[]`) Whitelist + of tool names. +- **Union Strategy**: Multiple hooks' whitelists are **combined**. +- **Limitations**: Does **not** support `decision`, `continue`, or + `systemMessage`. + +### `AfterModel` + +Fires immediately after an LLM response chunk is received. Used for real-time +redaction or PII filtering. + +- **Input Fields**: + - `llm_request`: (`object`) The original request. + - `llm_response`: (`object`) The model's response (or a single chunk during + streaming). +- **Relevant Output Fields**: + - `hookSpecificOutput.llm_response`: An object that **replaces** the model's + response chunk. + - `decision`: Set to `"deny"` to discard the response chunk and block the + turn. + - `continue`: Set to `false` to **kill the entire agent loop** immediately. +- **Note on Streaming**: Fired for **every chunk** generated by the model. + Modifying the response only affects the current chunk. +- **Exit Code 2 (Block Response)**: Aborts the turn and discards the model's + output. Uses `stderr` as the error message. + +--- + +## Lifecycle & system hooks + +### `SessionStart` + +Fires on application startup, resuming a session, or after a `/clear` command. +Used for loading initial context. + +- **Input fields**: + - `source`: (`"startup" | "resume" | "clear"`) +- **Relevant output fields**: + - `hookSpecificOutput.additionalContext`: (`string`) + - **Interactive**: Injected as the first turn in history. + - **Non-interactive**: Prepended to the user's prompt. + - `systemMessage`: Shown at the start of the session. +- **Advisory only**: `continue` and `decision` fields are **ignored**. Startup + is never blocked. + +### `SessionEnd` + +Fires when the CLI exits or a session is cleared. Used for cleanup or final +telemetry. + +- **Input Fields**: + - `reason`: (`"exit" | "clear" | "logout" | "prompt_input_exit" | "other"`) +- **Relevant Output Fields**: + - `systemMessage`: Displayed to the user during shutdown. +- **Best Effort**: The CLI **will not wait** for this hook to complete and + ignores all flow-control fields (`continue`, `decision`). + +### `Notification` + +Fires when the CLI emits a system alert (e.g., Tool Permissions). Used for +external logging or cross-platform alerts. + +- **Input Fields**: + - `notification_type`: (`"ToolPermission"`) + - `message`: Summary of the alert. + - `details`: JSON object with alert-specific metadata (e.g., tool name, file + path). +- **Relevant Output Fields**: + - `systemMessage`: Displayed alongside the system alert. +- **Observability Only**: This hook **cannot** block alerts or grant permissions + automatically. Flow-control fields are ignored. + +### `PreCompress` + +Fires before the CLI summarizes history to save tokens. Used for logging or +state saving. + +- **Input Fields**: + - `trigger`: (`"auto" | "manual"`) +- **Relevant Output Fields**: + - `systemMessage`: Displayed to the user before compression. +- **Advisory Only**: Fired asynchronously. It **cannot** block or modify the + compression process. Flow-control fields are ignored. --- ## Stable Model API -Gemini CLI uses a decoupled format for model interactions to ensure hooks remain -stable even if the underlying Gemini SDK changes. +Gemini CLI uses these structures to ensure hooks don't break across SDK updates. -### `LLMRequest` Object - -Used in `BeforeModel` and `BeforeToolSelection`. - -> πŸ’‘ **Note**: In v1, model hooks are primarily text-focused. Non-text parts -> (like images or function calls) provided in the `content` array will be -> simplified to their string representation by the translator. +**LLMRequest**: ```typescript { "model": string, "messages": Array<{ "role": "user" | "model" | "system", - "content": string | Array<{ "type": string, [key: string]: any }> + "content": string // Non-text parts are filtered out for hooks }>, - "config"?: { - "temperature"?: number, - "maxOutputTokens"?: number, - "topP"?: number, - "topK"?: number - }, - "toolConfig"?: { - "mode"?: "AUTO" | "ANY" | "NONE", - "allowedFunctionNames"?: string[] - } + "config": { "temperature": number, ... }, + "toolConfig": { "mode": string, "allowedFunctionNames": string[] } } + ``` -### `LLMResponse` Object - -Used in `AfterModel` and as a synthetic response in `BeforeModel`. +**LLMResponse**: ```typescript { - "text"?: string, "candidates": Array<{ - "content": { - "role": "model", - "parts": string[] - }, - "finishReason"?: "STOP" | "MAX_TOKENS" | "SAFETY" | "RECITATION" | "OTHER", - "index"?: number, - "safetyRatings"?: Array<{ - "category": string, - "probability": string, - "blocked"?: boolean - }> + "content": { "role": "model", "parts": string[] }, + "finishReason": string }>, - "usageMetadata"?: { - "promptTokenCount"?: number, - "candidatesTokenCount"?: number, - "totalTokenCount"?: number - } + "usageMetadata": { "totalTokenCount": number } } ``` diff --git a/docs/hooks/writing-hooks.md b/docs/hooks/writing-hooks.md index 817dd76ca8..7b66a90a65 100644 --- a/docs/hooks/writing-hooks.md +++ b/docs/hooks/writing-hooks.md @@ -1,8 +1,7 @@ # Writing hooks for Gemini CLI This guide will walk you through creating hooks for Gemini CLI, from a simple -logging hook to a comprehensive workflow assistant that demonstrates all hook -events working together. +logging hook to a comprehensive workflow assistant. ## Prerequisites @@ -17,9 +16,17 @@ Before you start, make sure you have: Let's create a simple hook that logs all tool executions to understand the basics. +**Crucial Rule:** Always write logs to `stderr`. Write only the final JSON to +`stdout`. + ### Step 1: Create your hook script -Create a directory for hooks and a simple logging script: +Create a directory for hooks and a simple logging script. + +> **Note**: +> +> This example uses `jq` to parse JSON. If you don't have it installed, you can +> perform similar logic using Node.js or Python. ```bash mkdir -p .gemini/hooks @@ -28,62 +35,38 @@ cat > .gemini/hooks/log-tools.sh << 'EOF' # Read hook input from stdin input=$(cat) -# Extract tool name +# Extract tool name (requires jq) tool_name=$(echo "$input" | jq -r '.tool_name') +# Log to stderr (visible in terminal if hook fails, or captured in logs) +echo "Logging tool: $tool_name" >&2 + # Log to file echo "[$(date)] Tool executed: $tool_name" >> .gemini/tool-log.txt -# Return success (exit 0) - output goes to user in transcript mode -echo "Logged: $tool_name" +# Return success (exit 0) with empty JSON +echo "{}" +exit 0 EOF chmod +x .gemini/hooks/log-tools.sh ``` -### Step 2: Configure the hook +## Exit Code Strategies -Add the hook configuration to `.gemini/settings.json`: +There are two ways to control or block an action in Gemini CLI: -```json -{ - "hooks": { - "AfterTool": [ - { - "matcher": "*", - "hooks": [ - { - "name": "tool-logger", - "type": "command", - "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/log-tools.sh", - "description": "Log all tool executions" - } - ] - } - ] - } -} -``` - -### Step 3: Test your hook - -Run Gemini CLI and execute any command that uses tools: - -``` -> Read the README.md file - -[Agent uses read_file tool] - -Logged: read_file -``` - -Check `.gemini/tool-log.txt` to see the logged tool executions. +| Strategy | Exit Code | Implementation | Best For | +| :------------------------- | :-------- | :----------------------------------------------------------------- | :---------------------------------------------------------- | +| **Structured (Idiomatic)** | `0` | Return a JSON object like `{"decision": "deny", "reason": "..."}`. | Production hooks, custom user feedback, and complex logic. | +| **Emergency Brake** | `2` | Print the error message to `stderr` and exit. | Simple security gates, script errors, or rapid prototyping. | ## Practical examples ### Security: Block secrets in commits -Prevent committing files containing API keys or passwords. +Prevent committing files containing API keys or passwords. Note that we use +**Exit Code 0** to provide a structured denial message to the agent. **`.gemini/hooks/block-secrets.sh`:** @@ -96,93 +79,26 @@ content=$(echo "$input" | jq -r '.tool_input.content // .tool_input.new_string / # Check for secrets if echo "$content" | grep -qE 'api[_-]?key|password|secret'; then - echo '{"decision":"deny","reason":"Potential secret detected"}' >&2 - exit 2 -fi + # Log to stderr + echo "Blocked potential secret" >&2 -exit 0 -``` - -**`.gemini/settings.json`:** - -```json + # Return structured denial to stdout + cat <&1 | head -20; then - echo "βœ… Tests passed" -else - echo "❌ Tests failed" -fi - +# Allow +echo '{"decision": "allow"}' exit 0 ``` -**`.gemini/settings.json`:** - -```json -{ - "hooks": { - "AfterTool": [ - { - "matcher": "write_file|replace", - "hooks": [ - { - "name": "auto-test", - "type": "command", - "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/auto-test.sh", - "description": "Run tests after code changes" - } - ] - } - ] - } -} -``` - -### Dynamic context injection +### Dynamic context injection (Git History) Add relevant project context before each agent interaction. @@ -205,20 +121,80 @@ cat < m.role === 'user'); + + if (!lastUserMessage) { + console.log(JSON.stringify({})); // Do nothing + return; + } + + const text = lastUserMessage.content; + const allowed = ['write_todos']; // Always allow memory + + // Simple keyword matching + if (text.includes('read') || text.includes('check')) { + allowed.push('read_file', 'list_directory'); + } + if (text.includes('test')) { + allowed.push('run_shell_command'); + } + + // If we found specific intent, filter tools. Otherwise allow all. + if (allowed.length > 1) { + console.log( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'BeforeToolSelection', + toolConfig: { + mode: 'ANY', // Force usage of one of these tools (or AUTO) + allowedFunctionNames: allowed, + }, + }, + }), + ); + } else { + console.log(JSON.stringify({})); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + **`.gemini/settings.json`:** ```json { "hooks": { - "BeforeAgent": [ + "BeforeToolSelection": [ { "matcher": "*", "hooks": [ { - "name": "git-context", - "type": "command", - "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/inject-context.sh", - "description": "Inject git commit history" + "name": "intent-filter", + "command": "node .gemini/hooks/filter-tools.js" } ] } @@ -227,113 +203,30 @@ EOF } ``` -## Advanced features - -### RAG-based tool filtering - -Use `BeforeToolSelection` to intelligently reduce the tool space based on the -current task. Instead of sending all 100+ tools to the model, filter to the most -relevant ~15 tools using semantic search or keyword matching. - -This improves: - -- **Model accuracy:** Fewer similar tools reduce confusion -- **Response speed:** Smaller tool space is faster to process -- **Cost efficiency:** Less tokens used per request - -### Cross-session memory - -Use `SessionStart` and `SessionEnd` hooks to maintain persistent knowledge -across sessions: - -- **SessionStart:** Load relevant memories from previous sessions -- **AfterModel:** Record important interactions during the session -- **SessionEnd:** Extract learnings and store for future use - -This enables the assistant to learn project conventions, remember important -decisions, and share knowledge across team members. - -### Hook chaining - -Multiple hooks for the same event run in the order declared. Each hook can build -upon previous hooks' outputs: - -```json -{ - "hooks": { - "BeforeAgent": [ - { - "matcher": "*", - "hooks": [ - { - "name": "load-memories", - "type": "command", - "command": "./hooks/load-memories.sh" - }, - { - "name": "analyze-sentiment", - "type": "command", - "command": "./hooks/analyze-sentiment.sh" - } - ] - } - ] - } -} -``` +> **TIP** +> +> **Union Aggregation Strategy**: `BeforeToolSelection` is unique in that it +> combines the results of all matching hooks. If you have multiple filtering +> hooks, the agent will receive the **union** of all whitelisted tools. Only +> using `mode: "NONE"` will override other hooks to disable all tools. ## Complete example: Smart Development Workflow Assistant -This comprehensive example demonstrates all hook events working together with -two advanced features: - -- **RAG-based tool selection:** Reduces 100+ tools to ~15 relevant ones per task -- **Cross-session memory:** Learns and persists project knowledge +This comprehensive example demonstrates all hook events working together. We +will build a system that maintains memory, filters tools, and checks for +security. ### Architecture -``` -SessionStart β†’ Initialize memory & index tools - ↓ -BeforeAgent β†’ Inject relevant memories - ↓ -BeforeModel β†’ Add system instructions - ↓ -BeforeToolSelection β†’ Filter tools via RAG - ↓ -BeforeTool β†’ Validate security - ↓ -AfterTool β†’ Run auto-tests - ↓ -AfterModel β†’ Record interaction - ↓ -SessionEnd β†’ Extract and store memories -``` +1. **SessionStart**: Load project memories. +2. **BeforeAgent**: Inject memories into context. +3. **BeforeToolSelection**: Filter tools based on intent. +4. **BeforeTool**: Scan for secrets. +5. **AfterModel**: Record interactions. +6. **AfterAgent**: Validate final response quality (Retry). +7. **SessionEnd**: Consolidate memories. -### Installation - -**Prerequisites:** - -- Node.js 18+ -- Gemini CLI installed - -**Setup:** - -```bash -# Create hooks directory -mkdir -p .gemini/hooks .gemini/memory - -# Install dependencies -npm install --save-dev chromadb @google/generative-ai - -# Copy hook scripts (shown below) -# Make them executable -chmod +x .gemini/hooks/*.js -``` - -### Configuration - -**`.gemini/settings.json`:** +### Configuration (`.gemini/settings.json`) ```json { @@ -341,14 +234,7 @@ chmod +x .gemini/hooks/*.js "SessionStart": [ { "matcher": "startup", - "hooks": [ - { - "name": "init-assistant", - "type": "command", - "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/init.js", - "description": "Initialize Smart Workflow Assistant" - } - ] + "hooks": [{ "name": "init", "command": "node .gemini/hooks/init.js" }] } ], "BeforeAgent": [ @@ -356,10 +242,8 @@ chmod +x .gemini/hooks/*.js "matcher": "*", "hooks": [ { - "name": "inject-memories", - "type": "command", - "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/inject-memories.js", - "description": "Inject relevant project memories" + "name": "memory", + "command": "node .gemini/hooks/inject-memories.js" } ] } @@ -368,38 +252,15 @@ chmod +x .gemini/hooks/*.js { "matcher": "*", "hooks": [ - { - "name": "rag-filter", - "type": "command", - "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/rag-filter.js", - "description": "Filter tools using RAG" - } + { "name": "filter", "command": "node .gemini/hooks/rag-filter.js" } ] } ], "BeforeTool": [ { - "matcher": "write_file|replace", + "matcher": "write_file", "hooks": [ - { - "name": "security-check", - "type": "command", - "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/security.js", - "description": "Prevent committing secrets" - } - ] - } - ], - "AfterTool": [ - { - "matcher": "write_file|replace", - "hooks": [ - { - "name": "auto-test", - "type": "command", - "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/auto-test.js", - "description": "Run tests after code changes" - } + { "name": "security", "command": "node .gemini/hooks/security.js" } ] } ], @@ -407,25 +268,23 @@ chmod +x .gemini/hooks/*.js { "matcher": "*", "hooks": [ - { - "name": "record-interaction", - "type": "command", - "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/record.js", - "description": "Record interaction for learning" - } + { "name": "record", "command": "node .gemini/hooks/record.js" } + ] + } + ], + "AfterAgent": [ + { + "matcher": "*", + "hooks": [ + { "name": "validate", "command": "node .gemini/hooks/validate.js" } ] } ], "SessionEnd": [ { - "matcher": "exit|logout", + "matcher": "exit", "hooks": [ - { - "name": "consolidate-memories", - "type": "command", - "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/consolidate.js", - "description": "Extract and store session learnings" - } + { "name": "save", "command": "node .gemini/hooks/consolidate.js" } ] } ] @@ -433,612 +292,131 @@ chmod +x .gemini/hooks/*.js } ``` -### Hook scripts +### Hook Scripts -#### 1. Initialize (SessionStart) +> **Note**: For brevity, these scripts use `console.error` for logging and +> standard `console.log` for JSON output. -**`.gemini/hooks/init.js`:** +#### 1. Initialize (`init.js`) + +```javascript +#!/usr/bin/env node +// Initialize DB or resources +console.error('Initializing assistant...'); + +// Output to user +console.log( + JSON.stringify({ + systemMessage: '🧠 Smart Assistant Loaded', + }), +); +``` + +#### 2. Inject Memories (`inject-memories.js`) ```javascript #!/usr/bin/env node -const { ChromaClient } = require('chromadb'); -const path = require('path'); const fs = require('fs'); async function main() { - const projectDir = process.env.GEMINI_PROJECT_DIR; - const chromaPath = path.join(projectDir, '.gemini', 'chroma'); - - // Ensure chroma directory exists - fs.mkdirSync(chromaPath, { recursive: true }); - - const client = new ChromaClient({ path: chromaPath }); - - // Initialize memory collection - await client.getOrCreateCollection({ - name: 'project_memories', - metadata: { 'hnsw:space': 'cosine' }, - }); - - // Count existing memories - const collection = await client.getCollection({ name: 'project_memories' }); - const memoryCount = await collection.count(); + const input = JSON.parse(fs.readFileSync(0, 'utf-8')); + // Assume we fetch memories from a DB here + const memories = '- [Memory] Always use TypeScript for this project.'; console.log( JSON.stringify({ hookSpecificOutput: { - hookEventName: 'SessionStart', - additionalContext: `Smart Workflow Assistant initialized with ${memoryCount} project memories.`, + hookEventName: 'BeforeAgent', + additionalContext: `\n## Relevant Memories\n${memories}`, }, - systemMessage: `🧠 ${memoryCount} memories loaded`, }), ); } - -function readStdin() { - return new Promise((resolve) => { - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); - }); -} - -readStdin().then(main).catch(console.error); +main(); ``` -#### 2. Inject memories (BeforeAgent) - -**`.gemini/hooks/inject-memories.js`:** +#### 3. Security Check (`security.js`) ```javascript #!/usr/bin/env node -const { GoogleGenerativeAI } = require('@google/generative-ai'); -const { ChromaClient } = require('chromadb'); -const path = require('path'); - -async function main() { - const input = JSON.parse(await readStdin()); - const { prompt } = input; - - if (!prompt?.trim()) { - console.log(JSON.stringify({})); - return; - } - - // Embed the prompt - const genai = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); - const model = genai.getGenerativeModel({ model: 'text-embedding-004' }); - const result = await model.embedContent(prompt); - - // Search memories - const projectDir = process.env.GEMINI_PROJECT_DIR; - const client = new ChromaClient({ - path: path.join(projectDir, '.gemini', 'chroma'), - }); - - try { - const collection = await client.getCollection({ name: 'project_memories' }); - const results = await collection.query({ - queryEmbeddings: [result.embedding.values], - nResults: 3, - }); - - if (results.documents[0]?.length > 0) { - const memories = results.documents[0] - .map((doc, i) => { - const meta = results.metadatas[0][i]; - return `- [${meta.category}] ${meta.summary}`; - }) - .join('\n'); - - console.log( - JSON.stringify({ - hookSpecificOutput: { - hookEventName: 'BeforeAgent', - additionalContext: `\n## Relevant Project Context\n\n${memories}\n`, - }, - systemMessage: `πŸ’­ ${results.documents[0].length} memories recalled`, - }), - ); - } else { - console.log(JSON.stringify({})); - } - } catch (error) { - console.log(JSON.stringify({})); - } -} - -function readStdin() { - return new Promise((resolve) => { - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); - }); -} - -readStdin().then(main).catch(console.error); -``` - -#### 3. RAG tool filter (BeforeToolSelection) - -**`.gemini/hooks/rag-filter.js`:** - -```javascript -#!/usr/bin/env node -const { GoogleGenerativeAI } = require('@google/generative-ai'); - -async function main() { - const input = JSON.parse(await readStdin()); - const { llm_request } = input; - const candidateTools = - llm_request.toolConfig?.functionCallingConfig?.allowedFunctionNames || []; - - // Skip if already filtered - if (candidateTools.length <= 20) { - console.log(JSON.stringify({})); - return; - } - - // Extract recent user messages - const recentMessages = llm_request.messages - .slice(-3) - .filter((m) => m.role === 'user') - .map((m) => m.content) - .join('\n'); - - // Use fast model to extract task keywords - const genai = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); - const model = genai.getGenerativeModel({ model: 'gemini-2.0-flash-exp' }); - - const result = await model.generateContent( - `Extract 3-5 keywords describing needed tool capabilities from this request:\n\n${recentMessages}\n\nKeywords (comma-separated):`, - ); - - const keywords = result.response - .text() - .toLowerCase() - .split(',') - .map((k) => k.trim()); - - // Simple keyword-based filtering + core tools - const coreTools = ['read_file', 'write_file', 'replace', 'run_shell_command']; - const filtered = candidateTools.filter((tool) => { - if (coreTools.includes(tool)) return true; - const toolLower = tool.toLowerCase(); - return keywords.some( - (kw) => toolLower.includes(kw) || kw.includes(toolLower), - ); - }); +const fs = require('fs'); +const input = JSON.parse(fs.readFileSync(0)); +const content = input.tool_input.content || ''; +if (content.includes('SECRET_KEY')) { console.log( JSON.stringify({ - hookSpecificOutput: { - hookEventName: 'BeforeToolSelection', - toolConfig: { - functionCallingConfig: { - mode: 'ANY', - allowedFunctionNames: filtered.slice(0, 20), - }, - }, - }, - systemMessage: `🎯 Filtered ${candidateTools.length} β†’ ${Math.min(filtered.length, 20)} tools`, + decision: 'deny', + reason: 'Found SECRET_KEY in content', + systemMessage: '🚨 Blocked sensitive commit', }), ); + process.exit(0); } -function readStdin() { - return new Promise((resolve) => { - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); - }); -} - -readStdin().then(main).catch(console.error); +console.log(JSON.stringify({ decision: 'allow' })); ``` -#### 4. Security validation (BeforeTool) - -**`.gemini/hooks/security.js`:** - -```javascript -#!/usr/bin/env node - -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 - /ghp_[a-zA-Z0-9]{36}/, // GitHub -]; - -async function main() { - const input = JSON.parse(await readStdin()); - const { tool_input } = input; - - const content = tool_input.content || tool_input.new_string || ''; - - for (const pattern of SECRET_PATTERNS) { - if (pattern.test(content)) { - console.log( - JSON.stringify({ - decision: 'deny', - reason: - 'Potential secret detected in code. Please remove sensitive data.', - systemMessage: '🚨 Secret scanner blocked operation', - }), - ); - process.exit(2); - } - } - - console.log(JSON.stringify({ decision: 'allow' })); -} - -function readStdin() { - return new Promise((resolve) => { - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); - }); -} - -readStdin().then(main).catch(console.error); -``` - -#### 5. Auto-test (AfterTool) - -**`.gemini/hooks/auto-test.js`:** - -```javascript -#!/usr/bin/env node -const { execSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); - -async function main() { - const input = JSON.parse(await readStdin()); - const { tool_input } = input; - const filePath = tool_input.file_path; - - if (!filePath?.match(/\.(ts|js|tsx|jsx)$/)) { - console.log(JSON.stringify({})); - return; - } - - // Find test file - const ext = path.extname(filePath); - const base = filePath.slice(0, -ext.length); - const testFile = `${base}.test${ext}`; - - if (!fs.existsSync(testFile)) { - console.log( - JSON.stringify({ - systemMessage: `⚠️ No test file: ${path.basename(testFile)}`, - }), - ); - return; - } - - // Run tests - try { - execSync(`npx vitest run ${testFile} --silent`, { - encoding: 'utf8', - stdio: 'pipe', - timeout: 30000, - }); - - console.log( - JSON.stringify({ - systemMessage: `βœ… Tests passed: ${path.basename(filePath)}`, - }), - ); - } catch (error) { - console.log( - JSON.stringify({ - systemMessage: `❌ Tests failed: ${path.basename(filePath)}`, - }), - ); - } -} - -function readStdin() { - return new Promise((resolve) => { - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); - }); -} - -readStdin().then(main).catch(console.error); -``` - -#### 6. Record interaction (AfterModel) - -**`.gemini/hooks/record.js`:** +#### 4. Record Interaction (`record.js`) ```javascript #!/usr/bin/env node const fs = require('fs'); const path = require('path'); -async function main() { - const input = JSON.parse(await readStdin()); - const { llm_request, llm_response } = input; - const projectDir = process.env.GEMINI_PROJECT_DIR; - const sessionId = process.env.GEMINI_SESSION_ID; +const input = JSON.parse(fs.readFileSync(0)); +const { llm_request, llm_response } = input; +const logFile = path.join( + process.env.GEMINI_PROJECT_DIR, + '.gemini/memory/session.jsonl', +); - const tempFile = path.join( - projectDir, - '.gemini', - 'memory', - `session-${sessionId}.jsonl`, +fs.appendFileSync( + logFile, + JSON.stringify({ + request: llm_request, + response: llm_response, + timestamp: new Date().toISOString(), + }) + '\n', +); + +console.log(JSON.stringify({})); +``` + +#### 5. Validate Response (`validate.js`) + +```javascript +#!/usr/bin/env node +const fs = require('fs'); +const input = JSON.parse(fs.readFileSync(0)); +const response = input.prompt_response; + +// Example: Check if the agent forgot to include a summary +if (!response.includes('Summary:')) { + console.log( + JSON.stringify({ + decision: 'block', // Triggers an automatic retry turn + reason: 'Your response is missing a Summary section. Please add one.', + systemMessage: 'πŸ”„ Requesting missing summary...', + }), ); - - fs.mkdirSync(path.dirname(tempFile), { recursive: true }); - - // Extract user message and model response - const userMsg = llm_request.messages - ?.filter((m) => m.role === 'user') - .slice(-1)[0]?.content; - - const modelMsg = llm_response.candidates?.[0]?.content?.parts - ?.map((p) => p.text) - .filter(Boolean) - .join(''); - - if (userMsg && modelMsg) { - const interaction = { - timestamp: new Date().toISOString(), - user: process.env.USER || 'unknown', - request: userMsg.slice(0, 500), // Truncate for storage - response: modelMsg.slice(0, 500), - }; - - fs.appendFileSync(tempFile, JSON.stringify(interaction) + '\n'); - } - - console.log(JSON.stringify({})); + process.exit(0); } -function readStdin() { - return new Promise((resolve) => { - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); - }); -} - -readStdin().then(main).catch(console.error); +console.log(JSON.stringify({ decision: 'allow' })); ``` -#### 7. Consolidate memories (SessionEnd) +#### 6. Consolidate Memories (`consolidate.js`) -**`.gemini/hooks/consolidate.js`:** - -````javascript +```javascript #!/usr/bin/env node -const fs = require('fs'); -const path = require('path'); -const { GoogleGenerativeAI } = require('@google/generative-ai'); -const { ChromaClient } = require('chromadb'); - -async function main() { - const input = JSON.parse(await readStdin()); - const projectDir = process.env.GEMINI_PROJECT_DIR; - const sessionId = process.env.GEMINI_SESSION_ID; - - const tempFile = path.join( - projectDir, - '.gemini', - 'memory', - `session-${sessionId}.jsonl`, - ); - - if (!fs.existsSync(tempFile)) { - console.log(JSON.stringify({})); - return; - } - - // Read interactions - const interactions = fs - .readFileSync(tempFile, 'utf8') - .trim() - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)); - - if (interactions.length === 0) { - fs.unlinkSync(tempFile); - console.log(JSON.stringify({})); - return; - } - - // Extract memories using LLM - const genai = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); - const model = genai.getGenerativeModel({ model: 'gemini-2.0-flash-exp' }); - - const prompt = `Extract important project learnings from this session. -Focus on: decisions, conventions, gotchas, patterns. -Return JSON array with: category, summary, keywords - -Session interactions: -${JSON.stringify(interactions, null, 2)} - -JSON:`; - - try { - const result = await model.generateContent(prompt); - const text = result.response.text().replace(/```json\n?|\n?```/g, ''); - const memories = JSON.parse(text); - - // Store in ChromaDB - const client = new ChromaClient({ - path: path.join(projectDir, '.gemini', 'chroma'), - }); - const collection = await client.getCollection({ name: 'project_memories' }); - const embedModel = genai.getGenerativeModel({ - model: 'text-embedding-004', - }); - - for (const memory of memories) { - const memoryText = `${memory.category}: ${memory.summary}`; - const embedding = await embedModel.embedContent(memoryText); - const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - - await collection.add({ - ids: [id], - embeddings: [embedding.embedding.values], - documents: [memoryText], - metadatas: [ - { - category: memory.category || 'general', - summary: memory.summary, - keywords: (memory.keywords || []).join(','), - timestamp: new Date().toISOString(), - }, - ], - }); - } - - fs.unlinkSync(tempFile); - - console.log( - JSON.stringify({ - systemMessage: `🧠 ${memories.length} new learnings saved for future sessions`, - }), - ); - } catch (error) { - console.error('Error consolidating memories:', error); - fs.unlinkSync(tempFile); - console.log(JSON.stringify({})); - } -} - -function readStdin() { - return new Promise((resolve) => { - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); - }); -} - -readStdin().then(main).catch(console.error); -```` - -### Example session - -``` -> gemini - -🧠 3 memories loaded - -> Fix the authentication bug in login.ts - -πŸ’­ 2 memories recalled: - - [convention] Use middleware pattern for auth - - [gotcha] Remember to update token types - -🎯 Filtered 127 β†’ 15 tools - -[Agent reads login.ts and proposes fix] - -βœ… Tests passed: login.ts - ---- - -> Add error logging to API endpoints - -πŸ’­ 3 memories recalled: - - [convention] Use middleware pattern for auth - - [pattern] Centralized error handling in middleware - - [decision] Log errors to CloudWatch - -🎯 Filtered 127 β†’ 18 tools - -[Agent implements error logging] - -> /exit - -🧠 2 new learnings saved for future sessions -``` - -### What makes this example special - -**RAG-based tool selection:** - -- Traditional: Send all 100+ tools causing confusion and context overflow -- This example: Extract intent, filter to ~15 relevant tools -- Benefits: Faster responses, better selection, lower costs - -**Cross-session memory:** - -- Traditional: Each session starts fresh -- This example: Learns conventions, decisions, gotchas, patterns -- Benefits: Shared knowledge across team members, persistent learnings - -**All hook events integrated:** - -Demonstrates every hook event with practical use cases in a cohesive workflow. - -### Cost efficiency - -- Uses `gemini-2.0-flash-exp` for intent extraction (fast, cheap) -- Uses `text-embedding-004` for RAG (inexpensive) -- Caches tool descriptions (one-time cost) -- Minimal overhead per request (<500ms typically) - -### Customization - -**Adjust memory relevance:** - -```javascript -// In inject-memories.js, change nResults -const results = await collection.query({ - queryEmbeddings: [result.embedding.values], - nResults: 5, // More memories -}); -``` - -**Modify tool filter count:** - -```javascript -// In rag-filter.js, adjust the limit -allowedFunctionNames: filtered.slice(0, 30), // More tools -``` - -**Add custom security patterns:** - -```javascript -// In security.js, add patterns -const SECRET_PATTERNS = [ - // ... existing patterns - /private[_-]?key/i, - /auth[_-]?token/i, -]; +// Logic to save final session state +console.error('Consolidating memories for session end...'); ``` ## Packaging as an extension -While project-level hooks are great for specific repositories, you might want to -share your hooks across multiple projects or with other users. You can do this -by packaging your hooks as a [Gemini CLI extension](../extensions/index.md). - -Packaging as an extension provides: - -- **Easy distribution:** Share hooks via a git repository or GitHub release. -- **Centralized management:** Install, update, and disable hooks using - `gemini extensions` commands. -- **Version control:** Manage hook versions separately from your project code. -- **Variable substitution:** Use `${extensionPath}` and `${process.execPath}` - for portable, cross-platform scripts. - -To package hooks as an extension, follow the -[extensions hook documentation](../extensions/index.md#hooks). - -## Learn more - -- [Hooks Reference](index.md) - Complete API reference and configuration -- [Best Practices](best-practices.md) - Security, performance, and debugging -- [Configuration](../get-started/configuration.md) - Gemini CLI settings -- [Custom Commands](../cli/custom-commands.md) - Create custom commands +While project-level hooks are great for specific repositories, you can share +your hooks across multiple projects by packaging them as a +[Gemini CLI extension](https://www.google.com/search?q=../extensions/index.md). +This provides version control, easy distribution, and centralized management.