diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index faa915b..7afbe01 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.9" + "ref": "v0.14.10" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.14.9", + "version": "0.14.10", "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.9/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.10/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.9/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.10/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 9d8004f..402d9bb 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.9+codex.20260923", + "version": "0.14.10+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 76249e4..cc304a5 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.14.9", + "version": "0.14.10", "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 18cd418..80bf735 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Coverage is module-level, not a symbol call graph or business-semantic proof. A ## Git content integrity -A plain commit checks the index, not an unstaged repair. Supported preceding git add operations overlay predicted worktree paths; pure push checks HEAD and upstream differences. Without a resolvable upstream, the HEAD tree is checked. Sensitive-file rules use the same proposed scope; removing a sensitive file is not treated as introducing it. +A plain commit checks the index, not an unstaged repair. Supported preceding git add operations overlay predicted worktree paths; in a direct command chain without shell substitution, an add after the final commit is not projected backward into that commit or a following push. Pure push checks HEAD and upstream differences. Without a resolvable upstream, the HEAD tree is checked. Sensitive-file rules use the same proposed scope; removing a sensitive file is not treated as introducing it. Checks materialize temporary Git blobs without stash, checkout or modifying the real index. The exact-content gate does not reuse the soft working-tree cache. Missing ignored dependencies remain UNVERIFIED rather than silently falling back to different source content. diff --git a/README.zh-CN.md b/README.zh-CN.md index 6f4d842..ce1a507 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -70,7 +70,7 @@ Maven 使用 verify 并追加 -DskipTests(测试代码仍编译,仅跳过执 ## Git 内容一致性 -纯 commit 检查 index,不会拿未暂存的修复冒充提交内容。已支持的前序 git add 操作叠加预测工作树路径;纯 push 检查 HEAD 和上游差异。无可解析上游时检查 HEAD 树。敏感文件规则采用同一预测范围;删除敏感文件不视为新增入库。 +纯 commit 检查 index,不会拿未暂存的修复冒充提交内容。已支持的前序 git add 操作叠加预测工作树路径;不含 Shell 命令替换的直接命令链里,最后一次 commit 后的 add 不会倒算进此前提交或随后的 push。纯 push 检查 HEAD 和上游差异。无可解析上游时检查 HEAD 树。敏感文件规则采用同一预测范围;删除敏感文件不视为新增入库。 检查在临时目录物化 Git blobs,不 stash、不 checkout、不更改真实 index。精确内容门禁不复用软提醒的工作树缓存。快照缺 ignored 依赖时保持 UNVERIFIED,不改查另一份源码。 diff --git a/docs/current-architecture.md b/docs/current-architecture.md index ac02e25..1021024 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -29,8 +29,10 @@ flowchart TD `execution` 只记录进程证据,不把非零退出自动认定为代码违规。`verdict`、CVE 与 Dockerfile 报告解析决定 PASS、FAIL 或 UNVERIFIED;入口仅按各自协议呈现和聚合退出码。Git 提交检查使用 index 或预测暂存快照,推送检查使用 HEAD;保存钩子只给反馈。计划、未执行和未验证不能宣传为通过。 `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 执行模拟。 +workspace 兜底产生的每个子仓是合成目标,暂存观察也绑定该子仓根目录;不能继续以非 Git 的 workspace cwd 解析,从而把脏子仓的已知违规变成未知放行。 一条命令链中的 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 仍未建模。 +不含脚本或命令替换的直接静态命令链,暂存事件先按目标仓和执行次序记录,再投影到该仓最后一次 commit:其后的 add 不会倒算到先前提交或随后的 push;两个 commit 之间的 add 仍进入检查面。带一层间接脚本或 `$(...)`/反引号命令替换的链仍保守合并暂存意图,不声称已经建立跨层事件时钟。 直接 Git 命令、解释器入口及 `skipGate` 状态/内联豁免解析共用裸前缀归一化;可解析的环境赋值、`env FOO=1`、`command` 和无参数 `sudo` 不会遮蔽后面的 Shell 提交或豁免取消。带值 wrapper 参数和运行时生成命令仍不在静态保证范围内。 ## 模块所有权 diff --git a/kimi.plugin.json b/kimi.plugin.json index 1233ea8..0630ee7 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.9", + "version": "0.14.10", "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/2026-09-22-gate-toolchain-unverified/tasks.md b/openspec/changes/2026-09-22-gate-toolchain-unverified/tasks.md index 017c623..4e02107 100644 --- a/openspec/changes/2026-09-22-gate-toolchain-unverified/tasks.md +++ b/openspec/changes/2026-09-22-gate-toolchain-unverified/tasks.md @@ -47,6 +47,6 @@ ## 7. 让位项(pre_tool_git_guard.py 并行修改活跃期,避免竞争写入) -- [ ] 7.1 `match_pathspec`:git status pathspec 引擎解析 add 目标(glob/目录/等价语法)接进 staging_intent(测试已备标 skip;当前手工 resolve 对 glob/目录按字面子处理 → 提交面算窄) -- [ ] 7.2 内联/链式豁免放行的用户即时明示(hookSpecificOutput 提醒,测试已备标 skip) +- [x] 7.1 已由架构重构接管:`git_staging.resolve_pathspecs` 成组调用只读 Git pathspec 查询,目录/glob/literal/exclude/`-u`/`-f` 与真实 Hook 敏感文件回归均已恢复为常规测试;无需另建第二份 `match_pathspec` 实现。 +- [x] 7.2 已由架构重构接管:内联与链式豁免按目标仓审计并在 Hook additionalContext 明示,`test_inline_skip_emits_user_visible_notice` 与多仓豁免测试为常规回归。 - [ ] 7.3 双副本判定一致性核对(partme-ai 0.3.4 悬挂 hook 已用 fail-open 占位止血——宿主仍执行已删除的 hook 致全部 Bash 报错;根治=宿主禁用 codeguard@partme-ai 旧副本) diff --git a/openspec/changes/refactor-codeguard-architecture/design.md b/openspec/changes/refactor-codeguard-architecture/design.md index ec0bfa2..d3b1eb4 100644 --- a/openspec/changes/refactor-codeguard-architecture/design.md +++ b/openspec/changes/refactor-codeguard-architecture/design.md @@ -76,6 +76,8 @@ MCP 的 `auto_fix` 还必须在应用层单独投影公开结果:`run_fix` 保 `path_policy` 是扫描排除表、产物识别与拟入库路径规则的单一来源;`scope` 和 `gate_lib` 只兼容导出/调用。策略接收已经确定的路径,不读取 Git,更不会执行建议中的移除操作。Git 快照采集仍在 git_snapshot,反馈格式仍在 reporting。 +直接命令链的拟暂存分析分成静态事件观察与按提交时间投影:先把同仓 `add`/`commit` 及其调用目录记录为不可变事件,再只将最后一个 commit 及以前的 add 送往 Git pathspec 查询和内容快照。这样后续 add 不会回溯污染已完成提交,也不跳过两个 commit 之间的 add。旧的独立 `staging_intent` 调用默认仍返回整条链的暂存意图;硬门禁只在没有一层间接脚本或 Shell 命令替换时启用按提交投影。`$(...)`/反引号的内层实际先执行,而旧静态展开把它附在文本尾部;间接脚本与外层 add 的跨层顺序也尚无统一事件时钟。这两类命令继续采用保守并集,不能以局部排序缩小检查面。 + 这不是完整 Shell 解释器:分隔符/引用/动态拼接和复杂相对 cd 仍有既有边界。显式 `cd` 或 `git -C` 指向非 Git 目标时,解析器现在返回独立的目标故障,硬门禁阻断而不回退调用者仓; 无显式目标且 cwd 非仓时继续保留 workspace 一层子仓兜底。仓库定位和拟暂存解析共用入口 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 3919b38..c495530 100644 --- a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md +++ b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md @@ -242,9 +242,16 @@ index 或工作树;不支持的动态/交互式形态必须明确未验证, 显式 `cd` 或 `git -C` 已给出目标时,若该目标不是可解析的 Git 工作树,MUST 阻断 该 Git 副作用命令并报告目标不可确定;不得回退调用者仓或扫调用者一层子仓。 只有命令未给出显式目标且调用目录不是 Git 仓时,才保留既有 workspace 子仓兜底策略。 +兜底为每个子仓构造的合成目标 MUST 同时用于暂存观察;不能仍以非 Git workspace 目录 +解析 commit,导致脏子仓的已确认违规降级为无阻断的未知结果。 一条命令链触及多个仓库时,commit/push 门禁面和是否存在待提交操作 MUST 按仓库分别 归属;A 仓的 commit 不得使 B 仓的纯 push 检查包含 B 仓尚未提交的 index。若同一仓 同时 commit 和 push,则继续检查该仓已有待推送内容及拟提交内容,不得缩窄既有检查面。 +对于没有间接脚本或命令替换的静态命令链,拟暂存面 MUST 按同仓 Git 操作的先后顺序投影:最后一个 +commit 之后才出现的 add 不得回溯到已完成的 commit 或随后的 push;最后一个 commit 之前的 +add(包括多个 commit 之间的 add)仍须检查。无法可靠排序的一层间接脚本继续采用保守并集 +或明确 UNVERIFIED;`$(...)` 和反引号会先执行内层、再执行外层,现有静态展开无执行时钟, +也 MUST 保守合并。不得通过此优化遗漏可能提交的敏感内容。 仓库级 `codeguard.skipGate` 只豁免设置它的仓库,不得因 A 仓豁免而跳过同链 B 仓。 内联 `git -c codeguard.skipGate` 只豁免对应 Git 操作;链式 `git config` 设置/取消 须按命令顺序归属到目标仓,不得把 A 仓的豁免传播到 B 仓或此前操作。写入 @@ -330,6 +337,26 @@ index 或工作树;不支持的动态/交互式形态必须明确未验证, - **WHEN** git add 指定包含与排除 pathspec,或字面文件名包含星号 - **THEN** 快照只覆盖 Git 实际匹配的文件,排除项及同名 glob 邻居不会被额外暂存 +#### Scenario: Add after the final commit is not retroactive +- **WHEN** 同仓静态命令链先提交已暂存的安全文件,再 `git add .env`,可选地随后执行 push,但没有第二次 commit +- **THEN** 门禁仍检查已提交内容和待推送 HEAD,不把之后才 add 的 `.env` 算进先前提交;真实 index 不被观察过程改写 + +#### Scenario: Add between commits remains guarded +- **WHEN** 同仓静态命令链先提交安全文件,再 `git add .env` 并进行第二次 commit +- **THEN** 门禁将 `.env` 纳入第二次拟提交内容并阻断整条工具调用 + +#### Scenario: Another repository's later commit does not extend the cutoff +- **WHEN** A 仓先 commit 后 add 敏感文件,命令链随后在 B 仓 commit +- **THEN** A 仓后置 add 不因 B 仓较晚的 commit 被倒算入 A 的提交;两仓的预测 index 均不被改写 + +#### Scenario: Command substitution stages before its outer commit +- **WHEN** 外层 `git commit -m "safe $(git add .env)"` 或反引号等价形式触发内层暂存 +- **THEN** 不按展开文本顺序将内层 add 裁成“提交后操作”;门禁仍将 `.env` 纳入拟提交面并阻断,index 不被观察过程改写 + +#### Scenario: Fallback uses its selected child repository +- **WHEN** 调用目录为非 Git workspace,兜底发现一个暂存违规的子仓及一个无提交面的子仓 +- **THEN** 暂存观察绑定前者的合成仓根,继续阻断其真实违规,且不连坐后者 + ### Requirement: Dockerfile checks SHALL preserve scope and incomplete evidence hadolint 与 Trivy config 的报告解析 MUST 与进程及 CLI 呈现分离。文本和 JSON MUST 使用同一 diff --git a/openspec/changes/refactor-codeguard-architecture/tasks.md b/openspec/changes/refactor-codeguard-architecture/tasks.md index 9b99ff0..7f88b85 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 和逐条规格追溯通过;第二十三批本地 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 检查)。第二十三批门禁故障隔离目前仅在本地,发布需独立验证。宿主安装运行独立记录,未授权不得改宿主。 +- [x] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过;第二十四批本地 521 项单测、143 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 +- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;第二十三批已发布 v0.14.9(插件 PR #55、市场 PR #13、源码 PR/main CI、注释 tag/正式 Release 均核对;市场 PR 无 CI 检查)。第二十四批静态 Git 命令顺序修正目前仅在本地,发布需独立验证。宿主安装运行独立记录,未授权不得改宿主。 - [ ] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。 diff --git a/openspec/changes/refactor-codeguard-architecture/verification.md b/openspec/changes/refactor-codeguard-architecture/verification.md index aaae193..5240b42 100644 --- a/openspec/changes/refactor-codeguard-architecture/verification.md +++ b/openspec/changes/refactor-codeguard-architecture/verification.md @@ -243,3 +243,14 @@ - 三项故障注入先 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 与进程池基础设施级故障仍未验收。 + +- 第二十三批随后发布为 v0.14.9:源码 PR #55 合并到 `a7cff4e8548b2f496f4572c8a9b951a404b6a0d8`,市场 PR #13 合并到 `46a2fc6571d44163f71d626c672930351c052010`;源码 PR 与合并后 main 的 `skills-check` 均成功。远端注释 tag 解引用到源码合并提交,正式 GitHub Release 非 draft、非 prerelease。Codex/ZCode/Kimi 生成市场清单全量校验通过且版本均为 0.14.9;市场 PR 无 CI 检查,不称其 CI 已通过。两仓本地 main 已快进且干净。此发布不覆盖以下第二十四批工作树。 + +## 第二十四批实际验证:按 Git 操作时间投影拟暂存面 + +- 最新源码同步临时 CodeGraph 索引后,沿 `evaluate_git_command → staging_intent → proposed_paths/validation_tree` 追出整条命令链暂存并集的时间归属缺口:安全文件已暂存,`git commit -m safe && git add .env` 或后接 push 会把后置 `.env` 倒算进此前提交并错误拦截。真实 PreToolUse Hook 的两个场景先 RED,index 字节保持不变;`commit && add .env && commit` 的阻断对照仍为 RED 测试中的通过项。 +- `git_staging` 先记录绑定目标仓的不可变 add/commit 事件,再在无间接脚本的硬门禁中投影到该仓最后一次 commit;旧独立 `staging_intent` 默认完整暂存面不变。一层间接脚本缺跨层事件时钟,继续保守并集,不能用本次局部排序削弱可能提交的敏感内容。三个真实 Hook 场景转绿;含 `git add -p` 的后置交互暂存不回溯污染此前提交。 +- 首轮全量发现 workspace 子仓兜底回归:合成子仓目标的暂存观察仍以非 Git workspace cwd 解析,原有脏子仓拦截变成 exit 0。保留原回归用例为 RED 证据,改为兜底时绑定已选子仓根;原测试与目标测试转绿。另增 A 仓先 commit 后 add、B 仓随后 commit 的真实 Hook 用例,证明截止点按仓计算且两个 index 字节均未改变。 +- 继续沿 CodeGraph 的 `_flatten_substitutions → resolve_git_operations/staging_intent` 审查时发现安全反例:`$(git add .env)` 与反引号在外层 commit 前执行,但静态展开把内层文本附到末尾。真实 Hook 测试先 RED(敏感文件被错误放行);现含命令替换的链保守并集,不启用文本顺序截止。两种带有效提交消息的形式均转绿,原始 index 不改写。单引号中的字面替换可能保守误拦,这一历史解析边界未宣称解决。 +- 本地完整单测 **521/521、0 skipped**,真实 Hook 回归 **143/0/0**;Ruff、架构依赖/循环、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 通过。受管技能及 lock 未改;已发布的 v0.14.9 不含本批工作树。当前只证明无脚本/命令替换的直接静态链顺序归属,跨一层脚本与命令替换仍保守合并;Windows、三宿主已安装运行、在线 CVE、大型 Maven/Gradle 与完整 Shell 控制流仍未验收。 +- 宿主只读复核:本机 ZCode `installed_plugins.json` 的 `codeguard@full-stack-plugins` 仍登记 `0.14.7`,不能拿 v0.14.9 的 Release 或市场 ref 当作该宿主的当前加载/触发证据。未修改任何宿主安装。 diff --git a/scripts/check_architecture.py b/scripts/check_architecture.py index 5019de1..a810d4b 100644 --- a/scripts/check_architecture.py +++ b/scripts/check_architecture.py @@ -32,7 +32,8 @@ # scope 是已有文件物化适配器:注入配置/排除目录,尚未迁入内核包。 "codeguard.planning": {"__future__", "collections", "pathlib", "scope", "codeguard.models"}, "codeguard.git_syntax": {"__future__", "pathlib", "re", "shlex"}, - "codeguard.git_staging": {"__future__", "os", "shlex", "pathlib", "git_snapshot", "codeguard.git_syntax"}, + "codeguard.git_staging": {"__future__", "dataclasses", "os", "shlex", "pathlib", + "git_snapshot", "codeguard.git_syntax"}, "codeguard.git_context": {"__future__", "dataclasses", "pathlib", "re", "shlex", "scope", "codeguard.execution", "codeguard.git_syntax", "codeguard.git_staging"}, "codeguard.path_policy": {"__future__", "fnmatch", "pathlib"}, diff --git a/scripts/codeguard/git_guard_application.py b/scripts/codeguard/git_guard_application.py index eaef45a..7c88bc2 100644 --- a/scripts/codeguard/git_guard_application.py +++ b/scripts/codeguard/git_guard_application.py @@ -111,14 +111,21 @@ def evaluate_git_command(command: str, *, cwd: Path, load_config: Callable[[], d root_mode = "push" if any(operation.mode == "push" for operation in active) else "commit" pending_commit = any(operation.mode == "commit" for operation in root_operations) try: - sources = [(command, cwd)] + # workspace 兜底创建的是合成仓库目标;暂存观察也必须绑定该目标, + # 否则从非 Git cwd 解析 commit 会失败并抹掉该子仓已确认的违规。 + sources = [(command, project_root if fallback_note else cwd)] sources.extend((operation.source_command, operation.source_cwd) for operation in root_operations if operation.source_command) + # Shell 命令替换先执行内层、再执行外层 commit;现有静态展开把 + # 内层文本附在末尾,不能把它误判为“提交后的 add”而裁掉。 + ordered_direct = (not any(operation.source_command for operation in root_operations) + and "$(" not in command and "`" not in command) lanes_found: set[str] = set() extras_found: set[str] = set() for source_command, source_cwd in dict.fromkeys(sources): source_lanes, source_extra = staging_intent( source_command, project_root=project_root, cwd=source_cwd, + through_last_commit=ordered_direct, ) lanes_found.update(source_lanes) extras_found.update(source_extra) diff --git a/scripts/codeguard/git_staging.py b/scripts/codeguard/git_staging.py index 884c936..e564933 100644 --- a/scripts/codeguard/git_staging.py +++ b/scripts/codeguard/git_staging.py @@ -3,6 +3,7 @@ import os import shlex +from dataclasses import dataclass from pathlib import Path from git_snapshot import SnapshotError, git @@ -12,6 +13,18 @@ _LANES = ("staged", "unstaged", "untracked") +@dataclass(frozen=True) +class _StagingAction: + """已绑定仓库的静态暂存事件;投影阶段再决定是否影响拟提交面。""" + + kind: str + root: Path + location: Path + flags: tuple[str, ...] = () + paths: tuple[str, ...] = () + literal: bool = False + + def repository_root(cwd: Path) -> Path: """Git 返回真实 worktree 根,不将仓内子目录当仓库身份。""" return Path(os.fsdecode(git(cwd, "rev-parse", "--show-toplevel")).strip()).resolve() @@ -30,17 +43,21 @@ def resolve_pathspecs(cwd: Path, paths: list[str], *, tracked_only=False, def staging_intent(command: str, project_root: Path | None = None, *, - cwd: Path | None = None) -> tuple[tuple[str, ...], list[str]]: + cwd: Path | None = None, + through_last_commit: bool = False) -> tuple[tuple[str, ...], list[str]]: """逐段绑定 cd/-C 与仓库;返回 lanes 和已解析的仓根相对字面路径。 保留旧无 project_root 的调用面;硬门禁必须传目标仓,以免多仓范围串扰。 不执行 add,不模拟动态 Shell、交互暂存或 pathspec-from-file;不能证明时抛 SnapshotError,由 Hook 以既有 fail-open 协议明示未验证。 + through_last_commit 仅供无间接脚本的硬门禁使用:同仓最后一次 commit + 之后的 add 不属于本次拟提交内容。默认保持旧的完整暂存意图调用面。 """ current_dir = (cwd or Path.cwd()).resolve() wanted = Path(project_root).resolve() if project_root is not None else None - lanes = {"staged"} - extra = set() + if through_last_commit and wanted is None: + raise ValueError("按提交顺序投影暂存面需要明确目标仓") + actions: list[_StagingAction] = [] for segment, _separator in split_shell_segments(_flatten_substitutions(command)): try: words = shlex.split(segment) @@ -55,15 +72,14 @@ def staging_intent(command: str, project_root: Path | None = None, *, if len(tokens) < 2 or tokens[0] != "git" or tokens[1] not in ("add", "commit"): continue sub, rest = tokens[1], tokens[2:] - if sub == "commit" and not any(flag in ("-a", "-am", "--all") for flag in rest): - continue location = (current_dir / c_path).resolve() if c_path is not None else current_dir repo = repository_root(location) if wanted is not None and repo != wanted: continue if sub == "commit": - if any(flag in ("-a", "-am", "--all") for flag in rest): - lanes.add("unstaged") + actions.append(_StagingAction("commit", repo, location, + flags=tuple(flag for flag in rest + if flag in ("-a", "-am", "--all")))) continue flags, paths = [], [] positional = False @@ -74,6 +90,21 @@ def staging_intent(command: str, project_root: Path | None = None, *, flags.append(item) else: paths.append(item) + literal = "--literal-pathspecs" in words[:words.index("add")] if "add" in words else False + actions.append(_StagingAction("add", repo, location, tuple(flags), tuple(paths), literal)) + + cutoff = len(actions) + if through_last_commit: + cutoff = max((index + 1 for index, action in enumerate(actions) + if action.kind == "commit"), default=0) + lanes = {"staged"} + extra = set() + for action in actions[:cutoff]: + if action.kind == "commit": + if action.flags: + lanes.add("unstaged") + continue + flags, paths = action.flags, action.paths if any(flag not in ("-A", "--all", "-u", "--update", "-f", "--force", "-v", "--verbose") for flag in flags): raise SnapshotError("不支持的 git add 选项,需要独立验证暂存面") if any("$" in path or "`" in path for path in paths): @@ -87,9 +118,9 @@ def staging_intent(command: str, project_root: Path | None = None, *, elif all_files: lanes.update(_LANES) continue - if paths in (["."], ["./"], [":/"]) and location == repo and not force: + if paths in ((".",), ("./",), (":/",)) and action.location == action.root and not force: lanes.update(("staged", "unstaged") if tracked else _LANES) continue - literal = "--literal-pathspecs" in words[:words.index("add")] if "add" in words else False - extra.update(resolve_pathspecs(location, paths, tracked_only=tracked, force=force, literal=literal)) + extra.update(resolve_pathspecs(action.location, list(paths), tracked_only=tracked, + force=force, literal=action.literal)) return tuple(lane for lane in _LANES if lane in lanes), sorted(extra) diff --git a/tests/test_git_staging.py b/tests/test_git_staging.py index 02b11d9..1c7062a 100644 --- a/tests/test_git_staging.py +++ b/tests/test_git_staging.py @@ -131,6 +131,82 @@ def test_real_hook_detects_sensitive_glob_before_add_without_touching_index(self self.assertIn("private/a.pem", result.stderr) self.assertEqual(before, (self.root / ".git/index").read_bytes()) + def test_real_hook_does_not_project_add_after_final_commit_backwards(self): + self.put("base.txt", "safe update") + self.git("add", "base.txt") + self.put(".env", "SECRET=fixture-only") + before = (self.root / ".git/index").read_bytes() + for command in ( + "git commit -m safe && git add .env", + "git commit -m safe && git add .env && git push origin main", + "git commit -m safe && git add -p", + ): + with self.subTest(command=command): + payload = {"tool_name": "Bash", "tool_input": {"command": command}} + result = subprocess.run([sys.executable, str(ROOT / "hooks/pre_tool_git_guard.py")], + input=json.dumps(payload), cwd=self.root, capture_output=True, + text=True, check=False, timeout=15, + env={**os.environ, "CODEGUARD_HOME": str(self.root.parent / "state")}) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertNotIn(".env", result.stderr) + self.assertEqual(before, (self.root / ".git/index").read_bytes()) + + def test_real_hook_still_blocks_add_between_two_commits(self): + self.put("base.txt", "safe update") + self.git("add", "base.txt") + self.put(".env", "SECRET=fixture-only") + before = (self.root / ".git/index").read_bytes() + payload = {"tool_name": "Bash", "tool_input": {"command": + "git commit -m safe && git add .env && git commit -m sensitive"}} + result = subprocess.run([sys.executable, str(ROOT / "hooks/pre_tool_git_guard.py")], + input=json.dumps(payload), cwd=self.root, capture_output=True, + text=True, check=False, timeout=15, + env={**os.environ, "CODEGUARD_HOME": str(self.root.parent / "state")}) + self.assertEqual(2, result.returncode, result.stdout + result.stderr) + self.assertIn(".env", result.stderr) + self.assertEqual(before, (self.root / ".git/index").read_bytes()) + + def test_command_substitution_add_runs_before_outer_commit(self): + self.put("base.txt", "safe update") + self.git("add", "base.txt") + self.put(".env", "SECRET=fixture-only") + before = (self.root / ".git/index").read_bytes() + for command in ('git commit -m "safe $(git add .env)"', + 'git commit -m "safe `git add .env`"'): + with self.subTest(command=command): + payload = {"tool_name": "Bash", "tool_input": {"command": command}} + result = subprocess.run([sys.executable, str(ROOT / "hooks/pre_tool_git_guard.py")], + input=json.dumps(payload), cwd=self.root, capture_output=True, + text=True, check=False, timeout=15, + env={**os.environ, "CODEGUARD_HOME": str(self.root.parent / "state")}) + self.assertEqual(2, result.returncode, result.stdout + result.stderr) + self.assertIn(".env", result.stderr) + self.assertEqual(before, (self.root / ".git/index").read_bytes()) + + def test_direct_chain_cutoff_is_per_repository(self): + other = self.root.parent / "other" + other.mkdir() + subprocess.run(["git", "init", "-q", str(other)], check=True, capture_output=True) + (other / "safe.txt").write_text("safe", encoding="utf-8") + subprocess.run(["git", "-C", str(other), "add", "safe.txt"], + check=True, capture_output=True) + self.put("base.txt", "safe update") + self.git("add", "base.txt") + self.put(".env", "SECRET=fixture-only") + before = (self.root / ".git/index").read_bytes() + other_before = (other / ".git/index").read_bytes() + command = (f"git -C {self.root} commit -m safe && git -C {self.root} add .env && " + f"git -C {other} commit -m other") + payload = {"tool_name": "Bash", "tool_input": {"command": command}} + result = subprocess.run([sys.executable, str(ROOT / "hooks/pre_tool_git_guard.py")], + input=json.dumps(payload), cwd=self.root, capture_output=True, + text=True, check=False, timeout=15, + env={**os.environ, "CODEGUARD_HOME": str(self.root.parent / "state")}) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertNotIn(".env", result.stderr) + self.assertEqual(before, (self.root / ".git/index").read_bytes()) + self.assertEqual(other_before, (other / ".git/index").read_bytes()) + def test_hook_normalizes_subdirectory_to_worktree_root(self): self.put("space dir/a.pem", "fixture-only") commands = [(f"git -C {shlex.quote(str(self.root / 'space dir'))} add '*.pem' && "