From 8db672e80c78082e9d387743ad1f174fb53abcc8 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 21 Aug 2026 14:40:00 +0800 Subject: [PATCH 1/7] fix(autofix): pass CI=true through the gate's env -i launches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verification gate launches (first pass + repair pass) run the branch's build/typecheck/lint/test through an env -i clean child that allowlisted only 8 variables and dropped the runner-provided CI=true. Without it the gate's checks run with inverted CI semantics relative to the repo's regular CI: packages/cli/src/ui/auth/AuthDialog.test.tsx skips 18 TUI-input tests on CI as unreliable, and without CI=true they un-skip inside the gate and one flakes (~5s vi.waitFor) — reject_fix fires retryable on a fix the PR's own CI passes green, burning the repair pass and mislabeling the round's A/B baseline. Add CI="${CI:-true}" to both env -i allowlists (probe: CI=true → file green; env -u CI → the TUI test fails 1/25), and pin the full allowlist contents in the contract tests — the old pin counted env -i occurrences only, so a missing variable shipped green. Follow-up from PR #9262 (R5-1); issue #9648. --- .github/workflows/qwen-autofix.yml | 2 ++ scripts/tests/qwen-autofix-workflow.test.js | 35 +++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index bde6c5d4c54..66479c746e2 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -5187,6 +5187,7 @@ jobs: WORKDIR="${WORKDIR}" \ BRANCH="${BRANCH}" \ GITHUB_OUTPUT="${GITHUB_OUTPUT}" \ + CI="${CI:-true}" \ KISS_AUDIT="${KISS_AUDIT:-false}" \ FOOTPRINT_ENFORCE="${FOOTPRINT_ENFORCE:-advisory}" \ bash --norc "${RUNNER_TEMP}/run-autofix-review-verification.sh" @@ -5390,6 +5391,7 @@ jobs: WORKDIR="${WORKDIR}" \ BRANCH="${BRANCH}" \ GITHUB_OUTPUT="${GITHUB_OUTPUT}" \ + CI="${CI:-true}" \ KISS_AUDIT="${KISS_AUDIT:-false}" \ FOOTPRINT_ENFORCE="${FOOTPRINT_ENFORCE:-advisory}" \ bash --norc "${RUNNER_TEMP}/run-autofix-review-verification.sh" diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 87645fa5e5b..721b98596c9 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -11183,6 +11183,41 @@ exit 1 expect(reviewVerificationGateStep).not.toContain( 'bash .github/scripts/run-autofix-review-verification.sh', ); + // The gate launches through an env -i clean child with a SANCTIONED + // allowlist (R5-1): every variable the gate's own build/test checks need + // must be passed, and the runner-provided CI=true is one of them — without + // it the gate's checks run with inverted CI semantics and the 18 + // deliberately-skipped TUI-input tests un-skip inside the gate (one flakes + // ~5s, reject_fix fires retryable on a fix the PR's own CI passes green). + // A contains-only pin accepts a symmetric DROP, so enumerate the full + // allowlist of BOTH gate launches (first pass + repair pass) as a sorted + // multiset — a symmetric duplicate or a dropped entry both fail here. + const gateAllowlist = (step) => { + const argStart = step.indexOf('/usr/bin/env -i \\'); + expect(argStart, 'gate step lacks the env -i launch').toBeGreaterThan(-1); + const argList = step.slice(argStart, step.indexOf('bash --norc')); + const passed = ( + argList.match(/[A-Z_][A-Z0-9_]*=(?:"[^"]*"|[^\s\\]*)/g) ?? [] + ) + .map((m) => m.split('=')[0]) + .sort(); + expect(passed).toEqual( + [ + 'PATH', + 'HOME', + 'RUNNER_TEMP', + 'WORKDIR', + 'BRANCH', + 'GITHUB_OUTPUT', + 'CI', + 'KISS_AUDIT', + 'FOOTPRINT_ENFORCE', + ].sort(), + ); + expect(argList).toContain('CI="${CI:-true}"'); + }; + gateAllowlist(reviewVerificationGateStep); + gateAllowlist(repairVerificationGateStep); expect( reviewVerifyGate.indexOf( 'bash "${RUNNER_TEMP}/check-autofix-contracts.sh"', From 0fcdd5afbe20ec3d72eb4b744e2d314b771bcff2 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 21 Aug 2026 10:46:54 +0000 Subject: [PATCH 2/7] fix(autofix): widen gate allowlist pins to lowercase env names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The allowlist pins extract passed variables with [A-Z_][A-Z0-9_]*, so a lowercase or mixed-case entry — e.g. npm's own npm_config_* convention — is invisible to the sorted-multiset check: adding one to a single launch ships green, and only a later asymmetric drop then fails, producing exactly the divergent-environment regression the pins exist to catch while CI stayed green the whole way. Widen the name class to [A-Za-z_][A-Za-z0-9_]* in both pins — the gate launches pin added in 8db672e80c and the sibling run_deferred_upsert pin that shares the identical regex and blind spot (probe: inject npm_config_registry="..." into one launch → old regex 215/215 green, widened regex fails with + "npm_config_registry" at each pin; pristine workflow stays green). Review round 1 finding R1-1. --- scripts/tests/qwen-autofix-workflow.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 721b98596c9..de3c30577d5 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -11197,7 +11197,7 @@ exit 1 expect(argStart, 'gate step lacks the env -i launch').toBeGreaterThan(-1); const argList = step.slice(argStart, step.indexOf('bash --norc')); const passed = ( - argList.match(/[A-Z_][A-Z0-9_]*=(?:"[^"]*"|[^\s\\]*)/g) ?? [] + argList.match(/[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|[^\s\\]*)/g) ?? [] ) .map((m) => m.split('=')[0]) .sort(); @@ -12806,7 +12806,7 @@ exit 1 // Delimited tokens, not substrings: match `NAME=value` up to the line // continuation, so a value swap or an extra entry is visible. const assignments = ( - argList.match(/[A-Z_][A-Z0-9_]*=(?:"[^"]*"|[^\s\\]*)/g) ?? [] + argList.match(/[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|[^\s\\]*)/g) ?? [] ).map((m) => m.trim()); const passed = assignments.map((m) => m.split('=')[0]); // Sorted multiset, not a Set: a symmetric duplicate entry is exactly From 0fbc3795119488b7a65820bbe0b6c59ee5375f54 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 21 Aug 2026 16:05:30 +0000 Subject: [PATCH 3/7] fix(autofix): pin the gate clean-child launches structurally (#9649) --- scripts/tests/qwen-autofix-workflow.test.js | 68 ++++++++++++--------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index ac600abfd38..dcb4e4de2b3 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -11189,35 +11189,45 @@ exit 1 // it the gate's checks run with inverted CI semantics and the 18 // deliberately-skipped TUI-input tests un-skip inside the gate (one flakes // ~5s, reject_fix fires retryable on a fix the PR's own CI passes green). - // A contains-only pin accepts a symmetric DROP, so enumerate the full - // allowlist of BOTH gate launches (first pass + repair pass) as a sorted - // multiset — a symmetric duplicate or a dropped entry both fail here. - const gateAllowlist = (step) => { - const argStart = step.indexOf('/usr/bin/env -i \\'); - expect(argStart, 'gate step lacks the env -i launch').toBeGreaterThan(-1); - const argList = step.slice(argStart, step.indexOf('bash --norc')); - const passed = ( - argList.match(/[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|[^\s\\]*)/g) ?? [] - ) - .map((m) => m.split('=')[0]) - .sort(); - expect(passed).toEqual( - [ - 'PATH', - 'HOME', - 'RUNNER_TEMP', - 'WORKDIR', - 'BRANCH', - 'GITHUB_OUTPUT', - 'CI', - 'KISS_AUDIT', - 'FOOTPRINT_ENFORCE', - ].sort(), - ); - expect(argList).toContain('CI="${CI:-true}"'); - }; - gateAllowlist(reviewVerificationGateStep); - gateAllowlist(repairVerificationGateStep); + // Pin the launch STRUCTURALLY — one verbatim adjacency chain from the + // LD_* prefix through the digest-verified script, every entry in order + // with its exact value — not as text tokens: shell edits that preserve + // token text (a commented-out entry, a dropped `\`, an =-less operand, a + // quote suffix, an entry smuggled behind a `bash --norc` value, the + // launch head moved into a comment) each broke the child's isolation + // while every token-level pin stayed green (R2-1). Anchoring the chain + // on the LD_* prefix pins the one channel env -i cannot block; the + // body-side unset and PATH export that protect the pre-launch digest + // check are pinned with it (R3-1, R2-2). + const gateLaunchPin = new RegExp( + [ + 'LD_PRELOAD= LD_AUDIT= LD_LIBRARY_PATH=', + '/usr/bin/env -i', + 'PATH="${TRUSTED_PATH}"', + 'HOME="${HOME}"', + 'RUNNER_TEMP="${RUNNER_TEMP}"', + 'WORKDIR="${WORKDIR}"', + 'BRANCH="${BRANCH}"', + 'GITHUB_OUTPUT="${GITHUB_OUTPUT}"', + 'CI="${CI:-true}"', + 'KISS_AUDIT="${KISS_AUDIT:-false}"', + 'FOOTPRINT_ENFORCE="${FOOTPRINT_ENFORCE:-advisory}"', + 'bash --norc "${RUNNER_TEMP}/run-autofix-review-verification.sh"', + ] + .map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join(' \\\\\n\\s*'), + ); + for (const step of [ + reviewVerificationGateStep, + repairVerificationGateStep, + ]) { + expect(step).toMatch(gateLaunchPin); + // Exactly one launch per step: a second, unpinned `bash --norc` (the + // pinned block demoted into a never-run arm) must fail here (R2-1). + expect((step.match(/bash --norc/g) ?? []).length).toBe(1); + expect(step).toContain('unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH'); + expect(step).toContain('export PATH="${TRUSTED_PATH}"'); + } expect( reviewVerifyGate.indexOf( 'bash "${RUNNER_TEMP}/check-autofix-contracts.sh"', From cde480e8e322622c3e0abc4c3118d2bd7ae8770a Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 21 Aug 2026 19:36:20 +0000 Subject: [PATCH 4/7] fix(autofix): pin the gate run-body statement list around the launch (#9649) --- scripts/tests/qwen-autofix-workflow.test.js | 76 +++++++++++++++------ 1 file changed, 57 insertions(+), 19 deletions(-) diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index dcb4e4de2b3..cfe91e144a9 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -11198,35 +11198,73 @@ exit 1 // while every token-level pin stayed green (R2-1). Anchoring the chain // on the LD_* prefix pins the one channel env -i cannot block; the // body-side unset and PATH export that protect the pre-launch digest - // check are pinned with it (R3-1, R2-2). + // check are pinned with it (R3-1, R2-2). The shapes AROUND the chain + // are closed by pinning the run body's whole statement list with + // comments stripped: a prefix command word that demotes the whole chain + // to one command's argv (the gate never executes and a forged outcome + // survives), a command appended or inserted around the launch, a + // demotion of the pinned block into a never-run arm, a commented-out or + // relocated statement — each adds, drops, reorders, or renames a + // statement here (R4-2, R4-3, R4-4). Within the chain, separators allow + // only bash whitespace — space/tab after the continuation newline: JS + // `\s` also matches a blank line, which splits the chain into two + // commands (the orphaned env -i prints and exits 0 while the rest runs + // with the FULL step environment), and NBSP/U+2028, which glue into the + // next operand and rename it; the statement list sees neither shape + // (blank lines filter out, trim strips a leading NBSP), so only this + // pin closes them (R4-1). + const gateLaunchTokens = [ + 'LD_PRELOAD= LD_AUDIT= LD_LIBRARY_PATH=', + '/usr/bin/env -i', + 'PATH="${TRUSTED_PATH}"', + 'HOME="${HOME}"', + 'RUNNER_TEMP="${RUNNER_TEMP}"', + 'WORKDIR="${WORKDIR}"', + 'BRANCH="${BRANCH}"', + 'GITHUB_OUTPUT="${GITHUB_OUTPUT}"', + 'CI="${CI:-true}"', + 'KISS_AUDIT="${KISS_AUDIT:-false}"', + 'FOOTPRINT_ENFORCE="${FOOTPRINT_ENFORCE:-advisory}"', + 'bash --norc "${RUNNER_TEMP}/run-autofix-review-verification.sh"', + ]; const gateLaunchPin = new RegExp( - [ - 'LD_PRELOAD= LD_AUDIT= LD_LIBRARY_PATH=', - '/usr/bin/env -i', - 'PATH="${TRUSTED_PATH}"', - 'HOME="${HOME}"', - 'RUNNER_TEMP="${RUNNER_TEMP}"', - 'WORKDIR="${WORKDIR}"', - 'BRANCH="${BRANCH}"', - 'GITHUB_OUTPUT="${GITHUB_OUTPUT}"', - 'CI="${CI:-true}"', - 'KISS_AUDIT="${KISS_AUDIT:-false}"', - 'FOOTPRINT_ENFORCE="${FOOTPRINT_ENFORCE:-advisory}"', - 'bash --norc "${RUNNER_TEMP}/run-autofix-review-verification.sh"', - ] + gateLaunchTokens .map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - .join(' \\\\\n\\s*'), - ); + .join(' \\\\\n[ \\t]*'), + ); + // The digest check executes in the PARENT shell before the clean child + // exists, so its own defenses — the TRUSTED_PATH export and the LD_* + // unset — and their order live in the pinned statement list: a + // commented copy matched a bare toContain, and a planted LD_PRELOAD or + // PATH reached the sha256sum exec (R4-4). The digest line is pinned + // whole and per step — a workflow-wide count accepts relocation out of + // the gates, and `|| true` accepts a digest mismatch under bash -e + // (R4-3, the resanitize sibling's doctrine). + const gateDigestCheck = + 'echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - > /dev/null'; + const gateBodyStatements = [ + 'export PATH="${TRUSTED_PATH}"', + 'unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH', + gateDigestCheck, + ...gateLaunchTokens.map((token, index) => + index < gateLaunchTokens.length - 1 ? `${token} \\` : token, + ), + ]; for (const step of [ reviewVerificationGateStep, repairVerificationGateStep, ]) { expect(step).toMatch(gateLaunchPin); + expect( + step + .slice(step.indexOf('run: |-') + 'run: |-'.length) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line !== '' && !line.startsWith('#')), + ).toEqual(gateBodyStatements); // Exactly one launch per step: a second, unpinned `bash --norc` (the // pinned block demoted into a never-run arm) must fail here (R2-1). expect((step.match(/bash --norc/g) ?? []).length).toBe(1); - expect(step).toContain('unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH'); - expect(step).toContain('export PATH="${TRUSTED_PATH}"'); } expect( reviewVerifyGate.indexOf( From b3cb25c25a71984049511de79f53f5c3a5b36cdb Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sat, 22 Aug 2026 00:27:09 +0000 Subject: [PATCH 5/7] fix(autofix): pin gate startup channels and slash-path the digest check (#9649) Co-authored-by: Qwen-Coder --- .github/workflows/qwen-autofix.yml | 50 ++++++++++++++- scripts/tests/qwen-autofix-workflow.test.js | 67 +++++++++++++++------ 2 files changed, 96 insertions(+), 21 deletions(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 17952464075..7a90da99493 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -5189,8 +5189,25 @@ jobs: # clean child, so its bash inherits nothing at all. BASH_ENV: '' SHELLOPTS: '' + # LD_* are likewise mapped by ld.so at process STARTUP, before the + # body's unset can run: the unset clears them for children but + # cannot unload a library already mapped into THIS step's bash, + # whose execve hooks would forge the pre-launch digest check + # below; ld.so ignores empty values (R6-2). + LD_PRELOAD: '' + LD_AUDIT: '' + LD_LIBRARY_PATH: '' TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' VERIFY_RUNNER_SHA256: '${{ steps.stage.outputs.verify_runner_sha256 }}' + # RUNNER_TEMP, WORKDIR, and BRANCH re-enter the digest check and + # the allowlisted child below from the step environment: a + # $GITHUB_ENV plant points the digest oracle at a decoy runner + # (the hash is expression-context, the checked path is not) and + # swaps the tree the gate builds/tests. Pin them from trusted + # expression context, the TRUSTED_PATH doctrine above (R6-3). + RUNNER_TEMP: '${{ runner.temp }}' + WORKDIR: '/tmp/autofix-review-${{ matrix.target.pr }}' + BRANCH: '${{ matrix.target.branch }}' # 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'. @@ -5205,9 +5222,15 @@ jobs: # 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 digest line's command words are called by absolute path: + # bare names — even builtins like echo — are shadowed by + # $GITHUB_ENV-planted BASH_FUNC_%% functions, imported at + # bash STARTUP even under --norc, ahead of builtins and PATH + # (R6-4; a shadowed echo prints any digest line, blinding the + # check to a mid-run overwrite of the staged runner). 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 + /usr/bin/echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | /usr/bin/sha256sum -c - > /dev/null # Launch the gate through the workflow's env -i clean-child # pattern: the step environment inherits every $GITHUB_ENV plant # earlier steps left (verdict-variable plants, BITE_RUNNER @@ -5397,8 +5420,25 @@ jobs: # clean child, so its bash inherits nothing at all. BASH_ENV: '' SHELLOPTS: '' + # LD_* are likewise mapped by ld.so at process STARTUP, before the + # body's unset can run: the unset clears them for children but + # cannot unload a library already mapped into THIS step's bash, + # whose execve hooks would forge the pre-launch digest check + # below; ld.so ignores empty values (R6-2). + LD_PRELOAD: '' + LD_AUDIT: '' + LD_LIBRARY_PATH: '' TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' VERIFY_RUNNER_SHA256: '${{ steps.stage.outputs.verify_runner_sha256 }}' + # RUNNER_TEMP, WORKDIR, and BRANCH re-enter the digest check and + # the allowlisted child below from the step environment: a + # $GITHUB_ENV plant points the digest oracle at a decoy runner + # (the hash is expression-context, the checked path is not) and + # swaps the tree the gate builds/tests. Pin them from trusted + # expression context, the TRUSTED_PATH doctrine above (R6-3). + RUNNER_TEMP: '${{ runner.temp }}' + WORKDIR: '/tmp/autofix-review-${{ matrix.target.pr }}' + BRANCH: '${{ matrix.target.branch }}' # 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'. @@ -5415,9 +5455,15 @@ jobs: # 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 digest line's command words are called by absolute path: + # bare names — even builtins like echo — are shadowed by + # $GITHUB_ENV-planted BASH_FUNC_%% functions, imported at + # bash STARTUP even under --norc, ahead of builtins and PATH + # (R6-4; a shadowed echo prints any digest line, blinding the + # check to a mid-run overwrite of the staged runner). 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 + /usr/bin/echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | /usr/bin/sha256sum -c - > /dev/null # Launch the gate through the workflow's env -i clean-child # pattern: the step environment inherits every $GITHUB_ENV plant # earlier steps left (verdict-variable plants, BITE_RUNNER diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index cf00eda9545..fd633daff9e 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -10544,7 +10544,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, + /\/usr\/bin\/echo "\$\{VERIFY_RUNNER_SHA256\} {2}\$\{RUNNER_TEMP\}\/run-autofix-review-verification\.sh" \| \/usr\/bin\/sha256sum -c - > \/dev\/null/g, ) ?? [], ).toHaveLength(2); expect( @@ -11215,8 +11215,13 @@ exit 1 // launch head moved into a comment) each broke the child's isolation // while every token-level pin stayed green (R2-1). Anchoring the chain // on the LD_* prefix pins the one channel env -i cannot block; the - // body-side unset and PATH export that protect the pre-launch digest - // check are pinned with it (R3-1, R2-2). The shapes AROUND the chain + // body-side unset and PATH export are pinned with it (R3-1, R2-2). By + // themselves they do NOT protect the pre-launch digest check, which + // runs in this step's own bash: startup-time channels — an LD_* + // library mapped before line 1, a BASH_FUNC function import, a + // path-variable redirection of the checked file — are closed by the + // step-level env pins and the absolute digest path below (R6-2, R6-3, + // R6-4). The shapes AROUND the chain // are closed by pinning the run body's whole statement list with // comments stripped: a prefix command word that demotes the whole chain // to one command's argv (the gate never executes and a forged outcome @@ -11228,9 +11233,10 @@ exit 1 // `\s` also matches a blank line, which splits the chain into two // commands (the orphaned env -i prints and exits 0 while the rest runs // with the FULL step environment), and NBSP/U+2028, which glue into the - // next operand and rename it; the statement list sees neither shape - // (blank lines filter out, trim strips a leading NBSP), so only this - // pin closes them (R4-1). + // next operand and rename it. Blank lines filter out of the statement + // list too, so only this pin closes the blank-line split; lines that + // carry NBSP/U+2028 instead fail the statement list's exact match, + // whose ASCII-only strip keeps them visible (R4-1, R6-1). const gateLaunchTokens = [ 'LD_PRELOAD= LD_AUDIT= LD_LIBRARY_PATH=', '/usr/bin/env -i', @@ -11254,12 +11260,16 @@ exit 1 // exists, so its own defenses — the TRUSTED_PATH export and the LD_* // unset — and their order live in the pinned statement list: a // commented copy matched a bare toContain, and a planted LD_PRELOAD or - // PATH reached the sha256sum exec (R4-4). The digest line is pinned - // whole and per step — a workflow-wide count accepts relocation out of - // the gates, and `|| true` accepts a digest mismatch under bash -e - // (R4-3, the resanitize sibling's doctrine). + // PATH reached the sha256sum exec (R4-4); the startup-time variants a + // body line cannot reach — an LD_* library mapped before line 1 and + // BASH_FUNC function imports shadowing the line's bare command words + // (echo included) — are closed by the step-level LD_* pins and the + // absolute binary paths below (R6-2, R6-4). The digest line is pinned + // whole and per step — a workflow-wide count + // accepts relocation out of the gates, and `|| true` accepts a digest + // mismatch under bash -e (R4-3, the resanitize sibling's doctrine). const gateDigestCheck = - 'echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - > /dev/null'; + '/usr/bin/echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | /usr/bin/sha256sum -c - > /dev/null'; const gateBodyStatements = [ 'export PATH="${TRUSTED_PATH}"', 'unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH', @@ -11268,18 +11278,25 @@ exit 1 index < gateLaunchTokens.length - 1 ? `${token} \\` : token, ), ]; + // Bash breaks words only on ASCII space/tab/newline: strip ASCII + // whitespace only, so a line carrying any other "whitespace" (NBSP, + // U+2000–U+200A, U+2028, ...) keeps it and fails the exact match. + // JS trim() stripped those too, classifying `\u00a0# x` as a comment + // while bash executed it — a smuggled statement invisible to + // every other pin here (R6-1). + const gateBodyStatementsOf = (stepText) => + stepText + .slice(stepText.indexOf('run: |-') + 'run: |-'.length) + .split('\n') + .map((line) => line.replace(/^[ \t]+|[ \t]+$/g, '')) + .filter((line) => line !== '' && !line.startsWith('#')); + expect(gateBodyStatementsOf('run: |-\n \u00a0# x')).toEqual(['\u00a0# x']); for (const step of [ reviewVerificationGateStep, repairVerificationGateStep, ]) { expect(step).toMatch(gateLaunchPin); - expect( - step - .slice(step.indexOf('run: |-') + 'run: |-'.length) - .split('\n') - .map((line) => line.trim()) - .filter((line) => line !== '' && !line.startsWith('#')), - ).toEqual(gateBodyStatements); + expect(gateBodyStatementsOf(step)).toEqual(gateBodyStatements); // Exactly one launch per step: a second, unpinned `bash --norc` (the // pinned block demoted into a never-run arm) must fail here (R2-1). expect((step.match(/bash --norc/g) ?? []).length).toBe(1); @@ -19404,10 +19421,22 @@ describe('growth-audit hardening: park wake set and verdict pipeline (round 3)', // level, which outranks any $GITHUB_ENV plant; the gate itself then // runs through the workflow's env -i clean-child pattern, so its bash // inherits nothing at all (enumerating plants is the failure mode the - // verdict pipeline kept hitting). + // verdict pipeline kept hitting). LD_* load at startup the same way — + // the body-side unset cannot unload a library already mapped into the + // parent running the digest check (R6-2) — and RUNNER_TEMP/WORKDIR/ + // BRANCH steer that digest check and the child's tree, so they are + // pinned from trusted expression context too (R6-3). for (const step of [verificationGateSteps[1], repairVerificationGateStep]) { expect(step).toContain("BASH_ENV: ''"); expect(step).toContain("SHELLOPTS: ''"); + expect(step).toContain("LD_PRELOAD: ''"); + expect(step).toContain("LD_AUDIT: ''"); + expect(step).toContain("LD_LIBRARY_PATH: ''"); + expect(step).toContain("RUNNER_TEMP: '${{ runner.temp }}'"); + expect(step).toContain( + "WORKDIR: '/tmp/autofix-review-${{ matrix.target.pr }}'", + ); + expect(step).toContain("BRANCH: '${{ matrix.target.branch }}'"); expect(step).toContain('/usr/bin/env -i'); expect(step).toContain( 'bash --norc "${RUNNER_TEMP}/run-autofix-review-verification.sh"', From ae63e71f7c32c1abbbe15ed2b3db7f6b88b60391 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sat, 22 Aug 2026 06:58:42 +0000 Subject: [PATCH 6/7] fix(autofix): pin CI at step level in both verification gates (#9649) --- .github/workflows/qwen-autofix.yml | 12 ++++++++++++ scripts/tests/qwen-autofix-workflow.test.js | 9 +++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 7a90da99493..968989bc210 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -5208,6 +5208,12 @@ jobs: RUNNER_TEMP: '${{ runner.temp }}' WORKDIR: '/tmp/autofix-review-${{ matrix.target.pr }}' BRANCH: '${{ matrix.target.branch }}' + # CI re-enters the allowlisted child through the step + # environment: the child's `:-true` default only covers an UNSET + # CI, so a $GITHUB_ENV plant of CI=false survives the expansion + # and inverts the gate's CI semantics. Pin it at step level, the + # FOOTPRINT_ENFORCE doctrine below (R1-1). + CI: 'true' # 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'. @@ -5439,6 +5445,12 @@ jobs: RUNNER_TEMP: '${{ runner.temp }}' WORKDIR: '/tmp/autofix-review-${{ matrix.target.pr }}' BRANCH: '${{ matrix.target.branch }}' + # CI re-enters the allowlisted child through the step + # environment: the child's `:-true` default only covers an UNSET + # CI, so a $GITHUB_ENV plant of CI=false survives the expansion + # and inverts the gate's CI semantics. Pin it at step level, the + # FOOTPRINT_ENFORCE doctrine below (R1-1). + CI: 'true' # 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'. diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index fd633daff9e..c1462f29325 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -11203,7 +11203,7 @@ exit 1 ); // The gate launches through an env -i clean child with a SANCTIONED // allowlist (R5-1): every variable the gate's own build/test checks need - // must be passed, and the runner-provided CI=true is one of them — without + // must be passed, and the step-pinned CI=true is one of them — without // it the gate's checks run with inverted CI semantics and the 18 // deliberately-skipped TUI-input tests un-skip inside the gate (one flakes // ~5s, reject_fix fires retryable on a fix the PR's own CI passes green). @@ -19425,7 +19425,11 @@ describe('growth-audit hardening: park wake set and verdict pipeline (round 3)', // the body-side unset cannot unload a library already mapped into the // parent running the digest check (R6-2) — and RUNNER_TEMP/WORKDIR/ // BRANCH steer that digest check and the child's tree, so they are - // pinned from trusted expression context too (R6-3). + // pinned from trusted expression context too (R6-3). CI reaches the + // child's `CI="${CI:-true}"` expansion from the step environment, and + // `:-true` only covers an UNSET CI — a $GITHUB_ENV plant of CI=false + // survives the expansion and inverts the gate's CI semantics — so CI + // is pinned at step level too (R1-1). for (const step of [verificationGateSteps[1], repairVerificationGateStep]) { expect(step).toContain("BASH_ENV: ''"); expect(step).toContain("SHELLOPTS: ''"); @@ -19437,6 +19441,7 @@ describe('growth-audit hardening: park wake set and verdict pipeline (round 3)', "WORKDIR: '/tmp/autofix-review-${{ matrix.target.pr }}'", ); expect(step).toContain("BRANCH: '${{ matrix.target.branch }}'"); + expect(step).toContain("CI: 'true'"); expect(step).toContain('/usr/bin/env -i'); expect(step).toContain( 'bash --norc "${RUNNER_TEMP}/run-autofix-review-verification.sh"', From 6aaf14ff1654c2e5de4b8cab52c32fa568efd99e Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sat, 22 Aug 2026 10:12:58 +0000 Subject: [PATCH 7/7] fix(autofix): drop shadowable gate-body pins, pin gate HOME from staging (#9649) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two gate bodies' leading statements were bare command words: bash imports $GITHUB_ENV-planted BASH_FUNC_export%%/BASH_FUNC_unset%% (or BASH_FUNC_builtin%% for a builtin-prefixed spelling) as functions at startup even under --norc, and a shadowed pin can arm a DEBUG trap that swaps the staged runner after the digest check passes and before the env -i launch executes it — forging the verdict that gates the PAT push. Both statements are redundant: PATH reaches the child through the env -i allowlist, and LD_* is closed by the step-level pins, the env execve prefix, and env -i. Probed: hostile plants fire on the pre-fix body and are inert on the fixed body; child env is byte-identical without them. HOME was the remaining $GITHUB_ENV channel into the gate child: npm resolves its userconfig from HOME, and a planted HOME's .npmrc script-shell wraps every verdict-determining npm run, so a red branch reports green (probed: exit 7 becomes exit 0). Capture HOME at stage time, before any branch code runs, and pin it at step level in both gates — the trusted_path doctrine. Contract test updated in lockstep: the pinned statement list drops the two entries, and the pin assertions cover the HOME pin and its stage-time capture (mutation-probed). --- .github/workflows/qwen-autofix.yml | 58 +++++++++++++++------ scripts/tests/qwen-autofix-workflow.test.js | 53 +++++++++++-------- 2 files changed, 73 insertions(+), 38 deletions(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 968989bc210..b466e5c8ca1 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -3860,6 +3860,12 @@ jobs: echo "${_upsert_delim}" } >> "${GITHUB_OUTPUT}" echo "trusted_path=${PATH}" >> "${GITHUB_OUTPUT}" + # HOME is captured on the same doctrine: the verification gates + # pin it at step level, so a $GITHUB_ENV plant after staging + # cannot reach npm's userconfig inside the gate child — a planted + # HOME's .npmrc script-shell wraps every verdict-determining + # `npm run` in an attacker shell (R8-3). + echo "trusted_home=${HOME}" >> "${GITHUB_OUTPUT}" # The agent step runs AFTER prepare checks out the PR branch, so # invoking the runner from the working tree would execute # branch-controlled code on the host with the model key in env @@ -5214,6 +5220,13 @@ jobs: # and inverts the gate's CI semantics. Pin it at step level, the # FOOTPRINT_ENFORCE doctrine below (R1-1). CI: 'true' + # HOME re-enters the allowlisted child below from the step + # environment: a $GITHUB_ENV-planted HOME points npm's userconfig + # at a .npmrc whose script-shell wraps every verdict-determining + # `npm run` in an attacker shell — a red branch reports green. + # Pin it from the stage-time capture, the TRUSTED_PATH doctrine + # above (R8-3). + HOME: '${{ steps.stage.outputs.trusted_home }}' # 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'. @@ -5224,18 +5237,21 @@ jobs: 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, + # second — so 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 digest line's command words are called by absolute path: - # bare names — even builtins like echo — are shadowed by + # Every command word below is called by absolute path: bare names + # — even builtins like echo, export, or unset — are shadowed by # $GITHUB_ENV-planted BASH_FUNC_%% functions, imported at # bash STARTUP even under --norc, ahead of builtins and PATH # (R6-4; a shadowed echo prints any digest line, blinding the - # check to a mid-run overwrite of the staged runner). - export PATH="${TRUSTED_PATH}" - unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH + # check to a mid-run overwrite of the staged runner, and a + # shadowed export/unset arms a DEBUG trap that swaps the staged + # runner AFTER the digest passes and BEFORE the launch executes + # it, R8-1). The body therefore carries no in-shell pin of its + # own: PATH reaches the child through the allowlist below, and + # the preload channels are closed by the step-level LD_* pins + # above, the env execve prefix, and env -i. /usr/bin/echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | /usr/bin/sha256sum -c - > /dev/null # Launch the gate through the workflow's env -i clean-child # pattern: the step environment inherits every $GITHUB_ENV plant @@ -5451,6 +5467,13 @@ jobs: # and inverts the gate's CI semantics. Pin it at step level, the # FOOTPRINT_ENFORCE doctrine below (R1-1). CI: 'true' + # HOME re-enters the allowlisted child below from the step + # environment: a $GITHUB_ENV-planted HOME points npm's userconfig + # at a .npmrc whose script-shell wraps every verdict-determining + # `npm run` in an attacker shell — a red branch reports green. + # Pin it from the stage-time capture, the TRUSTED_PATH doctrine + # above (R8-3). + HOME: '${{ steps.stage.outputs.trusted_home }}' # 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'. @@ -5463,18 +5486,21 @@ jobs: 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, + # second — so 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 digest line's command words are called by absolute path: - # bare names — even builtins like echo — are shadowed by + # Every command word below is called by absolute path: bare names + # — even builtins like echo, export, or unset — are shadowed by # $GITHUB_ENV-planted BASH_FUNC_%% functions, imported at # bash STARTUP even under --norc, ahead of builtins and PATH # (R6-4; a shadowed echo prints any digest line, blinding the - # check to a mid-run overwrite of the staged runner). - export PATH="${TRUSTED_PATH}" - unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH + # check to a mid-run overwrite of the staged runner, and a + # shadowed export/unset arms a DEBUG trap that swaps the staged + # runner AFTER the digest passes and BEFORE the launch executes + # it, R8-1). The body therefore carries no in-shell pin of its + # own: PATH reaches the child through the allowlist below, and + # the preload channels are closed by the step-level LD_* pins + # above, the env execve prefix, and env -i. /usr/bin/echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | /usr/bin/sha256sum -c - > /dev/null # Launch the gate through the workflow's env -i clean-child # pattern: the step environment inherits every $GITHUB_ENV plant diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index c1462f29325..3a5e6dbccd1 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -11257,22 +11257,20 @@ exit 1 .join(' \\\\\n[ \\t]*'), ); // The digest check executes in the PARENT shell before the clean child - // exists, so its own defenses — the TRUSTED_PATH export and the LD_* - // unset — and their order live in the pinned statement list: a - // commented copy matched a bare toContain, and a planted LD_PRELOAD or - // PATH reached the sha256sum exec (R4-4); the startup-time variants a - // body line cannot reach — an LD_* library mapped before line 1 and - // BASH_FUNC function imports shadowing the line's bare command words - // (echo included) — are closed by the step-level LD_* pins and the - // absolute binary paths below (R6-2, R6-4). The digest line is pinned - // whole and per step — a workflow-wide count - // accepts relocation out of the gates, and `|| true` accepts a digest - // mismatch under bash -e (R4-3, the resanitize sibling's doctrine). + // exists. Its binaries are absolute paths and its inputs step-level + // pins, because ANY bare command word in the body — echo, export, or + // unset alike — is shadowed by $GITHUB_ENV-planted BASH_FUNC functions + // imported at bash startup (R6-4), and a shadowed in-shell pin arms a + // DEBUG trap that swaps the staged runner AFTER the digest passes and + // BEFORE the launch executes it (R8-1): the body carries no pin + // statement of its own, so the pinned list is exactly the digest line + // and the launch. The digest line is pinned whole and per step — a + // workflow-wide count accepts relocation out of the gates, and + // `|| true` accepts a digest mismatch under bash -e (R4-3, the + // resanitize sibling's doctrine). const gateDigestCheck = '/usr/bin/echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | /usr/bin/sha256sum -c - > /dev/null'; const gateBodyStatements = [ - 'export PATH="${TRUSTED_PATH}"', - 'unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH', gateDigestCheck, ...gateLaunchTokens.map((token, index) => index < gateLaunchTokens.length - 1 ? `${token} \\` : token, @@ -19421,15 +19419,21 @@ describe('growth-audit hardening: park wake set and verdict pipeline (round 3)', // level, which outranks any $GITHUB_ENV plant; the gate itself then // runs through the workflow's env -i clean-child pattern, so its bash // inherits nothing at all (enumerating plants is the failure mode the - // verdict pipeline kept hitting). LD_* load at startup the same way — - // the body-side unset cannot unload a library already mapped into the - // parent running the digest check (R6-2) — and RUNNER_TEMP/WORKDIR/ - // BRANCH steer that digest check and the child's tree, so they are - // pinned from trusted expression context too (R6-3). CI reaches the - // child's `CI="${CI:-true}"` expansion from the step environment, and - // `:-true` only covers an UNSET CI — a $GITHUB_ENV plant of CI=false - // survives the expansion and inverts the gate's CI semantics — so CI - // is pinned at step level too (R1-1). + // verdict pipeline kept hitting). LD_* load at startup the same way, so + // they are pinned empty at step level too — an in-body unset cannot + // unload a library already mapped into the parent running the digest + // check, and a bare unset is itself a BASH_FUNC shadow target (R6-2, + // R8-1) — and RUNNER_TEMP/WORKDIR/BRANCH steer that digest check and + // the child's tree, so they are pinned from trusted expression context + // too (R6-3). CI reaches the child's `CI="${CI:-true}"` expansion from + // the step environment, and `:-true` only covers an UNSET CI — a + // $GITHUB_ENV plant of CI=false survives the expansion and inverts the + // gate's CI semantics — so CI is pinned at step level too (R1-1). + // HOME reaches the child's allowlist from the step environment, and + // npm resolves its userconfig from HOME — a planted HOME's .npmrc + // script-shell wraps every verdict-determining `npm run`, so a red + // branch reports green; HOME is pinned from the stage-time capture + // (R8-3). for (const step of [verificationGateSteps[1], repairVerificationGateStep]) { expect(step).toContain("BASH_ENV: ''"); expect(step).toContain("SHELLOPTS: ''"); @@ -19442,6 +19446,7 @@ describe('growth-audit hardening: park wake set and verdict pipeline (round 3)', ); expect(step).toContain("BRANCH: '${{ matrix.target.branch }}'"); expect(step).toContain("CI: 'true'"); + expect(step).toContain("HOME: '${{ steps.stage.outputs.trusted_home }}'"); expect(step).toContain('/usr/bin/env -i'); expect(step).toContain( 'bash --norc "${RUNNER_TEMP}/run-autofix-review-verification.sh"', @@ -19452,6 +19457,10 @@ describe('growth-audit hardening: park wake set and verdict pipeline (round 3)', 'FOOTPRINT_ENFORCE="${FOOTPRINT_ENFORCE:-advisory}"', ); } + // The review stage step records HOME before any branch code runs — the + // trusted_path doctrine — and only it: the issue job's stage has no + // gate child re-injecting HOME. + expect(workflow.match(/trusted_home=\$\{HOME\}/g) ?? []).toHaveLength(1); }); });