From 2dd6137bcf310a35acb9fb6991dbac056ea37406 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Mon, 5 Oct 2026 02:28:59 +0530 Subject: [PATCH 1/9] Add Copilot CLI plugin and MCP inventory --- CHANGELOG.md | 6 + README.md | 2 +- SCAN_COVERAGE.md | 5 +- internal/detector/mcp.go | 102 ++- internal/detector/mcp_copilot_test.go | 128 +++ internal/detector/mcp_discovery.go | 37 + internal/detector/plugins.go | 15 +- internal/detector/plugins_codex.go | 2 +- internal/detector/plugins_copilot.go | 866 ++++++++++++++++++ internal/detector/plugins_copilot_test.go | 523 +++++++++++ internal/executor/user_aware.go | 4 + internal/executor/user_aware_test.go | 33 + internal/model/agentplugins.go | 4 +- internal/model/agentplugins_copilot_test.go | 71 ++ .../agent_plugins_v1_copilot_golden.json | 154 ++++ internal/output/html.go | 19 +- internal/output/pretty.go | 9 +- 17 files changed, 1956 insertions(+), 24 deletions(-) create mode 100644 internal/detector/mcp_copilot_test.go create mode 100644 internal/detector/plugins_copilot.go create mode 100644 internal/detector/plugins_copilot_test.go create mode 100644 internal/model/agentplugins_copilot_test.go create mode 100644 internal/model/testdata/agent_plugins_v1_copilot_golden.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b174520..3709ddb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. +## [Unreleased] + +### Added + +- Copilot CLI recorded plugins and live directory-marketplace selections, supplied skills, custom-agent declarations, and sanitized plugin/user/project MCP definitions. Collection is file-only; uncertain formats report incomplete coverage. No Copilot usage or credential collection is added. + ## [1.17.0] - 2026-09-24 ### Added diff --git a/README.md b/README.md index 72f46225..6b76a0e9 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,7 @@ See [SCAN_COVERAGE.md](SCAN_COVERAGE.md) for the full catalog of supported detec | AI Agents | Claude Cowork, OpenClaw, ClawdBot, GPT-Engineer | | AI Frameworks | Ollama, LM Studio, LocalAI, Text Generation WebUI | | MCP Server Configs | Claude Desktop, Claude Code, Cursor, Windsurf, Antigravity, Zed, Open Interpreter, Codex, OpenCode | -| Agent Plugins & Skills | Claude Code and Codex plugin installations, declared components, standalone Claude commands, and recorded skill-use counters | +| Agent Plugins & Skills | Claude Code, Codex and GitHub Copilot CLI plugin installations, declared components, standalone Claude commands, and recorded skill-use counters | | IDE Extensions | VS Code, Cursor, Windsurf, Antigravity, JetBrains, Eclipse, Xcode, Android Studio | | Browser Extensions | Google Chrome, Microsoft Edge, Mozilla Firefox | | Node.js Packages | npm, yarn, pnpm, bun (opt-in) | diff --git a/SCAN_COVERAGE.md b/SCAN_COVERAGE.md index b452bce9..aa5304e4 100644 --- a/SCAN_COVERAGE.md +++ b/SCAN_COVERAGE.md @@ -137,16 +137,17 @@ Per skill, the scan records identity and frontmatter (name, description, version ## Agent Plugins, Commands and Recorded Skill Use -Claude Code and Codex plugin inventory runs in its own `agent_plugins_scan` phase after `agent_skills_scan`, with no feature gate. Standalone commands and recorded usage remain in the skills phase. Both phases reuse project discovery and parsed definitions, with separate deadlines and progress. Plugin collection reads native registration/configuration, selected materialized payloads and manifests. Installed, files present, configured enabled and effective enabled are separate observations; an unavailable value remains unknown. An old cache directory alone is not an installation. +Claude Code, Codex and GitHub Copilot CLI plugin inventory runs in its own `agent_plugins_scan` phase after `agent_skills_scan`, with no feature gate. Standalone commands and recorded usage remain in the skills phase. Both phases reuse project discovery and parsed definitions, with separate deadlines and progress. Plugin collection reads native registration/configuration, selected materialized payloads and manifests. Installed, files present, configured enabled and effective enabled are separate observations; an unavailable value remains unknown. An old cache directory alone is not an installation. - **Claude Code:** scoped version-2 installation records, registered and settings-declared catalogs, visible seed roots, manifest-bearing skill directories and synced payloads. Local directory catalogs use the original source. Skills, legacy commands, MCP servers, agents, hooks, LSP servers and declared apps retain their supplying plugin. - **Codex:** configured local/Git marketplaces, personal and discovered-project catalogs, selected versioned store payloads and recognized account markers. Portable manifests take precedence over compatible manifests. Portable skills and MCP roots are fixed; supported apps/hooks remain descriptive metadata. Account effective enablement remains unknown. +- **GitHub Copilot CLI:** recorded installations and selected live directory-marketplace payloads, including disabled selections. Collects skills, descriptive custom agents and shared/agent MCP declarations. Portable 1.0/1.1 and native legacy manifests use their own precedence. User `mcp-config.json` and discovered-project `.mcp.json`/`.github/mcp.json` retain sanitized standalone declarations. Runtime activation, credentials, usage, hooks and LSP inventory are outside this extension. Root-only skill fallback and path-valued MCP declarations follow pinned native fixtures. Unverified legacy store paths and remote catalog provenance remain incomplete. - **Standalone Claude commands:** user and discovered-project `commands/**/*.md` files retain their own paths and raw-byte hashes, independently of ordinary `SKILL.md` definitions. - **Recorded skill use:** Claude's `skillUsage` keys, cumulative counts (including zero) and native millisecond timestamps. Selected snapshots are attached to uniquely matching standalone or plugin definitions, including shared skills exposed through Claude symlinks. Ambiguous and unavailable usage is not zero; aliases and installation scopes are not summed. Unmatched counters are not uploaded separately. The collector never executes plugins, hooks, scripts, agent CLIs or network requests. MCP content uses the existing field allowlist and redaction. Plugin metadata and definition hashes are reported; instruction bodies, commands, credentials and complete settings files are not uploaded. Plugin-owned and stale-cache MCP declarations are excluded from ordinary MCP results, while unrelated MCP configurations retain existing coverage. -Reads are guarded and bounded: 5 MiB metadata, 1 MiB definitions, 1,024 plugin observations, 4,096 components, 2,000 new parsed definitions, 10,000 inspected native usage counters and an 8 MiB plugin envelope. Malformed, unreadable, unsupported or truncated scopes report incomplete coverage independently. Visible `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_PLUGIN_CACHE_DIR`, `CLAUDE_CODE_PLUGIN_SEED_DIR` and `CODEX_HOME` overrides are respected; overrides hidden from the scanning process cannot be discovered. Project presence does not prove session trust or activation. Native Windows project-plugin activation and account-synced delivery lifecycle remain outside the completed fixture validation. +Reads are guarded and bounded: 5 MiB metadata, 1 MiB definitions, 1,024 plugin observations, 4,096 components, 2,000 new parsed definitions, 10,000 inspected native usage counters and an 8 MiB plugin envelope. Malformed, unreadable, unsupported or truncated scopes report incomplete coverage independently. Visible `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_PLUGIN_CACHE_DIR`, `CLAUDE_CODE_PLUGIN_SEED_DIR` `CODEX_HOME`, `COPILOT_HOME` and independent `COPILOT_CACHE_HOME` overrides are respected; overrides hidden from the scanning process cannot be discovered. Project presence does not prove session trust or activation. Native Windows project-plugin activation and account-synced delivery lifecycle remain outside the completed fixture validation. ## IDE Extensions & Plugins diff --git a/internal/detector/mcp.go b/internal/detector/mcp.go index a311d8c4..b9840b32 100644 --- a/internal/detector/mcp.go +++ b/internal/detector/mcp.go @@ -34,6 +34,7 @@ var mcpConfigDefinitions = []mcpConfigSpec{ {"zed", "~/.config/zed/settings.json", "", "", "Zed"}, {"open_interpreter", "~/.config/open-interpreter/config.yaml", "", "", "OpenSource"}, {"codex", "~/.codex/config.toml", "", "", "OpenAI"}, + {"copilot", "~/.copilot/mcp-config.json", "", "", "GitHub"}, // VS Code and VS Code-based editors keep user-level MCP servers in // /User/mcp.json. These are targeted reads; on macOS the path // is under ~/Library, which the discovery walk deliberately never enters. @@ -92,8 +93,17 @@ func (d *MCPDetector) DetectEnterprise(_ context.Context, searchDirs []string) [ for _, loc := range d.allConfigLocations(homeDir, searchDirs) { reader := d.exec - if loc.SourceName == "codex" { - reader = reader.GuardedFiles([]string{filepath.Dir(loc.ConfigPath)}, func(path string) string { + if loc.SourceName == "codex" || loc.SourceName == model.AgentCopilot || isCopilotProjectMCP(loc.ConfigPath) { + roots := []string{filepath.Dir(loc.ConfigPath)} + if isCopilotProjectMCP(loc.ConfigPath) { + project := filepath.Dir(loc.ConfigPath) + if filepath.Base(project) == ".github" { + project = filepath.Dir(project) + } + roots = append(roots, homeDir, project) + roots = append(roots, searchDirs...) + } + reader = reader.GuardedFiles(roots, func(path string) string { if d.skipper.WithinProtected(path) { return "tcc_protected" } @@ -152,6 +162,9 @@ func (d *MCPDetector) discoverProjectMCPConfigs() []mcpConfigSpec { // resolveConfigPath returns the appropriate config path for the current platform. func (d *MCPDetector) resolveConfigPath(spec mcpConfigSpec, homeDir string) string { + if spec.SourceName == model.AgentCopilot { + return filepath.Join(copilotConfigRoot(d.exec, homeDir), "mcp-config.json") + } if spec.SourceName == "codex" { if root := d.exec.Getenv("CODEX_HOME"); filepath.IsAbs(root) { return filepath.Join(root, "config.toml") @@ -192,6 +205,10 @@ func (d *MCPDetector) filterMCPContent(sourceName, configPath string, content [] return nil, false // Non-JSON formats cannot be safely filtered } + if sourceName == model.AgentCopilot || sourceName == "copilot_project" { + return filterCopilotMCP(content, sourceName != model.AgentCopilot) + } + jsonInput := content // Strip JSONC comments for Zed @@ -204,7 +221,7 @@ func (d *MCPDetector) filterMCPContent(sourceName, configPath string, content [] // removes the comments but leaves the commas, which json.Unmarshal then // rejects — dropping the content and losing the servers. hujson handles // both, and is already this repo's front door for real-world JSONC. - if isOpenCodeConfigPath(configPath) { + if isOpenCodeConfigPath(configPath) || isCopilotProjectMCP(configPath) { standard, err := hujson.Standardize(jsonInput) if err != nil { return nil, false @@ -217,7 +234,23 @@ func (d *MCPDetector) filterMCPContent(sourceName, configPath string, content [] return nil, false // Can't parse; don't return raw content } + if isCopilotProjectMCP(configPath) && raw["mcpServers"] == nil && raw["context_servers"] == nil && raw["servers"] == nil && raw["mcp"] == nil { + return filterCopilotMCP(jsonInput, true) + } + filtered := d.extractMCPServers(raw) + projectServers, _ := filtered["mcpServers"].(map[string]any) + if isCopilotProjectMCP(configPath) && raw["mcpServers"] != nil && projectServers == nil { + if data, ok := filterCopilotMCP(jsonInput, false); ok { + var recovered map[string]any + if json.Unmarshal(data, &recovered) == nil { + if filtered == nil { + filtered = make(map[string]any) + } + filtered["mcpServers"] = recovered["mcpServers"] + } + } + } if filtered == nil { return nil, false // No MCP servers found } @@ -392,3 +425,66 @@ func stripJSONCComments(input []byte) []byte { } return out } + +// isCopilotProjectMCP limits bare-map parsing to documented project files. +func isCopilotProjectMCP(file string) bool { + return filepath.Base(file) == ".mcp.json" || filepath.Base(file) == "mcp.json" && filepath.Base(filepath.Dir(file)) == ".github" +} + +func filterCopilotMCP(content []byte, allowBare bool) ([]byte, bool) { + data, err := hujson.Standardize(content) + var doc map[string]json.RawMessage + if err != nil || json.Unmarshal(data, &doc) != nil || doc == nil { + return nil, false + } + var servers map[string]json.RawMessage + if raw, ok := doc["mcpServers"]; ok { + if json.Unmarshal(raw, &servers) != nil || servers == nil { + return nil, false + } + } else if allowBare { + servers = doc + } else { + return nil, false + } + filtered := map[string]any{} + for _, name := range sortedMapKeys(servers) { + if !validCopilotMCP(servers[name]) { + continue + } + one, _ := json.Marshal(map[string]json.RawMessage{name: servers[name]}) + for key, value := range filterServerFields(one) { + filtered[key] = value + } + } + out, err := json.Marshal(map[string]any{"mcpServers": filtered}) + return out, err == nil +} + +func validCopilotMCP(raw json.RawMessage) bool { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil || fields == nil { + return false + } + typ := "" + if raw, ok := fields["type"]; ok && !decodePortableValue(raw, &typ) { + return false + } + switch typ { + case "", "local", "stdio": + if command := jsonString(fields["command"]); command != "" { + if args, ok := fields["args"]; ok { + var values []string + if !decodePortableValue(args, &values) { + return false + } + } + return true + } + return typ == "" && jsonString(fields["url"]) != "" + case "http", "streamable-http", "sse": + return jsonString(fields["url"]) != "" + default: + return false + } +} diff --git a/internal/detector/mcp_copilot_test.go b/internal/detector/mcp_copilot_test.go new file mode 100644 index 00000000..8bdcc6ae --- /dev/null +++ b/internal/detector/mcp_copilot_test.go @@ -0,0 +1,128 @@ +package detector + +import ( + "context" + "encoding/base64" + "encoding/json" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/tcc" + "os" + "os/user" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestCopilotStandaloneMCP(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, "copilot-custom") + project := filepath.Join(testHome, "project") + m.SetEnv("COPILOT_HOME", root) + fs.addFile(filepath.Join(root, "mcp-config.json"), `// JSONC + {"mcpServers":{"local":{"type":"local","command":"node","args":["server.js"],"env":{"TOKEN":"SECRET_SENTINEL"}},"remote":{"type":"http","url":"https://docs.example/mcp","headers":{"Authorization":"SECRET_SENTINEL"}},"invalid":false,},}`) + fs.addFile(filepath.Join(project, ".github/mcp.json"), `{"project":{"type":"sse","url":"https://project.example/mcp"}}`) + fs.addFile(filepath.Join(testHome, ".claude.json"), `{"projects":{`+jsonQuote(project)+`:{}}}`) + fs.addFile(filepath.Join(project, ".mcp.json"), `{"mcpServers":{"shared":{"command":"node"}}}`) + fs.commit() + results := NewMCPDetector(m).DetectEnterprise(context.Background(), nil) + sources := map[string]string{} + for _, config := range results { + sources[config.ConfigPath] = config.ConfigSource + body, err := base64.StdEncoding.DecodeString(config.ConfigContentBase64) + if err != nil || strings.Contains(string(body), "SECRET_SENTINEL") { + t.Fatalf("unsafe MCP: %s", body) + } + if config.ConfigPath == filepath.Join(root, "mcp-config.json") { + if !strings.Contains(string(body), "local") || !strings.Contains(string(body), "remote") || strings.Contains(string(body), "invalid") { + t.Fatalf("valid siblings lost: %s", body) + } + } + } + if sources[filepath.Join(root, "mcp-config.json")] != "copilot" || sources[filepath.Join(project, ".github/mcp.json")] != "copilot_project" || sources[filepath.Join(project, ".mcp.json")] != "project_mcp" { + t.Fatalf("MCP attribution: %v", sources) + } +} + +func jsonQuote(value string) string { data, _ := json.Marshal(value); return string(data) } + +func TestCopilotBareMCPIsPathLimited(t *testing.T) { + d := &MCPDetector{} + raw := []byte(`{"docs":{"type":"http","url":"https://docs.example/mcp"}}`) + for _, tc := range []struct { + file string + ok bool + }{{"/repo/.mcp.json", true}, {"/repo/.github/mcp.json", true}, {"/repo/.vscode/mcp.json", false}, {"/repo/settings.json", false}} { + if _, ok := d.filterMCPContent("discovered_mcp", tc.file, raw); ok != tc.ok { + t.Errorf("%s: accepted=%v", tc.file, ok) + } + } +} + +func TestCopilotProjectMCPKeepsValidSiblings(t *testing.T) { + d := &MCPDetector{} + content := []byte(`{"mcpServers":{"good":{"type":"local","command":"node"},"bad":42},"mcp":null}`) + data, ok := d.filterMCPContent("discovered_mcp", "/project/.github/mcp.json", content) + if !ok || !strings.Contains(string(data), `"good"`) || strings.Contains(string(data), `"bad"`) || !strings.Contains(string(data), `"mcp":{}`) { + t.Fatalf("shared keys or valid siblings lost: %s", data) + } + for _, raw := range []string{`{"type":null,"command":"node"}`, `{"type":7,"command":"node"}`, `{"command":"node","args":null}`} { + if validCopilotMCP(json.RawMessage(raw)) { + t.Errorf("invalid server accepted: %s", raw) + } + } +} + +func TestSharedMCPRelativeSymlink(t *testing.T) { + for _, protected := range []bool{false, true} { + name := "shared" + if protected { + name = "protected" + } + t.Run(name, func(t *testing.T) { + if protected && runtime.GOOS != "darwin" { + t.Skip("macOS TCC paths") + } + project, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + target := "shared.json" + if protected { + target = "Library/shared.json" + } + for _, dir := range []string{filepath.Join(project, ".github"), filepath.Dir(filepath.Join(project, target))} { + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(project, target), []byte(`{"mcpServers":{"docs":{"command":"node"}}}`), 0600); err != nil { + t.Fatal(err) + } + file := filepath.Join(project, ".github/mcp.json") + if err := os.Symlink("../"+target, file); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + detector := NewMCPDetector(mcpFixtureExecutor{Executor: executor.NewReal(), home: project}).WithSkipper(tcc.New(project)) + found := false + for _, config := range detector.DetectEnterprise(context.Background(), []string{project}) { + if config.ConfigPath == file && config.ConfigContentBase64 != "" { + found = true + } + } + if found == protected { + t.Fatalf("collected=%v, protected=%v", found, protected) + } + }) + } +} + +type mcpFixtureExecutor struct { + executor.Executor + home string +} + +func (e mcpFixtureExecutor) LoggedInUser() (*user.User, error) { + return &user.User{HomeDir: e.home}, nil +} +func (e mcpFixtureExecutor) Getenv(string) string { return "" } diff --git a/internal/detector/mcp_discovery.go b/internal/detector/mcp_discovery.go index fbba9b0b..b135a86f 100644 --- a/internal/detector/mcp_discovery.go +++ b/internal/detector/mcp_discovery.go @@ -6,6 +6,9 @@ import ( "path/filepath" "runtime" "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" ) // mcpConfigBasenames are the filenames recognized as MCP configs wherever they @@ -101,6 +104,16 @@ func (d *MCPDetector) allConfigLocations(homeDir string, searchDirs []string) [] // (1) Known exact paths (includes ~/Library configs via targeted reads). for _, spec := range mcpConfigDefinitions { p := d.resolveConfigPath(spec, homeDir) + if spec.SourceName == model.AgentCopilot { + if executor.UserEnvironmentError(d.exec) != nil || d.skipper.WithinProtected(p) { + continue + } + reader := d.copilotMCPReader(filepath.Dir(p)) + if info, err := reader.Stat(p); err == nil && info.Mode().IsRegular() { + add(spec.SourceName, p, spec.Vendor) + } + continue + } if d.exec.FileExists(p) { add(spec.SourceName, p, spec.Vendor) } @@ -113,9 +126,33 @@ func (d *MCPDetector) allConfigLocations(homeDir string, searchDirs []string) [] for _, s := range d.discoverWalkedMCPConfigs(searchDirs, homeDir) { add(s.SourceName, s.ConfigPath, s.Vendor) } + + // Target already-known projects after existing discovery to retain attribution. + projects := append([]string{}, searchDirs...) + projects = append(projects, discoverClaudeProjects(d.exec)...) + for _, project := range projects { + for _, rel := range []string{".mcp.json", ".github/mcp.json"} { + file := filepath.Join(project, filepath.FromSlash(rel)) + if d.skipper.WithinProtected(file) { + continue + } + if info, err := d.copilotMCPReader(project).Stat(file); err == nil && info.Mode().IsRegular() { + add("copilot_project", file, "GitHub") + } + } + } return out } +func (d *MCPDetector) copilotMCPReader(root string) executor.Executor { + return d.exec.GuardedFiles([]string{root}, func(file string) string { + if d.skipper.WithinProtected(file) { + return "tcc_protected" + } + return "" + }, maxJSONConfigBytes) +} + // discoverWalkedMCPConfigs walks the configured search dirs and the per-user // IDE dotfile roots, recognizing MCP configs by basename. It never enters // ~/Library (TCC skipper), skips dependency/cache/build dirs and directory diff --git a/internal/detector/plugins.go b/internal/detector/plugins.go index 3510b114..40e60286 100644 --- a/internal/detector/plugins.go +++ b/internal/detector/plugins.go @@ -23,7 +23,7 @@ import ( ) // Agent plugin inventory: shared identity, caps, guarded reads and component -// construction for the Claude Code and Codex adapters. Everything here is a +// construction for the Claude Code, Codex and Copilot adapters. Everything here is a // filesystem read; no agent is ever executed. const ( @@ -52,7 +52,7 @@ const ( maxRecordedUses = 1<<53 - 1 ) -// pluginScan is the per-run state shared by both adapters: one observation +// pluginScan is the per-run state shared by the adapters: one observation // time, the SKILL.md parse memo shared with the ordinary skill walk, and the // envelope-wide budgets. type pluginScan struct { @@ -98,10 +98,13 @@ type pluginRootScan struct { attr nestedAttr } -// AgentVersions selects Claude Code and Codex versions from the AI CLI inventory. +// AgentVersions selects plugin agent versions from the AI CLI inventory. func AgentVersions(tools []model.AITool) map[string]string { out := map[string]string{} for _, t := range tools { + if t.Name == "github-copilot-cli" && t.Version != "" { + out[model.AgentCopilot] = t.Version + } if (t.Name == model.AgentClaudeCode || t.Name == model.AgentCodex) && t.Version != "" { out[t.Name] = t.Version } @@ -158,7 +161,7 @@ func (d *SkillsDetector) DetectPlugins(ctx context.Context, result *SkillsResult s.now = d.now() } var contexts []*model.AgentPluginContext - for _, c := range []*model.AgentPluginContext{s.detectClaude(), s.detectCodex()} { + for _, c := range []*model.AgentPluginContext{s.detectClaude(), s.detectCodex(), s.detectCopilot()} { if c != nil { // Missing projects can hide project settings and catalogs. if s.projectsIncomplete { @@ -949,7 +952,7 @@ func (r *pluginRootScan) mcpServerComponents(servers json.RawMessage, rel, point if pointer != "" { serverPointer += "/" + strings.NewReplacer("~", "~0", "/", "~1").Replace(name) } - if r.p.ManifestFormat == model.PluginManifestPortable { + if r.p.ManifestFormat == model.PluginManifestPortable && !(r.attr.agent == model.AgentCopilot && pointer == "/mcp-servers") { if code := r.portableMCPError(declarations[name]); code != "" { c := model.PluginComponent{Kind: model.PluginComponentMCP, Name: name, RelativePath: rel, DeclarationPointer: serverPointer, DefinitionPath: configPath} r.componentError(&c, code) @@ -1375,7 +1378,7 @@ func (r SkillsResult) replacesMCPConfig(source, p string, represented map[string if r.evidence == nil { return false } - if source == "project_mcp" { + if source == "project_mcp" || source == "copilot_project" { return false } for _, spec := range mcpConfigDefinitions { diff --git a/internal/detector/plugins_codex.go b/internal/detector/plugins_codex.go index 5bee1524..fd5e38a7 100644 --- a/internal/detector/plugins_codex.go +++ b/internal/detector/plugins_codex.go @@ -1145,7 +1145,7 @@ func (r *pluginRootScan) portableMCPError(raw json.RawMessage) string { if u.Scheme == "http" && u.Hostname() != "localhost" && !net.ParseIP(u.Hostname()).IsLoopback() { return bad } - if typ == "sse" { + if typ == "sse" && r.attr.agent != model.AgentCopilot { return model.AgentScanErrUnsupportedSchema } return "" diff --git a/internal/detector/plugins_copilot.go b/internal/detector/plugins_copilot.go new file mode 100644 index 00000000..ade7f7b9 --- /dev/null +++ b/internal/detector/plugins_copilot.go @@ -0,0 +1,866 @@ +package detector + +import ( + "encoding/json" + "path" + "path/filepath" + "strings" + + "github.com/tailscale/hujson" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +var copilotCatalogPaths = []string{"marketplace.json", ".plugin/marketplace.json", ".github/plugin/marketplace.json", ".claude-plugin/marketplace.json"} +var copilotManifestPaths = []string{".plugin/plugin.json", "plugin.json", ".github/plugin/plugin.json", ".claude-plugin/plugin.json"} + +type copilotLayer struct { + path, scope, project string + enabled map[string]json.RawMessage + markets map[string]json.RawMessage + disabledMCP []string +} + +type copilotAdapter struct { + s *pluginScan + gd *SkillsDetector + c *model.AgentPluginContext + root string + layers []copilotLayer + markets map[string]*model.MarketplaceObservation + cache string + catalogRoots map[string]string +} + +func copilotConfigRoot(exec executor.Executor, home string) string { + if root := exec.Getenv("COPILOT_HOME"); filepath.IsAbs(root) { + return filepath.Clean(root) + } + return filepath.Join(home, ".copilot") +} + +func copilotCacheRoot(exec executor.Executor, home string) string { + if root := exec.Getenv("COPILOT_CACHE_HOME"); filepath.IsAbs(root) { + return filepath.Clean(root) + } + switch exec.GOOS() { + case model.PlatformDarwin: + return filepath.Join(home, "Library", "Caches", "copilot") + case model.PlatformWindows: + if root := exec.Getenv("LOCALAPPDATA"); filepath.IsAbs(root) { + return filepath.Join(root, "copilot") + } + return "" + default: + if root := exec.Getenv("XDG_CACHE_HOME"); filepath.IsAbs(root) { + return filepath.Join(root, "copilot") + } + return filepath.Join(home, ".cache", "copilot") + } +} + +func (s *pluginScan) detectCopilot() *model.AgentPluginContext { + root := copilotConfigRoot(s.d.exec, s.home) + store := filepath.Join(root, "installed-plugins") + if executor.UserEnvironmentError(s.d.exec) != nil { + return s.unresolvedContext(model.AgentCopilot, root, store) + } + cache := copilotCacheRoot(s.d.exec, s.home) + gd := s.guarded(root, cache, filepath.Dir(copilotManagedSettingsPath(s.d.exec))) + state, _, _ := s.stat(gd, root) + // Project-only directory registrations can exist without a user config root. + if state != fileDir && state != fileAbsent { + return s.unresolvedContext(model.AgentCopilot, root, store) + } + a := &copilotAdapter{s: s, gd: gd, root: root, cache: cache} + restore := s.snapshotRetry() + for attempt := 0; ; attempt++ { + if attempt > 0 { + restore() + } + s.reads = map[string]pluginMetadataStamp{} + s.sourceChanged = false + a.c = s.newContext(model.AgentCopilot, root, store) + a.run() + if !s.snapshotChanged() { + break + } + if attempt == 1 { + a.fail(model.AgentScanErrSourceChanged, filepath.Join(root, "config.json")) + for i := range a.c.Plugins { + degrade(&a.c.Plugins[i].ComponentStatus, model.AgentScanStatusPartial) + } + break + } + } + s.evidence.suppress(store, filepath.Join(root, "plugin-data")) + if cache != "" { + s.evidence.suppress(filepath.Join(cache, "marketplaces")) + } + if state == fileAbsent && len(a.c.Plugins) == 0 && len(a.c.Marketplaces) == 0 && len(a.c.Errors) == 0 { + return nil + } + return a.c +} + +func (a *copilotAdapter) object(file string) (map[string]json.RawMessage, bool, string) { + data, absent, code := a.s.readMetadata(a.gd, file) + if absent || code != "" { + return nil, absent, code + } + data, err := hujson.Standardize(data) + var obj map[string]json.RawMessage + if err != nil || json.Unmarshal(data, &obj) != nil || obj == nil { + return nil, false, model.AgentScanErrParseFailed + } + return obj, false, "" +} + +func (a *copilotAdapter) fail(code, file string) { + degrade(&a.c.InstallationStatus, model.AgentScanStatusPartial) + degrade(&a.c.MarketplaceStatus, model.AgentScanStatusPartial) + scanError(&a.c.Errors, model.AgentScanError{Code: code, SourcePath: file}) +} + +func (a *copilotAdapter) run() { + a.markets = map[string]*model.MarketplaceObservation{} + a.catalogRoots = map[string]string{} + a.layers = nil + configPath := filepath.Join(a.root, "config.json") + state, _, code := a.object(configPath) + if code != "" { + a.fail(code, configPath) + } + a.settings(state) + records, exists := state["installedPlugins"] + if !exists { + records = state["installed_plugins"] + } + if len(records) > 0 { + var rows []json.RawMessage + if json.Unmarshal(records, &rows) != nil || rows == nil { + a.fail(model.AgentScanErrParseFailed, configPath) + } else { + for i, row := range rows { + if i >= maxPluginObs || a.s.ctx.Err() != nil { + a.fail(model.AgentScanErrLimitExceeded, configPath) + break + } + a.registry(row, configPath) + } + } + } + a.liveSelections() + for _, name := range sortedMapKeys(a.markets) { + a.c.Marketplaces = append(a.c.Marketplaces, *a.markets[name]) + } +} + +func (a *copilotAdapter) settings(state map[string]json.RawMessage) { + layers := []copilotLayer{{path: filepath.Join(a.root, "settings.json"), scope: model.PluginScopeUser}, {path: copilotManagedSettingsPath(a.s.d.exec), scope: model.PluginScopeSystem}} + for _, project := range a.s.projects { + for _, entry := range []struct{ rel, scope string }{{".github/copilot/settings.json", model.PluginScopeProject}, {".github/copilot/settings.local.json", model.PluginScopeLocal}, {".claude/settings.json", model.PluginScopeProject}, {".claude/settings.local.json", model.PluginScopeLocal}} { + layers = append(layers, copilotLayer{path: filepath.Join(project, filepath.FromSlash(entry.rel)), scope: entry.scope, project: project}) + } + } + for _, layer := range layers { + doc, absent, code := a.object(layer.path) + if absent && layer.scope == model.PluginScopeUser && state != nil { + doc, absent = state, false + layer.path = filepath.Join(a.root, "config.json") + } + if absent { + continue + } + if code != "" { + a.fail(code, layer.path) + continue + } + for key, dst := range map[string]*map[string]json.RawMessage{"enabledPlugins": &layer.enabled, "extraKnownMarketplaces": &layer.markets} { + if raw, ok := doc[key]; ok && (json.Unmarshal(raw, dst) != nil || *dst == nil) { + a.fail(model.AgentScanErrParseFailed, layer.path) + } + } + if raw, ok := doc["disabledMcpServers"]; ok { + if json.Unmarshal(raw, &layer.disabledMCP) != nil { + a.fail(model.AgentScanErrParseFailed, layer.path) + } + } + a.layers = append(a.layers, layer) + for _, name := range sortedMapKeys(layer.markets) { + if len(a.markets) >= maxMarketplaceObs { + a.fail(model.AgentScanErrLimitExceeded, layer.path) + break + } + var entry map[string]json.RawMessage + if json.Unmarshal(layer.markets[name], &entry) != nil || entry == nil { + a.fail(model.AgentScanErrParseFailed, layer.path) + continue + } + source := copilotSource(entry["source"]) + m := a.market(name) + m.Registered = true + if m.Source != nil && source != nil { + old, _ := json.Marshal(m.Source) + current, _ := json.Marshal(source) + if string(old) != string(current) { + a.fail(model.AgentScanErrUnsupportedSchema, layer.path) + m.Source = &model.SourceLocator{Kind: model.PluginSourceUnknown} + continue + } + } + m.Source = source + a.catalogRoots[name] = copilotCatalogRoot(entry["source"], a.cache) + if enabled := jsonBool(entry["autoUpdate"]); enabled != nil && len(m.AutoUpdatePreferences) < maxAutoUpdatePrefs { + m.AutoUpdatePreferences = append(m.AutoUpdatePreferences, model.EnablementObservation{Scope: layer.scope, ProjectPath: layer.project, SourcePath: layer.path, Enabled: *enabled}) + if layer.scope == model.PluginScopeUser { + m.AutoUpdateEnabled = enabled + } + } + } + } +} + +func (a *copilotAdapter) market(name string) *model.MarketplaceObservation { + if m := a.markets[name]; m != nil { + return m + } + m := &model.MarketplaceObservation{MarketplaceID: marketplaceID(a.c.ContextID, name), Name: name} + a.markets[name] = m + + return m +} + +func copilotSource(raw json.RawMessage) *model.SourceLocator { + if value := jsonString(raw); value != "" { + switch { + case isAbsPath(value): + return &model.SourceLocator{Kind: model.PluginSourceLocal, NativeKind: "local", Location: cleanPluginPath(value)} + case strings.Contains(value, "://"), scpLikeRE.MatchString(value): + return &model.SourceLocator{Kind: model.PluginSourceGit, NativeKind: "url", Location: sanitizeLocation(value)} + default: + repo, sub, _ := strings.Cut(value, ":") + if len(strings.Split(repo, "/")) == 2 && !strings.ContainsAny(repo, "?# ") { + loc := &model.SourceLocator{Kind: model.PluginSourceGitHub, NativeKind: "github", Location: githubLocation(repo), Subdirectory: sub} + if validSource(loc) { + return loc + } + } + return &model.SourceLocator{Kind: model.PluginSourceUnknown} + } + } + + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil || fields == nil { + return nil + } + native := jsonString(fields["source"]) + loc := &model.SourceLocator{NativeKind: native, Kind: model.PluginSourceUnknown, RequestedRef: jsonString(fields["ref"]), RequestedSHA: jsonString(fields["sha"])} + switch native { + case "directory", "local": + loc.Kind = model.PluginSourceLocal + if p := jsonString(fields["path"]); isAbsPath(p) { + loc.Location = cleanPluginPath(p) + } + case "github": + loc.Kind = model.PluginSourceGitHub + repo, sub, _ := strings.Cut(jsonString(fields["repo"]), ":") + loc.Location = githubLocation(repo) + loc.Subdirectory = sub + case "git", "url": + loc.Kind = model.PluginSourceGit + loc.Location = sanitizeLocation(jsonString(fields["url"])) + } + if loc.Kind == model.PluginSourceGit || loc.Kind == model.PluginSourceGitHub { + if sub := jsonString(fields["path"]); sub != "" { + loc.Subdirectory = sub + } + } + if !validSource(loc) { + return &model.SourceLocator{Kind: model.PluginSourceUnknown, NativeKind: native} + } + return loc +} + +// Copilot 1.0.91's native cache-path helper keys GitHub by repo and Git by URL. +func copilotCatalogRoot(raw json.RawMessage, cache string) string { + source := copilotSource(raw) + if source == nil { + return "" + } + if source.Kind == model.PluginSourceLocal { + return source.Location + } + if cache == "" { + return "" + } + var fields map[string]json.RawMessage + _ = json.Unmarshal(raw, &fields) + value := jsonString(raw) + var key string + switch source.Kind { + case model.PluginSourceGitHub: + if fields != nil { + value = jsonString(fields["repo"]) + } + if strings.ContainsAny(value, "?#@") { + return "" + } + key = strings.Replace(value, "/", "-", 1) + case model.PluginSourceGit: + if fields != nil { + value = jsonString(fields["url"]) + } + if sanitizeLocation(value) != value { + return "" + } + key = strings.Map(func(r rune) rune { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' { + return r + } + return '-' + }, value) + default: + return "" + } + if key == "" { + return "" + } + root, safe := insideRoot(filepath.Join(cache, "marketplaces"), key) + if !safe { + return "" + } + return root +} + +func (a *copilotAdapter) registry(raw json.RawMessage, file string) { + var record map[string]json.RawMessage + if json.Unmarshal(raw, &record) != nil || record == nil { + a.fail(model.AgentScanErrParseFailed, file) + return + } + name := jsonString(record["name"]) + var market string + if strings.TrimSpace(name) == "" || len(name) > maxNameBytes || !decodePortableValue(record["marketplace"], &market) { + a.fail(model.AgentScanErrParseFailed, file) + return + } + native, kind := name, model.PluginInstallDirectory + if market != "" { + native, kind = name+"@"+market, model.PluginInstallMarketplace + } + p := newPlugin(native, name, kind, model.PluginScopeUser) + p.Installed = boolPtr(true) + p.InstallationEvidence = model.PluginEvidenceRegistry + p.InstallPath = cleanPluginPath(jsonString(record["cache_path"])) + p.Source = copilotSource(record["source"]) + if p.Source != nil { + if p.Source.Kind == model.PluginSourceLocal { + p.SourcePath = p.Source.Location + } + } + p.CacheVersion = jsonString(record["version"]) + p.InstalledAtMs = parseNativeTimeMs(jsonString(record["installed_at"])) + p.ConfiguredEnabled = jsonBool(record["enabled"]) + if p.ConfiguredEnabled != nil { + p.Enablement = append(p.Enablement, model.EnablementObservation{Scope: p.Scope, SourcePath: file, Enabled: *p.ConfiguredEnabled}) + } + if market != "" { + m := a.market(market) + if m.Source == nil && !m.Registered && a.c.MarketplaceStatus == model.AgentScanStatusComplete && (market == "copilot-plugins" || market == "awesome-copilot") { + raw, _ := json.Marshal("github/" + market) + m.Source = copilotSource(raw) + a.catalogRoots[market] = copilotCatalogRoot(raw, a.cache) + } + p.MarketplaceID = m.MarketplaceID + root := a.catalogRoots[market] + if root == "" || m.Source == nil || m.Source.Kind == model.PluginSourceUnknown { + copilotComponentError(p, model.AgentScanErrRootUnresolved, a.root) + degrade(&a.c.MarketplaceStatus, model.AgentScanStatusPartial) + } else if entry, found := a.catalogEntry(m, root, name); !found { + copilotComponentError(p, model.AgentScanErrReadFailed, m.CatalogPath) + } else if p.Source == nil { + if declared := jsonString(entry["source"]); declared != "" && !isAbsPath(declared) && !strings.Contains(declared, ":") { + if _, safe := insideRoot(root, declared); safe { + source := *m.Source + if source.Kind == model.PluginSourceLocal { + source.Location, _ = insideRoot(root, declared) + p.SourcePath = source.Location + } else { + source.Subdirectory = path.Join(source.Subdirectory, declared) + } + p.Source = &source + } + } else { + p.Source = copilotSource(entry["source"]) + } + } + } + if p.Source != nil { + p.Source.ResolvedRevision = jsonString(record["source_sha"]) + } + if p.InstallPath == "" || !isAbsPath(p.InstallPath) { + p.InstallPath = "" + a.fail(model.AgentScanErrRootUnresolved, file) + p.ComponentStatus = model.AgentScanStatusPartial + scanError(&p.Errors, model.AgentScanError{Code: model.AgentScanErrRootUnresolved, SourcePath: file}) + } + a.finish(p) +} + +func (a *copilotAdapter) finish(p *model.PluginObservation) { + p.InstanceID = a.s.instanceID(a.c.ContextID, p) + for _, layer := range a.layers { + if raw, ok := layer.enabled[p.NativeID]; ok { + if enabled := jsonBool(raw); enabled != nil { + p.Enablement = append(p.Enablement, model.EnablementObservation{Scope: layer.scope, SourcePath: layer.path, ProjectPath: layer.project, Enabled: *enabled}) + } else { + a.fail(model.AgentScanErrParseFailed, layer.path) + } + } + } + if p.InstallPath != "" { + p.FilesPresent = a.s.dirExists(a.gd, p.InstallPath) + if p.FilesPresent != nil && *p.FilesPresent { + a.components(p) + } else { + degrade(&p.ComponentStatus, model.AgentScanStatusPartial) + scanError(&p.Errors, model.AgentScanError{Code: model.AgentScanErrReadFailed, SourcePath: p.InstallPath}) + } + } + for i := range p.Components { + component := &p.Components[i] + if component.Kind != model.PluginComponentMCP { + continue + } + for _, layer := range a.layers { + for _, name := range layer.disabledMCP { + if name == component.Name { + component.MCPEnablement = append(component.MCPEnablement, model.EnablementObservation{Scope: layer.scope, SourcePath: layer.path, ProjectPath: layer.project, Enabled: false}) + break + } + } + } + } + if p.InstallationEvidence == model.PluginEvidenceLocalConfig && p.ManifestName == "" { + a.fail(model.AgentScanErrParseFailed, p.InstallPath) + return + } + if p.FilesPresent != nil && *p.FilesPresent && p.ManifestName != "" { + a.s.evidence.suppress(p.InstallPath) + } + a.s.addPlugin(a.c, p) +} + +func (a *copilotAdapter) liveSelections() { + for _, layer := range a.layers { + for _, native := range sortedMapKeys(layer.enabled) { + if len(a.c.Plugins) >= maxPluginObs || a.s.ctx.Err() != nil { + a.fail(model.AgentScanErrLimitExceeded, layer.path) + return + } + enabled := jsonBool(layer.enabled[native]) + name, market, ok := strings.Cut(native, "@") + if enabled == nil || !ok || name == "" || market == "" { + a.fail(model.AgentScanErrParseFailed, layer.path) + continue + } + registered := false + for _, p := range a.c.Plugins { + if p.NativeID == native && p.InstallationEvidence == model.PluginEvidenceRegistry { + registered = true + break + } + } + if registered { + if m := a.markets[market]; m != nil && m.Source != nil && m.Source.Kind == model.PluginSourceLocal { + a.fail(model.AgentScanErrUnsupportedSchema, layer.path) + } + continue + } + m := a.markets[market] + if m == nil || m.Source == nil || m.Source.Kind != model.PluginSourceLocal || m.Source.Location == "" { + a.fail(model.AgentScanErrRootUnresolved, layer.path) + continue + } + registeredForScope := false + for _, registration := range a.layers { + if _, ok := registration.markets[market]; ok && (registration.scope == model.PluginScopeUser || registration.scope == model.PluginScopeSystem || registration.project == layer.project && layer.project != "") { + registeredForScope = true + break + } + } + if !registeredForScope { + a.fail(model.AgentScanErrRootUnresolved, layer.path) + continue + } + alreadySelected := false + for _, p := range a.c.Plugins { + if p.NativeID == native && p.InstallationEvidence == model.PluginEvidenceLocalConfig && (p.Scope == model.PluginScopeUser || p.Scope == model.PluginScopeSystem || p.ProjectPath == layer.project) { + alreadySelected = true + break + } + } + if alreadySelected { + continue + } + root := m.Source.Location + entry, found := a.catalogEntry(m, root, name) + if !found { + continue + } + declared := jsonString(entry["source"]) + payload, safe := insideRoot(root, declared) + if !safe { + a.fail(model.AgentScanErrUnsafePath, m.CatalogPath) + continue + } + gd := a.gd.componentReader(root) + if st, _, _ := a.s.stat(gd, payload); st != fileDir { + a.fail(model.AgentScanErrReadFailed, payload) + continue + } + p := newPlugin(native, name, model.PluginInstallMarketplace, layer.scope) + p.MarketplaceID = m.MarketplaceID + p.ProjectPath = layer.project + p.InstallPath, p.SourcePath = payload, payload + p.Source = &model.SourceLocator{Kind: model.PluginSourceLocal, NativeKind: "local", Location: payload} + p.Installed = boolPtr(true) + p.InstallationEvidence = model.PluginEvidenceLocalConfig + p.ConfiguredEnabled = enabled + a.finish(p) + } + } +} + +func (a *copilotAdapter) catalogEntry(m *model.MarketplaceObservation, root, name string) (map[string]json.RawMessage, bool) { + if st, _, err := a.s.stat(a.gd, root); st != fileDir { + a.fail(readCode(err), root) + return nil, false + } + local := *a + local.gd = a.gd.componentReader(root) + for _, rel := range copilotCatalogPaths { + file := filepath.Join(root, filepath.FromSlash(rel)) + doc, absent, code := local.object(file) + if absent { + continue + } + m.CatalogPath = file + if code != "" { + a.fail(code, file) + return nil, false + } + var owner map[string]json.RawMessage + if jsonString(doc["name"]) != m.Name || json.Unmarshal(doc["owner"], &owner) != nil || strings.TrimSpace(jsonString(owner["name"])) == "" { + a.fail(model.AgentScanErrParseFailed, file) + return nil, false + } + var entries []json.RawMessage + if json.Unmarshal(doc["plugins"], &entries) != nil || entries == nil { + a.fail(model.AgentScanErrParseFailed, file) + return nil, false + } + payloadRoot := root + if metadata := doc["metadata"]; len(metadata) > 0 { + var fields map[string]json.RawMessage + if json.Unmarshal(metadata, &fields) != nil { + a.fail(model.AgentScanErrParseFailed, file) + return nil, false + } + if raw, ok := fields["pluginRoot"]; ok { + var safe bool + payloadRoot, safe = insideRoot(root, jsonString(raw)) + if !safe { + a.fail(model.AgentScanErrUnsafePath, file) + return nil, false + } + } + } + var selected map[string]json.RawMessage + for i, raw := range entries { + if i >= maxPluginObs { + a.fail(model.AgentScanErrLimitExceeded, file) + return nil, false + } + var entry map[string]json.RawMessage + if json.Unmarshal(raw, &entry) != nil { + a.fail(model.AgentScanErrParseFailed, file) + continue + } + if declared := jsonString(entry["source"]); declared != "" && !strings.Contains(declared, ":") { + target, safe := insideRoot(payloadRoot, declared) + if !safe { + a.fail(model.AgentScanErrUnsafePath, file) + return nil, false + } + rel, _ := relSlash(root, target) + entry["source"], _ = json.Marshal(rel) + if target != root { + a.s.evidence.suppress(target) + } + } + if jsonString(entry["name"]) == name { + if selected != nil { + a.fail(model.AgentScanErrParseFailed, file) + return nil, false + } + selected = entry + } + } + if selected != nil { + return selected, true + } + a.fail(model.AgentScanErrRootUnresolved, file) + return nil, false + } + a.fail(model.AgentScanErrReadFailed, root) + return nil, false +} + +func copilotComponentError(p *model.PluginObservation, code, file string) { + degrade(&p.ComponentStatus, model.AgentScanStatusPartial) + scanError(&p.Errors, model.AgentScanError{Code: code, SourcePath: file, InstanceID: p.InstanceID}) +} + +func (a *copilotAdapter) components(p *model.PluginObservation) { + local := *a + a = &local + a.gd = a.gd.componentReader(p.InstallPath) + rootDoc, absent, code := a.object(filepath.Join(p.InstallPath, "plugin.json")) + if code != "" { + copilotComponentError(p, code, filepath.Join(p.InstallPath, "plugin.json")) + return + } + var doc map[string]json.RawMessage + schema := jsonString(rootDoc["$schema"]) + if schema != "" { + if schema != codexPortableSchema && schema != "https://agent-plugins.org/schemas/1.1.0/plugin.schema.json" { + p.ComponentStatus = model.AgentScanStatusUnsupported + scanError(&p.Errors, model.AgentScanError{Code: model.AgentScanErrUnsupportedSchema, SourcePath: filepath.Join(p.InstallPath, "plugin.json")}) + return + } + if _, ok := parsePortableManifest(rootDoc); !ok { + copilotComponentError(p, model.AgentScanErrParseFailed, filepath.Join(p.InstallPath, "plugin.json")) + return + } + doc = rootDoc + p.ManifestFormat = model.PluginManifestPortable + p.ManifestPath = filepath.Join(p.InstallPath, "plugin.json") + } else { + for _, rel := range copilotManifestPaths { + file := filepath.Join(p.InstallPath, filepath.FromSlash(rel)) + var missing bool + if rel == "plugin.json" { + doc, missing = rootDoc, absent + } else { + doc, missing, code = a.object(file) + } + if missing { + continue + } + if code != "" { + copilotComponentError(p, code, file) + return + } + p.ManifestFormat = model.PluginManifestCopilot + if strings.HasPrefix(rel, ".claude-plugin/") { + p.ManifestFormat = model.PluginManifestClaude + } + p.ManifestPath = file + break + } + } + if doc == nil { + p.ManifestFormat = model.PluginManifestNone + copilotComponentError(p, model.AgentScanErrReadFailed, p.InstallPath) + return + } + p.ManifestName = jsonString(doc["name"]) + if p.ManifestName == "" { + copilotComponentError(p, model.AgentScanErrParseFailed, p.ManifestPath) + return + } + p.ManifestVersion = jsonString(doc["version"]) + p.Description = truncRunes(jsonString(doc["description"]), maxDescriptionRunes) + p.Publisher = truncRunes(nameField(doc["author"]), maxNameBytes) + p.Homepage = sanitizeHomepage(jsonString(doc["homepage"])) + r := &pluginRootScan{s: a.s, gd: a.gd, p: p, root: p.InstallPath, attr: nestedAttr{agent: model.AgentCopilot, source: "copilot_plugin", vendor: "GitHub", scope: nestedScope(p.Scope), projectPath: p.ProjectPath}} + portable := p.ManifestFormat == model.PluginManifestPortable + skills, agents := []string{"skills"}, []string{"agents"} + if portable { + agents = []string{"com.github.copilot/agents"} + } else { + for key, dst := range map[string]*[]string{"skills": &skills, "agents": &agents} { + if raw, ok := doc[key]; ok { + *dst = stringList(raw) + if *dst == nil { + copilotComponentError(p, model.AgentScanErrUnsupportedSchema, p.ManifestPath) + } + } + } + } + for _, rel := range skills { + dir, safe := insideRoot(p.InstallPath, rel) + if !safe { + copilotComponentError(p, model.AgentScanErrUnsafePath, p.ManifestPath) + continue + } + dirs := r.skillDirs(dir, !portable) + if !portable { + entries, code := a.s.listDir(a.gd, dir) + if code != "" { + copilotComponentError(p, code, dir) + } + if pluginSkillMD(entries) { + dirs = []string{dir} + } + } + for _, dir := range dirs { + rel, _ := relSlash(p.InstallPath, dir) + r.skillComponent(dir, rel, path.Base(rel), "") + } + } + if !portable && doc["skills"] == nil { + if st, _, _ := a.s.stat(a.gd, filepath.Join(p.InstallPath, "skills")); st == fileAbsent { + entries, code := a.s.listDir(a.gd, p.InstallPath) + if code != "" { + copilotComponentError(p, code, p.InstallPath) + } else if pluginSkillMD(entries) { + r.skillComponent(p.InstallPath, ".", p.Name, "") + } + } + } + for _, rel := range agents { + dir, safe := insideRoot(p.InstallPath, rel) + if !safe { + copilotComponentError(p, model.AgentScanErrUnsafePath, p.ManifestPath) + continue + } + for _, file := range r.markdownFiles(dir) { + a.agent(r, file) + } + } + if portable { + a.mcpFile(r, "mcp.json", strings.Replace(schema, "plugin.schema.json", "mcp.schema.json", 1)) + } else { + for _, rel := range []string{".mcp.json", ".github/mcp.json"} { + if a.mcpFile(r, rel, "") { + return + } + } + if raw, ok := doc["mcpServers"]; ok { + if declared := jsonString(raw); declared != "" { + file, safe := insideRoot(p.InstallPath, declared) + if !safe { + copilotComponentError(p, model.AgentScanErrUnsafePath, p.ManifestPath) + return + } + rel, _ := relSlash(p.InstallPath, file) + if !a.mcpFile(r, rel, "") { + copilotComponentError(p, model.AgentScanErrReadFailed, file) + } + return + } + rel, _ := relSlash(p.InstallPath, p.ManifestPath) + a.mcpComponents(r, raw, rel, "/mcpServers", p.ManifestPath) + } + } +} + +func (a *copilotAdapter) mcpFile(r *pluginRootScan, rel, schema string) bool { + file := filepath.Join(r.root, filepath.FromSlash(rel)) + doc, absent, code := a.object(file) + if absent { + return false + } + if code != "" { + copilotComponentError(r.p, code, file) + return true + } + if schema != "" && (jsonString(doc["$schema"]) != schema || len(doc) != 2 || doc["mcpServers"] == nil) { + copilotComponentError(r.p, model.AgentScanErrUnsupportedSchema, file) + return true + } + raw, pointer := doc["mcpServers"], "/mcpServers" + if raw == nil { + raw, _ = json.Marshal(doc) + pointer = "" + } + a.mcpComponents(r, raw, rel, pointer, file) + a.s.evidence.owned[file] = true + return true +} + +func (a *copilotAdapter) agent(r *pluginRootScan, file string) { + if !r.takeDefinition() { + return + } + if _, info, _ := a.s.stat(a.gd, file); info != nil && info.Size() > maxSkillMDReadBytes { + copilotComponentError(r.p, model.AgentScanErrLimitExceeded, file) + return + } + data, absent, code := a.s.readMetadata(a.gd, file) + if absent || code != "" { + copilotComponentError(r.p, model.AgentScanErrReadFailed, file) + return + } + if len(data) > maxSkillMDReadBytes { + copilotComponentError(r.p, model.AgentScanErrLimitExceeded, file) + return + } + fm, _, ok := splitFrontmatter(string(data)) + fields, err := parseYAMLMap(fm) + if !ok || err != nil { + copilotComponentError(r.p, model.AgentScanErrParseFailed, file) + return + } + description, _ := fields["description"].(string) + if strings.TrimSpace(description) == "" { + copilotComponentError(r.p, model.AgentScanErrParseFailed, file) + return + } + rel, _ := relSlash(r.root, file) + name := strings.TrimSuffix(strings.TrimSuffix(path.Base(rel), ".md"), ".agent") + r.declared(model.PluginComponentAgent, name, rel, "", file, model.AgentScanStatusComplete) + if servers, ok := fields["mcp-servers"]; ok { + raw, err := json.Marshal(servers) + if err != nil { + copilotComponentError(r.p, model.AgentScanErrParseFailed, file) + return + } + a.mcpComponents(r, raw, rel, "/mcp-servers", file) + } +} + +func (a *copilotAdapter) mcpComponents(r *pluginRootScan, raw json.RawMessage, rel, pointer, file string) { + var servers map[string]json.RawMessage + if json.Unmarshal(raw, &servers) != nil || servers == nil { + copilotComponentError(r.p, model.AgentScanErrParseFailed, file) + return + } + for _, name := range sortedMapKeys(servers) { + if !validCopilotMCP(servers[name]) { + copilotComponentError(r.p, model.AgentScanErrParseFailed, file) + delete(servers, name) + } + } + raw, _ = json.Marshal(servers) + r.mcpServerComponents(raw, rel, pointer, file) +} + +func copilotManagedSettingsPath(exec executor.Executor) string { + switch exec.GOOS() { + case model.PlatformDarwin: + return "/Library/Application Support/GitHubCopilot/managed-settings.json" + case model.PlatformWindows: + root := exec.Getenv("ProgramFiles") + if root == "" { + root = `C:\Program Files` + } + return filepath.Join(root, "GitHubCopilot", "managed-settings.json") + default: + return "/etc/github-copilot/managed-settings.json" + } +} diff --git a/internal/detector/plugins_copilot_test.go b/internal/detector/plugins_copilot_test.go new file mode 100644 index 00000000..d3906b38 --- /dev/null +++ b/internal/detector/plugins_copilot_test.go @@ -0,0 +1,523 @@ +package detector + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/tcc" +) + +func copilotContext(t *testing.T, result SkillsResult) model.AgentPluginContext { + t.Helper() + if result.Plugins != nil { + for _, c := range result.Plugins.Contexts { + if c.Agent == model.AgentCopilot { + return c + } + } + } + t.Fatal("missing Copilot context") + return model.AgentPluginContext{} +} + +func TestCopilotRecordedInstallComponents(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(root, "installed-plugins", "_direct", "release-checks") + record := fmt.Sprintf(`{"name":"release-checks","marketplace":"","cache_path":%q,"version":"1.0.0","installed_at":"2026-10-05T00:00:00Z","enabled":false,"source":{"source":"local","path":%q}}`, payload, filepath.Join(testHome, "source")) + fs.addFile(filepath.Join(root, "config.json"), "// Generated state\n{\"installedPlugins\":["+record+",null]}") + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"release-checks","skills":["custom"],"mcpServers":{"loser":{"url":"https://inline.example/mcp"}}}`) + fs.addFile(filepath.Join(payload, "skills/ignored/SKILL.md"), validFrontmatter("ignored", "Ignored default")) + fs.addFile(filepath.Join(payload, "custom/check/SKILL.md"), validFrontmatter("declared-check", "Check releases")) + fs.addFile(filepath.Join(payload, "agents/reviewer.agent.md"), "---\nname: Display name\ndescription: Review a release\nmcp-servers:\n 'agent/docs~v1':\n type: http\n url: https://agent.example/mcp\n headers:\n Authorization: FAKE_SECRET_SENTINEL\n---\nPROMPT_MUST_NOT_LEAVE_DEVICE\n") + fs.addFile(filepath.Join(payload, ".mcp.json"), `{"mcpServers":{"winner":{"type":"http","url":"https://docs.example/mcp","env":{"TOKEN":"FAKE_SECRET_SENTINEL"}},"invalid":42}}`) + fs.addFile(filepath.Join(payload, ".github/mcp.json"), `{"mcpServers":{"loser":{"url":"https://github.example/mcp"}}}`) + fs.addFile(filepath.Join(root, "installed-plugins/orphan/.plugin/plugin.json"), `{"name":"orphan"}`) + fs.commit() + versions := AgentVersions([]model.AITool{{Name: "github-copilot-cli", Version: "1.0.91"}}) + c := copilotContext(t, NewSkillsDetector(m).WithAgentVersions(versions).DetectAll(context.Background(), nil, nil)) + if c.AgentVersion != "1.0.91" { + t.Fatal("existing CLI version was not mapped to Copilot") + } + if c.InstallationStatus == model.AgentScanStatusComplete || len(c.Plugins) != 1 { + t.Fatalf("registry siblings: %+v", c) + } + p := c.Plugins[0] + if p.Installed == nil || !*p.Installed || p.FilesPresent == nil || !*p.FilesPresent || p.ConfiguredEnabled == nil || *p.ConfiguredEnabled || p.EffectiveEnabled != nil || p.InstalledAtMs == nil { + t.Fatalf("installation observations: %+v", p) + } + if p.SourcePath != filepath.Join(testHome, "source") || p.ComponentStatus == model.AgentScanStatusComplete || len(p.Components) != 4 { + t.Fatalf("components: %+v", p) + } + found := map[string]bool{} + for _, component := range p.Components { + found[component.Name] = true + if component.Skill != nil && (component.Skill.Agent != model.AgentCopilot || component.Skill.Source != "copilot_plugin" || component.Skill.Usage != nil || len(component.CallableNames) != 0) { + t.Fatalf("skill attribution: %+v", component) + } + if component.Name == "agent/docs~v1" && component.DeclarationPointer != "/mcp-servers/agent~1docs~0v1" { + t.Fatalf("agent pointer: %+v", component) + } + if component.MCPConfig != nil { + body, err := base64.StdEncoding.DecodeString(component.MCPConfig.ConfigContentBase64) + if err != nil || strings.Contains(string(body), "FAKE_SECRET_SENTINEL") || component.MCPConfig.ConfigSource != "copilot_plugin" { + t.Fatalf("MCP content: %s", body) + } + } + } + for _, name := range []string{"declared-check", "reviewer", "winner", "agent/docs~v1"} { + if !found[name] { + t.Errorf("missing %s", name) + } + } + body, _ := json.Marshal(c) + if strings.Contains(string(body), "PROMPT_MUST_NOT_LEAVE_DEVICE") || strings.Contains(string(body), "FAKE_SECRET_SENTINEL") { + t.Fatal("private contents leaked") + } +} + +func TestCopilotCanonicalStateAndMissingPayload(t *testing.T) { + for _, tc := range []struct { + name, state string + count int + complete bool + }{ + {"empty", `{"installedPlugins":[]}`, 0, true}, + {"canonical invalid beats alias", `{"installedPlugins":{},"installed_plugins":[{"name":"old","marketplace":""}]}`, 0, false}, + {"canonical null beats alias", `{"installedPlugins":null,"installed_plugins":[{"name":"old","marketplace":""}]}`, 0, false}, + {"malformed", `{`, 0, false}, + {"legacy", `{"installed_plugins":[{"name":"legacy","marketplace":""}]}`, 1, false}, + {"missing payload", fmt.Sprintf(`{"installedPlugins":[{"name":"missing","marketplace":"","cache_path":%q}]}`, filepath.Join(testHome, "missing")), 1, true}, + } { + t.Run(tc.name, func(t *testing.T) { + m, fs := newPluginMock() + fs.addFile(filepath.Join(testHome, ".copilot/config.json"), tc.state) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins) != tc.count || (c.InstallationStatus == model.AgentScanStatusComplete) != tc.complete { + t.Fatalf("coverage: %+v", c) + } + for _, p := range c.Plugins { + if p.ComponentStatus == model.AgentScanStatusComplete || len(p.Components) != 0 { + t.Fatalf("missing payload complete: %+v", p) + } + } + }) + } +} + +func TestCopilotLiveSelectionAndProjectPreferences(t *testing.T) { + for _, enabled := range []bool{true, false} { + t.Run(fmt.Sprint(enabled), func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + catalog := filepath.Join(testHome, "catalog") + project := filepath.Join(testHome, "project") + fs.addFile(filepath.Join(root, "config.json"), `{"installedPlugins":[]}`) + fs.addFile(filepath.Join(project, ".github/copilot/settings.json"), fmt.Sprintf(`{"extraKnownMarketplaces":{"engineering":{"source":{"source":"directory","path":%q}}},"enabledPlugins":{"review@engineering":%t}}`, catalog, enabled)) + fs.addFile(filepath.Join(catalog, "marketplace.json"), `{"name":"engineering","owner":{"name":"Example Engineering"},"plugins":[{"name":"review","source":"./plugins/review"},{"name":"unselected","source":"./plugins/unused"}]}`) + fs.addFile(filepath.Join(catalog, "plugins/review/.plugin/plugin.json"), `{"name":"review"}`) + fs.addFile(filepath.Join(catalog, "plugins/review/skills/check/SKILL.md"), validFrontmatter("check", "Check releases")) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), []string{project}, nil)) + if len(c.Plugins) != 1 || c.InstallationStatus != model.AgentScanStatusComplete { + t.Fatalf("live discovery: %+v", c) + } + p := c.Plugins[0] + if p.Scope != model.PluginScopeProject || p.ProjectPath != project || p.InstallationEvidence != model.PluginEvidenceLocalConfig || p.ConfiguredEnabled == nil || *p.ConfiguredEnabled != enabled || p.EffectiveEnabled != nil || len(p.Components) != 1 { + t.Fatalf("live selection: %+v", p) + } + }) + } +} + +func TestCopilotManifestPrecedence(t *testing.T) { + for _, tc := range []struct { + name, rootManifest, legacy, mcp string + count int + format string + }{ + {"portable 1.1", `{"$schema":"https://agent-plugins.org/schemas/1.1.0/plugin.schema.json","name":"portable","version":"1.0.0"}`, `{"name":"legacy","skills":"custom"}`, `{"$schema":"https://agent-plugins.org/schemas/1.1.0/mcp.schema.json","mcpServers":{"docs":{"type":"streamable-http","url":"https://docs.example/mcp"},"events":{"type":"sse","url":"https://docs.example/events"}}}`, 3, model.PluginManifestPortable}, + {"unknown portable", `{"$schema":"https://agent-plugins.org/schemas/9.0.0/plugin.schema.json","name":"future"}`, `{"name":"legacy"}`, "", 0, ""}, + {"malformed root", `{`, `{"name":"legacy"}`, "", 0, ""}, + {"legacy priority", `{"name":"root","skills":"custom"}`, `{"name":"preferred"}`, "", 1, model.PluginManifestCopilot}, + } { + t.Run(tc.name, func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(root, "installed-plugins/fixture") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"fixture","marketplace":"","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(payload, "plugin.json"), tc.rootManifest) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), tc.legacy) + fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check releases")) + if tc.mcp != "" { + fs.addFile(filepath.Join(payload, "mcp.json"), tc.mcp) + } + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + p := c.Plugins[0] + if len(p.Components) != tc.count || p.ManifestFormat != tc.format { + t.Fatalf("manifest selection: %+v", p) + } + if tc.count == 0 && p.ComponentStatus == model.AgentScanStatusComplete { + t.Fatal("invalid preferred manifest reported complete") + } + }) + } +} + +func TestCopilotSameNameOrigins(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + var records []map[string]any + for _, suffix := range []string{"one", "two"} { + payload := filepath.Join(root, "installed-plugins", suffix) + records = append(records, map[string]any{"name": "same", "marketplace": "", "cache_path": payload, "source": map[string]string{"source": "local", "path": filepath.Join(testHome, "sources", suffix)}}) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"same"}`) + } + data, _ := json.Marshal(map[string]any{"installedPlugins": records}) + fs.addFile(filepath.Join(root, "config.json"), string(data)) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins) != 2 || c.Plugins[0].InstanceID == c.Plugins[1].InstanceID { + t.Fatalf("origins merged: %+v", c) + } +} + +func TestCopilotRoots(t *testing.T) { + m := executor.NewMock() + m.SetEnv("COPILOT_HOME", filepath.Join(testHome, "custom")) + m.SetGOOS(model.PlatformDarwin) + if got := copilotCacheRoot(m, testHome); got != filepath.Join(testHome, "Library/Caches/copilot") { + t.Fatalf("config override moved cache: %s", got) + } + for _, goos := range []string{model.PlatformLinux, model.PlatformDarwin, model.PlatformWindows} { + m.SetGOOS(goos) + m.SetEnv("COPILOT_CACHE_HOME", filepath.Join(testHome, "cache")) + if got := copilotCacheRoot(m, testHome); got != filepath.Join(testHome, "cache") { + t.Fatalf("%s cache override: %s", goos, got) + } + } + m.SetEnv("COPILOT_HOME", "relative") + if got := copilotConfigRoot(m, testHome); got != filepath.Join(testHome, ".copilot") { + t.Fatalf("relative root accepted: %s", got) + } +} + +func TestCopilotEscapingSkillLink(t *testing.T) { + if runtime.GOOS == model.PlatformWindows { + t.Skip("symlink creation requires privileges") + } + home, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + root := filepath.Join(home, ".copilot") + payload := filepath.Join(root, "installed-plugins/test") + for _, dir := range []string{filepath.Join(payload, ".plugin"), filepath.Join(home, "unrelated/check")} { + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + } + for file, body := range map[string]string{filepath.Join(root, "config.json"): fmt.Sprintf(`{"installedPlugins":[{"name":"test","marketplace":"","cache_path":%q}]}`, payload), filepath.Join(payload, ".plugin/plugin.json"): `{"name":"test"}`, filepath.Join(home, "unrelated/check/SKILL.md"): validFrontmatter("unrelated", "Unrelated skill")} { + if err := os.WriteFile(file, []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(filepath.Join(home, "unrelated"), filepath.Join(payload, "skills")); err != nil { + t.Fatal(err) + } + d := NewSkillsDetector(executor.NewReal()) + definitions := 0 + s := &pluginScan{d: d, ctx: context.Background(), home: home, goos: runtime.GOOS, memo: map[string]*skillScan{}, definitions: &definitions, evidence: newPluginEvidence()} + c := s.detectCopilot() + if c == nil || len(c.Plugins) != 1 || c.Plugins[0].ComponentStatus == model.AgentScanStatusComplete || len(c.Plugins[0].Components) != 0 { + t.Fatalf("escaping link: %+v", c) + } +} + +func TestCopilotFailedCatalogProtectsRecordedComponents(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + catalog := filepath.Join(testHome, "catalog") + payload := filepath.Join(root, "installed-plugins/test") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"test","marketplace":"engineering","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(root, "settings.json"), fmt.Sprintf(`{"extraKnownMarketplaces":{"engineering":{"source":{"source":"directory","path":%q}}}}`, catalog)) + fs.addFile(filepath.Join(catalog, "marketplace.json"), `{`) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"test"}`) + fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check releases")) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins) != 1 || len(c.Plugins[0].Components) != 1 || c.Plugins[0].ComponentStatus == model.AgentScanStatusComplete { + t.Fatalf("failed catalog gave complete component coverage: %+v", c) + } +} + +func TestCopilotInvalidLivePayloadIsNotInstalled(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + catalog := filepath.Join(testHome, "catalog") + fs.addFile(filepath.Join(root, "settings.json"), fmt.Sprintf(`{"extraKnownMarketplaces":{"engineering":{"source":{"source":"directory","path":%q}}},"enabledPlugins":{"test@engineering":false}}`, catalog)) + fs.addFile(filepath.Join(catalog, "marketplace.json"), `{"name":"engineering","owner":{"name":"Example Engineering"},"plugins":[{"name":"test","source":"./plugins/test"}]}`) + fs.addFile(filepath.Join(catalog, "plugins/test/.plugin/plugin.json"), `{`) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins) != 0 || c.InstallationStatus == model.AgentScanStatusComplete { + t.Fatalf("invalid payload invented installation: %+v", c) + } +} + +func TestCopilotNativeRootSkillFallback(t *testing.T) { + for _, tc := range []struct { + name, manifest string + skillsDir bool + count int + }{ + {"no skills directory", `{"name":"fixture"}`, false, 1}, + {"empty skills directory", `{"name":"fixture"}`, true, 0}, + {"explicit missing override", `{"name":"fixture","skills":"missing"}`, false, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(root, "installed-plugins/fixture") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"fixture","marketplace":"","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), tc.manifest) + fs.addFile(filepath.Join(payload, "SKILL.md"), validFrontmatter("check", "Check release")) + if tc.skillsDir { + fs.addFile(filepath.Join(payload, "skills/.keep"), "") + } + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins[0].Components) != tc.count { + t.Fatalf("native root fallback: %+v", c.Plugins[0]) + } + }) + } +} + +func TestCopilotNativePathMCPPrecedence(t *testing.T) { + for _, tc := range []struct{ name, defaultFile, winner string }{ + {"manifest path", "", "path-server"}, {"dot file wins", ".mcp.json", "default-server"}, {"github file wins", ".github/mcp.json", "default-server"}, + } { + t.Run(tc.name, func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(root, "installed-plugins/fixture") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"fixture","marketplace":"","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"fixture","mcpServers":"./servers.json"}`) + fs.addFile(filepath.Join(payload, "servers.json"), `{"mcpServers":{"path-server":{"type":"http","url":"https://path.example/mcp"}}}`) + if tc.defaultFile != "" { + fs.addFile(filepath.Join(payload, tc.defaultFile), `{"mcpServers":{"default-server":{"url":"https://default.example/mcp"}}}`) + } + fs.commit() + p := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)).Plugins[0] + if len(p.Components) != 1 || p.Components[0].Name != tc.winner || p.ComponentStatus != model.AgentScanStatusComplete { + t.Fatalf("native path precedence: %+v", p) + } + }) + } +} + +func TestCopilotLegacyPreferencesFallback(t *testing.T) { + for _, malformed := range []bool{false, true} { + t.Run(fmt.Sprint(malformed), func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + catalog := filepath.Join(testHome, "catalog") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[],"extraKnownMarketplaces":{"engineering":{"source":{"source":"directory","path":%q}}},"enabledPlugins":{"review@engineering":true}}`, catalog)) + if malformed { + fs.addFile(filepath.Join(root, "settings.json"), `{`) + } + fs.addFile(filepath.Join(catalog, "marketplace.json"), `{"name":"engineering","owner":{"name":"Example Engineering"},"metadata":{"pluginRoot":"./plugins"},"plugins":[{"name":"review","source":"./review"}]}`) + fs.addFile(filepath.Join(catalog, "plugins/review/.plugin/plugin.json"), `{"name":"review"}`) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + if malformed { + if len(c.Plugins) != 0 || c.InstallationStatus == model.AgentScanStatusComplete { + t.Fatalf("malformed preferred settings fell back: %+v", c) + } + } else if len(c.Plugins) != 1 || c.Plugins[0].Enablement[0].SourcePath != filepath.Join(root, "config.json") { + t.Fatalf("legacy fallback missing: %+v", c) + } + }) + } +} + +func TestCopilotDefinitionLimitAndRecovery(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(root, "installed-plugins/test") + state := fmt.Sprintf(`{"installedPlugins":[{"name":"test","marketplace":"","cache_path":%q}]}`, payload) + fs.addFile(filepath.Join(root, "config.json"), state) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"test"}`) + fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check release")) + fs.commit() + d := NewSkillsDetector(m) + definitions := maxNewDefinitions + s := &pluginScan{d: d, ctx: context.Background(), home: testHome, goos: model.PlatformLinux, memo: map[string]*skillScan{}, definitions: &definitions, evidence: newPluginEvidence()} + c := s.detectCopilot() + if c.Plugins[0].ComponentStatus == model.AgentScanStatusComplete || definitions != maxNewDefinitions { + t.Fatal("definition cap reported complete or kept growing") + } + recovered := copilotContext(t, d.DetectAll(context.Background(), nil, nil)) + if recovered.Plugins[0].ComponentStatus != model.AgentScanStatusComplete || recovered.Plugins[0].InstanceID != c.Plugins[0].InstanceID { + t.Fatal("recovery changed identity or remained partial") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s.ctx = ctx + cancelled := s.detectCopilot() + if cancelled == nil || cancelled.InstallationStatus == model.AgentScanStatusComplete { + t.Fatal("cancelled scan authorized removal") + } +} + +func TestCopilotProtectedPayloadAndMCP(t *testing.T) { + if runtime.GOOS != model.PlatformDarwin { + t.Skip("macOS TCC guard") + } + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(testHome, "Library/Caches/copilot/private") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"test","marketplace":"","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"test"}`) + fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check release")) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).WithSkipper(tcc.New(testHome)).DetectAll(context.Background(), nil, nil)) + if c.Plugins[0].FilesPresent != nil || c.Plugins[0].ComponentStatus == model.AgentScanStatusComplete || len(c.Plugins[0].Components) != 0 { + t.Fatalf("protected payload read: %+v", c.Plugins[0]) + } + m.SetEnv("COPILOT_HOME", payload) + fs.addFile(filepath.Join(payload, "mcp-config.json"), `{"mcpServers":{"private":{"command":"node"}}}`) + fs.commit() + for _, config := range NewMCPDetector(m).WithSkipper(tcc.New(testHome)).DetectEnterprise(context.Background(), nil) { + if config.ConfigPath == filepath.Join(payload, "mcp-config.json") { + t.Fatal("protected standalone config read") + } + } +} + +func TestCopilotPluginMCPSuppression(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(root, "installed-plugins/test") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"test","marketplace":"","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"test"}`) + fs.addFile(filepath.Join(payload, ".mcp.json"), `{"mcpServers":{"docs":{"url":"https://docs.example/mcp"}}}`) + fs.commit() + result := NewSkillsDetector(m).DetectAll(context.Background(), nil, nil) + unrelated := filepath.Join(testHome, "project/.mcp.json") + configs := []model.MCPConfig{{ConfigSource: "discovered_mcp", ConfigPath: filepath.Join(payload, ".mcp.json")}, {ConfigSource: "discovered_mcp", ConfigPath: filepath.Join(root, "installed-plugins/orphan/.mcp.json")}, {ConfigSource: "project_mcp", ConfigPath: unrelated}} + kept := result.ReconcilePluginMCPCommunity(configs) + if len(kept) != 1 || kept[0].ConfigPath != unrelated { + t.Fatalf("MCP reconciliation: %+v", kept) + } + StripNestedMCPContent(result.Plugins) + for _, c := range result.Plugins.Contexts { + for _, p := range c.Plugins { + for _, component := range p.Components { + if component.MCPConfig != nil && component.MCPConfig.ConfigContentBase64 != "" { + t.Fatal("community retained MCP body") + } + } + } + } +} + +func TestCopilotCatalogKeepsIndependentMCP(t *testing.T) { + m, fs := newPluginMock() + catalog := filepath.Join(testHome, "catalog") + fs.addFile(filepath.Join(testHome, ".copilot/settings.json"), fmt.Sprintf(`{"extraKnownMarketplaces":{"engineering":{"source":{"source":"directory","path":%q}}},"enabledPlugins":{"review@engineering":true}}`, catalog)) + fs.addFile(filepath.Join(catalog, "marketplace.json"), `{"name":"engineering","owner":{"name":"Engineering"},"plugins":[{"name":"review","source":"./plugins/review"}]}`) + fs.addFile(filepath.Join(catalog, "plugins/review/.plugin/plugin.json"), `{"name":"review"}`) + fs.commit() + result := NewSkillsDetector(m).DetectAll(context.Background(), nil, nil) + file := filepath.Join(catalog, ".github/mcp.json") + configs := result.ReconcilePluginMCP([]model.MCPConfigEnterprise{{ConfigSource: "discovered_mcp", ConfigPath: file}}) + if len(configs) != 1 || configs[0].ConfigPath != file { + t.Fatalf("independent catalog MCP suppressed: %+v", configs) + } +} + +func TestCopilotRemoteCatalogCoverage(t *testing.T) { + for _, tc := range []struct{ name, source, dir, location string }{ + {"github", `{"source":"github","repo":"test-org/catalog","ref":"release"}`, "test-org-catalog", "https://github.com/test-org/catalog"}, + {"git", `{"source":"url","url":"https://git.example.com/catalog.git","ref":"release"}`, "https---git-example-com-catalog-git", "https://git.example.com/catalog.git"}, + } { + for _, state := range []string{"complete", "removed", "missing", "malformed", "wrong name", "protected"} { + t.Run(tc.name+"/"+state, func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + cache := filepath.Join(testHome, "copilot-cache") + if state == "protected" { + if runtime.GOOS != model.PlatformDarwin { + t.Skip("macOS TCC paths") + } + cache = filepath.Join(testHome, "Library/Caches/copilot") + } + m.SetEnv("COPILOT_CACHE_HOME", cache) + payload := filepath.Join(root, "installed-plugins/fixture") + catalog := filepath.Join(cache, "marketplaces", tc.dir, "marketplace.json") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"fixture","marketplace":"engineering","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(root, "settings.json"), fmt.Sprintf(`{"extraKnownMarketplaces":{"engineering":{"source":%s}}}`, tc.source)) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"fixture"}`) + if state != "removed" { + fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check releases")) + fs.addFile(filepath.Join(payload, ".mcp.json"), `{"mcpServers":{"docs":{"command":"node"}}}`) + } + switch state { + case "missing": + case "malformed": + fs.addFile(catalog, `{`) + case "wrong name": + fs.addFile(catalog, `{"name":"unrelated","owner":{"name":"Example"},"plugins":[{"name":"fixture","source":"./plugins/fixture"}]}`) + default: + fs.addFile(catalog, `{"name":"engineering","owner":{"name":"Example"},"metadata":{"pluginRoot":"./packages"},"plugins":[{"name":"fixture","source":"./plugins/fixture","mcpServers":{"ignored":{"command":"ignored"}}}]}`) + } + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).WithSkipper(tcc.New(testHome)).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins) != 1 { + t.Fatalf("registry installation lost: %+v", c) + } + p := c.Plugins[0] + complete := state == "complete" || state == "removed" + if (p.ComponentStatus == model.AgentScanStatusComplete) != complete { + t.Fatalf("component coverage: %+v", p) + } + want := 2 + if state == "removed" { + want = 0 + } + if len(p.Components) != want { + t.Fatalf("readable components lost or catalog fallback invented: %+v", p.Components) + } + if complete && (p.Source == nil || p.Source.Location != tc.location || p.Source.Subdirectory != "packages/plugins/fixture" || p.Source.RequestedRef != "release" || c.MarketplaceStatus != model.AgentScanStatusComplete) { + t.Fatalf("remote provenance: %+v / %+v", p, c) + } + }) + } + } +} + +func TestCopilotNativeCachePaths(t *testing.T) { + for _, tc := range []struct{ source, want string }{ + {`"test-org/catalog"`, "test-org-catalog"}, + {`{"source":"github","repo":"Test_Org/Catalog.git","ref":"release"}`, "Test_Org-Catalog.git"}, + {`{"source":"url","url":"https://example.com/a_b.git"}`, "https---example-com-a-b-git"}, + } { + cache := filepath.Join(testHome, "cache") + if got := copilotCatalogRoot(json.RawMessage(tc.source), cache); got != filepath.Join(cache, "marketplaces", tc.want) { + t.Errorf("source %s: %s", tc.source, got) + } + } +} diff --git a/internal/executor/user_aware.go b/internal/executor/user_aware.go index 09d5a7e7..db1322a7 100644 --- a/internal/executor/user_aware.go +++ b/internal/executor/user_aware.go @@ -34,6 +34,10 @@ func (e *UserAwareExecutor) GuardedFiles(roots []string, guard func(string) stri var userEnvironmentKeys = []string{ "APPDATA", + "COPILOT_HOME", + "COPILOT_CACHE_HOME", + "XDG_CACHE_HOME", + "LOCALAPPDATA", "GOAUTH", "GOENV", "GOPROXY", diff --git a/internal/executor/user_aware_test.go b/internal/executor/user_aware_test.go index 3d5188a9..e115cc98 100644 --- a/internal/executor/user_aware_test.go +++ b/internal/executor/user_aware_test.go @@ -266,3 +266,36 @@ func TestUserAwareExecutor_LookPathHasDeadline(t *testing.T) { t.Fatal(err) } } + +func TestUserAwareExecutor_CopilotRootsUseScannedUser(t *testing.T) { + service := NewMock() + service.SetGOOS("linux") + keys := []string{"COPILOT_HOME", "COPILOT_CACHE_HOME", "XDG_CACHE_HOME", "LOCALAPPDATA"} + for _, key := range keys { + service.SetEnv(key, "/daemon/"+key) + } + calls := 0 + inner := &userContextExecutor{Executor: service, runAsUser: func(_ context.Context, user, command string) (string, error) { + calls++ + if user != "alice" { + t.Fatalf("wrong user: %s", user) + } + var entries []string + for _, key := range keys { + if !strings.Contains(command, key) { + t.Fatalf("missing selected key: %s", key) + } + entries = append(entries, key+"=/home/alice/"+key) + } + return strings.Join(entries, "\x00") + "\x00", nil + }} + scanned := NewUserAwareExecutor(inner, "alice") + for _, key := range keys { + if got := scanned.Getenv(key); got != "/home/alice/"+key { + t.Fatalf("%s used daemon environment: %s", key, got) + } + } + if calls != 1 { + t.Fatalf("environment probed %d times", calls) + } +} diff --git a/internal/model/agentplugins.go b/internal/model/agentplugins.go index 180fa200..95a1fe1e 100644 --- a/internal/model/agentplugins.go +++ b/internal/model/agentplugins.go @@ -21,6 +21,7 @@ package model const ( AgentClaudeCode = "claude-code" AgentCodex = "codex" + AgentCopilot = "copilot" ) // Coverage statuses. Each one answers whether an enumeration is the whole set for @@ -117,6 +118,7 @@ const ( const ( PluginManifestClaude = "claude" PluginManifestCodex = "codex" + PluginManifestCopilot = "copilot" PluginManifestCursor = "cursor" PluginManifestPortable = "portable" PluginManifestCatalog = "catalog" @@ -170,7 +172,7 @@ func (s *AgentPlugins) PluginCount() int { // not repeated for every discovered project. type AgentPluginContext struct { ContextID string `json:"context_id"` - Agent string `json:"agent"` // AgentClaudeCode | AgentCodex + Agent string `json:"agent"` // AgentClaudeCode | AgentCodex | AgentCopilot ConfigRoot string `json:"config_root"` // observed, normalized; not an instruction to read it PluginRoot string `json:"plugin_root"` // Trusted existing inventory or passive metadata only. This collector never diff --git a/internal/model/agentplugins_copilot_test.go b/internal/model/agentplugins_copilot_test.go new file mode 100644 index 00000000..5e3f4a45 --- /dev/null +++ b/internal/model/agentplugins_copilot_test.go @@ -0,0 +1,71 @@ +package model + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "reflect" + "testing" +) + +// This fixture is shared verbatim with Agent API internal/ddbmodels/testdata. +func TestCopilotGoldenContract(t *testing.T) { + data, err := os.ReadFile("testdata/agent_plugins_v1_copilot_golden.json") + if err != nil { + t.Fatal(err) + } + type wireFixture struct { + Platform string `json:"platform"` + AgentPlugins AgentPlugins `json:"agent_plugins"` + } + var envelope wireFixture + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&envelope); err != nil { + t.Fatal(err) + } + scan := envelope.AgentPlugins + encoded, err := json.Marshal(envelope) + if err != nil { + t.Fatal(err) + } + var roundTrip wireFixture + if err := json.Unmarshal(encoded, &roundTrip); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(envelope, roundTrip) { + t.Fatal("Copilot wire fixture changed on round trip") + } + id := func(prefix string, parts ...string) string { + data, _ := json.Marshal(parts) + sum := sha256.Sum256(data) + return prefix + hex.EncodeToString(sum[:]) + } + if scan.SchemaVersion != 1 || len(scan.Contexts) != 1 { + t.Fatal("invalid fixture envelope") + } + c := scan.Contexts[0] + if c.Agent != AgentCopilot || c.ContextID != id("ctx_", c.Agent, c.ConfigRoot, c.PluginRoot) { + t.Fatal("context identity mismatch") + } + for _, m := range c.Marketplaces { + if m.MarketplaceID != id("market_", c.ContextID, m.Name) { + t.Fatal("marketplace identity mismatch") + } + } + for _, p := range c.Plugins { + if p.InstanceID != id("inst_", c.ContextID, p.NativeID, p.InstallationKind, p.Scope, p.ProjectPath, p.MarketplaceID) { + t.Fatal("installation identity mismatch") + } + for _, component := range p.Components { + if component.ComponentID != id("comp_", p.InstanceID, component.Kind, component.RelativePath, component.DeclarationPointer, component.Name) { + t.Fatal("component identity mismatch") + } + if component.Skill != nil && (component.Skill.Usage != nil) { + t.Fatal("skill attribution mismatch") + } + } + } +} diff --git a/internal/model/testdata/agent_plugins_v1_copilot_golden.json b/internal/model/testdata/agent_plugins_v1_copilot_golden.json new file mode 100644 index 00000000..bdbe29e3 --- /dev/null +++ b/internal/model/testdata/agent_plugins_v1_copilot_golden.json @@ -0,0 +1,154 @@ +{ + "platform": "linux", + "agent_plugins": { + "schema_version": 1, + "collected_at_ms": 1791158400000, + "contexts": [ + { + "agent": "copilot", + "config_root": "/home/test/.copilot", + "plugin_root": "/home/test/.copilot/installed-plugins", + "marketplace_status": "complete", + "installation_status": "complete", + "marketplaces": [ + { + "marketplace_id": "market_325bf72161e5b6f82bc2510858881730493e2c8f14e1ba0c9151abf6406487b7", + "name": "release-review", + "source": { + "kind": "git", + "location": "https://git.example.com/test-org/catalog.git" + }, + "catalog_path": "/home/test/catalog/.github/plugin/marketplace.json", + "registered": true + }, + { + "marketplace_id": "market_dd0e0ea1e89f7667fb7bce7b5f89018ae89c90f61653423a6ccfac63de44db36", + "name": "local-review", + "source": { + "kind": "local", + "location": "/home/test/catalog" + }, + "catalog_path": "/home/test/catalog/.github/plugin/marketplace.json", + "registered": true + } + ], + "plugins": [ + { + "native_id": "release-review", + "name": "release-review", + "marketplace_id": "market_325bf72161e5b6f82bc2510858881730493e2c8f14e1ba0c9151abf6406487b7", + "installation_kind": "marketplace", + "scope": "user", + "source": { + "kind": "local", + "location": "./plugins/release-review" + }, + "manifest_format": "copilot", + "manifest_version": "1.0.0", + "installed": true, + "files_present": true, + "installation_evidence": "registry", + "enablement": [], + "component_status": "complete", + "components": [ + { + "kind": "skill", + "name": "review", + "relative_path": "skills/review/SKILL.md", + "skill": { + "skill_name": "review", + "skill_md_hash": "e2f3e3786dfe9011256952435702be706e6eb5f7553b6c94b7f746df1e15ac4d" + }, + "status": "complete", + "callable_names": [], + "component_id": "comp_222667de228313ab0c5937a6165e7d84066cdbe71edf82bbc66beee6a7098ce7" + }, + { + "kind": "agent", + "name": "reviewer", + "relative_path": "agents/reviewer.agent.md", + "status": "complete", + "callable_names": [], + "component_id": "comp_3140566e7551628232b566cd5abfb0e434f36bb24ab73e1e1aa84f4b0d9fa90a" + }, + { + "kind": "mcp", + "name": "example", + "relative_path": "agents/reviewer.agent.md", + "declaration_pointer": "/mcp-servers/example", + "mcp_config": { + "config_path": "/home/test/catalog/plugins/release-review/agents/reviewer.agent.md", + "config_content_base64": "eyJtY3BTZXJ2ZXJzIjp7ImV4YW1wbGUiOnsidXJsIjoiaHR0cHM6Ly9tY3AuZXhhbXBsZS5jb20ifX19" + }, + "status": "complete", + "callable_names": [], + "component_id": "comp_086288d09dbd19e77a46c7f9e38457805df51f5d1543144c01cecc41518b705a" + } + ], + "errors": [], + "configured_enabled": false, + "instance_id": "inst_28a64413e913230a8d9d5a4b7dbb8110e8f377382fffded3e52edb196835114b" + }, + { + "native_id": "local-review", + "name": "local-review", + "marketplace_id": "market_dd0e0ea1e89f7667fb7bce7b5f89018ae89c90f61653423a6ccfac63de44db36", + "installation_kind": "marketplace", + "scope": "project", + "source": { + "kind": "local", + "location": "./plugins/local-review" + }, + "manifest_format": "copilot", + "manifest_version": "1.0.0", + "installed": true, + "files_present": true, + "installation_evidence": "local_config", + "enablement": [], + "component_status": "complete", + "components": [ + { + "kind": "skill", + "name": "review", + "relative_path": "skills/review/SKILL.md", + "skill": { + "skill_name": "review", + "skill_md_hash": "e2f3e3786dfe9011256952435702be706e6eb5f7553b6c94b7f746df1e15ac4d" + }, + "status": "complete", + "callable_names": [], + "component_id": "comp_0bdb805e4aea9dd92379432ef7e4fcce285e44dfb9016552140a581a08511e9c" + }, + { + "kind": "agent", + "name": "reviewer", + "relative_path": "agents/reviewer.agent.md", + "status": "complete", + "callable_names": [], + "component_id": "comp_bd9657d55b0e9b1ef4172d8015c3c7fe0a52d048b9114f10f7d624ad498bb1e4" + }, + { + "kind": "mcp", + "name": "example", + "relative_path": "agents/reviewer.agent.md", + "declaration_pointer": "/mcp-servers/example", + "mcp_config": { + "config_path": "/home/test/catalog/plugins/local-review/agents/reviewer.agent.md", + "config_content_base64": "eyJtY3BTZXJ2ZXJzIjp7ImV4YW1wbGUiOnsidXJsIjoiaHR0cHM6Ly9tY3AuZXhhbXBsZS5jb20ifX19" + }, + "status": "complete", + "callable_names": [], + "component_id": "comp_2a6d341c50258c194d77ca2c6b5dc346083cdff38751027c0348f334050a4402" + } + ], + "errors": [], + "project_path": "/home/test/widgets", + "instance_id": "inst_b87e631b114651bde4794e053e5cdc1f7330ef45d6f6fe0c1e4af26058349be7" + } + ], + "errors": [], + "context_id": "ctx_27b0f806087df0ecb9ebf1f42154e85d4d0a12a047e7be6c4b3cccaa38ffffbf" + } + ] + } +} diff --git a/internal/output/html.go b/internal/output/html.go index 9abbb17b..8594af4c 100644 --- a/internal/output/html.go +++ b/internal/output/html.go @@ -84,14 +84,15 @@ func HTML(outputFile string, result *model.ScanResult) error { } funcMap := template.FuncMap{ - "ideDisplayName": ideDisplayName, - "typeLabel": typeLabel, - "pluginState": pluginState, - "platformDisplayName": model.PlatformDisplayName, - "add": func(a, b int) int { return a + b }, - "formatBytes": formatBytes, - "formatCPU": formatCPU, - "formatWSL": formatWSL, + "ideDisplayName": ideDisplayName, + "typeLabel": typeLabel, + "pluginState": pluginState, + "pluginAgentDisplayName": pluginAgentDisplayName, + "platformDisplayName": model.PlatformDisplayName, + "add": func(a, b int) int { return a + b }, + "formatBytes": formatBytes, + "formatCPU": formatCPU, + "formatWSL": formatWSL, } tmpl, err := template.New("report").Funcs(funcMap).Parse(htmlTemplate) @@ -287,7 +288,7 @@ const htmlTemplate = `

Agent Plugins

{{if .AgentPlugins}} {{range .AgentPlugins.Contexts}} -

{{.Agent}} — marketplaces: {{.MarketplaceStatus}}; installations: {{.InstallationStatus}}

+

{{pluginAgentDisplayName .Agent}} — marketplaces: {{.MarketplaceStatus}}; installations: {{.InstallationStatus}}

{{if .Marketplaces}} {{range .Marketplaces}}{{end}} diff --git a/internal/output/pretty.go b/internal/output/pretty.go index b3a60e45..70ec7a46 100644 --- a/internal/output/pretty.go +++ b/internal/output/pretty.go @@ -729,7 +729,7 @@ func printAgentPlugins(w io.Writer, c *colors, result *model.ScanResult) { fmt.Fprintln(w, " No agent contexts detected") } for _, context := range scan.Contexts { - fmt.Fprintf(w, " %s — marketplaces: %s; installations: %s\n", context.Agent, context.MarketplaceStatus, context.InstallationStatus) + fmt.Fprintf(w, " %s — marketplaces: %s; installations: %s\n", pluginAgentDisplayName(context.Agent), context.MarketplaceStatus, context.InstallationStatus) if len(context.Plugins) == 0 && context.InstallationStatus == model.AgentScanStatusComplete { fmt.Fprintln(w, " None detected") } @@ -845,3 +845,10 @@ func communityInventory(result *model.ScanResult) *model.ScanResult { view.Summary.MCPConfigsCount = len(view.MCPConfigs) return &view } + +func pluginAgentDisplayName(agent string) string { + if agent == model.AgentCopilot { + return "GitHub Copilot CLI" + } + return agent +} From b5dc3fa269010446798b1c74becc65f3b258353e Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Mon, 5 Oct 2026 02:39:38 +0530 Subject: [PATCH 2/9] test: consolidate Agent Plugins tests and golden fixture --- internal/detector/mcp_copilot_test.go | 128 ----- internal/detector/mcp_test.go | 117 ++++ internal/detector/plugins_copilot_test.go | 523 ------------------ internal/detector/plugins_test.go | 506 +++++++++++++++++ internal/model/agentplugins_copilot_test.go | 71 --- internal/model/agentplugins_golden_test.go | 27 +- .../agent_plugins_v1_copilot_golden.json | 154 ------ .../testdata/agent_plugins_v1_golden.json | 159 ++++++ 8 files changed, 806 insertions(+), 879 deletions(-) delete mode 100644 internal/detector/mcp_copilot_test.go delete mode 100644 internal/detector/plugins_copilot_test.go delete mode 100644 internal/model/agentplugins_copilot_test.go delete mode 100644 internal/model/testdata/agent_plugins_v1_copilot_golden.json diff --git a/internal/detector/mcp_copilot_test.go b/internal/detector/mcp_copilot_test.go deleted file mode 100644 index 8bdcc6ae..00000000 --- a/internal/detector/mcp_copilot_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package detector - -import ( - "context" - "encoding/base64" - "encoding/json" - "github.com/step-security/dev-machine-guard/internal/executor" - "github.com/step-security/dev-machine-guard/internal/tcc" - "os" - "os/user" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func TestCopilotStandaloneMCP(t *testing.T) { - m, fs := newPluginMock() - root := filepath.Join(testHome, "copilot-custom") - project := filepath.Join(testHome, "project") - m.SetEnv("COPILOT_HOME", root) - fs.addFile(filepath.Join(root, "mcp-config.json"), `// JSONC - {"mcpServers":{"local":{"type":"local","command":"node","args":["server.js"],"env":{"TOKEN":"SECRET_SENTINEL"}},"remote":{"type":"http","url":"https://docs.example/mcp","headers":{"Authorization":"SECRET_SENTINEL"}},"invalid":false,},}`) - fs.addFile(filepath.Join(project, ".github/mcp.json"), `{"project":{"type":"sse","url":"https://project.example/mcp"}}`) - fs.addFile(filepath.Join(testHome, ".claude.json"), `{"projects":{`+jsonQuote(project)+`:{}}}`) - fs.addFile(filepath.Join(project, ".mcp.json"), `{"mcpServers":{"shared":{"command":"node"}}}`) - fs.commit() - results := NewMCPDetector(m).DetectEnterprise(context.Background(), nil) - sources := map[string]string{} - for _, config := range results { - sources[config.ConfigPath] = config.ConfigSource - body, err := base64.StdEncoding.DecodeString(config.ConfigContentBase64) - if err != nil || strings.Contains(string(body), "SECRET_SENTINEL") { - t.Fatalf("unsafe MCP: %s", body) - } - if config.ConfigPath == filepath.Join(root, "mcp-config.json") { - if !strings.Contains(string(body), "local") || !strings.Contains(string(body), "remote") || strings.Contains(string(body), "invalid") { - t.Fatalf("valid siblings lost: %s", body) - } - } - } - if sources[filepath.Join(root, "mcp-config.json")] != "copilot" || sources[filepath.Join(project, ".github/mcp.json")] != "copilot_project" || sources[filepath.Join(project, ".mcp.json")] != "project_mcp" { - t.Fatalf("MCP attribution: %v", sources) - } -} - -func jsonQuote(value string) string { data, _ := json.Marshal(value); return string(data) } - -func TestCopilotBareMCPIsPathLimited(t *testing.T) { - d := &MCPDetector{} - raw := []byte(`{"docs":{"type":"http","url":"https://docs.example/mcp"}}`) - for _, tc := range []struct { - file string - ok bool - }{{"/repo/.mcp.json", true}, {"/repo/.github/mcp.json", true}, {"/repo/.vscode/mcp.json", false}, {"/repo/settings.json", false}} { - if _, ok := d.filterMCPContent("discovered_mcp", tc.file, raw); ok != tc.ok { - t.Errorf("%s: accepted=%v", tc.file, ok) - } - } -} - -func TestCopilotProjectMCPKeepsValidSiblings(t *testing.T) { - d := &MCPDetector{} - content := []byte(`{"mcpServers":{"good":{"type":"local","command":"node"},"bad":42},"mcp":null}`) - data, ok := d.filterMCPContent("discovered_mcp", "/project/.github/mcp.json", content) - if !ok || !strings.Contains(string(data), `"good"`) || strings.Contains(string(data), `"bad"`) || !strings.Contains(string(data), `"mcp":{}`) { - t.Fatalf("shared keys or valid siblings lost: %s", data) - } - for _, raw := range []string{`{"type":null,"command":"node"}`, `{"type":7,"command":"node"}`, `{"command":"node","args":null}`} { - if validCopilotMCP(json.RawMessage(raw)) { - t.Errorf("invalid server accepted: %s", raw) - } - } -} - -func TestSharedMCPRelativeSymlink(t *testing.T) { - for _, protected := range []bool{false, true} { - name := "shared" - if protected { - name = "protected" - } - t.Run(name, func(t *testing.T) { - if protected && runtime.GOOS != "darwin" { - t.Skip("macOS TCC paths") - } - project, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { - t.Fatal(err) - } - target := "shared.json" - if protected { - target = "Library/shared.json" - } - for _, dir := range []string{filepath.Join(project, ".github"), filepath.Dir(filepath.Join(project, target))} { - if err := os.MkdirAll(dir, 0700); err != nil { - t.Fatal(err) - } - } - if err := os.WriteFile(filepath.Join(project, target), []byte(`{"mcpServers":{"docs":{"command":"node"}}}`), 0600); err != nil { - t.Fatal(err) - } - file := filepath.Join(project, ".github/mcp.json") - if err := os.Symlink("../"+target, file); err != nil { - t.Skipf("symlinks unavailable: %v", err) - } - detector := NewMCPDetector(mcpFixtureExecutor{Executor: executor.NewReal(), home: project}).WithSkipper(tcc.New(project)) - found := false - for _, config := range detector.DetectEnterprise(context.Background(), []string{project}) { - if config.ConfigPath == file && config.ConfigContentBase64 != "" { - found = true - } - } - if found == protected { - t.Fatalf("collected=%v, protected=%v", found, protected) - } - }) - } -} - -type mcpFixtureExecutor struct { - executor.Executor - home string -} - -func (e mcpFixtureExecutor) LoggedInUser() (*user.User, error) { - return &user.User{HomeDir: e.home}, nil -} -func (e mcpFixtureExecutor) Getenv(string) string { return "" } diff --git a/internal/detector/mcp_test.go b/internal/detector/mcp_test.go index 0ae0613f..97f73bbe 100644 --- a/internal/detector/mcp_test.go +++ b/internal/detector/mcp_test.go @@ -5,12 +5,16 @@ import ( "encoding/base64" "encoding/json" "fmt" + "os" + "os/user" "path/filepath" + "runtime" "slices" "strings" "testing" "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/tcc" ) func TestCodexMCPContent(t *testing.T) { @@ -922,3 +926,116 @@ func TestMCPConfigDefinitions_OpenCodeIsPlatformAgnostic(t *testing.T) { } } } + +func TestCopilotStandaloneMCP(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, "copilot-custom") + project := filepath.Join(testHome, "project") + m.SetEnv("COPILOT_HOME", root) + fs.addFile(filepath.Join(root, "mcp-config.json"), `// JSONC + {"mcpServers":{"local":{"type":"local","command":"node","args":["server.js"],"env":{"TOKEN":"SECRET_SENTINEL"}},"remote":{"type":"http","url":"https://docs.example/mcp","headers":{"Authorization":"SECRET_SENTINEL"}},"invalid":false,},}`) + fs.addFile(filepath.Join(project, ".github/mcp.json"), `{"project":{"type":"sse","url":"https://project.example/mcp"}}`) + fs.addFile(filepath.Join(testHome, ".claude.json"), `{"projects":{`+jsonQuote(project)+`:{}}}`) + fs.addFile(filepath.Join(project, ".mcp.json"), `{"mcpServers":{"shared":{"command":"node"}}}`) + fs.commit() + results := NewMCPDetector(m).DetectEnterprise(context.Background(), nil) + sources := map[string]string{} + for _, config := range results { + sources[config.ConfigPath] = config.ConfigSource + body, err := base64.StdEncoding.DecodeString(config.ConfigContentBase64) + if err != nil || strings.Contains(string(body), "SECRET_SENTINEL") { + t.Fatalf("unsafe MCP: %s", body) + } + if config.ConfigPath == filepath.Join(root, "mcp-config.json") { + if !strings.Contains(string(body), "local") || !strings.Contains(string(body), "remote") || strings.Contains(string(body), "invalid") { + t.Fatalf("valid siblings lost: %s", body) + } + } + } + if sources[filepath.Join(root, "mcp-config.json")] != "copilot" || sources[filepath.Join(project, ".github/mcp.json")] != "copilot_project" || sources[filepath.Join(project, ".mcp.json")] != "project_mcp" { + t.Fatalf("MCP attribution: %v", sources) + } +} + +func jsonQuote(value string) string { data, _ := json.Marshal(value); return string(data) } + +func TestCopilotBareMCPIsPathLimited(t *testing.T) { + d := &MCPDetector{} + raw := []byte(`{"docs":{"type":"http","url":"https://docs.example/mcp"}}`) + for _, tc := range []struct { + file string + ok bool + }{{"/repo/.mcp.json", true}, {"/repo/.github/mcp.json", true}, {"/repo/.vscode/mcp.json", false}, {"/repo/settings.json", false}} { + if _, ok := d.filterMCPContent("discovered_mcp", tc.file, raw); ok != tc.ok { + t.Errorf("%s: accepted=%v", tc.file, ok) + } + } +} + +func TestCopilotProjectMCPKeepsValidSiblings(t *testing.T) { + d := &MCPDetector{} + content := []byte(`{"mcpServers":{"good":{"type":"local","command":"node"},"bad":42},"mcp":null}`) + data, ok := d.filterMCPContent("discovered_mcp", "/project/.github/mcp.json", content) + if !ok || !strings.Contains(string(data), `"good"`) || strings.Contains(string(data), `"bad"`) || !strings.Contains(string(data), `"mcp":{}`) { + t.Fatalf("shared keys or valid siblings lost: %s", data) + } + for _, raw := range []string{`{"type":null,"command":"node"}`, `{"type":7,"command":"node"}`, `{"command":"node","args":null}`} { + if validCopilotMCP(json.RawMessage(raw)) { + t.Errorf("invalid server accepted: %s", raw) + } + } +} + +func TestSharedMCPRelativeSymlink(t *testing.T) { + for _, protected := range []bool{false, true} { + name := "shared" + if protected { + name = "protected" + } + t.Run(name, func(t *testing.T) { + if protected && runtime.GOOS != "darwin" { + t.Skip("macOS TCC paths") + } + project, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + target := "shared.json" + if protected { + target = "Library/shared.json" + } + for _, dir := range []string{filepath.Join(project, ".github"), filepath.Dir(filepath.Join(project, target))} { + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(project, target), []byte(`{"mcpServers":{"docs":{"command":"node"}}}`), 0600); err != nil { + t.Fatal(err) + } + file := filepath.Join(project, ".github/mcp.json") + if err := os.Symlink("../"+target, file); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + detector := NewMCPDetector(mcpFixtureExecutor{Executor: executor.NewReal(), home: project}).WithSkipper(tcc.New(project)) + found := false + for _, config := range detector.DetectEnterprise(context.Background(), []string{project}) { + if config.ConfigPath == file && config.ConfigContentBase64 != "" { + found = true + } + } + if found == protected { + t.Fatalf("collected=%v, protected=%v", found, protected) + } + }) + } +} + +type mcpFixtureExecutor struct { + executor.Executor + home string +} + +func (e mcpFixtureExecutor) LoggedInUser() (*user.User, error) { + return &user.User{HomeDir: e.home}, nil +} +func (e mcpFixtureExecutor) Getenv(string) string { return "" } diff --git a/internal/detector/plugins_copilot_test.go b/internal/detector/plugins_copilot_test.go deleted file mode 100644 index d3906b38..00000000 --- a/internal/detector/plugins_copilot_test.go +++ /dev/null @@ -1,523 +0,0 @@ -package detector - -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/step-security/dev-machine-guard/internal/executor" - "github.com/step-security/dev-machine-guard/internal/model" - "github.com/step-security/dev-machine-guard/internal/tcc" -) - -func copilotContext(t *testing.T, result SkillsResult) model.AgentPluginContext { - t.Helper() - if result.Plugins != nil { - for _, c := range result.Plugins.Contexts { - if c.Agent == model.AgentCopilot { - return c - } - } - } - t.Fatal("missing Copilot context") - return model.AgentPluginContext{} -} - -func TestCopilotRecordedInstallComponents(t *testing.T) { - m, fs := newPluginMock() - root := filepath.Join(testHome, ".copilot") - payload := filepath.Join(root, "installed-plugins", "_direct", "release-checks") - record := fmt.Sprintf(`{"name":"release-checks","marketplace":"","cache_path":%q,"version":"1.0.0","installed_at":"2026-10-05T00:00:00Z","enabled":false,"source":{"source":"local","path":%q}}`, payload, filepath.Join(testHome, "source")) - fs.addFile(filepath.Join(root, "config.json"), "// Generated state\n{\"installedPlugins\":["+record+",null]}") - fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"release-checks","skills":["custom"],"mcpServers":{"loser":{"url":"https://inline.example/mcp"}}}`) - fs.addFile(filepath.Join(payload, "skills/ignored/SKILL.md"), validFrontmatter("ignored", "Ignored default")) - fs.addFile(filepath.Join(payload, "custom/check/SKILL.md"), validFrontmatter("declared-check", "Check releases")) - fs.addFile(filepath.Join(payload, "agents/reviewer.agent.md"), "---\nname: Display name\ndescription: Review a release\nmcp-servers:\n 'agent/docs~v1':\n type: http\n url: https://agent.example/mcp\n headers:\n Authorization: FAKE_SECRET_SENTINEL\n---\nPROMPT_MUST_NOT_LEAVE_DEVICE\n") - fs.addFile(filepath.Join(payload, ".mcp.json"), `{"mcpServers":{"winner":{"type":"http","url":"https://docs.example/mcp","env":{"TOKEN":"FAKE_SECRET_SENTINEL"}},"invalid":42}}`) - fs.addFile(filepath.Join(payload, ".github/mcp.json"), `{"mcpServers":{"loser":{"url":"https://github.example/mcp"}}}`) - fs.addFile(filepath.Join(root, "installed-plugins/orphan/.plugin/plugin.json"), `{"name":"orphan"}`) - fs.commit() - versions := AgentVersions([]model.AITool{{Name: "github-copilot-cli", Version: "1.0.91"}}) - c := copilotContext(t, NewSkillsDetector(m).WithAgentVersions(versions).DetectAll(context.Background(), nil, nil)) - if c.AgentVersion != "1.0.91" { - t.Fatal("existing CLI version was not mapped to Copilot") - } - if c.InstallationStatus == model.AgentScanStatusComplete || len(c.Plugins) != 1 { - t.Fatalf("registry siblings: %+v", c) - } - p := c.Plugins[0] - if p.Installed == nil || !*p.Installed || p.FilesPresent == nil || !*p.FilesPresent || p.ConfiguredEnabled == nil || *p.ConfiguredEnabled || p.EffectiveEnabled != nil || p.InstalledAtMs == nil { - t.Fatalf("installation observations: %+v", p) - } - if p.SourcePath != filepath.Join(testHome, "source") || p.ComponentStatus == model.AgentScanStatusComplete || len(p.Components) != 4 { - t.Fatalf("components: %+v", p) - } - found := map[string]bool{} - for _, component := range p.Components { - found[component.Name] = true - if component.Skill != nil && (component.Skill.Agent != model.AgentCopilot || component.Skill.Source != "copilot_plugin" || component.Skill.Usage != nil || len(component.CallableNames) != 0) { - t.Fatalf("skill attribution: %+v", component) - } - if component.Name == "agent/docs~v1" && component.DeclarationPointer != "/mcp-servers/agent~1docs~0v1" { - t.Fatalf("agent pointer: %+v", component) - } - if component.MCPConfig != nil { - body, err := base64.StdEncoding.DecodeString(component.MCPConfig.ConfigContentBase64) - if err != nil || strings.Contains(string(body), "FAKE_SECRET_SENTINEL") || component.MCPConfig.ConfigSource != "copilot_plugin" { - t.Fatalf("MCP content: %s", body) - } - } - } - for _, name := range []string{"declared-check", "reviewer", "winner", "agent/docs~v1"} { - if !found[name] { - t.Errorf("missing %s", name) - } - } - body, _ := json.Marshal(c) - if strings.Contains(string(body), "PROMPT_MUST_NOT_LEAVE_DEVICE") || strings.Contains(string(body), "FAKE_SECRET_SENTINEL") { - t.Fatal("private contents leaked") - } -} - -func TestCopilotCanonicalStateAndMissingPayload(t *testing.T) { - for _, tc := range []struct { - name, state string - count int - complete bool - }{ - {"empty", `{"installedPlugins":[]}`, 0, true}, - {"canonical invalid beats alias", `{"installedPlugins":{},"installed_plugins":[{"name":"old","marketplace":""}]}`, 0, false}, - {"canonical null beats alias", `{"installedPlugins":null,"installed_plugins":[{"name":"old","marketplace":""}]}`, 0, false}, - {"malformed", `{`, 0, false}, - {"legacy", `{"installed_plugins":[{"name":"legacy","marketplace":""}]}`, 1, false}, - {"missing payload", fmt.Sprintf(`{"installedPlugins":[{"name":"missing","marketplace":"","cache_path":%q}]}`, filepath.Join(testHome, "missing")), 1, true}, - } { - t.Run(tc.name, func(t *testing.T) { - m, fs := newPluginMock() - fs.addFile(filepath.Join(testHome, ".copilot/config.json"), tc.state) - fs.commit() - c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) - if len(c.Plugins) != tc.count || (c.InstallationStatus == model.AgentScanStatusComplete) != tc.complete { - t.Fatalf("coverage: %+v", c) - } - for _, p := range c.Plugins { - if p.ComponentStatus == model.AgentScanStatusComplete || len(p.Components) != 0 { - t.Fatalf("missing payload complete: %+v", p) - } - } - }) - } -} - -func TestCopilotLiveSelectionAndProjectPreferences(t *testing.T) { - for _, enabled := range []bool{true, false} { - t.Run(fmt.Sprint(enabled), func(t *testing.T) { - m, fs := newPluginMock() - root := filepath.Join(testHome, ".copilot") - catalog := filepath.Join(testHome, "catalog") - project := filepath.Join(testHome, "project") - fs.addFile(filepath.Join(root, "config.json"), `{"installedPlugins":[]}`) - fs.addFile(filepath.Join(project, ".github/copilot/settings.json"), fmt.Sprintf(`{"extraKnownMarketplaces":{"engineering":{"source":{"source":"directory","path":%q}}},"enabledPlugins":{"review@engineering":%t}}`, catalog, enabled)) - fs.addFile(filepath.Join(catalog, "marketplace.json"), `{"name":"engineering","owner":{"name":"Example Engineering"},"plugins":[{"name":"review","source":"./plugins/review"},{"name":"unselected","source":"./plugins/unused"}]}`) - fs.addFile(filepath.Join(catalog, "plugins/review/.plugin/plugin.json"), `{"name":"review"}`) - fs.addFile(filepath.Join(catalog, "plugins/review/skills/check/SKILL.md"), validFrontmatter("check", "Check releases")) - fs.commit() - c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), []string{project}, nil)) - if len(c.Plugins) != 1 || c.InstallationStatus != model.AgentScanStatusComplete { - t.Fatalf("live discovery: %+v", c) - } - p := c.Plugins[0] - if p.Scope != model.PluginScopeProject || p.ProjectPath != project || p.InstallationEvidence != model.PluginEvidenceLocalConfig || p.ConfiguredEnabled == nil || *p.ConfiguredEnabled != enabled || p.EffectiveEnabled != nil || len(p.Components) != 1 { - t.Fatalf("live selection: %+v", p) - } - }) - } -} - -func TestCopilotManifestPrecedence(t *testing.T) { - for _, tc := range []struct { - name, rootManifest, legacy, mcp string - count int - format string - }{ - {"portable 1.1", `{"$schema":"https://agent-plugins.org/schemas/1.1.0/plugin.schema.json","name":"portable","version":"1.0.0"}`, `{"name":"legacy","skills":"custom"}`, `{"$schema":"https://agent-plugins.org/schemas/1.1.0/mcp.schema.json","mcpServers":{"docs":{"type":"streamable-http","url":"https://docs.example/mcp"},"events":{"type":"sse","url":"https://docs.example/events"}}}`, 3, model.PluginManifestPortable}, - {"unknown portable", `{"$schema":"https://agent-plugins.org/schemas/9.0.0/plugin.schema.json","name":"future"}`, `{"name":"legacy"}`, "", 0, ""}, - {"malformed root", `{`, `{"name":"legacy"}`, "", 0, ""}, - {"legacy priority", `{"name":"root","skills":"custom"}`, `{"name":"preferred"}`, "", 1, model.PluginManifestCopilot}, - } { - t.Run(tc.name, func(t *testing.T) { - m, fs := newPluginMock() - root := filepath.Join(testHome, ".copilot") - payload := filepath.Join(root, "installed-plugins/fixture") - fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"fixture","marketplace":"","cache_path":%q}]}`, payload)) - fs.addFile(filepath.Join(payload, "plugin.json"), tc.rootManifest) - fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), tc.legacy) - fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check releases")) - if tc.mcp != "" { - fs.addFile(filepath.Join(payload, "mcp.json"), tc.mcp) - } - fs.commit() - c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) - p := c.Plugins[0] - if len(p.Components) != tc.count || p.ManifestFormat != tc.format { - t.Fatalf("manifest selection: %+v", p) - } - if tc.count == 0 && p.ComponentStatus == model.AgentScanStatusComplete { - t.Fatal("invalid preferred manifest reported complete") - } - }) - } -} - -func TestCopilotSameNameOrigins(t *testing.T) { - m, fs := newPluginMock() - root := filepath.Join(testHome, ".copilot") - var records []map[string]any - for _, suffix := range []string{"one", "two"} { - payload := filepath.Join(root, "installed-plugins", suffix) - records = append(records, map[string]any{"name": "same", "marketplace": "", "cache_path": payload, "source": map[string]string{"source": "local", "path": filepath.Join(testHome, "sources", suffix)}}) - fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"same"}`) - } - data, _ := json.Marshal(map[string]any{"installedPlugins": records}) - fs.addFile(filepath.Join(root, "config.json"), string(data)) - fs.commit() - c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) - if len(c.Plugins) != 2 || c.Plugins[0].InstanceID == c.Plugins[1].InstanceID { - t.Fatalf("origins merged: %+v", c) - } -} - -func TestCopilotRoots(t *testing.T) { - m := executor.NewMock() - m.SetEnv("COPILOT_HOME", filepath.Join(testHome, "custom")) - m.SetGOOS(model.PlatformDarwin) - if got := copilotCacheRoot(m, testHome); got != filepath.Join(testHome, "Library/Caches/copilot") { - t.Fatalf("config override moved cache: %s", got) - } - for _, goos := range []string{model.PlatformLinux, model.PlatformDarwin, model.PlatformWindows} { - m.SetGOOS(goos) - m.SetEnv("COPILOT_CACHE_HOME", filepath.Join(testHome, "cache")) - if got := copilotCacheRoot(m, testHome); got != filepath.Join(testHome, "cache") { - t.Fatalf("%s cache override: %s", goos, got) - } - } - m.SetEnv("COPILOT_HOME", "relative") - if got := copilotConfigRoot(m, testHome); got != filepath.Join(testHome, ".copilot") { - t.Fatalf("relative root accepted: %s", got) - } -} - -func TestCopilotEscapingSkillLink(t *testing.T) { - if runtime.GOOS == model.PlatformWindows { - t.Skip("symlink creation requires privileges") - } - home, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { - t.Fatal(err) - } - root := filepath.Join(home, ".copilot") - payload := filepath.Join(root, "installed-plugins/test") - for _, dir := range []string{filepath.Join(payload, ".plugin"), filepath.Join(home, "unrelated/check")} { - if err := os.MkdirAll(dir, 0700); err != nil { - t.Fatal(err) - } - } - for file, body := range map[string]string{filepath.Join(root, "config.json"): fmt.Sprintf(`{"installedPlugins":[{"name":"test","marketplace":"","cache_path":%q}]}`, payload), filepath.Join(payload, ".plugin/plugin.json"): `{"name":"test"}`, filepath.Join(home, "unrelated/check/SKILL.md"): validFrontmatter("unrelated", "Unrelated skill")} { - if err := os.WriteFile(file, []byte(body), 0600); err != nil { - t.Fatal(err) - } - } - if err := os.Symlink(filepath.Join(home, "unrelated"), filepath.Join(payload, "skills")); err != nil { - t.Fatal(err) - } - d := NewSkillsDetector(executor.NewReal()) - definitions := 0 - s := &pluginScan{d: d, ctx: context.Background(), home: home, goos: runtime.GOOS, memo: map[string]*skillScan{}, definitions: &definitions, evidence: newPluginEvidence()} - c := s.detectCopilot() - if c == nil || len(c.Plugins) != 1 || c.Plugins[0].ComponentStatus == model.AgentScanStatusComplete || len(c.Plugins[0].Components) != 0 { - t.Fatalf("escaping link: %+v", c) - } -} - -func TestCopilotFailedCatalogProtectsRecordedComponents(t *testing.T) { - m, fs := newPluginMock() - root := filepath.Join(testHome, ".copilot") - catalog := filepath.Join(testHome, "catalog") - payload := filepath.Join(root, "installed-plugins/test") - fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"test","marketplace":"engineering","cache_path":%q}]}`, payload)) - fs.addFile(filepath.Join(root, "settings.json"), fmt.Sprintf(`{"extraKnownMarketplaces":{"engineering":{"source":{"source":"directory","path":%q}}}}`, catalog)) - fs.addFile(filepath.Join(catalog, "marketplace.json"), `{`) - fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"test"}`) - fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check releases")) - fs.commit() - c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) - if len(c.Plugins) != 1 || len(c.Plugins[0].Components) != 1 || c.Plugins[0].ComponentStatus == model.AgentScanStatusComplete { - t.Fatalf("failed catalog gave complete component coverage: %+v", c) - } -} - -func TestCopilotInvalidLivePayloadIsNotInstalled(t *testing.T) { - m, fs := newPluginMock() - root := filepath.Join(testHome, ".copilot") - catalog := filepath.Join(testHome, "catalog") - fs.addFile(filepath.Join(root, "settings.json"), fmt.Sprintf(`{"extraKnownMarketplaces":{"engineering":{"source":{"source":"directory","path":%q}}},"enabledPlugins":{"test@engineering":false}}`, catalog)) - fs.addFile(filepath.Join(catalog, "marketplace.json"), `{"name":"engineering","owner":{"name":"Example Engineering"},"plugins":[{"name":"test","source":"./plugins/test"}]}`) - fs.addFile(filepath.Join(catalog, "plugins/test/.plugin/plugin.json"), `{`) - fs.commit() - c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) - if len(c.Plugins) != 0 || c.InstallationStatus == model.AgentScanStatusComplete { - t.Fatalf("invalid payload invented installation: %+v", c) - } -} - -func TestCopilotNativeRootSkillFallback(t *testing.T) { - for _, tc := range []struct { - name, manifest string - skillsDir bool - count int - }{ - {"no skills directory", `{"name":"fixture"}`, false, 1}, - {"empty skills directory", `{"name":"fixture"}`, true, 0}, - {"explicit missing override", `{"name":"fixture","skills":"missing"}`, false, 0}, - } { - t.Run(tc.name, func(t *testing.T) { - m, fs := newPluginMock() - root := filepath.Join(testHome, ".copilot") - payload := filepath.Join(root, "installed-plugins/fixture") - fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"fixture","marketplace":"","cache_path":%q}]}`, payload)) - fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), tc.manifest) - fs.addFile(filepath.Join(payload, "SKILL.md"), validFrontmatter("check", "Check release")) - if tc.skillsDir { - fs.addFile(filepath.Join(payload, "skills/.keep"), "") - } - fs.commit() - c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) - if len(c.Plugins[0].Components) != tc.count { - t.Fatalf("native root fallback: %+v", c.Plugins[0]) - } - }) - } -} - -func TestCopilotNativePathMCPPrecedence(t *testing.T) { - for _, tc := range []struct{ name, defaultFile, winner string }{ - {"manifest path", "", "path-server"}, {"dot file wins", ".mcp.json", "default-server"}, {"github file wins", ".github/mcp.json", "default-server"}, - } { - t.Run(tc.name, func(t *testing.T) { - m, fs := newPluginMock() - root := filepath.Join(testHome, ".copilot") - payload := filepath.Join(root, "installed-plugins/fixture") - fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"fixture","marketplace":"","cache_path":%q}]}`, payload)) - fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"fixture","mcpServers":"./servers.json"}`) - fs.addFile(filepath.Join(payload, "servers.json"), `{"mcpServers":{"path-server":{"type":"http","url":"https://path.example/mcp"}}}`) - if tc.defaultFile != "" { - fs.addFile(filepath.Join(payload, tc.defaultFile), `{"mcpServers":{"default-server":{"url":"https://default.example/mcp"}}}`) - } - fs.commit() - p := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)).Plugins[0] - if len(p.Components) != 1 || p.Components[0].Name != tc.winner || p.ComponentStatus != model.AgentScanStatusComplete { - t.Fatalf("native path precedence: %+v", p) - } - }) - } -} - -func TestCopilotLegacyPreferencesFallback(t *testing.T) { - for _, malformed := range []bool{false, true} { - t.Run(fmt.Sprint(malformed), func(t *testing.T) { - m, fs := newPluginMock() - root := filepath.Join(testHome, ".copilot") - catalog := filepath.Join(testHome, "catalog") - fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[],"extraKnownMarketplaces":{"engineering":{"source":{"source":"directory","path":%q}}},"enabledPlugins":{"review@engineering":true}}`, catalog)) - if malformed { - fs.addFile(filepath.Join(root, "settings.json"), `{`) - } - fs.addFile(filepath.Join(catalog, "marketplace.json"), `{"name":"engineering","owner":{"name":"Example Engineering"},"metadata":{"pluginRoot":"./plugins"},"plugins":[{"name":"review","source":"./review"}]}`) - fs.addFile(filepath.Join(catalog, "plugins/review/.plugin/plugin.json"), `{"name":"review"}`) - fs.commit() - c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) - if malformed { - if len(c.Plugins) != 0 || c.InstallationStatus == model.AgentScanStatusComplete { - t.Fatalf("malformed preferred settings fell back: %+v", c) - } - } else if len(c.Plugins) != 1 || c.Plugins[0].Enablement[0].SourcePath != filepath.Join(root, "config.json") { - t.Fatalf("legacy fallback missing: %+v", c) - } - }) - } -} - -func TestCopilotDefinitionLimitAndRecovery(t *testing.T) { - m, fs := newPluginMock() - root := filepath.Join(testHome, ".copilot") - payload := filepath.Join(root, "installed-plugins/test") - state := fmt.Sprintf(`{"installedPlugins":[{"name":"test","marketplace":"","cache_path":%q}]}`, payload) - fs.addFile(filepath.Join(root, "config.json"), state) - fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"test"}`) - fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check release")) - fs.commit() - d := NewSkillsDetector(m) - definitions := maxNewDefinitions - s := &pluginScan{d: d, ctx: context.Background(), home: testHome, goos: model.PlatformLinux, memo: map[string]*skillScan{}, definitions: &definitions, evidence: newPluginEvidence()} - c := s.detectCopilot() - if c.Plugins[0].ComponentStatus == model.AgentScanStatusComplete || definitions != maxNewDefinitions { - t.Fatal("definition cap reported complete or kept growing") - } - recovered := copilotContext(t, d.DetectAll(context.Background(), nil, nil)) - if recovered.Plugins[0].ComponentStatus != model.AgentScanStatusComplete || recovered.Plugins[0].InstanceID != c.Plugins[0].InstanceID { - t.Fatal("recovery changed identity or remained partial") - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - s.ctx = ctx - cancelled := s.detectCopilot() - if cancelled == nil || cancelled.InstallationStatus == model.AgentScanStatusComplete { - t.Fatal("cancelled scan authorized removal") - } -} - -func TestCopilotProtectedPayloadAndMCP(t *testing.T) { - if runtime.GOOS != model.PlatformDarwin { - t.Skip("macOS TCC guard") - } - m, fs := newPluginMock() - root := filepath.Join(testHome, ".copilot") - payload := filepath.Join(testHome, "Library/Caches/copilot/private") - fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"test","marketplace":"","cache_path":%q}]}`, payload)) - fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"test"}`) - fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check release")) - fs.commit() - c := copilotContext(t, NewSkillsDetector(m).WithSkipper(tcc.New(testHome)).DetectAll(context.Background(), nil, nil)) - if c.Plugins[0].FilesPresent != nil || c.Plugins[0].ComponentStatus == model.AgentScanStatusComplete || len(c.Plugins[0].Components) != 0 { - t.Fatalf("protected payload read: %+v", c.Plugins[0]) - } - m.SetEnv("COPILOT_HOME", payload) - fs.addFile(filepath.Join(payload, "mcp-config.json"), `{"mcpServers":{"private":{"command":"node"}}}`) - fs.commit() - for _, config := range NewMCPDetector(m).WithSkipper(tcc.New(testHome)).DetectEnterprise(context.Background(), nil) { - if config.ConfigPath == filepath.Join(payload, "mcp-config.json") { - t.Fatal("protected standalone config read") - } - } -} - -func TestCopilotPluginMCPSuppression(t *testing.T) { - m, fs := newPluginMock() - root := filepath.Join(testHome, ".copilot") - payload := filepath.Join(root, "installed-plugins/test") - fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"test","marketplace":"","cache_path":%q}]}`, payload)) - fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"test"}`) - fs.addFile(filepath.Join(payload, ".mcp.json"), `{"mcpServers":{"docs":{"url":"https://docs.example/mcp"}}}`) - fs.commit() - result := NewSkillsDetector(m).DetectAll(context.Background(), nil, nil) - unrelated := filepath.Join(testHome, "project/.mcp.json") - configs := []model.MCPConfig{{ConfigSource: "discovered_mcp", ConfigPath: filepath.Join(payload, ".mcp.json")}, {ConfigSource: "discovered_mcp", ConfigPath: filepath.Join(root, "installed-plugins/orphan/.mcp.json")}, {ConfigSource: "project_mcp", ConfigPath: unrelated}} - kept := result.ReconcilePluginMCPCommunity(configs) - if len(kept) != 1 || kept[0].ConfigPath != unrelated { - t.Fatalf("MCP reconciliation: %+v", kept) - } - StripNestedMCPContent(result.Plugins) - for _, c := range result.Plugins.Contexts { - for _, p := range c.Plugins { - for _, component := range p.Components { - if component.MCPConfig != nil && component.MCPConfig.ConfigContentBase64 != "" { - t.Fatal("community retained MCP body") - } - } - } - } -} - -func TestCopilotCatalogKeepsIndependentMCP(t *testing.T) { - m, fs := newPluginMock() - catalog := filepath.Join(testHome, "catalog") - fs.addFile(filepath.Join(testHome, ".copilot/settings.json"), fmt.Sprintf(`{"extraKnownMarketplaces":{"engineering":{"source":{"source":"directory","path":%q}}},"enabledPlugins":{"review@engineering":true}}`, catalog)) - fs.addFile(filepath.Join(catalog, "marketplace.json"), `{"name":"engineering","owner":{"name":"Engineering"},"plugins":[{"name":"review","source":"./plugins/review"}]}`) - fs.addFile(filepath.Join(catalog, "plugins/review/.plugin/plugin.json"), `{"name":"review"}`) - fs.commit() - result := NewSkillsDetector(m).DetectAll(context.Background(), nil, nil) - file := filepath.Join(catalog, ".github/mcp.json") - configs := result.ReconcilePluginMCP([]model.MCPConfigEnterprise{{ConfigSource: "discovered_mcp", ConfigPath: file}}) - if len(configs) != 1 || configs[0].ConfigPath != file { - t.Fatalf("independent catalog MCP suppressed: %+v", configs) - } -} - -func TestCopilotRemoteCatalogCoverage(t *testing.T) { - for _, tc := range []struct{ name, source, dir, location string }{ - {"github", `{"source":"github","repo":"test-org/catalog","ref":"release"}`, "test-org-catalog", "https://github.com/test-org/catalog"}, - {"git", `{"source":"url","url":"https://git.example.com/catalog.git","ref":"release"}`, "https---git-example-com-catalog-git", "https://git.example.com/catalog.git"}, - } { - for _, state := range []string{"complete", "removed", "missing", "malformed", "wrong name", "protected"} { - t.Run(tc.name+"/"+state, func(t *testing.T) { - m, fs := newPluginMock() - root := filepath.Join(testHome, ".copilot") - cache := filepath.Join(testHome, "copilot-cache") - if state == "protected" { - if runtime.GOOS != model.PlatformDarwin { - t.Skip("macOS TCC paths") - } - cache = filepath.Join(testHome, "Library/Caches/copilot") - } - m.SetEnv("COPILOT_CACHE_HOME", cache) - payload := filepath.Join(root, "installed-plugins/fixture") - catalog := filepath.Join(cache, "marketplaces", tc.dir, "marketplace.json") - fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"fixture","marketplace":"engineering","cache_path":%q}]}`, payload)) - fs.addFile(filepath.Join(root, "settings.json"), fmt.Sprintf(`{"extraKnownMarketplaces":{"engineering":{"source":%s}}}`, tc.source)) - fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"fixture"}`) - if state != "removed" { - fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check releases")) - fs.addFile(filepath.Join(payload, ".mcp.json"), `{"mcpServers":{"docs":{"command":"node"}}}`) - } - switch state { - case "missing": - case "malformed": - fs.addFile(catalog, `{`) - case "wrong name": - fs.addFile(catalog, `{"name":"unrelated","owner":{"name":"Example"},"plugins":[{"name":"fixture","source":"./plugins/fixture"}]}`) - default: - fs.addFile(catalog, `{"name":"engineering","owner":{"name":"Example"},"metadata":{"pluginRoot":"./packages"},"plugins":[{"name":"fixture","source":"./plugins/fixture","mcpServers":{"ignored":{"command":"ignored"}}}]}`) - } - fs.commit() - c := copilotContext(t, NewSkillsDetector(m).WithSkipper(tcc.New(testHome)).DetectAll(context.Background(), nil, nil)) - if len(c.Plugins) != 1 { - t.Fatalf("registry installation lost: %+v", c) - } - p := c.Plugins[0] - complete := state == "complete" || state == "removed" - if (p.ComponentStatus == model.AgentScanStatusComplete) != complete { - t.Fatalf("component coverage: %+v", p) - } - want := 2 - if state == "removed" { - want = 0 - } - if len(p.Components) != want { - t.Fatalf("readable components lost or catalog fallback invented: %+v", p.Components) - } - if complete && (p.Source == nil || p.Source.Location != tc.location || p.Source.Subdirectory != "packages/plugins/fixture" || p.Source.RequestedRef != "release" || c.MarketplaceStatus != model.AgentScanStatusComplete) { - t.Fatalf("remote provenance: %+v / %+v", p, c) - } - }) - } - } -} - -func TestCopilotNativeCachePaths(t *testing.T) { - for _, tc := range []struct{ source, want string }{ - {`"test-org/catalog"`, "test-org-catalog"}, - {`{"source":"github","repo":"Test_Org/Catalog.git","ref":"release"}`, "Test_Org-Catalog.git"}, - {`{"source":"url","url":"https://example.com/a_b.git"}`, "https---example-com-a-b-git"}, - } { - cache := filepath.Join(testHome, "cache") - if got := copilotCatalogRoot(json.RawMessage(tc.source), cache); got != filepath.Join(cache, "marketplaces", tc.want) { - t.Errorf("source %s: %s", tc.source, got) - } - } -} diff --git a/internal/detector/plugins_test.go b/internal/detector/plugins_test.go index e181fd25..610ddede 100644 --- a/internal/detector/plugins_test.go +++ b/internal/detector/plugins_test.go @@ -1877,3 +1877,509 @@ func TestPluginSkillUsageStandaloneZeroAndCollision(t *testing.T) { t.Fatal("failed source inferred a count") } } + +func copilotContext(t *testing.T, result SkillsResult) model.AgentPluginContext { + t.Helper() + if result.Plugins != nil { + for _, c := range result.Plugins.Contexts { + if c.Agent == model.AgentCopilot { + return c + } + } + } + t.Fatal("missing Copilot context") + return model.AgentPluginContext{} +} + +func TestCopilotRecordedInstallComponents(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(root, "installed-plugins", "_direct", "release-checks") + record := fmt.Sprintf(`{"name":"release-checks","marketplace":"","cache_path":%q,"version":"1.0.0","installed_at":"2026-10-05T00:00:00Z","enabled":false,"source":{"source":"local","path":%q}}`, payload, filepath.Join(testHome, "source")) + fs.addFile(filepath.Join(root, "config.json"), "// Generated state\n{\"installedPlugins\":["+record+",null]}") + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"release-checks","skills":["custom"],"mcpServers":{"loser":{"url":"https://inline.example/mcp"}}}`) + fs.addFile(filepath.Join(payload, "skills/ignored/SKILL.md"), validFrontmatter("ignored", "Ignored default")) + fs.addFile(filepath.Join(payload, "custom/check/SKILL.md"), validFrontmatter("declared-check", "Check releases")) + fs.addFile(filepath.Join(payload, "agents/reviewer.agent.md"), "---\nname: Display name\ndescription: Review a release\nmcp-servers:\n 'agent/docs~v1':\n type: http\n url: https://agent.example/mcp\n headers:\n Authorization: FAKE_SECRET_SENTINEL\n---\nPROMPT_MUST_NOT_LEAVE_DEVICE\n") + fs.addFile(filepath.Join(payload, ".mcp.json"), `{"mcpServers":{"winner":{"type":"http","url":"https://docs.example/mcp","env":{"TOKEN":"FAKE_SECRET_SENTINEL"}},"invalid":42}}`) + fs.addFile(filepath.Join(payload, ".github/mcp.json"), `{"mcpServers":{"loser":{"url":"https://github.example/mcp"}}}`) + fs.addFile(filepath.Join(root, "installed-plugins/orphan/.plugin/plugin.json"), `{"name":"orphan"}`) + fs.commit() + versions := AgentVersions([]model.AITool{{Name: "github-copilot-cli", Version: "1.0.91"}}) + c := copilotContext(t, NewSkillsDetector(m).WithAgentVersions(versions).DetectAll(context.Background(), nil, nil)) + if c.AgentVersion != "1.0.91" { + t.Fatal("existing CLI version was not mapped to Copilot") + } + if c.InstallationStatus == model.AgentScanStatusComplete || len(c.Plugins) != 1 { + t.Fatalf("registry siblings: %+v", c) + } + p := c.Plugins[0] + if p.Installed == nil || !*p.Installed || p.FilesPresent == nil || !*p.FilesPresent || p.ConfiguredEnabled == nil || *p.ConfiguredEnabled || p.EffectiveEnabled != nil || p.InstalledAtMs == nil { + t.Fatalf("installation observations: %+v", p) + } + if p.SourcePath != filepath.Join(testHome, "source") || p.ComponentStatus == model.AgentScanStatusComplete || len(p.Components) != 4 { + t.Fatalf("components: %+v", p) + } + found := map[string]bool{} + for _, component := range p.Components { + found[component.Name] = true + if component.Skill != nil && (component.Skill.Agent != model.AgentCopilot || component.Skill.Source != "copilot_plugin" || component.Skill.Usage != nil || len(component.CallableNames) != 0) { + t.Fatalf("skill attribution: %+v", component) + } + if component.Name == "agent/docs~v1" && component.DeclarationPointer != "/mcp-servers/agent~1docs~0v1" { + t.Fatalf("agent pointer: %+v", component) + } + if component.MCPConfig != nil { + body, err := base64.StdEncoding.DecodeString(component.MCPConfig.ConfigContentBase64) + if err != nil || strings.Contains(string(body), "FAKE_SECRET_SENTINEL") || component.MCPConfig.ConfigSource != "copilot_plugin" { + t.Fatalf("MCP content: %s", body) + } + } + } + for _, name := range []string{"declared-check", "reviewer", "winner", "agent/docs~v1"} { + if !found[name] { + t.Errorf("missing %s", name) + } + } + body, _ := json.Marshal(c) + if strings.Contains(string(body), "PROMPT_MUST_NOT_LEAVE_DEVICE") || strings.Contains(string(body), "FAKE_SECRET_SENTINEL") { + t.Fatal("private contents leaked") + } +} + +func TestCopilotCanonicalStateAndMissingPayload(t *testing.T) { + for _, tc := range []struct { + name, state string + count int + complete bool + }{ + {"empty", `{"installedPlugins":[]}`, 0, true}, + {"canonical invalid beats alias", `{"installedPlugins":{},"installed_plugins":[{"name":"old","marketplace":""}]}`, 0, false}, + {"canonical null beats alias", `{"installedPlugins":null,"installed_plugins":[{"name":"old","marketplace":""}]}`, 0, false}, + {"malformed", `{`, 0, false}, + {"legacy", `{"installed_plugins":[{"name":"legacy","marketplace":""}]}`, 1, false}, + {"missing payload", fmt.Sprintf(`{"installedPlugins":[{"name":"missing","marketplace":"","cache_path":%q}]}`, filepath.Join(testHome, "missing")), 1, true}, + } { + t.Run(tc.name, func(t *testing.T) { + m, fs := newPluginMock() + fs.addFile(filepath.Join(testHome, ".copilot/config.json"), tc.state) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins) != tc.count || (c.InstallationStatus == model.AgentScanStatusComplete) != tc.complete { + t.Fatalf("coverage: %+v", c) + } + for _, p := range c.Plugins { + if p.ComponentStatus == model.AgentScanStatusComplete || len(p.Components) != 0 { + t.Fatalf("missing payload complete: %+v", p) + } + } + }) + } +} + +func TestCopilotLiveSelectionAndProjectPreferences(t *testing.T) { + for _, enabled := range []bool{true, false} { + t.Run(fmt.Sprint(enabled), func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + catalog := filepath.Join(testHome, "catalog") + project := filepath.Join(testHome, "project") + fs.addFile(filepath.Join(root, "config.json"), `{"installedPlugins":[]}`) + fs.addFile(filepath.Join(project, ".github/copilot/settings.json"), fmt.Sprintf(`{"extraKnownMarketplaces":{"engineering":{"source":{"source":"directory","path":%q}}},"enabledPlugins":{"review@engineering":%t}}`, catalog, enabled)) + fs.addFile(filepath.Join(catalog, "marketplace.json"), `{"name":"engineering","owner":{"name":"Example Engineering"},"plugins":[{"name":"review","source":"./plugins/review"},{"name":"unselected","source":"./plugins/unused"}]}`) + fs.addFile(filepath.Join(catalog, "plugins/review/.plugin/plugin.json"), `{"name":"review"}`) + fs.addFile(filepath.Join(catalog, "plugins/review/skills/check/SKILL.md"), validFrontmatter("check", "Check releases")) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), []string{project}, nil)) + if len(c.Plugins) != 1 || c.InstallationStatus != model.AgentScanStatusComplete { + t.Fatalf("live discovery: %+v", c) + } + p := c.Plugins[0] + if p.Scope != model.PluginScopeProject || p.ProjectPath != project || p.InstallationEvidence != model.PluginEvidenceLocalConfig || p.ConfiguredEnabled == nil || *p.ConfiguredEnabled != enabled || p.EffectiveEnabled != nil || len(p.Components) != 1 { + t.Fatalf("live selection: %+v", p) + } + }) + } +} + +func TestCopilotManifestPrecedence(t *testing.T) { + for _, tc := range []struct { + name, rootManifest, legacy, mcp string + count int + format string + }{ + {"portable 1.1", `{"$schema":"https://agent-plugins.org/schemas/1.1.0/plugin.schema.json","name":"portable","version":"1.0.0"}`, `{"name":"legacy","skills":"custom"}`, `{"$schema":"https://agent-plugins.org/schemas/1.1.0/mcp.schema.json","mcpServers":{"docs":{"type":"streamable-http","url":"https://docs.example/mcp"},"events":{"type":"sse","url":"https://docs.example/events"}}}`, 3, model.PluginManifestPortable}, + {"unknown portable", `{"$schema":"https://agent-plugins.org/schemas/9.0.0/plugin.schema.json","name":"future"}`, `{"name":"legacy"}`, "", 0, ""}, + {"malformed root", `{`, `{"name":"legacy"}`, "", 0, ""}, + {"legacy priority", `{"name":"root","skills":"custom"}`, `{"name":"preferred"}`, "", 1, model.PluginManifestCopilot}, + } { + t.Run(tc.name, func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(root, "installed-plugins/fixture") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"fixture","marketplace":"","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(payload, "plugin.json"), tc.rootManifest) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), tc.legacy) + fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check releases")) + if tc.mcp != "" { + fs.addFile(filepath.Join(payload, "mcp.json"), tc.mcp) + } + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + p := c.Plugins[0] + if len(p.Components) != tc.count || p.ManifestFormat != tc.format { + t.Fatalf("manifest selection: %+v", p) + } + if tc.count == 0 && p.ComponentStatus == model.AgentScanStatusComplete { + t.Fatal("invalid preferred manifest reported complete") + } + }) + } +} + +func TestCopilotSameNameOrigins(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + var records []map[string]any + for _, suffix := range []string{"one", "two"} { + payload := filepath.Join(root, "installed-plugins", suffix) + records = append(records, map[string]any{"name": "same", "marketplace": "", "cache_path": payload, "source": map[string]string{"source": "local", "path": filepath.Join(testHome, "sources", suffix)}}) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"same"}`) + } + data, _ := json.Marshal(map[string]any{"installedPlugins": records}) + fs.addFile(filepath.Join(root, "config.json"), string(data)) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins) != 2 || c.Plugins[0].InstanceID == c.Plugins[1].InstanceID { + t.Fatalf("origins merged: %+v", c) + } +} + +func TestCopilotRoots(t *testing.T) { + m := executor.NewMock() + m.SetEnv("COPILOT_HOME", filepath.Join(testHome, "custom")) + m.SetGOOS(model.PlatformDarwin) + if got := copilotCacheRoot(m, testHome); got != filepath.Join(testHome, "Library/Caches/copilot") { + t.Fatalf("config override moved cache: %s", got) + } + for _, goos := range []string{model.PlatformLinux, model.PlatformDarwin, model.PlatformWindows} { + m.SetGOOS(goos) + m.SetEnv("COPILOT_CACHE_HOME", filepath.Join(testHome, "cache")) + if got := copilotCacheRoot(m, testHome); got != filepath.Join(testHome, "cache") { + t.Fatalf("%s cache override: %s", goos, got) + } + } + m.SetEnv("COPILOT_HOME", "relative") + if got := copilotConfigRoot(m, testHome); got != filepath.Join(testHome, ".copilot") { + t.Fatalf("relative root accepted: %s", got) + } +} + +func TestCopilotEscapingSkillLink(t *testing.T) { + if runtime.GOOS == model.PlatformWindows { + t.Skip("symlink creation requires privileges") + } + home, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + root := filepath.Join(home, ".copilot") + payload := filepath.Join(root, "installed-plugins/test") + for _, dir := range []string{filepath.Join(payload, ".plugin"), filepath.Join(home, "unrelated/check")} { + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + } + for file, body := range map[string]string{filepath.Join(root, "config.json"): fmt.Sprintf(`{"installedPlugins":[{"name":"test","marketplace":"","cache_path":%q}]}`, payload), filepath.Join(payload, ".plugin/plugin.json"): `{"name":"test"}`, filepath.Join(home, "unrelated/check/SKILL.md"): validFrontmatter("unrelated", "Unrelated skill")} { + if err := os.WriteFile(file, []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(filepath.Join(home, "unrelated"), filepath.Join(payload, "skills")); err != nil { + t.Fatal(err) + } + d := NewSkillsDetector(executor.NewReal()) + definitions := 0 + s := &pluginScan{d: d, ctx: context.Background(), home: home, goos: runtime.GOOS, memo: map[string]*skillScan{}, definitions: &definitions, evidence: newPluginEvidence()} + c := s.detectCopilot() + if c == nil || len(c.Plugins) != 1 || c.Plugins[0].ComponentStatus == model.AgentScanStatusComplete || len(c.Plugins[0].Components) != 0 { + t.Fatalf("escaping link: %+v", c) + } +} + +func TestCopilotFailedCatalogProtectsRecordedComponents(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + catalog := filepath.Join(testHome, "catalog") + payload := filepath.Join(root, "installed-plugins/test") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"test","marketplace":"engineering","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(root, "settings.json"), fmt.Sprintf(`{"extraKnownMarketplaces":{"engineering":{"source":{"source":"directory","path":%q}}}}`, catalog)) + fs.addFile(filepath.Join(catalog, "marketplace.json"), `{`) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"test"}`) + fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check releases")) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins) != 1 || len(c.Plugins[0].Components) != 1 || c.Plugins[0].ComponentStatus == model.AgentScanStatusComplete { + t.Fatalf("failed catalog gave complete component coverage: %+v", c) + } +} + +func TestCopilotInvalidLivePayloadIsNotInstalled(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + catalog := filepath.Join(testHome, "catalog") + fs.addFile(filepath.Join(root, "settings.json"), fmt.Sprintf(`{"extraKnownMarketplaces":{"engineering":{"source":{"source":"directory","path":%q}}},"enabledPlugins":{"test@engineering":false}}`, catalog)) + fs.addFile(filepath.Join(catalog, "marketplace.json"), `{"name":"engineering","owner":{"name":"Example Engineering"},"plugins":[{"name":"test","source":"./plugins/test"}]}`) + fs.addFile(filepath.Join(catalog, "plugins/test/.plugin/plugin.json"), `{`) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins) != 0 || c.InstallationStatus == model.AgentScanStatusComplete { + t.Fatalf("invalid payload invented installation: %+v", c) + } +} + +func TestCopilotNativeRootSkillFallback(t *testing.T) { + for _, tc := range []struct { + name, manifest string + skillsDir bool + count int + }{ + {"no skills directory", `{"name":"fixture"}`, false, 1}, + {"empty skills directory", `{"name":"fixture"}`, true, 0}, + {"explicit missing override", `{"name":"fixture","skills":"missing"}`, false, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(root, "installed-plugins/fixture") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"fixture","marketplace":"","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), tc.manifest) + fs.addFile(filepath.Join(payload, "SKILL.md"), validFrontmatter("check", "Check release")) + if tc.skillsDir { + fs.addFile(filepath.Join(payload, "skills/.keep"), "") + } + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins[0].Components) != tc.count { + t.Fatalf("native root fallback: %+v", c.Plugins[0]) + } + }) + } +} + +func TestCopilotNativePathMCPPrecedence(t *testing.T) { + for _, tc := range []struct{ name, defaultFile, winner string }{ + {"manifest path", "", "path-server"}, {"dot file wins", ".mcp.json", "default-server"}, {"github file wins", ".github/mcp.json", "default-server"}, + } { + t.Run(tc.name, func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(root, "installed-plugins/fixture") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"fixture","marketplace":"","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"fixture","mcpServers":"./servers.json"}`) + fs.addFile(filepath.Join(payload, "servers.json"), `{"mcpServers":{"path-server":{"type":"http","url":"https://path.example/mcp"}}}`) + if tc.defaultFile != "" { + fs.addFile(filepath.Join(payload, tc.defaultFile), `{"mcpServers":{"default-server":{"url":"https://default.example/mcp"}}}`) + } + fs.commit() + p := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)).Plugins[0] + if len(p.Components) != 1 || p.Components[0].Name != tc.winner || p.ComponentStatus != model.AgentScanStatusComplete { + t.Fatalf("native path precedence: %+v", p) + } + }) + } +} + +func TestCopilotLegacyPreferencesFallback(t *testing.T) { + for _, malformed := range []bool{false, true} { + t.Run(fmt.Sprint(malformed), func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + catalog := filepath.Join(testHome, "catalog") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[],"extraKnownMarketplaces":{"engineering":{"source":{"source":"directory","path":%q}}},"enabledPlugins":{"review@engineering":true}}`, catalog)) + if malformed { + fs.addFile(filepath.Join(root, "settings.json"), `{`) + } + fs.addFile(filepath.Join(catalog, "marketplace.json"), `{"name":"engineering","owner":{"name":"Example Engineering"},"metadata":{"pluginRoot":"./plugins"},"plugins":[{"name":"review","source":"./review"}]}`) + fs.addFile(filepath.Join(catalog, "plugins/review/.plugin/plugin.json"), `{"name":"review"}`) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + if malformed { + if len(c.Plugins) != 0 || c.InstallationStatus == model.AgentScanStatusComplete { + t.Fatalf("malformed preferred settings fell back: %+v", c) + } + } else if len(c.Plugins) != 1 || c.Plugins[0].Enablement[0].SourcePath != filepath.Join(root, "config.json") { + t.Fatalf("legacy fallback missing: %+v", c) + } + }) + } +} + +func TestCopilotDefinitionLimitAndRecovery(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(root, "installed-plugins/test") + state := fmt.Sprintf(`{"installedPlugins":[{"name":"test","marketplace":"","cache_path":%q}]}`, payload) + fs.addFile(filepath.Join(root, "config.json"), state) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"test"}`) + fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check release")) + fs.commit() + d := NewSkillsDetector(m) + definitions := maxNewDefinitions + s := &pluginScan{d: d, ctx: context.Background(), home: testHome, goos: model.PlatformLinux, memo: map[string]*skillScan{}, definitions: &definitions, evidence: newPluginEvidence()} + c := s.detectCopilot() + if c.Plugins[0].ComponentStatus == model.AgentScanStatusComplete || definitions != maxNewDefinitions { + t.Fatal("definition cap reported complete or kept growing") + } + recovered := copilotContext(t, d.DetectAll(context.Background(), nil, nil)) + if recovered.Plugins[0].ComponentStatus != model.AgentScanStatusComplete || recovered.Plugins[0].InstanceID != c.Plugins[0].InstanceID { + t.Fatal("recovery changed identity or remained partial") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s.ctx = ctx + cancelled := s.detectCopilot() + if cancelled == nil || cancelled.InstallationStatus == model.AgentScanStatusComplete { + t.Fatal("cancelled scan authorized removal") + } +} + +func TestCopilotProtectedPayloadAndMCP(t *testing.T) { + if runtime.GOOS != model.PlatformDarwin { + t.Skip("macOS TCC guard") + } + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(testHome, "Library/Caches/copilot/private") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"test","marketplace":"","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"test"}`) + fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check release")) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).WithSkipper(tcc.New(testHome)).DetectAll(context.Background(), nil, nil)) + if c.Plugins[0].FilesPresent != nil || c.Plugins[0].ComponentStatus == model.AgentScanStatusComplete || len(c.Plugins[0].Components) != 0 { + t.Fatalf("protected payload read: %+v", c.Plugins[0]) + } + m.SetEnv("COPILOT_HOME", payload) + fs.addFile(filepath.Join(payload, "mcp-config.json"), `{"mcpServers":{"private":{"command":"node"}}}`) + fs.commit() + for _, config := range NewMCPDetector(m).WithSkipper(tcc.New(testHome)).DetectEnterprise(context.Background(), nil) { + if config.ConfigPath == filepath.Join(payload, "mcp-config.json") { + t.Fatal("protected standalone config read") + } + } +} + +func TestCopilotPluginMCPSuppression(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(root, "installed-plugins/test") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"test","marketplace":"","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"test"}`) + fs.addFile(filepath.Join(payload, ".mcp.json"), `{"mcpServers":{"docs":{"url":"https://docs.example/mcp"}}}`) + fs.commit() + result := NewSkillsDetector(m).DetectAll(context.Background(), nil, nil) + unrelated := filepath.Join(testHome, "project/.mcp.json") + configs := []model.MCPConfig{{ConfigSource: "discovered_mcp", ConfigPath: filepath.Join(payload, ".mcp.json")}, {ConfigSource: "discovered_mcp", ConfigPath: filepath.Join(root, "installed-plugins/orphan/.mcp.json")}, {ConfigSource: "project_mcp", ConfigPath: unrelated}} + kept := result.ReconcilePluginMCPCommunity(configs) + if len(kept) != 1 || kept[0].ConfigPath != unrelated { + t.Fatalf("MCP reconciliation: %+v", kept) + } + StripNestedMCPContent(result.Plugins) + for _, c := range result.Plugins.Contexts { + for _, p := range c.Plugins { + for _, component := range p.Components { + if component.MCPConfig != nil && component.MCPConfig.ConfigContentBase64 != "" { + t.Fatal("community retained MCP body") + } + } + } + } +} + +func TestCopilotCatalogKeepsIndependentMCP(t *testing.T) { + m, fs := newPluginMock() + catalog := filepath.Join(testHome, "catalog") + fs.addFile(filepath.Join(testHome, ".copilot/settings.json"), fmt.Sprintf(`{"extraKnownMarketplaces":{"engineering":{"source":{"source":"directory","path":%q}}},"enabledPlugins":{"review@engineering":true}}`, catalog)) + fs.addFile(filepath.Join(catalog, "marketplace.json"), `{"name":"engineering","owner":{"name":"Engineering"},"plugins":[{"name":"review","source":"./plugins/review"}]}`) + fs.addFile(filepath.Join(catalog, "plugins/review/.plugin/plugin.json"), `{"name":"review"}`) + fs.commit() + result := NewSkillsDetector(m).DetectAll(context.Background(), nil, nil) + file := filepath.Join(catalog, ".github/mcp.json") + configs := result.ReconcilePluginMCP([]model.MCPConfigEnterprise{{ConfigSource: "discovered_mcp", ConfigPath: file}}) + if len(configs) != 1 || configs[0].ConfigPath != file { + t.Fatalf("independent catalog MCP suppressed: %+v", configs) + } +} + +func TestCopilotRemoteCatalogCoverage(t *testing.T) { + for _, tc := range []struct{ name, source, dir, location string }{ + {"github", `{"source":"github","repo":"test-org/catalog","ref":"release"}`, "test-org-catalog", "https://github.com/test-org/catalog"}, + {"git", `{"source":"url","url":"https://git.example.com/catalog.git","ref":"release"}`, "https---git-example-com-catalog-git", "https://git.example.com/catalog.git"}, + } { + for _, state := range []string{"complete", "removed", "missing", "malformed", "wrong name", "protected"} { + t.Run(tc.name+"/"+state, func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + cache := filepath.Join(testHome, "copilot-cache") + if state == "protected" { + if runtime.GOOS != model.PlatformDarwin { + t.Skip("macOS TCC paths") + } + cache = filepath.Join(testHome, "Library/Caches/copilot") + } + m.SetEnv("COPILOT_CACHE_HOME", cache) + payload := filepath.Join(root, "installed-plugins/fixture") + catalog := filepath.Join(cache, "marketplaces", tc.dir, "marketplace.json") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"fixture","marketplace":"engineering","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(root, "settings.json"), fmt.Sprintf(`{"extraKnownMarketplaces":{"engineering":{"source":%s}}}`, tc.source)) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"fixture"}`) + if state != "removed" { + fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check releases")) + fs.addFile(filepath.Join(payload, ".mcp.json"), `{"mcpServers":{"docs":{"command":"node"}}}`) + } + switch state { + case "missing": + case "malformed": + fs.addFile(catalog, `{`) + case "wrong name": + fs.addFile(catalog, `{"name":"unrelated","owner":{"name":"Example"},"plugins":[{"name":"fixture","source":"./plugins/fixture"}]}`) + default: + fs.addFile(catalog, `{"name":"engineering","owner":{"name":"Example"},"metadata":{"pluginRoot":"./packages"},"plugins":[{"name":"fixture","source":"./plugins/fixture","mcpServers":{"ignored":{"command":"ignored"}}}]}`) + } + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).WithSkipper(tcc.New(testHome)).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins) != 1 { + t.Fatalf("registry installation lost: %+v", c) + } + p := c.Plugins[0] + complete := state == "complete" || state == "removed" + if (p.ComponentStatus == model.AgentScanStatusComplete) != complete { + t.Fatalf("component coverage: %+v", p) + } + want := 2 + if state == "removed" { + want = 0 + } + if len(p.Components) != want { + t.Fatalf("readable components lost or catalog fallback invented: %+v", p.Components) + } + if complete && (p.Source == nil || p.Source.Location != tc.location || p.Source.Subdirectory != "packages/plugins/fixture" || p.Source.RequestedRef != "release" || c.MarketplaceStatus != model.AgentScanStatusComplete) { + t.Fatalf("remote provenance: %+v / %+v", p, c) + } + }) + } + } +} + +func TestCopilotNativeCachePaths(t *testing.T) { + for _, tc := range []struct{ source, want string }{ + {`"test-org/catalog"`, "test-org-catalog"}, + {`{"source":"github","repo":"Test_Org/Catalog.git","ref":"release"}`, "Test_Org-Catalog.git"}, + {`{"source":"url","url":"https://example.com/a_b.git"}`, "https---example-com-a-b-git"}, + } { + cache := filepath.Join(testHome, "cache") + if got := copilotCatalogRoot(json.RawMessage(tc.source), cache); got != filepath.Join(cache, "marketplaces", tc.want) { + t.Errorf("source %s: %s", tc.source, got) + } + } +} diff --git a/internal/model/agentplugins_copilot_test.go b/internal/model/agentplugins_copilot_test.go deleted file mode 100644 index 5e3f4a45..00000000 --- a/internal/model/agentplugins_copilot_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package model - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "reflect" - "testing" -) - -// This fixture is shared verbatim with Agent API internal/ddbmodels/testdata. -func TestCopilotGoldenContract(t *testing.T) { - data, err := os.ReadFile("testdata/agent_plugins_v1_copilot_golden.json") - if err != nil { - t.Fatal(err) - } - type wireFixture struct { - Platform string `json:"platform"` - AgentPlugins AgentPlugins `json:"agent_plugins"` - } - var envelope wireFixture - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&envelope); err != nil { - t.Fatal(err) - } - scan := envelope.AgentPlugins - encoded, err := json.Marshal(envelope) - if err != nil { - t.Fatal(err) - } - var roundTrip wireFixture - if err := json.Unmarshal(encoded, &roundTrip); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(envelope, roundTrip) { - t.Fatal("Copilot wire fixture changed on round trip") - } - id := func(prefix string, parts ...string) string { - data, _ := json.Marshal(parts) - sum := sha256.Sum256(data) - return prefix + hex.EncodeToString(sum[:]) - } - if scan.SchemaVersion != 1 || len(scan.Contexts) != 1 { - t.Fatal("invalid fixture envelope") - } - c := scan.Contexts[0] - if c.Agent != AgentCopilot || c.ContextID != id("ctx_", c.Agent, c.ConfigRoot, c.PluginRoot) { - t.Fatal("context identity mismatch") - } - for _, m := range c.Marketplaces { - if m.MarketplaceID != id("market_", c.ContextID, m.Name) { - t.Fatal("marketplace identity mismatch") - } - } - for _, p := range c.Plugins { - if p.InstanceID != id("inst_", c.ContextID, p.NativeID, p.InstallationKind, p.Scope, p.ProjectPath, p.MarketplaceID) { - t.Fatal("installation identity mismatch") - } - for _, component := range p.Components { - if component.ComponentID != id("comp_", p.InstanceID, component.Kind, component.RelativePath, component.DeclarationPointer, component.Name) { - t.Fatal("component identity mismatch") - } - if component.Skill != nil && (component.Skill.Usage != nil) { - t.Fatal("skill attribution mismatch") - } - } - } -} diff --git a/internal/model/agentplugins_golden_test.go b/internal/model/agentplugins_golden_test.go index afe32ae9..7c49e031 100644 --- a/internal/model/agentplugins_golden_test.go +++ b/internal/model/agentplugins_golden_test.go @@ -111,7 +111,7 @@ func TestAgentPluginsGolden_CoversTheWholeVocabulary(t *testing.T) { what string want []string }{ - {"agent", []string{AgentClaudeCode, AgentCodex}}, + {"agent", []string{AgentClaudeCode, AgentCodex, AgentCopilot}}, {"status", []string{AgentScanStatusComplete, AgentScanStatusPartial, AgentScanStatusError, AgentScanStatusUnsupported}}, {"error", []string{AgentScanErrReadFailed, AgentScanErrParseFailed, AgentScanErrUnsupportedSchema, AgentScanErrLimitExceeded, AgentScanErrUnsafePath, AgentScanErrSourceChanged, AgentScanErrRootUnresolved}}, @@ -121,9 +121,9 @@ func TestAgentPluginsGolden_CoversTheWholeVocabulary(t *testing.T) { {"install", []string{PluginInstallMarketplace, PluginInstallDirectory, PluginInstallSynced, PluginInstallAccount, PluginInstallUnknown}}, {"scope", []string{PluginScopeUser, PluginScopeProject, PluginScopeLocal, PluginScopeSystem, PluginScopeUnknown}}, {"evidence", []string{PluginEvidenceRegistry, PluginEvidenceLocalConfig, PluginEvidenceSkillDirectory, PluginEvidenceSyncedDirectory, PluginEvidenceRemoteMarker}}, - {"manifest", []string{PluginManifestClaude, PluginManifestCodex, PluginManifestCursor, PluginManifestPortable, PluginManifestCatalog, PluginManifestNone, PluginManifestUnknown}}, + {"manifest", []string{PluginManifestClaude, PluginManifestCodex, PluginManifestCopilot, PluginManifestCursor, PluginManifestPortable, PluginManifestCatalog, PluginManifestNone, PluginManifestUnknown}}, {"component", []string{PluginComponentSkill, PluginComponentCommand, PluginComponentMCP, PluginComponentAgent, PluginComponentHook, PluginComponentLSP, PluginComponentApp}}, - {"nested source", []string{"claude_plugin", "codex_plugin"}}, + {"nested source", []string{"claude_plugin", "codex_plugin", "copilot_plugin"}}, // An empty kind denotes SKILL.md and remains valid beside explicit kinds. {"definition", []string{"", AgentDefinitionSkill, AgentDefinitionCommand}}, } { @@ -349,3 +349,24 @@ func TestAgentPluginsGolden_IdentityVectors(t *testing.T) { } } } + +func TestAgentPluginsGolden_CopilotObservations(t *testing.T) { + _, doc := loadAgentPluginsGolden(t) + for _, c := range doc.AgentPlugins.Contexts { + if c.Agent != AgentCopilot { + continue + } + if len(c.Plugins) != 2 { + t.Fatalf("Copilot plugins: %d", len(c.Plugins)) + } + for _, p := range c.Plugins { + for _, component := range p.Components { + if component.Skill != nil && (component.Skill.Agent != AgentCopilot || component.Skill.Source != "copilot_plugin" || component.Skill.Usage != nil) { + t.Fatal("Copilot skill attribution or usage changed") + } + } + } + return + } + t.Fatal("shared fixture is missing Copilot") +} diff --git a/internal/model/testdata/agent_plugins_v1_copilot_golden.json b/internal/model/testdata/agent_plugins_v1_copilot_golden.json deleted file mode 100644 index bdbe29e3..00000000 --- a/internal/model/testdata/agent_plugins_v1_copilot_golden.json +++ /dev/null @@ -1,154 +0,0 @@ -{ - "platform": "linux", - "agent_plugins": { - "schema_version": 1, - "collected_at_ms": 1791158400000, - "contexts": [ - { - "agent": "copilot", - "config_root": "/home/test/.copilot", - "plugin_root": "/home/test/.copilot/installed-plugins", - "marketplace_status": "complete", - "installation_status": "complete", - "marketplaces": [ - { - "marketplace_id": "market_325bf72161e5b6f82bc2510858881730493e2c8f14e1ba0c9151abf6406487b7", - "name": "release-review", - "source": { - "kind": "git", - "location": "https://git.example.com/test-org/catalog.git" - }, - "catalog_path": "/home/test/catalog/.github/plugin/marketplace.json", - "registered": true - }, - { - "marketplace_id": "market_dd0e0ea1e89f7667fb7bce7b5f89018ae89c90f61653423a6ccfac63de44db36", - "name": "local-review", - "source": { - "kind": "local", - "location": "/home/test/catalog" - }, - "catalog_path": "/home/test/catalog/.github/plugin/marketplace.json", - "registered": true - } - ], - "plugins": [ - { - "native_id": "release-review", - "name": "release-review", - "marketplace_id": "market_325bf72161e5b6f82bc2510858881730493e2c8f14e1ba0c9151abf6406487b7", - "installation_kind": "marketplace", - "scope": "user", - "source": { - "kind": "local", - "location": "./plugins/release-review" - }, - "manifest_format": "copilot", - "manifest_version": "1.0.0", - "installed": true, - "files_present": true, - "installation_evidence": "registry", - "enablement": [], - "component_status": "complete", - "components": [ - { - "kind": "skill", - "name": "review", - "relative_path": "skills/review/SKILL.md", - "skill": { - "skill_name": "review", - "skill_md_hash": "e2f3e3786dfe9011256952435702be706e6eb5f7553b6c94b7f746df1e15ac4d" - }, - "status": "complete", - "callable_names": [], - "component_id": "comp_222667de228313ab0c5937a6165e7d84066cdbe71edf82bbc66beee6a7098ce7" - }, - { - "kind": "agent", - "name": "reviewer", - "relative_path": "agents/reviewer.agent.md", - "status": "complete", - "callable_names": [], - "component_id": "comp_3140566e7551628232b566cd5abfb0e434f36bb24ab73e1e1aa84f4b0d9fa90a" - }, - { - "kind": "mcp", - "name": "example", - "relative_path": "agents/reviewer.agent.md", - "declaration_pointer": "/mcp-servers/example", - "mcp_config": { - "config_path": "/home/test/catalog/plugins/release-review/agents/reviewer.agent.md", - "config_content_base64": "eyJtY3BTZXJ2ZXJzIjp7ImV4YW1wbGUiOnsidXJsIjoiaHR0cHM6Ly9tY3AuZXhhbXBsZS5jb20ifX19" - }, - "status": "complete", - "callable_names": [], - "component_id": "comp_086288d09dbd19e77a46c7f9e38457805df51f5d1543144c01cecc41518b705a" - } - ], - "errors": [], - "configured_enabled": false, - "instance_id": "inst_28a64413e913230a8d9d5a4b7dbb8110e8f377382fffded3e52edb196835114b" - }, - { - "native_id": "local-review", - "name": "local-review", - "marketplace_id": "market_dd0e0ea1e89f7667fb7bce7b5f89018ae89c90f61653423a6ccfac63de44db36", - "installation_kind": "marketplace", - "scope": "project", - "source": { - "kind": "local", - "location": "./plugins/local-review" - }, - "manifest_format": "copilot", - "manifest_version": "1.0.0", - "installed": true, - "files_present": true, - "installation_evidence": "local_config", - "enablement": [], - "component_status": "complete", - "components": [ - { - "kind": "skill", - "name": "review", - "relative_path": "skills/review/SKILL.md", - "skill": { - "skill_name": "review", - "skill_md_hash": "e2f3e3786dfe9011256952435702be706e6eb5f7553b6c94b7f746df1e15ac4d" - }, - "status": "complete", - "callable_names": [], - "component_id": "comp_0bdb805e4aea9dd92379432ef7e4fcce285e44dfb9016552140a581a08511e9c" - }, - { - "kind": "agent", - "name": "reviewer", - "relative_path": "agents/reviewer.agent.md", - "status": "complete", - "callable_names": [], - "component_id": "comp_bd9657d55b0e9b1ef4172d8015c3c7fe0a52d048b9114f10f7d624ad498bb1e4" - }, - { - "kind": "mcp", - "name": "example", - "relative_path": "agents/reviewer.agent.md", - "declaration_pointer": "/mcp-servers/example", - "mcp_config": { - "config_path": "/home/test/catalog/plugins/local-review/agents/reviewer.agent.md", - "config_content_base64": "eyJtY3BTZXJ2ZXJzIjp7ImV4YW1wbGUiOnsidXJsIjoiaHR0cHM6Ly9tY3AuZXhhbXBsZS5jb20ifX19" - }, - "status": "complete", - "callable_names": [], - "component_id": "comp_2a6d341c50258c194d77ca2c6b5dc346083cdff38751027c0348f334050a4402" - } - ], - "errors": [], - "project_path": "/home/test/widgets", - "instance_id": "inst_b87e631b114651bde4794e053e5cdc1f7330ef45d6f6fe0c1e4af26058349be7" - } - ], - "errors": [], - "context_id": "ctx_27b0f806087df0ecb9ebf1f42154e85d4d0a12a047e7be6c4b3cccaa38ffffbf" - } - ] - } -} diff --git a/internal/model/testdata/agent_plugins_v1_golden.json b/internal/model/testdata/agent_plugins_v1_golden.json index db90b03d..27c16ae7 100644 --- a/internal/model/testdata/agent_plugins_v1_golden.json +++ b/internal/model/testdata/agent_plugins_v1_golden.json @@ -1068,6 +1068,165 @@ "marketplaces": [], "plugins": [], "errors": [] + }, + { + "agent": "copilot", + "config_root": "/home/test/.copilot", + "plugin_root": "/home/test/.copilot/installed-plugins", + "marketplace_status": "complete", + "installation_status": "complete", + "marketplaces": [ + { + "marketplace_id": "market_325bf72161e5b6f82bc2510858881730493e2c8f14e1ba0c9151abf6406487b7", + "name": "release-review", + "source": { + "kind": "git", + "location": "https://git.example.com/test-org/catalog.git" + }, + "catalog_path": "/home/test/catalog/.github/plugin/marketplace.json", + "registered": true + }, + { + "marketplace_id": "market_dd0e0ea1e89f7667fb7bce7b5f89018ae89c90f61653423a6ccfac63de44db36", + "name": "local-review", + "source": { + "kind": "local", + "location": "/home/test/catalog" + }, + "catalog_path": "/home/test/catalog/.github/plugin/marketplace.json", + "registered": true + } + ], + "plugins": [ + { + "native_id": "release-review", + "name": "release-review", + "marketplace_id": "market_325bf72161e5b6f82bc2510858881730493e2c8f14e1ba0c9151abf6406487b7", + "installation_kind": "marketplace", + "scope": "user", + "source": { + "kind": "local", + "location": "./plugins/release-review" + }, + "manifest_format": "copilot", + "manifest_version": "1.0.0", + "installed": true, + "files_present": true, + "installation_evidence": "registry", + "enablement": [], + "component_status": "complete", + "components": [ + { + "kind": "skill", + "name": "review", + "relative_path": "skills/review/SKILL.md", + "skill": { + "skill_name": "review", + "skill_md_hash": "e2f3e3786dfe9011256952435702be706e6eb5f7553b6c94b7f746df1e15ac4d", + "skill_slug": "review", + "agent": "copilot", + "source": "copilot_plugin", + "scope": "global", + "has_frontmatter": true + }, + "status": "complete", + "callable_names": [], + "component_id": "comp_222667de228313ab0c5937a6165e7d84066cdbe71edf82bbc66beee6a7098ce7" + }, + { + "kind": "agent", + "name": "reviewer", + "relative_path": "agents/reviewer.agent.md", + "status": "complete", + "callable_names": [], + "component_id": "comp_3140566e7551628232b566cd5abfb0e434f36bb24ab73e1e1aa84f4b0d9fa90a" + }, + { + "kind": "mcp", + "name": "example", + "relative_path": "agents/reviewer.agent.md", + "declaration_pointer": "/mcp-servers/example", + "mcp_config": { + "config_path": "/home/test/catalog/plugins/release-review/agents/reviewer.agent.md", + "config_content_base64": "eyJtY3BTZXJ2ZXJzIjp7ImV4YW1wbGUiOnsidXJsIjoiaHR0cHM6Ly9tY3AuZXhhbXBsZS5jb20ifX19", + "config_source": "copilot_plugin", + "vendor": "GitHub" + }, + "status": "complete", + "callable_names": [], + "component_id": "comp_086288d09dbd19e77a46c7f9e38457805df51f5d1543144c01cecc41518b705a" + } + ], + "errors": [], + "configured_enabled": false, + "instance_id": "inst_28a64413e913230a8d9d5a4b7dbb8110e8f377382fffded3e52edb196835114b" + }, + { + "native_id": "local-review", + "name": "local-review", + "marketplace_id": "market_dd0e0ea1e89f7667fb7bce7b5f89018ae89c90f61653423a6ccfac63de44db36", + "installation_kind": "marketplace", + "scope": "project", + "source": { + "kind": "local", + "location": "./plugins/local-review" + }, + "manifest_format": "copilot", + "manifest_version": "1.0.0", + "installed": true, + "files_present": true, + "installation_evidence": "local_config", + "enablement": [], + "component_status": "complete", + "components": [ + { + "kind": "skill", + "name": "review", + "relative_path": "skills/review/SKILL.md", + "skill": { + "skill_name": "review", + "skill_md_hash": "e2f3e3786dfe9011256952435702be706e6eb5f7553b6c94b7f746df1e15ac4d", + "skill_slug": "review", + "agent": "copilot", + "source": "copilot_plugin", + "scope": "project", + "has_frontmatter": true + }, + "status": "complete", + "callable_names": [], + "component_id": "comp_0bdb805e4aea9dd92379432ef7e4fcce285e44dfb9016552140a581a08511e9c" + }, + { + "kind": "agent", + "name": "reviewer", + "relative_path": "agents/reviewer.agent.md", + "status": "complete", + "callable_names": [], + "component_id": "comp_bd9657d55b0e9b1ef4172d8015c3c7fe0a52d048b9114f10f7d624ad498bb1e4" + }, + { + "kind": "mcp", + "name": "example", + "relative_path": "agents/reviewer.agent.md", + "declaration_pointer": "/mcp-servers/example", + "mcp_config": { + "config_path": "/home/test/catalog/plugins/local-review/agents/reviewer.agent.md", + "config_content_base64": "eyJtY3BTZXJ2ZXJzIjp7ImV4YW1wbGUiOnsidXJsIjoiaHR0cHM6Ly9tY3AuZXhhbXBsZS5jb20ifX19", + "config_source": "copilot_plugin", + "vendor": "GitHub" + }, + "status": "complete", + "callable_names": [], + "component_id": "comp_2a6d341c50258c194d77ca2c6b5dc346083cdff38751027c0348f334050a4402" + } + ], + "errors": [], + "project_path": "/home/test/widgets", + "instance_id": "inst_b87e631b114651bde4794e053e5cdc1f7330ef45d6f6fe0c1e4af26058349be7" + } + ], + "errors": [], + "context_id": "ctx_27b0f806087df0ecb9ebf1f42154e85d4d0a12a047e7be6c4b3cccaa38ffffbf" } ] }, From 7aac909089b70b0a122d6f9143db8d84e7f8489b Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Tue, 6 Oct 2026 00:24:00 +0530 Subject: [PATCH 3/9] fix(plugins): collect Copilot app catalogs and hook declarations --- internal/detector/plugins_copilot.go | 65 ++++++++++++++++++++++- internal/detector/plugins_test.go | 78 ++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) diff --git a/internal/detector/plugins_copilot.go b/internal/detector/plugins_copilot.go index ade7f7b9..72534774 100644 --- a/internal/detector/plugins_copilot.go +++ b/internal/detector/plugins_copilot.go @@ -98,6 +98,9 @@ func (s *pluginScan) detectCopilot() *model.AgentPluginContext { if cache != "" { s.evidence.suppress(filepath.Join(cache, "marketplaces")) } + if s.d.exec.GOOS() == model.PlatformDarwin { + s.evidence.suppress(filepath.Join(root, "Library", "Caches", "copilot", "marketplaces")) + } if state == fileAbsent && len(a.c.Plugins) == 0 && len(a.c.Marketplaces) == 0 && len(a.c.Errors) == 0 { return nil } @@ -211,7 +214,7 @@ func (a *copilotAdapter) settings(state map[string]json.RawMessage) { } } m.Source = source - a.catalogRoots[name] = copilotCatalogRoot(entry["source"], a.cache) + a.catalogRoots[name] = a.catalogRoot(entry["source"]) if enabled := jsonBool(entry["autoUpdate"]); enabled != nil && len(m.AutoUpdatePreferences) < maxAutoUpdatePrefs { m.AutoUpdatePreferences = append(m.AutoUpdatePreferences, model.EnablementObservation{Scope: layer.scope, ProjectPath: layer.project, SourcePath: layer.path, Enabled: *enabled}) if layer.scope == model.PluginScopeUser { @@ -334,6 +337,25 @@ func copilotCatalogRoot(raw json.RawMessage, cache string) string { return root } +func (a *copilotAdapter) catalogRoot(raw json.RawMessage) string { + root := copilotCatalogRoot(raw, a.cache) + if root == "" || a.s.d.exec.GOOS() != model.PlatformDarwin || filepath.IsAbs(a.s.d.exec.Getenv("COPILOT_CACHE_HOME")) { + return root + } + if source := copilotSource(raw); source == nil || source.Kind == model.PluginSourceLocal { + return root + } + if state, _, _ := a.s.stat(a.gd, root); state == fileDir { + return root + } + // The native Mac app keeps its CLI marketplace cache inside the Copilot home. + appRoot := copilotCatalogRoot(raw, filepath.Join(a.root, "Library", "Caches", "copilot")) + if state, _, _ := a.s.stat(a.gd, appRoot); state == fileDir { + return appRoot + } + return root +} + func (a *copilotAdapter) registry(raw json.RawMessage, file string) { var record map[string]json.RawMessage if json.Unmarshal(raw, &record) != nil || record == nil { @@ -371,7 +393,7 @@ func (a *copilotAdapter) registry(raw json.RawMessage, file string) { if m.Source == nil && !m.Registered && a.c.MarketplaceStatus == model.AgentScanStatusComplete && (market == "copilot-plugins" || market == "awesome-copilot") { raw, _ := json.Marshal("github/" + market) m.Source = copilotSource(raw) - a.catalogRoots[market] = copilotCatalogRoot(raw, a.cache) + a.catalogRoots[market] = a.catalogRoot(raw) } p.MarketplaceID = m.MarketplaceID root := a.catalogRoots[market] @@ -742,6 +764,7 @@ func (a *copilotAdapter) components(p *model.PluginObservation) { a.agent(r, file) } } + a.hooks(r, doc, portable) if portable { a.mcpFile(r, "mcp.json", strings.Replace(schema, "plugin.schema.json", "mcp.schema.json", 1)) } else { @@ -769,6 +792,44 @@ func (a *copilotAdapter) components(p *model.PluginObservation) { } } +func (a *copilotAdapter) hooks(r *pluginRootScan, doc map[string]json.RawMessage, portable bool) { + files := []string{"hooks.json", "hooks/hooks.json"} + required := false + if portable { + files = []string{"com.github.copilot/hooks/hooks.json"} + } else if raw, ok := doc["hooks"]; ok { + if file := jsonString(raw); file != "" { + files, required = []string{file}, true + } else { + var hooks map[string]json.RawMessage + if json.Unmarshal(raw, &hooks) != nil || hooks == nil { + copilotComponentError(r.p, model.AgentScanErrParseFailed, r.p.ManifestPath) + return + } + rel, _ := relSlash(r.root, r.p.ManifestPath) + r.declared(model.PluginComponentHook, "hooks", rel, "/hooks", r.p.ManifestPath, model.AgentScanStatusComplete) + return + } + } + for _, rel := range files { + file, safe := insideRoot(r.root, rel) + if !safe { + copilotComponentError(r.p, model.AgentScanErrUnsafePath, r.p.ManifestPath) + continue + } + state, _, _ := a.s.stat(a.gd, file) + if state == fileAbsent && !required { + continue + } + status := model.AgentScanStatusComplete + if state != fileRegular { + status = model.AgentScanStatusError + } + rel, _ = relSlash(r.root, file) + r.declared(model.PluginComponentHook, "hooks", rel, "", file, status) + } +} + func (a *copilotAdapter) mcpFile(r *pluginRootScan, rel, schema string) bool { file := filepath.Join(r.root, filepath.FromSlash(rel)) doc, absent, code := a.object(file) diff --git a/internal/detector/plugins_test.go b/internal/detector/plugins_test.go index 610ddede..ae4cfddb 100644 --- a/internal/detector/plugins_test.go +++ b/internal/detector/plugins_test.go @@ -2383,3 +2383,81 @@ func TestCopilotNativeCachePaths(t *testing.T) { } } } + +func TestCopilotAppMarketplaceCache(t *testing.T) { + if runtime.GOOS != model.PlatformDarwin { + t.Skip("native macOS app cache") + } + for _, state := range []string{"complete", "malformed", "explicit override"} { + t.Run(state, func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(root, "installed-plugins/engineering/review") + catalog := filepath.Join(root, "Library/Caches/copilot/marketplaces/test-org-catalog/marketplace.json") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"review","marketplace":"engineering","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(root, "settings.json"), `{"extraKnownMarketplaces":{"engineering":{"source":{"source":"github","repo":"test-org/catalog"}}}}`) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"review"}`) + fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check releases")) + data := `{"name":"engineering","owner":{"name":"Engineering"},"plugins":[{"name":"review","source":{"source":"github","repo":"test-org/review"}}]}` + if state == "malformed" { + data = `{` + } + if state == "explicit override" { + m.SetEnv("COPILOT_CACHE_HOME", filepath.Join(testHome, "configured-cache")) + } + fs.addFile(catalog, data) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).WithSkipper(tcc.New(testHome)).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins) != 1 || len(c.Plugins[0].Components) != 1 { + t.Fatalf("readable installed evidence lost: %+v", c) + } + p := c.Plugins[0] + if state == "complete" { + if c.MarketplaceStatus != model.AgentScanStatusComplete || p.ComponentStatus != model.AgentScanStatusComplete || p.Source == nil || p.Source.Location != "https://github.com/test-org/review" || len(c.Errors) != 0 { + t.Fatalf("app cache provenance missing: %+v / %+v", c, p) + } + } else if p.ComponentStatus == model.AgentScanStatusComplete || p.Source != nil { + t.Fatalf("unverified catalog accepted: %+v", p) + } + }) + } +} + +func TestCopilotHookDeclarations(t *testing.T) { + for _, tc := range []struct{ name, manifest, file, wantStatus string }{ + {"manifest path", `{"name":"review","hooks":"hooks/review.json"}`, "hooks/review.json", model.AgentScanStatusComplete}, + {"default path", `{"name":"review"}`, "hooks/hooks.json", model.AgentScanStatusComplete}, + {"inline", `{"name":"review","hooks":{"sessionStart":[]}}`, "", model.AgentScanStatusComplete}, + {"missing", `{"name":"review","hooks":"hooks/missing.json"}`, "", model.AgentScanStatusError}, + {"unsafe", `{"name":"review","hooks":"../outside.json"}`, "", ""}, + {"portable", `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review"}`, "com.github.copilot/hooks/hooks.json", model.AgentScanStatusComplete}, + } { + t.Run(tc.name, func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(root, "installed-plugins/_direct/review") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"review","marketplace":"","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(payload, "plugin.json"), tc.manifest) + if tc.file != "" { + fs.addFile(filepath.Join(payload, tc.file), `{"hooks":{"sessionStart":[{"type":"command","bash":"HOOK_BODY_MUST_NOT_LEAVE_DEVICE"}]}}`) + } + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins) != 1 { + t.Fatalf("installation lost: %+v", c) + } + p := c.Plugins[0] + if tc.wantStatus == "" { + if len(p.Components) != 0 || p.ComponentStatus == model.AgentScanStatusComplete { + t.Fatalf("escaping hook accepted: %+v", p) + } + } else if len(p.Components) != 1 || p.Components[0].Kind != model.PluginComponentHook || p.Components[0].Status != tc.wantStatus { + t.Fatalf("hook declaration missing: %+v", p) + } + data, _ := json.Marshal(c) + if strings.Contains(string(data), "HOOK_BODY_MUST_NOT_LEAVE_DEVICE") { + t.Fatal("hook body leaked into inventory") + } + }) + } +} From 8b8e85c2f12c81d3b07dd735abf1cc7b54285622 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Tue, 6 Oct 2026 01:22:39 +0530 Subject: [PATCH 4/9] feat(plugins): inventory Copilot editor plugins and component declarations --- internal/detector/plugins.go | 4 +- internal/detector/plugins_copilot.go | 626 +++++++++++++++++- internal/detector/plugins_test.go | 261 ++++++++ internal/executor/user_aware.go | 2 + internal/executor/user_aware_test.go | 2 +- internal/model/agentplugins.go | 1 + internal/model/agentplugins_golden_test.go | 2 +- .../testdata/agent_plugins_v1_golden.json | 8 + 8 files changed, 896 insertions(+), 10 deletions(-) diff --git a/internal/detector/plugins.go b/internal/detector/plugins.go index 40e60286..685617ad 100644 --- a/internal/detector/plugins.go +++ b/internal/detector/plugins.go @@ -161,7 +161,9 @@ func (d *SkillsDetector) DetectPlugins(ctx context.Context, result *SkillsResult s.now = d.now() } var contexts []*model.AgentPluginContext - for _, c := range []*model.AgentPluginContext{s.detectClaude(), s.detectCodex(), s.detectCopilot()} { + candidates := []*model.AgentPluginContext{s.detectClaude(), s.detectCodex(), s.detectCopilot()} + candidates = append(candidates, s.detectCopilotEditors()...) + for _, c := range candidates { if c != nil { // Missing projects can hide project settings and catalogs. if s.projectsIncomplete { diff --git a/internal/detector/plugins_copilot.go b/internal/detector/plugins_copilot.go index 72534774..8d027897 100644 --- a/internal/detector/plugins_copilot.go +++ b/internal/detector/plugins_copilot.go @@ -2,8 +2,10 @@ package detector import ( "encoding/json" + "net/url" "path" "path/filepath" + "slices" "strings" "github.com/tailscale/hujson" @@ -31,6 +33,329 @@ type copilotAdapter struct { markets map[string]*model.MarketplaceObservation cache string catalogRoots map[string]string + editor bool +} + +// Editor installations have their own receipts, separate from the CLI registry. +func (s *pluginScan) detectCopilotEditors() []*model.AgentPluginContext { + if executor.UserEnvironmentError(s.d.exec) != nil { + root := filepath.Join(s.home, ".vscode", "agent-plugins") + return []*model.AgentPluginContext{s.unresolvedContext(model.AgentCopilot, root, root)} + } + restore := s.snapshotRetry() + for attempt := 0; ; attempt++ { + if attempt > 0 { + restore() + } + s.reads = map[string]pluginMetadataStamp{} + s.sourceChanged = false + contexts := s.copilotEditorContexts() + if !s.snapshotChanged() { + return contexts + } + if attempt == 1 { + for _, c := range contexts { + degrade(&c.InstallationStatus, model.AgentScanStatusPartial) + degrade(&c.MarketplaceStatus, model.AgentScanStatusPartial) + scanError(&c.Errors, model.AgentScanError{Code: model.AgentScanErrSourceChanged, SourcePath: c.ConfigRoot}) + for i := range c.Plugins { + degrade(&c.Plugins[i].ComponentStatus, model.AgentScanStatusPartial) + } + } + return contexts + } + } +} + +func (s *pluginScan) copilotEditorContexts() []*model.AgentPluginContext { + roots := []string{filepath.Join(s.home, ".vscode", "agent-plugins"), filepath.Join(s.home, ".vscode-insiders", "agent-plugins")} + if root := s.d.exec.Getenv("VSCODE_AGENT_PLUGINS"); filepath.IsAbs(root) { + roots = append(roots, filepath.Clean(root)) + } else if root := s.d.exec.Getenv("VSCODE_PORTABLE"); filepath.IsAbs(root) { + roots = append(roots, filepath.Join(root, "agent-plugins")) + } + var contexts []*model.AgentPluginContext + seen := map[string]bool{} + for _, root := range roots { + if seen[root] { + continue + } + seen[root] = true + a := &copilotAdapter{s: s, gd: s.guarded(root), root: root, c: s.newContext(model.AgentCopilot, root, root), markets: map[string]*model.MarketplaceObservation{}, editor: true} + file := filepath.Join(root, "installed.json") + doc, absent, code := a.object(file) + if absent { + continue + } + var version int + var records []json.RawMessage + if code == "" && (json.Unmarshal(doc["version"], &version) != nil || version != 1 || json.Unmarshal(doc["installed"], &records) != nil || records == nil) { + code = model.AgentScanErrUnsupportedSchema + } + if code != "" { + a.fail(code, file) + } else { + for i, raw := range records { + if i >= maxPluginObs || s.ctx.Err() != nil { + a.fail(model.AgentScanErrLimitExceeded, file) + break + } + a.editorReceipt(raw, file) + } + } + for _, name := range sortedMapKeys(a.markets) { + a.c.Marketplaces = append(a.c.Marketplaces, *a.markets[name]) + } + s.evidence.suppress(root) + contexts = append(contexts, a.c) + } + for _, variant := range []string{"Code", "Code - Insiders"} { + dotRoot := filepath.Join(s.home, ".vscode") + if variant == "Code - Insiders" { + dotRoot = filepath.Join(s.home, ".vscode-insiders") + } + if state, _, _ := s.stat(s.guarded(dotRoot), dotRoot); state != fileDir { + continue + } + var userRoot string + switch s.goos { + case model.PlatformDarwin: + userRoot = filepath.Join(s.home, "Library", "Application Support", variant, "User") + case model.PlatformWindows: + if appData := s.d.exec.Getenv("APPDATA"); filepath.IsAbs(appData) { + userRoot = filepath.Join(appData, variant, "User") + } + default: + config := s.d.exec.Getenv("XDG_CONFIG_HOME") + if !filepath.IsAbs(config) { + config = filepath.Join(s.home, ".config") + } + userRoot = filepath.Join(config, variant, "User") + } + if userRoot == "" { + continue + } + if c := s.copilotEditorSettings(filepath.Join(userRoot, "settings.json"), model.PluginScopeUser, ""); c != nil { + contexts = append(contexts, c) + } + gd := s.guarded(userRoot) + profiles := filepath.Join(userRoot, "profiles") + if state, _, _ := s.stat(gd, profiles); state == fileDir { + entries, code := s.listDir(gd, profiles) + if code != "" { + contexts = append(contexts, s.unresolvedContext(model.AgentCopilot, profiles, profiles)) + } + for i, entry := range entries { + if i >= maxPluginContexts || s.ctx.Err() != nil { + contexts = append(contexts, s.unresolvedContext(model.AgentCopilot, profiles, profiles)) + break + } + if entry.IsDir() { + if c := s.copilotEditorSettings(filepath.Join(profiles, entry.Name(), "settings.json"), model.PluginScopeUser, ""); c != nil { + contexts = append(contexts, c) + } + } + } + } + } + for _, project := range s.projects { + if c := s.copilotEditorSettings(filepath.Join(project, ".vscode", "settings.json"), model.PluginScopeProject, project); c != nil { + contexts = append(contexts, c) + } + } + return contexts +} + +func (s *pluginScan) copilotEditorSettings(file, scope, project string) *model.AgentPluginContext { + root := filepath.Dir(file) + a := &copilotAdapter{s: s, gd: s.guarded(root), root: root, c: s.newContext(model.AgentCopilot, root, root), editor: true} + doc, absent, code := a.object(file) + if absent { + return nil + } + if code != "" { + a.fail(code, file) + return a.c + } + raw, present := doc["chat.pluginLocations"] + if !present { + return nil + } + var locations map[string]bool + if json.Unmarshal(raw, &locations) != nil || locations == nil { + a.fail(model.AgentScanErrParseFailed, file) + return a.c + } + for i, location := range sortedMapKeys(locations) { + if i >= maxPluginObs || s.ctx.Err() != nil { + a.fail(model.AgentScanErrLimitExceeded, file) + break + } + payload := location + if strings.HasPrefix(payload, "~/") { + payload = filepath.Join(s.home, filepath.FromSlash(payload[2:])) + } else if !isAbsPath(payload) && project != "" { + payload = filepath.Join(project, filepath.FromSlash(payload)) + } + if !isAbsPath(payload) { + a.fail(model.AgentScanErrRootUnresolved, file) + continue + } + payload = cleanPluginPath(payload) + p := newPlugin(filepath.Base(payload), filepath.Base(payload), model.PluginInstallDirectory, scope) + p.InstallPath, p.SourcePath, p.ProjectPath = payload, payload, project + p.Source = &model.SourceLocator{Kind: model.PluginSourceLocal, NativeKind: "local", Location: payload} + p.Installed, p.ConfiguredEnabled = boolPtr(true), boolPtr(locations[location]) + p.InstallationEvidence = model.PluginEvidenceLocalConfig + p.Enablement = append(p.Enablement, model.EnablementObservation{Scope: scope, ProjectPath: project, SourcePath: file, Enabled: locations[location]}) + a.finish(p) + } + return a.c +} + +func (a *copilotAdapter) editorReceipt(raw json.RawMessage, file string) { + var row struct { + URI string `json:"pluginUri"` + Marketplace string `json:"marketplace"` + Name string `json:"name"` + } + if json.Unmarshal(raw, &row) != nil { + a.fail(model.AgentScanErrParseFailed, file) + return + } + u, err := url.Parse(row.URI) + if err != nil || u.Scheme != "file" || u.Host != "" && u.Host != "localhost" || u.RawQuery != "" || u.Fragment != "" { + a.fail(model.AgentScanErrUnsupportedSchema, file) + return + } + payload := filepath.FromSlash(u.Path) + if a.s.goos == model.PlatformWindows && len(payload) > 3 && payload[0] == filepath.Separator && payload[2] == ':' { + payload = payload[1:] + } + if !isAbsPath(payload) { + a.fail(model.AgentScanErrUnsafePath, file) + return + } + payload = cleanPluginPath(payload) + name := row.Name + if name == "" { + name = filepath.Base(payload) + } + p := newPlugin(name+"@"+row.Marketplace, name, model.PluginInstallMarketplace, model.PluginScopeUser) + p.InstallPath, p.Installed, p.InstallationEvidence = payload, boolPtr(true), model.PluginEvidenceRegistry + market := a.market(row.Marketplace) + market.Registered = true + p.MarketplaceID = market.MarketplaceID + ref, branch, _ := strings.Cut(row.Marketplace, "#") + encoded, _ := json.Marshal(ref) + market.Source = copilotSource(encoded) + if market.Source != nil { + market.Source.RequestedRef = branch + } + // Receipts can point to a payload repository separate from the catalog clone. + roots := []string{} + if root := copilotEditorGitRoot(a.root, market.Source, true); root != "" { + roots = append(roots, root) + } + for root, depth := payload, 0; depth < 8; root, depth = filepath.Dir(root), depth+1 { + if _, within := relSlash(a.root, root); !within { + break + } + if !slices.Contains(roots, root) { + roots = append(roots, root) + } + if root == a.root { + break + } + } + for _, root := range roots { + for _, rel := range copilotCatalogPaths { + doc, absent, code := a.object(filepath.Join(root, filepath.FromSlash(rel))) + if absent { + continue + } + if code != "" { + a.fail(code, filepath.Join(root, filepath.FromSlash(rel))) + continue + } + if name := jsonString(doc["name"]); name != "" { + market.Name = name + } + entry, found := a.catalogEntry(market, root, name) + if found { + selected, safe := insideRoot(root, jsonString(entry["source"])) + if declared := copilotSource(entry["source"]); len(entry["source"]) > 0 && entry["source"][0] == '{' && declared != nil && (declared.Kind == model.PluginSourceGit || declared.Kind == model.PluginSourceGitHub) { + selected = copilotEditorGitRoot(a.root, declared, false) + safe = selected != "" + if safe && a.s.hashPath(selected) == a.s.hashPath(payload) { + p.Source = declared + } + } else if safe && a.s.hashPath(selected) == a.s.hashPath(payload) && market.Source != nil { + source := *market.Source + if source.Kind == model.PluginSourceLocal { + source.Location, p.SourcePath = payload, payload + } else { + source.Subdirectory, _ = relSlash(root, payload) + } + p.Source = &source + } + } + break + } + if p.Source != nil { + break + } + } + if p.Source == nil { + copilotComponentError(p, model.AgentScanErrRootUnresolved, file) + degrade(&a.c.MarketplaceStatus, model.AgentScanStatusPartial) + } + a.finish(p) +} + +// VS Code uses repository paths plus distinct catalog and payload revision suffixes. +func copilotEditorGitRoot(store string, source *model.SourceLocator, catalog bool) string { + if source == nil || source.Kind != model.PluginSourceGit && source.Kind != model.PluginSourceGitHub { + return "" + } + u, err := url.Parse(source.Location) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return "" + } + sanitize := func(value string) string { + return strings.Map(func(r rune) rune { + if strings.ContainsRune(`\/:*?"<>|`, r) { + return '_' + } + return r + }, value) + } + parts := []string{sanitize(strings.ToLower(u.Host))} + repo := strings.Trim(strings.TrimSuffix(strings.TrimLeft(u.Path, "/"), ".git"), "/") + for _, part := range strings.Split(repo, "/") { + if part == "" || part == "." || part == ".." { + return "" + } + parts = append(parts, sanitize(part)) + } + if catalog && source.RequestedRef != "" { + parts = append(parts, "ref_"+url.PathEscape(source.RequestedRef)) + } else if !catalog && source.RequestedSHA != "" { + parts = append(parts, "sha_"+sanitize(source.RequestedSHA)) + } else if !catalog && source.RequestedRef != "" { + parts = append(parts, "ref_"+sanitize(source.RequestedRef)) + } + root, safe := insideRoot(store, filepath.Join(parts...)) + if !safe { + return "" + } + if !catalog && source.Subdirectory != "" { + root, safe = insideRoot(root, source.Subdirectory) + if !safe { + return "" + } + } + return root } func copilotConfigRoot(exec executor.Executor, home string) string { @@ -575,7 +900,7 @@ func (a *copilotAdapter) catalogEntry(m *model.MarketplaceObservation, root, nam return nil, false } var owner map[string]json.RawMessage - if jsonString(doc["name"]) != m.Name || json.Unmarshal(doc["owner"], &owner) != nil || strings.TrimSpace(jsonString(owner["name"])) == "" { + if !a.editor && (jsonString(doc["name"]) != m.Name || json.Unmarshal(doc["owner"], &owner) != nil || strings.TrimSpace(jsonString(owner["name"])) == "") { a.fail(model.AgentScanErrParseFailed, file) return nil, false } @@ -671,7 +996,11 @@ func (a *copilotAdapter) components(p *model.PluginObservation) { p.ManifestFormat = model.PluginManifestPortable p.ManifestPath = filepath.Join(p.InstallPath, "plugin.json") } else { - for _, rel := range copilotManifestPaths { + manifestPaths := copilotManifestPaths + if a.editor { + manifestPaths = []string{".plugin/plugin.json", ".claude-plugin/plugin.json", "plugin.json"} + } + for _, rel := range manifestPaths { file := filepath.Join(p.InstallPath, filepath.FromSlash(rel)) var missing bool if rel == "plugin.json" { @@ -696,10 +1025,16 @@ func (a *copilotAdapter) components(p *model.PluginObservation) { } if doc == nil { p.ManifestFormat = model.PluginManifestNone - copilotComponentError(p, model.AgentScanErrReadFailed, p.InstallPath) - return + if !a.editor { + copilotComponentError(p, model.AgentScanErrReadFailed, p.InstallPath) + return + } + doc = map[string]json.RawMessage{} } p.ManifestName = jsonString(doc["name"]) + if a.editor && p.ManifestFormat == model.PluginManifestNone { + p.ManifestName = p.Name + } if p.ManifestName == "" { copilotComponentError(p, model.AgentScanErrParseFailed, p.ManifestPath) return @@ -713,7 +1048,33 @@ func (a *copilotAdapter) components(p *model.PluginObservation) { skills, agents := []string{"skills"}, []string{"agents"} if portable { agents = []string{"com.github.copilot/agents"} - } else { + } + if a.editor { + configured := doc + namespace := "" + if portable { + namespace = "com.github.copilot" + var extensions map[string]json.RawMessage + if raw := doc["extensions"]; raw != nil { + if json.Unmarshal(raw, &extensions) != nil || extensions == nil { + copilotComponentError(p, model.AgentScanErrParseFailed, p.ManifestPath) + } + } + configured = nil + if raw := extensions[namespace]; raw != nil { + if json.Unmarshal(raw, &configured) != nil || configured == nil { + copilotComponentError(p, model.AgentScanErrParseFailed, p.ManifestPath) + } + } + } + for key, dst := range map[string]*[]string{"skills": &skills, "agents": &agents} { + paths, valid := copilotEditorPaths(configured[key], (*dst)[0], namespace) + if !valid { + copilotComponentError(p, model.AgentScanErrUnsupportedSchema, p.ManifestPath) + } + *dst = paths + } + } else if !portable { for key, dst := range map[string]*[]string{"skills": &skills, "agents": &agents} { if raw, ok := doc[key]; ok { *dst = stringList(raw) @@ -764,6 +1125,12 @@ func (a *copilotAdapter) components(p *model.PluginObservation) { a.agent(r, file) } } + a.commandAndRuleComponents(r, doc, portable) + a.lspComponents(r, doc, portable) + if a.editor { + a.editorHookAndMCPComponents(r, doc, portable) + return + } a.hooks(r, doc, portable) if portable { a.mcpFile(r, "mcp.json", strings.Replace(schema, "plugin.schema.json", "mcp.schema.json", 1)) @@ -792,6 +1159,248 @@ func (a *copilotAdapter) components(p *model.PluginObservation) { } } +// Commands and rules carry file metadata, not their instruction bodies. +func (a *copilotAdapter) commandAndRuleComponents(r *pluginRootScan, doc map[string]json.RawMessage, portable bool) { + r.gd = r.gd.componentReader(r.root) + namespace := "" + if portable { + namespace = "com.github.copilot" + var extensions map[string]json.RawMessage + _ = json.Unmarshal(doc["extensions"], &extensions) + doc = nil + _ = json.Unmarshal(extensions[namespace], &doc) + } + for _, kind := range []string{"commands", "rules"} { + fallback := path.Join(namespace, kind) + paths, valid := copilotEditorPaths(doc[kind], fallback, namespace) + if !valid { + copilotComponentError(r.p, model.AgentScanErrUnsupportedSchema, r.p.ManifestPath) + } + for _, declared := range paths { + file, safe := insideRoot(r.root, declared) + if !safe { + copilotComponentError(r.p, model.AgentScanErrUnsafePath, r.p.ManifestPath) + continue + } + state, _, err := a.s.stat(r.gd, file) + code := "" + if err != nil { + code = readCode(err) + } + if state == fileAbsent && declared == fallback { + continue + } + if code != "" || state == fileAbsent { + if code == "" { + code = model.AgentScanErrReadFailed + } + copilotComponentError(r.p, code, file) + continue + } + files := []string{file} + if state == fileDir { + entries, code := a.s.listDir(r.gd, file) + if code != "" { + copilotComponentError(r.p, code, file) + } + files = nil + for _, name := range sortedEntryNames(entries) { + entry := dirEntryByName(entries, name) + if entry.Type().IsRegular() { + files = append(files, filepath.Join(file, name)) + } + } + } + for _, file := range files { + name := filepath.Base(file) + lower := strings.ToLower(name) + rel, _ := relSlash(r.root, file) + if kind == "commands" && strings.HasSuffix(lower, ".md") { + name = name[:len(name)-3] + r.commandComponent(file, rel, name, r.p.Name+":"+name) + } else if kind == "rules" && (strings.HasSuffix(lower, ".mdc") || strings.HasSuffix(lower, ".md")) { + if !r.takeDefinition() { + return + } + name = strings.TrimSuffix(strings.TrimSuffix(name, filepath.Ext(name)), ".instructions") + _, absent, code := a.s.readMetadata(r.gd, file) + if absent { + code = model.AgentScanErrReadFailed + } + if code != "" { + copilotComponentError(r.p, code, file) + continue + } + r.declared(model.PluginComponentRule, name, rel, "", file, model.AgentScanStatusComplete) + } + } + } + } +} + +// LSP declarations use the same bounded metadata shape as Claude LSP components. +func (a *copilotAdapter) lspComponents(r *pluginRootScan, doc map[string]json.RawMessage, portable bool) { + r.gd = r.gd.componentReader(r.root) + files := []string{"lsp.json", ".github/lsp.json", "lsp-config/servers.json"} + if portable { + files = []string{"com.github.copilot/lsp.json"} + } + add := func(object map[string]json.RawMessage, rel, pointer, file string) { + if raw := object["lspServers"]; raw != nil { + var nested map[string]json.RawMessage + if json.Unmarshal(raw, &nested) != nil || nested == nil { + copilotComponentError(r.p, model.AgentScanErrParseFailed, file) + return + } + object, pointer = nested, pointer+"/lspServers" + } + for _, name := range sortedMapKeys(object) { + var server map[string]json.RawMessage + if json.Unmarshal(object[name], &server) != nil || server == nil { + copilotComponentError(r.p, model.AgentScanErrParseFailed, file) + continue + } + r.declared(model.PluginComponentLSP, name, rel, pointer+"/"+strings.NewReplacer("~", "~0", "/", "~1").Replace(name), file, model.AgentScanStatusComplete) + } + } + if !portable && doc["lspServers"] != nil { + if file := jsonString(doc["lspServers"]); file != "" { + files = []string{file} + } else { + var object map[string]json.RawMessage + if json.Unmarshal(doc["lspServers"], &object) != nil || object == nil { + copilotComponentError(r.p, model.AgentScanErrParseFailed, r.p.ManifestPath) + } else { + rel, _ := relSlash(r.root, r.p.ManifestPath) + add(object, rel, "/lspServers", r.p.ManifestPath) + } + return + } + } + for _, declared := range files { + file, safe := insideRoot(r.root, declared) + if !safe { + copilotComponentError(r.p, model.AgentScanErrUnsafePath, r.p.ManifestPath) + continue + } + object, absent, code := a.s.readJSONObject(r.gd, file) + if absent && (portable || doc["lspServers"] == nil) { + continue + } + if absent { + code = model.AgentScanErrReadFailed + } + if code != "" { + copilotComponentError(r.p, code, file) + continue + } + rel, _ := relSlash(r.root, file) + add(object, rel, "", file) + return + } +} + +func (a *copilotAdapter) editorHookAndMCPComponents(r *pluginRootScan, doc map[string]json.RawMessage, portable bool) { + namespace, hookDefault, mcpDefault := "", "hooks/hooks.json", ".mcp.json" + if r.p.ManifestPath == filepath.Join(r.root, "plugin.json") || r.p.ManifestFormat == model.PluginManifestNone { + hookDefault = "hooks.json" + } + if portable { + namespace, hookDefault, mcpDefault = "com.github.copilot", "com.github.copilot/hooks/hooks.json", "mcp.json" + var extensions map[string]json.RawMessage + _ = json.Unmarshal(doc["extensions"], &extensions) + doc = nil + _ = json.Unmarshal(extensions[namespace], &doc) + } + for _, kind := range []string{"hooks", "mcpServers"} { + raw := doc[kind] + var object map[string]json.RawMessage + _ = json.Unmarshal(raw, &object) + if object != nil && object["paths"] == nil { + rel, _ := relSlash(r.root, r.p.ManifestPath) + if kind == "hooks" { + r.declared(model.PluginComponentHook, "hooks", rel, "/hooks", r.p.ManifestPath, model.AgentScanStatusComplete) + continue + } + before := len(r.p.Components) + a.mcpComponents(r, raw, rel, "/mcpServers", r.p.ManifestPath) + if len(r.p.Components) > before { + continue + } + raw = nil + } + fallback := hookDefault + if kind == "mcpServers" { + fallback = mcpDefault + } + paths, valid := copilotEditorPaths(raw, fallback, namespace) + if !valid { + copilotComponentError(r.p, model.AgentScanErrUnsupportedSchema, r.p.ManifestPath) + } + for _, rel := range paths { + file, safe := insideRoot(r.root, rel) + if !safe { + copilotComponentError(r.p, model.AgentScanErrUnsafePath, r.p.ManifestPath) + continue + } + state, _, _ := a.s.stat(a.gd, file) + if state == fileAbsent && rel == fallback && len(raw) == 0 { + continue + } + if kind == "mcpServers" { + if !a.mcpFile(r, rel, "") { + if rel != fallback { + copilotComponentError(r.p, model.AgentScanErrReadFailed, file) + } + } + } else { + status := model.AgentScanStatusComplete + if state == fileAbsent && rel == fallback { + continue + } + if state != fileRegular { + status = model.AgentScanStatusError + } + r.declared(model.PluginComponentHook, "hooks", rel, "", file, status) + } + } + } +} + +// Editor paths supplement defaults unless the manifest explicitly excludes them. +func copilotEditorPaths(raw json.RawMessage, fallback, namespace string) ([]string, bool) { + paths := []string{fallback} + if len(raw) == 0 || string(raw) == "null" { + return paths, true + } + selected := stringList(raw) + if selected == nil { + var configured struct { + Paths []string `json:"paths"` + Exclusive bool `json:"exclusive"` + } + if json.Unmarshal(raw, &configured) != nil || configured.Paths == nil { + return paths, false + } + selected = configured.Paths + if configured.Exclusive { + paths = nil + } + } + for _, rel := range selected { + if namespace != "" { + rel = path.Join(namespace, rel) + if rel != namespace && !strings.HasPrefix(rel, namespace+"/") { + return paths, false + } + } + if !slices.Contains(paths, rel) { + paths = append(paths, rel) + } + } + return paths, true +} + func (a *copilotAdapter) hooks(r *pluginRootScan, doc map[string]json.RawMessage, portable bool) { files := []string{"hooks.json", "hooks/hooks.json"} required := false @@ -845,6 +1454,9 @@ func (a *copilotAdapter) mcpFile(r *pluginRootScan, rel, schema string) bool { return true } raw, pointer := doc["mcpServers"], "/mcpServers" + if a.editor && raw == nil && doc["servers"] != nil { + raw, pointer = doc["servers"], "/servers" + } if raw == nil { raw, _ = json.Marshal(doc) pointer = "" @@ -873,12 +1485,12 @@ func (a *copilotAdapter) agent(r *pluginRootScan, file string) { } fm, _, ok := splitFrontmatter(string(data)) fields, err := parseYAMLMap(fm) - if !ok || err != nil { + if !ok && !a.editor || err != nil { copilotComponentError(r.p, model.AgentScanErrParseFailed, file) return } description, _ := fields["description"].(string) - if strings.TrimSpace(description) == "" { + if strings.TrimSpace(description) == "" && !a.editor { copilotComponentError(r.p, model.AgentScanErrParseFailed, file) return } diff --git a/internal/detector/plugins_test.go b/internal/detector/plugins_test.go index ae4cfddb..4e994fa0 100644 --- a/internal/detector/plugins_test.go +++ b/internal/detector/plugins_test.go @@ -2384,6 +2384,267 @@ func TestCopilotNativeCachePaths(t *testing.T) { } } +func TestCopilotEditorReceipt(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".vscode", "agent-plugins") + catalog := filepath.Join(root, "github.com", "test-org", "catalog") + payload := filepath.Join(catalog, "plugins", "review") + fs.addFile(filepath.Join(root, "installed.json"), fmt.Sprintf(`{"version":1,"installed":[{"pluginUri":%q,"marketplace":"test-org/catalog","name":"review"}]}`, "file://"+filepath.ToSlash(payload))) + fs.addFile(filepath.Join(catalog, ".github/plugin/marketplace.json"), `{"name":"engineering","owner":{"name":"Example Engineering"},"plugins":[{"name":"review","source":"./plugins/review"},{"name":"unselected","source":"./plugins/unselected"}]}`) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"review"}`) + fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Review releases")) + fs.commit() + result := NewSkillsDetector(m).DetectAll(context.Background(), nil, nil) + c := copilotContext(t, result) + if len(c.Plugins) != 1 { + t.Fatalf("editor receipt not collected: %+v", c) + } + p := c.Plugins[0] + if p.InstallPath != payload || p.InstallationEvidence != model.PluginEvidenceRegistry || len(p.Components) != 1 || p.Source == nil || p.Source.Location != "https://github.com/test-org/catalog" || p.Source.Subdirectory != "plugins/review" || p.ConfiguredEnabled != nil { + t.Fatalf("editor evidence mismatch: %+v", p) + } +} + +func TestCopilotEditorExternalGitReceipt(t *testing.T) { + for _, tc := range []struct { + name, source, relative string + mismatch, missing bool + }{ + {"github", `{"source":"github","repo":"test-org/review"}`, "github.com/test-org/review", false, false}, + {"subdirectory", `{"source":"github","repo":"test-org/tools","path":"plugins/review","ref":"release/v2"}`, "github.com/test-org/tools/ref_release_v2/plugins/review", false, false}, + {"git", `{"source":"url","url":"https://example.com/test-org/tools.git","sha":"abcd","path":"plugins/review"}`, "example.com/test-org/tools/sha_abcd/plugins/review", false, false}, + {"mismatch", `{"source":"github","repo":"test-org/review"}`, "github.com/test-org/other", true, false}, + {"missing catalog", `{"source":"github","repo":"test-org/review"}`, "github.com/test-org/review", false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".vscode", "agent-plugins") + catalog := filepath.Join(root, "github.com", "test-org", "catalog") + payload := filepath.Join(root, filepath.FromSlash(tc.relative)) + fs.addFile(filepath.Join(root, "installed.json"), fmt.Sprintf(`{"version":1,"installed":[{"pluginUri":%q,"marketplace":"test-org/catalog","name":"review"}]}`, "file://"+filepath.ToSlash(payload))) + if !tc.missing { + fs.addFile(filepath.Join(catalog, ".github/plugin/marketplace.json"), fmt.Sprintf(`{"plugins":[{"name":"review","source":%s}]}`, tc.source)) + } + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"review"}`) + fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Review releases")) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins) != 1 || len(c.Plugins[0].Components) != 1 { + t.Fatalf("readable receipt evidence lost: %+v", c) + } + p := c.Plugins[0] + if tc.mismatch || tc.missing { + if p.Source != nil || c.MarketplaceStatus != model.AgentScanStatusPartial { + t.Fatalf("unverified provenance accepted: %+v", p) + } + } else if p.Source == nil || p.Source.Kind == model.PluginSourceUnknown || c.MarketplaceStatus != model.AgentScanStatusComplete { + t.Fatalf("external repository provenance missing: %+v", c) + } + }) + } +} + +func TestCopilotEditorReceiptCoverage(t *testing.T) { + for _, tc := range []struct { + name, receipt string + complete bool + }{ + {"empty", `{"version":1,"installed":[]}`, true}, + {"malformed", `{`, false}, + {"null", `null`, false}, + {"unknown version", `{"version":2,"installed":[]}`, false}, + {"remote URI", `{"version":1,"installed":[{"pluginUri":"vscode-remote://ssh-remote/host/plugin","marketplace":"test-org/catalog"}]}`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, "editor-plugins") + m.SetEnv("VSCODE_AGENT_PLUGINS", root) + fs.addFile(filepath.Join(root, "installed.json"), tc.receipt) + fs.addFile(filepath.Join(root, "orphan/.plugin/plugin.json"), `{"name":"orphan"}`) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins) != 0 || (c.InstallationStatus == model.AgentScanStatusComplete) != tc.complete { + t.Fatalf("receipt coverage: %+v", c) + } + }) + } +} + +func TestCopilotEditorLocalRegistration(t *testing.T) { + m, fs := newPluginMock() + project := filepath.Join(testHome, "project") + payload := filepath.Join(testHome, "release-review") + fs.addFile(filepath.Join(project, ".git/HEAD"), "ref: refs/heads/main\n") + fs.addFile(filepath.Join(project, ".vscode/settings.json"), fmt.Sprintf(`{// native editor settings +"chat.pluginLocations":{%q:false}}`, payload)) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"release-review","skills":"custom-skills"}`) + fs.addFile(filepath.Join(payload, "skills/default/SKILL.md"), validFrontmatter("default", "Default skill")) + fs.addFile(filepath.Join(payload, "custom-skills/custom/SKILL.md"), validFrontmatter("custom", "Custom skill")) + fs.addFile(filepath.Join(payload, "agents/reviewer.agent.md"), "Review releases.\n") + fs.commit() + result := NewSkillsDetector(m).DetectAll(context.Background(), []string{project}, nil) + c := copilotContext(t, result) + if len(c.Plugins) != 1 || c.Plugins[0].ConfiguredEnabled == nil || *c.Plugins[0].ConfiguredEnabled || c.Plugins[0].ProjectPath != project || c.Plugins[0].InstallationEvidence != model.PluginEvidenceLocalConfig || len(c.Plugins[0].Components) != 3 || c.Plugins[0].ComponentStatus != model.AgentScanStatusComplete { + t.Fatalf("editor registration, default/custom union and plain agent: %+v", c) + } +} + +func TestCopilotEditorComponentPaths(t *testing.T) { + for _, tc := range []struct { + name, manifest string + want int + }{ + {"exclusive", `{"name":"review","skills":{"paths":["custom"],"exclusive":true}}`, 1}, + {"supplemental", `{"name":"review","skills":{"paths":["custom"]}}`, 2}, + {"portable", `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review","extensions":{"com.github.copilot":{"skills":{"paths":["custom"]}}}}`, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + m, fs := newPluginMock() + project := filepath.Join(testHome, "project") + payload := filepath.Join(testHome, "review") + fs.addFile(filepath.Join(project, ".git/HEAD"), "ref: refs/heads/main\n") + fs.addFile(filepath.Join(project, ".vscode/settings.json"), fmt.Sprintf(`{"chat.pluginLocations":{%q:true}}`, payload)) + fs.addFile(filepath.Join(payload, "plugin.json"), tc.manifest) + fs.addFile(filepath.Join(payload, "skills/default/SKILL.md"), validFrontmatter("default", "Default skill")) + custom := "custom" + if tc.name == "portable" { + custom = "com.github.copilot/custom" + } + fs.addFile(filepath.Join(payload, custom, "check/SKILL.md"), validFrontmatter("check", "Custom skill")) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), []string{project}, nil)) + if len(c.Plugins) != 1 || len(c.Plugins[0].Components) != tc.want || c.Plugins[0].ComponentStatus != model.AgentScanStatusComplete { + t.Fatalf("editor path selection: %+v", c) + } + }) + } +} + +func TestCopilotEditorManifestAndMCPPrecedence(t *testing.T) { + m, fs := newPluginMock() + project, payload := filepath.Join(testHome, "project"), filepath.Join(testHome, "review") + fs.addFile(filepath.Join(project, ".git/HEAD"), "ref: refs/heads/main\n") + fs.addFile(filepath.Join(project, ".vscode/settings.json"), fmt.Sprintf(`{"chat.pluginLocations":{%q:true}}`, payload)) + fs.addFile(filepath.Join(payload, "plugin.json"), `{"name":"wrong-root"}`) + fs.addFile(filepath.Join(payload, ".claude-plugin/plugin.json"), `{"name":"review","mcpServers":{"selected":{"command":"node","args":["server.js"]}}}`) + fs.addFile(filepath.Join(payload, ".mcp.json"), `{"servers":{"not-selected":{"command":"node","args":["other.js"]}}}`) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), []string{project}, nil)) + if len(c.Plugins) != 1 || c.Plugins[0].ManifestFormat != model.PluginManifestClaude || c.Plugins[0].ManifestName != "review" || len(c.Plugins[0].Components) != 1 || c.Plugins[0].Components[0].Name != "selected" { + t.Fatalf("editor manifest and inline MCP precedence: %+v", c) + } +} + +func TestCopilotEditorOptionalManifestAndConfigPaths(t *testing.T) { + for _, tc := range []struct { + name, manifest, configRoot string + exclusive bool + }{ + {"no manifest", "", "", false}, + {"supplemental", `{"name":"review","hooks":{"paths":["custom/hooks.json"]},"mcpServers":{"paths":["custom/mcp.json"]}}`, "", false}, + {"exclusive", `{"name":"review","hooks":{"paths":["custom/hooks.json"],"exclusive":true},"mcpServers":{"paths":["custom/mcp.json"],"exclusive":true}}`, "", true}, + {"portable", `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review","extensions":{"com.github.copilot":{"hooks":{"paths":["custom/hooks.json"]},"mcpServers":{"paths":["custom/mcp.json"]}}}}`, "com.github.copilot", false}, + } { + t.Run(tc.name, func(t *testing.T) { + m, fs := newPluginMock() + project, payload := filepath.Join(testHome, "project"), filepath.Join(testHome, "review") + fs.addFile(filepath.Join(project, ".git/HEAD"), "ref: refs/heads/main\n") + fs.addFile(filepath.Join(project, ".vscode/settings.json"), fmt.Sprintf(`{"chat.pluginLocations":{%q:true}}`, payload)) + if tc.manifest != "" { + fs.addFile(filepath.Join(payload, "plugin.json"), tc.manifest) + } + fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Review releases")) + hooks, mcp := "hooks.json", ".mcp.json" + if tc.configRoot != "" { + hooks, mcp = "com.github.copilot/hooks/hooks.json", "mcp.json" + } + fs.addFile(filepath.Join(payload, hooks), `{"hooks":{}}`) + fs.addFile(filepath.Join(payload, mcp), `{"mcpServers":{"default":{"type":"stdio","command":"node","args":["default.js"]}}}`) + if tc.name != "no manifest" { + fs.addFile(filepath.Join(payload, tc.configRoot, "custom/hooks.json"), `{"hooks":{}}`) + fs.addFile(filepath.Join(payload, tc.configRoot, "custom/mcp.json"), `{"mcpServers":{"custom":{"type":"stdio","command":"node","args":["custom.js"]}}}`) + } + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), []string{project}, nil)) + want := 5 + if tc.name == "no manifest" || tc.exclusive { + want = 3 + } + if len(c.Plugins) != 1 || len(c.Plugins[0].Components) != want || c.Plugins[0].ComponentStatus != model.AgentScanStatusComplete { + t.Fatalf("editor config selection: %+v", c) + } + }) + } +} + +func TestCopilotCommandRuleAndLSPDeclarations(t *testing.T) { + for _, portable := range []bool{false, true} { + t.Run(fmt.Sprint(portable), func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(root, "installed-plugins/review") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"review","marketplace":"","cache_path":%q}]}`, payload)) + manifest, namespace, lsp := `{"name":"review","commands":"actions"}`, "", "lsp-config/servers.json" + if portable { + manifest = `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review","extensions":{"com.github.copilot":{"commands":{"paths":["actions"],"exclusive":true}}}}` + namespace, lsp = "com.github.copilot", "com.github.copilot/lsp.json" + } + fs.addFile(filepath.Join(payload, "plugin.json"), manifest) + fs.addFile(filepath.Join(payload, namespace, "actions/review.md"), validFrontmatter("review", "Review release changes")) + fs.addFile(filepath.Join(payload, namespace, "rules/safety.instructions.md"), "Review changes without running commands.\n") + fs.addFile(filepath.Join(payload, lsp), `{"lspServers":{"typescript":{"command":"language-server","fileExtensions":{".ts":"typescript"},"env":{"TOKEN":"not-uploaded"}}}}`) + fs.commit() + p := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)).Plugins[0] + if p.ComponentStatus != model.AgentScanStatusComplete || len(p.Components) != 3 { + t.Fatalf("component declarations incomplete: %+v", p) + } + seen := map[string]bool{} + for _, c := range p.Components { + seen[c.Kind] = true + if c.Kind == model.PluginComponentCommand && (c.Command == nil || c.Command.DefinitionHash == "" || c.CallableNames[0] != "review:review") { + t.Fatalf("command metadata missing: %+v", c) + } + } + for _, kind := range []string{model.PluginComponentCommand, model.PluginComponentRule, model.PluginComponentLSP} { + if !seen[kind] { + t.Fatalf("missing %s", kind) + } + } + data, _ := json.Marshal(p) + if strings.Contains(string(data), "not-uploaded") || strings.Contains(string(data), "language-server") { + t.Fatal("LSP contents leaked into declaration metadata") + } + }) + } +} + +func TestCopilotLSPFailureAndInlineDeclarations(t *testing.T) { + for _, tc := range []struct{ name, manifest, file, content, status string }{ + {"inline", `{"name":"review","lspServers":{"typescript":{"command":"language-server"}}}`, "", "", model.AgentScanStatusComplete}, + {"path", `{"name":"review","lspServers":"custom/lsp.json"}`, "custom/lsp.json", `{"lspServers":{"typescript":{"command":"language-server"}}}`, model.AgentScanStatusComplete}, + {"missing", `{"name":"review","lspServers":"custom/lsp.json"}`, "", "", model.AgentScanStatusPartial}, + {"malformed", `{"name":"review"}`, "lsp.json", `{`, model.AgentScanStatusPartial}, + {"escape", `{"name":"review","lspServers":"../outside.json"}`, "", "", model.AgentScanStatusPartial}, + } { + t.Run(tc.name, func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".copilot") + payload := filepath.Join(root, "installed-plugins/review") + fs.addFile(filepath.Join(root, "config.json"), fmt.Sprintf(`{"installedPlugins":[{"name":"review","marketplace":"","cache_path":%q}]}`, payload)) + fs.addFile(filepath.Join(payload, "plugin.json"), tc.manifest) + fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check releases")) + if tc.file != "" { + fs.addFile(filepath.Join(payload, tc.file), tc.content) + } + fs.commit() + p := copilotContext(t, NewSkillsDetector(m).DetectAll(context.Background(), nil, nil)).Plugins[0] + if p.ComponentStatus != tc.status || len(p.Components) < 1 { + t.Fatalf("LSP coverage lost independent skill: %+v", p) + } + }) + } +} + func TestCopilotAppMarketplaceCache(t *testing.T) { if runtime.GOOS != model.PlatformDarwin { t.Skip("native macOS app cache") diff --git a/internal/executor/user_aware.go b/internal/executor/user_aware.go index db1322a7..7dcba48d 100644 --- a/internal/executor/user_aware.go +++ b/internal/executor/user_aware.go @@ -57,6 +57,8 @@ var userEnvironmentKeys = []string{ "UV_INDEX_URL", "UV_NO_CONFIG", "UV_NO_INDEX", + "VSCODE_AGENT_PLUGINS", + "VSCODE_PORTABLE", "VIRTUAL_ENV", "XDG_CONFIG_HOME", } diff --git a/internal/executor/user_aware_test.go b/internal/executor/user_aware_test.go index e115cc98..07194204 100644 --- a/internal/executor/user_aware_test.go +++ b/internal/executor/user_aware_test.go @@ -270,7 +270,7 @@ func TestUserAwareExecutor_LookPathHasDeadline(t *testing.T) { func TestUserAwareExecutor_CopilotRootsUseScannedUser(t *testing.T) { service := NewMock() service.SetGOOS("linux") - keys := []string{"COPILOT_HOME", "COPILOT_CACHE_HOME", "XDG_CACHE_HOME", "LOCALAPPDATA"} + keys := []string{"COPILOT_HOME", "COPILOT_CACHE_HOME", "XDG_CACHE_HOME", "LOCALAPPDATA", "VSCODE_AGENT_PLUGINS", "VSCODE_PORTABLE"} for _, key := range keys { service.SetEnv(key, "/daemon/"+key) } diff --git a/internal/model/agentplugins.go b/internal/model/agentplugins.go index 95a1fe1e..e192d54c 100644 --- a/internal/model/agentplugins.go +++ b/internal/model/agentplugins.go @@ -137,6 +137,7 @@ const ( PluginComponentHook = "hook" PluginComponentLSP = "lsp" PluginComponentApp = "app" + PluginComponentRule = "rule" ) // Definition kinds for AgentSkill.DefinitionKind. Empty also denotes SKILL.md. diff --git a/internal/model/agentplugins_golden_test.go b/internal/model/agentplugins_golden_test.go index 7c49e031..0fa844ae 100644 --- a/internal/model/agentplugins_golden_test.go +++ b/internal/model/agentplugins_golden_test.go @@ -122,7 +122,7 @@ func TestAgentPluginsGolden_CoversTheWholeVocabulary(t *testing.T) { {"scope", []string{PluginScopeUser, PluginScopeProject, PluginScopeLocal, PluginScopeSystem, PluginScopeUnknown}}, {"evidence", []string{PluginEvidenceRegistry, PluginEvidenceLocalConfig, PluginEvidenceSkillDirectory, PluginEvidenceSyncedDirectory, PluginEvidenceRemoteMarker}}, {"manifest", []string{PluginManifestClaude, PluginManifestCodex, PluginManifestCopilot, PluginManifestCursor, PluginManifestPortable, PluginManifestCatalog, PluginManifestNone, PluginManifestUnknown}}, - {"component", []string{PluginComponentSkill, PluginComponentCommand, PluginComponentMCP, PluginComponentAgent, PluginComponentHook, PluginComponentLSP, PluginComponentApp}}, + {"component", []string{PluginComponentSkill, PluginComponentCommand, PluginComponentMCP, PluginComponentAgent, PluginComponentHook, PluginComponentLSP, PluginComponentApp, PluginComponentRule}}, {"nested source", []string{"claude_plugin", "codex_plugin", "copilot_plugin"}}, // An empty kind denotes SKILL.md and remains valid beside explicit kinds. {"definition", []string{"", AgentDefinitionSkill, AgentDefinitionCommand}}, diff --git a/internal/model/testdata/agent_plugins_v1_golden.json b/internal/model/testdata/agent_plugins_v1_golden.json index 27c16ae7..8170fb5e 100644 --- a/internal/model/testdata/agent_plugins_v1_golden.json +++ b/internal/model/testdata/agent_plugins_v1_golden.json @@ -1141,6 +1141,14 @@ "callable_names": [], "component_id": "comp_3140566e7551628232b566cd5abfb0e434f36bb24ab73e1e1aa84f4b0d9fa90a" }, + { + "kind": "rule", + "name": "safety", + "relative_path": "rules/safety.instructions.md", + "status": "complete", + "callable_names": [], + "component_id": "comp_4e19ec19e51e5ac6afa77b21fcffa3cc9b000f79fe7d12caee1e8db657b991ee" + }, { "kind": "mcp", "name": "example", From dcdb8fb39f8d80f8175d7c2f2cb39a10752aeb54 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Tue, 6 Oct 2026 01:46:51 +0530 Subject: [PATCH 5/9] fix(plugins): resolve editor local catalog payload provenance --- internal/detector/plugins_copilot.go | 23 +++++++++++++-- internal/detector/plugins_test.go | 42 ++++++++++++++++++++++------ 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/internal/detector/plugins_copilot.go b/internal/detector/plugins_copilot.go index 8d027897..6154909f 100644 --- a/internal/detector/plugins_copilot.go +++ b/internal/detector/plugins_copilot.go @@ -254,10 +254,23 @@ func (a *copilotAdapter) editorReceipt(raw json.RawMessage, file string) { } // Receipts can point to a payload repository separate from the catalog clone. roots := []string{} - if root := copilotEditorGitRoot(a.root, market.Source, true); root != "" { + catalogURI, catalogErr := url.Parse(ref) + localCatalog := catalogErr == nil && catalogURI.Scheme == "file" + if localCatalog { + market.Source = nil + root := filepath.FromSlash(catalogURI.Path) + if a.s.goos == model.PlatformWindows && len(root) > 3 && root[0] == filepath.Separator && root[2] == ':' { + root = root[1:] + } + if (catalogURI.Host == "" || catalogURI.Host == "localhost") && catalogURI.RawQuery == "" && branch == "" && isAbsPath(root) { + root = cleanPluginPath(root) + market.Source = &model.SourceLocator{Kind: model.PluginSourceLocal, NativeKind: "local", Location: root} + roots = append(roots, root) + } + } else if root := copilotEditorGitRoot(a.root, market.Source, true); root != "" { roots = append(roots, root) } - for root, depth := payload, 0; depth < 8; root, depth = filepath.Dir(root), depth+1 { + for root, depth := payload, 0; !localCatalog && depth < 8; root, depth = filepath.Dir(root), depth+1 { if _, within := relSlash(a.root, root); !within { break } @@ -284,7 +297,11 @@ func (a *copilotAdapter) editorReceipt(raw json.RawMessage, file string) { entry, found := a.catalogEntry(market, root, name) if found { selected, safe := insideRoot(root, jsonString(entry["source"])) - if declared := copilotSource(entry["source"]); len(entry["source"]) > 0 && entry["source"][0] == '{' && declared != nil && (declared.Kind == model.PluginSourceGit || declared.Kind == model.PluginSourceGitHub) { + declared := copilotSource(entry["source"]) + if declared != nil && declared.NativeKind == "git-subdir" { + declared, _ = claudePayloadSource(entry["source"]) + } + if len(entry["source"]) > 0 && entry["source"][0] == '{' && declared != nil && validSource(declared) && (declared.Kind == model.PluginSourceGit || declared.Kind == model.PluginSourceGitHub) { selected = copilotEditorGitRoot(a.root, declared, false) safe = selected != "" if safe && a.s.hashPath(selected) == a.s.hashPath(payload) { diff --git a/internal/detector/plugins_test.go b/internal/detector/plugins_test.go index 4e994fa0..5e0b487d 100644 --- a/internal/detector/plugins_test.go +++ b/internal/detector/plugins_test.go @@ -2267,6 +2267,21 @@ func TestCopilotProtectedPayloadAndMCP(t *testing.T) { t.Fatal("protected standalone config read") } } + t.Run("editor local catalog", func(t *testing.T) { + m, fs := newPluginMock() + root := filepath.Join(testHome, ".vscode", "agent-plugins") + catalog := filepath.Join(testHome, "Downloads", "catalog") + payload := filepath.Join(root, "github.com", "test-org", "review") + fs.addFile(filepath.Join(root, "installed.json"), fmt.Sprintf(`{"version":1,"installed":[{"pluginUri":%q,"marketplace":%q,"name":"review"}]}`, "file://"+filepath.ToSlash(payload), "file://"+filepath.ToSlash(catalog))) + fs.addFile(filepath.Join(catalog, ".github/plugin/marketplace.json"), `{"plugins":[{"name":"review","source":{"source":"github","repo":"test-org/review"}}]}`) + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"review"}`) + fs.addFile(filepath.Join(payload, "skills/check/SKILL.md"), validFrontmatter("check", "Check releases")) + fs.commit() + c := copilotContext(t, NewSkillsDetector(m).WithSkipper(tcc.New(testHome)).DetectAll(context.Background(), nil, nil)) + if len(c.Plugins) != 1 || len(c.Plugins[0].Components) != 1 || c.Plugins[0].Source != nil || c.MarketplaceStatus != model.AgentScanStatusPartial { + t.Fatalf("protected catalog supplied provenance or lost readable skill: %+v", c) + } + }) } func TestCopilotPluginMCPSuppression(t *testing.T) { @@ -2407,21 +2422,29 @@ func TestCopilotEditorReceipt(t *testing.T) { func TestCopilotEditorExternalGitReceipt(t *testing.T) { for _, tc := range []struct { - name, source, relative string - mismatch, missing bool + name, source, relative string + mismatch, missing, local bool }{ - {"github", `{"source":"github","repo":"test-org/review"}`, "github.com/test-org/review", false, false}, - {"subdirectory", `{"source":"github","repo":"test-org/tools","path":"plugins/review","ref":"release/v2"}`, "github.com/test-org/tools/ref_release_v2/plugins/review", false, false}, - {"git", `{"source":"url","url":"https://example.com/test-org/tools.git","sha":"abcd","path":"plugins/review"}`, "example.com/test-org/tools/sha_abcd/plugins/review", false, false}, - {"mismatch", `{"source":"github","repo":"test-org/review"}`, "github.com/test-org/other", true, false}, - {"missing catalog", `{"source":"github","repo":"test-org/review"}`, "github.com/test-org/review", false, true}, + {"github", `{"source":"github","repo":"test-org/review"}`, "github.com/test-org/review", false, false, false}, + {"subdirectory", `{"source":"github","repo":"test-org/tools","path":"plugins/review","ref":"release/v2"}`, "github.com/test-org/tools/ref_release_v2/plugins/review", false, false, false}, + {"git", `{"source":"url","url":"https://example.com/test-org/tools.git","sha":"abcd","path":"plugins/review"}`, "example.com/test-org/tools/sha_abcd/plugins/review", false, false, false}, + {"mismatch", `{"source":"github","repo":"test-org/review"}`, "github.com/test-org/other", true, false, false}, + {"missing catalog", `{"source":"github","repo":"test-org/review"}`, "github.com/test-org/review", false, true, false}, + {"local catalog git-subdir", `{"source":"git-subdir","url":"https://example.com/test-org/tools.git","sha":"abcd","path":"plugins/review"}`, "example.com/test-org/tools/sha_abcd/plugins/review", false, false, true}, + {"local catalog mismatch", `{"source":"github","repo":"test-org/review"}`, "github.com/test-org/other", true, false, true}, + {"local catalog missing", `{"source":"github","repo":"test-org/review"}`, "github.com/test-org/review", false, true, true}, } { t.Run(tc.name, func(t *testing.T) { m, fs := newPluginMock() root := filepath.Join(testHome, ".vscode", "agent-plugins") catalog := filepath.Join(root, "github.com", "test-org", "catalog") payload := filepath.Join(root, filepath.FromSlash(tc.relative)) - fs.addFile(filepath.Join(root, "installed.json"), fmt.Sprintf(`{"version":1,"installed":[{"pluginUri":%q,"marketplace":"test-org/catalog","name":"review"}]}`, "file://"+filepath.ToSlash(payload))) + marketplace := "test-org/catalog" + if tc.local { + catalog = filepath.Join(testHome, "private-catalog") + marketplace = "file://" + filepath.ToSlash(catalog) + } + fs.addFile(filepath.Join(root, "installed.json"), fmt.Sprintf(`{"version":1,"installed":[{"pluginUri":%q,"marketplace":%q,"name":"review"}]}`, "file://"+filepath.ToSlash(payload), marketplace)) if !tc.missing { fs.addFile(filepath.Join(catalog, ".github/plugin/marketplace.json"), fmt.Sprintf(`{"plugins":[{"name":"review","source":%s}]}`, tc.source)) } @@ -2440,6 +2463,9 @@ func TestCopilotEditorExternalGitReceipt(t *testing.T) { } else if p.Source == nil || p.Source.Kind == model.PluginSourceUnknown || c.MarketplaceStatus != model.AgentScanStatusComplete { t.Fatalf("external repository provenance missing: %+v", c) } + if tc.local && !tc.mismatch && !tc.missing && (p.Source.Location != "https://example.com/test-org/tools.git" || p.Source.Subdirectory != "plugins/review" || p.Source.RequestedSHA != "abcd") { + t.Fatalf("local catalog replaced payload provenance: %+v", p.Source) + } }) } } From b1c6b45fa8d56eb3b9d989f5d31b752574fdfd4b Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Tue, 6 Oct 2026 21:32:16 +0530 Subject: [PATCH 6/9] chore(ci): retrigger Copilot inventory checks From 8f92d9bf25c749586d5221dad3280664c94468cf Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Tue, 6 Oct 2026 22:18:59 +0530 Subject: [PATCH 7/9] fix(copilot): exclude uninstalled plugin MCP payloads --- internal/detector/mcp_plugins.go | 2 ++ internal/detector/mcp_plugins_test.go | 30 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/internal/detector/mcp_plugins.go b/internal/detector/mcp_plugins.go index 790de5ed..8ca18d79 100644 --- a/internal/detector/mcp_plugins.go +++ b/internal/detector/mcp_plugins.go @@ -19,6 +19,8 @@ type mcpPluginManifest struct { var mcpPluginManifests = []mcpPluginManifest{ {".claude-plugin", "claude_plugin", "Anthropic"}, {".codex-plugin", "codex_plugin", "OpenAI"}, + {".plugin", "copilot_plugin", "GitHub"}, + {".github/plugin", "copilot_plugin", "GitHub"}, } // pluginMCPBasename is the only MCP surface a plugin package declares. Any diff --git a/internal/detector/mcp_plugins_test.go b/internal/detector/mcp_plugins_test.go index b2767374..7eb86c95 100644 --- a/internal/detector/mcp_plugins_test.go +++ b/internal/detector/mcp_plugins_test.go @@ -78,6 +78,36 @@ func TestDiscoverWalkedMCPConfigs_CodexInstalledPlugin(t *testing.T) { } } +func TestDiscoverWalkedMCPConfigs_CopilotCatalogPayload(t *testing.T) { + for _, manifest := range []string{".plugin/plugin.json", ".github/plugin/plugin.json"} { + t.Run(manifest, func(t *testing.T) { + root := t.TempDir() + payload := "catalog/plugins/review/" + writeFile(t, root, payload+manifest) + leftover := writeFile(t, root, payload+".mcp.json") + vendored := writeFile(t, root, payload+".github/mcp.json") + independent := writeFile(t, root, "catalog/.github/mcp.json") + project := writeFile(t, root, "project/.mcp.json") + + d := &MCPDetector{} + got := gotSpecMap(d.discoverWalkedMCPConfigs([]string{root}, "")) + for _, path := range []string{leftover, vendored} { + if _, ok := got[path]; ok { + t.Errorf("reported uninstalled Copilot payload config %s", path) + } + } + for _, path := range []string{independent, project} { + if spec, ok := got[path]; !ok || spec.SourceName != "discovered_mcp" { + t.Errorf("independent config %s: got %+v, present=%v", path, spec, ok) + } + } + if len(got) != 2 { + t.Errorf("got %d configs, want 2", len(got)) + } + }) + } +} + // TestPluginPackageRoot_NestedAndBounded: a config nested inside a package // resolves to the package root; the search stops at the walk root. func TestPluginPackageRoot_NestedAndBounded(t *testing.T) { From cbe879290bcfe7303c6e9d4a6843ffa74d5e6e94 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Wed, 7 Oct 2026 01:43:30 +0530 Subject: [PATCH 8/9] fix(copilot): preserve editor marketplace identity --- internal/detector/plugins_copilot.go | 5 +---- internal/detector/plugins_test.go | 3 +++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/detector/plugins_copilot.go b/internal/detector/plugins_copilot.go index 6154909f..db75103b 100644 --- a/internal/detector/plugins_copilot.go +++ b/internal/detector/plugins_copilot.go @@ -283,7 +283,7 @@ func (a *copilotAdapter) editorReceipt(raw json.RawMessage, file string) { } for _, root := range roots { for _, rel := range copilotCatalogPaths { - doc, absent, code := a.object(filepath.Join(root, filepath.FromSlash(rel))) + _, absent, code := a.object(filepath.Join(root, filepath.FromSlash(rel))) if absent { continue } @@ -291,9 +291,6 @@ func (a *copilotAdapter) editorReceipt(raw json.RawMessage, file string) { a.fail(code, filepath.Join(root, filepath.FromSlash(rel))) continue } - if name := jsonString(doc["name"]); name != "" { - market.Name = name - } entry, found := a.catalogEntry(market, root, name) if found { selected, safe := insideRoot(root, jsonString(entry["source"])) diff --git a/internal/detector/plugins_test.go b/internal/detector/plugins_test.go index 5e0b487d..628a74cc 100644 --- a/internal/detector/plugins_test.go +++ b/internal/detector/plugins_test.go @@ -2415,6 +2415,9 @@ func TestCopilotEditorReceipt(t *testing.T) { t.Fatalf("editor receipt not collected: %+v", c) } p := c.Plugins[0] + if len(c.Marketplaces) != 1 || c.Marketplaces[0].Name != "test-org/catalog" || c.Marketplaces[0].MarketplaceID != marketplaceID(c.ContextID, c.Marketplaces[0].Name) || p.MarketplaceID != c.Marketplaces[0].MarketplaceID { + t.Fatalf("editor marketplace identity mismatch: %+v", c.Marketplaces) + } if p.InstallPath != payload || p.InstallationEvidence != model.PluginEvidenceRegistry || len(p.Components) != 1 || p.Source == nil || p.Source.Location != "https://github.com/test-org/catalog" || p.Source.Subdirectory != "plugins/review" || p.ConfiguredEnabled != nil { t.Fatalf("editor evidence mismatch: %+v", p) } From 63dfb399b7685c3f07e0dffacbdcd4e1d5870d79 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Wed, 7 Oct 2026 23:25:21 +0530 Subject: [PATCH 9/9] fix(copilot): correct editor metadata and coverage docs --- CHANGELOG.md | 2 +- README.md | 4 +-- SCAN_COVERAGE.md | 6 ++-- internal/detector/plugins_copilot.go | 8 ++++- internal/detector/plugins_test.go | 53 ++++++++++++++++++++++++++++ internal/output/html_test.go | 21 +++++++++++ internal/output/pretty.go | 2 +- 7 files changed, 88 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3709ddb3..506da8b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. ### Added -- Copilot CLI recorded plugins and live directory-marketplace selections, supplied skills, custom-agent declarations, and sanitized plugin/user/project MCP definitions. Collection is file-only; uncertain formats report incomplete coverage. No Copilot usage or credential collection is added. +- GitHub Copilot plugin inventory for the CLI, standalone app and VS Code. Collects recorded installations and live directory-marketplace selections, supplied skills, custom agents, command/rule/hook/LSP declarations, and sanitized plugin/user/project MCP definitions. Collection is file-only; uncertain formats report incomplete coverage. Runtime activation is not inferred. No Copilot usage or credential collection is added. ## [1.17.0] - 2026-09-24 diff --git a/README.md b/README.md index 115f4231..13dd06e9 100644 --- a/README.md +++ b/README.md @@ -286,8 +286,8 @@ See [SCAN_COVERAGE.md](SCAN_COVERAGE.md) for the full catalog of supported detec | AI CLI Tools | Claude Code, Codex, Gemini CLI, Kiro CLI, GitHub Copilot CLI, Aider, OpenCode, Cursor Agent, Pi, Factory Droid, Amp, Grok Build, Kimi Code, Muse Code, Hermes Agent, Oh My Pi | | AI Agents | Claude Cowork, OpenClaw, ClawdBot, GPT-Engineer | | AI Frameworks | Ollama, LM Studio, LocalAI, Text Generation WebUI | -| MCP Server Configs | Claude Desktop, Claude Code, Cursor, Windsurf, Antigravity, Zed, Open Interpreter, Codex, OpenCode | -| Agent Plugins & Skills | Claude Code, Codex and GitHub Copilot CLI plugin installations, declared components, standalone Claude commands, and recorded skill-use counters | +| MCP Server Configs | Claude Desktop, Claude Code, Cursor, Windsurf, Antigravity, Zed, Open Interpreter, Codex, OpenCode, GitHub Copilot | +| Agent Plugins & Skills | Claude Code, Codex and GitHub Copilot (CLI, standalone app and VS Code) plugin installations, declared components, standalone Claude commands, and recorded skill-use counters | | IDE Extensions | VS Code, Cursor, Windsurf, Antigravity, JetBrains, Eclipse, Xcode, Android Studio | | Browser Extensions | Google Chrome, Microsoft Edge, Mozilla Firefox | | Node.js Packages | npm, yarn, pnpm, bun (opt-in) | diff --git a/SCAN_COVERAGE.md b/SCAN_COVERAGE.md index 951760a6..f22452e2 100644 --- a/SCAN_COVERAGE.md +++ b/SCAN_COVERAGE.md @@ -137,17 +137,17 @@ Per skill, the scan records identity and frontmatter (name, description, version ## Agent Plugins, Commands and Recorded Skill Use -Claude Code, Codex and GitHub Copilot CLI plugin inventory runs in its own `agent_plugins_scan` phase after `agent_skills_scan`, with no feature gate. Standalone commands and recorded usage remain in the skills phase. Both phases reuse project discovery and parsed definitions, with separate deadlines and progress. Plugin collection reads native registration/configuration, selected materialized payloads and manifests. Installed, files present, configured enabled and effective enabled are separate observations; an unavailable value remains unknown. An old cache directory alone is not an installation. +Claude Code, Codex and GitHub Copilot plugin inventory runs in its own `agent_plugins_scan` phase after `agent_skills_scan`, with no feature gate. Standalone commands and recorded usage remain in the skills phase. Both phases reuse project discovery and parsed definitions, with separate deadlines and progress. Plugin collection reads native registration/configuration, selected materialized payloads and manifests. Installed, files present, configured enabled and effective enabled are separate observations; an unavailable value remains unknown. An old cache directory alone is not an installation. - **Claude Code:** scoped version-2 installation records, registered and settings-declared catalogs, visible seed roots, manifest-bearing skill directories and synced payloads. Local directory catalogs use the original source. Skills, legacy commands, MCP servers, agents, hooks, LSP servers and declared apps retain their supplying plugin. - **Codex:** configured local/Git marketplaces, personal and discovered-project catalogs, selected versioned store payloads and recognized account markers. Portable manifests take precedence over compatible manifests. Portable skills and MCP roots are fixed; supported apps/hooks remain descriptive metadata. Account effective enablement remains unknown. -- **GitHub Copilot CLI:** recorded installations and selected live directory-marketplace payloads, including disabled selections. Collects skills, descriptive custom agents and shared/agent MCP declarations. Portable 1.0/1.1 and native legacy manifests use their own precedence. User `mcp-config.json` and discovered-project `.mcp.json`/`.github/mcp.json` retain sanitized standalone declarations. Runtime activation, credentials, usage, hooks and LSP inventory are outside this extension. Root-only skill fallback and path-valued MCP declarations follow pinned native fixtures. Unverified legacy store paths and remote catalog provenance remain incomplete. +- **GitHub Copilot:** CLI and standalone-app recorded installations and selected live directory-marketplace payloads, including disabled selections. VS Code and Insiders use separate installed receipts and user/profile/project `chat.pluginLocations` registrations. Collects skills, custom agents, commands, rules, hooks, LSP declarations and shared/agent MCP declarations. Portable 1.0/1.1 and native legacy manifests use their own precedence; editor component selection follows its own defaults and settings. User `mcp-config.json` and discovered-project `.mcp.json`/`.github/mcp.json` retain sanitized standalone declarations. Runtime activation, credentials and usage are outside this extension. Native editor Disable/Enable state stored in SQLite is not read; JSON registration preferences do not prove effective activation. Editor versions remain unknown rather than inheriting the CLI version. Root-only skill fallback and path-valued MCP declarations follow pinned native fixtures. Unavailable or unverified catalog evidence remains incomplete. - **Standalone Claude commands:** user and discovered-project `commands/**/*.md` files retain their own paths and raw-byte hashes, independently of ordinary `SKILL.md` definitions. - **Recorded skill use:** Claude's `skillUsage` keys, cumulative counts (including zero) and native millisecond timestamps. Selected snapshots are attached to uniquely matching standalone or plugin definitions, including shared skills exposed through Claude symlinks. Ambiguous and unavailable usage is not zero; aliases and installation scopes are not summed. Unmatched counters are not uploaded separately. The collector never executes plugins, hooks, scripts, agent CLIs or network requests. MCP content uses the existing field allowlist and redaction. Plugin metadata and definition hashes are reported; instruction bodies, commands, credentials and complete settings files are not uploaded. Plugin-owned and stale-cache MCP declarations are excluded from ordinary MCP results, while unrelated MCP configurations retain existing coverage. -Reads are guarded and bounded: 5 MiB metadata, 1 MiB definitions, 1,024 plugin observations, 4,096 components, 2,000 new parsed definitions, 10,000 inspected native usage counters and an 8 MiB plugin envelope. Malformed, unreadable, unsupported or truncated scopes report incomplete coverage independently. Visible `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_PLUGIN_CACHE_DIR`, `CLAUDE_CODE_PLUGIN_SEED_DIR` `CODEX_HOME`, `COPILOT_HOME` and independent `COPILOT_CACHE_HOME` overrides are respected; overrides hidden from the scanning process cannot be discovered. Project presence does not prove session trust or activation. Native Windows project-plugin activation and account-synced delivery lifecycle remain outside the completed fixture validation. +Reads are guarded and bounded: 5 MiB metadata, 1 MiB definitions, 1,024 plugin observations, 4,096 components, 2,000 new parsed definitions, 10,000 inspected native usage counters and an 8 MiB plugin envelope. Malformed, unreadable, unsupported or truncated scopes report incomplete coverage independently. Visible `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_PLUGIN_CACHE_DIR`, `CLAUDE_CODE_PLUGIN_SEED_DIR`, `CODEX_HOME`, `COPILOT_HOME` and independent `COPILOT_CACHE_HOME` overrides are respected; overrides hidden from the scanning process cannot be discovered. Project presence does not prove session trust or activation. Native Windows project-plugin activation and account-synced delivery lifecycle remain outside the completed fixture validation. ## IDE Extensions & Plugins diff --git a/internal/detector/plugins_copilot.go b/internal/detector/plugins_copilot.go index db75103b..c1795624 100644 --- a/internal/detector/plugins_copilot.go +++ b/internal/detector/plugins_copilot.go @@ -40,7 +40,9 @@ type copilotAdapter struct { func (s *pluginScan) detectCopilotEditors() []*model.AgentPluginContext { if executor.UserEnvironmentError(s.d.exec) != nil { root := filepath.Join(s.home, ".vscode", "agent-plugins") - return []*model.AgentPluginContext{s.unresolvedContext(model.AgentCopilot, root, root)} + c := s.unresolvedContext(model.AgentCopilot, root, root) + c.AgentVersion = "" + return []*model.AgentPluginContext{c} } restore := s.snapshotRetry() for attempt := 0; ; attempt++ { @@ -50,6 +52,10 @@ func (s *pluginScan) detectCopilotEditors() []*model.AgentPluginContext { s.reads = map[string]pluginMetadataStamp{} s.sourceChanged = false contexts := s.copilotEditorContexts() + // The recorded Copilot version belongs to the CLI, not the editor. + for _, c := range contexts { + c.AgentVersion = "" + } if !s.snapshotChanged() { return contexts } diff --git a/internal/detector/plugins_test.go b/internal/detector/plugins_test.go index 628a74cc..6f4a3248 100644 --- a/internal/detector/plugins_test.go +++ b/internal/detector/plugins_test.go @@ -2399,6 +2399,59 @@ func TestCopilotNativeCachePaths(t *testing.T) { } } +func TestCopilotEditorVersions(t *testing.T) { + m, fs := newPluginMock() + m.SetGOOS(model.PlatformLinux) + cliRoot := filepath.Join(testHome, ".copilot") + project := filepath.Join(testHome, "project") + payload := filepath.Join(testHome, "review") + fs.addFile(filepath.Join(cliRoot, "config.json"), `{}`) + fs.addFile(filepath.Join(project, ".git/HEAD"), "ref: refs/heads/main\n") + fs.addFile(filepath.Join(payload, ".plugin/plugin.json"), `{"name":"review"}`) + root := filepath.Join(testHome, ".vscode", "agent-plugins") + fs.addFile(filepath.Join(root, "installed.json"), fmt.Sprintf(`{"version":1,"installed":[{"pluginUri":%q,"name":"review"}]}`, "file://"+filepath.ToSlash(payload))) + insiders := filepath.Join(testHome, ".vscode-insiders", "agent-plugins") + fs.addFile(filepath.Join(insiders, "installed.json"), `{`) + userRoot := filepath.Join(testHome, ".config", "Code", "User") + settingsRoots := []string{userRoot, filepath.Join(userRoot, "profiles", "review"), filepath.Join(project, ".vscode")} + for _, settingsRoot := range settingsRoots { + fs.addFile(filepath.Join(settingsRoot, "settings.json"), fmt.Sprintf(`{"chat.pluginLocations":{%q:true}}`, payload)) + } + fs.commit() + versions := AgentVersions([]model.AITool{{Name: "github-copilot-cli", Version: "1.0.91"}}) + result := NewSkillsDetector(m).WithAgentVersions(versions).DetectAll(context.Background(), []string{project}, nil) + want := map[string]string{cliRoot: "1.0.91", root: "", insiders: ""} + for _, settingsRoot := range settingsRoots { + want[settingsRoot] = "" + } + for _, c := range result.Plugins.Contexts { + if version, ok := want[c.ConfigRoot]; ok { + if c.AgentVersion != version { + t.Errorf("context %s version = %q, want %q", c.ConfigRoot, c.AgentVersion, version) + } + delete(want, c.ConfigRoot) + } + } + if len(want) != 0 { + t.Fatalf("missing version contexts: %v", want) + } +} + +func TestCopilotEditorUnresolvedVersion(t *testing.T) { + m, _ := newPluginMock() + m.SetGOOS(model.PlatformLinux) + versions := AgentVersions([]model.AITool{{Name: "github-copilot-cli", Version: "1.0.91"}}) + d := NewSkillsDetector(executor.NewUserAwareExecutor(m, "test-user")).WithAgentVersions(versions) + if executor.UserEnvironmentError(d.exec) == nil { + t.Fatal("expected unavailable user environment") + } + s := &pluginScan{d: d, home: testHome} + contexts := s.detectCopilotEditors() + if len(contexts) != 1 || contexts[0].AgentVersion != "" || contexts[0].InstallationStatus != model.AgentScanStatusError { + t.Fatalf("unresolved editor context: %+v", contexts) + } +} + func TestCopilotEditorReceipt(t *testing.T) { m, fs := newPluginMock() root := filepath.Join(testHome, ".vscode", "agent-plugins") diff --git a/internal/output/html_test.go b/internal/output/html_test.go index 37c263b7..6e6a42ac 100644 --- a/internal/output/html_test.go +++ b/internal/output/html_test.go @@ -48,6 +48,27 @@ func TestPluginOutputStatesAndEscaping(t *testing.T) { } } +func TestCopilotPluginDisplayName(t *testing.T) { + result := &model.ScanResult{AgentPlugins: &model.AgentPlugins{Contexts: []model.AgentPluginContext{{Agent: model.AgentCopilot}}}} + var pretty bytes.Buffer + if err := Pretty(&pretty, result, "never"); err != nil { + t.Fatal(err) + } + file := filepath.Join(t.TempDir(), "report.html") + if err := HTML(file, result); err != nil { + t.Fatal(err) + } + html, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + for _, text := range []string{pretty.String(), string(html)} { + if !strings.Contains(text, "GitHub Copilot") || strings.Contains(text, "GitHub Copilot CLI") { + t.Error("plugin output must use the shared GitHub Copilot label") + } + } +} + func TestPluginOnlyCommunityComponents(t *testing.T) { result := &model.ScanResult{ AgentSkillScan: &model.AgentSkillScanInfo{}, diff --git a/internal/output/pretty.go b/internal/output/pretty.go index 70ec7a46..9f14709c 100644 --- a/internal/output/pretty.go +++ b/internal/output/pretty.go @@ -848,7 +848,7 @@ func communityInventory(result *model.ScanResult) *model.ScanResult { func pluginAgentDisplayName(agent string) string { if agent == model.AgentCopilot { - return "GitHub Copilot CLI" + return "GitHub Copilot" } return agent }
MarketplaceSourceRegisteredAuto-update
{{.Name}}{{with .Source}}{{.Kind}} {{.Location}}{{else}}unknown{{end}}{{.Registered}}{{pluginState .AutoUpdateEnabled}}