Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b84f8b6
feat(agents): attach access groups to agents and enforce them for mod…
yassin-berriai Sep 17, 2026
cf9c8fe
Merge remote-tracking branch 'origin/main' into litellm_agent_access_…
yassin-berriai Sep 17, 2026
433c804
refactor(agents): mark Callable loader aliases for the type disciplin…
yassin-berriai Sep 17, 2026
322262d
style(ui): format add_agent_form test with prettier
yassin-berriai Sep 17, 2026
d743e08
test(agents): inject the access group ceiling resolver instead of pat…
yassin-berriai Sep 17, 2026
5b04560
refactor(agents): keep the model listing cap within the type-discipli…
yassin-berriai Sep 17, 2026
3a86567
fix(agents): evict the cached agent access groups on every agent writ…
yassin-berriai Sep 17, 2026
89330cd
refactor(agents): exhaust the agent access match and drop routine com…
yassin-berriai Sep 17, 2026
18c31e6
fix(agents): import assert_never from typing_extensions for Python 3.10
yassin-berriai Sep 17, 2026
1206fa8
refactor(agents): resolve attached access groups from the agent regis…
yassin-berriai Sep 17, 2026
7229fc1
fix(agents): return on every branch of the agent access ceiling so Co…
yassin-berriai Sep 17, 2026
25729c5
refactor(agents): combine key and team agent grants without a fall-th…
yassin-berriai Sep 17, 2026
31caa98
Merge remote-tracking branch 'origin/main' into litellm_agent_access_…
yassin-berriai Sep 20, 2026
85da9cb
Merge remote-tracking branch 'origin/main' into litellm_agent_access_…
yassin-berriai Sep 20, 2026
d338d3f
style(agents): tidy typing and docstring in access group ceiling helpers
yassin-berriai Sep 20, 2026
82eef2f
fix(proxy): scope agent permissions to invoking caller
yassin-berriai Sep 21, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LiteLLM_AgentsTable" ADD COLUMN IF NOT EXISTS "access_group_ids" TEXT[] DEFAULT ARRAY[]::TEXT[];
1 change: 1 addition & 0 deletions litellm-proxy-extras/litellm_proxy_extras/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ model LiteLLM_AgentsTable {
static_headers Json? @default("{}")
extra_headers String[] @default([])
agent_access_groups String[] @default([])
access_group_ids String[] @default([])
object_permission_id String?
object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id])
spend Float @default(0.0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1549,10 +1549,18 @@ async def get_mcp_server_access(
allowed_mcp_servers_for_agent: Final = await MCPRequestHandler._get_allowed_mcp_servers_for_agent(
user_api_key_auth
)
if len(allowed_mcp_servers_for_agent) > 0:
agent_access_group_servers: Final = await MCPRequestHandler._get_agent_access_group_server_ceiling(
user_api_key_auth
)
if len(allowed_mcp_servers_for_agent) > 0 or agent_access_group_servers is not None:
has_lower_level_mcp_restrictions = True
# Intersect: agent can only use servers allowed by BOTH key/team AND agent config
allowed_mcp_servers = [s for s in allowed_mcp_servers if s in allowed_mcp_servers_for_agent]
# Intersect: agent can only use servers allowed by key/team AND agent config AND agent access groups
allowed_mcp_servers = [
s
for s in allowed_mcp_servers
if (len(allowed_mcp_servers_for_agent) == 0 or s in allowed_mcp_servers_for_agent)
and (agent_access_group_servers is None or s in agent_access_group_servers)
]
verbose_logger.debug(
"Applied agent intersection filter. Final allowed servers: %s", allowed_mcp_servers
)
Expand Down Expand Up @@ -3137,6 +3145,29 @@ async def _get_allowed_mcp_servers_for_agent(
verbose_logger.warning("Failed to get allowed MCP servers for agent: %s", e)
return []

@staticmethod
async def _get_agent_access_group_server_ceiling(
user_api_key_auth: UserAPIKeyAuth,
) -> frozenset[str] | None:
"""
Server IDs the agent's attached unified access groups (``LiteLLM_AgentsTable.access_group_ids``)
allow, or None when the agent has none attached. Unlike the object_permission path above, an
attached group set that names no servers is an empty ceiling and denies every server.
"""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy.agent_endpoints.auth.agent_access_groups import (
resolve_agent_access_group_ceiling,
)

if not user_api_key_auth.agent_id:
return None
ceiling: Final = await resolve_agent_access_group_ceiling(user_api_key_auth.agent_id)
if ceiling is None:
return None
return frozenset(global_mcp_server_manager.expand_permission_list(sorted(ceiling.mcp_server_ids)))

@staticmethod
async def _get_agent_tool_permissions_for_server(
server_id: str,
Expand Down
42 changes: 42 additions & 0 deletions litellm/proxy/_lazy_openapi_snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -2357,6 +2357,20 @@
},
"AgentConfig": {
"properties": {
"access_group_ids": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Access Group Ids"
},
"agent_card_params": {
"$ref": "#/components/schemas/AgentCard"
},
Expand Down Expand Up @@ -2683,6 +2697,20 @@
},
"AgentResponse": {
"properties": {
"access_group_ids": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Access Group Ids"
},
"agent_card_params": {
"additionalProperties": true,
"title": "Agent Card Params",
Expand Down Expand Up @@ -3471,6 +3499,20 @@
},
"PatchAgentRequest": {
"properties": {
"access_group_ids": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Access Group Ids"
},
"agent_card_params": {
"$ref": "#/components/schemas/AgentCard"
},
Expand Down
9 changes: 8 additions & 1 deletion litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4127,6 +4127,11 @@ class ProxyErrorTypes(str, enum.Enum):
Project does not have access to the model
"""

agent_model_access_denied = "agent_model_access_denied"
"""
The agent behind the key does not have access to the model
"""

model_cost_map_missing = "model_cost_map_missing"

expired_key = "expired_key"
Expand Down Expand Up @@ -4201,7 +4206,7 @@ class ProxyErrorTypes(str, enum.Enum):

@classmethod
def get_model_access_error_type_for_object(
cls, object_type: Literal["key", "user", "team", "org", "project"]
cls, object_type: Literal["key", "user", "team", "org", "project", "agent"]
) -> "ProxyErrorTypes":
"""
Get the model access error type for object_type
Expand All @@ -4216,6 +4221,8 @@ def get_model_access_error_type_for_object(
return cls.org_model_access_denied
elif object_type == "project":
return cls.project_model_access_denied
elif object_type == "agent":
return cls.agent_model_access_denied

@classmethod
def get_vector_store_access_error_type_for_object(
Expand Down
15 changes: 14 additions & 1 deletion litellm/proxy/agent_endpoints/agent_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import TYPE_CHECKING, Final, NamedTuple, Protocol, TypedDict

from pydantic import TypeAdapter, ValidationError
from typing_extensions import ReadOnly

import litellm
from litellm.constants import REDACTED_BY_LITELM_STRING
Expand Down Expand Up @@ -37,6 +38,7 @@ class AgentRecordDump(TypedDict):
agent_card_params: dict[str, object]
static_headers: dict[str, str] | None
extra_headers: list[str] | None
access_group_ids: ReadOnly[Sequence[str] | None]
object_permission: dict[str, object] | None
spend: float
tpm_limit: int | None
Expand Down Expand Up @@ -284,6 +286,12 @@ def _resolved_agent_param_value(
return _MISSING_AGENT_PARAM


def _patched_access_group_ids(agent: PatchAgentRequest) -> Mapping[str, object]:
if "access_group_ids" not in agent:
return MappingProxyType({})
return MappingProxyType({"access_group_ids": tuple(dict.fromkeys(agent.get("access_group_ids") or ()))})


def _restore_redacted_litellm_params(
incoming: Mapping[str, object],
existing: Mapping[str, object],
Expand Down Expand Up @@ -516,6 +524,7 @@ async def add_agent_to_db(
static_headers_val: Final[str | None] = safe_dumps(dict(static_headers_obj)) if static_headers_obj else None

extra_headers_val: Final = agent.get("extra_headers")
access_group_ids_val: Final = agent.get("access_group_ids")

create_data: Final[dict[str, object]] = {
"agent_name": agent_name,
Expand All @@ -532,6 +541,8 @@ async def add_agent_to_db(
create_data["static_headers"] = static_headers_val
if extra_headers_val is not None:
create_data["extra_headers"] = extra_headers_val
if access_group_ids_val is not None:
create_data["access_group_ids"] = tuple(dict.fromkeys(access_group_ids_val))
if object_permission_id is not None:
create_data["object_permission_id"] = object_permission_id

Expand Down Expand Up @@ -601,7 +612,7 @@ async def patch_agent_in_db(
existing_agent: Final[Mapping[str, object]] = dict(existing_record)

augment_agent: Final = {**existing_agent, **agent}
update_data: Final[dict[str, object]] = {}
update_data: Final[dict[str, object]] = {**_patched_access_group_ids(agent)}
if augment_agent.get("agent_name"):
update_data["agent_name"] = augment_agent.get("agent_name")
if "litellm_params" in agent:
Expand Down Expand Up @@ -703,13 +714,15 @@ async def update_agent_in_db(
safe_dumps(dict(static_headers_obj_u)) if static_headers_obj_u is not None else safe_dumps({})
)
extra_headers_val_u: Final = agent.get("extra_headers") or []
access_group_ids_val_u: Final = tuple(dict.fromkeys(agent.get("access_group_ids") or ()))

update_data: Final[dict[str, object]] = {
"agent_name": agent_name,
"litellm_params": litellm_params,
"agent_card_params": agent_card_params,
"static_headers": static_headers_val_u,
"extra_headers": extra_headers_val_u,
"access_group_ids": access_group_ids_val_u,
"updated_by": updated_by,
"updated_at": datetime.now(timezone.utc),
}
Expand Down
81 changes: 81 additions & 0 deletions litellm/proxy/agent_endpoints/auth/agent_access_groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""
Ceiling that an agent's attached access groups place on requests made with that agent's key.

Keys and teams use access groups as grants. An agent uses them the way it already uses its
``object_permission``: the union of the attached groups caps what the agent's key can reach,
on top of whatever the key and team allow. A group that cannot be loaded contributes nothing,
so a missing or unreadable group can only narrow the agent, never widen it.
"""
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Final, TypeAlias

from fastapi import HTTPException

from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import LiteLLM_AccessGroupTable
from litellm.types.agents import AgentResponse

AgentLoader: TypeAlias = Callable[[str], Awaitable[AgentResponse | None]] # mutable-ok: Callable parameter syntax
LoadedAccessGroup: TypeAlias = LiteLLM_AccessGroupTable | None
AccessGroupLoader: TypeAlias = Callable[[str], Awaitable[LoadedAccessGroup]] # mutable-ok: Callable parameter syntax


@dataclass(frozen=True, slots=True)
class AgentAccessGroupCeiling:
"""Everything the agent's attached access groups allow. An empty set denies that resource kind."""

access_group_ids: tuple[str, ...]
models: frozenset[str]
mcp_server_ids: frozenset[str]
agent_ids: frozenset[str]


async def _load_agent(agent_id: str) -> AgentResponse | None:
from litellm.proxy.common_utils.registry_read_through import get_agent_with_read_through

return await get_agent_with_read_through(agent_id)


Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
async def _load_access_group(access_group_id: str) -> LoadedAccessGroup:
from litellm.proxy.auth.auth_checks import get_access_object
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache

if prisma_client is None:
verbose_proxy_logger.warning("Agent access group %s cannot be loaded without a DB", access_group_id)
return None
try:
return await get_access_object(
access_group_id=access_group_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
)
except HTTPException as e:
verbose_proxy_logger.warning(
"Agent access group %s could not be loaded, treating it as empty: %s", access_group_id, e.detail
)
return None


async def resolve_agent_access_group_ceiling(
agent_id: str,
load_agent: AgentLoader = _load_agent,
load_access_group: AccessGroupLoader = _load_access_group,
) -> AgentAccessGroupCeiling | None:
"""``None`` when the agent has no access groups attached, so nothing is capped."""
agent: Final = await load_agent(agent_id)
access_group_ids: Final = tuple(agent.access_group_ids or ()) if agent is not None else ()
if not access_group_ids:
return None

loaded: Final = await asyncio.gather(*(load_access_group(group_id) for group_id in access_group_ids))
groups: Final = tuple(group for group in loaded if group is not None)
return AgentAccessGroupCeiling(
access_group_ids=access_group_ids,
models=frozenset(model for group in groups for model in group.access_model_names),
mcp_server_ids=frozenset(server_id for group in groups for server_id in group.access_mcp_server_ids),
agent_ids=frozenset(target_id for group in groups for target_id in group.access_agent_ids),
)
33 changes: 31 additions & 2 deletions litellm/proxy/agent_endpoints/auth/agent_permission_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,24 @@ async def resolve_agent_access(
Resolve the agents the given user/key may reach.

``UnrestrictedAgentAccess`` is only returned when neither the key nor its team
carries any grant. Grants that intersect to nothing stay restricted, so
narrowing a caller can never widen what it reaches.
carries any grant and the agent behind the key has no access groups attached.
Grants that intersect to nothing stay restricted, so narrowing a caller can
never widen what it reaches.
"""
key_team_access: Final = await AgentRequestHandler._resolve_key_team_agent_access(user_api_key_auth)
agent_ceiling: Final = await AgentRequestHandler._agent_access_group_ceiling(user_api_key_auth)
if agent_ceiling is None:
return key_team_access
match key_team_access:
case UnrestrictedAgentAccess():
return RestrictedAgentAccess(agent_ceiling)
case RestrictedAgentAccess(key_team_ids):
return RestrictedAgentAccess(key_team_ids & agent_ceiling)

@staticmethod
async def _resolve_key_team_agent_access(
user_api_key_auth: UserAPIKeyAuth | None,
) -> AgentAccess:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
try:
key_access: Final = await AgentRequestHandler._get_allowed_agents_for_key(user_api_key_auth)
team_access: Final = await AgentRequestHandler._get_allowed_agents_for_team(user_api_key_auth)
Expand All @@ -86,6 +101,20 @@ async def resolve_agent_access(
verbose_logger.warning("Failed to get allowed agents: %s", e)
return UnrestrictedAgentAccess()

@staticmethod
async def _agent_access_group_ceiling(
user_api_key_auth: UserAPIKeyAuth | None,
) -> frozenset[str] | None:
"""Stable IDs of the agents the calling agent's attached access groups allow; None when none attached."""
from litellm.proxy.agent_endpoints.auth.agent_access_groups import resolve_agent_access_group_ceiling

if user_api_key_auth is None or not user_api_key_auth.agent_id:
return None
ceiling: Final = await resolve_agent_access_group_ceiling(user_api_key_auth.agent_id)
if ceiling is None:
return None
return _to_stable_ids(ceiling.agent_ids)

@staticmethod
async def is_agent_allowed(
agent_id: str,
Expand Down
Loading
Loading