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 }