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
+4 -2
View File
@@ -235,8 +235,10 @@ ROBOCO_DB_NETWORK_ISOLATED=true
# fastapi-guard HTTP security layer — v0.16.0
# =============================================================================
# Master switch + calibration knobs. Off by default; the NAS build compose
# arms it in PASSIVE (log-only) mode for false-positive review before
# enforcing. Not exposed on the panel's Feature Flags card (still calibrating).
# arms it in ACTIVE enforcement (passive/log-only calibration reviewed
# clean — see docs/rag/architecture/http-security-guard.md). Not exposed on
# the panel's Feature Flags card (compose/env-coupled, like
# ROBOCO_DB_NETWORK_ISOLATED).
# ROBOCO_GUARD_ENABLED=false
# Detect-and-log without blocking.
# ROBOCO_GUARD_PASSIVE_MODE=true
+3
View File
@@ -9,9 +9,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Security
- **Orchestrator API is no longer published on a routable host interface (GHSA-4f7g-w95g-5q2c).** Both deploy composes published the orchestrator's `:8000` on `0.0.0.0`, so anyone who could reach the host hit the control plane directly — bypassing nginx and, in the default header-trust posture (`ROBOCO_AGENT_AUTH_REQUIRED` unset, cloud auth off), reading/writing runtime settings and spoofing `X-Agent-Role: ceo` to spawn/stop agents with no credential. The publish is now bound to `127.0.0.1`; nginx reaches the API over the internal Docker network, so normal operation and on-host debugging are unchanged, while off-host access must go through nginx + cloud auth. The header-trust design itself is unchanged (it stays the deliberate local-no-login panel path); this closes the unintended off-host reachability that gave it teeth.
- **fastapi-guard flipped to active enforcement on the NAS build compose.** `ROBOCO_GUARD_PASSIVE_MODE`'s default flips from `true` to `false` in `docker-compose.yml`/`.yaml` (the registry compose already omits the guard trio and stays off) — the deliberately deferred step from the original guard build, now that passive-mode calibration reviewed clean and cloud auth + Tailscale are armed. A matching request is genuinely blocked now, not just logged; see `docs/rag/troubleshooting/blocked-http-requests.md` for the hygiene rules that stop legitimate agent traffic (e.g. quoting `ROBOCO_ENCRYPTION_KEY=<placeholder>`-shaped doc text, or "bypass/disable the guard" phrasing) from tripping it.
- **`can_a2a_direct` refuses a CEO DM to a no-comms-role target.** The CEO's asymmetric "may DM any agent" branch returned `True, None` unconditionally, bypassing the non-DM-role exclusion (auditor, pr_reviewer, prompter, secretary — none carry `dm`/`read_a2a` on their manifest) that the panel's New-DM dialog only enforced client-side. A conversation to one of these roles is now refused at `get_or_create_conversation` with a clear reason instead of silently creating an unreadable/unackable thread. The refusal set (`NO_COMMS_ROLES`) is now canonical in `roboco/foundation/policy/communications.py`, reused by both this check and the `dm()` sender-side guard in `content_actions.py` so the two can't drift apart.
### Fixed
- **Notification `expires_at` is now stamped at creation, so the ack-required re-escalation sweep can actually fire.** `NotificationService._create_notification` built every row without `expires_at`, so `NotificationDeliveryService.sweep_expired_notifications`'s `expires_at < now()` query always matched zero rows — every notification was effectively immortal, and a stuck ack-required notification never got re-escalated to the recipient's up-role. Ack-required rows are now stamped `now() + notification_ack_ttl_hours` (new setting, default 48h, `0` disables stamping); informational (non-ack-required) rows are left `NULL` since the sweep never touches them.
- **Python quality gate: restored green on roboco-api@slave (CI run 29653535468).** Two independent code-level bugs broke the 'Python quality gate' job, not a dependency pin (pydantic-settings was already correctly pinned at 2.14.2). ① `roboco/services/git.py`: `_delete_remote_branch_best_effort` was typed to return `bool` but its success path fell off the function end with no return statement, failing mypy's `missing-return-statement` check and silently returning `None` instead of the documented `True` — added the missing `return True`. ② `roboco/services/company_goals.py`: `CompanyGoalsService.upsert`'s six repetitive `if key in data: row.key = data[key]` branches pushed the module's average cyclomatic complexity past xenon's `--max-modules A` gate — refactored into a data-driven loop over a `_MUTABLE_FIELDS` tuple (behaviourally identical, now rank A). Verified by reproducing all three CI steps locally on the slave-branch worktree: `uv sync --extra dev`, `alembic upgrade head` (all 76 migrations apply cleanly), and `make quality` (13,510 tests, 94.56% coverage, all gates green).
## [0.25.0] - 2026-07-16
+5 -3
View File
@@ -35,9 +35,11 @@
# stays conservative; the build compose arms most of them ON for the
# personal NAS deploy. ROBOCO_ROUTING_STRICT and the fastapi-guard trio
# (ROBOCO_GUARD_ENABLED/_PASSIVE_MODE/_FAIL_SECURE) are the exception:
# both are still mid-calibration on the personal deploy, so they're
# omitted entirely here rather than carried — their config defaults
# (graceful-degrade routing, guard off) are already the safe posture.
# ROUTING_STRICT is still mid-calibration, and guard is now ACTIVE
# enforcement on the personal deploy (passive calibration reviewed clean).
# Both stay omitted here rather than carried — a third-party deployer
# hasn't run that calibration against their own traffic, so their config
# defaults (graceful-degrade routing, guard off) are the safer posture.
# - Host path defaults differ (/opt/roboco vs /volume1/roboco, ${HOME}
# instead of a hardcoded /home/renzof) — registry targets a generic host.
# - MinIO (object storage for rendered videos) is intentionally omitted —
+8 -6
View File
@@ -633,14 +633,16 @@ services:
# that would respawn forever. Default-OFF in config; ARMED here (inert in
# practice: every real delivery role is gateway-enabled). OFF in registry.
ROBOCO_SPAWN_PREFLIGHT_ENABLED: ${ROBOCO_SPAWN_PREFLIGHT_ENABLED:-true}
# fastapi-guard HTTP security layer (v0.16.0). ARMED here in PASSIVE /
# log-only calibration mode: guard mounts + observes + logs what it WOULD
# block, but blocks nothing until PASSIVE_MODE is flipped off after the
# false-positive review. FAIL_SECURE=false so a guard-internal error never
# 500s this personal deploy. Left OFF entirely in the registry compose.
# fastapi-guard HTTP security layer (v0.16.0). ACTIVE enforcement: passive
# / log-only calibration (WAF false-positive review, see
# docs/rag/architecture/http-security-guard.md) came back clean, CEO
# approved flipping to active now that cloud auth + Tailscale are armed.
# Guard mounts, observes, AND BLOCKS matching requests. FAIL_SECURE=false
# so a guard-internal error never 500s this personal deploy. Left OFF
# entirely in the registry compose.
# enforce_https follows ROBOCO_ENVIRONMENT (dev on the NAS → not enforced).
ROBOCO_GUARD_ENABLED: ${ROBOCO_GUARD_ENABLED:-true}
ROBOCO_GUARD_PASSIVE_MODE: ${ROBOCO_GUARD_PASSIVE_MODE:-true}
ROBOCO_GUARD_PASSIVE_MODE: ${ROBOCO_GUARD_PASSIVE_MODE:-false}
ROBOCO_GUARD_FAIL_SECURE: ${ROBOCO_GUARD_FAIL_SECURE:-false}
volumes:
# Docker socket - allows spawning agent containers
+8 -6
View File
@@ -633,14 +633,16 @@ services:
# that would respawn forever. Default-OFF in config; ARMED here (inert in
# practice: every real delivery role is gateway-enabled). OFF in registry.
ROBOCO_SPAWN_PREFLIGHT_ENABLED: ${ROBOCO_SPAWN_PREFLIGHT_ENABLED:-true}
# fastapi-guard HTTP security layer (v0.16.0). ARMED here in PASSIVE /
# log-only calibration mode: guard mounts + observes + logs what it WOULD
# block, but blocks nothing until PASSIVE_MODE is flipped off after the
# false-positive review. FAIL_SECURE=false so a guard-internal error never
# 500s this personal deploy. Left OFF entirely in the registry compose.
# fastapi-guard HTTP security layer (v0.16.0). ACTIVE enforcement: passive
# / log-only calibration (WAF false-positive review, see
# docs/rag/architecture/http-security-guard.md) came back clean, CEO
# approved flipping to active now that cloud auth + Tailscale are armed.
# Guard mounts, observes, AND BLOCKS matching requests. FAIL_SECURE=false
# so a guard-internal error never 500s this personal deploy. Left OFF
# entirely in the registry compose.
# enforce_https follows ROBOCO_ENVIRONMENT (dev on the NAS → not enforced).
ROBOCO_GUARD_ENABLED: ${ROBOCO_GUARD_ENABLED:-true}
ROBOCO_GUARD_PASSIVE_MODE: ${ROBOCO_GUARD_PASSIVE_MODE:-true}
ROBOCO_GUARD_PASSIVE_MODE: ${ROBOCO_GUARD_PASSIVE_MODE:-false}
ROBOCO_GUARD_FAIL_SECURE: ${ROBOCO_GUARD_FAIL_SECURE:-false}
volumes:
# Docker socket - allows spawning agent containers
+2 -2
View File
@@ -11,7 +11,7 @@ RoboCo's HTTP request layer is protected by `fastapi-guard` (v7.2.1), implemente
| `ROBOCO_GUARD_ENABLED` | `false` | Master switch. Off = completely inert — no middleware is mounted, the request path is entirely unchanged, and nothing is logged or blocked. |
| `ROBOCO_GUARD_PASSIVE_MODE` | see below | When the guard is enabled, controls whether it blocks matching requests or only logs them. |
As of 2026-07-01 the guard is built on the `feature/fastapi-guard-hardening` branch, gated off by default, and wherever it is enabled at all it is running in passive/log-only mode — so no agent request is currently being blocked by it anywhere.
As of 2026-07-19 the guard is gated off by default in config, but the NAS build compose arms it ON in ACTIVE enforcement (`ROBOCO_GUARD_PASSIVE_MODE=false`) — passive/log-only calibration came back clean, and the CEO approved the flip now that cloud auth + Tailscale are armed. A matching request on that deploy is actually blocked, not just logged. The registry compose still ships it fully off (see Enforcement Posture below).
## When Armed
@@ -27,7 +27,7 @@ On top of those generic checks, three RoboCo-specific custom validators run agai
## Enforcement Posture
`ROBOCO_GUARD_PASSIVE_MODE` decides what happens on a match: `true` (passive) detects and logs only, and never blocks a request — this is how the NAS production deploy is armed today. `false` (enforce) actually blocks the matching request.
`ROBOCO_GUARD_PASSIVE_MODE` decides what happens on a match: `true` (passive) detects and logs only, and never blocks a request. `false` (enforce) actually blocks the matching request — this is how the NAS build compose is armed today (its default flipped from `true` to `false` once passive-mode calibration reviewed clean). The registry compose omits the guard trio entirely, leaving a fresh third-party deploy on the safe config default (guard off).
A blocked request gets a generic `400` or `403` response — no rule or signature detail is returned, so the response body can't be used to fingerprint what tripped the guard.
@@ -6,7 +6,7 @@ A call to the orchestrator API fails with a generic `400` or `403` and no detail
## Cause
RoboCo's HTTP security layer (`fastapi-guard`; see `docs/rag/architecture/http-security-guard.md`) rejected the request. This can only happen when both `ROBOCO_GUARD_ENABLED=true` and `ROBOCO_GUARD_PASSIVE_MODE=false` (enforce mode) are set. As of 2026-07-01 the guard is off by default, and wherever it is enabled it runs passive/log-only, so this is not something that occurs today — it's documented so the block is recognizable if/when enforcement is turned on later.
RoboCo's HTTP security layer (`fastapi-guard`; see `docs/rag/architecture/http-security-guard.md`) rejected the request. This can only happen when both `ROBOCO_GUARD_ENABLED=true` and `ROBOCO_GUARD_PASSIVE_MODE=false` (enforce mode) are set. As of 2026-07-19 that is the default on the NAS build compose — a matching request is actually blocked there, not just logged.
The guard never returns which rule or signature matched, by design, so the 400/403 body itself gives you nothing to act on. Avoiding the triggers below is the only real mitigation.
@@ -14,18 +14,22 @@ The guard never returns which rule or signature matched, by design, so the 400/4
Generic WAF signatures (SQL injection, XSS, path traversal, suspicious URL patterns) are excluded from scanning on the free-text body fields of `note` / `commit` / `dm`, so normal code, SQL, diffs, file paths, and URLs in those bodies are safe from that layer. Three custom validators scan those same bodies regardless of that exclusion:
- Prompt-injection detection
- Secret-exfil detection — literal credential-shaped strings (`sk-ant-...`, `ghp_...`, postgres connection URLs) or phrasing like "reveal your api keys"
- Prompt-injection detection — phrasing like "ignore previous instructions", or "bypass/disable/override the guard/filter/restriction" (a real risk in this repo's own security-work commit messages and notes: `roboco/security.py`'s own doctring vocabulary uses "guard", "bypass", "disable" constantly)
- Secret-exfil detection — literal credential-shaped strings (`sk-ant-...`, `ghp_...`, postgres connection URLs) **or the literal doc pattern `ROBOCO_ENCRYPTION_KEY=<...>` / `ROBOCO_AGENT_AUTH_SECRET=<...>` / `FERNET_KEY=<...>`** (matches `CLAUDE.md` and `.env.example`'s own env-var documentation verbatim — quoting or editing those lines in a `note`/`commit` body trips this) or phrasing like "reveal your api keys"
- Internal-SSRF detection — fetch-type bodies targeting internal or metadata hosts (`169.254.169.254`, `roboco-*` internal service hostnames)
The three custom validators scan the raw request body regardless of which top-level field the text sits in — unlike the WAF's field exclusion, there is no safe field for these three.
## Solution: Hygiene Rules
Follow these when composing `note` / `commit` / `dm` bodies or any fetch-type payload, regardless of whether enforcement is currently active:
Follow these when composing `note` / `commit` / `dm` bodies or any fetch-type payload:
1. Never paste real secrets or credentials (API keys, tokens, DB connection strings) into a request body, even inside a code snippet or diff.
2. Never aim a fetch/HTTP-call body at an internal service host (`roboco-*`) or a cloud metadata endpoint (`169.254.169.254`).
3. Code, SQL, diffs, file paths, and HTML snippets are otherwise fine to include — the WAF layer is calibrated to exclude legitimate agent content on those fields.
2. When discussing an env var like `ROBOCO_ENCRYPTION_KEY` in a note/commit body, don't write it as `NAME=value` (even a placeholder value) — write `ROBOCO_ENCRYPTION_KEY` and describe the value separately, e.g. "set to a generated Fernet key", to avoid the `NAME=<10+ chars>` credential-shape match.
3. Avoid "bypass/override/disable the guard/filter/restriction/safety" phrasing in commit messages or notes about this security layer itself — describe the change without that verb+noun adjacency (e.g. "excludes X from the WAF scan" instead of "bypasses the guard's WAF scan").
4. Never aim a fetch/HTTP-call body at an internal service host (`roboco-*`) or a cloud metadata endpoint (`169.254.169.254`).
5. Code, SQL, diffs, file paths, and HTML snippets are otherwise fine to include — the WAF layer is calibrated to exclude legitimate agent content on those fields (this exclusion does not cover the three custom validators above).
## Current Status (2026-07-01)
## Current Status (2026-07-19)
The guard is built on the `feature/fastapi-guard-hardening` branch, off by default (`ROBOCO_GUARD_ENABLED=false`), and wherever enabled it runs in passive/log-only mode. No agent request is being blocked by it today — the rules above are about good hygiene now and correctness later, not a live restriction.
`ROBOCO_GUARD_ENABLED` is off by default in config; the NAS build compose arms it ON with `ROBOCO_GUARD_PASSIVE_MODE=false` (enforce). A matching request on that deploy is genuinely blocked, not just logged — the rules above are a live restriction there, not just future-proofing. The registry compose and a bare config default both stay off.
+20 -2
View File
@@ -632,6 +632,24 @@ def _check_main_pm_a2a(to_role: str, to_team: str | None) -> tuple[bool, str | N
return False, f"Main PM cannot A2A {to_role}s. Route through {pm or 'cell-pm'}."
def _check_ceo_a2a(to_role: str) -> tuple[bool, str | None]:
"""Check A2A permissions for the CEO's asymmetric send-to-anyone reach.
A target with no agent-comms surface (no dm/read_a2a on its manifest
auditor, pr_reviewer, prompter, secretary) can never read or answer a
DM regardless of who sends it; the panel's New-DM dialog already
excludes these roles client-side (EXCLUDE_NON_DM_ROLES), this is the
server-side backstop so a direct API/A2A-service call can't bypass it.
"""
if to_role in _comms.NO_COMMS_ROLES:
return (
False,
f"'{to_role}' has no agent-comms surface (no dm/read_a2a) and "
"cannot receive a DM.",
)
return True, None
def _check_pr_reviewer_a2a(to_role: str) -> tuple[bool, str | None]:
"""Check A2A permissions for a PR reviewer.
@@ -659,9 +677,9 @@ def can_a2a_direct(from_agent: str, to_agent: str) -> tuple[bool, str | None]:
# The CEO (human, via the panel) may chime into any agent's A2A thread —
# the one asymmetric rule in this matrix: CEO may send, nobody may
# target CEO.
# target CEO (except a no-comms target, see _check_ceo_a2a).
if from_role == "ceo":
return True, None
return _check_ceo_a2a(to_role)
# CEO is human - agents can never INITIATE with the CEO. The only path in
# is a reply inside a conversation the CEO itself opened (enforced
+12
View File
@@ -267,6 +267,18 @@ class Settings(BaseSettings):
"unacknowledged. 0 disables the damper (legacy every-tick respawn)."
),
)
notification_ack_ttl_hours: int = Field(
default=48,
ge=0,
description=(
"Hours until an ack-required notification's expires_at is stamped "
"at creation. sweep_expired_notifications re-escalates a still-"
"unacked row past that deadline to the recipient's up-role. "
"Informational (non-ack-required) notifications never get a "
"deadline regardless of this setting. 0 disables stamping "
"(legacy: expires_at stays NULL, notifications never expire)."
),
)
audit_interval_seconds: int = Field(
default=21600,
ge=0,
@@ -56,6 +56,23 @@ NOTIFY_SENDER_ROLES: frozenset[Role] = frozenset(
)
# Roles with no agent-comms surface at all: auditor (silent observer, no dm/
# read_a2a on its manifest), pr_reviewer (posts findings on the PR itself),
# and the human-only prompter/secretary (note + evidence only). A DM to any
# of these is a black hole — nothing on the other end can read or answer it.
# Canonical set consumed by both the dm() sender-side guard
# (services.gateway.content_actions) and the CEO's asymmetric target check
# (agents_config.can_a2a_direct) so the two never drift apart.
NO_COMMS_ROLES: frozenset[Role] = frozenset(
{
Role.AUDITOR,
Role.PR_REVIEWER,
Role.PROMPTER,
Role.SECRETARY,
}
)
# NotificationType -> requires_ack mapping.
# Convention from spec §5.5:
# - Action-required (CEO/PM acks needed) -> True
+4 -4
View File
@@ -107,10 +107,10 @@ _NOTIFY_ALLOWED_ROLES: frozenset[str] = frozenset(
# that refuses any call that bypassed the manifest (direct verb dispatch, test
# harness, future routing change), so the no-comms invariant holds regardless of
# how the call arrived. Matches the explicit role-frozenset gates on commit /
# notify / pitch / playbook.
_NO_COMMS_ROLES: frozenset[str] = frozenset(
{"auditor", "pr_reviewer", "prompter", "secretary"}
)
# notify / pitch / playbook. Derived from the canonical set in
# foundation.policy.communications — agents_config.can_a2a_direct's CEO
# target-side check reuses the same source.
_NO_COMMS_ROLES: frozenset[str] = frozenset(r.value for r in _comms.NO_COMMS_ROLES)
def _no_comms_remediate(role: str) -> str:
+31 -12
View File
@@ -6,12 +6,14 @@ Sends notifications through the API with proper enforcement.
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
from uuid import UUID
import structlog
from sqlalchemy import select
from roboco.config import settings
from roboco.db.base import get_db_context
from roboco.db.tables import AgentTable, NotificationTable
from roboco.foundation.policy.communications import ACK_REQUIRED_BY_TYPE
@@ -847,6 +849,32 @@ class NotificationService:
if created:
await db.commit()
def _ack_and_expiry(
self, params: CreateNotificationParams
) -> tuple[bool, datetime | None]:
"""(requires_ack, expires_at) for a new notification row.
requires_ack follows ACK_REQUIRED_BY_TYPE (action-required vs
informational) with default True for unmapped types; a per-row
override (params.requires_ack) wins when set used by
send_a2a_notification's CEO-wake path. expires_at feeds
sweep_expired_notifications' re-escalation: only ack-required rows
are ever swept, so only they get a deadline, and
notification_ack_ttl_hours=0 disables stamping (NULL, never
expires).
"""
requires_ack = (
params.requires_ack
if params.requires_ack is not None
else ACK_REQUIRED_BY_TYPE.get(params.notification_type, True)
)
expires_at = (
datetime.now(UTC) + timedelta(hours=settings.notification_ack_ttl_hours)
if requires_ack and settings.notification_ack_ttl_hours > 0
else None
)
return requires_ack, expires_at
async def _create_notification_with_session(
self, params: CreateNotificationParams, db: AsyncSession
) -> bool:
@@ -903,6 +931,7 @@ class NotificationService:
to_agents_uuids=to_agents_uuids,
):
return False
requires_ack, expires_at = self._ack_and_expiry(params)
notification = NotificationTable(
type=params.notification_type,
priority=params.priority,
@@ -911,18 +940,8 @@ class NotificationService:
subject=params.subject,
body=params.body,
related_task_id=params.related_task_id,
# requires_ack follows ACK_REQUIRED_BY_TYPE (action-required vs
# informational), not the column's True default; default True
# for an unmapped type preserves the safe action-required bias.
# A per-row override (params.requires_ack) wins when set — used
# by send_a2a_notification's CEO-wake path so that row is
# visible under pending_ack_only even though A2A_REQUEST's type
# default is False.
requires_ack=(
params.requires_ack
if params.requires_ack is not None
else ACK_REQUIRED_BY_TYPE.get(params.notification_type, True)
),
requires_ack=requires_ack,
expires_at=expires_at,
)
db.add(notification)
await db.flush()
+19 -1
View File
@@ -36,6 +36,7 @@ Redis isolation:
from __future__ import annotations
import contextlib
import json
import os
import socket
@@ -48,7 +49,7 @@ import pytest
import pytest_asyncio
from roboco.config import settings as _settings
from roboco.db import tables as roboco_tables
from roboco.db.base import Base
from roboco.db.base import Base, close_db
from roboco.db.tables import (
AgentTable,
AuditLogTable,
@@ -78,6 +79,23 @@ if TYPE_CHECKING:
from collections.abc import AsyncIterator
@pytest_asyncio.fixture(autouse=True)
async def _dispose_global_db_engine() -> AsyncIterator[None]:
"""Never let the lazy global engine outlive the test that created it.
Production code reaching ``get_db_context()``/``get_engine()`` creates
the process-global ``_DbHolder`` engine bound to the CURRENT event loop.
With function-scoped test loops, any later test touching that global
path inherits an engine from a dead loop and crashes with ``Future
attached to a different loop`` an order-dependent failure class that
moves around whenever test collection shifts. Disposing after every
test keeps the global path loop-local; a no-op when nothing touched it.
"""
yield
with contextlib.suppress(Exception):
await close_db()
@pytest.fixture(autouse=True)
def _no_live_redis(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep every test off the real localhost Redis (see module docstring).
+23 -8
View File
@@ -2494,21 +2494,36 @@ async def test_agent_reply_to_ceo_creates_no_wake(a2a_setup: dict) -> None:
@pytest.mark.asyncio
async def test_ceo_dm_to_non_a2a_role_creates_no_wake(a2a_setup: dict) -> None:
"""A CEO DM to a role with no read_a2a on its manifest (pr_reviewer,
auditor) must NOT create a wake row the recipient could never ack it,
so it would be immortal, permanently suppress future wakes via the dedup
pre-check, and drive futile respawns."""
async def test_ceo_dm_to_non_a2a_role_denied_at_conversation_creation(
a2a_setup: dict,
) -> None:
"""A CEO DM to a role with no dm/read_a2a on its manifest (pr_reviewer,
auditor) must be refused outright at conversation creation the root-
cause fix (can_a2a_direct's CEO branch now excludes NO_COMMS_ROLES)
supersedes the old symptom-level fix of letting the conversation exist
and only suppressing the wake notification (the recipient could never
ack it, so it would be immortal, permanently suppress future wakes via
the dedup pre-check, and drive futile respawns)."""
svc: A2AService = a2a_setup["svc"]
conv = await svc.get_or_create_conversation(agent_a="ceo", agent_b="pr-reviewer-1")
conv_id = UUID(conv.id)
with pytest.raises(A2AAccessDeniedError, match="no agent-comms surface"):
await svc.get_or_create_conversation(agent_a="ceo", agent_b="pr-reviewer-1")
@pytest.mark.asyncio
async def test_maybe_wake_ceo_recipient_still_noops_for_no_comms_role(
a2a_setup: dict,
) -> None:
"""Defense-in-depth: _maybe_wake_ceo_recipient's own read_a2a manifest
check independently no-ops for a no-comms role unreachable through the
normal send path now that conversation creation refuses it first, but
it must stay safe if ever called directly (e.g. on a pre-fix row)."""
svc: A2AService = a2a_setup["svc"]
mock_ns = AsyncMock()
mock_ns.send_a2a_notification = AsyncMock(return_value=None)
with patch(
"roboco.services.notification.NotificationService", return_value=mock_ns
):
await svc.send_chat_message(conv_id, "ceo", "review status?")
await svc._maybe_wake_ceo_recipient("ceo", "pr-reviewer-1", None)
mock_ns.send_a2a_notification.assert_not_awaited()
@@ -0,0 +1,169 @@
"""expires_at is now stamped at creation (NotificationService) and actually
matched by NotificationDeliveryService.sweep_expired_notifications' SQL
WHERE clause before the fix the column was never written, so this query
always matched zero rows regardless of how stale a notification was.
Integration tests against the migrated Postgres DB: `sweep_expired_notifications`
issues a real `expires_at < now()` query, so a mocked session (as
`tests/unit/services/test_notification_delivery.py` uses) can't exercise it.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, cast
from uuid import UUID, uuid4
import pytest
from roboco.config import settings
from roboco.db.tables import AgentTable, NotificationTable
from roboco.models import AgentRole, AgentStatus, NotificationPriority, NotificationType
from roboco.models.base import Team
from roboco.models.notification import CreateNotificationParams
from roboco.services.notification import NotificationService
from roboco.services.notification_delivery import get_notification_delivery_service
from sqlalchemy import select
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed_agent(db: AsyncSession, *, role: AgentRole, slug: str) -> UUID:
agent = AgentTable(
id=uuid4(),
name=slug,
slug=slug,
role=role,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt=slug,
capabilities=[],
permissions={},
metrics={},
)
db.add(agent)
await db.flush()
return cast("UUID", agent.id)
@pytest.mark.asyncio
async def test_created_notification_expires_at_is_stamped_and_matched_by_sweep(
db_session: AsyncSession,
) -> None:
"""End-to-end: NotificationService._create_notification stamps expires_at
for an ack-required row, and once that deadline is in the past,
sweep_expired_notifications' real Postgres query finds it (count 1) —
the exact round trip that was a dead no-op before this fix, since
expires_at was always NULL and `expires_at < now()` never matched."""
unique = uuid4().hex[:8]
sender = await _seed_agent(
db_session, role=AgentRole.DEVELOPER, slug=f"sndr-{unique}"
)
recipient = await _seed_agent(
db_session, role=AgentRole.CELL_PM, slug=f"pm-{unique}"
)
svc = NotificationService()
await svc._create_notification(
CreateNotificationParams(
notification_type=NotificationType.BLOCKER_ESCALATION,
priority=NotificationPriority.HIGH,
from_agent=str(sender),
to_agents=[str(recipient)],
subject="blocked",
body="external dependency",
),
db_session=db_session,
)
await db_session.flush()
row = (
await db_session.execute(
select(NotificationTable).where(
NotificationTable.type == NotificationType.BLOCKER_ESCALATION,
NotificationTable.from_agent == sender,
)
)
).scalar_one()
assert row.expires_at is not None
assert row.requires_ack is True
# Backdate it past the deadline (no real clock wait) and confirm the
# sweep's `expires_at < now()` predicate now actually matches.
row.expires_at = datetime.now(UTC) - timedelta(minutes=1)
await db_session.flush()
deliv = get_notification_delivery_service(db_session)
count = await deliv.sweep_expired_notifications()
assert count >= 1
@pytest.mark.asyncio
async def test_directly_stamped_expired_row_is_matched_by_sweep_query(
db_session: AsyncSession,
) -> None:
"""Isolates the sweep query mechanics from creation: a hand-built
ack-required, unacked row with expires_at in the past must be counted."""
unique = uuid4().hex[:8]
sender = await _seed_agent(
db_session, role=AgentRole.DEVELOPER, slug=f"s2-{unique}"
)
recipient = await _seed_agent(db_session, role=AgentRole.QA, slug=f"r2-{unique}")
notification = NotificationTable(
type=NotificationType.ALERT,
priority=NotificationPriority.HIGH,
from_agent=sender,
to_agents=[recipient],
subject="stale alert",
body="body",
requires_ack=True,
expires_at=datetime.now(UTC) - timedelta(hours=1),
)
db_session.add(notification)
await db_session.flush()
deliv = get_notification_delivery_service(db_session)
count = await deliv.sweep_expired_notifications()
assert count >= 1
@pytest.mark.asyncio
async def test_zero_ttl_disables_expires_at_stamping_end_to_end(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""notification_ack_ttl_hours=0 leaves expires_at NULL even for an
ack-required notification created through the real service."""
monkeypatch.setattr(settings, "notification_ack_ttl_hours", 0)
unique = uuid4().hex[:8]
sender = await _seed_agent(
db_session, role=AgentRole.DEVELOPER, slug=f"s3-{unique}"
)
recipient = await _seed_agent(
db_session, role=AgentRole.CELL_PM, slug=f"pm3-{unique}"
)
svc = NotificationService()
await svc._create_notification(
CreateNotificationParams(
notification_type=NotificationType.BLOCKER_ESCALATION,
priority=NotificationPriority.HIGH,
from_agent=str(sender),
to_agents=[str(recipient)],
subject="blocked",
body="external dependency",
),
db_session=db_session,
)
await db_session.flush()
row = (
await db_session.execute(
select(NotificationTable).where(
NotificationTable.type == NotificationType.BLOCKER_ESCALATION,
NotificationTable.from_agent == sender,
)
)
).scalar_one()
assert row.expires_at is None
+25
View File
@@ -9,6 +9,7 @@ from roboco.enforcement.a2a_access import (
get_a2a_allowed_targets,
validate_a2a_access,
)
from roboco.foundation.policy.communications import NO_COMMS_ROLES
def test_validate_a2a_self_a2a_denied() -> None:
@@ -94,3 +95,27 @@ def test_can_a2a_direct_to_ceo_message_explains_reply_only() -> None:
assert allowed is False
assert reason is not None
assert "reply" in reason.lower()
@pytest.mark.parametrize(
"target_slug",
["auditor", "pr-reviewer-1", "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 (auditor, pr_reviewer, prompter, secretary)
nothing on the other end could ever read or answer the DM. The panel's
New-DM dialog already filters these client-side (EXCLUDE_NON_DM_ROLES);
this is the server-side backstop for a direct API/A2A-service call."""
allowed, reason = can_a2a_direct("ceo", target_slug)
assert allowed is False
assert reason is not None
assert "comms" in reason.lower()
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 = {"auditor", "pr_reviewer", "prompter", "secretary"}
assert {role.value for role in NO_COMMS_ROLES} == expected
+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)
+13 -11
View File
@@ -243,7 +243,6 @@ def test_get_agent_skills_unknown_agent() -> None:
def test_issue_agent_token_returns_unsigned_when_secret_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("ROBOCO_AGENT_AUTH_SECRET", raising=False)
assert issue_agent_token("be-dev-1", "developer", "backend") == "UNSIGNED"
@@ -262,7 +261,6 @@ def test_issue_agent_token_signs_when_secret_present(
def test_verify_agent_token_round_trips(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", "rt-secret")
tok = issue_agent_token("be-dev-1", "developer", "backend")
assert verify_agent_token(tok, "be-dev-1", "developer", "backend") is True
@@ -271,7 +269,6 @@ def test_verify_agent_token_round_trips(monkeypatch: pytest.MonkeyPatch) -> None
def test_verify_agent_token_rejects_when_secret_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("ROBOCO_AGENT_AUTH_SECRET", raising=False)
assert verify_agent_token("anything", "be-dev-1", "developer", "backend") is False
@@ -279,7 +276,6 @@ def test_verify_agent_token_rejects_when_secret_missing(
def test_verify_agent_token_rejects_unsigned_sentinel(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", "any-secret")
assert verify_agent_token("UNSIGNED", "be-dev-1", "developer", "backend") is False
@@ -287,7 +283,6 @@ def test_verify_agent_token_rejects_unsigned_sentinel(
def test_verify_agent_token_rejects_empty_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", "any-secret")
assert verify_agent_token("", "be-dev-1", "developer", "backend") is False
@@ -295,7 +290,6 @@ def test_verify_agent_token_rejects_empty_token(
def test_verify_agent_token_rejects_mismatched_signature(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", "real-secret")
tok = issue_agent_token("be-dev-1", "developer", "backend")
# Verify with different role → mismatch.
@@ -539,10 +533,10 @@ def test_get_a2a_route_hint_unknown_from_agent_falls_through() -> None:
# A2A_ALLOWED_PAIRS — the switchboard's static org-chart pair matrix
# ---------------------------------------------------------------------------
_EXPECTED_PAIR_COUNT = 93
_EXPECTED_PAIR_COUNT = 88
_EXPECTED_GROUP_COUNTS = {
"board": 3,
"ceo": 23,
"ceo": 18,
"cell-backend": 15,
"cell-frontend": 15,
"cell-ux_ui": 15,
@@ -579,15 +573,23 @@ def test_a2a_allowed_pairs_excludes_non_participants_keeps_ceo() -> None:
assert "ceo" in slugs
def test_a2a_allowed_pairs_ceo_paired_with_every_agent() -> None:
"""CEO → anyone is always allowed, so every non-CEO switchboard slug
def test_a2a_allowed_pairs_ceo_paired_with_every_dm_capable_agent() -> None:
"""CEO → anyone with an agent-comms surface is allowed, so every non-CEO
switchboard slug EXCEPT the no-comms roles (auditor, pr_reviewer no
dm/read_a2a on their manifest, so a CEO DM to them is a black hole)
appears in exactly one ``ceo``-group pair."""
ceo_pairs = [p for p in A2A_ALLOWED_PAIRS if "ceo" in (p.agent_a, p.agent_b)]
non_ceo_slugs = (
{p.agent_a for p in A2A_ALLOWED_PAIRS} | {p.agent_b for p in A2A_ALLOWED_PAIRS}
) - {"ceo"}
dm_capable_slugs = {
s for s in non_ceo_slugs if get_agent_role(s) not in ("auditor", "pr_reviewer")
}
assert all(p.group_key == "ceo" for p in ceo_pairs)
assert len(ceo_pairs) == len(non_ceo_slugs)
assert len(ceo_pairs) == len(dm_capable_slugs)
# And the no-comms roles are confirmed absent from any ceo-group pair.
ceo_slugs = {p.agent_a for p in ceo_pairs} | {p.agent_b for p in ceo_pairs}
assert ceo_slugs.isdisjoint(non_ceo_slugs - dm_capable_slugs)
def test_a2a_allowed_pairs_group_key_counts() -> None: