diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100755
index 00000000..7c898d5b
--- /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 f144af15..7447d3fa 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 {
+
+
@@ -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 458f53a7..369cc70f 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 f5c9b301..3a8af7b5 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 00000000..8a267a7a
--- /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 00000000..0a4aaa2a
--- /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 9ca0c777..2057c45f 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 00000000..17bd675e
--- /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 00000000..9f379dfd
--- /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 a0a35950..c467c1b2 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 0f6002cf..52f9a8b7 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 00000000..daef6591
--- /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 8622e74a..fb15234e 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 {