diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index ce145e1..1d286c7 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.7" + "ref": "v0.14.8" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.14.7", + "version": "0.14.8", "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.7/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.8/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.7/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.8/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index f2d8522..bd8d88a 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.7+codex.20260923", + "version": "0.14.8+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 ad9a9f6..7835af6 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.14.7", + "version": "0.14.8", "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 2c8d728..fe3c35d 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. 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. 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 eebd1d5..d3931d3 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 关闭写日志。多命令检查失败时,日志保存每条已执行检查的完整输出。MCP 执行轨迹只返回阶段、序号、程序名、退出/故障及输出长度,不回显 argv、环境覆盖或检查器输出。本地日志可能含敏感文本,应排除出版本控制。MCP auto_fix 无法确定 Git 范围时不会写入。 +日志默认在 /out/.codeguard-last.log;CLI --quiet 关闭写日志。多命令检查失败时,日志保存每条已执行检查的完整输出。诊断日志以原子替换写入,POSIX 上仅当前用户可读写;输出目录是符号链接时不写日志,但检查结论不变。MCP 执行轨迹只返回阶段、序号、程序名、退出/故障及输出长度,不回显 argv、环境覆盖或检查器输出。本地日志可能含敏感文本,应排除出版本控制。MCP auto_fix 无法确定 Git 范围时不会写入。 ## 配置与覆盖 diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 02c02e9..b866a9b 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、环境覆盖或捕获输出。失败日志保留各已执行检查的完整输出,可能含敏感文本,应排除出版本控制。终止命令仍决定既有状态、退出码和旧字段;未运行的计划命令不进入证据。 +`language_check` 将 `PlanExecution` 的实际命令依序映射为应用层有界 `execution_trace`(检查、修复、复检阶段);`check_application` 将其进一步压成 MCP 安全元数据,不回显原始 argv、环境覆盖或捕获输出。失败日志保留各已执行检查的完整输出,可能含敏感文本,应排除出版本控制。项目日志和门禁截断日志共用 `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 仍未建模。 diff --git a/kimi.plugin.json b/kimi.plugin.json index 19ab735..f856f29 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.7", + "version": "0.14.8", "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 74e1f26..59479ec 100644 --- a/openspec/changes/refactor-codeguard-architecture/design.md +++ b/openspec/changes/refactor-codeguard-architecture/design.md @@ -66,6 +66,8 @@ flowchart TD `scripts/check_architecture.py` 仅解析第一方 scripts/hooks 的 AST,不执行被检查源码;检查所有静态导入(含函数内导入)、内核模块显式依赖白名单、相对导入与导入环。新增内核模块必须登记政策;不是 CodeGraph 的语义调用图替代,也不能保证运行时计算出来的动态加载关系。reporting 当前仍负责诊断日志落盘,所以只把 models/verdict 称为纯内核,不把整个包夸大为无 IO。CI 已配置独立检查步骤;真实远端运行留到发布验收。 +本轮审计发现 CLI/MCP 与 Git 门禁均直接写原始诊断日志:前者位于项目 `out`,后者使用可预测的临时文件名。保留各自的日志路径和呈现职责,但由 `storage` 的统一私有原子文本写入负责落盘;应用不得再用普通 `write_text` 跟随预置链接或留下默认可读权限。日志故障只影响日志证据的可用性,不得覆盖检查结论;这不是完整的对抗性文件系统沙箱。 + ### 5. 命令语法、仓库观察与路径政策分离 `git_syntax` 只解释已支持的静态命令形态,不查文件、不起进程;`git_context` 负责仓库探测、读取一层解释器脚本和映射提交面,仓库探测复用执行内核。`PreToolUse` 入口保留宿主 JSON、绕过审计、门禁调用及报告输出,旧 Python helper 导入继续可用。间接脚本读取只保留一个实现,主入口不再重复读取脚本来判定模式。 @@ -299,6 +301,8 @@ tracked,`-f` 保留指定忽略文件;无显式路径的 -A/-u 仍按 [git a ## Risks / Trade-offs +- CLI `fix` 的结果条数不保证等于语言条数:一个 shell 语言可同时产生 zsh 跳过和 shfmt 执行结果。呈现及退出码须遍历每条实际结果,并使用结果自身的 `language`,不能按输入语言列表 `zip` 截断;真实 Git 工作树与失败 formatter 的黑盒测试防止假成功回归。 + - 私有 monkeypatch 测试耦合旧模块 → 保留公开兼容面,新增黑盒行为测试先行;不以删除测试解决失败。 - 改造过程中两条路径暂时并存 → tasks 明确每个调用点迁移,最终审计重复实现是否消失。 - 主干还有其他会话开发 → 每批改动前核对 status;不覆盖未认领修改、不自动切分支。 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 c6b9ffa..d92750e 100644 --- a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md +++ b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md @@ -59,10 +59,30 @@ - **WHEN** 权威命令的 argv 含凭据文本,且检查器输出也包含该文本 - **THEN** 真实子进程仍收到原始 argv,MCP 的 `check_code_style` 和 `auto_fix` 执行轨迹不回显凭据;日志仍按既有本地路径保存原始诊断 +### Requirement: Diagnostic logs SHALL be private and atomic + +CLI/MCP 失败日志与 Git 门禁截断诊断日志可能包含检查器的原始敏感输出。写入 MUST 使用同一基础设施边界,创建的文件在支持 POSIX 权限的平台上 MUST 仅允许当前用户读写;替换 MUST 原子可见,不能跟随预置的日志文件符号链接覆盖其它文件。默认 `out` 日志目录若本身是符号链接,MUST 不向链接目标写入。日志写入失败 MUST 不掩盖已经得出的检查状态,且不得返回并不存在的日志路径。既有日志路径与内容格式保持不变;这不承诺抵御并发替换父目录的完整文件系统竞态。 + +#### Scenario: A pre-existing diagnostic log is a symlink +- **WHEN** 项目或临时目录里预置的日志文件指向另一文件,检查器随后产生失败诊断 +- **THEN** 链接目标内容保持不变,实际日志以仅当前用户可读的完整内容替换日志路径 + +#### Scenario: The project output directory is a symlink or unavailable +- **WHEN** 默认 `out` 是指向其它目录的符号链接或不可写入的路径 +- **THEN** 检查的 FAIL/UNVERIFIED 结论保持不变,不写入链接目标,也不返回虚假的 `log_path` + #### Scenario: Repair retains the original scope - **WHEN** delta 或 save 检查失败并成功运行 formatter - **THEN** 复检执行原物化命令,仅包含原检查范围;被跳过的文件不导致漏掉其它文件的复检 +### Requirement: CLI fix SHALL account for every repair outcome + +同一语言可同时产生跳过子范围与实际 formatter 结果。CLI MUST 遍历应用返回的每条结果,而不是按语言数截断;任何实际 formatter 失败或不可验证结果 MUST 不得被同语言的 SKIPPED 提示掩盖为成功。既有逐条提示与 dry-run 行为保持可见。 + +#### Scenario: Mixed shell and zsh changes with a failing formatter +- **WHEN** Git 改动同时包含 `.zsh` 与 `.sh`,前者安全跳过 formatter,后者 formatter 实际运行并失败 +- **THEN** CLI 同时显示跳过说明与 formatter 失败,退出非零;不得只处理第一条 SKIPPED 结果 + #### Scenario: No executable commands - **WHEN** 计划没有任何可执行命令 - **THEN** 规划调用方返回 SKIPPED、PLANNED 或 UNVERIFIED;执行器拒绝将空计划解释为成功 diff --git a/openspec/changes/refactor-codeguard-architecture/tasks.md b/openspec/changes/refactor-codeguard-architecture/tasks.md index 189fc1f..889154c 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 和逐条规格追溯通过;第二十批 500 项单测、143 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 -- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;第二十批已发布 v0.14.6(插件 PR #52、市场 PR #9、源码 PR/main CI、注释 tag/正式 Release 均核对;市场 PR 无 CI 检查)。第二十一批新增的逐命令证据仍只在本地,需独立升级版本并发布。宿主安装运行独立记录,未授权不得改宿主。 +- [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 发布闭环。宿主安装运行独立记录,未授权不得改宿主。 - [ ] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。 diff --git a/openspec/changes/refactor-codeguard-architecture/verification.md b/openspec/changes/refactor-codeguard-architecture/verification.md index 5e87c89..2112adc 100644 --- a/openspec/changes/refactor-codeguard-architecture/verification.md +++ b/openspec/changes/refactor-codeguard-architecture/verification.md @@ -223,3 +223,14 @@ - 真实 MCP stdio RED 复现:`codeguard.json` 权威命令中的哨兵 token 经原始 argv 和检查器输出进入新增轨迹,扩大了公开面。`check_application` 现将 `check_code_style` 与 `auto_fix` 的新增轨迹压成阶段/序号/程序名/退出与故障/输出长度,不回显 argv、环境覆盖或输出文本;原始证据仍由内部执行结果和本地失败日志保留。后者可能含敏感内容,文档明确要求排除版本控制,不宣称日志已脱敏。 - 新目标测试经真实 `mcp_tool_payload` 路径转绿;进一步通过官方 MCP SDK 的 stdio 客户端从 `check_code_style` 收到两条实际命令状态与完整失败日志路径,token 未出现在 JSON 响应。加入 `auto_fix` 轨迹收敛测试后完整本地单测 **503/503、0 skipped**,Hook 回归 **143/0/0**,Ruff、架构门禁、语言 schema **57 项/11 规则**、vendor 离线/在线、本 change strict 与 diff whitespace 通过。受管技能与 lock 未改;这仍是本地证据,当前发布的 v0.14.6 不含本批修改,三宿主安装现场、真实 Maven/Gradle、在线漏洞库及 Windows 未验收。 - 宿主安装登记只读抽查:本机 ZCode 的 `installed_plugins.json` 将 `codeguard@full-stack-plugins` 指向 `v0.12.1` 与其缓存目录;它不能证明 v0.14.6 或本批在 ZCode 的加载/触发。当前抽查的 Codex/Kimi 缓存路径未提供当前版证据,但不能据路径缺失推断所有宿主环境均未安装。宿主安装和真实加载需在后续有权限的独立验收中验证,不由源码测试、市场 ref 或旧缓存替代。 + +- 第二十一批随后发布为 v0.14.7:源码 PR #53 合并到 `1543a93ae893040633ced2818b6dd7fb30752c40`,市场 PR #10 合并到 `e5816b097006f65db24a7ac2c5d00080faae9ee1`;源码 PR 与合并后 main 的 `skills-check` 均通过。远端注释 tag 解引用到源码合并提交,正式 GitHub Release 非 draft、非 prerelease;市场 Codex/ZCode/Kimi 生成清单校验及导航版本为 0.14.7。市场 PR 没有报告 CI 检查,不声称市场 CI 通过。此发布只覆盖第二十一批及此前代码,不覆盖以下新工作树。 + +## 第二十二批实际验证:诊断日志私有原子落盘 + +- 继续做 CLI 能力矩阵黑盒审计:真实 Git 仓同时修改 `.zsh` 和 `.sh`,假 shfmt 确实被调用并退出 2;原 `fix.py` 按 `zip(results, languages)` 只打印第一条 zsh 跳过,错误返回 0。新测试 RED 后改为按每条结果的语言呈现和聚合,目标测试连同 `detect`/`init` 入口及日志测试 9/9 GREEN。 + +- 临时 CodeGraph 索引同步到最新源码(源仓未初始化索引),沿 `language_check.run_check → 项目 out 日志` 与 `reporting._truncate_detail → 临时门禁日志` 两条链定位到普通 `Path.write_text`。两处均写原始检查器输出;文档虽提示可能含敏感文本,但项目日志在默认 022 umask 下为 0644。预置日志路径符号链接会覆盖目标文件,项目 `out` 目录链接会把日志写到外部;`out` 是普通文件时,`mkdir` 异常会掩盖已有检查结论。 +- 真实文件系统新增 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 发布证据须单独记录。 diff --git a/scripts/check_architecture.py b/scripts/check_architecture.py index 7eb8176..b4aa571 100644 --- a/scripts/check_architecture.py +++ b/scripts/check_architecture.py @@ -26,9 +26,9 @@ "codeguard.verdict": {"__future__", "re", "posixpath", "collections", "collections.abc", "pathlib", "codeguard.models"}, "codeguard.execution": {"__future__", "os", "subprocess", "collections", "pathlib", "codeguard.models"}, - # 报告当前仍拥有诊断日志落盘;不将其误称为无 IO 的纯模型。 + # 报告仍决定日志路径与呈现;私有原子落盘交给 storage,不称纯模型。 "codeguard.reporting": {"__future__", "re", "json", "pathlib", "hashlib", "tempfile", - "codeguard.models", "codeguard.verdict"}, + "codeguard.models", "codeguard.verdict", "codeguard.storage"}, # scope 是已有文件物化适配器:注入配置/排除目录,尚未迁入内核包。 "codeguard.planning": {"__future__", "collections", "pathlib", "scope", "codeguard.models"}, "codeguard.git_syntax": {"__future__", "pathlib", "re", "shlex"}, @@ -54,7 +54,8 @@ "codeguard.toolchain": {"__future__", "pathlib", "threading", "codeguard.execution"}, "codeguard.language_check": {"__future__", "pathlib", "codeguard.config", "codeguard.discovery", "codeguard.execution", "codeguard.models", "codeguard.planning", - "codeguard.registry", "codeguard.verdict", "codeguard.java_analysis"}, + "codeguard.registry", "codeguard.storage", "codeguard.verdict", + "codeguard.java_analysis"}, "codeguard.check_application": {"__future__", "pathlib", "scope", "codeguard.config", "codeguard.discovery", "codeguard.java_analysis", "codeguard.language_check", "codeguard.registry"}, diff --git a/scripts/codeguard/language_check.py b/scripts/codeguard/language_check.py index f53fc7b..b473265 100644 --- a/scripts/codeguard/language_check.py +++ b/scripts/codeguard/language_check.py @@ -19,6 +19,7 @@ from .models import CheckPlan, Command, PlanExecution from .planning import scoped_plan from .registry import LANG_COMMANDS +from .storage import write_private_text __all__ = ["run_check", "run_fix"] @@ -137,7 +138,6 @@ def run_check(languages: list[str], project_root: Path, if combined: log_entries.append((lang, rc, combined)) if log_dir is not None and log_entries: - log_dir.mkdir(parents=True, exist_ok=True) log_path = log_dir / ".codeguard-last.log" chunks = [] for lang, rc, content in log_entries: @@ -146,7 +146,7 @@ def run_check(languages: list[str], project_root: Path, block += "\n" chunks.append(block) try: - log_path.write_text("\n".join(chunks), encoding="utf-8") + write_private_text(log_path, "\n".join(chunks)) except OSError: log_path = None if log_path is not None: diff --git a/scripts/codeguard/reporting.py b/scripts/codeguard/reporting.py index 2d1f59b..887735e 100644 --- a/scripts/codeguard/reporting.py +++ b/scripts/codeguard/reporting.py @@ -5,6 +5,8 @@ import re from pathlib import Path +from .storage import write_private_text + PLUGIN_ROOT = Path(__file__).resolve().parents[2] @@ -72,7 +74,7 @@ def _truncate_detail(full: str, project_root: Path, lang: str) -> str: if len(lines) > 8 or len(full) > 600: try: path = _log_path(project_root, lang) - path.write_text(full, encoding="utf-8") + write_private_text(path, full) detail += f"\n…(截断,共 {len(lines)} 行;完整输出: {path})" except OSError: detail += f"\n…(截断,共 {len(lines)} 行)" diff --git a/scripts/codeguard/storage.py b/scripts/codeguard/storage.py index fd29dff..bcfcfa6 100644 --- a/scripts/codeguard/storage.py +++ b/scripts/codeguard/storage.py @@ -71,6 +71,16 @@ def _replace(path: Path, content: str) -> None: Path(temporary).unlink(missing_ok=True) +def write_private_text(path: Path, content: str) -> None: + """以私有权限原子替换诊断日志,不向已存在的目录链接写入。""" + if path.parent.is_symlink(): + raise OSError(f"diagnostic directory is a symlink: {path.parent}") + path.parent.mkdir(parents=True, exist_ok=True) + if path.parent.is_symlink(): + raise OSError(f"diagnostic directory is a symlink: {path.parent}") + _replace(path, content) + + def update_json(path: Path, change: Callable[[dict], T]) -> T: """在锁内读取、变更、替换;回调异常不写入任何部分结果。""" with locked(path): diff --git a/scripts/fix.py b/scripts/fix.py index cc43751..50620d7 100644 --- a/scripts/fix.py +++ b/scripts/fix.py @@ -63,7 +63,8 @@ def main() -> int: languages, project_root, dry_run=args.dry_run, files=target_files, ) failed: list[str] = [] - for r, lang in zip(results, languages, strict=False): + for r in results: + lang = r["language"] if r.get("skipped"): print(f" {lang:12s} ⏭️ {r.get('note')}") continue diff --git a/tests/test_cli_contracts.py b/tests/test_cli_contracts.py new file mode 100644 index 0000000..4dd4415 --- /dev/null +++ b/tests/test_cli_contracts.py @@ -0,0 +1,89 @@ +"""通过真实 Bash CLI 入口保护初始化、发现与修复结果汇总合同。""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +PLUGIN = Path(__file__).resolve().parents[1] +CLI = PLUGIN / "bin" / "codeguard" + + +@unittest.skipUnless(shutil.which("bash") and shutil.which("git"), "requires Bash and Git") +class CliContractTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="cg-cli-contract-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1", + "CODEGUARD_HOME": str(self.root / "state")} + + def cli(self, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["bash", str(CLI), *args], cwd=self.root, env=self.env, + capture_output=True, text=True, timeout=30, check=False) + + def git(self, *args: str) -> None: + subprocess.run(["git", *args], cwd=self.root, env=self.env, + capture_output=True, text=True, timeout=10, check=True) + + def test_init_only_prints_guidance_without_writing_project(self): + completed = self.cli("init") + + self.assertEqual(1, completed.returncode) + self.assertIn("项目初始化引导", completed.stderr) + self.assertEqual([], list(self.root.iterdir())) + + def test_detect_returns_python_from_project_without_writing(self): + source = self.root / "demo.py" + marker = self.root / "pyproject.toml" + source.write_text("print('ready')\n", encoding="utf-8") + marker.write_text("[project]\nname = 'demo'\nversion = '0.1.0'\n", encoding="utf-8") + + completed = self.cli("detect", str(self.root)) + + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("python", json.loads(completed.stdout)) + self.assertEqual({source, marker}, set(self.root.iterdir())) + + def test_fix_reports_formatter_failure_after_same_language_skip(self): + self.git("init", "--quiet") + shell = self.root / "clean.sh" + zsh = self.root / "skip.zsh" + shell.write_text("echo first\n", encoding="utf-8") + zsh.write_text("echo first\n", encoding="utf-8") + self.git("add", "clean.sh", "skip.zsh") + self.git("-c", "user.name=CodeGuard Test", "-c", "user.email=test@example.com", + "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "baseline") + shell.write_text("echo second\n", encoding="utf-8") + zsh.write_text("echo second\n", encoding="utf-8") + + tool_dir = self.root / "tool-bin" + tool_dir.mkdir() + formatter = tool_dir / "shfmt" + formatter.write_text( + f"#!{sys.executable}\n" + "import json, sys\n" + "from pathlib import Path\n" + "Path('formatter-argv.json').write_text(json.dumps(sys.argv[1:]))\n" + "print('formatter failed', file=sys.stderr)\n" + "raise SystemExit(2)\n", encoding="utf-8") + formatter.chmod(0o755) + self.env["PATH"] = str(tool_dir) + os.pathsep + self.env["PATH"] + + completed = self.cli("fix", "--lang", "shell", str(self.root)) + + self.assertEqual(1, completed.returncode, completed.stdout + completed.stderr) + self.assertIn("zsh 文件跳过 formatter", completed.stdout) + self.assertIn("failed: exit=2", completed.stdout) + argv = json.loads((self.root / "formatter-argv.json").read_text(encoding="utf-8")) + self.assertIn("clean.sh", argv) + self.assertNotIn("skip.zsh", argv) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_diagnostic_logs.py b/tests/test_diagnostic_logs.py new file mode 100644 index 0000000..b160133 --- /dev/null +++ b/tests/test_diagnostic_logs.py @@ -0,0 +1,111 @@ +"""原始诊断日志必须保留内容,但不得借文件链接扩大写入面。""" +from __future__ import annotations + +import os +import stat +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +from codeguard import reporting, storage +from codeguard.language_check import run_check +from codeguard.registry import LANG_COMMANDS + + +class DiagnosticLogTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="cg-log-safety-") + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.root = (self.base / "project").resolve() + self.root.mkdir() + + def _failed_check(self, log_dir: Path) -> dict: + command = [sys.executable, "-c", "print('private evidence'); raise SystemExit(1)"] + with patch.dict(LANG_COMMANDS, {"python": {"lint": command, "gate": command}}): + return run_check(["python"], self.root, log_dir=log_dir)[0] + + @unittest.skipIf(os.name == "nt", "POSIX file modes and symlinks required") + def test_project_failure_log_is_private_with_permissive_umask(self): + previous = os.umask(0o022) + try: + result = self._failed_check(self.root / "out") + finally: + os.umask(previous) + log_path = Path(result["log_path"]) + self.assertEqual(0o600, stat.S_IMODE(log_path.stat().st_mode)) + self.assertIn("private evidence", log_path.read_text(encoding="utf-8")) + + @unittest.skipIf(os.name == "nt", "POSIX symlinks required") + def test_project_failure_log_replaces_symlink_without_overwriting_target(self): + target = self.base / "do-not-overwrite.txt" + target.write_text("original", encoding="utf-8") + log_dir = self.root / "out" + log_dir.mkdir() + log_path = log_dir / ".codeguard-last.log" + log_path.symlink_to(target) + + result = self._failed_check(log_dir) + + self.assertEqual("original", target.read_text(encoding="utf-8")) + self.assertEqual(log_path, Path(result["log_path"])) + self.assertFalse(log_path.is_symlink()) + self.assertIn("private evidence", log_path.read_text(encoding="utf-8")) + + @unittest.skipIf(os.name == "nt", "POSIX symlinks required") + def test_symlinked_output_directory_does_not_receive_project_log(self): + outside = self.base / "outside" + outside.mkdir() + (self.root / "out").symlink_to(outside, target_is_directory=True) + + result = self._failed_check(self.root / "out") + + self.assertNotIn("log_path", result) + self.assertFalse(result["passed"]) + self.assertFalse((outside / ".codeguard-last.log").exists()) + + def test_unavailable_output_directory_preserves_check_verdict(self): + (self.root / "out").write_text("occupied", encoding="utf-8") + + result = self._failed_check(self.root / "out") + + self.assertNotIn("log_path", result) + self.assertFalse(result["passed"]) + self.assertEqual("occupied", (self.root / "out").read_text(encoding="utf-8")) + + def test_failed_atomic_swap_preserves_previous_log_and_verdict(self): + log_dir = self.root / "out" + log_dir.mkdir() + log_path = log_dir / ".codeguard-last.log" + log_path.write_text("previous evidence", encoding="utf-8") + + with patch.object(storage.os, "replace", side_effect=OSError("swap failed")): + result = self._failed_check(log_dir) + + self.assertNotIn("log_path", result) + self.assertFalse(result["passed"]) + self.assertEqual("previous evidence", log_path.read_text(encoding="utf-8")) + self.assertEqual([], list(log_dir.glob(".codeguard-last.log.*.tmp"))) + + @unittest.skipIf(os.name == "nt", "POSIX file modes and symlinks required") + def test_gate_truncated_log_replaces_symlink_privately(self): + target = self.base / "do-not-overwrite.txt" + target.write_text("original", encoding="utf-8") + with patch("tempfile.gettempdir", return_value=str(self.base)): + log_path = reporting._log_path(self.root, "python") + log_path.symlink_to(target) + detail = reporting._truncate_detail("private evidence\n" * 70, self.root, "python") + + self.assertEqual("original", target.read_text(encoding="utf-8")) + self.assertFalse(log_path.is_symlink()) + self.assertEqual(0o600, stat.S_IMODE(log_path.stat().st_mode)) + self.assertIn("private evidence", log_path.read_text(encoding="utf-8")) + self.assertIn(str(log_path), detail) + + +if __name__ == "__main__": + unittest.main()