fix(security): active guard enforcement, CEO A2A target check, notification expiry (#595)

* fix(security): guard goes active; CEO A2A respects no-comms roles; ack notifications expire

ROBOCO_GUARD_PASSIVE_MODE defaults to false in both compose files — the
deferred post-calibration flip; fail_secure stays off and the env override
remains the rollback. can_a2a_direct no longer short-circuits the CEO past
the no-comms set (auditor/pr_reviewer/prompter/secretary), now canonical
in foundation.policy.communications.NO_COMMS_ROLES and shared with the
content-actions gate; the A2A service refuses at conversation creation
instead of silently suppressing the wake. Ack-required notifications get
expires_at stamped from ROBOCO_NOTIFICATION_ACK_TTL_HOURS (default 48,
0 disables), so the re-escalation sweeper's expires_at query matches rows
for the first time.

* refactor(notification): extract _ack_and_expiry — xenon rank back under B

The expires_at stamping pushed _create_notification_with_session to
rank C; the requires_ack + expiry derivation moves into a helper with
the same semantics and comments.

* test(conftest): dispose the global DB engine after every test

Production code reaching get_db_context()/get_engine() lazily creates the
process-global engine bound to the current event loop; with per-test
function-scoped loops, any later test touching the global path inherits a
dead-loop engine and dies with 'Future attached to a different loop' —
the order-dependent class that has been wandering the suite (cloud_auth
login, metrics, tasks-routes, full-lifecycle) whenever collection order
shifts. An autouse fixture now close_db()s after every test, keeping the
global path loop-local; no-op when untouched.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-19 18:46:44 +02:00
committed by GitHub
co-authored by Renn F
parent 5b27a443e9
commit fc41dfa40e
18 changed files with 445 additions and 65 deletions
+70
View File
@@ -9,6 +9,7 @@ without spinning up a Postgres + Redis stack.
from __future__ import annotations
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
@@ -17,6 +18,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID, uuid4
import pytest
from roboco.config import settings
from roboco.foundation.policy.communications import ACK_REQUIRED_BY_TYPE
from roboco.models import NotificationPriority, NotificationType
from roboco.models.notification import CreateNotificationParams
@@ -414,6 +416,74 @@ async def test_create_notification_requires_ack_derives_from_type(
)
# ---------------------------------------------------------------------------
# expires_at stamping (notification_ack_ttl_hours) — feeds
# NotificationDeliveryService.sweep_expired_notifications' re-escalation.
# Column existed but was never written, so the sweep query always matched
# zero rows.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ack_required_notification_gets_expires_at(
svc: NotificationService,
) -> None:
"""An ack-required row (BLOCKER_ESCALATION) is stamped expires_at ~=
now + notification_ack_ttl_hours."""
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
before = datetime.now(UTC)
with _patch_db_context(db):
await svc.send_blocker_notification(
task_id="t1", blocker_reason="r", from_agent="system", to_pm="cell-pm"
)
after = datetime.now(UTC)
rows = [r for r in db.added if r.type == NotificationType.BLOCKER_ESCALATION]
assert rows
expires_at = rows[0].expires_at
assert expires_at is not None
ttl = timedelta(hours=settings.notification_ack_ttl_hours)
assert before + ttl <= expires_at <= after + ttl
@pytest.mark.asyncio
async def test_informational_notification_gets_no_expires_at(
svc: NotificationService,
) -> None:
"""A non-ack-required row (REVIEW_REQUEST) never gets a deadline — the
sweep only ever re-escalates ack-required rows, so stamping one would be
dead weight."""
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc.send_qa_ready_notification(
task_id="t1", from_agent="be-dev-1", to_qa="be-qa"
)
rows = [r for r in db.added if r.type == NotificationType.REVIEW_REQUEST]
assert rows
assert rows[0].expires_at is None
@pytest.mark.asyncio
async def test_ack_required_notification_expires_at_disabled_by_zero_ttl(
svc: NotificationService,
) -> None:
"""notification_ack_ttl_hours=0 disables stamping entirely (legacy: NULL,
never expires) even for an ack-required type."""
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with (
patch("roboco.services.notification.settings.notification_ack_ttl_hours", 0),
_patch_db_context(db),
):
await svc.send_blocker_notification(
task_id="t1", blocker_reason="r", from_agent="system", to_pm="cell-pm"
)
rows = [r for r in db.added if r.type == NotificationType.BLOCKER_ESCALATION]
assert rows
assert rows[0].expires_at is None
# ---------------------------------------------------------------------------
# Coordination-event producers (reassignment / collision / unblock /
# dependency-revival / stale-claim-reaped)