Parent: #1936
Problem
proposeAction() (src/mcp/server.ts:2442-2466) builds the pending-action params from only label, reviewBody, mergeMethod, and closeComment — it never fetches or records the PR's live head SHA before staging. Compare to the webhook/sweep staging path (stageForApproval/actionParams, src/services/agent-action-executor.ts:227-256), which always persists action.expectedHeadSha from the reviewed head. Every action staged through the MCP tool has params.expectedHeadSha === undefined.
Failure scenario: a maintainer calls gittensory_propose_action to stage a merge on a PR. The contributor force-pushes different code. When the action is later accepted (via REST or gittensory_decide_pending_action), the supersede check in decidePendingAgentAction (src/services/agent-approval-queue.ts:44-45) short-circuits false because stagedHead is undefined — the "superseded — force-push after staging" rejection never fires. The executor's own freshness fallback then trivially matches whatever is live at accept time, so unreviewed force-pushed code merges with no freshness rejection at all — exactly the threat the guard's own code comments say it exists to stop.
Requirements
- Every action staged via MCP must be pinned to the head SHA that was actually reviewed at proposal time, matching the invariant the webhook/sweep path already upholds.
Deliverables
- In
proposeAction(), fetch the PR's live head via getPullRequest before building params, and include expectedHeadSha: pr?.headSha in the staged params (or reject the proposal if the PR can't be fetched).
- Add a regression test: stage an action via
proposeAction, force-push the PR, accept — assert the accept is superseded/rejected, not executed.
Acceptance criteria
- An MCP-staged action force-pushed after staging is rejected on accept, matching webhook/sweep-staged actions.
- MCP-staged actions accepted with the head unchanged still execute normally.
Expected outcome
MCP-originated staged actions get the same force-push protection every other staging path already has.
Parent: #1936
Problem
proposeAction()(src/mcp/server.ts:2442-2466) builds the pending-action params from onlylabel,reviewBody,mergeMethod, andcloseComment— it never fetches or records the PR's live head SHA before staging. Compare to the webhook/sweep staging path (stageForApproval/actionParams,src/services/agent-action-executor.ts:227-256), which always persistsaction.expectedHeadShafrom the reviewed head. Every action staged through the MCP tool hasparams.expectedHeadSha === undefined.Failure scenario: a maintainer calls
gittensory_propose_actionto stage a merge on a PR. The contributor force-pushes different code. When the action is later accepted (via REST orgittensory_decide_pending_action), the supersede check indecidePendingAgentAction(src/services/agent-approval-queue.ts:44-45) short-circuits false becausestagedHeadis undefined — the "superseded — force-push after staging" rejection never fires. The executor's own freshness fallback then trivially matches whatever is live at accept time, so unreviewed force-pushed code merges with no freshness rejection at all — exactly the threat the guard's own code comments say it exists to stop.Requirements
Deliverables
proposeAction(), fetch the PR's live head viagetPullRequestbefore buildingparams, and includeexpectedHeadSha: pr?.headShain the staged params (or reject the proposal if the PR can't be fetched).proposeAction, force-push the PR, accept — assert the accept is superseded/rejected, not executed.Acceptance criteria
Expected outcome
MCP-originated staged actions get the same force-push protection every other staging path already has.