diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 1d286c7..faa915b 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -9,20 +9,20 @@ "source": { "source": "url", "url": "https://github.com/partme-ai/partme-codeguard-plugin.git", - "ref": "v0.14.8" + "ref": "v0.14.9" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.14.8", + "version": "0.14.9", "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.", - "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.8/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.9/assets/official-logo.png", "interface": { "displayName": "代码规范守卫", "shortDescription": "Trustworthy code checks and Java impact analysis", - "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.8/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.9/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index bd8d88a..9d8004f 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.8+codex.20260923", + "version": "0.14.9+codex.20260923", "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", diff --git a/.zcode-plugin/plugin.json b/.zcode-plugin/plugin.json index 7835af6..76249e4 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.14.8", + "version": "0.14.9", "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.", "description_i18n": { "en": "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.", diff --git a/README.md b/README.md index fe3c35d..18cd418 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ python3 scripts/run_check.py --mcp /path/to/project | list_languages | Registry ids and display names | | analyze_java_impact | Read-only plan; accepts path and optional changed array | -Output logs default to /out/.codeguard-last.log; CLI --quiet disables log writing. A failed multi-command check logs output from every executed check. Diagnostic logs are replaced atomically with owner-only file permissions on POSIX; a symlinked output directory disables log writing without changing the check verdict. The MCP execution trace reports phase, sequence, program, exit/failure and output lengths; it does not echo argv, environment overrides or captured output. Local logs can contain sensitive checker output: keep them out of version control. MCP auto_fix does not write when a Git scope cannot be established. +Output logs default to /out/.codeguard-last.log; CLI --quiet disables log writing. A failed multi-command check logs output from every executed check. Diagnostic logs are replaced atomically with owner-only file permissions on POSIX; a symlinked output directory disables log writing without changing the check verdict. The MCP execution trace reports phase, sequence, program, exit/failure and output lengths; it does not echo argv, environment overrides or captured output. MCP auto_fix also keeps formatter argv/stderr out of its JSON result; available formatter diagnostics are written to a private /out/.codeguard-fix.log and returned by path. Local logs can contain sensitive checker output: keep them out of version control. MCP auto_fix does not write when a Git scope cannot be established. ## Configuration and coverage diff --git a/README.zh-CN.md b/README.zh-CN.md index d3931d3..6f4d842 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -112,7 +112,7 @@ python3 scripts/run_check.py --mcp /path/to/project | list_languages | 注册表语言标识和名称 | | analyze_java_impact | 只读计划,接受 path 和可选 changed 数组 | -日志默认在 /out/.codeguard-last.log;CLI --quiet 关闭写日志。多命令检查失败时,日志保存每条已执行检查的完整输出。诊断日志以原子替换写入,POSIX 上仅当前用户可读写;输出目录是符号链接时不写日志,但检查结论不变。MCP 执行轨迹只返回阶段、序号、程序名、退出/故障及输出长度,不回显 argv、环境覆盖或检查器输出。本地日志可能含敏感文本,应排除出版本控制。MCP auto_fix 无法确定 Git 范围时不会写入。 +日志默认在 /out/.codeguard-last.log;CLI --quiet 关闭写日志。多命令检查失败时,日志保存每条已执行检查的完整输出。诊断日志以原子替换写入,POSIX 上仅当前用户可读写;输出目录是符号链接时不写日志,但检查结论不变。MCP 执行轨迹只返回阶段、序号、程序名、退出/故障及输出长度,不回显 argv、环境覆盖或检查器输出。MCP auto_fix 的 JSON 结果也不回显 formatter 原始参数或 stderr;可用的修复诊断写到私有 /out/.codeguard-fix.log 并返回路径。本地日志可能含敏感文本,应排除出版本控制。MCP auto_fix 无法确定 Git 范围时不会写入。 ## 配置与覆盖 diff --git a/docs/current-architecture.md b/docs/current-architecture.md index b866a9b..ac02e25 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -27,7 +27,7 @@ flowchart TD ``` `execution` 只记录进程证据,不把非零退出自动认定为代码违规。`verdict`、CVE 与 Dockerfile 报告解析决定 PASS、FAIL 或 UNVERIFIED;入口仅按各自协议呈现和聚合退出码。Git 提交检查使用 index 或预测暂存快照,推送检查使用 HEAD;保存钩子只给反馈。计划、未执行和未验证不能宣传为通过。 -`language_check` 将 `PlanExecution` 的实际命令依序映射为应用层有界 `execution_trace`(检查、修复、复检阶段);`check_application` 将其进一步压成 MCP 安全元数据,不回显原始 argv、环境覆盖或捕获输出。失败日志保留各已执行检查的完整输出,可能含敏感文本,应排除出版本控制。项目日志和门禁截断日志共用 `storage.write_private_text` 的私有原子落盘;预置日志文件链接不被跟随,默认 `out` 目录为链接或不可写时不返回虚假日志路径,也不覆盖检查结论。终止命令仍决定既有状态、退出码和旧字段;未运行的计划命令不进入证据。 +`language_check` 将 `PlanExecution` 的实际命令依序映射为应用层有界 `execution_trace`(检查、修复、复检阶段);`check_application` 将其进一步压成 MCP 安全元数据,不回显原始 argv、环境覆盖或捕获输出。`auto_fix` 的 `fix_results` 也只公开状态与安全元数据;formatter stderr 尾部写入私有 `.codeguard-fix.log` 后只公开路径,日志不可用时不退回公开原文。失败日志保留各已执行检查的完整输出,可能含敏感文本,应排除出版本控制。项目日志和门禁截断日志共用 `storage.write_private_text` 的私有原子落盘;预置日志文件链接不被跟随,默认 `out` 目录为链接或不可写时不返回虚假日志路径,也不覆盖检查结论。终止命令仍决定既有状态、退出码和旧字段;未运行的计划命令不进入证据。 Git 命令的仓库定位与拟暂存解析共用入口传入的 cwd;显式 `cd`/`git -C` 无法绑定 Git 工作树时,硬门禁给出目标未验证并阻断,不退回到调用者仓或无关子仓。未给出显式目标且调用目录非仓时仍保留 workspace 子仓兜底。静态命令解析不等于完整 Shell 执行模拟。 一条命令链中的 Git 副作用先绑定为仓库与操作的有序对,再按仓库聚合检查面:纯 push 不读取该仓未提交的 index,同仓 commit→push 则保留已有 HEAD 与拟提交内容的并集。仓库级、内联及链式 `skipGate` 均按目标仓与操作顺序生效;已存在的仓库配置也可被同链先行 `git config --unset` 取消。链式设置跨 `||`、`;` 或换行时无法证明生效,门禁要求拆分命令;写入其它文件的 config 设置也不构成本仓豁免。语法、仓库归属、间接脚本与拟暂存分析共用引号/转义感知切分;参数文本中的控制符不能合成豁免。解析能力仍限于受支持的静态 Shell 形态。 一层 `bash`/`sh`/`zsh` 的 `-c`(含组合短选项)及可读脚本现在携带调用目录与正文进入同一 Git 操作计划;脚本内的 `git add` 可进入拟暂存范围,外层和内层 Git 操作按仓分别检查。识别到无法建模的间接 Git 操作、脚本内 `skipGate` 配置变更或跨解释器的暂存/提交关系时,明确以 Git 意图 UNVERIFIED 阻断,不借用调用者仓的检查结果。动态脚本、任意控制流及 Python/Node 中构造的 subprocess 仍未建模。 @@ -39,9 +39,9 @@ Git 命令的仓库定位与拟暂存解析共用入口传入的 cwd;显式 `c |---|---|---| | `bin/codeguard`、`scripts/run_check.py`、`scripts/fix.py` | CLI 参数、输出与 MCP stdio 注册 | 不复制扫描器或判定逻辑 | | `hooks/` | 宿主 JSON、会话事件、通知与退出协议 | 五类 Hook 将检查编排委托应用服务;入口保留旧导入兼容面及 fail-open 协议 | -| `scripts/codeguard/check_application.py`、`language_check.py` | 语言选择、检查与修复请求、MCP 工具分发 | 不导入 MCP SDK;旧 `run_per_language.py` 仅再导出 | +| `scripts/codeguard/check_application.py`、`language_check.py` | 语言选择、检查与修复请求、MCP 工具分发 | 不导入 MCP SDK;修复结果公开投影不复制内部原始命令/输出;旧 `run_per_language.py` 仅再导出 | | `startup_application.py`、`session_application.py`、`prompt_application.py`、`git_guard_application.py`、`save_application.py` | 五类 Hook 的盘点、状态消费、软提示、Git 守卫及保存反馈编排 | 返回结构化结果,不打印或调用宿主通知;Hook 控制输出、通知与 fail-open。Stop、软提示与保存反馈在 stdout 刷新后确认状态;Git 已知拦截在输出故障时仍拦截但不缓存。并发或交付重试可能重复反馈,不得丢掉未交付记录 | -| `gate.py`、`gate_checks.py`、`repository_policy.py`、`baseline.py` | Git 门禁、单语言结果、安全规则及有证据的存量比较 | 只有准确基线失败且逐条诊断的文件归属、内容及次数覆盖当前结果才可豁免;基线临时路径映射回被检查文件 | +| `gate.py`、`gate_checks.py`、`repository_policy.py`、`baseline.py` | Git 门禁、单语言结果、安全规则及有证据的存量比较 | 单个语言 worker 异常只标该语言 UNVERIFIED,保留其它结论;安全路径快照失败明确报告未验证,不能抹掉已确认拦截。只有准确基线失败且逐条诊断的文件归属、内容及次数覆盖当前结果才可豁免 | | `java_build.py`、`java_impact.py`、`java_planning.py` 等 | 构建读取、纯影响闭包、命令选择与环境观察 | 默认保留跳过测试的行为;复杂构建保守扩大检查范围 | | `cve_reports.py`、`cve_policy.py`、`cve_scanners.py`、`cve.py` | 漏洞报告、阈值、进程适配与复扫编排 | 无有效结构化报告就没有安全通过结论 | | `dockerfile_reports.py`、`dockerfile.py` | hadolint/Trivy 结构化证据与逐文件扫描 | `dockerfile_security.py` 只解析参数和呈现报告 | diff --git a/hooks/__protocol__.md b/hooks/__protocol__.md index 8bdb4c0..7fdf605 100644 --- a/hooks/__protocol__.md +++ b/hooks/__protocol__.md @@ -109,6 +109,9 @@ if __name__ == "__main__": **禁止**因工具未装、项目未接入、检查超时等 UNVERIFIED 状态 exit 2;保留 fail-open, 但 MUST 明确未验证,不能输出“全部通过”。没有适用改动是 SKIPPED,不等于工具故障。 +单个并行语言检查出现内部异常时,只将该语言标为 UNVERIFIED,其余语言的已知失败仍拦截。 +Git 安全路径观察无法读取拟入库路径时,以 JSON additionalContext 说明未验证;若已有已知失败, +该失败仍 exit 2,不能因后续安全观察故障被顶层 fail-open 清空。 退出码按工具解释,不能全局把 2 当崩溃(例如 pylint 的 2 是违规)。 **取消无基线存量归因**:报错在未修改文件上也可能是本次 API 变化造成的,不能凭路径放行。 diff --git a/kimi.plugin.json b/kimi.plugin.json index f856f29..1233ea8 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.8", + "version": "0.14.9", "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" diff --git a/openspec/changes/refactor-codeguard-architecture/design.md b/openspec/changes/refactor-codeguard-architecture/design.md index 59479ec..ec0bfa2 100644 --- a/openspec/changes/refactor-codeguard-architecture/design.md +++ b/openspec/changes/refactor-codeguard-architecture/design.md @@ -50,7 +50,7 @@ flowchart TD 不可变执行结果记录 argv、cwd、退出码、stdout、stderr、启动/超时故障。执行器不把 rc=1 判断成代码违规、不跑安装、不隐式 shell。`lint_verdict` 和 CVE 报告解析负责各工具语义。保留旧 tuple 包装以兼容已有消费者,但真实执行逻辑仅一份。 -超时保留部分输出;使用 UTF-8 替换无法解码的字节,防止 decode error 把整个门禁变成未知异常。缺命令=127、超时=124、其他启动 OS 错误=126;与已有 UNVERIFIED 语义一致。非预期程序错误仍向上暴露,不能用 except Exception 将开发缺陷吞成正常结果。 +超时保留部分输出;使用 UTF-8 替换无法解码的字节,防止 decode error 把整个门禁变成未知异常。缺命令=127、超时=124、其他启动 OS 错误=126;与已有 UNVERIFIED 语义一致。执行器不将非预期程序错误吞成正常进程结果;并行门禁在每个语言任务的应用边界把未预期异常显式标为 UNVERIFIED,继续保留其它语言已确认的结论,且不把异常原文注入宿主。 ### 3. 策略差异必须显式,不能强行拉平 @@ -68,6 +68,8 @@ flowchart TD 本轮审计发现 CLI/MCP 与 Git 门禁均直接写原始诊断日志:前者位于项目 `out`,后者使用可预测的临时文件名。保留各自的日志路径和呈现职责,但由 `storage` 的统一私有原子文本写入负责落盘;应用不得再用普通 `write_text` 跟随预置链接或留下默认可读权限。日志故障只影响日志证据的可用性,不得覆盖检查结论;这不是完整的对抗性文件系统沙箱。 +MCP 的 `auto_fix` 还必须在应用层单独投影公开结果:`run_fix` 保留执行所需的原始命令与 stderr,但 JSON 只允许修复状态、退出码及收敛后的执行元数据。formatter 的 stderr 尾部写入项目 `out/.codeguard-fix.log`,成功落盘才公开私有日志路径;日志不可写时不回退输出原文。此边界不修改 CLI 的本地诊断契约,也不把私有日志路径当成宿主已读取的证明。 + ### 5. 命令语法、仓库观察与路径政策分离 `git_syntax` 只解释已支持的静态命令形态,不查文件、不起进程;`git_context` 负责仓库探测、读取一层解释器脚本和映射提交面,仓库探测复用执行内核。`PreToolUse` 入口保留宿主 JSON、绕过审计、门禁调用及报告输出,旧 Python helper 导入继续可用。间接脚本读取只保留一个实现,主入口不再重复读取脚本来判定模式。 @@ -140,8 +142,10 @@ PostToolUse 在修复后重新捕获输入、复检后再决定是否可去重 ```mermaid flowchart TD - Hook[Hook 协议入口] --> Compat[gate_lib 兼容导出] - Compat --> Gate[gate 应用服务] + Hook[PreToolUse 协议入口] --> GitApp[git_guard_application] + Hook -. 旧 Python 导入 .-> Compat[gate_lib 兼容导出] + GitApp --> Gate[gate 应用服务] + GitApp --> Safety[repository_policy 路径安全] Gate --> Snapshot[准确 Git 快照 / 观察内容面] Gate --> Checks[gate_checks 独立语言检查] Checks --> Plan[共享计划与执行] @@ -149,10 +153,11 @@ flowchart TD Checks --> Outcome[不可变 GateOutcome 与 GateDecision] Outcome --> Merge[调用线程保序汇总] Merge --> Audit[原 worktree + 会话归属审计] - Merge --> Report[reporting 反馈适配] - Compat --> Safety[repository_policy 路径安全与豁免] + GitApp --> Report[reporting 反馈适配] ``` +语言 worker 的故障隔离只在 `gate` 的任务边界:每个输入语言仍恰好产生一条独立结果;异常产生 UNVERIFIED 备注,不授予 PASS、豁免或软缓存,并且其余 worker 的失败及审计不丢失。Git 安全路径扫描的 `SnapshotError` 属可预期的观察失败,由 `git_guard_application` 转为宿主可见的未验证上下文;若此前已有真实 lint 违规,仍保持 exit 2。没有已确认拦截时沿用 Hook 既有的未验证放行,不把这项隔离误称为 fail-closed。 + 这不仅改变文件位置:并发 worker 不再共享备注列表,也不依赖 ContextVar 隐式跨线程传播;审计在 调用线程记录原 worktree、实际 execution_root 与 argv。基线工具或当前结果为未知时不进入豁免, 避免文本相似覆盖执行失败。逐条基线诊断比较保留文件归属、描述与出现次数,只忽略同文件的 diff --git a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md index d92750e..3919b38 100644 --- a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md +++ b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md @@ -46,6 +46,7 @@ 检查计划 MUST 显式区分 repo、delta 与 save,保存物化后的命令、工作目录和逐命令环境覆盖。执行 MUST 按序进行,首个非零结果终止普通检查批次,保留已执行证据;未执行的命令不得计为成功。空计划不得生成 PASS。Java 原生计划给出的环境覆盖 MUST 在子进程中生效,不改变宿主环境。修复后的检查 MUST 复用原计划,不重新扩大范围。Git 门禁的逐文件基线豁免是独立策略,不由通用执行器决定。 语言检查与修复的应用结果 MUST 保留实际运行的每条命令身份、目录、退出码、故障标识和有界输出摘要;MCP 适配不得只保留终止命令,但其公开执行轨迹 MUST 只包含阶段、序号、程序名、退出码、故障标识和输出长度,不得新增暴露原始 argv、命令环境覆盖或任意检查器输出。失败日志 MUST 保留同一检查批次中此前已执行命令的完整输出,不能只记录最后一条。证据不得把尚未运行的计划命令写成已执行。 +MCP `auto_fix` 的 `fix_results` 公开面 MUST 同样只保留状态、退出码、安全元数据和可用诊断路径,不得因复制内部修复字典而回显 formatter 原始 argv 或 stderr。实际修复诊断仍应在可写时以私有原子日志保存;日志写入失败不得改变修复和复检结论,也不得回退为公开原文。 #### Scenario: Java commands use the selected JDK - **WHEN** Java 项目计划选择 JAVA_HOME,且声明多条权威检查命令 @@ -59,6 +60,10 @@ - **WHEN** 权威命令的 argv 含凭据文本,且检查器输出也包含该文本 - **THEN** 真实子进程仍收到原始 argv,MCP 的 `check_code_style` 和 `auto_fix` 执行轨迹不回显凭据;日志仍按既有本地路径保存原始诊断 +#### Scenario: Formatter stderr contains a credential +- **WHEN** `auto_fix` 的 formatter 命令及 stderr 包含凭据文本 +- **THEN** MCP 整个修复结果不包含该文本或原始命令参数;可用时返回仅当前用户可读的诊断日志路径,日志不可用时仍不泄漏原文 + ### Requirement: Diagnostic logs SHALL be private and atomic CLI/MCP 失败日志与 Git 门禁截断诊断日志可能包含检查器的原始敏感输出。写入 MUST 使用同一基础设施边界,创建的文件在支持 POSIX 权限的平台上 MUST 仅允许当前用户读写;替换 MUST 原子可见,不能跟随预置的日志文件符号链接覆盖其它文件。默认 `out` 日志目录若本身是符号链接,MUST 不向链接目标写入。日志写入失败 MUST 不掩盖已经得出的检查状态,且不得返回并不存在的日志路径。既有日志路径与内容格式保持不变;这不承诺抵御并发替换父目录的完整文件系统竞态。 @@ -137,11 +142,20 @@ UserPromptSubmit 与 PostToolUse 的已完成事件、文件去重和会话统 门禁应用 MUST 不依赖 hooks 导入或宿主 SDK。单个语言检查 MUST 返回独立结果,汇总 MUST 保持输入顺序,不用共享可变备注列表连接并发检查。准确快照的审计 MUST 保留原 worktree 和会话归属,实际执行目录只作为执行上下文,不冒充项目身份。基线比较遇到未验证工具结果 MUST NOT 产生存量豁免。 基线豁免 MUST 比较逐条诊断内容、出现次数及可识别的文件归属,且基线检查器本身 MUST 确认失败;规则码集合或仅有文本输出不足以证明当前发现已存在。基线临时文件路径可以映射回被检查的仓库相对路径,行列号移动不应单独取消同文件的存量豁免;不同文件的同文本诊断不得互相抵扣。 +单个并行语言检查抛出内部异常时,应用 MUST 将该语言标为 UNVERIFIED,保留其余语言已确认的失败和审计,不得让异常抹掉整批结论。Git 安全路径观察抛出预期的快照错误时,应用 MUST 以 additionalContext 明示安全扫描未验证,并保留已有 lint 拦截;只有没有已确认拦截时才沿用既有 fail-open 退出码。内部异常消息不得作为任意原文注入宿主上下文。 #### Scenario: Exact snapshot audit retains ownership - **WHEN** 带会话 ID 的提交门禁从临时 Git 快照执行检查 - **THEN** 审计记录归属原 worktree 和当前会话,并记录实际执行命令 +#### Scenario: A parallel checker crashes after another language finds a violation +- **WHEN** 一种语言的 worker 抛出内部异常,另一种语言真实执行并确认违规 +- **THEN** 返回前者的 UNVERIFIED 提示、后者的失败和审计,不能因为异常把整批检查变为成功 + +#### Scenario: Safety path scan cannot read the proposed Git face +- **WHEN** 语言门禁已有结论,而安全路径扫描无法取得拟入库路径 +- **THEN** 宿主收到安全扫描 UNVERIFIED 上下文;已有失败仍拦截,无已知失败时保持既有未验证放行语义 + #### Scenario: Baseline tool cannot verify - **WHEN** 基线检查器输出类似违规文本但退出结果为 UNVERIFIED - **THEN** 不因文本相似而豁免当前检查 diff --git a/openspec/changes/refactor-codeguard-architecture/tasks.md b/openspec/changes/refactor-codeguard-architecture/tasks.md index 889154c..9b99ff0 100644 --- a/openspec/changes/refactor-codeguard-architecture/tasks.md +++ b/openspec/changes/refactor-codeguard-architecture/tasks.md @@ -33,6 +33,6 @@ - [x] 5.1 增加自动架构依赖/循环检查并接入 CI,临时源码树注入反向依赖/函数内导入/相对导入环后确实拒绝;本地已验证,远端 CI 待发布阶段。不以文件行数当合格标准。 - [x] 5.2 四个历史 skipped 已全部恢复;Dockerfile CLI 接入统一报告解析与扫描应用,14 项真实进程边界测试通过;基线比较按诊断内容和次数核验并要求基线实际 FAIL,三项误豁免用例先红后绿。完整 433 项单测零跳过、142 项回归零失败。 - [x] 5.3 新增当前架构 Mermaid 与扩展指南;报告版本读取插件 manifest,双语 README 指向当前证据,旧设计标明历史范围;双语标题/链接/版本及本地链接存在性测试通过。发布版本仍需在 5.5 检查。 -- [x] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过;第二十二批最终 512 项单测、143 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 -- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;第二十一批已发布 v0.14.7(插件 PR #53、市场 PR #10、源码 PR/main CI、注释 tag/正式 Release 均核对;市场 PR 无 CI 检查)。第二十二批诊断日志与 CLI 汇总修复已获用户授权推送、合并 main,正在执行 v0.14.8 发布闭环。宿主安装运行独立记录,未授权不得改宿主。 +- [x] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过;第二十三批本地 517 项单测、143 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 +- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;第二十二批已发布 v0.14.8(插件 PR #54、市场 PR #12、源码 PR/main CI、注释 tag/正式 Release 均核对;市场 PR 无 CI 检查)。第二十三批门禁故障隔离目前仅在本地,发布需独立验证。宿主安装运行独立记录,未授权不得改宿主。 - [ ] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。 diff --git a/openspec/changes/refactor-codeguard-architecture/verification.md b/openspec/changes/refactor-codeguard-architecture/verification.md index 2112adc..aaae193 100644 --- a/openspec/changes/refactor-codeguard-architecture/verification.md +++ b/openspec/changes/refactor-codeguard-architecture/verification.md @@ -234,3 +234,12 @@ - 真实文件系统新增 5 项 RED:项目日志权限、项目日志文件链接、输出目录链接、不可用输出目录、门禁截断日志链接。统一由 `storage.write_private_text` 在同目录创建私有临时文件并原子替换,拒绝直接链接的日志目录;两类应用只决定路径与内容。日志不可用时不返回虚假的路径,检查状态保持原判;保留既有日志内容格式与可见路径。另加原子替换失败的故障注入,证明旧日志不被截断、临时文件清理、既有判定保留。目标 6/6 通过,POSIX 创建权限为 0600,链接目标保持原样。 - 完整本地单测 **512/512、0 skipped**,真实 Hook 回归 **143/0/0**;Ruff、架构依赖/循环、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 通过,受管技能及 lock 未改。CodeGraph `affected` 对本次文件返回空,但实际新增测试和原有调用链覆盖存在,不将该空结果当无影响证据。 - 本批仅证明静态预置链接、POSIX 权限和原子替换语义;不承诺抵御并发替换父目录的完整文件系统竞态,也没有 Windows 实机、三宿主现场、在线 CVE 或大型 Maven/Gradle 验收。v0.14.7 的 CI/Release 不能证明此新代码;v0.14.8 发布证据须单独记录。 + +- 第二十二批随后发布为 v0.14.8:源码 PR #54 合并到 `ef6ace131f5922a652f88bcf320a0edcd69e1c92`,市场 PR #12 合并到 `32d717d25498160617f326c4b0c192513d8c511a`;源码 PR 与合并后 main 的 `skills-check` 成功。远端注释 tag 解引用到源码合并提交,正式 GitHub Release 非 draft、非 prerelease。Codex/ZCode/Kimi 生成市场清单全量校验通过且版本均为 0.14.8;市场 PR 无 CI 检查,不称其 CI 已通过。本地两仓 main 已快进且干净。此发布不覆盖以下第二十三批工作树。 + +## 第二十三批实际验证:Git 门禁故障隔离与已知结论保留 + +- 当前 main 的临时 CodeGraph 索引同步后,沿 `gate.run_batch → gate_checks.check_language` 和 `git_guard_application.evaluate_git_command → repository_policy.check_commit_safety → git_snapshot.proposed_paths` 追出两处未隔离故障。前者的 `ThreadPoolExecutor.map` 会把一个 worker 异常抛出到整批门禁;后者的 `SnapshotError` 越过应用层进入 Hook 顶层 fail-open,连先前已确认的 lint 拦截都可能丢失。CodeGraph `affected` 对这两处返回空,不作为无测试影响的证据。 +- 三项故障注入先 RED:一种语言抛内部异常、另一语言真实执行违规命令时整批抛错;安全路径读取失败时,无论是否已有 lint 失败,应用均抛错;Hook 无法交付结构化未知上下文。现 `gate` 在单语言任务边界返回不可变 `GateOutcome` 的 UNVERIFIED 备注,不泄漏异常原文,继续保留其它语言的失败和原仓审计;`git_guard_application` 将预期的安全路径快照故障转为 JSON additionalContext,已有拦截仍 exit 2,未有已知失败则保持现行 fail-open exit 0。三项目标测试转绿,真实 Hook 协议入口的 JSON 也实测。 +- 沿 `mcp_tool_payload → auto_fix → run_fix` 审查发现,修复结果的公开投影原本只收敛 `execution_trace`,却把同一结果字典的原始 `command` 与 `stderr_tail` 复制进 MCP JSON。凭据注入测试先 RED;现按允许字段投影修复状态,将 formatter 诊断尾部写入私有 `out/.codeguard-fix.log`,仅在成功落盘后返回路径。真实失败 formatter 的进程输出、POSIX 0600 权限及输出目录链接不可用时不退回公开原文均已测试。CLI 的原始本地诊断未改变。 +- 本地完整单测 **517/517、0 skipped**,真实 Hook 回归 **143/0/0**;Ruff、架构依赖/循环、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 通过。临时 CodeGraph 索引已同步本批 3 个变更源码文件,当前 138 files / 2138 nodes / 4816 edges;源仓未初始化索引。受管技能及 lock 未改;当前发布的 v0.14.8 不含本批代码。三个宿主的已安装运行、Windows、在线 CVE、真实大型 Maven/Gradle 与进程池基础设施级故障仍未验收。 diff --git a/scripts/check_architecture.py b/scripts/check_architecture.py index b4aa571..5019de1 100644 --- a/scripts/check_architecture.py +++ b/scripts/check_architecture.py @@ -58,7 +58,7 @@ "codeguard.java_analysis"}, "codeguard.check_application": {"__future__", "pathlib", "scope", "codeguard.config", "codeguard.discovery", "codeguard.java_analysis", - "codeguard.language_check", "codeguard.registry"}, + "codeguard.language_check", "codeguard.registry", "codeguard.storage"}, "codeguard.session_application": {"__future__", "collections.abc", "dataclasses", "pathlib", "codeguard.execution", "codeguard.hook_state", "codeguard.repository_policy", @@ -96,7 +96,7 @@ "codeguard.gate": {"__future__", "concurrent", "functools", "pathlib", "scope", "git_snapshot", "codeguard.config", "codeguard.discovery", "codeguard.registry", "codeguard.toolchain", "codeguard.cache", "codeguard.fingerprint", "codeguard.gate_checks", "codeguard.hook_state", - "codeguard.spec_validation"}, + "codeguard.models", "codeguard.spec_validation"}, } diff --git a/scripts/codeguard/check_application.py b/scripts/codeguard/check_application.py index 7796782..2d6b6f4 100644 --- a/scripts/codeguard/check_application.py +++ b/scripts/codeguard/check_application.py @@ -10,6 +10,7 @@ from .java_analysis import analyze from .language_check import run_check, run_fix from .registry import REGISTRY +from .storage import write_private_text def filter_languages(project_root: Path, lang_arg: str | None) -> list[str]: @@ -66,6 +67,32 @@ def result_envelope(results: list[dict]) -> list[dict]: return envelope +def _public_fix_results(root: Path, results: list[dict]) -> list[dict]: + """仅公开修复状态;原始 formatter 诊断进入私有日志。""" + public = [] + diagnostics = [] + for number, item in enumerate(results, start=1): + safe = {key: item[key] for key in ( + "language", "fixed", "status", "exit_code", "note", "reason", "error", "skipped", "dry_run", + ) if key in item} + if "execution_trace" in item: + safe["execution_trace"] = _public_trace(item["execution_trace"]) + tail = item.get("stderr_tail") + if isinstance(tail, str) and tail: + safe["stderr_chars"] = len(tail) + diagnostics.append((len(public), f"===== {item.get('language', '')} fix #{number} =====\n{tail}")) + public.append(safe) + if diagnostics: + path = root / "out" / ".codeguard-fix.log" + try: + write_private_text(path, "\n".join(block for _, block in diagnostics)) + except OSError: + return public + for index, _ in diagnostics: + public[index]["stderr_path"] = str(path.resolve()) + return public + + def auto_fix(root: Path, languages: list[str]) -> dict: """仅修复当前 Git 改动;无法确定范围时不扩大写入面。""" files = changed_files(root) @@ -77,8 +104,7 @@ def auto_fix(root: Path, languages: list[str]) -> dict: fix_results = run_fix(languages, root, files=files) results = run_check(languages, root, files=files, log_dir=root / "out") changed = any(not path.is_file() or path.read_bytes() != body for path, body in before.items()) - public_fixes = [{**item, "execution_trace": _public_trace(item["execution_trace"])} - if "execution_trace" in item else item for item in fix_results] + public_fixes = _public_fix_results(root, fix_results) return {"fixed": changed, "fix_results": public_fixes, "check": result_envelope(results)} diff --git a/scripts/codeguard/gate.py b/scripts/codeguard/gate.py index 120aebf..cb77ee4 100644 --- a/scripts/codeguard/gate.py +++ b/scripts/codeguard/gate.py @@ -13,6 +13,7 @@ from .fingerprint import check_identity, digest, repository_identity from .gate_checks import check_language from .hook_state import codeguard_home, record_gate_decision +from .models import GateOutcome from .registry import LANG_COMMANDS from .spec_validation import validate as _openspec_validate from .toolchain import ToolchainProbe @@ -138,10 +139,17 @@ def run_batch(project_root: Path, cfg: dict, languages: list, *, scope: str = "r probe = ToolchainProbe(project_root) worker = partial(check_language, project_root, cfg, scope=scope, changed=changed, baseline_ref=baseline_ref, probe=probe) + + def checked(lang: str) -> GateOutcome: + try: + return worker(lang) + except Exception as exc: # noqa: BLE001 — 单语言故障不得抹去其它语言的已确认结论 + return GateOutcome(notes=(f"{lang} UNVERIFIED:检查器内部异常({type(exc).__name__});请重试并检查诊断",)) + with ThreadPoolExecutor(max_workers=min(4, max(1, len(languages)))) as pool: - results = list(pool.map(worker, languages)) + results = list(pool.map(checked, languages)) failures, skipped = [], [] - for lang, outcome in zip(languages, results): + for lang, outcome in zip(languages, results, strict=True): if outcome.failure: failures.append(outcome.failure) skipped.extend(outcome.notes) diff --git a/scripts/codeguard/git_guard_application.py b/scripts/codeguard/git_guard_application.py index cab9065..eaef45a 100644 --- a/scripts/codeguard/git_guard_application.py +++ b/scripts/codeguard/git_guard_application.py @@ -143,8 +143,13 @@ def evaluate_git_command(command: str, *, cwd: Path, load_config: Callable[[], d and "markdown 风格告警" not in item] if unknown: contexts.append("codeguard: 存在未验证项,不能宣称全部通过:" + ";".join(unknown)) - violations = check_commit_safety(project_root, root_mode, lanes=lanes, extra=root_extra, - pending_commit=pending_commit) + try: + violations = check_commit_safety(project_root, root_mode, lanes=lanes, extra=root_extra, + pending_commit=pending_commit) + except SnapshotError: + contexts.append("codeguard: Git 安全路径 UNVERIFIED:无法读取拟入库路径;" + "请检查 Git index/仓库状态并重试,不能宣称安全扫描通过。") + continue if violations: reports.append(format_safety_report(violations) + ( "\n\n**给 AI 的强制指令**:**密钥/凭据类**(.env、*.pem、id_* 等)" diff --git a/tests/test_check_plans.py b/tests/test_check_plans.py index c062885..a3f53fd 100644 --- a/tests/test_check_plans.py +++ b/tests/test_check_plans.py @@ -109,16 +109,59 @@ def test_mcp_auto_fix_does_not_echo_new_trace_command_or_output(self): "stdout_chars": len(secret), "stderr_chars": 0, "stdout_tail": secret, "stderr_tail": ""}] fix_result = {"language": "java", "fixed": True, "exit_code": 0, - "stderr_tail": "", "execution_trace": trace} + "command": ["formatter", f"--token={secret}"], + "stderr_tail": f"formatter warning: {secret}", "execution_trace": trace} check_result = {"language": "java", "passed": True, "status": "PASS", "reason": "", "exit_code": 0, "execution_trace": trace} with patch.object(check_application, "changed_files", return_value=["src/A.java"]), \ patch.object(check_application, "run_fix", return_value=[fix_result]), \ patch.object(check_application, "run_check", return_value=[check_result]): - payload = check_application.auto_fix(self.root, ["java"]) + payload = check_application.mcp_tool_payload( + "auto_fix", {"path": str(self.root), "languages": ["java"]}, self.root) self.assertNotIn(secret, json.dumps(payload)) self.assertEqual("formatter", payload["fix_results"][0]["execution_trace"][0]["program"]) self.assertEqual("formatter", payload["check"][0]["execution_trace"][0]["program"]) + log_path = Path(payload["fix_results"][0]["stderr_path"]) + self.assertIn(secret, log_path.read_text(encoding="utf-8")) + if os.name == "posix": + self.assertEqual(0o600, log_path.stat().st_mode & 0o777) + + def test_mcp_auto_fix_does_not_echo_stderr_when_private_log_is_unavailable(self): + self.put("src/A.java", "class A {}") + external = self.root / "external" + external.mkdir() + (self.root / "out").symlink_to(external, target_is_directory=True) + secret = "CG_TEST_SECRET_TOKEN_unavailable" + fix_result = {"language": "java", "fixed": False, "exit_code": 2, + "stderr_tail": secret, "execution_trace": []} + check_result = {"language": "java", "passed": False, "status": "UNVERIFIED", + "reason": "formatter failed", "exit_code": 2} + with patch.object(check_application, "changed_files", return_value=["src/A.java"]), \ + patch.object(check_application, "run_fix", return_value=[fix_result]), \ + patch.object(check_application, "run_check", return_value=[check_result]): + payload = check_application.mcp_tool_payload( + "auto_fix", {"path": str(self.root), "languages": ["java"]}, self.root) + self.assertNotIn(secret, json.dumps(payload)) + self.assertNotIn("stderr_path", payload["fix_results"][0]) + self.assertEqual([], list(external.iterdir())) + + def test_mcp_auto_fix_real_formatter_stderr_is_private(self): + self.put("changed.py", "print('ready')\n") + secret = "CG_TEST_SECRET_TOKEN_real_formatter" + command = [sys.executable, "-c", + f"import sys; print('{secret}', file=sys.stderr); raise SystemExit(2)"] + check_result = {"language": "python", "passed": False, "status": "UNVERIFIED", + "reason": "formatter failed", "exit_code": 2} + with patch.dict(LANG_COMMANDS, {"python": {"format": command, "append_files": True}}), \ + patch.object(check_application, "changed_files", return_value=["changed.py"]), \ + patch.object(check_application, "run_check", return_value=[check_result]): + payload = check_application.mcp_tool_payload( + "auto_fix", {"path": str(self.root), "languages": ["python"]}, self.root) + self.assertNotIn(secret, json.dumps(payload)) + result = payload["fix_results"][0] + self.assertEqual(2, result["exit_code"]) + self.assertEqual(False, result["fixed"]) + self.assertIn(secret, Path(result["stderr_path"]).read_text(encoding="utf-8")) def test_mixed_zsh_fix_rechecks_the_shell_plan_without_touching_other_files(self): self.put("a.sh", "GOOD") diff --git a/tests/test_gate_application.py b/tests/test_gate_application.py index 678212d..fb1378b 100644 --- a/tests/test_gate_application.py +++ b/tests/test_gate_application.py @@ -102,3 +102,26 @@ def test_parallel_checks_aggregate_in_requested_order_and_audit_each_command(sel self.assertEqual(commands[entry["lang"]]["lint"], entry["cmd"]) self.assertEqual(1, entry["rc"]) self.assertEqual("FAIL", entry["status"]) + + def test_parallel_worker_exception_preserves_other_failure_and_audit(self): + from codeguard import gate as gate_application + + original = gate_application.check_language + command = [sys.executable, "-c", "print('F821 javascript');raise SystemExit(1)"] + + def check_or_crash(root, cfg, lang, **kwargs): + if lang == "python": + raise RuntimeError("SECRET_SENTINEL") + return original(root, cfg, lang, **kwargs) + + with patch.dict(gate_application.LANG_COMMANDS, {"javascript": {"lint": command}}), \ + patch.object(gate_application, "check_language", side_effect=check_or_crash), \ + patch.dict(os.environ, {"CODEGUARD_HOME": str(self.home)}): + failures, notes = gate_application.run_batch( + self.repo, {}, ["python", "javascript"], scope="repo") + + self.assertEqual(["javascript"], [failure[0] for failure in failures]) + self.assertTrue(any("python UNVERIFIED" in note for note in notes), notes) + self.assertNotIn("SECRET_SENTINEL", "\n".join(notes)) + entries = [json.loads(line) for line in (self.home / "gate-decisions.jsonl").read_text().splitlines()] + self.assertEqual(["javascript"], [entry["lang"] for entry in entries]) diff --git a/tests/test_git_guard_application.py b/tests/test_git_guard_application.py index 71cfd20..f0beef7 100644 --- a/tests/test_git_guard_application.py +++ b/tests/test_git_guard_application.py @@ -602,6 +602,53 @@ def test_git_root_observation_failure_is_not_fail_open(self): self.assertIn("rev-parse failed", result.stderr) fallback.assert_not_called() + def test_safety_snapshot_failure_is_visible_without_erasing_lint_block(self): + from codeguard import git_guard_application + from git_snapshot import SnapshotError + + with tempfile.TemporaryDirectory(prefix="cg-safety-unknown-") as tmp: + repo = self._initialized_repo(Path(tmp).resolve(), "repo") + for failures, expected_code in ( + ([], 0), + ([("python", "F821 confirmed", "fix", "hint")], 2), + ): + with self.subTest(expected_code=expected_code), \ + patch.object(git_guard_application, "run_gate", return_value=(failures, [])), \ + patch.object(git_guard_application, "check_commit_safety", + side_effect=SnapshotError("index unavailable")): + result = git_guard_application.evaluate_git_command( + "git commit -m fixture", cwd=repo, load_config=dict) + self.assertEqual(expected_code, result.exit_code) + self.assertIn("安全路径 UNVERIFIED", "\n".join(result.contexts)) + if failures: + self.assertIn("F821 confirmed", result.stderr) + + def test_safety_snapshot_failure_reaches_hook_as_unverified_json(self): + from codeguard import git_guard_application + from git_snapshot import SnapshotError + + with tempfile.TemporaryDirectory(prefix="cg-safety-hook-") as tmp: + base = Path(tmp).resolve() + repo = self._initialized_repo(base, "repo") + output, errors = io.StringIO(), io.StringIO() + payload = {"tool_input": {"command": "git commit -m fixture"}} + with patch.object(pre_tool_git_guard, "read_payload", return_value=payload), \ + patch.object(pre_tool_git_guard, "ensure_user_path"), \ + patch.object(pre_tool_git_guard, "load_user_config", return_value={}), \ + patch.object(pre_tool_git_guard.os, "getcwd", return_value=str(repo)), \ + patch.object(git_guard_application, "run_gate", return_value=([], [])), \ + patch.object(git_guard_application, "check_commit_safety", + side_effect=SnapshotError("index unavailable")), \ + patch.dict(os.environ, {"CODEGUARD_HOME": str(base / "state")}), \ + contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors): + code = pre_tool_git_guard.main() + + self.assertEqual(0, code) + envelope = json.loads(output.getvalue()) + self.assertEqual("PreToolUse", envelope["hookSpecificOutput"]["hookEventName"]) + self.assertIn("安全路径 UNVERIFIED", envelope["hookSpecificOutput"]["additionalContext"]) + self.assertEqual("", errors.getvalue()) + def test_real_hook_blocks_explicit_non_git_target(self): with tempfile.TemporaryDirectory(prefix="cg-hook-target-") as tmp: base = Path(tmp).resolve()