From 79f1711780f0194f4e2f8cc7ea3d71d81c13a64d Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:58:55 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v0.8.0=EF=BC=88=E9=97=A8=E7=A6=81?= =?UTF-8?q?=E7=A1=AC=E5=8C=96=EF=BC=9A14=20=E9=A1=B9=E5=AE=9E=E5=BC=B9?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=E5=85=A8=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 整调用声明:exit 2 拒绝的是完整 Bash 调用(前序步骤未执行),指令要求拆分调用 - is_guarded 覆盖一层解释器间接(脚本/-c 内联),封堵 runner.sh 式绕过 - 缓存键纳入工作区内容指纹;git 仓缺省 delta 作用域 + gate_scope 项目级覆盖 - exit 2 归未验证(hook skipped / CLI-MCP unverified);节选附总量+完整日志 - scope 层(scripts/scope.py):ruff 默认配置注入、单文件物化、全量剔除 vendor - PostToolUse 单文件 format + 波及清单注入;fix 缺省 delta、--all 全仓 - UPS 非 git 跳过、与硬门共享 skipGate、否定语境不触发;bypass 记账 + Stop 告警 - 状态迁 ~/.codeguard + 条目归一化(修 KeyError-fail-open 潜伏炸弹) - 双副本 SessionStart 告警 + 事件去重;shell.lint 对齐 --severity=warning - __protocol__.md 符号定位;OpenSpec 2026-09-22-harden-commit-gate 三份 delta 已归档 - 测试:run_all 140/1(优于基线 139/2)+ unittest 73 + 新增 29 例硬编码回归 --- .agents/plugins/marketplace.json | 8 +- .codex-plugin/plugin.json | 2 +- .zcode-plugin/plugin.json | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- docs/LANGUAGES.md | 2 +- hooks/__protocol__.md | 37 +- hooks/env_check.py | 23 +- hooks/gate_lib.py | 248 +++++++++++-- hooks/post_tool_lint.py | 98 ++++- hooks/pre_tool_git_guard.py | 106 ++++-- hooks/stop_summary.py | 60 +++- hooks/user_prompt_validator.py | 61 +++- kimi.plugin.json | 2 +- linters/ruff/ruff.toml | 35 ++ .../.openspec.yaml | 2 + .../2026-09-22-harden-commit-gate/design.md | 44 +++ .../2026-09-22-harden-commit-gate/proposal.md | 33 ++ .../specs/gate-trigger-policy/spec.md | 15 + .../specs/hook-protocol/spec.md | 38 ++ .../specs/language-gate-commands/spec.md | 33 ++ .../2026-09-22-harden-commit-gate/tasks.md | 34 ++ openspec/specs/gate-trigger-policy/spec.md | 14 + openspec/specs/hook-protocol/spec.md | 37 ++ openspec/specs/language-gate-commands/spec.md | 32 ++ scripts/fix.py | 21 +- scripts/languages.json | 1 + scripts/paths.py | 19 +- scripts/run_check.py | 16 +- scripts/run_per_language.py | 43 ++- scripts/scope.py | 128 +++++++ scripts/user_config.py | 6 +- tests/run_all.py | 92 +++-- tests/test_hardening_fixes.py | 336 ++++++++++++++++++ 34 files changed, 1463 insertions(+), 169 deletions(-) create mode 100644 linters/ruff/ruff.toml create mode 100644 openspec/changes/archive/2026-09-22-harden-commit-gate/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-22-harden-commit-gate/design.md create mode 100644 openspec/changes/archive/2026-09-22-harden-commit-gate/proposal.md create mode 100644 openspec/changes/archive/2026-09-22-harden-commit-gate/specs/gate-trigger-policy/spec.md create mode 100644 openspec/changes/archive/2026-09-22-harden-commit-gate/specs/hook-protocol/spec.md create mode 100644 openspec/changes/archive/2026-09-22-harden-commit-gate/specs/language-gate-commands/spec.md create mode 100644 openspec/changes/archive/2026-09-22-harden-commit-gate/tasks.md create mode 100644 scripts/scope.py create mode 100644 tests/test_hardening_fixes.py diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 89b19fb..0ad583c 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -9,20 +9,20 @@ "source": { "source": "url", "url": "https://github.com/partme-ai/partme-codeguard-plugin.git", - "ref": "v0.7.1" + "ref": "v0.8.0" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.7.1", + "version": "0.8.0", "description": "Cross-language code lint enforcement for AI coding assistants (ZCode, Claude Code, Codex CLI, Kimi Code): Java, Rust, TypeScript, Python. PostToolUse hook auto-runs the native linter on every AI-written file and blocks on failure in strict mode.", - "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.7.1/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.8.0/assets/official-logo.png", "interface": { "displayName": "代码规范守卫", "shortDescription": "Make AI-written code pass lint on first try", - "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.7.1/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.8.0/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 807d87c..bbb2838 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.7.1+codex.20260922", + "version": "0.8.0+codex.20260922", "description": "Cross-language code lint enforcement for AI coding assistants (ZCode, Claude Code, Codex CLI, Kimi Code): Java, Rust, TypeScript, Python. PostToolUse hook auto-runs the native linter on every AI-written file and blocks on failure in strict mode.", "author": { "name": "Full Stack Skills / PartMe.AI", diff --git a/.zcode-plugin/plugin.json b/.zcode-plugin/plugin.json index 4819256..93ecdc3 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.7.1", + "version": "0.8.0", "description": "Cross-language code lint enforcement for AI coding assistants (ZCode, Claude Code, Codex CLI, Kimi Code): Java, Rust, TypeScript, Python. PostToolUse hook auto-runs the native linter on every AI-written file and blocks on failure in strict mode.", "description_i18n": { "en": "Cross-language code lint enforcement. PostToolUse hook auto-runs the language-specific linter on every AI-written file; failed lint blocks further writes when strict_mode is on. Ships ready-to-go .pre-commit-config.yaml templates for Java/Rust/TypeScript/Python.", diff --git a/README.md b/README.md index d64d684..8709f76 100644 --- a/README.md +++ b/README.md @@ -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.7.1` | +| Current version | `0.8.0` | | 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 | diff --git a/README.zh-CN.md b/README.zh-CN.md index bcdc8c0..d588b35 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -67,7 +67,7 @@ AI 一次写出就过 lint 的代码 |---|---| | 插件 ID | `partme-codeguard-plugin` | | 宿主 | ZCode、Claude Code、Codex CLI、Kimi Code | -| 当前版本 | `0.7.1` | +| 当前版本 | `0.8.0` | | ZCode manifest | `.zcode-plugin/plugin.json` | | Codex manifest | `.codex-plugin/plugin.json` | | MCP 服务 | 已发布:官方 SDK stdio 服务(`check_code_style` / `auto_fix` / `list_languages`);见快速开始 | diff --git a/docs/LANGUAGES.md b/docs/LANGUAGES.md index 2ec8c76..3221ebd 100644 --- a/docs/LANGUAGES.md +++ b/docs/LANGUAGES.md @@ -20,7 +20,7 @@ | PHP | `.php` | `php -l {file}` | `php-cs-fixer fix` | composer require --dev php-cs-fixer | | Ruby | `.rb` | `rubocop` | `rubocop -A` | gem install rubocop | | Scala | `.scala` `.sc` | `scalafmt --check .` | `scalafmt` | coursier install scalafmt | -| Shell | `.sh` `.bash` `.zsh` | `shellcheck {file}` | `shfmt -w .` | brew install shellcheck shfmt | +| Shell | `.sh` `.bash` `.zsh` | `shellcheck --severity=warning {file}` | `shfmt -w .` | brew install shellcheck shfmt | | Dockerfile | (文件名匹配) | `hadolint {file}` | `hadolint` | brew install hadolint | | YAML | `.yml` `.yaml` | `yamllint .` | `yamllint .` | pip install yamllint | | Elixir | `.ex` `.exs` | `mix credo --strict` | `mix format` | 项目需配置 credo 依赖 | diff --git a/hooks/__protocol__.md b/hooks/__protocol__.md index 4d22ee0..bc554f1 100644 --- a/hooks/__protocol__.md +++ b/hooks/__protocol__.md @@ -18,12 +18,12 @@ | PostToolUse `Write\|Edit\|MultiEdit` | `post_tool_lint.py` | JSON `{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"..."}, "systemMessage":"..."}` | 仅内部错误时 | 0 | 否 | | Stop | `stop_summary.py` | 人类可读会话摘要 | 仅内部错误时 | 0 | 否 | -实现定位(行号随 v0.6.2 锁定,下一次重构时核对): -- SessionStart 摘要:`hooks/env_check.py:34`。 -- UserPromptSubmit JSON:`hooks/user_prompt_validator.py:104-109` 与 `:117-125`。 -- PreToolUse stderr:`hooks/pre_tool_git_guard.py:159`。 -- PostToolUse JSON:`hooks/post_tool_lint.py:226-232`(通过)、`:253-259`(自动修复后通过)、`:275-281`(失败 additionalContext)。 -- Stop 摘要:`hooks/stop_summary.py:46`。 +实现定位(**符号而非行号**——行号随每次重构腐烂,符号不腐;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`;拦截判定 `::is_guarded`(直接 + `_command_indirect` 一层间接)。 +- PostToolUse JSON:`hooks/post_tool_lint.py::main`(通过/自动修复后通过/失败 additionalContext 三处 print)。 +- Stop 摘要:`hooks/stop_summary.py::main`(含绕过计数 `summarize` 与 skipGate 遗留告警)。 --- @@ -57,7 +57,7 @@ if __name__ == "__main__": 含义: - 钩子内部任何未捕获异常 → stderr 打印 traceback 摘要 → **exit 0**。 - AI 与用户上下文永远收到「钩子无意见」的语义。 -- 同一约定适用于:`env_check.py:127-131`、`post_tool_lint.py:293-298`、`pre_tool_git_guard.py:166-170`、`user_prompt_validator.py:137-141`、`stop_summary.py:55-62`。 +- 同一约定适用于全部 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——会破坏宿主工作流。 @@ -66,13 +66,28 @@ if __name__ == "__main__": ## 4. PreToolUse 硬拦截(exit 2)的语义边界 `pre_tool_git_guard.py` 是**唯一**会 exit 2 的 hook——且仅当: -1. 入参命令包含 `git commit` 或 `git push`(`is_guarded()` 命中);并且 -2. 仓库级 `git config codeguard.skipGate true` 未设置;并且 +1. 入参命令命中 `is_guarded()`:**直接**(分隔符切段后段首为 `git commit|push`; + 子串匹配会误伤 payload/echo 文本)**或一层解释器间接**(`bash|sh|python… <脚本>` + 的脚本文本、`-c` 内联代码按同规则扫描——`bash runner.sh` 式绕过曾连推 4 次漏网; + 拼接式 subprocess 不在静态扫描承诺内);并且 +2. 仓库级 `git config codeguard.skipGate true` 未设置(命中豁免时记账一次, + `gate_lib.record_skip_event`,Stop 汇总可见);并且 3. 实际跑 linter 后存在非 skipped 的 failures。 -出口内容(`pre_tool_git_guard.py:158-163`):stderr 报告块**首行必须是 `codeguard ❌ 提交门禁未通过:` 综述**,其后跟具体问题列表;这是 `tests/run_all.py:142-144` 守护的契约。 +出口内容(`gate_lib.gate_directive` 生成): +- **首行必须是 `codeguard ❌ 提交门禁未通过:` 综述**(tests/run_all 守护); +- 报告块**末段必须包含整调用声明**——"整个工具调用没有执行(含非 git 前序步骤), + 请把修复与提交拆成两次独立调用"。PreToolUse 的 exit 2 拒绝的是**整个 Bash 工具 + 调用**,此前未声明这一点,AI 反复把写文件与提交塞进同一调用并误判"编辑被吞"。 -**禁止**在 lint skipped(工具未装/项目未接入)时 exit 2——这是「无法验证」而非「验证失败」。 +**禁止**在 lint skipped(工具未装/项目未接入/本次改动未涉及/exit 2 工具链异常) +时 exit 2——这是「无法验证」而非「验证失败」。 + +**一致性约束**:`UserPromptSubmit` 软门禁与本硬门禁共用同一条 skipGate 豁免, +且**都不得在非 git 目录回退成"扫描 cwd"**——UPS 对非 git 目录输出一行 +`_non_git_note` 说明并 exit 0(工作区根被回退扫描 = 上百无关仓的存量 lint +变成永久红,实测)。双副本事件去重键:PreToolUse 用 `tool_use_id`、UPS 用 +`session_id + 文本`;payload 不带这些字段(测试协议)时不去重。 --- diff --git a/hooks/env_check.py b/hooks/env_check.py index aa38ce1..f14453f 100755 --- a/hooks/env_check.py +++ b/hooks/env_check.py @@ -6,6 +6,7 @@ 2. 检查是否已有 linter 配置(.pre-commit-config.yaml、checkstyle.xml、.clippy.toml、eslint.config.js、ruff.toml) 3. 输出一段 AGENTS.md 风格的提示,让 AI 知道自己在一个被 codeguard 管理的项目里 """ +import contextlib import os import subprocess import sys @@ -31,14 +32,12 @@ def notify(title: str, message: str) -> None: return safe_t = title.replace('"', "'") safe_m = message.replace('"', "'")[:200] - try: + with contextlib.suppress(OSError): subprocess.Popen( ["osascript", "-e", f'display notification "{safe_m}" with title "{safe_t}"'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - except OSError: - pass def detect_linter_config(project_root: Path) -> dict: @@ -114,6 +113,24 @@ def main() -> int: else: lines.append("- 未配置任何 linter(建议运行 /init 接入 codeguard)") + # 双副本检测:partme-ai 与 full-stack-plugins 同时启用时每个钩子事件 + # 跑两遍(历史实测双份触发);提示用户停用其一,而不是默默重复。 + try: + import json as _json + _reg = _json.loads((Path.home() / ".zcode" / "cli" / "config.json").read_text(encoding="utf-8")) + _orgs = sorted({ + key.split("@", 1)[1] + for key in (_reg.get("plugins", {}) or {}).get("enabledPlugins", {}) + if key.startswith("codeguard@") + }) + except (OSError, ValueError, AttributeError): + _orgs = [] + if len(_orgs) > 1: + lines.append( + f"- ⚠️ 检测到 codeguard **双副本同时启用**({', '.join(_orgs)}):" + "每个钩子事件会执行两遍,报告与统计可能翻倍或分裂——建议只保留一个来源" + ) + lines.append("- AI 写完代码会被 PostToolUse 钩子自动 lint,告警会出现在这里,按告警里的「怎么修」处理") lines.append("- 用户要求「提交/push」时,UserPromptSubmit 钩子会再次确认所有 linter 通过,未通过会拦截提交") lines.append("") diff --git a/hooks/gate_lib.py b/hooks/gate_lib.py index 60d64dc..4374642 100644 --- a/hooks/gate_lib.py +++ b/hooks/gate_lib.py @@ -8,6 +8,7 @@ """ from __future__ import annotations +import contextlib import json import os import subprocess @@ -19,10 +20,13 @@ from detect_lang import ( LANG_COMMANDS, + detect_language, detect_languages, + get_overrides, probe_toolchain, project_uses_linter, ) +from scope import changed_files, scope_cmd # === git 提交内容安全检查:绝不该进版本库的文件 === # 目录(路径任一段落匹配即违规):依赖/虚拟环境/构建产物/IDE/缓存 @@ -109,8 +113,34 @@ def _gate_cache_path(project_root: Path) -> Path: return Path(tempfile.gettempdir()) / f"codeguard-gate-{os.getuid()}-{key}.json" +def _worktree_fingerprint(project_root: Path) -> str: + """工作区指纹:staged + 未暂存 + 未跟踪三路 diff 的内容哈希。 + + 只用 index mtime 会漏掉"文件已修但未 git add"——键不变 → 60 秒内继续 + 报修复前的旧失败(retry-timing 陷阱,实测踩过)。内容哈希一改即失效。 + """ + import hashlib + + h = hashlib.sha1() + # 注意:("diff") 是字符串不是元组——*unpacking 会拆成字符。单元素必须带逗号。 + for args in (("diff", "--cached"), ("diff",)): + out = _git(project_root, *args) or "" + h.update(out.encode("utf-8", "replace")) + # 未跟踪文件:ls-files 只给文件名——内容改动名字不变,必须叠加 stat 指纹 + others = (_git(project_root, "ls-files", "--others") or "").splitlines() + for name in sorted(others)[:500]: + if not name.strip(): + continue + try: + st = (project_root / name).stat() + h.update(f"{name}:{st.st_size}:{st.st_mtime_ns}".encode("utf-8", "replace")) + except OSError: + h.update(name.encode("utf-8", "replace")) + return h.hexdigest()[:16] + + def _gate_cache_key(project_root: Path, languages: list) -> str | None: - """缓存键:HEAD + 暂存区指纹(index mtime + 文件数)。文件一变即失效。""" + """缓存键:HEAD + 暂存区指纹 + 工作区内容指纹。任一变化即失效。""" head = _git(project_root, "rev-parse", "HEAD") if head is None: return None @@ -119,7 +149,10 @@ def _gate_cache_key(project_root: Path, languages: list) -> str | None: idx_sig = f"{idx.stat().st_mtime_ns}:{idx.stat().st_size}" if idx.exists() else "no-index" except OSError: return None - return f"{head.strip()}|{idx_sig}|{','.join(sorted(languages))}" + return ( + f"{head.strip()}|{idx_sig}|{_worktree_fingerprint(project_root)}" + f"|{','.join(sorted(languages))}" + ) GATE_CACHE_TTL = 60 # 秒:UPS 软门禁与紧随的 PreToolUse 硬门禁之间复用 @@ -144,6 +177,12 @@ def run_gate(project_root: Path, cfg: dict, languages: list | None = None) -> tu languages = detect_languages(project_root) if not languages: return [], [] + # 作用域:项目可用 codeguard.json gate_scope 覆盖;缺省 = git 仓 delta、 + # 非 git 目录全量。delta 只检查本次改动涉及的文件——存量问题不拦新提交。 + changed = changed_files(project_root) + scope = (get_overrides(project_root) or {}).get("gate_scope") or ( + "delta" if changed is not None else "repo" + ) enabled = cfg.get("enabled_languages", []) if enabled and enabled != ["auto"]: languages = [lang for lang in languages if lang in enabled] @@ -158,32 +197,80 @@ def run_gate(project_root: Path, cfg: dict, languages: list | None = None) -> tu except (OSError, ValueError, KeyError): pass - failures, skipped = _run_gate_uncached(project_root, cfg, languages) + failures, skipped = _run_gate_uncached( + project_root, cfg, languages, + scope=scope, changed=changed if scope == "delta" else None, + ) if ck: - try: + with contextlib.suppress(OSError): cache_file.write_text(json.dumps( {"key": ck, "ts": _time.time(), "failures": failures, "skipped": skipped}, ensure_ascii=False)) - except OSError: - pass return failures, skipped -def _run_gate_uncached(project_root: Path, cfg: dict, languages: list) -> tuple[list, list]: - """单次全量门禁(多语言并行,结果顺序保持语言表顺序)""" +def _log_path(project_root: Path, lang: str) -> Path: + import hashlib + import tempfile + key = hashlib.sha1(str(project_root.resolve()).encode()).hexdigest()[:12] + return Path(tempfile.gettempdir()) / f"codeguard-gate-{key}-{lang}.log" + + +def _truncate_detail(full: str, project_root: Path, lang: str) -> str: + """节选 + 总量 + 完整日志路径:截断只留头 600 字符会让 AI 一次修 3 个、 + 重试再看 3 个(whack-a-mole);必须给出"共几行、完整在哪"。""" + lines = [ln for ln in full.splitlines() if ln.strip()] + detail = full[:600] + if len(lines) > 8 or len(full) > 600: + try: + path = _log_path(project_root, lang) + path.write_text(full, encoding="utf-8") + detail += f"\n…(截断,共 {len(lines)} 行;完整输出: {path})" + except OSError: + detail += f"\n…(截断,共 {len(lines)} 行)" + return detail + + +def _run_gate_uncached( + project_root: Path, + cfg: dict, + languages: list, + *, + scope: str = "repo", + changed: list[str] | None = None, +) -> tuple[list, list]: + """单次门禁(多语言并行,结果顺序保持语言表顺序)。 + + scope="delta" 时只检查 changed 里属于该语言的文件(存量问题不拦新提交; + lint 为 {file} 单文件模式则逐文件循环,上限 50 个,超出回退全量命令)。 + scope="repo" 时按项目级 gate/lint 全量扫(ruff 注入默认配置并剔除依赖 + 快照目录——vendor 是供应链不可变内容,扫它只会得到不可修的永久红)。 + """ from concurrent.futures import ThreadPoolExecutor def check(lang: str): cmd_def = LANG_COMMANDS.get(lang) if not cmd_def: return None - gate_cmd = cmd_def.get("gate") or cmd_def.get("lint") - if not gate_cmd: + lang_files: list[str] = [] + if scope == "delta": + lang_files = [ + f for f in (changed or []) + if detect_language(f, project_root) == lang + ] + if not lang_files: + return (lang, None, f"{lang} 本次改动未涉及,跳过") + uses_delta_files = bool(lang_files) and len(lang_files) <= 50 + base_cmd = (cmd_def.get("gate") or cmd_def.get("lint")) if ( + scope != "delta" or not uses_delta_files + ) else cmd_def.get("lint") + base_cmd = base_cmd or cmd_def.get("gate") or cmd_def.get("lint") + if not base_cmd: return None - if "{file}" in " ".join(gate_cmd): - # {file} 占位符只在 PostToolUse 单文件模式下被替换;门禁拿字面量 - # 当文件名跑必然报"文件不存在"→ 会被误判成 lint 失败。归跳过。 + if scope != "delta" and "{file}" in " ".join(base_cmd): + # {file} 占位符只在文件模式下被替换;门禁拿字面量当文件名跑必然 + # 报"文件不存在"→ 误判成 lint 失败。归跳过。 return (lang, None, f"{lang} 未配置项目级 gate 命令(lint 为单文件模式),本次未验证") timeout = cfg.get("lint_timeout_seconds", 120) hint = cmd_def.get("install_hint") or "见 docs/LANGUAGES.md" @@ -192,26 +279,52 @@ def check(lang: str): return (lang, None, f"{lang} 工具链不可用未验证:{reason}(安装: {hint})") if not project_uses_linter(cmd_def, project_root): return (lang, None, f"{lang} 项目未接入(缺 linter 配置文件),本次未验证") - try: - proc = subprocess.run( - gate_cmd, cwd=project_root, capture_output=True, check=False, text=True, timeout=timeout - ) - except subprocess.TimeoutExpired: + + outputs: list[tuple[int, str, str]] = [] + if scope == "delta" and uses_delta_files and "{file}" in " ".join(base_cmd or []): + for f in lang_files: + cmd = scope_cmd(base_cmd, project_root, single_file=f) + outputs.append(_run_one(cmd, timeout, lang, hint)) + if outputs[-1][0] not in (0,): + break + else: + if scope == "delta": + cmd = scope_cmd(base_cmd, project_root, files=lang_files if uses_delta_files else None) + else: + cmd = scope_cmd(base_cmd, project_root, full_excludes=True) + outputs.append(_run_one(cmd, timeout, lang, hint)) + + rc, out, err = outputs[0] + if rc == 124: return (lang, None, f"{lang} 检查超时(>{timeout}s),本次未验证") - except FileNotFoundError: - return (lang, None, f"{lang} linter 未安装,本次未验证(安装: {hint})") - if proc.returncode == 0: + if rc == 0: return None if lang == "markdown": return (lang, None, "markdown 风格告警(不阻塞提交)") - if proc.returncode == 127: + if rc == 127: return (lang, None, f"{lang} 工具链异常未验证:命令不存在(exit 127)") - out = (proc.stdout or "").strip() - err = (proc.stderr or "").strip() - detail = "\n".join(ln for ln in f"{out}\n{err}".splitlines() if ln.strip())[:600] + if rc == 2: + # exit 2 = 工具用法/依赖/配置崩溃,与仓库内容无关(与 markdownlint + # 用法错误同族)。按"无法验证≠验证失败"归 skipped,绝不拦提交。 + head = next((ln.strip() for ln in f"{out}\n{err}".splitlines() if ln.strip()), "") + return (lang, None, f"{lang} 工具链异常未验证:exit 2(非 lint 结论){('|' + head[:80]) if head else ''}") + full = "\n".join(seg for seg in ((out or "").rstrip(), (err or "").rstrip()) if seg) + detail = _truncate_detail(full or "(linter 无输出)", project_root, lang) fix = f"自动修复: {' '.join(cmd_def['format'])}" if cmd_def.get("format") else "按上述问题逐项修复" return (lang, (lang, detail, fix, hint), None) + def _run_one(cmd: list[str], timeout: int, lang: str, hint: str) -> tuple[int, str, str]: + try: + proc = subprocess.run( + cmd, cwd=project_root, capture_output=True, check=False, + text=True, timeout=timeout, + ) + except subprocess.TimeoutExpired: + return 124, "", f"timeout after {timeout}s" + except FileNotFoundError: + return 127, "", f"{lang} linter 未安装(安装: {hint})" + return proc.returncode, proc.stdout or "", proc.stderr or "" + results = [] with ThreadPoolExecutor(max_workers=min(4, max(1, len(languages)))) as pool: results = list(pool.map(check, languages)) @@ -227,6 +340,86 @@ def check(lang: str): skipped.append(skip) return failures, skipped +def skip_gate_via_git_config(project_root: Path) -> bool: + """仓库级豁免:git config codeguard.skipGate true。 + + CODEGUARD_SKIP_GATE 环境变量设在用户 shell,传不进宿主起的 hook 子进程 + (宿主环境独立);git config 由钩子进程在项目根读取,任何调用形态可用。 + """ + try: + proc = subprocess.run( + ["git", "config", "--get", "codeguard.skipGate"], + cwd=project_root, capture_output=True, check=False, text=True, timeout=10, + ) + except (subprocess.TimeoutExpired, OSError): + return False + return proc.returncode == 0 and proc.stdout.strip().lower() in ("true", "1", "yes") + + +def codeguard_home() -> Path: + """钩子共享状态目录(可用 CODEGUARD_HOME 覆盖,测试隔离用)。""" + import os as _os + return Path(_os.environ.get("CODEGUARD_HOME") or (Path.home() / ".codeguard")) + + +def session_state_path() -> Path: + """会话 lint 状态(原先存插件安装目录——升级即清零、双副本各存一半)。""" + return codeguard_home() / "session_state.json" + + +def record_skip_event(kind: str) -> None: + """记录一次绕过(skipGate/逃生门)到会话状态,Stop 汇总时可见。""" + state = {} + path = session_state_path() + try: + if path.exists(): + state = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + state = {} + meta = state.setdefault("_skip", {"count": 0, "kinds": {}}) + meta["count"] = int(meta.get("count", 0)) + 1 + kinds = meta.setdefault("kinds", {}) + kinds[kind] = int(kinds.get(kind, 0)) + 1 + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") + except OSError: + pass + + +def should_suppress_event(key: str, window: float = 2.0) -> bool: + """事件级双副本去重:同一 key 在窗口内重复触发只执行一次。 + + 双副本(partme-ai + full-stack-plugins)同时启用时每个钩子事件会跑两遍 + (PostToolUse 有文件级去重,这里给 PreToolUse/UserPromptSubmit 用)。key + 由调用方带 hook 事件 id(tool_use_id/session_id)构造;测试 payload 通常 + 不带这些字段——此时调用方不调用本函数,保持旧行为。 + """ + import hashlib + import time as _time + path = codeguard_home() / "hook_dedup.json" + now = _time.monotonic() + try: + path.parent.mkdir(parents=True, exist_ok=True) + dedup = {} + if path.exists(): + dedup = json.loads(path.read_text(encoding="utf-8")) + h = hashlib.sha1(key.encode("utf-8", "replace")).hexdigest()[:24] + last = dedup.get(h) or {} + stale = [k for k, v in dedup.items() if now - v.get("ts", 0) > 60] + for k in stale: + dedup.pop(k, None) + if now - last.get("ts", 0) < window: + dedup[h] = last + path.write_text(json.dumps(dedup), encoding="utf-8") + return True + dedup[h] = {"ts": now} + path.write_text(json.dumps(dedup), encoding="utf-8") + except (OSError, ValueError): + return False + return False + + def summarize_failures(failures: list) -> str: """问题综述(一行,用作标题/通知标题):哪几个生态、什么性质的问题""" langs = "、".join(f[0] for f in failures) @@ -263,5 +456,8 @@ def gate_directive(failures: list) -> str: + "2) 修复过程中无需向用户确认;3) 全部修复完成后重新执行用户要做的提交操作。\n" + "确需绕过(仅用户明确要求时):在该仓库执行 git config codeguard.skipGate true," + "完成后 git config --unset codeguard.skipGate 恢复。环境变量 CODEGUARD_SKIP_GATE " - + "只对手动直调 run_check 有效(无法传入宿主钩子进程)。" + + "只对手动直调 run_check 有效(无法传入宿主钩子进程)。\n\n" + + "**⚠️ 整个工具调用没有执行**:被拦截的是一次包含 git commit/push 的完整 Bash " + + "调用——其中非 git 的前序步骤(写文件、跑脚本)也全部未运行。请把「修复」与" + + "「提交」拆成两次独立的工具调用,修完再单独执行提交。" ) diff --git a/hooks/post_tool_lint.py b/hooks/post_tool_lint.py index 169572c..e52fe01 100644 --- a/hooks/post_tool_lint.py +++ b/hooks/post_tool_lint.py @@ -26,28 +26,46 @@ load_user_config, project_uses_linter, ) +from gate_lib import codeguard_home, session_state_path +from scope import scope_cmd # 状态目录 ~/.codeguard(可 CODEGUARD_HOME 覆盖) -STATE_FILE = PLUGIN_ROOT / ".session_state.json" # 双副本去重:同一插件可能以多个 marketplace 副本安装(partme-ai/ 与 # full-stack-plugins/ 各一份,钩子双份触发——实测),用户级固定路径跨副本共享 -DEDUP_FILE = Path.home() / ".codeguard" / "hook_dedup.json" +DEDUP_FILE = codeguard_home() / "hook_dedup.json" + + +def _state_path() -> Path: + """会话状态:现行 ~/.codeguard/session_state.json;回退读旧插件根文件。""" + modern = session_state_path() + if modern.exists() or not (PLUGIN_ROOT / ".session_state.json").exists(): + return modern + return PLUGIN_ROOT / ".session_state.json" def bump_state(lang: str, passed: bool, auto_fixed: bool = False) -> None: """累计本会话 lint 结果(Stop 钩子读取汇总);写失败静默忽略""" state = {} try: - if STATE_FILE.exists(): - state = json.loads(STATE_FILE.read_text()) + path = _state_path() + if path.exists(): + state = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): pass - entry = state.setdefault(lang, {"total": 0, "passed": 0, "failed": 0, "auto_fixed": 0}) + entry = state.get(lang) + if not isinstance(entry, dict) or "total" not in entry: + # 状态文件可能被 _notify 单独写过(只有 last_notify 的残缺条目)—— + # 直接 entry["total"] 会 KeyError 被 fail-open 吞掉、输出全空(实测)。 + entry = dict(entry) if isinstance(entry, dict) else {} + entry.update({"total": 0, "passed": 0, "failed": 0, "auto_fixed": 0}) + state[lang] = entry entry["total"] += 1 entry["passed" if passed else "failed"] += 1 if auto_fixed: entry["auto_fixed"] += 1 try: - STATE_FILE.write_text(json.dumps(state, ensure_ascii=False)) + path = _state_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") except OSError: pass @@ -57,18 +75,23 @@ def _notify_cooldown_ok(lang: str, cooldown_s: int = 60) -> bool: import time state = {} try: - if STATE_FILE.exists(): - state = json.loads(STATE_FILE.read_text()) + path = _state_path() + if path.exists(): + state = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): pass - entry = state.get(lang) or {} + entry = state.get(lang) + if not isinstance(entry, dict): + entry = {} now = time.time() if now - entry.get("last_notify", 0) < cooldown_s: return False entry["last_notify"] = now state[lang] = entry try: - STATE_FILE.write_text(json.dumps(state, ensure_ascii=False)) + path = _state_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") except OSError: pass return True @@ -215,8 +238,12 @@ def main() -> int: timeout = cfg.get("lint_timeout_seconds", 120) def materialize(cmd: list[str]) -> list[str]: - """{file} 占位符替换:文件型 linter(shellcheck/hadolint/clang-tidy…)必须传具体文件""" - return [c.replace("{file}", str(file_path)) for c in cmd] + """物化到【单文件】作用域:{file} 替换 + 全仓扫描 token(`.`、`**/*.md`) + 收敛到本文件。此前 lint/format 命令都带 `.`——一次保存触发全仓检查, + format 更是全仓 `ruff check . --fix`,静默重写几十个无关文件(实测 + 多次:写 1 个文件、git 树炸出 30+ 漂移,AI 后续读写全部过期)。 + """ + return scope_cmd(cmd, project_root, single_file=str(file_path)) rc, stdout, stderr = run(materialize(lint_cmd), cwd=project_root, timeout=timeout) @@ -234,13 +261,36 @@ def materialize(cmd: list[str]) -> list[str]: # lint 失败 → 尝试自动修复 auto_fixed = False + touched_others: list[str] = [] if cfg.get("auto_fix_on_save", True): fmt_cmd = cmd_def.get("format") if fmt_cmd: print(f"[codeguard] ⚠️ {lang} lint failed, attempting auto-fix...") + + def _porcelain() -> set[str]: + try: + import subprocess as _sp + proc = _sp.run( + ["git", "status", "--porcelain"], + cwd=project_root, capture_output=True, check=False, + text=True, timeout=10, + ) + return { + ln[3:].strip() for ln in proc.stdout.splitlines() + if len(ln) > 3 + } if proc.returncode == 0 else set() + except Exception: # noqa: BLE001 — 快照失败仅失去清单注入,不影响修复 + return set() + + before = _porcelain() frc, _, _ = run(materialize(fmt_cmd), cwd=project_root, timeout=timeout + 60) if frc == 0: auto_fixed = True + after = _porcelain() + touched_others = sorted( + name for name in (after - before) + if not name.rstrip("/").endswith(Path(file_path).name) + ) print("[codeguard] ✅ auto-fix succeeded, re-running lint...") rc, stdout, stderr = run(materialize(lint_cmd), cwd=project_root, timeout=timeout) @@ -249,7 +299,18 @@ def materialize(cmd: list[str]) -> list[str]: if passed: # 已自动修复:告知 AI 文件被工具改过,避免 AI 继续用旧内容 - note = f"(已自动运行 {' '.join(cmd_def.get('format') or [])} 修复,请重新读取文件)" if auto_fixed else "" + note = "" + if auto_fixed: + note = f"(已自动运行 {' '.join(cmd_def.get('format') or [])} 修复)" + if touched_others: + shown = ", ".join(touched_others[:10]) + more = f" 等 {len(touched_others)} 个" if len(touched_others) > 10 else "" + note += ( + f"。⚠️ format 命令还改动了本文件之外的文件{more}:{shown}" + "——这些文件你内存里的版本已过期,必须先重新读取再继续操作" + ) + else: + note += "(仅本文件被改动,请重新读取)" print(json.dumps({ "hookSpecificOutput": { "hookEventName": "PostToolUse", @@ -264,6 +325,17 @@ def materialize(cmd: list[str]) -> list[str]: raw = (stdout or stderr or "").strip() problem_lines = [ln.strip() for ln in raw.splitlines() if ln.strip()] problems = "\n".join(problem_lines[:5])[:500] or "(linter 未输出具体问题)" + if len(problem_lines) > 5 or len(raw) > 500: + # 节选会让 AI 一次修 3 个、重试再看 3 个(whack-a-mole):给总量+完整日志 + try: + import hashlib as _h + import tempfile as _tf + key = _h.sha1(str(file_path).encode()).hexdigest()[:12] + full_path = Path(_tf.gettempdir()) / f"codeguard-post-{lang}-{key}.log" + full_path.write_text(raw, encoding="utf-8") + problems += f"\n…(截断,共 {len(problem_lines)} 行;完整输出: {full_path})" + except OSError: + problems += f"\n…(截断,共 {len(problem_lines)} 行)" fix_cmd = " ".join(cmd_def.get("format") or []) or "按上述问题逐条修复" context = ( f"codeguard ⚠️ [{lang}] {Path(file_path).name} 检查未通过\n" diff --git a/hooks/pre_tool_git_guard.py b/hooks/pre_tool_git_guard.py index 76f6f74..8015cd4 100755 --- a/hooks/pre_tool_git_guard.py +++ b/hooks/pre_tool_git_guard.py @@ -28,7 +28,10 @@ check_commit_safety, format_safety_report, gate_directive, + record_skip_event, run_gate, + should_suppress_event, + skip_gate_via_git_config, # 规范实现已上移 gate_lib(UPS 也要用) ) # 拦截的 git 子命令(避免误拦 git status/diff/log 等只读命令) @@ -78,66 +81,115 @@ def resolve_project_roots(command: str) -> list[Path]: m = re.match(r"cd\s+(\"[^\"]+\"|'[^']+'|\S+)", seg) if m: target = Path(m.group(1).strip("\"'")) - if target.is_dir() and _is_git_repo(target): - last_cd = target - else: - last_cd = None # cd 到非 git 目录(如 workspace 根)后边界失效 + # cd 到非 git 目录(如 workspace 根)后边界失效——回退 cwd(与 shell 语义一致) + last_cd = target if target.is_dir() and _is_git_repo(target) else None continue tokens = seg.split() if len(tokens) >= 2 and tokens[0] == "git" and tokens[1] in ("commit", "push"): root = last_cd or (Path(os.getcwd()) if _is_git_repo(Path(os.getcwd())) else None) if root and root not in roots: roots.append(root) + # 有意行为(勿"修掉"):cd 到非 git 目录后 last_cd=None,git 段回退用 + # 调用方 cwd——与 shell 语义一致(进非仓后 git 在原 cwd 执行)。 return roots -def is_guarded(command: str) -> bool: - """精确匹配 git commit/push:子串匹配会误伤命令文本里的数据。 +def _segment_is_git_side_effect(seg: str) -> bool: + """单个命令段是不是 git commit/push:git 为分隔符后的命令词。 + + 首 token 剥一层包裹引号(`bash -c "git commit …"` 切段后首词是 `"git`)。 + """ + tokens = seg.strip().split() + if len(tokens) < 2: + return False + first = tokens[0].strip("\"'") + second = tokens[1].strip("\"'") + return first == "git" and second in ("commit", "push") + + +_SCRIPT_SUFFIXES = (".sh", ".bash", ".zsh", ".py", ".mjs", ".js", ".ts") +_INTERPRETERS = ("bash", "sh", "zsh", "python", "python3", "node") + - 例如 payload JSON、调试脚本内容含 "git push" 字面量时,子串匹配会 - 把 echo/debug 命令也拦下并触发全仓 lint(实测踩坑)。 - 匹配规则:git 是管道/分隔符后的命令词,且子命令为 commit/push。 +def _command_indirect(command: str) -> bool: + """间接提交识别:解释器跑的脚本文件 / -c 内联代码里含 git commit|push。 + + 硬门禁此前只看外层命令文本——`bash runner.sh`(脚本体内 git commit) + 完全绕过拦截,实测用 /tmp runner 连推 4 次全部漏网。这里对一层间接 + 做静态扫描:脚本文本按同款分隔符切段后,任一段段首为 git+commit|push + 即命中。更深的 subprocess 拼接不在静态扫描能力内,不夸大承诺。 """ import re for seg in re.split(r"&&|\|\||;|\n", command): tokens = seg.strip().split() - if len(tokens) >= 2 and tokens[0] == "git" and tokens[1] in ("commit", "push"): - return True + if not tokens: + continue + prog = tokens[0].rsplit("/", 1)[-1] + if prog not in _INTERPRETERS: + continue + rest = tokens[1:] + if "-c" in rest: + body = " ".join(rest[rest.index("-c") + 1:]) + if any(_segment_is_git_side_effect(s) + for s in re.split(r"&&|\|\||;|\n", body)): + return True + continue + for tok in rest: + if tok.startswith("-"): + continue + target = Path(tok) + if not target.is_file() or target.suffix not in _SCRIPT_SUFFIXES: + break + try: + if target.stat().st_size > 1_000_000: + break + body = target.read_text(encoding="utf-8", errors="ignore") + except OSError: + break + if any(_segment_is_git_side_effect(s) + for s in re.split(r"&&|\|\||;|\n", body)): + return True + break return False -def skip_gate_via_git_config(project_root: Path) -> bool: - """仓库级豁免:git config codeguard.skipGate true。 +def is_guarded(command: str) -> bool: + """精确匹配 git commit/push:直接命令 + 一层解释器间接。 - CODEGUARD_SKIP_GATE 环境变量设在用户 shell,传不进 ZCode 宿主起的 - hook 子进程(宿主环境独立);git config 由本钩子进程在项目根读取, - 任何调用形态都可用。优先级:环境变量 > 仓库级 git config。 + 直接:子串匹配会误伤命令文本里的数据(payload、调试脚本内容含 + "git push" 字面量),所以按分隔符切段、git 必须是段首命令词。 + 间接:解释器执行的脚本/内联代码按同规则扫一层(见 _command_indirect)。 """ - try: - proc = subprocess.run( - ["git", "config", "--get", "codeguard.skipGate"], - cwd=project_root, capture_output=True, check=False, text=True, timeout=10, - ) - except (subprocess.TimeoutExpired, OSError): - return False - return proc.returncode == 0 and proc.stdout.strip().lower() in ("true", "1", "yes") + import re + if any(_segment_is_git_side_effect(seg) + for seg in re.split(r"&&|\|\||;|\n", command)): + return True + return _command_indirect(command) def main() -> int: ensure_user_path() - if os.environ.get("CODEGUARD_SKIP_GATE"): - return 0 - payload = read_payload() command = extract_command(payload) if not command or not is_guarded(command): return 0 + # 双副本去重:宿主带事件 id(生产 payload 的 tool_use_id)时第二副本静默; + # 测试 payload 不带 id → 不去重,保持旧行为。 + event_id = payload.get("tool_use_id") + if event_id and should_suppress_event(f"pre:{event_id}"): + return 0 + + if os.environ.get("CODEGUARD_SKIP_GATE"): + record_skip_event("env") + return 0 + cfg = load_user_config() roots = resolve_project_roots(command) if not roots: return 0 if any(skip_gate_via_git_config(r) for r in roots): + record_skip_event("skipGate") return 0 # 每个被操作的仓库独立跑:linter 门禁 + 提交内容安全检查 diff --git a/hooks/stop_summary.py b/hooks/stop_summary.py index b7fe0c3..fb9dbcf 100755 --- a/hooks/stop_summary.py +++ b/hooks/stop_summary.py @@ -9,46 +9,84 @@ """ from __future__ import annotations +import contextlib import json import sys from pathlib import Path PLUGIN_ROOT = Path(__file__).resolve().parents[1] # hooks/ 的上级 = 插件根 -# 钩子状态文件(由 post_tool_lint.py 写) -STATE_FILE = PLUGIN_ROOT / ".session_state.json" +sys.path.insert(0, str(PLUGIN_ROOT / "hooks")) +sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) + +# 现行状态目录在 ~/.codeguard(CODEGUARD_HOME 可覆盖);插件根的旧文件 +# 升版本即丢、双副本各存一半——load_state 优先新路径,回退读旧文件一次。 +LEGACY_STATE_FILE = PLUGIN_ROOT / ".session_state.json" def load_state() -> dict: - if not STATE_FILE.exists(): + from gate_lib import session_state_path + + path = session_state_path() + target = path if path.exists() else LEGACY_STATE_FILE + if not target.exists(): return {} try: - return json.loads(STATE_FILE.read_text()) + return json.loads(target.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {} def summarize(state: dict) -> str: - if not state: + lang_state = {k: v for k, v in state.items() if not k.startswith("_")} + meta = {k: v for k, v in state.items() if k.startswith("_")} + if not lang_state and not meta: return "[codeguard] 本次会话无 linter 记录(可能没有改代码文件)" lines = ["[codeguard] 本次会话代码 lint 检查总结", ""] - for lang, info in state.items(): + for lang, info in lang_state.items(): + if not isinstance(info, dict): + continue total = info.get("total", 0) passed = info.get("passed", 0) failed = info.get("failed", 0) auto_fixed = info.get("auto_fixed", 0) lines.append(f"- {lang}: 共 {total} 次检查,{passed} 通过,{failed} 失败,{auto_fixed} 自动修复成功") + skip = meta.get("_skip") or {} + if skip.get("count"): + kinds = "、".join(f"{k}×{v}" for k, v in (skip.get("kinds") or {}).items()) + lines.append(f"- ⚠️ 本会话绕过门禁 {skip['count']} 次({kinds})——确认是用户明确要求,并尽快 git config --unset codeguard.skipGate") return "\n".join(lines) +def _skipgate_left_enabled() -> bool: + """会话结束时提醒:仓库级豁免还开着(忘记 unset = 门禁永久静默失效)。""" + try: + import subprocess + + from gate_lib import skip_gate_via_git_config + proc = subprocess.run( + ["git", "rev-parse", "--git-dir"], + cwd=Path.cwd(), capture_output=True, check=False, text=True, timeout=10, + ) + if proc.returncode != 0: + return False + return skip_gate_via_git_config(Path.cwd()) + except Exception: # noqa: BLE001 — 提醒类检查,失败绝不影响 Stop + return False + + def main() -> int: state = load_state() print(summarize(state)) - # 会话结束清理状态 - try: - STATE_FILE.unlink(missing_ok=True) - except OSError: - pass + if _skipgate_left_enabled(): + print("[codeguard] ⚠️ git config codeguard.skipGate 仍为 true——该仓门禁处于豁免状态;" + "若绕过已完成,请执行 git config --unset codeguard.skipGate 恢复") + # 会话结束清理状态(新旧两处都清) + from gate_lib import session_state_path + + for target in (session_state_path(), LEGACY_STATE_FILE): + with contextlib.suppress(OSError): + target.unlink(missing_ok=True) return 0 diff --git a/hooks/user_prompt_validator.py b/hooks/user_prompt_validator.py index ff0dc85..e9dac92 100755 --- a/hooks/user_prompt_validator.py +++ b/hooks/user_prompt_validator.py @@ -10,6 +10,7 @@ """ from __future__ import annotations +import contextlib import json import os import sys @@ -35,7 +36,10 @@ check_commit_safety, format_safety_report, gate_directive, + record_skip_event, run_gate, + should_suppress_event, + skip_gate_via_git_config, summarize_failures, ) @@ -61,14 +65,12 @@ def notify(title: str, message: str) -> None: import subprocess safe_t = title.replace('"', "'") safe_m = message.replace('"', "'")[:200] - try: + with contextlib.suppress(OSError): subprocess.Popen( ["osascript", "-e", f'display notification "{safe_m}" with title "{safe_t}" sound name "Pop"'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - except OSError: - pass # 疑问句特征:在询问功能/做法,不是真的要提交(实测误触发: @@ -114,27 +116,66 @@ def _detect_languages_in_text(user_text: str, language_ids: list[str]) -> list[s return hits -def read_user_text() -> str: +def read_payload() -> dict: + """读一次 stdin payload(text 与 session_id 都从中取,stdin 只能读一次)。""" if sys.stdin.isatty(): - return "" + return {} try: data = json.loads(sys.stdin.read()) except json.JSONDecodeError: - return "" - return data.get("user_prompt") or data.get("prompt") or "" + return {} + return data if isinstance(data, dict) else {} + + +def read_user_text() -> str: + payload = read_payload() + return str(payload.get("user_prompt") or payload.get("prompt") or "") + + +def _non_git_note() -> str: + return ( + "codeguard:当前目录不是 git 仓库,提交门禁已跳过" + "(门禁只对 git 仓生效;非 git 目录曾被回退成工作区根全仓扫描——" + "一个临时目录即可把上百个无关仓的存量 lint 变成永久红)。" + ) def main() -> int: + payload = read_payload() + user_text = str(payload.get("user_prompt") or payload.get("prompt") or "") + if not is_trigger(user_text): + return 0 + + # 双副本去重:session_id + 文本 派生事件 key;测试 payload 无该字段→旧行为 + session_id = payload.get("session_id") + if session_id and should_suppress_event(f"ups:{session_id}:{user_text[:200]}"): + return 0 + ensure_user_path(from_login_shell=True) # GUI 宿主 PATH 不含用户级工具目录 # 逃生门:设置此环境变量后跳过提交门禁(用于确实需要绕过的场景) if os.environ.get("CODEGUARD_SKIP_GATE"): + record_skip_event("env") return 0 - user_text = read_user_text() - if not is_trigger(user_text): + + root = find_project_root(os.getcwd()) + if root is None or not (root / ".git").exists(): + # 非 git 目录绝不回退成"扫这个目录"——曾经的 find_project_root or cwd + # 让工作区根成了全仓扫描对象(永久红且不可合法修复)。显式说明+跳过。 + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": _non_git_note(), + } + }, ensure_ascii=False)) + return 0 + project_root = root + if skip_gate_via_git_config(project_root): + # 软门禁与硬门禁共用同一条仓库级豁免:此前 UPS 不认 skipGate, + # 出现过"硬门放行、软门仍喊禁止提交"的自相矛盾。 + record_skip_event("skipGate") return 0 cfg = load_user_config() - project_root = find_project_root(os.getcwd()) or Path(os.getcwd()) # 2.2: 消息里提到了具体语言时只跑子集;没提到则回退全量探测(2.3)。 detected = detect_languages(project_root) subset = _detect_languages_in_text(user_text, detected) diff --git a/kimi.plugin.json b/kimi.plugin.json index 538e98a..c0a99a0 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.7.1", + "version": "0.8.0", "description": "Cross-language code lint enforcement for AI coding assistants (ZCode, Claude Code, Codex CLI, Kimi Code): Java, Rust, TypeScript, Python. PostToolUse hook auto-runs the native linter on every AI-written file and blocks on failure in strict mode.", "author": { "name": "Full Stack Skills / PartMe.AI" diff --git a/linters/ruff/ruff.toml b/linters/ruff/ruff.toml new file mode 100644 index 0000000..3c31ce1 --- /dev/null +++ b/linters/ruff/ruff.toml @@ -0,0 +1,35 @@ +# codeguard 默认 ruff 配置(项目无自有 ruff 配置时由 --config 注入) +# 与 pyproject-snippet.toml 语义一致,但为 ruff 原生格式—— +# --config 指向带 [tool.ruff] 包装的文件会直接 TOML 解析失败(实测)。 +line-length = 120 +target-version = "py310" +extend-exclude = [".venv", "build", "dist"] + +[lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort(import 排序) + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "N", # pep8-naming + "SIM", # flake8-simplify +] +ignore = [ + "E501", + "B008", +] + +# 插件钩子/脚本的结构性模式:sys.path 引导后才能 import 同仓模块, +# 这些 import 必然不在文件顶部(E402 属实但不可迁移)。按目录豁免。 +[lint.per-file-ignores] +"hooks/*.py" = ["E402"] +"scripts/*.py" = ["E402"] +"tests/*.py" = ["E402"] + +[format] +quote-style = "double" +indent-style = "space" +line-ending = "lf" diff --git a/openspec/changes/archive/2026-09-22-harden-commit-gate/.openspec.yaml b/openspec/changes/archive/2026-09-22-harden-commit-gate/.openspec.yaml new file mode 100644 index 0000000..1b9acb7 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-harden-commit-gate/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-22 diff --git a/openspec/changes/archive/2026-09-22-harden-commit-gate/design.md b/openspec/changes/archive/2026-09-22-harden-commit-gate/design.md new file mode 100644 index 0000000..0aed637 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-harden-commit-gate/design.md @@ -0,0 +1,44 @@ +## Context + +审计以实弹方式进行(image-factory 仓几十次真实提交尝试),每条缺陷都有复现记录。核心张力:门禁是"硬"的,但它的输出直接塑造 AI 的下一步行为——一句缺失或误导的反馈(不声明整调用被拒)会引发整类错误操作。另一张力:作用域太宽(全仓)让存量问题绑架新提交,作用域太窄(无)则失去保护;git 本身提供了天然的分界线——本次改动。 + +## Goals / Non-Goals + +**Goals:** + +- 让拦截反馈与宿主真实语义一致(整调用未执行)。 +- 让"改没改、查哪些"完全由 git 状态决定(delta),并可用 `gate_scope` 退回全仓。 +- 让判定可复现(规则集不随机器漂移)、诚实(工具崩溃=未验证,非失败)。 +- 让一切绕过可见、可回收(记账 + Stop 提醒 + 双门一致)。 + +**Non-Goals:** + +- 不改退出码与 JSON schema(§1 表原样)。 +- 不做更深层(subprocess 拼接)的间接执行分析——静态一层是能力边界,文档不夸大。 +- 不引入第三方依赖;不改 `languages.json` 之外的注册表语义。 +- 不处理 2 个与本变更无关的既有 yaml 环境性测试失败(基线同名保留)。 + +## Decisions + +1. **delta 为 git 仓缺省、`codeguard.json gate_scope:"repo"` 可退回**——提交门禁的正确语义是"新提交不引入新问题",仓健康属 `check` CLI(保持全量)。 +2. **作用域物化放独立 `scripts/scope.py`**——`language-gate-commands` 明文限定 detect_lang 只暴露检测面白名单,塞进去违反既有 spec;新模块无反向依赖。 +3. **exit 2 归未验证而非失败**——与该 spec 既有的"用法错误不是 lint 结论"要求同向;zig 等真问题工具实测用 exit 1,无冲突。 +4. **ruff 默认配置用原生格式文件**——`--config` 指向 `[tool.ruff]` 包装的 pyproject 片段直接解析失败(实测),故新增 `linters/ruff/ruff.toml` 与 snippet 并存(文档用 snippet、注入用原生)。 +5. **整调用声明进 `gate_directive` 末段而非首行**——首行综述是 tests 守护的契约,末段追加不破坏它。 +6. **状态目录迁 `~/.codeguard` + 条目归一化**——`_notify` 曾写出缺 `total` 的残缺条目,`entry["total"]` KeyError 被 fail-open 吞掉导致输出全空(实测复现);归一化从根上封死。 +7. **shell severity 政策对齐进 lint**——gate 带 `--severity=warning` 而单文件 lint 不带,delta 与 PostToolUse 都走 lint,政策分叉会造成门禁间互相矛盾。 + +## Risks / Trade-offs + +- [delta 让存量坏文件不再拦新提交] → 这是设计意图(提交门禁查增量),仓健康由 `check`/CI 全量承担;`gate_scope:"repo"` 可逐仓回退。 +- [静态一层间接可被更深构造绕过] → 文档明确能力边界,不夸大"硬保证";触发面(直接命令+一层脚本)已覆盖实测全部漏网案例。 +- [exit 2 归未验证对用 exit 2 报 findings 的假想工具变宽] → 主流工具(ruff/eslint/shellcheck/yamllint/zig fmt)findings 均为 exit 1,注册表内无反例(测试锁定)。 +- [双副本仍在宿主同时启用] → 插件只能告警+事件去重,卸载动作在用户;去重键依赖宿主事件 id,缺失时保持旧行为(宁可重复不误吞)。 + +## Migration Plan + +1. 本变更规格先行(已完成),三份 delta 挂既有能力。 +2. 实现按 B1-B8 批次落地(已完成),`tests/run_all.py` 的 delta 前提 3 处与 `shell.lint` 政策同步修正。 +3. `__protocol__.md` 同 commit 更新符号定位与新语义(本变更)。 +4. 版本 0.7.1 → 0.8.0(行为新增,minor),走 AGENTS.md 发版流程推两仓。 +5. 既有 2 个 yaml 环境性失败留在基线(与本变更无关、同名复现)。 diff --git a/openspec/changes/archive/2026-09-22-harden-commit-gate/proposal.md b/openspec/changes/archive/2026-09-22-harden-commit-gate/proposal.md new file mode 100644 index 0000000..1bcd9a3 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-harden-commit-gate/proposal.md @@ -0,0 +1,33 @@ +## Why + +一次 14 项实弹审计(两个仓、几十次真实提交尝试)暴露了门禁层的成组缺陷:整调用拦截不声明"前序步骤也没执行"(AI 误判编辑被吞 ×3)、脚本间接完全绕过硬门禁(/tmp runner 连推 4 次漏网)、缓存键漏工作区改动(修完仍报旧失败的 retry-timing 陷阱)、非 git 目录回退扫描 cwd(工作区根被上百无关仓的存量 lint 变成永久红)、exit 2 工具崩溃被当成 lint 失败、全仓 format 静默重写无关文件(单文件保存炸出 30+ 漂移)、skipGate 只拦硬门不拦软门(放行与"禁止提交"自相矛盾)、双副本重复执行、绕过无记账、状态存安装目录升级即失。这些都可离线复现,且大多与既有 spec 的"无法验证≠验证失败"原则同向。 + +## What Changes + +- 硬拦截反馈新增**整调用声明**:被拒的是一次完整 Bash 调用(含非 git 前序步骤),指令要求把修复与提交拆成独立调用。 +- `is_guarded` 覆盖**一层解释器间接**(脚本文件与 `-c` 内联的静态扫描),封堵 `bash runner.sh` 式绕过。 +- 门禁缓存键纳入**工作区内容指纹**(staged + 未暂存 + 未跟踪 stat),修完未暂存即失效。 +- git 仓门禁缺省 **delta 作用域**(只查 staged/未暂存/未跟踪中属于该语言的文件;存量问题不拦新提交;`codeguard.json gate_scope` 可退回全量),全量模式剔除 vendor 等不可编辑目录。 +- **exit 2 一律归未验证**(hook 路径 skipped、CLI/MCP `unverified` 标记),按既定"无法验证≠验证失败"。 +- ruff 命令在项目无自有配置时注入 `linters/ruff/ruff.toml`(原生格式)默认规则集,判定不再随机器 ruff 漂移;新增 `scripts/scope.py` 承载作用域物化(detect_lang 保持 spec 规定的纯检测面)。 +- PostToolUse 的 lint/format 物化到**单文件作用域**,自动修复后把被波及文件清单注入上下文;节选告警附总量与完整日志路径。 +- `UserPromptSubmit` 非 git 目录显式跳过(一行说明),并与硬门禁**共用 skipGate**;软硬两门的绕过都记账,Stop 汇总显示绕过次数与 skipGate 遗留告警。 +- 会话状态迁至 `~/.codeguard/session_state.json`(`CODEGUARD_HOME` 可覆盖),状态条目归一化防 KeyError-fail-open;双副本启用时 SessionStart 告警;PreToolUse/UPS 按宿主事件 id 去重。 +- 仓库级 `format`(如 shellcheck severity 政策)与单文件 lint 对齐:`shell.lint` 增 `--severity=warning`,消除"保存时绿、提交时红"的政策分叉。 +- `__protocol__.md` 指针从行号改为**符号级**定位并同步全部新语义。 + +## Capabilities + +### New Capabilities + +无。三项均落在既有能力上。 + +### Modified Capabilities + +- `hook-protocol`: 硬拦截出口新增整调用声明要求;`is_guarded` 拦截条件扩展为直接+一层间接;新增软硬门禁共享 skipGate、非 git 目录不回退扫描的语义边界。 +- `gate-trigger-policy`: 新增否定语境(未提交/别提交)不触发的回归要求。 +- `language-gate-commands`: 新增 git 仓缺省 delta 作用域、exit 2 归未验证、单文件钩子命令不得全仓展开三条要求。 + +## Impact + +改动面:`hooks/{gate_lib,pre_tool_git_guard,user_prompt_validator,post_tool_lint,stop_summary,env_check}.py`、`hooks/__protocol__.md`、新增 `scripts/scope.py` 与 `linters/ruff/ruff.toml`、`scripts/{paths,user_config,run_per_language,run_check,fix}.py`、`scripts/languages.json` + 重生成 `docs/LANGUAGES.md`、`tests/run_all.py`(delta 前提 3 处)+ 新增 `tests/test_hardening_fixes.py`(29 例)。退出码与 JSON schema 均未变——§1 协议总表字段原样,首行综述契约保持。CLI 行为变化:`check` 对 exit 2 显示 unverified(不计失败)、`fix` 缺省只修本次改动(`--all` 全仓)。 diff --git a/openspec/changes/archive/2026-09-22-harden-commit-gate/specs/gate-trigger-policy/spec.md b/openspec/changes/archive/2026-09-22-harden-commit-gate/specs/gate-trigger-policy/spec.md new file mode 100644 index 0000000..ab86fc4 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-harden-commit-gate/specs/gate-trigger-policy/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Negated statements SHALL NOT trigger the gate + +当触发词出现在否定语境(如 `未提交`、`还没提交`、`别提交`)中且不存在行动意图短语时,UserPromptSubmit MUST 不触发门禁。该规则 MUST 同时适用于中文子串触发词与英文词边界触发词。 + +#### Scenario: User reports something is not committed + +- **WHEN** 用户消息为 `未提交任何东西` 或 `别提交这些文件` +- **THEN** 钩子 exit 0 且 stdout 为空,不运行 linter + +#### Scenario: Negation removed, imperative remains + +- **WHEN** 用户消息为 `提交代码` +- **THEN** 钩子进入门禁路径(既有祈使规则保持) diff --git a/openspec/changes/archive/2026-09-22-harden-commit-gate/specs/hook-protocol/spec.md b/openspec/changes/archive/2026-09-22-harden-commit-gate/specs/hook-protocol/spec.md new file mode 100644 index 0000000..dd6182e --- /dev/null +++ b/openspec/changes/archive/2026-09-22-harden-commit-gate/specs/hook-protocol/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: Hard-block feedback SHALL declare whole-call rejection + +PreToolUse 硬拦截(exit 2)的 stderr 报告 MUST 包含一条整调用声明:本次被拒绝的是一次完整的 Bash 工具调用,其中非 git 的前序步骤(写文件、执行脚本)也全部未执行,指令 MUST 要求调用方把修复与提交拆成两次独立调用。该声明 MUST NOT 改变首行综述契约(首行仍为 `codeguard ❌ 提交门禁未通过:`)。 + +#### Scenario: AI retries a combined write-and-commit call + +- **WHEN** 一次同时包含写文件与 `git commit` 的调用被门禁拦截 +- **THEN** 报告在首行综述之后包含"整个工具调用没有执行/拆成两次独立调用"的指令,且首行仍是综述 + +### Requirement: The guard SHALL treat one-level interpreter indirection as guarded + +`is_guarded()` MUST 覆盖直接命令与一层解释器间接:解释器(bash/sh/zsh/python/node)执行的脚本文件文本、或 `-c` 内联代码,按与直接命令相同的分隔符切段规则扫描到 `git commit|push` 段首命令词时 MUST 判为拦截。更深的动态构造(如 subprocess 参数拼接)MUST 在文档中声明为能力边界而非承诺。 + +#### Scenario: A wrapper script performs the commit + +- **WHEN** 调用形如 `bash runner.sh` 且脚本体内含 `git commit` 或 `git push` +- **THEN** 钥入判定命中,硬门禁按正常流程跑 linter 并可能 exit 2 + +#### Scenario: Text mentioning git push in an echo is not guarded + +- **WHEN** 调用为 `echo "git push 是危险命令"` 或脚本内容仅为 `echo hi` +- **THEN** 判定不命中,钩子静默放行 + +### Requirement: Soft and hard gates SHALL share the skipGate escape and neither may fall back to scanning outside a git repository + +UserPromptSubmit 与 PreToolUse MUST 共用同一条仓库级豁免(`git config codeguard.skipGate`);两门在命中豁免时 MUST 记账一次供 Stop 汇总。UserPromptSubmit 在当前目录不属于任何 git 仓库时 MUST 输出一行跳过说明并 exit 0,MUST NOT 把非 git 目录(尤其是多仓工作区根)回退为扫描对象。 + +#### Scenario: SkipGate set, user asks to commit + +- **WHEN** 仓库已设 skipGate 且用户消息触发提交意图 +- **THEN** 软门禁静默退出(与硬门禁一致),绕过计数 +1 + +#### Scenario: Prompt fires outside any git repository + +- **WHEN** cwd 不在 git 仓库内且消息触发提交意图 +- **THEN** 输出"不是 git 仓库,提交门禁已跳过"的说明,不运行任何 linter、不扫描 cwd diff --git a/openspec/changes/archive/2026-09-22-harden-commit-gate/specs/language-gate-commands/spec.md b/openspec/changes/archive/2026-09-22-harden-commit-gate/specs/language-gate-commands/spec.md new file mode 100644 index 0000000..6cbba9d --- /dev/null +++ b/openspec/changes/archive/2026-09-22-harden-commit-gate/specs/language-gate-commands/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Gates in git repositories SHALL default to changed-file scope + +git 仓库内的门禁 MUST 缺省只检查本次改动涉及的文件(staged + 未暂存 + 未跟踪,按语言归属过滤);存量问题 MUST NOT 阻塞无关的新提交。项目可用 `codeguard.json` 的 `gate_scope`(`delta`/`repo`)显式覆盖;非 git 目录缺省为全量。全量模式 MUST 从 ruff 扫描中剔除依赖快照与构建产物目录(vendor/build/dist 等)。 + +#### Scenario: A committed legacy issue is untouched by a clean change + +- **WHEN** 仓内 HEAD 已含存量 lint 问题,本次提交只涉及无问题的文档文件 +- **THEN** 门禁判定通过,存量问题不拦截本次提交 + +#### Scenario: A new staged file introduces a problem + +- **WHEN** 新增或修改的文件被暂存且含 lint 问题 +- **THEN** 门禁按改动集检出该问题并正常拦截 + +### Requirement: A toolchain crash SHALL be recorded as unverified, not as a lint failure + +linter 以 exit 2(用法/依赖/配置崩溃)退出时,门禁 MUST 记为未验证(hook 路径归 skipped;CLI/MCP 标记 unverified 且不计为失败),MUST NOT 作为 lint 失败上报或触发自动修复。 + +#### Scenario: An npx tool crashes with exit 2 + +- **WHEN** 某语言的检查命令因包/运行时问题以 exit 2 退出且无 lint 结论 +- **THEN** 结果为"工具链异常未验证",不阻塞提交、不计入失败 + +### Requirement: Single-file hook commands MUST NOT silently expand to whole-repository scans + +PostToolUse 的 lint 与 format 命令 MUST 物化到被编辑文件的单文件作用域:存在 `{file}` 或全仓扫描 token(`.`、`**/*.md`)时 MUST 收敛为该文件,裸命令 MUST 追加该文件路径。自动修复成功后,若改动波及被编辑文件之外的文件,MUST 把文件清单注入返回上下文。 + +#### Scenario: Editing one Python file triggers format + +- **WHEN** AI 保存单个 `.py` 文件且该文件 lint 失败触发自动修复 +- **THEN** format 命令只作用于该文件;若仍有其它文件被改动,返回上下文列出被改动文件并要求重新读取 diff --git a/openspec/changes/archive/2026-09-22-harden-commit-gate/tasks.md b/openspec/changes/archive/2026-09-22-harden-commit-gate/tasks.md new file mode 100644 index 0000000..bae833e --- /dev/null +++ b/openspec/changes/archive/2026-09-22-harden-commit-gate/tasks.md @@ -0,0 +1,34 @@ +## 1. Specification + +- [x] 1.1 三份 delta spec(hook-protocol / gate-trigger-policy / language-gate-commands) +- [x] 1.2 `openspec validate --strict` 通过 + +## 2. Hard-gate semantics + +- [x] 2.1 gate_directive 追加整调用声明(首行综述契约保持) +- [x] 2.2 is_guarded 直接 + 一层间接(脚本文件与 -c 内联);边界在 docstring 声明 +- [x] 2.3 skipGate 命中记账(env/两门双通路);resolve_project_roots cd 语义注释 + +## 3. Scope and reproducibility + +- [x] 3.1 缓存键加工作区内容指纹(staged/未暂存/未跟踪) +- [x] 3.2 git 仓缺省 delta 作用域 + `gate_scope` 项目级覆盖 + 全量剔除 vendor +- [x] 3.3 `scripts/scope.py` 承载物化(detect_lang 回到 spec 白名单面) +- [x] 3.4 ruff 原生默认配置注入(项目自有配置时不注入) +- [x] 3.5 `shell.lint` 对齐 severity 政策 + 重生成 LANGUAGES.md + +## 4. Verdict honesty and feedback quality + +- [x] 4.1 exit 2 → hook skipped / CLI-MCP unverified 标记 +- [x] 4.2 节选附总量 + 完整日志路径(gate 与 post 两处) +- [x] 4.3 PostToolUse lint/format 单文件物化 + 波及文件清单注入 +- [x] 4.4 fix.py 缺省 delta、`--all` 全仓 + +## 5. Governance + +- [x] 5.1 UPS 非 git 目录显式跳过(不回退扫描 cwd) +- [x] 5.2 UPS 与硬门禁共享 skipGate;否定语境回归测试 +- [x] 5.3 状态迁 `~/.codeguard/session_state.json` + 条目归一化 + Stop 绕过/skipGate 提醒 +- [x] 5.4 双副本 SessionStart 告警 + PreToolUse/UPS 事件去重 +- [x] 5.5 run_all delta 前提 3 处修正;新增 `tests/test_hardening_fixes.py`(29 例) +- [x] 5.6 `__protocol__.md` 符号定位与新语义同 commit 同步 diff --git a/openspec/specs/gate-trigger-policy/spec.md b/openspec/specs/gate-trigger-policy/spec.md index 43339c3..7e1b0b2 100644 --- a/openspec/specs/gate-trigger-policy/spec.md +++ b/openspec/specs/gate-trigger-policy/spec.md @@ -42,3 +42,17 @@ When the trigger fires, the hook MUST extract candidate language ids from the us - **WHEN** the user prompt is `Should I push this?` - **THEN** the hook exits silently because `?` is a recognized question marker +### Requirement: Negated statements SHALL NOT trigger the gate + +当触发词出现在否定语境(如 `未提交`、`还没提交`、`别提交`)中且不存在行动意图短语时,UserPromptSubmit MUST 不触发门禁。该规则 MUST 同时适用于中文子串触发词与英文词边界触发词。 + +#### Scenario: User reports something is not committed + +- **WHEN** 用户消息为 `未提交任何东西` 或 `别提交这些文件` +- **THEN** 钩子 exit 0 且 stdout 为空,不运行 linter + +#### Scenario: Negation removed, imperative remains + +- **WHEN** 用户消息为 `提交代码` +- **THEN** 钩子进入门禁路径(既有祈使规则保持) + diff --git a/openspec/specs/hook-protocol/spec.md b/openspec/specs/hook-protocol/spec.md index 6bfea9f..e181315 100644 --- a/openspec/specs/hook-protocol/spec.md +++ b/openspec/specs/hook-protocol/spec.md @@ -35,3 +35,40 @@ The top-of-file docstring of `tests/run_all.py` SHALL contain a one-line referen - **WHEN** an uncaught `Exception` propagates to the top of a hook - **THEN** the hook prints the traceback tail to stderr and exits 0; the host CLI receives no block signal and continues its workflow +### Requirement: Hard-block feedback SHALL declare whole-call rejection + +PreToolUse 硬拦截(exit 2)的 stderr 报告 MUST 包含一条整调用声明:本次被拒绝的是一次完整的 Bash 工具调用,其中非 git 的前序步骤(写文件、执行脚本)也全部未执行,指令 MUST 要求调用方把修复与提交拆成两次独立调用。该声明 MUST NOT 改变首行综述契约(首行仍为 `codeguard ❌ 提交门禁未通过:`)。 + +#### Scenario: AI retries a combined write-and-commit call + +- **WHEN** 一次同时包含写文件与 `git commit` 的调用被门禁拦截 +- **THEN** 报告在首行综述之后包含"整个工具调用没有执行/拆成两次独立调用"的指令,且首行仍是综述 + +### Requirement: The guard SHALL treat one-level interpreter indirection as guarded + +`is_guarded()` MUST 覆盖直接命令与一层解释器间接:解释器(bash/sh/zsh/python/node)执行的脚本文件文本、或 `-c` 内联代码,按与直接命令相同的分隔符切段规则扫描到 `git commit|push` 段首命令词时 MUST 判为拦截。更深的动态构造(如 subprocess 参数拼接)MUST 在文档中声明为能力边界而非承诺。 + +#### Scenario: A wrapper script performs the commit + +- **WHEN** 调用形如 `bash runner.sh` 且脚本体内含 `git commit` 或 `git push` +- **THEN** 钥入判定命中,硬门禁按正常流程跑 linter 并可能 exit 2 + +#### Scenario: Text mentioning git push in an echo is not guarded + +- **WHEN** 调用为 `echo "git push 是危险命令"` 或脚本内容仅为 `echo hi` +- **THEN** 判定不命中,钩子静默放行 + +### Requirement: Soft and hard gates SHALL share the skipGate escape and neither may fall back to scanning outside a git repository + +UserPromptSubmit 与 PreToolUse MUST 共用同一条仓库级豁免(`git config codeguard.skipGate`);两门在命中豁免时 MUST 记账一次供 Stop 汇总。UserPromptSubmit 在当前目录不属于任何 git 仓库时 MUST 输出一行跳过说明并 exit 0,MUST NOT 把非 git 目录(尤其是多仓工作区根)回退为扫描对象。 + +#### Scenario: SkipGate set, user asks to commit + +- **WHEN** 仓库已设 skipGate 且用户消息触发提交意图 +- **THEN** 软门禁静默退出(与硬门禁一致),绕过计数 +1 + +#### Scenario: Prompt fires outside any git repository + +- **WHEN** cwd 不在 git 仓库内且消息触发提交意图 +- **THEN** 输出"不是 git 仓库,提交门禁已跳过"的说明,不运行任何 linter、不扫描 cwd + diff --git a/openspec/specs/language-gate-commands/spec.md b/openspec/specs/language-gate-commands/spec.md index 4e1e17a..6bbe8f2 100644 --- a/openspec/specs/language-gate-commands/spec.md +++ b/openspec/specs/language-gate-commands/spec.md @@ -86,3 +86,35 @@ The functions `load_user_config`, `load_project_overrides`, and `get_overrides` - **WHEN** a new function in `scripts/detect_lang.py` mixes PATH handling, config loading, or other unrelated concerns with language detection - **THEN** the change SHALL be rejected because it violates the single-responsibility boundary +### Requirement: Gates in git repositories SHALL default to changed-file scope + +git 仓库内的门禁 MUST 缺省只检查本次改动涉及的文件(staged + 未暂存 + 未跟踪,按语言归属过滤);存量问题 MUST NOT 阻塞无关的新提交。项目可用 `codeguard.json` 的 `gate_scope`(`delta`/`repo`)显式覆盖;非 git 目录缺省为全量。全量模式 MUST 从 ruff 扫描中剔除依赖快照与构建产物目录(vendor/build/dist 等)。 + +#### Scenario: A committed legacy issue is untouched by a clean change + +- **WHEN** 仓内 HEAD 已含存量 lint 问题,本次提交只涉及无问题的文档文件 +- **THEN** 门禁判定通过,存量问题不拦截本次提交 + +#### Scenario: A new staged file introduces a problem + +- **WHEN** 新增或修改的文件被暂存且含 lint 问题 +- **THEN** 门禁按改动集检出该问题并正常拦截 + +### Requirement: A toolchain crash SHALL be recorded as unverified, not as a lint failure + +linter 以 exit 2(用法/依赖/配置崩溃)退出时,门禁 MUST 记为未验证(hook 路径归 skipped;CLI/MCP 标记 unverified 且不计为失败),MUST NOT 作为 lint 失败上报或触发自动修复。 + +#### Scenario: An npx tool crashes with exit 2 + +- **WHEN** 某语言的检查命令因包/运行时问题以 exit 2 退出且无 lint 结论 +- **THEN** 结果为"工具链异常未验证",不阻塞提交、不计入失败 + +### Requirement: Single-file hook commands MUST NOT silently expand to whole-repository scans + +PostToolUse 的 lint 与 format 命令 MUST 物化到被编辑文件的单文件作用域:存在 `{file}` 或全仓扫描 token(`.`、`**/*.md`)时 MUST 收敛为该文件,裸命令 MUST 追加该文件路径。自动修复成功后,若改动波及被编辑文件之外的文件,MUST 把文件清单注入返回上下文。 + +#### Scenario: Editing one Python file triggers format + +- **WHEN** AI 保存单个 `.py` 文件且该文件 lint 失败触发自动修复 +- **THEN** format 命令只作用于该文件;若仍有其它文件被改动,返回上下文列出被改动文件并要求重新读取 + diff --git a/scripts/fix.py b/scripts/fix.py index 7ea6525..d41c903 100644 --- a/scripts/fix.py +++ b/scripts/fix.py @@ -20,12 +20,15 @@ detect_languages, find_project_root, ) +from scope import changed_files def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--lang", help="只修复指定语言") parser.add_argument("--dry-run", action="store_true", help="只看,不真改") + parser.add_argument("--all", action="store_true", + help="全仓修复(缺省只修本次改动涉及的文件——一键修复不该重写整个仓库)") parser.add_argument("path", nargs="?", default=".") args = parser.parse_args() @@ -42,11 +45,23 @@ def main() -> int: print(f"[codeguard] languages: {languages} dry-run={args.dry_run}") print() + target_files = None + if not args.all: + target_files = changed_files(project_root) + if target_files is not None: + if not target_files: + print("[codeguard] 本次无改动文件,无需修复(--all 可全仓修复)") + return 0 + print(f"[codeguard] scope: 仅本次改动的 {len(target_files)} 个文件(--all 全仓)") + # target_files None = 非 git 目录 → 退回全量(无改动概念可依) results = run_per_language.run_fix( - languages, project_root, dry_run=args.dry_run, + languages, project_root, dry_run=args.dry_run, files=target_files, ) failed: list[str] = [] - for r, lang in zip(results, languages): + for r, lang in zip(results, languages, strict=False): + if r.get("skipped"): + print(f" {lang:12s} ⏭️ {r.get('note')}") + continue if r.get("dry_run"): print(f" {lang:12s} would run: {' '.join(r.get('command') or [])}") continue @@ -64,4 +79,4 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/scripts/languages.json b/scripts/languages.json index a1c96c8..fdcc585 100644 --- a/scripts/languages.json +++ b/scripts/languages.json @@ -368,6 +368,7 @@ "since": "V0.2", "lint": [ "shellcheck", + "--severity=warning", "{file}" ], "format": [ diff --git a/scripts/paths.py b/scripts/paths.py index d4b4509..a7b9602 100644 --- a/scripts/paths.py +++ b/scripts/paths.py @@ -8,8 +8,10 @@ """ from __future__ import annotations +import contextlib import os import subprocess +import sys import tempfile from pathlib import Path @@ -25,6 +27,9 @@ def ensure_user_path(from_login_shell: bool = False) -> None: 继承完整 PATH(覆盖 nvm 等动态目录,约 100-300ms,只适合低频钩子)。 """ static_dirs = [ + # 运行本进程的解释器自己的 bin 目录:ruff 等工具常装在这里 + # (anaconda、python -m pip install 等),不补就会"装了却报不在 PATH"。 + str(Path(sys.executable).parent), "/opt/homebrew/bin", "/usr/local/bin", str(Path.home() / ".local" / "bin"), str(Path.home() / ".cargo" / "bin"), @@ -41,10 +46,10 @@ def ensure_user_path(from_login_shell: bool = False) -> None: # node/npx 不可用且静态目录未覆盖时,自动降级登录 shell 继承一次 # (覆盖 nvm/fnm/Kimi runtime 等非标准 node 安装;约 100-300ms) def _node_available() -> bool: - for d in os.environ.get("PATH", "").split(":"): - if d and (Path(d) / "node").exists(): - return True - return False + return any( + d and (Path(d) / "node").exists() + for d in os.environ.get("PATH", "").split(":") + ) if not from_login_shell and not _node_available(): ensure_user_path(from_login_shell=True) @@ -80,9 +85,7 @@ def _node_available() -> bool: # 每次钩子都会重复 spawn shell,违背十分钟缓存契约。 resolved = inherited[-1] if inherited[-1].count(":") > cur.count(":") else os.environ["PATH"] os.environ["PATH"] = resolved - try: + with contextlib.suppress(OSError): cache_file.write_text(resolved) - except OSError: - pass except (OSError, subprocess.SubprocessError): - pass \ No newline at end of file + pass diff --git a/scripts/run_check.py b/scripts/run_check.py index 50b9c93..2be1555 100644 --- a/scripts/run_check.py +++ b/scripts/run_check.py @@ -62,11 +62,10 @@ def cli_main(): if args.mcp: return mcp_main(project_root) - log_dir: Path | None - if args.quiet: - log_dir = None - else: - log_dir = Path(args.log_dir) if args.log_dir else project_root / "out" + log_dir: Path | None = ( + None if args.quiet + else (Path(args.log_dir) if args.log_dir else project_root / "out") + ) languages = _filter_languages(project_root, args.lang) if not languages: @@ -81,12 +80,15 @@ def cli_main(): languages, project_root, timeout=args.timeout, fix=args.fix, log_dir=log_dir, ) - for r, lang in zip(results, languages): + for r, lang in zip(results, languages, strict=False): if r.get("dry_run"): continue if not r["passed"] and args.fix: print(f"[codeguard] ⚠️ {lang} lint failed, attempting auto-fix...") - status = "✅ passed" if r["passed"] else f"❌ FAILED (exit={r.get('exit_code')})" + if r.get("passed") and r.get("unverified"): + status = f"⚠️ unverified ({r['unverified']})" + else: + status = "✅ passed" if r["passed"] else f"❌ FAILED (exit={r.get('exit_code')})" print(f" {lang:12s} {status}") if not r["passed"] and r.get("log_path") and not args.quiet: print(f" └─ 完整日志: {r['log_path']}") diff --git a/scripts/run_per_language.py b/scripts/run_per_language.py index 5ce3735..28b98c9 100644 --- a/scripts/run_per_language.py +++ b/scripts/run_per_language.py @@ -14,7 +14,8 @@ import subprocess from pathlib import Path -from detect_lang import LANG_COMMANDS +from detect_lang import LANG_COMMANDS, detect_language +from scope import scope_cmd __all__ = ["run_check", "run_fix"] @@ -52,12 +53,28 @@ def run_check(languages: list[str], project_root: Path, results.append({"language": lang, "passed": True, "dry_run": True, "command": lint_cmd or []}) continue + # CLI/MCP 是仓健康检查(全量):注入默认 ruff 配置 + 剔除 vendor 快照, + # 否则规则集随机器漂移、供应链快照给出不可修的永久红。 + lint_cmd = scope_cmd(lint_cmd, project_root, full_excludes=True) rc, out, err = _run(lint_cmd, cwd=project_root, timeout=timeout) + if rc == 2: + # exit 2 = 用法/依赖/配置崩溃,与仓库内容无关(house 规则: + # 无法验证 ≠ 验证失败)。不触发 fix、不写失败日志。 + results.append({ + "language": lang, + "passed": True, + "exit_code": 2, + "unverified": "exit 2(工具链异常,非 lint 结论)", + "stderr_tail": err[-2000:] if err else "", + "stdout_tail": out[-1000:] if out else "", + }) + continue passed = rc == 0 if not passed and fix: fmt = cmd_def.get("format") if fmt: - _run(fmt, cwd=project_root, timeout=timeout + 60) + _run(scope_cmd(fmt, project_root, full_excludes=True), + cwd=project_root, timeout=timeout + 60) rc, out, err = _run(lint_cmd, cwd=project_root, timeout=timeout) passed = rc == 0 results.append({ @@ -97,24 +114,38 @@ def run_check(languages: list[str], project_root: Path, def run_fix(languages: list[str], project_root: Path, - *, timeout: int = 120, dry_run: bool = False) -> list[dict]: - """对每个语言跑 format;命令缺失归失败;dry_run 仅打印 would-run 行。""" + *, timeout: int = 120, dry_run: bool = False, + files: list[str] | None = None) -> list[dict]: + """对每个语言跑 format;命令缺失归失败;dry_run 仅打印 would-run 行。 + + `files` 非空 = 仅修复这些文件(fix.py 缺省 delta 模式);某语言在改动 + 里没有文件 → 该语言整行跳过(skipped 标记),绝不动仓里其它存量文件。 + """ results: list[dict] = [] for lang in languages: cmd_def = LANG_COMMANDS.get(lang) if not cmd_def: results.append({"language": lang, "fixed": False, "error": "no command defined"}) continue + lang_files: list[str] | None = None + if files is not None: + lang_files = [f for f in files if detect_language(f, project_root) == lang] + if not lang_files: + results.append({"language": lang, "fixed": True, "skipped": True, + "note": "本次改动未涉及该语言"}) + continue fmt = cmd_def.get("format") if dry_run or not fmt: results.append({"language": lang, "fixed": True, "dry_run": True, "command": fmt or []}) continue - rc, _out, err = _run(fmt, cwd=project_root, timeout=timeout) + cmd = scope_cmd(fmt, project_root, files=lang_files, + full_excludes=(lang_files is None)) + rc, _out, err = _run(cmd, cwd=project_root, timeout=timeout) results.append({ "language": lang, "fixed": rc == 0, "exit_code": rc, "stderr_tail": err[-2000:] if err else "", }) - return results \ No newline at end of file + return results diff --git a/scripts/scope.py b/scripts/scope.py new file mode 100644 index 0000000..951d848 --- /dev/null +++ b/scripts/scope.py @@ -0,0 +1,128 @@ +"""scripts/scope.py:linter 命令作用域物化 + git 改动集(delta 门禁共享层)。 + +从 `detect_lang.py` 独立出来有 spec 依据:`language-gate-commands` 要求 +detect_lang 只暴露语言检测与命令表 API,不得混入配置/路径/其它职责。 +本模块无反向依赖(不 import detect_lang),职责三件套: + +- `ruff_config_args`:项目无自有 ruff 配置时注入 codeguard 默认配置 + (判定可复现性:规则集不能随机器上恰好装的 ruff 漂移;注入文件必须是 + ruff 原生格式,[tool.ruff] 包装的片段会被 TOML 解析拒绝)。 +- `scope_cmd`:物化命令——{file} 替换、全仓扫描 token 收敛为文件列表、 + 裸命令追加文件、全量模式剔除依赖快照目录。 +- `changed_files`:git 仓的本次改动集(staged + 未暂存 + 未跟踪); + 非 git 目录返回 None(调用方据此退回全量作用域)。 + +被 hooks/gate_lib、hooks/post_tool_lint、scripts/run_per_language、 +scripts/fix 共用;detect_language 的按文件语言归属仍从 detect_lang 取。 +""" +from __future__ import annotations + +import subprocess +from pathlib import Path + +# 门禁/CI 全量模式下 ruff 默认剔除的目录(不可编辑的依赖快照与构建产物)。 +# 与 hooks/gate_lib.GUARD_EXCLUDE_DIRS 语义重叠但职责不同:那边管"能否入库", +# 这边管"扫不扫"。vendor 快照是供应链不可变内容,扫它只会得到"永久红"。 +_RUFF_FULL_EXCLUDES = ( + ".venv", "venv", "node_modules", "vendor", "upstream", "build", "dist", + "target", ".tox", "__pycache__", +) +_RUFF_SNIPPET = Path(__file__).resolve().parents[1] / "linters" / "ruff" / "ruff.toml" + + +def ruff_config_args(cmd: list, project_root: str | Path) -> list: + """ruff 命令注入 codeguard 默认配置——仅当项目没有自己的 ruff 配置时。 + + 按 ruff 的配置发现顺序判定:ruff.toml / .ruff.toml / 含 [tool.ruff] 的 + pyproject.toml;存在自有配置则完全不动(项目规则优先)。注入的文件必须是 + ruff 原生格式——指向带 [tool.ruff] 包装的 pyproject 片段会 TOML 解析失败。 + 没有这层注入,门禁规则集取决于机器上恰好装了哪个 ruff 及其默认值, + 判定不可复现(实测:无配置环境直接按 I001/EXE001 级规则报错)。 + """ + if not cmd or cmd[0] != "ruff" or not _RUFF_SNIPPET.is_file(): + return [] + root = Path(project_root) + if (root / "ruff.toml").exists() or (root / ".ruff.toml").exists(): + return [] + pyproject = root / "pyproject.toml" + if pyproject.exists(): + try: + if "[tool.ruff]" in pyproject.read_text(encoding="utf-8", errors="ignore"): + return [] + except OSError: + pass + return ["--config", str(_RUFF_SNIPPET)] + + +def scope_cmd( + cmd: list, + project_root: str | Path, + *, + files: list[str] | None = None, + single_file: str | None = None, + full_excludes: bool = False, +) -> list: + """物化一条 linter 命令:占位符替换 + 作用域收敛 + ruff 配置注入。 + + - `{file}` 占位符 → single_file 路径(PostToolUse 文件型 linter); + - 全仓扫描 token(`.`、`**/*.md` 等;`#exclude` 辅助项一并处理): + 给了 files/single_file 就替换成具体文件列表,没给则保留(全量模式); + - 既无占位符也无扫描 token 的裸命令:给了文件就**追加**(yamllint 这类 + 不带路径时读 stdin 恒"通过",追加才检查得到东西); + - ruff 命令注入 ruff_config_args 的默认配置;full_excludes=True(全量 + 模式)再追加 --exclude,剔除依赖快照与构建产物。 + """ + out = list(cmd) + if not out: + return out + targets = [single_file] if single_file else list(files or []) + if "{file}" in " ".join(out): + return [c.replace("{file}", single_file or "") for c in out] + if out[0] == "ruff": + args = ruff_config_args(out, project_root) + out = [out[0]] + args + out[1:] + if full_excludes: + for d in _RUFF_FULL_EXCLUDES: + out += ["--exclude", d] + scan_idx = [i for i, tok in enumerate(out[1:], 1) if tok == "." or "**" in tok] + if targets and scan_idx: + flags = [tok for i, tok in enumerate(out) if i not in scan_idx and not tok.startswith("#")] + at = min(scan_idx) + return flags[:at] + targets + flags[at:] + if targets and not scan_idx: + return out + targets + return out + + +def changed_files(project_root: str | Path) -> list[str] | None: + """本次改动涉及的文件(staged + 未暂存 + 未跟踪);非 git 仓返回 None。 + + 门禁的 delta 作用域据此判定"这次要检查什么":存量问题不拦新提交, + 不可编辑的 vendor 快照没动就不会被扫。排序去重,路径相对仓根。 + """ + root = Path(project_root) + try: + proc = subprocess.run( + ["git", "rev-parse", "--git-dir"], + cwd=root, capture_output=True, check=False, text=True, timeout=10, + ) + except (subprocess.TimeoutExpired, OSError): + return None + if proc.returncode != 0: + return None + names: set[str] = set() + for args in ( + ("diff", "--cached", "--name-only"), + ("diff", "--name-only"), + ("ls-files", "--others", "--exclude-standard"), + ): + try: + out = subprocess.run( + ["git", *args], + cwd=root, capture_output=True, check=False, text=True, timeout=15, + ) + except (subprocess.TimeoutExpired, OSError): + continue + if out.returncode == 0 and out.stdout: + names.update(line for line in out.stdout.splitlines() if line.strip()) + return sorted(names) diff --git a/scripts/user_config.py b/scripts/user_config.py index e068bc2..f328406 100644 --- a/scripts/user_config.py +++ b/scripts/user_config.py @@ -62,9 +62,13 @@ def load_project_overrides(project_root: str | Path) -> dict: data = json.loads(cfg.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {} + scope = data.get("gate_scope") return { "extensions": {k.lower(): v for k, v in (data.get("extensions") or {}).items()}, "exclude": list(data.get("exclude") or []), + # 门禁扫描范围:"delta"(默认,只查本次改动涉及的文件——存量问题不拦新提交) + # 或 "repo"(全仓扫描;仍会剔除 vendor/build 等不可编辑目录) + "gate_scope": scope if scope in ("delta", "repo") else None, } @@ -72,4 +76,4 @@ def get_overrides(project_root: str | Path) -> dict: key = str(Path(project_root).resolve()) if key not in _OVERRIDES_CACHE: _OVERRIDES_CACHE[key] = load_project_overrides(project_root) - return _OVERRIDES_CACHE[key] \ No newline at end of file + return _OVERRIDES_CACHE[key] diff --git a/tests/run_all.py b/tests/run_all.py index f95537f..562efe1 100755 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -78,17 +78,17 @@ def make_repo() -> Path: def test_languages(): print("\n[1] 语言规则结构审计(全部 stable/beta)") langs = json.loads((PLUGIN / "scripts" / "languages.json").read_text(encoding="utf-8"))["languages"] - stable = [l for l in langs if l.get("status") in ("stable", "beta")] + stable = [entry for entry in langs if entry.get("status") in ("stable", "beta")] ok(f"stable/beta 数量 = {len(stable)}(≥50)", len(stable) >= 50) no_target, file_no_gate, npx_no_flag, npx_no_probe, bad_cfg = [], [], [], [], [] - for l in stable: - lint = l.get("lint") or [] + for entry in stable: + lint = entry.get("lint") or [] cs = " ".join(lint) if not lint: # format-only 语言(julia/pascal):无独立 linter,合法 - if not l.get("format"): - no_target.append(l["id"]) + if not entry.get("format"): + no_target.append(entry["id"]) continue # 目标参数:{file} 占位 / 明确路径 / 项目级命令词(裸跑默认检查当前目录) project_level = ( @@ -102,17 +102,17 @@ def test_languages(): or any(t in cs for t in (".", "*", "{file}", "src")) ) if not project_level: - no_target.append(f"{l['id']}:{cs}") - if "{file}" in cs and not l.get("gate"): - file_no_gate.append(l["id"]) + no_target.append(f"{entry['id']}:{cs}") + if "{file}" in cs and not entry.get("gate"): + file_no_gate.append(entry["id"]) if lint[0] == "npx": if "--no-install" not in cs: - npx_no_flag.append(l["id"]) - if not l.get("probe"): - npx_no_probe.append(l["id"]) - cfg = l.get("requiresConfig") + npx_no_flag.append(entry["id"]) + if not entry.get("probe"): + npx_no_probe.append(entry["id"]) + cfg = entry.get("requiresConfig") if cfg is not None and (not isinstance(cfg, list) or not all(isinstance(x, str) and x for x in cfg)): - bad_cfg.append(l["id"]) + bad_cfg.append(entry["id"]) ok("所有语言 lint 都有检查目标", not no_target, str(no_target)) ok("{file} 单文件模式语言都有项目级 gate", not file_no_gate, str(file_no_gate)) ok("npx 系全部 --no-install", not npx_no_flag, str(npx_no_flag)) @@ -120,7 +120,7 @@ def test_languages(): ok("requiresConfig 结构合法", not bad_cfg, str(bad_cfg)) # 关键语言的修正是否落地 - by_id = {l["id"]: l for l in stable} + by_id = {entry["id"]: entry for entry in stable} for lid, field, needle in [ ("nix", "lint", "{file}"), ("groovy", "lint", "{file}"), ("cfml", "lint", "{file}"), ("html", "lint", "{file}"), ("markdown", "lint", "--no-install"), @@ -143,6 +143,12 @@ def test_languages(): # runtime 抽查:已装工具的语言真跑一条坏样例 if shutil.which("shellcheck"): repo = make_repo() + # delta 门禁只看本次改动:把坏脚本重新暂存成"待提交内容", + # 存量已提交的坏文件不该再拦新提交(这正是 delta 的意义)。 + (repo / "scripts" / "deploy.sh").write_text( + '#!/bin/bash\nif [ $foo = bar ]; then echo hi; fi\nif [ $a = b ]; then echo x; fi\n' + ) + git(repo, "add", "-A") r = run_hook("pre_tool_git_guard.py", {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, repo) ok("shell 门禁真跑:坏脚本 → exit 2", r.returncode == 2, f"exit={r.returncode}") @@ -169,7 +175,7 @@ def test_hooks(): {"tool_name": "Write", "tool_input": {"file_path": str(repo / "scripts" / "deploy.sh")}}, repo) ok("PostToolUse 坏文件 exit 0(不阻断)", r.returncode == 0) try: - ctx = json.loads([l for l in r.stdout.splitlines() if l.startswith("{")][-1]) + ctx = json.loads([ln for ln in r.stdout.splitlines() if ln.startswith("{")][-1]) add = ctx["hookSpecificOutput"]["additionalContext"] first = add.splitlines()[0] body = add[len(first):] @@ -191,10 +197,14 @@ def test_hooks(): ok("好文件注入通过确认", "✅" in r.stdout and "passed" in r.stdout) # ── UserPromptSubmit:提交意图(脏仓)→ 软引导注入(exit 0,prompt 不被弹回) ── + # delta 门禁只查 staged/未暂存/未跟踪:显式造一个待提交的坏改动, + # 与旧版"存量坏文件即脏"的效果对齐但语义是"新提交引入的问题"。 + (repo / "scripts" / "delta-bad.sh").write_text('#!/bin/bash\nif [ $q = w ]; then true; fi\n') + git(repo, "add", "-A") r = run_hook("user_prompt_validator.py", {"user_prompt": "提交代码"}, repo) ok("提交意图 exit 0(软引导)", r.returncode == 0) try: - ctx = json.loads([l for l in r.stdout.splitlines() if l.startswith("{")][-1]) + ctx = json.loads([ln for ln in r.stdout.splitlines() if ln.startswith("{")][-1]) add = ctx["hookSpecificOutput"]["additionalContext"] first = add.splitlines()[0] ok("UPS 注入首行=综述", first.startswith("codeguard ❌ 提交门禁未通过:")) @@ -219,8 +229,11 @@ def test_hooks(): # ── PreToolUse:干净仓 → 完全静默 ── clean = Path(tempfile.mkdtemp(prefix="cg-clean-")) - git(clean, "init", "-q"); git(clean, "config", "user.email", "t@t"); git(clean, "config", "user.name", "t") - (clean / "README.md").write_text("ok\n"); git(clean, "add", "-A") + git(clean, "init", "-q") + git(clean, "config", "user.email", "t@t") + git(clean, "config", "user.name", "t") + (clean / "README.md").write_text("ok\n") + git(clean, "add", "-A") r = run_hook("pre_tool_git_guard.py", {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, clean) ok("干净仓 exit 0 且零输出(通过即静默)", r.returncode == 0 and r.stdout == "" and r.stderr == "", @@ -250,7 +263,8 @@ def test_hooks(): r = run_hook("stop_summary.py", None, repo) ok("Stop 汇总 exit 0", r.returncode == 0) - shutil.rmtree(repo); shutil.rmtree(clean) + shutil.rmtree(repo) + shutil.rmtree(clean) # ══════════════════════════ 子集 3:纯函数单测 ══════════════════════════ @@ -404,7 +418,9 @@ def test_edges(): # ── zig 运行时真跑:坏格式文件 → 门禁拦截 ── if _sh.which("zig"): zrepo = Path(tempfile.mkdtemp(prefix="cg-zig-")) - git(zrepo, "init", "-q"); git(zrepo, "config", "user.email", "t@t"); git(zrepo, "config", "user.name", "t") + git(zrepo, "init", "-q") + git(zrepo, "config", "user.email", "t@t") + git(zrepo, "config", "user.name", "t") (zrepo / "build.zig.zon").write_text(".\n.id = \"x\",\n\n") # 错误语法 (zrepo / "bad.zig").write_text("const x=1;const y :i32=2;\n") git(zrepo, "add", "-A") @@ -493,8 +509,10 @@ def test_field_regressions(): # ── 5. 真 warning 级问题仍拦截(severity 收紧不放走真问题) ── sev2 = make_repo() + # 待提交内容必须真正不同于 HEAD:delta 门禁只查改动,与 HEAD 逐字节 + # 相同的"重新 add"等于本次无改动(拦它没有意义,git 也不会提交任何东西)。 (sev2 / "scripts" / "deploy.sh").write_text( - '#!/bin/bash\nif [ $foo = bar ]; then echo hi; fi\n' # SC2086+SC2154 warning 级 + '#!/bin/bash\nif [ $foo = bar ]; then echo hi; fi\necho $undefined_var\n' # SC2154 warning 级 ) git(sev2, "add", "-A") r = run_hook("pre_tool_git_guard.py", @@ -530,9 +548,9 @@ def test_perf(): repo = make_repo() exec_count = [] orig_uncached = gate_lib._run_gate_uncached - def counting(root, cfg, langs): + def counting(root, cfg, langs, **kw): exec_count.append(1) - return orig_uncached(root, cfg, langs) + return orig_uncached(root, cfg, langs, **kw) gate_lib._run_gate_uncached = counting try: f1, _s1 = gate_lib.run_gate(repo, {}) @@ -742,9 +760,9 @@ def test_doc_sync(): ok("docs/LANGUAGES.md 与注册表完全可复现(双向一致)", True) else: import difflib - diff = [ln for ln in difflib.unified_diff( + diff = list(difflib.unified_diff( actual.splitlines(), expected.splitlines(), - "docs/LANGUAGES.md", "registry-generated", n=0, lineterm="")] + "docs/LANGUAGES.md", "registry-generated", n=0, lineterm="")) ok("docs/LANGUAGES.md 与注册表完全可复现(双向一致)", False, f"{len(diff)} 行差异,首 6 行: {diff[:6]}") @@ -752,14 +770,22 @@ def test_doc_sync(): def main(): which = sys.argv[1] if len(sys.argv) > 1 else "all" print(f"codeguard 测试集 plugin={PLUGIN.name}") - if which in ("all", "langs"): test_languages() - if which in ("all", "hooks"): test_hooks() - if which in ("all", "unit"): test_unit() - if which in ("all", "edges"): test_edges() - if which in ("all", "perf"): test_perf() - if which in ("all", "field"): test_field_regressions() - if which in ("all", "cve"): test_cve() - if which in ("all", "doc"): test_doc_sync() + if which in ("all", "langs"): + test_languages() + if which in ("all", "hooks"): + test_hooks() + if which in ("all", "unit"): + test_unit() + if which in ("all", "edges"): + test_edges() + if which in ("all", "perf"): + test_perf() + if which in ("all", "field"): + test_field_regressions() + if which in ("all", "cve"): + test_cve() + if which in ("all", "doc"): + test_doc_sync() print(f"\n═══ 结果: {len(PASS)} 通过 / {len(FAIL)} 失败 / {len(SKIP)} 跳过 ═══") if FAIL: print("失败项:", *FAIL, sep="\n - ") diff --git a/tests/test_hardening_fixes.py b/tests/test_hardening_fixes.py new file mode 100644 index 0000000..3c829a3 --- /dev/null +++ b/tests/test_hardening_fixes.py @@ -0,0 +1,336 @@ +"""硬化修复回归测试(2026-09-22 批次):覆盖 14 项审计发现中可离线锁定的行为。 + +对应 OpenSpec change 2026-09-22-harden-commit-gate(hook-protocol / +gate-trigger-policy / language-gate-commands delta)。每个用例锚定一个实测踩过的坑: +整调用拦截语义、脚本间接绕过、缓存键陈旧、delta 作用域、非 git 工作区回退、 +skipGate 双门一致性、全仓 format 漂移、exit 2 误判、双副本重复、bypass 记账。 +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +PLUGIN = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PLUGIN / "scripts")) +sys.path.insert(0, str(PLUGIN / "hooks")) + +import gate_lib # noqa: E402 +import pre_tool_git_guard as guard # noqa: E402 +import scope # noqa: E402 + + +def _git(root: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=False) + + +def _fresh_repo() -> Path: + root = Path(tempfile.mkdtemp(prefix="cg-hard-")) + _git(root, "init", "-q") + _git(root, "config", "user.email", "t@t") + _git(root, "config", "user.name", "t") + return root + + +def _run_hook(script: str, payload: dict | None, cwd: Path, + env_extra: dict | None = None) -> subprocess.CompletedProcess: + env = dict(os.environ) + env.update(env_extra or {}) + return subprocess.run( + [sys.executable, str(PLUGIN / "hooks" / script)], + input=json.dumps(payload) if payload is not None else "", + capture_output=True, text=True, cwd=cwd, env=env, timeout=120, + ) + + +class IndirectGuardTests(unittest.TestCase): + """P0-2:脚本间接执行曾完整绕过硬门禁(实测 /tmp runner 连推 4 次漏网)。""" + + def test_direct_git_still_guarded(self) -> None: + self.assertTrue(guard.is_guarded("git commit -m x")) + self.assertTrue(guard.is_guarded("cd r && git push origin main")) + + def test_echo_with_git_text_is_not_guarded(self) -> None: + self.assertFalse(guard.is_guarded('echo "git push 是危险命令"')) + self.assertFalse(guard.is_guarded("git status && ls")) + + def test_script_file_indirection_is_guarded(self) -> None: + script = Path(tempfile.mkdtemp(prefix="cg-ind-")) / "runner.sh" + script.write_text('set -e\ncd /tmp\ngit commit -m "from script"\n', encoding="utf-8") + self.assertTrue(guard.is_guarded(f"bash {script}")) + + def test_inline_dash_c_is_guarded(self) -> None: + self.assertTrue(guard.is_guarded('bash -c "git commit -m inline"')) + + def test_harmless_script_is_not_guarded(self) -> None: + script = Path(tempfile.mkdtemp(prefix="cg-ind-")) / "harmless.sh" + script.write_text("echo hi\n", encoding="utf-8") + self.assertFalse(guard.is_guarded(f"bash {script}")) + + def test_documented_boundary_subprocess_construction(self) -> None: + """静态扫描的能力边界(docstring 已声明):拼接式 subprocess 不在承诺内。""" + script = Path(tempfile.mkdtemp(prefix="cg-ind-")) / "crafted.py" + script.write_text('import subprocess\nsubprocess.run(["git", "push"])\n', encoding="utf-8") + self.assertFalse(guard.is_guarded(f"python3 {script}")) + # 但直接命令词形态仍然命中 + script.write_text("git push origin main\n", encoding="utf-8") + self.assertTrue(guard.is_guarded(f"python3 {script}")) + + +class WholeCallNoticeTests(unittest.TestCase): + """P0-1:整调用被拒时必须声明"前序步骤也没执行"(实测编辑被吞假象 ×3)。""" + + def test_directive_mentions_whole_call(self) -> None: + text = gate_lib.gate_directive([("shell", "SC2086: x", "shfmt -w .", "brew")]) + self.assertIn("整个工具调用没有执行", text) + self.assertIn("拆成两次独立的工具调用", text) + # 首行契约保持:综述在最前 + self.assertTrue(text.splitlines()[0].startswith("codeguard ❌ 提交门禁未通过:")) + + +class CacheKeyTests(unittest.TestCase): + """P0-3:修完未暂存的文件必须让缓存键变化(retry-timing 陷阱)。""" + + def setUp(self) -> None: + self.repo = _fresh_repo() + (self.repo / "a.py").write_text("x = 1\n", encoding="utf-8") + _git(self.repo, "add", "-A") + _git(self.repo, "commit", "-q", "-m", "i") + + def test_tracked_unstaged_edit_changes_key(self) -> None: + k1 = gate_lib._gate_cache_key(self.repo, ["python"]) + (self.repo / "a.py").write_text("x = 2\n", encoding="utf-8") + k2 = gate_lib._gate_cache_key(self.repo, ["python"]) + self.assertNotEqual(k1, k2) + + def test_untracked_content_edit_changes_key(self) -> None: + (self.repo / "u.py").write_text("x = 1\n", encoding="utf-8") + k1 = gate_lib._gate_cache_key(self.repo, ["python"]) + (self.repo / "u.py").write_text("x = 2\n", encoding="utf-8") + k2 = gate_lib._gate_cache_key(self.repo, ["python"]) + self.assertNotEqual(k1, k2) + + +class DeltaScopeTests(unittest.TestCase): + """P0-4:delta 默认作用域 + changed_files 三路并集。""" + + def test_changed_files_covers_three_lanes(self) -> None: + repo = _fresh_repo() + (repo / "base.py").write_text("x = 1\n", encoding="utf-8") + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "i") + self.assertEqual(scope.changed_files(repo), []) + (repo / "base.py").write_text("x = 2\n", encoding="utf-8") # 未暂存 + (repo / "staged.py").write_text("y = 1\n", encoding="utf-8") # staged + _git(repo, "add", "staged.py") + (repo / "fresh.py").write_text("z = 1\n", encoding="utf-8") # 未跟踪 + got = scope.changed_files(repo) + self.assertEqual(got, ["base.py", "fresh.py", "staged.py"]) + + def test_non_git_returns_none(self) -> None: + self.assertIsNone(scope.changed_files(Path(tempfile.mkdtemp(prefix="cg-nogit-")))) + + def test_scope_default_is_delta_for_git_repo(self) -> None: + repo = _fresh_repo() + self.assertEqual(scope.changed_files(repo), []) + (repo / "bad.py").write_text("import os,sys\n", encoding="utf-8") + # 未跟踪坏文件存在 → delta 会把它纳入检查(此处只验证并集非空) + self.assertEqual(scope.changed_files(repo), ["bad.py"]) + + +class ScopeCmdTests(unittest.TestCase): + """P1-6/P1-8:命令物化——ruff 默认配置注入、扫描 token 收敛、裸命令追加。""" + + def test_dot_token_replaced_by_files(self) -> None: + cmd = scope.scope_cmd(["ruff", "check", "."], "/tmp/no-such-proj", files=["a.py", "b.py"]) + self.assertIn("a.py", cmd) + self.assertIn("b.py", cmd) + self.assertNotIn(".", [tok for tok in cmd if tok == "."]) + + def test_ruff_config_injected_only_without_own_config(self) -> None: + bare = Path(tempfile.mkdtemp(prefix="cg-cfg-")) + cmd = scope.scope_cmd(["ruff", "check", "."], bare, files=["a.py"]) + self.assertIn("--config", cmd) + self.assertTrue(Path(cmd[cmd.index("--config") + 1]).is_file()) + (bare / "ruff.toml").write_text('line-length = 88\n', encoding="utf-8") + cmd2 = scope.scope_cmd(["ruff", "check", "."], bare, files=["a.py"]) + self.assertNotIn("--config", cmd2) + + def test_glob_token_and_excludes_dropped_in_delta(self) -> None: + cmd = scope.scope_cmd( + ["npx", "--no-install", "markdownlint-cli2", "**/*.md", "#node_modules"], + "/tmp/x", files=["R.md"], + ) + self.assertIn("R.md", cmd) + self.assertNotIn("**/*.md", cmd) + self.assertNotIn("#node_modules", cmd) + + def test_bare_command_appends_files(self) -> None: + cmd = scope.scope_cmd(["yamllint", "."], "/tmp/x", files=["a.yaml"]) + self.assertEqual(cmd, ["yamllint", "a.yaml"]) + + def test_file_placeholder_single_file(self) -> None: + cmd = scope.scope_cmd(["shellcheck", "{file}"], "/tmp/x", single_file="s.sh") + self.assertEqual(cmd, ["shellcheck", "s.sh"]) + + def test_full_mode_adds_ruff_excludes(self) -> None: + cmd = scope.scope_cmd(["ruff", "check", "."], "/tmp/no-such-proj", full_excludes=True) + self.assertIn("--exclude", cmd) + self.assertIn("vendor", cmd) + + +class ExitTwoTests(unittest.TestCase): + """P1-9:exit 2 = 工具链异常,不是 lint 结论——归未验证,绝不拦提交。""" + + def test_gate_rc2_is_skipped_not_failed(self) -> None: + repo = _fresh_repo() + (repo / "a.py").write_text("x = 1\n", encoding="utf-8") + _git(repo, "add", "-A") + + class _Fake: + returncode = 2 + stdout = "npm warn junk\n" + stderr = "Error: cannot find module\n" + + original_run = gate_lib.subprocess.run + original_probe = gate_lib.probe_toolchain + original_uses = gate_lib.project_uses_linter + gate_lib.subprocess.run = lambda *a, **k: _Fake() + gate_lib.probe_toolchain = lambda *a, **k: (True, "") + gate_lib.project_uses_linter = lambda *a, **k: True + try: + failures, skipped = gate_lib._run_gate_uncached( + repo, {"lint_timeout_seconds": 5}, ["python"], scope="repo", changed=None, + ) + finally: + gate_lib.subprocess.run = original_run + gate_lib.probe_toolchain = original_probe + gate_lib.project_uses_linter = original_uses + self.assertEqual(failures, []) + self.assertTrue(any("工具链异常未验证" in s and "exit 2" in s for s in skipped), skipped) + + +class TriggerNegationTests(unittest.TestCase): + """P1-10:否定语境(未提交/别提交)不得触发;祈使与行动意图仍触发。""" + + @classmethod + def setUpClass(cls) -> None: + import importlib.util + spec = importlib.util.spec_from_file_location( + "upv", PLUGIN / "hooks" / "user_prompt_validator.py" + ) + cls.upv = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.upv) + + def test_negated_statements_do_not_trigger(self) -> None: + for text in ("未提交任何东西", "还没提交呢", "别提交这些文件"): + with self.subTest(text=text): + self.assertFalse(self.upv.is_trigger(text)) + + def test_imperatives_still_trigger(self) -> None: + for text in ("提交代码", "commit this now", "请帮我 push"): + with self.subTest(text=text): + self.assertTrue(self.upv.is_trigger(text)) + + +class UserPromptSubmitScopeTests(unittest.TestCase): + """P0-5/P2-12:非 git 目录显式跳过(不回退扫描);skipGate 双门一致;bypass 记账。""" + + def setUp(self) -> None: + self.home = Path(tempfile.mkdtemp(prefix="cg-home-")) + self.env = {"CODEGUARD_HOME": str(self.home)} + + def test_non_git_dir_is_skipped_with_note(self) -> None: + bare = Path(tempfile.mkdtemp(prefix="cg-nogit-")) + r = _run_hook( + "user_prompt_validator.py", {"user_prompt": "提交代码"}, bare, + env_extra=self.env, + ) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("不是 git 仓库", r.stdout) + self.assertNotIn("强制指令", r.stdout) + + def test_skipgate_suppresses_soft_gate_too(self) -> None: + repo = _fresh_repo() + (repo / "bad.py").write_text("import os,sys\n", encoding="utf-8") + _git(repo, "add", "-A") + _git(repo, "config", "codeguard.skipGate", "true") + r = _run_hook( + "user_prompt_validator.py", {"user_prompt": "提交代码"}, repo, + env_extra=self.env, + ) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(r.stdout.strip(), "", "skipGate 时软门禁必须静默") + state = json.loads((self.home / "session_state.json").read_text(encoding="utf-8")) + self.assertEqual(state["_skip"]["kinds"].get("skipGate"), 1) + _git(repo, "config", "--unset", "codeguard.skipGate") + + def test_env_bypass_is_counted(self) -> None: + repo = _fresh_repo() + r = _run_hook( + "user_prompt_validator.py", {"user_prompt": "提交代码"}, repo, + env_extra={**self.env, "CODEGUARD_SKIP_GATE": "1"}, + ) + self.assertEqual(r.returncode, 0) + state = json.loads((self.home / "session_state.json").read_text(encoding="utf-8")) + self.assertEqual(state["_skip"]["kinds"].get("env"), 1) + + +class StopSummaryTests(unittest.TestCase): + """P2-12/P2-13:状态目录迁移 + 绕过可见 + skipGate 遗留提醒。""" + + def setUp(self) -> None: + self.home = Path(tempfile.mkdtemp(prefix="cg-home-")) + self.env = {"CODEGUARD_HOME": str(self.home)} + + def test_skip_count_rendered(self) -> None: + gate_lib.session_state_path # noqa: B018 — 确保符号存在 + os.environ["CODEGUARD_HOME"] = str(self.home) + try: + gate_lib.record_skip_event("skipGate") + finally: + os.environ.pop("CODEGUARD_HOME", None) + repo = _fresh_repo() + r = _run_hook("stop_summary.py", None, repo, env_extra=self.env) + self.assertIn("绕过门禁 1 次", r.stdout) + + def test_left_enabled_skipgate_is_flagged(self) -> None: + repo = _fresh_repo() + _git(repo, "config", "codeguard.skipGate", "true") + r = _run_hook("stop_summary.py", None, repo, env_extra=self.env) + self.assertIn("仍为 true", r.stdout) + _git(repo, "config", "--unset", "codeguard.skipGate") + + def test_state_path_honors_home_override(self) -> None: + os.environ["CODEGUARD_HOME"] = str(self.home) + try: + self.assertEqual(gate_lib.session_state_path(), self.home / "session_state.json") + finally: + os.environ.pop("CODEGUARD_HOME", None) + + +class EventDedupTests(unittest.TestCase): + """P2-11:同 key 窗口内二次触发被抑制(双副本场景),且 key 空间互不干扰。""" + + def setUp(self) -> None: + self.home = Path(tempfile.mkdtemp(prefix="cg-home-")) + os.environ["CODEGUARD_HOME"] = str(self.home) + + def tearDown(self) -> None: + os.environ.pop("CODEGUARD_HOME", None) + + def test_same_key_within_window_suppressed(self) -> None: + self.assertFalse(gate_lib.should_suppress_event("pre:abc123")) + self.assertTrue(gate_lib.should_suppress_event("pre:abc123")) + + def test_different_key_not_suppressed(self) -> None: + gate_lib.should_suppress_event("pre:one") + self.assertFalse(gate_lib.should_suppress_event("pre:two")) + + +if __name__ == "__main__": + unittest.main()