这份文档是 codeguard-plugin 与 Codex CLI / ZCode / Kimi Code 三端宿主 交互契约的单源事实。所有 hook 脚本(
hooks/*.py)的实现都必须与本文件 一致;若未来调整任何退出码或 JSON schema,同一 commit 内必须更新本文件。对应的 OpenSpec 规范是
hook-protocol(见openspec/specs/hook-protocol/spec.md)。
| 事件 | Hook 脚本 | stdout | stderr | exit | 阻断? |
|---|---|---|---|---|---|
| SessionStart | env_check.py |
人类可读一行摘要 codeguard 插件环境:... |
仅内部错误时 | 0 | 否 |
| UserPromptSubmit | user_prompt_validator.py |
JSON {"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"..."}} |
仅内部错误时 | 0 | 否 |
PreToolUse Bash |
pre_tool_git_guard.py |
通过:空;未验证:JSON additionalContext | 通过:空;失败:完整修复指令 | 0(通过/放行)/ 2(拦截) | 放行:否;确定违规:是 |
PostToolUse Write|Edit|MultiEdit |
post_tool_lint.py |
JSON {"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"..."}, "systemMessage":"..."} |
仅内部错误时 | 0 | 否 |
| Stop | stop_summary.py |
人类可读会话摘要 | 仅内部错误时 | 0 | 否 |
实现定位(符号而非行号——行号随每次重构腐烂,符号不腐;v0.8.0 起弃用行号指针):
- SessionStart 摘要:
hooks/env_check.py::main(含双副本告警段)。 - UserPromptSubmit JSON:
hooks/user_prompt_validator.py::main(非 git 目录跳过说明_non_git_note)与::is_trigger。 - PreToolUse stderr:
hooks/pre_tool_git_guard.py::main;静态语法在scripts/codeguard/git_syntax.py,仓库观察与is_guarded/_guarded_mode在scripts/codeguard/git_context.py;旧 Hook helper 导入继续兼容。一层脚本观察共用_indirect_bodies。 - PostToolUse JSON:
hooks/post_tool_lint.py::main(通过/自动修复后通过/失败 additionalContext 三处 print)。 - Stop 摘要:
hooks/stop_summary.py::main(含绕过计数summarize与 skipGate 遗留告警)。
门禁内部边界:hooks/gate_lib.py 只保留兼容导出;scripts/codeguard/gate.py 选择内容面与保序汇总,
gate_checks.py 返回各语言独立 GateOutcome;repository_policy.py 检查路径安全和显式豁免,
baseline.py 核验存量证据,spec_validation.py 适配可选 OpenSpec 校验,reporting.py 保留反馈格式。
应用服务无需 hooks 路径或宿主 SDK 即可运行;兼容面不承担第二份业务实现。
语言服务边界:registry_schema 的纯校验同时服务注册表 CLI 与运行时加载,registry 派生
识别/命令表;config 读取配置,discovery 只做项目发现,toolchain 负责探活。
SessionStart 与每轮门禁各持有独立 ToolchainProbe,在目标目录执行且 stdin=DEVNULL;
同批同命令成功探活去重,不同命令仍并行,失败与下一批检查都重新探测。
探活成功不代表 lint 通过。detect_lang、user_config 的原公开导入继续兼容。
codeguard.json 缺失时保留默认值;存在但 JSON/字段类型/扩展语言 ID/排除正则无效时,
CLI/MCP 明确返回 UNVERIFIED,自动修复不执行。门禁以未验证说明放行而非报告通过;
其它 Hook 的配置异常仍遵循既有 fail-open 和 stderr 告警,不把它变成代码违规。
| 宿主 | SessionStart | UserPromptSubmit | PreToolUse Bash | PostToolUse Write|Edit|MultiEdit | Stop |
|---|---|---|---|---|---|
| Codex CLI | stdout 注入 developer context | stdout JSON 注入 additionalContext | exit 2 走工具结果回给 AI;exit 0 静默 | stdout JSON 注入 additionalContext + systemMessage | stdout 摘要 |
| ZCode | 同 Codex | 同 Codex,additionalContext 进 systemMessage | 同 Codex | 同 Codex | 同 Codex |
| Kimi Code CLI | stdout 摘要注入会话上下文 | stdout JSON 注入上下文(观察型,不阻断用户消息) | exit 2 拦截 + stderr 指令;exit 0 静默 | stdout JSON 注入上下文 | stdout 摘要 |
所有三端在所有事件上,内部错误都走 fail-open(见 §3),hook bug 永远不阻断工作流。
任何 hook 脚本在 __main__ 处必须有:
if __name__ == "__main__":
try:
sys.exit(main())
except SystemExit:
raise
except Exception as exc:
print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr)
sys.exit(0)含义:
- 钩子内部任何未捕获异常 → stderr 打印 traceback 摘要 → exit 0。
- AI 与用户上下文永远收到「钩子无意见」的语义。
- 同一约定适用于全部 5 个钩子的
__main__尾部:env_check.py、post_tool_lint.py、pre_tool_git_guard.py、user_prompt_validator.py、stop_summary.py(符号级约束,见 §6)。
禁止把 fail-open 改为 exit 2——会破坏宿主工作流。
pre_tool_git_guard.py 是唯一会 exit 2 的 hook——且仅当:
- 入参命令命中
is_guarded():直接(先展开$(...)/反引号内层文本到待扫面, 再分隔符切段、剥除段首 shell 控制引导词(if/then/do/while/!…)与NAME=VAL/env/常见裸 wrapper 前缀并跳过 git 全局选项(-C/-c/…)后 子命令为git commit|push;子串匹配会误伤 payload/echo 文本)或一层解释器 间接(bash|sh|python… <脚本>的脚本文本、-c内联代码按同规则扫描——bash runner.sh式绕过曾连推 4 次漏网;ro=$(git push …)、if git push; then曾静默放行(实测);拼接式 subprocess 不在静态扫描承诺内);并且resolve_project_roots()用同一套归一化判定收集仓库边界(含git -C <path>的显式仓边界)——两处必须同源,否则 is_guarded 命中而 roots=[] → main 静默 放行,归一化修复被 roots 层击穿(0.8.2 实测:git -C/FOO=1 git push/sudo git push三种形态全部穿透);并且 - 仓库级
git config codeguard.skipGate true未设置(命中豁免时记账一次,gate_lib.record_skip_event,Stop 汇总可见;该豁免只覆盖语言门禁—— 入库内容安全扫描不受其覆盖,见下方一致性约束);并且 - 实际跑 linter 后存在非 skipped 的 failures。
出口内容(gate_lib.gate_directive 生成):
- 首行必须是
codeguard ❌ 提交门禁未通过:综述(tests/run_all 守护); - 整调用声明——"整个工具调用没有执行(含非 git 前序步骤),请把修复与提交拆成
两次独立调用"——必须紧跟首行综述(指令前置),其后才是每语言细节、
「怎么修」先于 linter 原始输出。报告总长受
REPORT_MAX_CHARS(3000)硬上限: 宿主把超长 stderr 从尾部截断,指令在末段时长报告下会整体丢失(实测 AI 只见到 首段 linter 报错,逃生门与拆调用指引全没读到);超限按"保头保尾"截细节, 尾部的「完整输出: /tmp/…」日志路径不得截掉。PreToolUse 的 exit 2 拒绝的是 整个 Bash 工具调用,此前未声明这一点,AI 反复把写文件与提交塞进同一调用 并误判"编辑被吞"。
禁止因工具未装、项目未接入、检查超时等 UNVERIFIED 状态 exit 2;保留 fail-open, 但 MUST 明确未验证,不能输出“全部通过”。没有适用改动是 SKIPPED,不等于工具故障。 单个并行语言检查出现内部异常时,只将该语言标为 UNVERIFIED,其余语言的已知失败仍拦截。 Git 安全路径观察无法读取拟入库路径时,以 JSON additionalContext 说明未验证;若已有已知失败, 该失败仍 exit 2,不能因后续安全观察故障被顶层 fail-open 清空。 退出码按工具解释,不能全局把 2 当崩溃(例如 pylint 的 2 是违规)。 取消无基线存量归因:报错在未修改文件上也可能是本次 API 变化造成的,不能凭路径放行。
一致性约束:UserPromptSubmit 软门禁与本硬门禁共用同一条 skipGate 豁免,
且豁免范围仅限语言门禁:入库内容安全扫描(密钥/凭据类路径)不被任何
代理可控豁免(仓库配置、内联 -c codeguard.skipGate=true、链式)覆盖,恒执行;
需要完整放行(含安全扫描)只能用进程环境变量 CODEGUARD_SKIP_GATE(1/true/yes,
仅用户可设——宿主内联赋值不会传入钩子进程)。软硬两门在豁免下也照常报告安全违规。
且都不得在非 git 目录回退成"扫描 cwd"——UPS 对非 git 目录输出一行
_non_git_note 说明并 exit 0(工作区根被回退扫描 = 上百无关仓的存量 lint
变成永久红,实测)。双副本事件去重键:PreToolUse 用 tool_use_id + 命令、
UserPromptSubmit 用 session_id + 完整文本 + 检查输入身份、SessionStart/Stop 用 session_id;
这些键同时绑定当前会话/worktree。payload 不带对应字段时不做事件去重。
去重仅记录已经完成的结果:SessionStart/Stop 异常后可重试;软门禁有跳过或未验证项
时不记录完成。硬门禁只短时重放已完成的拒绝(保留 exit 2 与诊断),绝不缓存放行;
第一份仍在执行时第二份可以重复检查,不可因“事件见过”直接返回 0。
这不是 exactly-once 执行保证,也不能代替当前代码快照。
推送语义:门禁面由 _guarded_mode 统一判定——直接命令与一层间接共用同一套
扫描;commit 面 = 按命令链预测的实际提交面(pre_tool_git_guard.staging_intent:
纯 git commit → 仅暂存区;git add -A → 含未跟踪;git add -u 或 commit -a → 仅已跟踪改动;
git add <paths> → 并入这些路径——add 在 PreToolUse 时尚未执行,
不并入会漏检"即将暂存"的文件;并行会话留在工作树的未暂存 WIP 不属于本次提交,
曾因此被误拦);纯 push 在 HEAD 快照中检查未推送差异(up...HEAD),无上游时检查 HEAD 树。
commit+push 使用预测提交快照并合并未推送范围。删除文件进入影响分析,不作为新增敏感文件。
git_staging 将每段 cd/-C、pathspec 与其 worktree 根绑定;硬门禁逐仓传入目标身份,
另一仓的 add -A 不得扩张本仓的纯 commit。Git ls-files 成组解析 glob/目录/literal/exclude;
-u 不包含未跟踪文件,-f 可包含指定忽略文件,解析后的名字在快照中按字面处理。
子目录 add . 不扩到兄弟目录;路径含空格与连续相对 cd/-C 保留上下文。
动态路径、交互暂存和 pathspec-from-file 等未支持形态返回 JSON additionalContext 的
git UNVERIFIED,保持 exit 0 兼容放行;不能被描述为已准确验证,也不冒充程序内部异常。
硬门禁从 Git blobs 物化一次性目录,不借真实 .git,不修改原 index/工作树,也不复用软门禁缓存。
符号链接/子模块/冲突、超限、依赖不可用都明确 UNVERIFIED。快照是内容隔离,不是执行沙箱;
复杂命令链、非 HEAD refspec、并发修改仍需独立 CI 验证。
UserPromptSubmit 按提示词里的 push/推送 选 commit/push 面,但不传 lanes =
三路宽口径——软门禁没有待执行命令可预测,按"工作树有待提交改动就提醒"注入
(注入非阻断,多提醒不算错;硬门禁少拦才是底线),软硬两门只在 skipGate 豁免
上严格一致(豁免只覆盖语言门禁;安全扫描两边都照常执行)。
PostToolUse 只运行可限定到单文件的检查和 formatter。append_files=false 的项目命令推迟到 显式 check/Git 门禁;保存不能触发整项目 formatter。工具异常不能自动修复。 CLI/MCP 共用 PASS/FAIL/UNVERIFIED/SKIPPED/PLANNED,只有 PASS 的 passed=true。 CLI 的 0/1/2 与 hook 的 fail-open 退出码是不同协议,不能混用。
Java 计划由 java_analysis 装配:java_build 读描述,java_impact 计算模块反向闭包,
java_planning 选择命令,java_environment 选择 wrapper/JDK,java_changes 观察版本差异。
java_project.py 保留原 CLI/导入面。默认 skipTests/check -x test 不变,权威命令不加默认参数;
纯版本降级必须由正确 Git 基线与当前描述证明,依赖版本和编译配置变化不得降为 validate/help。
根与变更路径先规范化;分析仍是模块级,不等于完整符号或任意构建脚本语义验证。
scripts/codeguard/hook_state.py 负责会话归属、统计、冷却、审计和已完成结果;
scripts/codeguard/storage.py 负责跨进程锁与原子文件替换。锁覆盖整个读改写,
使用独立且不随数据替换的 .lock 文件,不能只有原子写而没有读改写互斥。
- 宿主提供
session_id时,统计保存在CODEGUARD_HOME/sessions/<会话与worktree哈希>.json; 同一 session 在不同 Git worktree 也分开。Stop 只原子消费当前作用域,后续并发写入保留。 - 无
session_id时沿用session_state.json与旧插件内状态的兼容读取;此模式没有跨会话隔离保证。 旧全局记录不分配给任意一个带 ID 的新会话,不能把兼容读取称为可信归属迁移。 - 门禁审计由调用线程汇总后写入,
worktree是原仓根,execution_root是实际检查目录 (可能为临时快照),session_scope保留会话归属;cmd为已物化的执行 argv,不再写未展开模板。 存量豁免记录为 SKIPPED 而非检查通过;当前或基线为 UNVERIFIED 时均不得授予豁免。 - 保存检查只在获得 PASS/FAIL 后登记去重,UNVERIFIED 后允许立即重试。 去重同时校验文件内容和配置/命令身份;检查前后身份不一致不登记,不把执行中产生的新内容当作已检查。
- 本批真实并发与 worktree 验证在 macOS 完成;Windows 锁实现尚未在 Windows 实机验证。
scripts/codeguard/fingerprint.py 采集输入身份,scripts/codeguard/cache.py 只读写和校验完整软结果。
缓存保存在 CODEGUARD_HOME/gate-cache/<worktree哈希>.json;旧 /tmp 条目不读取、不自动删除。
- 身份包含真实 worktree 路径、HEAD、Git index 的内容条目、upstream、受版本控制及非忽略工作树文件的内容、 常见根 linter 配置/注册表声明的配置、用户配置、命令定义和本地工具身份;不再用 mtime/size 代替源码内容。
- Git 文件名按 NUL 分隔,linked worktree 不假设
.git是目录。项目codeguard.json不保留永不过期的进程副本。 - 预算为 10,000 个文件名、64 MiB 内容;符号链接/特殊文件、预算超限、读取失败或非 Git 目录时禁用该身份缓存, 仍实际检查。预算不截断成一个看似完整的键;非 Git 保存也仍检查,但不采用仓库身份去重。
- 只保存检查前后身份一致且
skipped为空的软结果;损坏格式、未来时间戳和未知结果均重跑。 硬门禁不读取软缓存,事件去重亦不缓存放行。 - 这是有明确输入范围的软观察优化,不是原子安全快照或可移植验收凭据;任意外部动态配置、远程依赖和未声明环境变化 不在完整性证明范围内。准确提交验证和 CI 仍独立运行,不得以软缓存证明整个项目已通过。
新加一个 hook 时,按顺序自检:
- 读本 cheat-sheet +
openspec/specs/hook-protocol/spec.md。 - 在
hooks/hooks.json注册(事件 / matcher / command / timeout)。 - 脚本实现遵守 §1 协议总表的 exit 码与 stdout 形态。
- 脚本
__main__加 §3 的 fail-open 套子。 - 在
tests/run_all.py加子集触发:stdin 喂真实宿主协议 JSON,断言 exit 码与 stdout 形状。 - 在本文件 §1 协议总表加一行,标注实现位置(
hooks/<new>.py:line)。 - OpenSpec change:proposal / design /
hook-protocol/spec.md加 Scenario / tasks 勾选后 archive。
任何对 §1 协议总表字段的修改:
- 同步改本文件 + 对应
hooks/*.py+ 对应测试。 - 走 OpenSpec change(
hook-protocolspec 下新增 MODIFIED Requirements + Scenarios)。 - 不允许「先改代码后补文档」的拆分 commit——契约一致是同一变更的原子单位。
| Cheat-sheet 节 | OpenSpec spec / Scenario |
|---|---|
| §1 协议总表 | hook-protocol::Requirement: Hook-host contract SHALL live in hooks/__protocol__.md |
| §3 Fail-open | hook-protocol::Requirement: The cheat-sheet SHALL document fail-open |
| §5 新增 hook checklist | hook-protocol::Requirement: tests/run_all.py SHALL reference the cheat-sheet |
变更通过 openspec archive add-host-protocol-cheatsheet 入库;spec 在 openspec/specs/hook-protocol/spec.md。