From 57fb875415ffa2cb2529eaf05d955fea75d35b6e Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:38:31 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=E5=B8=A6=E5=89=8D?= =?UTF-8?q?=E7=BC=80=20Git=20=E6=93=8D=E4=BD=9C=E5=88=A4=E5=AE=9A=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E5=B8=83=20v0.14.6?= 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 | 1 + README.zh-CN.md | 1 + docs/current-architecture.md | 1 + kimi.plugin.json | 2 +- .../refactor-codeguard-architecture/design.md | 3 + .../specs/execution-kernel/spec.md | 12 ++ .../refactor-codeguard-architecture/tasks.md | 4 +- .../verification.md | 10 ++ scripts/codeguard/git_context.py | 5 +- scripts/codeguard/git_syntax.py | 56 +++++----- tests/test_git_guard_application.py | 103 ++++++++++++++++-- 14 files changed, 167 insertions(+), 43 deletions(-) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index a6eea2d..fae5a15 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.5" + "ref": "v0.14.6" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.14.5", + "version": "0.14.6", "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.5/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.6/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.5/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.6/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index c38a4b2..5db15c0 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.5+codex.20260923", + "version": "0.14.6+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 de08ea3..7322161 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.14.5", + "version": "0.14.6", "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 a4c92e7..0b10011 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ Root codeguard.json may set gate_scope to delta or repo and customize extension/ The registry contains **54 Stable adapters and 3 Planned entries**. “Stable” does not certify every toolchain or project. Markdown/YAML require project configuration; missing configuration is UNVERIFIED. Markdown findings are advisory. Generated and dependency directories are excluded from ordinary lint scope, not automatically accepted for commit. Python checks honor the project's own ruff configuration (ruff.toml / .ruff.toml / [tool.ruff]); when none exists, codeguard injects a default rule set pinned to the CI baseline (ruff==0.16.8) so verdicts do not drift with whichever ruff version a machine happens to have. Full command inventory: [languages](docs/LANGUAGES.md). The explicit escape hatch git config codeguard.skipGate true bypasses the hook gate and is recorded in session summaries. Shared hook state lives under CODEGUARD_HOME (default ~/.codeguard). +The Git gate statically inspects one readable shell-wrapper layer, including bare assignment, env, command and sudo prefixes; the same prefix rules apply to skipGate changes and one-shot bypasses. It does not execute or fully interpret shell scripts. ## External skills diff --git a/README.zh-CN.md b/README.zh-CN.md index 7fd466f..e19e75d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -121,6 +121,7 @@ python3 scripts/run_check.py --mcp /path/to/project 注册表含 **54 个 Stable 适配器和 3 个 Planned 项**。“Stable” 不证明全部工具链或项目已验证。Markdown/YAML 需要项目配置,缺配置为 UNVERIFIED;Markdown 违规只告警。生成物和依赖目录从普通 lint 范围排除,不等于允许入库。Python 检查优先使用项目自有 ruff 配置(ruff.toml / .ruff.toml / [tool.ruff]);项目无自有配置时注入钉扎在 CI 基线(ruff==0.16.8)的默认规则集,判定不随机器上 ruff 版本漂移。完整命令见[语言清单](docs/LANGUAGES.md)。 显式逃生门 git config codeguard.skipGate true 会绕过钩子门禁,并在会话总结中记录。共享状态位于 CODEGUARD_HOME(默认 ~/.codeguard)。 +Git 门禁静态读取一层 Shell 包装命令,支持可解析的裸环境赋值、env、command 和 sudo 前缀;同一前缀规则也用于 skipGate 状态变更和单次豁免。它不执行或完整解释脚本。 ## 外部技能 diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 91af64b..4f9963f 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -30,6 +30,7 @@ flowchart TD Git 命令的仓库定位与拟暂存解析共用入口传入的 cwd;显式 `cd`/`git -C` 无法绑定 Git 工作树时,硬门禁给出目标未验证并阻断,不退回到调用者仓或无关子仓。未给出显式目标且调用目录非仓时仍保留 workspace 子仓兜底。静态命令解析不等于完整 Shell 执行模拟。 一条命令链中的 Git 副作用先绑定为仓库与操作的有序对,再按仓库聚合检查面:纯 push 不读取该仓未提交的 index,同仓 commit→push 则保留已有 HEAD 与拟提交内容的并集。仓库级、内联及链式 `skipGate` 均按目标仓与操作顺序生效;已存在的仓库配置也可被同链先行 `git config --unset` 取消。链式设置跨 `||`、`;` 或换行时无法证明生效,门禁要求拆分命令;写入其它文件的 config 设置也不构成本仓豁免。语法、仓库归属、间接脚本与拟暂存分析共用引号/转义感知切分;参数文本中的控制符不能合成豁免。解析能力仍限于受支持的静态 Shell 形态。 一层 `bash`/`sh`/`zsh` 的 `-c`(含组合短选项)及可读脚本现在携带调用目录与正文进入同一 Git 操作计划;脚本内的 `git add` 可进入拟暂存范围,外层和内层 Git 操作按仓分别检查。识别到无法建模的间接 Git 操作、脚本内 `skipGate` 配置变更或跨解释器的暂存/提交关系时,明确以 Git 意图 UNVERIFIED 阻断,不借用调用者仓的检查结果。动态脚本、任意控制流及 Python/Node 中构造的 subprocess 仍未建模。 +直接 Git 命令、解释器入口及 `skipGate` 状态/内联豁免解析共用裸前缀归一化;可解析的环境赋值、`env FOO=1`、`command` 和无参数 `sudo` 不会遮蔽后面的 Shell 提交或豁免取消。带值 wrapper 参数和运行时生成命令仍不在静态保证范围内。 ## 模块所有权 diff --git a/kimi.plugin.json b/kimi.plugin.json index 758cdea..609f1a1 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.5", + "version": "0.14.6", "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 8da4e37..74e1f26 100644 --- a/openspec/changes/refactor-codeguard-architecture/design.md +++ b/openspec/changes/refactor-codeguard-architecture/design.md @@ -84,6 +84,9 @@ flowchart TD 源码误当 Shell 执行计划:它们若被保守静态扫描命中但无法证明行为,返回明确的 Git 意图未验证,不能用外层仓库检查结果代替。仍只支持一层、静态可读的脚本,不推断任意 控制流、动态脚本生成或解释器在运行时调用的 subprocess。 +`git_syntax` 的命令前缀归一化同时供直接 Git 命令、解释器入口与仓库级/内联豁免解析使用:裸 `env`、环境赋值、 +`command`、无参数 `sudo` 不再遮蔽后面的静态 Shell 正文或 skipGate 状态变化。带值 wrapper 选项仍属明确边界, +不能把它们当成已完全求值的 Shell。 参考:pre-commit 的 [Language Protocol 与通用执行](https://github.com/pre-commit/pre-commit/blob/main/pre_commit/lang_base.py)、[Python 适配器](https://github.com/pre-commit/pre-commit/blob/main/pre_commit/languages/python.py)。只借鉴边界设计,不复制安装环境逻辑或新增插件生态协议。 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 015accd..28b99e3 100644 --- a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md +++ b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md @@ -214,6 +214,10 @@ index 或工作树;不支持的动态/交互式形态必须明确未验证, 拟暂存分析 MUST 使用脚本的实际调用目录和同一份内层命令;外层直接 Git 操作与内层操作 不得互相覆盖。若已识别间接 Git 副作用但不能可靠建立仓库或暂存计划,MUST 报告 Git 意图 UNVERIFIED 并阻断,不得回退扫描无关子仓或以错误仓库的 PASS 放行。 +静态可解析的环境赋值、`env`、`command` 与无参数 `sudo` 等裸命令前缀 MUST 在 Shell +解释器检测、直接 Git 检测及 `skipGate` 设置/取消/内联豁免中共享同一归一化语义, +不能让同一个 `bash -c` 的提交因前缀而逃过门禁,也不能漏掉前缀后的豁免状态变更而 +错误继承持久豁免;带参数且无法静态解析的 wrapper 不在此保证范围内。 #### Scenario: Two repositories have different staging commands - **WHEN** 一条命令链对 A 执行 add -A、对 B 只提交已暂存内容 @@ -247,6 +251,14 @@ index 或工作树;不支持的动态/交互式形态必须明确未验证, - **WHEN** 从 Git 仓执行 `bash -c 'git commit'` 或相对路径脚本,脚本内存在待提交内容 - **THEN** 将内层 commit 绑定到脚本调用目录的 Git 仓,而非报无仓或扫描其子仓 +#### Scenario: A bare prefix does not hide a shell commit +- **WHEN** 可读的 `bash -c` 或脚本前有环境赋值、`env FOO=1`、`command` 或无参数 `sudo` 前缀,内层提交包含敏感暂存内容 +- **THEN** 检测、仓库归属和拟暂存分析仍使用同一内层命令,按目标仓阻断;前缀本身不构成豁免 + +#### Scenario: A bare prefix does not hide a bypass change +- **WHEN** 仓库原有 `skipGate=true`,同链先执行 `env FOO=1 git config codeguard.skipGate false` 再提交敏感文件 +- **THEN** 后续提交不继承原持久豁免,仍运行门禁并阻断;带前缀的 `git -c` 单次豁免仍仅作用于其自身操作 + #### Scenario: Script arguments are not interpreter options - **WHEN** 一个无 Git 副作用的脚本接收名为 `-c` 的普通参数及包含 `git commit` 的文本 - **THEN** 解析器不得把脚本参数当成解释器 `-c` 代码并虚构 Git 副作用 diff --git a/openspec/changes/refactor-codeguard-architecture/tasks.md b/openspec/changes/refactor-codeguard-architecture/tasks.md index 81f489f..8dda4dc 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 和逐条规格追溯通过;第十九批 495 项单测、143 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 -- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;v0.14.4 阶段版本已完成插件/市场双仓合并、PR/main CI、tag/Release,但第十九批新改动仍需独立发布。宿主安装运行独立记录,未授权不得改宿主。 +- [x] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过;第二十批 500 项单测、143 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 +- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;第十九批已发布 v0.14.5(插件 PR #51、市场 PR #8、PR/main CI、注释 tag/正式 Release 均核对)。第二十批已准备 v0.14.6 元数据与本地验证,远端 PR/CI/tag/Release/市场主线仍待核对。宿主安装运行独立记录,未授权不得改宿主。 - [ ] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。 diff --git a/openspec/changes/refactor-codeguard-architecture/verification.md b/openspec/changes/refactor-codeguard-architecture/verification.md index 3ae73d9..f8d7b3f 100644 --- a/openspec/changes/refactor-codeguard-architecture/verification.md +++ b/openspec/changes/refactor-codeguard-architecture/verification.md @@ -203,3 +203,13 @@ - 一层 bash/sh/zsh `-c`(含 `-lc`)或可读脚本现在按解释器实际参数位置、调用目录解析;内层 Git 操作携带来源正文与 cwd,和外层操作按仓聚合。拟暂存分析合并外层与内层来源,真实 Hook 子进程证明未实际执行 `git add` 就拦截 `.env` 且 index 字节不变。纯 push 不纳入未提交 index;外层 skipGate 设置只传递给所属仓的内层操作。 - 不能可靠建模的 Python/Node 间接命中、脚本内 skipGate 配置变化、交互暂存与后续提交跨解释器边界时,明确返回 Git 意图 UNVERIFIED 并阻断;仅在纯 push 之后的独立暂存不误扩该 push 面。脚本的普通 `-c` 参数不再被误认成解释器代码。动态脚本与任意 subprocess 构造仍是未覆盖边界。 - RED→GREEN 的目标用例及最终完整单测 **495/495、0 skipped**;真实 Hook 回归 **143/0/0**。Ruff、架构门禁、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 均通过。受管技能及 lock 未改;这些是本地契约证据,不是三宿主安装、在线漏洞库、Windows 或第十九批发布证据。本批已准备 v0.14.5 元数据;远端发布须另行核对 PR、CI、tag、Release 与市场主线。 + +- 第十九批随后发布为 v0.14.5:源码 PR #51 合并为 `3f1266d8b57625e282989a51b20c528d578a01e1`,市场 PR #8 合并为 `a910cde2f6bfd5ebc6ca88d11fbb834687fb9084`;PR/main `skills-check` 成功,远端注释 tag 解引用到源码 main 合并提交,GitHub Release 为非 draft、非 prerelease,市场 Codex/ZCode/Kimi 及双语导航为 0.14.5。此证据不覆盖以下新工作树。 + +## 第二十批实际验证:裸前缀不遮蔽 Shell Git 副作用 + +- 将最新 v0.14.5 `main` 同步至已有临时 CodeGraph 索引(136 文件;源码仓未初始化索引),追踪 `git_syntax._analyze_segment`、`git_context._indirect_body`、`_guarded_mode`、`resolve_git_operations` 与 `staging_intent`。真实临时 Git 仓和纯函数探测先 RED:裸 `bash -c 'git commit'` 进入门禁,而 `FOO=1`、`env FOO=1`、`command`、`sudo` 前缀令同一 Shell 正文完全不命中;带敏感 `.env` 的暂存内容可被错误放行。 +- 直接 Git 与解释器入口现共用 `git_syntax` 的裸前缀归一化;一层脚本仍由同一个调用目录与正文进入仓库归属和拟暂存分析。RED→GREEN 用例覆盖五类前缀、内层 add/index 不变、`cd` 后相对脚本、真实 PreToolUse 子进程;带值 wrapper 参数、动态命令及任意控制流仍不在静态保证范围内。 +- `/opt/anaconda3/bin/python3` 3.13.5 完整执行 **498 unittest、0 skipped**;真实 Hook 回归 **143/0/0**。Ruff、架构门禁、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 均通过。CodeGraph 的 `affected` 对本次核心文件返回空,但真实目标测试存在且已运行,故不把空图谱结果当无影响证明。受管技能和 lock 未改;本批尚未版本升级、提交或发布,三宿主现场/Windows/在线漏洞库仍未验收。 +- 同步最新 CodeGraph 后继续追踪 `git_syntax` 发现:`inline_skip_gate` 和 `skip_gate_config_change` 仍用原始空白分词,与已共用的直接 Git/解释器前缀语义分叉。真实仓库 RED 证明已有持久 `skipGate=true` 时,`env FOO=1 git config codeguard.skipGate false && git commit` 被错误放行;带前缀的 `git -c` 单次豁免则被错误阻断且失去审计。二者现复用同一 `_segment_tokens`,目标测试转绿;此证据补充前述批内回归,不将早先 498 项计数当最终全量结果。 +- 最终本地完整回归:`/opt/anaconda3/bin/python3` 3.13.5 执行 **500 unittest、0 skipped**,真实 Hook **143/0/0**;Ruff、架构门禁、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 均通过。两项新 RED 场景及既有豁免/引号边界均转绿。本批已准备 v0.14.6 元数据;在 PR/CI/tag/Release/市场主线核对前,不能用 v0.14.5 的证据证明新改动已发布。 diff --git a/scripts/codeguard/git_context.py b/scripts/codeguard/git_context.py index afdd39b..db890b0 100644 --- a/scripts/codeguard/git_context.py +++ b/scripts/codeguard/git_context.py @@ -20,6 +20,7 @@ _git_c_path, _git_side_effect_sub, _segment_is_git_side_effect, + _strip_command_prefix, inline_skip_gate, skip_gate_config_change, split_shell_segments, @@ -188,7 +189,7 @@ def _fallback_roots(cwd: Path) -> list[Path]: def _indirect_body(segment: str, invocation_dir: Path) -> tuple[str, bool] | None: """取得一段解释器实际收到的 -c 文本或脚本正文;不执行脚本。""" try: - tokens = shlex.split(segment) + tokens = _strip_command_prefix(shlex.split(segment)) except ValueError: return None if not tokens: @@ -260,7 +261,7 @@ def _guarded_mode(command: str, cwd: Path | None = None) -> str | None: """门禁命中时返回兜底门禁面:commit 或 push(同一仓两面并存时推送面 携带 pending_commit 可覆盖两面);未命中返回 None。 - 直接命令段先判定;未命中再走一层解释器间接(脚本/-c 内联)。 + 同时汇总直接命令段与一层解释器间接正文(脚本/-c 内联)。 is_guarded 即本函数的存在性判断——面判定与命中判定共用同一套扫描, 避免"命中了却选错面"的分叉。可直接解析的命令由 GitOperation 按仓 分别选择门禁面,不能把这里的整链模式传播到其它仓。 diff --git a/scripts/codeguard/git_syntax.py b/scripts/codeguard/git_syntax.py index be44224..5c8c2fa 100644 --- a/scripts/codeguard/git_syntax.py +++ b/scripts/codeguard/git_syntax.py @@ -1,6 +1,7 @@ """Git 命令的静态语法识别;不读取仓库、执行进程或依赖 Hook 协议。""" from __future__ import annotations +import re import shlex from pathlib import Path @@ -110,6 +111,28 @@ def _flatten_substitutions(command: str) -> str: return "\n".join(parts) +def _strip_command_prefix(tokens: list[str]) -> list[str]: + """为直接 Git 和一层解释器共用裸 wrapper/环境前缀语义。""" + i = 0 + while i < len(tokens) and tokens[i] in _SHELL_LEADERS: + i += 1 + while i < len(tokens): + if re.match(_ASSIGN_PATTERN, tokens[i]) or tokens[i] in _BARE_WRAPPERS: + i += 1 + continue + break + return tokens[i:] if i < len(tokens) else tokens + + +def _segment_tokens(segment: str) -> list[str]: + """复用分词与裸前缀归一化;畸形引号维持旧接口的保守切词。""" + try: + tokens = shlex.split(segment) + except ValueError: + tokens = segment.strip().split() + return _strip_command_prefix(tokens) + + def _analyze_segment(seg: str) -> tuple[list[str], Path | None]: """归一化一段 shell 命令:返回 ([prog, sub, …] 形状的 tokens, `git -C` 的仓路径)。 @@ -117,33 +140,16 @@ def _analyze_segment(seg: str) -> tuple[list[str], Path | None]: → git 全局选项。同时捕获 `-C `——它是该 git 段的显式仓库边界。 漏检实例(实测静默放行):`VAR=1 git push` 段首是 `VAR=1`;`git -C path push` tokens[1] 是 `sudo`;`if git push; then` 段首是 `if`。剥完后若段首仍非 git - 则返回原 tokens(由调用方判定为非 git 段)。 + 则返回剥除前缀后的 tokens,供解释器检测复用。 能力边界(docstring 如实声明):带值的 wrapper 参数(`sudo -u root …`) 不做完整解析,只剥裸 wrapper token。 """ - import re as _re - try: - tokens = shlex.split(seg) - except ValueError: - # 旧间接命令识别可能传入引号不完整的片段,保留其保守命中行为。 - tokens = seg.strip().split() - i, n = 0, len(tokens) - while i < n and tokens[i] in _SHELL_LEADERS: - i += 1 - while True: - while i < n and _re.match(_ASSIGN_PATTERN, tokens[i]): - i += 1 - if i < n and tokens[i] == "env": - i += 1 - continue - break - while i < n and tokens[i] in _BARE_WRAPPERS: - i += 1 - if i >= n: + tokens = _segment_tokens(seg) + if not tokens: + return tokens, None + if tokens[0].strip("\"'") != "git": return tokens, None - if tokens[i].strip("\"'") != "git": - return tokens[i:], None - i += 1 + i, n = 1, len(tokens) c_path: str | None = None while i < n: tok = tokens[i].strip("\"'") @@ -233,7 +239,7 @@ def inline_skip_gate(command: str) -> bool: """ text = _flatten_substitutions(command) for seg, _separator in split_shell_segments(text): - tokens = [t.strip("\"'") for t in seg.strip().split()] + tokens = [t.strip("\"'") for t in _segment_tokens(seg)] parts = _git_seg_parts(tokens) if parts is None: continue @@ -246,7 +252,7 @@ def inline_skip_gate(command: str) -> bool: def skip_gate_config_change(segment: str) -> bool | None: """解析单段仓库级豁免变更;True 为设置,False 为取消,None 为无关。""" - tokens = [token.strip("\"'") for token in segment.strip().split()] + tokens = [token.strip("\"'") for token in _segment_tokens(segment)] parts = _git_seg_parts(tokens) if parts is None: return None diff --git a/tests/test_git_guard_application.py b/tests/test_git_guard_application.py index 78f63e0..71cfd20 100644 --- a/tests/test_git_guard_application.py +++ b/tests/test_git_guard_application.py @@ -65,6 +65,39 @@ def test_bash_lc_commit_is_not_an_unguarded_wrapper(self): self.assertEqual(2, result.exit_code) self.assertIn(".env", result.stderr) + def test_bare_prefixes_do_not_hide_shell_commit(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-prefixed-shell-") 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 prefix in ("FOO=1", "env FOO=1", "command", "sudo", + "sudo env FOO=1"): + with self.subTest(prefix=prefix), \ + patch.object(git_guard_application, "run_gate", return_value=([], [])) as gate: + result = git_guard_application.evaluate_git_command( + f"{prefix} bash -c 'git commit -m x'", cwd=repo, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn(".env", result.stderr) + gate.assert_called_once() + + def test_prefixed_shell_staging_is_predicted_without_mutating_index(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-prefixed-add-") as tmp: + repo = self._initialized_repo(Path(tmp).resolve(), "repo") + (repo / ".env").write_text("SECRET=value\n", encoding="utf-8") + before = (repo / ".git/index").read_bytes() + with patch.object(git_guard_application, "run_gate", return_value=([], [])): + result = git_guard_application.evaluate_git_command( + "env FOO=1 bash -c 'git add .env && git commit -m x'", + cwd=repo, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn(".env", result.stderr) + self.assertEqual(before, (repo / ".git/index").read_bytes()) + def test_script_argument_named_dash_c_is_not_interpreter_code(self): from codeguard import git_guard_application @@ -121,13 +154,15 @@ def test_shell_script_after_cd_binds_its_repository(self): subprocess.run(["git", "-C", str(target), "add", ".env"], check=True, capture_output=True) (target / "release.sh").write_text("git commit -m x\n", encoding="utf-8") - with patch.object(git_guard_application, "run_gate", return_value=([], [])) as gate: - result = git_guard_application.evaluate_git_command( - f"cd {target} && bash release.sh", cwd=caller, load_config=dict) - self.assertEqual(2, result.exit_code) - self.assertIn(".env", result.stderr) - gate.assert_called_once() - self.assertEqual(target, gate.call_args.args[0]) + for interpreter in ("bash", "env FOO=1 bash"): + with self.subTest(interpreter=interpreter), \ + patch.object(git_guard_application, "run_gate", return_value=([], [])) as gate: + result = git_guard_application.evaluate_git_command( + f"cd {target} && {interpreter} release.sh", cwd=caller, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn(".env", result.stderr) + gate.assert_called_once() + self.assertEqual(target, gate.call_args.args[0]) def test_outer_commit_does_not_hide_inner_shell_commit(self): from codeguard import git_guard_application @@ -258,6 +293,22 @@ def test_real_hook_blocks_sensitive_add_inside_shell_wrapper(self): self.assertIn(".env", result.stderr) self.assertEqual(before, (repo / ".git/index").read_bytes()) + def test_real_hook_blocks_prefixed_shell_commit(self): + with tempfile.TemporaryDirectory(prefix="cg-hook-prefixed-") 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) + payload = {"tool_name": "Bash", "tool_use_id": "prefixed-sensitive", + "tool_input": {"command": "env FOO=1 bash -c 'git commit -m x'"}} + result = subprocess.run( + [sys.executable, str(ROOT / "hooks/pre_tool_git_guard.py")], + input=json.dumps(payload), cwd=repo, capture_output=True, text=True, + env={**os.environ, "CODEGUARD_HOME": str(base / "state")}, timeout=15, check=False) + self.assertEqual(2, result.returncode, result.stdout + result.stderr) + self.assertIn(".env", result.stderr) + @staticmethod def _initialized_repo(base: Path, name: str) -> Path: repo = base / name @@ -330,6 +381,44 @@ def test_unset_before_commit_cancels_existing_repository_bypass(self): self.assertEqual(2, result.exit_code) self.assertIn(".env", result.stderr) + def test_prefixed_unset_cancels_existing_repository_bypass(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-prefixed-unset-") 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=([], [])) as gate: + result = git_guard_application.evaluate_git_command( + "env FOO=1 git config codeguard.skipGate false && git commit -m x", + cwd=repo, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn(".env", result.stderr) + gate.assert_called_once() + + def test_prefixed_inline_bypass_stays_audited_and_scoped(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-prefixed-inline-") 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 prefix in ("env FOO=1", "command"): + with self.subTest(prefix=prefix), \ + patch.object(git_guard_application, "run_gate", return_value=([], [])) as gate, \ + patch.object(git_guard_application, "record_skip_event") as record: + result = git_guard_application.evaluate_git_command( + f"{prefix} git -c codeguard.skipGate=true commit -m x", + cwd=repo, load_config=dict) + self.assertEqual(0, result.exit_code, result.stderr) + self.assertTrue(any("内联豁免" in item for item in result.contexts)) + gate.assert_not_called() + record.assert_called_once_with("inline-skipGate", repo) + def test_inline_bypass_only_covers_its_own_operation(self): from codeguard import git_guard_application