Files
roboco/tests/unit/enforcement/test_a2a_access.py
T
73c05cfa5e feat(a2a): CEO can DM the Auditor and PR reviewers (#623)
* feat(a2a): CEO can DM the Auditor and PR reviewers

A mid-flight PR reviewer or Auditor that's stuck was unreachable — the CEO
had no way to DM them. Both roles now carry dm/read_a2a, so the CEO can open
a 1:1 and they can reply in-thread through the existing CEO-reply path.

Scoped deliberately: the Auditor stays a silent observer to its peers — it
gains no peer-initiation surface (can_a2a_direct routes it through
_check_auditor_a2a, which refuses every initiation target; it can only reply
inside a CEO-opened DM). PR reviewers keep their owning-PM scope. Intake and
Secretary stay excluded — they have their own dedicated chat pages.

NO_COMMS_ROLES drops to {prompter, secretary}; the panel's EXCLUDE_NON_DM_ROLES
matches. KB/docs updated so the 'auditor/pr_reviewer have no dm' claim isn't
left stale.

* test(a2a): smoke guard checks _NO_COMMS_ROLES, not a hardcoded 'auditor'

The dm() runtime guard no longer names the auditor (it now carries dm to
reply to the CEO); it refuses the canonical _NO_COMMS_ROLES set. Assert on
that set so the smoke test tracks the guard, not a stale role name.

* chore(foundation): regenerate verb tables for auditor/pr_reviewer dm+read_a2a

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-21 05:54:29 +02:00

131 lines
4.8 KiB
Python

"""enforcement.a2a_access coverage."""
from __future__ import annotations
import pytest
from roboco.agents_config import can_a2a_direct
from roboco.enforcement.a2a_access import (
A2AAccessDeniedError,
get_a2a_allowed_targets,
validate_a2a_access,
)
from roboco.foundation.policy.communications import NO_COMMS_ROLES
def test_validate_a2a_self_a2a_denied() -> None:
with pytest.raises(A2AAccessDeniedError, match="cannot A2A yourself"):
validate_a2a_access("be-dev-1", "be-dev-1")
def test_validate_a2a_to_ceo_denied() -> None:
with pytest.raises(A2AAccessDeniedError):
validate_a2a_access("be-dev-1", "ceo")
def test_validate_a2a_within_cell() -> None:
"""Cell members can A2A within their cell."""
result = validate_a2a_access("be-dev-1", "be-qa")
assert result is True
def test_a2a_access_denied_error_has_attributes() -> None:
err = A2AAccessDeniedError(
from_agent="be-dev-1",
to_agent="ceo",
reason="CEO is human",
)
assert err.from_agent == "be-dev-1"
assert err.to_agent == "ceo"
def test_get_a2a_allowed_targets_returns_list() -> None:
targets = get_a2a_allowed_targets("be-dev-1", ["be-qa", "be-pm", "fe-dev-1", "ceo"])
assert isinstance(targets, list)
def test_get_a2a_allowed_targets_excludes_ceo() -> None:
targets = get_a2a_allowed_targets("be-dev-1", ["ceo"])
assert "ceo" not in targets
def test_get_a2a_allowed_targets_excludes_self() -> None:
targets = get_a2a_allowed_targets("be-dev-1", ["be-dev-1", "be-qa"])
# Self should be filtered.
assert "be-dev-1" not in targets or "be-qa" in targets
# ---------------------------------------------------------------------------
# CEO-initiated A2A — the one asymmetric rule: CEO may send, nobody may
# target CEO (the block above must still hold).
# ---------------------------------------------------------------------------
def test_validate_a2a_access_ceo_to_agent_allowed() -> None:
result = validate_a2a_access("ceo", "be-dev-1")
assert result is True
def test_can_a2a_direct_ceo_to_main_pm() -> None:
assert can_a2a_direct("ceo", "main-pm") == (True, None)
def test_can_a2a_direct_ceo_to_board_member() -> None:
"""Board members are normally unreachable via direct A2A for everyone
else (routed through main-pm) — CEO is exempt from that restriction."""
assert can_a2a_direct("ceo", "product-owner") == (True, None)
def test_validate_a2a_to_ceo_still_denied_with_ceo_send_rule() -> None:
"""Regression: allowing CEO-initiated A2A must not loosen the inbound
block — nobody may target the CEO."""
with pytest.raises(A2AAccessDeniedError):
validate_a2a_access("be-dev-1", "ceo")
def test_get_a2a_allowed_targets_ceo_includes_all_roles() -> None:
targets = get_a2a_allowed_targets("ceo", ["be-dev-1", "be-qa", "main-pm"])
assert set(targets) == {"be-dev-1", "be-qa", "main-pm"}
def test_can_a2a_direct_to_ceo_message_explains_reply_only() -> None:
"""An agent can never INITIATE with the CEO (only reply inside a
conversation the CEO opened) — the matrix denial message must say so,
not point at the old blanket 'use notify()' framing."""
allowed, reason = can_a2a_direct("be-dev-1", "ceo")
assert allowed is False
assert reason is not None
assert "reply" in reason.lower()
@pytest.mark.parametrize(
"target_slug",
["intake-1", "secretary-1"],
)
def test_can_a2a_direct_ceo_to_no_comms_role_denied(target_slug: str) -> None:
"""The CEO's asymmetric reach still can't target a role with no dm/
read_a2a on its manifest (prompter, secretary — human-only, own chat
pages) — nothing on the other end could ever read or answer the DM."""
allowed, reason = can_a2a_direct("ceo", target_slug)
assert allowed is False
assert reason is not None
assert "comms" in reason.lower()
@pytest.mark.parametrize("target_slug", ["auditor", "pr-reviewer-1"])
def test_can_a2a_direct_ceo_to_auditor_or_pr_reviewer_allowed(target_slug: str) -> None:
"""The CEO can now DM a mid-flight auditor or PR reviewer — both carry
dm/read_a2a so they can read and reply in-thread, even though neither
gains a peer-initiation surface (auditor stays silent via
can_a2a_direct; the PR reviewer stays scoped to its owning PM)."""
allowed, reason = can_a2a_direct("ceo", target_slug)
assert allowed is True
assert reason is None
def test_can_a2a_direct_ceo_to_no_comms_role_reuses_canonical_set() -> None:
"""The refusal set must be exactly foundation.policy.communications'
NO_COMMS_ROLES — the same set services.gateway.content_actions uses to
gate the dm() sender side — so the two never drift apart."""
expected = {"prompter", "secretary"}
assert {role.value for role in NO_COMMS_ROLES} == expected