diff --git a/openspec/changes/2026-09-22-gate-full-scan-excludes/.openspec.yaml b/openspec/changes/2026-09-22-gate-full-scan-excludes/.openspec.yaml new file mode 100644 index 0000000..4b5ddbb --- /dev/null +++ b/openspec/changes/2026-09-22-gate-full-scan-excludes/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-09-22 +skip_specs: true diff --git a/openspec/changes/2026-09-23-dot-prefix-default-skip/specs/scan-scope-policy/spec.md b/openspec/changes/2026-09-23-dot-prefix-default-skip/specs/scan-scope-policy/spec.md index aa1a354..9a2ba41 100644 --- a/openspec/changes/2026-09-23-dot-prefix-default-skip/specs/scan-scope-policy/spec.md +++ b/openspec/changes/2026-09-23-dot-prefix-default-skip/specs/scan-scope-policy/spec.md @@ -8,7 +8,7 @@ ### Requirement: Check and save faces MUST skip dot-prefixed paths by default -相对项目根,任一路径段以 `.` 开头(`.`、`..` 段除外)的目录与文件为点前缀路径,默认忽略:不扫描、不检查、不报告。适用于 PostToolUse 保存面、提交门禁 delta 面、语言发现面,以及全量扫描的 ruff 与 `find` 型 gate 通道。项目根本身位于点前缀父目录下(如 `~/.config/proj/`)不构成点前缀命中。 +点前缀路径的默认忽略 MUST 作用于全部检查面。相对项目根,任一路径段以 `.` 开头(`.`、`..` 段除外)的目录与文件为点前缀路径,默认忽略:不扫描、不检查、不报告。适用于 PostToolUse 保存面、提交门禁 delta 面、语言发现面,以及全量扫描的 ruff 与 `find` 型 gate 通道。项目根本身位于点前缀父目录下(如 `~/.config/proj/`)不构成点前缀命中。 #### Scenario: PostToolUse skips dot-prefixed files @@ -37,7 +37,7 @@ ### Requirement: Commit safety face MUST NOT be weakened by dot-prefix skipping -点前缀默认忽略只作用于检查面。入库安全检查独立收集拟入库路径:密钥/凭据类文件模式(`.env`、`*.pem`、`.DS_Store` 等)无论点前缀与否照常拦截;点前缀目录(宿主插件清单与第一方配置)照常可入库。 +入库安全检查 MUST NOT 因点前缀忽略而放宽。点前缀默认忽略只作用于检查面。入库安全检查独立收集拟入库路径:密钥/凭据类文件模式(`.env`、`*.pem`、`.DS_Store` 等)无论点前缀与否照常拦截;点前缀目录(宿主插件清单与第一方配置)照常可入库。 #### Scenario: Secret files remain blocked @@ -51,7 +51,7 @@ ### Requirement: Config discovery MUST keep matching dot-prefixed config files -linter 配置发现(`requiresConfig`、`linter_config_files` 项目级匹配)不受点前缀忽略影响:点前缀配置文件照常使语言判定为已接入。 +配置发现 MUST NOT 受点前缀忽略影响。linter 配置发现(`requiresConfig`、`linter_config_files` 项目级匹配)不受点前缀忽略影响:点前缀配置文件照常使语言判定为已接入。 #### Scenario: requiresConfig matches dot files diff --git a/openspec/changes/2026-09-23-fix-release-chain-and-gate-attribution/design.md b/openspec/changes/2026-09-23-fix-release-chain-and-gate-attribution/design.md new file mode 100644 index 0000000..8be8a16 --- /dev/null +++ b/openspec/changes/2026-09-23-fix-release-chain-and-gate-attribution/design.md @@ -0,0 +1,45 @@ +## Context + +v0.15.0 发版实测:bump-plugin 的两个 plain manifest 没被更新(catalog 0.14.14 vs manifest 0.14.12 +漂移 → replace 静默 no-op),且 `node scripts/bump-plugin.mjs` 被 git 意图门禁不可绕过地拦住 +(UNVERIFIED)。后者的机制:`_indirect_body` 取到 .mjs 正文后,`split_shell_segments` 按 shell +语法切段,帮助文本模板字符串里的 `git add && git commit && git push` 被认成真实副作用;resolve 阶段因 +「非 Shell 正文不可建模」抛 GitIntentError——而 skipGate/内联豁免都在解析成功之后才评估,故不可绕过。 + +## Goals / Non-Goals + +**Goals:** +- 版本漂移时发版工具 fail-loud,写后回读校验,消除半程假成功。 +- 非 Shell 正文的 git 归因只认 subprocess/exec 调用形态,消除帮助文本误报; + 真实间接 git 调用保持「UNVERIFIED 阻断」的既有保守语义。 + +**Non-Goals:** +- 不建模 Node/Python 的动态拼接 subprocess(设计既有边界)。 +- 不改 Shell 正文的切段解析与 skipGate 链语义。 +- 不改 bump 的版本计算规则(仍以 catalog 为 oldVersion 源)。 +- Bash heredoc/命令文本数据段的 git 样例误报不在本次范围(需完整 shell 解析,另立 change)。 + +## Decisions + +**fail-loud 而非 regex 自愈。** 替换失败时按任意版本强行覆盖会把「上游误升/误降」也静默吞掉; +显式抛错并报告 manifest 实际版本与 catalog 版本,把漂移暴露给人,符合 verdict-integrity +「不把缺失证据标为通过」。写后回读校验五文件版本一致,崩溃在 sync 步之前也能被拦住。 + +**非 Shell 正文按调用形态识别,不按文本切段。** 高精度信号是 `execFileSync("git", …)`、 +`subprocess.run(["git", …])`、`os.system("…")` 这类调用点;帮助文本不会以调用形态出现 +(bump 帮助文本前是 `console.log(`,不匹配调用词表)。命中调用形态 → 沿用既有阻断 +(不可建模 UNVERIFIED),保持「宁可拦住不放行不可证明的 git」的方向安全。 +误报面收窄到「帮助文本里恰好写成调用形态的样例」,可接受。 + +## Risks / Trade-offs + +- 调用词表是启发式:`eval(...)`、动态拼接命令等仍漏检——与既有「不承诺分析动态拼接」 + 边界一致,不新增风险敞口。 +- fail-loud 会让漂移状态的发版直接失败:这是特性而非代价(此前是假成功)。 +- bump 自身的发版即 dogfood 验证(工具修好后直跑它自己发版)。 + +## Verification + +- 新增回归测试全绿 + 全量 unittest / run_all 零新增失败 + ruff 干净。 +- 直跑 `node scripts/bump-plugin.mjs`(不带 sh -c 包装)通过门禁 = 误报修复的活证据。 +- bump 在制造的漂移 fixture 上抛错 = fail-loud 活证据。 diff --git a/openspec/changes/2026-09-23-fix-release-chain-and-gate-attribution/proposal.md b/openspec/changes/2026-09-23-fix-release-chain-and-gate-attribution/proposal.md new file mode 100644 index 0000000..2fec0ff --- /dev/null +++ b/openspec/changes/2026-09-23-fix-release-chain-and-gate-attribution/proposal.md @@ -0,0 +1,42 @@ +# 2026-09-23-fix-release-chain-and-gate-attribution + +## Why + +v0.15.0 发版过程实测出两个缺陷(用户指令 2026-09-23:修复): + +1. **bump-plugin.mjs 的 plain manifest 替换静默 no-op**:`bumpPlain` 按 catalog 版本字符串 + 精确替换,catalog 与 manifest 漂移时(实测 catalog 0.14.14 vs manifest 0.14.12)replace + 不匹配、无报错地写回原文,发版计划照常打印「-> 0.15.0」——半程假成功,版本链断裂还伴随 + 后续 sync 校验崩溃(同根因)。 +2. **git 意图门禁的间接正文误报**:非 Shell 解释器(node/python)脚本正文被按 shell 切段扫描, + JS 模板字符串里的 git commit/git add/git push **样例文本**(bump-plugin.mjs:164-165 帮助文本 + `cd … && git add && git commit && git push`)被当成真实 git 副作用 → + `node scripts/bump-plugin.mjs` 被判「间接 Git 操作不能可靠建模」**不可绕过地阻断**, + 发版工具被自家门禁锁死。 + +## What Changes + +- bump-plugin.mjs:plain manifest 替换失败(版本漂移)改为**显式抛错**,不再静默跳过; + 全部写入后回读校验版本一致,杜绝半程假成功。 +- git 意图归因:非 Shell 脚本正文不再 shell 切段,改按**subprocess/exec 调用形态**高精度识别 + git 副作用(`execFileSync("git", …)`、`os.system("…")` 等);帮助文本/模板字符串里的 + git 字面量不再触发门禁。命中真实调用时仍按既有语义「不可建模 → UNVERIFIED 阻断」。 +- Shell 脚本正文的行为完全不变(可建模,保留切段解析)。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +无(行为修复归入既有 gate-trigger-policy 语义:误报消除、触发面不变;按 fix-gate-trigger-and-mcp +先例以 proposal+design+tasks 收口)。 + +## Impact + +- `scripts/bump-plugin.mjs`、`scripts/codeguard/git_syntax.py`、`scripts/codeguard/git_context.py`。 +- 发版链路行为:漂移时 fail-loud(此前静默);`node scripts/bump-plugin.mjs` 恢复可直跑。 +- 测试:新增间接归因回归(JS 帮助文本不触发 / node、python 真实 git 调用仍触发并阻断 / Shell 行为不变)。 +- 已知边界(非目标):Bash heredoc/命令文本数据段里的 git 样例仍可能被外层切段误伤(写作侧规避)。 diff --git a/openspec/changes/2026-09-23-fix-release-chain-and-gate-attribution/specs/hook-protocol/spec.md b/openspec/changes/2026-09-23-fix-release-chain-and-gate-attribution/specs/hook-protocol/spec.md new file mode 100644 index 0000000..15c9466 --- /dev/null +++ b/openspec/changes/2026-09-23-fix-release-chain-and-gate-attribution/specs/hook-protocol/spec.md @@ -0,0 +1,32 @@ +# hook-protocol(增量):非 Shell 间接正文按调用形态归因 + +## MODIFIED Requirements + +### Requirement: The guard SHALL treat one-level interpreter indirection as guarded + +`is_guarded()` MUST 覆盖直接命令与一层解释器间接。**Shell 解释器(bash/sh/zsh)**:脚本文件文本或 `-c` 内联代码按与直接命令相同的分隔符切段规则扫描到 `git commit|git push` 段首命令词时 MUST 判为拦截;切段扫描前 MUST 先展开 `$(...)` 与反引号内层文本(shell 语义下它们会被真实执行;`ro=$(git push …)` 曾静默放行),段首归一化 MUST 覆盖 shell 控制引导词(`if`/`then`/`else`/`elif`/`while`/`until`/`do`/`!`——`if git push; then`、`for x; do git push; done` 曾静默放行)。**非 Shell 解释器(python/node 等)**:正文 MUST NOT 按 shell 切段归因——模板字符串/帮助文本里的 git 命令字面量样例不是调用(bump-plugin.mjs:164-165 帮助文本实测误报、发版工具被不可绕过地锁死);归因 MUST 只认 subprocess/exec 调用形态(`execFileSync("git", …)`、`subprocess.run(["git", …])`、`os.system("…")` 等调用点的 git 参数位),命中调用形态时 MUST 判为拦截并在 resolve 阶段按「不可建模」UNVERIFIED 阻断。`resolve_project_roots()` MUST 用同一套归一化判定收集仓库边界(含 `git -C ` 显式仓边界)——判定不同源时会出现"命中拦截但 roots=[] → 静默放行"的击穿(0.8.2 实测)。更深的动态构造(如 subprocess 参数拼接)MUST 在文档中声明为能力边界而非承诺。 + +#### Scenario: A wrapper script performs the commit + +- **WHEN** 调用形如 `bash runner.sh` 且脚本体内含 `git commit` 或 `git push` +- **THEN** 判定命中,硬门禁按正常流程跑 linter 并可能 exit 2 + +#### Scenario: Command substitution or shell control structure hides the git call + +- **WHEN** 调用形如 `ro=$(git push origin b 2>&1)`、`` x=`git commit` ``、`for x; do git push; done` 或 `if git push; then …; fi` +- **THEN** 展开/归一化后命中拦截,且 `resolve_project_roots` 解析出正确仓库边界(不为 []) + +#### Scenario: Text mentioning git push in an echo is not guarded + +- **WHEN** 调用为 `echo "git push 是危险命令"` 或脚本内容仅为 `echo hi` +- **THEN** 判定不命中,钩子静默放行 + +#### Scenario: Help text samples in a non-shell script are not guarded + +- **WHEN** `node scripts/tool.mjs` 的正文含模板字符串帮助文本 `cd && git add -A && git commit -m "x" && git push` +- **THEN** 判定不命中(字面量样例不是调用),钩子放行 + +#### Scenario: A non-shell script really invokes git via subprocess + +- **WHEN** `python3 work.py` 的正文含 `subprocess.run(["git", "push"])` 或 `node run.mjs` 的正文含 `execFileSync("git", ["git commit" …])` 形态的调用 +- **THEN** 判定命中,且 resolve 阶段按「间接 Git 操作不能可靠建模」UNVERIFIED 阻断(不静默放行) diff --git a/openspec/changes/2026-09-23-fix-release-chain-and-gate-attribution/tasks.md b/openspec/changes/2026-09-23-fix-release-chain-and-gate-attribution/tasks.md new file mode 100644 index 0000000..7cd42f2 --- /dev/null +++ b/openspec/changes/2026-09-23-fix-release-chain-and-gate-attribution/tasks.md @@ -0,0 +1,26 @@ +# Tasks: 2026-09-23-fix-release-chain-and-gate-attribution + +## 1. bump-plugin fail-loud + +- [x] 1.1 `bumpPlain` 替换无命中时抛错,报告 manifest 实际版本与 catalog 版本 +- [x] 1.2 全部写入后回读校验 catalog + 4 manifest 版本与计划一致 + +## 2. git 意图归因(非 Shell 正文) + +- [x] 2.1 `git_syntax` 新增调用形态检测(exec/spawn/subprocess/os.system 等 + git 参数位),返回 commit|push|add|config 与 skipGate 变更 +- [x] 2.2 `git_context` resolve 路径:非 Shell 正文改走调用形态检测;Shell 正文行为不变 +- [x] 2.3 `_guarded_mode`/`_command_indirect` 同步按 is_shell 分派 + +## 3. 回归测试 + +- [x] 3.1 JS 帮助文本(模板字符串 git 样例)不触发门禁 +- [x] 3.2 node `execFileSync("git", ["…"])` / python `subprocess.run(["git", …])` 仍触发并 UNVERIFIED 阻断 +- [x] 3.3 Shell 脚本真实 git commit 仍可建模(行为不变) +- [x] 3.4 非 Shell 帮助文本里的 `git config codeguard.skipGate` 不构成豁免变更 +- [x] 3.5 bump 漂移 fixture 抛错(fail-loud) + +## 4. 发版 + +- [ ] 4.1 全量回归 + ruff 干净 +- [ ] 4.2 dogfood:直跑 `node scripts/bump-plugin.mjs codeguard patch`(不带包装)过门禁并发版 +- [ ] 4.3 市场仓同步 + PR/CI/合并 + 两仓推送 diff --git a/openspec/specs/asset-canonicalization/spec.md b/openspec/specs/asset-canonicalization/spec.md index c0c0a1b..15d0007 100644 --- a/openspec/specs/asset-canonicalization/spec.md +++ b/openspec/specs/asset-canonicalization/spec.md @@ -1,7 +1,7 @@ # asset-canonicalization Specification ## Purpose -TBD - created by archiving change consolidate-marketing-assets. Update Purpose after archive. +定义营销资产的正典化契约:零引用资产必须移除、保留资产必须在 assets/README.md 逐个登记,使资产面始终可审计、可追溯,不积累无主文件。 ## Requirements ### Requirement: Zero-reference marketing assets SHALL be removed diff --git a/openspec/specs/bilingual-docs-consistency/spec.md b/openspec/specs/bilingual-docs-consistency/spec.md index e4677cb..40eab29 100644 --- a/openspec/specs/bilingual-docs-consistency/spec.md +++ b/openspec/specs/bilingual-docs-consistency/spec.md @@ -1,7 +1,7 @@ # bilingual-docs-consistency Specification ## Purpose -TBD - created by archiving change add-readme-parity-gate. Update Purpose after archive. +定义 README.md 与 README.zh-CN.md 的结构一致性契约:标题层级、本地链接目标与版本串必须镜像,由 tests/test_readme_parity.py 门禁强制,保证双语用户入口同步演进、任一边漏改即失败。 ## Requirements ### Requirement: README structural parity SHALL be enforced by tests/test_readme_parity.py diff --git a/openspec/specs/gate-trigger-policy/spec.md b/openspec/specs/gate-trigger-policy/spec.md index 7e1b0b2..e99ae0c 100644 --- a/openspec/specs/gate-trigger-policy/spec.md +++ b/openspec/specs/gate-trigger-policy/spec.md @@ -1,7 +1,7 @@ # gate-trigger-policy Specification ## Purpose -TBD - created by archiving change 2026-09-22-fix-gate-trigger-and-mcp. Update Purpose after archive. +定义 UserPromptSubmit 门禁的触发策略:只有真实提交意图才启动 lint 门禁的精确判据(词边界、疑问句识别、否定式排除、可识别语言收窄),消除日常对话的误触发与无关全量检查。 ## Requirements ### Requirement: The UserPromptSubmit hook SHALL run the lint gate only when intent is commit-like The `UserPromptSubmit` hook MUST evaluate the user's prompt for commit intent using word-boundary matching of trigger words (`commit|push|deploy|发布|部署`) and a separate action-verb check. The hook SHALL exit silently when either (a) no trigger word is present, (b) the only trigger occurrence is followed by `?`, or (c) a trigger word appears in an interrogative sentence identified by `QUESTION_MARKERS` (`?`, `?`, `么`, `吗`, `如何`, `怎么`, `有没有`, `是不是`, `什么是`, `哪些`). diff --git a/openspec/specs/idiomatic-runner/spec.md b/openspec/specs/idiomatic-runner/spec.md index 9745864..37a9c4c 100644 --- a/openspec/specs/idiomatic-runner/spec.md +++ b/openspec/specs/idiomatic-runner/spec.md @@ -1,7 +1,7 @@ # idiomatic-runner Specification ## Purpose -TBD - created by archiving change add-run-per-language. Update Purpose after archive. +定义按语言执行的运行器契约:per-language 子进程执行收口在 scripts/run_per_language.py,入口(run_check.py/fix.py)不再自建 subprocess 运行器,退出码统一归一化,避免同一执行语义多处漂移。 ## Requirements ### Requirement: Per-language execution SHALL live in scripts/run_per_language.py diff --git a/openspec/specs/languages-registry-contract/spec.md b/openspec/specs/languages-registry-contract/spec.md index 66c5973..cdafc59 100644 --- a/openspec/specs/languages-registry-contract/spec.md +++ b/openspec/specs/languages-registry-contract/spec.md @@ -1,7 +1,7 @@ # languages-registry-contract Specification ## Purpose -TBD - created by archiving change add-languages-schema-validator. Update Purpose after archive. +定义 languages.json 注册表契约:语言清单的单一事实源由 scripts/validate_languages_json.py 的结构与一致性规则校验并在 CI 强制,任何字段缺失、命令形态错误或命名冲突在入库前被拦截。 ## Requirements ### Requirement: languages.json SHALL be validated by scripts/validate_languages_json.py diff --git a/openspec/specs/plugin-manifest-contracts/spec.md b/openspec/specs/plugin-manifest-contracts/spec.md index 8fa71f8..4e515eb 100644 --- a/openspec/specs/plugin-manifest-contracts/spec.md +++ b/openspec/specs/plugin-manifest-contracts/spec.md @@ -1,7 +1,7 @@ # plugin-manifest-contracts Specification ## Purpose -TBD - created by archiving change add-manifest-bundle-dynamic. Update Purpose after archive. +定义插件清单族的一致性契约:四端 manifest 与市场清单的版本链一致、发布物中的 skills bundle 必须等于 skills.lock 与 plugin-local 声明的并集,安装面不出现缺技能或多技能。 ## Requirements ### Requirement: Released manifest skills bundle SHALL equal the lock+local union diff --git a/openspec/specs/registry-driven-config/spec.md b/openspec/specs/registry-driven-config/spec.md index a13c385..ecabae8 100644 --- a/openspec/specs/registry-driven-config/spec.md +++ b/openspec/specs/registry-driven-config/spec.md @@ -1,7 +1,7 @@ # registry-driven-config Specification ## Purpose -TBD - created by archiving change add-linter-config-from-registry. Update Purpose after archive. +定义注册表驱动的 linter 配置契约:各语言 linter 配置文件清单只在 languages.json 声明,env_check 等消费方从注册表派生配置事实而非硬编码字典,保证新增语言只改一处。 ## Requirements ### Requirement: Per-language linter config files SHALL live in scripts/languages.json diff --git a/scripts/bump-plugin.mjs b/scripts/bump-plugin.mjs index aa68429..43905b9 100644 --- a/scripts/bump-plugin.mjs +++ b/scripts/bump-plugin.mjs @@ -128,12 +128,19 @@ catalogText = catalogText.slice(0, segStart) + `"version": "${newVersion}"` + ca fs.writeFileSync(catalogPath, catalogText); // 2) 各仓 manifest -const bumpPlain = (text) => text.replace(`"version": "${oldVersion}"`, `"version": "${newVersion}"`); +const bumpPlain = (text, rel) => { + const updated = text.replace(`"version": "${oldVersion}"`, `"version": "${newVersion}"`); + if (updated === text) { + const actual = /"version": "([^"]+)"/.exec(text)?.[1] ?? "(未找到)"; + throw new Error(`${rel}: manifest 版本 ${actual} 与 catalog ${oldVersion} 漂移;拒绝静默跳过,请先对齐版本再发版`); + } + return updated; +}; const bumpCodex = (text) => text.replace(/"version": "\d+\.\d+\.\d+(?:\+codex\.\d+)?"/, `"version": "${newVersion}+codex.${today}"`); for (const rel of plainManifestRels) { const manifest = path.join(repoDir, rel); - fs.writeFileSync(manifest, bumpPlain(fs.readFileSync(manifest, "utf8"))); + fs.writeFileSync(manifest, bumpPlain(fs.readFileSync(manifest, "utf8"), rel)); } const repositoryMarketplace = path.join(repoDir, ".agents/plugins/marketplace.json"); const marketplace = JSON.parse(fs.readFileSync(repositoryMarketplace, "utf8")); @@ -153,6 +160,19 @@ fs.writeFileSync(repositoryMarketplace, `${JSON.stringify(marketplace, null, 2)} const codexManifest = path.join(repoDir, ".codex-plugin/plugin.json"); fs.writeFileSync(codexManifest, bumpCodex(fs.readFileSync(codexManifest, "utf8"))); +// 写后回读:五文件版本必须与计划一致——写后不验等于没写(防半程假成功) +for (const rel of plainManifestRels) { + const readback = JSON.parse(fs.readFileSync(path.join(repoDir, rel), "utf8")).version; + if (readback !== newVersion) throw new Error(`${rel}: 写后回读 ${readback} != ${newVersion}`); +} +const codexReadback = fs.readFileSync(codexManifest, "utf8"); +if (!codexReadback.includes(`"version": "${newVersion}+codex.${today}"`)) { + throw new Error(`.codex-plugin/plugin.json: 写后回读版本不符`); +} +if (!catalogText.includes(`"version": "${newVersion}"`)) { + throw new Error(`catalog.json: 写后回读版本不符`); +} + // 3) 重新生成三平台清单 + 全量校验 const pluginFilter = `--plugin=${pluginId}`; execFileSync(process.execPath, [path.join(root, "scripts/sync-marketplaces.mjs"), "--write", pluginFilter], { stdio: "inherit" }); diff --git a/scripts/codeguard/git_context.py b/scripts/codeguard/git_context.py index db890b0..d325d63 100644 --- a/scripts/codeguard/git_context.py +++ b/scripts/codeguard/git_context.py @@ -21,6 +21,7 @@ _git_side_effect_sub, _segment_is_git_side_effect, _strip_command_prefix, + dynamic_git_effects, inline_skip_gate, skip_gate_config_change, split_shell_segments, @@ -93,14 +94,26 @@ def resolve_git_operations(command: str, cwd: Path | None = None, *, 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 is_shell: + 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 + else: + # 非 Shell 正文不做 shell 切段:模板字符串/帮助文本里的 git 字面量 + # 不是调用(bump-plugin.mjs 帮助文本实测误报)。只认 exec/subprocess + # 调用形态,命中仍按「不可建模」阻断——方向安全不放松。 + dynamic_subs, dynamic_skip = dynamic_git_effects(body) + if dynamic_skip is not None: + raise GitIntentError("间接脚本修改 skipGate,无法证明后续配置状态;请拆分命令") + if "add" in dynamic_subs and not any( + name in dynamic_subs for name in ("commit", "push")): + unresolved_indirect_add = True + body_effects = [name for name in dynamic_subs if name in ("commit", "push")] if body_effects: if unresolved_indirect_add and "commit" in body_effects: raise GitIntentError("间接脚本暂存与后续提交跨越解释器边界;请拆分命令") @@ -233,14 +246,21 @@ def _indirect_bodies(command: str, cwd: Path | None = None): continue indirect = _indirect_body(segment, current_dir) if indirect is not None: - yield indirect[0] + yield indirect def _command_indirect(command: str) -> bool: """兼容一层间接识别;不执行脚本,不承诺分析动态拼接的 subprocess。""" - return any(_segment_is_git_side_effect(segment) - for body in _indirect_bodies(command) - for segment, _separator in split_shell_segments(body)) + for body, is_shell in _indirect_bodies(command): + if is_shell: + if any(_segment_is_git_side_effect(segment) + for segment, _separator in split_shell_segments(body)): + return True + else: + subs, _skip = dynamic_git_effects(body) + if any(name in subs for name in ("commit", "push")): + return True + return False def _repo_root_of(p: Path) -> Path | None: @@ -267,8 +287,12 @@ def _guarded_mode(command: str, cwd: Path | None = None) -> str | None: 分别选择门禁面,不能把这里的整链模式传播到其它仓。 """ subs = _collect_subs(command) - for body in _indirect_bodies(command, cwd): - subs.extend(_collect_subs(body)) + for body, is_shell in _indirect_bodies(command, cwd): + if is_shell: + subs.extend(_collect_subs(body)) + else: + dynamic_subs, _skip = dynamic_git_effects(body) + subs.extend(name for name in dynamic_subs if name in ("commit", "push")) if not subs: return None return "push" if "push" in subs else "commit" diff --git a/scripts/codeguard/git_syntax.py b/scripts/codeguard/git_syntax.py index 5c8c2fa..28820a9 100644 --- a/scripts/codeguard/git_syntax.py +++ b/scripts/codeguard/git_syntax.py @@ -295,6 +295,36 @@ def chain_skip_gate(command: str) -> bool: for seg, _separator in split_shell_segments(text)) +_DYNAMIC_GIT_CALL = re.compile( + r"(?:^|[^\w.])(?:os\.(?:system|popen)|subprocess\.(?:run|call|Popen|check_call|check_output)" + r"|(?:exec|execFile|execSync|execFileSync|spawn|spawnSync))\s*\(\s*\[?\s*[\"\'\x60]git(?:[\"\'\x60]|\s)" +) +_DYNAMIC_GIT_WINDOW = 200 +_DYNAMIC_SUB_QUOTED = re.compile(r"[\"\'\x60\[\s,](commit|push|add|config)[\"\'\x60\],\s]") +_DYNAMIC_SUB_TEXT = re.compile(r"\bgit\s+(commit|push|add|config)\b") + + +def dynamic_git_effects(text: str) -> tuple[list[str], bool | None]: + """非 Shell 源码正文中 git 副作用的高精度识别:只认调用形态。 + + `execFileSync("git", …)`、`subprocess.run(["git", …])`、`os.system("…")` + 这类调用点才是可归因信号;帮助文本/模板字符串里的 git 字面量样例不是调用 + (bump-plugin.mjs 帮助文本实测被 shell 切段误报)。命中调用点后在邻近窗口 + 收集子命令与 skipGate 配置变更。不承诺分析动态拼接的 subprocess。 + """ + subs: list[str] = [] + skip: bool | None = None + for match in _DYNAMIC_GIT_CALL.finditer(text): + window = text[match.start(): match.start() + _DYNAMIC_GIT_WINDOW] + found = set(_DYNAMIC_SUB_TEXT.findall(window)) | set(_DYNAMIC_SUB_QUOTED.findall(window)) + for name in ("commit", "push", "add"): + if name in found and name not in subs: + subs.append(name) + if "config" in found and re.search(r"codeguard\.skipgate", window, re.IGNORECASE): + skip = not re.search(r"--unset", window) + return subs, skip + + def _segment_is_git_side_effect(seg: str) -> bool: return _git_side_effect_sub(seg) is not None diff --git a/tests/test_git_guard_application.py b/tests/test_git_guard_application.py index f0beef7..11cc093 100644 --- a/tests/test_git_guard_application.py +++ b/tests/test_git_guard_application.py @@ -207,12 +207,16 @@ def test_parent_chain_bypass_covers_only_its_inner_repository(self): gate.assert_called_once() self.assertEqual(second, gate.call_args.args[0]) - def test_non_shell_indirect_git_text_is_explicitly_unverified(self): + def test_non_shell_indirect_git_call_is_explicitly_unverified(self): + # 2026-09-23 归因收紧:纯文本(帮助/文档样例)不再触发——bump-plugin.mjs + # 帮助文本实测误报;**调用形态**(exec/subprocess)仍判不可建模并阻断。 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") + (repo / "opaque.py").write_text( + 'import subprocess\nsubprocess.run(["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) @@ -220,6 +224,19 @@ def test_non_shell_indirect_git_text_is_explicitly_unverified(self): self.assertIn("Git 意图 UNVERIFIED", result.stderr) gate.assert_not_called() + def test_non_shell_indirect_git_text_no_longer_triggers(self): + # 同类文本(帮助/样例)不再触发门禁;真实调用形态见上一用例 + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-indirect-text-") 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(0, result.exit_code) + gate.assert_not_called() + def test_indirect_skip_gate_mutation_cannot_borrow_persisted_bypass(self): from codeguard import git_guard_application diff --git a/tests/test_hardening_fixes.py b/tests/test_hardening_fixes.py index 69e981e..a68a2c5 100644 --- a/tests/test_hardening_fixes.py +++ b/tests/test_hardening_fixes.py @@ -72,13 +72,17 @@ def test_harmless_script_is_not_guarded(self) -> None: self.assertFalse(guard.is_guarded(f"bash {script}")) def test_documented_boundary_subprocess_construction(self) -> None: - """静态扫描的能力边界(docstring 已声明):拼接式 subprocess 不在承诺内。""" + """静态扫描边界(2026-09-23 归因收紧后):调用形态命中、纯文本不命中。 + + 拼接式/动态构造的 subprocess 仍不在承诺内(docstring 边界不变)。 + """ script = Path(tempfile.mkdtemp(prefix="cg-ind-")) / "crafted.py" + # 调用形态现在命中(此前漏报) script.write_text('import subprocess\nsubprocess.run(["git", "push"])\n', encoding="utf-8") - self.assertFalse(guard.is_guarded(f"python3 {script}")) - # 但直接命令词形态仍然命中 - script.write_text("git push origin main\n", encoding="utf-8") self.assertTrue(guard.is_guarded(f"python3 {script}")) + # 纯文本行不再命中(帮助/文档样例,bump-plugin.mjs 误报同源) + script.write_text("git push origin main\n", encoding="utf-8") + self.assertFalse(guard.is_guarded(f"python3 {script}")) class WholeCallNoticeTests(unittest.TestCase): diff --git a/tests/test_indirect_git_attribution.py b/tests/test_indirect_git_attribution.py new file mode 100644 index 0000000..4c55dba --- /dev/null +++ b/tests/test_indirect_git_attribution.py @@ -0,0 +1,82 @@ +"""非 Shell 正文的 git 归因回归(2026-09-23 fix-release-chain-and-gate-attribution)。 + +锁定四件事: +1. JS 帮助文本(模板字符串里的 git 命令样例)不触发门禁——bump-plugin.mjs 实测误报; +2. node/python 真实间接 git 调用(exec/subprocess 形态)仍触发并按「不可建模」阻断; +3. Shell 脚本正文行为不变(可建模); +4. 非 Shell 帮助文本里的 git config codeguard.skipGate 不构成豁免变更。 +""" +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +PLUGIN = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PLUGIN / "scripts")) + +from codeguard.git_context import ( + GitIntentError, + is_guarded, + resolve_git_operations, +) +from codeguard.git_syntax import dynamic_git_effects + +JS_HELP = """console.log(`release usage: + cd && git add -A && git commit -m "release: x" && git push +`);""" + +JS_REAL = 'execFileSync("git", ["commit", "-m", "x"]);' +PY_REAL = "import subprocess\nsubprocess.run([\"git\", \"push\"])\n" +JS_SKIP_TEXT = 'console.log(`bypass: git config codeguard.skipGate true`);' + + +def _write(tmp: Path, name: str, text: str) -> Path: + target = tmp / name + target.write_text(text, encoding="utf-8") + return target + + +class DetectionTests(unittest.TestCase): + def test_js_help_text_not_guarded(self) -> None: + script = _write(Path(tempfile.mkdtemp()), "help.mjs", JS_HELP) + self.assertFalse(is_guarded(f"node {script}")) + + def test_node_real_git_call_guarded(self) -> None: + script = _write(Path(tempfile.mkdtemp()), "real.mjs", JS_REAL) + self.assertTrue(is_guarded(f"node {script}")) + + def test_python_subprocess_guarded(self) -> None: + script = _write(Path(tempfile.mkdtemp()), "real.py", PY_REAL) + self.assertTrue(is_guarded(f"python3 {script}")) + + def test_shell_script_still_modeled(self) -> None: + repo = Path(tempfile.mkdtemp()) + subprocess.run(["git", "init", "-q"], cwd=repo, capture_output=True, check=False) + script = _write(repo, "deploy.sh", "#!/bin/bash\ngit commit -m t\n") + self.assertTrue(is_guarded(f"bash {script}")) + operations = resolve_git_operations(f"bash {script}", cwd=repo) + self.assertTrue(operations and operations[0].mode == "commit") + + def test_nons_shell_help_skip_text_is_not_config_change(self) -> None: + self.assertEqual(dynamic_git_effects(JS_SKIP_TEXT), ([], None)) + + def test_dynamic_call_forms_detected(self) -> None: + subs, _ = dynamic_git_effects('os.system("git commit -m x")') + self.assertEqual(subs, ["commit"]) + subs, _ = dynamic_git_effects('subprocess.run(["git", "push"])') + self.assertEqual(subs, ["push"]) + subs, skip = dynamic_git_effects( + 'execFileSync("git", ["config", "codeguard.skipGate", "true"])') + self.assertTrue(skip) + + def test_non_shell_real_call_resolves_to_unverified(self) -> None: + script = _write(Path(tempfile.mkdtemp()), "real.mjs", JS_REAL) + with self.assertRaises(GitIntentError): + resolve_git_operations(f"node {script}") + + +if __name__ == "__main__": + unittest.main()