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)
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
await self._lock_parent_task_for_merge(parent_id)
+30 -2
View File
@@ -15,7 +15,7 @@ from uuid import uuid4
import pytest
from roboco.api.schemas.git import GitCreateBranchRequest
from roboco.config import settings
from roboco.services.base import NotFoundError
from roboco.services.base import NotFoundError, UnauthorizedError
from roboco.services.git import GitService
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, "_delete_pr_branch_best_effort", AsyncMock())
_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):
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"}
@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
# (issue #13 — the panel commit verb timed out on the 30s default budget).