Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
36 changes: 36 additions & 0 deletions docs/cmd/protect-mcp-status.md
Original file line number Diff line number Diff line change
@@ -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 <dir>` | 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.
6 changes: 6 additions & 0 deletions go.work.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down Expand Up @@ -56,15 +57,18 @@ 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=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
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=
Expand Down Expand Up @@ -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=
Expand All @@ -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=
Expand Down
8 changes: 8 additions & 0 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}
4 changes: 4 additions & 0 deletions internal/agent/antigravity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
65 changes: 63 additions & 2 deletions internal/agent/claudecode.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,72 @@ 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) {
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) {
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
}
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[key].MCPServers[safedepMCPKey]
return ok, nil
}

// writeClaudeCodeMCPConfig writes the SafeDep entry into a Claude Code JSON
Expand Down
64 changes: 64 additions & 0 deletions internal/agent/claudecode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,68 @@ 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)
})

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)
})
}
36 changes: 36 additions & 0 deletions internal/agent/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions internal/agent/cursor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
34 changes: 34 additions & 0 deletions internal/agent/cursor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
8 changes: 8 additions & 0 deletions internal/agent/geminicli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
6 changes: 6 additions & 0 deletions internal/agent/inject_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading