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
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,29 @@
Actual plugin files are hosted on GitHub/GitLab/Bitbucket.

Endpoints:
/claude-code/marketplace.json - GET - List plugins for Claude Code discovery
/claude-code/plugins - POST - Register a new plugin (create-only)
/claude-code/plugins - GET - List plugins (admin)
/claude-code/plugins/{name} - GET - Get plugin details
/claude-code/plugins/{name} - PUT - Update an existing plugin
/claude-code/plugins/{name}/enable - POST - Enable a plugin
/claude-code/plugins/{name}/disable - POST - Disable a plugin
/claude-code/plugins/{name} - DELETE - Delete a plugin
/claude-code/marketplace.json - GET - List plugins for Claude Code discovery (unauthenticated)
/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/{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)
/claude-code/plugins/{name} - DELETE - Delete a plugin (proxy admin only)
"""

import json
import re
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from typing import Final, Protocol, TypedDict
from typing import Annotated, Final, Protocol, TypedDict

from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse

from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
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
from litellm.types.proxy.claude_code_endpoints import (
ListPluginsResponse,
Expand Down Expand Up @@ -221,6 +222,18 @@ def _name_conflict_error(name: str) -> HTTPException:
)


def _require_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> None:
"""Catalog mutations are restricted to proxy admins: marketplace.json is served
unauthenticated and any registered/updated entry is immediately installable by
every user, so a non-admin key must never be able to add or overwrite one.
"""
if not is_proxy_admin(user_api_key_dict):
raise HTTPException(
status_code=403,
detail={"error": "Only proxy admins may modify the Claude Code plugin marketplace."},
)


@router.post(
"/claude-code/plugins",
tags=["Claude Code Marketplace"],
Expand All @@ -242,6 +255,8 @@ async def register_plugin(
the same name already exists it returns 409 Conflict; use
PUT /claude-code/plugins/{plugin_name} to update an existing plugin.

Requires a proxy admin API key.

Parameters:
- name: Plugin name (kebab-case)
- source: Git source reference (github, url, or git-subdir format)
Expand Down Expand Up @@ -271,6 +286,8 @@ async def register_plugin(
from prisma.errors import UniqueViolationError

try:
_require_proxy_admin(user_api_key_dict)

prisma_client: Final = await _get_prisma_client()

if not re.match(r"^[a-z0-9-]+$", request.name):
Expand Down Expand Up @@ -468,6 +485,7 @@ async def get_plugin(
async def update_plugin(
plugin_name: str,
request: UpdatePluginRequest,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
):
"""
Update an existing plugin in the LiteLLM marketplace.
Expand All @@ -481,6 +499,8 @@ async def update_plugin(
Returns 404 if no plugin with the given name exists; use
POST /claude-code/plugins to create a new plugin.

Requires a proxy admin API key.

Parameters:
- plugin_name: Name of the plugin to update (path parameter)
- source: Git source reference (github, url, or git-subdir format)
Expand Down Expand Up @@ -509,6 +529,8 @@ async def update_plugin(
from prisma.errors import PrismaError

try:
_require_proxy_admin(user_api_key_dict)

prisma_client: Final = await _get_prisma_client()

_validate_plugin_source(request.source)
Expand Down Expand Up @@ -566,10 +588,14 @@ async def enable_plugin(
"""
Enable a disabled plugin.

Requires a proxy admin API key.

Parameters:
- plugin_name: The name of the plugin to enable
"""
try:
_require_proxy_admin(user_api_key_dict)

prisma_client: Final = await _get_prisma_client()

plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
Expand Down Expand Up @@ -611,10 +637,14 @@ async def disable_plugin(
"""
Disable a plugin without deleting it.

Requires a proxy admin API key.

Parameters:
- plugin_name: The name of the plugin to disable
"""
try:
_require_proxy_admin(user_api_key_dict)

prisma_client: Final = await _get_prisma_client()

plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
Expand Down Expand Up @@ -656,10 +686,14 @@ async def delete_plugin(
"""
Delete a plugin from the marketplace.

Requires a proxy admin API key.

Parameters:
- plugin_name: The name of the plugin to delete
"""
try:
_require_proxy_admin(user_api_key_dict)

prisma_client: Final = await _get_prisma_client()

plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
UpdatePluginRequest,
)
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
delete_plugin,
disable_plugin,
enable_plugin,
get_marketplace,
register_plugin,
update_plugin,
Expand Down Expand Up @@ -72,6 +75,12 @@ async def _update(where, data):
user_id="test-user",
)

_NON_ADMIN_USER = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
api_key="sk-5678",
user_id="regular-user",
)

_GIT_SUBDIR_SOURCE = {
"source": "git-subdir",
"url": "https://github.com/org/monorepo.git",
Expand Down Expand Up @@ -151,6 +160,7 @@ async def test_update_plugin_replaces_existing_source():
response = await update_plugin(
plugin_name=name,
request=UpdatePluginRequest(source=new_source, version="2.0.0", description="updated"),
user_api_key_dict=_USER,
)

assert response.status == "success"
Expand All @@ -170,6 +180,7 @@ async def test_update_plugin_not_found():
await update_plugin(
plugin_name="does-not-exist",
request=UpdatePluginRequest(source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_USER,
)

assert exc_info.value.status_code == 404
Expand Down Expand Up @@ -213,6 +224,7 @@ async def test_update_plugin_db_error_maps_to_structured_500():
await update_plugin(
plugin_name=name,
request=UpdatePluginRequest(source={"source": "github", "repo": "org/replacement"}),
user_api_key_dict=_USER,
)

assert exc_info.value.status_code == 500
Expand Down Expand Up @@ -341,3 +353,62 @@ async def test_register_plugin_unknown_source_type():

assert exc_info.value.status_code == 400
assert "git-subdir" in exc_info.value.detail["error"]


@pytest.mark.asyncio
async def test_register_plugin_rejects_non_admin():
"""A non-admin key cannot add an entry to the marketplace catalog."""
request = RegisterPluginRequest(name="attacker-plugin", source=_GIT_SUBDIR_SOURCE)

with pytest.raises(HTTPException) as exc_info:
await register_plugin(request=request, user_api_key_dict=_NON_ADMIN_USER)

assert exc_info.value.status_code == 403

table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
assert await table.find_unique(where={"name": "attacker-plugin"}) is None


@pytest.mark.asyncio
async def test_update_plugin_rejects_non_admin_overwrite():
"""A non-admin key cannot overwrite an existing plugin's source."""
name = "trusted-plugin"
await register_plugin(
request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"),
user_api_key_dict=_USER,
)

malicious_source = {"source": "github", "repo": "attacker/malicious-repo"}
with pytest.raises(HTTPException) as exc_info:
await update_plugin(
plugin_name=name,
request=UpdatePluginRequest(source=malicious_source),
user_api_key_dict=_NON_ADMIN_USER,
)

assert exc_info.value.status_code == 403

stored = await _read_stored_manifest(name)
assert stored["source"] == _GIT_SUBDIR_SOURCE


@pytest.mark.asyncio
async def test_enable_disable_delete_plugin_reject_non_admin():
"""Non-admin keys cannot enable, disable, or delete catalog entries."""
name = "trusted-plugin-2"
await register_plugin(
request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"),
user_api_key_dict=_USER,
)

for coro in (
enable_plugin(plugin_name=name, user_api_key_dict=_NON_ADMIN_USER),
disable_plugin(plugin_name=name, user_api_key_dict=_NON_ADMIN_USER),
delete_plugin(plugin_name=name, user_api_key_dict=_NON_ADMIN_USER),
):
with pytest.raises(HTTPException) as exc_info:
await coro
assert exc_info.value.status_code == 403

table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
assert (await table.find_unique(where={"name": name})).enabled is True
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ import {
isValidSubPath,
buildMarketplaceSettingsSnippet,
} from "./helpers";
import { MarketplacePluginEntry, PluginSource } from "./types";
import { MarketplacePluginEntry } from "./types";

describe("buildMarketplaceSettingsSnippet", () => {
it("nests the url under a source object so Claude Code accepts the marketplace", () => {
expect(JSON.parse(buildMarketplaceSettingsSnippet("https://proxy.example.com"))).toEqual({
extraKnownMarketplaces: {
"my-org": {
litellm: {
source: {
source: "url",
url: "https://proxy.example.com/claude-code/marketplace.json",
Expand All @@ -37,28 +37,12 @@ describe("buildMarketplaceSettingsSnippet", () => {
});

describe("formatInstallCommand", () => {
it("formats github source with repo", () => {
const source: PluginSource = { source: "github", repo: "org/repo" };
expect(formatInstallCommand({ name: "my-plugin", source })).toBe("/plugin marketplace add org/repo");
it("produces a /plugin install command scoped to the litellm marketplace", () => {
expect(formatInstallCommand({ name: "my-plugin" })).toBe("/plugin install my-plugin@litellm");
});

it("formats url source", () => {
const source: PluginSource = { source: "url", url: "https://example.com/plugin" };
expect(formatInstallCommand({ name: "my-plugin", source })).toBe(
"/plugin marketplace add https://example.com/plugin",
);
});

it("formats git-subdir source using its url", () => {
const source: PluginSource = { source: "git-subdir", url: "https://github.com/org/repo", path: "plugins/x" };
expect(formatInstallCommand({ name: "my-plugin", source })).toBe(
"/plugin marketplace add https://github.com/org/repo",
);
});

it("falls back to plugin name when no repo or url", () => {
const source: PluginSource = { source: "github" };
expect(formatInstallCommand({ name: "my-plugin", source })).toBe("/plugin marketplace add my-plugin");
it("uses the plugin name as the identifier", () => {
expect(formatInstallCommand({ name: "code-review" })).toBe("/plugin install code-review@litellm");
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
return null;
}
if (
url.protocol !== "https:" ||

Check warning on line 56 in ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Boolean expression combines 6 conditions; extract it into a named variable
url.username !== "" ||
url.password !== "" ||
!url.hostname.includes(".") ||
Expand Down Expand Up @@ -179,13 +179,14 @@
/**
* Build the `~/.claude/settings.json` snippet that registers the proxy as a marketplace.
* Claude Code expects `extraKnownMarketplaces.<name>.source` to be a source object, not a
* bare `"url"` string, so the url/source pair is nested one level deeper.
* bare `"url"` string, so the url/source pair is nested one level deeper. The key must be
* "litellm" to match the name the proxy returns in marketplace.json.
*/
export const buildMarketplaceSettingsSnippet = (proxyOrigin: string): string =>
JSON.stringify(
{
extraKnownMarketplaces: {
"my-org": {
litellm: {
source: {
source: "url",
url: `${proxyOrigin}/claude-code/marketplace.json`,
Expand All @@ -198,20 +199,10 @@
);

/**
* Generate install command for Claude Code CLI
* Format: /plugin marketplace add org/repo OR /plugin marketplace add url
* Generate install command for Claude Code CLI.
* Installs the named plugin from the "litellm" marketplace registered in settings.json.
*/
export const formatInstallCommand = (plugin: { name: string; source: PluginSource }): string => {
const { source } = plugin;
if (source.source === "github" && source.repo) {
return `/plugin marketplace add ${source.repo}`;
}
if ((source.source === "url" || source.source === "git-subdir") && source.url) {
return `/plugin marketplace add ${source.url}`;
}
// Fallback to plugin name
return `/plugin marketplace add ${plugin.name}`;
};
export const formatInstallCommand = (plugin: { name: string }): string => `/plugin install ${plugin.name}@litellm`;
Comment thread
veria-ai[bot] marked this conversation as resolved.

/**
* Extract unique categories from plugins list
Expand Down
Loading
Loading