Skip to content

Commit 5e1a806

Browse files
authored
Merge pull request #727 from safedep/feat/antigravity-support
feat: vscode and antigravity support in endpoint scan
2 parents 5c3895f + 0072ad7 commit 5e1a806

14 files changed

Lines changed: 536 additions & 6 deletions

File tree

pkg/aitool/ai_extension.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ import (
1313
// ideNameFromPath extracts the IDE name from an extensions.json path.
1414
// e.g. "/home/user/.vscode/extensions/extensions.json" → "VS Code"
1515
var ideDirNames = map[string]string{
16-
".vscode": "VS Code",
17-
".vscode-oss": "VSCodium",
18-
".cursor": "Cursor",
19-
".windsurf": "Windsurf",
16+
".vscode": "VS Code",
17+
".vscode-oss": "VSCodium",
18+
".cursor": "Cursor",
19+
".windsurf": "Windsurf",
20+
".antigravity": "Antigravity",
2021
}
2122

2223
const (

pkg/aitool/antigravity.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package aitool
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
)
8+
9+
const (
10+
antigravityApp = "antigravity"
11+
antigravityAppDisplay = "Antigravity"
12+
)
13+
14+
type antigravityDiscoverer struct {
15+
homeDir string
16+
config DiscoveryConfig
17+
}
18+
19+
// NewAntigravityDiscoverer creates an Antigravity config discoverer.
20+
func NewAntigravityDiscoverer(config DiscoveryConfig) (AIToolReader, error) {
21+
homeDir := config.HomeDir
22+
if homeDir == "" {
23+
var err error
24+
homeDir, err = os.UserHomeDir()
25+
if err != nil {
26+
return nil, err
27+
}
28+
}
29+
return &antigravityDiscoverer{
30+
homeDir: homeDir,
31+
config: config,
32+
}, nil
33+
}
34+
35+
func (d *antigravityDiscoverer) Name() string { return "Antigravity Config" }
36+
func (d *antigravityDiscoverer) App() string { return antigravityApp }
37+
38+
func (d *antigravityDiscoverer) EnumTools(_ context.Context, handler AIToolHandlerFn) error {
39+
if !d.config.ScopeEnabled(AIToolScopeSystem) {
40+
return nil
41+
}
42+
43+
// ~/.gemini/antigravity/mcp_config.json holds global MCP server config.
44+
systemMCPPath := filepath.Join(d.homeDir, ".gemini", "antigravity", "mcp_config.json")
45+
if cfg, err := parseMCPAppConfig(systemMCPPath); err == nil {
46+
if err := emitMCPServers(cfg, systemMCPPath, AIToolScopeSystem, antigravityApp, antigravityAppDisplay, handler); err != nil {
47+
return err
48+
}
49+
}
50+
51+
// Emit coding_agent for the first known Antigravity data directory found.
52+
// Linux/macOS: ~/.antigravity (legacy), ~/.config/Antigravity (XDG config),
53+
// ~/.local/share/antigravity (XDG data).
54+
// Windows: %APPDATA%\Antigravity, %LOCALAPPDATA%\Antigravity.
55+
// os.Getenv returns "" on platforms where the variable is absent, causing
56+
// filepath.Join to produce a relative path that os.Stat will not find — the
57+
// directory is simply skipped.
58+
agDirs := []string{
59+
filepath.Join(d.homeDir, ".antigravity"),
60+
filepath.Join(d.homeDir, ".config", "Antigravity"),
61+
filepath.Join(d.homeDir, ".local", "share", "antigravity"),
62+
filepath.Join(os.Getenv("APPDATA"), "Antigravity"),
63+
filepath.Join(os.Getenv("LOCALAPPDATA"), "Antigravity"),
64+
}
65+
for _, dir := range agDirs {
66+
info, err := os.Stat(dir)
67+
if err != nil || !info.IsDir() {
68+
continue
69+
}
70+
agent := &AITool{
71+
Name: "Antigravity",
72+
Type: AIToolTypeCodingAgent,
73+
Scope: AIToolScopeSystem,
74+
App: antigravityApp,
75+
AppDisplay: antigravityAppDisplay,
76+
ConfigPath: dir,
77+
Agent: &AgentConfig{},
78+
}
79+
agent.ID = generateID(agent.App, string(agent.Type), string(agent.Scope), agent.Name, agent.ConfigPath)
80+
agent.SourceID = generateSourceID(agent.App, agent.ConfigPath)
81+
82+
if err := handler(agent); err != nil {
83+
return err
84+
}
85+
break
86+
}
87+
88+
return nil
89+
}

pkg/aitool/antigravity_cli.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package aitool
2+
3+
import "strings"
4+
5+
type antigravityCLIVerifier struct{}
6+
7+
func (d *antigravityCLIVerifier) BinaryNames() []string { return []string{"antigravity", "ag-kit"} }
8+
func (d *antigravityCLIVerifier) VerifyArgs() []string { return []string{"--version"} }
9+
func (d *antigravityCLIVerifier) DisplayName() string { return "Antigravity" }
10+
func (d *antigravityCLIVerifier) App() string { return antigravityApp }
11+
12+
func (d *antigravityCLIVerifier) VerifyOutput(stdout, stderr string) (string, bool) {
13+
// Output format: "1.107.0\n<commit-hash>\n<arch>"
14+
// Version is on the first line.
15+
combined := stdout + stderr
16+
firstLine := strings.SplitN(combined, "\n", 2)[0]
17+
if m := semverLineRe.FindStringSubmatch(strings.TrimSpace(firstLine)); len(m) > 1 {
18+
return m[1], true
19+
}
20+
return "", false
21+
}
22+
23+
// NewAntigravityCLIDiscoverer creates a discoverer for the Antigravity CLI binary.
24+
func NewAntigravityCLIDiscoverer(config DiscoveryConfig) (AIToolReader, error) {
25+
return &cliToolDiscoverer{verifier: &antigravityCLIVerifier{}, config: config}, nil
26+
}

pkg/aitool/antigravity_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package aitool
2+
3+
import (
4+
"context"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestAntigravityDiscoverer_WithFixtures(t *testing.T) {
13+
fixtures := fixturesDir(t)
14+
tmpDir := t.TempDir()
15+
16+
err := copyDir(
17+
filepath.Join(fixtures, "antigravity-gemini"),
18+
filepath.Join(tmpDir, ".gemini", "antigravity"),
19+
)
20+
require.NoError(t, err)
21+
22+
err = mkdir(filepath.Join(tmpDir, ".antigravity"))
23+
require.NoError(t, err)
24+
25+
reader, err := NewAntigravityDiscoverer(DiscoveryConfig{HomeDir: tmpDir})
26+
require.NoError(t, err)
27+
28+
var tools []*AITool
29+
err = reader.EnumTools(context.Background(), func(tool *AITool) error {
30+
tools = append(tools, tool)
31+
return nil
32+
})
33+
require.NoError(t, err)
34+
assert.NotEmpty(t, tools)
35+
36+
var systemMCP []*AITool
37+
for _, tool := range tools {
38+
if tool.Type == AIToolTypeMCPServer && tool.Scope == AIToolScopeSystem {
39+
systemMCP = append(systemMCP, tool)
40+
}
41+
}
42+
require.NotEmpty(t, systemMCP)
43+
assert.Equal(t, "ag-server", systemMCP[0].Name)
44+
assert.Equal(t, antigravityApp, systemMCP[0].App)
45+
assert.Equal(t, MCPTransportStreamableHTTP, systemMCP[0].MCPServer.Transport)
46+
47+
var agents []*AITool
48+
for _, tool := range tools {
49+
if tool.Type == AIToolTypeCodingAgent {
50+
agents = append(agents, tool)
51+
}
52+
}
53+
require.Len(t, agents, 1)
54+
assert.Equal(t, "Antigravity", agents[0].Name)
55+
assert.Equal(t, antigravityApp, agents[0].App)
56+
assert.Equal(t, AIToolScopeSystem, agents[0].Scope)
57+
}
58+
59+
func TestAntigravityDiscoverer_MissingConfig(t *testing.T) {
60+
reader, err := NewAntigravityDiscoverer(DiscoveryConfig{HomeDir: t.TempDir()})
61+
require.NoError(t, err)
62+
63+
var tools []*AITool
64+
err = reader.EnumTools(context.Background(), func(tool *AITool) error {
65+
tools = append(tools, tool)
66+
return nil
67+
})
68+
assert.NoError(t, err)
69+
assert.Empty(t, tools)
70+
}
71+
72+
func TestAntigravityDiscoverer_DirExistsButNoMCPJson(t *testing.T) {
73+
tmpDir := t.TempDir()
74+
require.NoError(t, mkdir(filepath.Join(tmpDir, ".antigravity")))
75+
76+
reader, err := NewAntigravityDiscoverer(DiscoveryConfig{HomeDir: tmpDir})
77+
require.NoError(t, err)
78+
79+
var tools []*AITool
80+
err = reader.EnumTools(context.Background(), func(tool *AITool) error {
81+
tools = append(tools, tool)
82+
return nil
83+
})
84+
require.NoError(t, err)
85+
require.Len(t, tools, 1)
86+
assert.Equal(t, AIToolTypeCodingAgent, tools[0].Type)
87+
assert.Equal(t, "Antigravity", tools[0].Name)
88+
}
89+
90+
func TestAntigravityDiscoverer_NoDuplicateCodingAgent(t *testing.T) {
91+
tmpDir := t.TempDir()
92+
require.NoError(t, mkdir(filepath.Join(tmpDir, ".antigravity")))
93+
require.NoError(t, mkdir(filepath.Join(tmpDir, ".config", "Antigravity")))
94+
95+
reader, err := NewAntigravityDiscoverer(DiscoveryConfig{HomeDir: tmpDir})
96+
require.NoError(t, err)
97+
98+
var agents []*AITool
99+
err = reader.EnumTools(context.Background(), func(tool *AITool) error {
100+
if tool.Type == AIToolTypeCodingAgent {
101+
agents = append(agents, tool)
102+
}
103+
return nil
104+
})
105+
require.NoError(t, err)
106+
assert.Len(t, agents, 1, "should emit only one coding_agent even when multiple dirs exist")
107+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"mcpServers": {
3+
"ag-server": {
4+
"serverUrl": "https://mcp.example.com/mcp"
5+
}
6+
}
7+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"servers": {
3+
"vscode-project-tool": {
4+
"command": "python",
5+
"args": ["tool.py"]
6+
}
7+
}
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"mcpServers": {
3+
"vscode-global": {
4+
"command": "node",
5+
"args": ["server.js"]
6+
}
7+
}
8+
}

pkg/aitool/mcp_config.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package aitool
22

33
import (
44
"encoding/json"
5+
"maps"
56
"os"
67
"sort"
78
"strings"
@@ -35,8 +36,11 @@ func (e mcpServerEntry) resolvedURL() string {
3536
// mcpAppConfig represents an application's JSON config file containing
3637
// MCP servers. The Permissions and Model fields are used by Claude Code;
3738
// other apps ignore them during JSON unmarshaling.
39+
// VS Code (and Antigravity project files) use "servers" as the top-level key
40+
// instead of "mcpServers"; both are read and merged during emit.
3841
type mcpAppConfig struct {
3942
MCPServers map[string]mcpServerEntry `json:"mcpServers,omitempty"`
43+
Servers map[string]mcpServerEntry `json:"servers,omitempty"`
4044
Permissions map[string]any `json:"permissions,omitempty"`
4145
Model string `json:"model,omitempty"`
4246
}
@@ -84,9 +88,14 @@ func detectTransport(entry mcpServerEntry) MCPTransport {
8488
}
8589

8690
// emitMCPServers creates and emits AITool entries for all MCP servers in a config.
91+
// Entries from "mcpServers" and "servers" are merged; "mcpServers" takes precedence
92+
// when the same name appears in both.
8793
func emitMCPServers(cfg *mcpAppConfig, configPath string, scope AIToolScope, app, appDisplay string, handler AIToolHandlerFn) error {
88-
for _, name := range sortedKeys(cfg.MCPServers) {
89-
entry := cfg.MCPServers[name]
94+
merged := make(map[string]mcpServerEntry, len(cfg.MCPServers)+len(cfg.Servers))
95+
maps.Copy(merged, cfg.Servers)
96+
maps.Copy(merged, cfg.MCPServers)
97+
for _, name := range sortedKeys(merged) {
98+
entry := merged[name]
9099

91100
transport := detectTransport(entry)
92101

pkg/aitool/registry.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,15 @@ func DefaultRegistry() *Registry {
7979
r.Register("claude_code_config", NewClaudeCodeDiscoverer)
8080
r.Register("cursor_config", NewCursorDiscoverer)
8181
r.Register("windsurf_config", NewWindsurfDiscoverer)
82+
r.Register("antigravity_config", NewAntigravityDiscoverer)
83+
r.Register("vscode_config", NewVSCodeDiscoverer)
8284

8385
// CLI tool discoverers
8486
r.Register("claude_code_cli", NewClaudeCLIDiscoverer)
8587
r.Register("cursor_cli", NewCursorCLIDiscoverer)
8688
r.Register("windsurf_cli", NewWindsurfCLIDiscoverer)
89+
r.Register("antigravity_cli", NewAntigravityCLIDiscoverer)
90+
r.Register("vscode_cli", NewVSCodeCLIDiscoverer)
8791
r.Register("aider", NewAiderDiscoverer)
8892
r.Register("gh_copilot", NewGhCopilotDiscoverer)
8993
r.Register("amazon_q", NewAmazonQDiscoverer)

0 commit comments

Comments
 (0)