"""Auditor is silent โ€” runtime guard refuses say()/dm(). Spec ยง5.5: the auditor is a silent observer. The spawn manifest already omits `say` and `dm` from the auditor's tool surface, but that is a convention-only defense. These tests pin a defense-in-depth runtime guard inside ContentActions.say/dm: if the caller's role is "auditor", the verb refuses with Envelope.not_authorized regardless of how the call arrived. """ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 import pytest from roboco.services.gateway.content_actions import ( ContentActions, ContentActionsDeps, ) def _make_deps(agent_role: str, **overrides: AsyncMock) -> ContentActionsDeps: """Build a ContentActionsDeps whose task.agent_for returns the given role.""" if "task" in overrides: task = overrides["task"] else: task = AsyncMock() task.get_active_task_for_agent.return_value = None task.agent_for.return_value = MagicMock(role=agent_role) 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()) notifications = overrides.get("notifications", AsyncMock()) return ContentActionsDeps( task=task, git=git, messaging=messaging, a2a=a2a, journal=journal, workspace=workspace, notifications=notifications, ) @pytest.mark.asyncio async def test_auditor_say_returns_not_authorized() -> None: """Auditor role calling say() is refused regardless of manifest.""" auditor_id = uuid4() deps = _make_deps("auditor") actions = ContentActions(deps) env = await actions.say(agent_id=auditor_id, channel="backend-cell", text="hi") body = env.as_dict() assert body["error"] == "not_authorized" haystack = (body.get("message") or "") + " " + (body.get("remediate") or "") assert "silent" in haystack.lower() or "auditor" in haystack.lower() # The messaging service must not have been touched โ€” the guard fires # before any downstream call. deps.messaging.post_to_channel.assert_not_called() @pytest.mark.asyncio async def test_auditor_dm_returns_not_authorized() -> None: """Auditor role calling dm() is refused regardless of manifest.""" auditor_id = uuid4() deps = _make_deps("auditor") actions = ContentActions(deps) env = await actions.dm( agent_id=auditor_id, recipient=str(uuid4()), text="hi", task_id=uuid4(), ) body = env.as_dict() assert body["error"] == "not_authorized" haystack = (body.get("message") or "") + " " + (body.get("remediate") or "") assert "silent" in haystack.lower() or "auditor" in haystack.lower() deps.a2a.send.assert_not_called() @pytest.mark.asyncio async def test_developer_say_passes_auditor_guard() -> None: """Non-auditor roles are not blocked by the new guard. The messaging mock returns None so downstream flow is whatever the happy path is โ€” we only assert that the auditor guard didn't fire (i.e. the response is not the auditor-silent not_authorized envelope). """ dev_id = uuid4() deps = _make_deps("developer") actions = ContentActions(deps) env = await actions.say(agent_id=dev_id, channel="backend-cell", text="hi") body = env.as_dict() # The auditor-silent message is what we explicitly want to NOT see. if body.get("error") == "not_authorized": haystack = (body.get("message") or "") + " " + (body.get("remediate") or "") assert "silent" not in haystack.lower() @pytest.mark.asyncio async def test_developer_dm_passes_auditor_guard() -> None: """dm() for a non-auditor role is not blocked by the new guard.""" dev_id = uuid4() deps = _make_deps("developer") actions = ContentActions(deps) env = await actions.dm( agent_id=dev_id, recipient=str(uuid4()), text="hi", task_id=uuid4(), ) body = env.as_dict() if body.get("error") == "not_authorized": haystack = (body.get("message") or "") + " " + (body.get("remediate") or "") assert "silent" not in haystack.lower()