diff --git a/images/code/Containerfile b/images/code/Containerfile index b02a8efe8..b1793f4e2 100644 --- a/images/code/Containerfile +++ b/images/code/Containerfile @@ -74,6 +74,12 @@ ENV PATH="/usr/local/go/bin:${PATH}" \ GOPATH="/sandbox/go" \ GOMODCACHE="/sandbox/go/pkg/mod" +# --------------------------------------------------------------------------- +# gopls — Go language server for Claude Code LSP code intelligence. +ARG GOPLS_VERSION=0.18.1 +RUN GOBIN=/usr/local/go/bin go install "golang.org/x/tools/gopls@v${GOPLS_VERSION}" \ + && gopls version + # --------------------------------------------------------------------------- # gitleaks is already installed in the base sandbox image. # See images/sandbox/Containerfile for the pinned version and checksums. diff --git a/internal/cli/run.go b/internal/cli/run.go index c3c2d0c3f..e47150b25 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -150,6 +150,9 @@ func runAgent(agentName, fullsendDir, outputBase, targetRepo, fullsendBinary str if len(h.Skills) > 0 { printer.KeyValue("Skills", strings.Join(h.Skills, ", ")) } + if len(h.Plugins) > 0 { + printer.KeyValue("Plugins", strings.Join(h.Plugins, ", ")) + } if h.AgentInput != "" { printer.KeyValue("Agent input", h.AgentInput) } @@ -414,7 +417,11 @@ func runAgent(agentName, fullsendDir, outputBase, targetRepo, fullsendBinary str // 9c. Run agent with validation loop. agentBaseName := strings.TrimSuffix(filepath.Base(h.Agent), ".md") - claudeCmd := buildClaudeCommand(agentBaseName, h.Model, repoDir) + var pluginDirs []string + for _, p := range h.Plugins { + pluginDirs = append(pluginDirs, fmt.Sprintf("%s/plugins/%s", sandbox.SandboxClaudeConfig, filepath.Base(p))) + } + claudeCmd := buildClaudeCommand(agentBaseName, h.Model, repoDir, pluginDirs) timeout := time.Duration(h.TimeoutMinutes) * time.Minute if timeout == 0 { @@ -615,8 +622,8 @@ func bootstrapSandbox(sandboxName, repoDir, fullsendBinary string, h *harness.Ha // Agent and skill definitions go in CLAUDE_CONFIG_DIR so `claude --agent` // finds them regardless of the repo's own .claude/ directory. When // CLAUDE_CONFIG_DIR is set, Claude uses it instead of ~/.claude/. - mkdirCmd := fmt.Sprintf("mkdir -p %s/agents %s/skills %s/hooks %s/bin %s/.env.d %s/.security %s %s/.claude/hooks", - sandbox.SandboxClaudeConfig, sandbox.SandboxClaudeConfig, sandbox.SandboxClaudeConfig, sandbox.SandboxWorkspace, sandbox.SandboxWorkspace, sandbox.SandboxWorkspace, sandbox.SandboxClaudeConfig, sandbox.SandboxWorkspace) + mkdirCmd := fmt.Sprintf("mkdir -p %s/agents %s/skills %s/hooks %s/plugins %s/bin %s/.env.d %s/.security %s %s/.claude/hooks", + sandbox.SandboxClaudeConfig, sandbox.SandboxClaudeConfig, sandbox.SandboxClaudeConfig, sandbox.SandboxClaudeConfig, sandbox.SandboxWorkspace, sandbox.SandboxWorkspace, sandbox.SandboxWorkspace, sandbox.SandboxClaudeConfig, sandbox.SandboxWorkspace) if _, _, _, err := sandbox.Exec(sandboxName, mkdirCmd, 10*time.Second); err != nil { return fmt.Errorf("creating workspace dirs: %w", err) } @@ -739,6 +746,35 @@ func bootstrapSandbox(sandboxName, repoDir, fullsendBinary string, h *harness.Ha } } + // Scan plugin definitions for injection before copying into sandbox. + if scanPipeline != nil { + for _, pluginPath := range h.Plugins { + for _, name := range []string{"plugin.json", ".lsp.json"} { + content, err := os.ReadFile(filepath.Join(pluginPath, name)) + if err != nil { + continue + } + result := scanPipeline.Scan(string(content)) + if security.HasCriticalFindings(result.Findings) { + if h.FailModeClosed() { + return fmt.Errorf("plugin %q blocked: critical injection findings in %s", pluginPath, name) + } + fmt.Fprintf(os.Stderr, "WARNING: plugin %q has critical injection findings in %s (fail_mode: open)\n", pluginPath, name) + } else if len(result.Findings) > 0 { + fmt.Fprintf(os.Stderr, "WARNING: plugin %q has %d injection finding(s) in %s\n", pluginPath, len(result.Findings), name) + } + } + } + } + + // Install plugins as marketplace-cached plugins so Claude Code registers + // the LSP tool. + if len(h.Plugins) > 0 { + if err := bootstrapPlugins(sandboxName, h.Plugins); err != nil { + return fmt.Errorf("bootstrapping plugins: %w", err) + } + } + // Write .env file (infrastructure vars) and copy host files. if err := bootstrapEnv(sandboxName, repoDir, h); err != nil { return fmt.Errorf("bootstrapping environment: %w", err) @@ -772,7 +808,12 @@ func bootstrapEnv(sandboxName, repoDir string, h *harness.Harness) error { var lines []string // Infrastructure vars. - lines = append(lines, fmt.Sprintf("export PATH=%s/bin:$PATH", sandbox.SandboxWorkspace)) + pathExport := fmt.Sprintf("export PATH=%s/bin", sandbox.SandboxWorkspace) + if len(h.Plugins) > 0 { + pathExport += ":/usr/local/go/bin" + } + pathExport += ":$PATH" + lines = append(lines, pathExport) lines = append(lines, fmt.Sprintf("export CLAUDE_CONFIG_DIR=%s", sandbox.SandboxClaudeConfig)) lines = append(lines, fmt.Sprintf("export FULLSEND_OUTPUT_DIR=%s", outputDir)) lines = append(lines, fmt.Sprintf("export FULLSEND_TARGET_REPO_DIR=%s", repoDir)) @@ -1009,7 +1050,7 @@ func refreshOIDCToken(ctx context.Context, sandboxName, oidcURL, oidcAuth string return nil } -func buildClaudeCommand(agentName, model, repoDir string) string { +func buildClaudeCommand(agentName, model, repoDir string, pluginDirs []string) string { envFile := sandbox.SandboxWorkspace + "/.env" // Defense-in-depth: escape single quotes even though Validate() rejects them. @@ -1020,12 +1061,21 @@ func buildClaudeCommand(agentName, model, repoDir string) string { modelFlag = fmt.Sprintf("--model '%s' ", strings.ReplaceAll(model, "'", "'\\''")) } + var pluginDirParts []string + for _, pd := range pluginDirs { + pluginDirParts = append(pluginDirParts, fmt.Sprintf("--plugin-dir '%s'", strings.ReplaceAll(pd, "'", "'\\''"))) + } + pluginDirFlags := "" + if len(pluginDirParts) > 0 { + pluginDirFlags = strings.Join(pluginDirParts, " ") + " " + } + return fmt.Sprintf( // --verbose increases log output in the job log. If artifact upload is // added to this workflow, consider whether verbose output should be // redacted or made conditional via an env var. - "cd %s && . %s && claude --print --verbose --output-format stream-json %s--agent '%s' --dangerously-skip-permissions 'Run the agent task'", - repoDir, envFile, modelFlag, safe, + "cd %s && . %s && claude --print --verbose --output-format stream-json %s%s--agent '%s' --dangerously-skip-permissions 'Run the agent task'", + repoDir, envFile, modelFlag, pluginDirFlags, safe, ) } @@ -1387,6 +1437,144 @@ func bootstrapSecurityHooks(sandboxName string, h *harness.Harness) error { return nil } +// bootstrapPlugins installs Claude Code plugins as marketplace-cached plugins. +// Claude Code's LSP tool only registers when lspServers config comes from a +// marketplace plugin definition. This function replicates the file structure +// from https://github.com/anthropics/claude-plugins-official (public repo). +// Schema: https://json.schemastore.org/claude-code-marketplace.json +// When Claude Code adds SEED_DIR support in --print mode, this can be replaced +// with: CLAUDE_CODE_PLUGIN_SEED_DIR pointed at a pre-built plugin directory. +func bootstrapPlugins(sandboxName string, plugins []string) error { + const marketplace = "claude-plugins-official" + const version = "1.0.0" + pluginsBase := sandbox.SandboxClaudeConfig + "/plugins" + mktBase := pluginsBase + "/marketplaces/" + marketplace + + // Create all directories and README stubs in a single batched command. + var mkdirParts, echoParts []string + mkdirParts = append(mkdirParts, mktBase+"/.claude-plugin") + for _, p := range plugins { + name := filepath.Base(p) + cacheDir := fmt.Sprintf("%s/cache/%s/%s/%s", pluginsBase, marketplace, name, version) + mkdirParts = append(mkdirParts, mktBase+"/plugins/"+name, cacheDir) + echoParts = append(echoParts, + fmt.Sprintf("echo '# %s' > %s/README.md", name, cacheDir), + fmt.Sprintf("echo '# %s' > %s/plugins/%s/README.md", name, mktBase, name), + ) + } + batchCmd := "mkdir -p " + strings.Join(mkdirParts, " ") + if len(echoParts) > 0 { + batchCmd += " && " + strings.Join(echoParts, " && ") + } + if _, _, _, err := sandbox.Exec(sandboxName, batchCmd, 10*time.Second); err != nil { + return fmt.Errorf("creating marketplace dirs: %w", err) + } + + // Upload plugin directories into sandbox. + for _, pluginPath := range plugins { + if err := sandbox.Upload(sandboxName, pluginPath, + fmt.Sprintf("%s/plugins/", sandbox.SandboxClaudeConfig)); err != nil { + return fmt.Errorf("copying plugin %q: %w", pluginPath, err) + } + } + + // Build and upload marketplace config files. + configs, err := buildPluginConfigs(plugins, pluginsBase, mktBase, marketplace, version) + if err != nil { + return fmt.Errorf("building plugin configs: %w", err) + } + for _, entry := range configs { + tmp, err := os.CreateTemp("", "fullsend-plugin-*.json") + if err != nil { + return fmt.Errorf("creating temp file for %s: %w", filepath.Base(entry.path), err) + } + if _, err := tmp.Write(entry.data); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return fmt.Errorf("writing %s: %w", filepath.Base(entry.path), err) + } + tmp.Close() + uploadErr := sandbox.Upload(sandboxName, tmp.Name(), entry.path) + os.Remove(tmp.Name()) + if uploadErr != nil { + return fmt.Errorf("uploading %s: %w", filepath.Base(entry.path), uploadErr) + } + } + return nil +} + +type pluginConfigEntry struct { + path string + data []byte +} + +// buildPluginConfigs builds the marketplace JSON config files for the given plugins. +// Returns entries for marketplace.json, known_marketplaces.json, installed_plugins.json, +// and settings.json. +func buildPluginConfigs(plugins []string, pluginsBase, mktBase, marketplace, version string) ([]pluginConfigEntry, error) { + var mktPlugins []any + installedPlugins := map[string]any{} + enabledPlugins := map[string]bool{} + ts := "2026-01-01T00:00:00.000Z" + + for _, pluginPath := range plugins { + name := filepath.Base(pluginPath) + qualifiedName := name + "@" + marketplace + cacheDir := fmt.Sprintf("%s/cache/%s/%s/%s", pluginsBase, marketplace, name, version) + + mp := map[string]any{ + "name": name, "version": version, + "source": "./plugins/" + name, "category": "development", + } + if data, err := os.ReadFile(filepath.Join(pluginPath, ".lsp.json")); err == nil { + var servers map[string]any + if json.Unmarshal(data, &servers) == nil { + mp["lspServers"] = servers + } + } + mktPlugins = append(mktPlugins, mp) + installedPlugins[qualifiedName] = []map[string]string{{ + "scope": "user", "installPath": cacheDir, "version": version, + "installedAt": ts, "lastUpdated": ts, + }} + enabledPlugins[qualifiedName] = true + } + + entries := []struct { + path string + data any + }{ + {mktBase + "/.claude-plugin/marketplace.json", map[string]any{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": marketplace, + "owner": map[string]string{"name": "Anthropic", "email": "support@anthropic.com"}, + "plugins": mktPlugins, + }}, + {pluginsBase + "/known_marketplaces.json", map[string]any{ + marketplace: map[string]any{ + "source": map[string]string{"source": "github", "repo": "anthropics/claude-plugins-official"}, + "installLocation": mktBase, "lastUpdated": ts, + }, + }}, + {pluginsBase + "/installed_plugins.json", map[string]any{ + "version": 2, "plugins": installedPlugins, + }}, + {sandbox.SandboxClaudeConfig + "/settings.json", map[string]any{ + "enabledPlugins": enabledPlugins, + }}, + } + + var result []pluginConfigEntry + for _, entry := range entries { + data, err := json.Marshal(entry.data) + if err != nil { + return nil, fmt.Errorf("marshaling %s: %w", filepath.Base(entry.path), err) + } + result = append(result, pluginConfigEntry{path: entry.path, data: data}) + } + return result, nil +} + // injectTraceID appends the FULLSEND_TRACE_ID to the sandbox .env file. func injectTraceID(sandboxName, traceID string) error { if !security.IsValidTraceID(traceID) { diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index ed0c1666b..946b7d04a 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -7,6 +7,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "io" "net/http" @@ -14,6 +15,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "sync/atomic" "testing" "time" @@ -79,24 +81,227 @@ func TestRunCommand_HasTargetRepoFlag(t *testing.T) { } func TestBuildClaudeCommand_Basic(t *testing.T) { - cmd := buildClaudeCommand("hello-world", "", "/tmp/workspace/repo") + cmd := buildClaudeCommand("hello-world", "", "/tmp/workspace/repo", nil) assert.Contains(t, cmd, "cd /tmp/workspace/repo") assert.Contains(t, cmd, "--agent 'hello-world'") assert.NotContains(t, cmd, "--model") + assert.NotContains(t, cmd, "--plugin-dir") } func TestBuildClaudeCommand_WithModel(t *testing.T) { - cmd := buildClaudeCommand("hello-world", "sonnet", "/tmp/workspace/repo") + cmd := buildClaudeCommand("hello-world", "sonnet", "/tmp/workspace/repo", nil) assert.Contains(t, cmd, "--model 'sonnet'") assert.Contains(t, cmd, "--agent 'hello-world'") } func TestBuildClaudeCommand_EscapesQuotes(t *testing.T) { - cmd := buildClaudeCommand("test'name", "", "/tmp/workspace/repo") + cmd := buildClaudeCommand("test'name", "", "/tmp/workspace/repo", nil) assert.NotContains(t, cmd, "'test'name'") assert.Contains(t, cmd, "'test'\\''name'") } +func TestBuildClaudeCommand_WithPluginDirs(t *testing.T) { + cmd := buildClaudeCommand("agent", "", "/tmp/workspace/repo", []string{"/tmp/claude-config/plugins/gopls-lsp"}) + assert.Contains(t, cmd, "--plugin-dir '/tmp/claude-config/plugins/gopls-lsp'") +} + +func TestBuildClaudeCommand_MultiplePluginDirs(t *testing.T) { + cmd := buildClaudeCommand("agent", "", "/tmp/workspace/repo", []string{ + "/tmp/claude-config/plugins/gopls-lsp", + "/tmp/claude-config/plugins/other-lsp", + }) + assert.Contains(t, cmd, "--plugin-dir '/tmp/claude-config/plugins/gopls-lsp'") + assert.Contains(t, cmd, "--plugin-dir '/tmp/claude-config/plugins/other-lsp'") +} + +func TestBuildClaudeCommand_PluginDirEscapesQuotes(t *testing.T) { + cmd := buildClaudeCommand("agent", "", "/tmp/workspace/repo", []string{"/tmp/path'with'quotes"}) + assert.Contains(t, cmd, "--plugin-dir '/tmp/path'\\''with'\\''quotes'") +} + +func TestBuildClaudeCommand_NoPlugins(t *testing.T) { + cmd := buildClaudeCommand("agent", "", "/tmp/workspace/repo", nil) + assert.NotContains(t, cmd, "--plugin-dir") +} + +func TestBuildPluginConfigs_SinglePlugin(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "gopls-lsp") + require.NoError(t, os.MkdirAll(pluginDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pluginDir, "plugin.json"), + []byte(`{"name":"gopls-lsp"}`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(pluginDir, ".lsp.json"), + []byte(`{"go":{"command":"gopls","args":["serve"]}}`), 0o644)) + + entries, err := buildPluginConfigs( + []string{pluginDir}, "/tmp/plugins", "/tmp/plugins/marketplaces/claude-plugins-official", "claude-plugins-official", "1.0.0", + ) + require.NoError(t, err) + require.Len(t, entries, 4) + + // marketplace.json: has lspServers, owner, correct plugin fields. + var mkt map[string]any + require.NoError(t, json.Unmarshal(entries[0].data, &mkt)) + assert.NotNil(t, mkt["owner"]) + plugins := mkt["plugins"].([]any) + require.Len(t, plugins, 1) + p := plugins[0].(map[string]any) + assert.Equal(t, "gopls-lsp", p["name"]) + assert.Equal(t, "1.0.0", p["version"]) + assert.Equal(t, "./plugins/gopls-lsp", p["source"]) + assert.Equal(t, "development", p["category"]) + assert.NotNil(t, p["lspServers"]) + lsp := p["lspServers"].(map[string]any) + goEntry := lsp["go"].(map[string]any) + assert.Equal(t, "gopls", goEntry["command"]) + + // installed_plugins.json: has qualified name. + var installed map[string]any + require.NoError(t, json.Unmarshal(entries[2].data, &installed)) + pluginsMap := installed["plugins"].(map[string]any) + assert.Contains(t, pluginsMap, "gopls-lsp@claude-plugins-official") + + // settings.json: enabledPlugins. + var settings map[string]any + require.NoError(t, json.Unmarshal(entries[3].data, &settings)) + enabled := settings["enabledPlugins"].(map[string]any) + assert.Equal(t, true, enabled["gopls-lsp@claude-plugins-official"]) +} + +func TestBuildPluginConfigs_MultiplePlugins(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"plugin-a", "plugin-b"} { + pd := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(pd, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pd, "plugin.json"), + []byte(fmt.Sprintf(`{"name":%q}`, name)), 0o644)) + } + + entries, err := buildPluginConfigs( + []string{filepath.Join(dir, "plugin-a"), filepath.Join(dir, "plugin-b")}, + "/tmp/plugins", "/tmp/plugins/marketplaces/claude-plugins-official", "claude-plugins-official", "1.0.0", + ) + require.NoError(t, err) + require.Len(t, entries, 4) + + var mkt map[string]any + require.NoError(t, json.Unmarshal(entries[0].data, &mkt)) + plugins := mkt["plugins"].([]any) + assert.Len(t, plugins, 2) + + var settings map[string]any + require.NoError(t, json.Unmarshal(entries[3].data, &settings)) + enabled := settings["enabledPlugins"].(map[string]any) + assert.Len(t, enabled, 2) +} + +func TestBuildPluginConfigs_NoLspJSON(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "simple-plugin") + require.NoError(t, os.MkdirAll(pluginDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pluginDir, "plugin.json"), + []byte(`{"name":"simple-plugin"}`), 0o644)) + + entries, err := buildPluginConfigs( + []string{pluginDir}, "/tmp/plugins", "/tmp/plugins/marketplaces/claude-plugins-official", "claude-plugins-official", "1.0.0", + ) + require.NoError(t, err) + require.Len(t, entries, 4) + + var mkt map[string]any + require.NoError(t, json.Unmarshal(entries[0].data, &mkt)) + plugins := mkt["plugins"].([]any) + p := plugins[0].(map[string]any) + assert.Nil(t, p["lspServers"]) +} + +func TestBuildPluginConfigs_InvalidLspJSON(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "bad-lsp") + require.NoError(t, os.MkdirAll(pluginDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pluginDir, "plugin.json"), + []byte(`{"name":"bad-lsp"}`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(pluginDir, ".lsp.json"), + []byte(`{broken`), 0o644)) + + entries, err := buildPluginConfigs( + []string{pluginDir}, "/tmp/plugins", "/tmp/plugins/marketplaces/claude-plugins-official", "claude-plugins-official", "1.0.0", + ) + require.NoError(t, err) + + var mkt map[string]any + require.NoError(t, json.Unmarshal(entries[0].data, &mkt)) + plugins := mkt["plugins"].([]any) + p := plugins[0].(map[string]any) + assert.Nil(t, p["lspServers"]) +} + +func TestBuildPluginConfigs_EmptyLspJSON(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "empty-lsp") + require.NoError(t, os.MkdirAll(pluginDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pluginDir, "plugin.json"), + []byte(`{"name":"empty-lsp"}`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(pluginDir, ".lsp.json"), + []byte(``), 0o644)) + + entries, err := buildPluginConfigs( + []string{pluginDir}, "/tmp/plugins", "/tmp/plugins/marketplaces/claude-plugins-official", "claude-plugins-official", "1.0.0", + ) + require.NoError(t, err) + + var mkt map[string]any + require.NoError(t, json.Unmarshal(entries[0].data, &mkt)) + plugins := mkt["plugins"].([]any) + p := plugins[0].(map[string]any) + assert.Nil(t, p["lspServers"]) +} + +func TestBuildPluginConfigs_ConfigStructure(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "test-plugin") + require.NoError(t, os.MkdirAll(pluginDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pluginDir, "plugin.json"), + []byte(`{"name":"test-plugin"}`), 0o644)) + + entries, err := buildPluginConfigs( + []string{pluginDir}, "/tmp/plugins", "/tmp/plugins/marketplaces/claude-plugins-official", "claude-plugins-official", "1.0.0", + ) + require.NoError(t, err) + require.Len(t, entries, 4) + + assert.True(t, strings.HasSuffix(entries[0].path, "/marketplace.json")) + assert.True(t, strings.HasSuffix(entries[1].path, "/known_marketplaces.json")) + assert.True(t, strings.HasSuffix(entries[2].path, "/installed_plugins.json")) + assert.True(t, strings.HasSuffix(entries[3].path, "/settings.json")) + + // known_marketplaces.json has source repo. + var km map[string]any + require.NoError(t, json.Unmarshal(entries[1].data, &km)) + mktEntry := km["claude-plugins-official"].(map[string]any) + source := mktEntry["source"].(map[string]any) + assert.Equal(t, "anthropics/claude-plugins-official", source["repo"]) +} + +func TestBuildPluginConfigs_EmptyPluginList(t *testing.T) { + entries, err := buildPluginConfigs( + nil, "/tmp/plugins", "/tmp/plugins/marketplaces/claude-plugins-official", "claude-plugins-official", "1.0.0", + ) + require.NoError(t, err) + require.Len(t, entries, 4) + + // marketplace.json has empty plugins array. + var mkt map[string]any + require.NoError(t, json.Unmarshal(entries[0].data, &mkt)) + assert.Nil(t, mkt["plugins"]) + + // settings.json has empty enabledPlugins. + var settings map[string]any + require.NoError(t, json.Unmarshal(entries[3].data, &settings)) + enabled := settings["enabledPlugins"].(map[string]any) + assert.Len(t, enabled, 0) +} + func TestBuildScanContextCommand_SourcesEnv(t *testing.T) { traceID := "aabbccdd-1122-4334-8556-aabbccddeeff" cmd := buildScanContextCommand("/tmp/workspace/repo", traceID) diff --git a/internal/harness/harness.go b/internal/harness/harness.go index 7f109e8ab..79ff9b984 100644 --- a/internal/harness/harness.go +++ b/internal/harness/harness.go @@ -11,8 +11,9 @@ import ( ) var ( - validAgentName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) - validModelName = regexp.MustCompile(`^[a-zA-Z0-9_.@-]+$`) + validAgentName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + validModelName = regexp.MustCompile(`^[a-zA-Z0-9_.@-]+$`) + validPluginName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) envVarRef = regexp.MustCompile(`\$\{([^}]+)\}`) ) @@ -192,6 +193,7 @@ type Harness struct { Image string `yaml:"image,omitempty"` Policy string `yaml:"policy,omitempty"` Skills []string `yaml:"skills,omitempty"` + Plugins []string `yaml:"plugins,omitempty"` Providers []string `yaml:"providers,omitempty"` HostFiles []HostFile `yaml:"host_files,omitempty"` APIServers []APIServer `yaml:"api_servers,omitempty"` @@ -237,6 +239,12 @@ func (h *Harness) Validate() error { if h.Model != "" && !validModelName.MatchString(h.Model) { return fmt.Errorf("model %q contains invalid characters (allowed: a-z, A-Z, 0-9, _, -, ., @)", h.Model) } + for i, p := range h.Plugins { + pluginBase := filepath.Base(p) + if !validPluginName.MatchString(pluginBase) { + return fmt.Errorf("plugins[%d] name %q contains invalid characters (allowed: a-z, A-Z, 0-9, _, -)", i, pluginBase) + } + } if h.TimeoutMinutes < 0 { return fmt.Errorf("timeout_minutes must be non-negative, got %d", h.TimeoutMinutes) } @@ -340,6 +348,11 @@ func (h *Harness) ResolveRelativeTo(baseDir string) error { return err } } + for i := range h.Plugins { + if h.Plugins[i], err = resolve(fmt.Sprintf("plugins[%d]", i), h.Plugins[i]); err != nil { + return err + } + } for i, hf := range h.HostFiles { if !strings.Contains(hf.Src, "${") { if h.HostFiles[i].Src, err = resolve(fmt.Sprintf("host_files[%d].src", i), hf.Src); err != nil { @@ -429,6 +442,11 @@ func (h *Harness) ValidateFilesExist() error { return err } } + for i, p := range h.Plugins { + if err := check(fmt.Sprintf("plugins[%d]", i), p); err != nil { + return err + } + } for i, hf := range h.HostFiles { // Skip ${VAR} paths — they are expanded at bootstrap time. if strings.Contains(hf.Src, "${") { diff --git a/internal/harness/harness_test.go b/internal/harness/harness_test.go index 4c0a12e5c..aed7360dc 100644 --- a/internal/harness/harness_test.go +++ b/internal/harness/harness_test.go @@ -531,6 +531,59 @@ func TestValidateFilesExist_SkipsVarPaths(t *testing.T) { require.NoError(t, h.ValidateFilesExist()) } +func TestValidate_PluginNameValid(t *testing.T) { + h := &Harness{ + Agent: "agents/test.md", + Plugins: []string{"plugins/gopls-lsp", "plugins/my_plugin-2"}, + } + require.NoError(t, h.Validate()) +} + +func TestValidate_PluginNameInvalid(t *testing.T) { + for _, name := range []string{"my plugin", "foo;bar", "bad@name"} { + h := &Harness{ + Agent: "agents/test.md", + Plugins: []string{"plugins/" + name}, + } + err := h.Validate() + require.Error(t, err, "expected error for plugin name %q", name) + assert.Contains(t, err.Error(), "contains invalid characters") + } +} + +func TestResolveRelativeTo_Plugins(t *testing.T) { + h := &Harness{ + Agent: "agents/test.md", + Plugins: []string{"plugins/gopls-lsp"}, + } + require.NoError(t, h.ResolveRelativeTo("/base/dir")) + assert.Equal(t, []string{"/base/dir/plugins/gopls-lsp"}, h.Plugins) +} + +func TestResolveRelativeTo_PluginTraversalRejected(t *testing.T) { + h := &Harness{ + Agent: "agents/test.md", + Plugins: []string{"../../etc/evil"}, + } + err := h.ResolveRelativeTo("/base/dir") + require.Error(t, err) + assert.Contains(t, err.Error(), "resolves outside fullsend directory") +} + +func TestValidateFilesExist_MissingPlugin(t *testing.T) { + dir := t.TempDir() + agentFile := filepath.Join(dir, "agent.md") + require.NoError(t, os.WriteFile(agentFile, []byte("agent"), 0o644)) + + h := &Harness{ + Agent: agentFile, + Plugins: []string{"/nonexistent/plugin"}, + } + err := h.ValidateFilesExist() + require.Error(t, err) + assert.Contains(t, err.Error(), "plugins[0]") +} + func TestValidateFilesExist_SkipsOptionalPaths(t *testing.T) { dir := t.TempDir() agentFile := filepath.Join(dir, "agent.md") diff --git a/internal/scaffold/fullsend-repo/harness/code.yaml b/internal/scaffold/fullsend-repo/harness/code.yaml index 9d5849342..38022edd0 100644 --- a/internal/scaffold/fullsend-repo/harness/code.yaml +++ b/internal/scaffold/fullsend-repo/harness/code.yaml @@ -31,6 +31,9 @@ host_files: skills: - skills/code-implementation +plugins: + - plugins/gopls-lsp + # Environment variables available to post_script on the runner. # These are expanded from the runner environment and NEVER enter the sandbox. runner_env: diff --git a/internal/scaffold/fullsend-repo/plugins/gopls-lsp/.lsp.json b/internal/scaffold/fullsend-repo/plugins/gopls-lsp/.lsp.json new file mode 100644 index 000000000..926d4ae1e --- /dev/null +++ b/internal/scaffold/fullsend-repo/plugins/gopls-lsp/.lsp.json @@ -0,0 +1 @@ +{"go":{"command":"gopls","args":["serve"],"extensionToLanguage":{".go":"go"}}} diff --git a/internal/scaffold/fullsend-repo/plugins/gopls-lsp/plugin.json b/internal/scaffold/fullsend-repo/plugins/gopls-lsp/plugin.json new file mode 100644 index 000000000..0086d3bf4 --- /dev/null +++ b/internal/scaffold/fullsend-repo/plugins/gopls-lsp/plugin.json @@ -0,0 +1 @@ +{"name": "gopls-lsp"} diff --git a/internal/security/injection.go b/internal/security/injection.go index 457d40917..a0d4c3a7e 100644 --- a/internal/security/injection.go +++ b/internal/security/injection.go @@ -69,6 +69,8 @@ var ScannableFiles = map[string]bool{ ".gemini.md": true, "copilot-instructions.md": true, "skill.md": true, + "plugin.json": true, + ".lsp.json": true, } // ShouldScan reports whether a filename should be scanned for injection. diff --git a/internal/security/scanner_test.go b/internal/security/scanner_test.go index b86578ba2..0c6301388 100644 --- a/internal/security/scanner_test.go +++ b/internal/security/scanner_test.go @@ -243,6 +243,9 @@ func TestShouldScan(t *testing.T) { assert.True(t, ShouldScan(".cursorrules")) assert.True(t, ShouldScan("SKILL.md")) assert.True(t, ShouldScan("skill.md")) + assert.True(t, ShouldScan("plugin.json")) + assert.True(t, ShouldScan("Plugin.json")) + assert.True(t, ShouldScan(".lsp.json")) assert.False(t, ShouldScan("README.md")) assert.False(t, ShouldScan("main.go")) }