fix(gateway): role-gate ContentActions.commit (developers + documenters only)

Smoke 2026-05-03 saw main-pm reach the git layer with a 'commit' call,
trying to author 'fix(gateway): allow claimed status in i_will_plan
preflight'. That should never have been possible — main_pm/cell_pm/
board/auditor/qa manifests all exclude commit. Reaching the verb body
means either the MCP manifest filter mis-routed, or the agent hit the
v2 do.py route directly.

Mirror Task 16 notify pattern: server-side role check in the verb body
rejects with not_authorized + 'PMs delegate, do not commit' remediate.
Defense-in-depth — manifest is still the primary gate.
This commit is contained in:
Renn F
2026-05-03 22:57:28 +02:00
parent ee743ffc7a
commit c61a3bf81e
4 changed files with 38 additions and 0 deletions
@@ -68,6 +68,11 @@ _NOTIFY_ALLOWED_ROLES: frozenset[str] = frozenset(
)
_VALID_NOTIFY_PRIORITIES: frozenset[str] = frozenset({"normal", "high", "urgent"})
# Only roles whose manifest includes "commit" should reach the verb body.
# Server-side gate is defense-in-depth in case the MCP manifest filter ever
# misroutes the call (smoke 2026-05-03 saw main-pm hit the git layer).
_COMMIT_ALLOWED_ROLES: frozenset[str] = frozenset({"developer", "documenter"})
class ContentActions:
def __init__(self, deps: ContentActionsDeps) -> None:
@@ -113,6 +118,21 @@ class ContentActions:
Auto-prefixes [task-id], validates message via commit_validator,
records progress entry from the commit message.
"""
agent = await self.task.agent_for(agent_id)
caller_role = agent.role if agent is not None else None
if caller_role not in _COMMIT_ALLOWED_ROLES:
return Envelope.not_authorized(
message=(
f"role '{caller_role}' may not commit code; only"
" developers and documenters write commits"
),
remediate=(
"PMs delegate code work via delegate(); board members"
" do not write code. If you intended to record an"
" observation, use note() instead."
),
context_briefing={},
)
subject = _strip_task_prefix(message).strip()
result = validate_commit_message(subject)
if not result.ok:
+5
View File
@@ -23,6 +23,11 @@ def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
task = AsyncMock()
task.get_active_task_for_agent.return_value = None
# commit() now checks caller role; default to developer.
from unittest.mock import MagicMock
task.agent_for.return_value = MagicMock(role="developer")
if "git" in overrides:
git = overrides["git"]
else:
@@ -18,6 +18,14 @@ def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
task = AsyncMock()
task.get_active_task_for_agent.return_value = None
# commit() now checks the caller's role server-side. Default the
# mock's agent_for to a developer so existing commit tests still
# exercise the success path; tests asserting the role gate should
# override task.agent_for AFTER _make_deps returns.
from unittest.mock import MagicMock
task.agent_for.return_value = MagicMock(role="developer")
if "git" in overrides:
git = overrides["git"]
else:
@@ -28,6 +28,11 @@ def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
task = AsyncMock()
task.get_active_task_for_agent.return_value = None
# commit() now checks caller role; default to developer.
from unittest.mock import MagicMock
task.agent_for.return_value = MagicMock(role="developer")
if "git" in overrides:
git = overrides["git"]
else: