Claude Code PreToolUse Hook:禁止 AI 修改 AGENTS.md 和 .claude 规则文件

2 阅读4分钟

我给 Claude Code 写了条规则:不许改治理文件(AGENTS.md、.claude/gates、hooks)。

写完那天我觉得挺严。第二天拿一组路径试了一下:

echo x > .claude/gates/rules.py          → 拦住
echo x >> ./.claude/./gates/rules.py     → 放行
cp /tmp/x .claude/hooks/gate.py          → 放行
ln -sf /tmp/x AGENTS.md                  → 放行

同一个文件,换个写法就进去了。

下面是完整可运行的修复:一个 gate.py,挂在 Claude Code 的 PreToolUse 钩子上,约 120 行,零依赖,附 8 条测试。


一、挂在哪儿

Claude Code 支持工具调用前的钩子。.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash|Write|Edit",
      "hooks": [{
        "type": "command",
        "command": "python3 $CLAUDE_PROJECT_DIR/.claude/hooks/gate.py"
      }]
    }]
  }
}

钩子从 stdin 收到一段 JSON:

{"tool_name": "Bash", "tool_input": {"command": "cp /tmp/x .claude/hooks/gate.py"}, "cwd": "/path/to/repo"}

往 stdout 输出这一行就是拒绝:

{"hookSpecificOutput": {"hookEventName": "PreToolUse",
                        "permissionDecision": "deny",
                        "permissionDecisionReason": "..."}}

二、核心:先归一,再匹配

规则匹配"你写的那串字符"永远会漏。要匹配"它最终指向的文件"。

import os, posixpath

FROZEN_PREFIXES = (".claude/gates/", ".claude/hooks/", ".claude/settings.json")
FROZEN_NAMES = ("agents.md", "claude.md", "kernel.md")

def norm(path: str, root: str) -> str:
    p = (path or "").strip().strip("'\"")
    p = p.replace("\\", "/")
    if not p.startswith("/"):
        p = posixpath.join(root.replace("\\", "/"), p)
    p = posixpath.normpath(p)              # 折叠 // 与 ./,解析 ..
    real = os.path.realpath(p)             # 解析软链:ln -sf 也逃不掉
    base = os.path.realpath(root)
    if real.startswith(base + os.sep):
        p = real
    return "/".join(seg.rstrip(". ") for seg in p.split("/"))   # 逐段去尾随点

def rel_to(path: str, root: str) -> str:
    p, r = norm(path, root), norm(root, root)
    return p[len(r) + 1:] if p.startswith(r + "/") else p

def hit(path: str, root: str) -> str:
    rel = rel_to(path, root).lower()
    if posixpath.basename(rel) in FROZEN_NAMES:
        return "frozen_name"
    for pref in FROZEN_PREFIXES:
        if rel == pref.rstrip("/") or rel.startswith(pref.lower()):
            return "frozen_path:" + pref
    return ""

注意 .claude/gates2/x.py 不会命中 .claude/gates/——因为比较的是带分隔符的前缀,不是 startswith(".claude/gates")。这个边界很多实现都写错了。


三、命令通道:枚举 + 兜底

只堵 > 重定向没用。写文件的方式至少十几种:

cp / mv / ln -sf / rsync / install / tee / dd of= / sed -i / perl -i / truncate

完整代码里对每个命令取"最后一个像路径的参数"作为目标,再统一走 hit():

WRITE_CMDS = {"cp", "mv", "ln", "install", "rsync", "tee", "dd", "sed", "perl", "truncate"}

def targets_of(cmd: str) -> list[str]:
    toks = shlex.split(cmd)
    out = [toks[i + 1] for i, t in enumerate(toks) if t in (">", ">>")]   # 重定向
    if posixpath.basename(toks[0]) in WRITE_CMDS:
        out += [t[3:] for t in toks if t.startswith("of=")]                # dd of=
        cand = [t for t in toks[1:] if not t.startswith("-")]
        if cand:
            out.append(cand[-1])
    out.append(cmd)          # 兜底:命令里出现受保护名字也当目标
    return out

枚举永远不完整,所以最后那条兜底很重要:命令里只要出现 agents.md、kernel.md 这类名字,不管动词是什么,都拦下来让人确认。宁可误拦一次,也不要漏一次。


四、fail-closed:门禁崩了也要拒绝

很多实现是 try/except: pass——门禁挂掉等于没有门禁。我的写法相反:

def main() -> int:
    try:
        payload = json.loads(sys.stdin.read() or "{}")
    except Exception as e:
        deny(f"载荷解析失败({type(e).__name__}),按 fail-closed 拒绝")
        return 0
    try:
        blocked, why = decide(payload)
    except Exception as e:
        blocked, why = True, f"gate 内部异常({type(e).__name__}),按 fail-closed 拒绝"
    if blocked:
        deny(why)
    return 0

读不懂输入、代码抛异常、超时——全部按拒绝处理。 只有"明确判定允许"才放行。


五、实测(8 条,含两条不该拦的)

DENY   echo x > .claude/gates/rules.py
DENY   echo x >> ./.claude/./gates/rules.py
DENY   cp /tmp/x .claude/hooks/gate.py
DENY   ln -sf /tmp/x AGENTS.md
DENY   sed -i '' s/a/b/ .claude/gates/rules.py
allow  Write .claude/gates2/other.py      ← 边界:不是 gates/
allow  Write src/app.py
allow  ls -la

后两条同样重要:一个好的门禁不该把正常开发也拦住,否则人 first thing 就是把它关掉。


六、边界(必须说清楚)

这套东西挡不住有 shell 权限的 agent。它能读你的门禁代码,找没堵的通道。

它真正能做到的是三件:

  • 把"顺手就能改"变成"必须显式做一次"
  • 把"谁想改哪个文件"写进日志,事后能追
  • 让常见的十几种写法失效

单机上做不到绝对隔离。 要隔离得上容器、只读挂载、独立用户——另一个成本量级。


七、可以直接抄的检查清单

① 归一:折叠 // ./ ..、逐段去尾随点、realpath 解析软链
② 匹配:用带分隔符的前缀(.claude/gates/),别用裸 startswith
③ 命令:重定向 + cp/mv/ln/rsync/tee/dd/sed/perl 逐个取目标
④ 兜底:命令里出现受保护名字就拦
⑤ fail-closed:解析失败、异常、超时一律拒绝
⑥ 留痕:记下时间、工具、目标、判定
⑦ 自测:既要测"该拦的拦住",也要测"不该拦的没拦"

少任何一步,都有一类写法能穿过去。


最后

你现在用 Claude Code 的话,最怕它改哪个文件?直接回文件名就行。

我先把我的写在评论里:AGENTS.md——因为它一改,后面所有规则都跟着变。

代码在 github.com/NinesunLiang/CarrorOS(MIT)。