From d4aa307983e6b12fe5108569481e3ff54570e9ae Mon Sep 17 00:00:00 2001 From: Arunanshu Biswas Date: Fri, 12 Jun 2026 14:28:17 +0530 Subject: [PATCH 1/2] feat: check safedep mcp installation status command --- README.md | 1 + docs/cmd/protect-mcp-status.md | 36 ++++++ go.work.sum | 6 + internal/agent/agent.go | 8 ++ internal/agent/antigravity.go | 4 + internal/agent/claudecode.go | 36 ++++++ internal/agent/claudecode_test.go | 38 ++++++ internal/agent/config.go | 36 ++++++ internal/agent/cursor.go | 8 ++ internal/agent/cursor_test.go | 34 ++++++ internal/agent/geminicli.go | 8 ++ internal/agent/inject_test.go | 6 + internal/agent/opencode.go | 8 ++ internal/agent/opencode_test.go | 22 ++++ internal/agent/vscode.go | 8 ++ internal/agent/vscode_test.go | 32 +++++ internal/cmd/protect/mcp/cmd.go | 1 + internal/cmd/protect/mcp/service.go | 64 ++++++++++ internal/cmd/protect/mcp/service_test.go | 76 +++++++++++- internal/cmd/protect/mcp/status.go | 146 +++++++++++++++++++++++ 20 files changed, 572 insertions(+), 6 deletions(-) create mode 100644 docs/cmd/protect-mcp-status.md create mode 100644 internal/cmd/protect/mcp/status.go diff --git a/README.md b/README.md index 3ac1112..dc87f8a 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ Push SafeDep malware findings to external security tools. JFrog XRay is supporte | [`safedep query schema list`](./docs/cmd/query-schema-list.md) | List tables in the query schema | | [`safedep query schema show`](./docs/cmd/query-schema-show.md) | Show one table from the query schema | | [`safedep query schema get`](./docs/cmd/query-schema-get.md) | Get the full schema in one call (for AI agents and scripts) | +| [`safedep protect mcp status`](./docs/cmd/protect-mcp-status.md) | Show SafeDep MCP integration status for detected AI agents | | [`safedep protect mcp install`](./docs/cmd/protect-mcp-install.md) | Inject SafeDep MCP server config into detected AI agents | | [`safedep protect mcp uninstall`](./docs/cmd/protect-mcp-uninstall.md) | Remove SafeDep MCP server config from detected AI agents | | [`safedep integration jfrog run`](./docs/cmd/integration-jfrog-run.md) | Push SafeDep malware findings to JFrog XRay | diff --git a/docs/cmd/protect-mcp-status.md b/docs/cmd/protect-mcp-status.md new file mode 100644 index 0000000..960c213 --- /dev/null +++ b/docs/cmd/protect-mcp-status.md @@ -0,0 +1,36 @@ +# safedep protect mcp status + +Report, for every supported AI coding agent, whether it is detected on this machine and whether the SafeDep MCP server is configured in its config file. + +Agents supported: Claude Code, Cursor, Gemini CLI, OpenCode, Antigravity, VS Code. + +## Synopsis + +``` +safedep protect mcp status [flags] +``` + +## Flags + +| Flag | Description | +|---|---| +| `--workspace ` | Project directory for workspace-level status. Empty (default) skips workspace inspection. | + +Inherits root flags `--output`, `--profile`, and `--insecure-keychain-fallback`. + +## Authentication + +Does not require authentication. The status check is a local filesystem read. + +## What it does + +1. Detects which supported AI agents are installed on this machine. +2. For each detected agent, reads its config file and reports whether the `safedep` MCP server entry is present. +3. With `--workspace`, also inspects the agent's workspace-level config for that project. + +Undetected agents and config scopes an agent does not support are reported as not applicable. + +## Exit codes + +- `0` on success, including the case where no agents are detected or none are configured. +- `1` on filesystem or config parse errors. diff --git a/go.work.sum b/go.work.sum index cd3aab5..e4468d5 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,5 +1,6 @@ al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= buf.build/gen/go/bufbuild/protovalidate/grpc/go v1.6.1-20240508200655-46a4cf4ba109.1/go.mod h1:12iIaR0LjReZQXXxBXMzWTMUIN6n4Y3HuSTwPpUQYSg= +buf.build/gen/go/bufbuild/protovalidate/grpc/go v1.6.2-20240508200655-46a4cf4ba109.1/go.mod h1:cz5A7G0AEk7z2kciJ1jK72u/8kRVlcfAhi2B9jYoMZw= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.121.2/go.mod h1:nRFlrHq39MNVWu+zESP2PosMWA0ryJw8KUBZ2iZpxbw= cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= @@ -56,6 +57,7 @@ github.com/cloudwego/eino v0.7.13/go.mod h1:nA8Vacmuqv3pqKBQbTWENBLQ8MmGmPt/Wqiy github.com/cloudwego/eino-ext/components/model/claude v0.1.17/go.mod h1:2sGGgwpR60LW+RdG/hcjdGBVEVEJ6EkKxlva8mju9BI= github.com/cloudwego/eino-ext/components/model/gemini v0.1.5/go.mod h1:yWVzN9Y5TU9MERRStVnfa28uXQbbAk+z/2SCi+G32Vc= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= @@ -63,8 +65,10 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= @@ -146,6 +150,7 @@ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+x github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.61.0/go.mod h1:/V0rmKWoHzXI2ROCfKE2PKPoo6hdlU1GRtzwzuO/3jc= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= @@ -166,6 +171,7 @@ google.golang.org/genai v1.21.0/go.mod h1:QPj5NGJw+3wEOHg+PrsWwJKvG6UC84ex5FR7qA google.golang.org/genproto v0.0.0-20250528174236-200df99c418a h1:KXuwdBmgjb4T3l4ZzXhP6HxxFKXD9FcK5/8qfJI4WwU= google.golang.org/genproto v0.0.0-20250528174236-200df99c418a/go.mod h1:Nlk93rrS2X7rV8hiC2gh2A/AJspZhElz9Oh2KGsjLEY= google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= gorm.io/driver/clickhouse v0.7.0/go.mod h1:TmNo0wcVTsD4BBObiRnCahUgHJHjBIwuRejHwYt3JRs= gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index b01e3d3..00f9c0d 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -31,6 +31,10 @@ type GlobalInjector interface { // RemoveGlobal deletes the SafeDep entry. No-op if absent. RemoveGlobal() error + + // GlobalConfigured reports whether the SafeDep entry is present in the + // global config file. Returns false (no error) when the file is absent. + GlobalConfigured() (bool, error) } // WorkspaceInjector writes or removes the SafeDep MCP config from a workspace config file. @@ -44,4 +48,8 @@ type WorkspaceInjector interface { // RemoveWorkspace deletes the SafeDep entry. No-op if absent. RemoveWorkspace(workspaceDir string) error + + // WorkspaceConfigured reports whether the SafeDep entry is present in the + // workspace config file. Returns false (no error) when the file is absent. + WorkspaceConfigured(workspaceDir string) (bool, error) } diff --git a/internal/agent/antigravity.go b/internal/agent/antigravity.go index 506773e..be61d3c 100644 --- a/internal/agent/antigravity.go +++ b/internal/agent/antigravity.go @@ -35,6 +35,10 @@ func (a *antigravity) RemoveGlobal() error { return removeMCPConfig(a.GlobalConfigPath()) } +func (a *antigravity) GlobalConfigured() (bool, error) { + return mcpEntryConfigured(a.GlobalConfigPath(), "mcpServers") +} + // antigravityMCPServerEntry is the entry format for ~/.gemini/antigravity/mcp_config.json. type antigravityMCPServerEntry struct { ServerURL string `json:"serverUrl"` diff --git a/internal/agent/claudecode.go b/internal/agent/claudecode.go index c5c3297..2c889a6 100644 --- a/internal/agent/claudecode.go +++ b/internal/agent/claudecode.go @@ -63,6 +63,42 @@ func (c *claudeCode) RemoveWorkspace(workspaceDir string) error { return removeClaudeCodeWorkspaceMCPConfig(c.GlobalConfigPath(), workspaceDir) } +func (c *claudeCode) GlobalConfigured() (bool, error) { + return mcpEntryConfigured(c.GlobalConfigPath(), "mcpServers") +} + +// claudeCodeProbe is a minimal view of ~/.claude.json used to detect a +// per-project SafeDep entry. Only projects[*].mcpServers is parsed; server +// bodies stay raw. +type claudeCodeProbe struct { + Projects map[string]struct { + MCPServers map[string]json.RawMessage `json:"mcpServers"` + } `json:"projects"` +} + +// WorkspaceConfigured checks projects[workspaceDir].mcpServers.safedep in +// ~/.claude.json, the per-project location Claude Code uses. +func (c *claudeCode) WorkspaceConfigured(workspaceDir string) (bool, error) { + raw, err := os.ReadFile(c.GlobalConfigPath()) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if len(raw) == 0 { + return false, nil + } + + var probe claudeCodeProbe + if err := json.Unmarshal(raw, &probe); err != nil { + return false, fmt.Errorf("agent: parse %s: %w", c.GlobalConfigPath(), err) + } + + _, ok := probe.Projects[workspaceDir].MCPServers[safedepMCPKey] + return ok, nil +} + // writeClaudeCodeMCPConfig writes the SafeDep entry into a Claude Code JSON // config file using the "type": "http" format required by ~/.claude.json. func writeClaudeCodeMCPConfig(path string, cfg MCPConfig) error { diff --git a/internal/agent/claudecode_test.go b/internal/agent/claudecode_test.go index 3e67aa1..d90f00b 100644 --- a/internal/agent/claudecode_test.go +++ b/internal/agent/claudecode_test.go @@ -155,4 +155,42 @@ func TestClaudeCode(t *testing.T) { } } }) + + t.Run("GlobalConfigured tracks inject and remove", func(t *testing.T) { + cc := newClaudeCode(t.TempDir()) + + got, err := cc.GlobalConfigured() + require.NoError(t, err) + assert.False(t, got) + + require.NoError(t, cc.InjectGlobal(cfg)) + got, err = cc.GlobalConfigured() + require.NoError(t, err) + assert.True(t, got) + + require.NoError(t, cc.RemoveGlobal()) + got, err = cc.GlobalConfigured() + require.NoError(t, err) + assert.False(t, got) + }) + + t.Run("WorkspaceConfigured reads nested projects entry", func(t *testing.T) { + home := t.TempDir() + workspace := t.TempDir() + cc := newClaudeCode(home) + + got, err := cc.WorkspaceConfigured(workspace) + require.NoError(t, err) + assert.False(t, got) + + require.NoError(t, cc.InjectWorkspace(workspace, cfg)) + got, err = cc.WorkspaceConfigured(workspace) + require.NoError(t, err) + assert.True(t, got) + + // A different project path must not report as configured. + got, err = cc.WorkspaceConfigured(t.TempDir()) + require.NoError(t, err) + assert.False(t, got) + }) } diff --git a/internal/agent/config.go b/internal/agent/config.go index 5316dbd..c3cdf20 100644 --- a/internal/agent/config.go +++ b/internal/agent/config.go @@ -65,6 +65,42 @@ func removeMCPConfig(path string) error { return writeJSONFile(path, data) } +// mcpEntryConfigured reports whether the SafeDep entry exists under +// rootKey.safedep in the JSON config file at path. Returns false (no error) +// when the file is absent or empty. rootKey is the agent-specific container +// key (e.g. "mcpServers", "servers", "mcp"). Server bodies are decoded as +// json.RawMessage so only the structure needed to detect the entry is parsed. +func mcpEntryConfigured(path, rootKey string) (bool, error) { + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if len(raw) == 0 { + return false, nil + } + + var doc map[string]json.RawMessage + if err := json.Unmarshal(raw, &doc); err != nil { + return false, fmt.Errorf("agent: parse %s: %w", path, err) + } + + container, ok := doc[rootKey] + if !ok { + return false, nil + } + + var servers map[string]json.RawMessage + if err := json.Unmarshal(container, &servers); err != nil { + return false, fmt.Errorf("agent: parse %s: %w", path, err) + } + + _, ok = servers[safedepMCPKey] + return ok, nil +} + // readJSONFile reads and unmarshals a JSON file. Returns an empty map when // the file does not exist or is empty so the caller can create one from scratch. func readJSONFile(path string) (map[string]any, error) { diff --git a/internal/agent/cursor.go b/internal/agent/cursor.go index 969c39b..ce86011 100644 --- a/internal/agent/cursor.go +++ b/internal/agent/cursor.go @@ -47,4 +47,12 @@ func (c *cursor) RemoveWorkspace(workspaceDir string) error { return removeMCPConfig(c.WorkspaceConfigPath(workspaceDir)) } +func (c *cursor) GlobalConfigured() (bool, error) { + return mcpEntryConfigured(c.GlobalConfigPath(), "mcpServers") +} + +func (c *cursor) WorkspaceConfigured(workspaceDir string) (bool, error) { + return mcpEntryConfigured(c.WorkspaceConfigPath(workspaceDir), "mcpServers") +} + var _ Agent = (*cursor)(nil) diff --git a/internal/agent/cursor_test.go b/internal/agent/cursor_test.go index 2044816..fc0a43a 100644 --- a/internal/agent/cursor_test.go +++ b/internal/agent/cursor_test.go @@ -102,4 +102,38 @@ func TestCursor(t *testing.T) { servers, _ := data["mcpServers"].(map[string]any) assert.NotContains(t, servers, "safedep") }) + + t.Run("GlobalConfigured is false on absent file", func(t *testing.T) { + got, err := newCursor(t.TempDir()).GlobalConfigured() + require.NoError(t, err) + assert.False(t, got) + }) + + t.Run("GlobalConfigured tracks inject and remove", func(t *testing.T) { + c := newCursor(t.TempDir()) + require.NoError(t, c.InjectGlobal(cfg)) + + got, err := c.GlobalConfigured() + require.NoError(t, err) + assert.True(t, got) + + require.NoError(t, c.RemoveGlobal()) + got, err = c.GlobalConfigured() + require.NoError(t, err) + assert.False(t, got) + }) + + t.Run("WorkspaceConfigured tracks inject", func(t *testing.T) { + workspace := t.TempDir() + c := newCursor(t.TempDir()) + + got, err := c.WorkspaceConfigured(workspace) + require.NoError(t, err) + assert.False(t, got) + + require.NoError(t, c.InjectWorkspace(workspace, cfg)) + got, err = c.WorkspaceConfigured(workspace) + require.NoError(t, err) + assert.True(t, got) + }) } diff --git a/internal/agent/geminicli.go b/internal/agent/geminicli.go index a72dd01..a871a96 100644 --- a/internal/agent/geminicli.go +++ b/internal/agent/geminicli.go @@ -47,4 +47,12 @@ func (g *geminiCLI) RemoveWorkspace(workspaceDir string) error { return removeMCPConfig(g.WorkspaceConfigPath(workspaceDir)) } +func (g *geminiCLI) GlobalConfigured() (bool, error) { + return mcpEntryConfigured(g.GlobalConfigPath(), "mcpServers") +} + +func (g *geminiCLI) WorkspaceConfigured(workspaceDir string) (bool, error) { + return mcpEntryConfigured(g.WorkspaceConfigPath(workspaceDir), "mcpServers") +} + var _ Agent = (*geminiCLI)(nil) diff --git a/internal/agent/inject_test.go b/internal/agent/inject_test.go index d888fba..566ca73 100644 --- a/internal/agent/inject_test.go +++ b/internal/agent/inject_test.go @@ -34,6 +34,9 @@ func (f *fakeGlobalInjector) RemoveGlobal() error { f.removed = true return nil } +func (f *fakeGlobalInjector) GlobalConfigured() (bool, error) { + return f.injected != nil, nil +} type fakeWorkspaceInjector struct { path string @@ -59,6 +62,9 @@ func (f *fakeWorkspaceInjector) RemoveWorkspace(_ string) error { f.removed = true return nil } +func (f *fakeWorkspaceInjector) WorkspaceConfigured(_ string) (bool, error) { + return f.injected != nil, nil +} type fakeAgent struct { name string diff --git a/internal/agent/opencode.go b/internal/agent/opencode.go index 6fe1f23..c7f556c 100644 --- a/internal/agent/opencode.go +++ b/internal/agent/opencode.go @@ -48,6 +48,14 @@ func (o *openCode) RemoveWorkspace(workspaceDir string) error { return removeOpenCodeMCPConfig(o.WorkspaceConfigPath(workspaceDir)) } +func (o *openCode) GlobalConfigured() (bool, error) { + return mcpEntryConfigured(o.GlobalConfigPath(), "mcp") +} + +func (o *openCode) WorkspaceConfigured(workspaceDir string) (bool, error) { + return mcpEntryConfigured(o.WorkspaceConfigPath(workspaceDir), "mcp") +} + type openCodeMCPServerEntry struct { Type string `json:"type"` URL string `json:"url"` diff --git a/internal/agent/opencode_test.go b/internal/agent/opencode_test.go index 3b433bb..9eaeae3 100644 --- a/internal/agent/opencode_test.go +++ b/internal/agent/opencode_test.go @@ -63,4 +63,26 @@ func TestOpenCode(t *testing.T) { mcp := data["mcp"].(map[string]any) assert.NotContains(t, mcp, "safedep") }) + + t.Run("GlobalConfigured reads the mcp key", func(t *testing.T) { + o := newOpenCode(t.TempDir()) + + got, err := o.GlobalConfigured() + require.NoError(t, err) + assert.False(t, got) + + require.NoError(t, o.InjectGlobal(testCfg)) + got, err = o.GlobalConfigured() + require.NoError(t, err) + assert.True(t, got) + }) + + t.Run("WorkspaceConfigured reads the mcp key", func(t *testing.T) { + o := newOpenCode(t.TempDir()) + workspace := t.TempDir() + require.NoError(t, o.InjectWorkspace(workspace, testCfg)) + got, err := o.WorkspaceConfigured(workspace) + require.NoError(t, err) + assert.True(t, got) + }) } diff --git a/internal/agent/vscode.go b/internal/agent/vscode.go index 3781e9e..07d6f0b 100644 --- a/internal/agent/vscode.go +++ b/internal/agent/vscode.go @@ -80,6 +80,14 @@ func (v *vsCode) RemoveWorkspace(workspaceDir string) error { return removeVSCodeMCPConfig(v.WorkspaceConfigPath(workspaceDir)) } +func (v *vsCode) GlobalConfigured() (bool, error) { + return mcpEntryConfigured(v.GlobalConfigPath(), "servers") +} + +func (v *vsCode) WorkspaceConfigured(workspaceDir string) (bool, error) { + return mcpEntryConfigured(v.WorkspaceConfigPath(workspaceDir), "servers") +} + // writeVSCodeMCPConfig writes the SafeDep entry into a VS Code mcp.json. // VS Code uses "servers" as the root key (not "mcpServers") and requires // "type": "http" with "url" for remote Streamable HTTP servers. diff --git a/internal/agent/vscode_test.go b/internal/agent/vscode_test.go index fbe45df..9dc11fe 100644 --- a/internal/agent/vscode_test.go +++ b/internal/agent/vscode_test.go @@ -144,4 +144,36 @@ func TestVSCode(t *testing.T) { require.NoError(t, os.WriteFile(path, []byte("not json"), 0o600)) require.Error(t, v.RemoveWorkspace(workspace)) }) + + t.Run("GlobalConfigured reads the servers key", func(t *testing.T) { + v := newVSCode(t.TempDir()) + + got, err := v.GlobalConfigured() + require.NoError(t, err) + assert.False(t, got) + + require.NoError(t, v.InjectGlobal(testCfg)) + got, err = v.GlobalConfigured() + require.NoError(t, err) + assert.True(t, got) + }) + + t.Run("GlobalConfigured returns error on invalid JSON", func(t *testing.T) { + v := newVSCode(t.TempDir()) + path := v.GlobalConfigPath() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte("not json"), 0o600)) + _, err := v.GlobalConfigured() + require.Error(t, err) + }) + + t.Run("WorkspaceConfigured reads the servers key", func(t *testing.T) { + workspace := t.TempDir() + v := newVSCode(t.TempDir()) + + require.NoError(t, v.InjectWorkspace(workspace, testCfg)) + got, err := v.WorkspaceConfigured(workspace) + require.NoError(t, err) + assert.True(t, got) + }) } diff --git a/internal/cmd/protect/mcp/cmd.go b/internal/cmd/protect/mcp/cmd.go index 17f8f17..2321b58 100644 --- a/internal/cmd/protect/mcp/cmd.go +++ b/internal/cmd/protect/mcp/cmd.go @@ -15,5 +15,6 @@ func Register(parent *cobra.Command, a *app.App) { } cmd.AddCommand(installCmd(a)) cmd.AddCommand(uninstallCmd(a)) + cmd.AddCommand(statusCmd(a)) parent.AddCommand(cmd) } diff --git a/internal/cmd/protect/mcp/service.go b/internal/cmd/protect/mcp/service.go index 5cb5ad7..50421e2 100644 --- a/internal/cmd/protect/mcp/service.go +++ b/internal/cmd/protect/mcp/service.go @@ -1,6 +1,8 @@ package mcp import ( + "errors" + "github.com/safedep/cli/internal/agent" "github.com/safedep/cli/internal/endpoint" "github.com/safedep/dry/cloud/endpointsync" @@ -18,6 +20,26 @@ type uninstallInput struct { WorkspaceDir string } +type statusInput struct { + WorkspaceDir string +} + +// scopeStatus is the SafeDep MCP state for one config scope (global or +// workspace) of an agent. +type scopeStatus struct { + Supported bool + Configured bool + Path string +} + +// agentStatus is the SafeDep MCP integration state of a single agent. +type agentStatus struct { + Name string + Detected bool + Global scopeStatus + Workspace scopeStatus +} + type mcpService struct { agents []agent.Agent resolver endpointsync.EndpointIdentityResolver @@ -66,6 +88,48 @@ func (s *mcpService) uninstall(in uninstallInput) error { return nil } +// status reports the SafeDep MCP integration state of every known agent. +// Workspace scope is only inspected when WorkspaceDir is set. Per-agent read +// errors are accumulated and returned alongside the partial report. +func (s *mcpService) status(in statusInput) ([]agentStatus, error) { + var ( + out []agentStatus + errs []error + ) + + for _, a := range s.agents { + st := agentStatus{Name: a.Name(), Detected: a.Detected()} + + if gi, ok := a.AsGlobalInjector(); ok { + st.Global.Supported = true + st.Global.Path = gi.GlobalConfigPath() + if st.Detected { + configured, err := gi.GlobalConfigured() + if err != nil { + errs = append(errs, err) + } + st.Global.Configured = configured + } + } + + if wi, ok := a.AsWorkspaceInjector(); ok && in.WorkspaceDir != "" { + st.Workspace.Supported = true + st.Workspace.Path = wi.WorkspaceConfigPath(in.WorkspaceDir) + if st.Detected { + configured, err := wi.WorkspaceConfigured(in.WorkspaceDir) + if err != nil { + errs = append(errs, err) + } + st.Workspace.Configured = configured + } + } + + out = append(out, st) + } + + return out, errors.Join(errs...) +} + // configurableAgents returns detected agents that have at least one applicable // injector given workspaceDir. Agents detected but with no injectors applicable // to the current invocation (e.g. workspace-only agents called without diff --git a/internal/cmd/protect/mcp/service_test.go b/internal/cmd/protect/mcp/service_test.go index c29e518..522959b 100644 --- a/internal/cmd/protect/mcp/service_test.go +++ b/internal/cmd/protect/mcp/service_test.go @@ -22,9 +22,11 @@ func (f *fakeResolver) Resolve() (*controltowerv1.EndpointIdentity, error) { } type fakeGlobalInjector struct { - injected *agent.MCPConfig - removed bool - injectErr error + injected *agent.MCPConfig + removed bool + injectErr error + configured bool + configuredErr error } func (f *fakeGlobalInjector) GlobalConfigPath() string { return "/fake/path" } @@ -40,11 +42,16 @@ func (f *fakeGlobalInjector) RemoveGlobal() error { f.removed = true return nil } +func (f *fakeGlobalInjector) GlobalConfigured() (bool, error) { + return f.configured, f.configuredErr +} type fakeWorkspaceInjector struct { - injected *agent.MCPConfig - removed bool - injectErr error + injected *agent.MCPConfig + removed bool + injectErr error + configured bool + configuredErr error } func (f *fakeWorkspaceInjector) WorkspaceConfigPath(_ string) string { return "/fake/ws/path" } @@ -60,6 +67,9 @@ func (f *fakeWorkspaceInjector) RemoveWorkspace(_ string) error { f.removed = true return nil } +func (f *fakeWorkspaceInjector) WorkspaceConfigured(_ string) (bool, error) { + return f.configured, f.configuredErr +} type fakeAgent struct { name string @@ -197,3 +207,57 @@ func TestMCPServiceUninstall(t *testing.T) { require.NoError(t, svc.uninstall(uninstallInput{})) }) } + +func TestMCPServiceStatus(t *testing.T) { + t.Run("reports detected and configured per agent", func(t *testing.T) { + configured := &fakeAgent{name: "claude-code", detected: true, global: &fakeGlobalInjector{configured: true}} + notConfigured := &fakeAgent{name: "cursor", detected: true, global: &fakeGlobalInjector{configured: false}} + undetected := &fakeAgent{name: "vscode", detected: false, global: &fakeGlobalInjector{configured: true}} + svc := newMCPService([]agent.Agent{configured, notConfigured, undetected}, nil) + + statuses, err := svc.status(statusInput{}) + require.NoError(t, err) + require.Len(t, statuses, 3) + + assert.True(t, statuses[0].Detected) + assert.True(t, statuses[0].Global.Supported) + assert.True(t, statuses[0].Global.Configured) + + assert.True(t, statuses[1].Detected) + assert.False(t, statuses[1].Global.Configured) + + // Undetected agents must not be probed for configuration. + assert.False(t, statuses[2].Detected) + assert.False(t, statuses[2].Global.Configured) + }) + + t.Run("skips workspace scope when no workspaceDir", func(t *testing.T) { + a := &fakeAgent{name: "vscode", detected: true, workspace: &fakeWorkspaceInjector{configured: true}} + svc := newMCPService([]agent.Agent{a}, nil) + + statuses, err := svc.status(statusInput{}) + require.NoError(t, err) + require.Len(t, statuses, 1) + assert.False(t, statuses[0].Workspace.Supported) + }) + + t.Run("reports workspace scope when workspaceDir set", func(t *testing.T) { + a := &fakeAgent{name: "vscode", detected: true, workspace: &fakeWorkspaceInjector{configured: true}} + svc := newMCPService([]agent.Agent{a}, nil) + + statuses, err := svc.status(statusInput{WorkspaceDir: "/project"}) + require.NoError(t, err) + require.Len(t, statuses, 1) + assert.True(t, statuses[0].Workspace.Supported) + assert.True(t, statuses[0].Workspace.Configured) + }) + + t.Run("accumulates read errors", func(t *testing.T) { + a := &fakeAgent{name: "claude-code", detected: true, global: &fakeGlobalInjector{configuredErr: errors.New("read fail")}} + svc := newMCPService([]agent.Agent{a}, nil) + + statuses, err := svc.status(statusInput{}) + require.Error(t, err) + require.Len(t, statuses, 1) + }) +} diff --git a/internal/cmd/protect/mcp/status.go b/internal/cmd/protect/mcp/status.go new file mode 100644 index 0000000..988d48c --- /dev/null +++ b/internal/cmd/protect/mcp/status.go @@ -0,0 +1,146 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/safedep/cli/internal/agent" + "github.com/safedep/cli/internal/app" + drytui "github.com/safedep/dry/tui" + "github.com/safedep/dry/tui/table" + "github.com/safedep/dry/tui/theme" + "github.com/spf13/cobra" +) + +func statusCmd(a *app.App) *cobra.Command { + var flags statusInput + + cmd := &cobra.Command{ + Use: "status", + Short: "Show SafeDep MCP integration status for AI agents", + Long: "Report, for every supported AI coding agent, whether it is detected on this " + + "machine and whether the SafeDep MCP server is configured in its config file. " + + "Does not require authentication. Pass --workspace to also inspect workspace-level config.", + RunE: func(cmd *cobra.Command, _ []string) error { + svc := newMCPService(agent.NewRegistry(), nil) + + statuses, err := svc.status(flags) + if err != nil { + return err + } + + return a.Output.Print(&statusResult{ + statuses: statuses, + workspaceDir: flags.WorkspaceDir, + }) + }, + } + + cmd.Flags().StringVar(&flags.WorkspaceDir, "workspace", "", "project directory for workspace-level status (empty = skip)") + + return cmd +} + +type statusResult struct { + statuses []agentStatus + workspaceDir string +} + +type scopeJSON struct { + Supported bool `json:"supported"` + Configured bool `json:"configured"` + Path string `json:"path,omitempty"` +} + +type agentStatusJSON struct { + Name string `json:"name"` + Detected bool `json:"detected"` + Global scopeJSON `json:"global"` + Workspace *scopeJSON `json:"workspace,omitempty"` +} + +func (r *statusResult) RenderJSON() ([]byte, error) { + out := make([]agentStatusJSON, 0, len(r.statuses)) + for _, st := range r.statuses { + row := agentStatusJSON{ + Name: st.Name, + Detected: st.Detected, + Global: scopeJSON(st.Global), + } + if r.workspaceDir != "" { + ws := scopeJSON(st.Workspace) + row.Workspace = &ws + } + out = append(out, row) + } + return json.MarshalIndent(out, "", " ") +} + +func (r *statusResult) RenderPlain() string { + var sb strings.Builder + for _, st := range r.statuses { + fmt.Fprintf(&sb, "%s\tdetected=%s\tglobal=%s", + st.Name, yesNo(st.Detected), scopePlain(st.Detected, st.Global)) + if r.workspaceDir != "" { + fmt.Fprintf(&sb, "\tworkspace=%s", scopePlain(st.Detected, st.Workspace)) + } + sb.WriteString("\n") + } + return strings.TrimRight(sb.String(), "\n") +} + +func (r *statusResult) RenderTable() string { + withWorkspace := r.workspaceDir != "" + + headers := []string{"Agent", "Detected", "Global"} + if withWorkspace { + headers = append(headers, "Workspace") + } + + t := table.New().Headers(headers...) + for _, st := range r.statuses { + row := []string{st.Name, detectedBadge(st.Detected), scopeBadge(st.Detected, st.Global)} + if withWorkspace { + row = append(row, scopeBadge(st.Detected, st.Workspace)) + } + t = t.Row(row...) + } + return t.Render() +} + +func yesNo(b bool) string { + if b { + return "yes" + } + return "no" +} + +func detectedBadge(detected bool) string { + if detected { + return drytui.Badge(theme.RoleSuccess, "yes") + } + return drytui.Badge(theme.RoleMuted, "no") +} + +// scopeBadge renders a styled cell for a config scope. Undetected agents and +// unsupported scopes carry no SafeDep state, so they render as a plain dash. +func scopeBadge(detected bool, sc scopeStatus) string { + if !detected || !sc.Supported { + return "-" + } + if sc.Configured { + return drytui.Badge(theme.RoleSuccess, "configured") + } + return drytui.Badge(theme.RoleWarning, "not configured") +} + +func scopePlain(detected bool, sc scopeStatus) string { + if !detected || !sc.Supported { + return "-" + } + if sc.Configured { + return "configured" + } + return "not configured" +} From af4bb766c684d333ec1147d4e7b654b157774c3e Mon Sep 17 00:00:00 2001 From: Arunanshu Biswas Date: Mon, 15 Jun 2026 14:21:32 +0530 Subject: [PATCH 2/2] fix review comments --- internal/agent/claudecode.go | 31 ++++++++++++++++-- internal/agent/claudecode_test.go | 26 +++++++++++++++ internal/cmd/protect/mcp/service.go | 6 +++- internal/cmd/protect/mcp/service_test.go | 19 +++++++++++ internal/cmd/protect/mcp/status.go | 41 ++++++++++++++++++------ 5 files changed, 110 insertions(+), 13 deletions(-) diff --git a/internal/agent/claudecode.go b/internal/agent/claudecode.go index 2c889a6..705ce5a 100644 --- a/internal/agent/claudecode.go +++ b/internal/agent/claudecode.go @@ -56,11 +56,31 @@ func (c *claudeCode) WorkspaceConfigPath(_ string) string { } func (c *claudeCode) InjectWorkspace(workspaceDir string, cfg MCPConfig) error { - return writeClaudeCodeWorkspaceMCPConfig(c.GlobalConfigPath(), workspaceDir, cfg) + key, err := claudeProjectKey(workspaceDir) + if err != nil { + return err + } + return writeClaudeCodeWorkspaceMCPConfig(c.GlobalConfigPath(), key, cfg) } func (c *claudeCode) RemoveWorkspace(workspaceDir string) error { - return removeClaudeCodeWorkspaceMCPConfig(c.GlobalConfigPath(), workspaceDir) + key, err := claudeProjectKey(workspaceDir) + if err != nil { + return err + } + return removeClaudeCodeWorkspaceMCPConfig(c.GlobalConfigPath(), key) +} + +// claudeProjectKey resolves workspaceDir to the absolute, cleaned path Claude +// Code uses as the projects map key in ~/.claude.json. A relative path like "." +// or one with a trailing slash would otherwise miss the entry Claude Code keys +// by absolute path, both when probing and when writing. +func claudeProjectKey(workspaceDir string) (string, error) { + abs, err := filepath.Abs(workspaceDir) + if err != nil { + return "", fmt.Errorf("agent: resolve workspace path %s: %w", workspaceDir, err) + } + return abs, nil } func (c *claudeCode) GlobalConfigured() (bool, error) { @@ -79,6 +99,11 @@ type claudeCodeProbe struct { // WorkspaceConfigured checks projects[workspaceDir].mcpServers.safedep in // ~/.claude.json, the per-project location Claude Code uses. func (c *claudeCode) WorkspaceConfigured(workspaceDir string) (bool, error) { + key, err := claudeProjectKey(workspaceDir) + if err != nil { + return false, err + } + raw, err := os.ReadFile(c.GlobalConfigPath()) if errors.Is(err, os.ErrNotExist) { return false, nil @@ -95,7 +120,7 @@ func (c *claudeCode) WorkspaceConfigured(workspaceDir string) (bool, error) { return false, fmt.Errorf("agent: parse %s: %w", c.GlobalConfigPath(), err) } - _, ok := probe.Projects[workspaceDir].MCPServers[safedepMCPKey] + _, ok := probe.Projects[key].MCPServers[safedepMCPKey] return ok, nil } diff --git a/internal/agent/claudecode_test.go b/internal/agent/claudecode_test.go index d90f00b..2d12762 100644 --- a/internal/agent/claudecode_test.go +++ b/internal/agent/claudecode_test.go @@ -193,4 +193,30 @@ func TestClaudeCode(t *testing.T) { require.NoError(t, err) assert.False(t, got) }) + + t.Run("WorkspaceConfigured matches non-absolute workspace path", func(t *testing.T) { + home := t.TempDir() + workspace := t.TempDir() + cc := newClaudeCode(home) + require.NoError(t, cc.InjectWorkspace(workspace, cfg)) + + // A trailing slash must resolve to the same projects key Claude Code + // stores by absolute, cleaned path. + got, err := cc.WorkspaceConfigured(workspace + string(filepath.Separator)) + require.NoError(t, err) + assert.True(t, got) + }) + + t.Run("InjectWorkspace keys projects by absolute path", func(t *testing.T) { + home := t.TempDir() + workspace := t.TempDir() + cc := newClaudeCode(home) + + // Inject via a path with a trailing slash, probe via the clean path. + require.NoError(t, cc.InjectWorkspace(workspace+string(filepath.Separator), cfg)) + + got, err := cc.WorkspaceConfigured(workspace) + require.NoError(t, err) + assert.True(t, got) + }) } diff --git a/internal/cmd/protect/mcp/service.go b/internal/cmd/protect/mcp/service.go index 50421e2..cae5ea2 100644 --- a/internal/cmd/protect/mcp/service.go +++ b/internal/cmd/protect/mcp/service.go @@ -25,11 +25,13 @@ type statusInput struct { } // scopeStatus is the SafeDep MCP state for one config scope (global or -// workspace) of an agent. +// workspace) of an agent. Err records a probe failure for this scope, so the +// report can mark it distinctly instead of conflating it with "not configured". type scopeStatus struct { Supported bool Configured bool Path string + Err error } // agentStatus is the SafeDep MCP integration state of a single agent. @@ -107,6 +109,7 @@ func (s *mcpService) status(in statusInput) ([]agentStatus, error) { configured, err := gi.GlobalConfigured() if err != nil { errs = append(errs, err) + st.Global.Err = err } st.Global.Configured = configured } @@ -119,6 +122,7 @@ func (s *mcpService) status(in statusInput) ([]agentStatus, error) { configured, err := wi.WorkspaceConfigured(in.WorkspaceDir) if err != nil { errs = append(errs, err) + st.Workspace.Err = err } st.Workspace.Configured = configured } diff --git a/internal/cmd/protect/mcp/service_test.go b/internal/cmd/protect/mcp/service_test.go index 522959b..6233416 100644 --- a/internal/cmd/protect/mcp/service_test.go +++ b/internal/cmd/protect/mcp/service_test.go @@ -260,4 +260,23 @@ func TestMCPServiceStatus(t *testing.T) { require.Error(t, err) require.Len(t, statuses, 1) }) + + t.Run("returns partial report alongside per-scope error", func(t *testing.T) { + failing := &fakeAgent{name: "claude-code", detected: true, global: &fakeGlobalInjector{configuredErr: errors.New("read fail")}} + healthy := &fakeAgent{name: "cursor", detected: true, global: &fakeGlobalInjector{configured: true}} + svc := newMCPService([]agent.Agent{failing, healthy}, nil) + + statuses, err := svc.status(statusInput{}) + require.Error(t, err) + require.Len(t, statuses, 2) + + // The failing scope carries its error and is not reported as a clean + // "not configured". + require.Error(t, statuses[0].Global.Err) + assert.False(t, statuses[0].Global.Configured) + + // The healthy agent is still reported. + assert.NoError(t, statuses[1].Global.Err) + assert.True(t, statuses[1].Global.Configured) + }) } diff --git a/internal/cmd/protect/mcp/status.go b/internal/cmd/protect/mcp/status.go index 988d48c..d9634e8 100644 --- a/internal/cmd/protect/mcp/status.go +++ b/internal/cmd/protect/mcp/status.go @@ -2,6 +2,7 @@ package mcp import ( "encoding/json" + "errors" "fmt" "strings" @@ -25,15 +26,16 @@ func statusCmd(a *app.App) *cobra.Command { RunE: func(cmd *cobra.Command, _ []string) error { svc := newMCPService(agent.NewRegistry(), nil) - statuses, err := svc.status(flags) - if err != nil { - return err - } - - return a.Output.Print(&statusResult{ + // status accumulates per-agent probe errors alongside a partial + // report. Print the report first so healthy agents are still shown, + // then surface the errors via stderr and a non-zero exit. + statuses, statusErr := svc.status(flags) + printErr := a.Output.Print(&statusResult{ statuses: statuses, workspaceDir: flags.WorkspaceDir, }) + + return errors.Join(statusErr, printErr) }, } @@ -51,6 +53,19 @@ type scopeJSON struct { Supported bool `json:"supported"` Configured bool `json:"configured"` Path string `json:"path,omitempty"` + Error string `json:"error,omitempty"` +} + +func toScopeJSON(sc scopeStatus) scopeJSON { + out := scopeJSON{ + Supported: sc.Supported, + Configured: sc.Configured, + Path: sc.Path, + } + if sc.Err != nil { + out.Error = sc.Err.Error() + } + return out } type agentStatusJSON struct { @@ -66,10 +81,10 @@ func (r *statusResult) RenderJSON() ([]byte, error) { row := agentStatusJSON{ Name: st.Name, Detected: st.Detected, - Global: scopeJSON(st.Global), + Global: toScopeJSON(st.Global), } if r.workspaceDir != "" { - ws := scopeJSON(st.Workspace) + ws := toScopeJSON(st.Workspace) row.Workspace = &ws } out = append(out, row) @@ -124,11 +139,16 @@ func detectedBadge(detected bool) string { } // scopeBadge renders a styled cell for a config scope. Undetected agents and -// unsupported scopes carry no SafeDep state, so they render as a plain dash. +// unsupported scopes carry no SafeDep state, so they render as a plain dash. A +// failed probe renders as "error" rather than "not configured", which would +// misreport an unreadable config as a clean absence. func scopeBadge(detected bool, sc scopeStatus) string { if !detected || !sc.Supported { return "-" } + if sc.Err != nil { + return drytui.Badge(theme.RoleError, "error") + } if sc.Configured { return drytui.Badge(theme.RoleSuccess, "configured") } @@ -139,6 +159,9 @@ func scopePlain(detected bool, sc scopeStatus) string { if !detected || !sc.Supported { return "-" } + if sc.Err != nil { + return "error" + } if sc.Configured { return "configured" }