生命不息,折腾不止。Agent 敢让它碰服务器,就得先给它装上刹车和计价器。
第五课我们把 Claude Code 包成了 FastAPI 服务,谁都能发个请求让它干活。上线第二天我就睡不着了:这玩意儿手里握着服务器权限、能跑任意 shell、能改任意文件,而我连它昨晚跑了多少条命令、烧了多少钱都不知道。
这一课不写新功能,专门补两个「生产环境才想起来、出事就来不及」的东西:门禁 和仪表盘 。
一、先把风险摆上桌 一个无人值守的 Agent,出事就三种:
手滑型 :rm -rf 路径拼错、git push --force 把同事的提交冲掉;
越界型 :改 .env、改 ~/.ssh/、往 /etc/ 里写东西;
烧钱型 :一个死循环把对话刷到 200 轮,账单比工资高。
这三种,靠「提示词里求它别这样」是没用的。大模型是概率机器,你说一百遍「小心点」,它总有一次会忘。能兜住概率的,只有确定性代码 ——这就是 Claude Code 的 Hooks 存在的意义:在工具真正执行之前,跑一段你写的脚本,由脚本说了算。
官方文档对它的定位很直白:Hooks 提供的是「确定性控制」,让某些操作总是 发生,而不是指望模型自己想起来。这句我认同,把它当成 Agent 的中间件防火墙来用就对了。
2.1 事件挂在哪儿 PreToolUse 在工具调用执行前 触发,是唯一能在动手前掐断的位置。配置写在设置文件里,三个位置对应三种生效范围:
文件
范围
~/.claude/settings.json
当前用户所有项目(我们服务用这个)
.claude/settings.json
当前项目,会进 git,团队共用
.claude/settings.local.json
当前项目,只给自己
注册长这样,matcher 是工具名,多个用 | 隔开:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 { "hooks" : { "PreToolUse" : [ { "matcher" : "Bash|Write|Edit|MultiEdit" , "hooks" : [ { "type" : "command" , "command" : "python3 /opt/agent/hooks/guard.py" , "timeout" : 10 } ] } ] } }
两个细节:matcher 区分大小写 ,写 bash 是不生效的;timeout 单位是秒,门禁脚本必须够快,卡十秒以上会明显拖慢每一轮对话。
2.2 写一个 guard.py Hook 脚本的输入是 stdin 上的一段 JSON,里面有 session_id、tool_name、tool_input。拿 Bash 举例,tool_input.command 就是它准备执行的那条命令。
下面这版只用 Python 标准库,不依赖 jq,可直接扔到服务器上跑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 """guard.py —— 危险命令拦截 + 敏感文件保护 + 全量审计""" import json, os, re, sys, time, pathlibHOME = os.path.expanduser("~" ) AUDIT = pathlib.Path(HOME) / "claude-audit.jsonl" PROTECTED_DIRS = ["/etc/" , "/boot/" , "/dev/" , "/proc/" , f"{HOME} /.ssh/" , f"{HOME} /.claude/" ] PROTECTED_NAMES = {".env" , ".env.local" , ".cf-env" , "id_rsa" , "id_ed25519" , "authorized_keys" } def check_bash (cmd: str ): compact = re.sub(r"\s+" , " " , cmd or "" ).strip() if re.search(r"\brm\b[^|;&]*\s-[a-zA-Z]*[rf]" , compact): target = compact.split()[-1 ].strip("'\"" ) if target in ("/" , "/*" , "~" , "~/" , "$HOME" , "*" , "./*" ): return f"拒绝执行:rm 递归删除 {target} " rules = [ (r"\bgit\s+push\b[^|;&]*(--force\b|\s-f\b)" , "拒绝执行:强制推送,远端历史会被覆盖" ), (r"\b(mkfs(\.\w+)?|fdisk|parted)\b" , "拒绝执行:磁盘分区/格式化" ), (r"\bdd\b[^|;&]*of=/dev/" , "拒绝执行:dd 直接写块设备" ), (r"\b(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(ba|z)?sh\b" , "拒绝执行:网络内容直接管道给 shell,先下载再人工审" ), (r"\b(DROP|TRUNCATE)\s+(DATABASE|TABLE)\b" , "拒绝执行:删库/清表语句" ), (r"\b(shutdown|reboot|halt|poweroff)\b" , "拒绝执行:关机或重启" ), (r"\bchmod\s+-R\s+0?777\s+/" , "拒绝执行:对根目录递归 777" ), ] for pattern, reason in rules: if re.search(pattern, compact, re.IGNORECASE): return reason return None def check_file (path: str ): if not path: return None p = os.path.abspath(os.path.expanduser(path)) for d in PROTECTED_DIRS: if p.startswith(os.path.abspath(d)): return f"拒绝修改:{p} 在受保护目录内" if os.path.basename(p) in PROTECTED_NAMES: return f"拒绝修改:{p} 是敏感文件(凭据/密钥)" return None def main (): try : payload = json.load(sys.stdin) except Exception: return tool = payload.get("tool_name" , "" ) ti = payload.get("tool_input" ) or {} reason = None if tool == "Bash" : reason = check_bash(ti.get("command" , "" )) elif tool in ("Write" , "Edit" , "MultiEdit" , "NotebookEdit" ): reason = check_file(ti.get("file_path" ) or ti.get("notebook_path" )) record = { "ts" : time.strftime("%Y-%m-%dT%H:%M:%S%z" ), "session" : payload.get("session_id" , "" )[:8 ], "tool" : tool, "decision" : "deny" if reason else "allow" , "reason" : reason, "input" : ti, } try : with AUDIT.open ("a" , encoding="utf-8" ) as f: f.write(json.dumps(record, ensure_ascii=False )[:4000 ] + "\n" ) except Exception: pass if reason: print (json.dumps({"hookSpecificOutput" : { "hookEventName" : "PreToolUse" , "permissionDecision" : "deny" , "permissionDecisionReason" : reason, }}, ensure_ascii=False )) if __name__ == "__main__" : main()
然后 chmod +x /opt/agent/hooks/guard.py。文件其实用 python3 调用,不加执行位也能跑,但加上省事——别的脚本引用时不用再想路径。
2.3 为什么用 JSON deny,而不是 exit 2 官方给的两条路:脚本 exit 2 把 stderr 当理由反馈给模型;或者 exit 0 往 stdout 打一个 JSON 决策。我选后者,原因有三:
能区分三档 :permissionDecision 可以是 deny(直接拒)、ask(弹给人确认)、allow(跳过交互提示)。exit 2 只有「拒」一种语气。
理由能说清楚 :permissionDecisionReason 会原样喂回给 Claude,它看得懂就不会一根筋重试,会换个思路。
最硬的一点 :文档写明 deny 在所有权限模式下都生效 ,连 bypassPermissions 和 --dangerously-skip-permissions 都绕不过去。
最后这条是整套方案的命门。我们的服务为了让它少卡在权限提示上,本来就用宽权限在跑;要是门禁能被宽权限模式绕过,那就是个摆设。
2.4 立刻验一下 不用开服务,直接拿 JSON 喂脚本:
1 echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | python3 /opt/agent/hooks/guard.py
应该看到 "permissionDecision": "deny"。再试 ls -la,什么都不输出——不输出就是放行 ,这条规矩要记住,后面排查全靠它。
接着在 Claude Code 里输入 /hooks,能看到刚注册的 hook 挂在 PreToolUse 下面,说明配置被读进去了。改完设置文件一般自动热加载,不生效就重开会话。
三、门禁第二层:审计日志和配置变更 拦截只能挡住「你想到的坏事」,剩下靠记录。
~/claude-audit.jsonl 现在是每行一条 JSON,随手用 jq 就能查:
1 2 3 4 5 jq -r 'select(.decision=="deny") | "\(.ts) \(.tool) \(.reason)"' ~/claude-audit.jsonl | tail -20 jq -r 'select(.session=="a1b2c3d4" and .tool=="Bash") | .input.command' ~/claude-audit.jsonl
还有一个容易漏的口子:模型没动你的文件,但它改了配置 。Claude Code 有个 ConfigChange 事件,外部进程或编辑器改设置文件、skills 文件时触发,一行就能记下来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { "hooks" : { "ConfigChange" : [ { "matcher" : "" , "hooks" : [ { "type" : "command" , "command" : "jq -c '{ts: now | todate, source: .source, file: .file_path}' >> ~/claude-config-audit.log" } ] } ] } }
matcher 留空表示全记,也可以只盯 user_settings、project_settings、skills 这几类。有人偷偷把门禁那行删了,这里会留痕——审计日志的第一价值不是抓模型,是抓人 。
四、仪表盘:把每次调用记成账 门禁解决「别乱来」,仪表盘解决「花了多少」。
4.1 成本数据藏在返回里 第五课我们用 claude -p 跑无头模式,那时只取了 result 文本。其实加 --output-format json 后,返回的是一整个结果对象,成本就在里面:
1 2 3 4 5 6 7 8 9 10 { "type" : "result" , "subtype" : "success" , "result" : "……最终回答……" , "session_id" : "abc-123-def" , "num_turns" : 4 , "duration_ms" : 2847 , "total_cost_usd" : 0.0034 , "is_error" : false }
四个字段直接够做报表:total_cost_usd 花了多少、num_turns 转了几轮、duration_ms 花了多久、session_id 串起整条链路。
一句提醒:这个金额是客户端按公开价目估算的 ,跟你实际账单可能有出入。当趋势看、当限额依据够用,别拿去跟财务对账。
4.2 记账 + 日限额熔断 在第五课那个 FastAPI 里插两段就成。先是记账:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import json, sqlite3, subprocess, time, datetimeDB = "/opt/agent/usage.db" def init_db (): with sqlite3.connect(DB) as c: c.execute("""CREATE TABLE IF NOT EXISTS usage( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, day TEXT, session_id TEXT, cost_usd REAL, turns INTEGER, duration_ms INTEGER, ts INTEGER)""" )def record_usage (user_id: str , data: dict ): day = datetime.date.today().isoformat() with sqlite3.connect(DB) as c: c.execute("INSERT INTO usage(user_id,day,session_id,cost_usd,turns,duration_ms,ts)" " VALUES(?,?,?,?,?,?,?)" , (user_id, day, data.get("session_id" ), float (data.get("total_cost_usd" ) or 0 ), int (data.get("num_turns" ) or 0 ), int (data.get("duration_ms" ) or 0 ), int (time.time())))
再是调用 + 熔断,注意 --max-budget-usd 这个参数,它是单次调用的硬上限 ,模型烧到这个数会被强制停:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 DAILY_QUOTA = 2.0 def run_claude (user_id: str , prompt: str ) -> dict : day = datetime.date.today().isoformat() with sqlite3.connect(DB) as c: spent = c.execute("SELECT COALESCE(SUM(cost_usd),0) FROM usage" " WHERE user_id=? AND day=?" , (user_id, day)).fetchone()[0 ] if spent >= DAILY_QUOTA: raise RuntimeError(f"今日额度已用完:${spent:.2 f} /${DAILY_QUOTA} " ) proc = subprocess.run( ["claude" , "-p" , prompt, "--output-format" , "json" , "--max-turns" , "12" , "--max-budget-usd" , "0.50" ], capture_output=True , text=True , timeout=900 , ) line = proc.stdout.strip().splitlines()[-1 ] data = json.loads(line) record_usage(user_id, data) return {"text" : data.get("result" , "" ), "cost_usd" : data.get("total_cost_usd" )}
三层防线叠起来:日限额挡「今天别再用」、--max-budget-usd 挡「单次别失控」、--max-turns 挡「别无限转圈」。少一层都会在某个深夜被教育。
4.3 加一个 /usage 接口 1 2 3 4 5 6 7 8 9 @app.get("/usage/{user_id}" ) def usage (user_id: str ): day = datetime.date.today().isoformat() with sqlite3.connect(DB) as c: rows = c.execute("SELECT day, SUM(cost_usd), COUNT(*), SUM(turns)" " FROM usage WHERE user_id=? GROUP BY day" " ORDER BY day DESC LIMIT 7" , (user_id,)).fetchall() return [{"day" : d, "cost_usd" : round (c or 0 , 4 ), "calls" : n, "turns" : t} for d, c, n, t in rows]
前端随便画个柱状图,「这个月谁在烧钱」一目了然。这套东西不高级,但真出问题时能救命:上周我这边一次异常重试,就是靠 calls 突然翻十倍发现的。
五、进阶:接 OpenTelemetry 做团队级看板 如果 Agent 不止一个服务、还想接 Grafana,Claude Code 原生支持 OTel 导出,不用自己埋点。环境变量四行搞定:
1 2 3 4 export CLAUDE_CODE_ENABLE_TELEMETRY=1export OTEL_METRICS_EXPORTER=otlpexport OTEL_EXPORTER_OTLP_PROTOCOL=grpcexport OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317
导出的指标里,跟我关心的直接相关这几个:
指标
含义
claude_code.cost.usage
会话成本,单位美元
claude_code.token.usage
token 消耗,按类型分
claude_code.session.count
会话数
claude_code.active_time.total
真实活跃时长,不含闲置
想按人/按团队拆分,加 OTEL_RESOURCE_ATTRIBUTES:
1 export OTEL_RESOURCE_ATTRIBUTES="user.id=wufeng,team.id=ops,cost_center=main"
这些键会挂到每一条指标上,Grafana 里直接 group by 就行。
三个坑先说清楚,省得你踩:
写在仓库里的 .claude/settings.json 里的 OTEL 变量会被忽略 。这是故意的,防止一个 clone 下来的仓库偷偷把你的遥测导到别处。要写在 ~/.claude/settings.json 的 env 块或托管设置里。
OTEL_* 不会传给子进程 ,包括 Bash 工具和 hooks。你在 hook 里调自己的埋点,得单独设一遍。
验证是否通了,看后端有没有 claude_code.session.count;本地也可以先用 export OTEL_METRICS_EXPORTER=console 打到终端上看看。
顺便提一句,交互模式下 /usage 命令能直接看当场会话的 token 用量,临时对一下账很方便,但它只是当场的,做不了历史统计——所以我们才自己记 SQLite。
六、验收清单 上线前照着走一遍,别只看「配置没报错」:
rm -rf / 被拒,理由出现在对话里 → 门禁生效;
让它改 .env,被拒 → 文件保护生效;
ls -la 正常执行 → 没误伤,这条最容易翻车;
~/claude-audit.jsonl 里有对应记录,decision 字段对得上;
跑一次正常任务,/usage/{user_id} 里出现一条 cost_usd > 0 的记录 → 记账生效;
手动把某用户当天额度改小,请求返回额度不足 → 熔断生效。
还有两个容易踩的坑,都是排查了一下午换来的:
hook 打印的 JSON 前面不能有别的东西 。有些机器的 ~/.bashrc 里有一句无脑 echo "Shell ready",hook 在非交互 shell 里跑也会加载 profile,结果 stdout 变成「Shell ready」+ JSON,Claude Code 一看不是 { 开头就当纯文本扔了,而且退出码是 0,连报错都没有 。修法是把那句 echo 包进 if [[ $- == *i* ]]; then ... fi,只在交互式 shell 里打印。
permissionDecision 必须在 hookSpecificOutput 里面 ,放顶层照样是合法 JSON,Claude Code 静默忽略。这类「没报错但没生效」的问题,用 claude --debug-file /tmp/cc.log 起一次,日志里搜 unrecognized keys 立刻现形。
最后说句实在的:这套门禁不防「蓄意攻击」,防的是「模型在你不看着的时候手滑」。真要跑不受信任的代码,还得上容器和沙箱,Hooks 只是第一道闸。
生命不息,折腾不止。门禁和仪表盘齐了,下一步该让这一个服务学会「分工」——下一课用 subagent 加 Skills,把一个大任务拆给几个专职小助手,顺带把单次成本再压一半。Claude Code 第七课:让 Agent 自己带团队。