From 55a80add5351e9491e073e6694c20a2f700f83c3 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 12 Aug 2026 09:58:17 -0700 Subject: [PATCH] feat(skills): self-service skill submission with admin review Resolves LIT-5465 --- .../migration.sql | 8 + .../litellm_proxy_extras/schema.prisma | 25 +- litellm/proxy/_lazy_openapi_snapshot.json | 341 ++++++++++- litellm/proxy/_types.py | 4 + .../claude_code_marketplace.py | 338 ++++++++++- .../public_endpoints/public_endpoints.py | 7 +- litellm/proxy/schema.prisma | 25 +- litellm/types/proxy/claude_code_endpoints.py | 52 +- schema.prisma | 25 +- .../test_claude_code_marketplace.py | 562 +++++++++++++++++- .../proxy/auth/test_route_checks.py | 26 + .../public_endpoints/test_public_endpoints.py | 45 ++ .../ClaudeCodePluginsPanel.test.tsx | 116 +++- .../_components/ClaudeCodePluginsPanel.tsx | 70 ++- .../skills/_components/PluginTable.test.tsx | 47 +- .../skills/_components/PluginTable.tsx | 14 +- .../skills/_components/PluginTableColumns.tsx | 52 +- .../skills/_components/ReviewSkillDialog.tsx | 83 +++ .../skills/_components/add_plugin_form.tsx | 8 +- .../MakeSkillPublicForm.tsx | 34 +- .../claude_code_plugins/helpers.test.ts | 48 ++ .../components/claude_code_plugins/helpers.ts | 42 +- .../components/claude_code_plugins/types.ts | 12 + .../src/components/networking.tsx | 26 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 274 ++++++++- 25 files changed, 2176 insertions(+), 108 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260812000000_add_claudecodeplugin_approval_status/migration.sql create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ReviewSkillDialog.tsx diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260812000000_add_claudecodeplugin_approval_status/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260812000000_add_claudecodeplugin_approval_status/migration.sql new file mode 100644 index 00000000000..0010e5934ca --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260812000000_add_claudecodeplugin_approval_status/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "LiteLLM_ClaudeCodePluginTable" ADD COLUMN IF NOT EXISTS "approval_status" TEXT DEFAULT 'active'; +ALTER TABLE "LiteLLM_ClaudeCodePluginTable" ADD COLUMN IF NOT EXISTS "review_notes" TEXT; +ALTER TABLE "LiteLLM_ClaudeCodePluginTable" ADD COLUMN IF NOT EXISTS "reviewed_by" TEXT; +ALTER TABLE "LiteLLM_ClaudeCodePluginTable" ADD COLUMN IF NOT EXISTS "reviewed_at" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "LiteLLM_ClaudeCodePluginTable_approval_status_idx" ON "LiteLLM_ClaudeCodePluginTable"("approval_status"); diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 79d778fb464..d562194033e 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -1338,17 +1338,22 @@ model LiteLLM_AccessGroupTable { } // Claude Code Plugin Marketplace table model LiteLLM_ClaudeCodePluginTable { - id String @id @default(uuid()) - name String @unique - version String? - description String? - manifest_json String? - files_json String? @default("{}") - enabled Boolean @default(true) - created_at DateTime? @default(now()) - updated_at DateTime? @default(now()) @updatedAt - created_by String? + id String @id @default(uuid()) + name String @unique + version String? + description String? + manifest_json String? + files_json String? @default("{}") + enabled Boolean @default(true) + approval_status String? @default("active") + review_notes String? + reviewed_by String? + reviewed_at DateTime? + created_at DateTime? @default(now()) + updated_at DateTime? @default(now()) @updatedAt + created_by String? + @@index([approval_status]) @@map("LiteLLM_ClaudeCodePluginTable") } diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 7fe02c6d8bc..c520d3c6d14 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -4315,6 +4315,33 @@ "claude_code_marketplace": { "components": { "schemas": { + "ApprovePluginRequest": { + "description": "Administrator approval of a submitted skill.\n\nThe fingerprint binds the approval to the content the administrator read.\nA submitter who edits their submission after it was read, but before it is\napproved, changes the fingerprint, so the approval is refused rather than\npublishing a manifest nobody reviewed.", + "properties": { + "review_notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Reviewer feedback shown to the submitter", + "title": "Review Notes" + }, + "reviewed_fingerprint": { + "description": "manifest_fingerprint of the submission that was reviewed, from GET /claude-code/plugins", + "title": "Reviewed Fingerprint", + "type": "string" + } + }, + "required": [ + "reviewed_fingerprint" + ], + "title": "ApprovePluginRequest", + "type": "object" + }, "HTTPValidationError": { "properties": { "detail": { @@ -4380,6 +4407,16 @@ "PluginListItem": { "description": "Plugin item in list responses.", "properties": { + "approval_status": { + "default": "active", + "enum": [ + "pending_review", + "active", + "rejected" + ], + "title": "Approval Status", + "type": "string" + }, "author": { "anyOf": [ { @@ -4412,6 +4449,17 @@ ], "title": "Created At" }, + "created_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created By" + }, "description": { "anyOf": [ { @@ -4467,6 +4515,11 @@ ], "title": "Keywords" }, + "manifest_fingerprint": { + "default": "", + "title": "Manifest Fingerprint", + "type": "string" + }, "name": { "title": "Name", "type": "string" @@ -4482,6 +4535,39 @@ ], "title": "Namespace" }, + "review_notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Review Notes" + }, + "reviewed_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reviewed At" + }, + "reviewed_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reviewed By" + }, "source": { "additionalProperties": { "type": "string" @@ -4528,6 +4614,17 @@ "PluginResponse": { "description": "Plugin information in API responses.", "properties": { + "approval_status": { + "default": "active", + "description": "Administrator approval state", + "enum": [ + "pending_review", + "active", + "rejected" + ], + "title": "Approval Status", + "type": "string" + }, "description": { "anyOf": [ { @@ -4713,7 +4810,7 @@ "description": "Response from plugin registration.", "properties": { "action": { - "description": "Action taken (created/updated)", + "description": "Action taken (created/submitted_for_review/updated)", "title": "Action", "type": "string" }, @@ -4735,6 +4832,99 @@ "title": "RegisterPluginResponse", "type": "object" }, + "RejectPluginRequest": { + "description": "Administrator rejection of a submitted skill.", + "properties": { + "review_notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Reviewer feedback shown to the submitter", + "title": "Review Notes" + } + }, + "title": "RejectPluginRequest", + "type": "object" + }, + "ReviewPluginResponse": { + "description": "Response from approving or rejecting a submitted skill.", + "properties": { + "approval_status": { + "description": "Resulting approval state", + "enum": [ + "pending_review", + "active", + "rejected" + ], + "title": "Approval Status", + "type": "string" + }, + "enabled": { + "description": "Whether the skill is now served to users", + "title": "Enabled", + "type": "boolean" + }, + "name": { + "description": "Skill name", + "title": "Name", + "type": "string" + }, + "review_notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Reviewer feedback", + "title": "Review Notes" + }, + "reviewed_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO timestamp of the review", + "title": "Reviewed At" + }, + "reviewed_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "User id of the reviewing administrator", + "title": "Reviewed By" + }, + "status": { + "description": "Operation status", + "title": "Status", + "type": "string" + } + }, + "required": [ + "status", + "name", + "approval_status", + "enabled" + ], + "title": "ReviewPluginResponse", + "type": "object" + }, "UpdatePluginRequest": { "description": "Request body for replacing an existing plugin.\n\nThe plugin name is the resource identity and is supplied as the path\nparameter, so it cannot be changed here. This is a full replace: omitted\nfields reset to their defaults, so version is cleared rather than\ndefaulting to the create-time \"1.0.0\".", "properties": { @@ -4916,7 +5106,7 @@ }, "/claude-code/plugins": { "get": { - "description": "List all plugins in the marketplace.\n\nParameters:\n - enabled_only: If true, only return enabled plugins\n\nReturns:\n List of plugins with their metadata.", + "description": "List plugins in the marketplace.\n\nAdmins see every skill, including submissions awaiting review. Everyone\nelse sees approved skills plus their own submissions, so a submitter can\ntrack the status of what they sent in.\n\nParameters:\n - enabled_only: If true, only return enabled plugins\n - approval_status: Filter to one approval state, e.g. `pending_review` for the admin review queue\n\nReturns:\n List of plugins with their metadata and review state.", "operationId": "list_plugins_claude_code_plugins_get", "parameters": [ { @@ -4928,6 +5118,27 @@ "title": "Enabled Only", "type": "boolean" } + }, + { + "in": "query", + "name": "approval_status", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "pending_review", + "active", + "rejected" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Approval Status" + } } ], "responses": { @@ -4963,7 +5174,7 @@ ] }, "post": { - "description": "Register a new plugin in the LiteLLM marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket. Claude Code will clone from the git source\nwhen users install.\n\nThis endpoint is create-only and never overwrites. If a plugin with\nthe same name already exists it returns 409 Conflict; use\nPUT /claude-code/plugins/{plugin_name} to update an existing plugin.\n\nParameters:\n - name: Plugin name (kebab-case)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Registration status (action is always \"created\") and plugin information.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"my-plugin\",\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"1.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", + "description": "Register a new plugin in the LiteLLM marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket. Claude Code will clone from the git source\nwhen users install.\n\nThis endpoint is create-only and never overwrites. If a plugin with\nthe same name already exists it returns 409 Conflict; use\nPUT /claude-code/plugins/{plugin_name} to update an existing plugin.\n\nCallers that are not proxy admins are self-service submitters: the skill\nis stored with approval_status=pending_review and stays disabled until an\nadmin approves it via POST /claude-code/plugins/{plugin_name}/approve.\n\nParameters:\n - name: Plugin name (kebab-case)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Registration status (\"created\" for admins, \"submitted_for_review\" otherwise)\n and plugin information.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"my-plugin\",\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"1.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", "operationId": "register_plugin_claude_code_plugins_post", "requestBody": { "content": { @@ -5010,7 +5221,7 @@ }, "/claude-code/plugins/{plugin_name}": { "delete": { - "description": "Delete a plugin from the marketplace.\n\nParameters:\n - plugin_name: The name of the plugin to delete", + "description": "Delete a plugin from the marketplace. Admins can delete any skill; a\nsubmitter can withdraw one they submitted.\n\nParameters:\n - plugin_name: The name of the plugin to delete", "operationId": "delete_plugin_claude_code_plugins__plugin_name__delete", "parameters": [ { @@ -5098,7 +5309,7 @@ ] }, "put": { - "description": "Update an existing plugin in the LiteLLM marketplace.\n\nThe plugin is identified by its name in the path, which is the resource\nidentity and cannot be changed here. This is a full replace, not a merge:\nthe manifest is rebuilt from the request body, so any optional field left\nout is reset to its default (e.g. an omitted version is cleared, not kept).\nSend the full desired state.\n\nReturns 404 if no plugin with the given name exists; use\nPOST /claude-code/plugins to create a new plugin.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Update status (action is always \"updated\") and plugin information.\n\nExample:\n ```bash\n curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"2.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", + "description": "Update an existing plugin in the LiteLLM marketplace.\n\nThe plugin is identified by its name in the path, which is the resource\nidentity and cannot be changed here. This is a full replace, not a merge:\nthe manifest is rebuilt from the request body, so any optional field left\nout is reset to its default (e.g. an omitted version is cleared, not kept).\nSend the full desired state.\n\nReturns 404 if no plugin with the given name exists; use\nPOST /claude-code/plugins to create a new plugin.\n\nAdmins can update any skill and the review state is left untouched. A\nsubmitter can only update their own skill, and doing so sends it back to\npending review, since the content an admin approved has changed.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Update status (action is always \"updated\") and plugin information.\n\nExample:\n ```bash\n curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"2.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```", "operationId": "update_plugin_claude_code_plugins__plugin_name__put", "parameters": [ { @@ -5154,9 +5365,67 @@ ] } }, + "/claude-code/plugins/{plugin_name}/approve": { + "post": { + "description": "Approve a submitted skill (admin only).\n\nApproving sets approval_status=active and publishes the skill to\nmarketplace.json and the public Skill Hub.\n\nreviewed_fingerprint is the manifest_fingerprint returned by\nGET /claude-code/plugins and GET /claude-code/plugins/{plugin_name}. It\nties the approval to the content that was read, so a submitter cannot get\nan edit published by landing it between the read and the approval.\n\nExample:\n ```bash\n FP=$(curl -s http://localhost:4000/claude-code/plugins/my-skill \\\n -H \"Authorization: Bearer sk-admin-...\" | jq -r .manifest_fingerprint)\n curl -X POST http://localhost:4000/claude-code/plugins/my-skill/approve \\\n -H \"Authorization: Bearer sk-admin-...\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\\\"reviewed_fingerprint\\\": \\\"$FP\\\"}\"\n ```", + "operationId": "approve_plugin_claude_code_plugins__plugin_name__approve_post", + "parameters": [ + { + "in": "path", + "name": "plugin_name", + "required": true, + "schema": { + "title": "Plugin Name", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApprovePluginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewPluginResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Approve Plugin", + "tags": [ + "claude_code_marketplace" + ] + } + }, "/claude-code/plugins/{plugin_name}/disable": { "post": { - "description": "Disable a plugin without deleting it.\n\nParameters:\n - plugin_name: The name of the plugin to disable", + "description": "Disable a plugin without deleting it. Proxy admins only.\n\nParameters:\n - plugin_name: The name of the plugin to disable", "operationId": "disable_plugin_claude_code_plugins__plugin_name__disable_post", "parameters": [ { @@ -5202,7 +5471,7 @@ }, "/claude-code/plugins/{plugin_name}/enable": { "post": { - "description": "Enable a disabled plugin.\n\nParameters:\n - plugin_name: The name of the plugin to enable", + "description": "Enable a disabled plugin. Proxy admins only.\n\nA skill that has not been approved cannot be enabled here: approve it\nthrough POST /claude-code/plugins/{plugin_name}/approve instead, so the\nreviewer is recorded on the row.\n\nParameters:\n - plugin_name: The name of the plugin to enable", "operationId": "enable_plugin_claude_code_plugins__plugin_name__enable_post", "parameters": [ { @@ -5245,6 +5514,64 @@ "claude_code_marketplace" ] } + }, + "/claude-code/plugins/{plugin_name}/reject": { + "post": { + "description": "Reject a submitted skill (admin only).\n\nThe row is kept unpublished so the submitter can read review_notes and fix the submission.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins/my-skill/reject \\\n -H \"Authorization: Bearer sk-admin-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"review_notes\": \"point the source at the skill folder\"}'\n ```", + "operationId": "reject_plugin_claude_code_plugins__plugin_name__reject_post", + "parameters": [ + { + "in": "path", + "name": "plugin_name", + "required": true, + "schema": { + "title": "Plugin Name", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RejectPluginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewPluginResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Reject Plugin", + "tags": [ + "claude_code_marketplace" + ] + } } } }, diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index bb330d00756..6fbaf2661b0 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -774,6 +774,10 @@ class LiteLLMRoutes(enum.Enum): # Create/update/delete and test_connection stay admin-only. "/search_tools/list", "/search_tools/ui/available_providers", + # Self-service skill submission. Approve/reject stay admin-only. + "/claude-code/marketplace.json", + "/claude-code/plugins", + "/claude-code/plugins/{plugin_name}", ] + spend_tracking_routes + key_management_routes diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py index 46ee9b0911d..d44572e3127 100644 --- a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py @@ -13,14 +13,23 @@ Endpoints: /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}/approve - POST - Approve a submitted plugin (admin) +/claude-code/plugins/{name}/reject - POST - Reject a submitted plugin (admin) /claude-code/plugins/{name} - DELETE - Delete a plugin + +Skills registered by a non-admin are submissions: they are stored with +approval_status="pending_review" and disabled until an administrator approves +them, so only approved skills reach marketplace.json and the public Skill Hub. +This mirrors the MCP server and guardrail submission flows. """ +import hashlib import json import re from collections.abc import Mapping, Sequence from datetime import datetime, timezone -from typing import Final, Protocol, TypedDict +from types import MappingProxyType +from typing import Annotated, Final, Protocol, TypedDict from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import JSONResponse @@ -28,14 +37,26 @@ 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 ( + get_primary_resource_owner_scope, + get_resource_owner_scopes, + is_proxy_admin, +) from litellm.repositories.table_repositories import ClaudeCodePluginRepository from litellm.types.proxy.claude_code_endpoints import ( + SKILL_ACTIVE, + SKILL_PENDING_REVIEW, + SKILL_REJECTED, + ApprovePluginRequest, ListPluginsResponse, PluginListItem, PluginResponse, PluginSpec, RegisterPluginRequest, RegisterPluginResponse, + RejectPluginRequest, + ReviewPluginResponse, + SkillApprovalStatus, UpdatePluginRequest, ) @@ -49,6 +70,10 @@ class _PluginRecord(Protocol): description: str | None manifest_json: str | None enabled: bool + approval_status: str | None + review_notes: str | None + reviewed_by: str | None + reviewed_at: datetime | None created_at: datetime | None updated_at: datetime | None created_by: str | None @@ -65,6 +90,60 @@ class _MarketplaceEntry(TypedDict, total=False): category: object +def published_skill_filter() -> dict[str, object]: # mutable-ok: prisma query arguments must be plain dicts + """Where-clause for the skills served to users: admin-approved and enabled.""" + return {"enabled": True, "approval_status": SKILL_ACTIVE} # mutable-ok: prisma query arguments are dicts + + +def _submitter_edit_resets_review(*, is_admin: bool) -> Mapping[str, object]: + if is_admin: + return MappingProxyType({}) + return MappingProxyType({"approval_status": SKILL_PENDING_REVIEW, "enabled": False}) + + +def _manifest_fingerprint(manifest_json: str | None) -> str: + """Names the exact submitted content, so an approval can be tied to the manifest the administrator read.""" + return hashlib.sha256((manifest_json or "").encode()).hexdigest() + + +def _as_approval_status(raw: str | None) -> SkillApprovalStatus: + """Rows written before approval existed, and any hand-edited row, read back as active.""" + match raw: + case "pending_review": + return SKILL_PENDING_REVIEW + case "rejected": + return SKILL_REJECTED + case _: + return SKILL_ACTIVE + + +def _caller_can_see(plugin: "_PluginRecord", user_api_key_dict: UserAPIKeyAuth) -> bool: + return ( + is_proxy_admin(user_api_key_dict) + or _as_approval_status(plugin.approval_status) == SKILL_ACTIVE + or plugin.created_by in get_resource_owner_scopes(user_api_key_dict) + ) + + +def _list_plugins_filter( + *, + enabled_only: bool, + approval_status: SkillApprovalStatus | None, + user_api_key_dict: UserAPIKeyAuth, +) -> dict[str, object]: # mutable-ok: prisma query arguments must be plain dicts + status_terms: Final[tuple[tuple[str, object], ...]] = ( + *((("enabled", True),) if enabled_only else ()), + *((("approval_status", approval_status),) if approval_status is not None else ()), + ) + if is_proxy_admin(user_api_key_dict): + return dict(status_terms) # mutable-ok: prisma query arguments are dicts + + owner_scopes: Final = get_resource_owner_scopes(user_api_key_dict) + own_skills: Final = ({"created_by": {"in": owner_scopes}},) if owner_scopes else () # mutable-ok: prisma dicts + visible: Final = [{"approval_status": SKILL_ACTIVE}, *own_skills] # mutable-ok: prisma query arguments are dicts + return dict((*status_terms, ("OR", visible))) # mutable-ok: prisma query arguments are dicts + + async def _get_prisma_client() -> object: """Get the prisma client from proxy_server.""" from litellm.proxy.proxy_server import prisma_client @@ -102,7 +181,7 @@ async def get_marketplace(): prisma_client: Final = await _get_prisma_client() plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many( - where={"enabled": True} + where=published_skill_filter() ) plugin_list: Final = [] @@ -242,6 +321,10 @@ 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. + Callers that are not proxy admins are self-service submitters: the skill + is stored with approval_status=pending_review and stays disabled until an + admin approves it via POST /claude-code/plugins/{plugin_name}/approve. + Parameters: - name: Plugin name (kebab-case) - source: Git source reference (github, url, or git-subdir format) @@ -253,7 +336,8 @@ async def register_plugin( - category: Plugin category (optional) Returns: - Registration status (action is always "created") and plugin information. + Registration status ("created" for admins, "submitted_for_review" otherwise) + and plugin information. Example: ```bash @@ -288,6 +372,17 @@ async def register_plugin( raise _name_conflict_error(request.name) manifest: Final[Mapping[str, object]] = _build_plugin_manifest(request.name, request) + submitted_for_review: Final = not is_proxy_admin(user_api_key_dict) + approval_status: Final[SkillApprovalStatus] = SKILL_PENDING_REVIEW if submitted_for_review else SKILL_ACTIVE + owner_scope: Final[str | None] = user_api_key_dict.user_id or get_primary_resource_owner_scope( + user_api_key_dict + ) + if submitted_for_review and owner_scope is None: + raise _error_response( + 403, + "Cannot submit a skill for review without an identity to attribute it to. " + "Use a key that carries a user, team, or organization.", + ) try: plugin: Final[_PluginRecord] = await ClaudeCodePluginRepository(prisma_client).table.create( @@ -297,20 +392,21 @@ async def register_plugin( "description": request.description, "manifest_json": json.dumps(manifest), "files_json": "{}", - "enabled": True, + "enabled": not submitted_for_review, + "approval_status": approval_status, "created_at": datetime.now(timezone.utc), "updated_at": datetime.now(timezone.utc), - "created_by": user_api_key_dict.user_id, + "created_by": owner_scope, } ) except UniqueViolationError: raise _name_conflict_error(request.name) - verbose_proxy_logger.info("Plugin %s created successfully", request.name) + verbose_proxy_logger.info("Plugin %s created with approval_status=%s", request.name, approval_status) return RegisterPluginResponse( status="success", - action="created", + action="submitted_for_review" if submitted_for_review else "created", plugin=PluginResponse( id=plugin.id, name=plugin.name, @@ -318,6 +414,7 @@ async def register_plugin( description=plugin.description, source=request.source, enabled=plugin.enabled, + approval_status=approval_status, ), ) @@ -339,23 +436,32 @@ async def register_plugin( ) async def list_plugins( enabled_only: bool = False, + approval_status: SkillApprovalStatus | None = None, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - List all plugins in the marketplace. + List plugins in the marketplace. + + Admins see every skill, including submissions awaiting review. Everyone + else sees approved skills plus their own submissions, so a submitter can + track the status of what they sent in. Parameters: - enabled_only: If true, only return enabled plugins + - approval_status: Filter to one approval state, e.g. `pending_review` for the admin review queue Returns: - List of plugins with their metadata. + List of plugins with their metadata and review state. """ try: prisma_client: Final = await _get_prisma_client() - where: Final = {"enabled": True} if enabled_only else {} plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many( - where=where + where=_list_plugins_filter( + enabled_only=enabled_only, + approval_status=approval_status, + user_api_key_dict=user_api_key_dict, + ) ) plugin_list: Final = [] @@ -377,6 +483,12 @@ async def list_plugins( domain=manifest.get("domain"), namespace=manifest.get("namespace"), enabled=p.enabled, + approval_status=_as_approval_status(p.approval_status), + manifest_fingerprint=_manifest_fingerprint(p.manifest_json), + review_notes=p.review_notes, + reviewed_by=p.reviewed_by, + reviewed_at=p.reviewed_at.isoformat() if p.reviewed_at else None, + created_by=p.created_by, created_at=p.created_at.isoformat() if p.created_at else None, updated_at=p.updated_at.isoformat() if p.updated_at else None, ) @@ -425,7 +537,7 @@ async def get_plugin( where={"name": plugin_name} ) - if not plugin: + if not plugin or not _caller_can_see(plugin, user_api_key_dict): raise HTTPException( status_code=404, detail={"error": f"Plugin '{plugin_name}' not found"}, @@ -444,6 +556,11 @@ async def get_plugin( "keywords": manifest.get("keywords"), "category": manifest.get("category"), "enabled": plugin.enabled, + "approval_status": _as_approval_status(plugin.approval_status), + "manifest_fingerprint": _manifest_fingerprint(plugin.manifest_json), + "review_notes": plugin.review_notes, + "reviewed_by": plugin.reviewed_by, + "reviewed_at": plugin.reviewed_at.isoformat() if plugin.reviewed_at else None, "created_at": plugin.created_at.isoformat() if plugin.created_at else None, "updated_at": plugin.updated_at.isoformat() if plugin.updated_at else None, "created_by": plugin.created_by, @@ -468,6 +585,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. @@ -478,9 +596,15 @@ async def update_plugin( out is reset to its default (e.g. an omitted version is cleared, not kept). Send the full desired state. - Returns 404 if no plugin with the given name exists; use + Returns 404 if no plugin with the given name exists, and the same 404 for + a skill the caller cannot see, so the status code never reveals that a + pending or rejected submission is sitting under that name; use POST /claude-code/plugins to create a new plugin. + Admins can update any skill and the review state is left untouched. A + submitter can only update their own skill, and doing so sends it back to + pending review, since the content an admin approved has changed. + Parameters: - plugin_name: Name of the plugin to update (path parameter) - source: Git source reference (github, url, or git-subdir format) @@ -516,9 +640,13 @@ async def update_plugin( existing: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique( where={"name": plugin_name} # mutable-ok: prisma query arguments must be plain dicts ) - if not existing: + if not existing or not _caller_can_see(existing, user_api_key_dict): raise _error_response(404, f"Plugin '{plugin_name}' not found") + is_admin: Final = is_proxy_admin(user_api_key_dict) + if not is_admin and existing.created_by not in get_resource_owner_scopes(user_api_key_dict): + raise _error_response(403, "Only proxy admins or the submitter can update this skill") + manifest: Final[Mapping[str, object]] = _build_plugin_manifest(plugin_name, request) plugin: Final[_PluginRecord] = await ClaudeCodePluginRepository(prisma_client).table.update( @@ -529,6 +657,7 @@ async def update_plugin( "manifest_json": json.dumps(manifest), "files_json": "{}", "updated_at": datetime.now(timezone.utc), + **_submitter_edit_resets_review(is_admin=is_admin), }, ) @@ -544,6 +673,7 @@ async def update_plugin( description=plugin.description, source=request.source, enabled=plugin.enabled, + approval_status=_as_approval_status(plugin.approval_status), ), ) @@ -554,6 +684,155 @@ async def update_plugin( raise _error_response(500, f"Update failed: {e}") +def _stale_review_error(plugin_name: str) -> HTTPException: + return _error_response( + 409, + f"Skill '{plugin_name}' is no longer the submission that was reviewed. " + "Read it again and review the current content.", + ) + + +async def _record_review( + *, + plugin_name: str, + approval_status: SkillApprovalStatus, + review_notes: str | None, + reviewed_fingerprint: str | None, + user_api_key_dict: UserAPIKeyAuth, +) -> ReviewPluginResponse: + if not is_proxy_admin(user_api_key_dict): + raise _error_response(403, "Admin access required to review submitted skills") + + prisma_client: Final = await _get_prisma_client() + repository: Final = ClaudeCodePluginRepository(prisma_client) + + existing: Final[_PluginRecord | None] = await repository.table.find_unique( + where={"name": plugin_name} # mutable-ok: prisma query arguments must be plain dicts + ) + if not existing: + raise _error_response(404, f"Plugin '{plugin_name}' not found") + + if _as_approval_status(existing.approval_status) == approval_status: + raise _error_response(400, f"Skill '{plugin_name}' is already {approval_status}") + + if approval_status == SKILL_REJECTED and _as_approval_status(existing.approval_status) == SKILL_ACTIVE: + raise _error_response( + 400, + f"Skill '{plugin_name}' is already approved. Disable it to unpublish it. " + "Rejecting applies to a skill awaiting review.", + ) + + publishes: Final = approval_status == SKILL_ACTIVE + if publishes and _manifest_fingerprint(existing.manifest_json) != reviewed_fingerprint: + raise _stale_review_error(plugin_name) + + reviewed_at: Final = datetime.now(timezone.utc) + reviewed_rows: Final[int] = await repository.table.update_many( + where={ # mutable-ok: prisma query arguments must be plain dicts + "name": plugin_name, + **({"manifest_json": existing.manifest_json} if publishes else {}), + }, + data={ # mutable-ok: prisma query arguments must be plain dicts + "approval_status": approval_status, + "review_notes": review_notes, + "reviewed_by": user_api_key_dict.user_id, + "reviewed_at": reviewed_at, + "enabled": publishes, + "updated_at": reviewed_at, + }, + ) + if reviewed_rows == 0: + raise _stale_review_error(plugin_name) + + verbose_proxy_logger.info("Plugin %s reviewed: approval_status=%s", plugin_name, approval_status) + + return ReviewPluginResponse( + status="success", + name=existing.name, + approval_status=approval_status, + enabled=publishes, + reviewed_by=user_api_key_dict.user_id, + reviewed_at=reviewed_at.isoformat(), + review_notes=review_notes, + ) + + +@router.post( + "/claude-code/plugins/{plugin_name}/approve", + tags=["Claude Code Marketplace"], # mutable-ok: FastAPI route decorators take lists + dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI route decorators take lists + response_model=ReviewPluginResponse, +) +async def approve_plugin( + plugin_name: str, + request: ApprovePluginRequest, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +): + """ + Approve a submitted skill (admin only). + + Approving sets approval_status=active and publishes the skill to + marketplace.json and the public Skill Hub. + + reviewed_fingerprint is the manifest_fingerprint returned by + GET /claude-code/plugins and GET /claude-code/plugins/{plugin_name}. It + ties the approval to the content that was read, so a submitter cannot get + an edit published by landing it between the read and the approval. + + Example: + ```bash + FP=$(curl -s http://localhost:4000/claude-code/plugins/my-skill \\ + -H "Authorization: Bearer sk-admin-..." | jq -r .manifest_fingerprint) + curl -X POST http://localhost:4000/claude-code/plugins/my-skill/approve \\ + -H "Authorization: Bearer sk-admin-..." \\ + -H "Content-Type: application/json" \\ + -d "{\\"reviewed_fingerprint\\": \\"$FP\\"}" + ``` + """ + return await _record_review( + plugin_name=plugin_name, + approval_status=SKILL_ACTIVE, + review_notes=request.review_notes, + reviewed_fingerprint=request.reviewed_fingerprint, + user_api_key_dict=user_api_key_dict, + ) + + +@router.post( + "/claude-code/plugins/{plugin_name}/reject", + tags=["Claude Code Marketplace"], # mutable-ok: FastAPI route decorators take lists + dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI route decorators take lists + response_model=ReviewPluginResponse, +) +async def reject_plugin( + plugin_name: str, + request: RejectPluginRequest, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +): + """ + Reject a submitted skill (admin only). + + The row is kept unpublished so the submitter can read review_notes and fix the submission. + An already-approved skill cannot be rejected, since that would hide it from everyone but its + submitter rather than merely unpublishing it. Disable it instead. + + Example: + ```bash + curl -X POST http://localhost:4000/claude-code/plugins/my-skill/reject \\ + -H "Authorization: Bearer sk-admin-..." \\ + -H "Content-Type: application/json" \\ + -d '{"review_notes": "point the source at the skill folder"}' + ``` + """ + return await _record_review( + plugin_name=plugin_name, + approval_status=SKILL_REJECTED, + review_notes=request.review_notes, + reviewed_fingerprint=None, + user_api_key_dict=user_api_key_dict, + ) + + @router.post( "/claude-code/plugins/{plugin_name}/enable", tags=["Claude Code Marketplace"], @@ -564,11 +843,18 @@ async def enable_plugin( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - Enable a disabled plugin. + Enable a disabled plugin. Proxy admins only. + + A skill that has not been approved cannot be enabled here: approve it + through POST /claude-code/plugins/{plugin_name}/approve instead, so the + reviewer is recorded on the row. Parameters: - plugin_name: The name of the plugin to enable """ + if not is_proxy_admin(user_api_key_dict): + raise _error_response(403, "Only proxy admins can publish skills") + try: prisma_client: Final = await _get_prisma_client() @@ -581,6 +867,13 @@ async def enable_plugin( detail={"error": f"Plugin '{plugin_name}' not found"}, ) + if _as_approval_status(plugin.approval_status) != SKILL_ACTIVE: + raise _error_response( + 409, + f"Skill '{plugin_name}' is awaiting review. Approve it via " + f"POST /claude-code/plugins/{plugin_name}/approve", + ) + await ClaudeCodePluginRepository(prisma_client).table.update( where={"name": plugin_name}, data={"enabled": True, "updated_at": datetime.now(timezone.utc)}, @@ -609,11 +902,14 @@ async def disable_plugin( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - Disable a plugin without deleting it. + Disable a plugin without deleting it. Proxy admins only. Parameters: - plugin_name: The name of the plugin to disable """ + if not is_proxy_admin(user_api_key_dict): + raise _error_response(403, "Only proxy admins can unpublish skills") + try: prisma_client: Final = await _get_prisma_client() @@ -654,7 +950,8 @@ async def delete_plugin( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - Delete a plugin from the marketplace. + Delete a plugin from the marketplace. Admins can delete any skill; a + submitter can withdraw one they submitted. Parameters: - plugin_name: The name of the plugin to delete @@ -665,12 +962,17 @@ async def delete_plugin( plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique( where={"name": plugin_name} ) - if not plugin: + if not plugin or not _caller_can_see(plugin, user_api_key_dict): raise HTTPException( status_code=404, detail={"error": f"Plugin '{plugin_name}' not found"}, ) + if not is_proxy_admin(user_api_key_dict) and plugin.created_by not in get_resource_owner_scopes( + user_api_key_dict + ): + raise _error_response(403, "Only proxy admins or the submitter can delete this skill") + await ClaudeCodePluginRepository(prisma_client).table.delete(where={"name": plugin_name}) verbose_proxy_logger.info("Plugin %s deleted", plugin_name) diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index 47e30555a4f..6b0d80e36a5 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -82,7 +82,7 @@ class _PluginRow(Protocol): class _PluginTableActions(Protocol): - def find_many(self, *, where: Mapping[str, bool]) -> Awaitable[Sequence[_PluginRow]]: ... + def find_many(self, *, where: Mapping[str, object]) -> Awaitable[Sequence[_PluginRow]]: ... def _plugin_table(prisma_client: object) -> _PluginTableActions: @@ -299,9 +299,10 @@ async def get_mcp_servers(): tags=["public", "Claude Code Marketplace"], ) async def public_skill_hub(): - """Return enabled (public) Claude Code skills — no auth required.""" + """Return approved, enabled (public) Claude Code skills. No auth required.""" from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import ( _get_prisma_client, + published_skill_filter, ) from litellm.types.proxy.claude_code_endpoints import ( ListPluginsResponse, @@ -310,7 +311,7 @@ async def public_skill_hub(): try: prisma_client: Final = await _get_prisma_client() - plugins: Final = await _plugin_table(prisma_client).find_many(where={"enabled": True}) + plugins: Final = await _plugin_table(prisma_client).find_many(where=published_skill_filter()) items: Final = [] for plugin in plugins: raw = plugin.manifest_json or {} diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 79d778fb464..d562194033e 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -1338,17 +1338,22 @@ model LiteLLM_AccessGroupTable { } // Claude Code Plugin Marketplace table model LiteLLM_ClaudeCodePluginTable { - id String @id @default(uuid()) - name String @unique - version String? - description String? - manifest_json String? - files_json String? @default("{}") - enabled Boolean @default(true) - created_at DateTime? @default(now()) - updated_at DateTime? @default(now()) @updatedAt - created_by String? + id String @id @default(uuid()) + name String @unique + version String? + description String? + manifest_json String? + files_json String? @default("{}") + enabled Boolean @default(true) + approval_status String? @default("active") + review_notes String? + reviewed_by String? + reviewed_at DateTime? + created_at DateTime? @default(now()) + updated_at DateTime? @default(now()) @updatedAt + created_by String? + @@index([approval_status]) @@map("LiteLLM_ClaudeCodePluginTable") } diff --git a/litellm/types/proxy/claude_code_endpoints.py b/litellm/types/proxy/claude_code_endpoints.py index 2ee1bbbbb98..e5431e87724 100644 --- a/litellm/types/proxy/claude_code_endpoints.py +++ b/litellm/types/proxy/claude_code_endpoints.py @@ -2,8 +2,16 @@ Claude Code Marketplace endpoint types for LiteLLM Proxy """ +from typing import Final, Literal + from pydantic import BaseModel, Field +SkillApprovalStatus = Literal["pending_review", "active", "rejected"] + +SKILL_PENDING_REVIEW: Final[SkillApprovalStatus] = "pending_review" +SKILL_ACTIVE: Final[SkillApprovalStatus] = "active" +SKILL_REJECTED: Final[SkillApprovalStatus] = "rejected" + class PluginAuthor(BaseModel): """Plugin author information.""" @@ -78,16 +86,52 @@ class PluginResponse(BaseModel): description: str | None = Field(None, description="Plugin description") source: dict[str, str] = Field(..., description="Git source reference") enabled: bool = Field(..., description="Whether plugin is enabled") + approval_status: SkillApprovalStatus = Field("active", description="Administrator approval state") class RegisterPluginResponse(BaseModel): """Response from plugin registration.""" status: str = Field(..., description="Operation status") - action: str = Field(..., description="Action taken (created/updated)") + action: str = Field(..., description="Action taken (created/submitted_for_review/updated)") plugin: PluginResponse = Field(..., description="Plugin information") +class ApprovePluginRequest(BaseModel): + """ + Administrator approval of a submitted skill. + + The fingerprint binds the approval to the content the administrator read. + A submitter who edits their submission after it was read, but before it is + approved, changes the fingerprint, so the approval is refused rather than + publishing a manifest nobody reviewed. + """ + + reviewed_fingerprint: str = Field( + ..., + description="manifest_fingerprint of the submission that was reviewed, from GET /claude-code/plugins", + ) + review_notes: str | None = Field(None, description="Reviewer feedback shown to the submitter") + + +class RejectPluginRequest(BaseModel): + """Administrator rejection of a submitted skill.""" + + review_notes: str | None = Field(None, description="Reviewer feedback shown to the submitter") + + +class ReviewPluginResponse(BaseModel): + """Response from approving or rejecting a submitted skill.""" + + status: str = Field(..., description="Operation status") + name: str = Field(..., description="Skill name") + approval_status: SkillApprovalStatus = Field(..., description="Resulting approval state") + enabled: bool = Field(..., description="Whether the skill is now served to users") + reviewed_by: str | None = Field(None, description="User id of the reviewing administrator") + reviewed_at: str | None = Field(None, description="ISO timestamp of the review") + review_notes: str | None = Field(None, description="Reviewer feedback") + + class PluginListItem(BaseModel): """Plugin item in list responses.""" @@ -103,6 +147,12 @@ class PluginListItem(BaseModel): domain: str | None = None namespace: str | None = None enabled: bool + approval_status: SkillApprovalStatus = "active" + manifest_fingerprint: str = "" + review_notes: str | None = None + reviewed_by: str | None = None + reviewed_at: str | None = None + created_by: str | None = None created_at: str | None updated_at: str | None diff --git a/schema.prisma b/schema.prisma index 79d778fb464..d562194033e 100644 --- a/schema.prisma +++ b/schema.prisma @@ -1338,17 +1338,22 @@ model LiteLLM_AccessGroupTable { } // Claude Code Plugin Marketplace table model LiteLLM_ClaudeCodePluginTable { - id String @id @default(uuid()) - name String @unique - version String? - description String? - manifest_json String? - files_json String? @default("{}") - enabled Boolean @default(true) - created_at DateTime? @default(now()) - updated_at DateTime? @default(now()) @updatedAt - created_by String? + id String @id @default(uuid()) + name String @unique + version String? + description String? + manifest_json String? + files_json String? @default("{}") + enabled Boolean @default(true) + approval_status String? @default("active") + review_notes String? + reviewed_by String? + reviewed_at DateTime? + created_at DateTime? @default(now()) + updated_at DateTime? @default(now()) @updatedAt + created_by String? + @@index([approval_status]) @@map("LiteLLM_ClaudeCodePluginTable") } diff --git a/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py b/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py index 18e0f2cb559..8046aa90ff0 100644 --- a/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py +++ b/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py @@ -5,6 +5,7 @@ Covers the git-subdir source type added alongside the existing github and url ty """ import json +from types import SimpleNamespace import pytest from fastapi import HTTPException @@ -12,14 +13,27 @@ from unittest.mock import AsyncMock, MagicMock import litellm from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.common_utils.resource_ownership import ( + get_primary_resource_owner_scope, + get_resource_owner_scopes, +) from litellm.proxy.proxy_server import LitellmUserRoles from litellm.types.proxy.claude_code_endpoints import ( + ApprovePluginRequest, RegisterPluginRequest, + RejectPluginRequest, UpdatePluginRequest, ) from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import ( + approve_plugin, + delete_plugin, + disable_plugin, + enable_plugin, get_marketplace, + get_plugin, + list_plugins, register_plugin, + reject_plugin, update_plugin, ) @@ -35,20 +49,41 @@ def _make_mock_prisma(): async def _find_unique(where): return store.get(where.get("name")) + def _matches(record, where): + for key, expected in where.items(): + if key == "OR": + if not any(_matches(record, clause) for clause in expected): + return False + continue + actual = getattr(record, key) + if isinstance(expected, dict) and "in" in expected: + if actual not in expected["in"]: + return False + elif actual != expected: + return False + return True + 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 + if not where: + return records + return [r for r in records if _matches(r, where)] async def _create(data): record = MagicMock() - record.id = "test-id" + record.id = f"test-id-{data['name']}" record.name = data["name"] record.version = data.get("version") record.description = data.get("description") record.manifest_json = data.get("manifest_json", "{}") record.enabled = data.get("enabled", True) + record.approval_status = data.get("approval_status", "active") + record.review_notes = data.get("review_notes") + record.reviewed_by = data.get("reviewed_by") + record.reviewed_at = data.get("reviewed_at") + record.created_by = data.get("created_by") + record.created_at = data.get("created_at") + record.updated_at = data.get("updated_at") store[data["name"]] = record return record @@ -58,10 +93,18 @@ def _make_mock_prisma(): setattr(record, k, v) return record + async def _update_many(where, data): + matched = [record for record in store.values() if _matches(record, where)] + for record in matched: + for k, v in data.items(): + setattr(record, k, v) + return len(matched) + mock_table.find_unique = AsyncMock(side_effect=_find_unique) mock_table.find_many = AsyncMock(side_effect=_find_many) mock_table.create = AsyncMock(side_effect=_create) mock_table.update = AsyncMock(side_effect=_update) + mock_table.update_many = AsyncMock(side_effect=_update_many) mock_client.db.litellm_claudecodeplugintable = mock_table return mock_client @@ -151,6 +194,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" @@ -170,6 +214,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 @@ -213,6 +258,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 @@ -341,3 +387,511 @@ async def test_register_plugin_unknown_source_type(): assert exc_info.value.status_code == 400 assert "git-subdir" in exc_info.value.detail["error"] + + +_SUBMITTER = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-submitter", + user_id="submitter-user", +) + +_OTHER_USER = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-other", + user_id="other-user", +) + + +async def _stored(name: str): + table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable + return await table.find_unique(where={"name": name}) + + +async def _fingerprint(name: str) -> str: + """Read the fingerprint the way a reviewer does, off the skill they are looking at.""" + details = await get_plugin(plugin_name=name, user_api_key_dict=_USER) + return details["manifest_fingerprint"] + + +async def _approve(name: str, *, reviewer=None, fingerprint: str | None = None): + return await approve_plugin( + plugin_name=name, + request=ApprovePluginRequest( + reviewed_fingerprint=fingerprint if fingerprint is not None else await _fingerprint(name) + ), + user_api_key_dict=reviewer if reviewer is not None else _USER, + ) + + +@pytest.mark.asyncio +async def test_non_admin_submission_is_pending_and_unpublished(): + response = await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + + assert response.action == "submitted_for_review" + assert response.plugin.approval_status == "pending_review" + assert response.plugin.enabled is False + + stored = await _stored("submitted-skill") + assert stored.approval_status == "pending_review" + assert stored.enabled is False + assert stored.created_by == "submitter-user" + + +@pytest.mark.asyncio +async def test_admin_registration_stays_auto_approved(): + response = await register_plugin( + request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + assert response.action == "created" + assert response.plugin.approval_status == "active" + assert response.plugin.enabled is True + + +@pytest.mark.asyncio +async def test_pending_submission_is_not_served_by_marketplace(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + await register_plugin( + request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + body = json.loads((await get_marketplace()).body) + + assert [plugin["name"] for plugin in body["plugins"]] == ["admin-skill"] + + +@pytest.mark.asyncio +async def test_approval_publishes_submission_and_records_reviewer(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + + response = await _approve("submitted-skill") + + assert response.approval_status == "active" + assert response.enabled is True + assert response.reviewed_by == "test-user" + assert response.reviewed_at is not None + + body = json.loads((await get_marketplace()).body) + assert [plugin["name"] for plugin in body["plugins"]] == ["submitted-skill"] + + +@pytest.mark.asyncio +async def test_rejection_keeps_submission_unpublished_with_notes(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + + response = await reject_plugin( + plugin_name="submitted-skill", + request=RejectPluginRequest(review_notes="point at the skill folder"), + user_api_key_dict=_USER, + ) + + assert response.approval_status == "rejected" + assert response.enabled is False + + body = json.loads((await get_marketplace()).body) + assert body["plugins"] == [] + + stored = await _stored("submitted-skill") + assert stored.review_notes == "point at the skill folder" + + +@pytest.mark.asyncio +async def test_non_admin_cannot_review(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + + with pytest.raises(HTTPException) as exc_info: + await _approve("submitted-skill", reviewer=_SUBMITTER) + + assert exc_info.value.status_code == 403 + stored = await _stored("submitted-skill") + assert stored.approval_status == "pending_review" + assert stored.enabled is False + + +@pytest.mark.asyncio +async def test_review_unknown_skill_returns_404(): + with pytest.raises(HTTPException) as exc_info: + await _approve("does-not-exist", fingerprint="any-fingerprint") + + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_submitter_sees_own_pending_skill_but_not_another_users(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + await register_plugin( + request=RegisterPluginRequest(name="other-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_OTHER_USER, + ) + await register_plugin( + request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + submitter_view = await list_plugins(user_api_key_dict=_SUBMITTER) + admin_view = await list_plugins(user_api_key_dict=_USER) + + assert sorted(p.name for p in submitter_view.plugins) == ["admin-skill", "submitted-skill"] + assert sorted(p.name for p in admin_view.plugins) == ["admin-skill", "other-skill", "submitted-skill"] + + +@pytest.mark.asyncio +async def test_admin_can_filter_the_pending_queue(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + await register_plugin( + request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + queue = await list_plugins(approval_status="pending_review", user_api_key_dict=_USER) + + assert [p.name for p in queue.plugins] == ["submitted-skill"] + + +@pytest.mark.asyncio +async def test_get_plugin_hides_another_users_pending_submission(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + + with pytest.raises(HTTPException) as exc_info: + await get_plugin(plugin_name="submitted-skill", user_api_key_dict=_OTHER_USER) + + assert exc_info.value.status_code == 404 + + own = await get_plugin(plugin_name="submitted-skill", user_api_key_dict=_SUBMITTER) + assert own["approval_status"] == "pending_review" + + +@pytest.mark.asyncio +async def test_enable_cannot_bypass_review(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + + with pytest.raises(HTTPException) as exc_info: + await enable_plugin(plugin_name="submitted-skill", user_api_key_dict=_USER) + + assert exc_info.value.status_code == 409 + stored = await _stored("submitted-skill") + assert stored.enabled is False + + +@pytest.mark.asyncio +async def test_non_admin_cannot_publish_or_unpublish(): + await register_plugin( + request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + with pytest.raises(HTTPException) as exc_info: + await disable_plugin(plugin_name="admin-skill", user_api_key_dict=_SUBMITTER) + + assert exc_info.value.status_code == 403 + stored = await _stored("admin-skill") + assert stored.enabled is True + + +@pytest.mark.asyncio +async def test_submitter_edit_sends_skill_back_to_review(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + await _approve("submitted-skill") + + await update_plugin( + plugin_name="submitted-skill", + request=UpdatePluginRequest(source={"source": "github", "repo": "org/changed"}), + user_api_key_dict=_SUBMITTER, + ) + + stored = await _stored("submitted-skill") + assert stored.approval_status == "pending_review" + assert stored.enabled is False + + +@pytest.mark.asyncio +async def test_unrelated_user_cannot_edit_or_delete_a_submission(): + """404 rather than 403, since a pending skill is hidden from this caller and the status must not out it.""" + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + + with pytest.raises(HTTPException) as update_exc: + await update_plugin( + plugin_name="submitted-skill", + request=UpdatePluginRequest(source={"source": "github", "repo": "org/hijacked"}), + user_api_key_dict=_OTHER_USER, + ) + assert update_exc.value.status_code == 404 + + with pytest.raises(HTTPException) as delete_exc: + await delete_plugin(plugin_name="submitted-skill", user_api_key_dict=_OTHER_USER) + assert delete_exc.value.status_code == 404 + + assert await _stored("submitted-skill") is not None + + +@pytest.mark.asyncio +async def test_approving_an_already_active_skill_is_rejected(): + await register_plugin( + request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + with pytest.raises(HTTPException) as exc_info: + await _approve("admin-skill") + + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_approval_is_refused_when_the_submission_changed_after_it_was_read(): + """The reviewer's window is minutes long, so an edit landing inside it must not be published.""" + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + reviewed_fingerprint = await _fingerprint("submitted-skill") + + await update_plugin( + plugin_name="submitted-skill", + request=UpdatePluginRequest(source={"source": "github", "repo": "org/swapped-in-after-review"}), + user_api_key_dict=_SUBMITTER, + ) + + with pytest.raises(HTTPException) as exc_info: + await _approve("submitted-skill", fingerprint=reviewed_fingerprint) + + assert exc_info.value.status_code == 409 + + stored = await _stored("submitted-skill") + assert stored.approval_status == "pending_review" + assert stored.enabled is False + + body = json.loads((await get_marketplace()).body) + assert body["plugins"] == [] + + +@pytest.mark.asyncio +async def test_approval_of_the_reviewed_content_still_succeeds_after_an_unrelated_edit_is_reviewed(): + """Re-reading the changed submission is all it takes to approve it, so the guard is not a dead end.""" + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + await update_plugin( + plugin_name="submitted-skill", + request=UpdatePluginRequest(source={"source": "github", "repo": "org/second-attempt"}), + user_api_key_dict=_SUBMITTER, + ) + + response = await _approve("submitted-skill") + + assert response.approval_status == "active" + assert response.enabled is True + + body = json.loads((await get_marketplace()).body) + assert [plugin["source"]["repo"] for plugin in body["plugins"]] == ["org/second-attempt"] + + +@pytest.mark.asyncio +async def test_approval_write_does_not_publish_an_edit_that_lands_after_the_fingerprint_check(): + """The read and the write are separate round trips, so the write itself has to be a compare-and-set.""" + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + reviewed_fingerprint = await _fingerprint("submitted-skill") + + table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable + unpatched_find_unique = table.find_unique.side_effect + + async def _edit_after_the_review_read(where): + record = await unpatched_find_unique(where) + # A real read hands back a snapshot, so the fingerprint check sees the pre-edit content + # and passes; only the write can still catch the edit. + snapshot = SimpleNamespace( + name=record.name, + manifest_json=record.manifest_json, + approval_status=record.approval_status, + ) + table.find_unique = AsyncMock(side_effect=unpatched_find_unique) + await update_plugin( + plugin_name="submitted-skill", + request=UpdatePluginRequest(source={"source": "github", "repo": "org/raced-in"}), + user_api_key_dict=_SUBMITTER, + ) + return snapshot + + table.find_unique = AsyncMock(side_effect=_edit_after_the_review_read) + + with pytest.raises(HTTPException) as exc_info: + await approve_plugin( + plugin_name="submitted-skill", + request=ApprovePluginRequest(reviewed_fingerprint=reviewed_fingerprint), + user_api_key_dict=_USER, + ) + + assert exc_info.value.status_code == 409 + + stored = await _stored("submitted-skill") + assert stored.enabled is False + assert json.loads(stored.manifest_json)["source"]["repo"] == "org/raced-in" + + +@pytest.mark.asyncio +async def test_rejection_does_not_require_a_fingerprint(): + """Rejecting leaves the skill unpublished either way, so it is not bound to the reviewed content.""" + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + await update_plugin( + plugin_name="submitted-skill", + request=UpdatePluginRequest(source={"source": "github", "repo": "org/changed"}), + user_api_key_dict=_SUBMITTER, + ) + + response = await reject_plugin( + plugin_name="submitted-skill", + request=RejectPluginRequest(review_notes="not this one"), + user_api_key_dict=_USER, + ) + + assert response.approval_status == "rejected" + assert response.enabled is False + + +_IDENTITY_LESS = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER) + + +async def _refusal(coro) -> tuple[int, object]: + with pytest.raises(HTTPException) as exc_info: + await coro + return exc_info.value.status_code, exc_info.value.detail + + +@pytest.mark.asyncio +async def test_update_of_a_hidden_skill_is_indistinguishable_from_an_absent_one(): + """A 403 here would tell an unrelated user that the name is taken by a pending submission.""" + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + edit = UpdatePluginRequest(source={"source": "github", "repo": "org/probe"}) + + hidden = await _refusal(update_plugin(plugin_name="submitted-skill", request=edit, user_api_key_dict=_OTHER_USER)) + absent = await _refusal(update_plugin(plugin_name="no-such-skill", request=edit, user_api_key_dict=_OTHER_USER)) + + assert absent == (404, {"error": "Plugin 'no-such-skill' not found"}) + assert hidden == (404, {"error": "Plugin 'submitted-skill' not found"}) + assert (await _stored("submitted-skill")).approval_status == "pending_review" + + +@pytest.mark.asyncio +async def test_delete_of_a_hidden_skill_is_indistinguishable_from_an_absent_one(): + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + + hidden = await _refusal(delete_plugin(plugin_name="submitted-skill", user_api_key_dict=_OTHER_USER)) + absent = await _refusal(delete_plugin(plugin_name="no-such-skill", user_api_key_dict=_OTHER_USER)) + + assert absent == (404, {"error": "Plugin 'no-such-skill' not found"}) + assert hidden == (404, {"error": "Plugin 'submitted-skill' not found"}) + assert await _stored("submitted-skill") is not None + + +@pytest.mark.asyncio +async def test_a_published_skill_still_refuses_a_non_owner_with_403(): + """Hiding is only for skills the caller cannot see; an active skill is public, so the refusal stays 403.""" + await register_plugin( + request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_USER, + ) + + updated = await _refusal( + update_plugin( + plugin_name="admin-skill", + request=UpdatePluginRequest(source={"source": "github", "repo": "org/probe"}), + user_api_key_dict=_OTHER_USER, + ) + ) + deleted = await _refusal(delete_plugin(plugin_name="admin-skill", user_api_key_dict=_OTHER_USER)) + + assert updated[0] == 403 + assert deleted[0] == 403 + + +@pytest.mark.asyncio +async def test_submission_without_an_attributable_identity_is_refused(): + """created_by would be null, leaving a pending row its own submitter can never list, read, or withdraw.""" + assert get_primary_resource_owner_scope(_IDENTITY_LESS) is None + assert get_resource_owner_scopes(_IDENTITY_LESS) == [] + + status_code, _ = await _refusal( + register_plugin( + request=RegisterPluginRequest(name="orphan-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_IDENTITY_LESS, + ) + ) + + assert status_code == 403 + assert await _stored("orphan-skill") is None + + +@pytest.mark.asyncio +async def test_rejecting_an_approved_skill_is_refused_and_leaves_it_published(): + """Reject hides a skill from every non-owner, so on an approved skill it is a takedown, not a review step.""" + await register_plugin( + request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE), + user_api_key_dict=_SUBMITTER, + ) + await _approve("submitted-skill") + + status_code, _ = await _refusal( + reject_plugin( + plugin_name="submitted-skill", + request=RejectPluginRequest(review_notes="taking this down"), + user_api_key_dict=_USER, + ) + ) + + assert status_code == 400 + + stored = await _stored("submitted-skill") + assert stored.approval_status == "active" + assert stored.enabled is True + assert stored.review_notes != "taking this down" + + body = json.loads((await get_marketplace()).body) + assert [plugin["name"] for plugin in body["plugins"]] == ["submitted-skill"] diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 0bfb10320f7..7a4d2fbb871 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -3298,3 +3298,29 @@ def test_user_daily_activity_aggregated_not_covered_by_prefix_match(): route="/user/daily/activity/aggregated", allowed_routes=["/user/daily/activity"], ) + + +@pytest.mark.parametrize( + "route, allowed", + [ + ("/claude-code/marketplace.json", True), + ("/claude-code/plugins", True), + ("/claude-code/plugins/my-skill", True), + ("/claude-code/plugins/my-skill/approve", False), + ("/claude-code/plugins/my-skill/reject", False), + ("/claude-code/plugins/my-skill/enable", False), + ("/claude-code/plugins/my-skill/disable", False), + ], +) +def test_skill_submission_routes_open_to_internal_users_but_review_stays_admin_only( + route, allowed +): + """Internal users can submit and track their own skills, while approving, + rejecting and publishing a skill stays with proxy admins. + """ + assert ( + RouteChecks.check_route_access( + route=route, allowed_routes=LiteLLMRoutes.internal_user_routes.value + ) + is allowed + ) diff --git a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py index 88dc07e741b..062a7b71aec 100644 --- a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py @@ -954,3 +954,48 @@ def test_public_mcp_hub_does_not_expose_upstream_url(): assert all("url" not in item for item in data) assert secret_url not in response.text app.dependency_overrides.clear() + + +def test_public_skill_hub_serves_only_approved_and_enabled_skills(): + """A submission awaiting review is enabled=False and approval_status=pending_review, so + /public/skill_hub must not serve it even if a row is enabled without approval.""" + import json as json_lib + + import litellm.proxy.proxy_server + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + def _record(name, enabled, approval_status): + record = MagicMock() + record.id = name + record.name = name + record.version = "1.0.0" + record.description = name + record.manifest_json = json_lib.dumps({"source": {"source": "github", "repo": f"org/{name}"}}) + record.enabled = enabled + record.approval_status = approval_status + record.created_at = datetime.now(timezone.utc) + record.updated_at = datetime.now(timezone.utc) + record.created_by = "someone" + return record + + records = [ + _record("approved-skill", True, "active"), + _record("pending-skill", False, "pending_review"), + _record("enabled-but-unapproved", True, "pending_review"), + _record("rejected-skill", True, "rejected"), + ] + + async def _find_many(where=None): + return [r for r in records if all(getattr(r, key) == value for key, value in (where or {}).items())] + + mock_client = MagicMock() + mock_client.db.litellm_claudecodeplugintable.find_many = AsyncMock(side_effect=_find_many) + + with patch.object(litellm.proxy.proxy_server, "prisma_client", mock_client): + response = client.get("/public/skill_hub") + + assert response.status_code == 200 + assert [plugin["name"] for plugin in response.json()["plugins"]] == ["approved-skill"] diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx index 67bab398bd2..4304ae225d0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx @@ -2,7 +2,8 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking"; +import { getClaudeCodePluginsList, deleteClaudeCodePlugin, reviewClaudeCodePlugin } from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; import type { Plugin } from "@/components/claude_code_plugins/types"; import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel"; @@ -10,6 +11,12 @@ import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel"; vi.mock("@/components/networking", () => ({ getClaudeCodePluginsList: vi.fn(), deleteClaudeCodePlugin: vi.fn(), + reviewClaudeCodePlugin: vi.fn(), +})); + +vi.mock("@/components/molecules/notifications_manager", () => ({ + __esModule: true, + default: { success: vi.fn(), error: vi.fn(), fromBackend: vi.fn() }, })); vi.mock("./PluginTable", () => ({ @@ -18,21 +25,28 @@ vi.mock("./PluginTable", () => ({ isLoading, pluginsList, onDeleteClick, + onReviewClick, }: { isLoading: boolean; pluginsList: Plugin[]; onDeleteClick: (pluginName: string, displayName: string) => void; + onReviewClick: (plugin: Plugin, decision: "approve" | "reject") => void; }) => (
{isLoading ? "table-loading" : "table-loaded"} {pluginsList.map((plugin) => ( - +
+ {`row-${plugin.name}`} + + + +
))}
), @@ -43,6 +57,7 @@ vi.mock("@/components/claude_code_plugins/skill_detail", () => ({ __esModule: tr const mockGetClaudeCodePluginsList = vi.mocked(getClaudeCodePluginsList); const mockDeleteClaudeCodePlugin = vi.mocked(deleteClaudeCodePlugin); +const mockReviewClaudeCodePlugin = vi.mocked(reviewClaudeCodePlugin); const skill: Plugin = { id: "plugin-1", @@ -122,3 +137,88 @@ describe("ClaudeCodePluginsPanel delete confirmation", () => { expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled(); }); }); + +describe("ClaudeCodePluginsPanel review queue", () => { + const submission: Plugin = { + id: "plugin-2", + name: "submitted-skill", + source: { source: "github", repo: "acme/submitted-skill" }, + enabled: false, + approval_status: "pending_review", + manifest_fingerprint: "fingerprint-of-the-reviewed-content", + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetClaudeCodePluginsList.mockResolvedValue({ plugins: [skill, submission], count: 2 }); + }); + + it("should let a non-admin submit a skill instead of disabling the button", async () => { + render(); + const submit = await screen.findByRole("button", { name: "+ Submit Skill" }); + expect(submit).toBeEnabled(); + expect(screen.queryByTestId("toggle-pending-review")).not.toBeInTheDocument(); + }); + + it("should filter the table down to submissions awaiting review", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByTestId("toggle-pending-review")); + + expect(screen.getByText("row-submitted-skill")).toBeInTheDocument(); + expect(screen.queryByText("row-my-skill")).not.toBeInTheDocument(); + }); + + it("should approve a submission and refresh the list", async () => { + const user = userEvent.setup(); + mockReviewClaudeCodePlugin.mockResolvedValue({}); + render(); + + await user.click(await screen.findByTestId("row-approve-plugin-2")); + await user.click(await screen.findByTestId("review-confirm")); + + await waitFor(() => + expect(mockReviewClaudeCodePlugin).toHaveBeenCalledWith("sk-test", "submitted-skill", { + decision: "approve", + reviewNotes: "", + reviewedFingerprint: "fingerprint-of-the-reviewed-content", + }), + ); + await waitFor(() => expect(mockGetClaudeCodePluginsList).toHaveBeenCalledTimes(2)); + }); + + it("should tell the reviewer to look again when the submission changed under them", async () => { + const user = userEvent.setup(); + mockReviewClaudeCodePlugin.mockRejectedValue(Object.assign(new Error("conflict"), { status: 409 })); + render(); + + await user.click(await screen.findByTestId("row-approve-plugin-2")); + await user.click(await screen.findByTestId("review-confirm")); + + await waitFor(() => + expect(NotificationsManager.error).toHaveBeenCalledWith( + "This submission changed since the list was loaded. Review the refreshed content before approving it", + ), + ); + await waitFor(() => expect(mockGetClaudeCodePluginsList).toHaveBeenCalledTimes(2)); + }); + + it("should send the rejection notes typed by the reviewer", async () => { + const user = userEvent.setup(); + mockReviewClaudeCodePlugin.mockResolvedValue({}); + render(); + + await user.click(await screen.findByTestId("row-reject-plugin-2")); + await user.type(await screen.findByTestId("review-notes"), "point at the skill folder"); + await user.click(screen.getByTestId("review-confirm")); + + await waitFor(() => + expect(mockReviewClaudeCodePlugin).toHaveBeenCalledWith("sk-test", "submitted-skill", { + decision: "reject", + reviewNotes: "point at the skill folder", + reviewedFingerprint: "fingerprint-of-the-reviewed-content", + }), + ); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx index 47fc8f41307..658c0546bc7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { AlertDialog, @@ -9,9 +9,11 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking"; +import { getClaudeCodePluginsList, deleteClaudeCodePlugin, reviewClaudeCodePlugin } from "@/components/networking"; import AddPluginForm from "./add_plugin_form"; import PluginTable from "./PluginTable"; +import ReviewSkillDialog, { ReviewDecision } from "./ReviewSkillDialog"; +import { countAwaitingReview, isAwaitingReview, reviewFailureMessage } from "@/components/claude_code_plugins/helpers"; import SkillDetail from "@/components/claude_code_plugins/skill_detail"; import { isAdminRole } from "@/utils/roles"; import NotificationsManager from "@/components/molecules/notifications_manager"; @@ -32,8 +34,16 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT displayName: string; } | null>(null); const [selectedSkill, setSelectedSkill] = useState(null); + const [pluginToReview, setPluginToReview] = useState<{ plugin: Plugin; decision: ReviewDecision } | null>(null); + const [isReviewing, setIsReviewing] = useState(false); + const [showPendingOnly, setShowPendingOnly] = useState(false); const isAdmin = userRole ? isAdminRole(userRole) : false; + const pendingCount = useMemo(() => countAwaitingReview(pluginsList), [pluginsList]); + const visiblePlugins = useMemo( + () => (showPendingOnly ? pluginsList.filter(isAwaitingReview) : pluginsList), + [pluginsList, showPendingOnly], + ); const fetchPlugins = async () => { if (!accessToken) { @@ -77,6 +87,32 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT } }; + const handleReviewConfirm = async (notes: string) => { + if (!pluginToReview || !accessToken) return; + + setIsReviewing(true); + try { + await reviewClaudeCodePlugin(accessToken, pluginToReview.plugin.name, { + decision: pluginToReview.decision, + reviewNotes: notes, + reviewedFingerprint: pluginToReview.plugin.manifest_fingerprint, + }); + NotificationsManager.success( + pluginToReview.decision === "approve" + ? `Skill "${pluginToReview.plugin.name}" approved and published` + : `Skill "${pluginToReview.plugin.name}" rejected`, + ); + fetchPlugins(); + } catch (error) { + console.error("Error reviewing skill:", error); + NotificationsManager.error(reviewFailureMessage(error)); + fetchPlugins(); + } finally { + setIsReviewing(false); + setPluginToReview(null); + } + }; + return (
{selectedSkill ? ( @@ -92,20 +128,32 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT

Skills

- Register Claude Code skills. Published skills appear in the Skill Hub for all users and are served via{" "} + {isAdmin + ? "Register Claude Code skills and review skills submitted by your users. Approved skills appear in the Skill Hub for all users and are served via " + : "Submit Claude Code skills for administrator review. Once approved, a skill appears in the Skill Hub for all users and is served via "} /claude-code/marketplace.json.

- + {isAdmin && pendingCount > 0 && ( + + )}
setPluginToReview({ plugin, decision })} isAdmin={isAdmin} onPluginClick={(id) => { const skill = pluginsList.find((p) => p.id === id); @@ -122,6 +170,16 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT onSuccess={fetchPlugins} /> + {pluginToReview && ( + setPluginToReview(null)} + onConfirm={handleReviewConfirm} + /> + )} + {pluginToDelete && ( { beforeEach(() => { vi.clearAllMocks(); @@ -44,7 +55,7 @@ describe("PluginTable", () => { it("should render every column header", () => { render(); - for (const header of ["Skill Name", "Version", "Description", "Category", "Public", "Created At"]) { + for (const header of ["Skill Name", "Version", "Description", "Category", "Public", "Review", "Created At"]) { expect(screen.getByText(header)).toBeInTheDocument(); } }); @@ -110,4 +121,38 @@ describe("PluginTable", () => { expect(await screen.findByTestId("plugin-action-copy")).toBeInTheDocument(); expect(screen.queryByTestId("plugin-action-delete")).not.toBeInTheDocument(); }); + + it("should badge a submission as pending review and a legacy skill as active", () => { + render(); + expect(screen.getByTestId("approval-status-submitted-skill")).toHaveTextContent("Pending Review"); + expect(screen.getByTestId("approval-status-newer-skill")).toHaveTextContent("Active"); + }); + + it.each([ + ["approve", "plugin-action-approve"], + ["reject", "plugin-action-reject"], + ])("should let an admin %s a submission from the actions menu", async (decision, testId) => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("plugin-actions-submitted-skill")); + await user.click(await screen.findByTestId(testId)); + expect(mockOnReviewClick).toHaveBeenCalledWith(submittedSkill, decision); + }); + + it("should not offer review actions on an already active skill", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("plugin-actions-newer-skill")); + expect(await screen.findByTestId("plugin-action-copy")).toBeInTheDocument(); + expect(screen.queryByTestId("plugin-action-approve")).not.toBeInTheDocument(); + expect(screen.queryByTestId("plugin-action-reject")).not.toBeInTheDocument(); + }); + + it("should not offer review actions to a non-admin viewing a submission", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("plugin-actions-submitted-skill")); + expect(await screen.findByTestId("plugin-action-copy")).toBeInTheDocument(); + expect(screen.queryByTestId("plugin-action-approve")).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx index c581b0dfdeb..7750b883f8c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx @@ -13,6 +13,7 @@ interface PluginTableProps { pluginsList: Plugin[]; isLoading: boolean; onDeleteClick: (pluginName: string, displayName: string) => void; + onReviewClick: (plugin: Plugin, decision: "approve" | "reject") => void; isAdmin: boolean; onPluginClick: (pluginId: string) => void; } @@ -31,12 +32,19 @@ function EmptyState() { ); } -const PluginTable: React.FC = ({ pluginsList, isLoading, onDeleteClick, isAdmin, onPluginClick }) => { +const PluginTable: React.FC = ({ + pluginsList, + isLoading, + onDeleteClick, + onReviewClick, + isAdmin, + onPluginClick, +}) => { const [sorting, setSorting] = useState(DEFAULT_SORTING); const columns = useMemo( - () => getPluginTableColumns({ isAdmin, onPluginClick, onDeleteClick }), - [isAdmin, onPluginClick, onDeleteClick], + () => getPluginTableColumns({ isAdmin, onPluginClick, onDeleteClick, onReviewClick }), + [isAdmin, onPluginClick, onDeleteClick, onReviewClick], ); return ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx index 95c9924b375..1f86c81a834 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx @@ -1,11 +1,15 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { Copy, MoreHorizontal, Trash2 } from "lucide-react"; +import { Check, Copy, MoreHorizontal, Trash2, X } from "lucide-react"; import { DataTableSortHeader } from "@/components/shared/DataTable"; import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells"; -import { getCategoryBadgeColor } from "@/components/claude_code_plugins/helpers"; +import { + getCategoryBadgeColor, + getApprovalStatusDisplay, + isAwaitingReview, +} from "@/components/claude_code_plugins/helpers"; import { Plugin } from "@/components/claude_code_plugins/types"; import { Badge } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; @@ -44,9 +48,10 @@ interface PluginRowActionsProps { plugin: Plugin; isAdmin: boolean; onDeleteClick: (pluginName: string, displayName: string) => void; + onReviewClick: (plugin: Plugin, decision: "approve" | "reject") => void; } -function PluginRowActions({ plugin, isAdmin, onDeleteClick }: PluginRowActionsProps) { +function PluginRowActions({ plugin, isAdmin, onDeleteClick, onReviewClick }: PluginRowActionsProps) { return ( Copy skill ID + {isAdmin && isAwaitingReview(plugin) && ( + <> + + onReviewClick(plugin, "approve")}> + + Approve + + onReviewClick(plugin, "reject")}> + + Reject + + + )} {isAdmin && ( <> @@ -86,12 +104,14 @@ interface PluginTableColumnsDeps { isAdmin: boolean; onPluginClick: (pluginId: string) => void; onDeleteClick: (pluginName: string, displayName: string) => void; + onReviewClick: (plugin: Plugin, decision: "approve" | "reject") => void; } export const getPluginTableColumns = ({ isAdmin, onPluginClick, onDeleteClick, + onReviewClick, }: PluginTableColumnsDeps): ColumnDef[] => [ { id: "name", @@ -154,6 +174,25 @@ export const getPluginTableColumns = ({ ), }, + { + id: "approval_status", + accessorKey: "approval_status", + meta: { title: "Review", skeleton: "badge" }, + header: "Review", + size: 140, + enableSorting: false, + cell: ({ row }) => { + const display = getApprovalStatusDisplay(row.original.approval_status); + return ( + + ); + }, + }, { id: "created_at", accessorKey: "created_at", @@ -173,7 +212,12 @@ export const getPluginTableColumns = ({ enableHiding: false, cell: ({ row }) => (
- +
), }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ReviewSkillDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ReviewSkillDialog.tsx new file mode 100644 index 00000000000..8661f821226 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ReviewSkillDialog.tsx @@ -0,0 +1,83 @@ +"use client"; + +import React, { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +export type ReviewDecision = "approve" | "reject"; + +interface ReviewSkillDialogProps { + skillName: string; + decision: ReviewDecision; + isSubmitting: boolean; + onCancel: () => void; + onConfirm: (notes: string) => void; +} + +const ReviewSkillDialog: React.FC = ({ + skillName, + decision, + isSubmitting, + onCancel, + onConfirm, +}) => { + const [notes, setNotes] = useState(""); + const approving = decision === "approve"; + + return ( + { + if (!open) onCancel(); + }} + > + + + {approving ? "Approve skill" : "Reject skill"} + + {approving + ? `Approving "${skillName}" publishes it to the Skill Hub and marketplace.json for all users.` + : `Rejecting "${skillName}" keeps it unpublished. The submitter sees your notes.`} + + + {!approving && ( +
+ +