Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/praisonai-agents/praisonaiagents/gateway/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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()
Expand Down Expand Up @@ -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")),
)


Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -1892,6 +1915,7 @@ def resolve_route(
f"matched binding (priority={best.priority}, "
f"specificity={best.specificity})"
),
profile=best.profile,
)

return RouteMatch(
Expand Down
51 changes: 51 additions & 0 deletions src/praisonai-agents/tests/unit/test_route_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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
Loading