面向要改这套 hook 的人。读之前先知道两件事: ① AtomCode 的 hook 只有 8 个事件、配置文件是
.hooks.json、只支持 shell 命令 (上游docs/hooks.md与docs/webhook-guide.md已过时,以cc_hooks.rs源码为准); ② 本发行版不改 AtomCode 内核,hook 是四个外部面之一,也是唯一能在无人值守下 压住模型行为的那一个(M0 §4.5 实测:bypass权限档下 PreToolUse 的 deny 依然生效)。
distro/workspace-template/.hooks/ 下五组实现,.hooks.json 里 8 条注册:
| 注册名 | 事件 | 实现 | 默认 | 干什么 |
|---|---|---|---|---|
hca-guard |
PreToolUse(matcher *) |
guard.sh → guard.py |
开 | 唯一的硬约束:写类工具只放行 reports/ theses/;bash/bash_start 只放行只读与纯计算;任何工具都不许碰工作区外;给 hunter 系 MCP 注入 _hermes_user_id;为 audit 落「开始时刻 + 参数摘要」 |
hca-context |
UserPromptSubmit | context.sh → context.py |
开 | 注入真实日期 / 交易日与时段(AKShare 真实日历)/ 工作区资产清单 |
hca-lang |
UserPromptSubmit | lang.sh → lang.py |
开(HCA_LANG_HOOK=0 关) |
语言硬约束(承接 opencode 的 hunter-lang 插件的提示词侧那一道) |
hca-budget |
UserPromptSubmit | budget.sh → budget.py |
关 | 超预算就把这一轮退回 |
hca-audit |
PostToolUse(matcher *) |
audit.sh → audit.py |
开 | 落 audit.jsonl |
hca-audit-fail |
PostToolUseFailure(matcher *) |
同上 | 开 | 失败的调用走这个事件 |
hca-budget-stop |
Stop | budget.sh → budget.py |
关 | 记账 |
hca-budget-stopfail |
StopFailure | 同上 | 关 | 记账(provider 出错那一类终止) |
为什么每组都是 .sh + .py 两个文件:在 .sh 里用 heredoc 喂 python 会占掉 stdin,
python 读到的是脚本自己而不是 hook 的输入(M1 实测踩过,session_id / tool 全是 null)。
.sh 只负责挑一个 python 解释器然后 exec。
opencode 那套有两个插件(hunter-community/scripts/opencode-mcp/plugins/,公开仓里就这两个;
全仓 grep budget 没有第三个)。加上 opencode 自己承担的两件事,逐条对照:
| opencode 侧 | 挂在哪 | 它做什么 | HCA 的去处 | 状态 |
|---|---|---|---|---|
hunter-mcp-context.ts |
tool.execute.before |
给 hunter 系 MCP 的参数注入 _hermes_user_id(从会话归属查) |
hca-guard(PreToolUse 的 hookSpecificOutput.updatedInput) |
✅ 等价(M2 关闭 P0-5;M3 改成按 session_id 查 /api/internal/session/{sid}/user,多用户不串户) |
hunter-lang.ts |
experimental.text.complete(出口) |
整段回答写完后送 /api/internal/lang/guard 净化/翻译 |
拆成两半:提示词侧 → hca-lang;出口侧 → AtomCode 没有对应事件,见 §5 |
|
| opencode 的登录态 / 用户鉴权 | 服务端 | 谁是谁、能不能用 | 上移到 Web 层,见 §6 | ✅ 有意上移 |
| (没有)预算控制 | — | opencode 版线上没有按会话/按天掐 token 的东西 | hca-budget,默认关 |
✅ 新增但默认关,行为与现状一致 |
| (没有)工具审计 | — | opencode 版没有落地的工具级审计流水 | hca-audit |
✅ 新增 |
| 事实 | 出处 | 影响 |
|---|---|---|
.hooks.json 必须是严格 JSON,连 // 注释都不许;解析失败静默返回空 |
cc_hooks.rs:170-176 |
表现是 hooks list 说文件 ✓ 找到了、却 (No hooks loaded)。说明文字只能塞在被 serde 忽略的 _说明 键里 |
hook 的 command 不做环境变量展开 |
cc_hooks.rs:276 直接 sh -c <command> |
${HCA_WORKSPACE} 由 deploy/daemon/hca-init.py 在铺工作区时替换(.mcp.json 相反,交给 AtomCode 自己展开,密钥不落盘) |
| 加载顺序 = hook 名字的字典序 | HooksFile.hooks 是 BTreeMap(cc_hooks.rs:154) |
PreToolUse 是顺序折叠且遇 deny 就 break,所以名字决定谁先跑。目前 PreToolUse 只有 guard 一条 |
| UserPromptSubmit 的多个 hook 并发跑,各自 stdout join 后追加到用户消息;任一条 block 即整轮退回 | cc_hooks.rs:622-684 |
context / lang / budget 三条互不影响;budget 要拦就直接拦得住 |
| 判定看 stdout 最后一行 JSON | last_json_line,cc_hooks.rs:422 |
纯文本注入的 hook(context / lang)最后一行不能长得像 JSON |
exit 2 才是 block,且必须有理由;裸 exit 2 或解释器启动失败被当成"脚本坏了"而放行 |
cc_hooks.rs:376-420 |
所以本发行版一律 exit 0 + stdout 表达语义,不用退出码 |
PostToolUse 的 payload 里没有 tool_input、没有耗时,两个事件都不带 call_id |
cc_hooks.rs:763-903 |
审计要记参数与耗时,只能由 PreToolUse 侧落一份起始记录再配对(见 §4.2) |
Stop / StopFailure 的 payload 只有 session_id / transcript_path / stop_hook_active / stop_reason / cwd,没有 token |
cc_hooks.rs:708-742 |
预算 hook 的 token 只能另找源(见 §4.3) |
transcript_path 指向会话的 .jsonl,同目录同名的 .meta 是 SessionMeta |
parts.rs:1027-1034、session/manager.rs:1062 |
这是唯一按会话精确的用量源 |
超时会 kill_on_drop 连子进程一起杀 |
cc_hooks.rs:307 |
想异步上报必须 start_new_session=True 脱离进程组 |
atomcode hooks test <名> 按事件名或 command 子串匹配,取第一个命中的;payload 是固定的样例,不能自定义 |
cli/main.rs:3752-3812 |
同一个脚本注册在多个事件上时,用事件名来点名;要喂自定义 payload 得用自己的用例脚本(tools/hooks/run-hook-cases.sh) |
都是先读上游源码找出来的,再写成单测;逐条对 M3 那版 guard 复验过,六个用例旧版全部漏过
(复验脚本与输出见 docs/evidence/M4/guard-old-vs-new.txt):
bash_start:上游tools/mod.rs:210无条件注册bash_start/bash_poll/bash_kill三个后台 shell 工具,bash_start的参数名和bash一样是command(bash.rs:453)。 M2/M3 的 guard 只判tool == "bash"—— 同一条rm -rf holdings/用bash被拒、 丢到bash_start直接放行。现在BASH_TOOLS = {"bash", "bash_start"}同判。- 直接调取数库(待办池 P1-20):
INLINE_NET_RE认的是"谁在发 HTTP",而import akshare as ak; ak.stock_zh_a_daily(...)是 akshare 自己在内部发,脚本里 一个requests字样都没有。改成按库名拦(DATA_LIB_RE,覆盖 akshare/tushare/ yfinance/baostock/efinance/adata/pywencai/jqdatasdk/rqdatac/mootdx/qstock)。 - 包装命令与
sh -c:env X=1 rm -rf /的首词是env、timeout 5 rm的首词是timeout,旧版判不到真正的命令;sh -c "rm -rf x"的子命令整条在引号里,压根不是 一个 token。现在real_head()剥包装(env/nohup/timeout/stdbuf/nice/ionice/setsid/ command/exec/time/watch/xargs/parallel),-c的子命令递归判(限 3 层)。 顺带补了python -m pip|venv|http.server|…(首词是 python,旧版判不到模块)。 - 中间带
..的路径:判据从「以/~../开头」放宽到「任何含/的 token 折成绝对路径再判」。旧版漏掉cat holdings/../../etc/passwd。 放宽不会误伤:折完还在工作区内的一律放行,print(1/2)、2026/09/22、s/a/b/折出来都在工作区里面(单测test_pure_calculation_still_allowed守着)。
guard 仍然不拦的(有意):web_fetch / web_search(它们带 URL,来源可追溯)、
只读的 read_file / grep / glob(限制在工作区内)、mcp__*(那正是我们希望它走的路)。
PostToolUse 的 payload 只有 session_id / hook_event_name / tool_name /
tool_response / cwd。要记「参数摘要」和「耗时」,就得有人在 PreToolUse 侧记下来;
而两个事件都不带 call_id,没有任何字段能把 pre 和 post 配起来。
两种做法:再挂一个 PreToolUse 的 audit hook(每次工具调用多起一个 python 进程), 或者让本来就每次都跑的 guard 顺手落一份。取后者:
- guard 在放行的调用上写
.atomcode/tool-start/<sha1(session+tool)[:16]>.json(os.replace原子换名,避开多进程读改写竞争;M2 实测一轮里出现过 5 组并行工具调用); - audit 在 PostToolUse 里取走并删除,算出
duration_ms; - 被 guard 拒的调用不留记录 —— 它不会有 PostToolUse,留下就成了孤儿, 会被下一次同名调用配走、算出一个假的耗时;
- 同一 (会话, 工具) 的并行调用会互相覆盖,这时耗时按最后一次开始算,
记录里标
duration_src="guard-start(may-overlap)",不假装精确; - 配不上就写
null+"—",不猜(总控红线 1)。
其余字段:user_id 从 guard 的身份反查缓存 .atomcode/session-user.json 里读
(只读缓存、不联网 —— 审计不该给每次工具调用加一跳 HTTP),拿不到回落
HUNTER_USER_ID,再拿不到写 null。response_len / response_head(默认 200 字符)
来自 tool_response。
写入:本地一次 open(..., "a") 追加(不 fsync、不读旧内容),超过 HCA_AUDIT_MAX_MB
(默认 64)轮转成 .1。可选异步上报 HCA_AUDIT_WEBHOOK:把记录写进临时文件、
start_new_session=True 派一个脱离进程组的子进程去 POST,父进程立刻退 ——
上游超时会 kill_on_drop 连子进程一起杀,不脱离进程组就白搭。
Stop 的 payload 里没有 token。两个真实的源,都可能拿不到:
-
会话
.meta的turn_stats(首选,按会话精确)。字段形状是真读出来的 (测试机主部署的真实会话,2026-09-22):{"turn_id": 1, "round_count": 4, "tool_call_count": 3, "duration_ms": 30256, "total_tokens": 0, "used_tokens": 12783, "model_usage": [{"provider_id": "hunter", "model_id": "hunter-chat", "tokens": {"input": 17038, "output": 40, "cached_input": 0}}]}实测结论:
tool_call_count与duration_ms可靠;total_tokens恒 0 (与待办池 P1-12 同一个根因);model_usage只有部分 turn 有 —— 同一会话里 turn 1 有、turn 2 整个字段都不在。所以 budget 会同时记「有 usage 的 turn 数 / 总 turn 数」, 只有覆盖率 100% 才拿它当 token 源。 -
网关配额差值(兜底,按 key 全局):
GET $HCA_QUOTA_URL的used_today, 两次 Stop 之间的差。这是 M2 评测算成本用的同一个口子,是真实计量, 但它统计的是这把 key 的全部消耗 —— 单工作区私有化部署(已拍板决策 5)下 就等于这套栈的用量;key 被别处共用时会偏大。记录里写明tokens_src。
两个都拿不到 → token 写 null 且不按 token 拦("不知道" ≠ "超了")。
工具调用次数一路都拿得到,所以那条闸是稳的。
阈值全是 0(不限)、总开关 HCA_BUDGET_ENABLED=0(默认关)。
为什么默认关:计划 v0.2 TP-05 写的是「默认关,与现状一致」——
公开仓里本来就没有 budget 插件,把默认改成"会拒绝用户"属于偷偷改产品语义。
原 hunter-lang 插件挂在 opencode 的 experimental.text.complete 上,是出口:
整段回答写完之后送 /api/internal/lang/guard,拿净化/翻译后的中文回来覆盖最终 part。
插件头注释里引的铁律 A10 说得很明确:「prompt 里的中文约束单独用无效,
必须 prompt + 出口强校验两道一起上」。
AtomCode 的 8 个事件里没有任何一个发生在"助手正文写完"这个点上:
PostToolUse 能改的是 updatedToolOutput(工具返回),不是模型的回答正文;
Stop 拿到的是 session_id / transcript_path / stop_reason,那时回答早已流给用户。
所以 hook 这一层只搬得动提示词侧那一道,hca-lang 做的就是这一道。
出口那一道的正确落点是 Web 转发层(它本来就坐在 SSE 流上,M3 已经有 TurnProjector):
在文本 part 终态时调一次 /api/internal/lang/guard。这个端点在 1.2.0 的 api 镜像里
已经存在(apps/api/app/routers/internal_tools.py:458),api 侧零改动。
M4 没有做这件事(任务书第 1 条只要求 hook 侧),记成 P1-21 进待办池。
现状的风险有多大:M2 的 A/B 评测里 C1「全中文」这一项两边 23 次全部满分
(docs/开发文档/M2-AB对比报告.md),说明人设 + 提示词约束在当前模型上够用;
原事故那种「英文 SKILL 带偏整段回答」的场景,本发行版的 6 个技能正文都是中文,
触发条件也不成立。所以这是一个"已知缺一道保险",不是"现在就在出英文"。
opencode 那套里,"谁在用"这件事由 opencode 服务端的登录态承担,插件只是顺手把
_hermes_user_id 塞进 MCP 参数。AtomCode daemon 不是这个形态:
- daemon 的鉴权只有一个 Bearer token,是它自己启动时生成的(
/run/hca/daemon-token), 没有用户概念;/live也不支持按请求指定工作区(待办池 P0-2)。 - 本发行版是 P0 单工作区(已拍板决策 5):一个工作区一个 daemon。
- 所以"谁是谁"必须在前面挡住:web(Next.js)负责登录与会话归属,api 负责授权, daemon 只在 compose 内网上听、不发布到宿主(总控端口表), 任何请求到 daemon 之前都已经过了 web/api 那一关。
hook 这一层因此不做 auth,只做一件相关的事:把 web 记下来的会话归属
(chat_session_owner,服务端权威、浏览器改不了)翻成 MCP 要的 _hermes_user_id。
查不到就不注入,让下游 MCP 自己报「缺用户身份」—— 比默默用别人的账本强得多。
# 在 daemon 容器里、工作区目录下跑
docker exec -w /workspace hca-daemon atomcode hooks list
docker exec -w /workspace hca-daemon atomcode hooks test guard.sh # 按 command 子串
docker exec -w /workspace hca-daemon atomcode hooks test Stop # 按事件名run_hook_for_test 复用的就是 live 中间件那个 run_command_hook
(cc_hooks.rs:360),所以它观察到的与真实一轮一致。但它的 payload 是固定样例:
PreToolUse 恒是 bash + echo hello、Stop 的 transcript_path 恒为 null。
一键跑全部 8 条并留档:
bash tools/hooks/hooks-test.sh # 输出存 docs/evidence/M4/hooks-test/固定样例覆盖不到的(deny 的理由、身份注入、耗时配对、预算拦截),用:
bash tools/hooks/run-hook-cases.sh # 25 条用例,逐条 期望/实得 对照
python3 -m pytest tools/tests/test_hooks_m4.py -q # 33 条单测(开发机上就能跑;全仓 163 条)distro/workspace-template/.hooks/下放你的.sh+你的.py(照抄lang.sh的三行);.hooks.json里加一条注册(严格 JSON;名字决定 PreToolUse 的排序);- 需要开关就加一个
HCA_*环境变量,并同时写进deploy/env.example与deploy/docker-compose.yml的 daemonenvironment(否则容器里读不到); - 写单测(
tools/tests/)+ 往tools/hooks/cases/加可复现用例; deploy/up.sh --rebuild或docker compose up -d --build daemon, 再atomcode hooks list确认条数对得上 —— 条数不对就是 JSON 写坏了。