Skip to content
Merged
54 changes: 54 additions & 0 deletions .github/scripts/resanitize-git-config.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -uo pipefail

# Re-sanitizes the git config surfaces a PAT-bearing git step is about to
# read, AFTER branch/agent code has run on the host. The inlined job-start
# sanitize steps are pre-checkout hygiene; between them and the push, the
# verification gates run branch test code on the host and the sandboxed
# agent has the workspace mounted — either can plant exec keys in the
# repo's LOCAL .git/config (the highest-precedence file, which the push
# reads) or rewrite the runner user's REAL global config: the gates' env
# redirect is inherited-env enforcement, not a filesystem boundary — a
# direct file write, `env -u GIT_CONFIG_GLOBAL git config --global`, or
# `git config --file "$HOME/.gitconfig"` all bypass it (probe-verified in
# the #8961 review).
#
# Invoked as `bash "${RUNNER_TEMP}/resanitize-git-config.sh"` from the
# copy the staging step took off the TRUSTED base checkout — never from
# the working tree, which holds the branch under test at call time.
#
# The allowlist and denylist are copies of the inlined pre-checkout
# sanitize steps in qwen-autofix.yml (which cannot call this script: it
# does not exist on disk before their checkout). The workflow contract
# tests pin every copy byte-identical — edit them together.

if [ -e .git ]; then
# Repo-scope redirect files first. `.git/commondir` (the file twin of
# GIT_COMMON_DIR) repoints local config, refs AND objects — a plant makes
# the very --local sweep below act on the ATTACKER's config, and lets the
# PAT push deliver attacker content; `.git/shallow` (twin of
# GIT_SHALLOW_FILE) narrows the object graph. A normal actions/checkout is
# not a linked worktree, so neither file legitimately exists here —
# removing them cannot break a real checkout, only defuse a plant. Then
# config.worktree (can carry core.hooksPath, invisible to `git config
# --local`), then the local allowlist sweep.
GIT_DIR_PATH="$(git rev-parse --git-dir 2>/dev/null || echo .git)"
rm -f "${GIT_DIR_PATH}/commondir" "${GIT_DIR_PATH}/shallow" 2>/dev/null || true
rm -f "$(git rev-parse --git-path config.worktree 2>/dev/null || echo /nonexistent)" 2>/dev/null || true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] resanitize never touches .git/shallow — the FILE twin of GIT_SHALLOW_FILE. The env-unset remedy for that channel (the separate unset-list finding) cannot close a DIRECTLY PLANTED .git/shallow file, which needs no env var at all. Both PAT-path checkouts use fetch-depth: 0 (workflow ~870 and ~3733), so a legitimate clone carries no shallow file — anything present is planted, and removing it cannot damage legitimate state.

Failure scenario (probe-verified, direct file plant, no env var): echo garbage > .git/shallow makes git status --porcelain, commit, log and merge all exit 128 fatal: bad shallow line: garbage; rm -f .git/shallow flips them back to rc=0. Same parse path as the env-channel probe — so after a green gate the PAT step's push/salvage hard-fails and the verified round is discarded under a misleading diagnosis; the channel is unaffected by unsetting GIT_SHALLOW_FILE.

Suggested fix: in the same if [ -e .git ] block: rm -f "$(git rev-parse --git-path shallow 2>/dev/null || echo /nonexistent)" 2>/dev/null || true (safe for these fetch-depth: 0 checkouts), or fail closed when .git/shallow exists in a fetch-depth: 0 workspace.

中文说明

resanitize 从不触及 .git/shallow——GIT_SHALLOW_FILE 的文件孪生。针对该通道的 env unset 修复(另一条 unset 列表发现)无法封闭直接植入.git/shallow 文件——它完全不需要环境变量。两个 PAT 路径的 checkout 都用 fetch-depth: 0(workflow ~870 与 ~3733),合法克隆不携带 shallow 文件——存在的必是植入,删除不会损害合法状态。

失败场景(已探针验证,直接文件植入、无 env 变量): echo garbage > .git/shallowgit status --porcelaincommitlogmerge 全部 exit 128 fatal: bad shallow line: garbagerm -f .git/shallow 翻回 rc=0。与 env 通道探针同一解析路径——于是绿色门之后 PAT 步骤的 push/salvage 硬失败,已验证轮次在误导性诊断下被丢弃;该通道不受 unset GIT_SHALLOW_FILE 影响。

修复建议: 在同一 if [ -e .git ] 块中:rm -f "$(git rev-parse --git-path shallow 2>/dev/null || echo /nonexistent)" 2>/dev/null || true(对这些 fetch-depth: 0 checkout 安全),或在 fetch-depth: 0 工作区中出现 .git/shallow 时失败关闭。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1cfa5e0 — resanitize now removes .git/shallow alongside .git/commondir (both file twins of the env channels, both defused).

git config --local --unset-all extensions.worktreeConfig 2>/dev/null || true
Comment on lines +37 to +38

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] resanitize removes config.worktree but never .git/commondir — the FILE twin of GIT_COMMON_DIR — which redirects the ENTIRE repository scope the PAT-bearing push reads: local config, refs, and objects. Neither the env unset lists, the job-start scrub, nor resanitize closes it, and once the redirect is planted, resanitize's --local allowlist sweep is pointed at the ATTACKER's config file.

Failure scenario (probe-verified end-to-end with the PAT step's exact env shape — GIT_CONFIG_COUNT=0, GIT_CONFIG_SYSTEM=/dev/null, throwaway global): gate-time branch code writes .git/commondir (the workspace-write capability this PR's own threat model rests on). Leg (a), nonexistent path: every git invocation dies rc=128 fatal: not a git repository → round discarded/misattributed. Leg (b), attacker-crafted git dir inside the workspace: git rev-parse autofix/issue-1 returned the ATTACKER's commit; assert_verification_tree passed its shape (git status --porcelain rc=128 but EMPTY stdout, so the [[ -n … ]] check passes silently); and git push --no-verify <url> autofix/issue-1 delivered ATTACKER CONTENT end-to-end — the pushed tree carried the payload file with the real fix absent, under the bot PAT. Nothing downstream catches it: the issue-fix flow has no post-test HEAD re-check and 'Publish PR' pushes with no HEAD comparison; VERIFIED_HEAD gates only post-push review-thread resolution. Ceiling: verification bypass/misattribution + DoS — no exfiltration (explicit URL, host-scoped helper with leading reset, sslVerify pin).

Suggested fix: in the if [ -e .git ] block, next to the config.worktree removal: rm -f "$(git rev-parse --git-dir 2>/dev/null || echo .git)/commondir" 2>/dev/null || true (the || echo .git fallback is required — under a broken commondir git rev-parse --git-dir itself dies rc=128). Add GIT_COMMON_DIR to the four unset lists with the env-twin gap.

中文说明

resanitize 删除 config.worktree 却从不删 .git/commondir——GIT_COMMON_DIR 的文件孪生——它会把 PAT push 读取的整个仓库作用域(本地配置、refs、对象)重定向。env unset 列表、job 起始清洗、resanitize 都不封闭它;且一旦植入该重定向,resanitize 的 --local allowlist 清扫指向的是攻击者的配置文件。

失败场景(已用 PAT 步骤的精确 env 形态端到端探针验证——GIT_CONFIG_COUNT=0GIT_CONFIG_SYSTEM=/dev/null、一次性 global): 门里的分支代码写入 .git/commondir(本 PR 自己的威胁模型所依赖的工作区写能力)。腿 (a):指向不存在的路径——每条 git 调用 rc=128 fatal: not a git repository → 轮次被丢弃/误判。腿 (b):指向工作区内攻击者构造的 git 目录——git rev-parse autofix/issue-1 返回攻击者的提交;assert_verification_tree 的形态通过(git status --porcelain rc=128 但 stdout 为空,[[ -n … ]] 静默通过);且 git push --no-verify <url> autofix/issue-1 端到端送达攻击者内容——推送树携带 payload 文件而真实修复缺失,使用的是 bot PAT。下游没有任何东西能拦截:issue-fix 流程没有测试后的 HEAD 复核,'Publish PR' 推送时没有任何 HEAD 比对;VERIFIED_HEAD 只门控 push 之后的评审线程解析。上限:验证绕过/误判 + DoS——无外泄(显式 URL、带前置重置的 host 作用域 helper、sslVerify pin)。

修复建议:if [ -e .git ] 块中、config.worktree 删除旁:rm -f "$(git rev-parse --git-dir 2>/dev/null || echo .git)/commondir" 2>/dev/null || true|| echo .git 回退是必需的——commondir 损坏时 git rev-parse --git-dir 本身 rc=128)。把 GIT_COMMON_DIR 与 env 孪生缺口一起加入四份 unset 列表。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1cfa5e0 — resanitize now rm -fs .git/commondir and .git/shallow (the file twins of GIT_COMMON_DIR/GIT_SHALLOW_FILE; a normal actions/checkout is not a linked worktree so neither legitimately exists), and both env-strip lists add GIT_COMMON_DIR + object-dir twins. Defense in depth: Push-and-report now refuses to push when git rev-parse HEAD != the gate's recorded verified_head, so a residual repo redirect can't deliver attacker content. Contract test pins the commondir/shallow removal and the HEAD guard.

git config --local --name-only --list 2>/dev/null \
| { grep -ivE '^(core\.(repositoryformatversion|bare|filemode|symlinks|ignorecase|precomposeunicode|logallrefupdates|worktree|hidedotfiles|protecthfs|protectntfs)|remote\..+\.(url|fetch|pushurl)|branch\.|extensions\.|gc\.|pack\.|fetch\.|index\.|safe\.|submodule\..+\.(url|active|branch))' || true; } \
| while IFS= read -r key; do git config --local --unset-all "$key" 2>/dev/null || true; done
fi
# The GLOBAL scope spans TWO files — ~/.gitconfig and
# ${XDG_CONFIG_HOME:-~/.config}/git/config — but with both present,
# `git config --global` lists and unsets ONLY ~/.gitconfig (probed on
# git 2.43 and 2.55: the listing omits the XDG keys and --unset-all
# exits 5 with them live), so sweep each file explicitly by pointing
# GIT_CONFIG_GLOBAL at it — the env var replaces the whole global
# scope with exactly that file, for reads and writes alike.
for global_file in "${HOME}/.gitconfig" "${XDG_CONFIG_HOME:-${HOME}/.config}/git/config"; do
{ GIT_CONFIG_GLOBAL="${global_file}" git config --global --name-only --list 2>/dev/null || true; } \
| { grep -iE '^(core\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\.external$|diff\..+\.(command|textconv)$|merge\..+\.driver$|filter\.|alias\.|pager\.|difftool\.|mergetool\.|interactive\.difffilter$|sequence\.editor$|gpg\.(.+\.)?program$|init\.templatedir$|remote\..+\.(uploadpack|receivepack)$|submodule\..+\.update$|url\..+\.(insteadof|pushinsteadof)$|http\.(.+\.)?(sslverify|sslcainfo)$|include\.|includeif\.|protocol\.(ext\.)?allow$)' || true; } \
| while IFS= read -r key; do GIT_CONFIG_GLOBAL="${global_file}" git config --global --unset-all "$key" 2>/dev/null || true; done
done
41 changes: 41 additions & 0 deletions .github/scripts/run-autofix-review-verification.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,47 @@ set -eo pipefail
# environment from the caller. WORKDIR and BRANCH are job-level env;
# GITHUB_OUTPUT and RUNNER_TEMP are runner-provided. None is defined here.

# Deterministic verification must not read the RUNNER's git config: the
# persistent pool accumulates state, and a leaked global exec knob fails
# branch tests the branch never caused. Measured counterexample, run
# 31516789251: a stray `diff.external=global-driver` in the runner user's
# ~/.gitconfig killed four per-hunk probe tests in packages/cli on #8613 —
# charged to the round (package tests are A/B-exempt), which burned the
# 18-minute repair on a failure no repair can reach and ended the round as
# a timeout. Every git this script or its checks spawn (vitest fixture
# repos included) reads a per-run throwaway global config instead — seeded
# with the workspace safe.directory actions/checkout put in the real one —
# and no system config — any system-level git setting the checks ever
# come to depend on (a CA bundle, a proxy) must be replicated via per-job
# env, not /etc/gitconfig, because the redirect silently drops it. The
# redirect also keeps a branch-authored `git config --global` from writing
# durable state onto the host: it lands in the throwaway file and dies
# with the run. Enforcement is inherited-env only — branch code writing
# the real file directly bypasses it, which is why the PAT-bearing steps
# re-run resanitize-git-config.sh afterwards.
# Environment-carried config outranks BOTH file redirects and defeats
# every file-level guard: GIT_CONFIG_COUNT/_PARAMETERS carry config at
# command-line precedence, GIT_SSL_* / GIT_PROXY_COMMAND steer transport,
# GIT_EXEC_PATH swaps the transport-helper binary, GIT_DIR/GIT_WORK_TREE
# repoint git, GIT_ASKPASS/GIT_SSH* hijack auth/exec — branch code in an
# earlier step can inject any of them through $GITHUB_ENV. Strip them, then
# redirect the file scopes. Keep this env+redirect block equal to the
# issue-fix gate's copy (the contract test pins them).
unset GIT_CONFIG_PARAMETERS GIT_ALLOW_PROTOCOL GIT_PROXY_COMMAND \
GIT_SSL_NO_VERIFY GIT_SSL_CAINFO GIT_EXEC_PATH GIT_DIR \
Comment on lines +34 to +35

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R4-4: all four byte-identical env-strip blocks (this one + twins at workflow ~1469, ~1661, ~5096) still omit git's command-executing environment variables GIT_EXTERNAL_DIFF, GIT_EDITOR, GIT_SEQUENCE_EDITOR, GIT_PAGER — carries forward round 4's R4-4/R4-6/R4-7/R4-13 (the round-4 fix added the repo-redirect twins but not these). — Failure scenario: a branch plants GIT_EXTERNAL_DIFF=<path> via $GITHUB_ENV; it survives every strip block and is honored by any full-content git diff — probe-verified: the planted program executed during the post-gate 'Show run artifacts' git diff in both jobs. No PAT-reachable vehicle exists today (PAT steps run only config/rev-parse/push/fetch/merge after their preambles), but nothing pins that invariant — a future full git diff or non -m commit in a PAT step turns this into command execution with the PAT in env.

Suggested change
unset GIT_CONFIG_PARAMETERS GIT_ALLOW_PROTOCOL GIT_PROXY_COMMAND \
GIT_SSL_NO_VERIFY GIT_SSL_CAINFO GIT_EXEC_PATH GIT_DIR \
unset GIT_CONFIG_PARAMETERS GIT_ALLOW_PROTOCOL GIT_PROXY_COMMAND \
GIT_SSL_NO_VERIFY GIT_SSL_CAINFO GIT_EXEC_PATH GIT_DIR \
GIT_EXTERNAL_DIFF GIT_EDITOR GIT_SEQUENCE_EDITOR GIT_PAGER \
中文说明

四份逐字节一致的 env 清洗块(本块 + workflow ~1469、~1661、~5096 三处孪生)仍遗漏 git 的命令执行类环境变量 GIT_EXTERNAL_DIFFGIT_EDITORGIT_SEQUENCE_EDITORGIT_PAGER——延续第 4 轮 R4-4/R4-6/R4-7/R4-13(第 4 轮补了仓库重定向孪生变量,没补这些)。失败场景:分支经 $GITHUB_ENV 植入 GIT_EXTERNAL_DIFF=<路径>,它能活过所有清洗块,任何全量 git diff 都会执行它——已探针验证:门后的 'Show run artifacts' git diff(两个 job)实际执行了植入程序。今天 PAT 步骤没有可触及的载体(前置块之后只跑 config/rev-parse/push/fetch/merge),但没有任何东西 pin 住这一不变量——将来 PAT 步骤里出现全量 git diff 或非 -m commit 时,这就是带着 PAT 的命令执行。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — a test-strengthening refinement. The round-4/5 pins already cover the core mechanism (full unset var set, mktemp path, exact-SHA push, digest verify line verbatim, three identical PAT preambles); this incremental pin-tightening is noted for the test-hardening follow-up rather than blocking the landing, per the maintainer's decision to close out this PR on the reliability fix + surface reduction.

GIT_WORK_TREE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY \
GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_SHALLOW_FILE \
GIT_ASKPASS GIT_SSH GIT_SSH_COMMAND
Comment on lines +36 to +38

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R5-13: None of the five strip lists (this script, the issue-gate inline twin, the three PAT preambles) unsets GIT_INDEX_FILE (grep: zero matches in .github/), so a $GITHUB_ENV-planted index survives into the PAT-bearing step — the same vehicle the preamble's own comment acknowledges. — Failure scenario: probe on the pool's git 2.43, flip-verified: a planted GIT_INDEX_FILE makes the push-race salvage merge (git merge --no-edit FETCH_HEAD) fail exit 2 ('Your local changes to the following files would be overwritten by merge') — the workflow maps any merge failure to ::error::the commits pushed during the run conflict with this fix + exit 1, discarding a fully verified round and misdiagnosing (nothing conflicted). A --cacheinfo-planted foreign blob is refused and a fast-forward rebuilds the index from the tree — ceiling is availability + misdiagnosis, not content injection or PAT exposure; the plant persists across salvage retries.

Suggested change
GIT_WORK_TREE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY \
GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_SHALLOW_FILE \
GIT_ASKPASS GIT_SSH GIT_SSH_COMMAND
GIT_WORK_TREE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY \
GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_SHALLOW_FILE \
GIT_INDEX_FILE \
GIT_ASKPASS GIT_SSH GIT_SSH_COMMAND

(add to all five copies and to the contract test's per-variable unset pin.)

中文说明

五份清除列表(本脚本、issue 门内联孪生、三个 PAT 前置块)都没有 unset GIT_INDEX_FILE(grep:.github/ 中 0 命中),因此经 $GITHUB_ENV 植入的索引会活进 PAT 步骤——正是前置块注释自己承认的载具。失败场景:在池内 git 2.43 上探针、翻转验证:植入的 GIT_INDEX_FILE 使 push 竞态 salvage merge(git merge --no-edit FETCH_HEAD)以 exit 2 失败('Your local changes to the following files would be overwritten by merge')——workflow 把任何 merge 失败映射为 ::error::the commits pushed during the run conflict with this fix + exit 1,丢弃已完整验证的一轮并误诊(实际没有冲突)。--cacheinfo 植入的外部 blob 会被拒绝,快进会从树重建索引——上限是可用性问题 + 误诊,不是内容注入或 PAT 暴露;植入在 salvage 重试间持续存在。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — a test-strengthening refinement. The round-4/5 pins already cover the core mechanism (full unset var set, mktemp path, exact-SHA push, digest verify line verbatim, three identical PAT preambles); this incremental pin-tightening is noted for the test-hardening follow-up rather than blocking the landing, per the maintainer's decision to close out this PR on the reliability fix + surface reduction.

export GIT_CONFIG_COUNT=0
export GIT_TERMINAL_PROMPT=0
export GIT_CONFIG_SYSTEM=/dev/null

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] GIT_CONFIG_SYSTEM=/dev/null redirect silently drops system-level git config. If a runner has a corporate proxy configured in /etc/gitconfig (http.proxy, http.sslCAInfo), git operations fail with opaque transport errors. The PR's own comment documents this trade-off. Consider adding a guarded ::warning:: diagnostic when system config exists.

Failure scenario: a self-hosted runner with corporate proxy in /etc/gitconfig — the gate silently drops the proxy config, all git commands fail with SSL errors, and the oncall engineer searches for network issues rather than the one-line export.

中文说明

[Suggestion] GIT_CONFIG_SYSTEM=/dev/null 重定向静默丢弃系统级 git 配置。如果 runner 有在 /etc/gitconfig 中配置的企业代理(http.proxyhttp.sslCAInfo),git 操作会因模糊的传输错误失败。PR 自己的注释记录了此权衡。建议在系统配置存在时添加受保护的 ::warning:: 诊断。

失败场景:带企业代理的自托管 runner 在 /etc/gitconfig 中配置了代理——门静默丢弃代理配置,所有 git 命令因 SSL 错误失败,oncall 工程师搜索网络问题而非检查这一行 export。

— deepseek-v4-flash via Qwen Code /review (v0.21.10)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e0031b8 — both gates emit a guarded ::notice when /etc/gitconfig exists, naming the redirect and pointing at per-job env as the replication path, so an oncall sees the bypass in the step log instead of chasing transport errors.

export GIT_CONFIG_GLOBAL="${RUNNER_TEMP}/autofix-gate-gitconfig"
Comment on lines +41 to +42

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] GIT_CONFIG_SYSTEM=/dev/null redirect undocumented at the system level

The throwaway redirect sets GIT_CONFIG_SYSTEM=/dev/null, which silently drops all system-level git config. A future maintainer adds a system-level git config entry that the gates depend on (e.g., a CA bundle for registry access, a proxy, or a credential helper). The redirect silently bypasses it, and the gate fails with an opaque error (TLS error, auth failure). The comment block documents the global config rationale (pool runner pollution) but the system config bypass is not separately explained.

Suggested change
export GIT_CONFIG_SYSTEM=/dev/null
export GIT_CONFIG_GLOBAL="${RUNNER_TEMP}/autofix-gate-gitconfig"
Add a one-line note in the comment block: 'System config is also bypassed — any system-level git config the gates depend on must be replicated via the per-job environment.'

— deepseek-v4-flash via Qwen Code /review (v0.21.8)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1213ee8 — both gates' comment blocks now state that system config is bypassed too and that any system-level git setting the checks come to depend on (CA bundle, proxy) must be replicated via per-job env, not /etc/gitconfig.

Comment on lines +41 to +42

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Every guard this diff adds operates on git config files, but gate branch code also inherits $GITHUB_ENV — writes to it inject environment variables into every later step of this same job, including the PAT-bearing ones. GIT_CONFIG_COUNT/GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_n entries apply at command-line precedence: they beat this redirect, survive resanitize, and beat the PAT steps' local core.hooksPath=/dev/null; PATH/GIT_EXEC_PATH entries hijack which git binary runs, reading GITHUB_TOKEN directly with no git-config surface involved.

Failure scenario: probe-verified at the git level — env GIT_CONFIG_* entries apply even when GIT_CONFIG_GLOBAL points at an existing file and outrank every file layer; file-scoped --unset-all cannot touch them (rc=5), so resanitize is structurally blind to this channel. Concrete paths: the repair leg re-runs the runner script whose GIT_CONFIG_GLOBAL export cannot outrank command-line precedence (an env-planted diff.external fires during its git diff — the incident class this PR fixes), and the push-race salvage merge runs with CI_DEV_BOT_PAT in env, where env-planted core.hooksPath/core.fsmonitor fire. The channel predates this diff, but every guard the diff adds is newly defeated by it. (The credential channel is confirmed closed by the -c credential.helper= reset.)

Suggested fix: add export GIT_CONFIG_COUNT=0 here, in the issue gate's twin redirect, and in the PAT-bearing steps' shells before their first git invocation (git reads zero env entries when count is 0); pin it in the contract tests alongside these redirect assertions.

中文说明

[Critical] 本 diff 新增的所有防御都作用于 git 配置文件,但门里的分支代码同样继承了 $GITHUB_ENV——向它写入会向同一 job 的所有后续步骤(包括 PAT 步骤)注入环境变量。GIT_CONFIG_COUNT/GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_n 以命令行优先级生效:高于本重定向、在 resanitize 后存活、也高于 PAT 步骤的 local core.hooksPath=/dev/nullPATH/GIT_EXEC_PATH 条目则直接劫持 git 二进制本身,从 env 直接读取 GITHUB_TOKEN,完全不经过 git 配置面。

失败场景:git 层面已探针验证——即使 GIT_CONFIG_GLOBAL 指向存在的文件,env GIT_CONFIG_* 条目仍生效且高于所有文件层;文件作用域的 --unset-all 无法触及(rc=5),resanitize 在结构上对该通道不可见。具体路径:修复腿(repair leg)重跑 runner 脚本,其 GIT_CONFIG_GLOBAL 导出压不过命令行优先级(env 植入的 diff.external 会在其 git diff 中执行——正是本 PR 要修复的事故类);push 竞态补救 merge 运行时 env 含 CI_DEV_BOT_PAT,env 植入的 core.hooksPath/core.fsmonitor 会被触发。该通道早于本 diff 存在,但 diff 新增的每一项防御都会被它击败。(凭证通道已确认被 -c credential.helper= 重置封闭。)

修复建议:在此处、issue 门的重定向孪生处、以及 PAT 步骤 shell 的首条 git 调用前加 export GIT_CONFIG_COUNT=0(count 为 0 时 git 不读取任何 env 条目);并在契约测试中与这些重定向断言一起 pin。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e0031b8export GIT_CONFIG_COUNT=0 is now in both gate redirect blocks and both PAT-bearing steps, before their first git. Pinned in the contract tests, including functionally: the extracted redirect block is executed with an env-planted GIT_CONFIG_COUNT=1/GIT_CONFIG_KEY_0=core.fsmonitor and a hostile HOME, and a child git must resolve neither key afterwards. The PATH/GIT_EXEC_PATH leg of the GITHUB_ENV channel is noted but out of scope here.

: > "${GIT_CONFIG_GLOBAL}"
git config --file "${GIT_CONFIG_GLOBAL}" safe.directory "$(pwd)"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Test efficacy: hunk-survived — the GIT_CONFIG_SYSTEM=/dev/null and GIT_CONFIG_GLOBAL throwaway-file redirect can be reverted without any test failing. The existing 'throwaway global git config' test checks ordering (redirect appears before npm run build) but not functional effect. Adding a functional test (e.g., writing a hostile global config key before the gate and confirming the gate's git commands are unaffected) would strengthen the coverage.

Failure scenario: the redirect is silently removed or broken; the ordering test still passes (it checks string presence, not behavior), and the gate runs exposed to the host's global config.

中文说明

[Suggestion] 测试有效性:hunk 存活——GIT_CONFIG_SYSTEM=/dev/nullGIT_CONFIG_GLOBAL 一次性文件重定向可以在没有测试失败的情况下被还原。现有的 'throwaway global git config' 测试检查顺序(重定向出现在 npm run build 之前)但不检查功能效果。添加功能测试(例如,在门前写入敌对全局配置键并确认门的 git 命令不受影响)将加强覆盖。

失败场景:重定向被静默移除或破坏;顺序测试仍然通过(它检查字符串存在而非行为),门暴露在宿主机的全局配置下运行。

— deepseek-v4-flash via Qwen Code /review (v0.21.10)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e0031b8 — the contract test now EXECUTES the extracted redirect block: hostile HOME (diff.external=global-driver in ~/.gitconfig) plus an env-planted GIT_CONFIG_* key, asserting a child git resolves neither afterwards, that a git config --global write lands in the throwaway file (the hostile HOME file stays byte-identical), and the block's exit is clean. Reverting or hollowing out the redirect now fails this test on any host.

if [ -s /etc/gitconfig ]; then
echo "::notice::/etc/gitconfig exists but is bypassed by the gate's GIT_CONFIG_SYSTEM redirect — replicate any setting the checks need via per-job env."
fi

# Record whether the agent left a commit FIRST — this is a ref-only
# diff, so it runs before the failure.md early-exits and covers an
# agent that commits and then aborts. The failure handoff keys its
Expand Down
Loading
Loading