DeepDojo.AI发布 2026-11-12English

用 Hook 保护 .env 和密钥文件

Claude CodeAI 编程

为了不让 Claude Code 改动 .env 和 secrets/,项目里加了一个 PreToolUse Hook,测试正常路径时一切顺利。直到有一次,事件里少了一个字段,脚本抛出 Python 异常退出,退出码是 1。Claude Code 显示了一条警告,然后写入照常发生了。

这正是保护类 Hook 最容易踩的坑:它在出错的时候放行。普通脚本习惯用退出码 1 表示失败,可在多数 Hook 事件里,退出码 1 只是一个不阻断的错误。还有几个容易漏的地方:

  • 只比较原始路径字符串,src/../../secrets/key.pem 这样的写法就能绕过去;
  • 只拦了 Edit 和 Write,Bash 里的写入不经过这道检查;
  • 用 HTTP 服务做检查时,服务返回 500 或连接失败,默认也不阻断。

一个拦得住的保护 Hook,要做到失败即阻断:输入缺字段、解析不了、路径越出项目,都走明确的阻断分支,返回退出码 2。静态的敏感路径先交给权限规则,Hook 只补充更细的判断;进程层面的访问仍交给沙箱。下面给出完整脚本,再逐段讲退出码、处理器选择和脚本审查。

01 静态路径先交给权限规则

静态敏感路径先用 permissions.deny 保护。项目需要更细的路径判断和反馈时,再用 PreToolUse 脚本补充检查。拒绝规则写在配置里,由 Claude Code 直接执行,比脚本更简单可靠,写法见Claude Code 权限白名单的五个常见漏洞。

需要阻止操作,必须用执行前的 PreToolUse;到 PostToolUse 时文件已经改了。

02 一个失败即阻断的保护脚本

创建 .claude/hooks/protect-files.py:

#!/usr/bin/env python3
import json
import os
import sys
from pathlib import Path


def is_protected(path: Path) -> bool:
    return any(
        part in {".git", "secrets", ".env"} or part.startswith(".env.")
        for part in path.parts
    )


def main() -> int:
    try:
        payload = json.load(sys.stdin)
        if not isinstance(payload, dict):
            raise ValueError("event must be an object")
        tool_input = payload.get("tool_input")
        if not isinstance(tool_input, dict):
            raise ValueError("tool_input must be an object")
        root_value = os.environ.get("CLAUDE_PROJECT_DIR")
        cwd_value = payload.get("cwd")
        raw_path = tool_input.get("file_path")
        if not all(isinstance(value, str) and value.strip()
                   for value in (root_value, cwd_value, raw_path)):
            raise ValueError("project directory, cwd and file_path are required")
        if not Path(root_value).is_absolute() or not Path(cwd_value).is_absolute():
            raise ValueError("project directory and cwd must be absolute")
        project_root = Path(root_value).resolve()
        candidate = Path(raw_path)
        if not candidate.is_absolute():
            candidate = Path(cwd_value) / candidate
        resolved_path = candidate.resolve(strict=False)
        relative_path = resolved_path.relative_to(project_root)
        if is_protected(Path(raw_path)) or is_protected(relative_path):
            raise ValueError("protected path")
    except (ValueError, TypeError, OSError, RuntimeError):
        print("Blocked: invalid input, protected path, or path outside the project",
              file=sys.stderr)
        return 2
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

然后在项目 Settings 中注册:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "python3",
            "args": [
              "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.py"
            ],
            "timeout": 10,
            "statusMessage": "Checking protected paths"
          }
        ]
      }
    ]
  }
}

示例由 python3 执行,无需 jq 或脚本可执行标记;目标机器必须提供 Python,也可按项目现有依赖改用 Node.js。

03 逐段看脚本怎样处理一条路径

读脚本时,可以看看它怎样处理一条路径。事件 JSON 经 stdin 进入,cwd 决定相对路径怎样解析,CLAUDE_PROJECT_DIR 再划出项目边界。原始写法和解析结果都要查。

relative_to(project_root) 这一步很关键:解析后的路径只要越出项目目录,就会抛出 ValueError,进入阻断分支。src/../../secrets/key.pem 要用 Path.resolve() 后比较,不能只看原字符串。

缺字段、解析不了,也应进入明确的阻断分支返回 2;若只是抛 Python 异常,常见的退出码 1 反而未必拦得住。开头那次放行,就是因为异常没被捕获,脚本以退出码 1 结束。

看到受保护路径被拦住,再回头看没有覆盖什么。这里只接了 Edit、Write,仍未覆盖 Bash 内部写入;检查通过到真正写入之间,路径也可能变化。进程访问边界还需沙箱:按沙箱文档,沙箱由操作系统作用在 Bash 命令及其子进程上,不管模型选择运行什么,边界都有效。即使返回 0,含义也只是这个脚本没拦,后面的权限判断还在继续。

小黑在 Hook 事件链末端根据处理结果选择继续、询问或阻断

04 退出码和输出的约定

Hook 通过退出码和输出传递结果。普通脚本常用的 exit 1,在多数 Hook 事件中只是非阻断错误。先确认事件是否支持阻断,再按协议返回:

退出码默认含义
0执行成功;Claude Code 会尝试把 stdout 解析为结构化 JSON
2阻断信号;忽略 stdout,把 stderr 作为反馈,具体效果取决于事件
其他值大多数事件中只是非阻断错误,显示告警后继续执行

Claude Code 的 Hooks 参考文档把这一点写成了专门的提醒:没有有效 JSON 输出的退出码 1 是非阻断错误,尽管 1 是 Unix 惯用的失败码;如果你的 Hook 是用来执行策略的,就用 exit 2。对 PreToolUse 来说,退出码 2 会阻止这次工具调用。

也可以退出 0,让 stdout 只包含一个合法的结构化决定,例如:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Production configs require approval"
  }
}

PreToolUse 的决定放在 hookSpecificOutput.permissionDecision。值写 allow、deny 或 ask,对应放行、拒绝或询问。官方说明,旧的顶层 decision 字段在这个事件上已经弃用,approve、block 会分别映射为 allow、deny;其他事件的 decision 格式也不能直接搬来。updatedInput 用于修改工具输入,使用时还需核对该事件要求的字段位置与权限。

还有一道额外的保险。按 Hooks 文档,在 v2.1.295 及以后的版本里,可以给 command 或 http 处理器加上 "onFailure": "block":脚本路径写错、脚本崩溃或超时时,Claude Code 会阻断这次操作,而不是照常执行。官方的原话是,在多数事件上,Hook 失败或超时后 Claude Code 仍会执行操作,一个路径写错或会崩溃的策略 Hook 就会放行一切。保护类 Hook 很适合加上它。

05 选处理器,保持同步

当前 Hook 支持五类 handler:

类型工作方式适合场景
command跑本地命令,用输入、退出码和输出传结果格式化、脚本检查、通知、日志
http通过 POST 把事件 JSON 交给服务集中审计、团队流程、远端策略检查
mcp_tool调用已连接的 MCP 工具让现有外部工具处理事件
prompt调一次模型,返回 ok/reason只看事件输入就能作出的含义判断
agent让代理读文件、搜索后再判断还需要调查项目状态的检查

路径条件可以用确定性代码检查,选 command 即可。对这类明确规则,模型判断会增加耗时、成本和不确定性。

HTTP 返回 4xx、5xx,连接失败或超时,默认都只是非阻断错误。真要拦住操作,服务应返回 2xx,再在 JSON 里按该事件的格式作出阻断决定,或者给这个处理器加上 onFailure: "block"。

决定当前操作能否继续的控制型 Hook 必须同步。通知、日志等不影响当前决定的 command handler 可以异步运行;异步结果无法阻止已继续的操作。

同一事件的匹配处理器并行运行,数组顺序不保证完成顺序。格式化和测试有依赖时,把两步放在同一脚本里顺序执行,避免测试读取尚未格式化完成的文件。

同步检查长时间等待时,核对 timeout。单位是秒,应按正常耗时留余量,不能直接搬用其他配置的毫秒值:写成 5000 就是五千秒。

06 审查脚本,再用三组输入测试

Command Hook 以当前系统用户身份运行,可能访问该用户的文件和环境变量。Hooks 文档的安全提醒说得直接:命令 Hook 以你的完整用户权限执行 Shell 命令,能修改、删除或读取你的账号能访问的任何文件。脚本本身需要独立审查,不能仅凭 Claude Code 的工具权限判断它安全。

写 Hook 或使用外部 Hook 时,逐项查:

  • 输入:stdin JSON、路径、参数、远端响应,都先校验再使用。
  • Shell:变量加引号,路径和命令校验后再组合。
  • 路径:先解析,再查 ..、符号链接和项目边界。
  • 位置:用 ${CLAUDE_PROJECT_DIR} 定位项目脚本,避免当前目录改变后运行错文件。
  • 凭证:Settings 不存密钥;HTTP Header 只插入 allowedEnvVars 明确允许的变量。
  • 共享:提交前请其他人审查,在没有秘密的环境中测试。

Hook 可以再收紧动作,但遇到 permissions.deny 仍要拒绝,不能借 Hook 绕过。

最后单独测试脚本:

printf '%s' '{"cwd":"/tmp/demo","tool_input":{"file_path":"/tmp/demo/.env"}}' \
  | CLAUDE_PROJECT_DIR=/tmp/demo python3 .claude/hooks/protect-files.py
printf '%s\n' "$?"

预期退出码是 2。再把路径换成 /tmp/demo/src/app.ts,预期退出码是 0;去掉 file_path 或输入无效 JSON,预期仍为 2。这些测试只判断路径,不会实际写文件。接进 Claude Code 后仍没反应,按Claude Code Hook 不触发怎么排查逐层定位。

回到开头那次放行:脚本把异常也收进阻断分支,再加上 onFailure: "block",缺字段、崩溃、超时都会落到“拦下”这一边。失败时拦得住,这个 Hook 才算真的在保护文件。