mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user