diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..7c898d5 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec core go qa full --fix diff --git a/cmd/bugseti/frontend/src/app/settings/settings.component.ts b/cmd/bugseti/frontend/src/app/settings/settings.component.ts index f144af1..7447d3f 100644 --- a/cmd/bugseti/frontend/src/app/settings/settings.component.ts +++ b/cmd/bugseti/frontend/src/app/settings/settings.component.ts @@ -9,6 +9,7 @@ interface Config { notificationsEnabled: boolean; notificationSound: boolean; workspaceDir: string; + marketplaceMcpRoot: string; theme: string; autoSeedContext: boolean; workHours?: { @@ -161,6 +162,13 @@ interface Config { + +
+ + +

Override the marketplace MCP root. Leave empty to auto-detect.

+
@@ -308,6 +316,7 @@ export class SettingsComponent implements OnInit { notificationsEnabled: true, notificationSound: true, workspaceDir: '', + marketplaceMcpRoot: '', theme: 'dark', autoSeedContext: true, workHours: { diff --git a/cmd/bugseti/main.go b/cmd/bugseti/main.go index 458f53a..369cc70 100644 --- a/cmd/bugseti/main.go +++ b/cmd/bugseti/main.go @@ -37,7 +37,7 @@ func main() { } // Initialize core services - notifyService := bugseti.NewNotifyService() + notifyService := bugseti.NewNotifyService(configService) statsService := bugseti.NewStatsService(configService) fetcherService := bugseti.NewFetcherService(configService, notifyService) queueService := bugseti.NewQueueService(configService) diff --git a/internal/bugseti/config.go b/internal/bugseti/config.go index f5c9b30..3a8af7b 100644 --- a/internal/bugseti/config.go +++ b/internal/bugseti/config.go @@ -37,6 +37,8 @@ type Config struct { // Workspace WorkspaceDir string `json:"workspaceDir,omitempty"` DataDir string `json:"dataDir,omitempty"` + // Marketplace MCP + MarketplaceMCPRoot string `json:"marketplaceMcpRoot,omitempty"` // Onboarding Onboarded bool `json:"onboarded"` @@ -96,6 +98,7 @@ func NewConfigService() *ConfigService { MaxConcurrentIssues: 1, AutoSeedContext: true, DataDir: bugsetiDir, + MarketplaceMCPRoot: "", UpdateChannel: "stable", AutoUpdate: false, UpdateCheckInterval: 6, // Check every 6 hours @@ -181,6 +184,13 @@ func (c *ConfigService) GetConfig() Config { return *c.config } +// GetMarketplaceMCPRoot returns the configured marketplace MCP root path. +func (c *ConfigService) GetMarketplaceMCPRoot() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.config.MarketplaceMCPRoot +} + // SetConfig updates the configuration and saves it. func (c *ConfigService) SetConfig(config Config) error { c.mu.Lock() diff --git a/internal/bugseti/ethics_guard.go b/internal/bugseti/ethics_guard.go new file mode 100644 index 0000000..8a267a7 --- /dev/null +++ b/internal/bugseti/ethics_guard.go @@ -0,0 +1,236 @@ +// Package bugseti provides services for the BugSETI distributed bug fixing application. +package bugseti + +import ( + "bytes" + "context" + "encoding/xml" + "strings" + "sync" + "time" +) + +const ( + maxEnvRunes = 512 + maxTitleRunes = 160 + maxNotificationRunes = 200 + maxSummaryRunes = 4000 + maxBodyRunes = 8000 + maxFileRunes = 260 +) + +type EthicsGuard struct { + Modal string + Axioms map[string]any + Loaded bool +} + +var ( + ethicsGuardMu sync.Mutex + ethicsGuard *EthicsGuard + ethicsGuardRoot string +) + +func getEthicsGuard(ctx context.Context) *EthicsGuard { + return getEthicsGuardWithRoot(ctx, "") +} + +func getEthicsGuardWithRoot(ctx context.Context, rootHint string) *EthicsGuard { + rootHint = strings.TrimSpace(rootHint) + + ethicsGuardMu.Lock() + defer ethicsGuardMu.Unlock() + + if ethicsGuard != nil && ethicsGuardRoot == rootHint { + return ethicsGuard + } + + guard := loadEthicsGuard(ctx, rootHint) + if guard == nil { + guard = &EthicsGuard{} + } + + ethicsGuard = guard + ethicsGuardRoot = rootHint + if ethicsGuard == nil { + return &EthicsGuard{} + } + return ethicsGuard +} + +func guardFromMarketplace(ctx context.Context, client marketplaceClient) *EthicsGuard { + if client == nil { + return &EthicsGuard{} + } + if ctx == nil { + ctx = context.Background() + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + ethics, err := client.EthicsCheck(ctx) + if err != nil || ethics == nil { + return &EthicsGuard{} + } + + return &EthicsGuard{ + Modal: ethics.Modal, + Axioms: ethics.Axioms, + Loaded: true, + } +} + +func loadEthicsGuard(ctx context.Context, rootHint string) *EthicsGuard { + if ctx == nil { + ctx = context.Background() + } + + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + client, err := newMarketplaceClient(ctx, rootHint) + if err != nil { + return &EthicsGuard{} + } + defer client.Close() + + ethics, err := client.EthicsCheck(ctx) + if err != nil || ethics == nil { + return &EthicsGuard{} + } + + return &EthicsGuard{ + Modal: ethics.Modal, + Axioms: ethics.Axioms, + Loaded: true, + } +} + +func (g *EthicsGuard) SanitizeEnv(value string) string { + return sanitizeInline(value, maxEnvRunes) +} + +func (g *EthicsGuard) SanitizeTitle(value string) string { + return sanitizeInline(value, maxTitleRunes) +} + +func (g *EthicsGuard) SanitizeNotification(value string) string { + return sanitizeInline(value, maxNotificationRunes) +} + +func (g *EthicsGuard) SanitizeSummary(value string) string { + return sanitizeMultiline(value, maxSummaryRunes) +} + +func (g *EthicsGuard) SanitizeBody(value string) string { + return sanitizeMultiline(value, maxBodyRunes) +} + +func (g *EthicsGuard) SanitizeFiles(values []string) []string { + if len(values) == 0 { + return nil + } + + seen := make(map[string]bool) + clean := make([]string, 0, len(values)) + for _, value := range values { + trimmed := sanitizeInline(value, maxFileRunes) + if trimmed == "" { + continue + } + if strings.Contains(trimmed, "..") { + continue + } + if seen[trimmed] { + continue + } + seen[trimmed] = true + clean = append(clean, trimmed) + } + return clean +} + +func (g *EthicsGuard) SanitizeList(values []string, maxRunes int) []string { + if len(values) == 0 { + return nil + } + if maxRunes <= 0 { + maxRunes = maxTitleRunes + } + clean := make([]string, 0, len(values)) + for _, value := range values { + trimmed := sanitizeInline(value, maxRunes) + if trimmed == "" { + continue + } + clean = append(clean, trimmed) + } + return clean +} + +func sanitizeInline(input string, maxRunes int) string { + return sanitizeText(input, maxRunes, false) +} + +func sanitizeMultiline(input string, maxRunes int) string { + return sanitizeText(input, maxRunes, true) +} + +func sanitizeText(input string, maxRunes int, allowNewlines bool) string { + if input == "" { + return "" + } + if maxRunes <= 0 { + maxRunes = maxSummaryRunes + } + + var b strings.Builder + count := 0 + for _, r := range input { + if r == '\r' { + continue + } + if r == '\n' { + if allowNewlines { + b.WriteRune(r) + count++ + } else { + b.WriteRune(' ') + count++ + } + if count >= maxRunes { + break + } + continue + } + if r == '\t' { + b.WriteRune(' ') + count++ + if count >= maxRunes { + break + } + continue + } + if r < 0x20 || r == 0x7f { + continue + } + b.WriteRune(r) + count++ + if count >= maxRunes { + break + } + } + + return strings.TrimSpace(b.String()) +} + +func escapeAppleScript(value string) string { + value = strings.ReplaceAll(value, "\\", "\\\\") + value = strings.ReplaceAll(value, "\"", "\\\"") + return value +} + +func escapePowerShellXML(value string) string { + var buffer bytes.Buffer + _ = xml.EscapeText(&buffer, []byte(value)) + return buffer.String() +} diff --git a/internal/bugseti/ethics_guard_test.go b/internal/bugseti/ethics_guard_test.go new file mode 100644 index 0000000..0a4aaa2 --- /dev/null +++ b/internal/bugseti/ethics_guard_test.go @@ -0,0 +1,28 @@ +package bugseti + +import "testing" + +func TestSanitizeInline_Good(t *testing.T) { + input := "Hello world" + output := sanitizeInline(input, 50) + if output != input { + t.Fatalf("expected %q, got %q", input, output) + } +} + +func TestSanitizeInline_Bad(t *testing.T) { + input := "Hello\nworld\t\x00" + expected := "Hello world" + output := sanitizeInline(input, 50) + if output != expected { + t.Fatalf("expected %q, got %q", expected, output) + } +} + +func TestSanitizeMultiline_Ugly(t *testing.T) { + input := "ab\ncd\tef\x00" + output := sanitizeMultiline(input, 5) + if output != "ab\ncd" { + t.Fatalf("expected %q, got %q", "ab\ncd", output) + } +} diff --git a/internal/bugseti/go.mod b/internal/bugseti/go.mod index 9ca0c77..2057c45 100644 --- a/internal/bugseti/go.mod +++ b/internal/bugseti/go.mod @@ -1,3 +1,17 @@ module github.com/host-uk/core/internal/bugseti go 1.25.5 + +require github.com/mark3labs/mcp-go v0.43.2 + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/internal/bugseti/go.sum b/internal/bugseti/go.sum new file mode 100644 index 0000000..17bd675 --- /dev/null +++ b/internal/bugseti/go.sum @@ -0,0 +1,39 @@ +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= +github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/bugseti/mcp_marketplace.go b/internal/bugseti/mcp_marketplace.go new file mode 100644 index 0000000..9f379df --- /dev/null +++ b/internal/bugseti/mcp_marketplace.go @@ -0,0 +1,246 @@ +// Package bugseti provides services for the BugSETI distributed bug fixing application. +package bugseti + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" +) + +type Marketplace struct { + Schema string `json:"$schema,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Owner MarketplaceOwner `json:"owner"` + Plugins []MarketplacePlugin `json:"plugins"` +} + +type MarketplaceOwner struct { + Name string `json:"name"` + Email string `json:"email"` +} + +type MarketplacePlugin struct { + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + Source string `json:"source"` + Category string `json:"category"` +} + +type PluginInfo struct { + Plugin MarketplacePlugin `json:"plugin"` + Path string `json:"path"` + Manifest map[string]any `json:"manifest,omitempty"` + Commands []string `json:"commands,omitempty"` + Skills []string `json:"skills,omitempty"` +} + +type EthicsContext struct { + Modal string `json:"modal"` + Axioms map[string]any `json:"axioms"` +} + +type marketplaceClient interface { + ListMarketplace(ctx context.Context) ([]MarketplacePlugin, error) + PluginInfo(ctx context.Context, name string) (*PluginInfo, error) + EthicsCheck(ctx context.Context) (*EthicsContext, error) + Close() error +} + +type mcpMarketplaceClient struct { + client *client.Client +} + +func newMarketplaceClient(ctx context.Context, rootHint string) (marketplaceClient, error) { + if ctx == nil { + ctx = context.Background() + } + + command, args, err := resolveMarketplaceCommand(rootHint) + if err != nil { + return nil, err + } + + mcpClient, err := client.NewStdioMCPClient(command, nil, args...) + if err != nil { + return nil, fmt.Errorf("failed to start marketplace MCP client: %w", err) + } + + initRequest := mcp.InitializeRequest{} + initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initRequest.Params.ClientInfo = mcp.Implementation{ + Name: "bugseti", + Version: GetVersion(), + } + + initCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if _, err := mcpClient.Initialize(initCtx, initRequest); err != nil { + _ = mcpClient.Close() + return nil, fmt.Errorf("failed to initialize marketplace MCP client: %w", err) + } + + return &mcpMarketplaceClient{client: mcpClient}, nil +} + +func (c *mcpMarketplaceClient) Close() error { + if c == nil || c.client == nil { + return nil + } + return c.client.Close() +} + +func (c *mcpMarketplaceClient) ListMarketplace(ctx context.Context) ([]MarketplacePlugin, error) { + var marketplace Marketplace + if err := c.callToolStructured(ctx, "marketplace_list", nil, &marketplace); err != nil { + return nil, err + } + return marketplace.Plugins, nil +} + +func (c *mcpMarketplaceClient) PluginInfo(ctx context.Context, name string) (*PluginInfo, error) { + var info PluginInfo + args := map[string]any{"name": name} + if err := c.callToolStructured(ctx, "marketplace_plugin_info", args, &info); err != nil { + return nil, err + } + return &info, nil +} + +func (c *mcpMarketplaceClient) EthicsCheck(ctx context.Context) (*EthicsContext, error) { + var ethics EthicsContext + if err := c.callToolStructured(ctx, "ethics_check", nil, ðics); err != nil { + return nil, err + } + return ðics, nil +} + +func (c *mcpMarketplaceClient) callToolStructured(ctx context.Context, name string, args map[string]any, target any) error { + if c == nil || c.client == nil { + return errors.New("marketplace client is not initialized") + } + if ctx == nil { + ctx = context.Background() + } + + request := mcp.CallToolRequest{} + request.Params.Name = name + if args != nil { + request.Params.Arguments = args + } + + result, err := c.client.CallTool(ctx, request) + if err != nil { + return err + } + if result == nil { + return errors.New("marketplace tool returned no result") + } + if result.IsError { + return fmt.Errorf("marketplace tool %s error: %s", name, toolResultMessage(result)) + } + if result.StructuredContent == nil { + return fmt.Errorf("marketplace tool %s returned no structured content", name) + } + payload, err := json.Marshal(result.StructuredContent) + if err != nil { + return fmt.Errorf("failed to encode marketplace response: %w", err) + } + if err := json.Unmarshal(payload, target); err != nil { + return fmt.Errorf("failed to decode marketplace response: %w", err) + } + return nil +} + +func toolResultMessage(result *mcp.CallToolResult) string { + if result == nil { + return "unknown error" + } + for _, content := range result.Content { + switch value := content.(type) { + case mcp.TextContent: + if value.Text != "" { + return value.Text + } + case *mcp.TextContent: + if value != nil && value.Text != "" { + return value.Text + } + } + } + return "unknown error" +} + +func resolveMarketplaceCommand(rootHint string) (string, []string, error) { + if command := strings.TrimSpace(os.Getenv("BUGSETI_MCP_COMMAND")); command != "" { + args := strings.Fields(os.Getenv("BUGSETI_MCP_ARGS")) + return command, args, nil + } + + if root := strings.TrimSpace(rootHint); root != "" { + path := filepath.Join(root, "mcp") + return "go", []string{"run", path}, nil + } + + if root := strings.TrimSpace(os.Getenv("BUGSETI_MCP_ROOT")); root != "" { + path := filepath.Join(root, "mcp") + return "go", []string{"run", path}, nil + } + + if root, ok := findCoreAgentRoot(); ok { + return "go", []string{"run", filepath.Join(root, "mcp")}, nil + } + + return "", nil, fmt.Errorf("marketplace MCP server not configured (set BUGSETI_MCP_COMMAND or BUGSETI_MCP_ROOT)") +} + +func findCoreAgentRoot() (string, bool) { + var candidates []string + if cwd, err := os.Getwd(); err == nil { + candidates = append(candidates, cwd) + candidates = append(candidates, filepath.Dir(cwd)) + } + if exe, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exe) + candidates = append(candidates, exeDir) + candidates = append(candidates, filepath.Dir(exeDir)) + } + + seen := make(map[string]bool) + for _, base := range candidates { + base = filepath.Clean(base) + if seen[base] { + continue + } + seen[base] = true + + root := filepath.Join(base, "core-agent") + if hasMcpDir(root) { + return root, true + } + + root = filepath.Join(base, "..", "core-agent") + if hasMcpDir(root) { + return filepath.Clean(root), true + } + } + + return "", false +} + +func hasMcpDir(root string) bool { + if root == "" { + return false + } + info, err := os.Stat(filepath.Join(root, "mcp", "main.go")) + return err == nil && !info.IsDir() +} diff --git a/internal/bugseti/notify.go b/internal/bugseti/notify.go index a0a3595..c467c1b 100644 --- a/internal/bugseti/notify.go +++ b/internal/bugseti/notify.go @@ -14,13 +14,15 @@ import ( type NotifyService struct { enabled bool sound bool + config *ConfigService } // NewNotifyService creates a new NotifyService. -func NewNotifyService() *NotifyService { +func NewNotifyService(config *ConfigService) *NotifyService { return &NotifyService{ enabled: true, sound: true, + config: config, } } @@ -45,7 +47,11 @@ func (n *NotifyService) Notify(title, message string) error { return nil } - log.Printf("Notification: %s - %s", title, message) + guard := getEthicsGuardWithRoot(context.Background(), n.getMarketplaceRoot()) + safeTitle := guard.SanitizeNotification(title) + safeMessage := guard.SanitizeNotification(message) + + log.Printf("Notification: %s - %s", safeTitle, safeMessage) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -53,11 +59,11 @@ func (n *NotifyService) Notify(title, message string) error { var err error switch runtime.GOOS { case "darwin": - err = n.notifyMacOS(ctx, title, message) + err = n.notifyMacOS(ctx, safeTitle, safeMessage) case "linux": - err = n.notifyLinux(ctx, title, message) + err = n.notifyLinux(ctx, safeTitle, safeMessage) case "windows": - err = n.notifyWindows(ctx, title, message) + err = n.notifyWindows(ctx, safeTitle, safeMessage) default: err = fmt.Errorf("unsupported platform: %s", runtime.GOOS) } @@ -68,6 +74,13 @@ func (n *NotifyService) Notify(title, message string) error { return err } +func (n *NotifyService) getMarketplaceRoot() string { + if n == nil || n.config == nil { + return "" + } + return n.config.GetMarketplaceMCPRoot() +} + // NotifyIssue sends a notification about a new issue. func (n *NotifyService) NotifyIssue(issue *Issue) error { title := "New Issue Available" @@ -84,7 +97,7 @@ func (n *NotifyService) NotifyPRStatus(repo string, prNumber int, status string) // notifyMacOS sends a notification on macOS using osascript. func (n *NotifyService) notifyMacOS(ctx context.Context, title, message string) error { - script := fmt.Sprintf(`display notification "%s" with title "%s"`, message, title) + script := fmt.Sprintf(`display notification "%s" with title "%s"`, escapeAppleScript(message), escapeAppleScript(title)) if n.sound { script += ` sound name "Glass"` } @@ -106,6 +119,9 @@ func (n *NotifyService) notifyLinux(ctx context.Context, title, message string) // notifyWindows sends a notification on Windows using PowerShell. func (n *NotifyService) notifyWindows(ctx context.Context, title, message string) error { + title = escapePowerShellXML(title) + message = escapePowerShellXML(message) + script := fmt.Sprintf(` [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null diff --git a/internal/bugseti/seeder.go b/internal/bugseti/seeder.go index 0f6002c..52f9a8b 100644 --- a/internal/bugseti/seeder.go +++ b/internal/bugseti/seeder.go @@ -48,7 +48,8 @@ func (s *SeederService) SeedIssue(issue *Issue) (*IssueContext, error) { if err != nil { log.Printf("Seed skill failed, using fallback: %v", err) // Fallback to basic context preparation - ctx = s.prepareBasicContext(issue) + guard := getEthicsGuardWithRoot(context.Background(), s.config.GetMarketplaceMCPRoot()) + ctx = s.prepareBasicContext(issue, guard) } ctx.PreparedAt = time.Now() @@ -91,22 +92,20 @@ func (s *SeederService) runSeedSkill(issue *Issue, workDir string) (*IssueContex ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() - // Look for the plugin script - pluginPaths := []string{ - "/home/shared/hostuk/claude-plugins/agentic-flows/skills/seed-agent-developer/scripts/analyze-issue.sh", - filepath.Join(os.Getenv("HOME"), ".claude/plugins/agentic-flows/skills/seed-agent-developer/scripts/analyze-issue.sh"), - } + mcpCtx, mcpCancel := context.WithTimeout(ctx, 20*time.Second) + defer mcpCancel() - var scriptPath string - for _, p := range pluginPaths { - if _, err := os.Stat(p); err == nil { - scriptPath = p - break - } + marketplace, err := newMarketplaceClient(mcpCtx, s.config.GetMarketplaceMCPRoot()) + if err != nil { + return nil, err } + defer marketplace.Close() - if scriptPath == "" { - return nil, fmt.Errorf("seed-agent-developer skill not found") + guard := guardFromMarketplace(mcpCtx, marketplace) + + scriptPath, err := findSeedSkillScript(mcpCtx, marketplace) + if err != nil { + return nil, err } // Run the analyze-issue script @@ -114,9 +113,9 @@ func (s *SeederService) runSeedSkill(issue *Issue, workDir string) (*IssueContex cmd.Dir = workDir cmd.Env = append(os.Environ(), fmt.Sprintf("ISSUE_NUMBER=%d", issue.Number), - fmt.Sprintf("ISSUE_REPO=%s", issue.Repo), - fmt.Sprintf("ISSUE_TITLE=%s", issue.Title), - fmt.Sprintf("ISSUE_URL=%s", issue.URL), + fmt.Sprintf("ISSUE_REPO=%s", guard.SanitizeEnv(issue.Repo)), + fmt.Sprintf("ISSUE_TITLE=%s", guard.SanitizeEnv(issue.Title)), + fmt.Sprintf("ISSUE_URL=%s", guard.SanitizeEnv(issue.URL)), ) var stdout, stderr bytes.Buffer @@ -139,36 +138,36 @@ func (s *SeederService) runSeedSkill(issue *Issue, workDir string) (*IssueContex if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { // If not JSON, treat as plain text summary - return &IssueContext{ + return sanitizeIssueContext(&IssueContext{ Summary: stdout.String(), Complexity: "unknown", - }, nil + }, guard), nil } - return &IssueContext{ + return sanitizeIssueContext(&IssueContext{ Summary: result.Summary, RelevantFiles: result.RelevantFiles, SuggestedFix: result.SuggestedFix, RelatedIssues: result.RelatedIssues, Complexity: result.Complexity, EstimatedTime: result.EstimatedTime, - }, nil + }, guard), nil } // prepareBasicContext creates a basic context without the seed skill. -func (s *SeederService) prepareBasicContext(issue *Issue) *IssueContext { +func (s *SeederService) prepareBasicContext(issue *Issue, guard *EthicsGuard) *IssueContext { // Extract potential file references from issue body files := extractFileReferences(issue.Body) // Estimate complexity based on labels and body length complexity := estimateComplexity(issue) - return &IssueContext{ + return sanitizeIssueContext(&IssueContext{ Summary: fmt.Sprintf("Issue #%d in %s: %s", issue.Number, issue.Repo, issue.Title), RelevantFiles: files, Complexity: complexity, EstimatedTime: estimateTime(complexity), - } + }, guard) } // sanitizeRepoName converts owner/repo to a safe directory name. @@ -256,6 +255,87 @@ func estimateTime(complexity string) string { } } +const seedSkillName = "seed-agent-developer" + +func findSeedSkillScript(ctx context.Context, marketplace marketplaceClient) (string, error) { + if marketplace == nil { + return "", fmt.Errorf("marketplace client is nil") + } + + plugins, err := marketplace.ListMarketplace(ctx) + if err != nil { + return "", err + } + + for _, plugin := range plugins { + info, err := marketplace.PluginInfo(ctx, plugin.Name) + if err != nil || info == nil { + continue + } + + if !containsSkill(info.Skills, seedSkillName) { + continue + } + + scriptPath, err := safeJoinUnder(info.Path, "skills", seedSkillName, "scripts", "analyze-issue.sh") + if err != nil { + continue + } + if stat, err := os.Stat(scriptPath); err == nil && !stat.IsDir() { + return scriptPath, nil + } + } + + return "", fmt.Errorf("seed-agent-developer skill not found in marketplace") +} + +func containsSkill(skills []string, name string) bool { + for _, skill := range skills { + if skill == name { + return true + } + } + return false +} + +func safeJoinUnder(base string, elems ...string) (string, error) { + if base == "" { + return "", fmt.Errorf("base path is empty") + } + baseAbs, err := filepath.Abs(base) + if err != nil { + return "", fmt.Errorf("failed to resolve base path: %w", err) + } + + joined := filepath.Join(append([]string{baseAbs}, elems...)...) + rel, err := filepath.Rel(baseAbs, joined) + if err != nil { + return "", fmt.Errorf("failed to resolve relative path: %w", err) + } + if strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("resolved path escapes base: %s", rel) + } + + return joined, nil +} + +func sanitizeIssueContext(ctx *IssueContext, guard *EthicsGuard) *IssueContext { + if ctx == nil { + return nil + } + if guard == nil { + guard = &EthicsGuard{} + } + + ctx.Summary = guard.SanitizeSummary(ctx.Summary) + ctx.SuggestedFix = guard.SanitizeSummary(ctx.SuggestedFix) + ctx.Complexity = guard.SanitizeTitle(ctx.Complexity) + ctx.EstimatedTime = guard.SanitizeTitle(ctx.EstimatedTime) + ctx.RelatedIssues = guard.SanitizeList(ctx.RelatedIssues, maxTitleRunes) + ctx.RelevantFiles = guard.SanitizeFiles(ctx.RelevantFiles) + return ctx +} + // GetWorkspaceDir returns the workspace directory for an issue. func (s *SeederService) GetWorkspaceDir(issue *Issue) string { baseDir := s.config.GetWorkspaceDir() diff --git a/internal/bugseti/seeder_test.go b/internal/bugseti/seeder_test.go new file mode 100644 index 0000000..daef659 --- /dev/null +++ b/internal/bugseti/seeder_test.go @@ -0,0 +1,97 @@ +package bugseti + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" +) + +type fakeMarketplaceClient struct { + plugins []MarketplacePlugin + infos map[string]*PluginInfo + listErr error + infoErr map[string]error +} + +func (f *fakeMarketplaceClient) ListMarketplace(ctx context.Context) ([]MarketplacePlugin, error) { + if f.listErr != nil { + return nil, f.listErr + } + return f.plugins, nil +} + +func (f *fakeMarketplaceClient) PluginInfo(ctx context.Context, name string) (*PluginInfo, error) { + if err, ok := f.infoErr[name]; ok { + return nil, err + } + info, ok := f.infos[name] + if !ok { + return nil, fmt.Errorf("plugin not found") + } + return info, nil +} + +func (f *fakeMarketplaceClient) EthicsCheck(ctx context.Context) (*EthicsContext, error) { + return nil, fmt.Errorf("not implemented") +} + +func (f *fakeMarketplaceClient) Close() error { + return nil +} + +func TestFindSeedSkillScript_Good(t *testing.T) { + root := t.TempDir() + scriptPath := filepath.Join(root, "skills", seedSkillName, "scripts", "analyze-issue.sh") + if err := os.MkdirAll(filepath.Dir(scriptPath), 0755); err != nil { + t.Fatalf("failed to create script directory: %v", err) + } + if err := os.WriteFile(scriptPath, []byte("#!/bin/bash\n"), 0755); err != nil { + t.Fatalf("failed to write script: %v", err) + } + + plugin := MarketplacePlugin{Name: "seed-plugin"} + client := &fakeMarketplaceClient{ + plugins: []MarketplacePlugin{plugin}, + infos: map[string]*PluginInfo{ + plugin.Name: { + Plugin: plugin, + Path: root, + Skills: []string{seedSkillName}, + }, + }, + } + + found, err := findSeedSkillScript(context.Background(), client) + if err != nil { + t.Fatalf("expected script path, got error: %v", err) + } + if found != scriptPath { + t.Fatalf("expected %q, got %q", scriptPath, found) + } +} + +func TestFindSeedSkillScript_Bad(t *testing.T) { + plugin := MarketplacePlugin{Name: "empty-plugin"} + client := &fakeMarketplaceClient{ + plugins: []MarketplacePlugin{plugin}, + infos: map[string]*PluginInfo{ + plugin.Name: { + Plugin: plugin, + Path: t.TempDir(), + Skills: []string{"not-the-skill"}, + }, + }, + } + + if _, err := findSeedSkillScript(context.Background(), client); err == nil { + t.Fatal("expected error when skill is missing") + } +} + +func TestSafeJoinUnder_Ugly(t *testing.T) { + if _, err := safeJoinUnder("", "skills"); err == nil { + t.Fatal("expected error for empty base path") + } +} diff --git a/internal/bugseti/submit.go b/internal/bugseti/submit.go index 8622e74..fb15234 100644 --- a/internal/bugseti/submit.go +++ b/internal/bugseti/submit.go @@ -67,6 +67,9 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) { return nil, fmt.Errorf("work directory not specified") } + guard := getEthicsGuardWithRoot(context.Background(), s.config.GetMarketplaceMCPRoot()) + issueTitle := guard.SanitizeTitle(issue.Title) + // Step 1: Ensure we have a fork forkOwner, err := s.ensureFork(issue.Repo) if err != nil { @@ -85,7 +88,9 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) { // Step 3: Stage and commit changes commitMsg := submission.CommitMsg if commitMsg == "" { - commitMsg = fmt.Sprintf("fix: resolve issue #%d\n\n%s\n\nFixes #%d", issue.Number, issue.Title, issue.Number) + commitMsg = fmt.Sprintf("fix: resolve issue #%d\n\n%s\n\nFixes #%d", issue.Number, issueTitle, issue.Number) + } else { + commitMsg = guard.SanitizeBody(commitMsg) } if err := s.commitChanges(workDir, submission.Files, commitMsg); err != nil { return &PRResult{Success: false, Error: fmt.Sprintf("commit failed: %v", err)}, err @@ -99,12 +104,15 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) { // Step 5: Create PR prTitle := submission.Title if prTitle == "" { - prTitle = fmt.Sprintf("Fix #%d: %s", issue.Number, issue.Title) + prTitle = fmt.Sprintf("Fix #%d: %s", issue.Number, issueTitle) + } else { + prTitle = guard.SanitizeTitle(prTitle) } prBody := submission.Body if prBody == "" { prBody = s.generatePRBody(issue) } + prBody = guard.SanitizeBody(prBody) prURL, prNumber, err := s.createPR(issue.Repo, forkOwner, branch, prTitle, prBody) if err != nil {