diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 52e91a73..5fe1a9c4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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 ``` @@ -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 @@ -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 @@ -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 "" +SNYK_TOKEN=... snyk-agent-scan guard install all --tenant-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 "" # Uninstall snyk-agent-scan guard uninstall all diff --git a/src/agent_scan/agents/__init__.py b/src/agent_scan/agents/__init__.py index dd5943f8..a0b994a4 100644 --- a/src/agent_scan/agents/__init__.py +++ b/src/agent_scan/agents/__init__.py @@ -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 @@ -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: @@ -67,6 +67,7 @@ def find_discoverers(home_directory: Path | None) -> list[AgentDiscoverer]: "ClaudeDesktopDiscoverer", "CodexDiscoverer", "CursorDiscoverer", + "DiscoveryScope", "KiroDiscoverer", "OpenCodeDiscoverer", "VSCodeDiscoverer", diff --git a/src/agent_scan/agents/base.py b/src/agent_scan/agents/base.py index b8d3208d..c55509ad 100644 --- a/src/agent_scan/agents/base.py +++ b/src/agent_scan/agents/base.py @@ -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 @@ -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 @@ -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. @@ -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: @@ -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, @@ -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. @@ -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) @@ -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 diff --git a/src/agent_scan/agents/claude_code.py b/src/agent_scan/agents/claude_code.py index 89c4ddb5..5a9abeb6 100644 --- a/src/agent_scan/agents/claude_code.py +++ b/src/agent_scan/agents/claude_code.py @@ -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: @@ -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..mcpServers`` recorded in ``.claude.json``. # For an ancestor this only matches if that ancestor was itself opened @@ -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) diff --git a/src/agent_scan/agents/codex.py b/src/agent_scan/agents/codex.py index 7895912d..912842b7 100644 --- a/src/agent_scan/agents/codex.py +++ b/src/agent_scan/agents/codex.py @@ -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 @@ -366,7 +366,7 @@ def _discover_global_skills(self) -> SkillsDirsResult: def _discover_project_skills(self) -> SkillsDirsResult: """Scan ``/.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: diff --git a/src/agent_scan/agents/opencode.py b/src/agent_scan/agents/opencode.py index b66be6dd..f182c89e 100644 --- a/src/agent_scan/agents/opencode.py +++ b/src/agent_scan/agents/opencode.py @@ -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): ``/opencode.{json,jsonc}`` *and* ``/.opencode/opencode.{json,jsonc}`` (both are real opencode config locations — see :meth:`_project_config_bases`) plus @@ -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 @@ -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 @@ -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) @@ -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): diff --git a/src/agent_scan/agents/vscode/antigravity.py b/src/agent_scan/agents/vscode/antigravity.py index 42de1c06..692960c5 100644 --- a/src/agent_scan/agents/vscode/antigravity.py +++ b/src/agent_scan/agents/vscode/antigravity.py @@ -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()) diff --git a/src/agent_scan/agents/vscode/base.py b/src/agent_scan/agents/vscode/base.py index 152bd50c..f5c0f7c4 100644 --- a/src/agent_scan/agents/vscode/base.py +++ b/src/agent_scan/agents/vscode/base.py @@ -592,7 +592,7 @@ def _discover_project_folders(self) -> list[Path]: ``.code-workspace`` files (:attr:`_code_workspace_enabled`) — so each folder's own workspace-scoped config (``.vscode/mcp.json``, skills, ``.devcontainer``, …) is discovered exactly as single-root folders are. - These roots flow into :meth:`_project_paths_with_ancestors`, which every + These roots flow into :meth:`_discovery_paths_with_ancestors`, which every workspace-relative scan consumes. Entries that are malformed, lack a resolvable root, or use a non-``file://`` @@ -618,7 +618,7 @@ def _discover_workspace_mcp(self) -> McpConfigsResult: result: McpConfigsResult = {} if not self._workspace_mcp_relative: return result - for path in self._project_paths_with_ancestors(): + for path in self._discovery_paths_with_ancestors(): for rel in self._workspace_mcp_relative: mcp_path = path / rel parsed = self._parse_mcp_file(mcp_path, formats=_VSCODE_FAMILY_FORMATS) @@ -658,7 +658,7 @@ def _discover_agent_config_mcp(self) -> McpConfigsResult: if not self._agent_config_dir_paths and not self._workspace_agent_config_relative: return {} dirs: list[Path] = [expand_path(Path(raw), self.home_directory) for raw in self._agent_config_dir_paths] - for root in self._project_paths_with_ancestors(): + for root in self._discovery_paths_with_ancestors(): dirs.extend(root / rel for rel in self._workspace_agent_config_relative) result: McpConfigsResult = {} for base in dirs: @@ -684,7 +684,7 @@ def _discover_workspace_skills(self) -> SkillsDirsResult: result: SkillsDirsResult = {} if not self._workspace_skills_relative: return result - for path in self._project_paths_with_ancestors(): + for path in self._discovery_paths_with_ancestors(): for rel in self._workspace_skills_relative: skills_path = path / rel entries = self._scan_skills_dir(skills_path) @@ -895,7 +895,7 @@ def _settings_files_for_skill_locations(self) -> list[tuple[Path, Path | None]]: pairs.append((userdata / self._user_settings_file, None)) for profile in self._profile_dirs(userdata): pairs.append((profile / "settings.json", None)) - for path in self._project_paths_with_ancestors(): + for path in self._discovery_paths_with_ancestors(): pairs.append((path / ".vscode" / "settings.json", path)) return pairs @@ -935,7 +935,7 @@ def _discover_devcontainer_mcp(self) -> McpConfigsResult: result: McpConfigsResult = {} if not self._devcontainer_mcp_enabled: return result - for root in self._project_paths_with_ancestors(): + for root in self._discovery_paths_with_ancestors(): for rel in (".devcontainer/devcontainer.json", ".devcontainer.json"): path = root / rel data = self._load_json_file(path) diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index 1f7254c4..a00ca014 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -21,6 +21,7 @@ from pydantic import ValidationError from rich.logging import RichHandler +from agent_scan.agents import DiscoveryScope from agent_scan.consent import collect_consent from agent_scan.models import ( FAILURE_CATEGORY_TO_CODE, @@ -942,6 +943,14 @@ def main(): dest="tenant_id", help="Snyk tenant ID (required when minting a push key; not needed if PUSH_KEY is set)", ) + guard_install_parser.add_argument( + "--machine-id", + dest="machine_id", + type=str, + default=None, + metavar="ID", + help="Required non-anonymous identifier for this machine, sent as the X-User identifier on hook events", + ) guard_install_parser.add_argument( "--test", action="store_true", @@ -961,6 +970,34 @@ def main(): help="Install hooks to the managed (admin/MDM) config path instead of the user-level path", ) + guard_discover_parser = guard_subparsers.add_parser( + "discover", + allow_abbrev=False, + help=( + "Run MCP server discovery and send a sessionStartServerDiscovery event directly to Agent Monitor " + "(used by the async session-start hooks that guard install configures)" + ), + ) + guard_discover_parser.add_argument( + "--url", + type=str, + default=None, + help="Remote hooks base URL (default: REMOTE_HOOKS_BASE_URL or https://api.snyk.io)", + ) + guard_discover_parser.add_argument( + "--client", + type=str, + choices=["claude-code", "cursor", "codex"], + required=True, + metavar="CLIENT", + help=("Required; read the selected agent's hook JSON payload from stdin and include its target folders"), + ) + guard_discover_parser.add_argument( + "--scope", + choices=[scope.value for scope in DiscoveryScope], + default=DiscoveryScope.ALL.value, + help="Discovery data to collect (default: all)", + ) guard_uninstall_parser = guard_subparsers.add_parser( "uninstall", allow_abbrev=False, @@ -1142,6 +1179,7 @@ async def run_scan(args, mode: Literal["scan", "inspect"] = "scan") -> ScanRespo paths=files, all_users=scan_all_users, scan_skills=scan_skills, + discovery_scope=DiscoveryScope.ALL if scan_skills else DiscoveryScope.SERVERS, ) # Resolve the MCP server IO flag and the consent flag. diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 818bd4d8..f3cb0960 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -11,12 +11,27 @@ import shutil import stat import sys +import threading +import time from importlib import resources as importlib_resources from pathlib import Path +from typing import TYPE_CHECKING, NamedTuple, TypeVar from urllib.parse import urlparse import rich +# ``tomllib`` is stdlib from 3.11; fall back to the ``tomli`` backport on 3.10, +# else skip the generated TOML self-check rather than failing import. +try: + import tomllib # type: ignore[import-not-found] +except ModuleNotFoundError: # pragma: no cover - Python 3.10 lacks stdlib TOML + try: + import tomli as tomllib # type: ignore[no-redef] + except ModuleNotFoundError: + tomllib = None # type: ignore[assignment] + +from agent_scan.agents import DiscoveryScope +from agent_scan.hook_events import HOOK_CLIENTS, send_hook_event from agent_scan.pushkeys import ( GuardEnabledAccessDeniedError, _is_localhost, @@ -25,8 +40,15 @@ revoke_push_key, ) from agent_scan.redact import redact_push_keys, redact_push_keys_in_data +from agent_scan.utils import toml_escape, toml_unescape + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + + from agent_scan.models import ClientToInspect IS_WINDOWS = sys.platform == "win32" +_T = TypeVar("_T") # --------------------------------------------------------------------------- # Constants @@ -36,9 +58,12 @@ DEFAULT_REMOTE_URL = "https://api.snyk.io" _DETECTION_RE = re.compile( r"PUSH_KEY=.*snyk-agent-guard" - r"|snyk-agent-guard.*-PushKey\b" + r"|snyk-agent-guard.*-PushKey\b", + re.DOTALL, ) _PERMISSION_DENIED = "__permission_denied__" +_STDIN_READ_TIMEOUT_SECONDS = 5.0 +_DISCOVERY_TIMEOUT_SECONDS = 60.0 CLAUDE_SETTINGS_PATH = Path.home() / ".claude" / "settings.json" CURSOR_HOOKS_PATH = Path.home() / ".cursor" / "hooks.json" @@ -112,6 +137,8 @@ def run_guard(args) -> int: guard_command = getattr(args, "guard_command", None) if guard_command == "install": _run_install(args) + elif guard_command == "discover": + return _run_discover(args) elif guard_command == "uninstall": _run_uninstall(args) else: @@ -186,6 +213,16 @@ def _run_install(args) -> None: if not tenant_id: tenant_id = (os.environ.get("TENANT_ID", "") or "").strip() managed: bool = getattr(args, "managed", False) + machine_id = (getattr(args, "machine_id", None) or os.environ.get("MACHINE_ID", "") or "").strip() + if not machine_id: + # Temporary compatibility fallback until ADS Installer supplies MACHINE_ID. + from agent_scan.utils import get_hostname + + machine_id = get_hostname() + rich.print( + "[yellow]Warning:[/yellow] MACHINE_ID is not set; temporarily using the hostname. " + "MACHINE_ID will become mandatory once ADS Installer is updated." + ) clients = ALL_CLIENTS if client == "all" else [client] @@ -252,7 +289,7 @@ def _run_install(args) -> None: minted = not headless # True if we minted the key in this run - installed_any = False + first_installed_client: str | None = None try: for c in clients: _install_hooks( @@ -266,11 +303,13 @@ def _run_install(args) -> None: minted, tenant_id, snyk_token, + machine_id, ) - installed_any = True + if first_installed_client is None: + first_installed_client = _hook_client_name(c) except BaseException: if minted: - if installed_any: + if first_installed_client is not None: rich.print( "[yellow]Warning:[/yellow] Installation partially completed. " "The push key is still active for already-configured clients. " @@ -280,8 +319,109 @@ def _run_install(args) -> None: _revoke_after_failure(url, tenant_id, snyk_token, push_key) raise + if first_installed_client is not None: + _send_servers_discovered_event( + push_key, + url, + first_installed_client, + machine_id, + discovery_scope=DiscoveryScope.SERVERS, + max_retries=2, + ) + + +def _run_with_timeout( + func: Callable[[], _T], + timeout: float, +) -> _T: + """Run ``func`` on a daemon thread and abandon the worker on timeout. + + Discovery can block inside a recursive glob, ``open()`` on a FIFO, ``codesign``, + or ``stat()`` on a dead mount. Those operations cannot be interrupted + cooperatively, so the daemon worker is deliberately abandoned after the deadline. + """ + result: list[_T] = [] + error: list[BaseException] = [] + + def run() -> None: + try: + result.append(func()) + except BaseException as e: + error.append(e) + + thread = threading.Thread(target=run, daemon=True) + thread.start() + thread.join(timeout) + if thread.is_alive(): + raise TimeoutError(f"timed out after {timeout:g}s") + if error: + raise error[0] + return result[0] + + +def _read_hook_payload() -> str: + """Read hook JSON with a timeout; a blocked stdin read cannot be interrupted cooperatively.""" + stream = sys.stdin + try: + if stream is None or stream.isatty(): + return "" + return _run_with_timeout(lambda: stream.read(1024 * 1024), _STDIN_READ_TIMEOUT_SECONDS) + except Exception: + return "" + + +def _run_discover(args) -> int: + push_key = os.environ.get("PUSH_KEY", "") + if not push_key: + rich.print("[bold red]Error:[/bold red] PUSH_KEY is required to run guard discovery.") + return 1 + + url = getattr(args, "url", None) or os.environ.get("REMOTE_HOOKS_BASE_URL") or DEFAULT_REMOTE_URL + hook_client = getattr(args, "client", None) + if not hook_client: + rich.print("[bold red]Error:[/bold red] --client is required to run guard discovery.") + return 1 + machine_id = (os.environ.get("MACHINE_ID", "") or "").strip() + if not machine_id: + rich.print("[bold red]Error:[/bold red] MACHINE_ID is required to run guard discovery.") + return 1 + + target_folders: list[str] = [] + session_id = "" + client = HOOK_CLIENTS[hook_client] + try: + hook_payload = json.loads(_read_hook_payload()) + target_folder = hook_payload.get(client.target_folder_field) if isinstance(hook_payload, dict) else None + if isinstance(target_folder, str) and target_folder: + target_folders.append(target_folder) + elif isinstance(target_folder, list): + target_folders.extend(folder for folder in target_folder if isinstance(folder, str) and folder) + raw_session_id = hook_payload.get(client.session_field) if isinstance(hook_payload, dict) else None + if isinstance(raw_session_id, str) and raw_session_id: + session_id = raw_session_id + except Exception: + pass + + success = _send_servers_discovered_event( + push_key, + url, + hook_client, + machine_id, + event_name="sessionStartServerDiscovery", + session_marker=session_id or "session-start-server-discovery", + target_folders=target_folders, + discovery_scope=getattr(args, "scope", DiscoveryScope.ALL), + ) + return 0 if success else 1 + -def _prepare_client_config(client: str, command: str, config_path: Path) -> tuple[dict | None, str | None, dict, int]: +def _prepare_client_config( + client: str, + command: str, + config_path: Path, + *, + discover_command: str | None = None, +) -> tuple[dict | None, str | None, dict, int]: """Dispatch to the client-specific config preparation function. Returns (prepared_config, prepared_content, hooks_diff, preserved). @@ -290,14 +430,24 @@ def _prepare_client_config(client: str, command: str, config_path: Path) -> tupl prepared_config: dict | None = None preserved = 0 if client == "claude": - prepared_config, hooks_diff, preserved = _prepare_claude_config(command, config_path) + prepared_config, hooks_diff, preserved = _prepare_claude_config( + command, config_path, discover_command=discover_command + ) elif client == "cursor": - prepared_config, hooks_diff, preserved = _prepare_cursor_config(command, config_path) + prepared_config, hooks_diff, preserved = _prepare_cursor_config( + command, config_path, discover_command=discover_command + ) elif client == "codex": if _is_codex_requirements_toml(config_path): - prepared_content, hooks_diff = _prepare_codex_managed_config(command, config_path) + prepared_content, hooks_diff = _prepare_codex_managed_config( + command, + config_path, + discover_command=discover_command, + ) else: - prepared_config, hooks_diff, preserved = _prepare_codex_config(command, config_path) + prepared_config, hooks_diff, preserved = _prepare_codex_config( + command, config_path, discover_command=discover_command + ) else: raise ValueError(f"Unknown client: {client}") return prepared_config, prepared_content, hooks_diff, preserved @@ -310,20 +460,14 @@ def _write_client_config( prepared_content: str | None, preserved: int, ) -> bool: - """Dispatch to the client-specific config writing function.""" - if client == "claude": - assert prepared_config is not None - return _write_claude_config(prepared_config, config_path, preserved) - if client == "cursor": - assert prepared_config is not None - return _write_cursor_config(prepared_config, config_path, preserved) - if client == "codex": - if _is_codex_requirements_toml(config_path): - assert prepared_content is not None - return _write_codex_managed_config(prepared_content, config_path) - assert prepared_config is not None - return _write_codex_config(prepared_config, config_path, preserved) - raise ValueError(f"Unknown client: {client}") + """Write prepared config using JSON, except for managed Codex TOML.""" + if client not in ALL_CLIENTS: + raise ValueError(f"Unknown client: {client}") + if client == "codex" and _is_codex_requirements_toml(config_path): + assert prepared_content is not None + return _write_codex_managed_config(prepared_content, config_path) + assert prepared_config is not None + return _write_config(prepared_config, config_path, preserved) def _detect_existing_install(client: str, config_path: Path) -> dict | None: @@ -335,6 +479,20 @@ def _detect_existing_install(client: str, config_path: Path) -> dict | None: return _detect_codex_install(config_path) +def _hooks_dir(config_path: Path) -> Path: + return config_path.parent / "hooks" + + +def _forwarder_script_path(config_path: Path) -> Path: + name = "snyk-agent-guard.ps1" if IS_WINDOWS else "snyk-agent-guard.sh" + return _hooks_dir(config_path) / name + + +def _discover_script_path(config_path: Path) -> Path: + name = "snyk-agent-guard-discover.ps1" if IS_WINDOWS else "snyk-agent-guard-discover.sh" + return _hooks_dir(config_path) / name + + def _install_hooks( client: str, hook_client: str, @@ -346,17 +504,62 @@ def _install_hooks( minted: bool, tenant_id: str, snyk_token: str, + machine_id: str, ) -> None: """Post-mint install steps. Extracted so _run_install can revoke on failure.""" existing_info = _detect_existing_install(client, config_path) old_push_key = existing_info.get("auth_value", "") if existing_info else "" push_key_changed = bool(old_push_key) and old_push_key != push_key - dest_path, script_existed, script_updated, current_checksum, new_checksum = _copy_hook_script(config_path) - command = _build_hook_command(push_key, url, dest_path, hook_client, tenant_id=tenant_id) - prepared_config, prepared_content, hooks_diff, preserved = _prepare_client_config(client, command, config_path) + configured_agent_scan_command = os.environ.get("AGENT_SCAN_COMMAND", "").strip() + agent_scan_command = _agent_scan_command() + install_discovery = agent_scan_command is not None + if not configured_agent_scan_command and agent_scan_command is not None: + rich.print( + "[yellow]Warning:[/yellow] AGENT_SCAN_COMMAND is not set; temporarily using the current " + "Agent Scan executable. AGENT_SCAN_COMMAND will become mandatory once ADS Installer is updated." + ) + elif agent_scan_command is None: + rich.print( + "[yellow]Warning:[/yellow] AGENT_SCAN_COMMAND is not set; " + "the session-start discovery hook will not be installed" + ) + discover_script_path = _discover_script_path(config_path) + discover_script_existed = discover_script_path.exists() - first_install = not script_existed + main_script = _copy_hook_script(_forwarder_script_path(config_path)) + discover_script = _copy_hook_script(discover_script_path) if install_discovery else None + + dest_path = main_script.path + script_updated = main_script.updated or bool(discover_script and discover_script.updated) + command = _build_hook_command( + push_key, + url, + dest_path, + hook_client, + tenant_id=tenant_id, + machine_id=machine_id, + ) + discover_command = None + if install_discovery: + assert agent_scan_command is not None + discover_command = _build_discover_hook_command( + push_key, + url, + discover_script_path, + agent_scan_command=agent_scan_command, + tenant_id=tenant_id, + machine_id=machine_id, + hook_client=hook_client, + ) + prepared_config, prepared_content, hooks_diff, preserved = _prepare_client_config( + client, + command, + config_path, + discover_command=discover_command, + ) + + first_install = not main_script.existed config_changed = bool(hooks_diff["added"] or hooks_diff["modified"] or hooks_diff["removed"]) if not _send_test_event( @@ -368,15 +571,23 @@ def _install_hooks( config_changed=config_changed, hooks_diff=hooks_diff, push_key_changed=push_key_changed, - current_checksum=current_checksum, - new_checksum=new_checksum, + current_checksum=main_script.current_checksum, + new_checksum=main_script.new_checksum, + discover_current_checksum=discover_script.current_checksum if discover_script else None, + discover_new_checksum=discover_script.new_checksum if discover_script else None, + machine_id=machine_id, ): - if not script_existed: + if not main_script.existed: dest_path.unlink(missing_ok=True) + if not discover_script_existed: + discover_script_path.unlink(missing_ok=True) rich.print("[bold red]Aborting install \u2014 test event failed.[/bold red]") raise SystemExit(1) config_written = _write_client_config(client, config_path, prepared_config, prepared_content, preserved) + if not install_discovery and discover_script_path.exists(): + discover_script_path.unlink() + rich.print(f"[green]✓[/green] Removed stale hook script [dim]{discover_script_path}[/dim]") if script_updated or config_written or minted: rich.print(f"[green]\u2713[/green] {scope.title()} hooks installed for [bold]{label}[/bold]") @@ -389,7 +600,12 @@ def _install_hooks( rich.print() -def _prepare_claude_config(command: str, path: Path) -> tuple[dict, dict, int]: +def _prepare_claude_config( + command: str, + path: Path, + *, + discover_command: str | None = None, +) -> tuple[dict, dict, int]: """Build new Claude settings with hooks and compute diff, without writing. Returns (new_settings, hooks_diff, preserved_count). @@ -412,6 +628,17 @@ def _prepare_claude_config(command: str, path: Path) -> tuple[dict, dict, int]: existing.append(group) hooks[event] = existing + if discover_command: + # Claude supports async hooks; keep session start independent of the discovery scan. + discover_entry: dict = { + "type": "command", + "command": discover_command, + "async": True, + } + if IS_WINDOWS: + discover_entry["shell"] = "powershell" + hooks["SessionStart"].append({"hooks": [discover_entry]}) + for event, groups in filtered.items(): if event not in hooks: hooks[event] = groups @@ -421,16 +648,20 @@ def _prepare_claude_config(command: str, path: Path) -> tuple[dict, dict, int]: return settings, diff, preserved -def _write_claude_config(settings: dict, path: Path, preserved: int) -> bool: - """Write Claude settings to disk. Returns True if file changed.""" - if not _write_json_if_changed(path, settings): +def _write_config(config: dict, path: Path, preserved: int) -> bool: + """Write a client config to disk. Returns True if the file changed.""" + if not _write_json_if_changed(path, config): return False - note = _preserved_note(preserved) - rich.print(f"[green]\u2713[/green] Written [dim]{path}[/dim]{note}") + rich.print(f"[green]\u2713[/green] Written [dim]{path}[/dim]{_preserved_note(preserved)}") return True -def _prepare_cursor_config(command: str, path: Path) -> tuple[dict, dict, int]: +def _prepare_cursor_config( + command: str, + path: Path, + *, + discover_command: str | None = None, +) -> tuple[dict, dict, int]: """Build new Cursor config with hooks and compute diff, without writing. Returns (new_data, hooks_diff, preserved_count). @@ -449,6 +680,10 @@ def _prepare_cursor_config(command: str, path: Path) -> tuple[dict, dict, int]: existing.append({"command": command}) hooks[event] = existing + if discover_command: + # Cursor sessionStart hooks are fire-and-forget without an explicit async marker. + hooks["sessionStart"].append({"command": discover_command}) + for event, entries in filtered.items(): if event not in hooks: hooks[event] = entries @@ -458,16 +693,12 @@ def _prepare_cursor_config(command: str, path: Path) -> tuple[dict, dict, int]: return data, diff, preserved -def _write_cursor_config(data: dict, path: Path, preserved: int) -> bool: - """Write Cursor config to disk. Returns True if file changed.""" - if not _write_json_if_changed(path, data): - return False - note = _preserved_note(preserved) - rich.print(f"[green]\u2713[/green] Written [dim]{path}[/dim]{note}") - return True - - -def _prepare_codex_config(command: str, path: Path) -> tuple[dict, dict, int]: +def _prepare_codex_config( + command: str, + path: Path, + *, + discover_command: str | None = None, +) -> tuple[dict, dict, int]: """Build new Codex config with hooks and compute diff, without writing. Returns (new_data, hooks_diff, preserved_count). @@ -486,6 +717,10 @@ def _prepare_codex_config(command: str, path: Path) -> tuple[dict, dict, int]: existing.append({"hooks": [entry]}) hooks[event] = existing + if discover_command: + # Codex supports async hooks; keep session start independent of the discovery scan. + hooks["SessionStart"].append({"hooks": [{"type": "command", "command": discover_command, "async": True}]}) + for event, groups in filtered.items(): if event not in hooks: hooks[event] = groups @@ -495,15 +730,6 @@ def _prepare_codex_config(command: str, path: Path) -> tuple[dict, dict, int]: return data, diff, preserved -def _write_codex_config(data: dict, path: Path, preserved: int) -> bool: - """Write Codex config to disk. Returns True if file changed.""" - if not _write_json_if_changed(path, data): - return False - note = _preserved_note(preserved) - rich.print(f"[green]✓[/green] Written [dim]{path}[/dim]{note}") - return True - - def _is_codex_requirements_toml(path: Path) -> bool: return path.suffix.lower() == ".toml" @@ -525,7 +751,12 @@ def _codex_managed_dirs(config_path: Path) -> tuple[str, str]: return managed_dir, windows_managed_dir -def _render_codex_requirements_toml(command: str, config_path: Path) -> str: +def _render_codex_requirements_toml( + command: str, + config_path: Path, + *, + discover_command: str | None = None, +) -> str: """Generate the requirements.toml content for managed Codex hooks.""" managed_dir, windows_managed_dir = _codex_managed_dirs(config_path) lines = [ @@ -533,46 +764,89 @@ def _render_codex_requirements_toml(command: str, config_path: Path) -> str: "hooks = true", "", "[hooks]", - f'managed_dir = "{managed_dir}"', - f"windows_managed_dir = '{windows_managed_dir}'", + f"managed_dir = {toml_escape(managed_dir)}", + f"windows_managed_dir = {toml_escape(windows_managed_dir)}", "", ] - escaped = command.replace("\\", "\\\\").replace('"', '\\"') for event in CODEX_HOOK_EVENTS: lines.append(f"[[hooks.{event}]]") lines.append(f"[[hooks.{event}.hooks]]") lines.append('type = "command"') - lines.append(f'command = "{escaped}"') + lines.append(f"command = {toml_escape(command)}") lines.append("") - return "\n".join(lines).rstrip("\n") + "\n" + if discover_command: + lines.append("[[hooks.SessionStart]]") + lines.append("[[hooks.SessionStart.hooks]]") + lines.append('type = "command"') + lines.append(f"command = {toml_escape(discover_command)}") + lines.append("async = true") + lines.append("") + content = "\n".join(lines).rstrip("\n") + "\n" + if tomllib is not None: # pragma: no branch - available on supported installs + try: + tomllib.loads(content) + except ValueError as exc: + raise ValueError("Generated requirements.toml is invalid") from exc + return content -def _prepare_codex_managed_config(command: str, path: Path) -> tuple[str, dict]: +def _prepare_codex_managed_config( + command: str, + path: Path, + *, + discover_command: str | None = None, +) -> tuple[str, dict]: """Build new Codex managed TOML content and compute diff, without writing. Returns (new_content, hooks_diff). + + Unlike the JSON clients, this rewrites requirements.toml wholesale: the + content is rendered from scratch, so any hooks, features or tables we do + not own are dropped on write. They are also absent from the returned diff, + because _parse_codex_requirements_toml only reports commands that match + _is_agent_scan_command. _write_codex_managed_config backs the old file up + first, so the discarded entries stay recoverable on disk. """ - new_content = _render_codex_requirements_toml(command, path) + new_content = _render_codex_requirements_toml( + command, + path, + discover_command=discover_command, + ) old_events: list[str] = [] - old_cmd: str | None = None + old_guard_command: str | None = None + old_discover_command: str | None = None if path.exists(): old_text = path.read_text() with contextlib.suppress(UnicodeDecodeError, ValueError): - old_events, old_cmd = _parse_codex_requirements_toml(old_text) + old_events, old_guard_command, old_discover_command = _parse_codex_requirements_toml(old_text) old_event_set = set(old_events) new_event_set = set(CODEX_HOOK_EVENTS) - removed = {e: [{"type": "command", "command": command}] for e in sorted(new_event_set - old_event_set)} - added = {e: [{"type": "command", "command": old_cmd or ""}] for e in sorted(old_event_set - new_event_set)} + def _entries(event: str, guard: str | None, discover: str | None) -> list[dict]: + entries: list[dict] = [] + if guard is not None: + entries.append({"type": "command", "command": guard}) + if event == "SessionStart" and discover is not None: + entries.append({"type": "command", "command": discover, "async": True}) + return entries + + added = {} modified = {} - if old_cmd is not None and old_cmd != command: - expected = [{"type": "command", "command": command}] - actual = [{"type": "command", "command": old_cmd}] - modified = { - e: {"expected_value": expected, "actual_value": actual} for e in sorted(old_event_set & new_event_set) - } + removed = {} + for event in sorted(old_event_set | new_event_set): + expected = _entries(event, command, discover_command) if event in new_event_set else [] + actual = _entries(event, old_guard_command, old_discover_command) if event in old_event_set else [] + if not actual and expected: + removed[event] = expected + elif actual and not expected: + added[event] = actual + elif actual != expected: + modified[event] = { + "expected_value": expected, + "actual_value": actual, + } diff = {"added": added, "modified": modified, "removed": removed} return new_content, diff @@ -590,14 +864,18 @@ def _write_codex_managed_config(content: str, path: Path) -> bool: return True -def _parse_codex_requirements_toml(text: str) -> tuple[list[str], str | None]: - """Extract Snyk Agent Guard events and the first matching command from requirements.toml. +def _is_discover_hook_command(command: str) -> bool: + return bool(re.search(r"\bsnyk-agent-guard-discover(?:\.(?:sh|ps1))?\b", command, re.IGNORECASE)) + - Returns (events, command). Only scans hook command lines containing the - agent-guard detection marker. +def _parse_codex_requirements_toml(text: str) -> tuple[list[str], str | None, str | None]: + """Extract Snyk Agent Guard events, guard command, and discovery command. + + Only scans hook command lines containing the agent-guard detection marker. """ events: list[str] = [] - found_cmd: str | None = None + guard_command: str | None = None + discover_command: str | None = None current_event: str | None = None header_re = re.compile(r"^\[\[hooks\.([A-Za-z]+)(?:\.hooks)?\]\]\s*$") command_re = re.compile(r'^command\s*=\s*"((?:[^"\\]|\\.)*)"\s*$') @@ -609,20 +887,25 @@ def _parse_codex_requirements_toml(text: str) -> tuple[list[str], str | None]: continue m = command_re.match(line) if m and current_event: - cmd = m.group(1).replace("\\\\", "\0").replace('\\"', '"').replace("\0", "\\") - if _is_agent_scan_command(cmd) and current_event not in events: + cmd = toml_unescape(m.group(1)) + if not _is_agent_scan_command(cmd): + continue + if current_event not in events: events.append(current_event) - if found_cmd is None: - found_cmd = cmd - return events, found_cmd + if _is_discover_hook_command(cmd): + if discover_command is None: + discover_command = cmd + elif guard_command is None: + guard_command = cmd + return events, guard_command, discover_command def _detect_codex_managed_install(path: Path) -> dict | None: text = path.read_text() - events, found_cmd = _parse_codex_requirements_toml(text) - if not events or found_cmd is None: + events, guard_command, _ = _parse_codex_requirements_toml(text) + if not events or guard_command is None: return None - return _parse_command_info(found_cmd, events) + return _parse_command_info(guard_command, events) def _uninstall_codex_managed(path: Path) -> None: @@ -630,7 +913,7 @@ def _uninstall_codex_managed(path: Path) -> None: rich.print("[dim]No requirements.toml found. Nothing to uninstall.[/dim]") return text = path.read_text() - events, _ = _parse_codex_requirements_toml(text) + events, _, _ = _parse_codex_requirements_toml(text) if not events: rich.print("[dim]No Agent Guard hooks found.[/dim]") return @@ -670,15 +953,14 @@ def _uninstall_single_client(client: str, args, managed: bool) -> None: info = _detect_existing_install(client, config_path) # Remove hooks from config - if client == "claude": - _uninstall_claude(config_path) - elif client == "cursor": - _uninstall_cursor(config_path) - elif client == "codex": - if _is_codex_requirements_toml(config_path): - _uninstall_codex_managed(config_path) - else: - _uninstall_codex(config_path) + if client == "codex" and _is_codex_requirements_toml(config_path): + _uninstall_codex_managed(config_path) + elif client in ALL_CLIENTS: + _uninstall_hooks( + config_path, + filter_hooks=_filter_cursor_hooks if client == "cursor" else _filter_claude_hooks, + prune_empty_hooks=client != "cursor", + ) # Remove hook script _remove_hook_script(client, config_path) @@ -709,43 +991,21 @@ def _try_revoke_push_key(info: dict, label: str) -> None: rich.print(f"[yellow]Warning:[/yellow] Could not revoke push key: {e}") -def _uninstall_claude(path: Path) -> None: - if not path.exists(): - rich.print("[dim]No settings.json found. Nothing to uninstall.[/dim]") - return - - settings = _read_json_or_empty(path) - hooks = settings.get("hooks", {}) - - total_before = sum(len(groups) for groups in hooks.values()) - filtered = _filter_claude_hooks(hooks) - total_after = sum(len(groups) for groups in filtered.values()) - - removed = total_before - total_after - if removed == 0: - rich.print("[dim]No Agent Guard hooks found.[/dim]") - return - - _backup_file(path) - if filtered: - settings["hooks"] = filtered - else: - settings.pop("hooks", None) - _write_json(path, settings) - rich.print(f"[green]\u2713[/green] Removed {removed} Agent Guard hook(s){_preserved_note(total_after)}") - - -def _uninstall_codex(path: Path) -> None: - """Codex uses the Claude-shaped hooks.json, so reuse the Claude filter.""" +def _uninstall_hooks( + path: Path, + *, + filter_hooks: Callable[[dict], dict], + prune_empty_hooks: bool, +) -> None: if not path.exists(): - rich.print("[dim]No hooks.json found. Nothing to uninstall.[/dim]") + rich.print(f"[dim]No {path.name} found. Nothing to uninstall.[/dim]") return data = _read_json_or_empty(path) hooks = data.get("hooks", {}) total_before = sum(len(groups) for groups in hooks.values()) - filtered = _filter_claude_hooks(hooks) + filtered = filter_hooks(hooks) total_after = sum(len(groups) for groups in filtered.values()) removed = total_before - total_after @@ -754,34 +1014,11 @@ def _uninstall_codex(path: Path) -> None: return _backup_file(path) - if filtered: + if filtered or not prune_empty_hooks: data["hooks"] = filtered else: data.pop("hooks", None) _write_json(path, data) - rich.print(f"[green]✓[/green] Removed {removed} Agent Guard hook(s){_preserved_note(total_after)}") - - -def _uninstall_cursor(path: Path) -> None: - if not path.exists(): - rich.print("[dim]No hooks.json found. Nothing to uninstall.[/dim]") - return - - data = _read_json_or_empty(path) - hooks = data.get("hooks", {}) - - total_before = sum(len(entries) for entries in hooks.values()) - filtered = _filter_cursor_hooks(hooks) - total_after = sum(len(entries) for entries in filtered.values()) - - removed = total_before - total_after - if removed == 0: - rich.print("[dim]No Agent Guard hooks found.[/dim]") - return - - _backup_file(path) - data["hooks"] = filtered - _write_json(path, data) rich.print(f"[green]\u2713[/green] Removed {removed} Agent Guard hook(s){_preserved_note(total_after)}") @@ -791,45 +1028,38 @@ def _uninstall_cursor(path: Path) -> None: def _run_status() -> None: + clients = ( + ("Claude Code", CLAUDE_SETTINGS_PATH, CLAUDE_MANAGED_SETTINGS_PATH, _detect_claude_install), + ("Cursor", CURSOR_HOOKS_PATH, CURSOR_MANAGED_HOOKS_PATH, _detect_cursor_install), + ("Codex", CODEX_HOOKS_PATH, CODEX_MANAGED_HOOKS_PATH, _detect_codex_install), + ) + rich.print("[bold]User-level hooks:[/bold]") - _print_client_status("Claude Code", CLAUDE_SETTINGS_PATH, _detect_claude_install()) - rich.print() - _print_client_status("Cursor", CURSOR_HOOKS_PATH, _detect_cursor_install()) - rich.print() - _print_client_status("Codex", CODEX_HOOKS_PATH, _detect_codex_install()) - rich.print() + for label, user_path, _, detect in clients: + _print_client_status(label, user_path, detect()) + rich.print() rich.print("[bold]Managed hooks:[/bold]") - claude_managed_info: dict | str | None - try: - claude_managed_info = _detect_claude_install(CLAUDE_MANAGED_SETTINGS_PATH) - except PermissionError: - claude_managed_info = _PERMISSION_DENIED - _print_client_status("Claude Code", CLAUDE_MANAGED_SETTINGS_PATH, claude_managed_info) - rich.print() - cursor_managed_info: dict | str | None - try: - cursor_managed_info = _detect_cursor_install(CURSOR_MANAGED_HOOKS_PATH) - except PermissionError: - cursor_managed_info = _PERMISSION_DENIED - _print_client_status("Cursor", CURSOR_MANAGED_HOOKS_PATH, cursor_managed_info) - rich.print() - codex_managed_info: dict | str | None - try: - codex_managed_info = _detect_codex_install(CODEX_MANAGED_HOOKS_PATH) - except PermissionError: - codex_managed_info = _PERMISSION_DENIED - _print_client_status("Codex", CODEX_MANAGED_HOOKS_PATH, codex_managed_info) - rich.print() + for label, _, managed_path, detect in clients: + info: dict | str | None + try: + info = detect(managed_path) + except PermissionError: + info = _PERMISSION_DENIED + _print_client_status(label, managed_path, info) + rich.print() rich.print("[dim]# interactive flow (user-level)[/dim]") - rich.print("[dim]snyk-agent-scan guard install [/dim]") + rich.print("[dim]snyk-agent-scan guard install --machine-id [/dim]") rich.print() rich.print("[dim]# managed flow[/dim]") - rich.print("[dim]snyk-agent-scan guard install --managed[/dim]") + rich.print("[dim]snyk-agent-scan guard install --managed --machine-id [/dim]") rich.print() rich.print("[dim]# headless flow (MDM)[/dim]") - rich.print("[dim]PUSH_KEY= snyk-agent-scan guard install [--managed][/dim]") + rich.print( + "[dim]PUSH_KEY= snyk-agent-scan guard install " + "[--managed] --machine-id [/dim]" + ) rich.print() rich.print( "[dim]If hooks are already installed and up to date, install commands are no-ops. To uninstall use 'snyk-agent-scan guard uninstall '[/dim]" @@ -856,81 +1086,161 @@ def _print_client_status(label: str, path: Path, info: dict | str | None) -> Non def _detect_claude_install(path: Path = CLAUDE_SETTINGS_PATH) -> dict | None: - if not path.exists(): - return None - settings = _read_json_or_empty(path) - hooks = settings.get("hooks", {}) + return _detect_install(path, CLAUDE_HOOK_EVENTS, _grouped_hook_commands) - events = [] - found_cmd = None - for event in CLAUDE_HOOK_EVENTS: - for group in hooks.get(event, []): - for h in group.get("hooks", []): - if _is_agent_scan_command(h.get("command", "")): - events.append(event) - if found_cmd is None: - found_cmd = h["command"] - break - else: - continue - break - if not events or found_cmd is None: - return None - return _parse_command_info(found_cmd, events) +def _detect_codex_install(path: Path = CODEX_HOOKS_PATH) -> dict | None: + if _is_codex_requirements_toml(path): + if not path.exists(): + return None + return _detect_codex_managed_install(path) + return _detect_install(path, CODEX_HOOK_EVENTS, _grouped_hook_commands) -def _detect_codex_install(path: Path = CODEX_HOOKS_PATH) -> dict | None: +def _detect_cursor_install(path: Path = CURSOR_HOOKS_PATH) -> dict | None: + return _detect_install(path, CURSOR_HOOK_EVENTS, _flat_hook_commands) + + +def _grouped_hook_commands(group: dict) -> Iterable[str]: + return (hook.get("command", "") for hook in group.get("hooks", [])) + + +def _flat_hook_commands(entry: dict) -> Iterable[str]: + return (entry.get("command", ""),) + + +def _detect_install(path: Path, events: list[str], commands: Callable[[dict], Iterable[str]]) -> dict | None: if not path.exists(): return None - if _is_codex_requirements_toml(path): - return _detect_codex_managed_install(path) data = _read_json_or_empty(path) hooks = data.get("hooks", {}) - events = [] + installed_events = [] found_cmd = None - for event in CODEX_HOOK_EVENTS: - for group in hooks.get(event, []): - for h in group.get("hooks", []): - if _is_agent_scan_command(h.get("command", "")): - events.append(event) + for event in events: + for entry in hooks.get(event, []): + for command in commands(entry): + if _is_agent_scan_command(command): + installed_events.append(event) if found_cmd is None: - found_cmd = h["command"] + found_cmd = command break else: continue break - if not events or found_cmd is None: + if not installed_events or found_cmd is None: return None - return _parse_command_info(found_cmd, events) + return _parse_command_info(found_cmd, installed_events) -def _detect_cursor_install(path: Path = CURSOR_HOOKS_PATH) -> dict | None: - if not path.exists(): - return None - data = _read_json_or_empty(path) - hooks = data.get("hooks", {}) +# --------------------------------------------------------------------------- +# Hook events +# --------------------------------------------------------------------------- - events = [] - found_cmd = None - for event in CURSOR_HOOK_EVENTS: - for entry in hooks.get(event, []): - if _is_agent_scan_command(entry.get("command", "")): - events.append(event) - if found_cmd is None: - found_cmd = entry["command"] - break - if not events or found_cmd is None: - return None - return _parse_command_info(found_cmd, events) +def _servers_discovered_entries(clients_to_inspect: list[ClientToInspect]) -> list[dict]: + """Serialize discovered clients exactly as ``scan`` serializes them for analysis.""" + from agent_scan.inspect import ( + _config_error_to_scan_error, + _inspection_component_name, + _join_scan_errors, + ) + from agent_scan.models import InspectedPath, InspectedServer, ScanError + from agent_scan.models.errors import CouldNotParseMCPConfig, FileNotFoundConfig, UnknownConfigFormat + from agent_scan.verify_api import build_scan_request + + inspected_paths: list[InspectedPath] = [] + for client in clients_to_inspect: + servers: list[InspectedServer] = [] + config_errors: list[ScanError] = [] + for config_path, discovered in client.mcp_configs.items(): + if isinstance(discovered, FileNotFoundConfig | UnknownConfigFormat | CouldNotParseMCPConfig): + config_errors.append(_config_error_to_scan_error(discovered)) + continue + servers.extend( + InspectedServer( + name=_inspection_component_name(name, "server", config_path), + config_path=config_path, + server=server, + ) + for name, server in discovered + ) + inspected_paths.append( + InspectedPath( + client=client.name, + path=client.client_path, + servers=servers, + error=_join_scan_errors(config_errors), + ) + ) + return [request.model_dump(mode="json") for request in build_scan_request(inspected_paths).scan_path_requests] -# --------------------------------------------------------------------------- -# Test event -# --------------------------------------------------------------------------- +def _discover_servers_payload( + target_folders: list[str] | None = None, + *, + discovery_scope: DiscoveryScope = DiscoveryScope.ALL, +) -> list[dict]: + import asyncio + + from agent_scan import pipelines + + # Discovery only parses config files; timeout is unused because no server is started. + inspect_args = pipelines.InspectArgs( + timeout=0, + tokens=[], + paths=[], + discovery_scope=discovery_scope, + target_folders=target_folders or [], + ) + clients_to_inspect, _, _ = _run_with_timeout( + lambda: asyncio.run(pipelines.discover_clients_to_inspect(inspect_args)), + _DISCOVERY_TIMEOUT_SECONDS, + ) + return _servers_discovered_entries(clients_to_inspect) + + +def _invoke_hook_script( + script_path: Path, + hook_client: str, + push_key: str, + url: str, + payload: str, + *, + machine_id: str, +) -> tuple[bool, str]: + import subprocess + + if not machine_id.strip(): + raise ValueError("machine ID is required") + + cmd, env = _render_argv( + _HookInvocation( + script_path=script_path, + hook_client=hook_client, + push_key=push_key, + url=url, + machine_id=machine_id, + ) + ) + + try: + result = subprocess.run( + cmd, + input=payload, + capture_output=True, + text=True, + timeout=15, + env=env, + ) + if result.returncode == 0: + return True, "" + return False, result.stderr.strip() or f"exit code {result.returncode}" + except subprocess.TimeoutExpired: + return False, "timeout" + except Exception as e: + return False, str(e) def _send_test_event( @@ -945,15 +1255,15 @@ def _send_test_event( push_key_changed: bool = False, current_checksum: str | None = None, new_checksum: str | None = None, + discover_current_checksum: str | None = None, + discover_new_checksum: str | None = None, + machine_id: str, ) -> bool: """Send a test hooksConfigured event by invoking the hook script. Returns True on success.""" - import subprocess - + if not machine_id.strip(): + raise ValueError("machine ID is required") payload_dict: dict = {"hook_event_name": "hooksConfigured"} - if hook_client == "claude-code" or hook_client == "codex": - payload_dict["session_id"] = "hooks-setup" - else: - payload_dict["conversation_id"] = "hooks-setup" + payload_dict[HOOK_CLIENTS[hook_client].session_field] = "hooks-setup" payload_dict["first_install"] = first_install payload_dict["push_key_changed"] = push_key_changed if not first_install: @@ -967,53 +1277,73 @@ def _send_test_event( hooks_script["current_checksum"] = current_checksum if new_checksum is not None: hooks_script["new_checksum"] = new_checksum + if discover_current_checksum is not None: + hooks_script["discover_current_checksum"] = discover_current_checksum + if discover_new_checksum is not None: + hooks_script["discover_new_checksum"] = discover_new_checksum if hooks_script: payload_dict["hooks_script"] = hooks_script redact_push_keys_in_data(payload_dict) payload = json.dumps(payload_dict) - if IS_WINDOWS: - cmd = [ - "powershell", - "-File", - str(script_path), - "-Client", - hook_client, - "-PushKey", - push_key, - "-RemoteUrl", - url, - ] - env = None # inherit current env - else: - cmd = ["bash", str(script_path), "--client", hook_client] - env = { - **os.environ, - "PUSH_KEY": push_key, - "REMOTE_HOOKS_BASE_URL": url, - } + ok, detail = _invoke_hook_script( + script_path, + hook_client, + push_key, + url, + payload, + machine_id=machine_id, + ) + if ok: + rich.print("[green]\u2713[/green] Test event sent [green]\u2192 OK[/green]") + return True + rich.print(f"[red]\u2717[/red] Test event failed: {detail}") + return False + +def _send_servers_discovered_event( + push_key: str, + url: str, + hook_client: str, + machine_id: str, + *, + event_name: str = "hooksConfiguredServerDiscovery", + session_marker: str = "hooks-setup", + target_folders: list[str] | None = None, + discovery_scope: DiscoveryScope = DiscoveryScope.ALL, + max_retries: int = 1, +) -> bool: + """Discover MCP servers and send an install- or session-scoped discovery event. + + The default event follows ``hooksConfigured`` during installation. Session-start + callers override it with ``sessionStartServerDiscovery``. + """ + rich.print("[dim]Discovering MCP servers...[/dim]") + started = time.monotonic() try: - result = subprocess.run( - cmd, - input=payload, - capture_output=True, - text=True, - timeout=15, - env=env, - ) - if result.returncode == 0: - rich.print("[green]\u2713[/green] Test event sent [green]\u2192 OK[/green]") - return True - stderr = result.stderr.strip() - rich.print(f"[red]\u2717[/red] Test event failed: {stderr or f'exit code {result.returncode}'}") - return False - except subprocess.TimeoutExpired: - rich.print("[red]\u2717[/red] Test event failed: timeout") - return False + servers = _discover_servers_payload(target_folders, discovery_scope=discovery_scope) except Exception as e: - rich.print(f"[red]\u2717[/red] Test event failed: {e}") + rich.print(f"[yellow]Warning:[/yellow] Could not discover MCP servers: {e}") return False + duration_ms = round((time.monotonic() - started) * 1000) + + payload_dict: dict = { + "hook_event_name": event_name, + "servers": servers, + "discovery_duration_ms": duration_ms, + } + payload_dict[HOOK_CLIENTS[hook_client].session_field] = session_marker + redact_push_keys_in_data(payload_dict) + payload = json.dumps(payload_dict) + + ok, detail = send_hook_event(url, hook_client, push_key, payload, machine_id, max_retries=max_retries) + if ok: + server_count = sum(len(entry.get("servers", [])) for entry in servers) + noun = "server" if server_count == 1 else "servers" + rich.print(f"[green]\u2713[/green] Discovered {server_count} MCP {noun} [green]\u2192 OK[/green]") + return True + rich.print(f"[yellow]Warning:[/yellow] Could not send discovered MCP servers: {detail}") + return False # --------------------------------------------------------------------------- @@ -1225,30 +1555,191 @@ def _revoke_after_failure(url: str, tenant_id: str, snyk_token: str, push_key: s rich.print(f"[yellow]Warning:[/yellow] Could not revoke push key: {e}") -def _build_hook_command(push_key: str, url: str, script_path: Path, hook_client: str, *, tenant_id: str = "") -> str: - if IS_WINDOWS: - return _build_hook_command_powershell(push_key, url, script_path, hook_client, tenant_id=tenant_id) +class _HookInvocation(NamedTuple): + """What a hook script needs, independent of how the values are handed over.""" + + script_path: Path + hook_client: str + push_key: str + url: str + machine_id: str = "" + tenant_id: str = "" + agent_scan_command: str = "" + scope: str = "" + quote_client: bool = False + + +def _render_posix_command(invocation: _HookInvocation) -> str: parts = [ - f"PUSH_KEY={_shell_quote(push_key)}", - f"REMOTE_HOOKS_BASE_URL={_shell_quote(url)}", + f"PUSH_KEY={_shell_quote(invocation.push_key)}", + f"REMOTE_HOOKS_BASE_URL={_shell_quote(invocation.url)}", ] - if tenant_id: - parts.append(f"TENANT_ID={_shell_quote(tenant_id)}") - parts.append(f"bash {_shell_quote(script_path.as_posix())}") - parts.append(f"--client {hook_client}") + if invocation.tenant_id: + parts.append(f"TENANT_ID={_shell_quote(invocation.tenant_id)}") + if invocation.machine_id: + parts.append(f"MACHINE_ID={_shell_quote(invocation.machine_id)}") + if invocation.agent_scan_command: + parts.append(f"AGENT_SCAN_COMMAND={_shell_quote(invocation.agent_scan_command)}") + parts.append(f"bash {_shell_quote(invocation.script_path.as_posix())}") + client = _shell_quote(invocation.hook_client) if invocation.quote_client else invocation.hook_client + parts.append(f"--client {client}") + if invocation.scope: + parts.append(f"--scope {invocation.scope}") return " ".join(parts) +def _render_powershell_command(invocation: _HookInvocation) -> str: + parts = [ + "powershell", + "-File", + _ps_quote(str(invocation.script_path)), + "-Client", + invocation.hook_client, + "-PushKey", + _ps_quote(invocation.push_key), + "-RemoteUrl", + _ps_quote(invocation.url), + ] + if invocation.machine_id: + parts.extend(["-MachineId", _ps_quote(invocation.machine_id)]) + if invocation.agent_scan_command: + parts.extend(["-AgentScanCommand", _ps_quote(invocation.agent_scan_command)]) + if invocation.scope: + parts.extend(["-Scope", invocation.scope]) + return " ".join(parts) + + +def _render_argv(invocation: _HookInvocation) -> tuple[list[str], dict[str, str] | None]: + if IS_WINDOWS: + argv = [ + "powershell", + "-File", + str(invocation.script_path), + "-Client", + invocation.hook_client, + "-PushKey", + invocation.push_key, + "-RemoteUrl", + invocation.url, + ] + if invocation.machine_id: + argv.extend(["-MachineId", invocation.machine_id]) + if invocation.agent_scan_command: + argv.extend(["-AgentScanCommand", invocation.agent_scan_command]) + if invocation.scope: + argv.extend(["-Scope", invocation.scope]) + return argv, None + + env = { + **os.environ, + "PUSH_KEY": invocation.push_key, + "REMOTE_HOOKS_BASE_URL": invocation.url, + } + if invocation.tenant_id: + env["TENANT_ID"] = invocation.tenant_id + if invocation.machine_id: + env["MACHINE_ID"] = invocation.machine_id + if invocation.agent_scan_command: + env["AGENT_SCAN_COMMAND"] = invocation.agent_scan_command + argv = ["bash", str(invocation.script_path), "--client", invocation.hook_client] + if invocation.scope: + argv.extend(["--scope", invocation.scope]) + return argv, env + + +def _build_hook_command( + push_key: str, + url: str, + script_path: Path, + hook_client: str, + *, + tenant_id: str = "", + machine_id: str = "", +) -> str: + invocation = _HookInvocation( + script_path=script_path, + hook_client=hook_client, + push_key=push_key, + url=url, + machine_id=machine_id, + tenant_id=tenant_id, + ) + if IS_WINDOWS: + return _render_powershell_command(invocation) + return _render_posix_command(invocation) + + +def _agent_scan_command() -> str | None: + """Return the configured command or infer this Agent Scan executable.""" + configured = os.environ.get("AGENT_SCAN_COMMAND", "").strip() + if configured: + return configured + + # Temporary compatibility fallback until ADS Installer supplies AGENT_SCAN_COMMAND. + if getattr(sys, "frozen", False): + return str(Path(sys.executable).absolute()) + + executable_name = "snyk-agent-scan.exe" if IS_WINDOWS else "snyk-agent-scan" + console_script = Path(sys.executable).parent / executable_name + if console_script.is_file() and os.access(console_script, os.X_OK): + return str(console_script.absolute()) + return None + + +def _build_discover_hook_command( + push_key: str, + url: str, + script_path: Path, + hook_client: str, + *, + agent_scan_command: str, + tenant_id: str = "", + machine_id: str = "", +) -> str: + invocation = _HookInvocation( + script_path=script_path, + hook_client=hook_client, + push_key=push_key, + url=url, + machine_id=machine_id, + agent_scan_command=agent_scan_command, + scope="servers", + quote_client=True, + ) + if IS_WINDOWS: + return _render_powershell_command(invocation) + return _render_posix_command(invocation) + + def _build_hook_command_powershell( - push_key: str, url: str, script_path: Path, hook_client: str, *, tenant_id: str = "" + push_key: str, + url: str, + script_path: Path, + hook_client: str, + *, + tenant_id: str = "", + machine_id: str = "", ) -> str: - return f"powershell -File '{script_path}' -Client {hook_client} -PushKey '{push_key}' -RemoteUrl '{url}'" + return _render_powershell_command( + _HookInvocation( + script_path=script_path, + hook_client=hook_client, + push_key=push_key, + url=url, + machine_id=machine_id, + ) + ) def _shell_quote(s: str) -> str: return "'" + s.replace("'", "'\"'\"'") + "'" +def _ps_quote(s: str) -> str: + """Quote a value for a PowerShell single-quoted literal.""" + return "'" + s.replace("'", "''") + "'" + + def _mask_key(k: str) -> str: if len(k) <= 8: return k @@ -1264,46 +1755,46 @@ def _compact_events(events: list[str]) -> str: return f"({', '.join(events[:show])} + {len(events) - show} more)" -def _copy_hook_script(config_path: Path) -> tuple[Path, bool, bool, str | None, str]: - """Copy bundled hook script to a hooks/ dir next to the config file. +class _CopiedScript(NamedTuple): + path: Path + existed: bool + updated: bool + current_checksum: str | None + new_checksum: str - Returns (path, already_existed, was_updated, current_checksum, new_checksum). - current_checksum is None when the script did not exist before. - """ - dest_dir = config_path.parent / "hooks" - - dest_dir.mkdir(parents=True, exist_ok=True) - script_name = "snyk-agent-guard.ps1" if IS_WINDOWS else "snyk-agent-guard.sh" - dest = dest_dir / script_name - existed = dest.exists() - current_checksum: str | None = None - if existed: - current_checksum = hashlib.sha256(dest.read_bytes()).hexdigest() +def _copy_hook_script(dest: Path) -> _CopiedScript: + """Copy the bundled hook script named ``dest.name`` to *dest*. + Handles both the forwarding hook and the session-start discovery trampoline; + the bundled resource and the destination share a basename. + """ from agent_scan.version import version_info - hook_pkg = importlib_resources.files("agent_scan.hooks") - source = hook_pkg.joinpath(script_name) + dest.parent.mkdir(parents=True, exist_ok=True) + + source = importlib_resources.files("agent_scan.hooks").joinpath(dest.name) new_content = source.read_bytes().replace(b"__AGENT_SCAN_VERSION__", version_info.encode()) new_checksum = hashlib.sha256(new_content).hexdigest() - if existed and current_checksum == new_checksum: - return dest, existed, False, current_checksum, new_checksum + current_content = dest.read_bytes() if dest.exists() else None + current_checksum = None if current_content is None else hashlib.sha256(current_content).hexdigest() + + updated = current_content != new_content + if updated: + dest.write_bytes(new_content) + rich.print(f"[green]\u2713[/green] Copied hook script to [dim]{dest}[/dim]") + if not IS_WINDOWS: + dest.chmod(dest.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) - dest.write_bytes(new_content) - dest.chmod(dest.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) - rich.print(f"[green]\u2713[/green] Copied hook script to [dim]{dest}[/dim]") - return dest, existed, True, current_checksum, new_checksum + return _CopiedScript(dest, current_content is not None, updated, current_checksum, new_checksum) def _remove_hook_script(client: str, config_path: Path) -> None: - dest_dir = config_path.parent / "hooks" - script_name = "snyk-agent-guard.ps1" if IS_WINDOWS else "snyk-agent-guard.sh" - dest = dest_dir / script_name - if dest.exists(): - dest.unlink() - rich.print(f"[green]\u2713[/green] Removed hook script [dim]{dest}[/dim]") + for dest in (_forwarder_script_path(config_path), _discover_script_path(config_path)): + if dest.exists(): + dest.unlink() + rich.print(f"[green]\u2713[/green] Removed hook script [dim]{dest}[/dim]") def _backup_file(path: Path) -> None: diff --git a/src/agent_scan/hook_events.py b/src/agent_scan/hook_events.py new file mode 100644 index 00000000..f35e7acb --- /dev/null +++ b/src/agent_scan/hook_events.py @@ -0,0 +1,100 @@ +"""Direct delivery of Agent Guard hook events to Agent Monitor.""" + +from __future__ import annotations + +import asyncio +import base64 +import json +from typing import NamedTuple + +import aiohttp + +from agent_scan.hook_version import HOOK_VERSION +from agent_scan.utils import get_hostname, get_username +from agent_scan.verify_api import RETRYABLE_TRANSPORT_EXCEPTIONS, backend_client_session +from agent_scan.version import version_info + + +class HookClient(NamedTuple): + target_folder_field: str + session_field: str + endpoint: str + + +HOOK_CLIENTS = { + "claude-code": HookClient("cwd", "session_id", "/hidden/agent-monitor/hooks/claude-code"), + "cursor": HookClient("workspace_roots", "conversation_id", "/hidden/agent-monitor/hooks/cursor"), + "codex": HookClient("cwd", "session_id", "/hidden/agent-monitor/hooks/codex"), +} +_HOOK_REQUEST_TIMEOUT_SECONDS = 15 + + +async def _post_hook_event(url: str, body: bytes, headers: dict[str, str], max_retries: int) -> tuple[bool, str]: + """POST once per attempt, retrying only transport errors like the analysis path does.""" + timeout = aiohttp.ClientTimeout(total=_HOOK_REQUEST_TIMEOUT_SECONDS) + detail = "" + for attempt in range(max_retries): + try: + async with backend_client_session() as session: + async with session.post(url, data=body, headers=headers, timeout=timeout) as response: + if response.status >= 400: + # A rejected event will be rejected again; only transport faults retry. + return False, f"HTTP {response.status}" + return True, "" + except RETRYABLE_TRANSPORT_EXCEPTIONS as error: + detail = str(error) or type(error).__name__ + if attempt + 1 < max_retries: + await asyncio.sleep(2**attempt) + except Exception as error: + return False, str(error) + return False, detail + + +def send_hook_event( + base_url: str, + hook_client: str, + push_key: str, + payload: str, + machine_id: str, + *, + max_retries: int = 1, +) -> tuple[bool, str]: + """POST a hook event using the same wire contract as the hook scripts. + + ``max_retries`` defaults to a single attempt: session-start discovery runs inside + the agent's hook budget, so waiting out a backoff there would cost more than the + event is worth. One-shot callers such as ``guard install`` can opt into retries. + """ + client = HOOK_CLIENTS.get(hook_client) + if client is None: + return False, f"unknown client: {hook_client}" + if not machine_id.strip(): + return False, "machine ID is required" + + hostname = get_hostname() + x_user = json.dumps( + { + "hostname": hostname, + "username": get_username(), + "identifier": machine_id, + }, + separators=(",", ":"), + ) + encoded_payload = base64.b64encode(payload.encode()).decode() + body = f"base64:{encoded_payload}".encode() + if not base_url.lower().startswith(("http://", "https://")): + # Match curl's handling in the hook scripts: a URL without a scheme defaults to HTTP. + base_url = f"http://{base_url}" + url = f"{base_url.rstrip('/')}{client.endpoint}?version={HOOK_VERSION}" + headers = { + "User-Agent": f"snyk/agent-scan Agent Scan v{version_info}", + "X-User": x_user, + "Content-Type": "text/plain", + "X-Client-Id": push_key, + } + + try: + return asyncio.run(_post_hook_event(url, body, headers, max_retries)) + except Exception as error: + # Delivery is best-effort; never let it break the caller. + return False, str(error) diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 new file mode 100644 index 00000000..8af0fe94 --- /dev/null +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 @@ -0,0 +1,54 @@ +# +# Session-start discovery trampoline for Snyk Agent Guard (Windows). +# Sets the environment expected by `guard discover` and hands it this process's +# stdin, from which it reads the hook payload. Parameters mirror snyk-agent-guard.ps1. +# +param( + [Parameter(Mandatory=$true)] + [ValidateSet("claude-code","cursor","codex")] + [string]$Client, + + [Parameter(Mandatory=$false)] + [string]$PushKey, + + [Parameter(Mandatory=$false)] + [string]$RemoteUrl, + + [Parameter(Mandatory=$false)] + [string]$MachineId, + + [Parameter(Mandatory=$false)] + [string]$AgentScanCommand, + + [Parameter(Mandatory=$false)] + [ValidateSet("servers","skills","all")] + [string]$Scope = "servers" +) + +$ErrorActionPreference = "Stop" + +if ($PushKey) { $env:PUSH_KEY = $PushKey } +if ($RemoteUrl) { $env:REMOTE_HOOKS_BASE_URL = $RemoteUrl } +if (-not $MachineId) { $MachineId = $env:MACHINE_ID } +if (-not $MachineId) { exit 0 } +$env:MACHINE_ID = $MachineId + +$cmd = if ($AgentScanCommand) { $AgentScanCommand } elseif ($env:AGENT_SCAN_COMMAND) { $env:AGENT_SCAN_COMMAND } else { $null } +if (-not $cmd) { exit 0 } + +$arguments = @("guard", "discover", "--client", $Client, "--scope", $Scope) + +# Do not read stdin here. Invoking the binary outside a pipeline lets it inherit this +# process's stdin, so `guard discover` reads the hook payload itself under its own 5s +# cap -- matching snyk-agent-guard-discover.sh, which never touches fd 0. Reading it +# here instead would block forever on an agent that keeps the pipe open. +try { + if (Test-Path -LiteralPath $cmd -PathType Leaf) { + & $cmd @arguments *> $null + } else { + Invoke-Expression "$cmd $($arguments -join ' ')" *> $null + } +} catch { + # Session-start discovery is best-effort telemetry. +} +exit 0 diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.sh b/src/agent_scan/hooks/snyk-agent-guard-discover.sh new file mode 100755 index 00000000..ba573601 --- /dev/null +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +[[ -n "${MACHINE_ID:-}" ]] || exit 0 +[[ -n "${AGENT_SCAN_COMMAND:-}" ]] || exit 0 +if [[ -x "$AGENT_SCAN_COMMAND" ]]; then + "$AGENT_SCAN_COMMAND" guard discover "$@" >/dev/null 2>&1 || true +else + eval "$AGENT_SCAN_COMMAND guard discover \"\$@\"" >/dev/null 2>&1 || true +fi +exit 0 diff --git a/src/agent_scan/hooks/snyk-agent-guard.ps1 b/src/agent_scan/hooks/snyk-agent-guard.ps1 index 1a659ef3..326a7972 100644 --- a/src/agent_scan/hooks/snyk-agent-guard.ps1 +++ b/src/agent_scan/hooks/snyk-agent-guard.ps1 @@ -18,7 +18,10 @@ param( [string]$PushKey, [Parameter(Mandatory=$false)] - [string]$RemoteUrl + [string]$RemoteUrl, + + [Parameter(Mandatory=$false)] + [string]$MachineId ) $ErrorActionPreference = "Stop" @@ -46,6 +49,12 @@ if (-not $RemoteUrl) { exit 1 } +if (-not $MachineId) { $MachineId = $env:MACHINE_ID } +if (-not $MachineId) { + Write-Error "MACHINE_ID is required (pass -MachineId or set env var)" + exit 1 +} + switch ($Client) { "claude-code" { $endpoint = "/hidden/agent-monitor/hooks/claude-code" @@ -89,7 +98,7 @@ function JsonEscape($s) { } $xUser = '{{"hostname":"{0}","username":"{1}","identifier":"{2}"}}' -f ` - (JsonEscape $hostname), (JsonEscape $username), (JsonEscape $hostname) + (JsonEscape $hostname), (JsonEscape $username), (JsonEscape $MachineId) # Execute request try { diff --git a/src/agent_scan/hooks/snyk-agent-guard.sh b/src/agent_scan/hooks/snyk-agent-guard.sh index 2544cec6..70c48056 100755 --- a/src/agent_scan/hooks/snyk-agent-guard.sh +++ b/src/agent_scan/hooks/snyk-agent-guard.sh @@ -88,6 +88,7 @@ hook_main() { local pushkey pushkey="${PUSH_KEY:-${PUSHKEY:-}}" [[ -n "$pushkey" ]] || die "PUSH_KEY environment variable is not set" + [[ -n "${MACHINE_ID:-}" ]] || die "MACHINE_ID environment variable is not set" # Determine endpoint and user-agent based on client local endpoint user_agent @@ -129,7 +130,7 @@ hook_main() { x_user="$(printf '{%s:%s,%s:%s,%s:%s}' \ "\"hostname\"" "$(json_quote "$hostname")" \ "\"username\"" "$(json_quote "$username")" \ - "\"identifier\"" "$(json_quote "$hostname")")" + "\"identifier\"" "$(json_quote "$MACHINE_ID")")" # Execute request local resp body http_code marker @@ -144,10 +145,10 @@ hook_main() { -H "X-User: ${x_user}" -H "Content-Type: text/plain" -H "X-Client-Id: ${pushkey}" - --data-binary "${encoded_body}" + --data-binary @- ) - resp="$(curl "${curl_args[@]}" -w $'\n'"${marker}%{http_code}")" || die "Request failed" + resp="$(printf '%s' "$encoded_body" | curl "${curl_args[@]}" -w $'\n'"${marker}%{http_code}")" || die "Request failed" http_code="${resp##*$'\n'"${marker}"}" body="${resp%$'\n'"${marker}"*}" diff --git a/src/agent_scan/inspect.py b/src/agent_scan/inspect.py index 371c9deb..2f52d206 100644 --- a/src/agent_scan/inspect.py +++ b/src/agent_scan/inspect.py @@ -5,6 +5,7 @@ from httpx import HTTPStatusError +from agent_scan.agents.base import DiscoveryScope from agent_scan.mcp_client import check_server, scan_mcp_config_file from agent_scan.models import ( CandidateClient, @@ -78,6 +79,8 @@ async def get_mcp_config_per_client( client: CandidateClient, home_dirs: list[tuple[Path, str]], create_file_not_found_error: bool = False, + *, + scope: DiscoveryScope = DiscoveryScope.ALL, ) -> list[ClientToInspect]: """ Looks for Client (Cursor, VSCode, etc.) across all home directories in the machine. @@ -86,25 +89,38 @@ async def get_mcp_config_per_client( if any(path.startswith("~") for path in client.client_exists_paths): for home_directory, username in home_dirs: - cti = await get_mcp_config_per_home_directory(client, home_directory, create_file_not_found_error) + cti = await get_mcp_config_per_home_directory( + client, home_directory, create_file_not_found_error, scope=scope + ) if cti is not None: cti.username = username ctis.append(cti) else: - cti = await get_mcp_config_per_home_directory(client, None, create_file_not_found_error) + cti = await get_mcp_config_per_home_directory(client, None, create_file_not_found_error, scope=scope) if cti is not None: ctis.append(cti) return ctis async def get_mcp_config_per_home_directory( - client: CandidateClient, home_directory: Path | None, create_file_not_found_error: bool = False + client: CandidateClient, + home_directory: Path | None, + create_file_not_found_error: bool = False, + *, + scope: DiscoveryScope = DiscoveryScope.ALL, ) -> ClientToInspect | None: """ Looks for Client (Cursor, VSCode, etc.) config files. If found, returns a ClientToInspect object with the MCP config paths and skills dir paths. If not found, returns None. + + ``scope`` gates the two halves the same way ``AgentDiscoverer.discover`` does, so a + servers-only request does not pay for the skills glob (and vice versa). Client + detection itself always runs, so a client never disappears from a scoped report. """ + scope = DiscoveryScope(scope) + want_servers = scope in (DiscoveryScope.SERVERS, DiscoveryScope.ALL) + want_skills = scope in (DiscoveryScope.SKILLS, DiscoveryScope.ALL) # check if client exists client_path: str | None = None @@ -130,13 +146,15 @@ async def get_mcp_config_per_home_directory( | CouldNotParseMCPConfig, ] = {} - all_mcp_config_paths: list[str] = list(client.mcp_config_paths) - for glob_pattern in client.mcp_config_globs: - expanded_glob = str(expand_path(Path(glob_pattern), home_directory)) - all_mcp_config_paths.extend(_resolve_glob_with_depth(expanded_glob, client.max_glob_depth)) - all_mcp_config_paths = list( - dict.fromkeys(str(expand_path(Path(p), home_directory).resolve()) for p in all_mcp_config_paths) - ) + all_mcp_config_paths: list[str] = [] + if want_servers: + all_mcp_config_paths = list(client.mcp_config_paths) + for glob_pattern in client.mcp_config_globs: + expanded_glob = str(expand_path(Path(glob_pattern), home_directory)) + all_mcp_config_paths.extend(_resolve_glob_with_depth(expanded_glob, client.max_glob_depth)) + all_mcp_config_paths = list( + dict.fromkeys(str(expand_path(Path(p), home_directory).resolve()) for p in all_mcp_config_paths) + ) for mcp_config_path in all_mcp_config_paths: mcp_config_path_expanded = expand_path(Path(mcp_config_path), home_directory) @@ -174,15 +192,17 @@ async def get_mcp_config_per_home_directory( # parse skills dirs skills_dirs: dict[str, list[DiscoveredSkill] | FileNotFoundConfig] = {} - all_skills_dir_paths: list[str] = list(client.skills_dir_paths) - for glob_pattern in client.skills_dir_globs: - expanded_glob = str(expand_path(Path(glob_pattern), home_directory)) - for match in _resolve_glob_with_depth(expanded_glob, client.max_glob_depth): - if Path(match).is_dir(): - all_skills_dir_paths.append(match) - all_skills_dir_paths = list( - dict.fromkeys(str(expand_path(Path(p), home_directory).resolve()) for p in all_skills_dir_paths) - ) + all_skills_dir_paths: list[str] = [] + if want_skills: + all_skills_dir_paths = list(client.skills_dir_paths) + for glob_pattern in client.skills_dir_globs: + expanded_glob = str(expand_path(Path(glob_pattern), home_directory)) + for match in _resolve_glob_with_depth(expanded_glob, client.max_glob_depth): + if Path(match).is_dir(): + all_skills_dir_paths.append(match) + all_skills_dir_paths = list( + dict.fromkeys(str(expand_path(Path(p), home_directory).resolve()) for p in all_skills_dir_paths) + ) for skills_dir_path in all_skills_dir_paths: skills_dir_path_expanded = expand_path(Path(skills_dir_path), home_directory) diff --git a/src/agent_scan/pipelines.py b/src/agent_scan/pipelines.py index 6a697be0..56cad822 100644 --- a/src/agent_scan/pipelines.py +++ b/src/agent_scan/pipelines.py @@ -3,9 +3,9 @@ import os from pathlib import Path -from pydantic import BaseModel +from pydantic import BaseModel, Field -from agent_scan.agents import find_discoverers +from agent_scan.agents import DiscoveryScope, find_discoverers from agent_scan.direct_scanner import direct_scan_to_server_config, is_direct_scan from agent_scan.inspect import ( get_mcp_config_per_client, @@ -35,6 +35,8 @@ class InspectArgs(BaseModel): paths: list[str] all_users: bool = False scan_skills: bool = False + discovery_scope: DiscoveryScope = DiscoveryScope.ALL + target_folders: list[str] = Field(default_factory=list) class AnalyzeArgs(BaseModel): @@ -85,9 +87,33 @@ async def discover_clients_to_inspect( ) ) else: + target_folders: list[Path] = [] + seen_target_folders: set[Path] = set() + for raw_path in inspect_args.target_folders: + target_path = Path(raw_path).expanduser() + try: + key = target_path.resolve() + except (OSError, RuntimeError, ValueError): + # Target folders come from untrusted hook-payload JSON, where a NUL byte + # raises ValueError; fall back to the literal path so one bad entry cannot + # abort the whole discovery. + key = target_path + if key in seen_target_folders: + continue + seen_target_folders.add(key) + try: + exists = key.exists() + except (OSError, RuntimeError, ValueError): + logger.warning("Skipping inaccessible target folder: %s", target_path) + continue + if not exists: + logger.warning("Skipping non-existent target folder: %s", target_path) + continue + target_folders.append(target_path) + # Phase A — legacy path. Runs for EVERY well-known client including Claude Code. for client in get_well_known_clients(): - ctis = await get_mcp_config_per_client(client, home_dirs_with_users) + ctis = await get_mcp_config_per_client(client, home_dirs_with_users, scope=inspect_args.discovery_scope) if ctis: clients_to_inspect.extend(ctis) else: @@ -95,9 +121,9 @@ async def discover_clients_to_inspect( # Phase B — ABC path. Runs sequentially after Phase A and merges into its output. for home_directory, username in home_dirs_with_users: - for discoverer in find_discoverers(home_directory): + for discoverer in find_discoverers(home_directory, target_folders=target_folders): try: - cti = discoverer.discover() + cti = discoverer.discover(inspect_args.discovery_scope) except Exception: logger.exception("Discoverer %s.discover() raised; skipping", type(discoverer).__name__) continue diff --git a/src/agent_scan/utils.py b/src/agent_scan/utils.py index 30323cc9..3596bb00 100644 --- a/src/agent_scan/utils.py +++ b/src/agent_scan/utils.py @@ -2,6 +2,7 @@ import getpass import glob import logging +import ntpath import os import platform import shutil @@ -61,13 +62,95 @@ def ensure_unicode_console() -> None: logger = logging.getLogger(__name__) +def toml_escape(value: str) -> str: + """Return a TOML basic string containing *value*.""" + escapes = { + "\\": "\\\\", + '"': '\\"', + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", + } + rendered: list[str] = ['"'] + for char in value: + if char in escapes: + rendered.append(escapes[char]) + elif ord(char) < 0x20 or ord(char) == 0x7F: + rendered.append(f"\\u{ord(char):04X}") + else: + rendered.append(char) + rendered.append('"') + return "".join(rendered) + + +def toml_unescape(value: str) -> str: + """Decode TOML basic-string escapes while preserving unknown escapes.""" + escapes = { + "b": "\b", + "t": "\t", + "n": "\n", + "f": "\f", + "r": "\r", + '"': '"', + "\\": "\\", + } + unescaped: list[str] = [] + index = 0 + while index < len(value): + char = value[index] + if char != "\\": + unescaped.append(char) + index += 1 + continue + if index + 1 == len(value): + unescaped.append("\\") + break + + escape = value[index + 1] + if escape in escapes: + unescaped.append(escapes[escape]) + index += 2 + continue + if escape in {"u", "U"}: + width = 4 if escape == "u" else 8 + end = index + 2 + width + codepoint = value[index + 2 : end] + if len(codepoint) == width and all(char in "0123456789abcdefABCDEF" for char in codepoint): + try: + unescaped.append(chr(int(codepoint, 16))) + except ValueError: + pass + else: + index = end + continue + + unescaped.extend(("\\", escape)) + index += 2 + return "".join(unescaped) + + def get_relative_path(path: str) -> str: try: - expanded_path = os.path.expanduser(path) - home_dir = os.path.expanduser("~") - result = "~" + expanded_path[len(home_dir) :] if expanded_path.startswith(home_dir) else path - # Normalize to forward slashes for consistent display across platforms. - return result.replace("\\", "/") + original_path = path.replace("\\", "/") + expanded_path = os.path.expanduser(path).replace("\\", "/") + home_dir = os.path.expanduser("~").replace("\\", "/").rstrip("/") + if sys.platform == "win32": + path_parts = expanded_path.split("/") + home_parts = home_dir.split("/") + if len(path_parts) >= len(home_parts) and all( + ntpath.normcase(path_part) == ntpath.normcase(home_part) + for path_part, home_part in zip(path_parts[: len(home_parts)], home_parts, strict=True) + ): + suffix = "/".join(path_parts[len(home_parts) :]) + return "~" + (f"/{suffix}" if suffix else "") + else: + if expanded_path == home_dir: + return "~" + if home_dir and expanded_path.startswith(home_dir + "/"): + return "~" + expanded_path[len(home_dir) :] + return original_path except Exception: return path.replace("\\", "/") diff --git a/src/agent_scan/verify_api.py b/src/agent_scan/verify_api.py index ae36dfb1..545a37f8 100644 --- a/src/agent_scan/verify_api.py +++ b/src/agent_scan/verify_api.py @@ -85,7 +85,7 @@ def _force_analysis_api_version(analysis_url: str) -> str: return urlunsplit(parsed._replace(query=urlencode(query))) -_RETRYABLE_TRANSPORT_EXCEPTIONS = ( +RETRYABLE_TRANSPORT_EXCEPTIONS = ( TimeoutError, aiohttp.ClientConnectionError, aiohttp.ClientPayloadError, @@ -120,7 +120,7 @@ async def _async_analysis_enabled( """ for attempt in range(max_retries): try: - async with _analysis_client_session(trace_configs, skip_ssl_verify) as session: + async with backend_client_session(trace_configs, skip_ssl_verify) as session: async with session.get( config_url, headers={"X-Push-Key": push_key}, @@ -140,7 +140,7 @@ async def _async_analysis_enabled( attempt + 1, max_retries, ) - except _RETRYABLE_TRANSPORT_EXCEPTIONS as e: + except RETRYABLE_TRANSPORT_EXCEPTIONS as e: logger.warning("Agent Scan config request failed (attempt %d/%d): %s", attempt + 1, max_retries, e) except aiohttp.ClientError as e: # Non-transient transport error (e.g. malformed URL): retrying will not help. @@ -180,7 +180,7 @@ async def _submit_async_analysis( for attempt in range(max_retries): try: - async with _analysis_client_session(trace_configs, skip_ssl_verify) as session: + async with backend_client_session(trace_configs, skip_ssl_verify) as session: async with session.post( async_url, data=body, @@ -201,7 +201,7 @@ async def _submit_async_analysis( attempt + 1, max_retries, ) - except _RETRYABLE_TRANSPORT_EXCEPTIONS as e: + except RETRYABLE_TRANSPORT_EXCEPTIONS as e: logger.warning( "Async analysis request failed (attempt %d/%d): %s", attempt + 1, @@ -417,8 +417,13 @@ def setup_tcp_connector(skip_ssl_verify: bool = False) -> aiohttp.TCPConnector: return connector -def _analysis_client_session(trace_configs: list | None, skip_ssl_verify: bool) -> aiohttp.ClientSession: - """Build a ClientSession with the shared connector, tracing and proxy settings.""" +def backend_client_session(trace_configs: list | None = None, skip_ssl_verify: bool = False) -> aiohttp.ClientSession: + """Build a ClientSession with the shared connector, tracing and proxy settings. + + Shared by every outbound call to the Snyk backend (analysis and Agent Guard hook + events alike) so they all get the same trust posture: certifi plus any CA the + environment points at via load_extra_ca_certs. + """ return aiohttp.ClientSession( trace_configs=trace_configs, connector=setup_tcp_connector(skip_ssl_verify=skip_ssl_verify), @@ -450,7 +455,7 @@ async def analyze_machine( identifier: Identifier for the user additional_headers: Additional headers to send to the analysis server verbose: Whether to enable verbose logging - skip_pushing: Whether to skip pushing the scan to the platform + skip_pushing: Whether to skip pushing the scan to the backend max_retries: Maximum number of retry attempts skip_ssl_verify: Whether to skip SSL verification scan_context: Optional dict containing scan metadata to include in the request @@ -521,7 +526,7 @@ async def analyze_machine( for attempt in range(max_retries): try: - async with _analysis_client_session(trace_configs, skip_ssl_verify) as session: + async with backend_client_session(trace_configs, skip_ssl_verify) as session: async with session.post( analysis_url, data=payload.model_dump_json(), diff --git a/tests/conftest.py b/tests/conftest.py index f7ccb1ff..00209a22 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,11 +50,24 @@ def _ensure_unicode_console(): ensure_unicode_console() +@pytest.fixture(autouse=True) +def _clear_agent_scan_command(monkeypatch): + """Keep session-start discovery opt-in unless a test enables it explicitly.""" + monkeypatch.delenv("AGENT_SCAN_COMMAND", raising=False) + + def _get_binary_path() -> Path: """Path to the PyInstaller-built mcp-scan binary.""" return REPO_ROOT / "dist" / ("agent-scan.exe" if sys.platform == "win32" else "agent-scan") +@pytest.fixture +def agent_scan_command() -> Path: + """Path to the virtual environment's Agent Scan console script.""" + executable = "snyk-agent-scan.exe" if sys.platform == "win32" else "snyk-agent-scan" + return Path(sys.executable).parent / executable + + def _build_binary() -> None: """Run the same steps as `make binary` (works on Windows without make).""" steps = [ diff --git a/tests/e2e/test_guard_install.py b/tests/e2e/test_guard_install.py index 9f4158d6..efef1f7f 100644 --- a/tests/e2e/test_guard_install.py +++ b/tests/e2e/test_guard_install.py @@ -1,10 +1,12 @@ """E2E test for guard install — ensures the bundled hook scripts are accessible.""" +import base64 import json import os import subprocess import threading from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import ClassVar import pytest @@ -12,9 +14,17 @@ class _FakeHookServer(BaseHTTPRequestHandler): """Accepts any POST and returns 200 — enough for the test-event handshake.""" + requests: ClassVar[list[dict]] = [] + def do_POST(self): length = int(self.headers.get("Content-Length", 0)) - self.rfile.read(length) + body = self.rfile.read(length).decode() + _FakeHookServer.requests.append( + { + "body": json.loads(base64.b64decode(body.removeprefix("base64:"))), + "headers": dict(self.headers), + } + ) self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() @@ -30,6 +40,7 @@ def fake_hook_server(): port = server.server_address[1] t = threading.Thread(target=server.serve_forever, daemon=True) t.start() + _FakeHookServer.requests = [] yield f"http://127.0.0.1:{port}" server.shutdown() @@ -44,6 +55,9 @@ class TestGuardInstallE2E: @pytest.mark.parametrize("agent_scan_cmd", ["uv", "binary"], indirect=True) def test_guard_install_claude(self, agent_scan_cmd, tmp_path, fake_hook_server): config_file = tmp_path / "settings.json" + install_env = {**os.environ, "PUSH_KEY": "test-pk-e2e"} + install_env.pop("AGENT_SCAN_COMMAND", None) + install_env.pop("MACHINE_ID", None) result = subprocess.run( [ *agent_scan_cmd, @@ -57,8 +71,8 @@ def test_guard_install_claude(self, agent_scan_cmd, tmp_path, fake_hook_server): ], capture_output=True, text=True, - timeout=30, - env={**os.environ, "PUSH_KEY": "test-pk-e2e"}, + timeout=60, + env=install_env, ) assert result.returncode == 0, f"guard install failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" @@ -68,9 +82,52 @@ def test_guard_install_claude(self, agent_scan_cmd, tmp_path, fake_hook_server): # Should have entries for standard Claude hook events assert "PreToolUse" in settings["hooks"] assert "Stop" in settings["hooks"] + discovery_groups = [ + group for group in settings["hooks"]["SessionStart"] if group.get("hooks", [{}])[0].get("async") is True + ] + assert len(discovery_groups) == 1 + assert "matcher" not in discovery_groups[0] + discover_command = discovery_groups[0]["hooks"][0]["command"] + discover_script = "snyk-agent-guard-discover.ps1" if os.name == "nt" else "snyk-agent-guard-discover.sh" + assert discover_script in discover_command + assert str(config_file) not in discover_command + assert ("-ConfigFile" if os.name == "nt" else "--file") not in discover_command + assert [request["body"]["hook_event_name"] for request in _FakeHookServer.requests] == [ + "hooksConfigured", + "hooksConfiguredServerDiscovery", + ] + discovered = _FakeHookServer.requests[1] + assert discovered["body"]["session_id"] == "hooks-setup" + assert isinstance(discovered["body"]["servers"], list) + assert isinstance(discovered["body"]["discovery_duration_ms"], int) + assert discovered["body"]["discovery_duration_ms"] >= 0 + discovered_user = json.loads(discovered["headers"]["X-User"]) + assert discovered_user["identifier"] == discovered_user["hostname"] + + discover_result = subprocess.run( + [*agent_scan_cmd, "guard", "discover", "--client", "claude-code"], + capture_output=True, + text=True, + timeout=60, + env={ + **os.environ, + "PUSH_KEY": "test-pk-e2e", + "REMOTE_HOOKS_BASE_URL": fake_hook_server, + "MACHINE_ID": "e2e-machine-id", + }, + ) + assert discover_result.returncode == 0, ( + f"guard discover failed:\nstdout: {discover_result.stdout}\nstderr: {discover_result.stderr}" + ) + session_discovery = _FakeHookServer.requests[-1] + assert session_discovery["body"]["hook_event_name"] == "sessionStartServerDiscovery" + assert session_discovery["body"]["session_id"] == "session-start-server-discovery" + assert isinstance(session_discovery["body"]["servers"], list) + assert isinstance(session_discovery["body"]["discovery_duration_ms"], int) + assert session_discovery["body"]["discovery_duration_ms"] >= 0 @pytest.mark.parametrize("agent_scan_cmd", ["uv", "binary"], indirect=True) - def test_guard_install_cursor(self, agent_scan_cmd, tmp_path, fake_hook_server): + def test_guard_install_cursor(self, agent_scan_cmd, agent_scan_command, tmp_path, fake_hook_server): config_file = tmp_path / "hooks.json" result = subprocess.run( [ @@ -82,11 +139,13 @@ def test_guard_install_cursor(self, agent_scan_cmd, tmp_path, fake_hook_server): str(config_file), "--url", fake_hook_server, + "--machine-id", + "e2e-machine-id", ], capture_output=True, text=True, - timeout=30, - env={**os.environ, "PUSH_KEY": "test-pk-e2e"}, + timeout=60, + env={**os.environ, "PUSH_KEY": "test-pk-e2e", "AGENT_SCAN_COMMAND": str(agent_scan_command)}, ) assert result.returncode == 0, f"guard install failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" @@ -94,3 +153,96 @@ def test_guard_install_cursor(self, agent_scan_cmd, tmp_path, fake_hook_server): assert "hooks" in data assert "preToolUse" in data["hooks"] assert "stop" in data["hooks"] + + discover_script = "snyk-agent-guard-discover.ps1" if os.name == "nt" else "snyk-agent-guard-discover.sh" + discovery_entries = [ + entry for entry in data["hooks"]["sessionStart"] if discover_script in entry.get("command", "") + ] + assert len(discovery_entries) == 1 + assert set(discovery_entries[0]) == {"command"} + assert str(config_file) not in discovery_entries[0]["command"] + + discover_result = subprocess.run( + [*agent_scan_cmd, "guard", "discover", "--client", "cursor"], + input=json.dumps({"workspace_roots": [str(tmp_path)], "conversation_id": "e2e-conversation"}), + capture_output=True, + text=True, + timeout=60, + env={ + **os.environ, + "PUSH_KEY": "test-pk-e2e", + "REMOTE_HOOKS_BASE_URL": fake_hook_server, + "MACHINE_ID": "e2e-machine-id", + }, + ) + assert discover_result.returncode == 0, ( + f"guard discover failed:\nstdout: {discover_result.stdout}\nstderr: {discover_result.stderr}" + ) + session_discovery = _FakeHookServer.requests[-1] + assert session_discovery["body"]["hook_event_name"] == "sessionStartServerDiscovery" + assert session_discovery["body"]["conversation_id"] == "e2e-conversation" + assert "session_id" not in session_discovery["body"] + assert isinstance(session_discovery["body"]["servers"], list) + assert isinstance(session_discovery["body"]["discovery_duration_ms"], int) + assert session_discovery["body"]["discovery_duration_ms"] >= 0 + + @pytest.mark.parametrize("agent_scan_cmd", ["uv", "binary"], indirect=True) + def test_guard_install_codex(self, agent_scan_cmd, agent_scan_command, tmp_path, fake_hook_server): + config_file = tmp_path / "hooks.json" + result = subprocess.run( + [ + *agent_scan_cmd, + "guard", + "install", + "codex", + "--file", + str(config_file), + "--url", + fake_hook_server, + "--machine-id", + "e2e-machine-id", + ], + capture_output=True, + text=True, + timeout=60, + env={**os.environ, "PUSH_KEY": "test-pk-e2e", "AGENT_SCAN_COMMAND": str(agent_scan_command)}, + ) + assert result.returncode == 0, f"guard install failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + + data = json.loads(config_file.read_text()) + assert "PreToolUse" in data["hooks"] + assert "Stop" in data["hooks"] + + discover_script = "snyk-agent-guard-discover.ps1" if os.name == "nt" else "snyk-agent-guard-discover.sh" + discovery_groups = [ + group + for group in data["hooks"]["SessionStart"] + if discover_script in group.get("hooks", [{}])[0].get("command", "") + ] + assert len(discovery_groups) == 1 + assert "matcher" not in discovery_groups[0] + assert discovery_groups[0]["hooks"][0]["async"] is True + assert str(config_file) not in discovery_groups[0]["hooks"][0]["command"] + + discover_result = subprocess.run( + [*agent_scan_cmd, "guard", "discover", "--client", "codex"], + input=json.dumps({"cwd": str(tmp_path), "session_id": "e2e"}), + capture_output=True, + text=True, + timeout=60, + env={ + **os.environ, + "PUSH_KEY": "test-pk-e2e", + "REMOTE_HOOKS_BASE_URL": fake_hook_server, + "MACHINE_ID": "e2e-machine-id", + }, + ) + assert discover_result.returncode == 0, ( + f"guard discover failed:\nstdout: {discover_result.stdout}\nstderr: {discover_result.stderr}" + ) + session_discovery = _FakeHookServer.requests[-1] + assert session_discovery["body"]["hook_event_name"] == "sessionStartServerDiscovery" + assert session_discovery["body"]["session_id"] == "e2e" + assert isinstance(session_discovery["body"]["servers"], list) + assert isinstance(session_discovery["body"]["discovery_duration_ms"], int) + assert session_discovery["body"]["discovery_duration_ms"] >= 0 diff --git a/tests/unit/test_agent_discovery.py b/tests/unit/test_agent_discovery.py index 38ee809f..c2a86233 100644 --- a/tests/unit/test_agent_discovery.py +++ b/tests/unit/test_agent_discovery.py @@ -1,10 +1,13 @@ """Tests for the per-agent discovery ABC (agent_scan.agents package).""" +import json import sys -from unittest.mock import patch +from pathlib import Path +from unittest.mock import AsyncMock, patch import pytest +from agent_scan.agents import DiscoveryScope from agent_scan.models import ( ClientToInspect, CouldNotParseMCPConfig, @@ -238,22 +241,22 @@ def test_claude_code_discoverer_project_folders_empty_when_config_missing(tmp_pa assert folders == [] -# --- ClaudeCodeDiscoverer: _project_paths_with_ancestors --- +# --- ClaudeCodeDiscoverer: _discovery_paths_with_ancestors --- -def test_project_paths_with_ancestors_empty_when_no_projects(tmp_path): +def test_discovery_paths_with_ancestors_empty_when_no_projects(tmp_path): """No projects listed in ~/.claude.json → empty list.""" from agent_scan.agents import ClaudeCodeDiscoverer (tmp_path / ".claude").mkdir() (tmp_path / ".claude.json").write_text('{"projects": {}}') - paths = ClaudeCodeDiscoverer(tmp_path)._project_paths_with_ancestors() + paths = ClaudeCodeDiscoverer(tmp_path)._discovery_paths_with_ancestors() assert paths == [] -def test_project_paths_with_ancestors_walks_up_to_filesystem_root(tmp_path): +def test_discovery_paths_with_ancestors_walks_up_to_filesystem_root(tmp_path): """A single project fans out into itself + every ancestor up to '/'.""" from pathlib import Path @@ -262,7 +265,7 @@ def test_project_paths_with_ancestors_walks_up_to_filesystem_root(tmp_path): (tmp_path / ".claude").mkdir() (tmp_path / ".claude.json").write_text('{"projects": {"/a/b/c/d": {"mcpServers": {}}}}') - paths = set(ClaudeCodeDiscoverer(tmp_path)._project_paths_with_ancestors()) + paths = set(ClaudeCodeDiscoverer(tmp_path)._discovery_paths_with_ancestors()) assert Path("/a/b/c/d") in paths assert Path("/a/b/c") in paths @@ -271,7 +274,7 @@ def test_project_paths_with_ancestors_walks_up_to_filesystem_root(tmp_path): assert Path("/") in paths -def test_project_paths_with_ancestors_dedups_shared_ancestors(tmp_path): +def test_discovery_paths_with_ancestors_dedups_shared_ancestors(tmp_path): """Two sibling projects sharing ancestors yield each ancestor only once.""" from pathlib import Path @@ -282,7 +285,7 @@ def test_project_paths_with_ancestors_dedups_shared_ancestors(tmp_path): '{"projects": {"/a/b/c/d": {"mcpServers": {}}, "/a/b/x/y": {"mcpServers": {}}}}' ) - paths = ClaudeCodeDiscoverer(tmp_path)._project_paths_with_ancestors() + paths = ClaudeCodeDiscoverer(tmp_path)._discovery_paths_with_ancestors() assert len(paths) == len(set(paths)) # no duplicates as_set = set(paths) @@ -297,7 +300,7 @@ def test_project_paths_with_ancestors_dedups_shared_ancestors(tmp_path): } <= as_set -def test_project_paths_with_ancestors_terminates_at_root(tmp_path): +def test_discovery_paths_with_ancestors_terminates_at_root(tmp_path): """Walk terminates at filesystem root (no infinite loop).""" from pathlib import Path @@ -306,7 +309,7 @@ def test_project_paths_with_ancestors_terminates_at_root(tmp_path): (tmp_path / ".claude").mkdir() (tmp_path / ".claude.json").write_text('{"projects": {"/": {"mcpServers": {}}}}') - paths = ClaudeCodeDiscoverer(tmp_path)._project_paths_with_ancestors() + paths = ClaudeCodeDiscoverer(tmp_path)._discovery_paths_with_ancestors() assert paths == [Path("/")] @@ -1147,6 +1150,32 @@ def test_claude_code_discoverer_discover_returns_none_when_not_installed(tmp_pat assert cti is None +@pytest.mark.parametrize( + "scope,expect_servers,expect_skills", + [ + ("all", True, True), + ("servers", True, False), + ("skills", False, True), + ], +) +def test_discover_scope_only_populates_requested_half(tmp_path, scope, expect_servers, expect_skills): + from agent_scan.agents import ClaudeCodeDiscoverer, DiscoveryScope + + discoverer = ClaudeCodeDiscoverer(tmp_path) + with ( + patch.object(discoverer, "client_exists", return_value="/installed/claude"), + patch.object(discoverer, "discover_mcp_servers", return_value={"servers": []}) as discover_servers, + patch.object(discoverer, "discover_skills", return_value={"skills": []}) as discover_skills, + ): + client = discoverer.discover(DiscoveryScope(scope)) + + assert client is not None + assert client.mcp_configs == ({"servers": []} if expect_servers else {}) + assert client.skills_dirs == ({"skills": []} if expect_skills else {}) + assert discover_servers.called is expect_servers + assert discover_skills.called is expect_skills + + # --- ABC enforcement --- @@ -1269,6 +1298,41 @@ async def test_discover_clients_to_inspect_runs_legacy_for_claude_code(tmp_path) await discover_clients_to_inspect(args) assert spy_legacy.called, "Legacy path must be called for claude code" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("scope", list(DiscoveryScope)) +async def test_phase_a_receives_the_requested_discovery_scope(tmp_path, scope): + """Phase A must honor discovery_scope too; otherwise --scope servers saves nothing.""" + from agent_scan.models import CandidateClient + from agent_scan.pipelines import InspectArgs, discover_clients_to_inspect + + (tmp_path / ".claude").mkdir() + (tmp_path / ".claude.json").write_text('{"mcpServers": {}}') + + candidate = CandidateClient( + name="claude code", + client_exists_paths=["~/.claude"], + mcp_config_paths=["~/.claude.json"], + skills_dir_paths=["~/.claude/skills"], + ) + + with ( + patch( + "agent_scan.pipelines.get_readable_home_directories", + return_value=[(tmp_path, "alice")], + ), + patch("agent_scan.pipelines.get_well_known_clients", return_value=[candidate]), + patch("agent_scan.pipelines.find_discoverers", return_value=[]), + patch( + "agent_scan.pipelines.get_mcp_config_per_client", + new=AsyncMock(return_value=[]), + ) as spy_legacy, + ): + args = InspectArgs(timeout=10, tokens=[], paths=[], discovery_scope=scope) + await discover_clients_to_inspect(args) + + assert spy_legacy.await_args.kwargs["scope"] is scope called_names = {call.args[0].name for call in spy_legacy.call_args_list} assert "claude code" in called_names @@ -3073,18 +3137,18 @@ def _setup_cursor_workspace(tmp_path, workspace_relpath): return discoverer, workspace -def test_project_paths_with_ancestors_lives_on_agent_discoverer_base(): +def test_discovery_paths_with_ancestors_lives_on_agent_discoverer_base(): """The ancestor walk is shared by every discoverer, so it lives on the abstract base.""" from agent_scan.agents import AgentDiscoverer - assert "_project_paths_with_ancestors" in AgentDiscoverer.__dict__ + assert "_discovery_paths_with_ancestors" in AgentDiscoverer.__dict__ -def test_vscode_family_project_paths_with_ancestors_uses_workspace_storage(tmp_path): +def test_vscode_family_discovery_paths_with_ancestors_uses_workspace_storage(tmp_path): """For VSCode family, project roots come from workspaceStorage, then fan out into ancestors.""" discoverer, workspace = _setup_cursor_workspace(tmp_path, "deep/nested/repo") - paths = set(discoverer._project_paths_with_ancestors()) + paths = set(discoverer._discovery_paths_with_ancestors()) # Workspace + every ancestor up to filesystem root. cur = workspace @@ -3095,12 +3159,12 @@ def test_vscode_family_project_paths_with_ancestors_uses_workspace_storage(tmp_p cur = cur.parent -def test_vscode_family_project_paths_empty_when_no_workspaces(tmp_path): +def test_vscode_family_discovery_paths_empty_when_no_workspaces(tmp_path): """No workspaceStorage entries means no project paths and no ancestors.""" from agent_scan.agents import CursorDiscoverer (tmp_path / ".cursor").mkdir() - assert CursorDiscoverer(tmp_path)._project_paths_with_ancestors() == [] + assert CursorDiscoverer(tmp_path)._discovery_paths_with_ancestors() == [] # --- Cursor workspace-scoped skills discovery --- @@ -6068,6 +6132,38 @@ def test_claude_code_project_skills_path_that_is_a_file_is_skipped(tmp_path): # --- #8: _scans_own_home resolves symlinks and accepts the uid's passwd home --- +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink semantics") +def test_scans_own_home_false_when_home_resolution_fails(tmp_path, monkeypatch): + """An unresolvable ``home_directory`` answers False instead of comparing literals. + + This gate decides whether the *scanning process's* ``CLAUDE_CONFIG_DIR`` / + ``VSCODE_PORTABLE`` are honored for the home being scanned, so "cannot prove this is + my own home" must fail closed. Here the scanned home really is the same directory as + ``Path.home()`` (one is a symlink of the other), but its resolution fails -- and the + conservative answer is still False. + """ + from pathlib import Path + + from agent_scan.agents import VSCodeDiscoverer + + real_home = tmp_path / "real_home" + real_home.mkdir() + link_home = tmp_path / "link_home" + link_home.symlink_to(real_home) + monkeypatch.setattr(Path, "home", lambda: link_home) + + unpatched_resolve = Path.resolve + + def resolve_fails_for_real_home(self, *args, **kwargs): + if self == real_home: + raise OSError("stale NFS file handle") + return unpatched_resolve(self, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", resolve_fails_for_real_home) + + assert VSCodeDiscoverer(real_home)._scans_own_home() is False + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink semantics") def test_scans_own_home_true_for_symlinked_home(tmp_path, monkeypatch): """A ``home_directory`` that is a symlink to the real home is still recognized @@ -8868,3 +8964,405 @@ def flaky_exists(self, *args, **kwargs): assert result is not None assert result.endswith("/.opencode") + + +# --- Explicit target-folder injection --- + + +def test_project_and_target_folders_remain_separate(tmp_path): + from agent_scan.agents import ClaudeCodeDiscoverer + + recorded = tmp_path / "recorded" + explicit = tmp_path / "explicit" + (tmp_path / ".claude.json").write_text(f'{{"projects": {{"{recorded.as_posix()}": {{}}}}}}') + + discoverer = ClaudeCodeDiscoverer(tmp_path, [explicit]) + + assert discoverer._discover_project_folders() == [recorded] + assert discoverer._discover_target_folders() == [explicit] + assert discoverer._all_discovery_folders() == [recorded, explicit] + + +def test_target_folders_gain_ancestors_and_dedup_recorded_roots(tmp_path): + from agent_scan.agents import ClaudeCodeDiscoverer + + project = tmp_path / "monorepo" / "package" + (tmp_path / ".claude.json").write_text(f'{{"projects": {{"{project.as_posix()}": {{}}}}}}') + + discoverer = ClaudeCodeDiscoverer(tmp_path, [project]) + paths = discoverer._discovery_paths_with_ancestors() + + # ``project`` is both a recorded project root and an explicit target: listed once. + assert paths.count(project) == 1 + assert project.parent in paths + assert tmp_path in paths + + +def test_folder_dedupe_survives_resolve_runtime_error(tmp_path): + from agent_scan.agents import ClaudeCodeDiscoverer + + target = tmp_path / "project" + target.mkdir() + discoverer = ClaudeCodeDiscoverer(tmp_path, [target]) + + with patch.object(Path, "resolve", side_effect=RuntimeError("Symlink loop")): + assert discoverer._all_discovery_folders() == [target] + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink semantics") +def test_literal_project_spellings_both_contribute_inline_servers(tmp_path): + from agent_scan.agents import ClaudeCodeDiscoverer + + (tmp_path / ".claude").mkdir() + project = tmp_path / "project" + project.mkdir() + project_link = tmp_path / "project-link" + project_link.symlink_to(project, target_is_directory=True) + (tmp_path / ".claude.json").write_text( + json.dumps( + { + "projects": { + project.as_posix(): {"mcpServers": {"literal": {"command": "echo"}}}, + project_link.as_posix(): {"mcpServers": {"linked": {"command": "echo"}}}, + } + } + ) + ) + + servers = ClaudeCodeDiscoverer(tmp_path).discover_mcp_servers() + + names = {name for entries in servers.values() if isinstance(entries, list) for name, _ in entries} + assert names >= {"literal", "linked"} + + +def test_discovery_paths_are_memoized_and_resolve_roots_once(tmp_path): + from agent_scan.agents import ClaudeCodeDiscoverer + + project = tmp_path / "project" + discoverer = ClaudeCodeDiscoverer(tmp_path) + + with ( + patch.object(discoverer, "_discover_project_folders", return_value=[project]), + patch.object(Path, "resolve", autospec=True, side_effect=lambda path: path) as resolve, + ): + first = discoverer._discovery_paths_with_ancestors() + second = discoverer._discovery_paths_with_ancestors() + + assert second is first + assert resolve.call_count == 1 + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink semantics") +def test_all_discovery_folders_dedupes_resolved_paths_and_keeps_recorded_spelling(tmp_path): + from agent_scan.agents import ClaudeCodeDiscoverer + + target = tmp_path / "real-project" + target.mkdir() + recorded_link = tmp_path / "linked-project" + recorded_link.symlink_to(target, target_is_directory=True) + (tmp_path / ".claude.json").write_text(f'{{"projects": {{"{recorded_link.as_posix()}": {{}}}}}}') + + discoverer = ClaudeCodeDiscoverer(tmp_path, [target]) + + assert discoverer._discover_project_folders() == [recorded_link] + assert discoverer._discover_target_folders() == [target] + assert discoverer._all_discovery_folders() == [recorded_link] + paths = discoverer._discovery_paths_with_ancestors() + assert recorded_link in paths + assert target not in paths + + +def test_all_discovery_folders_dedupes_project_roots_in_first_seen_order(tmp_path): + from agent_scan.agents import ClaudeCodeDiscoverer + + first = tmp_path / "first" + second = tmp_path / "second" + discoverer = ClaudeCodeDiscoverer(tmp_path) + + with patch.object(discoverer, "_discover_project_folders", return_value=[first, first, second]): + assert discoverer._all_discovery_folders() == [first, second] + + +def test_claude_code_discovers_servers_and_skills_from_target_without_state_entry(tmp_path): + from agent_scan.agents import ClaudeCodeDiscoverer + + (tmp_path / ".claude").mkdir() + project = tmp_path / "checkout" + project.mkdir() + (project / ".mcp.json").write_text('{"mcpServers":{"explicit-claude":{"command":"echo"}}}') + _write_skill(project / ".claude" / "skills", "claude-project-skill") + _write_skill(project / ".agents" / "skills", "shared-project-skill") + + discoverer = ClaudeCodeDiscoverer(tmp_path, [project]) + servers = discoverer.discover_mcp_servers() + skills = discoverer.discover_skills() + + assert "explicit-claude" in { + name for entries in servers.values() if isinstance(entries, list) for name, _ in entries + } + assert (project / ".claude" / "skills").as_posix() in skills + assert (project / ".agents" / "skills").as_posix() in skills + + +def test_claude_code_merges_project_history_and_target_discovery(tmp_path): + from agent_scan.agents import ClaudeCodeDiscoverer + + (tmp_path / ".claude").mkdir() + recorded = tmp_path / "recorded" + recorded.mkdir() + target = tmp_path / "target" + target.mkdir() + (tmp_path / ".claude.json").write_text(f'{{"projects": {{"{recorded.as_posix()}": {{}}}}}}') + (recorded / ".mcp.json").write_text('{"mcpServers":{"recorded-server":{"command":"echo"}}}') + (target / ".mcp.json").write_text('{"mcpServers":{"target-server":{"command":"echo"}}}') + + servers = ClaudeCodeDiscoverer(tmp_path, [target]).discover_mcp_servers() + + names = {name for entries in servers.values() if isinstance(entries, list) for name, _ in entries} + assert names >= {"recorded-server", "target-server"} + + +def test_codex_discovers_servers_and_skills_from_target(tmp_path): + from agent_scan.agents import CodexDiscoverer + + (tmp_path / ".codex").mkdir() + project = tmp_path / "checkout" + (project / ".codex").mkdir(parents=True) + (project / ".codex" / "config.toml").write_text('[mcp_servers.explicit_codex]\ncommand = "echo"\n') + _write_skill(project / ".agents" / "skills", "codex-project-skill") + + discoverer = CodexDiscoverer(tmp_path, [project]) + servers = discoverer.discover_mcp_servers() + skills = discoverer.discover_skills() + + assert "explicit_codex" in { + name for entries in servers.values() if isinstance(entries, list) for name, _ in entries + } + assert (project / ".agents" / "skills").as_posix() in skills + + +def test_cursor_discovers_servers_and_skills_from_target_without_workspace_state(tmp_path): + from agent_scan.agents import CursorDiscoverer + + (tmp_path / ".cursor").mkdir() + project = tmp_path / "checkout" + (project / ".cursor").mkdir(parents=True) + (project / ".cursor" / "mcp.json").write_text('{"mcpServers":{"explicit-cursor":{"command":"echo"}}}') + _write_skill(project / ".cursor" / "skills", "cursor-project-skill") + + discoverer = CursorDiscoverer(tmp_path, [project]) + servers = discoverer.discover_mcp_servers() + skills = discoverer.discover_skills() + + assert "explicit-cursor" in { + name for entries in servers.values() if isinstance(entries, list) for name, _ in entries + } + assert (project / ".cursor" / "skills").as_posix() in skills + + +def test_opencode_relative_skills_path_anchors_at_target_root(tmp_path): + from agent_scan.agents import OpenCodeDiscoverer + + _opencode_install(tmp_path) + project = tmp_path / "checkout" + project.mkdir() + (project / "opencode.json").write_text('{"skills":{"paths":["team-skills"]}}') + _write_skill(project / "team-skills", "relative-project-skill") + + skills = OpenCodeDiscoverer(tmp_path, [project]).discover_skills() + + assert (project / "team-skills").as_posix() in skills + + +def test_find_discoverers_threads_target_folders(tmp_path): + from agent_scan.agents import ClaudeCodeDiscoverer, find_discoverers + + (tmp_path / ".claude").mkdir() + project = tmp_path / "checkout" + + found = find_discoverers(tmp_path, target_folders=[project]) + + claude = next(discoverer for discoverer in found if isinstance(discoverer, ClaudeCodeDiscoverer)) + assert claude.target_folders == [project] + + +@pytest.mark.asyncio +async def test_pipeline_merges_target_servers_and_skills_into_installed_client(tmp_path): + from agent_scan.pipelines import InspectArgs, discover_clients_to_inspect + + home = tmp_path / "home" + (home / ".claude").mkdir(parents=True) + project = tmp_path / "checkout" + project.mkdir() + (project / ".mcp.json").write_text('{"mcpServers":{"pipeline-project":{"command":"echo"}}}') + _write_skill(project / ".claude" / "skills", "pipeline-project-skill") + + with ( + patch("agent_scan.pipelines.get_readable_home_directories", return_value=[(home, "alice")]), + patch("agent_scan.pipelines.get_well_known_clients", return_value=[]), + ): + clients, _, _ = await discover_clients_to_inspect( + InspectArgs(timeout=0, tokens=[], paths=[], scan_skills=True, target_folders=[str(project)]) + ) + + claude = next(client for client in clients if client.name == "claude code") + assert "pipeline-project" in { + name for entries in claude.mcp_configs.values() if isinstance(entries, list) for name, _ in entries + } + assert (project / ".claude" / "skills").as_posix() in claude.skills_dirs + + +@pytest.mark.asyncio +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink semantics") +async def test_pipeline_preserves_unresolved_target_folder_spelling(tmp_path): + from agent_scan.pipelines import InspectArgs, discover_clients_to_inspect + + home = tmp_path / "home" + home.mkdir() + target = tmp_path / "real-project" + target.mkdir() + project_link = tmp_path / "linked-project" + project_link.symlink_to(target, target_is_directory=True) + + with ( + patch("agent_scan.pipelines.get_readable_home_directories", return_value=[(home, "alice")]), + patch("agent_scan.pipelines.get_well_known_clients", return_value=[]), + patch("agent_scan.pipelines.find_discoverers", return_value=[]) as find, + ): + await discover_clients_to_inspect( + InspectArgs(timeout=0, tokens=[], paths=[], target_folders=[str(project_link)]) + ) + + find.assert_called_once_with(home, target_folders=[project_link]) + + +@pytest.mark.asyncio +async def test_pipeline_skips_missing_target_folder_with_warning(tmp_path, caplog): + from agent_scan.pipelines import InspectArgs, discover_clients_to_inspect + + home = tmp_path / "home" + (home / ".claude").mkdir(parents=True) + missing = tmp_path / "missing" + + with ( + patch("agent_scan.pipelines.get_readable_home_directories", return_value=[(home, "alice")]), + patch("agent_scan.pipelines.get_well_known_clients", return_value=[]), + caplog.at_level("WARNING", logger="agent_scan.pipelines"), + ): + await discover_clients_to_inspect(InspectArgs(timeout=0, tokens=[], paths=[], target_folders=[str(missing)])) + + assert str(missing) in caplog.text + assert "Skipping" in caplog.text + + +@pytest.mark.asyncio +async def test_pipeline_skips_target_folder_when_exists_raises(tmp_path, caplog): + import errno + + from agent_scan.pipelines import InspectArgs, discover_clients_to_inspect + + home = tmp_path / "home" + home.mkdir() + stale = tmp_path / "stale-mount" + good = tmp_path / "project" + good.mkdir() + real_exists = Path.exists + + def flaky_exists(path): + if path == stale: + raise OSError(errno.ESTALE, "Stale file handle") + return real_exists(path) + + with ( + patch("agent_scan.pipelines.get_readable_home_directories", return_value=[(home, "alice")]), + patch("agent_scan.pipelines.get_well_known_clients", return_value=[]), + patch("agent_scan.pipelines.find_discoverers", return_value=[]) as find, + patch.object(Path, "exists", flaky_exists), + caplog.at_level("WARNING", logger="agent_scan.pipelines"), + ): + await discover_clients_to_inspect( + InspectArgs(timeout=0, tokens=[], paths=[], target_folders=[str(stale), str(good)]) + ) + + find.assert_called_once_with(home, target_folders=[good]) + assert str(stale) in caplog.text + assert "Skipping" in caplog.text + + +@pytest.mark.asyncio +async def test_pipeline_explicit_paths_ignore_target_folders(tmp_path): + from unittest.mock import AsyncMock + + from agent_scan.pipelines import InspectArgs, discover_clients_to_inspect + + explicit_config = tmp_path / "config.json" + project = tmp_path / "checkout" + project.mkdir() + from_path = AsyncMock(return_value=[]) + + inspect_args = InspectArgs( + timeout=0, + tokens=[], + paths=[str(explicit_config)], + target_folders=[str(project)], + ) + assert inspect_args.target_folders == [str(project)] + + with ( + patch("agent_scan.pipelines.get_readable_home_directories", return_value=[]), + patch("agent_scan.pipelines.client_to_inspect_from_path", from_path), + patch("agent_scan.pipelines.find_discoverers") as find, + ): + await discover_clients_to_inspect(inspect_args) + + from_path.assert_awaited_once() + find.assert_not_called() + + +@pytest.mark.asyncio +async def test_pipeline_runtime_error_resolving_target_keeps_literal_folder(tmp_path): + from agent_scan.pipelines import InspectArgs, discover_clients_to_inspect + + target = tmp_path / "project" + target.mkdir() + home = tmp_path / "home" + home.mkdir() + + with ( + patch("agent_scan.pipelines.get_readable_home_directories", return_value=[(home, "alice")]), + patch("agent_scan.pipelines.get_well_known_clients", return_value=[]), + patch("agent_scan.pipelines.find_discoverers", return_value=[]) as find, + patch.object(Path, "resolve", side_effect=RuntimeError("Symlink loop")), + ): + await discover_clients_to_inspect( + InspectArgs(timeout=0, tokens=[], paths=[], target_folders=[target.as_posix()]) + ) + + find.assert_called_once_with(home, target_folders=[target]) + + +@pytest.mark.asyncio +async def test_pipeline_null_byte_target_folder_is_skipped_without_aborting(tmp_path): + """Target folders arrive from untrusted hook JSON, where a NUL byte raises ValueError. + + ``Path.resolve()`` raises ``ValueError`` (not ``OSError``) for an embedded NUL, so a + payload such as ``{"cwd": "a\\0b"}`` must not take the whole discovery down with it -- + the bad entry is dropped and the good one still reaches the discoverers. + """ + from agent_scan.pipelines import InspectArgs, discover_clients_to_inspect + + home = tmp_path / "home" + home.mkdir() + good = tmp_path / "project" + good.mkdir() + + with ( + patch("agent_scan.pipelines.get_readable_home_directories", return_value=[(home, "alice")]), + patch("agent_scan.pipelines.get_well_known_clients", return_value=[]), + patch("agent_scan.pipelines.find_discoverers", return_value=[]) as find, + ): + await discover_clients_to_inspect( + InspectArgs(timeout=0, tokens=[], paths=[], target_folders=["a\x00b", good.as_posix()]) + ) + + find.assert_called_once_with(home, target_folders=[good]) diff --git a/tests/unit/test_cli_config_file.py b/tests/unit/test_cli_config_file.py index 523fe1ec..90916ef9 100644 --- a/tests/unit/test_cli_config_file.py +++ b/tests/unit/test_cli_config_file.py @@ -2,13 +2,16 @@ complete-replacement semantics for block/list arguments.""" import argparse +import sys import pytest +from agent_scan import cli from agent_scan.cli import ( _coerce_config_value, _effective_identifier, _effective_push_key, + _iter_all_actions, apply_config_file, control_servers_from_config, explicitly_provided_dests, @@ -41,6 +44,11 @@ def _parse(argv: list[str]) -> tuple[argparse.ArgumentParser, argparse.Namespace return parser, args +def _provided(parser: argparse.ArgumentParser, argv: list[str]) -> set[str]: + """Ask which dests ``argv`` set explicitly.""" + return explicitly_provided_dests(parser, argv) + + def _write_yaml(tmp_path, text: str) -> str: path = tmp_path / "config.yaml" path.write_text(text) @@ -76,21 +84,38 @@ def test_non_mapping_top_level_exits_2(self, tmp_path): class TestExplicitlyProvidedDests: def test_detects_passed_flags_only(self): - parser = _build_parser() - provided = explicitly_provided_dests(parser, ["scan", "--server-timeout", "5", "--json"]) + provided = _provided(_build_parser(), ["scan", "--server-timeout", "5", "--json"]) assert "server_timeout" in provided assert "json" in provided assert "verbose" not in provided def test_detects_equals_form(self): - parser = _build_parser() - provided = explicitly_provided_dests(parser, ["scan", "--server-timeout=5"]) - assert "server_timeout" in provided + assert "server_timeout" in _provided(_build_parser(), ["scan", "--server-timeout=5"]) def test_boolean_optional_both_spellings_map_to_same_dest(self): - parser = _build_parser() - assert "skills" in explicitly_provided_dests(parser, ["scan", "--no-skills"]) - assert "skills" in explicitly_provided_dests(parser, ["scan", "--skills"]) + assert "skills" in _provided(_build_parser(), ["scan", "--no-skills"]) + assert "skills" in _provided(_build_parser(), ["scan", "--skills"]) + + def test_no_option_string_maps_to_two_dests(self, monkeypatch): + """``explicitly_provided_dests`` keys a flat option-string -> dest map, so an option + string reused across subcommands must always mean the same dest. A collision would + make the map order-dependent and silently misreport which flags were explicit.""" + captured: list[argparse.ArgumentParser] = [] + + def capture(self, *args, **kwargs): + captured.append(self) + raise SystemExit(0) + + monkeypatch.setattr(sys, "argv", ["agent-scan", "scan"]) + monkeypatch.setattr(argparse.ArgumentParser, "parse_args", capture) + with pytest.raises(SystemExit): + cli.main() + + seen: dict[str, str] = {} + for action in _iter_all_actions(captured[0]): + for option in action.option_strings: + previous = seen.setdefault(option, action.dest) + assert previous == action.dest, f"{option} maps to both {previous!r} and {action.dest!r}" class TestAbbreviationDisabled: diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 61e46db4..6394f7ba 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -4,17 +4,20 @@ import base64 import json +import os import shutil import subprocess import sys import threading from http.server import BaseHTTPRequestHandler, HTTPServer -from pathlib import Path +from pathlib import Path, PurePosixPath from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import call as mock_call import pytest +import agent_scan.guard as guard_module from agent_scan.guard import ( _PERMISSION_DENIED, ALL_CLIENTS, @@ -55,14 +58,12 @@ _run_uninstall, _send_test_event, _shell_quote, - _uninstall_claude, - _uninstall_codex, - _uninstall_cursor, - _write_claude_config, - _write_codex_config, + _uninstall_hooks, _write_codex_managed_config, - _write_cursor_config, + _write_config, ) +from agent_scan.models import ClientToInspect, InspectedPath, InspectedServer, RemoteServer, StdioServer +from agent_scan.models.errors import CouldNotParseMCPConfig, FileNotFoundConfig from agent_scan.pushkeys import GuardEnabledAccessDeniedError # --------------------------------------------------------------------------- @@ -100,17 +101,17 @@ def _write(path: Path, data) -> None: def _setup_claude_hooks(cmd: str, path: Path) -> None: settings, _, preserved = _prepare_claude_config(cmd, path) - _write_claude_config(settings, path, preserved) + _write_config(settings, path, preserved) def _setup_cursor_hooks(cmd: str, path: Path) -> None: data, _, preserved = _prepare_cursor_config(cmd, path) - _write_cursor_config(data, path, preserved) + _write_config(data, path, preserved) def _setup_codex_hooks(cmd: str, path: Path) -> None: data, _, preserved = _prepare_codex_config(cmd, path) - _write_codex_config(data, path, preserved) + _write_config(data, path, preserved) def _setup_codex_managed_hooks(cmd: str, path: Path) -> None: @@ -118,6 +119,23 @@ def _setup_codex_managed_hooks(cmd: str, path: Path) -> None: _write_codex_managed_config(content, path) +def _uninstall_test_client(client: str, path: Path) -> None: + _uninstall_hooks( + path, + filter_hooks=_filter_cursor_hooks if client == "cursor" else _filter_claude_hooks, + prune_empty_hooks=client != "cursor", + ) + + +def _detect_test_client(client: str, path: Path) -> dict | None: + detect = { + "claude": _detect_claude_install, + "cursor": _detect_cursor_install, + "codex": _detect_codex_install, + }[client] + return detect(path) + + # =================================================================== # Unit tests for pure helpers # =================================================================== @@ -213,6 +231,72 @@ def test_tenant_id(self): assert _extract_env_from_cmd(cmd, "TENANT_ID") == "tid-1" +@pytest.mark.parametrize( + "variant,is_windows,expected", + [ + ( + "main", + False, + "PUSH_KEY='pk' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' TENANT_ID='tenant' " + "MACHINE_ID='machine' bash '/x/snyk-agent-guard.sh' --client claude-code", + ), + ( + "main", + True, + "powershell -File 'C:\\hooks\\snyk-agent-guard.ps1' -Client claude-code -PushKey 'pk' " + "-RemoteUrl 'https://api.snyk.io' -MachineId 'machine'", + ), + ( + "discover", + False, + "PUSH_KEY='pk' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' MACHINE_ID='machine' " + "AGENT_SCAN_COMMAND='/usr/local/bin/snyk-agent-scan' bash '/x/snyk-agent-guard-discover.sh' " + "--client 'claude-code' --scope servers", + ), + ( + "discover", + True, + "powershell -File 'C:\\hooks\\snyk-agent-guard-discover.ps1' -Client claude-code -PushKey 'pk' " + "-RemoteUrl 'https://api.snyk.io' -MachineId 'machine' " + "-AgentScanCommand 'C:\\Program Files\\Snyk\\snyk-agent-scan.exe' -Scope servers", + ), + ], +) +def test_build_hook_command_preserves_exact_output(variant, is_windows, expected): + script_path = Path( + r"C:\hooks\snyk-agent-guard.ps1" + if is_windows and variant == "main" + else r"C:\hooks\snyk-agent-guard-discover.ps1" + if is_windows + else f"/x/snyk-agent-guard{'-discover' if variant == 'discover' else ''}.sh" + ) + + with patch(f"{_G}.IS_WINDOWS", is_windows): + if variant == "main": + command = _build_hook_command( + "pk", + "https://api.snyk.io", + script_path, + "claude-code", + tenant_id="tenant", + machine_id="machine", + ) + else: + command = guard_module._build_discover_hook_command( + "pk", + "https://api.snyk.io", + script_path, + "claude-code", + agent_scan_command=( + r"C:\Program Files\Snyk\snyk-agent-scan.exe" if is_windows else "/usr/local/bin/snyk-agent-scan" + ), + tenant_id="tenant", + machine_id="machine", + ) + + assert command == expected + + class TestBuildHookCommand: @pytest.mark.skipif(sys.platform == "win32", reason="bash command format") def test_without_tenant_bash(self): @@ -228,6 +312,46 @@ def test_with_tenant_bash(self): cmd = _build_hook_command("pk", "https://api.snyk.io", Path("/x/hook.sh"), "cursor", tenant_id="tid") assert "TENANT_ID='tid'" in cmd + @pytest.mark.skipif(sys.platform == "win32", reason="bash command format") + def test_with_machine_id_bash(self): + cmd = _build_hook_command("pk", "https://api.snyk.io", Path("/x/hook.sh"), "cursor", machine_id="machine-42") + assert "MACHINE_ID='machine-42'" in cmd + + @pytest.mark.skipif(sys.platform == "win32", reason="bash command format") + def test_without_machine_id_bash(self): + cmd = _build_hook_command("pk", "https://api.snyk.io", Path("/x/hook.sh"), "cursor") + assert "MACHINE_ID" not in cmd + + def test_with_machine_id_powershell(self): + cmd = _build_hook_command_powershell( + "pk", "https://api.snyk.io", Path("C:/x/hook.ps1"), "codex", machine_id="machine-42" + ) + assert "-MachineId 'machine-42'" in cmd + + def test_without_machine_id_powershell(self): + cmd = _build_hook_command_powershell("pk", "https://api.snyk.io", Path("C:/x/hook.ps1"), "codex") + assert "-MachineId" not in cmd + + def test_machine_id_powershell_escapes_single_quotes(self): + cmd = _build_hook_command_powershell( + "pk", "https://api.snyk.io", Path("C:/x/hook.ps1"), "codex", machine_id="O'Brien-laptop" + ) + assert "-MachineId 'O''Brien-laptop'" in cmd + + def test_powershell_escapes_single_quotes_in_all_literals(self): + script_path = Path("C:/Users/O'Brien/hook.ps1") + cmd = _build_hook_command_powershell( + "pk'quoted", + "https://example.com/O'Brien", + script_path, + "codex", + ) + + expected_path = str(script_path).replace("'", "''") + assert f"-File '{expected_path}'" in cmd + assert "-PushKey 'pk''quoted'" in cmd + assert "-RemoteUrl 'https://example.com/O''Brien'" in cmd + @pytest.mark.skipif(sys.platform != "win32", reason="powershell command format") def test_without_tenant_powershell(self): cmd = _build_hook_command("pk", "https://api.snyk.io", Path("/x/hook.ps1"), "claude-code") @@ -252,6 +376,849 @@ def test_roundtrip_extract(self): assert _extract_env_from_cmd(cmd, "TENANT_ID") == "t-1" +class TestAgentScanCommand: + def test_uses_environment_value(self, monkeypatch): + monkeypatch.setenv("AGENT_SCAN_COMMAND", "cd /repo; uv run -m src.agent_scan.cli") + monkeypatch.setattr(sys, "frozen", True, raising=False) + + assert guard_module._agent_scan_command() == "cd /repo; uv run -m src.agent_scan.cli" + + def test_frozen_runtime_uses_absolute_current_executable(self, tmp_path, monkeypatch): + monkeypatch.delenv("AGENT_SCAN_COMMAND", raising=False) + executable = tmp_path / "dist" / "snyk-agent-scan" + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", str(executable)) + + assert guard_module._agent_scan_command() == str(executable.absolute()) + + @pytest.mark.parametrize("value", ["", " "]) + def test_blank_environment_value_uses_runtime_fallback(self, tmp_path, monkeypatch, value): + monkeypatch.setenv("AGENT_SCAN_COMMAND", value) + executable = tmp_path / "dist" / "snyk-agent-scan" + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", str(executable)) + + assert guard_module._agent_scan_command() == str(executable.absolute()) + + def test_uv_runtime_uses_sibling_console_script(self, tmp_path, monkeypatch): + monkeypatch.delenv("AGENT_SCAN_COMMAND", raising=False) + bin_dir = tmp_path / ".venv" / "bin" + bin_dir.mkdir(parents=True) + console_script = bin_dir / "snyk-agent-scan" + console_script.write_text("#!/bin/sh\n") + console_script.chmod(0o755) + monkeypatch.setattr(sys, "frozen", False, raising=False) + monkeypatch.setattr(sys, "executable", str(bin_dir / "python")) + + with patch(f"{_G}.IS_WINDOWS", False): + command = guard_module._agent_scan_command() + + assert command == str(console_script.absolute()) + + def test_windows_uv_runtime_uses_sibling_console_executable(self, tmp_path, monkeypatch): + monkeypatch.delenv("AGENT_SCAN_COMMAND", raising=False) + scripts_dir = tmp_path / ".venv" / "Scripts" + scripts_dir.mkdir(parents=True) + console_script = scripts_dir / "snyk-agent-scan.exe" + console_script.write_text("binary") + console_script.chmod(0o755) + monkeypatch.setattr(sys, "frozen", False, raising=False) + monkeypatch.setattr(sys, "executable", str(scripts_dir / "python.exe")) + + with patch(f"{_G}.IS_WINDOWS", True): + command = guard_module._agent_scan_command() + + assert command == str(console_script.absolute()) + + def test_returns_none_when_runtime_cannot_be_resolved(self, tmp_path, monkeypatch): + monkeypatch.delenv("AGENT_SCAN_COMMAND", raising=False) + monkeypatch.setattr(sys, "frozen", False, raising=False) + monkeypatch.setattr(sys, "executable", str(tmp_path / "bin" / "python")) + + with patch(f"{_G}.IS_WINDOWS", False): + command = guard_module._agent_scan_command() + + assert command is None + + +class TestBuildDiscoverHookCommand: + @pytest.mark.parametrize( + "client, expected_field", + [("claude-code", "cwd"), ("cursor", "workspace_roots"), ("codex", "cwd")], + ) + def test_client_payload_fields_match_hook_schemas(self, client, expected_field): + from agent_scan.hook_events import HOOK_CLIENTS + + assert HOOK_CLIENTS[client].target_folder_field == expected_field + + @pytest.mark.parametrize("client", ["claude-code", "cursor", "codex"]) + def test_builds_quoted_environment_prefix_with_agent_scan_command(self, client): + with patch(f"{_G}.IS_WINDOWS", False): + command = guard_module._build_discover_hook_command( + "pk", + "https://api.snyk.io", + Path("/x/snyk-agent-guard-discover.sh"), + client, + agent_scan_command="/opt/Snyk's bin/snyk-agent-scan", + tenant_id="tenant", + machine_id="machine", + ) + + assert "PUSH_KEY='pk'" in command + assert "REMOTE_HOOKS_BASE_URL='https://api.snyk.io'" in command + assert "TENANT_ID=" not in command + assert "MACHINE_ID='machine'" in command + assert "AGENT_SCAN_COMMAND='/opt/Snyk'\"'\"'s bin/snyk-agent-scan'" in command + assert command.endswith(f"bash '/x/snyk-agent-guard-discover.sh' --client '{client}' --scope servers") + assert _is_agent_scan_command(command) + + @pytest.mark.parametrize("client", ["claude-code", "cursor", "codex"]) + def test_builds_powershell_command_for_each_client(self, client): + with patch(f"{_G}.IS_WINDOWS", True): + command = guard_module._build_discover_hook_command( + "pk", + "https://api.snyk.io", + Path(r"C:\hooks\snyk-agent-guard-discover.ps1"), + client, + agent_scan_command=r"C:\Program Files\Snyk\snyk-agent-scan.exe", + tenant_id="ignored", + machine_id="machine's-id", + ) + + assert command == ( + rf"powershell -File 'C:\hooks\snyk-agent-guard-discover.ps1' -Client {client} " + "-PushKey 'pk' -RemoteUrl 'https://api.snyk.io' -MachineId 'machine''s-id' " + r"-AgentScanCommand 'C:\Program Files\Snyk\snyk-agent-scan.exe' -Scope servers" + ) + + def test_powershell_escapes_single_quotes_in_paths(self): + with patch(f"{_G}.IS_WINDOWS", True): + command = guard_module._build_discover_hook_command( + "pk", + "https://api.snyk.io", + Path(r"C:\Users\O'Brien\discover.ps1"), + "claude-code", + agent_scan_command=r"C:\Users\O'Brien\snyk-agent-scan.exe", + ) + + assert r"-File 'C:\Users\O''Brien\discover.ps1'" in command + assert r"-AgentScanCommand 'C:\Users\O''Brien\snyk-agent-scan.exe'" in command + + @pytest.mark.parametrize( + "is_windows, expected", + [ + (False, "AGENT_SCAN_COMMAND='cd /repo; uv run -m src.agent_scan.cli'"), + (True, "-AgentScanCommand 'cd /repo; uv run -m src.agent_scan.cli'"), + ], + ) + def test_preserves_multi_word_shell_command(self, is_windows, expected): + script = Path(r"C:\hooks\snyk-agent-guard-discover.ps1" if is_windows else "/hooks/discover.sh") + with patch(f"{_G}.IS_WINDOWS", is_windows): + command = guard_module._build_discover_hook_command( + "pk", + "https://api.snyk.io", + script, + "claude-code", + agent_scan_command="cd /repo; uv run -m src.agent_scan.cli", + ) + + assert expected in command + + +class TestHookInvocationRenderers: + def test_render_argv_posix_returns_unquoted_argv_and_merged_environment(self): + script_path = Path("/hooks/snyk-agent-guard.sh") + invocation = guard_module._HookInvocation( + script_path=script_path, + hook_client="claude-code", + push_key="pk'raw", + url="https://example.test/hook's", + machine_id="machine'raw", + ) + + with patch.dict(os.environ, {"EXISTING": "value"}, clear=True), patch(f"{_G}.IS_WINDOWS", False): + argv, env = guard_module._render_argv(invocation) + + # str(Path) follows the host flavour, so compare against it rather than a hardcoded separator + assert argv == ["bash", str(script_path), "--client", "claude-code"] + assert env == { + "EXISTING": "value", + "PUSH_KEY": "pk'raw", + "REMOTE_HOOKS_BASE_URL": "https://example.test/hook's", + "MACHINE_ID": "machine'raw", + } + + def test_render_argv_windows_returns_unquoted_argv_without_environment(self): + invocation = guard_module._HookInvocation( + script_path=Path(r"C:\hooks\snyk-agent-guard.ps1"), + hook_client="codex", + push_key="pk'raw", + url="https://example.test/hook's", + machine_id="machine'raw", + ) + + with patch(f"{_G}.IS_WINDOWS", True): + argv, env = guard_module._render_argv(invocation) + + assert argv == [ + "powershell", + "-File", + str(Path(r"C:\hooks\snyk-agent-guard.ps1")), + "-Client", + "codex", + "-PushKey", + "pk'raw", + "-RemoteUrl", + "https://example.test/hook's", + "-MachineId", + "machine'raw", + ] + assert env is None + + def test_render_argv_posix_carries_discovery_fields(self): + """The discovery trampoline forwards ``"$@"`` to ``guard discover``, so scope travels in argv.""" + script_path = Path("/hooks/snyk-agent-guard-discover.sh") + invocation = guard_module._HookInvocation( + script_path=script_path, + hook_client="cursor", + push_key="pk", + url="https://api.snyk.io", + machine_id="machine", + tenant_id="tenant", + agent_scan_command="/opt/Snyk's bin/snyk-agent-scan", + scope="servers", + quote_client=True, + ) + + with patch.dict(os.environ, {"EXISTING": "value"}, clear=True), patch(f"{_G}.IS_WINDOWS", False): + argv, env = guard_module._render_argv(invocation) + + assert argv == [ + "bash", + str(script_path), + "--client", + "cursor", + "--scope", + "servers", + ] + assert env == { + "EXISTING": "value", + "PUSH_KEY": "pk", + "REMOTE_HOOKS_BASE_URL": "https://api.snyk.io", + "TENANT_ID": "tenant", + "MACHINE_ID": "machine", + "AGENT_SCAN_COMMAND": "/opt/Snyk's bin/snyk-agent-scan", + } + + def test_render_argv_windows_carries_discovery_fields(self): + invocation = guard_module._HookInvocation( + script_path=Path(r"C:\hooks\snyk-agent-guard-discover.ps1"), + hook_client="codex", + push_key="pk", + url="https://api.snyk.io", + machine_id="machine", + tenant_id="tenant", + agent_scan_command=r"C:\Program Files\Snyk\snyk-agent-scan.exe", + scope="servers", + ) + + with patch(f"{_G}.IS_WINDOWS", True): + argv, env = guard_module._render_argv(invocation) + + assert argv == [ + "powershell", + "-File", + str(Path(r"C:\hooks\snyk-agent-guard-discover.ps1")), + "-Client", + "codex", + "-PushKey", + "pk", + "-RemoteUrl", + "https://api.snyk.io", + "-MachineId", + "machine", + "-AgentScanCommand", + r"C:\Program Files\Snyk\snyk-agent-scan.exe", + "-Scope", + "servers", + ] + assert env is None + + def test_render_posix_command_skips_empty_optional_fields(self): + invocation = guard_module._HookInvocation( + script_path=Path("/hooks/snyk-agent-guard.sh"), + hook_client="claude-code", + push_key="pk", + url="https://api.snyk.io", + ) + + command = guard_module._render_posix_command(invocation) + + assert command == ( + "PUSH_KEY='pk' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' " + "bash '/hooks/snyk-agent-guard.sh' --client claude-code" + ) + + +class TestPrepareClaudeDiscoveryHook: + discover_command = ( + "PUSH_KEY='pk' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' bash '/x/snyk-agent-guard-discover.sh'" + ) + + def test_adds_separate_async_matcherless_session_start_group(self, tmp_path): + with patch(f"{_G}.IS_WINDOWS", False): + settings, _, _ = _prepare_claude_config( + AGENT_SCAN_CMD, + tmp_path / "settings.json", + discover_command=self.discover_command, + ) + + for event in CLAUDE_HOOK_EVENTS: + expected_count = 2 if event == "SessionStart" else 1 + assert len(settings["hooks"][event]) == expected_count + assert settings["hooks"]["SessionStart"][1] == { + "hooks": [{"type": "command", "command": self.discover_command, "async": True}] + } + + def test_none_preserves_current_hook_shape(self, tmp_path): + settings, _, _ = _prepare_claude_config( + AGENT_SCAN_CMD, + tmp_path / "settings.json", + discover_command=None, + ) + + assert all(len(settings["hooks"][event]) == 1 for event in CLAUDE_HOOK_EVENTS) + + def test_windows_discovery_entry_uses_powershell_shell(self, tmp_path): + with patch(f"{_G}.IS_WINDOWS", True): + settings, _, _ = _prepare_claude_config( + AGENT_SCAN_CMD, + tmp_path / "settings.json", + discover_command=self.discover_command, + ) + + assert settings["hooks"]["SessionStart"][1] == { + "hooks": [ + { + "type": "command", + "command": self.discover_command, + "async": True, + "shell": "powershell", + } + ] + } + + def test_reprepare_is_idempotent(self, tmp_path): + path = tmp_path / "settings.json" + settings, _, preserved = _prepare_claude_config( + AGENT_SCAN_CMD, + path, + discover_command=self.discover_command, + ) + _write_config(settings, path, preserved) + + _, diff, _ = _prepare_claude_config( + AGENT_SCAN_CMD, + path, + discover_command=self.discover_command, + ) + + assert diff == {"added": {}, "modified": {}, "removed": {}} + + +class TestPrepareCursorDiscoveryHook: + discover_command = ( + "PUSH_KEY='pk' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' bash '/x/snyk-agent-guard-discover.sh'" + ) + + def test_adds_flat_session_start_entry(self, tmp_path): + data, _, _ = _prepare_cursor_config( + AGENT_SCAN_CMD, + tmp_path / "hooks.json", + discover_command=self.discover_command, + ) + + assert data["hooks"]["sessionStart"][1] == {"command": self.discover_command} + + def test_none_preserves_current_hook_shape(self, tmp_path): + data, _, _ = _prepare_cursor_config( + AGENT_SCAN_CMD, + tmp_path / "hooks.json", + discover_command=None, + ) + + assert all(len(data["hooks"][event]) == 1 for event in CURSOR_HOOK_EVENTS) + + def test_reprepare_is_idempotent(self, tmp_path): + path = tmp_path / "hooks.json" + data, _, preserved = _prepare_cursor_config( + AGENT_SCAN_CMD, + path, + discover_command=self.discover_command, + ) + _write_config(data, path, preserved) + + _, diff, _ = _prepare_cursor_config( + AGENT_SCAN_CMD, + path, + discover_command=self.discover_command, + ) + + assert diff == {"added": {}, "modified": {}, "removed": {}} + + def test_uninstall_removes_discovery_entry(self, tmp_path): + path = tmp_path / "hooks.json" + data, _, preserved = _prepare_cursor_config( + AGENT_SCAN_CMD, + path, + discover_command=self.discover_command, + ) + _write_config(data, path, preserved) + + _uninstall_test_client("cursor", path) + + assert not any( + self.discover_command == entry.get("command") + for entries in json.loads(path.read_text())["hooks"].values() + for entry in entries + ) + + +class TestPrepareCodexDiscoveryHook: + discover_command = ( + "PUSH_KEY='pk' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' bash '/x/snyk-agent-guard-discover.sh'" + ) + + def test_adds_async_matcherless_session_start_group(self, tmp_path): + data, _, _ = _prepare_codex_config( + AGENT_SCAN_CMD, + tmp_path / "hooks.json", + discover_command=self.discover_command, + ) + + assert data["hooks"]["SessionStart"][1] == { + "hooks": [{"type": "command", "command": self.discover_command, "async": True}] + } + + def test_none_preserves_current_hook_shape(self, tmp_path): + data, _, _ = _prepare_codex_config( + AGENT_SCAN_CMD, + tmp_path / "hooks.json", + discover_command=None, + ) + + assert all(len(data["hooks"][event]) == 1 for event in CODEX_HOOK_EVENTS) + + def test_reprepare_is_idempotent(self, tmp_path): + path = tmp_path / "hooks.json" + data, _, preserved = _prepare_codex_config( + AGENT_SCAN_CMD, + path, + discover_command=self.discover_command, + ) + _write_config(data, path, preserved) + + _, diff, _ = _prepare_codex_config( + AGENT_SCAN_CMD, + path, + discover_command=self.discover_command, + ) + + assert diff == {"added": {}, "modified": {}, "removed": {}} + + def test_uninstall_removes_discovery_entry(self, tmp_path): + path = tmp_path / "hooks.json" + data, _, preserved = _prepare_codex_config( + AGENT_SCAN_CMD, + path, + discover_command=self.discover_command, + ) + _write_config(data, path, preserved) + + _uninstall_test_client("codex", path) + + assert "hooks" not in json.loads(path.read_text()) + + +class TestWriteConfig: + def test_unknown_client_rejected(self, tmp_path): + with pytest.raises(ValueError, match="Unknown client: unknown"): + guard_module._write_client_config("unknown", tmp_path / "hooks.json", {}, None, 0) + + def test_returns_false_and_prints_nothing_when_content_unchanged(self, tmp_path, capsys): + path = tmp_path / "hooks.json" + config = {"hooks": {}} + assert _write_config(config, path, 0) is True + capsys.readouterr() + + assert _write_config(config, path, 0) is False + + assert capsys.readouterr().out == "" + assert not Path(f"{path}.backup").exists() + + def test_returns_true_and_backs_up_when_content_changed(self, tmp_path, capsys): + path = tmp_path / "hooks.json" + original = {"version": 1} + updated = {"version": 1, "hooks": {}} + assert _write_config(original, path, 0) is True + capsys.readouterr() + + assert _write_config(updated, path, 0) is True + + backup = Path(f"{path}.backup") + assert json.loads(backup.read_text()) == original + assert json.loads(path.read_text()) == updated + output = capsys.readouterr().out + assert "Backed up" in output + assert "Written" in output + + def test_creates_parent_directory_when_missing(self, tmp_path): + path = tmp_path / "missing" / "nested" / "hooks.json" + + assert _write_config({"hooks": {}}, path, 0) is True + + assert json.loads(path.read_text()) == {"hooks": {}} + + def test_preserved_note_omitted_when_zero(self, tmp_path, capsys): + _write_config({"hooks": {}}, tmp_path / "hooks.json", 0) + + output = capsys.readouterr().out + assert "Written" in output + assert "other hook(s) preserved" not in output + + def test_preserved_note_included_when_nonzero(self, tmp_path, capsys): + _write_config({"hooks": {}}, tmp_path / "hooks.json", 2) + + # rich soft-wraps the line at the terminal width, so collapse whitespace first + assert "(2 other hook(s) preserved)" in " ".join(capsys.readouterr().out.split()) + + def test_codex_managed_writer_is_not_routed_through_write_config(self, tmp_path): + path = tmp_path / "requirements.toml" + with ( + patch(f"{_G}._write_config") as write, + patch(f"{_G}._write_codex_managed_config", return_value=True) as write_managed, + ): + result = guard_module._write_client_config("codex", path, None, "toml-content", 2) + + assert result is True + write.assert_not_called() + write_managed.assert_called_once_with("toml-content", path) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX discovery script") +class TestDiscoveryHookScriptFiles: + def test_copy_writes_executable_discovery_script_next_to_forwarder(self, tmp_path): + config = tmp_path / "settings.json" + + discover_script = guard_module._discover_script_path(config) + guard_module._copy_hook_script(discover_script) + + assert discover_script.read_text() == ( + "#!/usr/bin/env bash\nset -euo pipefail\n" + '[[ -n "${MACHINE_ID:-}" ]] || exit 0\n' + '[[ -n "${AGENT_SCAN_COMMAND:-}" ]] || exit 0\n' + 'if [[ -x "$AGENT_SCAN_COMMAND" ]]; then\n' + ' "$AGENT_SCAN_COMMAND" guard discover "$@" >/dev/null 2>&1 || true\n' + "else\n" + ' eval "$AGENT_SCAN_COMMAND guard discover \\"\\$@\\"" >/dev/null 2>&1 || true\n' + "fi\n" + "exit 0\n" + ) + assert os.access(discover_script, os.X_OK) + + def test_copy_reports_discovery_script_checksums(self, tmp_path): + import hashlib + + config = tmp_path / "settings.json" + discover_script = guard_module._discover_script_path(config) + script = guard_module._copy_hook_script(discover_script) + + assert script.current_checksum is None + assert script.new_checksum == hashlib.sha256(discover_script.read_bytes()).hexdigest() + + discover_script.write_text("stale discovery script\n") + script = guard_module._copy_hook_script(discover_script) + + assert script.current_checksum == hashlib.sha256(b"stale discovery script\n").hexdigest() + assert script.new_checksum == hashlib.sha256(discover_script.read_bytes()).hexdigest() + + def test_stale_absolute_command_does_not_fall_back_to_path(self, tmp_path): + script = Path(guard_module.__file__).parent / "hooks" / "snyk-agent-guard-discover.sh" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + stub = bin_dir / "snyk-agent-scan" + marker = tmp_path / "invoked" + stub.write_text('#!/bin/sh\nprintf "%s\\n" "$*" > "$MARKER"\n') + stub.chmod(0o755) + env = { + **os.environ, + "AGENT_SCAN_COMMAND": str(tmp_path / "deleted" / "snyk-agent-scan"), + "MACHINE_ID": "machine-42", + "MARKER": str(marker), + "PATH": f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}", + } + + result = subprocess.run( + ["bash", str(script), "--client", "claude-code"], + input="{}", + text=True, + capture_output=True, + timeout=5, + env=env, + ) + + assert result.returncode == 0 + assert not marker.exists() + + def test_unset_command_does_not_fall_back_to_path(self, tmp_path): + script = Path(guard_module.__file__).parent / "hooks" / "snyk-agent-guard-discover.sh" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + stub = bin_dir / "snyk-agent-scan" + marker = tmp_path / "invoked" + stub.write_text(f"#!/bin/sh\ntouch '{marker}'\n") + stub.chmod(0o755) + env = { + **os.environ, + "MACHINE_ID": "machine-42", + "PATH": f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}", + } + env.pop("AGENT_SCAN_COMMAND", None) + + result = subprocess.run( + ["bash", str(script), "--client", "claude-code"], + input="{}", + text=True, + capture_output=True, + timeout=5, + env=env, + ) + + assert result.returncode == 0 + assert not marker.exists() + + def test_multi_word_command_receives_prefix_and_hook_arguments(self, tmp_path): + script = Path(guard_module.__file__).parent / "hooks" / "snyk-agent-guard-discover.sh" + marker = tmp_path / "invoked" + stub = tmp_path / "runner" + stub.write_text('#!/bin/sh\nprintf "%s\\n" "$*" > "$MARKER"\n') + stub.chmod(0o755) + + result = subprocess.run( + ["bash", str(script), "--client", "claude-code", "--scope", "servers"], + input="{}", + text=True, + capture_output=True, + timeout=5, + env={ + **os.environ, + "AGENT_SCAN_COMMAND": f"{stub} arg1", + "MACHINE_ID": "machine-42", + "MARKER": str(marker), + }, + ) + + assert result.returncode == 0 + assert marker.read_text() == "arg1 guard discover --client claude-code --scope servers\n" + + def test_shell_syntax_command_runs_from_requested_directory(self, tmp_path): + script = Path(guard_module.__file__).parent / "hooks" / "snyk-agent-guard-discover.sh" + marker = tmp_path / "invoked" + stub = tmp_path / "runner" + stub.write_text('#!/bin/sh\nprintf "%s\\n" "$*" > "$MARKER"\n') + stub.chmod(0o755) + + result = subprocess.run( + ["bash", str(script), "--client", "cursor", "--scope", "servers"], + input="{}", + text=True, + capture_output=True, + timeout=5, + env={ + **os.environ, + "AGENT_SCAN_COMMAND": f"cd {_shell_quote(str(tmp_path))}; ./runner", + "MACHINE_ID": "machine-42", + "MARKER": str(marker), + }, + ) + + assert result.returncode == 0 + assert marker.read_text() == "guard discover --client cursor --scope servers\n" + + def test_executable_path_with_spaces_is_invoked_verbatim(self, tmp_path): + script = Path(guard_module.__file__).parent / "hooks" / "snyk-agent-guard-discover.sh" + marker = tmp_path / "invoked" + stub_dir = tmp_path / "dir with spaces" + stub_dir.mkdir() + stub = stub_dir / "runner" + stub.write_text('#!/bin/sh\nprintf "%s\\n" "$*" > "$MARKER"\n') + stub.chmod(0o755) + + result = subprocess.run( + ["bash", str(script), "--client", "codex", "--scope", "servers"], + input="{}", + text=True, + capture_output=True, + timeout=5, + env={ + **os.environ, + "AGENT_SCAN_COMMAND": str(stub), + "MACHINE_ID": "machine-42", + "MARKER": str(marker), + }, + ) + + assert result.returncode == 0 + assert marker.read_text() == "guard discover --client codex --scope servers\n" + + def test_nonzero_discovery_exit_is_swallowed(self, tmp_path): + script = Path(guard_module.__file__).parent / "hooks" / "snyk-agent-guard-discover.sh" + stub = tmp_path / "snyk-agent-scan" + stub.write_text("#!/bin/sh\nexit 1\n") + stub.chmod(0o755) + + result = subprocess.run( + ["bash", str(script), "--client", "claude-code", "--scope", "servers"], + input="{}", + text=True, + capture_output=True, + timeout=5, + env={**os.environ, "AGENT_SCAN_COMMAND": str(stub), "MACHINE_ID": "machine-42"}, + ) + + assert result.returncode == 0 + + def test_missing_machine_id_exits_zero_without_invoking_command(self, tmp_path): + script = Path(guard_module.__file__).parent / "hooks" / "snyk-agent-guard-discover.sh" + marker = tmp_path / "invoked" + stub = tmp_path / "snyk-agent-scan" + stub.write_text(f"#!/bin/sh\ntouch '{marker}'\n") + stub.chmod(0o755) + env = {**os.environ, "AGENT_SCAN_COMMAND": str(stub)} + env.pop("MACHINE_ID", None) + + result = subprocess.run( + ["bash", str(script), "--client", "claude-code"], + input="{}", + text=True, + capture_output=True, + timeout=5, + env=env, + ) + + assert result.returncode == 0 + assert not marker.exists() + + def test_copy_restores_missing_discovery_script_when_forwarder_is_current(self, tmp_path): + config = tmp_path / "settings.json" + guard_module._copy_hook_script(guard_module._forwarder_script_path(config)) + discover_script = guard_module._discover_script_path(config) + guard_module._copy_hook_script(discover_script) + discover_script.unlink() + + guard_module._copy_hook_script(discover_script) + + assert discover_script.exists() + + def test_copy_reports_update_when_only_discovery_script_changed(self, tmp_path): + config = tmp_path / "settings.json" + main_script = guard_module._forwarder_script_path(config) + discover_script = guard_module._discover_script_path(config) + guard_module._copy_hook_script(main_script) + guard_module._copy_hook_script(discover_script) + discover_script.unlink() + + copied_discovery = guard_module._copy_hook_script(discover_script) + + assert copied_discovery.updated is True + + def test_copy_reports_no_update_when_both_scripts_are_current(self, tmp_path): + config = tmp_path / "settings.json" + main_script = guard_module._forwarder_script_path(config) + discover_script = guard_module._discover_script_path(config) + guard_module._copy_hook_script(main_script) + guard_module._copy_hook_script(discover_script) + + copied_main = guard_module._copy_hook_script(main_script) + copied_discovery = guard_module._copy_hook_script(discover_script) + + assert copied_main.updated is False + assert copied_discovery.updated is False + + def test_remove_deletes_both_scripts(self, tmp_path): + config = tmp_path / "settings.json" + main_script = guard_module._forwarder_script_path(config) + discover_script = guard_module._discover_script_path(config) + guard_module._copy_hook_script(main_script) + guard_module._copy_hook_script(discover_script) + + guard_module._remove_hook_script("claude", config) + + assert not main_script.exists() + assert not discover_script.exists() + + def test_full_claude_install_shape_then_uninstall_removes_entries_and_scripts(self, tmp_path): + config = tmp_path / "settings.json" + discover_command = ( + "PUSH_KEY='pk' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' bash '/x/snyk-agent-guard-discover.sh'" + ) + settings, _, preserved = _prepare_claude_config( + AGENT_SCAN_CMD, + config, + discover_command=discover_command, + ) + _write_config(settings, config, preserved) + main_script = guard_module._forwarder_script_path(config) + discover_script = guard_module._discover_script_path(config) + guard_module._copy_hook_script(main_script) + guard_module._copy_hook_script(discover_script) + + _run_uninstall(SimpleNamespace(client="claude", file=str(config), managed=False)) + + assert "hooks" not in json.loads(config.read_text()) + assert not main_script.exists() + assert not discover_script.exists() + + +class TestWindowsDiscoveryHookScriptFiles: + def test_copy_writes_discovery_script_next_to_forwarder(self, tmp_path): + config = tmp_path / "settings.json" + + with patch(f"{_G}.IS_WINDOWS", True): + discover_script = guard_module._discover_script_path(config) + guard_module._copy_hook_script(discover_script) + + assert ( + discover_script.read_bytes() + == (Path(guard_module.__file__).parent / "hooks" / "snyk-agent-guard-discover.ps1").read_bytes() + ) + + def test_copy_restores_missing_script_and_reports_update(self, tmp_path): + config = tmp_path / "settings.json" + with patch(f"{_G}.IS_WINDOWS", True): + discover_script = guard_module._discover_script_path(config) + guard_module._copy_hook_script(discover_script) + discover_script.unlink() + + copied_discovery = guard_module._copy_hook_script(discover_script) + + assert discover_script.exists() + assert copied_discovery.updated is True + + def test_remove_deletes_both_scripts(self, tmp_path): + config = tmp_path / "settings.json" + with patch(f"{_G}.IS_WINDOWS", True): + main_script = guard_module._forwarder_script_path(config) + discover_script = guard_module._discover_script_path(config) + guard_module._copy_hook_script(main_script) + guard_module._copy_hook_script(discover_script) + + guard_module._remove_hook_script("claude", config) + + assert not main_script.exists() + assert not discover_script.exists() + + class TestParseCommandInfo: def test_full_command(self): info = _parse_command_info(AGENT_SCAN_CMD, ["PreToolUse", "Stop"]) @@ -274,12 +1241,12 @@ def test_no_tenant(self): class TestUninstallClaude: def test_missing_file(self, tmp_path): path = tmp_path / "settings.json" - _uninstall_claude(path) # should not raise + _uninstall_test_client("claude", path) # should not raise def test_no_hooks_key(self, tmp_path): path = tmp_path / "settings.json" _write(path, {"allowedTools": ["Bash"]}) - _uninstall_claude(path) + _uninstall_test_client("claude", path) data = json.loads(path.read_text()) assert data == {"allowedTools": ["Bash"]} @@ -287,7 +1254,7 @@ def test_no_hooks_key(self, tmp_path): def test_no_agent_scan_hooks(self, tmp_path): path = tmp_path / "settings.json" _write(path, {"hooks": {"PreToolUse": [_claude_group(OTHER_CMD, "*")]}}) - _uninstall_claude(path) + _uninstall_test_client("claude", path) data = json.loads(path.read_text()) assert len(data["hooks"]["PreToolUse"]) == 1 @@ -306,7 +1273,7 @@ def test_removes_only_agent_scan(self, tmp_path): } }, ) - _uninstall_claude(path) + _uninstall_test_client("claude", path) data = json.loads(path.read_text()) # PreToolUse keeps the other hook @@ -325,7 +1292,7 @@ def test_removes_hooks_key_when_empty(self, tmp_path): } }, ) - _uninstall_claude(path) + _uninstall_test_client("claude", path) data = json.loads(path.read_text()) assert "hooks" not in data @@ -343,7 +1310,7 @@ def test_preserves_agentguard(self, tmp_path): } }, ) - _uninstall_claude(path) + _uninstall_test_client("claude", path) data = json.loads(path.read_text()) assert len(data["hooks"]["PreToolUse"]) == 1 @@ -353,7 +1320,7 @@ def test_backup_created(self, tmp_path): path = tmp_path / "settings.json" original = {"hooks": {"Stop": [_claude_group(AGENT_SCAN_CMD)]}} _write(path, original) - _uninstall_claude(path) + _uninstall_test_client("claude", path) backup = Path(str(path) + ".backup") assert backup.exists() @@ -364,7 +1331,7 @@ def test_full_install_then_uninstall(self, tmp_path): path = tmp_path / "settings.json" _write(path, {"allowedTools": ["Bash"]}) _setup_claude_hooks(AGENT_SCAN_CMD, path) - _uninstall_claude(path) + _uninstall_test_client("claude", path) data = json.loads(path.read_text()) assert "hooks" not in data @@ -470,12 +1437,12 @@ def test_invalid_json(self, tmp_path): class TestUninstallCursor: def test_missing_file(self, tmp_path): path = tmp_path / "hooks.json" - _uninstall_cursor(path) # should not raise + _uninstall_test_client("cursor", path) # should not raise def test_no_hooks_key(self, tmp_path): path = tmp_path / "hooks.json" _write(path, {"version": 1}) - _uninstall_cursor(path) + _uninstall_test_client("cursor", path) data = json.loads(path.read_text()) assert data == {"version": 1} @@ -483,7 +1450,7 @@ def test_no_hooks_key(self, tmp_path): def test_no_agent_scan_hooks(self, tmp_path): path = tmp_path / "hooks.json" _write(path, {"version": 1, "hooks": {"stop": [_cursor_entry(CURSOR_OTHER_CMD)]}}) - _uninstall_cursor(path) + _uninstall_test_client("cursor", path) data = json.loads(path.read_text()) assert len(data["hooks"]["stop"]) == 1 @@ -503,7 +1470,7 @@ def test_removes_only_agent_scan(self, tmp_path): }, }, ) - _uninstall_cursor(path) + _uninstall_test_client("cursor", path) data = json.loads(path.read_text()) assert len(data["hooks"]["stop"]) == 1 @@ -513,7 +1480,7 @@ def test_removes_only_agent_scan(self, tmp_path): def test_leaves_empty_hooks_when_all_removed(self, tmp_path): path = tmp_path / "hooks.json" _write(path, {"version": 1, "hooks": {"stop": [_cursor_entry(CURSOR_AGENT_SCAN_CMD)]}}) - _uninstall_cursor(path) + _uninstall_test_client("cursor", path) data = json.loads(path.read_text()) assert data["hooks"] == {} @@ -532,7 +1499,7 @@ def test_preserves_agentguard(self, tmp_path): }, }, ) - _uninstall_cursor(path) + _uninstall_test_client("cursor", path) data = json.loads(path.read_text()) assert len(data["hooks"]["stop"]) == 1 @@ -542,7 +1509,7 @@ def test_backup_created(self, tmp_path): path = tmp_path / "hooks.json" original = {"version": 1, "hooks": {"stop": [_cursor_entry(CURSOR_AGENT_SCAN_CMD)]}} _write(path, original) - _uninstall_cursor(path) + _uninstall_test_client("cursor", path) backup = Path(str(path) + ".backup") assert backup.exists() @@ -551,7 +1518,7 @@ def test_backup_created(self, tmp_path): def test_full_install_then_uninstall(self, tmp_path): path = tmp_path / "hooks.json" _setup_cursor_hooks(CURSOR_AGENT_SCAN_CMD, path) - _uninstall_cursor(path) + _uninstall_test_client("cursor", path) data = json.loads(path.read_text()) assert data["hooks"] == {} @@ -799,7 +1766,7 @@ def test_detect_at_managed_path(self, tmp_path): def test_uninstall_from_managed_path(self, tmp_path): path = tmp_path / "managed-settings.json" _setup_claude_hooks(AGENT_SCAN_CMD, path) - _uninstall_claude(path) + _uninstall_test_client("claude", path) data = json.loads(path.read_text()) assert "hooks" not in data @@ -827,7 +1794,7 @@ def test_detect_at_managed_path(self, tmp_path): def test_uninstall_from_managed_path(self, tmp_path): path = tmp_path / "hooks.json" _setup_cursor_hooks(CURSOR_AGENT_SCAN_CMD, path) - _uninstall_cursor(path) + _uninstall_test_client("cursor", path) data = json.loads(path.read_text()) assert data["hooks"] == {} @@ -859,7 +1826,18 @@ def test_detect_cursor_raises_on_unreadable(self, tmp_path): path.chmod(0o000) try: with pytest.raises(PermissionError): - _detect_cursor_install(path) + _detect_cursor_install(path) + finally: + path.chmod(0o644) + + @pytest.mark.skipif(sys.platform == "win32", reason="chmod has no effect on Windows") + def test_detect_codex_raises_on_unreadable(self, tmp_path): + path = tmp_path / "hooks.json" + _write(path, {"hooks": {"PreToolUse": [_claude_group(CODEX_AGENT_SCAN_CMD)]}}) + path.chmod(0o000) + try: + with pytest.raises(PermissionError): + _detect_codex_install(path) finally: path.chmod(0o644) @@ -887,6 +1865,125 @@ def test_print_client_status_installed(self, tmp_path, capsys): assert "INSTALLED" in output +class TestRunStatus: + @staticmethod + def _info() -> dict: + return { + "host": "guard.example", + "auth_type": "pushkey", + "auth_value": "pk-1234567890", + "tenant_id": "tid-1", + "url": "https://guard.example", + "events": ["PreToolUse", "Stop"], + } + + def test_prints_user_then_managed_sections_in_client_order(self, capsys): + with ( + patch(f"{_G}._detect_claude_install", return_value=None), + patch(f"{_G}._detect_cursor_install", return_value=None), + patch(f"{_G}._detect_codex_install", return_value=None), + ): + guard_module._run_status() + + output = capsys.readouterr().out + expected_paths = [ + CLAUDE_SETTINGS_PATH, + CURSOR_HOOKS_PATH, + CODEX_HOOKS_PATH, + CLAUDE_MANAGED_SETTINGS_PATH, + CURSOR_MANAGED_HOOKS_PATH, + CODEX_MANAGED_HOOKS_PATH, + ] + positions = [output.index(str(path)) for path in expected_paths] + assert positions == sorted(positions) + assert output.index("User-level hooks:") < positions[0] + assert positions[2] < output.index("Managed hooks:") < positions[3] + lines = output.splitlines() + assert sum(line.startswith("Claude Code ") for line in lines) == 2 + assert sum(line.startswith("Cursor ") for line in lines) == 2 + assert sum(line.startswith("Codex ") for line in lines) == 2 + + def test_all_not_installed(self, capsys): + with ( + patch(f"{_G}._detect_claude_install", return_value=None), + patch(f"{_G}._detect_cursor_install", return_value=None), + patch(f"{_G}._detect_codex_install", return_value=None), + ): + guard_module._run_status() + + assert capsys.readouterr().out.count("NOT INSTALLED") == 6 + + def test_installed_shows_host_masked_key_and_events(self, capsys): + with ( + patch(f"{_G}._detect_claude_install", return_value=self._info()), + patch(f"{_G}._detect_cursor_install", return_value=None), + patch(f"{_G}._detect_codex_install", return_value=None), + ): + guard_module._run_status() + + output = capsys.readouterr().out + assert "guard.example" in output + assert "pk-1...7890" in output + assert "(PreToolUse, Stop)" in output + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + def test_managed_permission_error_renders_unreadable(self, capsys, client): + def detector(name): + def detect(*args): + if name == client and args: + raise PermissionError("denied") + return None + + return detect + + with ( + patch(f"{_G}._detect_claude_install", side_effect=detector("claude")), + patch(f"{_G}._detect_cursor_install", side_effect=detector("cursor")), + patch(f"{_G}._detect_codex_install", side_effect=detector("codex")), + ): + guard_module._run_status() + + assert capsys.readouterr().out.count("UNREADABLE") == 1 + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + def test_user_level_permission_error_propagates(self, capsys, client): + def detector(name): + def detect(*_args): + if name == client: + raise PermissionError("denied") + return None + + return detect + + with ( + patch(f"{_G}._detect_claude_install", side_effect=detector("claude")), + patch(f"{_G}._detect_cursor_install", side_effect=detector("cursor")), + patch(f"{_G}._detect_codex_install", side_effect=detector("codex")), + ): + with pytest.raises(PermissionError, match="denied"): + guard_module._run_status() + capsys.readouterr() + + result = guard_module.run_guard(SimpleNamespace(guard_command="status")) + + assert result == 1 + assert "Permission denied: denied" in capsys.readouterr().out + + def test_help_footer_present(self, capsys): + with ( + patch(f"{_G}._detect_claude_install", return_value=None), + patch(f"{_G}._detect_cursor_install", return_value=None), + patch(f"{_G}._detect_codex_install", return_value=None), + ): + guard_module._run_status() + + output = capsys.readouterr().out + assert "interactive flow (user-level)" in output + assert "managed flow" in output + assert "headless flow (MDM)" in output + assert "guard uninstall " in output + + # =================================================================== # Preflight writability check # =================================================================== @@ -971,11 +2068,32 @@ def _get_script_path(name: str) -> Path: "PUSH_KEY='pk-codex' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' " "TENANT_ID='tid-1' bash '/home/u/.codex/hooks/snyk-agent-guard.sh' --client codex" ) +CODEX_DISCOVER_CMD = ( + "PUSH_KEY='pk-discover' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' " + "AGENT_SCAN_COMMAND='/usr/local/bin/snyk-agent-scan' " + "bash '/home/u/.codex/hooks/snyk-agent-guard-discover.sh' --client codex --scope servers" +) class TestUninstallCodex: def test_missing_file(self, tmp_path): - _uninstall_codex(tmp_path / "hooks.json") # should not raise + _uninstall_test_client("codex", tmp_path / "hooks.json") # should not raise + + def test_no_hooks_key(self, tmp_path): + path = tmp_path / "hooks.json" + _write(path, {"allowedTools": ["Bash"]}) + _uninstall_test_client("codex", path) + + data = json.loads(path.read_text()) + assert data == {"allowedTools": ["Bash"]} + + def test_no_agent_scan_hooks(self, tmp_path): + path = tmp_path / "hooks.json" + _write(path, {"hooks": {"PreToolUse": [_claude_group(OTHER_CMD)]}}) + _uninstall_test_client("codex", path) + + data = json.loads(path.read_text()) + assert len(data["hooks"]["PreToolUse"]) == 1 def test_removes_only_agent_scan(self, tmp_path): path = tmp_path / "hooks.json" @@ -988,7 +2106,7 @@ def test_removes_only_agent_scan(self, tmp_path): } }, ) - _uninstall_codex(path) + _uninstall_test_client("codex", path) data = json.loads(path.read_text()) assert len(data["hooks"]["PreToolUse"]) == 1 @@ -998,16 +2116,45 @@ def test_removes_only_agent_scan(self, tmp_path): def test_removes_hooks_key_when_empty(self, tmp_path): path = tmp_path / "hooks.json" _write(path, {"hooks": {"PreToolUse": [_claude_group(CODEX_AGENT_SCAN_CMD)]}}) - _uninstall_codex(path) + _uninstall_test_client("codex", path) data = json.loads(path.read_text()) assert "hooks" not in data + def test_preserves_agentguard(self, tmp_path): + path = tmp_path / "hooks.json" + _write( + path, + { + "hooks": { + "PreToolUse": [ + _claude_group(AGENTGUARD_CMD), + _claude_group(CODEX_AGENT_SCAN_CMD), + ], + } + }, + ) + _uninstall_test_client("codex", path) + + data = json.loads(path.read_text()) + assert len(data["hooks"]["PreToolUse"]) == 1 + assert data["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == AGENTGUARD_CMD + + def test_backup_created(self, tmp_path): + path = tmp_path / "hooks.json" + original = {"hooks": {"Stop": [_claude_group(CODEX_AGENT_SCAN_CMD)]}} + _write(path, original) + _uninstall_test_client("codex", path) + + backup = Path(str(path) + ".backup") + assert backup.exists() + assert json.loads(backup.read_text()) == original + def test_full_install_then_uninstall(self, tmp_path): path = tmp_path / "hooks.json" _write(path, {"unrelated": True}) _setup_codex_hooks(CODEX_AGENT_SCAN_CMD, path) - _uninstall_codex(path) + _uninstall_test_client("codex", path) data = json.loads(path.read_text()) assert "hooks" not in data @@ -1018,6 +2165,11 @@ class TestDetectCodex: def test_missing_file(self, tmp_path): assert _detect_codex_install(tmp_path / "nope.json") is None + def test_empty_file(self, tmp_path): + path = tmp_path / "hooks.json" + _write(path, {}) + assert _detect_codex_install(path) is None + def test_no_hooks_key(self, tmp_path): path = tmp_path / "hooks.json" _write(path, {"other": 1}) @@ -1040,6 +2192,294 @@ def test_detects_after_install(self, tmp_path): assert info["host"] == "api.snyk.io" assert set(info["events"]) == set(CODEX_HOOK_EVENTS) + def test_detects_partial_install(self, tmp_path): + """Only some events have our hooks.""" + path = tmp_path / "hooks.json" + _write( + path, + { + "hooks": { + "PreToolUse": [_claude_group(CODEX_AGENT_SCAN_CMD)], + "Stop": [_claude_group(CODEX_AGENT_SCAN_CMD)], + } + }, + ) + info = _detect_codex_install(path) + assert info is not None + assert info["events"] == ["PreToolUse", "Stop"] + + def test_ignores_agentguard(self, tmp_path): + path = tmp_path / "hooks.json" + _write(path, {"hooks": {"PreToolUse": [_claude_group(AGENTGUARD_CMD)]}}) + assert _detect_codex_install(path) is None + + def test_detects_among_other_hooks(self, tmp_path): + path = tmp_path / "hooks.json" + _write( + path, + { + "hooks": { + "PreToolUse": [ + _claude_group(AGENTGUARD_CMD), + _claude_group(CODEX_AGENT_SCAN_CMD), + ], + } + }, + ) + info = _detect_codex_install(path) + assert info is not None + assert info["events"] == ["PreToolUse"] + + def test_invalid_json(self, tmp_path): + path = tmp_path / "hooks.json" + path.write_text("not json at all") + with pytest.raises(json.JSONDecodeError): + _detect_codex_install(path) + + +class TestDetectInstall: + @staticmethod + def _events(client: str) -> list[str]: + return { + "claude": CLAUDE_HOOK_EVENTS, + "cursor": CURSOR_HOOK_EVENTS, + "codex": CODEX_HOOK_EVENTS, + }[client] + + @staticmethod + def _entries(client: str, *commands: str) -> list[dict]: + if client == "cursor": + return [_cursor_entry(command) for command in commands] + return [_claude_group(command) for command in commands] + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + def test_events_follow_constant_order_not_file_order(self, tmp_path, client): + first, last = self._events(client)[0], self._events(client)[-1] + path = tmp_path / "hooks.json" + _write( + path, + { + "hooks": { + last: self._entries(client, CODEX_AGENT_SCAN_CMD), + first: self._entries(client, CODEX_AGENT_SCAN_CMD), + } + }, + ) + + info = _detect_test_client(client, path) + + assert info is not None + assert info["events"] == [first, last] + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + def test_event_listed_once_when_multiple_commands_match(self, tmp_path, client): + event = self._events(client)[0] + path = tmp_path / "hooks.json" + _write(path, {"hooks": {event: self._entries(client, CODEX_AGENT_SCAN_CMD, CODEX_DISCOVER_CMD)}}) + + info = _detect_test_client(client, path) + + assert info is not None + assert info["events"] == [event] + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + def test_first_match_in_constant_order_supplies_parsed_command(self, tmp_path, client): + first, last = self._events(client)[0], self._events(client)[-1] + first_command = ( + "PUSH_KEY='pk-first' REMOTE_HOOKS_BASE_URL='https://first.example' " + "bash '/x/snyk-agent-guard.sh' --client test" + ) + later_discovery_command = ( + "PUSH_KEY='pk-later' REMOTE_HOOKS_BASE_URL='https://later.example' " + "bash '/x/snyk-agent-guard-discover.sh' --client test --scope servers" + ) + path = tmp_path / "hooks.json" + _write( + path, + { + "hooks": { + last: self._entries(client, later_discovery_command), + first: self._entries(client, first_command), + } + }, + ) + + info = _detect_test_client(client, path) + + assert info is not None + assert info["auth_value"] == "pk-first" + assert info["host"] == "first.example" + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + def test_group_without_hooks_key(self, tmp_path, client): + event = self._events(client)[0] + path = tmp_path / "hooks.json" + _write(path, {"hooks": {event: [{"type": "command"}]}}) + + assert _detect_test_client(client, path) is None + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + def test_entry_without_command_key(self, tmp_path, client): + event = self._events(client)[0] + entries = [{"hooks": [{"type": "command"}]}] if client != "cursor" else [{"other": True}] + path = tmp_path / "hooks.json" + _write(path, {"hooks": {event: entries}}) + + assert _detect_test_client(client, path) is None + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + @pytest.mark.parametrize("entry", ["string", None]) + def test_non_dict_entry_in_event_list(self, tmp_path, client, entry): + event = self._events(client)[0] + path = tmp_path / "hooks.json" + _write(path, {"hooks": {event: [entry]}}) + + with pytest.raises(AttributeError): + _detect_test_client(client, path) + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + def test_unknown_events_ignored(self, tmp_path, client): + path = tmp_path / "hooks.json" + _write(path, {"hooks": {"CustomEvent": self._entries(client, CODEX_AGENT_SCAN_CMD)}}) + + assert _detect_test_client(client, path) is None + + def test_claude_shaped_file_read_by_cursor_detector_returns_none(self, tmp_path): + path = tmp_path / "hooks.json" + _write(path, {"hooks": {"stop": [_claude_group(CODEX_AGENT_SCAN_CMD)]}}) + + assert _detect_cursor_install(path) is None + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + def test_permission_error_propagates(self, tmp_path, client): + path = tmp_path / "hooks.json" + _write(path, {}) + with patch(f"{_G}._read_json_or_empty", side_effect=PermissionError("denied")): + with pytest.raises(PermissionError, match="denied"): + _detect_test_client(client, path) + + +class TestUninstallHooks: + @staticmethod + def _event_and_entry(client: str, command: str) -> tuple[str, dict]: + if client == "cursor": + return "stop", _cursor_entry(command) + return "Stop", _claude_group(command) + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + def test_removes_both_forwarder_and_discover_commands(self, tmp_path, capsys, client): + path = tmp_path / ("settings.json" if client == "claude" else "hooks.json") + prepare = { + "claude": _prepare_claude_config, + "cursor": _prepare_cursor_config, + "codex": _prepare_codex_config, + }[client] + config, _, preserved = prepare(CODEX_AGENT_SCAN_CMD, path, discover_command=CODEX_DISCOVER_CMD) + _write_config(config, path, preserved) + capsys.readouterr() + + _uninstall_test_client(client, path) + + expected_removed = len(self._events_for(client)) + 1 + assert f"Removed {expected_removed} Agent Guard hook(s)" in capsys.readouterr().out + + @staticmethod + def _events_for(client: str) -> list[str]: + return { + "claude": CLAUDE_HOOK_EVENTS, + "cursor": CURSOR_HOOK_EVENTS, + "codex": CODEX_HOOK_EVENTS, + }[client] + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + @pytest.mark.parametrize("filename", ["settings.json", "managed-settings.json", "custom.json"]) + def test_missing_file_message_names_the_actual_file(self, tmp_path, capsys, client, filename): + _uninstall_test_client(client, tmp_path / filename) + + assert f"No {filename} found. Nothing to uninstall." in capsys.readouterr().out + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + def test_overwrites_existing_backup(self, tmp_path, client): + event, entry = self._event_and_entry(client, CODEX_AGENT_SCAN_CMD) + path = tmp_path / ("settings.json" if client == "claude" else "hooks.json") + original = {"hooks": {event: [entry]}, "current": True} + _write(path, original) + backup = Path(f"{path}.backup") + backup.write_text("stale backup") + + _uninstall_test_client(client, path) + + assert json.loads(backup.read_text()) == original + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + def test_no_backup_when_nothing_matched(self, tmp_path, client): + event, entry = self._event_and_entry(client, OTHER_CMD) + path = tmp_path / ("settings.json" if client == "claude" else "hooks.json") + _write(path, {"hooks": {event: [entry]}}) + + _uninstall_test_client(client, path) + + assert not Path(f"{path}.backup").exists() + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + @pytest.mark.parametrize("entry", ["string", None]) + def test_survives_non_dict_entries_in_hook_list(self, tmp_path, client, entry): + event, _ = self._event_and_entry(client, OTHER_CMD) + path = tmp_path / ("settings.json" if client == "claude" else "hooks.json") + original = {"hooks": {event: [entry]}} + _write(path, original) + + with pytest.raises(AttributeError): + _uninstall_test_client(client, path) + + assert json.loads(path.read_text()) == original + assert not Path(f"{path}.backup").exists() + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + @pytest.mark.parametrize("hooks", [[], "x"]) + def test_hooks_value_not_a_dict(self, tmp_path, client, hooks): + path = tmp_path / ("settings.json" if client == "claude" else "hooks.json") + _write(path, {"hooks": hooks}) + + with pytest.raises(AttributeError): + _uninstall_test_client(client, path) + + def test_codex_toml_path_routes_to_managed_uninstall(self, tmp_path): + path = tmp_path / "requirements.toml" + args = SimpleNamespace(file=str(path)) + with ( + patch(f"{_G}._detect_existing_install", return_value=None), + patch(f"{_G}._uninstall_hooks") as uninstall, + patch(f"{_G}._uninstall_codex_managed") as uninstall_managed, + patch(f"{_G}._remove_hook_script"), + patch(f"{_G}.rich"), + ): + guard_module._uninstall_single_client("codex", args, managed=True) + + uninstall.assert_not_called() + uninstall_managed.assert_called_once_with(path) + + @pytest.mark.parametrize("client", ["claude", "cursor", "codex"]) + def test_preserves_unknown_events(self, tmp_path, client): + known_event, guard_entry = self._event_and_entry(client, CODEX_AGENT_SCAN_CMD) + _, foreign_entry = self._event_and_entry(client, OTHER_CMD) + path = tmp_path / ("settings.json" if client == "claude" else "hooks.json") + _write( + path, + { + "hooks": { + known_event: [guard_entry], + "CustomEvent": [foreign_entry], + } + }, + ) + + _uninstall_test_client(client, path) + + data = json.loads(path.read_text()) + assert known_event not in data["hooks"] + assert data["hooks"]["CustomEvent"] == [foreign_entry] + # =================================================================== # Codex managed: requirements.toml install / uninstall / detect @@ -1054,29 +2494,198 @@ def _import_managed_helpers(self): _uninstall_codex_managed, ) - def _install(command, path, _script=None): - content, _ = _prepare_codex_managed_config(command, path) - return _write_codex_managed_config(content, path) + def _install(command, path, _script=None, *, discover_command=None): + content, _ = _prepare_codex_managed_config( + command, + path, + discover_command=discover_command, + ) + return _write_codex_managed_config(content, path) + + return ( + _install, + _uninstall_codex_managed, + _detect_codex_managed_install, + _render_codex_requirements_toml, + ) + + def test_render_contains_features_and_all_events(self, tmp_path): + _, _, _, render = self._import_managed_helpers() + path = tmp_path / "requirements.toml" + content = render(CODEX_AGENT_SCAN_CMD, path) + assert "[features]" in content + assert "hooks = true" in content + assert "[hooks]" in content + assert "managed_dir" in content + assert "windows_managed_dir" in content + for event in CODEX_HOOK_EVENTS: + assert f"[[hooks.{event}]]" in content + assert f"[[hooks.{event}.hooks]]" in content + + def test_render_adds_async_discovery_group_only_to_session_start(self, tmp_path): + _, _, _, render = self._import_managed_helpers() + + content = render( + CODEX_AGENT_SCAN_CMD, + tmp_path / "requirements.toml", + discover_command=CODEX_DISCOVER_CMD, + ) + + assert content.count("[[hooks.SessionStart]]") == 2 + assert CODEX_DISCOVER_CMD in content + assert f'command = "{CODEX_DISCOVER_CMD}"\nasync = true' in content + for event in set(CODEX_HOOK_EVENTS) - {"SessionStart"}: + assert content.count(f"[[hooks.{event}]]") == 1 + + def test_render_none_preserves_existing_output(self, tmp_path): + _, _, _, render = self._import_managed_helpers() + path = tmp_path / "requirements.toml" + if IS_WINDOWS: + managed_dir = "/etc/codex/hooks" + windows_managed_dir = str(tmp_path / "hooks") + else: + managed_dir = (tmp_path / "hooks").as_posix() + windows_managed_dir = r"C:\ProgramData\OpenAI\Codex\hooks" + expected_lines = [ + "[features]", + "hooks = true", + "", + "[hooks]", + f"managed_dir = {json.dumps(managed_dir)}", + f"windows_managed_dir = {json.dumps(windows_managed_dir)}", + "", + ] + for event in CODEX_HOOK_EVENTS: + expected_lines.extend( + [ + f"[[hooks.{event}]]", + f"[[hooks.{event}.hooks]]", + 'type = "command"', + f'command = "{CODEX_AGENT_SCAN_CMD}"', + "", + ] + ) + + assert render(CODEX_AGENT_SCAN_CMD, path, discover_command=None) == "\n".join(expected_lines).rstrip() + "\n" + + def test_rendered_discovery_toml_is_valid(self, tmp_path): + tomllib = pytest.importorskip("tomllib") + _, _, _, render = self._import_managed_helpers() + + parsed = tomllib.loads( + render( + CODEX_AGENT_SCAN_CMD, + tmp_path / "requirements.toml", + discover_command=CODEX_DISCOVER_CMD, + ) + ) + + assert len(parsed["hooks"]["SessionStart"]) == 2 + assert parsed["hooks"]["SessionStart"][1]["hooks"] == [ + {"type": "command", "command": CODEX_DISCOVER_CMD, "async": True} + ] + + def test_rendered_guard_command_with_controls_round_trips_through_tomllib(self, tmp_path): + tomllib = pytest.importorskip("tomllib") + _, _, _, render = self._import_managed_helpers() + guard_command = CODEX_AGENT_SCAN_CMD.replace( + "TENANT_ID='tid-1'", + "TENANT_ID='tid-1\n\t\"quoted\" C:\\hooks'", + ) + + parsed = tomllib.loads(render(guard_command, tmp_path / "requirements.toml")) + + assert parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == guard_command + + def test_rendered_discovery_command_with_controls_round_trips_through_tomllib(self, tmp_path): + tomllib = pytest.importorskip("tomllib") + _, _, _, render = self._import_managed_helpers() + discover_command = CODEX_DISCOVER_CMD.replace( + "AGENT_SCAN_COMMAND='/usr/local/bin/snyk-agent-scan'", + "AGENT_SCAN_COMMAND='snyk-agent-scan\n\t\"quoted\" C:\\hooks'", + ) + + parsed = tomllib.loads( + render( + CODEX_AGENT_SCAN_CMD, + tmp_path / "requirements.toml", + discover_command=discover_command, + ) + ) + + assert parsed["hooks"]["SessionStart"][1]["hooks"][0]["command"] == discover_command + + def test_rendered_managed_directories_round_trip_quotes_and_backslashes(self, tmp_path): + tomllib = pytest.importorskip("tomllib") + _, _, _, render = self._import_managed_helpers() + path = tmp_path / 'O\'Brien "QA"' / "requirements.toml" + expected_managed_dir, expected_windows_managed_dir = guard_module._codex_managed_dirs(path) + + parsed = tomllib.loads(render(CODEX_AGENT_SCAN_CMD, path)) + + assert parsed["hooks"]["managed_dir"] == expected_managed_dir + assert parsed["hooks"]["windows_managed_dir"] == expected_windows_managed_dir + + def test_render_parse_round_trip_splits_guard_and_discovery_commands(self, tmp_path): + _, _, _, render = self._import_managed_helpers() + content = render( + CODEX_AGENT_SCAN_CMD, + tmp_path / "requirements.toml", + discover_command=CODEX_DISCOVER_CMD, + ) + + events, guard_command, discover_command = _parse_codex_requirements_toml(content) - return ( - _install, - _uninstall_codex_managed, - _detect_codex_managed_install, - _render_codex_requirements_toml, - ) + assert events == CODEX_HOOK_EVENTS + assert guard_command == CODEX_AGENT_SCAN_CMD + assert discover_command == CODEX_DISCOVER_CMD - def test_render_contains_features_and_all_events(self, tmp_path): + def test_render_parse_round_trip_preserves_control_characters(self, tmp_path): _, _, _, render = self._import_managed_helpers() + guard_command = CODEX_AGENT_SCAN_CMD.replace( + "TENANT_ID='tid-1'", + "TENANT_ID='tid-1\b\t\n\f\r\x00\x1f\x7f\"\\'", + ) + discover_command = CODEX_DISCOVER_CMD.replace( + "AGENT_SCAN_COMMAND='/usr/local/bin/snyk-agent-scan'", + "AGENT_SCAN_COMMAND='snyk-agent-scan\b\t\n\f\r\x00\x1f\x7f\"\\'", + ) + + content = render( + guard_command, + tmp_path / "requirements.toml", + discover_command=discover_command, + ) + + events, parsed_guard, parsed_discover = _parse_codex_requirements_toml(content) + assert events == CODEX_HOOK_EVENTS + assert parsed_guard == guard_command + assert parsed_discover == discover_command + + def test_write_then_prepare_same_control_commands_has_no_modified_diff(self, tmp_path): path = tmp_path / "requirements.toml" - content = render(CODEX_AGENT_SCAN_CMD, path) - assert "[features]" in content - assert "hooks = true" in content - assert "[hooks]" in content - assert "managed_dir" in content - assert "windows_managed_dir" in content - for event in CODEX_HOOK_EVENTS: - assert f"[[hooks.{event}]]" in content - assert f"[[hooks.{event}.hooks]]" in content + guard_command = CODEX_AGENT_SCAN_CMD.replace( + "TENANT_ID='tid-1'", + "TENANT_ID='tid-1\b\t\n\f\r\x00\x1f\x7f\"\\'", + ) + discover_command = CODEX_DISCOVER_CMD.replace( + "AGENT_SCAN_COMMAND='/usr/local/bin/snyk-agent-scan'", + "AGENT_SCAN_COMMAND='snyk-agent-scan\b\t\n\f\r\x00\x1f\x7f\"\\'", + ) + content, _ = _prepare_codex_managed_config( + guard_command, + path, + discover_command=discover_command, + ) + _write_codex_managed_config(content, path) + + _, diff = _prepare_codex_managed_config( + guard_command, + path, + discover_command=discover_command, + ) + + assert diff == {"added": {}, "modified": {}, "removed": {}} def test_install_writes_toml(self, tmp_path): install, _, _, _ = self._import_managed_helpers() @@ -1095,6 +2704,66 @@ def test_install_idempotent(self, tmp_path): install(CODEX_AGENT_SCAN_CMD, path, script) assert install(CODEX_AGENT_SCAN_CMD, path, script) is False + def test_guard_install_writes_discovery_script_and_toml_entry(self, tmp_path): + path = tmp_path / "requirements.toml" + + with ( + patch(f"{_G}.IS_WINDOWS", False), + patch(f"{_G}._send_test_event", return_value=True), + patch(f"{_G}.rich"), + patch.dict(os.environ, {"AGENT_SCAN_COMMAND": "/usr/local/bin/snyk-agent-scan"}), + ): + _install_hooks( + "codex", + "codex", + "pk-test", + "https://api.snyk.io", + path, + "managed", + "Codex", + False, + "tid-1", + "snyk-token", + "machine-42", + ) + + assert (tmp_path / "hooks" / "snyk-agent-guard.sh").exists() + assert (tmp_path / "hooks" / "snyk-agent-guard-discover.sh").exists() + text = path.read_text() + assert text.count("[[hooks.SessionStart]]") == 2 + assert "snyk-agent-guard-discover.sh" in text + assert "AGENT_SCAN_COMMAND='/usr/local/bin/snyk-agent-scan'" in text + + def test_guard_install_without_agent_scan_command_warns_and_removes_stale_discovery_script(self, tmp_path): + path = tmp_path / "requirements.toml" + discover_script = tmp_path / "hooks" / "snyk-agent-guard-discover.sh" + discover_script.parent.mkdir(parents=True) + discover_script.write_text("stale\n") + + with ( + patch(f"{_G}.IS_WINDOWS", False), + patch(f"{_G}._agent_scan_command", return_value=None), + patch(f"{_G}._send_test_event", return_value=True), + patch(f"{_G}.rich") as rich, + ): + _install_hooks( + "codex", + "codex", + "pk-test", + "https://api.snyk.io", + path, + "managed", + "Codex", + False, + "tid-1", + "snyk-token", + "machine-42", + ) + + assert not discover_script.exists() + assert "snyk-agent-guard-discover" not in path.read_text() + assert any("AGENT_SCAN_COMMAND is not set" in call.args[0] for call in rich.print.call_args_list if call.args) + def test_detect_after_install(self, tmp_path): install, _, detect, _ = self._import_managed_helpers() path = tmp_path / "requirements.toml" @@ -1107,6 +2776,64 @@ def test_detect_after_install(self, tmp_path): assert info["tenant_id"] == "tid-1" assert set(info["events"]) == set(CODEX_HOOK_EVENTS) + def test_detect_uses_guard_command_when_discovery_block_is_first(self, tmp_path): + _, _, detect, _ = self._import_managed_helpers() + path = tmp_path / "requirements.toml" + path.write_text( + "[[hooks.SessionStart]]\n" + "[[hooks.SessionStart.hooks]]\n" + 'type = "command"\n' + f'command = "{CODEX_DISCOVER_CMD}"\n' + "async = true\n\n" + "[[hooks.PreToolUse]]\n" + "[[hooks.PreToolUse.hooks]]\n" + 'type = "command"\n' + f'command = "{CODEX_AGENT_SCAN_CMD}"\n' + ) + + info = detect(path) + + assert info is not None + assert info["auth_value"] == "pk-codex" + assert info["tenant_id"] == "tid-1" + + def test_prepare_with_same_commands_is_idempotent(self, tmp_path): + path = tmp_path / "requirements.toml" + content, _ = _prepare_codex_managed_config( + CODEX_AGENT_SCAN_CMD, + path, + discover_command=CODEX_DISCOVER_CMD, + ) + path.write_text(content) + + _, diff = _prepare_codex_managed_config( + CODEX_AGENT_SCAN_CMD, + path, + discover_command=CODEX_DISCOVER_CMD, + ) + + assert diff == {"added": {}, "modified": {}, "removed": {}} + + def test_prepare_marks_discover_only_change_as_session_start_modified(self, tmp_path): + path = tmp_path / "requirements.toml" + content, _ = _prepare_codex_managed_config( + CODEX_AGENT_SCAN_CMD, + path, + discover_command=CODEX_DISCOVER_CMD, + ) + path.write_text(content) + new_discover_command = CODEX_DISCOVER_CMD.replace("/usr/local/bin", "/opt/snyk/bin") + + _, diff = _prepare_codex_managed_config( + CODEX_AGENT_SCAN_CMD, + path, + discover_command=new_discover_command, + ) + + assert set(diff["modified"]) == {"SessionStart"} + assert diff["modified"]["SessionStart"]["expected_value"][1]["command"] == new_discover_command + assert diff["modified"]["SessionStart"]["actual_value"][1]["command"] == CODEX_DISCOVER_CMD + def test_detect_dispatches_via_extension(self, tmp_path): install, _, _, _ = self._import_managed_helpers() path = tmp_path / "requirements.toml" @@ -1136,9 +2863,26 @@ def test_parse_backslash_path_no_unicode_escape(self): 'type = "command"\n' "command = \"PUSH_KEY='pk' bash 'C:\\\\Users\\\\me\\\\hooks\\\\snyk-agent-guard.sh' --client codex\"\n" ) - events, cmd = _parse_codex_requirements_toml(toml) + events, cmd, discover_cmd = _parse_codex_requirements_toml(toml) assert "PreToolUse" in events assert "C:\\Users\\me\\hooks\\snyk-agent-guard.sh" in cmd + assert discover_cmd is None + + def test_discovery_backslash_path_round_trips(self, tmp_path): + _, _, _, render = self._import_managed_helpers() + discover_command = CODEX_DISCOVER_CMD.replace( + "bash '/home/u/.codex/hooks/snyk-agent-guard-discover.sh'", + r"bash 'C:\ProgramData\OpenAI\Codex\hooks\snyk-agent-guard-discover.sh'", + ) + + content = render( + CODEX_AGENT_SCAN_CMD, + tmp_path / "requirements.toml", + discover_command=discover_command, + ) + _, _, parsed_discover = _parse_codex_requirements_toml(content) + + assert parsed_discover == discover_command def test_prepare_survives_unparseable_existing_toml(self, tmp_path): path = tmp_path / "requirements.toml" @@ -1170,6 +2914,7 @@ def test_posts_base64_payload(self, hook_server): "PATH": "/usr/bin:/bin:/usr/local/bin", "PUSH_KEY": "test-pk-123", "REMOTE_HOOKS_BASE_URL": hook_server, + "MACHINE_ID": "machine-42", }, ) assert result.returncode == 0, result.stderr @@ -1182,6 +2927,35 @@ def test_posts_base64_payload(self, hook_server): decoded = base64.b64decode(req["body"].removeprefix("base64:")) assert json.loads(decoded) == json.loads(payload) + def test_posts_large_payload_without_exec_argument_limit(self, hook_server): + script = _get_script_path("snyk-agent-guard.sh") + payload = json.dumps( + { + "hook_event_name": "hooksConfiguredServerDiscovery", + "session_id": "s1", + "servers": ["x" * (1024 * 1024)], + } + ) + result = subprocess.run( + ["bash", str(script), "--client", "claude-code"], + input=payload, + capture_output=True, + text=True, + timeout=10, + env={ + "PATH": "/usr/bin:/bin:/usr/local/bin", + "PUSH_KEY": "test-pk-large-payload", + "REMOTE_HOOKS_BASE_URL": hook_server, + "MACHINE_ID": "machine-42", + }, + ) + + assert result.returncode == 0, result.stderr + req = _HookHandler.last_request + assert req is not None + decoded = base64.b64decode(req["body"].removeprefix("base64:")) + assert json.loads(decoded) == json.loads(payload) + def test_cursor_endpoint(self, hook_server): script = _get_script_path("snyk-agent-guard.sh") payload = '{"hook_event_name":"test","conversation_id":"c1"}' @@ -1195,6 +2969,7 @@ def test_cursor_endpoint(self, hook_server): "PATH": "/usr/bin:/bin:/usr/local/bin", "PUSH_KEY": "test-pk-456", "REMOTE_HOOKS_BASE_URL": hook_server, + "MACHINE_ID": "machine-42", }, ) assert result.returncode == 0, result.stderr @@ -1213,11 +2988,48 @@ def test_codex_endpoint(self, hook_server): "PATH": "/usr/bin:/bin:/usr/local/bin", "PUSH_KEY": "test-pk-codex", "REMOTE_HOOKS_BASE_URL": hook_server, + "MACHINE_ID": "machine-42", }, ) assert result.returncode == 0, result.stderr assert "/hidden/agent-monitor/hooks/codex" in _HookHandler.last_request["path"] + def test_machine_id_sets_x_user_identifier(self, hook_server): + script = _get_script_path("snyk-agent-guard.sh") + result = subprocess.run( + ["bash", str(script), "--client", "claude-code"], + input='{"hook_event_name":"test","session_id":"s1"}', + capture_output=True, + text=True, + timeout=10, + env={ + "PATH": "/usr/bin:/bin:/usr/local/bin", + "PUSH_KEY": "test-pk", + "REMOTE_HOOKS_BASE_URL": hook_server, + "MACHINE_ID": "machine-42", + }, + ) + assert result.returncode == 0, result.stderr + x_user = json.loads(_HookHandler.last_request["headers"]["X-User"]) + assert x_user["identifier"] == "machine-42" + + def test_missing_machine_id_fails(self, hook_server): + script = _get_script_path("snyk-agent-guard.sh") + result = subprocess.run( + ["bash", str(script), "--client", "claude-code"], + input='{"hook_event_name":"test","session_id":"s1"}', + capture_output=True, + text=True, + timeout=10, + env={ + "PATH": "/usr/bin:/bin:/usr/local/bin", + "PUSH_KEY": "test-pk", + "REMOTE_HOOKS_BASE_URL": hook_server, + }, + ) + assert result.returncode != 0 + assert "MACHINE_ID" in result.stderr + def test_missing_push_key_fails(self, hook_server): script = _get_script_path("snyk-agent-guard.sh") result = subprocess.run( @@ -1245,12 +3057,140 @@ def test_missing_url_fails(self): env={ "PATH": "/usr/bin:/bin:/usr/local/bin", "PUSH_KEY": "pk", + "MACHINE_ID": "machine-42", }, ) assert result.returncode != 0 assert "REMOTE_HOOKS_BASE_URL" in result.stderr +@pytest.mark.skipif(not IS_WINDOWS, reason="PowerShell script; Windows only") +class TestPowerShellDiscoveryHookScript: + """Integration: execute the real discover .ps1, mirroring the POSIX .sh coverage. + + The POSIX trampoline lets the child inherit stdin; these tests pin the same + behaviour on Windows, which is the contract the script relies on. + """ + + @pytest.fixture(autouse=True) + def _skip_no_powershell(self): + if not shutil.which("powershell") and not shutil.which("pwsh"): + pytest.skip("powershell not available") + + @staticmethod + def _ps_cmd(): + return "powershell" if shutil.which("powershell") else "pwsh" + + @staticmethod + def _recording_stub(tmp_path: Path, marker: Path) -> Path: + """A ``snyk-agent-scan.cmd`` stub recording its argv and stdin to *marker*.""" + helper = tmp_path / "record.py" + helper.write_text( + "import os, sys\n" + "with open(os.environ['MARKER'], 'w') as fh:\n" + " fh.write(' '.join(sys.argv[1:]) + '\\n')\n" + " fh.write(sys.stdin.read())\n" + ) + stub = tmp_path / "snyk-agent-scan.cmd" + stub.write_text(f'@echo off\r\n"{sys.executable}" "{helper}" %*\r\n') + return stub + + def _run(self, script: Path, extra_args: list[str], env: dict, payload: str = "{}"): + return subprocess.run( + [self._ps_cmd(), "-File", str(script), "-Client", "claude-code", *extra_args], + input=payload, + capture_output=True, + text=True, + timeout=30, + env=env, + ) + + def test_stdin_payload_and_arguments_reach_the_child(self, tmp_path): + """The whole point of inheriting stdin: guard discover sees the hook payload.""" + script = _get_script_path("snyk-agent-guard-discover.ps1") + marker = tmp_path / "invoked" + stub = self._recording_stub(tmp_path, marker) + payload = '{"cwd":"C:\\\\work\\\\project","session_id":"s1"}' + + result = self._run( + script, + ["-AgentScanCommand", str(stub), "-MachineId", "machine-42"], + {**os.environ, "MARKER": str(marker)}, + payload=payload, + ) + + assert result.returncode == 0, result.stderr + recorded = marker.read_text().splitlines() + assert recorded[0] == "guard discover --client claude-code --scope servers" + assert json.loads("\n".join(recorded[1:])) == json.loads(payload) + + def test_nonzero_discovery_exit_is_swallowed(self, tmp_path): + script = _get_script_path("snyk-agent-guard-discover.ps1") + stub = tmp_path / "snyk-agent-scan.cmd" + stub.write_text("@echo off\r\nexit /b 1\r\n") + + result = self._run( + script, + ["-AgentScanCommand", str(stub), "-MachineId", "machine-42"], + dict(os.environ), + ) + + assert result.returncode == 0 + assert result.stderr == "" + + def test_missing_machine_id_exits_zero_without_invoking_command(self, tmp_path): + script = _get_script_path("snyk-agent-guard-discover.ps1") + marker = tmp_path / "invoked" + stub = self._recording_stub(tmp_path, marker) + env = {**os.environ, "MARKER": str(marker)} + env.pop("MACHINE_ID", None) + + result = self._run(script, ["-AgentScanCommand", str(stub)], env) + + assert result.returncode == 0 + assert not marker.exists() + + def test_stale_absolute_command_does_not_fall_back_to_path(self, tmp_path): + script = _get_script_path("snyk-agent-guard-discover.ps1") + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + marker = tmp_path / "invoked" + self._recording_stub(bin_dir, marker) + env = { + **os.environ, + "MARKER": str(marker), + "PATH": f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}", + } + + result = self._run( + script, + ["-AgentScanCommand", str(tmp_path / "deleted" / "snyk-agent-scan.exe"), "-MachineId", "machine-42"], + env, + ) + + assert result.returncode == 0, result.stderr + assert not marker.exists() + + def test_unset_command_does_not_fall_back_to_path(self, tmp_path): + script = _get_script_path("snyk-agent-guard-discover.ps1") + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + marker = tmp_path / "invoked" + self._recording_stub(bin_dir, marker) + env = { + **os.environ, + "MARKER": str(marker), + "MACHINE_ID": "machine-42", + "PATH": f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}", + } + env.pop("AGENT_SCAN_COMMAND", None) + + result = self._run(script, [], env) + + assert result.returncode == 0, result.stderr + assert not marker.exists() + + @pytest.mark.skipif(not IS_WINDOWS, reason="PowerShell script; Windows only") class TestPowerShellHookScript: """Integration: invoke the real .ps1 script against a local HTTP server.""" @@ -1278,6 +3218,8 @@ def test_posts_base64_payload(self, hook_server): "test-pk-123", "-RemoteUrl", hook_server, + "-MachineId", + "machine-42", ], input=payload, capture_output=True, @@ -1308,6 +3250,8 @@ def test_cursor_endpoint(self, hook_server): "test-pk-456", "-RemoteUrl", hook_server, + "-MachineId", + "machine-42", ], input=payload, capture_output=True, @@ -1317,6 +3261,56 @@ def test_cursor_endpoint(self, hook_server): assert result.returncode == 0, result.stderr assert "/hidden/agent-monitor/hooks/cursor" in _HookHandler.last_request["path"] + def test_machine_id_sets_x_user_identifier(self, hook_server): + script = _get_script_path("snyk-agent-guard.ps1") + result = subprocess.run( + [ + self._ps_cmd(), + "-File", + str(script), + "-Client", + "claude-code", + "-PushKey", + "test-pk", + "-RemoteUrl", + hook_server, + "-MachineId", + "machine-42", + ], + input='{"hook_event_name":"test","session_id":"s1"}', + capture_output=True, + text=True, + timeout=15, + ) + assert result.returncode == 0, result.stderr + x_user = json.loads(_HookHandler.last_request["headers"]["X-User"]) + assert x_user["identifier"] == "machine-42" + + def test_missing_machine_id_fails(self, hook_server): + script = _get_script_path("snyk-agent-guard.ps1") + env = dict(__import__("os").environ) + env.pop("MACHINE_ID", None) + result = subprocess.run( + [ + self._ps_cmd(), + "-File", + str(script), + "-Client", + "claude-code", + "-PushKey", + "test-pk", + "-RemoteUrl", + hook_server, + ], + input='{"hook_event_name":"test","session_id":"s1"}', + capture_output=True, + text=True, + timeout=15, + env=env, + ) + assert result.returncode != 0 + assert "MACHINE_ID" in result.stderr + def test_missing_push_key_fails(self, hook_server): script = _get_script_path("snyk-agent-guard.ps1") env = dict(__import__("os").environ) @@ -1363,6 +3357,7 @@ def test_cursor_invokes_command_string(self, hook_server): hook_server, script, "claude-code", + machine_id="machine-42", ) payload = '{"hook_event_name":"test","session_id":"cursor-test"}' result = subprocess.run( @@ -1398,6 +3393,7 @@ def test_cursor_invokes_command_string(self, hook_server): hook_server, script, "cursor", + machine_id="machine-42", ) payload = '{"hook_event_name":"test","conversation_id":"cursor-test"}' result = subprocess.run( @@ -1488,6 +3484,16 @@ def test_guard_enabled_continues(self, mock_fetch): class TestRunInstallCallsEnsureGuardEnabled: """_run_install invokes _ensure_guard_enabled_for_tenant only in the interactive (mint) path.""" + @pytest.fixture(autouse=True) + def _no_servers_discovered_event(self): + # _install_hooks is mocked below, so without this the real post-install + # send would run actual machine discovery and invoke the hook script. + with ( + patch("agent_scan.guard._send_servers_discovered_event", return_value=True), + patch.dict(os.environ, {"MACHINE_ID": "machine-42"}), + ): + yield + @patch("agent_scan.guard._install_hooks") @patch("agent_scan.guard.mint_push_key", return_value="minted-pk") @patch("agent_scan.guard.fetch_guard_enabled", return_value=True) @@ -1549,7 +3555,7 @@ def test_test_flag_true_does_not_change_install_hooks_call( mock_install.assert_called_once() call_args = mock_install.call_args assert "test" not in (call_args.kwargs or {}) - assert len(call_args.args) == 10, "args.test must not be forwarded to _install_hooks" + assert len(call_args.args) == 11, "args.test must not be forwarded to _install_hooks" @patch("agent_scan.guard._install_hooks") @patch("agent_scan.guard.fetch_guard_enabled", return_value=True) @@ -1606,6 +3612,8 @@ def test_headless_installs_without_snyk_token(self, mock_fetch, mock_install, tm _CURRENT_CHECKSUM = "a" * 64 _NEW_CHECKSUM = "b" * 64 +_DISCOVER_CURRENT_CHECKSUM = "c" * 64 +_DISCOVER_NEW_CHECKSUM = "d" * 64 class TestInstallHooksOrchestration: @@ -1620,8 +3628,10 @@ def ctx(self): """ dest = MagicMock(name="dest_path") targets = { - "copy": (f"{_G}._copy_hook_script", (dest, True, False, _CURRENT_CHECKSUM, _NEW_CHECKSUM)), + "copy": (f"{_G}._copy_hook_script", _NO_RETURN_VALUE), + "agent_scan_command": (f"{_G}._agent_scan_command", "/usr/local/bin/snyk-agent-scan"), "build": (f"{_G}._build_hook_command", "test-cmd"), + "build_discover": (f"{_G}._build_discover_hook_command", "discover-cmd"), "prep_claude": (f"{_G}._prepare_claude_config", (_PREPARED, _DIFF_REMOVED, 0)), "prep_cursor": (f"{_G}._prepare_cursor_config", (_PREPARED, _DIFF_REMOVED, 0)), "prep_codex": (f"{_G}._prepare_codex_config", (_PREPARED, _DIFF_REMOVED, 0)), @@ -1629,24 +3639,51 @@ def ctx(self): "is_toml": (f"{_G}._is_codex_requirements_toml", False), "detect_existing": (f"{_G}._detect_existing_install", None), "test_event": (f"{_G}._send_test_event", True), - "write_claude": (f"{_G}._write_claude_config", True), - "write_cursor": (f"{_G}._write_cursor_config", True), - "write_codex": (f"{_G}._write_codex_config", True), + "write": (f"{_G}._write_config", True), "write_codex_managed": (f"{_G}._write_codex_managed_config", True), "revoke": (f"{_G}._revoke_after_failure", _NO_RETURN_VALUE), "rich": (f"{_G}.rich", _NO_RETURN_VALUE), } active = {} - m = {"dest": dest} + m = { + "dest": dest, + "main_script": guard_module._CopiedScript( + dest, + True, + False, + _CURRENT_CHECKSUM, + _NEW_CHECKSUM, + ), + "discover_script": guard_module._CopiedScript( + MagicMock(name="discover_dest_path"), + True, + False, + _DISCOVER_CURRENT_CHECKSUM, + _DISCOVER_NEW_CHECKSUM, + ), + } for key, (target, rv) in targets.items(): p = patch(target) if rv is _NO_RETURN_VALUE else patch(target, return_value=rv) active[key] = p m[key] = p.start() + + def copy_script(script_dest): + return m["discover_script"] if "discover" in script_dest.name else m["main_script"] + + m["copy"].side_effect = copy_script yield m for p in active.values(): p.stop() - def _call(self, tmp_path, client="claude", hook_client="claude-code", minted=False, config_exists=False): + def _call( + self, + tmp_path, + client="claude", + hook_client="claude-code", + minted=False, + config_exists=False, + machine_id="", + ): config = tmp_path / "config.json" if config_exists: config.write_text("{}") @@ -1661,6 +3698,7 @@ def _call(self, tmp_path, client="claude", hook_client="claude-code", minted=Fal minted, "tid-1", "snyk-tok", + machine_id, ) return config @@ -1668,34 +3706,166 @@ def _print_messages(self, ctx): return [c.args[0] for c in ctx["rich"].print.call_args_list if c.args] # --------------------------------------------------------------- - # _copy_hook_script receives only config_path + # _copy_hook_script receives one destination path per script # --------------------------------------------------------------- - def test_copy_hook_script_called_with_config_path_only(self, ctx, tmp_path): + def test_copy_hook_script_includes_discovery_for_regular_config(self, ctx, tmp_path): config = self._call(tmp_path, client="claude", config_exists=True) - ctx["copy"].assert_called_once_with(config) + assert ctx["copy"].call_args_list == [ + mock_call(guard_module._forwarder_script_path(config)), + mock_call(guard_module._discover_script_path(config)), + ] + + def test_machine_id_forwarded_to_command_and_test_event(self, ctx, tmp_path): + self._call(tmp_path, machine_id="machine-42") + assert ctx["build"].call_args.kwargs["machine_id"] == "machine-42" + assert ctx["test_event"].call_args.kwargs["machine_id"] == "machine-42" + + def test_claude_builds_and_prepares_async_discovery_hook(self, ctx, tmp_path): + with patch(f"{_G}.IS_WINDOWS", False): + self._call(tmp_path, client="claude", machine_id="machine-42") + + ctx["build_discover"].assert_called_once() + assert ctx["build_discover"].call_args.kwargs == { + "agent_scan_command": "/usr/local/bin/snyk-agent-scan", + "tenant_id": "tid-1", + "machine_id": "machine-42", + "hook_client": "claude-code", + } + assert ctx["prep_claude"].call_args.kwargs["discover_command"] == "discover-cmd" + + def test_cursor_builds_discovery_hook(self, ctx, tmp_path): + self._call(tmp_path, client="cursor", hook_client="cursor") + + ctx["build_discover"].assert_called_once() + assert ctx["build_discover"].call_args.kwargs["hook_client"] == "cursor" + assert ctx["prep_cursor"].call_args.kwargs["discover_command"] == "discover-cmd" + + def test_windows_builds_discovery_hook(self, ctx, tmp_path): + with patch(f"{_G}.IS_WINDOWS", True): + self._call(tmp_path, client="claude") + + ctx["build_discover"].assert_called_once() + assert ctx["build_discover"].call_args.args[2] == tmp_path / "hooks" / "snyk-agent-guard-discover.ps1" + assert ctx["prep_claude"].call_args.kwargs["discover_command"] == "discover-cmd" + + def test_codex_json_builds_discovery_hook(self, ctx, tmp_path): + self._call(tmp_path, client="codex", hook_client="codex") + + ctx["build_discover"].assert_called_once() + assert ctx["build_discover"].call_args.kwargs["hook_client"] == "codex" + assert ctx["prep_codex"].call_args.kwargs["discover_command"] == "discover-cmd" + + def test_codex_managed_builds_discovery_hook(self, ctx, tmp_path): + ctx["is_toml"].return_value = True + + config = self._call(tmp_path, client="codex", hook_client="codex") + + ctx["build_discover"].assert_called_once() + assert ctx["copy"].call_args_list == [ + mock_call(guard_module._forwarder_script_path(config)), + mock_call(guard_module._discover_script_path(config)), + ] + assert ctx["prep_codex_managed"].call_args.kwargs["discover_command"] == "discover-cmd" + + def test_inferred_agent_scan_command_warns_that_fallback_is_temporary(self, ctx, tmp_path): + self._call(tmp_path, client="claude") + + assert any( + "AGENT_SCAN_COMMAND will become mandatory once ADS Installer is updated" in message + for message in self._print_messages(ctx) + ) + + def test_unset_agent_scan_command_skips_discovery_without_aborting(self, ctx, tmp_path): + ctx["agent_scan_command"].return_value = None + + config = self._call(tmp_path, client="claude") + + ctx["copy"].assert_called_once_with(guard_module._forwarder_script_path(config)) + ctx["build_discover"].assert_not_called() + assert ctx["prep_claude"].call_args.kwargs["discover_command"] is None + + @pytest.mark.parametrize( + "client, hook_client", + [("claude", "claude-code"), ("cursor", "cursor"), ("codex", "codex")], + ) + def test_unset_agent_scan_command_warns_once_per_client(self, ctx, tmp_path, client, hook_client): + ctx["agent_scan_command"].return_value = None + + self._call(tmp_path, client=client, hook_client=hook_client) + + warnings = [message for message in self._print_messages(ctx) if "AGENT_SCAN_COMMAND is not set" in message] + assert warnings == [ + "[yellow]Warning:[/yellow] AGENT_SCAN_COMMAND is not set; " + "the session-start discovery hook will not be installed" + ] + + def test_unset_agent_scan_command_removes_stale_script_after_config_write(self, ctx, tmp_path): + ctx["agent_scan_command"].return_value = None + discover_script = guard_module._discover_script_path(tmp_path / "config.json") + discover_script.parent.mkdir(parents=True) + discover_script.write_text("stale\n") + + def assert_stale_script_still_exists(*_args): + assert discover_script.exists() + return True + + ctx["write"].side_effect = assert_stale_script_still_exists + + self._call(tmp_path, client="claude") + + assert not discover_script.exists() + assert any("Removed stale hook script" in message for message in self._print_messages(ctx)) + + def test_unset_agent_scan_command_keeps_stale_script_when_test_event_fails(self, ctx, tmp_path): + ctx["agent_scan_command"].return_value = None + ctx["test_event"].return_value = False + discover_script = guard_module._discover_script_path(tmp_path / "config.json") + discover_script.parent.mkdir(parents=True) + discover_script.write_text("stale\n") + + with pytest.raises(SystemExit): + self._call(tmp_path, client="claude") + + assert discover_script.read_text() == "stale\n" + + def test_install_hooks_returns_none(self, ctx, tmp_path): + result = _install_hooks( + "claude", + "claude-code", + "pk-test", + "https://api.snyk.io", + tmp_path / "config.json", + "user", + "Claude Code", + False, + "tid-1", + "snyk-tok", + "", + ) + assert result is None # --------------------------------------------------------------- - # Client routing: each client calls its own prepare + write + # Client routing: each client calls its own prepare + the shared writer # --------------------------------------------------------------- def test_claude_routes_to_claude_functions(self, ctx, tmp_path): self._call(tmp_path, client="claude", config_exists=True) ctx["prep_claude"].assert_called_once() - ctx["write_claude"].assert_called_once() + ctx["write"].assert_called_once() ctx["prep_cursor"].assert_not_called() ctx["prep_codex"].assert_not_called() def test_cursor_routes_to_cursor_functions(self, ctx, tmp_path): self._call(tmp_path, client="cursor", hook_client="cursor", config_exists=True) ctx["prep_cursor"].assert_called_once() - ctx["write_cursor"].assert_called_once() + ctx["write"].assert_called_once() ctx["prep_claude"].assert_not_called() def test_codex_json_routes_to_codex_functions(self, ctx, tmp_path): self._call(tmp_path, client="codex", hook_client="codex", config_exists=True) ctx["prep_codex"].assert_called_once() - ctx["write_codex"].assert_called_once() + ctx["write"].assert_called_once() ctx["prep_codex_managed"].assert_not_called() def test_codex_managed_routes_to_toml_functions(self, ctx, tmp_path): @@ -1704,7 +3874,7 @@ def test_codex_managed_routes_to_toml_functions(self, ctx, tmp_path): ctx["prep_codex_managed"].assert_called_once() ctx["write_codex_managed"].assert_called_once() ctx["prep_codex"].assert_not_called() - ctx["write_codex"].assert_not_called() + ctx["write"].assert_not_called() # --------------------------------------------------------------- # Detection: config_changed derived from diff @@ -1741,7 +3911,7 @@ def test_config_changed_false_when_diff_empty(self, ctx, tmp_path): def test_test_event_sent_when_script_new(self, ctx, tmp_path): """first_install=True because script did not exist prior.""" - ctx["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM) + ctx["main_script"] = guard_module._CopiedScript(ctx["dest"], False, True, None, _NEW_CHECKSUM) self._call(tmp_path, config_exists=True) ctx["test_event"].assert_called_once() _, kwargs = ctx["test_event"].call_args @@ -1761,7 +3931,7 @@ def test_test_event_always_sent(self, ctx, tmp_path): def test_test_event_receives_diff(self, ctx, tmp_path): ctx["prep_claude"].return_value = (_PREPARED, _DIFF_REMOVED, 0) - ctx["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM) + ctx["main_script"] = guard_module._CopiedScript(ctx["dest"], False, True, None, _NEW_CHECKSUM) self._call(tmp_path) ctx["test_event"].assert_called_once_with( "pk-test", @@ -1774,11 +3944,14 @@ def test_test_event_receives_diff(self, ctx, tmp_path): push_key_changed=False, current_checksum=None, new_checksum=_NEW_CHECKSUM, + discover_current_checksum=_DISCOVER_CURRENT_CHECKSUM, + discover_new_checksum=_DISCOVER_NEW_CHECKSUM, + machine_id="", ) def test_test_event_receives_empty_diff(self, ctx, tmp_path): ctx["prep_claude"].return_value = (_PREPARED, _DIFF_EMPTY, 0) - ctx["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM) + ctx["main_script"] = guard_module._CopiedScript(ctx["dest"], False, True, None, _NEW_CHECKSUM) self._call(tmp_path) ctx["test_event"].assert_called_once_with( "pk-test", @@ -1791,6 +3964,9 @@ def test_test_event_receives_empty_diff(self, ctx, tmp_path): push_key_changed=False, current_checksum=None, new_checksum=_NEW_CHECKSUM, + discover_current_checksum=_DISCOVER_CURRENT_CHECKSUM, + discover_new_checksum=_DISCOVER_NEW_CHECKSUM, + machine_id="", ) def test_test_event_not_first_install(self, ctx, tmp_path): @@ -1807,11 +3983,14 @@ def test_test_event_not_first_install(self, ctx, tmp_path): push_key_changed=False, current_checksum=_CURRENT_CHECKSUM, new_checksum=_NEW_CHECKSUM, + discover_current_checksum=_DISCOVER_CURRENT_CHECKSUM, + discover_new_checksum=_DISCOVER_NEW_CHECKSUM, + machine_id="", ) def test_test_event_push_key_changed(self, ctx, tmp_path): ctx["detect_existing"].return_value = {"auth_value": "old-push-key"} - ctx["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM) + ctx["main_script"] = guard_module._CopiedScript(ctx["dest"], False, True, None, _NEW_CHECKSUM) self._call(tmp_path) ctx["test_event"].assert_called_once_with( "pk-test", @@ -1824,11 +4003,14 @@ def test_test_event_push_key_changed(self, ctx, tmp_path): push_key_changed=True, current_checksum=None, new_checksum=_NEW_CHECKSUM, + discover_current_checksum=_DISCOVER_CURRENT_CHECKSUM, + discover_new_checksum=_DISCOVER_NEW_CHECKSUM, + machine_id="", ) def test_test_event_push_key_unchanged(self, ctx, tmp_path): ctx["detect_existing"].return_value = {"auth_value": "pk-test"} - ctx["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM) + ctx["main_script"] = guard_module._CopiedScript(ctx["dest"], False, True, None, _NEW_CHECKSUM) self._call(tmp_path) ctx["test_event"].assert_called_once_with( "pk-test", @@ -1841,6 +4023,9 @@ def test_test_event_push_key_unchanged(self, ctx, tmp_path): push_key_changed=False, current_checksum=None, new_checksum=_NEW_CHECKSUM, + discover_current_checksum=_DISCOVER_CURRENT_CHECKSUM, + discover_new_checksum=_DISCOVER_NEW_CHECKSUM, + machine_id="", ) # --------------------------------------------------------------- @@ -1849,7 +4034,7 @@ def test_test_event_push_key_unchanged(self, ctx, tmp_path): def test_test_event_checksums_first_install(self, ctx, tmp_path): """First install: current_checksum is None, new_checksum is populated.""" - ctx["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM) + ctx["main_script"] = guard_module._CopiedScript(ctx["dest"], False, True, None, _NEW_CHECKSUM) self._call(tmp_path) _, kwargs = ctx["test_event"].call_args assert kwargs["current_checksum"] is None @@ -1862,6 +4047,21 @@ def test_test_event_checksums_existing_install(self, ctx, tmp_path): assert kwargs["current_checksum"] == _CURRENT_CHECKSUM assert kwargs["new_checksum"] == _NEW_CHECKSUM + def test_test_event_receives_discovery_script_checksums(self, ctx, tmp_path): + ctx["discover_script"] = guard_module._CopiedScript( + MagicMock(name="discover_dest_path"), + True, + False, + "discover-current", + "discover-new", + ) + + self._call(tmp_path, minted=True, config_exists=True) + + _, kwargs = ctx["test_event"].call_args + assert kwargs["discover_current_checksum"] == "discover-current" + assert kwargs["discover_new_checksum"] == "discover-new" + # --------------------------------------------------------------- # Test event failure: abort, cleanup, revoke # --------------------------------------------------------------- @@ -1878,21 +4078,69 @@ def test_test_event_failure_does_not_revoke_in_install_hooks(self, ctx, tmp_path ctx["revoke"].assert_not_called() def test_test_event_failure_no_revoke_when_not_minted(self, ctx, tmp_path): - ctx["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM) + ctx["main_script"] = guard_module._CopiedScript(ctx["dest"], False, True, None, _NEW_CHECKSUM) ctx["test_event"].return_value = False with pytest.raises(SystemExit): self._call(tmp_path, minted=False, config_exists=True) ctx["revoke"].assert_not_called() def test_test_event_failure_cleans_new_script(self, ctx, tmp_path): - ctx["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM) + ctx["main_script"] = guard_module._CopiedScript(ctx["dest"], False, True, None, _NEW_CHECKSUM) ctx["test_event"].return_value = False with pytest.raises(SystemExit): self._call(tmp_path) ctx["dest"].unlink.assert_called_once_with(missing_ok=True) + def test_test_event_failure_cleans_new_discovery_script(self, ctx, tmp_path): + discover_script_name = ( + "snyk-agent-guard-discover.ps1" if guard_module.IS_WINDOWS else "snyk-agent-guard-discover.sh" + ) + discover_script = tmp_path / "hooks" / discover_script_name + + def copy_scripts(dest): + if "discover" not in dest.name: + return ctx["main_script"] + discover_script.parent.mkdir(parents=True) + discover_script.write_text("#!/bin/sh\n") + return guard_module._CopiedScript( + discover_script, + False, + True, + None, + _DISCOVER_NEW_CHECKSUM, + ) + + ctx["copy"].side_effect = copy_scripts + ctx["test_event"].return_value = False + + with pytest.raises(SystemExit): + self._call(tmp_path) + + assert not discover_script.exists() + + def test_test_event_failure_keeps_existing_discovery_script(self, ctx, tmp_path): + discover_script_name = ( + "snyk-agent-guard-discover.ps1" if guard_module.IS_WINDOWS else "snyk-agent-guard-discover.sh" + ) + discover_script = tmp_path / "hooks" / discover_script_name + discover_script.parent.mkdir(parents=True) + discover_script.write_text("existing\n") + ctx["main_script"] = guard_module._CopiedScript(ctx["dest"], False, True, None, _NEW_CHECKSUM) + ctx["test_event"].return_value = False + + with pytest.raises(SystemExit): + self._call(tmp_path) + + assert discover_script.read_text() == "existing\n" + def test_test_event_failure_keeps_existing_script(self, ctx, tmp_path): - ctx["copy"].return_value = (ctx["dest"], True, False, _CURRENT_CHECKSUM, _NEW_CHECKSUM) + ctx["main_script"] = guard_module._CopiedScript( + ctx["dest"], + True, + False, + _CURRENT_CHECKSUM, + _NEW_CHECKSUM, + ) ctx["test_event"].return_value = False with pytest.raises(SystemExit): self._call(tmp_path, minted=True, config_exists=True) @@ -1902,9 +4150,7 @@ def test_test_event_failure_does_not_write_config(self, ctx, tmp_path): ctx["test_event"].return_value = False with pytest.raises(SystemExit): self._call(tmp_path, minted=True, config_exists=True) - ctx["write_claude"].assert_not_called() - ctx["write_cursor"].assert_not_called() - ctx["write_codex"].assert_not_called() + ctx["write"].assert_not_called() ctx["write_codex_managed"].assert_not_called() # --------------------------------------------------------------- @@ -1915,19 +4161,19 @@ def test_write_receives_prepared_claude_config(self, ctx, tmp_path): prepared = {"hooks": {"PreToolUse": [{"test": True}]}} ctx["prep_claude"].return_value = (prepared, _DIFF_REMOVED, 2) config = self._call(tmp_path, config_exists=True) - ctx["write_claude"].assert_called_once_with(prepared, config, 2) + ctx["write"].assert_called_once_with(prepared, config, 2) def test_write_receives_prepared_cursor_config(self, ctx, tmp_path): prepared = {"version": 1, "hooks": {"stop": [{"command": "x"}]}} ctx["prep_cursor"].return_value = (prepared, _DIFF_REMOVED, 1) config = self._call(tmp_path, client="cursor", hook_client="cursor", config_exists=True) - ctx["write_cursor"].assert_called_once_with(prepared, config, 1) + ctx["write"].assert_called_once_with(prepared, config, 1) def test_write_receives_prepared_codex_config(self, ctx, tmp_path): prepared = {"hooks": {"Stop": [{"hooks": []}]}} ctx["prep_codex"].return_value = (prepared, _DIFF_REMOVED, 3) config = self._call(tmp_path, client="codex", hook_client="codex", config_exists=True) - ctx["write_codex"].assert_called_once_with(prepared, config, 3) + ctx["write"].assert_called_once_with(prepared, config, 3) def test_write_receives_prepared_codex_managed_content(self, ctx, tmp_path): ctx["is_toml"].return_value = True @@ -1938,30 +4184,44 @@ def test_write_receives_prepared_codex_managed_content(self, ctx, tmp_path): def test_config_written_after_test_event(self, ctx, tmp_path): self._call(tmp_path, config_exists=True, minted=False) ctx["test_event"].assert_called_once() - ctx["write_claude"].assert_called_once() + ctx["write"].assert_called_once() # --------------------------------------------------------------- # Status output # --------------------------------------------------------------- def test_status_installed_when_config_written(self, ctx, tmp_path): - ctx["write_claude"].return_value = True + ctx["write"].return_value = True self._call(tmp_path, config_exists=True) assert any("hooks installed" in m for m in self._print_messages(ctx)) def test_status_installed_when_script_updated(self, ctx, tmp_path): - ctx["copy"].return_value = (ctx["dest"], True, True, _CURRENT_CHECKSUM, _NEW_CHECKSUM) - ctx["write_claude"].return_value = False + ctx["main_script"] = guard_module._CopiedScript( + ctx["dest"], + True, + True, + _CURRENT_CHECKSUM, + _NEW_CHECKSUM, + ) + ctx["write"].return_value = False self._call(tmp_path, config_exists=True) assert any("hooks installed" in m for m in self._print_messages(ctx)) + def test_status_installed_when_discovery_script_updated(self, ctx, tmp_path): + ctx["discover_script"] = ctx["discover_script"]._replace(updated=True) + ctx["write"].return_value = False + + self._call(tmp_path, config_exists=True) + + assert any("hooks installed" in m for m in self._print_messages(ctx)) + def test_status_installed_when_minted(self, ctx, tmp_path): - ctx["write_claude"].return_value = False + ctx["write"].return_value = False self._call(tmp_path, minted=True, config_exists=True) assert any("hooks installed" in m for m in self._print_messages(ctx)) def test_status_up_to_date_when_nothing_changed(self, ctx, tmp_path): - ctx["write_claude"].return_value = False + ctx["write"].return_value = False self._call(tmp_path, minted=False, config_exists=True) assert any("up to date" in m for m in self._print_messages(ctx)) @@ -1990,6 +4250,7 @@ def fake_run(cmd, *, input, **kw): "https://api.snyk.io", "claude-code", Path("/fake/script.sh"), + machine_id="machine-42", **kwargs, ) return captured["payload"] @@ -2012,11 +4273,1099 @@ def test_existing_install_both_checksums(self): "new_checksum": "new222", } + def test_discovery_script_checksums_are_included(self): + payload = self._capture_payload( + current_checksum="old111", + new_checksum="new222", + discover_current_checksum="discover-old", + discover_new_checksum="discover-new", + ) + + assert payload["hooks_script"] == { + "current_checksum": "old111", + "new_checksum": "new222", + "discover_current_checksum": "discover-old", + "discover_new_checksum": "discover-new", + } + def test_no_checksums_omits_hooks_script(self): payload = self._capture_payload(first_install=True) assert "hooks_script" not in payload +class TestServersDiscoveredPayload: + @staticmethod + def _client(*, mcp_configs, name="claude code", path=None): + return ClientToInspect( + name=name, + client_path=path or (Path.home() / ".claude").as_posix(), + mcp_configs=mcp_configs, + skills_dirs={}, + ) + + def test_builds_one_entry_per_client_and_merges_config_paths(self): + stdio = StdioServer( + command="npx", + args=["-y", "@mcp/github"], + env={"GITHUB_TOKEN": "secret-token"}, + binary_identifier="pkg:npm/%40mcp/github@1.0.0", + ) + remote = RemoteServer( + url="https://mcp.example.com/mcp?token=remote-secret", + type="http", + headers={"Authorization": "Bearer remote-secret"}, + ) + home = Path.home() + clients = [ + self._client( + mcp_configs={ + (home / ".claude.json").as_posix(): [("github", stdio)], + (home / "project" / ".mcp.json").as_posix(): [("remote", remote)], + } + ), + self._client(mcp_configs={}, name="cursor", path=(home / ".cursor").as_posix()), + ] + + result = guard_module._servers_discovered_entries(clients) + + assert [(entry["client"], entry["path"]) for entry in result] == [ + ("claude code", "~/.claude"), + ("cursor", "~/.cursor"), + ] + assert [(server["name"], server["config_path"]) for server in result[0]["servers"]] == [ + ("github", (home / ".claude.json").as_posix()), + ("remote", (home / "project" / ".mcp.json").as_posix()), + ] + assert result[1]["servers"] == [] + + def test_reports_config_discovery_errors(self): + client = self._client( + mcp_configs={ + "/bad.json": CouldNotParseMCPConfig(message="bad", traceback=None), + "/missing.json": FileNotFoundConfig(message="missing", traceback=None), + "/good.json": [("good", StdioServer(command="good"))], + } + ) + + result = guard_module._servers_discovered_entries([client]) + + assert [server["name"] for server in result[0]["servers"]] == ["good"] + assert result[0]["error"]["category"] == "parse_error" + + def test_client_with_only_error_configs_still_emits_entry(self): + client = self._client( + mcp_configs={ + "/bad.json": CouldNotParseMCPConfig(message="bad", traceback=None), + "/missing.json": FileNotFoundConfig(message="missing", traceback=None), + } + ) + + result = guard_module._servers_discovered_entries([client]) + + assert [(entry["client"], entry["servers"]) for entry in result] == [("claude code", [])] + assert result[0]["error"]["category"] == "parse_error" + + def test_unnamed_server_gets_attributable_name(self): + config_path = (Path.home() / "project" / ".mcp.json").as_posix() + client = self._client(mcp_configs={config_path: [("", StdioServer(command="server"))]}) + + result = guard_module._servers_discovered_entries([client]) + + assert result[0]["servers"][0]["name"] == "unnamed server (~/project/.mcp.json)" + + def test_top_level_paths_match_scan_transport_boundary(self): + from agent_scan.verify_api import build_scan_request + + clients = [ + self._client(mcp_configs={}), + self._client(mcp_configs={}, name="cursor", path=(Path.home() / ".cursor").as_posix()), + ] + inspected_paths = [InspectedPath(client=client.name, path=client.client_path, servers=[]) for client in clients] + + discovered = guard_module._servers_discovered_entries(clients) + scanned = build_scan_request(inspected_paths).scan_path_requests + + assert [entry["path"] for entry in discovered] == [entry.path for entry in scanned] + + def test_empty_input_returns_empty_list(self): + assert guard_module._servers_discovered_entries([]) == [] + + def test_matches_scan_path_request_wire_shape(self): + from agent_scan.verify_api import build_scan_request + + server = StdioServer(command="npx", args=["--mode", "read-only"], binary_identifier="binary-id") + client = self._client(mcp_configs={"/config.json": [("github", server)]}) + inspected = InspectedPath( + client=client.name, + path=client.client_path, + servers=[InspectedServer(name="github", config_path="/config.json", server=server)], + ) + + result = guard_module._servers_discovered_entries([client]) + + expected = build_scan_request([inspected]).scan_path_requests[0].model_dump(mode="json") + assert result == [expected] + assert set(result[0]) == {"client", "path", "servers", "skills", "error"} + assert set(result[0]["servers"][0]) == {"name", "config_path", "server", "signature", "error"} + assert result[0]["servers"][0]["server"] == { + "command": "npx", + "args": ["--mode", "read-only"], + "type": "stdio", + "env": None, + "binary_identifier": "binary-id", + } + + def test_redacts_stdio_env_without_mutating_discovery_result(self): + server = StdioServer(command="npx", env={"TOKEN": "raw-secret"}) + client = self._client(mcp_configs={"/config.json": [("github", server)]}) + + result = guard_module._servers_discovered_entries([client]) + + assert result[0]["servers"][0]["server"]["env"] == {"TOKEN": "**REDACTED**"} + assert "raw-secret" not in json.dumps(result) + assert server.env == {"TOKEN": "raw-secret"} + + def test_redacts_remote_headers_and_url_query(self): + server = RemoteServer( + url="https://mcp.example.com/mcp?token=raw-secret", + headers={"Authorization": "Bearer raw-secret"}, + ) + client = self._client(mcp_configs={"/config.json": [("remote", server)]}) + + result = guard_module._servers_discovered_entries([client]) + + dumped = json.dumps(result) + wire_server = result[0]["servers"][0]["server"] + assert wire_server["headers"] == {"Authorization": "**REDACTED**"} + assert "token=%2A%2AREDACTED%2A%2A" in wire_server["url"] + assert "raw-secret" not in dumped + + +class TestDiscoverServersPayload: + def test_uses_current_user_server_only_discovery(self): + clients = [ClientToInspect(name="cursor", client_path="/cursor", mcp_configs={}, skills_dirs={})] + discover = AsyncMock(return_value=(clients, [], ["me"])) + + with patch("agent_scan.pipelines.discover_clients_to_inspect", discover): + result = guard_module._discover_servers_payload() + + args = discover.await_args.args[0] + assert args.timeout == 0 + assert args.tokens == [] + assert args.paths == [] + assert args.all_users is False + assert args.scan_skills is False + assert discover.await_args.kwargs == {} + assert result == guard_module._servers_discovered_entries(clients) + + def test_forwards_discovery_scope(self): + from agent_scan.agents import DiscoveryScope + + discover = AsyncMock(return_value=([], [], [])) + + with patch("agent_scan.pipelines.discover_clients_to_inspect", discover): + guard_module._discover_servers_payload(discovery_scope=DiscoveryScope.SERVERS) + + assert discover.await_args.args[0].discovery_scope is DiscoveryScope.SERVERS + + def test_threads_target_folders_to_inspect_args(self): + discover = AsyncMock(return_value=([], [], [])) + + with patch("agent_scan.pipelines.discover_clients_to_inspect", discover): + result = guard_module._discover_servers_payload(["/repo/one", "/repo/two"]) + + args = discover.await_args.args[0] + assert args.target_folders == ["/repo/one", "/repo/two"] + assert result == [] + + def test_discovery_timeout_is_60_seconds(self): + assert guard_module._DISCOVERY_TIMEOUT_SECONDS == 60.0 + + +class TestInvokeHookScript: + def test_posix_invocation_sets_machine_id(self, monkeypatch): + monkeypatch.delenv("MACHINE_ID", raising=False) + completed = subprocess.CompletedProcess([], 0, stdout="ok", stderr="") + with patch(f"{_G}.IS_WINDOWS", False), patch("subprocess.run", return_value=completed) as run: + result = guard_module._invoke_hook_script( + PurePosixPath("/hook.sh"), + "claude-code", + "pk", + "https://api.snyk.io", + "{}", + machine_id="machine-42", + ) + + assert result == (True, "") + assert run.call_args.args[0] == ["bash", "/hook.sh", "--client", "claude-code"] + assert run.call_args.kwargs["env"]["MACHINE_ID"] == "machine-42" + assert run.call_args.kwargs["input"] == "{}" + + def test_posix_invocation_overwrites_ambient_machine_id(self, monkeypatch): + monkeypatch.setenv("MACHINE_ID", "ambient-machine") + completed = subprocess.CompletedProcess([], 0, stdout="ok", stderr="") + with patch(f"{_G}.IS_WINDOWS", False), patch("subprocess.run", return_value=completed) as run: + result = guard_module._invoke_hook_script( + PurePosixPath("/hook.sh"), + "cursor", + "pk", + "https://api.snyk.io", + "{}", + machine_id="chosen-machine", + ) + + assert result == (True, "") + assert run.call_args.kwargs["env"]["MACHINE_ID"] == "chosen-machine" + + def test_empty_machine_id_is_rejected(self): + with pytest.raises(ValueError, match="machine ID"): + guard_module._invoke_hook_script( + Path("/hook.sh"), "cursor", "pk", "https://api.snyk.io", "{}", machine_id=" " + ) + + def test_windows_invocation_machine_id_shape(self): + completed = subprocess.CompletedProcess([], 0, stdout="ok", stderr="") + with patch(f"{_G}.IS_WINDOWS", True), patch("subprocess.run", return_value=completed) as run: + result = guard_module._invoke_hook_script( + Path("C:/hook.ps1"), + "codex", + "pk", + "https://api.snyk.io", + "{}", + machine_id="machine-42", + ) + + assert result == (True, "") + assert run.call_args.args[0] == [ + "powershell", + "-File", + str(Path("C:/hook.ps1")), + "-Client", + "codex", + "-PushKey", + "pk", + "-RemoteUrl", + "https://api.snyk.io", + "-MachineId", + "machine-42", + ] + assert run.call_args.kwargs["env"] is None + + def test_nonzero_exit_returns_stderr(self): + completed = subprocess.CompletedProcess([], 7, stdout="", stderr="bad request\n") + with patch(f"{_G}.IS_WINDOWS", False), patch("subprocess.run", return_value=completed): + result = guard_module._invoke_hook_script( + Path("/hook.sh"), "cursor", "pk", "url", "{}", machine_id="machine-42" + ) + assert result == (False, "bad request") + + +def test_run_with_timeout_raises_when_worker_exceeds_deadline(): + stop = threading.Event() + + def worker(): + stop.wait(5) + + try: + with pytest.raises(TimeoutError, match="timed out"): + guard_module._run_with_timeout(worker, 0.01) + finally: + stop.set() + + +class TestSendServersDiscoveredEvent: + @staticmethod + def _capture(hook_client="claude-code", entries=None, machine_id="machine-42"): + captured = {} + + def fake_send(url, client, push_key, payload, identifier, **kwargs): + captured.update( + url=url, + client=client, + push_key=push_key, + payload=json.loads(payload), + machine_id=identifier, + ) + return True, "" + + with ( + patch(f"{_G}._discover_servers_payload", return_value=[] if entries is None else entries), + patch(f"{_G}.send_hook_event", side_effect=fake_send), + patch(f"{_G}.rich"), + ): + ok = guard_module._send_servers_discovered_event("pk-test", "https://api.snyk.io", hook_client, machine_id) + return ok, captured + + @pytest.mark.parametrize( + "hook_client, id_key", + [("claude-code", "session_id"), ("codex", "session_id"), ("cursor", "conversation_id")], + ) + def test_payload_contract_for_client(self, hook_client, id_key): + push_key = "12345678-1234-1234-1234-123456789abc" + entries = [{"command": f"PUSH_KEY='{push_key}'", "servers": []}] + ok, captured = self._capture(hook_client=hook_client, entries=entries) + + assert ok is True + payload = captured["payload"] + assert payload["hook_event_name"] == "hooksConfiguredServerDiscovery" + assert payload[id_key] == "hooks-setup" + assert ({"session_id", "conversation_id"} - {id_key}).isdisjoint(payload) + assert payload["servers"][0]["command"] == "PUSH_KEY='**REDACTED**'" + assert isinstance(payload["discovery_duration_ms"], int) + assert payload["discovery_duration_ms"] >= 0 + assert push_key not in json.dumps(payload) + assert captured["url"] == "https://api.snyk.io" + assert captured["client"] == hook_client + assert captured["push_key"] == "pk-test" + assert captured["machine_id"] == "machine-42" + + def test_empty_discovery_is_still_sent(self): + ok, captured = self._capture(entries=[]) + assert ok is True + assert captured["payload"]["servers"] == [] + + def test_event_name_and_session_marker_can_be_overridden(self): + captured = {} + + def fake_send(_url, _client, _push_key, payload, _machine_id, **kwargs): + captured["payload"] = json.loads(payload) + return True, "" + + with ( + patch(f"{_G}._discover_servers_payload", return_value=[]), + patch(f"{_G}.send_hook_event", side_effect=fake_send), + patch(f"{_G}.rich"), + ): + ok = guard_module._send_servers_discovered_event( + "pk", + "https://api.snyk.io", + "claude-code", + "machine-42", + event_name="sessionStartServerDiscovery", + session_marker="session-start-server-discovery", + ) + + assert ok is True + assert captured["payload"]["hook_event_name"] == "sessionStartServerDiscovery" + assert captured["payload"]["session_id"] == "session-start-server-discovery" + + def test_payload_includes_discovery_duration_ms_from_monotonic_clock(self): + captured = {} + + def fake_send(_url, _client, _push_key, payload, _machine_id, **kwargs): + captured["payload"] = json.loads(payload) + return True, "" + + with ( + patch(f"{_G}._discover_servers_payload", return_value=[]), + patch(f"{_G}.send_hook_event", side_effect=fake_send), + patch("time.monotonic", side_effect=[100.0, 100.25]), + patch(f"{_G}.rich"), + ): + ok = guard_module._send_servers_discovered_event( + "pk-test", "https://api.snyk.io", "claude-code", "machine-42" + ) + + assert ok is True + assert captured["payload"]["discovery_duration_ms"] == 250 + assert isinstance(captured["payload"]["discovery_duration_ms"], int) + + def test_send_failure_warns_and_returns_false(self): + with ( + patch(f"{_G}._discover_servers_payload", return_value=[]), + patch(f"{_G}.send_hook_event", return_value=(False, "HTTP 500")), + patch(f"{_G}.rich") as rich_mock, + ): + result = guard_module._send_servers_discovered_event("pk", "url", "cursor", "") + assert result is False + assert "HTTP 500" in rich_mock.print.call_args.args[0] + + def test_discovery_exception_does_not_send(self): + with ( + patch(f"{_G}._discover_servers_payload", side_effect=RuntimeError("discovery failed")), + patch(f"{_G}.send_hook_event") as send, + patch(f"{_G}.rich") as rich_mock, + ): + result = guard_module._send_servers_discovered_event("pk", "url", "cursor", "") + assert result is False + send.assert_not_called() + assert "discovery failed" in rich_mock.print.call_args.args[0] + + def test_discovery_timeout_warns_without_sending(self): + import asyncio + import time as test_time + + async def slow_discovery(_inspect_args): + await asyncio.sleep(0.5) + return [], [], [] + + with ( + patch("agent_scan.pipelines.discover_clients_to_inspect", side_effect=slow_discovery), + patch(f"{_G}._DISCOVERY_TIMEOUT_SECONDS", 0.01), + patch(f"{_G}.send_hook_event") as send, + patch(f"{_G}.rich") as rich_mock, + ): + started = test_time.monotonic() + result = guard_module._send_servers_discovered_event("pk", "url", "cursor", "") + elapsed = test_time.monotonic() - started + + assert result is False + assert elapsed < 0.2 + send.assert_not_called() + assert "timed out" in rich_mock.print.call_args.args[0] + + +class TestGuardInstallMachineIdCli: + def test_guard_install_accepts_machine_id(self, monkeypatch): + from agent_scan import cli + + monkeypatch.setattr(sys, "argv", ["agent-scan", "guard", "install", "claude", "--machine-id", "machine-42"]) + with patch(f"{_G}.run_guard", return_value=0) as run: + with pytest.raises(SystemExit) as exc: + cli.main() + + assert exc.value.code == 0 + assert run.call_args.args[0].machine_id == "machine-42" + + def test_guard_install_rejects_control_identifier(self, monkeypatch): + """--machine-id is the only spelling here; --control-identifier belongs to scan's + control-server blocks, where it means a different dest.""" + from agent_scan import cli + + monkeypatch.setattr( + sys, "argv", ["agent-scan", "guard", "install", "claude", "--control-identifier", "machine-42"] + ) + # Patched so a regression that re-accepts the flag fails the assertion below + # instead of running a real install against the developer's own config. + with patch(f"{_G}.run_guard", return_value=0) as run: + with pytest.raises(SystemExit) as exc: + cli.main() + + assert exc.value.code == 2 + run.assert_not_called() + + +class TestGuardDiscoverCli: + def test_parses_url(self, monkeypatch): + from agent_scan import cli + + monkeypatch.setattr( + sys, + "argv", + [ + "agent-scan", + "guard", + "discover", + "--url", + "https://hooks.example", + "--client", + "claude-code", + ], + ) + with patch(f"{_G}.run_guard", return_value=0) as run: + with pytest.raises(SystemExit) as exc: + cli.main() + + assert exc.value.code == 0 + args = run.call_args.args[0] + assert args.guard_command == "discover" + assert args.url == "https://hooks.example" + assert args.scope == "all" + assert not hasattr(args, "file") + + @pytest.mark.parametrize("scope", ["servers", "skills", "all"]) + def test_parses_discovery_scope(self, scope, monkeypatch): + from agent_scan import cli + + monkeypatch.setattr( + sys, + "argv", + ["agent-scan", "guard", "discover", "--client", "claude-code", "--scope", scope], + ) + with patch(f"{_G}.run_guard", return_value=0) as run: + with pytest.raises(SystemExit) as exc: + cli.main() + + assert exc.value.code == 0 + assert run.call_args.args[0].scope == scope + + def test_rejects_removed_file_option(self, monkeypatch): + from agent_scan import cli + + monkeypatch.setattr( + sys, + "argv", + ["agent-scan", "guard", "discover", "--client", "claude-code", "--file", "/tmp/settings.json"], + ) + with pytest.raises(SystemExit) as exc: + cli.main() + + assert exc.value.code == 2 + + def test_requires_discovery_client(self, monkeypatch): + from agent_scan import cli + + monkeypatch.setattr(sys, "argv", ["agent-scan", "guard", "discover"]) + with pytest.raises(SystemExit) as exc: + cli.main() + + assert exc.value.code == 2 + + @pytest.mark.parametrize("agent", ["claude-code", "cursor", "codex"]) + def test_parses_discovery_client(self, agent, monkeypatch): + from agent_scan import cli + + monkeypatch.setattr( + sys, + "argv", + [ + "agent-scan", + "guard", + "discover", + "--client", + agent, + ], + ) + with patch(f"{_G}.run_guard", return_value=0) as run: + with pytest.raises(SystemExit) as exc: + cli.main() + + assert exc.value.code == 0 + assert run.call_args.args[0].client == agent + + +class TestRunDiscover: + @pytest.fixture(autouse=True) + def _posix_mode(self): + with patch(f"{_G}.IS_WINDOWS", False), patch.dict(os.environ, {"MACHINE_ID": "machine-42"}): + yield + + @staticmethod + def _args(config: Path, url=None, **overrides): + values = { + "guard_command": "discover", + "url": url, + "client": "claude-code", + "scope": "all", + } + values.update(overrides) + return SimpleNamespace(**values) + + def test_happy_path_sends_session_start_discovery_from_environment(self, tmp_path, monkeypatch): + config = tmp_path / "custom" / "settings.json" + captured = {} + + def fake_send(url, client, push_key, payload, machine_id, **kwargs): + captured.update( + url=url, + client=client, + push_key=push_key, + payload=json.loads(payload), + machine_id=machine_id, + ) + return True, "" + + monkeypatch.setenv("PUSH_KEY", "env-pk") + monkeypatch.setenv("REMOTE_HOOKS_BASE_URL", "https://env-hooks.example") + monkeypatch.setenv("MACHINE_ID", "env-machine") + with ( + patch(f"{_G}._discover_servers_payload", return_value=[]), + patch(f"{_G}.send_hook_event", side_effect=fake_send), + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard(self._args(config)) + + assert result == 0 + duration = captured["payload"].pop("discovery_duration_ms") + assert isinstance(duration, int) + assert duration >= 0 + assert captured["payload"] == { + "hook_event_name": "sessionStartServerDiscovery", + "servers": [], + "session_id": "session-start-server-discovery", + } + assert captured["url"] == "https://env-hooks.example" + assert captured["client"] == "claude-code" + assert captured["push_key"] == "env-pk" + assert captured["machine_id"] == "env-machine" + + def test_explicit_url_overrides_environment(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + monkeypatch.setenv("PUSH_KEY", "env-pk") + monkeypatch.setenv("REMOTE_HOOKS_BASE_URL", "https://env-hooks.example") + with ( + patch(f"{_G}._discover_servers_payload", return_value=[]), + patch(f"{_G}.send_hook_event", return_value=(True, "")) as send, + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard(self._args(config, url="https://flag-hooks.example")) + + assert result == 0 + assert send.call_args.args[0] == "https://flag-hooks.example" + + def test_missing_push_key_returns_one_without_invoking_script(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + monkeypatch.delenv("PUSH_KEY", raising=False) + with patch(f"{_G}.send_hook_event") as send: + result = guard_module.run_guard(self._args(config)) + + assert result == 1 + send.assert_not_called() + + def test_missing_machine_id_returns_one_without_discovery(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + monkeypatch.setenv("PUSH_KEY", "env-pk") + monkeypatch.delenv("MACHINE_ID") + with ( + patch(f"{_G}._discover_servers_payload") as discover, + patch(f"{_G}.send_hook_event") as send, + patch(f"{_G}.rich") as rich_mock, + ): + result = guard_module.run_guard(self._args(config)) + + assert result == 1 + discover.assert_not_called() + send.assert_not_called() + assert "MACHINE_ID is required" in rich_mock.print.call_args.args[0] + + def test_no_forwarding_script_is_needed(self, tmp_path, monkeypatch): + monkeypatch.setenv("PUSH_KEY", "env-pk") + with ( + patch(f"{_G}._discover_servers_payload", return_value=[]), + patch(f"{_G}.send_hook_event", return_value=(True, "")) as send, + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard(self._args(tmp_path / "settings.json")) + + assert result == 0 + send.assert_called_once() + + def test_hook_stdin_reads_cwd_for_claude_code(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + monkeypatch.setenv("PUSH_KEY", "env-pk") + stdin = MagicMock() + stdin.isatty.return_value = False + stdin.read.return_value = '{"cwd":"/session/project","session_id":"session"}' + discover = MagicMock(return_value=[]) + with ( + patch.object(sys, "stdin", stdin), + patch(f"{_G}._discover_servers_payload", discover), + patch(f"{_G}.send_hook_event", return_value=(True, "")) as send, + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard( + self._args( + config, + client="claude-code", + ) + ) + + assert result == 0 + stdin.read.assert_called_once_with(1024 * 1024) + discover.assert_called_once_with(["/session/project"], discovery_scope="all") + assert json.loads(send.call_args.args[3])["session_id"] == "session" + + def test_hook_stdin_reads_cwd_for_codex(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + monkeypatch.setenv("PUSH_KEY", "env-pk") + stdin = MagicMock() + stdin.isatty.return_value = False + stdin.read.return_value = ( + '{"cwd":"/session/project","workspace_roots":["/wrong/project"],"session_id":"session"}' + ) + discover = MagicMock(return_value=[]) + with ( + patch.object(sys, "stdin", stdin), + patch(f"{_G}._discover_servers_payload", discover), + patch(f"{_G}.send_hook_event", return_value=(True, "")) as send, + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard(self._args(config, client="codex")) + + assert result == 0 + stdin.read.assert_called_once_with(1024 * 1024) + discover.assert_called_once_with(["/session/project"], discovery_scope="all") + assert json.loads(send.call_args.args[3])["session_id"] == "session" + + def test_hook_stdin_accepts_workspace_roots_list(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + monkeypatch.setenv("PUSH_KEY", "env-pk") + stdin = MagicMock() + stdin.isatty.return_value = False + stdin.read.return_value = ( + '{"workspace_roots":["/workspace/one","/workspace/two"],"conversation_id":"conversation"}' + ) + discover = MagicMock(return_value=[]) + with ( + patch.object(sys, "stdin", stdin), + patch(f"{_G}._discover_servers_payload", discover), + patch(f"{_G}.send_hook_event", return_value=(True, "")) as send, + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard(self._args(config, client="cursor")) + + assert result == 0 + discover.assert_called_once_with(["/workspace/one", "/workspace/two"], discovery_scope="all") + assert send.call_args.args[:3] == ("https://api.snyk.io", "cursor", "env-pk") + assert json.loads(send.call_args.args[3])["conversation_id"] == "conversation" + + def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + monkeypatch.setenv("PUSH_KEY", "env-pk") + stdin = MagicMock() + stdin.isatty.return_value = False + stdin.read.return_value = "not-json" + discover = MagicMock(return_value=[]) + with ( + patch.object(sys, "stdin", stdin), + patch(f"{_G}._discover_servers_payload", discover), + patch(f"{_G}.send_hook_event", return_value=(True, "")) as send, + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard( + self._args( + config, + client="claude-code", + ) + ) + + assert result == 0 + discover.assert_called_once_with([], discovery_scope="all") + assert json.loads(send.call_args.args[3])["session_id"] == "session-start-server-discovery" + + def test_tty_stdin_is_not_read(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + monkeypatch.setenv("PUSH_KEY", "env-pk") + stdin = MagicMock() + stdin.isatty.return_value = True + stdin.read.side_effect = AssertionError("tty stdin must not be read") + discover = MagicMock(return_value=[]) + + with ( + patch.object(sys, "stdin", stdin), + patch(f"{_G}._discover_servers_payload", discover), + patch(f"{_G}.send_hook_event", return_value=(True, "")), + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard(self._args(config, client="claude-code")) + + assert result == 0 + stdin.read.assert_not_called() + discover.assert_called_once_with([], discovery_scope="all") + + def test_pipe_that_never_closes_does_not_block_discovery(self, tmp_path, monkeypatch): + import time as test_time + + config = tmp_path / "settings.json" + monkeypatch.setenv("PUSH_KEY", "env-pk") + release_read = threading.Event() + stdin = MagicMock() + stdin.isatty.return_value = False + stdin.read.side_effect = lambda _limit: release_read.wait(0.5) and "{}" + + try: + with ( + patch.object(sys, "stdin", stdin), + patch(f"{_G}._STDIN_READ_TIMEOUT_SECONDS", 0.01), + patch(f"{_G}._discover_servers_payload", return_value=[]), + patch(f"{_G}.send_hook_event", return_value=(True, "")), + patch(f"{_G}.rich"), + ): + started = test_time.monotonic() + result = guard_module.run_guard(self._args(config, client="claude-code")) + elapsed = test_time.monotonic() - started + finally: + release_read.set() + + assert result == 0 + assert elapsed < 0.2 + + @pytest.mark.parametrize( + "client,session_field,event_session_field", + [ + ("claude-code", "session_id", "session_id"), + ("cursor", "conversation_id", "conversation_id"), + ("codex", "session_id", "session_id"), + ], + ) + @pytest.mark.parametrize( + "session_value,expected_marker", + [ + ("real-session", "real-session"), + (None, "session-start-server-discovery"), + ("", "session-start-server-discovery"), + (123, "session-start-server-discovery"), + ], + ) + def test_hook_stdin_forwards_valid_session_marker_or_falls_back( + self, + tmp_path, + monkeypatch, + client, + session_field, + event_session_field, + session_value, + expected_marker, + ): + config = tmp_path / "settings.json" + monkeypatch.setenv("PUSH_KEY", "env-pk") + hook_payload = {} if session_value is None else {session_field: session_value} + stdin = MagicMock() + stdin.isatty.return_value = False + stdin.read.return_value = json.dumps(hook_payload) + with ( + patch.object(sys, "stdin", stdin), + patch(f"{_G}._discover_servers_payload", return_value=[]), + patch(f"{_G}.send_hook_event", return_value=(True, "")) as send, + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard(self._args(config, client=client)) + + assert result == 0 + event_payload = json.loads(send.call_args.args[3]) + assert event_payload[event_session_field] == expected_marker + + def test_missing_client_fails_without_discovery_or_send(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + monkeypatch.setenv("PUSH_KEY", "env-pk") + stdin = MagicMock() + stdin.isatty.return_value = False + stdin.read.side_effect = AssertionError("stdin must not be read") + with ( + patch.object(sys, "stdin", stdin), + patch(f"{_G}._discover_servers_payload") as discover, + patch(f"{_G}.send_hook_event") as send, + patch(f"{_G}.rich") as rich_mock, + ): + result = guard_module.run_guard(self._args(config, client=None)) + + assert result == 1 + stdin.read.assert_not_called() + discover.assert_not_called() + send.assert_not_called() + assert "--client is required" in rich_mock.print.call_args.args[0] + + def test_windows_uses_direct_sender(self, tmp_path, monkeypatch): + config = tmp_path / "hooks.json" + monkeypatch.setenv("PUSH_KEY", "env-pk") + monkeypatch.setenv("REMOTE_HOOKS_BASE_URL", "https://env-hooks.example") + monkeypatch.setenv("MACHINE_ID", "env-machine") + with ( + patch(f"{_G}.IS_WINDOWS", True), + patch(f"{_G}._discover_servers_payload", return_value=[]), + patch(f"{_G}.send_hook_event", return_value=(True, "")) as send, + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard(self._args(config, client="codex")) + + assert result == 0 + assert send.call_args.args[:3] == ("https://env-hooks.example", "codex", "env-pk") + assert send.call_args.args[4] == "env-machine" + + +class TestRunInstallSendsServersDiscovered: + @staticmethod + def _args(tmp_path, *, client="claude", file_override=True, managed=False, machine_id="machine-42"): + return SimpleNamespace( + client=client, + url="https://api.snyk.io", + tenant_id="tid-1", + file=str(tmp_path / "config.json") if file_override else None, + managed=managed, + machine_id=machine_id, + ) + + @staticmethod + def _fake_paths(tmp_path, installed): + paths = {} + for client in ALL_CLIENTS: + path = tmp_path / client + if client in installed: + path.mkdir(exist_ok=True) + paths[client] = path + return paths + + def test_single_client_sends_once_directly(self, tmp_path, monkeypatch): + from agent_scan.agents import DiscoveryScope + + monkeypatch.setenv("PUSH_KEY", "headless-pk") + script = Path("/installed/claude/hook.sh") + with ( + patch(f"{_G}._install_hooks", return_value=script) as install, + patch(f"{_G}._send_servers_discovered_event", return_value=True) as send, + ): + _run_install(self._args(tmp_path, machine_id="machine-42")) + + assert install.call_args.args[-1] == "machine-42" + send.assert_called_once_with( + "headless-pk", + "https://api.snyk.io", + "claude-code", + "machine-42", + discovery_scope=DiscoveryScope.SERVERS, + max_retries=2, + ) + + def test_install_does_not_request_skills_discovery(self, tmp_path, monkeypatch): + """The install event only ever reports servers, so it must not pay for a skills sweep.""" + from agent_scan.agents import DiscoveryScope + + monkeypatch.setenv("PUSH_KEY", "headless-pk") + with ( + patch(f"{_G}._install_hooks", return_value=Path("/installed/claude/hook.sh")), + patch(f"{_G}._send_servers_discovered_event", return_value=True) as send, + ): + _run_install(self._args(tmp_path, machine_id="machine-42")) + + assert send.call_args.kwargs["discovery_scope"] is DiscoveryScope.SERVERS + + def test_install_retries_delivery_unlike_session_start(self, tmp_path, monkeypatch): + """``guard install`` is a one-shot the user is watching, so a transport blip retries.""" + monkeypatch.setenv("PUSH_KEY", "headless-pk") + with ( + patch(f"{_G}._install_hooks", return_value=Path("/installed/claude/hook.sh")), + patch(f"{_G}._send_servers_discovered_event", return_value=True) as send, + ): + _run_install(self._args(tmp_path, machine_id="machine-42")) + + assert send.call_args.kwargs["max_retries"] == 2 + + def test_cursor_install_uses_cursor_endpoint(self, tmp_path, monkeypatch): + monkeypatch.setenv("PUSH_KEY", "headless-pk") + script = Path("/installed/cursor/hook.sh") + with ( + patch(f"{_G}._install_hooks", return_value=script), + patch(f"{_G}._send_servers_discovered_event", return_value=True) as send, + ): + _run_install(self._args(tmp_path, client="cursor")) + + assert send.call_args.args[2] == "cursor" + + def test_install_all_sends_once_after_all_installs(self, tmp_path, monkeypatch): + monkeypatch.setenv("PUSH_KEY", "headless-pk") + scripts = [Path(f"/installed/{client}/hook.sh") for client in ALL_CLIENTS] + order = [] + + def install(*args): + order.append(f"install:{args[0]}") + return scripts[len(order) - 1] + + def send(*args, **kwargs): + order.append("send") + return True + + with ( + patch(f"{_G}._CLIENT_INSTALL_PATHS", self._fake_paths(tmp_path, ALL_CLIENTS)), + patch(f"{_G}._install_hooks", side_effect=install) as install_mock, + patch(f"{_G}._send_servers_discovered_event", side_effect=send) as send_mock, + ): + _run_install(self._args(tmp_path, client="all", file_override=False)) + + assert install_mock.call_count == 3 + assert send_mock.call_count == 1 + assert send_mock.call_args.args[2] == "claude-code" + assert order == ["install:claude", "install:cursor", "install:codex", "send"] + + def test_nothing_installed_does_not_send(self, tmp_path, monkeypatch): + monkeypatch.setenv("PUSH_KEY", "headless-pk") + with ( + patch(f"{_G}._CLIENT_INSTALL_PATHS", self._fake_paths(tmp_path, [])), + patch(f"{_G}._install_hooks") as install, + patch(f"{_G}._send_servers_discovered_event") as send, + ): + _run_install(self._args(tmp_path, file_override=False)) + + install.assert_not_called() + send.assert_not_called() + + def test_install_failure_revokes_minted_key_and_does_not_send(self, tmp_path, monkeypatch): + monkeypatch.delenv("PUSH_KEY", raising=False) + monkeypatch.setenv("SNYK_TOKEN", "token") + with ( + patch(f"{_G}.fetch_guard_enabled", return_value=True), + patch(f"{_G}.mint_push_key", return_value="minted-pk"), + patch(f"{_G}._install_hooks", side_effect=RuntimeError("install failed")), + patch(f"{_G}._revoke_after_failure") as revoke, + patch(f"{_G}._send_servers_discovered_event") as send, + ): + with pytest.raises(RuntimeError, match="install failed"): + _run_install(self._args(tmp_path)) + + revoke.assert_called_once_with("https://api.snyk.io", "tid-1", "token", "minted-pk") + send.assert_not_called() + + def test_send_failure_keeps_success_exit_and_does_not_revoke(self, tmp_path, monkeypatch): + monkeypatch.setenv("PUSH_KEY", "headless-pk") + args = self._args(tmp_path) + args.guard_command = "install" + with ( + patch(f"{_G}._install_hooks", return_value=Path("/installed/hook.sh")), + patch(f"{_G}._send_servers_discovered_event", return_value=False), + patch(f"{_G}._revoke_after_failure") as revoke, + ): + result = guard_module.run_guard(args) + + assert result == 0 + revoke.assert_not_called() + + @pytest.mark.parametrize( + "arg_machine_id, env_machine_id, expected", + [("args-id", "env-id", "args-id"), (None, "env-id", "env-id")], + ) + def test_machine_id_precedence_reaches_install_and_send( + self, tmp_path, monkeypatch, arg_machine_id, env_machine_id, expected + ): + monkeypatch.setenv("PUSH_KEY", "headless-pk") + if env_machine_id is None: + monkeypatch.delenv("MACHINE_ID", raising=False) + else: + monkeypatch.setenv("MACHINE_ID", env_machine_id) + with ( + patch(f"{_G}._install_hooks", return_value=Path("/installed/hook.sh")) as install, + patch(f"{_G}._send_servers_discovered_event", return_value=True) as send, + ): + _run_install(self._args(tmp_path, machine_id=arg_machine_id)) + + assert install.call_args.args[-1] == expected + assert send.call_args.args[-1] == expected + + @pytest.mark.parametrize("arg_machine_id, env_machine_id", [(None, None), (" ", " ")]) + def test_missing_machine_id_uses_hostname_for_install_and_send( + self, tmp_path, monkeypatch, arg_machine_id, env_machine_id + ): + monkeypatch.setenv("PUSH_KEY", "headless-pk") + if env_machine_id is None: + monkeypatch.delenv("MACHINE_ID", raising=False) + else: + monkeypatch.setenv("MACHINE_ID", env_machine_id) + with ( + patch("agent_scan.utils.get_hostname", return_value="fallback-host"), + patch(f"{_G}._install_hooks", return_value=Path("/installed/hook.sh")) as install, + patch(f"{_G}._send_servers_discovered_event", return_value=True) as send, + patch(f"{_G}.rich") as rich_mock, + ): + _run_install(self._args(tmp_path, machine_id=arg_machine_id)) + + assert install.call_args.args[-1] == "fallback-host" + assert send.call_args.args[-1] == "fallback-host" + assert any( + "MACHINE_ID will become mandatory once ADS Installer is updated" in call.args[0] + for call in rich_mock.print.call_args_list + if call.args + ) + + def test_managed_install_sends(self, tmp_path, monkeypatch): + monkeypatch.setenv("PUSH_KEY", "headless-pk") + with ( + patch(f"{_G}._CLIENT_INSTALL_PATHS", self._fake_paths(tmp_path, ["claude"])), + patch(f"{_G}._install_hooks", return_value=Path("/managed/hook.sh")), + patch(f"{_G}._send_servers_discovered_event", return_value=True) as send, + ): + _run_install(self._args(tmp_path, file_override=False, managed=True)) + + send.assert_called_once() + + # =================================================================== # _compute_hooks_diff # =================================================================== @@ -2455,7 +5804,7 @@ def test_claude_uninstall_preserves_custom_hooks(self, tmp_path): } }, ) - _uninstall_claude(path) + _uninstall_test_client("claude", path) data = json.loads(path.read_text()) assert len(data["hooks"]["PreToolUse"]) == 1 @@ -2480,7 +5829,7 @@ def test_cursor_uninstall_preserves_custom_hooks(self, tmp_path): }, }, ) - _uninstall_cursor(path) + _uninstall_test_client("cursor", path) data = json.loads(path.read_text()) assert len(data["hooks"]["stop"]) == 1 @@ -2504,7 +5853,7 @@ def test_codex_uninstall_preserves_custom_hooks(self, tmp_path): } }, ) - _uninstall_codex(path) + _uninstall_test_client("codex", path) data = json.loads(path.read_text()) assert len(data["hooks"]["PreToolUse"]) == 1 @@ -2532,6 +5881,16 @@ def _all_clients_installed(self, tmp_path): with patch("agent_scan.guard._CLIENT_INSTALL_PATHS", fake_paths): yield + @pytest.fixture(autouse=True) + def _no_servers_discovered_event(self): + # _install_hooks is mocked below, so without this the real post-install + # send would run actual machine discovery and invoke the hook script. + with ( + patch("agent_scan.guard._send_servers_discovered_event", return_value=True), + patch.dict(os.environ, {"MACHINE_ID": "machine-42"}), + ): + yield + @patch("agent_scan.guard._install_hooks") @patch("agent_scan.guard.mint_push_key", return_value="minted-pk") @patch("agent_scan.guard.fetch_guard_enabled", return_value=True) @@ -2717,6 +6076,16 @@ def test_permission_error_returns_false(self, tmp_path): class TestRunInstallSkipsUninstalledClients: """_run_install should skip hook installation for agents not present on the machine.""" + @pytest.fixture(autouse=True) + def _no_servers_discovered_event(self): + # _install_hooks is mocked below, so without this the real post-install + # send would run actual machine discovery and invoke the hook script. + with ( + patch("agent_scan.guard._send_servers_discovered_event", return_value=True), + patch.dict(os.environ, {"MACHINE_ID": "machine-42"}), + ): + yield + @staticmethod def _fake_paths(tmp_path, installed_clients): """Build a _CLIENT_INSTALL_PATHS dict where only *installed_clients* have real dirs.""" diff --git a/tests/unit/test_hook_events.py b/tests/unit/test_hook_events.py new file mode 100644 index 00000000..e39b2c4a --- /dev/null +++ b/tests/unit/test_hook_events.py @@ -0,0 +1,188 @@ +"""Tests for direct Agent Monitor hook-event delivery.""" + +from __future__ import annotations + +import base64 +import json +from unittest.mock import patch + +import aiohttp +import pytest + +from agent_scan.hook_events import _HOOK_REQUEST_TIMEOUT_SECONDS, send_hook_event +from agent_scan.hook_version import HOOK_VERSION +from agent_scan.version import version_info + + +class _FakeResponse: + def __init__(self, status: int) -> None: + self.status = status + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + +class _FakeSession: + """Stand-in for the aiohttp session the shared backend factory builds.""" + + def __init__(self, status: int = 200, error: BaseException | None = None) -> None: + self.status = status + self.error = error + self.posts: list[dict] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + def post(self, url, **kwargs): + self.posts.append({"url": url, **kwargs}) + if self.error is not None: + raise self.error + return _FakeResponse(self.status) + + +def _patch_session(session: _FakeSession): + """Patch the shared factory, so a test failure here means the TLS posture was bypassed.""" + return patch("agent_scan.hook_events.backend_client_session", return_value=session) + + +@pytest.mark.parametrize("client", ["claude-code", "cursor", "codex"]) +def test_sends_existing_hook_wire_contract(client): + session = _FakeSession() + payload = '{"hook_event_name":"hooksConfiguredServerDiscovery"}' + + with ( + patch("agent_scan.hook_events.get_hostname", return_value="host-1"), + patch("agent_scan.hook_events.get_username", return_value="user-1"), + _patch_session(session), + ): + result = send_hook_event("https://api.snyk.io/", client, "push-key", payload, "machine-1") + + assert result == (True, "") + assert len(session.posts) == 1 + post = session.posts[0] + assert post["url"] == f"https://api.snyk.io/hidden/agent-monitor/hooks/{client}?version={HOOK_VERSION}" + assert post["timeout"] == aiohttp.ClientTimeout(total=_HOOK_REQUEST_TIMEOUT_SECONDS) + assert base64.b64decode(post["data"].decode().removeprefix("base64:")).decode() == payload + headers = post["headers"] + assert headers["Content-Type"] == "text/plain" + assert headers["X-Client-Id"] == "push-key" + assert headers["User-Agent"] == f"snyk/agent-scan Agent Scan v{version_info}" + assert json.loads(headers["X-User"]) == { + "hostname": "host-1", + "username": "user-1", + "identifier": "machine-1", + } + + +@pytest.mark.parametrize( + "base_url, expected_base_url", + [ + ("localhost", "http://localhost"), + ("localhost:8000/", "http://localhost:8000"), + ("127.0.0.1:8000", "http://127.0.0.1:8000"), + ("localhost:8000/proxy/https://upstream", "http://localhost:8000/proxy/https://upstream"), + ], +) +def test_send_hook_event_defaults_scheme_less_base_url_to_http(base_url, expected_base_url): + session = _FakeSession() + + with _patch_session(session): + result = send_hook_event(base_url, "claude-code", "push-key", "{}", "machine-1") + + assert result == (True, "") + assert session.posts[0]["url"] == ( + f"{expected_base_url}/hidden/agent-monitor/hooks/claude-code?version={HOOK_VERSION}" + ) + + +def test_uses_the_shared_backend_session_factory(): + """Hook events must ride the same connector as the analysis path (certifi + extra CAs).""" + session = _FakeSession() + + with _patch_session(session) as factory: + send_hook_event("https://api.snyk.io", "claude-code", "push-key", "{}", "machine-1") + + factory.assert_called_once() + + +def test_rejects_missing_machine_identifier_without_request(): + session = _FakeSession() + + with _patch_session(session) as factory: + result = send_hook_event("https://api.snyk.io", "claude-code", "push-key", "{}", " ") + + assert result == (False, "machine ID is required") + factory.assert_not_called() + + +def test_rejects_unknown_client_without_request(): + session = _FakeSession() + + with _patch_session(session) as factory: + result = send_hook_event("https://api.snyk.io", "unknown", "push-key", "{}", "machine-1") + + assert result == (False, "unknown client: unknown") + factory.assert_not_called() + + +@pytest.mark.parametrize("status, expected", [(403, "HTTP 403"), (404, "HTTP 404"), (500, "HTTP 500")]) +def test_reports_http_failures(status, expected): + session = _FakeSession(status=status) + + with _patch_session(session): + result = send_hook_event("https://api.snyk.io", "claude-code", "push-key", "{}", "machine-1") + + assert result == (False, expected) + + +@pytest.mark.parametrize( + "error, expected", + [ + (aiohttp.ClientConnectionError("offline"), "offline"), + (TimeoutError("timed out"), "timed out"), + ], +) +def test_reports_transport_failures(error, expected): + session = _FakeSession(error=error) + + with _patch_session(session): + ok, detail = send_hook_event("https://api.snyk.io", "claude-code", "push-key", "{}", "machine-1") + + assert ok is False + assert expected in detail + + +def test_transport_failures_are_retried_when_requested(): + session = _FakeSession(error=aiohttp.ClientConnectionError("offline")) + + with _patch_session(session), patch("agent_scan.hook_events.asyncio.sleep") as sleep: + ok, _ = send_hook_event("https://api.snyk.io", "claude-code", "push-key", "{}", "machine-1", max_retries=3) + + assert ok is False + assert len(session.posts) == 3 + assert sleep.await_count == 2 + + +def test_single_attempt_by_default(): + """SessionStart discovery runs inside a hook budget, so retries are opt-in.""" + session = _FakeSession(error=aiohttp.ClientConnectionError("offline")) + + with _patch_session(session): + send_hook_event("https://api.snyk.io", "claude-code", "push-key", "{}", "machine-1") + + assert len(session.posts) == 1 + + +def test_http_errors_are_not_retried(): + session = _FakeSession(status=403) + + with _patch_session(session): + send_hook_event("https://api.snyk.io", "claude-code", "push-key", "{}", "machine-1", max_retries=3) + + assert len(session.posts) == 1 diff --git a/tests/unit/test_inspect.py b/tests/unit/test_inspect.py index c2cb4b75..9b0ad84a 100644 --- a/tests/unit/test_inspect.py +++ b/tests/unit/test_inspect.py @@ -9,6 +9,7 @@ from mcp.shared.auth import OAuthToken from mcp.types import Implementation, InitializeResult +from agent_scan.agents import DiscoveryScope from agent_scan.inspect import ( get_mcp_config_per_client, inspect_client, @@ -849,3 +850,84 @@ async def test_inspect_skill_falls_back_to_directory_name_on_frontmatter_error(t assert {file.path for file in skill.files} == {"SKILL.md", "helper.py"} assert skill.error is not None assert skill.error.category == "skill_scan_error" + + +# --- discovery scope tests --- + + +@pytest.fixture +def scoped_candidate(tmp_path): + """A client exposing both an MCP config and a skills dir, for scope gating.""" + home = tmp_path / "user" + (home / ".fake-client").mkdir(parents=True) + + plugin_dir = home / ".fake-client" / "plugins" / "cache" / "market" / "server-plugin" / "v1" + plugin_dir.mkdir(parents=True) + (plugin_dir / ".mcp.json").write_text('{"my-server": {"command": "node", "args": ["server.js"]}}') + + skills_dir = home / ".fake-client" / "plugins" / "cache" / "market" / "skill-plugin" / "v1" / "skills" / "my-skill" + skills_dir.mkdir(parents=True) + (skills_dir / "SKILL.md").write_text("# My Skill\nA test skill.") + + candidate = CandidateClient( + name="fake-client", + client_exists_paths=["~/.fake-client"], + mcp_config_paths=[], + skills_dir_paths=[], + mcp_config_globs=["~/.fake-client/plugins/cache/**/.mcp.json"], + skills_dir_globs=["~/.fake-client/plugins/cache/**/skills"], + ) + return candidate, home + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "scope, expect_servers, expect_skills", + [ + (DiscoveryScope.ALL, True, True), + (DiscoveryScope.SERVERS, True, False), + (DiscoveryScope.SKILLS, False, True), + ], +) +async def test_scope_only_populates_requested_half(scoped_candidate, scope, expect_servers, expect_skills): + """Mirror of AgentDiscoverer.discover's scope gate, for the well-known-client path.""" + candidate, home = scoped_candidate + + ctis = await get_mcp_config_per_client(candidate, [(home, "user")], scope=scope) + + assert len(ctis) == 1 + cti = ctis[0] + assert bool([path for path, value in cti.mcp_configs.items() if isinstance(value, list)]) is expect_servers + assert bool([path for path, value in cti.skills_dirs.items() if isinstance(value, list)]) is expect_skills + + +@pytest.mark.asyncio +async def test_servers_scope_does_no_skills_filesystem_work(scoped_candidate): + """``--scope servers`` exists to save latency, so the skills sweep must not run at all.""" + candidate, home = scoped_candidate + + with patch("agent_scan.inspect.inspect_skills_dir") as inspect_skills: + await get_mcp_config_per_client(candidate, [(home, "user")], scope=DiscoveryScope.SERVERS) + + inspect_skills.assert_not_called() + + +@pytest.mark.asyncio +async def test_scope_defaults_to_all(scoped_candidate): + candidate, home = scoped_candidate + + ctis = await get_mcp_config_per_client(candidate, [(home, "user")]) + + assert [path for path, value in ctis[0].mcp_configs.items() if isinstance(value, list)] + assert [path for path, value in ctis[0].skills_dirs.items() if isinstance(value, list)] + + +@pytest.mark.asyncio +async def test_client_detection_is_scope_independent(scoped_candidate): + """The client_exists probe must run whatever the scope, or clients vanish from reports.""" + candidate, home = scoped_candidate + + for scope in DiscoveryScope: + ctis = await get_mcp_config_per_client(candidate, [(home, "user")], scope=scope) + assert len(ctis) == 1, scope + assert ctis[0].client_path is not None, scope diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index d9f29527..e64d683f 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -13,9 +13,28 @@ get_readable_home_directories, get_relative_path, suppress_stdout, + toml_escape, + toml_unescape, ) +@pytest.mark.parametrize( + "value", + [ + "", + "\b\t\n\f\r", + "\x00\x1f\x7f", + "Grüezi, 世界", + "\\\\\\", + 'embedded "quotes"', + ], +) +def test_toml_escape_and_unescape_are_exact_inverses(value): + rendered = toml_escape(value) + + assert toml_unescape(rendered[1:-1]) == value + + class TestGetRelativePath: def test_path_in_home_directory(self): home = os.path.expanduser("~") @@ -35,6 +54,35 @@ def test_windows_path_outside_home_uses_forward_slashes(self): result = get_relative_path(r"C:\Users\someone\AppData\Local\config.json") assert result == "C:/Users/someone/AppData/Local/config.json" + def test_windows_home_path_with_mixed_separators(self, monkeypatch): + monkeypatch.setattr( + os.path, + "expanduser", + lambda value: r"C:\Users\runneradmin" if value == "~" else value, + ) + monkeypatch.setattr(utils_module.sys, "platform", "win32") + + assert get_relative_path("c:/USERS/RUNNERADMIN/.claude") == "~/.claude" + + def test_outside_home_tilde_spelling_is_preserved(self, monkeypatch): + monkeypatch.setattr( + os.path, + "expanduser", + lambda value: "/home/alice" if value == "~" else "/home/bob/mcp.json", + ) + + assert get_relative_path("~bob/mcp.json") == "~bob/mcp.json" + + def test_windows_unicode_fold_does_not_alias_home(self, monkeypatch): + monkeypatch.setattr( + os.path, + "expanduser", + lambda value: "C:/Users/ss" if value == "~" else value, + ) + monkeypatch.setattr(utils_module.sys, "platform", "win32") + + assert get_relative_path("C:/Users/ß/secret") == "C:/Users/ß/secret" + def test_empty_path(self): result = get_relative_path("") assert result == ""