diff --git a/.github/scripts/resanitize-git-config.sh b/.github/scripts/resanitize-git-config.sh new file mode 100644 index 00000000000..bc8b56d17bf --- /dev/null +++ b/.github/scripts/resanitize-git-config.sh @@ -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 + git config --local --unset-all extensions.worktreeConfig 2>/dev/null || true + 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 diff --git a/.github/scripts/run-autofix-review-verification.sh b/.github/scripts/run-autofix-review-verification.sh index 1e334ea923a..57d16a065b4 100755 --- a/.github/scripts/run-autofix-review-verification.sh +++ b/.github/scripts/run-autofix-review-verification.sh @@ -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 \ + GIT_WORK_TREE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_SHALLOW_FILE \ + GIT_ASKPASS GIT_SSH GIT_SSH_COMMAND +export GIT_CONFIG_COUNT=0 +export GIT_TERMINAL_PROMPT=0 +export GIT_CONFIG_SYSTEM=/dev/null +export GIT_CONFIG_GLOBAL="${RUNNER_TEMP}/autofix-gate-gitconfig" +: > "${GIT_CONFIG_GLOBAL}" +git config --file "${GIT_CONFIG_GLOBAL}" safe.directory "$(pwd)" +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 diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index a327f639145..c0a8f8bba3f 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -789,10 +789,55 @@ jobs: - name: 'Sanitize workspace git config' run: |- set -uo pipefail + # The runner USER's global config is the same exec surface as the + # workspace config below: pool jobs run human-authored code (branch + # tests) as this user, and a stray `git config --global` outlives + # the job on the persistent pool. Measured: run 31516789251 found + # diff.external=global-driver in ~/.gitconfig, failing per-hunk + # probe tests in every later verification gate on this host. The + # gates read a throwaway global config now, so this scrub is host + # hygiene plus protection for THIS job's PAT-bearing git steps, + # which do read the real file. It runs BEFORE the .git early-exit: + # host hygiene owes nothing to the workspace existing. Denylist + # here, not the local allowlist below: the file belongs to the + # pool image, so routing/credential keys (http.*, url.*, + # credential.*) may be deliberate infra and are left alone — only + # the command-execution families go, plus include/includeIf (which + # can pull any of them back in) and protocol.ext.allow (which arms + # the command-executing ext:: transport a kept url.insteadOf could + # redirect to). Two ROUTING exceptions ride the denylist because + # each defeats the PAT steps directly: url.*.insteadOf/ + # pushInsteadOf (rewrites the push/fetch URL at transport time — + # the rest of url.* stays) and http.*.sslVerify/sslCAInfo (turns + # a kept http.proxy into a TLS-terminating interceptor; the pool + # works on the default CA today, so scrubbing these can only + # fail loudly, never silently). Subsection slots are `.+`, never + # `[^.]+`: git subsection names may contain dots (`[diff "a.b"] + # command` flattens to diff.a.b.command and would slip past + # `[^.]+`); overmatching is harmless in a denylist. Guarded + # `|| true` twice: no global file and no match are both normal, + # and either would kill the step under the default `bash -e` + + # pipefail otherwise. The same denylist lives in + # resanitize-git-config.sh, which the PAT-bearing steps re-run + # AFTER branch code executed on the host; the workflow contract + # tests pin every copy byte-identical — edit them together. + # 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 # `.git` is a directory in a normal checkout but a gitlink file in # a worktree; -e covers both, and a missing .git (first run) too. if [ ! -e .git ]; then - echo "no prior workspace; nothing to sanitize" + echo "no prior workspace; nothing local to sanitize" exit 0 fi # Worktree-scoped config FIRST: `extensions.worktreeConfig=true` is @@ -817,7 +862,7 @@ jobs: # runner) means grep exits 1, which would kill the step exactly # when there is nothing to clean. 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; } \ + | { 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 # Belt and braces after the config scrub: only delete inside the # repository's own git dir. A hooks path resolving anywhere else is @@ -918,10 +963,22 @@ jobs: # Same staging as the review-address job: the verify gate always runs the # trusted checkout's copy of the schema gate, never a working-tree copy. - name: 'Stage trusted schema gate' + id: 'stage' run: |- cp .github/scripts/check-settings-schema.sh "${RUNNER_TEMP}/check-settings-schema.sh" cp .github/scripts/check-autofix-contracts.sh "${RUNNER_TEMP}/check-autofix-contracts.sh" cp .github/scripts/resolve-owning-packages.sh "${RUNNER_TEMP}/resolve-owning-packages.sh" + cp .github/scripts/resanitize-git-config.sh "${RUNNER_TEMP}/resanitize-git-config.sh" + # The staged copy's trusted-base provenance holds at cp time only: + # RUNNER_TEMP is writable by the branch/agent code later steps run + # on this host, so record the digest in GITHUB_OUTPUT — expression + # context, which a disk write after staging cannot reach — for the + # PAT-bearing step to verify at invocation time. The trusted PATH is + # recorded the same way and before any branch code runs, so a + # $GITHUB_ENV-planted PATH/preload cannot swap the sha256sum/bash/git + # the PAT step resolves (that would defeat the digest gate itself). + echo "resanitize_sha256=$(sha256sum "${RUNNER_TEMP}/resanitize-git-config.sh" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" + echo "trusted_path=${PATH}" >> "${GITHUB_OUTPUT}" - name: 'Check bot credentials' env: @@ -1430,6 +1487,28 @@ jobs: run: |- BRANCH="autofix/issue-${ISSUE}" + # Hermetic git config for the gate and every check it spawns, same + # rationale and shape as run-autofix-review-verification.sh (a + # leaked global exec knob on the persistent pool must not fail + # branch tests, a branch-authored `git config --global` must not + # outlive the run, and GITHUB_ENV-injected git env channels outrank + # every file layer) — the contract test pins this env+redirect + # block equal to the review gate's copy. + unset GIT_CONFIG_PARAMETERS GIT_ALLOW_PROTOCOL GIT_PROXY_COMMAND \ + GIT_SSL_NO_VERIFY GIT_SSL_CAINFO GIT_EXEC_PATH GIT_DIR \ + GIT_WORK_TREE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_SHALLOW_FILE \ + GIT_ASKPASS GIT_SSH GIT_SSH_COMMAND + export GIT_CONFIG_COUNT=0 + export GIT_TERMINAL_PROMPT=0 + export GIT_CONFIG_SYSTEM=/dev/null + export GIT_CONFIG_GLOBAL="${RUNNER_TEMP}/autofix-gate-gitconfig" + : > "${GIT_CONFIG_GLOBAL}" + git config --file "${GIT_CONFIG_GLOBAL}" safe.directory "$(pwd)" + 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 + if [[ -f "${WORKDIR}/failure.md" && -n "$(git status --porcelain)" ]]; then echo "❌ Agent wrote failure.md after leaving a dirty workspace:" git status --short @@ -1552,7 +1631,22 @@ jobs: GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' ISSUE: '${{ steps.decision.outputs.go_issue }}' MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}' + RESANITIZE_SHA256: '${{ steps.stage.outputs.resanitize_sha256 }}' + TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' run: |- + # gh has its own $GITHUB_ENV-injectable channels: pin the host and + # drop any planted token BEFORE the identity check below, so a + # GH_HOST reroute cannot spoof `gh api user` and a planted GH_TOKEN + # cannot outrank the inline GITHUB_TOKEN. (git's channels are + # stripped in the hermetic preamble further down.) + export GH_HOST=github.com + unset GH_ENTERPRISE_TOKEN GH_TOKEN + # Point gh at a fresh empty config dir, not the default + # ~/.config/gh on the shared attacker-writable HOME — its + # config.yml can carry http_unix_socket and other transport + # reroutes no sweep here touches. mktemp -d gives an + # unpredictable path a watcher cannot pre-seed. + export GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")" MODEL_DISPLAY="${MODEL:-default}" if [[ -z "${GITHUB_TOKEN}" ]]; then echo '::error::CI_DEV_BOT_PAT is required to publish the PR as the autofix bot.' @@ -1572,14 +1666,67 @@ jobs: exit 1 fi BRANCH="autofix/issue-${ISSUE}" + # Take this PAT-bearing step off every mutable host git surface — + # both the shared config FILES and git's ENV channels — keep this + # block byte-identical to its twin in 'Push and report' (the + # contract test pins them equal). File scopes: the pool shares one + # HOME across ~27 runner registrations and review-address fans out + # max-parallel, so a concurrent job can rewrite ~/.gitconfig inside + # this step's sweep->push window (a URL-scoped sslVerify=false there + # overrides the -c pin below over real TLS); redirect global/system + # to a per-run throwaway (as the gates do) so the push reads neither. + # Env channels: branch code in an earlier step of THIS job can inject + # env through $GITHUB_ENV, and several channels OUTRANK file config or + # bypass it entirely — pin PATH to the staged trusted value and drop + # LD_PRELOAD/LD_AUDIT/LD_LIBRARY_PATH first (else a swapped + # git/sha256sum/bash defeats the digest gate below), then strip + # GIT_CONFIG_COUNT/_PARAMETERS (command-line-precedence config), + # GIT_ALLOW_PROTOCOL (env twin of protocol.allow — arms ext::), + # GIT_SSL_NO_VERIFY/GIT_SSL_CAINFO (override the sslVerify pin over + # real TLS), GIT_PROXY_COMMAND, GIT_EXEC_PATH (transport-helper + # binary), GIT_DIR/GIT_WORK_TREE/GIT_COMMON_DIR/GIT_OBJECT_DIRECTORY/ + # GIT_ALTERNATE_OBJECT_DIRECTORIES/GIT_SHALLOW_FILE (repoint the repo + # git reads and pushes), GIT_ASKPASS/GIT_SSH/GIT_SSH_COMMAND + # (credential/exec hijack). The throwaway global uses an + # unpredictable mktemp path so a same-user watcher cannot re-plant + # http.proxy/sslCAInfo into a fixed literal after the seed. All + # probe-verified in the #8961 review. + export PATH="${TRUSTED_PATH}" + unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH \ + GIT_CONFIG_PARAMETERS GIT_ALLOW_PROTOCOL GIT_PROXY_COMMAND \ + GIT_SSL_NO_VERIFY GIT_SSL_CAINFO GIT_EXEC_PATH GIT_DIR \ + GIT_WORK_TREE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_SHALLOW_FILE \ + GIT_ASKPASS GIT_SSH GIT_SSH_COMMAND + export GIT_CONFIG_COUNT=0 + export GIT_TERMINAL_PROMPT=0 + export GIT_CONFIG_SYSTEM=/dev/null + export GIT_CONFIG_GLOBAL="$(mktemp "${RUNNER_TEMP}/autofix-pat-gitconfig.XXXXXX")" + git config --file "${GIT_CONFIG_GLOBAL}" safe.directory "$(pwd)" + # Host hygiene + LOCAL .git/config scrub (the throwaway global above + # covers only the global scope, not the highest-precedence local + # file the branch/agent can plant). The staged copy's trusted-base + # provenance holds at cp time only — RUNNER_TEMP is writable by that + # same branch code — so verify the digest the staging step recorded + # in GITHUB_OUTPUT (unreachable from a disk write) before executing. + # Never run the script from the working tree; it holds the branch. + echo "${RESANITIZE_SHA256} ${RUNNER_TEMP}/resanitize-git-config.sh" | sha256sum -c - > /dev/null + bash "${RUNNER_TEMP}/resanitize-git-config.sh" git config --local --unset-all http.https://github.com/.extraheader || true git config core.hooksPath /dev/null # Authenticate the push with a one-shot, host-scoped credential # helper via `git -c`: nothing is written to the reused # workspace's .git/config (no error path can strand it there), # argv holds only the literal ${GITHUB_TOKEN} reference, and the - # host scope means it cannot answer a non-GitHub URL. - git -c credential."https://github.com".helper='!f(){ echo username=x-access-token; echo "password=${GITHUB_TOKEN}"; };f' \ + # host scope means it cannot answer a non-GitHub URL. The leading + # empty credential.helper RESETS the inherited helper list first: + # helpers run in config order and the first to answer wins, so a + # helper planted at any earlier scope would otherwise see the + # request (and the env) before ours answers — probe-verified in + # the #8961 review. http.sslVerify pins the transport: a kept + # http.proxy plus a planted sslVerify=false would otherwise let + # an interceptor read the credential off the wire. + git -c http.sslVerify=true -c credential.helper= -c credential."https://github.com".helper='!f(){ echo username=x-access-token; echo "password=${GITHUB_TOKEN}"; };f' \ push --no-verify "https://github.com/${REPO}.git" "${BRANCH}" PR_URL="$(gh pr create --repo "${REPO}" \ @@ -3199,10 +3346,55 @@ jobs: - name: 'Sanitize workspace git config' run: |- set -uo pipefail + # The runner USER's global config is the same exec surface as the + # workspace config below: pool jobs run human-authored code (branch + # tests) as this user, and a stray `git config --global` outlives + # the job on the persistent pool. Measured: run 31516789251 found + # diff.external=global-driver in ~/.gitconfig, failing per-hunk + # probe tests in every later verification gate on this host. The + # gates read a throwaway global config now, so this scrub is host + # hygiene plus protection for THIS job's PAT-bearing git steps, + # which do read the real file. It runs BEFORE the .git early-exit: + # host hygiene owes nothing to the workspace existing. Denylist + # here, not the local allowlist below: the file belongs to the + # pool image, so routing/credential keys (http.*, url.*, + # credential.*) may be deliberate infra and are left alone — only + # the command-execution families go, plus include/includeIf (which + # can pull any of them back in) and protocol.ext.allow (which arms + # the command-executing ext:: transport a kept url.insteadOf could + # redirect to). Two ROUTING exceptions ride the denylist because + # each defeats the PAT steps directly: url.*.insteadOf/ + # pushInsteadOf (rewrites the push/fetch URL at transport time — + # the rest of url.* stays) and http.*.sslVerify/sslCAInfo (turns + # a kept http.proxy into a TLS-terminating interceptor; the pool + # works on the default CA today, so scrubbing these can only + # fail loudly, never silently). Subsection slots are `.+`, never + # `[^.]+`: git subsection names may contain dots (`[diff "a.b"] + # command` flattens to diff.a.b.command and would slip past + # `[^.]+`); overmatching is harmless in a denylist. Guarded + # `|| true` twice: no global file and no match are both normal, + # and either would kill the step under the default `bash -e` + + # pipefail otherwise. The same denylist lives in + # resanitize-git-config.sh, which the PAT-bearing steps re-run + # AFTER branch code executed on the host; the workflow contract + # tests pin every copy byte-identical — edit them together. + # 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 # `.git` is a directory in a normal checkout but a gitlink file in # a worktree; -e covers both, and a missing .git (first run) too. if [ ! -e .git ]; then - echo "no prior workspace; nothing to sanitize" + echo "no prior workspace; nothing local to sanitize" exit 0 fi # Worktree-scoped config FIRST: `extensions.worktreeConfig=true` is @@ -3227,7 +3419,7 @@ jobs: # runner) means grep exits 1, which would kill the step exactly # when there is nothing to clean. 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; } \ + | { 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 # Belt and braces after the config scrub: only delete inside the # repository's own git dir. A hooks path resolving anywhere else is @@ -3490,10 +3682,55 @@ jobs: - name: 'Sanitize workspace git config' run: |- set -uo pipefail + # The runner USER's global config is the same exec surface as the + # workspace config below: pool jobs run human-authored code (branch + # tests) as this user, and a stray `git config --global` outlives + # the job on the persistent pool. Measured: run 31516789251 found + # diff.external=global-driver in ~/.gitconfig, failing per-hunk + # probe tests in every later verification gate on this host. The + # gates read a throwaway global config now, so this scrub is host + # hygiene plus protection for THIS job's PAT-bearing git steps, + # which do read the real file. It runs BEFORE the .git early-exit: + # host hygiene owes nothing to the workspace existing. Denylist + # here, not the local allowlist below: the file belongs to the + # pool image, so routing/credential keys (http.*, url.*, + # credential.*) may be deliberate infra and are left alone — only + # the command-execution families go, plus include/includeIf (which + # can pull any of them back in) and protocol.ext.allow (which arms + # the command-executing ext:: transport a kept url.insteadOf could + # redirect to). Two ROUTING exceptions ride the denylist because + # each defeats the PAT steps directly: url.*.insteadOf/ + # pushInsteadOf (rewrites the push/fetch URL at transport time — + # the rest of url.* stays) and http.*.sslVerify/sslCAInfo (turns + # a kept http.proxy into a TLS-terminating interceptor; the pool + # works on the default CA today, so scrubbing these can only + # fail loudly, never silently). Subsection slots are `.+`, never + # `[^.]+`: git subsection names may contain dots (`[diff "a.b"] + # command` flattens to diff.a.b.command and would slip past + # `[^.]+`); overmatching is harmless in a denylist. Guarded + # `|| true` twice: no global file and no match are both normal, + # and either would kill the step under the default `bash -e` + + # pipefail otherwise. The same denylist lives in + # resanitize-git-config.sh, which the PAT-bearing steps re-run + # AFTER branch code executed on the host; the workflow contract + # tests pin every copy byte-identical — edit them together. + # 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 # `.git` is a directory in a normal checkout but a gitlink file in # a worktree; -e covers both, and a missing .git (first run) too. if [ ! -e .git ]; then - echo "no prior workspace; nothing to sanitize" + echo "no prior workspace; nothing local to sanitize" exit 0 fi # Worktree-scoped config FIRST: `extensions.worktreeConfig=true` is @@ -3518,7 +3755,7 @@ jobs: # runner) means grep exits 1, which would kill the step exactly # when there is nothing to clean. 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; } \ + | { 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 # Belt and braces after the config scrub: only delete inside the # repository's own git dir. A hooks path resolving anywhere else is @@ -3608,11 +3845,27 @@ jobs: # dies without an outcome), and an in-branch copy would let branch code # define its own gate. - name: 'Stage trusted schema gate and agent runner' + id: 'stage' run: |- cp .github/scripts/check-settings-schema.sh "${RUNNER_TEMP}/check-settings-schema.sh" cp .github/scripts/check-autofix-contracts.sh "${RUNNER_TEMP}/check-autofix-contracts.sh" cp .github/scripts/resolve-owning-packages.sh "${RUNNER_TEMP}/resolve-owning-packages.sh" cp .github/scripts/run-autofix-review-verification.sh "${RUNNER_TEMP}/run-autofix-review-verification.sh" + cp .github/scripts/resanitize-git-config.sh "${RUNNER_TEMP}/resanitize-git-config.sh" + # The staged copies' trusted-base provenance holds at cp time only: + # RUNNER_TEMP is writable by the branch/agent code later steps run + # on this host, so record each digest in GITHUB_OUTPUT — expression + # context, which a disk write after staging cannot reach — for the + # invoking step to verify before execution. The gate runner is + # pinned too: it runs the branch's own build/test between the two + # gate passes, so an unverified copy would let the branch define + # its own verdict. The trusted PATH is recorded before any branch + # code runs, so a $GITHUB_ENV-planted PATH/preload cannot swap the + # sha256sum/bash/git the steps resolve (that would defeat the digest + # gate itself). + echo "resanitize_sha256=$(sha256sum "${RUNNER_TEMP}/resanitize-git-config.sh" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" + echo "verify_runner_sha256=$(sha256sum "${RUNNER_TEMP}/run-autofix-review-verification.sh" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" + echo "trusted_path=${PATH}" >> "${GITHUB_OUTPUT}" # The agent step runs AFTER prepare checks out the PR branch, so # invoking the runner from the working tree would execute # branch-controlled code on the host with the model key in env @@ -3758,8 +4011,42 @@ jobs: id: 'prepare' env: GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' + TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' run: |- mkdir -p "${WORKDIR}" + # gh has its own $GITHUB_ENV-injectable channels: pin the host and + # drop any planted token BEFORE the gh calls below, so a GH_HOST + # reroute cannot spoof the eligibility re-check and a planted + # GH_TOKEN cannot outrank the inline GITHUB_TOKEN. + export GH_HOST=github.com + unset GH_ENTERPRISE_TOKEN GH_TOKEN + # Point gh at a fresh empty config dir, not the default + # ~/.config/gh on the shared attacker-writable HOME — its + # config.yml can carry http_unix_socket and other transport + # reroutes no sweep here touches. mktemp -d gives an + # unpredictable path a watcher cannot pre-seed. + export GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")" + # This PAT-bearing step runs git (status/restore/fetch/checkout and a + # push preflight) on the shared host BEFORE the agent/gate, so it + # takes the same hermetic preamble the push steps do — the contract + # test pins the executable lines equal across all three. Pin PATH and + # drop the preload channels, strip git's env knobs, redirect the file + # scopes to an unpredictable per-run throwaway (a concurrent job's + # ~/.gitconfig rewrite during this step's long window — staging, node + # setup, npm ci, artifact download all sit before it — cannot steer + # its git, and a fsmonitor/askpass/gpg.program plant cannot fire). + export PATH="${TRUSTED_PATH}" + unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH \ + GIT_CONFIG_PARAMETERS GIT_ALLOW_PROTOCOL GIT_PROXY_COMMAND \ + GIT_SSL_NO_VERIFY GIT_SSL_CAINFO GIT_EXEC_PATH GIT_DIR \ + GIT_WORK_TREE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_SHALLOW_FILE \ + GIT_ASKPASS GIT_SSH GIT_SSH_COMMAND + export GIT_CONFIG_COUNT=0 + export GIT_TERMINAL_PROMPT=0 + export GIT_CONFIG_SYSTEM=/dev/null + export GIT_CONFIG_GLOBAL="$(mktemp "${RUNNER_TEMP}/autofix-pat-gitconfig.XXXXXX")" + git config --file "${GIT_CONFIG_GLOBAL}" safe.directory "$(pwd)" # ---- address-time eligibility recheck --------------------------- # Fan-out can hold this job queued for hours behind max-parallel, @@ -3837,8 +4124,14 @@ jobs: git config core.hooksPath /dev/null if [[ "${HEAD_REPO:-${REPO}}" != "${REPO}" ]]; then # Maintainer-fork target: the branch does not exist on origin — - # fetch it (data only; hooks are severed) from the fork. - if ! git fetch "https://github.com/${HEAD_REPO}.git" "refs/heads/${BRANCH}"; then + # fetch it (data only; hooks are severed) from the fork. A public + # repo's fork heads are always public, so this fetch is anonymous: + # `-c credential.helper=` resets the inherited helper list (a + # planted global extraheader could 401 and hand a planted helper + # this step's PAT — the same class the push sites reset against) + # and `http.sslVerify=true` pins the transport. Fail closed on a + # 401 rather than authenticate. + if ! git -c http.sslVerify=true -c credential.helper= fetch "https://github.com/${HEAD_REPO}.git" "refs/heads/${BRANCH}"; then echo "🫥 fork fetch failed for ${HEAD_REPO} (${BRANCH}) — discarding without action or marker" { echo "stale=true" @@ -3857,7 +4150,11 @@ jobs: # and fine-grained PATs are documented as NOT receiving it. # Prove push access NOW, before an agent round is spent, instead # of 403ing at the report step after the work is done. - if ! git -c credential."https://github.com".helper='!f(){ echo username=x-access-token; echo "password=${GITHUB_TOKEN}"; };f' \ + # One-shot host-scoped helper like the push steps: the leading + # empty credential.helper resets the inherited helper list (a + # planted helper must never answer first) and http.sslVerify + # pins the transport — see 'Publish PR' for the full rationale. + if ! git -c http.sslVerify=true -c credential.helper= -c credential."https://github.com".helper='!f(){ echo username=x-access-token; echo "password=${GITHUB_TOKEN}"; };f' \ push --no-verify --dry-run "https://github.com/${HEAD_REPO}.git" HEAD:"${BRANCH}" > /dev/null 2>&1; then echo "🫥 fork push preflight failed for ${HEAD_REPO} (allow-edits grant or PAT type) — discarding without action or marker" { @@ -4717,7 +5014,19 @@ jobs: # alive, 'Finalize verification' sees an empty outcome, falls through # its case to exit 1, and the always() report step posts. timeout-minutes: 60 + env: + TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' + VERIFY_RUNNER_SHA256: '${{ steps.stage.outputs.verify_runner_sha256 }}' run: |- + # The gate decides whether the PAT push runs, and the first pass + # executes the branch's own build/test on the host before the + # second — so pin PATH to the staged trusted value, drop the + # preload channels, and verify the staged runner's digest (recorded + # in GITHUB_OUTPUT, unreachable from a disk write) before executing, + # or a mid-run overwrite lets the branch define its own verdict. + export PATH="${TRUSTED_PATH}" + unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH + echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - > /dev/null bash "${RUNNER_TEMP}/run-autofix-review-verification.sh" - name: 'Repair deterministic rejection' @@ -4824,7 +5133,19 @@ jobs: continue-on-error: true # Same bound as the first pass, for the same reason. timeout-minutes: 60 + env: + TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' + VERIFY_RUNNER_SHA256: '${{ steps.stage.outputs.verify_runner_sha256 }}' run: |- + # The gate decides whether the PAT push runs, and the first pass + # executes the branch's own build/test on the host before the + # second — so pin PATH to the staged trusted value, drop the + # preload channels, and verify the staged runner's digest (recorded + # in GITHUB_OUTPUT, unreachable from a disk write) before executing, + # or a mid-run overwrite lets the branch define its own verdict. + export PATH="${TRUSTED_PATH}" + unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH + echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - > /dev/null bash "${RUNNER_TEMP}/run-autofix-review-verification.sh" - name: 'Finalize verification' @@ -4917,6 +5238,8 @@ jobs: MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}' CHECKED_OUT_HEAD: '${{ steps.prepare.outputs.checked_out_head }}' VERIFIED_HEAD: '${{ steps.final_verify.outputs.verified_head }}' + RESANITIZE_SHA256: '${{ steps.stage.outputs.resanitize_sha256 }}' + TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' # Growth-brake baseline: written into this window's FIRST report # comment only (growth_base_new), so later rounds' first-wins parse # keeps the anchor. Empty when prepare exited early — no marker. @@ -4929,6 +5252,19 @@ jobs: # land under the key later reads will use. GROWTH_BASE_WIN: '${{ steps.prepare.outputs.growth_base_win }}' run: |- + # gh has its own $GITHUB_ENV-injectable channels: pin the host and + # drop any planted token BEFORE the identity check below, so a + # GH_HOST reroute cannot spoof `gh api user` and a planted GH_TOKEN + # cannot outrank the inline GITHUB_TOKEN. (git's channels are + # stripped in the hermetic preamble further down.) + export GH_HOST=github.com + unset GH_ENTERPRISE_TOKEN GH_TOKEN + # Point gh at a fresh empty config dir, not the default + # ~/.config/gh on the shared attacker-writable HOME — its + # config.yml can carry http_unix_socket and other transport + # reroutes no sweep here touches. mktemp -d gives an + # unpredictable path a watcher cannot pre-seed. + export GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")" # The head the agent actually evaluated — captured in prepare before # any mutation, not the report-time remote head (which can move # during the run). Empty when prepare exited early, which matches @@ -4956,8 +5292,74 @@ jobs: exit 1 fi + # Take this PAT-bearing step off every mutable host git surface — + # both the shared config FILES and git's ENV channels — keep this + # block byte-identical to its twin in 'Publish PR' (the contract + # test pins them equal). File scopes: the pool shares one HOME + # across ~27 runner registrations and review-address fans out + # max-parallel, so a concurrent job can rewrite ~/.gitconfig inside + # this step's sweep->push window (a URL-scoped sslVerify=false there + # overrides the -c pin below over real TLS); redirect global/system + # to a per-run throwaway (as the gates do) so the push reads neither. + # Env channels: branch code in an earlier step of THIS job can inject + # env through $GITHUB_ENV, and several channels OUTRANK file config or + # bypass it entirely — pin PATH to the staged trusted value and drop + # LD_PRELOAD/LD_AUDIT/LD_LIBRARY_PATH first (else a swapped + # git/sha256sum/bash defeats the digest gate below), then strip + # GIT_CONFIG_COUNT/_PARAMETERS (command-line-precedence config), + # GIT_ALLOW_PROTOCOL (env twin of protocol.allow — arms ext::), + # GIT_SSL_NO_VERIFY/GIT_SSL_CAINFO (override the sslVerify pin over + # real TLS), GIT_PROXY_COMMAND, GIT_EXEC_PATH (transport-helper + # binary), GIT_DIR/GIT_WORK_TREE/GIT_COMMON_DIR/GIT_OBJECT_DIRECTORY/ + # GIT_ALTERNATE_OBJECT_DIRECTORIES/GIT_SHALLOW_FILE (repoint the repo + # git reads and pushes), GIT_ASKPASS/GIT_SSH/GIT_SSH_COMMAND + # (credential/exec hijack). The throwaway global uses an + # unpredictable mktemp path so a same-user watcher cannot re-plant + # http.proxy/sslCAInfo into a fixed literal after the seed. All + # probe-verified in the #8961 review. + export PATH="${TRUSTED_PATH}" + unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH \ + GIT_CONFIG_PARAMETERS GIT_ALLOW_PROTOCOL GIT_PROXY_COMMAND \ + GIT_SSL_NO_VERIFY GIT_SSL_CAINFO GIT_EXEC_PATH GIT_DIR \ + GIT_WORK_TREE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_SHALLOW_FILE \ + GIT_ASKPASS GIT_SSH GIT_SSH_COMMAND + export GIT_CONFIG_COUNT=0 + export GIT_TERMINAL_PROMPT=0 + export GIT_CONFIG_SYSTEM=/dev/null + export GIT_CONFIG_GLOBAL="$(mktemp "${RUNNER_TEMP}/autofix-pat-gitconfig.XXXXXX")" + git config --file "${GIT_CONFIG_GLOBAL}" safe.directory "$(pwd)" + # Host hygiene + LOCAL .git/config scrub (the throwaway global above + # covers only the global scope, not the highest-precedence local + # file the branch/agent can plant). The staged copy's trusted-base + # provenance holds at cp time only — RUNNER_TEMP is writable by that + # same branch code — so verify the digest the staging step recorded + # in GITHUB_OUTPUT (unreachable from a disk write) before executing. + # Never run the script from the working tree; it holds the branch. + echo "${RESANITIZE_SHA256} ${RUNNER_TEMP}/resanitize-git-config.sh" | sha256sum -c - > /dev/null + bash "${RUNNER_TEMP}/resanitize-git-config.sh" + if [[ "${OUTCOME}" == "fixed" ]]; then NEXT_ROUND="$(( ROUND + 1 ))" + # The tree the gate verified is what gets pushed: assert HEAD is + # the gate's verified_head before touching credentials. A repo + # redirect (a planted .git/commondir/GIT_DIR — the first defused + # by resanitize, the second by the env strip) would otherwise let + # `git rev-parse HEAD` and the push read an attacker repo whose + # HEAD differs; this compares against the value the gate recorded + # in GITHUB_OUTPUT (unreachable from a disk write). Empty + # verified_head only on a noop, which does not reach this push. + HEAD_NOW="$(git rev-parse HEAD)" + if [[ -z "${VERIFIED_HEAD}" || "${HEAD_NOW}" != "${VERIFIED_HEAD}" ]]; then + echo "::error::HEAD ${HEAD_NOW} is not the gate's verified head ${VERIFIED_HEAD:-} — refusing to push" + exit 1 + fi + # Push the exact verified COMMIT OBJECT, never the symbolic + # HEAD: `HEAD:branch` would re-resolve at push time, re-opening + # the check-then-use race the guard above just closed (a watcher + # moving HEAD between the check and the push). PUSH_SHA is + # re-pinned to the concrete object after each salvage merge below. + PUSH_SHA="${VERIFIED_HEAD}" git config --local --unset-all http.https://github.com/.extraheader || true # This step carries the PAT; the branch carries PR-controlled # .husky hooks (hooksPath was pointed there so the AGENT's @@ -4966,8 +5368,17 @@ jobs: git config core.hooksPath /dev/null # Authenticate push/fetch with a one-shot, host-scoped credential # helper via a git_auth wrapper (see Publish PR) — nothing lands - # in .git/config, argv holds only the ${GITHUB_TOKEN} reference. - git_auth() { git -c credential."https://github.com".helper='!f(){ echo username=x-access-token; echo "password=${GITHUB_TOKEN}"; };f' "$@"; } + # in .git/config, argv holds only the ${GITHUB_TOKEN} reference, + # the leading empty credential.helper resets the inherited + # helper list so a planted helper never answers first, and + # http.sslVerify pins the transport against a planted + # sslVerify=false + proxy interceptor. + # fetch.recurseSubmodules=false + protocol.ext.allow=never: the + # salvage fetch must not walk a branch-planted submodule whose + # .git/modules config was rewritten to an ext:: URL (resanitize + # sweeps neither the kept fetch.* allowlist entry nor .git/modules) + # and execute it with the PAT in env. + git_auth() { git -c http.sslVerify=true -c fetch.recurseSubmodules=false -c protocol.ext.allow=never -c credential.helper= -c credential."https://github.com".helper='!f(){ echo username=x-access-token; echo "password=${GITHUB_TOKEN}"; };f' "$@"; } if [[ "${HEAD_REPO:-${REPO}}" != "${REPO}" ]]; then # Push back to the FORK branch via allow-edits (PAT has push # rights on the upstream, which GitHub extends to the fork's @@ -4993,7 +5404,7 @@ jobs: # through to the existing failure path — same as today. PUSH_RACE_MERGED='false' for push_attempt in 1 2 3; do - if git_auth push --no-verify "${PUSH_URL}" HEAD:"${BRANCH}"; then + if git_auth push --no-verify "${PUSH_URL}" "${PUSH_SHA}:refs/heads/${BRANCH}"; then break fi if [[ "${push_attempt}" == 3 ]]; then @@ -5011,14 +5422,25 @@ jobs: # flagging that would tell the reviewer to re-check mid-run # commits that never existed. PRE_MERGE_HEAD="$(git rev-parse HEAD)" - if ! git -c user.name="${AUTOFIX_BOT}" \ + # commit.gpgsign=false: this real merge commit would otherwise + # read the signing knob from config and, with no key on the + # runner, exit 128 ("gpg: signing failed") — misread below as a + # content conflict, discarding a verified round. The throwaway + # global above already hides a polluted ~/.gitconfig; this makes + # the merge independent of it regardless. + if ! git -c commit.gpgsign=false \ + -c user.name="${AUTOFIX_BOT}" \ -c user.email="${AUTOFIX_BOT}@users.noreply.github.com" \ merge --no-edit FETCH_HEAD; then git merge --abort || true echo "::error::the commits pushed during the run conflict with this fix — handing off instead of overwriting either side" exit 1 fi - if [[ "$(git rev-parse HEAD)" != "${PRE_MERGE_HEAD}" ]]; then + # Re-pin the exact object the next attempt pushes to the + # merge result, captured here under control — not a symbolic + # HEAD the push would re-resolve. + PUSH_SHA="$(git rev-parse HEAD)" + if [[ "${PUSH_SHA}" != "${PRE_MERGE_HEAD}" ]]; then PUSH_RACE_MERGED='true' fi done diff --git a/packages/cli/src/commands/review/comment-status.integration.test.ts b/packages/cli/src/commands/review/comment-status.integration.test.ts index 32f753c7b1a..9f26562e3aa 100644 --- a/packages/cli/src/commands/review/comment-status.integration.test.ts +++ b/packages/cli/src/commands/review/comment-status.integration.test.ts @@ -18,9 +18,11 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { makeGitProbe } from './comment-status.js'; +import { isolateHostGitConfig } from './lib/test-utils.js'; let repo: string; let savedCwd: string; +let gitIsolation: ReturnType; function git(...args: string[]): string { return execFileSync('git', args, { cwd: repo, encoding: 'utf8' }).trim(); @@ -44,6 +46,14 @@ function commitFile(path: string, content: string, message: string): string { beforeEach(() => { repo = mkdtempSync(join(tmpdir(), 'comment-status-probe-')); savedCwd = process.cwd(); + + // Isolate the fixture from the user's git environment (shared helper — + // see isolateHostGitConfig for the incident class): a global + // `commit.gpgsign=true` fails every commitFile for want of a key, and a + // global `core.hooksPath` executes host-state hooks on each fixture + // commit. + gitIsolation = isolateHostGitConfig(); + execFileSync('git', ['init', '-q', repo]); mkdirSync(join(repo, 'pkg', 'src'), { recursive: true }); }); @@ -51,6 +61,25 @@ beforeEach(() => { afterEach(() => { process.chdir(savedCwd); rmSync(repo, { recursive: true, force: true }); + gitIsolation.dispose(); +}); + +describe('fixture git-config isolation', () => { + it('spawned git reads the throwaway global config, not the host user config', () => { + // Same tripwire as test-efficacy.integration.test.ts: if the + // beforeEach isolation is ever removed, the sentinel below becomes + // unreadable through a child git and this goes red on every host — + // not only on hosts whose real config happens to be hostile. + writeFileSync( + join(gitIsolation.home, '.gitconfig'), + '[qwen]\n\tisolation = sentinel\n', + ); + expect(git('config', '--global', 'qwen.isolation')).toBe('sentinel'); + expect(process.env['GIT_CONFIG_NOSYSTEM']).toBe('1'); + expect(process.env['GIT_CONFIG_GLOBAL']).toBe( + join(gitIsolation.home, '.gitconfig'), + ); + }); }); describe('makeGitProbe (real git)', () => { diff --git a/packages/cli/src/commands/review/lib/diff-plan.integration.test.ts b/packages/cli/src/commands/review/lib/diff-plan.integration.test.ts index 6577916da5f..1b11a95d565 100644 --- a/packages/cli/src/commands/review/lib/diff-plan.integration.test.ts +++ b/packages/cli/src/commands/review/lib/diff-plan.integration.test.ts @@ -22,6 +22,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { buildDiffPlan, chunksCoverDiff, parseDiff } from './diff-plan.js'; import { PINNED_DIFF_CONFIG, PINNED_DIFF_FLAGS } from './diff-flags.js'; +import { isolateHostGitConfig } from './test-utils.js'; // Driven from the production constants, not a copy of them. Hand-maintained // duplicates of the flag list let a flag be deleted from the capture paths while @@ -45,6 +46,7 @@ const HOSTILE_CONFIG = [ let repo: string; let subRepo: string; let env: NodeJS.ProcessEnv; +let gitIsolation: ReturnType; /** Run git with the developer's system and global config switched off. */ const git = (...args: string[]) => @@ -55,14 +57,13 @@ const gitIn = (cwd: string, ...args: string[]) => beforeAll(() => { repo = mkdtempSync(join(tmpdir(), 'diff-plan-it-')); - const emptyConfig = join(repo, '.empty-gitconfig'); - writeFileSync(emptyConfig, ''); + // Shared host-git-config isolation (see isolateHostGitConfig for the + // incident class). This suite passes `env` explicitly to every child git + // rather than relying on process.env, so it snapshots the isolated env + // and adds its one real delta, the terminal-prompt guard. + gitIsolation = isolateHostGitConfig(); env = { ...process.env, - GIT_CONFIG_NOSYSTEM: '1', - GIT_CONFIG_GLOBAL: emptyConfig, - // Belt and braces on platforms where the above is unsupported. - HOME: repo, GIT_TERMINAL_PROMPT: '0', }; @@ -125,6 +126,7 @@ afterAll(() => { // submodule, so it must outlive the tests. if (repo) rmSync(repo, { recursive: true, force: true }); if (subRepo) rmSync(subRepo, { recursive: true, force: true }); + gitIsolation.dispose(); }); /** Capture exactly as `fetch-pr` does, but under hostile config. */ diff --git a/packages/cli/src/commands/review/lib/git.integration.test.ts b/packages/cli/src/commands/review/lib/git.integration.test.ts index 3fb21024d8b..8beadb847f7 100644 --- a/packages/cli/src/commands/review/lib/git.integration.test.ts +++ b/packages/cli/src/commands/review/lib/git.integration.test.ts @@ -20,11 +20,11 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { gitRawTolerateDiff, releaseWorktree } from './git.js'; import { NULL_DEVICE } from './diff-flags.js'; +import { isolateHostGitConfig } from './test-utils.js'; let repo: string; -let home: string; let cwd: string; -let savedEnv: NodeJS.ProcessEnv; +let gitIsolation: ReturnType; function git(...args: string[]): string { return execFileSync('git', args, { cwd: repo, encoding: 'utf8' }); @@ -32,20 +32,16 @@ function git(...args: string[]): string { beforeEach(() => { repo = mkdtempSync(join(tmpdir(), 'review-wt-')); - home = mkdtempSync(join(tmpdir(), 'review-wt-home-')); - writeFileSync(join(home, '.gitconfig'), ''); - // Isolate the fixture from the developer's git environment. Without this, + // Isolate the fixture from the developer's git environment (shared + // helper — see isolateHostGitConfig for the incident class). Without it, // `git init` loads their templates and the commit below runs their // `core.hooksPath` hooks — a targeted run visibly executed configured // pre-commit, prepare-commit-msg, commit-msg, post-commit and post-checkout - // hooks — and a global `commit.gpgsign=true` fails the suite for want of a key. - // The wrappers under test read `process.env` per call, so setting it here - // reaches them. - savedEnv = { ...process.env }; - process.env['GIT_CONFIG_NOSYSTEM'] = '1'; - process.env['GIT_CONFIG_GLOBAL'] = join(home, '.gitconfig'); - process.env['HOME'] = home; + // hooks — and a global `commit.gpgsign=true` fails the suite for want of + // a key. The wrappers under test read `process.env` per call, so setting + // it here reaches them. + gitIsolation = isolateHostGitConfig(); git('init', '-q', '--template=', '.'); git('config', 'user.email', 'a@b'); @@ -61,9 +57,8 @@ beforeEach(() => { afterEach(() => { process.chdir(cwd); - process.env = savedEnv; rmSync(repo, { recursive: true, force: true }); - rmSync(home, { recursive: true, force: true }); + gitIsolation.dispose(); }); describe('releaseWorktree', () => { diff --git a/packages/cli/src/commands/review/lib/local-diff.integration.test.ts b/packages/cli/src/commands/review/lib/local-diff.integration.test.ts index d56732c57cf..465ee3bc656 100644 --- a/packages/cli/src/commands/review/lib/local-diff.integration.test.ts +++ b/packages/cli/src/commands/review/lib/local-diff.integration.test.ts @@ -28,11 +28,11 @@ import { MAX_UNTRACKED_TOTAL_BYTES, } from './local-diff.js'; import { parseDiff, buildDiffPlan, chunksCoverDiff } from './diff-plan.js'; +import { isolateHostGitConfig } from './test-utils.js'; let repo: string; -let home: string; let cwd: string; -let savedEnv: NodeJS.ProcessEnv; +let gitIsolation: ReturnType; function git(...args: string[]): string { return execFileSync('git', args, { cwd: repo, encoding: 'utf8' }); @@ -61,28 +61,18 @@ beforeEach(() => { process.chdir(repo); // `captureLocalDiff` shells out to git through `process.env`, so the fixture - // has to isolate the *process* environment, not just its own git calls. Left - // inheriting the developer's setup, a global `core.hooksPath` runs during the - // test and a global `commit.gpgsign=true` fails it outright for want of a key - // — and a stray `~/.gitconfig` silently redefines what the "clean" baseline - // is. Matches the neighbouring diff-plan fixture. - savedEnv = { ...process.env }; - // The config lives OUTSIDE the repo. Written inside it, the fixture's own - // isolation file becomes an untracked file — and this suite's whole subject is - // what the capture does with untracked files. - home = realpathSync(mkdtempSync(join(tmpdir(), 'review-home-'))); - const emptyConfig = join(home, '.gitconfig'); - writeFileSync(emptyConfig, ''); - process.env['GIT_CONFIG_NOSYSTEM'] = '1'; - process.env['GIT_CONFIG_GLOBAL'] = emptyConfig; - process.env['HOME'] = home; // belt and braces where the above is unsupported + // has to isolate the *process* environment, not just its own git calls + // (shared helper — see isolateHostGitConfig for the incident class). The + // throwaway config lives OUTSIDE the repo: written inside it, the + // fixture's own isolation file becomes an untracked file — and this + // suite's whole subject is what the capture does with untracked files. + gitIsolation = isolateHostGitConfig(); }); afterEach(() => { process.chdir(cwd); - process.env = savedEnv; rmSync(repo, { recursive: true, force: true }); - rmSync(home, { recursive: true, force: true }); + gitIsolation.dispose(); }); /** Init a repo with hooks and signing off, so a fixture cannot run either. */ diff --git a/packages/cli/src/commands/review/lib/test-utils.ts b/packages/cli/src/commands/review/lib/test-utils.ts index 6f96962d750..2b29ca9e986 100644 --- a/packages/cli/src/commands/review/lib/test-utils.ts +++ b/packages/cli/src/commands/review/lib/test-utils.ts @@ -4,12 +4,49 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { mkdirSync, writeFileSync } from 'node:fs'; +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { PARSE_ARGS_REPORT } from './paths.js'; import { DIGEST_FILE } from './stale-bundle.js'; +/** + * Redirect every git the process (and its children) spawns away from the + * host's real config: a throwaway HOME + GIT_CONFIG_GLOBAL, and + * GIT_CONFIG_NOSYSTEM=1 for the system file. Call in beforeEach and + * `dispose()` in afterEach. Without this a fixture suite inherits whatever + * the host accumulated: a global `commit.gpgsign=true` fails every fixture + * commit for want of a key, a global `core.hooksPath` executes host hooks + * on each commit, and a global `diff.external` kills plain `git diff` — + * the incident class from run 31516789251, where a persistent CI runner's + * polluted ~/.gitconfig failed suites the branch never touched. The home + * path is realpath'd so suites can compare it against paths git reports. + */ +export function isolateHostGitConfig(): { + home: string; + dispose: () => void; +} { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'git-isolated-home-'))); + writeFileSync(join(home, '.gitconfig'), ''); + const savedEnv = { ...process.env }; + process.env['GIT_CONFIG_NOSYSTEM'] = '1'; + process.env['GIT_CONFIG_GLOBAL'] = join(home, '.gitconfig'); + process.env['HOME'] = home; + return { + home, + dispose() { + process.env = savedEnv; + rmSync(home, { recursive: true, force: true }); + }, + }; +} + /** Seed the report `parse-args` tees, so the effort fallback has something to read. */ export function seedParseArgs(dir: string, effort: unknown): void { mkdirSync(join(dir, dirname(PARSE_ARGS_REPORT)), { recursive: true }); diff --git a/packages/cli/src/commands/review/repo-context.test.ts b/packages/cli/src/commands/review/repo-context.test.ts index a10c0d18904..ab9493812a9 100644 --- a/packages/cli/src/commands/review/repo-context.test.ts +++ b/packages/cli/src/commands/review/repo-context.test.ts @@ -24,6 +24,7 @@ import { type RepositoryContextProvider, } from './lib/repository-context.js'; import { repoContextCommand, runRepoContext } from './repo-context.js'; +import { isolateHostGitConfig } from './lib/test-utils.js'; const tempRoots: string[] = []; @@ -47,21 +48,14 @@ function readJson(path: string): unknown { // fails the suite for want of a key, and a global `core.hooksPath` runs the // developer's hooks inside the test commits (`git worktree add` fires // post-checkout too). The wrappers under test read `process.env` per call. -let savedEnv: NodeJS.ProcessEnv; -let gitHome: string; +let gitIsolation: ReturnType; beforeEach(() => { - gitHome = mkdtempSync(join(tmpdir(), 'repo-context-home-')); - writeFileSync(join(gitHome, '.gitconfig'), ''); - savedEnv = { ...process.env }; - process.env['GIT_CONFIG_NOSYSTEM'] = '1'; - process.env['GIT_CONFIG_GLOBAL'] = join(gitHome, '.gitconfig'); - process.env['HOME'] = gitHome; + gitIsolation = isolateHostGitConfig(); }); afterEach(() => { - process.env = savedEnv; - rmSync(gitHome, { recursive: true, force: true }); + gitIsolation.dispose(); // Every test builds fixture worktrees (several with initialized git // repos) in the OS tmpdir; leaking them accumulates toward ENOSPC on // long-lived machines. diff --git a/packages/cli/src/commands/review/test-efficacy.integration.test.ts b/packages/cli/src/commands/review/test-efficacy.integration.test.ts index 1be22d8761e..7f1dd3b6a97 100644 --- a/packages/cli/src/commands/review/test-efficacy.integration.test.ts +++ b/packages/cli/src/commands/review/test-efficacy.integration.test.ts @@ -12,7 +12,7 @@ // where the probe runs and what it leaves behind. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, @@ -33,6 +33,7 @@ import { splitDiffIntoHunks, testEfficacyCommand, } from './test-efficacy.js'; +import { isolateHostGitConfig } from './lib/test-utils.js'; type Handler = (args: { report: string; @@ -45,6 +46,7 @@ const runHandler = testEfficacyCommand.handler as unknown as Handler; let repo: string; let outside: string; +let gitIsolation: ReturnType; function git(cwd: string, ...args: string[]): string { return execFileSync('git', args, { cwd, encoding: 'utf8' }); @@ -163,6 +165,12 @@ process.stdout.write(JSON.stringify({ beforeEach(() => { repo = mkdtempSync(join(tmpdir(), 'efficacy-iso-')); outside = mkdtempSync(join(tmpdir(), 'efficacy-outside-')); + // Isolate the fixtures from the user's git environment (shared helper — + // see isolateHostGitConfig for the incident class: a global + // `diff.external` kills every plain `git diff` in the helpers below, + // exactly what a polluted persistent CI runner did). The code under test + // spawns git with the ambient env, so process-level env reaches it too. + gitIsolation = isolateHostGitConfig(); git(repo, 'init', '-q', '-b', 'main', '.'); git(repo, 'config', 'core.autocrlf', 'false'); const hooksDir = join(repo, '.git-hooks-disabled'); @@ -236,6 +244,42 @@ afterEach(() => { } rmSync(repo, { recursive: true, force: true }); rmSync(outside, { recursive: true, force: true }); + gitIsolation.dispose(); +}); + +describe('fixture git-config isolation', () => { + it('spawned git reads the throwaway global config, not the host user config', () => { + // Tripwire for every leg of the beforeEach isolation. Global leg: if + // the GIT_CONFIG_GLOBAL / HOME redirect is ever removed, the sentinel + // below becomes unreadable through a child git and this test goes red + // — instead of the whole suite going red only on hosts whose real + // config happens to be hostile (the incident mode: a leaked global + // diff.external killed the per-hunk tests on a persistent CI runner). + writeFileSync( + join(gitIsolation.home, '.gitconfig'), + '[qwen]\n\tisolation = sentinel\n', + ); + expect(git(repo, 'config', '--global', 'qwen.isolation').trim()).toBe( + 'sentinel', + ); + expect(process.env['GIT_CONFIG_GLOBAL']).toBe( + join(gitIsolation.home, '.gitconfig'), + ); + // System leg: the sentinel resolves through the global redirect, which + // OUTRANKS system config — so the global check above stays green even + // with the NOSYSTEM leg deleted (mutation-tested in review). Pin the + // env, and prove behaviour: with NOSYSTEM set, a child git must not + // read a system file even when one is pointed at it. + expect(process.env['GIT_CONFIG_NOSYSTEM']).toBe('1'); + const sysCfg = join(gitIsolation.home, 'system-gitconfig'); + writeFileSync(sysCfg, '[qwen]\n\tsystemleak = yes\n'); + const sys = spawnSync('git', ['config', '--get', 'qwen.systemleak'], { + cwd: repo, + env: { ...process.env, GIT_CONFIG_SYSTEM: sysCfg }, + encoding: 'utf8', + }); + expect(sys.status).not.toBe(0); + }); }); describe('test-efficacy probe isolation (#6832)', () => { diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 0314b673ea4..bb46289e30e 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -87,6 +87,10 @@ const pushAndReportStep = workflow.match( /- name: 'Push and report'[\s\S]*?(?=\n[ ]{6}- name: 'Report dry-run \/ failure')/, )?.[0] ?? ''; +const prepareStep = + workflow.match( + /- name: 'Prepare branch and feedback'[\s\S]*?(?=\n[ ]{6}- name: 'Post autofix status comment')/, + )?.[0] ?? ''; const reportDryRunFailureSteps = workflow.match( /- name: 'Report dry-run \/ failure'[\s\S]*?(?=\n[ ]{6}- name: '|$)/g, @@ -2405,13 +2409,13 @@ describe('qwen-autofix workflow', () => { expect(workflow).toContain("HEAD_REPO: '${{ matrix.target.head_repo }}'"); expect(reviewScanJob).toContain('head_repo: $hr'); expect(workflow).toContain( - 'git fetch "https://github.com/${HEAD_REPO}.git" "refs/heads/${BRANCH}"', + 'git -c http.sslVerify=true -c credential.helper= fetch "https://github.com/${HEAD_REPO}.git" "refs/heads/${BRANCH}"', ); expect(workflow).toContain( 'PUSH_URL="https://github.com/${HEAD_REPO}.git"', ); expect(workflow).toContain( - 'git_auth push --no-verify "${PUSH_URL}" HEAD:"${BRANCH}"', + 'git_auth push --no-verify "${PUSH_URL}" "${PUSH_SHA}:refs/heads/${BRANCH}"', ); // The allow-edits grant rides the classic-PAT path only — prepare must // prove push access BEFORE an agent round is spent, discarding @@ -2420,7 +2424,7 @@ describe('qwen-autofix workflow', () => { // `push --no-verify …` match would still satisfy): the host-scoped // credential prefix must immediately precede the push. expect(workflow).toMatch( - /git -c credential\."https:\/\/github\.com"\.helper=[^\n]*\n\s+push --no-verify --dry-run "https:\/\/github\.com\/\$\{HEAD_REPO\}\.git"/, + /git -c http\.sslVerify=true -c credential\.helper= -c credential\."https:\/\/github\.com"\.helper=[^\n]*\n\s+push --no-verify --dry-run "https:\/\/github\.com\/\$\{HEAD_REPO\}\.git"/, ); expect(workflow).toContain('fork push preflight failed'); // First-pickup engage ack anchors the window when the label path could @@ -7267,7 +7271,10 @@ exit 1 const unsetExt = sanitizeStep.indexOf( '--unset-all extensions.worktreeConfig', ); - const sweep = sanitizeStep.indexOf('--name-only --list'); + // Explicitly the LOCAL sweep: the global scrub (asserted in its own + // test below) now sits at the top of the step and would otherwise be + // the first '--name-only --list' occurrence. + const sweep = sanitizeStep.indexOf('git config --local --name-only --list'); const hooks = sanitizeStep.indexOf('--git-path hooks'); expect(rmWorktreeCfg).toBeGreaterThan(-1); expect(unsetExt).toBeGreaterThan(rmWorktreeCfg); @@ -7290,6 +7297,579 @@ exit 1 expect(sanitizeStep).toContain('qwen-triage'); }); + it('scrubs exec-vector keys from the runner-user GLOBAL git config', () => { + // Run 31516789251: a stray `diff.external=global-driver` in the pool + // runner's ~/.gitconfig — planted by human-authored code an earlier job + // ran as this user — failed four per-hunk probe tests in every later + // verification gate on that host. The local sweep above never touches + // the global file, so the pollution outlived every job. Ordered BEFORE + // the `.git` early-exit — host hygiene owes nothing to the workspace + // existing (first run on a host, wiped workspace) — and before the + // hooks resolution, so a planted global core.hooksPath is removed, not + // merely bypassed while resolving. + const globalScrub = sanitizeStep.indexOf( + 'git config --global --name-only --list', + ); + const earlyExit = sanitizeStep.indexOf('[ ! -e .git ]'); + const localSweep = sanitizeStep.indexOf( + 'git config --local --name-only --list', + ); + const hooks = sanitizeStep.indexOf('--git-path hooks'); + expect(globalScrub).toBeGreaterThan(-1); + expect(earlyExit).toBeGreaterThan(globalScrub); + expect(localSweep).toBeGreaterThan(earlyExit); + expect(hooks).toBeGreaterThan(localSweep); + // The PAT-side re-run (resanitize-git-config.sh) duplicates both lists + // because the inlined copies cannot call a repo script pre-checkout — + // pin them equal so the copies cannot drift apart (a key added to one + // denylist but not the other re-opens the class on the stale side). + const resanitize = readFileSync( + '.github/scripts/resanitize-git-config.sh', + 'utf8', + ); + const denylistOf = (text) => text.match(/grep -iE '([^']+)'/)?.[1]; + const allowlistOf = (text) => text.match(/grep -ivE '([^']+)'/)?.[1]; + expect(denylistOf(sanitizeStep)).toBeTruthy(); + expect(denylistOf(resanitize)).toBe(denylistOf(sanitizeStep)); + expect(allowlistOf(resanitize)).toBe(allowlistOf(sanitizeStep)); + // Belt over the byte-identity pin: the list comparison holds against + // EVERY inlined copy, not only the canonical first. + for (const step of sanitizeSteps) { + expect(denylistOf(step)).toBe(denylistOf(sanitizeStep)); + } + // Functional: the extracted pipeline drops every command-execution key + // and keeps the routing/credential keys the pool image may own — the + // global file is infra territory, so this must stay a denylist. The + // fixture covers every denylist alternation (deleting one lets its + // family survive and fails the kept-set assertion) and plants dotted + // SUBSECTION names: git subsection names may contain dots, so a + // `[^.]+` slot would let `[diff "a.b"] command` slip through — the + // incident-class regression the `.+` slots exist to prevent. + // The scrub is a for-loop over BOTH files of the global scope — + // ~/.gitconfig and the XDG file — because `git config --global` + // lists/unsets only the former once both exist (probed: the listing + // omits the XDG keys and --unset-all exits 5 with them live). + const scrub = sanitizeStep.match( + /for global_file in[\s\S]*?\|\| true; done\n\s+done/, + )?.[0]; + expect(scrub).toBeTruthy(); + const runScrub = (home) => { + const env = { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: join(home, '.config'), + }; + delete env['GIT_CONFIG_GLOBAL']; + return spawnSync('bash', ['-e', '-o', 'pipefail', '-c', scrub], { + env, + encoding: 'utf8', + }); + }; + const dir = mkdtempSync(join(tmpdir(), 'autofix-global-scrub-')); + const cfg = join(dir, '.gitconfig'); + mkdirSync(join(dir, '.config', 'git'), { recursive: true }); + const xdgCfg = join(dir, '.config', 'git', 'config'); + writeFileSync( + xdgCfg, + '[diff]\n\texternal = xdg-evil\n[user]\n\temail = keep@x\n', + ); + writeFileSync( + cfg, + [ + '[safe]', + '\tdirectory = /work', + '[http]', + '\tproxy = http://proxy:3128', + '[credential]', + '\thelper = store', + '[user]', + '\tname = runner', + '[remote "origin"]', + '\turl = https://github.com/o/r', + '\tuploadpack = evil', + '\treceivepack = evil', + '[diff]', + '\texternal = global-driver', + '[diff "a.b"]', + '\tcommand = evil', + '\ttextconv = evil', + '[core]', + '\tfsmonitor = evil', + '\thooksPath = /tmp/evil', + '\tpager = evil', + '\teditor = evil', + '\tsshCommand = evil', + '\taskpass = evil', + '\talternateRefsCommand = evil', + '\tgitProxy = evil', + '\tautocrlf = false', + '[merge "x.y"]', + '\tdriver = evil', + '[filter "x"]', + '\tsmudge = evil', + '[alias]', + '\tpwn = !evil', + '[pager]', + '\tdiff = evil', + '[difftool "t"]', + '\tcmd = evil', + '[mergetool "t"]', + '\tcmd = evil', + '[interactive]', + '\tdiffFilter = evil', + '[sequence]', + '\teditor = evil', + '[gpg]', + '\tprogram = evil', + '[gpg "ssh"]', + '\tprogram = evil', + '[init]', + '\ttemplateDir = /tmp/evil', + '[include]', + '\tpath = /tmp/no-such-include', + '[includeIf "gitdir:/tmp/"]', + '\tpath = /tmp/evil.inc', + '[protocol]', + '\tallow = always', + '[protocol "ext"]', + '\tallow = always', + '[submodule "s.t"]', + '\tupdate = !evil', + '[url "https://mirror.example/"]', + '\tinsteadOf = https://github.com/', + '\tpushInsteadOf = https://github.com/', + '[http "https://github.com"]', + '\tsslVerify = false', + '\tsslCAInfo = /tmp/evil-ca', + '', + ].join('\n'), + ); + expect(runScrub(dir).status).toBe(0); + const keysOf = (file) => + execFileSync('git', ['config', '--file', file, '--name-only', '--list'], { + encoding: 'utf8', + }) + .trim() + .split('\n') + .filter(Boolean) + .sort(); + expect(keysOf(cfg)).toEqual([ + 'core.autocrlf', + 'credential.helper', + 'http.proxy', + 'remote.origin.url', + 'safe.directory', + 'user.name', + ]); + // The XDG leg of the loop scrubbed its exec key and kept its benign one. + expect(keysOf(xdgCfg)).toEqual(['user.email']); + // The two `|| true` guards are load-bearing under the step's default + // `bash -e` + pipefail: a config with NO exec keys (the steady state on + // a clean runner) makes grep exit 1, and a corrupt or missing global + // file makes git exit non-zero — none may kill the sanitize step. + writeFileSync(cfg, '[user]\n\tname = clean\n'); + rmSync(join(dir, '.config'), { recursive: true, force: true }); + expect(runScrub(dir).status).toBe(0); + writeFileSync(cfg, '[[[ not a git config\n'); + expect(runScrub(dir).status).toBe(0); + rmSync(cfg); + expect(runScrub(dir).status).toBe(0); + rmSync(dir, { recursive: true, force: true }); + }); + + it('re-sanitizes git config and resets the helper list at every PAT-bearing git step', () => { + // The job-start sanitize is pre-checkout hygiene; the gates then run + // branch test code on the host and the sandboxed agent has the + // workspace mounted — either can plant exec keys in the repo-LOCAL + // .git/config (highest precedence, read by the push) or rewrite the + // real ~/.gitconfig behind the gates' env redirect (a direct file + // write bypasses inherited env — probe-verified in the #8961 review). + // So both PAT-bearing git steps re-run the sweeps from a TRUSTED-BASE + // staged copy — never the working tree, which holds the branch under + // test at call time — before touching credentials. + const resanitizeCall = 'bash "${RUNNER_TEMP}/resanitize-git-config.sh"'; + for (const step of [publishPrStep, pushAndReportStep]) { + expect(step).toContain(resanitizeCall); + expect(step.indexOf(resanitizeCall)).toBeLessThan( + step.indexOf('credential."https://github.com".helper'), + ); + // The staged copy's provenance holds at cp time only — RUNNER_TEMP + // is writable by that same branch code — so the invocation must + // verify the digest the staging step parked in GITHUB_OUTPUT + // (expression context, unreachable from a disk write), and it must + // do so BEFORE executing the script. + // Pin the WHOLE verify line, not just its presence: `|| true` or a + // swapped digest target would turn the tamper gate into a decorative + // no-op while presence/order assertions stayed green (both mutants + // executed in the round-3 review). + const verifyLine = + 'echo "${RESANITIZE_SHA256} ${RUNNER_TEMP}/resanitize-git-config.sh" | sha256sum -c - > /dev/null'; + expect(step).toContain(verifyLine); + expect(step.indexOf(verifyLine)).toBeLessThan( + step.indexOf(resanitizeCall), + ); + expect(step).not.toMatch(/sha256sum -c[^\n]*\|\| true/); + expect(step).toContain( + "RESANITIZE_SHA256: '${{ steps.stage.outputs.resanitize_sha256 }}'", + ); + // Full env-channel closure, not just GIT_CONFIG_COUNT: GITHUB_ENV can + // inject any git env knob, and several outrank file config — the step + // strips them and redirects the file scopes to a throwaway (as the + // gates do), so a concurrent job's ~/.gitconfig rewrite and an + // env-planted GIT_SSL_NO_VERIFY/GIT_EXEC_PATH/GIT_DIR all miss. + expect(step).toContain('export GIT_CONFIG_COUNT=0'); + expect(step).toContain('export GIT_CONFIG_SYSTEM=/dev/null'); + // Unpredictable throwaway (mktemp), not a fixed literal a same-user + // watcher could re-plant into after the seed. + expect(step).toContain( + 'export GIT_CONFIG_GLOBAL="$(mktemp "${RUNNER_TEMP}/autofix-pat-gitconfig.XXXXXX")"', + ); + // PATH is pinned to the staged trusted value and the preload channels + // dropped BEFORE anything runs — else a swapped git/sha256sum/bash + // defeats the digest gate itself; the full env-channel closure covers + // the file-scope redirects (GLOBAL/SYSTEM), the exec/transport knobs, + // and every repo-redirect twin (DIR/WORK_TREE/COMMON_DIR/object dirs). + expect(step).toContain('export PATH="${TRUSTED_PATH}"'); + expect(step).toMatch(/unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH/); + for (const v of [ + 'GIT_SSL_NO_VERIFY', + 'GIT_SSL_CAINFO', + 'GIT_EXEC_PATH', + 'GIT_DIR', + 'GIT_WORK_TREE', + 'GIT_COMMON_DIR', + 'GIT_OBJECT_DIRECTORY', + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_SHALLOW_FILE', + 'GIT_ALLOW_PROTOCOL', + 'GIT_CONFIG_PARAMETERS', + 'GIT_PROXY_COMMAND', + 'GIT_SSH_COMMAND', + 'GIT_ASKPASS', + 'LD_PRELOAD', + 'LD_AUDIT', + 'LD_LIBRARY_PATH', + ]) { + expect(step).toMatch(new RegExp(`unset[\\s\\S]*?\\b${v}\\b`)); + } + } + // The three PAT hermetic preambles (both pushes AND Prepare) are + // identical (only their comment twin-names differ, stripped here): a + // hardening applied to one PAT git site but not the others re-opens the + // class on the stale side. Anchored from `export PATH` so the whole + // preamble — PATH pin, LD/env strip, mktemp redirect — is compared. + const patBlockOf = (step) => + step + .match( + /export PATH="\$\{TRUSTED_PATH\}"\n\s*unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH \\[\s\S]*?git config --file "\$\{GIT_CONFIG_GLOBAL\}" safe\.directory "\$\(pwd\)"/, + )?.[0] + .replace(/\s+/g, ' '); + expect(patBlockOf(publishPrStep)).toBeTruthy(); + expect(patBlockOf(pushAndReportStep)).toBe(patBlockOf(publishPrStep)); + expect(patBlockOf(prepareStep)).toBe(patBlockOf(publishPrStep)); + // Each PAT step carries the trusted-PATH env wiring. + for (const step of [publishPrStep, pushAndReportStep, prepareStep]) { + expect(step).toContain( + "TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}'", + ); + } + // The staging steps record the trusted PATH before any branch code runs. + expect(workflow.match(/trusted_path=\$\{PATH\}/g) ?? []).toHaveLength(2); + // gh's own env channels are pinned/stripped BEFORE the first gh call in + // each PAT step, so a $GITHUB_ENV-planted GH_HOST cannot reroute the + // identity check and a planted GH_TOKEN cannot outrank the inline one. + for (const [step, firstGh] of [ + [publishPrStep, 'GH_TOKEN="${GITHUB_TOKEN}" gh api user'], + [pushAndReportStep, 'GH_TOKEN="${GITHUB_TOKEN}" gh api user'], + [prepareStep, 'PR_LIVE="$(gh pr view'], + ]) { + const ghPin = step.indexOf('export GH_HOST=github.com'); + expect(ghPin).toBeGreaterThan(-1); + expect(step).toMatch(/unset GH_ENTERPRISE_TOKEN GH_TOKEN/); + // GH_CONFIG_DIR is PINNED to a fresh throwaway (unsetting it falls + // back to the attacker-writable ~/.config/gh with http_unix_socket). + expect(step).toContain( + 'export GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"', + ); + expect(step.indexOf(firstGh)).toBeGreaterThan(-1); + expect(ghPin).toBeLessThan(step.indexOf(firstGh)); + } + // The fork fetch and salvage fetch cannot recurse into a planted + // submodule and execute an ext:: URL with the PAT (env-level + // GIT_ALLOW_PROTOCOL is stripped; these pin the config level). + expect(pushAndReportStep).toContain( + '-c fetch.recurseSubmodules=false -c protocol.ext.allow=never', + ); + // The push refuses a HEAD that is not the gate's verified head — closes a + // repo redirect (planted .git/commondir / GIT_DIR) that would push an + // attacker tree. + expect(pushAndReportStep).toMatch( + /HEAD_NOW="\$\(git rev-parse HEAD\)"[\s\S]{0,400}!= "\$\{VERIFIED_HEAD\}"[\s\S]{0,200}refusing to push/, + ); + // And it pushes the exact verified OBJECT, not symbolic HEAD (which the + // push would re-resolve, re-opening the check-then-use race): PUSH_SHA + // is pinned to VERIFIED_HEAD under the guard and re-pinned to the merge + // result after each salvage merge. + expect(pushAndReportStep).toContain('PUSH_SHA="${VERIFIED_HEAD}"'); + expect(pushAndReportStep).toContain( + 'git_auth push --no-verify "${PUSH_URL}" "${PUSH_SHA}:refs/heads/${BRANCH}"', + ); + expect(pushAndReportStep).not.toMatch( + /git_auth push[^\n]*HEAD:"\$\{BRANCH\}"/, + ); + expect(pushAndReportStep).toMatch( + /PUSH_SHA="\$\(git rev-parse HEAD\)"[\s\S]{0,120}PRE_MERGE_HEAD/, + ); + // The gate runner is digest-verified before BOTH gate passes (the branch + // runs its own build/test between them), with PATH pinned first. + expect( + workflow.match( + /echo "\$\{VERIFY_RUNNER_SHA256\} {2}\$\{RUNNER_TEMP\}\/run-autofix-review-verification\.sh" \| sha256sum -c - > \/dev\/null/g, + ) ?? [], + ).toHaveLength(2); + expect( + workflow.match(/verify_runner_sha256=\$\(sha256sum /g) ?? [], + ).toHaveLength(1); + // resanitize defuses the repo-redirect FILES (.git/commondir/shallow). + const resanitizeScript = readFileSync( + '.github/scripts/resanitize-git-config.sh', + 'utf8', + ); + expect(resanitizeScript).toContain( + 'rm -f "${GIT_DIR_PATH}/commondir" "${GIT_DIR_PATH}/shallow"', + ); + // Both staging steps stage the script and record its digest. + expect( + workflow.match( + /cp \.github\/scripts\/resanitize-git-config\.sh "\$\{RUNNER_TEMP\}\/resanitize-git-config\.sh"/g, + ) ?? [], + ).toHaveLength(2); + expect( + workflow.match(/resanitize_sha256=\$\(sha256sum /g) ?? [], + ).toHaveLength(2); + // Every one-shot credential helper leads with an empty-helper reset: + // helpers run in config order and the FIRST to answer wins, so without + // the reset a helper planted at any earlier scope sees the request + // (and the env) before ours answers — probe-verified. http.sslVerify + // rides the same chain: a kept http.proxy plus a planted + // sslVerify=false would otherwise read the credential off the wire. + // Count equality pins a future push site to ship with both or fail. + const helperSites = + workflow.match(/-c credential\."https:\/\/github\.com"\.helper=/g) ?? []; + // Tolerant of the intermediate `-c` transport/protocol flags git_auth + // also carries between the sslVerify pin and the helper reset. + const resetSites = + workflow.match( + /-c http\.sslVerify=true (?:-c [^\n]*?)?-c credential\.helper= -c credential\."https:\/\/github\.com"\.helper=/g, + ) ?? []; + expect(helperSites).toHaveLength(3); + expect(resetSites).toHaveLength(helperSites.length); + // The fork fetch is PAT-bearing too (its step env carries the PAT) and + // is anonymous — a public repo's fork heads are public — so it leads + // with the helper-list reset + transport pin and never adds the PAT + // helper: a planted global extraheader must not 401 into a planted + // helper handing over the PAT. The bare fetch was the one network site + // the round-2 rollout skipped. + expect(prepareStep).toMatch( + /git -c http\.sslVerify=true -c credential\.helper= fetch "https:\/\/github\.com\/\$\{HEAD_REPO\}\.git"/, + ); + expect(prepareStep).not.toMatch( + /\n\s*if ! git fetch "https:\/\/github\.com\/\$\{HEAD_REPO\}\.git"/, + ); + // The push-race salvage merge is signing-proof: a global + // commit.gpgsign=true with no key on the runner would exit 128 and be + // misread as a content conflict, discarding a verified round. + expect(pushAndReportStep).toMatch( + /git -c commit\.gpgsign=false[\s\S]*?merge --no-edit FETCH_HEAD/, + ); + // Functional: run the staged script against a fixture repo with exec + // keys planted in LOCAL and WORKTREE config (what branch code can do + // between the job-start sanitize and the push) plus a polluted global + // file — the planted keys go, the allowlisted plumbing stays. + const dir = mkdtempSync(join(tmpdir(), 'autofix-resanitize-')); + const home = join(dir, 'home'); + mkdirSync(home, { recursive: true }); + writeFileSync( + join(home, '.gitconfig'), + '[diff]\n\texternal = global-driver\n', + ); + // A LIVE XDG global file too: git reads it for keys ~/.gitconfig does + // not define, and it is the file the incident host actually carries. + // Without it the script's second loop iteration runs against a + // nonexistent path and the XDG leg has no behavioural coverage + // (mutation: dropping the XDG file from the loop then stays green). + mkdirSync(join(home, '.config', 'git'), { recursive: true }); + writeFileSync( + join(home, '.config', 'git', 'config'), + '[core]\n\thooksPath = /tmp/xdg-evil\n', + ); + const repo = join(dir, 'repo'); + mkdirSync(repo); + execFileSync('git', ['init', '-q', repo]); + const env = { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: join(home, '.config'), + GIT_CONFIG_NOSYSTEM: '1', + }; + delete env['GIT_CONFIG_GLOBAL']; + const lgit = (...args) => + execFileSync('git', ['-C', repo, ...args], { env, encoding: 'utf8' }); + lgit('config', '--local', 'credential.helper', '!evil'); + lgit('config', '--local', 'core.fsmonitor', 'evil'); + lgit('config', '--local', 'remote.origin.url', 'https://github.com/o/r'); + // The worktree-config branch: extensions.worktreeConfig activates a + // second local file that `git config --local` neither lists nor unsets + // — the script must delete it, not merely sweep the local scope + // (mutation-tested: without this arm, deleting the `rm -f` line kept + // the whole suite green). + lgit('config', '--local', 'extensions.worktreeConfig', 'true'); + lgit('config', '--worktree', 'core.fsmonitor', 'evil-wt'); + const run = spawnSync( + 'bash', + [resolve('.github/scripts/resanitize-git-config.sh')], + { cwd: repo, env, encoding: 'utf8' }, + ); + expect(run.status).toBe(0); + expect(existsSync(join(repo, '.git', 'config.worktree'))).toBe(false); + const localKeys = lgit('config', '--local', '--name-only', '--list') + .trim() + .split('\n'); + expect(localKeys).not.toContain('credential.helper'); + expect(localKeys).not.toContain('core.fsmonitor'); + expect(localKeys).not.toContain('extensions.worktreeconfig'); + expect(localKeys).toContain('remote.origin.url'); + // Full-scope resolution: nothing plants back through any surviving file. + expect( + spawnSync('git', ['-C', repo, 'config', '--get', 'core.fsmonitor'], { + env, + encoding: 'utf8', + }).status, + ).not.toBe(0); + expect( + spawnSync('git', ['-C', repo, 'config', '--get', 'diff.external'], { + env, + encoding: 'utf8', + }).status, + ).not.toBe(0); + // The XDG-planted exec key is gone too — pins the script's two-file loop. + expect( + spawnSync('git', ['-C', repo, 'config', '--get', 'core.hooksPath'], { + env, + encoding: 'utf8', + }).stdout, + ).not.toContain('xdg-evil'); + rmSync(dir, { recursive: true, force: true }); + }); + + it('runs both verification gates under a throwaway global git config', () => { + // Same incident, the gate-side guard: the gates re-run branch tests on + // the HOST, so runner ~/.gitconfig pollution failed tests the branch + // never caused, the rejection charged the round (package tests are + // A/B-exempt), and an 18-minute repair burned on a failure no repair + // can reach. Both gates redirect global config to a throwaway file so + // every child — vitest fixture repos included — is hermetic to the + // host, and a branch-authored `git config --global` dies with the run + // instead of poisoning the next one. + for (const gate of verificationGateBodies) { + const globalRedirect = gate.indexOf( + 'export GIT_CONFIG_GLOBAL="${RUNNER_TEMP}/autofix-gate-gitconfig"', + ); + expect(gate).toContain('export GIT_CONFIG_SYSTEM=/dev/null'); + expect(globalRedirect).toBeGreaterThan(-1); + // Truncated per run: the repair leg must not inherit writes the first + // gate run's branch tests made into the throwaway file. + expect(gate).toContain(': > "${GIT_CONFIG_GLOBAL}"'); + // Seeded with the workspace safe.directory the redirect just hid + // (actions/checkout wrote it into the real global config). + expect(gate.indexOf('safe.directory "$(pwd)"')).toBeGreaterThan( + globalRedirect, + ); + // Before the deterministic checks, so they all see the redirect. + expect(globalRedirect).toBeLessThan(gate.indexOf('npm run build')); + // GITHUB_ENV-injected git env knobs outrank BOTH redirects — each + // gate zeroes GIT_CONFIG_COUNT and strips the transport/exec channels. + expect(gate).toContain('export GIT_CONFIG_COUNT=0'); + for (const v of ['GIT_SSL_NO_VERIFY', 'GIT_EXEC_PATH', 'GIT_DIR']) { + expect(gate).toMatch(new RegExp(`unset[\\s\\S]*\\b${v}\\b`)); + } + } + // The two gate env+redirect blocks are one hardening surface — pin them + // equal (whitespace-normalized; the shell copy and the YAML copy differ + // only in indentation) so a channel added to one but not the other + // cannot ship green, exactly as the three job-start scrub copies are + // pinned byte-identical. + const gateBlockOf = (body) => + body + .match( + /unset GIT_CONFIG_PARAMETERS[\s\S]*?git config --file "\$\{GIT_CONFIG_GLOBAL\}" safe\.directory "\$\(pwd\)"/, + )?.[0] + .replace(/\s+/g, ' '); + expect(gateBlockOf(reviewVerificationRunner)).toBeTruthy(); + expect(gateBlockOf(verificationGateSteps[0] ?? '')).toBe( + gateBlockOf(reviewVerificationRunner), + ); + // Before the FIRST git command in each gate, not merely before the + // checks: the committed-ref probe and dirty-tree asserts must live in + // the same config universe as everything after them. + expect( + reviewVerificationRunner.indexOf('export GIT_CONFIG_SYSTEM=/dev/null'), + ).toBeLessThan( + reviewVerificationRunner.indexOf('git diff --quiet "origin/${BRANCH}'), + ); + const issueGate = verificationGateSteps[0] ?? ''; + expect( + issueGate.indexOf('export GIT_CONFIG_SYSTEM=/dev/null'), + ).toBeLessThan(issueGate.indexOf('git status --porcelain')); + // Functional, not just positional: execute the extracted redirect + // block under a hostile HOME (a polluted ~/.gitconfig) AND a hostile + // env channel (GIT_CONFIG_COUNT-planted key). After the block, a child + // git must see neither, and a `git config --global` write must land in + // the throwaway file — the block can no longer be reverted or hollowed + // out while a string-presence test stays green. + const redirectBlock = reviewVerificationRunner.match( + /unset GIT_CONFIG_PARAMETERS[\s\S]*?safe\.directory "\$\(pwd\)"/, + )?.[0]; + expect(redirectBlock).toBeTruthy(); + const dir = mkdtempSync(join(tmpdir(), 'autofix-gate-redirect-')); + const home = join(dir, 'home'); + const temp = join(dir, 'temp'); + mkdirSync(home, { recursive: true }); + mkdirSync(temp, { recursive: true }); + writeFileSync( + join(home, '.gitconfig'), + '[diff]\n\texternal = global-driver\n', + ); + const env = { + ...process.env, + HOME: home, + RUNNER_TEMP: temp, + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.fsmonitor', + GIT_CONFIG_VALUE_0: 'evil', + }; + delete env['GIT_CONFIG_GLOBAL']; + const probe = spawnSync( + 'bash', + [ + '-c', + `${redirectBlock}\n` + + 'git config --get diff.external && exit 7\n' + + 'git config --get core.fsmonitor && exit 8\n' + + 'git config --global qwen.probe ok\n' + + 'git config --global --get qwen.probe', + ], + { cwd: dir, env, encoding: 'utf8' }, + ); + expect(probe.status).toBe(0); + expect(probe.stdout.trim().endsWith('ok')).toBe(true); + // The write above landed in the throwaway file, not the hostile HOME. + expect(readFileSync(join(home, '.gitconfig'), 'utf8')).not.toContain( + 'qwen', + ); + rmSync(dir, { recursive: true, force: true }); + }); + it('never invokes a local action before checkout', () => { // A `uses: './...'` local action resolves from $GITHUB_WORKSPACE, so // it only exists after a checkout — before one it fails on a clean @@ -8136,7 +8716,7 @@ exit 1 // git push twice more and the salvage legs execute against a branch // that was already pushed. expect(pushAndReportStep).toMatch( - /if git_auth push --no-verify "\$\{PUSH_URL\}" HEAD:"\$\{BRANCH\}"; then\n\s+break/, + /if git_auth push --no-verify "\$\{PUSH_URL\}" "\$\{PUSH_SHA\}:refs\/heads\/\$\{BRANCH\}"; then\n\s+break/, ); // BOTH push-URL constructions stay pinned — the fork one is pinned by // the fork-plumbing test, and the same-repo one lost its old @@ -8162,10 +8742,10 @@ exit 1 // date") and must NOT tell the reviewer to re-check commits that // never existed. expect(pushAndReportStep).toMatch( - /PRE_MERGE_HEAD="\$\(git rev-parse HEAD\)"\n\s+if ! git -c user\.name=/, + /PRE_MERGE_HEAD="\$\(git rev-parse HEAD\)"\n[\s\S]{0,600}if ! git -c commit\.gpgsign=false \\\n\s+-c user\.name=/, ); expect(pushAndReportStep).toMatch( - /if \[\[ "\$\(git rev-parse HEAD\)" != "\$\{PRE_MERGE_HEAD\}" \]\]; then\n\s+PUSH_RACE_MERGED='true'/, + /PUSH_SHA="\$\(git rev-parse HEAD\)"\n\s+if \[\[ "\$\{PUSH_SHA\}" != "\$\{PRE_MERGE_HEAD\}" \]\]; then\n\s+PUSH_RACE_MERGED='true'/, ); // Merge, never rebase: the agent's own conflict-resolution rounds create // merge commits, and a rebase would flatten them and can silently @@ -8228,7 +8808,7 @@ exit 1 // Same anchor as the dry-run: the publish push must carry the // host-scoped `git -c credential…` prefix, not a bare `git push`. expect(publishPrStep).toMatch( - /git -c credential\."https:\/\/github\.com"\.helper=[^\n]*\n\s+push --no-verify "https:\/\/github\.com\/\$\{REPO\}\.git"/, + /git -c http\.sslVerify=true -c credential\.helper= -c credential\."https:\/\/github\.com"\.helper=[^\n]*\n\s+push --no-verify "https:\/\/github\.com\/\$\{REPO\}\.git"/, ); // Neither PAT push may expose the token — not persisted to .git/config // (a `git remote set-url`) and not in the process argv (a token-bearing @@ -8251,7 +8831,7 @@ exit 1 'git config --local credential.helper', ); expect(pushAndReportStep).toContain( - 'git_auth push --no-verify "${PUSH_URL}" HEAD:"${BRANCH}"', + 'git_auth push --no-verify "${PUSH_URL}" "${PUSH_SHA}:refs/heads/${BRANCH}"', ); // Five sites now: both PAT pushes, the PAT-bearing prepare checkout, // AND both no-secret verification checkouts (convention: every host @@ -8274,10 +8854,10 @@ exit 1 // hence the wider windows — the assertions are about order, and one // hooksPath site genuinely covers both arms of the if. expect(workflow).toMatch( - /git config core\.hooksPath \/dev\/null\n[\s\S]{0,900}git checkout -B "\$\{BRANCH\}" FETCH_HEAD/, + /git config core\.hooksPath \/dev\/null\n[\s\S]{0,1400}git checkout -B "\$\{BRANCH\}" FETCH_HEAD/, ); expect(workflow).toMatch( - /git config core\.hooksPath \/dev\/null\n[\s\S]{0,2200}git checkout -B "\$\{BRANCH\}" "origin\/\$\{BRANCH\}"/, + /git config core\.hooksPath \/dev\/null\n[\s\S]{0,3000}git checkout -B "\$\{BRANCH\}" "origin\/\$\{BRANCH\}"/, ); // The agent step re-points hooks to .husky BEFORE invoking the runner. // Assert the ordering directly (not a fixed-width window) so adding a