diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 7555972..a6eea2d 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.4" + "ref": "v0.14.5" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.14.4", + "version": "0.14.5", "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.4/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.5/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.4/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.5/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 432d0c2..c38a4b2 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.4+codex.20260923", + "version": "0.14.5+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 e5233e1..de08ea3 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.14.4", + "version": "0.14.5", "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 fefea6d..a4c92e7 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ A plain commit checks the index, not an unstaged repair. Supported preceding git 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. Limits: 20,000 tracked files / 256 MiB Git content / 32 MiB per overlay file. Symlinks, submodules, conflicts and unsupported content need separate validation. Complex shell rewrites, arbitrary Git refspecs, dynamic aliases and concurrent edits are not a fully modeled transaction. A hook is not a replacement for protected-branch CI. +One statically readable layer of bash/sh/zsh `-c` or script execution is included in repository and staging analysis; unmodelled indirect Git operations are blocked as UNVERIFIED. Dynamic scripts and subprocess calls assembled by Python/Node remain outside this static model. There is no “historical debt” exemption based only on an unchanged diagnostic filename; a modified API can break an unchanged caller. diff --git a/README.zh-CN.md b/README.zh-CN.md index 0e54854..7fd466f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -75,6 +75,7 @@ Maven 使用 verify 并追加 -DskipTests(测试代码仍编译,仅跳过执 检查在临时目录物化 Git blobs,不 stash、不 checkout、不更改真实 index。精确内容门禁不复用软提醒的工作树缓存。快照缺 ignored 依赖时保持 UNVERIFIED,不改查另一份源码。 限制:20,000 个已跟踪文件 / 256 MiB Git 内容 / 每个覆盖文件 32 MiB。符号链接、子模块、冲突及不支持的内容需要独立验证。复杂 shell 写入、任意 Git refspec、动态别名与并发编辑尚未完整建模;钩子不能替代受保护分支 CI。 +仓库归属与拟暂存分析覆盖一层静态可读的 bash/sh/zsh `-c` 或脚本执行;无法建模的间接 Git 操作会以 UNVERIFIED 阻断。动态脚本以及 Python/Node 中拼接的 subprocess 调用仍不在此静态模型内。 不再仅凭“报错文件没修改”豁免历史债:变更 API 也会破坏未修改的调用方。 diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 1845110..91af64b 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -29,6 +29,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 形态。 +一层 `bash`/`sh`/`zsh` 的 `-c`(含组合短选项)及可读脚本现在携带调用目录与正文进入同一 Git 操作计划;脚本内的 `git add` 可进入拟暂存范围,外层和内层 Git 操作按仓分别检查。识别到无法建模的间接 Git 操作、脚本内 `skipGate` 配置变更或跨解释器的暂存/提交关系时,明确以 Git 意图 UNVERIFIED 阻断,不借用调用者仓的检查结果。动态脚本、任意控制流及 Python/Node 中构造的 subprocess 仍未建模。 ## 模块所有权 diff --git a/kimi.plugin.json b/kimi.plugin.json index a7a19b2..758cdea 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.4", + "version": "0.14.5", "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 64f6dfc..8da4e37 100644 --- a/openspec/changes/refactor-codeguard-architecture/design.md +++ b/openspec/changes/refactor-codeguard-architecture/design.md @@ -78,6 +78,13 @@ flowchart TD 传入的 cwd,不再分别读取进程全局工作目录。Git 根查询故障也保留阻断结论;这些只覆盖 支持的静态命令形态,不能宣称模拟完整 Shell 控制流。 +后续收敛一层 Shell 间接执行:从外层命令链提取解释器调用及其调用目录,静态读取 +`bash`/`sh`/`zsh` 的 `-c` 文本或脚本正文。内层 Git 操作携带来源命令与调用目录, +供仓库归属、豁免和拟暂存分析复用;外层与内层操作按目标仓聚合。不把 Python/Node +源码误当 Shell 执行计划:它们若被保守静态扫描命中但无法证明行为,返回明确的 Git +意图未验证,不能用外层仓库检查结果代替。仍只支持一层、静态可读的脚本,不推断任意 +控制流、动态脚本生成或解释器在运行时调用的 subprocess。 + 参考: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)。只借鉴边界设计,不复制安装环境逻辑或新增插件生态协议。 ### 6. 状态基础设施与已完成事件分开 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 d9f7a49..015accd 100644 --- a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md +++ b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md @@ -210,6 +210,10 @@ index 或工作树;不支持的动态/交互式形态必须明确未验证, 或换行后的配置状态无法静态证明时,MUST 阻断并提示拆分命令,不得猜测已生效。 命令切分 MUST 区分真实 Shell 分隔符和被引用/转义的字面文本;提交消息、echo 参数 或文件路径中的 `;`、`&&` 不得生成虚假的 Git 操作或豁免设置。 +对一层可静态读取的 `bash`/`sh`/`zsh` 内联命令(含 `-lc` 等组合短选项)或脚本,Git 副作用的识别、仓库归属与 +拟暂存分析 MUST 使用脚本的实际调用目录和同一份内层命令;外层直接 Git 操作与内层操作 +不得互相覆盖。若已识别间接 Git 副作用但不能可靠建立仓库或暂存计划,MUST 报告 Git +意图 UNVERIFIED 并阻断,不得回退扫描无关子仓或以错误仓库的 PASS 放行。 #### Scenario: Two repositories have different staging commands - **WHEN** 一条命令链对 A 执行 add -A、对 B 只提交已暂存内容 @@ -239,6 +243,26 @@ index 或工作树;不支持的动态/交互式形态必须明确未验证, - **WHEN** `echo` 的引号参数或 commit 消息包含 `; git config codeguard.skipGate true` - **THEN** 不产生虚假的 config 设置,随后未豁免的提交仍须检查并阻断违规内容 +#### Scenario: Shell wrapper commit uses the invocation repository +- **WHEN** 从 Git 仓执行 `bash -c 'git commit'` 或相对路径脚本,脚本内存在待提交内容 +- **THEN** 将内层 commit 绑定到脚本调用目录的 Git 仓,而非报无仓或扫描其子仓 + +#### Scenario: Script arguments are not interpreter options +- **WHEN** 一个无 Git 副作用的脚本接收名为 `-c` 的普通参数及包含 `git commit` 的文本 +- **THEN** 解析器不得把脚本参数当成解释器 `-c` 代码并虚构 Git 副作用 + +#### Scenario: Inner shell staging remains visible +- **WHEN** 可静态读取的 shell 命令内先 `git add` 敏感文件再提交,或先切换到另一仓再提交 +- **THEN** 拟暂存分析包含内层 add 并归属实际目标仓;同链外层 Git 操作仍分别检查 + +#### Scenario: Staging crosses an interpreter boundary +- **WHEN** 独立的 shell 包装器只执行 `git add`,之后外层或另一个包装器才提交 +- **THEN** 无法证明完整拟暂存计划时报告 Git 意图 UNVERIFIED 并阻断;若 add 仅发生于纯 push 之后且无后续提交,不扩张该 push 的检查面 + +#### Scenario: Unmodelled indirect Git operation cannot borrow another repository +- **WHEN** 非 Shell 解释器脚本静态命中 Git 副作用,但无法可靠推断其执行目录与暂存动作 +- **THEN** 返回 Git 意图 UNVERIFIED,不以调用者的其它仓库或兜底子仓的结果替代 + #### 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 44d2abc..81f489f 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 和逐条规格追溯通过;第十八批 481 项单测、143 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 -- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;v0.14.3 阶段版本已完成插件/市场双仓合并、PR/main CI、tag/Release,但本 change 后续改动仍需独立发布。宿主安装运行独立记录,未授权不得改宿主。 +- [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,但第十九批新改动仍需独立发布。宿主安装运行独立记录,未授权不得改宿主。 - [ ] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。 diff --git a/openspec/changes/refactor-codeguard-architecture/verification.md b/openspec/changes/refactor-codeguard-architecture/verification.md index 4babb47..3ae73d9 100644 --- a/openspec/changes/refactor-codeguard-architecture/verification.md +++ b/openspec/changes/refactor-codeguard-architecture/verification.md @@ -195,3 +195,11 @@ - 追加 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 均通过。本批发布证据仍待补齐。 +- 第十八批随后发布为 v0.14.4:插件 PR #50、市场 PR #7 已合并双仓 `main`;插件合并后 `skills-check` 成功,注释 tag 解引用到 `be619cd4b769495280c7d0d7405f9b01a8ca4dc9`,GitHub Release 正式发布;市场 Codex/ZCode/Kimi 清单及双语导航均为 0.14.4。此证据仅覆盖第十八批及此前代码,不覆盖以下新工作树。 + +## 第十九批实际验证:一层 Shell 间接 Git 操作归属 + +- 将最新 v0.14.4 `main` 同步至已有临时 CodeGraph 索引(136 文件;源码仓不建索引),沿 `PreToolUse → evaluate_git_command → _guarded_mode/resolve_git_operations → staging_intent` 追出检测与归属分叉。真实临时 Git 仓先 RED:`bash -c 'git commit'` 在调用仓报“无仓”;内层 `git add .env` 不进拟暂存面;外层已有 commit 时忽略后续 Shell 脚本对另一仓的 commit;`cd` 后相对脚本找不到。上述都可能让宿主得到错误仓库或错误范围的结论。 +- 一层 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 与市场主线。 diff --git a/scripts/codeguard/git_context.py b/scripts/codeguard/git_context.py index 8172cd4..afdd39b 100644 --- a/scripts/codeguard/git_context.py +++ b/scripts/codeguard/git_context.py @@ -14,6 +14,7 @@ staging_intent, # noqa: F401 — 原导入面兼容 ) from .git_syntax import ( + _analyze_segment, _collect_subs, _flatten_substitutions, _git_c_path, @@ -26,6 +27,7 @@ _SCRIPT_SUFFIXES = (".sh", ".bash", ".zsh", ".py", ".mjs", ".js", ".ts") _INTERPRETERS = ("bash", "sh", "zsh", "python", "python3", "node") +_SHELL_INTERPRETERS = ("bash", "sh", "zsh") class GitIntentError(ValueError): @@ -44,6 +46,8 @@ class GitOperation: mode: str bypass: str | None = None config_state: bool | None = None + source_command: str | None = None + source_cwd: Path | None = None def _is_git_repo(p: Path) -> bool: @@ -51,7 +55,8 @@ def _is_git_repo(p: Path) -> bool: return execute(["git", "-C", str(p), "rev-parse", "--git-dir"], Path.cwd(), 10).returncode == 0 -def resolve_git_operations(command: str, cwd: Path | None = None) -> list[GitOperation]: +def resolve_git_operations(command: str, cwd: Path | None = None, *, + _depth: int = 0) -> list[GitOperation]: """顺序扫描命令链,将每个 git commit/push 绑定到自己的仓库。 链式发布(cd plugins && git commit && cd minimax && git push)操作 @@ -68,6 +73,7 @@ def resolve_git_operations(command: str, cwd: Path | None = None) -> list[GitOpe operations: list[GitOperation] = [] chain_skip_states: dict[Path, bool] = {} uncertain_roots: set[Path] = set() + unresolved_indirect_add = False last_cd: Path | None = None explicit_cd = False caller_dir = (cwd or Path.cwd()).resolve() @@ -83,7 +89,38 @@ def resolve_git_operations(command: str, cwd: Path | None = None) -> list[GitOpe explicit_cd = True last_cd = target if target.is_dir() and _is_git_repo(target) else None continue + indirect = _indirect_body(seg, current_dir) + if indirect is not None: + body, is_shell = indirect + body_segments = split_shell_segments(_flatten_substitutions(body)) + if any(skip_gate_config_change(part) is not None + for part, _separator in body_segments): + raise GitIntentError("间接脚本修改 skipGate,无法证明后续配置状态;请拆分命令") + body_effects = _collect_subs(body) + if not body_effects and any(_analyze_segment(part)[0][:2] == ["git", "add"] + for part, _separator in body_segments): + unresolved_indirect_add = True + if body_effects: + if unresolved_indirect_add and "commit" in body_effects: + raise GitIntentError("间接脚本暂存与后续提交跨越解释器边界;请拆分命令") + if not is_shell or _depth: + raise GitIntentError("间接 Git 操作不能可靠建模;请单独执行并验证") + inner = resolve_git_operations(body, cwd=current_dir, _depth=1) + if not inner: + raise GitIntentError("间接 Git 操作无法绑定仓库;请单独执行并验证") + for operation in inner: + if operation.root in uncertain_roots: + raise GitIntentError("间接 Git 操作跨越不确定的 skipGate 控制流") + inherited = chain_skip_states.get(operation.root) + state = operation.config_state if operation.config_state is not None else inherited + bypass = operation.bypass or ("chain-skipGate" if state else None) + operations.append(GitOperation( + operation.root, operation.mode, bypass, state, body, current_dir, + )) + continue mode = _git_side_effect_sub(seg) + if mode == "commit" and unresolved_indirect_add: + raise GitIntentError("间接脚本暂存与后续提交跨越解释器边界;请拆分命令") skip_change = skip_gate_config_change(seg) if mode is None else None if mode is None and skip_change is None: continue @@ -148,36 +185,54 @@ def _fallback_roots(cwd: Path) -> list[Path]: return found -def _indirect_bodies(command: str): - """只读取得一层解释器脚本/内联文本,共享大小上限与读取失败边界。""" - for seg, _separator in split_shell_segments(command): - try: - tokens = shlex.split(seg) - except ValueError: - tokens = seg.strip().split() - if not tokens: - continue - prog = tokens[0].rsplit("/", 1)[-1] - if prog not in _INTERPRETERS: +def _indirect_body(segment: str, invocation_dir: Path) -> tuple[str, bool] | None: + """取得一段解释器实际收到的 -c 文本或脚本正文;不执行脚本。""" + try: + tokens = shlex.split(segment) + except ValueError: + return None + if not tokens: + return None + program = tokens[0].rsplit("/", 1)[-1] + if program not in _INTERPRETERS: + return None + rest = tokens[1:] + is_shell = program in _SHELL_INTERPRETERS + script: str | None = None + for index, option in enumerate(rest): + if option == "--": + script = rest[index + 1] if index + 1 < len(rest) else None + break + if option == "-c" or (is_shell and option.startswith("-") and not option.startswith("--") + and option[1:].isalpha() and "c" in option[1:]): + return (rest[index + 1], is_shell) if index + 1 < len(rest) else None + if option.startswith("-"): continue - rest = tokens[1:] - if "-c" in rest: - yield " ".join(rest[rest.index("-c") + 1:]) + script = option + break + if script is not None: + target = (invocation_dir / script).resolve() + if not target.is_file() or target.suffix not in _SCRIPT_SUFFIXES: + return None + try: + if target.stat().st_size <= 1_000_000: + return target.read_text(encoding="utf-8", errors="ignore"), is_shell + except OSError: + return None + return None + + +def _indirect_bodies(command: str, cwd: Path | None = None): + """按外层 cd 顺序取得一层解释器正文,供兼容检测接口复用。""" + current_dir = (cwd or Path.cwd()).resolve() + for segment, _separator in split_shell_segments(command): + match = re.match(r"cd\s+(\"[^\"]+\"|'[^']+'|\S+)", segment) + if match: + current_dir = (current_dir / match.group(1).strip("\"'")).resolve() continue - for tok in rest: - if tok.startswith("-"): - continue - target = Path(tok) - if not target.is_file() or target.suffix not in _SCRIPT_SUFFIXES: - break - try: - if target.stat().st_size > 1_000_000: - break - body = target.read_text(encoding="utf-8", errors="ignore") - except OSError: - break - yield body - break + indirect = _indirect_body(segment, current_dir) + if indirect is not None: + yield indirect[0] def _command_indirect(command: str) -> bool: @@ -201,7 +256,7 @@ def _repo_root_of(p: Path) -> Path | None: -def _guarded_mode(command: str) -> str | None: +def _guarded_mode(command: str, cwd: Path | None = None) -> str | None: """门禁命中时返回兜底门禁面:commit 或 push(同一仓两面并存时推送面 携带 pending_commit 可覆盖两面);未命中返回 None。 @@ -211,11 +266,8 @@ def _guarded_mode(command: str) -> str | None: 分别选择门禁面,不能把这里的整链模式传播到其它仓。 """ subs = _collect_subs(command) - if not subs: - for body in _indirect_bodies(command): - subs = _collect_subs(body) - if subs: - break + for body in _indirect_bodies(command, cwd): + subs.extend(_collect_subs(body)) if not subs: return None return "push" if "push" in subs else "commit" diff --git a/scripts/codeguard/git_guard_application.py b/scripts/codeguard/git_guard_application.py index 9ea878f..cab9065 100644 --- a/scripts/codeguard/git_guard_application.py +++ b/scripts/codeguard/git_guard_application.py @@ -41,7 +41,7 @@ class GitGuardResult: def evaluate_git_command(command: str, *, cwd: Path, load_config: Callable[[], dict], bypass_env: bool = False) -> GitGuardResult: """评估一条工具命令;不修改 index,不直接输出宿主协议。""" - mode = guarded_mode(command) if command else None + mode = guarded_mode(command, cwd=cwd) if command else None if mode is None: return GitGuardResult() if bypass_env: @@ -111,12 +111,26 @@ 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: - lanes, extra = staging_intent(command, project_root=project_root, cwd=cwd) + sources = [(command, cwd)] + sources.extend((operation.source_command, operation.source_cwd) + for operation in root_operations if operation.source_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, + ) + lanes_found.update(source_lanes) + extras_found.update(source_extra) + lanes, extra = tuple(lane for lane in ("staged", "unstaged", "untracked") + if lane in lanes_found), sorted(extras_found) except SnapshotError as exc: - contexts.append( - f"codeguard: git UNVERIFIED:{project_root} 无法准确预测暂存面:{exc};" - "请完成明确的暂存操作后单独提交,并复核检查结果。" - ) + message = (f"{project_root} 无法准确预测暂存面:{exc};" + "请完成明确的暂存操作后单独提交,并复核检查结果。") + if any(operation.source_command for operation in root_operations): + reports.append(f"[codeguard] Git 意图 UNVERIFIED:{message}") + else: + contexts.append(f"codeguard: git UNVERIFIED:{message}") continue root_extra = list(extra) failures, skipped = run_gate( diff --git a/tests/test_git_guard_application.py b/tests/test_git_guard_application.py index 5977761..78f63e0 100644 --- a/tests/test_git_guard_application.py +++ b/tests/test_git_guard_application.py @@ -37,6 +37,227 @@ def test_shell_segments_only_split_real_control_operators(self): 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')) + def test_bash_c_commit_uses_invocation_repository(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-indirect-repo-") 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( + "bash -c 'git commit -m x'", cwd=repo, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn(".env", result.stderr) + + def test_bash_lc_commit_is_not_an_unguarded_wrapper(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-indirect-login-") 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( + "bash -lc 'git commit -m x'", cwd=repo, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn(".env", result.stderr) + + def test_script_argument_named_dash_c_is_not_interpreter_code(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-script-argv-") as tmp: + repo = self._initialized_repo(Path(tmp).resolve(), "repo") + (repo / "harmless.sh").write_text("echo hello\n", encoding="utf-8") + (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( + "bash harmless.sh -c 'git commit -m x'", cwd=repo, load_config=dict) + self.assertEqual(0, result.exit_code) + gate.assert_not_called() + + def test_indirect_pure_push_does_not_inspect_uncommitted_index(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-indirect-push-") 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=([], [])) as gate: + result = git_guard_application.evaluate_git_command( + "bash -c 'git push origin main'", 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.assertFalse(gate.call_args.kwargs["pending_commit"]) + + def test_bash_c_add_predicts_sensitive_file_without_staging_it(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-indirect-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( + "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_shell_script_after_cd_binds_its_repository(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-indirect-cd-") as tmp: + base = Path(tmp).resolve() + caller = self._initialized_repo(base, "caller") + target = self._initialized_repo(base, "target") + (target / ".env").write_text("SECRET=value\n", encoding="utf-8") + 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]) + + def test_outer_commit_does_not_hide_inner_shell_commit(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-indirect-mixed-") as tmp: + base = Path(tmp).resolve() + caller = self._initialized_repo(base, "caller") + target = self._initialized_repo(base, "target") + (caller / "README.md").write_text("next\n", encoding="utf-8") + (target / ".env").write_text("SECRET=value\n", encoding="utf-8") + for repo, name in ((caller, "README.md"), (target, ".env")): + subprocess.run(["git", "-C", str(repo), "add", name], + check=True, capture_output=True) + command = (f"git -C {caller} commit -m x && " + f"bash -c 'cd {target} && git commit -m x'") + with patch.object(git_guard_application, "run_gate", return_value=([], [])) as gate: + result = git_guard_application.evaluate_git_command( + command, cwd=caller, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn(".env", result.stderr) + self.assertEqual([caller, target], [call.args[0] for call in gate.call_args_list]) + + def test_parent_chain_bypass_covers_only_its_inner_repository(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-indirect-bypass-") as tmp: + base = Path(tmp).resolve() + first = self._initialized_repo(base, "first") + second = self._initialized_repo(base, "second") + for repo in (first, second): + (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 -C {first} config codeguard.skipGate true && " + f"bash -c 'git -C {first} commit -m x' && " + f"git -C {second} commit -m x") + 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(2, result.exit_code) + self.assertIn(".env", result.stderr) + gate.assert_called_once() + self.assertEqual(second, gate.call_args.args[0]) + + def test_non_shell_indirect_git_text_is_explicitly_unverified(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-indirect-unknown-") as tmp: + repo = self._initialized_repo(Path(tmp).resolve(), "repo") + (repo / "opaque.py").write_text("git commit -m x\n", encoding="utf-8") + with patch.object(git_guard_application, "run_gate") as gate: + result = git_guard_application.evaluate_git_command( + "python3 opaque.py", cwd=repo, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn("Git 意图 UNVERIFIED", result.stderr) + gate.assert_not_called() + + def test_indirect_skip_gate_mutation_cannot_borrow_persisted_bypass(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-indirect-config-") 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) + with patch.object(git_guard_application, "run_gate") as gate: + result = git_guard_application.evaluate_git_command( + "bash -c 'git config codeguard.skipGate false' && git commit -m x", + cwd=repo, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn("Git 意图 UNVERIFIED", result.stderr) + gate.assert_not_called() + + def test_unmodelled_inner_staging_blocks_instead_of_reporting_soft_context(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-indirect-staging-") as tmp: + repo = self._initialized_repo(Path(tmp).resolve(), "repo") + (repo / "README.md").write_text("changed\n", encoding="utf-8") + with patch.object(git_guard_application, "run_gate") as gate: + result = git_guard_application.evaluate_git_command( + "bash -c 'git add -p && git commit -m x'", cwd=repo, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn("Git 意图 UNVERIFIED", result.stderr) + gate.assert_not_called() + + def test_shell_add_then_outer_commit_cannot_lose_staging_intent(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-shell-add-outer-") as tmp: + repo = self._initialized_repo(Path(tmp).resolve(), "repo") + (repo / ".env").write_text("SECRET=value\n", encoding="utf-8") + for command in ("bash -c 'git add .env' && git commit -m x", + "bash -c 'git add .env' && bash -c 'git commit -m x'"): + with self.subTest(command=command), \ + patch.object(git_guard_application, "run_gate", return_value=([], [])) as gate: + result = git_guard_application.evaluate_git_command( + command, cwd=repo, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn("Git 意图 UNVERIFIED", result.stderr) + gate.assert_not_called() + + def test_shell_add_after_pure_push_is_not_a_commit_bypass(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-shell-add-after-push-") as tmp: + repo = self._initialized_repo(Path(tmp).resolve(), "repo") + (repo / ".env").write_text("SECRET=value\n", encoding="utf-8") + with patch.object(git_guard_application, "run_gate", return_value=([], [])) as gate: + result = git_guard_application.evaluate_git_command( + "git push origin main && bash -c 'git add .env'", + cwd=repo, load_config=dict) + self.assertEqual(0, result.exit_code, result.stderr) + gate.assert_called_once() + + def test_real_hook_blocks_sensitive_add_inside_shell_wrapper(self): + with tempfile.TemporaryDirectory(prefix="cg-hook-indirect-") as tmp: + base = Path(tmp).resolve() + repo = self._initialized_repo(base, "repo") + (repo / ".env").write_text("SECRET=value\n", encoding="utf-8") + before = (repo / ".git/index").read_bytes() + payload = {"tool_name": "Bash", "tool_use_id": "indirect-sensitive", + "tool_input": {"command": "bash -c 'git add .env && 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) + self.assertEqual(before, (repo / ".git/index").read_bytes()) + @staticmethod def _initialized_repo(base: Path, name: str) -> Path: repo = base / name