Skip to content

Commit a216e02

Browse files
authored
feat: Add support for AI discovery (#685)
* feat: Add support for AI usage discovery * fix: Validate coding agent discovery * docs: Update README * fix: Add missing test fixture * fix: Remove stale spec * refactor: Re-use MCP config structs * refactor: Use scopes for discovery * refactor: Use apps instead of hosts * refactor: Set context as top level dependency * fix: Code review fixes * refactor: Protect internal concerns * fix: Hoist regex.MustCompile * fix: Code review fixes
1 parent a019c4d commit a216e02

46 files changed

Lines changed: 2948 additions & 6 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ dist/
2828
**/.DS_Store
2929

3030
# Auto-generated context files
31-
CLAUDE.md
31+
/CLAUDE.md
32+
.devcontainer

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@
4343
4444
Traditional SCA tools drown you in CVE noise. **vet** takes a different approach:
4545

46-
- **Catch malware before it ships** — Zero-day detection through static and dynamic behavioral analysis, not just advisory lookups
47-
- **Cut through vulnerability noise** — Analyzes your actual code usage to surface only the risks that matter
48-
- **Secure AI-generated code**[MCP server](./docs/mcp.md) integration protects against [slopsquatting](https://en.wikipedia.org/wiki/Slopsquatting) in tools like Cursor, VS Code, and Claude Code
49-
- **Enforce policy as code** — Express security, license, and quality requirements as [CEL](https://cel.dev/) expressions that gate your CI/CD pipeline
46+
- **Shadow AI discovery** — Discover AI tool usage signals across various tools and configurations
47+
- **Catch malware before it ships** — Zero-day detection through static and dynamic behavioral analysis (requires SafeDep Cloud access)
48+
- **Cut through vulnerability noise** — Analyzes actual code usage to surface only the risks that matter
49+
- **Enforce policy as code** — Express security, license, and quality requirements as [CEL](https://cel.dev/) expressions
50+
- **CI/CD integration** — Zero-config security guardrails in CI/CD
5051

5152
Free for open source. Hosted SaaS available at [SafeDep](https://safedep.io).
5253

@@ -278,8 +279,9 @@ vet version
278279

279280
**Learn more in our comprehensive documentation:**
280281

281-
- **[MCP Server](./docs/mcp.md)** - Run vet as an MCP server for AI-assisted code analysis
282+
- **[AI Usage Discovery](./docs/ai-discovery.md)** - Discover AI tool usage signals across various tools and configurations
282283
- **[AI Agent Mode](./docs/agent.md)** - Run vet as an AI agent
284+
- **[MCP Server](./docs/mcp.md)** - Run vet as an MCP server for AI-assisted code analysis
283285
- **[Reporting](./docs/reporting.md)** - SARIF, JSON, CSV, HTML, Markdown formats
284286
- **[SBOM Support](https://docs.safedep.io/vet/guides/cyclonedx-sbom)** - CycloneDX, SPDX import/export
285287
- **[Query Mode](https://docs.safedep.io/cloud/quickstart#query-your-data)** - Scan once, analyze multiple times

cmd/ai/discover.go

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package ai
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
12+
"github.com/jedib0t/go-pretty/v6/table"
13+
"github.com/spf13/cobra"
14+
15+
"github.com/safedep/vet/pkg/aitool"
16+
"github.com/safedep/vet/pkg/common/logger"
17+
)
18+
19+
var (
20+
discoverScopes []string
21+
discoverProjectDir string
22+
discoverReportJSON string
23+
discoverSilent bool
24+
)
25+
26+
func newDiscoverCommand() *cobra.Command {
27+
cmd := &cobra.Command{
28+
Use: "discover",
29+
Short: "Discover AI tools usage, MCP servers, and coding agents",
30+
RunE: func(cmd *cobra.Command, args []string) error {
31+
redirectLogOutput(cmd)
32+
return runDiscover(cmd.Context())
33+
},
34+
}
35+
36+
cmd.Flags().StringArrayVar(&discoverScopes, "scope", nil, "Limit to specific scopes (system, project); repeatable, empty for all")
37+
cmd.Flags().StringVarP(&discoverProjectDir, "project-dir", "D", "", "Project root for project-level discovery (default: cwd)")
38+
cmd.Flags().StringVar(&discoverReportJSON, "report-json", "", "Write JSON inventory to file")
39+
cmd.Flags().BoolVarP(&discoverSilent, "silent", "s", false, "Suppress default summary output")
40+
41+
return cmd
42+
}
43+
44+
func runDiscover(ctx context.Context) error {
45+
projectDir := discoverProjectDir
46+
if projectDir == "" {
47+
var err error
48+
projectDir, err = os.Getwd()
49+
if err != nil {
50+
return fmt.Errorf("failed to get working directory: %w", err)
51+
}
52+
}
53+
54+
scope, err := buildDiscoveryScope(discoverScopes)
55+
if err != nil {
56+
return err
57+
}
58+
59+
config := aitool.DiscoveryConfig{
60+
ProjectDir: projectDir,
61+
Scope: scope,
62+
}
63+
64+
registry := aitool.DefaultRegistry()
65+
inventory := aitool.NewAIToolInventory()
66+
67+
err = registry.Discover(ctx, config, func(tool *aitool.AITool) error {
68+
inventory.Add(tool)
69+
return nil
70+
})
71+
if err != nil {
72+
return err
73+
}
74+
75+
if !discoverSilent {
76+
printSummaryTable(inventory)
77+
}
78+
79+
if discoverReportJSON != "" {
80+
if err := writeJSONInventory(inventory, discoverReportJSON); err != nil {
81+
return fmt.Errorf("failed to write JSON report: %w", err)
82+
}
83+
}
84+
85+
return nil
86+
}
87+
88+
func printSummaryTable(inventory *aitool.AIToolInventory) {
89+
apps := inventory.GroupByApp()
90+
91+
fmt.Fprintf(os.Stderr, "\nDiscovered %d AI tool usage(s) across %d app(s)\n\n",
92+
len(inventory.Tools), len(apps))
93+
94+
if len(inventory.Tools) == 0 {
95+
return
96+
}
97+
98+
tbl := table.NewWriter()
99+
tbl.SetOutputMirror(os.Stderr)
100+
tbl.SetStyle(table.StyleLight)
101+
102+
tbl.AppendHeader(table.Row{"TYPE", "NAME", "APP", "SCOPE", "DETAIL"})
103+
104+
for _, tool := range inventory.Tools {
105+
detail := toolDetail(tool)
106+
tbl.AppendRow(table.Row{
107+
string(tool.Type),
108+
tool.Name,
109+
tool.App,
110+
string(tool.Scope),
111+
detail,
112+
})
113+
}
114+
115+
tbl.Render()
116+
fmt.Fprintln(os.Stderr)
117+
}
118+
119+
func toolDetail(tool *aitool.AITool) string {
120+
switch tool.Type {
121+
case aitool.AIToolTypeMCPServer:
122+
if tool.MCPServer != nil {
123+
if tool.MCPServer.Command != "" {
124+
detail := string(tool.MCPServer.Transport) + ": " + tool.MCPServer.Command
125+
if len(tool.MCPServer.Args) > 0 {
126+
for _, arg := range tool.MCPServer.Args {
127+
detail += " " + arg
128+
}
129+
}
130+
return detail
131+
}
132+
if tool.MCPServer.URL != "" {
133+
return string(tool.MCPServer.Transport) + ": " + tool.MCPServer.URL
134+
}
135+
}
136+
return tool.ConfigPath
137+
case aitool.AIToolTypeCLITool:
138+
version := tool.GetMetaString("binary.version")
139+
path := tool.GetMetaString("binary.path")
140+
if version != "" {
141+
return path + " v" + version
142+
}
143+
return path
144+
case aitool.AIToolTypeAIExtension:
145+
id := tool.GetMetaString("extension.id")
146+
version := tool.GetMetaString("extension.version")
147+
ide := tool.GetMetaString("extension.ide")
148+
detail := id
149+
if version != "" {
150+
detail += " v" + version
151+
}
152+
if ide != "" {
153+
detail += " (" + ide + ")"
154+
}
155+
return detail
156+
case aitool.AIToolTypeProjectConfig:
157+
if tool.Agent != nil && len(tool.Agent.InstructionFiles) > 0 {
158+
return strings.Join(fileBaseNames(tool.Agent.InstructionFiles), ", ")
159+
}
160+
return tool.ConfigPath
161+
default:
162+
return tool.ConfigPath
163+
}
164+
}
165+
166+
func writeJSONInventory(inventory *aitool.AIToolInventory, path string) error {
167+
data, err := json.MarshalIndent(inventory, "", " ")
168+
if err != nil {
169+
return err
170+
}
171+
172+
return os.WriteFile(path, data, 0o644)
173+
}
174+
175+
func redirectLogOutput(cmd *cobra.Command) {
176+
logFile, _ := cmd.Root().PersistentFlags().GetString("log")
177+
if logFile == "-" {
178+
logger.MigrateTo(os.Stdout)
179+
} else if logFile != "" {
180+
logger.LogToFile(logFile)
181+
} else {
182+
logger.MigrateTo(io.Discard)
183+
}
184+
}
185+
186+
func fileBaseNames(paths []string) []string {
187+
names := make([]string, len(paths))
188+
for i, p := range paths {
189+
names[i] = filepath.Base(p)
190+
}
191+
return names
192+
}
193+
194+
// buildDiscoveryScope converts CLI --scope flag values to a DiscoveryScope.
195+
// Returns nil (all scopes) when no scopes are specified.
196+
func buildDiscoveryScope(scopes []string) (*aitool.DiscoveryScope, error) {
197+
if len(scopes) == 0 {
198+
return nil, nil
199+
}
200+
parsed := make([]aitool.AIToolScope, len(scopes))
201+
for i, s := range scopes {
202+
parsed[i] = aitool.AIToolScope(s)
203+
}
204+
return aitool.NewDiscoveryScope(parsed...)
205+
}

cmd/ai/main.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package ai
2+
3+
import "github.com/spf13/cobra"
4+
5+
func NewAICommand() *cobra.Command {
6+
cmd := &cobra.Command{
7+
Use: "ai",
8+
Short: "AI tools usage discovery and analysis",
9+
Long: `Discover and audit AI tool usage across the local development environment.
10+
11+
AI coding agents, MCP servers, and extensions are often adopted by developers
12+
without centralized visibility, creating Shadow AI. This command group helps
13+
gain awareness of what AI tools are active, what permissions they hold, and
14+
what external services they connect to.`,
15+
RunE: func(cmd *cobra.Command, args []string) error {
16+
return cmd.Help()
17+
},
18+
}
19+
20+
cmd.AddCommand(newDiscoverCommand())
21+
return cmd
22+
}

docs/ai-discovery-dev.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# AI Tool Discovery: Developer Guide
2+
3+
This document covers the internal structure of `pkg/aitool` for contributors who need to add new applications, CLI tools, IDE extensions, or modify discovery behavior.
4+
5+
## Package layout
6+
7+
| File | Purpose |
8+
|------|---------|
9+
| `model.go` | Core types: `AITool`, `AIToolType`, `AIToolScope`, inventory helpers |
10+
| `reader.go` | `AIToolReader` interface that all discoverers implement |
11+
| `registry.go` | `Registry` that wires discoverers and runs discovery, `DiscoveryConfig` |
12+
| `scope.go` | `DiscoveryScope` with prerequisite validation |
13+
| `mcp_config.go` | Shared MCP config parsing used by config-based discoverers |
14+
| `cli_common.go` | Shared CLI probe/verify pipeline used by CLI tool discoverers |
15+
| `sanitize.go` | Argument redaction for secret patterns |
16+
| `known_extensions.go` | Map of recognized AI IDE extension IDs |
17+
18+
## Curated maps
19+
20+
Several maps require manual updates when adding support for new tools.
21+
22+
### `knownScopes` in `scope.go`
23+
24+
Maps each `AIToolScope` to its `ScopeMetadata` (prerequisites like `RequiresHomeDir` or `RequiresProjectDir`). A new scope must be registered here or `NewDiscoveryScope` will reject it.
25+
26+
### `knownAIExtensions` in `known_extensions.go`
27+
28+
Maps lowercase VSIX extension IDs to display names. The IDE extension discoverer filters installed extensions against this map. To recognize a new AI extension, add its ID and display name here.
29+
30+
### `ideDirNames` in `ai_extension.go`
31+
32+
Maps IDE config directory names (e.g. `.vscode`, `.cursor`) to human readable IDE names. This is used to label which IDE an extension belongs to. Add an entry when supporting a new IDE distribution.
33+
34+
### `argSecretPatterns` in `sanitize.go`
35+
36+
List of CLI argument prefixes (e.g. `--token=`, `--api-key=`) that trigger value redaction. Add new patterns here if a tool uses a nonstandard secret flag.
37+
38+
### `DefaultRegistry()` in `registry.go`
39+
40+
Wires all built-in discoverers in a fixed order. Any new discoverer must be registered here.
41+
42+
## Adding a new config-based app discoverer
43+
44+
A config-based discoverer reads JSON configuration files from well-known filesystem paths. Examples: `claude_code.go`, `cursor.go`, `windsurf.go`.
45+
46+
1. Create a new file (e.g. `newapp.go`) with a struct implementing `AIToolReader`.
47+
2. Define an app constant (e.g. `const newappApp = "newapp"`).
48+
3. In the factory function, resolve `HomeDir` from config (falling back to `os.UserHomeDir()`).
49+
4. In `EnumTools`, gate system work behind `config.ScopeEnabled(AIToolScopeSystem)` and project work behind `config.ScopeEnabled(AIToolScopeProject)`.
50+
5. Use `parseMCPAppConfig` and `emitMCPServers` from `mcp_config.go` if the app stores MCP servers in the standard `{"mcpServers": {...}}` JSON format.
51+
6. Register the factory in `DefaultRegistry()`.
52+
7. Add test fixtures under `fixtures/` and write tests following the pattern in existing `*_test.go` files.
53+
54+
## Adding a new CLI tool discoverer
55+
56+
A CLI tool discoverer searches `$PATH` for a binary, executes it with a version flag, and verifies the output matches a known pattern.
57+
58+
1. Create a new file (e.g. `newtool.go`) with a struct implementing `CLIToolVerifier`.
59+
2. Implement `BinaryNames()` (candidate names to search), `VerifyArgs()` (flags to run), `VerifyOutput()` (regex to extract version), `DisplayName()`, and `App()`.
60+
3. Create a factory function that returns `&cliToolDiscoverer{verifier: &newVerifier{}, config: config}`.
61+
4. Register in `DefaultRegistry()`.
62+
5. Add a test case to `TestCLIVerifiers_VerifyOutput` in `cli_common_test.go`.
63+
64+
## Adding a new IDE extension
65+
66+
Add a single entry to `knownAIExtensions` in `known_extensions.go`. The key must be the lowercase VSIX extension ID (e.g. `publisher.extension-name`). No other code changes are needed.
67+
68+
If the extension ships through a new IDE distribution not yet listed in `ideDirNames`, add the directory name mapping there as well.
69+
70+
## Scope rules
71+
72+
The `scope` field on emitted `AITool` entries must reflect where the configuration file physically lives, not which project it relates to.
73+
74+
- Files under `~/.<app>/` or similar user-global directories are `system` scoped.
75+
- Files inside a project repository (e.g. `.mcp.json`, `.cursorrules`) are `project` scoped.
76+
77+
For example, `~/.claude/projects/*/settings.json` lives inside the system-level `~/.claude/` directory, so tools discovered from it are `system` scoped even though the directory name contains "projects".
78+
79+
## Shared MCP config parsing
80+
81+
`mcp_config.go` provides types and helpers shared across apps that use the common `{"mcpServers": {...}}` JSON format:
82+
83+
- `mcpServerEntry` represents a single server entry. It handles both `url` and `serverUrl` keys (Windsurf uses `serverUrl`). The `resolvedURL()` method normalizes this.
84+
- `mcpAppConfig` represents the top-level JSON structure. Extra fields like `permissions` and `model` are used only by Claude Code; other apps ignore them.
85+
- `parseMCPAppConfig(path)` reads and parses the file, logging a warning on parse errors.
86+
- `emitMCPServers(cfg, path, scope, app, handler)` iterates servers in deterministic order and emits `AITool` entries with sanitized args and env var names (values are never captured).
87+
- `detectTransport(entry)` infers the MCP transport from an explicit `type` field or falls back to heuristics (command present means stdio, URL with `/sse` means SSE, other URLs mean streamable HTTP).
88+
89+
## Security invariants
90+
91+
- Environment variable and header **values** are never stored. Only key names are recorded.
92+
- CLI arguments matching patterns in `argSecretPatterns` are redacted by `SanitizeArgs`.
93+
- No network calls are made during discovery. All data comes from local filesystem and `$PATH`.

0 commit comments

Comments
 (0)