fix(messaging): restore channel-access RBAC in gateway say()

post_to_channel was calling send_message without agent_slug, which
disabled the validate_channel_access check. Forward the slug; convert
ChannelAccessDeniedError to a friendly not_authorized Envelope with
the agent's writable-channel list (pre-gateway behaviour).
This commit is contained in:
Renn F
2026-05-03 05:37:12 +02:00
parent 92294f90fb
commit 7907988e52
3 changed files with 131 additions and 13 deletions
@@ -0,0 +1,92 @@
"""ContentActions.say must enforce channel write-access via send_message.
The gateway path (post_to_channel -> send_message) historically called
send_message WITHOUT agent_slug, which bypassed validate_channel_access.
Pre-gateway returned a friendly ChannelAccessDeniedError listing the
agent's writable channels. These tests pin the restored behaviour:
1. say() converts ChannelAccessDeniedError into a not_authorized Envelope
that names the offending channel and includes a remediation hint
listing the writable channels for the agent's role.
"""
from __future__ import annotations
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from roboco.enforcement.channel_access import ChannelAccessDeniedError
from roboco.services.gateway.content_actions import (
ContentActions,
ContentActionsDeps,
)
def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
"""Mirrors test_content_actions._make_deps."""
if "task" in overrides:
task = overrides["task"]
else:
task = AsyncMock()
task.get_active_task_for_agent.return_value = None
git = overrides.get("git", AsyncMock())
messaging = overrides.get("messaging", AsyncMock())
a2a = overrides.get("a2a", AsyncMock())
journal = overrides.get("journal", AsyncMock())
workspace = overrides.get("workspace", AsyncMock())
return ContentActionsDeps(
task=task,
git=git,
messaging=messaging,
a2a=a2a,
journal=journal,
workspace=workspace,
)
@pytest.mark.asyncio
async def test_say_returns_not_authorized_envelope_on_access_denied() -> None:
"""When messaging.post_to_channel raises ChannelAccessDeniedError, say()
converts it into a not_authorized Envelope with a writable-channels hint.
"""
aid = uuid4()
msg_svc = AsyncMock()
msg_svc.post_to_channel.side_effect = ChannelAccessDeniedError(
agent_id="be-dev-1",
channel_slug="announcements",
action="write",
)
deps = _make_deps(messaging=msg_svc)
actions = ContentActions(deps)
env = await actions.say(agent_id=aid, channel="announcements", text="hi")
body = env.as_dict()
assert body["error"] == "not_authorized"
assert "announcements" in body["message"]
# Remediation should hint at the agent's writable channels (or that
# they have none). The role/list is resolved from CHANNEL_ACCESS via
# get_agent_channels using the agent's slug.
assert body["remediate"] is not None
assert "writable" in body["remediate"].lower()
@pytest.mark.asyncio
async def test_say_success_path_returns_posted_envelope() -> None:
"""Sanity: when messaging accepts the post, say() returns the
standard posted/continue Envelope (no regression in the happy path).
"""
aid = uuid4()
msg_svc = AsyncMock()
deps = _make_deps(messaging=msg_svc)
actions = ContentActions(deps)
env = await actions.say(agent_id=aid, channel="dev-all", text="hello")
body = env.as_dict()
assert body["error"] is None
assert body["status"] == "posted"
assert body["next"] == "continue"
msg_svc.post_to_channel.assert_awaited_once()