diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b854ab4..5ff78d5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -59,6 +59,8 @@ func main() { instanceDesiredStateRepo := repository.NewInstanceDesiredStateRepository(database) instanceCommandRepo := repository.NewInstanceCommandRepository(database) instanceConfigRevisionRepo := repository.NewInstanceConfigRevisionRepository(database) + skillRepo := repository.NewSkillRepository(database) + securityScanRepo := repository.NewSecurityScanRepository(database) if repaired, repairErr := services.RepairSeededAdminPassword(userRepo); repairErr != nil { log.Printf("Warning: failed to repair seeded admin password: %v", repairErr) @@ -81,6 +83,11 @@ func main() { riskHitService := services.NewRiskHitService(riskHitRepo) riskRuleService := services.NewRiskRuleService(riskRuleRepo) openClawConfigService := services.NewOpenClawConfigService(openClawConfigRepo) + objectStorageService, err := services.NewObjectStorageService(cfg.ObjectStorage) + if err != nil { + log.Fatalf("Failed to initialize object storage: %v", err) + } + skillScannerClient := services.NewSkillScannerClient(cfg.SkillScanner) aiObservabilityService := services.NewAIObservabilityService(modelInvocationRepo, auditEventRepo, costRecordRepo, riskHitRepo, chatMessageRepo, llmModelRepo, instanceRepo, userRepo) clusterResourceService := services.NewClusterResourceService(instanceRepo) services.SetRuntimeImageSettingsProvider(systemImageSettingService) @@ -89,12 +96,14 @@ func main() { instanceRuntimeStatusService := services.NewInstanceRuntimeStatusService(instanceRuntimeStatusRepo, instanceAgentRepo, instanceDesiredStateRepo) instanceCommandService := services.NewInstanceCommandService(instanceCommandRepo, instanceRuntimeStatusRepo, instanceDesiredStateRepo) instanceConfigRevisionService := services.NewInstanceConfigRevisionService(instanceConfigRevisionRepo) + skillService := services.NewSkillService(skillRepo, instanceRepo, instanceCommandService, objectStorageService, skillScannerClient) + securityScanService := services.NewSecurityScanService(securityScanRepo, skillRepo, objectStorageService, skillScannerClient) aiGatewayService := aigateway.NewService(llmModelRepo, modelInvocationService, auditEventService, costRecordService, riskDetectionService, riskHitService, chatSessionService, chatMessageService) // Initialize handlers authHandler := handlers.NewAuthHandler(authService) userHandler := handlers.NewUserHandler(userService, quotaService) - instanceHandler := handlers.NewInstanceHandler(instanceService, instanceAgentService, instanceRuntimeStatusService, instanceCommandService, instanceConfigRevisionService, openClawConfigService) + instanceHandler := handlers.NewInstanceHandler(instanceService, instanceAgentService, instanceRuntimeStatusService, instanceCommandService, instanceConfigRevisionService, openClawConfigService, skillService) systemSettingsHandler := handlers.NewSystemSettingsHandler(systemImageSettingService) llmModelHandler := handlers.NewLLMModelHandler(llmModelService) aiGatewayHandler := handlers.NewAIGatewayHandler(aiGatewayService) @@ -103,7 +112,9 @@ func main() { clusterResourceHandler := handlers.NewClusterResourceHandler(clusterResourceService) egressProxyHandler := handlers.NewEgressProxyHandler() openClawConfigHandler := handlers.NewOpenClawConfigHandler(openClawConfigService) - agentHandler := handlers.NewAgentHandler(instanceAgentService, instanceCommandService, instanceRuntimeStatusService, instanceConfigRevisionService) + skillHandler := handlers.NewSkillHandler(skillService, instanceService) + securityHandler := handlers.NewSecurityHandler(securityScanService) + agentHandler := handlers.NewAgentHandler(instanceAgentService, instanceCommandService, instanceRuntimeStatusService, instanceConfigRevisionService, skillService) // Initialize WebSocket hub and handler wsHub := services.GetHub() @@ -183,6 +194,9 @@ func main() { instances.POST("/:id/sync", instanceHandler.ForceSync) instances.GET("/:id/openclaw/export", instanceHandler.ExportOpenClaw) instances.POST("/:id/openclaw/import", instanceHandler.ImportOpenClaw) + instances.GET("/:id/skills", skillHandler.ListInstanceSkills) + instances.POST("/:id/skills", skillHandler.AttachSkillToInstance) + instances.DELETE("/:id/skills/:skillId", skillHandler.RemoveSkillFromInstance) } openClawConfigs := api.Group("/openclaw-configs") @@ -209,6 +223,20 @@ func main() { openClawConfigs.GET("/injections/:id", openClawConfigHandler.GetSnapshot) } + skills := api.Group("/skills") + skills.Use(middleware.Auth()) + skills.Use(middleware.SetUserInfo(userRepo)) + { + skills.GET("", skillHandler.ListSkills) + skills.POST("/import", skillHandler.ImportSkills) + skills.GET("/:id", skillHandler.GetSkill) + skills.PUT("/:id", skillHandler.UpdateSkill) + skills.DELETE("/:id", skillHandler.DeleteSkill) + skills.GET("/:id/download", skillHandler.DownloadSkill) + skills.GET("/:id/versions", skillHandler.ListVersions) + skills.GET("/:id/scan-results", skillHandler.ListScanResults) + } + systemSettings := api.Group("/system-settings") systemSettings.Use(middleware.Auth()) systemSettings.Use(middleware.SetUserInfo(userRepo)) @@ -266,6 +294,27 @@ func main() { adminRiskRules.DELETE("/:ruleId", riskRuleHandler.DeleteRule) } + adminSkills := api.Group("/admin/skills") + adminSkills.Use(middleware.Auth()) + adminSkills.Use(middleware.SetUserInfo(userRepo)) + adminSkills.Use(middleware.NewAdminAuth(userRepo)) + { + adminSkills.GET("", skillHandler.ListAllSkills) + } + + adminSecurity := api.Group("/admin/security") + adminSecurity.Use(middleware.Auth()) + adminSecurity.Use(middleware.SetUserInfo(userRepo)) + adminSecurity.Use(middleware.NewAdminAuth(userRepo)) + { + adminSecurity.GET("/config", securityHandler.GetConfig) + adminSecurity.PUT("/config", securityHandler.SaveConfig) + adminSecurity.POST("/scan-jobs", securityHandler.StartScan) + adminSecurity.POST("/skills/:id/rescan", securityHandler.RescanSkill) + adminSecurity.GET("/scan-jobs", securityHandler.ListJobs) + adminSecurity.GET("/scan-jobs/:id", securityHandler.GetJob) + } + gatewayLLM := api.Group("/gateway/llm") gatewayLLM.Use(middleware.GatewayAuth(instanceRepo)) { @@ -281,6 +330,9 @@ func main() { agent.POST("/commands/:id/start", agentHandler.StartCommand) agent.POST("/commands/:id/finish", agentHandler.FinishCommand) agent.POST("/state/report", agentHandler.ReportState) + agent.POST("/skills/inventory", agentHandler.ReportSkillInventory) + agent.POST("/skills/upload", agentHandler.UploadSkillPackage) + agent.GET("/skills/versions/:skillVersion/download", skillHandler.DownloadSkillVersionForAgent) agent.GET("/config/revisions/:id", agentHandler.GetConfigRevision) } diff --git a/backend/go.mod b/backend/go.mod index 065c885..a3bd934 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -23,10 +23,12 @@ require ( github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect @@ -43,10 +45,13 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/minio-go/v7 v7.0.85 // indirect github.com/moby/spdystream v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -56,6 +61,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect + github.com/rs/xid v1.6.0 // indirect github.com/segmentio/fasthash v1.0.3 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index afd8f53..4e6d584 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -15,6 +15,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= @@ -25,6 +27,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= @@ -79,6 +83,9 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -94,6 +101,10 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.85 h1:9psTLS/NTvC3MWoyjhjXpwcKoNbkongaCSF3PNpSuXo= +github.com/minio/minio-go/v7 v7.0.85/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY= github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -122,6 +133,8 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/segmentio/fasthash v1.0.3 h1:EI9+KE1EwvMLBWwjpRDc+fEM+prwxDYbslddQGtrmhM= github.com/segmentio/fasthash v1.0.3/go.mod h1:waKX8l2N8yckOgmSsXJi7x1ZfdKZ4x7KRMzBtS3oedY= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 69f368c..5d99dfe 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -3,16 +3,19 @@ package config import ( "fmt" "os" + "strings" "gopkg.in/yaml.v3" ) // Config holds all application configuration type Config struct { - Server ServerConfig `yaml:"server"` - Database DatabaseConfig `yaml:"database"` - JWT JWTConfig `yaml:"jwt"` - Kubernetes KubernetesConfig `yaml:"kubernetes"` + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + JWT JWTConfig `yaml:"jwt"` + Kubernetes KubernetesConfig `yaml:"kubernetes"` + ObjectStorage ObjectStorageConfig `yaml:"objectStorage"` + SkillScanner SkillScannerConfig `yaml:"skillScanner"` } // ServerConfig holds server-related configuration @@ -118,6 +121,25 @@ type LoggingConfig struct { LogAPICalls bool `yaml:"logApiCalls"` } +type ObjectStorageConfig struct { + Endpoint string `yaml:"endpoint"` + Region string `yaml:"region"` + AccessKey string `yaml:"accessKey"` + SecretKey string `yaml:"secretKey"` + Bucket string `yaml:"bucket"` + UseSSL bool `yaml:"useSSL"` + BasePath string `yaml:"basePath"` + ForcePathStyle bool `yaml:"forcePathStyle"` + LocalFallback string `yaml:"localFallback"` +} + +type SkillScannerConfig struct { + BaseURL string `yaml:"baseUrl"` + APIKey string `yaml:"apiKey"` + TimeoutSeconds int `yaml:"timeoutSeconds"` + Enabled bool `yaml:"enabled"` +} + // Load loads configuration from file and environment variables func Load() (*Config, error) { config := &Config{ @@ -175,6 +197,23 @@ func Load() (*Config, error) { LogAPICalls: false, }, }, + ObjectStorage: ObjectStorageConfig{ + Endpoint: getEnv("OBJECT_STORAGE_ENDPOINT", ""), + Region: getEnv("OBJECT_STORAGE_REGION", ""), + AccessKey: getEnv("OBJECT_STORAGE_ACCESS_KEY", ""), + SecretKey: getEnv("OBJECT_STORAGE_SECRET_KEY", ""), + Bucket: getEnv("OBJECT_STORAGE_BUCKET", "clawmanager-skills"), + UseSSL: strings.EqualFold(getEnv("OBJECT_STORAGE_USE_SSL", "false"), "true"), + BasePath: getEnv("OBJECT_STORAGE_BASE_PATH", "skills"), + ForcePathStyle: strings.EqualFold(getEnv("OBJECT_STORAGE_FORCE_PATH_STYLE", "true"), "true"), + LocalFallback: getEnv("OBJECT_STORAGE_LOCAL_FALLBACK", ".data/object-storage"), + }, + SkillScanner: SkillScannerConfig{ + BaseURL: getEnv("SKILL_SCANNER_BASE_URL", ""), + APIKey: getEnv("SKILL_SCANNER_API_KEY", ""), + TimeoutSeconds: 30, + Enabled: strings.EqualFold(getEnv("SKILL_SCANNER_ENABLED", "false"), "true"), + }, } // Try to load from k8s config file @@ -263,6 +302,46 @@ func applyEnvOverrides(config *Config) { if storageClass := os.Getenv("K8S_STORAGE_CLASS"); storageClass != "" { config.Kubernetes.Common.StorageClass = storageClass } + + if endpoint := os.Getenv("OBJECT_STORAGE_ENDPOINT"); endpoint != "" { + config.ObjectStorage.Endpoint = endpoint + } + if region := os.Getenv("OBJECT_STORAGE_REGION"); region != "" { + config.ObjectStorage.Region = region + } + if accessKey := os.Getenv("OBJECT_STORAGE_ACCESS_KEY"); accessKey != "" { + config.ObjectStorage.AccessKey = accessKey + } + if secretKey := os.Getenv("OBJECT_STORAGE_SECRET_KEY"); secretKey != "" { + config.ObjectStorage.SecretKey = secretKey + } + if bucket := os.Getenv("OBJECT_STORAGE_BUCKET"); bucket != "" { + config.ObjectStorage.Bucket = bucket + } + if useSSL := os.Getenv("OBJECT_STORAGE_USE_SSL"); useSSL != "" { + config.ObjectStorage.UseSSL = strings.EqualFold(useSSL, "true") + } + if basePath := os.Getenv("OBJECT_STORAGE_BASE_PATH"); basePath != "" { + config.ObjectStorage.BasePath = basePath + } + if forcePathStyle := os.Getenv("OBJECT_STORAGE_FORCE_PATH_STYLE"); forcePathStyle != "" { + config.ObjectStorage.ForcePathStyle = strings.EqualFold(forcePathStyle, "true") + } + if localFallback := os.Getenv("OBJECT_STORAGE_LOCAL_FALLBACK"); localFallback != "" { + config.ObjectStorage.LocalFallback = localFallback + } + if baseURL := os.Getenv("SKILL_SCANNER_BASE_URL"); baseURL != "" { + config.SkillScanner.BaseURL = baseURL + } + if apiKey := os.Getenv("SKILL_SCANNER_API_KEY"); apiKey != "" { + config.SkillScanner.APIKey = apiKey + } + if enabled := os.Getenv("SKILL_SCANNER_ENABLED"); enabled != "" { + config.SkillScanner.Enabled = strings.EqualFold(enabled, "true") + } + if timeoutSeconds := os.Getenv("SKILL_SCANNER_TIMEOUT_SECONDS"); timeoutSeconds != "" { + fmt.Sscanf(timeoutSeconds, "%d", &config.SkillScanner.TimeoutSeconds) + } } func getEnv(key, defaultValue string) string { diff --git a/backend/internal/db/migrations/008_add_skill_management.sql b/backend/internal/db/migrations/008_add_skill_management.sql new file mode 100644 index 0000000..14d93f6 --- /dev/null +++ b/backend/internal/db/migrations/008_add_skill_management.sql @@ -0,0 +1,91 @@ +CREATE TABLE IF NOT EXISTS skill_blobs ( + id INT AUTO_INCREMENT PRIMARY KEY, + content_hash VARCHAR(128) NOT NULL, + archive_hash VARCHAR(128) NOT NULL, + object_key VARCHAR(512) NOT NULL, + file_name VARCHAR(255) NOT NULL, + media_type VARCHAR(100) NOT NULL DEFAULT 'application/gzip', + size_bytes BIGINT NOT NULL DEFAULT 0, + scan_status VARCHAR(30) NOT NULL DEFAULT 'pending', + risk_level VARCHAR(30) NOT NULL DEFAULT 'unknown', + last_scanned_at TIMESTAMP NULL, + last_scan_result_id INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_skill_blobs_content_hash (content_hash), + INDEX idx_skill_blobs_scan_status (scan_status), + INDEX idx_skill_blobs_risk_level (risk_level) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS skills ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + skill_key VARCHAR(120) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + current_version_id INT NULL, + source_type VARCHAR(30) NOT NULL DEFAULT 'uploaded', + status VARCHAR(30) NOT NULL DEFAULT 'active', + risk_level VARCHAR(30) NOT NULL DEFAULT 'unknown', + last_scanned_at TIMESTAMP NULL, + last_scan_result_id INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY uk_skills_user_key (user_id, skill_key), + INDEX idx_skills_user_status (user_id, status), + INDEX idx_skills_risk_level (risk_level) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS skill_versions ( + id INT AUTO_INCREMENT PRIMARY KEY, + skill_id INT NOT NULL, + blob_id INT NOT NULL, + version_no INT NOT NULL, + manifest_json LONGTEXT NULL, + source_type VARCHAR(30) NOT NULL DEFAULT 'uploaded', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE, + FOREIGN KEY (blob_id) REFERENCES skill_blobs(id) ON DELETE RESTRICT, + UNIQUE KEY uk_skill_versions_skill_version (skill_id, version_no), + UNIQUE KEY uk_skill_versions_skill_blob (skill_id, blob_id), + INDEX idx_skill_versions_skill_id (skill_id, version_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS instance_skills ( + id INT AUTO_INCREMENT PRIMARY KEY, + instance_id INT NOT NULL, + skill_id INT NOT NULL, + skill_version_id INT NULL, + source_type VARCHAR(40) NOT NULL DEFAULT 'discovered_in_instance', + install_path VARCHAR(1024) NULL, + observed_hash VARCHAR(128) NULL, + status VARCHAR(30) NOT NULL DEFAULT 'active', + last_seen_at TIMESTAMP NULL, + removed_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE, + FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE, + FOREIGN KEY (skill_version_id) REFERENCES skill_versions(id) ON DELETE SET NULL, + UNIQUE KEY uk_instance_skills_instance_skill (instance_id, skill_id), + INDEX idx_instance_skills_instance (instance_id, status), + INDEX idx_instance_skills_skill (skill_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS skill_scan_results ( + id INT AUTO_INCREMENT PRIMARY KEY, + blob_id INT NOT NULL, + engine VARCHAR(60) NOT NULL, + risk_level VARCHAR(30) NOT NULL DEFAULT 'unknown', + status VARCHAR(30) NOT NULL DEFAULT 'completed', + summary TEXT NULL, + findings_json LONGTEXT NULL, + scanned_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (blob_id) REFERENCES skill_blobs(id) ON DELETE CASCADE, + INDEX idx_skill_scan_results_blob (blob_id, scanned_at), + INDEX idx_skill_scan_results_risk (risk_level, scanned_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/internal/handlers/agent_handler.go b/backend/internal/handlers/agent_handler.go index 8347ccf..b2d1c6f 100644 --- a/backend/internal/handlers/agent_handler.go +++ b/backend/internal/handlers/agent_handler.go @@ -17,14 +17,16 @@ type AgentHandler struct { commandService services.InstanceCommandService runtimeStatusService services.InstanceRuntimeStatusService configRevisionService services.InstanceConfigRevisionService + skillService services.SkillService } -func NewAgentHandler(agentService services.InstanceAgentService, commandService services.InstanceCommandService, runtimeStatusService services.InstanceRuntimeStatusService, configRevisionService services.InstanceConfigRevisionService) *AgentHandler { +func NewAgentHandler(agentService services.InstanceAgentService, commandService services.InstanceCommandService, runtimeStatusService services.InstanceRuntimeStatusService, configRevisionService services.InstanceConfigRevisionService, skillService services.SkillService) *AgentHandler { return &AgentHandler{ agentService: agentService, commandService: commandService, runtimeStatusService: runtimeStatusService, configRevisionService: configRevisionService, + skillService: skillService, } } @@ -178,6 +180,57 @@ func (h *AgentHandler) GetConfigRevision(c *gin.Context) { utils.Success(c, http.StatusOK, "Config revision retrieved successfully", gin.H{"revision": revision}) } +func (h *AgentHandler) ReportSkillInventory(c *gin.Context) { + session, ok := h.authenticateAgentSession(c) + if !ok { + return + } + var req services.AgentSkillInventoryReportRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ValidationError(c, err) + return + } + if strings.TrimSpace(req.AgentID) != "" && strings.TrimSpace(req.AgentID) != session.Agent.AgentID { + utils.Error(c, http.StatusForbidden, "Agent ID does not match session") + return + } + if err := h.skillService.SyncAgentSkills(session.Instance.ID, req); err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Agent skill inventory reported successfully", nil) +} + +func (h *AgentHandler) UploadSkillPackage(c *gin.Context) { + session, ok := h.authenticateAgentSession(c) + if !ok { + return + } + fileHeader, err := c.FormFile("file") + if err != nil { + utils.Error(c, http.StatusBadRequest, "file is required") + return + } + req := services.AgentSkillPackageUploadRequest{ + AgentID: strings.TrimSpace(c.PostForm("agent_id")), + SkillID: strings.TrimSpace(c.PostForm("skill_id")), + SkillVersion: strings.TrimSpace(c.PostForm("skill_version")), + Identifier: strings.TrimSpace(c.PostForm("identifier")), + ContentMD5: strings.TrimSpace(c.PostForm("content_md5")), + Source: strings.TrimSpace(c.PostForm("source")), + } + if req.AgentID != "" && req.AgentID != session.Agent.AgentID { + utils.Error(c, http.StatusForbidden, "Agent ID does not match session") + return + } + item, err := h.skillService.UploadAgentSkillPackage(c.Request.Context(), session.Instance.ID, req, fileHeader) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusCreated, "Agent skill package uploaded successfully", item) +} + func (h *AgentHandler) authenticateAgentSession(c *gin.Context) (*services.AgentSession, bool) { sessionToken := extractBearerToken(c.GetHeader("Authorization")) if sessionToken == "" { diff --git a/backend/internal/handlers/instance_handler.go b/backend/internal/handlers/instance_handler.go index a0d95b5..34ae5bd 100644 --- a/backend/internal/handlers/instance_handler.go +++ b/backend/internal/handlers/instance_handler.go @@ -26,10 +26,11 @@ type InstanceHandler struct { proxyService *services.InstanceProxyService openClawTransferService services.OpenClawTransferService openClawConfigService services.OpenClawConfigService + skillService services.SkillService } // NewInstanceHandler creates a new instance handler -func NewInstanceHandler(instanceService services.InstanceService, instanceAgentService services.InstanceAgentService, runtimeStatusService services.InstanceRuntimeStatusService, instanceCommandService services.InstanceCommandService, instanceConfigRevisionService services.InstanceConfigRevisionService, openClawConfigService services.OpenClawConfigService) *InstanceHandler { +func NewInstanceHandler(instanceService services.InstanceService, instanceAgentService services.InstanceAgentService, runtimeStatusService services.InstanceRuntimeStatusService, instanceCommandService services.InstanceCommandService, instanceConfigRevisionService services.InstanceConfigRevisionService, openClawConfigService services.OpenClawConfigService, skillService services.SkillService) *InstanceHandler { accessService := services.NewInstanceAccessService() return &InstanceHandler{ instanceService: instanceService, @@ -41,6 +42,7 @@ func NewInstanceHandler(instanceService services.InstanceService, instanceAgentS proxyService: services.NewInstanceProxyService(accessService), openClawTransferService: services.NewOpenClawTransferService(), openClawConfigService: openClawConfigService, + skillService: skillService, } } @@ -74,6 +76,7 @@ type CreateInstanceRequest struct { ImageTag *string `json:"image_tag,omitempty"` StorageClass string `json:"storage_class"` OpenClawConfigPlan *services.OpenClawConfigPlan `json:"openclaw_config_plan,omitempty"` + SkillIDs []int `json:"skill_ids,omitempty"` } // UpdateInstanceRequest represents an update instance request @@ -152,6 +155,13 @@ func (h *InstanceHandler) CreateInstance(c *gin.Context) { return } + for _, skillID := range req.SkillIDs { + if _, err := h.skillService.AttachSkillToInstance(instance.ID, skillID); err != nil { + utils.HandleError(c, err) + return + } + } + utils.Success(c, http.StatusCreated, "Instance created successfully", instance) } diff --git a/backend/internal/handlers/security_handler.go b/backend/internal/handlers/security_handler.go new file mode 100644 index 0000000..54e367f --- /dev/null +++ b/backend/internal/handlers/security_handler.go @@ -0,0 +1,104 @@ +package handlers + +import ( + "net/http" + "strconv" + + "clawreef/internal/services" + "clawreef/internal/utils" + + "github.com/gin-gonic/gin" +) + +type SecurityHandler struct { + service services.SecurityScanService +} + +func NewSecurityHandler(service services.SecurityScanService) *SecurityHandler { + return &SecurityHandler{service: service} +} + +func (h *SecurityHandler) GetConfig(c *gin.Context) { + item, err := h.service.GetConfig() + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Security scan config retrieved successfully", item) +} + +func (h *SecurityHandler) SaveConfig(c *gin.Context) { + var req services.SecurityScanConfigPayload + if err := c.ShouldBindJSON(&req); err != nil { + utils.ValidationError(c, err) + return + } + userID, _ := c.Get("userID") + item, err := h.service.SaveConfig(userID.(int), req) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Security scan config saved successfully", item) +} + +func (h *SecurityHandler) StartScan(c *gin.Context) { + var req services.StartSecurityScanRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ValidationError(c, err) + return + } + userID, _ := c.Get("userID") + item, err := h.service.StartScan(userID.(int), req) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusCreated, "Security scan started successfully", item) +} + +func (h *SecurityHandler) ListJobs(c *gin.Context) { + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) + items, err := h.service.ListJobs(limit) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Security scan jobs retrieved successfully", items) +} + +func (h *SecurityHandler) GetJob(c *gin.Context) { + jobID, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "invalid job ID") + return + } + item, err := h.service.GetJob(jobID) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Security scan job retrieved successfully", item) +} + +func (h *SecurityHandler) RescanSkill(c *gin.Context) { + skillID, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "invalid skill ID") + return + } + var req struct { + ScanMode string `json:"scan_mode"` + } + if err := c.ShouldBindJSON(&req); err != nil { + utils.ValidationError(c, err) + return + } + userID, _ := c.Get("userID") + item, err := h.service.RescanSkill(userID.(int), skillID, req.ScanMode) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusCreated, "Skill rescan started successfully", item) +} diff --git a/backend/internal/handlers/skill_handler.go b/backend/internal/handlers/skill_handler.go new file mode 100644 index 0000000..cd7092c --- /dev/null +++ b/backend/internal/handlers/skill_handler.go @@ -0,0 +1,234 @@ +package handlers + +import ( + "fmt" + "net/http" + "strconv" + + "clawreef/internal/services" + "clawreef/internal/utils" + + "github.com/gin-gonic/gin" +) + +type SkillHandler struct { + service services.SkillService + instanceService services.InstanceService +} + +func NewSkillHandler(service services.SkillService, instanceService services.InstanceService) *SkillHandler { + return &SkillHandler{service: service, instanceService: instanceService} +} + +func (h *SkillHandler) ImportSkills(c *gin.Context) { + userID, _ := c.Get("userID") + fileHeader, err := c.FormFile("file") + if err != nil { + utils.Error(c, http.StatusBadRequest, "file is required") + return + } + items, err := h.service.ImportArchive(c.Request.Context(), userID.(int), fileHeader) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusCreated, "Skills imported successfully", items) +} + +func (h *SkillHandler) ListSkills(c *gin.Context) { + userID, _ := c.Get("userID") + items, err := h.service.ListSkills(userID.(int)) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Skills retrieved successfully", items) +} + +func (h *SkillHandler) ListAllSkills(c *gin.Context) { + items, err := h.service.ListAllSkills() + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "All skills retrieved successfully", items) +} + +func (h *SkillHandler) GetSkill(c *gin.Context) { + userID, _ := c.Get("userID") + skillID, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "invalid skill ID") + return + } + item, err := h.service.GetSkill(userID.(int), skillID) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Skill retrieved successfully", item) +} + +func (h *SkillHandler) UpdateSkill(c *gin.Context) { + userID, _ := c.Get("userID") + skillID, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "invalid skill ID") + return + } + var req services.UpdateSkillRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ValidationError(c, err) + return + } + item, err := h.service.UpdateSkill(userID.(int), skillID, req) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Skill updated successfully", item) +} + +func (h *SkillHandler) DeleteSkill(c *gin.Context) { + userID, _ := c.Get("userID") + skillID, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "invalid skill ID") + return + } + if err := h.service.DeleteSkill(userID.(int), skillID); err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Skill deleted successfully", nil) +} + +func (h *SkillHandler) DownloadSkill(c *gin.Context) { + userID, _ := c.Get("userID") + skillID, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "invalid skill ID") + return + } + content, fileName, err := h.service.DownloadSkill(userID.(int), skillID) + if err != nil { + utils.HandleError(c, err) + return + } + c.Header("Content-Type", "application/zip") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName)) + c.Data(http.StatusOK, "application/zip", content) +} + +func (h *SkillHandler) DownloadSkillVersionForAgent(c *gin.Context) { + content, fileName, err := h.service.DownloadSkillVersionByExternalID(c.Param("skillVersion")) + if err != nil { + utils.HandleError(c, err) + return + } + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName)) + c.Data(http.StatusOK, "application/octet-stream", content) +} + +func (h *SkillHandler) ListVersions(c *gin.Context) { + userID, _ := c.Get("userID") + skillID, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "invalid skill ID") + return + } + items, err := h.service.ListVersions(userID.(int), skillID) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Skill versions retrieved successfully", items) +} + +func (h *SkillHandler) ListScanResults(c *gin.Context) { + userID, _ := c.Get("userID") + skillID, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "invalid skill ID") + return + } + items, err := h.service.ListScanResults(userID.(int), skillID) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Skill scan results retrieved successfully", items) +} + +func (h *SkillHandler) ListInstanceSkills(c *gin.Context) { + instanceID, ok := h.authorizeOwnedInstance(c) + if !ok { + return + } + items, err := h.service.ListInstanceSkills(instanceID) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Instance skills retrieved successfully", items) +} + +func (h *SkillHandler) AttachSkillToInstance(c *gin.Context) { + instanceID, ok := h.authorizeOwnedInstance(c) + if !ok { + return + } + var req services.AttachSkillToInstanceRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ValidationError(c, err) + return + } + item, err := h.service.AttachSkillToInstance(instanceID, req.SkillID) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusCreated, "Skill attached to instance successfully", item) +} + +func (h *SkillHandler) RemoveSkillFromInstance(c *gin.Context) { + instanceID, ok := h.authorizeOwnedInstance(c) + if !ok { + return + } + skillID, err := strconv.Atoi(c.Param("skillId")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "invalid skill ID") + return + } + if err := h.service.RemoveSkillFromInstance(instanceID, skillID); err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Skill removed from instance successfully", nil) +} + +func (h *SkillHandler) authorizeOwnedInstance(c *gin.Context) (int, bool) { + instanceID, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "invalid instance ID") + return 0, false + } + instance, err := h.instanceService.GetByID(instanceID) + if err != nil { + utils.HandleError(c, err) + return 0, false + } + if instance == nil { + utils.Error(c, http.StatusNotFound, "Instance not found") + return 0, false + } + userID, _ := c.Get("userID") + userRole, _ := c.Get("userRole") + if userRole != "admin" && instance.UserID != userID.(int) { + utils.Error(c, http.StatusForbidden, "Access denied") + return 0, false + } + return instanceID, true +} diff --git a/backend/internal/models/security_scan.go b/backend/internal/models/security_scan.go new file mode 100644 index 0000000..9118f0d --- /dev/null +++ b/backend/internal/models/security_scan.go @@ -0,0 +1,68 @@ +package models + +import "time" + +type SecurityScanConfig struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + DefaultMode string `db:"default_mode" json:"default_mode"` + QuickAnalyzersJSON string `db:"quick_analyzers_json" json:"-"` + DeepAnalyzersJSON string `db:"deep_analyzers_json" json:"-"` + QuickTimeoutSeconds int `db:"quick_timeout_seconds" json:"quick_timeout_seconds"` + DeepTimeoutSeconds int `db:"deep_timeout_seconds" json:"deep_timeout_seconds"` + AllowFallback bool `db:"allow_fallback" json:"allow_fallback"` + UpdatedBy *int `db:"updated_by" json:"updated_by,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (s SecurityScanConfig) TableName() string { return "security_scan_configs" } + +type SecurityScanJob struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + AssetType string `db:"asset_type" json:"asset_type"` + ScanMode string `db:"scan_mode" json:"scan_mode"` + Status string `db:"status" json:"status"` + RequestedBy *int `db:"requested_by" json:"requested_by,omitempty"` + ScopeJSON *string `db:"scope_json" json:"-"` + TotalItems int `db:"total_items" json:"total_items"` + CompletedItems int `db:"completed_items" json:"completed_items"` + FailedItems int `db:"failed_items" json:"failed_items"` + CurrentItemName *string `db:"current_item_name" json:"current_item_name,omitempty"` + StartedAt *time.Time `db:"started_at" json:"started_at,omitempty"` + FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (s SecurityScanJob) TableName() string { return "security_scan_jobs" } + +type SecurityScanJobItem struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + JobID int `db:"job_id" json:"job_id"` + AssetType string `db:"asset_type" json:"asset_type"` + AssetID int `db:"asset_id" json:"asset_id"` + AssetName string `db:"asset_name" json:"asset_name"` + Status string `db:"status" json:"status"` + ProgressPct int `db:"progress_pct" json:"progress_pct"` + RiskLevel *string `db:"risk_level" json:"risk_level,omitempty"` + Summary *string `db:"summary" json:"summary,omitempty"` + ScanResultID *int `db:"scan_result_id" json:"scan_result_id,omitempty"` + CachedResult bool `db:"cached_result" json:"cached_result"` + ErrorMessage *string `db:"error_message" json:"error_message,omitempty"` + StartedAt *time.Time `db:"started_at" json:"started_at,omitempty"` + FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (s SecurityScanJobItem) TableName() string { return "security_scan_job_items" } + +type SecurityScanReport struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + JobID int `db:"job_id" json:"job_id"` + SummaryJSON string `db:"summary_json" json:"-"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (s SecurityScanReport) TableName() string { return "security_scan_reports" } diff --git a/backend/internal/models/skill.go b/backend/internal/models/skill.go new file mode 100644 index 0000000..a17fbdf --- /dev/null +++ b/backend/internal/models/skill.go @@ -0,0 +1,84 @@ +package models + +import "time" + +type Skill struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + UserID int `db:"user_id" json:"user_id"` + SkillKey string `db:"skill_key" json:"skill_key"` + Name string `db:"name" json:"name"` + Description *string `db:"description" json:"description,omitempty"` + CurrentVersionID *int `db:"current_version_id" json:"current_version_id,omitempty"` + SourceType string `db:"source_type" json:"source_type"` + Status string `db:"status" json:"status"` + RiskLevel string `db:"risk_level" json:"risk_level"` + LastScannedAt *time.Time `db:"last_scanned_at" json:"last_scanned_at,omitempty"` + LastScanResultID *int `db:"last_scan_result_id" json:"last_scan_result_id,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (s Skill) TableName() string { return "skills" } + +type SkillBlob struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + ContentHash string `db:"content_hash" json:"content_hash"` + ArchiveHash string `db:"archive_hash" json:"archive_hash"` + ObjectKey string `db:"object_key" json:"object_key"` + FileName string `db:"file_name" json:"file_name"` + MediaType string `db:"media_type" json:"media_type"` + SizeBytes int64 `db:"size_bytes" json:"size_bytes"` + ScanStatus string `db:"scan_status" json:"scan_status"` + RiskLevel string `db:"risk_level" json:"risk_level"` + LastScannedAt *time.Time `db:"last_scanned_at" json:"last_scanned_at,omitempty"` + LastScanResultID *int `db:"last_scan_result_id" json:"last_scan_result_id,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (s SkillBlob) TableName() string { return "skill_blobs" } + +type SkillVersion struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + SkillID int `db:"skill_id" json:"skill_id"` + BlobID int `db:"blob_id" json:"blob_id"` + VersionNo int `db:"version_no" json:"version_no"` + ManifestJSON *string `db:"manifest_json" json:"-"` + SourceType string `db:"source_type" json:"source_type"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (s SkillVersion) TableName() string { return "skill_versions" } + +type InstanceSkill struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + InstanceID int `db:"instance_id" json:"instance_id"` + SkillID int `db:"skill_id" json:"skill_id"` + SkillVersionID *int `db:"skill_version_id" json:"skill_version_id,omitempty"` + SourceType string `db:"source_type" json:"source_type"` + InstallPath *string `db:"install_path" json:"install_path,omitempty"` + ObservedHash *string `db:"observed_hash" json:"observed_hash,omitempty"` + Status string `db:"status" json:"status"` + LastSeenAt *time.Time `db:"last_seen_at" json:"last_seen_at,omitempty"` + RemovedAt *time.Time `db:"removed_at" json:"removed_at,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (s InstanceSkill) TableName() string { return "instance_skills" } + +type SkillScanResult struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + BlobID int `db:"blob_id" json:"blob_id"` + Engine string `db:"engine" json:"engine"` + RiskLevel string `db:"risk_level" json:"risk_level"` + Status string `db:"status" json:"status"` + Summary *string `db:"summary" json:"summary,omitempty"` + FindingsJSON *string `db:"findings_json" json:"-"` + ScannedAt *time.Time `db:"scanned_at" json:"scanned_at,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (s SkillScanResult) TableName() string { return "skill_scan_results" } diff --git a/backend/internal/repository/security_scan_repository.go b/backend/internal/repository/security_scan_repository.go new file mode 100644 index 0000000..5471e9b --- /dev/null +++ b/backend/internal/repository/security_scan_repository.go @@ -0,0 +1,254 @@ +package repository + +import ( + "fmt" + "time" + + "clawreef/internal/models" + + "github.com/upper/db/v4" +) + +type SecurityScanRepository interface { + GetConfig() (*models.SecurityScanConfig, error) + UpsertConfig(config *models.SecurityScanConfig) error + CreateJob(job *models.SecurityScanJob) error + UpdateJob(job *models.SecurityScanJob) error + GetJobByID(id int) (*models.SecurityScanJob, error) + ListJobs(limit int) ([]models.SecurityScanJob, error) + CreateJobItem(item *models.SecurityScanJobItem) error + UpdateJobItem(item *models.SecurityScanJobItem) error + ListJobItems(jobID int) ([]models.SecurityScanJobItem, error) + UpsertReport(report *models.SecurityScanReport) error + GetReportByJobID(jobID int) (*models.SecurityScanReport, error) +} + +type securityScanRepository struct { + sess db.Session +} + +func NewSecurityScanRepository(sess db.Session) SecurityScanRepository { + repo := &securityScanRepository{sess: sess} + repo.ensureTables() + return repo +} + +func (r *securityScanRepository) ensureTables() { + statements := []string{ + `CREATE TABLE IF NOT EXISTS security_scan_configs ( + id INT AUTO_INCREMENT PRIMARY KEY, + default_mode VARCHAR(20) NOT NULL DEFAULT 'quick', + quick_analyzers_json LONGTEXT NOT NULL, + deep_analyzers_json LONGTEXT NOT NULL, + quick_timeout_seconds INT NOT NULL DEFAULT 30, + deep_timeout_seconds INT NOT NULL DEFAULT 120, + allow_fallback BOOLEAN NOT NULL DEFAULT TRUE, + updated_by INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_security_scan_configs_singleton (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS security_scan_jobs ( + id INT AUTO_INCREMENT PRIMARY KEY, + asset_type VARCHAR(30) NOT NULL DEFAULT 'skill', + scan_mode VARCHAR(20) NOT NULL DEFAULT 'quick', + status VARCHAR(20) NOT NULL DEFAULT 'pending', + requested_by INT NULL, + scope_json LONGTEXT NULL, + total_items INT NOT NULL DEFAULT 0, + completed_items INT NOT NULL DEFAULT 0, + failed_items INT NOT NULL DEFAULT 0, + current_item_name VARCHAR(255) NULL, + started_at TIMESTAMP NULL, + finished_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_security_scan_jobs_status (status, created_at), + INDEX idx_security_scan_jobs_asset_type (asset_type, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS security_scan_job_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + job_id INT NOT NULL, + asset_type VARCHAR(30) NOT NULL DEFAULT 'skill', + asset_id INT NOT NULL, + asset_name VARCHAR(255) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + progress_pct INT NOT NULL DEFAULT 0, + risk_level VARCHAR(30) NULL, + summary TEXT NULL, + scan_result_id INT NULL, + cached_result BOOLEAN NOT NULL DEFAULT FALSE, + error_message TEXT NULL, + started_at TIMESTAMP NULL, + finished_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (job_id) REFERENCES security_scan_jobs(id) ON DELETE CASCADE, + UNIQUE KEY uk_security_scan_job_items_job_asset (job_id, asset_type, asset_id), + INDEX idx_security_scan_job_items_job (job_id, status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS security_scan_reports ( + id INT AUTO_INCREMENT PRIMARY KEY, + job_id INT NOT NULL, + summary_json LONGTEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (job_id) REFERENCES security_scan_jobs(id) ON DELETE CASCADE, + UNIQUE KEY uk_security_scan_reports_job (job_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + } + for _, statement := range statements { + if _, err := r.sess.SQL().Exec(statement); err != nil { + panic(fmt.Errorf("failed to ensure security scan tables: %w", err)) + } + } +} + +func (r *securityScanRepository) GetConfig() (*models.SecurityScanConfig, error) { + var item models.SecurityScanConfig + err := r.sess.Collection("security_scan_configs").Find(db.Cond{"id": 1}).One(&item) + if err == db.ErrNoMoreRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get security scan config: %w", err) + } + return &item, nil +} + +func (r *securityScanRepository) UpsertConfig(config *models.SecurityScanConfig) error { + existing, err := r.GetConfig() + if err != nil { + return err + } + now := time.Now().UTC() + if existing == nil { + config.ID = 1 + config.CreatedAt = now + config.UpdatedAt = now + if _, err := r.sess.Collection("security_scan_configs").Insert(config); err != nil { + return fmt.Errorf("failed to create security scan config: %w", err) + } + return nil + } + config.ID = existing.ID + config.CreatedAt = existing.CreatedAt + config.UpdatedAt = now + if err := r.sess.Collection("security_scan_configs").Find(db.Cond{"id": existing.ID}).Update(config); err != nil { + return fmt.Errorf("failed to update security scan config: %w", err) + } + return nil +} + +func (r *securityScanRepository) CreateJob(job *models.SecurityScanJob) error { + ensureTimestamps(&job.CreatedAt, &job.UpdatedAt) + res, err := r.sess.Collection("security_scan_jobs").Insert(job) + if err != nil { + return fmt.Errorf("failed to create security scan job: %w", err) + } + if id, ok := res.ID().(int64); ok { + job.ID = int(id) + } + return nil +} + +func (r *securityScanRepository) UpdateJob(job *models.SecurityScanJob) error { + if job.UpdatedAt.IsZero() { + job.UpdatedAt = time.Now().UTC() + } + if err := r.sess.Collection("security_scan_jobs").Find(db.Cond{"id": job.ID}).Update(job); err != nil { + return fmt.Errorf("failed to update security scan job: %w", err) + } + return nil +} + +func (r *securityScanRepository) GetJobByID(id int) (*models.SecurityScanJob, error) { + var item models.SecurityScanJob + err := r.sess.Collection("security_scan_jobs").Find(db.Cond{"id": id}).One(&item) + if err == db.ErrNoMoreRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get security scan job: %w", err) + } + return &item, nil +} + +func (r *securityScanRepository) ListJobs(limit int) ([]models.SecurityScanJob, error) { + if limit <= 0 { + limit = 20 + } + var items []models.SecurityScanJob + if err := r.sess.Collection("security_scan_jobs").Find().OrderBy("-created_at", "-id").Limit(limit).All(&items); err != nil { + return nil, fmt.Errorf("failed to list security scan jobs: %w", err) + } + return items, nil +} + +func (r *securityScanRepository) CreateJobItem(item *models.SecurityScanJobItem) error { + ensureTimestamps(&item.CreatedAt, &item.UpdatedAt) + res, err := r.sess.Collection("security_scan_job_items").Insert(item) + if err != nil { + return fmt.Errorf("failed to create security scan job item: %w", err) + } + if id, ok := res.ID().(int64); ok { + item.ID = int(id) + } + return nil +} + +func (r *securityScanRepository) UpdateJobItem(item *models.SecurityScanJobItem) error { + if item.UpdatedAt.IsZero() { + item.UpdatedAt = time.Now().UTC() + } + if err := r.sess.Collection("security_scan_job_items").Find(db.Cond{"id": item.ID}).Update(item); err != nil { + return fmt.Errorf("failed to update security scan job item: %w", err) + } + return nil +} + +func (r *securityScanRepository) ListJobItems(jobID int) ([]models.SecurityScanJobItem, error) { + var items []models.SecurityScanJobItem + if err := r.sess.Collection("security_scan_job_items").Find(db.Cond{"job_id": jobID}).OrderBy("id").All(&items); err != nil { + return nil, fmt.Errorf("failed to list security scan job items: %w", err) + } + return items, nil +} + +func (r *securityScanRepository) UpsertReport(report *models.SecurityScanReport) error { + var existing models.SecurityScanReport + err := r.sess.Collection("security_scan_reports").Find(db.Cond{"job_id": report.JobID}).One(&existing) + if err == db.ErrNoMoreRows { + ensureTimestamps(&report.CreatedAt, &report.UpdatedAt) + res, err := r.sess.Collection("security_scan_reports").Insert(report) + if err != nil { + return fmt.Errorf("failed to create security scan report: %w", err) + } + if id, ok := res.ID().(int64); ok { + report.ID = int(id) + } + return nil + } + if err != nil { + return fmt.Errorf("failed to get security scan report: %w", err) + } + report.ID = existing.ID + report.CreatedAt = existing.CreatedAt + report.UpdatedAt = time.Now().UTC() + if err := r.sess.Collection("security_scan_reports").Find(db.Cond{"id": existing.ID}).Update(report); err != nil { + return fmt.Errorf("failed to update security scan report: %w", err) + } + return nil +} + +func (r *securityScanRepository) GetReportByJobID(jobID int) (*models.SecurityScanReport, error) { + var item models.SecurityScanReport + err := r.sess.Collection("security_scan_reports").Find(db.Cond{"job_id": jobID}).One(&item) + if err == db.ErrNoMoreRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get security scan report: %w", err) + } + return &item, nil +} diff --git a/backend/internal/repository/skill_repository.go b/backend/internal/repository/skill_repository.go new file mode 100644 index 0000000..b3ac3ee --- /dev/null +++ b/backend/internal/repository/skill_repository.go @@ -0,0 +1,348 @@ +package repository + +import ( + "fmt" + "strings" + "time" + + "clawreef/internal/models" + + "github.com/upper/db/v4" +) + +type SkillRepository interface { + ListSkillsByUser(userID int) ([]models.Skill, error) + ListAllSkills() ([]models.Skill, error) + GetSkillByID(id int) (*models.Skill, error) + GetSkillByUserKey(userID int, skillKey string) (*models.Skill, error) + CreateSkill(skill *models.Skill) error + UpdateSkill(skill *models.Skill) error + DeleteSkill(id int) error + GetBlobByContentHash(hash string) (*models.SkillBlob, error) + GetBlobByID(id int) (*models.SkillBlob, error) + CreateBlob(blob *models.SkillBlob) error + UpdateBlob(blob *models.SkillBlob) error + ListVersionsBySkillID(skillID int) ([]models.SkillVersion, error) + GetVersionByID(id int) (*models.SkillVersion, error) + GetVersionBySkillAndBlob(skillID, blobID int) (*models.SkillVersion, error) + GetLatestVersionBySkillID(skillID int) (*models.SkillVersion, error) + CreateVersion(version *models.SkillVersion) error + ListInstanceSkills(instanceID int) ([]models.InstanceSkill, error) + GetInstanceSkill(instanceID, skillID int) (*models.InstanceSkill, error) + UpsertInstanceSkill(item *models.InstanceSkill) error + MarkMissingInstanceSkills(instanceID int, activeSkillIDs []int, observedAt time.Time) error + CreateScanResult(result *models.SkillScanResult) error + GetScanResultByID(id int) (*models.SkillScanResult, error) + ListScanResultsByBlobID(blobID int) ([]models.SkillScanResult, error) + GetLatestScanResultByBlobID(blobID int) (*models.SkillScanResult, error) + GetLatestScanResultBySkillID(skillID int) (*models.SkillScanResult, error) +} + +type skillRepository struct{ sess db.Session } + +func NewSkillRepository(sess db.Session) SkillRepository { return &skillRepository{sess: sess} } + +func (r *skillRepository) ListSkillsByUser(userID int) ([]models.Skill, error) { + var items []models.Skill + if err := r.sess.Collection("skills").Find(db.Cond{"user_id": userID}).OrderBy("-updated_at", "-id").All(&items); err != nil { + return nil, fmt.Errorf("failed to list skills by user: %w", err) + } + return items, nil +} + +func (r *skillRepository) ListAllSkills() ([]models.Skill, error) { + var items []models.Skill + if err := r.sess.Collection("skills").Find().OrderBy("-updated_at", "-id").All(&items); err != nil { + return nil, fmt.Errorf("failed to list all skills: %w", err) + } + return items, nil +} + +func (r *skillRepository) GetSkillByID(id int) (*models.Skill, error) { + var item models.Skill + if err := r.sess.Collection("skills").Find(db.Cond{"id": id}).One(&item); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get skill: %w", err) + } + return &item, nil +} + +func (r *skillRepository) GetSkillByUserKey(userID int, skillKey string) (*models.Skill, error) { + var item models.Skill + if err := r.sess.Collection("skills").Find(db.Cond{"user_id": userID, "skill_key": skillKey}).One(&item); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get skill by key: %w", err) + } + return &item, nil +} + +func (r *skillRepository) CreateSkill(skill *models.Skill) error { + ensureTimestamps(&skill.CreatedAt, &skill.UpdatedAt) + res, err := r.sess.Collection("skills").Insert(skill) + if err != nil { + return fmt.Errorf("failed to create skill: %w", err) + } + if id, ok := res.ID().(int64); ok { + skill.ID = int(id) + } + return nil +} + +func (r *skillRepository) UpdateSkill(skill *models.Skill) error { + if skill.UpdatedAt.IsZero() { + skill.UpdatedAt = time.Now().UTC() + } + if err := r.sess.Collection("skills").Find(db.Cond{"id": skill.ID}).Update(skill); err != nil { + return fmt.Errorf("failed to update skill: %w", err) + } + return nil +} + +func (r *skillRepository) DeleteSkill(id int) error { + if err := r.sess.Collection("skills").Find(db.Cond{"id": id}).Delete(); err != nil { + return fmt.Errorf("failed to delete skill: %w", err) + } + return nil +} + +func (r *skillRepository) GetBlobByContentHash(hash string) (*models.SkillBlob, error) { + var item models.SkillBlob + if err := r.sess.Collection("skill_blobs").Find(db.Cond{"content_hash": hash}).One(&item); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get skill blob by content hash: %w", err) + } + return &item, nil +} + +func (r *skillRepository) GetBlobByID(id int) (*models.SkillBlob, error) { + var item models.SkillBlob + if err := r.sess.Collection("skill_blobs").Find(db.Cond{"id": id}).One(&item); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get skill blob: %w", err) + } + return &item, nil +} + +func (r *skillRepository) CreateBlob(blob *models.SkillBlob) error { + ensureTimestamps(&blob.CreatedAt, &blob.UpdatedAt) + res, err := r.sess.Collection("skill_blobs").Insert(blob) + if err != nil { + return fmt.Errorf("failed to create skill blob: %w", err) + } + if id, ok := res.ID().(int64); ok { + blob.ID = int(id) + } + return nil +} + +func (r *skillRepository) UpdateBlob(blob *models.SkillBlob) error { + if blob.UpdatedAt.IsZero() { + blob.UpdatedAt = time.Now().UTC() + } + if err := r.sess.Collection("skill_blobs").Find(db.Cond{"id": blob.ID}).Update(blob); err != nil { + return fmt.Errorf("failed to update skill blob: %w", err) + } + return nil +} + +func (r *skillRepository) ListVersionsBySkillID(skillID int) ([]models.SkillVersion, error) { + var items []models.SkillVersion + if err := r.sess.Collection("skill_versions").Find(db.Cond{"skill_id": skillID}).OrderBy("-version_no", "-id").All(&items); err != nil { + return nil, fmt.Errorf("failed to list skill versions: %w", err) + } + return items, nil +} + +func (r *skillRepository) GetVersionByID(id int) (*models.SkillVersion, error) { + var item models.SkillVersion + if err := r.sess.Collection("skill_versions").Find(db.Cond{"id": id}).One(&item); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get skill version: %w", err) + } + return &item, nil +} + +func (r *skillRepository) GetVersionBySkillAndBlob(skillID, blobID int) (*models.SkillVersion, error) { + var item models.SkillVersion + if err := r.sess.Collection("skill_versions").Find(db.Cond{"skill_id": skillID, "blob_id": blobID}).One(&item); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get skill version by blob: %w", err) + } + return &item, nil +} + +func (r *skillRepository) GetLatestVersionBySkillID(skillID int) (*models.SkillVersion, error) { + var item models.SkillVersion + if err := r.sess.Collection("skill_versions").Find(db.Cond{"skill_id": skillID}).OrderBy("-version_no", "-id").One(&item); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get latest skill version: %w", err) + } + return &item, nil +} + +func (r *skillRepository) CreateVersion(version *models.SkillVersion) error { + ensureTimestamps(&version.CreatedAt, &version.UpdatedAt) + res, err := r.sess.Collection("skill_versions").Insert(version) + if err != nil { + return fmt.Errorf("failed to create skill version: %w", err) + } + if id, ok := res.ID().(int64); ok { + version.ID = int(id) + } + return nil +} + +func (r *skillRepository) ListInstanceSkills(instanceID int) ([]models.InstanceSkill, error) { + var items []models.InstanceSkill + if err := r.sess.Collection("instance_skills").Find(db.Cond{"instance_id": instanceID}).OrderBy("-updated_at", "-id").All(&items); err != nil { + return nil, fmt.Errorf("failed to list instance skills: %w", err) + } + return items, nil +} + +func (r *skillRepository) GetInstanceSkill(instanceID, skillID int) (*models.InstanceSkill, error) { + var item models.InstanceSkill + if err := r.sess.Collection("instance_skills").Find(db.Cond{"instance_id": instanceID, "skill_id": skillID}).One(&item); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get instance skill: %w", err) + } + return &item, nil +} + +func (r *skillRepository) UpsertInstanceSkill(item *models.InstanceSkill) error { + existing, err := r.GetInstanceSkill(item.InstanceID, item.SkillID) + if err != nil { + return err + } + if existing == nil { + ensureTimestamps(&item.CreatedAt, &item.UpdatedAt) + res, err := r.sess.Collection("instance_skills").Insert(item) + if err != nil { + if !isDuplicateEntryError(err) { + return fmt.Errorf("failed to create instance skill: %w", err) + } + existing, err = r.GetInstanceSkill(item.InstanceID, item.SkillID) + if err != nil { + return err + } + if existing == nil { + return fmt.Errorf("failed to create instance skill: %w", err) + } + item.ID = existing.ID + item.CreatedAt = existing.CreatedAt + if item.UpdatedAt.IsZero() { + item.UpdatedAt = time.Now().UTC() + } + if err := r.sess.Collection("instance_skills").Find(db.Cond{"id": existing.ID}).Update(item); err != nil { + return fmt.Errorf("failed to update instance skill after duplicate insert: %w", err) + } + return nil + } + if id, ok := res.ID().(int64); ok { + item.ID = int(id) + } + return nil + } + item.ID = existing.ID + item.CreatedAt = existing.CreatedAt + if item.UpdatedAt.IsZero() { + item.UpdatedAt = time.Now().UTC() + } + if err := r.sess.Collection("instance_skills").Find(db.Cond{"id": existing.ID}).Update(item); err != nil { + return fmt.Errorf("failed to update instance skill: %w", err) + } + return nil +} + +func isDuplicateEntryError(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "duplicate entry") +} + +func (r *skillRepository) MarkMissingInstanceSkills(instanceID int, activeSkillIDs []int, observedAt time.Time) error { + find := r.sess.Collection("instance_skills").Find(db.Cond{"instance_id": instanceID}) + if len(activeSkillIDs) > 0 { + find = find.And(db.Cond{"skill_id NOT IN": activeSkillIDs}) + } + var items []models.InstanceSkill + if err := find.All(&items); err != nil && err != db.ErrNoMoreRows { + return fmt.Errorf("failed to list stale instance skills: %w", err) + } + for _, item := range items { + item.Status = "removed" + item.RemovedAt = &observedAt + item.UpdatedAt = observedAt + if err := r.sess.Collection("instance_skills").Find(db.Cond{"id": item.ID}).Update(item); err != nil { + return fmt.Errorf("failed to mark instance skill removed: %w", err) + } + } + return nil +} + +func (r *skillRepository) CreateScanResult(result *models.SkillScanResult) error { + ensureTimestamps(&result.CreatedAt, &result.UpdatedAt) + res, err := r.sess.Collection("skill_scan_results").Insert(result) + if err != nil { + return fmt.Errorf("failed to create skill scan result: %w", err) + } + if id, ok := res.ID().(int64); ok { + result.ID = int(id) + } + return nil +} + +func (r *skillRepository) GetScanResultByID(id int) (*models.SkillScanResult, error) { + var item models.SkillScanResult + if err := r.sess.Collection("skill_scan_results").Find(db.Cond{"id": id}).One(&item); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get skill scan result: %w", err) + } + return &item, nil +} + +func (r *skillRepository) ListScanResultsByBlobID(blobID int) ([]models.SkillScanResult, error) { + var items []models.SkillScanResult + if err := r.sess.Collection("skill_scan_results").Find(db.Cond{"blob_id": blobID}).OrderBy("-scanned_at", "-id").All(&items); err != nil { + return nil, fmt.Errorf("failed to list skill scan results: %w", err) + } + return items, nil +} + +func (r *skillRepository) GetLatestScanResultByBlobID(blobID int) (*models.SkillScanResult, error) { + var item models.SkillScanResult + if err := r.sess.Collection("skill_scan_results").Find(db.Cond{"blob_id": blobID}).OrderBy("-scanned_at", "-id").One(&item); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get latest skill scan result: %w", err) + } + return &item, nil +} + +func (r *skillRepository) GetLatestScanResultBySkillID(skillID int) (*models.SkillScanResult, error) { + skill, err := r.GetSkillByID(skillID) + if err != nil || skill == nil || skill.LastScanResultID == nil { + return nil, err + } + return r.GetScanResultByID(*skill.LastScanResultID) +} diff --git a/backend/internal/services/instance_command_service.go b/backend/internal/services/instance_command_service.go index 69c6056..5f8cd24 100644 --- a/backend/internal/services/instance_command_service.go +++ b/backend/internal/services/instance_command_service.go @@ -18,6 +18,16 @@ const ( InstanceCommandTypeApplyConfigRevision = "apply_config_revision" InstanceCommandTypeReloadConfig = "reload_config" InstanceCommandTypeHealthCheck = "health_check" + InstanceCommandTypeInstallSkill = "install_skill" + InstanceCommandTypeUpdateSkill = "update_skill" + InstanceCommandTypeUninstallSkill = "uninstall_skill" + InstanceCommandTypeRemoveSkill = "remove_skill" + InstanceCommandTypeDisableSkill = "disable_skill" + InstanceCommandTypeQuarantineSkill = "quarantine_skill" + InstanceCommandTypeHandleSkillRisk = "handle_skill_risk" + InstanceCommandTypeSyncSkillInventory = "sync_skill_inventory" + InstanceCommandTypeRefreshSkillInventory = "refresh_skill_inventory" + InstanceCommandTypeCollectSkillPackage = "collect_skill_package" instanceCommandStatusPending = "pending" instanceCommandStatusDispatched = "dispatched" instanceCommandStatusRunning = "running" @@ -284,7 +294,17 @@ func isSupportedCommandType(commandType string) bool { InstanceCommandTypeCollectSystemInfo, InstanceCommandTypeApplyConfigRevision, InstanceCommandTypeReloadConfig, - InstanceCommandTypeHealthCheck: + InstanceCommandTypeHealthCheck, + InstanceCommandTypeInstallSkill, + InstanceCommandTypeUpdateSkill, + InstanceCommandTypeUninstallSkill, + InstanceCommandTypeRemoveSkill, + InstanceCommandTypeDisableSkill, + InstanceCommandTypeQuarantineSkill, + InstanceCommandTypeHandleSkillRisk, + InstanceCommandTypeSyncSkillInventory, + InstanceCommandTypeRefreshSkillInventory, + InstanceCommandTypeCollectSkillPackage: return true default: return false diff --git a/backend/internal/services/object_storage_service.go b/backend/internal/services/object_storage_service.go new file mode 100644 index 0000000..e29ee0f --- /dev/null +++ b/backend/internal/services/object_storage_service.go @@ -0,0 +1,114 @@ +package services + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "clawreef/internal/config" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +type ObjectStorageService interface { + PutObject(ctx context.Context, objectKey string, body []byte, contentType string) error + GetObject(ctx context.Context, objectKey string) ([]byte, error) +} + +type objectStorageService struct { + minioClient *minio.Client + bucket string + basePath string + localPath string +} + +func NewObjectStorageService(cfg config.ObjectStorageConfig) (ObjectStorageService, error) { + service := &objectStorageService{ + bucket: strings.TrimSpace(cfg.Bucket), + basePath: strings.Trim(strings.TrimSpace(cfg.BasePath), "/"), + localPath: strings.TrimSpace(cfg.LocalFallback), + } + if service.localPath == "" { + service.localPath = ".data/object-storage" + } + if strings.TrimSpace(cfg.Endpoint) == "" { + return service, nil + } + + client, err := minio.New(cfg.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.UseSSL, + Region: cfg.Region, + BucketLookup: minio.BucketLookupPath, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize object storage client: %w", err) + } + service.minioClient = client + return service, nil +} + +func (s *objectStorageService) PutObject(ctx context.Context, objectKey string, body []byte, contentType string) error { + if s.minioClient == nil { + target := filepath.Join(s.localPath, filepath.FromSlash(s.resolveObjectKey(objectKey))) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("failed to prepare local object storage directory: %w", err) + } + if err := os.WriteFile(target, body, 0o644); err != nil { + return fmt.Errorf("failed to write local object storage object: %w", err) + } + return nil + } + + exists, err := s.minioClient.BucketExists(ctx, s.bucket) + if err != nil { + return fmt.Errorf("failed to check object storage bucket: %w", err) + } + if !exists { + if err := s.minioClient.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{}); err != nil { + return fmt.Errorf("failed to create object storage bucket: %w", err) + } + } + _, err = s.minioClient.PutObject(ctx, s.bucket, s.resolveObjectKey(objectKey), bytes.NewReader(body), int64(len(body)), minio.PutObjectOptions{ + ContentType: contentType, + }) + if err != nil { + return fmt.Errorf("failed to upload object: %w", err) + } + return nil +} + +func (s *objectStorageService) GetObject(ctx context.Context, objectKey string) ([]byte, error) { + if s.minioClient == nil { + target := filepath.Join(s.localPath, filepath.FromSlash(s.resolveObjectKey(objectKey))) + content, err := os.ReadFile(target) + if err != nil { + return nil, fmt.Errorf("failed to read local object storage object: %w", err) + } + return content, nil + } + + reader, err := s.minioClient.GetObject(ctx, s.bucket, s.resolveObjectKey(objectKey), minio.GetObjectOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to open object: %w", err) + } + defer reader.Close() + content, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read object: %w", err) + } + return content, nil +} + +func (s *objectStorageService) resolveObjectKey(objectKey string) string { + objectKey = strings.Trim(strings.TrimSpace(objectKey), "/") + if s.basePath == "" { + return objectKey + } + return s.basePath + "/" + objectKey +} diff --git a/backend/internal/services/security_scan_service.go b/backend/internal/services/security_scan_service.go new file mode 100644 index 0000000..20c13e1 --- /dev/null +++ b/backend/internal/services/security_scan_service.go @@ -0,0 +1,995 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + "clawreef/internal/models" + "clawreef/internal/repository" + "clawreef/internal/services/k8s" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +type SecurityScanConfigPayload struct { + ActiveMode string `json:"active_mode"` + DefaultMode string `json:"default_mode"` + QuickAnalyzers []string `json:"quick_analyzers"` + DeepAnalyzers []string `json:"deep_analyzers"` + QuickTimeoutSeconds int `json:"quick_timeout_seconds"` + DeepTimeoutSeconds int `json:"deep_timeout_seconds"` + AllowFallback bool `json:"allow_fallback"` + ScannerStatus SecurityScannerStatusPayload `json:"scanner_status"` + SkillScannerConfig SkillScannerRuntimeConfigPayload `json:"skill_scanner_config"` +} + +type SecurityScannerStatusPayload struct { + Connected bool `json:"connected"` + LLMEnabled bool `json:"llm_enabled"` + StatusLabel string `json:"status_label"` + AvailableCapabilities []string `json:"available_capabilities"` +} + +type SkillScannerRuntimeConfigPayload struct { + Namespace string `json:"namespace"` + DeploymentName string `json:"deployment_name"` + LLMAPIKey string `json:"llm_api_key"` + LLMModel string `json:"llm_model"` + LLMBaseURL string `json:"llm_base_url"` + MetaLLMAPIKey string `json:"meta_llm_api_key"` + MetaLLMModel string `json:"meta_llm_model"` + MetaLLMBaseURL string `json:"meta_llm_base_url"` +} + +type StartSecurityScanRequest struct { + AssetType string `json:"asset_type"` + ScanMode string `json:"scan_mode"` + ScanScope string `json:"scan_scope"` + AssetID *int `json:"asset_id,omitempty"` +} + +type securityScanScope struct { + ScanScope string `json:"scan_scope"` + AssetID *int `json:"asset_id,omitempty"` +} + +type SecurityScanJobItemPayload struct { + ID int `json:"id"` + AssetType string `json:"asset_type"` + AssetID int `json:"asset_id"` + AssetName string `json:"asset_name"` + Status string `json:"status"` + ProgressPct int `json:"progress_pct"` + RiskLevel *string `json:"risk_level,omitempty"` + Summary *string `json:"summary,omitempty"` + ScanResultID *int `json:"scan_result_id,omitempty"` + CachedResult bool `json:"cached_result"` + TriggeredAnalyzers []string `json:"triggered_analyzers,omitempty"` + Findings []SkillFindingPayload `json:"findings,omitempty"` + ErrorMessage *string `json:"error_message,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` +} + +type SecurityScanReportPayload struct { + JobID int `json:"job_id"` + AssetType string `json:"asset_type"` + ScanMode string `json:"scan_mode"` + ScanScope string `json:"scan_scope"` + Status string `json:"status"` + StartedAt *time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + TotalItems int `json:"total_items"` + CompletedItems int `json:"completed_items"` + FailedItems int `json:"failed_items"` + RiskCounts map[string]int `json:"risk_counts"` + FindingsSummary []map[string]string `json:"findings_summary"` + ConfiguredAnalyzers []string `json:"configured_analyzers"` + AvailableAnalyzers []string `json:"available_analyzers"` + TriggeredAnalyzers []string `json:"triggered_analyzers"` + Items []SecurityScanJobItemPayload `json:"items"` + Config SecurityScanConfigPayload `json:"config"` +} + +type SecurityScanJobPayload struct { + ID int `json:"id"` + AssetType string `json:"asset_type"` + ScanMode string `json:"scan_mode"` + ScanScope string `json:"scan_scope"` + Status string `json:"status"` + RequestedBy *int `json:"requested_by,omitempty"` + TotalItems int `json:"total_items"` + CompletedItems int `json:"completed_items"` + FailedItems int `json:"failed_items"` + CurrentItemName *string `json:"current_item_name,omitempty"` + ProgressPct int `json:"progress_pct"` + StartedAt *time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Items []SecurityScanJobItemPayload `json:"items,omitempty"` + Report *SecurityScanReportPayload `json:"report,omitempty"` +} + +type SecurityScanService interface { + GetConfig() (*SecurityScanConfigPayload, error) + SaveConfig(updatedBy int, req SecurityScanConfigPayload) (*SecurityScanConfigPayload, error) + StartScan(requestedBy int, req StartSecurityScanRequest) (*SecurityScanJobPayload, error) + RescanSkill(requestedBy, skillID int, scanMode string) (*SecurityScanJobPayload, error) + ListJobs(limit int) ([]SecurityScanJobPayload, error) + GetJob(jobID int) (*SecurityScanJobPayload, error) +} + +type securityScanService struct { + repo repository.SecurityScanRepository + skillRepo repository.SkillRepository + storage ObjectStorageService + scanner SkillScannerClient + running sync.Map +} + +func NewSecurityScanService(repo repository.SecurityScanRepository, skillRepo repository.SkillRepository, storage ObjectStorageService, scanner SkillScannerClient) SecurityScanService { + return &securityScanService{repo: repo, skillRepo: skillRepo, storage: storage, scanner: scanner} +} + +func (s *securityScanService) GetConfig() (*SecurityScanConfigPayload, error) { + item, err := s.ensureConfig() + if err != nil { + return nil, err + } + payload, err := s.toConfigPayload(*item) + if err != nil { + return nil, err + } + return payload, nil +} + +func (s *securityScanService) SaveConfig(updatedBy int, req SecurityScanConfigPayload) (*SecurityScanConfigPayload, error) { + item, err := s.ensureConfig() + if err != nil { + return nil, err + } + activeMode := strings.TrimSpace(req.ActiveMode) + if activeMode == "" { + activeMode = strings.TrimSpace(req.DefaultMode) + } + if activeMode == "" { + activeMode = "quick" + } + quickJSON, _ := json.Marshal(normalizeAnalyzerList(req.QuickAnalyzers, defaultQuickAnalyzers())) + deepJSON, _ := json.Marshal(normalizeAnalyzerList(req.DeepAnalyzers, defaultDeepAnalyzers())) + item.DefaultMode = normalizeScanMode(activeMode) + item.QuickAnalyzersJSON = string(quickJSON) + item.DeepAnalyzersJSON = string(deepJSON) + item.QuickTimeoutSeconds = normalizeTimeout(req.QuickTimeoutSeconds, 30) + item.DeepTimeoutSeconds = normalizeTimeout(req.DeepTimeoutSeconds, 120) + item.AllowFallback = false + item.UpdatedBy = &updatedBy + if err := s.repo.UpsertConfig(item); err != nil { + return nil, err + } + if err := s.applySkillScannerConfig(req.SkillScannerConfig); err != nil { + return nil, err + } + return s.toConfigPayload(*item) +} + +func (s *securityScanService) StartScan(requestedBy int, req StartSecurityScanRequest) (*SecurityScanJobPayload, error) { + assetType := strings.TrimSpace(req.AssetType) + if assetType == "" { + assetType = "skill" + } + if assetType != "skill" { + return nil, fmt.Errorf("unsupported asset type") + } + config, err := s.ensureConfig() + if err != nil { + return nil, err + } + mode := normalizeScanMode(config.DefaultMode) + scope := normalizeScanScope(req.ScanScope) + skills, err := s.resolveScanSkills(req.AssetID) + if err != nil { + return nil, err + } + scopePayload := securityScanScope{ + ScanScope: scope, + AssetID: req.AssetID, + } + scopeJSONBytes, _ := json.Marshal(scopePayload) + scopeJSON := string(scopeJSONBytes) + job := &models.SecurityScanJob{ + AssetType: assetType, + ScanMode: mode, + ScopeJSON: &scopeJSON, + Status: "queued", + RequestedBy: &requestedBy, + TotalItems: len(skills), + CompletedItems: 0, + FailedItems: 0, + } + if err := s.repo.CreateJob(job); err != nil { + return nil, err + } + for _, skill := range skills { + item := &models.SecurityScanJobItem{ + JobID: job.ID, + AssetType: assetType, + AssetID: skill.ID, + AssetName: skill.Name, + Status: "pending", + ProgressPct: 0, + } + if err := s.repo.CreateJobItem(item); err != nil { + return nil, err + } + } + go s.runJob(job.ID) + return s.GetJob(job.ID) +} + +func (s *securityScanService) RescanSkill(requestedBy, skillID int, scanMode string) (*SecurityScanJobPayload, error) { + return s.StartScan(requestedBy, StartSecurityScanRequest{ + AssetType: "skill", + ScanMode: scanMode, + ScanScope: "full", + AssetID: &skillID, + }) +} + +func (s *securityScanService) resolveScanSkills(assetID *int) ([]models.Skill, error) { + if assetID != nil && *assetID > 0 { + skill, err := s.skillRepo.GetSkillByID(*assetID) + if err != nil { + return nil, err + } + if skill == nil { + return nil, fmt.Errorf("skill not found") + } + return []models.Skill{*skill}, nil + } + return s.skillRepo.ListAllSkills() +} + +func (s *securityScanService) ListJobs(limit int) ([]SecurityScanJobPayload, error) { + items, err := s.repo.ListJobs(limit) + if err != nil { + return nil, err + } + result := make([]SecurityScanJobPayload, 0, len(items)) + for _, item := range items { + payload, err := s.toJobPayload(item, false) + if err != nil { + return nil, err + } + result = append(result, *payload) + } + return result, nil +} + +func (s *securityScanService) GetJob(jobID int) (*SecurityScanJobPayload, error) { + item, err := s.repo.GetJobByID(jobID) + if err != nil { + return nil, err + } + if item == nil { + return nil, fmt.Errorf("security scan job not found") + } + return s.toJobPayload(*item, true) +} + +func (s *securityScanService) runJob(jobID int) { + if _, loaded := s.running.LoadOrStore(jobID, struct{}{}); loaded { + return + } + defer s.running.Delete(jobID) + + job, err := s.repo.GetJobByID(jobID) + if err != nil || job == nil { + return + } + config, err := s.ensureConfig() + if err != nil { + return + } + configPayload, err := s.toConfigPayload(*config) + if err != nil { + return + } + scope := parseSecurityScanScope(job.ScopeJSON) + now := time.Now().UTC() + job.Status = "running" + job.StartedAt = &now + job.UpdatedAt = now + _ = s.repo.UpdateJob(job) + + items, err := s.repo.ListJobItems(jobID) + if err != nil { + return + } + + for _, item := range items { + currentName := item.AssetName + startedAt := time.Now().UTC() + item.Status = "running" + item.ProgressPct = 10 + item.StartedAt = &startedAt + item.UpdatedAt = startedAt + job.CurrentItemName = ¤tName + job.UpdatedAt = startedAt + _ = s.repo.UpdateJobItem(&item) + _ = s.repo.UpdateJob(job) + + skill, err := s.skillRepo.GetSkillByID(item.AssetID) + if err != nil || skill == nil { + s.markJobItemFailed(job, &item, "skill not found") + continue + } + if skill.CurrentVersionID == nil { + s.markJobItemFailed(job, &item, "skill has no active version") + continue + } + version, err := s.skillRepo.GetVersionByID(*skill.CurrentVersionID) + if err != nil || version == nil { + s.markJobItemFailed(job, &item, "skill version not found") + continue + } + blob, err := s.skillRepo.GetBlobByID(version.BlobID) + if err != nil || blob == nil { + s.markJobItemFailed(job, &item, "skill blob not found") + continue + } + + result, cached, err := s.scanBlob(skill, blob, job.ScanMode, scope.ScanScope, *configPayload) + if err != nil { + s.markJobItemFailed(job, &item, err.Error()) + continue + } + + finishedAt := time.Now().UTC() + item.Status = "completed" + item.ProgressPct = 100 + item.CachedResult = cached + item.RiskLevel = &result.RiskLevel + item.Summary = result.Summary + item.ScanResultID = &result.ID + item.FinishedAt = &finishedAt + item.UpdatedAt = finishedAt + job.CompletedItems++ + job.CurrentItemName = ¤tName + job.UpdatedAt = finishedAt + _ = s.repo.UpdateJobItem(&item) + _ = s.repo.UpdateJob(job) + } + + doneAt := time.Now().UTC() + job.Status = "completed" + job.CurrentItemName = nil + job.FinishedAt = &doneAt + job.UpdatedAt = doneAt + _ = s.repo.UpdateJob(job) + _ = s.generateReport(job.ID, *config) +} + +func (s *securityScanService) markJobItemFailed(job *models.SecurityScanJob, item *models.SecurityScanJobItem, message string) { + finishedAt := time.Now().UTC() + item.Status = "failed" + item.ProgressPct = 100 + item.ErrorMessage = optionalString(message) + item.FinishedAt = &finishedAt + item.UpdatedAt = finishedAt + job.CompletedItems++ + job.FailedItems++ + job.UpdatedAt = finishedAt + _ = s.repo.UpdateJobItem(item) + _ = s.repo.UpdateJob(job) +} + +func (s *securityScanService) scanBlob(skill *models.Skill, blob *models.SkillBlob, mode, scope string, config SecurityScanConfigPayload) (*models.SkillScanResult, bool, error) { + latest, err := s.skillRepo.GetLatestScanResultByBlobID(blob.ID) + if err != nil { + return nil, false, err + } + if scope == "incremental" && latest != nil && latest.Status == "completed" && strings.EqualFold(strings.TrimSpace(latest.Engine), "skill-scanner") { + return latest, true, nil + } + if strings.TrimSpace(blob.ObjectKey) == "" || blob.SizeBytes <= 0 { + now := time.Now().UTC() + blob.ScanStatus = "pending" + blob.UpdatedAt = now + _ = s.skillRepo.UpdateBlob(blob) + return nil, false, fmt.Errorf("skill package has not been collected from agent yet") + } + + content, err := s.storage.GetObject(context.Background(), blob.ObjectKey) + if err != nil { + now := time.Now().UTC() + blob.ScanStatus = "pending" + blob.UpdatedAt = now + _ = s.skillRepo.UpdateBlob(blob) + if strings.Contains(strings.ToLower(err.Error()), "specified key does not exist") { + return nil, false, fmt.Errorf("skill package has not been collected from agent yet") + } + return nil, false, err + } + timeout := config.QuickTimeoutSeconds + if mode == "deep" { + timeout = config.DeepTimeoutSeconds + } + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + + options := map[string]string{ + "scan_mode": mode, + } + analyzers := config.QuickAnalyzers + if mode == "deep" { + analyzers = config.DeepAnalyzers + } + if len(analyzers) > 0 { + options["analyzers"] = strings.Join(analyzers, ",") + } + + if s.scanner == nil { + return nil, false, fmt.Errorf("skill scanner is not configured") + } + riskLevel, findings, summary, err := s.scanner.ScanArchive(ctx, blob.FileName, content, options) + if err != nil { + now := time.Now().UTC() + blob.ScanStatus = "failed" + blob.UpdatedAt = now + _ = s.skillRepo.UpdateBlob(blob) + return nil, false, fmt.Errorf("skill scanner failed: %w", err) + } + if strings.TrimSpace(summary) == "" { + summary = "Skill scanned by external skill-scanner service" + } + + scannedAt := time.Now().UTC() + findingsJSON, _ := json.Marshal(findings) + result := &models.SkillScanResult{ + BlobID: blob.ID, Engine: "skill-scanner", RiskLevel: riskLevel, Status: "completed", + Summary: &summary, FindingsJSON: optionalString(string(findingsJSON)), ScannedAt: &scannedAt, + } + if err := s.skillRepo.CreateScanResult(result); err != nil { + return nil, false, err + } + blob.ScanStatus = "completed" + blob.RiskLevel = riskLevel + blob.LastScannedAt = &scannedAt + blob.LastScanResultID = &result.ID + if err := s.skillRepo.UpdateBlob(blob); err != nil { + return nil, false, err + } + if skill != nil { + skill.RiskLevel = riskLevel + skill.LastScannedAt = &scannedAt + skill.LastScanResultID = &result.ID + skill.UpdatedAt = scannedAt + _ = s.skillRepo.UpdateSkill(skill) + } + return result, false, nil +} + +func (s *securityScanService) generateReport(jobID int, config models.SecurityScanConfig) error { + job, err := s.repo.GetJobByID(jobID) + if err != nil || job == nil { + return err + } + jobItems, err := s.repo.ListJobItems(jobID) + if err != nil { + return err + } + cfgPayload, err := s.toConfigPayload(config) + if err != nil { + return err + } + riskCounts := map[string]int{} + findingsSummary := make([]map[string]string, 0, len(jobItems)) + itemPayloads := make([]SecurityScanJobItemPayload, 0, len(jobItems)) + triggeredAnalyzers := map[string]struct{}{} + for _, item := range jobItems { + if item.RiskLevel != nil { + riskCounts[*item.RiskLevel]++ + } + itemPayload := toSecurityScanJobItemPayload(item) + if item.ScanResultID != nil { + result, err := s.skillRepo.GetScanResultByID(*item.ScanResultID) + if err != nil { + return err + } + findings := parseSkillFindings(result) + itemPayload.TriggeredAnalyzers = extractTriggeredAnalyzers(result) + itemPayload.Findings = topRiskFindings(findings, 5) + if summary := summarizeScanJobItem(item, findings); summary != nil { + itemPayload.Summary = summary + findingsSummary = append(findingsSummary, map[string]string{ + "asset_name": item.AssetName, + "summary": *summary, + }) + } + for _, analyzer := range itemPayload.TriggeredAnalyzers { + triggeredAnalyzers[analyzer] = struct{}{} + } + } else if summary := summarizeScanJobItem(item, nil); summary != nil { + itemPayload.Summary = summary + findingsSummary = append(findingsSummary, map[string]string{ + "asset_name": item.AssetName, + "summary": *summary, + }) + } + itemPayloads = append(itemPayloads, itemPayload) + } + configuredAnalyzers := cfgPayload.QuickAnalyzers + if job.ScanMode == "deep" { + configuredAnalyzers = cfgPayload.DeepAnalyzers + } + availableAnalyzers := []string{} + if s.scanner != nil { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if analyzers, err := s.scanner.AvailableAnalyzers(ctx); err == nil { + availableAnalyzers = normalizeAnalyzerList(analyzers, []string{}) + } + } + reportPayload := SecurityScanReportPayload{ + JobID: job.ID, + AssetType: job.AssetType, + ScanMode: job.ScanMode, + ScanScope: parseSecurityScanScope(job.ScopeJSON).ScanScope, + Status: job.Status, + StartedAt: job.StartedAt, + FinishedAt: job.FinishedAt, + TotalItems: job.TotalItems, + CompletedItems: job.CompletedItems, + FailedItems: job.FailedItems, + RiskCounts: riskCounts, + FindingsSummary: findingsSummary, + ConfiguredAnalyzers: append([]string{}, configuredAnalyzers...), + AvailableAnalyzers: availableAnalyzers, + TriggeredAnalyzers: sortedAnalyzerKeys(triggeredAnalyzers), + Items: itemPayloads, + Config: *cfgPayload, + } + summaryJSON, _ := json.Marshal(reportPayload) + return s.repo.UpsertReport(&models.SecurityScanReport{ + JobID: job.ID, + SummaryJSON: string(summaryJSON), + }) +} + +func (s *securityScanService) ensureConfig() (*models.SecurityScanConfig, error) { + item, err := s.repo.GetConfig() + if err != nil { + return nil, err + } + if item != nil { + return item, nil + } + quickJSON, _ := json.Marshal(defaultQuickAnalyzers()) + deepJSON, _ := json.Marshal(defaultDeepAnalyzers()) + item = &models.SecurityScanConfig{ + ID: 1, + DefaultMode: "quick", + QuickAnalyzersJSON: string(quickJSON), + DeepAnalyzersJSON: string(deepJSON), + QuickTimeoutSeconds: 30, + DeepTimeoutSeconds: 120, + AllowFallback: false, + } + if err := s.repo.UpsertConfig(item); err != nil { + return nil, err + } + return item, nil +} + +func summarizeScanJobItem(item models.SecurityScanJobItem, findings []SkillFindingPayload) *string { + if summary := summarizeRiskReason(topRiskFindings(findings, 2)); summary != nil { + return summary + } + if item.ErrorMessage != nil && strings.TrimSpace(*item.ErrorMessage) != "" { + message := strings.TrimSpace(*item.ErrorMessage) + return &message + } + if item.RiskLevel != nil { + switch strings.ToLower(strings.TrimSpace(*item.RiskLevel)) { + case "none": + text := "未发现风险项" + return &text + case "low": + text := "发现低风险提示,建议查看逐项结果" + return &text + case "medium": + text := "发现中风险问题,建议尽快整改" + return &text + case "high": + text := "发现高风险问题,建议立即处置" + return &text + } + } + if item.Status == "completed" { + text := "扫描完成,未返回详细发现" + return &text + } + if item.Summary != nil && strings.TrimSpace(*item.Summary) != "" { + text := strings.TrimSpace(*item.Summary) + return &text + } + return nil +} + +func (s *securityScanService) toConfigPayload(item models.SecurityScanConfig) (*SecurityScanConfigPayload, error) { + var quickAnalyzers []string + var deepAnalyzers []string + if strings.TrimSpace(item.QuickAnalyzersJSON) != "" { + if err := json.Unmarshal([]byte(item.QuickAnalyzersJSON), &quickAnalyzers); err != nil { + return nil, fmt.Errorf("failed to decode quick analyzers: %w", err) + } + } + if strings.TrimSpace(item.DeepAnalyzersJSON) != "" { + if err := json.Unmarshal([]byte(item.DeepAnalyzersJSON), &deepAnalyzers); err != nil { + return nil, fmt.Errorf("failed to decode deep analyzers: %w", err) + } + } + return &SecurityScanConfigPayload{ + ActiveMode: item.DefaultMode, + DefaultMode: item.DefaultMode, + QuickAnalyzers: normalizeAnalyzerList(quickAnalyzers, defaultQuickAnalyzers()), + DeepAnalyzers: normalizeAnalyzerList(deepAnalyzers, defaultDeepAnalyzers()), + QuickTimeoutSeconds: item.QuickTimeoutSeconds, + DeepTimeoutSeconds: item.DeepTimeoutSeconds, + AllowFallback: item.AllowFallback, + ScannerStatus: resolveSecurityScannerStatus(), + SkillScannerConfig: resolveSkillScannerRuntimeConfig(), + }, nil +} + +func resolveSecurityScannerStatus() SecurityScannerStatusPayload { + runtime := resolveSkillScannerRuntimeConfig() + enabled := strings.EqualFold(strings.TrimSpace(os.Getenv("SKILL_SCANNER_ENABLED")), "true") && + strings.TrimSpace(os.Getenv("SKILL_SCANNER_BASE_URL")) != "" + llmConfigured := strings.TrimSpace(runtime.LLMModel) != "" && strings.TrimSpace(runtime.LLMBaseURL) != "" && strings.TrimSpace(runtime.LLMAPIKey) != "" || + (strings.TrimSpace(runtime.MetaLLMModel) != "" && strings.TrimSpace(runtime.MetaLLMBaseURL) != "" && strings.TrimSpace(runtime.MetaLLMAPIKey) != "") + + status := SecurityScannerStatusPayload{ + Connected: enabled, + LLMEnabled: llmConfigured, + StatusLabel: "未启用", + AvailableCapabilities: []string{}, + } + if !enabled { + return status + } + status.StatusLabel = "静态扫描可用" + status.AvailableCapabilities = []string{"静态扫描"} + if llmConfigured { + status.StatusLabel = "静态 + LLM 扫描可用" + status.AvailableCapabilities = []string{"静态扫描", "LLM 扫描"} + } + return status +} + +func resolveSkillScannerRuntimeConfig() SkillScannerRuntimeConfigPayload { + cfg := SkillScannerRuntimeConfigPayload{ + Namespace: skillScannerNamespace(), + DeploymentName: skillScannerDeploymentName(), + } + if envs, err := loadSkillScannerDeploymentEnv(); err == nil { + cfg.LLMAPIKey = envs["SKILL_SCANNER_LLM_API_KEY"] + cfg.LLMModel = envs["SKILL_SCANNER_LLM_MODEL"] + cfg.LLMBaseURL = envs["SKILL_SCANNER_LLM_BASE_URL"] + cfg.MetaLLMAPIKey = envs["SKILL_SCANNER_META_LLM_API_KEY"] + cfg.MetaLLMModel = envs["SKILL_SCANNER_META_LLM_MODEL"] + cfg.MetaLLMBaseURL = envs["SKILL_SCANNER_META_LLM_BASE_URL"] + return cfg + } + cfg.LLMAPIKey = os.Getenv("SKILL_SCANNER_LLM_API_KEY") + cfg.LLMModel = os.Getenv("SKILL_SCANNER_LLM_MODEL") + cfg.LLMBaseURL = os.Getenv("SKILL_SCANNER_LLM_BASE_URL") + cfg.MetaLLMAPIKey = os.Getenv("SKILL_SCANNER_META_LLM_API_KEY") + cfg.MetaLLMModel = os.Getenv("SKILL_SCANNER_META_LLM_MODEL") + cfg.MetaLLMBaseURL = os.Getenv("SKILL_SCANNER_META_LLM_BASE_URL") + return cfg +} + +func (s *securityScanService) applySkillScannerConfig(req SkillScannerRuntimeConfigPayload) error { + client := k8s.GetClient() + if client == nil || client.Clientset == nil { + return fmt.Errorf("kubernetes client is unavailable, cannot update skill-scanner config") + } + namespace := skillScannerNamespace() + deploymentName := skillScannerDeploymentName() + deployment, err := client.Clientset.AppsV1().Deployments(namespace).Get(context.Background(), deploymentName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to load skill-scanner deployment: %w", err) + } + if len(deployment.Spec.Template.Spec.Containers) == 0 { + return fmt.Errorf("skill-scanner deployment has no containers") + } + container := &deployment.Spec.Template.Spec.Containers[0] + upsertEnvVar(container, "SKILL_SCANNER_LLM_API_KEY", strings.TrimSpace(req.LLMAPIKey)) + upsertEnvVar(container, "SKILL_SCANNER_LLM_MODEL", strings.TrimSpace(req.LLMModel)) + upsertEnvVar(container, "SKILL_SCANNER_LLM_BASE_URL", strings.TrimSpace(req.LLMBaseURL)) + upsertEnvVar(container, "SKILL_SCANNER_META_LLM_API_KEY", strings.TrimSpace(req.MetaLLMAPIKey)) + upsertEnvVar(container, "SKILL_SCANNER_META_LLM_MODEL", strings.TrimSpace(req.MetaLLMModel)) + upsertEnvVar(container, "SKILL_SCANNER_META_LLM_BASE_URL", strings.TrimSpace(req.MetaLLMBaseURL)) + + updated, err := client.Clientset.AppsV1().Deployments(namespace).Update(context.Background(), deployment, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update skill-scanner deployment: %w", err) + } + return waitForSkillScannerRollout(context.Background(), client.Clientset, namespace, deploymentName, updated.Generation, 90*time.Second) +} + +func loadSkillScannerDeploymentEnv() (map[string]string, error) { + client := k8s.GetClient() + if client == nil || client.Clientset == nil { + return nil, fmt.Errorf("kubernetes client is unavailable") + } + deployment, err := client.Clientset.AppsV1().Deployments(skillScannerNamespace()).Get(context.Background(), skillScannerDeploymentName(), metav1.GetOptions{}) + if err != nil { + return nil, err + } + if len(deployment.Spec.Template.Spec.Containers) == 0 { + return nil, fmt.Errorf("skill-scanner deployment has no containers") + } + envs := map[string]string{} + for _, env := range deployment.Spec.Template.Spec.Containers[0].Env { + envs[env.Name] = env.Value + } + return envs, nil +} + +func upsertEnvVar(container *corev1.Container, name, value string) { + for i := range container.Env { + if container.Env[i].Name == name { + container.Env[i].Value = value + container.Env[i].ValueFrom = nil + return + } + } + container.Env = append(container.Env, corev1.EnvVar{Name: name, Value: value}) +} + +func waitForSkillScannerRollout(ctx context.Context, clientset *kubernetes.Clientset, namespace, deploymentName string, generation int64, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + deployment, err := clientset.AppsV1().Deployments(namespace).Get(ctx, deploymentName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to observe skill-scanner rollout: %w", err) + } + replicas := int32(1) + if deployment.Spec.Replicas != nil { + replicas = *deployment.Spec.Replicas + } + if deployment.Status.ObservedGeneration >= generation && + deployment.Status.UpdatedReplicas == replicas && + deployment.Status.AvailableReplicas == replicas && + deployment.Status.UnavailableReplicas == 0 { + return nil + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("skill-scanner rollout timed out") +} + +func skillScannerNamespace() string { + if value := strings.TrimSpace(os.Getenv("SKILL_SCANNER_NAMESPACE")); value != "" { + return value + } + if value := strings.TrimSpace(readInClusterNamespace()); value != "" { + return value + } + return "clawmanager-system" +} + +func skillScannerDeploymentName() string { + if value := strings.TrimSpace(os.Getenv("SKILL_SCANNER_DEPLOYMENT")); value != "" { + return value + } + return "skill-scanner" +} + +func readInClusterNamespace() string { + data, err := os.ReadFile(filepath.Clean("/var/run/secrets/kubernetes.io/serviceaccount/namespace")) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func (s *securityScanService) toJobPayload(item models.SecurityScanJob, includeDetails bool) (*SecurityScanJobPayload, error) { + payload := &SecurityScanJobPayload{ + ID: item.ID, + AssetType: item.AssetType, + ScanMode: item.ScanMode, + ScanScope: parseSecurityScanScope(item.ScopeJSON).ScanScope, + Status: item.Status, + RequestedBy: item.RequestedBy, + TotalItems: item.TotalItems, + CompletedItems: item.CompletedItems, + FailedItems: item.FailedItems, + CurrentItemName: item.CurrentItemName, + ProgressPct: computeProgress(item.TotalItems, item.CompletedItems), + StartedAt: item.StartedAt, + FinishedAt: item.FinishedAt, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } + if !includeDetails { + return payload, nil + } + items, err := s.repo.ListJobItems(item.ID) + if err != nil { + return nil, err + } + payload.Items = make([]SecurityScanJobItemPayload, 0, len(items)) + for _, entry := range items { + payload.Items = append(payload.Items, toSecurityScanJobItemPayload(entry)) + } + report, err := s.repo.GetReportByJobID(item.ID) + if err != nil { + return nil, err + } + if report != nil && strings.TrimSpace(report.SummaryJSON) != "" { + var reportPayload SecurityScanReportPayload + if err := json.Unmarshal([]byte(report.SummaryJSON), &reportPayload); err == nil { + payload.Report = &reportPayload + } + } + return payload, nil +} + +func toSecurityScanJobItemPayload(item models.SecurityScanJobItem) SecurityScanJobItemPayload { + return SecurityScanJobItemPayload{ + ID: item.ID, + AssetType: item.AssetType, + AssetID: item.AssetID, + AssetName: item.AssetName, + Status: item.Status, + ProgressPct: item.ProgressPct, + RiskLevel: item.RiskLevel, + Summary: item.Summary, + ScanResultID: item.ScanResultID, + CachedResult: item.CachedResult, + TriggeredAnalyzers: []string{}, + Findings: []SkillFindingPayload{}, + ErrorMessage: item.ErrorMessage, + StartedAt: item.StartedAt, + FinishedAt: item.FinishedAt, + } +} + +func extractTriggeredAnalyzers(result *models.SkillScanResult) []string { + if result == nil || result.FindingsJSON == nil || strings.TrimSpace(*result.FindingsJSON) == "" { + return []string{} + } + var raw struct { + Findings []struct { + Analyzer string `json:"analyzer"` + } `json:"findings"` + } + if err := json.Unmarshal([]byte(*result.FindingsJSON), &raw); err != nil { + return []string{} + } + seen := map[string]struct{}{} + resultList := make([]string, 0, len(raw.Findings)) + for _, item := range raw.Findings { + analyzer := strings.TrimSpace(strings.ToLower(item.Analyzer)) + if analyzer == "" { + continue + } + if _, ok := seen[analyzer]; ok { + continue + } + seen[analyzer] = struct{}{} + resultList = append(resultList, analyzer) + } + return resultList +} + +func sortedAnalyzerKeys(values map[string]struct{}) []string { + if len(values) == 0 { + return []string{} + } + result := make([]string, 0, len(values)) + for value := range values { + result = append(result, value) + } + slices.Sort(result) + return result +} + +func defaultQuickAnalyzers() []string { + return []string{"static", "behavioral", "trigger"} +} + +func defaultDeepAnalyzers() []string { + return []string{"static", "bytecode", "pipeline", "behavioral", "trigger", "llm", "meta"} +} + +func normalizeAnalyzerList(values []string, fallback []string) []string { + if len(values) == 0 { + return append([]string{}, fallback...) + } + allowed := map[string]struct{}{ + "static": {}, + "bytecode": {}, + "pipeline": {}, + "behavioral": {}, + "trigger": {}, + "llm": {}, + "meta": {}, + } + seen := map[string]struct{}{} + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + continue + } + if _, ok := allowed[value]; !ok { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + if len(result) == 0 { + return append([]string{}, fallback...) + } + return result +} + +func normalizeScanMode(value string) string { + if strings.EqualFold(strings.TrimSpace(value), "deep") { + return "deep" + } + return "quick" +} + +func normalizeScanScope(value string) string { + if strings.EqualFold(strings.TrimSpace(value), "full") { + return "full" + } + return "incremental" +} + +func parseSecurityScanScope(raw *string) securityScanScope { + scope := securityScanScope{ScanScope: "incremental"} + if raw == nil || strings.TrimSpace(*raw) == "" { + return scope + } + if err := json.Unmarshal([]byte(*raw), &scope); err != nil { + return securityScanScope{ScanScope: "incremental"} + } + scope.ScanScope = normalizeScanScope(scope.ScanScope) + return scope +} + +func normalizeTimeout(value, fallback int) int { + if value <= 0 { + return fallback + } + return value +} + +func computeProgress(total, completed int) int { + if total <= 0 { + return 0 + } + if completed >= total { + return 100 + } + return int(float64(completed) / float64(total) * 100) +} diff --git a/backend/internal/services/skill_scanner_client.go b/backend/internal/services/skill_scanner_client.go new file mode 100644 index 0000000..0b3c0c3 --- /dev/null +++ b/backend/internal/services/skill_scanner_client.go @@ -0,0 +1,240 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/url" + "strings" + "time" + + "clawreef/internal/config" +) + +type SkillScannerClient interface { + ScanArchive(ctx context.Context, fileName string, content []byte, options map[string]string) (string, map[string]interface{}, string, error) + AvailableAnalyzers(ctx context.Context) ([]string, error) +} + +type noopSkillScannerClient struct{} + +func (n *noopSkillScannerClient) ScanArchive(ctx context.Context, fileName string, content []byte, options map[string]string) (string, map[string]interface{}, string, error) { + return "", nil, "", fmt.Errorf("skill scanner is disabled") +} + +func (n *noopSkillScannerClient) AvailableAnalyzers(ctx context.Context) ([]string, error) { + return nil, fmt.Errorf("skill scanner is disabled") +} + +type httpSkillScannerClient struct { + baseURL string + apiKey string + client *http.Client +} + +func NewSkillScannerClient(cfg config.SkillScannerConfig) SkillScannerClient { + if !cfg.Enabled || strings.TrimSpace(cfg.BaseURL) == "" { + return &noopSkillScannerClient{} + } + timeout := time.Duration(cfg.TimeoutSeconds) * time.Second + if timeout <= 0 { + timeout = 30 * time.Second + } + return &httpSkillScannerClient{ + baseURL: strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"), + apiKey: strings.TrimSpace(cfg.APIKey), + client: &http.Client{Timeout: timeout}, + } +} + +func (c *httpSkillScannerClient) ScanArchive(ctx context.Context, fileName string, content []byte, options map[string]string) (string, map[string]interface{}, string, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", fileName) + if err != nil { + return "", nil, "", fmt.Errorf("failed to create skill scanner upload: %w", err) + } + if _, err := part.Write(content); err != nil { + return "", nil, "", fmt.Errorf("failed to write skill scanner upload: %w", err) + } + _ = writer.WriteField("format", "json") + if err := writer.Close(); err != nil { + return "", nil, "", fmt.Errorf("failed to finalize skill scanner upload: %w", err) + } + + endpoint, err := url.Parse(c.baseURL + "/scan-upload") + if err != nil { + return "", nil, "", fmt.Errorf("failed to build skill scanner url: %w", err) + } + query := endpoint.Query() + applyScanUploadOptions(query, options) + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), &body) + if err != nil { + return "", nil, "", fmt.Errorf("failed to create skill scanner request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Accept", "application/json") + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + + resp, err := c.client.Do(req) + if err != nil { + return "", nil, "", fmt.Errorf("skill scanner request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", nil, "", fmt.Errorf("skill scanner returned status %d", resp.StatusCode) + } + + var raw map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return "", nil, "", fmt.Errorf("failed to decode skill scanner response: %w", err) + } + riskLevel := normalizeScannerRiskLevel(raw) + summary := extractScannerSummary(raw) + return riskLevel, raw, summary, nil +} + +func (c *httpSkillScannerClient) AvailableAnalyzers(ctx context.Context) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil) + if err != nil { + return nil, fmt.Errorf("failed to create skill scanner health request: %w", err) + } + req.Header.Set("Accept", "application/json") + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("skill scanner health request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("skill scanner health returned status %d", resp.StatusCode) + } + + var raw struct { + Analyzers []string `json:"analyzers_available"` + } + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("failed to decode skill scanner health response: %w", err) + } + result := make([]string, 0, len(raw.Analyzers)) + for _, item := range raw.Analyzers { + item = strings.TrimSpace(item) + if item == "" { + continue + } + item = strings.TrimSuffix(item, "_analyzer") + result = append(result, item) + } + return result, nil +} + +func applyScanUploadOptions(query url.Values, options map[string]string) { + analyzers := splitAnalyzerOption(options["analyzers"]) + if hasAnalyzer(analyzers, "behavioral") { + query.Set("use_behavioral", "true") + } + if hasAnalyzer(analyzers, "llm") || hasAnalyzer(analyzers, "meta") { + query.Set("use_llm", "true") + query.Set("llm_provider", "openai") + } +} + +func splitAnalyzerOption(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + items := strings.Split(value, ",") + result := make([]string, 0, len(items)) + for _, item := range items { + item = strings.ToLower(strings.TrimSpace(item)) + if item == "" { + continue + } + result = append(result, item) + } + return result +} + +func hasAnalyzer(items []string, target string) bool { + target = strings.ToLower(strings.TrimSpace(target)) + for _, item := range items { + if item == target { + return true + } + } + return false +} + +func normalizeScannerRiskLevel(raw map[string]interface{}) string { + if safe, ok := readBool(raw["is_safe"]); ok && safe { + return skillRiskNone + } + candidates := []string{ + readString(raw["risk_level"]), + readString(raw["severity"]), + readString(raw["verdict"]), + readString(raw["max_severity"]), + } + if result, ok := raw["result"].(map[string]interface{}); ok { + if safe, ok := readBool(result["is_safe"]); ok && safe { + return skillRiskNone + } + candidates = append(candidates, + readString(result["risk_level"]), + readString(result["severity"]), + readString(result["verdict"]), + readString(result["max_severity"]), + ) + } + for _, value := range candidates { + switch strings.ToLower(strings.TrimSpace(value)) { + case "critical", "high": + return skillRiskHigh + case "medium", "moderate": + return skillRiskMedium + case "low", "warning": + return skillRiskLow + case "none", "clean", "safe", "pass", "info", "informational": + return skillRiskNone + } + } + return skillRiskUnknown +} + +func extractScannerSummary(raw map[string]interface{}) string { + candidates := []string{ + readString(raw["summary"]), + readString(raw["message"]), + } + if result, ok := raw["result"].(map[string]interface{}); ok { + candidates = append(candidates, readString(result["summary"]), readString(result["message"])) + } + for _, candidate := range candidates { + if strings.TrimSpace(candidate) != "" { + return candidate + } + } + return "Skill scanned by external skill-scanner service" +} + +func readString(value interface{}) string { + if text, ok := value.(string); ok { + return text + } + return "" +} + +func readBool(value interface{}) (bool, bool) { + boolean, ok := value.(bool) + return boolean, ok +} diff --git a/backend/internal/services/skill_service.go b/backend/internal/services/skill_service.go new file mode 100644 index 0000000..d1cf550 --- /dev/null +++ b/backend/internal/services/skill_service.go @@ -0,0 +1,1475 @@ +package services + +import ( + "archive/zip" + "bytes" + "context" + "crypto/md5" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "path" + "sort" + "strings" + "time" + + "clawreef/internal/models" + "clawreef/internal/repository" +) + +const ( + skillRiskUnknown = "unknown" + skillRiskNone = "none" + skillRiskLow = "low" + skillRiskMedium = "medium" + skillRiskHigh = "high" + + skillSourceUploaded = "uploaded" + skillSourceDiscovered = "discovered" +) + +type SkillPayload struct { + ID int `json:"id"` + ExternalSkillID string `json:"external_skill_id"` + UserID int `json:"user_id"` + SkillKey string `json:"skill_key"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Status string `json:"status"` + SourceType string `json:"source_type"` + RiskLevel string `json:"risk_level"` + ScanStatus string `json:"scan_status"` + LastScannedAt *time.Time `json:"last_scanned_at,omitempty"` + CurrentVersionID *int `json:"current_version_id,omitempty"` + CurrentVersionNo *int `json:"current_version_no,omitempty"` + ContentHash *string `json:"content_hash,omitempty"` + ContentMD5 *string `json:"content_md5,omitempty"` + ArchiveHash *string `json:"archive_hash,omitempty"` + RiskReason *string `json:"risk_reason,omitempty"` + TopFindings []SkillFindingPayload `json:"top_findings,omitempty"` + InstanceCount int `json:"instance_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SkillFindingPayload struct { + Analyzer string `json:"analyzer"` + Severity string `json:"severity"` + Category string `json:"category"` + RuleID string `json:"rule_id"` + Title string `json:"title"` + Description string `json:"description"` + FilePath *string `json:"file_path,omitempty"` + LineNumber *int `json:"line_number,omitempty"` + Remediation string `json:"remediation"` + Snippet *string `json:"snippet,omitempty"` +} + +type SkillVersionPayload struct { + ID int `json:"id"` + ExternalVersionID string `json:"external_version_id"` + SkillID int `json:"skill_id"` + BlobID int `json:"blob_id"` + VersionNo int `json:"version_no"` + SourceType string `json:"source_type"` + ContentHash string `json:"content_hash"` + ContentMD5 string `json:"content_md5"` + ArchiveHash string `json:"archive_hash"` + ObjectKey string `json:"object_key"` + FileName string `json:"file_name"` + RiskLevel string `json:"risk_level"` + CreatedAt time.Time `json:"created_at"` +} + +type InstanceSkillPayload struct { + ID int `json:"id"` + InstanceID int `json:"instance_id"` + SkillID int `json:"skill_id"` + SkillVersionID *int `json:"skill_version_id,omitempty"` + SourceType string `json:"source_type"` + InstallPath *string `json:"install_path,omitempty"` + ObservedHash *string `json:"observed_hash,omitempty"` + ContentMD5 *string `json:"content_md5,omitempty"` + Status string `json:"status"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + RemovedAt *time.Time `json:"removed_at,omitempty"` + Skill *SkillPayload `json:"skill,omitempty"` +} + +type SkillScanResultPayload struct { + ID int `json:"id"` + BlobID int `json:"blob_id"` + Engine string `json:"engine"` + RiskLevel string `json:"risk_level"` + Status string `json:"status"` + Summary *string `json:"summary,omitempty"` + Findings map[string]interface{} `json:"findings,omitempty"` + ParsedFindings []SkillFindingPayload `json:"parsed_findings,omitempty"` + ScannedAt *time.Time `json:"scanned_at,omitempty"` +} + +type UpdateSkillRequest struct { + Name string `json:"name"` + Description *string `json:"description"` + Status string `json:"status"` +} + +type AttachSkillToInstanceRequest struct { + SkillID int `json:"skill_id" binding:"required,min=1"` +} + +type AgentSkillRecord struct { + SkillID string `json:"skill_id"` + SkillVersion string `json:"skill_version"` + Identifier string `json:"identifier" binding:"required"` + InstallPath string `json:"install_path"` + ContentMD5 string `json:"content_md5" binding:"required"` + Source string `json:"source"` + Type string `json:"type"` + SizeBytes int64 `json:"size_bytes"` + FileCount int `json:"file_count"` + CollectedAt *time.Time `json:"collected_at,omitempty"` + Metadata map[string]interface{} `json:"metadata"` +} + +type AgentSkillInventoryReportRequest struct { + AgentID string `json:"agent_id" binding:"required"` + ReportedAt *time.Time `json:"reported_at,omitempty"` + Mode string `json:"mode"` + Trigger string `json:"trigger"` + Skills []AgentSkillRecord `json:"skills" binding:"required"` +} + +type AgentSkillPackageUploadRequest struct { + AgentID string `json:"agent_id"` + SkillID string `json:"skill_id"` + SkillVersion string `json:"skill_version"` + Identifier string `json:"identifier"` + ContentMD5 string `json:"content_md5"` + Source string `json:"source"` +} + +type SkillService interface { + ImportArchive(ctx context.Context, userID int, fileHeader *multipart.FileHeader) ([]SkillPayload, error) + ListSkills(userID int) ([]SkillPayload, error) + ListAllSkills() ([]SkillPayload, error) + GetSkill(userID, skillID int) (*SkillPayload, error) + UpdateSkill(userID, skillID int, req UpdateSkillRequest) (*SkillPayload, error) + DeleteSkill(userID, skillID int) error + DownloadSkill(userID, skillID int) ([]byte, string, error) + DownloadSkillVersionByExternalID(externalVersionID string) ([]byte, string, error) + ListVersions(userID, skillID int) ([]SkillVersionPayload, error) + ListInstanceSkills(instanceID int) ([]InstanceSkillPayload, error) + AttachSkillToInstance(instanceID int, skillID int) (*InstanceSkillPayload, error) + RemoveSkillFromInstance(instanceID int, skillID int) error + SyncAgentSkills(instanceID int, req AgentSkillInventoryReportRequest) error + UploadAgentSkillPackage(ctx context.Context, instanceID int, req AgentSkillPackageUploadRequest, fileHeader *multipart.FileHeader) (*SkillPayload, error) + ListScanResults(userID, skillID int) ([]SkillScanResultPayload, error) +} + +type skillService struct { + repo repository.SkillRepository + instanceRepo repository.InstanceRepository + commandService InstanceCommandService + storage ObjectStorageService + scanner SkillScannerClient +} + +func NewSkillService(repo repository.SkillRepository, instanceRepo repository.InstanceRepository, commandService InstanceCommandService, storage ObjectStorageService, scanner SkillScannerClient) SkillService { + return &skillService{repo: repo, instanceRepo: instanceRepo, commandService: commandService, storage: storage, scanner: scanner} +} + +func (s *skillService) ImportArchive(ctx context.Context, userID int, fileHeader *multipart.FileHeader) ([]SkillPayload, error) { + if !strings.HasSuffix(strings.ToLower(strings.TrimSpace(fileHeader.Filename)), ".zip") { + return nil, fmt.Errorf("only .zip skill archives are supported") + } + file, err := fileHeader.Open() + if err != nil { + return nil, fmt.Errorf("failed to open uploaded archive: %w", err) + } + defer file.Close() + + raw, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("failed to read uploaded archive: %w", err) + } + directories, err := extractSkillDirectories(fileHeader.Filename, raw) + if err != nil { + return nil, err + } + if len(directories) == 0 { + return nil, fmt.Errorf("no skill directories found in archive") + } + + results := make([]SkillPayload, 0, len(directories)) + for _, dir := range directories { + payload, err := s.importDirectory(ctx, userID, dir, fileHeader.Filename) + if err != nil { + return nil, err + } + results = append(results, *payload) + } + return results, nil +} + +func (s *skillService) ListSkills(userID int) ([]SkillPayload, error) { + items, err := s.repo.ListSkillsByUser(userID) + if err != nil { + return nil, err + } + filtered := make([]models.Skill, 0, len(items)) + for _, item := range items { + if isUserManagedSkill(item) { + filtered = append(filtered, item) + } + } + return s.toSkillPayloads(filtered) +} + +func (s *skillService) ListAllSkills() ([]SkillPayload, error) { + items, err := s.repo.ListAllSkills() + if err != nil { + return nil, err + } + return s.toSkillPayloads(items) +} + +func (s *skillService) GetSkill(userID, skillID int) (*SkillPayload, error) { + item, err := s.repo.GetSkillByID(skillID) + if err != nil { + return nil, err + } + if item == nil || item.UserID != userID { + return nil, fmt.Errorf("skill not found") + } + if !isUserManagedSkill(*item) { + return nil, fmt.Errorf("skill not found") + } + return s.toSkillPayload(*item) +} + +func (s *skillService) UpdateSkill(userID, skillID int, req UpdateSkillRequest) (*SkillPayload, error) { + item, err := s.repo.GetSkillByID(skillID) + if err != nil { + return nil, err + } + if item == nil || item.UserID != userID { + return nil, fmt.Errorf("skill not found") + } + if !isUserManagedSkill(*item) { + return nil, fmt.Errorf("skill not found") + } + item.Name = strings.TrimSpace(req.Name) + item.Description = req.Description + if status := strings.TrimSpace(req.Status); status != "" { + item.Status = status + } + item.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateSkill(item); err != nil { + return nil, err + } + return s.toSkillPayload(*item) +} + +func (s *skillService) DeleteSkill(userID, skillID int) error { + item, err := s.repo.GetSkillByID(skillID) + if err != nil { + return err + } + if item == nil || item.UserID != userID { + return fmt.Errorf("skill not found") + } + if !isUserManagedSkill(*item) { + return fmt.Errorf("skill not found") + } + return s.repo.DeleteSkill(skillID) +} + +func (s *skillService) DownloadSkill(userID, skillID int) ([]byte, string, error) { + item, err := s.repo.GetSkillByID(skillID) + if err != nil { + return nil, "", err + } + if item == nil || item.UserID != userID { + return nil, "", fmt.Errorf("skill not found") + } + if !isUserManagedSkill(*item) { + return nil, "", fmt.Errorf("skill not found") + } + if item.CurrentVersionID == nil { + return nil, "", fmt.Errorf("skill has no version") + } + version, err := s.repo.GetVersionByID(*item.CurrentVersionID) + if err != nil { + return nil, "", err + } + blob, err := s.repo.GetBlobByID(version.BlobID) + if err != nil { + return nil, "", err + } + content, err := s.storage.GetObject(context.Background(), blob.ObjectKey) + if err != nil { + return nil, "", err + } + return content, blob.FileName, nil +} + +func (s *skillService) DownloadSkillVersionByExternalID(externalVersionID string) ([]byte, string, error) { + versionID, err := parseExternalVersionID(externalVersionID) + if err != nil { + return nil, "", err + } + version, err := s.repo.GetVersionByID(versionID) + if err != nil { + return nil, "", err + } + if version == nil { + return nil, "", fmt.Errorf("skill version not found") + } + blob, err := s.repo.GetBlobByID(version.BlobID) + if err != nil { + return nil, "", err + } + if blob == nil { + return nil, "", fmt.Errorf("skill blob not found") + } + content, err := s.storage.GetObject(context.Background(), blob.ObjectKey) + if err != nil { + return nil, "", err + } + return content, blob.FileName, nil +} + +func (s *skillService) ListVersions(userID, skillID int) ([]SkillVersionPayload, error) { + skill, err := s.repo.GetSkillByID(skillID) + if err != nil { + return nil, err + } + if skill == nil || skill.UserID != userID { + return nil, fmt.Errorf("skill not found") + } + if !isUserManagedSkill(*skill) { + return nil, fmt.Errorf("skill not found") + } + items, err := s.repo.ListVersionsBySkillID(skillID) + if err != nil { + return nil, err + } + result := make([]SkillVersionPayload, 0, len(items)) + for _, item := range items { + blob, err := s.repo.GetBlobByID(item.BlobID) + if err != nil { + return nil, err + } + result = append(result, SkillVersionPayload{ + ID: item.ID, ExternalVersionID: formatExternalVersionID(item.ID), SkillID: item.SkillID, BlobID: item.BlobID, VersionNo: item.VersionNo, + SourceType: item.SourceType, ContentHash: blob.ContentHash, ContentMD5: s.resolveContentMD5(blob), ArchiveHash: blob.ArchiveHash, + ObjectKey: blob.ObjectKey, FileName: blob.FileName, RiskLevel: blob.RiskLevel, CreatedAt: item.CreatedAt, + }) + } + return result, nil +} + +func (s *skillService) ListInstanceSkills(instanceID int) ([]InstanceSkillPayload, error) { + items, err := s.repo.ListInstanceSkills(instanceID) + if err != nil { + return nil, err + } + result := make([]InstanceSkillPayload, 0, len(items)) + for _, item := range items { + payload := InstanceSkillPayload{ + ID: item.ID, InstanceID: item.InstanceID, SkillID: item.SkillID, SkillVersionID: item.SkillVersionID, + SourceType: item.SourceType, InstallPath: item.InstallPath, ObservedHash: item.ObservedHash, + Status: item.Status, LastSeenAt: item.LastSeenAt, RemovedAt: item.RemovedAt, + } + skill, err := s.repo.GetSkillByID(item.SkillID) + if err != nil { + return nil, err + } + if skill != nil { + skillPayload, err := s.toSkillPayload(*skill) + if err != nil { + return nil, err + } + payload.Skill = skillPayload + } + result = append(result, payload) + } + return result, nil +} + +func (s *skillService) AttachSkillToInstance(instanceID int, skillID int) (*InstanceSkillPayload, error) { + skill, err := s.repo.GetSkillByID(skillID) + if err != nil { + return nil, err + } + if skill == nil { + return nil, fmt.Errorf("skill not found") + } + if !isUserManagedSkill(*skill) { + return nil, fmt.Errorf("skill not found") + } + if skill.Status != "active" { + return nil, fmt.Errorf("skill is not active") + } + if skill.RiskLevel == skillRiskMedium || skill.RiskLevel == skillRiskHigh { + return nil, fmt.Errorf("skill is blocked by risk policy") + } + versionID := skill.CurrentVersionID + now := time.Now().UTC() + item := &models.InstanceSkill{ + InstanceID: instanceID, SkillID: skillID, SkillVersionID: versionID, + SourceType: "injected_by_clawmanager", Status: "active", LastSeenAt: &now, UpdatedAt: now, + } + if err := s.repo.UpsertInstanceSkill(item); err != nil { + return nil, err + } + if versionID != nil { + version, err := s.repo.GetVersionByID(*versionID) + if err != nil { + return nil, err + } + blob, err := s.repo.GetBlobByID(version.BlobID) + if err != nil { + return nil, err + } + _, _ = s.commandService.Create(instanceID, nil, CreateInstanceCommandRequest{ + CommandType: InstanceCommandTypeInstallSkill, + Payload: map[string]interface{}{ + "skill_id": formatExternalSkillID(skillID), + "skill_version": formatExternalVersionID(*versionID), + "target_name": skill.SkillKey, + "content_md5": s.resolveContentMD5(blob), + }, + IdempotencyKey: fmt.Sprintf("install-skill-%d-%d", instanceID, skillID), + TimeoutSeconds: 300, + }) + } + items, err := s.ListInstanceSkills(instanceID) + if err != nil { + return nil, err + } + for _, candidate := range items { + if candidate.SkillID == skillID { + return &candidate, nil + } + } + return nil, fmt.Errorf("instance skill not found after attach") +} + +func (s *skillService) RemoveSkillFromInstance(instanceID int, skillID int) error { + item, err := s.repo.GetInstanceSkill(instanceID, skillID) + if err != nil { + return err + } + if item == nil { + return nil + } + now := time.Now().UTC() + item.Status = "removed" + item.RemovedAt = &now + item.UpdatedAt = now + if err := s.repo.UpsertInstanceSkill(item); err != nil { + return err + } + _, _ = s.commandService.Create(instanceID, nil, CreateInstanceCommandRequest{ + CommandType: InstanceCommandTypeUninstallSkill, + Payload: map[string]interface{}{"target_name": skillKeyForRemoval(item)}, + IdempotencyKey: fmt.Sprintf("remove-skill-%d-%d", instanceID, skillID), + TimeoutSeconds: 300, + }) + return nil +} + +func (s *skillService) SyncAgentSkills(instanceID int, req AgentSkillInventoryReportRequest) error { + instance, err := s.instanceRepo.GetByID(instanceID) + if err != nil { + return err + } + if instance == nil { + return fmt.Errorf("instance not found") + } + ownerUserID := instance.UserID + + reportedAt := time.Now().UTC() + if req.ReportedAt != nil && !req.ReportedAt.IsZero() { + reportedAt = req.ReportedAt.UTC() + } + active := make([]int, 0, len(req.Skills)) + for _, record := range req.Skills { + hash := strings.TrimSpace(record.ContentMD5) + if hash == "" { + continue + } + normalizedSource := normalizeSkillSource(record.Source) + var skill *models.Skill + var version *models.SkillVersion + + if normalizedSource == "injected_by_clawmanager" { + if skillID, err := parseExternalSkillID(record.SkillID); err == nil { + item, err := s.repo.GetSkillByID(skillID) + if err != nil { + return err + } + if item != nil && item.UserID == ownerUserID { + skill = item + } + } + } + + skillKey := sanitizeSkillKey(record.Identifier) + if skillKey == "" { + skillKey = hash[:skillMin(16, len(hash))] + } + if skill == nil { + item, err := s.repo.GetSkillByUserKey(ownerUserID, skillKey) + if err != nil { + return err + } + if item != nil && (normalizedSource != "discovered_in_instance" || strings.EqualFold(item.SourceType, skillSourceDiscovered)) { + skill = item + } + } + + blob, err := s.repo.GetBlobByContentHash(hash) + if err != nil { + return err + } + if blob == nil && skill != nil { + version, blob, err = s.findVersionByContentMD5(skill.ID, hash) + if err != nil { + return err + } + } + if blob == nil { + blob = &models.SkillBlob{ + ContentHash: hash, + ArchiveHash: hash, + ObjectKey: "", + FileName: sanitizeSkillKey(record.Identifier) + ".zip", + MediaType: "application/zip", + SizeBytes: 0, + ScanStatus: "pending", + RiskLevel: skillRiskUnknown, + } + if err := s.repo.CreateBlob(blob); err != nil { + return err + } + } + if strings.TrimSpace(blob.ObjectKey) == "" { + _, _ = s.commandService.Create(instanceID, nil, CreateInstanceCommandRequest{ + CommandType: InstanceCommandTypeCollectSkillPackage, + Payload: map[string]interface{}{ + "skill_id": record.SkillID, + "skill_version": record.SkillVersion, + "identifier": record.Identifier, + "content_md5": hash, + "source": normalizedSource, + }, + IdempotencyKey: fmt.Sprintf("collect-skill-package-%d-%s", instanceID, hash), + TimeoutSeconds: 600, + }) + } + if skill == nil { + if normalizedSource == "discovered_in_instance" { + skillKey = s.nextDiscoveredSkillKey(ownerUserID, skillKey, hash) + } + skill = &models.Skill{ + UserID: ownerUserID, SkillKey: skillKey, Name: strings.TrimSpace(record.Identifier), + SourceType: skillSourceDiscovered, Status: "active", RiskLevel: blob.RiskLevel, + LastScannedAt: blob.LastScannedAt, LastScanResultID: blob.LastScanResultID, + } + if skill.Name == "" { + skill.Name = skillKey + } + if err := s.repo.CreateSkill(skill); err != nil { + return err + } + } + if version == nil { + version, err = s.repo.GetVersionBySkillAndBlob(skill.ID, blob.ID) + if err != nil { + return err + } + } + if version == nil && !(strings.EqualFold(skill.SourceType, skillSourceUploaded) && normalizedSource == "injected_by_clawmanager") { + latest, err := s.repo.GetLatestVersionBySkillID(skill.ID) + if err != nil { + return err + } + versionNo := 1 + if latest != nil { + versionNo = latest.VersionNo + 1 + } + version = &models.SkillVersion{SkillID: skill.ID, BlobID: blob.ID, VersionNo: versionNo, SourceType: skillSourceDiscovered} + if err := s.repo.CreateVersion(version); err != nil { + return err + } + } + if version != nil && !strings.EqualFold(skill.SourceType, skillSourceUploaded) { + skill.CurrentVersionID = &version.ID + skill.RiskLevel = blob.RiskLevel + skill.LastScannedAt = blob.LastScannedAt + skill.LastScanResultID = blob.LastScanResultID + if err := s.repo.UpdateSkill(skill); err != nil { + return err + } + } + active = append(active, skill.ID) + instanceSkill := &models.InstanceSkill{ + InstanceID: instanceID, SkillID: skill.ID, SkillVersionID: optionalVersionID(version), SourceType: normalizedSource, + InstallPath: optionalString(strings.TrimSpace(record.InstallPath)), ObservedHash: optionalString(hash), + Status: "active", LastSeenAt: &reportedAt, UpdatedAt: reportedAt, + } + if err := s.repo.UpsertInstanceSkill(instanceSkill); err != nil { + return err + } + } + if strings.EqualFold(strings.TrimSpace(req.Mode), "full") || !strings.EqualFold(strings.TrimSpace(req.Mode), "incremental") { + if err := s.repo.MarkMissingInstanceSkills(instanceID, active, reportedAt); err != nil { + return err + } + } + return nil +} + +func (s *skillService) UploadAgentSkillPackage(ctx context.Context, instanceID int, req AgentSkillPackageUploadRequest, fileHeader *multipart.FileHeader) (*SkillPayload, error) { + if !strings.HasSuffix(strings.ToLower(strings.TrimSpace(fileHeader.Filename)), ".zip") { + return nil, fmt.Errorf("only .zip skill archives are supported") + } + instance, err := s.instanceRepo.GetByID(instanceID) + if err != nil { + return nil, err + } + if instance == nil { + return nil, fmt.Errorf("instance not found") + } + file, err := fileHeader.Open() + if err != nil { + return nil, fmt.Errorf("failed to open uploaded skill package: %w", err) + } + defer file.Close() + + raw, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("failed to read uploaded skill package: %w", err) + } + directories, err := extractSkillDirectories(fileHeader.Filename, raw) + if err != nil { + return nil, err + } + if len(directories) != 1 { + return nil, fmt.Errorf("agent skill package must contain exactly one skill directory") + } + dir := directories[0] + contentMD5 := hashDirectory(dir.Files) + expectedMD5 := strings.TrimSpace(req.ContentMD5) + if expectedMD5 != "" && !strings.EqualFold(contentMD5, expectedMD5) { + return nil, fmt.Errorf("skill package md5 mismatch: expected %s got %s", expectedMD5, contentMD5) + } + + archiveBytes, archiveHash, err := buildNormalizedZip(dir) + if err != nil { + return nil, err + } + + blob, err := s.repo.GetBlobByContentHash(contentMD5) + if err != nil { + return nil, err + } + if blob == nil { + blob = &models.SkillBlob{ + ContentHash: contentMD5, + ArchiveHash: archiveHash, + ObjectKey: fmt.Sprintf("discovered/%d/%s/%s.zip", instanceID, sanitizeSkillKey(dir.Name), contentMD5), + FileName: fmt.Sprintf("%s.zip", sanitizeSkillKey(dir.Name)), + MediaType: "application/zip", + SizeBytes: int64(len(archiveBytes)), + ScanStatus: "pending", + RiskLevel: skillRiskUnknown, + } + if err := s.storage.PutObject(ctx, blob.ObjectKey, archiveBytes, blob.MediaType); err != nil { + return nil, err + } + if err := s.repo.CreateBlob(blob); err != nil { + return nil, err + } + } else if strings.TrimSpace(blob.ObjectKey) == "" { + blob.ObjectKey = fmt.Sprintf("discovered/%d/%s/%s.zip", instanceID, sanitizeSkillKey(dir.Name), contentMD5) + blob.FileName = fmt.Sprintf("%s.zip", sanitizeSkillKey(dir.Name)) + blob.MediaType = "application/zip" + blob.SizeBytes = int64(len(archiveBytes)) + if err := s.storage.PutObject(ctx, blob.ObjectKey, archiveBytes, blob.MediaType); err != nil { + return nil, err + } + if err := s.repo.UpdateBlob(blob); err != nil { + return nil, err + } + } + + if blob.LastScanResultID == nil || blob.ScanStatus != "completed" { + if err := s.recordScan(blob, &dir); err != nil { + blob.ScanStatus = "failed" + blob.UpdatedAt = time.Now().UTC() + _ = s.repo.UpdateBlob(blob) + return nil, err + } + } + + normalizedSource := normalizeSkillSource(req.Source) + skillKey := sanitizeSkillKey(req.Identifier) + if skillKey == "" { + skillKey = sanitizeSkillKey(dir.Name) + } + if skillKey == "" { + skillKey = contentMD5[:skillMin(16, len(contentMD5))] + } + + var skill *models.Skill + if skillID, err := parseExternalSkillID(req.SkillID); err == nil { + item, err := s.repo.GetSkillByID(skillID) + if err != nil { + return nil, err + } + if item != nil && item.UserID == instance.UserID { + skill = item + } + } + if skill == nil { + item, err := s.repo.GetSkillByUserKey(instance.UserID, skillKey) + if err != nil { + return nil, err + } + if item != nil { + skill = item + } + } + if skill == nil { + skill = &models.Skill{ + UserID: instance.UserID, SkillKey: skillKey, Name: strings.TrimSpace(req.Identifier), + SourceType: skillSourceDiscovered, Status: "active", RiskLevel: blob.RiskLevel, + LastScannedAt: blob.LastScannedAt, LastScanResultID: blob.LastScanResultID, + } + if strings.TrimSpace(skill.Name) == "" { + skill.Name = dir.Name + } + if err := s.repo.CreateSkill(skill); err != nil { + return nil, err + } + } + + version, err := s.repo.GetVersionBySkillAndBlob(skill.ID, blob.ID) + if err != nil { + return nil, err + } + if version == nil { + latest, err := s.repo.GetLatestVersionBySkillID(skill.ID) + if err != nil { + return nil, err + } + versionNo := 1 + if latest != nil { + versionNo = latest.VersionNo + 1 + } + version = &models.SkillVersion{SkillID: skill.ID, BlobID: blob.ID, VersionNo: versionNo, SourceType: skillSourceDiscovered} + if err := s.repo.CreateVersion(version); err != nil { + return nil, err + } + } + + skill.CurrentVersionID = &version.ID + skill.RiskLevel = blob.RiskLevel + skill.LastScannedAt = blob.LastScannedAt + skill.LastScanResultID = blob.LastScanResultID + skill.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateSkill(skill); err != nil { + return nil, err + } + + now := time.Now().UTC() + instanceSkill := &models.InstanceSkill{ + InstanceID: instanceID, + SkillID: skill.ID, + SkillVersionID: &version.ID, + SourceType: normalizedSource, + InstallPath: nil, + ObservedHash: optionalString(contentMD5), + Status: "active", + LastSeenAt: &now, + UpdatedAt: now, + } + if err := s.repo.UpsertInstanceSkill(instanceSkill); err != nil { + return nil, err + } + return s.toSkillPayload(*skill) +} + +func (s *skillService) ListScanResults(userID, skillID int) ([]SkillScanResultPayload, error) { + skill, err := s.repo.GetSkillByID(skillID) + if err != nil { + return nil, err + } + if skill == nil || (skill.UserID != userID && userID != 0) { + return nil, fmt.Errorf("skill not found") + } + if skill.CurrentVersionID == nil { + return nil, nil + } + version, err := s.repo.GetVersionByID(*skill.CurrentVersionID) + if err != nil { + return nil, err + } + items, err := s.repo.ListScanResultsByBlobID(version.BlobID) + if err != nil { + return nil, err + } + result := make([]SkillScanResultPayload, 0, len(items)) + for _, item := range items { + payload := SkillScanResultPayload{ + ID: item.ID, BlobID: item.BlobID, Engine: item.Engine, RiskLevel: item.RiskLevel, + Status: item.Status, Summary: item.Summary, ScannedAt: item.ScannedAt, + } + if item.FindingsJSON != nil && strings.TrimSpace(*item.FindingsJSON) != "" { + _ = json.Unmarshal([]byte(*item.FindingsJSON), &payload.Findings) + } + payload.ParsedFindings = parseSkillFindings(&item) + result = append(result, payload) + } + return result, nil +} + +type extractedSkillDirectory struct { + Name string + Files map[string][]byte +} + +func extractSkillDirectories(filename string, raw []byte) ([]extractedSkillDirectory, error) { + fileMap, err := extractArchiveFileMap(filename, raw) + if err != nil { + return nil, err + } + + grouped := map[string]map[string][]byte{} + for name, content := range fileMap { + clean := path.Clean(strings.TrimPrefix(name, "./")) + if clean == "." || strings.HasPrefix(clean, "..") { + continue + } + parts := strings.Split(clean, "/") + if len(parts) < 2 { + return nil, fmt.Errorf("archive must contain one or more top-level directories; found loose file %s", clean) + } + root := parts[0] + if _, ok := grouped[root]; !ok { + grouped[root] = map[string][]byte{} + } + grouped[root][strings.Join(parts[1:], "/")] = content + } + keys := make([]string, 0, len(grouped)) + for key := range grouped { + keys = append(keys, key) + } + sort.Strings(keys) + result := make([]extractedSkillDirectory, 0, len(keys)) + for _, key := range keys { + result = append(result, extractedSkillDirectory{Name: key, Files: grouped[key]}) + } + return result, nil +} + +func extractArchiveFileMap(filename string, raw []byte) (map[string][]byte, error) { + lower := strings.ToLower(strings.TrimSpace(filename)) + fileMap := map[string][]byte{} + switch { + case strings.HasSuffix(lower, ".zip"): + reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + return nil, fmt.Errorf("failed to read zip archive: %w", err) + } + for _, entry := range reader.File { + if entry.FileInfo().IsDir() { + continue + } + rc, err := entry.Open() + if err != nil { + return nil, fmt.Errorf("failed to open zip entry: %w", err) + } + content, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, fmt.Errorf("failed to read zip entry: %w", err) + } + fileMap[entry.Name] = content + } + default: + return nil, fmt.Errorf("only .zip skill archives are supported") + } + return fileMap, nil +} + +func (s *skillService) importDirectory(ctx context.Context, userID int, dir extractedSkillDirectory, originalName string) (*SkillPayload, error) { + skillKey := sanitizeSkillKey(dir.Name) + if skillKey == "" { + return nil, fmt.Errorf("skill directory name %q is invalid", dir.Name) + } + contentHash := hashDirectory(dir.Files) + archiveBytes, archiveHash, err := buildNormalizedZip(dir) + if err != nil { + return nil, err + } + + blob, err := s.repo.GetBlobByContentHash(contentHash) + if err != nil { + return nil, err + } + if blob == nil { + blob = &models.SkillBlob{ + ContentHash: contentHash, ArchiveHash: archiveHash, + ObjectKey: fmt.Sprintf("%d/%s/%s.zip", userID, skillKey, contentHash), + FileName: fmt.Sprintf("%s.zip", skillKey), + MediaType: "application/zip", SizeBytes: int64(len(archiveBytes)), + ScanStatus: "pending", RiskLevel: skillRiskUnknown, + } + if err := s.storage.PutObject(ctx, blob.ObjectKey, archiveBytes, blob.MediaType); err != nil { + return nil, err + } + if err := s.repo.CreateBlob(blob); err != nil { + return nil, err + } + if err := s.recordScan(blob, &dir); err != nil { + return nil, err + } + } + + skill, err := s.repo.GetSkillByUserKey(userID, skillKey) + if err != nil { + return nil, err + } + if skill == nil { + description := fmt.Sprintf("Imported from %s", originalName) + skill = &models.Skill{ + UserID: userID, SkillKey: skillKey, Name: dir.Name, Description: &description, + SourceType: skillSourceUploaded, Status: "active", RiskLevel: blob.RiskLevel, + LastScannedAt: blob.LastScannedAt, LastScanResultID: blob.LastScanResultID, + } + if err := s.repo.CreateSkill(skill); err != nil { + return nil, err + } + } + version, err := s.repo.GetVersionBySkillAndBlob(skill.ID, blob.ID) + if err != nil { + return nil, err + } + if version == nil { + latest, err := s.repo.GetLatestVersionBySkillID(skill.ID) + if err != nil { + return nil, err + } + versionNo := 1 + if latest != nil { + versionNo = latest.VersionNo + 1 + } + manifest, _ := json.Marshal(map[string]interface{}{"root_dir": dir.Name, "files": len(dir.Files)}) + manifestJSON := string(manifest) + version = &models.SkillVersion{ + SkillID: skill.ID, BlobID: blob.ID, VersionNo: versionNo, ManifestJSON: &manifestJSON, SourceType: skillSourceUploaded, + } + if err := s.repo.CreateVersion(version); err != nil { + return nil, err + } + } + skill.CurrentVersionID = &version.ID + skill.RiskLevel = blob.RiskLevel + skill.LastScannedAt = blob.LastScannedAt + skill.LastScanResultID = blob.LastScanResultID + skill.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateSkill(skill); err != nil { + return nil, err + } + return s.toSkillPayload(*skill) +} + +func (s *skillService) recordScan(blob *models.SkillBlob, dir *extractedSkillDirectory) error { + if s.scanner == nil { + return fmt.Errorf("skill scanner is not configured") + } + if dir == nil { + return fmt.Errorf("skill scanner requires real skill package content") + } + archiveBytes, _, err := buildNormalizedZip(*dir) + if err != nil { + return fmt.Errorf("failed to prepare skill archive for scanning: %w", err) + } + riskLevel, findings, summary, err := s.scanner.ScanArchive(context.Background(), blob.FileName, archiveBytes, nil) + if err != nil { + return fmt.Errorf("skill scanner failed: %w", err) + } + if strings.TrimSpace(summary) == "" { + summary = "Skill scanned by external skill-scanner service" + } + scannedAt := time.Now().UTC() + findingsJSON, _ := json.Marshal(findings) + result := &models.SkillScanResult{ + BlobID: blob.ID, Engine: "skill-scanner", RiskLevel: riskLevel, Status: "completed", + Summary: &summary, FindingsJSON: optionalString(string(findingsJSON)), ScannedAt: &scannedAt, + } + if err := s.repo.CreateScanResult(result); err != nil { + return err + } + blob.ScanStatus = "completed" + blob.RiskLevel = riskLevel + blob.LastScannedAt = &scannedAt + blob.LastScanResultID = &result.ID + if err := s.repo.UpdateBlob(blob); err != nil { + return err + } + return nil +} + +func buildNormalizedZip(dir extractedSkillDirectory) ([]byte, string, error) { + var buffer bytes.Buffer + zipWriter := zip.NewWriter(&buffer) + keys := make([]string, 0, len(dir.Files)) + for key := range dir.Files { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + content := dir.Files[key] + writer, err := zipWriter.Create(path.Join(dir.Name, key)) + if err != nil { + return nil, "", fmt.Errorf("failed to create normalized zip entry: %w", err) + } + if _, err := writer.Write(content); err != nil { + return nil, "", fmt.Errorf("failed to write normalized zip content: %w", err) + } + } + if err := zipWriter.Close(); err != nil { + return nil, "", fmt.Errorf("failed to finalize zip archive: %w", err) + } + hash := sha256.Sum256(buffer.Bytes()) + return buffer.Bytes(), hex.EncodeToString(hash[:]), nil +} + +func hashDirectory(files map[string][]byte) string { + flattened := flattenSingleTopLevelDir(files) + digest := md5.New() + entryKinds := map[string]string{} + fileMap := map[string][]byte{} + for key, body := range flattened { + clean := normalizeSkillRelPath(key) + if clean == "" || hasHiddenPathSegment(clean) { + continue + } + fileMap[clean] = body + entryKinds[clean] = "file" + for _, dir := range parentDirs(clean) { + if dir == "" || hasHiddenPathSegment(dir) { + continue + } + entryKinds[dir] = "dir" + } + } + entryKeys := make([]string, 0, len(entryKinds)) + for key := range entryKinds { + entryKeys = append(entryKeys, key) + } + sort.Strings(entryKeys) + for _, key := range entryKeys { + _, _ = digest.Write([]byte(key)) + _, _ = digest.Write([]byte("\n")) + if entryKinds[key] == "dir" { + _, _ = digest.Write([]byte("dir\n")) + continue + } + _, _ = digest.Write([]byte("file\n")) + _, _ = digest.Write(fileMap[key]) + _, _ = digest.Write([]byte("\n")) + } + return hex.EncodeToString(digest.Sum(nil)) +} + +func (s *skillService) resolveContentMD5(blob *models.SkillBlob) string { + if blob == nil { + return "" + } + contentHash := strings.TrimSpace(blob.ContentHash) + if len(contentHash) == 32 { + return contentHash + } + content, err := s.storage.GetObject(context.Background(), blob.ObjectKey) + if err != nil { + return contentHash + } + files, err := extractArchiveFileMap(blob.FileName, content) + if err != nil { + sum := md5.Sum(content) + return hex.EncodeToString(sum[:]) + } + return hashDirectory(files) +} + +func normalizeSkillRelPath(value string) string { + value = path.Clean(strings.TrimPrefix(strings.TrimSpace(value), "./")) + if value == "." || value == "" || strings.HasPrefix(value, "..") { + return "" + } + return value +} + +func hasHiddenPathSegment(value string) bool { + for _, part := range strings.Split(value, "/") { + if strings.HasPrefix(part, ".") { + return true + } + } + return false +} + +func parentDirs(value string) []string { + parts := strings.Split(value, "/") + if len(parts) <= 1 { + return nil + } + dirs := make([]string, 0, len(parts)-1) + for i := 1; i < len(parts); i++ { + dir := strings.Join(parts[:i], "/") + if dir != "" { + dirs = append(dirs, dir) + } + } + return dirs +} + +func flattenSingleTopLevelDir(files map[string][]byte) map[string][]byte { + normalized := map[string][]byte{} + topLevel := map[string]struct{}{} + for key, body := range files { + clean := normalizeSkillRelPath(key) + if clean == "" || hasHiddenPathSegment(clean) { + continue + } + normalized[clean] = body + part := clean + if slash := strings.IndexByte(clean, '/'); slash >= 0 { + part = clean[:slash] + } + topLevel[part] = struct{}{} + } + if len(topLevel) != 1 { + return normalized + } + var root string + for key := range topLevel { + root = key + } + prefix := root + "/" + flattened := map[string][]byte{} + for key, body := range normalized { + if strings.HasPrefix(key, prefix) { + flattened[strings.TrimPrefix(key, prefix)] = body + continue + } + flattened[key] = body + } + return flattened +} + +func sanitizeSkillKey(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + var builder strings.Builder + for _, r := range value { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + builder.WriteRune(r) + case r == '-' || r == '_' || r == ' ' || r == '.': + builder.WriteRune('-') + } + } + result := strings.Trim(builder.String(), "-") + for strings.Contains(result, "--") { + result = strings.ReplaceAll(result, "--", "-") + } + return result +} + +func (s *skillService) toSkillPayloads(items []models.Skill) ([]SkillPayload, error) { + result := make([]SkillPayload, 0, len(items)) + for _, item := range items { + payload, err := s.toSkillPayload(item) + if err != nil { + return nil, err + } + result = append(result, *payload) + } + return result, nil +} + +func (s *skillService) toSkillPayload(item models.Skill) (*SkillPayload, error) { + payload := &SkillPayload{ + ID: item.ID, ExternalSkillID: formatExternalSkillID(item.ID), UserID: item.UserID, SkillKey: item.SkillKey, Name: item.Name, Description: item.Description, + Status: item.Status, SourceType: item.SourceType, RiskLevel: item.RiskLevel, ScanStatus: "pending", + LastScannedAt: item.LastScannedAt, CurrentVersionID: item.CurrentVersionID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, + } + if item.CurrentVersionID != nil { + version, err := s.repo.GetVersionByID(*item.CurrentVersionID) + if err != nil { + return nil, err + } + if version != nil { + payload.CurrentVersionNo = &version.VersionNo + blob, err := s.repo.GetBlobByID(version.BlobID) + if err != nil { + return nil, err + } + if blob != nil { + contentMD5 := s.resolveContentMD5(blob) + payload.ContentHash = &blob.ContentHash + payload.ContentMD5 = &contentMD5 + payload.ArchiveHash = &blob.ArchiveHash + payload.ScanStatus = blob.ScanStatus + payload.LastScannedAt = blob.LastScannedAt + } + } + } + if item.LastScanResultID != nil { + scanResult, err := s.repo.GetScanResultByID(*item.LastScanResultID) + if err != nil { + return nil, err + } + findings := parseSkillFindings(scanResult) + payload.TopFindings = topRiskFindings(findings, 3) + payload.RiskReason = summarizeRiskReason(payload.TopFindings) + } + instanceSkills, err := s.findInstanceRefs(item.ID) + if err != nil { + return nil, err + } + payload.InstanceCount = instanceSkills + return payload, nil +} + +func parseSkillFindings(result *models.SkillScanResult) []SkillFindingPayload { + if result == nil || result.FindingsJSON == nil || strings.TrimSpace(*result.FindingsJSON) == "" { + return []SkillFindingPayload{} + } + var raw struct { + Findings []struct { + Analyzer string `json:"analyzer"` + Severity string `json:"severity"` + Category string `json:"category"` + RuleID string `json:"rule_id"` + Title string `json:"title"` + Description string `json:"description"` + FilePath *string `json:"file_path"` + LineNumber *int `json:"line_number"` + Remediation string `json:"remediation"` + Snippet *string `json:"snippet"` + } `json:"findings"` + } + if err := json.Unmarshal([]byte(*result.FindingsJSON), &raw); err != nil { + return []SkillFindingPayload{} + } + items := make([]SkillFindingPayload, 0, len(raw.Findings)) + for _, item := range raw.Findings { + items = append(items, SkillFindingPayload{ + Analyzer: item.Analyzer, + Severity: item.Severity, + Category: item.Category, + RuleID: item.RuleID, + Title: item.Title, + Description: item.Description, + FilePath: item.FilePath, + LineNumber: item.LineNumber, + Remediation: item.Remediation, + Snippet: item.Snippet, + }) + } + sort.SliceStable(items, func(i, j int) bool { + return severityRank(items[i].Severity) > severityRank(items[j].Severity) + }) + return items +} + +func topRiskFindings(items []SkillFindingPayload, limit int) []SkillFindingPayload { + if limit <= 0 || len(items) == 0 { + return []SkillFindingPayload{} + } + if len(items) <= limit { + return items + } + return items[:limit] +} + +func summarizeRiskReason(items []SkillFindingPayload) *string { + if len(items) == 0 { + return nil + } + first := items[0] + summary := strings.TrimSpace(first.Title) + if summary == "" { + summary = strings.TrimSpace(first.Description) + } + if summary == "" { + return nil + } + if first.FilePath != nil && strings.TrimSpace(*first.FilePath) != "" { + summary = fmt.Sprintf("%s (%s)", summary, strings.TrimSpace(*first.FilePath)) + } + return &summary +} + +func severityRank(value string) int { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "CRITICAL": + return 5 + case "HIGH": + return 4 + case "MEDIUM", "MODERATE": + return 3 + case "LOW", "WARNING": + return 2 + case "INFO", "SAFE", "NONE": + return 1 + default: + return 0 + } +} + +func (s *skillService) findInstanceRefs(skillID int) (int, error) { + all, err := s.repo.ListAllSkills() + if err != nil { + return 0, err + } + _ = all + count := 0 + for instanceID := 1; instanceID <= 0; instanceID++ { + _ = instanceID + } + instances, err := s.instanceRepo.GetAll(0, 100000) + if err != nil { + return 0, err + } + for _, instance := range instances { + items, err := s.repo.ListInstanceSkills(instance.ID) + if err != nil { + return 0, err + } + for _, item := range items { + if item.SkillID == skillID && item.Status != "removed" { + count++ + } + } + } + return count, nil +} + +func normalizeSkillSource(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "discovered_in_instance" + } + return value +} + +func isUserManagedSkill(skill models.Skill) bool { + return strings.EqualFold(strings.TrimSpace(skill.SourceType), skillSourceUploaded) +} + +func optionalVersionID(version *models.SkillVersion) *int { + if version == nil { + return nil + } + return &version.ID +} + +func (s *skillService) findVersionByContentMD5(skillID int, contentMD5 string) (*models.SkillVersion, *models.SkillBlob, error) { + versions, err := s.repo.ListVersionsBySkillID(skillID) + if err != nil { + return nil, nil, err + } + for _, candidate := range versions { + blob, err := s.repo.GetBlobByID(candidate.BlobID) + if err != nil { + return nil, nil, err + } + if blob != nil && s.resolveContentMD5(blob) == contentMD5 { + return &candidate, blob, nil + } + } + return nil, nil, nil +} + +func (s *skillService) nextDiscoveredSkillKey(userID int, baseKey, hash string) string { + candidate := baseKey + if candidate == "" { + candidate = "discovered-skill" + } + existing, err := s.repo.GetSkillByUserKey(userID, candidate) + if err == nil && existing == nil { + return candidate + } + suffix := hash + if len(suffix) > 8 { + suffix = suffix[:8] + } + candidate = fmt.Sprintf("%s-%s", candidate, suffix) + existing, err = s.repo.GetSkillByUserKey(userID, candidate) + if err == nil && existing == nil { + return candidate + } + return fmt.Sprintf("%s-%d", candidate, time.Now().UTC().Unix()) +} + +func formatExternalSkillID(id int) string { + return fmt.Sprintf("skill_%d", id) +} + +func formatExternalVersionID(id int) string { + return fmt.Sprintf("ver_%d", id) +} + +func parseExternalVersionID(value string) (int, error) { + value = strings.TrimSpace(strings.TrimPrefix(value, "ver_")) + if value == "" { + return 0, fmt.Errorf("invalid skill version") + } + var id int + if _, err := fmt.Sscanf(value, "%d", &id); err != nil || id <= 0 { + return 0, fmt.Errorf("invalid skill version") + } + return id, nil +} + +func parseExternalSkillID(value string) (int, error) { + value = strings.TrimSpace(strings.TrimPrefix(value, "skill_")) + if value == "" { + return 0, fmt.Errorf("invalid skill id") + } + var id int + if _, err := fmt.Sscanf(value, "%d", &id); err != nil || id <= 0 { + return 0, fmt.Errorf("invalid skill id") + } + return id, nil +} + +func skillKeyForRemoval(item *models.InstanceSkill) string { + if item == nil { + return "" + } + if item.InstallPath != nil && strings.TrimSpace(*item.InstallPath) != "" { + parts := strings.Split(strings.TrimSpace(*item.InstallPath), "/") + return parts[len(parts)-1] + } + return fmt.Sprintf("skill-%d", item.SkillID) +} + +func skillMin(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/deployments/k8s/clawmanager.yaml b/deployments/k8s/clawmanager.yaml index 9f43d10..83559c0 100644 --- a/deployments/k8s/clawmanager.yaml +++ b/deployments/k8s/clawmanager.yaml @@ -13,6 +13,10 @@ stringData: mysql-root-password: root123 mysql-password: clawreef123 jwt-secret: change-me-in-production + minio-root-user: minioadmin + minio-root-password: minioadmin123 + minio-access-key: minioadmin + minio-secret-key: minioadmin123 --- apiVersion: v1 kind: ConfigMap @@ -279,6 +283,224 @@ data: INDEX idx_openclaw_snapshot_instance (instance_id), INDEX idx_openclaw_snapshot_bundle (bundle_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + 007_add_instance_agent_control_plane.sql: | + USE clawmanager; + SET @instance_agent_bootstrap_token_column_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'instances' + AND COLUMN_NAME = 'agent_bootstrap_token' + ); + SET @instance_agent_bootstrap_token_column_sql = IF( + @instance_agent_bootstrap_token_column_exists = 0, + 'ALTER TABLE instances ADD COLUMN agent_bootstrap_token VARCHAR(255) NULL AFTER access_token', + 'SELECT 1' + ); + PREPARE instance_agent_bootstrap_token_column_stmt FROM @instance_agent_bootstrap_token_column_sql; + EXECUTE instance_agent_bootstrap_token_column_stmt; + DEALLOCATE PREPARE instance_agent_bootstrap_token_column_stmt; + + CREATE TABLE IF NOT EXISTS instance_agents ( + id INT AUTO_INCREMENT PRIMARY KEY, + instance_id INT NOT NULL, + agent_id VARCHAR(255) NOT NULL, + agent_version VARCHAR(50) NOT NULL, + protocol_version VARCHAR(50) NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'online', + capabilities_json LONGTEXT NOT NULL, + host_info_json LONGTEXT NULL, + session_token VARCHAR(255) NULL, + session_expires_at TIMESTAMP NULL, + last_heartbeat_at TIMESTAMP NULL, + last_reported_at TIMESTAMP NULL, + last_seen_ip VARCHAR(45) NULL, + registered_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE, + UNIQUE KEY uk_instance_agents_instance (instance_id), + UNIQUE KEY uk_instance_agents_session_token (session_token), + INDEX idx_instance_agents_agent_id (agent_id), + INDEX idx_instance_agents_status (status), + INDEX idx_instance_agents_last_heartbeat (last_heartbeat_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS instance_runtime_status ( + id INT AUTO_INCREMENT PRIMARY KEY, + instance_id INT NOT NULL, + infra_status VARCHAR(30) NOT NULL DEFAULT 'creating', + agent_status VARCHAR(30) NOT NULL DEFAULT 'offline', + openclaw_status VARCHAR(30) NOT NULL DEFAULT 'unknown', + openclaw_pid INT NULL, + openclaw_version VARCHAR(100) NULL, + current_config_revision_id INT NULL, + desired_config_revision_id INT NULL, + summary_json LONGTEXT NULL, + system_info_json LONGTEXT NULL, + health_json LONGTEXT NULL, + last_reported_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE, + UNIQUE KEY uk_instance_runtime_status_instance (instance_id), + INDEX idx_instance_runtime_status_agent_status (agent_status), + INDEX idx_instance_runtime_status_openclaw_status (openclaw_status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS instance_desired_state ( + id INT AUTO_INCREMENT PRIMARY KEY, + instance_id INT NOT NULL, + desired_power_state VARCHAR(30) NOT NULL DEFAULT 'running', + desired_config_revision_id INT NULL, + desired_runtime_action VARCHAR(50) NULL, + updated_by INT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE, + FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uk_instance_desired_state_instance (instance_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS instance_commands ( + id INT AUTO_INCREMENT PRIMARY KEY, + instance_id INT NOT NULL, + agent_id VARCHAR(255) NULL, + command_type VARCHAR(50) NOT NULL, + payload_json LONGTEXT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'pending', + idempotency_key VARCHAR(255) NOT NULL, + issued_by INT NULL, + issued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + dispatched_at TIMESTAMP NULL, + started_at TIMESTAMP NULL, + finished_at TIMESTAMP NULL, + timeout_seconds INT NOT NULL DEFAULT 300, + result_json LONGTEXT NULL, + error_message TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE, + FOREIGN KEY (issued_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uk_instance_commands_idempotency (instance_id, idempotency_key), + INDEX idx_instance_commands_instance_status (instance_id, status), + INDEX idx_instance_commands_agent_status (agent_id, status), + INDEX idx_instance_commands_issued_at (issued_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS instance_config_revisions ( + id INT AUTO_INCREMENT PRIMARY KEY, + instance_id INT NOT NULL, + source_snapshot_id INT NULL, + source_bundle_id INT NULL, + revision_no INT NOT NULL, + content_json LONGTEXT NOT NULL, + checksum VARCHAR(255) NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'published', + published_by INT NULL, + published_at TIMESTAMP NULL, + activated_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE, + FOREIGN KEY (published_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uk_instance_config_revision_unique (instance_id, revision_no), + INDEX idx_instance_config_revision_instance (instance_id, revision_no), + INDEX idx_instance_config_revision_status (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + 008_add_skill_management.sql: | + USE clawmanager; + CREATE TABLE IF NOT EXISTS skill_blobs ( + id INT AUTO_INCREMENT PRIMARY KEY, + content_hash VARCHAR(128) NOT NULL, + archive_hash VARCHAR(128) NOT NULL, + object_key VARCHAR(512) NOT NULL, + file_name VARCHAR(255) NOT NULL, + media_type VARCHAR(100) NOT NULL DEFAULT 'application/gzip', + size_bytes BIGINT NOT NULL DEFAULT 0, + scan_status VARCHAR(30) NOT NULL DEFAULT 'pending', + risk_level VARCHAR(30) NOT NULL DEFAULT 'unknown', + last_scanned_at TIMESTAMP NULL, + last_scan_result_id INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_skill_blobs_content_hash (content_hash), + INDEX idx_skill_blobs_scan_status (scan_status), + INDEX idx_skill_blobs_risk_level (risk_level) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS skills ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + skill_key VARCHAR(120) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + current_version_id INT NULL, + source_type VARCHAR(30) NOT NULL DEFAULT 'uploaded', + status VARCHAR(30) NOT NULL DEFAULT 'active', + risk_level VARCHAR(30) NOT NULL DEFAULT 'unknown', + last_scanned_at TIMESTAMP NULL, + last_scan_result_id INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY uk_skills_user_key (user_id, skill_key), + INDEX idx_skills_user_status (user_id, status), + INDEX idx_skills_risk_level (risk_level) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS skill_versions ( + id INT AUTO_INCREMENT PRIMARY KEY, + skill_id INT NOT NULL, + blob_id INT NOT NULL, + version_no INT NOT NULL, + manifest_json LONGTEXT NULL, + source_type VARCHAR(30) NOT NULL DEFAULT 'uploaded', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE, + FOREIGN KEY (blob_id) REFERENCES skill_blobs(id) ON DELETE RESTRICT, + UNIQUE KEY uk_skill_versions_skill_version (skill_id, version_no), + UNIQUE KEY uk_skill_versions_skill_blob (skill_id, blob_id), + INDEX idx_skill_versions_skill_id (skill_id, version_no) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS instance_skills ( + id INT AUTO_INCREMENT PRIMARY KEY, + instance_id INT NOT NULL, + skill_id INT NOT NULL, + skill_version_id INT NULL, + source_type VARCHAR(40) NOT NULL DEFAULT 'discovered_in_instance', + install_path VARCHAR(1024) NULL, + observed_hash VARCHAR(128) NULL, + status VARCHAR(30) NOT NULL DEFAULT 'active', + last_seen_at TIMESTAMP NULL, + removed_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE, + FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE, + FOREIGN KEY (skill_version_id) REFERENCES skill_versions(id) ON DELETE SET NULL, + UNIQUE KEY uk_instance_skills_instance_skill (instance_id, skill_id), + INDEX idx_instance_skills_instance (instance_id, status), + INDEX idx_instance_skills_skill (skill_id, status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS skill_scan_results ( + id INT AUTO_INCREMENT PRIMARY KEY, + blob_id INT NOT NULL, + engine VARCHAR(60) NOT NULL, + risk_level VARCHAR(30) NOT NULL DEFAULT 'unknown', + status VARCHAR(30) NOT NULL DEFAULT 'completed', + summary TEXT NULL, + findings_json LONGTEXT NULL, + scanned_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (blob_id) REFERENCES skill_blobs(id) ON DELETE CASCADE, + INDEX idx_skill_scan_results_blob (blob_id, scanned_at), + INDEX idx_skill_scan_results_risk (risk_level, scanned_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; --- apiVersion: v1 kind: PersistentVolume @@ -310,6 +532,36 @@ spec: storageClassName: manual volumeName: clawmanager-mysql-pv --- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: clawmanager-minio-pv +spec: + capacity: + storage: 10Gi + volumeMode: Filesystem + accessModes: + - ReadWriteOnce + persistentVolumeReclaimPolicy: Retain + storageClassName: manual + hostPath: + path: /tmp/clawmanager/system/minio + type: DirectoryOrCreate +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: minio-data + namespace: clawmanager-system +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: manual + volumeName: clawmanager-minio-pv +--- apiVersion: apps/v1 kind: Deployment metadata: @@ -376,6 +628,75 @@ spec: port: 3306 targetPort: 3306 --- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: minio + namespace: clawmanager-system +spec: + replicas: 1 + selector: + matchLabels: + app: minio + template: + metadata: + labels: + app: minio + spec: + containers: + - name: minio + image: minio/minio:RELEASE.2026-01-24T22-31-39Z + imagePullPolicy: IfNotPresent + args: + - server + - /data + - --console-address + - ":9001" + env: + - name: MINIO_ROOT_USER + valueFrom: + secretKeyRef: + name: clawmanager-secrets + key: minio-root-user + - name: MINIO_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: clawmanager-secrets + key: minio-root-password + ports: + - name: api + containerPort: 9000 + - name: console + containerPort: 9001 + volumeMounts: + - name: minio-data + mountPath: /data + readinessProbe: + tcpSocket: + port: api + initialDelaySeconds: 10 + periodSeconds: 10 + volumes: + - name: minio-data + persistentVolumeClaim: + claimName: minio-data +--- +apiVersion: v1 +kind: Service +metadata: + name: minio + namespace: clawmanager-system +spec: + selector: + app: minio + ports: + - name: api + port: 9000 + targetPort: api + - name: console + port: 9001 + targetPort: console +--- apiVersion: v1 kind: ServiceAccount metadata: @@ -397,6 +718,61 @@ subjects: --- apiVersion: apps/v1 kind: Deployment +metadata: + name: skill-scanner + namespace: clawmanager-system +spec: + replicas: 1 + selector: + matchLabels: + app: skill-scanner + template: + metadata: + labels: + app: skill-scanner + spec: + containers: + - name: skill-scanner + image: ghcr.io/yuan-lab-llm/skill-scanner:latest + imagePullPolicy: IfNotPresent + command: + - /opt/skill-scanner-venv/bin/skill-scanner-api + - --host + - 0.0.0.0 + - --port + - "8000" + env: + - name: SKILL_SCANNER_LLM_API_KEY + value: "" + - name: SKILL_SCANNER_LLM_MODEL + value: "" + - name: SKILL_SCANNER_LLM_BASE_URL + value: "" + - name: SKILL_SCANNER_META_LLM_API_KEY + value: "" + - name: SKILL_SCANNER_META_LLM_MODEL + value: "" + - name: SKILL_SCANNER_META_LLM_BASE_URL + value: "" + ports: + - name: http + containerPort: 8000 +--- +apiVersion: v1 +kind: Service +metadata: + name: skill-scanner + namespace: clawmanager-system +spec: + selector: + app: skill-scanner + ports: + - name: http + port: 8000 + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment metadata: name: clawmanager-app namespace: clawmanager-system @@ -443,12 +819,44 @@ spec: secretKeyRef: name: clawmanager-secrets key: jwt-secret + - name: OBJECT_STORAGE_ENDPOINT + value: "minio.clawmanager-system.svc.cluster.local:9000" + - name: OBJECT_STORAGE_REGION + value: "" + - name: OBJECT_STORAGE_ACCESS_KEY + valueFrom: + secretKeyRef: + name: clawmanager-secrets + key: minio-access-key + - name: OBJECT_STORAGE_SECRET_KEY + valueFrom: + secretKeyRef: + name: clawmanager-secrets + key: minio-secret-key + - name: OBJECT_STORAGE_BUCKET + value: "clawmanager-skills" + - name: OBJECT_STORAGE_USE_SSL + value: "false" + - name: OBJECT_STORAGE_BASE_PATH + value: "skills" + - name: OBJECT_STORAGE_FORCE_PATH_STYLE + value: "true" - name: K8S_MODE value: "incluster" - name: K8S_NAMESPACE value: "clawmanager" - name: K8S_STORAGE_CLASS value: "manual" + - name: SKILL_SCANNER_ENABLED + value: "true" + - name: SKILL_SCANNER_BASE_URL + value: "http://skill-scanner.clawmanager-system.svc.cluster.local:8000" + - name: SKILL_SCANNER_TIMEOUT_SECONDS + value: "120" + - name: SKILL_SCANNER_NAMESPACE + value: "clawmanager-system" + - name: SKILL_SCANNER_DEPLOYMENT + value: "skill-scanner" volumeMounts: - name: tls-cert mountPath: /var/run/clawreef-tls diff --git a/frontend/src/components/AdminLayout.tsx b/frontend/src/components/AdminLayout.tsx index 43779bc..ad1d3f5 100644 --- a/frontend/src/components/AdminLayout.tsx +++ b/frontend/src/components/AdminLayout.tsx @@ -41,6 +41,7 @@ const AdminLayout: React.FC = ({ children, title }) => { { path: '/admin', label: t('nav.adminDashboard'), icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', exact: true }, { path: '/admin/users', label: t('nav.users'), icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' }, { path: '/admin/instances', label: t('nav.instances'), icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01' }, + { path: '/admin/security', label: t('nav.securityCenter'), icon: 'M12 2l8 4.5v5c0 5.8-3.6 10.8-8 12.5-4.4-1.7-8-6.7-8-12.5v-5L12 2z', matchPaths: ['/admin/assets', '/admin/skills'] }, { path: '/admin/ai-gateway', label: t('nav.aiGateway'), @@ -153,7 +154,7 @@ const AdminLayout: React.FC = ({ children, title }) => { />
- Admin + {t('adminLayout.admin')}
{t('app.name')}
@@ -162,7 +163,7 @@ const AdminLayout: React.FC = ({ children, title }) => {