From 497dac6e9bdb09ab19b8f56628de3813d702a9fb Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:24:16 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E5=A4=8D=E6=A0=B8=E5=87=86=E7=A1=AE?= =?UTF-8?q?=20Git=20=E5=BF=AB=E7=85=A7=E7=9A=84=20index=20=E4=B8=8E=20HEAD?= =?UTF-8?q?=20=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/current-architecture.md | 1 + .../refactor-codeguard-architecture/design.md | 5 ++ .../specs/execution-kernel/spec.md | 5 ++ .../refactor-codeguard-architecture/tasks.md | 2 +- .../verification.md | 9 ++ scripts/git_snapshot.py | 26 ++++++ tests/test_git_snapshot_protocol.py | 88 +++++++++++++++++++ 7 files changed, 135 insertions(+), 1 deletion(-) diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 8d1e8eb..91505e2 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -29,6 +29,7 @@ flowchart TD 检查作用域默认忽略项目根下任一以 `.` 开头的目录与文件(PostToolUse 保存面、delta 门禁面、语言发现面与全量扫描的 ruff/`find` 型通道;项目根本身位于点前缀父目录下不构成命中);入库安全检查与 linter 配置发现不受此影响——密钥模式照拦,点前缀配置文件照常判定接入。 `execution` 只记录进程证据,不把非零退出自动认定为代码违规;stdout/stderr 合计按默认 16 MiB 捕获预算并发读取,超限终止执行、保留预算内诊断且返回 `output_limit`/UNVERIFIED,不把片段当成完整日志。`verdict`、CVE 与 Dockerfile 报告解析决定 PASS、FAIL 或 UNVERIFIED;入口仅按各自协议呈现和聚合退出码。Git 提交检查使用 index 或预测暂存快照,推送检查使用 HEAD;保存钩子只给反馈。计划、未执行和未验证不能宣传为通过。 Git 准确快照复用统一执行器的原始字节模式:路径列表默认最多捕获 16 MiB,`cat-file --batch-check` 按对象数分配响应预算;通过大小响应确认对象总量不超过 256 MiB 后,`--batch` 才按总 blob 大小和逐项头部预算读取。stdout/stderr 合计超限时及时终止 Git,抛出 `SnapshotError`,不交付截断内容。预测暂存的工作树覆盖层只列举一次,并以同一集合决定 `changed` 和物化内容;最多 20,000 项、单文件 32 MiB、实际复制总量 256 MiB,以 64 KiB 块流式读取。打开时使用非阻塞与不跟随末级链接的可用平台标志,避免类型检查后变成 FIFO 时卡住。特殊文件、读取期间变化和超限不交付临时树,文件↔目录转换按父路径先处理。随后严格解析 index/HEAD 对象列表的 NUL 帧、模式、ID、类型/阶段和唯一文件名,并与独立路径列举核对;`cat-file` 两阶段响应再逐项核对 ID、blob 类型、大小与边界,按 SHA-1/SHA-256 blob 对象格式复算内容哈希。缺项、截断、尾部脏数据或同长度内容替换均不交付临时树,而是明确标记 Git UNVERIFIED。原始 index 不在观察过程中改写。该校验不是恶意 Git 进程沙箱或完整 Git/Shell 模拟。 +临时树交付前及检查器返回后,会复核 index/HEAD 路径、模式、对象 ID 和 HEAD 提交 ID。提交模式的 HEAD 是暂存差异基线,推送模式的 HEAD 决定待推送范围;同名文件重新暂存、同树空提交或首次提交在检查期间发生时,该次结论为 Git UNVERIFIED。复核不能消除两次观察之间的竞态,也不构成原子 Git/工作树快照。 `language_check` 将 `PlanExecution` 的实际命令依序映射为应用层有界 `execution_trace`(检查、修复、复检阶段);`check_application` 将其进一步压成 MCP 安全元数据,不回显原始 argv、环境覆盖或捕获输出。MCP `auto_fix` 以 `fingerprint.file_content` 对仓内改动文件在修复前、formatter 后、复检后分别采集有总量预算的流式身份,仓外路径、符号链接或读取故障不启动 formatter;顶层 `fixed` 只归因于 formatter 且复检后仍保留的变化。公开 `fix_results` 将命令成功 `formatter_succeeded` 与逐项修复确认 `fixed` 分离;只有唯一成功执行的 formatter 且内容变化可验证时才把全局变化归给该项,多 formatter 执行不猜测归属。后续身份不可采集、formatter 失败后仍有内容变化或检查器改写目标文件时,保留实际执行证据、整体标为 UNVERIFIED,不把未知副作用声称为已确认修复。`fix_results` 只公开状态与安全元数据;formatter stderr 尾部写入私有 `.codeguard-fix.log` 后只公开路径,日志不可用时不退回公开原文。失败日志保留各已执行检查在捕获预算内的输出;输出超限时日志只含片段,不能称为完整诊断,且可能含敏感文本,应排除出版本控制。项目日志和门禁截断日志共用 `storage.write_private_text` 的私有原子落盘;预置日志文件链接不被跟随,默认 `out` 目录为链接或不可写时不返回虚假日志路径,也不覆盖检查结论。终止命令仍决定既有状态、退出码和旧字段;未运行的计划命令不进入证据。 `run_fix` 的历史 `fixed` 字段仍表示 formatter 命令以 0 退出,供旧调用方决定是否复检;它本身不是内容变化证明。CLI `fix` 因而只报告 formatter 执行成功与文件变化未验证,不再把退出码 0 呈现为已确认修复。 Git 命令的仓库定位与拟暂存解析共用入口传入的 cwd;显式 `cd`/`git -C` 无法绑定 Git 工作树时,硬门禁给出目标未验证并阻断,不退回到调用者仓或无关子仓。未给出显式目标且调用目录非仓时仍保留 workspace 子仓兜底。静态命令解析不等于完整 Shell 执行模拟。 diff --git a/openspec/changes/refactor-codeguard-architecture/design.md b/openspec/changes/refactor-codeguard-architecture/design.md index 14a70d2..c183ffb 100644 --- a/openspec/changes/refactor-codeguard-architecture/design.md +++ b/openspec/changes/refactor-codeguard-architecture/design.md @@ -92,6 +92,11 @@ payload 哈希。任一不一致转为 `SnapshotError`,准确门禁沿既有 或 `ls-tree --name-only` 路径面交叉核对,防止单个响应在完整记录边界被截断后仍交付 部分树。通用 `-z` 路径列举同样拒绝未终止或空记录;并发 index/HEAD 变化宁可转 UNVERIFIED,不把两个时点拼成一次确定快照。这不是对恶意 Git 进程的完整双源证明。 +对象列表在构建临时树后、交给检查器前,以及检查器正常返回时重新观察路径、模式和 blob ID; +提交与推送面同时复核 HEAD 提交 ID:前者以 HEAD 为暂存差异基线,后者据此确定待推送范围; +无 HEAD 的首次提交前状态可存在,但检查期间创建首个提交同样令结论失效。 +任一变化都令整次准确检查 UNVERIFIED。这样同名文件重新暂存不会让旧 blob 的结论冒充当前 index, +推送检查期间 HEAD 内容变化亦同。两次复核仍有观察间隙,不构成 Git 引用与工作树的原子事务。 工作树预测暂存覆盖层与 Git blob 是两种不同来源:前者不是 `cat-file` 的预算一部分,故另限 20,000 路径、单文件 32 MiB、实际复制总量 256 MiB,并通过有界块读取避免 `read_bytes()` 的整文件内存副本。覆盖路径仅列举一次,原样传给 changed 路径计算与临时树物化,避免两次 Git 查询之间集合变化造成“报告 A、检查 B”。源文件打开在支持的平台上使用 `O_NOFOLLOW | O_NONBLOCK`,类型检查与打开之间即使变成 FIFO 也不阻塞;源描述符在复制前后核对类型、大小与时间戳。超限、特殊文件和读取故障均拒绝交付。临时树按路径深度处理覆盖项,允许文件转目录;目录转文件时只清理一次性临时树中的旧子项,不改原仓。这个机制仍不承诺对抗父目录并发替换或同内容同元数据伪装的完整文件系统快照隔离。 直接命令链的拟暂存分析分成静态事件观察与按提交时间投影:先把同仓 `add`/`commit` 及其调用目录记录为不可变事件,再只将最后一个 commit 及以前的 add 送往 Git pathspec 查询和内容快照。这样后续 add 不会回溯污染已完成提交,也不跳过两个 commit 之间的 add。旧的独立 `staging_intent` 调用默认仍返回整条链的暂存意图;硬门禁只在没有一层间接脚本或 Shell 命令替换时启用按提交投影。`$(...)`/反引号的内层实际先执行,而旧静态展开把它附在文本尾部;间接脚本与外层 add 的跨层顺序也尚无统一事件时钟。这两类命令继续采用保守并集,不能以局部排序缩小检查面。 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 db87d8e..105e71a 100644 --- a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md +++ b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md @@ -12,6 +12,7 @@ Git 快照使用同一有界进程边界的原始字节模式,MUST 保留 NUL 与 blob 字节且不在协议校验前解码;路径列举、大小响应及对象内容 MUST 各有明确捕获预算。任何超限或执行故障 MUST 阻止交付快照并成为 SnapshotError,不能以截断数据继续门禁。对象总量预算必须在读取 batch 内容之前由大小响应确定。 工作树预测暂存覆盖层 MUST 以有界缓冲复制普通文件,并同时限制文件数量、单文件与累计字节数;检查期间文件超出预算、变成特殊文件或读取失败 MUST 拒绝交付临时树,不能把不完整覆盖层当准确 Git 快照。删除及文件与目录之间双向转换的预测语义必须保留,不能因覆盖路径迭代顺序不同而改变快照。 同一次准确快照中,拟提交路径与实际物化的覆盖层 MUST 使用同一份已列举的路径集合;不得分别查询两次 Git 工作树后把不同时点的路径清单拼成一个已验证结论。 +准确快照 MUST 在交付临时树前及检查器返回后复核所用的 index/HEAD 对象列表身份与 HEAD 提交身份;提交模式的 HEAD 是暂存差异比较基线,推送模式的 HEAD 决定待推送范围,即使文件树相同也不能忽略提交变化。首次提交前须允许不存在的 HEAD,但若检查期间产生首次提交,MUST 报告 Git UNVERIFIED。若路径、模式、对象 ID 或 HEAD 提交在观察窗口内变化,MUST 报告 Git UNVERIFIED,不得把旧 blob 的检查结果称为当前拟提交内容的结论。此复核不声称工作树与 Git 引用具备原子事务语义。 #### Scenario: A checker exits nonzero - **WHEN** 同一检查器经 CLI、保存 hook、Git 门禁或 CVE 执行并以非零退出 @@ -37,6 +38,10 @@ Git 快照使用同一有界进程边界的原始字节模式,MUST 保留 NUL - **WHEN** Git 工作树路径列举在两次调用之间返回不同集合 - **THEN** 单次快照只使用一次列举结果来计算 changed 与构建临时树;不能把第一份 changed 与第二份物化内容配对 +#### Scenario: Index or HEAD changes during an exact check +- **WHEN** 已列举对象之后,同名路径被重新暂存为另一个 blob,或检查器运行期间 index/HEAD 的路径、模式、对象 ID 或 HEAD 提交发生变化(即使提交文件树相同,或首次提交刚被创建) +- **THEN** 快照不交付旧内容,或在检查器返回时将该次结论标为 Git UNVERIFIED;不得把两个时点的内容拼成 PASS,观察过程自身不改写真实 index + #### Scenario: A predicted path is a special file - **WHEN** 拟暂存路径在快照复制时变成 FIFO、socket 或其它非普通文件 - **THEN** 快照拒绝该内容,即使类型检查与打开之间发生替换也不阻塞读取特殊文件,且不把它当作成功删除 diff --git a/openspec/changes/refactor-codeguard-architecture/tasks.md b/openspec/changes/refactor-codeguard-architecture/tasks.md index 306fe7f..b6c1e97 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 和逐条规格追溯通过;第二十九批本地 559 项单测、143 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 +- [x] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过;第三十批重基到 0.15.0 main 后本地 585 项单测、144 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 - [x] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;第二十九批已发布 v0.14.15(插件 PR #64、市场 PR #19、导航 PR #20、源码 PR/main CI、注释 tag/正式 Release 均核对;市场 PR 无 CI 检查)。宿主安装运行另行记录,未授权不得改宿主;发布证据见 verification.md。 - [ ] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。 diff --git a/openspec/changes/refactor-codeguard-architecture/verification.md b/openspec/changes/refactor-codeguard-architecture/verification.md index 0106a9e..9d25291 100644 --- a/openspec/changes/refactor-codeguard-architecture/verification.md +++ b/openspec/changes/refactor-codeguard-architecture/verification.md @@ -307,3 +307,12 @@ - 最终本地完整单测 **559/559、0 skipped**,真实 Hook **143/0/0**;Ruff、架构检查、语言 schema **57 项/11 规则**、vendor 离线及在线、本 change strict、diff whitespace 均通过。仍是隔离工作树未发布代码,不能用 v0.14.14 的远端 CI、Release 或旧宿主安装证明它已经交付。 - 第二十九批随后发布为 v0.14.15:源码 PR #64 合并到 `ee2e29a92a53f32724d511fa75db77cfc7aa1d2b`,市场 PR #19 合并到 `097075966e6239b1b549ce72a7fe22b83b8527f5`,中英文导航 PR #20 合并到 `905d24442a5591967ccf1804c24121f5e2433960`。源码 PR 的 `vendor-check` 和该合并提交在 main 的 `skills-check` 均成功;市场 PR/导航 PR 无 CI 检查,不称其 CI 通过。远端注释 tag `v0.14.15` 解引用到源码合并提交,GitHub Release 非 draft、非 prerelease;市场 7 个可安装插件的清单校验通过,CodeGuard 的 Codex/ZCode/Kimi ref 与中英文导航均为 0.14.15。本次发布不等于三宿主已安装运行,也不证明 Windows、在线 CVE、大型 Maven/Gradle 或并发恶意文件系统;task 5.6 全目标审计、规格同步与归档仍未完成。 + +## 第三十批本地验证:准确 Git 快照的观察窗口 + +- 以远端 v0.14.15 main 的隔离 worktree 建立临时 CodeGraph 索引(141 文件、2,265 节点、5,104 边),沿 `git_snapshot.validation_tree → gate.run_gate` 审查:原实现只在开始时取得 index/HEAD 对象列表;之后路径计算、物化和语言检查期间,同名文件可以被重新暂存,旧 blob 仍被作为当前结果交付。CodeGraph 受影响测试提示为空,但实际已有 `test_git_snapshot_protocol.py` 和准确门禁集成用例;未将图谱空结果当作无测试证明。 +- 真实临时 Git 仓新增三项 RED:同名文件在路径计算期间重新暂存、检查器期间重新暂存、准确门禁缺少 Git UNVERIFIED。快照现于交付前和检查器正常返回后复核对象路径、模式与 ID;三项转 GREEN。推送面另复核 HEAD 提交 ID:即使新提交文件树与旧提交相同,也可能改变待推送范围;对应真实 Git 用例先 RED 后 GREEN。原有 SHA-1/SHA-256、二进制内容、特殊路径与 index 不改写测试继续通过。 +- 准确门禁应用再加一项真实 Git 变化测试:检查器返回原本空失败列表时若 index 已变,`run_gate` 的 context 退出复核必须丢弃该结果并返回 Git UNVERIFIED。最终本地完整单测 **565/565、0 skipped**,`test_git_snapshot_protocol.py` **26/26**,真实 Hook 回归 **143/0/0**;Ruff、架构依赖/循环、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 均通过。该增量目前仅在隔离工作树,未 bump、提交、推送或发布;不能用 v0.14.15 的 CI/Release 证明它已经交付。 +- 两次复核之间仍有观察间隙;工作树覆盖层在复制后的变化、并发引用更新/回滚、跨进程文件系统对手,以及三宿主已安装运行、Windows、在线 CVE 与大型 Maven/Gradle 项目仍需独立证据。 +- 验证期间并行的点前缀默认忽略 PR #59 合并到源码 main `eb5b638db31db0535d269c0a0b8aed1c4b4bd6b2`,市场 catalog 同步为 0.15.0。本批本地提交重基到该 main 后重新执行完整单测 **583/583、0 skipped**,真实 Hook **144/0/0**;Ruff、架构门禁、语言 schema **57 项/11 规则**、vendor 离线及在线、本 change strict、diff whitespace 均通过。该次源码 main CI 也成功,但截至本次只读核对 0.15.0 尚无远端 tag/Release;本批本身仍未 bump、推送或发布,不能借并行变更的 main CI 充作本批 PR/CI 证据。 +- 继续自查发现提交模式同样以 HEAD 为暂存差异基线,原先只在推送模式复核 HEAD 会漏掉“检查期间产生同树空提交”和“unborn 仓首次提交”。两项真实 Git 测试先 RED,现统一复核存在/不存在的 HEAD 身份;对象列表与 HEAD 的两次复核覆盖 commit/push,协议测试 **28/28**。最终基于 0.15.0 main 的本地完整单测 **585/585、0 skipped**,真实 Hook **144/0/0**;Ruff、架构门禁、语言 schema **57 项/11 规则**、vendor 离线及在线、本 change strict、diff whitespace 均通过。仍不声称两次观察之间是原子事务,本批仍未远端发布。 diff --git a/scripts/git_snapshot.py b/scripts/git_snapshot.py index 179ed55..98fd8aa 100644 --- a/scripts/git_snapshot.py +++ b/scripts/git_snapshot.py @@ -240,10 +240,34 @@ def _copy_overlay(source: Path, dest: Path, remaining_bytes: int) -> int: raise SnapshotError(f"工作树覆盖层无法读取: {source}: {exc}") from exc +def _head_revision(root: Path) -> bytes: + """返回 HEAD 提交 ID;尚无 HEAD 提交(如首次提交前)用空字节表示。""" + proc = execute_bytes(["git", "rev-parse", "--verify", "-q", "HEAD"], root, 30, + stdin_null=True, max_output_bytes=1024) + if proc.failure: + raise SnapshotError(f"Git HEAD 身份未验证 ({proc.failure})") + if proc.returncode == 1 and not proc.stdout and not proc.stderr: + return b"" + identity = proc.stdout.strip() + if proc.returncode or not re.fullmatch(rb"(?:[0-9a-f]{40}|[0-9a-f]{64})", identity): + raise SnapshotError("Git HEAD 身份读取失败") + return identity + + +def _verify_object_list_unchanged(root: Path, head: bool, + expected: list[tuple[str, str, str]], + head_oid: bytes) -> None: + """复核路径、模式、对象 ID 和 HEAD 比较基线;不改真实 index。""" + if (_snapshot_entries(root, head) != expected + or _head_revision(root) != head_oid): + raise SnapshotError("Git index/HEAD 对象列表在快照检查期间变化") + + @contextlib.contextmanager def validation_tree(root: Path, mode="commit", *, lanes=None, extra=None, pending_commit=False): """原始 Git blobs 构成快照;拒绝外链、submodule、冲突和超限内容。""" head = mode == "push" and not pending_commit + head_oid = _head_revision(root) entries = _snapshot_entries(root, head) object_ids = "".join(oid + "\n" for _, oid, _ in entries).encode() sizes = _batch_sizes(git(root, "cat-file", "--batch-check", input_data=object_ids, @@ -279,4 +303,6 @@ def validation_tree(root: Path, mode="commit", *, lanes=None, extra=None, pendin raise SnapshotError(f"工作树覆盖层包含特殊文件: {name}") elif dest.is_file(): dest.unlink() + _verify_object_list_unchanged(root, head, entries, head_oid) yield target, changed + _verify_object_list_unchanged(root, head, entries, head_oid) diff --git a/tests/test_git_snapshot_protocol.py b/tests/test_git_snapshot_protocol.py index 9064fdf..8f6ec2b 100644 --- a/tests/test_git_snapshot_protocol.py +++ b/tests/test_git_snapshot_protocol.py @@ -111,6 +111,94 @@ def test_real_binary_blob_is_preserved_without_index_mutation(self): self.assertEqual(["sample.bin"], changed) self.assertEqual(before, (self.root / ".git/index").read_bytes()) + def test_restage_same_path_during_capture_rejects_old_blob(self): + original_paths = git_snapshot.proposed_paths + + def restage(*args, **kwargs): + (self.root / "sample.bin").write_bytes(b"replacement content\n") + subprocess.run(["git", "add", "sample.bin"], cwd=self.root, + check=True, capture_output=True) + return original_paths(*args, **kwargs) + + with (patch.object(git_snapshot, "proposed_paths", side_effect=restage), + self.assertRaisesRegex(git_snapshot.SnapshotError, "index/HEAD.*变化"), + git_snapshot.validation_tree(self.root)): + pass + + def test_restage_during_checker_rejects_previously_built_snapshot(self): + with (self.assertRaisesRegex(git_snapshot.SnapshotError, "index/HEAD.*变化"), + git_snapshot.validation_tree(self.root) as (snapshot, _)): + self.assertEqual(self.content, (snapshot / "sample.bin").read_bytes()) + (self.root / "sample.bin").write_bytes(b"replacement content\n") + subprocess.run(["git", "add", "sample.bin"], cwd=self.root, + check=True, capture_output=True) + + def test_new_head_during_push_check_rejects_old_snapshot(self): + subprocess.run(["git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid", + "commit", "-qm", "fixture"], cwd=self.root, check=True, capture_output=True) + with (self.assertRaisesRegex(git_snapshot.SnapshotError, "index/HEAD.*变化"), + git_snapshot.validation_tree(self.root, mode="push") as (snapshot, _)): + self.assertEqual(self.content, (snapshot / "sample.bin").read_bytes()) + (self.root / "sample.bin").write_bytes(b"replacement content\n") + subprocess.run(["git", "add", "sample.bin"], cwd=self.root, + check=True, capture_output=True) + subprocess.run(["git", "-c", "user.name=Test", "-c", + "user.email=test@example.invalid", "commit", "-qm", "updated"], + cwd=self.root, check=True, capture_output=True) + + def test_new_head_with_same_tree_during_push_check_is_still_unverified(self): + subprocess.run(["git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid", + "commit", "-qm", "fixture"], cwd=self.root, check=True, capture_output=True) + with (self.assertRaisesRegex(git_snapshot.SnapshotError, "index/HEAD.*变化"), + git_snapshot.validation_tree(self.root, mode="push")): + subprocess.run(["git", "-c", "user.name=Test", "-c", + "user.email=test@example.invalid", "commit", "--allow-empty", + "-qm", "same tree"], cwd=self.root, check=True, capture_output=True) + + def test_new_head_with_same_tree_during_commit_check_is_unverified(self): + subprocess.run(["git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid", + "commit", "-qm", "fixture"], cwd=self.root, check=True, capture_output=True) + with (self.assertRaisesRegex(git_snapshot.SnapshotError, "index/HEAD.*变化"), + git_snapshot.validation_tree(self.root)): + subprocess.run(["git", "-c", "user.name=Test", "-c", + "user.email=test@example.invalid", "commit", "--allow-empty", + "-qm", "same tree"], cwd=self.root, check=True, capture_output=True) + + def test_first_head_created_during_commit_check_is_unverified(self): + with (self.assertRaisesRegex(git_snapshot.SnapshotError, "index/HEAD.*变化"), + git_snapshot.validation_tree(self.root) as (snapshot, changed)): + self.assertEqual(["sample.bin"], changed) + self.assertEqual(self.content, (snapshot / "sample.bin").read_bytes()) + subprocess.run(["git", "-c", "user.name=Test", "-c", + "user.email=test@example.invalid", "commit", "-qm", "first"], + cwd=self.root, check=True, capture_output=True) + + def test_exact_gate_reports_restage_during_capture_as_unverified(self): + original_paths = git_snapshot.proposed_paths + + def restage(*args, **kwargs): + (self.root / "sample.bin").write_bytes(b"replacement content\n") + subprocess.run(["git", "add", "sample.bin"], cwd=self.root, + check=True, capture_output=True) + return original_paths(*args, **kwargs) + + with patch.object(git_snapshot, "proposed_paths", side_effect=restage): + failures, skipped = run_gate(self.root, {}, ["python"], exact=True) + self.assertEqual([], failures) + self.assertTrue(any("git UNVERIFIED" in item for item in skipped), skipped) + + def test_exact_gate_discards_checker_result_when_index_changes(self): + def restage_during_check(*args, **kwargs): + (self.root / "sample.bin").write_bytes(b"replacement content\n") + subprocess.run(["git", "add", "sample.bin"], cwd=self.root, + check=True, capture_output=True) + return [], [] + + with patch("codeguard.gate.run_batch", side_effect=restage_during_check): + failures, skipped = run_gate(self.root, {}, ["python"], exact=True) + self.assertEqual([], failures) + self.assertTrue(any("git UNVERIFIED" in item for item in skipped), skipped) + def test_git_binary_response_over_limit_never_returns_partial_records(self): before = (self.root / ".git/index").read_bytes() with self.assertRaisesRegex(git_snapshot.SnapshotError, "output_limit"): From 6ee695ba72f45d4dbb34e40ef9aefddfb6b47340 Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:45:59 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=9D=E5=AD=98=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E7=A7=81=E6=9C=89=E5=8E=9F=E5=AD=90=E8=90=BD?= =?UTF-8?q?=E7=9B=98=E5=B9=B6=E5=8F=91=E5=B8=83=200.15.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/plugins/marketplace.json | 8 +- .codex-plugin/plugin.json | 2 +- .zcode-plugin/plugin.json | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- docs/current-architecture.md | 2 +- kimi.plugin.json | 2 +- .../refactor-codeguard-architecture/design.md | 2 +- .../specs/execution-kernel/spec.md | 2 +- .../refactor-codeguard-architecture/tasks.md | 2 +- .../verification.md | 6 ++ scripts/check_architecture.py | 3 +- scripts/codeguard/save_application.py | 3 +- tests/test_diagnostic_logs.py | 79 ++++++++++++++++++- 14 files changed, 101 insertions(+), 16 deletions(-) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 54f31d6..2054826 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.15.0" + "ref": "v0.15.1" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.15.0", + "version": "0.15.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.", - "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.15.0/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.15.1/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.15.0/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.15.1/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 5d29744..3bf20b0 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.15.0+codex.20260923", + "version": "0.15.1+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 94d186d..2ca5cad 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.15.0", + "version": "0.15.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.", "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 80bf735..9f16feb 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. 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. +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, including truncated PostToolUse output, 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 ce1a507..a40adc5 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 的 JSON 结果也不回显 formatter 原始参数或 stderr;可用的修复诊断写到私有 /out/.codeguard-fix.log 并返回路径。本地日志可能含敏感文本,应排除出版本控制。MCP auto_fix 无法确定 Git 范围时不会写入。 +日志默认在 /out/.codeguard-last.log;CLI --quiet 关闭写日志。多命令检查失败时,日志保存每条已执行检查的完整输出。诊断日志(包括 PostToolUse 的截断输出)以原子替换写入,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 91505e2..c4a3c58 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -30,7 +30,7 @@ flowchart TD `execution` 只记录进程证据,不把非零退出自动认定为代码违规;stdout/stderr 合计按默认 16 MiB 捕获预算并发读取,超限终止执行、保留预算内诊断且返回 `output_limit`/UNVERIFIED,不把片段当成完整日志。`verdict`、CVE 与 Dockerfile 报告解析决定 PASS、FAIL 或 UNVERIFIED;入口仅按各自协议呈现和聚合退出码。Git 提交检查使用 index 或预测暂存快照,推送检查使用 HEAD;保存钩子只给反馈。计划、未执行和未验证不能宣传为通过。 Git 准确快照复用统一执行器的原始字节模式:路径列表默认最多捕获 16 MiB,`cat-file --batch-check` 按对象数分配响应预算;通过大小响应确认对象总量不超过 256 MiB 后,`--batch` 才按总 blob 大小和逐项头部预算读取。stdout/stderr 合计超限时及时终止 Git,抛出 `SnapshotError`,不交付截断内容。预测暂存的工作树覆盖层只列举一次,并以同一集合决定 `changed` 和物化内容;最多 20,000 项、单文件 32 MiB、实际复制总量 256 MiB,以 64 KiB 块流式读取。打开时使用非阻塞与不跟随末级链接的可用平台标志,避免类型检查后变成 FIFO 时卡住。特殊文件、读取期间变化和超限不交付临时树,文件↔目录转换按父路径先处理。随后严格解析 index/HEAD 对象列表的 NUL 帧、模式、ID、类型/阶段和唯一文件名,并与独立路径列举核对;`cat-file` 两阶段响应再逐项核对 ID、blob 类型、大小与边界,按 SHA-1/SHA-256 blob 对象格式复算内容哈希。缺项、截断、尾部脏数据或同长度内容替换均不交付临时树,而是明确标记 Git UNVERIFIED。原始 index 不在观察过程中改写。该校验不是恶意 Git 进程沙箱或完整 Git/Shell 模拟。 临时树交付前及检查器返回后,会复核 index/HEAD 路径、模式、对象 ID 和 HEAD 提交 ID。提交模式的 HEAD 是暂存差异基线,推送模式的 HEAD 决定待推送范围;同名文件重新暂存、同树空提交或首次提交在检查期间发生时,该次结论为 Git UNVERIFIED。复核不能消除两次观察之间的竞态,也不构成原子 Git/工作树快照。 -`language_check` 将 `PlanExecution` 的实际命令依序映射为应用层有界 `execution_trace`(检查、修复、复检阶段);`check_application` 将其进一步压成 MCP 安全元数据,不回显原始 argv、环境覆盖或捕获输出。MCP `auto_fix` 以 `fingerprint.file_content` 对仓内改动文件在修复前、formatter 后、复检后分别采集有总量预算的流式身份,仓外路径、符号链接或读取故障不启动 formatter;顶层 `fixed` 只归因于 formatter 且复检后仍保留的变化。公开 `fix_results` 将命令成功 `formatter_succeeded` 与逐项修复确认 `fixed` 分离;只有唯一成功执行的 formatter 且内容变化可验证时才把全局变化归给该项,多 formatter 执行不猜测归属。后续身份不可采集、formatter 失败后仍有内容变化或检查器改写目标文件时,保留实际执行证据、整体标为 UNVERIFIED,不把未知副作用声称为已确认修复。`fix_results` 只公开状态与安全元数据;formatter stderr 尾部写入私有 `.codeguard-fix.log` 后只公开路径,日志不可用时不退回公开原文。失败日志保留各已执行检查在捕获预算内的输出;输出超限时日志只含片段,不能称为完整诊断,且可能含敏感文本,应排除出版本控制。项目日志和门禁截断日志共用 `storage.write_private_text` 的私有原子落盘;预置日志文件链接不被跟随,默认 `out` 目录为链接或不可写时不返回虚假日志路径,也不覆盖检查结论。终止命令仍决定既有状态、退出码和旧字段;未运行的计划命令不进入证据。 +`language_check` 将 `PlanExecution` 的实际命令依序映射为应用层有界 `execution_trace`(检查、修复、复检阶段);`check_application` 将其进一步压成 MCP 安全元数据,不回显原始 argv、环境覆盖或捕获输出。MCP `auto_fix` 以 `fingerprint.file_content` 对仓内改动文件在修复前、formatter 后、复检后分别采集有总量预算的流式身份,仓外路径、符号链接或读取故障不启动 formatter;顶层 `fixed` 只归因于 formatter 且复检后仍保留的变化。公开 `fix_results` 将命令成功 `formatter_succeeded` 与逐项修复确认 `fixed` 分离;只有唯一成功执行的 formatter 且内容变化可验证时才把全局变化归给该项,多 formatter 执行不猜测归属。后续身份不可采集、formatter 失败后仍有内容变化或检查器改写目标文件时,保留实际执行证据、整体标为 UNVERIFIED,不把未知副作用声称为已确认修复。`fix_results` 只公开状态与安全元数据;formatter stderr 尾部写入私有 `.codeguard-fix.log` 后只公开路径,日志不可用时不退回公开原文。失败日志保留各已执行检查在捕获预算内的输出;输出超限时日志只含片段,不能称为完整诊断,且可能含敏感文本,应排除出版本控制。项目日志、门禁与保存 Hook 的截断诊断日志共用 `storage.write_private_text` 的私有原子落盘;预置日志文件链接不被跟随,默认 `out` 目录为链接或不可写时不返回虚假日志路径,也不覆盖检查结论。终止命令仍决定既有状态、退出码和旧字段;未运行的计划命令不进入证据。 `run_fix` 的历史 `fixed` 字段仍表示 formatter 命令以 0 退出,供旧调用方决定是否复检;它本身不是内容变化证明。CLI `fix` 因而只报告 formatter 执行成功与文件变化未验证,不再把退出码 0 呈现为已确认修复。 Git 命令的仓库定位与拟暂存解析共用入口传入的 cwd;显式 `cd`/`git -C` 无法绑定 Git 工作树时,硬门禁给出目标未验证并阻断,不退回到调用者仓或无关子仓。未给出显式目标且调用目录非仓时仍保留 workspace 子仓兜底。静态命令解析不等于完整 Shell 执行模拟。 workspace 兜底产生的每个子仓是合成目标,暂存观察也绑定该子仓根目录;不能继续以非 Git 的 workspace cwd 解析,从而把脏子仓的已知违规变成未知放行。 diff --git a/kimi.plugin.json b/kimi.plugin.json index 154dd65..264dc68 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.15.0", + "version": "0.15.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" diff --git a/openspec/changes/refactor-codeguard-architecture/design.md b/openspec/changes/refactor-codeguard-architecture/design.md index c183ffb..1399526 100644 --- a/openspec/changes/refactor-codeguard-architecture/design.md +++ b/openspec/changes/refactor-codeguard-architecture/design.md @@ -70,7 +70,7 @@ flowchart TD `scripts/check_architecture.py` 仅解析第一方 scripts/hooks 的 AST,不执行被检查源码;检查所有静态导入(含函数内导入)、内核模块显式依赖白名单、相对导入与导入环。新增内核模块必须登记政策;不是 CodeGraph 的语义调用图替代,也不能保证运行时计算出来的动态加载关系。reporting 当前仍负责诊断日志落盘,所以只把 models/verdict 称为纯内核,不把整个包夸大为无 IO。CI 已配置独立检查步骤;真实远端运行留到发布验收。 -本轮审计发现 CLI/MCP 与 Git 门禁均直接写原始诊断日志:前者位于项目 `out`,后者使用可预测的临时文件名。保留各自的日志路径和呈现职责,但由 `storage` 的统一私有原子文本写入负责落盘;应用不得再用普通 `write_text` 跟随预置链接或留下默认可读权限。日志故障只影响日志证据的可用性,不得覆盖检查结论;这不是完整的对抗性文件系统沙箱。 +本轮审计发现 CLI/MCP、Git 门禁与 PostToolUse 保存检查均直接写原始诊断日志:前者位于项目 `out`,后两者使用可预测的临时文件名。保留各自的日志路径和呈现职责,但由 `storage` 的统一私有原子文本写入负责落盘;应用不得再用普通 `write_text` 跟随预置链接或留下默认可读权限。日志故障只影响日志证据的可用性,不得覆盖检查结论;这不是完整的对抗性文件系统沙箱。 MCP 的 `auto_fix` 还必须在应用层单独投影公开结果:`run_fix` 保留执行所需的原始命令与 stderr,内部 `fixed` 维持退出码成功的兼容语义;JSON 用 `formatter_succeeded` 表示这个事实,逐项 `fixed` 只表示唯一可归因且复检后稳定的内容变化。多 formatter 执行时可以保留顶层观察到的变化,但逐项不猜测归属;失败命令后的部分改写整体转 UNVERIFIED。公开面只允许修复状态、退出码及收敛后的执行元数据。formatter 的 stderr 尾部写入项目 `out/.codeguard-fix.log`,成功落盘才公开私有日志路径;日志不可写时不回退输出原文。此边界不修改 CLI 的本地诊断契约,也不把私有日志路径当成宿主已读取的证明。 修复范围的前后观察复用 `fingerprint.file_content` 的流式散列和 64 MiB/10,000 文件预算;应用层先确认路径在根内、拒绝直接符号链接、设备和循环路径,再允许 `run_fix`。读取故障或身份超限不会靠整份 `read_bytes` 兜底。身份分别在修复前、formatter 返回后、检查器返回后采集:`fixed` 只来自 formatter 阶段且在复检后仍保留的变化;检查器改写目标文件或任一后续身份不可靠时,顶层 UNVERIFIED 且不宣称已确认修复,同时保留实际修复与复检证据。架构依赖门禁显式允许应用服务依赖 fingerprint 基础设施,不增加反向依赖。 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 105e71a..6aab276 100644 --- a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md +++ b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md @@ -106,7 +106,7 @@ MCP `auto_fix` 的 `fix_results` 公开面 MUST 同样只保留状态、退出 ### Requirement: Diagnostic logs SHALL be private and atomic -CLI/MCP 失败日志与 Git 门禁截断诊断日志可能包含检查器的原始敏感输出。写入 MUST 使用同一基础设施边界,创建的文件在支持 POSIX 权限的平台上 MUST 仅允许当前用户读写;替换 MUST 原子可见,不能跟随预置的日志文件符号链接覆盖其它文件。默认 `out` 日志目录若本身是符号链接,MUST 不向链接目标写入。日志写入失败 MUST 不掩盖已经得出的检查状态,且不得返回并不存在的日志路径。既有日志路径与内容格式保持不变;这不承诺抵御并发替换父目录的完整文件系统竞态。 +CLI/MCP 失败日志、Git 门禁与 PostToolUse 保存检查的截断诊断日志都可能包含检查器的原始敏感输出。写入 MUST 使用同一基础设施边界,创建的文件在支持 POSIX 权限的平台上 MUST 仅允许当前用户读写;替换 MUST 原子可见,不能跟随预置的日志文件符号链接覆盖其它文件。默认 `out` 日志目录若本身是符号链接,MUST 不向链接目标写入;保存 Hook 的临时日志目录若是符号链接也不得写入。日志写入失败 MUST 不掩盖已经得出的检查状态,且不得返回并不存在的日志路径。既有日志路径与内容格式保持不变;这不承诺抵御并发替换父目录的完整文件系统竞态。 #### Scenario: A pre-existing diagnostic log is a symlink - **WHEN** 项目或临时目录里预置的日志文件指向另一文件,检查器随后产生失败诊断 diff --git a/openspec/changes/refactor-codeguard-architecture/tasks.md b/openspec/changes/refactor-codeguard-architecture/tasks.md index b6c1e97..dab8a05 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 和逐条规格追溯通过;第三十批重基到 0.15.0 main 后本地 585 项单测、144 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 +- [x] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过;第三十一批在 0.15.0 main 上本地 590 项单测、144 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 - [x] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;第二十九批已发布 v0.14.15(插件 PR #64、市场 PR #19、导航 PR #20、源码 PR/main CI、注释 tag/正式 Release 均核对;市场 PR 无 CI 检查)。宿主安装运行另行记录,未授权不得改宿主;发布证据见 verification.md。 - [ ] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。 diff --git a/openspec/changes/refactor-codeguard-architecture/verification.md b/openspec/changes/refactor-codeguard-architecture/verification.md index 9d25291..6e4a3f4 100644 --- a/openspec/changes/refactor-codeguard-architecture/verification.md +++ b/openspec/changes/refactor-codeguard-architecture/verification.md @@ -316,3 +316,9 @@ - 两次复核之间仍有观察间隙;工作树覆盖层在复制后的变化、并发引用更新/回滚、跨进程文件系统对手,以及三宿主已安装运行、Windows、在线 CVE 与大型 Maven/Gradle 项目仍需独立证据。 - 验证期间并行的点前缀默认忽略 PR #59 合并到源码 main `eb5b638db31db0535d269c0a0b8aed1c4b4bd6b2`,市场 catalog 同步为 0.15.0。本批本地提交重基到该 main 后重新执行完整单测 **583/583、0 skipped**,真实 Hook **144/0/0**;Ruff、架构门禁、语言 schema **57 项/11 规则**、vendor 离线及在线、本 change strict、diff whitespace 均通过。该次源码 main CI 也成功,但截至本次只读核对 0.15.0 尚无远端 tag/Release;本批本身仍未 bump、推送或发布,不能借并行变更的 main CI 充作本批 PR/CI 证据。 - 继续自查发现提交模式同样以 HEAD 为暂存差异基线,原先只在推送模式复核 HEAD 会漏掉“检查期间产生同树空提交”和“unborn 仓首次提交”。两项真实 Git 测试先 RED,现统一复核存在/不存在的 HEAD 身份;对象列表与 HEAD 的两次复核覆盖 commit/push,协议测试 **28/28**。最终基于 0.15.0 main 的本地完整单测 **585/585、0 skipped**,真实 Hook **144/0/0**;Ruff、架构门禁、语言 schema **57 项/11 规则**、vendor 离线及在线、本 change strict、diff whitespace 均通过。仍不声称两次观察之间是原子事务,本批仍未远端发布。 + +## 第三十一批本地验证:保存 Hook 诊断日志边界 + +- CodeGraph 沿 `save_application._failure_context → storage.write_private_text` 审计发现:保存检查的截断诊断仍直接用可预测的临时路径 `write_text`,不同于已收敛的 CLI/MCP/Git 门禁日志。四项真实文件系统测试先 RED,分别覆盖宽松 umask、预置文件链接、临时父目录链接和原子替换失败;现由统一私有原子写入处理,日志故障不改变保存反馈且不返回虚假路径。另以 `evaluate_save` 的应用层用例锁定错误反馈及私有日志路径。 +- 在 0.15.0 main 上本地完整单测 **590/590、0 skipped**,真实 Hook **144/0/0**;Ruff、架构依赖门禁、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 均通过。临时索引已同步到 143 文件、2,334 节点、5,263 边;CodeGraph 的受影响测试列表为空,不能替代实际回归。上述只证明本地契约,不证明三宿主现场、Windows、联网 CVE、大型 Java 工程或恶意并发文件系统。 +- 并行的 0.15.0 源码 main `eb5b638` CI 已成功;缺失的 `v0.15.0` 注释 tag 与正式 Release 已补齐,均指向该 main 提交。第三十、三十一批自身仍须另行升级版本、PR/CI、市场同步和发布,不借 0.15.0 的证据声称交付。 diff --git a/scripts/check_architecture.py b/scripts/check_architecture.py index 0de8f9d..7453d57 100644 --- a/scripts/check_architecture.py +++ b/scripts/check_architecture.py @@ -81,7 +81,8 @@ "codeguard.save_application": {"__future__", "hashlib", "tempfile", "collections", "dataclasses", "pathlib", "scope", "codeguard.hook_state", "codeguard.discovery", "codeguard.execution", "codeguard.fingerprint", - "codeguard.planning", "codeguard.registry", "codeguard.verdict"}, + "codeguard.planning", "codeguard.registry", "codeguard.storage", + "codeguard.verdict"}, "codeguard.java_build": {"__future__", "re", "xml", "pathlib"}, "codeguard.java_impact": {"__future__", "collections", "pathlib"}, "codeguard.java_planning": {"__future__", "collections"}, diff --git a/scripts/codeguard/save_application.py b/scripts/codeguard/save_application.py index 9777892..07a5208 100644 --- a/scripts/codeguard/save_application.py +++ b/scripts/codeguard/save_application.py @@ -16,6 +16,7 @@ from .hook_state import codeguard_home from .planning import scoped_plan from .registry import LANG_COMMANDS +from .storage import write_private_text from .verdict import UNVERIFIED, lint_verdict PLUGIN_ROOT = Path(__file__).resolve().parents[2] @@ -100,7 +101,7 @@ def _failure_context(file_path: str, lang: str, cmd_def: dict, stdout: str, try: key = hashlib.sha1(str(file_path).encode()).hexdigest()[:12] full_path = Path(tempfile.gettempdir()) / f"codeguard-post-{lang}-{key}.log" - full_path.write_text(raw, encoding="utf-8") + write_private_text(full_path, raw) problems += f"\n…(截断,共 {len(problem_lines)} 行;完整输出: {full_path})" except OSError: problems += f"\n…(截断,共 {len(problem_lines)} 行)" diff --git a/tests/test_diagnostic_logs.py b/tests/test_diagnostic_logs.py index b160133..652c1ec 100644 --- a/tests/test_diagnostic_logs.py +++ b/tests/test_diagnostic_logs.py @@ -1,6 +1,7 @@ """原始诊断日志必须保留内容,但不得借文件链接扩大写入面。""" from __future__ import annotations +import hashlib import os import stat import sys @@ -11,7 +12,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) -from codeguard import reporting, storage +from codeguard import reporting, save_application, storage from codeguard.language_check import run_check from codeguard.registry import LANG_COMMANDS @@ -106,6 +107,82 @@ def test_gate_truncated_log_replaces_symlink_privately(self): self.assertIn("private evidence", log_path.read_text(encoding="utf-8")) self.assertIn(str(log_path), detail) + def _save_log_path(self, directory: Path) -> Path: + key = hashlib.sha1(str(self.root / "main.py").encode()).hexdigest()[:12] + return directory / f"codeguard-post-python-{key}.log" + + @unittest.skipIf(os.name == "nt", "POSIX file modes required") + def test_save_truncated_log_is_private_with_permissive_umask(self): + previous = os.umask(0o022) + try: + with patch("tempfile.gettempdir", return_value=str(self.base)): + context, _ = save_application._failure_context( + str(self.root / "main.py"), "python", {}, "private evidence\n" * 70, "") + finally: + os.umask(previous) + log_path = self._save_log_path(self.base) + 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), context) + + @unittest.skipIf(os.name == "nt", "POSIX symlinks required") + def test_save_truncated_log_replaces_symlink_without_overwriting_target(self): + target = self.base / "do-not-overwrite.txt" + target.write_text("original", encoding="utf-8") + log_path = self._save_log_path(self.base) + log_path.symlink_to(target) + with patch("tempfile.gettempdir", return_value=str(self.base)): + context, _ = save_application._failure_context( + str(self.root / "main.py"), "python", {}, "private evidence\n" * 70, "") + 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(str(log_path), context) + + @unittest.skipIf(os.name == "nt", "POSIX symlinks required") + def test_save_truncated_log_refuses_symlinked_directory(self): + outside = self.base / "outside" + outside.mkdir() + linked = self.base / "linked" + linked.symlink_to(outside, target_is_directory=True) + with patch("tempfile.gettempdir", return_value=str(linked)): + context, _ = save_application._failure_context( + str(self.root / "main.py"), "python", {}, "private evidence\n" * 70, "") + self.assertNotIn("完整输出:", context) + self.assertIn("怎么修", context) + self.assertFalse(self._save_log_path(outside).exists()) + + def test_save_truncated_log_swap_failure_keeps_previous_log_and_feedback(self): + log_path = self._save_log_path(self.base) + log_path.write_text("previous evidence", encoding="utf-8") + with (patch("tempfile.gettempdir", return_value=str(self.base)), + patch.object(storage.os, "replace", side_effect=OSError("swap failed"))): + context, _ = save_application._failure_context( + str(self.root / "main.py"), "python", {}, "private evidence\n" * 70, "") + self.assertNotIn("完整输出:", context) + self.assertIn("怎么修", context) + self.assertEqual("previous evidence", log_path.read_text(encoding="utf-8")) + self.assertEqual([], list(self.base.glob(log_path.name + ".*.tmp"))) + + def test_save_application_keeps_failure_feedback_and_private_log_path(self): + file_path = self.root / "main.py" + file_path.write_text("print(1)\n", encoding="utf-8") + with (patch("tempfile.gettempdir", return_value=str(self.base)), + patch.object(save_application, "detect_language", return_value="python"), + patch.object(save_application, "project_uses_linter", return_value=True), + patch.object(save_application, "check_identity", return_value=None), + patch.object(save_application, "run", return_value=(1, "private evidence\n" * 70, "")), + patch.dict(save_application.LANG_COMMANDS, + {"python": {"lint": ["ruff", "check", "{file}"], "append_files": True}})): + result = save_application.evaluate_save(str(file_path), self.root, + {"auto_fix_on_save": False}) + log_path = self._save_log_path(self.base) + self.assertIn("检查未通过", result.additional_context) + self.assertIn(str(log_path), result.additional_context) + self.assertIn("private evidence", log_path.read_text(encoding="utf-8")) + if os.name == "posix": + self.assertEqual(0o600, stat.S_IMODE(log_path.stat().st_mode)) + if __name__ == "__main__": unittest.main()