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.0"
"ref": "v0.8.1"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_USE"
},
"category": "Developer Tools",
"version": "0.8.0",
"version": "0.8.1",
"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.0/assets/official-logo.png",
"icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.8.1/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.0/assets/official-logo.png"
"logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.8.1/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.0+codex.20260922",
"version": "0.8.1+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.0",
"version": "0.8.1",
"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
30 changes: 28 additions & 2 deletions 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.0` |
| Current version | `0.8.1` |
| ZCode manifest | `.zcode-plugin/plugin.json` |
| Codex manifest | `.codex-plugin/plugin.json` |
| MCP server | Published: stdio server via the official SDK (`check_code_style` / `auto_fix` / `list_languages`); see Quick start |
Expand Down Expand Up @@ -233,10 +233,36 @@ codeguard:
| Key | Default | Effect |
|---|---|---|
| `enabled_languages` | `auto` (detect) | Restrict which linters run |
| `strict_mode` | `true` | Whether lint failure blocks AI |
| `strict_mode` | `true` | Reserved, currently unwired: PostToolUse never blocks (always exit 0, see `hooks/__protocol__.md`) |
| `auto_fix_on_save` | `true` | Whether to attempt auto-fix before reporting failure |
| `lint_timeout_seconds` | `120` | Per-linter timeout |

### Gate scope, escape audit, and unverified verdicts

Gate scanning is scoped to **what you are about to change**, decided by git state:

- **Commit face** (`staged + unstaged + untracked`): checked before `git commit`.
Pre-existing issues in HEAD do not block an unrelated new commit.
- **Push face** (commit face ∪ unpushed commits, `up...HEAD`): checked before
`git push`, so a bad commit made outside the gate is still caught on the way out.
- Project-level `codeguard.json` (repo root) overrides the default:
`{"gate_scope": "repo"}` restores full-repository scanning; `extensions` and
`exclude` customize language detection. Full scans always skip vendor/build
snapshots (immutable supply-chain content).

Verdict honesty: a linter crashing with exit 2 (usage/dependency failure) is
reported as **unverified**, never as a lint failure — "cannot verify" is not
"verified bad", and a tool crash never triggers auto-fix. Exit 127 (command
missing) is split by context on purpose: interactive hooks skip it (never block
a person for missing tooling), while `check`/CI treats it as **failure** — a
health surface must go red when tooling is absent.

Escape hatch: `git config codeguard.skipGate true` bypasses both the soft and
hard gate for one repository; every bypass is counted and surfaced by the Stop
summary, which also warns when the flag is left enabled. Shared hook state lives
under `CODEGUARD_HOME` (default `~/.codeguard`): session lint statistics,
double-install dedup keys, and the skip audit.

## Repository layout

```
Expand Down
26 changes: 24 additions & 2 deletions 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.0` |
| 当前版本 | `0.8.1` |
| ZCode manifest | `.zcode-plugin/plugin.json` |
| Codex manifest | `.codex-plugin/plugin.json` |
| MCP 服务 | 已发布:官方 SDK stdio 服务(`check_code_style` / `auto_fix` / `list_languages`);见快速开始 |
Expand Down Expand Up @@ -217,10 +217,32 @@ codeguard:
| 键 | 默认 | 作用 |
|---|---|---|
| `enabled_languages` | `auto`(自动检测) | 限定只跑哪些语言的 linter |
| `strict_mode` | `true` | lint 失败时是否阻塞 AI |
| `strict_mode` | `true` | 保留字段,当前未接线:PostToolUse 从不阻塞(恒 exit 0,见 `hooks/__protocol__.md`) |
| `auto_fix_on_save` | `true` | 是否在报错前先尝试自动修复 |
| `lint_timeout_seconds` | `120` | 每次 linter 调用的超时秒数 |

### 门禁作用域、绕过审计与未验证判定

门禁只扫描**你即将改动的东西**,由 git 状态决定:

- **提交面**(staged + 未暂存 + 未跟踪):`git commit` 前检查。HEAD 里的存量
问题不会拦截无关的新提交。
- **推送面**(提交面 ∪ 未推送提交,`up...HEAD`):`git push` 前检查——绕过门禁
提交进历史的坏改动,出门时仍会被拦下。
- 项目根的 `codeguard.json` 可覆盖缺省:`{"gate_scope": "repo"}` 恢复全仓扫描;
`extensions` 与 `exclude` 自定义语言识别。全仓扫描始终剔除 vendor/build
快照(供应链不可变内容)。

判定诚实性:linter 以 exit 2(用法/依赖崩溃)退出时按**未验证**上报,绝不计
为 lint 失败——"无法验证"不等于"验证失败",工具崩溃也不会触发自动修复。
exit 127(命令不存在)**按场景有意分流**:交互钩子跳过它(不因工具缺失挡人);
`check`/CI 面按**失败**处理——健康面在工具缺失时就该变红。

逃生门:`git config codeguard.skipGate true` 对单仓同时豁免软门与硬门;每次
绕过都会计数并由 Stop 汇总展示,标志遗留未清时也会提醒。钩子共享状态位于
`CODEGUARD_HOME`(缺省 `~/.codeguard`):会话 lint 统计、双副本去重键与绕过
审计。

## 仓库结构

```
Expand Down
8 changes: 6 additions & 2 deletions hooks/__protocol__.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,12 @@ if __name__ == "__main__":
**一致性约束**:`UserPromptSubmit` 软门禁与本硬门禁共用同一条 skipGate 豁免,
且**都不得在非 git 目录回退成"扫描 cwd"**——UPS 对非 git 目录输出一行
`_non_git_note` 说明并 exit 0(工作区根被回退扫描 = 上百无关仓的存量 lint
变成永久红,实测)。双副本事件去重键:PreToolUse 用 `tool_use_id`、UPS 用
`session_id + 文本`;payload 不带这些字段(测试协议)时不去重。
变成永久红,实测)。双副本事件去重键(四个钩子同一规则):PreToolUse 用 `tool_use_id`、
UserPromptSubmit 用 `session_id + 文本前缀`、SessionStart/Stop 用 `session_id`;
payload 不带这些字段(测试协议/其它宿主)时不去重,保持旧行为。
推送语义:门禁面由 `_guarded_mode` 统一判定——直接命令与一层间接共用同一套
扫描;commit 面=暂存+未暂存+未跟踪,push 面=并集未推送提交(`up...HEAD`);
UserPromptSubmit 按提示词里的 `push/推送` 选同一套面,软硬两门永远看同一组文件。

---

Expand Down
21 changes: 19 additions & 2 deletions hooks/env_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
3. 输出一段 AGENTS.md 风格的提示,让 AI 知道自己在一个被 codeguard 管理的项目里
"""
import contextlib
import json
import os
import subprocess
import sys
Expand Down Expand Up @@ -58,7 +59,15 @@ def detect_linter_config(project_root: Path) -> dict:
return found


def main() -> int:
def main(payload: dict | None = None) -> int:
# 双副本去重:宿主提供 session_id 时,同会话第二个副本静默(第一份的
# 摘要已注入;两份都打 = 同一段"项目记忆"重复两遍)。payload 缺字段
# (测试协议/其它宿主)时不去重,保持旧行为。
from gate_lib import should_suppress_event

session_id = (payload or {}).get("session_id")
if session_id and should_suppress_event(f"start:{session_id}"):
return 0
ensure_user_path(from_login_shell=True) # GUI 宿主 PATH 不含用户级工具目录,先补齐再盘点
project_root = find_project_root(os.getcwd())
if project_root is None:
Expand Down Expand Up @@ -142,7 +151,15 @@ def main() -> int:

if __name__ == "__main__":
try:
sys.exit(main())
payload: dict = {}
if not sys.stdin.isatty():
try:
data = json.loads(sys.stdin.read())
if isinstance(data, dict):
payload = data
except (json.JSONDecodeError, ValueError):
payload = {}
sys.exit(main(payload))
except SystemExit:
raise
except Exception as exc: # noqa: BLE001 — 内部错误 fail-open:traceback 绝不进 AI 上下文/阻断工作流
Expand Down
29 changes: 21 additions & 8 deletions hooks/gate_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,11 @@ def _worktree_fingerprint(project_root: Path) -> str:
return h.hexdigest()[:16]


def _gate_cache_key(project_root: Path, languages: list) -> str | None:
"""缓存键:HEAD + 暂存区指纹 + 工作区内容指纹。任一变化即失效。"""
def _gate_cache_key(project_root: Path, languages: list, *, mode: str = "commit") -> str | None:
"""缓存键:HEAD + 暂存区指纹 + 工作区内容指纹 + 门禁面(mode)。

同一 HEAD/工作树下 commit 面与 push 面看到的文件集不同(push 面含未推送
提交),不带 mode 会互相污染缓存。"""
head = _git(project_root, "rev-parse", "HEAD")
if head is None:
return None
Expand All @@ -151,14 +154,20 @@ def _gate_cache_key(project_root: Path, languages: list) -> str | None:
return None
return (
f"{head.strip()}|{idx_sig}|{_worktree_fingerprint(project_root)}"
f"|{','.join(sorted(languages))}"
f"|{mode}|{','.join(sorted(languages))}"
)


GATE_CACHE_TTL = 60 # 秒:UPS 软门禁与紧随的 PreToolUse 硬门禁之间复用


def run_gate(project_root: Path, cfg: dict, languages: list | None = None) -> tuple[list, list]:
def run_gate(
project_root: Path,
cfg: dict,
languages: list | None = None,
*,
mode: str = "commit",
) -> tuple[list, list]:
"""运行 linter 门禁(跨进程结果缓存 + 并行执行)。

`languages` 可选:调用方(UserPromptSubmit)可传入用户消息里提到的
Expand All @@ -179,15 +188,15 @@ def run_gate(project_root: Path, cfg: dict, languages: list | None = None) -> tu
return [], []
# 作用域:项目可用 codeguard.json gate_scope 覆盖;缺省 = git 仓 delta、
# 非 git 目录全量。delta 只检查本次改动涉及的文件——存量问题不拦新提交。
changed = changed_files(project_root)
changed = changed_files(project_root, mode=mode)
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]

ck = _gate_cache_key(project_root, languages)
ck = _gate_cache_key(project_root, languages, mode=mode)
cache_file = _gate_cache_path(project_root)
if ck:
try:
Expand Down Expand Up @@ -274,11 +283,15 @@ def check(lang: str):
return (lang, None, f"{lang} 未配置项目级 gate 命令(lint 为单文件模式),本次未验证")
timeout = cfg.get("lint_timeout_seconds", 120)
hint = cmd_def.get("install_hint") or "见 docs/LANGUAGES.md"
# 「未接入」先于「工具不可用」:requiresConfig 是纯文件系统判定(快、
# 不起进程),且是更根本的原因——项目没接这个 linter 时,装没装工具
# 都不该影响结论;反过来会把"未接入"报成"工具不可用"(本机无 yamllint
# 时既有 yaml 测试即因此失败)。
if not project_uses_linter(cmd_def, project_root):
return (lang, None, f"{lang} 项目未接入(缺 linter 配置文件),本次未验证")
ok, reason = probe_toolchain(cmd_def)
if not ok:
return (lang, None, f"{lang} 工具链不可用未验证:{reason}(安装: {hint})")
if not project_uses_linter(cmd_def, project_root):
return (lang, None, f"{lang} 项目未接入(缺 linter 配置文件),本次未验证")

outputs: list[tuple[int, str, str]] = []
if scope == "delta" and uses_delta_files and "{file}" in " ".join(base_cmd or []):
Expand Down
81 changes: 68 additions & 13 deletions hooks/pre_tool_git_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,21 @@ def resolve_project_roots(command: str) -> list[Path]:
return roots


def _segment_is_git_side_effect(seg: str) -> bool:
"""单个命令段是不是 git commit/push:git 为分隔符后的命令词。
def _git_side_effect_sub(seg: str) -> str | None:
"""命令段段首为 git commit|push 时返回子命令(commit/push),否则 None。

首 token 剥一层包裹引号(`bash -c "git commit …"` 切段后首词是 `"git`)。
"""
tokens = seg.strip().split()
if len(tokens) < 2:
return False
return None
first = tokens[0].strip("\"'")
second = tokens[1].strip("\"'")
return first == "git" and second in ("commit", "push")
return second if first == "git" and second in ("commit", "push") else None


def _segment_is_git_side_effect(seg: str) -> bool:
return _git_side_effect_sub(seg) is not None


_SCRIPT_SUFFIXES = (".sh", ".bash", ".zsh", ".py", ".mjs", ".js", ".ts")
Expand Down Expand Up @@ -153,18 +157,69 @@ def _command_indirect(command: str) -> bool:
return False


def _collect_subs(text: str) -> list[str]:
import re
return [
sub for seg in re.split(r"&&|\|\||;|\n", text)
if (sub := _git_side_effect_sub(seg))
]


def _guarded_mode(command: str) -> str | None:
"""门禁命中时返回生效的门禁面:commit 或 push(push 优先——两面并存时
推送面是提交面的超集,按更宽的面检查);未命中返回 None。

直接命令段先判定;未命中再走一层解释器间接(脚本/-c 内联)。
is_guarded 即本函数的存在性判断——面判定与命中判定共用同一套扫描,
避免"命中了却选错面"的分叉。
"""
subs = _collect_subs(command)
if not subs:
import re
for seg in re.split(r"&&|\|\||;|\n", command):
tokens = seg.strip().split()
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:])
subs = _collect_subs(body)
if subs:
break
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
subs = _collect_subs(body)
break
if subs:
break
if not subs:
return None
return "push" if "push" in subs else "commit"


def is_guarded(command: str) -> bool:
"""精确匹配 git commit/push:直接命令 + 一层解释器间接。

直接:子串匹配会误伤命令文本里的数据(payload、调试脚本内容含
"git push" 字面量),所以按分隔符切段、git 必须是段首命令词。
间接:解释器执行的脚本/内联代码按同规则扫一层(见 _command_indirect)。
间接:解释器执行的脚本/内联代码按同规则扫一层。
命中后面(commit/push)由 _guarded_mode 决定,供门禁选 delta 面。
"""
import re
if any(_segment_is_git_side_effect(seg)
for seg in re.split(r"&&|\|\||;|\n", command)):
return True
return _command_indirect(command)
return _guarded_mode(command) is not None


def main() -> int:
Expand All @@ -185,6 +240,7 @@ def main() -> int:
return 0

cfg = load_user_config()
mode = _guarded_mode(command) or "commit"
roots = resolve_project_roots(command)
if not roots:
return 0
Expand All @@ -196,11 +252,10 @@ def main() -> int:
# (commit 查暂存区;push 查未推送提交的 diff,防已提交未发现的坏文件)
reports = []
for project_root in roots:
failures, _skipped = run_gate(project_root, cfg)
failures, _skipped = run_gate(project_root, cfg, mode=mode)
if failures:
reports.append(gate_directive(failures))
safety_mode = "push" if "git push" in command.lower() else "commit"
violations = check_commit_safety(project_root, safety_mode)
violations = check_commit_safety(project_root, mode)
if violations:
reports.append(format_safety_report(violations) + (
"\n\n**给 AI 的强制指令**:先把上述文件移出版本库"
Expand Down
Loading
Loading