diff --git a/src/praisonai-agents/praisonaiagents/gateway/protocols.py b/src/praisonai-agents/praisonaiagents/gateway/protocols.py index c248e6e61..6cd16b83c 100644 --- a/src/praisonai-agents/praisonaiagents/gateway/protocols.py +++ b/src/praisonai-agents/praisonaiagents/gateway/protocols.py @@ -1685,6 +1685,12 @@ class RouteBinding: ``standard`` / ``trusted`` apply no tier deny-list. allow_tools: If set, only these tool names are exposed on this route. deny_tools: Tool names removed before the run on this route. + profile: Optional isolated tenant-profile name (Issue #3189). Names a + per-route isolation scope the wrapper enters for the turn (e.g. its + own memory namespace / secret scope / home), so one gateway can + safely multiplex tenants. ``None`` means the route is unscoped; the + wrapper must fail closed (never fall back to another tenant's + profile) rather than silently share memory/secrets. """ agent: str @@ -1697,6 +1703,7 @@ class RouteBinding: trust: Optional[str] = None allow_tools: Optional[List[str]] = None deny_tools: Optional[List[str]] = None + profile: Optional[str] = None # Specificity weights — exact peer beats role/channel beats account # beats chat-type. Higher means more specific. @@ -1709,13 +1716,21 @@ class RouteBinding: } def __post_init__(self) -> None: - """Normalise ``trust`` so config typos cannot silently fail open. + """Normalise ``trust``/``profile`` so config typos cannot fail open. Whitespace/case variants of a known tier (e.g. ``" Untrusted "``) are canonicalised. Any *unknown* non-empty value is treated as the most restrictive tier (``untrusted``) rather than as "no policy", so a misconfigured route can never accidentally expose the full toolset. + A blank ``profile`` is coerced to ``None`` (unscoped) for the same + fail-closed reason. """ + # Blank/whitespace-only profile means "unscoped" (None), never an + # empty-named scope, so a wrapper checking ``if profile is not None`` + # fails closed rather than entering an anonymous namespace. + if self.profile is not None and not str(self.profile).strip(): + self.profile = None + if self.trust is None: return normalized = str(self.trust).strip().lower() @@ -1792,6 +1807,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "RouteBinding": trust=_as_opt_str(data.get("trust")), allow_tools=_as_opt_str_list(data.get("allow_tools")), deny_tools=_as_opt_str_list(data.get("deny_tools")), + profile=_as_opt_str(data.get("profile")), ) @@ -1822,11 +1838,18 @@ class RouteMatch: agent: The resolved agent id. binding: The binding that matched, or ``None`` when the fallback was used. reason: Short human-readable explanation for logging/debugging. + profile: The isolated tenant-profile named by the matched binding, or + ``None`` when the route is unscoped / the fallback was used (Issue + #3189). Surfaced here so the wrapper can enter the profile's memory + namespace / secret scope for the turn without re-resolving, and so + an unmatched route fails closed (never inherits another tenant's + profile). """ agent: str binding: Optional[RouteBinding] = None reason: str = "" + profile: Optional[str] = None def _as_opt_str(value: Any) -> Optional[str]: @@ -1892,6 +1915,7 @@ def resolve_route( f"matched binding (priority={best.priority}, " f"specificity={best.specificity})" ), + profile=best.profile, ) return RouteMatch( diff --git a/src/praisonai-agents/tests/unit/test_route_bindings.py b/src/praisonai-agents/tests/unit/test_route_bindings.py index 2c4892ac3..b8541f577 100644 --- a/src/praisonai-agents/tests/unit/test_route_bindings.py +++ b/src/praisonai-agents/tests/unit/test_route_bindings.py @@ -149,3 +149,54 @@ def test_from_dict_defaults_agent(self): def test_from_dict_ignores_unknown_keys(self): b = RouteBinding.from_dict({"agent": "a", "future_field": "x"}) assert b.agent == "a" + + +class TestProfileIsolation: + """Per-route isolated tenant-profile dimension (Issue #3189).""" + + def test_profile_defaults_to_none(self): + assert RouteBinding(agent="a").profile is None + + def test_from_dict_parses_profile(self): + b = RouteBinding.from_dict({"agent": "support", "profile": "acme"}) + assert b.profile == "acme" + + def test_from_dict_profile_is_string_coerced(self): + b = RouteBinding.from_dict({"agent": "a", "profile": 42}) + assert b.profile == "42" + + def test_resolve_surfaces_matched_profile(self): + bindings = [ + RouteBinding(agent="support", channel_id="discord-acme", profile="acme"), + RouteBinding(agent="support", channel_id="slack-globex", profile="globex"), + ] + m = resolve_route(bindings, RouteFacts(channel_id="slack-globex")) + assert m.agent == "support" + assert m.profile == "globex" + + def test_unmatched_route_fails_closed_no_profile(self): + # A route with no matching binding must never inherit another + # tenant's profile — the fallback carries profile=None. + bindings = [RouteBinding(agent="support", channel_id="discord-acme", profile="acme")] + m = resolve_route( + bindings, + RouteFacts(channel_id="unknown"), + default_agent="support", + ) + assert m.binding is None + assert m.profile is None + + def test_unscoped_binding_has_no_profile(self): + bindings = [RouteBinding(agent="support", chat_type="dm")] + m = resolve_route(bindings, RouteFacts(chat_type="dm")) + assert m.binding is not None + assert m.profile is None + + def test_blank_profile_is_normalised_to_none(self): + # An empty or whitespace-only profile must be treated as unscoped + # (None), not as an empty-named scope, to honour the fail-closed + # contract a wrapper checking ``if profile is not None`` relies on. + assert RouteBinding(agent="a", profile="").profile is None + assert RouteBinding(agent="a", profile=" ").profile is None + assert RouteBinding.from_dict({"agent": "a", "profile": ""}).profile is None + assert RouteBinding.from_dict({"agent": "a", "profile": " "}).profile is None