fix: prevent cross-PR push from subagent-induced branch switch (#796)
* fix: prevent cross-PR push from subagent-induced branch switch A workflow_dispatch run for zed-industries/cloud (workflow run 26036155393) force-pushed the orchestrator's work onto an unrelated engineer's PR branch (origin/reactivate-pro-plan, PR #2582). The orchestrator's reviewfrog subagent called checkout_pr({pull_number: 2582}), which (1) moved the shared working tree to pr-2582 and (2) persisted pushDest pointing at reactivate-pro-plan. The orchestrator's subsequent commit + push_branch then clobbered the victim PR. Recovery + disclosure in PR #2584. Three compounding bugs closed here: 1. checkout_pr dirty-tree guard had a first-call hole: the previous condition required ctx.toolState.issueNumber to already be set, so on workflow_dispatch runs the first checkout_pr (commonly from a subagent) bypassed the guard entirely. Now any PR switch with a dirty tree is refused, including the first switch of a run. Idempotent same-PR re-checkouts are still absorbed by alreadyOnBranch inside checkoutPrBranch. 2. push_branch trusted sticky pushDest blindly. Added a backstop: refuse pushes where the local branch matches /^pr-(\d+)$/ AND pushDest.remoteBranch differs from it AND the current run is not scoped to PR N (event.is_pr === true && event.issue_number === N). This catches subagent-induced silent branch switches even if a future bug reintroduces a first-call hole in fix #1. 3. Build-mode self-review prompt told the orchestrator to ship "the output of git diff" to the reviewer. The model in this run synthesized git diff main...HEAD, which excludes uncommitted work — and Build self-review runs BEFORE the commit, so the reviewer saw an empty diff and thrashed, eventually calling checkout_pr on a random PR to find something to look at. Prompt now specifies git diff origin/<base-branch> (two-dot, no HEAD), which compares the working tree against the remote base. Refs: zed-industries/cloud workflow run 26036155393 zed-industries/cloud#2582 (victim) zed-industries/cloud#2584 (disclosure) * review: key dirty-tree guard on current branch + drop 'two-dot' misnomer Address review feedback on PR #796. 1. checkout_pr dirty-tree guard now keys off the live current branch (git rev-parse --abbrev-ref HEAD), not ctx.toolState.issueNumber. issueNumber is ALSO set by get_issue / get_issue_comments / get_issue_events, so a subagent doing get_issue(N) followed by checkout_pr(N) on a dirty tree would have bypassed the original guard (issueNumber === pull_number). The current branch is the actual primitive for "would this call move HEAD" — querying it directly avoids correlating on toolState that other tools write to. 2. modes.ts: drop the wrong "two-dot" label on git diff origin/<base>. That's the single-rev form, not two-dot. Copilot was right that the label was confusing/contradictory with the actually-shown command.
This commit is contained in:
committed by
pullfrog[bot]
parent
a0576a702a
commit
6c166ac1cc
+8
-10
@@ -805,16 +805,14 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
// refuse to clobber an active checkout if the working tree is dirty —
|
||||
// forces the agent to commit/push or discard before switching contexts
|
||||
// instead of silently overwriting uncommitted work. `issueNumber`
|
||||
// tracks any issue/PR the agent has touched (issues and PRs share
|
||||
// GitHub's number space); the guard fires only when the agent is
|
||||
// switching to a *different* number with a dirty tree, which captures
|
||||
// the legitimate "stop, you have unsaved work" case regardless of
|
||||
// whether the prior number was an issue or a PR.
|
||||
const current = ctx.toolState.issueNumber;
|
||||
if (current !== undefined && current !== pull_number) {
|
||||
// refuse to clobber an uncommitted tree whenever this call would move
|
||||
// HEAD away from the target pr-N branch. keyed off the live current
|
||||
// branch (not toolState.issueNumber, which is also written by
|
||||
// get_issue / get_issue_comments / get_issue_events and so doesn't
|
||||
// mean "currently checked out"). catches the subagent-sharing-cwd
|
||||
// case from zed-industries/cloud (2026-05-18).
|
||||
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }).trim();
|
||||
if (currentBranch !== `pr-${pull_number}`) {
|
||||
const dirty = $("git", ["status", "--porcelain"], { log: false }).trim();
|
||||
if (dirty) {
|
||||
throw new Error(
|
||||
|
||||
+22
@@ -253,6 +253,28 @@ export function PushBranchTool(ctx: ToolContext) {
|
||||
// validate push destination matches expected URL
|
||||
const pushDest = validatePushDestination(ctx, branch);
|
||||
|
||||
// backstop against subagent-induced cross-PR clobbers: a subagent
|
||||
// shares cwd + toolState with the orchestrator, so its `checkout_pr(N)`
|
||||
// moves HEAD to pr-N and persists pushDest pointing at the foreign
|
||||
// PR's remote branch. refuse pr-N → origin/<other> pushes unless this
|
||||
// run is itself scoped to PR N (zed-industries/cloud, 2026-05-18).
|
||||
const prBranchMatch = branch.match(/^pr-(\d+)$/);
|
||||
if (prBranchMatch && pushDest.remoteBranch !== branch) {
|
||||
const prNumber = Number(prBranchMatch[1]);
|
||||
const event = ctx.payload.event;
|
||||
const runScoped = event.is_pr === true && event.issue_number === prNumber;
|
||||
if (!runScoped) {
|
||||
throw new Error(
|
||||
`push blocked: local branch '${branch}' would push to '${pushDest.remoteName}/${pushDest.remoteBranch}', ` +
|
||||
`but this run is not scoped to PR #${prNumber}. ` +
|
||||
`the 'pr-${prNumber}' branch was created by a prior checkout_pr call (likely from a subagent — subagents share the working tree and toolState with the orchestrator). ` +
|
||||
`you have probably landed your commit on the wrong branch. ` +
|
||||
`switch to your own feature branch first (e.g. 'git checkout <feature-branch>') and then push. ` +
|
||||
`if the push to PR #${prNumber} is intentional, this run needs to be triggered against that PR.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// block pushes to default branch in restricted mode
|
||||
if (pushPermission === "restricted" && pushDest.remoteBranch === defaultBranch) {
|
||||
throw new Error(
|
||||
|
||||
Reference in New Issue
Block a user