Skip to content
Merged
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
249 changes: 249 additions & 0 deletions docs/design/autofix-gate-runner-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
# Autofix: runner-level isolation for PAT-bearing steps

Supersedes the in-step approach attempted in PR #9214 (frozen).
Tracks the structural close for issue #9089. Findings inventory: issue #9524.

## Problem statement

`review-address` and `issue-autofix` (`.github/workflows/qwen-autofix.yml`) run
**untrusted branch code** and **hold `CI_DEV_BOT_PAT`** in the same job, on the
same OS user, on a persistent self-hosted pool that shares one `HOME` across
~27 runner registrations. In `review-address` the sequence inside one job is:

| Step | Executes branch code | Holds the PAT |
| -------------------------------- | -------------------- | ------------- |
| `Triage and address` (agent) | yes | no |
| `Verification gate` (build/test) | yes | no |
| `Repair …` / `Finalize …` | partly | no |
| `Push and report` | no | **yes** |

Everything the later step trusts — `$GITHUB_ENV`, `$GITHUB_OUTPUT`,
`$RUNNER_TEMP`, `$HOME`, the workspace `.git`, live processes — is writable by
the earlier steps' code and by any co-resident survivor at the same uid. #9089
enumerates the vectors that execute _before a step's first line_
(`BASH_ENV`, `BASH_FUNC_*`, `LD_PRELOAD`/`LD_AUDIT`) and the runner-provided
shared channels that no in-step `unset` can reach.

### What PR #9214 tried, and what it proved

#9214 ran the verification gate in an ephemeral container and hardened the
surrounding steps: digest chains, type/timeout discipline, env pin blocks,
process-tree sweeps, fingerprint sets, and contract tests pinning all of it.
It did not converge, and two of its results are load-bearing for this design:

1. **The approach is self-defeating at this scale.** Eleven review rounds took
the diff from 329 to 2,700 lines while the per-round Critical count rose
8 → 9 → 15 → 19 → 29. Rounds 10 and 11 changed no code at all, and round 11
still produced 14 new Criticals on byte-identical files — six of them of the
form _"the site this diff ADDS lacks the discipline this diff installs
elsewhere."_ Each added guard was the next round's attack surface.

2. **A container around the executor cannot help, because the root of trust is
outside it** (R11-8). The gate's whole digest chain is rooted in
`steps.stage.outputs.*`. The runner parses a step's `$GITHUB_OUTPUT`
last-wins at step _end_, the file lives under attacker-writable
`$RUNNER_TEMP`, and no sweep of any kind runs before the staging step. A
same-uid co-resident can swap the staged bytes _and_ append forged digest
lines that displace the honest ones — both sides of every downstream
comparison are then attacker-steered. Wrapping the consumer in a container
changes nothing about that.

Independently: the wrapper could never have run at all post-merge (R9-1: it
digest-verifies a copy of itself that the staging loop never creates; R9-2: the
helper digest is path-dependent and computed over two different path sets), and
CI stayed green because the pre-merge `-z` skip path masks it. That is a
symptom of the same problem — the design had grown past what its tests could
cover.

## Design principle

> A job that has executed branch code must never hold the PAT, and must never
> be able to influence the _inputs_ of the job that does.

The boundary moves from _inside a step_ to _between jobs_, and the channel
between them narrows to one artifact that the consumer re-derives its decision
from rather than trusting.

## Proposed design

### A. Split `review-address` into `execute` and `publish`

**`execute`** — everything that touches branch code: prepare, agent, gate,
repair, artifacts. No `CI_DEV_BOT_PAT`, no write-scoped `GITHUB_TOKEN`, no
push, no PR comment. It emits exactly two things as a job artifact:

- `fix.patch` — `git format-patch`/`git diff` output against the recorded base
SHA, and
- `report.md` + a plain `verdict.txt` (advisory: what the gate concluded).

**`publish`** — `needs: execute`, **never executes branch code**: no
`npm ci`, no build, no test, no repo script, no hooks. It checks out the base
branch fresh, downloads the artifact, re-derives its own decision (see B),
applies the patch, and pushes with the PAT.

What crosses the boundary is an Actions artifact, not a runner-managed channel.
`$GITHUB_ENV`, `$GITHUB_OUTPUT`, `$HOME`, `$RUNNER_TEMP` and live processes do
not cross a job boundary; a forged line in the execute job's `$GITHUB_OUTPUT`
reaches nothing that the publish job reads.

Hook safety in `publish` is explicit, not incidental: apply with
`git -c core.hooksPath=/dev/null apply --index`, never `git am` with a hostile
`.git`, and never check out the PR head into the publish job's workspace.

### B. The publish job re-derives; it does not trust the verdict

In this threat model the execute job is _assumed compromised_ — it runs branch
code by design. Therefore its verdict is untrusted by construction, and no
signature over that verdict can fix it. What the publish job can do is evaluate
the patch itself, with checks that need no branch execution:

1. the patch applies cleanly to the recorded base SHA;
2. every touched path is inside the PR's declared footprint allowlist;
3. `.github/workflows/**`, `.github/actions/**`, lockfiles, and release
manifests are refused unless the run was explicitly authorised for them;
4. net added lines are within the round's growth budget (the brake that already
exists, moved to the trusted side);
5. the commit message matches the Conventional Commit contract;
6. the head SHA still matches the lease recorded before the agent ran
(`--force-with-lease`).

Anything that requires _executing_ the branch (build, typecheck, unit tests)
stays in `execute` and is **advisory**. This is a real reduction in what
"verified" means, and it should be stated plainly in the status comment. The
honest comparison is not against a working guarantee: today's gate verdict is
forgeable (R11-8) and, on the #9214 branch, was never produced at all
(R9-1/R9-2).

Optional defence in depth: `actions/attest-build-provenance` over the artifact
binds _which job produced it_. It does not make a compromised producer's output
trustworthy, so it is additive, not a substitute for (B).

### C. Ephemeral registration for the `execute` leg

The survivor/co-resident class exists because registrations are long-lived and
share `HOME`. Register the pool's autofix runners with `--ephemeral` (one job
per registration, fresh `HOME`), or, where the pool cannot be changed, run
`execute` in a job-level `container:` with a per-job `HOME`. The container is a
mitigation (concurrent legs still share the host kernel and the docker socket);
ephemeral registration is the close.

This is an infrastructure change and is sequenced last — (A) and (B) already
remove the PAT from the shared host, which is the part that matters most.

### D. Kill agent descendants by lineage, not by env marker

Replace the `AUTOFIX_AGENT_TREE` marker sweeps (the source of R8-8, R9-3 and
R11-10) with a cgroup scope per agent invocation — `systemd-run --scope` or a
`cgcreate`/`cgclassify` pair — and kill the cgroup. Cgroup membership is not
forgeable from an environment variable, cross-leg kills become impossible
because each scope is per invocation, and self-kill becomes impossible because
the gate step is not a member of the scope it kills. Where cgroup delegation is
unavailable, `setsid` + process-group kill is a strictly better fallback than
marker matching.

### E. Delete what A–D make redundant

The point of this work is a **smaller** trust surface, not another layer. Once
the PAT is off the shared host and the decision is re-derived on the trusted
side, the in-step enumeration machinery (env pin blocks, staged-script digest
chains, fingerprint sets, marker sweeps and the contract tests pinning them)
protects a boundary that no longer carries a secret. Removing it is part of
this change, not a follow-up: a guard kept "just in case" is the thing that
regenerated findings in #9214.

## Alternatives considered

| Option | Verdict |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| In-step hardening (#9214) | Rejected — measured non-convergence, and its root of trust sits outside the step (R11-8). |
| Sign the verdict in `execute`, verify in `publish` | Rejected alone — signs an untrusted producer's claim. Useful only as (B)'s optional attestation. |
| Move the whole job to GitHub-hosted runners | Closes the class outright but loses the pool's cache/network locality and costs minutes. |
| Move **only `publish`** to GitHub-hosted | **Attractive.** The publish job is short, needs no repo cache, and runs no branch code — running it on `ubuntu-latest` removes the shared host from the PAT path entirely. Proposed as step 2 of the rollout. |

## Rollout

1. Land (A) + (B) behind a kill switch (`AUTOFIX_SPLIT_PUBLISH_DISABLED`), with
`publish` still on the self-hosted pool.
2. Move `publish` to `ubuntu-latest`.
3. Replace the marker sweeps with (D).
4. Ephemeral registrations for `execute` (C) — infra ticket.
5. Delete the machinery (E) that steps 1–4 make redundant, in one PR per
cluster so each deletion is reviewable on its own.

## Test plan

- **Contract (static, mutation-tested):** the `publish` job's step list contains
no `npm`, no `run: .*scripts/`, no checkout of the PR head, and no step that
sources anything from the artifact other than `fix.patch`.
- **Negative control:** a patch touching `.github/workflows/**` is refused by
the footprint check; a patch exceeding the growth budget is refused.
- **Probe — forged output:** during `execute`, append `outcome=fixed` to
`$GITHUB_OUTPUT` from a background process; assert `publish`'s decision is
unchanged.
- **Probe — hostile patch:** a patch that adds `.git/hooks/pre-applypatch` and
a `core.hooksPath` change; assert no hook executes in `publish`.
- **Probe — lineage kill (D):** start a grandchild that `exec`s with a forged
`AUTOFIX_AGENT_TREE`; assert the cgroup kill reaps the real tree and spares
the forger, and that the gate step survives its own sweep.

## Risks and open questions

- "Verified" weakens to "deterministically checked + advisory build/test". This
must be visible in the PR status comment, not buried.
- Artifact hand-off adds one upload/download per round and a second checkout.
- Does the org permit `--ephemeral` registrations on the ECS pool?
- Is a GitHub-hosted runner acceptable for the PAT-bearing `publish` job under
the current egress/IP policy?
- `issue-autofix` has the same shape and should follow the same split, but it
creates a branch and a PR rather than pushing to an existing head; its
publish-side checks differ and are not designed here.

<details>
<summary>中文说明</summary>

# autofix:为携带 PAT 的步骤做 runner 级隔离

取代 PR #9214 中尝试的"步骤内加固"方案(该 PR 已冻结)。对应 issue #9089 的结构性收口,发现清单见 issue #9524。

## 问题

`review-address` 与 `issue-autofix` 在**同一个 job**里既执行**不可信的分支代码**(agent、构建、测试),又持有 `CI_DEV_BOT_PAT`(`Push and report`),且运行在常驻自建池上——同一 OS 用户、~27 个 runner 注册共享一个 `HOME`。后续步骤所信任的一切(`$GITHUB_ENV`、`$GITHUB_OUTPUT`、`$RUNNER_TEMP`、`$HOME`、工作区 `.git`、存活进程)都可被先前步骤的代码或同 uid 的幸存进程写入。

### #9214 证明了什么

1. **该路线在这个规模上自我挫败:** 11 轮评审把 diff 从 329 行推到 2700 行,每轮 Critical 数量为 8 → 9 → 15 → 19 → 29;第 10、11 轮没有任何代码改动,第 11 轮仍在逐字节相同的文件上产生 14 个新 Critical,其中 6 条的形态是"这个 diff 新加的地方,缺少这个 diff 在别处安装的纪律"。
2. **把执行方装进容器无济于事,因为信任根在容器之外**(R11-8):整条摘要链的根是 `steps.stage.outputs.*`,而 runner 在步骤**结束时**以 last-wins 方式解析位于可写 `$RUNNER_TEMP` 下的 `$GITHUB_OUTPUT`,且暂存步骤之前没有任何清扫。同 uid 的进程可以同时替换磁盘字节并追加伪造的摘要行,使下游比较的两侧都被操控。

此外该 wrapper 合入后根本无法运行(R9-1、R9-2),而 CI 保持全绿只是因为 pre-merge 的 `-z` 跳过路径把它挡住了。

## 设计原则

> 执行过分支代码的 job 绝不持有 PAT,也绝不能影响持有 PAT 的 job 的**输入**。

边界从"步骤内部"上移到"job 之间",两者之间只经由一个产物传递,且消费方**自行重新推导**结论而不是信任它。

## 方案

- **A. 拆成 `execute` 与 `publish` 两个 job。** `execute` 承载一切分支代码,不持有任何写权限凭据,只产出 `fix.patch` + 报告 + 仅供参考的 `verdict.txt`;`publish` 全新检出基线分支,**不执行任何分支代码**(无 npm、无构建、无测试、无仓库脚本、无 git hook),下载产物、自行判定、应用补丁并用 PAT 推送。`$GITHUB_ENV`/`$GITHUB_OUTPUT`/`$HOME`/`$RUNNER_TEMP` 与存活进程都不跨 job 边界。
- **B. `publish` 自行推导,不信任 verdict。** 威胁模型中 `execute` 被假定为已失陷,因此对其 verdict 的任何签名都无济于事。`publish` 只做无需执行分支代码的判定:补丁能否干净应用于记录的基线 SHA、路径是否在足迹白名单内、是否触碰 `.github/workflows/**` 与锁文件、净增行是否在增长预算内、提交信息是否符合规范、head SHA 是否仍匹配租约。构建与测试留在 `execute`,且**降级为仅供参考**——这一点必须写进状态评论。诚实的对比基准不是"原本可用的保证":现有 verdict 本就可伪造(R11-8),而在 #9214 分支上它根本没被产生过(R9-1/R9-2)。
- **C. `execute` 使用一次性注册(`--ephemeral`)**,或退而求其次用 job 级 `container:` 提供独立 `HOME`;后者是缓解,前者才是收口。属基础设施改动,排在最后。
- **D. 按血缘杀进程树,而不是按 env 标记。** 用 `systemd-run --scope` / cgroup 取代 `AUTOFIX_AGENT_TREE` 标记匹配(R8-8、R9-3、R11-10 的来源):cgroup 归属无法由环境变量伪造,跨 leg 误杀与自杀都不再可能。
- **E. 删掉 A–D 让其失去意义的机制。** 本项工作的目的是**更小**的信任面,而不是再加一层。PAT 离开共享主机、判定移到可信侧之后,步骤内的枚举式机制(env 固定块、暂存脚本摘要链、指纹集、标记清扫及其契约测试)守护的是一个已不再承载秘密的边界,删除它属于本次改动的一部分——"以防万一"留下的守卫正是 #9214 里不断再生发现的东西。

## 备选方案

步骤内加固(#9214):否决,已实测不收敛且信任根在步骤之外。仅对 verdict 签名:否决,签的是失陷方的断言。整个 job 迁到 GitHub 托管 runner:能彻底关闭该类问题,但损失缓存与网络就近性且有成本。**仅把 `publish` 迁到 GitHub 托管**:可取——该 job 很短、不需要仓库缓存、不执行分支代码,这样 PAT 路径上就不再有共享主机;列为推进步骤 2。

## 推进顺序

1. 带 kill switch 落地 A + B(`publish` 仍在自建池);2. 把 `publish` 迁到 `ubuntu-latest`;3. 用 D 取代标记清扫;4. 为 `execute` 启用一次性注册(C,基础设施工单);5. 按簇分 PR 删除 1–4 让其冗余的机制(E)。

## 测试计划

静态契约(做变异测试):`publish` 的步骤列表中不得出现 `npm`、`run: .*scripts/`、检出 PR head,或读取 `fix.patch` 以外的产物内容。负向对照:触碰 `.github/workflows/**` 或超出增长预算的补丁必须被拒。探针:在 `execute` 中由后台进程向 `$GITHUB_OUTPUT` 追加 `outcome=fixed`,断言 `publish` 的判定不变;构造包含 `.git/hooks` 与 `core.hooksPath` 的恶意补丁,断言 `publish` 中没有 hook 被执行;伪造 `AUTOFIX_AGENT_TREE` 的孙进程,断言 cgroup 清杀命中真实进程树、放过伪造者,且门步骤不会杀死自己。

## 风险与未决问题

"已验证"会弱化为"确定性检查 + 仅供参考的构建/测试",必须在状态评论中明示;产物传递增加一次上传/下载与一次检出;ECS 池是否允许 `--ephemeral` 注册;当前出网/IP 策略下,携带 PAT 的 `publish` 能否放在 GitHub 托管 runner 上;`issue-autofix` 形态相同但它是新建分支并开 PR,其发布侧判定不同,本文未涵盖。

</details>
Loading