Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
5 changes: 3 additions & 2 deletions SCAN_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
102 changes: 99 additions & 3 deletions internal/detector/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <app-config>/User/mcp.json. These are targeted reads; on macOS the path
// is under ~/Library, which the discovery walk deliberately never enters.
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
}
37 changes: 37 additions & 0 deletions internal/detector/mcp_discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/detector/mcp_plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions internal/detector/mcp_plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading