Skip to content

Gemini CLI 钩子:最佳实践

本指南涵盖在 Gemini CLI 中开发和部署钩子的安全考虑、性能优化、调试技术和隐私考虑。

安全考虑

验证所有输入

永远不要在没有验证的情况下信任来自钩子的数据。钩子输入可能包含恶意的用户提供数据:

bash
#!/usr/bin/env bash
input=$(cat)

# 验证 JSON 结构
if ! echo "$input" | jq empty 2>/dev/null; then
  echo "无效的 JSON 输入" >&2
  exit 1
fi

# 验证必需字段
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
if [ -z "$tool_name" ]; then
  echo "缺少 tool_name 字段" >&2
  exit 1
fi

使用超时

设置合理的超时以防止钩子无限期挂起:

json
{
  "hooks": {
    "BeforeTool": [
      {
        "matcher": "*",
        "hooks": [
          {
            "name": "slow-validator",
            "type": "command",
            "command": "./hooks/validate.sh",
            "timeout": 5000
          }
        ]
      }
    ]
  }
}

推荐超时:

  • 快速验证:1000-5000ms
  • 网络请求:10000-30000ms
  • 重计算:30000-60000ms

限制权限

以最小所需权限运行钩子:

bash
#!/usr/bin/env bash
# 不要以 root 身份运行
if [ "$EUID" -eq 0 ]; then
  echo "钩子不应以 root 身份运行" >&2
  exit 1
fi

# 写入前检查文件权限
if [ -w "$file_path" ]; then
  # 可以安全写入
else
  echo "权限不足" >&2
  exit 1
fi

扫描密钥

使用 BeforeTool 钩子防止提交敏感数据:

javascript
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 个人访问令牌
  /sk-[a-zA-Z0-9]{48}/, // OpenAI API 密钥
];

function containsSecret(content) {
  return SECRET_PATTERNS.some((pattern) => pattern.test(content));
}

审查外部脚本

在启用之前始终审查来自不受信任来源的钩子脚本:

bash
# 安装前审查
cat third-party-hook.sh | less

# 检查可疑模式
grep -E 'curl|wget|ssh|eval' third-party-hook.sh

# 验证钩子来源
ls -la third-party-hook.sh

沙箱化不受信任的钩子

为了最大安全性,考虑在隔离环境中运行不受信任的钩子:

bash
# 在 Docker 容器中运行钩子
docker run --rm \
  -v "$GEMINI_PROJECT_DIR:/workspace:ro" \
  -i untrusted-hook-image \
  /hook-script.sh < input.json

性能

保持钩子快速

钩子同步运行——慢钩子会延迟代理循环。通过使用并行操作来优化速度:

javascript
// 顺序操作较慢
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());

// 优先使用并行操作以获得更好的性能
const [data1, data2, data3] = await Promise.all([
  fetch(url1).then((r) => r.json()),
  fetch(url2).then((r) => r.json()),
  fetch(url3).then((r) => r.json()),
]);

缓存昂贵操作

在调用之间存储结果以避免重复计算:

javascript
const fs = require('fs');
const path = require('path');

const CACHE_FILE = '.gemini/hook-cache.json';

function readCache() {
  try {
    return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
  } catch {
    return {};
  }
}

function writeCache(data) {
  fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
}

async function main() {
  const cache = readCache();
  const cacheKey = `tool-list-${(Date.now() / 3600000) | 0}`; // 每小时缓存

  if (cache[cacheKey]) {
    console.log(JSON.stringify(cache[cacheKey]));
    return;
  }

  // 昂贵操作
  const result = await computeExpensiveResult();
  cache[cacheKey] = result;
  writeCache(cache);

  console.log(JSON.stringify(result));
}

使用适当的事件

选择与你的用例匹配的钩子事件以避免不必要的执行。AfterAgent 在每个代理循环完成时触发一次,而 AfterModel 在每次 LLM 调用后触发(每个循环可能多次):

json
// 如果检查最终完成,使用 AfterAgent 而不是 AfterModel
{
  "hooks": {
    "AfterAgent": [
      {
        "matcher": "*",
        "hooks": [
          {
            "name": "final-checker",
            "command": "./check-completion.sh"
          }
        ]
      }
    ]
  }
}

使用匹配器过滤

使用特定匹配器避免不必要的钩子执行。不要用 * 匹配所有工具,而是只指定你需要的工具:

json
{
  "matcher": "write_file|replace",
  "hooks": [
    {
      "name": "validate-writes",
      "command": "./validate.sh"
    }
  ]
}

优化 JSON 解析

对于大输入,使用流式 JSON 解析器避免将所有内容加载到内存:

javascript
// 标准方法:解析整个输入
const input = JSON.parse(await readStdin());
const content = input.tool_input.content;

// 对于非常大的输入:流式处理并只提取需要的字段
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;
});

调试

记录到文件

将调试信息写入专用日志文件:

bash
#!/usr/bin/env bash
LOG_FILE=".gemini/hooks/debug.log"

# 带时间戳记录
log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
}

input=$(cat)
log "收到输入: ${input:0:100}..."

# 钩子逻辑在这里

log "钩子成功完成"

使用 stderr 报告错误

stderr 上的错误消息根据退出码适当显示:

javascript
try {
  const result = dangerousOperation();
  console.log(JSON.stringify({ result }));
} catch (error) {
  console.error(`钩子错误: ${error.message}`);
  process.exit(2); // 阻塞错误
}

独立测试钩子

使用示例 JSON 输入手动运行钩子脚本:

bash
# 创建测试输入
cat > test-input.json << 'EOF'
{
  "session_id": "test-123",
  "cwd": "/tmp/test",
  "hook_event_name": "BeforeTool",
  "tool_name": "write_file",
  "tool_input": {
    "file_path": "test.txt",
    "content": "测试内容"
  }
}
EOF

# 测试钩子
cat test-input.json | .gemini/hooks/my-hook.sh

# 检查退出码
echo "退出码: $?"

检查退出码

确保你的脚本返回正确的退出码:

bash
#!/usr/bin/env bash
set -e  # 出错时退出

# 钩子逻辑
process_input() {
  # ...
}

if process_input; then
  echo "成功消息"
  exit 0
else
  echo "错误消息" >&2
  exit 2
fi

启用遥测

telemetry.logPrompts 启用时,钩子执行会被记录:

json
{
  "telemetry": {
    "logPrompts": true
  }
}

在日志中查看钩子遥测以调试执行问题。

使用钩子面板

/hooks panel 命令显示执行状态和最近输出:

bash
/hooks panel

检查:

  • 钩子执行计数
  • 最近的成功/失败
  • 错误消息
  • 执行时间

开发

从简单开始

在实现复杂逻辑之前,从基本的日志钩子开始:

bash
#!/usr/bin/env bash
# 简单的日志钩子以了解输入结构
input=$(cat)
echo "$input" >> .gemini/hook-inputs.log
echo "已记录输入"

使用 JSON 库

使用适当的库解析 JSON,而不是文本处理:

不好:

bash
# 脆弱的文本解析
tool_name=$(echo "$input" | grep -oP '"tool_name":\s*"\K[^"]+')

好:

bash
# 健壮的 JSON 解析
tool_name=$(echo "$input" | jq -r '.tool_name')

使脚本可执行

始终使钩子脚本可执行:

bash
chmod +x .gemini/hooks/*.sh
chmod +x .gemini/hooks/*.js

版本控制

提交钩子以与团队共享:

bash
git add .gemini/hooks/
git add .gemini/settings.json
git commit -m "添加用于安全和测试的项目钩子"

.gitignore 考虑:

gitignore
# 忽略钩子缓存和日志
.gemini/hook-cache.json
.gemini/hook-debug.log
.gemini/memory/session-*.jsonl

# 保留钩子脚本
!.gemini/hooks/*.sh
!.gemini/hooks/*.js

记录行为

添加描述以帮助他人理解你的钩子:

json
{
  "hooks": {
    "BeforeTool": [
      {
        "matcher": "write_file|replace",
        "hooks": [
          {
            "name": "secret-scanner",
            "type": "command",
            "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh",
            "description": "在写入前扫描代码更改中的 API 密钥、密码和其他密钥"
          }
        ]
      }
    ]
  }
}

在钩子脚本中添加注释:

javascript
#!/usr/bin/env node
/**
 * RAG 工具过滤钩子
 *
 * 此钩子通过从用户请求中提取关键词并基于语义相似性
 * 过滤工具,将工具空间从 100+ 工具减少到约 15 个相关工具。
 *
 * 性能:平均约 500ms,缓存工具嵌入
 * 依赖:@google/generative-ai
 */

故障排除

钩子未执行

/hooks panel 中检查钩子名称:

bash
/hooks panel

验证钩子出现在列表中并已启用。

验证匹配器模式:

bash
# 测试正则表达式模式
echo "write_file|replace" | grep -E "write_.*|replace"

检查禁用列表:

json
{
  "hooks": {
    "disabled": ["my-hook-name"]
  }
}

确保脚本可执行:

bash
ls -la .gemini/hooks/my-hook.sh
chmod +x .gemini/hooks/my-hook.sh

验证脚本路径:

bash
# 检查路径展开
echo "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh"

# 验证文件存在
test -f "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" && echo "文件存在"

钩子超时

检查配置的超时:

json
{
  "name": "slow-hook",
  "timeout": 60000
}

优化慢操作:

javascript
// 之前:顺序操作(慢)
for (const item of items) {
  await processItem(item);
}

// 之后:并行操作(快)
await Promise.all(items.map((item) => processItem(item)));

使用缓存:

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;
}

考虑拆分为多个更快的钩子:

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
          }
        ]
      }
    ]
  }
}

无效的 JSON 输出

输出前验证 JSON:

bash
#!/usr/bin/env bash
output='{"decision": "allow"}'

# 验证 JSON
if echo "$output" | jq empty 2>/dev/null; then
  echo "$output"
else
  echo "生成了无效的 JSON" >&2
  exit 1
fi

确保正确的引号和转义:

javascript
// 不好:未转义的字符串插值
const message = `用户说: ${userInput}`;
console.log(JSON.stringify({ message }));

// 好:自动转义
console.log(JSON.stringify({ message: `用户说: ${userInput}` }));

检查二进制数据或控制字符:

javascript
function sanitizeForJSON(str) {
  return str.replace(/[\x00-\x1F\x7F-\x9F]/g, ''); // 删除控制字符
}

const cleanContent = sanitizeForJSON(content);
console.log(JSON.stringify({ content: cleanContent }));

退出码问题

验证脚本返回正确的代码:

bash
#!/usr/bin/env bash
set -e  # 出错时退出

# 处理逻辑
if validate_input; then
  echo "成功"
  exit 0
else
  echo "验证失败" >&2
  exit 2
fi

检查意外错误:

bash
#!/usr/bin/env bash
# 如果你想显式处理错误,不要使用 'set -e'
# set -e

if ! command_that_might_fail; then
  # 处理错误
  echo "命令失败但继续" >&2
fi

# 始终显式退出
exit 0

使用 trap 进行清理:

bash
#!/usr/bin/env bash

cleanup() {
  # 清理逻辑
  rm -f /tmp/hook-temp-*
}

trap cleanup EXIT

# 钩子逻辑在这里

环境变量不可用

检查变量是否设置:

bash
#!/usr/bin/env bash

if [ -z "$GEMINI_PROJECT_DIR" ]; then
  echo "GEMINI_PROJECT_DIR 未设置" >&2
  exit 1
fi

if [ -z "$CUSTOM_VAR" ]; then
  echo "警告: CUSTOM_VAR 未设置,使用默认值" >&2
  CUSTOM_VAR="default-value"
fi

调试可用变量:

bash
#!/usr/bin/env bash

# 列出所有环境变量
env > .gemini/hook-env.log

# 检查特定变量
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

使用 .env 文件:

bash
#!/usr/bin/env bash

# 如果存在则加载 .env 文件
if [ -f "$GEMINI_PROJECT_DIR/.env" ]; then
  source "$GEMINI_PROJECT_DIR/.env"
fi

隐私考虑

钩子输入和输出可能包含敏感信息。Gemini CLI 遵守钩子数据记录的 telemetry.logPrompts 设置。

收集哪些数据

钩子遥测可能包括:

  • 钩子输入: 用户提示、工具参数、文件内容
  • 钩子输出: 钩子响应、决策原因、添加的上下文
  • 标准流: 钩子进程的 stdout 和 stderr
  • 执行元数据: 钩子名称、事件类型、持续时间、成功/失败

隐私设置

启用(默认):

完整的钩子 I/O 记录到遥测。在以下情况下使用:

  • 开发和调试钩子
  • 遥测重定向到受信任的企业系统
  • 你理解并接受隐私影响

禁用:

只记录元数据(事件名称、持续时间、成功/失败)。钩子输入和输出被排除。在以下情况下使用:

  • 将遥测发送到第三方系统
  • 处理敏感数据
  • 隐私法规要求最小化数据收集

配置

在设置中禁用 PII 记录:

json
{
  "telemetry": {
    "logPrompts": false
  }
}

通过环境变量禁用:

bash
export GEMINI_TELEMETRY_LOG_PROMPTS=false

钩子中的敏感数据

如果你的钩子处理敏感数据:

  1. 最小化日志: 不要将敏感数据写入日志文件
  2. 清理输出: 输出前删除敏感数据
  3. 使用安全存储: 加密静态敏感数据
  4. 限制访问: 限制钩子脚本权限

示例清理:

javascript
function sanitizeOutput(data) {
  const sanitized = { ...data };

  // 删除敏感字段
  delete sanitized.apiKey;
  delete sanitized.password;

  // 编辑敏感字符串
  if (sanitized.content) {
    sanitized.content = sanitized.content.replace(
      /api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/gi,
      '[已编辑]',
    );
  }

  return sanitized;
}

console.log(JSON.stringify(sanitizeOutput(hookOutput)));

了解更多

aicodex 文档网站