Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ AI code that passes lint on first try
|---|---|
| Plugin ID | `partme-codeguard-plugin` |
| Hosts | ZCode, Claude Code, Codex CLI, Kimi Code |
| Current version | `0.8.2` |
| Current version | `0.10.1` |
| ZCode manifest | `.zcode-plugin/plugin.json` |
| Codex manifest | `.codex-plugin/plugin.json` |
| MCP server | Published: stdio server via the official SDK (`check_code_style` / `auto_fix` / `list_languages`); see Quick start |
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ AI 一次写出就过 lint 的代码
|---|---|
| 插件 ID | `partme-codeguard-plugin` |
| 宿主 | ZCode、Claude Code、Codex CLI、Kimi Code |
| 当前版本 | `0.8.2` |
| 当前版本 | `0.10.1` |
| ZCode manifest | `.zcode-plugin/plugin.json` |
| Codex manifest | `.codex-plugin/plugin.json` |
| MCP 服务 | 已发布:官方 SDK stdio 服务(`check_code_style` / `auto_fix` / `list_languages`);见快速开始 |
Expand Down
47 changes: 35 additions & 12 deletions hooks/__protocol__.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,22 +66,36 @@ if __name__ == "__main__":
## 4. PreToolUse 硬拦截(exit 2)的语义边界

`pre_tool_git_guard.py` 是**唯一**会 exit 2 的 hook——且仅当:
1. 入参命令命中 `is_guarded()`:**直接**(分隔符切段、剥除段首 `NAME=VAL`/`env`/常见裸 wrapper 前缀并跳过 git 全局选项(`-C`/`-c`/…)后子命令为 `git commit|push`;
子串匹配会误伤 payload/echo 文本)**或一层解释器间接**(`bash|sh|python… <脚本>`
的脚本文本、`-c` 内联代码按同规则扫描——`bash runner.sh` 式绕过曾连推 4 次漏网;
拼接式 subprocess 不在静态扫描承诺内);并且
1. 入参命令命中 `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` 三种形态全部穿透);并且
2. 仓库级 `git config codeguard.skipGate true` 未设置(命中豁免时记账一次,
`gate_lib.record_skip_event`,Stop 汇总可见);并且
3. 实际跑 linter 后存在非 skipped 的 failures。

出口内容(`gate_lib.gate_directive` 生成):
- **首行必须是 `codeguard ❌ 提交门禁未通过:` 综述**(tests/run_all 守护);
- 报告块**末段必须包含整调用声明**——"整个工具调用没有执行(含非 git 前序步骤),
请把修复与提交拆成两次独立调用"。PreToolUse 的 exit 2 拒绝的是**整个 Bash 工具
调用**,此前未声明这一点,AI 反复把写文件与提交塞进同一调用并误判"编辑被吞"。

**禁止**在 lint skipped(工具未装/项目未接入/本次改动未涉及/exit 2 工具链异常)
时 exit 2——这是「无法验证」而非「验证失败」。
- 整调用声明——"整个工具调用没有执行(含非 git 前序步骤),请把修复与提交拆成
两次独立调用"——**必须紧跟首行综述(指令前置)**,其后才是每语言细节、
「怎么修」先于 linter 原始输出。**报告总长受 `REPORT_MAX_CHARS`(3000)硬上限**:
宿主把超长 stderr 从尾部截断,指令在末段时长报告下会整体丢失(实测 AI 只见到
首段 linter 报错,逃生门与拆调用指引全没读到);超限按"保头保尾"截细节,
尾部的「完整输出: /tmp/…」日志路径不得截掉。PreToolUse 的 exit 2 拒绝的是
**整个 Bash 工具调用**,此前未声明这一点,AI 反复把写文件与提交塞进同一调用
并误判"编辑被吞"。

**禁止**在 lint skipped(工具未装/项目未接入/本次改动未涉及/exit 2 工具链异常/
**存量归因**——delta 报错提到的文件全部在本次改动集之外)时 exit 2——这是
「无法验证」而非「验证失败」;存量归因是防"历史债不还就永远提交不了 → 只能
skipGate → 门禁信誉清零"的最后一道闸。

**一致性约束**:`UserPromptSubmit` 软门禁与本硬门禁共用同一条 skipGate 豁免,
且**都不得在非 git 目录回退成"扫描 cwd"**——UPS 对非 git 目录输出一行
Expand All @@ -90,8 +104,17 @@ if __name__ == "__main__":
UserPromptSubmit 用 `session_id + 文本前缀`、SessionStart/Stop 用 `session_id`;
payload 不带这些字段(测试协议/其它宿主)时不去重,保持旧行为。
推送语义:门禁面由 `_guarded_mode` 统一判定——直接命令与一层间接共用同一套
扫描;commit 面=暂存+未暂存+未跟踪,push 面=并集未推送提交(`up...HEAD`);
UserPromptSubmit 按提示词里的 `push/推送` 选同一套面,软硬两门永远看同一组文件。
扫描;**commit 面 = 按命令链预测的实际提交面**(`pre_tool_git_guard.staging_intent`:
纯 `git commit` → 仅暂存区;`git add -A/-a/-u` 或 `commit -a` → 相应扩到
未暂存/未跟踪;`git add <paths>` → 并入这些路径——add 在 PreToolUse 时**尚未执行**,
不并入会漏检"即将暂存"的文件;并行会话留在工作树的未暂存 WIP 不属于本次提交,
曾因此被误拦);push 面 = 并集未推送提交(`up...HEAD`)。lanes/extra 必须进
缓存键——同一工作树状态下窄面 pass 不得被宽面复用(staged 干净 + 未暂存有病
时,纯 commit 通过的结果若被 `add -A && commit` 复用 = 绕过)。
UserPromptSubmit 按提示词里的 `push/推送` 选 commit/push 面,但**不传 lanes =
三路宽口径**——软门禁没有待执行命令可预测,按"工作树有待提交改动就提醒"注入
(注入非阻断,多提醒不算错;硬门禁少拦才是底线),软硬两门只在 skipGate 豁免
上严格一致。

---

Expand Down
56 changes: 56 additions & 0 deletions hooks/env_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,48 @@ def detect_linter_config(project_root: Path) -> dict:
return found


def _semver_tuple(v: str) -> tuple[int, ...]:
import re as _re
m = _re.match(r"(\d+(?:\.\d+)*)", v.strip())
if not m:
return (0,)
return tuple(int(x) for x in m.group(1).split("."))


def version_backlog_note(current: str, cache_root: Path) -> str | None:
"""纯函数:本地插件 cache 存在比 current 更新的版本 → 提示文案;否则 None。

检测面 = `~/.zcode/cli/plugins/cache/<vendor>/codeguard/<version>` 的**版本目录
集合**(离线可得的唯一权威)。抓两类漂移:同 vendor 升级后旧目录未清、跨
vendor 版本参差导致实际生效副本落后于本机已有副本。抓不到"本机最新 vs
远端仓库最新"(需网络,钩子不联网)——文案里明示提醒核对远端。
"""
newer: list[str] = []
installed: list[str] = []
if not cache_root.is_dir():
return None
try:
for vendor_dir in sorted(cache_root.glob("*/codeguard/*")):
if not vendor_dir.is_dir():
continue
v = vendor_dir.name
installed.append(v)
if _semver_tuple(v) > _semver_tuple(current):
newer.append(v)
except OSError:
return None
if not newer:
return None
newest = max(newer, key=_semver_tuple)
return (
f"- ⚠️ **版本积压**:当前生效 codeguard `v{current}`,本机 cache 已有更新版本 "
f"`v{newest}`(共 {len(installed)} 份: {', '.join(sorted(installed))})——"
"钩子行为以**生效副本**为准,修复可能已存在却没跑到;请更新插件并清理旧 "
"cache(`~/.zcode/cli/plugins/cache/*/codeguard/`),同时核对远端是否还有"
"更新版本(本检测离线,看不到仓库最新版)。"
)


def main(payload: dict | None = None) -> int:
# 双副本去重:宿主提供 session_id 时,同会话第二个副本静默(第一份的
# 摘要已注入;两份都打 = 同一段"项目记忆"重复两遍)。payload 缺字段
Expand Down Expand Up @@ -140,6 +182,20 @@ def main(payload: dict | None = None) -> int:
"每个钩子事件会执行两遍,报告与统计可能翻倍或分裂——建议只保留一个来源"
)

# 版本积压:本机 cache 里有比当前生效副本更新的版本(实测 8 份副本停留在
# 0.5.x 而源码已 0.8.x——"发布了但没跑到"的盲区,SessionStart 即可见)
try:
_mf = json.loads((PLUGIN_ROOT / ".zcode-plugin" / "plugin.json").read_text(encoding="utf-8"))
_current = str(_mf.get("version") or "")
except (OSError, ValueError, AttributeError):
_current = ""
if _current:
_note = version_backlog_note(
_current, Path.home() / ".zcode" / "cli" / "plugins" / "cache"
)
if _note:
lines.append(_note)

lines.append("- AI 写完代码会被 PostToolUse 钩子自动 lint,告警会出现在这里,按告警里的「怎么修」处理")
lines.append("- 用户要求「提交/push」时,UserPromptSubmit 钩子会再次确认所有 linter 通过,未通过会拦截提交")
lines.append("")
Expand Down
Loading
Loading