我给 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)。