feat(skills): add optional manual security scans (#50194)

This commit is contained in:
tin-berri 2026-11-05 01:57:39 -08:00 • committed by GitHub
parent 639625d679
commit 79e63d3e06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1811 additions and 2 deletions

View file

@ -0,0 +1,60 @@
# Manual skill security scans
This standalone adapter runs NVIDIA SkillSpector on a separate machine. It is not installed in the LiteLLM proxy and does not require a shared Helm or Compose deployment
Start it with Python 3.12 or newer and uv. The script pins SkillSpector and its HTTP dependencies
```sh
export SKILL_SCANNER_API_KEY="$(openssl rand -hex 32)"
uv run server.py
```
For another host, place the loopback service behind an HTTPS reverse proxy that forwards the Authorization header and enforces a 65 MiB request limit. Run it under an unprivileged account with a writable temporary directory and no production files or credentials. The scanner host needs Git and network access to requested sources and optional model providers. Apply network egress restrictions suitable for those sources
Configure the same scanner secret in the gateway's environment, then add this to LiteLLM's configuration
```yaml
general_settings:
skill_scanner:
endpoint: https://scanner.example.com/scan
api_key_env: SKILL_SCANNER_API_KEY
use_llm: false
timeout_seconds: 180
```
The endpoint must be reachable from the gateway. A customer's laptop works while it is reachable through their network, VPN or authenticated HTTPS tunnel. `localhost` refers to the gateway machine, not the admin's browser. HTTP is accepted only for loopback development
Open Skills, Submissions, and Review as a proxy admin. Click **Scan for threats**, inspect the report, then independently approve or reject the proposal. Scanning is optional and never changes approval permissions or blocks publication. The report shows findings, locations, remediation, risk score, scanner version and whether semantic analysis actually ran. Incomplete reports do not establish safety
Uploaded ZIPs are sent privately to the scanner with their exact stored bytes. Metadata-only edits reuse the matching stored archive. If that archive is missing or changed, submit a new ZIP before scanning. The displayed SHA-256 identifies that archive, and the scan examines its files rather than proposed catalog metadata. Remote URL reports describe the source at scan time and do not freeze its contents. GitHub branch/tag refs and explicit-ref subdirectories are supported. Other ref/subdirectory combinations require a ZIP so the adapter does not silently scan the wrong revision
The latest scan result is retained with the submission. Running scans have a deadline. If a gateway restarts or the scanner becomes unavailable, the attempt expires and an admin can retry. Retries replace the latest report. Approving or rejecting during a scan does not cancel it or change the decision
Set `use_llm: true` to request semantic analysis and configure the provider on the scanner machine using [SkillSpector's provider instructions](https://github.com/NVIDIA/skillspector#llm-analysis). Provider credentials stay on that machine. Static analysis can also call OSV for dependency checks
## HTTP contract
The gateway sends an authenticated `POST` to the configured endpoint. The body contains form fields `source` (JSON source descriptor), `use_llm` (boolean), and `expires_at` (timezone-aware ISO timestamp), plus `file` (ZIP) when bytes are available. The service returns HTTP 200 with this JSON shape
```json
{
"scanner_version": "2.12.0",
"complete": true,
"scan_mode": "static-only",
"risk_score": 70,
"findings": [{
"rule_id": "example-rule",
"severity": "HIGH",
"message": "Potential data exfiltration",
"file": "SKILL.md",
"start_line": 8,
"explanation": "Instructions request sending local files to an external host",
"remediation": "Remove the external upload instruction"
}],
"limitations": []
}
```
Reports are limited to 1 MiB and 500 findings. Scanner uploads allow 64 MiB because the gateway can expand compressed skill uploads into stored compatibility ZIPs. The gateway rejects malformed reports, refuses redirects and verifies TLS. Only the configured scanner credential is sent. A timeout, HTTP error or incomplete report remains visible to the reviewer
The bundled adapter runs at most two child scans concurrently. Each child and its descendants are terminated at the gateway deadline, capped at 570 seconds. Keep the gateway and scanner clocks synchronized. Direct adapter clients may omit `expires_at` to use the cap. Child processes never execute submitted skill scripts. Raise the gateway deadline up to 600 seconds when needed for semantic analysis and configure the reverse proxy timeout to match

View file

@ -0,0 +1,172 @@
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "fastapi==0.162.0", "uvicorn==0.54.0", "python-multipart==0.0.33",
# "skillspector @ git+https://github.com/NVIDIA/skillspector.git@62c2fe8622f580ac292d4078c3b71f790194dbbd",
# ]
# ///
import asyncio
import hmac
import json
import os
import signal
import sys
import tempfile
from contextlib import suppress
from datetime import datetime, timezone
from pathlib import Path
from typing import Annotated, Final
from urllib.parse import quote, urlsplit
from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import AwareDatetime, TypeAdapter, ValidationError
MAX_UPLOAD: Final = 64 * 1024 * 1024
MAX_REPORT: Final = 1024 * 1024
SCAN_TIMEOUT: Final = 570
SOURCE: Final = TypeAdapter(dict[str, str])
SLOTS: Final = asyncio.Semaphore(2)
app: Final = FastAPI(title="SkillSpector adapter", docs_url=None, redoc_url=None, openapi_url=None)
def authenticate(credentials: Annotated[HTTPAuthorizationCredentials, Depends(HTTPBearer())]) -> None:
token: Final = os.environ.get("SKILL_SCANNER_API_KEY", "")
if not token or not hmac.compare_digest(credentials.credentials, token):
raise HTTPException(401, "Invalid scanner credentials")
def source_target(source: dict[str, str]) -> str:
target: Final = (
"https://github.com/" + source.get("repo", "") if source.get("source") == "github" else source.get("url", "")
)
parsed: Final = urlsplit(target)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
raise HTTPException(400, "Remote sources require an HTTPS URL without credentials or a fragment")
ref: Final = source.get("ref")
subdir: Final = source.get("path")
if ref or subdir:
if parsed.hostname != "github.com" or len(parsed.path.strip("/").split("/")) != 2 or not ref:
raise HTTPException(400, "Upload a ZIP to scan this source's exact ref and subdirectory")
suffix: Final = "/" + quote(subdir, safe="/") if subdir else ""
return target.removesuffix(".git").rstrip("/") + "/tree/" + quote(ref, safe="/") + suffix
return target
async def worker(target: str, output: Path, use_llm: bool) -> None:
from skillspector.mcp_server import run_scan
result: Final = await run_scan(target, use_llm=use_llm, allow_local_targets=not target.startswith("https://"))
coverage: Final = result.get("analysis_completeness", {})
findings: Final = result["findings"]
complete: Final = (
result.get("execution_successful") is True
and coverage.get("is_complete") is True
and (not use_llm or (result.get("llm_available") is True and result.get("llm_used") is True))
and len(findings) <= 500
)
limitations: Final = tuple(str(value)[:2000] for value in coverage.get("limitations", ()))
report: Final = {
"scanner_version": result["version"],
"complete": complete,
"scan_mode": result["scan_mode"],
"risk_score": result["risk_score"],
"findings": [
{
"rule_id": finding["id"],
"severity": finding["severity"],
"message": finding.get("pattern") or finding.get("finding") or "Security finding",
"file": finding["location"]["file"],
"start_line": finding["location"].get("start_line"),
"explanation": finding.get("explanation"),
"remediation": finding.get("remediation"),
}
for finding in findings[:500]
],
"limitations": (
*limitations,
*(("Only the first 500 findings are displayed.",) if len(findings) > 500 else ()),
),
}
encoded: Final = json.dumps(report).encode()
if len(encoded) > MAX_REPORT:
raise ValueError("Scanner report exceeded the response limit")
output.write_bytes(encoded)
async def run_worker(target: str, output: Path, use_llm: bool, expires_at: datetime | None = None) -> dict[str, object]:
timeout: Final = (
SCAN_TIMEOUT
if expires_at is None
else min(SCAN_TIMEOUT, (expires_at - datetime.now(timezone.utc)).total_seconds())
)
if timeout <= 0:
raise HTTPException(504, "SkillSpector scan deadline expired")
process: Final = await asyncio.create_subprocess_exec(
sys.executable,
str(Path(__file__).resolve()),
"--scan",
target,
str(output),
str(use_llm).lower(),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
start_new_session=True,
env={
**os.environ,
"HOME": str(output.parent),
"TMPDIR": str(output.parent),
"GIT_CONFIG_GLOBAL": os.devnull,
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_TERMINAL_PROMPT": "0",
"GIT_ALLOW_PROTOCOL": "https",
},
)
try:
code: Final = await asyncio.wait_for(process.wait(), timeout)
if code != 0 or not output.is_file() or output.stat().st_size > MAX_REPORT:
raise HTTPException(502, "SkillSpector could not produce a report for this source")
return TypeAdapter(dict[str, object]).validate_json(output.read_bytes())
except TimeoutError:
raise HTTPException(504, "SkillSpector scan timed out") from None
finally:
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGKILL)
await process.wait()
@app.post("/scan", dependencies=[Depends(authenticate)])
async def scan(
source: Annotated[str, Form(max_length=16384)],
use_llm: Annotated[bool, Form()] = False,
expires_at: Annotated[AwareDatetime | None, Form()] = None,
file: Annotated[UploadFile | None, File()] = None,
) -> dict[str, object]:
if SLOTS.locked():
raise HTTPException(429, "Scanner is busy; try again shortly")
try:
parsed: Final = SOURCE.validate_json(source)
except ValidationError:
raise HTTPException(400, "Invalid source metadata") from None
async with SLOTS:
with tempfile.TemporaryDirectory(prefix="skill-scan-") as directory:
root: Final = Path(directory)
if file is not None:
content: Final = await file.read(MAX_UPLOAD + 1)
if len(content) > MAX_UPLOAD:
raise HTTPException(413, "Upload exceeds 64 MiB")
archive: Final = root / "skill.zip"
archive.write_bytes(content)
return await run_worker(str(archive), root / "report.json", use_llm, expires_at)
return await run_worker(source_target(parsed), root / "report.json", use_llm, expires_at)
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--scan":
asyncio.run(worker(sys.argv[2], Path(sys.argv[3]), sys.argv[4] == "true"))
else:
import uvicorn
if not os.environ.get("SKILL_SCANNER_API_KEY"):
raise SystemExit("Set SKILL_SCANNER_API_KEY before starting the scanner")
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PORT", "8099")))

View file

@ -0,0 +1,3 @@
SET LOCAL lock_timeout = '10s';
ALTER TABLE "LiteLLM_SkillSubmission" ADD COLUMN IF NOT EXISTS "scan_json" TEXT;

View file

@ -1652,6 +1652,7 @@ model LiteLLM_SkillSubmission {
reviewed_by String?
reviewed_at DateTime?
rejection_reason String?
scan_json String?
@@index([submitted_by, created_at(sort: Desc), id(sort: Desc)])
@@index([submitted_by, status, created_at(sort: Desc), id(sort: Desc)])

View file

@ -9,6 +9,7 @@ from litellm.models.plugin_upload import (
PluginPublicationError,
PublishedPlugin,
)
from litellm.types.proxy.skill_scanning import SkillScan
SubmissionStatus: TypeAlias = Literal["pending_review", "approved", "rejected"]
SubmissionOperation: TypeAlias = Literal["create", "update"]
@ -70,7 +71,22 @@ class SubmissionReviewed:
SubmissionReviewResult: TypeAlias = SubmissionReviewed | SubmissionMissing | SubmissionConflict | PluginPublicationError
@dataclass(frozen=True, slots=True)
class SkillScanInput:
scan: SkillScan
source: Mapping[str, str]
content: bytes | None
class SkillSubmissionStore(Protocol):
async def get_scan(self, submission_id: str) -> SkillScan | SubmissionMissing | None: ...
async def start_scan(
self, submission_id: str, scan: SkillScan
) -> SkillScanInput | SubmissionMissing | SubmissionConflict: ...
async def finish_scan(self, submission_id: str, scan: SkillScan) -> None: ...
async def submit(
self, upload: PluginPublication, submitted_by: str, base: PluginPublicationBase | None
) -> SkillSubmission | SubmissionLimitExceeded: ...

View file

@ -50,9 +50,11 @@ def _mount_app(prefix: str, attr_name: str = "app") -> Callable[["FastAPI", obje
def _include_skill_hub(app: "FastAPI", module: object) -> None:
from litellm.proxy.discovery_endpoints.skill_hub_submissions import router
from litellm.proxy.discovery_endpoints.skill_scanning import router as scan_router
_include_router()(app, module)
app.include_router(router)
app.include_router(scan_router)
@dataclass(frozen=True)

View file

@ -59741,6 +59741,248 @@
"title": "SkillHubVersions",
"type": "object"
},
"SkillScan": {
"properties": {
"archive_sha256": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Archive Sha256"
},
"completed_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Completed At"
},
"error": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Error"
},
"expires_at": {
"format": "date-time",
"title": "Expires At",
"type": "string"
},
"id": {
"title": "Id",
"type": "string"
},
"report": {
"anyOf": [
{
"$ref": "#/components/schemas/SkillScanReport"
},
{
"type": "null"
}
]
},
"requested_by": {
"title": "Requested By",
"type": "string"
},
"started_at": {
"format": "date-time",
"title": "Started At",
"type": "string"
},
"status": {
"enum": [
"running",
"completed",
"failed"
],
"title": "Status",
"type": "string"
}
},
"required": [
"id",
"status",
"requested_by",
"started_at",
"expires_at"
],
"title": "SkillScan",
"type": "object"
},
"SkillScanFinding": {
"properties": {
"explanation": {
"anyOf": [
{
"maxLength": 8000,
"type": "string"
},
{
"type": "null"
}
],
"title": "Explanation"
},
"file": {
"maxLength": 2048,
"title": "File",
"type": "string"
},
"message": {
"maxLength": 8000,
"title": "Message",
"type": "string"
},
"remediation": {
"anyOf": [
{
"maxLength": 8000,
"type": "string"
},
{
"type": "null"
}
],
"title": "Remediation"
},
"rule_id": {
"maxLength": 200,
"title": "Rule Id",
"type": "string"
},
"severity": {
"enum": [
"LOW",
"MEDIUM",
"HIGH",
"CRITICAL"
],
"title": "Severity",
"type": "string"
},
"start_line": {
"anyOf": [
{
"minimum": 1.0,
"type": "integer"
},
{
"type": "null"
}
],
"title": "Start Line"
}
},
"required": [
"rule_id",
"severity",
"message",
"file"
],
"title": "SkillScanFinding",
"type": "object"
},
"SkillScanReport": {
"properties": {
"complete": {
"title": "Complete",
"type": "boolean"
},
"findings": {
"items": {
"$ref": "#/components/schemas/SkillScanFinding"
},
"maxItems": 500,
"title": "Findings",
"type": "array"
},
"limitations": {
"default": [],
"items": {
"maxLength": 2000,
"type": "string"
},
"maxItems": 100,
"title": "Limitations",
"type": "array"
},
"risk_score": {
"maximum": 100.0,
"minimum": 0.0,
"title": "Risk Score",
"type": "integer"
},
"scan_mode": {
"enum": [
"static-only",
"static+llm"
],
"title": "Scan Mode",
"type": "string"
},
"scanner_version": {
"maxLength": 100,
"minLength": 1,
"title": "Scanner Version",
"type": "string"
}
},
"required": [
"scanner_version",
"complete",
"scan_mode",
"risk_score",
"findings"
],
"title": "SkillScanReport",
"type": "object"
},
"SkillScanRequest": {
"additionalProperties": false,
"properties": {},
"title": "SkillScanRequest",
"type": "object"
},
"SkillScanResponse": {
"properties": {
"configured": {
"title": "Configured",
"type": "boolean"
},
"scan": {
"anyOf": [
{
"$ref": "#/components/schemas/SkillScan"
},
{
"type": "null"
}
]
}
},
"required": [
"configured",
"scan"
],
"title": "SkillScanResponse",
"type": "object"
},
"ValidationError": {
"properties": {
"ctx": {
@ -60890,6 +61132,117 @@
"skill_hub"
]
}
},
"/skill-hub/submissions/{submission_id}/scan": {
"get": {
"description": "Read the latest manual security scan. Only proxy admins may read reports.",
"operationId": "get_skill_scan_skill_hub_submissions__submission_id__scan_get",
"parameters": [
{
"in": "path",
"name": "submission_id",
"required": true,
"schema": {
"title": "Submission Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillScanResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Get Skill Scan",
"tags": [
"skill_hub"
]
},
"post": {
"description": "Start an optional scan of a pending submission. Scans never change approval decisions.",
"operationId": "start_skill_scan_skill_hub_submissions__submission_id__scan_post",
"parameters": [
{
"in": "path",
"name": "submission_id",
"required": true,
"schema": {
"title": "Submission Id",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SkillScanRequest"
},
{
"type": "null"
}
],
"title": " Request"
}
}
}
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillScanResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Start Skill Scan",
"tags": [
"skill_hub"
]
}
}
}
},

View file

@ -88,6 +88,7 @@ from litellm.types.proxy.carried_budget_state import (
)
from litellm.types.proxy.control_plane_endpoints import WorkerRegistryEntry
from litellm.types.proxy.request_tag_mappings import RequestTagMapping
from litellm.types.proxy.skill_scanning import SkillScannerConfig
from litellm.types.proxy.spend_capture_rate import SpendCaptureRateCheckSettings
from litellm.types.router import AllowedModelRegion, RouterErrors, UpdateRouterConfig
from litellm.types.router_weights import validate_router_settings_dict
@ -467,6 +468,7 @@ class LiteLLMRoutes(enum.Enum):
"/skill-hub/submissions/{submission_id}/archive",
"/skill-hub/submissions/{submission_id}/approve",
"/skill-hub/submissions/{submission_id}/reject",
"/skill-hub/submissions/{submission_id}/scan",
)
litellm_native_routes = [
@ -3041,6 +3043,9 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
None,
description="If True, forwards client headers (e.g. Authorization) to the LLM API. Required for Claude Code with Max subscription.",
)
skill_scanner: SkillScannerConfig | None = Field(
None, description="Opt-in external scanner for manual admin review of skill submissions"
)
mcp_required_fields: list[str] | None = Field(
None,
description="List of MCP server fields that must be filled in for a submission to pass standards checks (e.g. ['description', 'source_url', 'alias']).",

View file

@ -0,0 +1,186 @@
import asyncio
import io
import json
from datetime import datetime, timedelta, timezone
from typing import Annotated, Final
from uuid import uuid4
import httpx
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException
from pydantic import ValidationError
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client, # pyright: ignore[reportUnknownVariableType] # shared HTTP factory has untyped optional params
)
from litellm.models.skill_submission import SkillScanInput, SkillSubmissionStore, SubmissionConflict, SubmissionMissing
from litellm.proxy._types import 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.proxy.deps.repositories import SkillSubmissionRepositoryDep
from litellm.secret_managers.main import get_secret_str
from litellm.types.proxy.skill_scanning import (
SkillScan,
SkillScannerConfig,
SkillScanReport,
SkillScanRequest,
SkillScanResponse,
)
router: Final = APIRouter()
_MAX_REPORT_BYTES: Final = 1024 * 1024
def scanner_config() -> SkillScannerConfig | str | None:
from litellm.proxy.proxy_server import general_settings_view
value: Final = general_settings_view().get("skill_scanner")
if value is None:
return None
try:
return SkillScannerConfig.model_validate(value)
except ValidationError:
return "The skill scanner configuration is invalid"
async def scan_report(
client: httpx.AsyncClient, config: SkillScannerConfig, api_key: str, target: SkillScanInput
) -> SkillScanReport | str:
try:
async with client.stream(
"POST",
config.endpoint,
headers={"Authorization": f"Bearer {api_key}", "Accept-Encoding": "identity"},
data={
"source": json.dumps(dict(target.source)),
"use_llm": str(config.use_llm).lower(),
"expires_at": target.scan.expires_at.isoformat(),
},
files=None if target.content is None else {"file": ("skill.zip", target.content, "application/zip")},
timeout=httpx.Timeout(config.timeout_seconds, connect=10),
follow_redirects=False,
) as response:
if response.status_code != 200:
return f"Scanner returned HTTP {response.status_code}. Check the scanner service and authentication."
output: Final = io.BytesIO()
async for chunk in response.aiter_bytes(chunk_size=65536):
if output.tell() + len(chunk) > _MAX_REPORT_BYTES:
return "Scanner report exceeded the 1 MiB limit."
output.write(chunk)
report: Final = SkillScanReport.model_validate_json(output.getvalue())
if config.use_llm and report.scan_mode != "static+llm":
return report.model_copy(
update={
"complete": False,
"limitations": (*report.limitations[:99], "Requested semantic analysis did not run."),
}
)
return report
except (httpx.HTTPError, ValidationError):
return "The scanner was unreachable or returned an invalid report."
async def complete_scan(
submissions: SkillSubmissionStore,
submission_id: str,
target: SkillScanInput,
config: SkillScannerConfig,
api_key: str,
) -> None:
try:
client: Final = get_async_httpx_client(
"skill_scanner", params={"ssl_verify": True, "follow_redirects": False}
).client
report: Final = await asyncio.wait_for(
scan_report(client, config, api_key, target),
timeout=(target.scan.expires_at - datetime.now(timezone.utc)).total_seconds(),
)
completed: Final = target.scan.model_copy(
update={
"status": "completed" if isinstance(report, SkillScanReport) else "failed",
"report": report if isinstance(report, SkillScanReport) else None,
"error": report if isinstance(report, str) else None,
"completed_at": datetime.now(timezone.utc),
}
)
await submissions.finish_scan(submission_id, completed)
except (TimeoutError, asyncio.CancelledError) as error:
await submissions.finish_scan(
submission_id,
target.scan.model_copy(
update={
"status": "failed",
"error": "Scan timed out or was interrupted. Try scanning again.",
"completed_at": datetime.now(timezone.utc),
}
),
)
if isinstance(error, asyncio.CancelledError):
raise
@router.get(
"/skill-hub/submissions/{submission_id}/scan",
tags=["Skills Hub"],
response_model=SkillScanResponse,
)
async def get_skill_scan(
submission_id: str,
submissions: SkillSubmissionRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> SkillScanResponse:
"""Read the latest manual security scan. Only proxy admins may read reports."""
if not is_proxy_admin(caller):
raise HTTPException(403, "Proxy admin access is required to review security scans")
if submissions is None:
raise HTTPException(503, "Database is not connected")
scan: Final = await submissions.get_scan(submission_id)
if isinstance(scan, SubmissionMissing):
raise HTTPException(404, "Skill submission not found")
return SkillScanResponse(
configured=isinstance(scanner_config(), SkillScannerConfig),
scan=None if scan is None else scan.at(datetime.now(timezone.utc)),
)
@router.post(
"/skill-hub/submissions/{submission_id}/scan",
tags=["Skills Hub"],
response_model=SkillScanResponse,
)
async def start_skill_scan(
submission_id: str,
background_tasks: BackgroundTasks,
submissions: SkillSubmissionRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
_request: Annotated[SkillScanRequest | None, Body()] = None,
) -> SkillScanResponse:
"""Start an optional scan of a pending submission. Scans never change approval decisions."""
if not is_proxy_admin(caller):
raise HTTPException(403, "Proxy admin access is required to scan skills")
if submissions is None:
raise HTTPException(503, "Database is not connected")
config: Final = scanner_config()
if config is None:
raise HTTPException(503, "Security scanning is not configured")
if isinstance(config, str):
raise HTTPException(503, config)
api_key: Final = get_secret_str(config.api_key_env)
if not api_key:
raise HTTPException(503, "The scanner authentication secret is not configured")
now: Final = datetime.now(timezone.utc)
target: Final = await submissions.start_scan(
submission_id,
SkillScan(
id=str(uuid4()),
status="running",
requested_by=caller.user_id or "proxy-admin",
started_at=now,
expires_at=now + timedelta(seconds=config.timeout_seconds),
),
)
if isinstance(target, SubmissionMissing):
raise HTTPException(404, "Skill submission not found")
if isinstance(target, SubmissionConflict):
raise HTTPException(409, target.message)
background_tasks.add_task(complete_scan, submissions, submission_id, target, config, api_key)
return SkillScanResponse(configured=True, scan=target.scan)

View file

@ -1652,6 +1652,7 @@ model LiteLLM_SkillSubmission {
reviewed_by String?
reviewed_at DateTime?
rejection_reason String?
scan_json String?
@@index([submitted_by, created_at(sort: Desc), id(sort: Desc)])
@@index([submitted_by, status, created_at(sort: Desc), id(sort: Desc)])

View file

@ -1,9 +1,11 @@
import hashlib
import json
from collections.abc import Mapping
from contextlib import AbstractAsyncContextManager
from dataclasses import replace
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Final, Protocol, cast # noqa: TID251 # Prisma's generated delegates are dynamic
from urllib.parse import urlsplit
from pydantic import BaseModel, ConfigDict, TypeAdapter
@ -16,6 +18,7 @@ from litellm.models.plugin_upload import (
)
from litellm.models.skill_submission import (
SkillProposal,
SkillScanInput,
SkillSubmission,
SubmissionConflict,
SubmissionLimitExceeded,
@ -26,8 +29,9 @@ from litellm.models.skill_submission import (
SubmissionReviewResult,
SubmissionStatus,
)
from litellm.repositories.plugin_publication import PluginPublicationTransaction, publish_plugin
from litellm.repositories.plugin_publication import PluginPublicationTransaction, publish_plugin, verified_archive
from litellm.repositories.prisma_protocols import TableActions
from litellm.types.proxy.skill_scanning import SkillScan
if TYPE_CHECKING:
from prisma import models as prisma_models
@ -90,6 +94,7 @@ class _SubmissionRow(BaseModel):
)
_SCAN_ROWS: Final = TypeAdapter(tuple[Mapping[str, str | None], ...])
_ROWS: Final = TypeAdapter(tuple[_SubmissionRow, ...])
_DESCRIPTION: Final = TypeAdapter[str | None](str | None)
_SOURCE: Final = TypeAdapter(dict[str, str])
@ -125,6 +130,81 @@ class SkillSubmissionRepository:
SubmissionDatabase, prisma_client.writer_db
)
async def get_scan(self, submission_id: str) -> SkillScan | SubmissionMissing | None:
rows: Final = _SCAN_ROWS.validate_python(
await self._database.query_raw(
'SELECT scan_json FROM "LiteLLM_SkillSubmission" WHERE id = $1', submission_id
)
)
if not rows:
return SubmissionMissing()
value: Final = rows[0]["scan_json"]
return None if value is None else SkillScan.model_validate_json(value)
async def start_scan(
self, submission_id: str, scan: SkillScan
) -> SkillScanInput | SubmissionMissing | SubmissionConflict:
async with self._database.tx() as transaction:
await transaction.query_raw(
'SELECT id FROM "LiteLLM_SkillSubmission" WHERE id = $1 FOR UPDATE', submission_id
)
row: Final = await transaction.litellm_skillsubmission.find_unique(where={"id": submission_id})
if row is None:
return SubmissionMissing()
if row.status != "pending_review":
return SubmissionConflict("Only pending submissions can be scanned.")
previous: Final = None if row.scan_json is None else SkillScan.model_validate_json(row.scan_json)
if previous is not None and previous.at(scan.started_at).status == "running":
return SubmissionConflict("A scan is already running for this submission.")
stored: Final = _StoredProposal.model_validate_json(row.proposal_json)
source: Final = _SOURCE.validate_python(stored.manifest.get("source", {}))
inherited: Final = (
await transaction.litellm_claudecodepluginupload.find_unique(where={"plugin_name": row.plugin_name})
if row.archive_content is None and source.get("source") == "archive"
else None
)
content: Final = (
row.archive_content.decode()
if row.archive_content is not None
else verified_archive(
None if inherited is None else inherited.content.decode(), json.dumps(stored.manifest)
)
)
managed_archive: Final = urlsplit(source.get("url", "")).path.endswith(
f"/claude-code/plugins/{row.plugin_name}/archive/{source.get('sha256', '')}.zip"
)
if source.get("source") == "archive" and managed_archive and content is None:
return SubmissionConflict("The proposal's archive is no longer available. Submit a new ZIP to scan it.")
started: Final = scan.model_copy(
update={"archive_sha256": None if content is None else hashlib.sha256(content).hexdigest()}
)
await transaction.litellm_skillsubmission.update(
where={"id": submission_id}, data={"scan_json": started.model_dump_json()}
)
return SkillScanInput(started, source, content)
async def finish_scan(self, submission_id: str, scan: SkillScan) -> None:
async with self._database.tx() as transaction:
rows: Final = _SCAN_ROWS.validate_python(
await transaction.query_raw(
'SELECT scan_json FROM "LiteLLM_SkillSubmission" WHERE id = $1 FOR UPDATE', submission_id
)
)
value: Final = rows[0]["scan_json"] if rows else None
if value is None:
return
current: Final = SkillScan.model_validate_json(value)
if current.id != scan.id or current.status != "running":
return
result: Final = (
current.at(scan.completed_at)
if scan.completed_at is not None and scan.completed_at >= current.expires_at
else scan
)
await transaction.litellm_skillsubmission.update(
where={"id": submission_id}, data={"scan_json": result.model_dump_json()}
)
async def submit(
self, upload: PluginPublication, submitted_by: str, base: PluginPublicationBase | None
) -> SkillSubmission | SubmissionLimitExceeded:

View file

@ -0,0 +1,91 @@
from datetime import datetime
from typing import Annotated, Final, Literal
from urllib.parse import urlsplit
from pydantic import BaseModel, ConfigDict, Field, field_validator
class SkillScannerConfig(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
endpoint: str = Field(max_length=2048, description="External scanner's authenticated POST endpoint")
api_key_env: str = Field(
pattern=r"^[A-Za-z_][A-Za-z0-9_]*$", description="Environment secret name for scanner auth"
)
use_llm: bool = False
timeout_seconds: int = Field(default=180, ge=10, le=600)
@field_validator("endpoint")
@classmethod
def validate_endpoint(cls, value: str) -> str:
parsed: Final = urlsplit(value)
if (
not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or not (
parsed.scheme == "https"
or (parsed.scheme == "http" and parsed.hostname in {"127.0.0.1", "::1", "localhost"})
)
):
raise ValueError(
"Scanner endpoint must use HTTPS without credentials, query or fragment (HTTP loopback allowed)"
)
return value
class SkillScanFinding(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
rule_id: str = Field(max_length=200)
severity: Literal["LOW", "MEDIUM", "HIGH", "CRITICAL"]
message: str = Field(max_length=8000)
file: str = Field(max_length=2048)
start_line: int | None = Field(default=None, ge=1)
explanation: str | None = Field(default=None, max_length=8000)
remediation: str | None = Field(default=None, max_length=8000)
class SkillScanReport(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
scanner_version: str = Field(min_length=1, max_length=100)
complete: bool
scan_mode: Literal["static-only", "static+llm"]
risk_score: int = Field(ge=0, le=100)
findings: tuple[SkillScanFinding, ...] = Field(max_length=500)
limitations: tuple[Annotated[str, Field(max_length=2000)], ...] = Field(default=(), max_length=100)
class SkillScan(BaseModel):
model_config = ConfigDict(frozen=True)
id: str
status: Literal["running", "completed", "failed"]
requested_by: str
started_at: datetime
expires_at: datetime
completed_at: datetime | None = None
archive_sha256: str | None = None
report: SkillScanReport | None = None
error: str | None = None
def at(self, now: datetime) -> "SkillScan":
if self.status == "running" and self.expires_at <= now:
return self.model_copy(
update={"status": "failed", "error": "Scan timed out or was interrupted. Try scanning again."}
)
return self
class SkillScanRequest(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
class SkillScanResponse(BaseModel):
model_config = ConfigDict(frozen=True)
configured: bool
scan: SkillScan | None

View file

@ -1652,6 +1652,7 @@ model LiteLLM_SkillSubmission {
reviewed_by String?
reviewed_at DateTime?
rejection_reason String?
scan_json String?
@@index([submitted_by, created_at(sort: Desc), id(sort: Desc)])
@@index([submitted_by, status, created_at(sort: Desc), id(sort: Desc)])

View file

@ -5,7 +5,7 @@ import os
import uuid
import zipfile
from collections.abc import AsyncIterator, Mapping, Sequence
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import Final, Literal
import psycopg
@ -371,3 +371,84 @@ async def test_submission_pages_filter_owner_and_status_before_limit(pending_upd
global_pending: Final = await case.submissions.list(None, "pending_review", 3, 0)
all_pending: Final = tuple(sorted((*own_pending, submitted[1].id, submitted[3].id), reverse=True))
assert (tuple(row.id for row in global_pending.submissions), global_pending.has_more) == (all_pending[:3], True)
@pytest.mark.parametrize("completion_seconds", (13, 21), ids=("before-deadline", "at-deadline"))
async def test_scan_claim_retry_and_late_result_preserve_proposal(
pending_update: PendingUpdate, completion_seconds: int,
) -> None:
from datetime import datetime, timedelta, timezone
from litellm.models.skill_submission import SkillScanInput
from litellm.types.proxy.skill_scanning import SkillScan, SkillScanReport
case: Final = pending_update
now: Final = datetime(2026, 1, 1, tzinfo=timezone.utc)
first: Final = SkillScan(
id="first", status="running", requested_by="reviewer", started_at=now,
expires_at=now + timedelta(seconds=10),
)
started: Final = await case.submissions.start_scan(case.submission.id, first)
assert isinstance(started, SkillScanInput)
assert started.content == case.proposal.content
assert started.scan.archive_sha256 == hashlib.sha256(case.proposal.content or b"").hexdigest()
duplicate: Final = await case.submissions.start_scan(case.submission.id, first.model_copy(update={"id": "duplicate"}))
assert isinstance(duplicate, SubmissionConflict)
second: Final = first.model_copy(update={
"id": "second", "started_at": now + timedelta(seconds=11), "expires_at": now + timedelta(seconds=21),
})
retry: Final = await case.submissions.start_scan(case.submission.id, second)
assert isinstance(retry, SkillScanInput)
report: Final = SkillScanReport(
scanner_version="test-version", complete=False, scan_mode="static-only", risk_score=90, findings=(),
)
await case.submissions.finish_scan(case.submission.id, started.scan.model_copy(update={
"status": "completed", "report": report, "completed_at": now + timedelta(seconds=12),
}))
assert await case.submissions.get_scan(case.submission.id) == retry.scan
completed: Final = retry.scan.model_copy(update={
"status": "completed", "report": report, "completed_at": now + timedelta(seconds=completion_seconds),
})
expected: Final = completed if completion_seconds < 21 else retry.scan.at(now + timedelta(seconds=21))
await case.submissions.finish_scan(case.submission.id, completed)
assert await case.submissions.get_scan(case.submission.id) == expected
assert await case.submissions.get(case.submission.id, None) == case.submission
approved: Final = await case.submissions.review(case.submission.id, "reviewer", True, None, publication_base_url="https://gateway.test")
assert isinstance(approved, SubmissionReviewed)
assert await case.submissions.get_scan(case.submission.id) == expected
denied: Final = await case.submissions.start_scan(case.submission.id, second)
assert isinstance(denied, SubmissionConflict)
@pytest.mark.parametrize("available, remote", ((True, False), (False, False), (False, True)))
async def test_metadata_scan_requires_the_matching_managed_archive(
pending_update: PendingUpdate, available: bool, remote: bool
) -> None:
from datetime import datetime, timedelta, timezone
from litellm.models.skill_submission import SkillScanInput
from litellm.types.proxy.skill_scanning import SkillScan
case: Final = pending_update
submission: Final = await case.submissions.submit(
replace(case.original, content=None, manifest={
**case.original.manifest,
**({"source": {"source": "archive", "url": "https://example.org/skill.zip"}} if remote else {}),
}), "member", PluginPublicationBase(case.base.id, case.base.updated_at)
)
if not available:
write_rows('DELETE FROM "LiteLLM_ClaudeCodePluginUpload" WHERE plugin_name=%s', (case.original.name,))
now: Final = datetime.now(timezone.utc)
result: Final = await case.submissions.start_scan(submission.id, SkillScan(
id="metadata-scan", status="running", requested_by="admin", started_at=now,
expires_at=now + timedelta(seconds=10),
))
if available or remote:
assert isinstance(result, SkillScanInput)
assert result.content == (None if remote else case.original.content)
if remote:
assert result.source == {"source": "archive", "url": "https://example.org/skill.zip"}
assert result.scan.archive_sha256 is None
else:
assert isinstance(result, SubmissionConflict)
assert await case.submissions.get_scan(submission.id) is None

View file

@ -0,0 +1,58 @@
import asyncio
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Final
import psutil
import pytest
from fastapi import HTTPException
from cookbook.skill_scanner import server
from tests.integration._support.process import _wait_until_gone, group_members, stop_processes
@pytest.mark.parametrize("mode", ("deadline", "cap", "complete"))
async def test_adapter_bounds_worker_lifetime_and_reaps_its_process_group(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, mode: str,
) -> None:
script: Final = (
"import subprocess, sys, time\n"
"child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)'], "
"stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n"
"print(child.pid, flush=True)\n"
+ ("pass\n" if mode == "complete" else "time.sleep(30)\n")
)
child: Final = await asyncio.create_subprocess_exec(
sys.executable, "-c", script, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL, start_new_session=True,
)
try:
assert child.stdout is not None
descendant: Final = psutil.Process(int(await asyncio.wait_for(child.stdout.readline(), 5)))
if mode == "complete":
await asyncio.wait_for(child.wait(), 5)
output: Final = tmp_path / "report.json"
output.write_text('{"complete": true}')
async def launch(*args: object, **kwargs: object) -> asyncio.subprocess.Process:
return child
monkeypatch.setattr(server.asyncio, "create_subprocess_exec", launch)
monkeypatch.setattr(server, "SCAN_TIMEOUT", 0.05 if mode == "cap" else 30)
deadline: Final = (
None if mode == "cap"
else datetime.now(timezone.utc) + timedelta(seconds=0.05 if mode == "deadline" else 30)
)
result: Final = server.run_worker("unused.zip", output, False, deadline)
if mode == "complete":
assert await asyncio.wait_for(result, 5) == {"complete": True}
else:
with pytest.raises(HTTPException) as raised:
await asyncio.wait_for(result, 5)
assert raised.value.status_code == 504
assert child.returncode is not None
_wait_until_gone(descendant, 3)
finally:
await asyncio.to_thread(stop_processes, group_members(child.pid), 0.1)
await child.wait()

View file

@ -684,3 +684,72 @@ async def test_replacement_rejections_do_not_stage_or_change_files(pending_hub:
assert hub.database.litellm_skillsubmission.rows == ()
hub.plugins.update.assert_not_awaited()
hub.archives.upsert.assert_not_awaited()
@pytest.mark.parametrize("key", (MEMBER_KEY, OTHER_KEY, VIEWER_KEY))
@pytest.mark.parametrize("method", ("GET", "POST"))
async def test_skill_scan_requires_full_admin(hub: HubClient, key: str, method: str) -> None:
response = await hub.client.request(method, "/skill-hub/submissions/absent/scan", headers=_headers(key))
assert response.status_code == 403, response.text
async def test_unconfigured_scanning_preserves_manual_approval(pending_hub: HubClient) -> None:
hub = pending_hub
created = await _upload(hub, _zip(FILES), MEMBER_KEY, version=None)
path = f"/skill-hub/submissions/{created.json()['submission_id']}"
state = await hub.client.get(path + "/scan", headers=_headers(MASTER_KEY))
assert state.status_code == 200, state.text
assert state.json() == {"configured": False, "scan": None}
unavailable = await hub.client.post(path + "/scan", headers=_headers(MASTER_KEY))
assert unavailable.status_code == 503, unavailable.text
approved = await hub.client.put(path + "/approve", headers=_headers(MASTER_KEY))
assert approved.status_code == 200, approved.text
assert approved.json()["submission"]["status"] == "approved"
@pytest.mark.parametrize("scanner_status", (200, 503), ids=("report", "error"))
async def test_configured_scan_persists_result_without_changing_manual_approval(
pending_hub: HubClient, monkeypatch: pytest.MonkeyPatch, scanner_status: int,
) -> None:
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy.discovery_endpoints import skill_scanning
hub: Final = pending_hub
report: Final = {
"scanner_version": "test-version", "complete": True, "scan_mode": "static-only",
"risk_score": 0, "findings": [], "limitations": [],
}
def respond(request: httpx.Request) -> httpx.Response:
assert request.headers["authorization"] == "Bearer scanner-only-token"
return httpx.Response(scanner_status, json=report)
handler: Final = AsyncHTTPHandler(transport=httpx.MockTransport(respond))
def scanner_client(provider: str, params: Mapping[str, object]) -> AsyncHTTPHandler:
return handler
monkeypatch.setattr(skill_scanning, "get_async_httpx_client", scanner_client)
monkeypatch.setattr(proxy_server, "general_settings", {"skill_scanner": {
"endpoint": "https://scanner.example/scan", "api_key_env": "SCANNER_TEST_KEY",
}})
monkeypatch.setenv("SCANNER_TEST_KEY", "scanner-only-token")
created: Final = await _upload(hub, _zip(FILES), MEMBER_KEY, version=None)
path: Final = f"/skill-hub/submissions/{created.json()['submission_id']}"
before: Final = (await hub.client.get(path, headers=_headers(MASTER_KEY))).json()
async with handler.client:
started: Final = await hub.client.post(path + "/scan", headers=_headers(MASTER_KEY))
assert started.status_code == 200, started.text
completed: Final = (await hub.client.get(path + "/scan", headers=_headers(MASTER_KEY))).json()["scan"]
assert completed["id"] == started.json()["scan"]["id"]
expected: Final = ("completed", report) if scanner_status == 200 else ("failed", None)
assert (completed["status"], completed["report"]) == expected
assert completed["error"] is None if scanner_status == 200 else "HTTP 503" in completed["error"]
monkeypatch.setattr(proxy_server, "general_settings", {"skill_scanner": {"endpoint": "invalid"}})
retained: Final = await hub.client.get(path + "/scan", headers=_headers(MASTER_KEY))
assert retained.status_code == 200 and retained.json() == {"configured": False, "scan": completed}
unavailable: Final = await hub.client.post(path + "/scan", headers=_headers(MASTER_KEY))
assert unavailable.status_code == 503 and "configuration is invalid" in unavailable.text
assert (await hub.client.get(path, headers=_headers(MASTER_KEY))).json() == before
approved: Final = await hub.client.put(path + "/approve", headers=_headers(MASTER_KEY))
assert approved.status_code == 200 and approved.json()["submission"]["status"] == "approved"

View file

@ -0,0 +1,155 @@
import hashlib
import io
import zipfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Final
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from litellm.models.skill_submission import SkillScanInput
from litellm.proxy.discovery_endpoints.skill_scanning import scan_report
from litellm.types.proxy.skill_scanning import SkillScan, SkillScannerConfig, SkillScanReport
NOW: Final = datetime(2026, 1, 1, tzinfo=timezone.utc)
CONFIG: Final = SkillScannerConfig(endpoint="https://scanner.example/scan", api_key_env="SCANNER_TEST_KEY")
REPORT: Final = SkillScanReport(
scanner_version="test-version", complete=True, scan_mode="static-only", risk_score=0, findings=(),
)
TARGET: Final = SkillScanInput(
SkillScan(id="scan-1", status="running", requested_by="admin", started_at=NOW, expires_at=NOW + timedelta(seconds=10)),
{"source": "archive", "url": "https://gateway.example/private.zip"},
b"exact-private-archive",
)
@pytest.mark.asyncio
async def test_scan_posts_stored_bytes_with_only_scanner_credentials() -> None:
def respond(request: httpx.Request) -> httpx.Response:
assert request.url == CONFIG.endpoint
assert request.headers["authorization"] == "Bearer scanner-only-token"
assert b"exact-private-archive" in request.content
assert b'name="use_llm"\r\n\r\nfalse' in request.content
assert f'name="expires_at"\r\n\r\n{TARGET.scan.expires_at.isoformat()}'.encode() in request.content
return httpx.Response(200, json=REPORT.model_dump())
async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client:
result: Final = await scan_report(client, CONFIG, "scanner-only-token", TARGET)
assert result == REPORT
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", ("redirect", "unavailable", "malformed", "oversized", "network"))
async def test_scanner_failures_never_become_clean_reports(failure: str) -> None:
def respond(request: httpx.Request) -> httpx.Response:
assert request.url.host == "scanner.example"
if failure == "redirect":
return httpx.Response(302, headers={"Location": "https://untrusted.example/scan"})
if failure == "unavailable":
return httpx.Response(503, text="provider-internal-secret")
if failure == "malformed":
return httpx.Response(200, json={"complete": True})
if failure == "oversized":
return httpx.Response(200, content=b" " * (1024 * 1024 + 1))
raise httpx.ConnectError("provider-internal-secret", request=request)
async with httpx.AsyncClient(transport=httpx.MockTransport(respond), follow_redirects=True) as client:
result: Final = await scan_report(client, CONFIG, "scanner-only-token", TARGET)
assert isinstance(result, str)
assert "provider-internal-secret" not in result
@pytest.mark.asyncio
@pytest.mark.parametrize("existing_limitations", (0, 100), ids=("empty", "full"))
async def test_missing_requested_semantic_pass_remains_incomplete(existing_limitations: int) -> None:
report: Final = REPORT.model_copy(update={"limitations": ("Existing limitation",) * existing_limitations})
def respond(request: httpx.Request) -> httpx.Response:
assert b'name="use_llm"\r\n\r\ntrue' in request.content
return httpx.Response(200, json=report.model_dump())
async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client:
result: Final = await scan_report(client, CONFIG.model_copy(update={"use_llm": True}), "token", TARGET)
assert isinstance(result, SkillScanReport)
assert result == report.model_copy(update={
"complete": False, "limitations": (*report.limitations[:99], "Requested semantic analysis did not run."),
})
assert SkillScanReport.model_validate(result.model_dump()) == result
def test_interrupted_scan_becomes_retryable_without_changing_completed_evidence() -> None:
assert TARGET.scan.at(NOW) == TARGET.scan
assert TARGET.scan.at(NOW + timedelta(seconds=10)).status == "failed"
complete: Final = TARGET.scan.model_copy(update={"status": "completed", "report": REPORT})
assert complete.at(NOW + timedelta(days=1)) == complete
@pytest.mark.parametrize("endpoint", (
"http://scanner.example/scan", "https://user:secret@scanner.example/scan", "https://scanner.example/scan?token=secret",
))
def test_scanner_config_rejects_unsafe_credential_destinations(endpoint: str) -> None:
with pytest.raises(ValidationError):
SkillScannerConfig(endpoint=endpoint, api_key_env="SCANNER_TEST_KEY")
def test_adapter_authentication_and_exact_remote_source(monkeypatch: pytest.MonkeyPatch) -> None:
from cookbook.skill_scanner import server
monkeypatch.setenv("SKILL_SCANNER_API_KEY", "adapter-secret")
clock: Final = MagicMock()
clock.now.return_value = NOW + timedelta(seconds=1)
monkeypatch.setattr(server, "datetime", clock)
launch: Final = AsyncMock(side_effect=AssertionError("Invalid deadline launched a worker"))
monkeypatch.setattr(server.asyncio, "create_subprocess_exec", launch)
with TestClient(server.app) as client:
assert client.post("/scan", data={"source": "{}"}).status_code == 401
assert client.post("/scan", headers={"Authorization": "Bearer wrong"}, data={"source": "{}"}).status_code == 401
response = client.post("/scan", headers={"Authorization": "Bearer adapter-secret"}, data={
"source": '{"source":"git","url":"https://example.org/repo.git","ref":"release"}',
})
assert response.status_code == 400
for deadline, status in ((NOW.isoformat(), 504), (NOW.replace(tzinfo=None).isoformat(), 422)):
rejected: Final = client.post("/scan", headers={"Authorization": "Bearer adapter-secret"}, data={
"source": '{"source":"archive","url":"https://example.org/skill.zip"}', "expires_at": deadline,
})
assert rejected.status_code == status
launch.assert_not_called()
assert server.source_target({"source": "archive", "url": "https://example.org/skill.zip"}) == "https://example.org/skill.zip"
assert server.source_target({"source": "github", "repo": "example/skills", "ref": "v2", "path": "skills/review"}) == "https://github.com/example/skills/tree/v2/skills/review"
def test_adapter_accepts_expanded_stored_skill_bytes(monkeypatch: pytest.MonkeyPatch) -> None:
from cookbook.skill_scanner import server
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.skill_upload_archive import (
build_uploaded_skill_archive,
)
uploaded: Final = io.BytesIO()
with zipfile.ZipFile(uploaded, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr("SKILL.md", "---\nname: large-skill\ndescription: Synthetic archive\n---\n")
for name in ("first.bin", "second.bin"):
archive.writestr(name, b"x" * (6 * 1024 * 1024))
stored: Final = build_uploaded_skill_archive(uploaded.getvalue(), "large-skill")
assert stored is not None
stored_size: Final = len(stored)
assert stored_size > 10 * 1024 * 1024
expected: Final = (stored_size, hashlib.sha256(stored).hexdigest())
async def run_worker(
target: str, output: Path, use_llm: bool, expires_at: datetime | None = None,
) -> dict[str, object]:
received: Final = Path(target).read_bytes()
actual: Final = (len(received), hashlib.sha256(received).hexdigest())
assert actual == expected
return {"complete": True}
monkeypatch.setattr(server, "run_worker", run_worker)
monkeypatch.setenv("SKILL_SCANNER_API_KEY", "adapter-secret")
with TestClient(server.app) as client:
response: Final = client.post("/scan", headers={"Authorization": "Bearer adapter-secret"},
data={"source": "{}"}, files={"file": ("skill.zip", stored, "application/zip")})
assert response.status_code == 200 and response.json() == {"complete": True}

View file

@ -41,6 +41,8 @@ class SubmissionRows:
return updated
async def query_raw(self, query: str, *args: object) -> tuple[Mapping[str, object], ...]:
if query.startswith("SELECT scan_json"):
return tuple({"scan_json": row.scan_json} for row in self.rows if row.id == args[0])
if 'FROM "LiteLLM_SkillSubmission"' not in query or "FOR UPDATE" in query:
return ()
if "COUNT(*) AS count" in query:

View file

@ -115,7 +115,48 @@ const makeSubmissions = (count: number): SkillSubmission[] =>
proposal: { ...pending.proposal, description: `Instructions ${index + 1}` },
}));
let scanFailure = false;
let scanRequested = false;
function scanResponse(method: string) {
if (method === "POST") scanRequested = true;
return Response.json({
configured: true,
scan: !scanRequested
? null
: {
id: "scan-1",
status: scanFailure ? "failed" : "completed",
requested_by: "admin",
started_at: "2026-01-01T00:00:00Z",
expires_at: "2026-01-01T00:03:00Z",
archive_sha256: "a".repeat(64),
error: scanFailure ? "Scanner was unreachable" : null,
report: scanFailure
? null
: {
scanner_version: "test-version",
complete: false,
scan_mode: "static-only",
risk_score: 85,
findings: [
{
rule_id: "unsafe-upload",
severity: "HIGH",
message: "Potential data exfiltration",
file: "SKILL.md",
start_line: 8,
},
],
limitations: [],
},
},
});
}
beforeEach(() => {
scanFailure = false;
scanRequested = false;
testQueryClient.clear();
vi.clearAllMocks();
catalog = [published];
@ -129,6 +170,9 @@ beforeEach(() => {
const url = new URL(request?.url ?? String(input), "http://localhost");
const path = url.pathname;
const method = request?.method ?? options?.method ?? "GET";
if (path === `/skill-hub/submissions/${pending.id}/scan`) {
return scanResponse(method);
}
if (method !== "GET") {
const body = request ? await request.text() : options?.body;
writes.push({ path, method, body });
@ -630,3 +674,71 @@ it("ignores an in-flight admin submission list that resolves after the same acco
expect(screen.queryByText("Proposed instructions")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Review" })).not.toBeInTheDocument();
});
it.each([false, true])("keeps approval available after manual scanning, failure=%s", async (failure) => {
scanFailure = failure;
renderSubmissions();
fireEvent.click(await screen.findByRole("button", { name: "Review" }));
const scan = await screen.findByRole("button", { name: "Scan for threats" });
await waitFor(() => expect(scan).toBeEnabled());
expect(screen.getByRole("button", { name: "Approve and publish" })).toBeEnabled();
fireEvent.click(scan);
expect(
await screen.findByText(failure ? "Scanner was unreachable" : "Potential data exfiltration"),
).toBeInTheDocument();
if (!failure) {
expect(screen.getByText("SKILL.md:8")).toBeInTheDocument();
expect(
screen.getByText("Analysis was incomplete. This report does not establish that the skill is safe."),
).toBeInTheDocument();
}
expect(screen.getByRole("button", { name: "Reject" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Approve and publish" }));
expect(await screen.findByText("Approved")).toBeInTheDocument();
expect(writes).toHaveLength(1);
expect(writes[0].path).toBe(`/skill-hub/submissions/${pending.id}/approve`);
});
it.each([200, 409])("refreshes the running scan after POST %s and ignores an older GET", async (status) => {
renderSubmissions();
fireEvent.click(await screen.findByRole("button", { name: "Review" }));
const scan = await screen.findByRole("button", { name: "Scan for threats" });
await waitFor(() => expect(scan).toBeEnabled());
const older = Promise.withResolvers<Response>();
const refreshed = Promise.withResolvers<Response>();
const running = {
configured: true,
scan: {
id: "current-scan",
status: "running",
requested_by: "another-admin",
started_at: "2026-01-01T00:00:00Z",
expires_at: "2026-01-01T00:03:00Z",
},
};
const originalFetch = fetchMock.getMockImplementation()!;
let reads = 0;
fetchMock.mockImplementation((input, options) => {
const url = input instanceof Request ? input.url : String(input);
if (!url.endsWith(`/submissions/${pending.id}/scan`)) return originalFetch(input, options);
if ((input instanceof Request ? input.method : options?.method) === "POST") {
return Promise.resolve(
Response.json(status === 409 ? { detail: "A scan is already running" } : running, { status }),
);
}
return ++reads === 1 ? older.promise : refreshed.promise;
});
void testQueryClient.refetchQueries({ type: "active" });
await waitFor(() => expect(reads).toBe(1));
fireEvent.click(scan);
await waitFor(() => expect(reads).toBe(2));
expect(screen.getByRole("button", { name: "Scanning…" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Approve and publish" })).toBeEnabled();
refreshed.resolve(Response.json(running));
expect(await screen.findByText("Requested by another-admin")).toBeInTheDocument();
await act(async () => older.resolve(Response.json({ configured: true, scan: null })));
expect(screen.getByRole("button", { name: "Scanning…" })).toBeDisabled();
expect(screen.getByText("Requested by another-admin")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Approve and publish" }));
expect(await screen.findByText("Approved")).toBeInTheDocument();
});

View file

@ -0,0 +1,131 @@
import { useMutation, useQuery, useQueryClient, type QueryKey } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { getSkillScan, startSkillScan } from "@/components/networking";
import type { components } from "@/lib/http/schema";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
interface SkillScanPanelProps {
id: string;
accessToken: string;
queryScope: QueryKey;
pending: boolean;
}
export default function SkillScanPanel({ id, accessToken, queryScope, pending }: SkillScanPanelProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const queryKey = [...queryScope, "submission", id, "scan"];
const result = useQuery({
queryKey,
queryFn: () => getSkillScan(accessToken, id),
refetchInterval: (query) => (query.state.data?.scan?.status === "running" ? 2000 : false),
});
const start = useMutation({
mutationFn: () => startSkillScan(accessToken, id),
onSettled: () => queryClient.invalidateQueries({ queryKey, exact: true }),
});
const scan = result.data?.scan;
const report = scan?.report;
const running = start.isPending || scan?.status === "running";
return (
<section aria-label={t("skills.scanning.title")} className="space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">{t("skills.scanning.title")}</h3>
{pending && (
<Button
variant="outline"
disabled={running || result.isError || !result.data?.configured}
onClick={() => start.mutate()}
>
{t(running ? "skills.scanning.running" : "skills.scanning.scan")}
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">{t("skills.scanning.advisory")}</p>
{result.isPending && <p className="text-sm">{t("skills.scanning.loading")}</p>}
{result.data && !result.data.configured && (
<p className="text-sm text-muted-foreground">{t("skills.scanning.unconfigured")}</p>
)}
{result.isError && (
<div role="alert" className="text-sm text-destructive">
{t("skills.scanning.loadFailed")}
<Button variant="link" onClick={() => void result.refetch()}>
{t("skills.submissions.retry")}
</Button>
</div>
)}
{start.isError && (
<p role="alert" className="text-sm text-destructive">
{start.error.message}
</p>
)}
{scan && (
<>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline">{t(`skills.scanning.status.${scan.status}`)}</Badge>
<span>{t("skills.scanning.requestedBy", { name: scan.requested_by })}</span>
<span>{new Date(scan.started_at).toLocaleString()}</span>
</div>
<p className="break-all text-xs text-muted-foreground">
{scan.archive_sha256
? t("skills.scanning.archiveDigest", { digest: scan.archive_sha256 })
: t("skills.scanning.mutableSource")}
</p>
{scan.error && (
<p role="alert" className="text-sm text-destructive">
{scan.error}
</p>
)}
</>
)}
{report && <ScanReport report={report} />}
</section>
);
}
function ScanReport({ report }: { report: components["schemas"]["SkillScanReport"] }) {
const { t } = useTranslation();
return (
<>
<p className="text-sm">
{t("skills.scanning.summary", {
count: report.findings.length,
score: report.risk_score,
version: report.scanner_version,
})}
</p>
<p className="text-xs text-muted-foreground">
{t(report.scan_mode === "static+llm" ? "skills.scanning.semantic" : "skills.scanning.static")}
</p>
{!report.complete && (
<p role="alert" className="text-sm text-warning">
{t("skills.scanning.incomplete")}
</p>
)}
{(report.limitations ?? []).map((limitation, index) => (
<p key={index} className="text-xs text-muted-foreground">
{limitation}
</p>
))}
{report.findings.length === 0 && <p className="text-sm">{t("skills.scanning.noFindings")}</p>}
<div className="max-h-80 space-y-3 overflow-y-auto">
{report.findings.map((finding, index) => (
<article key={index} className="space-y-1 rounded border p-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{finding.severity}</Badge>
<span className="font-mono text-xs">{finding.rule_id}</span>
</div>
<p className="whitespace-pre-wrap break-words font-medium">{finding.message}</p>
<p className="break-all font-mono text-xs text-muted-foreground">
{finding.file}
{finding.start_line ? `:${finding.start_line}` : ""}
</p>
{finding.explanation && <p className="whitespace-pre-wrap break-words">{finding.explanation}</p>}
{finding.remediation && <p className="whitespace-pre-wrap break-words">{finding.remediation}</p>}
</article>
))}
</div>
</>
);
}

View file

@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { useUrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable/useUrlTableState";
import SkillScanPanel from "./SkillScanPanel";
import { toast } from "@/lib/toast";
const METADATA_FIELDS = ["name", "version", "description", "source", "keywords", "domain", "namespace"] as const;
@ -208,6 +209,14 @@ function SubmissionReview({ id, accessToken, queryScope, canReview, onClose, onC
{t(downloading ? "skills.submissions.downloading" : "skills.submissions.download")}
</Button>
)}
{canReview && (
<SkillScanPanel
id={id}
accessToken={accessToken}
queryScope={queryScope}
pending={submission.status === "pending_review"}
/>
)}
{canDecide && (
<>
<label htmlFor="skill-rejection-reason" className="text-sm font-medium">

View file

@ -6975,6 +6975,12 @@ export const reviewSkillSubmission = (
): Promise<components["schemas"]["SkillHubReviewResponse"]> =>
apiClient.put(`/skill-hub/submissions/${encodeURIComponent(id)}/${action}`, { accessToken, body });
export const getSkillScan = (accessToken: string, id: string): Promise<components["schemas"]["SkillScanResponse"]> =>
apiClient.get(`/skill-hub/submissions/${encodeURIComponent(id)}/scan`, { accessToken });
export const startSkillScan = (accessToken: string, id: string): Promise<components["schemas"]["SkillScanResponse"]> =>
apiClient.post(`/skill-hub/submissions/${encodeURIComponent(id)}/scan`, { accessToken });
export const downloadSkillSubmission = (accessToken: string, id: string): Promise<Blob> =>
apiClient.getBlob(`/skill-hub/submissions/${encodeURIComponent(id)}/archive`, { accessToken, redirect: "error" });

View file

@ -1755,6 +1755,28 @@
"importHint": "Enter the original source URL. Use a skill selector or path when the repository contains several skills. Leave the ref blank to use HEAD.",
"saving": "Saving..."
},
"scanning": {
"title": "Security scan",
"scan": "Scan for threats",
"running": "Scanning…",
"advisory": "Scanning is optional. You can approve or reject this submission at any time.",
"loading": "Loading scan status…",
"unconfigured": "Security scanning is not configured. Ask your gateway administrator to connect a scanner.",
"loadFailed": "Could not load the scan status.",
"requestedBy": "Requested by {{name}}",
"archiveDigest": "Scanned archive SHA-256: {{digest}}",
"mutableSource": "This scan uses an external source that may change after scanning.",
"summary": "{{count}} findings · Risk score {{score}}/100 · SkillSpector {{version}}",
"semantic": "Static and semantic analysis",
"static": "Static analysis only",
"incomplete": "Analysis was incomplete. This report does not establish that the skill is safe.",
"noFindings": "No findings were reported in the analyzed content.",
"status": {
"running": "In progress",
"completed": "Completed",
"failed": "Failed"
}
},
"submissions": {
"adminIntro": "Review submitted skills and changes before publishing them",
"authorIntro": "Your submissions and review outcomes. Pending changes are not published",

View file

@ -16019,6 +16019,30 @@ export interface paths {
patch?: never;
trace?: never;
};
"/skill-hub/submissions/{submission_id}/scan": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Skill Scan
* @description Read the latest manual security scan. Only proxy admins may read reports.
*/
get: operations["get_skill_scan_skill_hub_submissions__submission_id__scan_get"];
put?: never;
/**
* Start Skill Scan
* @description Start an optional scan of a pending submission. Scans never change approval decisions.
*/
post: operations["start_skill_scan_skill_hub_submissions__submission_id__scan_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/spend/calculate": {
parameters: {
query?: never;
@ -31226,6 +31250,8 @@ export interface components {
* @description Lets a non-admin change max_budget or budget_limits on a key they created and still own via /key/update. 'disabled' (default) requires an admin for any budget change. 'lower_only' lets the owner add or lower limits. 'ceiling' also lets the owner raise or remove limits when they could create that key with the new budget via /key/generate.
*/
self_serve_budget_policy?: ("disabled" | "lower_only" | "ceiling") | null;
/** @description Opt-in external scanner for manual admin review of skill submissions */
skill_scanner?: components["schemas"]["SkillScannerConfig"] | null;
/** @description Daily check of the spend LiteLLM captured against the provider's own bill (OpenAI via OPENAI_ADMIN_KEY). Publishes litellm_spend_capture_rate per provider and alerts when the ratio over the lookback window falls under the threshold (default 0.9). Off unless set. */
spend_capture_rate_check?: components["schemas"]["SpendCaptureRateCheckSettings"] | null;
/** @description Which keys of LiteLLM_SpendLogs.metadata are written to the database. Set exactly one of 'include' (write only these keys) or 'exclude' (drop these keys). 'status' and 'cold_storage_object_key' are always written. Daily spend tables, budgets and logging callbacks still see every key. Unset writes every key */
@ -47034,6 +47060,107 @@ export interface components {
/** Version */
version?: string;
};
/** SkillScan */
SkillScan: {
/** Archive Sha256 */
archive_sha256?: string | null;
/** Completed At */
completed_at?: string | null;
/** Error */
error?: string | null;
/**
* Expires At
* Format: date-time
*/
expires_at: string;
/** Id */
id: string;
report?: components["schemas"]["SkillScanReport"] | null;
/** Requested By */
requested_by: string;
/**
* Started At
* Format: date-time
*/
started_at: string;
/**
* Status
* @enum {string}
*/
status: "running" | "completed" | "failed";
};
/** SkillScanFinding */
SkillScanFinding: {
/** Explanation */
explanation?: string | null;
/** File */
file: string;
/** Message */
message: string;
/** Remediation */
remediation?: string | null;
/** Rule Id */
rule_id: string;
/**
* Severity
* @enum {string}
*/
severity: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
/** Start Line */
start_line?: number | null;
};
/** SkillScanReport */
SkillScanReport: {
/** Complete */
complete: boolean;
/** Findings */
findings: components["schemas"]["SkillScanFinding"][];
/**
* Limitations
* @default []
*/
limitations: string[];
/** Risk Score */
risk_score: number;
/**
* Scan Mode
* @enum {string}
*/
scan_mode: "static-only" | "static+llm";
/** Scanner Version */
scanner_version: string;
};
/** SkillScanRequest */
SkillScanRequest: Record<string, never>;
/** SkillScanResponse */
SkillScanResponse: {
/** Configured */
configured: boolean;
scan: components["schemas"]["SkillScan"] | null;
};
/** SkillScannerConfig */
SkillScannerConfig: {
/**
* Api Key Env
* @description Environment secret name for scanner auth
*/
api_key_env: string;
/**
* Endpoint
* @description External scanner's authenticated POST endpoint
*/
endpoint: string;
/**
* Timeout Seconds
* @default 180
*/
timeout_seconds: number;
/**
* Use Llm
* @default false
*/
use_llm: boolean;
};
/** Span */
Span: {
/** Agent */
@ -74756,6 +74883,72 @@ export interface operations {
};
};
};
get_skill_scan_skill_hub_submissions__submission_id__scan_get: {
parameters: {
query?: never;
header?: never;
path: {
submission_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillScanResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
start_skill_scan_skill_hub_submissions__submission_id__scan_post: {
parameters: {
query?: never;
header?: never;
path: {
submission_id: string;
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": components["schemas"]["SkillScanRequest"] | null;
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillScanResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
calculate_spend_spend_calculate_post: {
parameters: {
query?: never;