feat(proxy): granular key/team access control for Claude Code marketplace plugins (#40518)

Adds object_permission.skills to keys and teams, enforces it on
/claude-code/marketplace.json?key=, /claude-code/plugins and
/claude-code/plugins/{name}, and exposes an Allowed Skills selector in
the key and team create/edit forms of the Admin UI

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-10 14:26:09 -07:00 committed by GitHub
parent 03815cf9f6
commit 6cc13e07a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 892 additions and 34 deletions

View file

@ -0,0 +1 @@
ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN IF NOT EXISTS "skills" TEXT[] DEFAULT ARRAY[]::TEXT[];

View file

@ -282,6 +282,7 @@ model LiteLLM_ObjectPermissionTable {
mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user
search_tools String[] @default([]) // search_tool_name values this key/team/user may call
mcp_tool_search_enabled Boolean?
skills String[] @default([]) // Claude Code plugin names granted to this key/team beyond the public (enabled) set
teams LiteLLM_TeamTable[]
projects LiteLLM_ProjectTable[]
verification_tokens LiteLLM_VerificationToken[]

View file

@ -23,3 +23,4 @@ class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase):
blocked_tools: list[str] | None = []
search_tools: list[str] | None = []
mcp_tool_search_enabled: bool | None = None
skills: list[str] | None = None

View file

@ -5721,8 +5721,26 @@
"paths": {
"/claude-code/marketplace.json": {
"get": {
"description": "Serve marketplace.json for Claude Code plugin discovery.\n\nThis endpoint is accessed by Claude Code CLI when users run:\n- claude plugin marketplace add <url>\n- claude plugin install <name>@<marketplace>\n\nReturns:\n Marketplace catalog with list of available plugins and their git sources.\n\nExample:\n ```bash\n claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json\n claude plugin install my-plugin@litellm\n ```",
"description": "Serve marketplace.json for Claude Code plugin discovery.\n\nThis endpoint is accessed by Claude Code CLI when users run:\n- claude plugin marketplace add <url>\n- claude plugin install <name>@<marketplace>\n\nWithout `key` the catalog holds the enabled (public) plugins. With `?key=sk-...`\nthe key is authenticated and the catalog also holds the disabled plugins granted\nto it through `object_permission.skills` on the key or its team.\n\nReturns:\n Marketplace catalog with list of available plugins and their git sources.\n\nExample:\n ```bash\n claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json\n claude plugin marketplace add \"http://localhost:4000/claude-code/marketplace.json?key=sk-...\"\n claude plugin install my-plugin@litellm\n ```",
"operationId": "get_marketplace_claude_code_marketplace_json_get",
"parameters": [
{
"in": "query",
"name": "key",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Key"
}
}
],
"responses": {
"200": {
"content": {
@ -5731,6 +5749,16 @@
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Get Marketplace",

View file

@ -497,6 +497,9 @@ class LiteLLMRoutes(enum.Enum):
"/v1/messages/count_tokens",
"/v1/skills",
"/v1/skills/{skill_id}",
"/claude-code/marketplace.json",
"/claude-code/plugins",
"/claude-code/plugins/{plugin_name}",
]
# MCP tool-call / passthrough routes — data-plane. Gated by DISABLE_LLM_API_ENDPOINTS.
@ -1124,6 +1127,7 @@ class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase):
models: list[str] | None = None
search_tools: list[str] | None = None
mcp_tool_search_enabled: bool | None = None
skills: list[str] | None = None
from litellm.models.team import BudgetLimitEntry as BudgetLimitEntry # noqa: E402

View file

@ -7,10 +7,10 @@ Actual plugin files are hosted on GitHub/GitLab/Bitbucket or as a zip archive on
any HTTPS host (S3, Artifactory, a static file server).
Endpoints:
/claude-code/marketplace.json - GET - List plugins for Claude Code discovery (unauthenticated)
/claude-code/marketplace.json - GET - List plugins for Claude Code discovery (unauthenticated; `?key=` adds the key's granted skills)
/claude-code/plugins - POST - Register a new plugin (create-only, proxy admin only)
/claude-code/plugins - GET - List plugins (any authenticated key)
/claude-code/plugins/{name} - GET - Get plugin details (any authenticated key)
/claude-code/plugins - GET - List plugins visible to the key (enabled, plus granted disabled ones)
/claude-code/plugins/{name} - GET - Get plugin details (403 on a disabled plugin the key is not granted)
/claude-code/plugins/{name} - PUT - Update an existing plugin (proxy admin only)
/claude-code/plugins/{name}/enable - POST - Enable a plugin (proxy admin only)
/claude-code/plugins/{name}/disable - POST - Disable a plugin (proxy admin only)
@ -24,11 +24,15 @@ from datetime import datetime, timezone
from typing import Annotated, Final, Protocol, TypedDict
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
from litellm.proxy._types import CommonProxyErrors, ProxyException, UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import (
SkillVisibility,
skill_visibility,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
@ -84,7 +88,7 @@ async def _get_prisma_client() -> object:
"/claude-code/marketplace.json",
tags=["Claude Code Marketplace"],
)
async def get_marketplace():
async def get_marketplace(request: Request, key: str | None = None):
"""
Serve marketplace.json for Claude Code plugin discovery.
@ -92,24 +96,35 @@ async def get_marketplace():
- claude plugin marketplace add <url>
- claude plugin install <name>@<marketplace>
Without `key` the catalog holds the enabled (public) plugins. With `?key=sk-...`
the key is authenticated and the catalog also holds the disabled plugins granted
to it through `object_permission.skills` on the key or its team.
Returns:
Marketplace catalog with list of available plugins and their git sources.
Example:
```bash
claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json
claude plugin marketplace add "http://localhost:4000/claude-code/marketplace.json?key=sk-..."
claude plugin install my-plugin@litellm
```
"""
try:
prisma_client: Final = await _get_prisma_client()
caller: Final[UserAPIKeyAuth | None] = (
await user_api_key_auth(request=request, api_key=f"Bearer {key}") if key else None
)
visibility: Final[SkillVisibility] = skill_visibility(caller)
plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many(
where={"enabled": True}
where=visibility.where()
)
plugin_list: Final = []
for plugin in plugins:
if not visibility.allows(plugin):
continue
try:
manifest: Mapping[str, object] = json.loads(plugin.manifest_json or "{}")
except json.JSONDecodeError:
@ -149,7 +164,7 @@ async def get_marketplace():
return JSONResponse(content=marketplace)
except HTTPException:
except (HTTPException, ProxyException):
raise
except Exception as e:
verbose_proxy_logger.exception("Error generating marketplace: %s", e)
@ -395,13 +410,15 @@ async def list_plugins(
try:
prisma_client: Final = await _get_prisma_client()
where: Final = {"enabled": True} if enabled_only else {}
visibility: Final[SkillVisibility] = skill_visibility(user_api_key_dict)
plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many(
where=where
where={"enabled": True} if enabled_only else visibility.where()
)
plugin_list: Final = []
for p in plugins:
if not visibility.allows(p):
continue
# Parse manifest to get additional fields
manifest = json.loads(p.manifest_json) if p.manifest_json else {}
@ -473,6 +490,12 @@ async def get_plugin(
detail={"error": f"Plugin '{plugin_name}' not found"},
)
if not skill_visibility(user_api_key_dict).allows(plugin):
raise HTTPException(
status_code=403,
detail={"error": f"Plugin '{plugin_name}' is not granted to this key"},
)
manifest: Final[Mapping[str, object]] = json.loads(plugin.manifest_json or "{}") if plugin.manifest_json else {}
return {

View file

@ -0,0 +1,67 @@
"""
Claude Code marketplace visibility: enabled plugins are public, disabled plugins
are private and resolve only for proxy admins or keys granted them via
``object_permission.skills``.
"""
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final, Protocol
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
if TYPE_CHECKING:
from prisma.types import LiteLLM_ClaudeCodePluginTableWhereInput
class _SkillRecord(Protocol):
name: str
enabled: bool
def _skills_of(permission: LiteLLM_ObjectPermissionTable | None) -> frozenset[str]:
return frozenset(permission.skills or ()) if permission is not None else frozenset()
def granted_skills(user_api_key_dict: UserAPIKeyAuth) -> frozenset[str]:
"""Key grant intersected with the team grant when both are non-empty; either alone applies as is.
An empty list is the Prisma column default for every object-permission row, so it means
"no private grants configured here" and defers to the other scope, same as the agents check.
"""
key_skills: Final = _skills_of(user_api_key_dict.object_permission)
team_skills: Final = _skills_of(user_api_key_dict.team_object_permission)
match (bool(key_skills), bool(team_skills)):
case (True, True):
return key_skills & team_skills
case (True, False):
return key_skills
case _:
return team_skills
@dataclass(frozen=True, slots=True)
class SkillVisibility:
granted: frozenset[str]
sees_private: bool
def allows(self, skill: _SkillRecord) -> bool:
return skill.enabled or self.sees_private or skill.name in self.granted
def where(self) -> "LiteLLM_ClaudeCodePluginTableWhereInput":
if self.sees_private:
return {}
if not self.granted:
return {"enabled": True}
return {"OR": [{"enabled": True}, {"name": {"in": sorted(self.granted)}}]}
PUBLIC_ONLY: Final = SkillVisibility(granted=frozenset(), sees_private=False)
def skill_visibility(user_api_key_dict: UserAPIKeyAuth | None) -> SkillVisibility:
if user_api_key_dict is None:
return PUBLIC_ONLY
if is_proxy_admin(user_api_key_dict):
return SkillVisibility(granted=frozenset(), sees_private=True)
return SkillVisibility(granted=granted_skills(user_api_key_dict), sees_private=False)

View file

@ -282,6 +282,7 @@ model LiteLLM_ObjectPermissionTable {
mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user
search_tools String[] @default([]) // search_tool_name values this key/team/user may call
mcp_tool_search_enabled Boolean?
skills String[] @default([]) // Claude Code plugin names granted to this key/team beyond the public (enabled) set
teams LiteLLM_TeamTable[]
projects LiteLLM_ProjectTable[]
verification_tokens LiteLLM_VerificationToken[]

View file

@ -40,6 +40,7 @@ class ObjectPermissionRepository(BaseRepository[LiteLLM_ObjectPermissionTable]):
blocked_tools: list[str] | None = None,
mcp_toolsets: list[str] | None = None,
search_tools: list[str] | None = None,
skills: list[str] | None = None,
) -> LiteLLM_ObjectPermissionTable:
"""Create a new object permission record."""
data: Final[dict[str, Any]] = {}
@ -63,6 +64,8 @@ class ObjectPermissionRepository(BaseRepository[LiteLLM_ObjectPermissionTable]):
data["mcp_toolsets"] = mcp_toolsets
if search_tools is not None:
data["search_tools"] = search_tools
if skills is not None:
data["skills"] = skills
return await self.create(data)
@ -79,6 +82,7 @@ class ObjectPermissionRepository(BaseRepository[LiteLLM_ObjectPermissionTable]):
blocked_tools: list[str] | None = None,
mcp_toolsets: list[str] | None = None,
search_tools: list[str] | None = None,
skills: list[str] | None = None,
) -> LiteLLM_ObjectPermissionTable | None:
"""Update an object permission record."""
data: Final[dict[str, Any]] = {}
@ -102,6 +106,8 @@ class ObjectPermissionRepository(BaseRepository[LiteLLM_ObjectPermissionTable]):
data["mcp_toolsets"] = mcp_toolsets
if search_tools is not None:
data["search_tools"] = search_tools
if skills is not None:
data["skills"] = skills
return await self.update(object_permission_id, data, id_field="object_permission_id")

View file

@ -8,7 +8,7 @@ can adopt the type without violating the SDK-must-not-import-from-proxy
layering rule.
"""
from typing_extensions import TypedDict
from typing_extensions import ReadOnly, TypedDict
class ObjectPermissionDict(TypedDict, total=False):
@ -23,3 +23,4 @@ class ObjectPermissionDict(TypedDict, total=False):
models: list[str] | None
search_tools: list[str] | None
mcp_tool_search_enabled: bool | None
skills: ReadOnly[list[str] | None]

View file

@ -282,6 +282,7 @@ model LiteLLM_ObjectPermissionTable {
mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user
search_tools String[] @default([]) // search_tool_name values this key/team/user may call
mcp_tool_search_enabled Boolean?
skills String[] @default([]) // Claude Code plugin names granted to this key/team beyond the public (enabled) set
teams LiteLLM_TeamTable[]
projects LiteLLM_ProjectTable[]
verification_tokens LiteLLM_VerificationToken[]

View file

@ -216,7 +216,7 @@ async def test_get_marketplace(mock_prisma_client):
)
# Now get the marketplace
response = await get_marketplace()
response = await get_marketplace(request=MagicMock())
# Response is a JSONResponse, get the body
body = json.loads(response.body.decode())

View file

@ -11,17 +11,20 @@ from fastapi import HTTPException
from unittest.mock import AsyncMock, MagicMock
import litellm
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, ProxyException, UserAPIKeyAuth
from litellm.proxy.proxy_server import LitellmUserRoles
from litellm.types.proxy.claude_code_endpoints import (
RegisterPluginRequest,
UpdatePluginRequest,
)
from litellm.proxy.anthropic_endpoints.claude_code_endpoints import claude_code_marketplace
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
delete_plugin,
disable_plugin,
enable_plugin,
get_marketplace,
get_plugin,
list_plugins,
register_plugin,
update_plugin,
)
@ -38,11 +41,15 @@ def _make_mock_prisma():
async def _find_unique(where):
return store.get(where.get("name"))
def _matches(record, where) -> bool:
if "OR" in where:
return any(_matches(record, clause) for clause in where["OR"])
if "enabled" in where and record.enabled != where["enabled"]:
return False
return "name" not in where or record.name in where["name"]["in"]
async def _find_many(where=None):
records = list(store.values())
if where and "enabled" in where:
return [r for r in records if r.enabled == where["enabled"]]
return records
return [r for r in store.values() if _matches(r, where or {})]
async def _create(data):
record = MagicMock()
@ -52,6 +59,8 @@ def _make_mock_prisma():
record.description = data.get("description")
record.manifest_json = data.get("manifest_json", "{}")
record.enabled = data.get("enabled", True)
record.created_at = data.get("created_at")
record.updated_at = data.get("updated_at")
store[data["name"]] = record
return record
@ -271,13 +280,89 @@ async def test_get_marketplace_skips_plugin_with_null_manifest():
table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
await table.create(data={"name": "null-manifest-plugin", "manifest_json": None, "enabled": True})
response = await get_marketplace()
response = await get_marketplace(request=MagicMock())
assert response.status_code == 200
body = json.loads(response.body)
assert [plugin["name"] for plugin in body["plugins"]] == ["good-plugin"]
def _granted_user(skills: list[str]) -> UserAPIKeyAuth:
return UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
api_key="sk-granted",
user_id="granted-user",
object_permission=LiteLLM_ObjectPermissionTable(object_permission_id="perm-1", skills=skills),
)
async def _register_public_and_private_plugins() -> None:
for name, enabled in (("public-skill", True), ("private-skill", False)):
await register_plugin(
request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"),
user_api_key_dict=_USER,
)
if not enabled:
await disable_plugin(plugin_name=name, user_api_key_dict=_USER)
async def _listed_names(user: UserAPIKeyAuth) -> set[str]:
response = await list_plugins(user_api_key_dict=user)
return {plugin.name for plugin in response.plugins}
@pytest.mark.asyncio
async def test_list_plugins_shows_disabled_plugin_only_to_granted_key_or_admin():
await _register_public_and_private_plugins()
assert await _listed_names(_NON_ADMIN_USER) == {"public-skill"}
assert await _listed_names(_granted_user(["other-skill"])) == {"public-skill"}
assert await _listed_names(_granted_user(["private-skill"])) == {"public-skill", "private-skill"}
assert await _listed_names(_USER) == {"public-skill", "private-skill"}
@pytest.mark.asyncio
async def test_get_plugin_returns_403_for_disabled_plugin_the_key_is_not_granted():
await _register_public_and_private_plugins()
with pytest.raises(HTTPException) as exc_info:
await get_plugin(plugin_name="private-skill", user_api_key_dict=_NON_ADMIN_USER)
assert exc_info.value.status_code == 403
assert (await get_plugin(plugin_name="public-skill", user_api_key_dict=_NON_ADMIN_USER))["name"] == "public-skill"
granted = await get_plugin(plugin_name="private-skill", user_api_key_dict=_granted_user(["private-skill"]))
assert granted["name"] == "private-skill"
assert granted["enabled"] is False
async def _marketplace_names(key: str | None) -> list[str]:
response = await get_marketplace(request=MagicMock(), key=key)
assert response.status_code == 200
return sorted(plugin["name"] for plugin in json.loads(response.body)["plugins"])
@pytest.mark.asyncio
async def test_get_marketplace_key_query_param_adds_granted_disabled_plugins(monkeypatch):
await _register_public_and_private_plugins()
keys = {"sk-granted": _granted_user(["private-skill"]), "sk-plain": _NON_ADMIN_USER}
async def _fake_auth(request, api_key: str) -> UserAPIKeyAuth:
token = api_key.removeprefix("Bearer ")
if token not in keys:
raise ProxyException(message="invalid key", type="auth_error", param="key", code=401)
return keys[token]
monkeypatch.setattr(claude_code_marketplace, "user_api_key_auth", _fake_auth)
assert await _marketplace_names(None) == ["public-skill"]
assert await _marketplace_names("sk-plain") == ["public-skill"]
assert await _marketplace_names("sk-granted") == ["private-skill", "public-skill"]
with pytest.raises(ProxyException) as exc_info:
await get_marketplace(request=MagicMock(), key="sk-bogus")
assert exc_info.value.code == "401"
@pytest.mark.asyncio
async def test_register_plugin_git_subdir_missing_url():
"""git-subdir without url field raises HTTP 400."""

View file

@ -0,0 +1,79 @@
"""Unit tests for claude_code_skill_access.py: key/team grant resolution."""
from unittest.mock import MagicMock
import pytest
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import (
granted_skills,
skill_visibility,
)
def _perm(skills: list[str] | None) -> LiteLLM_ObjectPermissionTable:
return LiteLLM_ObjectPermissionTable(object_permission_id="perm", skills=skills)
def _key(key_skills: list[str] | None, team_skills: list[str] | None) -> UserAPIKeyAuth:
return UserAPIKeyAuth(
api_key="sk-user",
user_role=LitellmUserRoles.INTERNAL_USER,
object_permission=_perm(key_skills) if key_skills is not None else None,
team_object_permission=_perm(team_skills) if team_skills is not None else None,
)
def _plugin(name: str, enabled: bool) -> MagicMock:
record = MagicMock()
record.name = name
record.enabled = enabled
return record
@pytest.mark.parametrize(
("key_skills", "team_skills", "expected"),
[
(None, None, frozenset()),
([], [], frozenset()),
(["a", "b"], None, frozenset({"a", "b"})),
(None, ["a", "b"], frozenset({"a", "b"})),
(["a", "b"], ["b", "c"], frozenset({"b"})),
(["a"], ["c"], frozenset()),
(["a", "b"], [], frozenset({"a", "b"})),
([], ["a", "b"], frozenset({"a", "b"})),
],
)
def test_granted_skills_intersects_key_with_team(key_skills, team_skills, expected):
assert granted_skills(_key(key_skills, team_skills)) == expected
def test_visibility_enabled_plugin_is_public_for_everyone():
public = _plugin("public-skill", enabled=True)
assert skill_visibility(None).allows(public)
assert skill_visibility(_key(None, None)).allows(public)
assert skill_visibility(_key([], ["other"])).allows(public)
def test_visibility_disabled_plugin_needs_grant_or_admin():
private = _plugin("private-skill", enabled=False)
admin = UserAPIKeyAuth(api_key="sk-1234", user_role=LitellmUserRoles.PROXY_ADMIN)
assert not skill_visibility(None).allows(private)
assert not skill_visibility(_key(None, None)).allows(private)
assert not skill_visibility(_key(["other-skill"], None)).allows(private)
assert not skill_visibility(_key(["private-skill"], ["other-skill"])).allows(private)
assert skill_visibility(_key(["private-skill"], None)).allows(private)
assert skill_visibility(_key(None, ["private-skill"])).allows(private)
assert skill_visibility(admin).allows(private)
def test_where_clause_bounds_the_plugin_query_to_what_the_caller_may_see():
admin = UserAPIKeyAuth(api_key="sk-1234", user_role=LitellmUserRoles.PROXY_ADMIN)
assert skill_visibility(None).where() == {"enabled": True}
assert skill_visibility(_key(None, None)).where() == {"enabled": True}
assert skill_visibility(_key(["b", "a"], None)).where() == {"OR": [{"enabled": True}, {"name": {"in": ["a", "b"]}}]}
assert skill_visibility(_key(["a", "b"], ["b"])).where() == {"OR": [{"enabled": True}, {"name": {"in": ["b"]}}]}
assert skill_visibility(admin).where() == {}

View file

@ -3826,3 +3826,17 @@ def test_team_disable_logging_stays_proxy_admin_only():
def test_neighbouring_team_routes_stay_closed(route):
"""The grant is the callback paths and nothing else on the team namespace."""
assert "Only proxy admin" in _gate(route, LitellmUserRoles.INTERNAL_USER.value)
@pytest.mark.parametrize(
"route",
[
"/claude-code/marketplace.json",
"/claude-code/plugins",
"/claude-code/plugins/my-skill",
],
)
def test_claude_code_marketplace_routes_open_to_internal_users(route):
"""Per-skill visibility is enforced inside the handler, so the route gate must let non-admins through."""
assert RouteChecks.is_llm_api_route(route) is True
assert _gate(route, LitellmUserRoles.INTERNAL_USER.value) == "allowed"

View file

@ -763,6 +763,7 @@ _EXPECTED_CUSTOMER = {
"blocked_tools": [],
"search_tools": [],
"mcp_tool_search_enabled": None,
"skills": None,
},
}

View file

@ -122,6 +122,29 @@ async def test_set_object_permission_persists_mcp_tool_search_enabled():
assert created_data["mcp_tool_search_enabled"] is True
@pytest.mark.asyncio
async def test_set_object_permission_persists_skills():
mock_prisma_client = MagicMock()
mock_created_permission = MagicMock()
mock_created_permission.object_permission_id = "perm_id"
mock_prisma_client.db.litellm_objectpermissiontable.create = AsyncMock(
return_value=mock_created_permission
)
data_json = {
"object_permission": LiteLLM_ObjectPermissionBase(skills=["private-skill"]).model_dump(),
}
await _set_object_permission(data_json=data_json, prisma_client=mock_prisma_client)
created_data = (
mock_prisma_client.db.litellm_objectpermissiontable.create.call_args.kwargs[
"data"
]
)
assert created_data["skills"] == ["private-skill"]
# ---- Tests for _extract_requested_mcp_server_ids ----

View file

@ -33,6 +33,14 @@ vi.mock("./mcp_server_management/MCPServerSelector", () => ({
),
}));
vi.mock("./skills/SkillSelector", () => ({
default: ({ onChange }: { onChange: (selected: string[]) => void }) => (
<button type="button" data-testid="select-private-skill" onClick={() => onChange(["private-skill"])}>
Select private skill
</button>
),
}));
const can = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({
default: (...args: unknown[]) => can(...args),
@ -1359,6 +1367,27 @@ describe("Teams - the exact bytes the create call sends", () => {
});
});
it("puts the selected skills into object_permission.skills and drops the form key", async () => {
await openCreateModal();
await openSection("Skill Settings", /Allowed Skills/);
fireEvent.click(screen.getByTestId("select-private-skill"));
const payload = await submit();
expect(payload.object_permission).toStrictEqual({ skills: ["private-skill"] });
expect(payload).not.toHaveProperty("object_permission_skills");
});
it("sends no object_permission when Skill Settings is opened but nothing is selected", async () => {
await openCreateModal();
await openSection("Skill Settings", /Allowed Skills/);
const payload = await submit();
expect(payload).not.toHaveProperty("object_permission");
expect(payload).not.toHaveProperty("object_permission_skills");
});
it("includes selected MCP toolsets in the create object permission", async () => {
await openCreateModal();
await openSection("MCP Settings", /Allowed MCP Servers/);

View file

@ -50,6 +50,7 @@ import { Organization, getDefaultTeamSettings, getGuardrailsList, getPoliciesLis
import NumericalInput from "./shared/numerical_input";
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
import SearchToolSelector from "./search_tools/SearchToolSelector";
import SkillSelector from "./skills/SkillSelector";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
interface TeamProps {
@ -99,6 +100,7 @@ const teamCreateFieldsSchema = z.object({
mcp_tool_permissions: z.record(z.string(), z.array(z.string())).optional(),
allowed_agents_and_groups: z.object({ agents: z.array(z.string()), accessGroups: z.array(z.string()) }).optional(),
object_permission_search_tools: z.array(z.string()).optional(),
object_permission_skills: z.array(z.string()).optional(),
});
type TeamCreateFormValues = z.infer<typeof teamCreateFieldsSchema>;
@ -128,6 +130,7 @@ const EMPTY_TEAM_CREATE_VALUES: TeamCreateFormValues = {
mcp_tool_permissions: {},
allowed_agents_and_groups: undefined,
object_permission_search_tools: undefined,
object_permission_skills: undefined,
};
const ADDITIONAL_SETTINGS_FIELDS = [
@ -147,6 +150,7 @@ const ADDITIONAL_SETTINGS_FIELDS = [
const MCP_SETTINGS_FIELDS = ["allowed_mcp_servers_and_groups", "mcp_tool_permissions"] as const;
const AGENT_SETTINGS_FIELDS = ["allowed_agents_and_groups"] as const;
const SEARCH_TOOL_SETTINGS_FIELDS = ["object_permission_search_tools"] as const;
const SKILL_SETTINGS_FIELDS = ["object_permission_skills"] as const;
const isParsableJson = (value: string | undefined): boolean => {
if (!value) {
@ -214,6 +218,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
const [mcpSettingsOpen, setMcpSettingsOpen] = useState(false);
const [agentSettingsOpen, setAgentSettingsOpen] = useState(false);
const [searchToolSettingsOpen, setSearchToolSettingsOpen] = useState(false);
const [skillSettingsOpen, setSkillSettingsOpen] = useState(false);
const adminOrgs = useMemo(
() => getAdminOrganizations(userRole, userID, organizations),
@ -505,6 +510,14 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
delete formValues.object_permission_search_tools;
}
if (Array.isArray(formValues.object_permission_skills) && formValues.object_permission_skills.length > 0) {
if (!formValues.object_permission) {
formValues.object_permission = {};
}
formValues.object_permission.skills = formValues.object_permission_skills;
}
delete formValues.object_permission_skills;
// Add model_aliases if any are defined
if (Object.keys(modelAliases).length > 0) {
formValues.model_aliases = modelAliases;
@ -540,6 +553,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
...(mcpSettingsOpen ? [] : MCP_SETTINGS_FIELDS),
...(agentSettingsOpen ? [] : AGENT_SETTINGS_FIELDS),
...(searchToolSettingsOpen ? [] : SEARCH_TOOL_SETTINGS_FIELDS),
...(skillSettingsOpen ? [] : SKILL_SETTINGS_FIELDS),
]);
return Object.fromEntries(Object.entries(values).filter(([key]) => !unmounted.has(key)));
};
@ -1163,6 +1177,38 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
</CollapsibleContent>
</Collapsible>
<Collapsible
open={skillSettingsOpen}
onOpenChange={setSkillSettingsOpen}
className="mt-8 mb-8 overflow-hidden rounded-lg border"
>
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Skill Settings</b>
<ChevronDown className="size-5 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
<FormField
control={form.control}
name="object_permission_skills"
className="mt-4"
label={labelWithHint(
"Allowed Skills",
"Enabled skills are visible to every team. Grant disabled (private) Claude Code plugins to this team here.",
)}
description="Private skills keys on this team may see in the Claude Code marketplace."
>
{({ value, onChange }) => (
<SkillSelector
onChange={onChange}
value={value}
accessToken={accessToken || ""}
placeholder="Select skills (optional)"
/>
)}
</FormField>
</CollapsibleContent>
</Collapsible>
<Collapsible className="mt-8 mb-8 overflow-hidden rounded-lg border">
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Logging Settings</b>

View file

@ -30,6 +30,7 @@ export function ObjectPermissionsView({
const agents = objectPermission?.agents || [];
const agentAccessGroups = objectPermission?.agent_access_groups || [];
const searchTools = objectPermission?.search_tools || [];
const skills = objectPermission?.skills || [];
const content = (
<div className={variant === "card" ? "grid grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 gap-6" : "space-y-4"}>
@ -58,6 +59,16 @@ export function ObjectPermissionsView({
<p className="mt-1 block text-xs break-words text-foreground">{searchTools.join(", ")}</p>
)}
</div>
<div className="min-w-0 rounded-md border border-border p-4">
<p className="text-sm font-medium text-foreground">Skills</p>
{skills.length === 0 ? (
<p className="mt-1 block text-xs text-muted-foreground">
No private skills granted. Only enabled (public) Claude Code plugins are visible.
</p>
) : (
<p className="mt-1 block text-xs break-words text-foreground">{skills.join(", ")}</p>
)}
</div>
</div>
);

View file

@ -301,6 +301,16 @@ describe("object_permission", () => {
).toStrictEqual(aliasOnly({ object_permission: { agents: ["a-1"], agent_access_groups: ["ag-1"] } }));
});
it("moves the selected skills under object_permission and off the top level", () => {
expect(payloadOf(build({ key_alias: "my-key", allowed_skills: ["private-skill"] }))).toStrictEqual(
aliasOnly({ object_permission: { skills: ["private-skill"] } }),
);
});
it("sends no object_permission for an empty skill selection", () => {
expect(payloadOf(build({ key_alias: "my-key", allowed_skills: [] }))).toStrictEqual(aliasOnly());
});
it("merges every source into a single object_permission", () => {
const everySource = {
key_alias: "my-key",
@ -308,6 +318,7 @@ describe("object_permission", () => {
allowed_mcp_servers_and_groups: { servers: ["s-1"], accessGroups: ["g-1"], toolsets: ["t-1"] },
mcp_tool_permissions: { "s-1": ["read"] },
allowed_agents_and_groups: { agents: ["a-1"], accessGroups: ["ag-1"] },
allowed_skills: ["private-skill"],
};
expect(payloadOf(build(everySource))).toStrictEqual(
aliasOnly({
@ -319,6 +330,7 @@ describe("object_permission", () => {
mcp_tool_permissions: { "s-1": ["read"] },
agents: ["a-1"],
agent_access_groups: ["ag-1"],
skills: ["private-skill"],
},
}),
);

View file

@ -112,6 +112,7 @@ interface PermissionSources {
readonly toolPermissions: unknown | undefined;
readonly extraMcpAccessGroups: unknown[] | undefined;
readonly agents: AgentSelection | undefined;
readonly skills: unknown[] | undefined;
}
const readPermissionSources = (values: Record<string, unknown>): PermissionSources => ({
@ -120,6 +121,7 @@ const readPermissionSources = (values: Record<string, unknown>): PermissionSourc
toolPermissions: readToolPermissions(values.mcp_tool_permissions),
extraMcpAccessGroups: nonEmptyList(values.allowed_mcp_access_groups),
agents: readAgentSelection(values.allowed_agents_and_groups),
skills: nonEmptyList(values.allowed_skills),
});
const buildObjectPermission = ({
@ -128,6 +130,7 @@ const buildObjectPermission = ({
toolPermissions,
extraMcpAccessGroups,
agents,
skills,
}: PermissionSources): Record<string, unknown> | undefined => {
const permission: Record<string, unknown> = {
...(vectorStores && { vector_stores: vectorStores }),
@ -138,6 +141,7 @@ const buildObjectPermission = ({
...(extraMcpAccessGroups && { mcp_access_groups: extraMcpAccessGroups }),
...(agents?.agents && { agents: agents.agents }),
...(agents?.accessGroups && { agent_access_groups: agents.accessGroups }),
...(skills && { skills }),
};
return Object.keys(permission).length > 0 ? permission : undefined;
};
@ -148,6 +152,7 @@ const consumedSourceKeys = (
): ReadonlySet<string> =>
new Set<string>([
"mcp_tool_permissions",
"allowed_skills",
...(values.disable_global_guardrails ? [] : ["disable_global_guardrails"]),
...(vectorStores ? ["allowed_vector_store_ids"] : []),
...(mcp ? ["allowed_mcp_servers_and_groups"] : []),

View file

@ -84,6 +84,13 @@ vi.mock("../networking", async (importOriginal) => {
getPossibleUserRoles: vi.fn().mockResolvedValue({}),
userFilterUICall: vi.fn().mockResolvedValue([]),
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
getClaudeCodePluginsList: vi.fn().mockResolvedValue({
plugins: [
{ name: "public-skill", enabled: true },
{ name: "private-skill", enabled: false },
],
count: 2,
}),
getPassThroughEndpointsCall: vi.fn().mockResolvedValue({ endpoints: [] }),
vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }),
listMCPTools: vi.fn().mockResolvedValue(emptyMcpTools),
@ -109,6 +116,7 @@ const OPENAPI_SCHEMA = {
const SECTIONS = {
mcp: /MCP Settings/i,
agent: /Agent Settings/i,
skill: /Skill Settings/i,
logging: /Logging Settings/i,
router: /Router Settings/i,
aliases: /Model Aliases/i,
@ -164,6 +172,7 @@ const ROUTER_SETTINGS_DEFAULT = {
const SECTION_PAYLOAD_ADDITIONS: Record<keyof typeof SECTIONS, Record<string, unknown>> = {
mcp: { allowed_mcp_servers_and_groups: { servers: [], accessGroups: [] } },
agent: { allowed_agents_and_groups: undefined },
skill: {},
logging: {},
router: { router_settings: ROUTER_SETTINGS_DEFAULT },
aliases: {},
@ -329,6 +338,21 @@ describe("CreateKey", () => {
expect(Object.keys(serialised).sort()).toStrictEqual([...wireKeys].sort());
});
it("moves a picked private skill under object_permission.skills and off the top level", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await openSection(SECTIONS.skill);
await userEvent.click(await screen.findByRole("combobox", { name: "Select skills (optional)" }));
await userEvent.click(await screen.findByRole("option", { name: "private-skill (private)" }));
await userEvent.keyboard("{Escape}");
await submit();
const payload = await createdPayload();
expect(payload.object_permission).toStrictEqual({ skills: ["private-skill"] });
expect(payload).not.toHaveProperty("allowed_skills");
});
it("omits a budget typed into a section the user closed again, rather than sending it as null", async () => {
await openModal();
await nameTheKey();

View file

@ -27,6 +27,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { type Control, useForm, useWatch, type UseFormSetValue } from "react-hook-form";
import { rolesWithWriteAccess } from "../../utils/roles";
import AgentSelector from "../agent_management/AgentSelector";
import SkillSelector from "../skills/SkillSelector";
import AccessGroupSelector from "../common_components/AccessGroupSelector";
import BudgetDurationDropdown from "../common_components/budget_duration_dropdown";
import SchemaFormFields from "../common_components/check_openapi_schema";
@ -1557,6 +1558,36 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
</CollapsibleContent>
</Collapsible>
<Collapsible className="mt-4 mb-4 overflow-hidden rounded-lg border">
<CollapsibleTrigger className={SECTION_HEADER_CLASS}>
<b>Skill Settings</b>
<ChevronDown className={SECTION_CHEVRON_CLASS} />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
<MountedFormField
label={
<span>
Allowed Skills{" "}
<SimpleTooltip content="Enabled skills are visible to every key. Grant disabled (private) Claude Code plugins to this key here">
<Info className="ml-1 inline size-3.5 align-text-bottom" />
</SimpleTooltip>
</span>
}
name="allowed_skills"
help="Select private skills this key can access in the Claude Code marketplace"
>
{(control) => (
<SkillSelector
onChange={control.onChange}
value={control.value as string[] | undefined}
accessToken={accessToken}
placeholder="Select skills (optional)"
/>
)}
</MountedFormField>
</CollapsibleContent>
</Collapsible>
{premiumUser ? (
<Collapsible className="mt-4 mb-4 overflow-hidden rounded-lg border">
<CollapsibleTrigger className={SECTION_HEADER_CLASS}>

View file

@ -0,0 +1,54 @@
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { getClaudeCodePluginsList } from "../networking";
import SkillSelector from "./SkillSelector";
vi.mock("../networking", () => ({
getClaudeCodePluginsList: vi.fn(),
}));
describe("SkillSelector", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getClaudeCodePluginsList).mockResolvedValue({
plugins: [
{ name: "public-skill", enabled: true },
{ name: "private-skill", enabled: false },
],
count: 2,
});
});
it("should list marketplace plugins and mark disabled ones as private", async () => {
const user = userEvent.setup();
renderWithProviders(<SkillSelector accessToken="token" onChange={vi.fn()} />);
await user.click(screen.getByRole("combobox"));
expect(await screen.findByRole("option", { name: "public-skill" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "private-skill (private)" })).toBeInTheDocument();
expect(getClaudeCodePluginsList).toHaveBeenCalledWith("token");
});
it("should report the selected skill names", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithProviders(<SkillSelector accessToken="token" onChange={onChange} />);
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByRole("option", { name: "private-skill (private)" }));
expect(onChange).toHaveBeenCalledWith(["private-skill"]);
});
it("should clear all selected skills", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithProviders(<SkillSelector accessToken="" value={["public-skill"]} onChange={onChange} />);
await user.click(screen.getByRole("button", { name: "Clear all skills" }));
expect(onChange).toHaveBeenCalledWith([]);
});
});

View file

@ -0,0 +1,109 @@
import React, { useEffect, useState } from "react";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxClear,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
import { cn } from "@/lib/cva.config";
import { getClaudeCodePluginsList } from "../networking";
export interface SkillSelectorProps {
onChange: (selected: string[]) => void;
value?: string[];
className?: string;
accessToken: string;
placeholder?: string;
disabled?: boolean;
}
interface SkillOption {
readonly name: string;
readonly enabled: boolean;
}
const readSkillOptions = (data: unknown): SkillOption[] => {
const plugins = (data as { plugins?: unknown[] } | undefined)?.plugins;
if (!Array.isArray(plugins)) return [];
return plugins.flatMap((plugin) => {
const record = plugin as { name?: unknown; enabled?: unknown };
return typeof record.name === "string" && record.name.length > 0
? [{ name: record.name, enabled: record.enabled !== false }]
: [];
});
};
const SkillSelector: React.FC<SkillSelectorProps> = ({
onChange,
value,
className,
accessToken,
placeholder = "Select skills (optional)",
disabled = false,
}) => {
const anchor = useComboboxAnchor();
const [options, setOptions] = useState<SkillOption[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const load = async () => {
if (!accessToken) return;
setLoading(true);
try {
setOptions(readSkillOptions(await getClaudeCodePluginsList(accessToken)));
} catch (e) {
console.error("Failed to load skills:", e);
} finally {
setLoading(false);
}
};
load();
}, [accessToken]);
return (
<Combobox
multiple
items={options.map((option) => option.name)}
value={value ?? []}
onValueChange={(selected: string[]) => onChange(selected)}
disabled={disabled}
>
<ComboboxChips render={<div ref={anchor} />} className={cn("w-full", className)} aria-busy={loading}>
<ComboboxValue>
{(selected: string[]) =>
selected.map((skill) => (
<ComboboxChip key={skill} aria-label={skill}>
{skill}
</ComboboxChip>
))
}
</ComboboxValue>
<ComboboxChipsInput placeholder={placeholder} aria-label={placeholder} disabled={disabled} />
{value && value.length > 0 && <ComboboxClear aria-label="Clear all skills" disabled={disabled} />}
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>{loading ? "Loading skills…" : "No skills found"}</ComboboxEmpty>
<ComboboxList>
{(skill: string) => {
const isPrivate = options.some((option) => option.name === skill && !option.enabled);
return (
<ComboboxItem key={skill} value={skill} aria-label={isPrivate ? `${skill} (private)` : skill}>
{skill}
{isPrivate && <span className="ml-2 text-xs text-muted-foreground">private</span>}
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
};
export default SkillSelector;

View file

@ -52,6 +52,7 @@ vi.mock("@/components/networking", () => ({
listMCPTools: vi.fn().mockResolvedValue({ tools: [] }),
vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }),
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
getClaudeCodePluginsList: vi.fn().mockResolvedValue({ plugins: [], count: 0 }),
}));
const can = vi.fn();
@ -1990,6 +1991,7 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
agents: [],
agent_access_groups: [],
vector_stores: ["vs-1"],
skills: [],
};
it("leaves every team member key out of the request body for an untouched save with both sections closed", async () => {
@ -2052,6 +2054,47 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
expect(objectPermission.agent_access_groups).toStrictEqual([]);
});
it("resends the stored skills when the selector is left untouched", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({ models: ["gpt-4"], object_permission: { skills: ["private-skill"] } }),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...props} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
const payload = await save(user);
const objectPermission = wireBody(payload).object_permission as Record<string, unknown>;
expect(objectPermission.skills).toStrictEqual(["private-skill"]);
});
it("sends an empty skills array after the last skill chip is removed", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({ models: ["gpt-4"], object_permission: { skills: ["private-skill"] } }),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...props} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
await user.click(within(screen.getByLabelText("private-skill")).getByRole("button"));
expect(screen.queryByLabelText("private-skill")).not.toBeInTheDocument();
const payload = await save(user);
const objectPermission = wireBody(payload).object_permission as Record<string, unknown>;
expect(objectPermission.skills).toStrictEqual([]);
});
it("sends an empty vector_stores array after the last vector store chip is removed", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);

View file

@ -89,6 +89,7 @@ import ObjectPermissionsView from "../object_permissions_view";
import NumericalInput from "../shared/numerical_input";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
import SearchToolSelector from "../search_tools/SearchToolSelector";
import SkillSelector from "../skills/SkillSelector";
import EditLoggingSettings from "./EditLoggingSettings";
import RouterSettingsAccordion, { RouterSettingsAccordionRef } from "../common_components/RouterSettingsAccordion";
import MemberModal from "./EditMembership";
@ -371,6 +372,7 @@ const teamUpdateFieldsSchema = z.object({
mcp_tool_permissions: z.record(z.string(), z.array(z.string())).optional(),
agents_and_groups: z.object({ agents: z.array(z.string()), accessGroups: z.array(z.string()) }).optional(),
object_permission_search_tools: z.array(z.string()).optional(),
object_permission_skills: z.array(z.string()).optional(),
organization_id: z.string().nullish(),
logging_settings: z.array(z.unknown()).optional(),
secret_manager_settings: z.string().optional(),
@ -419,6 +421,7 @@ const EMPTY_TEAM_UPDATE_VALUES: TeamUpdateFormValues = {
mcp_tool_permissions: {},
agents_and_groups: { agents: [], accessGroups: [] },
object_permission_search_tools: [],
object_permission_skills: [],
organization_id: null,
logging_settings: [],
secret_manager_settings: "",
@ -485,6 +488,7 @@ const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]):
accessGroups: info.object_permission?.agent_access_groups || [],
},
object_permission_search_tools: info.object_permission?.search_tools || [],
object_permission_skills: info.object_permission?.skills || [],
organization_id: info.organization_id,
logging_settings: info.metadata?.logging || [],
secret_manager_settings: info.metadata?.secret_manager_settings
@ -1047,6 +1051,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
updateData.object_permission.search_tools = values.object_permission_search_tools;
}
if (Array.isArray(values.object_permission_skills)) {
updateData.object_permission.skills = values.object_permission_skills;
}
// Pass access_group_ids to the update request
if (values.access_group_ids !== undefined) {
updateData.access_group_ids = values.access_group_ids;
@ -1848,6 +1856,24 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
</CollapsibleContent>
</Collapsible>
<FormField
control={form.control}
name="object_permission_skills"
label={labelWithHint(
"Skills",
"Enabled skills are visible to every team. Grant disabled (private) Claude Code plugins to this team here.",
)}
>
{({ value, onChange }) => (
<SkillSelector
onChange={onChange}
value={value}
accessToken={accessToken || ""}
placeholder="Select skills (optional)"
/>
)}
</FormField>
<FormField control={form.control} name="organization_id" label="Organization">
{({ id, value, onChange }) => (
<SearchSelect

View file

@ -4,8 +4,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { CircleHelp } from "lucide-react";
import { FormField } from "@/components/shared/form/FormField";
import AgentSelector from "../agent_management/AgentSelector";
import NumericalInput from "../shared/numerical_input";
import { KeyEditFormValues } from "./keyEditFormValues";
import SkillSelector from "../skills/SkillSelector";
import { AgentsAndGroups, KeyEditFormValues } from "./keyEditFormValues";
export const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => (
<>
@ -53,6 +55,36 @@ export const KeyTypeSelect = ({
</Select>
);
const SKILLS_HINT =
"Enabled skills are visible to every key. Grant disabled (private) Claude Code plugins to this key here.";
export const KeyAgentAndSkillFields = ({
control,
accessToken,
}: {
control: Control<KeyEditFormValues>;
accessToken: string;
}) => (
<>
<FormField control={control} name="agents_and_groups" label="Agents / Access Groups">
{({ value, onChange }) => (
<AgentSelector
onChange={onChange}
value={value as AgentsAndGroups | undefined}
accessToken={accessToken}
placeholder="Select agents or access groups (optional)"
/>
)}
</FormField>
<FormField control={control} name="skills" label={labelWithHint("Skills", SKILLS_HINT)}>
{({ value, onChange }) => (
<SkillSelector onChange={onChange} value={value as string[] | undefined} accessToken={accessToken} />
)}
</FormField>
</>
);
export const KeyBudgetNumberField = ({
control,
name,

View file

@ -366,6 +366,45 @@ describe("KeyInfoView handleKeyUpdate mcp_toolsets", () => {
});
});
describe("KeyInfoView handleKeyUpdate skills", () => {
it("should forward the skills the edit form supplies into object_permission and drop the form key", async () => {
renderView(true);
fireEvent.click(screen.getByText("Settings"));
fireEvent.click(screen.getByText("Edit Settings"));
(globalThis as any).__TEST_FORM_VALUES = {
token: "tok_123",
skills: ["private-skill"],
};
fireEvent.click(screen.getByText("Mock Submit"));
await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled());
const [, sentPayload] = keyUpdateCallMock.mock.calls[0];
expect(sentPayload.object_permission.skills).toEqual(["private-skill"]);
expect(sentPayload).not.toHaveProperty("skills");
});
it("should send an explicit empty skills list when the form clears every skill", async () => {
renderView(true);
fireEvent.click(screen.getByText("Settings"));
fireEvent.click(screen.getByText("Edit Settings"));
(globalThis as any).__TEST_FORM_VALUES = {
token: "tok_123",
skills: [],
};
fireEvent.click(screen.getByText("Mock Submit"));
await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled());
const [, sentPayload] = keyUpdateCallMock.mock.calls[0];
expect(sentPayload.object_permission.skills).toEqual([]);
});
});
describe("KeyInfoView handleKeyUpdate budget_duration", () => {
it("should send a canonical budget_duration through unchanged", async () => {
renderView(true);

View file

@ -46,6 +46,7 @@ export interface KeyEditFormValues {
mcp_servers_and_groups?: McpServersAndGroups;
mcp_tool_permissions?: Record<string, string[]>;
agents_and_groups?: AgentsAndGroups;
skills?: string[];
organization_id?: string | null;
team_id?: string | null;
logging_settings?: unknown[];
@ -102,6 +103,7 @@ export const toKeyEditFormValues = (keyData: KeyResponse): KeyEditFormValues =>
agents: keyData.object_permission?.agents || [],
accessGroups: keyData.object_permission?.agent_access_groups || [],
},
skills: keyData.object_permission?.skills || [],
organization_id: keyData.organization_id,
team_id: keyData.team_id,
logging_settings: extractLoggingSettings(keyData.metadata),
@ -148,6 +150,7 @@ export const keyEditFormSchema = z.object({
mcp_servers_and_groups: z.custom<McpServersAndGroups | undefined>(),
mcp_tool_permissions: z.custom<Record<string, string[]> | undefined>(),
agents_and_groups: z.custom<AgentsAndGroups | undefined>(),
skills: z.custom<string[] | undefined>(),
organization_id: z.custom<string | null | undefined>(),
team_id: z.custom<string | null | undefined>(),
logging_settings: z.custom<unknown[] | undefined>(),
@ -196,6 +199,7 @@ export const toSubmittedValues = (
mcp_servers_and_groups: values.mcp_servers_and_groups,
mcp_tool_permissions: values.mcp_tool_permissions,
agents_and_groups: values.agents_and_groups,
skills: values.skills,
organization_id: values.organization_id,
team_id: values.team_id,
logging_settings: values.logging_settings,

View file

@ -59,6 +59,7 @@ vi.mock("../networking", async () => {
agents: [],
}),
getAgentAccessGroups: vi.fn().mockResolvedValue([]),
getClaudeCodePluginsList: vi.fn().mockResolvedValue({ plugins: [], count: 0 }),
};
});
@ -135,6 +136,14 @@ vi.mock("../agent_management/AgentSelector", () => ({
),
}));
vi.mock("../skills/SkillSelector", () => ({
default: ({ onChange }: { onChange: (selected: string[]) => void }) => (
<button type="button" data-testid="skill-selector" onClick={() => onChange(["private-skill"])}>
pick skill
</button>
),
}));
vi.mock("../common_components/AccessGroupSelector", () => ({
default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => (
<input
@ -1907,6 +1916,7 @@ describe("KeyEditView", () => {
mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] },
mcp_tool_permissions: {},
agents_and_groups: { agents: [], accessGroups: [] },
skills: [],
organization_id: null,
team_id: null,
logging_settings: [],
@ -2241,6 +2251,36 @@ describe("KeyEditView", () => {
expect(onSubmitMock.mock.calls[0][0].agents_and_groups.agents).toEqual(["agent-1"]);
});
it("carries a picked skill into the payload", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
await userEvent.click(screen.getByRole("button", { name: "pick skill" }));
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0].skills).toEqual(["private-skill"]);
});
it("preloads the stored skills into the payload when the selector is left untouched", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock, {
...MOCK_KEY_DATA,
object_permission: { ...MOCK_KEY_DATA.object_permission, skills: ["stored-skill"] },
} as KeyResponse);
await screen.findByRole("button", { name: /save changes/i });
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0].skills).toEqual(["stored-skill"]);
});
it("carries an added logging integration into the payload", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);

View file

@ -15,7 +15,6 @@ import { FormField } from "@/components/shared/form/FormField";
import React, { useEffect, useRef, useState } from "react";
import { hasCapability } from "../../utils/capabilities";
import { isProxyAdminRole, rolesWithWriteAccess } from "../../utils/roles";
import AgentSelector from "../agent_management/AgentSelector";
import AccessGroupSelector from "../common_components/AccessGroupSelector";
import BudgetDurationDropdown from "../common_components/budget_duration_dropdown";
import { mapInternalToDisplayNames } from "../callback_info_helpers";
@ -32,9 +31,8 @@ import {
modelSentinelOptions,
parseAllowedRoutes,
} from "./keyEditFieldNormalizers";
import { KeyBudgetNumberField, KeyTypeSelect, labelWithHint } from "./KeyEditViewControls";
import { KeyAgentAndSkillFields, KeyBudgetNumberField, KeyTypeSelect, labelWithHint } from "./KeyEditViewControls";
import {
AgentsAndGroups,
KeyEditFormValues,
keyEditFormSchema,
McpServersAndGroups,
@ -762,16 +760,7 @@ export function KeyEditView({
/>
</div>
<FormField control={form.control} name="agents_and_groups" label="Agents / Access Groups">
{({ value, onChange }) => (
<AgentSelector
onChange={onChange}
value={value as AgentsAndGroups | undefined}
accessToken={accessToken || ""}
placeholder="Select agents or access groups (optional)"
/>
)}
</FormField>
<KeyAgentAndSkillFields control={form.control} accessToken={accessToken || ""} />
<FormField
control={form.control}

View file

@ -277,6 +277,14 @@ export default function KeyInfoView({
delete formValues.agents_and_groups;
}
if (formValues.skills !== undefined) {
formValues.object_permission = {
...formValues.object_permission,
skills: formValues.skills || [],
};
delete formValues.skills;
}
formValues.max_budget = mapEmptyStringToNull(formValues.max_budget);
formValues.tpm_limit = mapEmptyStringToNull(formValues.tpm_limit);
formValues.rpm_limit = mapEmptyStringToNull(formValues.rpm_limit);

View file

@ -2110,12 +2110,17 @@ export interface paths {
* - claude plugin marketplace add <url>
* - claude plugin install <name>@<marketplace>
*
* Without `key` the catalog holds the enabled (public) plugins. With `?key=sk-...`
* the key is authenticated and the catalog also holds the disabled plugins granted
* to it through `object_permission.skills` on the key or its team.
*
* Returns:
* Marketplace catalog with list of available plugins and their git sources.
*
* Example:
* ```bash
* claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json
* claude plugin marketplace add "http://localhost:4000/claude-code/marketplace.json?key=sk-..."
* claude plugin install my-plugin@litellm
* ```
*/
@ -29333,6 +29338,8 @@ export interface components {
models?: string[] | null;
/** Search Tools */
search_tools?: string[] | null;
/** Skills */
skills?: string[] | null;
/** Vector Stores */
vector_stores?: string[] | null;
};
@ -29386,6 +29393,8 @@ export interface components {
* @default []
*/
search_tools: string[] | null;
/** Skills */
skills?: string[] | null;
/**
* Vector Stores
* @default []
@ -43206,7 +43215,9 @@ export interface operations {
};
get_marketplace_claude_code_marketplace_json_get: {
parameters: {
query?: never;
query?: {
key?: string | null;
};
header?: never;
path?: never;
cookie?: never;
@ -43222,6 +43233,15 @@ export interface operations {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_plugins_claude_code_plugins_get: {