Skip to content

feat(autofix): run the verification gate in an ephemeral container - #9214

Closed
wenshao wants to merge 14 commits into
mainfrom
fix/autofix-gate-container-9089
Closed

feat(autofix): run the verification gate in an ephemeral container#9214
wenshao wants to merge 14 commits into
mainfrom
fix/autofix-gate-container-9089

Conversation

@wenshao

@wenshao wenshao commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Runs the autofix verification gate inside an ephemeral container instead of on the host, and pins the resulting trust boundary with a structural test. This is Phase 1 + Phase 2 of the design agreed in #9089.

  • The gate moves into a container. Both gate steps (Verification gate, Repair verification gate) now invoke a staged, digest-verified wrapper that runs run-autofix-review-verification.sh in the sandbox image the agent already uses. The image name comes from the resolve step's output (expression context), not $GITHUB_ENV, so branch code cannot choose it. docker run does not inherit the host environment, so inside the container there is no CI_DEV_BOT_PAT, no $GITHUB_ENV, no real $GITHUB_OUTPUT, $HOME is a throwaway, and only three paths are mounted: the workspace, the round's workdir, and a copy-staged container temp. The real RUNNER_TEMP — which holds the staged agent runner and the PAT steps' throwaway git/gh configs — is never mounted.
  • The verdict crosses back on an unforgeable signal. The container writes to a host-created verdict file; the host combines it with the container exit code. Branch code inside the container can append to that 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, and any other code leaves the outcome unset for the existing gate-crash retry path.
  • Phase 2 pins the property, not the patch. A contract test fails if any step whose env carries CI_DEV_BOT_PAT invokes branch-authored code (the gate script, the agent runner, npm/npx) outside a container.

Why it's needed

The gate executes the branch's own build/typecheck/lint/test. On the host that code shares the OS user, $HOME, $GITHUB_ENV and $GITHUB_OUTPUT with the same job's later PAT-bearing steps, and #8961's review showed several channels that no in-step guard can reach:

  • BASH_ENV / BASH_FUNC_* — applied at the step shell's startup, before line 1 (the runner's $GITHUB_ENV blocklist is NODE_OPTIONS-only).
  • LD_PRELOAD / LD_AUDIT — mapped by the loader into the step's own bash at exec time, with the PAT already in environ.
  • $GITHUB_OUTPUT — a runner-provided file the gate-run branch code can append outcome=fixed to, regardless of any digest check on the gate script's bytes.

The trust boundary cannot be a job boundary. A job that has executed attacker code cannot vouch for anything it emits (its outputs, its "gate passed" verdict, even a signature), and a second job that re-verified would have to run that same code — while holding the PAT. So the boundary has to be an execution-isolation boundary the PAT never crosses: the container wall.

Reviewer Test Plan

How to verify

  • npx vitest run --config ./scripts/tests/vitest.config.ts scripts/tests/qwen-autofix-workflow.test.js174/174. Two new tests: one pins the container invocation (wrapper + both digests, image from steps.sandbox.outputs.image, the exact --env allowlist and --volume set, --user/--rm) and drives the verdict translation behaviourally; the other is the PAT/branch-code invariant.
  • Probed against the real wrapper and the real sandbox image family (ghcr.io/qwenlm/qwen-code), not a mock:
Probe Result
CI_DEV_BOT_PAT visible inside the container <absent>
$GITHUB_ENV visible inside <absent>
host RUNNER_TEMP reachable inside not mounted ✅
$GITHUB_OUTPUT inside redirected to the verdict file ✅
host $GITHUB_ENV after the run untouched ✅
gate exits 1 with a forged outcome=fixed in the verdict host gets outcome=failed + retryable=true
gate exits 0 writing no verdict outcome unset (crash path) + warning ✅
docker/image failure (125) outcome unset (crash path) ✅

Evidence (Before & After)

Before: bash "${RUNNER_TEMP}/run-autofix-review-verification.sh" on the host — branch build/test in the same execution context as the PAT.
After: bash "${RUNNER_TEMP}/run-autofix-gate-container.sh"docker run with an explicit six-entry env allowlist and three mounts; the PAT/$GITHUB_ENV/host $GITHUB_OUTPUT are absent by construction.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Risk & Scope

  • Main risk: the gate now depends on docker being usable for a nested/sibling container on the pool. The job already preflights docker info and the agent already runs in this image against this same workspace with the same npm run build/typecheck/lint + npx vitest commands, so the toolchain is proven — but the first real round is where a mount/permission surprise would show. --user $(id -u):$(id -g) keeps container writes owned by the runner user (the ownership-restore step's failure mode). A docker failure is a gate crash (retry), never a silent pass.
  • Not validated locally: a full gate run inside the container against a real PR (macOS docker + no pool workspace); the contract tests and the isolation probes above are what I could run here.
  • Not in scope: the issue-autofix job's inline gate (same class, but agent-authored branch rather than an external contributor's PR) and Phase 3 (per-job ephemeral runners, a pool-infra decision) — both stay tracked in autofix: PAT-bearing jobs share a host with untrusted branch code — needs runner-level isolation #9089.

Linked Issues

中文说明

本 PR 做了什么

把 autofix 的验证门放进临时容器执行(不再在宿主上),并用结构性测试钉住由此建立的信任边界。这是 #9089 中已确认设计的阶段 1 + 阶段 2。

  • 门移入容器。 两个门步骤(Verification gateRepair verification gate)现在调用一个暂存且经 digest 校验的 wrapper,由它在 agent 已在使用的 sandbox 镜像中运行 run-autofix-review-verification.sh。镜像名取自 resolve 步骤的输出(expression context)而非 $GITHUB_ENV,因此分支代码无法选择它。docker run 不继承宿主环境,故容器内没有 CI_DEV_BOT_PAT、没有 $GITHUB_ENV、没有真实的 $GITHUB_OUTPUT$HOME 是一次性目录,且只挂载三个路径:workspace、本轮 workdir、以及拷贝暂存的容器 temp。真实的 RUNNER_TEMP(存放暂存的 agent runner 与 PAT 步骤的一次性 git/gh 配置)从不挂载。
  • 裁决经由不可伪造的信号回传。 容器写入宿主创建的裁决文件;宿主将其与容器退出码结合判断。容器内的分支代码可以往该文件追加内容,但无法让失败的门以 0 退出——因此只有退出码为 0 才接受"通过",退出码 1 一律强制 failed(无视文件内容),其他退出码则不设置 outcome,走既有的门崩溃重试路径。
  • 阶段 2 钉住的是性质而非补丁。 一条契约测试会在"任何 env 携带 CI_DEV_BOT_PAT 的步骤在容器之外调用分支编写的代码(门脚本、agent runner、npm/npx)"时失败。

为什么需要

门执行的是分支自己的 build/typecheck/lint/test。在宿主上,这些代码与同一 job 后续携带 PAT 的步骤共享 OS 用户、$HOME$GITHUB_ENV$GITHUB_OUTPUT,而 #8961 的评审已证明有几条通道任何步骤内的防御都够不到

  • BASH_ENV / BASH_FUNC_* —— 在步骤 shell 启动时即生效,早于第 1 行(runner 对 $GITHUB_ENV 的屏蔽名单只有 NODE_OPTIONS)。
  • LD_PRELOAD / LD_AUDIT —— 由加载器在 exec 步骤自身 bash 时映射进去,此时 PAT 已在 environ 中。
  • $GITHUB_OUTPUT —— runner 提供的文件,门中运行的分支代码可直接追加 outcome=fixed,无论 digest 如何校验门脚本的字节。

信任边界不能是 job 边界。 执行过攻击者代码的 job 无法为其产出的任何东西背书(输出、"门已通过"的裁决、甚至签名),而负责重新校验的第二个 job 又必须运行同一批代码——同时还持有 PAT。因此边界必须是一道 PAT 永不跨越的执行隔离边界:容器墙。

审阅者测试计划

如何验证

  • npx vitest run --config ./scripts/tests/vitest.config.ts scripts/tests/qwen-autofix-workflow.test.js —— 174/174。两个新测试:其一钉住容器调用(wrapper + 两个 digest、镜像取自 steps.sandbox.outputs.image、精确的 --env 白名单与 --volume 集合、--user/--rm)并对裁决翻译做行为性验证;其二是 PAT/分支代码不变量。
  • 针对真实 wrapper 与真实 sandbox 镜像族(ghcr.io/qwenlm/qwen-code)探针验证,非 mock:
探针 结果
容器内可见 CI_DEV_BOT_PAT <absent>
容器内可见 $GITHUB_ENV <absent>
容器内可达宿主 RUNNER_TEMP 未挂载 ✅
容器内的 $GITHUB_OUTPUT 已重定向至裁决文件 ✅
运行后宿主 $GITHUB_ENV 未被改动 ✅
门以 1 退出且裁决文件中伪造 outcome=fixed 宿主得到 outcome=failed + retryable=true
门以 0 退出但未写裁决 outcome 不设置(崩溃路径)并告警 ✅
docker/镜像失败(125) outcome 不设置(崩溃路径)✅

证据(Before & After)

Before:宿主上 bash "${RUNNER_TEMP}/run-autofix-review-verification.sh" —— 分支 build/test 与 PAT 处于同一执行上下文。
After:bash "${RUNNER_TEMP}/run-autofix-gate-container.sh"docker run,带显式六项 env 白名单与三个挂载;PAT/$GITHUB_ENV/宿主 $GITHUB_OUTPUT 由构造上即不存在。

测试平台

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

风险与范围

  • 主要风险: 门现在依赖池上可用的嵌套/兄弟容器。该 job 已有 docker info 预检,且 agent 本就在这个镜像中、对同一 workspace 运行同样的 npm run build/typecheck/lintnpx vitest,工具链已被证明可用——但首个真实轮次才是挂载/权限意外会暴露的地方。--user $(id -u):$(id -g) 确保容器写入仍归 runner 用户所有(即 ownership-restore 步骤所针对的故障模式)。docker 失败属于门崩溃(重试),绝不会成为静默通过。
  • 本地未验证: 在真实 PR 上于容器内完整跑一遍门(macOS docker + 无池 workspace);此处能跑的是上述契约测试与隔离探针。
  • 范围外: issue-autofix job 的内联门(同类问题,但分支由 agent 编写而非外部贡献者 PR)与阶段 3(每 job 临时 runner,属池基础设施决策)——两者继续在 autofix: PAT-bearing jobs share a host with untrusted branch code — needs runner-level isolation #9089 跟踪。

关联 Issue

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

autofix/needs-human The autofix loop stopped on this PR — a human must re-arm, split, merge, or close it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants