Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 98 additions & 12 deletions .github/workflows/qwen-autofix.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
name: 'Qwen Autofix'

# One workflow for the whole autonomous-fix lifecycle:
Expand Down Expand Up @@ -3860,6 +3860,12 @@
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
Expand Down Expand Up @@ -5189,8 +5195,38 @@
# 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 }}'
Comment on lines +5214 to +5216

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] This pin block closes every $GITHUB_ENV→gate channel except HOME — the only allowlisted child variable (HOME="${HOME}" in the launch below, both gates) whose value still flows from the plantable step environment. Git is defended in the gate child, but npm resolves its userconfig from $HOME and has no defense: a planted HOME whose .npmrc sets script-shell wraps every npm run build|typecheck|lint|test invocation in an attacker-controlled shell, so a genuinely failing check exits 0 — every verdict-determining check reports green for a red branch, and the PAT-bearing push runs. actions/runner accepts the plant (FileCommandManager blocklist is NODE_OPTIONS only; ScriptHandler re-injects only RUNNER_*/allowlisted GITHUB_*, so HOME carries the plant through), which is exactly the doctrine this block applies to RUNNER_TEMP/WORKDIR/BRANCH/CI.

Witness (probe, node 22.23.2 / npm 10.9.8):

CONTROL (real HOME):                        npm run fail -> exit=7
PLANTED (HOME with .npmrc script-shell):    npm run fail -> exit=0
wrap.log: WRAPPED argv=-c exit 7

Fix: capture HOME at stage time with the existing trusted-value doctrine — add echo "trusted_home=${HOME}" >> "${GITHUB_OUTPUT}" to 'Stage trusted schema gate and agent runner' (it runs before any branch code), then pin HOME: '${{ steps.stage.outputs.trusted_home }}' in both gates' env blocks here and extend the pin assertions to it.

中文说明

[Critical] 本 pin 块关闭了所有 $GITHUB_ENV→gate 通道,唯独剩 HOME——它是允许列表中唯一一个(下方启动的 HOME="${HOME}",两个 gate 皆然)取值仍来自可植入步骤环境的变量。gate 子进程中 git 已有防御,但 npm 从 $HOME 解析 userconfig 且无防御:植入的 HOME 目录里 .npmrc 设置 script-shell 后,每一次 npm run build|typecheck|lint|test 都会被攻击者控制的 shell 包裹,真正失败的检查也会以 0 退出——红分支的所有 verdict 判定检查全绿,PAT push 随之执行。actions/runner 接受该植入(FileCommandManager 屏蔽名单仅 NODE_OPTIONSScriptHandler 只重新注入 RUNNER_*/允许列表内的 GITHUB_*,HOME 带着植入值透传)——这正是本块对 RUNNER_TEMP/WORKDIR/BRANCH/CI 所应用的学说。

修复:按既有的受信任值学说在 stage 阶段捕获 HOME——在 'Stage trusted schema gate and agent runner'(先于任何分支代码运行)中加 echo "trusted_home=${HOME}" >> "${GITHUB_OUTPUT}",然后在此处两个 gate 的 env 块中钉住 HOME: '${{ steps.stage.outputs.trusted_home }}',并扩展 pin 断言覆盖它。

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

# 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'
# 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'.
Expand All @@ -5201,13 +5237,22 @@
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.
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
# 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_<name>%% 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, 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
# earlier steps left (verdict-variable plants, BITE_RUNNER
Expand All @@ -5222,6 +5267,7 @@
WORKDIR="${WORKDIR}" \
BRANCH="${BRANCH}" \
GITHUB_OUTPUT="${GITHUB_OUTPUT}" \
Comment on lines 5267 to 5269

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] The allowlist this PR's new pin canonizes as the SANCTIONED chain re-injects path/state variables (WORKDIR, and with it RUNNER_TEMP, HOME, BRANCH) verbatim from the step environment into the clean child, and none of them is pinned at step level — unlike TRUSTED_PATH/VERIFY_RUNNER_SHA256/FOOTPRINT_ENFORCE/KISS_AUDIT in the same env blocks. actions/runner settles the plant premise: JobExtension.cs seeds runner defaults, merges workflow/job env into the same dict last-write-wins, and FileCommandManager.cs applies $GITHUB_ENV writes AFTER both — overriding runner defaults and job env for subsequent steps, blocklist NODE_OPTIONS only. The digest check then reads its PATH from the planted env while its oracle is expression-context, so it verifies whichever file the attacker points at.

Failure: branch code writes RUNNER_TEMP=/tmp/decoy to $GITHUB_ENV and places a byte-identical copy of the trusted runner script at the decoy. In the gate, sha256sum -c verifies ${RUNNER_TEMP}/run-autofix-review-verification.sh at the decoy path — passes — and the child execs the decoy with RUNNER_TEMP=/tmp/decoy, resolving check-settings-schema.sh, check-autofix-contracts.sh, resolve-owning-packages.sh and GIT_CONFIG_GLOBAL from the decoy: attacker code executes inside the otherwise-clean child and writes the verdict that gates the PAT push. WORKDIR-plant variant: the gate builds/tests a pre-greened decoy tree and the push steps consume the same planted value. All pins this PR adds stay green (allowlist text unchanged).

Witness: not run on a live GitHub runner — merge premise settled against the actions/runner implementation (JobExtension.cs InitializeJob seeds runner defaults then merges workflow/job env into Global.EnvironmentVariables last-write-wins; FileCommandManager.cs applies $GITHUB_ENV after both, blocklist {NODE_OPTIONS} only; ExecutionContext.SetEnvContext checks only NotNullOrEmpty(name)); value-source trace through this workflow is deterministic given it

Fix: pin the path variables from trusted expression context at step level, mirroring TRUSTED_PATH/VERIFY_RUNNER_SHA256, in both gate steps' env blocks, e.g.:

RUNNER_TEMP: '${{ runner.temp }}'
WORKDIR: '/tmp/autofix-review-${{ matrix.target.pr }}'

and extend the new pins to assert the step-env entries.

中文说明

[Critical] 本 PR 新 pin 宣告为 SANCTIONED 链的允许列表,把路径/状态变量(WORKDIR,以及 RUNNER_TEMPHOMEBRANCH)逐字从步骤环境重新注入干净子进程,而它们都没有 step 级钉住——同一个 env 块里的 TRUSTED_PATH/VERIFY_RUNNER_SHA256/FOOTPRINT_ENFORCE/KISS_AUDIT 却有。actions/runner 确认了植入前提:JobExtension.cs 先播下 runner 默认值,再以后写覆盖方式把 workflow/job env 合入同一字典;FileCommandManager.cs 在两者之后应用 $GITHUB_ENV 写入——对后续步骤覆盖 runner 默认与 job env,屏蔽名单仅 NODE_OPTIONS。digest 检查随后从被植入的环境读取路径,而其预期摘要来自表达式上下文,因此它会校验攻击者指向的任何文件。

失败路径:分支代码向 $GITHUB_ENV 写入 RUNNER_TEMP=/tmp/decoy,并在该假目录放置与受信任 runner 脚本逐字节一致的副本。gate 中 sha256sum -c 在假路径上校验 ${RUNNER_TEMP}/run-autofix-review-verification.sh——通过——子进程以 RUNNER_TEMP=/tmp/decoy exec 该副本,从假目录解析 check-settings-schema.shcheck-autofix-contracts.shresolve-owning-packages.shGIT_CONFIG_GLOBAL:攻击者代码在本应干净的子进程内执行,并写入决定 PAT push 的 verdict。WORKDIR 植入变体:gate 在预先绿化的假树上 build/test,push 步骤消费同一植入值。本 PR 新增的所有 pin 保持绿色(允许列表文本未变)。

见证:未在真实 GitHub runner 上运行——合并前提依据 actions/runner 实现确认(JobExtension.cs InitializeJob 先播下 runner 默认值再以后写覆盖合并 workflow/job env 到 Global.EnvironmentVariables;FileCommandManager.cs 在两者之后应用 $GITHUB_ENV,屏蔽名单仅 {NODE_OPTIONS};ExecutionContext.SetEnvContext 仅检查 NotNullOrEmpty(name));给定该前提,本 workflow 内的取值源追踪是确定性的。

修复:仿照 TRUSTED_PATH/VERIFY_RUNNER_SHA256,在两个 gate 步骤的 env 块中从受信任表达式上下文 step 级钉住路径变量(如 RUNNER_TEMP: '${{ runner.temp }}'WORKDIR: '/tmp/autofix-review-${{ matrix.target.pr }}'),并扩展新 pin 断言这些 step-env 条目。

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

CI="${CI:-true}" \
Comment thread
doudouOUC marked this conversation as resolved.
KISS_AUDIT="${KISS_AUDIT:-false}" \
FOOTPRINT_ENFORCE="${FOOTPRINT_ENFORCE:-advisory}" \
bash --norc "${RUNNER_TEMP}/run-autofix-review-verification.sh"
Expand Down Expand Up @@ -5396,8 +5442,38 @@
# 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 }}'
# 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'
# 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'.
Expand All @@ -5410,13 +5486,22 @@
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.
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
# 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_<name>%% 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, 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
# earlier steps left (verdict-variable plants, BITE_RUNNER
Expand All @@ -5431,6 +5516,7 @@
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"
Expand Down
132 changes: 129 additions & 3 deletions scripts/tests/qwen-autofix-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -11201,6 +11201,104 @@ 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 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).
// 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 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
// 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. 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',
'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(
gateLaunchTokens
.map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join(' \\\\\n[ \\t]*'),
);
// The digest check executes in the PARENT shell before the clean child
// 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 = [
gateDigestCheck,
...gateLaunchTokens.map((token, index) =>
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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This block pins the chain "through the digest-verified script", but the pre-launch digest check itself is pinned only by a workflow-wide toHaveLength(2) count (line ~10529, regex not end-anchored) — never per gate step, never ordered before the launch, never guarded against weakening. Probe-verified mutants, baseline all-green and each mutant all-green against every pin touching the gate steps: relocating both digest lines out of the gate steps (or after the launch line) — the launch executes the staged script with no pre-execution verification, and RUNNER_TEMP is branch/agent-writable between staging and the gate per the stage step's own comment, with both gate steps continue-on-error so forged $GITHUB_OUTPUT verdicts survive; and appending || true to a digest line — the count regex still matches, and under the runner's bash -e the mismatch no longer aborts, so a tampered staged script launches. This is the exact mutant the resanitize sibling's comment (~10354) records as turning "the tamper gate into a decorative no-op while presence/order assertions stayed green" — that sibling pins all three properties (whole-line toContain + indexOf ordering before the call + no-|| true); this new block should mirror the doctrine in its own loop. The relocation and || true shapes also survive the head/tail anchoring proposed in the other thread (the count pin is workflow-global, the weakening intra-line).

Witness:

M1a (digest after launch):       exit=0 | Tests 215 passed
M1b (digest removed from gates): exit=0 | Tests 215 passed
M2  (|| true appended):          exit=0 | Tests 215 passed
with mirrored doctrine: M1a "expected 3407 to be less than 3333", M1b/M2 red, baseline green
Suggested change
expect(step).toMatch(gateLaunchPin);
expect(step).toMatch(gateLaunchPin);
expect(step).toContain('echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - > /dev/null');
expect(
step.indexOf('sha256sum -c - > /dev/null'),
).toBeLessThan(step.search(gateLaunchPin));
expect(step).not.toMatch(/sha256sum -c[^\n]*\|\| true/);
中文说明

本块把链钉到「digest 校验过的脚本」,但启动前的 digest 检查本身只由一个全 workflow 的 toHaveLength(2) 计数 pin(~10529 行,正则无尾锚定)——既不按 gate 步骤钉,也不钉其在启动之前的顺序,更不防削弱。探针验证的突变体:基线全绿,且每个突变体对触及 gate 步骤的所有 pin 均保持绿色。把两条 digest 行移出 gate 步骤(或挪到启动行之后)——启动会在无执行前校验的情况下运行暂存脚本,而按 stage 步骤自己的注释,RUNNER_TEMP 在 staging 与 gate 之间可被分支/agent 代码写入,两个 gate 步骤又都是 continue-on-error,伪造的 $GITHUB_OUTPUT verdict 会留存;在 digest 行尾追加 || true——计数正则仍能匹配,而在 runner 的 bash -e 下不匹配不再中止步骤,被篡改的暂存脚本照样启动。这正是 resanitize sibling 注释(~10354)记录过的突变体——「防篡改 gate 变成装饰性空操作,而存在/顺序断言保持绿色」;该 sibling 钉住了全部三个属性(整行 toContain + 调用前 indexOf 排序 + 禁止 || true),本新块应在自己的循环里镜像该纪律。迁移与 || true 形态在另一线程提议的头/尾锚定下依然存活(计数 pin 是全 workflow 的,削弱发生在行内)。

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

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);
}
expect(
reviewVerifyGate.indexOf(
'bash "${RUNNER_TEMP}/check-autofix-contracts.sh"',
Expand Down Expand Up @@ -12793,7 +12891,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
Expand Down Expand Up @@ -19321,10 +19419,34 @@ 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, 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: ''");
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("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"',
Expand All @@ -19335,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);
});
});

Expand Down
Loading