From 72923f4b4873882b9b57c1e1a4a9f3c14852c252 Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:41:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=E5=A4=9A=E4=BB=93=20G?= =?UTF-8?q?it=20=E9=97=A8=E7=A6=81=E5=88=A4=E5=AE=9A=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E5=B8=83=20v0.14.4?= 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 +- docs/current-architecture.md | 1 + kimi.plugin.json | 2 +- .../specs/execution-kernel/spec.md | 35 +++ .../refactor-codeguard-architecture/tasks.md | 4 +- .../verification.md | 9 + scripts/check_architecture.py | 4 +- scripts/codeguard/git_context.py | 80 +++++-- scripts/codeguard/git_guard_application.py | 69 +++--- scripts/codeguard/git_staging.py | 5 +- scripts/codeguard/git_syntax.py | 94 ++++++-- tests/test_git_guard_application.py | 219 +++++++++++++++++- 14 files changed, 444 insertions(+), 90 deletions(-) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index abd7b7c..7555972 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.3" + "ref": "v0.14.4" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.14.3", + "version": "0.14.4", "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.3/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.4/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.3/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.4/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 3eca08e..432d0c2 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.3+codex.20260923", + "version": "0.14.4+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 7969d05..e5233e1 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.14.3", + "version": "0.14.4", "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/docs/current-architecture.md b/docs/current-architecture.md index 6c28c6d..1845110 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -28,6 +28,7 @@ flowchart TD `execution` 只记录进程证据,不把非零退出自动认定为代码违规。`verdict`、CVE 与 Dockerfile 报告解析决定 PASS、FAIL 或 UNVERIFIED;入口仅按各自协议呈现和聚合退出码。Git 提交检查使用 index 或预测暂存快照,推送检查使用 HEAD;保存钩子只给反馈。计划、未执行和未验证不能宣传为通过。 Git 命令的仓库定位与拟暂存解析共用入口传入的 cwd;显式 `cd`/`git -C` 无法绑定 Git 工作树时,硬门禁给出目标未验证并阻断,不退回到调用者仓或无关子仓。未给出显式目标且调用目录非仓时仍保留 workspace 子仓兜底。静态命令解析不等于完整 Shell 执行模拟。 +一条命令链中的 Git 副作用先绑定为仓库与操作的有序对,再按仓库聚合检查面:纯 push 不读取该仓未提交的 index,同仓 commit→push 则保留已有 HEAD 与拟提交内容的并集。仓库级、内联及链式 `skipGate` 均按目标仓与操作顺序生效;已存在的仓库配置也可被同链先行 `git config --unset` 取消。链式设置跨 `||`、`;` 或换行时无法证明生效,门禁要求拆分命令;写入其它文件的 config 设置也不构成本仓豁免。语法、仓库归属、间接脚本与拟暂存分析共用引号/转义感知切分;参数文本中的控制符不能合成豁免。解析能力仍限于受支持的静态 Shell 形态。 ## 模块所有权 diff --git a/kimi.plugin.json b/kimi.plugin.json index c420316..a7a19b2 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.3", + "version": "0.14.4", "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/specs/execution-kernel/spec.md b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md index 3068c99..d9f7a49 100644 --- a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md +++ b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md @@ -199,11 +199,46 @@ index 或工作树;不支持的动态/交互式形态必须明确未验证, 显式 `cd` 或 `git -C` 已给出目标时,若该目标不是可解析的 Git 工作树,MUST 阻断 该 Git 副作用命令并报告目标不可确定;不得回退调用者仓或扫调用者一层子仓。 只有命令未给出显式目标且调用目录不是 Git 仓时,才保留既有 workspace 子仓兜底策略。 +一条命令链触及多个仓库时,commit/push 门禁面和是否存在待提交操作 MUST 按仓库分别 +归属;A 仓的 commit 不得使 B 仓的纯 push 检查包含 B 仓尚未提交的 index。若同一仓 +同时 commit 和 push,则继续检查该仓已有待推送内容及拟提交内容,不得缩窄既有检查面。 +仓库级 `codeguard.skipGate` 只豁免设置它的仓库,不得因 A 仓豁免而跳过同链 B 仓。 +内联 `git -c codeguard.skipGate` 只豁免对应 Git 操作;链式 `git config` 设置/取消 +须按命令顺序归属到目标仓,不得把 A 仓的豁免传播到 B 仓或此前操作。写入 +其它配置文件的 `git config --file=...` 不构成当前仓库生效的豁免。 +链式配置变更只有经 `&&` 确认成功路径后才能传递到后续操作;跨越 `||`、`;` +或换行后的配置状态无法静态证明时,MUST 阻断并提示拆分命令,不得猜测已生效。 +命令切分 MUST 区分真实 Shell 分隔符和被引用/转义的字面文本;提交消息、echo 参数 +或文件路径中的 `;`、`&&` 不得生成虚假的 Git 操作或豁免设置。 #### Scenario: Two repositories have different staging commands - **WHEN** 一条命令链对 A 执行 add -A、对 B 只提交已暂存内容 - **THEN** B 的未暂存及未跟踪文件不进入拟提交检查面 +#### Scenario: Commit and push belong to different repositories +- **WHEN** 一条命令链提交 A 仓、只推送 B 仓,而 B 仓另有未提交的暂存改动 +- **THEN** A 使用 commit 面,B 使用纯 push 的 HEAD 面;B 的暂存改动不因 A 的 commit 被误纳入 + +#### Scenario: Commit and push belong to the same repository +- **WHEN** 同一仓在一条命令链中先提交再推送 +- **THEN** 门禁同时覆盖该仓拟提交 index 和已有待推送 HEAD 内容 + +#### Scenario: A bypass belongs to its repository +- **WHEN** 同链 A 仓设置了 `codeguard.skipGate`,B 仓没有豁免且包含应拦截内容 +- **THEN** 只审计并跳过 A 仓,仍对 B 仓运行完整门禁并阻断其违规内容 + +#### Scenario: Inline and chain bypasses are not global +- **WHEN** A 仓使用单次 `git -c` 或链式 `git config` 豁免,B 仓在同链执行无豁免提交 +- **THEN** 仅 A 的指定操作获得豁免,B 仓仍完整检查;同仓 unset 后的操作也不继承之前的豁免 + +#### Scenario: A conditional bypass setter cannot be assumed successful +- **WHEN** `git config codeguard.skipGate true` 后以 `||`、`;` 或换行连接提交,或设置写入其它配置文件 +- **THEN** 不把该设置当作已生效的本仓豁免;无法证明控制流时报告 UNVERIFIED 并要求拆分命令 + +#### Scenario: Quoted control characters are data +- **WHEN** `echo` 的引号参数或 commit 消息包含 `; git config codeguard.skipGate true` +- **THEN** 不产生虚假的 config 设置,随后未豁免的提交仍须检查并阻断违规内容 + #### Scenario: An explicit Git target is not a repository - **WHEN** 命令从 Git 仓调用,但在 `cd` 到非 Git 目录后运行 `git push`,或使用无效的 `git -C` 目标 - **THEN** 门禁以明确诊断阻断,而不是检查调用者仓或用 workspace 兜底;后续显式 `git -C` 指向有效仓时仍能正确绑定该仓 diff --git a/openspec/changes/refactor-codeguard-architecture/tasks.md b/openspec/changes/refactor-codeguard-architecture/tasks.md index 5e21fad..44d2abc 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 检查。 -- [ ] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过。 -- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;v0.14.2 阶段版本已完成插件/市场双仓合并、PR/main CI、tag/Release,但本 change 后续改动仍需独立发布。宿主安装运行独立记录,未授权不得改宿主。 +- [x] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过;第十八批 481 项单测、143 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 +- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;v0.14.3 阶段版本已完成插件/市场双仓合并、PR/main CI、tag/Release,但本 change 后续改动仍需独立发布。宿主安装运行独立记录,未授权不得改宿主。 - [ ] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。 diff --git a/openspec/changes/refactor-codeguard-architecture/verification.md b/openspec/changes/refactor-codeguard-architecture/verification.md index c3b92b0..4babb47 100644 --- a/openspec/changes/refactor-codeguard-architecture/verification.md +++ b/openspec/changes/refactor-codeguard-architecture/verification.md @@ -186,3 +186,12 @@ - 当前 `GitTargetError` 将显式目标不可绑定与无显式目标的 workspace 兜底区分;前者在应用边界返回 exit 2 和具体路径,不再检查错误仓。Git 根观察的 `SnapshotError` 同样不交给顶层 fail-open。拟暂存解析接受可选显式 cwd,并由应用传入同一调用目录;旧无参导入/调用仍可使用进程 cwd。真实 PreToolUse 子进程证明无效目标阻断,`git -C` 有效目标仍覆盖非 Git cd。 - TDD RED 包含错误放行、错误兜底、遗漏 pathspec 与根观察异常;GREEN 后完整单测 **470/470、0 skipped**,真实 Hook 回归 **143/0/0**。ruff、架构门禁、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 通过;受管技能与 lock 未改。 - 这不是 Shell 求值器:条件链、动态拼接、命令替换、复杂引用和链内环境变化仍是明确边界。三宿主现场、Windows、联网漏洞库与全部语言工具链未因此获得验收。本批在此记录时尚未 bump/提交/发布。 + +## 第十八批实际验证:多仓操作与豁免归属 + +- v0.14.3 已经插件 PR #49、市场 PR #6 合并到双仓 `main`;插件 PR 及合并后 `main` 的 `skills-check` 成功,不可变注释 tag 与 GitHub Release 解引用到 `f646f031bf35d269da82d4f70c0a4666991eb175`,市场三宿主版本为 0.14.3。该发布只覆盖第十七批及此前源码,不覆盖本批修改。 +- 临时 CodeGraph 快照从最新 main 同步并追踪 `evaluate_git_command → resolve_git_operations → validation_tree/proposed_paths`。原实现按整链 `push` 和 `pending_commit` 选面:A 仓 commit、B 仓纯 push 时,B 暂存的 `.env` 被错误纳入本次推送并阻断。真实双 Git 仓测试先 RED;现在 `GitOperation` 保留每个副作用的仓、模式与有序豁免状态,B 只检查 HEAD,同仓 commit→push 仍检查已有待推送内容与拟提交内容。 +- 仓库级 `skipGate` 原先以 `any(...)` 令 A 仓配置豁免 B 仓;内联 `-c` 和链式 `git config` 也会全链放行。新增测试分别先 RED,再证明只豁免所属仓与操作;同仓后续无豁免提交仍受检,后置设置不追溯前面的提交。已有仓库配置为 true 但命令先 unset 再提交也曾被预读配置误放行,现按命令序列覆盖旧配置。旧 `resolve_project_roots` 与语法布尔 helper 导入面保留。 +- 追加 RED:`git config --file=其它文件` 错被当成本仓豁免;`git config ... || git commit` 错把仅在设置失败时才会执行的提交放行。现在前者不构成豁免,后者及 `;`/换行连接的配置状态报告 UNVERIFIED 并要求拆分命令。未把完整 Shell 求值器伪装成已实现。 +- 完整单测 **479/479、0 skipped**;真实 Hook 回归 **143/0/0**。Ruff、架构依赖/循环、语言 schema **57 项/11 规则**、vendor 离线及在线、本 change strict、diff whitespace 均通过;受管技能及 lock 未改。测试覆盖真实 Git/快照与应用层故障,但静态 Shell 解析仍非完整求值器,三宿主现场、Windows、在线漏洞库和全部语言工具链仍未独立验收。本批发布闭环待执行。 +- 后续 RED 发现:`echo "docs; git config codeguard.skipGate true" && git commit` 会把引号内分号当真实分隔符,错误放行含 `.env` 的提交。语法/仓库/间接脚本/拟暂存改用同一个引号及转义感知切分函数;真实 Git 提交安全用例与纯语法用例转绿。复核后完整单测 **481/481、0 skipped**,真实 Hook 回归 **143/0/0**;Ruff、架构依赖/循环、语言 schema **57 项/11 规则**、vendor 离线及在线、本 change strict、diff whitespace 均通过。本批发布证据仍待补齐。 diff --git a/scripts/check_architecture.py b/scripts/check_architecture.py index 58936a1..7eb8176 100644 --- a/scripts/check_architecture.py +++ b/scripts/check_architecture.py @@ -32,8 +32,8 @@ # scope 是已有文件物化适配器:注入配置/排除目录,尚未迁入内核包。 "codeguard.planning": {"__future__", "collections", "pathlib", "scope", "codeguard.models"}, "codeguard.git_syntax": {"__future__", "pathlib", "re", "shlex"}, - "codeguard.git_staging": {"__future__", "os", "re", "shlex", "pathlib", "git_snapshot", "codeguard.git_syntax"}, - "codeguard.git_context": {"__future__", "pathlib", "re", "shlex", "scope", + "codeguard.git_staging": {"__future__", "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"}, "codeguard.storage": {"__future__", "contextlib", "json", "os", "tempfile", "time", diff --git a/scripts/codeguard/git_context.py b/scripts/codeguard/git_context.py index 105f4b9..8172cd4 100644 --- a/scripts/codeguard/git_context.py +++ b/scripts/codeguard/git_context.py @@ -1,7 +1,9 @@ """将静态 Git 意图绑定到本地仓库;负责文件/进程观察,不产生宿主输出。""" from __future__ import annotations +import re import shlex +from dataclasses import dataclass from pathlib import Path from scope import changed_files @@ -17,45 +19,63 @@ _git_c_path, _git_side_effect_sub, _segment_is_git_side_effect, + inline_skip_gate, + skip_gate_config_change, + split_shell_segments, ) _SCRIPT_SUFFIXES = (".sh", ".bash", ".zsh", ".py", ".mjs", ".js", ".ts") _INTERPRETERS = ("bash", "sh", "zsh", "python", "python3", "node") -class GitTargetError(ValueError): +class GitIntentError(ValueError): + """静态命令意图无法可靠归属时拒绝猜测。""" + + +class GitTargetError(GitIntentError): """显式 Git 目标无法绑定时拒绝猜测其它仓库。""" +@dataclass(frozen=True) +class GitOperation: + """一个静态 Git 副作用及其实际仓库;门禁面不能跨仓传播。""" + + root: Path + mode: str + bypass: str | None = None + config_state: bool | None = None + + def _is_git_repo(p: Path) -> bool: """只读查询 Git 仓边界;工具不可用时交由调用者降级。""" return execute(["git", "-C", str(p), "rev-parse", "--git-dir"], Path.cwd(), 10).returncode == 0 -def resolve_project_roots(command: str, cwd: Path | None = None) -> list[Path]: - """顺序扫描命令链,收集每个 git commit/push 段各自的仓库边界。 +def resolve_git_operations(command: str, cwd: Path | None = None) -> list[GitOperation]: + """顺序扫描命令链,将每个 git commit/push 绑定到自己的仓库。 链式发布(cd plugins && git commit && cd minimax && git push)操作 多个仓库——每个 git 段的边界 = `git -C ` 显式指定的仓,否则取它 之前最近的 cd(且必须是 git 仓);无 cd 才用 cwd(需是 git 仓)。 - 去重保序,找不到任何边界返回 []。 + 保留操作顺序,找不到任何边界返回 []。 **必须与 is_guarded 同源判定**(`_git_side_effect_sub` 归一化后判子命令): 此前这里用原始 tokens[0]=="git",`git -C /repo push`、`FOO=1 git push`、 `sudo git push` 虽被 is_guarded 命中却解析出 roots=[],main() 见空直接 放行——归一化修复在 roots 层被击穿(实测三形态全部静默通过)。 """ - import re command = _flatten_substitutions(command) - roots: list[Path] = [] + operations: list[GitOperation] = [] + chain_skip_states: dict[Path, bool] = {} + uncertain_roots: set[Path] = set() last_cd: Path | None = None explicit_cd = False caller_dir = (cwd or Path.cwd()).resolve() current_dir = caller_dir - for seg in re.split(r"&&|\|\||;|\n", command): - seg = seg.strip() - if not seg: - continue + for seg, separator in split_shell_segments(command): + if separator in ("||", ";", "\n"): + uncertain_roots.update(chain_skip_states) + chain_skip_states.clear() m = re.match(r"cd\s+(\"[^\"]+\"|'[^']+'|\S+)", seg) if m: target = (current_dir / m.group(1).strip("\"'")).resolve() @@ -63,7 +83,9 @@ def resolve_project_roots(command: str, cwd: Path | None = None) -> list[Path]: explicit_cd = True last_cd = target if target.is_dir() and _is_git_repo(target) else None continue - if _git_side_effect_sub(seg) is None: + mode = _git_side_effect_sub(seg) + skip_change = skip_gate_config_change(seg) if mode is None else None + if mode is None and skip_change is None: continue c_path = _git_c_path(seg) if c_path is not None: @@ -77,10 +99,25 @@ def resolve_project_roots(command: str, cwd: Path | None = None) -> list[Path]: raise GitTargetError(f"cd 显式目标不是可解析的 Git 工作树:{current_dir}") root = last_cd if explicit_cd else (caller_dir if _is_git_repo(caller_dir) else None) if root: - root = repository_root(root) - if root not in roots: - roots.append(root) - return roots + repo = repository_root(root) + if skip_change is not None: + chain_skip_states[repo] = skip_change + uncertain_roots.discard(repo) + else: + if repo in uncertain_roots: + raise GitIntentError( + f"{repo} 的链式 skipGate 跨越非 && 分隔符,配置是否生效无法证明;" + "请拆分命令后重试" + ) + bypass = ("inline-skipGate" if inline_skip_gate(seg) else + "chain-skipGate" if chain_skip_states.get(repo) else None) + operations.append(GitOperation(repo, mode, bypass, chain_skip_states.get(repo))) + return operations + + +def resolve_project_roots(command: str, cwd: Path | None = None) -> list[Path]: + """兼容旧导入面:返回 Git 副作用涉及的仓根,去重且保序。""" + return list(dict.fromkeys(op.root for op in resolve_git_operations(command, cwd))) def _fallback_roots(cwd: Path) -> list[Path]: @@ -113,8 +150,7 @@ def _fallback_roots(cwd: Path) -> list[Path]: def _indirect_bodies(command: str): """只读取得一层解释器脚本/内联文本,共享大小上限与读取失败边界。""" - import re - for seg in re.split(r"&&|\|\||;|\n", command): + for seg, _separator in split_shell_segments(command): try: tokens = shlex.split(seg) except ValueError: @@ -146,10 +182,9 @@ def _indirect_bodies(command: str): def _command_indirect(command: str) -> bool: """兼容一层间接识别;不执行脚本,不承诺分析动态拼接的 subprocess。""" - import re return any(_segment_is_git_side_effect(segment) for body in _indirect_bodies(command) - for segment in re.split(r"&&|\|\||;|\n", body)) + for segment, _separator in split_shell_segments(body)) def _repo_root_of(p: Path) -> Path | None: @@ -167,12 +202,13 @@ def _repo_root_of(p: Path) -> Path | None: def _guarded_mode(command: str) -> str | None: - """门禁命中时返回生效的门禁面:commit 或 push(push 优先——两面并存时 - 推送面是提交面的超集,按更宽的面检查);未命中返回 None。 + """门禁命中时返回兜底门禁面:commit 或 push(同一仓两面并存时推送面 + 携带 pending_commit 可覆盖两面);未命中返回 None。 直接命令段先判定;未命中再走一层解释器间接(脚本/-c 内联)。 is_guarded 即本函数的存在性判断——面判定与命中判定共用同一套扫描, - 避免"命中了却选错面"的分叉。 + 避免"命中了却选错面"的分叉。可直接解析的命令由 GitOperation 按仓 + 分别选择门禁面,不能把这里的整链模式传播到其它仓。 """ subs = _collect_subs(command) if not subs: diff --git a/scripts/codeguard/git_guard_application.py b/scripts/codeguard/git_guard_application.py index cd41cbf..9ea878f 100644 --- a/scripts/codeguard/git_guard_application.py +++ b/scripts/codeguard/git_guard_application.py @@ -8,7 +8,13 @@ from git_snapshot import SnapshotError from .gate import run_gate -from .git_context import GitTargetError, resolve_project_roots, staging_intent +from .git_context import ( + GitIntentError, + GitOperation, + GitTargetError, + resolve_git_operations, + staging_intent, +) from .git_context import ( _fallback_roots as fallback_roots, ) @@ -18,8 +24,6 @@ from .git_context import ( _guarded_mode as guarded_mode, ) -from .git_syntax import _collect_subs as collect_subs -from .git_syntax import chain_skip_gate, inline_skip_gate from .hook_state import record_skip_event from .reporting import format_safety_report, gate_directive from .repository_policy import check_commit_safety, skip_gate_via_git_config @@ -46,17 +50,23 @@ def evaluate_git_command(command: str, *, cwd: Path, load_config: Callable[[], d cfg = load_config() try: - roots = resolve_project_roots(command, cwd=cwd) - except (GitTargetError, SnapshotError) as exc: + operations = resolve_git_operations(command, cwd=cwd) + except (GitIntentError, SnapshotError) as exc: + label = "Git 目标" if isinstance(exc, GitTargetError) else "Git 意图" return GitGuardResult( 2, - stderr=f"[codeguard] Git 目标 UNVERIFIED:{exc}。请确认命令实际工作目录后重新提交。", + stderr=f"[codeguard] {label} UNVERIFIED:{exc}。请确认命令语义后重新提交。", ) fallback_note: str | None = None + operations_by_root: dict[Path, list[GitOperation]] = {} + for operation in operations: + operations_by_root.setdefault(operation.root, []).append(operation) + roots = list(operations_by_root) if not roots: fallback = fallback_roots(cwd) if fallback: roots = fallback + operations_by_root = {root: [GitOperation(root, mode)] for root in roots} fallback_note = f"未检测到显式 cd 仓;兜底扫描 cwd 一层子目录找到 {len(fallback)} 个 git 仓" else: return GitGuardResult( @@ -68,33 +78,38 @@ def evaluate_git_command(command: str, *, cwd: Path, load_config: Callable[[], d if fallback_note: before = len(roots) roots = filter_fallback_roots(roots, mode, None) + operations_by_root = {root: operations_by_root[root] for root in roots} fallback_note += f";提交面收窄 {before}→{len(roots)} 个仓" if not roots: record_skip_event("monorepo-fallback-empty", detail=fallback_note) return GitGuardResult() record_skip_event("monorepo-fallback", roots[0], detail=fallback_note) - if any(skip_gate_via_git_config(root) for root in roots): - record_skip_event("skipGate", roots[0]) - return GitGuardResult() - if inline_skip_gate(command): - record_skip_event("inline-skipGate", roots[0]) - context = ( - "codeguard: 已通过内联豁免 `-c codeguard.skipGate` 放行本次提交" - "(已审计记录,Stop 摘要可见)。" - ) - return GitGuardResult(contexts=(context,)) - if chain_skip_gate(command): - record_skip_event("chain-skipGate", roots[0]) - context = ( - "codeguard: 已通过链式豁免(set→提交→unset 同链)放行本次提交" - "(已审计记录,Stop 摘要可见)。" - ) - return GitGuardResult(contexts=(context,)) - - pending_commit = "commit" in collect_subs(command) contexts: list[str] = [] reports: list[str] = [] for project_root in roots: + root_operations = operations_by_root[project_root] + persisted_skip = (skip_gate_via_git_config(project_root) + if any(operation.config_state is None for operation in root_operations) + else False) + active: list[GitOperation] = [] + recorded_skips: set[str] = set() + for operation in root_operations: + bypass = operation.bypass or ( + "skipGate" if operation.config_state is None and persisted_skip else None + ) + if bypass is None: + active.append(operation) + continue + if bypass not in recorded_skips: + record_skip_event(bypass, project_root) + recorded_skips.add(bypass) + if bypass != "skipGate": + label = "内联豁免 `-c codeguard.skipGate`" if bypass == "inline-skipGate" else "链式豁免" + contexts.append(f"codeguard: {project_root} 的 {operation.mode} 已通过{label}放行(已审计记录)。") + if not active: + continue + 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: lanes, extra = staging_intent(command, project_root=project_root, cwd=cwd) except SnapshotError as exc: @@ -105,7 +120,7 @@ def evaluate_git_command(command: str, *, cwd: Path, load_config: Callable[[], d continue root_extra = list(extra) failures, skipped = run_gate( - project_root, cfg, mode=mode, lanes=lanes, extra=root_extra, + project_root, cfg, mode=root_mode, lanes=lanes, extra=root_extra, exact=True, pending_commit=pending_commit, ) if failures: @@ -114,7 +129,7 @@ def evaluate_git_command(command: str, *, cwd: Path, load_config: Callable[[], d and "markdown 风格告警" not in item] if unknown: contexts.append("codeguard: 存在未验证项,不能宣称全部通过:" + ";".join(unknown)) - violations = check_commit_safety(project_root, mode, lanes=lanes, extra=root_extra, + violations = check_commit_safety(project_root, root_mode, lanes=lanes, extra=root_extra, pending_commit=pending_commit) if violations: reports.append(format_safety_report(violations) + ( diff --git a/scripts/codeguard/git_staging.py b/scripts/codeguard/git_staging.py index 5577f4b..884c936 100644 --- a/scripts/codeguard/git_staging.py +++ b/scripts/codeguard/git_staging.py @@ -2,13 +2,12 @@ from __future__ import annotations import os -import re import shlex from pathlib import Path from git_snapshot import SnapshotError, git -from .git_syntax import _analyze_segment, _flatten_substitutions +from .git_syntax import _analyze_segment, _flatten_substitutions, split_shell_segments _LANES = ("staged", "unstaged", "untracked") @@ -42,7 +41,7 @@ def staging_intent(command: str, project_root: Path | None = None, *, wanted = Path(project_root).resolve() if project_root is not None else None lanes = {"staged"} extra = set() - for segment in re.split(r"&&|\|\||;|\n", _flatten_substitutions(command)): + for segment, _separator in split_shell_segments(_flatten_substitutions(command)): try: words = shlex.split(segment) except ValueError as exc: diff --git a/scripts/codeguard/git_syntax.py b/scripts/codeguard/git_syntax.py index 2b0cf8c..be44224 100644 --- a/scripts/codeguard/git_syntax.py +++ b/scripts/codeguard/git_syntax.py @@ -29,6 +29,45 @@ "--list", "-l") +def split_shell_segments(command: str) -> list[tuple[str, str | None]]: + """只按未引用、未转义的 Shell 控制符切段,保留前置分隔符。 + + 这不是完整 Shell 解释器;嵌套替换由 `_flatten_substitutions` 另行展开。 + 引号内的 `;`/`&&` 和 `\\;` 是参数数据,不能合成 git config 豁免。 + """ + segments: list[tuple[str, str | None]] = [] + start = 0 + before: str | None = None + quote: str | None = None + escaped = False + i = 0 + while i < len(command): + char = command[i] + if escaped: + escaped = False + elif char == "\\" and quote != "'": + escaped = True + elif quote is not None: + if char == quote: + quote = None + elif char in ("'", '"'): + quote = char + elif char in (";", "\n") or command[i:i + 2] in ("&&", "||"): + separator = command[i:i + 2] if command[i:i + 2] in ("&&", "||") else char + segment = command[start:i].strip() + if segment: + segments.append((segment, before)) + before = separator + i += len(separator) + start = i + continue + i += 1 + segment = command[start:].strip() + if segment: + segments.append((segment, before)) + return segments + + def _flatten_substitutions(command: str) -> str: """把 `$(...)` 与反引号的内层文本并入待扫面(按 shell 语义它们会真的执行)。 @@ -193,8 +232,7 @@ def inline_skip_gate(command: str) -> bool: 同名字符串不构成豁免;value 精确匹配 _SKIP_GATE_ASSIGN。 """ text = _flatten_substitutions(command) - import re as _re - for seg in _re.split(r"&&|\|\||;|\n", text): + for seg, _separator in split_shell_segments(text): tokens = [t.strip("\"'") for t in seg.strip().split()] parts = _git_seg_parts(tokens) if parts is None: @@ -206,6 +244,34 @@ def inline_skip_gate(command: str) -> bool: return False +def skip_gate_config_change(segment: str) -> bool | None: + """解析单段仓库级豁免变更;True 为设置,False 为取消,None 为无关。""" + tokens = [token.strip("\"'") for token in segment.strip().split()] + parts = _git_seg_parts(tokens) + if parts is None: + return None + sub, index, _pre = parts + if sub != "config": + return None + rest = tokens[index + 1:] + # 其它文件或跨仓配置作用域不是当前仓库的可证明本地豁免。 + if any(token in ("-f", "--file", "--global", "--system", "--blob") + or token.startswith(("-f", "--file=", "--blob=")) for token in rest): + return None + if "--unset" in rest or "--unset-all" in rest: + return False if any(token.lower() == "codeguard.skipgate" for token in rest) else None + if any(token in _QUERY_CONFIG_FLAGS for token in rest): + return None + args = [token for token in rest if not token.startswith("-")] + if len(args) < 2 or args[0].lower() != "codeguard.skipgate": + return None + if args[1].lower() in ("true", "1", "yes"): + return True + if args[1].lower() in ("false", "0", "no"): + return False + return None + + def chain_skip_gate(command: str) -> bool: """命令链里含 `git config codeguard.skipGate ` **设置**段 = 显式豁免意图。 @@ -219,25 +285,8 @@ def chain_skip_gate(command: str) -> bool: 里出现的同名字符串不构成豁免(见 _git_seg_parts 的子命令边界)。 """ text = _flatten_substitutions(command) - import re as _re - for seg in _re.split(r"&&|\|\||;|\n", text): - tokens = [t.strip("\"'") for t in seg.strip().split()] - parts = _git_seg_parts(tokens) - if parts is None: - continue - sub, si, _pre = parts - if sub != "config": - continue - rest = tokens[si + 1:] - if any(t in _QUERY_CONFIG_FLAGS for t in rest): - continue - args = [t for t in rest if not t.startswith("-")] - if len(args) < 2: - continue - key, value = args[0], args[1] - if key.lower() == "codeguard.skipgate" and value.lower() in ("true", "1", "yes"): - return True - return False + return any(skip_gate_config_change(seg) is True + for seg, _separator in split_shell_segments(text)) def _segment_is_git_side_effect(seg: str) -> bool: @@ -245,9 +294,8 @@ def _segment_is_git_side_effect(seg: str) -> bool: def _collect_subs(text: str) -> list[str]: - import re text = _flatten_substitutions(text) return [ - sub for seg in re.split(r"&&|\|\||;|\n", text) + sub for seg, _separator in split_shell_segments(text) if (sub := _git_side_effect_sub(seg)) ] diff --git a/tests/test_git_guard_application.py b/tests/test_git_guard_application.py index 65e0c4c..5977761 100644 --- a/tests/test_git_guard_application.py +++ b/tests/test_git_guard_application.py @@ -19,6 +19,215 @@ class GitGuardApplicationTests(unittest.TestCase): + def test_shell_segments_only_split_real_control_operators(self): + from codeguard.git_syntax import chain_skip_gate, split_shell_segments + + self.assertEqual( + [('echo "docs; git config codeguard.skipGate true"', None), + ('git commit -m "a && b"', '&&')], + split_shell_segments( + 'echo "docs; git config codeguard.skipGate true" && git commit -m "a && b"'), + ) + self.assertEqual( + [(r'echo docs\; git config codeguard.skipGate true', None), + ('git commit -m x', '&&')], + split_shell_segments(r'echo docs\; git config codeguard.skipGate true && git commit -m x'), + ) + self.assertFalse(chain_skip_gate('echo "docs; git config codeguard.skipGate true" && git commit')) + self.assertFalse(chain_skip_gate(r'echo docs\; git config codeguard.skipGate true && git commit')) + self.assertTrue(chain_skip_gate('git config codeguard.skipGate true && git commit')) + + @staticmethod + def _initialized_repo(base: Path, name: str) -> Path: + repo = base / name + repo.mkdir() + for args in (("init", "-q"), ("config", "user.email", "test@example.com"), + ("config", "user.name", "Test")): + subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True) + (repo / "README.md").write_text("safe\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "README.md"], + check=True, capture_output=True) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "initial"], + check=True, capture_output=True) + return repo + + def test_multi_repo_commit_does_not_include_other_repos_staged_file_in_push(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-per-repo-mode-") as tmp: + base = Path(tmp).resolve() + repos = [self._initialized_repo(base, "commit-repo"), + self._initialized_repo(base, "push-repo")] + (repos[1] / ".env").write_text("SECRET=value\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repos[1]), "add", ".env"], + check=True, capture_output=True) + command = f"cd {repos[0]} && git commit -m next && cd {repos[1]} && git push origin main" + with patch.object(git_guard_application, "run_gate", return_value=([], [])) as gate: + result = git_guard_application.evaluate_git_command( + command, cwd=base, load_config=dict) + self.assertEqual(0, result.exit_code, result.stderr) + self.assertEqual(2, gate.call_count) + self.assertEqual( + [("commit", True), ("push", False)], + [(call.kwargs["mode"], call.kwargs["pending_commit"]) for call in gate.call_args_list], + ) + + def test_inline_and_chain_bypass_do_not_bypass_another_repository(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-scoped-bypass-") as tmp: + base = Path(tmp).resolve() + first = self._initialized_repo(base, "first") + second = self._initialized_repo(base, "second") + (second / ".env").write_text("SECRET=value\n", encoding="utf-8") + subprocess.run(["git", "-C", str(second), "add", ".env"], + check=True, capture_output=True) + for prefix in ("git -c codeguard.skipGate=true commit -m x", + "git config codeguard.skipGate true && git commit -m x"): + command = f"cd {first} && {prefix} && cd {second} && git commit -m x" + with self.subTest(prefix=prefix), \ + patch.object(git_guard_application, "run_gate", return_value=([], [])): + result = git_guard_application.evaluate_git_command( + command, cwd=base, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn(".env", result.stderr) + + def test_unset_before_commit_cancels_existing_repository_bypass(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-unset-bypass-") as tmp: + repo = self._initialized_repo(Path(tmp).resolve(), "repo") + subprocess.run(["git", "-C", str(repo), "config", "codeguard.skipGate", "true"], + check=True, capture_output=True) + (repo / ".env").write_text("SECRET=value\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", ".env"], + check=True, capture_output=True) + with patch.object(git_guard_application, "run_gate", return_value=([], [])): + result = git_guard_application.evaluate_git_command( + "git config --unset codeguard.skipGate && git commit -m x", + cwd=repo, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn(".env", result.stderr) + + def test_inline_bypass_only_covers_its_own_operation(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-inline-once-") as tmp: + repo = self._initialized_repo(Path(tmp).resolve(), "repo") + (repo / ".env").write_text("SECRET=value\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", ".env"], + check=True, capture_output=True) + with patch.object(git_guard_application, "run_gate", return_value=([], [])): + result = git_guard_application.evaluate_git_command( + "git -c codeguard.skipGate=true commit -m first && git commit -m second", + cwd=repo, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn(".env", result.stderr) + self.assertTrue(any("内联豁免" in context for context in result.contexts)) + + def test_later_chain_bypass_does_not_retroactively_skip_commit(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-late-bypass-") as tmp: + repo = self._initialized_repo(Path(tmp).resolve(), "repo") + (repo / ".env").write_text("SECRET=value\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", ".env"], + check=True, capture_output=True) + with patch.object(git_guard_application, "run_gate", return_value=([], [])): + result = git_guard_application.evaluate_git_command( + "git commit -m x && git config codeguard.skipGate true", + cwd=repo, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn(".env", result.stderr) + + def test_writing_other_config_file_does_not_bypass_repository(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-other-config-") as tmp: + base = Path(tmp).resolve() + repo = self._initialized_repo(base, "repo") + (repo / ".env").write_text("SECRET=value\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", ".env"], + check=True, capture_output=True) + command = f"git config --file={base / 'other-config'} codeguard.skipGate true && git commit -m x" + with patch.object(git_guard_application, "run_gate", return_value=([], [])): + result = git_guard_application.evaluate_git_command( + command, cwd=repo, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn(".env", result.stderr) + + def test_unproven_chain_config_does_not_bypass_commit(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-config-flow-") as tmp: + repo = self._initialized_repo(Path(tmp).resolve(), "repo") + (repo / ".env").write_text("SECRET=value\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", ".env"], + check=True, capture_output=True) + for separator in ("||", ";"): + command = f"git config codeguard.skipGate true {separator} git commit -m x" + with self.subTest(separator=separator), \ + patch.object(git_guard_application, "run_gate", return_value=([], [])): + result = git_guard_application.evaluate_git_command( + command, cwd=repo, load_config=dict) + self.assertEqual(2, result.exit_code) + + def test_quoted_config_text_does_not_bypass_commit(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-quoted-config-") as tmp: + repo = self._initialized_repo(Path(tmp).resolve(), "repo") + (repo / ".env").write_text("SECRET=value\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", ".env"], + check=True, capture_output=True) + command = 'echo "docs; git config codeguard.skipGate true" && git commit -m x' + with patch.object(git_guard_application, "run_gate", return_value=([], [])): + result = git_guard_application.evaluate_git_command( + command, cwd=repo, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn(".env", result.stderr) + + def test_same_repo_commit_then_push_keeps_both_surfaces(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-same-repo-mode-") as tmp: + repo = Path(tmp).resolve() + subprocess.run(["git", "init", "-q", str(repo)], check=True, capture_output=True) + command = "git commit -m next && git push origin main" + with patch.object(git_guard_application, "run_gate", return_value=([], [])) as gate, \ + patch.object(git_guard_application, "check_commit_safety", return_value=[]): + result = git_guard_application.evaluate_git_command( + command, cwd=repo, load_config=dict) + self.assertEqual(0, result.exit_code, result.stderr) + gate.assert_called_once() + self.assertEqual("push", gate.call_args.kwargs["mode"]) + self.assertTrue(gate.call_args.kwargs["pending_commit"]) + + def test_repository_skip_gate_does_not_bypass_another_repository(self): + from codeguard import git_guard_application + from codeguard.git_context import GitOperation + + first, second = Path("/first-repo"), Path("/second-repo") + with patch.object(git_guard_application, "resolve_git_operations", + return_value=[GitOperation(first, "commit"), + GitOperation(second, "commit")]), \ + patch.object(git_guard_application, "skip_gate_via_git_config", + side_effect=lambda root: root == first), \ + patch.object(git_guard_application, "staging_intent", + return_value=(("staged",), [])), \ + patch.object(git_guard_application, "run_gate", return_value=([], [])) as gate, \ + patch.object(git_guard_application, "check_commit_safety", + side_effect=lambda root, *_args, **_kwargs: + [(".env", "sensitive", "remove it")] if root == second else []), \ + patch.object(git_guard_application, "record_skip_event") as record: + result = git_guard_application.evaluate_git_command( + "git commit -m x", cwd=first, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn(".env", result.stderr) + gate.assert_called_once() + self.assertEqual(second, gate.call_args.args[0]) + record.assert_called_once_with("skipGate", first) + def test_explicit_non_git_target_never_falls_back_to_callers_repo(self): from codeguard import git_guard_application @@ -74,7 +283,7 @@ def test_git_root_observation_failure_is_not_fail_open(self): from codeguard import git_guard_application from git_snapshot import SnapshotError - with patch.object(git_guard_application, "resolve_project_roots", + with patch.object(git_guard_application, "resolve_git_operations", side_effect=SnapshotError("rev-parse failed")), \ patch.object(git_guard_application, "fallback_roots") as fallback: result = git_guard_application.evaluate_git_command( @@ -105,7 +314,7 @@ def test_no_git_repository_blocks_with_diagnostic_without_printing(self): with tempfile.TemporaryDirectory(prefix="cg-guard-app-") as tmp: output, errors = io.StringIO(), io.StringIO() - with patch.object(git_guard_application, "resolve_project_roots", return_value=[]), \ + with patch.object(git_guard_application, "resolve_git_operations", return_value=[]), \ patch.object(git_guard_application, "fallback_roots", return_value=[]), \ contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors): result = git_guard_application.evaluate_git_command( @@ -121,9 +330,11 @@ def test_inline_bypass_returns_context_and_audit_without_printing(self): with tempfile.TemporaryDirectory(prefix="cg-guard-inline-") as tmp: root = Path(tmp) output = io.StringIO() - with patch.object(git_guard_application, "resolve_project_roots", return_value=[root]), \ + from codeguard.git_context import GitOperation + + with patch.object(git_guard_application, "resolve_git_operations", + return_value=[GitOperation(root, "commit", "inline-skipGate")]), \ patch.object(git_guard_application, "skip_gate_via_git_config", return_value=False), \ - patch.object(git_guard_application, "inline_skip_gate", return_value=True), \ patch.object(git_guard_application, "record_skip_event") as record, \ contextlib.redirect_stdout(output): result = git_guard_application.evaluate_git_command(