diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 9ca734b..5bfdb2b 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.8.1" + "ref": "v0.8.2" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.8.1", + "version": "0.8.2", "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.8.1/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.8.2/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.8.1/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.8.2/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index a80fc88..2aae0e9 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.8.1+codex.20260922", + "version": "0.8.2+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 4dbfd3b..060889c 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.8.1", + "version": "0.8.2", "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 f71a13f..3cb9525 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.8.1` | +| Current version | `0.8.2` | | 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 1498291..e966886 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.8.1` | +| 当前版本 | `0.8.2` | | 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/hooks/__protocol__.md b/hooks/__protocol__.md index 4990008..ba4b8de 100644 --- a/hooks/__protocol__.md +++ b/hooks/__protocol__.md @@ -66,7 +66,7 @@ if __name__ == "__main__": ## 4. PreToolUse 硬拦截(exit 2)的语义边界 `pre_tool_git_guard.py` 是**唯一**会 exit 2 的 hook——且仅当: -1. 入参命令命中 `is_guarded()`:**直接**(分隔符切段后段首为 `git commit|push`; +1. 入参命令命中 `is_guarded()`:**直接**(分隔符切段、剥除段首 `NAME=VAL`/`env`/常见裸 wrapper 前缀并跳过 git 全局选项(`-C`/`-c`/…)后子命令为 `git commit|push`; 子串匹配会误伤 payload/echo 文本)**或一层解释器间接**(`bash|sh|python… <脚本>` 的脚本文本、`-c` 内联代码按同规则扫描——`bash runner.sh` 式绕过曾连推 4 次漏网; 拼接式 subprocess 不在静态扫描承诺内);并且 diff --git a/hooks/gate_lib.py b/hooks/gate_lib.py index 829d95f..0ddbfe4 100644 --- a/hooks/gate_lib.py +++ b/hooks/gate_lib.py @@ -36,13 +36,17 @@ ".nuxt", ".gradle", "vendor", ".idea", ".vscode", "coverage", ".terraform", ".tox", ".eggs", "htmlcov", ".turbo", ".parcel-cache", } -# 文件名模式(fnmatch):密钥/凭据/本地环境/数据库/系统垃圾 +# 文件名模式(fnmatch,任意层级):密钥/凭据/本地环境/系统垃圾 GUARD_EXCLUDE_FILES = [ ".env", ".env.*", "*.env", "*.pem", "*.key", "*.p12", "*.pfx", "*.jks", "*.keystore", "id_rsa", "id_ed25519", "id_ecdsa", "*.pem.orig", "credentials*.json", "serviceAccount*.json", "*service-account*.json", - "*.sqlite", "*.sqlite3", "*.db", ".DS_Store", "Thumbs.db", "*.log", "*.pyc", + ".DS_Store", "Thumbs.db", "*.pyc", ] +# 仅**仓根级**命中的模式:数据库/日志常作第一方 fixture 合法入库 +# (tests/fixtures/sample.db、docs 里的归档 log),只拦散落在根目录的 +# 数据转储与调试日志——此前与密钥同池任意层级匹配,误伤合法入库(实测)。 +GUARD_ROOT_ONLY_FILES = ["*.sqlite", "*.sqlite3", "*.db", "*.log"] def _git(project_root: Path, *args: str) -> str | None: @@ -78,7 +82,17 @@ def check_commit_safety(project_root: Path, mode: str) -> list[tuple[str, str, s if not f: continue parts = f.split("/") - hit_dir = next((seg for seg in parts[:-1] if seg in GUARD_EXCLUDE_DIRS), None) + hit_dir = None + for idx, seg in enumerate(parts[:-1]): + if seg not in GUARD_EXCLUDE_DIRS: + continue + # vendor 仅**仓根级**=依赖快照;嵌套(scripts/vendor、tools/vendor) + # 是第一方源码树——裸段匹配曾把 skill_vendor.py 判成"移出版本库" + # (实测误伤,且打断依赖它的 skills-check CI)。 + if seg == "vendor" and idx != 0: + continue + hit_dir = seg + break if hit_dir: violations.append(( f, f"目录 ./{hit_dir}/ 属于依赖/产物/本地环境,不应入库", @@ -87,6 +101,11 @@ def check_commit_safety(project_root: Path, mode: str) -> list[tuple[str, str, s continue name = parts[-1] rule = next((pat for pat in GUARD_EXCLUDE_FILES if fnmatch.fnmatch(name, pat)), None) + if rule is None and len(parts) == 1: + # 仓根级 db/log/sqlite 单独一池(见 GUARD_ROOT_ONLY_FILES 注释) + rule = next( + (pat for pat in GUARD_ROOT_ONLY_FILES if fnmatch.fnmatch(name, pat)), None + ) if rule: violations.append(( f, f"文件命中敏感模式 {rule}(密钥/凭据/本地配置类)", @@ -466,7 +485,9 @@ def gate_directive(failures: list) -> str: + "\n\n" + "**给 AI 的强制指令**:提交门禁未通过,禁止执行 git commit / git push。\n" + "请立即处理:1) 按上面「怎么修」逐项修复(能自动修复的先跑自动修复命令);" - + "2) 修复过程中无需向用户确认;3) 全部修复完成后重新执行用户要做的提交操作。\n" + + "2) 纯 lint 类修复可直接继续、不必逐项追问;但凡涉及付费、发布、删除、" + + "密钥、或跨出本仓的操作,必须先征得用户同意再执行;" + + "3) 修复完成后重新执行用户要做的提交操作。\n" + "确需绕过(仅用户明确要求时):在该仓库执行 git config codeguard.skipGate true," + "完成后 git config --unset codeguard.skipGate 恢复。环境变量 CODEGUARD_SKIP_GATE " + "只对手动直调 run_check 有效(无法传入宿主钩子进程)。\n\n" diff --git a/hooks/pre_tool_git_guard.py b/hooks/pre_tool_git_guard.py index 682ef09..8fcee4f 100755 --- a/hooks/pre_tool_git_guard.py +++ b/hooks/pre_tool_git_guard.py @@ -94,12 +94,65 @@ def resolve_project_roots(command: str) -> list[Path]: return roots +_ASSIGN_RE = None # 延迟编译,保持模块零顶层 re 依赖 +_ASSIGN_PATTERN = r"^[A-Za-z_][A-Za-z0-9_.-]*=" +_BARE_WRAPPERS = ("env", "sudo", "nohup", "command", "time", "nice", "ionice", "setsid", "stdbuf") +_GIT_VALUE_FLAGS = ("-C", "-c", "--git-dir", "--work-tree", "--exec-path", + "--namespace", "--config-env", "--attr-source") +_GIT_BOOL_FLAGS = ("--no-pager", "--bare", "--literal-pathspecs", + "--no-optional-locks", "--shallow-file", "--exec-path") + + +def _normalize_segment(seg: str) -> list[str]: + """剥掉段首的环境赋值/env/裸 wrapper 前缀与 git 全局选项,暴露子命令。 + + 漏检实例(实测静默放行):`VAR=1 git push` 段首是 `VAR=1`;`git -C path push` + tokens[1] 是 `-C`;`sudo git push` 段首是 `sudo`。剥完后若段首仍非 git 则 + 返回原 tokens(由调用方判定为非 git 段)。 + 能力边界(docstring 如实声明):带值的 wrapper 参数(`sudo -u root …`) + 不做完整解析,只剥裸 wrapper token。 + """ + import re as _re + tokens = seg.strip().split() + i, n = 0, len(tokens) + while True: + while i < n and _re.match(_ASSIGN_PATTERN, tokens[i]): + i += 1 + if i < n and tokens[i] == "env": + i += 1 + continue + break + while i < n and tokens[i] in _BARE_WRAPPERS: + i += 1 + if i >= n: + return tokens + if tokens[i].strip("\"'") != "git": + return tokens[i:] + i += 1 + while i < n: + tok = tokens[i].strip("\"'") + if tok in _GIT_BOOL_FLAGS: + i += 1 + continue + if tok in _GIT_VALUE_FLAGS: + i += 2 + continue + if tok.startswith(("-C", "-c", "--git-dir", "--work-tree", "--exec-path")) and len(tok) > 2: + i += 1 + continue + break + # 保留 "git" 作为首 token(调用方按 [prog, sub, …] 形状判定),只剥掉前面的 + # 前缀与后面的全局选项;`git` 裸段(无子命令)返回 ["git"] → len<2 → None。 + return ["git"] + tokens[i:] + + def _git_side_effect_sub(seg: str) -> str | None: - """命令段段首为 git commit|push 时返回子命令(commit/push),否则 None。 + """归一化后,段的子命令为 commit|push 则返回之,否则 None。 - 首 token 剥一层包裹引号(`bash -c "git commit …"` 切段后首词是 `"git`)。 + 首 token 剥一层包裹引号(`bash -c "git commit …"` 切段后首词是 `"git`); + 前缀与 git 全局选项见 `_normalize_segment`。 """ - tokens = seg.strip().split() + tokens = _normalize_segment(seg) if len(tokens) < 2: return None first = tokens[0].strip("\"'") @@ -258,9 +311,11 @@ def main() -> int: violations = check_commit_safety(project_root, mode) if violations: reports.append(format_safety_report(violations) + ( - "\n\n**给 AI 的强制指令**:先把上述文件移出版本库" - "(git rm --cached + .gitignore),然后重新执行本次 git 命令;" - "涉及密钥/凭据的必须提醒用户轮换,不能只删了事。" + "\n\n**给 AI 的强制指令**:**密钥/凭据类**(.env、*.pem、id_* 等)" + "立即 git rm --cached 并提醒用户轮换密钥,不能只删了事;" + "**非密钥类**(依赖/产物目录、仓根 db/log 等)**先与用户确认**" + "是否为有意入库的第一方代码或合法 fixture——确认属误入库才执行" + "git rm --cached + .gitignore,确认后重新执行本次 git 命令。" )) if not reports: diff --git a/hooks/user_prompt_validator.py b/hooks/user_prompt_validator.py index 591f336..b4c36c3 100755 --- a/hooks/user_prompt_validator.py +++ b/hooks/user_prompt_validator.py @@ -187,15 +187,23 @@ def main() -> int: violations = check_commit_safety(project_root, mode) if failures or violations: - # 软引导:prompt 正常送达 AI,同时注入修复指令,AI 自动修复后重新提交 + # 软引导:prompt 正常送达 AI,且注入修复指令,AI 自动修复后重新提交。 + # 注意:可能"只有安全违规、零 lint 失败"——此前 notify 直接取 + # failures[0][0] 越界,fail-open 吞成空输出(实测复现),两路都要兜。 _first_issue = failures[0][1].splitlines()[0][:120] if failures and failures[0][1] else "详见对话" - notify(summarize_failures(failures), f"{failures[0][0]}: {_first_issue}") + _headline = ( + f"{failures[0][0]}: {_first_issue}" if failures + else (f"提交安全: {violations[0][0]}" if violations else "提交门禁发现违规") + ) + notify(summarize_failures(failures), _headline) parts = [gate_directive(failures)] if violations: parts.append(format_safety_report(violations)) parts.append( - "**给 AI 的强制指令**:安全违规文件必须 git rm --cached + 补 .gitignore," - "涉及密钥/凭据的提醒用户轮换;全部修复后重新执行提交,无需向用户确认。" + "**给 AI 的强制指令**:**密钥/凭据类**必须 git rm --cached 并提醒用户" + "轮换密钥;**非密钥类**先与用户确认是否为有意入库的第一方代码或合法" + " fixture,确认误入库再 git rm --cached + 补 .gitignore;确认并修复后" + "重新执行提交。" ) print(json.dumps({ "hookSpecificOutput": { diff --git a/kimi.plugin.json b/kimi.plugin.json index c2e2199..5577008 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.8.1", + "version": "0.8.2", "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/tests/test_hardening_fixes.py b/tests/test_hardening_fixes.py index b57811d..4c61274 100644 --- a/tests/test_hardening_fixes.py +++ b/tests/test_hardening_fixes.py @@ -511,3 +511,106 @@ def test_different_sessions_not_deduped(self) -> None: b = _run_hook("stop_summary.py", {"session_id": "s-b"}, repo, env_extra=self.env) self.assertTrue(a.stdout.strip()) self.assertTrue(b.stdout.strip()) + + +# ══════════ 2026-09-22 round-2 handoff batch(env前缀/vendor范围/db-log/指令措辞)══════════ + +class EnvPrefixGuardTests(unittest.TestCase): + """转交项①:`VAR=1 git push` / `git -C path push` / wrapper 前缀曾静默放行。""" + + def test_prefixed_and_flagged_forms_are_guarded(self) -> None: + cases = [ + ("FOO=1 git push", "push"), + ("env FOO=1 git push", "push"), + ("git -C /repo push", "push"), + ("git -c user.name=x commit -m y", "commit"), + ("sudo git push", "push"), + ('bash -c "git commit -m x"', "commit"), + ("VAR=1 git add -A && git commit -m x", "commit"), + ("nohup git commit -m x", "commit"), + ] + for cmd, want in cases: + with self.subTest(cmd=cmd): + self.assertEqual(guard._guarded_mode(cmd), want) + + def test_non_git_after_normalization_stays_unguarded(self) -> None: + for cmd in ("VAR=1 echo git push", "echo git push", "git status", "VAR=1 ls", "git"): + with self.subTest(cmd=cmd): + self.assertIsNone(guard._guarded_mode(cmd)) + + def test_is_guarded_uses_the_same_normalizer(self) -> None: + self.assertTrue(guard.is_guarded("FOO=1 git push")) + self.assertFalse(guard.is_guarded('echo "git push"')) + + +class SafetyScopeTests(unittest.TestCase): + """转交项②④:嵌套 vendor=第一方、仓根 db/log=转储;密钥类仍任意层级。""" + + @classmethod + def setUpClass(cls) -> None: + cls.repo = _fresh_repo() + (cls.repo / "scripts" / "vendor").mkdir(parents=True) + (cls.repo / "scripts" / "vendor" / "tool.py").write_text("x=1\n", encoding="utf-8") + (cls.repo / "tests" / "fixtures").mkdir(parents=True) + (cls.repo / "tests" / "fixtures" / "sample.db").write_bytes(b"SQLite fake") + (cls.repo / "tests" / "fixtures" / "archive.log").write_text("ok\n", encoding="utf-8") + (cls.repo / "vendor").mkdir() + (cls.repo / "vendor" / "dep.py").write_text("y=2\n", encoding="utf-8") + (cls.repo / "junk.db").write_bytes(b"root dump") + (cls.repo / "debug.log").write_text("trace\n", encoding="utf-8") + (cls.repo / "nested").mkdir() + (cls.repo / "nested" / ".env").write_text("SECRET=1\n", encoding="utf-8") + _git(cls.repo, "add", "-A") + + def _paths(self) -> list[str]: + import gate_lib as gl + return [v[0] for v in gl.check_commit_safety(self.repo, "commit")] + + def test_first_party_vendor_and_fixtures_pass(self) -> None: + paths = self._paths() + self.assertNotIn("scripts/vendor/tool.py", paths) + self.assertNotIn("tests/fixtures/sample.db", paths) + self.assertNotIn("tests/fixtures/archive.log", paths) + + def test_root_vendor_and_root_dumps_still_blocked(self) -> None: + paths = self._paths() + self.assertIn("vendor/dep.py", paths) + self.assertIn("junk.db", paths) + self.assertIn("debug.log", paths) + + def test_secrets_still_blocked_at_any_depth(self) -> None: + self.assertIn("nested/.env", self._paths()) + + +class DirectiveWordingTests(unittest.TestCase): + """转交项③:指令不再无条件"无需确认";非密钥类命中先确认第一方身份。""" + + def test_gate_directive_requires_consent_for_sensitive_ops(self) -> None: + text = gate_lib.gate_directive([("shell", "SC1", "f", "h")]) + self.assertIn("先征得用户同意", text) + self.assertIn("纯 lint 类修复", text) + self.assertNotIn("无需向用户确认", text) + + def test_guard_safety_paragraph_splits_secret_vs_structural(self) -> None: + repo = _fresh_repo() + (repo / "junk.db").write_bytes(b"root dump") + _git(repo, "add", "-A") + r = _run_hook( + "pre_tool_git_guard.py", + {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, + repo, env_extra={"CODEGUARD_HOME": str(Path(tempfile.mkdtemp(prefix="cg-home-")))}, + ) + self.assertEqual(r.returncode, 2, r.stderr[:300]) + self.assertIn("先与用户确认", r.stderr) + self.assertIn("第一方", r.stderr) + + def test_ups_safety_paragraph_requires_confirmation(self) -> None: + repo = _fresh_repo() + (repo / "junk.db").write_bytes(b"root dump") + _git(repo, "add", "-A") + r = _run_hook( + "user_prompt_validator.py", {"user_prompt": "提交代码"}, repo, + env_extra={"CODEGUARD_HOME": str(Path(tempfile.mkdtemp(prefix="cg-home-")))}, + ) + self.assertIn("先与用户确认", r.stdout) + self.assertNotIn("无需向用户确认", r.stdout)