feat(git): forbid the agent merge path from merging into master

pr_merge (the gateway path a cell PM uses to merge a leaf/cell PR up the
hierarchy) accepted any target, including a repo's default branch — the
hole that let cell completion land on master. It now refuses any target
equal to the project's default branch with a CEO_ONLY error: a root→master
PR is merged solely by the CEO via approve-&-merge (merge_pr_for_task,
already CEO-gated from awaiting_ceo_approval). Agents open the master PR
and escalate; they never merge it.

Belt-and-suspenders to the integration-branch routing: even if a target
ever resolved to master, this blocks the merge at the GitHub-API boundary.
This commit is contained in:
Renn F
2026-06-04 05:55:03 +02:00
parent 6cce556536
commit 3cfac2e503
2 changed files with 47 additions and 2 deletions
+17
View File
@@ -2177,6 +2177,23 @@ class GitService(BaseService):
git_token = await self._get_project_token_or_raise(project.slug) git_token = await self._get_project_token_or_raise(project.slug)
owner, repo = self._parse_github_remote(workspace) owner, repo = self._parse_github_remote(workspace)
# CEO is the only one who merges to master. This agent-facing merge path
# (a cell PM merging a leaf/cell PR up the chain) may NEVER target a
# repo's default branch — a root→master PR is merged solely by the CEO
# via approve-&-merge (merge_pr_for_task, CEO-gated from
# awaiting_ceo_approval). Agents open the master PR and escalate.
default_branch = await self._project_default_branch(project.slug)
if target == default_branch:
raise UnauthorizedError(
action="pr_merge",
reason=(
"CEO_ONLY: merging into the default branch "
f"('{default_branch}') is reserved for the CEO via "
"approve-&-merge from awaiting_ceo_approval. Open the PR "
"and escalate; agents never merge to master."
),
)
parent_id = UUID(str(task.parent_task_id)) if task.parent_task_id else None parent_id = UUID(str(task.parent_task_id)) if task.parent_task_id else None
await self._lock_parent_task_for_merge(parent_id) await self._lock_parent_task_for_merge(parent_id)
+30 -2
View File
@@ -15,7 +15,7 @@ from uuid import uuid4
import pytest import pytest
from roboco.api.schemas.git import GitCreateBranchRequest from roboco.api.schemas.git import GitCreateBranchRequest
from roboco.config import settings from roboco.config import settings
from roboco.services.base import NotFoundError from roboco.services.base import NotFoundError, UnauthorizedError
from roboco.services.git import GitService from roboco.services.git import GitService
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -245,12 +245,40 @@ async def test_pr_merge_returns_merge_commit_dict() -> None:
_bind(svc, "_call_merge_api", AsyncMock(return_value=fake_resp)) _bind(svc, "_call_merge_api", AsyncMock(return_value=fake_resp))
_bind(svc, "_delete_pr_branch_best_effort", AsyncMock()) _bind(svc, "_delete_pr_branch_best_effort", AsyncMock())
_bind(svc, "_sync_target_branch", AsyncMock(return_value="abc123sha")) _bind(svc, "_sync_target_branch", AsyncMock(return_value="abc123sha"))
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
# Merges flow UP the chain (cell -> Main-PM branch), never into master via
# this agent path — target is the integration branch, not the default branch.
with _patch_project_service(fake_project): with _patch_project_service(fake_project):
out = await svc.pr_merge(11, target="master") out = await svc.pr_merge(11, target="feature/main_pm/root1234")
assert out == {"merge_commit_sha": "abc123sha"} assert out == {"merge_commit_sha": "abc123sha"}
@pytest.mark.asyncio
async def test_pr_merge_into_default_branch_is_ceo_only() -> None:
"""The agent merge path refuses to merge into a repo's default branch."""
fake_task = MagicMock(project_id=uuid4(), parent_task_id=None, assigned_to=uuid4())
fake_project = MagicMock(slug="roboco")
result = MagicMock()
result.scalar_one_or_none.return_value = fake_task
svc = _service(execute_returns=result)
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
merge_api = AsyncMock()
_bind(svc, "_call_merge_api", merge_api)
with (
_patch_project_service(fake_project),
pytest.raises(UnauthorizedError, match="CEO_ONLY"),
):
await svc.pr_merge(11, target="master")
# Guard fires before any GitHub merge call.
merge_api.assert_not_called()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# commit: stages + commits a large changeset with the longer git timeout # commit: stages + commits a large changeset with the longer git timeout
# (issue #13 — the panel commit verb timed out on the 30s default budget). # (issue #13 — the panel commit verb timed out on the 30s default budget).