diff --git a/com.github.copilot/hooks/__protocol__.md b/com.github.copilot/hooks/__protocol__.md new file mode 100644 index 0000000..4549211 --- /dev/null +++ b/com.github.copilot/hooks/__protocol__.md @@ -0,0 +1,231 @@ +# hooks/ 与宿主 CLI 之间的契约(canonical cheat-sheet) + +> 这份文档是 codeguard-plugin 与 Codex CLI / ZCode / Kimi Code 三端宿主 +> 交互契约的**单源事实**。所有 hook 脚本(`hooks/*.py`)的实现都必须与本文件 +> 一致;若未来调整任何退出码或 JSON schema,**同一 commit 内**必须更新本文件。 +> +> 对应的 OpenSpec 规范是 `hook-protocol`(见 `openspec/specs/hook-protocol/spec.md`)。 + +--- + +## 1. 协议总表 + +| 事件 | Hook 脚本 | stdout | stderr | exit | 阻断? | +|---|---|---|---|---|---| +| SessionStart | `env_check.py` | 人类可读一行摘要 `codeguard 插件环境:...` | 仅内部错误时 | 0 | 否 | +| UserPromptSubmit | `user_prompt_validator.py` | JSON `{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"..."}}` | 仅内部错误时 | 0 | 否 | +| PreToolUse `Bash` | `pre_tool_git_guard.py` | 通过:空;未验证:JSON additionalContext | 通过:空;失败:完整修复指令 | 0(通过/放行)/ 2(拦截) | 放行:否;确定违规:是 | +| PostToolUse `Write\|Edit\|MultiEdit` | `post_tool_lint.py` | JSON `{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"..."}, "systemMessage":"..."}` | 仅内部错误时 | 0 | 否 | +| Stop | `stop_summary.py` | 人类可读会话摘要 | 仅内部错误时 | 0 | 否 | + +实现定位(**符号而非行号**——行号随每次重构腐烂,符号不腐;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`;静态语法在 `scripts/codeguard/git_syntax.py`,仓库观察与 `is_guarded/_guarded_mode` 在 `scripts/codeguard/git_context.py`;旧 Hook helper 导入继续兼容。一层脚本观察共用 `_indirect_bodies`。 +- PostToolUse JSON:`hooks/post_tool_lint.py::main`(通过/自动修复后通过/失败 additionalContext 三处 print)。 +- Stop 摘要:`hooks/stop_summary.py::main`(含绕过计数 `summarize` 与 skipGate 遗留告警)。 + +门禁内部边界:`hooks/gate_lib.py` 只保留兼容导出;`scripts/codeguard/gate.py` 选择内容面与保序汇总, +`gate_checks.py` 返回各语言独立 `GateOutcome`;`repository_policy.py` 检查路径安全和显式豁免, +`baseline.py` 核验存量证据,`spec_validation.py` 适配可选 OpenSpec 校验,`reporting.py` 保留反馈格式。 +应用服务无需 hooks 路径或宿主 SDK 即可运行;兼容面不承担第二份业务实现。 + +语言服务边界:`registry_schema` 的纯校验同时服务注册表 CLI 与运行时加载,`registry` 派生 +识别/命令表;`config` 读取配置,`discovery` 只做项目发现,`toolchain` 负责探活。 +SessionStart 与每轮门禁各持有独立 `ToolchainProbe`,在目标目录执行且 stdin=DEVNULL; +同批同命令成功探活去重,不同命令仍并行,失败与下一批检查都重新探测。 +探活成功不代表 lint 通过。`detect_lang`、`user_config` 的原公开导入继续兼容。 + +`codeguard.json` 缺失时保留默认值;存在但 JSON/字段类型/扩展语言 ID/排除正则无效时, +CLI/MCP 明确返回 UNVERIFIED,自动修复不执行。门禁以未验证说明放行而非报告通过; +其它 Hook 的配置异常仍遵循既有 fail-open 和 stderr 告警,不把它变成代码违规。 + +--- + +## 2. 三端兼容矩阵 + +| 宿主 | SessionStart | UserPromptSubmit | PreToolUse Bash | PostToolUse Write\|Edit\|MultiEdit | Stop | +|---|---|---|---|---|---| +| Codex CLI | stdout 注入 developer context | stdout JSON 注入 additionalContext | exit 2 走工具结果回给 AI;exit 0 静默 | stdout JSON 注入 additionalContext + systemMessage | stdout 摘要 | +| ZCode | 同 Codex | 同 Codex,additionalContext 进 systemMessage | 同 Codex | 同 Codex | 同 Codex | +| Kimi Code CLI | stdout 摘要注入会话上下文 | stdout JSON 注入上下文(观察型,**不阻断用户消息**) | exit 2 拦截 + stderr 指令;exit 0 静默 | stdout JSON 注入上下文 | stdout 摘要 | + +所有三端在所有事件上,**内部错误都走 fail-open**(见 §3),hook bug 永远不阻断工作流。 + +--- + +## 3. Fail-open 约定(强制) + +任何 hook 脚本在 `__main__` 处必须有: + +```python +if __name__ == "__main__": + try: + sys.exit(main()) + except SystemExit: + raise + except Exception as exc: + print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr) + sys.exit(0) +``` + +含义: +- 钩子内部任何未捕获异常 → stderr 打印 traceback 摘要 → **exit 0**。 +- AI 与用户上下文永远收到「钩子无意见」的语义。 +- 同一约定适用于全部 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——会破坏宿主工作流。 + +--- + +## 4. PreToolUse 硬拦截(exit 2)的语义边界 + +`pre_tool_git_guard.py` 是**唯一**会 exit 2 的 hook——且仅当: +1. 入参命令命中 `is_guarded()`:**直接**(先展开 `$(...)`/反引号内层文本到待扫面, + 再分隔符切段、剥除段首 shell 控制引导词(`if`/`then`/`do`/`while`/`!`…)与 + `NAME=VAL`/`env`/常见裸 wrapper 前缀并跳过 git 全局选项(`-C`/`-c`/…)后 + 子命令为 `git commit|push`;子串匹配会误伤 payload/echo 文本)**或一层解释器 + 间接**(`bash|sh|python… <脚本>` 的脚本文本、`-c` 内联代码按同规则扫描—— + `bash runner.sh` 式绕过曾连推 4 次漏网;`ro=$(git push …)`、`if git push; then` + 曾静默放行(实测);拼接式 subprocess 不在静态扫描承诺内);并且 + `resolve_project_roots()` 用**同一套归一化判定**收集仓库边界(含 `git -C ` + 的显式仓边界)——两处必须同源,否则 is_guarded 命中而 roots=[] → main 静默 + 放行,归一化修复被 roots 层击穿(0.8.2 实测:`git -C`/`FOO=1 git push`/ + `sudo git push` 三种形态全部穿透);并且 +2. 仓库级 `git config codeguard.skipGate true` 未设置(命中豁免时记账一次, + `gate_lib.record_skip_event`,Stop 汇总可见;**该豁免只覆盖语言门禁**—— + 入库内容安全扫描不受其覆盖,见下方一致性约束);并且 +3. 实际跑 linter 后存在非 skipped 的 failures。 + +出口内容(`gate_lib.gate_directive` 生成): +- **首行必须是 `codeguard ❌ 提交门禁未通过:` 综述**(tests/run_all 守护); +- 整调用声明——"整个工具调用没有执行(含非 git 前序步骤),请把修复与提交拆成 + 两次独立调用"——**必须紧跟首行综述(指令前置)**,其后才是每语言细节、 + 「怎么修」先于 linter 原始输出。**报告总长受 `REPORT_MAX_CHARS`(3000)硬上限**: + 宿主把超长 stderr 从尾部截断,指令在末段时长报告下会整体丢失(实测 AI 只见到 + 首段 linter 报错,逃生门与拆调用指引全没读到);超限按"保头保尾"截细节, + 尾部的「完整输出: /tmp/…」日志路径不得截掉。PreToolUse 的 exit 2 拒绝的是 + **整个 Bash 工具调用**,此前未声明这一点,AI 反复把写文件与提交塞进同一调用 + 并误判"编辑被吞"。 + +**禁止**因工具未装、项目未接入、检查超时等 UNVERIFIED 状态 exit 2;保留 fail-open, +但 MUST 明确未验证,不能输出“全部通过”。没有适用改动是 SKIPPED,不等于工具故障。 +单个并行语言检查出现内部异常时,只将该语言标为 UNVERIFIED,其余语言的已知失败仍拦截。 +Git 安全路径观察无法读取拟入库路径时,以 JSON additionalContext 说明未验证;若已有已知失败, +该失败仍 exit 2,不能因后续安全观察故障被顶层 fail-open 清空。 +退出码按工具解释,不能全局把 2 当崩溃(例如 pylint 的 2 是违规)。 +**取消无基线存量归因**:报错在未修改文件上也可能是本次 API 变化造成的,不能凭路径放行。 + +**一致性约束**:`UserPromptSubmit` 软门禁与本硬门禁共用同一条 skipGate 豁免, +且**豁免范围仅限语言门禁**:入库内容安全扫描(密钥/凭据类路径)不被任何 +代理可控豁免(仓库配置、内联 `-c codeguard.skipGate=true`、链式)覆盖,恒执行; +需要完整放行(含安全扫描)只能用进程环境变量 `CODEGUARD_SKIP_GATE`(1/true/yes, +仅用户可设——宿主内联赋值不会传入钩子进程)。软硬两门在豁免下也照常报告安全违规。 +且**都不得在非 git 目录回退成"扫描 cwd"**——UPS 对非 git 目录输出一行 +`_non_git_note` 说明并 exit 0(工作区根被回退扫描 = 上百无关仓的存量 lint +变成永久红,实测)。双副本事件去重键:PreToolUse 用 `tool_use_id + 命令`、 +UserPromptSubmit 用 `session_id + 完整文本 + 检查输入身份`、SessionStart/Stop 用 `session_id`; +这些键同时绑定当前会话/worktree。payload 不带对应字段时不做事件去重。 +去重仅记录**已经完成**的结果:SessionStart/Stop 异常后可重试;软门禁有跳过或未验证项 +时不记录完成。硬门禁只短时重放已完成的拒绝(保留 exit 2 与诊断),绝不缓存放行; +第一份仍在执行时第二份可以重复检查,不可因“事件见过”直接返回 0。 +这不是 exactly-once 执行保证,也不能代替当前代码快照。 +推送语义:门禁面由 `_guarded_mode` 统一判定——直接命令与一层间接共用同一套 +扫描;**commit 面 = 按命令链预测的实际提交面**(`pre_tool_git_guard.staging_intent`: +纯 `git commit` → 仅暂存区;`git add -A` → 含未跟踪;`git add -u` 或 `commit -a` → 仅已跟踪改动; +`git add ` → 并入这些路径——add 在 PreToolUse 时**尚未执行**, +不并入会漏检"即将暂存"的文件;并行会话留在工作树的未暂存 WIP 不属于本次提交, +曾因此被误拦);纯 push 在 HEAD 快照中检查未推送差异(`up...HEAD`),无上游时检查 HEAD 树。 +commit+push 使用预测提交快照并合并未推送范围。删除文件进入影响分析,不作为新增敏感文件。 +`git_staging` 将每段 cd/-C、pathspec 与其 worktree 根绑定;硬门禁逐仓传入目标身份, +另一仓的 add -A 不得扩张本仓的纯 commit。Git ls-files 成组解析 glob/目录/literal/exclude; +`-u` 不包含未跟踪文件,`-f` 可包含指定忽略文件,解析后的名字在快照中按字面处理。 +子目录 add . 不扩到兄弟目录;路径含空格与连续相对 cd/-C 保留上下文。 +动态路径、交互暂存和 pathspec-from-file 等未支持形态返回 JSON additionalContext 的 +git UNVERIFIED,保持 exit 0 兼容放行;不能被描述为已准确验证,也不冒充程序内部异常。 +硬门禁从 Git blobs 物化一次性目录,不借真实 .git,不修改原 index/工作树,也不复用软门禁缓存。 +符号链接/子模块/冲突、超限、依赖不可用都明确 UNVERIFIED。快照是内容隔离,不是执行沙箱; +复杂命令链、非 HEAD refspec、并发修改仍需独立 CI 验证。 +UserPromptSubmit 按提示词里的 `push/推送` 选 commit/push 面,但**不传 lanes = +三路宽口径**——软门禁没有待执行命令可预测,按"工作树有待提交改动就提醒"注入 +(注入非阻断,多提醒不算错;硬门禁少拦才是底线),软硬两门只在 skipGate 豁免 +上严格一致(豁免只覆盖语言门禁;安全扫描两边都照常执行)。 + +PostToolUse 只运行可限定到单文件的检查和 formatter。append_files=false 的项目命令推迟到 +显式 check/Git 门禁;保存不能触发整项目 formatter。工具异常不能自动修复。 +CLI/MCP 共用 PASS/FAIL/UNVERIFIED/SKIPPED/PLANNED,只有 PASS 的 passed=true。 +CLI 的 0/1/2 与 hook 的 fail-open 退出码是不同协议,不能混用。 + +Java 计划由 `java_analysis` 装配:`java_build` 读描述,`java_impact` 计算模块反向闭包, +`java_planning` 选择命令,`java_environment` 选择 wrapper/JDK,`java_changes` 观察版本差异。 +`java_project.py` 保留原 CLI/导入面。默认 skipTests/check -x test 不变,权威命令不加默认参数; +纯版本降级必须由正确 Git 基线与当前描述证明,依赖版本和编译配置变化不得降为 validate/help。 +根与变更路径先规范化;分析仍是模块级,不等于完整符号或任意构建脚本语义验证。 + +### 状态归属与持久化 + +`scripts/codeguard/hook_state.py` 负责会话归属、统计、冷却、审计和已完成结果; +`scripts/codeguard/storage.py` 负责跨进程锁与原子文件替换。锁覆盖整个读改写, +使用独立且不随数据替换的 `.lock` 文件,不能只有原子写而没有读改写互斥。 + +- 宿主提供 `session_id` 时,统计保存在 `CODEGUARD_HOME/sessions/<会话与worktree哈希>.json`; + 同一 session 在不同 Git worktree 也分开。Stop 只原子消费当前作用域,后续并发写入保留。 +- 无 `session_id` 时沿用 `session_state.json` 与旧插件内状态的兼容读取;此模式**没有跨会话隔离保证**。 + 旧全局记录不分配给任意一个带 ID 的新会话,不能把兼容读取称为可信归属迁移。 +- 门禁审计由调用线程汇总后写入,`worktree` 是原仓根,`execution_root` 是实际检查目录 + (可能为临时快照),`session_scope` 保留会话归属;`cmd` 为已物化的执行 argv,不再写未展开模板。 + 存量豁免记录为 SKIPPED 而非检查通过;当前或基线为 UNVERIFIED 时均不得授予豁免。 +- 保存检查只在获得 PASS/FAIL 后登记去重,UNVERIFIED 后允许立即重试。 + 去重同时校验文件内容和配置/命令身份;检查前后身份不一致不登记,不把执行中产生的新内容当作已检查。 +- 本批真实并发与 worktree 验证在 macOS 完成;Windows 锁实现尚未在 Windows 实机验证。 + +### 缓存身份与失效 + +`scripts/codeguard/fingerprint.py` 采集输入身份,`scripts/codeguard/cache.py` 只读写和校验完整软结果。 +缓存保存在 `CODEGUARD_HOME/gate-cache/.json`;旧 `/tmp` 条目不读取、不自动删除。 + +- 身份包含真实 worktree 路径、HEAD、Git index 的内容条目、upstream、受版本控制及非忽略工作树文件的内容、 + 常见根 linter 配置/注册表声明的配置、用户配置、命令定义和本地工具身份;不再用 mtime/size 代替源码内容。 +- Git 文件名按 NUL 分隔,linked worktree 不假设 `.git` 是目录。项目 `codeguard.json` 不保留永不过期的进程副本。 +- 预算为 10,000 个文件名、64 MiB 内容;符号链接/特殊文件、预算超限、读取失败或非 Git 目录时禁用该身份缓存, + 仍实际检查。预算不截断成一个看似完整的键;非 Git 保存也仍检查,但不采用仓库身份去重。 +- 只保存检查前后身份一致且 `skipped` 为空的软结果;损坏格式、未来时间戳和未知结果均重跑。 + 硬门禁不读取软缓存,事件去重亦不缓存放行。 +- 这是有明确输入范围的软观察优化,不是原子安全快照或可移植验收凭据;任意外部动态配置、远程依赖和未声明环境变化 + 不在完整性证明范围内。准确提交验证和 CI 仍独立运行,不得以软缓存证明整个项目已通过。 + +--- + +## 5. 新增 hook checklist + +新加一个 hook 时,按顺序自检: + +1. **读本 cheat-sheet** + `openspec/specs/hook-protocol/spec.md`。 +2. 在 `hooks/hooks.json` 注册(事件 / matcher / command / timeout)。 +3. 脚本实现遵守 §1 协议总表的 exit 码与 stdout 形态。 +4. 脚本 `__main__` 加 §3 的 fail-open 套子。 +5. 在 `tests/run_all.py` 加子集触发:stdin 喂真实宿主协议 JSON,断言 exit 码与 stdout 形状。 +6. 在本文件 §1 协议总表加一行,标注实现位置(`hooks/.py:line`)。 +7. OpenSpec change:proposal / design / `hook-protocol/spec.md` 加 Scenario / tasks 勾选后 archive。 + +--- + +## 6. 协议变更策略 + +任何对 §1 协议总表字段的修改: + +- 同步改本文件 + 对应 `hooks/*.py` + 对应测试。 +- 走 OpenSpec change(`hook-protocol` spec 下新增 MODIFIED Requirements + Scenarios)。 +- 不允许「先改代码后补文档」的拆分 commit——契约一致是同一变更的原子单位。 + +--- + +## 7. 与 OpenSpec 的对应 + +| Cheat-sheet 节 | OpenSpec spec / Scenario | +|---|---| +| §1 协议总表 | `hook-protocol::Requirement: Hook-host contract SHALL live in hooks/__protocol__.md` | +| §3 Fail-open | `hook-protocol::Requirement: The cheat-sheet SHALL document fail-open` | +| §5 新增 hook checklist | `hook-protocol::Requirement: tests/run_all.py SHALL reference the cheat-sheet` | + +变更通过 `openspec archive add-host-protocol-cheatsheet` 入库;spec 在 `openspec/specs/hook-protocol/spec.md`。 diff --git a/com.github.copilot/hooks/env_check.py b/com.github.copilot/hooks/env_check.py new file mode 100755 index 0000000..7296185 --- /dev/null +++ b/com.github.copilot/hooks/env_check.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""SessionStart 宿主适配器:读取事件与本机插件配置,输出项目记忆。""" +from __future__ import annotations + +import contextlib +import json +import os +import subprocess +import sys +from pathlib import Path + +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) +sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) + +from codeguard import startup_application +from codeguard.reporting import plugin_version +from detect_lang import ( # 兼容原有 Python 导入接口 + LANG_COMMANDS, + REGISTRY, + detect_languages, + ensure_user_path, + find_project_root, + load_user_config, +) + +__all__ = [ + "LANG_COMMANDS", + "REGISTRY", + "detect_languages", + "detect_linter_config", + "ensure_user_path", + "find_project_root", + "load_user_config", + "main", + "notify", + "version_backlog_note", +] + +detect_linter_config = startup_application.detect_linter_config +version_backlog_note = startup_application.version_backlog_note + + +def notify(title: str, message: str) -> None: + """macOS 系统通知;其他宿主静默。""" + if sys.platform != "darwin": + return + safe_title = title.replace('"', "'") + safe_message = message.replace('"', "'")[:200] + with contextlib.suppress(OSError): + subprocess.Popen( + ["osascript", "-e", + f'display notification "{safe_message}" with title "{safe_title}"'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + +def _enabled_plugin_orgs() -> tuple[str, ...]: + """ZCode 宿主注册表只在适配器读取,其他宿主缺文件时无副作用。""" + try: + config = json.loads((Path.home() / ".zcode" / "cli" / "config.json").read_text(encoding="utf-8")) + orgs = { + key.split("@", 1)[1] + for key in (config.get("plugins", {}) or {}).get("enabledPlugins", {}) + if key.startswith("codeguard@") + } + return tuple(sorted(orgs)) + except (OSError, ValueError, AttributeError): + return () + + +def main(payload: dict | None = None) -> int: + from codeguard.hook_state import observe_once, session_scope + session_id = (payload or {}).get("session_id") + with session_scope(payload or {}): + return observe_once(f"start:{session_id}" if session_id else None, lambda: _main(payload)) + + +def _main(payload: dict | None = None) -> int: + ensure_user_path(from_login_shell=True) + project_root = find_project_root(os.getcwd()) or Path(os.getcwd()) + current = plugin_version() + report = startup_application.build_startup_report( + project_root, + plugin_version="" if current == "unknown" else current, + cache_root=Path.home() / ".zcode" / "cli" / "plugins" / "cache", + enabled_plugin_orgs=_enabled_plugin_orgs(), + ) + if report.notification: + notify(*report.notification) + if report.text: + print(report.text) + return 0 + + +if __name__ == "__main__": + try: + 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,不阻断宿主工作流 + print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr) + sys.exit(0) diff --git a/com.github.copilot/hooks/gate_lib.py b/com.github.copilot/hooks/gate_lib.py new file mode 100644 index 0000000..76527b8 --- /dev/null +++ b/com.github.copilot/hooks/gate_lib.py @@ -0,0 +1,124 @@ +"""旧 Hook 导入兼容面;检查、仓库策略、审计与反馈均委托到独立服务。""" +from __future__ import annotations + +import sys +from pathlib import Path + +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) +sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) + +from codeguard.baseline import ( + _git, + _mentioned_files, + _stale_attribution, + baseline_stale_finding, +) +from codeguard.gate import ( + GATE_CACHE_TTL, + _gate_cache_key, + _gate_cache_path, + _worktree_fingerprint, + run_gate, +) +from codeguard.gate import run_batch as _run_gate_uncached +from codeguard.hook_state import ( + codeguard_home, + record_gate_decision, + record_skip_event, + session_state_path, + should_suppress_event, +) +from codeguard.path_policy import ( + GUARD_EXCLUDE_DIRS, + GUARD_EXCLUDE_FILES, + GUARD_ROOT_ONLY_FILES, +) +from codeguard.reporting import ( + CODEGUARD_VERSION, + REPORT_MAX_CHARS, + _dependency_resolution_hint, + _failure_detail_blocks, + _log_path, + _rule_summary, + _squeeze, + _truncate_detail, + format_failure_report, + format_safety_report, + gate_directive, + summarize_failures, +) +from codeguard.repository_policy import check_commit_safety, skip_gate_via_git_config +from codeguard.spec_validation import validate as _openspec_validate +from detect_lang import ( + LANG_COMMANDS, + detect_language, + detect_languages, + get_overrides, + probe_toolchain, + project_uses_linter, +) +from scope import changed_files + +__all__ = [ + "CODEGUARD_VERSION", + "GATE_CACHE_TTL", + "GUARD_EXCLUDE_DIRS", + "GUARD_EXCLUDE_FILES", + "GUARD_ROOT_ONLY_FILES", + "LANG_COMMANDS", + "PLUGIN_ROOT", + "REPORT_MAX_CHARS", + "_dependency_resolution_hint", + "_failure_detail_blocks", + "_gate_cache_key", + "_gate_cache_path", + "_git", + "_log_path", + "_mentioned_files", + "_openspec_validate", + "_rule_summary", + "_run_gate_uncached", + "_squeeze", + "_stale_attribution", + "_truncate_detail", + "_worktree_fingerprint", + "baseline_stale_finding", + "changed_files", + "check_commit_safety", + "codeguard_home", + "detect_language", + "detect_languages", + "format_failure_report", + "format_safety_report", + "gate_directive", + "get_overrides", + "probe_toolchain", + "project_uses_linter", + "record_gate_decision", + "record_skip_event", + "run_gate", + "session_state_path", + "should_suppress_event", + "skip_gate_via_git_config", + "summarize_failures", +] + + +def _java_executable_from_plan(plan: dict) -> str | None: + """从 java_project.analyze 的产物里解析构建可执行文件。 + + 两条来源按优先级: + 1. 顶层 ``executable`` 键(java_project 正常产出); + 2. 回退 ``commands[0].argv[0]``——历史版本的 java_project 只把 + wrapper 体现在计划命令里(实测 68c8a37 曾因此让 mvnw 感知成为 + 死代码:消费者读的键生产者从不写)。 + 非 mvn/gradle wrapper(如 codeguard.json 显式命令)不参与替换。 + """ + exe = plan.get("executable") + if exe: + return exe + for cmd in plan.get("commands", []) or []: + argv = cmd.get("argv") or [] + if argv and Path(argv[0]).name in ("mvnw", "gradlew"): + return argv[0] + return None diff --git a/com.github.copilot/hooks/hooks.json b/com.github.copilot/hooks/hooks.json new file mode 100644 index 0000000..781375d --- /dev/null +++ b/com.github.copilot/hooks/hooks.json @@ -0,0 +1,64 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/env_check.py\"", + "timeout": 30, + "statusMessage": "codeguard: 检测项目语言与现有 linter 配置…" + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": "提交|commit|push|提交代码|部署|deploy", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/user_prompt_validator.py\"", + "timeout": 120 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_git_guard.py\"", + "timeout": 120 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_lint.py\"", + "timeout": 180 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/stop_summary.py\"", + "timeout": 15 + } + ] + } + ] + } +} diff --git a/com.github.copilot/hooks/post_tool_lint.py b/com.github.copilot/hooks/post_tool_lint.py new file mode 100644 index 0000000..bb778ad --- /dev/null +++ b/com.github.copilot/hooks/post_tool_lint.py @@ -0,0 +1,131 @@ +"""PostToolUse 宿主适配器:保存反馈以 JSON 注入上下文,绝不阻断写文件。""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) +sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) + +from codeguard import save_application +from codeguard.hook_state import codeguard_home, session_scope +from detect_lang import ( # 兼容原有 Python 导入接口 + LANG_COMMANDS, + detect_language, + ensure_user_path, + find_project_root, + load_user_config, + project_uses_linter, +) +from scope import is_build_artifact + +__all__ = [ + "DEDUP_FILE", "LANG_COMMANDS", "bump_state", "detect_language", "ensure_user_path", + "extract_file_path", "find_project_root", "is_build_artifact", "is_ejs_template", + "load_user_config", "mac_notify", "main", "project_uses_linter", "read_payload", + "run", "should_skip", "should_suppress_duplicate", +] + +# 多个 marketplace 副本共享用户级去重状态;保留原测试/嵌入方的覆盖点。 +DEDUP_FILE: Path | None = None + + +def _dedup_path() -> Path: + return DEDUP_FILE or codeguard_home() / "file_dedup.json" + + +def _state_path() -> Path: + return save_application.state_path() + + +def bump_state(lang: str, passed: bool, auto_fixed: bool = False) -> None: + save_application.bump_state(lang, passed, auto_fixed) + + +def _notify_cooldown_ok(lang: str, cooldown_s: int = 60) -> bool: + return save_application.notification_allowed(lang, cooldown_s) + + +def mac_notify(title: str, message: str) -> None: + """macOS 通知;非 macOS 宿主由 JSON 上下文呈现问题。""" + if sys.platform != "darwin": + return + safe_title = title.replace('"', "'") + safe_message = message.replace('"', "'")[:200] + subprocess.Popen( + ["osascript", "-e", + f'display notification "{safe_message}" with title "{safe_title}" sound name "Pop"'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + +def should_suppress_duplicate(file_path: str, identity: str | None = None) -> bool: + return save_application.should_suppress_duplicate(file_path, identity, _dedup_path()) + + +is_ejs_template = save_application.is_ejs_template +run = save_application.run +should_skip = save_application.should_skip + + +def read_payload() -> dict: + if sys.stdin.isatty(): + return {} + try: + return json.loads(sys.stdin.read()) + except (json.JSONDecodeError, ValueError): + return {} + + +def extract_file_path(payload: dict) -> str: + for candidate in (payload.get("file_path"), + (payload.get("tool_input") or {}).get("file_path"), + (payload.get("tool_input") or {}).get("path"), + (payload.get("input") or {}).get("file_path")): + if candidate: + return str(candidate) + return "" + + +def main() -> int: + payload = read_payload() + with session_scope(payload): + return _main(payload) + + +def _main(payload: dict) -> int: + ensure_user_path() + file_path = extract_file_path(payload) + if not file_path: + return 0 + cfg = load_user_config() + project_root = find_project_root(os.getcwd()) or Path(os.getcwd()) + result = save_application.evaluate_save(file_path, project_root, cfg, store=_dedup_path()) + if result.additional_context is not None: + message = {"hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": result.additional_context, + }} + if result.system_message is not None: + message["systemMessage"] = result.system_message + print(json.dumps(message, ensure_ascii=False)) + sys.stdout.flush() + result.acknowledge() + if result.notification: + lang, title, message = result.notification + if _notify_cooldown_ok(lang): + mac_notify(title, message) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except SystemExit: + raise + except Exception as exc: # noqa: BLE001 — 内部错误 fail-open,不阻断 AI 写文件 + print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr) + sys.exit(0) diff --git a/com.github.copilot/hooks/pre_tool_git_guard.py b/com.github.copilot/hooks/pre_tool_git_guard.py new file mode 100755 index 0000000..43a536d --- /dev/null +++ b/com.github.copilot/hooks/pre_tool_git_guard.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""PreToolUse 宿主适配器:AI 执行 git commit/push 前输出硬门禁决策。""" +from __future__ import annotations + +import contextlib +import io +import json +import os +import sys +from pathlib import Path + +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) +sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) +sys.path.insert(0, str(PLUGIN_ROOT / "hooks")) + +from codeguard.git_context import ( + _command_indirect, + _fallback_roots, + _filter_fallback_roots, + _guarded_mode, + _is_git_repo, + _repo_root_of, + is_guarded, + resolve_project_roots, + staging_intent, +) +from codeguard.git_guard_application import evaluate_git_command +from codeguard.git_syntax import ( + _analyze_segment, + _collect_subs, + _flatten_substitutions, + _git_c_path, + _git_seg_parts, + _git_side_effect_sub, + _normalize_segment, + _segment_is_git_side_effect, + chain_skip_gate, + inline_skip_gate, +) +from codeguard.hook_state import completed_event, record_completed_event, session_scope +from codeguard.repository_policy import env_skip_gate +from detect_lang import ensure_user_path, load_user_config +from gate_lib import ( # 兼容原有 Python 导入接口 + check_commit_safety, + format_safety_report, + gate_directive, + record_skip_event, + run_gate, + skip_gate_via_git_config, +) + +__all__ = [ + "GUARDED_PATTERNS", "_analyze_segment", "_collect_subs", "_command_indirect", + "_fallback_roots", "_filter_fallback_roots", "_flatten_substitutions", "_git_c_path", + "_git_seg_parts", "_git_side_effect_sub", "_guarded_mode", "_is_git_repo", + "_normalize_segment", "_repo_root_of", "_segment_is_git_side_effect", "chain_skip_gate", + "check_commit_safety", "extract_command", "format_safety_report", "gate_directive", + "inline_skip_gate", "is_guarded", "main", "read_payload", "record_skip_event", + "resolve_project_roots", "run_gate", "skip_gate_via_git_config", "staging_intent", +] + +GUARDED_PATTERNS = ("git commit", "git push") + + +def read_payload() -> dict: + if sys.stdin.isatty(): + return {} + try: + return json.loads(sys.stdin.read()) + except (json.JSONDecodeError, ValueError): + return {} + + +def extract_command(payload: dict) -> str: + tool_input = payload.get("tool_input") or {} + command = tool_input.get("command") + return str(command) if command else "" + + +def main() -> int: + payload = read_payload() + with session_scope(payload): + key = ("pre:" + str(payload["tool_use_id"]) + ":" + extract_command(payload) + if payload.get("tool_use_id") else None) + cached = completed_event(key) if key else None + if cached is not None and cached["code"] == 2: + _deliver(cached["code"], cached["stdout"], cached["stderr"]) + return cached["code"] + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + code = _main(payload) + out, err = stdout.getvalue(), stderr.getvalue() + # 只复用阻断结果,硬门放行和未验证永远重跑准确快照。 + delivered = _deliver(code, out, err) + if key and code == 2 and delivered: + record_completed_event(key, code, out, err) + return code + + +def _deliver(code: int, stdout: str, stderr: str) -> bool: + """刷新宿主输出后才允许缓存;已知拦截不能因输出故障降为放行。""" + try: + sys.stdout.write(stdout) + sys.stderr.write(stderr) + sys.stdout.flush() + sys.stderr.flush() + except (OSError, UnicodeError, ValueError): + if code == 2: + return False + raise + return True + + +def _main(payload: dict) -> int: + ensure_user_path() + result = evaluate_git_command( + extract_command(payload), + cwd=Path(os.getcwd()), + load_config=load_user_config, + bypass_env=env_skip_gate(), + ) + for context in result.contexts: + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "PreToolUse", "additionalContext": context, + }}, ensure_ascii=False)) + if result.stderr: + print(result.stderr, file=sys.stderr) + return result.exit_code + + +if __name__ == "__main__": + try: + sys.exit(main()) + except SystemExit: + raise + except Exception as exc: # noqa: BLE001 — 内部错误 fail-open,不阻断宿主工具 + print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr) + sys.exit(0) diff --git a/com.github.copilot/hooks/stop_summary.py b/com.github.copilot/hooks/stop_summary.py new file mode 100755 index 0000000..63469ac --- /dev/null +++ b/com.github.copilot/hooks/stop_summary.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Stop 钩子:会话结束前总结本次会话产生的代码规范问题。 + +让用户在关闭会话前能看到: +- 本次修改了哪些语言 +- 哪些 linter 通过、哪些失败 +- 自动修复成功的次数 +- 残留需要手动处理的警告 +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) # hooks/ 的上级 = 插件根 + +sys.path.insert(0, str(PLUGIN_ROOT / "hooks")) +sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) + +from codeguard import session_application + +# 现行状态目录在 ~/.codeguard(CODEGUARD_HOME 可覆盖);插件根的旧文件 +# 升版本即丢、双副本各存一半——load_state 优先新路径,回退读旧文件一次。 +LEGACY_STATE_FILE = PLUGIN_ROOT / ".session_state.json" + + +def load_state() -> dict: + return session_application.load_state(LEGACY_STATE_FILE) + + +def summarize(state: dict) -> str: + return session_application.summarize(state) + + +def _skipgate_left_enabled() -> bool: + return session_application.skipgate_left_enabled(Path.cwd()) + + +def main(payload: dict | None = None) -> int: + from codeguard.hook_state import observe_once, session_scope + session_id = (payload or {}).get("session_id") + with session_scope(payload or {}): + return observe_once(f"stop:{session_id}" if session_id else None, lambda: _main(payload)) + + +def _main(payload: dict | None = None) -> int: + prepared = session_application.prepare_summary(LEGACY_STATE_FILE, Path.cwd()) + for line in prepared.lines: + print(line) + sys.stdout.flush() + prepared.acknowledge() + return 0 + + +if __name__ == "__main__": + try: + 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 上下文/阻断工作流 + print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr) + sys.exit(0) diff --git a/com.github.copilot/hooks/user_prompt_validator.py b/com.github.copilot/hooks/user_prompt_validator.py new file mode 100755 index 0000000..ae4a3a2 --- /dev/null +++ b/com.github.copilot/hooks/user_prompt_validator.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""UserPromptSubmit 宿主适配器:软提醒注入,不阻断用户消息。""" +from __future__ import annotations + +import contextlib +import json +import os +import sys +from pathlib import Path + +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) +sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) +sys.path.insert(0, str(PLUGIN_ROOT / "hooks")) + +from codeguard import prompt_application, prompt_policy +from codeguard.hook_state import session_scope +from codeguard.repository_policy import env_skip_gate +from detect_lang import ( # 兼容原有 Python 导入接口 + LANG_COMMANDS, + detect_languages, + ensure_user_path, + find_project_root, + load_user_config, +) +from gate_lib import ( # 兼容原有 Python 导入接口 + check_commit_safety, + format_safety_report, + gate_directive, + record_skip_event, + run_gate, + skip_gate_via_git_config, + summarize_failures, +) + +__all__ = [ + "LANG_COMMANDS", "QUESTION_MARKERS", "TRIGGER_PATTERNS", "check_commit_safety", + "detect_languages", "ensure_user_path", "find_project_root", "format_safety_report", + "gate_directive", "is_trigger", "load_user_config", "main", "notify", "read_payload", + "read_user_text", "record_skip_event", "run_gate", "skip_gate_via_git_config", + "summarize_failures", +] + +TRIGGER_PATTERNS = prompt_policy.TRIGGER_PATTERNS +QUESTION_MARKERS = prompt_policy.QUESTION_MARKERS +is_trigger = prompt_policy.is_trigger +_detect_languages_in_text = prompt_policy.detect_languages_in_text +_non_git_note = prompt_application.non_git_note + + +def notify(title: str, message: str) -> None: + """macOS 系统通知;其他宿主仅依赖对话上下文。""" + if sys.platform != "darwin": + return + import subprocess + safe_title = title.replace('"', "'") + safe_message = message.replace('"', "'")[:200] + with contextlib.suppress(OSError): + subprocess.Popen( + ["osascript", "-e", + f'display notification "{safe_message}" with title "{safe_title}" sound name "Pop"'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + +def read_payload() -> dict: + """stdin 只读取一次,兼容 user_prompt 与 prompt 字段。""" + if sys.stdin.isatty(): + return {} + try: + data = json.loads(sys.stdin.read()) + except json.JSONDecodeError: + 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 main() -> int: + payload = read_payload() + with session_scope(payload): + return _main(payload) + + +def _main(payload: dict) -> int: + user_text = str(payload.get("user_prompt") or payload.get("prompt") or "") + if not is_trigger(user_text): + return 0 + ensure_user_path(from_login_shell=True) + result = prompt_application.evaluate_prompt( + user_text, + project_root=find_project_root(os.getcwd()), + session_id=payload.get("session_id"), + load_config=load_user_config, + bypass_env=env_skip_gate(), + ) + if result.notification: + notify(*result.notification) + if result.additional_context is not None: + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": result.additional_context, + }}, ensure_ascii=False)) + sys.stdout.flush() + result.acknowledge() + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except SystemExit: + raise + except Exception as exc: # noqa: BLE001 — 内部错误 fail-open,不阻断用户消息 + print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr) + sys.exit(0) diff --git a/dev.openhands/commands/check.json b/dev.openhands/commands/check.json new file mode 100644 index 0000000..9c10ba6 --- /dev/null +++ b/dev.openhands/commands/check.json @@ -0,0 +1,5 @@ +{ + "name": "check", + "description": "运行项目代码 lint 检查(跨语言)", + "prompt": "运行 /check。\n\n步骤:\n1. 调用 `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/detect_lang.py .` 识别项目语言\n2. 对每种语言跑对应 linter(mvn javadoc+checkstyle / cargo clippy+fmt / npx eslint / ruff check)\n3. 失败时尝试自动修复(spotless:apply / cargo fmt / eslint --fix / ruff --fix)\n4. 给出表格化报告:每种语言通过/失败/修复数量\n5. 如有失败,给出每个文件的具体错误位置和修复建议\n\n也可以直接调用:`python3 ${CLAUDE_PLUGIN_ROOT}/scripts/run_check.py`(推荐)" +} diff --git a/dev.openhands/commands/fix.json b/dev.openhands/commands/fix.json new file mode 100644 index 0000000..b688774 --- /dev/null +++ b/dev.openhands/commands/fix.json @@ -0,0 +1,5 @@ +{ + "name": "fix", + "description": "自动修复所有 linter 问题", + "prompt": "运行 /fix。\n\n步骤:\n1. 调用 `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/detect_lang.py .` 识别项目语言\n2. 对每种语言跑对应修复命令(spotless:apply / cargo fmt / eslint --fix / ruff check --fix)\n3. 修复后跑 lint 验证\n4. 报告哪些被自动修复、哪些仍需手动处理\n\n也可以直接调用:`python3 ${CLAUDE_PLUGIN_ROOT}/scripts/fix.py [--dry-run]`\n\n注意:\n- 不要自动修改 linter 配置文件(checkstyle.xml、clippy.toml 等)来消除失败\n- 不要在业务代码中加 `@SuppressWarnings` 来屏蔽 javadoc 警告\n- 不要为了通过 lint 而改坏代码逻辑" +} diff --git a/dev.openhands/commands/init.json b/dev.openhands/commands/init.json new file mode 100644 index 0000000..fae4aae --- /dev/null +++ b/dev.openhands/commands/init.json @@ -0,0 +1,5 @@ +{ + "name": "init", + "description": "把 codeguard 接入当前项目(写 .pre-commit-config.yaml / AGENTS.md / CI)", + "prompt": "运行 /init。\n\n按 `codeguard-init` skill 的步骤操作:\n1. 检测项目语言\n2. 拷贝对应 linter 配置到项目根(checkstyle.xml / clippy.toml / eslint.config.js / ruff 配置)\n3. 写 .pre-commit-config.yaml(基于插件的模板)\n4. 增量更新 AGENTS.md(追加代码规范章节,不覆盖已有内容)\n5. 询问用户是否生成 GitHub Actions 工作流\n6. 输出安装指令:`pip install pre-commit && pre-commit install`(不自动执行)\n\n边界:\n- 不删除/覆盖已有配置\n- 不覆盖 AGENTS.md 已有内容\n- 不自动执行 pre-commit install" +} diff --git a/dev.openhands/hooks/__protocol__.md b/dev.openhands/hooks/__protocol__.md new file mode 100644 index 0000000..4549211 --- /dev/null +++ b/dev.openhands/hooks/__protocol__.md @@ -0,0 +1,231 @@ +# hooks/ 与宿主 CLI 之间的契约(canonical cheat-sheet) + +> 这份文档是 codeguard-plugin 与 Codex CLI / ZCode / Kimi Code 三端宿主 +> 交互契约的**单源事实**。所有 hook 脚本(`hooks/*.py`)的实现都必须与本文件 +> 一致;若未来调整任何退出码或 JSON schema,**同一 commit 内**必须更新本文件。 +> +> 对应的 OpenSpec 规范是 `hook-protocol`(见 `openspec/specs/hook-protocol/spec.md`)。 + +--- + +## 1. 协议总表 + +| 事件 | Hook 脚本 | stdout | stderr | exit | 阻断? | +|---|---|---|---|---|---| +| SessionStart | `env_check.py` | 人类可读一行摘要 `codeguard 插件环境:...` | 仅内部错误时 | 0 | 否 | +| UserPromptSubmit | `user_prompt_validator.py` | JSON `{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"..."}}` | 仅内部错误时 | 0 | 否 | +| PreToolUse `Bash` | `pre_tool_git_guard.py` | 通过:空;未验证:JSON additionalContext | 通过:空;失败:完整修复指令 | 0(通过/放行)/ 2(拦截) | 放行:否;确定违规:是 | +| PostToolUse `Write\|Edit\|MultiEdit` | `post_tool_lint.py` | JSON `{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"..."}, "systemMessage":"..."}` | 仅内部错误时 | 0 | 否 | +| Stop | `stop_summary.py` | 人类可读会话摘要 | 仅内部错误时 | 0 | 否 | + +实现定位(**符号而非行号**——行号随每次重构腐烂,符号不腐;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`;静态语法在 `scripts/codeguard/git_syntax.py`,仓库观察与 `is_guarded/_guarded_mode` 在 `scripts/codeguard/git_context.py`;旧 Hook helper 导入继续兼容。一层脚本观察共用 `_indirect_bodies`。 +- PostToolUse JSON:`hooks/post_tool_lint.py::main`(通过/自动修复后通过/失败 additionalContext 三处 print)。 +- Stop 摘要:`hooks/stop_summary.py::main`(含绕过计数 `summarize` 与 skipGate 遗留告警)。 + +门禁内部边界:`hooks/gate_lib.py` 只保留兼容导出;`scripts/codeguard/gate.py` 选择内容面与保序汇总, +`gate_checks.py` 返回各语言独立 `GateOutcome`;`repository_policy.py` 检查路径安全和显式豁免, +`baseline.py` 核验存量证据,`spec_validation.py` 适配可选 OpenSpec 校验,`reporting.py` 保留反馈格式。 +应用服务无需 hooks 路径或宿主 SDK 即可运行;兼容面不承担第二份业务实现。 + +语言服务边界:`registry_schema` 的纯校验同时服务注册表 CLI 与运行时加载,`registry` 派生 +识别/命令表;`config` 读取配置,`discovery` 只做项目发现,`toolchain` 负责探活。 +SessionStart 与每轮门禁各持有独立 `ToolchainProbe`,在目标目录执行且 stdin=DEVNULL; +同批同命令成功探活去重,不同命令仍并行,失败与下一批检查都重新探测。 +探活成功不代表 lint 通过。`detect_lang`、`user_config` 的原公开导入继续兼容。 + +`codeguard.json` 缺失时保留默认值;存在但 JSON/字段类型/扩展语言 ID/排除正则无效时, +CLI/MCP 明确返回 UNVERIFIED,自动修复不执行。门禁以未验证说明放行而非报告通过; +其它 Hook 的配置异常仍遵循既有 fail-open 和 stderr 告警,不把它变成代码违规。 + +--- + +## 2. 三端兼容矩阵 + +| 宿主 | SessionStart | UserPromptSubmit | PreToolUse Bash | PostToolUse Write\|Edit\|MultiEdit | Stop | +|---|---|---|---|---|---| +| Codex CLI | stdout 注入 developer context | stdout JSON 注入 additionalContext | exit 2 走工具结果回给 AI;exit 0 静默 | stdout JSON 注入 additionalContext + systemMessage | stdout 摘要 | +| ZCode | 同 Codex | 同 Codex,additionalContext 进 systemMessage | 同 Codex | 同 Codex | 同 Codex | +| Kimi Code CLI | stdout 摘要注入会话上下文 | stdout JSON 注入上下文(观察型,**不阻断用户消息**) | exit 2 拦截 + stderr 指令;exit 0 静默 | stdout JSON 注入上下文 | stdout 摘要 | + +所有三端在所有事件上,**内部错误都走 fail-open**(见 §3),hook bug 永远不阻断工作流。 + +--- + +## 3. Fail-open 约定(强制) + +任何 hook 脚本在 `__main__` 处必须有: + +```python +if __name__ == "__main__": + try: + sys.exit(main()) + except SystemExit: + raise + except Exception as exc: + print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr) + sys.exit(0) +``` + +含义: +- 钩子内部任何未捕获异常 → stderr 打印 traceback 摘要 → **exit 0**。 +- AI 与用户上下文永远收到「钩子无意见」的语义。 +- 同一约定适用于全部 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——会破坏宿主工作流。 + +--- + +## 4. PreToolUse 硬拦截(exit 2)的语义边界 + +`pre_tool_git_guard.py` 是**唯一**会 exit 2 的 hook——且仅当: +1. 入参命令命中 `is_guarded()`:**直接**(先展开 `$(...)`/反引号内层文本到待扫面, + 再分隔符切段、剥除段首 shell 控制引导词(`if`/`then`/`do`/`while`/`!`…)与 + `NAME=VAL`/`env`/常见裸 wrapper 前缀并跳过 git 全局选项(`-C`/`-c`/…)后 + 子命令为 `git commit|push`;子串匹配会误伤 payload/echo 文本)**或一层解释器 + 间接**(`bash|sh|python… <脚本>` 的脚本文本、`-c` 内联代码按同规则扫描—— + `bash runner.sh` 式绕过曾连推 4 次漏网;`ro=$(git push …)`、`if git push; then` + 曾静默放行(实测);拼接式 subprocess 不在静态扫描承诺内);并且 + `resolve_project_roots()` 用**同一套归一化判定**收集仓库边界(含 `git -C ` + 的显式仓边界)——两处必须同源,否则 is_guarded 命中而 roots=[] → main 静默 + 放行,归一化修复被 roots 层击穿(0.8.2 实测:`git -C`/`FOO=1 git push`/ + `sudo git push` 三种形态全部穿透);并且 +2. 仓库级 `git config codeguard.skipGate true` 未设置(命中豁免时记账一次, + `gate_lib.record_skip_event`,Stop 汇总可见;**该豁免只覆盖语言门禁**—— + 入库内容安全扫描不受其覆盖,见下方一致性约束);并且 +3. 实际跑 linter 后存在非 skipped 的 failures。 + +出口内容(`gate_lib.gate_directive` 生成): +- **首行必须是 `codeguard ❌ 提交门禁未通过:` 综述**(tests/run_all 守护); +- 整调用声明——"整个工具调用没有执行(含非 git 前序步骤),请把修复与提交拆成 + 两次独立调用"——**必须紧跟首行综述(指令前置)**,其后才是每语言细节、 + 「怎么修」先于 linter 原始输出。**报告总长受 `REPORT_MAX_CHARS`(3000)硬上限**: + 宿主把超长 stderr 从尾部截断,指令在末段时长报告下会整体丢失(实测 AI 只见到 + 首段 linter 报错,逃生门与拆调用指引全没读到);超限按"保头保尾"截细节, + 尾部的「完整输出: /tmp/…」日志路径不得截掉。PreToolUse 的 exit 2 拒绝的是 + **整个 Bash 工具调用**,此前未声明这一点,AI 反复把写文件与提交塞进同一调用 + 并误判"编辑被吞"。 + +**禁止**因工具未装、项目未接入、检查超时等 UNVERIFIED 状态 exit 2;保留 fail-open, +但 MUST 明确未验证,不能输出“全部通过”。没有适用改动是 SKIPPED,不等于工具故障。 +单个并行语言检查出现内部异常时,只将该语言标为 UNVERIFIED,其余语言的已知失败仍拦截。 +Git 安全路径观察无法读取拟入库路径时,以 JSON additionalContext 说明未验证;若已有已知失败, +该失败仍 exit 2,不能因后续安全观察故障被顶层 fail-open 清空。 +退出码按工具解释,不能全局把 2 当崩溃(例如 pylint 的 2 是违规)。 +**取消无基线存量归因**:报错在未修改文件上也可能是本次 API 变化造成的,不能凭路径放行。 + +**一致性约束**:`UserPromptSubmit` 软门禁与本硬门禁共用同一条 skipGate 豁免, +且**豁免范围仅限语言门禁**:入库内容安全扫描(密钥/凭据类路径)不被任何 +代理可控豁免(仓库配置、内联 `-c codeguard.skipGate=true`、链式)覆盖,恒执行; +需要完整放行(含安全扫描)只能用进程环境变量 `CODEGUARD_SKIP_GATE`(1/true/yes, +仅用户可设——宿主内联赋值不会传入钩子进程)。软硬两门在豁免下也照常报告安全违规。 +且**都不得在非 git 目录回退成"扫描 cwd"**——UPS 对非 git 目录输出一行 +`_non_git_note` 说明并 exit 0(工作区根被回退扫描 = 上百无关仓的存量 lint +变成永久红,实测)。双副本事件去重键:PreToolUse 用 `tool_use_id + 命令`、 +UserPromptSubmit 用 `session_id + 完整文本 + 检查输入身份`、SessionStart/Stop 用 `session_id`; +这些键同时绑定当前会话/worktree。payload 不带对应字段时不做事件去重。 +去重仅记录**已经完成**的结果:SessionStart/Stop 异常后可重试;软门禁有跳过或未验证项 +时不记录完成。硬门禁只短时重放已完成的拒绝(保留 exit 2 与诊断),绝不缓存放行; +第一份仍在执行时第二份可以重复检查,不可因“事件见过”直接返回 0。 +这不是 exactly-once 执行保证,也不能代替当前代码快照。 +推送语义:门禁面由 `_guarded_mode` 统一判定——直接命令与一层间接共用同一套 +扫描;**commit 面 = 按命令链预测的实际提交面**(`pre_tool_git_guard.staging_intent`: +纯 `git commit` → 仅暂存区;`git add -A` → 含未跟踪;`git add -u` 或 `commit -a` → 仅已跟踪改动; +`git add ` → 并入这些路径——add 在 PreToolUse 时**尚未执行**, +不并入会漏检"即将暂存"的文件;并行会话留在工作树的未暂存 WIP 不属于本次提交, +曾因此被误拦);纯 push 在 HEAD 快照中检查未推送差异(`up...HEAD`),无上游时检查 HEAD 树。 +commit+push 使用预测提交快照并合并未推送范围。删除文件进入影响分析,不作为新增敏感文件。 +`git_staging` 将每段 cd/-C、pathspec 与其 worktree 根绑定;硬门禁逐仓传入目标身份, +另一仓的 add -A 不得扩张本仓的纯 commit。Git ls-files 成组解析 glob/目录/literal/exclude; +`-u` 不包含未跟踪文件,`-f` 可包含指定忽略文件,解析后的名字在快照中按字面处理。 +子目录 add . 不扩到兄弟目录;路径含空格与连续相对 cd/-C 保留上下文。 +动态路径、交互暂存和 pathspec-from-file 等未支持形态返回 JSON additionalContext 的 +git UNVERIFIED,保持 exit 0 兼容放行;不能被描述为已准确验证,也不冒充程序内部异常。 +硬门禁从 Git blobs 物化一次性目录,不借真实 .git,不修改原 index/工作树,也不复用软门禁缓存。 +符号链接/子模块/冲突、超限、依赖不可用都明确 UNVERIFIED。快照是内容隔离,不是执行沙箱; +复杂命令链、非 HEAD refspec、并发修改仍需独立 CI 验证。 +UserPromptSubmit 按提示词里的 `push/推送` 选 commit/push 面,但**不传 lanes = +三路宽口径**——软门禁没有待执行命令可预测,按"工作树有待提交改动就提醒"注入 +(注入非阻断,多提醒不算错;硬门禁少拦才是底线),软硬两门只在 skipGate 豁免 +上严格一致(豁免只覆盖语言门禁;安全扫描两边都照常执行)。 + +PostToolUse 只运行可限定到单文件的检查和 formatter。append_files=false 的项目命令推迟到 +显式 check/Git 门禁;保存不能触发整项目 formatter。工具异常不能自动修复。 +CLI/MCP 共用 PASS/FAIL/UNVERIFIED/SKIPPED/PLANNED,只有 PASS 的 passed=true。 +CLI 的 0/1/2 与 hook 的 fail-open 退出码是不同协议,不能混用。 + +Java 计划由 `java_analysis` 装配:`java_build` 读描述,`java_impact` 计算模块反向闭包, +`java_planning` 选择命令,`java_environment` 选择 wrapper/JDK,`java_changes` 观察版本差异。 +`java_project.py` 保留原 CLI/导入面。默认 skipTests/check -x test 不变,权威命令不加默认参数; +纯版本降级必须由正确 Git 基线与当前描述证明,依赖版本和编译配置变化不得降为 validate/help。 +根与变更路径先规范化;分析仍是模块级,不等于完整符号或任意构建脚本语义验证。 + +### 状态归属与持久化 + +`scripts/codeguard/hook_state.py` 负责会话归属、统计、冷却、审计和已完成结果; +`scripts/codeguard/storage.py` 负责跨进程锁与原子文件替换。锁覆盖整个读改写, +使用独立且不随数据替换的 `.lock` 文件,不能只有原子写而没有读改写互斥。 + +- 宿主提供 `session_id` 时,统计保存在 `CODEGUARD_HOME/sessions/<会话与worktree哈希>.json`; + 同一 session 在不同 Git worktree 也分开。Stop 只原子消费当前作用域,后续并发写入保留。 +- 无 `session_id` 时沿用 `session_state.json` 与旧插件内状态的兼容读取;此模式**没有跨会话隔离保证**。 + 旧全局记录不分配给任意一个带 ID 的新会话,不能把兼容读取称为可信归属迁移。 +- 门禁审计由调用线程汇总后写入,`worktree` 是原仓根,`execution_root` 是实际检查目录 + (可能为临时快照),`session_scope` 保留会话归属;`cmd` 为已物化的执行 argv,不再写未展开模板。 + 存量豁免记录为 SKIPPED 而非检查通过;当前或基线为 UNVERIFIED 时均不得授予豁免。 +- 保存检查只在获得 PASS/FAIL 后登记去重,UNVERIFIED 后允许立即重试。 + 去重同时校验文件内容和配置/命令身份;检查前后身份不一致不登记,不把执行中产生的新内容当作已检查。 +- 本批真实并发与 worktree 验证在 macOS 完成;Windows 锁实现尚未在 Windows 实机验证。 + +### 缓存身份与失效 + +`scripts/codeguard/fingerprint.py` 采集输入身份,`scripts/codeguard/cache.py` 只读写和校验完整软结果。 +缓存保存在 `CODEGUARD_HOME/gate-cache/.json`;旧 `/tmp` 条目不读取、不自动删除。 + +- 身份包含真实 worktree 路径、HEAD、Git index 的内容条目、upstream、受版本控制及非忽略工作树文件的内容、 + 常见根 linter 配置/注册表声明的配置、用户配置、命令定义和本地工具身份;不再用 mtime/size 代替源码内容。 +- Git 文件名按 NUL 分隔,linked worktree 不假设 `.git` 是目录。项目 `codeguard.json` 不保留永不过期的进程副本。 +- 预算为 10,000 个文件名、64 MiB 内容;符号链接/特殊文件、预算超限、读取失败或非 Git 目录时禁用该身份缓存, + 仍实际检查。预算不截断成一个看似完整的键;非 Git 保存也仍检查,但不采用仓库身份去重。 +- 只保存检查前后身份一致且 `skipped` 为空的软结果;损坏格式、未来时间戳和未知结果均重跑。 + 硬门禁不读取软缓存,事件去重亦不缓存放行。 +- 这是有明确输入范围的软观察优化,不是原子安全快照或可移植验收凭据;任意外部动态配置、远程依赖和未声明环境变化 + 不在完整性证明范围内。准确提交验证和 CI 仍独立运行,不得以软缓存证明整个项目已通过。 + +--- + +## 5. 新增 hook checklist + +新加一个 hook 时,按顺序自检: + +1. **读本 cheat-sheet** + `openspec/specs/hook-protocol/spec.md`。 +2. 在 `hooks/hooks.json` 注册(事件 / matcher / command / timeout)。 +3. 脚本实现遵守 §1 协议总表的 exit 码与 stdout 形态。 +4. 脚本 `__main__` 加 §3 的 fail-open 套子。 +5. 在 `tests/run_all.py` 加子集触发:stdin 喂真实宿主协议 JSON,断言 exit 码与 stdout 形状。 +6. 在本文件 §1 协议总表加一行,标注实现位置(`hooks/.py:line`)。 +7. OpenSpec change:proposal / design / `hook-protocol/spec.md` 加 Scenario / tasks 勾选后 archive。 + +--- + +## 6. 协议变更策略 + +任何对 §1 协议总表字段的修改: + +- 同步改本文件 + 对应 `hooks/*.py` + 对应测试。 +- 走 OpenSpec change(`hook-protocol` spec 下新增 MODIFIED Requirements + Scenarios)。 +- 不允许「先改代码后补文档」的拆分 commit——契约一致是同一变更的原子单位。 + +--- + +## 7. 与 OpenSpec 的对应 + +| Cheat-sheet 节 | OpenSpec spec / Scenario | +|---|---| +| §1 协议总表 | `hook-protocol::Requirement: Hook-host contract SHALL live in hooks/__protocol__.md` | +| §3 Fail-open | `hook-protocol::Requirement: The cheat-sheet SHALL document fail-open` | +| §5 新增 hook checklist | `hook-protocol::Requirement: tests/run_all.py SHALL reference the cheat-sheet` | + +变更通过 `openspec archive add-host-protocol-cheatsheet` 入库;spec 在 `openspec/specs/hook-protocol/spec.md`。 diff --git a/dev.openhands/hooks/env_check.py b/dev.openhands/hooks/env_check.py new file mode 100755 index 0000000..7296185 --- /dev/null +++ b/dev.openhands/hooks/env_check.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""SessionStart 宿主适配器:读取事件与本机插件配置,输出项目记忆。""" +from __future__ import annotations + +import contextlib +import json +import os +import subprocess +import sys +from pathlib import Path + +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) +sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) + +from codeguard import startup_application +from codeguard.reporting import plugin_version +from detect_lang import ( # 兼容原有 Python 导入接口 + LANG_COMMANDS, + REGISTRY, + detect_languages, + ensure_user_path, + find_project_root, + load_user_config, +) + +__all__ = [ + "LANG_COMMANDS", + "REGISTRY", + "detect_languages", + "detect_linter_config", + "ensure_user_path", + "find_project_root", + "load_user_config", + "main", + "notify", + "version_backlog_note", +] + +detect_linter_config = startup_application.detect_linter_config +version_backlog_note = startup_application.version_backlog_note + + +def notify(title: str, message: str) -> None: + """macOS 系统通知;其他宿主静默。""" + if sys.platform != "darwin": + return + safe_title = title.replace('"', "'") + safe_message = message.replace('"', "'")[:200] + with contextlib.suppress(OSError): + subprocess.Popen( + ["osascript", "-e", + f'display notification "{safe_message}" with title "{safe_title}"'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + +def _enabled_plugin_orgs() -> tuple[str, ...]: + """ZCode 宿主注册表只在适配器读取,其他宿主缺文件时无副作用。""" + try: + config = json.loads((Path.home() / ".zcode" / "cli" / "config.json").read_text(encoding="utf-8")) + orgs = { + key.split("@", 1)[1] + for key in (config.get("plugins", {}) or {}).get("enabledPlugins", {}) + if key.startswith("codeguard@") + } + return tuple(sorted(orgs)) + except (OSError, ValueError, AttributeError): + return () + + +def main(payload: dict | None = None) -> int: + from codeguard.hook_state import observe_once, session_scope + session_id = (payload or {}).get("session_id") + with session_scope(payload or {}): + return observe_once(f"start:{session_id}" if session_id else None, lambda: _main(payload)) + + +def _main(payload: dict | None = None) -> int: + ensure_user_path(from_login_shell=True) + project_root = find_project_root(os.getcwd()) or Path(os.getcwd()) + current = plugin_version() + report = startup_application.build_startup_report( + project_root, + plugin_version="" if current == "unknown" else current, + cache_root=Path.home() / ".zcode" / "cli" / "plugins" / "cache", + enabled_plugin_orgs=_enabled_plugin_orgs(), + ) + if report.notification: + notify(*report.notification) + if report.text: + print(report.text) + return 0 + + +if __name__ == "__main__": + try: + 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,不阻断宿主工作流 + print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr) + sys.exit(0) diff --git a/dev.openhands/hooks/gate_lib.py b/dev.openhands/hooks/gate_lib.py new file mode 100644 index 0000000..76527b8 --- /dev/null +++ b/dev.openhands/hooks/gate_lib.py @@ -0,0 +1,124 @@ +"""旧 Hook 导入兼容面;检查、仓库策略、审计与反馈均委托到独立服务。""" +from __future__ import annotations + +import sys +from pathlib import Path + +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) +sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) + +from codeguard.baseline import ( + _git, + _mentioned_files, + _stale_attribution, + baseline_stale_finding, +) +from codeguard.gate import ( + GATE_CACHE_TTL, + _gate_cache_key, + _gate_cache_path, + _worktree_fingerprint, + run_gate, +) +from codeguard.gate import run_batch as _run_gate_uncached +from codeguard.hook_state import ( + codeguard_home, + record_gate_decision, + record_skip_event, + session_state_path, + should_suppress_event, +) +from codeguard.path_policy import ( + GUARD_EXCLUDE_DIRS, + GUARD_EXCLUDE_FILES, + GUARD_ROOT_ONLY_FILES, +) +from codeguard.reporting import ( + CODEGUARD_VERSION, + REPORT_MAX_CHARS, + _dependency_resolution_hint, + _failure_detail_blocks, + _log_path, + _rule_summary, + _squeeze, + _truncate_detail, + format_failure_report, + format_safety_report, + gate_directive, + summarize_failures, +) +from codeguard.repository_policy import check_commit_safety, skip_gate_via_git_config +from codeguard.spec_validation import validate as _openspec_validate +from detect_lang import ( + LANG_COMMANDS, + detect_language, + detect_languages, + get_overrides, + probe_toolchain, + project_uses_linter, +) +from scope import changed_files + +__all__ = [ + "CODEGUARD_VERSION", + "GATE_CACHE_TTL", + "GUARD_EXCLUDE_DIRS", + "GUARD_EXCLUDE_FILES", + "GUARD_ROOT_ONLY_FILES", + "LANG_COMMANDS", + "PLUGIN_ROOT", + "REPORT_MAX_CHARS", + "_dependency_resolution_hint", + "_failure_detail_blocks", + "_gate_cache_key", + "_gate_cache_path", + "_git", + "_log_path", + "_mentioned_files", + "_openspec_validate", + "_rule_summary", + "_run_gate_uncached", + "_squeeze", + "_stale_attribution", + "_truncate_detail", + "_worktree_fingerprint", + "baseline_stale_finding", + "changed_files", + "check_commit_safety", + "codeguard_home", + "detect_language", + "detect_languages", + "format_failure_report", + "format_safety_report", + "gate_directive", + "get_overrides", + "probe_toolchain", + "project_uses_linter", + "record_gate_decision", + "record_skip_event", + "run_gate", + "session_state_path", + "should_suppress_event", + "skip_gate_via_git_config", + "summarize_failures", +] + + +def _java_executable_from_plan(plan: dict) -> str | None: + """从 java_project.analyze 的产物里解析构建可执行文件。 + + 两条来源按优先级: + 1. 顶层 ``executable`` 键(java_project 正常产出); + 2. 回退 ``commands[0].argv[0]``——历史版本的 java_project 只把 + wrapper 体现在计划命令里(实测 68c8a37 曾因此让 mvnw 感知成为 + 死代码:消费者读的键生产者从不写)。 + 非 mvn/gradle wrapper(如 codeguard.json 显式命令)不参与替换。 + """ + exe = plan.get("executable") + if exe: + return exe + for cmd in plan.get("commands", []) or []: + argv = cmd.get("argv") or [] + if argv and Path(argv[0]).name in ("mvnw", "gradlew"): + return argv[0] + return None diff --git a/dev.openhands/hooks/hooks.json b/dev.openhands/hooks/hooks.json new file mode 100644 index 0000000..781375d --- /dev/null +++ b/dev.openhands/hooks/hooks.json @@ -0,0 +1,64 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/env_check.py\"", + "timeout": 30, + "statusMessage": "codeguard: 检测项目语言与现有 linter 配置…" + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": "提交|commit|push|提交代码|部署|deploy", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/user_prompt_validator.py\"", + "timeout": 120 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_git_guard.py\"", + "timeout": 120 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_lint.py\"", + "timeout": 180 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/stop_summary.py\"", + "timeout": 15 + } + ] + } + ] + } +} diff --git a/dev.openhands/hooks/post_tool_lint.py b/dev.openhands/hooks/post_tool_lint.py new file mode 100644 index 0000000..bb778ad --- /dev/null +++ b/dev.openhands/hooks/post_tool_lint.py @@ -0,0 +1,131 @@ +"""PostToolUse 宿主适配器:保存反馈以 JSON 注入上下文,绝不阻断写文件。""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) +sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) + +from codeguard import save_application +from codeguard.hook_state import codeguard_home, session_scope +from detect_lang import ( # 兼容原有 Python 导入接口 + LANG_COMMANDS, + detect_language, + ensure_user_path, + find_project_root, + load_user_config, + project_uses_linter, +) +from scope import is_build_artifact + +__all__ = [ + "DEDUP_FILE", "LANG_COMMANDS", "bump_state", "detect_language", "ensure_user_path", + "extract_file_path", "find_project_root", "is_build_artifact", "is_ejs_template", + "load_user_config", "mac_notify", "main", "project_uses_linter", "read_payload", + "run", "should_skip", "should_suppress_duplicate", +] + +# 多个 marketplace 副本共享用户级去重状态;保留原测试/嵌入方的覆盖点。 +DEDUP_FILE: Path | None = None + + +def _dedup_path() -> Path: + return DEDUP_FILE or codeguard_home() / "file_dedup.json" + + +def _state_path() -> Path: + return save_application.state_path() + + +def bump_state(lang: str, passed: bool, auto_fixed: bool = False) -> None: + save_application.bump_state(lang, passed, auto_fixed) + + +def _notify_cooldown_ok(lang: str, cooldown_s: int = 60) -> bool: + return save_application.notification_allowed(lang, cooldown_s) + + +def mac_notify(title: str, message: str) -> None: + """macOS 通知;非 macOS 宿主由 JSON 上下文呈现问题。""" + if sys.platform != "darwin": + return + safe_title = title.replace('"', "'") + safe_message = message.replace('"', "'")[:200] + subprocess.Popen( + ["osascript", "-e", + f'display notification "{safe_message}" with title "{safe_title}" sound name "Pop"'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + +def should_suppress_duplicate(file_path: str, identity: str | None = None) -> bool: + return save_application.should_suppress_duplicate(file_path, identity, _dedup_path()) + + +is_ejs_template = save_application.is_ejs_template +run = save_application.run +should_skip = save_application.should_skip + + +def read_payload() -> dict: + if sys.stdin.isatty(): + return {} + try: + return json.loads(sys.stdin.read()) + except (json.JSONDecodeError, ValueError): + return {} + + +def extract_file_path(payload: dict) -> str: + for candidate in (payload.get("file_path"), + (payload.get("tool_input") or {}).get("file_path"), + (payload.get("tool_input") or {}).get("path"), + (payload.get("input") or {}).get("file_path")): + if candidate: + return str(candidate) + return "" + + +def main() -> int: + payload = read_payload() + with session_scope(payload): + return _main(payload) + + +def _main(payload: dict) -> int: + ensure_user_path() + file_path = extract_file_path(payload) + if not file_path: + return 0 + cfg = load_user_config() + project_root = find_project_root(os.getcwd()) or Path(os.getcwd()) + result = save_application.evaluate_save(file_path, project_root, cfg, store=_dedup_path()) + if result.additional_context is not None: + message = {"hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": result.additional_context, + }} + if result.system_message is not None: + message["systemMessage"] = result.system_message + print(json.dumps(message, ensure_ascii=False)) + sys.stdout.flush() + result.acknowledge() + if result.notification: + lang, title, message = result.notification + if _notify_cooldown_ok(lang): + mac_notify(title, message) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except SystemExit: + raise + except Exception as exc: # noqa: BLE001 — 内部错误 fail-open,不阻断 AI 写文件 + print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr) + sys.exit(0) diff --git a/dev.openhands/hooks/pre_tool_git_guard.py b/dev.openhands/hooks/pre_tool_git_guard.py new file mode 100755 index 0000000..43a536d --- /dev/null +++ b/dev.openhands/hooks/pre_tool_git_guard.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""PreToolUse 宿主适配器:AI 执行 git commit/push 前输出硬门禁决策。""" +from __future__ import annotations + +import contextlib +import io +import json +import os +import sys +from pathlib import Path + +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) +sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) +sys.path.insert(0, str(PLUGIN_ROOT / "hooks")) + +from codeguard.git_context import ( + _command_indirect, + _fallback_roots, + _filter_fallback_roots, + _guarded_mode, + _is_git_repo, + _repo_root_of, + is_guarded, + resolve_project_roots, + staging_intent, +) +from codeguard.git_guard_application import evaluate_git_command +from codeguard.git_syntax import ( + _analyze_segment, + _collect_subs, + _flatten_substitutions, + _git_c_path, + _git_seg_parts, + _git_side_effect_sub, + _normalize_segment, + _segment_is_git_side_effect, + chain_skip_gate, + inline_skip_gate, +) +from codeguard.hook_state import completed_event, record_completed_event, session_scope +from codeguard.repository_policy import env_skip_gate +from detect_lang import ensure_user_path, load_user_config +from gate_lib import ( # 兼容原有 Python 导入接口 + check_commit_safety, + format_safety_report, + gate_directive, + record_skip_event, + run_gate, + skip_gate_via_git_config, +) + +__all__ = [ + "GUARDED_PATTERNS", "_analyze_segment", "_collect_subs", "_command_indirect", + "_fallback_roots", "_filter_fallback_roots", "_flatten_substitutions", "_git_c_path", + "_git_seg_parts", "_git_side_effect_sub", "_guarded_mode", "_is_git_repo", + "_normalize_segment", "_repo_root_of", "_segment_is_git_side_effect", "chain_skip_gate", + "check_commit_safety", "extract_command", "format_safety_report", "gate_directive", + "inline_skip_gate", "is_guarded", "main", "read_payload", "record_skip_event", + "resolve_project_roots", "run_gate", "skip_gate_via_git_config", "staging_intent", +] + +GUARDED_PATTERNS = ("git commit", "git push") + + +def read_payload() -> dict: + if sys.stdin.isatty(): + return {} + try: + return json.loads(sys.stdin.read()) + except (json.JSONDecodeError, ValueError): + return {} + + +def extract_command(payload: dict) -> str: + tool_input = payload.get("tool_input") or {} + command = tool_input.get("command") + return str(command) if command else "" + + +def main() -> int: + payload = read_payload() + with session_scope(payload): + key = ("pre:" + str(payload["tool_use_id"]) + ":" + extract_command(payload) + if payload.get("tool_use_id") else None) + cached = completed_event(key) if key else None + if cached is not None and cached["code"] == 2: + _deliver(cached["code"], cached["stdout"], cached["stderr"]) + return cached["code"] + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + code = _main(payload) + out, err = stdout.getvalue(), stderr.getvalue() + # 只复用阻断结果,硬门放行和未验证永远重跑准确快照。 + delivered = _deliver(code, out, err) + if key and code == 2 and delivered: + record_completed_event(key, code, out, err) + return code + + +def _deliver(code: int, stdout: str, stderr: str) -> bool: + """刷新宿主输出后才允许缓存;已知拦截不能因输出故障降为放行。""" + try: + sys.stdout.write(stdout) + sys.stderr.write(stderr) + sys.stdout.flush() + sys.stderr.flush() + except (OSError, UnicodeError, ValueError): + if code == 2: + return False + raise + return True + + +def _main(payload: dict) -> int: + ensure_user_path() + result = evaluate_git_command( + extract_command(payload), + cwd=Path(os.getcwd()), + load_config=load_user_config, + bypass_env=env_skip_gate(), + ) + for context in result.contexts: + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "PreToolUse", "additionalContext": context, + }}, ensure_ascii=False)) + if result.stderr: + print(result.stderr, file=sys.stderr) + return result.exit_code + + +if __name__ == "__main__": + try: + sys.exit(main()) + except SystemExit: + raise + except Exception as exc: # noqa: BLE001 — 内部错误 fail-open,不阻断宿主工具 + print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr) + sys.exit(0) diff --git a/dev.openhands/hooks/stop_summary.py b/dev.openhands/hooks/stop_summary.py new file mode 100755 index 0000000..63469ac --- /dev/null +++ b/dev.openhands/hooks/stop_summary.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Stop 钩子:会话结束前总结本次会话产生的代码规范问题。 + +让用户在关闭会话前能看到: +- 本次修改了哪些语言 +- 哪些 linter 通过、哪些失败 +- 自动修复成功的次数 +- 残留需要手动处理的警告 +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) # hooks/ 的上级 = 插件根 + +sys.path.insert(0, str(PLUGIN_ROOT / "hooks")) +sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) + +from codeguard import session_application + +# 现行状态目录在 ~/.codeguard(CODEGUARD_HOME 可覆盖);插件根的旧文件 +# 升版本即丢、双副本各存一半——load_state 优先新路径,回退读旧文件一次。 +LEGACY_STATE_FILE = PLUGIN_ROOT / ".session_state.json" + + +def load_state() -> dict: + return session_application.load_state(LEGACY_STATE_FILE) + + +def summarize(state: dict) -> str: + return session_application.summarize(state) + + +def _skipgate_left_enabled() -> bool: + return session_application.skipgate_left_enabled(Path.cwd()) + + +def main(payload: dict | None = None) -> int: + from codeguard.hook_state import observe_once, session_scope + session_id = (payload or {}).get("session_id") + with session_scope(payload or {}): + return observe_once(f"stop:{session_id}" if session_id else None, lambda: _main(payload)) + + +def _main(payload: dict | None = None) -> int: + prepared = session_application.prepare_summary(LEGACY_STATE_FILE, Path.cwd()) + for line in prepared.lines: + print(line) + sys.stdout.flush() + prepared.acknowledge() + return 0 + + +if __name__ == "__main__": + try: + 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 上下文/阻断工作流 + print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr) + sys.exit(0) diff --git a/dev.openhands/hooks/user_prompt_validator.py b/dev.openhands/hooks/user_prompt_validator.py new file mode 100755 index 0000000..ae4a3a2 --- /dev/null +++ b/dev.openhands/hooks/user_prompt_validator.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""UserPromptSubmit 宿主适配器:软提醒注入,不阻断用户消息。""" +from __future__ import annotations + +import contextlib +import json +import os +import sys +from pathlib import Path + +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) +sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) +sys.path.insert(0, str(PLUGIN_ROOT / "hooks")) + +from codeguard import prompt_application, prompt_policy +from codeguard.hook_state import session_scope +from codeguard.repository_policy import env_skip_gate +from detect_lang import ( # 兼容原有 Python 导入接口 + LANG_COMMANDS, + detect_languages, + ensure_user_path, + find_project_root, + load_user_config, +) +from gate_lib import ( # 兼容原有 Python 导入接口 + check_commit_safety, + format_safety_report, + gate_directive, + record_skip_event, + run_gate, + skip_gate_via_git_config, + summarize_failures, +) + +__all__ = [ + "LANG_COMMANDS", "QUESTION_MARKERS", "TRIGGER_PATTERNS", "check_commit_safety", + "detect_languages", "ensure_user_path", "find_project_root", "format_safety_report", + "gate_directive", "is_trigger", "load_user_config", "main", "notify", "read_payload", + "read_user_text", "record_skip_event", "run_gate", "skip_gate_via_git_config", + "summarize_failures", +] + +TRIGGER_PATTERNS = prompt_policy.TRIGGER_PATTERNS +QUESTION_MARKERS = prompt_policy.QUESTION_MARKERS +is_trigger = prompt_policy.is_trigger +_detect_languages_in_text = prompt_policy.detect_languages_in_text +_non_git_note = prompt_application.non_git_note + + +def notify(title: str, message: str) -> None: + """macOS 系统通知;其他宿主仅依赖对话上下文。""" + if sys.platform != "darwin": + return + import subprocess + safe_title = title.replace('"', "'") + safe_message = message.replace('"', "'")[:200] + with contextlib.suppress(OSError): + subprocess.Popen( + ["osascript", "-e", + f'display notification "{safe_message}" with title "{safe_title}" sound name "Pop"'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + +def read_payload() -> dict: + """stdin 只读取一次,兼容 user_prompt 与 prompt 字段。""" + if sys.stdin.isatty(): + return {} + try: + data = json.loads(sys.stdin.read()) + except json.JSONDecodeError: + 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 main() -> int: + payload = read_payload() + with session_scope(payload): + return _main(payload) + + +def _main(payload: dict) -> int: + user_text = str(payload.get("user_prompt") or payload.get("prompt") or "") + if not is_trigger(user_text): + return 0 + ensure_user_path(from_login_shell=True) + result = prompt_application.evaluate_prompt( + user_text, + project_root=find_project_root(os.getcwd()), + session_id=payload.get("session_id"), + load_config=load_user_config, + bypass_env=env_skip_gate(), + ) + if result.notification: + notify(*result.notification) + if result.additional_context is not None: + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": result.additional_context, + }}, ensure_ascii=False)) + sys.stdout.flush() + result.acknowledge() + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except SystemExit: + raise + except Exception as exc: # noqa: BLE001 — 内部错误 fail-open,不阻断用户消息 + print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr) + sys.exit(0) diff --git a/docs/portable-migration.md b/docs/portable-migration.md new file mode 100644 index 0000000..2eb64cb --- /dev/null +++ b/docs/portable-migration.md @@ -0,0 +1,94 @@ +# Portable Agent Plugins migration + +**Status: migrated to Agent Plugins v1.0.0.** + +This package originally shipped only per-client manifests. None of those is part +of the Agent Plugins format, so a conformant client rejected the package +outright and never reached the 68 skills inside it — a missing root +manifest is fatal, not a warning (§5.1, §5.3). + +## What ships now + +| File | Role | +|------|------| +| `plugin.json` | Canonical portable manifest (published `agent-plugins.org` 1.0.0 schema) | +| `mcp.json` | _(none — this package bundles no MCP servers)_ | +| `.codex-plugin/plugin.json` | Codex compatibility manifest, unchanged | +| `.zcode-plugin/plugin.json` | ZCode compatibility manifest, unchanged | +| `kimi.plugin.json` | Kimi compatibility manifest, unchanged | + +## Layout rules honoured + +- `plugin.json`, `skills/` live at the package root. +- The portable manifest declares **only** published-schema fields. The schema is + closed (`additionalProperties: false`), so it deliberately omits: + - `interface` — carried at `extensions["com.openai"]["interface"]` (§8) + - `skills` — discovered from the fixed `skills/` location; it MUST NOT be declared (§6.1) +- `name` is `codeguard` — lowercase alphanumerics, `-` and `.` only, starting and + ending alphanumeric, with no `--` or `..` (§5.5). +- `version` is the **base** version `0.16.1`. The `+codex.` build + metadata in `.codex-plugin/plugin.json` describes that compatibility channel's + build, not the portable plugin, so it stays there. +- `$schema` is the exact published constant + `https://agent-plugins.org/schemas/1.0.0/plugin.schema.json`. + +## Client-owned data + +Everything the portable schema has no field for lives under a reverse-domain +client extension namespace (§8): + +- `interface` + +## Client coverage + +`skills/` and `mcp.json` are the portable component types, so **every client in +the [Agent Plugins registry](https://agent-plugins.org/compatible-clients) loads +this package with no client-specific work**: VS Code, Cursor, GitHub Copilot, +ChatGPT & Codex, Kiro, Hermes Agent, OpenClaw, Grok Bot, NanoClaw and OpenHands. + +Commands, agents and hooks are explicitly *not* portable, so a client reads them +from the extension directory it owns (§8.2). This package mirrors its +non-portable components accordingly: + +- `com.github.copilot/` — `hooks/` for VS Code and GitHub Copilot (they share this namespace) +- `dev.openhands/` — `commands/`, `hooks/` for OpenHands +- `extensions["com.openai"]` — manifest data for ChatGPT & Codex + +The root `commands/`, `hooks/` and `agents/` directories are kept unchanged, so +the Codex, ZCode and Kimi channels keep working. `scripts/validate_portable_plugin.py` +fails if a mirror drifts from its root copy. + +> **OpenClaw precedence.** OpenClaw checks for a client-specific bundle +> marker (`.codex-plugin/`) before a root `plugin.json`, and treats the +> client-specific format as winning so its richer mappings survive. This +> package ships both, so OpenClaw loads it as a Codex bundle — which keeps +> its commands and hooks working — rather than as an Agent Plugins bundle. +> Removing `.codex-plugin/` would change that, and would break the Codex +> channel, so it stays. + +## MCP + +This package bundles no MCP servers, so there is no `mcp.json`. +Declaring an empty server set would be noise, and declaring a server +without a working transport would be an untruthful claim. + +## Verifying + +The check is machine-runnable and needs no network access or third-party +package: + +```bash +python3 scripts/validate_portable_plugin.py +``` + +It validates the root manifest against the published schema rules (closed field +set, `$schema` constant, name constraints, `author`/`extensions` sub-shapes), +validates `mcp.json` including every closed transport variant and `cwd` form, +checks skill discovery under `skills/`, and asserts identity agreement with the +compatibility manifest. + +## References + +- [Agent Plugins specification](https://agent-plugins.org/specification) +- [Plugin manifest schema](https://agent-plugins.org/schemas/1.0.0/plugin.schema.json) +- [MCP configuration schema](https://agent-plugins.org/schemas/1.0.0/mcp.schema.json) diff --git a/hooks/env_check.py b/hooks/env_check.py index bfc1585..7296185 100755 --- a/hooks/env_check.py +++ b/hooks/env_check.py @@ -9,7 +9,7 @@ import sys from pathlib import Path -PLUGIN_ROOT = Path(__file__).resolve().parents[1] +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) from codeguard import startup_application diff --git a/hooks/gate_lib.py b/hooks/gate_lib.py index 226c9ed..76527b8 100644 --- a/hooks/gate_lib.py +++ b/hooks/gate_lib.py @@ -4,7 +4,7 @@ import sys from pathlib import Path -PLUGIN_ROOT = Path(__file__).resolve().parents[1] +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) from codeguard.baseline import ( diff --git a/hooks/post_tool_lint.py b/hooks/post_tool_lint.py index ddf480e..bb778ad 100644 --- a/hooks/post_tool_lint.py +++ b/hooks/post_tool_lint.py @@ -7,7 +7,7 @@ import sys from pathlib import Path -PLUGIN_ROOT = Path(__file__).resolve().parents[1] +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) from codeguard import save_application diff --git a/hooks/pre_tool_git_guard.py b/hooks/pre_tool_git_guard.py index 69afcff..43a536d 100755 --- a/hooks/pre_tool_git_guard.py +++ b/hooks/pre_tool_git_guard.py @@ -9,7 +9,7 @@ import sys from pathlib import Path -PLUGIN_ROOT = Path(__file__).resolve().parents[1] +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) sys.path.insert(0, str(PLUGIN_ROOT / "hooks")) diff --git a/hooks/stop_summary.py b/hooks/stop_summary.py index b624946..63469ac 100755 --- a/hooks/stop_summary.py +++ b/hooks/stop_summary.py @@ -13,7 +13,7 @@ import sys from pathlib import Path -PLUGIN_ROOT = Path(__file__).resolve().parents[1] # hooks/ 的上级 = 插件根 +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) # hooks/ 的上级 = 插件根 sys.path.insert(0, str(PLUGIN_ROOT / "hooks")) sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) diff --git a/hooks/user_prompt_validator.py b/hooks/user_prompt_validator.py index a4397d8..ae4a3a2 100755 --- a/hooks/user_prompt_validator.py +++ b/hooks/user_prompt_validator.py @@ -8,7 +8,7 @@ import sys from pathlib import Path -PLUGIN_ROOT = Path(__file__).resolve().parents[1] +PLUGIN_ROOT = next((c for c in Path(__file__).resolve().parents if (c / "plugin.json").is_file()), Path(__file__).resolve().parents[1]) sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) sys.path.insert(0, str(PLUGIN_ROOT / "hooks")) diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..924c885 --- /dev/null +++ b/plugin.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "codeguard", + "version": "0.16.1", + "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", + "author": { + "name": "Full Stack Skills / PartMe.AI", + "url": "https://github.com/partme-ai" + }, + "homepage": "https://github.com/partme-ai/partme-codeguard-plugin", + "repository": "https://github.com/partme-ai/partme-codeguard-plugin", + "license": "Apache-2.0", + "keywords": [ + "java", + "rust", + "typescript", + "python", + "javadoc", + "checkstyle", + "clippy", + "eslint", + "ruff", + "pre-commit" + ], + "extensions": { + "com.openai": { + "interface": { + "displayName": "代码规范守卫", + "shortDescription": "Trustworthy code checks and Java impact analysis", + "longDescription": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit. Java planning is read-only; executing project checks runs trusted build/test commands, not a security sandbox.", + "developerName": "Full Stack Skills / PartMe.AI", + "category": "Developer Tools", + "capabilities": [ + "Read", + "Write" + ], + "websiteURL": "https://github.com/partme-ai/partme-codeguard-plugin", + "privacyPolicyURL": "https://github.com/partme-ai/partme-codeguard-plugin/blob/main/PRIVACY.md", + "termsOfServiceURL": "https://github.com/partme-ai/partme-codeguard-plugin/blob/main/TERMS.md", + "defaultPrompt": [ + "Run /check on the current project and fix what fails", + "Initialize codeguard on this repository with /init", + "Analyze Java module impact, then run the project's verification plan" + ], + "brandColor": "#2563EB", + "composerIcon": "./assets/composer-icon.png", + "logo": "./assets/official-logo.png", + "logoDark": "./assets/official-logo.png", + "screenshots": [] + } + }, + "ai.nanoco.nanoclaw": { + "agentName": "代码规范守卫" + } + } +} diff --git a/scripts/check_architecture.py b/scripts/check_architecture.py index afa1ab0..b107bc7 100644 --- a/scripts/check_architecture.py +++ b/scripts/check_architecture.py @@ -25,6 +25,7 @@ "java_project.py": "entry", "run_check.py": "entry", "validate_languages_json.py": "entry", + "validate_portable_plugin.py": "entry", "verdict.py": "compat-shim", "user_config.py": "compat-shim", "run_per_language.py": "compat-shim", diff --git a/scripts/validate_portable_plugin.py b/scripts/validate_portable_plugin.py new file mode 100755 index 0000000..053bf62 --- /dev/null +++ b/scripts/validate_portable_plugin.py @@ -0,0 +1,830 @@ +#!/usr/bin/env python3 +"""Validate this package against the Agent Plugins v1.0.0 specification. + +The Agent Plugins format is a *closed* portable contract: a conformant client +MUST reject a plugin whose root `plugin.json` is missing or invalid, and MUST +NOT discover any of its components. This script is the machine-checkable half +of the claim that the package conforms, so it validates against the published +schema rules directly rather than trusting a hand-maintained checklist. + +Checked here: + * root `plugin.json` -- presence, closed top-level field set, `$schema` + constant, plugin-name constraints, `author` / `extensions` sub-shapes + * root `mcp.json` -- when present: `$schema` constant, closed top-level + field set, version agreement with `plugin.json`, and every server entry + matching exactly one closed transport variant + * `skills/` -- discovery shape (immediate children with SKILL.md) + and Agent Skills frontmatter for each discovered skill + * parity -- identity agreement with the client compatibility + manifest (`.codex-plugin/plugin.json`) when one is shipped + +Pure standard library: no third-party dependency and no network access. + +Usage: + python3 scripts/validate_portable_plugin.py [--root DIR] [--json] +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +# --- Published schema constants (agent-plugins.org 1.0.0) ------------------ + +PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" +MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json" + +# plugin.schema.json: additionalProperties false +PLUGIN_TOP_LEVEL = frozenset( + { + "$schema", + "name", + "version", + "description", + "author", + "homepage", + "repository", + "license", + "keywords", + "extensions", + } +) +PLUGIN_REQUIRED = frozenset({"$schema", "name"}) + +# §5.5 / plugin.schema.json name pattern +NAME_PATTERN = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$") + +# §5.4: the author object may contain only these three string fields. +AUTHOR_ALLOWED = frozenset({"name", "email", "url"}) + +# mcp.schema.json: additionalProperties false +MCP_TOP_LEVEL = frozenset({"$schema", "mcpServers"}) +MCP_REQUIRED = frozenset({"$schema", "mcpServers"}) + +# §7.2.1 cwd forms: plugin-relative, or rooted at the two reserved placeholders. +CWD_PATTERN = re.compile(r"^(?:\./|\$\{PLUGIN_ROOT\}(?:/|$)|\$\{PLUGIN_DATA\}(?:/|$))") + +# §9.2: a plugin may not override the client-provided variables. +RESERVED_ENV = frozenset({"PLUGIN_ROOT", "PLUGIN_DATA"}) + +# §7.2.1: a bundled executable must use a plugin-relative command. +BUNDLED_COMMAND_PATTERN = re.compile(r"^\./[^\s]+$") + +STDIO_FIELDS = frozenset({"type", "command", "args", "env", "cwd"}) +REMOTE_FIELDS = frozenset({"type", "url", "headers"}) + +IDENTITY_FIELDS = ( + "name", + "description", + "author", + "homepage", + "repository", + "license", + "keywords", +) + +# §8.2: client-specific files live in a top-level directory named exactly the +# extension namespace. The Agent Plugins registry clients each own one, and the +# packages mirror their non-portable components into the directories those +# clients actually read. The root copies stay in place for the Codex / ZCode / +# Kimi channels, so the mirrors are checked for drift rather than assumed. +CLIENT_EXTENSION_MIRRORS = { + "com.github.copilot": ("hooks",), # VS Code and GitHub Copilot + "dev.openhands": ("commands", "agents", "hooks"), # OpenHands +} + +# Build noise that must never be mirrored or compared. +_IGNORED_PARTS = {"__pycache__", ".pytest_cache", ".ruff_cache"} +_IGNORED_SUFFIXES = {".pyc", ".pyo"} + + +class Report: + """Collects findings without aborting on the first problem.""" + + def __init__(self) -> None: + self.errors: list[str] = [] + self.notes: list[str] = [] + + def fail(self, where: str, message: str) -> None: + self.errors.append(f"{where}: {message}") + + def note(self, message: str) -> None: + self.notes.append(message) + + +def load_json(path: Path, report: Report, where: str): + if not path.is_file(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + report.fail(where, f"is not readable JSON ({exc})") + return None + + +# --- plugin.json (§5) ------------------------------------------------------ + + +def check_plugin_manifest(root: Path, report: Report) -> dict | None: + where = "plugin.json" + path = root / "plugin.json" + if not path.is_file(): + report.fail( + where, + "missing. A conformant client MUST reject the plugin and MUST NOT " + "discover its components (§5.1, §5.3).", + ) + return None + + manifest = load_json(path, report, where) + if manifest is None: + return None + if not isinstance(manifest, dict): + report.fail(where, f"must be a top-level JSON object, got {type(manifest).__name__}") + return None + + unknown = sorted(set(manifest) - PLUGIN_TOP_LEVEL) + if unknown: + # Non-fatal for a client, but it is a schema violation and a client + # will report and ignore these fields (§5.2). + report.fail( + where, + f"unknown top-level field(s) {unknown}. The schema is closed; " + f"client-specific data belongs under `extensions` (§5.2).", + ) + + missing = sorted(PLUGIN_REQUIRED - set(manifest)) + if missing: + report.fail(where, f"missing required field(s) {missing} (§5.3)") + return None + + schema = manifest["$schema"] + if schema != PLUGIN_SCHEMA: + report.fail( + where, + f"$schema is {schema!r}, expected the canonical identifier " + f"{PLUGIN_SCHEMA!r} (§5.2).", + ) + + name = manifest["name"] + if not isinstance(name, str): + report.fail(where, f"name must be a string, got {type(name).__name__} (§5.3)") + else: + if not 1 <= len(name) <= 64: + report.fail(where, f"name is {len(name)} characters, must be 1-64 (§5.5)") + if not NAME_PATTERN.match(name): + report.fail( + where, + f"name {name!r} violates §5.5 (lowercase alphanumerics, '-', '.'; " + f"must start and end alphanumeric; no '--' or '..').", + ) + + # §5.4: every metadata field, when present, MUST have its declared JSON type. + # An explicit `null` is a type violation, not absence — detect it with `in`. + if "version" in manifest and not isinstance(manifest["version"], str): + report.fail( + where, + f"version must be a string, got {type(manifest['version']).__name__} (§5.4)", + ) + + if "keywords" in manifest and ( + not isinstance(manifest["keywords"], list) + or not all(isinstance(k, str) for k in manifest["keywords"]) + ): + report.fail(where, "keywords must be an array of strings (§5.4)") + + for scalar in ("description", "homepage", "repository", "license"): + if scalar in manifest and not isinstance(manifest[scalar], str): + report.fail( + where, + f"{scalar} must be a string, got {type(manifest[scalar]).__name__} (§5.4)", + ) + + if "author" in manifest: + author = manifest["author"] + if not isinstance(author, dict): + report.fail( + where, + f"author must be an object, got {type(author).__name__} (§5.4)", + ) + else: + extra = sorted(set(author) - AUTHOR_ALLOWED) + if extra: + report.fail( + where, + f"author has field(s) {extra}; the author object allows only " + f"{sorted(AUTHOR_ALLOWED)} and is otherwise invalid (§5.4).", + ) + for key, value in author.items(): + if not isinstance(value, str): + report.fail( + where, + f"author.{key} must be a string, got {type(value).__name__} (§5.4)", + ) + + if "extensions" in manifest: + extensions = manifest["extensions"] + if not isinstance(extensions, dict): + # Non-fatal: the client reports and ignores it (§8.1). + report.fail( + where, + "extensions must be an object keyed by client extension namespace (§8.1).", + ) + else: + for namespace, value in extensions.items(): + if not isinstance(value, dict): + report.fail( + where, + f"extensions[{namespace!r}] must be an object (§8.1).", + ) + elif not _looks_like_namespace(namespace): + report.note( + f"extensions key {namespace!r} is not a reverse-domain " + f"namespace (§8 suggests a domain the client controls)." + ) + + return manifest + + +def _looks_like_namespace(namespace: str) -> bool: + return bool(re.match(r"^[a-z0-9]+(?:\.[a-z0-9-]+)+$", namespace)) + + +# --- mcp.json (§7.2) ------------------------------------------------------- + + +def check_mcp_manifest(root: Path, plugin: dict | None, report: Report) -> None: + path = root / "mcp.json" + where = "mcp.json" + if not path.is_file(): + return + + doc = load_json(path, report, where) + if doc is None: + return + if not isinstance(doc, dict): + report.fail(where, f"must be a top-level JSON object, got {type(doc).__name__}") + return + + unknown = sorted(set(doc) - MCP_TOP_LEVEL) + if unknown: + report.fail( + where, + f"unknown top-level field(s) {unknown}; mcp.json allows only " + f"{sorted(MCP_TOP_LEVEL)} and an invalid document disables MCP for " + f"the whole plugin (§7.2.1, §7.2.2).", + ) + missing = sorted(MCP_REQUIRED - set(doc)) + if missing: + report.fail(where, f"missing required field(s) {missing} (§7.2.1)") + return + + if doc["$schema"] != MCP_SCHEMA: + report.fail( + where, + f"$schema is {doc['$schema']!r}, expected {MCP_SCHEMA!r} (§7.2.1).", + ) + + # §10.1: the mcp.json version must match the version plugin.json declares. + if plugin is not None: + p_schema = plugin.get("$schema") + m_schema = doc["$schema"] + if isinstance(p_schema, str) and isinstance(m_schema, str): + p_version = p_schema.rsplit("/", 2)[-2] + m_version = m_schema.rsplit("/", 2)[-2] + if p_version != m_version: + report.fail( + where, + f"targets Agent Plugins {m_version} but plugin.json targets " + f"{p_version}; a mismatch invalidates this document (§10.1).", + ) + + servers = doc["mcpServers"] + if not isinstance(servers, dict): + report.fail(where, f"mcpServers must be an object, got {type(servers).__name__}") + return + + for server_name, entry in servers.items(): + check_server_entry(f"{where} mcpServers[{server_name!r}]", entry, root, report) + + +def check_server_entry(where: str, entry, root: Path, report: Report) -> None: + if not isinstance(entry, dict): + report.fail(where, f"must be an object, got {type(entry).__name__}") + return + + kind = entry.get("type") + if kind is None: + report.fail( + where, + "missing required `type`; each server entry MUST match exactly one " + "closed transport variant (§7.2.1).", + ) + return + + if kind == "stdio": + allowed, required = STDIO_FIELDS, {"type", "command"} + elif kind in ("streamable-http", "sse"): + allowed, required = REMOTE_FIELDS, {"type", "url"} + else: + report.fail( + where, + f"unknown type {kind!r}; valid values are 'stdio', " + f"'streamable-http', 'sse' (§7.2.1).", + ) + return + + extra = sorted(set(entry) - allowed) + if extra: + report.fail( + where, + f"unknown field(s) {extra} for the {kind!r} variant; the variant is " + f"closed, so this makes the entry invalid (§7.2.1).", + ) + missing = sorted(required - set(entry)) + if missing: + report.fail(where, f"missing required field(s) {missing} for {kind!r} (§7.2.1)") + return + + if kind == "stdio": + _check_stdio(where, entry, root, report) + else: + _check_remote(where, entry, report) + + +def _check_stdio(where: str, entry: dict, root: Path, report: Report) -> None: + command = entry["command"] + if not isinstance(command, str) or not command: + report.fail(where, "command must be a non-empty string (§7.2.1)") + elif any(ch.isspace() for ch in command): + # §7.2.1: one executable token. Any whitespace — space, tab, newline — + # means it is a shell command string, not a token. + report.fail( + where, + f"command {command!r} must be a single executable token, not a shell " + f"command string (§7.2.1).", + ) + elif command.startswith("./"): + target = (root / command).resolve() + try: + target.relative_to(root.resolve()) + except ValueError: + report.fail( + where, + f"command {command!r} escapes the plugin root (§4.1).", + ) + elif "/" in command: + # §7.2.1: command is either a bare executable name (resolved by platform + # search) or a plugin-relative path beginning with ./ . An absolute path + # or a ../ traversal is neither and must not pass as PATH-resolved. + report.fail( + where, + f"command {command!r} must be a bare executable name or a plugin-relative " + f"path beginning with './' (§7.2.1).", + ) + + args = entry.get("args") + if args is not None and ( + not isinstance(args, list) or not all(isinstance(a, str) for a in args) + ): + report.fail(where, "args must be an array of strings (§7.2.1)") + + env = entry.get("env") + if env is not None: + if not isinstance(env, dict): + report.fail(where, "env must be an object of strings (§7.2.1)") + else: + reserved = sorted(set(env) & RESERVED_ENV) + if reserved: + report.fail( + where, + f"env declares reserved variable(s) {reserved}; a plugin MUST NOT " + f"override them and the entry is invalid (§9.2).", + ) + for key, value in env.items(): + if not isinstance(value, str): + report.fail(where, f"env[{key!r}] must be a string (§9.2)") + + cwd = entry.get("cwd") + if cwd is not None: + if not isinstance(cwd, str): + report.fail(where, "cwd must be a string (§7.2.1)") + elif not CWD_PATTERN.match(cwd): + report.fail( + where, + f"cwd {cwd!r} must be plugin-relative (starting with './') or rooted " + f"at ${{PLUGIN_ROOT}} / ${{PLUGIN_DATA}} (§7.2.1).", + ) + elif cwd.startswith("./"): + target = (root / cwd).resolve() + try: + target.relative_to(root.resolve()) + except ValueError: + report.fail(where, f"cwd {cwd!r} escapes the plugin root (§4.1)") + else: + # §9.2 + §4.1: expand the declared placeholder and verify containment. + # `${PLUGIN_ROOT}/../../outside` must not pass as conformant. + if cwd.startswith("${PLUGIN_DATA}"): + base = _plugin_data_dir(root) + expanded = base / cwd[len("${PLUGIN_DATA}"):].lstrip("/") + else: + base = root.resolve() + expanded = base / cwd[len("${PLUGIN_ROOT}"):].lstrip("/") + try: + expanded.resolve().relative_to(base.resolve()) + except ValueError: + report.fail( + where, + f"cwd {cwd!r} escapes its declared root after placeholder " + f"expansion (§4.1)", + ) + + +def _plugin_data_dir(root: Path) -> Path: + """The client-managed PLUGIN_DATA location for this installed instance. + + Mirrors the reference layout clients use: a dedicated writable directory + that persists across updates. Validation only needs a stable anchor for + containment checks.""" + return root.resolve().parent / "data" / root.name + + +def _check_remote(where: str, entry: dict, report: Report) -> None: + url = entry["url"] + if not isinstance(url, str) or not url: + report.fail(where, "url must be a non-empty string (§7.2.1)") + return + if "${" in url: + report.fail( + where, + f"url {url!r} must not contain placeholders; clients MUST NOT perform " + f"placeholder or environment-variable expansion in url (§7.2.1).", + ) + # §7.2.1: an absolute HTTP or HTTPS URL. Parse, don't prefix-match — a bare + # `https://` or a URL with an empty host or embedded whitespace must not pass. + from urllib.parse import urlsplit + + if any(ch.isspace() for ch in url): + report.fail(where, f"url {url!r} must not contain whitespace (§7.2.1)") + else: + parts = urlsplit(url) + if parts.scheme not in ("http", "https") or not parts.netloc: + report.fail( + where, + f"url {url!r} must be an absolute HTTP or HTTPS URL with a host (§7.2.1)", + ) + elif parts.fragment: + report.fail(where, f"url {url!r} must not contain a fragment (§7.2.1)") + + headers = entry.get("headers") + if headers is not None: + if not isinstance(headers, dict): + report.fail(where, "headers must be an object of strings (§7.2.1)") + else: + seen: dict[str, str] = {} + for key in headers: + if not isinstance(key, str): + report.fail(where, f"header name {key!r} must be a string (§7.2.1)") + continue + lowered = key.lower() + if lowered in seen: + report.fail( + where, + f"header {key!r} duplicates {seen[lowered]!r} under different " + f"casing; header names are case-insensitive (§7.2.1).", + ) + seen[lowered] = key + if "${" in key: + report.fail( + where, + f"header name {key!r} must not contain placeholders (§7.2.1).", + ) + for key, value in headers.items(): + if not isinstance(value, str): + report.fail(where, f"header {key!r} value must be a string (§7.2.1)") + elif "${" in value: + report.fail( + where, + f"header {key!r} value must not contain placeholders; header " + f"values are literal visible package data (§7.2.1).", + ) + + +# --- skills/ (§6.1, §7.1) -------------------------------------------------- + +# Agent Skills identifiers: lowercase alphanumerics and hyphens (kebab-case), +# 1-64 characters. This is deliberately stricter than the plugin-name grammar +# (§5.5) — skill names do not use periods. +FRONTMATTER_NAME_RE = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") + + +def check_skills(root: Path, report: Report) -> tuple[int, list[str]]: + where = "skills/" + skills_dir = root / "skills" + if not skills_dir.exists(): + # §6.2: a missing fixed location is not an error. + return 0, [] + if not skills_dir.is_dir(): + report.fail( + where, + "exists but is not a directory; the skills component type is invalid " + "without affecting other component types (§6.2).", + ) + return 0, [] + + root_resolved = root.resolve() + discovered: list[str] = [] + for child in sorted(skills_dir.iterdir()): + # Dot-prefixed entries are host/tool metadata, never skills. + if child.name.startswith("."): + continue + if not child.is_dir(): + # Stray files are not skills; §7.1 only looks at immediate child dirs. + report.note(f"skills/{child.name} is a file, not a skill directory") + continue + # §4.1: symlinks may resolve within the plugin root, but a skill whose + # directory resolves outside it must be skipped, not validated. + if child.is_symlink(): + try: + child.resolve().relative_to(root_resolved) + except ValueError: + report.fail( + where, + f"skills/{child.name} is a symlink escaping the plugin root (§4.1)", + ) + continue + skill_md = child / "SKILL.md" + if not skill_md.is_file(): + report.note(f"skills/{child.name}/ has no SKILL.md and is not a skill") + continue + try: + skill_md.resolve().relative_to(root_resolved) + except ValueError: + report.fail( + f"skills/{child.name}/SKILL.md", + "resolves outside the plugin root (§4.1)", + ) + continue + discovered.append(child.name) + check_skill_md(f"skills/{child.name}/SKILL.md", child, skill_md, report) + return len(discovered), discovered + + +def check_skill_md(where: str, skill_dir: Path, path: Path, report: Report) -> None: + text = path.read_text(encoding="utf-8", errors="replace") + # The opening delimiter must be a line that is exactly `---`; a prefix or + # substring check would accept `---not-a-fence`. + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + report.fail(where, "missing YAML frontmatter; required by the Agent Skills spec (§7.1)") + return + end = -1 + for index in range(1, len(lines)): + if lines[index].strip() == "---": + end = index + break + if end == -1: + report.fail(where, "unterminated YAML frontmatter") + return + + # Extract `key: value` pairs. A value that is empty or comment-only + # (`description: # omitted`) is not a value; treat it as absent so the + # required-field checks fire instead of silently accepting a comment. + fields: dict[str, str] = {} + for line in lines[1:end]: + match = re.match(r"^([A-Za-z_-]+):\s*(.*)$", line) + if match: + raw = match.group(2).strip() + # a value that is empty or comment-only (`description: # omitted`) + # is not a value; a trailing ` # comment` is stripped too + raw = "" if raw.startswith("#") else re.sub(r"\s+#.*$", "", raw).strip() + fields[match.group(1)] = raw.strip('"').strip("'") + + name = fields.get("name", "") + if not name: + report.fail(where, "frontmatter is missing `name`") + else: + if not 1 <= len(name) <= 64: + report.fail(where, f"frontmatter name is {len(name)} characters, must be 1-64") + if not FRONTMATTER_NAME_RE.match(name): + report.fail( + where, + f"frontmatter name {name!r} is not a valid skill identifier " + f"(lowercase alphanumerics and hyphens only)", + ) + if name != skill_dir.name: + report.fail( + where, + f"frontmatter name {name!r} does not match its directory " + f"{skill_dir.name!r}; discovery is by directory (§7.1).", + ) + description = fields.get("description", "") + if not description: + report.fail(where, "frontmatter is missing `description`") + elif len(description) > 1024: + # Agent Skills limits `description` to 1,024 characters; a client may + # reject a skill that the validator accepted. + report.fail( + where, + f"frontmatter description is {len(description)} characters, must be at most 1024", + ) + + +# --- client extension directories (§8.2) ---------------------------------- + + +def _content_files(directory: Path) -> dict[str, Path]: + """Map relative path -> file for every real content file under `directory`. + + Dot-prefixed paths are skipped along with build caches — mirrors and scans + must never treat `.DS_Store`, `.gitignore` or similar as content.""" + found: dict[str, Path] = {} + if not directory.is_dir(): + return found + for path in directory.rglob("*"): + if not path.is_file(): + continue + relative = path.relative_to(directory) + if any(part.startswith(".") for part in relative.parts): + continue + if any(part in _IGNORED_PARTS for part in path.parts): + continue + if path.suffix in _IGNORED_SUFFIXES: + continue + found[str(relative)] = path + return found + + +def check_client_extensions(root: Path, report: Report) -> None: + for namespace, sources in CLIENT_EXTENSION_MIRRORS.items(): + namespace_dir = root / namespace + if not namespace_dir.exists(): + continue + if not namespace_dir.is_dir(): + report.fail( + namespace, + "exists but is not a directory; an extension directory is a " + "top-level directory named exactly the namespace (§8.2).", + ) + continue + if not _looks_like_namespace(namespace): + report.fail( + namespace, + "is not a reverse-domain namespace (§8 suggests a domain the " + "owning client controls).", + ) + + for source in sources: + mirror = namespace_dir / source + source_dir = root / source + mirrored = _content_files(mirror) + original = _content_files(source_dir) + + if mirrored and not original: + report.fail( + f"{namespace}/{source}", + f"mirrors a root `{source}/` that no longer exists; remove the " + f"stale extension directory (§8.2).", + ) + continue + + for relative in sorted(set(original) - set(mirrored)): + report.fail( + f"{namespace}/{source}/{relative}", + f"is missing from the client extension mirror of `{source}/`; " + f"refresh the `{namespace}/{source}/` mirror from the root copy (§8.2).", + ) + for relative in sorted(set(mirrored) - set(original)): + report.fail( + f"{namespace}/{source}/{relative}", + f"has no counterpart in the root `{source}/`; remove the stale " + f"mirror file (§8.2).", + ) + for relative in sorted(set(mirrored) & set(original)): + if mirrored[relative].read_bytes() != original[relative].read_bytes(): + report.fail( + f"{namespace}/{source}/{relative}", + f"has drifted from the root `{source}/` copy; refresh the " + f"mirror from the root copy (§8.2).", + ) + + if mirrored: + report.note( + f"{namespace}/{source}/ mirrors {len(mirrored)} file(s) from " + f"the root {source}/" + ) + + +# --- parity with the client compatibility manifest ------------------------- + + +def check_parity(root: Path, plugin: dict | None, report: Report) -> None: + compat = root / ".codex-plugin" / "plugin.json" + if plugin is None or not compat.is_file(): + return + where = ".codex-plugin/plugin.json" + other = load_json(compat, report, where) + if not isinstance(other, dict): + return + + for field in IDENTITY_FIELDS: + if field in other and plugin.get(field) != other[field]: + report.fail( + where, + f"{field} disagrees with the portable manifest " + f"({other[field]!r} vs {plugin.get(field)!r}); the two manifests must " + f"describe one plugin.", + ) + + def base(value): + return value.split("+", 1)[0] if isinstance(value, str) else value + + if "version" in other and base(plugin.get("version")) != base(other["version"]): + report.fail( + where, + f"version {other['version']!r} does not share the portable base version " + f"{plugin.get('version')!r}.", + ) + + interface = other.get("interface") + if isinstance(interface, dict): + extensions = plugin.get("extensions") + openai = extensions.get("com.openai") if isinstance(extensions, dict) else None + carried = openai.get("interface") if isinstance(openai, dict) else None + if carried is None: + report.fail( + where, + "declares an `interface` block that the portable manifest does not " + "carry under extensions['com.openai'] (§8).", + ) + elif carried != interface: + report.fail( + where, + "the `interface` block has drifted from " + "extensions['com.openai']['interface'].", + ) + + +# --- entry point ----------------------------------------------------------- + + +def validate(root: Path) -> Report: + report = Report() + if not root.is_dir(): + report.fail("root", f"{root} is not a directory") + return report + plugin = check_plugin_manifest(root, report) + check_mcp_manifest(root, plugin, report) + count, _ = check_skills(root, report) + if count: + report.note(f"{count} skill(s) discovered under skills/") + check_client_extensions(root, report) + check_parity(root, plugin, report) + return report + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).resolve().parent.parent, + help="plugin package root (default: the repository containing this script)", + ) + parser.add_argument("--json", action="store_true", help="emit a machine-readable report") + args = parser.parse_args(argv) + + report = validate(args.root.resolve()) + + if args.json: + json.dump( + { + "root": str(args.root.resolve()), + "ok": not report.errors, + "errors": report.errors, + "notes": report.notes, + }, + sys.stdout, + indent=2, + ensure_ascii=False, + ) + sys.stdout.write("\n") + return 1 if report.errors else 0 + + print(f"Agent Plugins v1.0.0 validation: {args.root.resolve()}") + for note in report.notes: + print(f" note {note}") + for error in report.errors: + print(f" FAIL {error}") + if report.errors: + print(f"\n{len(report.errors)} problem(s) found.") + return 1 + print("\nPASS - package conforms to Agent Plugins v1.0.0.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))