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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
]
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .zcode-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ AI code that passes lint on first try
|---|---|
| Plugin ID | `partme-codeguard-plugin` |
| Hosts | ZCode, Claude Code, Codex CLI, Kimi Code |
| Current version | `0.8.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 |
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ AI 一次写出就过 lint 的代码
|---|---|
| 插件 ID | `partme-codeguard-plugin` |
| 宿主 | ZCode、Claude Code、Codex CLI、Kimi Code |
| 当前版本 | `0.8.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`);见快速开始 |
Expand Down
2 changes: 1 addition & 1 deletion hooks/__protocol__.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 不在静态扫描承诺内);并且
Expand Down
29 changes: 25 additions & 4 deletions hooks/gate_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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}/ 属于依赖/产物/本地环境,不应入库",
Expand All @@ -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}(密钥/凭据/本地配置类)",
Expand Down Expand Up @@ -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"
Expand Down
67 changes: 61 additions & 6 deletions hooks/pre_tool_git_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("\"'")
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 12 additions & 4 deletions hooks/user_prompt_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion kimi.plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
103 changes: 103 additions & 0 deletions tests/test_hardening_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading