Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
0eeed88
feat: send discovered servers during guard install
iamcristi Aug 20, 2026
2c1fd06
fix: escape PowerShell machine identifiers
iamcristi Aug 20, 2026
0ad0809
fix: scope explicit flags to active subparser
iamcristi Aug 20, 2026
0a8c03e
test: keep run-install tests hermetic from discovery event
iamcristi Aug 20, 2026
2e332a4
feat: refresh discovered servers on Claude sessions
iamcristi Aug 20, 2026
34b80b4
test: gate Claude discovery flow to POSIX
iamcristi Aug 20, 2026
c30bbd9
fix: report hook-script update when only the discovery script changed
iamcristi Aug 20, 2026
088c6b5
feat: discover explicit project folders
iamcristi Aug 20, 2026
ce243d9
feat: configure hook project folder payload key
iamcristi Aug 20, 2026
665ae86
refactor: remove Claude-specific hook payload flag
iamcristi Aug 20, 2026
02cb89c
refactor: keep project roots internal to hook discovery
iamcristi Aug 20, 2026
898f417
feat: configure project root field per agent hook
iamcristi Aug 20, 2026
885c3fc
refactor: resolve hook project roots by agent
iamcristi Aug 21, 2026
cbb1e6f
refactor: reuse hook client for project roots
iamcristi Aug 21, 2026
9e49202
feat: session-start server discovery for cursor, codex, and windows
iamcristi Aug 21, 2026
76fb424
fix: silence session-start discovery hook output
iamcristi Aug 21, 2026
4fa2e80
fix: forward session id in discovery hook
iamcristi Aug 21, 2026
5ae0a96
feat: report server discovery duration
iamcristi Aug 24, 2026
025e379
fix: harden discovery hook delivery
iamcristi Aug 24, 2026
be7557f
test: make discovery hooks portable on Windows
iamcristi Aug 24, 2026
9ebbff5
fix: harden server discovery hooks
iamcristi Aug 24, 2026
adde67b
fix: bound discovery timeout overrides
iamcristi Aug 24, 2026
5e6268d
feat: send discovery events directly
iamcristi Aug 24, 2026
c1120b1
refactor: separate discovery target folders
iamcristi Aug 25, 2026
4b417c1
fix: require machine ID for Agent Guard hooks
iamcristi Aug 25, 2026
2029ba1
fix: harden session-start server discovery
iamcristi Aug 25, 2026
19510d0
fix: address PR 443 review findings
iamcristi Aug 25, 2026
c6bd875
refactor: inline resolve fallbacks, drop safe_resolve
iamcristi Aug 25, 2026
31c9538
refactor: consolidate discovery folder deduplication
iamcristi Aug 25, 2026
2d72d24
refactor: simplify discovery timeout watchdog
iamcristi Aug 25, 2026
0803137
fix: address discovery review findings
iamcristi Aug 25, 2026
8e5e48f
test: make rollback assertion cross-platform
iamcristi Aug 25, 2026
c2381f2
Merge remote-tracking branch 'origin/main' into feat/guard-serversdis…
iamcristi Aug 25, 2026
7f0bc61
fix: make guard tests portable on Windows
iamcristi Aug 26, 2026
1f10322
fix: preserve relative path semantics
iamcristi Aug 26, 2026
4fe519c
refactor: single machine-id spelling for guard install
iamcristi Aug 26, 2026
c95505e
revert: drop end-of-options guard from explicitly_provided_dests
iamcristi Aug 26, 2026
1797922
docs: restore explicitly_provided_dests docstring
iamcristi Aug 26, 2026
1a72cef
revert: drop hook-script snapshot rollback from guard install
iamcristi Aug 26, 2026
c04bf11
fix: send an honest User-Agent for direct hook events
iamcristi Aug 26, 2026
58a2238
refactor: take AGENT_SCAN_BIN from the environment only
iamcristi Aug 26, 2026
2714274
fix: make session discovery opt-in
iamcristi Aug 26, 2026
5b44922
feat: add discovery hook for managed Codex
iamcristi Aug 26, 2026
982c377
refactor: make hook script copy generic
iamcristi Aug 26, 2026
3deaba5
refactor: unify hook command builders behind one invocation spec
iamcristi Aug 26, 2026
d1d8b7a
docs: drop the PATH-lookup claim for AGENT_SCAN_BIN
iamcristi Aug 26, 2026
5665da2
refactor: generalize guard client operations
iamcristi Aug 26, 2026
9412aa9
feat: support agent scan discovery commands
iamcristi Aug 26, 2026
e1b42e9
refactor: derive uninstall missing-file label from the path
iamcristi Aug 26, 2026
113c937
chore: comment clean-up
iamcristi Aug 26, 2026
260233d
test: fix test
iamcristi Aug 26, 2026
1e1b46e
test: fix test
iamcristi Aug 26, 2026
c7dd31c
Merge branch 'main' into feat/guard-serversdiscovered-event
iamcristi Aug 26, 2026
a6ce61b
test: compare rendered argv against str(Path)
iamcristi Aug 26, 2026
abf8d1a
docs: document guard discover --scope
iamcristi Aug 26, 2026
092ef22
fix: escape Codex managed hooks TOML
iamcristi Aug 27, 2026
5b9b1aa
refactor: move TOML escaping to utils
iamcristi Aug 27, 2026
656da7b
refactor: rename server discovery hook events
iamcristi Aug 27, 2026
4c79505
fix: default schemeless hook URLs to HTTP
iamcristi Aug 27, 2026
3ef9db1
fix: add temporary discovery install fallbacks (#454)
iamcristi Aug 28, 2026
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
28 changes: 25 additions & 3 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ snyk-agent-scan scan --config-file agent-scan.yaml \
Manage [Agent Guard](https://evo.ai.snyk.io) hooks for Claude Code, Cursor, and Codex:

```bash
snyk-agent-scan guard [install|uninstall] [OPTIONS]
snyk-agent-scan guard [install|uninstall|discover] [OPTIONS]
snyk-agent-scan guard
```

Expand All @@ -355,14 +355,34 @@ snyk-agent-scan guard
snyk-agent-scan guard install {claude,cursor,codex,all} [OPTIONS]
```

After configuring the hooks, installation sends a `hooksConfiguredServerDiscovery` event. It also configures a
fire-and-forget session-start hook that reports discovered MCP servers with a `sessionStartServerDiscovery` event.

| Flag | Type | Default | Description |
| --- | --- | --- | --- |
| `--url URL` | string | `https://api.snyk.io` | Remote hook base URL for the Snyk API environment. |
| `--tenant-id ID` | string | — | Snyk tenant UUID. Required when minting a push key; unnecessary when `PUSH_KEY` is set. |
| `--machine-id ID` | string | — | Required non-anonymous machine identifier sent in the `X-User` header's `identifier` field. May instead be set with `MACHINE_ID`. |
| `--file PATH` | string | — | Override the client configuration path. |
| `--managed` | boolean | `false` | Install in the admin/MDM-managed configuration rather than the user configuration. |
| `--test` | boolean | `false` | **Deprecated/no-op.** |

### `guard discover`

```bash
snyk-agent-scan guard discover [OPTIONS]
```

This internal command is invoked by the SessionStart hook configured by `guard install`. It reads the current target
folder(s) from the selected client's hook payload, discovers MCP servers locally, and sends the resulting
`sessionStartServerDiscovery` event directly to Agent Monitor; it is not normally run by hand.

| Flag | Type | Default | Description |
| --- | --- | --- | --- |
| `--url URL` | string | `https://api.snyk.io` | Remote hook base URL for the Snyk API environment. |
| `--client {claude-code,cursor,codex}` | string | required | Hook client whose target-folder payload and endpoint conventions should be used. |
| `--scope {servers,skills,all}` | string | `all` | Discovery data to collect. The session-start hook installed by `guard install` passes `servers`, because the event it sends carries MCP servers only. |

### `guard uninstall`

```bash
Expand All @@ -381,6 +401,8 @@ snyk-agent-scan guard uninstall {claude,cursor,codex,all} [OPTIONS]
| `PUSH_KEY` | Pre-provisioned push key; skips minting when set |
| `TENANT_ID` | Tenant UUID alternative to `--tenant-id` |
| `SNYK_TOKEN` | Required to mint/revoke push keys and verify that Guard is enabled for the tenant |
| `MACHINE_ID` | Required non-anonymous machine identifier sent with hook events; alternative to `guard install --machine-id` |
| `AGENT_SCAN_COMMAND` | Optional Agent Scan command invoked by the session-start discovery hook, with the hook arguments appended. The hook is installed only when this is set. A value that is not an existing executable file is run as a shell command. |

## Environment variables

Expand Down Expand Up @@ -502,10 +524,10 @@ snyk agent-scan --experimental ~/.claude/skills
snyk-agent-scan guard

# Install for all supported clients
SNYK_TOKEN=... snyk-agent-scan guard install all --tenant-id "<tenant-uuid>"
SNYK_TOKEN=... snyk-agent-scan guard install all --tenant-id "<tenant-uuid>" --machine-id "<machine-id>"

# Install through an MDM-managed configuration
PUSH_KEY=... snyk-agent-scan guard install cursor --managed
PUSH_KEY=... snyk-agent-scan guard install cursor --managed --machine-id "<machine-id>"

# Uninstall
snyk-agent-scan guard uninstall all
Expand Down
15 changes: 8 additions & 7 deletions src/agent_scan/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import logging
from pathlib import Path

from agent_scan.agents.base import AgentDiscoverer
from agent_scan.agents.base import AgentDiscoverer, DiscoveryScope
from agent_scan.agents.claude_code import ClaudeCodeDiscoverer
from agent_scan.agents.claude_desktop import ClaudeDesktopDiscoverer
from agent_scan.agents.codex import CodexDiscoverer
Expand Down Expand Up @@ -37,18 +37,18 @@
}


def find_discoverers(home_directory: Path | None) -> list[AgentDiscoverer]:
"""Construct one instance per registered discoverer with the given home, and
return only those whose ``client_exists()`` confirms the agent is installed.
Each returned instance is home-bound; the caller just runs
``d.discover()`` on each.
def find_discoverers(home_directory: Path | None, target_folders: list[Path] | None = None) -> list[AgentDiscoverer]:
"""Construct one instance per registered discoverer with the given home and
explicit request targets, then return only those whose ``client_exists()``
confirms the agent is installed. Each returned instance is home-bound; the
caller just runs ``d.discover()`` on each.

A discoverer whose ``client_exists()`` raises is skipped (and logged) so a
single buggy subclass cannot abort discovery for the whole machine.
"""
found: list[AgentDiscoverer] = []
for cls in DISCOVERERS.values():
discoverer = cls(home_directory)
discoverer = cls(home_directory, target_folders)
try:
exists = discoverer.client_exists() is not None
except Exception:
Expand All @@ -67,6 +67,7 @@ def find_discoverers(home_directory: Path | None) -> list[AgentDiscoverer]:
"ClaudeDesktopDiscoverer",
"CodexDiscoverer",
"CursorDiscoverer",
"DiscoveryScope",
"KiroDiscoverer",
"OpenCodeDiscoverer",
"VSCodeDiscoverer",
Expand Down
98 changes: 75 additions & 23 deletions src/agent_scan/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import traceback
from abc import ABC, abstractmethod
from collections.abc import Callable, Iterator
from enum import Enum
from pathlib import Path

import pyjson5
Expand Down Expand Up @@ -43,6 +44,13 @@
# when the file is absent/empty/not-MCP.
McpScanResult = list[tuple[str, StdioServer | RemoteServer]] | CouldNotParseMCPConfig | None


class DiscoveryScope(str, Enum):
SERVERS = "servers"
SKILLS = "skills"
ALL = "all"


# Cap traversal into ``~/.claude/plugins/{cache,repos}``
_MAX_PLUGIN_RGLOB_DEPTH = 10

Expand Down Expand Up @@ -145,17 +153,19 @@ class AgentDiscoverer(ABC):

name: str = ""

def __init__(self, home_directory: Path | None) -> None:
def __init__(self, home_directory: Path | None, target_folders: list[Path] | None = None) -> None:
# ``None`` is the own-home sentinel; normalize to ``Path.home()`` so the
# stored home is always concrete. ``expand_path`` treats ``None`` as
# "unknown home — don't expand", which would leave a ``~``-prefixed literal
# (e.g. ``~/.claude``) on an own-home scan whose relocating env var is unset.
self.home_directory = home_directory if home_directory is not None else Path.home()
# Lazily-populated cache for _project_paths_with_ancestors. A discoverer
# serves a single scan (see find_discoverers), so the project list is
# stable for its lifetime and the discovery methods that consult it need
# not re-walk workspaceStorage / re-read ~/.claude.json each time.
self._project_paths_cache: list[Path] | None = None
self.target_folders = list(target_folders or [])
# Lazily-populated cache of the discovery roots (recorded project roots plus
# explicit target roots) with their ancestors. A discoverer serves a single
# scan (see find_discoverers), so the list is stable for its lifetime and
# discovery does not need to re-walk workspaceStorage / re-read
# ~/.claude.json each time.
self._discovery_paths_cache: list[Path] | None = None

def _scans_own_home(self) -> bool:
"""True when this discoverer targets the scanning process's own user.
Expand Down Expand Up @@ -185,7 +195,11 @@ def _scans_own_home(self) -> bool:
try:
resolved_home = self.home_directory.resolve()
return any(resolved_home == candidate.resolve() for candidate in candidates)
except OSError:
except (OSError, RuntimeError, ValueError):
# Fail closed: an unresolvable path is not proof of own-home, and this gate
# decides whether *this* process's relocating env vars apply to it. Catching
# more than OSError only widens what maps to that same safe answer -- letting
# anything else escape here would abort the whole discovery.
return False

def __init_subclass__(cls, *, abstract: bool = False, **kwargs: object) -> None:
Expand Down Expand Up @@ -214,13 +228,14 @@ def discover_mcp_servers(self) -> McpConfigsResult:
def discover_skills(self) -> SkillsDirsResult:
"""List the agent's skills, keyed by absolute skills-dir path."""

def discover(self) -> ClientToInspect | None:
def discover(self, scope: DiscoveryScope = DiscoveryScope.ALL) -> ClientToInspect | None:
"""Assemble a ClientToInspect, or None when the agent isn't installed."""
client_path = self.client_exists()
if client_path is None:
return None
mcp_configs = self.discover_mcp_servers()
skills_dirs = self.discover_skills()
scope = DiscoveryScope(scope)
mcp_configs = self.discover_mcp_servers() if scope in (DiscoveryScope.SERVERS, DiscoveryScope.ALL) else {}
skills_dirs = self.discover_skills() if scope in (DiscoveryScope.SKILLS, DiscoveryScope.ALL) else {}
return ClientToInspect(
name=self.name,
client_path=client_path,
Expand Down Expand Up @@ -442,7 +457,7 @@ def _discover_plugin_mcp_files(
result[mcp_file.as_posix()] = parsed
return result

# --- shared project-folder enumeration (used by both Claude Code and the VSCode family) ---
# --- shared project/target-folder enumeration ---

def _discover_project_folders(self) -> list[Path]:
"""Return the project roots this agent has opened.
Expand All @@ -454,21 +469,45 @@ def _discover_project_folders(self) -> list[Path]:
"""
return []

def _project_paths_with_ancestors(self) -> list[Path]:
"""Project roots plus every ancestor up to filesystem root, deduplicated.

Walking up lets project-scope MCP and skills discovery pick up config
living in any parent folder of an opened project (e.g. a monorepo root
that contains many project subdirectories).
def _discover_target_folders(self) -> list[Path]:
"""Return explicit roots targeted by the current discovery request.

The result is cached for the discoverer's lifetime.
Target folders come from request context (for example, a session-start
hook's current working directory or workspace roots). They remain
separate from the agent's persisted project history returned by
:meth:`_discover_project_folders`.
"""
if self._project_paths_cache is not None:
return self._project_paths_cache
return list(self.target_folders)

def _all_discovery_folders(self) -> list[Path]:
"""Return project roots and non-alias target roots in stable literal order."""
projects = self._discover_project_folders()
resolved_projects: set[Path] = set()
for project in projects:
try:
resolved_projects.add(project.resolve())
except (OSError, RuntimeError, ValueError):
# Unresolvable paths stay distinct under their literal spelling.
resolved_projects.add(project)
targets: list[Path] = []
for target in self._discover_target_folders():
try:
key = target.resolve()
except (OSError, RuntimeError, ValueError):
key = target
if key not in resolved_projects:
targets.append(target)
# Deduped here because opencode's anchor list is the one consumer that does not go
# through _folders_with_ancestors, whose walk already absorbs duplicates.
return list(dict.fromkeys((*projects, *targets)))

@staticmethod
def _folders_with_ancestors(folders: list[Path]) -> list[Path]:
"""Return each folder and its ancestors, preserving first-seen order."""
seen: set[Path] = set()
result: list[Path] = []
for project_path in self._discover_project_folders():
cur = project_path
for folder in folders:
cur = folder
while True:
if cur not in seen:
seen.add(cur)
Expand All @@ -477,5 +516,18 @@ def _project_paths_with_ancestors(self) -> list[Path]:
if parent == cur:
break
cur = parent
self._project_paths_cache = result
return result

def _discovery_paths_with_ancestors(self) -> list[Path]:
"""Project and target roots plus every ancestor, deduplicated across both.

Walking up lets project-scope MCP and skills discovery pick up config living
in any parent folder of an opened project (e.g. a monorepo root that contains
many project subdirectories).

The result is cached for the discoverer's lifetime.
"""
if self._discovery_paths_cache is not None:
return self._discovery_paths_cache
self._discovery_paths_cache = self._folders_with_ancestors(self._all_discovery_folders())
return self._discovery_paths_cache
6 changes: 3 additions & 3 deletions src/agent_scan/agents/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def _discover_global_mcp_servers(self) -> McpConfigsResult:
return {config_path.as_posix(): entries}

def _discover_project_mcp_servers(self) -> McpConfigsResult:
"""Per-project MCP discovery for each path in ``_project_paths_with_ancestors``.
"""Per-project MCP discovery for each path in ``_discovery_paths_with_ancestors``.

Two sources are checked at every path:

Expand All @@ -197,7 +197,7 @@ def _discover_project_mcp_servers(self) -> McpConfigsResult:
# Iterate every opened project root *and* its ancestors up to filesystem
# root, so config in a parent folder (e.g. a monorepo root) is picked up
# for the sub-projects beneath it.
for path in self._project_paths_with_ancestors():
for path in self._discovery_paths_with_ancestors():
key = path.as_posix()
# Source 1: inline ``projects.<path>.mcpServers`` recorded in ``.claude.json``.
# For an ancestor this only matches if that ancestor was itself opened
Expand Down Expand Up @@ -248,7 +248,7 @@ def _discover_project_skills(self) -> SkillsDirsResult:
``PermissionError`` tolerance as :meth:`_discover_global_skill`.
"""
result: SkillsDirsResult = {}
for path in self._project_paths_with_ancestors():
for path in self._discovery_paths_with_ancestors():
for rel in self._project_skills_relative:
skills_dir = path / rel
entries = self._scan_skills_dir(skills_dir)
Expand Down
4 changes: 2 additions & 2 deletions src/agent_scan/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ def _discover_project_mcp_servers(self) -> McpConfigsResult:
absolute path, so an ancestor equal to ``codex_home`` dedups in the merge.
"""
result: McpConfigsResult = {}
for path in self._project_paths_with_ancestors():
for path in self._discovery_paths_with_ancestors():
config_path = path / ".codex" / self._config_filename
result.update(self._mcp_servers_from_data(self._load_toml_file(config_path), config_path))
return result
Expand Down Expand Up @@ -366,7 +366,7 @@ def _discover_global_skills(self) -> SkillsDirsResult:
def _discover_project_skills(self) -> SkillsDirsResult:
"""Scan ``<project>/.agents/skills`` for every registered project and ancestor."""
result: SkillsDirsResult = {}
for path in self._project_paths_with_ancestors():
for path in self._discovery_paths_with_ancestors():
skills_dir = path / ".agents" / "skills"
entries = self._scan_skills_dir(skills_dir)
if entries is not None:
Expand Down
10 changes: 5 additions & 5 deletions src/agent_scan/agents/opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class OpenCodeDiscoverer(AgentDiscoverer):
empirically). Singular ``skill/`` is opencode's documented
backwards-compat spelling (https://opencode.ai/docs/config: "Singular
names (e.g., ``agent/``) are also supported for backwards compatibility").
* Project — for every project root in ``_project_paths_with_ancestors``
* Project — for every project root in ``_discovery_paths_with_ancestors``
(and its ancestors): ``<root>/opencode.{json,jsonc}`` *and*
``<root>/.opencode/opencode.{json,jsonc}`` (both are real opencode config
locations — see :meth:`_project_config_bases`) plus
Expand Down Expand Up @@ -516,7 +516,7 @@ def _discover_global_mcp_servers(self) -> McpConfigsResult:

def _discover_project_mcp_servers(self) -> McpConfigsResult:
result: McpConfigsResult = {}
for project in self._project_paths_with_ancestors():
for project in self._discovery_paths_with_ancestors():
for base in self._project_config_bases(project):
result.update(self._scan_config_dir(base))
return result
Expand Down Expand Up @@ -565,7 +565,7 @@ def _discover_global_skills(self) -> SkillsDirsResult:

def _discover_project_skills(self) -> SkillsDirsResult:
result: SkillsDirsResult = {}
for project in self._project_paths_with_ancestors():
for project in self._discovery_paths_with_ancestors():
for rel in self._project_skills_relative:
self._record_skills_at(result, project / rel)
return result
Expand Down Expand Up @@ -593,7 +593,7 @@ def _iter_candidate_config_files(self) -> list[Path]:
for base in self._global_config_dirs():
for filename in _CONFIG_FILENAMES:
candidates.append(base / filename)
for project in self._project_paths_with_ancestors():
for project in self._discovery_paths_with_ancestors():
for base in self._project_config_bases(project):
for filename in _CONFIG_FILENAMES:
candidates.append(base / filename)
Expand Down Expand Up @@ -630,7 +630,7 @@ def _discover_config_skills_paths(self) -> SkillsDirsResult:
# opencode's instance dirs (the db ``worktree`` leaves); computed once so
# the relative-entry resolution below doesn't re-read the SQLite db per
# candidate config file.
worktrees = self._discover_project_folders()
worktrees = self._all_discovery_folders()
for config_path in self._iter_candidate_config_files():
data = self._load_json_file(config_path)
if not isinstance(data, dict):
Expand Down
2 changes: 1 addition & 1 deletion src/agent_scan/agents/vscode/antigravity.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ def _discover_project_folders(self) -> list[Path]:
``super()`` (the ``workspaceStorage`` walk) is still consulted so that if
a future Antigravity build does populate it, those workspaces surface too;
in practice it returns nothing today. Duplicates across the two sources are
collapsed downstream by :meth:`_project_paths_with_ancestors`.
collapsed downstream by :meth:`_discovery_paths_with_ancestors`.
"""
folders = super()._discover_project_folders()
folders.extend(self._gemini_project_folders())
Expand Down
Loading
Loading