fix(core): confirm read-only git commands when repo config executes programs (#8575) - #8645
Conversation
…rograms (#8575) Whitelisted read-only git sub-commands (status, diff, log, show, ...) are auto-approved based purely on command text, but git can execute programs configured in the repository-local config while running them: diff.external, core.fsmonitor, core.pager / pager overrides, diff driver textconv, core.askpass, credential.helper, core.sshCommand, remote proxies, ext:: remote URLs, gpg.program. A planted .git/config could turn an auto-approved command into arbitrary code execution. Add a synchronous repo-local config probe (bounded stat walk + small file reads, fail-closed) shared by the AST and regex classifiers: when a git command would classify as read-only and the repo-local config reachable from the execution cwd contains program-executing keys, the verdict is downgraded so the command requires confirmation. Global/system config is deliberately out of scope (the user's own setup, not a cloned-repo attack surface). All permission entry points (shell tool, monitor tool, permission manager, memory-scoped agent policy) now pass the execution cwd to the classifier. Classifier APIs only gain an optional parameter; behavior without cwd is unchanged.
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
|
Re-run on Template looks good ✓ Problem: observed, not theoretical. Linked issue #8575 is triaged as a P2 security bug ( Direction: aligned — this closes a hole in the shell approval gate, core mission for an agent that executes commands. No direct counterpart in the claude-code CHANGELOG (closest signal: their Size: core paths ( Approach: scope still right, and the three new commits are exactly the follow-through the last round asked for: the regex fallback now matches the AST path on quoted/backgrounded Risk: elevated-review signal unchanged — Moving on to code review. 🔍 中文说明应 模板完整 ✓ 问题:已观测到,不是理论问题。关联 issue #8575 已被 triage 为 P2 安全 bug( 方向:对齐——这补上了 shell 审批门口的一个洞,对执行命令的 agent 来说是核心职责。claude-code CHANGELOG 没有直接对应项(最接近的信号是其只读 git 命令的 规模:核心路径( 方案:范围仍然合理,三个新提交正是上一轮要求的落实:正则兜底路径在带引号/后台 风险:升级审查信号不变—— 进入代码审查 🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code reviewUnattended CI run — no PR code was built or executed here. This pass reviewed the new head
Two precision tightenings in the same delta, both checked against git semantics before accepting: Everything the full pass at Non-blocking follow-ups (carried + new):
Files changed (11 production + 13 test files)
…plus 13 test files (2297 lines), incl. the real-fs Test evidenceThe PR's own CI on
The three skips are CI design, not fail-fast: Behavioural substantiation (attributed, not bot-run): @wenshao's maintainer verification targets 中文说明代码审查(无人值守 CI 运行——未构建/执行任何 PR 代码)。本轮以「
同一增量里还有两处精度收紧,均先对照 git 语义核实才接受:
非阻塞跟进(延续 + 新增):(1) R1——submodule 本地配置:扫描被撤后,植入的 测试证据:PR 自身 CI( — Qwen Code · qwen3.8-max Reviewed at |
|
Confidence: 3/5 — clean review across every stage on this head and the maintainer's approval is on record for it; the cap is the Stage 0 size escalation for core changes of this scale, not any doubt about the code. Stepping back: the three commits since the last pass are exactly what I would have asked for, and nothing else. Two of the sandboxed verification's findings got real fixes — regex/AST parity on quoted and backgrounded Why the bot still doesn't approve, plainly:
No 中文说明置信度:3/5 —— 当前 head 上每一阶段的审查都是干净的,维护者对该 head 的批准也已在案;上限来自 Stage 0 对这一规模核心改动的升级机制,而不是对代码的任何疑虑。 整体看:自上次审查以来的三个提交,正是我会要求的全部内容,且没有任何多余的东西。沙箱验证的三个发现中,两个得到了真正的修复——正则/AST 在带引号与后台 为什么 bot 仍不批准,直说:
我不会发 — Qwen Code · qwen3.8-max Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Two small but real gaps in the coverage this PR claims — the follow-up speculation path executes shell commands without the probe, and the main checkout's .git/config.worktree is never read when extensions.worktreeConfig is on. Both are the same hole class this PR closes; fixes are mechanical and spelled out in my review comment. CI is also red (one likely-unrelated CLI test) and needs a re-run. Everything else is in good shape. 🙏
- Speculation gate now receives the execution cwd: speculated shell calls bypass the permission flow, so evaluateToolCall passes cwd (and the shell directory arg, which takes precedence) into classifyShellCommandSafety. A speculated `git diff` in a repo with diff.external planted now hits the boundary instead of executing. - Probe reads `.git/config.worktree` of the main checkout too — with extensions.worktreeConfig enabled git reads it for the main worktree, so a key planted there no longer bypasses the probe. - plan-mode shell policy passes its effective cwd to the classifier for consistent classification (no execution hole there; consistency). - Document bare repos as out of scope. - Add end-to-end integration test driving the real probe + classifier through ShellToolInvocation.getDefaultPermission (no fs mocking).
|
Both gaps closed in 3c46d35:
Also addressed from the non-blocking notes: plan-mode shell policy now passes its effective cwd to the classifier for consistent classification (hole was verified absent, this is consistency-only), and bare repos are documented as out of scope in the module doc. On the verification gap: added CI: the ubuntu failure was @qwen-code /triage |
|
@qwen-code /triage |
|
Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 283 passed · 4 failed · 287 total 中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:283 通过 · 4 失败 · 287 总计 Verification reportPR 8645 Deep Verification —
|
| command (cwd = dirty repo) | base (control) | head | head (clean repo) | head (no cwd) |
|---|---|---|---|---|
git diff |
true (auto-runs) |
false (confirm) |
true |
true |
git status |
true |
false |
true |
true |
git log -p |
true |
false |
true |
true |
git show HEAD |
true |
false |
true |
true |
git blame file.txt |
true |
false |
true |
true |
git remote show origin |
true |
false |
true |
true |
git branch |
true |
false |
true |
true |
The false flip holds on both classifier paths (AST and regex fallback) for every row; the REGEX column mirrors AST in all 94 cells. Non-git commands (ls -la, cat file.txt) and the immune forms (git --version, bare git) stay true on head even in the dirty repo — confirming the probe only narrows the git whitelist it targets. 94/94 assertions pass.
Findings
Ordered by severity. All four are over-conservative (ask-more) divergences from the PR's stated guarantees — none is a security hole, and none reproduces on the primary AST path in default (tree-sitter-loaded) operation. Each is paired with its exact repro.
What was ruled out first (no bypass found): the config-shape oracle matrix (shape-matrix.mjs, evidence/02-config-shape-matrix-44-cells.png) plants 44 shapes — every program-valued key the PR lists plus dot-form, inline, case-folded, continuation, include/includeIf, url.*.insteadOf, protocol.*.allow, opaque headers — and asserts the verdict; 44/44 downgrade or stay read-only exactly as intended, with fail-closed on >1 MiB / unreadable / unparseable-pointer configs. The adversarial hunt (bypass-hunt.mjs) confirms the command-line execution vectors that repo-config probing cannot see — -c config injection, -C, --git-dir, --work-tree, --exec-path, GIT_EXEC_PATH, and same-name alias shadowing — are all classified non-read-only by the pre-existing text analysis (8/8, and real git confirms a same-name alias cannot shadow a builtin). Env-prefix injection (GIT_CONFIG_COUNT/GIT_DIR, incl. inside a bash -c wrapper) is also refused on both paths (env-perf.mjs, 4/4). Probe cost is 0.03–0.08 ms/call.
#1 — REGEX fallback is stricter than AST for quoted / backgrounded cd (low)
isShellCommandReadOnly (the deprecated regex path, used only when tree-sitter init fails) resolves cd targets more conservatively than the AST path, so two commands that are genuinely read-only come back "confirm" on the fallback but "auto-approve" on AST:
cd 'sub' && git status(quoted target, clean repo): ASTtrue, REGEXfalse.cd sub & git status(backgrounded cd, clean repo): ASTtrue, REGEXfalse.
Repro: node cd-matrix.mjs → cells C15, C20 (evidence/03-cd-tracking-matrix-both-classifiers.png, which also shows the other 21 cd/pushd/subshell/||/& scenarios agreeing across both paths). Impact: in tree-sitter-failure mode only, some legitimate read-only commands prompt. Safe direction. Root cause: trackDirectoryChange rejects any target containing a quote character and treats & as a diverging separator, while resolveCdContext/evaluateSequenceSafety in the AST path statically resolve quoted raw_string/string targets and keep &-terminated statements on the prior directory. If parity matters, align the regex path's target-resolution with the AST's; otherwise document the fallback as intentionally stricter.
#2 — empty core.hooksPath still downgrades (low; contradicts the PR's own doc)
hooksPathDirectories() correctly treats an empty core.hooksPath as "git runs no hooks at all", but gitConfigMayExecutePrograms() independently adds the default .git/hooks directory for every config file it reads, so a repo with core.hooksPath="" plus an executable post-index-change in .git/hooks is still downgraded. Ground truth from real git: with core.hooksPath="", git rev-parse --git-path hooks returns ./ and a git status does not fire the default hook, so the command is genuinely safe.
Repro: node hooks-worktree.mjs → cell H5b (expects read-only, got downgrade). See evidence/04-hookspath-worktree-git-oracles.png — the same run shows husky-style hooksPath staying read-only (H1), executable read-only-triggered hooks still downgrading (H2/H3), real git firing post-index-change on plain git status (H7), relative hooksPath resolving against the worktree root exactly as the probe assumes (H6a/H6b), and linked-worktree config.worktree matching git's own read semantics (H8a/H8c). Note on the H8b cell visible in that capture: its NOTE — safe over-flag line reflects the first oracle (git config --local, which does not display config.worktree); the deeper execution oracle — git diff in the linked worktree with extensions.worktreeConfig=true actually ran the planted diff.external — proves git's runtime reads the file there and the probe's downgrade is correct, so H8b is a pass against execution semantics, not an over-flag.
Measured fix (validated, not eyeballed): applied in a scratch dist copy — skip the default-hooks probe when any parsed config contains a core.hooksPath entry. Result on the patched build (scratch-verify.mjs, evidence in logs): the empty-hooksPath case flips to read-only (F1), while the hostile fixtures stay identical — executable default hook still flagged (F2), husky-style hooksPath still read-only (F3), hooksPath with an executable read-only-triggered hook still flagged (F4), dirty diff.external still flagged (F5), clean repo still read-only (F6). 6/6. Suggested diff (collapses to the probe's hooks collection):
// only add the default hooks dir when no core.hooksPath entry was parsed
const hasHooksPath = entries.some(
(e) => e.section === 'core' && e.subsection === null && e.key === 'hookspath',
);
if (!hasHooksPath) hooksDirs.add(path.join(path.dirname(file), 'hooks'));#3 — no-cwd behavior changes for compound cd with an unresolvable target (low)
The PR states "behavior without cwd is unchanged", but with no cwd, (cd /nonexistent && git status) flips from read-only (base) to confirm (head) on both classifier paths. Cause: cd-tracking marks unknownDir for a target that cannot be stat'd, independent of whether a cwd was supplied. In production a cwd is always provided (ShellTool/Monitor pass params.directory || config.getTargetDir()), so real impact is limited to callers that omit cwd. Repro: node regress-battery.mjs (69 commands no-cwd; only this one diverges). Safe direction; either accept and reword the guarantee, or gate cd-tracking on checkOptions?.cwd being present.
Not covered
- Windows semantics. The probe uses
node:path/fs.realpathSyncand~expansion; the PR itself marks Windows⚠️ . Drive-letter / backslash handling incdtargets andhooksPathwas not exercised (Linux container). - Repo-wide gate. Only the 12 affected test files were run (1906 tests), not the full
packages/coresuite or other workspaces. The PR author reports 3 pre-existing env-specific failures elsewhere; I did not re-baseline those. - Live network execution.
credential.helper,core.sshCommand,core.askpass,remote.*.proxy, andext::remotes are flagged fail-closed by key presence, but their actual execution needs a remote/network and was not driven (author likewise notesext::helper execution was not reproducible on git 2.39.5). - Pager on a TTY.
core.pageronly fires when stdout is a TTY; the tool pipes output, so it would not trigger — it is flagged fail-closed regardless, which is safe but means a configured pager prompts even when it could not execute. - Per-commit attribution. Depth-2 merge checkout exposes only the merge commit + two parents (
is-shallow-repository=true), so I verified the aggregateHEAD^1..HEADdiff, not each of the PR's 13 commits individually. - Deep-repo / symlink-edge performance. Probe cost was measured at 0.03–0.08 ms/call on shallow fixtures with configs up to ~1 MiB (linear, capped); pathological nesting near the 64-depth bound and adversarial symlink loops were not stress-tested.
Methodology
One paragraph: verification ran inside the CI container at refs/pull/8645/merge (head 874ba258, base HEAD^1=4ec0371e61). npm ci + npm run build had completed at head before the clock started; I rebuilt only packages/core inside a scratch base worktree (git worktree add tmp/base-tree HEAD^1, wired to the already-installed root node_modules, and asserted via readlink -f plus a "base dist must not contain git-config-safety.js" check that every loaded classifier resolved to the correct tree, so the control was not contaminated by the head build; the worktree was removed after the A/B cells were captured — recreate with that command to re-run the A/B harnesses). Harnesses are mock-free .mjs files under tmp/pr8645-verify-20260808-013830/ that import the compiled dist/ classifiers and drive them against real on-disk git repos; ground truth for config/hook semantics was taken from real git 2.39.5 execution (marker files) rather than from the code's own comments. Per-cell stdout lives in logs/; image evidence in evidence/. Mutations (probe always-clean, probe always-dirty, and a ShellTool cwd-wiring cut) were applied to source, shown to fail 95 / 54 / 5 tests respectively (evidence/05-mutation-m1-kills-95-tests.png shows the M1 kill; logs/mutation-M2.txt and logs/mutation-M3.txt carry the other two summaries), then reverted (git status clean, no residue in dist) — so the new tests are load-bearing, not vacuous. The final gate (evidence/06-final-gate-1906-tests-green.png) re-ran all 12 affected test files at the restored head: 1906/1906 green.
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
|
Follow-up security audit fixes are pushed in
Verification on the pushed tree: 91 focused tests passed ( @qwen-code /triage |
Round-3 hardening of the config probe, closing bypasses found in local security review (all empirically reachable via attacker-written .git/config): - Section headers the minimal parser cannot interpret (e.g. `]` inside a quoted subsection) now fail closed instead of silently dropping the entries beneath them. - Inline `[section] key = value` lines are parsed instead of discarded. - Unparseable `.git` pointer files fail closed like unreadable ones. - include/includeIf entries are flagged rather than resolved: their targets can live outside `.git` (e.g. tracked working-tree files). - core.gitProxy added to the program-valued keys (git:// transport via whitelisted `git remote show`). - Document the cd-into-another-repo limitation in the module doc. - Add the missing PermissionManager cwd-threading contract test (dirty repo config → ask, clean → allow) and regression tests for each behavior above.
|
Complementing
Verification on the pushed tree: 1965 tests across the 11 affected suites pass; core typecheck, ESLint, and Prettier clean. Deliberately not done (assessed and rejected): per-call probe memoization (bounded cost, only on the auto-approve path) and a gitdir-pointer path-containment check (the probe reads exactly what git reads, so pointer redirection is not a bypass). @qwen-code /triage 中文说明作为
推送树上的验证:受影响的 11 个套件共 1965 个测试全部通过;core 包 typecheck、ESLint、Prettier 干净。刻意不做(评估后否决):按调用缓存探针结果(开销有上限且只在自动放行路径触发);gitdir 指针的路径收容检查(探针读取的正是 git 会读取的文件,指针重定向不构成绕过)。 |
…8575) Round-4 hardening from the local security/correctness review — each item was empirically demonstrated against the prior head: - Compound commands now track cd/pushd/popd: statically resolvable targets move the probe's base directory (same-repo `cd subdir` stays read-only), unresolvable targets (`cd`, `cd -`, `cd $VAR`, `popd`, quoted/expanded targets) downgrade later git segments. Closes the `cd <dirty-repo> && git status` bypass in both the AST and regex classifiers, including tree-sitter's nested-list chains. - filter.<name>.clean/smudge/process flagged: `git diff` runs worktree content through the configured clean filter with no extra flags. - url.<base>.insteadOf rewrite targets starting with ext:: flagged (combined with protocol.ext.allow in the same file this executes on whitelisted `git remote show`). - Config reads are size-capped at 1 MiB and fail closed above it (DoS guard for the synchronous permission path). - Boolean pager overrides (pager.<cmd> = true/false) no longer flagged. - Added the missing wiring contract tests: PermissionManager config.getCwd() fallback, memory-scoped agent shell policy, plan-mode shell policy (including the directory-param override).
|
Round-4 hardening pushed in
Verification on the pushed tree: 1983 tests across the 11 affected suites pass; core typecheck, ESLint, Prettier clean. Remaining accepted residuals (inherent, noted for the record): persisted @qwen-code /triage 中文说明Round-4 加固已推送至
推送树上的验证:受影响 11 个套件 1983 个测试全部通过;core typecheck、ESLint、Prettier 干净。 剩余已接受残留(固有属性,仅记录):配置干净时授予的持久 |
|
🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the 中文说明🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 |
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix review triage — PR #8645 (no changes this round)Both gaps named in the CHANGES_REQUESTED review were verified already closed at the current head ( 1. "The follow-up speculation path executes shell commands without the probe" — already fixed
2. "The main checkout's .git/config.worktree is never read when extensions.worktreeConfig is on" — already fixed
3. "CI is also red (one likely-unrelated CLI test) and needs a re-run" — no failing checks remain; local evidence below
Verification (run on head
|
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix review round — no action requiredThis round's feedback contains no actionable findings for this PR:
The report's single suggestion (§4) — surfacing the probe finding into the AUTO-mode classifier context, or treating probe-downgraded git calls as not auto-approvable in AUTO mode — is explicitly marked "follow-up, not a merge blocker" by the maintainer. Changing AUTO-mode (the shipped default) approval semantics is a scope/design decision that belongs in that follow-up rather than in this PR's diff, so it is recorded here instead of implemented:
No code changes were made this round; the branch remains at 中文说明Autofix 审查轮次 —— 无需处理本轮反馈中没有针对本 PR 的可处理项:
报告中唯一的建议(§4)—— 将探针结论带入 AUTO 模式分类器上下文,或让被探针降级的 git 调用在 AUTO 模式下不可自动放行 —— 已被维护者明确标注为"后续项,不阻塞合并"。修改 AUTO 模式(发布默认值)的放行语义属于范围/设计决策,应放在该后续项中,而不是本 PR 的 diff 里,因此在此记录而不实施:
本轮未做任何代码改动;分支保持在 Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
原问题已在 c04dd29 修复:packages/core/src/followup/speculation.test.ts 的 6 处 mock config(第 66/134/196/257/319/383 行,另 447 行)均已补上 getTargetDir,speculation.ts:302 的调用不再抛 TypeError,Test (ubuntu-latest, Node 22.x) 已转绿。同评论里提到的 core.hooksPath 问题也已修复:packages/core/src/utils/git-config-safety.ts:88-98 的 PROGRAM_VALUED_KEYS 不再无条件包含 core.hooksPath,改为在 602-652 行只探测被重定向到的 hooks 目录中实际存在的 read-only-triggered hook。解除本条 CHANGES_REQUESTED,不代表对本 PR 的整体批准。
|
@qwen-code /review |
| _Qwen Code review request accepted. Review is queued in [workflow run](https://github.com/QwenLM/qwen-code/actions/runs/31243209490)._ |
|
Scope reset: the current approach grew into a 656-line Git-config parser plus duplicate cwd simulation across both shell classifiers, and it is still incomplete ( I am replacing it with the issue-sized fix:
Additional config-driven execution paths should be handled as focused follow-ups with their own reproduction and command mapping, rather than being bundled into #8575. |
Scope reset implementedThe rewrite is now pushed in The current PR diff against
Removed from this PR: the 656-line Git config parser, the cwd simulator, speculative coverage for unrelated Git execution paths, and the associated broad test surface. Local verification at the pushed head: 1,342 focused tests, core typecheck, core build, and |
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: 🤖 Reviewed the latest feedback — no changes needed this round. Point-by-point reasoning: Autofix review triage — PR #8645 (no changes this round)Scope of this round. Only feedback newer than the last evaluation (2026-08-08T05:17:14Z) was considered: no new reviews, no inline findings, no failed or still-red checks (current CI: 9 SUCCESS, 13 SKIPPED). The single new item is an issue-level comment from @yiliang114 (2026-08-08T06:53:12Z) announcing a scope reset. Feedback point — scope reset → deferred to the maintainer, no bot change:
Disposition: defer to the maintainer, for three reasons:
If the intent was instead for the autofix loop to implement the scope reset, please say so explicitly (e.g. "@qwen-code implement the scope reset described above") and the next round will implement it on this branch. 中文说明🤖 已审阅最新反馈——本轮无需改动。逐点说明如下: Autofix 评审分诊 — PR #8645(本轮无改动)本轮范围。 仅考虑上次评估(2026-08-08T05:17:14Z)之后的新反馈:没有新的评审、没有行内评论、没有失败或持续失败的检查(当前 CI:9 项 SUCCESS,13 项 SKIPPED)。唯一的新条目是 @yiliang114 于 2026-08-08T06:53:12Z 发布的 issue 级评论,宣布范围重置(scope reset)。 反馈点 — 范围重置 → 移交维护者处理,机器人不做改动:
处置:移交维护者,原因有三:
如果本意是让 autofix 循环来实现这次范围重置,请明确说明(例如 "@qwen-code implement the scope reset described above"),下一轮将在本分支上实施。 Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix review triage — PR #8645: no changes needed this roundNo code changes were made. Point-by-point triage of the new feedback: Issue-level comment (@yiliang114, "Scope reset implemented")This is a status update, not a change request. It confirms the scope-reset rewrite was pushed in Reviews / inline commentsNone newer than the last evaluation (2026-08-08T06:53:12Z). All existing inline review comments predate it and were handled in earlier rounds; this round's feedback contains no open threads. Failed checks (all
|
|
Qwen Code review timed out. Qwen review timed out after 21600 seconds (of the 360-minute budget). This run already used the maximum 360 minute timeout. See workflow logs. |
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix review round: no action takenThis round's feedback contains no review findings — only a notification that Triage
Remedy: re-run the No other feedback arrived this round, so there is nothing else to address. 中文说明Autofix 评审轮次:未采取任何操作本轮反馈中没有任何评审发现——只有一条"自动 Qwen 评审超时"的通知,以及对应的红色 分类处理
补救方式: 重新运行本 PR 的 本轮没有其他反馈,因此没有其他需要处理的内容。 Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
doudouOUC
left a comment
There was a problem hiding this comment.
Approving — reviewed at 8000fafd7b. C=0 (42/42 Critical and 25/25 Suggestion threads resolved).
Worth stating up front: this is effectively a different PR from the one I approved on c2fc8b3fad, and that approval was correctly dismissed. The diff went from +3897/−111 across 23 files to +349/−24 across 13, and refactor(core): reset git config probe to issue scope is why. My prior verification is void, so this is a fresh pass.
The redesign is the right call, and it deletes a whole bug class
The old approach hand-rolled a 777-line git-config parser. Reviewing that meant verifying escape decoding, subsection \<char> semantics, key case-folding, commondir, gitfile redirects, per-worktree admin dirs, and a search-depth cap that could fail open — I probed 48 such rows last time, and the fact that it took 48 rows is itself the argument against that design.
The new git-config-safety.ts is 84 lines that ask git:
git -C <cwd> config --includes --show-scope --null --get-regexp '^diff\.external$|^core\.fsmonitor$'
Git now owns syntax, escapes, includes, precedence and worktree scope. Every one of those 48 rows becomes git's problem rather than this repo's. That is a strictly better place to put the boundary.
Verified against real git, not assumed
The load-bearing assumption is the wire format, so I checked it on real git 2.50.1 rather than trusting the parse:
local\0core.fsmonitor\nmy-monitor\0local\0diff.external\ntouch /tmp/PWNED\0
split('\0') with fields[i]=scope, fields[i+1]=key\nvalue, stepping i += 2 while i + 1 < length, parses that exactly and correctly ignores the trailing empty field. Exit status is 0 with matches and 1 with none, which matches the status === 1 → NO_RISK branch.
Then end-to-end through getLocalGitConfigRisk against real repos — 20/20 rows correct:
| Case | Result |
|---|---|
diff.external set / core.fsmonitor = program / both |
correctly flagged |
core.fsmonitor = true,false,TRUE,yes,no,on,off,1,0 |
correctly not flagged — a boolean selects git's built-in monitor, not a program |
clean repo, unrelated keys (diff.tool, core.editor), empty diff.external |
correctly not flagged |
| directory that is not a repo, nonexistent path | correctly not flagged |
diff.external via relative [include], absolute [include], and a nested include chain |
all correctly flagged, and git reports scope local for them, so the local/worktree filter genuinely covers includes as the design doc claims |
One correction to my own work: my first pass reported the include case as a miss. That was my fixture — a relative include.path resolves against the including file's directory (.git/), not the worktree, so git itself never saw my include. Placed correctly, all three include shapes are covered.
Fail-closed is right where it matters: PROBE_FAILED = { diffExternal: true, fsmonitor: true } covers a non-zero/non-1 exit, a non-string stdout, and a malformed record with no newline; timeout: 1000 and maxBuffer: 64 KiB land there too.
Integration sites
All seven are the same small shape — thread a directory through to a new *InDirectory variant, falling back to the cwd-less one when none is known: plan-mode-shell-policy (permissionContext.cwd), speculationToolGate (args['directory'] ?? cwd), speculation (config.getTargetDir?.()), memory-scoped-agent-config (ctx.cwd ?? projectRoot), permission-manager, monitor, shell. I read each; nothing surprising.
Note the consequence rather than a defect: where no cwd is available the fallback runs no probe, so a command can still classify read-only in a repo whose config would execute a program. That is unavoidable — you cannot probe a directory you do not know — and it is the pre-PR behaviour on those paths, so it is not a regression. Worth knowing it exists.
Tests
959 pass at this commit — shellAstParser 546, permission-manager 332, monitor 81. Test (ubuntu-latest, Node 22.x) is green on this head. (review-pr shows failure, but that is the review-bot workflow, not a code test.)
One Suggestion: git-config-safety.ts ships 84 lines of security-relevant core logic with no dedicated test file — the 875-line git-config-safety.test.ts went away with the old design and nothing replaced it. Coverage is indirect via shellAstParser.test.ts (+97) and permission-manager.test.ts (+14). The behaviours most worth pinning are exactly the ones I had to establish by hand: the boolean-fsmonitor exclusion, local/worktree-only scoping, and the fail-closed branches. Those are cheap unit tests against a temp repo and they would stop a future edit from silently widening the probe.
中文说明
批准 —— 审查提交 8000fafd7b,C=0(42/42 Critical 与 25/25 Suggestion 均已解决)。
需先说明:这实际上已是与我此前在 c2fc8b3fad 上批准的那个 PR 不同的 PR,且那次批准被正确地 dismiss 了。 差异从 +3897/−111、23 文件缩减到 +349/−24、13 文件,原因是 refactor(core): reset git config probe to issue scope。我先前的验证已失效,故本次为全新审查。
这次重构方向正确,并且消灭了一整类缺陷。 旧做法手写了 777 行 git-config 解析器;审查那份代码意味着要验证转义解码、子节 \<char> 语义、键名大小写折叠、commondir、gitfile 重定向、per-worktree admin 目录,以及一个可能 fail-open 的搜索深度上限——我上次为此跑了 48 个探针用例,而"需要 48 个用例"本身就是反对那种设计的论据。新的 git-config-safety.ts 只有 84 行,改为向 git 提问(git config --includes --show-scope --null --get-regexp …)。语法、转义、include、优先级与 worktree 作用域从此由 git 负责,那 48 行关注点变成了 git 的问题而非本仓库的问题——这是更合适的边界位置。
对真实 git 验证而非假定:承重假设是输出格式,我在真实 git 2.50.1 上核对了 local\0key\nvalue\0 形态,确认按 i += 2、i + 1 < length 的配对解析完全正确且会忽略尾部空字段;有匹配时退出码 0、无匹配时 1,与 status === 1 → NO_RISK 分支一致。
随后针对真实仓库端到端跑 getLocalGitConfigRisk,20/20 全部正确:两个可复现组合被正确标记;core.fsmonitor 取 9 种布尔值(含大写 TRUE)均正确不标记(布尔值选的是 git 内建监视器而非外部程序);干净仓库、无关键、空值均不标记;非仓库目录与不存在路径均不标记;diff.external 经相对 [include]、绝对 [include]、嵌套 include 链全部正确标记,且 git 对它们报告的 scope 是 local,因此 local/worktree 过滤确实覆盖 include,与设计文档所称一致。
对我自己的一处更正:首轮我把 include 用例报成漏判,那是我的 fixture 错了——相对 include.path 是相对包含它的配置文件所在目录(.git/)解析,而非工作区,故 git 本身就没看见我的 include。放对位置后三种 include 形态都被覆盖。
fail-closed 落在了该落的地方:非 0/1 退出码、stdout 非字符串、记录缺少换行都会走 PROBE_FAILED(两项均为 true);timeout: 1000 与 64 KiB maxBuffer 也归入该分支。
集成点七处形态一致:把目录透传给新的 *InDirectory 变体,未知时回退到无 cwd 版本。我逐一读过,没有意外。需指出一个后果而非缺陷:无 cwd 可用时回退路径不做探测,因此命令仍可能在一个配置会执行程序的仓库中被判为只读。这无法避免(无法探测未知目录),且是这些路径改动前的既有行为,故不是回归,但值得知晓。
测试:本提交下 959 通过(shellAstParser 546、permission-manager 332、monitor 81);Test (ubuntu-latest) 在本 head 为绿(review-pr 的失败是评审机器人工作流,非代码测试)。
一条 Suggestion:git-config-safety.ts 带着 84 行与安全相关的 core 逻辑合入,却没有专属测试文件——875 行的 git-config-safety.test.ts 随旧设计一起消失且无替代,覆盖只能间接依赖 shellAstParser.test.ts(+97) 与 permission-manager.test.ts(+14)。最值得钉住的行为恰是我手工建立的那几条:布尔 fsmonitor 排除、仅 local/worktree 作用域、以及各 fail-closed 分支。针对临时仓库写这些单测成本很低,却能阻止未来某次改动静默放宽该探测。
Verdict: ✅ merge-readyVerified head: 中文摘要(点击展开)
Central claim + A/BClaim: repo-local Git config that selects a program for a whitelisted read-only git command — Method: mock-free harness driving the compiled Cells (PM level, 18 scenarios)
7/7 vulnerable scenarios flip ro→ask on head; 11/11 untouched cells unchanged. Same result at the classifier-API level (head 18/18, base 18/18).
Vacuity: mutation matrix (all new guards load-bearing)
Every guard is pinned by a red test; no survivors; source restored clean after each. Witness: Extra probes
Targeted gates
FindingsNo blocking findings.
Not covered
MethodologyFresh Evidence (captures from this round)01 — A/B head: 18/18 (7 vulnerable scenarios downgrade to ask) 02 — A/B base control: same matrix, pre-PR classifier auto-approves all 7 03 — Mutation matrix: every new guard killed by the PR's own tests 04 — Head focused suites: 959/959 pass Local maintainer verification round · artifact dir |
|
@qwen-code /triage |
|
Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 98 passed · 1 failed · 99 total 中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:98 通过 · 1 失败 · 99 总计 Verification reportPR 8645 Deep Verification (fourth round) —
|
| # | finding (round 3) | severity | status at this head |
|---|---|---|---|
| 1 | REGEX fallback stricter than AST for quoted (cd 'sub') and backgrounded cd targets in a clean repo |
low | superseded — the new design resolves no cd targets at all: any cd/pushd before a gated git command confirms unconditionally on both paths (cells cd "/tmp/…" && git status → false on AST; fallback regex cd…git → false). The divergence shape cannot recur because there is nothing for the two paths to disagree about. |
| 2 | Empty core.hooksPath still downgrades despite git running no hooks |
low | superseded — core.hooksPath is no longer probed (reset to issue scope; the probe regex matches exactly diff.external and core.fsmonitor). A repo with hooksPath set and neither issue key classifies read-only again. The design doc records hooks and other exec-bearing settings as explicit follow-up work. |
| 3 | No-cwd behavior changed for compound cd with an unresolvable target | low | superseded — now unchanged from base, as the body claims. Re-measured: without cwd, git diff/git status/cd /tmp && git status all classify identically to base (3/3 cells in the no-cwd arm). Every shipped consumer passes a cwd (shell: params.directory || getTargetDir(); monitor: || process.cwd(); permission manager: ctx.cwd ?? getCwd()), so the no-cwd arm is a compatibility property, not a live path. |
Central claim + A/B table
Central claim: a git sub-command that would otherwise classify read-only is downgraded to confirm iff it is one of the two reproduced pairs — git diff with a repo-local diff.external, or git status with a repo-local program-valued core.fsmonitor — with probe/parse errors failing closed and any preceding cd/pushd confirming.
Threat oracle first: real git 2.39.5 in this container executed the planted programs — git diff ran diff.external (T1), git status ran the core.fsmonitor program (T2); boolean values (false, true) fired nothing (T3, S10), git status did not run diff.external (T4). 6/6 oracle-validity assertions, evidence 01-threat-oracle-real-git-executes-planted-programs.png.
| command | base @dirty | head AST @diff.external | head AST @fsmonitor | head @clean | head no-cwd |
|---|---|---|---|---|---|
git diff |
true (auto-runs) |
false |
true |
true |
true |
git status |
true |
true |
false |
true |
true |
git log -p / git show HEAD |
true |
true |
true |
— | — |
git diff --cached |
true |
false (over-asks, safe) |
true |
— | — |
git blame file.txt / git branch |
true |
true |
true |
— | — |
ls -la / git --version |
true |
true |
true |
true |
— |
cd <repo> && git status |
true |
false |
false |
false |
true (= base) |
02-ab-base-auto-approves-head-downgrades-matching-pairs.png.
Reviewer Test Plan, verbatim: steps 1–5 all behave exactly as the author wrote, 13/13 — including native-Git include/precedence agreement (include value flags; local empty override restores read-only; reversed order follows native last-wins; includeIf gitdir: matched), global-scope exclusion (sandboxed-HOME --global value leaves both commands read-only while the probe sees it as scope global), and cd <other-repo> && git status confirming. Evidence 03-reviewer-test-plan-13-of-13.png. Caveat: step 2's "git diff remains read-only" is what the code does, and is exactly the unsafe cell — F-1.
Flag-prefix claim (design doc): git -C …, git --no-pager …, git -c …, git --git-dir/--work-tree …, and GIT_EXTERNAL_DIFF=… git … are all already refused read-only at base (6/6, both arms checked) — no hole around the gate. Repo-local alias shadowing is impossible (built-ins win: planted alias.status = !evil did not execute). Evidence 04-boundary-failclosed-and-crosspair-e2e.png, 07-alias-shadow-impossible-blame-executes-fsmonitor.png.
Findings
F-1 (high — demonstrated bypass within the PR's own two keys): the fsmonitor pairing table is wrong against Git semantics; four measured execution paths stay auto-approved. The PR downgrades core.fsmonitor only for git status, on the premise that the two keys map one-to-one onto the two commands ("downgrades only the matching command/config pair"). Real git does not honor that exclusivity: every command that refreshes the index consults fsmonitor. Measured victim sweep on git 2.39.5 (fresh repo, planted program-valued core.fsmonitor, marker file as oracle):
| command | program executed | head classifies | result |
|---|---|---|---|
git status / git status --porcelain |
YES | read-only false | covered |
git diff |
YES | read-only true | auto-approved execution path |
git diff --cached |
YES | read-only true | auto-approved execution path |
git blame file.txt |
YES | read-only true | auto-approved execution path |
git ls-files |
YES | read-only true | auto-approved execution path |
git show HEAD / git log |
no | read-only true | correctly untouched |
End-to-end: the classifier returned read-only for git diff in an fsmonitor repo, and the very same command then executed the planted program — also with a clean worktree and nothing to diff (04-boundary.mjs cells C1/C2, evidence 04-boundary-failclosed-and-crosspair-e2e.png; sweep evidence 05-fsmonitor-victim-sweep-auto-approved-execution-paths.png). The counterpart diff.external premise does hold: only git diff executes it (log -p, show, --cached, blame, branch all measured negative, cells S1–S3/S7/S8). Reviewer Test Plan step 2 pins the unsafe direction as the expected behavior, so the fixture itself needs updating with the fix.
Suggested fix (measured in a scratch copy — applied to localGitConfigMakesCommandUnsafe, rebuilt, re-driven): keep diff.external → git diff, but pair core.fsmonitor with every measured index-refreshing sub-command:
usesOtherIndexRefresh ||= subcommand === 'blame' || subcommand === 'ls-files';
…
return (
(usesDiff && risk.diffExternal) ||
((usesStatus || usesDiff || usesOtherIndexRefresh) && risk.fsmonitor)
);Results: all six executing paths downgrade, show/log stay read-only (evidence 09-candidate-fix-closes-all-six-execution-paths.png); collateral matrix 10/10 unchanged (diff.external pair, cross pair, clean-repo diff/status/blame/ls-files, show/log under fsmonitor, non-git); and the affected suite goes red on exactly one test — downgrades only the two reproduced command/config pairs, whose fsmonitor half asserts the bypass (logs/18-suite-under-fix.txt). That test is the fixture to flip. Scratch copy restored afterwards (git status clean, dist rebuilt to PR behavior, sanity cell re-checked).
O-1 (nit): a missing include file fails the probe OPEN. git config --includes exits 1 both for "no key matched" and for "include.path target does not exist"; the probe maps all status-1 to NO_RISK. Measured: corrupt config (status 128), include loop (status 128), 64 KB maxBuffer overflow (ENOBUFS), and missing git binary all fail correctly closed (cells B1/B3/B5/B7), but the missing-include cell B2 classifies read-only where the PR text says "probe and parse errors fail closed". No exploitable gap: a nonexistent include file cannot carry an executable value, and real git diff ignores the missing include too (measured: command still runs). The two exit-1 shapes are indistinguishable from the probe output (both have empty stderr), so this is a wording/expectation issue, not a patch target — unless the author prefers a --list-based probe that surfaces include errors differently.
O-2 (nit, by design): the regex fallback is stricter than the AST path. When tree-sitter is unavailable, any risk key downgrades every git command (e.g. git diff in an fsmonitor-only repo asks). Safe direction; measured divergence 2 cells, both as designed.
O-3 (nit, safe direction): git diff --cached over-asks under diff.external. Oracle S3 measured no execution for that shape; the head downgrades it anyway. One extra prompt, no behavior risk.
Test vacuity & mutation matrix
Unmutated control green (1698/1698, logs/05-gate.txt, evidence 08-final-gate-9-files-1698-green.png), so the kills below mean the suite pins the change. Positive controls: M1/M3/M5 each went red on exactly the test(s) added for the mutated behavior, with behavioral assertion messages (expected true to be false, expected 'allow' to be 'ask').
| mutant | description | result | killed by / adjudication |
|---|---|---|---|
| M1 | getLocalGitConfigRisk → always NO_RISK (probe disabled) |
killed — 3 red | pair test, include/precedence test, fallback-gate test (evidence 06-mutation-matrix-3-killed-2-coverage-gaps.png) |
| M2 | PROBE_FAILED → NO_RISK (fail OPEN on probe/parse error) | SURVIVED | coverage gap — no probe-error test exists; the behavior itself is proven correct by harness cells B1/B3/B5/B7 (all fail closed at head). The fixture that would pin it: corrupt .git/config → git diff classified non-read-only |
| M3 | cd/pushd no longer tracked | killed — 2 red | fails closed instead of simulating a changed directory + PermissionManager keeps Git after a directory change in the confirmation boundary |
| M4 | scope filter removed (global/system values flagged) | SURVIVED | coverage gap — no global-scope test; pinned instead by review-plan step 4e (global-only value stays read-only, scope read as global). Safe direction |
| M5 | PermissionManager compound reassembly reverted | killed — 1 red | the new pm test, expected 'allow' to be 'ask' |
Survivors are completeness reporting, not merge conditions; neither is dead code — both clauses decide outcomes, proven by the cited harness cells. Note the suite's one unsafe fixture (see F-1): under the measured candidate fix it is the only test that goes red.
Not covered
- Windows semantics — probe spawn (
windowsHideis set but untested here), path handling, and the PR's own⚠️ Windows/Linux marks. - Repo-wide gate — the 9 affected files (1698 tests) ran; the full
packages/coresuite and other workspaces did not. - Keys dropped by the reset —
core.pager, hooks (core.hooksPath, executable hooks in.git/hooks),credential.helper,core.sshCommand,core.askpass,filter.*,url.*.insteadOf,protocol.*,diff.<driver>.command, submodule storage configs: all probed in earlier rounds, all removed by the scope reset. The design doc defers them to follow-up "after an independent reproduction identifies the affected read-only subcommand". This report supplies one such reproduction for a key the PR does handle (F-1); the dropped keys were not re-reproduced this round. - Monitor end-to-end under the real probe —
monitor.test.tsmocks the classifier at the module boundary (the mock was renamed toisShellCommandReadOnlyASTInDirectoryin this PR); wiring is pinned mock-style only. - Per-commit attribution — shallow checkout; the 17 snapshot commits are not individually reachable (
rev-listat the graft returns 1). The aggregate diff was exercised. - Adversarial probe DoS beyond the caps — 1 s timeout and 64 KB maxBuffer fail closed (maxBuffer path proven in B5); a slow-but-legal
git configunder 1 s was not stress-driven. - Persistent-shell
popd— not a bypass here: every command spawns a fresh shell process (shellExecutionService.tspty spawn per command), so the directory stack is empty per invocation andpopdcan only follow apushdin the same command, which already confirms. Verified by reading the spawn site, not by driving a persistent session.
Methodology
Verification ran inside the CI container at refs/pull/8645/merge (merge bada7a7ed5, head 8000fafd, base 59b750fc); the head dist/ was already built at checkout and node_modules was healthy (no npm ci needed this round; git 2.39.5, node v22.23.2). Base control: git worktree add tmp/base-tree HEAD^1, rebuilt only packages/core (scripts/build_package.js with root .bin on PATH; one environmental repair — the worktree lacks the untracked per-package node_modules, so the third-party-only packages/core/node_modules was symlinked in after asserting it contains no @qwen-code/* or in-tree links; packages/core has no workspace dependencies). Control purity asserted: base dist has no git-config-safety.js, zero git-config-safety references in base shellAstParser.js, base exports no *InDirectory API, and every harness logged the realpath of the module it imported. Harnesses are mock-free .mjs files in this directory driving the compiled classifiers against real on-disk repos; ground truth came from real git execution (marker-file oracles), and probe-parse format was confirmed byte-for-byte (scope\0key\nvalue\0). Mutations were applied to source, run against the affected test files, and restored (git status clean; dist rebuilt back to PR behavior and sanity-checked). Per-cell stdout in logs/, evidence images in evidence/; assertion totals come from the oracle (6), A/B (45), review-plan (13), boundary (20, incl. the one fail B2), mutation predictions (5), and post-fix collateral (10) runs.
Evidence images
1 additional image(s) did not pass the hosting checks (PNG magic, unique sanitized name, ≤2 MB, max 8) and remain in the run artifacts.
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix review round — no changes neededNo actionable feedback reached this round, so no code changes were made. The PR head remains Feedback triage
Still-red check:
|
Semantic resolution: main's QwenLM#8645 added the directory-scoped classifyShellCommandSafetyInDirectory consumed by plan-mode-shell-policy and speculationToolGate. Apply this PR's hasShellSubstitution gate to it via a shared internal helper, mirroring isShellCommandReadOnlyInternal, so the QwenLM#8582 downgrade covers both variants.
|
Released in v0.21.9. |

















What this PR does
This PR closes the two repository-local Git configuration execution paths reproduced in #8575:
diff.externalwhen runninggit diff, andcore.fsmonitorwhen runninggit status. Before either command is auto-approved as read-only, the classifier asks native Git for the effective included configuration at the execution directory and downgrades only the matching command/config pair. Probe and parse errors fail closed. A precedingcdorpushdalso requires confirmation instead of introducing a shell cwd simulator.Why it's needed
Command-text analysis cannot see programs selected from repository-local Git configuration. A writable or shared workspace can therefore turn an apparently read-only Git command into program execution. Native
git configis the smallest reliable source of config syntax, include, precedence, scope, and worktree semantics.Reviewer Test Plan
How to verify
diff.externaland confirmgit diffrequires confirmation whilegit statusremains read-only.core.fsmonitorto a program, and confirmgit statusrequires confirmation whilegit diffremains read-only.core.fsmonitor=falseand confirmgit statusremains read-only.cd <other-repo> && git statusrequires confirmation rather than probing the wrong repository.git diff --check.Evidence (Before & After)
N/A — permission-classifier behavior covered by temporary-repository tests.
Tested on
Environment (optional)
Node 22, Git 2.39.5, and the core unit-test environment.
Risk & Scope
git -C, or changing directory before Git, already require confirmation.Linked Issues
Fixes #8575
中文说明
本 PR 做了什么
本 PR 修复 #8575 已复现的两个仓库本地 Git 配置执行路径:运行
git diff时的diff.external,以及运行git status时的core.fsmonitor。在这两个命令被自动判为只读之前,分类器会让原生 Git 读取执行目录下包含 include 后的有效配置,并且只降级匹配的命令/配置组合。探针或解析失败时采用失败关闭。前面出现cd或pushd时也会要求确认,而不是引入 shell cwd 模拟器。为什么需要
命令文本分析看不到仓库本地 Git 配置选择的程序。可写或共享工作区因此可能把表面只读的 Git 命令变成程序执行。原生
git config是覆盖配置语法、include、优先级、scope 与 worktree 语义的最小可靠来源。评审测试计划
如何验证
diff.external,确认git diff要求确认,而git status仍为只读。core.fsmonitor设置程序,确认git status要求确认,而git diff仍为只读。core.fsmonitor=false,确认git status仍为只读。cd <其他仓库> && git status会要求确认,而不是探测错误仓库。git diff --check。前后证据
N/A——权限分类器行为由临时仓库测试覆盖。
测试环境
环境(可选)
Node 22、Git 2.39.5 与 core 单元测试环境。
风险与范围
git -C或先切换目录再运行 Git 的命令已经会要求确认。关联 Issue
Fixes #8575