From 4bfa081cb2d5e94d2bf307d3819fcf1cd1bc3635 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 15 Aug 2026 16:54:16 +0800 Subject: [PATCH 01/11] feat(autofix): run the verification gate in an ephemeral container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1+2 of #9089. The gate executes the branch's OWN build/test; run on the host, that code shares the OS user, $HOME, $GITHUB_ENV and $GITHUB_OUTPUT with the same job's later PAT-bearing steps. Several channels fire before any in-step guard can run — BASH_ENV/BASH_FUNC_* and LD_PRELOAD/LD_AUDIT are applied by the shell/loader at startup (the runner's $GITHUB_ENV blocklist is NODE_OPTIONS-only), and a forged `outcome=fixed` appended to $GITHUB_OUTPUT beats any digest check on the gate script's bytes. The trust boundary cannot be a job boundary: a job that executed attacker code cannot vouch for what it emits, and a second job that re-verifies would have to run that code itself. So the boundary is the container wall and the PAT never crosses it. - The gate now runs via a staged, digest-verified wrapper that invokes it inside an ephemeral container (the sandbox image the agent already uses, resolved from the resolve step's OUTPUT rather than $GITHUB_ENV so branch code cannot choose it). docker does not inherit the host environment, so the PAT, $GITHUB_ENV and the real $GITHUB_OUTPUT are absent inside; $HOME is a throwaway and only three paths are mounted (workspace, the round's workdir, a copy-staged container temp). The real RUNNER_TEMP — staged agent runner, the PAT steps' throwaway configs — is never mounted. - The verdict crosses back as a host-created file plus the container EXIT CODE. Branch code can append to the mounted file, but it cannot make a failing gate exit 0, so a pass is accepted only on exit 0; exit 1 forces `failed` regardless of the file; any other code leaves the outcome unset for the existing gate-crash retry path. - Phase 2 pins the durable property structurally: a contract test fails if any step whose env carries CI_DEV_BOT_PAT invokes branch-authored code (gate script, agent runner, npm/npx) outside a container. The one recorded exception is issue triage, which runs the agent before any branch is checked out, so its working tree is still the trusted base. Probed against the real wrapper and the real sandbox image family: inside the container the PAT, $GITHUB_ENV and the host RUNNER_TEMP are all unreachable; a forged `outcome=fixed` on a gate that exits 1 lands on the host as `outcome=failed`; a verdict-less exit 0 and a docker failure both leave the outcome unset. Contract suite 174/174. Not in scope: the issue-autofix job's inline gate (same class, agent- authored branch rather than an external PR) and Phase 3 (per-job ephemeral runners), both of which stay tracked in #9089. --- .github/scripts/resolve-sandbox-image.mjs | 6 + .github/scripts/run-autofix-gate-container.sh | 132 +++++++++++++++ .github/workflows/qwen-autofix.yml | 52 ++++-- scripts/tests/qwen-autofix-workflow.test.js | 157 +++++++++++++++++- 4 files changed, 329 insertions(+), 18 deletions(-) create mode 100755 .github/scripts/run-autofix-gate-container.sh diff --git a/.github/scripts/resolve-sandbox-image.mjs b/.github/scripts/resolve-sandbox-image.mjs index 4d02ad1e8bb..b14ce6549cd 100644 --- a/.github/scripts/resolve-sandbox-image.mjs +++ b/.github/scripts/resolve-sandbox-image.mjs @@ -113,6 +113,12 @@ function exportImage(image) { if (process.env.GITHUB_ENV) { appendFileSync(process.env.GITHUB_ENV, `QWEN_SANDBOX_IMAGE=${image}\n`); } + // Also as a step OUTPUT: $GITHUB_ENV is a file later steps can append to, + // so a consumer that must not be steered by branch code (the verification + // gate's container image) reads the expression-context value instead. + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `image=${image}\n`); + } console.log(`QWEN_SANDBOX_IMAGE=${image}`); } diff --git a/.github/scripts/run-autofix-gate-container.sh b/.github/scripts/run-autofix-gate-container.sh new file mode 100755 index 00000000000..7e8a988f3f0 --- /dev/null +++ b/.github/scripts/run-autofix-gate-container.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +set -uo pipefail + +# Runs the deterministic verification gate inside an EPHEMERAL CONTAINER, and +# translates its verdict back to the host. +# +# WHY (#9089): the gate executes the branch's OWN build/test. Run on the host, +# that code shares the OS user, $HOME, $GITHUB_ENV and $GITHUB_OUTPUT with the +# later PAT-bearing steps of the same job, and several channels fire before any +# in-step guard can run — `BASH_ENV`/`BASH_FUNC_*` and `LD_PRELOAD`/`LD_AUDIT` +# are applied by the shell/loader at startup (the runner's $GITHUB_ENV +# blocklist is NODE_OPTIONS-only), and a forged `outcome=fixed` appended to the +# runner-provided $GITHUB_OUTPUT beats any digest check on the gate script's +# bytes. In a container the branch code reaches none of them: docker does not +# inherit the host environment, so there is no PAT, no $GITHUB_ENV and no host +# $GITHUB_OUTPUT inside, $HOME is a throwaway, and only three paths are mounted. +# +# The trust boundary is the container wall, NOT a job boundary: a job that has +# executed attacker code cannot vouch for anything it emits, so the PAT must +# never be in the same execution context as branch code. What crosses back is +# a host-created verdict file plus the container's EXIT CODE — the exit code +# is the unforgeable half (branch code can append to the mounted verdict file, +# but it cannot make a failing gate exit 0), so a passing verdict is accepted +# only on exit 0. +# +# Invoked as a child `bash` from the host verify step, which digest-verifies +# both this script and the gate script first. Inherits its environment from +# that caller: WORKDIR and BRANCH are job-level env, RUNNER_TEMP and +# GITHUB_WORKSPACE/GITHUB_OUTPUT are runner-provided, GATE_IMAGE and +# FOOTPRINT_ENFORCE are step-level env. None is defined here. + +GATE_SCRIPT="${RUNNER_TEMP}/run-autofix-review-verification.sh" +VERDICT="${WORKDIR}/gate-verdict" +# The container's own RUNNER_TEMP: a fresh directory holding COPIES of just +# the scripts the gate reads. The real RUNNER_TEMP is never mounted — it holds +# the staged agent runner and the throwaway git/gh configs the PAT-bearing +# steps use, none of which the gate needs and none of which branch code may +# reach or tamper with. +CTEMP="${RUNNER_TEMP}/gate-container-temp" + +if [[ -z "${GATE_IMAGE:-}" ]]; then + echo "::error::GATE_IMAGE is empty — the sandbox image did not resolve; refusing to run the gate on the host." + exit 125 +fi + +: > "${VERDICT}" || { + echo "::error::could not create the gate verdict file at ${VERDICT}" + exit 125 +} +rm -rf "${CTEMP}" +mkdir -p "${CTEMP}" || exit 125 +for staged in run-autofix-review-verification.sh check-settings-schema.sh \ + check-autofix-contracts.sh resolve-owning-packages.sh; do + cp "${RUNNER_TEMP}/${staged}" "${CTEMP}/${staged}" || { + echo "::error::could not stage ${staged} for the gate container" + exit 125 + } +done + +# --user: the workspace is bind-mounted, so container writes (dist/, vitest +# caches, the gate log) must land as the runner user or the next steps hit +# root-owned files — the same failure the job's ownership-restore step exists +# for. --env is an explicit allowlist: anything not named here is simply +# absent inside, which is the whole point. +docker run --rm \ + --user "$(id -u):$(id -g)" \ + --workdir "${GITHUB_WORKSPACE}" \ + --volume "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ + --volume "${WORKDIR}:${WORKDIR}" \ + --volume "${CTEMP}:${CTEMP}" \ + --env HOME="${CTEMP}" \ + --env BRANCH="${BRANCH}" \ + --env WORKDIR="${WORKDIR}" \ + --env RUNNER_TEMP="${CTEMP}" \ + --env GITHUB_OUTPUT="${VERDICT}" \ + --env FOOTPRINT_ENFORCE="${FOOTPRINT_ENFORCE:-advisory}" \ + "${GATE_IMAGE}" \ + bash "${CTEMP}/run-autofix-review-verification.sh" +GATE_RC=$? + +echo "🧱 gate container exited ${GATE_RC}" + +# Verdict translation. Only these keys are forwarded, last value wins (the +# gate appends its final verdict last), and `outcome` is gated on the exit +# code — the file alone is not authority. +verdict_value() { + grep -E "^${1}=" "${VERDICT}" 2> /dev/null | tail -n 1 | cut -d= -f2- +} +OUTCOME="$(verdict_value outcome)" +COMMITTED="$(verdict_value committed)" +RETRYABLE="$(verdict_value retryable)" +PREEXISTING="$(verdict_value preexisting)" +VERIFIED_HEAD="$(verdict_value verified_head)" + +# committed= is a ref-only fact the gate records before any check runs; the +# failure handoff keys its "was NOT pushed" wording on it, so forward it on +# every path (a forged value only changes report wording, never a push). +[[ "${COMMITTED}" == 'true' ]] && echo "committed=true" >> "${GITHUB_OUTPUT}" + +case "${GATE_RC}" in + 0) + # A pass must ALSO be a pass in the file: an exit-0 container whose + # verdict says anything else (or nothing) is a gate that did not reach a + # verdict, which is the crash path, not a silent success. + if [[ "${OUTCOME}" == 'fixed' || "${OUTCOME}" == 'noop' ]]; then + echo "outcome=${OUTCOME}" >> "${GITHUB_OUTPUT}" + [[ -n "${VERIFIED_HEAD}" ]] && echo "verified_head=${VERIFIED_HEAD}" >> "${GITHUB_OUTPUT}" + else + echo "::warning::gate container exited 0 without a verdict (outcome='${OUTCOME}') — treating as a gate crash so the next scan retries." + fi + ;; + 1) + # The gate's own deterministic rejection: reject_fix always exits 1. + # Forced to failed regardless of the file, so a forged `outcome=fixed` + # cannot survive a red gate. The routing flags are forwarded because both + # branches they select (same-run repair, base-update handoff) stop short + # of a push. + echo "outcome=failed" >> "${GITHUB_OUTPUT}" + [[ "${PREEXISTING}" == 'true' ]] && echo "preexisting=true" >> "${GITHUB_OUTPUT}" + [[ "${RETRYABLE}" == 'true' ]] && echo "retryable=true" >> "${GITHUB_OUTPUT}" + ;; + *) + # Docker itself failed (125/126/127), the container was killed (137), or + # the gate died before reaching a verdict. Leave outcome UNSET: an + # EVALUATED rejection advances the watermark and hands the item off for + # good, while an empty outcome takes 'Finalize verification's gate-crashed + # path and retries on the next scan's fresh checkout. + echo "::warning::gate container exited ${GATE_RC} without a deterministic verdict — reporting as a gate crash." + ;; +esac + +exit "${GATE_RC}" diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index f60f052e8f9..9bf09518526 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -3970,6 +3970,7 @@ jobs: 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/run-autofix-gate-container.sh "${RUNNER_TEMP}/run-autofix-gate-container.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 @@ -3984,6 +3985,7 @@ jobs: # 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 "gate_container_sha256=$(sha256sum "${RUNNER_TEMP}/run-autofix-gate-container.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 @@ -4122,6 +4124,12 @@ jobs: qwen --version - name: 'Resolve sandbox image' + # id + step output: the verification gate runs in this image, and it + # must not be steerable by branch code. This step runs BEFORE prepare + # checks out the PR branch, so the package.json it reads is still the + # trusted base, and the resolved name rides the expression context + # rather than $GITHUB_ENV (which a later step can append to). + id: 'sandbox' run: |- node .github/scripts/resolve-sandbox-image.mjs \ "$(node -p "require('./package.json').config.sandboxImageUri")" @@ -5228,21 +5236,29 @@ jobs: env: TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' VERIFY_RUNNER_SHA256: '${{ steps.stage.outputs.verify_runner_sha256 }}' + GATE_CONTAINER_SHA256: '${{ steps.stage.outputs.gate_container_sha256 }}' + # The image the gate runs in, from the resolve step's OUTPUT rather + # than $GITHUB_ENV: branch code must not be able to choose it. + GATE_IMAGE: '${{ steps.sandbox.outputs.image }}' # Step-level env outranks $GITHUB_ENV: an earlier shell-capable # step (the agent runs branch code on the host) must not be able # to downgrade a repo-variable 'reject' back to 'advisory'. FOOTPRINT_ENFORCE: "${{ vars.QWEN_AUTOFIX_FOOTPRINT_ENFORCE || 'advisory' }}" 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. + # The gate decides whether the PAT push runs, and it executes the + # branch's OWN build/test — so it runs inside an ephemeral container + # (#9089), never on this host: the PAT, $GITHUB_ENV and the real + # $GITHUB_OUTPUT are simply absent inside, so BASH_ENV/LD_PRELOAD + # and a forged verdict line have nothing to reach. Both staged + # scripts are digest-verified first (digests recorded in + # GITHUB_OUTPUT, unreachable from a disk write), with PATH pinned + # and the preload channels dropped so the sha256sum/bash doing the + # verifying cannot themselves be swapped. 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" + echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - > /dev/null + bash "${RUNNER_TEMP}/run-autofix-gate-container.sh" - name: 'Repair deterministic rejection' id: 'repair' @@ -5351,21 +5367,29 @@ jobs: env: TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' VERIFY_RUNNER_SHA256: '${{ steps.stage.outputs.verify_runner_sha256 }}' + GATE_CONTAINER_SHA256: '${{ steps.stage.outputs.gate_container_sha256 }}' + # The image the gate runs in, from the resolve step's OUTPUT rather + # than $GITHUB_ENV: branch code must not be able to choose it. + GATE_IMAGE: '${{ steps.sandbox.outputs.image }}' # Step-level env outranks $GITHUB_ENV: an earlier shell-capable # step (the agent runs branch code on the host) must not be able # to downgrade a repo-variable 'reject' back to 'advisory'. FOOTPRINT_ENFORCE: "${{ vars.QWEN_AUTOFIX_FOOTPRINT_ENFORCE || 'advisory' }}" 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. + # The gate decides whether the PAT push runs, and it executes the + # branch's OWN build/test — so it runs inside an ephemeral container + # (#9089), never on this host: the PAT, $GITHUB_ENV and the real + # $GITHUB_OUTPUT are simply absent inside, so BASH_ENV/LD_PRELOAD + # and a forged verdict line have nothing to reach. Both staged + # scripts are digest-verified first (digests recorded in + # GITHUB_OUTPUT, unreachable from a disk write), with PATH pinned + # and the preload channels dropped so the sha256sum/bash doing the + # verifying cannot themselves be swapped. 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" + echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - > /dev/null + bash "${RUNNER_TEMP}/run-autofix-gate-container.sh" - name: 'Finalize verification' id: 'final_verify' diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 201607c998f..153de01f7ad 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -6220,6 +6220,155 @@ exit 1 expect(skill).toContain('Deferred non-Critical feedback'); }); + it('runs the verification gate in an ephemeral container that cannot reach the PAT or the host verdict file', () => { + // #9089: the gate executes the branch's OWN build/test. On the host that + // code shares the OS user, $HOME, $GITHUB_ENV and $GITHUB_OUTPUT with the + // later PAT-bearing steps, and BASH_ENV/LD_PRELOAD fire before any in-step + // guard. In a container none of that is reachable — docker does not + // inherit the host environment, so the isolation is by construction. + const wrapper = readFileSync( + '.github/scripts/run-autofix-gate-container.sh', + 'utf8', + ); + // Both gate steps invoke the WRAPPER, never the gate script directly, and + // digest-verify both staged scripts before executing either. + for (const gate of [verificationGateSteps[1], repairVerificationGateStep]) { + expect(gate).toContain( + 'bash "${RUNNER_TEMP}/run-autofix-gate-container.sh"', + ); + expect(gate).not.toMatch( + /bash "\$\{RUNNER_TEMP\}\/run-autofix-review-verification\.sh"/, + ); + expect(gate).toContain( + 'echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - > /dev/null', + ); + // The image comes from the resolve step's OUTPUT (expression context), + // not $GITHUB_ENV, which an earlier step can append to. + expect(gate).toContain( + "GATE_IMAGE: '${{ steps.sandbox.outputs.image }}'", + ); + expect(gate).not.toContain('QWEN_SANDBOX_IMAGE'); + } + expect(workflow).toContain( + "- name: 'Resolve sandbox image'\n # id + step output", + ); + expect(workflow).toContain( + 'cp .github/scripts/run-autofix-gate-container.sh "${RUNNER_TEMP}/run-autofix-gate-container.sh"', + ); + expect(workflow).toContain('echo "gate_container_sha256=$(sha256sum '); + // The container env is an explicit ALLOWLIST: everything unnamed is + // absent inside, which is the isolation. The PAT, the host $GITHUB_ENV + // and the runner's real $GITHUB_OUTPUT must never appear in it. + const dockerRun = + wrapper.match(/docker run --rm[\s\S]*?"\$\{GATE_IMAGE\}"/)?.[0] ?? ''; + expect(dockerRun).toBeTruthy(); + const passedEnv = [...dockerRun.matchAll(/--env ([A-Z_]+)=/g)].map( + (m) => m[1], + ); + expect(passedEnv.sort()).toEqual([ + 'BRANCH', + 'FOOTPRINT_ENFORCE', + 'GITHUB_OUTPUT', + 'HOME', + 'RUNNER_TEMP', + 'WORKDIR', + ]); + // …and the two it does pass by those names are REDIRECTED, not the host's. + expect(dockerRun).toContain('--env GITHUB_OUTPUT="${VERDICT}"'); + expect(dockerRun).toContain('--env RUNNER_TEMP="${CTEMP}"'); + expect(dockerRun).toContain('--env HOME="${CTEMP}"'); + expect(dockerRun).not.toContain('CI_DEV_BOT_PAT'); + expect(dockerRun).not.toContain('GITHUB_ENV'); + // Only three paths are mounted: the workspace, the round's workdir, and + // the container's own staging copy. The real RUNNER_TEMP (staged agent + // runner, the PAT steps' throwaway configs) is never mounted. + const mounts = [...dockerRun.matchAll(/--volume "([^:]+):/g)].map( + (m) => m[1], + ); + expect(mounts.sort()).toEqual([ + '${CTEMP}', + '${GITHUB_WORKSPACE}', + '${WORKDIR}', + ]); + expect(dockerRun).toContain('--user "$(id -u):$(id -g)"'); + expect(dockerRun).toContain('--rm'); + + // Behavioural: the verdict translation is the security-critical half — + // branch code inside the container CAN append to the mounted verdict + // file, so a pass is accepted only on exit 0. Extract the translation and + // drive it with a controlled exit code + verdict (no docker needed). + const translate = wrapper.match(/verdict_value\(\) \{[\s\S]*?\nesac/)?.[0]; + expect(translate).toBeTruthy(); + // The translation leans on `[[ … ]] && echo` guards, which return 1 when + // the flag is absent — under `set -e` that would abort the translation + // half-written. The wrapper must stay errexit-free and end on its own + // explicit `exit "${GATE_RC}"` (the harness below appends `exit 0` for + // the same reason: it runs the fragment, not the whole script). + expect(wrapper).toContain('set -uo pipefail'); + expect(wrapper).not.toMatch(/^set -e/m); + expect(wrapper.trimEnd().endsWith('exit "${GATE_RC}"')).toBe(true); + const dir = mkdtempSync(join(tmpdir(), 'autofix-gate-verdict-')); + const runTranslate = (rc, verdictLines) => { + const verdict = join(dir, 'verdict'); + const out = join(dir, 'gh-output'); + writeFileSync(verdict, verdictLines.join('\n') + '\n'); + writeFileSync(out, ''); + execFileSync( + 'bash', + [ + '-c', + `set -uo pipefail\nVERDICT='${verdict}'\nGITHUB_OUTPUT='${out}'\nGATE_RC=${rc}\n${translate}\nexit 0`, + ], + { encoding: 'utf8' }, + ); + return readFileSync(out, 'utf8').trim(); + }; + // A genuine pass carries through. + expect( + runTranslate(0, ['committed=true', 'outcome=fixed', 'verified_head=abc']), + ).toBe('committed=true\noutcome=fixed\nverified_head=abc'); + // A FORGED pass on a failing gate is refused: exit 1 forces failed, and + // the routing flags (which only pick repair vs handoff, never a push) + // carry through. This is the R5-5 forgery the container closes. + expect( + runTranslate(1, ['outcome=fixed', 'outcome=failed', 'retryable=true']), + ).toBe('outcome=failed\nretryable=true'); + // Even a verdict file that ONLY claims a pass cannot survive exit 1. + expect(runTranslate(1, ['outcome=fixed'])).toBe('outcome=failed'); + // Exit 0 with no verdict is a crash, not a silent success: outcome stays + // unset so 'Finalize verification' retries on the next scan. + expect(runTranslate(0, [])).toBe(''); + // A docker/infra failure (125) leaves outcome unset too. + expect(runTranslate(125, ['outcome=fixed'])).toBe(''); + rmSync(dir, { recursive: true, force: true }); + }); + + it('keeps branch-authored code out of every PAT-bearing step (trust-boundary invariant)', () => { + // Phase 2 of #9089: the durable property is not "the gate is hardened" but + // "the PAT is never in the same execution context as branch code". Assert + // it structurally, so a future step that adds a build/test next to the PAT + // fails here instead of silently re-opening the class. + const steps = workflow.split(/\n {6}- name: /).slice(1); + const branchCode = + /bash "\$\{RUNNER_TEMP\}\/run-autofix-review-verification\.sh"|run-agent\.mjs|\bnpm (run|ci|test)\b|\bnpx /; + const offenders = []; + for (const step of steps) { + if (!step.includes('CI_DEV_BOT_PAT')) continue; + const name = step.split('\n')[0].replace(/'/g, '').trim(); + const code = step + .split('\n') + .filter((l) => !l.trim().startsWith('#')) + .join('\n'); + if (branchCode.test(code)) offenders.push(name); + } + // The ONE recorded exception: issue triage runs the agent while holding + // the PAT, but it runs BEFORE any branch is checked out — the working + // tree is still the trusted base, so the code it executes is not + // branch-authored. Listed explicitly so a NEW agent/build invocation in a + // PAT step shows up as a failure rather than joining a silent allowlist. + expect(offenders).toEqual(['Assess candidates']); + }); + it('escalates to a maintainer-decision handoff when the diff keeps growing past budget (non-convergence)', () => { // Critical-only only trims non-Criticals, so a Critical-driven diff keeps // growing anyway. The divergence detector reads this window's prior @@ -8892,7 +9041,7 @@ exit 1 ).toBeLessThan(reviewVerifyGate.indexOf('outcome=noop')); const reviewVerificationGateStep = verificationGateSteps[1]; expect(reviewVerificationGateStep).toContain( - 'bash "${RUNNER_TEMP}/run-autofix-review-verification.sh"', + 'bash "${RUNNER_TEMP}/run-autofix-gate-container.sh"', ); expect(reviewVerificationGateStep).not.toContain('npm run build'); expect(reviewVerificationGateStep).not.toContain( @@ -10774,7 +10923,7 @@ exit 1 "if: |-\n ${{ always() && steps.prepare.outputs.stale != 'true' }}", ); expect(reviewVerificationGateStep).toContain( - 'bash "${RUNNER_TEMP}/run-autofix-review-verification.sh"', + 'bash "${RUNNER_TEMP}/run-autofix-gate-container.sh"', ); expect(reviewVerificationRunner).toContain('failure.md'); expect(reviewVerificationRunner).toContain('outcome=failed'); @@ -10845,7 +10994,7 @@ exit 1 "steps.repair.outputs.attempted == 'true'", ); expect(repairVerificationGateStep).toContain( - 'bash "${RUNNER_TEMP}/run-autofix-review-verification.sh"', + 'bash "${RUNNER_TEMP}/run-autofix-gate-container.sh"', ); expect( reviewVerificationRunner.match(/retryable=true/g) ?? [], @@ -13004,7 +13153,7 @@ exit 1 const stepBlocks = jobBlock.split(/\n {6}- name: /).slice(1); const longSteps = stepBlocks.filter((b) => - /node [^\n]*run-agent\.mjs|bash [^\n]*run-autofix-review-verification\.sh/.test( + /node [^\n]*run-agent\.mjs|bash [^\n]*run-autofix-gate-container\.sh/.test( b, ), ); From 576995bedde4cc1d77965f4da6db4e6984ee2fd0 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 15 Aug 2026 22:47:15 +0800 Subject: [PATCH 02/11] fix(autofix): harden the gate container against the review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All seven Criticals from the round-1 review: - The staged scripts are now mounted READ-ONLY, with a separate writable scratch mount for what the gate legitimately writes (its throwaway git config, via a new GATE_TMPDIR that defaults to RUNNER_TEMP on host runs). bash reads a script incrementally, so a writable copy of the running gate let the branch code the gate itself executes rewrite its own remaining bytes — the exit code was no longer unforgeable. - Exit 1 no longer synthesizes `outcome=failed`. The gate has deliberate exit-1-WITHOUT-verdict paths (baseline-A/B and bite tree-restore failures) where an evaluated rejection would advance the watermark and hand the item off for good; `failed` is now taken from the verdict file only. Forgery protection is unchanged: `fixed` is accepted only on exit 0, so a forged pass on a failing gate leaves the outcome unset and the round retries. - The container is named `qwen-code-gate-*` (so the pool's existing stale-container janitors can see it) and torn down by a trap: `--rm` only fires on a normal exit, but a step timeout / job cap / cancel kills the docker client and left an orphan running branch code as the runner uid with rw mounts on the shared workspace. - `CI=true` is passed: Actions sets it on every host step, and without it the branch's own suites take their non-CI path (18 TUI tests suppressed on CI as timing-flaky would run, and a flake is charged to the round). - The wrapper is added to the gate's `autofix-loop` sensitive class; it was falling through to the broader `ci-scripts` class, so any PR touching `.github/scripts/*` licensed a round to rewrite the referee. - The re-anchor finding is the withdrawn half of #9192, merged in here so this PR's diff no longer carries it. - `scripts/tests/package-scripts.test.js` asserted the old direct invocation — the red CI check at the reviewed SHA, and my miss for running only one suite locally. Re-probed against the real wrapper and sandbox image: the running gate script is read-only to the code it executes, the scratch mount is writable, CI/GATE_TMPDIR arrive, the PAT does not, no container is left behind, and all four verdict arms behave (genuine pass → fixed; genuine rejection → failed+flags; forged pass on a failing gate → unset/retry; exit 1 with no verdict → unset/retry). Full scripts/tests suite green, including package-scripts. --- .github/scripts/run-autofix-gate-container.sh | 55 ++++++++++++++----- .../run-autofix-review-verification.sh | 9 ++- .github/workflows/qwen-autofix.yml | 7 ++- scripts/tests/package-scripts.test.js | 4 +- scripts/tests/qwen-autofix-workflow.test.js | 52 ++++++++++++++---- 5 files changed, 98 insertions(+), 29 deletions(-) diff --git a/.github/scripts/run-autofix-gate-container.sh b/.github/scripts/run-autofix-gate-container.sh index 7e8a988f3f0..d62e45339df 100755 --- a/.github/scripts/run-autofix-gate-container.sh +++ b/.github/scripts/run-autofix-gate-container.sh @@ -37,6 +37,21 @@ VERDICT="${WORKDIR}/gate-verdict" # steps use, none of which the gate needs and none of which branch code may # reach or tamper with. CTEMP="${RUNNER_TEMP}/gate-container-temp" +# Split in two: the staged scripts are mounted READ-ONLY (bash reads a script +# incrementally, so a writable copy of the running gate would let the branch +# code the gate itself executes rewrite its own remaining bytes and hand back a +# forged pass — the exit code would no longer be unforgeable), and a separate +# scratch mount carries everything the gate legitimately writes ($HOME, the +# throwaway git config via GATE_TMPDIR). +CBIN="${CTEMP}/bin" +CRW="${CTEMP}/rw" +# Named so the pool's stale-container janitors (name=qwen-code-*) can see it, +# and torn down explicitly: --rm only fires on a normal exit, but a step +# timeout / job cap / cancel kills the docker CLIENT and leaves the container +# running as the runner uid with rw mounts on the shared workspace. +GATE_CONTAINER="qwen-code-gate-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-0}-$$" +teardown() { docker rm -f "${GATE_CONTAINER}" > /dev/null 2>&1 || true; } +trap teardown EXIT INT TERM if [[ -z "${GATE_IMAGE:-}" ]]; then echo "::error::GATE_IMAGE is empty — the sandbox image did not resolve; refusing to run the gate on the host." @@ -48,10 +63,10 @@ fi exit 125 } rm -rf "${CTEMP}" -mkdir -p "${CTEMP}" || exit 125 +mkdir -p "${CBIN}" "${CRW}" || exit 125 for staged in run-autofix-review-verification.sh check-settings-schema.sh \ check-autofix-contracts.sh resolve-owning-packages.sh; do - cp "${RUNNER_TEMP}/${staged}" "${CTEMP}/${staged}" || { + cp "${RUNNER_TEMP}/${staged}" "${CBIN}/${staged}" || { echo "::error::could not stage ${staged} for the gate container" exit 125 } @@ -62,20 +77,23 @@ done # root-owned files — the same failure the job's ownership-restore step exists # for. --env is an explicit allowlist: anything not named here is simply # absent inside, which is the whole point. -docker run --rm \ +docker run --rm --name "${GATE_CONTAINER}" \ --user "$(id -u):$(id -g)" \ --workdir "${GITHUB_WORKSPACE}" \ --volume "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ --volume "${WORKDIR}:${WORKDIR}" \ - --volume "${CTEMP}:${CTEMP}" \ - --env HOME="${CTEMP}" \ + --volume "${CBIN}:${CBIN}:ro" \ + --volume "${CRW}:${CRW}" \ + --env HOME="${CRW}" \ --env BRANCH="${BRANCH}" \ --env WORKDIR="${WORKDIR}" \ - --env RUNNER_TEMP="${CTEMP}" \ + --env RUNNER_TEMP="${CBIN}" \ + --env GATE_TMPDIR="${CRW}" \ --env GITHUB_OUTPUT="${VERDICT}" \ --env FOOTPRINT_ENFORCE="${FOOTPRINT_ENFORCE:-advisory}" \ + --env CI=true \ "${GATE_IMAGE}" \ - bash "${CTEMP}/run-autofix-review-verification.sh" + bash "${CBIN}/run-autofix-review-verification.sh" GATE_RC=$? echo "🧱 gate container exited ${GATE_RC}" @@ -110,14 +128,21 @@ case "${GATE_RC}" in fi ;; 1) - # The gate's own deterministic rejection: reject_fix always exits 1. - # Forced to failed regardless of the file, so a forged `outcome=fixed` - # cannot survive a red gate. The routing flags are forwarded because both - # branches they select (same-run repair, base-update handoff) stop short - # of a push. - echo "outcome=failed" >> "${GITHUB_OUTPUT}" - [[ "${PREEXISTING}" == 'true' ]] && echo "preexisting=true" >> "${GITHUB_OUTPUT}" - [[ "${RETRYABLE}" == 'true' ]] && echo "retryable=true" >> "${GITHUB_OUTPUT}" + # A deterministic rejection: reject_fix writes outcome=failed to the file + # and exits 1. Take `failed` only from the FILE — the gate also has + # exit-1 paths that deliberately write NO verdict (the baseline-A/B and + # bite tree-restore failures), where an EVALUATED rejection would advance + # the watermark and hand the item off for good, and an unset outcome is + # what routes them to the gate-crashed retry instead. A forged + # `outcome=fixed` still cannot pass: `fixed` is accepted only on exit 0, + # so here it leaves the outcome unset and the round retries. + if [[ "${OUTCOME}" == 'failed' ]]; then + echo "outcome=failed" >> "${GITHUB_OUTPUT}" + [[ "${PREEXISTING}" == 'true' ]] && echo "preexisting=true" >> "${GITHUB_OUTPUT}" + [[ "${RETRYABLE}" == 'true' ]] && echo "retryable=true" >> "${GITHUB_OUTPUT}" + else + echo "::warning::gate container exited 1 without a deterministic verdict (outcome='${OUTCOME}') — reporting as a gate crash so the next scan retries." + fi ;; *) # Docker itself failed (125/126/127), the container was killed (137), or diff --git a/.github/scripts/run-autofix-review-verification.sh b/.github/scripts/run-autofix-review-verification.sh index 369d688fa8c..015496e0423 100755 --- a/.github/scripts/run-autofix-review-verification.sh +++ b/.github/scripts/run-autofix-review-verification.sh @@ -39,7 +39,12 @@ unset GIT_CONFIG_PARAMETERS GIT_ALLOW_PROTOCOL GIT_PROXY_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" +# GATE_TMPDIR defaults to RUNNER_TEMP (host runs); the container sets it to a +# writable scratch mount, because there the staged scripts are mounted +# READ-ONLY — bash reads a script incrementally, so a writable copy of the +# running gate would let the branch code it executes rewrite its own remaining +# bytes and hand back a forged pass. +export GIT_CONFIG_GLOBAL="${GATE_TMPDIR:-${RUNNER_TEMP}}/autofix-gate-gitconfig" : > "${GIT_CONFIG_GLOBAL}" git config --file "${GIT_CONFIG_GLOBAL}" safe.directory "$(pwd)" if [ -s /etc/gitconfig ]; then @@ -438,7 +443,7 @@ sensitive_class_of() { # the class ledger — fail CLOSED as its own class instead of open. echo 'suspicious-path' ;; .github/workflows/qwen-autofix*.yml | .github/workflows/qwen-triage*.yml | .github/workflows/qwen-pr-safety-precheck.yml) echo 'autofix-loop' ;; - .github/scripts/run-autofix-review-verification.sh | .github/scripts/resolve-owning-packages.sh | .github/scripts/check-settings-schema.sh | .github/scripts/check-autofix-contracts.sh | .github/scripts/resolve-sandbox-image.mjs | .github/scripts/pr-safety-precheck.mjs) echo 'autofix-loop' ;; + .github/scripts/run-autofix-review-verification.sh | .github/scripts/run-autofix-gate-container.sh | .github/scripts/resolve-owning-packages.sh | .github/scripts/check-settings-schema.sh | .github/scripts/check-autofix-contracts.sh | .github/scripts/resolve-sandbox-image.mjs | .github/scripts/pr-safety-precheck.mjs) echo 'autofix-loop' ;; .github/workflows/* | .github/actions/*) echo 'ci-workflows' ;; .github/scripts/*) echo 'ci-scripts' ;; .github/*) echo 'gh-metadata' ;; diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 9bf09518526..d2ef48043d4 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -1512,7 +1512,12 @@ jobs: 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" + # GATE_TMPDIR defaults to RUNNER_TEMP (host runs); the container sets it to a + # writable scratch mount, because there the staged scripts are mounted + # READ-ONLY — bash reads a script incrementally, so a writable copy of the + # running gate would let the branch code it executes rewrite its own remaining + # bytes and hand back a forged pass. + export GIT_CONFIG_GLOBAL="${GATE_TMPDIR:-${RUNNER_TEMP}}/autofix-gate-gitconfig" : > "${GIT_CONFIG_GLOBAL}" git config --file "${GIT_CONFIG_GLOBAL}" safe.directory "$(pwd)" if [ -s /etc/gitconfig ]; then diff --git a/scripts/tests/package-scripts.test.js b/scripts/tests/package-scripts.test.js index d27bca3b0af..ceef4c82c42 100644 --- a/scripts/tests/package-scripts.test.js +++ b/scripts/tests/package-scripts.test.js @@ -555,8 +555,10 @@ describe('package scripts', () => { ); } + // The gate runs inside an ephemeral container (#9089); the step invokes + // the wrapper, which invokes the gate script in the container. expect(getWorkflowStep(reviewJob, 'Verification gate')).toContain( - 'bash "${RUNNER_TEMP}/run-autofix-review-verification.sh"', + 'bash "${RUNNER_TEMP}/run-autofix-gate-container.sh"', ); }); }); diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 153de01f7ad..10fab0d6d83 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -6267,16 +6267,22 @@ exit 1 ); expect(passedEnv.sort()).toEqual([ 'BRANCH', + 'CI', 'FOOTPRINT_ENFORCE', + 'GATE_TMPDIR', 'GITHUB_OUTPUT', 'HOME', 'RUNNER_TEMP', 'WORKDIR', ]); - // …and the two it does pass by those names are REDIRECTED, not the host's. + // …and the ones it does pass by those names are REDIRECTED, not the host's. expect(dockerRun).toContain('--env GITHUB_OUTPUT="${VERDICT}"'); - expect(dockerRun).toContain('--env RUNNER_TEMP="${CTEMP}"'); - expect(dockerRun).toContain('--env HOME="${CTEMP}"'); + expect(dockerRun).toContain('--env RUNNER_TEMP="${CBIN}"'); + expect(dockerRun).toContain('--env HOME="${CRW}"'); + // CI=true is set on every host step by Actions; without it the branch's + // own suites take their non-CI path (18 TUI tests that are suppressed on + // CI as timing-flaky would run, and a flake is charged to the round). + expect(dockerRun).toContain('--env CI=true'); expect(dockerRun).not.toContain('CI_DEV_BOT_PAT'); expect(dockerRun).not.toContain('GITHUB_ENV'); // Only three paths are mounted: the workspace, the round's workdir, and @@ -6286,12 +6292,28 @@ exit 1 (m) => m[1], ); expect(mounts.sort()).toEqual([ - '${CTEMP}', + '${CBIN}', + '${CRW}', '${GITHUB_WORKSPACE}', '${WORKDIR}', ]); + // The staged scripts are READ-ONLY: bash reads a script incrementally, so + // a writable copy would let the branch code the gate executes rewrite the + // running gate's remaining bytes and hand back a forged pass — the exit + // code would stop being the unforgeable half. Everything the gate + // legitimately writes goes to the separate rw scratch mount. + expect(dockerRun).toContain('--volume "${CBIN}:${CBIN}:ro"'); + expect(dockerRun).toContain('--volume "${CRW}:${CRW}"'); expect(dockerRun).toContain('--user "$(id -u):$(id -g)"'); expect(dockerRun).toContain('--rm'); + // --rm only fires on a normal exit: a step timeout / job cap / cancel + // kills the docker CLIENT and would leave the container running as the + // runner uid with rw mounts on the shared workspace. Name it so the + // pool's qwen-code-* janitors can see it, and tear it down explicitly. + expect(dockerRun).toContain('--name "${GATE_CONTAINER}"'); + expect(wrapper).toMatch(/GATE_CONTAINER="qwen-code-gate-/); + expect(wrapper).toContain('docker rm -f "${GATE_CONTAINER}"'); + expect(wrapper).toMatch(/trap teardown EXIT INT TERM/); // Behavioural: the verdict translation is the security-critical half — // branch code inside the container CAN append to the mounted verdict @@ -6327,14 +6349,20 @@ exit 1 expect( runTranslate(0, ['committed=true', 'outcome=fixed', 'verified_head=abc']), ).toBe('committed=true\noutcome=fixed\nverified_head=abc'); - // A FORGED pass on a failing gate is refused: exit 1 forces failed, and - // the routing flags (which only pick repair vs handoff, never a push) - // carry through. This is the R5-5 forgery the container closes. + // A real rejection (the gate wrote failed, then exited 1) carries through + // with its routing flags, which only pick repair vs handoff, never a push. expect( runTranslate(1, ['outcome=fixed', 'outcome=failed', 'retryable=true']), ).toBe('outcome=failed\nretryable=true'); - // Even a verdict file that ONLY claims a pass cannot survive exit 1. - expect(runTranslate(1, ['outcome=fixed'])).toBe('outcome=failed'); + // A FORGED pass cannot survive exit 1 — `fixed` is accepted only on exit + // 0 — and with no genuine `failed` in the file the outcome stays UNSET so + // the round retries rather than being handed off as evaluated. + expect(runTranslate(1, ['outcome=fixed'])).toBe(''); + // The gate's deliberate exit-1-WITHOUT-verdict crash paths (the baseline + // A/B and bite tree-restore failures) must keep reaching the gate-crashed + // retry: synthesizing `failed` there would advance the watermark and hand + // the item off for good. + expect(runTranslate(1, [])).toBe(''); // Exit 0 with no verdict is a crash, not a silent success: outcome stays // unset so 'Finalize verification' retries on the next scan. expect(runTranslate(0, [])).toBe(''); @@ -8552,8 +8580,12 @@ exit 1 // host, and a branch-authored `git config --global` dies with the run // instead of poisoning the next one. for (const gate of verificationGateBodies) { + // GATE_TMPDIR defaults to RUNNER_TEMP on the host and points at the + // container's writable scratch when the gate runs containerized (its + // staged scripts are mounted read-only, so the throwaway config cannot + // live beside them). const globalRedirect = gate.indexOf( - 'export GIT_CONFIG_GLOBAL="${RUNNER_TEMP}/autofix-gate-gitconfig"', + 'export GIT_CONFIG_GLOBAL="${GATE_TMPDIR:-${RUNNER_TEMP}}/autofix-gate-gitconfig"', ); expect(gate).toContain('export GIT_CONFIG_SYSTEM=/dev/null'); expect(globalRedirect).toBeGreaterThan(-1); From 61b16abffedff0095b7eec17ed45e9140d3ac3a7 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 15 Aug 2026 22:58:50 +0800 Subject: [PATCH 03/11] fix(autofix): close the gate container's remaining review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the round-1 Suggestions, the substantive ones: - Sandbox posture now matches repo-hygiene.yml's SANDBOX_ARGS, which runs the same command set in the same image: --network none (the gate builds and tests against node_modules installed host-side and needs no egress, so a malicious branch's build script can no longer exfiltrate what it can read), --cap-drop ALL, --security-opt no-new-privileges, --init. - The agent-authored verdict INPUTS in the rw WORKDIR mount are fingerprinted around the run. Branch code could otherwise plant no-action.md to manufacture a `noop` — published under the bot's identity as its own "no changes needed" rationale, advancing the watermark — or plant resolved-comments.txt to close human review threads. A change to any of the four now refuses the verdict and takes the retry path. - The three helper scripts the gate EXECUTES (schema, contracts, resolver) are staged in the same writable RUNNER_TEMP as everything else but were copied across the wall undigested; they are now digested at staging and verified before the wrapper runs. Test hardening from the same review: - mount TARGETS are compared to their sources, --workdir/--env BRANCH/WORKDIR pinned (a --workdir drop would make every gate run die exit 128 in git while the suite stayed green), and the new posture flags are pinned. - verdict-translation arms added: noop, last-wins extraction, preexisting under rc=1, committed on a failing path. - the trust-boundary regex also catches npm install/i/rebuild/exec, pnpm and yarn. - the two gate steps' run bodies are pinned identical, so a guard added to the first pass cannot miss the repair pass. - `id: 'sandbox'` is pinned directly and `exportImage` gained a unit test: the step output is load-bearing (empty GATE_IMAGE → the wrapper refuses → every round takes the gate-crashed retry), and both mutants the review ran survived the suite before. Re-probed against the real image: no egress inside the container, a planted no-action.md is refused (host output empty, retry), and the normal pass still passes. --- .github/scripts/resolve-sandbox-image.mjs | 2 +- .../scripts/resolve-sandbox-image.test.mjs | 35 ++++++++ .github/scripts/run-autofix-gate-container.sh | 31 +++++++ .github/workflows/qwen-autofix.yml | 25 ++++++ scripts/tests/qwen-autofix-workflow.test.js | 80 ++++++++++++++++++- 5 files changed, 171 insertions(+), 2 deletions(-) diff --git a/.github/scripts/resolve-sandbox-image.mjs b/.github/scripts/resolve-sandbox-image.mjs index b14ce6549cd..fc3796bbba9 100644 --- a/.github/scripts/resolve-sandbox-image.mjs +++ b/.github/scripts/resolve-sandbox-image.mjs @@ -109,7 +109,7 @@ function pullImage(command, image) { }); } -function exportImage(image) { +export function exportImage(image) { if (process.env.GITHUB_ENV) { appendFileSync(process.env.GITHUB_ENV, `QWEN_SANDBOX_IMAGE=${image}\n`); } diff --git a/.github/scripts/resolve-sandbox-image.test.mjs b/.github/scripts/resolve-sandbox-image.test.mjs index fc583155b28..8b6f90cdfbf 100644 --- a/.github/scripts/resolve-sandbox-image.test.mjs +++ b/.github/scripts/resolve-sandbox-image.test.mjs @@ -1,9 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { latestSemverTag, validateRequestedImage, + exportImage, } from './resolve-sandbox-image.mjs'; test('latestSemverTag returns the highest stable semver tag', () => { @@ -40,3 +44,34 @@ test('validateRequestedImage rejects missing package config output', () => { ); } }); + +test('exportImage publishes the resolved image as a step output', () => { + // The autofix gate reads this output (GATE_IMAGE) to choose the container + // it runs the branch's build/test in — deliberately NOT $GITHUB_ENV, which + // an earlier step can append to. An empty output makes the gate wrapper + // refuse and every round take the gate-crashed retry path, so the write is + // load-bearing enough to pin. + const dir = mkdtempSync(join(tmpdir(), 'sandbox-image-')); + const outFile = join(dir, 'out'); + const envFile = join(dir, 'env'); + const saved = { out: process.env.GITHUB_OUTPUT, env: process.env.GITHUB_ENV }; + try { + process.env.GITHUB_OUTPUT = outFile; + process.env.GITHUB_ENV = envFile; + exportImage('ghcr.io/qwenlm/qwen-code:1.2.3'); + assert.equal( + readFileSync(outFile, 'utf8'), + 'image=ghcr.io/qwenlm/qwen-code:1.2.3\n', + ); + assert.equal( + readFileSync(envFile, 'utf8'), + 'QWEN_SANDBOX_IMAGE=ghcr.io/qwenlm/qwen-code:1.2.3\n', + ); + } finally { + if (saved.out === undefined) delete process.env.GITHUB_OUTPUT; + else process.env.GITHUB_OUTPUT = saved.out; + if (saved.env === undefined) delete process.env.GITHUB_ENV; + else process.env.GITHUB_ENV = saved.env; + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/.github/scripts/run-autofix-gate-container.sh b/.github/scripts/run-autofix-gate-container.sh index d62e45339df..3d5f95e6eeb 100755 --- a/.github/scripts/run-autofix-gate-container.sh +++ b/.github/scripts/run-autofix-gate-container.sh @@ -77,7 +77,30 @@ done # root-owned files — the same failure the job's ownership-restore step exists # for. --env is an explicit allowlist: anything not named here is simply # absent inside, which is the whole point. +# The gate reads agent-authored verdict INPUTS from the rw WORKDIR mount +# after its build legs — branch code in the container can plant +# `no-action.md` to manufacture a `noop` (published as the bot's own "no +# changes needed" rationale, advancing the watermark) or plant +# `resolved-comments.txt` to close human review threads. The gate never +# writes these four, so fingerprint them before the run and refuse the +# verdict if they moved. +verdict_inputs_digest() { + local f + for f in no-action.md address-summary.md resolved-comments.txt comment-replies.json; do + printf '%s:%s\n' "${f}" "$(sha256sum "${WORKDIR}/${f}" 2> /dev/null | cut -d' ' -f1)" + done +} +INPUTS_BEFORE="$(verdict_inputs_digest)" + +# --network none: the gate only runs build/typecheck/lint/test against +# node_modules already installed on the host side, so it needs no egress — +# and offline operation in this same image is established in-repo by +# repo-hygiene.yml's SANDBOX_ARGS, whose posture (--init, --cap-drop ALL, +# --security-opt no-new-privileges) this mirrors. Without it a malicious +# branch's build script can exfiltrate anything readable in the container +# using the runner's network position. docker run --rm --name "${GATE_CONTAINER}" \ + --init --network none --cap-drop ALL --security-opt no-new-privileges \ --user "$(id -u):$(id -g)" \ --workdir "${GITHUB_WORKSPACE}" \ --volume "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ @@ -98,6 +121,14 @@ GATE_RC=$? echo "🧱 gate container exited ${GATE_RC}" +if [[ "$(verdict_inputs_digest)" != "${INPUTS_BEFORE}" ]]; then + # Something in the container rewrote the agent's verdict inputs. The gate's + # own exit code is now meaningless as evidence about the fix, so take the + # crash path (retry) rather than publish a verdict derived from them. + echo "::error::the gate container modified the agent's verdict inputs in ${WORKDIR} — refusing the verdict; the next scan retries." + exit 125 +fi + # Verdict translation. Only these keys are forwarded, last value wins (the # gate appends its final verdict last), and `outcome` is gated on the exit # code — the file alone is not authority. diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index d2ef48043d4..fbb505678ff 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -3991,6 +3991,13 @@ jobs: 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 "gate_container_sha256=$(sha256sum "${RUNNER_TEMP}/run-autofix-gate-container.sh" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" + # The gate EXECUTES these three helpers too, and RUNNER_TEMP is + # writable by the branch/agent code later steps run on this host: a + # neutered schema/contracts/resolver helper lets a fix that should be + # rejected exit 0 honestly, and the wrapper would carry that exit code + # to the push. Digest them as one blob (they are staged and verified + # together, so a single value is enough and cannot drift apart). + echo "gate_helpers_sha256=$(cat "${RUNNER_TEMP}/check-settings-schema.sh" "${RUNNER_TEMP}/check-autofix-contracts.sh" "${RUNNER_TEMP}/resolve-owning-packages.sh" | sha256sum | 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 @@ -5242,6 +5249,7 @@ jobs: TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' VERIFY_RUNNER_SHA256: '${{ steps.stage.outputs.verify_runner_sha256 }}' GATE_CONTAINER_SHA256: '${{ steps.stage.outputs.gate_container_sha256 }}' + GATE_HELPERS_SHA256: '${{ steps.stage.outputs.gate_helpers_sha256 }}' # The image the gate runs in, from the resolve step's OUTPUT rather # than $GITHUB_ENV: branch code must not be able to choose it. GATE_IMAGE: '${{ steps.sandbox.outputs.image }}' @@ -5263,6 +5271,14 @@ jobs: unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - > /dev/null echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - > /dev/null + # The helpers the gate executes are staged in the same writable + # RUNNER_TEMP, so verify them as one blob before the wrapper copies + # them across the container wall. + GATE_HELPERS_NOW="$(cat "${RUNNER_TEMP}/check-settings-schema.sh" "${RUNNER_TEMP}/check-autofix-contracts.sh" "${RUNNER_TEMP}/resolve-owning-packages.sh" | sha256sum | cut -d' ' -f1)" + if [[ "${GATE_HELPERS_NOW}" != "${GATE_HELPERS_SHA256}" ]]; then + echo "::error::staged gate helpers no longer match the digest recorded at staging time; refusing to run the gate." + exit 1 + fi bash "${RUNNER_TEMP}/run-autofix-gate-container.sh" - name: 'Repair deterministic rejection' @@ -5373,6 +5389,7 @@ jobs: TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' VERIFY_RUNNER_SHA256: '${{ steps.stage.outputs.verify_runner_sha256 }}' GATE_CONTAINER_SHA256: '${{ steps.stage.outputs.gate_container_sha256 }}' + GATE_HELPERS_SHA256: '${{ steps.stage.outputs.gate_helpers_sha256 }}' # The image the gate runs in, from the resolve step's OUTPUT rather # than $GITHUB_ENV: branch code must not be able to choose it. GATE_IMAGE: '${{ steps.sandbox.outputs.image }}' @@ -5394,6 +5411,14 @@ jobs: unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - > /dev/null echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - > /dev/null + # The helpers the gate executes are staged in the same writable + # RUNNER_TEMP, so verify them as one blob before the wrapper copies + # them across the container wall. + GATE_HELPERS_NOW="$(cat "${RUNNER_TEMP}/check-settings-schema.sh" "${RUNNER_TEMP}/check-autofix-contracts.sh" "${RUNNER_TEMP}/resolve-owning-packages.sh" | sha256sum | cut -d' ' -f1)" + if [[ "${GATE_HELPERS_NOW}" != "${GATE_HELPERS_SHA256}" ]]; then + echo "::error::staged gate helpers no longer match the digest recorded at staging time; refusing to run the gate." + exit 1 + fi bash "${RUNNER_TEMP}/run-autofix-gate-container.sh" - name: 'Finalize verification' diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 10fab0d6d83..cb8e0234782 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -6252,10 +6252,37 @@ exit 1 expect(workflow).toContain( "- name: 'Resolve sandbox image'\n # id + step output", ); + // The two gate steps are one hardening surface: their env+run bodies must + // stay identical, or a guard added to the first pass silently misses the + // repair pass (the one that runs after the branch's code already ran). + const gateBodyOf = (step) => + step.replace(/^ +- name: '[^']*'\n/, '').replace(/\s+/g, ' '); + expect(gateBodyOf(verificationGateSteps[1]).length).toBeGreaterThan(200); + expect( + gateBodyOf(verificationGateSteps[1]).includes( + 'bash "${RUNNER_TEMP}/run-autofix-gate-container.sh"', + ), + ).toBe(true); + const runBodyOf = (step) => + (step.match(/ {8}run: \|-\n([\s\S]*)$/)?.[1] ?? '').replace(/\s+/g, ' '); + expect(runBodyOf(repairVerificationGateStep)).toBe( + runBodyOf(verificationGateSteps[1]), + ); expect(workflow).toContain( 'cp .github/scripts/run-autofix-gate-container.sh "${RUNNER_TEMP}/run-autofix-gate-container.sh"', ); expect(workflow).toContain('echo "gate_container_sha256=$(sha256sum '); + // The image output is load-bearing (empty GATE_IMAGE → the wrapper + // refuses and every round takes the gate-crashed retry), so pin the id + // itself, not just the consumer string. + expect(workflow).toMatch( + /- name: 'Resolve sandbox image'\n[^\n]*\n[\s\S]{0,400}?id: 'sandbox'/, + ); + // The three helpers the gate EXECUTES are staged in the same writable + // RUNNER_TEMP, so they are digested at staging and verified before the + // wrapper copies them across the wall. + expect(workflow).toContain('echo "gate_helpers_sha256=$(cat '); + expect(workflow.match(/GATE_HELPERS_NOW="\$\(cat /g) ?? []).toHaveLength(2); // The container env is an explicit ALLOWLIST: everything unnamed is // absent inside, which is the isolation. The PAT, the host $GITHUB_ENV // and the runner's real $GITHUB_OUTPUT must never appear in it. @@ -6304,8 +6331,42 @@ exit 1 // legitimately writes goes to the separate rw scratch mount. expect(dockerRun).toContain('--volume "${CBIN}:${CBIN}:ro"'); expect(dockerRun).toContain('--volume "${CRW}:${CRW}"'); + // Mount TARGETS must equal their sources (a retargeting mutant would slip + // past a sources-only comparison), and the workdir must be the workspace — + // without it the container starts at / and every gate run dies exit 128 + // in git, reporting as a crash while every other assertion stays green. + for (const [, src, dst] of dockerRun.matchAll( + /--volume "([^":]+):([^":]+)(?::ro)?"/g, + )) { + expect(dst).toBe(src); + } + expect(dockerRun).toContain('--workdir "${GITHUB_WORKSPACE}"'); + expect(dockerRun).toContain('--env BRANCH="${BRANCH}"'); + expect(dockerRun).toContain('--env WORKDIR="${WORKDIR}"'); expect(dockerRun).toContain('--user "$(id -u):$(id -g)"'); expect(dockerRun).toContain('--rm'); + // Sandbox posture carried over from repo-hygiene.yml's SANDBOX_ARGS, which + // runs the same command set in the same image: no egress for a build that + // needs none, and no extra capabilities. + expect(dockerRun).toContain('--network none'); + expect(dockerRun).toContain('--cap-drop ALL'); + expect(dockerRun).toContain('--security-opt no-new-privileges'); + expect(dockerRun).toContain('--init'); + // The agent-authored verdict inputs are fingerprinted around the run: the + // WORKDIR mount is writable, so branch code could otherwise plant + // no-action.md to manufacture a noop the bot publishes as its own. + expect(wrapper).toContain('INPUTS_BEFORE="$(verdict_inputs_digest)"'); + expect(wrapper).toMatch( + /if \[\[ "\$\(verdict_inputs_digest\)" != "\$\{INPUTS_BEFORE\}" \]\]/, + ); + for (const f of [ + 'no-action.md', + 'address-summary.md', + 'resolved-comments.txt', + 'comment-replies.json', + ]) { + expect(wrapper).toContain(f); + } // --rm only fires on a normal exit: a step timeout / job cap / cancel // kills the docker CLIENT and would leave the container running as the // runner uid with rw mounts on the shared workspace. Name it so the @@ -6363,6 +6424,23 @@ exit 1 // retry: synthesizing `failed` there would advance the watermark and hand // the item off for good. expect(runTranslate(1, [])).toBe(''); + // A legitimate no-op round carries through as well. + expect(runTranslate(0, ['outcome=noop'])).toBe('outcome=noop'); + // Last-wins extraction is the anti-forgery shape: branch code appending an + // earlier forged line cannot displace the gate's final verdict. + expect(runTranslate(0, ['outcome=failed', 'outcome=fixed'])).toBe( + 'outcome=fixed', + ); + // preexisting routes to the base-update handoff (never a push) and rides + // along with a genuine failure. + expect(runTranslate(1, ['outcome=failed', 'preexisting=true'])).toBe( + 'outcome=failed\npreexisting=true', + ); + // committed= is a ref-only fact the gate records before any check runs; it + // is forwarded on non-zero paths too so the handoff wording stays right. + expect(runTranslate(1, ['committed=true', 'outcome=failed'])).toBe( + 'committed=true\noutcome=failed', + ); // Exit 0 with no verdict is a crash, not a silent success: outcome stays // unset so 'Finalize verification' retries on the next scan. expect(runTranslate(0, [])).toBe(''); @@ -6378,7 +6456,7 @@ exit 1 // fails here instead of silently re-opening the class. const steps = workflow.split(/\n {6}- name: /).slice(1); const branchCode = - /bash "\$\{RUNNER_TEMP\}\/run-autofix-review-verification\.sh"|run-agent\.mjs|\bnpm (run|ci|test)\b|\bnpx /; + /bash "\$\{RUNNER_TEMP\}\/run-autofix-review-verification\.sh"|run-agent\.mjs|\bnpm (run|ci|test|install|i|rebuild|exec)\b|\bnpx |\bpnpm |\byarn /; const offenders = []; for (const step of steps) { if (!step.includes('CI_DEV_BOT_PAT')) continue; From 4b6e61605b0e7b345e6fef66eef7ae4b4f345377 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 16 Aug 2026 15:43:29 +0800 Subject: [PATCH 04/11] fix(autofix): drop the dead gate-script handle and bound the teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GATE_SCRIPT was assigned and never read, and it named the HOST RUNNER_TEMP copy of the gate — the one path deliberately never mounted into the container. Wiring that misleading handle into the docker command would make every round die file-not-found in the gate-crashed retry loop. teardown's docker rm -f was the only docker call here without a timeout, while the pool janitors wrap every one of theirs: the trap fires on exactly the cancel/step-timeout paths where a wedged daemon blocks the CLI, and a hung teardown is killed with the process group, leaving the running container the trap exists to remove. Pin what was unpinned: the container COMMAND, the staging list against the digested helper set, both sides of the helpers blob digest, the verdict reset, and the ordering of the input fingerprint, the tamper refusal and the gate steps' sha256sum -c. --- .github/scripts/run-autofix-gate-container.sh | 8 +- scripts/tests/qwen-autofix-workflow.test.js | 97 ++++++++++++++++++- 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/.github/scripts/run-autofix-gate-container.sh b/.github/scripts/run-autofix-gate-container.sh index 3d5f95e6eeb..23efffca020 100755 --- a/.github/scripts/run-autofix-gate-container.sh +++ b/.github/scripts/run-autofix-gate-container.sh @@ -29,7 +29,6 @@ set -uo pipefail # GITHUB_WORKSPACE/GITHUB_OUTPUT are runner-provided, GATE_IMAGE and # FOOTPRINT_ENFORCE are step-level env. None is defined here. -GATE_SCRIPT="${RUNNER_TEMP}/run-autofix-review-verification.sh" VERDICT="${WORKDIR}/gate-verdict" # The container's own RUNNER_TEMP: a fresh directory holding COPIES of just # the scripts the gate reads. The real RUNNER_TEMP is never mounted — it holds @@ -49,8 +48,13 @@ CRW="${CTEMP}/rw" # and torn down explicitly: --rm only fires on a normal exit, but a step # timeout / job cap / cancel kills the docker CLIENT and leaves the container # running as the runner uid with rw mounts on the shared workspace. +# `timeout 30` like every other docker call here: the trap fires on the same +# cancel/step-timeout paths where a wedged daemon blocks the CLI indefinitely, +# and a hung teardown is killed with the process group — leaving exactly the +# running leftover the trap exists to remove, which no janitor reaps (they +# skip RUNNING containers). GATE_CONTAINER="qwen-code-gate-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-0}-$$" -teardown() { docker rm -f "${GATE_CONTAINER}" > /dev/null 2>&1 || true; } +teardown() { timeout 30 docker rm -f "${GATE_CONTAINER}" > /dev/null 2>&1 || true; } trap teardown EXIT INT TERM if [[ -z "${GATE_IMAGE:-}" ]]; then diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index cb8e0234782..5404f2df024 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -6242,6 +6242,22 @@ exit 1 expect(gate).toContain( 'echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - > /dev/null', ); + // Presence is not enough: the digest checks are only a wall if they run + // BEFORE the wrapper and actually abort. Moving the invocation above + // them (a tampered wrapper executes before verification sees it) or + // appending `|| true` (verification that cannot fail) both leave every + // other assertion here green. + for (const verifyLine of [ + 'echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - > /dev/null', + 'echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - > /dev/null', + 'GATE_HELPERS_NOW="$(cat ', + ]) { + expect(gate.indexOf(verifyLine)).toBeGreaterThan(-1); + expect(gate.indexOf(verifyLine)).toBeLessThan( + gate.indexOf('bash "${RUNNER_TEMP}/run-autofix-gate-container.sh"'), + ); + } + expect(gate).not.toMatch(/sha256sum -c[^\n]*\|\| true/); // The image comes from the resolve step's OUTPUT (expression context), // not $GITHUB_ENV, which an earlier step can append to. expect(gate).toContain( @@ -6281,7 +6297,21 @@ exit 1 // The three helpers the gate EXECUTES are staged in the same writable // RUNNER_TEMP, so they are digested at staging and verified before the // wrapper copies them across the wall. - expect(workflow).toContain('echo "gate_helpers_sha256=$(cat '); + // Writer and reader must digest the SAME files in the SAME order. Pinned + // only by prefix and count, the two sides can silently disagree: swapping + // the cat order at staging alone makes the digests never match, so every + // gate run exits 1 at 'staged gate helpers no longer match the digest + // recorded at staging time' — the review autofix loop fail-closed-broken + // for every PR, with the whole suite green. Compare them directly. + const helperBlob = workflow.match( + /echo "gate_helpers_sha256=\$\(cat (.*?) \| sha256sum/, + )?.[1]; + expect(helperBlob).toBeTruthy(); + for (const gate of [verificationGateSteps[1], repairVerificationGateStep]) { + expect(gate).toContain( + `GATE_HELPERS_NOW="$(cat ${helperBlob} | sha256sum`, + ); + } expect(workflow.match(/GATE_HELPERS_NOW="\$\(cat /g) ?? []).toHaveLength(2); // The container env is an explicit ALLOWLIST: everything unnamed is // absent inside, which is the isolation. The PAT, the host $GITHUB_ENV @@ -6289,6 +6319,32 @@ exit 1 const dockerRun = wrapper.match(/docker run --rm[\s\S]*?"\$\{GATE_IMAGE\}"/)?.[0] ?? ''; expect(dockerRun).toBeTruthy(); + // The COMMAND sits past the image, outside the extraction above. Pin it: + // the digests cover what gets STAGED, not what docker is told to run, so + // a mutant running a different staged script — or the host copy under the + // real RUNNER_TEMP, which is deliberately never mounted — leaves every + // assertion here green while the container stops executing the gate. + expect(wrapper).toMatch( + /"\$\{GATE_IMAGE\}" \\\n\s*bash "\$\{CBIN\}\/run-autofix-review-verification\.sh"\s*$/m, + ); + // What the loop stages into CBIN must be exactly the gate plus the three + // helpers the workflow digests. Dropping one (e.g. check-autofix- + // contracts.sh) makes the gate invoke a missing helper INSIDE the + // container: bash exit 127 becomes a retryable deterministic rejection + // charged to every round — a permanent outage with no test signal. + const stagedList = ( + wrapper.match(/for staged in ([\s\S]*?); do/)?.[1] ?? '' + ) + .replace(/\\\s+/g, ' ') + .trim() + .split(/\s+/); + const digestedHelpers = [ + ...helperBlob.matchAll(/\$\{RUNNER_TEMP\}\/([\w.-]+)/g), + ].map((m) => m[1]); + expect(digestedHelpers.length).toBe(3); + expect(stagedList.slice().sort()).toEqual( + ['run-autofix-review-verification.sh', ...digestedHelpers].sort(), + ); const passedEnv = [...dockerRun.matchAll(/--env ([A-Z_]+)=/g)].map( (m) => m[1], ); @@ -6359,6 +6415,20 @@ exit 1 expect(wrapper).toMatch( /if \[\[ "\$\(verdict_inputs_digest\)" != "\$\{INPUTS_BEFORE\}" \]\]/, ); + // Both halves are ORDERING properties, and presence alone pins neither. + // Capturing the fingerprint after the run compares post-run state with + // itself, so it never fires; running the refusal after the translation + // forwards a tampered round's outcome=fixed to the step's real + // $GITHUB_OUTPUT before exiting 125 — and with continue-on-error, + // 'Finalize verification' reads it and 'Push and report' PAT-pushes it. + expect( + wrapper.indexOf('INPUTS_BEFORE="$(verdict_inputs_digest)"'), + ).toBeLessThan(wrapper.indexOf('docker run --rm')); + expect( + wrapper.indexOf( + 'if [[ "$(verdict_inputs_digest)" != "${INPUTS_BEFORE}" ]]', + ), + ).toBeLessThan(wrapper.indexOf('OUTCOME="$(verdict_value')); for (const f of [ 'no-action.md', 'address-summary.md', @@ -6373,8 +6443,19 @@ exit 1 // pool's qwen-code-* janitors can see it, and tear it down explicitly. expect(dockerRun).toContain('--name "${GATE_CONTAINER}"'); expect(wrapper).toMatch(/GATE_CONTAINER="qwen-code-gate-/); - expect(wrapper).toContain('docker rm -f "${GATE_CONTAINER}"'); + // …under a timeout, like every docker call in the pool janitors: the trap + // fires on the same cancel/step-timeout paths where a wedged daemon + // blocks the CLI indefinitely, and a teardown that hangs past the + // SIGINT→SIGKILL grace window is killed with the process group, leaving + // the running leftover it exists to remove (janitors skip RUNNING ones). + expect(wrapper).toContain('timeout 30 docker rm -f "${GATE_CONTAINER}"'); expect(wrapper).toMatch(/trap teardown EXIT INT TERM/); + // Every docker invocation in the wrapper is either timeout-wrapped or the + // gate run itself, which is bounded by the step's own timeout-minutes. + for (const line of wrapper.split('\n')) { + if (!/\bdocker /.test(line) || /^\s*#/.test(line)) continue; + expect(line).toMatch(/timeout \d+ docker |docker run --rm/); + } // Behavioural: the verdict translation is the security-critical half — // branch code inside the container CAN append to the mounted verdict @@ -6387,6 +6468,18 @@ exit 1 // half-written. The wrapper must stay errexit-free and end on its own // explicit `exit "${GATE_RC}"` (the harness below appends `exit 0` for // the same reason: it runs the fragment, not the whole script). + // The verdict file lives in the round's job-level WORKDIR, so the repair + // pass reuses the first pass's path. Without this reset a verdict-less + // crash (rc=1, no outcome written) reads the FIRST pass's stale + // outcome=failed + retryable=true, and 'Finalize verification' books the + // repair round as an EVALUATED rejection — watermark advanced, item + // handed off for good — instead of the gate-crashed retry. No malicious + // branch code needed; the behavioural harness below writes a fresh file + // per case, so it cannot see the cross-pass reuse. + expect(wrapper).toContain(': > "${VERDICT}"'); + expect(wrapper.indexOf(': > "${VERDICT}"')).toBeLessThan( + wrapper.indexOf('docker run --rm'), + ); expect(wrapper).toContain('set -uo pipefail'); expect(wrapper).not.toMatch(/^set -e/m); expect(wrapper.trimEnd().endsWith('exit "${GATE_RC}"')).toBe(true); From ea94e28be59db86517fcf8382222e75998a479bd Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 16 Aug 2026 15:58:33 +0800 Subject: [PATCH 05/11] fix(autofix): close the verdict-input and helper-digest gaps in the gate wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fingerprint set omitted the bite check's own inputs. rc.json and rv.json are written by the workflow and only read by the gate, after the build legs: truncating rc.json from inside the container flips BITE_ENFORCE off, so the bogus-fix round the bite check exists to reject exits 0 as `fixed` and is pushed. failure.md is read back by the host's report step, so a plant publishes attacker text as the bot's rejection. All three are now covered. The fingerprint is also type-aware and refuses non-regular paths before hashing them: a symlink to gate-verdict made the gate's own writes trip the compare (exit 125 every round, so a deterministic rejection never landed), and a FIFO blocked sha256sum's open() until the step's 60-minute timeout with no diagnostic. Neither needs a capability on the rw mount. gate_helpers_sha256 hashed the CONCATENATION of the three helpers, which is invariant under file-boundary shifts — a repartition (contracts body prepended to the resolver, which then emits no CHANGED_PKGS and skips the package tests) kept the digest identical. Digest the per-file digests instead, which bind each hash to its path. preexisting and retryable are mutually exclusive at the source, so forwarding them independently let an appended retryable=true ride a genuine preexisting rejection into a repair leg the repair agent is forbidden to act on. Pins added for each, plus the previously unpinned empty-GATE_IMAGE refusal, the resolve-before-checkout ordering that makes GATE_IMAGE trusted, the gate steps' env-block equality, the wrapper's autofix-loop classification, and the `^` anchor in verdict_value. --- .github/scripts/run-autofix-gate-container.sh | 36 +++++- .github/workflows/qwen-autofix.yml | 13 ++- scripts/tests/qwen-autofix-workflow.test.js | 108 ++++++++++++++++-- 3 files changed, 140 insertions(+), 17 deletions(-) diff --git a/.github/scripts/run-autofix-gate-container.sh b/.github/scripts/run-autofix-gate-container.sh index 23efffca020..2c76e227322 100755 --- a/.github/scripts/run-autofix-gate-container.sh +++ b/.github/scripts/run-autofix-gate-container.sh @@ -88,13 +88,35 @@ done # `resolved-comments.txt` to close human review threads. The gate never # writes these four, so fingerprint them before the run and refuse the # verdict if they moved. +# rc.json/rv.json are the bite check's inputs, read AFTER the build legs: +# truncating rc.json flips BITE_ENFORCE to false, so the bogus-fix round the +# bite check exists to reject exits 0 as `fixed`. failure.md is read back by +# the host's report step, so a plant publishes attacker text as the bot's +# rejection. The gate writes none of these (only gate-advisories.md and +# gate-rejection.md, which is why those are absent here). +# The type is part of the fingerprint, and non-regular paths are never +# hashed: a symlink to gate-verdict would make the gate's own verdict writes +# trip the compare (exit 125 every round, suppressing the rejection), and a +# FIFO would block sha256sum's open() until the step's 60-minute timeout with +# no diagnostic. Neither needs a capability on the rw mount. verdict_inputs_digest() { - local f - for f in no-action.md address-summary.md resolved-comments.txt comment-replies.json; do - printf '%s:%s\n' "${f}" "$(sha256sum "${WORKDIR}/${f}" 2> /dev/null | cut -d' ' -f1)" + local f type + for f in no-action.md address-summary.md resolved-comments.txt \ + comment-replies.json rc.json rv.json failure.md; do + type="$(stat -c '%F' "${WORKDIR}/${f}" 2> /dev/null || true)" + case "${type}" in + '' | 'regular file' | 'regular empty file') + printf '%s:%s\n' "${f}" "$(sha256sum "${WORKDIR}/${f}" 2> /dev/null | cut -d' ' -f1)" + ;; + *) printf '%s:NONREGULAR:%s\n' "${f}" "${type}" ;; + esac done } INPUTS_BEFORE="$(verdict_inputs_digest)" +if [[ "${INPUTS_BEFORE}" == *':NONREGULAR:'* ]]; then + echo "::error::a verdict input in ${WORKDIR} is not a regular file — refusing to run the gate." + exit 125 +fi # --network none: the gate only runs build/typecheck/lint/test against # node_modules already installed on the host side, so it needs no egress — @@ -173,8 +195,14 @@ case "${GATE_RC}" in # so here it leaves the outcome unset and the round retries. if [[ "${OUTCOME}" == 'failed' ]]; then echo "outcome=failed" >> "${GITHUB_OUTPUT}" + # preexisting and retryable are mutually exclusive at the source + # (reject_fix: a pre-existing failure is NOT retryable — the repair + # agent may only amend this round's fix). Forwarding them independently + # admits a pair the gate never emits: an appended `retryable=true` rides + # a genuine preexisting rejection into an 18-minute repair leg the + # repair agent is forbidden to act on, burned every round. [[ "${PREEXISTING}" == 'true' ]] && echo "preexisting=true" >> "${GITHUB_OUTPUT}" - [[ "${RETRYABLE}" == 'true' ]] && echo "retryable=true" >> "${GITHUB_OUTPUT}" + [[ "${PREEXISTING}" != 'true' && "${RETRYABLE}" == 'true' ]] && echo "retryable=true" >> "${GITHUB_OUTPUT}" else echo "::warning::gate container exited 1 without a deterministic verdict (outcome='${OUTCOME}') — reporting as a gate crash so the next scan retries." fi diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index fbb505678ff..e19ca8bde87 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -3995,9 +3995,12 @@ jobs: # writable by the branch/agent code later steps run on this host: a # neutered schema/contracts/resolver helper lets a fix that should be # rejected exit 0 honestly, and the wrapper would carry that exit code - # to the push. Digest them as one blob (they are staged and verified - # together, so a single value is enough and cannot drift apart). - echo "gate_helpers_sha256=$(cat "${RUNNER_TEMP}/check-settings-schema.sh" "${RUNNER_TEMP}/check-autofix-contracts.sh" "${RUNNER_TEMP}/resolve-owning-packages.sh" | sha256sum | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" + # to the push. One value covers all three, but it must be the digest + # of the PER-FILE digests: hashing the concatenation is invariant + # under file-boundary shifts, so a writer could repartition the three + # (contracts body prepended to the resolver, which then emits no + # CHANGED_PKGS and skips the package tests) with the digest unchanged. + echo "gate_helpers_sha256=$(sha256sum "${RUNNER_TEMP}/check-settings-schema.sh" "${RUNNER_TEMP}/check-autofix-contracts.sh" "${RUNNER_TEMP}/resolve-owning-packages.sh" | sha256sum | 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 @@ -5274,7 +5277,7 @@ jobs: # The helpers the gate executes are staged in the same writable # RUNNER_TEMP, so verify them as one blob before the wrapper copies # them across the container wall. - GATE_HELPERS_NOW="$(cat "${RUNNER_TEMP}/check-settings-schema.sh" "${RUNNER_TEMP}/check-autofix-contracts.sh" "${RUNNER_TEMP}/resolve-owning-packages.sh" | sha256sum | cut -d' ' -f1)" + GATE_HELPERS_NOW="$(sha256sum "${RUNNER_TEMP}/check-settings-schema.sh" "${RUNNER_TEMP}/check-autofix-contracts.sh" "${RUNNER_TEMP}/resolve-owning-packages.sh" | sha256sum | cut -d' ' -f1)" if [[ "${GATE_HELPERS_NOW}" != "${GATE_HELPERS_SHA256}" ]]; then echo "::error::staged gate helpers no longer match the digest recorded at staging time; refusing to run the gate." exit 1 @@ -5414,7 +5417,7 @@ jobs: # The helpers the gate executes are staged in the same writable # RUNNER_TEMP, so verify them as one blob before the wrapper copies # them across the container wall. - GATE_HELPERS_NOW="$(cat "${RUNNER_TEMP}/check-settings-schema.sh" "${RUNNER_TEMP}/check-autofix-contracts.sh" "${RUNNER_TEMP}/resolve-owning-packages.sh" | sha256sum | cut -d' ' -f1)" + GATE_HELPERS_NOW="$(sha256sum "${RUNNER_TEMP}/check-settings-schema.sh" "${RUNNER_TEMP}/check-autofix-contracts.sh" "${RUNNER_TEMP}/resolve-owning-packages.sh" | sha256sum | cut -d' ' -f1)" if [[ "${GATE_HELPERS_NOW}" != "${GATE_HELPERS_SHA256}" ]]; then echo "::error::staged gate helpers no longer match the digest recorded at staging time; refusing to run the gate." exit 1 diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 5404f2df024..ca71cef47c5 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -6250,7 +6250,7 @@ exit 1 for (const verifyLine of [ 'echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - > /dev/null', 'echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - > /dev/null', - 'GATE_HELPERS_NOW="$(cat ', + 'GATE_HELPERS_NOW="$(sha256sum ', ]) { expect(gate.indexOf(verifyLine)).toBeGreaterThan(-1); expect(gate.indexOf(verifyLine)).toBeLessThan( @@ -6284,6 +6284,19 @@ exit 1 expect(runBodyOf(repairVerificationGateStep)).toBe( runBodyOf(verificationGateSteps[1]), ); + // …and the env blocks, which the comment above claims but the run-body + // comparison does not cover. An env entry changed in one step only (a + // FOOTPRINT_ENFORCE default, the source of a digest variable) would let + // the repair pass — the LAST gate before the push, running after branch + // code already ran — enforce a different posture with the suite green. + const envBodyOf = (step) => + (step.match(/\n {8}env:\n([\s\S]*?)(?=\n {8}\w[\w-]*:)/)?.[1] ?? '') + .replace(/\s+/g, ' ') + .trim(); + expect(envBodyOf(verificationGateSteps[1]).length).toBeGreaterThan(100); + expect(envBodyOf(repairVerificationGateStep)).toBe( + envBodyOf(verificationGateSteps[1]), + ); expect(workflow).toContain( 'cp .github/scripts/run-autofix-gate-container.sh "${RUNNER_TEMP}/run-autofix-gate-container.sh"', ); @@ -6294,6 +6307,20 @@ exit 1 expect(workflow).toMatch( /- name: 'Resolve sandbox image'\n[^\n]*\n[\s\S]{0,400}?id: 'sandbox'/, ); + // What makes that image TRUSTED is the ordering: the resolve step runs + // before the PR branch is checked out, so the package.json it reads (and + // the resolve script it runs) are still the trusted base. Reordered below + // 'Prepare branch and feedback' it would take the image from the PR's own + // package.json — validateRequestedImage only trims and rejects empty — and + // an attacker-chosen runtime voids every guarantee downstream of the wall. + expect( + reviewAddressJob.indexOf("- name: 'Resolve sandbox image'"), + ).toBeGreaterThan(-1); + expect( + reviewAddressJob.indexOf("- name: 'Resolve sandbox image'"), + ).toBeLessThan( + reviewAddressJob.indexOf("- name: 'Prepare branch and feedback'"), + ); // The three helpers the gate EXECUTES are staged in the same writable // RUNNER_TEMP, so they are digested at staging and verified before the // wrapper copies them across the wall. @@ -6304,15 +6331,26 @@ exit 1 // recorded at staging time' — the review autofix loop fail-closed-broken // for every PR, with the whole suite green. Compare them directly. const helperBlob = workflow.match( - /echo "gate_helpers_sha256=\$\(cat (.*?) \| sha256sum/, + /echo "gate_helpers_sha256=\$\(sha256sum (.*?) \| sha256sum/, )?.[1]; expect(helperBlob).toBeTruthy(); for (const gate of [verificationGateSteps[1], repairVerificationGateStep]) { expect(gate).toContain( - `GATE_HELPERS_NOW="$(cat ${helperBlob} | sha256sum`, + `GATE_HELPERS_NOW="$(sha256sum ${helperBlob} | sha256sum`, ); } - expect(workflow.match(/GATE_HELPERS_NOW="\$\(cat /g) ?? []).toHaveLength(2); + expect( + workflow.match(/GATE_HELPERS_NOW="\$\(sha256sum /g) ?? [], + ).toHaveLength(2); + // The digest must cover the three files INDIVIDUALLY: `cat a b c | + // sha256sum` is invariant under file-boundary shifts, so a writer with + // access to the staged copies could repartition them — the contracts body + // prepended to the resolver, which then emits no CHANGED_PKGS and the + // package tests are silently skipped — with the digest unchanged (proven: + // the concatenation digest is byte-identical across such a repartition, + // the per-file digest is not). + expect(workflow).not.toMatch(/gate_helpers_sha256=\$\(cat /); + expect(workflow).not.toMatch(/GATE_HELPERS_NOW="\$\(cat /); // The container env is an explicit ALLOWLIST: everything unnamed is // absent inside, which is the isolation. The PAT, the host $GITHUB_ENV // and the runner's real $GITHUB_OUTPUT must never appear in it. @@ -6429,14 +6467,45 @@ exit 1 'if [[ "$(verdict_inputs_digest)" != "${INPUTS_BEFORE}" ]]', ), ).toBeLessThan(wrapper.indexOf('OUTCOME="$(verdict_value')); - for (const f of [ + // Pin the LOOP, not the filenames: `no-action.md` and + // `resolved-comments.txt` also appear in the comment above it, so a + // contains-check on the names passes even when they are dropped from the + // loop — and a dropped `no-action.md` is exactly the plant that publishes + // attacker text as the bot's "no changes needed" rationale. + // rc.json/rv.json feed the bite check AFTER the build legs (truncating + // rc.json flips BITE_ENFORCE off, so the bogus-fix round the bite check + // exists to reject exits 0 as `fixed`), and failure.md is read back by the + // host's report step. The gate writes none of these — gate-advisories.md + // and gate-rejection.md, which it does write, must stay out. + const fingerprinted = (wrapper.match(/for f in ([\s\S]*?); do/)?.[1] ?? '') + .replace(/\\\s+/g, ' ') + .trim() + .split(/\s+/); + expect(fingerprinted).toEqual([ 'no-action.md', 'address-summary.md', 'resolved-comments.txt', 'comment-replies.json', - ]) { - expect(wrapper).toContain(f); - } + 'rc.json', + 'rv.json', + 'failure.md', + ]); + // Type-aware, and non-regular paths are refused before anything hashes + // them: a symlink to gate-verdict makes the gate's own verdict writes trip + // the compare (exit 125 every round, so a deterministic rejection never + // lands), and a FIFO blocks sha256sum's open() until the step's timeout + // with no diagnostic. Neither needs a capability on the rw mount. + expect(wrapper).toContain("stat -c '%F'"); + expect(wrapper).toMatch(/NONREGULAR/); + expect(wrapper).toMatch( + /if \[\[ "\$\{INPUTS_BEFORE\}" == \*':NONREGULAR:'\* \]\]; then[\s\S]{0,240}?exit 125/, + ); + // The empty-GATE_IMAGE refusal is load-bearing (docker would otherwise + // take `bash` as the image name and every round becomes an opaque + // crash-retry with no ::error:: diagnostic), and it was pinned nowhere. + expect(wrapper).toMatch( + /if \[\[ -z "\$\{GATE_IMAGE:-\}" \]\]; then[\s\S]{0,300}?exit 125\nfi/, + ); // --rm only fires on a normal exit: a step timeout / job cap / cancel // kills the docker CLIENT and would leave the container running as the // runner uid with rw mounts on the shared workspace. Name it so the @@ -6539,6 +6608,20 @@ exit 1 expect(runTranslate(0, [])).toBe(''); // A docker/infra failure (125) leaves outcome unset too. expect(runTranslate(125, ['outcome=fixed'])).toBe(''); + // The `^` anchor in verdict_value's grep is load-bearing and every case + // above is blind to it (they all feed exact key names): without it, + // `tail -n 1` picks up an attacker-appended `xoutcome=fixed`, OUTCOME + // reads `fixed` at exit 1 and a genuine evaluated rejection is rerouted + // into the crash-retry loop. + expect(runTranslate(1, ['outcome=failed', 'xoutcome=fixed'])).toBe( + 'outcome=failed', + ); + // preexisting and retryable are mutually exclusive at the source, so an + // appended `retryable=true` must not ride a genuine preexisting rejection + // into a repair leg the repair agent is forbidden to act on. + expect( + runTranslate(1, ['retryable=true', 'outcome=failed', 'preexisting=true']), + ).toBe('outcome=failed\npreexisting=true'); rmSync(dir, { recursive: true, force: true }); }); @@ -10423,9 +10506,18 @@ exit 1 'packages/desktop-shell/.npmrc', 'eslint.legacy-filenames.mjs', '.github/workflows/qwen-pr-safety-precheck.yml', + '.github/scripts/run-autofix-gate-container.sh', ]); expect(classes).toContain('.github/actions/a/action.yml=ci-workflows'); expect(classes).toContain('.github/scripts/x.sh=ci-scripts'); + // The wrapper defines the container wall, so it must class as + // autofix-loop, not fall through to the `.github/scripts/*` catch-all: + // the class gate only rejects round classes absent from the PR's own, so + // as ci-scripts ANY PR touching ANY .github/scripts file would license a + // steered round to rewrite it — committed and PAT-pushed. + expect(classes).toContain( + '.github/scripts/run-autofix-gate-container.sh=autofix-loop', + ); expect(classes).toContain('.husky/pre-commit=git-hooks'); expect(classes).toContain('.npmrc=toolchain-config'); expect(classes).toContain('.nvmrc=toolchain-config'); From 8fc052470351274be06448758807126212b16aef Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 16 Aug 2026 16:02:47 +0800 Subject: [PATCH 06/11] test(autofix): resolve PAT wiring per job in the trust-boundary invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A job-level env: block grants the PAT to every step in the job and lives above the first `- name:`, so splitting the workflow into step chunks attributed it to the PREVIOUS job. Three jobs wire the PAT that way and their steps were reached only because an error message happens to spell the secret's name. Resolve the wiring per job instead. Also record what the loop does NOT assert: it is step-local, and the job-level property does not hold yet — issue-autofix still runs npm build/typecheck/lint/test inline on the host in the same job as the PAT-bearing 'Publish PR'. Asserted as a live fact so containerizing the issue path trips this and the invariant is widened with it. --- scripts/tests/qwen-autofix-workflow.test.js | 46 +++++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index ca71cef47c5..98e7aa2bb20 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -6630,18 +6630,30 @@ exit 1 // "the PAT is never in the same execution context as branch code". Assert // it structurally, so a future step that adds a build/test next to the PAT // fails here instead of silently re-opening the class. - const steps = workflow.split(/\n {6}- name: /).slice(1); + // Resolve the secret WIRING, not step text: a job-level `env:` grants the + // PAT to every step in the job, and it lives above the first `- name:`, so + // a step-chunk scan attributes it to the PREVIOUS job entirely by accident + // (three jobs here wire the PAT that way; their steps are reached today + // only because an error message happens to spell the secret's name). + const jobs = workflow + .slice(workflow.indexOf('\njobs:\n')) + .split(/\n {2}(?=[a-z][a-z0-9-]*:\n)/) + .slice(1); const branchCode = /bash "\$\{RUNNER_TEMP\}\/run-autofix-review-verification\.sh"|run-agent\.mjs|\bnpm (run|ci|test|install|i|rebuild|exec)\b|\bnpx |\bpnpm |\byarn /; const offenders = []; - for (const step of steps) { - if (!step.includes('CI_DEV_BOT_PAT')) continue; - const name = step.split('\n')[0].replace(/'/g, '').trim(); - const code = step - .split('\n') - .filter((l) => !l.trim().startsWith('#')) - .join('\n'); - if (branchCode.test(code)) offenders.push(name); + for (const job of jobs) { + const jobEnv = job.match(/\n {4}env:\n((?: {6}\S.*\n|\n)+)/)?.[1] ?? ''; + const jobHoldsPat = jobEnv.includes('CI_DEV_BOT_PAT'); + for (const step of job.split(/\n {6}- name: /).slice(1)) { + if (!jobHoldsPat && !step.includes('CI_DEV_BOT_PAT')) continue; + const name = step.split('\n')[0].replace(/'/g, '').trim(); + const code = step + .split('\n') + .filter((l) => !l.trim().startsWith('#')) + .join('\n'); + if (branchCode.test(code)) offenders.push(name); + } } // The ONE recorded exception: issue triage runs the agent while holding // the PAT, but it runs BEFORE any branch is checked out — the working @@ -6649,6 +6661,22 @@ exit 1 // branch-authored. Listed explicitly so a NEW agent/build invocation in a // PAT step shows up as a failure rather than joining a silent allowlist. expect(offenders).toEqual(['Assess candidates']); + // Scope, stated so it is not mistaken for more than it is: what the loop + // above asserts is STEP-local — no PAT-bearing step also runs branch code. + // The job-level property (no PAT anywhere in a job that runs branch code) + // does NOT hold yet: issue-autofix still runs its gate inline on the host, + // in the same job as the PAT-bearing 'Publish PR'. That is the remaining + // half of #9089 — this PR containerizes the review path only — and it is + // asserted here as a live fact so containerizing the issue path trips this + // and the invariant gets widened with it. + const issueAutofixJob = + jobs.find((j) => j.startsWith('issue-autofix:')) ?? ''; + const issueGateStep = issueAutofixJob + .split(/\n {6}- name: /) + .find((s) => s.startsWith("'Verification gate'")); + expect(issueGateStep).toBeTruthy(); + expect(branchCode.test(issueGateStep)).toBe(true); + expect(issueAutofixJob).toContain('CI_DEV_BOT_PAT'); }); it('escalates to a maintainer-decision handoff when the diff keeps growing past budget (non-convergence)', () => { From 7c70e7d323f8dc4f9686d9e255c68997ef7be143 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 16 Aug 2026 18:31:43 +0000 Subject: [PATCH 07/11] fix(autofix): close the host-side verdict channels around the gate container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the verdict-input fingerprint to every WORKDIR file the host's report/upsert steps consume post-gate (handoff.md, deferred-feedback.md, agent-api-error{,-kind}, agent-timeout, deferred-findings{,.carry}.json), reset the gate-authored gate-rejection.md pre-run, and mirror the regular-file type guard on the verdict file itself (rm -f before the truncation defeats pre-planted FIFO/symlink shapes). Refuse a verdict carrying both preexisting and retryable — the gate never emits both — as tampered instead of letting a planted key override the genuine one. Sever the runner's GITHUB_ENV/GITHUB_PATH/GITHUB_OUTPUT channels in both agent steps before the branch-code-bearing process tree starts: MCP stdio servers from a branch-committed .mcp.json spawn on the host and could otherwise plant a BASH_ENV that every later step shell sources before line 1, forging verdict output after the wrapper's honest write. Pin the gate image by content: resolve-sandbox-image.mjs now exports the pulled image's repo@sha256 digest reference (fail closed on none), which docker tag/build cannot move. Digest rc.json/rv.json at prepare time and verify in both gate steps before the wrapper's baseline runs, so an agent-step plant cannot become the baseline itself. --- .github/scripts/resolve-sandbox-image.mjs | 58 ++++++- .../scripts/resolve-sandbox-image.test.mjs | 52 +++++- .github/scripts/run-autofix-gate-container.sh | 90 +++++++--- .github/workflows/qwen-autofix.yml | 66 ++++++++ scripts/tests/qwen-autofix-workflow.test.js | 157 ++++++++++++++++-- 5 files changed, 383 insertions(+), 40 deletions(-) diff --git a/.github/scripts/resolve-sandbox-image.mjs b/.github/scripts/resolve-sandbox-image.mjs index fc3796bbba9..1f0f3a788d3 100644 --- a/.github/scripts/resolve-sandbox-image.mjs +++ b/.github/scripts/resolve-sandbox-image.mjs @@ -109,6 +109,60 @@ function pullImage(command, image) { }); } +// Resolve a PULLED image to its content digest (repo@sha256:…). The tag +// alone is a mutable local handle: `docker run ` resolves against the +// local store without re-pull, and a co-resident process with daemon access +// can `docker tag` different content under the same name between resolve +// and gate. A digest reference cannot be moved by `docker tag`/`docker build`. +export function repoDigestOf(command, image) { + return new Promise((resolve) => { + const child = spawn( + command, + ['image', 'inspect', '--format', '{{index .RepoDigests 0}}', image], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let stdout = ''; + let settled = false; + let timer; + const finish = (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + timer = setTimeout(() => { + console.error( + `::error::Timed out inspecting ${image} after ${FETCH_TIMEOUT_MS / 1000}s.`, + ); + child.kill('SIGKILL'); + finish(''); + }, FETCH_TIMEOUT_MS); + + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.on('error', (error) => { + console.error( + `::error::Failed to start '${command} image inspect ${image}': ${error.message}`, + ); + finish(''); + }); + child.on('close', (code) => { + finish(code === 0 ? stdout.trim() : ''); + }); + }).then((digest) => { + // `` is what the Go template prints for an image without + // RepoDigests (a locally built one); empty means the inspect failed. + // Either way the mutable tag is exactly what must not be exported. + if (!digest.includes('@sha256:')) { + throw new Error( + `Pulled image ${image} resolved to no repository digest ('${digest}'); refusing to export a mutable tag.`, + ); + } + return digest; + }); +} + export function exportImage(image) { if (process.env.GITHUB_ENV) { appendFileSync(process.env.GITHUB_ENV, `QWEN_SANDBOX_IMAGE=${image}\n`); @@ -127,7 +181,7 @@ async function main() { const command = process.env.SANDBOX_COMMAND || 'docker'; if (await pullImage(command, requestedImage)) { - exportImage(requestedImage); + exportImage(await repoDigestOf(command, requestedImage)); return; } @@ -145,7 +199,7 @@ async function main() { if (!(await pullImage(command, fallbackImage))) { throw new Error(`Fallback sandbox image failed to pull: ${fallbackImage}`); } - exportImage(fallbackImage); + exportImage(await repoDigestOf(command, fallbackImage)); } if ( diff --git a/.github/scripts/resolve-sandbox-image.test.mjs b/.github/scripts/resolve-sandbox-image.test.mjs index 8b6f90cdfbf..f9255900cef 100644 --- a/.github/scripts/resolve-sandbox-image.test.mjs +++ b/.github/scripts/resolve-sandbox-image.test.mjs @@ -1,13 +1,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { latestSemverTag, validateRequestedImage, exportImage, + repoDigestOf, } from './resolve-sandbox-image.mjs'; test('latestSemverTag returns the highest stable semver tag', () => { @@ -75,3 +76,52 @@ test('exportImage publishes the resolved image as a step output', () => { rmSync(dir, { recursive: true, force: true }); } }); + +function withDockerStub(scriptBody, fn) { + const dir = mkdtempSync(join(tmpdir(), 'sandbox-image-stub-')); + const stub = join(dir, 'docker-stub'); + try { + writeFileSync(stub, `#!/bin/sh\n${scriptBody}\n`, { mode: 0o755 }); + chmodSync(stub, 0o755); + return fn(stub); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test('repoDigestOf resolves a pulled image to its content digest', async () => { + await withDockerStub( + 'printf "%s\\n" "ghcr.io/qwenlm/qwen-code@sha256:0123456789abcdef"', + async (stub) => { + // The exported reference must be pinned by CONTENT: `docker tag` and + // `docker build` cannot move a digest reference, while the tag the + // image was pulled under can be retagged by any co-resident process + // with daemon access before the gate runs. + assert.equal( + await repoDigestOf(stub, 'ghcr.io/qwenlm/qwen-code:1.2.3'), + 'ghcr.io/qwenlm/qwen-code@sha256:0123456789abcdef', + ); + }, + ); +}); + +test('repoDigestOf refuses an image without a repository digest', async () => { + // `` is what `docker image inspect --format + // {{index .RepoDigests 0}}` prints for a locally built image; exporting + // the mutable tag in that state is exactly what the pin exists to block. + await withDockerStub('printf "%s\\n" ""', async (stub) => { + await assert.rejects( + repoDigestOf(stub, 'ghcr.io/qwenlm/qwen-code:1.2.3'), + /no repository digest/, + ); + }); +}); + +test('repoDigestOf fails closed when the inspect fails', async () => { + await withDockerStub('exit 1', async (stub) => { + await assert.rejects( + repoDigestOf(stub, 'ghcr.io/qwenlm/qwen-code:1.2.3'), + /no repository digest/, + ); + }); +}); diff --git a/.github/scripts/run-autofix-gate-container.sh b/.github/scripts/run-autofix-gate-container.sh index 2c76e227322..b4816663746 100755 --- a/.github/scripts/run-autofix-gate-container.sh +++ b/.github/scripts/run-autofix-gate-container.sh @@ -21,7 +21,11 @@ set -uo pipefail # a host-created verdict file plus the container's EXIT CODE — the exit code # is the unforgeable half (branch code can append to the mounted verdict file, # but it cannot make a failing gate exit 0), so a passing verdict is accepted -# only on exit 0. +# only on exit 0. The container wall does not, by itself, make the HOST-side +# translation shell trusted: the agent steps therefore sever the runner's +# $GITHUB_ENV/$GITHUB_PATH/$GITHUB_OUTPUT channels before the branch-code- +# bearing process tree starts (see 'Triage and address'), because a planted +# BASH_ENV is sourced by every later step shell before line 1. # # Invoked as a child `bash` from the host verify step, which digest-verifies # both this script and the gate script first. Inherits its environment from @@ -54,6 +58,9 @@ CRW="${CTEMP}/rw" # running leftover the trap exists to remove, which no janitor reaps (they # skip RUNNING containers). GATE_CONTAINER="qwen-code-gate-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-0}-$$" +# Referenced only through `trap` below, which shellcheck's reachability scan +# does not resolve (SC2317, visible under --enable=all). +# shellcheck disable=SC2317 teardown() { timeout 30 docker rm -f "${GATE_CONTAINER}" > /dev/null 2>&1 || true; } trap teardown EXIT INT TERM @@ -62,10 +69,24 @@ if [[ -z "${GATE_IMAGE:-}" ]]; then exit 125 fi +# Explicit unlink before the truncation: the verdict file is the one path +# this wrapper opens pre-run AND post-run, and WORKDIR persists within the +# round — a pre-planted FIFO blocks the truncation's write-open until the +# step timeout (a one-line per-round DoS of the verification loop), and a +# symlink to a fingerprinted input (e.g. rc.json) would truncate the target +# THROUGH the link before the baseline capture below records it, hiding the +# damage from the compare. +rm -f "${VERDICT}" : > "${VERDICT}" || { echo "::error::could not create the gate verdict file at ${VERDICT}" exit 125 } +# Single host-side reset for the gate-authored rejection detail: the gate +# writes it only on rejection paths, so a copy surviving a run that never +# rejected was planted, and the report steps would publish it as the bot's +# own rejection rationale. (The other gate-authored file resets itself: the +# gate rm -f's gate-advisories.md at its own start.) +rm -f "${WORKDIR}/gate-rejection.md" rm -rf "${CTEMP}" mkdir -p "${CBIN}" "${CRW}" || exit 125 for staged in run-autofix-review-verification.sh check-settings-schema.sh \ @@ -81,19 +102,21 @@ done # root-owned files — the same failure the job's ownership-restore step exists # for. --env is an explicit allowlist: anything not named here is simply # absent inside, which is the whole point. -# The gate reads agent-authored verdict INPUTS from the rw WORKDIR mount -# after its build legs — branch code in the container can plant -# `no-action.md` to manufacture a `noop` (published as the bot's own "no -# changes needed" rationale, advancing the watermark) or plant -# `resolved-comments.txt` to close human review threads. The gate never -# writes these four, so fingerprint them before the run and refuse the -# verdict if they moved. -# rc.json/rv.json are the bite check's inputs, read AFTER the build legs: -# truncating rc.json flips BITE_ENFORCE to false, so the bogus-fix round the -# bite check exists to reject exits 0 as `fixed`. failure.md is read back by -# the host's report step, so a plant publishes attacker text as the bot's -# rejection. The gate writes none of these (only gate-advisories.md and -# gate-rejection.md, which is why those are absent here). +# The WORKDIR mount is rw, and everything fingerprinted below is a file the +# gate NEVER writes yet some consumer reads after the run — so branch code +# in the container, awake for the whole run, can plant them to steer what +# the host publishes or routes: no-action.md manufactures a noop published +# as the bot's own "no changes needed" rationale, resolved-comments.txt +# closes human review threads, handoff.md is picked by the report step's +# detail chain AHEAD of the fingerprinted summary files, agent-api-error +# forces the model-error retry sentinel and injects its text into the +# published headline, rc.json truncated flips the bite check's BITE_ENFORCE +# off so the bogus-fix round it exists to reject exits 0 as `fixed`, and +# deferred-findings.json feeds the post-gate upsert. Fingerprint them +# before the run and refuse the verdict if they moved. The two gate- +# authored files stay out of the set for the opposite reason (see the +# resets above): fingerprinting them would make the gate's own writes trip +# the compare. # The type is part of the fingerprint, and non-regular paths are never # hashed: a symlink to gate-verdict would make the gate's own verdict writes # trip the compare (exit 125 every round, suppressing the rejection), and a @@ -102,7 +125,9 @@ done verdict_inputs_digest() { local f type for f in no-action.md address-summary.md resolved-comments.txt \ - comment-replies.json rc.json rv.json failure.md; do + comment-replies.json rc.json rv.json failure.md handoff.md \ + deferred-feedback.md agent-api-error agent-api-error-kind \ + agent-timeout deferred-findings.json deferred-findings.carry.json; do type="$(stat -c '%F' "${WORKDIR}/${f}" 2> /dev/null || true)" case "${type}" in '' | 'regular file' | 'regular empty file') @@ -155,6 +180,20 @@ if [[ "$(verdict_inputs_digest)" != "${INPUTS_BEFORE}" ]]; then exit 125 fi +# Mirror the inputs' type discipline on the verdict file itself BEFORE +# anything opens it: the container can swap it after the run — rm + mkfifo +# while holding a reader fd (the gate's appends succeed inside, then the +# grep below blocks its read-open until the step timeout) or a symlink, +# whose target the extraction would then read with host permissions. +VERDICT_TYPE="$(stat -c '%F' "${VERDICT}" 2> /dev/null || true)" +case "${VERDICT_TYPE}" in + 'regular file' | 'regular empty file') : ;; + *) + echo "::error::the gate verdict file at ${VERDICT} is not a regular file (${VERDICT_TYPE:-missing}) after the run — refusing to read it." + exit 125 + ;; +esac + # Verdict translation. Only these keys are forwarded, last value wins (the # gate appends its final verdict last), and `outcome` is gated on the exit # code — the file alone is not authority. @@ -185,6 +224,19 @@ case "${GATE_RC}" in fi ;; 1) + # preexisting and retryable are mutually exclusive at the source + # (reject_fix: a pre-existing failure is NOT retryable — the repair + # agent may only amend this round's fix). Both present is therefore + # proof of an append the gate never made, so refuse the verdict BEFORE + # forwarding any of it: a planted `preexisting=true` overriding a + # genuine `retryable=true` would otherwise skip the repair the round is + # entitled to and permanently misclassify a fixable rejection as a + # terminal pre-existing failure. The crash path retries with a fresh + # checkout instead. + if [[ "${PREEXISTING}" == 'true' && "${RETRYABLE}" == 'true' ]]; then + echo "::error::verdict carries both preexisting and retryable — the gate never emits both; refusing the verdict as tampered." + exit 125 + fi # A deterministic rejection: reject_fix writes outcome=failed to the file # and exits 1. Take `failed` only from the FILE — the gate also has # exit-1 paths that deliberately write NO verdict (the baseline-A/B and @@ -195,14 +247,8 @@ case "${GATE_RC}" in # so here it leaves the outcome unset and the round retries. if [[ "${OUTCOME}" == 'failed' ]]; then echo "outcome=failed" >> "${GITHUB_OUTPUT}" - # preexisting and retryable are mutually exclusive at the source - # (reject_fix: a pre-existing failure is NOT retryable — the repair - # agent may only amend this round's fix). Forwarding them independently - # admits a pair the gate never emits: an appended `retryable=true` rides - # a genuine preexisting rejection into an 18-minute repair leg the - # repair agent is forbidden to act on, burned every round. [[ "${PREEXISTING}" == 'true' ]] && echo "preexisting=true" >> "${GITHUB_OUTPUT}" - [[ "${PREEXISTING}" != 'true' && "${RETRYABLE}" == 'true' ]] && echo "retryable=true" >> "${GITHUB_OUTPUT}" + [[ "${RETRYABLE}" == 'true' ]] && echo "retryable=true" >> "${GITHUB_OUTPUT}" else echo "::warning::gate container exited 1 without a deterministic verdict (outcome='${OUTCOME}') — reporting as a gate crash so the next scan retries." fi diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index d7d4391667d..0191aa50c29 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -4591,6 +4591,19 @@ jobs: --json statusCheckRollup --jq '.statusCheckRollup // []' > "${WORKDIR}/checks.json" \ 2> /dev/null || echo '[]' > "${WORKDIR}/checks.json" + # The gate reads rc.json/rv.json AFTER its build legs (the bite + # check), but the wrapper's verdict-input baseline is captured at + # gate start — AFTER the agent step, whose sandbox mounts WORKDIR + # (/tmp) rw at the same path as the runner uid. A truncation + # planted during the agent step would therefore BECOME the + # baseline and stay invisible to the compare. Digest these two + # host-authored files at creation time into expression context — + # the same shape as the staged-script digests — for the gate + # steps to verify before the wrapper's baseline runs. Per-file + # digests, so a boundary shift between the two files cannot keep + # the value stable. + echo "verdict_inputs_sha256=$(sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/rv.json" | sha256sum | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" + # Newest actionable feedback timestamp — stamped into the eval marker so # the next scan knows everything up to here has been considered. # Includes reviews, inline review comments, and issue-level PR comments @@ -5532,6 +5545,22 @@ jobs: QWEN_TIMEOUT_MS="${BUDGET_CAP_MS}" fi export QWEN_TIMEOUT_MS + # Sever the runner's mutation channels before the branch-code- + # bearing process tree starts (#9089): MCP stdio servers from a + # branch-committed .mcp.json spawn as HOST processes in this + # step's env (run-agent.mjs runs --yolo, which waives the + # .mcp.json approval gate) and inherit GITHUB_ENV/GITHUB_PATH/ + # GITHUB_OUTPUT. An append there reaches every later step of + # this job: bash sources a planted BASH_ENV before line 1 of the + # gate steps — before the PATH pin, the digest checks and the + # wrapper — and a planted EXIT trap appends a forged verdict to + # the runner-provided output file AFTER the wrapper's honest + # write; runner output parsing is last-wins, so the forgery + # would displace it. The container wall does not cover this — + # the forgery lands host-side, outside the container. This step + # writes no outputs of its own, so the channels are dropped for + # the whole agent process tree. + unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT # Trusted staged copy in the mirrored layout — resolves # ../SKILL.md to the trusted staged SKILL, never the PR branch's. node "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs" \ @@ -5560,6 +5589,11 @@ jobs: VERIFY_RUNNER_SHA256: '${{ steps.stage.outputs.verify_runner_sha256 }}' GATE_CONTAINER_SHA256: '${{ steps.stage.outputs.gate_container_sha256 }}' GATE_HELPERS_SHA256: '${{ steps.stage.outputs.gate_helpers_sha256 }}' + # The bite check's inputs, digested at prepare time (see + # 'Prepare branch and feedback'): verified below before the + # wrapper's baseline runs, so a plant during the agent step + # cannot become the baseline itself. + VERDICT_INPUTS_SHA256: '${{ steps.prepare.outputs.verdict_inputs_sha256 }}' # The image the gate runs in, from the resolve step's OUTPUT rather # than $GITHUB_ENV: branch code must not be able to choose it. GATE_IMAGE: '${{ steps.sandbox.outputs.image }}' @@ -5589,6 +5623,17 @@ jobs: echo "::error::staged gate helpers no longer match the digest recorded at staging time; refusing to run the gate." exit 1 fi + # rc.json/rv.json are host-authored in prepare but read by the + # gate AFTER its build legs, and the wrapper's baseline is + # captured only at gate start — after the agent step, whose + # sandbox mounts WORKDIR rw at the same path as the runner uid. + # Verify the prepare-time digest first, so a plant during the + # agent step fails here instead of becoming the baseline. + VERDICT_INPUTS_NOW="$(sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/rv.json" | sha256sum | cut -d' ' -f1)" + if [[ "${VERDICT_INPUTS_NOW}" != "${VERDICT_INPUTS_SHA256}" ]]; then + echo "::error::verdict inputs rc.json/rv.json no longer match the digest recorded at prepare time; refusing to run the gate." + exit 1 + fi bash "${RUNNER_TEMP}/run-autofix-gate-container.sh" - name: 'Repair deterministic rejection' @@ -5730,6 +5775,11 @@ jobs: mkdir -p .qwen "${QWEN_HOME}" printf '%s\n' "${SETTINGS_JSON}" > .qwen/settings.json git config core.hooksPath .husky + # Same severing as 'Triage and address': the repair agent runs + # branch code again, and its attempted= output was already + # written above, so the runner's mutation channels are dropped + # for the whole agent process tree. + unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT node "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs" \ --mode address-review \ --pr "${PR}" \ @@ -5750,6 +5800,11 @@ jobs: VERIFY_RUNNER_SHA256: '${{ steps.stage.outputs.verify_runner_sha256 }}' GATE_CONTAINER_SHA256: '${{ steps.stage.outputs.gate_container_sha256 }}' GATE_HELPERS_SHA256: '${{ steps.stage.outputs.gate_helpers_sha256 }}' + # The bite check's inputs, digested at prepare time (see + # 'Prepare branch and feedback'): verified below before the + # wrapper's baseline runs, so a plant during the agent step + # cannot become the baseline itself. + VERDICT_INPUTS_SHA256: '${{ steps.prepare.outputs.verdict_inputs_sha256 }}' # The image the gate runs in, from the resolve step's OUTPUT rather # than $GITHUB_ENV: branch code must not be able to choose it. GATE_IMAGE: '${{ steps.sandbox.outputs.image }}' @@ -5779,6 +5834,17 @@ jobs: echo "::error::staged gate helpers no longer match the digest recorded at staging time; refusing to run the gate." exit 1 fi + # rc.json/rv.json are host-authored in prepare but read by the + # gate AFTER its build legs, and the wrapper's baseline is + # captured only at gate start — after the agent step, whose + # sandbox mounts WORKDIR rw at the same path as the runner uid. + # Verify the prepare-time digest first, so a plant during the + # agent step fails here instead of becoming the baseline. + VERDICT_INPUTS_NOW="$(sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/rv.json" | sha256sum | cut -d' ' -f1)" + if [[ "${VERDICT_INPUTS_NOW}" != "${VERDICT_INPUTS_SHA256}" ]]; then + echo "::error::verdict inputs rc.json/rv.json no longer match the digest recorded at prepare time; refusing to run the gate." + exit 1 + fi bash "${RUNNER_TEMP}/run-autofix-gate-container.sh" - name: 'Finalize verification' diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 099d24919af..e0dde244f38 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -6844,6 +6844,40 @@ exit 1 ).toBeLessThan( reviewAddressJob.indexOf("- name: 'Prepare branch and feedback'"), ); + // The image is pinned by CONTENT, not just by name: after the pull the + // resolve script exports repo@sha256:…, which `docker tag`/`docker + // build` cannot move. A mutable tag lets a co-resident process with + // daemon access retag different content under the resolved name + // between resolve and gate, and `docker run` follows the swapped tag + // without re-pull — the gate would execute the doctored runtime. + expect(sandboxImageResolverScript).toContain( + 'exportImage(await repoDigestOf(command, requestedImage))', + ); + expect(sandboxImageResolverScript).toContain( + 'exportImage(await repoDigestOf(command, fallbackImage))', + ); + // The container wall does not, by itself, make the host-side + // translation shell trusted: MCP stdio servers from a branch-committed + // .mcp.json spawn as HOST processes during the agent steps (run-agent + // runs --yolo, waiving the approval gate) and can append BASH_ENV to + // the job's $GITHUB_ENV — sourced by every later step shell before + // line 1, an EXIT trap appending a forged verdict AFTER the wrapper's + // honest write (runner output parsing is last-wins). The agent steps + // therefore sever the runner's mutation channels before the + // branch-code-bearing process tree starts. + for (const agentStep of [ + triageAndAddressStep, + repairDeterministicRejectionStep, + ]) { + expect(agentStep).toContain('unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT'); + expect( + agentStep.indexOf('unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT'), + ).toBeLessThan( + agentStep.indexOf( + 'node "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs"', + ), + ); + } // The three helpers the gate EXECUTES are staged in the same writable // RUNNER_TEMP, so they are digested at staging and verified before the // wrapper copies them across the wall. @@ -6874,6 +6908,33 @@ exit 1 // the per-file digest is not). expect(workflow).not.toMatch(/gate_helpers_sha256=\$\(cat /); expect(workflow).not.toMatch(/GATE_HELPERS_NOW="\$\(cat /); + // rc.json/rv.json are host-authored in prepare but read by the gate + // AFTER its build legs, and the wrapper's verdict-input baseline is + // captured only at gate start — AFTER the agent step, whose sandbox + // mounts WORKDIR rw at the same path as the runner uid. A truncation + // planted during the agent step would therefore BECOME the baseline + // and stay invisible to the compare, so prepare digests the two files + // at creation time and BOTH gate steps verify the digest before the + // wrapper runs. Writer and reader must digest the same files in the + // same order, as with the helpers above. + expect(prepareBranchAndFeedbackStep).toContain( + 'echo "verdict_inputs_sha256=$(sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/rv.json" | sha256sum | cut -d\' \' -f1)" >> "${GITHUB_OUTPUT}"', + ); + const verdictInputsBlob = prepareBranchAndFeedbackStep.match( + /echo "verdict_inputs_sha256=\$\(sha256sum (.*?) \| sha256sum/, + )?.[1]; + expect(verdictInputsBlob).toBeTruthy(); + for (const gate of [verificationGateSteps[1], repairVerificationGateStep]) { + expect(gate).toContain( + "VERDICT_INPUTS_SHA256: '${{ steps.prepare.outputs.verdict_inputs_sha256 }}'", + ); + expect(gate).toContain( + `VERDICT_INPUTS_NOW="$(sha256sum ${verdictInputsBlob} | sha256sum`, + ); + expect(gate.indexOf('VERDICT_INPUTS_NOW="$(sha256sum ')).toBeLessThan( + gate.indexOf('bash "${RUNNER_TEMP}/run-autofix-gate-container.sh"'), + ); + } // The container env is an explicit ALLOWLIST: everything unnamed is // absent inside, which is the isolation. The PAT, the host $GITHUB_ENV // and the runner's real $GITHUB_OUTPUT must never appear in it. @@ -6888,6 +6949,13 @@ exit 1 expect(wrapper).toMatch( /"\$\{GATE_IMAGE\}" \\\n\s*bash "\$\{CBIN\}\/run-autofix-review-verification\.sh"\s*$/m, ); + // The exit-code capture is the unforgeable half of the verdict, and it + // sits in the seam between the command pin above and the translation + // extraction below — pin the adjacency: a mutant hardcoding GATE_RC=0 + // forwards a planted outcome=fixed from a failing gate. + expect(wrapper).toMatch( + /bash "\$\{CBIN\}\/run-autofix-review-verification\.sh"\nGATE_RC=\$\?/, + ); // What the loop stages into CBIN must be exactly the gate plus the three // helpers the workflow digests. Dropping one (e.g. check-autofix- // contracts.sh) makes the gate invoke a missing helper INSIDE the @@ -6990,6 +7058,12 @@ exit 1 'if [[ "$(verdict_inputs_digest)" != "${INPUTS_BEFORE}" ]]', ), ).toBeLessThan(wrapper.indexOf('OUTCOME="$(verdict_value')); + // …and the refusal must actually ABORT: deleting this exit leaves every + // pin above green while a tampered run sails into the translation and + // forwards outcome=fixed derived from planted inputs. + expect(wrapper).toMatch( + /refusing the verdict; the next scan retries\."\n {2}exit 125/, + ); // Pin the LOOP, not the filenames: `no-action.md` and // `resolved-comments.txt` also appear in the comment above it, so a // contains-check on the names passes even when they are dropped from the @@ -6997,9 +7071,16 @@ exit 1 // attacker text as the bot's "no changes needed" rationale. // rc.json/rv.json feed the bite check AFTER the build legs (truncating // rc.json flips BITE_ENFORCE off, so the bogus-fix round the bite check - // exists to reject exits 0 as `fixed`), and failure.md is read back by the - // host's report step. The gate writes none of these — gate-advisories.md - // and gate-rejection.md, which it does write, must stay out. + // exists to reject exits 0 as `fixed`), and the host's post-gate report + // and upsert steps consume the rest: failure.md and handoff.md are the + // report step's detail chain (a planted handoff.md is picked AHEAD of + // the agent's own summary), agent-api-error/-kind/agent-timeout steer + // the retry sentinel and the published headline, deferred-feedback.md + // rides the push comment, and deferred-findings.json feeds the + // post-gate upsert. The gate writes none of these — gate-advisories.md + // and gate-rejection.md, which it does write, must stay out (the + // wrapper resets gate-rejection.md before the run; a surviving copy + // from a run that never rejected was planted). const fingerprinted = (wrapper.match(/for f in ([\s\S]*?); do/)?.[1] ?? '') .replace(/\\\s+/g, ' ') .trim() @@ -7012,6 +7093,13 @@ exit 1 'rc.json', 'rv.json', 'failure.md', + 'handoff.md', + 'deferred-feedback.md', + 'agent-api-error', + 'agent-api-error-kind', + 'agent-timeout', + 'deferred-findings.json', + 'deferred-findings.carry.json', ]); // Type-aware, and non-regular paths are refused before anything hashes // them: a symlink to gate-verdict makes the gate's own verdict writes trip @@ -7023,6 +7111,18 @@ exit 1 expect(wrapper).toMatch( /if \[\[ "\$\{INPUTS_BEFORE\}" == \*':NONREGULAR:'\* \]\]; then[\s\S]{0,240}?exit 125/, ); + // …and mirrored on the verdict file itself BEFORE anything reads it: + // the container can swap it post-run — rm + mkfifo while holding a + // reader fd (the gate's appends succeed inside, then verdict_value's + // grep blocks its read-open until the step timeout) or a symlink, + // whose target the extraction would then read with host permissions. + expect(wrapper).toMatch(/VERDICT_TYPE="\$\(stat -c '%F' "\$\{VERDICT\}"/); + expect(wrapper).toMatch( + /is not a regular file \(\$\{VERDICT_TYPE:-missing\}\)[\s\S]{0,120}?exit 125/, + ); + expect(wrapper.indexOf('VERDICT_TYPE="$(stat')).toBeLessThan( + wrapper.indexOf('OUTCOME="$(verdict_value'), + ); // The empty-GATE_IMAGE refusal is load-bearing (docker would otherwise // take `bash` as the image name and every round becomes an opaque // crash-retry with no ::error:: diagnostic), and it was pinned nowhere. @@ -7069,9 +7169,26 @@ exit 1 // branch code needed; the behavioural harness below writes a fresh file // per case, so it cannot see the cross-pass reuse. expect(wrapper).toContain(': > "${VERDICT}"'); + // Explicit unlink BEFORE the truncation: WORKDIR persists within the + // round, so a pre-planted FIFO blocks the truncation's write-open until + // the step timeout (a one-line per-round DoS of the verification loop), + // and a symlink to a fingerprinted input would truncate the target + // THROUGH the link before the baseline capture records it. + expect(wrapper).toContain('rm -f "${VERDICT}"'); + expect(wrapper.indexOf('rm -f "${VERDICT}"')).toBeLessThan( + wrapper.indexOf(': > "${VERDICT}"'), + ); expect(wrapper.indexOf(': > "${VERDICT}"')).toBeLessThan( wrapper.indexOf('docker run --rm'), ); + // The gate-authored rejection detail resets at the same single point: + // the gate writes it only on rejection paths, so a surviving copy from + // a run that never rejected was planted, and the report steps would + // publish it inside the rejection markers as the bot's own rationale. + expect(wrapper).toContain('rm -f "${WORKDIR}/gate-rejection.md"'); + expect( + wrapper.indexOf('rm -f "${WORKDIR}/gate-rejection.md"'), + ).toBeLessThan(wrapper.indexOf('docker run --rm')); expect(wrapper).toContain('set -uo pipefail'); expect(wrapper).not.toMatch(/^set -e/m); expect(wrapper.trimEnd().endsWith('exit "${GATE_RC}"')).toBe(true); @@ -7081,14 +7198,19 @@ exit 1 const out = join(dir, 'gh-output'); writeFileSync(verdict, verdictLines.join('\n') + '\n'); writeFileSync(out, ''); - execFileSync( - 'bash', - [ - '-c', - `set -uo pipefail\nVERDICT='${verdict}'\nGITHUB_OUTPUT='${out}'\nGATE_RC=${rc}\n${translate}\nexit 0`, - ], - { encoding: 'utf8' }, - ); + try { + execFileSync( + 'bash', + [ + '-c', + `set -uo pipefail\nVERDICT='${verdict}'\nGITHUB_OUTPUT='${out}'\nGATE_RC=${rc}\n${translate}\nexit 0`, + ], + { encoding: 'utf8' }, + ); + } catch { + // A tamper refusal exits 125 mid-translation; the output file + // (which it deliberately left empty) is still the assertion target. + } return readFileSync(out, 'utf8').trim(); }; // A genuine pass carries through. @@ -7139,12 +7261,17 @@ exit 1 expect(runTranslate(1, ['outcome=failed', 'xoutcome=fixed'])).toBe( 'outcome=failed', ); - // preexisting and retryable are mutually exclusive at the source, so an - // appended `retryable=true` must not ride a genuine preexisting rejection - // into a repair leg the repair agent is forbidden to act on. + // preexisting and retryable are mutually exclusive at the source + // (reject_fix writes one or the other), so BOTH present is proof of an + // append the gate never made. The translation refuses the verdict as + // tampered — nothing forwarded, crash-retry path — instead of guessing + // which flag is genuine: a planted `preexisting=true` overriding a + // genuine `retryable=true` would otherwise skip the repair the round is + // entitled to and permanently misclassify a fixable rejection as a + // terminal pre-existing failure. expect( runTranslate(1, ['retryable=true', 'outcome=failed', 'preexisting=true']), - ).toBe('outcome=failed\npreexisting=true'); + ).toBe(''); rmSync(dir, { recursive: true, force: true }); }); From bd268f36f448eaeced11a259a40e87f4309b3752 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 16 Aug 2026 23:27:35 +0000 Subject: [PATCH 08/11] fix(autofix): address round-5 gate-wall review findings (#9214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five Criticals, each reproduced before fixing: - R5-25: a lone forged retryable=true appended to a non-retryable rejection (the bite check wrote no flags) flipped it into a PAT-backed repair round. reject_fix now always writes BOTH routing flags, and the wrapper refuses any verdict whose flag lines are missing, repeated, or both true. - R5-24: ic.json feeds the post-gate breaker file-preferred but was in neither the wrapper fingerprint set nor the prepare-time digest; a planted copy tripped the consecutive-failure cap (probe: 4 forged headlines => terminal). It joins both channels. - R5-7: the host-side verdict-input hash read attacker-writable WORKDIR paths bare — a planted FIFO hung sha256sum until the 60-minute step timeout (probe: exit 124). Both gate steps now stat the inputs before hashing, mirroring the wrapper's type discipline. - R5-26: the unset severed only variable inheritance; the runner reads the step's channel files at step END, so a planted BASH_ENV fired in the gate steps before line 1 (probe: payload sourced before line 1; empty BASH_ENV inert). Both agent steps capture and unlink the files after the agent returns, and both gate steps pin BASH_ENV empty. - R5-1: withDockerStub's synchronous finally unlinked the stub before the async body settled (probe: 23/30 loop failures); return await plus a loop regression test. Plus three coupled Suggestions: R4-3 (per-file attribution in the gate digest checks), R5-2 (pins for the digest-check enforcement exits), R5-15 (GATE_RC must stay the only assignment). Pins and the verdict harness updated; all 176 workflow tests green. --- .../scripts/resolve-sandbox-image.test.mjs | 24 ++- .github/scripts/run-autofix-gate-container.sh | 55 +++--- .../run-autofix-review-verification.sh | 12 +- .github/workflows/qwen-autofix.yml | 131 +++++++++++---- scripts/tests/qwen-autofix-workflow.test.js | 158 ++++++++++++++---- 5 files changed, 284 insertions(+), 96 deletions(-) diff --git a/.github/scripts/resolve-sandbox-image.test.mjs b/.github/scripts/resolve-sandbox-image.test.mjs index f9255900cef..5fcf98b4405 100644 --- a/.github/scripts/resolve-sandbox-image.test.mjs +++ b/.github/scripts/resolve-sandbox-image.test.mjs @@ -77,13 +77,17 @@ test('exportImage publishes the resolved image as a step output', () => { } }); -function withDockerStub(scriptBody, fn) { +// async + `return await`: a bare `return fn(stub)` would run the `finally` +// unlink BEFORE the async body's promise settles, racing the spawned child's +// script-open — the parent wins often enough to flake the success path with +// a misleading 'no repository digest' error (probe: 23/30 loops failed). +async function withDockerStub(scriptBody, fn) { const dir = mkdtempSync(join(tmpdir(), 'sandbox-image-stub-')); const stub = join(dir, 'docker-stub'); try { writeFileSync(stub, `#!/bin/sh\n${scriptBody}\n`, { mode: 0o755 }); chmodSync(stub, 0o755); - return fn(stub); + return await fn(stub); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -105,6 +109,22 @@ test('repoDigestOf resolves a pulled image to its content digest', async () => { ); }); +test('withDockerStub keeps the stub alive until the async body settles', async () => { + // One success-path call per process hides the unlink race above, so drive + // the spawn→open window in a loop. + for (let i = 0; i < 30; i++) { + await withDockerStub( + 'printf "%s\\n" "ghcr.io/qwenlm/qwen-code@sha256:0123456789abcdef"', + async (stub) => { + assert.equal( + await repoDigestOf(stub, 'ghcr.io/qwenlm/qwen-code:1.2.3'), + 'ghcr.io/qwenlm/qwen-code@sha256:0123456789abcdef', + ); + }, + ); + } +}); + test('repoDigestOf refuses an image without a repository digest', async () => { // `` is what `docker image inspect --format // {{index .RepoDigests 0}}` prints for a locally built image; exporting diff --git a/.github/scripts/run-autofix-gate-container.sh b/.github/scripts/run-autofix-gate-container.sh index b4816663746..6933ebcf98b 100755 --- a/.github/scripts/run-autofix-gate-container.sh +++ b/.github/scripts/run-autofix-gate-container.sh @@ -111,7 +111,9 @@ done # detail chain AHEAD of the fingerprinted summary files, agent-api-error # forces the model-error retry sentinel and injects its text into the # published headline, rc.json truncated flips the bite check's BITE_ENFORCE -# off so the bogus-fix round it exists to reject exits 0 as `fixed`, and +# off so the bogus-fix round it exists to reject exits 0 as `fixed`, ic.json +# feeds the post-gate consecutive-failure breaker file-preferred, so planted +# bot failure headlines trip its cap and mark the PR terminal, and # deferred-findings.json feeds the post-gate upsert. Fingerprint them # before the run and refuse the verdict if they moved. The two gate- # authored files stay out of the set for the opposite reason (see the @@ -125,7 +127,7 @@ done verdict_inputs_digest() { local f type for f in no-action.md address-summary.md resolved-comments.txt \ - comment-replies.json rc.json rv.json failure.md handoff.md \ + comment-replies.json rc.json rv.json ic.json failure.md handoff.md \ deferred-feedback.md agent-api-error agent-api-error-kind \ agent-timeout deferred-findings.json deferred-findings.carry.json; do type="$(stat -c '%F' "${WORKDIR}/${f}" 2> /dev/null || true)" @@ -224,32 +226,39 @@ case "${GATE_RC}" in fi ;; 1) - # preexisting and retryable are mutually exclusive at the source - # (reject_fix: a pre-existing failure is NOT retryable — the repair - # agent may only amend this round's fix). Both present is therefore - # proof of an append the gate never made, so refuse the verdict BEFORE - # forwarding any of it: a planted `preexisting=true` overriding a - # genuine `retryable=true` would otherwise skip the repair the round is - # entitled to and permanently misclassify a fixable rejection as a - # terminal pre-existing failure. The crash path retries with a fresh - # checkout instead. - if [[ "${PREEXISTING}" == 'true' && "${RETRYABLE}" == 'true' ]]; then - echo "::error::verdict carries both preexisting and retryable — the gate never emits both; refusing the verdict as tampered." - exit 125 - fi - # A deterministic rejection: reject_fix writes outcome=failed to the file - # and exits 1. Take `failed` only from the FILE — the gate also has - # exit-1 paths that deliberately write NO verdict (the baseline-A/B and - # bite tree-restore failures), where an EVALUATED rejection would advance - # the watermark and hand the item off for good, and an unset outcome is - # what routes them to the gate-crashed retry instead. A forged - # `outcome=fixed` still cannot pass: `fixed` is accepted only on exit 0, - # so here it leaves the outcome unset and the round retries. + # A deterministic rejection (reject_fix) writes outcome=failed plus BOTH + # routing flags, exactly once each and mutually exclusive at the source + # (a pre-existing failure is NOT retryable — the repair agent may only + # amend this round's fix). Any other shape — a flag line missing, + # repeated, or both true — is proof the file was touched after the gate + # wrote it (branch code can append to the mounted verdict file), so + # refuse the verdict BEFORE forwarding any of it: a lone forged + # `retryable=true` would otherwise flip a deliberately non-retryable + # rejection (e.g. the bite check) into a PAT-backed repair round, and a + # planted `preexisting=true` overriding a genuine `retryable=true` would + # skip the repair the round is entitled to and permanently misclassify a + # fixable rejection as a terminal pre-existing failure. The crash path + # retries with a fresh checkout instead. if [[ "${OUTCOME}" == 'failed' ]]; then + RETRYABLE_LINES="$(grep -c '^retryable=' "${VERDICT}" 2> /dev/null || true)" + PREEXISTING_LINES="$(grep -c '^preexisting=' "${VERDICT}" 2> /dev/null || true)" + if [[ "${RETRYABLE_LINES:-0}" -ne 1 ]] || + [[ "${PREEXISTING_LINES:-0}" -ne 1 ]] || + [[ "${PREEXISTING}" == 'true' && "${RETRYABLE}" == 'true' ]]; then + echo "::error::verdict carries a forged routing flag (retryable lines: ${RETRYABLE_LINES:-0}, preexisting lines: ${PREEXISTING_LINES:-0}) — refusing the verdict as tampered." + exit 125 + fi echo "outcome=failed" >> "${GITHUB_OUTPUT}" [[ "${PREEXISTING}" == 'true' ]] && echo "preexisting=true" >> "${GITHUB_OUTPUT}" [[ "${RETRYABLE}" == 'true' ]] && echo "retryable=true" >> "${GITHUB_OUTPUT}" else + # Take `failed` only from the FILE — the gate also has exit-1 paths + # that deliberately write NO verdict (the baseline-A/B and bite + # tree-restore failures), where an EVALUATED rejection would advance + # the watermark and hand the item off for good, and an unset outcome is + # what routes them to the gate-crashed retry instead. A forged + # `outcome=fixed` still cannot pass: `fixed` is accepted only on exit 0, + # so here it leaves the outcome unset and the round retries. echo "::warning::gate container exited 1 without a deterministic verdict (outcome='${OUTCOME}') — reporting as a gate crash so the next scan retries." fi ;; diff --git a/.github/scripts/run-autofix-review-verification.sh b/.github/scripts/run-autofix-review-verification.sh index 015496e0423..3056adc3a64 100755 --- a/.github/scripts/run-autofix-review-verification.sh +++ b/.github/scripts/run-autofix-review-verification.sh @@ -102,14 +102,22 @@ reject_fix() { # step means the gate itself crashed, so losing the detail file must not turn # a deterministic rejection into an infrastructure retry. echo "outcome=failed" >> "${GITHUB_OUTPUT}" + # Write BOTH routing flags on every rejection: the host-side translation + # refuses a verdict whose retryable=/preexisting= lines are missing, + # repeated, or both true, and branch code can append to the mounted verdict + # file — a lone forged `retryable=true` used to flip a deliberately + # non-retryable rejection (e.g. the bite check, which wrote neither flag) + # into a PAT-backed repair round. if [[ "${preexisting}" == 'true' ]]; then # NOT retryable: the repair agent is only allowed to amend this round's # fix, and a failure that exists without the fix is outside that boundary # by definition — the 18-minute repair budget cannot reach it. The remedy # is a base update (merge main into the branch), not a repair. echo "preexisting=true" >> "${GITHUB_OUTPUT}" - elif [[ "${retryable}" == 'true' ]]; then - echo "retryable=true" >> "${GITHUB_OUTPUT}" + echo "retryable=false" >> "${GITHUB_OUTPUT}" + else + echo "preexisting=false" >> "${GITHUB_OUTPUT}" + echo "retryable=${retryable}" >> "${GITHUB_OUTPUT}" fi # The evidence tail flexes so the WHOLE document stays under the report # step's head -c 3900 render cap: truncating the finished document from diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 0191aa50c29..f362d25c8a1 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -4592,17 +4592,17 @@ jobs: 2> /dev/null || echo '[]' > "${WORKDIR}/checks.json" # The gate reads rc.json/rv.json AFTER its build legs (the bite - # check), but the wrapper's verdict-input baseline is captured at - # gate start — AFTER the agent step, whose sandbox mounts WORKDIR - # (/tmp) rw at the same path as the runner uid. A truncation - # planted during the agent step would therefore BECOME the - # baseline and stay invisible to the compare. Digest these two - # host-authored files at creation time into expression context — - # the same shape as the staged-script digests — for the gate - # steps to verify before the wrapper's baseline runs. Per-file - # digests, so a boundary shift between the two files cannot keep - # the value stable. - echo "verdict_inputs_sha256=$(sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/rv.json" | sha256sum | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" + # check) and the host's breaker reads ic.json AFTER the gate, but + # the wrapper's verdict-input baseline is captured at gate start — + # AFTER the agent step, whose sandbox mounts WORKDIR (/tmp) rw at + # the same path as the runner uid. A truncation planted during the + # agent step would therefore BECOME the baseline and stay + # invisible to the compare. Digest these three host-authored files + # at creation time into expression context — the same shape as the + # staged-script digests — for the gate steps to verify before the + # wrapper's baseline runs. Per-file digests, so a boundary shift + # between the files cannot keep the value stable. + echo "verdict_inputs_sha256=$(sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/rv.json" "${WORKDIR}/ic.json" | sha256sum | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" # Newest actionable feedback timestamp — stamped into the eval marker so # the next scan knows everything up to here has been considered. @@ -5560,16 +5560,32 @@ jobs: # the forgery lands host-side, outside the container. This step # writes no outputs of its own, so the channels are dropped for # the whole agent process tree. + # The unset severs only variable INHERITANCE: the runner reads + # this step's channel files at step END and injects whatever was + # appended there into every later step regardless of any process + # environment (#9214 review). Capture the paths before the unset + # and unlink the files after the agent returns, so a BASH_ENV (or + # BASH_FUNC_*, or PATH) planted during the run reaches nothing — + # the gate steps pin BASH_ENV empty as defense in depth. The + # OUTPUT file needs no unlink: this step has no id, so nothing + # can consume outputs planted there. + SEVERED_ENV_FILE="${GITHUB_ENV:-}" + SEVERED_PATH_FILE="${GITHUB_PATH:-}" unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT # Trusted staged copy in the mirrored layout — resolves # ../SKILL.md to the trusted staged SKILL, never the PR branch's. + AGENT_RC=0 node "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs" \ --mode address-review \ --pr "${PR}" \ --issue "${ISSUE}" \ --workdir "${WORKDIR}" \ --conflict "${CONFLICT}" \ - --base "${BASE}" + --base "${BASE}" || AGENT_RC="$?" + # Runs even when the agent failed (bash -e would otherwise abort + # at the node line and leave the planted channel files live). + rm -f "${SEVERED_ENV_FILE}" "${SEVERED_PATH_FILE}" + exit "${AGENT_RC}" - name: 'Verification gate' id: 'verify' @@ -5601,6 +5617,11 @@ jobs: # step (the agent runs branch code on the host) must not be able # to downgrade a repo-variable 'reject' back to 'advisory'. FOOTPRINT_ENFORCE: "${{ vars.QWEN_AUTOFIX_FOOTPRINT_ENFORCE || 'advisory' }}" + # Same precedence, same reason (#9214 review): bash sources a + # planted BASH_ENV before line 1 of the run block — before the + # PATH pin, the digest checks and the wrapper — so pin it empty + # even though the agent steps unlink their channel files. + BASH_ENV: '' run: |- # The gate decides whether the PAT push runs, and it executes the # branch's OWN build/test — so it runs inside an ephemeral container @@ -5613,8 +5634,8 @@ jobs: # verifying cannot themselves be swapped. 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 - echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - > /dev/null + echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - + echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - # The helpers the gate executes are staged in the same writable # RUNNER_TEMP, so verify them as one blob before the wrapper copies # them across the container wall. @@ -5623,15 +5644,29 @@ jobs: echo "::error::staged gate helpers no longer match the digest recorded at staging time; refusing to run the gate." exit 1 fi - # rc.json/rv.json are host-authored in prepare but read by the - # gate AFTER its build legs, and the wrapper's baseline is - # captured only at gate start — after the agent step, whose - # sandbox mounts WORKDIR rw at the same path as the runner uid. - # Verify the prepare-time digest first, so a plant during the - # agent step fails here instead of becoming the baseline. - VERDICT_INPUTS_NOW="$(sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/rv.json" | sha256sum | cut -d' ' -f1)" + # rc.json/rv.json are read by the gate AFTER its build legs and + # ic.json by the host's breaker after the gate — all host-authored + # in prepare, while the wrapper's baseline is captured only at gate + # start — after the agent step, whose sandbox mounts WORKDIR rw at + # the same path as the runner uid. Verify the prepare-time digest + # first, so a plant during the agent step fails here instead of + # becoming the baseline. + # Type discipline before hashing (the wrapper's own shape): WORKDIR + # is attacker-writable, and a planted FIFO or device symlink would + # hang sha256sum's open() until the step's 60-minute timeout — a + # one-line per-round DoS of the verification loop. + for _vi in rc.json rv.json ic.json; do + case "$(stat -c '%F' "${WORKDIR}/${_vi}" 2> /dev/null || true)" in + 'regular file' | 'regular empty file') : ;; + *) + echo "::error::verdict input ${_vi} is not a regular file — refusing to run the gate." + exit 1 + ;; + esac + done + VERDICT_INPUTS_NOW="$(sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/rv.json" "${WORKDIR}/ic.json" | sha256sum | cut -d' ' -f1)" if [[ "${VERDICT_INPUTS_NOW}" != "${VERDICT_INPUTS_SHA256}" ]]; then - echo "::error::verdict inputs rc.json/rv.json no longer match the digest recorded at prepare time; refusing to run the gate." + echo "::error::verdict inputs rc.json/rv.json/ic.json no longer match the digest recorded at prepare time; refusing to run the gate." exit 1 fi bash "${RUNNER_TEMP}/run-autofix-gate-container.sh" @@ -5778,15 +5813,24 @@ jobs: # Same severing as 'Triage and address': the repair agent runs # branch code again, and its attempted= output was already # written above, so the runner's mutation channels are dropped - # for the whole agent process tree. + # for the whole agent process tree. The unset severs only + # variable INHERITANCE (#9214 review): the runner still reads + # the channel files at step END, so capture them before the + # unset and unlink them after the agent returns. The OUTPUT file + # stays — the attempted= output above must survive to step END. + SEVERED_ENV_FILE="${GITHUB_ENV:-}" + SEVERED_PATH_FILE="${GITHUB_PATH:-}" unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT + AGENT_RC=0 node "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs" \ --mode address-review \ --pr "${PR}" \ --issue "${ISSUE}" \ --workdir "${WORKDIR}" \ --conflict "${CONFLICT}" \ - --base "${BASE}" + --base "${BASE}" || AGENT_RC="$?" + rm -f "${SEVERED_ENV_FILE}" "${SEVERED_PATH_FILE}" + exit "${AGENT_RC}" - name: 'Repair verification gate' id: 'verify_repair' @@ -5812,6 +5856,11 @@ jobs: # step (the agent runs branch code on the host) must not be able # to downgrade a repo-variable 'reject' back to 'advisory'. FOOTPRINT_ENFORCE: "${{ vars.QWEN_AUTOFIX_FOOTPRINT_ENFORCE || 'advisory' }}" + # Same precedence, same reason (#9214 review): bash sources a + # planted BASH_ENV before line 1 of the run block — before the + # PATH pin, the digest checks and the wrapper — so pin it empty + # even though the agent steps unlink their channel files. + BASH_ENV: '' run: |- # The gate decides whether the PAT push runs, and it executes the # branch's OWN build/test — so it runs inside an ephemeral container @@ -5824,8 +5873,8 @@ jobs: # verifying cannot themselves be swapped. 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 - echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - > /dev/null + echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - + echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - # The helpers the gate executes are staged in the same writable # RUNNER_TEMP, so verify them as one blob before the wrapper copies # them across the container wall. @@ -5834,15 +5883,29 @@ jobs: echo "::error::staged gate helpers no longer match the digest recorded at staging time; refusing to run the gate." exit 1 fi - # rc.json/rv.json are host-authored in prepare but read by the - # gate AFTER its build legs, and the wrapper's baseline is - # captured only at gate start — after the agent step, whose - # sandbox mounts WORKDIR rw at the same path as the runner uid. - # Verify the prepare-time digest first, so a plant during the - # agent step fails here instead of becoming the baseline. - VERDICT_INPUTS_NOW="$(sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/rv.json" | sha256sum | cut -d' ' -f1)" + # rc.json/rv.json are read by the gate AFTER its build legs and + # ic.json by the host's breaker after the gate — all host-authored + # in prepare, while the wrapper's baseline is captured only at gate + # start — after the agent step, whose sandbox mounts WORKDIR rw at + # the same path as the runner uid. Verify the prepare-time digest + # first, so a plant during the agent step fails here instead of + # becoming the baseline. + # Type discipline before hashing (the wrapper's own shape): WORKDIR + # is attacker-writable, and a planted FIFO or device symlink would + # hang sha256sum's open() until the step's 60-minute timeout — a + # one-line per-round DoS of the verification loop. + for _vi in rc.json rv.json ic.json; do + case "$(stat -c '%F' "${WORKDIR}/${_vi}" 2> /dev/null || true)" in + 'regular file' | 'regular empty file') : ;; + *) + echo "::error::verdict input ${_vi} is not a regular file — refusing to run the gate." + exit 1 + ;; + esac + done + VERDICT_INPUTS_NOW="$(sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/rv.json" "${WORKDIR}/ic.json" | sha256sum | cut -d' ' -f1)" if [[ "${VERDICT_INPUTS_NOW}" != "${VERDICT_INPUTS_SHA256}" ]]; then - echo "::error::verdict inputs rc.json/rv.json no longer match the digest recorded at prepare time; refusing to run the gate." + echo "::error::verdict inputs rc.json/rv.json/ic.json no longer match the digest recorded at prepare time; refusing to run the gate." exit 1 fi bash "${RUNNER_TEMP}/run-autofix-gate-container.sh" diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index e0dde244f38..69d64d139c6 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -6763,7 +6763,7 @@ exit 1 /bash "\$\{RUNNER_TEMP\}\/run-autofix-review-verification\.sh"/, ); expect(gate).toContain( - 'echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - > /dev/null', + 'echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c -', ); // Presence is not enough: the digest checks are only a wall if they run // BEFORE the wrapper and actually abort. Moving the invocation above @@ -6771,8 +6771,8 @@ exit 1 // appending `|| true` (verification that cannot fail) both leave every // other assertion here green. for (const verifyLine of [ - 'echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - > /dev/null', - 'echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - > /dev/null', + 'echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c -', + 'echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c -', 'GATE_HELPERS_NOW="$(sha256sum ', ]) { expect(gate.indexOf(verifyLine)).toBeGreaterThan(-1); @@ -6781,12 +6781,27 @@ exit 1 ); } expect(gate).not.toMatch(/sha256sum -c[^\n]*\|\| true/); + // Presence of the comparisons is pinned above; pin the ENFORCEMENT + // too: deleting the exit under either if-block (or flipping != to ==) + // leaves every pin green while tampered staged helpers or planted + // verdict inputs are no longer refused before the wrapper runs. + expect(gate).toMatch( + /if \[\[ "\$\{GATE_HELPERS_NOW\}" != "\$\{GATE_HELPERS_SHA256\}" \]\]; then[\s\S]{0,240}?exit 1/, + ); + expect(gate).toMatch( + /if \[\[ "\$\{VERDICT_INPUTS_NOW\}" != "\$\{VERDICT_INPUTS_SHA256\}" \]\]; then[\s\S]{0,240}?exit 1/, + ); // The image comes from the resolve step's OUTPUT (expression context), // not $GITHUB_ENV, which an earlier step can append to. expect(gate).toContain( "GATE_IMAGE: '${{ steps.sandbox.outputs.image }}'", ); expect(gate).not.toContain('QWEN_SANDBOX_IMAGE'); + // bash sources a planted BASH_ENV before line 1 of the run block — + // before the PATH pin, the digest checks and the wrapper — and + // step-level env outranks anything appended to $GITHUB_ENV during the + // agent step (#9214 review), so both gate steps pin it empty. + expect(gate).toContain("BASH_ENV: ''"); } expect(workflow).toContain( "- name: 'Resolve sandbox image'\n # id + step output", @@ -6877,6 +6892,28 @@ exit 1 'node "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs"', ), ); + // The unset severs only variable INHERITANCE: the runner reads the + // step's channel files at step END regardless of any process + // environment, so a BASH_ENV planted through them fires in every + // later step before line 1 (#9214 review). Capture the paths before + // the unset and unlink the files after the agent returns. + expect(agentStep).toContain('SEVERED_ENV_FILE="${GITHUB_ENV:-}"'); + expect(agentStep).toContain('SEVERED_PATH_FILE="${GITHUB_PATH:-}"'); + expect( + agentStep.indexOf('SEVERED_ENV_FILE="${GITHUB_ENV:-}"'), + ).toBeLessThan( + agentStep.indexOf('unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT'), + ); + expect(agentStep).toContain( + 'rm -f "${SEVERED_ENV_FILE}" "${SEVERED_PATH_FILE}"', + ); + expect( + agentStep.indexOf('rm -f "${SEVERED_ENV_FILE}" "${SEVERED_PATH_FILE}"'), + ).toBeGreaterThan( + agentStep.indexOf( + 'node "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs"', + ), + ); } // The three helpers the gate EXECUTES are staged in the same writable // RUNNER_TEMP, so they are digested at staging and verified before the @@ -6908,17 +6945,18 @@ exit 1 // the per-file digest is not). expect(workflow).not.toMatch(/gate_helpers_sha256=\$\(cat /); expect(workflow).not.toMatch(/GATE_HELPERS_NOW="\$\(cat /); - // rc.json/rv.json are host-authored in prepare but read by the gate - // AFTER its build legs, and the wrapper's verdict-input baseline is - // captured only at gate start — AFTER the agent step, whose sandbox - // mounts WORKDIR rw at the same path as the runner uid. A truncation - // planted during the agent step would therefore BECOME the baseline - // and stay invisible to the compare, so prepare digests the two files - // at creation time and BOTH gate steps verify the digest before the - // wrapper runs. Writer and reader must digest the same files in the - // same order, as with the helpers above. + // rc.json/rv.json are read by the gate AFTER its build legs and ic.json + // by the host's breaker after the gate — all host-authored in prepare, + // while the wrapper's verdict-input baseline is captured only at gate + // start — AFTER the agent step, whose sandbox mounts WORKDIR rw at the + // same path as the runner uid. A truncation planted during the agent + // step would therefore BECOME the baseline and stay invisible to the + // compare, so prepare digests the three files at creation time and BOTH + // gate steps verify the digest before the wrapper runs. Writer and + // reader must digest the same files in the same order, as with the + // helpers above. expect(prepareBranchAndFeedbackStep).toContain( - 'echo "verdict_inputs_sha256=$(sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/rv.json" | sha256sum | cut -d\' \' -f1)" >> "${GITHUB_OUTPUT}"', + 'echo "verdict_inputs_sha256=$(sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/rv.json" "${WORKDIR}/ic.json" | sha256sum | cut -d\' \' -f1)" >> "${GITHUB_OUTPUT}"', ); const verdictInputsBlob = prepareBranchAndFeedbackStep.match( /echo "verdict_inputs_sha256=\$\(sha256sum (.*?) \| sha256sum/, @@ -6956,6 +6994,11 @@ exit 1 expect(wrapper).toMatch( /bash "\$\{CBIN\}\/run-autofix-review-verification\.sh"\nGATE_RC=\$\?/, ); + // …and it must be the ONLY assignment: an appended `GATE_RC=0` between + // the capture and the translation keeps the adjacency pin green while a + // failing container's exit is translated as 0 — forwarding a planted + // outcome=fixed (with verified_head) to the PAT push. + expect((wrapper.match(/GATE_RC=/g) ?? []).length).toBe(1); // What the loop stages into CBIN must be exactly the gate plus the three // helpers the workflow digests. Dropping one (e.g. check-autofix- // contracts.sh) makes the gate invoke a missing helper INSIDE the @@ -7071,8 +7114,11 @@ exit 1 // attacker text as the bot's "no changes needed" rationale. // rc.json/rv.json feed the bite check AFTER the build legs (truncating // rc.json flips BITE_ENFORCE off, so the bogus-fix round the bite check - // exists to reject exits 0 as `fixed`), and the host's post-gate report - // and upsert steps consume the rest: failure.md and handoff.md are the + // exists to reject exits 0 as `fixed`), ic.json feeds the post-gate + // consecutive-failure breaker file-preferred (planted bot failure + // headlines trip its cap and mark the PR terminal), and the host's + // post-gate report and upsert steps consume the rest: failure.md and + // handoff.md are the // report step's detail chain (a planted handoff.md is picked AHEAD of // the agent's own summary), agent-api-error/-kind/agent-timeout steer // the retry sentinel and the published headline, deferred-feedback.md @@ -7092,6 +7138,7 @@ exit 1 'comment-replies.json', 'rc.json', 'rv.json', + 'ic.json', 'failure.md', 'handoff.md', 'deferred-feedback.md', @@ -7218,9 +7265,16 @@ exit 1 runTranslate(0, ['committed=true', 'outcome=fixed', 'verified_head=abc']), ).toBe('committed=true\noutcome=fixed\nverified_head=abc'); // A real rejection (the gate wrote failed, then exited 1) carries through - // with its routing flags, which only pick repair vs handoff, never a push. + // with its routing flags, which only pick repair vs handoff, never a + // push. reject_fix writes BOTH flags on every rejection, so the genuine + // shape carries the explicit baseline. expect( - runTranslate(1, ['outcome=fixed', 'outcome=failed', 'retryable=true']), + runTranslate(1, [ + 'outcome=fixed', + 'outcome=failed', + 'preexisting=false', + 'retryable=true', + ]), ).toBe('outcome=failed\nretryable=true'); // A FORGED pass cannot survive exit 1 — `fixed` is accepted only on exit // 0 — and with no genuine `failed` in the file the outcome stays UNSET so @@ -7240,14 +7294,23 @@ exit 1 ); // preexisting routes to the base-update handoff (never a push) and rides // along with a genuine failure. - expect(runTranslate(1, ['outcome=failed', 'preexisting=true'])).toBe( - 'outcome=failed\npreexisting=true', - ); + expect( + runTranslate(1, [ + 'outcome=failed', + 'preexisting=true', + 'retryable=false', + ]), + ).toBe('outcome=failed\npreexisting=true'); // committed= is a ref-only fact the gate records before any check runs; it // is forwarded on non-zero paths too so the handoff wording stays right. - expect(runTranslate(1, ['committed=true', 'outcome=failed'])).toBe( - 'committed=true\noutcome=failed', - ); + expect( + runTranslate(1, [ + 'committed=true', + 'outcome=failed', + 'preexisting=false', + 'retryable=false', + ]), + ).toBe('committed=true\noutcome=failed'); // Exit 0 with no verdict is a crash, not a silent success: outcome stays // unset so 'Finalize verification' retries on the next scan. expect(runTranslate(0, [])).toBe(''); @@ -7258,20 +7321,41 @@ exit 1 // `tail -n 1` picks up an attacker-appended `xoutcome=fixed`, OUTCOME // reads `fixed` at exit 1 and a genuine evaluated rejection is rerouted // into the crash-retry loop. - expect(runTranslate(1, ['outcome=failed', 'xoutcome=fixed'])).toBe( - 'outcome=failed', - ); - // preexisting and retryable are mutually exclusive at the source - // (reject_fix writes one or the other), so BOTH present is proof of an - // append the gate never made. The translation refuses the verdict as - // tampered — nothing forwarded, crash-retry path — instead of guessing - // which flag is genuine: a planted `preexisting=true` overriding a - // genuine `retryable=true` would otherwise skip the repair the round is - // entitled to and permanently misclassify a fixable rejection as a - // terminal pre-existing failure. + expect( + runTranslate(1, [ + 'outcome=failed', + 'preexisting=false', + 'retryable=false', + 'xoutcome=fixed', + ]), + ).toBe('outcome=failed'); + // reject_fix writes BOTH routing flags on every deterministic rejection + // (mutually exclusive at the source), so any other shape — a flag line + // missing, repeated, or both true — is proof of an append the gate never + // made. The translation refuses the verdict as tampered — nothing + // forwarded, crash-retry path — instead of guessing which flag is + // genuine: a lone forged `retryable=true` used to flip a deliberately + // non-retryable rejection (the bite check wrote neither flag) into a + // PAT-backed repair round, and a planted + // `preexisting=true` overriding a genuine `retryable=true` would skip + // the repair the round is entitled to and permanently misclassify a + // fixable rejection as a terminal pre-existing failure. expect( runTranslate(1, ['retryable=true', 'outcome=failed', 'preexisting=true']), ).toBe(''); + // The bite check's non-retryable rejection carries the false baseline, + // and a forged `retryable=true` append repeats the key. + expect( + runTranslate(1, [ + 'outcome=failed', + 'preexisting=false', + 'retryable=false', + 'retryable=true', + ]), + ).toBe(''); + // The pre-baseline flag-less shape is indistinguishable from a forgery + // and is refused the same way. + expect(runTranslate(1, ['outcome=failed', 'retryable=true'])).toBe(''); rmSync(dir, { recursive: true, force: true }); }); @@ -9665,7 +9749,7 @@ exit 1 // 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, + /echo "\$\{VERIFY_RUNNER_SHA256\} {2}\$\{RUNNER_TEMP\}\/run-autofix-review-verification\.sh" \| sha256sum -c -$/gm, ) ?? [], ).toHaveLength(2); expect( @@ -14711,6 +14795,10 @@ exit 1 expect(status).not.toBe(0); expect(readFileSync(out, 'utf8')).toContain('outcome=failed'); expect(readFileSync(out, 'utf8')).toContain('retryable=true'); + // BOTH flags are always written, even when only one applies: the + // host-side translation treats any other shape as tampered, which is + // what makes a lone forged `retryable=true` append detectable. + expect(readFileSync(out, 'utf8')).toContain('preexisting=false'); // The verdict must be declared BEFORE the detail file is written, and the // write must be non-fatal. An empty outcome on a failed job reads as "the // gate never reached a verdict" — a CRASH, which is retried — so a From 06b141ecabac78c2a3ac4595017bae8f3433aa2f Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Mon, 17 Aug 2026 04:31:08 +0000 Subject: [PATCH 09/11] fix(autofix): address round-6 gate-wall review findings (#9214) --- .github/scripts/resolve-sandbox-image.mjs | 65 ++++-- .../scripts/resolve-sandbox-image.test.mjs | 72 ++++++- .github/scripts/run-autofix-gate-container.sh | 27 ++- .../run-autofix-review-verification.sh | 44 +++- .github/workflows/qwen-autofix.yml | 28 ++- scripts/tests/qwen-autofix-workflow.test.js | 193 +++++++++++++++++- 6 files changed, 398 insertions(+), 31 deletions(-) diff --git a/.github/scripts/resolve-sandbox-image.mjs b/.github/scripts/resolve-sandbox-image.mjs index 1f0f3a788d3..d5aac237a82 100644 --- a/.github/scripts/resolve-sandbox-image.mjs +++ b/.github/scripts/resolve-sandbox-image.mjs @@ -73,38 +73,55 @@ async function fetchLatestGhcrSemver() { return latest; } -function pullImage(command, image) { +// The `Digest: sha256:…` line docker prints for the tag it just resolved is +// the only pull-time content identity: the post-pull inspect can race a +// `docker tag` swap (see repoDigestOf), so the exported reference must be +// bound to what the pull itself reported, never to inspect alone. +export function parsePullDigest(pullOutput) { + return pullOutput.match(/^Digest: (sha256:[0-9a-f]{64})\s*$/m)?.[1] ?? ''; +} + +export function pullImage(command, image) { return new Promise((resolve) => { - const child = spawn(command, ['pull', image], { stdio: 'inherit' }); + const child = spawn(command, ['pull', image], { + stdio: ['ignore', 'pipe', 'inherit'], + }); + let stdout = ''; let settled = false; let timer; - const finish = (ok) => { + const finish = (result) => { if (settled) return; settled = true; clearTimeout(timer); - resolve(ok); + resolve(result); }; timer = setTimeout(() => { console.error( `::error::Timed out pulling ${image} after ${PULL_TIMEOUT_MS / 1000}s.`, ); child.kill('SIGKILL'); - finish(false); + finish({ ok: false, digest: '' }); }, PULL_TIMEOUT_MS); + child.stdout.on('data', (chunk) => { + stdout += chunk; + process.stdout.write(chunk); + }); child.on('error', (error) => { console.error( `::error::Failed to start '${command} pull ${image}': ${error.message}`, ); - finish(false); + finish({ ok: false, digest: '' }); }); child.on('close', (code) => { if (code !== 0) { console.error( `::error::'${command} pull ${image}' exited with code ${code}.`, ); + finish({ ok: false, digest: '' }); + return; } - finish(code === 0); + finish({ ok: true, digest: parsePullDigest(stdout) }); }); }); } @@ -114,7 +131,12 @@ function pullImage(command, image) { // local store without re-pull, and a co-resident process with daemon access // can `docker tag` different content under the same name between resolve // and gate. A digest reference cannot be moved by `docker tag`/`docker build`. -export function repoDigestOf(command, image) { +// With `expectedDigest` (the pull's own `Digest:` line), the resolved digest +// must MATCH it: retagged attacker content keeps ITS original repo in +// RepoDigests[0] (and a requested-repo entry still carries the attacker +// digest), so only the digest the pull reported binds the export to the +// content the pull fetched (#9214 review). +export function repoDigestOf(command, image, expectedDigest = '') { return new Promise((resolve) => { const child = spawn( command, @@ -159,6 +181,11 @@ export function repoDigestOf(command, image) { `Pulled image ${image} resolved to no repository digest ('${digest}'); refusing to export a mutable tag.`, ); } + if (expectedDigest && !digest.endsWith(`@${expectedDigest}`)) { + throw new Error( + `Pulled image ${image} resolved to a digest the pull did not report ('${digest}' vs '${expectedDigest}') — the tag moved between pull and inspect; refusing.`, + ); + } return digest; }); } @@ -180,8 +207,16 @@ async function main() { const requestedImage = validateRequestedImage(process.argv[2]); const command = process.env.SANDBOX_COMMAND || 'docker'; - if (await pullImage(command, requestedImage)) { - exportImage(await repoDigestOf(command, requestedImage)); + const requestedPull = await pullImage(command, requestedImage); + if (requestedPull.ok) { + if (!requestedPull.digest) { + throw new Error( + `'${command} pull ${requestedImage}' reported no Digest line; refusing to export an unbound image reference.`, + ); + } + exportImage( + await repoDigestOf(command, requestedImage, requestedPull.digest), + ); return; } @@ -196,10 +231,16 @@ async function main() { console.warn( `::warning::Falling back from ${requestedImage} to latest GHCR semver ${fallbackImage}; sandbox image version may differ from package version.`, ); - if (!(await pullImage(command, fallbackImage))) { + const fallbackPull = await pullImage(command, fallbackImage); + if (!fallbackPull.ok) { throw new Error(`Fallback sandbox image failed to pull: ${fallbackImage}`); } - exportImage(await repoDigestOf(command, fallbackImage)); + if (!fallbackPull.digest) { + throw new Error( + `'${command} pull ${fallbackImage}' reported no Digest line; refusing to export an unbound image reference.`, + ); + } + exportImage(await repoDigestOf(command, fallbackImage, fallbackPull.digest)); } if ( diff --git a/.github/scripts/resolve-sandbox-image.test.mjs b/.github/scripts/resolve-sandbox-image.test.mjs index 5fcf98b4405..3a041933db1 100644 --- a/.github/scripts/resolve-sandbox-image.test.mjs +++ b/.github/scripts/resolve-sandbox-image.test.mjs @@ -1,7 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { @@ -9,6 +15,8 @@ import { validateRequestedImage, exportImage, repoDigestOf, + parsePullDigest, + pullImage, } from './resolve-sandbox-image.mjs'; test('latestSemverTag returns the highest stable semver tag', () => { @@ -145,3 +153,65 @@ test('repoDigestOf fails closed when the inspect fails', async () => { ); }); }); + +// The exported reference is bound to the digest the PULL itself reported: +// `docker tag` never rewrites digests, so retagged attacker content keeps +// its original repo in RepoDigests[0] (measured live: a tag moved to other +// content resolves to `busybox@sha256:…` and passes the `@sha256:` presence +// check). Only the pull's own Digest line ties the export to the fetched +// content (#9214 review). +const GENUINE = + 'sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + +test('repoDigestOf refuses a digest the pull did not report (retag race)', async () => { + await withDockerStub( + 'printf "%s\\n" "aaa.example/backdoor@sha256:dc2d74b2dc2d74b2dc2d74b2dc2d74b2dc2d74b2dc2d74b2dc2d74b2dc2d74b2"', + async (stub) => { + await assert.rejects( + repoDigestOf(stub, 'ghcr.io/qwenlm/qwen-code:1.2.3', GENUINE), + /digest the pull did not report/, + ); + }, + ); +}); + +test('repoDigestOf accepts the digest the pull reported', async () => { + await withDockerStub( + `printf "%s\\n" "ghcr.io/qwenlm/qwen-code@${GENUINE}"`, + async (stub) => { + assert.equal( + await repoDigestOf(stub, 'ghcr.io/qwenlm/qwen-code:1.2.3', GENUINE), + `ghcr.io/qwenlm/qwen-code@${GENUINE}`, + ); + }, + ); +}); + +test('parsePullDigest extracts the Digest line from pull output', () => { + const pullLog = [ + '1.2.3: Pulling from qwenlm/qwen-code', + `Digest: ${GENUINE}`, + 'Status: Image is up to date for ghcr.io/qwenlm/qwen-code:1.2.3', + 'ghcr.io/qwenlm/qwen-code:1.2.3', + ].join('\n'); + assert.equal(parsePullDigest(pullLog), GENUINE); + assert.equal(parsePullDigest('Status: Image is up to date'), ''); + assert.equal(parsePullDigest('Digest: sha256:tooshort'), ''); +}); + +test('pullImage captures the pull-reported digest on success', async () => { + await withDockerStub( + `printf "%s\\n" "pulling..." "Digest: ${GENUINE}" "Status: Downloaded"`, + async (stub) => { + const result = await pullImage(stub, 'ghcr.io/qwenlm/qwen-code:1.2.3'); + assert.deepEqual(result, { ok: true, digest: GENUINE }); + }, + ); +}); + +test('pullImage reports failure without a digest', async () => { + await withDockerStub('exit 1', async (stub) => { + const result = await pullImage(stub, 'ghcr.io/qwenlm/qwen-code:1.2.3'); + assert.deepEqual(result, { ok: false, digest: '' }); + }); +}); diff --git a/.github/scripts/run-autofix-gate-container.sh b/.github/scripts/run-autofix-gate-container.sh index 6933ebcf98b..684ca0d2ce3 100755 --- a/.github/scripts/run-autofix-gate-container.sh +++ b/.github/scripts/run-autofix-gate-container.sh @@ -81,6 +81,13 @@ rm -f "${VERDICT}" echo "::error::could not create the gate verdict file at ${VERDICT}" exit 125 } +# Pin the file's IDENTITY across the run, not just its type: the gate only +# ever APPENDS to this file (inode preserved), but a pre-staged forgery +# renamed over it after the gate's final write — one atomic rename(2) before +# the container exits — passes every other post-run check (the fingerprint +# set deliberately excludes the verdict, and the type check sees a regular +# file with exactly one of each flag line). A moved inode is a swap (#9214). +VERDICT_INODE="$(stat -c '%i' "${VERDICT}" 2> /dev/null || true)" # Single host-side reset for the gate-authored rejection detail: the gate # writes it only on rejection paths, so a copy surviving a run that never # rejected was planted, and the report steps would publish it as the bot's @@ -182,6 +189,14 @@ if [[ "$(verdict_inputs_digest)" != "${INPUTS_BEFORE}" ]]; then exit 125 fi +# Refuse a REPLACED verdict before anything reads it: the gate's appends +# keep the creation-time inode, a rename(2) swap does not (see the capture +# above). Runs before the type check so a swapped-in FIFO never gets opened. +VERDICT_INODE_NOW="$(stat -c '%i' "${VERDICT}" 2> /dev/null || true)" +if [[ "${VERDICT_INODE_NOW}" != "${VERDICT_INODE}" ]]; then + echo "::error::the gate verdict file at ${VERDICT} was replaced during the run (inode ${VERDICT_INODE:-?} -> ${VERDICT_INODE_NOW:-?}) — refusing the verdict as tampered." + exit 125 +fi # Mirror the inputs' type discipline on the verdict file itself BEFORE # anything opens it: the container can swap it after the run — rm + mkfifo # while holding a reader fd (the gate's appends succeed inside, then the @@ -240,12 +255,20 @@ case "${GATE_RC}" in # fixable rejection as a terminal pre-existing failure. The crash path # retries with a fresh checkout instead. if [[ "${OUTCOME}" == 'failed' ]]; then + # The gate writes `outcome=` exactly once on every genuine path, so a + # duplicated or emptied outcome line is an in-container append exactly + # like a duplicated flag: without the count, a watcher appending a bare + # `outcome=` after the gate's write empties OUTCOME through last-wins + # and silently turns an EVALUATED rejection into a no-diagnostic + # crash-retry loop (#9214 review). + OUTCOME_LINES="$(grep -c '^outcome=' "${VERDICT}" 2> /dev/null || true)" RETRYABLE_LINES="$(grep -c '^retryable=' "${VERDICT}" 2> /dev/null || true)" PREEXISTING_LINES="$(grep -c '^preexisting=' "${VERDICT}" 2> /dev/null || true)" - if [[ "${RETRYABLE_LINES:-0}" -ne 1 ]] || + if [[ "${OUTCOME_LINES:-0}" -ne 1 ]] || + [[ "${RETRYABLE_LINES:-0}" -ne 1 ]] || [[ "${PREEXISTING_LINES:-0}" -ne 1 ]] || [[ "${PREEXISTING}" == 'true' && "${RETRYABLE}" == 'true' ]]; then - echo "::error::verdict carries a forged routing flag (retryable lines: ${RETRYABLE_LINES:-0}, preexisting lines: ${PREEXISTING_LINES:-0}) — refusing the verdict as tampered." + echo "::error::verdict carries a forged line (outcome lines: ${OUTCOME_LINES:-0}, retryable lines: ${RETRYABLE_LINES:-0}, preexisting lines: ${PREEXISTING_LINES:-0}) — refusing the verdict as tampered." exit 125 fi echo "outcome=failed" >> "${GITHUB_OUTPUT}" diff --git a/.github/scripts/run-autofix-review-verification.sh b/.github/scripts/run-autofix-review-verification.sh index 3056adc3a64..5c799a55ce4 100755 --- a/.github/scripts/run-autofix-review-verification.sh +++ b/.github/scripts/run-autofix-review-verification.sh @@ -65,19 +65,31 @@ if [[ "${committed_rc}" -eq 1 ]]; then echo "committed=true" >> "${GITHUB_OUTPUT}" fi +# Canonical verdict shape for the evaluated-handoff exits below (failure.md +# aborts, agent-produced-nothing): the host-side translation refuses an +# outcome=failed verdict that does not carry exactly one of each routing +# flag — the same anti-append forgery discipline reject_fix satisfies — so a +# flag-less shape would be dropped as tampered and loop in crash-retries +# instead of advancing the watermark as an EVALUATED handoff. The false +# baseline forwards only `outcome=failed`, keeping pre-container semantics. +fail_handoff() { + echo "outcome=failed" >> "${GITHUB_OUTPUT}" + echo "preexisting=false" >> "${GITHUB_OUTPUT}" + echo "retryable=false" >> "${GITHUB_OUTPUT}" + exit 1 +} + if [[ -f "${WORKDIR}/failure.md" && -n "$(git status --porcelain)" ]]; then echo "❌ Agent wrote failure.md after leaving a dirty workspace:" git status --short cat "${WORKDIR}/failure.md" - echo "outcome=failed" >> "${GITHUB_OUTPUT}" - exit 1 + fail_handoff fi if [[ -f "${WORKDIR}/failure.md" ]]; then echo "🛑 Agent aborted intentionally:" cat "${WORKDIR}/failure.md" - echo "outcome=failed" >> "${GITHUB_OUTPUT}" - exit 1 + fail_handoff fi # Convention: hooks are severed at EVERY host checkout of the PR @@ -331,6 +343,21 @@ if [[ -n "$(git status --porcelain)" ]]; then fi VERIFICATION_HEAD="$(git rev-parse HEAD)" +# The bite section reads rc.json / resolved-comments.txt / rv.json AFTER the +# build legs — host-authored before the container, never written by the gate. +# A branch background process can truncate one for the mid-run read (flipping +# BITE_ENFORCE off, so the bogus-fix round the bite check exists to reject +# sails to outcome=fixed) and restore the bytes before exit — the wrapper's +# post-run fingerprint compare only sees the restoration. Capture identity +# now, before any branch code runs, and refuse at the bite section if the +# inputs moved (#9214 review). +bite_input_digest() { + { sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/resolved-comments.txt" \ + "${WORKDIR}/rv.json" 2> /dev/null || true; } | + sha256sum | cut -d' ' -f1 +} +BITE_INPUTS_BEFORE="$(bite_input_digest)" + # The schema generator resolves '@qwen-code/qwen-code-core' to core's DIST # entry point, which the CLI bundle restored from the TRUSTED BASE. When the # branch itself changed core's sources, that base-built dist can disagree @@ -376,14 +403,12 @@ if git diff --quiet "origin/${BRANCH}...${BRANCH}"; then exit 0 fi echo "❌ Branch unchanged and no no-action.md — agent produced nothing" - echo "outcome=failed" >> "${GITHUB_OUTPUT}" - exit 1 + fail_handoff fi if [[ ! -s "${WORKDIR}/address-summary.md" ]]; then echo "❌ Branch changed but address-summary.md is missing" - echo "outcome=failed" >> "${GITHUB_OUTPUT}" - exit 1 + fail_handoff fi # --- Content-based validity checks ------------------------------------------- @@ -874,6 +899,9 @@ BITE_SRC="$(git diff --name-only -z --no-renames "${ROUND_RANGE}" \ # code? resolved-comments.txt is the agent's own machine-readable claim of # what it fixed; rc.json/rv.json carry the thread bodies and review states # the scan already fetched. Absent/empty inputs read as "no defect claim". +if [[ "$(bite_input_digest)" != "${BITE_INPUTS_BEFORE}" ]]; then + reject_fix 'bite check inputs changed during the gate run' +fi BITE_ENFORCE='false' if [[ -s "${WORKDIR}/resolved-comments.txt" && -s "${WORKDIR}/rc.json" ]]; then # Ids tolerate the rc: prefix and CR the other consumers strip (SKILL diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index f362d25c8a1..535073f5d62 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -4209,7 +4209,11 @@ jobs: 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/run-autofix-gate-container.sh "${RUNNER_TEMP}/run-autofix-gate-container.sh" + # Absent from the trusted base until this PR merges, and this step + # runs under -e: a hard failure here would kill every pre-merge + # round. An empty digest reaches the gate steps' own `-z` guard, + # which skips the container gate with notice. + cp .github/scripts/run-autofix-gate-container.sh "${RUNNER_TEMP}/run-autofix-gate-container.sh" 2> /dev/null || true 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 @@ -4224,7 +4228,9 @@ jobs: # 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 "gate_container_sha256=$(sha256sum "${RUNNER_TEMP}/run-autofix-gate-container.sh" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" + if [[ -f "${RUNNER_TEMP}/run-autofix-gate-container.sh" ]]; then + echo "gate_container_sha256=$(sha256sum "${RUNNER_TEMP}/run-autofix-gate-container.sh" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" + fi # The gate EXECUTES these three helpers too, and RUNNER_TEMP is # writable by the branch/agent code later steps run on this host: a # neutered schema/contracts/resolver helper lets a fix that should be @@ -5634,6 +5640,15 @@ jobs: # verifying cannot themselves be swapped. export PATH="${TRUSTED_PATH}" unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH + # Pre-merge: the wrapper is absent from the trusted base (staged + # empty above), so there is nothing digest-verifiable to run. Skip + # with notice instead of failing sha256sum -c opaquely; the empty + # outcome routes through 'Finalize verification's crash path and + # the next scan retries — self-resolving once this PR merges. + if [[ -z "${GATE_CONTAINER_SHA256}" ]]; then + echo "::notice::run-autofix-gate-container.sh is absent from the trusted base (pre-merge) — skipping the container gate; the next scan retries." + exit 0 + fi echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - # The helpers the gate executes are staged in the same writable @@ -5873,6 +5888,15 @@ jobs: # verifying cannot themselves be swapped. export PATH="${TRUSTED_PATH}" unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH + # Pre-merge: the wrapper is absent from the trusted base (staged + # empty above), so there is nothing digest-verifiable to run. Skip + # with notice instead of failing sha256sum -c opaquely; the empty + # outcome routes through 'Finalize verification's crash path and + # the next scan retries — self-resolving once this PR merges. + if [[ -z "${GATE_CONTAINER_SHA256}" ]]; then + echo "::notice::run-autofix-gate-container.sh is absent from the trusted base (pre-merge) — skipping the container gate; the next scan retries." + exit 0 + fi echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - # The helpers the gate executes are staged in the same writable diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 69d64d139c6..d87310d0565 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -6762,6 +6762,17 @@ exit 1 expect(gate).not.toMatch( /bash "\$\{RUNNER_TEMP\}\/run-autofix-review-verification\.sh"/, ); + // Pre-merge skip: an empty digest means the wrapper was absent from + // the trusted base, and the step must skip with notice BEFORE the + // digest checks — sha256sum -c on an empty digest fails opaquely, + // and running an unverified wrapper is exactly what the wall forbids. + expect(gate).toMatch( + /if \[\[ -z "\$\{GATE_CONTAINER_SHA256\}" \]\]; then[\s\S]{0,400}?exit 0\n\s+fi/, + ); + expect(gate.indexOf('-z "${GATE_CONTAINER_SHA256}"')).toBeGreaterThan(-1); + expect(gate.indexOf('-z "${GATE_CONTAINER_SHA256}"')).toBeLessThan( + gate.indexOf('echo "${VERIFY_RUNNER_SHA256}'), + ); expect(gate).toContain( 'echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c -', ); @@ -6835,10 +6846,19 @@ exit 1 expect(envBodyOf(repairVerificationGateStep)).toBe( envBodyOf(verificationGateSteps[1]), ); + // The wrapper is absent from the trusted base until this PR merges and + // the stage step runs under -e, so its cp is guarded like the sibling + // upsert script's cat — an unguarded cp hard-fails every pre-merge + // round before triage even starts. The digest is recorded only when the + // copy exists; the gate steps turn the empty digest into an explicit + // skip (pinned below), never an opaque sha256sum -c failure. expect(workflow).toContain( - 'cp .github/scripts/run-autofix-gate-container.sh "${RUNNER_TEMP}/run-autofix-gate-container.sh"', + 'cp .github/scripts/run-autofix-gate-container.sh "${RUNNER_TEMP}/run-autofix-gate-container.sh" 2> /dev/null || true', ); expect(workflow).toContain('echo "gate_container_sha256=$(sha256sum '); + expect(workflow).toMatch( + /if \[\[ -f "\$\{RUNNER_TEMP\}\/run-autofix-gate-container\.sh" \]\]; then\n\s+echo "gate_container_sha256=\$\(sha256sum "\$\{RUNNER_TEMP\}\/run-autofix-gate-container\.sh"/, + ); // The image output is load-bearing (empty GATE_IMAGE → the wrapper // refuses and every round takes the gate-crashed retry), so pin the id // itself, not just the consumer string. @@ -6866,10 +6886,15 @@ exit 1 // between resolve and gate, and `docker run` follows the swapped tag // without re-pull — the gate would execute the doctored runtime. expect(sandboxImageResolverScript).toContain( - 'exportImage(await repoDigestOf(command, requestedImage))', + 'await repoDigestOf(command, requestedImage, requestedPull.digest)', ); expect(sandboxImageResolverScript).toContain( - 'exportImage(await repoDigestOf(command, fallbackImage))', + 'await repoDigestOf(command, fallbackImage, fallbackPull.digest)', + ); + // …and the binding is fail-closed: a pull whose output carries no + // Digest line must refuse, not fall back to an unbound inspect. + expect(sandboxImageResolverScript).toContain( + 'reported no Digest line; refusing to export an unbound image reference', ); // The container wall does not, by itself, make the host-side // translation shell trusted: MCP stdio servers from a branch-committed @@ -7034,6 +7059,11 @@ exit 1 expect(dockerRun).toContain('--env GITHUB_OUTPUT="${VERDICT}"'); expect(dockerRun).toContain('--env RUNNER_TEMP="${CBIN}"'); expect(dockerRun).toContain('--env HOME="${CRW}"'); + // GATE_TMPDIR must land on the WRITABLE scratch mount: the gate's + // GIT_CONFIG_GLOBAL resolves onto it and truncates it at start — on the + // read-only CBIN mount that dies Permission-denied with no verdict, + // every round a permanent crash-retry (mutant run-verified). + expect(dockerRun).toContain('--env GATE_TMPDIR="${CRW}"'); // CI=true is set on every host step by Actions; without it the branch's // own suites take their non-CI path (18 TUI tests that are suppressed on // CI as timing-flaky would run, and a flake is charged to the round). @@ -7148,6 +7178,33 @@ exit 1 'deferred-findings.json', 'deferred-findings.carry.json', ]); + // The fingerprint only catches modifications that PERSIST until the + // post-run compare: a truncate-during-run-then-restore flips the bite + // check's BITE_ENFORCE off mid-run (its -s guard reads the truncated + // file) while the restored bytes pass the compare — the bogus-fix round + // the bite check exists to reject sails to outcome=fixed. The gate + // therefore captures its bite inputs' identity before any branch code + // runs and refuses at the bite section if they moved (#9214 review). + expect(reviewVerificationRunner).toContain( + 'BITE_INPUTS_BEFORE="$(bite_input_digest)"', + ); + expect(reviewVerificationRunner).toMatch( + /if \[\[ "\$\(bite_input_digest\)" != "\$\{BITE_INPUTS_BEFORE\}" \]\]; then\n\s+reject_fix 'bite check inputs changed during the gate run'/, + ); + expect( + reviewVerificationRunner.indexOf( + 'BITE_INPUTS_BEFORE="$(bite_input_digest)"', + ), + ).toBeLessThan( + reviewVerificationRunner.indexOf( + "run_check 'build failed on the agent-committed fix'", + ), + ); + expect( + reviewVerificationRunner.indexOf( + "reject_fix 'bite check inputs changed during the gate run'", + ), + ).toBeLessThan(reviewVerificationRunner.indexOf("BITE_ENFORCE='false'")); // Type-aware, and non-regular paths are refused before anything hashes // them: a symlink to gate-verdict makes the gate's own verdict writes trip // the compare (exit 125 every round, so a deterministic rejection never @@ -7170,6 +7227,37 @@ exit 1 expect(wrapper.indexOf('VERDICT_TYPE="$(stat')).toBeLessThan( wrapper.indexOf('OUTCOME="$(verdict_value'), ); + // Identity, not just type: the gate only APPENDS to the verdict (inode + // preserved), while a pre-staged forgery renamed over it after the + // gate's final write passes both the fingerprint compare (the verdict + // is deliberately outside the set) and the type check. Capture the + // creation inode and refuse a moved one post-run — before the type + // check, so a swapped-in FIFO never gets opened (#9214 review). + expect(wrapper).toContain('VERDICT_INODE="$(stat -c \'%i\' "${VERDICT}"'); + expect(wrapper.indexOf('VERDICT_INODE="$(stat')).toBeGreaterThan( + wrapper.indexOf(': > "${VERDICT}"'), + ); + expect(wrapper.indexOf('VERDICT_INODE="$(stat')).toBeLessThan( + wrapper.indexOf('docker run --rm'), + ); + expect(wrapper).toMatch( + /if \[\[ "\$\{VERDICT_INODE_NOW\}" != "\$\{VERDICT_INODE\}" \]\]; then[\s\S]{0,300}?exit 125/, + ); + // The rc=1 arm also counts outcome lines: the gate writes `outcome=` + // exactly once on every genuine path, so a watcher appending a bare + // `outcome=` after the gate's write would empty OUTCOME through + // last-wins and silently turn an EVALUATED rejection into a + // no-diagnostic crash-retry loop (#9214 review). + expect(wrapper).toContain( + 'OUTCOME_LINES="$(grep -c \'^outcome=\' "${VERDICT}"', + ); + expect(wrapper).toMatch(/if \[\[ "\$\{OUTCOME_LINES:-0\}" -ne 1 \]\]/); + expect(wrapper.indexOf('VERDICT_INODE_NOW="$(stat')).toBeGreaterThan( + wrapper.indexOf('GATE_RC=$?'), + ); + expect(wrapper.indexOf('VERDICT_INODE_NOW="$(stat')).toBeLessThan( + wrapper.indexOf('OUTCOME="$(verdict_value'), + ); // The empty-GATE_IMAGE refusal is load-bearing (docker would otherwise // take `bash` as the image name and every round becomes an opaque // crash-retry with no ::error:: diagnostic), and it was pinned nowhere. @@ -7267,10 +7355,11 @@ exit 1 // A real rejection (the gate wrote failed, then exited 1) carries through // with its routing flags, which only pick repair vs handoff, never a // push. reject_fix writes BOTH flags on every rejection, so the genuine - // shape carries the explicit baseline. + // shape carries the explicit baseline. The gate writes `outcome=` exactly + // once on every genuine path — a two-outcome fixture corresponds to no + // real gate shape and is refused by the count check (cases below). expect( runTranslate(1, [ - 'outcome=fixed', 'outcome=failed', 'preexisting=false', 'retryable=true', @@ -7356,6 +7445,38 @@ exit 1 // The pre-baseline flag-less shape is indistinguishable from a forgery // and is refused the same way. expect(runTranslate(1, ['outcome=failed', 'retryable=true'])).toBe(''); + // The evaluated-handoff shape the gate's four non-reject_fix failure + // exits write (fail_handoff: failure.md aborts, agent produced + // nothing): only outcome=failed crosses the wall — the watermark + // advances and the item is handed off, exactly as pre-container; a + // flag-less `outcome=failed` never reaches the wall anymore. + expect( + runTranslate(1, [ + 'outcome=failed', + 'preexisting=false', + 'retryable=false', + ]), + ).toBe('outcome=failed'); + // An in-container appender cannot silence a genuine rejection: a bare + // or duplicated `outcome=` line appended after the gate's write trips + // the count check and is refused as tampered (explicit ::error:: and + // crash-retry) instead of emptying OUTCOME through last-wins. + expect( + runTranslate(1, [ + 'outcome=failed', + 'preexisting=false', + 'retryable=false', + 'outcome=', + ]), + ).toBe(''); + expect( + runTranslate(1, [ + 'outcome=failed', + 'preexisting=false', + 'retryable=false', + 'outcome=failed', + ]), + ).toBe(''); rmSync(dir, { recursive: true, force: true }); }); @@ -17110,6 +17231,8 @@ describe('review verification gate: baseline A/B on deterministic rejection', () baselineNoIdentity = false, trackedDirt = false, commFail = false, + biteTamper = false, + failureMd = false, }) => { const dir = mkdtempSync(join(tmpdir(), 'gate-ab-')); try { @@ -17219,6 +17342,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () ' if [[ "${NOISY_SUCCESS:-}" == "1" ]]; then', ' for i in $(seq 1 200); do echo "baseline build banner line $i — all green, nothing to see"; done', ' fi', + ' if [[ "${BITE_TAMPER:-}" == "1" ]]; then : > "${WORKDIR}/rc.json"; fi', 'fi', 'if [[ "$1" == "run" && "$2" == "typecheck" && "${TYPECHECK_FAIL:-}" == "1" ]]; then', ' echo "stub typecheck FAILED"; exit 1', @@ -17255,6 +17379,17 @@ describe('review verification gate: baseline A/B on deterministic rejection', () const workdir = join(dir, 'wd'); mkdirSync(workdir); writeFileSync(join(workdir, 'address-summary.md'), 'summary\n'); + if (biteTamper) { + writeFileSync( + join(workdir, 'rc.json'), + '[{"id":1,"body":"**[Critical]** something"}]\n', + ); + writeFileSync(join(workdir, 'resolved-comments.txt'), '1\n'); + writeFileSync(join(workdir, 'rv.json'), '[]\n'); + } + if (failureMd) { + writeFileSync(join(workdir, 'failure.md'), 'agent aborted\n'); + } const outFile = join(dir, 'gh-output'); writeFileSync(outFile, ''); @@ -17289,6 +17424,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () HUGE_FAIL: hugeFail ? '1' : '', WORKSPACE_TEST_FAIL: addWorkspace ? '1' : '', RESOLVED_PKGS: addWorkspace ? 'packages/newpkg' : '', + BITE_TAMPER: biteTamper ? '1' : '', }, }, ); @@ -17329,7 +17465,10 @@ describe('review verification gate: baseline A/B on deterministic rejection', () expect(r.status).toBe(1); expect(r.outputs).toContain('outcome=failed'); expect(r.outputs).toContain('preexisting=true'); - // retryable stays unset: the repair step keys on it and must not run. + // The explicit false baseline rides every rejection — the wrapper + // refuses a failed verdict without exactly one of each routing flag — + // and the repair step keys on `== 'true'`, so false keeps it parked. + expect(r.outputs).toContain('retryable=false'); expect(r.outputs).not.toContain('retryable=true'); expect(r.rejection).toContain('pre-existing'); expect(r.rejection).toContain('base update (merge main)'); @@ -17581,6 +17720,48 @@ describe('review verification gate: baseline A/B on deterministic rejection', () expect(r.outputs).not.toContain('preexisting=true'); expect(r.stdout).not.toContain('Baseline A/B'); }); + + it('writes the canonical routing flags on the evaluated-handoff exits', () => { + // The four non-reject_fix failure exits (failure.md aborts, agent + // produced nothing) used to write a flag-less outcome=failed — the + // wrapper's forgery discipline refuses that shape, so the genuine + // verdict died as a "forged routing flag" and the round looped in + // crash-retries instead of advancing the watermark (#9214 review). + // agentCommit: false leaves the branch == origin/branch with no + // no-action.md: the 'agent produced nothing' path. + const r = runGate({ agentCommit: false }); + expect(r.status).toBe(1); + expect(r.outputs).toContain('outcome=failed'); + expect(r.outputs).toContain('preexisting=false'); + expect(r.outputs).toContain('retryable=false'); + expect(r.stdout).toContain('Branch unchanged and no no-action.md'); + // No true flag written: the host semantics are bit-for-bit pre-container. + expect(r.outputs).not.toContain('retryable=true'); + expect(r.outputs).not.toContain('preexisting=true'); + }); + + it('writes the canonical routing flags on the failure.md abort', () => { + const r = runGate({ failureMd: true }); + expect(r.status).toBe(1); + expect(r.outputs).toContain('outcome=failed'); + expect(r.outputs).toContain('preexisting=false'); + expect(r.outputs).toContain('retryable=false'); + expect(r.stdout).toContain('Agent aborted intentionally'); + }); + + it('refuses when a bite input moves mid-run, even if restored before exit', () => { + // The wrapper's post-run fingerprint compare only sees the restoration: + // truncating rc.json during the build legs flips the bite check's + // BITE_ENFORCE off for the mid-run read, and the restored bytes pass + // the compare. The gate-internal digest pin is what catches it (#9214). + const r = runGate({ biteTamper: true }); + expect(r.status).toBe(1); + expect(r.outputs).toContain('outcome=failed'); + expect(r.outputs).toContain('retryable=true'); + expect(r.rejection).toContain( + 'bite check inputs changed during the gate run', + ); + }); }); describe('review verification gate: preexisting output is consumed', () => { From 16ca0744abbbd5c484f3c8a33bdb9b3be2a4612b Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Mon, 17 Aug 2026 12:46:10 +0000 Subject: [PATCH 10/11] fix(autofix): address round-7 gate-wall review findings (#9214) --- .github/scripts/run-autofix-gate-container.sh | 51 ++++- .../run-autofix-review-verification.sh | 178 +++++++++------- .github/workflows/qwen-autofix.yml | 94 ++++++++- scripts/tests/qwen-autofix-workflow.test.js | 191 +++++++++++++++--- 4 files changed, 407 insertions(+), 107 deletions(-) diff --git a/.github/scripts/run-autofix-gate-container.sh b/.github/scripts/run-autofix-gate-container.sh index 684ca0d2ce3..d99f6ad66b9 100755 --- a/.github/scripts/run-autofix-gate-container.sh +++ b/.github/scripts/run-autofix-gate-container.sh @@ -64,8 +64,16 @@ GATE_CONTAINER="qwen-code-gate-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-0}-$$" teardown() { timeout 30 docker rm -f "${GATE_CONTAINER}" > /dev/null 2>&1 || true; } trap teardown EXIT INT TERM -if [[ -z "${GATE_IMAGE:-}" ]]; then - echo "::error::GATE_IMAGE is empty — the sandbox image did not resolve; refusing to run the gate on the host." +# The resolver only ever exports a digest-bound reference to the trusted +# image, ghcr.io/qwenlm/qwen-code@sha256:… — validate the shape at this +# consumer. A host watcher can append a forged `image=` line during the +# sandbox step's own window (up to the 10-minute pull), and the runner +# parses the step's output file last-wins at step END, displacing the +# honest value before the gate steps capture it into GATE_IMAGE; running +# the gate in an attacker-chosen image forges the entire verdict. Defense +# in depth — both gate steps validate the same shape (#9214 review). +if [[ ! "${GATE_IMAGE:-}" =~ ^ghcr\.io/qwenlm/qwen-code@sha256:[0-9a-f]{64}$ ]]; then + echo "::error::GATE_IMAGE is not a digest-bound ghcr.io/qwenlm/qwen-code reference (got '${GATE_IMAGE:-}') — refusing to run the gate." exit 125 fi @@ -151,6 +159,15 @@ if [[ "${INPUTS_BEFORE}" == *':NONREGULAR:'* ]]; then echo "::error::a verdict input in ${WORKDIR} is not a regular file — refusing to run the gate." exit 125 fi +# The container mounts the workspace rw at the runner uid, so a detached +# in-container process can move the branch ref to an attacker commit AND +# append verified_head= after the gate's honest final write — the +# extraction below is last-wins, and the push guard compares `git rev-parse +# HEAD` (read from the same rw-mounted .git) against the forwarded value: +# both operands attacker-consistent, and a head the gate never verified +# would ship on the PAT. Pin the host-side HEAD across the run and refuse +# any move, whatever the verdict file says (#9214 review). +HOST_HEAD_BEFORE="$(git rev-parse HEAD 2> /dev/null || true)" # --network none: the gate only runs build/typecheck/lint/test against # node_modules already installed on the host side, so it needs no egress — @@ -188,6 +205,15 @@ if [[ "$(verdict_inputs_digest)" != "${INPUTS_BEFORE}" ]]; then echo "::error::the gate container modified the agent's verdict inputs in ${WORKDIR} — refusing the verdict; the next scan retries." exit 125 fi +# Host-side half of the HEAD pin captured before the run: the container can +# write the workspace's .git (same uid, rw mount), so re-read HEAD here — a +# moved ref means the tree now present is not the one the gate verified, and +# the verdict is refused regardless of its content. +HOST_HEAD_NOW="$(git rev-parse HEAD 2> /dev/null || true)" +if [[ -z "${HOST_HEAD_NOW}" || "${HOST_HEAD_NOW}" != "${HOST_HEAD_BEFORE}" ]]; then + echo "::error::the workspace HEAD moved during the gate run (${HOST_HEAD_BEFORE:-?} -> ${HOST_HEAD_NOW:-?}) — refusing the verdict; the next scan retries." + exit 125 +fi # Refuse a REPLACED verdict before anything reads it: the gate's appends # keep the creation-time inode, a rename(2) swap does not (see the capture @@ -234,8 +260,27 @@ case "${GATE_RC}" in # verdict says anything else (or nothing) is a gate that did not reach a # verdict, which is the crash path, not a silent success. if [[ "${OUTCOME}" == 'fixed' || "${OUTCOME}" == 'noop' ]]; then + # The same anti-append discipline as the rc=1 arm: every genuine + # exit-0 verdict writes outcome= and verified_head= exactly once + # (fixed at the gate's end, noop at the no-action exit), and + # committed= at most once, so any duplicate is an in-container append. + # Without the count, a watcher appending `outcome=noop` after the + # gate's `outcome=fixed` flips last-wins extraction and silently + # discards a verified fix as no-action (the reverse reports a fix on + # an unchanged branch), and an appended `verified_head=` is the + # identity forgery the host-HEAD pin above independently refuses + # (#9214 review). + OUTCOME_LINES="$(grep -c '^outcome=' "${VERDICT}" 2> /dev/null || true)" + VERIFIED_HEAD_LINES="$(grep -c '^verified_head=' "${VERDICT}" 2> /dev/null || true)" + COMMITTED_LINES="$(grep -c '^committed=' "${VERDICT}" 2> /dev/null || true)" + if [[ "${OUTCOME_LINES:-0}" -ne 1 ]] || + [[ "${VERIFIED_HEAD_LINES:-0}" -ne 1 ]] || + [[ "${COMMITTED_LINES:-0}" -gt 1 ]]; then + echo "::error::verdict carries a forged line (outcome lines: ${OUTCOME_LINES:-0}, verified_head lines: ${VERIFIED_HEAD_LINES:-0}, committed lines: ${COMMITTED_LINES:-0}) — refusing the verdict as tampered." + exit 125 + fi echo "outcome=${OUTCOME}" >> "${GITHUB_OUTPUT}" - [[ -n "${VERIFIED_HEAD}" ]] && echo "verified_head=${VERIFIED_HEAD}" >> "${GITHUB_OUTPUT}" + echo "verified_head=${VERIFIED_HEAD}" >> "${GITHUB_OUTPUT}" else echo "::warning::gate container exited 0 without a verdict (outcome='${OUTCOME}') — treating as a gate crash so the next scan retries." fi diff --git a/.github/scripts/run-autofix-review-verification.sh b/.github/scripts/run-autofix-review-verification.sh index 5c799a55ce4..eebaae6a139 100755 --- a/.github/scripts/run-autofix-review-verification.sh +++ b/.github/scripts/run-autofix-review-verification.sh @@ -343,20 +343,97 @@ if [[ -n "$(git status --porcelain)" ]]; then fi VERIFICATION_HEAD="$(git rev-parse HEAD)" -# The bite section reads rc.json / resolved-comments.txt / rv.json AFTER the -# build legs — host-authored before the container, never written by the gate. -# A branch background process can truncate one for the mid-run read (flipping -# BITE_ENFORCE off, so the bogus-fix round the bite check exists to reject -# sails to outcome=fixed) and restore the bytes before exit — the wrapper's -# post-run fingerprint compare only sees the restoration. Capture identity -# now, before any branch code runs, and refuse at the bite section if the -# inputs moved (#9214 review). +# The bite section's three inputs are host-authored before the container and +# never written by the gate, but they stay attacker-writable for the whole +# container run. Every read that feeds the enforcement decision therefore +# happens ONCE, here, before any branch code runs: the old shape digested the +# live files here but let the bite section RE-READ them after the build legs — +# check-then-use, with two probe-confirmed entrances: a watcher truncates in +# the digest→read gap (BITE_ENFORCE flips off for the mid-run read), or +# substitutes a FIFO that feeds the digest the original bytes while `[[ -s ]]` +# sees st_size 0 — and restored bytes pass the wrapper's post-run compare. +# Freezing the decision at gate start makes the mid-run files inert (#9214 +# review). Non-regular inputs crash the gate verdict-less: the wrapper +# type-checked them before the run, so a non-regular file here moved during +# container startup, and no verdict may be built on inputs of unknown +# identity. Absent inputs are legitimate (the -s guards read them as "no +# defect claim"), matching the wrapper's own fingerprint discipline. +for _bi in rc.json resolved-comments.txt rv.json; do + case "$(stat -c '%F' "${WORKDIR}/${_bi}" 2> /dev/null || true)" in + '' | 'regular file' | 'regular empty file') : ;; + *) + echo "❌ bite check input ${_bi} is not a regular file at gate start" + exit 1 + ;; + esac +done bite_input_digest() { { sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/resolved-comments.txt" \ "${WORKDIR}/rv.json" 2> /dev/null || true; } | sha256sum | cut -d' ' -f1 } BITE_INPUTS_BEFORE="$(bite_input_digest)" +BITE_ENFORCE='false' +if [[ -s "${WORKDIR}/resolved-comments.txt" && -s "${WORKDIR}/rc.json" ]]; then + # Ids tolerate the rc: prefix and CR the other consumers strip (SKILL + # tells the agent to write the rc: handle); a reply resolved inside a + # Critical-rooted thread is a defect claim too, matching how the feedback + # renderers classify replies. + BITE_ENFORCE="$(jq -rs --rawfile ids "${WORKDIR}/resolved-comments.txt" \ + --slurpfile reviews "${WORKDIR}/rv.json" ' + (add // []) as $comments + | ($reviews | add // []) as $reviews + | ($ids | split("\n") + | map(sub("^rc:"; "") | sub("\r$"; "") + | select(test("^[0-9]+$")) | tonumber)) as $resolved + | def cr_attached($x): + (($x.pull_request_review_id // null) as $review + | $review != null + and any($reviews[]; .id == $review and ((.state // "") == "CHANGES_REQUESTED"))); + def critical($c): + (($c.body // "") | contains("**[Critical]**")) + or (($c.in_reply_to_id // null) as $root + | $root != null + and any($comments[]; + .id == $root + and (((.body // "") | contains("**[Critical]**")) or cr_attached(.)))) + or cr_attached($c); + any($comments[]; (.id as $id | $resolved | index($id) != null) and critical(.))' \ + "${WORKDIR}/rc.json" 2> /dev/null)" || BITE_ENFORCE='false' + [[ "${BITE_ENFORCE}" == 'true' ]] || BITE_ENFORCE='false' + # A defect claim whose EVERY resolved-Critical thread sits on a test file + # is a test-side claim ("this test asserts the wrong behavior"): its fixed + # test legitimately passes on the pre-round tree, so it takes the advisory + # arm, never the rejection. + if [[ "${BITE_ENFORCE}" == 'true' ]]; then + TESTSIDE="$(jq -rs --rawfile ids "${WORKDIR}/resolved-comments.txt" \ + --slurpfile reviews "${WORKDIR}/rv.json" ' + (add // []) as $comments + | ($reviews | add // []) as $reviews + | ($ids | split("\n") + | map(sub("^rc:"; "") | sub("\r$"; "") + | select(test("^[0-9]+$")) | tonumber)) as $resolved + | def cr_attached($x): + (($x.pull_request_review_id // null) as $review + | $review != null + and any($reviews[]; .id == $review and ((.state // "") == "CHANGES_REQUESTED"))); + def critical($c): + (($c.body // "") | contains("**[Critical]**")) + or (($c.in_reply_to_id // null) as $root + | $root != null + and any($comments[]; + .id == $root + and (((.body // "") | contains("**[Critical]**")) or cr_attached(.)))) + or cr_attached($c); + [ $comments[] + | select(.id as $id | $resolved | index($id) != null) + | select(critical(.)) | (.path // "") ] + | (length > 0) and all(.[]; + test("\\.(test|spec)\\.") or test("__tests__/|__snapshots__/|test-utils/|^integration-tests/"))' \ + "${WORKDIR}/rc.json" 2> /dev/null)" || TESTSIDE='false' + [[ "${TESTSIDE}" == 'true' ]] && BITE_ENFORCE='advisory' + fi +fi # The schema generator resolves '@qwen-code/qwen-code-core' to core's DIST # entry point, which the CLI bundle restored from the TRUSTED BASE. When the @@ -386,11 +463,17 @@ fi # that predates the script does not contain it (bash would exit 127 # and kill the gate with no outcome), and the gate logic must come # from the trusted base, not the branch under verification. +# GITHUB_OUTPUT= blanks the verdict channel for the helpers' own fail(): +# both append outcome=failed to $GITHUB_OUTPUT when set (their host-run +# contract), and inside the container $GITHUB_OUTPUT IS the verdict file — +# the duplicate outcome= line would trip the wrapper's exactly-one count +# and drop a genuine rejection as forged. reject_fix writes the sole +# verdict line below (#9214 review). run_check_no_ab 'settings schema is stale on the agent-committed fix' \ - bash "${RUNNER_TEMP}/check-settings-schema.sh" + env GITHUB_OUTPUT= bash "${RUNNER_TEMP}/check-settings-schema.sh" CHANGED_FILES="$(git diff --name-only "origin/main...${BRANCH}")" run_check_no_ab 'cross-package contract verification failed' \ - bash "${RUNNER_TEMP}/check-autofix-contracts.sh" <<< "${CHANGED_FILES}" + env GITHUB_OUTPUT= bash "${RUNNER_TEMP}/check-autofix-contracts.sh" <<< "${CHANGED_FILES}" assert_verification_tree if git diff --quiet "origin/${BRANCH}...${BRANCH}"; then @@ -476,7 +559,7 @@ sensitive_class_of() { # the class ledger — fail CLOSED as its own class instead of open. echo 'suspicious-path' ;; .github/workflows/qwen-autofix*.yml | .github/workflows/qwen-triage*.yml | .github/workflows/qwen-pr-safety-precheck.yml) echo 'autofix-loop' ;; - .github/scripts/run-autofix-review-verification.sh | .github/scripts/run-autofix-gate-container.sh | .github/scripts/resolve-owning-packages.sh | .github/scripts/check-settings-schema.sh | .github/scripts/check-autofix-contracts.sh | .github/scripts/resolve-sandbox-image.mjs | .github/scripts/pr-safety-precheck.mjs) echo 'autofix-loop' ;; + .github/scripts/run-autofix-review-verification.sh | .github/scripts/run-autofix-gate-container.sh | .github/scripts/resolve-owning-packages.sh | .github/scripts/check-settings-schema.sh | .github/scripts/check-autofix-contracts.sh | .github/scripts/resolve-sandbox-image.mjs | .github/scripts/pr-safety-precheck.mjs | .github/scripts/resanitize-git-config.sh) echo 'autofix-loop' ;; .github/workflows/* | .github/actions/*) echo 'ci-workflows' ;; .github/scripts/*) echo 'ci-scripts' ;; .github/*) echo 'gh-metadata' ;; @@ -899,70 +982,21 @@ BITE_SRC="$(git diff --name-only -z --no-renames "${ROUND_RANGE}" \ # code? resolved-comments.txt is the agent's own machine-readable claim of # what it fixed; rc.json/rv.json carry the thread bodies and review states # the scan already fetched. Absent/empty inputs read as "no defect claim". +# BITE_ENFORCE was frozen at gate start from the inputs as captured there; +# what remains here is the tamper ALARM — re-check the live inputs type-safely +# (never opening a non-regular file, so a planted FIFO cannot hang the gate +# out) and refuse loudly if they moved. The decision no longer depends on +# this re-read, but a tampered round must not carry a verdict built on inputs +# whose integrity the run cannot vouch for (#9214 review). +for _bi in rc.json resolved-comments.txt rv.json; do + case "$(stat -c '%F' "${WORKDIR}/${_bi}" 2> /dev/null || true)" in + '' | 'regular file' | 'regular empty file') : ;; + *) reject_fix 'bite check inputs changed during the gate run' ;; + esac +done if [[ "$(bite_input_digest)" != "${BITE_INPUTS_BEFORE}" ]]; then reject_fix 'bite check inputs changed during the gate run' fi -BITE_ENFORCE='false' -if [[ -s "${WORKDIR}/resolved-comments.txt" && -s "${WORKDIR}/rc.json" ]]; then - # Ids tolerate the rc: prefix and CR the other consumers strip (SKILL - # tells the agent to write the rc: handle); a reply resolved inside a - # Critical-rooted thread is a defect claim too, matching how the feedback - # renderers classify replies. - BITE_ENFORCE="$(jq -rs --rawfile ids "${WORKDIR}/resolved-comments.txt" \ - --slurpfile reviews "${WORKDIR}/rv.json" ' - (add // []) as $comments - | ($reviews | add // []) as $reviews - | ($ids | split("\n") - | map(sub("^rc:"; "") | sub("\r$"; "") - | select(test("^[0-9]+$")) | tonumber)) as $resolved - | def cr_attached($x): - (($x.pull_request_review_id // null) as $review - | $review != null - and any($reviews[]; .id == $review and ((.state // "") == "CHANGES_REQUESTED"))); - def critical($c): - (($c.body // "") | contains("**[Critical]**")) - or (($c.in_reply_to_id // null) as $root - | $root != null - and any($comments[]; - .id == $root - and (((.body // "") | contains("**[Critical]**")) or cr_attached(.)))) - or cr_attached($c); - any($comments[]; (.id as $id | $resolved | index($id) != null) and critical(.))' \ - "${WORKDIR}/rc.json" 2> /dev/null)" || BITE_ENFORCE='false' - [[ "${BITE_ENFORCE}" == 'true' ]] || BITE_ENFORCE='false' - # A defect claim whose EVERY resolved-Critical thread sits on a test file - # is a test-side claim ("this test asserts the wrong behavior"): its fixed - # test legitimately passes on the pre-round tree, so it takes the advisory - # arm, never the rejection. - if [[ "${BITE_ENFORCE}" == 'true' ]]; then - TESTSIDE="$(jq -rs --rawfile ids "${WORKDIR}/resolved-comments.txt" \ - --slurpfile reviews "${WORKDIR}/rv.json" ' - (add // []) as $comments - | ($reviews | add // []) as $reviews - | ($ids | split("\n") - | map(sub("^rc:"; "") | sub("\r$"; "") - | select(test("^[0-9]+$")) | tonumber)) as $resolved - | def cr_attached($x): - (($x.pull_request_review_id // null) as $review - | $review != null - and any($reviews[]; .id == $review and ((.state // "") == "CHANGES_REQUESTED"))); - def critical($c): - (($c.body // "") | contains("**[Critical]**")) - or (($c.in_reply_to_id // null) as $root - | $root != null - and any($comments[]; - .id == $root - and (((.body // "") | contains("**[Critical]**")) or cr_attached(.)))) - or cr_attached($c); - [ $comments[] - | select(.id as $id | $resolved | index($id) != null) - | select(critical(.)) | (.path // "") ] - | (length > 0) and all(.[]; - test("\\.(test|spec)\\.") or test("__tests__/|__snapshots__/|test-utils/|^integration-tests/"))' \ - "${WORKDIR}/rc.json" 2> /dev/null)" || TESTSIDE='false' - [[ "${TESTSIDE}" == 'true' ]] && BITE_ENFORCE='advisory' - fi -fi if [[ -z "${BITE_SRC}" && ( "${BITE_ENFORCE}" == 'true' || "${BITE_ENFORCE}" == 'advisory' ) ]]; then # A defect-claim round that changed only tests cannot be bite-checked # (a fixed test legitimately passes on the pre-round tree) — surface diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 535073f5d62..4d7a4b5e440 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -5628,6 +5628,15 @@ jobs: # PATH pin, the digest checks and the wrapper — so pin it empty # even though the agent steps unlink their channel files. BASH_ENV: '' + # LD_* is applied by the loader at process STARTUP: a planted + # LD_PRELOAD is loaded into this step's OWN bash before line 1, + # which an in-script unset can no longer prevent — it only ever + # stripped the channel from children. Step-level env is applied + # before the step's bash starts and outranks $GITHUB_ENV: the one + # point the preload channels can still be dropped (#9214 review). + LD_PRELOAD: '' + LD_AUDIT: '' + LD_LIBRARY_PATH: '' run: |- # The gate decides whether the PAT push runs, and it executes the # branch's OWN build/test — so it runs inside an ephemeral container @@ -5635,11 +5644,26 @@ jobs: # $GITHUB_OUTPUT are simply absent inside, so BASH_ENV/LD_PRELOAD # and a forged verdict line have nothing to reach. Both staged # scripts are digest-verified first (digests recorded in - # GITHUB_OUTPUT, unreachable from a disk write), with PATH pinned - # and the preload channels dropped so the sha256sum/bash doing the - # verifying cannot themselves be swapped. + # GITHUB_OUTPUT, unreachable from a disk write), with PATH pinned, + # the image shape validated, and the preload channels pinned empty + # at step env: level above — the loader applies LD_* at process + # STARTUP, so only a pre-startup pin keeps the sha256sum/bash doing + # the verifying from being swapped; an in-script unset would strip + # the channel from children after this step's own bash was already + # preloaded (#9214 review). export PATH="${TRUSTED_PATH}" - unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH + # The image must be the resolver's digest-bound reference to the + # trusted image: a watcher can append a forged `image=` line during + # the sandbox step's own window (up to the 10-minute pull), and the + # runner's last-wins parse at step END displaces the honest value + # before this step captures it — validate the shape at the consumer. + # The resolver can only export this exact form, so the pin is exact + # and a forged foreign-registry reference becomes an explicit + # refusal (#9214 review). + if [[ ! "${GATE_IMAGE}" =~ ^ghcr\.io/qwenlm/qwen-code@sha256:[0-9a-f]{64}$ ]]; then + echo "::error::GATE_IMAGE is not a digest-bound ghcr.io/qwenlm/qwen-code reference — refusing to run the gate." + exit 1 + fi # Pre-merge: the wrapper is absent from the trusted base (staged # empty above), so there is nothing digest-verifiable to run. Skip # with notice instead of failing sha256sum -c opaquely; the empty @@ -5649,6 +5673,21 @@ jobs: echo "::notice::run-autofix-gate-container.sh is absent from the trusted base (pre-merge) — skipping the container gate; the next scan retries." exit 0 fi + # Type discipline before hashing, the wrapper's own shape: RUNNER_TEMP + # is attacker-writable during the agent step, and a planted FIFO or + # /dev/zero symlink at any digested script path hangs sha256sum's + # open() until the step's 60-minute timeout — a one-line-per-round + # DoS of the verification loop (#9214 review). + for _gs in run-autofix-review-verification.sh run-autofix-gate-container.sh \ + check-settings-schema.sh check-autofix-contracts.sh resolve-owning-packages.sh; do + case "$(stat -c '%F' "${RUNNER_TEMP}/${_gs}" 2> /dev/null || true)" in + 'regular file' | 'regular empty file') : ;; + *) + echo "::error::staged gate script ${_gs} is not a regular file — refusing to run the gate." + exit 1 + ;; + esac + done echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - # The helpers the gate executes are staged in the same writable @@ -5876,6 +5915,15 @@ jobs: # PATH pin, the digest checks and the wrapper — so pin it empty # even though the agent steps unlink their channel files. BASH_ENV: '' + # LD_* is applied by the loader at process STARTUP: a planted + # LD_PRELOAD is loaded into this step's OWN bash before line 1, + # which an in-script unset can no longer prevent — it only ever + # stripped the channel from children. Step-level env is applied + # before the step's bash starts and outranks $GITHUB_ENV: the one + # point the preload channels can still be dropped (#9214 review). + LD_PRELOAD: '' + LD_AUDIT: '' + LD_LIBRARY_PATH: '' run: |- # The gate decides whether the PAT push runs, and it executes the # branch's OWN build/test — so it runs inside an ephemeral container @@ -5883,11 +5931,26 @@ jobs: # $GITHUB_OUTPUT are simply absent inside, so BASH_ENV/LD_PRELOAD # and a forged verdict line have nothing to reach. Both staged # scripts are digest-verified first (digests recorded in - # GITHUB_OUTPUT, unreachable from a disk write), with PATH pinned - # and the preload channels dropped so the sha256sum/bash doing the - # verifying cannot themselves be swapped. + # GITHUB_OUTPUT, unreachable from a disk write), with PATH pinned, + # the image shape validated, and the preload channels pinned empty + # at step env: level above — the loader applies LD_* at process + # STARTUP, so only a pre-startup pin keeps the sha256sum/bash doing + # the verifying from being swapped; an in-script unset would strip + # the channel from children after this step's own bash was already + # preloaded (#9214 review). export PATH="${TRUSTED_PATH}" - unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH + # The image must be the resolver's digest-bound reference to the + # trusted image: a watcher can append a forged `image=` line during + # the sandbox step's own window (up to the 10-minute pull), and the + # runner's last-wins parse at step END displaces the honest value + # before this step captures it — validate the shape at the consumer. + # The resolver can only export this exact form, so the pin is exact + # and a forged foreign-registry reference becomes an explicit + # refusal (#9214 review). + if [[ ! "${GATE_IMAGE}" =~ ^ghcr\.io/qwenlm/qwen-code@sha256:[0-9a-f]{64}$ ]]; then + echo "::error::GATE_IMAGE is not a digest-bound ghcr.io/qwenlm/qwen-code reference — refusing to run the gate." + exit 1 + fi # Pre-merge: the wrapper is absent from the trusted base (staged # empty above), so there is nothing digest-verifiable to run. Skip # with notice instead of failing sha256sum -c opaquely; the empty @@ -5897,6 +5960,21 @@ jobs: echo "::notice::run-autofix-gate-container.sh is absent from the trusted base (pre-merge) — skipping the container gate; the next scan retries." exit 0 fi + # Type discipline before hashing, the wrapper's own shape: RUNNER_TEMP + # is attacker-writable during the agent step, and a planted FIFO or + # /dev/zero symlink at any digested script path hangs sha256sum's + # open() until the step's 60-minute timeout — a one-line-per-round + # DoS of the verification loop (#9214 review). + for _gs in run-autofix-review-verification.sh run-autofix-gate-container.sh \ + check-settings-schema.sh check-autofix-contracts.sh resolve-owning-packages.sh; do + case "$(stat -c '%F' "${RUNNER_TEMP}/${_gs}" 2> /dev/null || true)" in + 'regular file' | 'regular empty file') : ;; + *) + echo "::error::staged gate script ${_gs} is not a regular file — refusing to run the gate." + exit 1 + ;; + esac + done echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - # The helpers the gate executes are staged in the same writable diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index d87310d0565..76c3a530dad 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -6813,6 +6813,39 @@ exit 1 // step-level env outranks anything appended to $GITHUB_ENV during the // agent step (#9214 review), so both gate steps pin it empty. expect(gate).toContain("BASH_ENV: ''"); + // LD_* is applied by the loader at process STARTUP — a planted + // LD_PRELOAD is loaded into the gate step's OWN bash before line 1, + // so the in-script unset only ever stripped it from children. The pin + // must sit in env: (applied before the bash starts), and the run body + // no longer carries the weaker in-script unset (#9214 review). + expect(gate).toContain("LD_PRELOAD: ''"); + expect(gate).toContain("LD_AUDIT: ''"); + expect(gate).toContain("LD_LIBRARY_PATH: ''"); + expect(gate).not.toMatch( + /^ {10}unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH\s*$/m, + ); + // The image reference is shape-validated at the consumer: a forged + // `image=` line appended during the sandbox step's own window (the + // runner parses the step output file last-wins at step END) must not + // choose the gate's runtime; the resolver can only export this exact + // digest-bound form (#9214 review). + expect(gate).toMatch( + /if \[\[ ! "\$\{GATE_IMAGE\}" =~ \^ghcr\\\.io\/qwenlm\/qwen-code@sha256:\[0-9a-f\]\{64\}\$ \]\]; then[\s\S]{0,340}?exit 1\n\s+fi/, + ); + // The five digested scripts are type-checked BEFORE any sha256sum: + // RUNNER_TEMP is attacker-writable during the agent step, and a + // planted FIFO or /dev/zero symlink at a digested path would hang + // sha256sum's open() until the step's 60-minute timeout instead of + // failing loudly (#9214 review). + expect(gate).toContain( + 'for _gs in run-autofix-review-verification.sh run-autofix-gate-container.sh', + ); + expect( + gate.indexOf('for _gs in run-autofix-review-verification.sh'), + ).toBeLessThan(gate.indexOf('echo "${VERIFY_RUNNER_SHA256}')); + expect(gate).toMatch( + /staged gate script \$\{_gs\} is not a regular file[\s\S]{0,120}?exit 1/, + ); } expect(workflow).toContain( "- name: 'Resolve sandbox image'\n # id + step output", @@ -7131,6 +7164,25 @@ exit 1 'if [[ "$(verdict_inputs_digest)" != "${INPUTS_BEFORE}" ]]', ), ).toBeLessThan(wrapper.indexOf('OUTCOME="$(verdict_value')); + // The host-side HEAD pin: the container mounts the workspace rw at the + // runner uid, so a detached in-container process can move the branch + // ref to an attacker commit AND append verified_head= after the + // gate's honest write — extraction is last-wins and the push guard + // compares `git rev-parse HEAD` (the same rw-mounted .git) against the + // forwarded value, both attacker-consistent. Capture HEAD on the host + // before the run and refuse any move after it, whatever the verdict + // file says (#9214 review). + expect(wrapper).toContain('HOST_HEAD_BEFORE="$(git rev-parse HEAD'); + expect( + wrapper.indexOf('HOST_HEAD_BEFORE="$(git rev-parse HEAD'), + ).toBeLessThan(wrapper.indexOf('docker run --rm')); + expect(wrapper).toContain('HOST_HEAD_NOW="$(git rev-parse HEAD'); + expect( + wrapper.indexOf('HOST_HEAD_NOW="$(git rev-parse HEAD'), + ).toBeGreaterThan(wrapper.indexOf('GATE_RC=$?')); + expect(wrapper).toMatch( + /if \[\[ -z "\$\{HOST_HEAD_NOW\}" \|\| "\$\{HOST_HEAD_NOW\}" != "\$\{HOST_HEAD_BEFORE\}" \]\]; then[\s\S]{0,400}?exit 125/, + ); // …and the refusal must actually ABORT: deleting this exit leaves every // pin above green while a tampered run sails into the translation and // forwards outcome=fixed derived from planted inputs. @@ -7183,28 +7235,55 @@ exit 1 // check's BITE_ENFORCE off mid-run (its -s guard reads the truncated // file) while the restored bytes pass the compare — the bogus-fix round // the bite check exists to reject sails to outcome=fixed. The gate - // therefore captures its bite inputs' identity before any branch code - // runs and refuses at the bite section if they moved (#9214 review). + // therefore reads its bite inputs ONCE, before any branch code runs, + // and freezes the enforcement decision there: a check-then-use re-read + // after the build legs let a watcher truncate in the digest→read gap or + // substitute a FIFO that feeds the digest original bytes while `[[ -s ]]` + // sees st_size 0 — both probe-confirmed (#9214 review). The late site + // keeps only a type-safe tamper alarm. expect(reviewVerificationRunner).toContain( 'BITE_INPUTS_BEFORE="$(bite_input_digest)"', ); + // Type discipline BEFORE any hash or read of the inputs: a FIFO planted + // in the container-startup window must crash the gate loudly, never hang + // sha256sum's open() until the step timeout. expect(reviewVerificationRunner).toMatch( - /if \[\[ "\$\(bite_input_digest\)" != "\$\{BITE_INPUTS_BEFORE\}" \]\]; then\n\s+reject_fix 'bite check inputs changed during the gate run'/, + /for _bi in rc\.json resolved-comments\.txt rv\.json; do[\s\S]{0,420}?BITE_INPUTS_BEFORE="\$\(bite_input_digest\)"/, ); + // Every read that feeds the decision — the digest, the -s guards and + // both jq computations — sits BEFORE the first build leg: the frozen + // decision is what makes a mid-run truncate or FIFO substitution inert. expect( + reviewVerificationRunner.indexOf("BITE_ENFORCE='false'"), + ).toBeLessThan( reviewVerificationRunner.indexOf( - 'BITE_INPUTS_BEFORE="$(bite_input_digest)"', + "run_check 'build failed on the agent-committed fix'", + ), + ); + expect( + reviewVerificationRunner.lastIndexOf( + '--slurpfile reviews "${WORKDIR}/rv.json"', ), ).toBeLessThan( reviewVerificationRunner.indexOf( "run_check 'build failed on the agent-committed fix'", ), ); + // The alarm survives at the bite section: refuse a moved (or swapped + // non-regular) input loudly instead of letting a tampered round carry a + // verdict built on inputs of unknown integrity. + expect(reviewVerificationRunner).toMatch( + /if \[\[ "\$\(bite_input_digest\)" != "\$\{BITE_INPUTS_BEFORE\}" \]\]; then\n\s+reject_fix 'bite check inputs changed during the gate run'/, + ); expect( reviewVerificationRunner.indexOf( "reject_fix 'bite check inputs changed during the gate run'", ), - ).toBeLessThan(reviewVerificationRunner.indexOf("BITE_ENFORCE='false'")); + ).toBeGreaterThan( + reviewVerificationRunner.indexOf( + "run_check 'build failed on the agent-committed fix'", + ), + ); // Type-aware, and non-regular paths are refused before anything hashes // them: a symlink to gate-verdict makes the gate's own verdict writes trip // the compare (exit 125 every round, so a deterministic rejection never @@ -7243,26 +7322,42 @@ exit 1 expect(wrapper).toMatch( /if \[\[ "\$\{VERDICT_INODE_NOW\}" != "\$\{VERDICT_INODE\}" \]\]; then[\s\S]{0,300}?exit 125/, ); - // The rc=1 arm also counts outcome lines: the gate writes `outcome=` - // exactly once on every genuine path, so a watcher appending a bare - // `outcome=` after the gate's write would empty OUTCOME through - // last-wins and silently turn an EVALUATED rejection into a - // no-diagnostic crash-retry loop (#9214 review). + // BOTH arms count outcome lines: the gate writes `outcome=` exactly + // once on every genuine path, so a watcher appending a bare `outcome=` + // after the gate's write would empty OUTCOME through last-wins and + // silently turn an EVALUATED rejection into a no-diagnostic crash-retry + // loop — and on the rc=0 arm an appended `outcome=noop` silently + // discards a verified fix as no-action. The exit-0 arm additionally + // counts verified_head= (an appended line is the push-guard identity + // forgery) and bounds committed= (#9214 review). + expect( + wrapper.match( + /OUTCOME_LINES="\$\(grep -c '\^outcome=' "\$\{VERDICT\}"/g, + ) ?? [], + ).toHaveLength(2); + expect(wrapper).toMatch(/if \[\[ "\$\{OUTCOME_LINES:-0\}" -ne 1 \]\]/); expect(wrapper).toContain( - 'OUTCOME_LINES="$(grep -c \'^outcome=\' "${VERDICT}"', + 'VERIFIED_HEAD_LINES="$(grep -c \'^verified_head=\' "${VERDICT}"', + ); + expect(wrapper).toContain( + 'COMMITTED_LINES="$(grep -c \'^committed=\' "${VERDICT}"', ); - expect(wrapper).toMatch(/if \[\[ "\$\{OUTCOME_LINES:-0\}" -ne 1 \]\]/); expect(wrapper.indexOf('VERDICT_INODE_NOW="$(stat')).toBeGreaterThan( wrapper.indexOf('GATE_RC=$?'), ); expect(wrapper.indexOf('VERDICT_INODE_NOW="$(stat')).toBeLessThan( wrapper.indexOf('OUTCOME="$(verdict_value'), ); - // The empty-GATE_IMAGE refusal is load-bearing (docker would otherwise - // take `bash` as the image name and every round becomes an opaque - // crash-retry with no ::error:: diagnostic), and it was pinned nowhere. + // The GATE_IMAGE shape pin is load-bearing on two counts: an empty + // GATE_IMAGE (the resolver never ran) would make docker take `bash` as + // the image name and every round becomes an opaque crash-retry with no + // ::error:: diagnostic, and a FORGED `image=` line appended during the + // sandbox step's own window — the runner parses the step's output file + // last-wins at step END, displacing the honest value — would otherwise + // choose the gate's runtime. The resolver can only export this exact + // digest-bound form, so the pin is exact (#9214 review). expect(wrapper).toMatch( - /if \[\[ -z "\$\{GATE_IMAGE:-\}" \]\]; then[\s\S]{0,300}?exit 125\nfi/, + /if \[\[ ! "\$\{GATE_IMAGE:-\}" =~ \^ghcr\\\.io\/qwenlm\/qwen-code@sha256:\[0-9a-f\]\{64\}\$ \]\]; then[\s\S]{0,400}?exit 125\nfi/, ); // --rm only fires on a normal exit: a step timeout / job cap / cancel // kills the docker CLIENT and would leave the container running as the @@ -7374,13 +7469,32 @@ exit 1 // retry: synthesizing `failed` there would advance the watermark and hand // the item off for good. expect(runTranslate(1, [])).toBe(''); - // A legitimate no-op round carries through as well. - expect(runTranslate(0, ['outcome=noop'])).toBe('outcome=noop'); - // Last-wins extraction is the anti-forgery shape: branch code appending an - // earlier forged line cannot displace the gate's final verdict. - expect(runTranslate(0, ['outcome=failed', 'outcome=fixed'])).toBe( - 'outcome=fixed', - ); + // A legitimate no-op round carries through as well. The gate's noop + // exit writes verified_head too, so the count check rides along. + expect(runTranslate(0, ['verified_head=abc', 'outcome=noop'])).toBe( + 'outcome=noop\nverified_head=abc', + ); + // A duplicated outcome line is an in-container append on BOTH arms: a + // watcher appending `outcome=noop` after the gate's `outcome=fixed` + // flips last-wins extraction and silently discards a verified fix as + // no-action (the reverse reports a fix on an unchanged branch). The + // count refuses the verdict — crash-retry — instead of forwarding + // whichever line last-wins picks (#9214 review). + expect(runTranslate(0, ['outcome=failed', 'outcome=fixed'])).toBe(''); + expect( + runTranslate(0, ['outcome=fixed', 'verified_head=abc', 'outcome=noop']), + ).toBe(''); + // …and the identity half of the same class: an appended verified_head= + // displaces the gate's honest value through last-wins, and the push + // guard then compares two attacker-consistent operands (the wrapper's + // host-side HEAD pin independently refuses the moved ref). + expect( + runTranslate(0, [ + 'outcome=fixed', + 'verified_head=abc', + 'verified_head=attacker', + ]), + ).toBe(''); // preexisting routes to the base-update handoff (never a push) and rides // along with a genuine failure. expect( @@ -11691,6 +11805,7 @@ exit 1 'eslint.legacy-filenames.mjs', '.github/workflows/qwen-pr-safety-precheck.yml', '.github/scripts/run-autofix-gate-container.sh', + '.github/scripts/resanitize-git-config.sh', ]); expect(classes).toContain('.github/actions/a/action.yml=ci-workflows'); expect(classes).toContain('.github/scripts/x.sh=ci-scripts'); @@ -11702,6 +11817,14 @@ exit 1 expect(classes).toContain( '.github/scripts/run-autofix-gate-container.sh=autofix-loop', ); + // Same licensing hole, sibling file: resanitize-git-config.sh executes + // in the PAT-bearing shell immediately before `git push`, so as + // ci-scripts ANY PR touching ANY .github/scripts file would license a + // steered round to rewrite it — committed, PAT-pushed, and then run on + // every later round's trusted-base staging (#9214 review). + expect(classes).toContain( + '.github/scripts/resanitize-git-config.sh=autofix-loop', + ); expect(classes).toContain('.husky/pre-commit=git-hooks'); expect(classes).toContain('.npmrc=toolchain-config'); expect(classes).toContain('.nvmrc=toolchain-config'); @@ -13170,6 +13293,14 @@ exit 1 /(# Bite check:[\s\S]*?)\nassert_verification_tree\necho "verified_head/, )?.[1]; expect(block).toBeTruthy(); + // The enforcement decision is frozen at GATE START (#9214 review): the + // type check, digest capture and both jq computations run before any + // branch code, and the bite block consumes the computed value. The + // harness runs both fragments, in script order, to see a decision. + const gateStart = reviewVerificationRunner.match( + /(# The bite section's three inputs[\s\S]*?\nfi)(?=\n\n# The schema generator)/, + )?.[1]; + expect(gateStart).toBeTruthy(); const run = ( build, { runnerExit, runnerScript, resolverLines, workdir, prelude = '' }, @@ -13209,6 +13340,7 @@ exit 1 'ROUND_RANGE="origin/feat...feat"', freightHelper(), prelude, + gateStart, 'BITE_RUNNER="$2/bite-runner"', 'reject_fix() { echo "REJECT:${1}"; exit 1; }', block, @@ -17364,9 +17496,14 @@ describe('review verification gate: baseline A/B on deterministic rejection', () } const rt = join(dir, 'rt'); mkdirSync(rt); + // Mirrors the REAL helper's fail(): it appends outcome=failed to + // $GITHUB_OUTPUT when set — inside the gate container that file IS + // the verdict, so the gate must blank the channel at the call site + // or reject_fix's own outcome= lands as a duplicate the wrapper's + // count check refuses as forged (#9214 review). writeFileSync( join(rt, 'check-settings-schema.sh'), - 'if [[ "${SCHEMA_FAIL:-}" == "1" ]]; then echo "schema stale"; exit 1; fi\nexit 0\n', + 'if [[ "${SCHEMA_FAIL:-}" == "1" ]]; then echo "schema stale"; if [[ -n "${GITHUB_OUTPUT:-}" ]]; then echo "outcome=failed" >> "${GITHUB_OUTPUT}"; fi; exit 1; fi\nexit 0\n', ); writeFileSync( join(rt, 'check-autofix-contracts.sh'), @@ -17709,6 +17846,12 @@ describe('review verification gate: baseline A/B on deterministic rejection', () expect(r.outputs).toContain('retryable=true'); expect(r.outputs).not.toContain('preexisting=true'); expect(r.stdout).not.toContain('Baseline A/B'); + // Exactly ONE outcome= line: the helpers' own fail() writes one to + // $GITHUB_OUTPUT when set, and inside the container that file IS the + // verdict — a duplicate trips the wrapper's exactly-one count and a + // genuine rejection dies as "forged line" in crash-retries instead + // of reaching the repair round (#9214 review). + expect((r.outputs.match(/^outcome=failed$/gm) ?? []).length).toBe(1); } }); From fa7ad00eb7146c9f3196ecec97001037e4da82ec Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Mon, 17 Aug 2026 19:57:45 +0000 Subject: [PATCH 11/11] fix(autofix): address round-8 gate-wall review findings (#9214) --- .github/scripts/resolve-sandbox-image.mjs | 61 +- .../scripts/resolve-sandbox-image.test.mjs | 70 ++- .github/scripts/run-autofix-gate-container.sh | 200 +++++-- .../run-autofix-review-verification.sh | 109 ++-- .github/workflows/qwen-autofix.yml | 201 ++++++- scripts/tests/qwen-autofix-workflow.test.js | 540 ++++++++++++------ 6 files changed, 876 insertions(+), 305 deletions(-) diff --git a/.github/scripts/resolve-sandbox-image.mjs b/.github/scripts/resolve-sandbox-image.mjs index d5aac237a82..a01d807a093 100644 --- a/.github/scripts/resolve-sandbox-image.mjs +++ b/.github/scripts/resolve-sandbox-image.mjs @@ -126,21 +126,35 @@ export function pullImage(command, image) { }); } +// The repository part of an image reference: everything before the :tag / +// @digest. A registry port keeps its colon — the tag only ever follows the +// LAST '/'. +export function repoOfImage(image) { + const withoutDigest = image.split('@')[0]; + const lastColon = withoutDigest.lastIndexOf(':'); + const lastSlash = withoutDigest.lastIndexOf('/'); + return lastColon > lastSlash + ? withoutDigest.slice(0, lastColon) + : withoutDigest; +} + // Resolve a PULLED image to its content digest (repo@sha256:…). The tag // alone is a mutable local handle: `docker run ` resolves against the // local store without re-pull, and a co-resident process with daemon access // can `docker tag` different content under the same name between resolve // and gate. A digest reference cannot be moved by `docker tag`/`docker build`. -// With `expectedDigest` (the pull's own `Digest:` line), the resolved digest -// must MATCH it: retagged attacker content keeps ITS original repo in -// RepoDigests[0] (and a requested-repo entry still carries the attacker -// digest), so only the digest the pull reported binds the export to the -// content the pull fetched (#9214 review). +// The export must be the EXACT `@` RepoDigests entry: +// RepoDigests is shared by every tag of the same content, so `docker tag` +// of the pulled image adds an alphabetically-sorted entry for the new name +// and index 0 can move OFF the pulled repo (a suffix-only digest check +// still passes) — and retagged attacker content keeps ITS original repo, so +// only the pulled repo + the pull's own `Digest:` line together bind the +// export to the content the pull fetched (#9214 review). export function repoDigestOf(command, image, expectedDigest = '') { return new Promise((resolve) => { const child = spawn( command, - ['image', 'inspect', '--format', '{{index .RepoDigests 0}}', image], + ['image', 'inspect', '--format', '{{json .RepoDigests}}', image], { stdio: ['ignore', 'pipe', 'pipe'] }, ); let stdout = ''; @@ -172,21 +186,34 @@ export function repoDigestOf(command, image, expectedDigest = '') { child.on('close', (code) => { finish(code === 0 ? stdout.trim() : ''); }); - }).then((digest) => { - // `` is what the Go template prints for an image without - // RepoDigests (a locally built one); empty means the inspect failed. - // Either way the mutable tag is exactly what must not be exported. - if (!digest.includes('@sha256:')) { - throw new Error( - `Pulled image ${image} resolved to no repository digest ('${digest}'); refusing to export a mutable tag.`, - ); + }).then((raw) => { + // `null`/`[]` (no RepoDigests, a locally built image), `` and + // empty (the inspect failed) all mean there is no repository digest — + // the mutable tag is exactly what must not be exported. + let digests = []; + try { + const parsed = JSON.parse(raw.trim()); + if (Array.isArray(parsed)) { + digests = parsed.filter((entry) => typeof entry === 'string'); + } + } catch { + // Non-JSON output carries no digests. + } + const repo = repoOfImage(image); + const digest = expectedDigest + ? digests.find((entry) => entry === `${repo}@${expectedDigest}`) ?? '' + : digests.find((entry) => entry.startsWith(`${repo}@sha256:`)) ?? ''; + if (digest.includes('@sha256:')) { + return digest; } - if (expectedDigest && !digest.endsWith(`@${expectedDigest}`)) { + if (digests.length > 0) { throw new Error( - `Pulled image ${image} resolved to a digest the pull did not report ('${digest}' vs '${expectedDigest}') — the tag moved between pull and inspect; refusing.`, + `Pulled image ${image} resolved to digests none of which is '${repo}@${expectedDigest || 'sha256:…'}' (${digests.join(', ')}); refusing to export a foreign or mutable reference.`, ); } - return digest; + throw new Error( + `Pulled image ${image} resolved to no repository digest ('${raw.trim()}'); refusing to export a mutable tag.`, + ); }); } diff --git a/.github/scripts/resolve-sandbox-image.test.mjs b/.github/scripts/resolve-sandbox-image.test.mjs index 3a041933db1..135f9990dec 100644 --- a/.github/scripts/resolve-sandbox-image.test.mjs +++ b/.github/scripts/resolve-sandbox-image.test.mjs @@ -15,6 +15,7 @@ import { validateRequestedImage, exportImage, repoDigestOf, + repoOfImage, parsePullDigest, pullImage, } from './resolve-sandbox-image.mjs'; @@ -103,7 +104,7 @@ async function withDockerStub(scriptBody, fn) { test('repoDigestOf resolves a pulled image to its content digest', async () => { await withDockerStub( - 'printf "%s\\n" "ghcr.io/qwenlm/qwen-code@sha256:0123456789abcdef"', + "printf '%s\\n' '[\"ghcr.io/qwenlm/qwen-code@sha256:0123456789abcdef\"]'", async (stub) => { // The exported reference must be pinned by CONTENT: `docker tag` and // `docker build` cannot move a digest reference, while the tag the @@ -122,7 +123,7 @@ test('withDockerStub keeps the stub alive until the async body settles', async ( // the spawn→open window in a loop. for (let i = 0; i < 30; i++) { await withDockerStub( - 'printf "%s\\n" "ghcr.io/qwenlm/qwen-code@sha256:0123456789abcdef"', + "printf '%s\\n' '[\"ghcr.io/qwenlm/qwen-code@sha256:0123456789abcdef\"]'", async (stub) => { assert.equal( await repoDigestOf(stub, 'ghcr.io/qwenlm/qwen-code:1.2.3'), @@ -134,15 +135,17 @@ test('withDockerStub keeps the stub alive until the async body settles', async ( }); test('repoDigestOf refuses an image without a repository digest', async () => { - // `` is what `docker image inspect --format - // {{index .RepoDigests 0}}` prints for a locally built image; exporting - // the mutable tag in that state is exactly what the pin exists to block. - await withDockerStub('printf "%s\\n" ""', async (stub) => { - await assert.rejects( - repoDigestOf(stub, 'ghcr.io/qwenlm/qwen-code:1.2.3'), - /no repository digest/, - ); - }); + // A locally built image has no RepoDigests — `{{json .RepoDigests}}` + // renders `null`, older daemons print ``; exporting the mutable + // tag in either state is exactly what the pin exists to block. + for (const shape of ['null', '', '[]']) { + await withDockerStub(`printf "%s\\n" "${shape}"`, async (stub) => { + await assert.rejects( + repoDigestOf(stub, 'ghcr.io/qwenlm/qwen-code:1.2.3'), + /no repository digest/, + ); + }); + } }); test('repoDigestOf fails closed when the inspect fails', async () => { @@ -156,20 +159,20 @@ test('repoDigestOf fails closed when the inspect fails', async () => { // The exported reference is bound to the digest the PULL itself reported: // `docker tag` never rewrites digests, so retagged attacker content keeps -// its original repo in RepoDigests[0] (measured live: a tag moved to other +// its original repo in RepoDigests (measured live: a tag moved to other // content resolves to `busybox@sha256:…` and passes the `@sha256:` presence -// check). Only the pull's own Digest line ties the export to the fetched -// content (#9214 review). +// check). Only the pulled repo + the pull's own Digest line together tie +// the export to the fetched content (#9214 review). const GENUINE = 'sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; -test('repoDigestOf refuses a digest the pull did not report (retag race)', async () => { +test('repoDigestOf refuses content whose repo is not the pulled image', async () => { await withDockerStub( - 'printf "%s\\n" "aaa.example/backdoor@sha256:dc2d74b2dc2d74b2dc2d74b2dc2d74b2dc2d74b2dc2d74b2dc2d74b2dc2d74b2"', + "printf '%s\\n' '[\"aaa.example/backdoor@sha256:dc2d74b2dc2d74b2dc2d74b2dc2d74b2dc2d74b2dc2d74b2dc2d74b2dc2d74b2\"]'", async (stub) => { await assert.rejects( repoDigestOf(stub, 'ghcr.io/qwenlm/qwen-code:1.2.3', GENUINE), - /digest the pull did not report/, + /none of which is/, ); }, ); @@ -177,7 +180,26 @@ test('repoDigestOf refuses a digest the pull did not report (retag race)', async test('repoDigestOf accepts the digest the pull reported', async () => { await withDockerStub( - `printf "%s\\n" "ghcr.io/qwenlm/qwen-code@${GENUINE}"`, + `printf '%s\\n' '["ghcr.io/qwenlm/qwen-code@${GENUINE}"]'`, + async (stub) => { + assert.equal( + await repoDigestOf(stub, 'ghcr.io/qwenlm/qwen-code:1.2.3', GENUINE), + `ghcr.io/qwenlm/qwen-code@${GENUINE}`, + ); + }, + ); +}); + +test('repoDigestOf keeps the pulled repo when a same-content tag sorts first', async () => { + // `docker tag` of the SAME content adds an alphabetically-sorted + // RepoDigests entry for the new name: index 0 moves off the pulled repo + // while a suffix-only digest check still passes (docker 29.1.3 probe: + // after `docker tag a/a:1`, RepoDigests[0] is `a/a@sha256:…`). + // The resolver must export the `@` entry, not index 0 — + // every gate consumer's shape regex refuses a foreign repo, so exporting + // index 0 gate-crashes the autofix loop until a manual `docker rmi`. + await withDockerStub( + `printf '%s\\n' '["a/a@${GENUINE}","ghcr.io/qwenlm/qwen-code@${GENUINE}"]'`, async (stub) => { assert.equal( await repoDigestOf(stub, 'ghcr.io/qwenlm/qwen-code:1.2.3', GENUINE), @@ -187,6 +209,18 @@ test('repoDigestOf accepts the digest the pull reported', async () => { ); }); +test('repoOfImage strips tag and digest but keeps a registry port', () => { + assert.equal( + repoOfImage('ghcr.io/qwenlm/qwen-code:1.2.3'), + 'ghcr.io/qwenlm/qwen-code', + ); + assert.equal( + repoOfImage('ghcr.io/qwenlm/qwen-code@sha256:ab'), + 'ghcr.io/qwenlm/qwen-code', + ); + assert.equal(repoOfImage('registry:5000/img:tag'), 'registry:5000/img'); +}); + test('parsePullDigest extracts the Digest line from pull output', () => { const pullLog = [ '1.2.3: Pulling from qwenlm/qwen-code', diff --git a/.github/scripts/run-autofix-gate-container.sh b/.github/scripts/run-autofix-gate-container.sh index d99f6ad66b9..d8141a8e858 100755 --- a/.github/scripts/run-autofix-gate-container.sh +++ b/.github/scripts/run-autofix-gate-container.sh @@ -33,6 +33,14 @@ set -uo pipefail # GITHUB_WORKSPACE/GITHUB_OUTPUT are runner-provided, GATE_IMAGE and # FOOTPRINT_ENFORCE are step-level env. None is defined here. +# Unload imported shell functions FIRST: BASH_FUNC_* env carriers cannot be +# pinned by name at step env: level, and an imported function shadows +# sha256sum/stat/bash ahead of any PATH pin — including the caller's, since +# bash re-imports the carriers at every startup (#9214 review). +for _fn in $(compgen -A function); do + unset -f -- "${_fn}" +done + VERDICT="${WORKDIR}/gate-verdict" # The container's own RUNNER_TEMP: a fresh directory holding COPIES of just # the scripts the gate reads. The real RUNNER_TEMP is never mounted — it holds @@ -48,6 +56,11 @@ CTEMP="${RUNNER_TEMP}/gate-container-temp" # throwaway git config via GATE_TMPDIR). CBIN="${CTEMP}/bin" CRW="${CTEMP}/rw" +# Read-only snapshot of the host-authored bite inputs, mounted :ro into the +# container so the gate's enforcement decision reads bytes that were +# digest-verified at check time — and which nothing inside the container can +# rewrite — instead of the live WORKDIR copies (#9214 review). +CINPUTS="${CTEMP}/inputs" # Named so the pool's stale-container janitors (name=qwen-code-*) can see it, # and torn down explicitly: --rm only fires on a normal exit, but a step # timeout / job cap / cancel kills the docker CLIENT and leaves the container @@ -103,7 +116,7 @@ VERDICT_INODE="$(stat -c '%i' "${VERDICT}" 2> /dev/null || true)" # gate rm -f's gate-advisories.md at its own start.) rm -f "${WORKDIR}/gate-rejection.md" rm -rf "${CTEMP}" -mkdir -p "${CBIN}" "${CRW}" || exit 125 +mkdir -p "${CBIN}" "${CRW}" "${CINPUTS}" || exit 125 for staged in run-autofix-review-verification.sh check-settings-schema.sh \ check-autofix-contracts.sh resolve-owning-packages.sh; do cp "${RUNNER_TEMP}/${staged}" "${CBIN}/${staged}" || { @@ -111,6 +124,31 @@ for staged in run-autofix-review-verification.sh check-settings-schema.sh \ exit 125 } done +# Verify the COPIES against the staging-time digests before docker run: the +# gate step checked the RUNNER_TEMP bytes at CHECK time, but this cp runs +# afterwards — a watcher overwriting a staged script in that seam would +# otherwise be copied in and execute AS the gate. The digests ride step env +# from GITHUB_OUTPUT, unreachable from a disk write; an empty one fails +# closed. Bounded reads: a watcher swap in the stat→open window is a refusal +# here, not a hang until the step timeout (#9214 review). +copy_digest() { timeout 10 sha256sum "${CBIN}/${1}" 2> /dev/null | cut -d' ' -f1; } +if [[ -z "${VERIFY_RUNNER_SHA256:-}" ]] || + [[ "$(copy_digest run-autofix-review-verification.sh)" != "${VERIFY_RUNNER_SHA256}" ]]; then + echo "::error::the staged gate script copy no longer matches the digest recorded at staging time — refusing to run the gate." + exit 125 +fi +if [[ -z "${GATE_CONTAINER_SHA256:-}" ]] || + [[ "$(copy_digest run-autofix-gate-container.sh)" != "${GATE_CONTAINER_SHA256}" ]]; then + echo "::error::the staged wrapper copy no longer matches the digest recorded at staging time — refusing to run the gate." + exit 125 +fi +HELPERS_COPY_NOW="$(timeout 30 sha256sum "${CBIN}/check-settings-schema.sh" \ + "${CBIN}/check-autofix-contracts.sh" "${CBIN}/resolve-owning-packages.sh" 2> /dev/null | + sha256sum | cut -d' ' -f1)" +if [[ -z "${GATE_HELPERS_SHA256:-}" ]] || [[ "${HELPERS_COPY_NOW}" != "${GATE_HELPERS_SHA256}" ]]; then + echo "::error::the staged helper copies no longer match the digest recorded at staging time — refusing to run the gate." + exit 125 +fi # --user: the workspace is bind-mounted, so container writes (dist/, vitest # caches, the gate log) must land as the runner user or the next steps hit @@ -140,15 +178,21 @@ done # FIFO would block sha256sum's open() until the step's 60-minute timeout with # no diagnostic. Neither needs a capability on the rw mount. verdict_inputs_digest() { - local f type + local f type hash for f in no-action.md address-summary.md resolved-comments.txt \ comment-replies.json rc.json rv.json ic.json failure.md handoff.md \ deferred-feedback.md agent-api-error agent-api-error-kind \ agent-timeout deferred-findings.json deferred-findings.carry.json; do type="$(stat -c '%F' "${WORKDIR}/${f}" 2> /dev/null || true)" case "${type}" in - '' | 'regular file' | 'regular empty file') - printf '%s:%s\n' "${f}" "$(sha256sum "${WORKDIR}/${f}" 2> /dev/null | cut -d' ' -f1)" + '') printf '%s:\n' "${f}" ;; + 'regular file' | 'regular empty file') + # Bounded: a watcher swap landing in the stat→open window must + # surface as a NONREGULAR refusal, not hang sha256sum's open() + # until the step timeout (#9214 review). + hash="$(timeout 10 sha256sum "${WORKDIR}/${f}" 2> /dev/null | cut -d' ' -f1)" || + hash='NONREGULAR:read-blocked' + printf '%s:%s\n' "${f}" "${hash}" ;; *) printf '%s:NONREGULAR:%s\n' "${f}" "${type}" ;; esac @@ -168,6 +212,50 @@ fi # would ship on the PAT. Pin the host-side HEAD across the run and refuse # any move, whatever the verdict file says (#9214 review). HOST_HEAD_BEFORE="$(git rev-parse HEAD 2> /dev/null || true)" +# Snapshot the workspace's .git/config across the run: the mount is rw at +# the runner uid, so the container can plant exec vectors ([diff] +# external=) that fire in the host's post-run git steps — 'Show +# run artifacts' runs `git diff` before any resanitize. The restore below +# runs immediately after the container exits, whatever the verdict (#9214 +# review). +GIT_CONFIG_SNAPSHOT="${CTEMP}/git-config.snapshot" +if [[ -f "${GITHUB_WORKSPACE}/.git/config" ]]; then + timeout 10 cp "${GITHUB_WORKSPACE}/.git/config" "${GIT_CONFIG_SNAPSHOT}" || { + echo "::error::could not snapshot the workspace git config — refusing to run the gate." + exit 125 + } +fi +# Re-verify the host-authored bite inputs against the prepare-time digest, +# then snapshot them for the gate: the gate step checked the live bytes at +# CHECK time, but a surviving host process can truncate rc.json before the +# gate's own read — the truncation would become the wrapper baseline AND +# the gate's capture, flipping BITE_ENFORCE off so the bogus-fix round the +# bite check exists to reject exits 0 as `fixed`. The per-file hashes ride +# the same expression-context digest; the copies are re-digested after the +# cp so a swap in the verify→copy seam is refused too (#9214 review). +VERDICT_INPUTS_DETAIL="$(timeout 30 sha256sum "${WORKDIR}/rc.json" \ + "${WORKDIR}/rv.json" "${WORKDIR}/ic.json" 2> /dev/null)" || VERDICT_INPUTS_DETAIL='' +VERDICT_INPUTS_NOW="$(printf '%s\n' "${VERDICT_INPUTS_DETAIL}" | sha256sum | cut -d' ' -f1)" +if [[ -z "${VERDICT_INPUTS_SHA256:-}" ]] || [[ "${VERDICT_INPUTS_NOW}" != "${VERDICT_INPUTS_SHA256}" ]]; then + echo "::error::verdict inputs rc.json/rv.json/ic.json no longer match the digest recorded at prepare time — refusing to run the gate." + exit 125 +fi +for _vi in rc.json rv.json resolved-comments.txt; do + if [[ -f "${WORKDIR}/${_vi}" ]]; then + timeout 10 cp "${WORKDIR}/${_vi}" "${CINPUTS}/${_vi}" || { + echo "::error::could not snapshot verdict input ${_vi} for the gate container" + exit 125 + } + fi +done +for _vi in rc.json rv.json; do + _vi_expected="$(printf '%s\n' "${VERDICT_INPUTS_DETAIL}" | grep -F " ${WORKDIR}/${_vi}" | cut -d' ' -f1)" + _vi_now="$(timeout 10 sha256sum "${CINPUTS}/${_vi}" 2> /dev/null | cut -d' ' -f1)" + if [[ -z "${_vi_expected}" || "${_vi_now}" != "${_vi_expected}" ]]; then + echo "::error::verdict input snapshot ${_vi} no longer matches the digest recorded at prepare time — refusing to run the gate." + exit 125 + fi +done # --network none: the gate only runs build/typecheck/lint/test against # node_modules already installed on the host side, so it needs no egress — @@ -183,12 +271,14 @@ docker run --rm --name "${GATE_CONTAINER}" \ --volume "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ --volume "${WORKDIR}:${WORKDIR}" \ --volume "${CBIN}:${CBIN}:ro" \ + --volume "${CINPUTS}:${CINPUTS}:ro" \ --volume "${CRW}:${CRW}" \ --env HOME="${CRW}" \ --env BRANCH="${BRANCH}" \ --env WORKDIR="${WORKDIR}" \ --env RUNNER_TEMP="${CBIN}" \ --env GATE_TMPDIR="${CRW}" \ + --env GATE_INPUTS="${CINPUTS}" \ --env GITHUB_OUTPUT="${VERDICT}" \ --env FOOTPRINT_ENFORCE="${FOOTPRINT_ENFORCE:-advisory}" \ --env CI=true \ @@ -198,6 +288,17 @@ GATE_RC=$? echo "🧱 gate container exited ${GATE_RC}" +# Restore the workspace git config BEFORE anything else touches the +# workspace (host steps below run `git rev-parse`/`git diff`): the +# container mounts it rw at the runner uid and can plant exec vectors +# a post-run resanitize may not precede (#9214 review). +if [[ -f "${GIT_CONFIG_SNAPSHOT}" ]]; then + timeout 10 cp "${GIT_CONFIG_SNAPSHOT}" "${GITHUB_WORKSPACE}/.git/config" || { + echo "::error::could not restore the workspace git config after the gate run — refusing the verdict." + exit 125 + } +fi + if [[ "$(verdict_inputs_digest)" != "${INPUTS_BEFORE}" ]]; then # Something in the container rewrote the agent's verdict inputs. The gate's # own exit code is now meaningless as evidence about the fix, so take the @@ -237,16 +338,20 @@ case "${VERDICT_TYPE}" in ;; esac -# Verdict translation. Only these keys are forwarded, last value wins (the -# gate appends its final verdict last), and `outcome` is gated on the exit -# code — the file alone is not authority. +# Verdict translation. Rejection routing rides the container's EXIT CODE — +# the gate exits 10 (retryable), 11 (pre-existing handoff) or 12 (terminal), +# and branch code in the container cannot change the code the gate exited +# with, while it CAN rewrite the mounted verdict file in place: +# open(O_WRONLY|O_TRUNC) preserves the inode and the type pinned above, and +# a well-formed rewrite satisfies any line count — so on the rejection arms +# the file is human-facing detail only. A pass is still file-gated on exit +# 0, and every read of the file is bounded: a watcher swap in the type→open +# window is a crash here, not a hang until the step timeout (#9214 review). verdict_value() { - grep -E "^${1}=" "${VERDICT}" 2> /dev/null | tail -n 1 | cut -d= -f2- + timeout 10 grep -E "^${1}=" "${VERDICT}" 2> /dev/null | tail -n 1 | cut -d= -f2- } OUTCOME="$(verdict_value outcome)" COMMITTED="$(verdict_value committed)" -RETRYABLE="$(verdict_value retryable)" -PREEXISTING="$(verdict_value preexisting)" VERIFIED_HEAD="$(verdict_value verified_head)" # committed= is a ref-only fact the gate records before any check runs; the @@ -270,9 +375,9 @@ case "${GATE_RC}" in # an unchanged branch), and an appended `verified_head=` is the # identity forgery the host-HEAD pin above independently refuses # (#9214 review). - OUTCOME_LINES="$(grep -c '^outcome=' "${VERDICT}" 2> /dev/null || true)" - VERIFIED_HEAD_LINES="$(grep -c '^verified_head=' "${VERDICT}" 2> /dev/null || true)" - COMMITTED_LINES="$(grep -c '^committed=' "${VERDICT}" 2> /dev/null || true)" + OUTCOME_LINES="$(timeout 10 grep -c '^outcome=' "${VERDICT}" 2> /dev/null || true)" + VERIFIED_HEAD_LINES="$(timeout 10 grep -c '^verified_head=' "${VERDICT}" 2> /dev/null || true)" + COMMITTED_LINES="$(timeout 10 grep -c '^committed=' "${VERDICT}" 2> /dev/null || true)" if [[ "${OUTCOME_LINES:-0}" -ne 1 ]] || [[ "${VERIFIED_HEAD_LINES:-0}" -ne 1 ]] || [[ "${COMMITTED_LINES:-0}" -gt 1 ]]; then @@ -285,57 +390,28 @@ case "${GATE_RC}" in echo "::warning::gate container exited 0 without a verdict (outcome='${OUTCOME}') — treating as a gate crash so the next scan retries." fi ;; - 1) - # A deterministic rejection (reject_fix) writes outcome=failed plus BOTH - # routing flags, exactly once each and mutually exclusive at the source - # (a pre-existing failure is NOT retryable — the repair agent may only - # amend this round's fix). Any other shape — a flag line missing, - # repeated, or both true — is proof the file was touched after the gate - # wrote it (branch code can append to the mounted verdict file), so - # refuse the verdict BEFORE forwarding any of it: a lone forged - # `retryable=true` would otherwise flip a deliberately non-retryable - # rejection (e.g. the bite check) into a PAT-backed repair round, and a - # planted `preexisting=true` overriding a genuine `retryable=true` would - # skip the repair the round is entitled to and permanently misclassify a - # fixable rejection as a terminal pre-existing failure. The crash path - # retries with a fresh checkout instead. - if [[ "${OUTCOME}" == 'failed' ]]; then - # The gate writes `outcome=` exactly once on every genuine path, so a - # duplicated or emptied outcome line is an in-container append exactly - # like a duplicated flag: without the count, a watcher appending a bare - # `outcome=` after the gate's write empties OUTCOME through last-wins - # and silently turns an EVALUATED rejection into a no-diagnostic - # crash-retry loop (#9214 review). - OUTCOME_LINES="$(grep -c '^outcome=' "${VERDICT}" 2> /dev/null || true)" - RETRYABLE_LINES="$(grep -c '^retryable=' "${VERDICT}" 2> /dev/null || true)" - PREEXISTING_LINES="$(grep -c '^preexisting=' "${VERDICT}" 2> /dev/null || true)" - if [[ "${OUTCOME_LINES:-0}" -ne 1 ]] || - [[ "${RETRYABLE_LINES:-0}" -ne 1 ]] || - [[ "${PREEXISTING_LINES:-0}" -ne 1 ]] || - [[ "${PREEXISTING}" == 'true' && "${RETRYABLE}" == 'true' ]]; then - echo "::error::verdict carries a forged line (outcome lines: ${OUTCOME_LINES:-0}, retryable lines: ${RETRYABLE_LINES:-0}, preexisting lines: ${PREEXISTING_LINES:-0}) — refusing the verdict as tampered." - exit 125 - fi - echo "outcome=failed" >> "${GITHUB_OUTPUT}" - [[ "${PREEXISTING}" == 'true' ]] && echo "preexisting=true" >> "${GITHUB_OUTPUT}" - [[ "${RETRYABLE}" == 'true' ]] && echo "retryable=true" >> "${GITHUB_OUTPUT}" - else - # Take `failed` only from the FILE — the gate also has exit-1 paths - # that deliberately write NO verdict (the baseline-A/B and bite - # tree-restore failures), where an EVALUATED rejection would advance - # the watermark and hand the item off for good, and an unset outcome is - # what routes them to the gate-crashed retry instead. A forged - # `outcome=fixed` still cannot pass: `fixed` is accepted only on exit 0, - # so here it leaves the outcome unset and the round retries. - echo "::warning::gate container exited 1 without a deterministic verdict (outcome='${OUTCOME}') — reporting as a gate crash so the next scan retries." - fi + 10 | 11 | 12) + # A deterministic rejection: routing comes from the exit code (see the + # block comment above), never from the rewriteable file. 10 = retryable + # (repair runs), 11 = pre-existing (base-update handoff, NOT retryable — + # the repair agent may only amend this round's fix), 12 = terminal + # evaluated rejection / failure.md handoff (watermark advances, no + # repair). A forged `retryable=true` rewrite can no longer flip a + # deliberately non-retryable rejection into a PAT-backed repair round, + # nor a planted `preexisting=true` misclassify a fixable one. + echo "outcome=failed" >> "${GITHUB_OUTPUT}" + [[ "${GATE_RC}" == '10' ]] && echo "retryable=true" >> "${GITHUB_OUTPUT}" + [[ "${GATE_RC}" == '11' ]] && echo "preexisting=true" >> "${GITHUB_OUTPUT}" ;; *) - # Docker itself failed (125/126/127), the container was killed (137), or - # the gate died before reaching a verdict. Leave outcome UNSET: an - # EVALUATED rejection advances the watermark and hands the item off for - # good, while an empty outcome takes 'Finalize verification's gate-crashed - # path and retries on the next scan's fresh checkout. + # Docker itself failed (125/126/127), the container was killed (137), + # the gate crashed verdict-less (1 — the baseline-A/B and bite + # tree-restore failures), or it died before reaching a verdict. Leave + # outcome UNSET: an EVALUATED rejection advances the watermark and hands + # the item off for good, while an empty outcome takes 'Finalize + # verification's gate-crashed path and retries on the next scan's fresh + # checkout. A forged `outcome=fixed` still cannot pass: `fixed` is + # accepted only on exit 0. echo "::warning::gate container exited ${GATE_RC} without a deterministic verdict — reporting as a gate crash." ;; esac diff --git a/.github/scripts/run-autofix-review-verification.sh b/.github/scripts/run-autofix-review-verification.sh index eebaae6a139..b82129757fa 100755 --- a/.github/scripts/run-autofix-review-verification.sh +++ b/.github/scripts/run-autofix-review-verification.sh @@ -76,7 +76,9 @@ fail_handoff() { echo "outcome=failed" >> "${GITHUB_OUTPUT}" echo "preexisting=false" >> "${GITHUB_OUTPUT}" echo "retryable=false" >> "${GITHUB_OUTPUT}" - exit 1 + # Terminal evaluated handoff — same exit class as a reject_fix with both + # flags false (see its comment): the watermark advances, no repair runs. + exit 12 } if [[ -f "${WORKDIR}/failure.md" && -n "$(git status --porcelain)" ]]; then @@ -152,7 +154,19 @@ reject_fix() { echo '````' } > "${WORKDIR}/gate-rejection.md" || echo "::warning::could not write the gate rejection detail; the verdict stands." - exit 1 + # Routing rides the EXIT CODE: branch code inside the container can + # rewrite the mounted verdict file IN PLACE (open(O_WRONLY|O_TRUNC) keeps + # the inode and the type the wrapper pins, and a well-formed rewrite + # satisfies its line counts), but it cannot change the code the gate + # exits with — 10 = retryable, 11 = pre-existing handoff, 12 = terminal + # (#9214 review). The file keeps the flags for human-facing detail. + if [[ "${preexisting}" == 'true' ]]; then + exit 11 + fi + if [[ "${retryable}" == 'true' ]]; then + exit 10 + fi + exit 12 } baseline_also_fails() { # A deterministic rejection is only chargeable to this round if the same @@ -344,22 +358,23 @@ fi VERIFICATION_HEAD="$(git rev-parse HEAD)" # The bite section's three inputs are host-authored before the container and -# never written by the gate, but they stay attacker-writable for the whole -# container run. Every read that feeds the enforcement decision therefore -# happens ONCE, here, before any branch code runs: the old shape digested the -# live files here but let the bite section RE-READ them after the build legs — -# check-then-use, with two probe-confirmed entrances: a watcher truncates in -# the digest→read gap (BITE_ENFORCE flips off for the mid-run read), or -# substitutes a FIFO that feeds the digest the original bytes while `[[ -s ]]` -# sees st_size 0 — and restored bytes pass the wrapper's post-run compare. -# Freezing the decision at gate start makes the mid-run files inert (#9214 -# review). Non-regular inputs crash the gate verdict-less: the wrapper -# type-checked them before the run, so a non-regular file here moved during -# container startup, and no verdict may be built on inputs of unknown -# identity. Absent inputs are legitimate (the -s guards read them as "no -# defect claim"), matching the wrapper's own fingerprint discipline. +# never written by the gate, but the live copies stay attacker-writable for +# the whole run — so the enforcement decision is computed ONCE, here, before +# any branch code runs, from ONE bounded capture per input: the digest and +# both jq decisions read the SAME capture (the old shape digested the live +# files here but let the jq decisions RE-READ them — check-then-use, so a +# watcher swapping rc.json in the digest→read gap steered BITE_ENFORCE and +# restored the bytes before the alarm), and a watcher racing the stat→open +# window hits the capture's timeout instead of hanging the gate out (#9214 +# review). The container gate reads the wrapper's digest-verified snapshot +# mount (GATE_INPUTS), which nothing inside the container can rewrite; host +# runs read WORKDIR directly. Non-regular inputs crash the gate verdict-less: +# no verdict may be built on inputs of unknown identity. Absent inputs are +# legitimate (the -n guards read them as "no defect claim"), matching the +# wrapper's own fingerprint discipline. +GATE_INPUTS="${GATE_INPUTS:-${WORKDIR}}" for _bi in rc.json resolved-comments.txt rv.json; do - case "$(stat -c '%F' "${WORKDIR}/${_bi}" 2> /dev/null || true)" in + case "$(stat -c '%F' "${GATE_INPUTS}/${_bi}" 2> /dev/null || true)" in '' | 'regular file' | 'regular empty file') : ;; *) echo "❌ bite check input ${_bi} is not a regular file at gate start" @@ -367,20 +382,39 @@ for _bi in rc.json resolved-comments.txt rv.json; do ;; esac done +bite_capture() { + # $1 = input name, $2 = dir (defaults to GATE_INPUTS). Absent files + # capture empty; the timeout bounds the open() so a watcher swapping a + # writerless FIFO into the stat→open window is a crash, not a hang. + local dir="${2:-${GATE_INPUTS}}" + [[ -f "${dir}/${1}" ]] || return 0 + timeout 10 cat "${dir}/${1}" 2> /dev/null +} +BITE_RC_CAPTURE="$(bite_capture rc.json)" || { + echo "❌ bite check input rc.json could not be captured at gate start" + exit 1 +} +BITE_RESOLVED_CAPTURE="$(bite_capture resolved-comments.txt)" || { + echo "❌ bite check input resolved-comments.txt could not be captured at gate start" + exit 1 +} +BITE_RV_CAPTURE="$(bite_capture rv.json)" || { + echo "❌ bite check input rv.json could not be captured at gate start" + exit 1 +} bite_input_digest() { - { sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/resolved-comments.txt" \ - "${WORKDIR}/rv.json" 2> /dev/null || true; } | - sha256sum | cut -d' ' -f1 + printf '%s\n%s\n%s\n' "${1}" "${2}" "${3}" | sha256sum | cut -d' ' -f1 } -BITE_INPUTS_BEFORE="$(bite_input_digest)" +BITE_INPUTS_BEFORE="$(bite_input_digest "${BITE_RC_CAPTURE}" "${BITE_RESOLVED_CAPTURE}" "${BITE_RV_CAPTURE}")" BITE_ENFORCE='false' -if [[ -s "${WORKDIR}/resolved-comments.txt" && -s "${WORKDIR}/rc.json" ]]; then +if [[ -n "${BITE_RESOLVED_CAPTURE}" && -n "${BITE_RC_CAPTURE}" ]]; then # Ids tolerate the rc: prefix and CR the other consumers strip (SKILL # tells the agent to write the rc: handle); a reply resolved inside a # Critical-rooted thread is a defect claim too, matching how the feedback # renderers classify replies. - BITE_ENFORCE="$(jq -rs --rawfile ids "${WORKDIR}/resolved-comments.txt" \ - --slurpfile reviews "${WORKDIR}/rv.json" ' + BITE_ENFORCE="$(jq -rs \ + --rawfile ids <(printf '%s\n' "${BITE_RESOLVED_CAPTURE}") \ + --slurpfile reviews <(printf '%s\n' "${BITE_RV_CAPTURE}") ' (add // []) as $comments | ($reviews | add // []) as $reviews | ($ids | split("\n") @@ -399,15 +433,16 @@ if [[ -s "${WORKDIR}/resolved-comments.txt" && -s "${WORKDIR}/rc.json" ]]; then and (((.body // "") | contains("**[Critical]**")) or cr_attached(.)))) or cr_attached($c); any($comments[]; (.id as $id | $resolved | index($id) != null) and critical(.))' \ - "${WORKDIR}/rc.json" 2> /dev/null)" || BITE_ENFORCE='false' + <(printf '%s\n' "${BITE_RC_CAPTURE}") 2> /dev/null)" || BITE_ENFORCE='false' [[ "${BITE_ENFORCE}" == 'true' ]] || BITE_ENFORCE='false' # A defect claim whose EVERY resolved-Critical thread sits on a test file # is a test-side claim ("this test asserts the wrong behavior"): its fixed # test legitimately passes on the pre-round tree, so it takes the advisory # arm, never the rejection. if [[ "${BITE_ENFORCE}" == 'true' ]]; then - TESTSIDE="$(jq -rs --rawfile ids "${WORKDIR}/resolved-comments.txt" \ - --slurpfile reviews "${WORKDIR}/rv.json" ' + TESTSIDE="$(jq -rs \ + --rawfile ids <(printf '%s\n' "${BITE_RESOLVED_CAPTURE}") \ + --slurpfile reviews <(printf '%s\n' "${BITE_RV_CAPTURE}") ' (add // []) as $comments | ($reviews | add // []) as $reviews | ($ids | split("\n") @@ -430,7 +465,7 @@ if [[ -s "${WORKDIR}/resolved-comments.txt" && -s "${WORKDIR}/rc.json" ]]; then | select(critical(.)) | (.path // "") ] | (length > 0) and all(.[]; test("\\.(test|spec)\\.") or test("__tests__/|__snapshots__/|test-utils/|^integration-tests/"))' \ - "${WORKDIR}/rc.json" 2> /dev/null)" || TESTSIDE='false' + <(printf '%s\n' "${BITE_RC_CAPTURE}") 2> /dev/null)" || TESTSIDE='false' [[ "${TESTSIDE}" == 'true' ]] && BITE_ENFORCE='advisory' fi fi @@ -983,18 +1018,24 @@ BITE_SRC="$(git diff --name-only -z --no-renames "${ROUND_RANGE}" \ # what it fixed; rc.json/rv.json carry the thread bodies and review states # the scan already fetched. Absent/empty inputs read as "no defect claim". # BITE_ENFORCE was frozen at gate start from the inputs as captured there; -# what remains here is the tamper ALARM — re-check the live inputs type-safely -# (never opening a non-regular file, so a planted FIFO cannot hang the gate -# out) and refuse loudly if they moved. The decision no longer depends on -# this re-read, but a tampered round must not carry a verdict built on inputs -# whose integrity the run cannot vouch for (#9214 review). +# what remains here is the tamper ALARM — re-capture the LIVE inputs bounded +# (the timeout converts a watcher swap in the stat→open window into a +# rejection instead of a hang) and refuse loudly if they moved. The decision +# no longer depends on this re-read, but a tampered round must not carry a +# verdict built on inputs whose integrity the run cannot vouch for (#9214 +# review). for _bi in rc.json resolved-comments.txt rv.json; do case "$(stat -c '%F' "${WORKDIR}/${_bi}" 2> /dev/null || true)" in '' | 'regular file' | 'regular empty file') : ;; *) reject_fix 'bite check inputs changed during the gate run' ;; esac done -if [[ "$(bite_input_digest)" != "${BITE_INPUTS_BEFORE}" ]]; then +if ! { BITE_RC_NOW="$(bite_capture rc.json "${WORKDIR}")" && + BITE_RESOLVED_NOW="$(bite_capture resolved-comments.txt "${WORKDIR}")" && + BITE_RV_NOW="$(bite_capture rv.json "${WORKDIR}")"; }; then + reject_fix 'bite check inputs changed during the gate run' +fi +if [[ "$(bite_input_digest "${BITE_RC_NOW}" "${BITE_RESOLVED_NOW}" "${BITE_RV_NOW}")" != "${BITE_INPUTS_BEFORE}" ]]; then reject_fix 'bite check inputs changed during the gate run' fi if [[ -z "${BITE_SRC}" && ( "${BITE_ENFORCE}" == 'true' || "${BITE_ENFORCE}" == 'advisory' ) ]]; then diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 4d7a4b5e440..9be1a87ee86 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -5467,6 +5467,16 @@ jobs: QWEN_TIMEOUT_MS: '${{ vars.QWEN_AUTOFIX_TIMEOUT_MS || 7200000 }}' CONFLICT: '${{ steps.prepare.outputs.conflict }}' BASE: 'main' + # Startup-time injection channels, pinned at env: level (applied + # before the step's bash starts, and outranking $GITHUB_ENV): an + # earlier step ran branch code on this host and can plant them — + # an in-script unset only strips a channel from children AFTER + # this step's own bash was already seeded/preloaded (#9214 review). + BASH_ENV: '' + LD_PRELOAD: '' + LD_AUDIT: '' + LD_LIBRARY_PATH: '' + NODE_OPTIONS: '' SETTINGS_JSON: |- { "maxSessionTurns": 400, @@ -5578,6 +5588,12 @@ jobs: SEVERED_ENV_FILE="${GITHUB_ENV:-}" SEVERED_PATH_FILE="${GITHUB_PATH:-}" unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT + # Mark the agent's whole process tree: env survives setsid and + # re-parenting, so the post-run sweep below finds every leftover + # the group kill misses (MCP stdio servers from a branch-committed + # .mcp.json spawn as HOST processes in this env and outlive the + # step's process-group signals) (#9214 review). + export AUTOFIX_AGENT_TREE="${GITHUB_RUN_ID:-0}-$$" # Trusted staged copy in the mirrored layout — resolves # ../SKILL.md to the trusted staged SKILL, never the PR branch's. AGENT_RC=0 @@ -5588,6 +5604,26 @@ jobs: --workdir "${WORKDIR}" \ --conflict "${CONFLICT}" \ --base "${BASE}" || AGENT_RC="$?" + # Kill every carrier of the tree marker BEFORE the channel files + # are unlinked: a survivor re-creates GITHUB_ENV/GITHUB_PATH + # between the rm and the runner's step-END parse, and the runner + # injects whatever is there into every later step regardless of + # any process environment. Two passes: a process forked after the + # first pass's /proc scan inherits the marker and dies in the + # second (#9214 review). + for _pass in 1 2; do + _tree_killed=0 + for _e in /proc/[0-9]*/environ; do + _tree_pid="${_e#/proc/}" + _tree_pid="${_tree_pid%/environ}" + [[ "${_tree_pid}" == "$$" ]] && continue + if tr '\0' '\n' < "${_e}" 2> /dev/null | + grep -qs "^AUTOFIX_AGENT_TREE=${AUTOFIX_AGENT_TREE}$"; then + kill -KILL "${_tree_pid}" 2> /dev/null && _tree_killed=1 || true + fi + done + [[ "${_tree_killed}" == 0 ]] && break + done # Runs even when the agent failed (bash -e would otherwise abort # at the node line and leave the planted channel files live). rm -f "${SEVERED_ENV_FILE}" "${SEVERED_PATH_FILE}" @@ -5637,6 +5673,11 @@ jobs: LD_PRELOAD: '' LD_AUDIT: '' LD_LIBRARY_PATH: '' + # DOCKER_HOST/DOCKER_CONTEXT reroute the wrapper's `docker run` + # to a rogue daemon — a demonstrated entrance the job never + # pinned; same precedence rationale as the pins above (#9214). + DOCKER_HOST: '' + DOCKER_CONTEXT: '' run: |- # The gate decides whether the PAT push runs, and it executes the # branch's OWN build/test — so it runs inside an ephemeral container @@ -5652,6 +5693,29 @@ jobs: # the channel from children after this step's own bash was already # preloaded (#9214 review). export PATH="${TRUSTED_PATH}" + # Unload imported shell functions: BASH_FUNC_* env carriers cannot + # be pinned by name at env: level, and an imported function + # shadows sha256sum/stat/bash ahead of the PATH pin above (#9214). + for _fn in $(compgen -A function); do + unset -f -- "${_fn}" + done + # A step-timeout kill of the agent step skips that step's own tree + # sweep and channel unlink, so re-sweep this run's tree marker + # before the gate trusts anything on this host: env survives + # setsid and re-parenting, so the marker finds every leftover the + # group kill missed (#9214 review). + for _pass in 1 2; do + _tree_killed=0 + for _e in /proc/[0-9]*/environ; do + _tree_pid="${_e#/proc/}" + _tree_pid="${_tree_pid%/environ}" + if tr '\0' '\n' < "${_e}" 2> /dev/null | + grep -qs "^AUTOFIX_AGENT_TREE=${GITHUB_RUN_ID:-0}-"; then + kill -KILL "${_tree_pid}" 2> /dev/null && _tree_killed=1 || true + fi + done + [[ "${_tree_killed}" == 0 ]] && break + done # The image must be the resolver's digest-bound reference to the # trusted image: a watcher can append a forged `image=` line during # the sandbox step's own window (up to the 10-minute pull), and the @@ -5688,12 +5752,12 @@ jobs: ;; esac done - echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - - echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - + echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | timeout 30 sha256sum -c - + echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | timeout 30 sha256sum -c - # The helpers the gate executes are staged in the same writable # RUNNER_TEMP, so verify them as one blob before the wrapper copies # them across the container wall. - GATE_HELPERS_NOW="$(sha256sum "${RUNNER_TEMP}/check-settings-schema.sh" "${RUNNER_TEMP}/check-autofix-contracts.sh" "${RUNNER_TEMP}/resolve-owning-packages.sh" | sha256sum | cut -d' ' -f1)" + GATE_HELPERS_NOW="$(timeout 30 sha256sum "${RUNNER_TEMP}/check-settings-schema.sh" "${RUNNER_TEMP}/check-autofix-contracts.sh" "${RUNNER_TEMP}/resolve-owning-packages.sh" | sha256sum | cut -d' ' -f1)" if [[ "${GATE_HELPERS_NOW}" != "${GATE_HELPERS_SHA256}" ]]; then echo "::error::staged gate helpers no longer match the digest recorded at staging time; refusing to run the gate." exit 1 @@ -5718,7 +5782,7 @@ jobs: ;; esac done - VERDICT_INPUTS_NOW="$(sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/rv.json" "${WORKDIR}/ic.json" | sha256sum | cut -d' ' -f1)" + VERDICT_INPUTS_NOW="$(timeout 30 sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/rv.json" "${WORKDIR}/ic.json" | sha256sum | cut -d' ' -f1)" if [[ "${VERDICT_INPUTS_NOW}" != "${VERDICT_INPUTS_SHA256}" ]]; then echo "::error::verdict inputs rc.json/rv.json/ic.json no longer match the digest recorded at prepare time; refusing to run the gate." exit 1 @@ -5741,6 +5805,16 @@ jobs: QWEN_TIMEOUT_MS: '1080000' CONFLICT: '${{ steps.prepare.outputs.conflict }}' BASE: 'main' + # Startup-time injection channels, pinned at env: level (applied + # before the step's bash starts, and outranking $GITHUB_ENV): an + # earlier step ran branch code on this host and can plant them — + # an in-script unset only strips a channel from children AFTER + # this step's own bash was already seeded/preloaded (#9214 review). + BASH_ENV: '' + LD_PRELOAD: '' + LD_AUDIT: '' + LD_LIBRARY_PATH: '' + NODE_OPTIONS: '' SETTINGS_JSON: |- { "maxSessionTurns": 400, @@ -5875,6 +5949,12 @@ jobs: SEVERED_ENV_FILE="${GITHUB_ENV:-}" SEVERED_PATH_FILE="${GITHUB_PATH:-}" unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT + # Mark the agent's whole process tree: env survives setsid and + # re-parenting, so the post-run sweep below finds every leftover + # the group kill misses (MCP stdio servers from a branch-committed + # .mcp.json spawn as HOST processes in this env and outlive the + # step's process-group signals) (#9214 review). + export AUTOFIX_AGENT_TREE="${GITHUB_RUN_ID:-0}-$$" AGENT_RC=0 node "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs" \ --mode address-review \ @@ -5883,6 +5963,26 @@ jobs: --workdir "${WORKDIR}" \ --conflict "${CONFLICT}" \ --base "${BASE}" || AGENT_RC="$?" + # Kill every carrier of the tree marker BEFORE the channel files + # are unlinked: a survivor re-creates GITHUB_ENV/GITHUB_PATH + # between the rm and the runner's step-END parse, and the runner + # injects whatever is there into every later step regardless of + # any process environment. Two passes: a process forked after the + # first pass's /proc scan inherits the marker and dies in the + # second (#9214 review). + for _pass in 1 2; do + _tree_killed=0 + for _e in /proc/[0-9]*/environ; do + _tree_pid="${_e#/proc/}" + _tree_pid="${_tree_pid%/environ}" + [[ "${_tree_pid}" == "$$" ]] && continue + if tr '\0' '\n' < "${_e}" 2> /dev/null | + grep -qs "^AUTOFIX_AGENT_TREE=${AUTOFIX_AGENT_TREE}$"; then + kill -KILL "${_tree_pid}" 2> /dev/null && _tree_killed=1 || true + fi + done + [[ "${_tree_killed}" == 0 ]] && break + done rm -f "${SEVERED_ENV_FILE}" "${SEVERED_PATH_FILE}" exit "${AGENT_RC}" @@ -5924,6 +6024,11 @@ jobs: LD_PRELOAD: '' LD_AUDIT: '' LD_LIBRARY_PATH: '' + # DOCKER_HOST/DOCKER_CONTEXT reroute the wrapper's `docker run` + # to a rogue daemon — a demonstrated entrance the job never + # pinned; same precedence rationale as the pins above (#9214). + DOCKER_HOST: '' + DOCKER_CONTEXT: '' run: |- # The gate decides whether the PAT push runs, and it executes the # branch's OWN build/test — so it runs inside an ephemeral container @@ -5939,6 +6044,29 @@ jobs: # the channel from children after this step's own bash was already # preloaded (#9214 review). export PATH="${TRUSTED_PATH}" + # Unload imported shell functions: BASH_FUNC_* env carriers cannot + # be pinned by name at env: level, and an imported function + # shadows sha256sum/stat/bash ahead of the PATH pin above (#9214). + for _fn in $(compgen -A function); do + unset -f -- "${_fn}" + done + # A step-timeout kill of the agent step skips that step's own tree + # sweep and channel unlink, so re-sweep this run's tree marker + # before the gate trusts anything on this host: env survives + # setsid and re-parenting, so the marker finds every leftover the + # group kill missed (#9214 review). + for _pass in 1 2; do + _tree_killed=0 + for _e in /proc/[0-9]*/environ; do + _tree_pid="${_e#/proc/}" + _tree_pid="${_tree_pid%/environ}" + if tr '\0' '\n' < "${_e}" 2> /dev/null | + grep -qs "^AUTOFIX_AGENT_TREE=${GITHUB_RUN_ID:-0}-"; then + kill -KILL "${_tree_pid}" 2> /dev/null && _tree_killed=1 || true + fi + done + [[ "${_tree_killed}" == 0 ]] && break + done # The image must be the resolver's digest-bound reference to the # trusted image: a watcher can append a forged `image=` line during # the sandbox step's own window (up to the 10-minute pull), and the @@ -5975,12 +6103,12 @@ jobs: ;; esac done - echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - - echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c - + echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | timeout 30 sha256sum -c - + echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | timeout 30 sha256sum -c - # The helpers the gate executes are staged in the same writable # RUNNER_TEMP, so verify them as one blob before the wrapper copies # them across the container wall. - GATE_HELPERS_NOW="$(sha256sum "${RUNNER_TEMP}/check-settings-schema.sh" "${RUNNER_TEMP}/check-autofix-contracts.sh" "${RUNNER_TEMP}/resolve-owning-packages.sh" | sha256sum | cut -d' ' -f1)" + GATE_HELPERS_NOW="$(timeout 30 sha256sum "${RUNNER_TEMP}/check-settings-schema.sh" "${RUNNER_TEMP}/check-autofix-contracts.sh" "${RUNNER_TEMP}/resolve-owning-packages.sh" | sha256sum | cut -d' ' -f1)" if [[ "${GATE_HELPERS_NOW}" != "${GATE_HELPERS_SHA256}" ]]; then echo "::error::staged gate helpers no longer match the digest recorded at staging time; refusing to run the gate." exit 1 @@ -6005,7 +6133,7 @@ jobs: ;; esac done - VERDICT_INPUTS_NOW="$(sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/rv.json" "${WORKDIR}/ic.json" | sha256sum | cut -d' ' -f1)" + VERDICT_INPUTS_NOW="$(timeout 30 sha256sum "${WORKDIR}/rc.json" "${WORKDIR}/rv.json" "${WORKDIR}/ic.json" | sha256sum | cut -d' ' -f1)" if [[ "${VERDICT_INPUTS_NOW}" != "${VERDICT_INPUTS_SHA256}" ]]; then echo "::error::verdict inputs rc.json/rv.json/ic.json no longer match the digest recorded at prepare time; refusing to run the gate." exit 1 @@ -6017,6 +6145,16 @@ jobs: if: |- ${{ always() && steps.prepare.outputs.stale != 'true' }} env: + # Startup-time injection channels, pinned at env: level (applied + # before the step's bash starts, and outranking $GITHUB_ENV): an + # earlier step ran branch code on this host and can plant them — + # an in-script unset only strips a channel from children AFTER + # this step's own bash was already seeded/preloaded (#9214 review). + BASH_ENV: '' + LD_PRELOAD: '' + LD_AUDIT: '' + LD_LIBRARY_PATH: '' + NODE_OPTIONS: '' FIRST_OUTCOME: '${{ steps.verify.outputs.outcome }}' FIRST_COMMITTED: '${{ steps.verify.outputs.committed }}' FIRST_VERIFIED_HEAD: '${{ steps.verify.outputs.verified_head }}' @@ -6063,7 +6201,44 @@ jobs: - name: 'Show run artifacts' if: |- ${{ always() }} + env: + TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' + RESANITIZE_SHA256: '${{ steps.stage.outputs.resanitize_sha256 }}' + # Startup-time injection channels, pinned at env: level (applied + # before the step's bash starts, and outranking $GITHUB_ENV): an + # earlier step ran branch code on this host and can plant them — + # an in-script unset only strips a channel from children AFTER + # this step's own bash was already seeded/preloaded (#9214 review). + BASH_ENV: '' + LD_PRELOAD: '' + LD_AUDIT: '' + LD_LIBRARY_PATH: '' + NODE_OPTIONS: '' run: |- + # The gate container mounts the workspace rw at the runner uid, + # so branch code in it can plant git exec vectors ([diff] + # external=) in the repo's .git/config for this step's + # `git diff` to fire — this step runs BEFORE the PAT steps' own + # resanitize. Resanitize first, with the same staged-copy digest + # discipline and env strip as 'Push and report'; the wrapper's + # pre-run .git/config restore covers the same plant on the + # container side, this is the half that also covers gate skips + # (#9214 review). + export PATH="${TRUSTED_PATH}" + for _fn in $(compgen -A function); do + unset -f -- "${_fn}" + done + 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="$(mktemp "${RUNNER_TEMP}/autofix-artifacts-gitconfig.XXXXXX")" + echo "${RESANITIZE_SHA256} ${RUNNER_TEMP}/resanitize-git-config.sh" | sha256sum -c - > /dev/null + bash "${RUNNER_TEMP}/resanitize-git-config.sh" if git rev-parse --verify "${BRANCH}" > /dev/null 2>&1; then git diff "origin/main...${BRANCH}" > "${WORKDIR}/pr.diff" || true fi @@ -6126,6 +6301,16 @@ jobs: GROWTH_TEST: '${{ steps.prepare.outputs.growth_test }}' CRITICAL_ONLY_GROWTH: '${{ steps.prepare.outputs.critical_only_growth }}' MEASURED_AT: '${{ steps.prepare.outputs.measured_at }}' + # Startup-time injection channels, pinned at env: level (applied + # before the step's bash starts, and outranking $GITHUB_ENV): an + # earlier step ran branch code on this host and can plant them — + # an in-script unset only strips a channel from children AFTER + # this step's own bash was already seeded/preloaded (#9214 review). + BASH_ENV: '' + LD_PRELOAD: '' + LD_AUDIT: '' + LD_LIBRARY_PATH: '' + NODE_OPTIONS: '' run: |- # gh has its own $GITHUB_ENV-injectable channels: pin the host and # drop any planted token BEFORE the identity check below, so a diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 76c3a530dad..8d4c78a6628 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -6774,7 +6774,7 @@ exit 1 gate.indexOf('echo "${VERIFY_RUNNER_SHA256}'), ); expect(gate).toContain( - 'echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c -', + 'echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | timeout 30 sha256sum -c -', ); // Presence is not enough: the digest checks are only a wall if they run // BEFORE the wrapper and actually abort. Moving the invocation above @@ -6782,9 +6782,9 @@ exit 1 // appending `|| true` (verification that cannot fail) both leave every // other assertion here green. for (const verifyLine of [ - 'echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c -', - 'echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | sha256sum -c -', - 'GATE_HELPERS_NOW="$(sha256sum ', + 'echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | timeout 30 sha256sum -c -', + 'echo "${GATE_CONTAINER_SHA256} ${RUNNER_TEMP}/run-autofix-gate-container.sh" | timeout 30 sha256sum -c -', + 'GATE_HELPERS_NOW="$(timeout 30 sha256sum ', ]) { expect(gate.indexOf(verifyLine)).toBeGreaterThan(-1); expect(gate.indexOf(verifyLine)).toBeLessThan( @@ -6824,6 +6824,29 @@ exit 1 expect(gate).not.toMatch( /^ {10}unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH\s*$/m, ); + // DOCKER_HOST/DOCKER_CONTEXT reroute the wrapper's `docker run` to a + // rogue daemon (the entrance demonstrated in the round-7 probe), and + // NODE_OPTIONS injects into every node process at startup — pinned + // empty at env: level like the preload channels (#9214 review). + expect(gate).toContain("DOCKER_HOST: ''"); + expect(gate).toContain("DOCKER_CONTEXT: ''"); + // BASH_FUNC_* carriers cannot be pinned by name: an imported shell + // function shadows sha256sum/stat/bash ahead of the PATH pin, so the + // run body unloads every imported function BEFORE the digest checks, + // and re-sweeps this run's agent-tree marker — a step-timeout kill of + // the agent step skips that step's own sweep and channel unlink + // (#9214 review). Both run before anything trusts this host. + expect(gate).toContain('for _fn in $(compgen -A function); do'); + expect(gate).toMatch(/unset -f -- "\$\{_fn\}"/); + expect(gate).toContain( + 'grep -qs "^AUTOFIX_AGENT_TREE=${GITHUB_RUN_ID:-0}-"', + ); + expect( + gate.indexOf('for _fn in $(compgen -A function); do'), + ).toBeLessThan(gate.indexOf('echo "${VERIFY_RUNNER_SHA256}')); + expect( + gate.indexOf('grep -qs "^AUTOFIX_AGENT_TREE=${GITHUB_RUN_ID:-0}-"'), + ).toBeLessThan(gate.indexOf('echo "${VERIFY_RUNNER_SHA256}')); // The image reference is shape-validated at the consumer: a forged // `image=` line appended during the sandbox step's own window (the // runner parses the step output file last-wins at step END) must not @@ -6972,6 +6995,40 @@ exit 1 'node "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs"', ), ); + // A process-group kill misses setsid'd descendants, and a detached + // survivor re-creates GITHUB_ENV/GITHUB_PATH between the rm and the + // runner's step-END parse, injecting every later step (#9214 review). + // The agent tree is marked via env (which survives setsid and + // re-parenting) and every carrier is killed BEFORE the channel files + // are unlinked. + expect(agentStep).toContain( + 'export AUTOFIX_AGENT_TREE="${GITHUB_RUN_ID:-0}-$$"', + ); + expect(agentStep.indexOf('export AUTOFIX_AGENT_TREE=')).toBeLessThan( + agentStep.indexOf( + 'node "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs"', + ), + ); + expect(agentStep).toContain( + 'grep -qs "^AUTOFIX_AGENT_TREE=${AUTOFIX_AGENT_TREE}$"', + ); + expect(agentStep).toContain('for _pass in 1 2; do'); + expect( + agentStep.indexOf( + 'grep -qs "^AUTOFIX_AGENT_TREE=${AUTOFIX_AGENT_TREE}$"', + ), + ).toBeGreaterThan( + agentStep.indexOf( + 'node "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs"', + ), + ); + expect( + agentStep.indexOf( + 'grep -qs "^AUTOFIX_AGENT_TREE=${AUTOFIX_AGENT_TREE}$"', + ), + ).toBeLessThan( + agentStep.indexOf('rm -f "${SEVERED_ENV_FILE}" "${SEVERED_PATH_FILE}"'), + ); } // The three helpers the gate EXECUTES are staged in the same writable // RUNNER_TEMP, so they are digested at staging and verified before the @@ -6988,11 +7045,11 @@ exit 1 expect(helperBlob).toBeTruthy(); for (const gate of [verificationGateSteps[1], repairVerificationGateStep]) { expect(gate).toContain( - `GATE_HELPERS_NOW="$(sha256sum ${helperBlob} | sha256sum`, + `GATE_HELPERS_NOW="$(timeout 30 sha256sum ${helperBlob} | sha256sum`, ); } expect( - workflow.match(/GATE_HELPERS_NOW="\$\(sha256sum /g) ?? [], + workflow.match(/GATE_HELPERS_NOW="\$\(timeout 30 sha256sum /g) ?? [], ).toHaveLength(2); // The digest must cover the three files INDIVIDUALLY: `cat a b c | // sha256sum` is invariant under file-boundary shifts, so a writer with @@ -7025,9 +7082,11 @@ exit 1 "VERDICT_INPUTS_SHA256: '${{ steps.prepare.outputs.verdict_inputs_sha256 }}'", ); expect(gate).toContain( - `VERDICT_INPUTS_NOW="$(sha256sum ${verdictInputsBlob} | sha256sum`, + `VERDICT_INPUTS_NOW="$(timeout 30 sha256sum ${verdictInputsBlob} | sha256sum`, ); - expect(gate.indexOf('VERDICT_INPUTS_NOW="$(sha256sum ')).toBeLessThan( + expect( + gate.indexOf('VERDICT_INPUTS_NOW="$(timeout 30 sha256sum '), + ).toBeLessThan( gate.indexOf('bash "${RUNNER_TEMP}/run-autofix-gate-container.sh"'), ); } @@ -7075,6 +7134,31 @@ exit 1 expect(stagedList.slice().sort()).toEqual( ['run-autofix-review-verification.sh', ...digestedHelpers].sort(), ); + // The gate step's digest checks cover the RUNNER_TEMP bytes at CHECK + // time, but the wrapper cp's the scripts into CBIN AFTERWARDS — a + // watcher overwriting a staged script in that seam would otherwise be + // copied in and execute AS the gate. The wrapper therefore re-digests + // the COPIES against the staging-time digests (which ride step env from + // GITHUB_OUTPUT, unreachable from a disk write) before docker run, and + // refuses on a mismatch or an empty digest (#9214 review). + expect(wrapper).toContain( + 'copy_digest() { timeout 10 sha256sum "${CBIN}/${1}" 2> /dev/null | cut -d\' \' -f1; }', + ); + expect(wrapper).toMatch( + /if \[\[ -z "\$\{VERIFY_RUNNER_SHA256:-\}" \]\] \|\|[\s\S]{0,160}?copy_digest run-autofix-review-verification\.sh[\s\S]{0,300}?exit 125/, + ); + expect(wrapper).toMatch( + /if \[\[ -z "\$\{GATE_CONTAINER_SHA256:-\}" \]\] \|\|[\s\S]{0,160}?copy_digest run-autofix-gate-container\.sh[\s\S]{0,300}?exit 125/, + ); + expect(wrapper).toContain( + 'HELPERS_COPY_NOW="$(timeout 30 sha256sum "${CBIN}/check-settings-schema.sh"', + ); + expect(wrapper).toMatch( + /if \[\[ -z "\$\{GATE_HELPERS_SHA256:-\}" \]\] \|\| \[\[ "\$\{HELPERS_COPY_NOW\}" != "\$\{GATE_HELPERS_SHA256\}" \]\]; then[\s\S]{0,240}?exit 125/, + ); + expect(wrapper.indexOf('copy_digest() {')).toBeLessThan( + wrapper.indexOf('docker run --rm'), + ); const passedEnv = [...dockerRun.matchAll(/--env ([A-Z_]+)=/g)].map( (m) => m[1], ); @@ -7082,6 +7166,7 @@ exit 1 'BRANCH', 'CI', 'FOOTPRINT_ENFORCE', + 'GATE_INPUTS', 'GATE_TMPDIR', 'GITHUB_OUTPUT', 'HOME', @@ -7103,14 +7188,16 @@ exit 1 expect(dockerRun).toContain('--env CI=true'); expect(dockerRun).not.toContain('CI_DEV_BOT_PAT'); expect(dockerRun).not.toContain('GITHUB_ENV'); - // Only three paths are mounted: the workspace, the round's workdir, and - // the container's own staging copy. The real RUNNER_TEMP (staged agent - // runner, the PAT steps' throwaway configs) is never mounted. + // Only four paths are mounted: the workspace, the round's workdir, the + // container's own staging copy, and the digest-verified inputs snapshot. + // The real RUNNER_TEMP (staged agent runner, the PAT steps' throwaway + // configs) is never mounted. const mounts = [...dockerRun.matchAll(/--volume "([^:]+):/g)].map( (m) => m[1], ); expect(mounts.sort()).toEqual([ '${CBIN}', + '${CINPUTS}', '${CRW}', '${GITHUB_WORKSPACE}', '${WORKDIR}', @@ -7121,6 +7208,8 @@ exit 1 // code would stop being the unforgeable half. Everything the gate // legitimately writes goes to the separate rw scratch mount. expect(dockerRun).toContain('--volume "${CBIN}:${CBIN}:ro"'); + expect(dockerRun).toContain('--volume "${CINPUTS}:${CINPUTS}:ro"'); + expect(dockerRun).toContain('--env GATE_INPUTS="${CINPUTS}"'); expect(dockerRun).toContain('--volume "${CRW}:${CRW}"'); // Mount TARGETS must equal their sources (a retargeting mutant would slip // past a sources-only comparison), and the workdir must be the workspace — @@ -7164,6 +7253,27 @@ exit 1 'if [[ "$(verdict_inputs_digest)" != "${INPUTS_BEFORE}" ]]', ), ).toBeLessThan(wrapper.indexOf('OUTCOME="$(verdict_value')); + // The host-authored bite inputs are re-verified against the + // prepare-time digest and snapshotted into the read-only mount BEFORE + // the run: a surviving host process truncating rc.json after the gate + // step's check would otherwise become BOTH the wrapper baseline and the + // gate's capture, flipping BITE_ENFORCE off so the bogus-fix round the + // bite check exists to reject exits 0 as `fixed`. The copies are + // re-digested after the cp, so a swap in the verify->copy seam is + // refused too (#9214 review). + expect(wrapper).toMatch( + /if \[\[ -z "\$\{VERDICT_INPUTS_SHA256:-\}" \]\] \|\| \[\[ "\$\{VERDICT_INPUTS_NOW\}" != "\$\{VERDICT_INPUTS_SHA256\}" \]\]; then[\s\S]{0,280}?exit 125/, + ); + expect(wrapper).toContain( + 'VERDICT_INPUTS_DETAIL="$(timeout 30 sha256sum "${WORKDIR}/rc.json"', + ); + expect(wrapper).toMatch( + /timeout 10 cp "\$\{WORKDIR\}\/\$\{_vi\}" "\$\{CINPUTS\}\/\$\{_vi\}"/, + ); + expect(wrapper).toContain('grep -F " ${WORKDIR}/${_vi}"'); + expect( + wrapper.indexOf('VERDICT_INPUTS_DETAIL="$(timeout 30 sha256sum'), + ).toBeLessThan(wrapper.indexOf('docker run --rm')); // The host-side HEAD pin: the container mounts the workspace rw at the // runner uid, so a detached in-container process can move the branch // ref to an attacker commit AND append verified_head= after the @@ -7232,27 +7342,35 @@ exit 1 ]); // The fingerprint only catches modifications that PERSIST until the // post-run compare: a truncate-during-run-then-restore flips the bite - // check's BITE_ENFORCE off mid-run (its -s guard reads the truncated - // file) while the restored bytes pass the compare — the bogus-fix round - // the bite check exists to reject sails to outcome=fixed. The gate - // therefore reads its bite inputs ONCE, before any branch code runs, - // and freezes the enforcement decision there: a check-then-use re-read - // after the build legs let a watcher truncate in the digest→read gap or - // substitute a FIFO that feeds the digest original bytes while `[[ -s ]]` - // sees st_size 0 — both probe-confirmed (#9214 review). The late site - // keeps only a type-safe tamper alarm. + // check's BITE_ENFORCE off mid-run while the restored bytes pass the + // compare — the bogus-fix round the bite check exists to reject sails + // to outcome=fixed. The gate therefore captures each input ONCE, + // bounded, before any branch code runs, and computes the digest AND + // both jq decisions from that single capture: the old shape digested + // the live files and let the jq decisions RE-READ them — check-then- + // use, so a watcher swapping rc.json in the digest→read gap steered + // BITE_ENFORCE (probe-confirmed), and a FIFO swapped into the + // stat→open window hung the read out to the step timeout. One capture + // closes both: the bytes cannot move between uses of them, and the + // timeout converts a racing swap into a crash (#9214 review). The + // container reads the wrapper's digest-verified snapshot mount + // (GATE_INPUTS); host runs read WORKDIR directly. + expect(reviewVerificationRunner).toContain( + 'GATE_INPUTS="${GATE_INPUTS:-${WORKDIR}}"', + ); expect(reviewVerificationRunner).toContain( - 'BITE_INPUTS_BEFORE="$(bite_input_digest)"', + 'BITE_INPUTS_BEFORE="$(bite_input_digest "${BITE_RC_CAPTURE}" "${BITE_RESOLVED_CAPTURE}" "${BITE_RV_CAPTURE}")', + ); + expect(reviewVerificationRunner).toMatch( + /bite_capture\(\) \{[\s\S]{0,420}?timeout 10 cat/, ); - // Type discipline BEFORE any hash or read of the inputs: a FIFO planted - // in the container-startup window must crash the gate loudly, never hang - // sha256sum's open() until the step timeout. + // Type discipline BEFORE any capture, and every read that feeds the + // decision — the captures, the -n guards and both jq computations — + // sits BEFORE the first build leg: the frozen decision is what makes a + // mid-run truncate or FIFO substitution inert. expect(reviewVerificationRunner).toMatch( - /for _bi in rc\.json resolved-comments\.txt rv\.json; do[\s\S]{0,420}?BITE_INPUTS_BEFORE="\$\(bite_input_digest\)"/, + /for _bi in rc\.json resolved-comments\.txt rv\.json; do[\s\S]{0,1800}?BITE_INPUTS_BEFORE="\$\(bite_input_digest "\$\{BITE_RC_CAPTURE\}"/, ); - // Every read that feeds the decision — the digest, the -s guards and - // both jq computations — sits BEFORE the first build leg: the frozen - // decision is what makes a mid-run truncate or FIFO substitution inert. expect( reviewVerificationRunner.indexOf("BITE_ENFORCE='false'"), ).toBeLessThan( @@ -7262,7 +7380,7 @@ exit 1 ); expect( reviewVerificationRunner.lastIndexOf( - '--slurpfile reviews "${WORKDIR}/rv.json"', + '--slurpfile reviews <(printf \'%s\\n\' "${BITE_RV_CAPTURE}")', ), ).toBeLessThan( reviewVerificationRunner.indexOf( @@ -7271,9 +7389,15 @@ exit 1 ); // The alarm survives at the bite section: refuse a moved (or swapped // non-regular) input loudly instead of letting a tampered round carry a - // verdict built on inputs of unknown integrity. + // verdict built on inputs of unknown integrity. Its re-capture is + // BOUNDED: a watcher swap landing in the stat→open window hits the + // timeout and becomes the same rejection instead of hanging the gate + // out to the step timeout (#9214 review). expect(reviewVerificationRunner).toMatch( - /if \[\[ "\$\(bite_input_digest\)" != "\$\{BITE_INPUTS_BEFORE\}" \]\]; then\n\s+reject_fix 'bite check inputs changed during the gate run'/, + /if ! \{ BITE_RC_NOW="\$\(bite_capture rc\.json "\$\{WORKDIR\}"\)" &&[\s\S]{0,400}?reject_fix 'bite check inputs changed during the gate run'/, + ); + expect(reviewVerificationRunner).toMatch( + /if \[\[ "\$\(bite_input_digest "\$\{BITE_RC_NOW\}" "\$\{BITE_RESOLVED_NOW\}" "\$\{BITE_RV_NOW\}"\)" != "\$\{BITE_INPUTS_BEFORE\}" \]\]; then\n\s+reject_fix 'bite check inputs changed during the gate run'/, ); expect( reviewVerificationRunner.indexOf( @@ -7322,25 +7446,26 @@ exit 1 expect(wrapper).toMatch( /if \[\[ "\$\{VERDICT_INODE_NOW\}" != "\$\{VERDICT_INODE\}" \]\]; then[\s\S]{0,300}?exit 125/, ); - // BOTH arms count outcome lines: the gate writes `outcome=` exactly - // once on every genuine path, so a watcher appending a bare `outcome=` - // after the gate's write would empty OUTCOME through last-wins and - // silently turn an EVALUATED rejection into a no-diagnostic crash-retry - // loop — and on the rc=0 arm an appended `outcome=noop` silently - // discards a verified fix as no-action. The exit-0 arm additionally + // The exit-0 arm counts outcome lines: the gate writes `outcome=` + // exactly once on every genuine path, so a watcher appending a bare + // `outcome=` after the gate's write would empty OUTCOME through + // last-wins and silently turn an EVALUATED rejection into a + // no-diagnostic crash-retry loop — and an appended `outcome=noop` + // silently discards a verified fix as no-action. The arm additionally // counts verified_head= (an appended line is the push-guard identity - // forgery) and bounds committed= (#9214 review). + // forgery) and bounds committed= (#9214 review). The rejection arms + // read no flags from the file at all — routing rides the exit code. expect( wrapper.match( - /OUTCOME_LINES="\$\(grep -c '\^outcome=' "\$\{VERDICT\}"/g, + /OUTCOME_LINES="\$\(timeout 10 grep -c '\^outcome=' "\$\{VERDICT\}"/g, ) ?? [], - ).toHaveLength(2); + ).toHaveLength(1); expect(wrapper).toMatch(/if \[\[ "\$\{OUTCOME_LINES:-0\}" -ne 1 \]\]/); expect(wrapper).toContain( - 'VERIFIED_HEAD_LINES="$(grep -c \'^verified_head=\' "${VERDICT}"', + 'VERIFIED_HEAD_LINES="$(timeout 10 grep -c \'^verified_head=\' "${VERDICT}"', ); expect(wrapper).toContain( - 'COMMITTED_LINES="$(grep -c \'^committed=\' "${VERDICT}"', + 'COMMITTED_LINES="$(timeout 10 grep -c \'^committed=\' "${VERDICT}"', ); expect(wrapper.indexOf('VERDICT_INODE_NOW="$(stat')).toBeGreaterThan( wrapper.indexOf('GATE_RC=$?'), @@ -7422,6 +7547,62 @@ exit 1 expect(wrapper).toContain('set -uo pipefail'); expect(wrapper).not.toMatch(/^set -e/m); expect(wrapper.trimEnd().endsWith('exit "${GATE_RC}"')).toBe(true); + // Imported shell functions are unloaded FIRST: bash re-imports + // BASH_FUNC_* carriers at every startup — the caller's strip does not + // cover the child — and an imported function shadows sha256sum/stat + // ahead of any PATH pin (#9214 review). + expect(wrapper).toContain('for _fn in $(compgen -A function); do'); + expect( + wrapper.indexOf('for _fn in $(compgen -A function); do'), + ).toBeLessThan(wrapper.indexOf('VERDICT="${WORKDIR}/gate-verdict"')); + // Every read of an attacker-writable path is BOUNDED: a watcher swap + // landing in the stat→open window must surface as a refusal (fingerprint + // NONREGULAR marker, verdict-grep empty → crash/refusal), never a hang + // until the step's 60-minute timeout (#9214 review). + expect(wrapper).toContain( + 'hash="$(timeout 10 sha256sum "${WORKDIR}/${f}" 2> /dev/null | cut -d\' \' -f1)" ||', + ); + expect(wrapper).toContain("hash='NONREGULAR:read-blocked'"); + expect(wrapper).toMatch(/verdict_value\(\) \{\n\s+timeout 10 grep -E/); + // The workspace git config is snapshotted pre-run and restored + // immediately after the container exits: the rw mount lets the + // container plant exec vectors ([diff] external=) for the + // host's post-run git steps, and 'Show run artifacts' runs `git diff` + // before the PAT steps' resanitize (#9214 review). + expect(wrapper).toContain( + 'GIT_CONFIG_SNAPSHOT="${CTEMP}/git-config.snapshot"', + ); + expect( + wrapper.indexOf('GIT_CONFIG_SNAPSHOT="${CTEMP}/git-config.snapshot"'), + ).toBeLessThan(wrapper.indexOf('docker run --rm')); + expect(wrapper).toContain( + 'timeout 10 cp "${GIT_CONFIG_SNAPSHOT}" "${GITHUB_WORKSPACE}/.git/config"', + ); + expect( + wrapper.indexOf('timeout 10 cp "${GIT_CONFIG_SNAPSHOT}"'), + ).toBeGreaterThan(wrapper.indexOf('GATE_RC=$?')); + expect( + wrapper.indexOf('timeout 10 cp "${GIT_CONFIG_SNAPSHOT}"'), + ).toBeLessThan(wrapper.indexOf('OUTCOME="$(verdict_value')); + // Rejection routing rides the gate's exit code end-to-end: reject_fix + // exits 10/11/12 by class and fail_handoff the terminal 12; the + // wrapper's case arms translate exactly those codes, and the crash + // paths keep exit 1 (#9214 review). + expect(reviewVerificationRunner).toMatch( + /if \[\[ "\$\{preexisting\}" == 'true' \]\]; then\n\s+exit 11\n\s+fi\n\s+if \[\[ "\$\{retryable\}" == 'true' \]\]; then\n\s+exit 10\n\s+fi\n\s+exit 12\n\}/, + ); + expect(reviewVerificationRunner).toMatch( + /retryable=false" >> "\$\{GITHUB_OUTPUT\}"[\s\S]{0,340}?exit 12\n\}/, + ); + expect(wrapper).toContain('10 | 11 | 12)'); + expect(wrapper).toMatch( + /\[\[ "\$\{GATE_RC\}" == '10' \]\] && echo "retryable=true"/, + ); + expect(wrapper).toMatch( + /\[\[ "\$\{GATE_RC\}" == '11' \]\] && echo "preexisting=true"/, + ); + expect(wrapper).not.toContain('RETRYABLE="$(verdict_value retryable)"'); + expect(wrapper).not.toContain('PREEXISTING="$(verdict_value preexisting)"'); const dir = mkdtempSync(join(tmpdir(), 'autofix-gate-verdict-')); const runTranslate = (rc, verdictLines) => { const verdict = join(dir, 'verdict'); @@ -7447,28 +7628,73 @@ exit 1 expect( runTranslate(0, ['committed=true', 'outcome=fixed', 'verified_head=abc']), ).toBe('committed=true\noutcome=fixed\nverified_head=abc'); - // A real rejection (the gate wrote failed, then exited 1) carries through - // with its routing flags, which only pick repair vs handoff, never a - // push. reject_fix writes BOTH flags on every rejection, so the genuine - // shape carries the explicit baseline. The gate writes `outcome=` exactly - // once on every genuine path — a two-outcome fixture corresponds to no - // real gate shape and is refused by the count check (cases below). - expect( - runTranslate(1, [ + // Rejection routing rides the EXIT CODE — the unforgeable half. The + // gate exits 10 (retryable), 11 (pre-existing handoff), 12 (terminal); + // the file's routing flags are human-facing detail the translation + // never reads, so an IN-PLACE rewrite of the mounted verdict file + // (open(O_WRONLY|O_TRUNC) preserves the inode and the type pinned + // above, and a well-formed rewrite satisfies any line count) can no + // longer flip a deliberately non-retryable rejection into a PAT-backed + // repair round or misclassify a fixable one as pre-existing (#9214). + expect( + runTranslate(10, [ 'outcome=failed', 'preexisting=false', 'retryable=true', ]), ).toBe('outcome=failed\nretryable=true'); - // A FORGED pass cannot survive exit 1 — `fixed` is accepted only on exit - // 0 — and with no genuine `failed` in the file the outcome stays UNSET so - // the round retries rather than being handed off as evaluated. - expect(runTranslate(1, ['outcome=fixed'])).toBe(''); - // The gate's deliberate exit-1-WITHOUT-verdict crash paths (the baseline - // A/B and bite tree-restore failures) must keep reaching the gate-crashed - // retry: synthesizing `failed` there would advance the watermark and hand - // the item off for good. + expect( + runTranslate(11, [ + 'outcome=failed', + 'preexisting=true', + 'retryable=false', + ]), + ).toBe('outcome=failed\npreexisting=true'); + expect( + runTranslate(12, [ + 'outcome=failed', + 'preexisting=false', + 'retryable=false', + ]), + ).toBe('outcome=failed'); + // The rewrite-immunity proof: the same exit codes with FORGED file + // content — flipped flags, extra keys — forward exactly the same + // routing. The file has no authority on the rejection arms. + expect(runTranslate(12, ['outcome=failed', 'retryable=true'])).toBe( + 'outcome=failed', + ); + expect( + runTranslate(10, [ + 'outcome=failed', + 'preexisting=true', + 'retryable=false', + ]), + ).toBe('outcome=failed\nretryable=true'); + expect( + runTranslate(11, [ + 'outcome=failed', + 'preexisting=false', + 'retryable=true', + ]), + ).toBe('outcome=failed\npreexisting=true'); + // committed= is a ref-only fact the gate records before any check runs; + // it is forwarded on rejection paths too so the handoff wording stays + // right (a forged value only changes report wording, never a push). + expect(runTranslate(12, ['committed=true', 'outcome=failed'])).toBe( + 'committed=true\noutcome=failed', + ); + // A FORGED pass cannot survive a rejection exit: `fixed` is accepted + // only on exit 0, and the rejection arms read no outcome from the file. + expect(runTranslate(10, ['outcome=fixed'])).toBe( + 'outcome=failed\nretryable=true', + ); + // The gate's verdict-less crash paths (the baseline-A/B and bite + // tree-restore failures) keep exit 1 — now distinct from the + // 10/11/12 rejection classes — and translate to no outcome at all: an + // EVALUATED rejection would advance the watermark and hand the item off + // for good, while the empty outcome takes the gate-crashed retry. expect(runTranslate(1, [])).toBe(''); + expect(runTranslate(1, ['outcome=failed', 'retryable=true'])).toBe(''); // A legitimate no-op round carries through as well. The gate's noop // exit writes verified_head too, so the count check rides along. expect(runTranslate(0, ['verified_head=abc', 'outcome=noop'])).toBe( @@ -7495,101 +7721,29 @@ exit 1 'verified_head=attacker', ]), ).toBe(''); - // preexisting routes to the base-update handoff (never a push) and rides - // along with a genuine failure. - expect( - runTranslate(1, [ - 'outcome=failed', - 'preexisting=true', - 'retryable=false', - ]), - ).toBe('outcome=failed\npreexisting=true'); - // committed= is a ref-only fact the gate records before any check runs; it - // is forwarded on non-zero paths too so the handoff wording stays right. - expect( - runTranslate(1, [ - 'committed=true', - 'outcome=failed', - 'preexisting=false', - 'retryable=false', - ]), - ).toBe('committed=true\noutcome=failed'); // Exit 0 with no verdict is a crash, not a silent success: outcome stays // unset so 'Finalize verification' retries on the next scan. expect(runTranslate(0, [])).toBe(''); - // A docker/infra failure (125) leaves outcome unset too. + // A docker/infra failure (125) or a killed container (137) leaves + // outcome unset too — even against a planted verdict file. expect(runTranslate(125, ['outcome=fixed'])).toBe(''); - // The `^` anchor in verdict_value's grep is load-bearing and every case - // above is blind to it (they all feed exact key names): without it, - // `tail -n 1` picks up an attacker-appended `xoutcome=fixed`, OUTCOME - // reads `fixed` at exit 1 and a genuine evaluated rejection is rerouted - // into the crash-retry loop. - expect( - runTranslate(1, [ - 'outcome=failed', - 'preexisting=false', - 'retryable=false', - 'xoutcome=fixed', - ]), - ).toBe('outcome=failed'); - // reject_fix writes BOTH routing flags on every deterministic rejection - // (mutually exclusive at the source), so any other shape — a flag line - // missing, repeated, or both true — is proof of an append the gate never - // made. The translation refuses the verdict as tampered — nothing - // forwarded, crash-retry path — instead of guessing which flag is - // genuine: a lone forged `retryable=true` used to flip a deliberately - // non-retryable rejection (the bite check wrote neither flag) into a - // PAT-backed repair round, and a planted - // `preexisting=true` overriding a genuine `retryable=true` would skip - // the repair the round is entitled to and permanently misclassify a - // fixable rejection as a terminal pre-existing failure. - expect( - runTranslate(1, ['retryable=true', 'outcome=failed', 'preexisting=true']), - ).toBe(''); - // The bite check's non-retryable rejection carries the false baseline, - // and a forged `retryable=true` append repeats the key. - expect( - runTranslate(1, [ - 'outcome=failed', - 'preexisting=false', - 'retryable=false', - 'retryable=true', - ]), - ).toBe(''); - // The pre-baseline flag-less shape is indistinguishable from a forgery - // and is refused the same way. - expect(runTranslate(1, ['outcome=failed', 'retryable=true'])).toBe(''); - // The evaluated-handoff shape the gate's four non-reject_fix failure - // exits write (fail_handoff: failure.md aborts, agent produced - // nothing): only outcome=failed crosses the wall — the watermark - // advances and the item is handed off, exactly as pre-container; a - // flag-less `outcome=failed` never reaches the wall anymore. - expect( - runTranslate(1, [ - 'outcome=failed', - 'preexisting=false', - 'retryable=false', - ]), - ).toBe('outcome=failed'); - // An in-container appender cannot silence a genuine rejection: a bare - // or duplicated `outcome=` line appended after the gate's write trips - // the count check and is refused as tampered (explicit ::error:: and + expect(runTranslate(137, ['outcome=failed', 'retryable=true'])).toBe(''); + // The `^` anchor in verdict_value's grep is load-bearing on the pass + // arm — the only arm still reading outcome= from the file: without it, + // `tail -n 1` picks up an attacker-appended `xoutcome=noop` and + // silently discards a verified fix as no-action. + expect( + runTranslate(0, ['outcome=fixed', 'verified_head=abc', 'xoutcome=noop']), + ).toBe('outcome=fixed\nverified_head=abc'); + // An in-container appender cannot silence a genuine PASS: a bare or + // duplicated `outcome=` line appended after the gate's write trips the + // count check and is refused as tampered (explicit ::error:: and // crash-retry) instead of emptying OUTCOME through last-wins. expect( - runTranslate(1, [ - 'outcome=failed', - 'preexisting=false', - 'retryable=false', - 'outcome=', - ]), + runTranslate(0, ['outcome=fixed', 'verified_head=abc', 'outcome=']), ).toBe(''); expect( - runTranslate(1, [ - 'outcome=failed', - 'preexisting=false', - 'retryable=false', - 'outcome=failed', - ]), + runTranslate(0, ['outcome=fixed', 'verified_head=abc', 'outcome=fixed']), ).toBe(''); rmSync(dir, { recursive: true, force: true }); }); @@ -9984,7 +10138,7 @@ exit 1 // 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 -$/gm, + /echo "\$\{VERIFY_RUNNER_SHA256\} {2}\$\{RUNNER_TEMP\}\/run-autofix-review-verification\.sh" \| timeout 30 sha256sum -c -$/gm, ) ?? [], ).toHaveLength(2); expect( @@ -10122,6 +10276,60 @@ exit 1 rmSync(dir, { recursive: true, force: true }); }); + it('pins every post-agent consumer at startup and resanitizes before the first host git', () => { + // After an agent step ran branch code on this host, every consumer + // pins the startup-time injection channels at env: level — applied + // before the step's bash starts, and outranking anything appended to + // $GITHUB_ENV; an in-script unset only strips a channel from children + // AFTER the step's own bash was already seeded/preloaded (#9214 + // review). The gate steps pin DOCKER_HOST/DOCKER_CONTEXT instead (no + // node runs there), pinned where the docker client reads them. + const postAgentSteps = [ + triageAndAddressStep, + repairDeterministicRejectionStep, + finalizeVerificationStep, + pushAndReportStep, + ]; + for (const step of postAgentSteps) { + expect(step).toContain("BASH_ENV: ''"); + expect(step).toContain("LD_PRELOAD: ''"); + expect(step).toContain("LD_AUDIT: ''"); + expect(step).toContain("LD_LIBRARY_PATH: ''"); + expect(step).toContain("NODE_OPTIONS: ''"); + } + // 'Show run artifacts' runs `git diff` on the host BEFORE any PAT + // step's resanitize, and the gate container mounts the workspace rw + // at the runner uid — it can plant [diff] external= in the + // repo's .git/config for that diff to fire. The step therefore + // resanitizes from the trusted staged copy first (digest-verified, + // PATH-pinned, git env stripped), with the wrapper's pre-run + // .git/config restore covering the container side (#9214 review). + const showRunArtifactsStep = + reviewAddressJob.match( + /- name: 'Show run artifacts'[\s\S]*?(?=\n[ ]{6}- name: ')/, + )?.[0] ?? ''; + expect(showRunArtifactsStep.length).toBeGreaterThan(200); + expect(showRunArtifactsStep).toContain( + "TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}'", + ); + expect(showRunArtifactsStep).toContain( + "RESANITIZE_SHA256: '${{ steps.stage.outputs.resanitize_sha256 }}'", + ); + expect(showRunArtifactsStep).toContain("BASH_ENV: ''"); + const resanitizeCall = 'bash "${RUNNER_TEMP}/resanitize-git-config.sh"'; + expect(showRunArtifactsStep).toContain(resanitizeCall); + expect(showRunArtifactsStep).toContain( + 'echo "${RESANITIZE_SHA256} ${RUNNER_TEMP}/resanitize-git-config.sh" | sha256sum -c - > /dev/null', + ); + expect(showRunArtifactsStep).toContain('export GIT_CONFIG_COUNT=0'); + expect(showRunArtifactsStep.indexOf(resanitizeCall)).toBeLessThan( + showRunArtifactsStep.indexOf('git diff "origin/main...${BRANCH}"'), + ); + expect( + showRunArtifactsStep.indexOf('export PATH="${TRUSTED_PATH}"'), + ).toBeLessThan(showRunArtifactsStep.indexOf(resanitizeCall)); + }); + 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 @@ -17582,7 +17790,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () it('charges a failure to the round when the baseline is green', () => { const r = runGate({ failAt: ['feature'] }); - expect(r.status).toBe(1); + expect(r.status).toBe(10); expect(r.outputs).toContain('outcome=failed'); expect(r.outputs).toContain('retryable=true'); // The repair agent's only warning that dist/ now holds baseline-built @@ -17599,7 +17807,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () it('reports pre-existing only on a matching failure signature, with the baseline transcript as evidence', () => { const r = runGate({ failAt: ['feature', 'origin/feature'] }); - expect(r.status).toBe(1); + expect(r.status).toBe(11); expect(r.outputs).toContain('outcome=failed'); expect(r.outputs).toContain('preexisting=true'); // The explicit false baseline rides every rejection — the wrapper @@ -17628,7 +17836,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () failAt: ['feature', 'origin/feature'], baselineMsg: 'an entirely different defect', }); - expect(r.status).toBe(1); + expect(r.status).toBe(10); expect(r.outputs).toContain('retryable=true'); expect(r.outputs).not.toContain('preexisting=true'); expect(r.stdout).toContain('DIFFERENT reason'); @@ -17661,7 +17869,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () // signature fails closed REGARDLESS of the baseline, so the gate must // decide before paying the detach + full baseline re-run + restore. const r = runGate({ failAt: ['feature'], noIdentity: true }); - expect(r.status).toBe(1); + expect(r.status).toBe(10); expect(r.outputs).toContain('retryable=true'); expect(r.outputs).not.toContain('preexisting=true'); expect(r.stdout).toContain('no failure identity in the head transcript'); @@ -17677,7 +17885,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () failAt: ['feature', 'origin/feature'], trackedDirt: true, }); - expect(r.status).toBe(1); + expect(r.status).toBe(11); expect(r.outputs).toContain('preexisting=true'); expect(r.stdout).not.toContain('could not restore the verification tree'); }); @@ -17691,7 +17899,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () failAt: ['feature', 'origin/feature'], hugeFail: true, }); - expect(r.status).toBe(1); + expect(r.status).toBe(11); expect(r.outputs).toContain('preexisting=true'); expect(r.rejection.length).toBeLessThanOrEqual(3900); expect(r.rejection.endsWith('````\n')).toBe(true); @@ -17708,7 +17916,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () headMsg: "Cannot find module './foo'", baselineMsg: "Cannot find module './bar'", }); - expect(r.status).toBe(1); + expect(r.status).toBe(10); expect(r.outputs).toContain('retryable=true'); expect(r.outputs).not.toContain('preexisting=true'); }); @@ -17722,7 +17930,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () failAt: ['feature', 'origin/feature'], extraRoundDiag: true, }); - expect(r.status).toBe(1); + expect(r.status).toBe(10); expect(r.outputs).toContain('retryable=true'); expect(r.outputs).not.toContain('preexisting=true'); }); @@ -17738,7 +17946,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () failAt: ['feature', 'origin/feature'], extraBaselineDiag: true, }); - expect(r.status).toBe(1); + expect(r.status).toBe(11); expect(r.outputs).toContain('preexisting=true'); expect(r.outputs).not.toContain('retryable=true'); expect(r.rejection).toContain('pre-existing'); @@ -17752,7 +17960,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () failAt: ['feature', 'origin/feature'], baselineCode: '8888', }); - expect(r.status).toBe(1); + expect(r.status).toBe(10); expect(r.outputs).toContain('retryable=true'); expect(r.outputs).not.toContain('preexisting=true'); expect(r.stdout).toContain('DIFFERENT reason'); @@ -17771,7 +17979,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () failAt: ['feature', 'origin/feature'], baselineNoIdentity: true, }); - expect(r.status).toBe(1); + expect(r.status).toBe(10); expect(r.outputs).toContain('retryable=true'); expect(r.outputs).not.toContain('preexisting=true'); expect(r.stdout).toContain('DIFFERENT reason'); @@ -17787,7 +17995,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () failAt: ['feature', 'origin/feature'], commFail: true, }); - expect(r.status).toBe(1); + expect(r.status).toBe(10); expect(r.outputs).toContain('retryable=true'); expect(r.outputs).not.toContain('preexisting=true'); expect(r.rejection).toContain('run npm run build before typecheck/tests'); @@ -17807,7 +18015,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () it('keeps the failure text in the evidence window past a chatty green baseline', () => { const r = runGate({ failAt: ['feature'], noisySuccess: true }); - expect(r.status).toBe(1); + expect(r.status).toBe(10); expect(r.outputs).toContain('retryable=true'); expect(r.rejection).toContain('stub build FAILED'); }); @@ -17819,7 +18027,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () // or constant change that breaks the invariant fails here, not in a // posted comment. const r = runGate({ failAt: ['feature'], hugeFail: true }); - expect(r.status).toBe(1); + expect(r.status).toBe(10); expect(r.rejection.length).toBeLessThanOrEqual(3900); expect(r.rejection.endsWith('````\n')).toBe(true); expect(r.rejection).toContain('root cause marker line'); @@ -17831,7 +18039,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () touchCore: true, failAt: ['feature'], }); - expect(r.status).toBe(1); + expect(r.status).toBe(10); expect(r.outputs).toContain('retryable=true'); expect(r.outputs).not.toContain('preexisting=true'); expect(r.stdout).not.toContain('Baseline A/B'); @@ -17842,7 +18050,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () // typecheck (sdk-typescript resolves core d.ts from dist) are exempt. for (const opts of [{ schemaFail: true }, { typecheckFail: true }]) { const r = runGate(opts); - expect(r.status).toBe(1); + expect(r.status).toBe(10); expect(r.outputs).toContain('retryable=true'); expect(r.outputs).not.toContain('preexisting=true'); expect(r.stdout).not.toContain('Baseline A/B'); @@ -17857,7 +18065,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () it('never A/Bs package tests (dist-resolving dependencies)', () => { const r = runGate({ addWorkspace: true }); - expect(r.status).toBe(1); + expect(r.status).toBe(10); expect(r.rejection).toContain('tests failed in packages/newpkg'); expect(r.outputs).toContain('retryable=true'); expect(r.outputs).not.toContain('preexisting=true'); @@ -17873,7 +18081,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () // agentCommit: false leaves the branch == origin/branch with no // no-action.md: the 'agent produced nothing' path. const r = runGate({ agentCommit: false }); - expect(r.status).toBe(1); + expect(r.status).toBe(12); expect(r.outputs).toContain('outcome=failed'); expect(r.outputs).toContain('preexisting=false'); expect(r.outputs).toContain('retryable=false'); @@ -17885,7 +18093,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () it('writes the canonical routing flags on the failure.md abort', () => { const r = runGate({ failureMd: true }); - expect(r.status).toBe(1); + expect(r.status).toBe(12); expect(r.outputs).toContain('outcome=failed'); expect(r.outputs).toContain('preexisting=false'); expect(r.outputs).toContain('retryable=false'); @@ -17898,7 +18106,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () // BITE_ENFORCE off for the mid-run read, and the restored bytes pass // the compare. The gate-internal digest pin is what catches it (#9214). const r = runGate({ biteTamper: true }); - expect(r.status).toBe(1); + expect(r.status).toBe(10); expect(r.outputs).toContain('outcome=failed'); expect(r.outputs).toContain('retryable=true'); expect(r.rejection).toContain(