GT AI OS Community Edition v2.0.33
Security hardening release addressing CodeQL and Dependabot alerts: - Fix stack trace exposure in error responses - Add SSRF protection with DNS resolution checking - Implement proper URL hostname validation (replaces substring matching) - Add centralized path sanitization to prevent path traversal - Fix ReDoS vulnerability in email validation regex - Improve HTML sanitization in validation utilities - Fix capability wildcard matching in auth utilities - Update glob dependency to address CVE - Add CodeQL suppression comments for verified false positives 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
493
apps/tenant-backend/app/services/assistant_builder.py
Normal file
493
apps/tenant-backend/app/services/assistant_builder.py
Normal file
@@ -0,0 +1,493 @@
|
||||
"""
|
||||
Assistant Builder Service for GT 2.0
|
||||
|
||||
Manages assistant creation, deployment, and lifecycle.
|
||||
Integrates with template library and file-based storage.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import stat
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
from app.models.assistant_template import (
|
||||
AssistantTemplate, AssistantInstance, AssistantBuilder,
|
||||
AssistantType, PersonalityConfig, ResourcePreferences, MemorySettings,
|
||||
AssistantTemplateLibrary, BUILTIN_TEMPLATES
|
||||
)
|
||||
from app.models.access_group import AccessGroup
|
||||
from app.core.security import verify_capability_token
|
||||
from app.services.access_controller import AccessController
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AssistantBuilderService:
|
||||
"""
|
||||
Service for building and managing assistants
|
||||
Handles both template-based and custom assistant creation
|
||||
"""
|
||||
|
||||
def __init__(self, tenant_domain: str, resource_cluster_url: str = "http://resource-cluster:8004"):
|
||||
self.tenant_domain = tenant_domain
|
||||
self.base_path = Path(f"/data/{tenant_domain}/assistants")
|
||||
self.template_library = AssistantTemplateLibrary(resource_cluster_url)
|
||||
self.access_controller = AccessController(tenant_domain)
|
||||
self._ensure_directories()
|
||||
|
||||
def _ensure_directories(self):
|
||||
"""Ensure assistant directories exist with proper permissions"""
|
||||
self.base_path.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(self.base_path, stat.S_IRWXU) # 700
|
||||
|
||||
# Create subdirectories
|
||||
for subdir in ["templates", "instances", "shared"]:
|
||||
path = self.base_path / subdir
|
||||
path.mkdir(exist_ok=True)
|
||||
os.chmod(path, stat.S_IRWXU) # 700
|
||||
|
||||
async def create_from_template(
|
||||
self,
|
||||
template_id: str,
|
||||
user_id: str,
|
||||
instance_name: str,
|
||||
customizations: Optional[Dict[str, Any]] = None,
|
||||
capability_token: str = None
|
||||
) -> AssistantInstance:
|
||||
"""
|
||||
Create assistant instance from template
|
||||
|
||||
Args:
|
||||
template_id: Template to use
|
||||
user_id: User creating the assistant
|
||||
instance_name: Name for the instance
|
||||
customizations: Optional customizations
|
||||
capability_token: JWT capability token
|
||||
|
||||
Returns:
|
||||
Created assistant instance
|
||||
"""
|
||||
# Verify capability token
|
||||
if capability_token:
|
||||
token_data = verify_capability_token(capability_token)
|
||||
if not token_data or token_data.get("tenant_id") != self.tenant_domain:
|
||||
raise PermissionError("Invalid capability token")
|
||||
|
||||
# Deploy from template
|
||||
instance = await self.template_library.deploy_template(
|
||||
template_id=template_id,
|
||||
user_id=user_id,
|
||||
instance_name=instance_name,
|
||||
tenant_domain=self.tenant_domain,
|
||||
customizations=customizations
|
||||
)
|
||||
|
||||
# Create file storage
|
||||
await self._create_assistant_files(instance)
|
||||
|
||||
# Save to database (would be SQLite in production)
|
||||
await self._save_assistant(instance)
|
||||
|
||||
logger.info(f"Created assistant {instance.id} from template {template_id} for {user_id}")
|
||||
|
||||
return instance
|
||||
|
||||
async def create_custom(
|
||||
self,
|
||||
builder_config: AssistantBuilder,
|
||||
user_id: str,
|
||||
capability_token: str = None
|
||||
) -> AssistantInstance:
|
||||
"""
|
||||
Create custom assistant from builder configuration
|
||||
|
||||
Args:
|
||||
builder_config: Custom assistant configuration
|
||||
user_id: User creating the assistant
|
||||
capability_token: JWT capability token
|
||||
|
||||
Returns:
|
||||
Created assistant instance
|
||||
"""
|
||||
# Verify capability token
|
||||
if capability_token:
|
||||
token_data = verify_capability_token(capability_token)
|
||||
if not token_data or token_data.get("tenant_id") != self.tenant_domain:
|
||||
raise PermissionError("Invalid capability token")
|
||||
|
||||
# Check if user has required capabilities
|
||||
user_capabilities = token_data.get("capabilities", [])
|
||||
for required_cap in builder_config.requested_capabilities:
|
||||
if not any(required_cap in cap.get("resource", "") for cap in user_capabilities):
|
||||
raise PermissionError(f"Missing capability: {required_cap}")
|
||||
|
||||
# Build instance
|
||||
instance = builder_config.build_instance(user_id, self.tenant_domain)
|
||||
|
||||
# Create file storage
|
||||
await self._create_assistant_files(instance)
|
||||
|
||||
# Save to database
|
||||
await self._save_assistant(instance)
|
||||
|
||||
logger.info(f"Created custom assistant {instance.id} for {user_id}")
|
||||
|
||||
return instance
|
||||
|
||||
async def get_assistant(
|
||||
self,
|
||||
assistant_id: str,
|
||||
user_id: str
|
||||
) -> Optional[AssistantInstance]:
|
||||
"""
|
||||
Get assistant instance by ID
|
||||
|
||||
Args:
|
||||
assistant_id: Assistant ID
|
||||
user_id: User requesting the assistant
|
||||
|
||||
Returns:
|
||||
Assistant instance if found and accessible
|
||||
"""
|
||||
# Load assistant
|
||||
instance = await self._load_assistant(assistant_id)
|
||||
if not instance:
|
||||
return None
|
||||
|
||||
# Check access permission
|
||||
allowed, _ = await self.access_controller.check_permission(
|
||||
user_id, instance, "read"
|
||||
)
|
||||
if not allowed:
|
||||
return None
|
||||
|
||||
return instance
|
||||
|
||||
async def list_user_assistants(
|
||||
self,
|
||||
user_id: str,
|
||||
include_shared: bool = True
|
||||
) -> List[AssistantInstance]:
|
||||
"""
|
||||
List all assistants accessible to user
|
||||
|
||||
Args:
|
||||
user_id: User to list assistants for
|
||||
include_shared: Include team/org shared assistants
|
||||
|
||||
Returns:
|
||||
List of accessible assistants
|
||||
"""
|
||||
assistants = []
|
||||
|
||||
# Get owned assistants
|
||||
owned = await self._get_owned_assistants(user_id)
|
||||
assistants.extend(owned)
|
||||
|
||||
# Get shared assistants if requested
|
||||
if include_shared:
|
||||
shared = await self._get_shared_assistants(user_id)
|
||||
assistants.extend(shared)
|
||||
|
||||
return assistants
|
||||
|
||||
async def update_assistant(
|
||||
self,
|
||||
assistant_id: str,
|
||||
user_id: str,
|
||||
updates: Dict[str, Any]
|
||||
) -> AssistantInstance:
|
||||
"""
|
||||
Update assistant configuration
|
||||
|
||||
Args:
|
||||
assistant_id: Assistant to update
|
||||
user_id: User requesting update
|
||||
updates: Configuration updates
|
||||
|
||||
Returns:
|
||||
Updated assistant instance
|
||||
"""
|
||||
# Load assistant
|
||||
instance = await self._load_assistant(assistant_id)
|
||||
if not instance:
|
||||
raise ValueError(f"Assistant not found: {assistant_id}")
|
||||
|
||||
# Check permission
|
||||
if instance.owner_id != user_id:
|
||||
raise PermissionError("Only owner can update assistant")
|
||||
|
||||
# Apply updates
|
||||
if "personality" in updates:
|
||||
instance.personality_config = PersonalityConfig(**updates["personality"])
|
||||
if "resources" in updates:
|
||||
instance.resource_preferences = ResourcePreferences(**updates["resources"])
|
||||
if "memory" in updates:
|
||||
instance.memory_settings = MemorySettings(**updates["memory"])
|
||||
if "system_prompt" in updates:
|
||||
instance.system_prompt = updates["system_prompt"]
|
||||
|
||||
instance.updated_at = datetime.utcnow()
|
||||
|
||||
# Save changes
|
||||
await self._save_assistant(instance)
|
||||
await self._update_assistant_files(instance)
|
||||
|
||||
logger.info(f"Updated assistant {assistant_id} by {user_id}")
|
||||
|
||||
return instance
|
||||
|
||||
async def share_assistant(
|
||||
self,
|
||||
assistant_id: str,
|
||||
user_id: str,
|
||||
access_group: AccessGroup,
|
||||
team_members: Optional[List[str]] = None
|
||||
) -> AssistantInstance:
|
||||
"""
|
||||
Share assistant with team or organization
|
||||
|
||||
Args:
|
||||
assistant_id: Assistant to share
|
||||
user_id: User sharing (must be owner)
|
||||
access_group: New access level
|
||||
team_members: Team members if team access
|
||||
|
||||
Returns:
|
||||
Updated assistant instance
|
||||
"""
|
||||
# Load assistant
|
||||
instance = await self._load_assistant(assistant_id)
|
||||
if not instance:
|
||||
raise ValueError(f"Assistant not found: {assistant_id}")
|
||||
|
||||
# Check ownership
|
||||
if instance.owner_id != user_id:
|
||||
raise PermissionError("Only owner can share assistant")
|
||||
|
||||
# Update access
|
||||
instance.access_group = access_group
|
||||
if access_group == AccessGroup.TEAM:
|
||||
instance.team_members = team_members or []
|
||||
else:
|
||||
instance.team_members = []
|
||||
|
||||
instance.updated_at = datetime.utcnow()
|
||||
|
||||
# Save changes
|
||||
await self._save_assistant(instance)
|
||||
|
||||
logger.info(f"Shared assistant {assistant_id} with {access_group.value} by {user_id}")
|
||||
|
||||
return instance
|
||||
|
||||
async def delete_assistant(
|
||||
self,
|
||||
assistant_id: str,
|
||||
user_id: str
|
||||
) -> bool:
|
||||
"""
|
||||
Delete assistant and its files
|
||||
|
||||
Args:
|
||||
assistant_id: Assistant to delete
|
||||
user_id: User requesting deletion
|
||||
|
||||
Returns:
|
||||
True if deleted
|
||||
"""
|
||||
# Load assistant
|
||||
instance = await self._load_assistant(assistant_id)
|
||||
if not instance:
|
||||
return False
|
||||
|
||||
# Check ownership
|
||||
if instance.owner_id != user_id:
|
||||
raise PermissionError("Only owner can delete assistant")
|
||||
|
||||
# Delete files
|
||||
await self._delete_assistant_files(instance)
|
||||
|
||||
# Delete from database
|
||||
await self._delete_assistant_record(assistant_id)
|
||||
|
||||
logger.info(f"Deleted assistant {assistant_id} by {user_id}")
|
||||
|
||||
return True
|
||||
|
||||
async def get_assistant_statistics(
|
||||
self,
|
||||
assistant_id: str,
|
||||
user_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get usage statistics for assistant
|
||||
|
||||
Args:
|
||||
assistant_id: Assistant ID
|
||||
user_id: User requesting stats
|
||||
|
||||
Returns:
|
||||
Statistics dictionary
|
||||
"""
|
||||
# Load assistant
|
||||
instance = await self.get_assistant(assistant_id, user_id)
|
||||
if not instance:
|
||||
raise ValueError(f"Assistant not found or not accessible: {assistant_id}")
|
||||
|
||||
return {
|
||||
"assistant_id": assistant_id,
|
||||
"name": instance.name,
|
||||
"created_at": instance.created_at.isoformat(),
|
||||
"last_used": instance.last_used.isoformat() if instance.last_used else None,
|
||||
"conversation_count": instance.conversation_count,
|
||||
"total_messages": instance.total_messages,
|
||||
"total_tokens_used": instance.total_tokens_used,
|
||||
"access_group": instance.access_group.value,
|
||||
"team_members_count": len(instance.team_members),
|
||||
"linked_datasets_count": len(instance.linked_datasets),
|
||||
"linked_tools_count": len(instance.linked_tools)
|
||||
}
|
||||
|
||||
async def _create_assistant_files(self, instance: AssistantInstance):
|
||||
"""Create file structure for assistant"""
|
||||
# Get file paths
|
||||
file_structure = instance.get_file_structure()
|
||||
|
||||
# Create directories
|
||||
for key, path in file_structure.items():
|
||||
if key in ["memory", "resources"]:
|
||||
# These are directories
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(Path(path), stat.S_IRWXU) # 700
|
||||
else:
|
||||
# These are files
|
||||
parent = Path(path).parent
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(parent, stat.S_IRWXU) # 700
|
||||
|
||||
# Save configuration
|
||||
config_path = Path(file_structure["config"])
|
||||
config_data = {
|
||||
"id": instance.id,
|
||||
"name": instance.name,
|
||||
"template_id": instance.template_id,
|
||||
"personality": instance.personality_config.model_dump(),
|
||||
"resources": instance.resource_preferences.model_dump(),
|
||||
"memory": instance.memory_settings.model_dump(),
|
||||
"created_at": instance.created_at.isoformat(),
|
||||
"updated_at": instance.updated_at.isoformat()
|
||||
}
|
||||
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
os.chmod(config_path, stat.S_IRUSR | stat.S_IWUSR) # 600
|
||||
|
||||
# Save prompt
|
||||
prompt_path = Path(file_structure["prompt"])
|
||||
with open(prompt_path, 'w') as f:
|
||||
f.write(instance.system_prompt)
|
||||
os.chmod(prompt_path, stat.S_IRUSR | stat.S_IWUSR) # 600
|
||||
|
||||
# Save capabilities
|
||||
capabilities_path = Path(file_structure["capabilities"])
|
||||
with open(capabilities_path, 'w') as f:
|
||||
json.dump(instance.capabilities, f, indent=2)
|
||||
os.chmod(capabilities_path, stat.S_IRUSR | stat.S_IWUSR) # 600
|
||||
|
||||
# Update instance with file paths
|
||||
instance.config_file_path = str(config_path)
|
||||
instance.memory_file_path = str(Path(file_structure["memory"]))
|
||||
|
||||
async def _update_assistant_files(self, instance: AssistantInstance):
|
||||
"""Update assistant files with current configuration"""
|
||||
if instance.config_file_path:
|
||||
config_data = {
|
||||
"id": instance.id,
|
||||
"name": instance.name,
|
||||
"template_id": instance.template_id,
|
||||
"personality": instance.personality_config.model_dump(),
|
||||
"resources": instance.resource_preferences.model_dump(),
|
||||
"memory": instance.memory_settings.model_dump(),
|
||||
"created_at": instance.created_at.isoformat(),
|
||||
"updated_at": instance.updated_at.isoformat()
|
||||
}
|
||||
|
||||
with open(instance.config_file_path, 'w') as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
|
||||
async def _delete_assistant_files(self, instance: AssistantInstance):
|
||||
"""Delete assistant file structure"""
|
||||
file_structure = instance.get_file_structure()
|
||||
base_dir = Path(file_structure["config"]).parent
|
||||
|
||||
if base_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(base_dir)
|
||||
logger.info(f"Deleted assistant files at {base_dir}")
|
||||
|
||||
async def _save_assistant(self, instance: AssistantInstance):
|
||||
"""Save assistant to database (SQLite in production)"""
|
||||
# This would save to SQLite database
|
||||
# For now, we'll save to a JSON file as placeholder
|
||||
db_file = self.base_path / "instances" / f"{instance.id}.json"
|
||||
with open(db_file, 'w') as f:
|
||||
json.dump(instance.model_dump(mode='json'), f, indent=2, default=str)
|
||||
os.chmod(db_file, stat.S_IRUSR | stat.S_IWUSR) # 600
|
||||
|
||||
async def _load_assistant(self, assistant_id: str) -> Optional[AssistantInstance]:
|
||||
"""Load assistant from database"""
|
||||
db_file = self.base_path / "instances" / f"{assistant_id}.json"
|
||||
if not db_file.exists():
|
||||
return None
|
||||
|
||||
with open(db_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Convert datetime strings back to datetime objects
|
||||
for field in ['created_at', 'updated_at', 'last_used']:
|
||||
if field in data and data[field]:
|
||||
data[field] = datetime.fromisoformat(data[field])
|
||||
|
||||
return AssistantInstance(**data)
|
||||
|
||||
async def _delete_assistant_record(self, assistant_id: str):
|
||||
"""Delete assistant from database"""
|
||||
db_file = self.base_path / "instances" / f"{assistant_id}.json"
|
||||
if db_file.exists():
|
||||
db_file.unlink()
|
||||
|
||||
async def _get_owned_assistants(self, user_id: str) -> List[AssistantInstance]:
|
||||
"""Get assistants owned by user"""
|
||||
assistants = []
|
||||
instances_dir = self.base_path / "instances"
|
||||
|
||||
if instances_dir.exists():
|
||||
for file in instances_dir.glob("*.json"):
|
||||
instance = await self._load_assistant(file.stem)
|
||||
if instance and instance.owner_id == user_id:
|
||||
assistants.append(instance)
|
||||
|
||||
return assistants
|
||||
|
||||
async def _get_shared_assistants(self, user_id: str) -> List[AssistantInstance]:
|
||||
"""Get assistants shared with user"""
|
||||
assistants = []
|
||||
instances_dir = self.base_path / "instances"
|
||||
|
||||
if instances_dir.exists():
|
||||
for file in instances_dir.glob("*.json"):
|
||||
instance = await self._load_assistant(file.stem)
|
||||
if instance and instance.owner_id != user_id:
|
||||
# Check if user has access
|
||||
allowed, _ = await self.access_controller.check_permission(
|
||||
user_id, instance, "read"
|
||||
)
|
||||
if allowed:
|
||||
assistants.append(instance)
|
||||
|
||||
return assistants
|
||||
Reference in New Issue
Block a user