Skip to content

Commit f7e64bc

Browse files
authored
release: v0.8.3(会话实测 6 项:守卫绕过闭环 + 提交面收窄 + 报告指令前置) (#31)
来自真实多仓修复会话的反馈批次(门禁在主工作树拦 3 次、又被 4 种命令形态 静默绕过、长报告把指令段截掉): 1) 守卫绕过闭环(4 形态实测漏网 + 0.8.2 roots 层击穿) - 切段扫描前展开 $(...) 与反引号内层文本(ro=$(git push…) 实测静默放行) - 段首归一化剥 shell 控制引导词 if/then/else/elif/while/until/do/! (if git push; then、for x; do git push; done 实测静默放行) - resolve_project_roots 与 is_guarded 同源判定 + git -C <path> 显式仓边界: 0.8.2 ①只修了 _guarded_mode,roots 层仍用原始 tokens[0]——git -C / FOO=1 git / sudo git 三形态 is_guarded=True 但 roots=[] → main 静默放行 2) commit 面按命令链收窄(staging_intent) - 纯 git commit → 仅 staged;add -A/-a/-u 或 commit -a → 扩到未暂存/未跟踪; git add <paths> → 并入 extra(PreToolUse 时 add 未执行、暂存区是旧的); 并行会话的未暂存 WIP 不再拦无关提交(本会话实测误拦点) - lanes/extra 进缓存键(窄面 pass 被宽面复用 = 绕过);extra 按仓根换算 + 各仓存在性过滤;UPS 保持三路宽口径(软门无命令可预测,注释+协议已改) 3) 报告结构:整调用声明/skipGate/同意边界前置到首行综述紧后;总长硬上限 REPORT_MAX_CHARS=3000;超限保头保尾截细节(尾部日志路径不截掉) 4) 存量归因:delta 下项目级命令报错文件全部在改动集外 → skipped + 完整日志 (防"历史债不还就永远提交不了 → 只能 skipGate"的信誉清零) 5) append_files=false:java/kotlin/rust/go/csharp/vbnet/terraform/protobuf/elm 九个项目级命令不再被追加文件路径(mvn javadoc:jar foo.java → Unknown lifecycle phase 假失败) 6) SessionStart 版本积压检测(version_backlog_note 纯函数)+ 缓存指纹轻量化 (name-only+stat 替代全仓全文 diff,staged 由 index mtime 兜底) 契约同 commit 同步:__protocol__.md §4(归一化/同源判定/指令前置/上限/存量 skip/lanes 语义)+ openspec hook-protocol(MUST 扩写 + 新 Requirement: The commit face SHALL match the actual staging surface + 3 Scenario);README 双语 与 4 份 manifest → 0.8.3。 验证:unittest 120 OK(+26:tests/test_session_fixes_20260922.py)、 run_all 141/0、validate_languages_json 11 规则、README/manifest 一致性全绿
1 parent 7f86fdf commit f7e64bc

11 files changed

Lines changed: 902 additions & 78 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ AI code that passes lint on first try
6767
|---|---|
6868
| Plugin ID | `partme-codeguard-plugin` |
6969
| Hosts | ZCode, Claude Code, Codex CLI, Kimi Code |
70-
| Current version | `0.8.2` |
70+
| Current version | `0.10.1` |
7171
| ZCode manifest | `.zcode-plugin/plugin.json` |
7272
| Codex manifest | `.codex-plugin/plugin.json` |
7373
| MCP server | Published: stdio server via the official SDK (`check_code_style` / `auto_fix` / `list_languages`); see Quick start |

‎README.zh-CN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ AI 一次写出就过 lint 的代码
6767
|---|---|
6868
| 插件 ID | `partme-codeguard-plugin` |
6969
| 宿主 | ZCode、Claude Code、Codex CLI、Kimi Code |
70-
| 当前版本 | `0.8.2` |
70+
| 当前版本 | `0.10.1` |
7171
| ZCode manifest | `.zcode-plugin/plugin.json` |
7272
| Codex manifest | `.codex-plugin/plugin.json` |
7373
| MCP 服务 | 已发布:官方 SDK stdio 服务(`check_code_style` / `auto_fix` / `list_languages`);见快速开始 |

‎hooks/__protocol__.md‎

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -66,22 +66,36 @@ if __name__ == "__main__":
6666
## 4. PreToolUse 硬拦截(exit 2)的语义边界
6767

6868
`pre_tool_git_guard.py` 是**唯一**会 exit 2 的 hook——且仅当:
69-
1. 入参命令命中 `is_guarded()`:**直接**(分隔符切段、剥除段首 `NAME=VAL`/`env`/常见裸 wrapper 前缀并跳过 git 全局选项(`-C`/`-c`/…)后子命令为 `git commit|push`;
70-
子串匹配会误伤 payload/echo 文本)**或一层解释器间接**(`bash|sh|python… <脚本>`
71-
的脚本文本、`-c` 内联代码按同规则扫描——`bash runner.sh` 式绕过曾连推 4 次漏网;
72-
拼接式 subprocess 不在静态扫描承诺内);并且
69+
1. 入参命令命中 `is_guarded()`:**直接**(先展开 `$(...)`/反引号内层文本到待扫面,
70+
再分隔符切段、剥除段首 shell 控制引导词(`if`/`then`/`do`/`while`/`!`…)与
71+
`NAME=VAL`/`env`/常见裸 wrapper 前缀并跳过 git 全局选项(`-C`/`-c`/…)后
72+
子命令为 `git commit|push`;子串匹配会误伤 payload/echo 文本)**或一层解释器
73+
间接**(`bash|sh|python… <脚本>` 的脚本文本、`-c` 内联代码按同规则扫描——
74+
`bash runner.sh` 式绕过曾连推 4 次漏网;`ro=$(git push …)`、`if git push; then`
75+
曾静默放行(实测);拼接式 subprocess 不在静态扫描承诺内);并且
76+
`resolve_project_roots()` 用**同一套归一化判定**收集仓库边界(含 `git -C <path>`
77+
的显式仓边界)——两处必须同源,否则 is_guarded 命中而 roots=[] → main 静默
78+
放行,归一化修复被 roots 层击穿(0.8.2 实测:`git -C`/`FOO=1 git push`/
79+
`sudo git push` 三种形态全部穿透);并且
7380
2. 仓库级 `git config codeguard.skipGate true` 未设置(命中豁免时记账一次,
7481
`gate_lib.record_skip_event`,Stop 汇总可见);并且
7582
3. 实际跑 linter 后存在非 skipped 的 failures。
7683

7784
出口内容(`gate_lib.gate_directive` 生成):
7885
- **首行必须是 `codeguard ❌ 提交门禁未通过:` 综述**(tests/run_all 守护);
79-
- 报告块**末段必须包含整调用声明**——"整个工具调用没有执行(含非 git 前序步骤),
80-
请把修复与提交拆成两次独立调用"。PreToolUse 的 exit 2 拒绝的是**整个 Bash 工具
81-
调用**,此前未声明这一点,AI 反复把写文件与提交塞进同一调用并误判"编辑被吞"。
82-
83-
**禁止**在 lint skipped(工具未装/项目未接入/本次改动未涉及/exit 2 工具链异常)
84-
时 exit 2——这是「无法验证」而非「验证失败」。
86+
- 整调用声明——"整个工具调用没有执行(含非 git 前序步骤),请把修复与提交拆成
87+
两次独立调用"——**必须紧跟首行综述(指令前置)**,其后才是每语言细节、
88+
「怎么修」先于 linter 原始输出。**报告总长受 `REPORT_MAX_CHARS`(3000)硬上限**:
89+
宿主把超长 stderr 从尾部截断,指令在末段时长报告下会整体丢失(实测 AI 只见到
90+
首段 linter 报错,逃生门与拆调用指引全没读到);超限按"保头保尾"截细节,
91+
尾部的「完整输出: /tmp/…」日志路径不得截掉。PreToolUse 的 exit 2 拒绝的是
92+
**整个 Bash 工具调用**,此前未声明这一点,AI 反复把写文件与提交塞进同一调用
93+
并误判"编辑被吞"。
94+
95+
**禁止**在 lint skipped(工具未装/项目未接入/本次改动未涉及/exit 2 工具链异常/
96+
**存量归因**——delta 报错提到的文件全部在本次改动集之外)时 exit 2——这是
97+
「无法验证」而非「验证失败」;存量归因是防"历史债不还就永远提交不了 → 只能
98+
skipGate → 门禁信誉清零"的最后一道闸。
8599

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

96119
---
97120

‎hooks/env_check.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,48 @@ def detect_linter_config(project_root: Path) -> dict:
5959
return found
6060

6161

62+
def _semver_tuple(v: str) -> tuple[int, ...]:
63+
import re as _re
64+
m = _re.match(r"(\d+(?:\.\d+)*)", v.strip())
65+
if not m:
66+
return (0,)
67+
return tuple(int(x) for x in m.group(1).split("."))
68+
69+
70+
def version_backlog_note(current: str, cache_root: Path) -> str | None:
71+
"""纯函数:本地插件 cache 存在比 current 更新的版本 → 提示文案;否则 None。
72+
73+
检测面 = `~/.zcode/cli/plugins/cache/<vendor>/codeguard/<version>` 的**版本目录
74+
集合**(离线可得的唯一权威)。抓两类漂移:同 vendor 升级后旧目录未清、跨
75+
vendor 版本参差导致实际生效副本落后于本机已有副本。抓不到"本机最新 vs
76+
远端仓库最新"(需网络,钩子不联网)——文案里明示提醒核对远端。
77+
"""
78+
newer: list[str] = []
79+
installed: list[str] = []
80+
if not cache_root.is_dir():
81+
return None
82+
try:
83+
for vendor_dir in sorted(cache_root.glob("*/codeguard/*")):
84+
if not vendor_dir.is_dir():
85+
continue
86+
v = vendor_dir.name
87+
installed.append(v)
88+
if _semver_tuple(v) > _semver_tuple(current):
89+
newer.append(v)
90+
except OSError:
91+
return None
92+
if not newer:
93+
return None
94+
newest = max(newer, key=_semver_tuple)
95+
return (
96+
f"- ⚠️ **版本积压**:当前生效 codeguard `v{current}`,本机 cache 已有更新版本 "
97+
f"`v{newest}`(共 {len(installed)} 份: {', '.join(sorted(installed))})——"
98+
"钩子行为以**生效副本**为准,修复可能已存在却没跑到;请更新插件并清理旧 "
99+
"cache(`~/.zcode/cli/plugins/cache/*/codeguard/`),同时核对远端是否还有"
100+
"更新版本(本检测离线,看不到仓库最新版)。"
101+
)
102+
103+
62104
def main(payload: dict | None = None) -> int:
63105
# 双副本去重:宿主提供 session_id 时,同会话第二个副本静默(第一份的
64106
# 摘要已注入;两份都打 = 同一段"项目记忆"重复两遍)。payload 缺字段
@@ -140,6 +182,20 @@ def main(payload: dict | None = None) -> int:
140182
"每个钩子事件会执行两遍,报告与统计可能翻倍或分裂——建议只保留一个来源"
141183
)
142184

185+
# 版本积压:本机 cache 里有比当前生效副本更新的版本(实测 8 份副本停留在
186+
# 0.5.x 而源码已 0.8.x——"发布了但没跑到"的盲区,SessionStart 即可见)
187+
try:
188+
_mf = json.loads((PLUGIN_ROOT / ".zcode-plugin" / "plugin.json").read_text(encoding="utf-8"))
189+
_current = str(_mf.get("version") or "")
190+
except (OSError, ValueError, AttributeError):
191+
_current = ""
192+
if _current:
193+
_note = version_backlog_note(
194+
_current, Path.home() / ".zcode" / "cli" / "plugins" / "cache"
195+
)
196+
if _note:
197+
lines.append(_note)
198+
143199
lines.append("- AI 写完代码会被 PostToolUse 钩子自动 lint,告警会出现在这里,按告警里的「怎么修」处理")
144200
lines.append("- 用户要求「提交/push」时,UserPromptSubmit 钩子会再次确认所有 linter 通过,未通过会拦截提交")
145201
lines.append("")

0 commit comments

Comments
 (0)