Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions backend/alembic/versions/a1b2c3d4e5f6_add_skill_binaries_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""add skill binaries table

Revision ID: a1b2c3d4e5f6
Revises: 0c086b93f8b9
Create Date: 2025-01-20 10:00:00.000000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '0c086b93f8b9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Add skill_binaries table for storing Skill ZIP packages."""

op.execute("""
CREATE TABLE IF NOT EXISTS skill_binaries (
id INT NOT NULL AUTO_INCREMENT,
kind_id INT NOT NULL,
binary_data LONGBLOB NOT NULL COMMENT 'ZIP package binary data',
file_size INT NOT NULL COMMENT 'File size in bytes',
file_hash VARCHAR(64) NOT NULL COMMENT 'SHA256 hash',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY idx_skill_binary_kind_id (kind_id),
CONSTRAINT fk_skill_binary_kind_id FOREIGN KEY (kind_id) REFERENCES kinds(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
""")


def downgrade() -> None:
"""Drop skill_binaries table."""
op.drop_table('skill_binaries')
3 changes: 3 additions & 0 deletions backend/app/api/endpoints/kind/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@

from app.api.endpoints.kind.kinds import router as kinds_router
from app.api.endpoints.kind.batch import router as batch_router
from app.api.endpoints.kind.skills import router as skills_router

# Create main kind API router
k_router = APIRouter(prefix="/v1")

# Include batch router first to avoid path conflicts
k_router.include_router(batch_router, tags=["kinds-batch"])
# Include skills router
k_router.include_router(skills_router, prefix="/kinds/skills", tags=["skills"])
# Include unified kinds router after batch router
k_router.include_router(kinds_router, tags=["kinds"])
176 changes: 176 additions & 0 deletions backend/app/api/endpoints/kind/skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# SPDX-FileCopyrightText: 2025 Weibo, Inc.
#
# SPDX-License-Identifier: Apache-2.0

"""
Skills API endpoints for Claude Code Skills management
"""
from fastapi import APIRouter, Depends, UploadFile, File, Form, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
import io

from app.api.dependencies import get_db
from app.core import security
from app.models.user import User
from app.schemas.kind import Skill, SkillList
from app.services.skill_service import SkillService

router = APIRouter()


@router.post("/upload", response_model=Skill, status_code=status.HTTP_201_CREATED)
async def upload_skill(
file: UploadFile = File(...),
name: str = Form(...),
namespace: str = Form("default"),
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db)
):
"""
Upload and create a new Skill

- **file**: ZIP package containing SKILL.md (max 10MB)
- **name**: Unique Skill name
- **namespace**: Namespace (default: "default")
"""
# Validate file type
if not file.filename.endswith('.zip'):
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="File must be a ZIP archive")

# Read file content
file_content = await file.read()

# Create skill
skill = SkillService.create_skill(
db=db,
user_id=current_user.id,
name=name.strip(),
namespace=namespace,
file_content=file_content
)

return skill


@router.get("", response_model=SkillList)
def list_skills(
skip: int = 0,
limit: int = 100,
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db)
):
"""
List all Skills for the current user

- **skip**: Number of items to skip (for pagination)
- **limit**: Maximum number of items to return
"""
return SkillService.list_skills(
db=db,
user_id=current_user.id,
skip=skip,
limit=limit
)


@router.get("/{skill_id}", response_model=Skill)
def get_skill(
skill_id: int,
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db)
):
"""
Get Skill details by ID
"""
return SkillService.get_skill(
db=db,
user_id=current_user.id,
skill_id=skill_id
)


@router.get("/{skill_id}/download")
def download_skill(
skill_id: int,
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db)
):
"""
Download Skill ZIP package

Used by Executor to download Skills for deployment
"""
binary_data = SkillService.get_skill_binary(
db=db,
user_id=current_user.id,
skill_id=skill_id
)

# Get skill name for filename
skill = SkillService.get_skill(
db=db,
user_id=current_user.id,
skill_id=skill_id
)

return StreamingResponse(
io.BytesIO(binary_data),
media_type="application/zip",
headers={
"Content-Disposition": f"attachment; filename={skill.metadata.name}.zip"
}
)
Comment on lines +118 to +124
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Sanitize filename in Content-Disposition header to prevent header injection.

The skill name is used directly in the Content-Disposition header without sanitization. If the name contains special characters (quotes, newlines, semicolons), it could corrupt the header or enable HTTP response splitting.

+    # Sanitize filename for Content-Disposition header
+    safe_filename = "".join(c for c in skill.metadata.name if c.isalnum() or c in "-_")
+    
     return StreamingResponse(
         io.BytesIO(binary_data),
         media_type="application/zip",
         headers={
-            "Content-Disposition": f"attachment; filename={skill.metadata.name}.zip"
+            "Content-Disposition": f'attachment; filename="{safe_filename}.zip"'
         }
     )

Note: The filename should also be wrapped in quotes per RFC 6266.

🤖 Prompt for AI Agents
In backend/app/api/endpoints/kind/skills.py around lines 118 to 124, the
Content-Disposition header uses skill.metadata.name directly which risks header
injection and invalid headers; sanitize the filename by removing or replacing
CR/LF, quotes, semicolons and other unsafe characters (e.g., replace with
underscore), URL- or RFC5987-encode a UTF-8 fallback if needed, and wrap the
final filename in double quotes per RFC 6266 (or provide both filename and
filename* for UTF-8). Update the header construction to use the
sanitized/encoded filename and a safe fallback like "skill.zip".



@router.put("/{skill_id}", response_model=Skill)
async def update_skill(
skill_id: int,
file: UploadFile = File(...),
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db)
):
"""
Update Skill with new ZIP package

- **file**: New ZIP package containing SKILL.md (max 10MB)
"""
# Validate file type
if not file.filename.endswith('.zip'):
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="File must be a ZIP archive")

# Read file content
file_content = await file.read()

# Update skill
skill = SkillService.update_skill(
db=db,
user_id=current_user.id,
skill_id=skill_id,
file_content=file_content
)

return skill


@router.delete("/{skill_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_skill(
skill_id: int,
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db)
):
"""
Delete Skill

Will check if Skill is referenced by any Ghost.
If referenced, deletion will be rejected.
"""
SkillService.delete_skill(
db=db,
user_id=current_user.id,
skill_id=skill_id
)

return None
4 changes: 3 additions & 1 deletion backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
)
from app.models.subtask import Subtask
from app.models.shared_team import SharedTeam
from app.models.skill_binary import SkillBinary

__all__ = [
"User",
"Kind",
"Subtask",
"SharedTeam"
"SharedTeam",
"SkillBinary"
]
26 changes: 26 additions & 0 deletions backend/app/models/skill_binary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: 2025 Weibo, Inc.
#
# SPDX-License-Identifier: Apache-2.0

"""
Skill binary storage model for Claude Code Skills ZIP packages
"""
from sqlalchemy import Column, Integer, String, LargeBinary, ForeignKey, DateTime
from datetime import datetime
from app.db.base import Base


class SkillBinary(Base):
"""Model for storing Skill ZIP package binary data"""
__tablename__ = "skill_binaries"

id = Column(Integer, primary_key=True, index=True)
kind_id = Column(Integer, ForeignKey("kinds.id", ondelete="CASCADE"), nullable=False, unique=True)
binary_data = Column(LargeBinary, nullable=False) # ZIP package binary data
file_size = Column(Integer, nullable=False) # File size in bytes
file_hash = Column(String(64), nullable=False) # SHA256 hash
created_at = Column(DateTime, default=datetime.now)

__table_args__ = (
{"mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"},
)
5 changes: 4 additions & 1 deletion backend/app/schemas/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# SPDX-License-Identifier: Apache-2.0

from datetime import datetime
from typing import Any, Optional
from typing import Any, Optional, List

from pydantic import BaseModel

Expand All @@ -16,6 +16,7 @@ class BotBase(BaseModel):
agent_config: dict[str, Any]
system_prompt: Optional[str] = None
mcp_servers: Optional[dict[str, Any]] = None
skills: Optional[List[str]] = None # List of Skill names to associate
is_active: bool = True

class BotCreate(BotBase):
Expand All @@ -29,6 +30,7 @@ class BotUpdate(BaseModel):
agent_config: Optional[dict[str, Any]] = None
system_prompt: Optional[str] = None
mcp_servers: Optional[dict[str, Any]] = None
skills: Optional[List[str]] = None # List of Skill names to associate
is_active: Optional[bool] = None

class BotInDB(BotBase):
Expand All @@ -50,6 +52,7 @@ class BotDetail(BaseModel):
agent_config: dict[str, Any]
system_prompt: Optional[str] = None
mcp_servers: Optional[dict[str, Any]] = None
skills: Optional[List[str]] = None
is_active: bool = True
created_at: datetime
updated_at: datetime
Expand Down
35 changes: 34 additions & 1 deletion backend/app/schemas/kind.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class GhostSpec(BaseModel):
"""Ghost specification"""
systemPrompt: str
mcpServers: Optional[Dict[str, Any]] = None
skills: Optional[List[str]] = None # List of Skill names to associate


class GhostStatus(Status):
Expand Down Expand Up @@ -285,4 +286,36 @@ class BatchResponse(BaseModel):
"""Batch operation response"""
success: bool
message: str
results: List[Dict[str, Any]]
results: List[Dict[str, Any]]


# Skill CRD schemas
class SkillSpec(BaseModel):
"""Skill specification"""
description: str # Description extracted from SKILL.md YAML frontmatter
version: Optional[str] = None # Skill version
author: Optional[str] = None # Author
tags: Optional[List[str]] = None # Tags


class SkillStatus(Status):
"""Skill status"""
state: str = "Available" # Available, Unavailable
fileSize: Optional[int] = None # ZIP package size in bytes
fileHash: Optional[str] = None # SHA256 hash


class Skill(BaseModel):
"""Skill CRD"""
apiVersion: str = "agent.wecode.io/v1"
kind: str = "Skill"
metadata: ObjectMeta
spec: SkillSpec
status: Optional[SkillStatus] = None


class SkillList(BaseModel):
"""Skill list"""
apiVersion: str = "agent.wecode.io/v1"
kind: str = "SkillList"
items: List[Skill]
Loading
Loading