Copilot hook 的 stdout 是契约:别让拦截静默失效
写 hook 最舒服的地方是它一定会执行,最危险的地方也在这里:它失效的时候通常不吭声。Copilot 的官方参考里有一条解析规则,几乎决定了脚本该怎么写。CLI 会逐行扫描 hook 的 stdout,把 progress 状态行(形如 {"type": "progress", "message": "..."})剥掉,剩下的内容拼接、去空白之后,当作一个 JSON 文档做一次解析,这一次解析的结果才算 hook 的输出。[4] 注意这不是”每行一个 JSON”,而是”整个 stdout 恰好是一个 JSON”。
契约被破坏的后果不是报错,而是降级:内容为空或者解析失败时,hook 会被当成没有输出,流程直接回到默认行为。[4] 你在脚本里顺手加一句给运维看的 echo,或者为了排查打开 set -x,闸门就安静地打开了。还有一个更细的坑:打印两个决策对象不会以后一个为准,而是拼接成非法 JSON 之后被整体忽略。[4]
退出码的不对称:崩溃会拦,卡住会放#
Copilot 的 hook 退出码语义里有一段很反直觉的不对称。[4] 退出码 0 表示成功,stdout 会被解析成 hook 输出 JSON;退出码 2 默认只是一个警告,stderr 展示给人看、流程继续,但在 preToolUse 和 permissionRequest 上,2 直接等价于拒绝,而且 stdout 里的 JSON 会被并进拒绝决定,哪怕它写的是 permissionDecision: "allow";其他非零退出一般记为 hook 失败并放行,例外是 preToolUse 属于 fail-closed,任意非 2 的非零退出都会拒绝工具调用。[4] 超时则完全相反:脚本被 timeoutSec 杀掉之后,包括 preToolUse 和管理员下发的 policy hook 在内,所有事件都 fail-open,系统只给一个警告,然后按这个 hook 从未运行过继续下去。[4]
把崩溃和超时摆在一起,结论很明确:脚本崩了会拦人,卡住了会放人。这约束了策略脚本的写法:本地决策、不访问网络、不做重活;timeoutSec 也从一个随手填的默认值变成安全参数,默认 30 秒意味着慢脚本最多拖 30 秒,然后静默失效。
matcher 是静默开关,不是过滤器#
matcher 是每个 hook 条目上可选的正则,编译成 ^(?:PATTERN)$ 并要求全值匹配,而且只对部分事件生效:preToolUse、postToolUse、permissionRequest 匹配工具名,preCompact 匹配触发方式,subagentStart 匹配 agent 名,notification 匹配通知类型。[4] 这里的失效方式同样值得记一笔:正则写错不会打断任何东西,只是这个条目被跳过。[4] 工具名是一个固定集合,bash、edit、create、view、grep、glob、task、web_fetch、ask_user,Windows 上多了 powershell,而云 agent 的 Linux 沙箱里没有 powershell。[4]
我的做法是让 matcher 只做粗筛,写工具名就够了;命令和路径的判断全部交给脚本。两类错误的可发现性差得太多:matcher 写错没人告诉你,脚本写错能在离线样例里撞出来。
快速上手:三层结构加一个 JSON 契约#
配置文件本身很克制:必须有 version 字段和 hooks 对象,hook 从 JSON 输入拿到本次调用的上下文。[1] 官方把它定位成在会话关键点执行你自己的 shell 命令。[2] 我用三层结构:JSON 只做事件到脚本的映射,适配层只负责 JSON 进、JSON 出,真正的判断收在一个不读环境、不写文件的决策脚本里,再配一组能直接喂进去的样例。
{
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"bash": "./.github/hooks/pre-tool-use.sh",
"matcher": "bash|edit|create",
"timeoutSec": 5,
"env": { "GUARD_AUDIT_LOG": "/tmp/copilot-guard.jsonl" }
}
]
}
}
适配层的全部职责就是四件事:读 stdin、写审计、调用决策脚本、把结果原样吐到 stdout。
cat > .github/hooks/pre-tool-use.sh <<'SH'
#!/usr/bin/env bash
set -uo pipefail
payload=$(cat)
printf '%s\n' "$payload" >> "${GUARD_AUDIT_LOG:-/tmp/copilot-guard.jsonl}"
if ! decision=$(printf '%s' "$payload" | ./.github/hooks/policy/decide.sh); then
printf 'guard: 决策脚本异常,按拒绝处理\n' >&2
exit 2
fi
if ! printf '%s' "$decision" | jq -e 'has("permissionDecision")' >/dev/null 2>&1; then
printf 'guard: 决策输出不是合法 JSON,按拒绝处理\n' >&2
exit 2
fi
printf '%s\n' "$decision"
SH
chmod +x .github/hooks/pre-tool-use.sh
决策脚本保持纯函数:读 payload,输出一个决策 JSON,别的一概不做。
mkdir -p .github/hooks/policy
cat > .github/hooks/policy/decide.sh <<'SH'
#!/usr/bin/env bash
set -uo pipefail
payload=$(cat)
tool=$(printf '%s' "$payload" | jq -r '.toolName // empty')
args=$(printf '%s' "$payload" | jq -r '.toolArgs // ""')
deny() { jq -nc --arg r "$1" '{permissionDecision:"deny", permissionDecisionReason:$r}'; }
pass() { jq -nc '{permissionDecision:"allow"}'; }
case "$tool" in
bash)
case "$args" in
*"git push --force"*|*"rm -rf /"*) deny '拒绝:破坏性命令,请人工执行' ;;
*) pass ;;
esac ;;
edit|create)
case "$args" in
*.env*|*pnpm-lock.yaml*) deny '拒绝:受保护文件不在 agent 可写范围内' ;;
*) pass ;;
esac ;;
*) pass ;;
esac
SH
chmod +x .github/hooks/policy/decide.sh
三点值得强调。审计写文件、进度写 stderr,stdout 只留给那一个决策 JSON。工具参数在 payload 里的类型是 unknown,官方示例给的是字符串化 JSON(形如 "{\"command\":\"ls\"}"),所以要么再解析一层,要么直接做子串匹配,上面选了后者。[4][5] 适配层把”决策脚本异常”和”输出不是合法 JSON”都当拒绝处理,这与 preToolUse command hook 的 fail-closed 语义一致。[4]
官方给的自检方式就是直接喂样例,把测试输入管道进 hook,再看退出码和输出是不是合法 JSON。[5] 我在这上面加了三条断言:fixture 的输出必须能被 jq 解析出 permissionDecision;审计文件必须在这次调用后变长,证明 hook 真的被触发,而不是被写错的 matcher 跳过;每条 fixture 都写明期望的决策,改策略先跑一遍全部样例;决策脚本是纯函数,测它不需要开任何会话。
printf '%s' '{"timestamp":1704614400000,"cwd":"/tmp","toolName":"bash","toolArgs":"{\"command\":\"ls\"}"}' \
> .github/hooks/policy/fixtures/list.json
./.github/hooks/pre-tool-use.sh < .github/hooks/policy/fixtures/list.json \
| jq -e '.permissionDecision == "allow"' >/dev/null \
&& echo '契约 OK' || echo '契约被破坏:检查 stdout 是否被多写了内容'
仓库不是唯一来源#
另外,.github/hooks/*.json 不是唯一来源。CLI 会按 policy、user、仓库、插件的顺序合并 hook,同一事件命中多个来源时全部都会执行,而 preToolUse 的判定规则是任何一个返回 deny 就阻断。[4] 所以仓库里的 hook 只能加严、不能放宽,覆盖不了同事本机用户级 hook 的拒绝。
需要整体暂停时,用顶层 disableAllHooks: true:写在某个 hooks 文件里,CLI 和云 agent 都认;写在仓库 settings.json 顶层就只有 CLI 认,因为云 agent 不加载它,而 policy hook 不受这个开关影响,仍然会运行。[4] 云 agent 的差异更该单独记一笔:它只加载克隆仓库里的 .github/hooks/*.json,沙箱是一次性的,HOME 指向 /root,并且没有任何用户在场,所以决策里的 ask 会被当作 deny。[4] 想让同一份 hook 同时服务本地和云端,就别写 ask,也别依赖用户级配置或交互确认。
指令文件(AGENTS.md、github/copilot-instructions.md)提供的是关于项目构建和验证的持续上下文,[3] hook 负责的则是”什么绝不能发生”;别把一次都不能错的安全规则只写进指令里。
回到最开始那条契约。hook 的价值在于确定性,而确定性只在三个条件同时成立时存在:输出被正确解析、脚本真的被触发、决策真的生效。所以我把 hook 目录当接口来评审:diff 里的 JSON 只该出现声明级别的变化,脚本是否仍然只输出一个 JSON,样例是否全绿。配好 hook 不等于有保护,能证明它被触发、拒绝真的生效,才算数。
Sources#
[1] https://docs.github.com/en/copilot/concepts/agents/hooks — GitHub Docs: About hooks for GitHub Copilot [2] https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/overview — GitHub Docs: Customize GitHub Copilot CLI [3] https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-custom-instructions — GitHub Docs: Adding custom instructions for GitHub Copilot CLI [4] https://docs.github.com/en/copilot/reference/hooks-reference — GitHub Docs: GitHub Copilot hooks reference [5] https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-hooks — GitHub Docs: Using hooks with GitHub Copilot CLI